akm-cli 0.6.0 → 0.7.0-rc1
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 +66 -0
- package/dist/{cli.js → src/cli.js} +672 -29
- package/dist/{commands → src/commands}/config-cli.js +5 -4
- package/dist/src/commands/distill.js +283 -0
- package/dist/src/commands/events.js +108 -0
- package/dist/src/commands/history.js +120 -0
- package/dist/{commands → src/commands}/installed-stashes.js +28 -2
- package/dist/src/commands/proposal.js +119 -0
- package/dist/src/commands/propose.js +171 -0
- package/dist/src/commands/reflect.js +193 -0
- package/dist/{commands → src/commands}/registry-search.js +2 -1
- package/dist/{commands → src/commands}/remember.js +12 -0
- package/dist/{commands → src/commands}/search.js +74 -1
- package/dist/{commands → src/commands}/self-update.js +4 -3
- package/dist/{commands → src/commands}/show.js +67 -2
- package/dist/{core → src/core}/asset-ref.js +5 -5
- package/dist/{core → src/core}/asset-spec.js +12 -0
- package/dist/{core → src/core}/common.js +1 -1
- package/dist/{core → src/core}/config.js +175 -121
- package/dist/{core → src/core}/errors.js +4 -0
- package/dist/src/core/events.js +239 -0
- package/dist/src/core/lesson-lint.js +86 -0
- package/dist/src/core/proposals.js +406 -0
- package/dist/src/core/warn.js +72 -0
- package/dist/{core → src/core}/write-source.js +80 -5
- package/dist/{indexer → src/indexer}/db-search.js +119 -27
- package/dist/{indexer → src/indexer}/db.js +76 -23
- package/dist/{indexer → src/indexer}/file-context.js +0 -3
- package/dist/src/indexer/graph-boost.js +179 -0
- package/dist/src/indexer/graph-extraction.js +212 -0
- package/dist/{indexer → src/indexer}/indexer.js +73 -6
- package/dist/src/indexer/memory-inference.js +263 -0
- package/dist/{indexer → src/indexer}/metadata.js +114 -11
- package/dist/src/integrations/agent/config.js +292 -0
- package/dist/src/integrations/agent/detect.js +94 -0
- package/dist/src/integrations/agent/index.js +17 -0
- package/dist/src/integrations/agent/profiles.js +65 -0
- package/dist/src/integrations/agent/prompts.js +167 -0
- package/dist/src/integrations/agent/spawn.js +221 -0
- package/dist/{integrations → src/integrations}/lockfile.js +0 -26
- package/dist/{llm → src/llm}/client.js +33 -2
- package/dist/src/llm/feature-gate.js +108 -0
- package/dist/src/llm/graph-extract.js +107 -0
- package/dist/src/llm/index-passes.js +35 -0
- package/dist/src/llm/memory-infer.js +86 -0
- package/dist/{output → src/output}/renderers.js +60 -1
- package/dist/src/output/shapes.js +516 -0
- package/dist/{output → src/output}/text.js +447 -4
- package/dist/{registry → src/registry}/build-index.js +14 -4
- package/dist/{registry → src/registry}/factory.js +0 -8
- package/dist/{registry → src/registry}/providers/static-index.js +3 -2
- package/dist/{registry → src/registry}/resolve.js +68 -2
- package/dist/{setup → src/setup}/setup.js +43 -5
- package/dist/{sources → src/sources}/providers/git.js +7 -15
- package/dist/{wiki → src/wiki}/wiki.js +9 -11
- package/dist/tests/add-website-source.test.js +119 -0
- package/dist/tests/agent/agent-config-loader.test.js +70 -0
- package/dist/tests/agent/agent-config.test.js +221 -0
- package/dist/tests/agent/agent-detect.test.js +100 -0
- package/dist/tests/agent/agent-spawn.test.js +234 -0
- package/dist/tests/agent-output.test.js +186 -0
- package/dist/tests/architecture/agent-no-llm-sdk-guard.test.js +103 -0
- package/dist/tests/architecture/agent-spawn-seam.test.js +193 -0
- package/dist/tests/architecture/llm-stateless-seam.test.js +112 -0
- package/dist/tests/asset-ref.test.js +192 -0
- package/dist/tests/asset-registry.test.js +103 -0
- package/dist/tests/asset-spec.test.js +241 -0
- package/dist/tests/bench/attribution.test.js +995 -0
- package/dist/tests/bench/cleanup-sigint.test.js +83 -0
- package/dist/tests/bench/cleanup.js +203 -0
- package/dist/tests/bench/cleanup.test.js +166 -0
- package/dist/tests/bench/cli.js +683 -0
- package/dist/tests/bench/cli.test.js +177 -0
- package/dist/tests/bench/compare.test.js +556 -0
- package/dist/tests/bench/corpus.js +314 -0
- package/dist/tests/bench/corpus.test.js +258 -0
- package/dist/tests/bench/driver.js +346 -0
- package/dist/tests/bench/driver.test.js +443 -0
- package/dist/tests/bench/evolve-metrics.js +179 -0
- package/dist/tests/bench/evolve-metrics.test.js +187 -0
- package/dist/tests/bench/evolve.js +580 -0
- package/dist/tests/bench/evolve.test.js +616 -0
- package/dist/tests/bench/failure-modes.test.js +300 -0
- package/dist/tests/bench/feedback-integrity.test.js +456 -0
- package/dist/tests/bench/leakage.test.js +125 -0
- package/dist/tests/bench/learning-curve.test.js +133 -0
- package/dist/tests/bench/metrics.js +2319 -0
- package/dist/tests/bench/metrics.test.js +1144 -0
- package/dist/tests/bench/no-os-tmpdir-invariant.test.js +43 -0
- package/dist/tests/bench/report.js +1821 -0
- package/dist/tests/bench/report.test.js +989 -0
- package/dist/tests/bench/runner.js +536 -0
- package/dist/tests/bench/runner.test.js +958 -0
- package/dist/tests/bench/search-bridge.test.js +331 -0
- package/dist/tests/bench/tmp.js +41 -0
- package/dist/tests/bench/trajectory.js +116 -0
- package/dist/tests/bench/trajectory.test.js +127 -0
- package/dist/tests/bench/verifier.js +109 -0
- package/dist/tests/bench/verifier.test.js +118 -0
- package/dist/tests/bench/workflow-evaluator.js +557 -0
- package/dist/tests/bench/workflow-evaluator.test.js +421 -0
- package/dist/tests/bench/workflow-spec.js +358 -0
- package/dist/tests/bench/workflow-spec.test.js +363 -0
- package/dist/tests/bench/workflow-trace.js +438 -0
- package/dist/tests/bench/workflow-trace.test.js +254 -0
- package/dist/tests/benchmark-search-quality.js +536 -0
- package/dist/tests/benchmark-suite.js +1441 -0
- package/dist/tests/capture-cli.test.js +112 -0
- package/dist/tests/cli-errors.test.js +203 -0
- package/dist/tests/commands/events.test.js +370 -0
- package/dist/tests/commands/history.test.js +223 -0
- package/dist/tests/commands/import.test.js +103 -0
- package/dist/tests/commands/proposal-cli.test.js +209 -0
- package/dist/tests/commands/reflect-propose-cli.test.js +333 -0
- package/dist/tests/commands/remember.test.js +97 -0
- package/dist/tests/commands/scope-flags.test.js +300 -0
- package/dist/tests/commands/search.test.js +537 -0
- package/dist/tests/commands/show-indexer-parity.test.js +117 -0
- package/dist/tests/commands/show.test.js +294 -0
- package/dist/tests/common.test.js +266 -0
- package/dist/tests/completions.test.js +142 -0
- package/dist/tests/config-cli.test.js +193 -0
- package/dist/tests/config-llm-features.test.js +139 -0
- package/dist/tests/config.test.js +544 -0
- package/dist/tests/contracts/migration-baseline.test.js +43 -0
- package/dist/tests/contracts/reflect-propose-envelope.test.js +139 -0
- package/dist/tests/contracts/spec-helpers.js +46 -0
- package/dist/tests/contracts/v1-spec-section-11-proposal-queue.test.js +228 -0
- package/dist/tests/contracts/v1-spec-section-12-agent-config.test.js +56 -0
- package/dist/tests/contracts/v1-spec-section-13-lesson-type.test.js +34 -0
- package/dist/tests/contracts/v1-spec-section-14-llm-features.test.js +94 -0
- package/dist/tests/contracts/v1-spec-section-4-1-asset-types.test.js +39 -0
- package/dist/tests/contracts/v1-spec-section-4-2-quality-rules.test.js +44 -0
- package/dist/tests/contracts/v1-spec-section-5-configuration.test.js +47 -0
- package/dist/tests/contracts/v1-spec-section-6-orchestration.test.js +40 -0
- package/dist/tests/contracts/v1-spec-section-7-module-layout.test.js +58 -0
- package/dist/tests/contracts/v1-spec-section-8-extension-points.test.js +34 -0
- package/dist/tests/contracts/v1-spec-section-9-4-cli-surface.test.js +75 -0
- package/dist/tests/contracts/v1-spec-section-9-7-llm-agent-boundary.test.js +36 -0
- package/dist/tests/core/write-source.test.js +366 -0
- package/dist/tests/curate-command.test.js +87 -0
- package/dist/tests/db-scoring.test.js +201 -0
- package/dist/tests/db.test.js +654 -0
- package/dist/tests/distill-cli-flag.test.js +208 -0
- package/dist/tests/distill.test.js +515 -0
- package/dist/tests/docker-install.test.js +120 -0
- package/dist/tests/e2e.test.js +1398 -0
- package/dist/tests/embedder.test.js +340 -0
- package/dist/tests/embedding-model-config.test.js +379 -0
- package/dist/tests/feedback-command.test.js +172 -0
- package/dist/tests/file-context.test.js +552 -0
- package/dist/tests/fixtures/scripts/git/summarize-diff.js +9 -0
- package/dist/tests/fixtures/scripts/lint/eslint-check.js +7 -0
- package/dist/tests/fixtures/stashes/load.js +166 -0
- package/dist/tests/fixtures/stashes/load.test.js +88 -0
- package/dist/tests/fixtures/stashes/ranking-baseline/scripts/mem0-search.js +12 -0
- package/dist/tests/frontmatter.test.js +190 -0
- package/dist/tests/fts-field-weighting.test.js +254 -0
- package/dist/tests/fuzzy-search.test.js +230 -0
- package/dist/tests/git-provider-clone.test.js +45 -0
- package/dist/tests/github.test.js +161 -0
- package/dist/tests/graph-boost-ranking.test.js +305 -0
- package/dist/tests/graph-extraction.test.js +282 -0
- package/dist/tests/helpers/usage-events.js +8 -0
- package/dist/tests/index-pass-llm.test.js +161 -0
- package/dist/tests/indexer.test.js +559 -0
- package/dist/tests/info-command.test.js +166 -0
- package/dist/tests/init.test.js +69 -0
- package/dist/tests/install-script.test.js +246 -0
- package/dist/tests/integration/agent-real-profile.test.js +94 -0
- package/dist/tests/issue-36-repro.test.js +304 -0
- package/dist/tests/issues-191-194.test.js +160 -0
- package/dist/tests/lesson-lint.test.js +111 -0
- package/dist/tests/llm-client.test.js +115 -0
- package/dist/tests/llm-feature-gate.test.js +151 -0
- package/dist/tests/llm.test.js +139 -0
- package/dist/tests/lockfile.test.js +216 -0
- package/dist/tests/manifest.test.js +205 -0
- package/dist/tests/markdown.test.js +126 -0
- package/dist/tests/matchers-unit.test.js +189 -0
- package/dist/tests/memory-inference.test.js +299 -0
- package/dist/tests/merge-scoring.test.js +136 -0
- package/dist/tests/metadata.test.js +313 -0
- package/dist/tests/migration-help.test.js +89 -0
- package/dist/tests/origin-resolve.test.js +124 -0
- package/dist/tests/output-baseline.test.js +217 -0
- package/dist/tests/output-shapes-unit.test.js +476 -0
- package/dist/tests/parallel-search.test.js +272 -0
- package/dist/tests/parameter-metadata.test.js +365 -0
- package/dist/tests/paths.test.js +177 -0
- package/dist/tests/progressive-disclosure.test.js +280 -0
- package/dist/tests/proposals.test.js +279 -0
- package/dist/tests/proposed-quality.test.js +271 -0
- package/dist/tests/provider-registry.test.js +32 -0
- package/dist/tests/ranking-regression.test.js +548 -0
- package/dist/tests/reflect-propose.test.js +455 -0
- package/dist/tests/registry-build-index.test.js +378 -0
- package/dist/tests/registry-cli.test.js +290 -0
- package/dist/tests/registry-index-v2.test.js +430 -0
- package/dist/tests/registry-install.test.js +728 -0
- package/dist/tests/registry-providers/parity.test.js +189 -0
- package/dist/tests/registry-providers/skills-sh.test.js +309 -0
- package/dist/tests/registry-providers/static-index.test.js +204 -0
- package/dist/tests/registry-resolve.test.js +126 -0
- package/dist/tests/registry-search.test.js +723 -0
- package/dist/tests/remember-frontmatter.test.js +380 -0
- package/dist/tests/remember-unit.test.js +123 -0
- package/dist/tests/ripgrep-install.test.js +251 -0
- package/dist/tests/ripgrep-resolve.test.js +108 -0
- package/dist/tests/ripgrep.test.js +163 -0
- package/dist/tests/save-command.test.js +94 -0
- package/dist/tests/save-trust-qa-fixes.test.js +270 -0
- package/dist/tests/scoring-pipeline.test.js +648 -0
- package/dist/tests/search-include-proposed-cli.test.js +118 -0
- package/dist/tests/self-update.test.js +442 -0
- package/dist/tests/semantic-search-e2e.test.js +512 -0
- package/dist/tests/semantic-status.test.js +471 -0
- package/dist/tests/setup-run.integration.js +877 -0
- package/dist/tests/setup-wizard.test.js +198 -0
- package/dist/tests/setup.test.js +131 -0
- package/dist/tests/source-add.test.js +11 -0
- package/dist/tests/source-clone.test.js +254 -0
- package/dist/tests/source-manage.test.js +366 -0
- package/dist/tests/source-providers/filesystem.test.js +82 -0
- package/dist/tests/source-providers/git.test.js +252 -0
- package/dist/tests/source-providers/website.test.js +128 -0
- package/dist/tests/source-qa-fixes.test.js +268 -0
- package/dist/tests/source-registry.test.js +350 -0
- package/dist/tests/source-resolve.test.js +100 -0
- package/dist/tests/source-source.test.js +221 -0
- package/dist/tests/source.test.js +533 -0
- package/dist/tests/tar-utils-scan.test.js +73 -0
- package/dist/tests/toggle-components.test.js +73 -0
- package/dist/tests/usage-telemetry.test.js +265 -0
- package/dist/tests/utility-scoring.test.js +558 -0
- package/dist/tests/vault-load-error.test.js +78 -0
- package/dist/tests/vault-qa-fixes.test.js +194 -0
- package/dist/tests/vault.test.js +429 -0
- package/dist/tests/vector-search.test.js +608 -0
- package/dist/tests/walker.test.js +252 -0
- package/dist/tests/wave2-cluster-bc.test.js +228 -0
- package/dist/tests/wave2-cluster-d.test.js +180 -0
- package/dist/tests/wave2-cluster-e.test.js +179 -0
- package/dist/tests/wiki-qa-fixes.test.js +270 -0
- package/dist/tests/wiki.test.js +529 -0
- package/dist/tests/workflow-cli.test.js +271 -0
- package/dist/tests/workflow-markdown.test.js +171 -0
- package/dist/tests/workflow-path-escape.test.js +132 -0
- package/dist/tests/workflow-qa-fixes.test.js +377 -0
- package/dist/tests/workflows/indexer-rejection.test.js +213 -0
- package/docs/README.md +8 -0
- package/docs/migration/release-notes/0.7.0.md +244 -0
- package/package.json +2 -2
- package/dist/core/warn.js +0 -27
- package/dist/output/shapes.js +0 -212
- /package/dist/{commands → src/commands}/completions.js +0 -0
- /package/dist/{commands → src/commands}/curate.js +0 -0
- /package/dist/{commands → src/commands}/info.js +0 -0
- /package/dist/{commands → src/commands}/init.js +0 -0
- /package/dist/{commands → src/commands}/install-audit.js +0 -0
- /package/dist/{commands → src/commands}/migration-help.js +0 -0
- /package/dist/{commands → src/commands}/source-add.js +0 -0
- /package/dist/{commands → src/commands}/source-clone.js +0 -0
- /package/dist/{commands → src/commands}/source-manage.js +0 -0
- /package/dist/{commands → src/commands}/vault.js +0 -0
- /package/dist/{core → src/core}/asset-registry.js +0 -0
- /package/dist/{core → src/core}/frontmatter.js +0 -0
- /package/dist/{core → src/core}/markdown.js +0 -0
- /package/dist/{core → src/core}/paths.js +0 -0
- /package/dist/{indexer → src/indexer}/manifest.js +0 -0
- /package/dist/{indexer → src/indexer}/matchers.js +0 -0
- /package/dist/{indexer → src/indexer}/search-fields.js +0 -0
- /package/dist/{indexer → src/indexer}/search-source.js +0 -0
- /package/dist/{indexer → src/indexer}/semantic-status.js +0 -0
- /package/dist/{indexer → src/indexer}/usage-events.js +0 -0
- /package/dist/{indexer → src/indexer}/walker.js +0 -0
- /package/dist/{integrations → src/integrations}/github.js +0 -0
- /package/dist/{llm → src/llm}/embedder.js +0 -0
- /package/dist/{llm → src/llm}/embedders/cache.js +0 -0
- /package/dist/{llm → src/llm}/embedders/local.js +0 -0
- /package/dist/{llm → src/llm}/embedders/remote.js +0 -0
- /package/dist/{llm → src/llm}/embedders/types.js +0 -0
- /package/dist/{llm → src/llm}/metadata-enhance.js +0 -0
- /package/dist/{output → src/output}/cli-hints.js +0 -0
- /package/dist/{output → src/output}/context.js +0 -0
- /package/dist/{registry → src/registry}/create-provider-registry.js +0 -0
- /package/dist/{registry → src/registry}/origin-resolve.js +0 -0
- /package/dist/{registry → src/registry}/providers/index.js +0 -0
- /package/dist/{registry → src/registry}/providers/skills-sh.js +0 -0
- /package/dist/{registry → src/registry}/providers/types.js +0 -0
- /package/dist/{registry → src/registry}/types.js +0 -0
- /package/dist/{setup → src/setup}/detect.js +0 -0
- /package/dist/{setup → src/setup}/ripgrep-install.js +0 -0
- /package/dist/{setup → src/setup}/ripgrep-resolve.js +0 -0
- /package/dist/{setup → src/setup}/steps.js +0 -0
- /package/dist/{sources → src/sources}/include.js +0 -0
- /package/dist/{sources → src/sources}/provider-factory.js +0 -0
- /package/dist/{sources → src/sources}/provider.js +0 -0
- /package/dist/{sources → src/sources}/providers/filesystem.js +0 -0
- /package/dist/{sources → src/sources}/providers/index.js +0 -0
- /package/dist/{sources → src/sources}/providers/install-types.js +0 -0
- /package/dist/{sources → src/sources}/providers/npm.js +0 -0
- /package/dist/{sources → src/sources}/providers/provider-utils.js +0 -0
- /package/dist/{sources → src/sources}/providers/sync-from-ref.js +0 -0
- /package/dist/{sources → src/sources}/providers/tar-utils.js +0 -0
- /package/dist/{sources → src/sources}/providers/website.js +0 -0
- /package/dist/{sources → src/sources}/resolve.js +0 -0
- /package/dist/{sources → src/sources}/types.js +0 -0
- /package/dist/{templates → src/templates}/wiki-templates.js +0 -0
- /package/dist/{version.js → src/version.js} +0 -0
- /package/dist/{workflows → src/workflows}/authoring.js +0 -0
- /package/dist/{workflows → src/workflows}/cli.js +0 -0
- /package/dist/{workflows → src/workflows}/db.js +0 -0
- /package/dist/{workflows → src/workflows}/document-cache.js +0 -0
- /package/dist/{workflows → src/workflows}/parser.js +0 -0
- /package/dist/{workflows → src/workflows}/renderer.js +0 -0
- /package/dist/{workflows → src/workflows}/runs.js +0 -0
- /package/dist/{workflows → src/workflows}/schema.js +0 -0
- /package/dist/{workflows → src/workflows}/validator.js +0 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proposal substrate (#225).
|
|
3
|
+
*
|
|
4
|
+
* One durable proposal store for every future reflection / generation flow
|
|
5
|
+
* (`akm reflect`, `akm propose`, `akm distill`, lesson distillation, …).
|
|
6
|
+
* Proposals are *queue state*, not source-of-truth assets — they sit on disk
|
|
7
|
+
* waiting for human (or automated) review and only become assets after
|
|
8
|
+
* `akm proposal accept` validates and promotes them via
|
|
9
|
+
* {@link writeAssetToSource}.
|
|
10
|
+
*
|
|
11
|
+
* # Storage layout
|
|
12
|
+
*
|
|
13
|
+
* <stashRoot>/.akm/proposals/<id>/proposal.json
|
|
14
|
+
* <stashRoot>/.akm/proposals/archive/<id>/proposal.json
|
|
15
|
+
*
|
|
16
|
+
* One directory per proposal id (a stable `crypto.randomUUID()`), so multiple
|
|
17
|
+
* proposals can target the same `ref` without filesystem collisions.
|
|
18
|
+
*
|
|
19
|
+
* # Why direct fs (and not `writeAssetToSource`)
|
|
20
|
+
*
|
|
21
|
+
* The architectural rule "all writes go through `writeAssetToSource`" applies
|
|
22
|
+
* to *assets*. Proposals are **not** assets — they live outside the asset tree
|
|
23
|
+
* (under `.akm/proposals/`, parallel to how `events.jsonl` lives outside the
|
|
24
|
+
* asset tree). Routing them through `writeAssetToSource` would force them into
|
|
25
|
+
* a `TYPE_DIRS` slot, would commit them to git, and would leak unaccepted
|
|
26
|
+
* drafts through the normal indexer. None of that is what we want for queue
|
|
27
|
+
* state. The {@link promoteProposal} step is the bridge: it routes the
|
|
28
|
+
* accepted payload through `writeAssetToSource` so the actual asset write
|
|
29
|
+
* still funnels through the single dispatch point in
|
|
30
|
+
* `src/core/write-source.ts`.
|
|
31
|
+
*
|
|
32
|
+
* Direct `fs` IO here is deliberate and the only place in the v1 codebase
|
|
33
|
+
* that bypasses `writeAssetToSource` for "stash-adjacent" durable state. See
|
|
34
|
+
* CLAUDE.md ("Writes" section) for the contract.
|
|
35
|
+
*/
|
|
36
|
+
import { randomUUID } from "node:crypto";
|
|
37
|
+
import fs from "node:fs";
|
|
38
|
+
import path from "node:path";
|
|
39
|
+
import { makeAssetRef, parseAssetRef } from "./asset-ref";
|
|
40
|
+
import { resolveAssetPathFromName, TYPE_DIRS } from "./asset-spec";
|
|
41
|
+
import { NotFoundError, UsageError } from "./errors";
|
|
42
|
+
import { parseFrontmatter } from "./frontmatter";
|
|
43
|
+
import { lintLessonContent } from "./lesson-lint";
|
|
44
|
+
import { resolveWriteTarget, writeAssetToSource } from "./write-source";
|
|
45
|
+
// ── Path helpers ────────────────────────────────────────────────────────────
|
|
46
|
+
/**
|
|
47
|
+
* Resolve `<stashRoot>/.akm/proposals` (or its archive subdirectory). Direct
|
|
48
|
+
* fs paths because proposal storage is queue state, not asset state — see the
|
|
49
|
+
* module docblock for the architectural carve-out.
|
|
50
|
+
*/
|
|
51
|
+
export function getProposalsRoot(stashDir, archive = false) {
|
|
52
|
+
return archive ? path.join(stashDir, ".akm", "proposals", "archive") : path.join(stashDir, ".akm", "proposals");
|
|
53
|
+
}
|
|
54
|
+
function proposalDir(stashDir, id, archive) {
|
|
55
|
+
return path.join(getProposalsRoot(stashDir, archive), id);
|
|
56
|
+
}
|
|
57
|
+
function proposalFile(stashDir, id, archive) {
|
|
58
|
+
return path.join(proposalDir(stashDir, id, archive), "proposal.json");
|
|
59
|
+
}
|
|
60
|
+
function nowIso(ctx) {
|
|
61
|
+
const fn = ctx?.now ?? Date.now;
|
|
62
|
+
return new Date(fn()).toISOString();
|
|
63
|
+
}
|
|
64
|
+
function newId(ctx) {
|
|
65
|
+
const fn = ctx?.randomUUID ?? randomUUID;
|
|
66
|
+
return fn();
|
|
67
|
+
}
|
|
68
|
+
// ── Read / write primitives ─────────────────────────────────────────────────
|
|
69
|
+
function readProposalFile(filePath) {
|
|
70
|
+
let raw;
|
|
71
|
+
try {
|
|
72
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
throw new NotFoundError(`Proposal not found at ${filePath}.`, "FILE_NOT_FOUND", `The proposal file is missing or unreadable: ${err.message}`);
|
|
76
|
+
}
|
|
77
|
+
let parsed;
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(raw);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
throw new UsageError(`Proposal file at ${filePath} is not valid JSON: ${err.message}`, "INVALID_JSON_ARGUMENT", "Re-create the proposal or remove the corrupt file under .akm/proposals/<id>/.");
|
|
83
|
+
}
|
|
84
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
85
|
+
throw new UsageError(`Proposal file at ${filePath} is not a JSON object.`, "INVALID_JSON_ARGUMENT");
|
|
86
|
+
}
|
|
87
|
+
return parsed;
|
|
88
|
+
}
|
|
89
|
+
function writeProposalFile(filePath, proposal) {
|
|
90
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
91
|
+
fs.writeFileSync(filePath, `${JSON.stringify(proposal, null, 2)}\n`, "utf8");
|
|
92
|
+
}
|
|
93
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
94
|
+
/**
|
|
95
|
+
* Create a new pending proposal. The id is a stable random UUID, so two
|
|
96
|
+
* proposals with the same `ref` never collide on disk.
|
|
97
|
+
*/
|
|
98
|
+
export function createProposal(stashDir, input, ctx) {
|
|
99
|
+
// Validate the ref up front so callers get a clear error instead of a
|
|
100
|
+
// surprise during `accept`. This also normalises the ref string.
|
|
101
|
+
const parsedRef = parseAssetRef(input.ref);
|
|
102
|
+
const normalizedRef = makeAssetRef(parsedRef.type, parsedRef.name, parsedRef.origin);
|
|
103
|
+
const id = newId(ctx);
|
|
104
|
+
const created = nowIso(ctx);
|
|
105
|
+
const proposal = {
|
|
106
|
+
id,
|
|
107
|
+
ref: normalizedRef,
|
|
108
|
+
status: "pending",
|
|
109
|
+
source: input.source,
|
|
110
|
+
...(input.sourceRun !== undefined ? { sourceRun: input.sourceRun } : {}),
|
|
111
|
+
createdAt: created,
|
|
112
|
+
updatedAt: created,
|
|
113
|
+
payload: {
|
|
114
|
+
content: input.payload.content,
|
|
115
|
+
...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
writeProposalFile(proposalFile(stashDir, id, false), proposal);
|
|
119
|
+
return proposal;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* List every proposal under the stash. By default returns pending proposals
|
|
123
|
+
* from the live queue; pass `{ includeArchive: true }` to include rejected /
|
|
124
|
+
* accepted entries that have been moved aside.
|
|
125
|
+
*/
|
|
126
|
+
export function listProposals(stashDir, options = {}) {
|
|
127
|
+
const out = [];
|
|
128
|
+
const roots = [{ dir: getProposalsRoot(stashDir, false), archive: false }];
|
|
129
|
+
if (options.includeArchive) {
|
|
130
|
+
roots.push({ dir: getProposalsRoot(stashDir, true), archive: true });
|
|
131
|
+
}
|
|
132
|
+
for (const { dir } of roots) {
|
|
133
|
+
if (!fs.existsSync(dir))
|
|
134
|
+
continue;
|
|
135
|
+
let entries;
|
|
136
|
+
try {
|
|
137
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
for (const entry of entries) {
|
|
143
|
+
// Skip the archive subdirectory when iterating the live queue.
|
|
144
|
+
if (!entry.isDirectory())
|
|
145
|
+
continue;
|
|
146
|
+
if (entry.name === "archive")
|
|
147
|
+
continue;
|
|
148
|
+
const filePath = path.join(dir, entry.name, "proposal.json");
|
|
149
|
+
if (!fs.existsSync(filePath))
|
|
150
|
+
continue;
|
|
151
|
+
try {
|
|
152
|
+
out.push(readProposalFile(filePath));
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// Surface invalid proposal files via a synthetic stub so callers can
|
|
156
|
+
// see something in `akm proposal list` rather than the file
|
|
157
|
+
// disappearing silently.
|
|
158
|
+
out.push({
|
|
159
|
+
id: entry.name,
|
|
160
|
+
ref: "unknown:unknown",
|
|
161
|
+
status: "pending",
|
|
162
|
+
source: "invalid",
|
|
163
|
+
createdAt: "",
|
|
164
|
+
updatedAt: "",
|
|
165
|
+
payload: { content: "" },
|
|
166
|
+
review: {
|
|
167
|
+
outcome: "rejected",
|
|
168
|
+
reason: "Invalid proposal file (could not be parsed).",
|
|
169
|
+
decidedAt: "",
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return out
|
|
176
|
+
.filter((p) => (options.status ? p.status === options.status : true))
|
|
177
|
+
.filter((p) => (options.ref ? p.ref === options.ref : true))
|
|
178
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Look up a proposal by id. Searches the live queue first, then the archive.
|
|
182
|
+
* Throws `NotFoundError` when no match exists.
|
|
183
|
+
*/
|
|
184
|
+
export function getProposal(stashDir, id) {
|
|
185
|
+
const livePath = proposalFile(stashDir, id, false);
|
|
186
|
+
if (fs.existsSync(livePath))
|
|
187
|
+
return readProposalFile(livePath);
|
|
188
|
+
const archivedPath = proposalFile(stashDir, id, true);
|
|
189
|
+
if (fs.existsSync(archivedPath))
|
|
190
|
+
return readProposalFile(archivedPath);
|
|
191
|
+
throw new NotFoundError(`Proposal "${id}" not found.`, "FILE_NOT_FOUND");
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Whether a proposal currently lives in the archive (used by callers that
|
|
195
|
+
* need to know whether to look in the archive root for files / paths).
|
|
196
|
+
*/
|
|
197
|
+
export function isProposalArchived(stashDir, id) {
|
|
198
|
+
return !fs.existsSync(proposalFile(stashDir, id, false)) && fs.existsSync(proposalFile(stashDir, id, true));
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Move a proposal directory into the archive subtree and update its status.
|
|
202
|
+
* Used by both accept (status `accepted`) and reject (status `rejected`)
|
|
203
|
+
* paths so the live queue only contains pending entries.
|
|
204
|
+
*/
|
|
205
|
+
export function archiveProposal(stashDir, id, status, reason, ctx) {
|
|
206
|
+
const sourceDir = proposalDir(stashDir, id, false);
|
|
207
|
+
if (!fs.existsSync(sourceDir)) {
|
|
208
|
+
// If it's already archived, just update the metadata in place.
|
|
209
|
+
const archived = proposalFile(stashDir, id, true);
|
|
210
|
+
if (fs.existsSync(archived)) {
|
|
211
|
+
const existing = readProposalFile(archived);
|
|
212
|
+
const updated = {
|
|
213
|
+
...existing,
|
|
214
|
+
status,
|
|
215
|
+
updatedAt: nowIso(ctx),
|
|
216
|
+
review: {
|
|
217
|
+
outcome: status,
|
|
218
|
+
...(reason !== undefined ? { reason } : {}),
|
|
219
|
+
decidedAt: nowIso(ctx),
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
writeProposalFile(archived, updated);
|
|
223
|
+
return updated;
|
|
224
|
+
}
|
|
225
|
+
throw new NotFoundError(`Proposal "${id}" not found.`, "FILE_NOT_FOUND");
|
|
226
|
+
}
|
|
227
|
+
const targetDir = proposalDir(stashDir, id, true);
|
|
228
|
+
fs.mkdirSync(path.dirname(targetDir), { recursive: true });
|
|
229
|
+
fs.renameSync(sourceDir, targetDir);
|
|
230
|
+
const updated = {
|
|
231
|
+
...readProposalFile(proposalFile(stashDir, id, true)),
|
|
232
|
+
status,
|
|
233
|
+
updatedAt: nowIso(ctx),
|
|
234
|
+
review: {
|
|
235
|
+
outcome: status,
|
|
236
|
+
...(reason !== undefined ? { reason } : {}),
|
|
237
|
+
decidedAt: nowIso(ctx),
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
writeProposalFile(proposalFile(stashDir, id, true), updated);
|
|
241
|
+
return updated;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Validate a proposal payload before promotion. Generic by default — any
|
|
245
|
+
* proposal must parse cleanly and carry a non-empty body. Lessons get the
|
|
246
|
+
* extra per-type lint from {@link lintLessonContent} so the contract documented
|
|
247
|
+
* in v1 spec §13 is enforced at promotion time. Other asset types can hook
|
|
248
|
+
* here in the future without changing call sites.
|
|
249
|
+
*/
|
|
250
|
+
export function validateProposal(proposal) {
|
|
251
|
+
const findings = [];
|
|
252
|
+
if (!proposal.payload || typeof proposal.payload.content !== "string" || proposal.payload.content.trim() === "") {
|
|
253
|
+
findings.push({ kind: "empty-content", message: `Proposal ${proposal.id} has empty content.` });
|
|
254
|
+
}
|
|
255
|
+
let ref;
|
|
256
|
+
try {
|
|
257
|
+
ref = parseAssetRef(proposal.ref);
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
findings.push({
|
|
261
|
+
kind: "invalid-ref",
|
|
262
|
+
message: `Proposal ${proposal.id} has invalid ref "${proposal.ref}": ${err.message}`,
|
|
263
|
+
});
|
|
264
|
+
return { ok: false, findings };
|
|
265
|
+
}
|
|
266
|
+
// Generic frontmatter parse check for markdown-y types. If the content
|
|
267
|
+
// *looks* like it has frontmatter (`---\n…\n---`) we ensure it parses.
|
|
268
|
+
if (proposal.payload.content.startsWith("---")) {
|
|
269
|
+
try {
|
|
270
|
+
parseFrontmatter(proposal.payload.content);
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
findings.push({
|
|
274
|
+
kind: "invalid-frontmatter",
|
|
275
|
+
message: `Proposal ${proposal.id} frontmatter could not be parsed: ${err.message}`,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// Type-specific validators.
|
|
280
|
+
if (ref.type === "lesson") {
|
|
281
|
+
const lessonReport = lintLessonContent(proposal.payload.content, `proposal:${proposal.id}`);
|
|
282
|
+
for (const finding of lessonReport.findings) {
|
|
283
|
+
findings.push({ kind: finding.kind, message: finding.message });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return { ok: findings.length === 0, findings };
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Validate a proposal, then promote it through the canonical
|
|
290
|
+
* {@link writeAssetToSource} dispatch (the single place that branches on
|
|
291
|
+
* `source.kind`). On success the proposal directory is moved to the archive
|
|
292
|
+
* with status `accepted`. Validation failures throw a `UsageError` carrying
|
|
293
|
+
* every finding so the CLI can render a single clear error envelope.
|
|
294
|
+
*/
|
|
295
|
+
export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
|
|
296
|
+
const proposal = getProposal(stashDir, id);
|
|
297
|
+
if (proposal.status !== "pending") {
|
|
298
|
+
throw new UsageError(`Proposal ${id} is not pending (current status: ${proposal.status}). Only pending proposals can be accepted.`, "INVALID_FLAG_VALUE");
|
|
299
|
+
}
|
|
300
|
+
const report = validateProposal(proposal);
|
|
301
|
+
if (!report.ok) {
|
|
302
|
+
const message = report.findings.map((f) => `[${f.kind}] ${f.message}`).join("\n");
|
|
303
|
+
throw new UsageError(`Proposal ${id} failed validation:\n${message}`, "MISSING_REQUIRED_ARGUMENT", "Fix the proposal payload (frontmatter / content) and try again, or reject the proposal with a reason.");
|
|
304
|
+
}
|
|
305
|
+
const ref = parseAssetRef(proposal.ref);
|
|
306
|
+
if (!TYPE_DIRS[ref.type]) {
|
|
307
|
+
throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
|
|
308
|
+
}
|
|
309
|
+
const target = resolveWriteTarget(config, options.target);
|
|
310
|
+
const written = await writeAssetToSource(target.source, target.config, ref, proposal.payload.content);
|
|
311
|
+
const archived = archiveProposal(stashDir, id, "accepted", undefined, ctx);
|
|
312
|
+
return { proposal: archived, assetPath: written.path, ref: written.ref };
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Compute a diff between a proposal payload and the existing on-disk asset.
|
|
316
|
+
* Uses {@link resolveWriteTarget} to find where the asset would land — so the
|
|
317
|
+
* diff matches exactly what `accept` will write. Falls back to "new asset"
|
|
318
|
+
* when no asset is currently materialised at the target ref.
|
|
319
|
+
*/
|
|
320
|
+
export function diffProposal(stashDir, config, id, options = {}) {
|
|
321
|
+
const proposal = getProposal(stashDir, id);
|
|
322
|
+
const ref = parseAssetRef(proposal.ref);
|
|
323
|
+
let targetPath;
|
|
324
|
+
let existing = null;
|
|
325
|
+
try {
|
|
326
|
+
const target = resolveWriteTarget(config, options.target);
|
|
327
|
+
targetPath = resolveAssetFilePathSafe(target.source, ref);
|
|
328
|
+
if (targetPath && fs.existsSync(targetPath)) {
|
|
329
|
+
existing = fs.readFileSync(targetPath, "utf8");
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
// No writable target configured — still return a "new asset" diff so
|
|
334
|
+
// callers can see the proposed payload without erroring out.
|
|
335
|
+
}
|
|
336
|
+
const proposed = proposal.payload.content;
|
|
337
|
+
if (existing === null) {
|
|
338
|
+
return {
|
|
339
|
+
existing: null,
|
|
340
|
+
proposed,
|
|
341
|
+
unified: formatNewAssetDiff(proposal.ref, proposed),
|
|
342
|
+
isNew: true,
|
|
343
|
+
...(targetPath ? { targetPath } : {}),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
existing,
|
|
348
|
+
proposed,
|
|
349
|
+
unified: formatUnifiedDiff(existing, proposed, proposal.ref),
|
|
350
|
+
isNew: false,
|
|
351
|
+
...(targetPath ? { targetPath } : {}),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
function resolveAssetFilePathSafe(source, ref) {
|
|
355
|
+
const typeDir = TYPE_DIRS[ref.type];
|
|
356
|
+
if (!typeDir)
|
|
357
|
+
return undefined;
|
|
358
|
+
const typeRoot = path.join(source.path, typeDir);
|
|
359
|
+
try {
|
|
360
|
+
return resolveAssetPathFromName(ref.type, typeRoot, ref.name);
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return undefined;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Minimal unified-diff renderer. We deliberately avoid pulling a runtime
|
|
368
|
+
* dependency just for this — proposals diffs are usually small (a single
|
|
369
|
+
* lesson / skill file), so the LCS-free greedy renderer below is plenty for
|
|
370
|
+
* humans to review. The output mirrors `git diff --no-index` for the first
|
|
371
|
+
* `@@ … @@` hunk: enough to be familiar, not so detailed that we re-implement
|
|
372
|
+
* a full LCS table.
|
|
373
|
+
*/
|
|
374
|
+
export function formatUnifiedDiff(left, right, label) {
|
|
375
|
+
if (left === right)
|
|
376
|
+
return "";
|
|
377
|
+
const leftLines = left.split("\n");
|
|
378
|
+
const rightLines = right.split("\n");
|
|
379
|
+
const lines = [`--- ${label} (existing)`, `+++ ${label} (proposed)`];
|
|
380
|
+
// Pad to the longer side so alignment is one-to-one. Real diff tools use
|
|
381
|
+
// LCS to align matching runs; we don't need that fidelity for a review
|
|
382
|
+
// surface — both halves are visible regardless.
|
|
383
|
+
const max = Math.max(leftLines.length, rightLines.length);
|
|
384
|
+
lines.push(`@@ 1,${leftLines.length} 1,${rightLines.length} @@`);
|
|
385
|
+
for (let i = 0; i < max; i += 1) {
|
|
386
|
+
const l = leftLines[i];
|
|
387
|
+
const r = rightLines[i];
|
|
388
|
+
if (l === r && l !== undefined) {
|
|
389
|
+
lines.push(` ${l}`);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (l !== undefined)
|
|
393
|
+
lines.push(`-${l}`);
|
|
394
|
+
if (r !== undefined)
|
|
395
|
+
lines.push(`+${r}`);
|
|
396
|
+
}
|
|
397
|
+
return lines.join("\n");
|
|
398
|
+
}
|
|
399
|
+
function formatNewAssetDiff(ref, content) {
|
|
400
|
+
const lines = [`--- /dev/null`, `+++ ${ref} (proposed, new asset)`];
|
|
401
|
+
lines.push(`@@ 0,0 1,${content.split("\n").length} @@`);
|
|
402
|
+
for (const line of content.split("\n")) {
|
|
403
|
+
lines.push(`+${line}`);
|
|
404
|
+
}
|
|
405
|
+
return lines.join("\n");
|
|
406
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-level quiet/verbose flags for stderr warning gating.
|
|
3
|
+
*
|
|
4
|
+
* `quiet` is controlled by the CLI `--quiet`/`-q` flag.
|
|
5
|
+
* `verbose` is controlled by the CLI `--verbose` flag, with `AKM_VERBOSE`
|
|
6
|
+
* (env var) winning regardless: env > flag > default (false).
|
|
7
|
+
*/
|
|
8
|
+
let quiet = false;
|
|
9
|
+
let verbose = false;
|
|
10
|
+
export function setQuiet(value) {
|
|
11
|
+
quiet = value;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Reset the quiet flag to false.
|
|
15
|
+
* Intended for test teardown to prevent quiet state from leaking between tests.
|
|
16
|
+
*/
|
|
17
|
+
export function resetQuiet() {
|
|
18
|
+
quiet = false;
|
|
19
|
+
}
|
|
20
|
+
export function isQuiet() {
|
|
21
|
+
return quiet;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Set the verbose flag from a CLI flag. The `AKM_VERBOSE` env var, when set,
|
|
25
|
+
* always wins regardless of this flag (env > flag > default).
|
|
26
|
+
*/
|
|
27
|
+
export function setVerbose(value) {
|
|
28
|
+
verbose = value;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Reset the verbose flag to false. Intended for test teardown so verbose
|
|
32
|
+
* state does not leak between tests.
|
|
33
|
+
*/
|
|
34
|
+
export function resetVerbose() {
|
|
35
|
+
verbose = false;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Returns true when verbose output is requested.
|
|
39
|
+
*
|
|
40
|
+
* Precedence: `AKM_VERBOSE` env var (when truthy) > `setVerbose(true)` > false.
|
|
41
|
+
* Truthy matches `1`, `true`, `yes`, `on` (case-insensitive). The values
|
|
42
|
+
* `0`, `false`, `no`, `off` hard-disable verbose even if the flag is set,
|
|
43
|
+
* so operators can override per-invocation. Any other value (including
|
|
44
|
+
* empty string) is treated as "not set" and falls through to the flag.
|
|
45
|
+
*/
|
|
46
|
+
export function isVerbose() {
|
|
47
|
+
const env = process.env.AKM_VERBOSE?.trim().toLowerCase();
|
|
48
|
+
if (env === "1" || env === "true" || env === "yes" || env === "on")
|
|
49
|
+
return true;
|
|
50
|
+
if (env === "0" || env === "false" || env === "no" || env === "off")
|
|
51
|
+
return false;
|
|
52
|
+
return verbose;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Emit a warning to stderr unless --quiet is active.
|
|
56
|
+
* Drop-in replacement for console.warn() across the codebase.
|
|
57
|
+
*/
|
|
58
|
+
export function warn(...args) {
|
|
59
|
+
if (!quiet) {
|
|
60
|
+
console.warn(...args);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Emit a warning only when verbose output is requested. Use for noisy
|
|
65
|
+
* per-item diagnostics that should be replaced by a one-line summary at
|
|
66
|
+
* default verbosity (e.g. registry-content workflow validation errors).
|
|
67
|
+
*/
|
|
68
|
+
export function warnVerbose(...args) {
|
|
69
|
+
if (isVerbose()) {
|
|
70
|
+
warn(...args);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -30,6 +30,50 @@ import { ConfigError, UsageError } from "./errors";
|
|
|
30
30
|
* {@link assertWritableAllowedForKind}.
|
|
31
31
|
*/
|
|
32
32
|
const REJECTED_WRITABLE_KINDS = new Set(["website", "npm"]);
|
|
33
|
+
/**
|
|
34
|
+
* Maximum length of a sanitized git commit message. Git itself imposes no
|
|
35
|
+
* fixed limit, but message strings come from refs and `--message` flags that
|
|
36
|
+
* can be supplied by users or upstream config. A 4096-char clamp keeps audit
|
|
37
|
+
* trails readable and prevents pathological payloads from bloating the log
|
|
38
|
+
* stream a downstream consumer parses.
|
|
39
|
+
*/
|
|
40
|
+
const COMMIT_MESSAGE_MAX_LENGTH = 4096;
|
|
41
|
+
/**
|
|
42
|
+
* Sanitize a string before passing it as `git commit -m <message>`.
|
|
43
|
+
*
|
|
44
|
+
* Defenses, in order:
|
|
45
|
+
* 1. Strip NUL bytes (`\0`) — git rejects them anyway, but we never want
|
|
46
|
+
* them in argv.
|
|
47
|
+
* 2. Replace any CR/LF (`\r`, `\n`) and other ASCII control chars with a
|
|
48
|
+
* single space. This collapses newline-injection attempts that would
|
|
49
|
+
* otherwise turn a single-line commit subject into a forged trailer
|
|
50
|
+
* block.
|
|
51
|
+
* 3. Collapse runs of whitespace into a single space and trim.
|
|
52
|
+
* 4. Clamp to {@link COMMIT_MESSAGE_MAX_LENGTH} characters.
|
|
53
|
+
*
|
|
54
|
+
* If the result is empty after sanitization the caller should substitute a
|
|
55
|
+
* default — this helper returns `""` rather than throwing because not every
|
|
56
|
+
* callsite has a sensible "invalid input" exit code, and "empty" is a
|
|
57
|
+
* recoverable signal.
|
|
58
|
+
*/
|
|
59
|
+
export function sanitizeCommitMessage(input) {
|
|
60
|
+
if (typeof input !== "string")
|
|
61
|
+
return "";
|
|
62
|
+
// 1. Strip NULs outright.
|
|
63
|
+
let out = input.replace(/\0/g, "");
|
|
64
|
+
// 2. Replace CR/LF + other C0 control characters (0x00-0x1F, 0x7F) with a
|
|
65
|
+
// space. Tab (0x09) is included intentionally — commit subjects should
|
|
66
|
+
// be a single visual line.
|
|
67
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional sanitization
|
|
68
|
+
out = out.replace(/[\x00-\x1F\x7F]/g, " ");
|
|
69
|
+
// 3. Collapse whitespace runs and trim.
|
|
70
|
+
out = out.replace(/\s+/g, " ").trim();
|
|
71
|
+
// 4. Clamp length.
|
|
72
|
+
if (out.length > COMMIT_MESSAGE_MAX_LENGTH) {
|
|
73
|
+
out = out.slice(0, COMMIT_MESSAGE_MAX_LENGTH).trimEnd();
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
33
77
|
// ── Public helpers ──────────────────────────────────────────────────────────
|
|
34
78
|
/**
|
|
35
79
|
* Resolve the effective `writable` flag for a source config entry, applying
|
|
@@ -135,8 +179,18 @@ export function resolveWriteTarget(akmConfig, explicitTarget) {
|
|
|
135
179
|
// 2. config.defaultWriteTarget.
|
|
136
180
|
if (akmConfig.defaultWriteTarget) {
|
|
137
181
|
const match = configuredSources.find((s) => s.name === akmConfig.defaultWriteTarget);
|
|
138
|
-
if (match)
|
|
182
|
+
if (match) {
|
|
183
|
+
// BUG-H3: mirror the --target writability gate so a misconfigured
|
|
184
|
+
// defaultWriteTarget pointed at a non-writable kind (website/npm) or
|
|
185
|
+
// an explicit `writable: false` filesystem entry fails fast with a
|
|
186
|
+
// ConfigError, rather than surfacing as a generic UsageError after
|
|
187
|
+
// path-building has already begun.
|
|
188
|
+
const effectiveWritable = resolveWritable({ type: match.type, writable: match.writable });
|
|
189
|
+
if (!effectiveWritable) {
|
|
190
|
+
throw new ConfigError(`defaultWriteTarget "${akmConfig.defaultWriteTarget}" is not writable`, "INVALID_CONFIG_FILE", `Set \`writable: true\` on the "${akmConfig.defaultWriteTarget}" source in your config, or change \`defaultWriteTarget\` to a writable source.`);
|
|
191
|
+
}
|
|
139
192
|
return adaptConfiguredSource(match);
|
|
193
|
+
}
|
|
140
194
|
// Fall through if the named target no longer exists — surface a clear error.
|
|
141
195
|
throw new ConfigError(`defaultWriteTarget "${akmConfig.defaultWriteTarget}" does not match any configured source.`, "INVALID_CONFIG_FILE", "Update `defaultWriteTarget` in your config (run `akm config get defaultWriteTarget`) or run `akm list` to see configured sources.");
|
|
142
196
|
}
|
|
@@ -200,9 +254,14 @@ function runGitCommit(repoDir, filePath, message) {
|
|
|
200
254
|
if (addResult.status !== 0) {
|
|
201
255
|
throw new Error(`git add failed: ${addResult.stderr?.trim() || "unknown error"}`);
|
|
202
256
|
}
|
|
257
|
+
// Defense in depth: sanitize the commit subject one more time at the spawn
|
|
258
|
+
// boundary. Callers should already pass sanitized strings (via
|
|
259
|
+
// formatRefForMessage / saveGitStash), but this guards against future
|
|
260
|
+
// refactors that forget. Empty after sanitize falls back to a safe stub.
|
|
261
|
+
const safeMessage = sanitizeCommitMessage(message) || "akm update";
|
|
203
262
|
// Provide a fallback identity so fresh CI/test environments without
|
|
204
263
|
// user.name/user.email configured can always commit.
|
|
205
|
-
const commitResult = spawnSync("git", ["-C", repoDir, "-c", "user.name=akm", "-c", "user.email=akm@local", "commit", "-m",
|
|
264
|
+
const commitResult = spawnSync("git", ["-C", repoDir, "-c", "user.name=akm", "-c", "user.email=akm@local", "commit", "-m", safeMessage], { encoding: "utf8" });
|
|
206
265
|
if (commitResult.status !== 0) {
|
|
207
266
|
// `nothing to commit` is a no-op success — the file may have matched the
|
|
208
267
|
// existing tree exactly. Surface other errors verbatim.
|
|
@@ -221,7 +280,16 @@ function runGitPush(repoDir) {
|
|
|
221
280
|
}
|
|
222
281
|
}
|
|
223
282
|
function formatRefForMessage(ref) {
|
|
224
|
-
|
|
283
|
+
// Sanitize each component independently. `ref.origin` originates from user
|
|
284
|
+
// config and could contain CR/LF that would otherwise be smuggled into the
|
|
285
|
+
// commit subject and forge trailers downstream. `ref.type` and `ref.name`
|
|
286
|
+
// are also sanitized defensively — the asset-spec validator should already
|
|
287
|
+
// reject control bytes there, but a single sanitizer at the boundary keeps
|
|
288
|
+
// the contract explicit and centralized.
|
|
289
|
+
const origin = ref.origin ? sanitizeCommitMessage(ref.origin) : "";
|
|
290
|
+
const type = sanitizeCommitMessage(ref.type);
|
|
291
|
+
const name = sanitizeCommitMessage(ref.name);
|
|
292
|
+
return origin ? `${origin}//${type}:${name}` : `${type}:${name}`;
|
|
225
293
|
}
|
|
226
294
|
/**
|
|
227
295
|
* Derive a {@link WriteTargetSource} + persisted {@link SourceConfigEntry}
|
|
@@ -241,8 +309,15 @@ function adaptConfiguredSource(runtime) {
|
|
|
241
309
|
throw new ConfigError(`Source "${runtime.name}" has no resolvable on-disk path; writes are unsupported for this entry.`, "INVALID_CONFIG_FILE");
|
|
242
310
|
}
|
|
243
311
|
// Map the runtime kind to the write helper's `kind` discriminator. Only
|
|
244
|
-
// filesystem and git produce writable sources at v1
|
|
245
|
-
|
|
312
|
+
// filesystem and git produce writable sources at v1; any other kind
|
|
313
|
+
// reaching this point is a config-loader bug (assertWritableAllowedForKind
|
|
314
|
+
// should have rejected it). Throw a ConfigError rather than silently
|
|
315
|
+
// forwarding an unsupported kind.
|
|
316
|
+
if (runtime.type !== "filesystem" && runtime.type !== "git") {
|
|
317
|
+
throw new ConfigError(`write-source: source "${runtime.name}" has unsupported kind "${runtime.type}" for writes. ` +
|
|
318
|
+
"Writes are only defined for `filesystem` and `git` sources.", "INVALID_CONFIG_FILE", 'Use `kind: "filesystem"` or `kind: "git"` for writable sources.');
|
|
319
|
+
}
|
|
320
|
+
const kind = runtime.type;
|
|
246
321
|
const config = {
|
|
247
322
|
type: runtime.type,
|
|
248
323
|
name: runtime.name,
|