feat(setup): per-checkout service name and docker image tag

Two NanoClaw installs on the same host used to fight over the shared `com.nanoclaw` launchd label / `nanoclaw.service` systemd unit and the `nanoclaw-agent:latest` docker tag — the second install silently rewrote the service pointer and rebuilt the image out from under the first. Introduces a deterministic per-checkout slug (sha1(projectRoot)[:8]) and namespaces everything off it:

- Service: `com.nanoclaw-v2-<slug>` / `nanoclaw-v2-<slug>.service`
- Image:   `nanoclaw-agent-v2-<slug>:latest` (base), `nanoclaw-agent-v2-<slug>:<agentGroupId>` (per-group)

New shared helpers: src/install-slug.ts (host) + setup/lib/install-slug.sh (bash). Both compute the same slug so verify/probe/add-*.sh/build.sh/container-runner all agree. Any v1 `com.nanoclaw` service left on the host stays untouched and can coexist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-23 10:10:09 +03:00
parent 4f6d62a65e
commit 7a9401ddf2
15 changed files with 156 additions and 44 deletions

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,