Merge branch 'main' into setup-headless-auth-message

This commit is contained in:
gavrielc
2026-05-04 10:07:56 +03:00
committed by GitHub
8 changed files with 100 additions and 15 deletions

View File

@@ -6,8 +6,8 @@ This is NanoClaw v2, a ground-up rewrite with breaking changes throughout. It ca
**Do this instead:** **Do this instead:**
1. Abort the merge: `git merge --abort` (or `git reset --hard ORIG_HEAD` if the merge already completed). 1. Abort the merge: `git merge --abort` (or `git reset --hard ORIG_HEAD` if the merge already completed).
2. Tell the user, verbatim: *"This is the v2 rewrite — it can't be merged into your existing install. Run `bash migrate-v2.sh` to migrate instead."* 2. Tell the user, verbatim: *"This is the v2 rewrite — it can't be merged into your existing install. Exit Claude Code (or open a separate terminal) and run `bash migrate-v2.sh` from the shell."*
3. Wait for the user to confirm before doing anything else. Do not attempt the migration yourself. 3. Wait for the user to confirm before doing anything else. Do not run the migration script yourself — it requires an interactive terminal and cannot be run from within Claude Code.
If you are a fresh install (you ran `git clone`, not `git pull`) and there are no conflicts, ignore this banner and continue below. If you are a fresh install (you ran `git clone`, not `git pull`) and there are no conflicts, ignore this banner and continue below.

View File

@@ -5,6 +5,8 @@
# Run from the v2 directory: # Run from the v2 directory:
# bash migrate-v2.sh # bash migrate-v2.sh
# #
# If you're in Claude Code, exit first or open a separate terminal.
#
# Finds v1 automatically (sibling directory, or $NANOCLAW_V1_PATH). # Finds v1 automatically (sibling directory, or $NANOCLAW_V1_PATH).
# Installs prerequisites (Node, pnpm, deps) via the existing setup.sh # Installs prerequisites (Node, pnpm, deps) via the existing setup.sh
# bootstrap, then runs the migration steps. # bootstrap, then runs the migration steps.
@@ -17,6 +19,19 @@ set -uo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$PROJECT_ROOT" cd "$PROJECT_ROOT"
# This script has interactive prompts (channel selection, service switchover)
# and streams progress output — it must run in a real terminal, not inside
# a tool subprocess (e.g. Claude Code's Bash tool, which collapses output).
if ! [ -t 0 ] || ! [ -t 1 ]; then
echo "This script requires an interactive terminal."
echo ""
echo "If you're in Claude Code, exit first or open a separate terminal,"
echo "then run:"
echo " bash migrate-v2.sh"
echo ""
exit 1
fi
LOGS_DIR="$PROJECT_ROOT/logs" LOGS_DIR="$PROJECT_ROOT/logs"
STEPS_DIR="$LOGS_DIR/migrate-steps" STEPS_DIR="$LOGS_DIR/migrate-steps"
MIGRATE_LOG="$LOGS_DIR/migrate-v2.log" MIGRATE_LOG="$LOGS_DIR/migrate-v2.log"
@@ -547,6 +562,26 @@ echo
echo "$(bold 'Service switchover')" echo "$(bold 'Service switchover')"
echo echo
# Disable the v1 service so it doesn't auto-start, but leave the unit file
# on disk so the user can rollback with: systemctl --user start nanoclaw
# Idempotent — safe to call multiple times.
disable_v1_service() {
if [ "$PLATFORM_SERVICE" = "systemd" ]; then
local v1_file="$HOME/.config/systemd/user/${V1_SERVICE}.service"
if [ -f "$v1_file" ] || [ -L "$v1_file" ]; then
systemctl --user stop "$V1_SERVICE" 2>/dev/null || true
systemctl --user disable "$V1_SERVICE" 2>/dev/null || true
step_ok "Disabled $V1_SERVICE (unit file kept for rollback)"
fi
elif [ "$PLATFORM_SERVICE" = "launchd" ]; then
local v1_plist="$HOME/Library/LaunchAgents/${V1_SERVICE}.plist"
if [ -f "$v1_plist" ] || [ -L "$v1_plist" ]; then
launchctl unload "$v1_plist" 2>/dev/null || true
step_ok "Unloaded $V1_SERVICE (plist kept for rollback)"
fi
fi
}
# Detect platform and service names # Detect platform and service names
V1_SERVICE="" V1_SERVICE=""
V2_SERVICE="" V2_SERVICE=""
@@ -635,16 +670,14 @@ if [ "$V1_RUNNING" = "true" ]; then
SERVICE_SWITCHED=false SERVICE_SWITCHED=false
else else
step_ok "Keeping v2 service" step_ok "Keeping v2 service"
# Disable v1 from auto-starting disable_v1_service
if [ "$PLATFORM_SERVICE" = "systemd" ]; then
systemctl --user disable "$V1_SERVICE" 2>/dev/null || true
fi
fi fi
else else
step_skip "Service switchover skipped" step_skip "Service switchover skipped"
fi fi
else else
step_skip "v1 service not running — nothing to switch" step_skip "v1 service not running — nothing to switch"
disable_v1_service
fi fi
echo echo
@@ -676,6 +709,16 @@ echo " $(green '✓') Channels installed: ${SELECTED_CHANNELS[*]}"
fi fi
echo " $(green '✓') Container skills copied" echo " $(green '✓') Container skills copied"
echo " $(green '✓') Container image built" echo " $(green '✓') Container image built"
if [ "$SERVICE_SWITCHED" = "true" ] && [ -n "$V2_SERVICE" ]; then
echo " $(green '✓') Service switched to v2 $(dim "($V2_SERVICE)")"
echo
echo " $(bold 'Rollback to v1:')"
if [ "$PLATFORM_SERVICE" = "systemd" ]; then
echo " $(dim '$') systemctl --user stop $V2_SERVICE && systemctl --user start $V1_SERVICE"
elif [ "$PLATFORM_SERVICE" = "launchd" ]; then
echo " $(dim '$') launchctl unload ~/Library/LaunchAgents/${V2_SERVICE}.plist && launchctl load ~/Library/LaunchAgents/${V1_SERVICE}.plist"
fi
fi
echo echo
echo " $(bold 'What still needs a human:')" echo " $(bold 'What still needs a human:')"
if [ "$ONECLI_OK" = "false" ]; then if [ "$ONECLI_OK" = "false" ]; then

