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 +102 -47
- package/bin/joinweave.js +40 -13
- package/package.json +1 -1
- package/src/installer.js +143 -26
- package/src/managed-mcp.js +143 -0
- package/src/terminal.js +158 -0
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.
|
|
6
|
+
npx joinweave@0.1.2
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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.
|
|
58
|
+
npx joinweave@0.1.2 --yes --path --mcp-client cursor,codex --json
|
|
24
59
|
```
|
|
25
60
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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.
|
|
65
|
-
npx joinweave@0.1.
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
entries are
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
19
|
-
|
|
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(`\
|
|
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
|
-
|
|
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 {
|
|
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
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.
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
@@ -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 = () => {},
|
|
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 =
|
|
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
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
349
|
-
return
|
|
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 ||
|
|
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
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
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 (
|
|
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
|
+
}
|
package/src/terminal.js
ADDED
|
@@ -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
|
+
}
|