@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 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] launch the visual workbench (installs @zuzuucodes/web on demand)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zuzuucodes/cli",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -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
+ }
@@ -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
- // 2. find the workbench: bundled PATH → repair hint
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
- // 3. launch — the workbench opens the browser and prints its URL
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
  }