Merge remote-tracking branch 'upstream/main'
This commit is contained in:
@@ -98,6 +98,13 @@ Run `npx tsx setup/index.ts --step environment` and parse the status block.
|
|||||||
- If HAS_REGISTERED_GROUPS=true → note existing config, offer to skip or reconfigure
|
- If HAS_REGISTERED_GROUPS=true → note existing config, offer to skip or reconfigure
|
||||||
- Record APPLE_CONTAINER and DOCKER values for step 3
|
- Record APPLE_CONTAINER and DOCKER values for step 3
|
||||||
|
|
||||||
|
## 2a. Timezone
|
||||||
|
|
||||||
|
Run `npx tsx setup/index.ts --step timezone` and parse the status block.
|
||||||
|
|
||||||
|
- If NEEDS_USER_INPUT=true → The system timezone could not be autodetected (e.g. POSIX-style TZ like `IST-2`). AskUserQuestion: "What is your timezone?" with common options (America/New_York, Europe/London, Asia/Jerusalem, Asia/Tokyo) and an "Other" escape. Then re-run: `npx tsx setup/index.ts --step timezone -- --tz <their-answer>`.
|
||||||
|
- If STATUS=success → Timezone is configured. Note RESOLVED_TZ for reference.
|
||||||
|
|
||||||
## 3. Container Runtime
|
## 3. Container Runtime
|
||||||
|
|
||||||
### 3a. Choose runtime
|
### 3a. Choose runtime
|
||||||
|
|||||||
7
.github/workflows/fork-sync-skills.yml
vendored
7
.github/workflows/fork-sync-skills.yml
vendored
@@ -67,10 +67,15 @@ jobs:
|
|||||||
AUTO_RESOLVABLE=true
|
AUTO_RESOLVABLE=true
|
||||||
for f in $CONFLICTED; do
|
for f in $CONFLICTED; do
|
||||||
case "$f" in
|
case "$f" in
|
||||||
package-lock.json|package.json|repo-tokens/badge.svg)
|
package-lock.json|package.json|repo-tokens/badge.svg|.github/workflows/*)
|
||||||
git checkout --theirs "$f"
|
git checkout --theirs "$f"
|
||||||
git add "$f"
|
git add "$f"
|
||||||
;;
|
;;
|
||||||
|
.env.example)
|
||||||
|
# Keep fork's channel-specific env vars
|
||||||
|
git checkout --ours "$f"
|
||||||
|
git add "$f"
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
AUTO_RESOLVABLE=false
|
AUTO_RESOLVABLE=false
|
||||||
;;
|
;;
|
||||||
|
|||||||
2
.github/workflows/merge-forward-skills.yml
vendored
2
.github/workflows/merge-forward-skills.yml
vendored
@@ -160,6 +160,8 @@ jobs:
|
|||||||
'nanoclaw-slack',
|
'nanoclaw-slack',
|
||||||
'nanoclaw-gmail',
|
'nanoclaw-gmail',
|
||||||
'nanoclaw-docker-sandboxes',
|
'nanoclaw-docker-sandboxes',
|
||||||
|
'nanoclaw-docker-sandbox',
|
||||||
|
'nanoclaw-docker-sandbox-windows',
|
||||||
];
|
];
|
||||||
const sha = context.sha.substring(0, 7);
|
const sha = context.sha.substring(0, 7);
|
||||||
for (const repo of forks) {
|
for (const repo of forks) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# NanoClaw Agent Container
|
# NanoClaw Agent Container
|
||||||
# Runs Claude Agent SDK in isolated Linux VM with browser automation
|
# Runs Claude Agent SDK in isolated Linux VM with browser automation
|
||||||
|
|
||||||
FROM node:24-slim
|
FROM node:22-slim
|
||||||
|
|
||||||
# Install system dependencies for Chromium
|
# Install system dependencies for Chromium
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
|
|||||||
347
package-lock.json
generated
347
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -23,12 +23,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@onecli-sh/sdk": "^0.2.0",
|
"@onecli-sh/sdk": "^0.2.0",
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
"grammy": "^1.39.3",
|
|
||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "^13.0.0",
|
||||||
"yaml": "^2.8.2",
|
"yaml": "^2.8.2",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6",
|
||||||
|
"grammy": "^1.39.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.35.0",
|
"@eslint/js": "^9.35.0",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="97" height="20" role="img" aria-label="42.2k tokens, 21% of context window">
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="97" height="20" role="img" aria-label="42.4k tokens, 21% of context window">
|
||||||
<title>42.2k tokens, 21% of context window</title>
|
<title>42.4k tokens, 21% of context window</title>
|
||||||
<linearGradient id="s" x2="0" y2="100%">
|
<linearGradient id="s" x2="0" y2="100%">
|
||||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
<stop offset="1" stop-opacity=".1"/>
|
<stop offset="1" stop-opacity=".1"/>
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||||
<text x="26" y="14">tokens</text>
|
<text x="26" y="14">tokens</text>
|
||||||
<text aria-hidden="true" x="74" y="15" fill="#010101" fill-opacity=".3">42.2k</text>
|
<text aria-hidden="true" x="74" y="15" fill="#010101" fill-opacity=".3">42.4k</text>
|
||||||
<text x="74" y="14">42.2k</text>
|
<text x="74" y="14">42.4k</text>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -9,6 +9,7 @@ const STEPS: Record<
|
|||||||
string,
|
string,
|
||||||
() => Promise<{ run: (args: string[]) => Promise<void> }>
|
() => Promise<{ run: (args: string[]) => Promise<void> }>
|
||||||
> = {
|
> = {
|
||||||
|
timezone: () => import('./timezone.js'),
|
||||||
environment: () => import('./environment.js'),
|
environment: () => import('./environment.js'),
|
||||||
container: () => import('./container.js'),
|
container: () => import('./container.js'),
|
||||||
groups: () => import('./groups.js'),
|
groups: () => import('./groups.js'),
|
||||||
|
|||||||
67
setup/timezone.ts
Normal file
67
setup/timezone.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Step: timezone — Detect, validate, and persist the user's timezone.
|
||||||
|
* Writes TZ to .env if a valid IANA timezone is resolved.
|
||||||
|
* Emits NEEDS_USER_INPUT=true when autodetection fails.
|
||||||
|
*/
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { isValidTimezone } from '../src/timezone.js';
|
||||||
|
import { logger } from '../src/logger.js';
|
||||||
|
import { emitStatus } from './status.js';
|
||||||
|
|
||||||
|
export async function run(args: string[]): Promise<void> {
|
||||||
|
const projectRoot = process.cwd();
|
||||||
|
const envFile = path.join(projectRoot, '.env');
|
||||||
|
|
||||||
|
// Check what's already in .env
|
||||||
|
let envFileTz: string | undefined;
|
||||||
|
if (fs.existsSync(envFile)) {
|
||||||
|
const content = fs.readFileSync(envFile, 'utf-8');
|
||||||
|
const match = content.match(/^TZ=(.+)$/m);
|
||||||
|
if (match) envFileTz = match[1].trim().replace(/^["']|["']$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
const envTz = process.env.TZ;
|
||||||
|
|
||||||
|
// Accept --tz flag from CLI (used when setup skill collects from user)
|
||||||
|
const tzFlagIdx = args.indexOf('--tz');
|
||||||
|
const userTz = tzFlagIdx !== -1 ? args[tzFlagIdx + 1] : undefined;
|
||||||
|
|
||||||
|
// Resolve: user-provided > .env > process.env > system autodetect
|
||||||
|
let resolvedTz: string | undefined;
|
||||||
|
for (const candidate of [userTz, envFileTz, envTz, systemTz]) {
|
||||||
|
if (candidate && isValidTimezone(candidate)) {
|
||||||
|
resolvedTz = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsUserInput = !resolvedTz;
|
||||||
|
|
||||||
|
if (resolvedTz && resolvedTz !== envFileTz) {
|
||||||
|
// Write/update TZ in .env
|
||||||
|
if (fs.existsSync(envFile)) {
|
||||||
|
let content = fs.readFileSync(envFile, 'utf-8');
|
||||||
|
if (/^TZ=/m.test(content)) {
|
||||||
|
content = content.replace(/^TZ=.*$/m, `TZ=${resolvedTz}`);
|
||||||
|
} else {
|
||||||
|
content = content.trimEnd() + `\nTZ=${resolvedTz}\n`;
|
||||||
|
}
|
||||||
|
fs.writeFileSync(envFile, content);
|
||||||
|
} else {
|
||||||
|
fs.writeFileSync(envFile, `TZ=${resolvedTz}\n`);
|
||||||
|
}
|
||||||
|
logger.info({ timezone: resolvedTz }, 'Set TZ in .env');
|
||||||
|
}
|
||||||
|
|
||||||
|
emitStatus('TIMEZONE', {
|
||||||
|
SYSTEM_TZ: systemTz || 'unknown',
|
||||||
|
ENV_TZ: envTz || 'unset',
|
||||||
|
ENV_FILE_TZ: envFileTz || 'unset',
|
||||||
|
RESOLVED_TZ: resolvedTz || 'none',
|
||||||
|
NEEDS_USER_INPUT: needsUserInput,
|
||||||
|
STATUS: needsUserInput ? 'needs_input' : 'success',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@ import os from 'os';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { readEnvFile } from './env.js';
|
import { readEnvFile } from './env.js';
|
||||||
|
import { isValidTimezone } from './timezone.js';
|
||||||
|
|
||||||
// Read config values from .env (falls back to process.env).
|
// Read config values from .env (falls back to process.env).
|
||||||
const envConfig = readEnvFile([
|
const envConfig = readEnvFile([
|
||||||
'ASSISTANT_NAME',
|
'ASSISTANT_NAME',
|
||||||
'ASSISTANT_HAS_OWN_NUMBER',
|
'ASSISTANT_HAS_OWN_NUMBER',
|
||||||
'ONECLI_URL',
|
'ONECLI_URL',
|
||||||
|
'TZ',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const ASSISTANT_NAME =
|
export const ASSISTANT_NAME =
|
||||||
@@ -67,7 +69,17 @@ export const TRIGGER_PATTERN = new RegExp(
|
|||||||
'i',
|
'i',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Timezone for scheduled tasks (cron expressions, etc.)
|
// Timezone for scheduled tasks, message formatting, etc.
|
||||||
// Uses system timezone by default
|
// Validates each candidate is a real IANA identifier before accepting.
|
||||||
export const TIMEZONE =
|
function resolveConfigTimezone(): string {
|
||||||
process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
const candidates = [
|
||||||
|
process.env.TZ,
|
||||||
|
envConfig.TZ,
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
];
|
||||||
|
for (const tz of candidates) {
|
||||||
|
if (tz && isValidTimezone(tz)) return tz;
|
||||||
|
}
|
||||||
|
return 'UTC';
|
||||||
|
}
|
||||||
|
export const TIMEZONE = resolveConfigTimezone();
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
import { formatLocalTime } from './timezone.js';
|
import {
|
||||||
|
formatLocalTime,
|
||||||
|
isValidTimezone,
|
||||||
|
resolveTimezone,
|
||||||
|
} from './timezone.js';
|
||||||
|
|
||||||
// --- formatLocalTime ---
|
// --- formatLocalTime ---
|
||||||
|
|
||||||
@@ -26,4 +30,44 @@ describe('formatLocalTime', () => {
|
|||||||
expect(ny).toContain('8:00');
|
expect(ny).toContain('8:00');
|
||||||
expect(tokyo).toContain('9:00');
|
expect(tokyo).toContain('9:00');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not throw on invalid timezone, falls back to UTC', () => {
|
||||||
|
expect(() =>
|
||||||
|
formatLocalTime('2026-01-01T00:00:00.000Z', 'IST-2'),
|
||||||
|
).not.toThrow();
|
||||||
|
const result = formatLocalTime('2026-01-01T12:00:00.000Z', 'IST-2');
|
||||||
|
// Should format as UTC (noon UTC = 12:00 PM)
|
||||||
|
expect(result).toContain('12:00');
|
||||||
|
expect(result).toContain('PM');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidTimezone', () => {
|
||||||
|
it('accepts valid IANA identifiers', () => {
|
||||||
|
expect(isValidTimezone('America/New_York')).toBe(true);
|
||||||
|
expect(isValidTimezone('UTC')).toBe(true);
|
||||||
|
expect(isValidTimezone('Asia/Tokyo')).toBe(true);
|
||||||
|
expect(isValidTimezone('Asia/Jerusalem')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid timezone strings', () => {
|
||||||
|
expect(isValidTimezone('IST-2')).toBe(false);
|
||||||
|
expect(isValidTimezone('XYZ+3')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty and garbage strings', () => {
|
||||||
|
expect(isValidTimezone('')).toBe(false);
|
||||||
|
expect(isValidTimezone('NotATimezone')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveTimezone', () => {
|
||||||
|
it('returns the timezone if valid', () => {
|
||||||
|
expect(resolveTimezone('America/New_York')).toBe('America/New_York');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to UTC for invalid timezone', () => {
|
||||||
|
expect(resolveTimezone('IST-2')).toBe('UTC');
|
||||||
|
expect(resolveTimezone('')).toBe('UTC');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Check whether a timezone string is a valid IANA identifier
|
||||||
|
* that Intl.DateTimeFormat can use.
|
||||||
|
*/
|
||||||
|
export function isValidTimezone(tz: string): boolean {
|
||||||
|
try {
|
||||||
|
Intl.DateTimeFormat(undefined, { timeZone: tz });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the given timezone if valid IANA, otherwise fall back to UTC.
|
||||||
|
*/
|
||||||
|
export function resolveTimezone(tz: string): string {
|
||||||
|
return isValidTimezone(tz) ? tz : 'UTC';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a UTC ISO timestamp to a localized display string.
|
* Convert a UTC ISO timestamp to a localized display string.
|
||||||
* Uses the Intl API (no external dependencies).
|
* Uses the Intl API (no external dependencies).
|
||||||
|
* Falls back to UTC if the timezone is invalid.
|
||||||
*/
|
*/
|
||||||
export function formatLocalTime(utcIso: string, timezone: string): string {
|
export function formatLocalTime(utcIso: string, timezone: string): string {
|
||||||
const date = new Date(utcIso);
|
const date = new Date(utcIso);
|
||||||
return date.toLocaleString('en-US', {
|
return date.toLocaleString('en-US', {
|
||||||
timeZone: timezone,
|
timeZone: resolveTimezone(timezone),
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
|||||||
Reference in New Issue
Block a user