akm-cli 0.9.10 → 0.9.12
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 +226 -0
- package/STABILITY.md +28 -15
- package/dist/assets/hints/cli-hints-full.md +1 -1
- package/dist/assets/improve-strategies/consolidate.json +1 -1
- package/dist/assets/improve-strategies/default.json +1 -1
- package/dist/assets/improve-strategies/thorough.json +1 -2
- package/dist/cli/shared.js +16 -4
- package/dist/cli.js +15 -13
- package/dist/commands/agent/agent-dispatch.js +8 -0
- package/dist/commands/command/execution-source-loader.js +25 -22
- package/dist/commands/command/portable-template.js +4 -26
- package/dist/commands/config-cli.js +10 -4
- package/dist/commands/env/env-binding.js +10 -3
- package/dist/commands/env/env-cli.js +7 -0
- package/dist/commands/env/secret-cli.js +15 -4
- package/dist/commands/health/checks.js +209 -78
- package/dist/commands/health/improve-metrics.js +12 -0
- package/dist/commands/health.js +16 -4
- package/dist/commands/improve/distill/quality-gate.js +15 -7
- package/dist/commands/improve/distill.js +28 -12
- package/dist/commands/improve/eval-cases.js +9 -2
- package/dist/commands/improve/execution.js +1 -2
- package/dist/commands/improve/extract.js +82 -56
- package/dist/commands/improve/improve-strategies.js +26 -8
- package/dist/commands/improve/improve.js +32 -4
- package/dist/commands/improve/loop-stages.js +13 -3
- package/dist/commands/improve/preparation.js +9 -6
- package/dist/commands/improve/reflect.js +61 -77
- package/dist/commands/lint/index.js +3 -1
- package/dist/commands/migrate-cli.js +6 -4
- package/dist/commands/proposal/drain-policies.js +22 -2
- package/dist/commands/proposal/repository.js +4 -4
- package/dist/commands/proposal/validators/proposal-quality-validators.js +23 -2
- package/dist/commands/proposal/validators/proposals.js +10 -19
- package/dist/commands/read/show.js +42 -31
- package/dist/commands/registry-cli.js +4 -2
- package/dist/commands/sources/init.js +4 -8
- package/dist/commands/sources/self-update.js +2 -2
- package/dist/commands/sources/source-clone.js +5 -7
- package/dist/commands/sources/sources-cli.js +3 -5
- package/dist/commands/tasks/tasks-cli.js +36 -12
- package/dist/commands/tasks/tasks.js +38 -35
- package/dist/commands/tasks/validate.js +186 -0
- package/dist/commands/url-checker.js +75 -16
- package/dist/commands/workflow-cli.js +17 -15
- package/dist/core/activation-policy.js +31 -3
- package/dist/core/adapter/execution-source.js +39 -11
- package/dist/core/asset/stash-meta.js +7 -41
- package/dist/core/bundle-id.js +7 -1
- package/dist/core/common.js +8 -17
- package/dist/core/config/config-schema.js +3 -23
- package/dist/core/config/config-walker.js +50 -4
- package/dist/core/config/config.js +21 -5
- package/dist/core/config/legacy-source-shape-shim.js +79 -0
- package/dist/core/config/schema/embedding.js +2 -2
- package/dist/core/config/schema/engines.js +19 -2
- package/dist/core/config/schema/index-config.js +19 -21
- package/dist/core/config/schema/primitives.js +21 -10
- package/dist/core/config/schema/sources-bundles.js +1 -6
- package/dist/core/errors.js +2 -3
- package/dist/core/improve-result.js +8 -0
- package/dist/core/improve-types.js +17 -0
- package/dist/core/json-schema.js +1 -11
- package/dist/core/maintenance-barrier.js +17 -2
- package/dist/core/paths.js +124 -15
- package/dist/core/state/migrations.js +28 -0
- package/dist/core/state-db.js +28 -1
- package/dist/core/write-source.js +6 -6
- package/dist/indexer/bundle-identity-guard.js +3 -0
- package/dist/indexer/ensure-index.js +5 -0
- package/dist/indexer/indexer.js +11 -3
- package/dist/indexer/lookup/adapter-concept-owner.js +14 -3
- package/dist/indexer/passes/metadata.js +16 -5
- package/dist/indexer/search/search-fields.js +1 -30
- package/dist/indexer/search/search-source.js +3 -2
- package/dist/integrations/agent/engine-resolution.js +107 -4
- package/dist/integrations/agent/execution-lowering.js +15 -2
- package/dist/integrations/agent/model-map.js +16 -10
- package/dist/integrations/agent/prompts.js +13 -6
- package/dist/integrations/agent/runner-dispatch.js +16 -3
- package/dist/integrations/agent/runner.js +2 -0
- package/dist/integrations/lockfile.js +22 -7
- package/dist/llm/client.js +16 -0
- package/dist/llm/index-passes.js +3 -2
- package/dist/output/shapes/passthrough.js +10 -3
- package/dist/output/shapes.js +5 -3
- package/dist/output/text/workflow-format.js +8 -1
- package/dist/scripts/akm-migrate-node.js +2028 -1613
- package/dist/scripts/akm-migrate.js +2027 -1612
- package/dist/setup/setup.js +14 -21
- package/dist/sources/include.js +150 -20
- package/dist/sources/providers/git-install.js +14 -12
- package/dist/sources/providers/git-provider.js +3 -3
- package/dist/sources/snapshot-fetchers/website-ingest.js +54 -16
- package/dist/sources/website-url.js +12 -4
- package/dist/storage/engines/sqlite-migrations.js +40 -10
- package/dist/storage/like-pattern.js +7 -0
- package/dist/storage/repositories/extract-sessions-repository.js +23 -0
- package/dist/storage/repositories/index-connection.js +27 -10
- package/dist/storage/repositories/index-entry-schema.js +19 -2
- package/dist/storage/repositories/index-schema.js +30 -9
- package/dist/storage/repositories/proposals-repository.js +2 -1
- package/dist/storage/repositories/task-history-repository.js +14 -7
- package/dist/storage/repositories/workflow-runs-repository.js +15 -1
- package/dist/storage/sqlite-read-snapshot.js +11 -9
- package/dist/tasks/backends/cron.js +34 -5
- package/dist/tasks/backends/launchd.js +23 -26
- package/dist/tasks/backends/schtasks.js +50 -3
- package/dist/tasks/frozen-script.js +2 -0
- package/dist/tasks/prepare/prepare.js +2 -7
- package/dist/tasks/prepare/script-capture.js +38 -6
- package/dist/tasks/schedule.js +154 -13
- package/dist/tasks/scheduler-sync.js +51 -25
- package/dist/tasks/source/task-source-v3-frozen.js +0 -1
- package/dist/tasks/source/task-source-v4.js +0 -1
- package/dist/workflows/exec/child-workflow.js +2 -3
- package/dist/workflows/exec/dispatch-redaction.js +21 -7
- package/dist/workflows/exec/exec-unit.js +3 -4
- package/dist/workflows/exec/run-workflow.js +19 -10
- package/dist/workflows/exec/step-work.js +35 -56
- package/dist/workflows/freeze/resolve-steps.js +19 -11
- package/dist/workflows/freeze/source-freeze.js +7 -0
- package/dist/workflows/freeze/targets/child-workflow.js +12 -18
- package/dist/workflows/freeze/targets/command.js +14 -2
- package/dist/workflows/ir/environment-v4.js +4 -2
- package/dist/workflows/ir/freeze-v4.js +2 -5
- package/dist/workflows/ir/plan-hash.js +0 -3
- package/dist/workflows/ir/schema-v4.js +14 -9
- package/dist/workflows/ir/schema.js +1 -3
- package/dist/workflows/resource-limits.js +35 -48
- package/dist/workflows/runtime/plan-classifier.js +89 -41
- package/dist/workflows/runtime/run-outputs.js +1 -21
- package/dist/workflows/runtime/runs.js +76 -150
- package/dist/workflows/source-files.js +28 -54
- package/dist/workflows/source-ir/program.js +2 -2
- package/dist/workflows/source-ir/semantics.js +5 -23
- package/docs/integration/bundling-akm.md +1 -1
- package/docs/migration/v0.8-to-v0.9.md +32 -0
- package/docs/migration/v0.9.1-to-v0.9.2.md +20 -0
- package/docs/reference/cli.md +105 -22
- package/docs/reference/configuration.md +12 -2
- package/docs/reference/data-and-telemetry.md +1 -1
- package/docs/reference/tasks.md +8 -0
- package/package.json +1 -1
- package/schemas/akm-config.json +11 -8
|
@@ -0,0 +1,186 @@
|
|
|
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
|
+
* `akm task validate <path>` (#907) — parse ONE task file by filesystem
|
|
6
|
+
* path, not a concept ref/id, and report the same diagnostic `akm task
|
|
7
|
+
* sync` would produce for it. The file need not live in any configured
|
|
8
|
+
* bundle: unlike every other `akm task` subcommand, this one never resolves
|
|
9
|
+
* a bundle/adapter/concept id at all — it reads exactly the path it was
|
|
10
|
+
* given and classifies it.
|
|
11
|
+
*
|
|
12
|
+
* Reuses the exact version-routing shim `parseTaskSource`
|
|
13
|
+
* (`src/tasks/source/parse-task-source.ts`) already applies for every other
|
|
14
|
+
* task-source reader (`akm task sync`'s `compileTaskSources` included) —
|
|
15
|
+
* this module never forks a second parser or a second v2/v3 migration
|
|
16
|
+
* planner. `readBoundedTaskSourceYaml` / `peekTaskSourceVersion` / `own` are
|
|
17
|
+
* the SAME front-end helpers that shim itself calls first; they are used
|
|
18
|
+
* here only to recover the file's ORIGINALLY DECLARED schema version for
|
|
19
|
+
* the report, because `parseTaskSource`'s own `ParsedTaskSource.version` is
|
|
20
|
+
* always `4` post-shim — it cannot answer "was this a v2/v3/v4 file?" on
|
|
21
|
+
* its own once a v2/v3 source has been converted in memory.
|
|
22
|
+
*
|
|
23
|
+
* Beyond parsing, this module also runs the SAME two per-source gates
|
|
24
|
+
* `akm task sync`'s `compileTaskSources` runs before it ever installs a
|
|
25
|
+
* schedule — `assertTaskScheduleInputsSatisfyContract` and
|
|
26
|
+
* `assertTaskScheduleCronValid` (both extracted from `scheduler-sync.ts` for
|
|
27
|
+
* exactly this reuse) — so a file `sync` would reject can never
|
|
28
|
+
* be reported `valid`/`converts` here. Cron dialect is checked against
|
|
29
|
+
* `backendNameForPlatform()`, the same platform default `sync` falls back
|
|
30
|
+
* to whenever it has no injected/native-inspected backend to hand (see
|
|
31
|
+
* `akmTasksAdd`, `src/commands/tasks/tasks.ts`); a bare file was never
|
|
32
|
+
* installed anywhere; there is no native scheduler state to inspect for it.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately DOES NOT call `prepareTaskV3Execution` (the function
|
|
35
|
+
* `compileTaskSources` calls between those two gates) or resolve an
|
|
36
|
+
* execution engine: that path resolves a composed command/persona ref
|
|
37
|
+
* against the local index and lowers the task's cascade-composed
|
|
38
|
+
* engine/model — both of which assume a real, indexed bundle and a
|
|
39
|
+
* configured engine. A bare file passed to `validate` has neither, so
|
|
40
|
+
* running that step would make an otherwise-valid command-kind task report
|
|
41
|
+
* `invalid` on any machine with no engine configured. `resolved`
|
|
42
|
+
* is therefore the compiled task shape `sync` itself would build a
|
|
43
|
+
* scheduler binding from — id, the compiled v4 `version`, `target`
|
|
44
|
+
* (`uses`/`run`), the declared `inputs` contract, and `schedule` bindings —
|
|
45
|
+
* never an execution-lowered plan.
|
|
46
|
+
*
|
|
47
|
+
* Outcome classification (mirrors `parse-task-source.ts`'s own routing
|
|
48
|
+
* table in its header, extended for the two gates above):
|
|
49
|
+
* - `valid` — parses as task source v4 directly (declared `version: 4`)
|
|
50
|
+
* and passes both sync gates.
|
|
51
|
+
* - `converts` — declared `version: 2` or `3`; the deterministic
|
|
52
|
+
* in-memory migrator produced a valid v4 document that
|
|
53
|
+
* passes both sync gates.
|
|
54
|
+
* - `blocked` — declared `version: 2` or `3`; the migrator itself
|
|
55
|
+
* could not convert it (an ambiguous/unmigratable
|
|
56
|
+
* shape) — the ONLY way `parseTaskSource` ever throws
|
|
57
|
+
* for those two version numbers, so no message-text
|
|
58
|
+
* sniffing is needed to tell this apart from `invalid`.
|
|
59
|
+
* - `invalid` — the document declares SOME version (`4`, or anything
|
|
60
|
+
* other than 2/3/4) but fails to parse/validate, OR it
|
|
61
|
+
* parsed (directly or via a SUCCESSFUL v2/v3
|
|
62
|
+
* conversion) but fails one of the two sync gates
|
|
63
|
+
* above, OR the YAML itself does not parse at all
|
|
64
|
+
* (a genuine syntax error, not merely a non-task
|
|
65
|
+
* shape) — reported with the parser's own reason.
|
|
66
|
+
* - `not-a-task` — the document parses as YAML but never declares a
|
|
67
|
+
* `version:` field at all (or isn't a YAML mapping) —
|
|
68
|
+
* the strongest signal available that the file was
|
|
69
|
+
* never intended as a task source in the first place.
|
|
70
|
+
*/
|
|
71
|
+
import fs from "node:fs";
|
|
72
|
+
import path from "node:path";
|
|
73
|
+
import { UsageError } from "../../core/errors.js";
|
|
74
|
+
import { backendNameForPlatform } from "../../tasks/backends/index.js";
|
|
75
|
+
import { assertTaskScheduleCronValid, assertTaskScheduleInputsSatisfyContract } from "../../tasks/scheduler-sync.js";
|
|
76
|
+
import { own, readBoundedTaskSourceYaml } from "../../tasks/source/bounded-document.js";
|
|
77
|
+
import { parseTaskSource, peekTaskSourceVersion } from "../../tasks/source/parse-task-source.js";
|
|
78
|
+
/** True when `root` is a YAML mapping that itself declares a `version:` key, regardless of that key's type/value. */
|
|
79
|
+
function declaresVersionKey(root) {
|
|
80
|
+
return root !== null && typeof root === "object" && !Array.isArray(root) && own(root, "version");
|
|
81
|
+
}
|
|
82
|
+
function buildResolved(id, v4) {
|
|
83
|
+
return {
|
|
84
|
+
id,
|
|
85
|
+
version: v4.version,
|
|
86
|
+
...(v4.name !== undefined ? { name: v4.name } : {}),
|
|
87
|
+
...(v4.description !== undefined ? { description: v4.description } : {}),
|
|
88
|
+
target: v4.target,
|
|
89
|
+
inputs: v4.inputs ?? {},
|
|
90
|
+
schedule: v4.schedule,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
export async function akmTaskValidate(filePath) {
|
|
94
|
+
const resolvedPath = path.resolve(filePath);
|
|
95
|
+
let stat;
|
|
96
|
+
try {
|
|
97
|
+
stat = fs.statSync(resolvedPath);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
throw new UsageError(`Task file not found: ${JSON.stringify(filePath)}.`, "INVALID_FLAG_VALUE");
|
|
101
|
+
}
|
|
102
|
+
if (!stat.isFile()) {
|
|
103
|
+
throw new UsageError(`${JSON.stringify(filePath)} is not a regular file.`, "INVALID_FLAG_VALUE");
|
|
104
|
+
}
|
|
105
|
+
let yaml;
|
|
106
|
+
try {
|
|
107
|
+
yaml = fs.readFileSync(resolvedPath, "utf8");
|
|
108
|
+
}
|
|
109
|
+
catch (cause) {
|
|
110
|
+
throw new UsageError(`Task file ${JSON.stringify(filePath)} could not be read: ${cause instanceof Error ? cause.message : String(cause)}`, "INVALID_FLAG_VALUE");
|
|
111
|
+
}
|
|
112
|
+
// Peek the declared version BEFORE the real parse, using the identical
|
|
113
|
+
// bounded YAML front end `parseTaskSource` calls internally — never a
|
|
114
|
+
// second/looser YAML reader. A front-end failure here (unparseable YAML,
|
|
115
|
+
// not a mapping, exceeds a resource bound) means the document itself does
|
|
116
|
+
// not parse at all — tracked as `peekFailed` so that case reports
|
|
117
|
+
// `invalid`, never `not-a-task` (`not-a-task` is reserved for
|
|
118
|
+
// YAML that DOES parse but never declared a task shape). The real parse
|
|
119
|
+
// below throws the identical error either way, so nothing is lost by
|
|
120
|
+
// swallowing it here.
|
|
121
|
+
let root;
|
|
122
|
+
let peekFailed = false;
|
|
123
|
+
try {
|
|
124
|
+
root = readBoundedTaskSourceYaml({ yaml, filePath: resolvedPath }, { sourceLabel: "task source" }).root;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
peekFailed = true;
|
|
128
|
+
}
|
|
129
|
+
const declaredVersion = peekFailed ? undefined : peekTaskSourceVersion(root);
|
|
130
|
+
const hasVersionKey = !peekFailed && declaresVersionKey(root);
|
|
131
|
+
const workspaceRoot = path.dirname(resolvedPath);
|
|
132
|
+
const backend = backendNameForPlatform();
|
|
133
|
+
let parsed;
|
|
134
|
+
try {
|
|
135
|
+
parsed = parseTaskSource({ yaml, filePath: resolvedPath, workspaceRoot });
|
|
136
|
+
}
|
|
137
|
+
catch (cause) {
|
|
138
|
+
if (!(cause instanceof UsageError))
|
|
139
|
+
throw cause;
|
|
140
|
+
const reason = cause.message;
|
|
141
|
+
// `parseTaskSource` only ever throws for a declared version 2/3 via the
|
|
142
|
+
// unmigratable-conversion branch (see this file's header) — no separate
|
|
143
|
+
// message check needed to recognize "blocked" here.
|
|
144
|
+
if (declaredVersion === 2 || declaredVersion === 3) {
|
|
145
|
+
return { ok: false, path: resolvedPath, sourceVersion: declaredVersion, outcome: "blocked", reason };
|
|
146
|
+
}
|
|
147
|
+
if (peekFailed) {
|
|
148
|
+
return { ok: false, path: resolvedPath, outcome: "invalid", reason };
|
|
149
|
+
}
|
|
150
|
+
if (!hasVersionKey) {
|
|
151
|
+
return { ok: false, path: resolvedPath, outcome: "not-a-task", reason };
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
path: resolvedPath,
|
|
156
|
+
...(declaredVersion !== undefined ? { sourceVersion: declaredVersion } : {}),
|
|
157
|
+
outcome: "invalid",
|
|
158
|
+
reason,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
// Success is unreachable from any path that leaves `declaredVersion`
|
|
162
|
+
// undefined — the router requires a numeric 2/3/4 version to reach here.
|
|
163
|
+
const sourceVersion = declaredVersion ?? 4;
|
|
164
|
+
// The document itself parsed (directly, or via a successful v2/v3
|
|
165
|
+
// conversion) — now the two gates `compileTaskSources` runs before
|
|
166
|
+
// accepting it. A violation here is `invalid`, never `blocked`: the
|
|
167
|
+
// migrator already succeeded, so this is the same kind of defect a
|
|
168
|
+
// native v4 document with the identical schedule would have.
|
|
169
|
+
try {
|
|
170
|
+
assertTaskScheduleInputsSatisfyContract(parsed.v4, resolvedPath);
|
|
171
|
+
assertTaskScheduleCronValid(parsed.v4, backend);
|
|
172
|
+
}
|
|
173
|
+
catch (cause) {
|
|
174
|
+
if (!(cause instanceof UsageError))
|
|
175
|
+
throw cause;
|
|
176
|
+
return { ok: false, path: resolvedPath, sourceVersion, outcome: "invalid", reason: cause.message };
|
|
177
|
+
}
|
|
178
|
+
const id = path.parse(resolvedPath).name;
|
|
179
|
+
return {
|
|
180
|
+
ok: true,
|
|
181
|
+
path: resolvedPath,
|
|
182
|
+
sourceVersion,
|
|
183
|
+
outcome: sourceVersion === 2 || sourceVersion === 3 ? "converts" : "valid",
|
|
184
|
+
resolved: buildResolved(id, parsed.v4),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -1,34 +1,93 @@
|
|
|
1
1
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
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
|
+
import { concurrentMap } from "../core/concurrent.js";
|
|
5
|
+
import { systemErrorCode } from "../core/system-error.js";
|
|
4
6
|
const URL_RE = /https?:\/\/[^\s"'<>)\]]+/g;
|
|
7
|
+
/**
|
|
8
|
+
* URLs are checked `URL_CHECK_CONCURRENCY` at a time via {@link concurrentMap}
|
|
9
|
+
* rather than all at once: a knowledge bundle can hold thousands of links, and
|
|
10
|
+
* firing every HEAD request in one `Promise.allSettled` batch is a
|
|
11
|
+
* self-inflicted denial-of-service against whatever host happens to be linked
|
|
12
|
+
* most. Bounding concurrency changes only how fast the check runs, never how
|
|
13
|
+
* much of it happens — every URL still gets checked.
|
|
14
|
+
*/
|
|
15
|
+
const URL_CHECK_CONCURRENCY = 8;
|
|
16
|
+
/**
|
|
17
|
+
* Per-HEAD-request timeout, matching the old `TIMEOUT_MS` the caps this
|
|
18
|
+
* checker replaced used. A dead site rarely refuses cleanly — it hangs — so
|
|
19
|
+
* without this a handful of unresponsive hosts could stall the whole check
|
|
20
|
+
* indefinitely instead of the timed-out URLs simply showing up as dead.
|
|
21
|
+
*/
|
|
22
|
+
const DEAD_URL_TIMEOUT_MS = 5000;
|
|
23
|
+
/**
|
|
24
|
+
* DNS/connection-level codes meaning "this machine could not reach the host
|
|
25
|
+
* right now" — a corporate DNS block, an offline sandbox, a transient blip —
|
|
26
|
+
* as opposed to "the resource is gone". Same grouping `classifyVectorFailure`
|
|
27
|
+
* (indexer/search/db-search.ts) uses for its "connection failed" bucket.
|
|
28
|
+
* These are counted in `coverage.skipped`, never reported as a `DeadUrl`: an
|
|
29
|
+
* indeterminate result is not evidence a link is dead.
|
|
30
|
+
*/
|
|
31
|
+
const NETWORK_ERROR_CODES = new Set([
|
|
32
|
+
"ECONNREFUSED",
|
|
33
|
+
"ECONNRESET",
|
|
34
|
+
"ENETUNREACH",
|
|
35
|
+
"EHOSTUNREACH",
|
|
36
|
+
"ENOTFOUND",
|
|
37
|
+
"EAI_AGAIN",
|
|
38
|
+
]);
|
|
5
39
|
/**
|
|
6
40
|
* Check every URL in `entries` and report the ones that are dead.
|
|
7
41
|
*
|
|
8
42
|
* No cap, no per-entry slice, no ceiling option. There used to be a
|
|
9
43
|
* `MAX_URLS = 20` plus an undocumented `slice(0, 3)` per entry, so this
|
|
10
44
|
* examined at most twenty links in a bundle holding thousands and reported
|
|
11
|
-
* success.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
45
|
+
* success. Both are gone. It checks what you asked it to check, at a bounded
|
|
46
|
+
* concurrency (see {@link URL_CHECK_CONCURRENCY}) so a large bundle does not
|
|
47
|
+
* turn into a request flood, and each request is bounded by
|
|
48
|
+
* {@link DEAD_URL_TIMEOUT_MS} so one unresponsive host cannot stall the rest.
|
|
15
49
|
*
|
|
16
|
-
* A
|
|
17
|
-
* rather than being swallowed, so a network problem is
|
|
18
|
-
* looking like a clean bill of health.
|
|
50
|
+
* A `>=400` response or a timeout surfaces as a `DeadUrl` (a timeout as
|
|
51
|
+
* `status: "timeout"`) rather than being swallowed, so a network problem is
|
|
52
|
+
* visible instead of looking like a clean bill of health. A DNS/connection
|
|
53
|
+
* failure (see {@link NETWORK_ERROR_CODES}) is different: it says the check
|
|
54
|
+
* itself could not run, not that the URL is dead, so it is counted in
|
|
55
|
+
* `coverage.skipped` instead of either `deadUrls` or a silent success. Any
|
|
56
|
+
* other thrown error still reports as `status: "error"`. `coverage.checked`
|
|
57
|
+
* and `coverage.total` are equal only when nothing was skipped this way.
|
|
19
58
|
*/
|
|
20
59
|
export async function checkDeadUrls(_stashDir, entries) {
|
|
21
60
|
const urlsToCheck = entries.flatMap((entry) => (entry.body.match(URL_RE) ?? []).map((url) => ({ ref: entry.ref, url })));
|
|
22
|
-
const
|
|
23
|
-
await Promise.allSettled(urlsToCheck.map(async ({ ref, url }) => {
|
|
61
|
+
const outcomes = await concurrentMap(urlsToCheck, async ({ ref, url }) => {
|
|
24
62
|
try {
|
|
25
|
-
const res = await fetch(url, {
|
|
26
|
-
|
|
27
|
-
|
|
63
|
+
const res = await fetch(url, {
|
|
64
|
+
method: "HEAD",
|
|
65
|
+
redirect: "follow",
|
|
66
|
+
signal: AbortSignal.timeout(DEAD_URL_TIMEOUT_MS),
|
|
67
|
+
});
|
|
68
|
+
return res.status >= 400 ? { dead: { ref, url, status: res.status } } : {};
|
|
28
69
|
}
|
|
29
|
-
catch {
|
|
30
|
-
|
|
70
|
+
catch (err) {
|
|
71
|
+
if (err instanceof DOMException && err.name === "TimeoutError") {
|
|
72
|
+
return { dead: { ref, url, status: "timeout" } };
|
|
73
|
+
}
|
|
74
|
+
const code = systemErrorCode(err);
|
|
75
|
+
if (code && NETWORK_ERROR_CODES.has(code)) {
|
|
76
|
+
return { skipped: true };
|
|
77
|
+
}
|
|
78
|
+
return { dead: { ref, url, status: "error" } };
|
|
31
79
|
}
|
|
32
|
-
})
|
|
33
|
-
|
|
80
|
+
}, URL_CHECK_CONCURRENCY);
|
|
81
|
+
const deadUrls = [];
|
|
82
|
+
let skipped = 0;
|
|
83
|
+
for (const outcome of outcomes) {
|
|
84
|
+
if (outcome?.dead)
|
|
85
|
+
deadUrls.push(outcome.dead);
|
|
86
|
+
if (outcome?.skipped)
|
|
87
|
+
skipped += 1;
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
deadUrls,
|
|
91
|
+
coverage: { checked: urlsToCheck.length - skipped, total: urlsToCheck.length, skipped },
|
|
92
|
+
};
|
|
34
93
|
}
|
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
* GitHub-shaped `.yml` workflow sources. Validate with `akm lint --type workflows`.
|
|
9
9
|
*/
|
|
10
10
|
import { getStringArg } from "../cli/parse-args.js";
|
|
11
|
-
import { defineGroupCommand, defineJsonCommand, EXIT_CODES, output } from "../cli/shared.js";
|
|
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
14
|
import { NotFoundError, UsageError } from "../core/errors.js";
|
|
15
15
|
import { akmIndex } from "../indexer/indexer.js";
|
|
16
16
|
import { assertWorkflowMarkdownName, createWorkflowAsset, getWorkflowTemplate } from "../workflows/authoring/authoring.js";
|
|
17
17
|
import { WORKFLOW_MAX_TIMEOUT_MS } from "../workflows/ir/schema.js";
|
|
18
|
-
import { abandonWorkflowRun, getWorkflowStatus,
|
|
18
|
+
import { abandonWorkflowRun, getWorkflowStatus, listWorkflowRuns, resolveWorkflowRunTarget, resumeWorkflowRun, } from "../workflows/runtime/runs.js";
|
|
19
19
|
import { akmWorkflowPlan } from "./workflow/plan.js";
|
|
20
20
|
const workflowStatusCommand = defineJsonCommand({
|
|
21
21
|
meta: {
|
|
@@ -34,8 +34,9 @@ const workflowStatusCommand = defineJsonCommand({
|
|
|
34
34
|
async run({ args }) {
|
|
35
35
|
const target = args.target;
|
|
36
36
|
const includeUnits = args.units === true;
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
const resolvedRunId = await resolveWorkflowRunTarget(target);
|
|
38
|
+
if (resolvedRunId !== undefined) {
|
|
39
|
+
const result = await getWorkflowStatus(resolvedRunId, { includeUnits });
|
|
39
40
|
output("workflow-status", result);
|
|
40
41
|
return;
|
|
41
42
|
}
|
|
@@ -100,12 +101,12 @@ const workflowCreateCommand = defineJsonCommand({
|
|
|
100
101
|
},
|
|
101
102
|
force: {
|
|
102
103
|
type: "boolean",
|
|
103
|
-
description: "Overwrite an existing workflow
|
|
104
|
+
description: "Overwrite an existing workflow. Combined with --from, replaces its content; alone, replaces it with a fresh template.",
|
|
104
105
|
default: false,
|
|
105
106
|
},
|
|
106
107
|
reset: {
|
|
107
108
|
type: "boolean",
|
|
108
|
-
description: "
|
|
109
|
+
description: "Deprecated alias for --force with no --from (replaces an existing workflow with a fresh template).",
|
|
109
110
|
default: false,
|
|
110
111
|
},
|
|
111
112
|
print: {
|
|
@@ -130,9 +131,6 @@ const workflowCreateCommand = defineJsonCommand({
|
|
|
130
131
|
process.stdout.write(getWorkflowTemplate());
|
|
131
132
|
return;
|
|
132
133
|
}
|
|
133
|
-
if (args.force && !args.from && !args.reset) {
|
|
134
|
-
throw new UsageError("Refusing to overwrite with template: pass --from <file> to replace content, or --reset to explicitly replace with a fresh template.");
|
|
135
|
-
}
|
|
136
134
|
const result = createWorkflowAsset({
|
|
137
135
|
name: effectiveName,
|
|
138
136
|
from: args.from,
|
|
@@ -157,6 +155,12 @@ const workflowRunCommand = defineJsonCommand({
|
|
|
157
155
|
"max-steps": { type: "string", description: "Stop after executing this many steps" },
|
|
158
156
|
"max-retries": { type: "string", description: "Retry a failed workflow step this many additional times" },
|
|
159
157
|
timeout: { type: "string", description: "Whole-run timeout: N, Nms, Ns, or Nm (bare N is milliseconds)" },
|
|
158
|
+
new: {
|
|
159
|
+
type: "boolean",
|
|
160
|
+
description: "Start a fresh run even if one is already active for this ref, leaving the existing run untouched " +
|
|
161
|
+
"(never abandons it). A workflow ref only — passing a run id with --new is a usage error.",
|
|
162
|
+
default: false,
|
|
163
|
+
},
|
|
160
164
|
},
|
|
161
165
|
async run({ args, rawArgs }) {
|
|
162
166
|
const { runWorkflowSteps } = await import("../workflows/exec/run-workflow.js");
|
|
@@ -186,6 +190,7 @@ const workflowRunCommand = defineJsonCommand({
|
|
|
186
190
|
parameterFlags,
|
|
187
191
|
...(maxSteps !== undefined ? { maxSteps } : {}),
|
|
188
192
|
...(maxRetries !== undefined ? { maxRetries } : {}),
|
|
193
|
+
newRun: args.new,
|
|
189
194
|
signal: controller.signal,
|
|
190
195
|
});
|
|
191
196
|
// The abort is observed between steps, so a deadline landing in the run's
|
|
@@ -193,14 +198,11 @@ const workflowRunCommand = defineJsonCommand({
|
|
|
193
198
|
// timed out would send an operator to resume a run with nothing left to
|
|
194
199
|
// resume — `tasks/runner.ts` suppresses the same case.
|
|
195
200
|
const timedOut = deadline.timedOut() && result.run.status !== "completed";
|
|
196
|
-
const rendered = { ...result, ...(timedOut ? { timedOut: true } : {}) };
|
|
197
|
-
output("workflow-run", rendered);
|
|
198
201
|
// `blocked` is a stopped, unverified run — a verification-judge failure
|
|
199
202
|
// leaves it there for `akm workflow resume` — so it must not exit 0 and
|
|
200
203
|
// read as success to a script (it maps to 1 for scheduled tasks too).
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
204
|
+
const failed = result.run.status === "failed" || result.run.status === "blocked" || result.gateRejection || result.aborted;
|
|
205
|
+
outputWithExitCode("workflow-run", { ...result, ...(timedOut ? { timedOut: true } : {}) }, failed ? (signalExitCode ?? EXIT_CODES.GENERAL) : undefined);
|
|
204
206
|
}
|
|
205
207
|
finally {
|
|
206
208
|
deadline.disarm();
|
|
@@ -220,7 +222,7 @@ const WORKFLOW_RUN_VALUE_FLAGS = new Set([
|
|
|
220
222
|
"shape",
|
|
221
223
|
"output",
|
|
222
224
|
]);
|
|
223
|
-
const WORKFLOW_RUN_BOOLEAN_FLAGS = new Set(["quiet", "verbose", "help", "no-quiet", "no-verbose"]);
|
|
225
|
+
const WORKFLOW_RUN_BOOLEAN_FLAGS = new Set(["quiet", "verbose", "help", "no-quiet", "no-verbose", "new", "no-new"]);
|
|
224
226
|
export function parseWorkflowParameterFlags(rawArgs, target) {
|
|
225
227
|
const flags = [];
|
|
226
228
|
let targetSeen = false;
|
|
@@ -1,20 +1,48 @@
|
|
|
1
1
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
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
|
+
/**
|
|
5
|
+
* `EDITOR`/`VISUAL`/`PAGER` are flagged by `isDangerousEnvKey` for a
|
|
6
|
+
* documented RCE vector (many tools invoke them to launch an editor/pager),
|
|
7
|
+
* but env-key-rules.ts's own module doc calls out their "high FP rate" in
|
|
8
|
+
* the same breath — an installed bundle's env file can supply a value, but
|
|
9
|
+
* nothing in akm's own env-injection path ever *invokes* EDITOR/VISUAL/PAGER
|
|
10
|
+
* with that value, so the RCE vector these three describe cannot fire from
|
|
11
|
+
* an injected env the way LD_PRELOAD or GIT_SSH_COMMAND can. Blocking a
|
|
12
|
+
* third-party install over them protects nothing while making "the operator
|
|
13
|
+
* legitimately wants to set their editor" the common case that eats the
|
|
14
|
+
* refusal. Kept as a name-level literal set (not an import of
|
|
15
|
+
* `commands/lint/env-key-rules.ts`) so this module stays the pure leaf its
|
|
16
|
+
* own doc comment promises — no new import edges into the rest of the tree.
|
|
17
|
+
*/
|
|
18
|
+
const INTERACTIVE_TOOL_ENV_KEYS = new Set(["EDITOR", "VISUAL", "PAGER"]);
|
|
4
19
|
/**
|
|
5
20
|
* Decide whether injecting an env with the given dangerous keys is allowed,
|
|
6
|
-
* warned, or blocked. Third-party (registry-installed) stashes hard-block
|
|
7
|
-
* first-party stashes warn.
|
|
21
|
+
* warned, or blocked. Third-party (registry-installed) stashes hard-block a
|
|
22
|
+
* genuine RCE-class key; first-party stashes warn. The interactive-tool
|
|
23
|
+
* group (see {@link INTERACTIVE_TOOL_ENV_KEYS}) only ever warns, since akm's
|
|
24
|
+
* own env-injection path never invokes those keys as a command. An explicit
|
|
25
|
+
* `--allow-insecure` (threaded through by the caller, same override
|
|
26
|
+
* `decideDangerousKeyInstall`'s `"warn-allow"` already honors for rule 2)
|
|
27
|
+
* downgrades a remaining block to a warning too — the operator is not racing
|
|
28
|
+
* themselves. See rule 1 above.
|
|
8
29
|
*
|
|
9
30
|
* @param dangerousKeys The subset of injected keys flagged as process-hijacking
|
|
10
31
|
* (already filtered by the caller via `isDangerousEnvKey`).
|
|
11
32
|
* @param thirdParty `true` when the env's source is a third-party stash — i.e.
|
|
12
33
|
* its origin carries a `registryId`.
|
|
34
|
+
* @param allowInsecure `true` when the operator passed `--allow-insecure` (or
|
|
35
|
+
* its equivalent) for this injection. Defaults to `false`.
|
|
13
36
|
*/
|
|
14
37
|
export function decideDangerousEnvInjection(input) {
|
|
15
38
|
if (input.dangerousKeys.length === 0)
|
|
16
39
|
return "allow";
|
|
17
|
-
|
|
40
|
+
if (!input.thirdParty)
|
|
41
|
+
return "warn";
|
|
42
|
+
if (input.allowInsecure)
|
|
43
|
+
return "warn";
|
|
44
|
+
const onlyInteractiveTool = input.dangerousKeys.every((key) => INTERACTIVE_TOOL_ENV_KEYS.has(key));
|
|
45
|
+
return onlyInteractiveTool ? "warn" : "block";
|
|
18
46
|
}
|
|
19
47
|
/**
|
|
20
48
|
* Decide the baseline install stance for a freshly-installed stash's
|
|
@@ -6,6 +6,8 @@ import { isMap, isScalar, parseDocument, visit } from "yaml";
|
|
|
6
6
|
import { cloneExecutionJson, cloneExecutionJsonObject, } from "../../execution/json.js";
|
|
7
7
|
import { assertSnapshotKeys, snapshotStrictRecord } from "../../execution/record.js";
|
|
8
8
|
import { cloneToolSelection, createAdapterRenderedExecutionSource, } from "../../execution/source.js";
|
|
9
|
+
import { UsageError } from "../errors.js";
|
|
10
|
+
import { warnOnce } from "../warn.js";
|
|
9
11
|
function nextLine(text, start) {
|
|
10
12
|
const lf = text.indexOf("\n", start);
|
|
11
13
|
if (lf < 0)
|
|
@@ -13,16 +15,39 @@ function nextLine(text, start) {
|
|
|
13
15
|
const end = lf > start && text[lf - 1] === "\r" ? lf - 1 : lf;
|
|
14
16
|
return { line: text.slice(start, end), next: lf + 1 };
|
|
15
17
|
}
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Strict execution-only frontmatter parser; indexing's tolerant parser is
|
|
20
|
+
* deliberately not reused.
|
|
21
|
+
*
|
|
22
|
+
* `filePath`, when given, names the offending asset in a thrown error so an
|
|
23
|
+
* operator (or CI) sees which file to fix instead of a bare message.
|
|
24
|
+
*
|
|
25
|
+
* A third party's markdown that genuinely cannot be parsed into a mapping
|
|
26
|
+
* (unterminated frontmatter, broken YAML syntax, a non-mapping document)
|
|
27
|
+
* throws {@link UsageError} \u2014 exit 2, an actionable usage problem, not exit
|
|
28
|
+
* 70 (`TypeError`'s "unclassified internal error" code, which is what a
|
|
29
|
+
* `throw new TypeError(...)` here used to surface as). Anchors, explicit
|
|
30
|
+
* tags, and non-string mapping keys are recognized-but-unsupported
|
|
31
|
+
* constructs rather than parse failures on their own \u2014 an anchor nobody
|
|
32
|
+
* aliases, or a tag `toJS` converts to a plain value, is harmless \u2014 so they
|
|
33
|
+
* warn once (naming the file) and fall through to conversion instead of
|
|
34
|
+
* being pre-emptively rejected by a second, stricter gate in front of it.
|
|
35
|
+
* `toJS({ maxAliasCount: 0 })` is the real, UNCHANGED safety bound against
|
|
36
|
+
* alias-expansion abuse \u2014 it disables alias RESOLUTION outright, so a value
|
|
37
|
+
* that actually references an anchor via `*name` still fails, now as a
|
|
38
|
+
* {@link UsageError} from this same conversion step rather than the removed
|
|
39
|
+
* duplicate pre-check.
|
|
40
|
+
*/
|
|
41
|
+
export function parseExecutionMarkdown(raw, filePath) {
|
|
18
42
|
if (typeof raw !== "string")
|
|
19
43
|
throw new TypeError("execution source raw content must be a string");
|
|
44
|
+
const where = filePath ? ` (${filePath})` : "";
|
|
20
45
|
const withoutBom = raw.startsWith("\uFEFF") ? raw.slice(1) : raw;
|
|
21
46
|
const opening = nextLine(withoutBom, 0);
|
|
22
47
|
if (opening.line !== "---")
|
|
23
48
|
return { content: withoutBom, data: Object.freeze({}) };
|
|
24
49
|
if (opening.next === withoutBom.length) {
|
|
25
|
-
throw new
|
|
50
|
+
throw new UsageError(`execution source${where} has unterminated frontmatter`);
|
|
26
51
|
}
|
|
27
52
|
let cursor = opening.next;
|
|
28
53
|
let frontmatterEnd = -1;
|
|
@@ -40,18 +65,18 @@ export function parseExecutionMarkdown(raw) {
|
|
|
40
65
|
cursor = current.next;
|
|
41
66
|
}
|
|
42
67
|
if (frontmatterEnd < 0 || bodyStart < 0) {
|
|
43
|
-
throw new
|
|
68
|
+
throw new UsageError(`execution source${where} has unterminated frontmatter`);
|
|
44
69
|
}
|
|
45
70
|
const yaml = withoutBom.slice(opening.next, frontmatterEnd);
|
|
46
71
|
const document = parseDocument(yaml, { uniqueKeys: true });
|
|
47
72
|
if (document.errors.length > 0) {
|
|
48
|
-
throw new
|
|
73
|
+
throw new UsageError(`execution source${where} has invalid YAML frontmatter: ${document.errors[0]?.message ?? "parse error"}`);
|
|
49
74
|
}
|
|
50
75
|
if (document.warnings.length > 0) {
|
|
51
|
-
|
|
76
|
+
warnOnce(`execution-source:yaml-warning:${filePath ?? "<inline>"}`, `execution source${where} YAML frontmatter uses an unsupported tag or construct: ${document.warnings[0]?.message}. Using it as parsed.`);
|
|
52
77
|
}
|
|
53
78
|
if (!isMap(document.contents)) {
|
|
54
|
-
throw new
|
|
79
|
+
throw new UsageError(`execution source${where} YAML frontmatter must be a mapping`);
|
|
55
80
|
}
|
|
56
81
|
let unsupported;
|
|
57
82
|
visit(document, {
|
|
@@ -69,14 +94,16 @@ export function parseExecutionMarkdown(raw) {
|
|
|
69
94
|
unsupported ??= "non-string mapping keys";
|
|
70
95
|
},
|
|
71
96
|
});
|
|
72
|
-
if (unsupported)
|
|
73
|
-
|
|
97
|
+
if (unsupported) {
|
|
98
|
+
warnOnce(`execution-source:unsupported:${filePath ?? "<inline>"}`, `execution source${where} YAML frontmatter uses ${unsupported}, which akm does not fully support; using the bounded conversion's result as-is.`);
|
|
99
|
+
}
|
|
74
100
|
let root;
|
|
75
101
|
try {
|
|
76
102
|
root = document.toJS({ maxAliasCount: 0 });
|
|
77
103
|
}
|
|
78
104
|
catch (cause) {
|
|
79
|
-
|
|
105
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
106
|
+
throw new UsageError(`execution source${where} YAML frontmatter could not be converted safely: ${detail}`);
|
|
80
107
|
}
|
|
81
108
|
const data = cloneExecutionJsonObject(root, "execution source YAML frontmatter");
|
|
82
109
|
return { content: withoutBom.slice(bodyStart), data: Object.freeze(data) };
|
|
@@ -279,7 +306,8 @@ export function renderMarkdownExecutionSource(input) {
|
|
|
279
306
|
const kind = snapshots.input.kind;
|
|
280
307
|
if (kind !== "command" && kind !== "persona")
|
|
281
308
|
throw new TypeError("adapter execution source.kind is invalid");
|
|
282
|
-
const
|
|
309
|
+
const identityFile = snapshots.identity.file;
|
|
310
|
+
const parsed = parseExecutionMarkdown(raw, typeof identityFile === "string" ? identityFile : undefined);
|
|
283
311
|
const hasDefaults = Object.hasOwn(snapshots.input, "defaults");
|
|
284
312
|
const defaultsProjection = snapshots.input.defaults;
|
|
285
313
|
const defaults = typeof defaultsProjection === "function" ? defaultsProjection(parsed.data) : defaultsProjection;
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import fs from "node:fs";
|
|
20
20
|
import path from "node:path";
|
|
21
|
+
import { isWithin } from "../common.js";
|
|
21
22
|
import { UsageError } from "../errors.js";
|
|
22
23
|
/** Root-relative directory holding a stash's meta docs. */
|
|
23
24
|
export const META_DIR = ".meta";
|
|
@@ -113,7 +114,7 @@ export function readMetaFile(sourceRoot, name) {
|
|
|
113
114
|
return null;
|
|
114
115
|
let fd;
|
|
115
116
|
try {
|
|
116
|
-
fd = fs.openSync(filePath, fs.constants.O_RDONLY
|
|
117
|
+
fd = fs.openSync(filePath, fs.constants.O_RDONLY);
|
|
117
118
|
const opened = fs.fstatSync(fd);
|
|
118
119
|
if (!opened.isFile())
|
|
119
120
|
throw metaPathEscape();
|
|
@@ -141,60 +142,25 @@ export function readMetaFile(sourceRoot, name) {
|
|
|
141
142
|
}
|
|
142
143
|
}
|
|
143
144
|
function isSafeRegularMetaFile(sourceRoot, metaRoot, filePath) {
|
|
144
|
-
const sourceRootReal = realPathOrNull(sourceRoot);
|
|
145
|
-
if (!sourceRootReal)
|
|
146
|
-
return false;
|
|
147
|
-
const metaStat = lstatOrNull(metaRoot);
|
|
148
|
-
if (!metaStat)
|
|
149
|
-
return false;
|
|
150
|
-
if (metaStat.isSymbolicLink())
|
|
151
|
-
throw metaPathEscape();
|
|
152
|
-
if (!metaStat.isDirectory())
|
|
153
|
-
return false;
|
|
154
145
|
const relative = path.relative(metaRoot, filePath);
|
|
155
146
|
if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
156
147
|
throw metaPathEscape();
|
|
157
148
|
}
|
|
158
|
-
|
|
159
|
-
for (const segment of relative.split(path.sep)) {
|
|
160
|
-
current = path.join(current, segment);
|
|
161
|
-
const stat = lstatOrNull(current);
|
|
162
|
-
if (!stat)
|
|
163
|
-
return false;
|
|
164
|
-
if (stat.isSymbolicLink())
|
|
165
|
-
throw metaPathEscape();
|
|
166
|
-
}
|
|
167
|
-
const finalStat = lstatOrNull(filePath);
|
|
149
|
+
const finalStat = statOrNull(filePath);
|
|
168
150
|
if (!finalStat?.isFile())
|
|
169
151
|
return false;
|
|
170
|
-
|
|
171
|
-
const fileReal = realPathOrNull(filePath);
|
|
172
|
-
if (!metaRootReal || !fileReal)
|
|
173
|
-
return false;
|
|
174
|
-
if (!isWithin(sourceRootReal, metaRootReal) || !isWithin(metaRootReal, fileReal))
|
|
152
|
+
if (!isWithin(filePath, sourceRoot))
|
|
175
153
|
throw metaPathEscape();
|
|
176
154
|
return true;
|
|
177
155
|
}
|
|
178
|
-
function
|
|
156
|
+
function statOrNull(filePath) {
|
|
179
157
|
try {
|
|
180
|
-
return fs.
|
|
158
|
+
return fs.statSync(filePath);
|
|
181
159
|
}
|
|
182
160
|
catch {
|
|
183
161
|
return null;
|
|
184
162
|
}
|
|
185
163
|
}
|
|
186
|
-
function realPathOrNull(filePath) {
|
|
187
|
-
try {
|
|
188
|
-
return fs.realpathSync(filePath);
|
|
189
|
-
}
|
|
190
|
-
catch {
|
|
191
|
-
return null;
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
function isWithin(root, candidate) {
|
|
195
|
-
const relative = path.relative(root, candidate);
|
|
196
|
-
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
197
|
-
}
|
|
198
164
|
function metaPathEscape() {
|
|
199
|
-
return new UsageError("Meta doc must
|
|
165
|
+
return new UsageError("Meta doc must resolve to a regular file inside the stash .meta directory, not one that escapes it.", "PATH_ESCAPE_VIOLATION");
|
|
200
166
|
}
|
package/dist/core/bundle-id.js
CHANGED
|
@@ -46,6 +46,12 @@ function ensureUniqueId(preferred, sourcePath, used) {
|
|
|
46
46
|
n++;
|
|
47
47
|
return `${suffixed}-${n}`;
|
|
48
48
|
}
|
|
49
|
-
|
|
49
|
+
/**
|
|
50
|
+
* First 8 hex chars of `input`'s sha256 — deterministic and short enough to
|
|
51
|
+
* suffix a slug or a path key. Exported so callers needing the same
|
|
52
|
+
* short-hash-of-a-resolved-path primitive (e.g. `getStashStateKey` in
|
|
53
|
+
* `paths.ts`) don't grow their own duplicate.
|
|
54
|
+
*/
|
|
55
|
+
export function shortHash(input) {
|
|
50
56
|
return crypto.createHash("sha256").update(input).digest("hex").slice(0, 8);
|
|
51
57
|
}
|