joinweave 0.1.1 → 0.1.2

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/README.md CHANGED
@@ -3,70 +3,125 @@
3
3
  The web wasn't built for agents. Weave was.
4
4
 
5
5
  ```sh
6
- npx joinweave@0.1.0
6
+ npx joinweave@0.1.2
7
7
  ```
8
8
 
9
- Installs the prebuilt `weave` executable, creates `~/.weave` and `~/Weave`,
10
- initializes a local identity, runs a user-level background node, and checks live
11
- connectivity with `weave doctor`. No Rust toolchain or compilation is required.
12
- macOS and Linux arm64/x64 are supported; Windows is not supported in 0.1.0.
13
- Private keys stay in your local workspace.
9
+ One command installs the developer runtime: the prebuilt native CLI, a local
10
+ identity, a peer node, your workspace, and a persistent agent MCP server.
11
+ A perspective woven ASCII scene follows the real installation stages. There
12
+ are no simulated progress percentages, success messages or artificial delays.
13
+ No Rust toolchain is required.
14
14
 
15
- The installer asks before adding a shell PATH line or editing any agent client
16
- configuration. Existing unrelated entries are retained. Conflicting files,
17
- modified installer entries and symbolic links are refused. Config edits use
18
- atomic replacements and private backup files beside the config.
15
+ | Component | Installed location / behavior |
16
+ | --- | --- |
17
+ | Native CLI 0.1.0 | `~/.weave/bin/weave` |
18
+ | Local identity and data | `~/.weave`; private keys stay on this computer |
19
+ | Peer node | User-level launchd or systemd service |
20
+ | Visible workspace | `~/Weave` |
21
+ | MCP server 0.1.0 | `~/.weave/tools/mcp-0.1.0`; independent of the npx cache |
19
22
 
20
- For agents and unattended installs:
23
+ Requirements: Node.js 20 or later with npm; macOS arm64/x64 (binary minimum
24
+ load target macOS 11), or Linux arm64/x64 with glibc 2.39 or later. Linux arm64
25
+ also needs the system OpenSSL 3 libraries (`libssl.so.3`, `libcrypto.so.3`).
26
+ Linux managed services require a logged-in `systemd --user` session. Windows,
27
+ musl and older glibc systems are unsupported. Native optional dependencies must
28
+ be enabled when fetching joinweave's CLI dependency.
29
+
30
+ The installer checks live connectivity with native `weave doctor`. A failed
31
+ check remains an install failure. Your node and recoverable installation receipt
32
+ remain available for a rerun. Doctor is a local runtime/connectivity check;
33
+ independent discovery and sparse replication still require another live peer.
34
+
35
+ The installer does not register an OpenAgentID or Life ID account and does not
36
+ install a desktop application. Its component inventory describes only the
37
+ runtime components actually installed by this release. The public distribution
38
+ repository contains JavaScript tooling and prebuilt executables; Rust source is
39
+ maintained separately in a private repository.
40
+
41
+ ## Consent and unattended use
42
+
43
+ The MCP server is installed by default, but connecting an agent client requires
44
+ separate consent. The installer asks before adding a shell PATH line or editing
45
+ client settings. Existing unrelated entries are retained; conflicting files,
46
+ modified installer entries and unsafe symbolic links are refused. Configuration
47
+ edits use atomic replacements and private backup files beside the config.
48
+
49
+ ```sh
50
+ npx joinweave@0.1.2 --yes --json
51
+ ```
52
+
53
+ `--json` produces exactly one JSON result on stdout, with no animation, prompts
54
+ or progress noise. Errors return `ok: false` and a nonzero exit status.
55
+ `--yes` skips all PATH and client configuration questions. To explicitly consent:
21
56
 
22
57
  ```sh
23
- npx joinweave@0.1.0 --yes --json
58
+ npx joinweave@0.1.2 --yes --path --mcp-client cursor,codex --json
24
59
  ```
25
60
 
26
- `--json` prints exactly one JSON result to stdout. Errors return `ok: false` and
27
- a nonzero exit status. A doctor failure is an install failure; a build or healthy
28
- process alone cannot substitute for peer discovery and an announce/find round
29
- trip. The node and recoverable installation receipt remain available for a rerun.
61
+ The installer supports `claude-desktop`, `claude-code`, `cursor`, and `codex`.
62
+ `one` and `bridge` provide a manual MCP definition; their settings are not
63
+ written because no verified writable schema is available. Restart configured
64
+ clients after installation. Start a new shell after adding PATH, or use the
65
+ absolute binary path printed in the result.
30
66
 
31
- Options:
67
+ Selected clients run the persistent MCP entry with the absolute Node executable
68
+ used during installation and explicit `WEAVE_HOME` / `WEAVE_BINARY` values.
69
+ They do not download MCP on startup. An existing owned npx-based configuration
70
+ is migrated only when that client is explicitly selected again. Otherwise a
71
+ headless rerun leaves client settings byte-for-byte unchanged. Keep that Node
72
+ installation available for clients using it. If Node/nvm changes its location,
73
+ a rerun with explicit `--mcp-client` consent migrates an intact owned entry to
74
+ the current Node; a headless rerun leaves it unchanged. Uninstall still recognizes
75
+ the exact owned old entry even if its Node executable is gone.
32
76
 
33
- - `--yes`: headless installation; skips PATH and MCP questions.
34
- - `--path`: explicitly consent to adding the managed shell PATH entry.
35
- - `--mcp-client cursor`: explicitly consent to configuring a named MCP client;
36
- repeat or use a comma-separated list. Clients are `claude-desktop`,
37
- `claude-code`, `cursor`, and `codex`. `one` and `bridge` print a manual setup
38
- definition; their configuration files are not edited because no verified
39
- writable MCP schema is available. These clients are detected when their
40
- `.one`/`.bridge` directories or macOS application bundles are present.
41
- - `--no-mcp`: skip agent configuration.
42
- - `--no-service`: run a detached node with `weave node start` instead of
43
- installing launchd or systemd. A live node is still required for doctor.
44
- - `--json`: machine-readable output, with no prompts.
77
+ The MCP installation uses npm with `--ignore-scripts`, a private exclusive
78
+ staging directory, the pinned published package integrity, and an owned
79
+ file/mode census. User npm release-age policy is preserved. If a policy or a
80
+ network failure prevents installation, fix that condition and rerun; no partial
81
+ MCP tree is reported as installed.
45
82
 
46
- For example, consent to Cursor and Codex configuration headlessly:
83
+ ## Options and next actions
84
+
85
+ - `--no-animation`: static progress; no cursor control or motion.
86
+ - `NO_COLOR`: disable terminal colors. `TERM=dumb` and redirected output use
87
+ static progress automatically.
88
+ - `--no-mcp`: skip MCP installation and client setup.
89
+ - `--no-service`: start a detached node instead of an OS-managed service.
90
+ - `--path`: consent to the managed shell PATH entry.
91
+ - `--mcp-client CLIENT`: consent to a named client; repeat or separate names
92
+ with commas.
93
+ - `--json`: machine-readable output without prompts.
94
+ - `--yes`: headless installation; no implicit client or PATH consent.
47
95
 
48
96
  ```sh
49
- npx joinweave@0.1.0 --yes --path --mcp-client cursor,codex --json
97
+ ~/.weave/bin/weave node status --json
98
+ ~/.weave/bin/weave strand create --json
99
+ ~/.weave/bin/weave node logs
50
100
  ```
51
101
 
