@jini-ai/platform 0.1.2 → 0.5.0

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.
Files changed (128) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/README.md +207 -0
  3. package/dist/asset-cache.d.ts +0 -22
  4. package/dist/asset-cache.js +3 -138
  5. package/dist/download.js +2 -2
  6. package/dist/fetch-with-timeout.d.ts +105 -0
  7. package/dist/fetch-with-timeout.js +121 -0
  8. package/dist/fs/atomic-write.d.ts +103 -0
  9. package/dist/fs/atomic-write.js +201 -0
  10. package/dist/fs/durable-json.d.ts +89 -0
  11. package/dist/fs/durable-json.js +166 -0
  12. package/dist/fs/env-text.d.ts +11 -0
  13. package/dist/fs/env-text.js +22 -0
  14. package/dist/fs/file-lock.d.ts +126 -0
  15. package/dist/fs/file-lock.js +306 -0
  16. package/dist/fs/guarded-reader.d.ts +60 -0
  17. package/dist/fs/guarded-reader.js +310 -0
  18. package/dist/fs/index.d.ts +8 -0
  19. package/dist/fs/index.js +9 -0
  20. package/dist/fs/strict-containment.d.ts +5 -0
  21. package/dist/fs/strict-containment.js +13 -0
  22. package/dist/fs.js +2 -3
  23. package/dist/http/guarded/client.d.ts +13 -0
  24. package/dist/http/guarded/client.js +240 -0
  25. package/dist/http/guarded/errors.d.ts +21 -0
  26. package/dist/http/guarded/errors.js +20 -0
  27. package/dist/http/guarded/fetch-adapter.d.ts +23 -0
  28. package/dist/http/guarded/fetch-adapter.js +30 -0
  29. package/dist/http/guarded/index.d.ts +12 -0
  30. package/dist/http/guarded/index.js +7 -0
  31. package/dist/http/guarded/node-ports.d.ts +8 -0
  32. package/dist/http/guarded/node-ports.js +11 -0
  33. package/dist/http/guarded/ports.d.ts +51 -0
  34. package/dist/http/guarded/ports.js +2 -0
  35. package/dist/http/guarded/transport.fetch.d.ts +11 -0
  36. package/dist/http/guarded/transport.fetch.js +109 -0
  37. package/dist/http/guarded/types.d.ts +8 -0
  38. package/dist/http/guarded/types.js +2 -0
  39. package/dist/http.js +10 -1
  40. package/dist/index.d.ts +65 -1
  41. package/dist/index.js +64 -1
  42. package/dist/mail/adapter-module.d.ts +20 -0
  43. package/dist/mail/adapter-module.js +2 -0
  44. package/dist/mail/delivery-availability.d.ts +7 -0
  45. package/dist/mail/delivery-availability.js +6 -0
  46. package/dist/mail/index.d.ts +7 -0
  47. package/dist/mail/index.js +3 -0
  48. package/dist/mail/ports.d.ts +43 -0
  49. package/dist/mail/ports.js +2 -0
  50. package/dist/mail/purpose-scoped-mailer.d.ts +18 -0
  51. package/dist/mail/purpose-scoped-mailer.js +50 -0
  52. package/dist/mail/smtp.d.ts +79 -0
  53. package/dist/mail/smtp.js +102 -0
  54. package/dist/mail/types.d.ts +83 -0
  55. package/dist/mail/types.js +2 -0
  56. package/dist/messages.d.ts +22 -0
  57. package/dist/messages.js +15 -0
  58. package/dist/net/address.d.ts +38 -0
  59. package/dist/net/address.js +241 -0
  60. package/dist/net/index.d.ts +2 -0
  61. package/dist/net/index.js +2 -0
  62. package/dist/process.d.ts +2 -1
  63. package/dist/process.js +2 -1
  64. package/dist/proxy-env.js +1 -1
  65. package/dist/resource-paths.js +1 -1
  66. package/dist/sandbox-env.d.ts +1 -1
  67. package/dist/sandbox-env.js +1 -1
  68. package/dist/secrets/aad.d.ts +7 -0
  69. package/dist/secrets/aad.js +5 -0
  70. package/dist/secrets/credential-sets/aad.d.ts +7 -0
  71. package/dist/secrets/credential-sets/aad.js +7 -0
  72. package/dist/secrets/credential-sets/index.d.ts +4 -0
  73. package/dist/secrets/credential-sets/index.js +3 -0
  74. package/dist/secrets/credential-sets/repo.memory.d.ts +36 -0
  75. package/dist/secrets/credential-sets/repo.memory.js +72 -0
  76. package/dist/secrets/credential-sets/types.d.ts +67 -0
  77. package/dist/secrets/credential-sets/types.js +6 -0
  78. package/dist/secrets/index.d.ts +9 -0
  79. package/dist/secrets/index.js +5 -0
  80. package/dist/secrets/keyring.env.d.ts +176 -0
  81. package/dist/secrets/keyring.env.js +271 -0
  82. package/dist/secrets/keyring.memory.d.ts +10 -0
  83. package/dist/secrets/keyring.memory.js +9 -0
  84. package/dist/secrets/ports.d.ts +33 -0
  85. package/dist/secrets/ports.js +2 -0
  86. package/dist/secrets/secret-sealer.aesgcm.d.ts +22 -0
  87. package/dist/secrets/secret-sealer.aesgcm.js +64 -0
  88. package/dist/secrets/testing.d.ts +4 -0
  89. package/dist/secrets/testing.js +4 -0
  90. package/dist/secrets/types.d.ts +10 -0
  91. package/dist/secrets/types.js +2 -0
  92. package/dist/shell.d.ts +10 -6
  93. package/dist/shell.js +10 -6
  94. package/dist/terminal.d.ts +2 -2
  95. package/dist/terminal.js +9 -5
  96. package/package.json +85 -4
  97. package/dist/asset-cache.d.ts.map +0 -1
  98. package/dist/asset-cache.js.map +0 -1
  99. package/dist/aws-sigv4.d.ts.map +0 -1
  100. package/dist/aws-sigv4.js.map +0 -1
  101. package/dist/blob-storage.d.ts.map +0 -1
  102. package/dist/blob-storage.js.map +0 -1
  103. package/dist/command.d.ts.map +0 -1
  104. package/dist/command.js.map +0 -1
  105. package/dist/download.d.ts.map +0 -1
  106. package/dist/download.js.map +0 -1
  107. package/dist/fs.d.ts.map +0 -1
  108. package/dist/fs.js.map +0 -1
  109. package/dist/home-expansion.d.ts.map +0 -1
  110. package/dist/home-expansion.js.map +0 -1
  111. package/dist/http.d.ts.map +0 -1
  112. package/dist/http.js.map +0 -1
  113. package/dist/index.d.ts.map +0 -1
  114. package/dist/index.js.map +0 -1
  115. package/dist/process.d.ts.map +0 -1
  116. package/dist/process.js.map +0 -1
  117. package/dist/proxy-env.d.ts.map +0 -1
  118. package/dist/proxy-env.js.map +0 -1
  119. package/dist/resource-paths.d.ts.map +0 -1
  120. package/dist/resource-paths.js.map +0 -1
  121. package/dist/sandbox-env.d.ts.map +0 -1
  122. package/dist/sandbox-env.js.map +0 -1
  123. package/dist/shell.d.ts.map +0 -1
  124. package/dist/shell.js.map +0 -1
  125. package/dist/terminal.d.ts.map +0 -1
  126. package/dist/terminal.js.map +0 -1
  127. package/dist/toolchain.d.ts.map +0 -1
  128. package/dist/toolchain.js.map +0 -1
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @module fetch-with-timeout
3
+ *
4
+ * The shared timeout wrapper for every outbound `fetch()` call this monorepo makes to a remote
5
+ * service — see the 2026-08-16 Jini failure-mode audit, Finding 2: 101 raw `fetch()` call sites
6
+ * repo-wide (corrected from the audit's own initial "67" — re-count before trusting any figure in
7
+ * a handoff), across devops deploy providers, agent-runtime providers, media-provider dispatch,
8
+ * github-client, and llm-provider, most already protected in some ad hoc way. A remote that accepts
9
+ * the TCP connection but
10
+ * never responds — a stalled load balancer, a provider having a bad day, a firewall black-holing the
11
+ * request — leaves `await fetch(...)` unresolved forever; whatever awaits it hangs indefinitely, with
12
+ * nothing to alert on. A hang like that is worse than a crash: a crash is loud.
13
+ *
14
+ * Deliberately ONE helper, not 67 call sites each hand-rolling a slightly different
15
+ * `AbortController`/`setTimeout` idiom — a caller who already has their own `AbortSignal` (a
16
+ * user-initiated cancel, a parent request's own deadline) can still supply it via `init.signal`;
17
+ * `AbortSignal.any` composes it with the timeout so either one aborts the call, and the thrown
18
+ * `FetchTimeoutError` vs. the caller's own abort reason stay distinguishable (see `fetchWithTimeout`'s
19
+ * own doc).
20
+ *
21
+ * `FETCH_TIMEOUT_MS` gives call sites a small, documented, shared vocabulary instead of inventing a
22
+ * number each time — a deploy-provider API call, an image byte-fetch, and a large asset upload
23
+ * genuinely have different reasonable ceilings. `timeoutMs` is never hidden inside the helper as an
24
+ * undocumented default with no parameter, though: every call site names one explicitly (usually one
25
+ * of these constants), because a caller reading its own code should see the number, not have to go
26
+ * find it.
27
+ *
28
+ * Deliberately excludes anything meant to stay open for a long time independent of transfer size —
29
+ * Server-Sent Events, WebSockets, long-poll. None of the 67 flagged call sites are that shape (the
30
+ * audit traced this: the repo's real streaming transports are `EventSource` on the browser side and
31
+ * a hand-rolled SSE channel server-side, both entirely separate from `fetch()`), but a future caller
32
+ * reaching for this helper for one of those would be reaching for the wrong tool — `AbortSignal.timeout`
33
+ * bounds the WHOLE call, body included, so it would sever a deliberately long-lived stream partway
34
+ * through exactly like the bug this fixes, just self-inflicted instead of accidental.
35
+ *
36
+ * **Also exported as its own subpath, `@jini-ai/platform/fetch-with-timeout`, deliberately.** This
37
+ * module has zero imports of its own — no `node:*` builtin, no `undici` — unlike the package's root
38
+ * barrel (`index.ts`), which re-exports sibling modules (`process.ts`, `fs.ts`, `terminal.ts`,
39
+ * `asset-cache.ts`, …) that import `node:child_process`, `node:fs`, `undici`'s `Agent`, etc.
40
+ * Importing from the root barrel in browser-bundled code is silently fine in a *production* build
41
+ * through Vite/Rollup — tree-shaking (this package sets `sideEffects: false`) proves the Node-only
42
+ * re-exports are unused and drops them before the `node:*` imports are ever resolved — but it is
43
+ * NOT fine in `vite dev` or any other unbundled-native-ESM serving mode: there is no tree-shaking on
44
+ * that path, so the browser eagerly loads and evaluates every sibling the barrel re-exports, and
45
+ * `process.ts`'s very first statement destructures `execFile`/`spawn` off `node:child_process` —
46
+ * which Vite's dev server serves as a `Proxy` that throws on first property access. The whole module
47
+ * graph fails to evaluate the instant anything imports the barrel, regardless of which export is
48
+ * actually used. Verified empirically (`esbuild --bundle --platform=browser` on the barrel: 154
49
+ * resolution errors; `vite build`: clean, 5.92kB, zero Node-only code in the output; `vite dev`
50
+ * serving `index.js`: throws at import time) — see the 2026-08-17 Jini hardening report. Browser
51
+ * call sites should import from this subpath, never the root barrel.
52
+ */
53
+ /** Thrown in place of the platform's generic abort error specifically when the timeout — not a
54
+ * caller-supplied `init.signal` — is what ended the call, so a caller can tell "this took too long"
55
+ * apart from "I cancelled it myself" without string-matching an error message. */
56
+ export declare class FetchTimeoutError extends Error {
57
+ readonly url: string;
58
+ readonly timeoutMs: number;
59
+ constructor(url: string, timeoutMs: number);
60
+ }
61
+ /**
62
+ * Shared, documented timeout classes for outbound `fetch()` calls. Every call site names one
63
+ * explicitly (or its own justified number) rather than relying on an unstated default.
64
+ */
65
+ export declare const FETCH_TIMEOUT_MS: {
66
+ /** A single metadata/status/API-read call expected to return quickly: a model catalog listing, a
67
+ * connection test, a GitHub API read, an LLM provider's non-streaming completion call. */
68
+ readonly QUICK: 15000;
69
+ /** A deploy-provider API call that may involve real backend work on the provider's side — site
70
+ * creation, DNS record lookups, deployment status polling. */
71
+ readonly DEPLOY: 30000;
72
+ /** A large-payload transfer: an asset/blob upload, a generated-media byte fetch. */
73
+ readonly UPLOAD: 120000;
74
+ /** A media-generation API call whose response only arrives once the vendor has actually finished
75
+ * generating the image/video/audio server-side — routinely minutes, not seconds. Matches the
76
+ * value OpenAI's and OpenRouter's own image providers already hardcode locally
77
+ * (`OPENAI_IMAGE_*_TIMEOUT_MS`/`OPENROUTER_IMAGE_TIMEOUT_MS`); used as the shared dispatch-level
78
+ * backstop in `@jini-ai/integrations`' `dispatchVendorRequest` for every OTHER vendor that (as of
79
+ * 2026-08-16) sets no timeout of its own at all. Deliberately NOT the same as `UPLOAD`: a backstop
80
+ * shorter than a vendor's own already-correct 10-minute generation timeout would abort a
81
+ * legitimately still-generating request early. */
82
+ readonly GENERATE: number;
83
+ };
84
+ export interface FetchWithTimeoutOptions {
85
+ /** Milliseconds before the call is aborted. Pick one of {@link FETCH_TIMEOUT_MS}, or a specific
86
+ * justified number for a call site that doesn't fit those classes. */
87
+ timeoutMs: number;
88
+ }
89
+ /**
90
+ * `fetch`, but aborted after `options.timeoutMs` if it hasn't settled by then. If `init.signal` is
91
+ * already set, the call aborts on EITHER that signal or the timeout, via `AbortSignal.any` — a
92
+ * caller's own cancellation (e.g. a user-initiated cancel button) keeps working exactly as it did
93
+ * without this wrapper.
94
+ *
95
+ * @param url - Same as `fetch`'s first argument.
96
+ * @param init - Same as `fetch`'s second argument. `init.signal`, if present, is combined with the
97
+ * timeout rather than replaced.
98
+ * @param options - See {@link FetchWithTimeoutOptions}.
99
+ * @returns The same `Promise<Response>` `fetch` itself would return.
100
+ * @throws {@link FetchTimeoutError} if the timeout fires before the call settles and the caller's own
101
+ * `init.signal` (if any) did not itself already abort. Any other rejection (a caller's own abort, a
102
+ * genuine network error) propagates unchanged.
103
+ */
104
+ export declare function fetchWithTimeout(url: string | URL, init: RequestInit | undefined, options: FetchWithTimeoutOptions): Promise<Response>;
105
+ //# sourceMappingURL=fetch-with-timeout.d.ts.map
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @module fetch-with-timeout
3
+ *
4
+ * The shared timeout wrapper for every outbound `fetch()` call this monorepo makes to a remote
5
+ * service — see the 2026-08-16 Jini failure-mode audit, Finding 2: 101 raw `fetch()` call sites
6
+ * repo-wide (corrected from the audit's own initial "67" — re-count before trusting any figure in
7
+ * a handoff), across devops deploy providers, agent-runtime providers, media-provider dispatch,
8
+ * github-client, and llm-provider, most already protected in some ad hoc way. A remote that accepts
9
+ * the TCP connection but
10
+ * never responds — a stalled load balancer, a provider having a bad day, a firewall black-holing the
11
+ * request — leaves `await fetch(...)` unresolved forever; whatever awaits it hangs indefinitely, with
12
+ * nothing to alert on. A hang like that is worse than a crash: a crash is loud.
13
+ *
14
+ * Deliberately ONE helper, not 67 call sites each hand-rolling a slightly different
15
+ * `AbortController`/`setTimeout` idiom — a caller who already has their own `AbortSignal` (a
16
+ * user-initiated cancel, a parent request's own deadline) can still supply it via `init.signal`;
17
+ * `AbortSignal.any` composes it with the timeout so either one aborts the call, and the thrown
18
+ * `FetchTimeoutError` vs. the caller's own abort reason stay distinguishable (see `fetchWithTimeout`'s
19
+ * own doc).
20
+ *
21
+ * `FETCH_TIMEOUT_MS` gives call sites a small, documented, shared vocabulary instead of inventing a
22
+ * number each time — a deploy-provider API call, an image byte-fetch, and a large asset upload
23
+ * genuinely have different reasonable ceilings. `timeoutMs` is never hidden inside the helper as an
24
+ * undocumented default with no parameter, though: every call site names one explicitly (usually one
25
+ * of these constants), because a caller reading its own code should see the number, not have to go
26
+ * find it.
27
+ *
28
+ * Deliberately excludes anything meant to stay open for a long time independent of transfer size —
29
+ * Server-Sent Events, WebSockets, long-poll. None of the 67 flagged call sites are that shape (the
30
+ * audit traced this: the repo's real streaming transports are `EventSource` on the browser side and
31
+ * a hand-rolled SSE channel server-side, both entirely separate from `fetch()`), but a future caller
32
+ * reaching for this helper for one of those would be reaching for the wrong tool — `AbortSignal.timeout`
33
+ * bounds the WHOLE call, body included, so it would sever a deliberately long-lived stream partway
34
+ * through exactly like the bug this fixes, just self-inflicted instead of accidental.
35
+ *
36
+ * **Also exported as its own subpath, `@jini-ai/platform/fetch-with-timeout`, deliberately.** This
37
+ * module has zero imports of its own — no `node:*` builtin, no `undici` — unlike the package's root
38
+ * barrel (`index.ts`), which re-exports sibling modules (`process.ts`, `fs.ts`, `terminal.ts`,
39
+ * `asset-cache.ts`, …) that import `node:child_process`, `node:fs`, `undici`'s `Agent`, etc.
40
+ * Importing from the root barrel in browser-bundled code is silently fine in a *production* build
41
+ * through Vite/Rollup — tree-shaking (this package sets `sideEffects: false`) proves the Node-only
42
+ * re-exports are unused and drops them before the `node:*` imports are ever resolved — but it is
43
+ * NOT fine in `vite dev` or any other unbundled-native-ESM serving mode: there is no tree-shaking on
44
+ * that path, so the browser eagerly loads and evaluates every sibling the barrel re-exports, and
45
+ * `process.ts`'s very first statement destructures `execFile`/`spawn` off `node:child_process` —
46
+ * which Vite's dev server serves as a `Proxy` that throws on first property access. The whole module
47
+ * graph fails to evaluate the instant anything imports the barrel, regardless of which export is
48
+ * actually used. Verified empirically (`esbuild --bundle --platform=browser` on the barrel: 154
49
+ * resolution errors; `vite build`: clean, 5.92kB, zero Node-only code in the output; `vite dev`
50
+ * serving `index.js`: throws at import time) — see the 2026-08-17 Jini hardening report. Browser
51
+ * call sites should import from this subpath, never the root barrel.
52
+ */
53
+ /** Thrown in place of the platform's generic abort error specifically when the timeout — not a
54
+ * caller-supplied `init.signal` — is what ended the call, so a caller can tell "this took too long"
55
+ * apart from "I cancelled it myself" without string-matching an error message. */
56
+ export class FetchTimeoutError extends Error {
57
+ url;
58
+ timeoutMs;
59
+ constructor(url, timeoutMs) {
60
+ super(`fetch timed out after ${timeoutMs}ms: ${url}`);
61
+ this.name = "FetchTimeoutError";
62
+ this.url = url;
63
+ this.timeoutMs = timeoutMs;
64
+ }
65
+ }
66
+ /**
67
+ * Shared, documented timeout classes for outbound `fetch()` calls. Every call site names one
68
+ * explicitly (or its own justified number) rather than relying on an unstated default.
69
+ */
70
+ export const FETCH_TIMEOUT_MS = {
71
+ /** A single metadata/status/API-read call expected to return quickly: a model catalog listing, a
72
+ * connection test, a GitHub API read, an LLM provider's non-streaming completion call. */
73
+ QUICK: 15_000,
74
+ /** A deploy-provider API call that may involve real backend work on the provider's side — site
75
+ * creation, DNS record lookups, deployment status polling. */
76
+ DEPLOY: 30_000,
77
+ /** A large-payload transfer: an asset/blob upload, a generated-media byte fetch. */
78
+ UPLOAD: 120_000,
79
+ /** A media-generation API call whose response only arrives once the vendor has actually finished
80
+ * generating the image/video/audio server-side — routinely minutes, not seconds. Matches the
81
+ * value OpenAI's and OpenRouter's own image providers already hardcode locally
82
+ * (`OPENAI_IMAGE_*_TIMEOUT_MS`/`OPENROUTER_IMAGE_TIMEOUT_MS`); used as the shared dispatch-level
83
+ * backstop in `@jini-ai/integrations`' `dispatchVendorRequest` for every OTHER vendor that (as of
84
+ * 2026-08-16) sets no timeout of its own at all. Deliberately NOT the same as `UPLOAD`: a backstop
85
+ * shorter than a vendor's own already-correct 10-minute generation timeout would abort a
86
+ * legitimately still-generating request early. */
87
+ GENERATE: 10 * 60_000,
88
+ };
89
+ /**
90
+ * `fetch`, but aborted after `options.timeoutMs` if it hasn't settled by then. If `init.signal` is
91
+ * already set, the call aborts on EITHER that signal or the timeout, via `AbortSignal.any` — a
92
+ * caller's own cancellation (e.g. a user-initiated cancel button) keeps working exactly as it did
93
+ * without this wrapper.
94
+ *
95
+ * @param url - Same as `fetch`'s first argument.
96
+ * @param init - Same as `fetch`'s second argument. `init.signal`, if present, is combined with the
97
+ * timeout rather than replaced.
98
+ * @param options - See {@link FetchWithTimeoutOptions}.
99
+ * @returns The same `Promise<Response>` `fetch` itself would return.
100
+ * @throws {@link FetchTimeoutError} if the timeout fires before the call settles and the caller's own
101
+ * `init.signal` (if any) did not itself already abort. Any other rejection (a caller's own abort, a
102
+ * genuine network error) propagates unchanged.
103
+ */
104
+ export async function fetchWithTimeout(url, init = {}, options) {
105
+ const { timeoutMs } = options;
106
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
107
+ const signal = init.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal;
108
+ try {
109
+ return await fetch(url, { ...init, signal });
110
+ }
111
+ catch (error) {
112
+ // `timeoutSignal` is the original object this function created, not the (possibly composed)
113
+ // signal handed to `fetch` — checking its own `.aborted` tells us specifically whether OUR
114
+ // timeout fired, independent of whether a caller-supplied signal also exists or also aborted.
115
+ if (timeoutSignal.aborted && !init.signal?.aborted) {
116
+ throw new FetchTimeoutError(String(url), timeoutMs);
117
+ }
118
+ throw error;
119
+ }
120
+ }
121
+ //# sourceMappingURL=fetch-with-timeout.js.map
@@ -0,0 +1,103 @@
1
+ import fs from "node:fs";
2
+ import * as fsPromises from "node:fs/promises";
3
+ import { type PlatformMessages } from "../messages.js";
4
+ /** Synchronous seam preserves the original fail-before-rename and permission contract.
5
+ * Descriptor ownership separates failed exclusive creates from failures after creating a temp. */
6
+ export interface AtomicFilesystemPort {
7
+ stat(required: {
8
+ path: string;
9
+ }): {
10
+ mode: number;
11
+ };
12
+ lstat(required: {
13
+ path: string;
14
+ }): {
15
+ mode: number;
16
+ isSymbolicLink(): boolean;
17
+ };
18
+ open(required: {
19
+ path: string;
20
+ flags: "wx" | "r";
21
+ mode?: number;
22
+ }): number;
23
+ write(required: {
24
+ descriptor: number;
25
+ content: string;
26
+ }): void;
27
+ sync(required: {
28
+ descriptor: number;
29
+ }): void;
30
+ close(required: {
31
+ descriptor: number;
32
+ }): void;
33
+ chmod(required: {
34
+ path: string;
35
+ mode: number;
36
+ }): void;
37
+ rename(required: {
38
+ from: string;
39
+ to: string;
40
+ }): void;
41
+ remove(required: {
42
+ path: string;
43
+ }): void;
44
+ }
45
+ export type AtomicNativeFilesystem = Pick<typeof fs, "statSync" | "lstatSync" | "openSync" | "writeFileSync" | "fsyncSync" | "closeSync" | "chmodSync" | "renameSync" | "rmSync">;
46
+ export type AtomicAsyncFilesystem = Pick<typeof fsPromises, "stat" | "lstat" | "open" | "chmod" | "rename" | "rm" | "mkdir">;
47
+ export interface AtomicWriteRequired {
48
+ filePath: string;
49
+ content: string;
50
+ fs: AtomicFilesystemPort;
51
+ }
52
+ export interface AtomicWriteOptions {
53
+ messages?: PlatformMessages;
54
+ /** Optional collision token; native pid + UUID restores the original default. */
55
+ tempName?: (required: {
56
+ filePath: string;
57
+ }) => string;
58
+ /** Explicit mode overrides preservation, e.g. secret-bearing files must use 0600. */
59
+ mode?: number;
60
+ /** Default for a missing destination; shared durable stores retain their former 0666 create mode. */
61
+ defaultMode?: number;
62
+ refuseSymlink?: boolean;
63
+ /** Verify owner-only permissions before rename on POSIX, as secret stores require. */
64
+ verifyOwnerOnly?: boolean;
65
+ platform?: NodeJS.Platform;
66
+ /** Same-directory legacy temp layout, for stores that expose their temp path publicly. */
67
+ tempPath?: (required: {
68
+ filePath: string;
69
+ }) => string;
70
+ }
71
+ /** Optional adapter wiring: no layout, env key, or application name is selected here. */
72
+ export declare function createNodeAtomicFilesystem(_required: Record<string, never>, { filesystem }?: {
73
+ filesystem?: AtomicNativeFilesystem;
74
+ }): AtomicFilesystemPort;
75
+ /** Same-directory rename; preserve existing mode exactly, default new files to 0600.
76
+ * Only ENOENT means absent. Stat/write/chmod failures propagate before any rename.
77
+ * Flush file bytes before rename so metadata cannot precede content; then flush the parent dir.
78
+ * A directory-sync failure is reported after replacement, because rename cannot be rolled back safely.
79
+ */
80
+ export declare function writeFileAtomic(required: AtomicWriteRequired, options?: AtomicWriteOptions): void;
81
+ /** Pretty JSON delegates to the same mode-preserving atomic primitive. */
82
+ export declare function writeJsonFileAtomic(required: Omit<AtomicWriteRequired, "content"> & {
83
+ data: unknown;
84
+ }, options?: AtomicWriteOptions): void;
85
+ /** Async counterpart with the same ownership, permission and durability guarantees.
86
+ * Parent creation supports runtime-state and secret stores without another writer implementation. */
87
+ export declare function writeFileAtomicAsync(required: {
88
+ filePath: string;
89
+ content: string;
90
+ }, options?: AtomicWriteOptions & {
91
+ fs?: AtomicAsyncFilesystem;
92
+ createParent?: boolean;
93
+ }): Promise<void>;
94
+ /** Pretty JSON can retain a state-store's trailing newline without changing its serialization. */
95
+ export declare function writeJsonFileAtomicAsync(required: {
96
+ filePath: string;
97
+ data: unknown;
98
+ }, options?: AtomicWriteOptions & {
99
+ fs?: AtomicAsyncFilesystem;
100
+ createParent?: boolean;
101
+ trailingNewline?: boolean;
102
+ }): Promise<void>;
103
+ //# sourceMappingURL=atomic-write.d.ts.map
@@ -0,0 +1,201 @@
1
+ import fs from "node:fs";
2
+ import * as fsPromises from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { defaultPlatformMessages } from "../messages.js";
6
+ /** Optional adapter wiring: no layout, env key, or application name is selected here. */
7
+ export function createNodeAtomicFilesystem(_required, { filesystem = fs } = {}) {
8
+ return {
9
+ stat: ({ path }) => filesystem.statSync(path),
10
+ lstat: ({ path }) => filesystem.lstatSync(path),
11
+ open: ({ path, flags, mode }) => filesystem.openSync(path, flags, mode),
12
+ write: ({ descriptor, content }) => filesystem.writeFileSync(descriptor, content),
13
+ sync: ({ descriptor }) => filesystem.fsyncSync(descriptor),
14
+ close: ({ descriptor }) => filesystem.closeSync(descriptor),
15
+ chmod: ({ path, mode }) => filesystem.chmodSync(path, mode),
16
+ rename: ({ from, to }) => filesystem.renameSync(from, to),
17
+ remove: ({ path }) => filesystem.rmSync(path, { force: true }),
18
+ };
19
+ }
20
+ function isMissing(error) {
21
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
22
+ }
23
+ function validateMode(mode, options) {
24
+ if (!Number.isInteger(mode) || mode < 0 || mode > 0o777)
25
+ throw new RangeError((options.messages ?? defaultPlatformMessages).atomicModeInvalid());
26
+ return mode;
27
+ }
28
+ function temporaryPath({ filePath }, options) {
29
+ const { tempName = () => `${process.pid}.${randomUUID()}` } = options;
30
+ if (options.tempPath) {
31
+ const temporary = options.tempPath({ filePath });
32
+ if (path.resolve(path.dirname(temporary)) !== path.resolve(path.dirname(filePath)) || path.resolve(temporary) === path.resolve(filePath) || temporary.includes("\0"))
33
+ throw new RangeError((options.messages ?? defaultPlatformMessages).atomicTempPathInvalid());
34
+ return temporary;
35
+ }
36
+ const token = tempName({ filePath });
37
+ if (!token || token === "." || token === ".." || /[/\\\x00]/.test(token))
38
+ throw new RangeError((options.messages ?? defaultPlatformMessages).atomicTempNameInvalid());
39
+ return path.join(path.dirname(filePath), `.${path.basename(filePath)}.${token}.tmp`);
40
+ }
41
+ function assertNotSymlink(info, options) {
42
+ if (info.isSymbolicLink())
43
+ throw new Error((options.messages ?? defaultPlatformMessages).atomicSymlinkRefused());
44
+ }
45
+ function assertOwnerOnly(mode, options) {
46
+ // Windows has no POSIX group/other bits; secret stores skip this verification there.
47
+ if (options.verifyOwnerOnly && (options.platform ?? process.platform) !== "win32" && (mode & 0o077) !== 0)
48
+ throw new Error((options.messages ?? defaultPlatformMessages).atomicPermissionsRefused());
49
+ }
50
+ /** Same-directory rename; preserve existing mode exactly, default new files to 0600.
51
+ * Only ENOENT means absent. Stat/write/chmod failures propagate before any rename.
52
+ * Flush file bytes before rename so metadata cannot precede content; then flush the parent dir.
53
+ * A directory-sync failure is reported after replacement, because rename cannot be rolled back safely.
54
+ */
55
+ export function writeFileAtomic(required, options = {}) {
56
+ const { filePath, content, fs: filesystem } = required;
57
+ let existingMode;
58
+ try {
59
+ if (options.refuseSymlink) {
60
+ const info = filesystem.lstat({ path: filePath });
61
+ assertNotSymlink(info, options);
62
+ existingMode = info.mode & 0o777;
63
+ }
64
+ else {
65
+ existingMode = filesystem.stat({ path: filePath }).mode & 0o777;
66
+ }
67
+ }
68
+ catch (error) {
69
+ if (!isMissing(error))
70
+ throw error;
71
+ }
72
+ const mode = validateMode(options.mode ?? existingMode ?? options.defaultMode ?? 0o600, options);
73
+ const temporary = temporaryPath(required, options);
74
+ // A failed exclusive create does not grant ownership of an existing temporary file.
75
+ let descriptor = filesystem.open({ path: temporary, flags: "wx", mode });
76
+ let renamed = false;
77
+ try {
78
+ filesystem.write({ descriptor, content });
79
+ filesystem.chmod({ path: temporary, mode });
80
+ assertOwnerOnly(filesystem.stat({ path: temporary }).mode, options);
81
+ filesystem.sync({ descriptor });
82
+ const fileDescriptor = descriptor;
83
+ descriptor = undefined;
84
+ filesystem.close({ descriptor: fileDescriptor });
85
+ if (options.refuseSymlink) {
86
+ try {
87
+ assertNotSymlink(filesystem.lstat({ path: filePath }), options);
88
+ }
89
+ catch (error) {
90
+ if (!isMissing(error))
91
+ throw error;
92
+ }
93
+ }
94
+ filesystem.rename({ from: temporary, to: filePath });
95
+ renamed = true;
96
+ const directory = filesystem.open({ path: path.dirname(filePath), flags: "r" });
97
+ try {
98
+ filesystem.sync({ descriptor: directory });
99
+ }
100
+ finally {
101
+ filesystem.close({ descriptor: directory });
102
+ }
103
+ }
104
+ catch (error) {
105
+ if (descriptor !== undefined) {
106
+ try {
107
+ filesystem.close({ descriptor });
108
+ }
109
+ catch { /* preserve the original failure */ }
110
+ }
111
+ if (!renamed) {
112
+ try {
113
+ filesystem.remove({ path: temporary });
114
+ }
115
+ catch { /* preserve the original failure */ }
116
+ }
117
+ throw error;
118
+ }
119
+ }
120
+ /** Pretty JSON delegates to the same mode-preserving atomic primitive. */
121
+ export function writeJsonFileAtomic(required, options = {}) {
122
+ const { data, ...writer } = required;
123
+ const content = JSON.stringify(data, null, 2);
124
+ if (content === undefined)
125
+ throw new TypeError((options.messages ?? defaultPlatformMessages).atomicJsonUnserializable());
126
+ writeFileAtomic({ ...writer, content }, options);
127
+ }
128
+ /** Async counterpart with the same ownership, permission and durability guarantees.
129
+ * Parent creation supports runtime-state and secret stores without another writer implementation. */
130
+ export async function writeFileAtomicAsync(required, options = {}) {
131
+ const { filePath, content } = required;
132
+ const filesystem = options.fs ?? fsPromises;
133
+ if (options.createParent)
134
+ await filesystem.mkdir(path.dirname(filePath), { recursive: true });
135
+ let existingMode;
136
+ try {
137
+ const info = options.refuseSymlink ? await filesystem.lstat(filePath) : await filesystem.stat(filePath);
138
+ if (options.refuseSymlink)
139
+ assertNotSymlink(info, options);
140
+ existingMode = info.mode & 0o777;
141
+ }
142
+ catch (error) {
143
+ if (!isMissing(error))
144
+ throw error;
145
+ }
146
+ const mode = validateMode(options.mode ?? existingMode ?? options.defaultMode ?? 0o600, options);
147
+ const temporary = temporaryPath(required, options);
148
+ // Keep the exclusive create outside cleanup: EEXIST belongs to a different writer.
149
+ const handle = await filesystem.open(temporary, "wx", mode);
150
+ let closed = false, renamed = false;
151
+ try {
152
+ await handle.writeFile(content, "utf8");
153
+ await handle.chmod(mode);
154
+ assertOwnerOnly((await handle.stat()).mode, options);
155
+ await handle.sync();
156
+ await handle.close();
157
+ closed = true;
158
+ if (options.refuseSymlink) {
159
+ try {
160
+ assertNotSymlink(await filesystem.lstat(filePath), options);
161
+ }
162
+ catch (error) {
163
+ if (!isMissing(error))
164
+ throw error;
165
+ }
166
+ }
167
+ await filesystem.rename(temporary, filePath);
168
+ renamed = true;
169
+ const directory = await filesystem.open(path.dirname(filePath), "r");
170
+ try {
171
+ await directory.sync();
172
+ }
173
+ finally {
174
+ await directory.close();
175
+ }
176
+ }
177
+ catch (error) {
178
+ if (!closed) {
179
+ try {
180
+ await handle.close();
181
+ }
182
+ catch { /* preserve the original failure */ }
183
+ }
184
+ if (!renamed) {
185
+ try {
186
+ await filesystem.rm(temporary, { force: true });
187
+ }
188
+ catch { /* preserve the original failure */ }
189
+ }
190
+ throw error;
191
+ }
192
+ }
193
+ /** Pretty JSON can retain a state-store's trailing newline without changing its serialization. */
194
+ export async function writeJsonFileAtomicAsync(required, options = {}) {
195
+ const { data, ...writer } = required;
196
+ const content = JSON.stringify(data, null, 2);
197
+ if (content === undefined)
198
+ throw new TypeError((options.messages ?? defaultPlatformMessages).atomicJsonUnserializable());
199
+ await writeFileAtomicAsync({ ...writer, content: content + (options.trailingNewline ? "\n" : "") }, options);
200
+ }
201
+ //# sourceMappingURL=atomic-write.js.map
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Shared stores once wrote JSON directly, treated torn reads as empty, then persisted the empty
3
+ * state on the next write, erasing recoverable records. One mechanism keeps stores from drifting:
4
+ * atomic replacement, explicit unreadable state, quarantine, and locks for shared read-modify-write.
5
+ * Recovery policy stays with each store: salvage, restart, or leave untouched is not decided here.
6
+ * Native effects are Electron-free so desktop, CLI, and bridge processes can share the mechanism.
7
+ */
8
+ import nodeFs from "node:fs";
9
+ import type { Clock } from "@jini-ai/core/primitives";
10
+ export type JsonFileRead = {
11
+ state: "ok";
12
+ value: unknown;
13
+ } | {
14
+ state: "missing";
15
+ } | {
16
+ state: "unreadable";
17
+ text?: string;
18
+ };
19
+ export interface QuarantineNotice {
20
+ label: string;
21
+ consequence: string;
22
+ }
23
+ export interface FileLockOptions {
24
+ waitMs?: number;
25
+ }
26
+ export interface DurableJsonPorts {
27
+ filesystem: Pick<typeof nodeFs, "readFileSync" | "mkdirSync" | "openSync" | "writeFileSync" | "fsyncSync" | "closeSync" | "renameSync" | "rmSync" | "writeSync" | "statSync" | "fstatSync" | "lstatSync" | "chmodSync">;
28
+ clock: Clock;
29
+ sleep: {
30
+ sleep(required: {
31
+ durationMs: number;
32
+ }): void;
33
+ };
34
+ process: {
35
+ pid: number;
36
+ isAlive(required: {
37
+ pid: number;
38
+ }): boolean;
39
+ };
40
+ random: {
41
+ token(_required: Record<string, never>): string;
42
+ };
43
+ report: {
44
+ error(required: {
45
+ message: string;
46
+ }): void;
47
+ };
48
+ }
49
+ export interface DurableJsonNotices {
50
+ prefix: string;
51
+ lockTimeout(required: {
52
+ lockPath: string;
53
+ waitMs: number;
54
+ }): string;
55
+ }
56
+ /** Native synchronous adapters. Liveness probes treat permission errors as alive. */
57
+ export declare function createNodeDurableJsonPorts(_required: Record<string, never>): DurableJsonPorts;
58
+ export interface DurableJsonFile {
59
+ read(required: Record<string, never>): JsonFileRead;
60
+ tempPath(required: Record<string, never>, optional?: {
61
+ pid?: number;
62
+ }): string;
63
+ write(required: {
64
+ value: unknown;
65
+ }): void;
66
+ quarantine(required: {
67
+ notice: QuarantineNotice;
68
+ }): boolean;
69
+ withLock<T>(required: {
70
+ critical: () => T;
71
+ }, optional?: FileLockOptions): T;
72
+ }
73
+ /** Atomic replacement, distinct corrupt reads, quarantine, and token-owned synchronous locks.
74
+ * Lock stealing rechecks inode, bytes and mtime; conditional unlink is not available on all hosts.
75
+ * File fsync precedes rename; directory fsync follows it. Persistence remains filesystem-dependent.
76
+ */
77
+ export declare function createDurableJsonFile({ filePath, filesystem: fs, clock, sleep, process, random, report, notices }: DurableJsonPorts & {
78
+ filePath: string;
79
+ notices: DurableJsonNotices;
80
+ }, { pollMs, staleMs }?: {
81
+ pollMs?: number;
82
+ staleMs?: number;
83
+ }): DurableJsonFile;
84
+ export declare function salvageJsonPrefix({ text }: {
85
+ text: string;
86
+ }, { maxAttempts }?: {
87
+ maxAttempts?: number;
88
+ }): unknown;
89
+ //# sourceMappingURL=durable-json.d.ts.map