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.
- package/CHANGELOG.md +1388 -0
- package/dist/cli/commands/config.d.ts.map +1 -1
- package/dist/cli/commands/config.js +52 -0
- package/dist/cli/commands/config.js.map +1 -1
- package/dist/cli/commands/upgrade.d.ts +9 -0
- package/dist/cli/commands/upgrade.d.ts.map +1 -0
- package/dist/cli/commands/upgrade.js +149 -0
- package/dist/cli/commands/upgrade.js.map +1 -0
- package/dist/cli/index.js +28 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/models/schema.d.ts +12 -0
- package/dist/models/schema.d.ts.map +1 -1
- package/dist/models/schema.js +9 -0
- package/dist/models/schema.js.map +1 -1
- package/dist/models/types.d.ts +8 -0
- package/dist/models/types.d.ts.map +1 -1
- package/dist/services/claude-plugin-service.d.ts +9 -0
- package/dist/services/claude-plugin-service.d.ts.map +1 -1
- package/dist/services/claude-plugin-service.js +22 -0
- package/dist/services/claude-plugin-service.js.map +1 -1
- package/dist/services/migration-registry.d.ts +94 -0
- package/dist/services/migration-registry.d.ts.map +1 -0
- package/dist/services/migration-registry.js +106 -0
- package/dist/services/migration-registry.js.map +1 -0
- package/dist/services/runtime-manager-service.d.ts +26 -0
- package/dist/services/runtime-manager-service.d.ts.map +1 -1
- package/dist/services/runtime-manager-service.js +39 -5
- package/dist/services/runtime-manager-service.js.map +1 -1
- package/dist/services/upgrade-offer-service.d.ts +89 -0
- package/dist/services/upgrade-offer-service.d.ts.map +1 -0
- package/dist/services/upgrade-offer-service.js +256 -0
- package/dist/services/upgrade-offer-service.js.map +1 -0
- package/dist/services/upgrade-service.d.ts +156 -0
- package/dist/services/upgrade-service.d.ts.map +1 -0
- package/dist/services/upgrade-service.js +510 -0
- package/dist/services/upgrade-service.js.map +1 -0
- 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
|