akm-cli 0.10.261014-beta → 0.10.261016-beta
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 +118 -0
- package/README.md +7 -8
- package/SECURITY.md +3 -3
- package/STABILITY.md +2 -1
- package/dist/assets/hints/cli-hints-full.md +6 -4
- package/dist/assets/hints/cli-hints-short.md +4 -2
- package/dist/assets/opencode/akm-plugin.js +16 -8
- package/dist/assets/prompts/workflow-unit-preamble.md +3 -1
- package/dist/cli.js +2 -2
- package/dist/commands/health/plugin-staleness.js +86 -22
- package/dist/commands/health/semantic-search-check.js +2 -2
- package/dist/commands/read/search-cli.js +2 -2
- package/dist/commands/read/show.js +15 -3
- package/dist/core/asset/markdown.js +9 -0
- package/dist/core/config/config-walker.js +35 -32
- package/dist/core/config/schema/connections.js +6 -3
- package/dist/core/config/schema/harnesses.js +8 -1
- package/dist/core/config/schema/primitives.js +12 -1
- package/dist/core/config/schema/sources-bundles.js +2 -2
- package/dist/core/npm-package.js +124 -0
- package/dist/{integrations/harnesses/opencode/runtime → core}/tar.js +25 -16
- package/dist/integrations/harnesses/opencode/runtime/binary.js +8 -75
- package/dist/integrations/harnesses/opencode/runtime/index.js +5 -1
- package/dist/llm/embedders/local.js +23 -58
- package/dist/llm/embedders/onnx-runtime.js +121 -0
- package/dist/output/renderers.js +2 -2
- package/dist/output/shapes/helpers.js +2 -0
- package/dist/output/text/show-format.js +7 -0
- package/dist/registry/semver.js +12 -0
- package/dist/scripts/akm-migrate-node.js +21638 -5760
- package/dist/scripts/akm-migrate.js +21645 -5767
- package/dist/setup/semantic-assets.js +9 -6
- package/dist/setup/setup.js +56 -2
- package/dist/setup/steps/embeddings.js +21 -4
- package/dist/storage/database.js +17 -57
- package/dist/tasks/backends/cron.js +19 -0
- package/docs/architecture/akm-core-principles.md +3 -2
- package/docs/architecture/internals/health-advisories.md +2 -2
- package/docs/architecture/internals/storage-locations.md +1 -0
- package/docs/architecture/specs/ref.md +4 -3
- package/docs/architecture/testing/testing-workflow.md +28 -20
- package/docs/guides/discover-and-load.md +12 -4
- package/docs/guides/getting-started.md +23 -20
- package/docs/guides/recipes/headless-install.md +13 -8
- package/docs/guides/use-with-any-agent.md +4 -1
- package/docs/maintainers/release-checklist.md +15 -8
- package/docs/migration/v0.9-to-v0.10.md +17 -0
- package/docs/reference/cli.md +42 -12
- package/docs/reference/configuration.md +37 -15
- package/package.json +6 -9
- package/schemas/akm-config.json +535 -302
- package/dist/standalone/onnxruntime-library.js +0 -52
|
@@ -27,7 +27,7 @@ import { hasRegistryUrlCredentials, REGISTRY_CREDENTIALS_UNSUPPORTED } from "../
|
|
|
27
27
|
import { warnOnce } from "../warn.js";
|
|
28
28
|
import { AkmConfigBaseSchema, listTopLevelConfigKeys } from "./config-schema.js";
|
|
29
29
|
import { deepMergeConfig } from "./deep-merge.js";
|
|
30
|
-
import { isEnvReference } from "./schema/primitives.js";
|
|
30
|
+
import { isEnvReference, OpaqueFieldSchema } from "./schema/primitives.js";
|
|
31
31
|
/**
|
|
32
32
|
* Parse a dotted path into segments. Empty segments are rejected. Bracket
|
|
33
33
|
* notation (e.g. `sources[0]`) is NOT supported — arrays are set as JSON.
|
|
@@ -46,33 +46,39 @@ function parsePath(dotted) {
|
|
|
46
46
|
function unwrap(schema) {
|
|
47
47
|
let current = schema;
|
|
48
48
|
while (true) {
|
|
49
|
-
if (current instanceof z.ZodOptional
|
|
50
|
-
current
|
|
51
|
-
|
|
52
|
-
else if (current instanceof z.ZodDefault) {
|
|
53
|
-
current = current._def.innerType;
|
|
54
|
-
}
|
|
55
|
-
else if (current instanceof z.ZodNullable) {
|
|
56
|
-
current = current._def.innerType;
|
|
57
|
-
}
|
|
58
|
-
else if (current instanceof z.ZodCatch) {
|
|
49
|
+
if (current instanceof z.ZodOptional ||
|
|
50
|
+
current instanceof z.ZodDefault ||
|
|
51
|
+
current instanceof z.ZodNullable ||
|
|
59
52
|
// `.catch(...)` wraps an inner schema with a fallback value.
|
|
60
|
-
current
|
|
53
|
+
current instanceof z.ZodCatch ||
|
|
54
|
+
current instanceof z.ZodReadonly) {
|
|
55
|
+
current = current.def.innerType;
|
|
61
56
|
}
|
|
62
|
-
else if (current instanceof z.
|
|
63
|
-
// `.
|
|
64
|
-
// descend into the
|
|
65
|
-
|
|
66
|
-
current =
|
|
67
|
-
}
|
|
68
|
-
else if (current instanceof z.ZodReadonly) {
|
|
69
|
-
current = current._def.innerType;
|
|
57
|
+
else if (current instanceof z.ZodPipe) {
|
|
58
|
+
// `.transform()` pipes the schema into a transform and `z.preprocess()` pipes a transform into it:
|
|
59
|
+
// descend into the side that is a schema. (`.refine()` and `.superRefine()` wrap nothing.)
|
|
60
|
+
const { in: input, out } = current.def;
|
|
61
|
+
current = (input instanceof z.ZodTransform ? out : input);
|
|
70
62
|
}
|
|
71
63
|
else {
|
|
72
64
|
return current;
|
|
73
65
|
}
|
|
74
66
|
}
|
|
75
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* The schema of the keys an object takes without naming them (`index.<passName>`,
|
|
70
|
+
* a connection's request fields), or `undefined` when its extra keys are not
|
|
71
|
+
* ones akm defines. `.passthrough()` gives an object an `unknown` catchall, so
|
|
72
|
+
* only `OpaqueFieldSchema` itself says "any key, contents left to the endpoint".
|
|
73
|
+
*/
|
|
74
|
+
function namedCatchall(object) {
|
|
75
|
+
const catchall = object.def.catchall;
|
|
76
|
+
if (catchall === undefined || catchall instanceof z.ZodNever)
|
|
77
|
+
return undefined;
|
|
78
|
+
if (catchall instanceof z.ZodUnknown && catchall !== OpaqueFieldSchema)
|
|
79
|
+
return undefined;
|
|
80
|
+
return catchall;
|
|
81
|
+
}
|
|
76
82
|
/** True for a schema that accepts any value (`z.unknown()`): its contents are not keys akm defines. */
|
|
77
83
|
export function isOpaqueSchema(schema) {
|
|
78
84
|
return unwrap(schema) instanceof z.ZodUnknown;
|
|
@@ -91,13 +97,10 @@ export function resolveSchemaAt(path, config, raw) {
|
|
|
91
97
|
const next = schema.shape[segment];
|
|
92
98
|
if (!next) {
|
|
93
99
|
// Catchall (e.g. index.<passName>) — descend into the catchall schema.
|
|
94
|
-
const catchall = schema
|
|
95
|
-
if (
|
|
96
|
-
schema = catchall;
|
|
97
|
-
}
|
|
98
|
-
else {
|
|
100
|
+
const catchall = namedCatchall(schema);
|
|
101
|
+
if (!catchall)
|
|
99
102
|
return undefined;
|
|
100
|
-
|
|
103
|
+
schema = catchall;
|
|
101
104
|
}
|
|
102
105
|
else {
|
|
103
106
|
schema = next;
|
|
@@ -106,13 +109,13 @@ export function resolveSchemaAt(path, config, raw) {
|
|
|
106
109
|
else if (schema instanceof z.ZodRecord) {
|
|
107
110
|
// Named records (connections, harnesses, improve.strategies, sources, etc.)
|
|
108
111
|
// accept any string key — descend into the value schema.
|
|
109
|
-
schema = schema.
|
|
112
|
+
schema = schema.def.valueType;
|
|
110
113
|
}
|
|
111
114
|
else if (schema instanceof z.ZodUnion) {
|
|
112
|
-
const option = selectUnionObjectOption(schema.
|
|
115
|
+
const option = selectUnionObjectOption(schema.options, segment, existing, index === path.length - 1 ? raw : undefined);
|
|
113
116
|
if (!option)
|
|
114
117
|
return undefined;
|
|
115
|
-
schema = (option.shape[segment] ?? option
|
|
118
|
+
schema = (option.shape[segment] ?? namedCatchall(option));
|
|
116
119
|
}
|
|
117
120
|
else {
|
|
118
121
|
// Cannot descend into a non-object schema.
|
|
@@ -145,7 +148,7 @@ function selectUnionObjectOption(candidates, segment, existing, raw) {
|
|
|
145
148
|
}
|
|
146
149
|
return (options.find((option) => segment in option.shape) ??
|
|
147
150
|
// A model entry such as `{ model, reasoning_effort }` holds request fields the object does not name.
|
|
148
|
-
options.find((option) =>
|
|
151
|
+
options.find((option) => namedCatchall(option) !== undefined));
|
|
149
152
|
}
|
|
150
153
|
/**
|
|
151
154
|
* Get the value at the dotted path from a config-shaped object. Returns
|
|
@@ -350,7 +353,7 @@ function coerceForSchema(schema, raw, key) {
|
|
|
350
353
|
return raw;
|
|
351
354
|
}
|
|
352
355
|
if (target instanceof z.ZodLiteral) {
|
|
353
|
-
return typeof target.
|
|
356
|
+
return typeof target.value === "string" ? raw : target.value;
|
|
354
357
|
}
|
|
355
358
|
if (target instanceof z.ZodArray || target instanceof z.ZodObject || target instanceof z.ZodRecord) {
|
|
356
359
|
if (raw === "" || raw === "null")
|
|
@@ -366,7 +369,7 @@ function coerceForSchema(schema, raw, key) {
|
|
|
366
369
|
if (target instanceof z.ZodUnion) {
|
|
367
370
|
// Try each option in order. Use the first that the schema accepts after
|
|
368
371
|
// coercion. Falls through to JSON parsing if all fail.
|
|
369
|
-
const options = target.
|
|
372
|
+
const options = target.options;
|
|
370
373
|
let lastErr;
|
|
371
374
|
for (const opt of options) {
|
|
372
375
|
try {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { z } from "zod";
|
|
14
14
|
import { WORKFLOW_MAX_TIMEOUT_MS } from "../../../workflows/resource-limits.js";
|
|
15
|
-
import { connectionEndpoint, engineName, envReferenceApiKey, nonEmptyString, positiveInt, retiredApiKeyFile, } from "./primitives.js";
|
|
15
|
+
import { connectionEndpoint, engineName, envReferenceApiKey, nonEmptyString, OpaqueFieldSchema, positiveInt, retiredApiKeyFile, } from "./primitives.js";
|
|
16
16
|
/**
|
|
17
17
|
* Timeouts share the workflow ceiling: a larger value would pass config
|
|
18
18
|
* validation and then fail every run with an unlocated "invocation is invalid".
|
|
@@ -24,7 +24,10 @@ export const timeoutMsField = z.union([positiveInt.max(WORKFLOW_MAX_TIMEOUT_MS),
|
|
|
24
24
|
* request fields of its own. The object is listed first so a JSON value given to
|
|
25
25
|
* `akm config set` is read as one.
|
|
26
26
|
*/
|
|
27
|
-
export const ModelEntrySchema = z.union([
|
|
27
|
+
export const ModelEntrySchema = z.union([
|
|
28
|
+
z.object({ model: nonEmptyString }).catchall(OpaqueFieldSchema),
|
|
29
|
+
nonEmptyString,
|
|
30
|
+
]);
|
|
28
31
|
/** Alias (`fast`, `balanced`, `reasoning`, or any the user adds) to the model it names. */
|
|
29
32
|
export const ModelAliasesSchema = z.record(nonEmptyString, ModelEntrySchema);
|
|
30
33
|
export const ConnectionSchema = z
|
|
@@ -37,5 +40,5 @@ export const ConnectionSchema = z
|
|
|
37
40
|
timeoutMs: timeoutMsField,
|
|
38
41
|
concurrency: positiveInt.optional(),
|
|
39
42
|
})
|
|
40
|
-
.catchall(
|
|
43
|
+
.catchall(OpaqueFieldSchema);
|
|
41
44
|
export const ConnectionsSchema = z.record(engineName, ConnectionSchema);
|
|
@@ -13,7 +13,14 @@ import { ModelAliasesSchema, timeoutMsField } from "./connections.js";
|
|
|
13
13
|
import { engineName, nonEmptyString } from "./primitives.js";
|
|
14
14
|
export const HarnessSchema = z
|
|
15
15
|
.object({
|
|
16
|
-
|
|
16
|
+
// Zod 4's message for a value outside an enum no longer quotes the value; the warning for a dropped harness should.
|
|
17
|
+
platform: z
|
|
18
|
+
.enum(HARNESS_CONFIG_PLATFORMS, {
|
|
19
|
+
error: (issue) => issue.code === "invalid_value"
|
|
20
|
+
? `${JSON.stringify(issue.input)} is not one of ${HARNESS_CONFIG_PLATFORMS.join(", ")}`
|
|
21
|
+
: undefined,
|
|
22
|
+
})
|
|
23
|
+
.optional(),
|
|
17
24
|
bin: nonEmptyString.optional(),
|
|
18
25
|
args: z.array(z.string()).optional(),
|
|
19
26
|
workspace: nonEmptyString.optional(),
|
|
@@ -12,11 +12,22 @@ import { redactUrlCredentials } from "../../redaction.js";
|
|
|
12
12
|
import { isSecretRefToken } from "../../ref-token.js";
|
|
13
13
|
import { warnOnce } from "../../warn.js";
|
|
14
14
|
import { ENGINE_NAME_PATTERN_SOURCE } from "../engine-semantics.js";
|
|
15
|
+
// Zod 4 reports a map key its key schema rejects as "Invalid key in record" and keeps that schema's own message
|
|
16
|
+
// (the naming rule, the slug rule) inside the issue; config errors and warnings print only the outer message.
|
|
17
|
+
z.config({
|
|
18
|
+
customError: (issue) => issue.code === "invalid_key" ? issue.issues.map((inner) => inner.message).join("; ") : undefined,
|
|
19
|
+
});
|
|
15
20
|
/** Persisted config schema version. Package prerelease/patch versions do not change this value. */
|
|
16
21
|
export const CURRENT_CONFIG_VERSION = "0.9.0";
|
|
17
22
|
// ── Reusable atomic schemas ─────────────────────────────────────────────────
|
|
18
23
|
/** Positive integer (used for tokens, timeouts, batch sizes). */
|
|
19
24
|
export const positiveInt = z.number().int().positive();
|
|
25
|
+
/**
|
|
26
|
+
* The catchall of an object whose extra keys are the endpoint's to define (a connection's request fields):
|
|
27
|
+
* any key, contents left alone. The config walker knows it by identity: a `.passthrough()` object also has an
|
|
28
|
+
* `unknown` catchall, and its extra keys are ones akm does not define.
|
|
29
|
+
*/
|
|
30
|
+
export const OpaqueFieldSchema = z.unknown();
|
|
20
31
|
/** Non-negative finite number (used for scores, weights, days). */
|
|
21
32
|
export const nonNegativeNumber = z.number().finite().min(0);
|
|
22
33
|
/** Non-empty string (rejects "" and whitespace-only). */
|
|
@@ -92,7 +103,7 @@ export const connectionEndpoint = z.string().superRefine((value, ctx) => {
|
|
|
92
103
|
warnOnce(`connectionEndpoint:userinfo:${shown}`, `Config endpoint "${shown}" embeds a username/password; move the credential out of the URL (apiKey: "$VAR", or credentials.json).`);
|
|
93
104
|
}
|
|
94
105
|
});
|
|
95
|
-
export const ExtraParamsSchema = z.record(z.unknown()).superRefine((value, ctx) => {
|
|
106
|
+
export const ExtraParamsSchema = z.record(z.string(), z.unknown()).superRefine((value, ctx) => {
|
|
96
107
|
for (const issue of validateExtraParams(value)) {
|
|
97
108
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: issue.path, message: issue.message });
|
|
98
109
|
}
|
|
@@ -16,7 +16,7 @@ import { isBundleSlug } from "../../asset/asset-ref.js";
|
|
|
16
16
|
import { httpUrl, isApiKeyReference, nonEmptyString, positiveInt } from "./primitives.js";
|
|
17
17
|
const VALID_ADAPTER_IDS_SET = new Set(VALID_ADAPTER_IDS);
|
|
18
18
|
// ── Sources / registries / installed ────────────────────────────────────────
|
|
19
|
-
const SourceConfigEntryOptionsSchema = z.record(z.unknown());
|
|
19
|
+
const SourceConfigEntryOptionsSchema = z.record(z.string(), z.unknown());
|
|
20
20
|
export const SourceConfigEntrySchema = z
|
|
21
21
|
.object({
|
|
22
22
|
type: nonEmptyString,
|
|
@@ -74,7 +74,7 @@ export const RegistryConfigEntrySchema = z
|
|
|
74
74
|
name: z.string().min(1).optional(),
|
|
75
75
|
enabled: z.boolean().optional(),
|
|
76
76
|
provider: z.string().min(1).optional(),
|
|
77
|
-
options: z.record(z.unknown()).optional(),
|
|
77
|
+
options: z.record(z.string(), z.unknown()).optional(),
|
|
78
78
|
})
|
|
79
79
|
.passthrough();
|
|
80
80
|
// ── Bundles ─────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* Files out of a package on the npm registry, fetched without installing the
|
|
6
|
+
* package. akm's own agent runtime (an OpenCode binary) and the local
|
|
7
|
+
* embedder's runtime (onnxruntime-node's binding and library) come this way:
|
|
8
|
+
* installing either package would bring what akm does not use, every platform's
|
|
9
|
+
* binary in one case and an install script that downloads CUDA providers in the
|
|
10
|
+
* other.
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { sleep, writeResponseToFileCapped } from "../runtime.js";
|
|
16
|
+
import { fetchWithRetry, isRecord } from "./common.js";
|
|
17
|
+
import { releaseLock } from "./file-lock.js";
|
|
18
|
+
import { formatLockHolderPid, tryAcquireRunLock } from "./run-lock.js";
|
|
19
|
+
import { extractTarGzMembers } from "./tar.js";
|
|
20
|
+
export const NPM_REGISTRY = "https://registry.npmjs.org";
|
|
21
|
+
/** The `sha512-<base64>` integrity npm records for a tarball. */
|
|
22
|
+
function sha512Integrity(file) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const hash = createHash("sha512");
|
|
25
|
+
fs.createReadStream(file)
|
|
26
|
+
.on("data", (chunk) => hash.update(chunk))
|
|
27
|
+
.on("error", reject)
|
|
28
|
+
.on("end", () => resolve(`sha512-${hash.digest("base64")}`));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** Throws unless the tarball at `file` has the integrity the registry lists for it. */
|
|
32
|
+
export async function verifyIntegrity(file, expected) {
|
|
33
|
+
const listed = expected.split(/\s+/).filter((integrity) => integrity.startsWith("sha512-"));
|
|
34
|
+
if (listed.length === 0)
|
|
35
|
+
throw new Error(`the registry lists no sha512 integrity for it (${expected || "none"})`);
|
|
36
|
+
const actual = await sha512Integrity(file);
|
|
37
|
+
if (!listed.includes(actual))
|
|
38
|
+
throw new Error(`its sha512 integrity is ${actual}, not the ${listed.join(" or ")} the registry lists`);
|
|
39
|
+
}
|
|
40
|
+
async function registryDocument(url) {
|
|
41
|
+
const response = await fetchWithRetry(url, { headers: { accept: "application/json" } }, { timeout: 30_000 });
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
await response.body?.cancel().catch(() => undefined);
|
|
44
|
+
throw new Error(`the registry answered HTTP ${response.status}`);
|
|
45
|
+
}
|
|
46
|
+
const dist = (await response.json()).dist;
|
|
47
|
+
if (!isRecord(dist) || typeof dist.tarball !== "string" || typeof dist.integrity !== "string") {
|
|
48
|
+
throw new Error("the registry document has no dist.tarball and dist.integrity");
|
|
49
|
+
}
|
|
50
|
+
return { tarball: dist.tarball, integrity: dist.integrity };
|
|
51
|
+
}
|
|
52
|
+
/** How long a fetch waits for another process's fetch into the same directory: a large tarball on a slow link takes minutes. */
|
|
53
|
+
const FETCH_LOCK_TIMEOUT_MS = 600_000;
|
|
54
|
+
/**
|
|
55
|
+
* Keep members of `name@version`'s tarball in `directory`, each as `file`. One fetch runs per directory at a time: the
|
|
56
|
+
* processes fetching there, this one included, wait on a lock file in it, and a dead holder's lock is reclaimed.
|
|
57
|
+
*/
|
|
58
|
+
export async function fetchNpmPackageFiles(name, version, directory, files) {
|
|
59
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
60
|
+
const lock = path.join(directory, "fetch.lock");
|
|
61
|
+
const deadline = Date.now() + FETCH_LOCK_TIMEOUT_MS;
|
|
62
|
+
for (;;) {
|
|
63
|
+
const attempt = tryAcquireRunLock(lock, { label: "fetch" });
|
|
64
|
+
if (attempt.state === "acquired") {
|
|
65
|
+
try {
|
|
66
|
+
return await fetchHoldingLock(name, version, directory, files);
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
releaseLock(attempt.ownership);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (Date.now() >= deadline) {
|
|
73
|
+
throw new Error(`another akm process (pid ${formatLockHolderPid(attempt.holder)}) is still fetching into ${directory} (lock ${lock})`);
|
|
74
|
+
}
|
|
75
|
+
await sleep(100);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Whether `file` is in place whole: files only ever arrive by rename, so an executable regular file is complete. */
|
|
79
|
+
function isExecutableFile(file) {
|
|
80
|
+
try {
|
|
81
|
+
fs.accessSync(file, fs.constants.X_OK);
|
|
82
|
+
return fs.statSync(file).isFile();
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The registry document of that one version, its tarball, the check against the sha512 integrity the registry lists
|
|
90
|
+
* before anything is extracted, then each member by temp file and rename (mode 0755), in the order given. A fetch that
|
|
91
|
+
* finds every file in place downloads nothing, and a file in place at the size the tarball holds is never renamed over.
|
|
92
|
+
*/
|
|
93
|
+
async function fetchHoldingLock(name, version, directory, files) {
|
|
94
|
+
if (files.every(({ file }) => isExecutableFile(path.join(directory, file))))
|
|
95
|
+
return;
|
|
96
|
+
const download = path.join(directory, `.download-${process.pid}.tgz`);
|
|
97
|
+
const partial = (file) => path.join(directory, `.${file}-${process.pid}.tmp`);
|
|
98
|
+
try {
|
|
99
|
+
const { tarball, integrity } = await registryDocument(`${NPM_REGISTRY}/${name}/${version}`);
|
|
100
|
+
const response = await fetchWithRetry(tarball, undefined, { timeout: 60_000 });
|
|
101
|
+
if (!response.ok) {
|
|
102
|
+
await response.body?.cancel().catch(() => undefined);
|
|
103
|
+
throw new Error(`${tarball} answered HTTP ${response.status}`);
|
|
104
|
+
}
|
|
105
|
+
// No overall deadline for a large tarball on a slow link: only 60 s without bytes fails it.
|
|
106
|
+
await writeResponseToFileCapped(download, response, { stallTimeoutMs: 60_000 });
|
|
107
|
+
await verifyIntegrity(download, integrity);
|
|
108
|
+
const sizes = await extractTarGzMembers(download, new Map(files.map(({ member, file }) => [member, partial(file)])));
|
|
109
|
+
for (const { member, file } of files) {
|
|
110
|
+
const target = path.join(directory, file);
|
|
111
|
+
// A whole file in place (an akm without this lock may have made it) stands: a rename would unlink a binary that
|
|
112
|
+
// may be running on Linux and fails on Windows.
|
|
113
|
+
if (isExecutableFile(target) && fs.statSync(target).size === sizes.get(member))
|
|
114
|
+
continue;
|
|
115
|
+
fs.chmodSync(partial(file), 0o755);
|
|
116
|
+
fs.renameSync(partial(file), target);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
fs.rmSync(download, { force: true });
|
|
121
|
+
for (const { file } of files)
|
|
122
|
+
fs.rmSync(partial(file), { force: true });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
* akm needs of tar:
|
|
7
|
-
*
|
|
8
|
-
* is a dependency.
|
|
5
|
+
* Named members of a gzip-compressed tar archive, streamed to files. This is all
|
|
6
|
+
* akm needs of tar: a few regular files out of an npm package tarball (the
|
|
7
|
+
* OpenCode binary, about 240 MB; onnxruntime-node's binding and library), so
|
|
8
|
+
* nothing is held in memory and no tar library is a dependency.
|
|
9
9
|
*/
|
|
10
10
|
import fs from "node:fs";
|
|
11
11
|
import { pipeline } from "node:stream";
|
|
@@ -57,35 +57,41 @@ const field = (header, start, end) => {
|
|
|
57
57
|
return bytes.subarray(0, stop < 0 ? bytes.length : stop).toString("utf8");
|
|
58
58
|
};
|
|
59
59
|
/**
|
|
60
|
-
* Write
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
60
|
+
* Write each regular file of the `.tgz` at `archive` that `members` names (archive path → destination, mode 0755) and
|
|
61
|
+
* return each one's size, in one pass that stops once the last is written. Entries are matched by the header's name
|
|
62
|
+
* field alone, which holds up to 100 bytes; a longer name, which tar keeps in the ustar prefix field or a PAX `path`
|
|
63
|
+
* record, is not read, so such a member is never found. The members akm reads (`package/bin/opencode`,
|
|
64
|
+
* `package/bin/napi-v6/<platform>/<arch>/<file>`) are short. Extended headers are skipped like any other entry that is
|
|
65
|
+
* not a member.
|
|
64
66
|
*/
|
|
65
|
-
export async function
|
|
67
|
+
export async function extractTarGzMembers(archive, members) {
|
|
66
68
|
const gunzip = zlib.createGunzip();
|
|
67
69
|
pipeline(fs.createReadStream(archive), gunzip, () => { });
|
|
68
70
|
const reader = new ByteReader(gunzip);
|
|
69
71
|
try {
|
|
70
|
-
return await extractFrom(reader, archive,
|
|
72
|
+
return await extractFrom(reader, archive, members);
|
|
71
73
|
}
|
|
72
74
|
finally {
|
|
73
75
|
// The rest of the archive is not read: close it now rather than when the stream is collected.
|
|
74
76
|
gunzip.destroy();
|
|
75
77
|
}
|
|
76
78
|
}
|
|
77
|
-
async function extractFrom(reader, archive,
|
|
79
|
+
async function extractFrom(reader, archive, members) {
|
|
80
|
+
const sizes = new Map();
|
|
78
81
|
for (;;) {
|
|
79
82
|
const header = await reader.exact(BLOCK_BYTES);
|
|
80
|
-
if (header.every((byte) => byte === 0))
|
|
81
|
-
|
|
83
|
+
if (header.every((byte) => byte === 0)) {
|
|
84
|
+
const missing = [...members.keys()].filter((member) => !sizes.has(member));
|
|
85
|
+
throw new Error(`${archive} has no ${missing.join(", ")}`);
|
|
86
|
+
}
|
|
82
87
|
const name = field(header, 0, 100).replace(/^\.\//, "");
|
|
83
88
|
const typeflag = String.fromCharCode(header[156] ?? 0);
|
|
84
89
|
const size = Number.parseInt(field(header, 124, 136).trim() || "0", 8);
|
|
85
90
|
if (!Number.isSafeInteger(size))
|
|
86
91
|
throw new Error(`${archive} is not a tar archive this reader understands`);
|
|
87
92
|
const padding = (BLOCK_BYTES - (size % BLOCK_BYTES)) % BLOCK_BYTES;
|
|
88
|
-
|
|
93
|
+
const destination = members.get(name);
|
|
94
|
+
if (destination === undefined || sizes.has(name) || (typeflag !== "0" && typeflag !== "\0")) {
|
|
89
95
|
await reader.skip(size + padding);
|
|
90
96
|
continue;
|
|
91
97
|
}
|
|
@@ -94,7 +100,7 @@ async function extractFrom(reader, archive, member, destination) {
|
|
|
94
100
|
for (let left = size; left > 0;) {
|
|
95
101
|
const part = await reader.read(left);
|
|
96
102
|
if (!part)
|
|
97
|
-
throw new Error(`${archive} ends inside ${
|
|
103
|
+
throw new Error(`${archive} ends inside ${name}`);
|
|
98
104
|
await out.writeFile(part);
|
|
99
105
|
left -= part.length;
|
|
100
106
|
}
|
|
@@ -102,6 +108,9 @@ async function extractFrom(reader, archive, member, destination) {
|
|
|
102
108
|
finally {
|
|
103
109
|
await out.close();
|
|
104
110
|
}
|
|
105
|
-
|
|
111
|
+
sizes.set(name, size);
|
|
112
|
+
if (sizes.size === members.size)
|
|
113
|
+
return sizes;
|
|
114
|
+
await reader.skip(padding);
|
|
106
115
|
}
|
|
107
116
|
}
|
|
@@ -15,14 +15,12 @@
|
|
|
15
15
|
* `postinstall.mjs` of `@opencode/cli`.
|
|
16
16
|
*/
|
|
17
17
|
import { spawnSync } from "node:child_process";
|
|
18
|
-
import { createHash } from "node:crypto";
|
|
19
18
|
import fs from "node:fs";
|
|
20
19
|
import path from "node:path";
|
|
21
|
-
import {
|
|
20
|
+
import { toErrorMessage } from "../../../../core/common.js";
|
|
21
|
+
import { fetchNpmPackageFiles, NPM_REGISTRY } from "../../../../core/npm-package.js";
|
|
22
22
|
import { getCacheDir } from "../../../../core/paths.js";
|
|
23
23
|
import { info } from "../../../../core/warn.js";
|
|
24
|
-
import { writeResponseToFileCapped } from "../../../../runtime.js";
|
|
25
|
-
import { extractTarGzMember } from "./tar.js";
|
|
26
24
|
/**
|
|
27
25
|
* The OpenCode release akm's own runtime runs. It is pinned because the runtime depends on things no OpenCode release
|
|
28
26
|
* promises: the inline config's shape, the `--standalone` flag, the plugin API, the text `run` prints, and the
|
|
@@ -31,7 +29,6 @@ import { extractTarGzMember } from "./tar.js";
|
|
|
31
29
|
* binary (it fetches it) and read `docs/reference/configuration.md` ("Without a harness") for what the version changes.
|
|
32
30
|
*/
|
|
33
31
|
export const OPENCODE_RUNTIME_VERSION = "2.1.4";
|
|
34
|
-
const REGISTRY = "https://registry.npmjs.org";
|
|
35
32
|
/** The platform packages of `@opencode/cli` at {@link OPENCODE_RUNTIME_VERSION}. */
|
|
36
33
|
const PACKAGES = new Set([
|
|
37
34
|
"@opencode/cli-linux-x64",
|
|
@@ -109,84 +106,20 @@ export function detectHost(env = process.env, operatingSystem = process.platform
|
|
|
109
106
|
export function runtimeBinaryPath(platform = process.platform) {
|
|
110
107
|
return path.join(getCacheDir(), "opencode", OPENCODE_RUNTIME_VERSION, platform === "win32" ? "opencode.exe" : "opencode");
|
|
111
108
|
}
|
|
112
|
-
/** The `sha512-<base64>` integrity npm records for a tarball. */
|
|
113
|
-
function sha512Integrity(file) {
|
|
114
|
-
return new Promise((resolve, reject) => {
|
|
115
|
-
const hash = createHash("sha512");
|
|
116
|
-
fs.createReadStream(file)
|
|
117
|
-
.on("data", (chunk) => hash.update(chunk))
|
|
118
|
-
.on("error", reject)
|
|
119
|
-
.on("end", () => resolve(`sha512-${hash.digest("base64")}`));
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
/** Throws unless the tarball at `file` has the integrity the registry lists for it. */
|
|
123
|
-
export async function verifyIntegrity(file, expected) {
|
|
124
|
-
const listed = expected.split(/\s+/).filter((integrity) => integrity.startsWith("sha512-"));
|
|
125
|
-
if (listed.length === 0)
|
|
126
|
-
throw new Error(`the registry lists no sha512 integrity for it (${expected || "none"})`);
|
|
127
|
-
const actual = await sha512Integrity(file);
|
|
128
|
-
if (!listed.includes(actual))
|
|
129
|
-
throw new Error(`its sha512 integrity is ${actual}, not the ${listed.join(" or ")} the registry lists`);
|
|
130
|
-
}
|
|
131
|
-
async function registryDocument(url) {
|
|
132
|
-
const response = await fetchWithRetry(url, { headers: { accept: "application/json" } }, { timeout: 30_000 });
|
|
133
|
-
if (!response.ok) {
|
|
134
|
-
await response.body?.cancel().catch(() => undefined);
|
|
135
|
-
throw new Error(`the registry answered HTTP ${response.status}`);
|
|
136
|
-
}
|
|
137
|
-
const dist = (await response.json()).dist;
|
|
138
|
-
if (!isRecord(dist) || typeof dist.tarball !== "string" || typeof dist.integrity !== "string") {
|
|
139
|
-
throw new Error("the registry document has no dist.tarball and dist.integrity");
|
|
140
|
-
}
|
|
141
|
-
return { tarball: dist.tarball, integrity: dist.integrity };
|
|
142
|
-
}
|
|
143
|
-
/**
|
|
144
|
-
* Fetch the binary of the package `name` into `binary`: the registry document of that one package, its tarball, the
|
|
145
|
-
* integrity check before anything is extracted, then the executable by temp file and rename. Two akm processes fetching
|
|
146
|
-
* at once both finish: a rename that loses to the other leaves the file the other made.
|
|
147
|
-
*/
|
|
148
|
-
async function fetchBinary(name, binary) {
|
|
149
|
-
const directory = path.dirname(binary);
|
|
150
|
-
fs.mkdirSync(directory, { recursive: true });
|
|
151
|
-
const download = path.join(directory, `.download-${process.pid}.tgz`);
|
|
152
|
-
const partial = path.join(directory, `.opencode-${process.pid}.tmp`);
|
|
153
|
-
try {
|
|
154
|
-
const { tarball, integrity } = await registryDocument(`${REGISTRY}/${name}/${OPENCODE_RUNTIME_VERSION}`);
|
|
155
|
-
info(`Fetching OpenCode ${OPENCODE_RUNTIME_VERSION} for akm's own agent runtime (about 114 MB, once) into ${directory}.`);
|
|
156
|
-
const response = await fetchWithRetry(tarball, undefined, { timeout: 60_000 });
|
|
157
|
-
if (!response.ok) {
|
|
158
|
-
await response.body?.cancel().catch(() => undefined);
|
|
159
|
-
throw new Error(`${tarball} answered HTTP ${response.status}`);
|
|
160
|
-
}
|
|
161
|
-
// No overall deadline for a 114 MB file on a slow link: only 60 s without bytes fails it.
|
|
162
|
-
await writeResponseToFileCapped(download, response, { stallTimeoutMs: 60_000 });
|
|
163
|
-
await verifyIntegrity(download, integrity);
|
|
164
|
-
const size = await extractTarGzMember(download, `package/bin/${path.basename(binary)}`, partial);
|
|
165
|
-
fs.chmodSync(partial, 0o755);
|
|
166
|
-
try {
|
|
167
|
-
fs.renameSync(partial, binary);
|
|
168
|
-
}
|
|
169
|
-
catch (error) {
|
|
170
|
-
// Where an open executable cannot be replaced, the other process's file stands.
|
|
171
|
-
if (!fs.existsSync(binary) || fs.statSync(binary).size !== size)
|
|
172
|
-
throw error;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
finally {
|
|
176
|
-
fs.rmSync(download, { force: true });
|
|
177
|
-
fs.rmSync(partial, { force: true });
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
109
|
/** Fetch the binary for this machine, naming the package, its URL and the cache path when it cannot. */
|
|
181
110
|
async function fetchForThisMachine(binary) {
|
|
182
111
|
let name = "an @opencode/cli platform package";
|
|
183
112
|
try {
|
|
184
113
|
name = opencodePackage(detectHost());
|
|
185
|
-
|
|
114
|
+
info(`Fetching OpenCode ${OPENCODE_RUNTIME_VERSION} for akm's own agent runtime (about 114 MB, once) into ${path.dirname(binary)}.`);
|
|
115
|
+
const file = path.basename(binary);
|
|
116
|
+
await fetchNpmPackageFiles(name, OPENCODE_RUNTIME_VERSION, path.dirname(binary), [
|
|
117
|
+
{ member: `package/bin/${file}`, file },
|
|
118
|
+
]);
|
|
186
119
|
return binary;
|
|
187
120
|
}
|
|
188
121
|
catch (error) {
|
|
189
|
-
throw new Error(`Could not fetch OpenCode ${OPENCODE_RUNTIME_VERSION}, which akm's own agent runtime runs: package ${name} from ${
|
|
122
|
+
throw new Error(`Could not fetch OpenCode ${OPENCODE_RUNTIME_VERSION}, which akm's own agent runtime runs: package ${name} from ${NPM_REGISTRY}/${name}/${OPENCODE_RUNTIME_VERSION} into ${path.dirname(binary)}: ${toErrorMessage(error)}. ` +
|
|
190
123
|
"Without network access for this step, enable the opencode harness with an installed OpenCode 2 instead (see docs/reference/configuration.md).");
|
|
191
124
|
}
|
|
192
125
|
}
|
|
@@ -24,7 +24,7 @@ import { resolveAkmInvocation } from "../../../../tasks/resolve-akm-bin.js";
|
|
|
24
24
|
import { buildChildEnv, runBuiltCommand, } from "../../../agent/spawn.js";
|
|
25
25
|
import { akmOpencodeDatabasePath, akmRuntimeDir } from "../roots.js";
|
|
26
26
|
import { v2SessionTokens } from "../session-log-v2.js";
|
|
27
|
-
import { ensureRuntimeBinary, OPENCODE_RUNTIME_VERSION } from "./binary.js";
|
|
27
|
+
import { ensureRuntimeBinary, OPENCODE_RUNTIME_VERSION, runtimeBinaryPath } from "./binary.js";
|
|
28
28
|
import { runtimeArgv, runtimeConfig, runtimeEnv, runtimePrepareArgv } from "./command.js";
|
|
29
29
|
/**
|
|
30
30
|
* What a request is lowered for when it runs here. The persona goes in the prompt, as for the opencode harness
|
|
@@ -126,6 +126,10 @@ function sessionRecord(sessionId) {
|
|
|
126
126
|
}
|
|
127
127
|
export const opencodeRuntime = {
|
|
128
128
|
lowering: runtimeLowerer,
|
|
129
|
+
pendingFetch: () => fs.existsSync(runtimeBinaryPath()) ? undefined : `OpenCode ${OPENCODE_RUNTIME_VERSION} (about 114 MB, once)`,
|
|
130
|
+
async fetch() {
|
|
131
|
+
await ensureRuntimeBinary();
|
|
132
|
+
},
|
|
129
133
|
async run(profile, opts, connection) {
|
|
130
134
|
const label = `akm's runtime on connection "${profile.name}"`;
|
|
131
135
|
const dispatch = opts.dispatch;
|