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
|
||||
- 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
|
||||
|
||||
7
.github/workflows/fork-sync-skills.yml
vendored
7
.github/workflows/fork-sync-skills.yml
vendored
@@ -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
|
||||
;;
|
||||
|
||||
2
.github/workflows/merge-forward-skills.yml
vendored
2
.github/workflows/merge-forward-skills.yml
vendored
@@ -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) {
|
||||
|
||||
@@ -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
347
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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
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 { 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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user