openplanr 1.8.1 → 1.9.0
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 +38 -20
- package/dist/cli/commands/doctor.d.ts +3 -0
- package/dist/cli/commands/doctor.d.ts.map +1 -0
- package/dist/cli/commands/doctor.js +52 -0
- package/dist/cli/commands/doctor.js.map +1 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +14 -14
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/pipeline.d.ts +3 -0
- package/dist/cli/commands/pipeline.d.ts.map +1 -0
- package/dist/cli/commands/pipeline.js +51 -0
- package/dist/cli/commands/pipeline.js.map +1 -0
- package/dist/cli/commands/runtime.d.ts +3 -0
- package/dist/cli/commands/runtime.d.ts.map +1 -0
- package/dist/cli/commands/runtime.js +82 -0
- package/dist/cli/commands/runtime.js.map +1 -0
- package/dist/cli/commands/setup.d.ts +3 -0
- package/dist/cli/commands/setup.d.ts.map +1 -0
- package/dist/cli/commands/setup.js +59 -0
- package/dist/cli/commands/setup.js.map +1 -0
- package/dist/cli/index.js +23 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/generators/codex-generator.d.ts.map +1 -1
- package/dist/generators/codex-generator.js +6 -3
- package/dist/generators/codex-generator.js.map +1 -1
- package/dist/generators/cursor-generator.d.ts +0 -2
- package/dist/generators/cursor-generator.d.ts.map +1 -1
- package/dist/generators/cursor-generator.js +28 -46
- package/dist/generators/cursor-generator.js.map +1 -1
- package/dist/services/artifact-service.d.ts.map +1 -1
- package/dist/services/artifact-service.js +2 -1
- package/dist/services/artifact-service.js.map +1 -1
- package/dist/services/atomic-write-service.d.ts.map +1 -1
- package/dist/services/atomic-write-service.js +3 -1
- package/dist/services/atomic-write-service.js.map +1 -1
- package/dist/services/pipeline-package-service.d.ts +9 -0
- package/dist/services/pipeline-package-service.d.ts.map +1 -0
- package/dist/services/pipeline-package-service.js +45 -0
- package/dist/services/pipeline-package-service.js.map +1 -0
- package/dist/services/provenance-service.d.ts +13 -0
- package/dist/services/provenance-service.d.ts.map +1 -0
- package/dist/services/provenance-service.js +48 -0
- package/dist/services/provenance-service.js.map +1 -0
- package/dist/services/revise-plan-service.js +3 -1
- package/dist/services/revise-plan-service.js.map +1 -1
- package/dist/services/runtime-manager-service.d.ts +79 -0
- package/dist/services/runtime-manager-service.d.ts.map +1 -0
- package/dist/services/runtime-manager-service.js +772 -0
- package/dist/services/runtime-manager-service.js.map +1 -0
- package/dist/services/spec-service.d.ts +4 -3
- package/dist/services/spec-service.d.ts.map +1 -1
- package/dist/services/spec-service.js +14 -3
- package/dist/services/spec-service.js.map +1 -1
- package/install.ps1 +35 -0
- package/install.sh +48 -0
- package/package.json +7 -2
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { copyFile, mkdir, open, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { spliceManagedBlock } from '../utils/splice-managed-block.js';
|
|
8
|
+
import { resolvePipelinePackage } from './pipeline-package-service.js';
|
|
9
|
+
import { readOpenPlanrVersion } from './provenance-service.js';
|
|
10
|
+
export class RuntimeManagerError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
recovery;
|
|
13
|
+
constructor(code, message, recovery) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.recovery = recovery;
|
|
17
|
+
this.name = 'RuntimeManagerError';
|
|
18
|
+
}
|
|
19
|
+
toJSON() {
|
|
20
|
+
return { ok: false, code: this.code, problem: this.message, recovery: this.recovery };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const executable = {
|
|
24
|
+
'claude-code': 'claude',
|
|
25
|
+
codex: 'codex',
|
|
26
|
+
cursor: 'cursor',
|
|
27
|
+
};
|
|
28
|
+
const skillNames = [
|
|
29
|
+
'planr-plan',
|
|
30
|
+
'planr-design',
|
|
31
|
+
'planr-ship',
|
|
32
|
+
'planr-dashboard',
|
|
33
|
+
'planr-sync',
|
|
34
|
+
'planr-doctor',
|
|
35
|
+
];
|
|
36
|
+
function hash(value) {
|
|
37
|
+
return createHash('sha256').update(value).digest('hex');
|
|
38
|
+
}
|
|
39
|
+
function managedBlockBytes(content, marker = 'runtime') {
|
|
40
|
+
const text = Buffer.isBuffer(content) ? content.toString('utf8') : content;
|
|
41
|
+
const begin = `<!-- ##planr-${marker}:begin##`;
|
|
42
|
+
const end = `<!-- ##planr-${marker}:end## -->`;
|
|
43
|
+
const start = text.indexOf(begin);
|
|
44
|
+
const finish = text.indexOf(end, start);
|
|
45
|
+
if (start === -1 || finish === -1)
|
|
46
|
+
return Buffer.alloc(0);
|
|
47
|
+
return Buffer.from(text.slice(start, finish + end.length));
|
|
48
|
+
}
|
|
49
|
+
function ownershipHash(content, kind, marker) {
|
|
50
|
+
return hash(kind === 'managed-block' ? managedBlockBytes(content, marker) : content);
|
|
51
|
+
}
|
|
52
|
+
function projectKey(projectDir) {
|
|
53
|
+
return hash(path.resolve(projectDir)).slice(0, 16);
|
|
54
|
+
}
|
|
55
|
+
function runtimeRoot() {
|
|
56
|
+
return path.join(userHome(), '.planr', 'runtime');
|
|
57
|
+
}
|
|
58
|
+
function userHome() {
|
|
59
|
+
return process.env.OPENPLANR_HOME ?? os.homedir();
|
|
60
|
+
}
|
|
61
|
+
function statePath() {
|
|
62
|
+
return path.join(runtimeRoot(), 'state.json');
|
|
63
|
+
}
|
|
64
|
+
function detectCommand(command) {
|
|
65
|
+
const result = spawnSync(command, ['--version'], { encoding: 'utf8', windowsHide: true });
|
|
66
|
+
return !result.error && result.status === 0;
|
|
67
|
+
}
|
|
68
|
+
export function detectRuntimes() {
|
|
69
|
+
return Object.keys(executable).map((runtime) => ({
|
|
70
|
+
runtime,
|
|
71
|
+
installed: detectCommand(executable[runtime]),
|
|
72
|
+
command: executable[runtime],
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
export function listRuntimeAdapters() {
|
|
76
|
+
const pipeline = resolvePipelinePackage(false);
|
|
77
|
+
if (!pipeline)
|
|
78
|
+
return [];
|
|
79
|
+
const registry = JSON.parse(readFileSync(pipeline.adapterRegistryPath, 'utf8'));
|
|
80
|
+
return registry.adapters.map((adapter) => structuredClone(adapter));
|
|
81
|
+
}
|
|
82
|
+
function assertNodeVersion() {
|
|
83
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
84
|
+
if (major < 20) {
|
|
85
|
+
throw new RuntimeManagerError('E_NODE_VERSION', `Node.js 20 or newer is required; found ${process.versions.node}.`, 'Install Node.js 20+ and rerun `planr setup`. OpenPlanr will not modify Node.js for you.');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function normalizeRuntime(value = 'auto') {
|
|
89
|
+
return value === 'claude' ? 'claude-code' : value;
|
|
90
|
+
}
|
|
91
|
+
function chooseRuntimes(choice) {
|
|
92
|
+
const normalized = normalizeRuntime(choice);
|
|
93
|
+
if (normalized === 'all')
|
|
94
|
+
return ['claude-code', 'codex', 'cursor'];
|
|
95
|
+
if (normalized !== 'auto') {
|
|
96
|
+
if (!['claude-code', 'codex', 'cursor'].includes(normalized)) {
|
|
97
|
+
throw new RuntimeManagerError('E_RUNTIME_UNSUPPORTED', `Runtime "${normalized}" is not supported.`, 'Choose auto, claude, codex, cursor, or all.');
|
|
98
|
+
}
|
|
99
|
+
return [normalized];
|
|
100
|
+
}
|
|
101
|
+
const detected = detectRuntimes()
|
|
102
|
+
.filter((item) => item.installed)
|
|
103
|
+
.map((item) => item.runtime);
|
|
104
|
+
if (detected.length === 0) {
|
|
105
|
+
throw new RuntimeManagerError('E_RUNTIME_NOT_FOUND', 'No supported coding runtime was detected.', 'Install Claude Code, Codex, or Cursor, or pass `--runtime all` to prepare adapter assets.');
|
|
106
|
+
}
|
|
107
|
+
return detected;
|
|
108
|
+
}
|
|
109
|
+
function readRegistry() {
|
|
110
|
+
const pipeline = resolvePipelinePackage();
|
|
111
|
+
if (!pipeline)
|
|
112
|
+
throw new RuntimeManagerError('E_PIPELINE_NOT_INSTALLED', 'Pipeline missing.');
|
|
113
|
+
return {
|
|
114
|
+
root: pipeline.root,
|
|
115
|
+
version: pipeline.version,
|
|
116
|
+
registry: JSON.parse(readFileSync(pipeline.adapterRegistryPath, 'utf8')),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function managedContent(text) {
|
|
120
|
+
return text
|
|
121
|
+
.replace(/^<!-- openplanr:runtime:start -->\s*/m, '')
|
|
122
|
+
.replace(/\s*<!-- openplanr:runtime:end -->\s*$/m, '')
|
|
123
|
+
.trim();
|
|
124
|
+
}
|
|
125
|
+
function actionBytes(action) {
|
|
126
|
+
if (action.kind === 'file')
|
|
127
|
+
return action.content;
|
|
128
|
+
const existing = existsSync(action.target) ? readFileSync(action.target, 'utf8') : '';
|
|
129
|
+
const spliced = spliceManagedBlock(existing, action.marker ?? 'runtime', action.content.toString('utf8'));
|
|
130
|
+
return Buffer.from(spliced.endsWith('\n') ? spliced : `${spliced}\n`);
|
|
131
|
+
}
|
|
132
|
+
function runtimeMarker(runtime, pipelineVersion) {
|
|
133
|
+
return Buffer.from(`${JSON.stringify({ schemaVersion: '1.0.0', runtime, pipelineVersion, managedBy: 'openplanr' }, null, 2)}\n`);
|
|
134
|
+
}
|
|
135
|
+
function normalizeInstallScope(adapter, requested) {
|
|
136
|
+
const supportsUser = adapter.installScopes.includes('user');
|
|
137
|
+
const supportsProject = adapter.installScopes.includes('project');
|
|
138
|
+
if (requested === 'user' && !supportsUser) {
|
|
139
|
+
throw new RuntimeManagerError('E_SCOPE_UNSUPPORTED', `${adapter.id} does not support user-scope installation.`, `Run \`planr runtime install ${adapter.id} --scope project\`.`);
|
|
140
|
+
}
|
|
141
|
+
if (requested === 'project' && !supportsProject) {
|
|
142
|
+
throw new RuntimeManagerError('E_SCOPE_UNSUPPORTED', `${adapter.id} does not support project-scope installation.`);
|
|
143
|
+
}
|
|
144
|
+
if (requested !== 'both')
|
|
145
|
+
return requested;
|
|
146
|
+
if (supportsUser && supportsProject)
|
|
147
|
+
return 'both';
|
|
148
|
+
return supportsProject ? 'project' : 'user';
|
|
149
|
+
}
|
|
150
|
+
function inferRuntimeScope(files, runtime) {
|
|
151
|
+
const owned = files.filter((file) => file.runtime === runtime);
|
|
152
|
+
const hasUser = owned.some((file) => file.scope === 'user' || (!file.scope && file.target.startsWith(runtimeRoot())));
|
|
153
|
+
const hasProject = owned.some((file) => file.scope === 'project' || (!file.scope && !file.target.startsWith(runtimeRoot())));
|
|
154
|
+
return hasUser && hasProject ? 'both' : hasUser ? 'user' : 'project';
|
|
155
|
+
}
|
|
156
|
+
function buildRuntimeLock(options, runtimes, runtimeScopes, registry, pipelineVersion) {
|
|
157
|
+
const adapters = runtimes.map((runtime) => {
|
|
158
|
+
const adapter = registry.adapters.find((entry) => entry.id === runtime);
|
|
159
|
+
if (!adapter)
|
|
160
|
+
throw new RuntimeManagerError('E_ADAPTER_MISSING', `Adapter ${runtime} is absent.`);
|
|
161
|
+
const installScope = normalizeInstallScope(adapter, runtimeScopes[runtime] ?? options.scope ?? 'both');
|
|
162
|
+
return {
|
|
163
|
+
runtime,
|
|
164
|
+
version: adapter.version,
|
|
165
|
+
capabilityLevel: adapter.capabilityLevel,
|
|
166
|
+
installScope,
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
const digestInput = JSON.stringify({
|
|
170
|
+
protocol: registry.protocolVersion,
|
|
171
|
+
pipelineVersion,
|
|
172
|
+
adapters,
|
|
173
|
+
});
|
|
174
|
+
const components = { cli: options.cliVersion, pipeline: pipelineVersion, skills: '1.12.0' };
|
|
175
|
+
const manifestDigest = `sha256:${hash(digestInput)}`;
|
|
176
|
+
const lockPath = path.join(options.projectDir, '.planr', 'runtime-lock.json');
|
|
177
|
+
if (existsSync(lockPath)) {
|
|
178
|
+
try {
|
|
179
|
+
const existing = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
180
|
+
if (existing.manifestDigest === manifestDigest &&
|
|
181
|
+
existing.protocolVersion === registry.protocolVersion &&
|
|
182
|
+
JSON.stringify(existing.components) === JSON.stringify(components) &&
|
|
183
|
+
JSON.stringify(existing.adapters) === JSON.stringify(adapters)) {
|
|
184
|
+
return readFileSync(lockPath);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// Invalid locks are replaced after backup during setup.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return Buffer.from(`${JSON.stringify({
|
|
192
|
+
schemaVersion: '1.0.0',
|
|
193
|
+
generatedAt: new Date().toISOString(),
|
|
194
|
+
manifestDigest,
|
|
195
|
+
protocolVersion: registry.protocolVersion,
|
|
196
|
+
components,
|
|
197
|
+
adapters,
|
|
198
|
+
}, null, 2)}\n`);
|
|
199
|
+
}
|
|
200
|
+
function buildActions(options, runtimes, runtimeScopes) {
|
|
201
|
+
if (options.minimal)
|
|
202
|
+
return [];
|
|
203
|
+
const { root, version, registry } = readRegistry();
|
|
204
|
+
if (options.version && options.version !== version) {
|
|
205
|
+
throw new RuntimeManagerError('E_VERSION_UNAVAILABLE', `Installed pipeline ${version} does not match requested ${options.version}.`, `Install planr-pipeline@${options.version} and rerun setup.`);
|
|
206
|
+
}
|
|
207
|
+
const actions = [];
|
|
208
|
+
for (const runtime of runtimes) {
|
|
209
|
+
const adapter = registry.adapters.find((entry) => entry.id === runtime);
|
|
210
|
+
if (!adapter)
|
|
211
|
+
throw new RuntimeManagerError('E_ADAPTER_MISSING', `Adapter ${runtime} is absent.`);
|
|
212
|
+
const scope = normalizeInstallScope(adapter, runtimeScopes[runtime] ?? options.scope ?? 'both');
|
|
213
|
+
const installUser = (scope === 'user' || scope === 'both') && adapter.installScopes.includes('user');
|
|
214
|
+
const installProject = (scope === 'project' || scope === 'both') && adapter.installScopes.includes('project');
|
|
215
|
+
if (installUser) {
|
|
216
|
+
if (runtime === 'codex') {
|
|
217
|
+
for (const name of skillNames) {
|
|
218
|
+
actions.push({
|
|
219
|
+
runtime,
|
|
220
|
+
scope: 'user',
|
|
221
|
+
target: path.join(userHome(), '.codex', 'skills', name, 'SKILL.md'),
|
|
222
|
+
content: readFileSync(path.join(root, 'adapters', 'codex', 'skills', name, 'SKILL.md')),
|
|
223
|
+
kind: 'file',
|
|
224
|
+
description: `Install Codex skill ${name}`,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
actions.push({
|
|
229
|
+
runtime,
|
|
230
|
+
scope: 'user',
|
|
231
|
+
target: path.join(runtimeRoot(), 'adapters', `${runtime}.json`),
|
|
232
|
+
content: runtimeMarker(runtime, version),
|
|
233
|
+
kind: 'file',
|
|
234
|
+
description: `Record ${runtime} adapter installation`,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
if (installProject) {
|
|
238
|
+
if (runtime === 'codex') {
|
|
239
|
+
actions.push({
|
|
240
|
+
runtime,
|
|
241
|
+
scope: 'project',
|
|
242
|
+
target: path.join(options.projectDir, 'AGENTS.md'),
|
|
243
|
+
content: Buffer.from(managedContent(readFileSync(path.join(root, 'adapters', 'codex', 'project-guidance.md'), 'utf8'))),
|
|
244
|
+
kind: 'managed-block',
|
|
245
|
+
marker: 'pipeline',
|
|
246
|
+
description: 'Update concise Codex project policy',
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
else if (runtime === 'cursor') {
|
|
250
|
+
actions.push({
|
|
251
|
+
runtime,
|
|
252
|
+
scope: 'project',
|
|
253
|
+
target: path.join(options.projectDir, '.cursor', 'rules', 'openplanr.mdc'),
|
|
254
|
+
content: readFileSync(path.join(root, 'adapters', 'cursor', 'rules', 'openplanr.mdc')),
|
|
255
|
+
kind: 'file',
|
|
256
|
+
description: 'Install portable Cursor project rule',
|
|
257
|
+
});
|
|
258
|
+
const roleRegistry = JSON.parse(readFileSync(path.join(root, 'registry', 'roles.json'), 'utf8'));
|
|
259
|
+
for (const role of roleRegistry.roles) {
|
|
260
|
+
actions.push({
|
|
261
|
+
runtime,
|
|
262
|
+
scope: 'project',
|
|
263
|
+
target: path.join(options.projectDir, '.cursor', 'rules', 'openplanr-roles', `${role.id}.md`),
|
|
264
|
+
content: Buffer.from(`# ${role.id}\n\nCapability tier: \`${role.capability}\`\nPhase: \`${role.phase}\`\nActivation: \`${role.activation}\`\n\n- ${role.writeBoundary}\n`),
|
|
265
|
+
kind: 'file',
|
|
266
|
+
description: `Install Cursor role ${role.id}`,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
actions.push({
|
|
272
|
+
runtime,
|
|
273
|
+
scope: 'project',
|
|
274
|
+
target: path.join(options.projectDir, 'CLAUDE.md'),
|
|
275
|
+
content: Buffer.from('Use the native planr-pipeline plugin for PLAN, Design, SHIP, dashboard, sync, and doctor. Portable procedures and deterministic state are supplied by planr-pipeline. PLAN and SHIP remain separate user actions.'),
|
|
276
|
+
kind: 'managed-block',
|
|
277
|
+
marker: 'pipeline',
|
|
278
|
+
description: 'Update Claude Code project policy',
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (runtimes.some((runtime) => {
|
|
284
|
+
const scope = runtimeScopes[runtime] ?? options.scope ?? 'both';
|
|
285
|
+
return scope === 'project' || scope === 'both';
|
|
286
|
+
})) {
|
|
287
|
+
actions.push({
|
|
288
|
+
runtime: 'core',
|
|
289
|
+
scope: 'project',
|
|
290
|
+
target: path.join(options.projectDir, '.planr', 'runtime-lock.json'),
|
|
291
|
+
content: buildRuntimeLock(options, runtimes, runtimeScopes, registry, version),
|
|
292
|
+
kind: 'file',
|
|
293
|
+
description: 'Write exact runtime compatibility lock',
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
return actions;
|
|
297
|
+
}
|
|
298
|
+
function operationFor(action) {
|
|
299
|
+
if (!existsSync(action.target))
|
|
300
|
+
return 'create';
|
|
301
|
+
return hash(readFileSync(action.target)) === hash(actionBytes(action)) ? 'unchanged' : 'update';
|
|
302
|
+
}
|
|
303
|
+
async function loadState() {
|
|
304
|
+
try {
|
|
305
|
+
return JSON.parse(await readFile(statePath(), 'utf8'));
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return { schemaVersion: '1.0.0', projects: {} };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
async function atomicWrite(target, content) {
|
|
312
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
313
|
+
const temp = `${target}.${process.pid}.tmp`;
|
|
314
|
+
await writeFile(temp, content, { mode: 0o600 });
|
|
315
|
+
await rename(temp, target);
|
|
316
|
+
}
|
|
317
|
+
async function createBackup(projectDir, actions) {
|
|
318
|
+
const stamp = new Date().toISOString().replaceAll(':', '-');
|
|
319
|
+
const dir = path.join(userHome(), '.planr', 'backups', projectKey(projectDir), stamp);
|
|
320
|
+
const manifest = {
|
|
321
|
+
schemaVersion: '1.0.0',
|
|
322
|
+
projectDir: path.resolve(projectDir),
|
|
323
|
+
createdAt: new Date().toISOString(),
|
|
324
|
+
files: [],
|
|
325
|
+
};
|
|
326
|
+
await mkdir(dir, { recursive: true });
|
|
327
|
+
for (const [index, action] of actions.entries()) {
|
|
328
|
+
const entry = { target: action.target, existed: existsSync(action.target) };
|
|
329
|
+
if (entry.existed) {
|
|
330
|
+
const backup = path.join(dir, 'files', `${String(index).padStart(3, '0')}-${path.basename(action.target)}`);
|
|
331
|
+
await mkdir(path.dirname(backup), { recursive: true });
|
|
332
|
+
await copyFile(action.target, backup);
|
|
333
|
+
entry.backup = backup;
|
|
334
|
+
entry.beforeHash = hash(await readFile(action.target));
|
|
335
|
+
}
|
|
336
|
+
manifest.files.push(entry);
|
|
337
|
+
}
|
|
338
|
+
await atomicWrite(path.join(dir, 'migration-manifest.json'), Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`));
|
|
339
|
+
return { dir, manifest };
|
|
340
|
+
}
|
|
341
|
+
export async function previewSetup(options) {
|
|
342
|
+
assertNodeVersion();
|
|
343
|
+
if (!['user', 'project', 'both'].includes(options.scope ?? 'both')) {
|
|
344
|
+
throw new RuntimeManagerError('E_SCOPE_INVALID', `Install scope "${options.scope}" is invalid.`, 'Choose user, project, or both.');
|
|
345
|
+
}
|
|
346
|
+
const selectedRuntimes = options.minimal ? [] : chooseRuntimes(options.runtime ?? 'auto');
|
|
347
|
+
let runtimes = selectedRuntimes;
|
|
348
|
+
const runtimeScopes = {};
|
|
349
|
+
if (options.merge && !options.minimal) {
|
|
350
|
+
const state = await loadState();
|
|
351
|
+
const project = state.projects[projectKey(options.projectDir)];
|
|
352
|
+
const existing = project?.runtimes ?? [];
|
|
353
|
+
for (const runtime of existing) {
|
|
354
|
+
runtimeScopes[runtime] =
|
|
355
|
+
project?.runtimeScopes?.[runtime] ?? inferRuntimeScope(project?.ownedFiles ?? [], runtime);
|
|
356
|
+
}
|
|
357
|
+
runtimes = [...new Set([...existing, ...runtimes])];
|
|
358
|
+
}
|
|
359
|
+
for (const runtime of selectedRuntimes)
|
|
360
|
+
runtimeScopes[runtime] = options.scope ?? 'both';
|
|
361
|
+
const pipeline = options.minimal ? null : resolvePipelinePackage();
|
|
362
|
+
if (!options.minimal) {
|
|
363
|
+
const { registry } = readRegistry();
|
|
364
|
+
for (const runtime of runtimes) {
|
|
365
|
+
const adapter = registry.adapters.find((entry) => entry.id === runtime);
|
|
366
|
+
if (!adapter)
|
|
367
|
+
throw new RuntimeManagerError('E_ADAPTER_MISSING', `Adapter ${runtime} is absent.`);
|
|
368
|
+
runtimeScopes[runtime] = normalizeInstallScope(adapter, runtimeScopes[runtime] ?? options.scope ?? 'both');
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const actions = buildActions(options, runtimes, runtimeScopes);
|
|
372
|
+
return {
|
|
373
|
+
ok: true,
|
|
374
|
+
dryRun: Boolean(options.dryRun),
|
|
375
|
+
minimal: Boolean(options.minimal),
|
|
376
|
+
runtimes,
|
|
377
|
+
runtimeScopes,
|
|
378
|
+
scope: options.scope ?? 'both',
|
|
379
|
+
pipelineVersion: pipeline?.version ?? null,
|
|
380
|
+
actions: actions.map((action) => ({
|
|
381
|
+
runtime: action.runtime,
|
|
382
|
+
scope: action.scope,
|
|
383
|
+
target: action.target,
|
|
384
|
+
operation: operationFor(action),
|
|
385
|
+
description: action.description,
|
|
386
|
+
})),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
export async function applySetup(options) {
|
|
390
|
+
const preview = await previewSetup(options);
|
|
391
|
+
if (options.dryRun || options.minimal)
|
|
392
|
+
return preview;
|
|
393
|
+
await mkdir(runtimeRoot(), { recursive: true });
|
|
394
|
+
const lockPath = path.join(runtimeRoot(), 'setup.lock');
|
|
395
|
+
let lockHandle;
|
|
396
|
+
try {
|
|
397
|
+
lockHandle = await open(lockPath, 'wx', 0o600);
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
throw new RuntimeManagerError('E_SETUP_BUSY', 'Another OpenPlanr setup or migration is already running.', 'Wait for it to finish. If no process is running, remove the verified stale setup lock.');
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
const actions = buildActions(options, preview.runtimes, preview.runtimeScopes);
|
|
404
|
+
const changed = actions.filter((action) => operationFor(action) !== 'unchanged');
|
|
405
|
+
if (changed.length === 0)
|
|
406
|
+
return preview;
|
|
407
|
+
let backup;
|
|
408
|
+
try {
|
|
409
|
+
backup = await createBackup(options.projectDir, changed);
|
|
410
|
+
}
|
|
411
|
+
catch (cause) {
|
|
412
|
+
throw new RuntimeManagerError('E_BACKUP_FAILED', `Could not create byte-for-byte migration backup: ${cause instanceof Error ? cause.message : String(cause)}`, 'No setup files were changed. Fix backup permissions and rerun setup.');
|
|
413
|
+
}
|
|
414
|
+
const owned = [];
|
|
415
|
+
for (const action of actions) {
|
|
416
|
+
const content = actionBytes(action);
|
|
417
|
+
await atomicWrite(action.target, content);
|
|
418
|
+
owned.push({
|
|
419
|
+
runtime: action.runtime,
|
|
420
|
+
scope: action.scope,
|
|
421
|
+
target: action.target,
|
|
422
|
+
kind: action.kind,
|
|
423
|
+
marker: action.marker,
|
|
424
|
+
hash: ownershipHash(content, action.kind, action.marker),
|
|
425
|
+
});
|
|
426
|
+
const backupEntry = backup.manifest.files.find((entry) => entry.target === action.target);
|
|
427
|
+
if (backupEntry)
|
|
428
|
+
backupEntry.afterHash = hash(content);
|
|
429
|
+
}
|
|
430
|
+
await atomicWrite(path.join(backup.dir, 'migration-manifest.json'), Buffer.from(`${JSON.stringify(backup.manifest, null, 2)}\n`));
|
|
431
|
+
const state = await loadState();
|
|
432
|
+
state.projects[projectKey(options.projectDir)] = {
|
|
433
|
+
projectDir: path.resolve(options.projectDir),
|
|
434
|
+
updatedAt: new Date().toISOString(),
|
|
435
|
+
backupDir: backup.dir,
|
|
436
|
+
runtimes: preview.runtimes,
|
|
437
|
+
runtimeScopes: preview.runtimeScopes,
|
|
438
|
+
...(preview.runtimes.length === 1 ? { activeRuntime: preview.runtimes[0] } : {}),
|
|
439
|
+
ownedFiles: owned,
|
|
440
|
+
};
|
|
441
|
+
await atomicWrite(statePath(), Buffer.from(`${JSON.stringify(state, null, 2)}\n`));
|
|
442
|
+
return { ...preview, backupDir: backup.dir };
|
|
443
|
+
}
|
|
444
|
+
finally {
|
|
445
|
+
await lockHandle.close();
|
|
446
|
+
await unlink(lockPath).catch(() => undefined);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function removeManagedBlock(existing, marker) {
|
|
450
|
+
const begin = `<!-- ##planr-${marker}:begin##`;
|
|
451
|
+
const end = `<!-- ##planr-${marker}:end## -->`;
|
|
452
|
+
const start = existing.indexOf(begin);
|
|
453
|
+
const finish = existing.indexOf(end, start);
|
|
454
|
+
if (start === -1 || finish === -1)
|
|
455
|
+
return existing;
|
|
456
|
+
return `${existing.slice(0, start).trimEnd()}${existing.slice(finish + end.length)}`.trimStart();
|
|
457
|
+
}
|
|
458
|
+
export async function rollbackRuntime(projectDir, backupDir) {
|
|
459
|
+
const state = await loadState();
|
|
460
|
+
const project = state.projects[projectKey(projectDir)];
|
|
461
|
+
const selected = backupDir ?? project?.backupDir;
|
|
462
|
+
if (!selected)
|
|
463
|
+
throw new RuntimeManagerError('E_ROLLBACK_NOT_FOUND', 'No runtime backup is recorded for this project.');
|
|
464
|
+
const manifest = JSON.parse(await readFile(path.join(selected, 'migration-manifest.json'), 'utf8'));
|
|
465
|
+
const restored = [];
|
|
466
|
+
const retainedShared = [];
|
|
467
|
+
const key = projectKey(projectDir);
|
|
468
|
+
const sharedTargets = new Set(manifest.files
|
|
469
|
+
.filter((entry) => Object.entries(state.projects).some(([otherKey, otherProject]) => otherKey !== key &&
|
|
470
|
+
otherProject.ownedFiles.some((owned) => owned.target === entry.target)))
|
|
471
|
+
.map((entry) => entry.target));
|
|
472
|
+
for (const entry of manifest.files) {
|
|
473
|
+
if (!sharedTargets.has(entry.target) &&
|
|
474
|
+
!entry.existed &&
|
|
475
|
+
existsSync(entry.target) &&
|
|
476
|
+
entry.afterHash &&
|
|
477
|
+
hash(await readFile(entry.target)) !== entry.afterHash) {
|
|
478
|
+
throw new RuntimeManagerError('E_MIGRATION_CONFLICT', `Refusing to remove modified file ${entry.target}.`, 'Restore it manually or choose a different backup.');
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
for (const entry of manifest.files) {
|
|
482
|
+
if (sharedTargets.has(entry.target)) {
|
|
483
|
+
retainedShared.push(entry.target);
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (entry.existed && entry.backup) {
|
|
487
|
+
await mkdir(path.dirname(entry.target), { recursive: true });
|
|
488
|
+
await copyFile(entry.backup, entry.target);
|
|
489
|
+
}
|
|
490
|
+
else if (existsSync(entry.target)) {
|
|
491
|
+
await unlink(entry.target);
|
|
492
|
+
}
|
|
493
|
+
restored.push(entry.target);
|
|
494
|
+
}
|
|
495
|
+
delete state.projects[key];
|
|
496
|
+
await atomicWrite(statePath(), Buffer.from(`${JSON.stringify(state, null, 2)}\n`));
|
|
497
|
+
return { ok: true, restored, retainedShared };
|
|
498
|
+
}
|
|
499
|
+
export async function removeRuntime(runtime, projectDir) {
|
|
500
|
+
const state = await loadState();
|
|
501
|
+
const key = projectKey(projectDir);
|
|
502
|
+
const project = state.projects[key];
|
|
503
|
+
if (!project)
|
|
504
|
+
throw new RuntimeManagerError('E_RUNTIME_STATE_MISSING', 'No managed runtime installation is recorded for this project.');
|
|
505
|
+
const removed = [];
|
|
506
|
+
const retainedShared = [];
|
|
507
|
+
const runtimeFiles = project.ownedFiles.filter((file) => file.runtime === runtime);
|
|
508
|
+
const sharedTargets = new Set(runtimeFiles
|
|
509
|
+
.filter((file) => Object.entries(state.projects).some(([otherKey, otherProject]) => otherKey !== key &&
|
|
510
|
+
otherProject.ownedFiles.some((owned) => owned.runtime === runtime && owned.target === file.target)))
|
|
511
|
+
.map((file) => file.target));
|
|
512
|
+
const lockFile = project.ownedFiles.find((file) => file.runtime === 'core' && file.target.endsWith('runtime-lock.json'));
|
|
513
|
+
// Validate every owned byte before mutating anything so a late conflict cannot
|
|
514
|
+
// leave the installation half-removed.
|
|
515
|
+
for (const file of runtimeFiles) {
|
|
516
|
+
if (!existsSync(file.target) || sharedTargets.has(file.target))
|
|
517
|
+
continue;
|
|
518
|
+
const current = await readFile(file.target);
|
|
519
|
+
if (ownershipHash(current, file.kind, file.marker) !== file.hash) {
|
|
520
|
+
throw new RuntimeManagerError('E_MIGRATION_CONFLICT', `Refusing to remove modified OpenPlanr file ${file.target}.`, 'Run rollback or preserve the hand edits before removing the adapter.');
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
let lock;
|
|
524
|
+
if (lockFile && existsSync(lockFile.target)) {
|
|
525
|
+
const current = await readFile(lockFile.target);
|
|
526
|
+
if (hash(current) !== lockFile.hash) {
|
|
527
|
+
throw new RuntimeManagerError('E_MIGRATION_CONFLICT', `Refusing to update modified OpenPlanr file ${lockFile.target}.`, 'Run rollback or preserve the hand edits before removing the adapter.');
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
lock = JSON.parse(current.toString('utf8'));
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
throw new RuntimeManagerError('E_MIGRATION_CONFLICT', `Refusing to update invalid runtime lock ${lockFile.target}.`, 'Repair or roll back the runtime lock before removing the adapter.');
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
for (const file of runtimeFiles) {
|
|
537
|
+
if (!existsSync(file.target))
|
|
538
|
+
continue;
|
|
539
|
+
if (sharedTargets.has(file.target)) {
|
|
540
|
+
retainedShared.push(file.target);
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
const current = await readFile(file.target);
|
|
544
|
+
if (file.kind === 'managed-block') {
|
|
545
|
+
await atomicWrite(file.target, Buffer.from(removeManagedBlock(current.toString('utf8'), file.marker ?? 'runtime')));
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
await unlink(file.target);
|
|
549
|
+
}
|
|
550
|
+
removed.push(file.target);
|
|
551
|
+
}
|
|
552
|
+
project.ownedFiles = project.ownedFiles.filter((file) => file.runtime !== runtime);
|
|
553
|
+
project.runtimes = project.runtimes.filter((item) => item !== runtime);
|
|
554
|
+
if (project.runtimeScopes)
|
|
555
|
+
delete project.runtimeScopes[runtime];
|
|
556
|
+
project.activeRuntime = project.runtimes.length === 1 ? project.runtimes[0] : undefined;
|
|
557
|
+
project.updatedAt = new Date().toISOString();
|
|
558
|
+
if (lockFile && lock) {
|
|
559
|
+
lock.adapters = lock.adapters.filter((adapter) => adapter.runtime !== runtime);
|
|
560
|
+
if (lock.adapters.length === 0) {
|
|
561
|
+
await unlink(lockFile.target);
|
|
562
|
+
project.ownedFiles = project.ownedFiles.filter((file) => file !== lockFile);
|
|
563
|
+
removed.push(lockFile.target);
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
lock.generatedAt = new Date().toISOString();
|
|
567
|
+
lock.manifestDigest = `sha256:${hash(JSON.stringify({
|
|
568
|
+
protocol: lock.protocolVersion,
|
|
569
|
+
pipelineVersion: lock.components.pipeline,
|
|
570
|
+
adapters: lock.adapters,
|
|
571
|
+
}))}`;
|
|
572
|
+
const content = Buffer.from(`${JSON.stringify(lock, null, 2)}\n`);
|
|
573
|
+
await atomicWrite(lockFile.target, content);
|
|
574
|
+
lockFile.hash = hash(content);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
else if (lockFile && project.runtimes.length === 0 && !existsSync(lockFile.target)) {
|
|
578
|
+
project.ownedFiles = project.ownedFiles.filter((file) => file !== lockFile);
|
|
579
|
+
}
|
|
580
|
+
if (project.runtimes.length === 0 && project.ownedFiles.length === 0)
|
|
581
|
+
delete state.projects[key];
|
|
582
|
+
await atomicWrite(statePath(), Buffer.from(`${JSON.stringify(state, null, 2)}\n`));
|
|
583
|
+
return { ok: true, removed, retainedShared };
|
|
584
|
+
}
|
|
585
|
+
export async function runtimeDoctor(projectDir) {
|
|
586
|
+
const diagnostics = [];
|
|
587
|
+
let lockedAdapters;
|
|
588
|
+
const nodeMajor = Number(process.versions.node.split('.')[0]);
|
|
589
|
+
diagnostics.push({
|
|
590
|
+
code: 'node-version',
|
|
591
|
+
status: nodeMajor >= 20 ? 'pass' : 'fail',
|
|
592
|
+
message: `Node.js ${process.versions.node}`,
|
|
593
|
+
...(nodeMajor < 20 ? { fix: 'Install Node.js 20 or newer.' } : {}),
|
|
594
|
+
});
|
|
595
|
+
for (const result of detectRuntimes()) {
|
|
596
|
+
diagnostics.push({
|
|
597
|
+
code: `runtime-${result.runtime}`,
|
|
598
|
+
status: result.installed ? 'pass' : 'warn',
|
|
599
|
+
message: result.installed ? `${result.runtime} detected` : `${result.runtime} not detected`,
|
|
600
|
+
...(!result.installed
|
|
601
|
+
? { fix: `Install ${result.command} only if you intend to use this adapter.` }
|
|
602
|
+
: {}),
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
const pipeline = resolvePipelinePackage(false);
|
|
606
|
+
diagnostics.push({
|
|
607
|
+
code: 'pipeline-package',
|
|
608
|
+
status: pipeline ? 'pass' : 'warn',
|
|
609
|
+
message: pipeline ? `planr-pipeline ${pipeline.version}` : 'Planning-only installation',
|
|
610
|
+
...(!pipeline ? { fix: 'Install openplanr without omitting optional dependencies.' } : {}),
|
|
611
|
+
});
|
|
612
|
+
const lock = path.join(projectDir, '.planr', 'runtime-lock.json');
|
|
613
|
+
if (!existsSync(lock)) {
|
|
614
|
+
diagnostics.push({
|
|
615
|
+
code: 'runtime-lock',
|
|
616
|
+
status: 'warn',
|
|
617
|
+
message: 'Project runtime lock missing',
|
|
618
|
+
fix: 'Run `planr setup --scope project`.',
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
try {
|
|
623
|
+
const value = JSON.parse(readFileSync(lock, 'utf8'));
|
|
624
|
+
lockedAdapters = value.adapters;
|
|
625
|
+
const cliVersion = readOpenPlanrVersion();
|
|
626
|
+
const componentDrift = value.components?.cli !== cliVersion ||
|
|
627
|
+
(pipeline && value.components?.pipeline !== pipeline.version);
|
|
628
|
+
const expectedDigest = `sha256:${hash(JSON.stringify({
|
|
629
|
+
protocol: value.protocolVersion,
|
|
630
|
+
pipelineVersion: value.components?.pipeline,
|
|
631
|
+
adapters: value.adapters,
|
|
632
|
+
}))}`;
|
|
633
|
+
const digestDrift = value.manifestDigest !== expectedDigest;
|
|
634
|
+
const registry = listRuntimeAdapters();
|
|
635
|
+
const adapterDrift = (value.adapters ?? []).some((locked) => {
|
|
636
|
+
const current = registry.find((adapter) => adapter.id === locked.runtime);
|
|
637
|
+
return (!current ||
|
|
638
|
+
current.version !== locked.version ||
|
|
639
|
+
current.capabilityLevel !== locked.capabilityLevel);
|
|
640
|
+
});
|
|
641
|
+
const drift = componentDrift || digestDrift || adapterDrift;
|
|
642
|
+
diagnostics.push({
|
|
643
|
+
code: drift ? 'lock-drift' : 'runtime-lock',
|
|
644
|
+
status: drift ? 'fail' : 'pass',
|
|
645
|
+
message: drift
|
|
646
|
+
? `Runtime lock drift detected (components: ${componentDrift}, digest: ${digestDrift}, adapters: ${adapterDrift})`
|
|
647
|
+
: 'Project runtime lock matches installed component versions',
|
|
648
|
+
...(drift ? { fix: 'Run `planr runtime update all --scope project`.' } : {}),
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
diagnostics.push({
|
|
653
|
+
code: 'runtime-lock-invalid',
|
|
654
|
+
status: 'fail',
|
|
655
|
+
message: 'Project runtime lock is not valid JSON',
|
|
656
|
+
fix: 'Run `planr setup --scope project` after reviewing the existing lock.',
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
const state = await loadState();
|
|
661
|
+
const installed = state.projects[projectKey(projectDir)];
|
|
662
|
+
if (installed) {
|
|
663
|
+
if (lockedAdapters) {
|
|
664
|
+
const lockedState = lockedAdapters
|
|
665
|
+
.map((adapter) => `${adapter.runtime}:${adapter.installScope}`)
|
|
666
|
+
.sort();
|
|
667
|
+
const installedState = installed.runtimes
|
|
668
|
+
.map((runtime) => `${runtime}:${installed.runtimeScopes?.[runtime] ?? inferRuntimeScope(installed.ownedFiles, runtime)}`)
|
|
669
|
+
.sort();
|
|
670
|
+
const stateDrift = JSON.stringify(lockedState) !== JSON.stringify(installedState);
|
|
671
|
+
diagnostics.push({
|
|
672
|
+
code: stateDrift ? 'lock-state-drift' : 'lock-state',
|
|
673
|
+
status: stateDrift ? 'fail' : 'pass',
|
|
674
|
+
message: stateDrift
|
|
675
|
+
? 'Runtime lock adapters do not match the managed installation state'
|
|
676
|
+
: 'Runtime lock adapters match the managed installation state',
|
|
677
|
+
...(stateDrift ? { fix: 'Run `planr setup --dry-run`, then approve the repair.' } : {}),
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
const conflicts = [];
|
|
681
|
+
const missing = [];
|
|
682
|
+
for (const file of installed.ownedFiles) {
|
|
683
|
+
if (!existsSync(file.target))
|
|
684
|
+
missing.push(file.target);
|
|
685
|
+
else if (ownershipHash(readFileSync(file.target), file.kind, file.marker) !== file.hash)
|
|
686
|
+
conflicts.push(file.target);
|
|
687
|
+
}
|
|
688
|
+
diagnostics.push({
|
|
689
|
+
code: conflicts.length ? 'migration-conflict' : 'managed-files',
|
|
690
|
+
status: conflicts.length ? 'fail' : missing.length ? 'warn' : 'pass',
|
|
691
|
+
message: conflicts.length
|
|
692
|
+
? `${conflicts.length} managed file(s) changed outside setup`
|
|
693
|
+
: missing.length
|
|
694
|
+
? `${missing.length} managed file(s) are missing`
|
|
695
|
+
: 'Managed runtime files match recorded ownership hashes',
|
|
696
|
+
...(conflicts.length || missing.length
|
|
697
|
+
? { fix: 'Run `planr setup --dry-run`, then explicitly approve repair or rollback.' }
|
|
698
|
+
: {}),
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
else if (lockedAdapters?.length) {
|
|
702
|
+
diagnostics.push({
|
|
703
|
+
code: 'runtime-state-missing',
|
|
704
|
+
status: 'warn',
|
|
705
|
+
message: 'The project has a runtime lock but this machine has no managed adapter state',
|
|
706
|
+
fix: 'Run `planr setup` to install the locked runtime adapters on this machine.',
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
const provenancePath = path.join(projectDir, '.planr', 'provenance.jsonl');
|
|
710
|
+
if (existsSync(provenancePath)) {
|
|
711
|
+
const invalidLines = [];
|
|
712
|
+
const lines = readFileSync(provenancePath, 'utf8').split(/\r?\n/);
|
|
713
|
+
for (const [index, line] of lines.entries()) {
|
|
714
|
+
if (!line.trim())
|
|
715
|
+
continue;
|
|
716
|
+
try {
|
|
717
|
+
const event = JSON.parse(line);
|
|
718
|
+
if (!event.event_id || !event.producer?.product)
|
|
719
|
+
invalidLines.push(index + 1);
|
|
720
|
+
}
|
|
721
|
+
catch {
|
|
722
|
+
invalidLines.push(index + 1);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
diagnostics.push({
|
|
726
|
+
code: invalidLines.length ? 'provenance-invalid' : 'provenance',
|
|
727
|
+
status: invalidLines.length ? 'fail' : 'pass',
|
|
728
|
+
message: invalidLines.length
|
|
729
|
+
? `Provenance contains invalid event lines: ${invalidLines.join(', ')}`
|
|
730
|
+
: 'Provenance is append-only JSONL with identifiable producers',
|
|
731
|
+
...(invalidLines.length
|
|
732
|
+
? {
|
|
733
|
+
fix: 'Repair the invalid bytes, then explicitly append a recovery event. Doctor will not invent history.',
|
|
734
|
+
}
|
|
735
|
+
: {}),
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
if (pipeline) {
|
|
739
|
+
const result = spawnSync(process.execPath, [path.join(pipeline.root, 'scripts', 'doctor.mjs'), '--json'], {
|
|
740
|
+
cwd: projectDir,
|
|
741
|
+
encoding: 'utf8',
|
|
742
|
+
windowsHide: true,
|
|
743
|
+
});
|
|
744
|
+
try {
|
|
745
|
+
const report = JSON.parse(result.stdout);
|
|
746
|
+
for (const check of report.checks ?? []) {
|
|
747
|
+
if (check.status === 'ok')
|
|
748
|
+
continue;
|
|
749
|
+
diagnostics.push({
|
|
750
|
+
code: `pipeline-${check.id}`,
|
|
751
|
+
status: check.status,
|
|
752
|
+
message: check.message,
|
|
753
|
+
...(check.fix ? { fix: check.fix } : {}),
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
catch {
|
|
758
|
+
diagnostics.push({
|
|
759
|
+
code: 'pipeline-doctor-unavailable',
|
|
760
|
+
status: 'warn',
|
|
761
|
+
message: 'The pipeline doctor did not return valid JSON',
|
|
762
|
+
fix: 'Run `planr pipeline doctor` for direct diagnostics.',
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const fail = diagnostics.some((item) => item.status === 'fail');
|
|
767
|
+
return { ok: !fail, diagnostics };
|
|
768
|
+
}
|
|
769
|
+
export async function clearRuntimeStateForTests(root) {
|
|
770
|
+
await rm(root, { recursive: true, force: true });
|
|
771
|
+
}
|
|
772
|
+
//# sourceMappingURL=runtime-manager-service.js.map
|