throughline 0.10.2 → 0.10.5
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/CHANGELOG.md +88 -27
- package/README.ja.md +83 -49
- package/README.md +106 -78
- package/bin/throughline.mjs +32 -13
- package/docs/00_overview.md +56 -42
- package/docs/01_l1_l2_l3_redesign.md +1 -1
- package/docs/02_clear_auto_handoff_plan.md +39 -333
- package/docs/04_public_release_plan.md +73 -190
- package/docs/05_codex_first_roadmap.md +4 -4
- package/docs/06_codex_trim_rollback_fix_plan.md +1 -1
- package/docs/08_codex_dual_support.md +1 -1
- package/docs/09_rollback_context_trim_insight.md +1 -1
- package/docs/12_desktop_clear_handoff_plan.md +6 -213
- package/docs/15_windows_ci_release_latency_plan.md +6 -87
- package/docs/16_readonly_handoff_context_plan.md +7 -38
- package/docs/adr/0005-observer-read-pagination.md +1 -1
- package/docs/adr/0014-two-phase-handoff-ghost-baton.md +1 -1
- package/docs/adr/0019-product-owned-database-migration-acceptance.md +1 -1
- package/docs/adr/0021-grok-host-capture.md +1 -1
- package/docs/adr/0022-cursor-host-capture.md +39 -0
- package/docs/archive/02_clear_auto_handoff_plan.md +350 -0
- package/docs/{03_inheritance_on_clear_only.md → archive/03_inheritance_on_clear_only.md} +22 -22
- package/docs/{07_codex_trim_implementation_plan.md → archive/07_codex_trim_implementation_plan.md} +8 -8
- package/docs/{10_transcript_injection_plan.md → archive/10_transcript_injection_plan.md} +12 -12
- package/docs/archive/12_desktop_clear_handoff_plan.md +218 -0
- package/docs/{14_observer_completed_turn_feed_plan.md → archive/14_observer_completed_turn_feed_plan.md} +7 -7
- package/docs/archive/15_windows_ci_release_latency_plan.md +89 -0
- package/docs/archive/16_readonly_handoff_context_plan.md +40 -0
- package/docs/archive/README.md +28 -15
- package/docs/archive/plan_grok-successor-launch.md +99 -0
- package/docs/archive/room-log_throughline_20260830-155052.md +285 -0
- package/docs/plan_grok-successor-launch.md +6 -97
- package/package.json +19 -11
- package/rag/INDEX.md +2 -2
- package/src/baton.mjs +11 -9
- package/src/cli/handoff-context.test.mjs +36 -0
- package/src/cli/help.test.mjs +5 -0
- package/src/cli/install.mjs +91 -0
- package/src/cli/install.test.mjs +57 -0
- package/src/cli/runtime-errors.mjs +9 -3
- package/src/cli/runtime-errors.test.mjs +13 -13
- package/src/cli/self-update.mjs +402 -0
- package/src/cli/self-update.test.mjs +525 -0
- package/src/db.mjs +1 -1
- package/src/docs-contract.test.mjs +153 -0
- package/src/hosts/claude.mjs +1 -0
- package/src/hosts/codex.mjs +1 -0
- package/src/hosts/cursor.mjs +128 -0
- package/src/hosts/cursor.test.mjs +104 -0
- package/src/hosts/grok.mjs +1 -0
- package/src/hosts/identity.mjs +19 -2
- package/src/hosts/identity.test.mjs +27 -4
- package/src/hosts/index.mjs +11 -2
- package/src/product-ci-contract.test.mjs +14 -0
- package/src/prompt-submit.mjs +8 -10
- package/src/resume-context.mjs +4 -4
- package/src/runtime-error-hook.test.mjs +4 -6
- package/src/runtime-error-store.mjs +40 -18
- package/src/runtime-error-store.test.mjs +53 -26
- package/src/session-merger.mjs +16 -7
- package/src/session-merger.test.mjs +27 -0
- package/src/session-start.mjs +24 -1
- package/src/spike-transcript-writer.mjs +1 -1
- package/src/transcript-reader-cursor.test.mjs +43 -0
- package/src/transcript-reader.mjs +14 -7
- /package/docs/{11_codex_monitor_implementation_plan.md → archive/11_codex_monitor_implementation_plan.md} +0 -0
- /package/docs/{13_native_factory_diagnostics_plan.md → archive/13_native_factory_diagnostics_plan.md} +0 -0
- /package/docs/{BUGHUB_RUNTIME_ERROR_STORE_PLAN.md → archive/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md} +0 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
SELF_UPDATE_IDENTITY_SCHEMA,
|
|
9
|
+
SELF_UPDATE_SCHEMA,
|
|
10
|
+
parseArgs,
|
|
11
|
+
resolveGlobalCliPath,
|
|
12
|
+
run,
|
|
13
|
+
validateMigrationResult,
|
|
14
|
+
validatePostInstallResult,
|
|
15
|
+
validatePostUpdateDiagnostics,
|
|
16
|
+
validateSelfUpdateIdentity,
|
|
17
|
+
} from './self-update.mjs';
|
|
18
|
+
|
|
19
|
+
function sink() {
|
|
20
|
+
const values = [];
|
|
21
|
+
return { values, write(value) { values.push(value); } };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ok(stdout = '') {
|
|
25
|
+
return { status: 0, stdout, stderr: '' };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function migration(status = 'already_current') {
|
|
29
|
+
return {
|
|
30
|
+
schema: 'throughline.database_migration.v1',
|
|
31
|
+
status,
|
|
32
|
+
beforeSchemaVersion: status === 'not_applicable' ? null : 9,
|
|
33
|
+
afterSchemaVersion: status === 'not_applicable' ? null : 9,
|
|
34
|
+
supportedSchemaVersion: 9,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readyDiagnostics(version = '0.10.5') {
|
|
39
|
+
return {
|
|
40
|
+
schema: 'throughline.native_factory_diagnostics.v1',
|
|
41
|
+
version,
|
|
42
|
+
overall: { status: 'ready' },
|
|
43
|
+
databaseSchema: {
|
|
44
|
+
status: 'ready',
|
|
45
|
+
databaseSchemaVersion: 9,
|
|
46
|
+
supportedDatabaseSchemaVersion: 9,
|
|
47
|
+
},
|
|
48
|
+
hooks: {
|
|
49
|
+
status: 'ready',
|
|
50
|
+
events: { userPromptSubmit: 'ready', postToolUse: 'ready', stop: 'ready' },
|
|
51
|
+
},
|
|
52
|
+
readiness: {
|
|
53
|
+
capture: { status: 'not_applicable' },
|
|
54
|
+
restore: { status: 'ready' },
|
|
55
|
+
handoff: { status: 'not_applicable' },
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function updateSuccess(beforeVersion = '0.10.4', afterVersion = '0.10.5') {
|
|
61
|
+
return {
|
|
62
|
+
schema: SELF_UPDATE_SCHEMA,
|
|
63
|
+
status: beforeVersion === afterVersion ? 'already_current' : 'updated',
|
|
64
|
+
beforeVersion,
|
|
65
|
+
afterVersion,
|
|
66
|
+
migrationStatus: 'already_current',
|
|
67
|
+
diagnosticsStatus: 'ready',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function updateIdentity(cliPath, version = '0.10.5') {
|
|
72
|
+
return { schema: SELF_UPDATE_IDENTITY_SCHEMA, version, cliPath };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const fakeCanonicalizePath = (path) => path;
|
|
76
|
+
|
|
77
|
+
test('CLI identity handshake reports the executing package path and version', () => {
|
|
78
|
+
const binPath = fileURLToPath(new URL('../../bin/throughline.mjs', import.meta.url));
|
|
79
|
+
const packageVersion = JSON.parse(
|
|
80
|
+
readFileSync(new URL('../../package.json', import.meta.url), 'utf8'),
|
|
81
|
+
).version;
|
|
82
|
+
const result = spawnSync(process.execPath, [binPath, '--self-update-identity'], {
|
|
83
|
+
encoding: 'utf8',
|
|
84
|
+
});
|
|
85
|
+
assert.equal(result.status, 0, result.stderr);
|
|
86
|
+
assert.equal(validateSelfUpdateIdentity(JSON.parse(result.stdout), {
|
|
87
|
+
version: packageVersion,
|
|
88
|
+
cliPath: binPath,
|
|
89
|
+
canonicalizePath: realpathSync,
|
|
90
|
+
}), true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('self-update delegates post-install work to the newly installed CLI', () => {
|
|
94
|
+
const calls = [];
|
|
95
|
+
const stdout = sink();
|
|
96
|
+
const runner = (command, args, options) => {
|
|
97
|
+
calls.push({ command, args, env: options.env });
|
|
98
|
+
const key = [command, ...args].join(' ');
|
|
99
|
+
if (key === 'npm install -g throughline@latest') return ok('installed');
|
|
100
|
+
if (key === 'npm root --global') return ok('/global/lib/node_modules\n');
|
|
101
|
+
if (key === '/node /global/lib/node_modules/throughline/bin/throughline.mjs --version') {
|
|
102
|
+
return ok('0.10.5\n');
|
|
103
|
+
}
|
|
104
|
+
if (key === 'throughline --self-update-identity') {
|
|
105
|
+
return ok(`${JSON.stringify(updateIdentity(
|
|
106
|
+
'/global/lib/node_modules/throughline/bin/throughline.mjs',
|
|
107
|
+
))}\n`);
|
|
108
|
+
}
|
|
109
|
+
if (key === '/node /global/lib/node_modules/throughline/bin/throughline.mjs self-update --json') {
|
|
110
|
+
return ok(`${JSON.stringify(updateSuccess())}\n`);
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`unexpected command: ${key}`);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
assert.equal(run(['--json'], {
|
|
116
|
+
stdout,
|
|
117
|
+
stderr: sink(),
|
|
118
|
+
env: { PATH: '/fixture/bin' },
|
|
119
|
+
runner,
|
|
120
|
+
packageVersion: '0.10.4',
|
|
121
|
+
nodePath: '/node',
|
|
122
|
+
cliPath: '/old-throughline.mjs',
|
|
123
|
+
canonicalizePath: fakeCanonicalizePath,
|
|
124
|
+
}), 0);
|
|
125
|
+
assert.deepEqual(calls.map(({ command, args }) => [command, ...args]), [
|
|
126
|
+
['npm', 'install', '-g', 'throughline@latest'],
|
|
127
|
+
['npm', 'root', '--global'],
|
|
128
|
+
['/node', '/global/lib/node_modules/throughline/bin/throughline.mjs', '--version'],
|
|
129
|
+
['throughline', '--self-update-identity'],
|
|
130
|
+
['/node', '/global/lib/node_modules/throughline/bin/throughline.mjs', 'self-update', '--json'],
|
|
131
|
+
]);
|
|
132
|
+
assert.equal(calls[4].env.THROUGHLINE_SELF_UPDATE_PHASE, 'post-install');
|
|
133
|
+
assert.equal(calls[4].env.THROUGHLINE_SELF_UPDATE_BEFORE_VERSION, '0.10.4');
|
|
134
|
+
assert.equal(calls[4].env.THROUGHLINE_SELF_UPDATE_EXPECTED_VERSION, '0.10.5');
|
|
135
|
+
assert.match(stdout.values.join(''), /"status":"updated"/u);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('new CLI completes install, migration, and diagnostics in order', () => {
|
|
139
|
+
const calls = [];
|
|
140
|
+
const stdout = sink();
|
|
141
|
+
const runner = (command, args) => {
|
|
142
|
+
calls.push([command, ...args]);
|
|
143
|
+
const key = [command, ...args].join(' ');
|
|
144
|
+
if (key === 'npm view throughline version --json') return ok('"0.10.5"\n');
|
|
145
|
+
if (key.endsWith(' install')) return ok();
|
|
146
|
+
if (key.endsWith(' migrate --json')) return ok(`${JSON.stringify(migration())}\n`);
|
|
147
|
+
if (key.endsWith(' factory-diagnostics --json')) {
|
|
148
|
+
return ok(`${JSON.stringify(readyDiagnostics())}\n`);
|
|
149
|
+
}
|
|
150
|
+
throw new Error(`unexpected command: ${key}`);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
assert.equal(run(['--json'], {
|
|
154
|
+
stdout,
|
|
155
|
+
stderr: sink(),
|
|
156
|
+
env: {
|
|
157
|
+
THROUGHLINE_SELF_UPDATE_PHASE: 'post-install',
|
|
158
|
+
THROUGHLINE_SELF_UPDATE_BEFORE_VERSION: '0.10.4',
|
|
159
|
+
THROUGHLINE_SELF_UPDATE_EXPECTED_VERSION: '0.10.5',
|
|
160
|
+
},
|
|
161
|
+
runner,
|
|
162
|
+
packageVersion: '0.10.5',
|
|
163
|
+
nodePath: '/node',
|
|
164
|
+
cliPath: '/throughline.mjs',
|
|
165
|
+
}), 0);
|
|
166
|
+
assert.deepEqual(calls, [
|
|
167
|
+
['npm', 'view', 'throughline', 'version', '--json'],
|
|
168
|
+
['/node', '/throughline.mjs', 'install'],
|
|
169
|
+
['/node', '/throughline.mjs', 'migrate', '--json'],
|
|
170
|
+
['/node', '/throughline.mjs', 'factory-diagnostics', '--json'],
|
|
171
|
+
]);
|
|
172
|
+
assert.deepEqual(JSON.parse(stdout.values.join('')), {
|
|
173
|
+
schema: SELF_UPDATE_SCHEMA,
|
|
174
|
+
status: 'updated',
|
|
175
|
+
beforeVersion: '0.10.4',
|
|
176
|
+
afterVersion: '0.10.5',
|
|
177
|
+
migrationStatus: 'already_current',
|
|
178
|
+
diagnosticsStatus: 'ready',
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('self-update fails closed without reflecting command output', () => {
|
|
183
|
+
const stdout = sink();
|
|
184
|
+
const stderr = sink();
|
|
185
|
+
assert.equal(run(['--json'], {
|
|
186
|
+
stdout,
|
|
187
|
+
stderr,
|
|
188
|
+
runner: () => ({ status: 1, stdout: '', stderr: '/Users/private/token' }),
|
|
189
|
+
packageVersion: '0.10.4',
|
|
190
|
+
}), 1);
|
|
191
|
+
assert.deepEqual(JSON.parse(stdout.values.join('')), {
|
|
192
|
+
schema: SELF_UPDATE_SCHEMA,
|
|
193
|
+
status: 'failed',
|
|
194
|
+
stage: 'package_update_failed',
|
|
195
|
+
version: '0.10.4',
|
|
196
|
+
});
|
|
197
|
+
assert.doesNotMatch(stdout.values.join('') + stderr.values.join(''), /private|token/u);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('post-install refuses invalid migration and not-ready diagnostics', () => {
|
|
201
|
+
const base = migration();
|
|
202
|
+
assert.equal(validateMigrationResult(base), true);
|
|
203
|
+
assert.equal(validateMigrationResult({ ...base, beforeSchemaVersion: 8 }), false);
|
|
204
|
+
assert.equal(validatePostUpdateDiagnostics(readyDiagnostics(), '0.10.5'), true);
|
|
205
|
+
for (const status of [undefined, 'unverified', 'not_ready']) {
|
|
206
|
+
const diagnostics = readyDiagnostics();
|
|
207
|
+
diagnostics.overall = status === undefined ? undefined : { status };
|
|
208
|
+
assert.equal(validatePostUpdateDiagnostics(diagnostics, '0.10.5'), false);
|
|
209
|
+
}
|
|
210
|
+
assert.equal(validatePostUpdateDiagnostics({
|
|
211
|
+
...readyDiagnostics(),
|
|
212
|
+
databaseSchema: { status: 'ready', databaseSchemaVersion: 8, supportedDatabaseSchemaVersion: 9 },
|
|
213
|
+
}, '0.10.5'), false);
|
|
214
|
+
assert.equal(validatePostUpdateDiagnostics({
|
|
215
|
+
...readyDiagnostics(),
|
|
216
|
+
readiness: { ...readyDiagnostics().readiness, handoff: { status: 'unverified' } },
|
|
217
|
+
}, '0.10.5'), false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('post-install success handshake rejects help text, missing fields, and version drift', () => {
|
|
221
|
+
assert.equal(validatePostInstallResult(updateSuccess(), {
|
|
222
|
+
beforeVersion: '0.10.4',
|
|
223
|
+
afterVersion: '0.10.5',
|
|
224
|
+
}), true);
|
|
225
|
+
assert.equal(validatePostInstallResult('throughline v0.10.4\nUsage:', {
|
|
226
|
+
beforeVersion: '0.10.4',
|
|
227
|
+
afterVersion: '0.10.5',
|
|
228
|
+
}), false);
|
|
229
|
+
assert.equal(validatePostInstallResult({ ...updateSuccess(), diagnosticsStatus: undefined }, {
|
|
230
|
+
beforeVersion: '0.10.4',
|
|
231
|
+
afterVersion: '0.10.5',
|
|
232
|
+
}), false);
|
|
233
|
+
assert.equal(validatePostInstallResult({ ...updateSuccess(), afterVersion: '0.10.4' }, {
|
|
234
|
+
beforeVersion: '0.10.4',
|
|
235
|
+
afterVersion: '0.10.5',
|
|
236
|
+
}), false);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('old CLI help with exit 0 cannot satisfy the post-install protocol', () => {
|
|
240
|
+
const stdout = sink();
|
|
241
|
+
const runner = (command, args) => {
|
|
242
|
+
const key = [command, ...args].join(' ');
|
|
243
|
+
if (key === 'npm install -g throughline@latest') return ok();
|
|
244
|
+
if (key === 'npm root --global') return ok('/global/lib/node_modules\n');
|
|
245
|
+
if (key.endsWith(' --version')) return ok('0.10.5\n');
|
|
246
|
+
if (key === 'throughline --self-update-identity') {
|
|
247
|
+
return ok(`${JSON.stringify(updateIdentity(
|
|
248
|
+
'/global/lib/node_modules/throughline/bin/throughline.mjs',
|
|
249
|
+
))}\n`);
|
|
250
|
+
}
|
|
251
|
+
if (key.endsWith(' self-update --json')) return ok('throughline v0.10.4\n\nUsage:\n');
|
|
252
|
+
throw new Error(`unexpected command: ${key}`);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
assert.equal(run(['--json'], {
|
|
256
|
+
stdout,
|
|
257
|
+
stderr: sink(),
|
|
258
|
+
runner,
|
|
259
|
+
packageVersion: '0.10.4',
|
|
260
|
+
nodePath: '/node',
|
|
261
|
+
canonicalizePath: fakeCanonicalizePath,
|
|
262
|
+
}), 1);
|
|
263
|
+
assert.deepEqual(JSON.parse(stdout.values.join('')), {
|
|
264
|
+
schema: SELF_UPDATE_SCHEMA,
|
|
265
|
+
status: 'failed',
|
|
266
|
+
stage: 'post_install_protocol_failed',
|
|
267
|
+
version: '0.10.5',
|
|
268
|
+
});
|
|
269
|
+
assert.doesNotMatch(stdout.values.join(''), /Usage/u);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('post-install non-JSON failure preserves child stderr as the cause', () => {
|
|
273
|
+
const stdout = sink();
|
|
274
|
+
const stderr = sink();
|
|
275
|
+
const runner = (command, args) => {
|
|
276
|
+
const key = [command, ...args].join(' ');
|
|
277
|
+
if (key === 'npm install -g throughline@latest') return ok();
|
|
278
|
+
if (key === 'npm root --global') return ok('/global/lib/node_modules\n');
|
|
279
|
+
if (key.endsWith(' --version')) return ok('0.10.5\n');
|
|
280
|
+
if (key === 'throughline --self-update-identity') {
|
|
281
|
+
return ok(`${JSON.stringify(updateIdentity(
|
|
282
|
+
'/global/lib/node_modules/throughline/bin/throughline.mjs',
|
|
283
|
+
))}\n`);
|
|
284
|
+
}
|
|
285
|
+
if (key.endsWith(' self-update --json')) {
|
|
286
|
+
return { status: 7, stdout: '', stderr: 'migration command failed\n' };
|
|
287
|
+
}
|
|
288
|
+
throw new Error(`unexpected command: ${key}`);
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
assert.equal(run(['--json'], {
|
|
292
|
+
stdout,
|
|
293
|
+
stderr,
|
|
294
|
+
runner,
|
|
295
|
+
packageVersion: '0.10.4',
|
|
296
|
+
nodePath: '/node',
|
|
297
|
+
canonicalizePath: fakeCanonicalizePath,
|
|
298
|
+
}), 1);
|
|
299
|
+
assert.match(stderr.values.join(''), /migration command failed/u);
|
|
300
|
+
assert.equal(JSON.parse(stdout.values.join('')).stage, 'post_install_protocol_failed');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('post-install structured failure preserves its stage and stderr', () => {
|
|
304
|
+
const stdout = sink();
|
|
305
|
+
const stderr = sink();
|
|
306
|
+
const failure = {
|
|
307
|
+
schema: SELF_UPDATE_SCHEMA,
|
|
308
|
+
status: 'failed',
|
|
309
|
+
stage: 'database_migration_failed',
|
|
310
|
+
version: '0.10.5',
|
|
311
|
+
};
|
|
312
|
+
const runner = (command, args) => {
|
|
313
|
+
const key = [command, ...args].join(' ');
|
|
314
|
+
if (key === 'npm install -g throughline@latest') return ok();
|
|
315
|
+
if (key === 'npm root --global') return ok('/global/lib/node_modules\n');
|
|
316
|
+
if (key.endsWith(' --version')) return ok('0.10.5\n');
|
|
317
|
+
if (key === 'throughline --self-update-identity') {
|
|
318
|
+
return ok(`${JSON.stringify(updateIdentity(
|
|
319
|
+
'/global/lib/node_modules/throughline/bin/throughline.mjs',
|
|
320
|
+
))}\n`);
|
|
321
|
+
}
|
|
322
|
+
if (key.endsWith(' self-update --json')) {
|
|
323
|
+
return { status: 7, stdout: `${JSON.stringify(failure)}\n`, stderr: 'database is locked\n' };
|
|
324
|
+
}
|
|
325
|
+
throw new Error(`unexpected command: ${key}`);
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
assert.equal(run(['--json'], {
|
|
329
|
+
stdout,
|
|
330
|
+
stderr,
|
|
331
|
+
runner,
|
|
332
|
+
packageVersion: '0.10.4',
|
|
333
|
+
nodePath: '/node',
|
|
334
|
+
canonicalizePath: fakeCanonicalizePath,
|
|
335
|
+
}), 7);
|
|
336
|
+
assert.deepEqual(JSON.parse(stdout.values.join('')), failure);
|
|
337
|
+
assert.match(stderr.values.join(''), /database is locked/u);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('global CLI path is derived from the npm global root on Unix and Windows', () => {
|
|
341
|
+
assert.equal(
|
|
342
|
+
resolveGlobalCliPath(ok('/opt/npm/lib/node_modules\n'), 'linux'),
|
|
343
|
+
'/opt/npm/lib/node_modules/throughline/bin/throughline.mjs',
|
|
344
|
+
);
|
|
345
|
+
assert.equal(
|
|
346
|
+
resolveGlobalCliPath(ok('C:\\Users\\kite\\AppData\\Roaming\\npm\\node_modules\r\n'), 'win32'),
|
|
347
|
+
'C:\\Users\\kite\\AppData\\Roaming\\npm\\node_modules\\throughline\\bin\\throughline.mjs',
|
|
348
|
+
);
|
|
349
|
+
assert.equal(resolveGlobalCliPath(ok('relative/node_modules\n'), 'linux'), null);
|
|
350
|
+
assert.equal(resolveGlobalCliPath(ok('/one\n/two\n'), 'linux'), null);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test('public CLI identity must match both the installed version and target path', () => {
|
|
354
|
+
assert.equal(validateSelfUpdateIdentity(updateIdentity('/prefix/bin/throughline.mjs'), {
|
|
355
|
+
version: '0.10.5',
|
|
356
|
+
cliPath: '/prefix/bin/throughline.mjs',
|
|
357
|
+
platform: 'linux',
|
|
358
|
+
canonicalizePath: fakeCanonicalizePath,
|
|
359
|
+
}), true);
|
|
360
|
+
assert.equal(validateSelfUpdateIdentity(updateIdentity('/prefix-a/bin/throughline.mjs'), {
|
|
361
|
+
version: '0.10.5',
|
|
362
|
+
cliPath: '/prefix-b/bin/throughline.mjs',
|
|
363
|
+
platform: 'linux',
|
|
364
|
+
canonicalizePath: fakeCanonicalizePath,
|
|
365
|
+
}), false);
|
|
366
|
+
assert.equal(validateSelfUpdateIdentity(updateIdentity('/prefix/bin/throughline.mjs', '0.10.4'), {
|
|
367
|
+
version: '0.10.5',
|
|
368
|
+
cliPath: '/prefix/bin/throughline.mjs',
|
|
369
|
+
platform: 'linux',
|
|
370
|
+
canonicalizePath: fakeCanonicalizePath,
|
|
371
|
+
}), false);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test('Unix fails closed when PATH still resolves a different global prefix', () => {
|
|
375
|
+
const stdout = sink();
|
|
376
|
+
let childStarted = false;
|
|
377
|
+
const runner = (command, args) => {
|
|
378
|
+
const key = [command, ...args].join(' ');
|
|
379
|
+
if (key === 'npm install -g throughline@latest') return ok();
|
|
380
|
+
if (key === 'npm root --global') return ok('/prefix-b/lib/node_modules\n');
|
|
381
|
+
if (key === '/node /prefix-b/lib/node_modules/throughline/bin/throughline.mjs --version') {
|
|
382
|
+
return ok('0.10.5\n');
|
|
383
|
+
}
|
|
384
|
+
if (key === 'throughline --self-update-identity') {
|
|
385
|
+
return ok(`${JSON.stringify(updateIdentity(
|
|
386
|
+
'/prefix-a/lib/node_modules/throughline/bin/throughline.mjs',
|
|
387
|
+
))}\n`);
|
|
388
|
+
}
|
|
389
|
+
if (key.endsWith(' self-update --json')) childStarted = true;
|
|
390
|
+
throw new Error(`unexpected command: ${key}`);
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
assert.equal(run(['--json'], {
|
|
394
|
+
stdout,
|
|
395
|
+
stderr: sink(),
|
|
396
|
+
runner,
|
|
397
|
+
packageVersion: '0.10.4',
|
|
398
|
+
nodePath: '/node',
|
|
399
|
+
platform: 'linux',
|
|
400
|
+
canonicalizePath: fakeCanonicalizePath,
|
|
401
|
+
}), 1);
|
|
402
|
+
assert.equal(JSON.parse(stdout.values.join('')).stage, 'public_cli_mismatch');
|
|
403
|
+
assert.equal(childStarted, false);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test('Windows self-update uses pwsh and the official npm.cmd entry', () => {
|
|
407
|
+
const calls = [];
|
|
408
|
+
const runner = (command, args, options) => {
|
|
409
|
+
calls.push({ command, args, env: options.env });
|
|
410
|
+
if (command === 'pwsh.exe') {
|
|
411
|
+
if (args[4].includes("'npm.cmd' 'install' '-g' 'throughline@latest'")) return ok();
|
|
412
|
+
if (args[4].includes("'npm.cmd' 'root' '--global'")) {
|
|
413
|
+
return ok('C:\\Users\\kite\\AppData\\Roaming\\npm\\node_modules\r\n');
|
|
414
|
+
}
|
|
415
|
+
if (args[4].includes("'throughline.cmd' '--self-update-identity'")) {
|
|
416
|
+
return ok(`${JSON.stringify(updateIdentity(
|
|
417
|
+
'C:\\Users\\kite\\AppData\\Roaming\\npm\\node_modules\\throughline\\bin\\throughline.mjs',
|
|
418
|
+
))}\r\n`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (command === 'C:\\Program Files\\nodejs\\node.exe' && args.at(-1) === '--version') {
|
|
422
|
+
return ok('0.10.5\r\n');
|
|
423
|
+
}
|
|
424
|
+
if (command === 'C:\\Program Files\\nodejs\\node.exe' && args.at(-2) === 'self-update') {
|
|
425
|
+
return ok(`${JSON.stringify(updateSuccess())}\r\n`);
|
|
426
|
+
}
|
|
427
|
+
throw new Error(`unexpected command: ${command} ${args.join(' ')}`);
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
assert.equal(run(['--json'], {
|
|
431
|
+
stdout: sink(),
|
|
432
|
+
stderr: sink(),
|
|
433
|
+
env: { Path: 'C:\\Program Files\\nodejs' },
|
|
434
|
+
runner,
|
|
435
|
+
packageVersion: '0.10.4',
|
|
436
|
+
nodePath: 'C:\\Program Files\\nodejs\\node.exe',
|
|
437
|
+
platform: 'win32',
|
|
438
|
+
canonicalizePath: fakeCanonicalizePath,
|
|
439
|
+
}), 0);
|
|
440
|
+
assert.equal(calls.filter(({ command }) => command === 'pwsh.exe').length, 3);
|
|
441
|
+
assert.equal(calls.filter(({ command, args }) =>
|
|
442
|
+
command === 'pwsh.exe' && args[4].includes('npm.cmd')).length, 2);
|
|
443
|
+
assert.equal(calls.filter(({ command, args }) =>
|
|
444
|
+
command === 'pwsh.exe' && args[4].includes('throughline.cmd')).length, 1);
|
|
445
|
+
assert.ok(calls.every(({ command }) => command !== 'powershell.exe' && command !== 'npm'));
|
|
446
|
+
assert.deepEqual(calls.at(-1).args.slice(0, 2), [
|
|
447
|
+
'C:\\Users\\kite\\AppData\\Roaming\\npm\\node_modules\\throughline\\bin\\throughline.mjs',
|
|
448
|
+
'self-update',
|
|
449
|
+
]);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test('Windows fails closed when PATH still resolves a different global prefix', () => {
|
|
453
|
+
const stdout = sink();
|
|
454
|
+
let childStarted = false;
|
|
455
|
+
const runner = (command, args) => {
|
|
456
|
+
if (command === 'pwsh.exe') {
|
|
457
|
+
if (args[4].includes("'npm.cmd' 'install' '-g' 'throughline@latest'")) return ok();
|
|
458
|
+
if (args[4].includes("'npm.cmd' 'root' '--global'")) {
|
|
459
|
+
return ok('C:\\prefix-b\\node_modules\r\n');
|
|
460
|
+
}
|
|
461
|
+
if (args[4].includes("'throughline.cmd' '--self-update-identity'")) {
|
|
462
|
+
return ok(`${JSON.stringify(updateIdentity(
|
|
463
|
+
'C:\\prefix-a\\node_modules\\throughline\\bin\\throughline.mjs',
|
|
464
|
+
))}\r\n`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (command === 'C:\\node\\node.exe' && args.at(-1) === '--version') {
|
|
468
|
+
return ok('0.10.5\r\n');
|
|
469
|
+
}
|
|
470
|
+
if (args.at(-2) === 'self-update') childStarted = true;
|
|
471
|
+
throw new Error(`unexpected command: ${command} ${args.join(' ')}`);
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
assert.equal(run(['--json'], {
|
|
475
|
+
stdout,
|
|
476
|
+
stderr: sink(),
|
|
477
|
+
env: { Path: 'C:\\prefix-a;C:\\prefix-b' },
|
|
478
|
+
runner,
|
|
479
|
+
packageVersion: '0.10.4',
|
|
480
|
+
nodePath: 'C:\\node\\node.exe',
|
|
481
|
+
platform: 'win32',
|
|
482
|
+
canonicalizePath: fakeCanonicalizePath,
|
|
483
|
+
}), 1);
|
|
484
|
+
assert.equal(JSON.parse(stdout.values.join('')).stage, 'public_cli_mismatch');
|
|
485
|
+
assert.equal(childStarted, false);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test('Windows post-install registry check also uses pwsh with npm.cmd', () => {
|
|
489
|
+
const calls = [];
|
|
490
|
+
const runner = (command, args) => {
|
|
491
|
+
calls.push([command, ...args]);
|
|
492
|
+
if (command === 'pwsh.exe') return ok('"0.10.5"\r\n');
|
|
493
|
+
const key = [command, ...args].join(' ');
|
|
494
|
+
if (key.endsWith(' install')) return ok();
|
|
495
|
+
if (key.endsWith(' migrate --json')) return ok(`${JSON.stringify(migration())}\n`);
|
|
496
|
+
if (key.endsWith(' factory-diagnostics --json')) {
|
|
497
|
+
return ok(`${JSON.stringify(readyDiagnostics())}\n`);
|
|
498
|
+
}
|
|
499
|
+
throw new Error(`unexpected command: ${key}`);
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
assert.equal(run(['--json'], {
|
|
503
|
+
stdout: sink(),
|
|
504
|
+
stderr: sink(),
|
|
505
|
+
env: {
|
|
506
|
+
THROUGHLINE_SELF_UPDATE_PHASE: 'post-install',
|
|
507
|
+
THROUGHLINE_SELF_UPDATE_BEFORE_VERSION: '0.10.4',
|
|
508
|
+
THROUGHLINE_SELF_UPDATE_EXPECTED_VERSION: '0.10.5',
|
|
509
|
+
},
|
|
510
|
+
runner,
|
|
511
|
+
packageVersion: '0.10.5',
|
|
512
|
+
nodePath: 'C:\\Program Files\\nodejs\\node.exe',
|
|
513
|
+
cliPath: 'C:\\npm\\node_modules\\throughline\\bin\\throughline.mjs',
|
|
514
|
+
platform: 'win32',
|
|
515
|
+
}), 0);
|
|
516
|
+
assert.equal(calls[0][0], 'pwsh.exe');
|
|
517
|
+
assert.match(calls[0][5], /npm\.cmd/u);
|
|
518
|
+
assert.ok(calls.every(([command]) => command !== 'powershell.exe'));
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test('self-update accepts only the public default and JSON forms', () => {
|
|
522
|
+
assert.deepEqual(parseArgs([]), { json: false });
|
|
523
|
+
assert.deepEqual(parseArgs(['--json']), { json: true });
|
|
524
|
+
assert.throws(() => parseArgs(['--force']), /usage error/u);
|
|
525
|
+
});
|
package/src/db.mjs
CHANGED
|
@@ -181,7 +181,7 @@ function initSchema(db) {
|
|
|
181
181
|
// v5 → v6: handoff_batons テーブル追加(/tl スラッシュコマンドによる明示的引き継ぎ指名用)
|
|
182
182
|
// - project_path ごとに最新 1 件のみ (PRIMARY KEY)
|
|
183
183
|
// - SessionStart で読み出し、TTL 以内なら merge して DELETE
|
|
184
|
-
// - docs/03_inheritance_on_clear_only.md 参照: 案 D (時間差) 撤去、バトン方式へ移行
|
|
184
|
+
// - docs/archive/03_inheritance_on_clear_only.md 参照: 案 D (時間差) 撤去、バトン方式へ移行
|
|
185
185
|
if (version < 6) {
|
|
186
186
|
db.exec(`
|
|
187
187
|
CREATE TABLE IF NOT EXISTS handoff_batons (
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
extractMarkdownTargets,
|
|
9
|
+
findMissingPackedTargets,
|
|
10
|
+
} from '../scripts/verify-pack-markdown-links.mjs';
|
|
11
|
+
|
|
12
|
+
test('Markdown-only CI runs the product-owned documentation contract', () => {
|
|
13
|
+
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
14
|
+
assert.equal(
|
|
15
|
+
packageJson.scripts['verify:docs'],
|
|
16
|
+
'node scripts/verify-docs.mjs && node scripts/verify-pack-markdown-links.mjs',
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const workflow = readFileSync(new URL('../.github/workflows/test.yml', import.meta.url), 'utf8');
|
|
20
|
+
assert.match(workflow, /uses:\s+\.\/\.github\/workflows\/product-full-ci\.yml/);
|
|
21
|
+
assert.match(
|
|
22
|
+
workflow,
|
|
23
|
+
/dependency-command:\s+npm install --ignore-scripts --no-package-lock --no-audit --no-fund/,
|
|
24
|
+
);
|
|
25
|
+
assert.match(
|
|
26
|
+
workflow,
|
|
27
|
+
/documentation-command:\s+npm install --ignore-scripts --no-package-lock --no-audit --no-fund && npm run verify:docs/,
|
|
28
|
+
);
|
|
29
|
+
assert.doesNotMatch(workflow, /documentation-command:\s*(?:["']{2})?\s*$/m);
|
|
30
|
+
|
|
31
|
+
const reusable = readFileSync(new URL('../.github/workflows/product-full-ci.yml', import.meta.url), 'utf8');
|
|
32
|
+
assert.match(reusable, /setup documentation Node[\s\S]*actions\/setup-node@[0-9a-f]{40}[\s\S]*node-version:\s*22/);
|
|
33
|
+
assert.ok(
|
|
34
|
+
reusable.indexOf('setup documentation Node') < reusable.indexOf('- name: documentation check'),
|
|
35
|
+
'documentation Node must be configured before dependency installation and verification',
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
39
|
+
const result = spawnSync(npmCommand, ['run', 'verify:docs', '--silent'], {
|
|
40
|
+
cwd: fileURLToPath(new URL('..', import.meta.url)),
|
|
41
|
+
encoding: 'utf8',
|
|
42
|
+
});
|
|
43
|
+
assert.equal(result.status, 0, result.stderr);
|
|
44
|
+
assert.match(result.stdout, /docs verified:/);
|
|
45
|
+
assert.match(result.stdout, /packed Markdown links:/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('release candidate version is synchronized across current product documents', () => {
|
|
49
|
+
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
50
|
+
const version = packageJson.version;
|
|
51
|
+
const currentSources = [
|
|
52
|
+
readFileSync(new URL('../README.md', import.meta.url), 'utf8'),
|
|
53
|
+
readFileSync(new URL('../CLAUDE.md', import.meta.url), 'utf8'),
|
|
54
|
+
readFileSync(new URL('../docs/04_public_release_plan.md', import.meta.url), 'utf8'),
|
|
55
|
+
];
|
|
56
|
+
for (const source of currentSources) assert.match(source, new RegExp(`\\bv?${version.replaceAll('.', '\\.')}\\b`));
|
|
57
|
+
|
|
58
|
+
const changelog = readFileSync(new URL('../CHANGELOG.md', import.meta.url), 'utf8');
|
|
59
|
+
assert.match(changelog, new RegExp(`^## \\[${version.replaceAll('.', '\\.')}\\]`, 'm'));
|
|
60
|
+
assert.match(changelog, new RegExp(`^\\[Unreleased\\]: .+/compare/v${version.replaceAll('.', '\\.')}\\.\\.\\.HEAD$`, 'm'));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('packed Markdown closure covers nested links, balanced targets, references, and HTML images', () => {
|
|
64
|
+
const markdown = [
|
|
65
|
+
'[](docs/outer.md)',
|
|
66
|
+
'[balanced](docs/name_(draft).md)',
|
|
67
|
+
'',
|
|
68
|
+
'[reference]: docs/reference.md "title"',
|
|
69
|
+
'[continued]:',
|
|
70
|
+
' docs/continued.md',
|
|
71
|
+
'',
|
|
72
|
+
'<img src="images/hero.png" srcset="images/hero.png 1x, images/hero@2x.png 2x">',
|
|
73
|
+
'`[ignored](missing.md)`',
|
|
74
|
+
'```md',
|
|
75
|
+
'[also ignored](missing.md)',
|
|
76
|
+
'```',
|
|
77
|
+
].join('\n');
|
|
78
|
+
|
|
79
|
+
assert.deepEqual(
|
|
80
|
+
extractMarkdownTargets(markdown).map(({ raw }) => raw),
|
|
81
|
+
[
|
|
82
|
+
'docs/outer.md',
|
|
83
|
+
'docs/inner.md',
|
|
84
|
+
'docs/name_(draft).md',
|
|
85
|
+
'docs/reference.md',
|
|
86
|
+
'docs/continued.md',
|
|
87
|
+
'images/hero.png',
|
|
88
|
+
'images/hero.png',
|
|
89
|
+
'images/hero@2x.png',
|
|
90
|
+
],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const packed = new Set([
|
|
94
|
+
'README.md',
|
|
95
|
+
'docs/inner.md',
|
|
96
|
+
'docs/outer.md',
|
|
97
|
+
'docs/name_(draft).md',
|
|
98
|
+
'docs/reference.md',
|
|
99
|
+
'docs/continued.md',
|
|
100
|
+
'images/hero.png',
|
|
101
|
+
]);
|
|
102
|
+
const result = findMissingPackedTargets('README.md', markdown, packed);
|
|
103
|
+
assert.equal(result.checked, 8);
|
|
104
|
+
assert.deepEqual(result.failures, [
|
|
105
|
+
'README.md:8: tarball外の相対target images/hero@2x.png',
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('documentation parser follows CommonMark code, reference, entity, and HTML attribute boundaries', () => {
|
|
110
|
+
const markdown = [
|
|
111
|
+
' [indented code](missing-indented.md)',
|
|
112
|
+
'``code [real](docs/real.md)```',
|
|
113
|
+
'',
|
|
114
|
+
'[escaped\\]]: docs/escaped.md',
|
|
115
|
+
'> [quoted]: docs/quoted.md',
|
|
116
|
+
'- [listed]: docs/listed.md',
|
|
117
|
+
'[bad]: docs/not-a-definition.md "unterminated',
|
|
118
|
+
'',
|
|
119
|
+
'[entity](assets/a&b.png)',
|
|
120
|
+
'[not a link](',
|
|
121
|
+
'',
|
|
122
|
+
'missing-blank.md)',
|
|
123
|
+
'<div title="note href=\'missing-fake.md\'"><img src="assets/real.png"></div>',
|
|
124
|
+
'<source srcset="assets/crop,wide.png 1x, assets/next.png 2x">',
|
|
125
|
+
].join('\n');
|
|
126
|
+
|
|
127
|
+
assert.deepEqual(
|
|
128
|
+
extractMarkdownTargets(markdown).map(({ raw }) => raw),
|
|
129
|
+
[
|
|
130
|
+
'docs/real.md',
|
|
131
|
+
'docs/escaped.md',
|
|
132
|
+
'docs/quoted.md',
|
|
133
|
+
'docs/listed.md',
|
|
134
|
+
'assets/a&b.png',
|
|
135
|
+
'assets/real.png',
|
|
136
|
+
'assets/crop,wide.png',
|
|
137
|
+
'assets/next.png',
|
|
138
|
+
],
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('current product docs expose one self-update entry instead of a manual update sequence', () => {
|
|
143
|
+
const root = new URL('..', import.meta.url);
|
|
144
|
+
const read = (path) => readFileSync(new URL(path, root), 'utf8');
|
|
145
|
+
for (const path of ['README.md', 'README.ja.md', 'docs/04_public_release_plan.md']) {
|
|
146
|
+
assert.match(read(path), /throughline self-update/u, `${path} is missing the product update entry`);
|
|
147
|
+
}
|
|
148
|
+
const releaseContract = read('docs/04_public_release_plan.md');
|
|
149
|
+
assert.doesNotMatch(
|
|
150
|
+
releaseContract,
|
|
151
|
+
/npm install -g throughline@latest.*throughline install.*throughline migrate.*throughline doctor/su,
|
|
152
|
+
);
|
|
153
|
+
});
|