View File

@@ -1,6 +1,6 @@
{ {
"name": "nanoclaw", "name": "nanoclaw",
"version": "2.0.27", "version": "2.0.28",
"description": "Personal Claude assistant. Lightweight, secure, customizable.", "description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module", "type": "module",
"packageManager": "pnpm@10.33.0", "packageManager": "pnpm@10.33.0",

View File

@@ -84,21 +84,28 @@ describe('credentials detection', () => {
const content = const content =
'SOME_KEY=value\nANTHROPIC_API_KEY=sk-ant-test123\nOTHER=foo'; 'SOME_KEY=value\nANTHROPIC_API_KEY=sk-ant-test123\nOTHER=foo';
const hasCredentials = const hasCredentials =
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content); /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|ONECLI_URL)=/m.test(content);
expect(hasCredentials).toBe(true); expect(hasCredentials).toBe(true);
}); });
it('detects CLAUDE_CODE_OAUTH_TOKEN in env content', () => { it('detects CLAUDE_CODE_OAUTH_TOKEN in env content', () => {
const content = 'CLAUDE_CODE_OAUTH_TOKEN=token123'; const content = 'CLAUDE_CODE_OAUTH_TOKEN=token123';
const hasCredentials = const hasCredentials =
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content); /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|ONECLI_URL)=/m.test(content);
expect(hasCredentials).toBe(true);
});
it('detects ANTHROPIC_AUTH_TOKEN in env content', () => {
const content = 'ANTHROPIC_AUTH_TOKEN=token123\nANTHROPIC_BASE_URL=http://localhost:8080';
const hasCredentials =
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|ONECLI_URL)=/m.test(content);
expect(hasCredentials).toBe(true); expect(hasCredentials).toBe(true);
}); });
it('returns false when no credentials', () => { it('returns false when no credentials', () => {
const content = 'ASSISTANT_NAME="Andy"\nOTHER=foo'; const content = 'ASSISTANT_NAME="Andy"\nOTHER=foo';
const hasCredentials = const hasCredentials =
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content); /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|ONECLI_URL)=/m.test(content);
expect(hasCredentials).toBe(false); expect(hasCredentials).toBe(false);
}); });
}); });

View File

