@zuzuucodes/cli 1.3.0 → 1.3.1
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/bin/zuzuu.mjs +4 -2
- package/package.json +1 -1
- package/web-app/dist/index.js +19 -0
- package/web-app/dist/instance-file.js +62 -0
- package/zuzuu/commands/web.mjs +113 -8
package/bin/zuzuu.mjs
CHANGED
|
@@ -61,7 +61,9 @@ function help() {
|
|
|
61
61
|
usage: zuzuu <command> [options]
|
|
62
62
|
|
|
63
63
|
code [dir] launch OpenCode as the bundled default host (faculty home + capture + gate + digest)
|
|
64
|
-
web [dir]
|
|
64
|
+
web [dir] [--stop|--status]
|
|
65
|
+
launch the visual workbench (reuses a running one;
|
|
66
|
+
--stop ends it, --status reports it)
|
|
65
67
|
init scaffold the faculty home (.zuzuu/) — git-style, idempotent
|
|
66
68
|
status detected hosts + recorded sessions
|
|
67
69
|
capture [--host NAME] capture a session → .zuzuu/.traces + .zuzuu/sessions.json
|
|
@@ -108,7 +110,7 @@ const args = parseArgs(rest);
|
|
|
108
110
|
|
|
109
111
|
switch (cmd) {
|
|
110
112
|
case 'code': process.exit(code(args)); break;
|
|
111
|
-
case 'web': web(args); break;
|
|
113
|
+
case 'web': await web(args); break;
|
|
112
114
|
case 'init': init(args); break;
|
|
113
115
|
case 'remember': remember(args); break;
|
|
114
116
|
case 'recall': await recall(args); break;
|
package/package.json
CHANGED
package/web-app/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import crypto from "node:crypto";
|
|
8
8
|
import { WebcodeServer } from "./server.js";
|
|
9
9
|
import { addRecent } from "./config.js";
|
|
10
|
+
import { writeInstanceFile, removeInstanceFile } from "./instance-file.js";
|
|
10
11
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
11
12
|
const DEFAULT_PORT = 7770;
|
|
12
13
|
function parseArgs(argv) {
|
|
@@ -147,14 +148,32 @@ async function main() {
|
|
|
147
148
|
console.log(`\n zuzuu-web v${pkg.version}`);
|
|
148
149
|
console.log(` workspace ${root}`);
|
|
149
150
|
console.log(` url ${url}\n`);
|
|
151
|
+
// Singleton contract: record this instance so `zuzuu web` can reuse it
|
|
152
|
+
// instead of spawning a duplicate (never in hosted mode; never fatal).
|
|
153
|
+
if (!hosted) {
|
|
154
|
+
writeInstanceFile({
|
|
155
|
+
root,
|
|
156
|
+
port: boundPort,
|
|
157
|
+
pid: process.pid,
|
|
158
|
+
token,
|
|
159
|
+
startedAt: new Date().toISOString(),
|
|
160
|
+
version: pkg.version,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
150
163
|
if (args.open)
|
|
151
164
|
openBrowser(url);
|
|
152
165
|
});
|
|
153
166
|
const shutdown = () => {
|
|
167
|
+
if (!hosted)
|
|
168
|
+
removeInstanceFile(root);
|
|
154
169
|
server.stop();
|
|
155
170
|
process.exit(0);
|
|
156
171
|
};
|
|
157
172
|
process.on("SIGINT", shutdown);
|
|
158
173
|
process.on("SIGTERM", shutdown);
|
|
174
|
+
process.on("exit", () => {
|
|
175
|
+
if (!hosted)
|
|
176
|
+
removeInstanceFile(root); // best-effort; idempotent after shutdown()
|
|
177
|
+
});
|
|
159
178
|
}
|
|
160
179
|
void main();
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Per-workspace daemon instance state — the singleton contract with `zuzuu web`.
|
|
2
|
+
//
|
|
3
|
+
// After a successful listen the daemon writes
|
|
4
|
+
// ~/.webcode/instances/<sha256(realpath-root).slice(0,16)>.json
|
|
5
|
+
// and removes it on clean shutdown. The zuzuu CLI computes the same path for a
|
|
6
|
+
// workspace to discover (and reuse / stop) an already-running daemon instead of
|
|
7
|
+
// spawning a fresh one — so the port + token stay stable across `zuzuu web` runs.
|
|
8
|
+
//
|
|
9
|
+
// Security note: the file contains the auth token. That's acceptable here —
|
|
10
|
+
// it's 0600 in the user's own home directory, and the same token already
|
|
11
|
+
// appears in the daemon's own stdout URL. Hosted mode never writes this file.
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
export function instancesDir() {
|
|
17
|
+
return path.join(os.homedir(), ".webcode", "instances");
|
|
18
|
+
}
|
|
19
|
+
/** Deterministic per-workspace file path. `root` must already be realpath'd. */
|
|
20
|
+
export function instancePath(root, dir = instancesDir()) {
|
|
21
|
+
const id = crypto.createHash("sha256").update(root).digest("hex").slice(0, 16);
|
|
22
|
+
return path.join(dir, `${id}.json`);
|
|
23
|
+
}
|
|
24
|
+
/** Write the instance file (0600). Never throws — a failed write only costs reuse. */
|
|
25
|
+
export function writeInstanceFile(info, dir = instancesDir()) {
|
|
26
|
+
const file = instancePath(info.root, dir);
|
|
27
|
+
try {
|
|
28
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
29
|
+
fs.writeFileSync(file, JSON.stringify(info, null, 2) + "\n", { mode: 0o600 });
|
|
30
|
+
fs.chmodSync(file, 0o600); // mode option only applies on create; enforce on overwrite too
|
|
31
|
+
return file;
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
console.warn(`zuzuu-web: could not write instance state (${String(err)}) — continuing without it`);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Best-effort read; null on missing/corrupt. */
|
|
39
|
+
export function readInstanceFile(root, dir = instancesDir()) {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(fs.readFileSync(instancePath(root, dir), "utf8"));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Best-effort removal on shutdown. Only removes the file if it still belongs
|
|
49
|
+
* to `pid` — if another daemon raced us and overwrote it, leave theirs alone.
|
|
50
|
+
*/
|
|
51
|
+
export function removeInstanceFile(root, pid = process.pid, dir = instancesDir()) {
|
|
52
|
+
const file = instancePath(root, dir);
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
55
|
+
if (typeof parsed.pid === "number" && parsed.pid !== pid)
|
|
56
|
+
return; // not ours anymore
|
|
57
|
+
fs.unlinkSync(file);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* already gone or unreadable — nothing to do */
|
|
61
|
+
}
|
|
62
|
+
}
|
package/zuzuu/commands/web.mjs
CHANGED
|
@@ -11,14 +11,27 @@
|
|
|
11
11
|
// the workbench, never the CLI. `--omit=optional` installs skip the deps; this
|
|
12
12
|
// command then explains how to repair.
|
|
13
13
|
//
|
|
14
|
+
// Singleton-per-workspace (2026-06-12): the daemon records itself in
|
|
15
|
+
// ~/.webcode/instances/<sha256(realpath-root).slice(0,16)>.json after listen
|
|
16
|
+
// and removes it on clean shutdown (daemon src/instance-file.ts — the path
|
|
17
|
+
// scheme here MUST stay in sync with it). Before spawning we check that file:
|
|
18
|
+
// a live instance (pid alive + HTTP listener answering) is REUSED — same port,
|
|
19
|
+
// same token, old browser tabs keep working; a stale file is deleted and we
|
|
20
|
+
// spawn fresh. `--stop` / `--status` manage the running instance.
|
|
21
|
+
// (The file carries the auth token: 0600, user's own machine, and the token
|
|
22
|
+
// already appears in the daemon's stdout — acceptable.)
|
|
23
|
+
//
|
|
14
24
|
// Resolution order:
|
|
15
25
|
// 1. the bundled web-app/dist next to this package (installed OR repo after build:web)
|
|
16
26
|
// 2. the nested dev project's built daemon (web/packages/daemon, repo checkout)
|
|
17
27
|
// 3. a standalone `zuzuu-web` on PATH (legacy/manual installs)
|
|
18
28
|
// 4. none → repair hint (reinstall, or `npm run build:web` in a checkout)
|
|
19
29
|
|
|
20
|
-
import { existsSync } from 'node:fs';
|
|
30
|
+
import { existsSync, readFileSync, realpathSync, unlinkSync } from 'node:fs';
|
|
21
31
|
import { resolve, dirname, join } from 'node:path';
|
|
32
|
+
import { homedir } from 'node:os';
|
|
33
|
+
import { createHash } from 'node:crypto';
|
|
34
|
+
import http from 'node:http';
|
|
22
35
|
import { spawn } from 'node:child_process';
|
|
23
36
|
import { spawnSync } from 'node:child_process';
|
|
24
37
|
import { fileURLToPath } from 'node:url';
|
|
@@ -45,25 +58,108 @@ const realLaunch = ({ cwd, entryScript }) => {
|
|
|
45
58
|
if (entryScript) spawn(process.execPath, [entryScript, cwd], { detached: true, stdio: 'ignore' }).unref();
|
|
46
59
|
else spawn('zuzuu-web', [cwd], { detached: true, stdio: 'ignore' }).unref();
|
|
47
60
|
};
|
|
61
|
+
// Same scheme as the daemon's instance-file.ts: sha256 of the realpath'd root.
|
|
62
|
+
const realInstancePathFor = (dir) => {
|
|
63
|
+
let real = dir;
|
|
64
|
+
try { real = realpathSync(dir); } catch { /* missing dir → daemon will refuse anyway */ }
|
|
65
|
+
const id = createHash('sha256').update(real).digest('hex').slice(0, 16);
|
|
66
|
+
return join(homedir(), '.webcode', 'instances', `${id}.json`);
|
|
67
|
+
};
|
|
68
|
+
const realReadInstance = (file) => {
|
|
69
|
+
try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
|
|
70
|
+
};
|
|
71
|
+
const realRemoveFile = (file) => { try { unlinkSync(file); } catch { /* gone already */ } };
|
|
72
|
+
const realPidAlive = (pid) => {
|
|
73
|
+
try { process.kill(pid, 0); return true; }
|
|
74
|
+
catch (err) { return err?.code === 'EPERM'; } // EPERM = alive, just not ours
|
|
75
|
+
};
|
|
76
|
+
const realKillPid = (pid, signal) => { try { process.kill(pid, signal); } catch { /* gone */ } };
|
|
77
|
+
// Connectivity probe only — ANY HTTP answer (even 401) means a listener is there.
|
|
78
|
+
const realProbe = (port) => new Promise((done) => {
|
|
79
|
+
const req = http.get({ host: '127.0.0.1', port, path: '/api/health', timeout: 1000 }, (res) => {
|
|
80
|
+
res.resume();
|
|
81
|
+
done(true);
|
|
82
|
+
});
|
|
83
|
+
req.on('timeout', () => { req.destroy(); done(false); });
|
|
84
|
+
req.on('error', () => done(false));
|
|
85
|
+
});
|
|
86
|
+
const realOpenBrowser = (url) => { // fail-soft: a missing opener never breaks the command
|
|
87
|
+
try {
|
|
88
|
+
const cmd = process.platform === 'darwin' ? 'open'
|
|
89
|
+
: process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
90
|
+
spawn(cmd, [url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
|
|
91
|
+
} catch { /* ignore */ }
|
|
92
|
+
};
|
|
93
|
+
const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
94
|
+
|
|
95
|
+
const urlOf = (inst) => `http://127.0.0.1:${inst.port}/?token=${inst.token}`;
|
|
48
96
|
|
|
49
97
|
/**
|
|
50
|
-
* `zuzuu web [dir]`
|
|
51
|
-
* Launch the visual workbench for the given directory (default: cwd)
|
|
98
|
+
* `zuzuu web [dir] [--stop|--status]`
|
|
99
|
+
* Launch the visual workbench for the given directory (default: cwd) —
|
|
100
|
+
* reusing an already-running daemon for that workspace when there is one.
|
|
52
101
|
* Bundled-first; never installs anything — the workbench ships in this package.
|
|
53
102
|
*/
|
|
54
|
-
export function web(args = {}, deps = {}) {
|
|
103
|
+
export async function web(args = {}, deps = {}) {
|
|
55
104
|
const d = {
|
|
56
105
|
resolveBundled: realResolveBundled,
|
|
57
106
|
detectPath: realDetectPath,
|
|
58
107
|
launch: realLaunch,
|
|
108
|
+
instancePathFor: realInstancePathFor,
|
|
109
|
+
readInstance: realReadInstance,
|
|
110
|
+
removeFile: realRemoveFile,
|
|
111
|
+
pidAlive: realPidAlive,
|
|
112
|
+
killPid: realKillPid,
|
|
113
|
+
probe: realProbe,
|
|
114
|
+
openBrowser: realOpenBrowser,
|
|
115
|
+
sleep: realSleep,
|
|
59
116
|
log: (...m) => console.log(...m),
|
|
60
117
|
...deps,
|
|
61
118
|
};
|
|
62
119
|
|
|
63
|
-
// 1. resolve the target directory
|
|
120
|
+
// 1. resolve the target directory + its instance state
|
|
64
121
|
const dir = args._?.[0] ? resolve(String(args._[0])) : process.cwd();
|
|
122
|
+
const instanceFile = d.instancePathFor(dir);
|
|
123
|
+
const inst = d.readInstance(instanceFile);
|
|
124
|
+
const isAlive = async (i) =>
|
|
125
|
+
!!(i && Number.isInteger(i.pid) && d.pidAlive(i.pid) && await d.probe(i.port));
|
|
126
|
+
|
|
127
|
+
// --stop: terminate the running daemon for this workspace
|
|
128
|
+
if (args.stop) {
|
|
129
|
+
if (!inst) { d.log(`no workbench running for ${dir}`); return; }
|
|
130
|
+
if (d.pidAlive(inst.pid)) {
|
|
131
|
+
d.killPid(inst.pid, 'SIGTERM');
|
|
132
|
+
for (let i = 0; i < 15 && d.pidAlive(inst.pid); i++) await d.sleep(200); // ~3s grace
|
|
133
|
+
if (d.pidAlive(inst.pid)) d.log(`workbench (pid ${inst.pid}) hasn't exited yet — still shutting down.`);
|
|
134
|
+
else d.log(`stopped workbench for ${dir} (pid ${inst.pid})`);
|
|
135
|
+
} else {
|
|
136
|
+
d.log(`workbench for ${dir} was not running — cleaned up stale state.`);
|
|
137
|
+
}
|
|
138
|
+
d.removeFile(instanceFile); // daemon removes it itself on SIGTERM; this is belt-and-braces
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
65
141
|
|
|
66
|
-
//
|
|
142
|
+
// --status: report without side effects
|
|
143
|
+
if (args.status) {
|
|
144
|
+
if (await isAlive(inst)) {
|
|
145
|
+
d.log(`workbench running for ${dir}`);
|
|
146
|
+
d.log(` pid ${inst.pid} → ${urlOf(inst)}`);
|
|
147
|
+
} else {
|
|
148
|
+
d.log(`no workbench running for ${dir}`);
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 2. reuse a live instance: same port + token, old tabs stay valid
|
|
154
|
+
if (await isAlive(inst)) {
|
|
155
|
+
d.log(`workbench already running for ${dir}`);
|
|
156
|
+
d.log(` → ${urlOf(inst)}`);
|
|
157
|
+
d.openBrowser(urlOf(inst));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (inst) d.removeFile(instanceFile); // stale (dead pid / no listener) → spawn fresh
|
|
161
|
+
|
|
162
|
+
// 3. find the workbench: bundled → PATH → repair hint
|
|
67
163
|
const entryScript = d.resolveBundled();
|
|
68
164
|
if (!entryScript && !d.detectPath()) {
|
|
69
165
|
d.log('the workbench is not available in this install.');
|
|
@@ -72,8 +168,17 @@ export function web(args = {}, deps = {}) {
|
|
|
72
168
|
return;
|
|
73
169
|
}
|
|
74
170
|
|
|
75
|
-
//
|
|
171
|
+
// 4. launch — the daemon opens the browser itself on fresh boot, so we only
|
|
172
|
+
// wait for its instance file to surface the URL here (don't double-open).
|
|
76
173
|
d.log(`zuzuu web → launching visual workbench in ${dir} …`);
|
|
77
|
-
d.log(' it will open your browser and print its URL.');
|
|
78
174
|
d.launch({ cwd: dir, entryScript });
|
|
175
|
+
for (let i = 0; i < 30; i++) { // ~6s
|
|
176
|
+
await d.sleep(200);
|
|
177
|
+
const fresh = d.readInstance(instanceFile);
|
|
178
|
+
if (fresh && d.pidAlive(fresh.pid)) {
|
|
179
|
+
d.log(` → ${urlOf(fresh)}`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
d.log(' it will open your browser and print its URL.');
|
|
79
184
|
}
|