openplanr 1.22.0 → 1.23.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +1388 -0
  2. package/dist/cli/commands/config.d.ts.map +1 -1
  3. package/dist/cli/commands/config.js +52 -0
  4. package/dist/cli/commands/config.js.map +1 -1
  5. package/dist/cli/commands/upgrade.d.ts +9 -0
  6. package/dist/cli/commands/upgrade.d.ts.map +1 -0
  7. package/dist/cli/commands/upgrade.js +149 -0
  8. package/dist/cli/commands/upgrade.js.map +1 -0
  9. package/dist/cli/index.js +28 -2
  10. package/dist/cli/index.js.map +1 -1
  11. package/dist/models/schema.d.ts +12 -0
  12. package/dist/models/schema.d.ts.map +1 -1
  13. package/dist/models/schema.js +9 -0
  14. package/dist/models/schema.js.map +1 -1
  15. package/dist/models/types.d.ts +8 -0
  16. package/dist/models/types.d.ts.map +1 -1
  17. package/dist/services/claude-plugin-service.d.ts +9 -0
  18. package/dist/services/claude-plugin-service.d.ts.map +1 -1
  19. package/dist/services/claude-plugin-service.js +22 -0
  20. package/dist/services/claude-plugin-service.js.map +1 -1
  21. package/dist/services/migration-registry.d.ts +94 -0
  22. package/dist/services/migration-registry.d.ts.map +1 -0
  23. package/dist/services/migration-registry.js +106 -0
  24. package/dist/services/migration-registry.js.map +1 -0
  25. package/dist/services/runtime-manager-service.d.ts +26 -0
  26. package/dist/services/runtime-manager-service.d.ts.map +1 -1
  27. package/dist/services/runtime-manager-service.js +39 -5
  28. package/dist/services/runtime-manager-service.js.map +1 -1
  29. package/dist/services/upgrade-offer-service.d.ts +89 -0
  30. package/dist/services/upgrade-offer-service.d.ts.map +1 -0
  31. package/dist/services/upgrade-offer-service.js +256 -0
  32. package/dist/services/upgrade-offer-service.js.map +1 -0
  33. package/dist/services/upgrade-service.d.ts +156 -0
  34. package/dist/services/upgrade-service.d.ts.map +1 -0
  35. package/dist/services/upgrade-service.js +510 -0
  36. package/dist/services/upgrade-service.js.map +1 -0
  37. package/package.json +2 -1