52
- The MCP configuration starts `npx --yes @useweave/mcp@0.1.0` with the persistent
53
- binary and workspace paths. The installed binary remains at `~/.weave/bin/weave`
54
- after npm's execution cache is cleaned. Restart configured agent clients after
55
- installation. Start a fresh shell after adding PATH, or use the absolute binary
56
- path printed in the JSON result.
102
+ Use `weave node start`, `weave node stop`, `weave node status`, and
103
+ `weave node logs` to manage the installed node. `WEAVE_HOME` can select a
104
+ dedicated absolute workspace. See the [quickstart](https://docs.weave.earth/getting-started/quickstart)
105
+ and [MCP reference](https://docs.weave.earth/tooling/mcp) for the supported runtime
106
+ commands and ten MCP tools. Other library models are not added to the MCP tool
107
+ surface by this installer.
57
108
 
58
- Manage the node with `weave node start`, `weave node stop`, `weave node status`,
59
- and `weave node logs`. Linux service installation requires an available
60
- `systemd --user` session. On a box without one, use `--no-service`.
61
- `WEAVE_HOME` can select a dedicated absolute workspace directory.
109
+ ## Recovery and removal
62
110
 
63
111
  ```sh
64
- npx joinweave@0.1.0 uninstall --yes
65
- npx joinweave@0.1.0 uninstall --yes --purge
112
+ npx joinweave@0.1.2 uninstall --yes
113
+ npx joinweave@0.1.2 uninstall --yes --purge
66
114
  ```
67
115
 
68
- Uninstall stops the node and removes only owned service, executable, PATH and
69
- MCP entries. It retains workspace data by default. `--purge` also deletes the
70
- dedicated workspace, including local identity keys; `~/Weave` is always
71
- preserved because it may contain unrelated user files. Modified installer
72
- entries are retained and reported instead of being overwritten or deleted.
116
+ Uninstall stops the node and removes only owned service, executable, PATH,
117
+ client entries and the unchanged managed MCP tree. It retains identity and
118
+ workspace data by default. `--purge` also deletes the dedicated workspace,
119
+ including private identity keys. `~/Weave` is always preserved because it may
120
+ contain unrelated files. Modified entries are preserved and reported; they
121
+ block purge. An interruption preserves recoverable installed components; rerun
122
+ the installer to continue.
123
+
124
+ Cancellation waits for the installer child process to close, with bounded
125
+ SIGTERM/SIGKILL escalation. If termination cannot be confirmed, its persistent
126
+ lock and MCP stage are retained. A new installer refuses the lock while its
127
+ recorded parent or child is alive; do not manually remove it under a live process.
package/bin/joinweave.js CHANGED
@@ -1,38 +1,65 @@
1
1
  #!/usr/bin/env node
2
2
  import { createInterface } from 'node:readline/promises';
3
- import { Installer, VERSION, parseArgs } from '../src/installer.js';
3
+ import { Installer, InstallError, VERSION, parseArgs } from '../src/installer.js';
4
+ import { TerminalPresenter, installationSummary } from '../src/terminal.js';
4
5
 
5
6
  const jsonRequested = process.argv.slice(2).includes('--json');
6
- let prompt;
7
+ const controller = new AbortController();
8
+ let prompt, presenter, interrupted;
9
+ const interrupt = (signal) => {
10
+ interrupted ||= signal;
11
+ presenter?.finish();
12
+ controller.abort();
13
+ };
14
+ const onInt = () => interrupt('SIGINT'), onTerm = () => interrupt('SIGTERM');
7
15
  try {
8
16
  const options = parseArgs(process.argv.slice(2));
9
17
  if (options.help) {
10
- const usage = 'joinweave [uninstall] [--yes] [--json] [--no-service] [--no-mcp] [--path] [--mcp-client CLIENT] [--purge]\n\n--yes installs headlessly; PATH and MCP edits require --path and --mcp-client.\nClients: claude-desktop, claude-code, cursor, codex; one and bridge provide manual setup.\n--no-service starts a detached node. Uninstall keeps your data; --purge removes ~/.weave data.\n';
18
+ const usage = 'joinweave [uninstall] [--yes] [--json] [--no-animation] [--no-service] [--no-mcp] [--path] [--mcp-client CLIENT] [--purge]\n\nInstalls native CLI, local identity, peer node, workspace and a persistent MCP server.\n--yes installs headlessly; PATH and MCP client edits require --path and --mcp-client.\nClients: claude-desktop, claude-code, cursor, codex; one and bridge provide manual setup.\n--no-mcp skips the MCP server and client setup. --no-animation disables terminal motion.\n--no-service starts a detached node. Uninstall keeps your data; --purge removes ~/.weave data.\n';
11
19
  console.log(options.json ? JSON.stringify({ ok: true, version: VERSION, usage }) : usage);
12
20
  } else if (options.version) console.log(options.json ? JSON.stringify({ ok: true, version: VERSION }) : VERSION);
13
21
  else {
14
- if (!options.yes && !options.json && !process.stdin.isTTY && options.action === 'install') throw Object.assign(new Error('For unattended installation use --yes. MCP and PATH edits still need explicit flags.'), { code: 'INTERACTION_REQUIRED' });
22
+ if (!options.yes && !options.json && !process.stdin.isTTY && options.action === 'install') throw new InstallError('INTERACTION_REQUIRED', 'For unattended installation use --yes. MCP client and PATH edits still need explicit flags.');
23
+ presenter = new TerminalPresenter({ json: options.json, noAnimation: options.noAnimation });
15
24
  if (!options.yes && !options.json && process.stdin.isTTY) prompt = createInterface({ input: process.stdin, output: process.stderr });
25
+ process.once('SIGINT', onInt); process.once('SIGTERM', onTerm);
26
+ prompt?.once('SIGINT', onInt);
16
27
  const installer = new Installer({
17
28
  resolveNative: options.action === 'install' ? (await import('@useweave/cli')).resolveBinary : undefined,
18
- confirm: async (question) => prompt ? /^y(?:es)?$/i.test((await prompt.question(`${question} [y/N] `)).trim()) : false,
19
- progress: (message) => { if (!options.json) console.error(`✓ ${message}`); },
29
+ signal: controller.signal,
30
+ confirm: async (question) => {
31
+ if (!prompt) return false;
32
+ presenter.pause();
33
+ try { return /^y(?:es)?$/i.test((await prompt.question(`${question} [y/N] `, { signal: controller.signal })).trim()); }
34
+ finally { if (!controller.signal.aborted) presenter.resume(); }
35
+ },
36
+ stage: (stage) => {
37
+ if (controller.signal.aborted) throw new InstallError('INSTALL_INTERRUPTED', 'Installation interrupted. Your identity and recoverable installed components were preserved; rerun to continue.');
38
+ presenter.stage(stage);
39
+ },
40
+ progress: (message) => presenter.progress(message),
20
41
  });
21
42
  const result = await installer[options.action](options);
43
+ presenter.finish();
22
44
  if (options.json) console.log(JSON.stringify(result));
23
45
  else if (options.action === 'install') {
24
- console.log(`\nWeave is ready.\nWorkspace: ${result.shared}\nTry: ${result.pathAdded ? 'weave doctor' : `${result.binary} doctor`}\n`);
46
+ console.log(`\n${installationSummary(result)}\n`);
25
47
  for (const manual of result.manualMcpClients) console.log(`${manual.client}: add this MCP server in the client's settings:\n${JSON.stringify({ weave: manual.server }, null, 2)}\n`);
26
- }
27
- else {
48
+ } else {
28
49
  console.log(result.ok ? `Weave uninstalled. ${result.dataRetained ? 'Your data is preserved.' : 'Workspace data removed.'} Your ~/Weave folder is preserved.` : 'Uninstall preserved modified files.');
29
50
  for (const warning of result.warnings) console.error(warning);
30
51
  }
31
52
  if (!result.ok) process.exitCode = 1;
32
53
  }
33
54
  } catch (error) {
34
- const result = { ok: false, error: { code: error.code || 'INSTALL_FAILED', message: error.message } };
55
+ presenter?.finish();
56
+ const result = { ok: false, error: interrupted
57
+ ? { code: 'INSTALL_INTERRUPTED', message: 'Installation interrupted. Your identity and recoverable installed components were preserved; rerun to continue.' }
58
+ : { code: error.code || 'INSTALL_FAILED', message: error.message } };
35
59
  if (jsonRequested) console.log(JSON.stringify(result));
36
- else console.error(`joinweave: ${error.message}`);
37
- process.exitCode = 1;
38
- } finally { prompt?.close(); }
60
+ else console.error(`joinweave: ${result.error.message}`);
61
+ process.exitCode = interrupted === 'SIGINT' ? 130 : interrupted === 'SIGTERM' ? 143 : 1;
62
+ } finally {
63
+ presenter?.finish(); prompt?.close();
64
+ process.removeListener('SIGINT', onInt); process.removeListener('SIGTERM', onTerm);
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "joinweave",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "One command to join the Weave agent-native web.",
6
6
  "license": "MIT OR Apache-2.0",
package/src/installer.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import * as fs from 'node:fs';
2
2
  import { homedir, hostname } from 'node:os';
3
- import { resolve, join, dirname, isAbsolute, parse } from 'node:path';
3
+ import { resolve, join, dirname, basename, isAbsolute, parse } from 'node:path';
4
4
  import { createHash, randomUUID } from 'node:crypto';
5
5
  import { execFile } from 'node:child_process';
6
+ import { ManagedMcp } from './managed-mcp.js';
6
7
 
7
- export const VERSION = '0.1.1';
8
+ export const VERSION = '0.1.2';
8
9
  const NATIVE_PACKAGE_VERSION = '0.1.0';
9
10
  // Published native0.1.0 treats this as its service-ownership protocol version.
10
11
  const RECEIPT_VERSION = '0.1.0';
@@ -24,7 +25,7 @@ const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
24
25
  const regular = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
25
26
 
26
27
  export function parseArgs(args) {
27
- const options = { action: 'install', yes: false, json: false, noService: false, noMcp: false, path: false, purge: false, mcpClients: [] };
28
+ const options = { action: 'install', yes: false, json: false, noService: false, noMcp: false, noAnimation: false, path: false, purge: false, mcpClients: [] };
28
29
  for (let i = 0; i < args.length; i++) {
29
30
  const arg = args[i];
30
31
  if (arg === 'uninstall' && i === 0) options.action = 'uninstall';
@@ -32,6 +33,7 @@ export function parseArgs(args) {
32
33
  else if (arg === '--json') options.json = true;
33
34
  else if (arg === '--no-service') options.noService = true;
34
35
  else if (arg === '--no-mcp') options.noMcp = true;
36
+ else if (arg === '--no-animation') options.noAnimation = true;
35
37
  else if (arg === '--path') options.path = true;
36
38
  else if (arg === '--purge') options.purge = true;
37
39
  else if (arg === '--help' || arg === '-h') options.help = true;
@@ -114,10 +116,58 @@ function unlinkIfUnchanged(path, expectedHash) {
114
116
  return true;
115
117
  }
116
118
 
117
- export function runCommand(command, args, { env = process.env, timeout = 60_000 } = {}) {
118
- return new Promise((done) => execFile(command, args, { env, timeout, maxBuffer: 2 * 1024 * 1024, encoding: 'utf8', shell: false }, (error, stdout, stderr) => {
119
- done({ code: error ? (typeof error.code === 'number' ? error.code : 1) : 0, stdout, stderr });
120
- }));
119
+ /** Callback errors can precede close. Ownership is released only after close. */
120
+ export function runCommand(command, args, { env = process.env, timeout = 60_000, cwd, signal } = {}) {
121
+ return new Promise((done) => {
122
+ let child, stdout = '', stderr = '', error, closed = false, settled = false;
123
+ let deadlineTimer, killTimer, closeTimer, stopping = false;
124
+ const finish = (confirmed) => {
125
+ if (settled) return;
126
+ settled = true;
127
+ clearTimeout(deadlineTimer); clearTimeout(killTimer); clearTimeout(closeTimer);
128
+ signal?.removeEventListener('abort', cancel);
129
+ const result = { code: error || stopping ? (typeof error?.code === 'number' ? error.code : 1) : (child?.exitCode ?? 1), stdout, stderr, terminationConfirmed: confirmed };
130
+ if (!confirmed) {
131
+ result.childPid = child?.pid;
132
+ // Keep the persistent lock/stage, but allow the reporting installer to
133
+ // exit. A future installer checks the recorded child's liveness too.
134
+ child?.unref(); child?.stdout?.destroy(); child?.stderr?.destroy();
135
+ }
136
+ done(result);
137
+ };
138
+ const stop = (reason) => {
139
+ if (settled || stopping) return;
140
+ stopping = true; error ||= Object.assign(new Error(reason), { code: reason });
141
+ if (closed) { finish(true); return; }
142
+ child?.kill('SIGTERM');
143
+ killTimer = setTimeout(() => { if (!closed) child?.kill('SIGKILL'); }, 500);
144
+ closeTimer = setTimeout(() => finish(closed), 3_000);
145
+ };
146
+ const cancel = () => stop('ABORT_ERR');
147
+ if (signal?.aborted) { done({ code: 1, stdout, stderr, terminationConfirmed: true }); return; }
148
+ try {
149
+ // Lifecycle/timeout are owned here rather than execFile's early abort
150
+ // callback. No shell, and no lifecycle script descendants for MCP npm.
151
+ child = execFile(command, args, { env, cwd, maxBuffer: 2 * 1024 * 1024, encoding: 'utf8', shell: false }, (failure, out, err) => {
152
+ error ||= failure; stdout = out || ''; stderr = err || '';
153
+ if (failure && !closed && child?.exitCode === null && child?.signalCode === null) stop('COMMAND_ERROR');
154
+ });
155
+ child.once('close', () => { closed = true; finish(true); });
156
+ child.once('error', (failure) => { error ||= failure; stop('SPAWN_ERROR'); });
157
+ signal?.addEventListener('abort', cancel, { once: true });
158
+ if (signal?.aborted) cancel();
159
+ else deadlineTimer = setTimeout(() => stop('ETIMEDOUT'), Math.max(1, timeout));
160
+ } catch (failure) { error = failure; finish(true); }
161
+ });
162
+ }
163
+
164
+ function abortableSleep(ms, signal) {
165
+ return new Promise((done, reject) => {
166
+ if (signal?.aborted) { reject(new InstallError('INSTALL_INTERRUPTED', 'Installation interrupted.')); return; }
167
+ const abort = () => { clearTimeout(timer); reject(new InstallError('INSTALL_INTERRUPTED', 'Installation interrupted.')); };
168
+ const timer = setTimeout(() => { signal?.removeEventListener('abort', abort); done(); }, ms);
169
+ signal?.addEventListener('abort', abort, { once: true });
170
+ });
121
171
  }
122
172
 
123
173
  const xml = (value) => value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
@@ -127,7 +177,7 @@ const systemdPath = (value) => value.replaceAll('%', '%%');
127
177
  const shellQuote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
128
178
 
129
179
  export class Installer {
130
- constructor({ home = homedir(), weaveHome = process.env.WEAVE_HOME || join(home, '.weave'), platform = process.platform, uid = process.getuid?.(), env = process.env, run = runCommand, resolveNative, confirm = async () => false, progress = () => {}, sleep = (ms) => new Promise((done) => setTimeout(done, ms)), now = Date.now } = {}) {
180
+ constructor({ home = homedir(), weaveHome = process.env.WEAVE_HOME || join(home, '.weave'), platform = process.platform, uid = process.getuid?.(), env = process.env, run = runCommand, resolveNative, confirm = async () => false, progress = () => {}, stage = () => {}, signal, nodePath = process.execPath, npmCli, sleep = abortableSleep, now = Date.now } = {}) {
131
181
  this.home = resolve(home);
132
182
  this.weaveHome = resolve(weaveHome);
133
183
  if ([this.home, this.weaveHome].some((path) => /[\x00-\x1f\x7f]/.test(path))) fail('UNSAFE_PATH', 'Workspace and home paths cannot contain control characters.');
@@ -137,10 +187,29 @@ export class Installer {
137
187
  this.platform = platform;
138
188
  this.uid = uid;
139
189
  this.env = { ...env, WEAVE_HOME: this.weaveHome };
140
- this.run = run;
190
+ this.run = async (...args) => {
191
+ const result = await run(...args);
192
+ if (result.terminationConfirmed === false) {
193
+ this.preserveLock = true;
194
+ if (Number.isInteger(result.childPid) && result.childPid > 0 && this.lockBytes) {
195
+ const owner = JSON.parse(this.lockBytes);
196
+ owner.children = [...new Set([...(owner.children || []), result.childPid])];
197
+ const next = Buffer.from(JSON.stringify(owner));
198
+ atomicWrite(this.lockPath, next, { previous: this.lockBytes });
199
+ this.lockBytes = next;
200
+ }
201
+ fail('CHILD_TERMINATION_UNCONFIRMED', 'Could not confirm installer subprocess termination. Its persistent lock, MCP staging tree and installed data were preserved. Do not remove the lock while its recorded process is alive.');
202
+ }
203
+ return result;
204
+ };
141
205
  this.resolveNative = resolveNative;
142
206
  this.confirm = confirm;
143
207
  this.progress = progress;
208
+ this.stage = stage;
209
+ this.signal = signal;
210
+ this.nodePath = nodePath;
211
+ this.npmCli = npmCli;
212
+ this.mcpRuntime = new ManagedMcp(this);
144
213
  this.sleep = sleep;
145
214
  this.now = now;
146
215
  this.binary = join(this.weaveHome, 'bin', 'weave');
@@ -177,8 +246,10 @@ export class Installer {
177
246
  let owner;
178
247
  try { owner = JSON.parse(old); } catch { fail('INSTALL_LOCKED', 'An unrecognized installer lock exists. It will not be removed automatically.'); }
179
248
  if (owner.host !== hostname() || !Number.isInteger(owner.pid) || owner.pid < 1) fail('INSTALL_LOCKED', 'Another installer owns this workspace.');
180
- let alive = true;
181
- try { process.kill(owner.pid, 0); } catch (error) { if (error.code === 'ESRCH') alive = false; }
249
+ if (owner.children !== undefined && (!Array.isArray(owner.children) || owner.children.some((pid) => !Number.isInteger(pid) || pid < 1))) fail('INSTALL_LOCKED', 'Installer lock has an unrecognized subprocess owner.');
250
+ const alive = [owner.pid, ...(owner.children || [])].some((pid) => {
251
+ try { process.kill(pid, 0); return true; } catch (error) { return error.code !== 'ESRCH'; }
252
+ });
182
253
  if (alive) fail('INSTALL_LOCKED', 'Another installer is running in this workspace.');
183
254
  if (!unlinkIfUnchanged(this.lockPath, digest(old))) fail('INSTALL_LOCKED', 'Installer lock changed; rerun after the other installer finishes.');
184
255
  }
@@ -187,11 +258,12 @@ export class Installer {
187
258
  }
188
259
 
189
260
  unlock() {
190
- if (this.lockBytes) unlinkIfUnchanged(this.lockPath, digest(this.lockBytes));
261
+ if (this.lockBytes && !this.preserveLock) unlinkIfUnchanged(this.lockPath, digest(this.lockBytes));
191
262
  }
192
263
 
193
264
  async native(args, label, timeout = 60_000) {
194
- const result = await this.run(this.binary, args, { env: this.env, timeout });
265
+ if (this.signal?.aborted) fail('INSTALL_INTERRUPTED', 'Installation interrupted.');
266
+ const result = await this.run(this.binary, args, { env: this.env, timeout, signal: this.signal });
195
267
  if (result.code !== 0) fail('NATIVE_COMMAND_FAILED', `${label} failed (exit ${result.code}). Run ${this.binary} ${args.join(' ')} to inspect the runtime result.`);
196
268
  let data;
197
269
  try { data = JSON.parse(result.stdout); } catch { fail('INVALID_NATIVE_RESULT', `${label} did not return valid JSON.`); }
@@ -204,8 +276,9 @@ export class Installer {
204
276
  for (let attempt = 0; ; attempt++) {
205
277
  try { return await this.native(['doctor', '--json'], 'Weave doctor', 90_000); }
206
278
  catch (error) {
279
+ if (this.signal?.aborted) fail('INSTALL_INTERRUPTED', 'Installation interrupted.');
207
280
  if (!['NATIVE_COMMAND_FAILED', 'NATIVE_CHECK_FAILED'].includes(error.code) || attempt >= 9 || Date.now() >= deadline) throw error;
208
- await this.sleep(1_000);
281
+ await this.sleep(1_000, this.signal);
209
282
  }
210
283
  }
211
284
  }
@@ -243,7 +316,7 @@ export class Installer {
243
316
 
244
317
  async serviceCommand(args, required = true, timeout = 30_000) {
245
318
  const command = this.platform === 'darwin' ? 'launchctl' : 'systemctl';
246
- const result = await this.run(command, args, { env: this.env, timeout });
319
+ const result = await this.run(command, args, { env: this.env, timeout, signal: this.signal });
247
320
  if (required && result.code !== 0) fail('SERVICE_COMMAND_FAILED', `${command} ${args.join(' ')} failed. Confirm a logged-in user service session is available; use --no-service for a detached node.`);
248
321
  return result;
249
322
  }
@@ -264,7 +337,7 @@ export class Installer {
264
337
  if (status.code !== 113 || !status.stderr.includes(missing)) fail('SERVICE_STOP_UNKNOWN', 'Could not verify launchd shutdown. Its binary and configuration were preserved.');
265
338
  break;
266
339
  }
267
- await this.sleep(Math.min(100, deadline - checkedAt));
340
+ await this.sleep(Math.min(100, deadline - checkedAt), this.signal);
268
341
  }
269
342
  } else {
270
343
  await this.serviceCommand(['--user', 'disable', '--now', 'weave-node.service'], false);
@@ -341,12 +414,36 @@ export class Installer {
341
414
  return detected;
342
415
  }
343
416
 
344
- mcpDefinition() {
417
+ legacyMcpDefinition() {
345
418
  return { command: 'npx', args: ['--yes', '@useweave/mcp@0.1.0'], env: { WEAVE_HOME: this.weaveHome, WEAVE_BINARY: this.binary } };
346
419
  }
347
420
 
348
- mcpTomlBlock() {
349
- return `${MCP_BEGIN}\n[mcp_servers.weave]\ncommand = "npx"\nargs = ["--yes", "@useweave/mcp@0.1.0"]\n\n[mcp_servers.weave.env]\nWEAVE_HOME = ${JSON.stringify(this.weaveHome)}\nWEAVE_BINARY = ${JSON.stringify(this.binary)}\n${MCP_END}\n`;
421
+ mcpDefinition() {
422
+ return { command: this.nodePath, args: [this.mcpRuntime.entry], env: { WEAVE_HOME: this.weaveHome, WEAVE_BINARY: this.binary } };
423
+ }
424
+
425
+ mcpTomlBlock(spec = this.mcpDefinition()) {
426
+ return `${MCP_BEGIN}\n[mcp_servers.weave]\ncommand = ${JSON.stringify(spec.command)}\nargs = [${spec.args.map((arg) => JSON.stringify(arg)).join(', ')}]\n\n[mcp_servers.weave.env]\nWEAVE_HOME = ${JSON.stringify(this.weaveHome)}\nWEAVE_BINARY = ${JSON.stringify(this.binary)}\n${MCP_END}\n`;
427
+ }
428
+
429
+ recognizedMcpReceipt(receipt) {
430
+ if (receipt.format === 'json') {
431
+ const spec = receipt.spec;
432
+ if (JSON.stringify(spec) === JSON.stringify(this.legacyMcpDefinition())) return true;
433
+ return regular(spec) && Object.keys(spec).sort().join(',') === 'args,command,env'
434
+ && typeof spec.command === 'string' && isAbsolute(spec.command) && ['node', 'nodejs'].includes(basename(spec.command)) && !/[\x00-\x1f\x7f]/.test(spec.command)
435
+ && JSON.stringify(spec.args) === JSON.stringify([this.mcpRuntime.entry])
436
+ && JSON.stringify(spec.env) === JSON.stringify({ WEAVE_HOME: this.weaveHome, WEAVE_BINARY: this.binary });
437
+ }
438
+ if (receipt.format !== 'toml' || typeof receipt.block !== 'string') return false;
439
+ if (receipt.block === this.mcpTomlBlock(this.legacyMcpDefinition())) return true;
440
+ // Parse only the JSON-encoded command line in our exact generated block.
441
+ // Everything else must equal the regenerated fixed entry/environment.
442
+ const line = receipt.block.match(/^command = (.+)$/m);
443
+ if (!line) return false;
444
+ let command; try { command = JSON.parse(line[1]); } catch { return false; }
445
+ return typeof command === 'string' && isAbsolute(command) && ['node', 'nodejs'].includes(basename(command)) && !/[\x00-\x1f\x7f]/.test(command)
446
+ && receipt.block === this.mcpTomlBlock({ ...this.mcpDefinition(), command });
350
447
  }
351
448
 
352
449
  installMcp(client) {
@@ -363,7 +460,7 @@ export class Installer {
363
460
  if (!regular(config) || (config.mcpServers !== undefined && !regular(config.mcpServers))) fail('INVALID_CLIENT_CONFIG', `${client} config has an unsupported MCP structure. It will not be changed.`);
364
461
  config.mcpServers ||= {};
365
462
  if (Object.hasOwn(config.mcpServers, 'weave')) {
366
- if (!previous || previous.path !== path || JSON.stringify(previous.spec) !== JSON.stringify(spec) || JSON.stringify(config.mcpServers.weave) !== JSON.stringify(previous.spec)) fail('MCP_ENTRY_CONFLICT', `${client} already has an unrelated or modified weave MCP entry. It will not be changed.`);
463
+ if (!previous || previous.path !== path || !this.recognizedMcpReceipt(previous) || JSON.stringify(config.mcpServers.weave) !== JSON.stringify(previous.spec)) fail('MCP_ENTRY_CONFLICT', `${client} already has an unrelated or modified weave MCP entry. It will not be changed.`);
367
464
  }
368
465
  config.mcpServers.weave = spec;
369
466
  bytes = Buffer.from(`${JSON.stringify(config, null, 2)}\n`);
@@ -372,8 +469,8 @@ export class Installer {
372
469
  const text = old?.toString('utf8') || '';
373
470
  const block = this.mcpTomlBlock();
374
471
  let next = text;
375
- if (previous?.path === path && previous.block !== block) fail('INVALID_MANIFEST', 'The Codex MCP receipt is not a recognized Weave server definition.');
376
- if (previous?.path === path && text.includes(block)) next = text.replace(block, '');
472
+ if (previous?.path === path && !this.recognizedMcpReceipt(previous)) fail('INVALID_MANIFEST', 'The Codex MCP receipt is not a recognized Weave server definition.');
473
+ if (previous?.path === path && text.includes(previous.block)) next = text.replace(previous.block, '');
377
474
  // Do not guess at inline/quoted TOML tables or overwrite foreign definitions.
378
475
  if (/^\s*\[\[?\s*(?:mcp_servers|["']mcp_servers["'])\s*\./m.test(next) && /^\s*\[\[?\s*(?:mcp_servers|["']mcp_servers["'])\s*\.\s*(?:weave|["']weave["'])\s*(?:\.|\])/m.test(next)) fail('MCP_ENTRY_CONFLICT', 'Codex already has a weave MCP table. It will not be changed.');
379
476
  if (next.includes(MCP_BEGIN) || next.includes(MCP_END) || /^\s*(?:mcp_servers|["']mcp_servers["'])\s*=/m.test(next) || /^\s*\[\s*(?:mcp_servers|["']mcp_servers["'])\s*\]\s*$/m.test(next)) fail('INVALID_CLIENT_CONFIG', 'Codex config uses a structure that cannot be merged safely. Add the Weave MCP entry manually.');
@@ -392,20 +489,30 @@ export class Installer {
392
489
  this.lock();
393
490
  try {
394
491
  this.loadManifest();
492
+ this.stage({ id: 'runtime', label: 'Installing the native runtime' });
395
493
  this.installBinary();
396
494
  ensureDirectory(this.shared, 0o755);
397
- await this.native(['init', '--json'], 'Workspace initialization');
495
+ this.stage({ id: 'identity', label: 'Opening your workspace and local identity' });
496
+ const initialized = await this.native(['init', '--json'], 'Workspace initialization');
398
497
  this.progress('Created your workspace and identity');
498
+ this.stage({ id: 'node', label: options.noService ? 'Starting your peer node' : 'Starting your supervised peer node' });
399
499
  if (options.noService) {
400
500
  // A rerun switching off service management must not leave two runtime owners.
401
501
  if (this.manifest.service) await this.removeService();
402
502
  if (this.manifest.service) fail('MODIFIED_SERVICE', 'The modified service was preserved. Stop and remove it before starting a detached node.');
403
503
  await this.native(['node', 'start', '--json'], 'Detached node startup');
504
+ this.progress('Started the detached node');
404
505
  } else {
405
506
  // Stop an earlier detached runtime before the service takes ownership.
406
507
  await this.native(['node', 'stop', '--json'], 'Node shutdown before service startup');
407
508
  await this.installService();
408
509
  }
510
+ this.stage({ id: 'mcp', label: options.noMcp ? 'Keeping MCP installation disabled' : 'Installing the persistent MCP server' });
511
+ if (!options.noMcp) {
512
+ await this.mcpRuntime.install();
513
+ this.progress('Installed the MCP server');
514
+ } else this.progress('MCP installation skipped');
515
+ this.stage({ id: 'clients', label: 'Choosing your agent connections' });
409
516
  const detected = this.detectClients();
410
517
  if (options.path || (!options.yes && !options.json && await this.confirm('Add Weave to your shell PATH?'))) this.installPath();
411
518
  const selected = [...options.mcpClients];
@@ -414,9 +521,16 @@ export class Installer {
414
521
  }
415
522
  for (const client of selected) this.installMcp(client);
416
523
  // Doctor owns connectivity, peer and announce/find validation. Never turn a failed doctor into success.
524
+ this.stage({ id: 'verify', label: 'Verifying live peer connectivity' });
417
525
  const doctor = await this.readyDoctor();
418
526
  this.progress('Verified Weave connectivity');
419
- return { ok: true, action: 'install', version: VERSION, binary: this.binary, workspace: this.weaveHome, shared: this.shared, service: { managed: !options.noService, running: true }, pathAdded: Boolean(this.manifest.path), mcpClients: Object.keys(this.manifest.mcp), detectedMcpClients: detected, manualMcpClients: this.manualMcp, doctor, next: this.manifest.path ? 'weave doctor' : `${this.binary} doctor` };
527
+ return { ok: true, action: 'install', version: VERSION, binary: this.binary, workspace: this.weaveHome, shared: this.shared, service: { managed: !options.noService, running: true }, pathAdded: Boolean(this.manifest.path), mcpClients: Object.keys(this.manifest.mcp), detectedMcpClients: detected, manualMcpClients: this.manualMcp, doctor, components: [
528
+ { id: 'runtime', name: 'Native CLI', version: NATIVE_PACKAGE_VERSION, status: 'installed', path: this.binary },
529
+ { id: 'identity', name: 'Local identity', status: 'ready', localOnly: true },
530
+ { id: 'node', name: 'Peer node', status: 'running', managed: !options.noService },
531
+ { id: 'workspace', name: 'Workspace', status: 'ready', path: this.shared },
532
+ { id: 'mcp', name: 'Agent MCP server', version: NATIVE_PACKAGE_VERSION, status: options.noMcp ? 'skipped' : 'installed', ...(options.noMcp ? {} : { command: this.nodePath, entry: this.mcpRuntime.entry }) },
533
+ ], initialization: initialized, next: this.manifest.path ? 'weave doctor' : `${this.binary} doctor` };
420
534
  } finally { this.unlock(); }
421
535
  }
422
536
 
@@ -454,7 +568,7 @@ export class Installer {
454
568
  if (!clients.has(client)) fail('INVALID_MANIFEST', 'Unknown MCP client in the installation receipt.');
455
569
  const receipt = this.manifest.mcp[client], expected = this.clientDefinition(client);
456
570
  if (receipt.path !== expected.path || receipt.format !== expected.format) fail('INVALID_MANIFEST', 'MCP receipt does not match this user.');
457
- if (expected.format === 'json' && JSON.stringify(receipt.spec) !== JSON.stringify(this.mcpDefinition())) fail('INVALID_MANIFEST', 'MCP receipt is not a recognized Weave server definition.');
571
+ if (!this.recognizedMcpReceipt(receipt)) fail('INVALID_MANIFEST', 'MCP receipt is not a recognized Weave server definition.');
458
572
  const old = readFile(expected.path);
459
573
  if (old) {
460
574
  let bytes;
@@ -469,7 +583,7 @@ export class Installer {
469
583
  }
470
584
  } else {
471
585
  const text = old.toString('utf8');
472
- if (receipt.block !== this.mcpTomlBlock()) fail('INVALID_MANIFEST', 'MCP block receipt is not a recognized Weave server definition.');
586
+ if (!this.recognizedMcpReceipt(receipt)) fail('INVALID_MANIFEST', 'MCP block receipt is not a recognized Weave server definition.');
473
587
  if (!text.includes(receipt.block)) { this.warnings.push('Preserved modified Codex weave MCP entry.'); return; }
474
588
  bytes = Buffer.from(text.replace(receipt.block, ''));
475
589
  }
@@ -489,6 +603,7 @@ export class Installer {
489
603
  this.loadManifest();
490
604
  const known = readFile(this.manifestPath);
491
605
  if (!known) fail('NO_INSTALL_RECEIPT', 'No joinweave installation receipt exists. Existing files will not be removed.');
606
+ this.stage({ id: 'node', label: 'Stopping the owned background node' });
492
607
  await this.removeService();
493
608
  if (this.manifest.service) fail('MODIFIED_SERVICE', 'The modified service was preserved. Stop and remove it before uninstalling the binary.');
494
609
  const binary = readFile(this.binary);
@@ -500,6 +615,8 @@ export class Installer {
500
615
  } else if (binary) this.warnings.push('Preserved unrelated or modified executable.');
501
616
  this.removePath();
502
617
  for (const client of Object.keys(this.manifest.mcp)) this.removeMcp(client);
618
+ this.stage({ id: 'mcp', label: 'Removing owned tools and connections' });
619
+ this.mcpRuntime.remove();
503
620
  if (options.purge && this.warnings.length) fail('PURGE_BLOCKED', 'Modified installer entries were preserved. Resolve them before purging workspace data.');
504
621
  if (!this.warnings.length) {
505
622
  const receipt = readFile(this.manifestPath);
@@ -0,0 +1,143 @@
1
+ import * as fs from 'node:fs';
2
+ import { join, dirname, resolve, isAbsolute } from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { InstallError, assertSafePath } from './installer.js';
5
+
6
+ export const MCP_VERSION = '0.1.0';
7
+ export const MCP_INTEGRITY = 'sha512-l5i5RCgWYSZYtwXWF9AG0Bgpz6X9jcDQFYq4lzlioXkD3ZYYfs0Gxw44D+jLYAae/Dq22CGVKScA6MQRBP7RDg==';
8
+ const ENTRY = 'node_modules/@useweave/mcp/bin/weave-mcp.js';
9
+ const sha = (bytes) => createHash('sha256').update(bytes).digest('hex');
10
+ const fail = (code, message) => { throw new InstallError(code, message); };
11
+
12
+ /** No links, sockets or special files; bounded, private, immutable runtime tree. */
13
+ export function censusTree(root) {
14
+ assertSafePath(root);
15
+ if (!fs.lstatSync(root).isDirectory()) fail('UNSAFE_MCP_TREE', 'MCP runtime is not a directory.');
16
+ const files = []; let size = 0;
17
+ const visit = (relative) => {
18
+ const path = join(root, relative), stat = fs.lstatSync(path);
19
+ if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) fail('UNSAFE_MCP_TREE', `MCP runtime contains an unsupported file: ${relative}.`);
20
+ if (files.length >= 20_000) fail('MCP_TREE_LIMIT', 'MCP runtime exceeds the file limit.');
21
+ if (stat.isDirectory()) {
22
+ files.push({ path: relative, type: 'directory', mode: stat.mode & 0o777 });
23
+ for (const name of fs.readdirSync(path).sort()) visit(relative ? `${relative}/${name}` : name);
24
+ } else {
25
+ size += stat.size;
26
+ if (size > 128 * 1024 * 1024) fail('MCP_TREE_LIMIT', 'MCP runtime exceeds the size limit.');
27
+ files.push({ path: relative, type: 'file', mode: stat.mode & 0o777, size: stat.size, sha256: sha(fs.readFileSync(path)) });
28
+ }
29
+ };
30
+ visit('');
31
+ return files;
32
+ }
33
+
34
+ export function resolveNpmCli(env, nodePath) {
35
+ const named = env.npm_execpath;
36
+ const candidates = [named && join(dirname(named), 'npm-cli.js'), join(dirname(nodePath), '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')].filter(Boolean);
37
+ for (const candidate of candidates) {
38
+ if (!isAbsolute(candidate)) continue;
39
+ try {
40
+ const path = fs.realpathSync(candidate);
41
+ if (fs.statSync(path).isFile() && path.endsWith('/npm-cli.js')) return path;
42
+ } catch { /* Try the npm distribution next to Node. */ }
43
+ }
44
+ fail('NPM_UNAVAILABLE', 'Cannot locate npm beside Node. Run this installer through npx, or use --no-mcp.');
45
+ }
46
+
47
+ export function verifyMcpTree(root) {
48
+ const files = censusTree(root);
49
+ const pkg = JSON.parse(fs.readFileSync(join(root, 'node_modules/@useweave/mcp/package.json'), 'utf8'));
50
+ const lock = JSON.parse(fs.readFileSync(join(root, 'package-lock.json'), 'utf8'));
51
+ const top = lock.packages?.['node_modules/@useweave/mcp'];
52
+ if (pkg.name !== '@useweave/mcp' || pkg.version !== MCP_VERSION || pkg.bin?.['weave-mcp'] !== 'bin/weave-mcp.js' || top?.version !== MCP_VERSION || top?.integrity !== MCP_INTEGRITY || !files.some((file) => file.path === ENTRY && file.type === 'file')) fail('INVALID_MCP_PACKAGE', 'MCP package does not match the pinned published release.');
53
+ for (const [path, entry] of Object.entries(lock.packages || {})) {
54
+ if (!path) continue;
55
+ if (entry.link || !/^https:\/\/registry\.npmjs\.org\//.test(entry.resolved || '') || !/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(entry.integrity || '')) fail('INVALID_MCP_LOCK', 'MCP dependency lock contains an unverified package source.');
56
+ }
57
+ return files;
58
+ }
59
+
60
+ export class ManagedMcp {
61
+ constructor(installer) {
62
+ this.owner = installer;
63
+ this.path = join(installer.weaveHome, 'tools', `mcp-${MCP_VERSION}`);
64
+ this.entry = join(this.path, ENTRY);
65
+ }
66
+
67
+ validReceipt(receipt) {
68
+ return receipt && receipt.version === MCP_VERSION && receipt.path === this.path && receipt.entry === this.entry && Array.isArray(receipt.files);
69
+ }
70
+
71
+ unchanged(receipt) {
72
+ return this.validReceipt(receipt) && JSON.stringify(censusTree(this.path)) === JSON.stringify(receipt.files);
73
+ }
74
+
75
+ async install() {
76
+ const owner = this.owner;
77
+ let receipt = owner.manifest.mcpRuntime;
78
+ assertSafePath(this.path);
79
+ if (receipt?.staging) {
80
+ if (!this.validReceipt(receipt) || dirname(receipt.staging) !== dirname(this.path) || !/^\.mcp-[a-f0-9-]{36}\.staging$/.test(receipt.staging.split('/').pop())) fail('INVALID_MANIFEST', 'MCP staging receipt is not recognized.');
81
+ assertSafePath(receipt.staging);
82
+ if (fs.existsSync(receipt.staging)) {
83
+ if (fs.existsSync(this.path)) fail('CONCURRENT_EDIT', 'Both staged and installed MCP runtimes exist. They were preserved.');
84
+ if (JSON.stringify(verifyMcpTree(receipt.staging)) !== JSON.stringify(receipt.files)) fail('UNMANAGED_MCP_RUNTIME', 'The recoverable MCP stage was modified. It was preserved.');
85
+ fs.renameSync(receipt.staging, this.path);
86
+ } else if (!fs.existsSync(this.path)) {
87
+ delete owner.manifest.mcpRuntime;
88
+ owner.saveManifest();
89
+ receipt = undefined;
90
+ }
91
+ if (receipt) {
92
+ if (!this.unchanged(receipt)) fail('UNMANAGED_MCP_RUNTIME', 'The recoverable MCP runtime was modified. It was preserved.');
93
+ delete receipt.staging;
94
+ owner.saveManifest();
95
+ }
96
+ }
97
+ if (fs.existsSync(this.path)) {
98
+ if (!this.unchanged(receipt)) fail('UNMANAGED_MCP_RUNTIME', `An unrelated or modified MCP runtime exists at ${this.path}. It was preserved.`);
99
+ verifyMcpTree(this.path);
100
+ return;
101
+ }
102
+ if (receipt) fail('MISSING_MCP_RUNTIME', 'The recorded MCP runtime is missing. Uninstall the owned receipt before reinstalling.');
103
+ assertSafePath(dirname(this.path));
104
+ fs.mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
105
+ const stage = join(dirname(this.path), `.mcp-${randomUUID()}.staging`);
106
+ fs.mkdirSync(stage, { mode: 0o700 });
107
+ try {
108
+ fs.writeFileSync(join(stage, 'package.json'), JSON.stringify({ name: 'joinweave-managed-mcp', version: MCP_VERSION, private: true, dependencies: { '@useweave/mcp': MCP_VERSION } }), { flag: 'wx', mode: 0o600 });
109
+ const npmCli = owner.npmCli || resolveNpmCli(owner.env, owner.nodePath);
110
+ const result = await owner.run(owner.nodePath, [npmCli, 'install', '--prefix', stage, '--ignore-scripts', '--bin-links=false', '--omit=optional', '--no-audit', '--no-fund', '--save-exact', '--registry=https://registry.npmjs.org', '--loglevel=error'], { env: owner.env, cwd: stage, timeout: 180_000, signal: owner.signal });
111
+ if (result.code !== 0) fail('MCP_INSTALL_FAILED', `MCP installation failed (exit ${result.code}). Your runtime and identity remain available. Check npm connectivity and its existing release-age policy, then rerun.`);
112
+ const files = verifyMcpTree(stage);
113
+ assertSafePath(this.path);
114
+ if (fs.existsSync(this.path)) fail('CONCURRENT_EDIT', 'MCP destination appeared during installation. It was not replaced.');
115
+ // Journal the exact verified stage before rename, so an interruption at
116
+ // either side of the atomic commit remains recoverable without guessing.
117
+ owner.manifest.mcpRuntime = { version: MCP_VERSION, path: this.path, entry: this.entry, files, staging: stage };
118
+ owner.saveManifest();
119
+ fs.renameSync(stage, this.path);
120
+ delete owner.manifest.mcpRuntime.staging;
121
+ owner.saveManifest();
122
+ } finally {
123
+ // Only this process's exclusive random staging directory; never a receipt path.
124
+ if (!owner.preserveLock && fs.existsSync(stage)) fs.rmSync(stage, { recursive: true });
125
+ }
126
+ }
127
+
128
+ remove() {
129
+ const owner = this.owner, receipt = owner.manifest.mcpRuntime;
130
+ if (!receipt) return;
131
+ if (!this.validReceipt(receipt)) fail('INVALID_MANIFEST', 'MCP runtime receipt is not recognized.');
132
+ if (receipt.staging) { owner.warnings.push('Preserved a recoverable MCP staging receipt. Rerun installation before uninstalling it.'); return; }
133
+ assertSafePath(this.path);
134
+ if (fs.existsSync(this.path)) {
135
+ if (owner.warnings.length || !this.unchanged(receipt)) { owner.warnings.push('Preserved modified or still-referenced MCP runtime.'); return; }
136
+ // Recheck the entire owned tree immediately before removing it.
137
+ if (!this.unchanged(receipt)) fail('CONCURRENT_EDIT', 'MCP runtime changed while preparing removal.');
138
+ fs.rmSync(this.path, { recursive: true });
139
+ }
140
+ delete owner.manifest.mcpRuntime;
141
+ owner.saveManifest();
142
+ }
143
+ }
@@ -0,0 +1,158 @@
1
+ const RESET = '\x1b[0m', HIDE = '\x1b[?25l', SHOW = '\x1b[?25h';
2
+ const clean = (value) => String(value).replace(/[\x00-\x1f\x7f-\x9f]/g, '');
3
+ const tint = (value, color, enabled) => enabled ? `\x1b[38;5;${color}m${value}${RESET}` : value;
4
+ const stages = ['runtime', 'identity', 'node', 'mcp', 'clients', 'verify'];
5
+ const glyphs = '.:-=+*#';
6
+
7
+ /** A deterministic protocol illustration. Packets describe motion, never live telemetry. */
8
+ export function fabricFrame(width, height, tick = 0, phase = 0, color = true) {
9
+ width = Math.max(24, Math.min(88, Math.floor(width)));
10
+ height = Math.max(8, Math.min(23, Math.floor(height)));
11
+ const cells = Array(width * height).fill(' '), depths = new Float64Array(width * height).fill(-Infinity), colors = new Uint8Array(width * height);
12
+ const angle = -0.48 + Math.sin(tick / 28) * 0.09, ca = Math.cos(angle), sa = Math.sin(angle);
13
+ const plot = (x, y, z, char, hue = 245) => {
14
+ const rx = x * ca + z * sa, rz = z * ca - x * sa;
15
+ const perspective = 1 / (1 - rz * 0.11);
16
+ const px = Math.round(width / 2 + rx * perspective * width * 0.19);
17
+ const py = Math.round(height / 2 - (y * 0.78 - rz * 0.29) * perspective * height * 0.31);
18
+ if (px < 0 || px >= width || py < 0 || py >= height) return;
19
+ const i = py * width + px;
20
+ if (rz < depths[i]) return;
21
+ depths[i] = rz; cells[i] = char; colors[i] = hue;
22
+ };
23
+ // Registration planes below, within and above the fabric.
24
+ for (const y of [-1.34, 0, 1.34]) {
25
+ for (let i = 0; i <= 70; i++) {
26
+ const p = i / 70 * 3.9 - 1.95;
27
+ plot(p, y, -1.08, '.', 240); plot(p, y, 1.08, '.', 240);
28
+ plot(-1.95, y, p * 0.55, '.', 240); plot(1.95, y, p * 0.55, '.', 240);
29
+ }
30
+ }
31
+ // Counter-wound filaments cross around a signed central spine.
32
+ const count = width < 50 ? 22 : 38;
33
+ for (let strand = 0; strand < count; strand++) {
34
+ const offset = strand / count * Math.PI * 2;
35
+ const handed = strand % 2 ? 1 : -1;
36
+ for (let i = 0; i < 100; i++) {
37
+ const t = i / 99, y = (t - 0.5) * 3.65;
38
+ const radius = 0.80 + 0.48 * Math.pow(Math.sin(t * Math.PI), 2);
39
+ const a = offset + t * Math.PI * 2.1 * handed;
40
+ const x = Math.cos(a) * radius, z = Math.sin(a) * radius;
41
+ const shade = glyphs[Math.round((z / radius + 1) * 0.5 * (glyphs.length - 1))];
42
+ const active = strand % 6 === phase % 6;
43
+ plot(x, y, z, shade, active ? (handed > 0 ? 81 : 141) : z > 0 ? 250 : 240);
44
+ if (active && Math.abs((t + strand * 0.037 - tick / 70) % 1) < 0.016) plot(x, y, z + 0.02, '@', 159);
45
+ }
46
+ }
47
+ // Individually composed peer modules, not a globe or starfield.
48
+ for (let i = 0; i < 18; i++) {
49
+ const a = i * 2.399963, y = (i % 6 - 2.5) * 0.52;
50
+ const x = Math.cos(a) * (1.50 + (i % 3) * 0.15), z = Math.sin(a) * 1.18;
51
+ const hue = i % 6 === phase % 6 ? 117 : 246;
52
+ plot(x - 0.10, y, z, '[', hue); plot(x, y, z, i % 3 === 0 ? '+' : ':', hue); plot(x + 0.10, y, z, ']', hue);
53
+ }
54
+ for (let i = 0; i < 7; i++) {
55
+ const y = (i - 3) * 0.45;
56
+ plot(-0.14, y, 1.33, '[', 147); plot(0, y, 1.34, phase === 1 || phase === 5 ? '#' : '=', 153); plot(0.14, y, 1.33, ']', 147);
57
+ if (i < 6) plot(0, y + 0.22, 1.32, '|', 245);
58
+ }
59
+ const lines = [];
60
+ for (let y = 0; y < height; y++) {
61
+ let line = '', last = -1;
62
+ for (let x = 0; x < width; x++) {
63
+ const i = y * width + x, hue = colors[i];
64
+ if (color && hue !== last) { line += hue ? `\x1b[38;5;${hue}m` : RESET; last = hue; }
65
+ line += cells[i];
66
+ }
67
+ lines.push(color ? `${line}${RESET}` : line);
68
+ }
69
+ return lines;
70
+ }
71
+
72
+ export class TerminalPresenter {
73
+ constructor({ stream = process.stderr, env = process.env, json = false, noAnimation = false, timers = globalThis } = {}) {
74
+ this.stream = stream; this.json = json; this.timers = timers;
75
+ this.color = !Object.hasOwn(env, 'NO_COLOR') && env.TERM !== 'dumb';
76
+ this.animated = !json && !noAnimation && Boolean(stream.isTTY) && env.TERM !== 'dumb';
77
+ this.active = false; this.closed = false; this.height = 0; this.tick = 0;
78
+ this.phase = 0; this.label = 'Preparing your developer runtime'; this.completed = [];
79
+ this.resize = () => { if (this.active) this.render(); };
80
+ if (this.animated) stream.on?.('resize', this.resize);
81
+ }
82
+ start() {
83
+ if (!this.animated || this.closed || this.active) return;
84
+ this.active = true;
85
+ this.stream.write(HIDE);
86
+ this.render();
87
+ this.timer = this.timers.setInterval(() => { this.tick++; this.render(); }, 120);
88
+ this.timer?.unref?.();
89
+ }
90
+ stage({ id, label }) {
91
+ this.phase = Math.max(0, stages.indexOf(id)); this.label = clean(label);
92
+ if (this.animated) { if (!this.active) this.start(); else this.render(); }
93
+ else if (!this.json && !this.closed) this.stream.write(`\n> ${this.label}\n`);
94
+ }
95
+ progress(message) {
96
+ const text = clean(message);
97
+ this.completed.push(text);
98
+ if (this.active) this.render();
99
+ else if (!this.json && !this.closed) this.stream.write(` + ${text}\n`);
100
+ }
101
+ render() {
102
+ if (!this.active || this.closed) return;
103
+ const columns = Math.max(1, Math.floor(this.stream.columns || 80));
104
+ const width = Math.min(88, columns - 2), rows = Math.floor(this.stream.rows || 36);
105
+ // Tiny terminals use readable status, rather than clipping a sculpture.
106
+ const title = width >= 48 ? 'W E A V E / THE AGENT-NATIVE WEB' : 'W E A V E';
107
+ const lines = [tint(title.slice(0, columns - 1), 147, this.color)];
108
+ if (width >= 26 && rows >= 17) lines.push(...fabricFrame(width, Math.min(23, rows - 11), this.tick, this.phase, this.color));
109
+ lines.push('', tint(this.label.slice(0, columns - 1), 159, this.color));
110
+ const trace = stages.map((id, i) => i === this.phase ? `[${id}]` : id).join(' / ');
111
+ if (trace.length < columns) lines.push(tint(trace, 245, this.color));
112
+ const recent = this.completed.slice(-2);
113
+ for (const message of recent) lines.push(`+ ${message}`.slice(0, columns - 1));
114
+ if (width >= 44) lines.push(tint('Protocol illustration | real installation stages'.slice(0, columns - 1), 240, this.color));
115
+ if (rows < 7) lines.splice(0, lines.length, tint(this.label.slice(0, columns - 1), 159, this.color));
116
+ this.clear();
117
+ for (const line of lines) this.stream.write(`\r\x1b[2K${line}\n`);
118
+ this.widths = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, '').length);
119
+ this.height = lines.length;
120
+ }
121
+ clear() {
122
+ if (!this.height) return;
123
+ const columns = Math.max(1, Math.floor(this.stream.columns || 80));
124
+ const priorRows = this.widths?.reduce((sum, width) => sum + Math.max(1, Math.ceil(width / columns)), 0) || this.height;
125
+ // Reflow may put old illustration lines into scrollback. Clear only the
126
+ // currently visible owned region; never scroll a tall blank frame through
127
+ // a newly tiny terminal.
128
+ const height = Math.min(priorRows, Math.max(1, Math.floor(this.stream.rows || 36) - 1));
129
+ this.stream.write(`\x1b[${height}A`);
130
+ for (let i = 0; i < height; i++) this.stream.write('\r\x1b[2K\n');
131
+ this.stream.write(`\x1b[${height}A\r`);
132
+ this.height = 0;
133
+ }
134
+ pause() {
135
+ if (this.timer !== undefined) { this.timers.clearInterval(this.timer); this.timer = undefined; }
136
+ this.active = false;
137
+ if (this.animated) { this.clear(); this.stream.write(`${RESET}${SHOW}`); }
138
+ }
139
+ resume() { this.start(); }
140
+ finish() {
141
+ if (this.closed) return;
142
+ this.pause(); this.closed = true;
143
+ if (this.animated) this.stream.off?.('resize', this.resize);
144
+ }
145
+ }
146
+
147
+ const quote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
148
+ export function installationSummary(result) {
149
+ const cli = result.pathAdded ? 'weave' : quote(result.binary);
150
+ const lines = ['Weave is ready.', ''];
151
+ for (const component of result.components || []) {
152
+ const status = component.status === 'installed' || component.status === 'ready' ? '+' : component.status === 'running' ? '+' : '-';
153
+ lines.push(`${status} ${component.name}${component.version ? ` ${component.version}` : ''} / ${component.status}${component.id === 'node' ? ` (${component.managed ? 'user service' : 'detached'})` : ''}`);
154
+ }
155
+ lines.push('', `Workspace: ${result.shared}`, `Private identity and data: ${result.workspace}`, `Agent clients: ${result.mcpClients.length ? result.mcpClients.join(', ') : 'none configured; consent is required'}`, '', 'Your next thread:', ` ${cli} node status --json`, ` ${cli} strand create --json`, '', 'Docs: https://docs.weave.earth/getting-started/quickstart', 'Your identity stays local. No account or desktop app was installed.');
156
+ if (!result.pathAdded) lines.push('Add the CLI to PATH with a consented rerun: npx joinweave@0.1.2 --yes --path');
157
+ return lines.map(clean).join('\n');
158
+ }