Merge remote-tracking branch 'origin/main' into setup-feedback-fixes

# Conflicts:
#	setup/auto.ts
#	setup/channels/whatsapp.ts
This commit is contained in:
gavrielc
2026-04-23 10:39:35 +03:00
17 changed files with 167 additions and 49 deletions
+5 -3
View File
@@ -110,13 +110,15 @@ mkdir -p data/env
cp .env data/env/env
log "Restarting service so the new adapter picks up the credentials…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart nanoclaw >&2 2>/dev/null \
|| sudo systemctl restart nanoclaw >&2 2>/dev/null \
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|| true
;;
esac
+5 -3
View File
@@ -119,13 +119,15 @@ mkdir -p data/env
cp .env data/env/env
log "Restarting service so the new adapter picks up the credentials…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart nanoclaw >&2 2>/dev/null \
|| sudo systemctl restart nanoclaw >&2 2>/dev/null \
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|| true
;;
esac
+5 -3
View File
@@ -144,13 +144,15 @@ cp .env data/env/env
# non-interactive install.
log "Restarting service so the new adapter picks up the token…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart nanoclaw >&2 2>/dev/null \
|| sudo systemctl restart nanoclaw >&2 2>/dev/null \
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|| true
;;
esac
+6 -4
View File
@@ -34,6 +34,7 @@ import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
import { brightSelect } from './lib/bright-select.js';
import { offerClaudeAssist } from './lib/claude-assist.js';
import { runWindowedStep } from './lib/windowed-runner.js';
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
import {
claudeCliAvailable,
resolveTimezoneViaClaude,
@@ -308,13 +309,14 @@ async function main(): Promise<void> {
}
const service = res.terminal?.fields.SERVICE;
if (service === 'running_other_checkout') {
const label = getLaunchdLabel();
notes.push(
wrapForGutter(
[
'• Your NanoClaw service is running from a different folder on this machine.',
' Point it at this checkout with:',
' launchctl bootout gui/$(id -u)/com.nanoclaw',
' launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.nanoclaw.plist',
` launchctl bootout gui/$(id -u)/${label}`,
` launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/${label}.plist`,
].join('\n'),
6,
),
@@ -460,8 +462,8 @@ function renderPingFailureNote(result: PingResult): void {
6,
),
'',
k.dim(' macOS: launchctl kickstart -k gui/$(id -u)/com.nanoclaw'),
k.dim(' Linux: systemctl --user restart nanoclaw'),
k.dim(` macOS: launchctl kickstart -k gui/$(id -u)/${getLaunchdLabel()}`),
k.dim(` Linux: systemctl --user restart ${getSystemdUnit()}`),
].join('\n')
: wrapForGutter(
'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.',
+5 -3
View File
@@ -34,6 +34,7 @@ import k from 'kleur';
import * as setupLog from '../logs.js';
import { brightSelect } from '../lib/bright-select.js';
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
import {
type Block,
type StepResult,
@@ -359,17 +360,18 @@ async function restartService(): Promise<void> {
if (platform === 'darwin') {
spawnSync(
'launchctl',
['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/com.nanoclaw`],
['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/${getLaunchdLabel()}`],
{ stdio: 'ignore' },
);
} else if (platform === 'linux') {
const unit = getSystemdUnit();
const user = spawnSync(
'systemctl',
['--user', 'restart', 'nanoclaw'],
['--user', 'restart', unit],
{ stdio: 'ignore' },
);
if (user.status !== 0) {
spawnSync('sudo', ['systemctl', 'restart', 'nanoclaw'], {
spawnSync('sudo', ['systemctl', 'restart', unit], {
stdio: 'ignore',
});
}
+2 -1
View File
@@ -7,6 +7,7 @@ import path from 'path';
import { setTimeout as sleep } from 'timers/promises';
import { log } from '../src/log.js';
import { getDefaultContainerImage } from '../src/install-slug.js';
import { commandExists, getPlatform } from './platform.js';
import { emitStatus } from './status.js';
@@ -81,7 +82,7 @@ function parseArgs(args: string[]): { runtime: string } {
export async function run(args: string[]): Promise<void> {
const projectRoot = process.cwd();
const { runtime } = parseArgs(args);
const image = 'nanoclaw-agent:latest';
const image = getDefaultContainerImage(projectRoot);
const logFile = path.join(projectRoot, 'logs', 'setup.log');
if (runtime !== 'docker') {
+37
View File
@@ -0,0 +1,37 @@
# install-slug.sh — shell mirror of setup/lib/install-slug.ts.
#
# Source this file after $PROJECT_ROOT is set:
#
# source "$PROJECT_ROOT/setup/lib/install-slug.sh"
# label=$(launchd_label) # com.nanoclaw-v2-<slug>
# unit=$(systemd_unit) # nanoclaw-v2-<slug>
# image=$(container_image_base) # nanoclaw-agent-v2-<slug>
#
# Slug is sha1(PROJECT_ROOT)[:8] — must match the TS helper exactly so both
# halves of setup name things consistently.
_nanoclaw_install_slug() {
local root="${NANOCLAW_PROJECT_ROOT:-${PROJECT_ROOT:-$PWD}}"
if command -v shasum >/dev/null 2>&1; then
printf '%s' "$root" | shasum | cut -c 1-8
elif command -v sha1sum >/dev/null 2>&1; then
printf '%s' "$root" | sha1sum | cut -c 1-8
else
# Fallback: hash the path with something deterministic-ish. Not ideal —
# but shasum is present on every modern macOS/Linux, so this is just
# belt-and-braces against a truly minimal system.
printf '%s' "$root" | od -An -tx1 | tr -d ' \n' | cut -c 1-8
fi
}
launchd_label() {
printf 'com.nanoclaw-v2-%s' "$(_nanoclaw_install_slug)"
}
systemd_unit() {
printf 'nanoclaw-v2-%s' "$(_nanoclaw_install_slug)"
}
container_image_base() {
printf 'nanoclaw-agent-v2-%s' "$(_nanoclaw_install_slug)"
}
+9 -3
View File
@@ -19,7 +19,13 @@ START_S=$(date +%s)
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOCAL_BIN="$HOME/.local/bin"
AGENT_IMAGE="nanoclaw-agent:latest"
# Per-checkout install names (match setup/lib/install-slug.ts).
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
LAUNCHD_LABEL=$(launchd_label)
SYSTEMD_UNIT=$(systemd_unit)
AGENT_IMAGE="$(container_image_base):latest"
export PATH="$LOCAL_BIN:$PATH"
@@ -144,7 +150,7 @@ probe_service_status() {
macos)
command_exists launchctl || { echo "not_configured"; return; }
local line
line=$(with_timeout launchctl list 2>/dev/null | grep "com.nanoclaw") || {
line=$(with_timeout launchctl list 2>/dev/null | grep "$LAUNCHD_LABEL") || {
echo "not_configured"; return; }
local pid
pid=$(echo "$line" | awk '{print $1}')
@@ -156,7 +162,7 @@ probe_service_status() {
;;
linux|wsl)
command_exists systemctl || { echo "not_configured"; return; }
if with_timeout systemctl --user is-active nanoclaw >/dev/null 2>&1; then
if with_timeout systemctl --user is-active "$SYSTEMD_UNIT" >/dev/null 2>&1; then
echo "running"
elif with_timeout systemctl --user cat nanoclaw >/dev/null 2>&1; then
echo "stopped"
+9 -8
View File
@@ -1,6 +1,8 @@
import { describe, it, expect } from 'vitest';
import path from 'path';
import { getLaunchdLabel } from '../src/install-slug.js';
/**
* Tests for service configuration generation.
*
@@ -14,12 +16,13 @@ function generatePlist(
projectRoot: string,
homeDir: string,
): string {
const label = getLaunchdLabel(projectRoot);
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.nanoclaw</string>
<string>${label}</string>
<key>ProgramArguments</key>
<array>
<string>${nodePath}</string>
@@ -73,13 +76,11 @@ WantedBy=${isSystem ? 'multi-user.target' : 'default.target'}`;
}
describe('plist generation', () => {
it('contains the correct label', () => {
const plist = generatePlist(
'/usr/local/bin/node',
'/home/user/nanoclaw',
'/home/user',
);
expect(plist).toContain('<string>com.nanoclaw</string>');
it('contains the slug-scoped label', () => {
const projectRoot = '/home/user/nanoclaw';
const plist = generatePlist('/usr/local/bin/node', projectRoot, '/home/user');
expect(plist).toContain(`<string>${getLaunchdLabel(projectRoot)}</string>`);
expect(plist).toMatch(/<string>com\.nanoclaw-v2-[0-9a-f]{8}<\/string>/);
});
it('uses the correct node path', () => {
+16 -8
View File
@@ -10,6 +10,7 @@ import os from 'os';
import path from 'path';
import { log } from '../src/log.js';
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
import {
commandExists,
getPlatform,
@@ -74,11 +75,14 @@ function setupLaunchd(
nodePath: string,
homeDir: string,
): void {
// Per-checkout service label so multiple NanoClaw installs can coexist
// without clobbering each other's plist.
const label = getLaunchdLabel(projectRoot);
const plistPath = path.join(
homeDir,
'Library',
'LaunchAgents',
'com.nanoclaw.plist',
`${label}.plist`,
);
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
@@ -87,7 +91,7 @@ function setupLaunchd(
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.nanoclaw</string>
<string>${label}</string>
<key>ProgramArguments</key>
<array>
<string>${nodePath}</string>
@@ -146,13 +150,14 @@ function setupLaunchd(
let serviceLoaded = false;
try {
const output = execSync('launchctl list', { encoding: 'utf-8' });
serviceLoaded = output.includes('com.nanoclaw');
serviceLoaded = output.includes(label);
} catch {
// launchctl list failed
}
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: 'launchd',
SERVICE_LABEL: label,
NODE_PATH: nodePath,
PROJECT_PATH: projectRoot,
PLIST_PATH: plistPath,
@@ -225,13 +230,15 @@ function setupSystemd(
homeDir: string,
): void {
const runningAsRoot = isRoot();
const unitName = getSystemdUnit(projectRoot);
const unitFileName = `${unitName}.service`;
// Root uses system-level service, non-root uses user-level
let unitPath: string;
let systemctlPrefix: string;
if (runningAsRoot) {
unitPath = '/etc/systemd/system/nanoclaw.service';
unitPath = `/etc/systemd/system/${unitFileName}`;
systemctlPrefix = 'systemctl';
log.info('Running as root — installing system-level systemd unit');
} else {
@@ -247,7 +254,7 @@ function setupSystemd(
}
const unitDir = path.join(homeDir, '.config', 'systemd', 'user');
fs.mkdirSync(unitDir, { recursive: true });
unitPath = path.join(unitDir, 'nanoclaw.service');
unitPath = path.join(unitDir, unitFileName);
systemctlPrefix = 'systemctl --user';
}
@@ -328,7 +335,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
}
try {
execSync(`${systemctlPrefix} enable nanoclaw`, { stdio: 'ignore' });
execSync(`${systemctlPrefix} enable ${unitName}`, { stdio: 'ignore' });
} catch (err) {
log.error('systemctl enable failed', { err });
}
@@ -339,7 +346,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
// `restart` on a stopped unit is equivalent to `start`, so this is safe
// as a first-install path too.
try {
execSync(`${systemctlPrefix} restart nanoclaw`, { stdio: 'ignore' });
execSync(`${systemctlPrefix} restart ${unitName}`, { stdio: 'ignore' });
} catch (err) {
log.error('systemctl restart failed', { err });
}
@@ -347,7 +354,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
// Verify
let serviceLoaded = false;
try {
execSync(`${systemctlPrefix} is-active nanoclaw`, { stdio: 'ignore' });
execSync(`${systemctlPrefix} is-active ${unitName}`, { stdio: 'ignore' });
serviceLoaded = true;
} catch {
// Not active
@@ -355,6 +362,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: runningAsRoot ? 'systemd-system' : 'systemd-user',
SERVICE_UNIT: unitName,
NODE_PATH: nodePath,
PROJECT_PATH: projectRoot,
UNIT_PATH: unitPath,
+8 -4
View File
@@ -15,6 +15,7 @@ import { DATA_DIR } from '../src/config.js';
import { readEnvFile } from '../src/env.js';
import { log } from '../src/log.js';
import { pingCliAgent } from './lib/agent-ping.js';
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
import {
getPlatform,
getServiceManager,
@@ -45,10 +46,13 @@ export async function run(_args: string[]): Promise<void> {
let runningFromPath: string | null = null;
const mgr = getServiceManager();
const launchdLabel = getLaunchdLabel(projectRoot);
const systemdUnit = getSystemdUnit(projectRoot);
if (mgr === 'launchd') {
try {
const output = execSync('launchctl list', { encoding: 'utf-8' });
const line = output.split('\n').find((l) => l.includes('com.nanoclaw'));
const line = output.split('\n').find((l) => l.includes(launchdLabel));
if (line) {
const pidField = line.trim().split(/\s+/)[0];
if (pidField !== '-' && pidField) {
@@ -67,11 +71,11 @@ export async function run(_args: string[]): Promise<void> {
} else if (mgr === 'systemd') {
const prefix = isRoot() ? 'systemctl' : 'systemctl --user';
try {
execSync(`${prefix} is-active nanoclaw`, { stdio: 'ignore' });
execSync(`${prefix} is-active ${systemdUnit}`, { stdio: 'ignore' });
service = 'running';
try {
const pidStr = execSync(
`${prefix} show nanoclaw -p MainPID --value`,
`${prefix} show ${systemdUnit} -p MainPID --value`,
{ encoding: 'utf-8' },
).trim();
const pid = Number(pidStr);
@@ -86,7 +90,7 @@ export async function run(_args: string[]): Promise<void> {
const output = execSync(`${prefix} list-unit-files`, {
encoding: 'utf-8',
});
if (output.includes('nanoclaw')) {
if (output.includes(systemdUnit)) {
service = 'stopped';
}
} catch {