openplanr 1.8.1 → 1.9.1

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