@open-agent-toolkit/cli 0.2.50 → 0.2.52
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/assets/bundle-metadata.json +1 -1
- package/assets/docs/cli-utilities/bootstrap.md +6 -2
- package/assets/docs/cli-utilities/tool-packs.md +87 -33
- package/assets/docs/provider-sync/commands.md +30 -2
- package/assets/docs/provider-sync/config.md +19 -0
- package/assets/docs/provider-sync/manifest-and-drift.md +37 -0
- package/assets/docs/provider-sync/providers.md +69 -2
- package/assets/docs/provider-sync/scope-and-surface.md +44 -0
- package/assets/docs/reference/cli-reference.md +21 -5
- package/assets/docs/reference/file-locations.md +8 -2
- package/assets/docs/reference/oat-directory-structure.md +25 -9
- package/assets/docs/workflows/projects/artifacts.md +9 -4
- package/assets/docs/workflows/projects/evidence-layers.md +110 -1
- package/assets/docs/workflows/projects/implementation-execution.md +13 -0
- package/assets/docs/workflows/projects/lifecycle.md +17 -5
- package/assets/docs/workflows/projects/orchestration-model.md +18 -0
- package/assets/docs/workflows/projects/picking-up-projects.md +26 -9
- package/assets/docs/workflows/projects/pr-flow.md +5 -3
- package/assets/docs/workflows/projects/reviewing-oat-prs.md +12 -8
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/oat-dispatch-subagents/SKILL.md +57 -1
- package/assets/skills/oat-dispatch-subagents/references/record-schema.md +77 -1
- package/assets/skills/oat-project-autonomous/references/gate-inventory.md +3 -3
- package/assets/skills/oat-project-complete/SKILL.md +193 -26
- package/assets/skills/oat-project-complete/scripts/execute-synced-archive-entry.mjs +306 -0
- package/assets/skills/oat-project-complete/scripts/finalize-synced-archive.mjs +125 -0
- package/assets/skills/oat-project-complete/scripts/parse-synced-archive-resume-fields.mjs +106 -0
- package/assets/skills/oat-project-complete/scripts/recover-completion-receipts.mjs +110 -0
- package/assets/skills/oat-project-complete/scripts/resolve-synced-archive-entry.mjs +164 -0
- package/assets/skills/oat-project-dispatch-subagents/SKILL.md +24 -1
- package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +3 -3
- package/assets/skills/oat-project-implement/references/dispatch-and-dry-run.md +11 -0
- package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +3 -3
- package/assets/skills/oat-project-plan-writing/SKILL.md +12 -1
- package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +3 -3
- package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +3 -3
- package/assets/skills/oat-project-review-provide/SKILL.md +12 -1
- package/assets/skills/oat-project-review-provide-remote/SKILL.md +12 -1
- package/dist/agents/canonical/index.d.ts +2 -0
- package/dist/agents/canonical/index.d.ts.map +1 -1
- package/dist/agents/canonical/index.js +1 -0
- package/dist/agents/canonical/resolve.d.ts +45 -0
- package/dist/agents/canonical/resolve.d.ts.map +1 -0
- package/dist/agents/canonical/resolve.js +175 -0
- package/dist/commands/decision/agents-guidance.d.ts +2 -2
- package/dist/commands/decision/agents-guidance.d.ts.map +1 -1
- package/dist/commands/decision/agents-guidance.js +1 -4
- package/dist/commands/decision/index.d.ts.map +1 -1
- package/dist/commands/decision/index.js +25 -6
- package/dist/commands/docs/init/index.d.ts +7 -1
- package/dist/commands/docs/init/index.d.ts.map +1 -1
- package/dist/commands/docs/init/index.js +53 -20
- package/dist/commands/doctor/index.d.ts +8 -2
- package/dist/commands/doctor/index.d.ts.map +1 -1
- package/dist/commands/doctor/index.js +157 -46
- package/dist/commands/init/index.d.ts +8 -3
- package/dist/commands/init/index.d.ts.map +1 -1
- package/dist/commands/init/index.js +64 -26
- package/dist/commands/init/tools/index.d.ts +12 -12
- package/dist/commands/init/tools/index.d.ts.map +1 -1
- package/dist/commands/init/tools/index.js +430 -99
- package/dist/commands/init/tools/install-state.d.ts +2 -0
- package/dist/commands/init/tools/install-state.d.ts.map +1 -1
- package/dist/commands/init/tools/install-state.js +11 -0
- package/dist/commands/init/tools/project-guidance.d.ts +49 -0
- package/dist/commands/init/tools/project-guidance.d.ts.map +1 -0
- package/dist/commands/init/tools/project-guidance.js +144 -0
- package/dist/commands/init/tools/workflows/index.d.ts +7 -0
- package/dist/commands/init/tools/workflows/index.d.ts.map +1 -1
- package/dist/commands/init/tools/workflows/index.js +160 -7
- package/dist/commands/pjm/index.d.ts.map +1 -1
- package/dist/commands/pjm/index.js +28 -3
- package/dist/commands/pjm/init.d.ts +5 -0
- package/dist/commands/pjm/init.d.ts.map +1 -1
- package/dist/commands/pjm/init.js +19 -3
- package/dist/commands/project/archive/archive-utils.d.ts +8 -1
- package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
- package/dist/commands/project/archive/archive-utils.js +147 -50
- package/dist/commands/project/archive/push-runner.d.ts.map +1 -1
- package/dist/commands/project/archive/push-runner.js +15 -2
- package/dist/commands/project/dispatch/index.d.ts +10 -0
- package/dist/commands/project/dispatch/index.d.ts.map +1 -0
- package/dist/commands/project/dispatch/index.js +137 -0
- package/dist/commands/project/dispatch/record.d.ts +91 -0
- package/dist/commands/project/dispatch/record.d.ts.map +1 -0
- package/dist/commands/project/dispatch/record.js +402 -0
- package/dist/commands/project/index.d.ts.map +1 -1
- package/dist/commands/project/index.js +2 -0
- package/dist/commands/project/links/compute.d.ts +1 -0
- package/dist/commands/project/links/compute.d.ts.map +1 -1
- package/dist/commands/project/links/compute.js +5 -4
- package/dist/commands/project/links/index.d.ts +9 -0
- package/dist/commands/project/links/index.d.ts.map +1 -1
- package/dist/commands/project/links/index.js +71 -2
- package/dist/commands/project/list.d.ts +10 -2
- package/dist/commands/project/list.d.ts.map +1 -1
- package/dist/commands/project/list.js +205 -29
- package/dist/commands/project/open/index.d.ts +2 -0
- package/dist/commands/project/open/index.d.ts.map +1 -1
- package/dist/commands/project/open/index.js +16 -1
- package/dist/commands/project/prune/index.d.ts +10 -4
- package/dist/commands/project/prune/index.d.ts.map +1 -1
- package/dist/commands/project/prune/index.js +85 -15
- package/dist/commands/project/pull/index.d.ts +4 -0
- package/dist/commands/project/pull/index.d.ts.map +1 -1
- package/dist/commands/project/pull/index.js +82 -3
- package/dist/commands/project/sync/git.d.ts +9 -0
- package/dist/commands/project/sync/git.d.ts.map +1 -1
- package/dist/commands/project/sync/git.js +18 -0
- package/dist/commands/project/sync/ref-sync.d.ts +16 -1
- package/dist/commands/project/sync/ref-sync.d.ts.map +1 -1
- package/dist/commands/project/sync/ref-sync.js +288 -17
- package/dist/commands/project/sync/resolve-target.d.ts +10 -0
- package/dist/commands/project/sync/resolve-target.d.ts.map +1 -1
- package/dist/commands/project/sync/resolve-target.js +56 -1
- package/dist/commands/providers/inspect/inspect.d.ts +8 -1
- package/dist/commands/providers/inspect/inspect.d.ts.map +1 -1
- package/dist/commands/providers/inspect/inspect.js +146 -26
- package/dist/commands/providers/list/list.d.ts +8 -1
- package/dist/commands/providers/list/list.d.ts.map +1 -1
- package/dist/commands/providers/list/list.js +139 -23
- package/dist/commands/providers/providers.types.d.ts +25 -1
- package/dist/commands/providers/providers.types.d.ts.map +1 -1
- package/dist/commands/providers/set/index.d.ts +9 -1
- package/dist/commands/providers/set/index.d.ts.map +1 -1
- package/dist/commands/providers/set/index.js +43 -33
- package/dist/commands/remove/skill/remove-skill.d.ts.map +1 -1
- package/dist/commands/remove/skill/remove-skill.js +11 -0
- package/dist/commands/shared/agents-md.d.ts +42 -14
- package/dist/commands/shared/agents-md.d.ts.map +1 -1
- package/dist/commands/shared/agents-md.js +364 -70
- package/dist/commands/shared/project-scope.d.ts +2 -0
- package/dist/commands/shared/project-scope.d.ts.map +1 -1
- package/dist/commands/shared/project-scope.js +5 -0
- package/dist/commands/state/generate.d.ts +2 -0
- package/dist/commands/state/generate.d.ts.map +1 -1
- package/dist/commands/state/generate.js +93 -28
- package/dist/commands/status/index.d.ts +8 -3
- package/dist/commands/status/index.d.ts.map +1 -1
- package/dist/commands/status/index.js +147 -32
- package/dist/commands/sync/apply.d.ts.map +1 -1
- package/dist/commands/sync/apply.js +220 -29
- package/dist/commands/sync/dry-run.d.ts.map +1 -1
- package/dist/commands/sync/dry-run.js +30 -6
- package/dist/commands/sync/index.d.ts +17 -2
- package/dist/commands/sync/index.d.ts.map +1 -1
- package/dist/commands/sync/index.js +106 -41
- package/dist/commands/sync/sync.types.d.ts +39 -2
- package/dist/commands/sync/sync.types.d.ts.map +1 -1
- package/dist/commands/sync/sync.utils.d.ts +5 -1
- package/dist/commands/sync/sync.utils.d.ts.map +1 -1
- package/dist/commands/sync/sync.utils.js +73 -0
- package/dist/commands/tools/info/info-tool.d.ts +2 -0
- package/dist/commands/tools/info/info-tool.d.ts.map +1 -1
- package/dist/commands/tools/info/info-tool.js +34 -12
- package/dist/commands/tools/install/index.d.ts.map +1 -1
- package/dist/commands/tools/install/index.js +19 -3
- package/dist/commands/tools/list/list-tools.d.ts +2 -0
- package/dist/commands/tools/list/list-tools.d.ts.map +1 -1
- package/dist/commands/tools/list/list-tools.js +36 -7
- package/dist/commands/tools/remove/index.d.ts.map +1 -1
- package/dist/commands/tools/remove/index.js +150 -21
- package/dist/commands/tools/remove/remove-tools.d.ts +20 -0
- package/dist/commands/tools/remove/remove-tools.d.ts.map +1 -1
- package/dist/commands/tools/remove/remove-tools.js +122 -1
- package/dist/commands/tools/shared/format-pack-inventory.d.ts +19 -0
- package/dist/commands/tools/shared/format-pack-inventory.d.ts.map +1 -1
- package/dist/commands/tools/shared/format-pack-inventory.js +109 -0
- package/dist/commands/tools/shared/pack-evidence.d.ts +66 -0
- package/dist/commands/tools/shared/pack-evidence.d.ts.map +1 -0
- package/dist/commands/tools/shared/pack-evidence.js +132 -0
- package/dist/commands/tools/shared/pack-inventory.d.ts +1 -0
- package/dist/commands/tools/shared/pack-inventory.d.ts.map +1 -1
- package/dist/commands/tools/shared/pack-lifecycle-outcome.d.ts +44 -0
- package/dist/commands/tools/shared/pack-lifecycle-outcome.d.ts.map +1 -0
- package/dist/commands/tools/shared/pack-lifecycle-outcome.js +91 -0
- package/dist/commands/tools/update/index.d.ts.map +1 -1
- package/dist/commands/tools/update/index.js +74 -4
- package/dist/commands/tools/update/update-tools.d.ts +5 -0
- package/dist/commands/tools/update/update-tools.d.ts.map +1 -1
- package/dist/commands/tools/update/update-tools.js +64 -2
- package/dist/config/oat-config.d.ts +5 -0
- package/dist/config/oat-config.d.ts.map +1 -1
- package/dist/config/oat-config.js +17 -5
- package/dist/config/user-sync-config.d.ts +1 -0
- package/dist/config/user-sync-config.d.ts.map +1 -1
- package/dist/config/user-sync-config.js +8 -0
- package/dist/drift/detector.d.ts +2 -2
- package/dist/drift/detector.d.ts.map +1 -1
- package/dist/drift/detector.js +18 -0
- package/dist/drift/strays.d.ts.map +1 -1
- package/dist/drift/strays.js +5 -0
- package/dist/engine/collection-sync.d.ts +14 -0
- package/dist/engine/collection-sync.d.ts.map +1 -0
- package/dist/engine/collection-sync.js +282 -0
- package/dist/engine/compute-plan.d.ts +8 -1
- package/dist/engine/compute-plan.d.ts.map +1 -1
- package/dist/engine/compute-plan.js +303 -12
- package/dist/engine/engine.types.d.ts +61 -0
- package/dist/engine/engine.types.d.ts.map +1 -1
- package/dist/engine/execute-plan.d.ts +22 -2
- package/dist/engine/execute-plan.d.ts.map +1 -1
- package/dist/engine/execute-plan.js +459 -27
- package/dist/engine/provider-path-safety.d.ts +1 -0
- package/dist/engine/provider-path-safety.d.ts.map +1 -1
- package/dist/engine/provider-path-safety.js +15 -0
- package/dist/engine/scanner.d.ts +2 -2
- package/dist/engine/scanner.d.ts.map +1 -1
- package/dist/engine/scanner.js +21 -4
- package/dist/fs/io.d.ts +67 -0
- package/dist/fs/io.d.ts.map +1 -1
- package/dist/fs/io.js +536 -3
- package/dist/manifest/index.d.ts +2 -2
- package/dist/manifest/index.d.ts.map +1 -1
- package/dist/manifest/index.js +1 -1
- package/dist/manifest/manager.d.ts +2 -2
- package/dist/manifest/manager.d.ts.map +1 -1
- package/dist/manifest/manager.js +20 -3
- package/dist/manifest/manifest.types.d.ts +704 -13
- package/dist/manifest/manifest.types.d.ts.map +1 -1
- package/dist/manifest/manifest.types.js +131 -15
- package/dist/providers/codex/codec/sync-extension.d.ts.map +1 -1
- package/dist/providers/codex/codec/sync-extension.js +41 -10
- package/dist/providers/cursor/codec/sync-extension.d.ts.map +1 -1
- package/dist/providers/cursor/codec/sync-extension.js +41 -10
- package/dist/providers/identity/absolute-paths.d.ts +68 -0
- package/dist/providers/identity/absolute-paths.d.ts.map +1 -0
- package/dist/providers/identity/absolute-paths.js +144 -0
- package/dist/providers/identity/claude-runtime-observation.d.ts +105 -0
- package/dist/providers/identity/claude-runtime-observation.d.ts.map +1 -0
- package/dist/providers/identity/claude-runtime-observation.fixtures.d.ts +40 -0
- package/dist/providers/identity/claude-runtime-observation.fixtures.d.ts.map +1 -0
- package/dist/providers/identity/claude-runtime-observation.fixtures.js +146 -0
- package/dist/providers/identity/claude-runtime-observation.js +229 -0
- package/dist/providers/identity/codex-runtime-observation.d.ts +75 -0
- package/dist/providers/identity/codex-runtime-observation.d.ts.map +1 -0
- package/dist/providers/identity/codex-runtime-observation.fixtures.d.ts +37 -0
- package/dist/providers/identity/codex-runtime-observation.fixtures.d.ts.map +1 -0
- package/dist/providers/identity/codex-runtime-observation.fixtures.js +539 -0
- package/dist/providers/identity/codex-runtime-observation.js +255 -0
- package/dist/providers/identity/generic-dispatch-record.d.ts +313 -0
- package/dist/providers/identity/generic-dispatch-record.d.ts.map +1 -0
- package/dist/providers/identity/generic-dispatch-record.js +539 -0
- package/dist/providers/identity/oat-dispatch-record.d.ts +1964 -0
- package/dist/providers/identity/oat-dispatch-record.d.ts.map +1 -0
- package/dist/providers/identity/oat-dispatch-record.js +691 -0
- package/dist/providers/identity/runtime-observation.d.ts +100 -0
- package/dist/providers/identity/runtime-observation.d.ts.map +1 -0
- package/dist/providers/identity/runtime-observation.js +169 -0
- package/dist/providers/shared/index.d.ts +2 -0
- package/dist/providers/shared/index.d.ts.map +1 -1
- package/dist/providers/shared/index.js +1 -0
- package/dist/providers/shared/materialization-extension.d.ts +12 -0
- package/dist/providers/shared/materialization-extension.d.ts.map +1 -1
- package/dist/providers/shared/registry.d.ts +69 -0
- package/dist/providers/shared/registry.d.ts.map +1 -0
- package/dist/providers/shared/registry.js +252 -0
- package/dist/providers/shared/restart-adviser.d.ts +26 -0
- package/dist/providers/shared/restart-adviser.d.ts.map +1 -0
- package/dist/providers/shared/restart-adviser.js +96 -0
- package/dist/shared/types.d.ts +4 -0
- package/dist/shared/types.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/fs/io.d.ts
CHANGED
|
@@ -1,10 +1,77 @@
|
|
|
1
1
|
export declare function fileExists(path: string): Promise<boolean>;
|
|
2
2
|
export declare function dirExists(path: string): Promise<boolean>;
|
|
3
3
|
export type LinkStrategy = 'symlink' | 'copy';
|
|
4
|
+
export interface CreatedCollectionSymlink {
|
|
5
|
+
linkText: string;
|
|
6
|
+
device: string;
|
|
7
|
+
inode: string;
|
|
8
|
+
}
|
|
9
|
+
export interface CollectionSymlinkCreationGuard {
|
|
10
|
+
scopeRoot: string;
|
|
11
|
+
expectedParent: {
|
|
12
|
+
device: string;
|
|
13
|
+
inode: string;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
4
16
|
export type CopyDirectoryFilter = (sourcePath: string, relativePath: string) => boolean | Promise<boolean>;
|
|
5
17
|
export declare function ensureDir(dirPath: string): Promise<void>;
|
|
6
18
|
export declare function copyDirectory(src: string, dest: string, filter?: CopyDirectoryFilter, sourceRoot?: string): Promise<void>;
|
|
7
19
|
export declare function copySingleFile(src: string, dest: string): Promise<void>;
|
|
20
|
+
export declare function copySingleFileNoClobber(src: string, dest: string): Promise<void>;
|
|
21
|
+
export declare function writeFileNoClobber(dest: string, content: string): Promise<void>;
|
|
8
22
|
export declare function createSymlink(target: string, linkPath: string, onFallback?: (error: unknown) => void, isFile?: boolean): Promise<LinkStrategy>;
|
|
23
|
+
export declare function createSymlinkNoClobber(target: string, linkPath: string, isFile?: boolean): Promise<'symlink'>;
|
|
24
|
+
export declare function createCollectionSymlinkNoClobber(_target: string, linkPath: string, guard: CollectionSymlinkCreationGuard): Promise<CreatedCollectionSymlink>;
|
|
25
|
+
export declare function removeCollectionSymlinkIfUnchanged(linkPath: string, created: CreatedCollectionSymlink): Promise<boolean>;
|
|
26
|
+
/**
|
|
27
|
+
* The one serialization of a JSON document OAT writes to disk.
|
|
28
|
+
*
|
|
29
|
+
* Every size bound that claims to constrain a published file must measure this
|
|
30
|
+
* exact string. Validating compact `JSON.stringify` while writing pretty-printed
|
|
31
|
+
* output with a trailing newline let a record validated at 65,536 bytes land on
|
|
32
|
+
* disk at 65,874.
|
|
33
|
+
*/
|
|
34
|
+
export declare function serializeJsonDocument(data: unknown): string;
|
|
9
35
|
export declare function atomicWriteJson(filePath: string, data: unknown): Promise<void>;
|
|
36
|
+
export interface ContainedWriterLockOptions {
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
minReclaimMs?: number;
|
|
39
|
+
hardStaleMs?: number;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Exclusive contained writer lock. `mkdir` without `recursive` is the atomic
|
|
43
|
+
* primitive: exactly one caller can create the directory, and every other
|
|
44
|
+
* caller observes `EEXIST`. The lock is validated as a real directory so a
|
|
45
|
+
* planted symlink fails closed instead of redirecting the guarded section, it
|
|
46
|
+
* records holder evidence so a crashed writer can be reclaimed rather than
|
|
47
|
+
* wedging the project forever, and no message it raises contains an absolute
|
|
48
|
+
* path.
|
|
49
|
+
*/
|
|
50
|
+
export declare function withContainedWriterLock<T>(lockPath: string, scopeRoot: string, run: () => Promise<T>, options?: ContainedWriterLockOptions): Promise<T>;
|
|
51
|
+
export declare function journalErrorCode(error: unknown): string | null;
|
|
52
|
+
/**
|
|
53
|
+
* Re-raise a filesystem error without Node's absolute-path message text while
|
|
54
|
+
* preserving its `code` so callers can still branch on `EEXIST`/`ENOENT`.
|
|
55
|
+
* Exported so every journal producer redacts identically instead of each one
|
|
56
|
+
* re-deriving the rule.
|
|
57
|
+
*/
|
|
58
|
+
export declare function redactedFsError(error: unknown, operation: string, relativeTarget: string): Error;
|
|
59
|
+
/**
|
|
60
|
+
* Publish one immutable JSON journal revision.
|
|
61
|
+
*
|
|
62
|
+
* Guarantees, stated as what the code does:
|
|
63
|
+
* - The destination is created with `link` only. An occupied destination raises
|
|
64
|
+
* `EEXIST`; the destination is never renamed onto, removed, or truncated.
|
|
65
|
+
* - After publication the destination is proven to be the exact staged inode,
|
|
66
|
+
* inside the same directory inode validated before staging, still resolving
|
|
67
|
+
* inside `scopeRoot`. Failure raises and removes nothing.
|
|
68
|
+
* - Staging cleanup removes only the inode this call created.
|
|
69
|
+
* - No raised message contains an absolute path.
|
|
70
|
+
*
|
|
71
|
+
* Residual, unchanged by this design: a privileged concurrent process that
|
|
72
|
+
* swaps the validated directory pathname can cause the `link` to create an
|
|
73
|
+
* unreferenced file under a directory it controls, and can strand the staged
|
|
74
|
+
* file. Neither outcome replaces or deletes pre-existing content.
|
|
75
|
+
*/
|
|
76
|
+
export declare function publishContainedJsonRevision(filePath: string, data: unknown, scopeRoot: string): Promise<void>;
|
|
10
77
|
//# sourceMappingURL=io.d.ts.map
|
package/dist/fs/io.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"io.d.ts","sourceRoot":"","sources":["../../src/fs/io.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"io.d.ts","sourceRoot":"","sources":["../../src/fs/io.ts"],"names":[],"mappings":"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"}
|
package/dist/fs/io.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmod, lstat, link, mkdir, open, readdir, readFile, readlink, realpath, rename, rm, stat, symlink, writeFile, } from 'node:fs/promises';
|
|
3
|
+
import { hostname } from 'node:os';
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep, } from 'node:path';
|
|
3
5
|
export async function fileExists(path) {
|
|
4
6
|
try {
|
|
5
7
|
const s = await stat(path);
|
|
@@ -18,6 +20,7 @@ export async function dirExists(path) {
|
|
|
18
20
|
return false;
|
|
19
21
|
}
|
|
20
22
|
}
|
|
23
|
+
const COLLECTION_LINK_UNSAFE_CODE = 'E_COLLECTION_LINK_UNSAFE';
|
|
21
24
|
export async function ensureDir(dirPath) {
|
|
22
25
|
await mkdir(dirPath, { recursive: true });
|
|
23
26
|
}
|
|
@@ -49,6 +52,15 @@ export async function copySingleFile(src, dest) {
|
|
|
49
52
|
const content = await readFile(src);
|
|
50
53
|
await writeFile(dest, content);
|
|
51
54
|
}
|
|
55
|
+
export async function copySingleFileNoClobber(src, dest) {
|
|
56
|
+
await ensureDir(dirname(dest));
|
|
57
|
+
const content = await readFile(src);
|
|
58
|
+
await writeFile(dest, content, { flag: 'wx' });
|
|
59
|
+
}
|
|
60
|
+
export async function writeFileNoClobber(dest, content) {
|
|
61
|
+
await ensureDir(dirname(dest));
|
|
62
|
+
await writeFile(dest, content, { encoding: 'utf8', flag: 'wx' });
|
|
63
|
+
}
|
|
52
64
|
export async function createSymlink(target, linkPath, onFallback, isFile) {
|
|
53
65
|
await ensureDir(dirname(linkPath));
|
|
54
66
|
// Use relative symlink targets so links stay valid when the source tree
|
|
@@ -73,9 +85,530 @@ export async function createSymlink(target, linkPath, onFallback, isFile) {
|
|
|
73
85
|
return 'copy';
|
|
74
86
|
}
|
|
75
87
|
}
|
|
88
|
+
export async function createSymlinkNoClobber(target, linkPath, isFile) {
|
|
89
|
+
await ensureDir(dirname(linkPath));
|
|
90
|
+
const symlinkTarget = isAbsolute(target)
|
|
91
|
+
? relative(dirname(linkPath), target)
|
|
92
|
+
: target;
|
|
93
|
+
await symlink(symlinkTarget, linkPath, isFile ? 'file' : 'dir');
|
|
94
|
+
return 'symlink';
|
|
95
|
+
}
|
|
96
|
+
export async function createCollectionSymlinkNoClobber(_target, linkPath, guard) {
|
|
97
|
+
const scopeRoot = resolve(guard.scopeRoot);
|
|
98
|
+
const parent = resolve(dirname(linkPath));
|
|
99
|
+
const relativeParent = relative(scopeRoot, parent);
|
|
100
|
+
if (relativeParent === '..' ||
|
|
101
|
+
relativeParent.startsWith(`..${sep}`) ||
|
|
102
|
+
isAbsolute(relativeParent)) {
|
|
103
|
+
throw new Error('Collection destination parent is outside the sync scope.');
|
|
104
|
+
}
|
|
105
|
+
const parentSegments = relativeParent === '' ? [] : relativeParent.split(sep);
|
|
106
|
+
const ancestors = [scopeRoot];
|
|
107
|
+
let current = scopeRoot;
|
|
108
|
+
for (const segment of parentSegments) {
|
|
109
|
+
current = resolve(current, segment);
|
|
110
|
+
ancestors.push(current);
|
|
111
|
+
}
|
|
112
|
+
let nearestExisting;
|
|
113
|
+
for (const ancestor of [...ancestors].reverse()) {
|
|
114
|
+
try {
|
|
115
|
+
nearestExisting = await lstat(ancestor);
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (typeof error !== 'object' ||
|
|
120
|
+
error === null ||
|
|
121
|
+
!('code' in error) ||
|
|
122
|
+
error.code !== 'ENOENT') {
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (nearestExisting === undefined ||
|
|
128
|
+
nearestExisting.isSymbolicLink() ||
|
|
129
|
+
!nearestExisting.isDirectory() ||
|
|
130
|
+
String(nearestExisting.dev) !== guard.expectedParent.device ||
|
|
131
|
+
String(nearestExisting.ino) !== guard.expectedParent.inode) {
|
|
132
|
+
throw new Error('Collection destination ancestry changed before creation.');
|
|
133
|
+
}
|
|
134
|
+
for (const ancestor of ancestors) {
|
|
135
|
+
let ancestorStat;
|
|
136
|
+
try {
|
|
137
|
+
ancestorStat = await lstat(ancestor);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (typeof error !== 'object' ||
|
|
141
|
+
error === null ||
|
|
142
|
+
!('code' in error) ||
|
|
143
|
+
error.code !== 'ENOENT') {
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (ancestorStat.isSymbolicLink() || !ancestorStat.isDirectory()) {
|
|
149
|
+
throw new Error('Collection destination ancestry must contain only real directories.');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
await lstat(linkPath);
|
|
154
|
+
throw Object.assign(new Error('Collection destination already exists.'), {
|
|
155
|
+
code: 'EEXIST',
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (typeof error !== 'object' ||
|
|
160
|
+
error === null ||
|
|
161
|
+
!('code' in error) ||
|
|
162
|
+
error.code !== 'ENOENT') {
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
throw Object.assign(new Error('Collection link creation is disabled because this runtime has no securely guarded parent-relative symlink primitive.'), { code: COLLECTION_LINK_UNSAFE_CODE });
|
|
167
|
+
}
|
|
168
|
+
export async function removeCollectionSymlinkIfUnchanged(linkPath, created) {
|
|
169
|
+
try {
|
|
170
|
+
const current = await lstat(linkPath);
|
|
171
|
+
if (!current.isSymbolicLink() ||
|
|
172
|
+
String(current.dev) !== created.device ||
|
|
173
|
+
String(current.ino) !== created.inode) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
if ((await readlink(linkPath)) !== created.linkText) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
// Node's path-based unlink cannot bind the final removal to the identity
|
|
180
|
+
// proven above. Preserve the alias until a guarded parent-relative
|
|
181
|
+
// no-follow removal primitive is available.
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The one serialization of a JSON document OAT writes to disk.
|
|
190
|
+
*
|
|
191
|
+
* Every size bound that claims to constrain a published file must measure this
|
|
192
|
+
* exact string. Validating compact `JSON.stringify` while writing pretty-printed
|
|
193
|
+
* output with a trailing newline let a record validated at 65,536 bytes land on
|
|
194
|
+
* disk at 65,874.
|
|
195
|
+
*/
|
|
196
|
+
export function serializeJsonDocument(data) {
|
|
197
|
+
return `${JSON.stringify(data, null, 2)}\n`;
|
|
198
|
+
}
|
|
76
199
|
export async function atomicWriteJson(filePath, data) {
|
|
77
200
|
const tempPath = `${filePath}.tmp`;
|
|
78
201
|
await ensureDir(dirname(filePath));
|
|
79
|
-
await writeFile(tempPath,
|
|
202
|
+
await writeFile(tempPath, serializeJsonDocument(data), 'utf8');
|
|
80
203
|
await rename(tempPath, filePath);
|
|
81
204
|
}
|
|
205
|
+
const WRITER_LOCK_RETRY_MS = 10;
|
|
206
|
+
const WRITER_LOCK_TIMEOUT_MS = 5000;
|
|
207
|
+
/** A lock younger than this is never reclaimed, so a writer that has not yet
|
|
208
|
+
* written its holder file is not mistaken for a crash. */
|
|
209
|
+
const WRITER_LOCK_MIN_RECLAIM_MS = 1000;
|
|
210
|
+
/**
|
|
211
|
+
* Hard cap. A lock older than this is reclaimed unconditionally — including one
|
|
212
|
+
* whose holder is provably alive — because a dispatch write is a sub-second
|
|
213
|
+
* operation and a holder past this bound is hung rather than working. This is
|
|
214
|
+
* safe because the lock is not what protects the journal: the create-only
|
|
215
|
+
* revision compare-and-swap is, and it holds independently of any lock, so a
|
|
216
|
+
* wrongly reclaimed lock costs mutual exclusion but never correctness.
|
|
217
|
+
*/
|
|
218
|
+
const WRITER_LOCK_HARD_STALE_MS = 15 * 60 * 1000;
|
|
219
|
+
const HOLDER_FILE = 'holder.json';
|
|
220
|
+
/**
|
|
221
|
+
* Non-identifying, stable-per-host token. Liveness of a recorded pid is only
|
|
222
|
+
* meaningful on the host that recorded it, and the raw hostname is never
|
|
223
|
+
* written into a project artifact or an error message.
|
|
224
|
+
*/
|
|
225
|
+
function hostId() {
|
|
226
|
+
return createHash('sha256')
|
|
227
|
+
.update(hostname(), 'utf8')
|
|
228
|
+
.digest('hex')
|
|
229
|
+
.slice(0, 16);
|
|
230
|
+
}
|
|
231
|
+
function processStartedAt() {
|
|
232
|
+
return Math.round(Date.now() - process.uptime() * 1000);
|
|
233
|
+
}
|
|
234
|
+
function holderIsRunning(holder) {
|
|
235
|
+
if (holder.hostId !== hostId()) {
|
|
236
|
+
// Another host's pid says nothing about this one; fall back to age alone.
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
process.kill(holder.pid, 0);
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
// EPERM means the process exists but is owned by somebody else.
|
|
245
|
+
return errorCode(error) !== 'ESRCH';
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
async function readWriterLockHolder(lockPath) {
|
|
249
|
+
try {
|
|
250
|
+
const parsed = JSON.parse(await readFile(join(lockPath, HOLDER_FILE), 'utf8'));
|
|
251
|
+
if (typeof parsed !== 'object' ||
|
|
252
|
+
parsed === null ||
|
|
253
|
+
typeof parsed.pid !== 'number' ||
|
|
254
|
+
typeof parsed.hostId !== 'string') {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
return parsed;
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Bounded staleness policy. A lock is reclaimable when it is old enough that a
|
|
265
|
+
* live writer would have finished, and either its recorded holder is provably
|
|
266
|
+
* gone on this host or its age exceeds the hard cap. Returns the reason so the
|
|
267
|
+
* caller can report what it did.
|
|
268
|
+
*/
|
|
269
|
+
async function writerLockStaleReason(lockPath, minReclaimMs, hardStaleMs) {
|
|
270
|
+
let age;
|
|
271
|
+
try {
|
|
272
|
+
age = Date.now() - (await lstat(lockPath)).mtimeMs;
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
if (age < minReclaimMs)
|
|
278
|
+
return null;
|
|
279
|
+
const holder = await readWriterLockHolder(lockPath);
|
|
280
|
+
if (holder !== null && !holderIsRunning(holder)) {
|
|
281
|
+
return 'its recorded holder process is no longer running';
|
|
282
|
+
}
|
|
283
|
+
return age >= hardStaleMs ? 'it exceeded the staleness cap' : null;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Exclusive contained writer lock. `mkdir` without `recursive` is the atomic
|
|
287
|
+
* primitive: exactly one caller can create the directory, and every other
|
|
288
|
+
* caller observes `EEXIST`. The lock is validated as a real directory so a
|
|
289
|
+
* planted symlink fails closed instead of redirecting the guarded section, it
|
|
290
|
+
* records holder evidence so a crashed writer can be reclaimed rather than
|
|
291
|
+
* wedging the project forever, and no message it raises contains an absolute
|
|
292
|
+
* path.
|
|
293
|
+
*/
|
|
294
|
+
export async function withContainedWriterLock(lockPath, scopeRoot, run, options = {}) {
|
|
295
|
+
const resolvedScope = resolve(scopeRoot);
|
|
296
|
+
const resolvedLock = resolve(lockPath);
|
|
297
|
+
if (dirname(resolvedLock) !== resolvedScope) {
|
|
298
|
+
throw new Error('Writer lock must live directly inside its scope root.');
|
|
299
|
+
}
|
|
300
|
+
const scopeStat = await lstat(resolvedScope);
|
|
301
|
+
if (scopeStat.isSymbolicLink() || !scopeStat.isDirectory()) {
|
|
302
|
+
throw new Error('Writer lock scope must be a real directory.');
|
|
303
|
+
}
|
|
304
|
+
const reportedLock = scopeRelative(resolvedScope, resolvedLock);
|
|
305
|
+
const timeoutMs = options.timeoutMs ?? WRITER_LOCK_TIMEOUT_MS;
|
|
306
|
+
const deadline = Date.now() + timeoutMs;
|
|
307
|
+
let reclaimed = false;
|
|
308
|
+
for (;;) {
|
|
309
|
+
try {
|
|
310
|
+
await mkdir(resolvedLock);
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
if (errorCode(error) !== 'EEXIST') {
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
if (!reclaimed) {
|
|
318
|
+
const stale = await writerLockStaleReason(resolvedLock, options.minReclaimMs ?? WRITER_LOCK_MIN_RECLAIM_MS, options.hardStaleMs ?? WRITER_LOCK_HARD_STALE_MS);
|
|
319
|
+
if (stale !== null) {
|
|
320
|
+
const held = await lstat(resolvedLock);
|
|
321
|
+
if (held.isSymbolicLink() || !held.isDirectory()) {
|
|
322
|
+
throw new Error(`Writer lock ${reportedLock} is not a real directory.`, { cause: error });
|
|
323
|
+
}
|
|
324
|
+
await rm(resolvedLock, { recursive: true, force: true });
|
|
325
|
+
reclaimed = true;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (Date.now() >= deadline) {
|
|
330
|
+
throw new Error(`Another writer holds ${reportedLock}. If no writer is running, remove ${reportedLock} inside the project and retry.`, { cause: error });
|
|
331
|
+
}
|
|
332
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, WRITER_LOCK_RETRY_MS));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const holder = {
|
|
336
|
+
hostId: hostId(),
|
|
337
|
+
pid: process.pid,
|
|
338
|
+
processStartedAt: processStartedAt(),
|
|
339
|
+
acquiredAt: new Date().toISOString(),
|
|
340
|
+
};
|
|
341
|
+
const held = await lstat(resolvedLock);
|
|
342
|
+
if (held.isSymbolicLink() || !held.isDirectory()) {
|
|
343
|
+
throw new Error(`Writer lock ${reportedLock} is not a real directory.`);
|
|
344
|
+
}
|
|
345
|
+
const lockIdentity = identityOf(held);
|
|
346
|
+
try {
|
|
347
|
+
await writeFile(join(resolvedLock, HOLDER_FILE), `${JSON.stringify(holder, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
|
|
348
|
+
return await run();
|
|
349
|
+
}
|
|
350
|
+
finally {
|
|
351
|
+
await releaseWriterLock(resolvedLock, lockIdentity, holder);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Release only a lock this call still provably holds. Both the directory
|
|
356
|
+
* identity captured at acquisition and the holder record must still match. A
|
|
357
|
+
* missing holder file counts as "not mine" — it is exactly the window in which
|
|
358
|
+
* a reclaiming writer has re-created the directory but not yet written its own
|
|
359
|
+
* record — so the replacement is left to the staleness policy rather than
|
|
360
|
+
* removed from under its new owner.
|
|
361
|
+
*/
|
|
362
|
+
async function releaseWriterLock(lockPath, identity, holder) {
|
|
363
|
+
try {
|
|
364
|
+
const current = await lstat(lockPath);
|
|
365
|
+
if (current.isSymbolicLink() || !current.isDirectory())
|
|
366
|
+
return;
|
|
367
|
+
if (!sameIdentity(identityOf(current), identity))
|
|
368
|
+
return;
|
|
369
|
+
const currentHolder = await readWriterLockHolder(lockPath);
|
|
370
|
+
if (currentHolder === null ||
|
|
371
|
+
currentHolder.hostId !== holder.hostId ||
|
|
372
|
+
currentHolder.pid !== holder.pid ||
|
|
373
|
+
currentHolder.acquiredAt !== holder.acquiredAt) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
// Nothing releasable remains.
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
function identityOf(entry) {
|
|
383
|
+
return { device: String(entry.dev), inode: String(entry.ino) };
|
|
384
|
+
}
|
|
385
|
+
function sameIdentity(left, right) {
|
|
386
|
+
return left.device === right.device && left.inode === right.inode;
|
|
387
|
+
}
|
|
388
|
+
async function assertUnchangedIdentity(path, expected, message) {
|
|
389
|
+
if (!sameIdentity(identityOf(await lstat(path)), expected)) {
|
|
390
|
+
throw new Error(message);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Scope-relative rendering for any path this module reports. NFR1 requires that
|
|
395
|
+
* home paths and user-specific absolute paths stay out of durable output, so no
|
|
396
|
+
* error raised below may carry an absolute path.
|
|
397
|
+
*/
|
|
398
|
+
function scopeRelative(resolvedScope, target) {
|
|
399
|
+
const rendered = relative(resolvedScope, resolve(target));
|
|
400
|
+
return rendered === '' ? '.' : rendered.split(sep).join('/');
|
|
401
|
+
}
|
|
402
|
+
export function journalErrorCode(error) {
|
|
403
|
+
return errorCode(error);
|
|
404
|
+
}
|
|
405
|
+
function errorCode(error) {
|
|
406
|
+
return typeof error === 'object' && error !== null && 'code' in error
|
|
407
|
+
? String(error.code)
|
|
408
|
+
: null;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Re-raise a filesystem error without Node's absolute-path message text while
|
|
412
|
+
* preserving its `code` so callers can still branch on `EEXIST`/`ENOENT`.
|
|
413
|
+
* Exported so every journal producer redacts identically instead of each one
|
|
414
|
+
* re-deriving the rule.
|
|
415
|
+
*/
|
|
416
|
+
export function redactedFsError(error, operation, relativeTarget) {
|
|
417
|
+
const code = errorCode(error);
|
|
418
|
+
const raised = new Error(`Journal ${operation} failed for ${relativeTarget}${code === null ? '' : ` (${code})`}.`, { cause: error });
|
|
419
|
+
return code === null ? raised : Object.assign(raised, { code });
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Node exposes no `openat`/`renameat`/`linkat`, so a path-based final operation
|
|
423
|
+
* cannot be bound to a directory file descriptor. Publication is therefore
|
|
424
|
+
* append-only and create-only: the destination must not already exist, the only
|
|
425
|
+
* publishing syscall is `link`, and this module never issues `rename`, `rm`, or
|
|
426
|
+
* `unlink` against a publication destination. A pathname swapped after the last
|
|
427
|
+
* check can therefore cause at most an unreferenced create, never a replacement
|
|
428
|
+
* or a deletion of content this call did not create.
|
|
429
|
+
*/
|
|
430
|
+
async function publicationIsMisplaced(resolvedFile, tempIdentity, parentIdentity, realScope) {
|
|
431
|
+
let published;
|
|
432
|
+
try {
|
|
433
|
+
published = await lstat(resolvedFile);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
return 'the published name disappeared';
|
|
437
|
+
}
|
|
438
|
+
if (published.isSymbolicLink() || !published.isFile()) {
|
|
439
|
+
return 'the published name is not a regular file';
|
|
440
|
+
}
|
|
441
|
+
if (!sameIdentity(identityOf(published), tempIdentity)) {
|
|
442
|
+
return 'the published name is not the staged file';
|
|
443
|
+
}
|
|
444
|
+
try {
|
|
445
|
+
if (!sameIdentity(identityOf(await lstat(dirname(resolvedFile))), parentIdentity)) {
|
|
446
|
+
return 'the parent directory identity changed';
|
|
447
|
+
}
|
|
448
|
+
const currentRealParent = await realpath(dirname(resolvedFile));
|
|
449
|
+
const currentRelative = relative(realScope, currentRealParent);
|
|
450
|
+
if (currentRelative === '..' ||
|
|
451
|
+
currentRelative.startsWith(`..${sep}`) ||
|
|
452
|
+
isAbsolute(currentRelative)) {
|
|
453
|
+
return 'the parent directory resolves outside the allowed scope';
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
return 'the parent directory could not be re-validated';
|
|
458
|
+
}
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Remove the staging file only when the name still resolves to the exact inode
|
|
463
|
+
* this call created. A swapped directory pathname therefore strands the staged
|
|
464
|
+
* file rather than deleting an unrelated one. Returns whether the staged data
|
|
465
|
+
* was provably removed, so callers can report the outcome accurately.
|
|
466
|
+
*
|
|
467
|
+
* Precisely: this removes only the *inode* this call created, not only the
|
|
468
|
+
* *names* it created. A privileged process that hard-links the staged file into
|
|
469
|
+
* a directory it controls and then swaps the journal pathname at it can have
|
|
470
|
+
* that alias unlinked here. No data is destroyed — the inode stays linked from
|
|
471
|
+
* the real directory, and no pre-existing foreign file can ever match, since a
|
|
472
|
+
* distinct file has a distinct dev/ino pair.
|
|
473
|
+
*/
|
|
474
|
+
async function removeStagedFile(tempPath, tempIdentity) {
|
|
475
|
+
try {
|
|
476
|
+
if (tempIdentity === null) {
|
|
477
|
+
await rm(tempPath, { force: true });
|
|
478
|
+
return 'removed';
|
|
479
|
+
}
|
|
480
|
+
const current = await lstat(tempPath);
|
|
481
|
+
if (!sameIdentity(identityOf(current), tempIdentity)) {
|
|
482
|
+
return 'stranded';
|
|
483
|
+
}
|
|
484
|
+
await rm(tempPath, { force: true });
|
|
485
|
+
return 'removed';
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
return 'stranded';
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Publish one immutable JSON journal revision.
|
|
493
|
+
*
|
|
494
|
+
* Guarantees, stated as what the code does:
|
|
495
|
+
* - The destination is created with `link` only. An occupied destination raises
|
|
496
|
+
* `EEXIST`; the destination is never renamed onto, removed, or truncated.
|
|
497
|
+
* - After publication the destination is proven to be the exact staged inode,
|
|
498
|
+
* inside the same directory inode validated before staging, still resolving
|
|
499
|
+
* inside `scopeRoot`. Failure raises and removes nothing.
|
|
500
|
+
* - Staging cleanup removes only the inode this call created.
|
|
501
|
+
* - No raised message contains an absolute path.
|
|
502
|
+
*
|
|
503
|
+
* Residual, unchanged by this design: a privileged concurrent process that
|
|
504
|
+
* swaps the validated directory pathname can cause the `link` to create an
|
|
505
|
+
* unreferenced file under a directory it controls, and can strand the staged
|
|
506
|
+
* file. Neither outcome replaces or deletes pre-existing content.
|
|
507
|
+
*/
|
|
508
|
+
export async function publishContainedJsonRevision(filePath, data, scopeRoot) {
|
|
509
|
+
const resolvedScope = resolve(scopeRoot);
|
|
510
|
+
const resolvedFile = resolve(filePath);
|
|
511
|
+
const relativeFile = relative(resolvedScope, resolvedFile);
|
|
512
|
+
if (relativeFile === '..' ||
|
|
513
|
+
relativeFile.startsWith(`..${sep}`) ||
|
|
514
|
+
isAbsolute(relativeFile)) {
|
|
515
|
+
throw new Error('JSON journal destination is outside its allowed scope.');
|
|
516
|
+
}
|
|
517
|
+
const reportedFile = scopeRelative(resolvedScope, resolvedFile);
|
|
518
|
+
const scopeStat = await lstat(resolvedScope);
|
|
519
|
+
if (scopeStat.isSymbolicLink() || !scopeStat.isDirectory()) {
|
|
520
|
+
throw new Error('JSON journal scope must be a real directory.');
|
|
521
|
+
}
|
|
522
|
+
const realScope = await realpath(resolvedScope);
|
|
523
|
+
const parentRelative = relative(resolvedScope, dirname(resolvedFile));
|
|
524
|
+
const segments = parentRelative === '' ? [] : parentRelative.split(sep);
|
|
525
|
+
let current = resolvedScope;
|
|
526
|
+
for (const segment of segments) {
|
|
527
|
+
current = join(current, segment);
|
|
528
|
+
try {
|
|
529
|
+
const currentStat = await lstat(current);
|
|
530
|
+
if (currentStat.isSymbolicLink() || !currentStat.isDirectory()) {
|
|
531
|
+
throw new Error('JSON journal ancestry must not contain symlinks and must contain only real directories.');
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
if (errorCode(error) !== 'ENOENT') {
|
|
536
|
+
throw error;
|
|
537
|
+
}
|
|
538
|
+
await mkdir(current);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const realParent = await realpath(dirname(resolvedFile));
|
|
542
|
+
const relativeParent = relative(realScope, realParent);
|
|
543
|
+
if (relativeParent === '..' ||
|
|
544
|
+
relativeParent.startsWith(`..${sep}`) ||
|
|
545
|
+
isAbsolute(relativeParent)) {
|
|
546
|
+
throw new Error('JSON journal ancestry resolves outside its allowed scope.');
|
|
547
|
+
}
|
|
548
|
+
// Publication is create-only, so an already-published revision is a lost
|
|
549
|
+
// compare-and-swap rather than something to overwrite.
|
|
550
|
+
try {
|
|
551
|
+
await lstat(resolvedFile);
|
|
552
|
+
throw Object.assign(new Error(`Journal revision ${reportedFile} already exists.`), { code: 'EEXIST' });
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
if (errorCode(error) !== 'ENOENT') {
|
|
556
|
+
throw error;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
const parentIdentity = identityOf(await lstat(dirname(resolvedFile)));
|
|
560
|
+
const content = serializeJsonDocument(data);
|
|
561
|
+
const tempPath = join(dirname(resolvedFile), `.${basename(resolvedFile)}.${randomUUID()}.tmp`);
|
|
562
|
+
const reportedTemp = scopeRelative(resolvedScope, tempPath);
|
|
563
|
+
let tempCreated = false;
|
|
564
|
+
let stagedIdentity = null;
|
|
565
|
+
try {
|
|
566
|
+
let handle;
|
|
567
|
+
try {
|
|
568
|
+
handle = await open(tempPath, 'wx');
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
throw redactedFsError(error, 'staging', reportedTemp);
|
|
572
|
+
}
|
|
573
|
+
let tempIdentity;
|
|
574
|
+
try {
|
|
575
|
+
tempCreated = true;
|
|
576
|
+
await handle.writeFile(content, 'utf8');
|
|
577
|
+
tempIdentity = identityOf(await handle.stat());
|
|
578
|
+
stagedIdentity = tempIdentity;
|
|
579
|
+
}
|
|
580
|
+
finally {
|
|
581
|
+
await handle.close();
|
|
582
|
+
}
|
|
583
|
+
// Last ancestry check, then the identity binding that narrows the window
|
|
584
|
+
// between that check and the path-based publication below.
|
|
585
|
+
if ((await realpath(dirname(resolvedFile))) !== realParent) {
|
|
586
|
+
throw new Error('JSON journal ancestry changed before publication.');
|
|
587
|
+
}
|
|
588
|
+
await assertUnchangedIdentity(dirname(resolvedFile), parentIdentity, 'JSON journal directory identity changed before publication.');
|
|
589
|
+
await assertUnchangedIdentity(tempPath, tempIdentity, 'JSON journal staging file identity changed before publication.');
|
|
590
|
+
try {
|
|
591
|
+
await link(tempPath, resolvedFile);
|
|
592
|
+
}
|
|
593
|
+
catch (error) {
|
|
594
|
+
throw redactedFsError(error, 'publication', reportedFile);
|
|
595
|
+
}
|
|
596
|
+
const misplaced = await publicationIsMisplaced(resolvedFile, tempIdentity, parentIdentity, realScope);
|
|
597
|
+
if (misplaced) {
|
|
598
|
+
// Never remove the destination: under a swapped pathname it may name
|
|
599
|
+
// content this call did not create.
|
|
600
|
+
const staged = await removeStagedFile(tempPath, tempIdentity);
|
|
601
|
+
tempCreated = false;
|
|
602
|
+
throw new Error(`Journal revision ${reportedFile} could not be verified inside the validated directory (${misplaced}). It was not published to the project journal, and this call removed and replaced nothing. ${staged === 'removed'
|
|
603
|
+
? 'Staged data was removed.'
|
|
604
|
+
: 'A staged copy may remain outside the validated directory.'}`);
|
|
605
|
+
}
|
|
606
|
+
await removeStagedFile(tempPath, tempIdentity);
|
|
607
|
+
tempCreated = false;
|
|
608
|
+
}
|
|
609
|
+
finally {
|
|
610
|
+
if (tempCreated) {
|
|
611
|
+
await removeStagedFile(tempPath, stagedIdentity);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
package/dist/manifest/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { computeDirectoryHash } from './hash.js';
|
|
2
2
|
export { addEntry, createEmptyManifest, findEntry, loadManifest, removeEntry, saveManifest, } from './manager.js';
|
|
3
|
-
export type { Manifest, ManifestEntry } from './manifest.types.js';
|
|
4
|
-
export { ManifestEntrySchema, ManifestSchema } from './manifest.types.js';
|
|
3
|
+
export type { Manifest, ManifestCollectionEntry, ManifestEntry, ManifestEntryV2, ManifestV1, ManifestV2, } from './manifest.types.js';
|
|
4
|
+
export { ManifestCollectionEntrySchema, ManifestEntrySchema, ManifestSchema, ManifestV1Schema, ManifestV2Schema, } from './manifest.types.js';
|
|
5
5
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/manifest/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EACL,QAAQ,EACR,mBAAmB,EACnB,SAAS,EACT,YAAY,EACZ,WAAW,EACX,YAAY,GACb,MAAM,WAAW,CAAC;AACnB,YAAY,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/manifest/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EACL,QAAQ,EACR,mBAAmB,EACnB,SAAS,EACT,YAAY,EACZ,WAAW,EACX,YAAY,GACb,MAAM,WAAW,CAAC;AACnB,YAAY,EACV,QAAQ,EACR,uBAAuB,EACvB,aAAa,EACb,eAAe,EACf,UAAU,EACV,UAAU,GACX,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,6BAA6B,EAC7B,mBAAmB,EACnB,cAAc,EACd,gBAAgB,EAChB,gBAAgB,GACjB,MAAM,kBAAkB,CAAC"}
|
package/dist/manifest/index.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { computeDirectoryHash } from './hash.js';
|
|
2
2
|
export { addEntry, createEmptyManifest, findEntry, loadManifest, removeEntry, saveManifest, } from './manager.js';
|
|
3
|
-
export { ManifestEntrySchema, ManifestSchema } from './manifest.types.js';
|
|
3
|
+
export { ManifestCollectionEntrySchema, ManifestEntrySchema, ManifestSchema, ManifestV1Schema, ManifestV2Schema, } from './manifest.types.js';
|