kenaz 0.3.11 → 0.3.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/.collaboration/core/task-2288-followups.test.js +2 -37
- package/core/.collaboration/core/task-2421-replica.test.js +229 -0
- package/core/.collaboration/core/task-api.js +221 -12
- package/core/.collaboration/core/task-replica.js +189 -0
- package/core/.formation/.merge-lock +1 -0
- package/core/.formation/bifrost/codex-doctor.js +12 -2
- package/core/.formation/bifrost/codex-doctor.test.js +15 -0
- package/core/.formation/bifrost/setup-codex.js +84 -16
- package/core/.formation/tools/post-complete/task2420.test.js +129 -0
- package/core/.formation/tools/post-complete.js +129 -8
- package/core/.kenaz/specialist/memory_guard_fired.json +1 -1
- package/core/.kenaz/specialist/memory_guard_trigger_cache.json +1 -1
- package/core/.specialist/tools/__tests__/rea-detect.test.js +10 -0
- package/core/.specialist/tools/rea-detect.js +6 -2
- package/core/.synapse/memory-shared.json.sync +1 -0
- package/core/KENAZ_CORE_VERSION +1 -1
- package/core/dev/plans/CLI_FIRST_INSTALL_FLOW.md +75 -109
- package/core/dev/specs/peer/CODEX_MANAGED_DAEMON.md +54 -0
- package/core/dev/specs/peer/setup-codex-hooks.test.js +38 -0
- package/core/dev/spikes/task2145-evidence-20261004/backend-log-excerpt.txt +56 -0
- package/core/dev/spikes/task2145-evidence-20261004/graph.shadow +0 -0
- package/core/dev/spikes/task2145-evidence-20261004/graph.wal +0 -0
- package/core/dev/spikes/task2145-evidence-20261004/graph_corrupt_suspect.marker +1 -0
- package/core/plugins/kenaz/scripts/kenaz.test.js +68 -0
- package/core/plugins/kenaz/scripts/ygg.js +43 -4
- package/lib/cli.js +17 -7
- package/lib/cli.test.js +10 -0
- package/lib/dashboard.test.js +3 -3
- package/package.json +6 -6
- package/vendor/install-kenaz-cli.js +167 -16
- package/core/.collaboration/core/task-2264-single-after-capture.test.js +0 -61
package/lib/cli.js
CHANGED
|
@@ -14,7 +14,7 @@ const dash = require('./dashboard');
|
|
|
14
14
|
const PKG_ROOT = path.resolve(__dirname, '..');
|
|
15
15
|
const PKG = require('../package.json');
|
|
16
16
|
const MIN_NODE = 18;
|
|
17
|
-
const USAGE = 'usage: kenaz <install|uninstall|status|doctor|init|ygg join <key>|ygg status|ygg leave|alaya status|alaya register|model [set <id>|unset] [--rigor r]|stats [--days N] [--json]|learn <add|list|rm>> [--dry-run] [--home <dir>] [--no-service] [--no-claude] [--no-codex] [--keep-dashboard] [--server-bin <path>]';
|
|
17
|
+
const USAGE = 'usage: kenaz <install|uninstall|status|doctor|init|ygg join <key>|ygg create [--name N]|ygg status|ygg leave|alaya status|alaya register|model [set <id>|unset] [--rigor r]|stats [--days N] [--json]|learn <add|list|rm>> [--dry-run] [--home <dir>] [--no-service] [--no-claude] [--no-codex] [--keep-dashboard] [--server-bin <path>]';
|
|
18
18
|
|
|
19
19
|
function parseArgs(argv) {
|
|
20
20
|
const o = { cmd: argv[0], flags: {} };
|
|
@@ -114,7 +114,7 @@ function retryBusy(fn, tries = 10) {
|
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
function install(p, flags, out, deps = {}) {
|
|
117
|
+
async function install(p, flags, out, deps = {}) {
|
|
118
118
|
const dry = !!flags['dry-run'];
|
|
119
119
|
const tag = dry ? '[dry-run] ' : '';
|
|
120
120
|
const log = (m) => out(tag + m);
|
|
@@ -167,7 +167,10 @@ function install(p, flags, out, deps = {}) {
|
|
|
167
167
|
if (flags['no-service']) a.push('--no-service');
|
|
168
168
|
if (dry) a.push('--dry-run');
|
|
169
169
|
const ver = hasClaude ? inst.claudeVersion() : null;
|
|
170
|
-
|
|
170
|
+
let plan = null;
|
|
171
|
+
if (inst.main(a, out, () => ver, { sh: deps.sh, platform: deps.platform, uid: deps.uid, onPlan: (x) => { plan = x; } }) !== 0) rc = 1;
|
|
172
|
+
// TASK_2418: the service was (re)started above; confirm the running server is the one just installed.
|
|
173
|
+
if (!dry && plan && srv && !flags['no-service']) await inst.verifyRuntime(plan, srv.version, out, deps.verify);
|
|
171
174
|
}
|
|
172
175
|
if (dry && hasClaude) out(`[dry-run] note: plugin paths above are the packaged core; after install they live under ${path.join(p.core, 'plugins')}`);
|
|
173
176
|
|
|
@@ -235,6 +238,10 @@ async function doctor(p, flags, out, deps = {}) {
|
|
|
235
238
|
let ver = ''; try { ver = JSON.parse(r.body).version; } catch (_) { /* health body is optional */ }
|
|
236
239
|
add('server reachable', r.ok ? 'ok' : 'FAIL', r.detail + (ver ? `, version ${ver}` : ''));
|
|
237
240
|
|
|
241
|
+
try { // TASK_2413: which mechanism restarts the server after a reboot (read-only; `deps.sh` / `deps.platform` for tests)
|
|
242
|
+
const sm = installer().detectServiceMode(p.home, p.core, deps.sh, deps.platform);
|
|
243
|
+
add('service mode', sm.mode === 'manual' ? 'skip' : 'ok', sm.mode + ' (' + sm.detail + ')');
|
|
244
|
+
} catch (e) { add('service mode', 'skip', 'unknown: ' + e.message); }
|
|
238
245
|
const d = dash.detect(p.home, deps.dashboard);
|
|
239
246
|
if (!d.installed) add('dashboard', 'skip', 'no Kenaz Dashboard on this machine');
|
|
240
247
|
else {
|
|
@@ -284,13 +291,13 @@ async function doctor(p, flags, out, deps = {}) {
|
|
|
284
291
|
if (rea) {
|
|
285
292
|
const r = rea.detectRea({ home: p.home, projectPath: process.cwd() });
|
|
286
293
|
add('rea (optional)', r.installed ? 'ok' : 'skip', r.installed ? 'MCP registered in ' + [r.claude && 'claude', r.codex && 'codex'].filter(Boolean).join(', ')
|
|
287
|
-
: `not installed; \`${rea.SETUP_CMD}\` lets Ansuz analyse reference products` + (r.nodeOk ? '' : ` (needs Node >=22.19, found ${process.versions.node})`));
|
|
294
|
+
: `not installed; \`${r.setupCmd || rea.SETUP_CMD}\` lets Ansuz analyse reference products` + (r.nodeOk ? '' : ` (needs Node >=22.19, found ${process.versions.node})`));
|
|
288
295
|
}
|
|
289
296
|
for (const [l, s, d] of rows) out(`${s.padEnd(4)} ${l.padEnd(20)} ${d}`);
|
|
290
297
|
return rows.some((r) => r[1] === 'FAIL') ? 1 : 0;
|
|
291
298
|
}
|
|
292
299
|
|
|
293
|
-
// `kenaz ygg join|status|leave ...` and `kenaz init`: short forms of the same core scripts the
|
|
300
|
+
// `kenaz ygg join|create|status|leave ...` and `kenaz init`: short forms of the same core scripts the
|
|
294
301
|
// /kenaz (Claude) and $kenaz (Codex) commands run, from a normal terminal in the project folder.
|
|
295
302
|
function passthrough(argv, deps) {
|
|
296
303
|
const p = paths(os.homedir());
|
|
@@ -353,8 +360,11 @@ function run(argv, out = console.log, deps = {}) {
|
|
|
353
360
|
const fn = { install, uninstall, status, doctor }[cmd];
|
|
354
361
|
if (!fn) { out(USAGE); return 2; }
|
|
355
362
|
if (!nodeOk()) { out(`Node.js >= ${MIN_NODE} is required (found ${process.versions.node}). Install it from https://nodejs.org and rerun.`); return 1; }
|
|
356
|
-
|
|
357
|
-
|
|
363
|
+
const fail = (e) => { out('kenaz ' + cmd + ': ERROR ' + e.message); return 1; };
|
|
364
|
+
try {
|
|
365
|
+
const r = fn(paths(path.resolve(flags.home || os.homedir())), flags, out, deps);
|
|
366
|
+
return r && typeof r.then === 'function' ? r.catch(fail) : r;
|
|
367
|
+
} catch (e) { return fail(e); }
|
|
358
368
|
}
|
|
359
369
|
|
|
360
370
|
module.exports = { run, parseArgs, platformServer, paths, stopOwnServer, retryBusy };
|
package/lib/cli.test.js
CHANGED
|
@@ -107,3 +107,13 @@ test('retryBusy retries a locked copy and then succeeds; other errors are thrown
|
|
|
107
107
|
assert.strictEqual(n, 3);
|
|
108
108
|
assert.throws(() => retryBusy(() => { const e = new Error('nope'); e.code = 'ENOENT'; throw e; }, 5), /nope/);
|
|
109
109
|
});
|
|
110
|
+
|
|
111
|
+
// TASK_2413: doctor names the active service mode. Stubbed `sh` + forced platform: no real system command runs.
|
|
112
|
+
test('doctor reports the service mode (cron on a Linux box without systemd, manual when nothing is installed)', async () => {
|
|
113
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kenaz-doc-'));
|
|
114
|
+
const dashboard = { candidateDirs: () => [], procs: () => [], portPids: () => [] };
|
|
115
|
+
const go = async (sh) => { const l = []; await run(['doctor', '--home', home, '--no-claude', '--no-codex'], (x) => l.push(x), { dashboard, sh, platform: 'linux' }); return l.find((x) => /service mode/.test(x)); };
|
|
116
|
+
const cron = await go((c) => (c === 'crontab' ? { status: 0, stdout: '@reboot x # kenaz-server (managed by kenaz install)\n' } : { status: 1, stdout: '' }));
|
|
117
|
+
assert.match(cron, /^ok\s+service mode\s+cron/);
|
|
118
|
+
assert.match(await go(() => ({ status: 1, stdout: '' })), /^skip\s+service mode\s+manual/);
|
|
119
|
+
});
|
package/lib/dashboard.test.js
CHANGED
|
@@ -12,9 +12,9 @@ const tmpHome = () => fs.mkdtempSync(path.join(os.tmpdir(), 'kenaz-dash-'));
|
|
|
12
12
|
const fake = (o = {}) => ({ candidateDirs: () => o.dirs || [], procs: () => o.procs || [], portPids: () => o.owners || [] });
|
|
13
13
|
const DASH_PROC = { pid: 4242, name: 'kenaz-dashboard.exe', exe: 'C:/Users/u/AppData/Local/BLOCK ACTION/kenaz-dashboard.exe' };
|
|
14
14
|
|
|
15
|
-
test('install with a running Dashboard owning the port: Chinese steps, rc 1, nothing written', () => {
|
|
15
|
+
test('install with a running Dashboard owning the port: Chinese steps, rc 1, nothing written', async () => {
|
|
16
16
|
const home = tmpHome(), lines = [];
|
|
17
|
-
const rc = run(['install', '--home', home, '--no-claude', '--no-codex'], (m) => lines.push(m), { dashboard: fake({ procs: [DASH_PROC], owners: [4242] }) });
|
|
17
|
+
const rc = await run(['install', '--home', home, '--no-claude', '--no-codex'], (m) => lines.push(m), { dashboard: fake({ procs: [DASH_PROC], owners: [4242] }) });
|
|
18
18
|
assert.strictEqual(rc, 1);
|
|
19
19
|
assert.ok(lines.join('\n').includes('npx kenaz install') && lines.join('\n').includes('開機時啟動'));
|
|
20
20
|
assert.ok(!fs.existsSync(path.join(home, '.kenaz')), 'no files, no service');
|
|
@@ -43,7 +43,7 @@ test('a foreign process on the port is not treated as the Dashboard', () => {
|
|
|
43
43
|
|
|
44
44
|
test('doctor flags a running Dashboard, its port and Dashboard plugin dirs', async () => {
|
|
45
45
|
const home = tmpHome(), lines = [];
|
|
46
|
-
const rc = await run(['doctor', '--home', home, '--no-claude', '--no-codex'], (m) => lines.push(m), { dashboard: fake({ dirs: [os.tmpdir()], procs: [DASH_PROC], owners: [4242] }) });
|
|
46
|
+
const rc = await run(['doctor', '--home', home, '--no-claude', '--no-codex'], (m) => lines.push(m), { dashboard: fake({ dirs: [os.tmpdir()], procs: [DASH_PROC], owners: [4242] }), sh: () => ({ status: 1, stdout: '' }) });
|
|
47
47
|
assert.strictEqual(rc, 1);
|
|
48
48
|
assert.ok(lines.some((l) => /^FAIL\s+dashboard running/.test(l)) && lines.some((l) => /^FAIL\s+port \d+ owner/.test(l)));
|
|
49
49
|
const d = dash.detect(home, fake({ dirs: [os.tmpdir()] }));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kenaz",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.12",
|
|
4
4
|
"description": "Install Kenaz (plugin, hooks, kenaz-server) for Claude Code and Codex: npx kenaz install",
|
|
5
5
|
"bin": {
|
|
6
6
|
"kenaz": "bin/kenaz.js"
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
"test": "node --test lib/cli.test.js"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@kenazai/server-darwin-arm64": "0.3.
|
|
24
|
-
"@kenazai/server-darwin-x64": "0.3.
|
|
25
|
-
"@kenazai/server-linux-arm64": "0.3.
|
|
26
|
-
"@kenazai/server-linux-x64": "0.3.
|
|
27
|
-
"@kenazai/server-win32-x64": "0.3.
|
|
23
|
+
"@kenazai/server-darwin-arm64": "0.3.12",
|
|
24
|
+
"@kenazai/server-darwin-x64": "0.3.12",
|
|
25
|
+
"@kenazai/server-linux-arm64": "0.3.12",
|
|
26
|
+
"@kenazai/server-linux-x64": "0.3.12",
|
|
27
|
+
"@kenazai/server-win32-x64": "0.3.12"
|
|
28
28
|
},
|
|
29
29
|
"license": "UNLICENSED",
|
|
30
30
|
"repository": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
const fs = require('fs');
|
|
14
14
|
const os = require('os');
|
|
15
15
|
const path = require('path');
|
|
16
|
+
const http = require('http');
|
|
16
17
|
const { spawnSync } = require('child_process');
|
|
17
18
|
|
|
18
19
|
const ENV_KEY = 'CLAUDE_CODE_PLUGIN_DIRS';
|
|
@@ -144,7 +145,30 @@ function systemdUnit(bin, core, nodeExe) {
|
|
|
144
145
|
return '[Unit]\nDescription=Kenaz server (headless backend)\nAfter=network.target\n\n[Service]\nExecStart=' + bin + '\nEnvironment=KENAZ_SOURCE_DIR=' + core + '\nEnvironment=PATH=' + servicePath(nodeExe) + '\nRestart=on-failure\n\n[Install]\nWantedBy=default.target\n';
|
|
145
146
|
}
|
|
146
147
|
|
|
147
|
-
|
|
148
|
+
// TASK_2413: no usable systemd user session (containers, WSL1, minimal servers) -> `systemctl --user enable --now`
|
|
149
|
+
// silently does nothing. Fall back to an @reboot crontab entry ('cron') or, without crontab, a manual start ('manual').
|
|
150
|
+
const CRON_MARK = '# kenaz-server (managed by kenaz install)';
|
|
151
|
+
const shq = (v) => "'" + String(v).replace(/'/g, "'\\''") + "'";
|
|
152
|
+
function cronStartCmd(bin, core, log, nodeExe) {
|
|
153
|
+
return 'KENAZ_SOURCE_DIR=' + shq(core) + ' PATH=' + shq(servicePath(nodeExe)) + ' ' + shq(bin) + ' >> ' + shq(log) + ' 2>&1';
|
|
154
|
+
}
|
|
155
|
+
function cronLine(bin, core, log, nodeExe) { // % is special in crontab
|
|
156
|
+
return '@reboot ' + cronStartCmd(bin, core, log, nodeExe).replace(/%/g, '\\%') + ' ' + CRON_MARK;
|
|
157
|
+
}
|
|
158
|
+
// stop exactly this binary (never by process name: another install's kenaz-server must survive). An upgraded
|
|
159
|
+
// process still runs the unlinked inode, which /proc reports as "<path> (deleted)".
|
|
160
|
+
function stopCmd(plan) {
|
|
161
|
+
const b = shq(plan.bin);
|
|
162
|
+
return 'for p in $(pgrep -x kenaz-server); do e=$(readlink /proc/$p/exe); ' +
|
|
163
|
+
'[ "$e" = ' + b + ' ] || [ "$e" = ' + shq(plan.bin + ' (deleted)') + ' ] && kill $p; done; true';
|
|
164
|
+
}
|
|
165
|
+
// start now, detached from the installer (new session when setsid exists), output to the log
|
|
166
|
+
function detachedStartCmd(plan) {
|
|
167
|
+
return 'if command -v setsid >/dev/null 2>&1; then S=setsid; else S=; fi; ' +
|
|
168
|
+
'KENAZ_SOURCE_DIR=' + shq(plan.core) + ' PATH=' + shq(servicePath()) + ' $S nohup ' + shq(plan.bin) + ' >> ' + shq(plan.log) + ' 2>&1 < /dev/null &';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function servicePlan(home, bin, core, platform = process.platform, caps = { systemd: true }) {
|
|
148
172
|
const L = launcherPaths(home);
|
|
149
173
|
if (platform === 'win32') {
|
|
150
174
|
const roaming = home === os.homedir() && process.env.APPDATA ? process.env.APPDATA : path.join(home, 'AppData', 'Roaming');
|
|
@@ -155,12 +179,101 @@ function servicePlan(home, bin, core, platform = process.platform) {
|
|
|
155
179
|
const p = path.join(home, 'Library', 'LaunchAgents', LABEL + '.plist');
|
|
156
180
|
return { kind: 'launchd', files: [[p, launchdPlist(bin, core, L.log)]], unit: p };
|
|
157
181
|
}
|
|
182
|
+
if (caps.systemd === false) {
|
|
183
|
+
return { kind: caps.crontab ? 'cron' : 'manual', files: [], bin, core, log: L.log, cronLine: cronLine(bin, core, L.log) };
|
|
184
|
+
}
|
|
158
185
|
const p = path.join(home, '.config', 'systemd', 'user', 'kenaz-server.service');
|
|
159
186
|
return { kind: 'systemd', files: [[p, systemdUnit(bin, core)]], unit: p };
|
|
160
187
|
}
|
|
161
|
-
const sh = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8' });
|
|
188
|
+
const sh = (cmd, args, opts) => spawnSync(cmd, args, { encoding: 'utf8', ...(opts || {}) });
|
|
189
|
+
|
|
190
|
+
// crontab -l: null = no crontab binary; '' = binary present, no crontab yet. All through the injected `run`.
|
|
191
|
+
function readCron(run) {
|
|
192
|
+
const r = run('crontab', ['-l']);
|
|
193
|
+
if (!r || r.error || r.status === 127) return null;
|
|
194
|
+
return r.status === 0 ? String(r.stdout || '') : '';
|
|
195
|
+
}
|
|
196
|
+
function probeLinux(run) {
|
|
197
|
+
const r = run('systemctl', ['--user', 'show-environment']);
|
|
198
|
+
const systemd = !!r && !r.error && r.status === 0;
|
|
199
|
+
return { systemd, crontab: systemd ? undefined : readCron(run) !== null };
|
|
200
|
+
}
|
|
201
|
+
function writeCron(run, current, line) { // line null = remove our entry; keeps every other line; idempotent
|
|
202
|
+
const keep = current.split('\n').filter((l) => l.trim() && !l.includes(CRON_MARK));
|
|
203
|
+
const next = line ? keep.concat(line) : keep;
|
|
204
|
+
const text = next.length ? next.join('\n') + '\n' : '';
|
|
205
|
+
if (text === current) return false;
|
|
206
|
+
if (text) run('crontab', ['-'], { input: text }); else run('crontab', ['-r']);
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
162
209
|
|
|
163
|
-
|
|
210
|
+
// TASK_2418: loading/enabling a unit that is already loaded/active is a no-op, so an upgrade must restart it
|
|
211
|
+
// explicitly, after the new binary is in place. launchd KeepAlive respawns the OLD file in the window between
|
|
212
|
+
// stopOwnServer() and the copy (the process then keeps the unlinked inode); systemd `enable --now` does not
|
|
213
|
+
// restart an active unit. restartHint is also the command printed when the version check fails.
|
|
214
|
+
const defaultUid = () => (typeof process.getuid === 'function' ? process.getuid() : 501);
|
|
215
|
+
function restartHint(plan, uid = defaultUid()) {
|
|
216
|
+
if (plan.kind === 'launchd') return 'launchctl kickstart -k gui/' + uid + '/' + LABEL;
|
|
217
|
+
if (plan.kind === 'systemd') return 'systemctl --user restart kenaz-server';
|
|
218
|
+
if (plan.kind === 'cron' || plan.kind === 'manual') return stopCmd(plan) + '; ' + detachedStartCmd(plan);
|
|
219
|
+
// the launcher works for both the scheduled task and the Startup-folder fallback (no admin rights needed)
|
|
220
|
+
return 'end kenaz-server.exe in Task Manager, then run: wscript.exe ' + q(plan.files && plan.files[0] ? plan.files[0][0] : 'kenaz-server.vbs');
|
|
221
|
+
}
|
|
222
|
+
function serviceRestart(plan, dry, log, run = sh, uid = defaultUid()) {
|
|
223
|
+
if (plan.kind === 'cron' || plan.kind === 'manual') { // no supervisor: start it (stopOwnServer freed the old one)
|
|
224
|
+
log('start now (detached, log ' + plan.log + ')');
|
|
225
|
+
if (!dry) { fs.mkdirSync(path.dirname(plan.log), { recursive: true }); run('sh', ['-c', detachedStartCmd(plan)]); }
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (plan.kind === 'windows') return; // /Create + /Run above start the just-copied exe (stopOwnServer freed it)
|
|
229
|
+
log(restartHint(plan, uid));
|
|
230
|
+
if (dry) return;
|
|
231
|
+
if (plan.kind === 'launchd') {
|
|
232
|
+
const r = run('launchctl', ['kickstart', '-k', 'gui/' + uid + '/' + LABEL]);
|
|
233
|
+
if (r && r.status !== 0) { // old macOS / no GUI session domain: unload + load restarts it the long way
|
|
234
|
+
log('kickstart failed (exit ' + r.status + '), falling back to launchctl unload + load');
|
|
235
|
+
run('launchctl', ['unload', plan.unit]); run('launchctl', ['load', '-w', plan.unit]);
|
|
236
|
+
}
|
|
237
|
+
} else run('systemctl', ['--user', 'restart', 'kenaz-server']);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** One GET /api/health; resolves { ok, version }. */
|
|
241
|
+
function healthOnce(port, getFn = http.get) {
|
|
242
|
+
return new Promise((resolve) => {
|
|
243
|
+
const req = getFn({ host: '127.0.0.1', port, path: '/api/health', timeout: 2000 }, (res) => {
|
|
244
|
+
let b = ''; res.on('data', (d) => { b += d; });
|
|
245
|
+
res.on('end', () => { let v = null; try { v = JSON.parse(b).version || null; } catch (_) { /* body optional */ } resolve({ ok: res.statusCode < 500, version: v }); });
|
|
246
|
+
});
|
|
247
|
+
req.on('timeout', () => { req.destroy(); resolve({ ok: false, version: null }); });
|
|
248
|
+
req.on('error', () => resolve({ ok: false, version: null }));
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* TASK_2418: after the service (re)start, ask the running server for its version and compare with the one just
|
|
253
|
+
* installed. Retries (the server needs a moment to bind). Returns 'ok' | 'mismatch' | 'unreachable'; the last two
|
|
254
|
+
* print a warning with the exact restart command and do not fail the install.
|
|
255
|
+
*/
|
|
256
|
+
async function verifyRuntime(plan, expected, log, opts = {}) {
|
|
257
|
+
const port = opts.port || process.env.KENAZ_HTTP_PORT || '18080';
|
|
258
|
+
const tries = opts.tries || 6, delay = opts.delayMs === undefined ? 1000 : opts.delayMs;
|
|
259
|
+
const probe = opts.health || ((p) => healthOnce(p));
|
|
260
|
+
let last = { ok: false, version: null };
|
|
261
|
+
for (let i = 0; i < tries; i++) {
|
|
262
|
+
last = await probe(port);
|
|
263
|
+
if (last.ok && last.version === expected) { log('server: running version ' + expected + ' on :' + port); return 'ok'; }
|
|
264
|
+
if (i < tries - 1 && delay) await new Promise((r) => setTimeout(r, delay));
|
|
265
|
+
}
|
|
266
|
+
const hint = restartHint(plan, opts.uid);
|
|
267
|
+
if (last.ok && last.version) {
|
|
268
|
+
log('WARNING: the server on :' + port + ' still reports version ' + last.version + ' but ' + expected + ' was just installed. Restart it: ' + hint);
|
|
269
|
+
return 'mismatch';
|
|
270
|
+
}
|
|
271
|
+
log('WARNING: could not confirm the new server (' + expected + ') on 127.0.0.1:' + port + (last.ok ? ' (no version in /api/health)' : ' (no answer)') + '. If it is not running: ' + hint);
|
|
272
|
+
return 'unreachable';
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function serviceInstall(plan, dry, log, run = sh, uid) {
|
|
276
|
+
const sh = run; // injectable for tests; shadows the module-level spawnSync wrapper inside this function
|
|
164
277
|
for (const [f, body] of plan.files) { log('write ' + f); if (!dry) { fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, body); } }
|
|
165
278
|
if (plan.kind === 'windows') {
|
|
166
279
|
const tr = 'wscript.exe ' + q(plan.files[0][0]);
|
|
@@ -178,29 +291,63 @@ function serviceInstall(plan, dry, log) {
|
|
|
178
291
|
}
|
|
179
292
|
} else if (plan.kind === 'launchd') {
|
|
180
293
|
log('launchctl load -w ' + plan.unit); if (!dry) sh('launchctl', ['load', '-w', plan.unit]);
|
|
294
|
+
serviceRestart(plan, dry, log, sh, uid);
|
|
295
|
+
} else if (plan.kind === 'cron' || plan.kind === 'manual') {
|
|
296
|
+
if (plan.kind === 'cron') {
|
|
297
|
+
log('crontab: @reboot entry ' + plan.cronLine);
|
|
298
|
+
if (!dry) { const cur = readCron(sh); if (cur !== null) writeCron(sh, cur, plan.cronLine); }
|
|
299
|
+
} else {
|
|
300
|
+
log('WARNING: no systemd user session and no crontab here. Add this to your own init/boot script to start the server at boot: ' + cronStartCmd(plan.bin, plan.core, plan.log));
|
|
301
|
+
}
|
|
302
|
+
serviceRestart(plan, dry, log, sh, uid);
|
|
181
303
|
} else {
|
|
182
304
|
log('systemctl --user enable --now kenaz-server');
|
|
183
305
|
if (!dry) { sh('systemctl', ['--user', 'daemon-reload']); sh('systemctl', ['--user', 'enable', '--now', 'kenaz-server']); }
|
|
306
|
+
serviceRestart(plan, dry, log, sh, uid);
|
|
184
307
|
}
|
|
185
308
|
}
|
|
186
|
-
function serviceUninstall(plan, dry, log) {
|
|
309
|
+
function serviceUninstall(plan, dry, log, run = sh) {
|
|
187
310
|
if (plan.kind === 'windows') {
|
|
188
311
|
log('schtasks /End + /Delete /TN ' + TASK_NAME);
|
|
189
|
-
if (!dry) {
|
|
312
|
+
if (!dry) { run('schtasks', ['/End', '/TN', TASK_NAME]); run('schtasks', ['/Delete', '/TN', TASK_NAME, '/F']); }
|
|
190
313
|
if (fs.existsSync(plan.fallbackFile)) { log('remove ' + plan.fallbackFile); if (!dry) fs.rmSync(plan.fallbackFile, { force: true }); }
|
|
191
314
|
} else if (plan.kind === 'launchd') {
|
|
192
|
-
log('launchctl unload ' + plan.unit); if (!dry)
|
|
315
|
+
log('launchctl unload ' + plan.unit); if (!dry) run('launchctl', ['unload', '-w', plan.unit]);
|
|
316
|
+
} else if (plan.kind === 'cron' || plan.kind === 'manual') {
|
|
317
|
+
if (plan.kind === 'cron') { log('crontab: remove the kenaz-server @reboot entry'); if (!dry) { const cur = readCron(run); if (cur !== null) writeCron(run, cur, null); } }
|
|
318
|
+
log('stop ' + plan.bin); if (!dry) run('sh', ['-c', stopCmd(plan)]);
|
|
193
319
|
} else {
|
|
194
|
-
log('systemctl --user disable --now kenaz-server'); if (!dry)
|
|
320
|
+
log('systemctl --user disable --now kenaz-server'); if (!dry) run('systemctl', ['--user', 'disable', '--now', 'kenaz-server']);
|
|
195
321
|
}
|
|
196
322
|
for (const [f] of plan.files) if (fs.existsSync(f)) { log('remove ' + f); if (!dry) fs.rmSync(f, { force: true }); }
|
|
197
323
|
}
|
|
198
|
-
function serviceStatus(plan) {
|
|
199
|
-
if (plan.kind === '
|
|
200
|
-
if (plan.kind === '
|
|
324
|
+
function serviceStatus(plan, run = sh) {
|
|
325
|
+
if (plan.kind === 'cron') { const c = readCron(run); return c && c.includes(CRON_MARK) ? 'cron @reboot entry present' : 'not installed'; }
|
|
326
|
+
if (plan.kind === 'manual') return 'manual (no systemd user session, no crontab)';
|
|
327
|
+
if (plan.kind === 'windows') return run('schtasks', ['/Query', '/TN', TASK_NAME]).status === 0 ? 'scheduled task present' : (fs.existsSync(plan.fallbackFile) ? 'startup launcher present' : 'not installed');
|
|
328
|
+
if (plan.kind === 'systemd') return run('systemctl', ['--user', 'is-active', 'kenaz-server']).stdout.trim() || 'not installed';
|
|
201
329
|
return fs.existsSync(plan.unit) ? 'plist present' : 'not installed';
|
|
202
330
|
}
|
|
203
331
|
|
|
332
|
+
/** Which mechanism keeps the server running on this machine (for `kenaz doctor`). Read-only, all via `run`. */
|
|
333
|
+
function detectServiceMode(home, core, run = sh, platform = process.platform) {
|
|
334
|
+
if (platform === 'win32') {
|
|
335
|
+
const plan = servicePlan(home, '', core, 'win32');
|
|
336
|
+
if (run('schtasks', ['/Query', '/TN', TASK_NAME]).status === 0) return { mode: 'windows task', detail: 'scheduled task ' + TASK_NAME };
|
|
337
|
+
if (fs.existsSync(plan.fallbackFile)) return { mode: 'startup launcher', detail: plan.fallbackFile };
|
|
338
|
+
return { mode: 'manual', detail: 'no scheduled task or Startup launcher; run `kenaz install`' };
|
|
339
|
+
}
|
|
340
|
+
if (platform === 'darwin') {
|
|
341
|
+
const p = servicePlan(home, '', core, 'darwin').unit;
|
|
342
|
+
return fs.existsSync(p) ? { mode: 'launchd', detail: p } : { mode: 'manual', detail: 'no launchd agent; run `kenaz install`' };
|
|
343
|
+
}
|
|
344
|
+
const unit = servicePlan(home, '', core, 'linux').unit;
|
|
345
|
+
if (fs.existsSync(unit) && probeLinux(run).systemd) return { mode: 'systemd', detail: unit };
|
|
346
|
+
const c = readCron(run);
|
|
347
|
+
if (c && c.includes(CRON_MARK)) return { mode: 'cron', detail: '@reboot crontab entry (no systemd user session)' };
|
|
348
|
+
return { mode: 'manual', detail: 'no systemd unit or cron entry; run `kenaz install`' };
|
|
349
|
+
}
|
|
350
|
+
|
|
204
351
|
function parseArgs(argv) {
|
|
205
352
|
const o = { cmd: argv[0], flags: {} };
|
|
206
353
|
for (let i = 1; i < argv.length; i++) {
|
|
@@ -217,7 +364,7 @@ function findServerBin(core, home) {
|
|
|
217
364
|
return c.map((p) => path.resolve(p)).find((p) => fs.existsSync(p)) || null;
|
|
218
365
|
}
|
|
219
366
|
|
|
220
|
-
function main(argv, out = console.log, getVersion = claudeVersion) { // getVersion: injectable for tests
|
|
367
|
+
function main(argv, out = console.log, getVersion = claudeVersion, deps = {}) { // getVersion, deps {sh, platform, uid, onPlan}: injectable for tests
|
|
221
368
|
const { cmd, flags } = parseArgs(argv);
|
|
222
369
|
if (!['install', 'uninstall', 'status'].includes(cmd)) {
|
|
223
370
|
out('usage: install-kenaz-cli.js <install|uninstall|status> [--dry-run] [--home d] [--core-dir d] [--server-bin p] [--no-service] [--no-plugin]');
|
|
@@ -264,11 +411,15 @@ function main(argv, out = console.log, getVersion = claudeVersion) { // getVersi
|
|
|
264
411
|
|
|
265
412
|
if (!flags['no-service']) {
|
|
266
413
|
const bin = flags['server-bin'] ? path.resolve(flags['server-bin']) : findServerBin(core, home);
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
414
|
+
const run = deps.sh || sh, platform = deps.platform || process.platform;
|
|
415
|
+
// the probe is read-only but still a system call: skipped on --dry-run (assumes systemd, as before)
|
|
416
|
+
const caps = platform === 'linux' && !dry ? probeLinux(run) : undefined;
|
|
417
|
+
const plan = servicePlan(home, bin || '<kenaz-server not found>', core, platform, caps);
|
|
418
|
+
if (deps.onPlan) deps.onPlan(plan);
|
|
419
|
+
if (cmd === 'status') out('service: ' + (dry ? 'skipped (dry-run)' : serviceStatus(plan, run)));
|
|
420
|
+
else if (cmd === 'uninstall') serviceUninstall(plan, dry, log, run);
|
|
270
421
|
else if (!bin) { out('service: skipped, kenaz-server binary not found (pass --server-bin)'); rc = 1; }
|
|
271
|
-
else serviceInstall(plan, dry, log);
|
|
422
|
+
else serviceInstall(plan, dry, log, run, deps.uid);
|
|
272
423
|
}
|
|
273
424
|
if (cmd === 'install' && rc === 0) out('Next: cd <your project>; claude; /kenaz:kenaz init; /kenaz:Ansuz (Codex: run /hooks once to trust the kenaz hooks, then $kenaz status)');
|
|
274
425
|
if (cmd === 'uninstall') out('Project .kenaz/ data was left untouched.');
|
|
@@ -276,4 +427,4 @@ function main(argv, out = console.log, getVersion = claudeVersion) { // getVersi
|
|
|
276
427
|
}
|
|
277
428
|
|
|
278
429
|
if (require.main === module) process.exit(main(process.argv.slice(2)));
|
|
279
|
-
module.exports = { main, claudeVersion, loadSettings, entriesOf, norm, MIN_CLI, registerPlugin, unregisterPlugin, retireDashboardHooks, servicePlan, winVbs, launchdPlist, systemdUnit, parseVersion, cmpVersion };
|
|
430
|
+
module.exports = { main, verifyRuntime, healthOnce, serviceInstall, serviceRestart, restartHint, claudeVersion, loadSettings, entriesOf, norm, MIN_CLI, registerPlugin, unregisterPlugin, retireDashboardHooks, servicePlan, detectServiceMode, probeLinux, cronLine, CRON_MARK, winVbs, launchdPlist, systemdUnit, parseVersion, cmpVersion };
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
// TASK_2264 — completeTask must schedule exactly ONE 'after' health capture.
|
|
2
|
-
// moveTask used to schedule a detached 'after' capture on every ->done move even when
|
|
3
|
-
// completeTask's own postComplete was about to measure the same snapshot.
|
|
4
|
-
// Counting seam: child_process.spawn is patched (task-api requires it at call time) and
|
|
5
|
-
// calls whose inline script carries phase "after" are counted. Isolated temp project.
|
|
6
|
-
// Usage: node .collaboration/core/task-2264-single-after-capture.test.js
|
|
7
|
-
const fs = require('fs');
|
|
8
|
-
const path = require('path');
|
|
9
|
-
const os = require('os');
|
|
10
|
-
const cp = require('child_process');
|
|
11
|
-
const { TaskAPI, toYAML } = require('./task-api.js');
|
|
12
|
-
|
|
13
|
-
const SB = fs.mkdtempSync(path.join(os.tmpdir(), 't2264_'));
|
|
14
|
-
const tasksDir = path.join(SB, '.kenaz', 'tasks');
|
|
15
|
-
for (const d of ['backlog', 'active', 'done', 'blocked', 'cancel', 'archived']) fs.mkdirSync(path.join(tasksDir, d), { recursive: true });
|
|
16
|
-
delete process.env.FORMATION_SCHEDULER; delete process.env.FORMATION_HEADLESS;
|
|
17
|
-
|
|
18
|
-
let pass = 0, fail = 0;
|
|
19
|
-
const check = (n, c, d) => { console.log((c ? 'PASS' : 'FAIL') + ' | ' + n + (c ? '' : ' | ' + d)); c ? pass++ : fail++; };
|
|
20
|
-
|
|
21
|
-
let phases = [];
|
|
22
|
-
const realSpawn = cp.spawn;
|
|
23
|
-
cp.spawn = function (cmd, argv, opts) {
|
|
24
|
-
const script = Array.isArray(argv) ? argv.find(a => typeof a === 'string' && a.includes('const CFG')) : null;
|
|
25
|
-
if (script) {
|
|
26
|
-
const m = script.match(/"phase":"(before|after)"/);
|
|
27
|
-
phases.push(m ? m[1] : '?');
|
|
28
|
-
return { unref() {}, on() {} };
|
|
29
|
-
}
|
|
30
|
-
return realSpawn.apply(this, arguments);
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const mkActive = (id) => fs.writeFileSync(path.join(tasksDir, 'active', id + '_t.yaml'),
|
|
34
|
-
toYAML({ id, title: 't2264 ' + id, priority: 'P2', status: 'in_progress' }));
|
|
35
|
-
|
|
36
|
-
(async () => {
|
|
37
|
-
const api = new TaskAPI({ tasksDir, projectPath: SB });
|
|
38
|
-
try {
|
|
39
|
-
// A: complete with post-complete running in-process (dry run, no git/archive) -> post-complete owns 'after'
|
|
40
|
-
mkActive('TASK_9001'); phases = [];
|
|
41
|
-
await api.completeTask('TASK_9001', { dryRun: true, skipGit: true, skipArchive: true });
|
|
42
|
-
check('A complete w/ post-complete: moveTask schedules NO after capture', phases.filter(p => p === 'after').length === 0, JSON.stringify(phases));
|
|
43
|
-
|
|
44
|
-
// B control: post-complete skipped -> nobody else measures, moveTask must still capture exactly once
|
|
45
|
-
mkActive('TASK_9002'); phases = [];
|
|
46
|
-
await api.completeTask('TASK_9002', { skipPostComplete: true });
|
|
47
|
-
check('B control: complete w/ skipPostComplete schedules exactly 1 after capture', phases.filter(p => p === 'after').length === 1, JSON.stringify(phases));
|
|
48
|
-
|
|
49
|
-
// C control: bare move done (no post-complete) keeps its single capture
|
|
50
|
-
mkActive('TASK_9003'); phases = [];
|
|
51
|
-
await api.moveTask('TASK_9003', 'done');
|
|
52
|
-
check('C control: bare move done schedules exactly 1 after capture', phases.filter(p => p === 'after').length === 1, JSON.stringify(phases));
|
|
53
|
-
} catch (e) {
|
|
54
|
-
check('no exception', false, e && e.stack);
|
|
55
|
-
} finally {
|
|
56
|
-
cp.spawn = realSpawn;
|
|
57
|
-
try { fs.rmSync(SB, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
58
|
-
}
|
|
59
|
-
console.log(`\n${pass} passed, ${fail} failed`);
|
|
60
|
-
process.exit(fail ? 1 : 0);
|
|
61
|
-
})();
|