akm-cli 0.9.0-rc.2 → 0.9.0-rc.3
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 +34 -8
- package/dist/assets/improve-strategies/catchup.json +3 -1
- package/dist/assets/improve-strategies/consolidate.json +3 -1
- package/dist/assets/improve-strategies/frequent.json +3 -1
- package/dist/assets/improve-strategies/graph-refresh.json +3 -1
- package/dist/assets/improve-strategies/memory-focus.json +4 -1
- package/dist/assets/improve-strategies/proactive-maintenance.json +1 -0
- package/dist/assets/improve-strategies/quick.json +3 -1
- package/dist/assets/improve-strategies/recombine-only.json +2 -0
- package/dist/assets/improve-strategies/reflect-distill.json +1 -0
- package/dist/assets/improve-strategies/synthesize.json +2 -0
- package/dist/assets/tasks/core/backup.yml +2 -2
- package/dist/cli/config-migrate.js +554 -26
- package/dist/cli.js +3 -1
- package/dist/commands/backup-cli.js +6 -4
- package/dist/commands/config-cli.js +10 -3
- package/dist/commands/feedback-cli.js +24 -7
- package/dist/commands/health/checks.js +94 -15
- package/dist/commands/health/surfaces.js +2 -1
- package/dist/commands/improve/anti-collapse.js +3 -3
- package/dist/commands/improve/collapse-detector.js +5 -5
- package/dist/commands/improve/consolidate.js +123 -66
- package/dist/commands/improve/distill/promote-memory.js +8 -6
- package/dist/commands/improve/distill/quality-gate.js +1 -1
- package/dist/commands/improve/distill-guards.js +1 -1
- package/dist/commands/improve/distill-promotion-policy.js +32 -26
- package/dist/commands/improve/distill.js +52 -36
- package/dist/commands/improve/eligibility.js +9 -8
- package/dist/commands/improve/extract-prompt.js +2 -2
- package/dist/commands/improve/extract.js +43 -11
- package/dist/commands/improve/improve-auto-accept.js +14 -28
- package/dist/commands/improve/improve-cli.js +4 -2
- package/dist/commands/improve/improve-strategies.js +2 -1
- package/dist/commands/improve/improve.js +49 -8
- package/dist/commands/improve/loop-stages.js +11 -6
- package/dist/commands/improve/preparation.js +51 -17
- package/dist/commands/improve/procedural.js +10 -6
- package/dist/commands/improve/recombine.js +31 -7
- package/dist/commands/improve/reflect.js +24 -14
- package/dist/commands/improve/salience.js +5 -1
- package/dist/commands/improve/source-identity.js +43 -0
- package/dist/commands/migrate-cli.js +36 -0
- package/dist/commands/mv-cli.js +647 -258
- package/dist/commands/proposal/drain.js +16 -4
- package/dist/commands/proposal/proposal-cli.js +3 -2
- package/dist/commands/proposal/proposal.js +16 -55
- package/dist/commands/proposal/repository.js +664 -58
- package/dist/commands/read/curate.js +4 -2
- package/dist/commands/read/knowledge.js +4 -1
- package/dist/commands/read/search.js +6 -2
- package/dist/commands/read/show.js +8 -7
- package/dist/commands/sources/self-update.js +156 -112
- package/dist/commands/sources/sources-cli.js +7 -2
- package/dist/core/common.js +36 -3
- package/dist/core/config/config-io.js +2 -2
- package/dist/core/config/config-schema.js +15 -3
- package/dist/core/config/config.js +14 -13
- package/dist/core/file-lock.js +2 -2
- package/dist/core/migration-backup.js +816 -171
- package/dist/core/migration-operation.js +44 -0
- package/dist/core/state/migrations.js +4 -7
- package/dist/core/state-db.js +22 -7
- package/dist/core/write-source.js +86 -18
- package/dist/indexer/db/db.js +109 -53
- package/dist/indexer/index-writer-lock.js +38 -37
- package/dist/indexer/index-written-assets.js +73 -56
- package/dist/indexer/indexer.js +5 -1
- package/dist/indexer/search/search-source.js +2 -2
- package/dist/indexer/usage/usage-events.js +8 -2
- package/dist/integrations/agent/engine-resolution.js +14 -7
- package/dist/scripts/migrate-storage.js +867 -990
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +405 -566
- package/dist/setup/detected-engines.js +7 -13
- package/dist/setup/engine-config.js +14 -4
- package/dist/setup/setup.js +1 -1
- package/dist/setup/steps/connection.js +1 -1
- package/dist/setup/steps/tasks.js +1 -1
- package/dist/sources/providers/git-stash.js +58 -21
- package/dist/sources/providers/git.js +1 -1
- package/dist/storage/engines/sqlite-migrations.js +138 -3
- package/dist/storage/repositories/events-repository.js +24 -0
- package/dist/storage/repositories/proposals-repository.js +14 -0
- package/dist/tasks/embedded.js +2 -0
- package/dist/workflows/db.js +24 -13
- package/dist/workflows/validator.js +24 -2
- package/docs/migration/release-notes/0.9.0.md +24 -7
- package/docs/migration/v0.8-to-v0.9.md +124 -19
- package/package.json +1 -1
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* `mergeCurateSearchResponses`) by importing them directly.
|
|
15
15
|
*/
|
|
16
16
|
import fs from "node:fs";
|
|
17
|
-
import { parseAssetRef } from "../../core/asset/asset-ref.js";
|
|
17
|
+
import { parseAssetRef, refToString } from "../../core/asset/asset-ref.js";
|
|
18
18
|
import { parseFrontmatter } from "../../core/asset/frontmatter.js";
|
|
19
19
|
import { getIndexPassConfig, loadConfig } from "../../core/config/config.js";
|
|
20
20
|
import { rethrowIfTestIsolationError, UsageError } from "../../core/errors.js";
|
|
@@ -78,10 +78,12 @@ function logCurateEvent(query, result, eventSource = "user") {
|
|
|
78
78
|
for (const item of result.items) {
|
|
79
79
|
if (!("ref" in item) || typeof item.ref !== "string")
|
|
80
80
|
continue;
|
|
81
|
+
const parsed = parseAssetRef(item.ref);
|
|
82
|
+
const itemOrigin = "origin" in item && typeof item.origin === "string" ? item.origin : undefined;
|
|
81
83
|
insertUsageEvent(db, {
|
|
82
84
|
event_type: "curate",
|
|
83
85
|
query,
|
|
84
|
-
entry_ref:
|
|
86
|
+
entry_ref: refToString({ ...parsed, ...(parsed.origin || !itemOrigin ? {} : { origin: itemOrigin }) }),
|
|
85
87
|
source: eventSource,
|
|
86
88
|
});
|
|
87
89
|
}
|
|
@@ -21,7 +21,7 @@ import { loadConfig } from "../../core/config/config.js";
|
|
|
21
21
|
import { UsageError } from "../../core/errors.js";
|
|
22
22
|
import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
|
|
23
23
|
import { warn } from "../../core/warn.js";
|
|
24
|
-
import { commitWriteTargetBoundary, formatRefForMessage, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
|
|
24
|
+
import { commitWriteTargetBoundary, formatRefForMessage, recordWriteTargetPath, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
|
|
25
25
|
import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
|
|
26
26
|
import { resolveSourceEntries } from "../../indexer/search/search-source.js";
|
|
27
27
|
import { fetchWebsiteMarkdownSnapshot, shouldAllowPrivateWebsiteUrlForTests } from "../../sources/website-ingest.js";
|
|
@@ -528,6 +528,9 @@ export async function writeMarkdownAsset(options) {
|
|
|
528
528
|
// Degrade to the same applied:false report the non-writable path uses.
|
|
529
529
|
try {
|
|
530
530
|
writeSupersededEdge(item.filePath, result.ref);
|
|
531
|
+
if (path.resolve(item.stashRoot) === path.resolve(source.path)) {
|
|
532
|
+
recordWriteTargetPath(source, item.filePath);
|
|
533
|
+
}
|
|
531
534
|
}
|
|
532
535
|
catch (error) {
|
|
533
536
|
const reason = `demotion failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* a registry search behind `--source registry|both`, and log a usage event.
|
|
12
12
|
* Provider `search()` methods do not exist.
|
|
13
13
|
*/
|
|
14
|
+
import { parseAssetRef, refToString } from "../../core/asset/asset-ref.js";
|
|
14
15
|
import { loadConfig } from "../../core/config/config.js";
|
|
15
16
|
import { rethrowIfTestIsolationError, UsageError } from "../../core/errors.js";
|
|
16
17
|
import { appendEvent } from "../../core/events.js";
|
|
@@ -191,8 +192,11 @@ function resolveEntryIds(db, hits) {
|
|
|
191
192
|
for (const hit of hits) {
|
|
192
193
|
try {
|
|
193
194
|
const entryId = getEntryIdByFilePath(db, hit.path);
|
|
194
|
-
if (entryId !== undefined)
|
|
195
|
-
|
|
195
|
+
if (entryId !== undefined) {
|
|
196
|
+
const parsed = parseAssetRef(hit.ref);
|
|
197
|
+
const origin = parsed.origin ?? hit.origin ?? undefined;
|
|
198
|
+
results.push({ entryId, ref: refToString({ ...parsed, ...(origin ? { origin } : {}) }) });
|
|
199
|
+
}
|
|
196
200
|
}
|
|
197
201
|
catch {
|
|
198
202
|
/* skip unresolvable */
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
import fs from "node:fs";
|
|
20
20
|
import path from "node:path";
|
|
21
21
|
import { findCittyTopLevelCommandIndex } from "../../cli/parse-args.js";
|
|
22
|
-
import { parseAssetRef } from "../../core/asset/asset-ref.js";
|
|
22
|
+
import { parseAssetRef, refToString } from "../../core/asset/asset-ref.js";
|
|
23
23
|
import { parseFrontmatter } from "../../core/asset/frontmatter.js";
|
|
24
24
|
import { META_DIR, parseMetaRef, resolveMetaFilePath } from "../../core/asset/stash-meta.js";
|
|
25
25
|
import { asNonEmptyString } from "../../core/common.js";
|
|
26
26
|
import { getIndexPassConfig, loadConfig } from "../../core/config/config.js";
|
|
27
27
|
import { NotFoundError, rethrowIfTestIsolationError, UsageError } from "../../core/errors.js";
|
|
28
28
|
import { appendEvent, readEvents } from "../../core/events.js";
|
|
29
|
-
import { closeDatabase, computeBodyHash, findEntryIdByRef, openExistingDatabase } from "../../indexer/db/db.js";
|
|
29
|
+
import { closeDatabase, computeBodyHash, findEntryIdByRef, getEntryIdByFilePath, openExistingDatabase, } from "../../indexer/db/db.js";
|
|
30
30
|
import { hasGraphData } from "../../indexer/db/graph-db.js";
|
|
31
31
|
import { listRelatedPathsForFile } from "../../indexer/graph/graph-boost.js";
|
|
32
32
|
import { extractGraphForSingleFile } from "../../indexer/graph/graph-extraction.js";
|
|
@@ -160,7 +160,7 @@ export async function akmShowUnified(input) {
|
|
|
160
160
|
}
|
|
161
161
|
// Count prior shows of this ref before logging the current one.
|
|
162
162
|
const priorShowCount = recentShowCount(ref);
|
|
163
|
-
logShowEvent(ref, input.eventSource);
|
|
163
|
+
logShowEvent(ref, input.eventSource, result.path, result.origin);
|
|
164
164
|
if (priorShowCount >= 2) {
|
|
165
165
|
// Agent has shown this same asset 3+ times — inject a loop-break hint.
|
|
166
166
|
result.showLoopWarning = priorShowCount + 1;
|
|
@@ -257,11 +257,12 @@ function recentShowCount(ref) {
|
|
|
257
257
|
return 0;
|
|
258
258
|
}
|
|
259
259
|
}
|
|
260
|
-
function logShowEvent(ref, eventSource = "user") {
|
|
260
|
+
function logShowEvent(ref, eventSource = "user", filePath, origin) {
|
|
261
261
|
// Emit a structured event to events.jsonl so workflow-trace consumers
|
|
262
262
|
// detect akm show invocations without relying on stdout scraping.
|
|
263
263
|
const parsed = parseAssetRef(ref);
|
|
264
|
-
|
|
264
|
+
const eventRef = refToString({ ...parsed, ...(parsed.origin || !origin ? {} : { origin }) });
|
|
265
|
+
appendEvent({ eventType: "show", ref: eventRef, metadata: { type: parsed.type, name: parsed.name } });
|
|
265
266
|
// Detect if this show is a selection from a recent search result.
|
|
266
267
|
try {
|
|
267
268
|
// D7: bound the query to the last 60 s so we never scan unbounded history
|
|
@@ -295,8 +296,8 @@ function logShowEvent(ref, eventSource = "user") {
|
|
|
295
296
|
withIndexDb((db) => {
|
|
296
297
|
insertUsageEvent(db, {
|
|
297
298
|
event_type: "show",
|
|
298
|
-
entry_ref:
|
|
299
|
-
entry_id: findEntryIdByRef(db,
|
|
299
|
+
entry_ref: eventRef,
|
|
300
|
+
entry_id: filePath ? getEntryIdByFilePath(db, filePath) : findEntryIdByRef(db, eventRef),
|
|
300
301
|
source: eventSource,
|
|
301
302
|
});
|
|
302
303
|
}, { busyTimeoutMs: TELEMETRY_BUSY_TIMEOUT_MS });
|
|
@@ -5,7 +5,7 @@ import * as childProcess from "node:child_process";
|
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
6
|
import fs from "node:fs";
|
|
7
7
|
import path from "node:path";
|
|
8
|
-
import { fetchWithRetry, IS_WINDOWS } from "../../core/common.js";
|
|
8
|
+
import { fetchWithRetry, IS_WINDOWS, ResponseTooLargeError, readBodyWithByteCap } from "../../core/common.js";
|
|
9
9
|
import { warn } from "../../core/warn.js";
|
|
10
10
|
import { githubHeaders } from "../../integrations/github.js";
|
|
11
11
|
import { getDirname, mainPath, semverOrder } from "../../runtime.js";
|
|
@@ -14,6 +14,61 @@ const DEFAULT_PACKAGE_NAME = "akm-cli";
|
|
|
14
14
|
const NODE_MODULES_SEGMENT = "/node_modules/";
|
|
15
15
|
const BUN_GLOBAL_INSTALL_PATTERN = /(^|\/)\.bun\/(?:[^/]+\/)+node_modules\//;
|
|
16
16
|
const PNPM_GLOBAL_INSTALL_PATTERN = /(^|\/)(?:pnpm\/global|\.pnpm-global)(?:\/\d+)?\/node_modules\//;
|
|
17
|
+
const MIGRATION_CONTRACT_VERSION = "0.9.0-rc.0";
|
|
18
|
+
const MAX_BINARY_DOWNLOAD_BYTES = 256 * 1024 * 1024;
|
|
19
|
+
const MAX_CHECKSUM_METADATA_BYTES = 1024 * 1024;
|
|
20
|
+
export function defaultMigrationCommand(preparedConfigPath) {
|
|
21
|
+
const configArgs = preparedConfigPath ? ["--config", preparedConfigPath] : [];
|
|
22
|
+
return {
|
|
23
|
+
preflight: (akmBin) => runRequiredCommand(akmBin, ["migrate", "status"], "Migration preflight"),
|
|
24
|
+
stagedPreflight: (akmBin) => runRequiredCommand(akmBin, ["migrate", "status", ...configArgs], "Staged migration preflight"),
|
|
25
|
+
apply: (akmBin) => runRequiredCommand(akmBin, ["migrate", "apply", ...configArgs], "Migration apply"),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
async function streamResponseToFile(response, destination, maxBytes) {
|
|
29
|
+
const declaredText = response.headers.get("content-length");
|
|
30
|
+
if (declaredText) {
|
|
31
|
+
const declared = Number(declaredText);
|
|
32
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
33
|
+
await response.body?.cancel().catch(() => undefined);
|
|
34
|
+
throw new ResponseTooLargeError(response.url || "binary download", maxBytes, declared);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (!response.body)
|
|
38
|
+
throw new Error("Binary download response did not provide a streaming body.");
|
|
39
|
+
const fd = fs.openSync(destination, "w", 0o600);
|
|
40
|
+
const reader = response.body.getReader();
|
|
41
|
+
const hash = createHash("sha256");
|
|
42
|
+
let byteSize = 0;
|
|
43
|
+
try {
|
|
44
|
+
while (true) {
|
|
45
|
+
const { done, value } = await reader.read();
|
|
46
|
+
if (done)
|
|
47
|
+
break;
|
|
48
|
+
if (!value)
|
|
49
|
+
continue;
|
|
50
|
+
byteSize += value.byteLength;
|
|
51
|
+
if (byteSize > maxBytes) {
|
|
52
|
+
await reader.cancel().catch(() => undefined);
|
|
53
|
+
throw new ResponseTooLargeError(response.url || "binary download", maxBytes, byteSize);
|
|
54
|
+
}
|
|
55
|
+
hash.update(value);
|
|
56
|
+
let written = 0;
|
|
57
|
+
while (written < value.byteLength)
|
|
58
|
+
written += fs.writeSync(fd, value, written, value.byteLength - written);
|
|
59
|
+
}
|
|
60
|
+
fs.fdatasyncSync(fd);
|
|
61
|
+
return { byteSize, sha256: hash.digest("hex") };
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
removeFileBestEffort(destination);
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
reader.releaseLock();
|
|
69
|
+
fs.closeSync(fd);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
17
72
|
/** Read live runtime signals. */
|
|
18
73
|
export function getInstallSignals() {
|
|
19
74
|
return {
|
|
@@ -72,7 +127,7 @@ export async function checkForUpdate(currentVersion) {
|
|
|
72
127
|
if (!response.ok) {
|
|
73
128
|
throw new Error(`Failed to check for updates: ${response.status} ${response.statusText}`);
|
|
74
129
|
}
|
|
75
|
-
const release = (await response
|
|
130
|
+
const release = JSON.parse(await readBodyWithByteCap(response, MAX_CHECKSUM_METADATA_BYTES));
|
|
76
131
|
const latestTag = release.tag_name ?? "";
|
|
77
132
|
const latestVersion = latestTag.replace(/^v/, "");
|
|
78
133
|
return {
|
|
@@ -82,10 +137,11 @@ export async function checkForUpdate(currentVersion) {
|
|
|
82
137
|
installMethod,
|
|
83
138
|
};
|
|
84
139
|
}
|
|
85
|
-
export async function performUpgrade(check, opts) {
|
|
140
|
+
export async function performUpgrade(check, opts, dependencies) {
|
|
86
141
|
const { currentVersion, latestVersion, installMethod } = check;
|
|
87
142
|
const force = opts?.force === true;
|
|
88
143
|
const skipPostUpgrade = opts?.skipPostUpgrade === true;
|
|
144
|
+
const migration = dependencies?.migration ?? defaultMigrationCommand(opts?.migrationConfig);
|
|
89
145
|
// All install methods can short-circuit here unless the user explicitly forces an upgrade.
|
|
90
146
|
if (!check.updateAvailable && !force) {
|
|
91
147
|
return {
|
|
@@ -96,11 +152,19 @@ export async function performUpgrade(check, opts) {
|
|
|
96
152
|
message: `akm v${currentVersion} is already the latest version`,
|
|
97
153
|
};
|
|
98
154
|
}
|
|
155
|
+
if (latestVersion &&
|
|
156
|
+
semverOrder(currentVersion, MIGRATION_CONTRACT_VERSION) < 0 &&
|
|
157
|
+
semverOrder(latestVersion, MIGRATION_CONTRACT_VERSION) >= 0) {
|
|
158
|
+
throw new Error("AKM 0.8 does not implement the migrate command or the --migration-config upgrade contract, so self-update cannot safely cross into 0.9. " +
|
|
159
|
+
"Prepare the 0.9 config and an independent backup, install or stage the 0.9 binary manually, then use the new binary to run: " +
|
|
160
|
+
"akm migrate apply --config <prepared-0.9.json>. See docs/migration/v0.8-to-v0.9.md.");
|
|
161
|
+
}
|
|
99
162
|
const packageManagerCommand = getPackageManagerUpgradeCommand(installMethod);
|
|
100
163
|
if (packageManagerCommand) {
|
|
101
164
|
if (!latestVersion) {
|
|
102
165
|
throw new Error("Unable to determine latest version from GitHub releases. Check https://github.com/itlackey/akm/releases");
|
|
103
166
|
}
|
|
167
|
+
migration.preflight("akm");
|
|
104
168
|
const result = childProcess.spawnSync(packageManagerCommand.command, packageManagerCommand.args, {
|
|
105
169
|
encoding: "utf8",
|
|
106
170
|
env: process.env,
|
|
@@ -113,6 +177,7 @@ export async function performUpgrade(check, opts) {
|
|
|
113
177
|
const details = (result.stderr ?? "").trim() || (result.stdout ?? "").trim() || `exit code ${result.status}`;
|
|
114
178
|
throw new Error(`Failed to upgrade akm via ${installMethod}: ${details}\nRun manually: ${packageManagerCommand.displayCommand}`);
|
|
115
179
|
}
|
|
180
|
+
migration.apply("akm");
|
|
116
181
|
return {
|
|
117
182
|
currentVersion,
|
|
118
183
|
newVersion: latestVersion,
|
|
@@ -139,12 +204,34 @@ export async function performUpgrade(check, opts) {
|
|
|
139
204
|
const binaryName = getAkmBinaryName();
|
|
140
205
|
const binaryUrl = `https://github.com/${REPO}/releases/download/${tag}/${binaryName}`;
|
|
141
206
|
const checksumsUrl = `https://github.com/${REPO}/releases/download/${tag}/checksums.txt`;
|
|
207
|
+
const execPath = dependencies?.execPath ?? process.execPath;
|
|
208
|
+
const execDir = path.dirname(execPath);
|
|
209
|
+
const execName = path.basename(execPath);
|
|
210
|
+
const stagedPath = IS_WINDOWS
|
|
211
|
+
? path.join(execDir, `.${execName}.new.${process.pid}`)
|
|
212
|
+
: path.join(execDir, `.${execName}.tmp.${process.pid}`);
|
|
213
|
+
const backupPath = `${execPath}${IS_WINDOWS ? ".old" : ".bak"}`;
|
|
142
214
|
// Download binary
|
|
143
215
|
const binaryResponse = await fetchWithRetry(binaryUrl);
|
|
144
216
|
if (!binaryResponse.ok) {
|
|
145
217
|
throw new Error(`Failed to download binary: ${binaryResponse.status} ${binaryResponse.statusText}`);
|
|
146
218
|
}
|
|
147
|
-
|
|
219
|
+
let downloaded;
|
|
220
|
+
try {
|
|
221
|
+
downloaded = await streamResponseToFile(binaryResponse, stagedPath, MAX_BINARY_DOWNLOAD_BYTES);
|
|
222
|
+
if (!IS_WINDOWS)
|
|
223
|
+
fs.chmodSync(stagedPath, 0o755);
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
removeFileBestEffort(stagedPath);
|
|
227
|
+
const code = err.code;
|
|
228
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
229
|
+
throw new Error(`Permission denied writing to ${execDir}.\n` +
|
|
230
|
+
`${IS_WINDOWS ? "Try running as Administrator." : "Run: sudo akm upgrade"}\n` +
|
|
231
|
+
`Or re-run the install script: curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | bash`);
|
|
232
|
+
}
|
|
233
|
+
throw err;
|
|
234
|
+
}
|
|
148
235
|
// Download and verify checksum (mandatory — upgrade is blocked if checksums cannot be fetched)
|
|
149
236
|
let checksumVerified = false;
|
|
150
237
|
const skipChecksum = opts?.skipChecksum === true;
|
|
@@ -160,10 +247,10 @@ export async function performUpgrade(check, opts) {
|
|
|
160
247
|
}
|
|
161
248
|
}
|
|
162
249
|
else {
|
|
163
|
-
const checksumsText = await checksumsResponse
|
|
250
|
+
const checksumsText = await readBodyWithByteCap(checksumsResponse, MAX_CHECKSUM_METADATA_BYTES);
|
|
164
251
|
const expectedHash = parseChecksumForFile(checksumsText, binaryName);
|
|
165
252
|
if (expectedHash) {
|
|
166
|
-
const actualHash =
|
|
253
|
+
const actualHash = downloaded.sha256;
|
|
167
254
|
if (actualHash !== expectedHash) {
|
|
168
255
|
throw new Error(`Checksum mismatch for ${binaryName}.\n` + `Expected: ${expectedHash}\n` + `Got: ${actualHash}`);
|
|
169
256
|
}
|
|
@@ -183,6 +270,7 @@ export async function performUpgrade(check, opts) {
|
|
|
183
270
|
catch (err) {
|
|
184
271
|
if (err instanceof Error &&
|
|
185
272
|
(err.message.includes("Checksum mismatch") || err.message.includes("Checksum verification failed"))) {
|
|
273
|
+
removeFileBestEffort(stagedPath);
|
|
186
274
|
throw err;
|
|
187
275
|
}
|
|
188
276
|
// Network or parse failure
|
|
@@ -190,99 +278,46 @@ export async function performUpgrade(check, opts) {
|
|
|
190
278
|
warn(`WARNING: Could not fetch or parse checksums: ${err instanceof Error ? err.message : String(err)}. Proceeding because --skip-checksum was provided.`);
|
|
191
279
|
}
|
|
192
280
|
else {
|
|
281
|
+
removeFileBestEffort(stagedPath);
|
|
193
282
|
throw new Error(`Checksum verification failed: ${err instanceof Error ? err.message : String(err)}. ` +
|
|
194
283
|
`Use --skip-checksum to bypass (not recommended).`);
|
|
195
284
|
}
|
|
196
285
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (IS_WINDOWS) {
|
|
201
|
-
// Windows: rename running exe, write new one, clean up old on success
|
|
202
|
-
const oldPath = `${execPath}.old`;
|
|
203
|
-
try {
|
|
204
|
-
fs.renameSync(execPath, oldPath);
|
|
205
|
-
}
|
|
206
|
-
catch (err) {
|
|
207
|
-
const code = err.code;
|
|
208
|
-
if (code === "EPERM" || code === "EACCES") {
|
|
209
|
-
throw new Error(`Permission denied. Cannot rename ${execPath}.\n` +
|
|
210
|
-
`Try running as Administrator, or re-download from https://github.com/${REPO}/releases`);
|
|
211
|
-
}
|
|
212
|
-
const detail = err instanceof Error ? err.message : String(err);
|
|
213
|
-
throw new Error(`Failed to rename ${execPath}: ${detail}`);
|
|
214
|
-
}
|
|
215
|
-
try {
|
|
216
|
-
fs.writeFileSync(execPath, binaryData);
|
|
217
|
-
}
|
|
218
|
-
catch (err) {
|
|
219
|
-
// Restore from old
|
|
220
|
-
fs.renameSync(oldPath, execPath);
|
|
221
|
-
throw err;
|
|
222
|
-
}
|
|
223
|
-
// Best-effort cleanup of .old
|
|
224
|
-
try {
|
|
225
|
-
fs.unlinkSync(oldPath);
|
|
226
|
-
}
|
|
227
|
-
catch {
|
|
228
|
-
// Windows may lock the old exe — it will be cleaned up on next startup or manually
|
|
229
|
-
}
|
|
286
|
+
try {
|
|
287
|
+
migration.preflight(execPath);
|
|
288
|
+
migration.stagedPreflight(stagedPath);
|
|
230
289
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
`Run: sudo akm upgrade\n` +
|
|
251
|
-
`Or re-run the install script: curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | bash`);
|
|
252
|
-
}
|
|
253
|
-
throw err;
|
|
254
|
-
}
|
|
255
|
-
// Backup current, then atomic rename
|
|
256
|
-
try {
|
|
257
|
-
fs.copyFileSync(execPath, bakPath);
|
|
258
|
-
fs.renameSync(tmpPath, execPath);
|
|
259
|
-
}
|
|
260
|
-
catch (err) {
|
|
261
|
-
// Restore from backup if rename failed
|
|
262
|
-
try {
|
|
263
|
-
fs.unlinkSync(tmpPath);
|
|
264
|
-
}
|
|
265
|
-
catch {
|
|
266
|
-
/* ignore */
|
|
267
|
-
}
|
|
268
|
-
try {
|
|
269
|
-
if (fs.existsSync(bakPath) && !fs.existsSync(execPath)) {
|
|
270
|
-
fs.renameSync(bakPath, execPath);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
catch {
|
|
274
|
-
/* ignore */
|
|
275
|
-
}
|
|
276
|
-
throw err;
|
|
277
|
-
}
|
|
278
|
-
// Cleanup backup
|
|
279
|
-
try {
|
|
280
|
-
fs.unlinkSync(bakPath);
|
|
281
|
-
}
|
|
282
|
-
catch {
|
|
283
|
-
/* ignore */
|
|
290
|
+
catch (err) {
|
|
291
|
+
removeFileBestEffort(stagedPath);
|
|
292
|
+
throw err;
|
|
293
|
+
}
|
|
294
|
+
if (fs.existsSync(backupPath)) {
|
|
295
|
+
removeFileBestEffort(stagedPath);
|
|
296
|
+
throw new Error(`Refusing to overwrite retained previous binary at ${backupPath}.`);
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
if (IS_WINDOWS)
|
|
300
|
+
fs.renameSync(execPath, backupPath);
|
|
301
|
+
else
|
|
302
|
+
fs.copyFileSync(execPath, backupPath);
|
|
303
|
+
fs.renameSync(stagedPath, execPath);
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
removeFileBestEffort(stagedPath);
|
|
307
|
+
if (!fs.existsSync(execPath) && fs.existsSync(backupPath)) {
|
|
308
|
+
fs.renameSync(backupPath, execPath);
|
|
284
309
|
}
|
|
310
|
+
throw err;
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
migration.apply(execPath);
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
317
|
+
throw new Error(`Migration apply failed; the new binary remains installed and the previous binary retained at ${backupPath}: ${detail}`);
|
|
285
318
|
}
|
|
319
|
+
// Keep the previous binary available until migration apply has completed.
|
|
320
|
+
removeFileBestEffort(backupPath);
|
|
286
321
|
return {
|
|
287
322
|
currentVersion,
|
|
288
323
|
newVersion: latestVersion,
|
|
@@ -290,32 +325,19 @@ export async function performUpgrade(check, opts) {
|
|
|
290
325
|
installMethod,
|
|
291
326
|
binaryPath: execPath,
|
|
292
327
|
checksumVerified,
|
|
293
|
-
// For binary installs, the new binary now lives at execPath; spawn it
|
|
294
|
-
// directly so the post-upgrade work runs against the new code.
|
|
295
328
|
postUpgrade: runPostUpgradeTasks(execPath, { skip: skipPostUpgrade }),
|
|
296
329
|
};
|
|
297
330
|
}
|
|
298
331
|
/**
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
* Why a child process: the running akm process still has the old code in
|
|
302
|
-
* memory. Calling loadConfig()/akmIndex() in-process would use the old
|
|
303
|
-
* implementations and miss any DB_VERSION / config-key changes the new
|
|
304
|
-
* release introduces.
|
|
305
|
-
*
|
|
306
|
-
* The new binary's `akm index` does the work for us:
|
|
307
|
-
* 1. loadConfig() runs at startup — auto-migrates legacy `stashes` →
|
|
308
|
-
* `sources` if the on-disk config still uses the old key.
|
|
309
|
-
* 2. ensureSchema() converges index.db forward via its idempotent baseline
|
|
310
|
-
* schema + additive migrations (no destructive rebuild).
|
|
311
|
-
* 3. The full reindex repopulates entries + workflow_documents + FTS.
|
|
332
|
+
* Rebuild the derived index after the explicit migration command succeeds.
|
|
333
|
+
* Migration and indexing are intentionally separate lifecycle steps.
|
|
312
334
|
*/
|
|
313
335
|
function runPostUpgradeTasks(akmBin, opts) {
|
|
314
336
|
if (opts.skip) {
|
|
315
337
|
return {
|
|
316
338
|
ok: true,
|
|
317
339
|
skipped: true,
|
|
318
|
-
message: "
|
|
340
|
+
message: "Migration completed; skipped the index rebuild. Run `akm index` manually to rebuild the index.",
|
|
319
341
|
};
|
|
320
342
|
}
|
|
321
343
|
try {
|
|
@@ -328,7 +350,7 @@ function runPostUpgradeTasks(akmBin, opts) {
|
|
|
328
350
|
return {
|
|
329
351
|
ok: false,
|
|
330
352
|
skipped: false,
|
|
331
|
-
message: `
|
|
353
|
+
message: `Migration completed, but the index rebuild could not start: ${result.error.message}. Run \`akm index\` manually.`,
|
|
332
354
|
};
|
|
333
355
|
}
|
|
334
356
|
if (result.status !== 0) {
|
|
@@ -337,14 +359,14 @@ function runPostUpgradeTasks(akmBin, opts) {
|
|
|
337
359
|
ok: false,
|
|
338
360
|
skipped: false,
|
|
339
361
|
exitCode: result.status,
|
|
340
|
-
message: `
|
|
362
|
+
message: `Migration completed, but post-upgrade \`akm index\` failed (${detail}). Run \`akm index\` manually.`,
|
|
341
363
|
};
|
|
342
364
|
}
|
|
343
365
|
return {
|
|
344
366
|
ok: true,
|
|
345
367
|
skipped: false,
|
|
346
368
|
exitCode: 0,
|
|
347
|
-
message: "
|
|
369
|
+
message: "Migration completed and the index was rebuilt against the new binary.",
|
|
348
370
|
};
|
|
349
371
|
}
|
|
350
372
|
catch (err) {
|
|
@@ -352,10 +374,32 @@ function runPostUpgradeTasks(akmBin, opts) {
|
|
|
352
374
|
return {
|
|
353
375
|
ok: false,
|
|
354
376
|
skipped: false,
|
|
355
|
-
message: `
|
|
377
|
+
message: `Migration completed, but the index rebuild failed: ${detail}. Run \`akm index\` manually.`,
|
|
356
378
|
};
|
|
357
379
|
}
|
|
358
380
|
}
|
|
381
|
+
function runRequiredCommand(akmBin, args, label) {
|
|
382
|
+
const result = childProcess.spawnSync(akmBin, args, {
|
|
383
|
+
encoding: "utf8",
|
|
384
|
+
env: process.env,
|
|
385
|
+
stdio: "pipe",
|
|
386
|
+
});
|
|
387
|
+
if (result.error) {
|
|
388
|
+
throw new Error(`${label} could not start: ${result.error.message}`);
|
|
389
|
+
}
|
|
390
|
+
if (result.status !== 0) {
|
|
391
|
+
const detail = (result.stderr ?? "").trim() || (result.stdout ?? "").trim() || `exit code ${result.status}`;
|
|
392
|
+
throw new Error(`${label} failed (${detail}).`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
function removeFileBestEffort(filePath) {
|
|
396
|
+
try {
|
|
397
|
+
fs.unlinkSync(filePath);
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
// Cleanup is best-effort; the primary operation determines success.
|
|
401
|
+
}
|
|
402
|
+
}
|
|
359
403
|
function parseChecksumForFile(checksumsText, filename) {
|
|
360
404
|
for (const line of checksumsText.split("\n")) {
|
|
361
405
|
const trimmed = line.trim();
|
|
@@ -115,9 +115,13 @@ export const upgradeCommand = defineJsonCommand({
|
|
|
115
115
|
},
|
|
116
116
|
"skip-post-upgrade": {
|
|
117
117
|
type: "boolean",
|
|
118
|
-
description: "Skip the post-upgrade
|
|
118
|
+
description: "Skip the post-upgrade index rebuild (migration preflight and apply still run)",
|
|
119
119
|
default: false,
|
|
120
120
|
},
|
|
121
|
+
"migration-config": {
|
|
122
|
+
type: "string",
|
|
123
|
+
description: "For 0.9+ upgrades, pass an operator-prepared config only to the new binary's migration apply",
|
|
124
|
+
},
|
|
121
125
|
},
|
|
122
126
|
async run({ args }) {
|
|
123
127
|
const check = await checkForUpdate(pkgVersion);
|
|
@@ -127,7 +131,8 @@ export const upgradeCommand = defineJsonCommand({
|
|
|
127
131
|
}
|
|
128
132
|
const skipChecksum = args["skip-checksum"];
|
|
129
133
|
const skipPostUpgrade = args["skip-post-upgrade"];
|
|
130
|
-
const
|
|
134
|
+
const migrationConfig = args["migration-config"];
|
|
135
|
+
const result = await performUpgrade(check, { force: args.force, skipChecksum, skipPostUpgrade, migrationConfig });
|
|
131
136
|
output("upgrade", result);
|
|
132
137
|
},
|
|
133
138
|
});
|
package/dist/core/common.js
CHANGED
|
@@ -27,6 +27,9 @@ export const ASSET_TYPES = Object.freeze([...getAssetTypes()]);
|
|
|
27
27
|
export const ASSET_TYPE_SET = new Set(ASSET_TYPES);
|
|
28
28
|
// ── Constants ───────────────────────────────────────────────────────────────
|
|
29
29
|
export const IS_WINDOWS = process.platform === "win32";
|
|
30
|
+
export const MAX_CONFIG_FILE_BYTES = 1024 * 1024;
|
|
31
|
+
export const MAX_LOCAL_METADATA_BYTES = 1024 * 1024;
|
|
32
|
+
export const MAX_LOCK_METADATA_BYTES = 64 * 1024;
|
|
30
33
|
export function isHttpUrl(value) {
|
|
31
34
|
return !!value && /^https?:\/\//.test(value);
|
|
32
35
|
}
|
|
@@ -58,6 +61,35 @@ export function isAssetType(type) {
|
|
|
58
61
|
return Object.hasOwn(TYPE_DIRS, type);
|
|
59
62
|
}
|
|
60
63
|
// ── Utilities ───────────────────────────────────────────────────────────────
|
|
64
|
+
export function readTextFileDescriptorWithLimit(fd, maxBytes, label = "File", displayPath = "(open file)") {
|
|
65
|
+
const stat = fs.fstatSync(fd);
|
|
66
|
+
if (!stat.isFile())
|
|
67
|
+
throw new ConfigError(`${label} is not a regular file: ${displayPath}.`, "INVALID_CONFIG_FILE");
|
|
68
|
+
if (stat.size > maxBytes) {
|
|
69
|
+
throw new ConfigError(`${label} exceeds the ${maxBytes}-byte limit: ${displayPath}.`, "INVALID_CONFIG_FILE");
|
|
70
|
+
}
|
|
71
|
+
const buffer = Buffer.allocUnsafe(maxBytes + 1);
|
|
72
|
+
let total = 0;
|
|
73
|
+
while (total <= maxBytes) {
|
|
74
|
+
const bytesRead = fs.readSync(fd, buffer, total, maxBytes + 1 - total, null);
|
|
75
|
+
if (bytesRead === 0)
|
|
76
|
+
break;
|
|
77
|
+
total += bytesRead;
|
|
78
|
+
}
|
|
79
|
+
if (total > maxBytes) {
|
|
80
|
+
throw new ConfigError(`${label} exceeds the ${maxBytes}-byte limit: ${displayPath}.`, "INVALID_CONFIG_FILE");
|
|
81
|
+
}
|
|
82
|
+
return buffer.subarray(0, total).toString("utf8");
|
|
83
|
+
}
|
|
84
|
+
export function readTextFileWithLimit(filePath, maxBytes, label = "File") {
|
|
85
|
+
const fd = fs.openSync(filePath, "r");
|
|
86
|
+
try {
|
|
87
|
+
return readTextFileDescriptorWithLimit(fd, maxBytes, label, filePath);
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
fs.closeSync(fd);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
61
93
|
/**
|
|
62
94
|
* Write content to a file atomically via a temp file + rename.
|
|
63
95
|
* Prevents partial-write corruption on crash.
|
|
@@ -172,7 +204,7 @@ function isValidDirectory(dir) {
|
|
|
172
204
|
function readStashDirFromConfig() {
|
|
173
205
|
try {
|
|
174
206
|
const configPath = getConfigPath();
|
|
175
|
-
const text =
|
|
207
|
+
const text = readTextFileWithLimit(configPath, MAX_CONFIG_FILE_BYTES, "Config file");
|
|
176
208
|
const raw = JSON.parse(text);
|
|
177
209
|
if (typeof raw === "object" && raw !== null && typeof raw.stashDir === "string" && raw.stashDir.trim()) {
|
|
178
210
|
return raw.stashDir.trim();
|
|
@@ -370,8 +402,9 @@ export async function readBodyWithByteCap(response, maxBytes = DEFAULT_RESPONSE_
|
|
|
370
402
|
// No streaming body available (e.g., some mock environments). Fall
|
|
371
403
|
// back to text() but still enforce the cap post-hoc.
|
|
372
404
|
const text = await response.text();
|
|
373
|
-
|
|
374
|
-
|
|
405
|
+
const byteLength = Buffer.byteLength(text, "utf8");
|
|
406
|
+
if (byteLength > maxBytes)
|
|
407
|
+
throw new ResponseTooLargeError(url, maxBytes, byteLength);
|
|
375
408
|
return text;
|
|
376
409
|
}
|
|
377
410
|
const reader = body.getReader();
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import fs from "node:fs";
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import { sleepSync } from "../../runtime.js";
|
|
18
|
-
import { writeFileAtomic } from "../common.js";
|
|
18
|
+
import { MAX_CONFIG_FILE_BYTES, readTextFileWithLimit, writeFileAtomic } from "../common.js";
|
|
19
19
|
import { ConfigError } from "../errors.js";
|
|
20
20
|
import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync } from "../file-lock.js";
|
|
21
21
|
import { getCacheDir, getConfigDir } from "../paths.js";
|
|
@@ -25,7 +25,7 @@ import { getCacheDir, getConfigDir } from "../paths.js";
|
|
|
25
25
|
*/
|
|
26
26
|
export function readConfigText(configPath) {
|
|
27
27
|
try {
|
|
28
|
-
return
|
|
28
|
+
return readTextFileWithLimit(configPath, MAX_CONFIG_FILE_BYTES, "Config file");
|
|
29
29
|
}
|
|
30
30
|
catch (err) {
|
|
31
31
|
if (err.code === "ENOENT")
|