akm-cli 0.9.14 → 0.9.15-beta.2
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 +559 -0
- package/STABILITY.md +6 -3
- package/dist/akm +54 -1
- package/dist/akm-migrate +34 -1
- package/dist/assets/prompts/reflect-feedback-framing.md +1 -0
- package/dist/assets/prompts/reflect-llm-framed-contract.md +2 -0
- package/dist/assets/prompts/reflect-llm-schema-contract.md +2 -0
- package/dist/assets/tasks/core/improve.yml +1 -1
- package/dist/assets/tasks/core/index-refresh.yml +1 -1
- package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +1 -1
- package/dist/assets/tasks/improve/akm-improve-catchup.yml +1 -1
- package/dist/assets/tasks/improve/akm-improve-consolidate.yml +1 -1
- package/dist/assets/tasks/improve/akm-improve-frequent.yml +1 -1
- package/dist/assets/tasks/improve/akm-improve-nightly.yml +1 -1
- package/dist/cli/retired-commands.js +0 -1
- package/dist/cli/shared.js +9 -0
- package/dist/cli/unknown-flags.js +1 -0
- package/dist/cli.js +40 -3
- package/dist/commands/config-cli.js +85 -3
- package/dist/commands/env/env-cli.js +1 -42
- package/dist/commands/env/env.js +1 -1
- package/dist/commands/env/secret-cli.js +1 -2
- package/dist/commands/health/checks.js +357 -63
- package/dist/commands/health/engine-usage.js +45 -0
- package/dist/commands/health/improve-metrics.js +18 -0
- package/dist/commands/health/llm-usage.js +41 -1
- package/dist/commands/health/plugin-staleness.js +7 -3
- package/dist/commands/health/version-drift.js +93 -0
- package/dist/commands/health/windows.js +3 -1
- package/dist/commands/health.js +44 -9
- package/dist/commands/improve/consolidate/chunking.js +4 -2
- package/dist/commands/improve/improve-cli.js +99 -5
- package/dist/commands/improve/improve-report.js +154 -0
- package/dist/commands/improve/improve-result-file.js +45 -33
- package/dist/commands/improve/improve-strategies.js +133 -3
- package/dist/commands/improve/improve-usage-report.js +182 -0
- package/dist/commands/improve/improve.js +40 -3
- package/dist/commands/improve/locks.js +28 -78
- package/dist/commands/improve/planner.js +1 -0
- package/dist/commands/improve/preparation.js +9 -1
- package/dist/commands/improve/reflect.js +44 -4
- package/dist/commands/models-cli.js +50 -1
- package/dist/commands/proposal/repository.js +8 -3
- package/dist/commands/proposal/validators/proposal-quality-validators.js +41 -6
- package/dist/commands/proposal/validators/proposal-validators.js +24 -0
- package/dist/commands/read/search-cli.js +38 -2
- package/dist/commands/read/show.js +103 -4
- package/dist/commands/sources/info.js +5 -1
- package/dist/commands/sources/installed-stashes.js +58 -16
- package/dist/commands/sources/self-update.js +2 -2
- package/dist/commands/sources/stash-cli.js +48 -0
- package/dist/commands/tasks/tasks-cli.js +49 -2
- package/dist/commands/workflow-cli.js +86 -12
- package/dist/core/asset/markdown-fragments.js +35 -0
- package/dist/core/config/config-schema.js +14 -0
- package/dist/core/config/config.js +302 -24
- package/dist/core/config/schema/embedding.js +41 -0
- package/dist/core/env-secret-ref.js +58 -5
- package/dist/core/errors.js +30 -0
- package/dist/core/file-lock.js +49 -15
- package/dist/core/improve-result.js +51 -0
- package/dist/core/loopback.js +17 -0
- package/dist/core/parent-watchdog.js +64 -0
- package/dist/core/paths.js +11 -0
- package/dist/core/run-lock.js +107 -0
- package/dist/core/sensitive-marker-path.js +19 -0
- package/dist/core/state-db.js +74 -14
- package/dist/indexer/index-rebuild-lock.js +73 -0
- package/dist/indexer/index-writer-lock.js +40 -1
- package/dist/indexer/index-written-assets.js +29 -1
- package/dist/indexer/indexer.js +93 -29
- package/dist/indexer/materialize-embeddings.js +564 -48
- package/dist/indexer/search/db-search.js +49 -2
- package/dist/indexer/search/search-source.js +23 -1
- package/dist/integrations/agent/engine-resolution.js +96 -6
- package/dist/integrations/agent/execution-definitions.js +6 -15
- package/dist/integrations/agent/execution-lowering.js +6 -1
- package/dist/integrations/agent/execution-preparation.js +1 -1
- package/dist/integrations/agent/model-map.js +123 -20
- package/dist/integrations/agent/prompts.js +40 -8
- package/dist/integrations/agent/runner-dispatch.js +9 -3
- package/dist/integrations/agent/runner.js +2 -0
- package/dist/llm/client.js +8 -3
- package/dist/llm/embedder.js +20 -8
- package/dist/llm/embedders/local.js +10 -2
- package/dist/llm/embedders/remote.js +497 -32
- package/dist/output/shapes/helpers.js +38 -2
- package/dist/output/shapes/models-list.js +16 -0
- package/dist/output/shapes/passthrough.js +2 -0
- package/dist/output/shapes.js +4 -0
- package/dist/output/text/command-format.js +29 -0
- package/dist/output/text/helpers.js +1 -1
- package/dist/output/text/improve-report.js +27 -0
- package/dist/{commands/env/marker-path.js → output/text/models.js} +4 -3
- package/dist/output/text/show-format.js +4 -0
- package/dist/output/text.js +4 -0
- package/dist/scripts/akm-migrate-node.js +25146 -21759
- package/dist/scripts/akm-migrate.js +24271 -20885
- package/dist/storage/repositories/embedding-salvage-repository.js +184 -0
- package/dist/storage/repositories/improve-runs-repository.js +34 -0
- package/dist/storage/repositories/index-fts-repository.js +49 -6
- package/dist/storage/repositories/index-schema.js +16 -0
- package/dist/storage/repositories/index-vec-repository.js +30 -0
- package/dist/storage/repositories/workflow-runs-repository.js +55 -18
- package/dist/tasks/backends/cron.js +14 -7
- package/dist/tasks/run/run-native-task.js +23 -1
- package/dist/tasks/run/run-workflow-task.js +16 -0
- package/dist/workflows/exec/child-workflow.js +2 -2
- package/dist/workflows/exec/dispatch-redaction.js +21 -9
- package/dist/workflows/exec/run-workflow.js +6 -5
- package/dist/workflows/runtime/runs.js +33 -5
- package/docs/migration/release-notes/0.9.15.md +133 -0
- package/docs/migration/release-notes/README.md +5 -0
- package/docs/reference/cli.md +271 -30
- package/docs/reference/configuration.md +234 -21
- package/docs/reference/data-and-telemetry.md +8 -0
- package/docs/reference/tasks.md +16 -1
- package/docs/reference/workflow-schema.md +5 -1
- package/package.json +1 -1
- package/schemas/akm-config.json +47 -0
|
@@ -11,7 +11,8 @@ import { getStringArg } from "../cli/parse-args.js";
|
|
|
11
11
|
import { defineGroupCommand, defineJsonCommand, EXIT_CODES, output, outputWithExitCode } from "../cli/shared.js";
|
|
12
12
|
import { armAbortDeadline } from "../core/abort-deadline.js";
|
|
13
13
|
import { assertFlatAssetName, combineCreatePath, normalizeCreateSubPath } from "../core/asset/asset-create.js";
|
|
14
|
-
import { NotFoundError, UsageError } from "../core/errors.js";
|
|
14
|
+
import { NotFoundError, TransientError, UsageError } from "../core/errors.js";
|
|
15
|
+
import { warn } from "../core/warn.js";
|
|
15
16
|
import { akmIndex } from "../indexer/indexer.js";
|
|
16
17
|
import { assertWorkflowMarkdownName, createWorkflowAsset, getWorkflowTemplate } from "../workflows/authoring/authoring.js";
|
|
17
18
|
import { WORKFLOW_MAX_TIMEOUT_MS } from "../workflows/ir/schema.js";
|
|
@@ -30,10 +31,16 @@ const workflowStatusCommand = defineJsonCommand({
|
|
|
30
31
|
"diagnostic text). Diagnostics only — step evidence stays deterministic and is unaffected.",
|
|
31
32
|
default: false,
|
|
32
33
|
},
|
|
34
|
+
"all-scopes": {
|
|
35
|
+
type: "boolean",
|
|
36
|
+
description: "When resolving a workflow ref (not a run id), search every scope instead of only the current one (#942).",
|
|
37
|
+
default: false,
|
|
38
|
+
},
|
|
33
39
|
},
|
|
34
40
|
async run({ args }) {
|
|
35
41
|
const target = args.target;
|
|
36
42
|
const includeUnits = args.units === true;
|
|
43
|
+
const allScopes = args["all-scopes"] === true;
|
|
37
44
|
const resolvedRunId = await resolveWorkflowRunTarget(target);
|
|
38
45
|
if (resolvedRunId !== undefined) {
|
|
39
46
|
const result = await getWorkflowStatus(resolvedRunId, { includeUnits });
|
|
@@ -41,8 +48,9 @@ const workflowStatusCommand = defineJsonCommand({
|
|
|
41
48
|
return;
|
|
42
49
|
}
|
|
43
50
|
let runs;
|
|
51
|
+
let scopeKey;
|
|
44
52
|
try {
|
|
45
|
-
({ runs } = await listWorkflowRuns({ workflowRef: target }));
|
|
53
|
+
({ runs, scopeKey } = await listWorkflowRuns({ workflowRef: target, allScopes }));
|
|
46
54
|
}
|
|
47
55
|
catch (error) {
|
|
48
56
|
if (!target.includes(":") && !target.includes("/")) {
|
|
@@ -51,8 +59,17 @@ const workflowStatusCommand = defineJsonCommand({
|
|
|
51
59
|
throw error;
|
|
52
60
|
}
|
|
53
61
|
const mostRecent = runs[0];
|
|
54
|
-
if (!mostRecent)
|
|
62
|
+
if (!mostRecent) {
|
|
63
|
+
// #942: name the scope actually searched and point at `--all-scopes`
|
|
64
|
+
// rather than a bare "not found" — the ref-fallthrough lookup is
|
|
65
|
+
// scope-local by default, so "no runs" here means "none in THIS
|
|
66
|
+
// scope", not "none anywhere". Already searching every scope (or no
|
|
67
|
+
// real scope was filtered on) has nothing more specific to suggest.
|
|
68
|
+
if (!allScopes && scopeKey !== null) {
|
|
69
|
+
throw new NotFoundError(`No workflow runs found for ${target} in scope ${scopeKey}.`, "WORKFLOW_NOT_FOUND", `Run 'akm workflow status ${target} --all-scopes' to search every scope.`);
|
|
70
|
+
}
|
|
55
71
|
throw new NotFoundError(`No workflow runs found for ${target}`, "WORKFLOW_NOT_FOUND");
|
|
72
|
+
}
|
|
56
73
|
const result = await getWorkflowStatus(mostRecent.id, { includeUnits });
|
|
57
74
|
output("workflow-status", result);
|
|
58
75
|
},
|
|
@@ -70,12 +87,20 @@ const workflowListCommand = defineJsonCommand({
|
|
|
70
87
|
description: "Also include child workflow runs (hidden by default, P3b)",
|
|
71
88
|
default: false,
|
|
72
89
|
},
|
|
90
|
+
"all-scopes": {
|
|
91
|
+
type: "boolean",
|
|
92
|
+
description: "Search every scope instead of only the current one (#942). The envelope's top-level `scopeKey` is " +
|
|
93
|
+
"`null` with this flag, otherwise the scope that was searched — so an empty `runs: []` is never " +
|
|
94
|
+
'indistinguishable from "nothing anywhere".',
|
|
95
|
+
default: false,
|
|
96
|
+
},
|
|
73
97
|
},
|
|
74
98
|
async run({ args }) {
|
|
75
99
|
const result = await listWorkflowRuns({
|
|
76
100
|
workflowRef: args.ref,
|
|
77
101
|
activeOnly: args.active,
|
|
78
102
|
includeChildren: args.children,
|
|
103
|
+
allScopes: args["all-scopes"],
|
|
79
104
|
});
|
|
80
105
|
output("workflow-list", result);
|
|
81
106
|
},
|
|
@@ -145,6 +170,20 @@ const workflowCreateCommand = defineJsonCommand({
|
|
|
145
170
|
output("workflow-create", { ok: true, ...result });
|
|
146
171
|
},
|
|
147
172
|
});
|
|
173
|
+
/**
|
|
174
|
+
* `--skip-if-locked` (#948) eligibility: only these two named, retryable
|
|
175
|
+
* `TransientError` codes (#948 addendum — moved off UsageError, exit 75) turn
|
|
176
|
+
* a `workflow run` failure into a graceful skip — `RUN_LEASE_HELD` (another
|
|
177
|
+
* engine invocation is driving THIS run, `workflow-runs-repository.ts`'s
|
|
178
|
+
* single-driver lease) and `STATE_DB_CONTENDED` (an unrelated akm process is
|
|
179
|
+
* writing state.db right now, `core/state-db.ts`'s BEGIN IMMEDIATE retry
|
|
180
|
+
* exhaustion). Every other error — a bad flag, an unresolvable target —
|
|
181
|
+
* still fails loudly.
|
|
182
|
+
*/
|
|
183
|
+
const WORKFLOW_RUN_SKIP_REASONS = {
|
|
184
|
+
RUN_LEASE_HELD: "lock-held",
|
|
185
|
+
STATE_DB_CONTENDED: "state-db-contended",
|
|
186
|
+
};
|
|
148
187
|
const workflowRunCommand = defineJsonCommand({
|
|
149
188
|
meta: {
|
|
150
189
|
name: "run",
|
|
@@ -161,6 +200,13 @@ const workflowRunCommand = defineJsonCommand({
|
|
|
161
200
|
"(never abandons it). A workflow ref only — passing a run id with --new is a usage error.",
|
|
162
201
|
default: false,
|
|
163
202
|
},
|
|
203
|
+
"skip-if-locked": {
|
|
204
|
+
type: "boolean",
|
|
205
|
+
description: "If another akm process already holds this run's engine lease, or state.db is busy with another " +
|
|
206
|
+
"writer, skip gracefully (exit 0) instead of failing (exit 75). Use for high-frequency scheduled runs " +
|
|
207
|
+
"so they don't pile up failures while a longer-running invocation is in progress.",
|
|
208
|
+
default: false,
|
|
209
|
+
},
|
|
164
210
|
},
|
|
165
211
|
async run({ args, rawArgs }) {
|
|
166
212
|
const { runWorkflowSteps } = await import("../workflows/exec/run-workflow.js");
|
|
@@ -168,6 +214,7 @@ const workflowRunCommand = defineJsonCommand({
|
|
|
168
214
|
const maxSteps = parseIntegerFlag(getStringArg(args, "max-steps"), "--max-steps", 1);
|
|
169
215
|
const maxRetries = parseIntegerFlag(getStringArg(args, "max-retries"), "--max-retries", 0);
|
|
170
216
|
const timeoutMs = parseWorkflowTimeout(getStringArg(args, "timeout"));
|
|
217
|
+
const skipIfLocked = args["skip-if-locked"];
|
|
171
218
|
const controller = new AbortController();
|
|
172
219
|
let signalExitCode;
|
|
173
220
|
const interrupt = (signal) => {
|
|
@@ -185,14 +232,31 @@ const workflowRunCommand = defineJsonCommand({
|
|
|
185
232
|
reason: `Workflow run timed out after ${timeoutMs}ms.`,
|
|
186
233
|
});
|
|
187
234
|
try {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
235
|
+
let result;
|
|
236
|
+
try {
|
|
237
|
+
result = await runWorkflowSteps({
|
|
238
|
+
target: args.target,
|
|
239
|
+
parameterFlags,
|
|
240
|
+
...(maxSteps !== undefined ? { maxSteps } : {}),
|
|
241
|
+
...(maxRetries !== undefined ? { maxRetries } : {}),
|
|
242
|
+
newRun: args.new,
|
|
243
|
+
signal: controller.signal,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
// #948: `--skip-if-locked` extends improve's "another run already
|
|
248
|
+
// holds this" skip semantics to `workflow run`. Only these two named,
|
|
249
|
+
// retryable TransientError codes are eligible (#948 addendum — moved
|
|
250
|
+
// off UsageError) — a bad flag or malformed input still fails loudly
|
|
251
|
+
// even with the flag set.
|
|
252
|
+
if (skipIfLocked && err instanceof TransientError && WORKFLOW_RUN_SKIP_REASONS[err.code]) {
|
|
253
|
+
const reason = WORKFLOW_RUN_SKIP_REASONS[err.code];
|
|
254
|
+
warn(`[workflow] ${err.message} skipping (--skip-if-locked)`);
|
|
255
|
+
output("workflow-run", { ok: true, target: args.target, skipped: { reason, message: err.message } });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
196
260
|
// The abort is observed between steps, so a deadline landing in the run's
|
|
197
261
|
// final bookkeeping fires on a run that then finishes. Reporting that as
|
|
198
262
|
// timed out would send an operator to resume a run with nothing left to
|
|
@@ -222,7 +286,17 @@ const WORKFLOW_RUN_VALUE_FLAGS = new Set([
|
|
|
222
286
|
"shape",
|
|
223
287
|
"output",
|
|
224
288
|
]);
|
|
225
|
-
const WORKFLOW_RUN_BOOLEAN_FLAGS = new Set([
|
|
289
|
+
const WORKFLOW_RUN_BOOLEAN_FLAGS = new Set([
|
|
290
|
+
"quiet",
|
|
291
|
+
"verbose",
|
|
292
|
+
"help",
|
|
293
|
+
"no-quiet",
|
|
294
|
+
"no-verbose",
|
|
295
|
+
"new",
|
|
296
|
+
"no-new",
|
|
297
|
+
"skip-if-locked",
|
|
298
|
+
"no-skip-if-locked",
|
|
299
|
+
]);
|
|
226
300
|
export function parseWorkflowParameterFlags(rawArgs, target) {
|
|
227
301
|
const flags = [];
|
|
228
302
|
let targetSeen = false;
|
|
@@ -12,6 +12,8 @@ import { createHash } from "node:crypto";
|
|
|
12
12
|
import { markdownHeadingSlug, parseMarkdownToc } from "./markdown.js";
|
|
13
13
|
export const MARKDOWN_FRAGMENT_MAX_CHARS = 1600;
|
|
14
14
|
export const MARKDOWN_FRAGMENT_PREFIX = "akm-fragment-";
|
|
15
|
+
export const MARKDOWN_FRAGMENT_CONTEXT_DEFAULT_MAX_CHARS = 3200;
|
|
16
|
+
export const MARKDOWN_FRAGMENT_SELECTED_LABEL = "[Selected matching fragment]";
|
|
15
17
|
function hash(text) {
|
|
16
18
|
return createHash("sha256").update(text).digest("hex");
|
|
17
19
|
}
|
|
@@ -144,3 +146,36 @@ export function splitMarkdownFragments(body, maxChars = MARKDOWN_FRAGMENT_MAX_CH
|
|
|
144
146
|
export function fragmentForSelector(body, selector) {
|
|
145
147
|
return splitMarkdownFragments(body).find((fragment) => fragment.fragmentId === selector || fragment.headingSlug === selector);
|
|
146
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Assemble the document lead and selected fragment under one hard character
|
|
151
|
+
* budget. The selected match is always labelled and last. When both pieces do
|
|
152
|
+
* not fit, lead bytes are discarded before any selected-fragment bytes so the
|
|
153
|
+
* evidence that caused retrieval remains intact whenever the caller's budget
|
|
154
|
+
* can hold it.
|
|
155
|
+
*/
|
|
156
|
+
export function buildMarkdownLeadContext(fragments, selectedOrdinal, maxChars = MARKDOWN_FRAGMENT_CONTEXT_DEFAULT_MAX_CHARS) {
|
|
157
|
+
if (!Number.isSafeInteger(maxChars) || maxChars <= 0) {
|
|
158
|
+
throw new RangeError("Markdown fragment context maxChars must be a positive safe integer");
|
|
159
|
+
}
|
|
160
|
+
const selected = fragments[selectedOrdinal];
|
|
161
|
+
if (!selected)
|
|
162
|
+
throw new RangeError(`Markdown fragment ordinal ${selectedOrdinal} is out of range`);
|
|
163
|
+
const selectedBlock = `${MARKDOWN_FRAGMENT_SELECTED_LABEL}\n${selected.text}`;
|
|
164
|
+
if (selectedBlock.length > maxChars) {
|
|
165
|
+
return { content: selectedBlock.slice(0, maxChars), truncated: true };
|
|
166
|
+
}
|
|
167
|
+
const lead = fragments[0];
|
|
168
|
+
if (!lead || lead.ordinal === selected.ordinal)
|
|
169
|
+
return { content: selectedBlock, truncated: false };
|
|
170
|
+
const separator = "\n\n";
|
|
171
|
+
const availableLeadChars = maxChars - selectedBlock.length - separator.length;
|
|
172
|
+
if (availableLeadChars <= 0)
|
|
173
|
+
return { content: selectedBlock, truncated: true };
|
|
174
|
+
const leadText = lead.text.slice(0, availableLeadChars).trimEnd();
|
|
175
|
+
if (!leadText)
|
|
176
|
+
return { content: selectedBlock, truncated: true };
|
|
177
|
+
return {
|
|
178
|
+
content: `${leadText}${separator}${selectedBlock}`,
|
|
179
|
+
truncated: leadText.length < lead.text.length,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -89,6 +89,20 @@ export const DefaultsSchema = z
|
|
|
89
89
|
*/
|
|
90
90
|
export const AkmConfigShape = {
|
|
91
91
|
configVersion: z.literal(CURRENT_CONFIG_VERSION),
|
|
92
|
+
// #945 — fleet inheritance. A filesystem path (relative to the directory of
|
|
93
|
+
// the config file that declares it; `~` expands) or a `bundle//<path>` ref
|
|
94
|
+
// naming an already-synced local file to deep-merge underneath this config
|
|
95
|
+
// (local keys win) — the part after `//` is a plain file path relative to
|
|
96
|
+
// that bundle's content root, not an asset conceptId; it needs no asset
|
|
97
|
+
// type and is never indexed. No URL form: config load is synchronous and
|
|
98
|
+
// runs on every invocation, and akm deliberately does not fetch network
|
|
99
|
+
// resources at load time (see `registries`, never fetched until a
|
|
100
|
+
// registry-touching command runs) — a URL-backed shared config should be
|
|
101
|
+
// synced via `akm bundle add` (git/website) and referenced as
|
|
102
|
+
// `extends: bundle//<path>` once materialized locally. Resolved in
|
|
103
|
+
// `resolveExtendsChain` (./config.ts), not validated here (a bad ref
|
|
104
|
+
// surfaces as a `ConfigError` at load, naming the ref).
|
|
105
|
+
extends: nonEmptyString.optional(),
|
|
92
106
|
engines: EnginesSchema.optional(),
|
|
93
107
|
defaults: DefaultsSchema.optional(),
|
|
94
108
|
semanticSearchMode: z.enum(["off", "auto"]).default("off"),
|
|
@@ -2,15 +2,19 @@
|
|
|
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
|
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
5
6
|
import path from "node:path";
|
|
7
|
+
import { isDeepStrictEqual } from "node:util";
|
|
8
|
+
import { isBundleSlug } from "../asset/asset-ref.js";
|
|
9
|
+
import { isRecord } from "../common.js";
|
|
6
10
|
import { ConfigError } from "../errors.js";
|
|
7
11
|
import { liftLegacyEngineExtraParams } from "../extra-params.js";
|
|
8
12
|
import { formatRegistryLabel, hasRegistryUrlCredentials } from "../registry-url.js";
|
|
9
13
|
import { acquireConfigLock, backupExistingConfig, parseConfigText, readConfigText, withConfigLock, writeConfigAtomic, } from "./config-io.js";
|
|
10
14
|
import { AkmConfigSchema, CURRENT_CONFIG_VERSION } from "./config-schema.js";
|
|
11
|
-
import { bundlesToSourceEntries } from "./config-sources.js";
|
|
15
|
+
import { bundleComponentConfig, bundleContentRoot, bundlesToSourceEntries } from "./config-sources.js";
|
|
12
16
|
import { upgradeConfigVersion } from "./config-version-shim.js";
|
|
13
|
-
import { deepMergeConfig } from "./deep-merge.js";
|
|
17
|
+
import { deepMergeConfig, isPlainObject } from "./deep-merge.js";
|
|
14
18
|
import { migrateLegacySourceShape } from "./legacy-source-shape-shim.js";
|
|
15
19
|
import { isApiKeyReference, SECRET_STORE_REFERENCE_PATTERN } from "./schema/primitives.js";
|
|
16
20
|
export { stripJsonComments } from "./config-io.js";
|
|
@@ -139,25 +143,31 @@ export function acquireConfigReadFence() {
|
|
|
139
143
|
}
|
|
140
144
|
}
|
|
141
145
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
146
|
+
* Run the per-file config pipeline every raw config object goes through
|
|
147
|
+
* before it is either validated (the local/top-level file) or merged in as
|
|
148
|
+
* an `extends` base: JSONC parse already done by the caller, then version
|
|
149
|
+
* shim, then legacy `stashDir`/`sources[]`/`installed[]` shim, then the
|
|
150
|
+
* legacy `extraParams` lift (#852). Shared by {@link parseAndValidateConfigText}
|
|
151
|
+
* (the local file) and {@link resolveExtendsChain} (each base in the chain) so
|
|
152
|
+
* a fleet-shared base config can carry its own old `configVersion` / legacy
|
|
153
|
+
* shape independently of the file that extends it.
|
|
149
154
|
*/
|
|
150
|
-
|
|
155
|
+
function runConfigFilePipeline(text, sourcePath) {
|
|
151
156
|
const versioned = upgradeConfigVersion(parseConfigText(text, sourcePath), sourcePath);
|
|
152
157
|
const parsedRaw = migrateLegacySourceShape(versioned, sourcePath);
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
158
|
+
return liftExtraParamsOrThrow(parsedRaw, sourcePath);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* #852 (following #815): a config still using legacy `extraParams` keys —
|
|
162
|
+
* e.g. `reasoning_effort`, a documented 0.9.1 workaround — needs to be
|
|
163
|
+
* rewritten onto the first-class engine field they now shadow. This used
|
|
164
|
+
* to happen silently, in memory, on every load; that ran forever and never
|
|
165
|
+
* converged. The lift itself is now `akm migrate apply`'s job (see
|
|
166
|
+
* scripts/akm-migrate/migrate/config-extra-params.ts) and persists to disk, so a
|
|
167
|
+
* config that has not been migrated yet fails closed here instead of
|
|
168
|
+
* silently drifting from what's on disk.
|
|
169
|
+
*/
|
|
170
|
+
function liftExtraParamsOrThrow(parsedRaw, sourcePath) {
|
|
161
171
|
const where = sourcePath ? ` at ${sourcePath}` : "";
|
|
162
172
|
const { config: liftedConfig, lifted, conflicts } = liftLegacyEngineExtraParams(parsedRaw);
|
|
163
173
|
if (conflicts.length > 0) {
|
|
@@ -169,7 +179,22 @@ export function parseAndValidateConfigText(text, sourcePath) {
|
|
|
169
179
|
if (lifted.length > 0) {
|
|
170
180
|
warnOnce(`config:extra-params-lift${sourcePath ? `:${sourcePath}` : ""}`, `Config${where} uses deprecated extraParams keys with first-class equivalents — auto-lifted in memory:\n - ${lifted.join("\n - ")}\n\nRun \`akm migrate apply\` to rewrite the config file and silence this warning.`);
|
|
171
181
|
}
|
|
172
|
-
|
|
182
|
+
return liftedConfig;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Resolve a local file's `extends` chain and validate the merged result
|
|
186
|
+
* (deep-merged under `DEFAULT_CONFIG`) via Zod ({@link AkmConfigSchema}).
|
|
187
|
+
* `liftedLocalRaw` must already be through {@link runConfigFilePipeline}.
|
|
188
|
+
*
|
|
189
|
+
* Split out of {@link parseAndValidateConfigText} so `mutateConfig` (#945
|
|
190
|
+
* finding: baking extends-inherited fields into the local file on every
|
|
191
|
+
* write) can build the same effective config from a `localRaw` it also
|
|
192
|
+
* keeps around, instead of only getting the final merged `AkmConfig` back.
|
|
193
|
+
*/
|
|
194
|
+
function buildEffectiveConfig(liftedLocalRaw, sourcePath) {
|
|
195
|
+
const withExtends = resolveExtendsChain(liftedLocalRaw, sourcePath);
|
|
196
|
+
const where = sourcePath ? ` at ${sourcePath}` : "";
|
|
197
|
+
const parsed = AkmConfigSchema.safeParse(withExtends);
|
|
173
198
|
if (!parsed.success) {
|
|
174
199
|
const lines = parsed.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
|
|
175
200
|
throw new ConfigError(`Invalid config${where}:\n${lines}`, "INVALID_CONFIG_FILE");
|
|
@@ -182,6 +207,199 @@ export function parseAndValidateConfigText(text, sourcePath) {
|
|
|
182
207
|
}
|
|
183
208
|
return finalResult.data;
|
|
184
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* Parse raw config text and validate via Zod.
|
|
212
|
+
* ({@link AkmConfigSchema}). Returns the merged-with-defaults AkmConfig.
|
|
213
|
+
*
|
|
214
|
+
* The schema accepts only the current config version. A known older version
|
|
215
|
+
* is auto-upgraded in memory first (see `./config-version-shim`); anything
|
|
216
|
+
* else — including anything newer — is rejected before the canonical shape
|
|
217
|
+
* is validated. When the config sets `extends` (#945), its resolved chain is
|
|
218
|
+
* deep-merged underneath before validation — see {@link resolveExtendsChain}.
|
|
219
|
+
*/
|
|
220
|
+
export function parseAndValidateConfigText(text, sourcePath) {
|
|
221
|
+
const liftedConfig = runConfigFilePipeline(text, sourcePath);
|
|
222
|
+
return buildEffectiveConfig(liftedConfig, sourcePath);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Walk an `extends` chain starting at `localRaw` (already through
|
|
226
|
+
* {@link runConfigFilePipeline}), returning every layer from the local file
|
|
227
|
+
* outward to the chain's root, local first. A chain is allowed (a base may
|
|
228
|
+
* itself set `extends`); a cycle (a resolved source repeating) throws
|
|
229
|
+
* {@link ConfigError} instead of recursing forever.
|
|
230
|
+
*/
|
|
231
|
+
function collectExtendsLayers(localRaw, configPath) {
|
|
232
|
+
const layers = [{ ref: undefined, raw: localRaw }];
|
|
233
|
+
const visited = new Set(configPath ? [path.resolve(configPath)] : []);
|
|
234
|
+
let current = localRaw;
|
|
235
|
+
let currentPath = configPath;
|
|
236
|
+
while (true) {
|
|
237
|
+
const ref = current.extends;
|
|
238
|
+
if (ref === undefined)
|
|
239
|
+
return layers;
|
|
240
|
+
if (typeof ref !== "string" || !ref.trim()) {
|
|
241
|
+
throw new ConfigError(`Invalid "extends"${currentPath ? ` at ${currentPath}` : ""}: expected a non-empty string (a file path or bundle//path ref), got ${JSON.stringify(ref)}.`, "INVALID_CONFIG_FILE");
|
|
242
|
+
}
|
|
243
|
+
const { text, resolvedPath } = resolveConfigRefSource(ref, current, currentPath);
|
|
244
|
+
if (visited.has(resolvedPath)) {
|
|
245
|
+
throw new ConfigError(`Config "extends" cycle detected: "${ref}"${currentPath ? ` (from ${currentPath})` : ""} resolves back to an already-visited config at ${resolvedPath}.`, "INVALID_CONFIG_FILE");
|
|
246
|
+
}
|
|
247
|
+
visited.add(resolvedPath);
|
|
248
|
+
const baseRaw = runConfigFilePipeline(text, resolvedPath);
|
|
249
|
+
layers.push({ ref, raw: baseRaw });
|
|
250
|
+
current = baseRaw;
|
|
251
|
+
currentPath = resolvedPath;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Deep-merge an `extends` chain into its effective raw shape: each layer's
|
|
256
|
+
* own fields win over its base's (`deepMergeConfig` "local wins" semantics),
|
|
257
|
+
* reduced root-to-local so the local file's fields win overall. `DEFAULT_CONFIG`
|
|
258
|
+
* is NOT applied here — the caller still layers it outermost, unchanged.
|
|
259
|
+
*
|
|
260
|
+
* The merged result's `extends` field, if any, is always the LOCAL file's own
|
|
261
|
+
* literal value (never a base's) — `deepMergeConfig`'s local-wins semantics
|
|
262
|
+
* already guarantee this at every level, since a layer only has `extends` set
|
|
263
|
+
* when it itself declares one. Keeping it (rather than stripping it) lets
|
|
264
|
+
* `akm config set`/`unset` round-trip the directive: `mutateConfig` reads the
|
|
265
|
+
* effective config as `current` for the mutation, but writes back only the
|
|
266
|
+
* fields `pruneUnchangedInheritedFields` finds changed-or-already-local (#945
|
|
267
|
+
* finding — the effective object used to be baked into the local file
|
|
268
|
+
* verbatim, duplicating every inherited field into it). That pruning keys off
|
|
269
|
+
* which fields are present in `current`/`next`, so `extends` has to still be
|
|
270
|
+
* one of them or it would silently vanish from disk on the very next
|
|
271
|
+
* `config set`.
|
|
272
|
+
*/
|
|
273
|
+
function resolveExtendsChain(localRaw, configPath) {
|
|
274
|
+
const layers = collectExtendsLayers(localRaw, configPath);
|
|
275
|
+
let merged = {};
|
|
276
|
+
for (let i = layers.length - 1; i >= 0; i--) {
|
|
277
|
+
merged = deepMergeConfig(merged, layers[i].raw);
|
|
278
|
+
}
|
|
279
|
+
return merged;
|
|
280
|
+
}
|
|
281
|
+
/** `~` expands to the home directory, mirroring `apiKeyFile`'s resolution (engine-resolution.ts). */
|
|
282
|
+
function expandExtendsHomePath(p) {
|
|
283
|
+
return p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p;
|
|
284
|
+
}
|
|
285
|
+
/** True when `ref`'s segment before the first `//` is a legal bundle slug — the `bundle//<path>` shape, not a plain filesystem path. */
|
|
286
|
+
function looksLikeBundleAssetRef(ref) {
|
|
287
|
+
const boundary = ref.indexOf("//");
|
|
288
|
+
return boundary > 0 && isBundleSlug(ref.slice(0, boundary));
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Resolve an `extends` value (or a `config diff <ref>` CLI argument — same
|
|
292
|
+
* grammar) to its config text and the absolute path it was read from. Either
|
|
293
|
+
* a filesystem path (relative to the directory of `fromConfigPath`; `~`
|
|
294
|
+
* expands) or a `bundle//<path>` ref, where the part after `//` is a plain
|
|
295
|
+
* file path *relative to that bundle's content root* — NOT an asset
|
|
296
|
+
* conceptId. It needs no asset type (`scripts/`, `knowledge/`, …) and is
|
|
297
|
+
* never indexed; a shared config file just lives wherever the bundle puts
|
|
298
|
+
* it. No URL form and no fetch/sync — the source must already exist locally;
|
|
299
|
+
* a missing file/bundle throws {@link ConfigError} naming the ref, as does an
|
|
300
|
+
* empty, absolute, or content-root-escaping path after `//`.
|
|
301
|
+
*/
|
|
302
|
+
function resolveConfigRefSource(ref, context, fromConfigPath) {
|
|
303
|
+
return looksLikeBundleAssetRef(ref)
|
|
304
|
+
? resolveConfigBundleRefSource(ref, context)
|
|
305
|
+
: resolveConfigFileRefSource(ref, fromConfigPath);
|
|
306
|
+
}
|
|
307
|
+
function resolveConfigFileRefSource(ref, fromConfigPath) {
|
|
308
|
+
const expanded = expandExtendsHomePath(ref);
|
|
309
|
+
let resolvedPath;
|
|
310
|
+
if (path.isAbsolute(expanded)) {
|
|
311
|
+
resolvedPath = expanded;
|
|
312
|
+
}
|
|
313
|
+
else if (fromConfigPath) {
|
|
314
|
+
resolvedPath = path.resolve(path.dirname(fromConfigPath), expanded);
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
throw new ConfigError(`extends "${ref}" is a relative path, but the current config has no known file location to resolve it against.`, "INVALID_CONFIG_FILE");
|
|
318
|
+
}
|
|
319
|
+
const text = readConfigText(resolvedPath);
|
|
320
|
+
if (text === undefined) {
|
|
321
|
+
throw new ConfigError(`extends "${ref}" resolves to ${resolvedPath}, which does not exist. Create the file first, or point "extends" at an existing config.`, "INVALID_CONFIG_FILE");
|
|
322
|
+
}
|
|
323
|
+
return { text, resolvedPath };
|
|
324
|
+
}
|
|
325
|
+
function resolveConfigBundleRefSource(ref, context) {
|
|
326
|
+
// Split by hand rather than through `parseBundleRef`: the part after `//`
|
|
327
|
+
// is a plain file path here, not an asset conceptId, so it must not be run
|
|
328
|
+
// through conceptId validation (which, for instance, rejects every `..`
|
|
329
|
+
// segment outright — stricter than the "must not escape the content root"
|
|
330
|
+
// rule this function enforces itself below via `path.resolve`).
|
|
331
|
+
// `looksLikeBundleAssetRef` already confirmed `ref` has this `bundle//`
|
|
332
|
+
// shape with a legal bundle slug before routing here.
|
|
333
|
+
const boundary = ref.indexOf("//");
|
|
334
|
+
const bundleId = ref.slice(0, boundary);
|
|
335
|
+
const relativePath = ref.slice(boundary + 2);
|
|
336
|
+
if (!relativePath) {
|
|
337
|
+
throw new ConfigError(`extends "${ref}" is missing a path after "${bundleId}//".`, "INVALID_CONFIG_FILE");
|
|
338
|
+
}
|
|
339
|
+
if (path.isAbsolute(relativePath)) {
|
|
340
|
+
throw new ConfigError(`extends "${ref}" must be a path relative to bundle "${bundleId}"'s content root, not absolute.`, "INVALID_CONFIG_FILE");
|
|
341
|
+
}
|
|
342
|
+
const bundles = isRecord(context.bundles) ? context.bundles : undefined;
|
|
343
|
+
const bundleEntry = bundles?.[bundleId];
|
|
344
|
+
const bundlePath = isRecord(bundleEntry) && typeof bundleEntry.path === "string" && bundleEntry.path.length > 0
|
|
345
|
+
? bundleEntry.path
|
|
346
|
+
: undefined;
|
|
347
|
+
if (!bundlePath || !isRecord(bundleEntry)) {
|
|
348
|
+
throw new ConfigError(`extends "${ref}" names bundle "${bundleId}", which is not a configured filesystem bundle (bundles.${bundleId}.path). Only a filesystem bundle can host an "extends" source.`, "INVALID_CONFIG_FILE");
|
|
349
|
+
}
|
|
350
|
+
let componentRoot;
|
|
351
|
+
try {
|
|
352
|
+
componentRoot = bundleComponentConfig(bundleEntry)?.root;
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
throw new ConfigError(`extends "${ref}" names bundle "${bundleId}": ${err instanceof Error ? err.message : String(err)}`, "INVALID_CONFIG_FILE");
|
|
356
|
+
}
|
|
357
|
+
const bundleRoot = bundleContentRoot(bundlePath, componentRoot);
|
|
358
|
+
const resolvedPath = path.resolve(bundleRoot, relativePath);
|
|
359
|
+
const relativeToRoot = path.relative(bundleRoot, resolvedPath);
|
|
360
|
+
if (relativeToRoot === ".." || relativeToRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeToRoot)) {
|
|
361
|
+
throw new ConfigError(`extends "${ref}" escapes bundle "${bundleId}"'s content root.`, "INVALID_CONFIG_FILE");
|
|
362
|
+
}
|
|
363
|
+
const text = readConfigText(resolvedPath);
|
|
364
|
+
if (text === undefined) {
|
|
365
|
+
throw new ConfigError(`extends "${ref}" resolves to ${resolvedPath}, which does not exist locally. Sync the bundle first, or point "extends" at an existing file.`, "INVALID_CONFIG_FILE");
|
|
366
|
+
}
|
|
367
|
+
return { text, resolvedPath };
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* `akm config get --show-source` (#945): which raw layer the dotted path's
|
|
371
|
+
* value comes from — `"local"` when the local file's own raw JSON sets it,
|
|
372
|
+
* `"extends:<ref>"` for the nearest chain member that sets it (the ref by
|
|
373
|
+
* which that member is reached), or `"default"` when no layer sets it
|
|
374
|
+
* (`DEFAULT_CONFIG` or schema default supplies it). Computed lazily by
|
|
375
|
+
* re-walking the raw layers on each call — no merge-time bookkeeping.
|
|
376
|
+
*/
|
|
377
|
+
export function getConfigValueSource(dotted) {
|
|
378
|
+
const configPath = getConfigPath();
|
|
379
|
+
const text = readConfigText(configPath);
|
|
380
|
+
if (text === undefined)
|
|
381
|
+
return "default";
|
|
382
|
+
const liftedConfig = runConfigFilePipeline(text, configPath);
|
|
383
|
+
const segments = dotted.split(".").filter((s) => s.length > 0);
|
|
384
|
+
for (const layer of collectExtendsLayers(liftedConfig, configPath)) {
|
|
385
|
+
if (hasRawPath(layer.raw, segments)) {
|
|
386
|
+
return layer.ref === undefined ? "local" : `extends:${layer.ref}`;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return "default";
|
|
390
|
+
}
|
|
391
|
+
function hasRawPath(raw, segments) {
|
|
392
|
+
let cursor = raw;
|
|
393
|
+
for (const segment of segments) {
|
|
394
|
+
if (!isRecord(cursor) || !(segment in cursor))
|
|
395
|
+
return false;
|
|
396
|
+
cursor = cursor[segment];
|
|
397
|
+
}
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
// Exposed for `akm config diff` (config-cli.ts): the `<path|bundle//ref>`
|
|
401
|
+
// argument shares the exact same resolution grammar as `extends`.
|
|
402
|
+
export { resolveConfigRefSource };
|
|
185
403
|
/**
|
|
186
404
|
* The configured stash sources as an ordered {@link SourceConfigEntry} list.
|
|
187
405
|
*
|
|
@@ -229,6 +447,56 @@ export function validateCompleteConfig(config) {
|
|
|
229
447
|
throw new ConfigError(`Refusing to save invalid config:\n${lines}`, "INVALID_CONFIG_FILE", "Fix the listed fields, or undo the offending `akm config set`. " +
|
|
230
448
|
"If this looks like an akm bug, re-run with --debug to attach the traceback.");
|
|
231
449
|
}
|
|
450
|
+
/**
|
|
451
|
+
* #945 review finding: `mutateConfig` used to write the entire extends-merged
|
|
452
|
+
* *effective* config (`next`) back to the local file on every `config
|
|
453
|
+
* set`/`unset`, duplicating every inherited `engines`/`improve.strategies`
|
|
454
|
+
* field into the local file on the very first ordinary write after adopting
|
|
455
|
+
* `extends` — defeating the feature (shared config, ≤20-line local files)
|
|
456
|
+
* and silently freezing the local copy against future upstream changes.
|
|
457
|
+
*
|
|
458
|
+
* Reduces `after` (the mutated effective config) down to only what the
|
|
459
|
+
* mutation actually changed relative to `before` (the effective config
|
|
460
|
+
* *before* the mutation), plus whatever `localRaw` — the local file's own
|
|
461
|
+
* raw content, pre-`extends`-merge — already had explicitly. Everything
|
|
462
|
+
* else that only came along for the ride from `extends`/`DEFAULT_CONFIG` is
|
|
463
|
+
* left out, so it keeps being read from the base on the next load instead
|
|
464
|
+
* of being frozen as a local literal.
|
|
465
|
+
*/
|
|
466
|
+
function pruneUnchangedInheritedFields(before, after, localRaw) {
|
|
467
|
+
if (!isPlainObject(after) || !isPlainObject(before))
|
|
468
|
+
return after;
|
|
469
|
+
const localRecord = isPlainObject(localRaw) ? localRaw : undefined;
|
|
470
|
+
const result = {};
|
|
471
|
+
for (const key of Object.keys(after)) {
|
|
472
|
+
const afterValue = after[key];
|
|
473
|
+
const beforeValue = before[key];
|
|
474
|
+
const localHasKey = localRecord ? Object.hasOwn(localRecord, key) : false;
|
|
475
|
+
if (isPlainObject(afterValue) && isPlainObject(beforeValue)) {
|
|
476
|
+
const pruned = pruneUnchangedInheritedFields(beforeValue, afterValue, localHasKey ? localRecord?.[key] : undefined);
|
|
477
|
+
const prunedHasContent = isPlainObject(pruned) ? Object.keys(pruned).length > 0 : pruned !== undefined;
|
|
478
|
+
if (prunedHasContent || localHasKey)
|
|
479
|
+
result[key] = pruned;
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (localHasKey || !isDeepStrictEqual(afterValue, beforeValue)) {
|
|
483
|
+
result[key] = afterValue;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return result;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* What to persist for a `mutateConfig`/`mutateConfigWithPrecommit` write:
|
|
490
|
+
* the full effective `next` when the local file has no `extends` (unchanged
|
|
491
|
+
* pre-#945 behavior), otherwise only the changed-or-already-local fields
|
|
492
|
+
* (#945 finding above).
|
|
493
|
+
*/
|
|
494
|
+
function configWriteBody(localRaw, current, next) {
|
|
495
|
+
const usesExtends = typeof localRaw?.extends === "string" && localRaw.extends.trim().length > 0;
|
|
496
|
+
if (!usesExtends)
|
|
497
|
+
return next;
|
|
498
|
+
return pruneUnchangedInheritedFields(current, next, localRaw);
|
|
499
|
+
}
|
|
232
500
|
/**
|
|
233
501
|
* Mutate config under one fail-closed lock spanning read, merge, validation,
|
|
234
502
|
* ordinary backup, and atomic write.
|
|
@@ -241,7 +509,8 @@ export function mutateConfig(mutate, options) {
|
|
|
241
509
|
if (text === undefined && options?.absentNoop) {
|
|
242
510
|
return { config: { ...DEFAULT_CONFIG }, written: false };
|
|
243
511
|
}
|
|
244
|
-
const
|
|
512
|
+
const localRaw = text === undefined ? undefined : runConfigFilePipeline(text, configPath);
|
|
513
|
+
const current = localRaw === undefined ? { ...DEFAULT_CONFIG } : buildEffectiveConfig(localRaw, configPath);
|
|
245
514
|
const mutated = mutate(current);
|
|
246
515
|
if (mutated === current)
|
|
247
516
|
return { config: current, written: false };
|
|
@@ -249,7 +518,7 @@ export function mutateConfig(mutate, options) {
|
|
|
249
518
|
if (text !== undefined)
|
|
250
519
|
backupExistingConfig(configPath);
|
|
251
520
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
252
|
-
writeConfigAtomic(configPath, sanitizeConfigForWrite(next));
|
|
521
|
+
writeConfigAtomic(configPath, sanitizeConfigForWrite(configWriteBody(localRaw, current, next)));
|
|
253
522
|
return { config: next, written: true };
|
|
254
523
|
});
|
|
255
524
|
}
|
|
@@ -264,7 +533,8 @@ export async function mutateConfigWithPrecommit(mutate, precommit) {
|
|
|
264
533
|
const release = acquireConfigLock();
|
|
265
534
|
try {
|
|
266
535
|
const text = readConfigText(configPath);
|
|
267
|
-
const
|
|
536
|
+
const localRaw = text === undefined ? undefined : runConfigFilePipeline(text, configPath);
|
|
537
|
+
const current = localRaw === undefined ? { ...DEFAULT_CONFIG } : buildEffectiveConfig(localRaw, configPath);
|
|
268
538
|
const mutated = mutate(current);
|
|
269
539
|
const next = validateCompleteConfig({ ...mutated, configVersion: CURRENT_CONFIG_VERSION });
|
|
270
540
|
if (text !== undefined)
|
|
@@ -273,7 +543,7 @@ export async function mutateConfigWithPrecommit(mutate, precommit) {
|
|
|
273
543
|
if (mutated === current)
|
|
274
544
|
return { config: current, written: false, precommit: precommitResult };
|
|
275
545
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
276
|
-
writeConfigAtomic(configPath, sanitizeConfigForWrite(next));
|
|
546
|
+
writeConfigAtomic(configPath, sanitizeConfigForWrite(configWriteBody(localRaw, current, next)));
|
|
277
547
|
return { config: next, written: true, precommit: precommitResult };
|
|
278
548
|
}
|
|
279
549
|
finally {
|
|
@@ -383,7 +653,15 @@ export function resolveSecret(value, resolveFromStore) {
|
|
|
383
653
|
if (!value.includes("$"))
|
|
384
654
|
return value;
|
|
385
655
|
return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, braced, bare) => {
|
|
386
|
-
|
|
656
|
+
const name = (braced ?? bare);
|
|
657
|
+
const resolved = process.env[name];
|
|
658
|
+
if (!resolved) {
|
|
659
|
+
// #953: an unset/empty $VAR used to substitute silently, so a gateway
|
|
660
|
+
// that enforces auth on inference (e.g. Bifrost) failed every call with
|
|
661
|
+
// an opaque 401 instead of a diagnosable warning naming the variable.
|
|
662
|
+
warnOnce(`config:empty-env-var:${name}`, `Environment variable ${name} referenced by a $VAR apiKey is unset or empty; the request will be sent without a valid credential.`);
|
|
663
|
+
}
|
|
664
|
+
return resolved ?? "";
|
|
387
665
|
});
|
|
388
666
|
}
|
|
389
667
|
/**
|