akm-cli 0.10.261019 → 0.10.261021
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 +46 -0
- package/STABILITY.md +0 -8
- package/dist/assets/templates/html/metrics.html +14 -8
- package/dist/assets/ui/overview.js +2 -2
- package/dist/commands/health/llm-usage.js +16 -2
- package/dist/commands/health/plugin-staleness.js +126 -112
- package/dist/commands/health/stash-exposure.js +23 -5
- package/dist/commands/health/type-directory-check.js +17 -49
- package/dist/commands/health.js +45 -15
- package/dist/commands/metrics/collect.js +9 -1
- package/dist/commands/metrics/metrics-cli.js +1 -1
- package/dist/commands/metrics/report-view.js +20 -9
- package/dist/commands/sources/plugin-channels.js +147 -0
- package/dist/commands/sources/plugin-upgrade.js +341 -59
- package/dist/commands/sources/self-update.js +6 -3
- package/dist/commands/sources/source-add.js +7 -3
- package/dist/commands/sources/sources-cli.js +15 -2
- package/dist/core/common.js +4 -1
- package/dist/indexer/passes/metadata.js +8 -6
- package/dist/indexer/walk/matchers.js +10 -0
- package/dist/output/text/command-format.js +10 -3
- package/dist/scripts/akm-migrate-node.js +69 -40
- package/dist/scripts/akm-migrate.js +69 -40
- package/dist/workflows/exec/param-secrets.js +29 -1
- package/docs/architecture/internals/health-advisories.md +3 -3
- package/docs/architecture/testing/testing-workflow.md +13 -7
- package/docs/reference/cli.md +39 -11
- package/package.json +3 -1
|
@@ -14,11 +14,14 @@ import fs from "node:fs";
|
|
|
14
14
|
import os from "node:os";
|
|
15
15
|
import path from "node:path";
|
|
16
16
|
import { IS_WINDOWS, parseJsonc } from "../../core/common.js";
|
|
17
|
+
import { UsageError } from "../../core/errors.js";
|
|
17
18
|
import { moveToTrash } from "../../core/trash.js";
|
|
18
19
|
import { OPENCODE_V1, OPENCODE_V2, readPackageVersion, } from "../../integrations/harnesses/opencode/plugin-layout.js";
|
|
19
20
|
import { opencodeCacheDir, opencodeConfigDir } from "../../integrations/harnesses/opencode/roots.js";
|
|
20
21
|
import { detectOpencodeMajor, OPENCODE_BIN, resolveOpencodeBin } from "../../integrations/harnesses/opencode/version.js";
|
|
22
|
+
import { isExactSemver, isPrerelease } from "../../registry/semver.js";
|
|
21
23
|
import { semverOrder } from "../../runtime.js";
|
|
24
|
+
import { ChannelWriteError, claudeChannelEdits, codexChannelEdit, codexMarketplace, editChannelJson, writeChannelEdits, } from "./plugin-channels.js";
|
|
22
25
|
const MARKETPLACE = "akm-plugins";
|
|
23
26
|
const PLUGIN_ID = `akm@${MARKETPLACE}`;
|
|
24
27
|
const READ_TIMEOUT_MS = 30_000;
|
|
@@ -60,6 +63,13 @@ function skipped(harness, message) {
|
|
|
60
63
|
function failed(harness, message) {
|
|
61
64
|
return { harness, outcome: "failed", message };
|
|
62
65
|
}
|
|
66
|
+
function channelWriteFailure(harness, error, before) {
|
|
67
|
+
return {
|
|
68
|
+
...failed(harness, String(error)),
|
|
69
|
+
channel: error instanceof ChannelWriteError && error.writtenFiles.length ? "unknown" : before,
|
|
70
|
+
...(error instanceof ChannelWriteError ? { backups: error.backups } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
63
73
|
/** An entry for a refresh that ran: `updated` when the installed version moved, else `current`. */
|
|
64
74
|
function refreshed(harness, from, to) {
|
|
65
75
|
const moved = from !== undefined && to !== undefined && from !== to;
|
|
@@ -74,48 +84,88 @@ function unknownRefresh(harness, from) {
|
|
|
74
84
|
message: "checking needs a fetch of the akm-plugins marketplace, which --check does not do; `akm upgrade` refreshes it",
|
|
75
85
|
};
|
|
76
86
|
}
|
|
77
|
-
function
|
|
87
|
+
function claudePlugins() {
|
|
78
88
|
const listed = runCommand("claude", ["plugin", "list", "--json"], READ_TIMEOUT_MS);
|
|
79
89
|
if (!listed.ok)
|
|
80
90
|
return { error: listed };
|
|
81
91
|
const plugins = parseJson(listed.stdout);
|
|
82
|
-
const
|
|
83
|
-
|
|
92
|
+
const installations = Array.isArray(plugins)
|
|
93
|
+
? plugins
|
|
94
|
+
.filter((p) => p?.id === PLUGIN_ID)
|
|
95
|
+
.map((p) => ({
|
|
96
|
+
version: typeof p.version === "string" ? p.version : undefined,
|
|
97
|
+
scope: typeof p.scope === "string" ? p.scope : undefined,
|
|
98
|
+
projectPath: typeof p.projectPath === "string" ? p.projectPath : undefined,
|
|
99
|
+
}))
|
|
100
|
+
: [];
|
|
101
|
+
return { installations };
|
|
84
102
|
}
|
|
85
|
-
function detectClaudeCode() {
|
|
103
|
+
function detectClaudeCode(strict = false) {
|
|
86
104
|
const marketplaces = runCommand("claude", ["plugin", "marketplace", "list", "--json"], READ_TIMEOUT_MS);
|
|
87
105
|
if (!marketplaces.ok) {
|
|
88
|
-
|
|
106
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
|
107
|
+
const pluginsRoot = process.env.AKM_CLAUDE_PLUGINS_DIR ?? process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR ?? path.join(configDir, "plugins");
|
|
108
|
+
const configured = fs.existsSync(path.join(pluginsRoot, "known_marketplaces.json"));
|
|
109
|
+
return {
|
|
110
|
+
entry: strict && (!marketplaces.missing || configured)
|
|
111
|
+
? { ...failed("claude-code", marketplaces.error), channel: "unknown" }
|
|
112
|
+
: skipped("claude-code", marketplaces.missing ? "claude is not on PATH" : marketplaces.error),
|
|
113
|
+
};
|
|
89
114
|
}
|
|
90
115
|
const list = parseJson(marketplaces.stdout);
|
|
91
116
|
const marketplace = Array.isArray(list) ? list.find((m) => m?.name === MARKETPLACE) : undefined;
|
|
92
117
|
if (!marketplace)
|
|
93
118
|
return { entry: skipped("claude-code", `the ${MARKETPLACE} marketplace is not configured`) };
|
|
94
|
-
const plugin =
|
|
119
|
+
const plugin = claudePlugins();
|
|
95
120
|
if ("error" in plugin)
|
|
96
|
-
return {
|
|
97
|
-
|
|
121
|
+
return {
|
|
122
|
+
entry: strict
|
|
123
|
+
? { ...failed("claude-code", plugin.error.error), channel: "unknown" }
|
|
124
|
+
: skipped("claude-code", plugin.error.error),
|
|
125
|
+
};
|
|
126
|
+
if (!plugin.installations.length)
|
|
98
127
|
return { entry: skipped("claude-code", "the akm plugin is not installed") };
|
|
99
|
-
return {
|
|
128
|
+
return {
|
|
129
|
+
version: plugin.installations[0]?.version,
|
|
130
|
+
installations: plugin.installations,
|
|
131
|
+
ref: typeof marketplace.ref === "string" ? marketplace.ref : undefined,
|
|
132
|
+
};
|
|
100
133
|
}
|
|
101
|
-
function upgradeClaudeCode(dryRun, next) {
|
|
102
|
-
const detected = detectClaudeCode();
|
|
134
|
+
function upgradeClaudeCode(dryRun, next, channel) {
|
|
135
|
+
const detected = detectClaudeCode(channel !== undefined);
|
|
103
136
|
if ("entry" in detected)
|
|
104
137
|
return detected.entry;
|
|
105
|
-
const
|
|
106
|
-
|
|
138
|
+
const before = refChannel(detected.ref);
|
|
139
|
+
let backups = [];
|
|
140
|
+
if (channel && !dryRun) {
|
|
141
|
+
try {
|
|
142
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
|
143
|
+
const pluginsRoot = process.env.AKM_CLAUDE_PLUGINS_DIR ??
|
|
144
|
+
process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR ??
|
|
145
|
+
path.join(configDir, "plugins");
|
|
146
|
+
backups = writeChannelEdits(claudeChannelEdits(configDir, pluginsRoot, channel, detected.installations.flatMap((p) => (p.projectPath ? [p.projectPath] : []))));
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
return channelWriteFailure("claude-code", error, before);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const entry = refreshClaudeCode(dryRun, detected.version, channel ? detected.installations : undefined);
|
|
153
|
+
const noted = next && !channel ? withChannelNote(entry, detected.ref) : entry;
|
|
154
|
+
return channelResult(noted, before, channel, dryRun, backups);
|
|
107
155
|
}
|
|
108
|
-
function refreshClaudeCode(dryRun, from) {
|
|
156
|
+
function refreshClaudeCode(dryRun, from, installations) {
|
|
109
157
|
if (dryRun)
|
|
110
158
|
return unknownRefresh("claude-code", from);
|
|
111
159
|
const marketplace = runCommand("claude", ["plugin", "marketplace", "update", MARKETPLACE], REFRESH_TIMEOUT_MS);
|
|
112
160
|
if (!marketplace.ok)
|
|
113
161
|
return failed("claude-code", marketplace.error);
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
162
|
+
for (const installation of installations ?? [{}]) {
|
|
163
|
+
const update = runCommand("claude", ["plugin", "update", PLUGIN_ID, ...(installation.scope ? ["--scope", installation.scope] : [])], REFRESH_TIMEOUT_MS, { cwd: installation.projectPath });
|
|
164
|
+
if (!update.ok)
|
|
165
|
+
return failed("claude-code", update.error);
|
|
166
|
+
}
|
|
167
|
+
const after = claudePlugins();
|
|
168
|
+
return refreshed("claude-code", from, "installations" in after ? after.installations[0]?.version : undefined);
|
|
119
169
|
}
|
|
120
170
|
// ── Codex ───────────────────────────────────────────────────────────────────
|
|
121
171
|
function codexPluginVersion() {
|
|
@@ -126,29 +176,39 @@ function codexPluginVersion() {
|
|
|
126
176
|
const plugin = parsed?.installed?.find((p) => p?.pluginId === PLUGIN_ID);
|
|
127
177
|
return { version: plugin ? String(plugin.version ?? "") : undefined };
|
|
128
178
|
}
|
|
129
|
-
|
|
179
|
+
function codexConfigFile() {
|
|
180
|
+
return path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "config.toml");
|
|
181
|
+
}
|
|
130
182
|
function codexMarketplaceRef() {
|
|
131
183
|
try {
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
const section = config.split(/^\[/m).find((part) => part.startsWith(`marketplaces.${MARKETPLACE}]`));
|
|
135
|
-
return section?.match(/^ref\s*=\s*"([^"]+)"/m)?.[1];
|
|
184
|
+
const ref = codexMarketplace(codexConfigFile())?.ref;
|
|
185
|
+
return typeof ref === "string" ? ref : undefined;
|
|
136
186
|
}
|
|
137
187
|
catch {
|
|
138
188
|
return undefined;
|
|
139
189
|
}
|
|
140
190
|
}
|
|
141
|
-
function upgradeCodex(dryRun, next) {
|
|
191
|
+
function upgradeCodex(dryRun, next, channel) {
|
|
142
192
|
const before = codexPluginVersion();
|
|
143
193
|
if ("error" in before)
|
|
144
|
-
return
|
|
194
|
+
return channel && (!before.error.missing || fs.existsSync(codexConfigFile()))
|
|
195
|
+
? { ...failed("codex", before.error.error), channel: "unknown" }
|
|
196
|
+
: skipped("codex", before.error.missing ? "codex is not on PATH" : before.error.error);
|
|
145
197
|
if (before.version === undefined) {
|
|
146
198
|
return skipped("codex", `the akm plugin is not installed from the ${MARKETPLACE} marketplace`);
|
|
147
199
|
}
|
|
200
|
+
const previous = refChannel(codexMarketplaceRef());
|
|
201
|
+
const backups = [];
|
|
202
|
+
if (channel && previous !== channel && !dryRun) {
|
|
203
|
+
try {
|
|
204
|
+
backups.push(...writeChannelEdits([codexChannelEdit(codexConfigFile(), channel)]));
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
return channelWriteFailure("codex", error, previous);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
148
210
|
const entry = refreshCodex(dryRun, before.version);
|
|
149
|
-
return next
|
|
150
|
-
? withChannelNote(entry, codexMarketplaceRef(), "run `codex plugin marketplace add itlackey/akm-plugins --ref next`")
|
|
151
|
-
: entry;
|
|
211
|
+
return channelResult(next && !channel ? withChannelNote(entry, codexMarketplaceRef()) : entry, previous, channel, dryRun, backups);
|
|
152
212
|
}
|
|
153
213
|
function refreshCodex(dryRun, from) {
|
|
154
214
|
if (dryRun)
|
|
@@ -192,10 +252,10 @@ export function openCodeConfigSpec(flavor) {
|
|
|
192
252
|
const entries = config[key];
|
|
193
253
|
if (!Array.isArray(entries))
|
|
194
254
|
continue;
|
|
195
|
-
for (const entry of entries) {
|
|
255
|
+
for (const [index, entry] of entries.entries()) {
|
|
196
256
|
const spec = entrySpec(entry);
|
|
197
257
|
if (spec !== undefined && namesPackage(spec, flavor.pkg))
|
|
198
|
-
return { spec, key };
|
|
258
|
+
return { spec, key, file, jsonPath: [key, index, ...(Array.isArray(entry) ? [0] : [])] };
|
|
199
259
|
}
|
|
200
260
|
}
|
|
201
261
|
}
|
|
@@ -317,7 +377,7 @@ function upgradeOpenCode(dryRun, target) {
|
|
|
317
377
|
return failed(harness, latest.error);
|
|
318
378
|
if (target.notFollowingNext)
|
|
319
379
|
return skipped(harness, notFollowingNext(flavor));
|
|
320
|
-
if (cache.version === latest.version)
|
|
380
|
+
if (!target.switchChannel && cache.version === latest.version)
|
|
321
381
|
return { harness, outcome: "current", from: cache.version, to: latest.version };
|
|
322
382
|
const base = { harness, from: cache.version, to: latest.version };
|
|
323
383
|
// OpenCode 1's refresh replaces the cache the running OpenCode is using, so it waits for the exit.
|
|
@@ -350,12 +410,27 @@ function upgradeOpenCode(dryRun, target) {
|
|
|
350
410
|
return skipped(harness, `\`${bin}\` on PATH is ${major === undefined ? "of an unknown version" : `OpenCode ${major}`}, ` +
|
|
351
411
|
`not OpenCode ${flavor.major}, so it cannot refresh the ${flavor.pkg} cache; the cache is left alone`);
|
|
352
412
|
}
|
|
353
|
-
|
|
413
|
+
let backups = [];
|
|
414
|
+
if (target.switchChannel) {
|
|
415
|
+
const configured = openCodeConfigSpec(flavor);
|
|
416
|
+
if (!configured)
|
|
417
|
+
return failed(harness, "the configured plugin entry disappeared before switching its channel");
|
|
418
|
+
try {
|
|
419
|
+
backups = writeChannelEdits([editChannelJson(configured.file, configured.jsonPath, target.spec)]);
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
return channelWriteFailure(harness, error, target.currentChannel ?? "unknown");
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (flavor.replacesCache && fs.existsSync(cache.dir)) {
|
|
354
426
|
try {
|
|
355
427
|
moveToTrash(cache.dir);
|
|
356
428
|
}
|
|
357
429
|
catch (error) {
|
|
358
|
-
return
|
|
430
|
+
return {
|
|
431
|
+
...failed(harness, `could not move ${cache.dir} to the trash: ${error instanceof Error ? error.message : String(error)}`),
|
|
432
|
+
backups,
|
|
433
|
+
};
|
|
359
434
|
}
|
|
360
435
|
}
|
|
361
436
|
const trashed = flavor.replacesCache ? " (the old cache is in the trash)" : "";
|
|
@@ -369,36 +444,64 @@ function upgradeOpenCode(dryRun, target) {
|
|
|
369
444
|
refresh = runCommand(bin, flavor.refresh(target.spec), PREFETCH_TIMEOUT_MS, { cwd: workDir });
|
|
370
445
|
}
|
|
371
446
|
if (!refresh.ok)
|
|
372
|
-
return failed(harness, `${refresh.error}${trashed}`);
|
|
447
|
+
return { ...failed(harness, `${refresh.error}${trashed}`), backups };
|
|
373
448
|
}
|
|
374
449
|
finally {
|
|
375
450
|
fs.rmSync(workDir, { recursive: true, force: true });
|
|
376
451
|
}
|
|
377
452
|
const after = detectOpenCodeCache(tag, flavor);
|
|
378
453
|
if (!after)
|
|
379
|
-
return failed(harness, `opencode did not re-create the plugin cache${trashed}`);
|
|
380
|
-
return { ...base, outcome: "updated", to: after.version ?? latest.version };
|
|
454
|
+
return { ...failed(harness, `opencode did not re-create the plugin cache${trashed}`), backups };
|
|
455
|
+
return { ...base, outcome: "updated", to: after.version ?? latest.version, ...(backups.length ? { backups } : {}) };
|
|
381
456
|
}
|
|
382
457
|
// ── Orchestration ───────────────────────────────────────────────────────────
|
|
383
458
|
/**
|
|
384
459
|
* The note `--next` adds to a marketplace harness. The channel is the ref the marketplace follows (`next` is the
|
|
385
|
-
* prerelease branch, the default branch is stable);
|
|
460
|
+
* prerelease branch, the default branch is stable); only an explicit --channel switches it.
|
|
386
461
|
*/
|
|
387
|
-
function
|
|
462
|
+
function refChannel(ref) {
|
|
463
|
+
return !ref || ref === "main" || ref === "master" ? "latest" : ref === "next" ? "next" : "pinned";
|
|
464
|
+
}
|
|
465
|
+
function channelResult(entry, before, target, dryRun, backups = []) {
|
|
466
|
+
const changed = target !== undefined && target !== before;
|
|
467
|
+
return {
|
|
468
|
+
...entry,
|
|
469
|
+
channel: changed && !dryRun ? target : before,
|
|
470
|
+
...(target ? { targetChannel: target } : {}),
|
|
471
|
+
...(changed
|
|
472
|
+
? {
|
|
473
|
+
previousChannel: before,
|
|
474
|
+
message: `${entry.message ? `${entry.message}; ` : ""}${dryRun ? "would configure" : "configured"} channel ${before} → ${target}`,
|
|
475
|
+
}
|
|
476
|
+
: {}),
|
|
477
|
+
...(backups.length ? { backups } : {}),
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
function withChannelNote(entry, ref) {
|
|
388
481
|
if (entry.outcome === "skipped")
|
|
389
482
|
return entry;
|
|
390
483
|
const note = ref === "next"
|
|
391
484
|
? `--next: the ${MARKETPLACE} marketplace follows ref next, the prerelease channel`
|
|
392
|
-
: `--next: the ${MARKETPLACE} marketplace follows ${ref ? `ref ${ref}` : "the default branch (stable)"}
|
|
393
|
-
`not the prerelease channel; akm upgrade cannot switch it, ${howToSwitch} to follow prereleases`;
|
|
485
|
+
: `--next: the ${MARKETPLACE} marketplace follows ${ref ? `ref ${ref}` : "the default branch (stable)"}; use akm upgrade --channel next to switch all installed plugins`;
|
|
394
486
|
return { ...entry, message: entry.message ? `${entry.message}; ${note}` : note };
|
|
395
487
|
}
|
|
396
488
|
export function upgradePlugins(opts) {
|
|
397
489
|
return [
|
|
398
|
-
upgradeClaudeCode(opts.dryRun, opts.next),
|
|
399
|
-
upgradeCodex(opts.dryRun, opts.next),
|
|
400
|
-
...opts.openCode.map((target) =>
|
|
401
|
-
|
|
490
|
+
upgradeClaudeCode(opts.dryRun, opts.next, opts.channel),
|
|
491
|
+
upgradeCodex(opts.dryRun, opts.next, opts.channel),
|
|
492
|
+
...opts.openCode.map((target) => {
|
|
493
|
+
const entry = upgradeOpenCode(opts.dryRun, target);
|
|
494
|
+
if (target.currentChannel === undefined)
|
|
495
|
+
return entry;
|
|
496
|
+
const changed = entry.channel === undefined &&
|
|
497
|
+
target.switchChannel &&
|
|
498
|
+
((entry.backups?.length ?? 0) > 0 || entry.outcome === "updated" || entry.outcome === "current");
|
|
499
|
+
return channelResult(entry, entry.channel ?? target.currentChannel ?? "unknown", opts.dryRun || changed ? target.switchChannel : undefined, opts.dryRun, entry.backups);
|
|
500
|
+
}),
|
|
501
|
+
].map((entry) => ({
|
|
502
|
+
...entry,
|
|
503
|
+
...(entry.channel && opts.currentChannel ? { channelMismatch: entry.channel !== opts.currentChannel } : {}),
|
|
504
|
+
}));
|
|
402
505
|
}
|
|
403
506
|
/**
|
|
404
507
|
* When an OpenCode plugin is present the CLI target is the akm-cli that
|
|
@@ -469,10 +572,32 @@ function lockstepSource(targets) {
|
|
|
469
572
|
* naming any other spec (an exact version, or `@next` on a plain run) is left
|
|
470
573
|
* alone, with no lockstep.
|
|
471
574
|
*/
|
|
472
|
-
function resolveOpenCodeTarget(next, flavor, configured) {
|
|
575
|
+
function resolveOpenCodeTarget(next, flavor, configured, channel) {
|
|
473
576
|
// OpenCode 1 with no config entry behaves like a bare one: it would resolve @latest. OpenCode 2's
|
|
474
577
|
// re-fetch writes the config, so its plugin is only refreshed where the config already names it.
|
|
475
|
-
const
|
|
578
|
+
const currentSpec = configured?.spec ?? flavor.pkg;
|
|
579
|
+
const currentChannel = currentSpec === flavor.pkg || currentSpec === `${flavor.pkg}@latest`
|
|
580
|
+
? "latest"
|
|
581
|
+
: currentSpec === nextSpec(flavor)
|
|
582
|
+
? "next"
|
|
583
|
+
: "pinned";
|
|
584
|
+
const spec = channel && configured ? (channel === "next" ? nextSpec(flavor) : flavor.pkg) : currentSpec;
|
|
585
|
+
if (channel && configured) {
|
|
586
|
+
const fromSpec = currentSpec === flavor.pkg ? `${flavor.pkg}@latest` : currentSpec;
|
|
587
|
+
const oldDir = path.join(opencodeCacheDir(), flavor.cacheFolder(fromSpec));
|
|
588
|
+
const oldModules = flavor.nodeModulesDir(oldDir);
|
|
589
|
+
const version = oldModules ? readPackageVersion(path.join(oldModules, flavor.pkg, "package.json")) : undefined;
|
|
590
|
+
return {
|
|
591
|
+
flavor,
|
|
592
|
+
spec,
|
|
593
|
+
tag: channel,
|
|
594
|
+
currentChannel,
|
|
595
|
+
notFollowingNext: false,
|
|
596
|
+
switchChannel: currentChannel !== channel ? channel : undefined,
|
|
597
|
+
cache: { dir: path.join(opencodeCacheDir(), flavor.cacheFolder(`${flavor.pkg}@${channel}`)), version },
|
|
598
|
+
latest: lookupOpenCodeLatest(channel, flavor),
|
|
599
|
+
};
|
|
600
|
+
}
|
|
476
601
|
const leftAlone = (skip) => ({
|
|
477
602
|
flavor,
|
|
478
603
|
cache: undefined,
|
|
@@ -480,6 +605,7 @@ function resolveOpenCodeTarget(next, flavor, configured) {
|
|
|
480
605
|
tag: next ? "next" : "latest",
|
|
481
606
|
spec,
|
|
482
607
|
notFollowingNext: false,
|
|
608
|
+
currentChannel: configured ? currentChannel : undefined,
|
|
483
609
|
skip,
|
|
484
610
|
});
|
|
485
611
|
if (flavor.globalConfigOnly) {
|
|
@@ -499,45 +625,201 @@ function resolveOpenCodeTarget(next, flavor, configured) {
|
|
|
499
625
|
const tag = followNext ? "next" : "latest";
|
|
500
626
|
const cache = detectOpenCodeCache(tag, flavor);
|
|
501
627
|
const latest = cache ? lookupOpenCodeLatest(tag, flavor) : undefined;
|
|
502
|
-
return {
|
|
628
|
+
return {
|
|
629
|
+
flavor,
|
|
630
|
+
cache,
|
|
631
|
+
latest,
|
|
632
|
+
tag,
|
|
633
|
+
spec,
|
|
634
|
+
currentChannel: configured || cache ? currentChannel : undefined,
|
|
635
|
+
notFollowingNext: next && !followNext,
|
|
636
|
+
};
|
|
503
637
|
}
|
|
504
638
|
/**
|
|
505
639
|
* The OpenCode plugins this host has: OpenCode 1's always, OpenCode 2's only when the global config
|
|
506
640
|
* names `akm-opencode-v2` or OpenCode 2 has cached it, so a host with just OpenCode 1 sees the same
|
|
507
641
|
* output as before.
|
|
508
642
|
*/
|
|
509
|
-
function resolveOpenCodeTargets(next) {
|
|
510
|
-
const targets = [resolveOpenCodeTarget(next, OPENCODE_V1, openCodeConfigSpec(OPENCODE_V1))];
|
|
643
|
+
function resolveOpenCodeTargets(next, channel) {
|
|
644
|
+
const targets = [resolveOpenCodeTarget(next, OPENCODE_V1, openCodeConfigSpec(OPENCODE_V1), channel)];
|
|
511
645
|
const v2 = openCodeConfigSpec(OPENCODE_V2);
|
|
512
646
|
if (v2 || openCodeV2Cached())
|
|
513
|
-
targets.push(resolveOpenCodeTarget(next, OPENCODE_V2, v2));
|
|
647
|
+
targets.push(resolveOpenCodeTarget(next, OPENCODE_V2, v2, channel));
|
|
514
648
|
return targets;
|
|
515
649
|
}
|
|
650
|
+
/** Inspect the requested branch in a disposable clone before changing any installation. */
|
|
651
|
+
function marketplaceTargetVersion(source, channel, harness) {
|
|
652
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "akm-channel-check-"));
|
|
653
|
+
const checkout = path.join(root, "marketplace");
|
|
654
|
+
try {
|
|
655
|
+
const cloned = runCommand("git", [
|
|
656
|
+
"clone",
|
|
657
|
+
"--depth",
|
|
658
|
+
"1",
|
|
659
|
+
"--single-branch",
|
|
660
|
+
...(channel === "next" ? ["--branch", "next"] : []),
|
|
661
|
+
"--",
|
|
662
|
+
source,
|
|
663
|
+
checkout,
|
|
664
|
+
], REFRESH_TIMEOUT_MS);
|
|
665
|
+
if (!cloned.ok)
|
|
666
|
+
throw new UsageError(`Could not check the target plugin version: ${cloned.error}`);
|
|
667
|
+
const catalog = parseJson(fs.readFileSync(path.join(checkout, ...(harness === "codex" ? [".agents", "plugins"] : [".claude-plugin"]), "marketplace.json"), "utf8"));
|
|
668
|
+
const plugin = catalog?.plugins?.find((p) => p.name === "akm");
|
|
669
|
+
const relative = typeof plugin?.source === "string"
|
|
670
|
+
? plugin.source
|
|
671
|
+
: plugin?.source?.source === "local"
|
|
672
|
+
? plugin.source.path
|
|
673
|
+
: undefined;
|
|
674
|
+
const local = relative ? path.resolve(checkout, relative) : undefined;
|
|
675
|
+
const contained = local === checkout || local?.startsWith(`${checkout}${path.sep}`);
|
|
676
|
+
const version = plugin?.version ??
|
|
677
|
+
(local && contained
|
|
678
|
+
? readPackageVersion(path.join(local, harness === "codex" ? ".codex-plugin" : ".claude-plugin", "plugin.json"))
|
|
679
|
+
: undefined);
|
|
680
|
+
if (!version || !isExactSemver(version))
|
|
681
|
+
throw new UsageError("Could not determine the requested marketplace's akm plugin version; no channel was changed");
|
|
682
|
+
return version;
|
|
683
|
+
}
|
|
684
|
+
finally {
|
|
685
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
function preflightMarketplaceChannels(channel, allowDowngrade) {
|
|
689
|
+
const versions = new Map();
|
|
690
|
+
const verify = (harness, source, installed) => {
|
|
691
|
+
if (allowDowngrade)
|
|
692
|
+
return;
|
|
693
|
+
const key = `${harness}:${source}`;
|
|
694
|
+
let target = versions.get(key);
|
|
695
|
+
if (!target) {
|
|
696
|
+
target = marketplaceTargetVersion(source, channel, harness);
|
|
697
|
+
versions.set(key, target);
|
|
698
|
+
}
|
|
699
|
+
if (installed && semverOrder(target, installed) < 0)
|
|
700
|
+
throw new UsageError(`Switching to ${channel} would downgrade ${harness} from ${installed} to ${target}; repeat with --allow-downgrade`);
|
|
701
|
+
};
|
|
702
|
+
const claude = detectClaudeCode(true);
|
|
703
|
+
if ("entry" in claude && claude.entry.outcome === "failed")
|
|
704
|
+
throw new UsageError(`Could not inventory Claude before switching channels: ${claude.entry.message}`);
|
|
705
|
+
if (!("entry" in claude)) {
|
|
706
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
|
707
|
+
const pluginsRoot = process.env.AKM_CLAUDE_PLUGINS_DIR ?? process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR ?? path.join(configDir, "plugins");
|
|
708
|
+
claudeChannelEdits(configDir, pluginsRoot, channel, claude.installations.flatMap((p) => (p.projectPath ? [p.projectPath] : [])));
|
|
709
|
+
const known = parseJson(fs.readFileSync(path.join(pluginsRoot, "known_marketplaces.json"), "utf8"));
|
|
710
|
+
const source = known[MARKETPLACE]?.source;
|
|
711
|
+
const url = source?.source === "github" && source.repo ? `https://github.com/${source.repo}.git` : source?.url;
|
|
712
|
+
if (!url)
|
|
713
|
+
throw new UsageError("Could not determine the Claude marketplace Git source; no channel was changed");
|
|
714
|
+
for (const installed of claude.installations)
|
|
715
|
+
verify("claude", url, installed.version);
|
|
716
|
+
}
|
|
717
|
+
const codex = codexPluginVersion();
|
|
718
|
+
if ("error" in codex && (!codex.error.missing || fs.existsSync(codexConfigFile())))
|
|
719
|
+
throw new UsageError(`Could not inventory Codex before switching channels: ${codex.error.error}`);
|
|
720
|
+
if (!("error" in codex) && codex.version !== undefined) {
|
|
721
|
+
codexChannelEdit(codexConfigFile(), channel);
|
|
722
|
+
const source = codexMarketplace(codexConfigFile())?.source;
|
|
723
|
+
if (typeof source !== "string")
|
|
724
|
+
throw new UsageError("Could not determine the Codex marketplace Git source; no channel was changed");
|
|
725
|
+
verify("codex", source, codex.version);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
516
728
|
/** `akm upgrade`: the CLI step (held to the OpenCode plugin's akm), then the plugins. */
|
|
517
729
|
export async function runUpgrade(args, currentVersion, deps) {
|
|
518
|
-
|
|
730
|
+
if (args.channel !== undefined && args.channel !== "latest" && args.channel !== "next")
|
|
731
|
+
throw new UsageError("--channel must be latest or next");
|
|
732
|
+
if (args.next && args.channel === "latest")
|
|
733
|
+
throw new UsageError("--next conflicts with --channel latest");
|
|
734
|
+
if (args.allowDowngrade && !args.channel)
|
|
735
|
+
throw new UsageError("--allow-downgrade requires --channel latest or next");
|
|
736
|
+
const switchChannel = args.channel;
|
|
737
|
+
const next = switchChannel ? switchChannel === "next" : args.next === true;
|
|
519
738
|
const channel = next ? "next" : "latest";
|
|
520
|
-
const
|
|
739
|
+
const currentChannel = isPrerelease(currentVersion) ? "next" : "latest";
|
|
740
|
+
const openCode = resolveOpenCodeTargets(next, switchChannel);
|
|
521
741
|
const held = lockstepSource(openCode);
|
|
522
742
|
const check = {
|
|
523
|
-
...applyLockstep(await deps.checkForUpdate(currentVersion, channel), held?.latest, held?.tag ?? "latest", held?.flavor.pkg ?? OPENCODE_V1.pkg),
|
|
743
|
+
...applyLockstep(await deps.checkForUpdate(currentVersion, channel, switchChannel !== undefined), held?.latest, held?.tag ?? "latest", held?.flavor.pkg ?? OPENCODE_V1.pkg),
|
|
524
744
|
channel,
|
|
745
|
+
currentChannel,
|
|
746
|
+
...(switchChannel ? { targetChannel: switchChannel } : {}),
|
|
525
747
|
};
|
|
748
|
+
if (switchChannel &&
|
|
749
|
+
held?.latest &&
|
|
750
|
+
!("error" in held.latest) &&
|
|
751
|
+
held.latest.akmCli &&
|
|
752
|
+
semverOrder(held.latest.akmCli, check.latestVersion) < 0) {
|
|
753
|
+
check.latestVersion = held.latest.akmCli;
|
|
754
|
+
check.updateAvailable = semverOrder(currentVersion, check.latestVersion) < 0;
|
|
755
|
+
}
|
|
526
756
|
if (args.check) {
|
|
527
|
-
return {
|
|
757
|
+
return {
|
|
758
|
+
mode: "check",
|
|
759
|
+
result: {
|
|
760
|
+
...check,
|
|
761
|
+
plugins: upgradePlugins({ dryRun: true, next, openCode, channel: switchChannel, currentChannel }),
|
|
762
|
+
},
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
if (switchChannel && !args.allowDowngrade) {
|
|
766
|
+
if (semverOrder(check.latestVersion, currentVersion) < 0)
|
|
767
|
+
throw new UsageError(`Switching to ${channel} would downgrade akm from ${currentVersion} to ${check.latestVersion}; repeat with --allow-downgrade`);
|
|
768
|
+
for (const target of openCode) {
|
|
769
|
+
if (target.cache?.version &&
|
|
770
|
+
target.latest &&
|
|
771
|
+
!("error" in target.latest) &&
|
|
772
|
+
semverOrder(target.latest.version, target.cache.version) < 0)
|
|
773
|
+
throw new UsageError(`Switching to ${channel} would downgrade ${target.flavor.pkg} from ${target.cache.version} to ${target.latest.version}; repeat with --allow-downgrade`);
|
|
774
|
+
}
|
|
528
775
|
}
|
|
776
|
+
if (switchChannel)
|
|
777
|
+
preflightMarketplaceChannels(switchChannel, args.allowDowngrade === true);
|
|
778
|
+
if (switchChannel && args.allowDowngrade && check.latestVersion !== currentVersion)
|
|
779
|
+
check.updateAvailable = true;
|
|
529
780
|
const upgraded = await deps.performUpgrade(check, {
|
|
530
781
|
force: args.force,
|
|
531
782
|
skipPostUpgrade: args.skipPostUpgrade,
|
|
532
783
|
// A package manager install must name the version, or `@latest` goes past the pin
|
|
533
784
|
// (or past the prerelease that `--next` chose).
|
|
534
|
-
...(check.lockstep?.heldBack || (next && check.latestVersion)
|
|
785
|
+
...(switchChannel || check.lockstep?.heldBack || (next && check.latestVersion)
|
|
786
|
+
? { targetVersion: check.latestVersion }
|
|
787
|
+
: {}),
|
|
535
788
|
});
|
|
536
|
-
|
|
537
|
-
|
|
789
|
+
if (switchChannel &&
|
|
790
|
+
(upgraded.migration?.status === "blocked" ||
|
|
791
|
+
upgraded.migration?.status === "failed" ||
|
|
792
|
+
(check.latestVersion !== currentVersion && !upgraded.upgraded))) {
|
|
793
|
+
return {
|
|
794
|
+
mode: "upgrade",
|
|
795
|
+
result: { ...upgraded, channel, currentChannel, targetChannel: switchChannel, plugins: [] },
|
|
796
|
+
failed: true,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
const plugins = upgradePlugins({
|
|
800
|
+
dryRun: false,
|
|
801
|
+
next,
|
|
802
|
+
openCode,
|
|
803
|
+
channel: switchChannel,
|
|
804
|
+
currentChannel: upgraded.upgraded ? (isPrerelease(upgraded.newVersion) ? "next" : "latest") : currentChannel,
|
|
805
|
+
});
|
|
806
|
+
const result = {
|
|
807
|
+
...upgraded,
|
|
808
|
+
channel,
|
|
809
|
+
currentChannel,
|
|
810
|
+
...(switchChannel ? { targetChannel: switchChannel } : {}),
|
|
811
|
+
...(check.lockstep ? { lockstep: check.lockstep } : {}),
|
|
812
|
+
plugins,
|
|
813
|
+
};
|
|
538
814
|
// The install may have succeeded, but an upgrade whose migration is
|
|
539
815
|
// blocked or could not run is not done, and neither is one whose plugin
|
|
540
816
|
// step failed.
|
|
541
817
|
const migrationFailed = upgraded.migration?.status === "blocked" || upgraded.migration?.status === "failed";
|
|
542
|
-
|
|
818
|
+
const switchIncomplete = switchChannel !== undefined &&
|
|
819
|
+
plugins.some((p) => p.channel !== undefined && (p.channel !== switchChannel || p.outcome === "deferred" || p.outcome === "skipped"));
|
|
820
|
+
return {
|
|
821
|
+
mode: "upgrade",
|
|
822
|
+
result,
|
|
823
|
+
failed: migrationFailed || switchIncomplete || plugins.some((p) => p.outcome === "failed"),
|
|
824
|
+
};
|
|
543
825
|
}
|
|
@@ -187,11 +187,12 @@ async function lookupNextVersion(fetchOptions) {
|
|
|
187
187
|
/**
|
|
188
188
|
* The newest release. With `channel: "next"` that includes prereleases: the
|
|
189
189
|
* `next` dist-tag when it is newer than the latest stable release, else the
|
|
190
|
-
* stable release
|
|
190
|
+
* stable release. An explicit channel switch sets exactChannel to select the
|
|
191
|
+
* actual next tag, leaving downgrade consent to runUpgrade.
|
|
191
192
|
* A binary install downloads the GitHub release tagged `v<version>`, which a
|
|
192
193
|
* prerelease has too, so the version is all the install step needs.
|
|
193
194
|
*/
|
|
194
|
-
export async function checkForUpdate(currentVersion, fetchOptions, channel = "latest") {
|
|
195
|
+
export async function checkForUpdate(currentVersion, fetchOptions, channel = "latest", exactChannel = false) {
|
|
195
196
|
const installMethod = detectInstallMethod();
|
|
196
197
|
const url = `https://api.github.com/repos/${REPO}/releases/latest`;
|
|
197
198
|
const response = await fetchWithRetry(url, { headers: githubHeaders() }, fetchOptions);
|
|
@@ -203,7 +204,9 @@ export async function checkForUpdate(currentVersion, fetchOptions, channel = "la
|
|
|
203
204
|
let latestVersion = latestTag.replace(/^v/, "");
|
|
204
205
|
if (channel === "next") {
|
|
205
206
|
const next = await lookupNextVersion(fetchOptions);
|
|
206
|
-
if (
|
|
207
|
+
if (exactChannel && !next)
|
|
208
|
+
throw new Error("No next prerelease is published; no channel was changed");
|
|
209
|
+
if (next && (exactChannel || latestVersion === "" || semverOrder(latestVersion, next) < 0))
|
|
207
210
|
latestVersion = next;
|
|
208
211
|
}
|
|
209
212
|
return {
|
|
@@ -219,7 +219,9 @@ async function addRegistryStash(ref, stashDir, explicitName, writable, position
|
|
|
219
219
|
: [];
|
|
220
220
|
const synced = await syncFromRef(ref, {
|
|
221
221
|
writable: effectiveWritable,
|
|
222
|
-
|
|
222
|
+
// Reuse a checkout only when the install is already writable; promoting a
|
|
223
|
+
// read-only snapshot (#1296) materializes a fresh checkout instead.
|
|
224
|
+
...(existingWritable && priorLock?.localRoot ? { writableRoot: priorLock.localRoot } : {}),
|
|
223
225
|
...(requiredRoots.length > 0 ? { writableRequiredRoots: requiredRoots } : {}),
|
|
224
226
|
...(credentialRef
|
|
225
227
|
? { credential: resolveSecret(credentialRef, storeSecretResolver.resolveSecret) }
|
|
@@ -253,8 +255,10 @@ async function addRegistryStash(ref, stashDir, explicitName, writable, position
|
|
|
253
255
|
localRoot: synced.contentDir,
|
|
254
256
|
installedAt: synced.syncedAt,
|
|
255
257
|
});
|
|
256
|
-
// Clean up the old materialized root on re-install (moved cache).
|
|
257
|
-
|
|
258
|
+
// Clean up the old materialized root on re-install (moved cache). A writable
|
|
259
|
+
// checkout may hold local work and is never removed; a read-only snapshot
|
|
260
|
+
// (including one just promoted to writable, #1296) is.
|
|
261
|
+
if (!existingWritable && priorLocalRoot && path.resolve(priorLocalRoot) !== path.resolve(synced.contentDir)) {
|
|
258
262
|
try {
|
|
259
263
|
fs.rmSync(priorLocalRoot, { recursive: true, force: true });
|
|
260
264
|
}
|
|
@@ -52,6 +52,12 @@ export const upgradeCommand = defineJsonCommand({
|
|
|
52
52
|
description: "Skip the post-upgrade index rebuild",
|
|
53
53
|
default: false,
|
|
54
54
|
},
|
|
55
|
+
channel: { type: "string", description: "Switch akm and every installed harness plugin to latest or next" },
|
|
56
|
+
"allow-downgrade": {
|
|
57
|
+
type: "boolean",
|
|
58
|
+
description: "Allow an explicit channel switch to install an older release",
|
|
59
|
+
default: false,
|
|
60
|
+
},
|
|
55
61
|
next: {
|
|
56
62
|
type: "boolean",
|
|
57
63
|
description: "Install the newest prerelease (the @next npm tag) of akm and, if OpenCode's config names akm-opencode@next, of its plugin",
|
|
@@ -59,8 +65,15 @@ export const upgradeCommand = defineJsonCommand({
|
|
|
59
65
|
},
|
|
60
66
|
},
|
|
61
67
|
async run({ args }) {
|
|
62
|
-
const run = await runUpgrade({
|
|
63
|
-
|
|
68
|
+
const run = await runUpgrade({
|
|
69
|
+
check: args.check,
|
|
70
|
+
force: args.force,
|
|
71
|
+
skipPostUpgrade: args["skip-post-upgrade"],
|
|
72
|
+
next: args.next,
|
|
73
|
+
channel: args.channel,
|
|
74
|
+
allowDowngrade: args["allow-downgrade"],
|
|
75
|
+
}, pkgVersion, {
|
|
76
|
+
checkForUpdate: (version, channel, exactChannel) => checkForUpdate(version, undefined, channel, exactChannel),
|
|
64
77
|
performUpgrade: (check, opts) => performUpgrade(check, opts),
|
|
65
78
|
});
|
|
66
79
|
if (run.mode === "check") {
|