Merge remote-tracking branch 'upstream/main'

This commit is contained in:
github-actions[bot]
2026-03-24 23:05:38 +00:00
12 changed files with 338 additions and 196 deletions

View File

@@ -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
- 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
### 3a. Choose runtime

View File

@@ -67,10 +67,15 @@ jobs:
AUTO_RESOLVABLE=true
for f in $CONFLICTED; do
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 add "$f"
;;
.env.example)
# Keep fork's channel-specific env vars
git checkout --ours "$f"
git add "$f"
;;
*)
AUTO_RESOLVABLE=false
;;

View File

@@ -160,6 +160,8 @@ jobs:
'nanoclaw-slack',
'nanoclaw-gmail',
'nanoclaw-docker-sandboxes',
'nanoclaw-docker-sandbox',
'nanoclaw-docker-sandbox-windows',
];
const sha = context.sha.substring(0, 7);
for (const repo of forks) {

View File

@@ -1,7 +1,7 @@
# NanoClaw Agent Container
# Runs Claude Agent SDK in isolated Linux VM with browser automation
FROM node:24-slim
FROM node:22-slim
# Install system dependencies for Chromium
RUN apt-get update && apt-get install -y \

347
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,12 +23,12 @@
"dependencies": {
"@onecli-sh/sdk": "^0.2.0",
"better-sqlite3": "^11.8.1",
"grammy": "^1.39.3",
"cron-parser": "^5.5.0",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
"yaml": "^2.8.2",
"zod": "^4.3.6"
"zod": "^4.3.6",
"grammy": "^1.39.3"
},
"devDependencies": {
"@eslint/js": "^9.35.0",

View File

@@ -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">
<title>42.2k tokens, 21% of context window</title>
<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.4k tokens, 21% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" 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">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">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 x="74" y="14">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.4k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -9,6 +9,7 @@ const STEPS: Record<
string,
() => Promise<{ run: (args: string[]) => Promise<void> }>
> = {
timezone: () => import('./timezone.js'),
environment: () => import('./environment.js'),
container: () => import('./container.js'),
groups: () => import('./groups.js'),

67
setup/timezone.ts Normal file
View 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',
});
}

View File

@@ -2,12 +2,14 @@ import os from 'os';
import path from 'path';
import { readEnvFile } from './env.js';
import { isValidTimezone } from './timezone.js';
// Read config values from .env (falls back to process.env).
const envConfig = readEnvFile([
'ASSISTANT_NAME',
'ASSISTANT_HAS_OWN_NUMBER',
'ONECLI_URL',
'TZ',
]);
export const ASSISTANT_NAME =
@@ -67,7 +69,17 @@ export const TRIGGER_PATTERN = new RegExp(
'i',
);
// Timezone for scheduled tasks (cron expressions, etc.)
// Uses system timezone by default
export const TIMEZONE =
process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
// Timezone for scheduled tasks, message formatting, etc.
// Validates each candidate is a real IANA identifier before accepting.
function resolveConfigTimezone(): string {
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();

View File

@@ -1,6 +1,10 @@
import { describe, it, expect } from 'vitest';
import { formatLocalTime } from './timezone.js';
import {
formatLocalTime,
isValidTimezone,
resolveTimezone,
} from './timezone.js';
// --- formatLocalTime ---
@@ -26,4 +30,44 @@ describe('formatLocalTime', () => {
expect(ny).toContain('8: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');
});
});

View File

@@ -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.
* Uses the Intl API (no external dependencies).
* Falls back to UTC if the timezone is invalid.
*/
export function formatLocalTime(utcIso: string, timezone: string): string {
const date = new Date(utcIso);
return date.toLocaleString('en-US', {
timeZone: timezone,
timeZone: resolveTimezone(timezone),
year: 'numeric',
month: 'short',
day: 'numeric',