akm-cli 0.9.17-alpha.2 → 0.9.17-alpha.4
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 +756 -0
- package/dist/akm +94 -196
- package/dist/cli/shared.js +6 -2
- package/dist/cli.js +22 -9
- package/dist/commands/agent/agent-dispatch.js +1 -1
- package/dist/commands/command/command-execution.js +24 -62
- package/dist/commands/feedback-cli.js +0 -1
- package/dist/commands/health/accept-rate.js +2 -2
- package/dist/commands/health/checks.js +30 -75
- package/dist/commands/health/config-skew.js +38 -0
- package/dist/commands/health/egress.js +54 -0
- package/dist/commands/health/html-report.js +0 -38
- package/dist/commands/health/improve-metrics.js +123 -562
- package/dist/commands/health/plugin-staleness.js +53 -3
- package/dist/commands/health/renderers.js +12 -4
- package/dist/commands/health/report-view-model.js +11 -106
- package/dist/commands/health/types-improve.js +4 -19
- package/dist/commands/health/windows.js +64 -73
- package/dist/commands/health.js +122 -143
- package/dist/commands/improve/consolidate/chunking.js +25 -100
- package/dist/commands/improve/consolidate/sanitize.js +54 -149
- package/dist/commands/improve/consolidate.js +538 -1075
- package/dist/commands/improve/content-hash.js +16 -24
- package/dist/commands/improve/distill/content-repair.js +18 -100
- package/dist/commands/improve/distill-guards.js +20 -81
- package/dist/commands/improve/distill-promotion-policy.js +23 -243
- package/dist/commands/improve/distill.js +608 -1075
- package/dist/commands/improve/eligibility.js +126 -400
- package/dist/commands/improve/execution.js +3 -5
- package/dist/commands/improve/extract.js +487 -1046
- package/dist/commands/improve/feedback-valence.js +0 -25
- package/dist/commands/improve/improve-cli.js +29 -166
- package/dist/commands/improve/improve-result-file.js +10 -66
- package/dist/commands/improve/improve-strategies.js +12 -7
- package/dist/commands/improve/improve-usage-report.js +18 -64
- package/dist/commands/improve/improve.js +443 -1063
- package/dist/commands/improve/ledger.js +114 -0
- package/dist/commands/improve/locks.js +2 -8
- package/dist/commands/improve/loop-stages.js +459 -1172
- package/dist/commands/improve/memory/derived-ref.js +12 -77
- package/dist/commands/improve/memory/memory-belief.js +14 -118
- package/dist/commands/improve/memory/memory-improve.js +4 -3
- package/dist/commands/improve/outcome-loop.js +28 -156
- package/dist/commands/improve/planner.js +5 -10
- package/dist/commands/improve/preparation.js +851 -2339
- package/dist/commands/improve/proactive-maintenance.js +34 -101
- package/dist/commands/improve/reflect-noise.js +104 -280
- package/dist/commands/improve/reflect.js +621 -1367
- package/dist/commands/improve/salience.js +46 -232
- package/dist/commands/improve/session-asset.js +19 -100
- package/dist/commands/improve/stage.js +323 -0
- package/dist/commands/proposal/drain.js +251 -644
- package/dist/commands/proposal/proposal-cli.js +3 -18
- package/dist/commands/proposal/proposal-types.js +20 -41
- package/dist/commands/proposal/proposal.js +1 -2
- package/dist/commands/proposal/propose.js +134 -160
- package/dist/commands/proposal/repository.js +502 -1487
- package/dist/commands/proposal/validators/proposal-quality-validators.js +71 -174
- package/dist/commands/proposal/validators/proposal-validators.js +1 -1
- package/dist/commands/proposal/validators/proposals.js +13 -89
- package/dist/commands/read/curate.js +63 -413
- package/dist/commands/read/search-cli.js +16 -33
- package/dist/commands/read/search.js +17 -23
- package/dist/commands/read/show.js +2 -13
- package/dist/commands/sources/bundle-cli.js +25 -2
- package/dist/commands/sources/bundle-config-ops.js +7 -0
- package/dist/commands/sources/dangerous-env-audit.js +1 -2
- package/dist/commands/sources/info.js +2 -11
- package/dist/commands/sources/installed-stashes.js +197 -746
- package/dist/commands/sources/schema-repair.js +98 -129
- package/dist/commands/sources/source-add.js +62 -12
- package/dist/commands/sources/stash-cli.js +1 -1
- package/dist/commands/tasks/explain.js +10 -13
- package/dist/commands/tasks/tasks-cli.js +9 -8
- package/dist/commands/tasks/tasks.js +326 -930
- package/dist/commands/tasks/validate.js +42 -21
- package/dist/commands/workflow/plan.js +22 -29
- package/dist/commands/workflow-cli.js +4 -4
- package/dist/core/adapter/adapters/akm-adapter.js +0 -1
- package/dist/core/adapter/adapters/akm-lint.js +2 -3
- package/dist/core/adapter/adapters/akm-metadata.js +11 -12
- package/dist/core/adapter/adapters/akm-workflow-adapter.js +1 -1
- package/dist/core/adapter/execution-source.js +17 -29
- package/dist/core/asset/resolve-ref.js +1 -1
- package/dist/core/bundle-id.js +42 -5
- package/dist/core/bundle-rename.js +291 -0
- package/dist/core/config/config-io.js +1 -2
- package/dist/core/config/config-schema.js +1 -33
- package/dist/core/config/config-walker.js +1 -1
- package/dist/core/config/config.js +163 -68
- package/dist/core/config/legacy-source-shape-shim.js +38 -9
- package/dist/core/config/schema/embedding.js +20 -5
- package/dist/core/config/schema/engines.js +5 -0
- package/dist/core/config/schema/execution.js +1 -1
- package/dist/core/config/schema/experimental.js +1 -1
- package/dist/core/config/schema/improve-processes.js +21 -95
- package/dist/core/config/schema/improve.js +4 -42
- package/dist/core/config/schema/scheduler.js +12 -12
- package/dist/core/config/schema/search.js +6 -22
- package/dist/core/env-secret-ref.js +0 -1
- package/dist/core/errors.js +8 -9
- package/dist/core/file-lock.js +76 -173
- package/dist/core/logs-db.js +2 -2
- package/dist/core/paths.js +0 -27
- package/dist/core/redaction.js +109 -2
- package/dist/core/run-lock.js +2 -5
- package/dist/core/spawn-env.js +1 -1
- package/dist/core/state/migrations.js +108 -61
- package/dist/core/state-db-scope.js +2 -4
- package/dist/core/state-db.js +126 -692
- package/dist/core/type-presentation.js +1 -9
- package/dist/core/write-source.js +293 -1012
- package/dist/execution/input-contract.js +1 -1
- package/dist/execution/resolved-request.js +135 -689
- package/dist/execution/source.js +63 -257
- package/dist/execution/target-ref.js +1 -1
- package/dist/indexer/bundle-identity-guard.js +2 -2
- package/dist/indexer/db/graph-db.js +106 -46
- package/dist/indexer/ensure-index.js +44 -85
- package/dist/indexer/graph/graph-extraction.js +340 -562
- package/dist/indexer/graph/graph-related.js +130 -0
- package/dist/indexer/index-rebuild-lock.js +3 -11
- package/dist/indexer/index-writer-lock.js +8 -17
- package/dist/indexer/index-written-assets.js +139 -151
- package/dist/indexer/indexer.js +524 -846
- package/dist/indexer/materialize-embeddings.js +60 -397
- package/dist/indexer/passes/memory-inference.js +81 -90
- package/dist/indexer/passes/metadata.js +132 -200
- package/dist/indexer/read-preflight.js +0 -7
- package/dist/indexer/scan/doc-to-entry.js +1 -3
- package/dist/indexer/scan/drain-dir.js +1 -1
- package/dist/indexer/search/db-search.js +181 -590
- package/dist/indexer/search/fts-query.js +30 -41
- package/dist/indexer/search/ranking.js +28 -154
- package/dist/indexer/search/search-attribution.js +12 -32
- package/dist/indexer/search/search-fields.js +11 -15
- package/dist/indexer/search/search-hit-enrichers.js +54 -85
- package/dist/indexer/search/search-source.js +1 -4
- package/dist/indexer/usage/usage-events.js +2 -7
- package/dist/integrations/agent/engine-fallback.js +23 -40
- package/dist/integrations/agent/engine-resolution.js +93 -183
- package/dist/integrations/agent/execution.js +507 -0
- package/dist/integrations/agent/model-map.js +28 -156
- package/dist/integrations/agent/request-lowering.js +66 -141
- package/dist/integrations/agent/runner-dispatch.js +143 -321
- package/dist/integrations/agent/runner.js +54 -14
- package/dist/integrations/lockfile.js +53 -101
- package/dist/llm/embedders/deterministic.js +2 -3
- package/dist/llm/embedders/profile.js +71 -0
- package/dist/llm/embedders/remote.js +10 -15
- package/dist/llm/graph-extract.js +3 -12
- package/dist/llm/index-passes.js +3 -5
- package/dist/llm/memory-infer.js +1 -2
- package/dist/llm/metadata-enhance.js +1 -2
- package/dist/llm/structured-call.js +5 -24
- package/dist/output/generic-render.js +23 -11
- package/dist/output/html-render.js +13 -10
- package/dist/output/render-registry.js +3 -32
- package/dist/output/shapes/helpers.js +2 -34
- package/dist/output/shapes/passthrough.js +1 -9
- package/dist/{indexer/search/ranking-types.js → output/text/bundle-rename.js} +4 -1
- package/dist/output/text/command-format.js +60 -23
- package/dist/output/text/helpers.js +1 -1
- package/dist/output/text/migrate.js +5 -14
- package/dist/output/text/proposal-format.js +1 -2
- package/dist/output/text/workflow-format.js +0 -32
- package/dist/output/text.js +2 -0
- package/dist/registry/factory.js +4 -19
- package/dist/registry/network.js +66 -220
- package/dist/registry/providers/index.js +0 -2
- package/dist/registry/providers/skills-sh.js +3 -14
- package/dist/registry/providers/static-index.js +24 -26
- package/dist/registry/resolve.js +55 -131
- package/dist/scripts/akm-migrate-node.js +43937 -93313
- package/dist/scripts/akm-migrate.js +43697 -93071
- package/dist/setup/registry-stash-loader.js +4 -13
- package/dist/setup/semantic-assets.js +3 -44
- package/dist/setup/setup.js +1 -1
- package/dist/setup/steps/tasks.js +25 -15
- package/dist/sources/provider-factory.js +17 -18
- package/dist/sources/providers/filesystem.js +2 -3
- package/dist/sources/providers/git-install.js +7 -1
- package/dist/sources/providers/git-provider.js +0 -3
- package/dist/sources/providers/git-stash.js +0 -17
- package/dist/sources/providers/npm.js +2 -4
- package/dist/sources/providers/provider-utils.js +5 -10
- package/dist/sources/providers/website.js +0 -2
- package/dist/sources/snapshot-fetchers/website-ingest.js +1 -1
- package/dist/sources/website-url.js +2 -2
- package/dist/storage/database.js +9 -35
- package/dist/storage/repositories/improve-ledger-repository.js +168 -0
- package/dist/storage/repositories/index-connection.js +34 -70
- package/dist/storage/repositories/index-entries-repository.js +69 -111
- package/dist/storage/repositories/index-entry-mapper.js +1 -2
- package/dist/storage/repositories/index-entry-schema.js +83 -269
- package/dist/storage/repositories/index-fts-repository.js +86 -256
- package/dist/storage/repositories/index-llm-cache-repository.js +17 -0
- package/dist/storage/repositories/index-meta-repository.js +6 -4
- package/dist/storage/repositories/index-schema.js +192 -220
- package/dist/storage/repositories/index-utility-repository.js +8 -29
- package/dist/storage/repositories/index-vec-repository.js +133 -414
- package/dist/storage/repositories/outcome-repository.js +2 -1
- package/dist/storage/repositories/proposals-repository.js +35 -0
- package/dist/storage/repositories/registry-index-cache-repository.js +100 -0
- package/dist/storage/repositories/task-history-repository.js +26 -4
- package/dist/storage/repositories/workflow-runs-repository.js +53 -244
- package/dist/storage/sqlite-migrations.js +136 -0
- package/dist/storage/sqlite-pragmas.js +11 -9
- package/dist/storage/sqlite-transaction.js +170 -0
- package/dist/storage/state-db-integrity.js +34 -27
- package/dist/tasks/activation-config.js +134 -62
- package/dist/tasks/backends/cron.js +129 -277
- package/dist/tasks/backends/exec-utils.js +2 -5
- package/dist/tasks/backends/launchd.js +125 -745
- package/dist/tasks/backends/schtasks.js +101 -620
- package/dist/tasks/prepare/prepare-support.js +5 -15
- package/dist/tasks/prepare/prepare.js +0 -2
- package/dist/tasks/resolve-akm-bin.js +20 -79
- package/dist/tasks/run/attempt-lifecycle.js +0 -1
- package/dist/tasks/scheduler-binding.js +18 -238
- package/dist/tasks/scheduler-invocation.js +52 -52
- package/dist/tasks/scheduler-lock.js +53 -0
- package/dist/tasks/scheduler-sync.js +363 -679
- package/dist/tasks/source/parse-task-source.js +160 -10
- package/dist/tasks/source/task-source-v3-frozen.js +3 -4
- package/dist/tasks/source/task-to-v4.js +2 -2
- package/dist/workflows/authoring/authoring.js +3 -12
- package/dist/workflows/compile.js +211 -0
- package/dist/workflows/concurrency-policy.js +13 -74
- package/dist/workflows/exec/child-invocation.js +3 -17
- package/dist/workflows/exec/child-workflow.js +32 -141
- package/dist/workflows/exec/dispatch-redaction.js +13 -53
- package/dist/workflows/exec/environment.js +98 -0
- package/dist/workflows/exec/exec-unit.js +33 -140
- package/dist/workflows/exec/frozen-judge.js +7 -59
- package/dist/workflows/exec/native-executor.js +82 -341
- package/dist/workflows/exec/param-secrets.js +29 -47
- package/dist/workflows/exec/run-workflow.js +154 -387
- package/dist/workflows/exec/scheduler.js +9 -36
- package/dist/workflows/exec/step-work.js +127 -430
- package/dist/workflows/exec/unit-dispatch.js +11 -63
- package/dist/workflows/exec/unit-writer.js +8 -52
- package/dist/workflows/exec/worktree.js +39 -273
- package/dist/workflows/freeze/child-output-references.js +4 -15
- package/dist/workflows/freeze/environment.js +99 -92
- package/dist/workflows/freeze/freeze.js +172 -0
- package/dist/workflows/freeze/step-values.js +19 -21
- package/dist/workflows/freeze/targets/child-workflow.js +23 -92
- package/dist/workflows/freeze/targets/command.js +10 -33
- package/dist/workflows/freeze/targets/script.js +5 -12
- package/dist/workflows/freeze/targets/shell.js +3 -6
- package/dist/workflows/freeze/targets/task.js +25 -80
- package/dist/workflows/freeze/task-bindings.js +20 -67
- package/dist/workflows/{source-ir/github-yaml.js → github-yaml.js} +88 -206
- package/dist/workflows/ir/params.js +6 -51
- package/dist/workflows/ir/plan-hash.js +2 -34
- package/dist/workflows/parser.js +140 -43
- package/dist/{commands/improve/consolidate/types.js → workflows/plan.js} +2 -1
- package/dist/workflows/renderer.js +36 -69
- package/dist/workflows/resource-limits.js +12 -120
- package/dist/workflows/runtime/agent-identity.js +8 -40
- package/dist/workflows/runtime/run-outputs.js +3 -6
- package/dist/workflows/runtime/run-plan.js +316 -0
- package/dist/workflows/runtime/runs.js +48 -200
- package/dist/workflows/runtime/workflow-asset-loader.js +24 -57
- package/dist/workflows/{source-ir/semantics.js → source-semantics.js} +16 -20
- package/dist/workflows/validate-summary.js +2 -7
- package/docs/integration/bundling-akm.md +49 -42
- package/docs/migration/README.md +1 -0
- package/docs/migration/release-notes/0.9.17.md +41 -0
- package/docs/migration/v0.9.1-to-v0.9.2.md +19 -7
- package/docs/reference/cli.md +182 -125
- package/docs/reference/configuration.md +49 -56
- package/docs/reference/data-and-telemetry.md +19 -20
- package/docs/reference/tasks.md +86 -38
- package/docs/reference/workflow-schema.md +14 -18
- package/docs/reference/workflows.md +6 -9
- package/package.json +1 -1
- package/schemas/akm-config.json +87 -406
- package/dist/commands/health/advisories.js +0 -150
- package/dist/commands/health/metrics.js +0 -329
- package/dist/commands/health/surfaces.js +0 -102
- package/dist/commands/improve/anti-collapse.js +0 -83
- package/dist/commands/improve/collapse-detector.js +0 -432
- package/dist/commands/improve/consolidate/eligibility.js +0 -48
- package/dist/commands/improve/consolidate/merge.js +0 -146
- package/dist/commands/improve/distill/promote-memory.js +0 -329
- package/dist/commands/improve/distill/quality-gate.js +0 -500
- package/dist/commands/improve/memory/memory-contradiction-detect.js +0 -291
- package/dist/commands/improve/proposal-envelope.js +0 -31
- package/dist/commands/improve/run-context.js +0 -123
- package/dist/commands/improve/shared.js +0 -21
- package/dist/commands/improve/source-identity.js +0 -28
- package/dist/commands/improve/triage.js +0 -96
- package/dist/commands/proposal/drain-policies.js +0 -151
- package/dist/commands/sources/update-transaction.js +0 -220
- package/dist/core/action-contributors.js +0 -28
- package/dist/core/config/config-version-shim.js +0 -101
- package/dist/core/config/retired-experimental-keys-shim.js +0 -62
- package/dist/core/fs-txn.js +0 -405
- package/dist/core/lexical-score.js +0 -25
- package/dist/core/maintenance-barrier.js +0 -167
- package/dist/execution/executable-identity.js +0 -105
- package/dist/execution/guarded-source.js +0 -427
- package/dist/indexer/graph/graph-boost.js +0 -427
- package/dist/indexer/graph/graph-dedup.js +0 -95
- package/dist/indexer/search/name-match.js +0 -35
- package/dist/indexer/search/ranking-contributors.js +0 -515
- package/dist/indexer/walk/project-context.js +0 -192
- package/dist/integrations/agent/execution-cascade.js +0 -566
- package/dist/integrations/agent/execution-definitions.js +0 -202
- package/dist/integrations/agent/execution-lowering.js +0 -841
- package/dist/integrations/agent/execution-preparation.js +0 -98
- package/dist/integrations/agent/inline-execution.js +0 -74
- package/dist/registry/create-provider-registry.js +0 -29
- package/dist/registry/pinned-request-helper.js +0 -247
- package/dist/registry/pinned-transport.js +0 -717
- package/dist/sources/providers/index.js +0 -14
- package/dist/storage/engines/sqlite-migrations.js +0 -271
- package/dist/storage/repositories/canaries-repository.js +0 -107
- package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
- package/dist/storage/repositories/registry-cache.js +0 -113
- package/dist/tasks/scheduler-sync-preview.js +0 -52
- package/dist/workflows/freeze/resolve-steps.js +0 -86
- package/dist/workflows/freeze/source-freeze.js +0 -64
- package/dist/workflows/ir/compile.js +0 -321
- package/dist/workflows/ir/environment-v4.js +0 -330
- package/dist/workflows/ir/freeze-v4.js +0 -153
- package/dist/workflows/ir/schema-v4.js +0 -745
- package/dist/workflows/ir/schema.js +0 -354
- package/dist/workflows/program/schema.js +0 -78
- package/dist/workflows/runtime/checkin.js +0 -57
- package/dist/workflows/runtime/plan-classifier.js +0 -196
- package/dist/workflows/runtime/unit-checkin.js +0 -45
- package/dist/workflows/runtime/unit-phases.js +0 -20
- package/dist/workflows/schema.js +0 -4
- package/dist/workflows/source-ir/compile.js +0 -200
- package/dist/workflows/source-ir/program.js +0 -50
- package/dist/workflows/source-ir/result.js +0 -26
- package/dist/workflows/source-ir/schema.js +0 -786
- package/dist/workflows/source-ir/triggers.js +0 -79
- package/dist/workflows/source-ir/uses.js +0 -40
- package/dist/workflows/validator.js +0 -60
|
@@ -2,32 +2,32 @@
|
|
|
2
2
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
4
|
/**
|
|
5
|
-
* write-source —
|
|
5
|
+
* write-source — resolve a write target and publish writes to it.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* command-layer helper that does a plain filesystem write for **every** kind.
|
|
7
|
+
* The only module that branches on `source.kind` for writes. It does two
|
|
8
|
+
* things:
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
* `
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
10
|
+
* 1. Resolve the destination: `--target` → `defaultWriteTarget` → working
|
|
11
|
+
* stash (`defaultBundle`). `writable` defaults to true on `filesystem` and
|
|
12
|
+
* false on `git`; `website` / `npm` are never writable (the config loader
|
|
13
|
+
* rejects `writable: true` on them).
|
|
14
|
+
* 2. Publish: write the file atomically inside the bundle root, and for a
|
|
15
|
+
* git-backed target commit exactly the operation's paths once at the
|
|
16
|
+
* boundary ({@link commitWriteTargetBoundary} → `saveGitStash`), pushing
|
|
17
|
+
* with `--force-with-lease` when the target is writable and has an
|
|
18
|
+
* upstream.
|
|
18
19
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* writable git target.
|
|
20
|
+
* Nothing commits per asset (issue #507). Callers write and delete through
|
|
21
|
+
* {@link writeAssetToSource} / {@link deleteAssetFromSource}, then fire
|
|
22
|
+
* {@link commitWriteTargetBoundary} once — or wrap a custom mutation in
|
|
23
|
+
* {@link withWriteTargetMutation}, which does both under the asset lease.
|
|
24
24
|
*/
|
|
25
25
|
import fs from "node:fs";
|
|
26
26
|
import path from "node:path";
|
|
27
27
|
import { withAssetMutationLeaseSync } from "../indexer/index-writer-lock.js";
|
|
28
28
|
import { lockContentRootFor } from "../integrations/lockfile.js";
|
|
29
29
|
import { GitStashPushError, getCachePaths, inspectGitUpstream, isGitBackedStash, parseGitRepoUrl, runGit, saveGitStash, } from "../sources/providers/git.js";
|
|
30
|
-
import {
|
|
30
|
+
import { listIgnoredExactPaths } from "../sources/providers/git-stash.js";
|
|
31
31
|
import { detectAdapterId } from "./adapter/detect-adapter.js";
|
|
32
32
|
import { ensureAkmMarkdownType } from "./asset/akm-markdown.js";
|
|
33
33
|
import { assetPathForName, stashDirFor } from "./asset/asset-placement.js";
|
|
@@ -39,868 +39,26 @@ import { ConfigError, UsageError } from "./errors.js";
|
|
|
39
39
|
import { sanitizeCommitMessage } from "./git-message.js";
|
|
40
40
|
import { warn, warnOnce } from "./warn.js";
|
|
41
41
|
import { recordWrittenPath } from "./write-provenance.js";
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
* else is rejected at config load (per locked decision 4) — see
|
|
45
|
-
* {@link assertWritableAllowedForKind}.
|
|
46
|
-
*/
|
|
47
|
-
const REJECTED_WRITABLE_KINDS = new Set(["website", "npm"]);
|
|
48
|
-
const pendingGitMutations = new Map();
|
|
49
|
-
const EMPTY_GIT_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
50
|
-
const GIT_PUSH_TIMEOUT_MS = 120_000;
|
|
51
|
-
function gitTargetKey(source) {
|
|
52
|
-
return `${path.resolve(source.repoPath ?? source.path)}\0${path.resolve(source.path)}`;
|
|
53
|
-
}
|
|
54
|
-
/** Record the exact post-mutation blob for the target's boundary commit. */
|
|
55
|
-
export function recordWriteTargetPath(source, filePath) {
|
|
56
|
-
if (source.kind !== "git")
|
|
57
|
-
return;
|
|
58
|
-
const repoDir = path.resolve(source.repoPath ?? source.path);
|
|
59
|
-
if (!isGitBackedStash(repoDir))
|
|
60
|
-
return;
|
|
61
|
-
const key = gitTargetKey(source);
|
|
62
|
-
const pending = pendingGitMutations.get(key) ?? {
|
|
63
|
-
baseHead: readOptionalGitHead(repoDir),
|
|
64
|
-
snapshots: {},
|
|
65
|
-
};
|
|
66
|
-
const snapshot = captureGitPathSnapshot({ source, config: { type: "git" } }, filePath);
|
|
67
|
-
pending.snapshots[snapshot.path] = snapshot.state;
|
|
68
|
-
pendingGitMutations.set(key, pending);
|
|
69
|
-
}
|
|
70
|
-
function repoRelativeGitPath(source, filePath) {
|
|
71
|
-
const repoDir = path.resolve(source.repoPath ?? source.path);
|
|
72
|
-
const absolutePath = path.resolve(filePath);
|
|
73
|
-
const [relativePath] = normalizePublicationPaths([path.relative(repoDir, absolutePath).replaceAll(path.sep, "/")]);
|
|
74
|
-
return relativePath;
|
|
75
|
-
}
|
|
76
|
-
/** Reject dirty or ignored exact paths before a direct transaction mutation. */
|
|
77
|
-
export function assertWriteTargetPathsClean(source, filePaths) {
|
|
78
|
-
if (source.kind !== "git")
|
|
79
|
-
return;
|
|
80
|
-
const repoDir = path.resolve(source.repoPath ?? source.path);
|
|
81
|
-
if (!isGitBackedStash(repoDir))
|
|
82
|
-
return;
|
|
83
|
-
const key = gitTargetKey(source);
|
|
84
|
-
const pending = pendingGitMutations.get(key);
|
|
85
|
-
for (const filePath of filePaths) {
|
|
86
|
-
const relativePath = repoRelativeGitPath(source, filePath);
|
|
87
|
-
if (pending && Object.hasOwn(pending.snapshots, relativePath)) {
|
|
88
|
-
preflightGitPathMutation(source, filePath);
|
|
89
|
-
}
|
|
90
|
-
else {
|
|
91
|
-
assertGitExactPathsClean(repoDir, [relativePath]);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
/** Preflight one operation's complete exact-path set before its first mutation. */
|
|
96
|
-
export function planWriteTargetPublication(target, filePaths, options) {
|
|
97
|
-
const paths = [...new Set(filePaths.map((filePath) => path.resolve(filePath)))];
|
|
98
|
-
assertNoWritePathDescendantSymlinks(target.source, paths);
|
|
99
|
-
if (target.source.kind !== "git")
|
|
100
|
-
return { target, paths, publish: false };
|
|
101
|
-
const repoDir = path.resolve(target.source.repoPath ?? target.source.path);
|
|
102
|
-
if (!isGitBackedStash(repoDir))
|
|
103
|
-
return { target, paths, publish: false };
|
|
104
|
-
const expectedBaseHead = readOptionalGitHead(repoDir);
|
|
105
|
-
const relativePaths = paths.map((filePath) => repoRelativeGitPath(target.source, filePath));
|
|
106
|
-
const ignored = new Set(listIgnoredExactPaths(repoDir, relativePaths));
|
|
107
|
-
if (ignored.size > 0) {
|
|
108
|
-
if (options.ignored === "reject") {
|
|
109
|
-
throw new UsageError(`Exact Git publication path is ignored: ${[...ignored][0]}. Update .gitignore or choose a tracked destination before writing.`);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
const unignoredPaths = paths.filter((_, index) => !ignored.has(relativePaths[index]));
|
|
113
|
-
assertWriteTargetPathsClean(target.source, unignoredPaths);
|
|
114
|
-
// Not re-verified against `expectedBaseHead` here: any drift during the
|
|
115
|
-
// ignored/clean-path checks above is caught by the SAME expectedBaseHead
|
|
116
|
-
// comparison `beginWriteTargetMutation` runs immediately before the first
|
|
117
|
-
// mutation (its documented contract, and every real caller calls it right
|
|
118
|
-
// after this function returns) -- checking it again here a few lines
|
|
119
|
-
// earlier for the exact same value only duplicated that check, not added
|
|
120
|
-
// a new one. `publishWriteTargetPlan` independently re-checks a third time
|
|
121
|
-
// after the actual mutation, which is a genuinely different point in time
|
|
122
|
-
// and stays.
|
|
123
|
-
return { target, paths, publish: ignored.size === 0, expectedBaseHead };
|
|
124
|
-
}
|
|
125
|
-
function assertNoWritePathDescendantSymlinks(source, filePaths) {
|
|
126
|
-
const lexicalRoot = path.resolve(source.path);
|
|
127
|
-
const canonicalRoot = fs.realpathSync(lexicalRoot);
|
|
128
|
-
for (const filePath of filePaths) {
|
|
129
|
-
const relative = path.relative(lexicalRoot, filePath);
|
|
130
|
-
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
131
|
-
throw new UsageError(`Write path resolves outside source "${source.name}".`, "PATH_ESCAPE_VIOLATION");
|
|
132
|
-
}
|
|
133
|
-
let current = canonicalRoot;
|
|
134
|
-
for (const segment of relative.split(path.sep)) {
|
|
135
|
-
current = path.join(current, segment);
|
|
136
|
-
try {
|
|
137
|
-
if (fs.lstatSync(current).isSymbolicLink()) {
|
|
138
|
-
throw new UsageError(`Write path contains a symbolic link below source "${source.name}": ${relative}`);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
catch (error) {
|
|
142
|
-
if (error.code === "ENOENT")
|
|
143
|
-
break;
|
|
144
|
-
throw error;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
function assertWriteTargetPlanBase(target, expectedBaseHead) {
|
|
150
|
-
const repoDir = path.resolve(target.source.repoPath ?? target.source.path);
|
|
151
|
-
if (readOptionalGitHead(repoDir) !== expectedBaseHead) {
|
|
152
|
-
throw new UsageError(`Git target "${target.source.name}" advanced after exact-path preflight.`);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
/** Revalidate a publication plan immediately before its first mutation. */
|
|
156
|
-
export function beginWriteTargetMutation(plan) {
|
|
157
|
-
if (plan.expectedBaseHead === undefined)
|
|
158
|
-
return;
|
|
159
|
-
assertWriteTargetPlanBase(plan.target, plan.expectedBaseHead);
|
|
160
|
-
}
|
|
161
|
-
/** Publish exactly the paths bound by {@link planWriteTargetPublication}. */
|
|
162
|
-
export function publishWriteTargetPlan(plan, message, expectedSnapshots) {
|
|
163
|
-
if (!plan.publish)
|
|
164
|
-
return;
|
|
165
|
-
if (plan.expectedBaseHead === undefined) {
|
|
166
|
-
throw new UsageError(`Git publication plan for "${plan.target.source.name}" has no preflight base.`);
|
|
167
|
-
}
|
|
168
|
-
assertWriteTargetPlanBase(plan.target, plan.expectedBaseHead);
|
|
169
|
-
for (const filePath of plan.paths)
|
|
170
|
-
recordWriteTargetPath(plan.target.source, filePath);
|
|
171
|
-
commitWriteTargetBoundary(plan.target, message, {
|
|
172
|
-
paths: [...plan.paths],
|
|
173
|
-
expectedBaseHead: plan.expectedBaseHead,
|
|
174
|
-
...(expectedSnapshots ? { expectedSnapshots } : {}),
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
/** Hold the shared asset lease from exact-path preflight through publication. */
|
|
178
|
-
export function withWriteTargetMutation(target, paths, options, mutate) {
|
|
179
|
-
return withAssetMutationLeaseSync(options.purpose, () => {
|
|
180
|
-
const plan = planWriteTargetPublication(target, paths, { ignored: options.ignored });
|
|
181
|
-
beginWriteTargetMutation(plan);
|
|
182
|
-
const result = mutate();
|
|
183
|
-
const expectedSnapshots = captureIntendedWriteTargetState(plan);
|
|
184
|
-
publishWriteTargetPlan(plan, options.message, expectedSnapshots);
|
|
185
|
-
return result;
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
function captureIntendedWriteTargetState(plan) {
|
|
189
|
-
if (plan.expectedBaseHead === undefined)
|
|
190
|
-
return undefined;
|
|
191
|
-
const snapshots = {};
|
|
192
|
-
for (const filePath of plan.paths) {
|
|
193
|
-
const snapshot = captureGitPathSnapshot(plan.target, filePath);
|
|
194
|
-
snapshots[snapshot.path] = snapshot.state;
|
|
195
|
-
}
|
|
196
|
-
return snapshots;
|
|
197
|
-
}
|
|
198
|
-
function readOptionalGitHead(repoDir) {
|
|
199
|
-
const result = runGit(["-C", repoDir, "rev-parse", "--verify", "HEAD"]);
|
|
200
|
-
return result.status === 0 && result.stdout.trim() ? result.stdout.trim() : null;
|
|
201
|
-
}
|
|
202
|
-
function preflightGitPathMutation(source, filePath) {
|
|
203
|
-
if (source.kind !== "git")
|
|
204
|
-
return undefined;
|
|
205
|
-
const repoDir = path.resolve(source.repoPath ?? source.path);
|
|
206
|
-
if (!isGitBackedStash(repoDir))
|
|
207
|
-
return undefined;
|
|
208
|
-
const relativePath = repoRelativeGitPath(source, filePath);
|
|
209
|
-
const key = gitTargetKey(source);
|
|
210
|
-
let pending = pendingGitMutations.get(key);
|
|
211
|
-
const created = pending === undefined;
|
|
212
|
-
if (!pending) {
|
|
213
|
-
pending = { baseHead: readOptionalGitHead(repoDir), snapshots: {} };
|
|
214
|
-
pendingGitMutations.set(key, pending);
|
|
215
|
-
}
|
|
216
|
-
try {
|
|
217
|
-
if (readOptionalGitHead(repoDir) !== pending.baseHead) {
|
|
218
|
-
throw new UsageError(`Git target "${source.name}" advanced before its exact-path mutation.`);
|
|
219
|
-
}
|
|
220
|
-
if (!Object.hasOwn(pending.snapshots, relativePath)) {
|
|
221
|
-
assertGitExactPathsClean(repoDir, [relativePath]);
|
|
222
|
-
return { key, created };
|
|
223
|
-
}
|
|
224
|
-
assertNoIgnoredExactPaths(repoDir, [relativePath]);
|
|
225
|
-
const index = runGit([
|
|
226
|
-
"--literal-pathspecs",
|
|
227
|
-
"-C",
|
|
228
|
-
repoDir,
|
|
229
|
-
"diff",
|
|
230
|
-
"--cached",
|
|
231
|
-
"--quiet",
|
|
232
|
-
pending.baseHead ?? EMPTY_GIT_TREE,
|
|
233
|
-
"--",
|
|
234
|
-
relativePath,
|
|
235
|
-
]);
|
|
236
|
-
if (index.status === 1) {
|
|
237
|
-
throw new UsageError(`Exact Git operation path has staged work: ${relativePath}. Commit, stash, or discard that path before retrying.`);
|
|
238
|
-
}
|
|
239
|
-
if (index.status !== 0) {
|
|
240
|
-
throw new Error(`Cannot inspect Git index for exact operation path ${relativePath}: ${index.stderr.trim()}`);
|
|
241
|
-
}
|
|
242
|
-
const current = captureGitPathSnapshot({ source, config: { type: "git" } }, filePath);
|
|
243
|
-
if (!sameGitPathState(current.state, pending.snapshots[relativePath] ?? null)) {
|
|
244
|
-
throw new UsageError(`Exact Git operation path changed after AKM wrote it: ${relativePath}. Commit, stash, or discard that path before retrying.`);
|
|
245
|
-
}
|
|
246
|
-
return { key, created };
|
|
247
|
-
}
|
|
248
|
-
catch (error) {
|
|
249
|
-
if (created && Object.keys(pending.snapshots).length === 0)
|
|
250
|
-
pendingGitMutations.delete(key);
|
|
251
|
-
throw error;
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
function discardEmptyGitPreflight(preflight) {
|
|
255
|
-
if (!preflight?.created)
|
|
256
|
-
return;
|
|
257
|
-
const pending = pendingGitMutations.get(preflight.key);
|
|
258
|
-
if (pending && Object.keys(pending.snapshots).length === 0)
|
|
259
|
-
pendingGitMutations.delete(preflight.key);
|
|
260
|
-
}
|
|
261
|
-
// ── Portability advisory (review 13, D1) ────────────────────────────────────
|
|
262
|
-
/**
|
|
263
|
-
* Matches an absolute host **home** path — `/home/<user>` or `/Users/<user>` —
|
|
264
|
-
* requiring at least one user segment after the prefix. A bare `/home/` or
|
|
265
|
-
* `/Users/` (no user segment) does NOT match. The user segment stops at the
|
|
266
|
-
* first path separator, whitespace, or common delimiter so we capture just the
|
|
267
|
-
* `/home/<user>` prefix rather than the whole path.
|
|
268
|
-
*
|
|
269
|
-
* Deliberately conservative: it does not exempt fenced code blocks, so content
|
|
270
|
-
* that legitimately *documents* a system path (e.g. a tutorial) can produce a
|
|
271
|
-
* false positive. That is accepted — the advisory is non-fatal and correctness
|
|
272
|
-
* (never missing a real leak) is preferred over cleverness here.
|
|
273
|
-
*/
|
|
274
|
-
const ABSOLUTE_HOME_PATH_RE = /\/(?:home|Users)\/[^\s/"'`)\]}<>|:;,]+/g;
|
|
275
|
-
/**
|
|
276
|
-
* Return the distinct `/home/<user>` / `/Users/<user>` prefixes embedded in
|
|
277
|
-
* `content`, in first-seen order. Empty when the content is portable.
|
|
278
|
-
*
|
|
279
|
-
* Used by {@link writeAssetToSource} to emit a write-time advisory: absolute
|
|
280
|
-
* host home paths make the stash non-portable and leak the local username.
|
|
281
|
-
*/
|
|
282
|
-
export function findAbsoluteHomePaths(content) {
|
|
283
|
-
const seen = new Set();
|
|
284
|
-
for (const match of content.matchAll(ABSOLUTE_HOME_PATH_RE)) {
|
|
285
|
-
seen.add(match[0]);
|
|
286
|
-
}
|
|
287
|
-
return [...seen];
|
|
288
|
-
}
|
|
289
|
-
// ── Public helpers ──────────────────────────────────────────────────────────
|
|
290
|
-
/**
|
|
291
|
-
* Resolve the effective `writable` flag for a source config entry, applying
|
|
292
|
-
* the v1 default policy from spec §5.4:
|
|
293
|
-
*
|
|
294
|
-
* - `filesystem` → `true` by default
|
|
295
|
-
* - everything else → `false` by default
|
|
296
|
-
*
|
|
297
|
-
* Users can opt out for `filesystem` via `writable: false`. They cannot opt
|
|
298
|
-
* **in** for `website` / `npm` — that combination is rejected at config load
|
|
299
|
-
* (see {@link assertWritableAllowedForKind}).
|
|
300
|
-
*/
|
|
42
|
+
// ── Write-target resolution ─────────────────────────────────────────────────
|
|
43
|
+
/** `writable` defaults to true on `filesystem` and false on every other kind. */
|
|
301
44
|
export function resolveWritable(entry) {
|
|
302
45
|
if (typeof entry.writable === "boolean")
|
|
303
46
|
return entry.writable;
|
|
304
47
|
return entry.type === "filesystem";
|
|
305
48
|
}
|
|
306
|
-
/**
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
* clobber writes on the next refresh, so allowing writes here is a footgun.
|
|
310
|
-
*
|
|
311
|
-
* Throws {@link ConfigError} when the combination is rejected.
|
|
312
|
-
*/
|
|
313
|
-
export function assertWritableAllowedForKind(entry) {
|
|
314
|
-
if (entry.writable !== true)
|
|
315
|
-
return;
|
|
316
|
-
if (REJECTED_WRITABLE_KINDS.has(entry.type)) {
|
|
317
|
-
const label = entry.name ? ` "${entry.name}"` : "";
|
|
318
|
-
throw new ConfigError(`writable: true is only supported on filesystem and git sources (got "${entry.type}" on source${label}).`, "INVALID_CONFIG_FILE", "To author into a checked-out package, add the same path as a separate filesystem source.");
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
/**
|
|
322
|
-
* Write a textual asset (`content`) into `source` at the path implied by
|
|
323
|
-
* `ref`. Always:
|
|
324
|
-
*
|
|
325
|
-
* 1. Refuses if `config.writable` is not truthy (per §5.4).
|
|
326
|
-
* 2. Rejects unsupported kinds (anything but `filesystem` / `git`).
|
|
327
|
-
* 3. Performs a plain filesystem write to `path.join(source.path, …)`.
|
|
328
|
-
*
|
|
329
|
-
* No commit runs here — for **every** kind. Git-backed targets are committed in
|
|
330
|
-
* one batch at the operation boundary via {@link commitWriteTargetBoundary}
|
|
331
|
-
* (0.9.0 amendment, issue #507). The caller fires that boundary commit once
|
|
332
|
-
* after a batch of mutations to a writable git target.
|
|
333
|
-
*/
|
|
334
|
-
export async function writeAssetToSource(source, config, ref, content) {
|
|
335
|
-
ensureWritable(source, config);
|
|
336
|
-
assertSupportedKind(source);
|
|
337
|
-
assertAkmAssetWrite(source);
|
|
338
|
-
const filePath = resolveAssetFilePath(source, ref);
|
|
339
|
-
const authored = filePath.toLowerCase().endsWith(".md") ? ensureAkmMarkdownType(content, ref.type) : content;
|
|
340
|
-
const normalized = authored.endsWith("\n") ? authored : `${authored}\n`;
|
|
341
|
-
const preflight = preflightGitPathMutation(source, filePath);
|
|
342
|
-
try {
|
|
343
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
344
|
-
// Atomic: truncate-and-rewrite left a window in which a crash, a full disk,
|
|
345
|
-
// or a concurrent reader saw a half-written or empty asset — destroying user
|
|
346
|
-
// content that was fine a moment earlier. writeFileAtomic writes a sibling
|
|
347
|
-
// temp file, fdatasyncs it, and renames over the target.
|
|
348
|
-
writeFileAtomic(filePath, normalized, existingFileMode(filePath));
|
|
349
|
-
recordWriteTargetPath(source, filePath);
|
|
350
|
-
// #652: run-scoped write provenance — the canonical asset write is the
|
|
351
|
-
// single largest contributor to an improve run's written-path set.
|
|
352
|
-
recordWrittenPath(filePath);
|
|
353
|
-
}
|
|
354
|
-
catch (error) {
|
|
355
|
-
discardEmptyGitPreflight(preflight);
|
|
356
|
-
throw error;
|
|
357
|
-
}
|
|
358
|
-
// Non-fatal portability advisory (review 13, D1): flag absolute host home
|
|
359
|
-
// paths in the written content. These make the stash non-portable and leak
|
|
360
|
-
// the local username. We warn AFTER the write so the advisory never blocks it.
|
|
361
|
-
const hostPaths = findAbsoluteHomePaths(normalized);
|
|
362
|
-
if (hostPaths.length > 0) {
|
|
363
|
-
warn(`warning: asset "${formatRefForMessage(ref)}" embeds absolute host path(s): ${hostPaths.join(", ")}. ` +
|
|
364
|
-
"These make the stash non-portable and leak the local username — prefer $HOME or ~ relative references.");
|
|
365
|
-
}
|
|
366
|
-
return { path: filePath, ref: displayRef({ type: ref.type, name: ref.name, bundleId: ref.origin }) };
|
|
367
|
-
}
|
|
368
|
-
/**
|
|
369
|
-
* Delete the asset at `ref` from `source`. Symmetric to
|
|
370
|
-
* {@link writeAssetToSource}: same writable check, same unsupported-kind guard,
|
|
371
|
-
* a plain `unlink` with no commit. Git-backed targets are committed once at the
|
|
372
|
-
* operation boundary via {@link commitWriteTargetBoundary}.
|
|
373
|
-
*/
|
|
374
|
-
export async function deleteAssetFromSource(source, config, ref) {
|
|
375
|
-
ensureWritable(source, config);
|
|
376
|
-
assertSupportedKind(source);
|
|
377
|
-
assertAkmAssetWrite(source);
|
|
378
|
-
const filePath = resolveAssetFilePath(source, ref);
|
|
379
|
-
if (!fs.existsSync(filePath)) {
|
|
380
|
-
throw new UsageError(`Asset "${formatRefForMessage(ref)}" not found in source "${source.name}" (expected at ${filePath}).`, "MISSING_REQUIRED_ARGUMENT");
|
|
381
|
-
}
|
|
382
|
-
const preflight = preflightGitPathMutation(source, filePath);
|
|
383
|
-
try {
|
|
384
|
-
fs.unlinkSync(filePath);
|
|
385
|
-
recordWriteTargetPath(source, filePath);
|
|
386
|
-
// #652: a removal is journaled exactly like a write — the stager stages the
|
|
387
|
-
// final on-disk state, so a deleted path lands as a staged deletion.
|
|
388
|
-
recordWrittenPath(filePath);
|
|
389
|
-
}
|
|
390
|
-
catch (error) {
|
|
391
|
-
discardEmptyGitPreflight(preflight);
|
|
392
|
-
throw error;
|
|
393
|
-
}
|
|
394
|
-
return { path: filePath, ref: displayRef({ type: ref.type, name: ref.name, bundleId: ref.origin }) };
|
|
395
|
-
}
|
|
396
|
-
/**
|
|
397
|
-
* Fire the one-shot batch-at-boundary commit for a resolved write target.
|
|
398
|
-
*
|
|
399
|
-
* 0.9.0 (issue #507): replaces the retired per-asset git commit. Callers invoke
|
|
400
|
-
* this EXACTLY ONCE after a batch of writes/deletes to a resolved write target.
|
|
401
|
-
* It is a no-op for any non-git target (plain filesystem sources and the
|
|
402
|
-
* primary stash stay non-committing here — the primary stash is committed by
|
|
403
|
-
* the existing improve auto-sync boundary).
|
|
404
|
-
*
|
|
405
|
-
* For a git target it delegates to `saveGitStash(name, message, writable, …)`
|
|
406
|
-
* with the exact paths recorded by the write/delete helpers (plus any explicit
|
|
407
|
-
* caller paths), commits once, and pushes when the target is writable, has a
|
|
408
|
-
* remote, and `push !== false`.
|
|
409
|
-
*
|
|
410
|
-
*/
|
|
411
|
-
export function commitWriteTargetBoundary(target, message, options) {
|
|
412
|
-
if (target.source.kind !== "git")
|
|
413
|
-
return;
|
|
414
|
-
const push = options?.push;
|
|
415
|
-
const writable = resolveWritable(target.config);
|
|
416
|
-
const repoDir = path.resolve(target.source.repoPath ?? target.source.path);
|
|
417
|
-
const key = gitTargetKey(target.source);
|
|
418
|
-
const pending = pendingGitMutations.get(key);
|
|
419
|
-
const expectedBaseHead = options?.expectedBaseHead !== undefined
|
|
420
|
-
? options.expectedBaseHead
|
|
421
|
-
: pending
|
|
422
|
-
? pending.baseHead
|
|
423
|
-
: readOptionalGitHead(repoDir);
|
|
424
|
-
const normalizeBoundaryPath = (filePath) => {
|
|
425
|
-
const relativePath = path.isAbsolute(filePath)
|
|
426
|
-
? path.relative(repoDir, path.resolve(filePath)).replaceAll(path.sep, "/")
|
|
427
|
-
: filePath.replaceAll(path.sep, "/");
|
|
428
|
-
return normalizePublicationPaths([relativePath])[0];
|
|
429
|
-
};
|
|
430
|
-
const providedSnapshots = new Map(Object.entries(options?.expectedSnapshots ?? {}).map(([filePath, state]) => [
|
|
431
|
-
normalizeBoundaryPath(filePath),
|
|
432
|
-
state,
|
|
433
|
-
]));
|
|
434
|
-
const paths = normalizePublicationPaths([
|
|
435
|
-
...(options?.paths ?? []).map(normalizeBoundaryPath),
|
|
436
|
-
...Object.keys(pending?.snapshots ?? {}),
|
|
437
|
-
]);
|
|
438
|
-
const expectedSnapshots = {};
|
|
439
|
-
for (const filePath of paths) {
|
|
440
|
-
if (providedSnapshots.has(filePath)) {
|
|
441
|
-
expectedSnapshots[filePath] = providedSnapshots.get(filePath) ?? null;
|
|
442
|
-
}
|
|
443
|
-
else if (pending && Object.hasOwn(pending.snapshots, filePath)) {
|
|
444
|
-
expectedSnapshots[filePath] = pending.snapshots[filePath] ?? null;
|
|
445
|
-
}
|
|
446
|
-
else {
|
|
447
|
-
expectedSnapshots[filePath] = captureGitPathSnapshot(target, path.join(repoDir, filePath)).state;
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
if (options?.expectedBaseHead !== undefined &&
|
|
451
|
-
pending !== undefined &&
|
|
452
|
-
options.expectedBaseHead !== pending.baseHead) {
|
|
453
|
-
throw new Error(`Git boundary base does not match the recorded exact-path mutation base.`);
|
|
454
|
-
}
|
|
455
|
-
// Assets may live under <repo>/content, but git synchronization always runs
|
|
456
|
-
// against the repository root.
|
|
457
|
-
try {
|
|
458
|
-
saveGitStash(undefined, message, writable, {
|
|
459
|
-
repoDir,
|
|
460
|
-
paths,
|
|
461
|
-
expectedSnapshots,
|
|
462
|
-
expectedBaseHead,
|
|
463
|
-
...(push === undefined ? {} : { push }),
|
|
464
|
-
...(options?.transactionId === undefined ? {} : { transactionId: options.transactionId }),
|
|
465
|
-
});
|
|
466
|
-
pendingGitMutations.delete(key);
|
|
467
|
-
}
|
|
468
|
-
catch (error) {
|
|
469
|
-
const currentHead = readOptionalGitHead(repoDir);
|
|
470
|
-
if (pending && currentHead !== pending.baseHead)
|
|
471
|
-
pendingGitMutations.delete(key);
|
|
472
|
-
if (error instanceof GitStashPushError) {
|
|
473
|
-
throw new Error(`Changes were committed as ${error.commit}, but publication failed: ${error.message}`, {
|
|
474
|
-
cause: error,
|
|
475
|
-
});
|
|
476
|
-
}
|
|
477
|
-
throw error;
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
function sameGitPathState(left, right) {
|
|
481
|
-
return left === null ? right === null : right !== null && left.oid === right.oid && left.mode === right.mode;
|
|
482
|
-
}
|
|
483
|
-
/** Capture the exact checkout/upstream identity before a durable mutation starts. */
|
|
484
|
-
export function captureGitPublication(target) {
|
|
485
|
-
if (target.source.kind !== "git")
|
|
486
|
-
return undefined;
|
|
487
|
-
const identity = readGitPublicationIdentity(target);
|
|
488
|
-
const head = runGit(["-C", identity.repoPath, "rev-parse", "HEAD"]);
|
|
489
|
-
if (head.status !== 0 || !head.stdout.trim()) {
|
|
490
|
-
throw new Error(`Cannot read Git HEAD for target "${target.source.name}".`);
|
|
491
|
-
}
|
|
492
|
-
const baseHead = head.stdout.trim();
|
|
493
|
-
let upstreamHead;
|
|
494
|
-
if (identity.upstream) {
|
|
495
|
-
const upstream = runGit(["-C", identity.repoPath, "rev-parse", identity.upstream]);
|
|
496
|
-
if (upstream.status !== 0 || !upstream.stdout.trim()) {
|
|
497
|
-
throw new Error(`Cannot read Git upstream for target "${target.source.name}".`);
|
|
498
|
-
}
|
|
499
|
-
upstreamHead = upstream.stdout.trim();
|
|
500
|
-
if (baseHead !== upstreamHead) {
|
|
501
|
-
throw new Error(`Writable Git target "${target.source.name}" changed after mutation preflight.`);
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
return { ...identity, baseHead, ...(upstreamHead ? { upstreamHead } : {}) };
|
|
505
|
-
}
|
|
506
|
-
/** Capture one repo-relative path exactly as Git will stage it. */
|
|
507
|
-
export function captureGitPathSnapshot(target, filePath) {
|
|
508
|
-
if (target.source.kind !== "git")
|
|
509
|
-
throw new Error(`Target "${target.source.name}" is not Git-backed.`);
|
|
510
|
-
const repoPath = path.resolve(target.source.repoPath ?? target.source.path);
|
|
511
|
-
const absolutePath = path.resolve(filePath);
|
|
512
|
-
const relativePath = path.relative(repoPath, absolutePath).replaceAll(path.sep, "/");
|
|
513
|
-
const [normalizedPath] = normalizePublicationPaths([relativePath]);
|
|
514
|
-
return {
|
|
515
|
-
path: normalizedPath,
|
|
516
|
-
state: captureGitPathState(repoPath, absolutePath, normalizedPath),
|
|
517
|
-
};
|
|
518
|
-
}
|
|
519
|
-
function captureGitPathState(repoPath, absolutePath, relativePath) {
|
|
520
|
-
let stat;
|
|
521
|
-
try {
|
|
522
|
-
stat = fs.lstatSync(absolutePath);
|
|
523
|
-
}
|
|
524
|
-
catch (error) {
|
|
525
|
-
if (error.code === "ENOENT")
|
|
526
|
-
return null;
|
|
527
|
-
throw error;
|
|
528
|
-
}
|
|
529
|
-
let mode;
|
|
530
|
-
let oidResult;
|
|
531
|
-
if (stat.isSymbolicLink()) {
|
|
532
|
-
mode = "120000";
|
|
533
|
-
oidResult = runGit(["-C", repoPath, "hash-object", "--stdin"], { input: fs.readlinkSync(absolutePath) });
|
|
534
|
-
}
|
|
535
|
-
else if (stat.isFile()) {
|
|
536
|
-
mode = stat.mode & 0o111 ? "100755" : "100644";
|
|
537
|
-
oidResult = runGit(["-C", repoPath, "hash-object", `--path=${relativePath}`, "--", absolutePath]);
|
|
538
|
-
}
|
|
539
|
-
else {
|
|
540
|
-
throw new Error(`Git publication path is not a file: ${relativePath}`);
|
|
541
|
-
}
|
|
542
|
-
if (oidResult.status !== 0 || !oidResult.stdout.trim()) {
|
|
543
|
-
throw new Error(`Cannot snapshot Git publication path: ${relativePath}`);
|
|
544
|
-
}
|
|
545
|
-
return { oid: oidResult.stdout.trim(), mode };
|
|
546
|
-
}
|
|
547
|
-
/** Create or recover the one transaction-owned commit without pushing it. */
|
|
548
|
-
export function ensureGitTransactionCommit(target, publication, options) {
|
|
549
|
-
const paths = normalizePublicationPaths(options.paths);
|
|
550
|
-
validateGitWorktreeSnapshots(target, paths, options.snapshots);
|
|
551
|
-
if (publication.commit !== undefined) {
|
|
552
|
-
if (publication.commit !== null) {
|
|
553
|
-
validateGitTransactionCommit(target, publication, publication.commit, options.transactionId, paths, options.snapshots);
|
|
554
|
-
reconcileGitExactPathIndex(publication.repoPath, publication.baseHead, publication.commit, paths);
|
|
555
|
-
}
|
|
556
|
-
return publication.commit;
|
|
557
|
-
}
|
|
558
|
-
const existing = findGitTransactionCommit(publication.repoPath, publication.baseHead, options.transactionId);
|
|
559
|
-
if (existing) {
|
|
560
|
-
validateGitTransactionCommit(target, publication, existing, options.transactionId, paths, options.snapshots);
|
|
561
|
-
reconcileGitExactPathIndex(publication.repoPath, publication.baseHead, existing, paths);
|
|
562
|
-
return existing;
|
|
563
|
-
}
|
|
564
|
-
const head = readGitHead(publication.repoPath, target.source.name);
|
|
565
|
-
if (head !== publication.baseHead) {
|
|
566
|
-
throw new Error(`Cannot publish Git transaction ${options.transactionId}: target "${target.source.name}" advanced before its commit was recorded.`);
|
|
567
|
-
}
|
|
568
|
-
commitWriteTargetBoundary(target, options.message, {
|
|
569
|
-
paths,
|
|
570
|
-
push: false,
|
|
571
|
-
transactionId: options.transactionId,
|
|
572
|
-
expectedBaseHead: publication.baseHead,
|
|
573
|
-
expectedSnapshots: options.snapshots,
|
|
574
|
-
});
|
|
575
|
-
const committed = findGitTransactionCommit(publication.repoPath, publication.baseHead, options.transactionId);
|
|
576
|
-
if (!committed) {
|
|
577
|
-
if (readGitHead(publication.repoPath, target.source.name) === publication.baseHead) {
|
|
578
|
-
validateGitCommitSnapshots(publication.repoPath, publication.baseHead, paths, options.snapshots);
|
|
579
|
-
return null;
|
|
580
|
-
}
|
|
581
|
-
throw new Error(`Cannot identify the Git commit for transaction ${options.transactionId}.`);
|
|
582
|
-
}
|
|
583
|
-
validateGitTransactionCommit(target, publication, committed, options.transactionId, paths, options.snapshots);
|
|
584
|
-
reconcileGitExactPathIndex(publication.repoPath, publication.baseHead, committed, paths);
|
|
585
|
-
return committed;
|
|
586
|
-
}
|
|
587
|
-
/**
|
|
588
|
-
* Kind-neutral snapshot capture for one mutated path.
|
|
589
|
-
*
|
|
590
|
-
* Returns `undefined` for kinds with no publication model, so command layers
|
|
591
|
-
* never branch on `source.kind` themselves — the same fail-soft contract
|
|
592
|
-
* {@link captureGitPublication} and {@link commitWriteTargetBoundary} already
|
|
593
|
-
* use. `captureGitPathSnapshot` throws for non-git targets, which is what
|
|
594
|
-
* forced callers to guard; this absorbs that guard.
|
|
595
|
-
*/
|
|
596
|
-
export function captureWriteTargetPathSnapshot(target, filePath) {
|
|
597
|
-
if (target.source.kind !== "git")
|
|
598
|
-
return undefined;
|
|
599
|
-
return captureGitPathSnapshot(target, filePath);
|
|
600
|
-
}
|
|
601
|
-
/**
|
|
602
|
-
* Kind-neutral commit + publish for one transaction boundary.
|
|
603
|
-
*
|
|
604
|
-
* A no-op (returns `undefined`) for kinds with no publication model. For a
|
|
605
|
-
* publication-backed target this absorbs BOTH the kind test and the
|
|
606
|
-
* "transaction lacks durable publication identity" invariant, so callers hold
|
|
607
|
-
* no provider knowledge. `onCommitRecorded` fires between the ensure and the
|
|
608
|
-
* push, letting a caller persist the commit and advance its own journal phase
|
|
609
|
-
* without inspecting the target.
|
|
610
|
-
*
|
|
611
|
-
* `missingPublicationError` lets a caller keep its own error CLASS for the
|
|
612
|
-
* missing-identity case. The two call sites disagreed historically —
|
|
613
|
-
* consolidate threw `ConfigError` (exit 78), proposal a plain `Error`
|
|
614
|
-
* (exit 70) — and collapsing them here would silently change one command's
|
|
615
|
-
* exit code. The guard moves; the classification stays with the caller.
|
|
616
|
-
*/
|
|
617
|
-
export function publishWriteTargetTransaction(target, publication, options) {
|
|
618
|
-
if (target.source.kind !== "git")
|
|
619
|
-
return undefined;
|
|
620
|
-
if (!publication) {
|
|
621
|
-
throw (options.missingPublicationError?.(target.source.name) ??
|
|
622
|
-
new Error(`Proposal transaction ${options.transactionId} has no Git publication identity.`));
|
|
623
|
-
}
|
|
624
|
-
const commit = ensureGitTransactionCommit(target, publication, {
|
|
625
|
-
transactionId: options.transactionId,
|
|
626
|
-
message: options.message,
|
|
627
|
-
paths: options.paths,
|
|
628
|
-
snapshots: options.snapshots,
|
|
629
|
-
});
|
|
630
|
-
options.onCommitRecorded?.(commit);
|
|
631
|
-
publishGitTransactionCommit(target, publication, options.transactionId, options.paths, options.snapshots);
|
|
632
|
-
return { commit };
|
|
633
|
-
}
|
|
634
|
-
/** Push only the recorded transaction commit, never later local descendants. */
|
|
635
|
-
export function publishGitTransactionCommit(target, publication, transactionId, paths, snapshots) {
|
|
636
|
-
if (publication.commit === undefined) {
|
|
637
|
-
throw new Error(`Git transaction ${transactionId} has no recorded publication decision.`);
|
|
638
|
-
}
|
|
639
|
-
if (publication.commit === null)
|
|
640
|
-
return;
|
|
641
|
-
validateGitTransactionCommit(target, publication, publication.commit, transactionId, normalizePublicationPaths(paths), snapshots);
|
|
642
|
-
if (!publication.remote || !publication.mergeRef)
|
|
643
|
-
return;
|
|
644
|
-
if (!publication.upstream)
|
|
645
|
-
throw new Error(`Git transaction ${transactionId} has no recorded upstream.`);
|
|
646
|
-
if (runGit(["-C", publication.repoPath, "merge-base", "--is-ancestor", publication.commit, publication.upstream])
|
|
647
|
-
.status === 0) {
|
|
648
|
-
return;
|
|
649
|
-
}
|
|
650
|
-
if (runGit(["-C", publication.repoPath, "merge-base", "--is-ancestor", publication.upstream, publication.commit])
|
|
651
|
-
.status !== 0) {
|
|
652
|
-
throw new Error(`Cannot publish Git transaction ${transactionId}: upstream history diverged.`);
|
|
653
|
-
}
|
|
654
|
-
if (!publication.upstreamHead) {
|
|
655
|
-
throw new Error(`Git transaction ${transactionId} has no recorded upstream lease.`);
|
|
656
|
-
}
|
|
657
|
-
const pushed = runGit([
|
|
658
|
-
"-C",
|
|
659
|
-
publication.repoPath,
|
|
660
|
-
"push",
|
|
661
|
-
`--force-with-lease=${publication.mergeRef}:${publication.upstreamHead}`,
|
|
662
|
-
publication.remote,
|
|
663
|
-
`${publication.commit}:${publication.mergeRef}`,
|
|
664
|
-
], { timeout: GIT_PUSH_TIMEOUT_MS });
|
|
665
|
-
if (pushed.status !== 0) {
|
|
666
|
-
throw new Error(`git push failed for target "${target.source.name}": ${pushed.stderr.trim()}`);
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
function readGitPublicationIdentity(target) {
|
|
670
|
-
const repoPath = path.resolve(target.source.repoPath ?? target.source.path);
|
|
671
|
-
const branchResult = runGit(["-C", repoPath, "symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
672
|
-
const branch = branchResult.status === 0 ? branchResult.stdout.trim() : undefined;
|
|
673
|
-
const remotes = runGit(["-C", repoPath, "remote"]);
|
|
674
|
-
if (remotes.status !== 0)
|
|
675
|
-
throw new Error(`Cannot inspect Git remotes for target "${target.source.name}".`);
|
|
676
|
-
if (!remotes.stdout.trim())
|
|
677
|
-
return { repoPath, ...(branch ? { branch } : {}) };
|
|
678
|
-
if (!branch)
|
|
679
|
-
throw new Error(`Writable Git target "${target.source.name}" is detached from a branch.`);
|
|
680
|
-
const remoteResult = runGit(["-C", repoPath, "config", "--get", `branch.${branch}.remote`]);
|
|
681
|
-
const mergeResult = runGit(["-C", repoPath, "config", "--get", `branch.${branch}.merge`]);
|
|
682
|
-
if (remoteResult.status !== 0 || mergeResult.status !== 0) {
|
|
683
|
-
throw new Error(`Writable Git target "${target.source.name}" has no configured upstream branch.`);
|
|
684
|
-
}
|
|
685
|
-
const remote = remoteResult.stdout.trim();
|
|
686
|
-
const mergeRef = mergeResult.stdout.trim();
|
|
687
|
-
const upstreamResult = runGit(["-C", repoPath, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
|
688
|
-
if (upstreamResult.status !== 0 || !upstreamResult.stdout.trim()) {
|
|
689
|
-
throw new Error(`Cannot read Git upstream for target "${target.source.name}".`);
|
|
690
|
-
}
|
|
691
|
-
const urlResult = runGit(["-C", repoPath, "remote", "get-url", remote]);
|
|
692
|
-
if (urlResult.status !== 0 || !urlResult.stdout.trim()) {
|
|
693
|
-
throw new Error(`Cannot read Git remote "${remote}" for target "${target.source.name}".`);
|
|
694
|
-
}
|
|
695
|
-
const pushUrlsResult = runGit(["-C", repoPath, "remote", "get-url", "--push", "--all", remote]);
|
|
696
|
-
if (pushUrlsResult.status !== 0 || !pushUrlsResult.stdout.trim()) {
|
|
697
|
-
throw new Error(`Cannot read Git push URL for target "${target.source.name}".`);
|
|
698
|
-
}
|
|
699
|
-
return {
|
|
700
|
-
repoPath,
|
|
701
|
-
branch,
|
|
702
|
-
remote,
|
|
703
|
-
mergeRef,
|
|
704
|
-
remoteUrl: urlResult.stdout.trim(),
|
|
705
|
-
pushUrls: pushUrlsResult.stdout
|
|
706
|
-
.split("\n")
|
|
707
|
-
.map((value) => value.trim())
|
|
708
|
-
.filter(Boolean),
|
|
709
|
-
upstream: upstreamResult.stdout.trim(),
|
|
710
|
-
};
|
|
711
|
-
}
|
|
712
|
-
function normalizePublicationPaths(paths) {
|
|
713
|
-
const normalized = new Set();
|
|
714
|
-
for (const filePath of paths) {
|
|
715
|
-
const candidate = filePath.replaceAll(path.sep, "/");
|
|
716
|
-
if (!candidate ||
|
|
717
|
-
candidate.includes("\0") ||
|
|
718
|
-
path.isAbsolute(filePath) ||
|
|
719
|
-
path.posix.isAbsolute(candidate) ||
|
|
720
|
-
candidate === "." ||
|
|
721
|
-
candidate === ".." ||
|
|
722
|
-
candidate.startsWith("../") ||
|
|
723
|
-
path.posix.normalize(candidate) !== candidate) {
|
|
724
|
-
throw new Error("Git publication contains an unsafe path.");
|
|
725
|
-
}
|
|
726
|
-
normalized.add(candidate);
|
|
727
|
-
}
|
|
728
|
-
return [...normalized];
|
|
729
|
-
}
|
|
730
|
-
function readGitHead(repoPath, targetName) {
|
|
731
|
-
const result = runGit(["-C", repoPath, "rev-parse", "HEAD"]);
|
|
732
|
-
if (result.status !== 0 || !result.stdout.trim())
|
|
733
|
-
throw new Error(`Cannot read Git HEAD for target "${targetName}".`);
|
|
734
|
-
return result.stdout.trim();
|
|
735
|
-
}
|
|
736
|
-
function findGitTransactionCommit(repoPath, baseHead, transactionId) {
|
|
737
|
-
const result = runGit([
|
|
738
|
-
"-C",
|
|
739
|
-
repoPath,
|
|
740
|
-
"log",
|
|
741
|
-
"--format=%H",
|
|
742
|
-
"--fixed-strings",
|
|
743
|
-
`--grep=AKM-Transaction: ${transactionId}`,
|
|
744
|
-
`${baseHead}..HEAD`,
|
|
745
|
-
]);
|
|
746
|
-
if (result.status !== 0)
|
|
747
|
-
throw new Error(`Cannot inspect Git transaction ${transactionId}.`);
|
|
748
|
-
const matches = result.stdout
|
|
749
|
-
.split("\n")
|
|
750
|
-
.map((value) => value.trim())
|
|
751
|
-
.filter(Boolean);
|
|
752
|
-
if (matches.length > 1)
|
|
753
|
-
throw new Error(`Git transaction ${transactionId} has multiple candidate commits.`);
|
|
754
|
-
return matches[0];
|
|
755
|
-
}
|
|
756
|
-
function validateGitTransactionCommit(target, publication, commit, transactionId, expectedPaths, snapshots) {
|
|
757
|
-
const parent = runGit(["-C", publication.repoPath, "rev-list", "--parents", "-n", "1", commit]);
|
|
758
|
-
const ancestry = parent.stdout.trim().split(/\s+/);
|
|
759
|
-
if (parent.status !== 0 || ancestry.length !== 2 || ancestry[1] !== publication.baseHead) {
|
|
760
|
-
throw new Error(`Git commit ${commit} is not the direct transaction commit for "${target.source.name}".`);
|
|
761
|
-
}
|
|
762
|
-
const message = runGit(["-C", publication.repoPath, "show", "-s", "--format=%B", commit]);
|
|
763
|
-
if (message.status !== 0 ||
|
|
764
|
-
!message.stdout.split(/\r?\n/).some((line) => line.trim() === `AKM-Transaction: ${transactionId}`)) {
|
|
765
|
-
throw new Error(`Git commit ${commit} is not owned by transaction ${transactionId}.`);
|
|
766
|
-
}
|
|
767
|
-
const changed = runGit([
|
|
768
|
-
"-C",
|
|
769
|
-
publication.repoPath,
|
|
770
|
-
"diff-tree",
|
|
771
|
-
"--no-commit-id",
|
|
772
|
-
"--name-only",
|
|
773
|
-
"-z",
|
|
774
|
-
"--no-renames",
|
|
775
|
-
"-r",
|
|
776
|
-
commit,
|
|
777
|
-
]);
|
|
778
|
-
const expected = new Set(expectedPaths);
|
|
779
|
-
const changedPaths = changed.stdout.split("\0").filter(Boolean);
|
|
780
|
-
if (changed.status !== 0 || changedPaths.length === 0 || changedPaths.some((filePath) => !expected.has(filePath))) {
|
|
781
|
-
throw new Error(`Git commit ${commit} contains paths outside transaction ${transactionId}.`);
|
|
782
|
-
}
|
|
783
|
-
validateGitCommitSnapshots(publication.repoPath, commit, expectedPaths, snapshots);
|
|
784
|
-
if (runGit(["-C", publication.repoPath, "merge-base", "--is-ancestor", commit, "HEAD"]).status !== 0) {
|
|
785
|
-
throw new Error(`Git commit ${commit} is no longer on the target branch.`);
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
function validateGitWorktreeSnapshots(target, expectedPaths, snapshots) {
|
|
789
|
-
for (const expectedPath of expectedPaths) {
|
|
790
|
-
if (!Object.hasOwn(snapshots, expectedPath)) {
|
|
791
|
-
throw new Error(`Git publication lacks a snapshot for ${expectedPath}.`);
|
|
792
|
-
}
|
|
793
|
-
const current = captureGitPathSnapshot(target, path.join(target.source.repoPath ?? target.source.path, expectedPath));
|
|
794
|
-
if (!sameGitPathState(current.state, snapshots[expectedPath] ?? null)) {
|
|
795
|
-
throw new Error(`Git publication path diverged after mutation: ${expectedPath}.`);
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
function validateGitCommitSnapshots(repoPath, commit, expectedPaths, snapshots) {
|
|
800
|
-
for (const expectedPath of expectedPaths) {
|
|
801
|
-
if (!Object.hasOwn(snapshots, expectedPath)) {
|
|
802
|
-
throw new Error(`Git publication lacks a snapshot for ${expectedPath}.`);
|
|
803
|
-
}
|
|
804
|
-
const expected = snapshots[expectedPath];
|
|
805
|
-
const tree = runGit(["-C", repoPath, "ls-tree", commit, "--", expectedPath]);
|
|
806
|
-
if (tree.status !== 0)
|
|
807
|
-
throw new Error(`Cannot inspect committed Git path: ${expectedPath}.`);
|
|
808
|
-
const match = tree.stdout.trim().match(/^(\d+)\s+blob\s+([0-9a-f]+)\t/);
|
|
809
|
-
if (expected === null) {
|
|
810
|
-
if (tree.stdout.trim())
|
|
811
|
-
throw new Error(`Git transaction unexpectedly retained ${expectedPath}.`);
|
|
812
|
-
}
|
|
813
|
-
else if (expected === undefined || !match || match[1] !== expected.mode || match[2] !== expected.oid) {
|
|
814
|
-
throw new Error(`Git transaction committed unexpected content for ${expectedPath}.`);
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
/**
|
|
819
|
-
* Validate and normalize a write target before a command mutates it.
|
|
820
|
-
*
|
|
821
|
-
* Git bundle locks record the materialized content root, which can be a
|
|
822
|
-
* subdirectory of the checkout. Resolve the actual repository boundary from
|
|
823
|
-
* that root so scoped commits use repository-relative paths. Extracted or
|
|
824
|
-
* unmaterialized Git caches are not writable checkouts and must fail before a
|
|
825
|
-
* command writes files into them.
|
|
826
|
-
*/
|
|
827
|
-
export function prepareWriteTargetForMutation(target, options = {}) {
|
|
828
|
-
assertAkmAssetWrite(target.source, options.allowedAdapters);
|
|
829
|
-
if (target.source.kind !== "git")
|
|
830
|
-
return target;
|
|
831
|
-
const contentRoot = path.resolve(target.source.path);
|
|
832
|
-
let stat;
|
|
833
|
-
try {
|
|
834
|
-
stat = fs.statSync(contentRoot);
|
|
835
|
-
}
|
|
836
|
-
catch {
|
|
837
|
-
throw gitTargetNotMaterialized(target, contentRoot);
|
|
838
|
-
}
|
|
839
|
-
if (!stat.isDirectory())
|
|
840
|
-
throw gitTargetNotMaterialized(target, contentRoot);
|
|
841
|
-
const rootResult = runGit(["-C", contentRoot, "rev-parse", "--show-toplevel"]);
|
|
842
|
-
if (rootResult.status !== 0 || !rootResult.stdout.trim()) {
|
|
843
|
-
throw gitTargetNotMaterialized(target, contentRoot);
|
|
844
|
-
}
|
|
845
|
-
const repoPath = path.resolve(rootResult.stdout.trim());
|
|
846
|
-
const gitDirResult = runGit(["-C", repoPath, "rev-parse", "--git-dir"]);
|
|
847
|
-
if (gitDirResult.status !== 0 || !gitDirResult.stdout.trim()) {
|
|
848
|
-
throw gitTargetNotMaterialized(target, contentRoot);
|
|
849
|
-
}
|
|
850
|
-
const gitDir = path.resolve(repoPath, gitDirResult.stdout.trim());
|
|
851
|
-
let realContentRoot;
|
|
852
|
-
let realRepoPath;
|
|
853
|
-
try {
|
|
854
|
-
realContentRoot = fs.realpathSync(contentRoot);
|
|
855
|
-
realRepoPath = fs.realpathSync(repoPath);
|
|
856
|
-
fs.accessSync(realContentRoot, fs.constants.W_OK);
|
|
857
|
-
fs.accessSync(gitDir, fs.constants.W_OK);
|
|
858
|
-
}
|
|
859
|
-
catch {
|
|
860
|
-
throw new ConfigError(`Writable Git target "${target.source.name}" is not writable at ${contentRoot}.`, "INVALID_CONFIG_FILE", `Fix the checkout permissions or choose a different --target.`);
|
|
861
|
-
}
|
|
862
|
-
if (!isWithin(realContentRoot, realRepoPath)) {
|
|
863
|
-
throw new ConfigError(`Writable Git target "${target.source.name}" resolves outside its checkout: ${contentRoot}.`, "INVALID_CONFIG_FILE");
|
|
864
|
-
}
|
|
865
|
-
const statusResult = runGit(["-C", repoPath, "status", "--porcelain"]);
|
|
866
|
-
if (statusResult.status !== 0 || statusResult.error) {
|
|
867
|
-
throw gitTargetNotMaterialized(target, contentRoot);
|
|
868
|
-
}
|
|
869
|
-
const branchResult = runGit(["-C", repoPath, "symbolic-ref", "--quiet", "HEAD"]);
|
|
870
|
-
if (branchResult.status !== 0 || !branchResult.stdout.trim()) {
|
|
871
|
-
throw new UsageError(`Writable Git target "${target.source.name}" is detached from a branch.`, "INVALID_FLAG_VALUE");
|
|
872
|
-
}
|
|
873
|
-
const upstream = inspectGitUpstream(repoPath);
|
|
874
|
-
if (upstream.behind > 0) {
|
|
875
|
-
warnOnce(`write-source:git-behind:${realRepoPath}`, `Writable Git target "${target.source.name}" is ${upstream.behind} commit(s) behind ${upstream.upstream}; writing anyway. Run \`akm bundle update ${target.source.name}\` to catch up.`);
|
|
876
|
-
}
|
|
877
|
-
if (upstream.ahead > 0) {
|
|
878
|
-
warnOnce(`write-source:git-ahead:${realRepoPath}`, `Writable Git target "${target.source.name}" has ${upstream.ahead} unpushed commit(s); writing another on top. Push or reconcile them when convenient.`);
|
|
879
|
-
}
|
|
880
|
-
return {
|
|
881
|
-
...target,
|
|
882
|
-
source: { ...target.source, path: contentRoot, repoPath },
|
|
883
|
-
};
|
|
884
|
-
}
|
|
885
|
-
function gitTargetNotMaterialized(target, contentRoot) {
|
|
886
|
-
return new ConfigError(`Writable Git target "${target.source.name}" is not materialized as a Git checkout at ${contentRoot}; refusing to write without a commit boundary.`, "INVALID_CONFIG_FILE", `Run \`akm bundle update ${target.source.name}\` to materialize it, or point the bundle at a writable Git checkout.`);
|
|
49
|
+
/** The two kinds writes are defined for; scheduler state binds only to these. */
|
|
50
|
+
export function isWriteCapableSourceKind(kind) {
|
|
51
|
+
return kind === "filesystem" || kind === "git";
|
|
887
52
|
}
|
|
888
53
|
/**
|
|
889
|
-
* Resolve the destination for a write
|
|
890
|
-
*
|
|
891
|
-
*
|
|
892
|
-
* 2. `config.defaultWriteTarget`
|
|
893
|
-
* 3. `config.defaultBundle`'s path (the working stash created by `akm bundle create`)
|
|
894
|
-
* 4. `ConfigError("no writable source configured; run `akm bundle create`")`
|
|
895
|
-
*
|
|
896
|
-
* The legacy `first-writable-in-source-array-order` fallback is *not* used —
|
|
897
|
-
* see plan §6 decision 3 for the rationale.
|
|
54
|
+
* Resolve the destination for a write: explicit `--target`, then
|
|
55
|
+
* `defaultWriteTarget`, then the working stash (`defaultBundle`). There is no
|
|
56
|
+
* fallback to the first writable source.
|
|
898
57
|
*/
|
|
899
58
|
export function resolveWriteTarget(akmConfig, explicitTarget, options = {}) {
|
|
900
59
|
const allConfiguredSources = resolveConfiguredSources(akmConfig);
|
|
901
60
|
const configuredSources = resolveActiveConfiguredSources(akmConfig);
|
|
902
61
|
const requireWritable = options.requireWritable !== false;
|
|
903
|
-
// 1. Explicit --target wins.
|
|
904
62
|
if (explicitTarget) {
|
|
905
63
|
const match = configuredSources.find((s) => s.name === explicitTarget);
|
|
906
64
|
if (!match) {
|
|
@@ -909,36 +67,21 @@ export function resolveWriteTarget(akmConfig, explicitTarget, options = {}) {
|
|
|
909
67
|
}
|
|
910
68
|
throw new UsageError(`--target must reference a source name from your config. No source named "${explicitTarget}" is configured. Run \`akm bundle list\` to see available sources.`, "INVALID_FLAG_VALUE");
|
|
911
69
|
}
|
|
912
|
-
|
|
913
|
-
// ConfigError (rather than the generic UsageError ensureWritable would
|
|
914
|
-
// raise after we've already started building paths). Resolve the
|
|
915
|
-
// effective writable flag (filesystem defaults to true; everything else
|
|
916
|
-
// defaults to false) so unset values are interpreted correctly.
|
|
917
|
-
const effectiveWritable = resolveWritable({ type: match.type, writable: match.writable });
|
|
918
|
-
if (requireWritable && !effectiveWritable) {
|
|
70
|
+
if (requireWritable && !resolveWritable({ type: match.type, writable: match.writable })) {
|
|
919
71
|
throw new ConfigError(`source ${explicitTarget} is not writable`, "INVALID_CONFIG_FILE", `Set \`writable: true\` on the "${explicitTarget}" source in your config, or pass --target to a different source.`);
|
|
920
72
|
}
|
|
921
73
|
return adaptConfiguredSource(match);
|
|
922
74
|
}
|
|
923
|
-
// 2. config.defaultWriteTarget.
|
|
924
75
|
if (akmConfig.defaultWriteTarget) {
|
|
925
76
|
const match = configuredSources.find((s) => s.name === akmConfig.defaultWriteTarget);
|
|
926
|
-
if (match) {
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
// path-building has already begun.
|
|
932
|
-
const effectiveWritable = resolveWritable({ type: match.type, writable: match.writable });
|
|
933
|
-
if (requireWritable && !effectiveWritable) {
|
|
934
|
-
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.`);
|
|
935
|
-
}
|
|
936
|
-
return adaptConfiguredSource(match);
|
|
77
|
+
if (!match) {
|
|
78
|
+
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 bundle list` to see configured sources.");
|
|
79
|
+
}
|
|
80
|
+
if (requireWritable && !resolveWritable({ type: match.type, writable: match.writable })) {
|
|
81
|
+
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.`);
|
|
937
82
|
}
|
|
938
|
-
|
|
939
|
-
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 bundle list` to see configured sources.");
|
|
83
|
+
return adaptConfiguredSource(match);
|
|
940
84
|
}
|
|
941
|
-
// 3. Configured default bundle.
|
|
942
85
|
return resolveWorkingStashTarget(akmConfig, options);
|
|
943
86
|
}
|
|
944
87
|
/** Resolve the implicit working stash without consulting `defaultWriteTarget`. */
|
|
@@ -978,120 +121,19 @@ export function resolveWorkingStashTarget(akmConfig, options = {}) {
|
|
|
978
121
|
}
|
|
979
122
|
return { ...target, selector: undefined };
|
|
980
123
|
}
|
|
981
|
-
// ── Internals ───────────────────────────────────────────────────────────────
|
|
982
|
-
function ensureWritable(source, config) {
|
|
983
|
-
// Apply the same default-resolution rule as resolveWritable so callers can
|
|
984
|
-
// pass through a SourceConfigEntry with an absent `writable` field.
|
|
985
|
-
const writable = resolveWritable(config);
|
|
986
|
-
if (!writable) {
|
|
987
|
-
throw new UsageError(`Source "${source.name}" is not writable. Set \`writable: true\` on the source config entry to enable writes.`, "INVALID_FLAG_VALUE");
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
124
|
/**
|
|
991
|
-
*
|
|
992
|
-
*
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
"prn",
|
|
997
|
-
"aux",
|
|
998
|
-
"nul",
|
|
999
|
-
...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
|
|
1000
|
-
...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
|
|
1001
|
-
]);
|
|
1002
|
-
function resolveAssetFilePath(source, ref) {
|
|
1003
|
-
const basename = path.posix.basename(ref.name.replaceAll("\\", "/")).replace(/\.md$/i, "").toLowerCase();
|
|
1004
|
-
if (basename === "index" || basename === "log") {
|
|
1005
|
-
warnOnce(`write-source:reserved-basename:${basename}`, `Concept name "${basename}" collides with a reserved word some tooling treats specially; writing it anyway.`);
|
|
1006
|
-
}
|
|
1007
|
-
// Windows resolves these names as DEVICES no matter the directory or the
|
|
1008
|
-
// extension, so `CON.md` is not a file — a write goes to the console and a
|
|
1009
|
-
// read blocks on console input. Rejected on every platform so a stash stays
|
|
1010
|
-
// portable: an asset authored on Linux must not become unopenable when the
|
|
1011
|
-
// same bundle is used on Windows.
|
|
1012
|
-
if (WINDOWS_RESERVED_DEVICE_NAMES.has(basename)) {
|
|
1013
|
-
warnOnce(`write-source:windows-device-name:${basename}`, `Asset name "${basename}" is a reserved Windows device name; writing it anyway, but this bundle will not be portable to Windows.`);
|
|
1014
|
-
}
|
|
1015
|
-
const typeDir = stashDirFor(ref.type);
|
|
1016
|
-
if (!typeDir) {
|
|
1017
|
-
throw new UsageError(`Unknown asset type "${ref.type}". Cannot resolve a write path.`, "INVALID_FLAG_VALUE");
|
|
1018
|
-
}
|
|
1019
|
-
const typeRoot = path.join(source.path, typeDir);
|
|
1020
|
-
const assetPath = assetPathForName(ref.type, typeRoot, ref.name);
|
|
1021
|
-
if (!isWithin(assetPath, typeRoot)) {
|
|
1022
|
-
throw new UsageError(`Resolved asset path escapes its source: "${ref.name}" in source "${source.name}".`, "PATH_ESCAPE_VIOLATION");
|
|
1023
|
-
}
|
|
1024
|
-
return assetPath;
|
|
1025
|
-
}
|
|
1026
|
-
export function assertAkmAssetWrite(source, allowedAdapters = ["akm"]) {
|
|
1027
|
-
if (!source.adapterId || allowedAdapters.includes(source.adapterId))
|
|
1028
|
-
return;
|
|
1029
|
-
throw new UsageError(`Bundle "${source.name}" uses adapter "${source.adapterId}", which does not support AKM asset writes.`, "INVALID_FLAG_VALUE");
|
|
1030
|
-
}
|
|
1031
|
-
/**
|
|
1032
|
-
* The write-capable source kinds. Writes (and therefore scheduler state,
|
|
1033
|
-
* which only ever binds to a writable source) are defined only for these
|
|
1034
|
-
* two kinds; anything else throws `ConfigError`.
|
|
1035
|
-
*/
|
|
1036
|
-
export function isWriteCapableSourceKind(kind) {
|
|
1037
|
-
return kind === "filesystem" || kind === "git";
|
|
1038
|
-
}
|
|
1039
|
-
/**
|
|
1040
|
-
* Reject any kind reaching the write/delete helpers other than the two
|
|
1041
|
-
* supported writable kinds. The config loader is the first line of defence
|
|
1042
|
-
* (assertWritableAllowedForKind), but we throw here so external callers that
|
|
1043
|
-
* bypass the loader still get a clear error.
|
|
1044
|
-
*/
|
|
1045
|
-
function assertSupportedKind(source) {
|
|
1046
|
-
if (isWriteCapableSourceKind(source.kind))
|
|
1047
|
-
return;
|
|
1048
|
-
throw new ConfigError(`write-source: unsupported kind "${source.kind}" for source "${source.name}". ` +
|
|
1049
|
-
"Writes are only defined for `filesystem` and `git` sources.", "INVALID_CONFIG_FILE", 'Set `kind: "filesystem"` (or `kind: "git"`) on the source, or add a parallel filesystem entry.');
|
|
1050
|
-
}
|
|
1051
|
-
export function formatRefForMessage(ref) {
|
|
1052
|
-
// Sanitize each component independently. `ref.origin` originates from user
|
|
1053
|
-
// config and could contain CR/LF that would otherwise be smuggled into the
|
|
1054
|
-
// commit subject and forge trailers downstream. `ref.type` and `ref.name`
|
|
1055
|
-
// are also sanitized defensively — the asset-spec validator should already
|
|
1056
|
-
// reject control bytes there, but a single sanitizer at the boundary keeps
|
|
1057
|
-
// the contract explicit and centralized.
|
|
1058
|
-
const origin = ref.origin ? sanitizeCommitMessage(ref.origin) : "";
|
|
1059
|
-
const type = sanitizeCommitMessage(ref.type);
|
|
1060
|
-
const name = sanitizeCommitMessage(ref.name);
|
|
1061
|
-
// 0.9.0 (Q-02): the retired `type:name` colon grammar is gone — emit the
|
|
1062
|
-
// slash conceptId (`workflows/name`), qualified with `origin//` when the
|
|
1063
|
-
// ref carries one. Mirrors the `displayRef`/`conceptIdFromTypeName` rule
|
|
1064
|
-
// used elsewhere in this file (see `resolveAssetFilePath` callers above).
|
|
1065
|
-
const conceptId = conceptIdFromTypeName(type, name);
|
|
1066
|
-
return origin ? `${origin}//${conceptId}` : conceptId;
|
|
1067
|
-
}
|
|
1068
|
-
/**
|
|
1069
|
-
* Derive a {@link WriteTargetSource} + persisted {@link SourceConfigEntry}
|
|
1070
|
-
* from the runtime {@link ConfiguredSource} representation used elsewhere in
|
|
1071
|
-
* the codebase. The mapping is:
|
|
1072
|
-
*
|
|
1073
|
-
* ConfiguredSource.type → WriteTargetSource.kind
|
|
1074
|
-
* ConfiguredSource.name → WriteTargetSource.name
|
|
1075
|
-
* ConfiguredSource.source.* → WriteTargetSource.path (via parseSourceSpec)
|
|
1076
|
-
*
|
|
125
|
+
* Map a runtime {@link ConfiguredSource} onto a write target. A managed git
|
|
126
|
+
* bundle's content root comes from the lock (`localRoot`) first — the same
|
|
127
|
+
* resolver the indexer's read path uses, so a write lands exactly where a read
|
|
128
|
+
* walks. Before the first lock row it falls back to the cache checkout and its
|
|
129
|
+
* `content/` convention.
|
|
1077
130
|
*/
|
|
1078
131
|
function adaptConfiguredSource(runtime) {
|
|
1079
|
-
// Map the runtime kind to the write helper's `kind` discriminator. Only
|
|
1080
|
-
// filesystem and git produce writable sources at v1; any other kind
|
|
1081
|
-
// reaching this point is a config-loader bug (assertWritableAllowedForKind
|
|
1082
|
-
// should have rejected it). Throw a ConfigError rather than silently
|
|
1083
|
-
// forwarding an unsupported kind.
|
|
1084
132
|
if (!isWriteCapableSourceKind(runtime.type)) {
|
|
1085
133
|
throw new ConfigError(`write-source: source "${runtime.name}" has unsupported kind "${runtime.type}" for writes. ` +
|
|
1086
134
|
"Writes are only defined for `filesystem` and `git` sources.", "INVALID_CONFIG_FILE", 'Use `kind: "filesystem"` or `kind: "git"` for writable sources.');
|
|
1087
135
|
}
|
|
1088
136
|
const kind = runtime.type;
|
|
1089
|
-
// §10.2 lock-first (BEHAVIOR FIX): a managed git bundle's resolved content
|
|
1090
|
-
// root lives in the lock (`localRoot`), NOT the desired config. Resolve there
|
|
1091
|
-
// FIRST — via the SAME shared resolver the indexer READ path uses — so a write
|
|
1092
|
-
// lands in exactly the directory a read walks; git sync/commit then runs
|
|
1093
|
-
// against that same root. Before the first lock row exists, fall back to the
|
|
1094
|
-
// derived cache repoDir + content/-subdir convention used by the read path.
|
|
1095
137
|
const lockRoot = kind === "git" ? lockContentRootFor(runtime.name, runtime.type) : undefined;
|
|
1096
138
|
const repoPath = lockRoot ?? pathFromConfiguredSource(runtime);
|
|
1097
139
|
if (!repoPath) {
|
|
@@ -1112,13 +154,7 @@ function adaptConfiguredSource(runtime) {
|
|
|
1112
154
|
};
|
|
1113
155
|
return {
|
|
1114
156
|
selector: runtime.name,
|
|
1115
|
-
source: {
|
|
1116
|
-
kind,
|
|
1117
|
-
name: runtime.name,
|
|
1118
|
-
path: componentRoot,
|
|
1119
|
-
adapterId,
|
|
1120
|
-
...(kind === "git" ? { repoPath } : {}),
|
|
1121
|
-
},
|
|
157
|
+
source: { kind, name: runtime.name, path: componentRoot, adapterId, ...(kind === "git" ? { repoPath } : {}) },
|
|
1122
158
|
config,
|
|
1123
159
|
};
|
|
1124
160
|
}
|
|
@@ -1128,21 +164,12 @@ export function resolveGitContentRoot(repoPath) {
|
|
|
1128
164
|
return fs.existsSync(contentPath) && fs.statSync(contentPath).isDirectory() ? contentPath : repoPath;
|
|
1129
165
|
}
|
|
1130
166
|
function pathFromConfiguredSource(runtime) {
|
|
1131
|
-
// ConfiguredSource.source is the parsed SourceSpec (filesystem|git|website|npm).
|
|
1132
|
-
// For writable kinds we only ever care about a local on-disk path: filesystem
|
|
1133
|
-
// sources expose it directly; git sources resolve through the cache mirror
|
|
1134
|
-
// (handled by the existing source provider). For v1 the helper trusts
|
|
1135
|
-
// callers to materialise the cache path beforehand and does not re-clone.
|
|
1136
167
|
const spec = runtime.source;
|
|
1137
168
|
if (spec.type === "filesystem")
|
|
1138
169
|
return spec.path;
|
|
1139
|
-
// For git sources we fall back to the cached repo directory the provider
|
|
1140
|
-
// already materialised. The lookup is intentionally lazy — we only import
|
|
1141
|
-
// it when needed to keep the helper's import graph small.
|
|
1142
170
|
if (spec.type === "git") {
|
|
1143
171
|
try {
|
|
1144
|
-
|
|
1145
|
-
return getCachePaths(repo.canonicalUrl).repoDir;
|
|
172
|
+
return getCachePaths(parseGitRepoUrl(spec.url).canonicalUrl).repoDir;
|
|
1146
173
|
}
|
|
1147
174
|
catch {
|
|
1148
175
|
return undefined;
|
|
@@ -1150,3 +177,257 @@ function pathFromConfiguredSource(runtime) {
|
|
|
1150
177
|
}
|
|
1151
178
|
return undefined;
|
|
1152
179
|
}
|
|
180
|
+
// ── Preparing a target for mutation ─────────────────────────────────────────
|
|
181
|
+
/** Refuse AKM asset writes into a bundle whose adapter owns a different layout. */
|
|
182
|
+
export function assertAkmAssetWrite(source, allowedAdapters = ["akm"]) {
|
|
183
|
+
if (!source.adapterId || allowedAdapters.includes(source.adapterId))
|
|
184
|
+
return;
|
|
185
|
+
throw new UsageError(`Bundle "${source.name}" uses adapter "${source.adapterId}", which does not support AKM asset writes.`, "INVALID_FLAG_VALUE");
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Validate a write target before a command mutates it. A git target must be a
|
|
189
|
+
* materialized checkout on a branch; its repository root is resolved from the
|
|
190
|
+
* content root (which may be a subdirectory) so the boundary commit uses
|
|
191
|
+
* repository-relative paths. Being ahead of or behind upstream only warns.
|
|
192
|
+
*/
|
|
193
|
+
export function prepareWriteTargetForMutation(target, options = {}) {
|
|
194
|
+
assertAkmAssetWrite(target.source, options.allowedAdapters);
|
|
195
|
+
if (target.source.kind !== "git")
|
|
196
|
+
return target;
|
|
197
|
+
const contentRoot = path.resolve(target.source.path);
|
|
198
|
+
const rootResult = runGit(["-C", contentRoot, "rev-parse", "--show-toplevel"]);
|
|
199
|
+
if (rootResult.status !== 0 || !rootResult.stdout.trim()) {
|
|
200
|
+
throw new ConfigError(`Writable Git target "${target.source.name}" is not materialized as a Git checkout at ${contentRoot}; refusing to write without a commit boundary.`, "INVALID_CONFIG_FILE", `Run \`akm bundle update ${target.source.name}\` to materialize it, or point the bundle at a writable Git checkout.`);
|
|
201
|
+
}
|
|
202
|
+
const repoPath = path.resolve(rootResult.stdout.trim());
|
|
203
|
+
const branchResult = runGit(["-C", repoPath, "symbolic-ref", "--quiet", "HEAD"]);
|
|
204
|
+
if (branchResult.status !== 0 || !branchResult.stdout.trim()) {
|
|
205
|
+
throw new UsageError(`Writable Git target "${target.source.name}" is detached from a branch.`, "INVALID_FLAG_VALUE");
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
const upstream = inspectGitUpstream(repoPath);
|
|
209
|
+
if (upstream.behind > 0) {
|
|
210
|
+
warnOnce(`write-source:git-behind:${repoPath}`, `Writable Git target "${target.source.name}" is ${upstream.behind} commit(s) behind ${upstream.upstream}; writing anyway. Run \`akm bundle update ${target.source.name}\` to catch up.`);
|
|
211
|
+
}
|
|
212
|
+
if (upstream.ahead > 0) {
|
|
213
|
+
warnOnce(`write-source:git-ahead:${repoPath}`, `Writable Git target "${target.source.name}" has ${upstream.ahead} unpushed commit(s); writing another on top. Push or reconcile them when convenient.`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
// The upstream check is advisory (it fetches). Being offline must not stop
|
|
218
|
+
// a local write; the push reports its own failure at the boundary.
|
|
219
|
+
warnOnce(`write-source:git-upstream:${repoPath}`, `Could not check upstream for Git target "${target.source.name}" (${error instanceof Error ? error.message : String(error)}); writing anyway.`);
|
|
220
|
+
}
|
|
221
|
+
return { ...target, source: { ...target.source, path: contentRoot, repoPath } };
|
|
222
|
+
}
|
|
223
|
+
// ── Writing assets ──────────────────────────────────────────────────────────
|
|
224
|
+
/**
|
|
225
|
+
* Write a textual asset into `source` at the path implied by `ref`: refuses a
|
|
226
|
+
* non-writable config, rejects any kind but `filesystem` / `git`, then writes
|
|
227
|
+
* atomically inside the bundle root. No commit runs here for any kind; a git
|
|
228
|
+
* target records the path for {@link commitWriteTargetBoundary}.
|
|
229
|
+
*/
|
|
230
|
+
export async function writeAssetToSource(source, config, ref, content) {
|
|
231
|
+
ensureWritable(source, config);
|
|
232
|
+
assertSupportedKind(source);
|
|
233
|
+
assertAkmAssetWrite(source);
|
|
234
|
+
const filePath = resolveAssetFilePath(source, ref);
|
|
235
|
+
const authored = filePath.toLowerCase().endsWith(".md") ? ensureAkmMarkdownType(content, ref.type) : content;
|
|
236
|
+
const normalized = authored.endsWith("\n") ? authored : `${authored}\n`;
|
|
237
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
238
|
+
// Atomic: sibling temp file, fdatasync, rename — a crash or a full disk never
|
|
239
|
+
// leaves a half-written asset where a good one was.
|
|
240
|
+
writeFileAtomic(filePath, normalized, existingFileMode(filePath));
|
|
241
|
+
recordWriteTargetPath(source, filePath);
|
|
242
|
+
// Run-scoped write provenance (#652).
|
|
243
|
+
recordWrittenPath(filePath);
|
|
244
|
+
// Non-fatal portability advisory, after the write so it never blocks it.
|
|
245
|
+
const hostPaths = findAbsoluteHomePaths(normalized);
|
|
246
|
+
if (hostPaths.length > 0) {
|
|
247
|
+
warn(`warning: asset "${formatRefForMessage(ref)}" embeds absolute host path(s): ${hostPaths.join(", ")}. ` +
|
|
248
|
+
"These make the stash non-portable and leak the local username — prefer $HOME or ~ relative references.");
|
|
249
|
+
}
|
|
250
|
+
return { path: filePath, ref: displayRef({ type: ref.type, name: ref.name, bundleId: ref.origin }) };
|
|
251
|
+
}
|
|
252
|
+
/** Delete the asset at `ref` from `source`. Same gates as {@link writeAssetToSource}, no commit. */
|
|
253
|
+
export async function deleteAssetFromSource(source, config, ref) {
|
|
254
|
+
ensureWritable(source, config);
|
|
255
|
+
assertSupportedKind(source);
|
|
256
|
+
assertAkmAssetWrite(source);
|
|
257
|
+
const filePath = resolveAssetFilePath(source, ref);
|
|
258
|
+
if (!fs.existsSync(filePath)) {
|
|
259
|
+
throw new UsageError(`Asset "${formatRefForMessage(ref)}" not found in source "${source.name}" (expected at ${filePath}).`, "MISSING_REQUIRED_ARGUMENT");
|
|
260
|
+
}
|
|
261
|
+
fs.unlinkSync(filePath);
|
|
262
|
+
recordWriteTargetPath(source, filePath);
|
|
263
|
+
recordWrittenPath(filePath);
|
|
264
|
+
return { path: filePath, ref: displayRef({ type: ref.type, name: ref.name, bundleId: ref.origin }) };
|
|
265
|
+
}
|
|
266
|
+
function ensureWritable(source, config) {
|
|
267
|
+
if (resolveWritable(config))
|
|
268
|
+
return;
|
|
269
|
+
throw new UsageError(`Source "${source.name}" is not writable. Set \`writable: true\` on the source config entry to enable writes.`, "INVALID_FLAG_VALUE");
|
|
270
|
+
}
|
|
271
|
+
function assertSupportedKind(source) {
|
|
272
|
+
if (isWriteCapableSourceKind(source.kind))
|
|
273
|
+
return;
|
|
274
|
+
throw new ConfigError(`write-source: unsupported kind "${source.kind}" for source "${source.name}". ` +
|
|
275
|
+
"Writes are only defined for `filesystem` and `git` sources.", "INVALID_CONFIG_FILE", 'Set `kind: "filesystem"` (or `kind: "git"`) on the source, or add a parallel filesystem entry.');
|
|
276
|
+
}
|
|
277
|
+
/** MS-DOS device names Windows reserves in every directory, with or without an extension. */
|
|
278
|
+
const WINDOWS_RESERVED_DEVICE_NAMES = new Set([
|
|
279
|
+
"con",
|
|
280
|
+
"prn",
|
|
281
|
+
"aux",
|
|
282
|
+
"nul",
|
|
283
|
+
...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
|
|
284
|
+
...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
|
|
285
|
+
]);
|
|
286
|
+
/** The on-disk path for `ref` inside `source`, refusing anything that escapes the type directory. */
|
|
287
|
+
function resolveAssetFilePath(source, ref) {
|
|
288
|
+
const basename = path.posix.basename(ref.name.replaceAll("\\", "/")).replace(/\.md$/i, "").toLowerCase();
|
|
289
|
+
if (basename === "index" || basename === "log") {
|
|
290
|
+
warnOnce(`write-source:reserved-basename:${basename}`, `Concept name "${basename}" collides with a reserved word some tooling treats specially; writing it anyway.`);
|
|
291
|
+
}
|
|
292
|
+
if (WINDOWS_RESERVED_DEVICE_NAMES.has(basename)) {
|
|
293
|
+
warnOnce(`write-source:windows-device-name:${basename}`, `Asset name "${basename}" is a reserved Windows device name; writing it anyway, but this bundle will not be portable to Windows.`);
|
|
294
|
+
}
|
|
295
|
+
const typeDir = stashDirFor(ref.type);
|
|
296
|
+
if (!typeDir) {
|
|
297
|
+
throw new UsageError(`Unknown asset type "${ref.type}". Cannot resolve a write path.`, "INVALID_FLAG_VALUE");
|
|
298
|
+
}
|
|
299
|
+
const typeRoot = path.join(source.path, typeDir);
|
|
300
|
+
const assetPath = assetPathForName(ref.type, typeRoot, ref.name);
|
|
301
|
+
if (!isWithin(assetPath, typeRoot)) {
|
|
302
|
+
throw new UsageError(`Resolved asset path escapes its source: "${ref.name}" in source "${source.name}".`, "PATH_ESCAPE_VIOLATION");
|
|
303
|
+
}
|
|
304
|
+
return assetPath;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Matches an absolute host home path (`/home/<user>`, `/Users/<user>`) with at
|
|
308
|
+
* least one user segment. Deliberately does not exempt fenced code, so content
|
|
309
|
+
* that documents such a path can trip it; the advisory is non-fatal.
|
|
310
|
+
*/
|
|
311
|
+
const ABSOLUTE_HOME_PATH_RE = /\/(?:home|Users)\/[^\s/"'`)\]}<>|:;,]+/g;
|
|
312
|
+
/** Distinct `/home/<user>` / `/Users/<user>` prefixes in `content`, first-seen order. */
|
|
313
|
+
export function findAbsoluteHomePaths(content) {
|
|
314
|
+
const seen = new Set();
|
|
315
|
+
for (const match of content.matchAll(ABSOLUTE_HOME_PATH_RE))
|
|
316
|
+
seen.add(match[0]);
|
|
317
|
+
return [...seen];
|
|
318
|
+
}
|
|
319
|
+
/** `[origin//]conceptId` for a commit subject, each component sanitized against CR/LF/NUL smuggling. */
|
|
320
|
+
export function formatRefForMessage(ref) {
|
|
321
|
+
const origin = ref.origin ? sanitizeCommitMessage(ref.origin) : "";
|
|
322
|
+
const conceptId = conceptIdFromTypeName(sanitizeCommitMessage(ref.type), sanitizeCommitMessage(ref.name));
|
|
323
|
+
return origin ? `${origin}//${conceptId}` : conceptId;
|
|
324
|
+
}
|
|
325
|
+
// ── Boundary commit ─────────────────────────────────────────────────────────
|
|
326
|
+
/**
|
|
327
|
+
* Absolute paths written to a git target since its last boundary commit, keyed
|
|
328
|
+
* by repository root. This is what lets the boundary `git add` exactly the
|
|
329
|
+
* operation's files and nothing else in the checkout.
|
|
330
|
+
*/
|
|
331
|
+
const pendingGitPaths = new Map();
|
|
332
|
+
function repoDirFor(source) {
|
|
333
|
+
return path.resolve(source.repoPath ?? source.path);
|
|
334
|
+
}
|
|
335
|
+
/** Record a path the git target's next boundary commit must include. */
|
|
336
|
+
export function recordWriteTargetPath(source, filePath) {
|
|
337
|
+
if (source.kind !== "git")
|
|
338
|
+
return;
|
|
339
|
+
const repoDir = repoDirFor(source);
|
|
340
|
+
const pending = pendingGitPaths.get(repoDir) ?? new Set();
|
|
341
|
+
pending.add(path.resolve(filePath));
|
|
342
|
+
pendingGitPaths.set(repoDir, pending);
|
|
343
|
+
}
|
|
344
|
+
function lstatOrNull(filePath) {
|
|
345
|
+
try {
|
|
346
|
+
return fs.lstatSync(filePath);
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
if (error.code === "ENOENT")
|
|
350
|
+
return null;
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Refuse a write path outside the source root, or one that passes through a
|
|
356
|
+
* symbolic link below it (a linked directory would redirect the write out of
|
|
357
|
+
* the bundle). The source root itself may be a symlink.
|
|
358
|
+
*/
|
|
359
|
+
function assertWritePathsInsideSource(source, filePaths) {
|
|
360
|
+
const lexicalRoot = path.resolve(source.path);
|
|
361
|
+
const canonicalRoot = fs.realpathSync(lexicalRoot);
|
|
362
|
+
for (const filePath of filePaths) {
|
|
363
|
+
const relative = path.relative(lexicalRoot, path.resolve(filePath));
|
|
364
|
+
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
365
|
+
throw new UsageError(`Write path resolves outside source "${source.name}".`, "PATH_ESCAPE_VIOLATION");
|
|
366
|
+
}
|
|
367
|
+
let current = canonicalRoot;
|
|
368
|
+
for (const segment of relative.split(path.sep)) {
|
|
369
|
+
current = path.join(current, segment);
|
|
370
|
+
const stat = lstatOrNull(current);
|
|
371
|
+
if (!stat)
|
|
372
|
+
break;
|
|
373
|
+
if (stat.isSymbolicLink()) {
|
|
374
|
+
throw new UsageError(`Write path contains a symbolic link below source "${source.name}": ${relative}`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Run `mutate` under the shared asset-mutation lease, then commit exactly
|
|
381
|
+
* `paths` on a git target (a no-op on filesystem targets). Every path must
|
|
382
|
+
* stay inside the source root.
|
|
383
|
+
*/
|
|
384
|
+
export function withWriteTargetMutation(target, paths, options, mutate) {
|
|
385
|
+
return withAssetMutationLeaseSync(options.purpose, () => {
|
|
386
|
+
assertWritePathsInsideSource(target.source, paths);
|
|
387
|
+
const result = mutate();
|
|
388
|
+
commitWriteTargetBoundary(target, options.message, { paths });
|
|
389
|
+
return result;
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Commit a git target's recorded paths plus `options.paths` (absolute or
|
|
394
|
+
* repository-relative) as one commit, and push it with `--force-with-lease`
|
|
395
|
+
* when the target is writable, has an upstream, and `push !== false`. A no-op
|
|
396
|
+
* for filesystem targets. Ignored paths stay local: they are dropped from the
|
|
397
|
+
* commit with a warning rather than failing a write that already landed.
|
|
398
|
+
*/
|
|
399
|
+
export function commitWriteTargetBoundary(target, message, options) {
|
|
400
|
+
if (target.source.kind !== "git")
|
|
401
|
+
return;
|
|
402
|
+
const repoDir = repoDirFor(target.source);
|
|
403
|
+
const recorded = pendingGitPaths.get(repoDir) ?? new Set();
|
|
404
|
+
pendingGitPaths.delete(repoDir);
|
|
405
|
+
if (!isGitBackedStash(repoDir))
|
|
406
|
+
return;
|
|
407
|
+
const toRepoRelative = (filePath) => (path.isAbsolute(filePath) ? path.relative(repoDir, filePath) : filePath).replaceAll(path.sep, "/");
|
|
408
|
+
const paths = [...new Set([...(options?.paths ?? []), ...recorded].map(toRepoRelative))];
|
|
409
|
+
if (paths.length === 0)
|
|
410
|
+
return;
|
|
411
|
+
const ignored = new Set(listIgnoredExactPaths(repoDir, paths));
|
|
412
|
+
if (ignored.size > 0) {
|
|
413
|
+
warn(`warning: ${ignored.size} path(s) in "${target.source.name}" are ignored by .gitignore and stay local (not committed): ${[...ignored].join(", ")}`);
|
|
414
|
+
}
|
|
415
|
+
const committable = paths.filter((filePath) => !ignored.has(filePath));
|
|
416
|
+
if (committable.length === 0)
|
|
417
|
+
return;
|
|
418
|
+
try {
|
|
419
|
+
saveGitStash(undefined, message, resolveWritable(target.config), {
|
|
420
|
+
repoDir,
|
|
421
|
+
paths: committable,
|
|
422
|
+
...(options?.push === undefined ? {} : { push: options.push }),
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
catch (error) {
|
|
426
|
+
if (error instanceof GitStashPushError) {
|
|
427
|
+
throw new Error(`Changes were committed as ${error.commit}, but publication failed: ${error.message}`, {
|
|
428
|
+
cause: error,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
throw error;
|
|
432
|
+
}
|
|
433
|
+
}
|