@@ -0,0 +1,156 @@
1
+ import { type ClaudeCommandRunner, type ClaudePluginOperation } from './claude-plugin-service.js';
2
+ /**
3
+ * One component of the published compatibility manifest (`ecosystem.json`'s
4
+ * `components.*`). Each artifact carries its own version plus the mutual
5
+ * compatibility range it requires of its sibling.
6
+ */
7
+ export interface EcosystemComponent {
8
+ version: string;
9
+ cliRange?: string;
10
+ pipelineRange?: string;
11
+ }
12
+ export interface EcosystemComponents {
13
+ cli: EcosystemComponent;
14
+ pipeline: EcosystemComponent;
15
+ skills: EcosystemComponent;
16
+ marketplace?: EcosystemComponent;
17
+ }
18
+ /**
19
+ * Where the compatibility manifest came from for this reconciliation.
20
+ *
21
+ * - `network` — freshly fetched and cached this run.
22
+ * - `cache` — a still-fresh cache (within the TTL); no network was touched.
23
+ * - `stale-cache` — the fetch failed, so a past cache was reused.
24
+ * - `unavailable` — neither a fetch nor any cache; the tuple cannot be judged.
25
+ */
26
+ export type EcosystemSource = 'network' | 'cache' | 'stale-cache' | 'unavailable';
27
+ export interface UpgradeReconciliation {
28
+ status: 'aligned' | 'upgrade-available' | 'incompatible' | 'unknown';
29
+ installed: {
30
+ cli: string;
31
+ skills: string | null;
32
+ pipeline: string | null;
33
+ };
34
+ published: EcosystemComponents | null;
35
+ ecosystemSource: EcosystemSource;
36
+ }
37
+ export interface ReconcileOptions {
38
+ /** Injectable `claude` runner; defaults to the real host command. */
39
+ claudeCommandRunner?: ClaudeCommandRunner;
40
+ /** Injectable fetch, for hermetic offline/hung-network tests. */
41
+ fetchImpl?: typeof fetch;
42
+ /** Clock override (ms since epoch), for deterministic TTL tests. */
43
+ now?: number;
44
+ /** Hard fetch timeout in ms; a hung network must never exceed this. */
45
+ timeoutMs?: number;
46
+ }
47
+ /**
48
+ * FR3: read the published compatibility manifest, compare it against the real
49
+ * installed tuple (this CLI's version, plus both host-plugin versions), and
50
+ * report whether the tuple is aligned, has an upgrade available, or is
51
+ * genuinely incompatible. The warn-vs-fail call is delegated to
52
+ * `classifyComponentDrift` so it is doctor's exact distinction, not a re-derivation.
53
+ */
54
+ export declare function reconcileInstalledTuple(_projectDir: string, options?: ReconcileOptions): Promise<UpgradeReconciliation>;
55
+ export interface NpmCommandResult {
56
+ status: number | null;
57
+ stdout: string;
58
+ stderr: string;
59
+ error?: Error;
60
+ }
61
+ export type NpmCommandRunner = (args: string[]) => NpmCommandResult;
62
+ export interface UpgradePlan {
63
+ proceed: boolean;
64
+ targetCliVersion: string | null;
65
+ reason: string;
66
+ }
67
+ /**
68
+ * FR4 ownership split, decided once so the CLI command stays thin: the npm half
69
+ * is executed only when the CLI itself can move forward — `upgrade-available`,
70
+ * or `incompatible` with the CLI genuinely behind the published version. An
71
+ * `incompatible` tuple whose CLI is not behind cannot be fixed by upgrading the
72
+ * CLI (the plugin half must move); apply prints the prescription instead of
73
+ * mutating. `aligned`/`unknown` never mutate.
74
+ */
75
+ export declare function planCliUpgrade(reconciliation: UpgradeReconciliation): UpgradePlan;
76
+ /**
77
+ * FR8 — "what's new, honestly." Return the changelog bullets between two
78
+ * `## <version>` headers: everything after `## <newVersion>` and before
79
+ * `## <oldVersion>` (the changelog is newest-first, so the new version sits
80
+ * above the old one). Only real list items in that window are returned, each
81
+ * verbatim from the file. When the window or its entries are missing — an
82
+ * unreleased target, a CHANGELOG the package does not ship, a shifted file, no
83
+ * bullets — the result is empty, and the caller says so rather than inventing a
84
+ * summary. If the old header is absent, the window is bounded at the next
85
+ * version header so a summary can never reach back past the target's section.
86
+ */
87
+ export declare function summarizeChangelogBetween(oldVersion: string, newVersion: string): string[];
88
+ /**
89
+ * FR4's manifest-refresh guarantee: the marketplace add/refresh command is
90
+ * printed FIRST — "without which the installer reinstalls the stale version." A
91
+ * stable sort keeps every other operation in the order
92
+ * `inspectClaudePluginIntegration` produced, and each is rendered by
93
+ * `formatClaudePluginOperationCommand` (the same argv an apply would run), so
94
+ * the prescription can never drift from what would actually execute. This is a
95
+ * pure formatter — it prints the plugin half, it never runs it.
96
+ */
97
+ export declare function prescribePluginHalfCommands(operations: ClaudePluginOperation[]): string[];
98
+ /** One migration's outcome as the injected registry runner reports it (T-006). */
99
+ export interface MigrationRunResult {
100
+ id: string;
101
+ applied: boolean;
102
+ alreadyApplied: boolean;
103
+ failure?: string;
104
+ }
105
+ /**
106
+ * FR7's migration registry, injected rather than imported so this service owns
107
+ * only the call site and result field (the registry lives in
108
+ * `migration-registry.ts`). Called with the pre-upgrade and verified
109
+ * post-upgrade versions so a migration runs only when the upgrade crosses its
110
+ * version. `runPendingMigrations` satisfies this shape structurally.
111
+ */
112
+ export type MigrationRunner = (fromVersion: string, toVersion: string, ctx: {
113
+ projectDir: string;
114
+ }) => Promise<MigrationRunResult[]>;
115
+ export interface ExecuteCliHalfUpgradeInput {
116
+ projectDir: string;
117
+ targetCliVersion: string;
118
+ /** Injectable npm runner; defaults to the real (or `OPENPLANR_NPM_BIN`) npm. */
119
+ npmCommandRunner?: NpmCommandRunner;
120
+ /** Injectable `claude` runner for the prescription's inspection (hermetic tests). */
121
+ claudeCommandRunner?: ClaudeCommandRunner;
122
+ /**
123
+ * FR7 migration runner, run after the CLI half verifies. Omitted (no runner)
124
+ * means no migrations are attempted; the `apply` command injects the real
125
+ * registry's `runPendingMigrations`.
126
+ */
127
+ migrationRunner?: MigrationRunner;
128
+ }
129
+ export interface ExecuteCliHalfUpgradeResult {
130
+ ok: boolean;
131
+ cliUpgraded: boolean;
132
+ installedVersion: string;
133
+ restoredTo?: string;
134
+ changelogBullets: string[];
135
+ pluginHalfCommands: string[];
136
+ /** Per-migration results for every registered migration this upgrade crossed. */
137
+ migrations: MigrationRunResult[];
138
+ failure?: {
139
+ step: 'npm-install' | 'verify' | 'changelog' | 'migration';
140
+ message: string;
141
+ };
142
+ }
143
+ /**
144
+ * FR4/FR8/FR9. Execute the one half the CLI owns — a global npm install — and
145
+ * prescribe (never execute) the plugin half.
146
+ *
147
+ * FR9's atomicity is enforced by verify-after-write: the previously installed
148
+ * version is captured *before* any mutation as the restorable backup, and after
149
+ * a zero-exit install the on-disk version is re-read. A clean exit that did not
150
+ * land the target is the decisive case the spec names — it triggers an automatic
151
+ * reinstall of the captured previous version and reports exactly what was
152
+ * restored, so a partially-upgraded install can never report success. `ok` is
153
+ * `false` whenever an owned step fails, even when npm itself exited zero.
154
+ */
155
+ export declare function executeCliHalfUpgrade(input: ExecuteCliHalfUpgradeInput): Promise<ExecuteCliHalfUpgradeResult>;
156
+ //# sourceMappingURL=upgrade-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"upgrade-service.d.ts","sourceRoot":"","sources":["../../src/services/upgrade-service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,510 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { formatClaudePluginOperationCommand, inspectClaudePluginIntegration, OPENPLANR_CLAUDE_MARKETPLACE_SOURCE, } from './claude-plugin-service.js';
7
+ import { resolvePipelinePackage } from './pipeline-package-service.js';
8
+ import { readOpenPlanrVersion } from './provenance-service.js';
9
+ import { classifyComponentDrift, runtimeRoot } from './runtime-manager-service.js';
10
+ /**
11
+ * The published manifest lives at `main` HEAD of the marketplace repository,
12
+ * whose closeout sequence guarantees HEAD only reflects a finalized operation.
13
+ * `OPENPLANR_ECOSYSTEM_SOURCE` overrides it with an `http(s)` URL (a local stub
14
+ * server) or a filesystem path (a fixture), which the packed e2e test uses to
15
+ * avoid any real network.
16
+ */
17
+ const DEFAULT_ECOSYSTEM_URL = `https://raw.githubusercontent.com/${OPENPLANR_CLAUDE_MARKETPLACE_SOURCE}/main/ecosystem.json`;
18
+ /**
19
+ * Within the TTL the cached manifest is trusted without a network round-trip.
20
+ * This is what keeps an otherwise-offline-capable CLI from acquiring a network
21
+ * dependency on every check.
22
+ */
23
+ const CACHE_TTL_MS = 15 * 60 * 1000;
24
+ /**
25
+ * A short hard ceiling on the fetch. A captive portal, a VPN, or an airplane
26
+ * must never make `planr` hang: past this, the fetch is abandoned and the CLI
27
+ * falls back to cache (or reports the manifest unavailable).
28
+ */
29
+ const DEFAULT_FETCH_TIMEOUT_MS = 2_000;
30
+ function ecosystemSourceLocation() {
31
+ return process.env.OPENPLANR_ECOSYSTEM_SOURCE?.trim() || DEFAULT_ECOSYSTEM_URL;
32
+ }
33
+ function ecosystemCachePath() {
34
+ return path.join(runtimeRoot(), 'ecosystem-cache.json');
35
+ }
36
+ /** Narrow the raw manifest JSON to the compatibility components we reconcile. */
37
+ function parseComponents(text) {
38
+ try {
39
+ const data = JSON.parse(text);
40
+ const components = data.components;
41
+ if (!components?.cli?.version || !components.pipeline?.version || !components.skills?.version) {
42
+ return null;
43
+ }
44
+ return {
45
+ cli: { version: components.cli.version, ...pickRange(components.cli) },
46
+ pipeline: { version: components.pipeline.version, ...pickRange(components.pipeline) },
47
+ skills: { version: components.skills.version, ...pickRange(components.skills) },
48
+ ...(components.marketplace?.version
49
+ ? { marketplace: { version: components.marketplace.version } }
50
+ : {}),
51
+ };
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ function pickRange(component) {
58
+ return {
59
+ ...(component.cliRange ? { cliRange: component.cliRange } : {}),
60
+ ...(component.pipelineRange ? { pipelineRange: component.pipelineRange } : {}),
61
+ };
62
+ }
63
+ function readCache() {
64
+ const cachePath = ecosystemCachePath();
65
+ if (!existsSync(cachePath))
66
+ return null;
67
+ try {
68
+ const parsed = JSON.parse(readFileSync(cachePath, 'utf8'));
69
+ if (typeof parsed.fetchedAt !== 'number' || !parsed.components?.cli?.version)
70
+ return null;
71
+ return { fetchedAt: parsed.fetchedAt, components: parsed.components };
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ async function writeCache(components, now) {
78
+ const cachePath = ecosystemCachePath();
79
+ await mkdir(path.dirname(cachePath), { recursive: true });
80
+ const payload = { fetchedAt: now, components };
81
+ await writeFile(cachePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
82
+ }
83
+ /**
84
+ * Read the manifest text, bounded by a hard timeout that always wins even if
85
+ * the underlying fetch ignores the abort signal. A local filesystem source is
86
+ * read directly (no timeout needed). Any failure resolves to `null`.
87
+ */
88
+ async function fetchManifestText(location, fetchImpl, timeoutMs) {
89
+ if (!/^https?:\/\//i.test(location)) {
90
+ try {
91
+ return await readFile(location, 'utf8');
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ }
97
+ const controller = new AbortController();
98
+ let timer;
99
+ const timeout = new Promise((resolve) => {
100
+ timer = setTimeout(() => {
101
+ controller.abort();
102
+ resolve(null);
103
+ }, timeoutMs);
104
+ });
105
+ const attempt = (async () => {
106
+ try {
107
+ const response = await fetchImpl(location, { signal: controller.signal });
108
+ if (!response.ok)
109
+ return null;
110
+ return await response.text();
111
+ }
112
+ catch {
113
+ return null;
114
+ }
115
+ })();
116
+ try {
117
+ return await Promise.race([attempt, timeout]);
118
+ }
119
+ finally {
120
+ if (timer)
121
+ clearTimeout(timer);
122
+ }
123
+ }
124
+ async function loadEcosystem(options) {
125
+ const now = options.now ?? Date.now();
126
+ const cache = readCache();
127
+ if (cache && now - cache.fetchedAt < CACHE_TTL_MS) {
128
+ return { components: cache.components, source: 'cache' };
129
+ }
130
+ const fetchImpl = options.fetchImpl ?? fetch;
131
+ const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
132
+ const text = await fetchManifestText(ecosystemSourceLocation(), fetchImpl, timeoutMs);
133
+ const fetched = text ? parseComponents(text) : null;
134
+ if (fetched) {
135
+ await writeCache(fetched, now);
136
+ return { components: fetched, source: 'network' };
137
+ }
138
+ if (cache)
139
+ return { components: cache.components, source: 'stale-cache' };
140
+ return { components: null, source: 'unavailable' };
141
+ }
142
+ /** Parse an `X.Y.Z` version into numeric parts, or `null` if it is not stable. */
143
+ function stableVersionParts(version) {
144
+ if (!/^\d+\.\d+\.\d+$/.test(version))
145
+ return null;
146
+ return version.split('.').map(Number);
147
+ }
148
+ /**
149
+ * The same major/minor compatibility window `claude-plugin-service.ts`'s
150
+ * `newestCompatibleTarget` already applies, expressed as range satisfaction so
151
+ * no `semver` dependency is added: a caret range `^X.Y.Z` is satisfied by the
152
+ * same major (and, when the major is 0, the same minor) at or above the base.
153
+ * Anything unparseable is treated as satisfied — an absent range must never be
154
+ * reported as an incompatibility.
155
+ */
156
+ function satisfiesRange(version, range) {
157
+ const base = range.startsWith('^') ? range.slice(1) : range;
158
+ const target = stableVersionParts(base);
159
+ const actual = stableVersionParts(version);
160
+ if (!target || !actual)
161
+ return true;
162
+ if (actual[0] !== target[0])
163
+ return false;
164
+ if (target[0] === 0 && actual[1] !== target[1])
165
+ return false;
166
+ for (let index = 0; index < 3; index += 1) {
167
+ if (actual[index] > target[index])
168
+ return true;
169
+ if (actual[index] < target[index])
170
+ return false;
171
+ }
172
+ return true;
173
+ }
174
+ /** A present installed version that falls outside a declared range is a violation. */
175
+ function violatesRange(version, range) {
176
+ if (!version || !range)
177
+ return false;
178
+ return !satisfiesRange(version, range);
179
+ }
180
+ /**
181
+ * FR3: read the published compatibility manifest, compare it against the real
182
+ * installed tuple (this CLI's version, plus both host-plugin versions), and
183
+ * report whether the tuple is aligned, has an upgrade available, or is
184
+ * genuinely incompatible. The warn-vs-fail call is delegated to
185
+ * `classifyComponentDrift` so it is doctor's exact distinction, not a re-derivation.
186
+ */
187
+ export async function reconcileInstalledTuple(_projectDir, options = {}) {
188
+ const cliVersion = readOpenPlanrVersion();
189
+ const pipelinePackageVersion = resolvePipelinePackage(false)?.version ?? cliVersion;
190
+ const inspection = inspectClaudePluginIntegration(pipelinePackageVersion, options.claudeCommandRunner);
191
+ const skillsInstalled = inspection.plugins.find((plugin) => plugin.name === 'openplanr')?.installedVersion ?? null;
192
+ const pipelineInstalled = inspection.plugins.find((plugin) => plugin.name === 'planr-pipeline')?.installedVersion ?? null;
193
+ const installed = { cli: cliVersion, skills: skillsInstalled, pipeline: pipelineInstalled };
194
+ const { components: published, source: ecosystemSource } = await loadEcosystem(options);
195
+ if (!published) {
196
+ return { status: 'unknown', installed, published: null, ecosystemSource };
197
+ }
198
+ const cliDrift = installed.cli !== published.cli.version;
199
+ const componentDrift = cliDrift ||
200
+ (installed.skills !== null && installed.skills !== published.skills.version) ||
201
+ (installed.pipeline !== null && installed.pipeline !== published.pipeline.version);
202
+ // A real mutual-compatibility violation: an installed component sits outside
203
+ // the range its published sibling declares. Absent (uninstalled) plugins are
204
+ // not violations — that is a different condition from incompatibility.
205
+ const incompatibleDrift = violatesRange(installed.pipeline, published.cli.pipelineRange) ||
206
+ violatesRange(installed.cli, published.skills.cliRange) ||
207
+ violatesRange(installed.cli, published.pipeline.cliRange);
208
+ const classification = classifyComponentDrift({ cliDrift, componentDrift, incompatibleDrift });
209
+ const status = classification.status === 'pass'
210
+ ? 'aligned'
211
+ : classification.status === 'warn'
212
+ ? 'upgrade-available'
213
+ : 'incompatible';
214
+ return { status, installed, published, ecosystemSource };
215
+ }
216
+ /**
217
+ * The npm-owned half of an upgrade is a single global install. This mirrors
218
+ * `claude-plugin-service.ts`'s `defaultRunner`: a thin `spawnSync` wrapper that
219
+ * surfaces exit status and streams rather than throwing.
220
+ *
221
+ * `OPENPLANR_NPM_BIN` is a test seam of the same shape as T-002's
222
+ * `OPENPLANR_ECOSYSTEM_SOURCE`: a path to a Node script that stands in for the
223
+ * npm binary, so the packed-install e2e can drive a real `apply` without a real,
224
+ * machine-wide `npm install -g`. Unset in production, where the real `npm` runs.
225
+ */
226
+ function defaultNpmRunner(args) {
227
+ const override = process.env.OPENPLANR_NPM_BIN?.trim();
228
+ const onWindows = process.platform === 'win32';
229
+ const command = override ? process.execPath : onWindows ? 'npm.cmd' : 'npm';
230
+ const commandArgs = override ? [override, ...args] : args;
231
+ const result = spawnSync(command, commandArgs, {
232
+ encoding: 'utf8',
233
+ windowsHide: true,
234
+ shell: !override && onWindows,
235
+ });
236
+ return {
237
+ status: result.status,
238
+ stdout: result.stdout ?? '',
239
+ stderr: result.stderr ?? '',
240
+ ...(result.error ? { error: result.error } : {}),
241
+ };
242
+ }
243
+ /** Compare two `X.Y.Z` versions: -1 (a<b), 0 (equal or unparseable), 1 (a>b). */
244
+ function compareStableVersions(a, b) {
245
+ const left = stableVersionParts(a);
246
+ const right = stableVersionParts(b);
247
+ if (!left || !right)
248
+ return 0;
249
+ for (let index = 0; index < 3; index += 1) {
250
+ if (left[index] > right[index])
251
+ return 1;
252
+ if (left[index] < right[index])
253
+ return -1;
254
+ }
255
+ return 0;
256
+ }
257
+ /**
258
+ * FR4 ownership split, decided once so the CLI command stays thin: the npm half
259
+ * is executed only when the CLI itself can move forward — `upgrade-available`,
260
+ * or `incompatible` with the CLI genuinely behind the published version. An
261
+ * `incompatible` tuple whose CLI is not behind cannot be fixed by upgrading the
262
+ * CLI (the plugin half must move); apply prints the prescription instead of
263
+ * mutating. `aligned`/`unknown` never mutate.
264
+ */
265
+ export function planCliUpgrade(reconciliation) {
266
+ const { status, installed, published } = reconciliation;
267
+ if (!published) {
268
+ return {
269
+ proceed: false,
270
+ targetCliVersion: null,
271
+ reason: 'The published compatibility manifest is unavailable; no upgrade target can be determined.',
272
+ };
273
+ }
274
+ const target = published.cli.version;
275
+ if (status === 'aligned') {
276
+ return {
277
+ proceed: false,
278
+ targetCliVersion: null,
279
+ reason: 'The installed tuple already matches the published compatible set; nothing to upgrade.',
280
+ };
281
+ }
282
+ if (status === 'upgrade-available') {
283
+ return {
284
+ proceed: true,
285
+ targetCliVersion: target,
286
+ reason: `An upgrade is available: the CLI can move from ${installed.cli} to ${target}.`,
287
+ };
288
+ }
289
+ if (status === 'incompatible' && compareStableVersions(installed.cli, target) < 0) {
290
+ return {
291
+ proceed: true,
292
+ targetCliVersion: target,
293
+ reason: `The tuple is incompatible and the CLI is behind; moving the CLI from ${installed.cli} to ${target}.`,
294
+ };
295
+ }
296
+ if (status === 'incompatible') {
297
+ return {
298
+ proceed: false,
299
+ targetCliVersion: null,
300
+ reason: `The tuple is incompatible but the CLI (${installed.cli}) is not behind the published ${target}; the plugin half must move — run the prescribed commands below.`,
301
+ };
302
+ }
303
+ return {
304
+ proceed: false,
305
+ targetCliVersion: null,
306
+ reason: 'The tuple could not be judged.',
307
+ };
308
+ }
309
+ /**
310
+ * The changelog is read from the package that is on disk *after* the install,
311
+ * so a successful upgrade summarises the target's own entries. Resolved the same
312
+ * way `readOpenPlanrVersion` finds `package.json`, and shipped in the package's
313
+ * `files` list so this works on a real installed tuple, not only in-repo.
314
+ */
315
+ function locateChangelog() {
316
+ const here = path.dirname(fileURLToPath(import.meta.url));
317
+ for (const candidate of [
318
+ path.resolve(here, '../../CHANGELOG.md'),
319
+ path.resolve(here, '../../../CHANGELOG.md'),
320
+ ]) {
321
+ if (existsSync(candidate))
322
+ return candidate;
323
+ }
324
+ return null;
325
+ }
326
+ function changelogHeaderMatches(line, version) {
327
+ const trimmed = line.trim();
328
+ return trimmed === `## ${version}` || trimmed === `## [${version}]`;
329
+ }
330
+ /**
331
+ * Extract only the real `-` list items from a slice of changelog lines. A
332
+ * changeset commit-hash link prefix is stripped so the text is user-facing, and
333
+ * because only a leading prefix is removed each returned bullet stays a verbatim
334
+ * substring of the file — a summary can never carry a change the changelog does
335
+ * not. Multi-line wrapped items keep only their first line, which preserves that
336
+ * substring guarantee.
337
+ */
338
+ function extractChangelogBullets(lines) {
339
+ const bullets = [];
340
+ for (const raw of lines) {
341
+ const match = /^\s*-\s+(.*\S)\s*$/.exec(raw);
342
+ if (!match)
343
+ continue;
344
+ const cleaned = match[1].replace(/^\[`[0-9a-f]+`\]\([^)]*\)\s*/, '').trim();
345
+ if (cleaned)
346
+ bullets.push(cleaned);
347
+ }
348
+ return bullets;
349
+ }
350
+ /**
351
+ * FR8 — "what's new, honestly." Return the changelog bullets between two
352
+ * `## <version>` headers: everything after `## <newVersion>` and before
353
+ * `## <oldVersion>` (the changelog is newest-first, so the new version sits
354
+ * above the old one). Only real list items in that window are returned, each
355
+ * verbatim from the file. When the window or its entries are missing — an
356
+ * unreleased target, a CHANGELOG the package does not ship, a shifted file, no
357
+ * bullets — the result is empty, and the caller says so rather than inventing a
358
+ * summary. If the old header is absent, the window is bounded at the next
359
+ * version header so a summary can never reach back past the target's section.
360
+ */
361
+ export function summarizeChangelogBetween(oldVersion, newVersion) {
362
+ const changelogPath = locateChangelog();
363
+ if (!changelogPath)
364
+ return [];
365
+ let text;
366
+ try {
367
+ text = readFileSync(changelogPath, 'utf8');
368
+ }
369
+ catch {
370
+ return [];
371
+ }
372
+ const lines = text.split(/\r?\n/);
373
+ const startIndex = lines.findIndex((line) => changelogHeaderMatches(line, newVersion));
374
+ if (startIndex === -1)
375
+ return [];
376
+ let endIndex = lines.length;
377
+ let firstHeaderAfterStart = lines.length;
378
+ for (let index = startIndex + 1; index < lines.length; index += 1) {
379
+ if (firstHeaderAfterStart === lines.length && /^##\s/.test(lines[index].trim())) {
380
+ firstHeaderAfterStart = index;
381
+ }
382
+ if (changelogHeaderMatches(lines[index], oldVersion)) {
383
+ endIndex = index;
384
+ break;
385
+ }
386
+ }
387
+ // Old header never found: bound at the target's own section rather than
388
+ // over-claiming every older entry as "new".
389
+ if (endIndex === lines.length)
390
+ endIndex = firstHeaderAfterStart;
391
+ return extractChangelogBullets(lines.slice(startIndex + 1, endIndex));
392
+ }
393
+ /**
394
+ * FR4's manifest-refresh guarantee: the marketplace add/refresh command is
395
+ * printed FIRST — "without which the installer reinstalls the stale version." A
396
+ * stable sort keeps every other operation in the order
397
+ * `inspectClaudePluginIntegration` produced, and each is rendered by
398
+ * `formatClaudePluginOperationCommand` (the same argv an apply would run), so
399
+ * the prescription can never drift from what would actually execute. This is a
400
+ * pure formatter — it prints the plugin half, it never runs it.
401
+ */
402
+ export function prescribePluginHalfCommands(operations) {
403
+ const rank = (operation) => operation.kind === 'add-marketplace' || operation.kind === 'refresh-marketplace' ? 0 : 1;
404
+ return [...operations].sort((a, b) => rank(a) - rank(b)).map(formatClaudePluginOperationCommand);
405
+ }
406
+ /**
407
+ * FR4/FR8/FR9. Execute the one half the CLI owns — a global npm install — and
408
+ * prescribe (never execute) the plugin half.
409
+ *
410
+ * FR9's atomicity is enforced by verify-after-write: the previously installed
411
+ * version is captured *before* any mutation as the restorable backup, and after
412
+ * a zero-exit install the on-disk version is re-read. A clean exit that did not
413
+ * land the target is the decisive case the spec names — it triggers an automatic
414
+ * reinstall of the captured previous version and reports exactly what was
415
+ * restored, so a partially-upgraded install can never report success. `ok` is
416
+ * `false` whenever an owned step fails, even when npm itself exited zero.
417
+ */
418
+ export async function executeCliHalfUpgrade(input) {
419
+ const runNpm = input.npmCommandRunner ?? defaultNpmRunner;
420
+ // The restorable backup, captured before any mutation: the version we can
421
+ // always reinstall to undo a bad upgrade.
422
+ const previousVersion = readOpenPlanrVersion();
423
+ const install = runNpm(['install', '-g', `openplanr@${input.targetCliVersion}`]);
424
+ if (install.error || install.status !== 0) {
425
+ // A failed global install leaves the previous package in place — npm never
426
+ // half-replaces a package. Report honestly; render nothing.
427
+ const detail = install.error?.message || install.stderr.trim() || `npm exited with status ${install.status}`;
428
+ return {
429
+ ok: false,
430
+ cliUpgraded: false,
431
+ installedVersion: readOpenPlanrVersion(),
432
+ changelogBullets: [],
433
+ pluginHalfCommands: [],
434
+ migrations: [],
435
+ failure: {
436
+ step: 'npm-install',
437
+ message: `npm install of openplanr@${input.targetCliVersion} failed: ${detail}. The previous version ${previousVersion} is untouched.`,
438
+ },
439
+ };
440
+ }
441
+ // Verify-after-write: re-read the on-disk version the install just wrote.
442
+ const verifiedVersion = readOpenPlanrVersion();
443
+ if (verifiedVersion !== input.targetCliVersion) {
444
+ // The decisive FR9 case: a clean exit that did NOT land the target. Restore
445
+ // the captured previous version and never report success.
446
+ const restore = runNpm(['install', '-g', `openplanr@${previousVersion}`]);
447
+ const restoredVersion = readOpenPlanrVersion();
448
+ const restored = !restore.error && restore.status === 0 && restoredVersion === previousVersion;
449
+ const message = restored
450
+ ? `npm reported success but installed ${verifiedVersion}, not ${input.targetCliVersion}. Restored the previous version ${previousVersion}. Retry with \`planr upgrade apply\` once the registry serves ${input.targetCliVersion}.`
451
+ : `npm reported success but installed ${verifiedVersion}, not ${input.targetCliVersion}, and the automatic restore did not complete (now ${restoredVersion}). Reinstall manually: \`npm install -g openplanr@${previousVersion}\`.`;
452
+ return {
453
+ ok: false,
454
+ cliUpgraded: false,
455
+ installedVersion: restoredVersion,
456
+ restoredTo: previousVersion,
457
+ changelogBullets: [],
458
+ pluginHalfCommands: [],
459
+ migrations: [],
460
+ failure: { step: 'verify', message },
461
+ };
462
+ }
463
+ // The CLI half landed and verified — `cliUpgraded` is now true and stays true
464
+ // regardless of what follows, so the npm step's own success is reported
465
+ // accurately. FR7: run the migrations this upgrade crosses. Each owns its
466
+ // restorable backup, so a failure is recoverable; the registry reports each
467
+ // result rather than swallowing it.
468
+ const migrations = input.migrationRunner
469
+ ? await input.migrationRunner(previousVersion, verifiedVersion, {
470
+ projectDir: input.projectDir,
471
+ })
472
+ : [];
473
+ const failedMigration = migrations.find((migration) => migration.failure !== undefined);
474
+ if (failedMigration) {
475
+ // The decisive FR7/FR9 case: the CLI upgraded, but a post-upgrade migration
476
+ // failed. `ok` is false and the migration is named, so a half-migrated
477
+ // install can never report success — while `cliUpgraded` stays true, because
478
+ // the migration's failure must not hide the npm step's real success.
479
+ return {
480
+ ok: false,
481
+ cliUpgraded: true,
482
+ installedVersion: verifiedVersion,
483
+ changelogBullets: [],
484
+ pluginHalfCommands: [],
485
+ migrations,
486
+ failure: {
487
+ step: 'migration',
488
+ message: `The CLI upgraded to ${verifiedVersion}, but the post-upgrade migration \`${failedMigration.id}\` failed: ${failedMigration.failure}. Each migration takes its own restorable backup before mutating; re-run \`planr upgrade apply\` to retry it.`,
489
+ },
490
+ };
491
+ }
492
+ // Success: the CLI half landed and every crossed migration completed. Now the
493
+ // honest reporting half. Reading/summarising the changelog is a report step,
494
+ // never a mutation, so it does not flip `ok`: an empty summary is reported as
495
+ // "no entries", not as a failed upgrade (which would misreport a machine that
496
+ // is, in fact, upgraded).
497
+ const changelogBullets = summarizeChangelogBetween(previousVersion, verifiedVersion);
498
+ const pipelineVersion = resolvePipelinePackage(false)?.version ?? verifiedVersion;
499
+ const inspection = inspectClaudePluginIntegration(pipelineVersion, input.claudeCommandRunner);
500
+ const pluginHalfCommands = prescribePluginHalfCommands(inspection.operations);
501
+ return {
502
+ ok: true,
503
+ cliUpgraded: true,
504
+ installedVersion: verifiedVersion,
505
+ changelogBullets,
506
+ pluginHalfCommands,
507
+ migrations,
508
+ };
509
+ }
510
+ //# sourceMappingURL=upgrade-service.js.map