@@ -12,6 +12,7 @@
import fs from 'fs'; import fs from 'fs';
import * as p from '@clack/prompts'; import * as p from '@clack/prompts';
import { styleText } from 'node:util';
const CHANNELS = [ const CHANNELS = [
{ value: 'telegram', label: 'Telegram' }, { value: 'telegram', label: 'Telegram' },
@@ -47,7 +48,7 @@ async function main(): Promise<void> {
} }
const selected = await p.multiselect({ const selected = await p.multiselect({
message: 'Which channels do you want to set up?', message: 'Which channels do you want to set up?\n' + styleText('dim', ' space to select, enter to confirm') + '\n',
options: CHANNELS, options: CHANNELS,
required: false, required: false,
}); });

View File

@@ -115,9 +115,43 @@ function installOnecliCliOnly(): { stdout: string; ok: boolean } {
return { stdout: upstream.stdout + (upstream.stderr ?? '') + '\n' + fallback.stdout, ok: fallback.ok }; return { stdout: upstream.stdout + (upstream.stderr ?? '') + '\n' + fallback.stdout, ok: fallback.ok };
} }
// Remove containers in the "onecli" compose project whose service name isn't
// in the v2 set. Pre-v2 OneCLI used service "app" (container onecli-app-1);
// v2 uses "onecli". Compose flags the old container as an orphan but won't
// stop it without --remove-orphans, leaving port 10254 bound and crashing
// the new bring-up. Filed upstream; this is the downstream workaround.
function removeLegacyOnecliContainers(): string {
const out: string[] = [];
let list = '';
try {
list = execSync(
`docker ps -a --filter "label=com.docker.compose.project=onecli" --format '{{.Names}}|{{.Label "com.docker.compose.service"}}'`,
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },
).trim();
} catch {
return '';
}
if (!list) return '';
const v2Services = new Set(['onecli', 'postgres']);
for (const line of list.split('\n')) {
const [name, service] = line.split('|');
if (!name || !service || v2Services.has(service)) continue;
out.push(`Removing legacy OneCLI container: ${name} (service=${service})`);
try {
execSync(`docker rm -f ${JSON.stringify(name)}`, { stdio: ['ignore', 'pipe', 'pipe'] });
} catch (err) {
out.push(` rm failed (continuing): ${(err as Error).message}`);
}
}
return out.join('\n');
}
function installOnecli(): { stdout: string; ok: boolean } { function installOnecli(): { stdout: string; ok: boolean } {
let stdout = ''; let stdout = '';
const cleanup = removeLegacyOnecliContainers();
if (cleanup) stdout += cleanup + '\n';
// Gateway install (docker-compose based, no rate-limit concerns). // Gateway install (docker-compose based, no rate-limit concerns).
const gw = runInstall('curl -fsSL onecli.sh/install | sh'); const gw = runInstall('curl -fsSL onecli.sh/install | sh');
stdout += gw.stdout; stdout += gw.stdout;

View File

@@ -139,7 +139,7 @@ export async function run(_args: string[]): Promise<void> {
const envFile = path.join(projectRoot, '.env'); const envFile = path.join(projectRoot, '.env');
if (fs.existsSync(envFile)) { if (fs.existsSync(envFile)) {
const envContent = fs.readFileSync(envFile, 'utf-8'); const envContent = fs.readFileSync(envFile, 'utf-8');
if (/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ONECLI_URL)=/m.test(envContent)) { if (/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|ONECLI_URL)=/m.test(envContent)) {
credentials = 'configured'; credentials = 'configured';
} }
} }

View File

@@ -253,12 +253,12 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
// Chat SDK dispatch (handling-events.mdx §"Handler dispatch order") is // Chat SDK dispatch (handling-events.mdx §"Handler dispatch order") is
// exclusive: subscribed → onSubscribedMessage; unsubscribed+mention → // exclusive: subscribed → onSubscribedMessage; unsubscribed+mention →
// onNewMention; unsubscribed+pattern-match → onNewMessage. Registering // onNewMention; unsubscribed+pattern-match → onNewMessage. Registering
// with `/./` lets the router see every plain message on every // with `/[\s\S]*/` lets the router see every plain message (including
// unsubscribed thread the bot can see. The router short-circuits via // media-only messages with empty text) on every unsubscribed thread the
// getMessagingGroupWithAgentCount (~1 DB read) for unwired channels, // getMessagingGroupWithAgentCount (~1 DB read) for unwired channels,
// so forwarding every one is cheap enough to not need a bridge-side // so forwarding every one is cheap enough to not need a bridge-side
// flood gate. // flood gate.
chat.onNewMessage(/./, async (thread, message) => { chat.onNewMessage(/[\s\S]*/, async (thread, message) => {
const channelId = adapter.channelIdFromThreadId(thread.id); const channelId = adapter.channelIdFromThreadId(thread.id);
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false, true)); await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false, true));
}); });