refactor(new-setup): rewrite probe in pure bash, drop unavailable fallback
The probe now returns a real snapshot from second zero, so every step consults real probe fields instead of falling back to "run every step blindly" when Node isn't installed. Also drops the redundant CLI_AGENT_WIRED field (it gated the last step on its own end-state) and scopes timezone out of the probe (timezone is not part of /new-setup). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@ Before each step, narrate to the user in your own words what's about to happen
|
|||||||
|
|
||||||
Each step is invoked as `pnpm exec tsx setup/index.ts --step <name>` and emits a structured status block Claude parses to decide what to do next.
|
Each step is invoked as `pnpm exec tsx setup/index.ts --step <name>` and emits a structured status block Claude parses to decide what to do next.
|
||||||
|
|
||||||
Start with a probe: a single parallel scan that snapshots every prerequisite and dependency. The rest of the flow reads this snapshot to decide what to run, skip, or ask about — no per-step re-checking. The probe is plain ESM JS (`setup/probe.mjs`) with no external deps so it can run before step 1 has installed `pnpm`/`node_modules`.
|
Start with a probe: a single upfront scan that snapshots every prerequisite and dependency. The rest of the flow reads this snapshot to decide what to run, skip, or ask about — no per-step re-checking. The probe is pure bash (`setup/probe.sh`) with no external deps so it runs correctly before Node has been installed.
|
||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
@@ -22,9 +22,7 @@ Start with a probe: a single parallel scan that snapshots every prerequisite and
|
|||||||
|
|
||||||
## Flow
|
## Flow
|
||||||
|
|
||||||
Parse the probe block above. For each step below, consult the named probe fields and skip, ask, or run accordingly.
|
Parse the probe block above. For each step below, consult the named probe fields and skip, ask, or run accordingly. The probe always returns a real snapshot — there is no "node not installed" fallback; `HOST_DEPS=missing` is how you know Node/pnpm haven't been bootstrapped yet.
|
||||||
|
|
||||||
If the probe reports `STATUS: unavailable` (Node isn't installed yet), ignore all `skip if …` probe conditions and run every step from 1 onward — each step has its own idempotency check, so re-running is safe.
|
|
||||||
|
|
||||||
## Ordering and parallelism
|
## Ordering and parallelism
|
||||||
|
|
||||||
@@ -40,12 +38,12 @@ One permitted parallelism:
|
|||||||
|
|
||||||
Check probe results and skip if `HOST_DEPS=ok` — Node, pnpm, `node_modules`, and `better-sqlite3`'s native binding are already in place.
|
Check probe results and skip if `HOST_DEPS=ok` — Node, pnpm, `node_modules`, and `better-sqlite3`'s native binding are already in place.
|
||||||
|
|
||||||
If the probe reported `STATUS: unavailable` (Node isn't installed yet — probe itself couldn't run), install Node 22 **before** running `bash setup.sh`, otherwise the first bootstrap run is guaranteed to fail:
|
If `HOST_DEPS=missing` and `node --version` fails (Node isn't installed at all), install Node 22 **before** running `bash setup.sh`, otherwise the first bootstrap run is guaranteed to fail:
|
||||||
|
|
||||||
- macOS: `brew install node@22`
|
- macOS: `brew install node@22`
|
||||||
- Linux / WSL: `curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs`
|
- Linux / WSL: `curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs`
|
||||||
|
|
||||||
Then run `bash setup.sh`. If the probe succeeded but `HOST_DEPS=missing`, run `bash setup.sh` directly — Node is there, deps aren't.
|
Then run `bash setup.sh`. If Node is already present and only `HOST_DEPS=missing`, run `bash setup.sh` directly — deps just haven't been installed yet.
|
||||||
|
|
||||||
Parse the status block:
|
Parse the status block:
|
||||||
|
|
||||||
@@ -118,8 +116,6 @@ Start the NanoClaw background service — it relays messages between the user an
|
|||||||
|
|
||||||
### 6. First CLI agent
|
### 6. First CLI agent
|
||||||
|
|
||||||
Check probe results and skip if `CLI_AGENT_WIRED=true`.
|
|
||||||
|
|
||||||
If step 2's container build is still running in the background, join it here before proceeding — the agent needs the image.
|
If step 2's container build is still running in the background, join it here before proceeding — the agent needs the image.
|
||||||
|
|
||||||
Create the first agent and wire it to the CLI channel. Ask the user "What should I call you?" first — default the offered value to `INFERRED_DISPLAY_NAME` from the probe.
|
Create the first agent and wire it to the CLI channel. Ask the user "What should I call you?" first — default the offered value to `INFERRED_DISPLAY_NAME` from the probe.
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ function installOnecli(): { stdout: string; ok: boolean } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
|
async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
|
||||||
// `/api/health` matches the path probe.mjs uses — keep them aligned.
|
// `/api/health` matches the path probe.sh uses — keep them aligned.
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
367
setup/probe.mjs
367
setup/probe.mjs
@@ -1,367 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Setup step: probe — Single upfront parallel scan for /new-setup's dynamic
|
|
||||||
* context injection. Rendered into the SKILL.md prompt via
|
|
||||||
* `!node setup/probe.mjs` so Claude sees the current system state before
|
|
||||||
* generating its first response.
|
|
||||||
*
|
|
||||||
* This is a routing aid, NOT a replacement for per-step idempotency checks.
|
|
||||||
* Each step keeps its own checks; probe tells the skill which steps to skip.
|
|
||||||
*
|
|
||||||
* Plain ESM JS (zero deps) by design: this runs BEFORE setup.sh has installed
|
|
||||||
* pnpm and node_modules, so it can only use Node built-ins. `better-sqlite3`
|
|
||||||
* is dynamic-imported so the probe degrades gracefully on fresh installs.
|
|
||||||
*
|
|
||||||
* Keep fast (<2s total). All probes swallow their own errors and report a
|
|
||||||
* neutral state rather than failing the whole scan.
|
|
||||||
*/
|
|
||||||
import { execFileSync, execSync } from 'node:child_process';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
const LOCAL_BIN = path.join(os.homedir(), '.local', 'bin');
|
|
||||||
const PROBE_TIMEOUT_MS = 2000;
|
|
||||||
const HEALTH_TIMEOUT_MS = 2000;
|
|
||||||
const AGENT_IMAGE = 'nanoclaw-agent:latest';
|
|
||||||
const DATA_DIR = path.resolve(process.cwd(), 'data');
|
|
||||||
|
|
||||||
function childEnv() {
|
|
||||||
const parts = [LOCAL_BIN];
|
|
||||||
if (process.env.PATH) parts.push(process.env.PATH);
|
|
||||||
return { ...process.env, PATH: parts.join(path.delimiter) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPlatform() {
|
|
||||||
const p = os.platform();
|
|
||||||
if (p === 'darwin') return 'macos';
|
|
||||||
if (p === 'linux') return 'linux';
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWSL() {
|
|
||||||
if (os.platform() !== 'linux') return false;
|
|
||||||
try {
|
|
||||||
const release = fs.readFileSync('/proc/version', 'utf-8').toLowerCase();
|
|
||||||
return release.includes('microsoft') || release.includes('wsl');
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function commandExists(name) {
|
|
||||||
try {
|
|
||||||
execSync(`command -v ${name}`, { stdio: 'ignore' });
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidTimezone(tz) {
|
|
||||||
try {
|
|
||||||
new Intl.DateTimeFormat(undefined, { timeZone: tz });
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitStatus(step, fields) {
|
|
||||||
const lines = [`=== NANOCLAW SETUP: ${step} ===`];
|
|
||||||
for (const [k, v] of Object.entries(fields)) {
|
|
||||||
lines.push(`${k}: ${v}`);
|
|
||||||
}
|
|
||||||
lines.push('=== END ===');
|
|
||||||
console.log(lines.join('\n'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function readEnvVar(name) {
|
|
||||||
const envFile = path.join(process.cwd(), '.env');
|
|
||||||
if (!fs.existsSync(envFile)) return null;
|
|
||||||
const content = fs.readFileSync(envFile, 'utf-8');
|
|
||||||
const m = content.match(new RegExp(`^${name}=(.+)$`, 'm'));
|
|
||||||
if (!m) return null;
|
|
||||||
return m[1].trim().replace(/^["']|["']$/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function probeDocker() {
|
|
||||||
if (!commandExists('docker')) return { status: 'not_found', imagePresent: false };
|
|
||||||
try {
|
|
||||||
execSync('docker info', { stdio: 'ignore', timeout: PROBE_TIMEOUT_MS });
|
|
||||||
} catch {
|
|
||||||
return { status: 'installed_not_running', imagePresent: false };
|
|
||||||
}
|
|
||||||
let imagePresent = false;
|
|
||||||
try {
|
|
||||||
execSync(`docker image inspect ${AGENT_IMAGE}`, {
|
|
||||||
stdio: 'ignore',
|
|
||||||
timeout: PROBE_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
imagePresent = true;
|
|
||||||
} catch {
|
|
||||||
// image not built yet
|
|
||||||
}
|
|
||||||
return { status: 'running', imagePresent };
|
|
||||||
}
|
|
||||||
|
|
||||||
function probeOnecliUrl() {
|
|
||||||
const fromEnv = readEnvVar('ONECLI_URL');
|
|
||||||
if (fromEnv) return fromEnv;
|
|
||||||
try {
|
|
||||||
const out = execFileSync('onecli', ['config', 'get', 'api-host'], {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
env: childEnv(),
|
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
|
||||||
timeout: PROBE_TIMEOUT_MS,
|
|
||||||
}).trim();
|
|
||||||
const parsed = JSON.parse(out);
|
|
||||||
if (typeof parsed.value === 'string' && parsed.value) return parsed.value;
|
|
||||||
} catch {
|
|
||||||
// onecli not installed or config not set
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function probeOnecliStatus(url) {
|
|
||||||
const installed =
|
|
||||||
commandExists('onecli') || fs.existsSync(path.join(LOCAL_BIN, 'onecli'));
|
|
||||||
if (!installed) return 'not_found';
|
|
||||||
if (!url) return 'installed_not_healthy';
|
|
||||||
try {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
|
|
||||||
const res = await fetch(`${url}/api/health`, { signal: controller.signal });
|
|
||||||
clearTimeout(timer);
|
|
||||||
return res.ok ? 'healthy' : 'installed_not_healthy';
|
|
||||||
} catch {
|
|
||||||
return 'installed_not_healthy';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function probeAnthropicSecret() {
|
|
||||||
try {
|
|
||||||
const out = execFileSync('onecli', ['secrets', 'list'], {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
env: childEnv(),
|
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
|
||||||
timeout: PROBE_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
const parsed = JSON.parse(out);
|
|
||||||
return !!(parsed.data && parsed.data.some((s) => s.type === 'anthropic'));
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function probeServiceStatus() {
|
|
||||||
const platform = getPlatform();
|
|
||||||
if (platform === 'macos') {
|
|
||||||
try {
|
|
||||||
const out = execSync('launchctl list', {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
timeout: PROBE_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
const line = out.split('\n').find((l) => l.includes('com.nanoclaw'));
|
|
||||||
if (!line) return 'not_configured';
|
|
||||||
const pid = line.trim().split(/\s+/)[0];
|
|
||||||
return pid && pid !== '-' ? 'running' : 'stopped';
|
|
||||||
} catch {
|
|
||||||
return 'not_configured';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (platform === 'linux') {
|
|
||||||
try {
|
|
||||||
execSync('systemctl --user is-active nanoclaw', {
|
|
||||||
stdio: 'ignore',
|
|
||||||
timeout: PROBE_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
return 'running';
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
execSync('systemctl --user cat nanoclaw', {
|
|
||||||
stdio: 'ignore',
|
|
||||||
timeout: PROBE_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
return 'stopped';
|
|
||||||
} catch {
|
|
||||||
return 'not_configured';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 'not_configured';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function probeCliAgentWired() {
|
|
||||||
const dbPath = path.join(DATA_DIR, 'v2.db');
|
|
||||||
if (!fs.existsSync(dbPath)) return false;
|
|
||||||
// Dynamic-import so probe still runs before `pnpm install` has built the
|
|
||||||
// native module. On truly fresh installs `data/v2.db` can't exist anyway,
|
|
||||||
// so the short-circuit above handles that path.
|
|
||||||
try {
|
|
||||||
const mod = await import('better-sqlite3');
|
|
||||||
const Database = mod.default ?? mod;
|
|
||||||
const db = new Database(dbPath, { readonly: true });
|
|
||||||
try {
|
|
||||||
const row = db
|
|
||||||
.prepare(
|
|
||||||
`SELECT 1 FROM messaging_group_agents mga
|
|
||||||
JOIN messaging_groups mg ON mg.id = mga.messaging_group_id
|
|
||||||
WHERE mg.channel_type = 'cli' LIMIT 1`,
|
|
||||||
)
|
|
||||||
.get();
|
|
||||||
return !!row;
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function probeInferredDisplayName() {
|
|
||||||
const reject = (s) => !s || !s.trim() || s.trim().toLowerCase() === 'root';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const name = execFileSync('git', ['config', '--global', 'user.name'], {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
timeout: 1000,
|
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
|
||||||
}).trim();
|
|
||||||
if (!reject(name)) return name;
|
|
||||||
} catch {
|
|
||||||
// git missing or no config set
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = process.env.USER || os.userInfo().username;
|
|
||||||
const platform = getPlatform();
|
|
||||||
|
|
||||||
if (platform === 'macos') {
|
|
||||||
try {
|
|
||||||
const fullName = execFileSync('id', ['-F', user], {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
timeout: 1000,
|
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
|
||||||
}).trim();
|
|
||||||
if (!reject(fullName)) return fullName;
|
|
||||||
} catch {
|
|
||||||
// id -F not supported
|
|
||||||
}
|
|
||||||
} else if (platform === 'linux') {
|
|
||||||
try {
|
|
||||||
const entry = execFileSync('getent', ['passwd', user], {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
timeout: 1000,
|
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
|
||||||
}).trim();
|
|
||||||
const gecos = entry.split(':')[4];
|
|
||||||
if (gecos) {
|
|
||||||
const fullName = gecos.split(',')[0].trim();
|
|
||||||
if (!reject(fullName)) return fullName;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// getent missing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!reject(user)) return user;
|
|
||||||
return 'User';
|
|
||||||
}
|
|
||||||
|
|
||||||
function probeHostDeps() {
|
|
||||||
const nodeModules = path.resolve(process.cwd(), 'node_modules');
|
|
||||||
if (!fs.existsSync(nodeModules)) return 'missing';
|
|
||||||
// better-sqlite3's compiled native binding is the canonical proof that
|
|
||||||
// `pnpm install` ran AND the native build step succeeded. Cheaper than
|
|
||||||
// actually loading the module, and unambiguous on success.
|
|
||||||
const nativeBinding = path.join(
|
|
||||||
nodeModules,
|
|
||||||
'better-sqlite3',
|
|
||||||
'build',
|
|
||||||
'Release',
|
|
||||||
'better_sqlite3.node',
|
|
||||||
);
|
|
||||||
return fs.existsSync(nativeBinding) ? 'ok' : 'missing';
|
|
||||||
}
|
|
||||||
|
|
||||||
function probeTimezone() {
|
|
||||||
const envTz = readEnvVar('TZ');
|
|
||||||
const systemTz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
|
||||||
|
|
||||||
let status;
|
|
||||||
if (envTz && isValidTimezone(envTz)) {
|
|
||||||
status = 'configured';
|
|
||||||
} else if (systemTz === 'UTC' || systemTz === 'Etc/UTC') {
|
|
||||||
status = 'utc_suspicious';
|
|
||||||
} else if (systemTz && isValidTimezone(systemTz)) {
|
|
||||||
status = 'autodetected';
|
|
||||||
} else {
|
|
||||||
status = 'needs_input';
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status,
|
|
||||||
envTz: envTz || 'none',
|
|
||||||
systemTz: systemTz || 'unknown',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function run() {
|
|
||||||
const started = Date.now();
|
|
||||||
|
|
||||||
const platform = getPlatform();
|
|
||||||
const wsl = isWSL();
|
|
||||||
const osLabel = wsl
|
|
||||||
? 'wsl'
|
|
||||||
: platform === 'macos'
|
|
||||||
? 'macos'
|
|
||||||
: platform === 'linux'
|
|
||||||
? 'linux'
|
|
||||||
: 'unknown';
|
|
||||||
const shell = process.env.SHELL || 'unknown';
|
|
||||||
|
|
||||||
const docker = probeDocker();
|
|
||||||
const oneCliUrl = probeOnecliUrl();
|
|
||||||
const serviceStatus = probeServiceStatus();
|
|
||||||
const displayName = probeInferredDisplayName();
|
|
||||||
const tz = probeTimezone();
|
|
||||||
const hostDeps = probeHostDeps();
|
|
||||||
|
|
||||||
const [onecliStatus, cliAgentWired] = await Promise.all([
|
|
||||||
probeOnecliStatus(oneCliUrl),
|
|
||||||
probeCliAgentWired(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const anthropicSecret =
|
|
||||||
onecliStatus !== 'not_found' ? probeAnthropicSecret() : false;
|
|
||||||
|
|
||||||
const elapsedMs = Date.now() - started;
|
|
||||||
|
|
||||||
emitStatus('PROBE', {
|
|
||||||
OS: osLabel,
|
|
||||||
SHELL: shell,
|
|
||||||
HOST_DEPS: hostDeps,
|
|
||||||
DOCKER: docker.status,
|
|
||||||
IMAGE_PRESENT: docker.imagePresent,
|
|
||||||
ONECLI_STATUS: onecliStatus,
|
|
||||||
ONECLI_URL: oneCliUrl || 'none',
|
|
||||||
ANTHROPIC_SECRET: anthropicSecret,
|
|
||||||
SERVICE_STATUS: serviceStatus,
|
|
||||||
CLI_AGENT_WIRED: cliAgentWired,
|
|
||||||
INFERRED_DISPLAY_NAME: displayName,
|
|
||||||
TZ_STATUS: tz.status,
|
|
||||||
TZ_ENV: tz.envTz,
|
|
||||||
TZ_SYSTEM: tz.systemTz,
|
|
||||||
ELAPSED_MS: elapsedMs,
|
|
||||||
STATUS: 'success',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const invokedDirectly =
|
|
||||||
import.meta.url === `file://${path.resolve(process.argv[1] ?? '')}`;
|
|
||||||
if (invokedDirectly) {
|
|
||||||
run().catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
247
setup/probe.sh
247
setup/probe.sh
@@ -1,19 +1,248 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Wrapper for setup/probe.mjs so /new-setup's inline `!` block is a single
|
# Setup step: probe — single upfront parallel-ish scan that snapshots every
|
||||||
# shell command (permission-check friendly). When Node isn't installed yet,
|
# prerequisite and dependency for /new-setup's dynamic context injection.
|
||||||
# emit an "unavailable" status block so the skill's flow knows to skip the
|
# Rendered into the SKILL.md prompt via `!bash setup/probe.sh` so Claude sees
|
||||||
# probe's skip-if conditions and run every step from 1.
|
# the current system state before generating its first response.
|
||||||
|
#
|
||||||
|
# Pure bash by design: this runs BEFORE setup.sh has installed Node, pnpm, and
|
||||||
|
# node_modules, so it cannot rely on any Node-based tooling. Every field below
|
||||||
|
# is computed from POSIX utilities + grep/awk/curl.
|
||||||
|
#
|
||||||
|
# This is a routing aid, NOT a replacement for per-step idempotency checks.
|
||||||
|
# Each step keeps its own checks; probe tells the skill which steps to skip.
|
||||||
|
#
|
||||||
|
# Keep fast (<2s total). All probes swallow their own errors and report a
|
||||||
|
# neutral state rather than failing the whole scan.
|
||||||
set -u
|
set -u
|
||||||
|
|
||||||
|
START_S=$(date +%s)
|
||||||
|
|
||||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
LOCAL_BIN="$HOME/.local/bin"
|
||||||
|
AGENT_IMAGE="nanoclaw-agent:latest"
|
||||||
|
|
||||||
if command -v node >/dev/null 2>&1; then
|
export PATH="$LOCAL_BIN:$PATH"
|
||||||
exec node "$PROJECT_ROOT/setup/probe.mjs" "$@"
|
|
||||||
|
command_exists() { command -v "$1" >/dev/null 2>&1; }
|
||||||
|
|
||||||
|
# Best-effort 2s timeout; falls back to no timeout on macOS if `timeout` isn't
|
||||||
|
# installed (the probed commands are all expected to return fast anyway).
|
||||||
|
with_timeout() {
|
||||||
|
if command_exists timeout; then timeout 2 "$@"
|
||||||
|
elif command_exists gtimeout; then gtimeout 2 "$@"
|
||||||
|
else "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
trim() {
|
||||||
|
local s="$1"
|
||||||
|
s="${s#"${s%%[![:space:]]*}"}"
|
||||||
|
s="${s%"${s##*[![:space:]]}"}"
|
||||||
|
printf '%s' "$s"
|
||||||
|
}
|
||||||
|
|
||||||
|
read_env_var() {
|
||||||
|
local name="$1"
|
||||||
|
local envfile="$PROJECT_ROOT/.env"
|
||||||
|
[[ -f "$envfile" ]] || return 0
|
||||||
|
local line
|
||||||
|
line=$(grep -E "^${name}=" "$envfile" 2>/dev/null | head -n1) || return 0
|
||||||
|
[[ -z "$line" ]] && return 0
|
||||||
|
local val="${line#*=}"
|
||||||
|
val="${val%\"}"; val="${val#\"}"
|
||||||
|
val="${val%\'}"; val="${val#\'}"
|
||||||
|
trim "$val"
|
||||||
|
}
|
||||||
|
|
||||||
|
probe_os() {
|
||||||
|
case "$(uname -s 2>/dev/null)" in
|
||||||
|
Darwin) echo "macos" ;;
|
||||||
|
Linux)
|
||||||
|
if [[ -r /proc/version ]] && grep -qEi "microsoft|wsl" /proc/version; then
|
||||||
|
echo "wsl"
|
||||||
|
else
|
||||||
|
echo "linux"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*) echo "unknown" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
probe_host_deps() {
|
||||||
|
local node_modules="$PROJECT_ROOT/node_modules"
|
||||||
|
local native="$node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||||
|
# `better-sqlite3`'s compiled native binding is the canonical proof that
|
||||||
|
# `pnpm install` ran AND the native build step succeeded.
|
||||||
|
if [[ -d "$node_modules" && -f "$native" ]]; then
|
||||||
|
echo "ok"
|
||||||
|
else
|
||||||
|
echo "missing"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sets DOCKER_STATUS and IMAGE_PRESENT as globals.
|
||||||
|
probe_docker() {
|
||||||
|
DOCKER_STATUS="not_found"
|
||||||
|
IMAGE_PRESENT="false"
|
||||||
|
command_exists docker || return 0
|
||||||
|
if ! with_timeout docker info >/dev/null 2>&1; then
|
||||||
|
DOCKER_STATUS="installed_not_running"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
DOCKER_STATUS="running"
|
||||||
|
if with_timeout docker image inspect "$AGENT_IMAGE" >/dev/null 2>&1; then
|
||||||
|
IMAGE_PRESENT="true"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
probe_onecli_url() {
|
||||||
|
local url
|
||||||
|
url=$(read_env_var ONECLI_URL)
|
||||||
|
if [[ -n "$url" ]]; then
|
||||||
|
printf '%s' "$url"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
command_exists onecli || return 0
|
||||||
|
local out
|
||||||
|
out=$(with_timeout onecli config get api-host 2>/dev/null) || return 0
|
||||||
|
# Minimal JSON extract: {"value":"http..."} — avoid hard dep on jq
|
||||||
|
if [[ "$out" =~ \"value\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then
|
||||||
|
printf '%s' "${BASH_REMATCH[1]}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
probe_onecli_status() {
|
||||||
|
local url="$1"
|
||||||
|
if ! command_exists onecli && [[ ! -x "$LOCAL_BIN/onecli" ]]; then
|
||||||
|
echo "not_found"; return
|
||||||
|
fi
|
||||||
|
if [[ -z "$url" ]]; then
|
||||||
|
echo "installed_not_healthy"; return
|
||||||
|
fi
|
||||||
|
if command_exists curl \
|
||||||
|
&& curl -fsS --max-time 2 "${url}/api/health" >/dev/null 2>&1; then
|
||||||
|
echo "healthy"
|
||||||
|
else
|
||||||
|
echo "installed_not_healthy"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
probe_anthropic_secret() {
|
||||||
|
command_exists onecli || { echo "false"; return; }
|
||||||
|
local out
|
||||||
|
out=$(with_timeout onecli secrets list 2>/dev/null) || { echo "false"; return; }
|
||||||
|
if echo "$out" | grep -Eq '"type"[[:space:]]*:[[:space:]]*"anthropic"'; then
|
||||||
|
echo "true"
|
||||||
|
else
|
||||||
|
echo "false"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
probe_service_status() {
|
||||||
|
local platform="$1"
|
||||||
|
case "$platform" in
|
||||||
|
macos)
|
||||||
|
command_exists launchctl || { echo "not_configured"; return; }
|
||||||
|
local line
|
||||||
|
line=$(with_timeout launchctl list 2>/dev/null | grep "com.nanoclaw") || {
|
||||||
|
echo "not_configured"; return; }
|
||||||
|
local pid
|
||||||
|
pid=$(echo "$line" | awk '{print $1}')
|
||||||
|
if [[ -n "$pid" && "$pid" != "-" ]]; then
|
||||||
|
echo "running"
|
||||||
|
else
|
||||||
|
echo "stopped"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
linux|wsl)
|
||||||
|
command_exists systemctl || { echo "not_configured"; return; }
|
||||||
|
if with_timeout systemctl --user is-active nanoclaw >/dev/null 2>&1; then
|
||||||
|
echo "running"
|
||||||
|
elif with_timeout systemctl --user cat nanoclaw >/dev/null 2>&1; then
|
||||||
|
echo "stopped"
|
||||||
|
else
|
||||||
|
echo "not_configured"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "not_configured"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
probe_display_name() {
|
||||||
|
local platform="$1"
|
||||||
|
local reject_re='^(|root)$'
|
||||||
|
local name
|
||||||
|
|
||||||
|
if command_exists git; then
|
||||||
|
name=$(trim "$(git config --global user.name 2>/dev/null)")
|
||||||
|
if [[ -n "$name" && ! "$name" =~ $reject_re ]]; then
|
||||||
|
printf '%s' "$name"; return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
local user="${USER:-$(id -un 2>/dev/null)}"
|
||||||
|
|
||||||
|
case "$platform" in
|
||||||
|
macos)
|
||||||
|
if command_exists id; then
|
||||||
|
name=$(trim "$(id -F "$user" 2>/dev/null)")
|
||||||
|
if [[ -n "$name" && ! "$name" =~ $reject_re ]]; then
|
||||||
|
printf '%s' "$name"; return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
linux|wsl)
|
||||||
|
if command_exists getent; then
|
||||||
|
local entry gecos
|
||||||
|
entry=$(getent passwd "$user" 2>/dev/null)
|
||||||
|
gecos=$(echo "$entry" | awk -F: '{print $5}')
|
||||||
|
name=$(trim "$(echo "$gecos" | awk -F, '{print $1}')")
|
||||||
|
if [[ -n "$name" && ! "$name" =~ $reject_re ]]; then
|
||||||
|
printf '%s' "$name"; return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [[ -n "$user" && ! "$user" =~ $reject_re ]]; then
|
||||||
|
printf '%s' "$user"
|
||||||
|
else
|
||||||
|
printf 'User'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
OS=$(probe_os)
|
||||||
|
SHELL_NAME="${SHELL:-unknown}"
|
||||||
|
HOST_DEPS=$(probe_host_deps)
|
||||||
|
probe_docker
|
||||||
|
ONECLI_URL_VAL=$(probe_onecli_url)
|
||||||
|
ONECLI_STATUS=$(probe_onecli_status "$ONECLI_URL_VAL")
|
||||||
|
if [[ "$ONECLI_STATUS" == "not_found" ]]; then
|
||||||
|
ANTHROPIC_SECRET="false"
|
||||||
|
else
|
||||||
|
ANTHROPIC_SECRET=$(probe_anthropic_secret)
|
||||||
fi
|
fi
|
||||||
|
SERVICE_STATUS=$(probe_service_status "$OS")
|
||||||
|
DISPLAY_NAME=$(probe_display_name "$OS")
|
||||||
|
|
||||||
cat <<'EOF'
|
END_S=$(date +%s)
|
||||||
|
ELAPSED_MS=$(( (END_S - START_S) * 1000 ))
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
=== NANOCLAW SETUP: PROBE ===
|
=== NANOCLAW SETUP: PROBE ===
|
||||||
STATUS: unavailable
|
OS: ${OS}
|
||||||
REASON: node_not_installed
|
SHELL: ${SHELL_NAME}
|
||||||
|
HOST_DEPS: ${HOST_DEPS}
|
||||||
|
DOCKER: ${DOCKER_STATUS}
|
||||||
|
IMAGE_PRESENT: ${IMAGE_PRESENT}
|
||||||
|
ONECLI_STATUS: ${ONECLI_STATUS}
|
||||||
|
ONECLI_URL: ${ONECLI_URL_VAL:-none}
|
||||||
|
ANTHROPIC_SECRET: ${ANTHROPIC_SECRET}
|
||||||
|
SERVICE_STATUS: ${SERVICE_STATUS}
|
||||||
|
INFERRED_DISPLAY_NAME: ${DISPLAY_NAME}
|
||||||
|
ELAPSED_MS: ${ELAPSED_MS}
|
||||||
|
STATUS: success
|
||||||
=== END ===
|
=== END ===
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
Reference in New Issue
Block a user