@linzumi/cli 1.0.86 → 1.0.88

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linzumi/cli",
3
- "version": "1.0.86",
3
+ "version": "1.0.88",
4
4
  "description": "Linzumi CLI \u2014 point a Codex agent at the real code on your laptop, with your team watching and steering from shared threads.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,186 @@
1
+ // Demo/evidence stand-in for the compute-nodes GraphQL surface (spec
2
+ // kandan/plans/2026-07-11-m0.1-compute-nodes-schema-protocol-spec.md,
3
+ // sections 3-4): an in-memory /api/v2/graphql that answers the CLI's
4
+ // ComputeNodeCliList / ComputeNodeCliCommand documents with the frozen
5
+ // result-union shapes, including the reset retire+successor semantics
6
+ // (generation + 1, one active generation per identity) and rename.
7
+ //
8
+ // PURPOSE: terminal recordings + local demos of the REAL CLI code paths
9
+ // before M1.3 is deployed (D29 gates the merge on the real mutation; this
10
+ // stand-in never ships and is not a test double for correctness claims -
11
+ // the live acceptance run is scripts/compute-node-reset-e2e.mjs against a
12
+ // real server).
13
+ //
14
+ // RUN: node scripts/compute-node-demo-server.mjs [port] (default 4123)
15
+ // Then: linzumi node list --linzumi-url ws://127.0.0.1:4123 --token demo
16
+ import { createServer } from 'node:http';
17
+ import { hostname } from 'node:os';
18
+ import { randomUUID } from 'node:crypto';
19
+
20
+ const port = Number(process.argv[2] ?? 4123);
21
+
22
+ // One pre-adopted active node for this machine, like a real first connect
23
+ // (adoption names the node after the hello hostname).
24
+ const nodes = [
25
+ {
26
+ id: `ComputeNode:${randomUUID()}`,
27
+ name: hostname(),
28
+ identityKey: 'mfp2-demo',
29
+ identityQuality: 'FULL',
30
+ resetGeneration: 0,
31
+ firstSeenAt: new Date(Date.now() - 86_400_000).toISOString(),
32
+ lastSeenAt: new Date().toISOString(),
33
+ retiredAt: null,
34
+ },
35
+ {
36
+ id: `ComputeNode:${randomUUID()}`,
37
+ name: 'basement-linux',
38
+ identityKey: 'demo-degraded-machine-id',
39
+ identityQuality: 'DEGRADED',
40
+ resetGeneration: 2,
41
+ firstSeenAt: new Date(Date.now() - 30 * 86_400_000).toISOString(),
42
+ lastSeenAt: new Date(Date.now() - 3 * 86_400_000).toISOString(),
43
+ retiredAt: null,
44
+ },
45
+ ];
46
+
47
+ function activeNodes() {
48
+ return nodes.filter((node) => node.retiredAt === null);
49
+ }
50
+
51
+ function summary(node) {
52
+ return {
53
+ id: node.id,
54
+ name: node.name,
55
+ identityQuality: node.identityQuality,
56
+ onlineStatus: node.retiredAt === null ? 'OFFLINE' : 'UNKNOWN',
57
+ resetGeneration: node.resetGeneration,
58
+ firstSeenAt: node.firstSeenAt,
59
+ lastSeenAt: node.lastSeenAt,
60
+ };
61
+ }
62
+
63
+ // Spec section 4: retire + insert successor in one step; retry-idempotent.
64
+ function resetNode(nodeId) {
65
+ const target = nodes.find((node) => node.id === nodeId);
66
+ if (target === undefined) {
67
+ return {
68
+ __typename: 'ComputeNodeNotFoundError',
69
+ message: 'no such compute node',
70
+ nodeId,
71
+ };
72
+ }
73
+ if (target.retiredAt !== null) {
74
+ const successor = nodes.find(
75
+ (node) =>
76
+ node.identityKey === target.identityKey &&
77
+ node.resetGeneration === target.resetGeneration + 1
78
+ );
79
+ if (successor === undefined) {
80
+ return {
81
+ __typename: 'ComputeNodeNotFoundError',
82
+ message: 'retired without successor (admin-retired lineage)',
83
+ nodeId,
84
+ };
85
+ }
86
+ return {
87
+ __typename: 'ResetComputeNodeSuccess',
88
+ computeNode: summary(successor),
89
+ };
90
+ }
91
+ target.retiredAt = new Date().toISOString();
92
+ const successor = {
93
+ ...target,
94
+ id: `ComputeNode:${randomUUID()}`,
95
+ resetGeneration: target.resetGeneration + 1,
96
+ firstSeenAt: null,
97
+ lastSeenAt: null,
98
+ retiredAt: null,
99
+ };
100
+ nodes.push(successor);
101
+ return {
102
+ __typename: 'ResetComputeNodeSuccess',
103
+ computeNode: summary(successor),
104
+ };
105
+ }
106
+
107
+ function renameNode(nodeId, name) {
108
+ const target = nodes.find(
109
+ (node) => node.id === nodeId && node.retiredAt === null
110
+ );
111
+ if (target === undefined) {
112
+ return {
113
+ __typename: 'ComputeNodeNotFoundError',
114
+ message: 'no such compute node',
115
+ nodeId,
116
+ };
117
+ }
118
+ const trimmed = name.trim();
119
+ if (trimmed.length === 0 || trimmed.length > 255) {
120
+ return {
121
+ __typename: 'ComputeNodeInvalidNameError',
122
+ message: 'name must be 1-255 characters',
123
+ };
124
+ }
125
+ target.name = trimmed;
126
+ return {
127
+ __typename: 'RenameComputeNodeSuccess',
128
+ computeNode: summary(target),
129
+ };
130
+ }
131
+
132
+ const server = createServer((request, response) => {
133
+ if (request.method !== 'POST' || request.url !== '/api/v2/graphql') {
134
+ response.writeHead(404).end();
135
+ return;
136
+ }
137
+ let body = '';
138
+ request.on('data', (chunk) => (body += chunk));
139
+ request.on('end', () => {
140
+ const { query, variables } = JSON.parse(body);
141
+ let data;
142
+ if (query.includes('ComputeNodeCliList')) {
143
+ data = {
144
+ viewer: {
145
+ computeNodes: {
146
+ edges: activeNodes().map((node) => ({ node: summary(node) })),
147
+ },
148
+ },
149
+ };
150
+ } else if (query.includes('ComputeNodeCliCommand')) {
151
+ const input = variables.input ?? {};
152
+ const members = Object.keys(input);
153
+ if (members.length !== 1) {
154
+ // OneOf violation (D32) - a real server rejects this in validation.
155
+ response.writeHead(200, { 'content-type': 'application/json' }).end(
156
+ JSON.stringify({
157
+ errors: [{ message: 'OneOf input must have exactly one member' }],
158
+ })
159
+ );
160
+ return;
161
+ }
162
+ data = {
163
+ executeComputeNodeCommand:
164
+ members[0] === 'reset'
165
+ ? resetNode(input.reset.nodeId)
166
+ : renameNode(input.rename.nodeId, input.rename.name),
167
+ };
168
+ } else {
169
+ response.writeHead(200, { 'content-type': 'application/json' }).end(
170
+ JSON.stringify({
171
+ errors: [{ message: 'Cannot query field (demo server)' }],
172
+ })
173
+ );
174
+ return;
175
+ }
176
+ response
177
+ .writeHead(200, { 'content-type': 'application/json' })
178
+ .end(JSON.stringify({ data }));
179
+ });
180
+ });
181
+
182
+ server.listen(port, '127.0.0.1', () => {
183
+ console.log(
184
+ `compute-node demo server on http://127.0.0.1:${port}/api/v2/graphql`
185
+ );
186
+ });
@@ -0,0 +1,92 @@
1
+ // Hello witness (evidence tooling for M1.5): a minimal Phoenix-protocol WS
2
+ // endpoint that PRINTS the machine-identity fields of the real CLI's join
3
+ // hello (proving fingerprint v2 rides the wire next to v1, hashed only - D5)
4
+ // and replies with an M1.1-shaped `compute_node` adoption receipt so the
5
+ // runner's `runner.compute_node` log line fires.
6
+ //
7
+ // Evidence-only, like compute-node-demo-server.mjs: never a correctness
8
+ // oracle (the live acceptance run is compute-node-reset-e2e.mjs against a
9
+ // real server).
10
+ //
11
+ // RUN: node scripts/compute-node-hello-witness.mjs [port] (default 4124)
12
+ // Then: linzumi connect --linzumi-url ws://127.0.0.1:4124 --token demo --cwd .
13
+ import { createRequire } from 'node:module';
14
+ import { createServer } from 'node:http';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const { WebSocketServer } = require('ws');
18
+
19
+ const port = Number(process.argv[2] ?? 4124);
20
+
21
+ // The CLI's connect flow makes HTTP preflight calls against the same host
22
+ // (token/config probes). Answer them all with an empty-ok JSON so the flow
23
+ // proceeds to the websocket join - the part this witness exists to print.
24
+ const httpServer = createServer((request, response) => {
25
+ console.log(`[http] ${request.method} ${request.url}`);
26
+ response
27
+ .writeHead(200, { 'content-type': 'application/json' })
28
+ .end(JSON.stringify({ ok: true }));
29
+ });
30
+ const server = new WebSocketServer({ server: httpServer });
31
+ httpServer.listen(port, '127.0.0.1');
32
+
33
+ server.on('connection', (socket) => {
34
+ socket.on('message', (rawMessage) => {
35
+ const frame = JSON.parse(String(rawMessage));
36
+ const [, ref, topic, event, payload] = frame;
37
+
38
+ if (event === 'phx_join' && payload?.clientName !== undefined) {
39
+ console.log(`\n=== join hello received on ${topic} ===`);
40
+ console.log(` machineFingerprint (v1): ${payload.machineFingerprint}`);
41
+ console.log(
42
+ ` machineFingerprintV2 (v2): ${
43
+ 'machineFingerprintV2' in payload
44
+ ? payload.machineFingerprintV2
45
+ : '<ABSENT - degraded path>'
46
+ }`
47
+ );
48
+ console.log(
49
+ ` hostname: ${payload.hostname} version: ${payload.version}`
50
+ );
51
+ console.log('=== replying with compute_node adoption receipt ===\n');
52
+
53
+ socket.send(
54
+ JSON.stringify([
55
+ null,
56
+ ref,
57
+ topic,
58
+ 'phx_reply',
59
+ {
60
+ status: 'ok',
61
+ response: {
62
+ ok: true,
63
+ protocol: 'kandan-local-runner.v1',
64
+ compute_node: {
65
+ id: 'Q29tcHV0ZU5vZGU6ZGVtbw==',
66
+ name: payload.hostname,
67
+ identity_quality:
68
+ 'machineFingerprintV2' in payload ? 'full' : 'degraded',
69
+ reset_generation: 0,
70
+ },
71
+ },
72
+ },
73
+ ])
74
+ );
75
+ return;
76
+ }
77
+
78
+ if (ref !== null && ref !== undefined) {
79
+ socket.send(
80
+ JSON.stringify([
81
+ null,
82
+ ref,
83
+ topic,
84
+ 'phx_reply',
85
+ { status: 'ok', response: { ok: true, event } },
86
+ ])
87
+ );
88
+ }
89
+ });
90
+ });
91
+
92
+ console.log(`hello witness listening on ws://127.0.0.1:${port}`);
@@ -0,0 +1,250 @@
1
+ // Compute-nodes plan M1 acceptance (a), CLI legs - LIVE e2e against a REAL
2
+ // server (compute-nodes program; spec
3
+ // kandan/plans/2026-07-11-m0.1-compute-nodes-schema-protocol-spec.md
4
+ // section 4 join-time selection + section 7 acceptance commands):
5
+ //
6
+ // P1 adoption: a fresh connect adopts ONE active compute node for this
7
+ // (machine, OS user); on a v2-capable host its identity
8
+ // quality is FULL (fingerprint v2 rode the hello).
9
+ // P2 reset: `linzumi node reset` retires the generation and the
10
+ // server returns the successor (generation + 1).
11
+ // P3 config wipe: rm -rf of the CLI's local config/state, then reconnect,
12
+ // SELECTS THE SUCCESSOR - never a revived generation 0,
13
+ // never a second active row (D21: lineage lives server-side,
14
+ // so there is nothing local whose loss could fork identity).
15
+ // P4 rename: `linzumi node rename` renames the node and the list
16
+ // read-back shows the new name (M1.8).
17
+ //
18
+ // REQUIREMENTS: a server running M1.1 (join adoption) + M1.3 (the
19
+ // executeComputeNodeCommand mutation) - D29 gates the CLI merge on M1.3
20
+ // being deployed. Run against a LOCAL dev server or a preview with a TEST
21
+ // account; never a real user.
22
+ //
23
+ // RUN (from packages/linzumi-cli, after `npm run build`):
24
+ //
25
+ // LINZUMI_E2E_URL=ws://127.0.0.1:4000 \
26
+ // LINZUMI_E2E_TOKEN=<local-runner token for the test user> \
27
+ // node scripts/compute-node-reset-e2e.mjs
28
+ //
29
+ // The script never touches the real ~/.linzumi: all CLI state is scoped into
30
+ // a temp dir via LINZUMI_CONFIG_FILE, and the "config wipe" leg wipes THAT.
31
+ import { spawn, spawnSync } from 'node:child_process';
32
+ import { mkdtempSync, rmSync } from 'node:fs';
33
+ import { tmpdir } from 'node:os';
34
+ import { dirname, join } from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+
37
+ const here = dirname(fileURLToPath(import.meta.url));
38
+ const pkgRoot = dirname(here);
39
+ const cliBin = join(pkgRoot, 'bin', 'linzumi.js');
40
+
41
+ const serverUrl = process.env.LINZUMI_E2E_URL ?? 'ws://127.0.0.1:4000';
42
+ const token = process.env.LINZUMI_E2E_TOKEN;
43
+ // Extra args appended to `linzumi connect` (space-separated), e.g.
44
+ // `--code-server-bin /tmp/stub` on hosts without the editor runtime.
45
+ const extraConnectArgs = (process.env.LINZUMI_E2E_CONNECT_ARGS ?? '')
46
+ .split(' ')
47
+ .filter((arg) => arg !== '');
48
+
49
+ if (token === undefined || token === '') {
50
+ console.error(
51
+ 'LINZUMI_E2E_TOKEN is required (a local-runner token for a TEST account).'
52
+ );
53
+ process.exit(2);
54
+ }
55
+
56
+ let stateRoot = mkdtempSync(join(tmpdir(), 'linzumi-node-e2e-'));
57
+ const workDir = mkdtempSync(join(tmpdir(), 'linzumi-node-e2e-cwd-'));
58
+
59
+ function cliEnv() {
60
+ return {
61
+ ...process.env,
62
+ // Scope EVERY piece of CLI state into the wipeable temp root.
63
+ LINZUMI_CONFIG_FILE: join(stateRoot, 'config.json'),
64
+ };
65
+ }
66
+
67
+ function runCli(args, options = {}) {
68
+ const result = spawnSync('node', [cliBin, ...args], {
69
+ env: cliEnv(),
70
+ encoding: 'utf8',
71
+ timeout: options.timeoutMs ?? 60_000,
72
+ });
73
+ return {
74
+ code: result.status,
75
+ stdout: result.stdout ?? '',
76
+ stderr: result.stderr ?? '',
77
+ };
78
+ }
79
+
80
+ function nodeList() {
81
+ const result = runCli([
82
+ 'node',
83
+ 'list',
84
+ '--json',
85
+ '--linzumi-url',
86
+ serverUrl,
87
+ '--token',
88
+ token,
89
+ ]);
90
+ if (result.code !== 0) {
91
+ throw new Error(`node list failed: ${result.stderr}`);
92
+ }
93
+ return JSON.parse(result.stdout);
94
+ }
95
+
96
+ // Connect a runner long enough for join-time adoption to commit, then stop
97
+ // it. Adoption happens ON JOIN (spec section 4), so we poll the list read
98
+ // until the node appears rather than parsing runner logs.
99
+ async function connectAndAdopt(label) {
100
+ const child = spawn(
101
+ 'node',
102
+ [
103
+ cliBin,
104
+ 'connect',
105
+ '--linzumi-url',
106
+ serverUrl,
107
+ '--token',
108
+ token,
109
+ '--cwd',
110
+ workDir,
111
+ ...extraConnectArgs,
112
+ ],
113
+ { env: cliEnv(), stdio: ['ignore', 'pipe', 'pipe'] }
114
+ );
115
+ let output = '';
116
+ child.stdout.on('data', (chunk) => (output += chunk));
117
+ child.stderr.on('data', (chunk) => (output += chunk));
118
+
119
+ try {
120
+ const deadline = Date.now() + 60_000;
121
+ for (;;) {
122
+ const nodes = nodeList();
123
+ if (nodes.length > 0) {
124
+ return nodes;
125
+ }
126
+ if (Date.now() > deadline) {
127
+ throw new Error(
128
+ `${label}: no compute node adopted within 60s. runner output:\n${output}`
129
+ );
130
+ }
131
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
132
+ }
133
+ } finally {
134
+ child.kill('SIGTERM');
135
+ await new Promise((resolve) => setTimeout(resolve, 500));
136
+ child.kill('SIGKILL');
137
+ }
138
+ }
139
+
140
+ const failures = [];
141
+ function check(name, condition, detail) {
142
+ if (condition) {
143
+ console.log(`PASS ${name}`);
144
+ } else {
145
+ console.error(`FAIL ${name}: ${detail}`);
146
+ failures.push(name);
147
+ }
148
+ }
149
+
150
+ try {
151
+ // P1: fresh adoption.
152
+ const adopted = await connectAndAdopt('P1');
153
+ check(
154
+ 'P1 exactly one active node adopted',
155
+ adopted.length === 1,
156
+ JSON.stringify(adopted)
157
+ );
158
+ const original = adopted[0];
159
+ check(
160
+ 'P1 identity quality is FULL on a v2-capable host',
161
+ original.identityQuality === 'FULL',
162
+ `identityQuality=${original.identityQuality} (expected FULL on macOS/Linux; DEGRADED means the v2 fingerprint did not ride the hello)`
163
+ );
164
+ const originalGeneration = original.resetGeneration;
165
+
166
+ // P2: reset returns the successor generation.
167
+ const reset = runCli([
168
+ 'node',
169
+ 'reset',
170
+ '--node',
171
+ original.id,
172
+ '--yes',
173
+ '--linzumi-url',
174
+ serverUrl,
175
+ '--token',
176
+ token,
177
+ ]);
178
+ check('P2 reset exits 0', reset.code === 0, reset.stderr);
179
+ check(
180
+ 'P2 reset prints the successor generation',
181
+ reset.stdout.includes(`generation ${originalGeneration + 1}`),
182
+ reset.stdout
183
+ );
184
+
185
+ const afterReset = nodeList();
186
+ check(
187
+ 'P2 exactly one active node after reset',
188
+ afterReset.length === 1,
189
+ JSON.stringify(afterReset)
190
+ );
191
+ const successor = afterReset[0];
192
+ check(
193
+ 'P2 successor generation is original + 1',
194
+ successor.resetGeneration === originalGeneration + 1,
195
+ `generation=${successor.resetGeneration}`
196
+ );
197
+ check(
198
+ 'P2 successor is a NEW row (fresh id)',
199
+ successor.id !== original.id,
200
+ `id unchanged: ${successor.id}`
201
+ );
202
+
203
+ // P3: config wipe + reconnect selects the successor (the D21 property:
204
+ // nothing local can revive the retired generation).
205
+ rmSync(stateRoot, { recursive: true, force: true });
206
+ stateRoot = mkdtempSync(join(tmpdir(), 'linzumi-node-e2e-rewiped-'));
207
+
208
+ const rejoined = await connectAndAdopt('P3');
209
+ check(
210
+ 'P3 still exactly one active node after wipe + reconnect',
211
+ rejoined.length === 1,
212
+ JSON.stringify(rejoined)
213
+ );
214
+ check(
215
+ 'P3 reconnect selected the successor (no generation-0 revival, no new row)',
216
+ rejoined[0].id === successor.id &&
217
+ rejoined[0].resetGeneration === originalGeneration + 1,
218
+ JSON.stringify(rejoined[0])
219
+ );
220
+
221
+ // P4: rename (M1.8) + read-back.
222
+ const newName = `e2e renamed ${Date.now()}`;
223
+ const rename = runCli([
224
+ 'node',
225
+ 'rename',
226
+ newName,
227
+ '--node',
228
+ successor.id,
229
+ '--linzumi-url',
230
+ serverUrl,
231
+ '--token',
232
+ token,
233
+ ]);
234
+ check('P4 rename exits 0', rename.code === 0, rename.stderr);
235
+ const renamed = nodeList().find((node) => node.id === successor.id);
236
+ check(
237
+ 'P4 list read-back shows the new name',
238
+ renamed?.name === newName,
239
+ JSON.stringify(renamed)
240
+ );
241
+ } finally {
242
+ rmSync(stateRoot, { recursive: true, force: true });
243
+ rmSync(workDir, { recursive: true, force: true });
244
+ }
245
+
246
+ if (failures.length > 0) {
247
+ console.error(`\n${failures.length} property(ies) FAILED`);
248
+ process.exit(1);
249
+ }
250
+ console.log('\nAll compute-node CLI acceptance properties PASSED');