akm-cli 0.7.4 → 0.8.0-rc.10
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 +224 -1
- package/README.md +22 -6
- package/SECURITY.md +93 -0
- package/dist/cli/config-migrate.js +144 -0
- package/dist/cli/config-validate.js +39 -0
- package/dist/cli/confirm.js +73 -0
- package/dist/cli/parse-args.js +133 -0
- package/dist/cli/shared.js +129 -0
- package/dist/cli.js +2631 -1440
- package/dist/commands/add-cli.js +279 -0
- package/dist/commands/agent-dispatch.js +110 -0
- package/dist/commands/agent-support.js +68 -0
- package/dist/commands/completions.js +3 -0
- package/dist/commands/config-cli.js +130 -534
- package/dist/commands/consolidate.js +2122 -0
- package/dist/commands/curate.js +45 -3
- package/dist/commands/db-cli.js +23 -0
- package/dist/commands/distill-promotion-policy.js +660 -0
- package/dist/commands/distill.js +1081 -73
- package/dist/commands/env.js +213 -0
- package/dist/commands/eval-cases.js +43 -0
- package/dist/commands/events.js +15 -24
- package/dist/commands/extract-cli.js +127 -0
- package/dist/commands/extract-prompt.js +204 -0
- package/dist/commands/extract.js +477 -0
- package/dist/commands/feedback-cli.js +331 -0
- package/dist/commands/graph.js +477 -0
- package/dist/commands/health.js +1302 -0
- package/dist/commands/help/help-accept.md +12 -0
- package/dist/commands/help/help-improve.md +69 -0
- package/dist/commands/help/help-proposals.md +18 -0
- package/dist/commands/help/help-propose.md +17 -0
- package/dist/commands/help/help-reject.md +11 -0
- package/dist/commands/history.js +54 -46
- package/dist/commands/improve-auto-accept.js +97 -0
- package/dist/commands/improve-cli.js +217 -0
- package/dist/commands/improve-profiles.js +166 -0
- package/dist/commands/improve-result-file.js +167 -0
- package/dist/commands/improve.js +2373 -0
- package/dist/commands/info.js +5 -2
- package/dist/commands/init.js +50 -2
- package/dist/commands/installed-stashes.js +102 -139
- package/dist/commands/knowledge.js +136 -0
- package/dist/commands/lint/agent-linter.js +49 -0
- package/dist/commands/lint/base-linter.js +479 -0
- package/dist/commands/lint/command-linter.js +49 -0
- package/dist/commands/lint/default-linter.js +16 -0
- package/dist/commands/lint/env-key-rules.js +154 -0
- package/dist/commands/lint/index.js +196 -0
- package/dist/commands/lint/knowledge-linter.js +16 -0
- package/dist/commands/lint/markdown-insertion.js +343 -0
- package/dist/commands/lint/memory-linter.js +61 -0
- package/dist/commands/lint/registry.js +36 -0
- package/dist/commands/lint/skill-linter.js +45 -0
- package/dist/commands/lint/task-linter.js +50 -0
- package/dist/commands/lint/types.js +4 -0
- package/dist/commands/lint/workflow-linter.js +56 -0
- package/dist/commands/lint.js +4 -0
- package/dist/commands/migration-help.js +3 -0
- package/dist/commands/proposal.js +67 -12
- package/dist/commands/propose.js +120 -45
- package/dist/commands/reflect.js +1104 -60
- package/dist/commands/registry-cli.js +150 -0
- package/dist/commands/registry-search.js +5 -2
- package/dist/commands/remember-cli.js +257 -0
- package/dist/commands/remember.js +70 -7
- package/dist/commands/schema-repair.js +203 -0
- package/dist/commands/search.js +115 -14
- package/dist/commands/secret.js +173 -0
- package/dist/commands/self-update.js +3 -0
- package/dist/commands/show.js +158 -60
- package/dist/commands/source-add.js +17 -45
- package/dist/commands/source-clone.js +3 -0
- package/dist/commands/source-manage.js +14 -19
- package/dist/commands/tasks.js +437 -0
- package/dist/commands/url-checker.js +42 -0
- package/dist/core/action-contributors.js +28 -0
- package/dist/core/asset-ref.js +17 -2
- package/dist/core/asset-registry.js +12 -17
- package/dist/core/asset-serialize.js +88 -0
- package/dist/core/asset-spec.js +67 -1
- package/dist/core/common.js +182 -0
- package/dist/core/concurrent.js +25 -0
- package/dist/core/config-io.js +347 -0
- package/dist/core/config-migration.js +622 -0
- package/dist/core/config-schema.js +534 -0
- package/dist/core/config-sources.js +108 -0
- package/dist/core/config-types.js +4 -0
- package/dist/core/config-walker.js +337 -0
- package/dist/core/config.js +364 -968
- package/dist/core/errors.js +42 -20
- package/dist/core/events.js +105 -135
- package/dist/core/file-lock.js +104 -0
- package/dist/core/frontmatter.js +75 -8
- package/dist/core/lesson-lint.js +3 -0
- package/dist/core/markdown.js +20 -0
- package/dist/core/memory-belief.js +62 -0
- package/dist/core/memory-contradiction-detect.js +274 -0
- package/dist/core/memory-improve.js +806 -0
- package/dist/core/parse.js +158 -0
- package/dist/core/paths.js +280 -14
- package/dist/core/proposal-quality-validators.js +380 -0
- package/dist/core/proposal-validators.js +69 -0
- package/dist/core/proposals.js +512 -42
- package/dist/core/state-db.js +1068 -0
- package/dist/core/text-truncation.js +107 -0
- package/dist/core/time.js +54 -0
- package/dist/core/tty.js +59 -0
- package/dist/core/warn.js +64 -1
- package/dist/core/write-source.js +3 -0
- package/dist/indexer/db-backup.js +391 -0
- package/dist/indexer/db-search.js +198 -489
- package/dist/indexer/db.js +990 -108
- package/dist/indexer/ensure-index.js +136 -0
- package/dist/indexer/file-context.js +3 -0
- package/dist/indexer/graph-boost.js +376 -101
- package/dist/indexer/graph-db.js +391 -0
- package/dist/indexer/graph-dedup.js +95 -0
- package/dist/indexer/graph-extraction.js +550 -114
- package/dist/indexer/index-context.js +4 -0
- package/dist/indexer/indexer.js +547 -309
- package/dist/indexer/llm-cache.js +52 -0
- package/dist/indexer/manifest.js +3 -0
- package/dist/indexer/matchers.js +167 -160
- package/dist/indexer/memory-inference.js +152 -74
- package/dist/indexer/metadata-contributors.js +29 -0
- package/dist/indexer/metadata.js +275 -196
- package/dist/indexer/path-resolver.js +92 -0
- package/dist/indexer/project-context.js +192 -0
- package/dist/indexer/ranking-contributors.js +331 -0
- package/dist/indexer/ranking.js +81 -0
- package/dist/indexer/search-fields.js +5 -9
- package/dist/indexer/search-hit-enrichers.js +111 -0
- package/dist/indexer/search-source.js +44 -10
- package/dist/indexer/semantic-status.js +6 -17
- package/dist/indexer/staleness-detect.js +447 -0
- package/dist/indexer/usage-events.js +12 -9
- package/dist/indexer/walker.js +28 -0
- package/dist/integrations/agent/builders.js +135 -0
- package/dist/integrations/agent/config.js +122 -230
- package/dist/integrations/agent/detect.js +3 -0
- package/dist/integrations/agent/index.js +7 -13
- package/dist/integrations/agent/model-aliases.js +55 -0
- package/dist/integrations/agent/profiles.js +70 -5
- package/dist/integrations/agent/prompts.js +250 -36
- package/dist/integrations/agent/runner.js +151 -0
- package/dist/integrations/agent/sdk-runner.js +126 -0
- package/dist/integrations/agent/spawn.js +183 -35
- package/dist/integrations/github.js +3 -0
- package/dist/integrations/lockfile.js +32 -69
- package/dist/integrations/session-logs/index.js +69 -0
- package/dist/integrations/session-logs/inline-refs.js +35 -0
- package/dist/integrations/session-logs/pre-filter.js +152 -0
- package/dist/integrations/session-logs/providers/claude-code.js +282 -0
- package/dist/integrations/session-logs/providers/opencode.js +258 -0
- package/dist/integrations/session-logs/types.js +4 -0
- package/dist/llm/call-ai.js +62 -0
- package/dist/llm/client.js +79 -88
- package/dist/llm/embedder.js +20 -29
- package/dist/llm/embedders/cache.js +3 -7
- package/dist/llm/embedders/local.js +42 -1
- package/dist/llm/embedders/remote.js +20 -8
- package/dist/llm/embedders/types.js +3 -7
- package/dist/llm/feature-gate.js +95 -48
- package/dist/llm/graph-extract.js +676 -72
- package/dist/llm/index-passes.js +44 -29
- package/dist/llm/memory-infer.js +80 -71
- package/dist/llm/metadata-enhance.js +42 -29
- package/dist/llm/prompts/extract-session.md +80 -0
- package/dist/llm/prompts/graph-extract-user-prompt.md +35 -0
- package/dist/output/cli-hints-full.md +292 -0
- package/dist/output/cli-hints-short.md +66 -0
- package/dist/output/cli-hints.js +7 -311
- package/dist/output/context.js +60 -8
- package/dist/output/renderers.js +306 -258
- package/dist/output/shapes/curate.js +56 -0
- package/dist/output/shapes/distill.js +10 -0
- package/dist/output/shapes/env-list.js +19 -0
- package/dist/output/shapes/events.js +11 -0
- package/dist/output/shapes/helpers.js +424 -0
- package/dist/output/shapes/history.js +7 -0
- package/dist/output/shapes/passthrough.js +102 -0
- package/dist/output/shapes/proposal-accept.js +7 -0
- package/dist/output/shapes/proposal-diff.js +7 -0
- package/dist/output/shapes/proposal-list.js +7 -0
- package/dist/output/shapes/proposal-producer.js +11 -0
- package/dist/output/shapes/proposal-reject.js +7 -0
- package/dist/output/shapes/proposal-show.js +7 -0
- package/dist/output/shapes/registry-search.js +6 -0
- package/dist/output/shapes/registry.js +30 -0
- package/dist/output/shapes/search.js +6 -0
- package/dist/output/shapes/secret-list.js +19 -0
- package/dist/output/shapes/show.js +6 -0
- package/dist/output/shapes/vault-list.js +19 -0
- package/dist/output/shapes.js +51 -511
- package/dist/output/text/add.js +6 -0
- package/dist/output/text/clone.js +6 -0
- package/dist/output/text/config.js +6 -0
- package/dist/output/text/curate.js +6 -0
- package/dist/output/text/distill.js +7 -0
- package/dist/output/text/enable-disable.js +7 -0
- package/dist/output/text/events.js +10 -0
- package/dist/output/text/feedback.js +6 -0
- package/dist/output/text/helpers.js +1039 -0
- package/dist/output/text/history.js +7 -0
- package/dist/output/text/import.js +6 -0
- package/dist/output/text/index.js +6 -0
- package/dist/output/text/info.js +6 -0
- package/dist/output/text/init.js +6 -0
- package/dist/output/text/list.js +6 -0
- package/dist/output/text/proposal-producer.js +8 -0
- package/dist/output/text/proposal.js +11 -0
- package/dist/output/text/registry-commands.js +11 -0
- package/dist/output/text/registry.js +30 -0
- package/dist/output/text/remember.js +6 -0
- package/dist/output/text/remove.js +6 -0
- package/dist/output/text/save.js +6 -0
- package/dist/output/text/search.js +6 -0
- package/dist/output/text/show.js +6 -0
- package/dist/output/text/update.js +6 -0
- package/dist/output/text/upgrade.js +6 -0
- package/dist/output/text/vault.js +16 -0
- package/dist/output/text/wiki.js +15 -0
- package/dist/output/text/workflow.js +14 -0
- package/dist/output/text.js +44 -1093
- package/dist/registry/build-index.js +3 -0
- package/dist/registry/create-provider-registry.js +3 -0
- package/dist/registry/factory.js +4 -1
- package/dist/registry/origin-resolve.js +3 -0
- package/dist/registry/providers/index.js +3 -0
- package/dist/registry/providers/skills-sh.js +71 -50
- package/dist/registry/providers/static-index.js +53 -48
- package/dist/registry/providers/types.js +3 -24
- package/dist/registry/resolve.js +11 -16
- package/dist/registry/types.js +3 -0
- package/dist/scripts/migrate-storage.js +17750 -0
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +9031 -0
- package/dist/scripts/migrations/v16-to-v17.js +141 -0
- package/dist/setup/detect.js +3 -0
- package/dist/setup/ripgrep-install.js +3 -0
- package/dist/setup/ripgrep-resolve.js +3 -0
- package/dist/setup/setup.js +775 -37
- package/dist/setup/steps.js +3 -15
- package/dist/sources/include.js +3 -0
- package/dist/sources/provider-factory.js +5 -12
- package/dist/sources/provider.js +3 -20
- package/dist/sources/providers/filesystem.js +19 -23
- package/dist/sources/providers/git.js +179 -20
- package/dist/sources/providers/index.js +3 -0
- package/dist/sources/providers/install-types.js +3 -13
- package/dist/sources/providers/npm.js +3 -4
- package/dist/sources/providers/provider-utils.js +3 -0
- package/dist/sources/providers/sync-from-ref.js +3 -11
- package/dist/sources/providers/tar-utils.js +3 -0
- package/dist/sources/providers/website.js +18 -22
- package/dist/sources/resolve.js +3 -0
- package/dist/sources/types.js +3 -0
- package/dist/sources/website-ingest.js +7 -0
- package/dist/tasks/backends/cron.js +203 -0
- package/dist/tasks/backends/exec-utils.js +28 -0
- package/dist/tasks/backends/index.js +24 -0
- package/dist/tasks/backends/launchd-template.xml +19 -0
- package/dist/tasks/backends/launchd.js +187 -0
- package/dist/tasks/backends/schtasks-template.xml +29 -0
- package/dist/tasks/backends/schtasks.js +215 -0
- package/dist/tasks/parser.js +211 -0
- package/dist/tasks/resolveAkmBin.js +87 -0
- package/dist/tasks/runner.js +458 -0
- package/dist/tasks/schedule.js +227 -0
- package/dist/tasks/schema.js +15 -0
- package/dist/tasks/validator.js +62 -0
- package/dist/version.js +3 -0
- package/dist/wiki/index-template.md +12 -0
- package/dist/wiki/ingest-workflow-template.md +54 -0
- package/dist/wiki/log-template.md +8 -0
- package/dist/wiki/schema-template.md +61 -0
- package/dist/wiki/wiki-templates.js +15 -0
- package/dist/wiki/wiki.js +13 -61
- package/dist/workflows/authoring.js +8 -25
- package/dist/workflows/cli.js +3 -0
- package/dist/workflows/db.js +141 -2
- package/dist/workflows/document-cache.js +3 -10
- package/dist/workflows/parser.js +3 -0
- package/dist/workflows/renderer.js +11 -3
- package/dist/workflows/runs.js +91 -89
- package/dist/workflows/schema.js +3 -0
- package/dist/workflows/scope-key.js +79 -0
- package/dist/workflows/validator.js +4 -8
- package/dist/workflows/workflow-template.md +24 -0
- package/docs/README.md +10 -2
- package/docs/data-and-telemetry.md +225 -0
- package/docs/migration/release-notes/0.7.0.md +1 -1
- package/docs/migration/release-notes/0.7.4.md +1 -1
- package/docs/migration/release-notes/0.7.5.md +20 -0
- package/docs/migration/release-notes/0.8.0.md +48 -0
- package/docs/migration/v0.7-to-v0.8.md +1307 -0
- package/package.json +29 -11
- package/dist/commands/install-audit.js +0 -381
- package/dist/commands/vault.js +0 -333
- package/dist/templates/wiki-templates.js +0 -100
|
@@ -0,0 +1,203 @@
|
|
|
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
|
+
* Schema-repair pass for `akm improve`.
|
|
6
|
+
*
|
|
7
|
+
* Attempts to patch missing frontmatter fields (`description`, `when_to_use`)
|
|
8
|
+
* on assets that failed schema validation, using a single bounded in-tree LLM
|
|
9
|
+
* call per asset. Results are recorded as `schema_repair_invoked` events.
|
|
10
|
+
*
|
|
11
|
+
* This module is extracted from `improve.ts` to make the repair logic
|
|
12
|
+
* independently testable and to use the `tryLlmFeature` seam rather than raw
|
|
13
|
+
* `chatCompletion`.
|
|
14
|
+
*/
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { parseAssetRef } from "../core/asset-ref";
|
|
18
|
+
import { assembleAsset } from "../core/asset-serialize";
|
|
19
|
+
import { appendEvent, readEvents } from "../core/events";
|
|
20
|
+
import { parseFrontmatter } from "../core/frontmatter";
|
|
21
|
+
import { createProposal, isProposalSkipped } from "../core/proposals";
|
|
22
|
+
import { info, warn } from "../core/warn";
|
|
23
|
+
import { resolveAssetPath } from "../indexer/path-resolver";
|
|
24
|
+
import { chatCompletion, parseEmbeddedJsonResponse } from "../llm/client";
|
|
25
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
26
|
+
/** Minimum gap between schema-repair attempts on the same asset. */
|
|
27
|
+
const SCHEMA_REPAIR_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
28
|
+
/**
|
|
29
|
+
* Per-ref attempt cap (O-6 / #379): maximum number of schema-repair attempts
|
|
30
|
+
* allowed within SCHEMA_REPAIR_WINDOW_MS. Prevents indefinite nightly re-repair
|
|
31
|
+
* of assets whose source content is genuinely ambiguous or inconsistently
|
|
32
|
+
* structured. After cap, the asset is skipped until the window rolls over.
|
|
33
|
+
* Self-Refine arXiv:2303.17651 — iteration must be bounded.
|
|
34
|
+
*/
|
|
35
|
+
const SCHEMA_REPAIR_MAX_ATTEMPTS = 3;
|
|
36
|
+
const SCHEMA_REPAIR_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
37
|
+
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
38
|
+
/**
|
|
39
|
+
* Run the schema-repair loop for a batch of validation failures.
|
|
40
|
+
* Returns a list of per-asset outcome records and the set of refs that were
|
|
41
|
+
* successfully repaired (so the caller can exclude them from skip logic).
|
|
42
|
+
*/
|
|
43
|
+
export async function runSchemaRepairPass(failures, options) {
|
|
44
|
+
const repairs = [];
|
|
45
|
+
const repairedRefs = new Set();
|
|
46
|
+
const { startMs, budgetMs, llmConfig, stashDir, findFilePath = defaultFindFilePath, isLessonCandidateFn = defaultIsLessonCandidate, chatFn = chatCompletion, } = options;
|
|
47
|
+
for (const failure of failures) {
|
|
48
|
+
if (Date.now() - startMs >= budgetMs)
|
|
49
|
+
break;
|
|
50
|
+
// Cooldown: skip repair if we ran it successfully recently.
|
|
51
|
+
const recentRepairs = readEvents({ type: "schema_repair_invoked", ref: failure.ref });
|
|
52
|
+
const lastRepair = recentRepairs.events
|
|
53
|
+
.filter((e) => e.metadata?.outcome === "written")
|
|
54
|
+
.sort((a, b) => new Date(b.ts ?? 0).getTime() - new Date(a.ts ?? 0).getTime())[0];
|
|
55
|
+
if (lastRepair?.ts && Date.now() - new Date(lastRepair.ts).getTime() < SCHEMA_REPAIR_COOLDOWN_MS) {
|
|
56
|
+
repairs.push({ ref: failure.ref, reason: failure.reason, outcome: "skipped" });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
// O-6 / #379: Cap total attempts at SCHEMA_REPAIR_MAX_ATTEMPTS per SCHEMA_REPAIR_WINDOW_MS.
|
|
60
|
+
// Prevents indefinite nightly re-repair of assets whose source is genuinely ambiguous.
|
|
61
|
+
// After the cap is reached, the asset is skipped until the window rolls over.
|
|
62
|
+
const windowStart = Date.now() - SCHEMA_REPAIR_WINDOW_MS;
|
|
63
|
+
const attemptsInWindow = recentRepairs.events.filter((e) => e.ts !== undefined && new Date(e.ts).getTime() >= windowStart).length;
|
|
64
|
+
if (attemptsInWindow >= SCHEMA_REPAIR_MAX_ATTEMPTS) {
|
|
65
|
+
repairs.push({
|
|
66
|
+
ref: failure.ref,
|
|
67
|
+
reason: failure.reason,
|
|
68
|
+
outcome: "skipped",
|
|
69
|
+
error: `schema-repair attempt cap reached (${attemptsInWindow}/${SCHEMA_REPAIR_MAX_ATTEMPTS} in 30d window)`,
|
|
70
|
+
});
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const filePath = await findFilePath(failure.ref, stashDir);
|
|
74
|
+
if (!filePath) {
|
|
75
|
+
repairs.push({ ref: failure.ref, reason: failure.reason, outcome: "skipped" });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (path.extname(filePath).toLowerCase() !== ".md") {
|
|
79
|
+
repairs.push({ ref: failure.ref, reason: failure.reason, outcome: "skipped" });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
84
|
+
const fm = parseFrontmatter(raw);
|
|
85
|
+
const missingFields = [];
|
|
86
|
+
if (!fm.data.description)
|
|
87
|
+
missingFields.push("description");
|
|
88
|
+
if (isLessonCandidateFn(failure.ref) && !fm.data.when_to_use)
|
|
89
|
+
missingFields.push("when_to_use");
|
|
90
|
+
if (missingFields.length === 0) {
|
|
91
|
+
repairs.push({ ref: failure.ref, reason: failure.reason, outcome: "skipped" });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const fieldList = missingFields.join(" and ");
|
|
95
|
+
info(`[improve] schema-repair ${failure.ref} (${fieldList})`);
|
|
96
|
+
const bodyPreview = (fm.content ?? raw).slice(0, 2000);
|
|
97
|
+
const llmResponse = await chatFn(llmConfig, [
|
|
98
|
+
{
|
|
99
|
+
role: "system",
|
|
100
|
+
content: `You generate concise asset frontmatter fields. Respond with a JSON object containing only the missing fields. No prose, no markdown fences.`,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
role: "user",
|
|
104
|
+
content: `Generate the missing frontmatter fields (${fieldList}) for this ${parseAssetRef(failure.ref).type} asset. Return ONLY valid JSON like {"description": "...", "when_to_use": "..."}\n\n${bodyPreview}`,
|
|
105
|
+
},
|
|
106
|
+
]);
|
|
107
|
+
const parsed = parseEmbeddedJsonResponse(llmResponse.trim());
|
|
108
|
+
if (!parsed) {
|
|
109
|
+
repairs.push({
|
|
110
|
+
ref: failure.ref,
|
|
111
|
+
reason: failure.reason,
|
|
112
|
+
outcome: "error",
|
|
113
|
+
error: "LLM returned unparseable JSON for schema repair",
|
|
114
|
+
});
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const newFm = { ...fm.data };
|
|
118
|
+
if (parsed.description)
|
|
119
|
+
newFm.description = parsed.description;
|
|
120
|
+
if (parsed.when_to_use)
|
|
121
|
+
newFm.when_to_use = parsed.when_to_use;
|
|
122
|
+
const newContent = assembleAsset(newFm, fm.content);
|
|
123
|
+
// M-3 / #387: Route through proposal queue instead of writing directly to
|
|
124
|
+
// disk. This restores akm's safety invariant — the proposal queue is the
|
|
125
|
+
// only path to a committed asset write. LLM-generated `description` /
|
|
126
|
+
// `when_to_use` fields can be incorrect; routing through the queue makes
|
|
127
|
+
// them human-reviewable before they affect search ranking and curate hints.
|
|
128
|
+
// mem0 open gaps (arXiv:2504.19413) — any LLM write to a memory field
|
|
129
|
+
// should be human-reviewable.
|
|
130
|
+
if (stashDir) {
|
|
131
|
+
const proposalResult = createProposal(stashDir, {
|
|
132
|
+
ref: failure.ref,
|
|
133
|
+
source: "schema-repair",
|
|
134
|
+
payload: {
|
|
135
|
+
content: newContent,
|
|
136
|
+
...(Object.keys(newFm).length > 0 ? { frontmatter: newFm } : {}),
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
if (isProposalSkipped(proposalResult)) {
|
|
140
|
+
info(`[improve] schema-repair proposal skipped for ${failure.ref}: ${proposalResult.message}`);
|
|
141
|
+
repairs.push({ ref: failure.ref, reason: failure.reason, outcome: "skipped" });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
info(`[improve] schema-repair queued: ${failure.ref} (proposal id: ${proposalResult.id})`);
|
|
145
|
+
appendEvent({
|
|
146
|
+
eventType: "schema_repair_invoked",
|
|
147
|
+
ref: failure.ref,
|
|
148
|
+
metadata: { outcome: "queued", reason: failure.reason, proposalId: proposalResult.id },
|
|
149
|
+
});
|
|
150
|
+
repairs.push({
|
|
151
|
+
ref: failure.ref,
|
|
152
|
+
reason: failure.reason,
|
|
153
|
+
outcome: "queued",
|
|
154
|
+
proposalId: proposalResult.id,
|
|
155
|
+
});
|
|
156
|
+
// Mark as repaired so the caller removes it from the validation-failure set.
|
|
157
|
+
repairedRefs.add(failure.ref);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
// Fallback: no stash dir available — write directly (legacy path).
|
|
161
|
+
// This should not occur in production; stashDir is always provided by
|
|
162
|
+
// `runSchemaRepairPass` callers in improve.ts.
|
|
163
|
+
warn(`[improve] schema-repair: no stashDir available for ${failure.ref}, falling back to direct write`);
|
|
164
|
+
fs.writeFileSync(filePath, newContent, "utf8");
|
|
165
|
+
info(`[improve] schema-repair written: ${failure.ref}`);
|
|
166
|
+
appendEvent({
|
|
167
|
+
eventType: "schema_repair_invoked",
|
|
168
|
+
ref: failure.ref,
|
|
169
|
+
metadata: { outcome: "written", reason: failure.reason },
|
|
170
|
+
});
|
|
171
|
+
repairs.push({ ref: failure.ref, reason: failure.reason, outcome: "written" });
|
|
172
|
+
repairedRefs.add(failure.ref);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
appendEvent({
|
|
177
|
+
eventType: "schema_repair_invoked",
|
|
178
|
+
ref: failure.ref,
|
|
179
|
+
metadata: { outcome: "error", reason: failure.reason, error: String(e) },
|
|
180
|
+
});
|
|
181
|
+
repairs.push({ ref: failure.ref, reason: failure.reason, outcome: "error", error: String(e) });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { repairs, repairedRefs };
|
|
185
|
+
}
|
|
186
|
+
// ── Default seam implementations ─────────────────────────────────────────────
|
|
187
|
+
function defaultIsLessonCandidate(ref) {
|
|
188
|
+
try {
|
|
189
|
+
const parsed = parseAssetRef(ref);
|
|
190
|
+
return parsed.type === "lesson";
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async function defaultFindFilePath(ref, stashDir) {
|
|
197
|
+
return resolveAssetPath(ref, {
|
|
198
|
+
stashDir,
|
|
199
|
+
mode: "index-first",
|
|
200
|
+
directoryIndexNames: ["SKILL.md", "index.md", "README.md"],
|
|
201
|
+
preserveDirectNameFallback: true,
|
|
202
|
+
});
|
|
203
|
+
}
|
package/dist/commands/search.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
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/.
|
|
1
4
|
/**
|
|
2
5
|
* `akm search` — entry point.
|
|
3
6
|
*
|
|
@@ -9,11 +12,13 @@
|
|
|
9
12
|
* Provider `search()` methods do not exist.
|
|
10
13
|
*/
|
|
11
14
|
import { loadConfig } from "../core/config";
|
|
12
|
-
import { UsageError } from "../core/errors";
|
|
15
|
+
import { rethrowIfTestIsolationError, UsageError } from "../core/errors";
|
|
13
16
|
import { appendEvent } from "../core/events";
|
|
14
|
-
import {
|
|
17
|
+
import { isTransientStashPath } from "../core/paths";
|
|
18
|
+
import { bumpUtilityScoresBatch, closeDatabase, openExistingDatabase } from "../indexer/db";
|
|
15
19
|
import { searchLocal } from "../indexer/db-search";
|
|
16
20
|
import { resolveSourceEntries } from "../indexer/search-source";
|
|
21
|
+
import { getCurrentWorkflowScopeKey } from "../workflows/scope-key";
|
|
17
22
|
// Eagerly import source providers to trigger self-registration before the
|
|
18
23
|
// indexer or path-resolution code runs.
|
|
19
24
|
import "../sources/providers/index";
|
|
@@ -26,10 +31,42 @@ export async function akmSearch(input) {
|
|
|
26
31
|
const normalizedQuery = query.toLowerCase();
|
|
27
32
|
const searchType = input.type ?? "any";
|
|
28
33
|
const limit = normalizeLimit(input.limit);
|
|
29
|
-
const
|
|
34
|
+
const parsedSource = parseSearchSource(input.source ?? "stash");
|
|
30
35
|
const config = loadConfig();
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
// Named-source filter: when --source is not a standard enum value, treat it
|
|
37
|
+
// as a named source from config.sources[].name. Validate early (before
|
|
38
|
+
// resolveSourceEntries, which can throw STASH_DIR_NOT_FOUND) so that a bad
|
|
39
|
+
// --source name always produces INVALID_SOURCE_VALUE regardless of stash state.
|
|
40
|
+
let namedSourceName;
|
|
41
|
+
let source;
|
|
42
|
+
if (parsedSource !== "stash" && parsedSource !== "registry" && parsedSource !== "both") {
|
|
43
|
+
namedSourceName = parsedSource;
|
|
44
|
+
// Check that the named source exists in the config before touching the stash.
|
|
45
|
+
const configSources = config.sources ?? [];
|
|
46
|
+
const foundInConfig = configSources.some((s) => s.name === namedSourceName) || configSources.some((s) => s.path === namedSourceName);
|
|
47
|
+
if (!foundInConfig) {
|
|
48
|
+
const validNames = configSources.map((s) => s.name).filter((n) => Boolean(n));
|
|
49
|
+
const hint = validNames.length > 0
|
|
50
|
+
? `Known source names: ${validNames.join(", ")}`
|
|
51
|
+
: "No named sources are configured. Run `akm list` to see installed stashes.";
|
|
52
|
+
throw new UsageError(`Unknown source name: "${namedSourceName}". ${hint}`, "INVALID_SOURCE_VALUE");
|
|
53
|
+
}
|
|
54
|
+
source = "stash";
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
source = parsedSource;
|
|
58
|
+
}
|
|
59
|
+
let allSources = resolveSourceEntries(undefined, config);
|
|
60
|
+
// When a named source was requested, narrow the sources list to just that entry.
|
|
61
|
+
// `resolveSourceEntries` sets `registryId` to `entry.name` for each config source.
|
|
62
|
+
if (namedSourceName !== undefined) {
|
|
63
|
+
const ns = namedSourceName;
|
|
64
|
+
allSources = allSources.filter((s) => s.registryId === ns || s.path === ns);
|
|
65
|
+
// allSources may still be empty if the configured source dir doesn't exist on
|
|
66
|
+
// disk (resolveSourceEntries skips non-existent dirs). Fall through to the
|
|
67
|
+
// zero-sources guard below which emits a friendly warning.
|
|
68
|
+
}
|
|
69
|
+
if (allSources.length === 0) {
|
|
33
70
|
// stashDir: "" is a safe sentinel here — the response carries zero hits
|
|
34
71
|
// and a warning, so no downstream code will try to use the empty path.
|
|
35
72
|
const response = {
|
|
@@ -40,14 +77,18 @@ export async function akmSearch(input) {
|
|
|
40
77
|
warnings: ["No stashes configured. Run `akm init` to create your working stash."],
|
|
41
78
|
timing: { totalMs: Date.now() - t0 },
|
|
42
79
|
};
|
|
43
|
-
|
|
80
|
+
if (!input.skipLogging)
|
|
81
|
+
logSearchEvent(query, response, undefined, undefined, input.eventSource);
|
|
44
82
|
return response;
|
|
45
83
|
}
|
|
46
84
|
// Primary stash directory — used for DB path lookups and as the default
|
|
47
85
|
// stash root. Safe because the empty-sources case is handled above.
|
|
48
|
-
const stashDir =
|
|
86
|
+
const stashDir = allSources[0].path;
|
|
87
|
+
// Expose the filtered source list to downstream search calls.
|
|
88
|
+
const sources = allSources;
|
|
49
89
|
const filters = normalizeScopeFilters(input.filters);
|
|
50
90
|
const includeProposed = input.includeProposed === true;
|
|
91
|
+
const belief = input.belief ?? "all";
|
|
51
92
|
const localResult = source === "registry"
|
|
52
93
|
? undefined
|
|
53
94
|
: await searchLocal({
|
|
@@ -59,6 +100,13 @@ export async function akmSearch(input) {
|
|
|
59
100
|
config,
|
|
60
101
|
filters,
|
|
61
102
|
includeProposed,
|
|
103
|
+
beliefFilter: belief,
|
|
104
|
+
// When `--source <name>` narrowed the source list above, propagate
|
|
105
|
+
// that intent down to the database layer so FTS/vector hits from
|
|
106
|
+
// sources outside the narrowed set are filtered out post-ranking.
|
|
107
|
+
// Without this, the index (which spans every configured source)
|
|
108
|
+
// would leak hits from sources the caller did not request.
|
|
109
|
+
restrictToSources: namedSourceName !== undefined,
|
|
62
110
|
});
|
|
63
111
|
const registryResult = source === "stash" ? undefined : await searchRegistry(query, { limit, registries: config.registries });
|
|
64
112
|
if (source === "stash") {
|
|
@@ -73,7 +121,8 @@ export async function akmSearch(input) {
|
|
|
73
121
|
warnings: localResult?.warnings?.length ? localResult.warnings : undefined,
|
|
74
122
|
timing: { totalMs: Date.now() - t0, rankMs: localResult?.rankMs, embedMs: localResult?.embedMs },
|
|
75
123
|
};
|
|
76
|
-
|
|
124
|
+
if (!input.skipLogging)
|
|
125
|
+
logSearchEvent(query, response, undefined, localResult?.mode ?? "keyword", input.eventSource);
|
|
77
126
|
return response;
|
|
78
127
|
}
|
|
79
128
|
const registryHits = (registryResult?.hits ?? []).map((hit) => {
|
|
@@ -107,7 +156,8 @@ export async function akmSearch(input) {
|
|
|
107
156
|
warnings: registryResult?.warnings.length ? registryResult.warnings : undefined,
|
|
108
157
|
timing: { totalMs: Date.now() - t0 },
|
|
109
158
|
};
|
|
110
|
-
|
|
159
|
+
if (!input.skipLogging)
|
|
160
|
+
logSearchEvent(query, response, undefined, undefined, input.eventSource);
|
|
111
161
|
return response;
|
|
112
162
|
}
|
|
113
163
|
// source === "both"
|
|
@@ -124,7 +174,8 @@ export async function akmSearch(input) {
|
|
|
124
174
|
warnings: warnings.length ? warnings : undefined,
|
|
125
175
|
timing: { totalMs: Date.now() - t0 },
|
|
126
176
|
};
|
|
127
|
-
|
|
177
|
+
if (!input.skipLogging)
|
|
178
|
+
logSearchEvent(query, response, undefined, undefined, input.eventSource);
|
|
128
179
|
return response;
|
|
129
180
|
}
|
|
130
181
|
/**
|
|
@@ -160,13 +211,16 @@ function resolveEntryIds(db, hits) {
|
|
|
160
211
|
* Per-entry events are recorded only for stash hits because registry hits
|
|
161
212
|
* have no local entry_id to reference.
|
|
162
213
|
*/
|
|
163
|
-
function logSearchEvent(query, response, existingDb) {
|
|
214
|
+
function logSearchEvent(query, response, existingDb, mode = "keyword", eventSource = "user") {
|
|
164
215
|
// Emit a structured event to events.jsonl so workflow-trace consumers
|
|
165
216
|
// detect akm search invocations without relying on stdout scraping.
|
|
166
217
|
const stashHits = response.hits.filter((h) => h.type !== "registry");
|
|
218
|
+
// D8: include registry hit refs so a show following a registry-only search generates a select event
|
|
219
|
+
const registryHitRefs = (response.registryHits ?? []).map((h) => `registry:${h.id}`);
|
|
220
|
+
const allResultRefs = [...stashHits.map((h) => h.ref), ...registryHitRefs];
|
|
167
221
|
appendEvent({
|
|
168
222
|
eventType: "search",
|
|
169
|
-
metadata: { query, hitCount: stashHits.length, resultRefs:
|
|
223
|
+
metadata: { query, hitCount: stashHits.length, resultRefs: allResultRefs, mode },
|
|
170
224
|
});
|
|
171
225
|
try {
|
|
172
226
|
const db = existingDb ?? openExistingDatabase();
|
|
@@ -178,8 +232,24 @@ function logSearchEvent(query, response, existingDb) {
|
|
|
178
232
|
query,
|
|
179
233
|
entry_id: entryId,
|
|
180
234
|
entry_ref: ref,
|
|
235
|
+
source: eventSource,
|
|
181
236
|
});
|
|
182
237
|
}
|
|
238
|
+
// Bump utility scores for all resolved entries (MemRL retrieval signal).
|
|
239
|
+
// The indexer overwrites these at next reindex; bumps are temporary hints.
|
|
240
|
+
const resolvedIds = resolved.map((r) => r.entryId).filter((id) => id !== undefined);
|
|
241
|
+
if (resolvedIds.length > 0) {
|
|
242
|
+
let scopeKey;
|
|
243
|
+
try {
|
|
244
|
+
const stashPath = response.stashDir;
|
|
245
|
+
const disabled = process.env.AKM_DISABLE_SCOPED_UTILITY === "1" || (stashPath && isTransientStashPath(stashPath));
|
|
246
|
+
scopeKey = disabled ? undefined : getCurrentWorkflowScopeKey();
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// Non-fatal — fall back to global-only bumps on any error.
|
|
250
|
+
}
|
|
251
|
+
bumpUtilityScoresBatch(db, resolvedIds, 1.0, 0.1, scopeKey);
|
|
252
|
+
}
|
|
183
253
|
// Count registry hits separately so registry-only searches record a
|
|
184
254
|
// non-zero resultCount. response.hits is always [] when source="registry".
|
|
185
255
|
const stashHitCount = response.hits.length;
|
|
@@ -192,7 +262,9 @@ function logSearchEvent(query, response, existingDb) {
|
|
|
192
262
|
stashHitCount,
|
|
193
263
|
registryHitCount,
|
|
194
264
|
resolvedCount: resolved.length,
|
|
265
|
+
mode,
|
|
195
266
|
}),
|
|
267
|
+
source: eventSource,
|
|
196
268
|
});
|
|
197
269
|
}
|
|
198
270
|
finally {
|
|
@@ -200,7 +272,8 @@ function logSearchEvent(query, response, existingDb) {
|
|
|
200
272
|
closeDatabase(db);
|
|
201
273
|
}
|
|
202
274
|
}
|
|
203
|
-
catch {
|
|
275
|
+
catch (err) {
|
|
276
|
+
rethrowIfTestIsolationError(err);
|
|
204
277
|
/* fire-and-forget */
|
|
205
278
|
}
|
|
206
279
|
}
|
|
@@ -211,6 +284,24 @@ function normalizeLimit(limit) {
|
|
|
211
284
|
}
|
|
212
285
|
return Math.min(Math.floor(limit), 200);
|
|
213
286
|
}
|
|
287
|
+
/**
|
|
288
|
+
* Parse the `--source` flag value.
|
|
289
|
+
*
|
|
290
|
+
* Accepts:
|
|
291
|
+
* - `stash` (default) — search the local stash index only
|
|
292
|
+
* - `registry` — search remote registries only
|
|
293
|
+
* - `both` — search stash and registries
|
|
294
|
+
* - `local` — alias for `stash`
|
|
295
|
+
* - Any named source from `config.sources[].name` — filters stash results to
|
|
296
|
+
* that single source only. The named-source path is detected and resolved
|
|
297
|
+
* inside `akmSearch`; this function returns the raw name so the caller can
|
|
298
|
+
* pass it through to `akmSearch` which accepts `SearchSource | string`.
|
|
299
|
+
*
|
|
300
|
+
* Unknown values that are not a known enum AND not a named source will still
|
|
301
|
+
* produce an error inside `akmSearch` when the config lookup finds nothing.
|
|
302
|
+
* This allows the CLI to accept named sources without requiring config access
|
|
303
|
+
* at parse time.
|
|
304
|
+
*/
|
|
214
305
|
export function parseSearchSource(source) {
|
|
215
306
|
if (source === "stash" || source === "registry" || source === "both")
|
|
216
307
|
return source;
|
|
@@ -219,7 +310,17 @@ export function parseSearchSource(source) {
|
|
|
219
310
|
return "stash";
|
|
220
311
|
if (typeof source === "undefined")
|
|
221
312
|
return "stash";
|
|
222
|
-
|
|
313
|
+
// Pass through unknown strings — they may be valid named sources.
|
|
314
|
+
// `akmSearch` will validate against config.sources and throw a UsageError
|
|
315
|
+
// with a helpful message if the name isn't found.
|
|
316
|
+
return source;
|
|
317
|
+
}
|
|
318
|
+
export function parseBeliefFilterMode(value) {
|
|
319
|
+
if (value === undefined || value === "all")
|
|
320
|
+
return "all";
|
|
321
|
+
if (value === "current" || value === "historical")
|
|
322
|
+
return value;
|
|
323
|
+
throw new UsageError(`Invalid value for --belief: ${String(value)}. Expected one of: all|current|historical`, "INVALID_FLAG_VALUE");
|
|
223
324
|
}
|
|
224
325
|
/**
|
|
225
326
|
* Strip empty / non-string values from a scope filter object. Returns
|
|
@@ -0,0 +1,173 @@
|
|
|
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
|
+
* Secret asset type — whole-file secret storage.
|
|
6
|
+
*
|
|
7
|
+
* A `secret` holds a single SENSITIVE value used on its own for authentication
|
|
8
|
+
* (a PEM private key, an API token, a TLS cert, a service-account JSON): the
|
|
9
|
+
* ENTIRE file is the secret. There is no safe region to parse, so only the
|
|
10
|
+
* filename is ever surfaced. Where an `env` file holds a GROUP of related
|
|
11
|
+
* configuration and exposes key NAMES as metadata, a secret is ONE value and
|
|
12
|
+
* exposes nothing but its name — reach for `secret` when one value *is* the
|
|
13
|
+
* credential, and for `env` when loading a service's related configuration.
|
|
14
|
+
*
|
|
15
|
+
* Invariant: a secret's bytes must never be written to stdout, returned
|
|
16
|
+
* through the indexer / `akm show` renderer, or any structured output channel.
|
|
17
|
+
* The supported value-use paths are:
|
|
18
|
+
*
|
|
19
|
+
* - `akm secret run <ref> <VAR> -- <cmd>` — value injected into the child
|
|
20
|
+
* process env as `VAR=<value>` (see `readValue`).
|
|
21
|
+
* - `akm secret path <ref>` — print the file path so a command can read it
|
|
22
|
+
* itself (Docker `/run/secrets` + `_FILE` convention).
|
|
23
|
+
*
|
|
24
|
+
* Values are stored as raw bytes (no quoting, multi-line allowed) so they
|
|
25
|
+
* round-trip byte-exact, unlike env values which forbid literal newlines.
|
|
26
|
+
*/
|
|
27
|
+
import crypto from "node:crypto";
|
|
28
|
+
import fs from "node:fs";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import { probeLock, releaseLock, tryAcquireLockSync } from "../core/file-lock";
|
|
31
|
+
// ── Write-lock helper ─────────────────────────────────────────────────────────
|
|
32
|
+
/**
|
|
33
|
+
* Acquire an exclusive lock for the given secret path, run `fn`, then release.
|
|
34
|
+
* Mirrors the env write-lock: O_EXCL creation, 5s deadline, PID-based stale
|
|
35
|
+
* detection. A timeout is always a stale lock or a programming error, so we
|
|
36
|
+
* throw rather than silently proceeding.
|
|
37
|
+
*/
|
|
38
|
+
export function withSecretLock(secretPath, fn) {
|
|
39
|
+
const lockPath = `${secretPath}.lock`;
|
|
40
|
+
const deadline = Date.now() + 5000;
|
|
41
|
+
while (!tryAcquireLockSync(lockPath, String(process.pid))) {
|
|
42
|
+
const probe = probeLock(lockPath);
|
|
43
|
+
if (probe.state === "stale") {
|
|
44
|
+
releaseLock(lockPath);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (Date.now() > deadline) {
|
|
48
|
+
const holderHint = probe.state === "held"
|
|
49
|
+
? ` Lock file ${lockPath} is held by live PID ${probe.holderPid}.`
|
|
50
|
+
: ` Lock file ${lockPath} could not be inspected.`;
|
|
51
|
+
throw new Error(`Could not acquire secret lock for ${secretPath} after 5s.${holderHint} Retry once any other akm secret operation finishes, or remove the stale lock file.`);
|
|
52
|
+
}
|
|
53
|
+
if (typeof globalThis.Bun?.sleepSync ===
|
|
54
|
+
"function") {
|
|
55
|
+
globalThis.Bun.sleepSync(10);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
let spin = 0;
|
|
59
|
+
while (spin++ < 100_000) {
|
|
60
|
+
/* yield */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
return fn();
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
releaseLock(lockPath);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// ── Atomic byte write ──────────────────────────────────────────────────────────
|
|
72
|
+
/**
|
|
73
|
+
* Atomically write `data` to `target` at mode 0600. Unlike `writeFileAtomic`
|
|
74
|
+
* in core/common (string content), this accepts a Buffer so secret bytes
|
|
75
|
+
* round-trip exactly — binary certs and CRLF/LF line endings are preserved.
|
|
76
|
+
*/
|
|
77
|
+
function writeSecretAtomic(target, data) {
|
|
78
|
+
const tmp = `${target}.tmp.${process.pid}.${crypto.randomBytes(8).toString("hex")}`;
|
|
79
|
+
const fd = fs.openSync(tmp, "w", 0o600);
|
|
80
|
+
try {
|
|
81
|
+
fs.writeSync(fd, data);
|
|
82
|
+
try {
|
|
83
|
+
fs.fdatasyncSync(fd);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Best-effort durability; some pseudo-filesystems lack fdatasync.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
fs.closeSync(fd);
|
|
91
|
+
}
|
|
92
|
+
fs.renameSync(tmp, target);
|
|
93
|
+
try {
|
|
94
|
+
const dirFd = fs.openSync(path.dirname(target), "r");
|
|
95
|
+
try {
|
|
96
|
+
fs.fsyncSync(dirFd);
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
fs.closeSync(dirFd);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Directory fsync is unsupported on FAT / some FUSE mounts / Windows.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function ensureParentDir(filePath) {
|
|
107
|
+
const dir = path.dirname(filePath);
|
|
108
|
+
if (!fs.existsSync(dir))
|
|
109
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
110
|
+
}
|
|
111
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
112
|
+
/**
|
|
113
|
+
* Walk a `secrets/` directory and return the POSIX-relative names of every
|
|
114
|
+
* secret file. Lock files (`*.lock`), sensitive markers (`*.sensitive`), and
|
|
115
|
+
* secrets with a sibling `<name>.sensitive` marker are excluded. The file
|
|
116
|
+
* bodies are NEVER read.
|
|
117
|
+
*/
|
|
118
|
+
export function listNames(secretsRoot) {
|
|
119
|
+
if (!fs.existsSync(secretsRoot))
|
|
120
|
+
return [];
|
|
121
|
+
const names = [];
|
|
122
|
+
const walk = (dir) => {
|
|
123
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
124
|
+
const full = path.join(dir, entry.name);
|
|
125
|
+
if (entry.isDirectory()) {
|
|
126
|
+
walk(full);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!entry.isFile())
|
|
130
|
+
continue;
|
|
131
|
+
if (entry.name.endsWith(".lock") || entry.name.endsWith(".sensitive"))
|
|
132
|
+
continue;
|
|
133
|
+
// A sibling `<name>.sensitive` marker suppresses listing.
|
|
134
|
+
if (fs.existsSync(`${full}.sensitive`))
|
|
135
|
+
continue;
|
|
136
|
+
names.push(path.relative(secretsRoot, full).split(path.sep).join("/"));
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
walk(secretsRoot);
|
|
140
|
+
return names.sort();
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Read a secret's raw bytes. Internal use only (for `secret run` / `secret
|
|
144
|
+
* path`). Callers MUST NOT write the returned value to stdout or any log.
|
|
145
|
+
*/
|
|
146
|
+
export function readValue(secretPath) {
|
|
147
|
+
return fs.readFileSync(secretPath);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Write (create or overwrite) a secret with the given raw bytes, atomically at
|
|
151
|
+
* mode 0600 under a write-lock. No quoting; multi-line / binary allowed.
|
|
152
|
+
*/
|
|
153
|
+
export function setSecret(secretPath, value) {
|
|
154
|
+
ensureParentDir(secretPath);
|
|
155
|
+
withSecretLock(secretPath, () => {
|
|
156
|
+
writeSecretAtomic(secretPath, value);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Remove a secret file (and its `.sensitive` marker, if present). Returns true
|
|
161
|
+
* if the secret existed.
|
|
162
|
+
*/
|
|
163
|
+
export function removeSecret(secretPath) {
|
|
164
|
+
return withSecretLock(secretPath, () => {
|
|
165
|
+
if (!fs.existsSync(secretPath))
|
|
166
|
+
return false;
|
|
167
|
+
fs.rmSync(secretPath);
|
|
168
|
+
const marker = `${secretPath}.sensitive`;
|
|
169
|
+
if (fs.existsSync(marker))
|
|
170
|
+
fs.rmSync(marker);
|
|
171
|
+
return true;
|
|
172
|
+
});
|
|
173
|
+
}
|
|
@@ -1,3 +1,6 @@
|
|
|
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/.
|
|
1
4
|
import * as childProcess from "node:child_process";
|
|
2
5
|
import { createHash } from "node:crypto";
|
|
3
6
|
import fs from "node:fs";
|