@jini-ai/platform 0.5.2 → 0.5.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -1
  3. package/dist/asset-cache.d.ts +12 -1
  4. package/dist/asset-cache.js +9 -5
  5. package/dist/child-environment.d.ts +18 -0
  6. package/dist/child-environment.js +26 -0
  7. package/dist/fs/boot-identity.d.ts +10 -0
  8. package/dist/fs/boot-identity.js +34 -0
  9. package/dist/fs/directory-home.d.ts +15 -0
  10. package/dist/fs/directory-home.js +17 -0
  11. package/dist/fs/directory-registry.d.ts +50 -0
  12. package/dist/fs/directory-registry.js +99 -0
  13. package/dist/fs/directory-running-lock.d.ts +43 -0
  14. package/dist/fs/directory-running-lock.js +66 -0
  15. package/dist/fs/directory-sync.d.ts +20 -0
  16. package/dist/fs/directory-sync.js +87 -0
  17. package/dist/fs/directory-user-config.d.ts +79 -0
  18. package/dist/fs/directory-user-config.js +183 -0
  19. package/dist/fs/directory-workspace.d.ts +132 -0
  20. package/dist/fs/directory-workspace.js +266 -0
  21. package/dist/fs/durable-json.d.ts +21 -0
  22. package/dist/fs/durable-json.js +59 -4
  23. package/dist/fs/file-lock.d.ts +3 -0
  24. package/dist/fs/file-lock.js +16 -7
  25. package/dist/fs/index.d.ts +6 -0
  26. package/dist/fs/index.js +6 -0
  27. package/dist/fs/user-data-directories.d.ts +28 -0
  28. package/dist/fs/user-data-directories.js +53 -0
  29. package/dist/fs/verified-directory-move.d.ts +37 -0
  30. package/dist/fs/verified-directory-move.js +138 -0
  31. package/dist/fs/verified-file-relocation.d.ts +27 -0
  32. package/dist/fs/verified-file-relocation.js +115 -0
  33. package/dist/fs/workspace-directory.d.ts +19 -0
  34. package/dist/fs/workspace-directory.js +23 -0
  35. package/dist/index.d.ts +2 -1
  36. package/dist/index.js +2 -1
  37. package/dist/secrets/credential-sets/index.d.ts +5 -2
  38. package/dist/secrets/credential-sets/index.js +5 -2
  39. package/dist/secrets/credential-sets/vault.aad.d.ts +4 -0
  40. package/dist/secrets/credential-sets/vault.aad.js +8 -0
  41. package/dist/secrets/credential-sets/vault.meta.d.ts +10 -0
  42. package/dist/secrets/credential-sets/vault.meta.js +52 -0
  43. package/dist/secrets/credential-sets/vault.repo.memory.d.ts +51 -0
  44. package/dist/secrets/credential-sets/vault.repo.memory.js +148 -0
  45. package/dist/secrets/credential-sets/vault.service.d.ts +115 -0
  46. package/dist/secrets/credential-sets/vault.service.js +273 -0
  47. package/dist/secrets/credential-sets/vault.types.d.ts +151 -0
  48. package/dist/secrets/credential-sets/vault.types.js +28 -0
  49. package/dist/secrets/testing.d.ts +0 -1
  50. package/dist/secrets/testing.js +0 -1
  51. package/dist/web/html-document.d.ts +70 -0
  52. package/dist/web/html-document.js +268 -0
  53. package/dist/web/html-resource.d.ts +15 -0
  54. package/dist/web/html-resource.js +12 -0
  55. package/dist/web/html-to-markdown.d.ts +11 -0
  56. package/dist/web/html-to-markdown.js +245 -0
  57. package/dist/web/index.d.ts +5 -0
  58. package/dist/web/index.js +5 -0
  59. package/dist/web/web-page.d.ts +157 -0
  60. package/dist/web/web-page.js +310 -0
  61. package/package.json +14 -3
  62. package/dist/secrets/credential-sets/aad.d.ts +0 -7
  63. package/dist/secrets/credential-sets/aad.js +0 -7
  64. package/dist/secrets/credential-sets/repo.memory.d.ts +0 -44
  65. package/dist/secrets/credential-sets/repo.memory.js +0 -80
@@ -0,0 +1,266 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { createDurableJsonFile, createNodeDurableJsonPorts, updateDurableJsonFiles } from "./durable-json.js";
6
+ import { createDirectoryUserConfig, validateDirectoryUserConfig } from "./directory-user-config.js";
7
+ import { resolveDirectoryHome } from "./directory-home.js";
8
+ import { createDirectoryRegistry } from "./directory-registry.js";
9
+ import { createDirectoryRunningLock } from "./directory-running-lock.js";
10
+ /** Node assembly only. Policy modules receive explicit filesystem, clock and process ports.
11
+ * Settings live outside the selected home so changing the setting never loses the pointer.
12
+ * Reading a missing home/catalog does not create it. Opt-in registry recovery may prune known
13
+ * pollution on load; read-only hosts omit that policy. Mutations use the durable JSON owner.
14
+ * @complexity O(n) registry records, O(1) settings/lease operations.
15
+ */
16
+ export function createNodeDirectoryWorkspace({ homeDir, defaultFolder, envName, settingsFile, registryName, runningName, userConfig, registryRecovery }, { env = process.env, cwd = process.cwd(), ports = createNodeDurableJsonPorts({}), canonicalPath = canonicalNodeDirectory, ...directoryOptions } = {}) {
17
+ const fileAt = (filePath) => createDurableJsonFile({ ...ports, filePath, notices: {
18
+ prefix: "Directory workspace", lockTimeout: ({ lockPath }) => `Timed out waiting for file lock: ${lockPath}`,
19
+ } }, { staleMs: Infinity });
20
+ const config = userConfig ? createDirectoryUserConfig({ homeDir, defaultFolder, envName, policy: userConfig,
21
+ fileAt, canonicalPath, removeFile: ({ filePath }) => ports.filesystem.rmSync(filePath, { force: true }), isDirectory: (file) => ports.filesystem.statSync(file).isDirectory(), exists: (file) => { try {
22
+ ports.filesystem.statSync(file);
23
+ return true;
24
+ }
25
+ catch (error) {
26
+ if (error.code === "ENOENT")
27
+ return false;
28
+ throw error;
29
+ } },
30
+ }, { env, cwd, report: (message) => ports.report.error({ message }), ...directoryOptions }) : undefined;
31
+ const settings = fileAt(config?.filePath ?? settingsFile);
32
+ function readSetting() {
33
+ const read = settings.read({});
34
+ if (read.state === "missing")
35
+ return undefined;
36
+ if (read.state !== "ok" || typeof read.value?.home !== "string")
37
+ throw new Error("The directory home setting is unreadable.");
38
+ return read.value.home;
39
+ }
40
+ const legacyHome = () => canonicalPath({ path: resolveDirectoryHome({ homeDir, defaultFolder, envName, env, setting: env[envName] === undefined ? readSetting() : undefined }, { cwd }) });
41
+ const home = () => config?.read({}).sitesHome ?? legacyHome();
42
+ const fixturePattern = registryRecovery ? new RegExp(registryRecovery.fixtureIdPattern.source, registryRecovery.fixtureIdPattern.flags.replace(/[gy]/g, "")) : undefined;
43
+ const atHome = () => createDirectoryRegistry({ file: fileAt(path.join(config?.appData ?? home(), registryName)), canonicalPath }, registryRecovery ? {
44
+ discard: ({ entry }) => {
45
+ const absolute = canonicalPath({ path: entry.path });
46
+ // HOME may itself be under tmp (desktop E2E), and a selected sites home may live there.
47
+ // Neither is pollution, including temporarily unavailable descendants and symlink aliases.
48
+ const protectedPath = [homeDir, home()].some((root) => {
49
+ const relative = path.relative(canonicalPath({ path: root }), absolute);
50
+ return relative === "" || (!relative.startsWith(".." + path.sep) && relative !== ".." && !path.isAbsolute(relative));
51
+ });
52
+ if (protectedPath)
53
+ return false;
54
+ const temporary = (registryRecovery.temporaryRoots ?? ["/private/tmp", "/tmp", os.tmpdir()]).some((root) => {
55
+ const relative = path.relative(canonicalPath({ path: root }), absolute);
56
+ return relative === "" || (!relative.startsWith(".." + path.sep) && relative !== ".." && !path.isAbsolute(relative));
57
+ });
58
+ if (!temporary && !fixturePattern.test(entry.id))
59
+ return false;
60
+ // A live temp-path folder is a real site. Only ENOENT proves this candidate is stale.
61
+ try {
62
+ ports.filesystem.statSync(entry.path);
63
+ return false;
64
+ }
65
+ catch (error) {
66
+ if (error.code === "ENOENT")
67
+ return true;
68
+ throw error;
69
+ }
70
+ },
71
+ reportDiscarded: ({ entries }) => ports.report.error({ message: `Directory workspace: removed ${entries.length} test/temporary catalog entries: ${entries.map((entry) => entry.id).join(", ")}` }),
72
+ } : {});
73
+ // Read old catalogs without modifying sites. Import only under the settings lane on a mutation;
74
+ // dry-run and startup discovery can inspect the old state without creating app-data files.
75
+ const oldCatalogs = () => {
76
+ // An explicit host profile is an isolation boundary. Importing HOME's legacy settings/catalog
77
+ // would silently enroll the owner's sites (and potentially read real app-data) into QA profiles.
78
+ if (directoryOptions.appDataDir !== undefined)
79
+ return [];
80
+ if (!config || !userConfig?.legacySettingsFile)
81
+ return [];
82
+ const legacy = fileAt(userConfig.legacySettingsFile).read({});
83
+ if (legacy.state === "unreadable")
84
+ throw new Error("Legacy directory setting is unreadable; nothing was overwritten.");
85
+ const oldHome = legacy.state === "ok" ? legacy.value?.home : path.join(homeDir, userConfig.legacyDefaultFolder ?? defaultFolder);
86
+ if (typeof oldHome !== "string")
87
+ throw new Error("Legacy directory setting has an invalid home.");
88
+ return [...new Set([oldHome, ...config.legacyHomes({}), home()])].map((root) => createDirectoryRegistry({ file: fileAt(path.join(root, registryName)), canonicalPath }));
89
+ };
90
+ const importOldCatalog = () => {
91
+ const current = atHome();
92
+ if (!config || current.hasMigration({ migration: "app-data-catalog-v1" }))
93
+ return;
94
+ const previous = oldCatalogs();
95
+ current.merge({ entries: previous.flatMap((catalog) => catalog.list({})), migrations: previous.flatMap((catalog) => catalog.migrations({})), migration: "app-data-catalog-v1" });
96
+ };
97
+ const mutate = (operation) => settings.withLock({ critical: () => { importOldCatalog(); return operation(); } });
98
+ // Serialize pointer changes with catalog mutations. A caller that obtained a registry before
99
+ // settings changed must still write into the current home, never strand a late registration.
100
+ const registry = {
101
+ list: (required, options = {}) => {
102
+ const current = atHome();
103
+ if (!config || current.hasMigration({ migration: "app-data-catalog-v1" }))
104
+ return current.list(required, options);
105
+ const rows = [...oldCatalogs().flatMap((catalog) => catalog.list(required, options)), ...current.list(required, options)];
106
+ return [...new Map(rows.map((row) => [canonicalPath({ path: row.path }), row])).values()];
107
+ },
108
+ hasMigration: (required, options = {}) => atHome().hasMigration(required, options),
109
+ migrations: (required, options = {}) => atHome().migrations(required, options),
110
+ merge: (required, options = {}) => mutate(() => atHome().merge(required, options)),
111
+ opened: (required, options = {}) => mutate(() => atHome().opened(required, options)),
112
+ remove: (required, options = {}) => mutate(() => atHome().remove(required, options)),
113
+ relocate: (required, options = {}) => mutate(() => atHome().relocate(required, options)),
114
+ };
115
+ return {
116
+ homeDir,
117
+ config: (_required, _options = {}) => thisConfig(),
118
+ updateConfig: (required, options = {}) => {
119
+ if (!config)
120
+ throw new Error("User config policy is not configured.");
121
+ config.update(required, options);
122
+ return thisConfig();
123
+ },
124
+ updateRoots: (required, options = {}) => {
125
+ if (!config)
126
+ throw new Error("User config policy is not configured.");
127
+ config.updateRoots(required, options);
128
+ return thisConfig();
129
+ },
130
+ roots: (_required, _options = {}) => config?.roots({}) ?? [home()],
131
+ databasePath: (required, options = {}) => {
132
+ if (!config)
133
+ return path.join(required.dir, required.filename);
134
+ return config.databasePath(required, options);
135
+ },
136
+ /** Only the implicit nested home may create its application parent below mounted Documents.
137
+ * Custom homes still require their immediate parent, so an absent volume is never fabricated.
138
+ */
139
+ homeAvailable: (_required, _options = {}) => {
140
+ const selected = home();
141
+ const parent = config?.legacyHomes({}, { includeMissing: true }).includes(path.dirname(selected)) ? path.dirname(path.dirname(selected)) : path.dirname(selected);
142
+ try {
143
+ return ports.filesystem.statSync(parent).isDirectory();
144
+ }
145
+ catch (error) {
146
+ if (error.code === "ENOENT")
147
+ return false;
148
+ throw error;
149
+ }
150
+ },
151
+ legacyHomes: (_required, _options = {}) => config?.legacyHomes({}) ?? [],
152
+ /** Commit a verified move's catalog and every injected path reference as one locked unit.
153
+ * Uses the registry owner to preserve identity/collision rules; settings retain env precedence.
154
+ * @complexity O(total catalog/reference bytes), with rollback snapshots in memory.
155
+ */
156
+ relocate({ from, to, entry, references = [] }, _options = {}) {
157
+ if (!config)
158
+ throw new Error("Relocation requires user config policy.");
159
+ const repoint = (dir) => {
160
+ const current = canonicalPath({ path: dir });
161
+ if (current === path.dirname(home()) && config.legacyHomes({}).includes(current))
162
+ return home();
163
+ return current === from || current.startsWith(from + path.sep) ? path.join(to, path.relative(from, current)) : dir;
164
+ };
165
+ updateDurableJsonFiles({ fileAt, remove: ({ filePath }) => ports.filesystem.rmSync(filePath, { force: true }), changes: [
166
+ { filePath: config.filePath, transform: (value) => {
167
+ if (value === undefined)
168
+ return undefined;
169
+ const saved = validateDirectoryUserConfig({ value });
170
+ // Persist only the saved pointer's nesting transition. Environment overrides belong
171
+ // to this process and must never become another host's permanent configuration.
172
+ const savedHome = saved.sitesHome === undefined ? undefined : resolveDirectoryHome({ homeDir, defaultFolder, envName, env: {}, setting: saved.sitesHome }, { cwd, ...(directoryOptions.platform ? { platform: directoryOptions.platform } : {}) });
173
+ const paths = directoryOptions.platform === "win32" ? path.win32 : path;
174
+ return { ...saved, ...(saved.sitesHome && savedHome && config.legacyHomes({}, { includeMissing: true }).includes(canonicalPath({ path: savedHome })) ? { sitesHome: paths.join(saved.sitesHome, userConfig.defaultSubfolder) } : {}),
175
+ ...(saved.siteRoots ? { siteRoots: [...new Set(saved.siteRoots.map(repoint))] } : {}) };
176
+ } },
177
+ { filePath: path.join(config.appData, registryName), transform: (value) => {
178
+ // A transaction-local file adapter lets the registry's existing validation and merge
179
+ // own this schema without re-acquiring the physical lock already held above.
180
+ let state = value;
181
+ const staged = createDirectoryRegistry({ canonicalPath, file: {
182
+ read: () => state === undefined ? { state: "missing" } : { state: "ok", value: state },
183
+ write: ({ value: next }) => { state = next; }, withLock: ({ critical }) => critical(),
184
+ tempPath: () => "", quarantine: () => false,
185
+ } });
186
+ if (!staged.hasMigration({ migration: "app-data-catalog-v1" })) {
187
+ const previous = oldCatalogs();
188
+ staged.merge({ entries: previous.flatMap((catalog) => catalog.list({})), migrations: previous.flatMap((catalog) => catalog.migrations({})), migration: "app-data-catalog-v1" });
189
+ }
190
+ const existing = staged.list({}).find((row) => row.path === from || row.id === entry.id);
191
+ if (existing && existing.path !== from && existing.path !== to)
192
+ throw new Error("Site identity is registered at an unrelated path.");
193
+ if (!existing)
194
+ staged.merge({ entries: [{ ...entry, path: from }] });
195
+ staged.relocate({ from, to });
196
+ return state;
197
+ } }, ...references,
198
+ ] });
199
+ },
200
+ home: (_required, _options = {}) => home(),
201
+ setHome({ path: dir }, _options = {}) {
202
+ if (env[envName] !== undefined)
203
+ throw new Error(`${envName} overrides this setting. Unset it before changing the directory home.`);
204
+ if (config) {
205
+ config.update({ patch: { sitesHome: dir } });
206
+ return home();
207
+ }
208
+ const selected = canonicalPath({ path: resolveDirectoryHome({ homeDir, defaultFolder, envName, env: {}, setting: dir }, { cwd }) });
209
+ settings.withLock({ critical: () => {
210
+ const previous = createDirectoryRegistry({ file: fileAt(path.join(home(), registryName)), canonicalPath });
211
+ const target = createDirectoryRegistry({ file: fileAt(path.join(selected, registryName)), canonicalPath });
212
+ target.merge({ entries: previous.list({}), migrations: previous.migrations({}) });
213
+ settings.write({ value: { home: selected } });
214
+ } });
215
+ return home();
216
+ },
217
+ registry: (_required, _options = {}) => registry,
218
+ running: ({ path: dir }, _options = {}) => {
219
+ const filePath = config?.runningPath({ dir }) ?? path.join(canonicalPath({ path: dir }), runningName);
220
+ const current = createDirectoryRunningLock({ file: fileAt(filePath), isAlive: ports.process.isAlive,
221
+ remove: () => ports.filesystem.rmSync(filePath, { force: true }) }, { ...(ports.process.bootIdentity ? { bootIdentity: ports.process.bootIdentity } : {}) });
222
+ if (!config)
223
+ return current;
224
+ const legacyPath = path.join(canonicalPath({ path: dir }), runningName);
225
+ const legacy = createDirectoryRunningLock({ file: fileAt(legacyPath), isAlive: ports.process.isAlive,
226
+ remove: () => ports.filesystem.rmSync(legacyPath, { force: true }) }, { ...(ports.process.bootIdentity ? { bootIdentity: ports.process.bootIdentity } : {}) });
227
+ return { ...current,
228
+ // Older servers may still own a site-local lease. Observe it without moving/editing it;
229
+ // claim serializes against new clients and refuses the old live owner.
230
+ peek: (_required, _options = {}) => legacy.peek({}) ?? current.peek({}),
231
+ read: (_required, _options = {}) => legacy.peek({}) ?? current.read({}),
232
+ claim: (required, options = {}) => settings.withLock({ critical: () => {
233
+ const existing = legacy.peek({});
234
+ if (existing)
235
+ return { acquired: false, record: existing };
236
+ return current.claim(required, options);
237
+ } }),
238
+ };
239
+ },
240
+ claim({ path: dir, app, port = 0, scheme = "http" }, _options = {}) {
241
+ const lock = this.running({ path: dir });
242
+ return lock.claim({ record: { pid: ports.process.pid, port, app, scheme, started: new Date(ports.clock.nowMs()).toISOString(), token: randomUUID() } });
243
+ },
244
+ };
245
+ function thisConfig() {
246
+ if (!config)
247
+ throw new Error("User config policy is not configured.");
248
+ return { ...config.read({}), defaultRoots: config.defaultRoots({}), extraRoots: config.extraRoots({}), configPath: config.filePath, appData: config.appData, warning: config.syncWarning({}),
249
+ overrides: { sitesHome: env[envName] !== undefined, siteRoots: env[userConfig.rootsEnvName] !== undefined } };
250
+ }
251
+ }
252
+ /** Canonicalize existing ancestors without creating the selected directory. */
253
+ function canonicalNodeDirectory({ path: dir }, _options = {}) {
254
+ try {
255
+ return fs.realpathSync(dir);
256
+ }
257
+ catch (error) {
258
+ if (error.code !== "ENOENT")
259
+ throw error;
260
+ // Resolve the nearest existing ancestor too: a not-yet-created home may sit below a
261
+ // symlink (notably /var on macOS) and must compare equal before and after creation.
262
+ const absolute = path.resolve(dir), parent = path.dirname(absolute);
263
+ return parent === absolute ? absolute : path.join(canonicalNodeDirectory({ path: parent }), path.basename(absolute));
264
+ }
265
+ }
266
+ //# sourceMappingURL=directory-workspace.js.map
@@ -5,6 +5,7 @@
5
5
  * Recovery policy stays with each store: salvage, restart, or leave untouched is not decided here.
6
6
  * Native effects are Electron-free so desktop, CLI, and bridge processes can share the mechanism.
7
7
  */
8
+ import { type BootIdentity } from "./boot-identity.js";
8
9
  import nodeFs from "node:fs";
9
10
  import type { Clock } from "@jini-ai/core/primitives";
10
11
  export type JsonFileRead = {
@@ -36,6 +37,7 @@ export interface DurableJsonPorts {
36
37
  isAlive(required: {
37
38
  pid: number;
38
39
  }): boolean;
40
+ bootIdentity?: () => BootIdentity | undefined;
39
41
  };
40
42
  random: {
41
43
  token(_required: Record<string, never>): string;
@@ -86,4 +88,23 @@ export declare function salvageJsonPrefix({ text }: {
86
88
  }, { maxAttempts }?: {
87
89
  maxAttempts?: number;
88
90
  }): unknown;
91
+ export interface DurableJsonChange {
92
+ filePath: string;
93
+ /** Missing optional references stay missing; transforms must reject unreadable schemas. */
94
+ transform: (value: unknown | undefined) => unknown | undefined;
95
+ }
96
+ /** Update related JSON pointers under their existing locks, with atomic replacement per file.
97
+ * Compute every change before writing; a failed write restores prior bytes before releasing locks.
98
+ * The host supplies schema transforms, so this owner knows nothing about product catalogs.
99
+ * @throws Unreadable input, transform, lock or persistence failures (including rollback failures).
100
+ * @example updateDurableJsonFiles({ changes, fileAt, remove });
101
+ * @complexity O(total JSON bytes), O(total JSON bytes) rollback storage, one lock per file.
102
+ */
103
+ export declare function updateDurableJsonFiles({ changes, fileAt, remove }: {
104
+ changes: readonly DurableJsonChange[];
105
+ fileAt: (path: string) => DurableJsonFile;
106
+ remove: (required: {
107
+ filePath: string;
108
+ }) => void;
109
+ }, _options?: {}): void;
89
110
  //# sourceMappingURL=durable-json.d.ts.map
@@ -5,6 +5,7 @@
5
5
  * Recovery policy stays with each store: salvage, restart, or leave untouched is not decided here.
6
6
  * Native effects are Electron-free so desktop, CLI, and bridge processes can share the mechanism.
7
7
  */
8
+ import { readBootIdentity } from "./boot-identity.js";
8
9
  import nodeFs from "node:fs";
9
10
  import path from "node:path";
10
11
  import { randomBytes } from "node:crypto";
@@ -18,7 +19,7 @@ export function createNodeDurableJsonPorts(_required) {
18
19
  clock: { nowMs: () => Date.now() },
19
20
  // Synchronous store/IPC writers cannot await lock polling without changing all callers' ABI.
20
21
  sleep: { sleep: ({ durationMs }) => { Atomics.wait(cell, 0, 0, durationMs); } },
21
- process: { pid: process.pid, isAlive: ({ pid }) => {
22
+ process: { pid: process.pid, bootIdentity: () => readBootIdentity({}), isAlive: ({ pid }) => {
22
23
  // Signal zero sends nothing. Only ESRCH proves death; permission failure must count as alive
23
24
  // because a false "dead" authorizes breaking a lock still owned by another process.
24
25
  try {
@@ -40,9 +41,9 @@ export function createNodeDurableJsonPorts(_required) {
40
41
  export function createDurableJsonFile({ filePath, filesystem: fs, clock, sleep, process, random, report, notices }, { pollMs = 10, staleMs = 30000 } = {}) {
41
42
  if (!filePath)
42
43
  throw new TypeError("filePath is required");
43
- for (const value of [pollMs, staleMs])
44
- if (!Number.isSafeInteger(value) || value <= 0)
45
- throw new TypeError("lock intervals must be positive safe integers");
44
+ if (!Number.isSafeInteger(pollMs) || pollMs <= 0 ||
45
+ ((!Number.isSafeInteger(staleMs) && staleMs !== Infinity) || staleMs <= 0))
46
+ throw new TypeError("lock intervals must be positive safe integers");
46
47
  // A synchronous read-modify-write normally takes milliseconds; five seconds allows contention
47
48
  // while refusing a wedged change. Short ten-millisecond polling fits those short critical sections.
48
49
  // Thirty-second staleness also handles pid reuse after a crash:
@@ -163,4 +164,58 @@ function endOfString(text, start) {
163
164
  }
164
165
  return text.length;
165
166
  }
167
+ /** Update related JSON pointers under their existing locks, with atomic replacement per file.
168
+ * Compute every change before writing; a failed write restores prior bytes before releasing locks.
169
+ * The host supplies schema transforms, so this owner knows nothing about product catalogs.
170
+ * @throws Unreadable input, transform, lock or persistence failures (including rollback failures).
171
+ * @example updateDurableJsonFiles({ changes, fileAt, remove });
172
+ * @complexity O(total JSON bytes), O(total JSON bytes) rollback storage, one lock per file.
173
+ */
174
+ export function updateDurableJsonFiles({ changes, fileAt, remove }, _options = {}) {
175
+ if (new Set(changes.map((change) => change.filePath)).size !== changes.length)
176
+ throw new Error("Duplicate JSON transaction path.");
177
+ const ordered = [...changes].sort((a, b) => a.filePath.localeCompare(b.filePath));
178
+ const files = ordered.map((change) => fileAt(change.filePath));
179
+ const lock = (index) => {
180
+ if (index < files.length) {
181
+ files[index].withLock({ critical: () => lock(index + 1) });
182
+ return;
183
+ }
184
+ const updates = ordered.map((change, i) => {
185
+ const before = files[i].read({});
186
+ if (before.state === "unreadable")
187
+ throw new Error(`JSON reference is unreadable: ${change.filePath}. Nothing was moved.`);
188
+ const previous = before.state === "ok" ? before.value : undefined;
189
+ return { file: files[i], filePath: change.filePath, previous, next: change.transform(previous) };
190
+ }).filter((item) => JSON.stringify(item.previous) !== JSON.stringify(item.next));
191
+ const attempted = [];
192
+ try {
193
+ for (const item of updates) {
194
+ attempted.push(item); // A write can fail after its rename; include it in rollback.
195
+ if (item.next === undefined)
196
+ remove({ filePath: item.filePath });
197
+ else
198
+ item.file.write({ value: item.next });
199
+ }
200
+ }
201
+ catch (failure) {
202
+ const errors = [failure];
203
+ for (const item of attempted.reverse()) {
204
+ try {
205
+ if (item.previous === undefined)
206
+ remove({ filePath: item.filePath });
207
+ else
208
+ item.file.write({ value: item.previous });
209
+ }
210
+ catch (error) {
211
+ errors.push(error);
212
+ }
213
+ }
214
+ if (errors.length > 1)
215
+ throw new AggregateError(errors, "JSON reference rollback failed.");
216
+ throw failure;
217
+ }
218
+ };
219
+ lock(0);
220
+ }
166
221
  //# sourceMappingURL=durable-json.js.map
@@ -10,6 +10,7 @@
10
10
  * multi-process races and are deliberately avoided. Atomic file replacement independently prevents
11
11
  * torn content. Hosts whose operations can legitimately exceed the stale budget use staleMs: Infinity.
12
12
  */
13
+ import { type BootIdentity } from "./boot-identity.js";
13
14
  import fs from "node:fs";
14
15
  import * as asyncFs from "node:fs/promises";
15
16
  import { type Clock } from "@jini-ai/core/primitives";
@@ -21,6 +22,7 @@ export interface FileLockHolder {
21
22
  readonly hostname: string;
22
23
  readonly token: string;
23
24
  readonly acquiredAt: string;
25
+ readonly bootId?: string;
24
26
  }
25
27
  export interface HeldFileLock {
26
28
  readonly lockPath: string;
@@ -50,6 +52,7 @@ export interface FileLockOptions {
50
52
  isAlive(required: {
51
53
  pid: number;
52
54
  }): boolean;
55
+ bootIdentity?: () => BootIdentity | undefined;
53
56
  };
54
57
  token?: () => string;
55
58
  hostname?: () => string;
@@ -10,6 +10,7 @@
10
10
  * multi-process races and are deliberately avoided. Atomic file replacement independently prevents
11
11
  * torn content. Hosts whose operations can legitimately exceed the stale budget use staleMs: Infinity.
12
12
  */
13
+ import { readBootIdentity } from "./boot-identity.js";
13
14
  import fs from "node:fs";
14
15
  import * as asyncFs from "node:fs/promises";
15
16
  import { randomUUID } from "node:crypto";
@@ -52,7 +53,7 @@ function parseHolder(raw) {
52
53
  const value = JSON.parse(raw);
53
54
  if (typeof value !== "object" || value === null || !("pid" in value) || !validPid(value.pid) || !("hostname" in value) || typeof value.hostname !== "string" || !("token" in value) || typeof value.token !== "string" || !("acquiredAt" in value) || typeof value.acquiredAt !== "string")
54
55
  return undefined;
55
- return { pid: value.pid, hostname: value.hostname, token: value.token, acquiredAt: value.acquiredAt };
56
+ return { pid: value.pid, hostname: value.hostname, token: value.token, acquiredAt: value.acquiredAt, ...("bootId" in value && typeof value.bootId === "string" ? { bootId: value.bootId } : {}) };
56
57
  }
57
58
  catch {
58
59
  return undefined;
@@ -67,7 +68,7 @@ function settings(options) {
67
68
  const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS, staleMs = options.staleMs ?? DEFAULT_LOCK_STALE_AFTER_MS, pollMs = options.pollMs ?? 10;
68
69
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 0 || (!Number.isSafeInteger(staleMs) && staleMs !== Infinity) || staleMs <= 0 || !Number.isSafeInteger(pollMs) || pollMs <= 0)
69
70
  throw new RangeError((options.messages ?? defaultPlatformMessages).fileLockIntervalsInvalid());
70
- const processPort = options.process ?? { pid: process.pid, isAlive: ({ pid }) => { try {
71
+ const processPort = options.process ?? { pid: process.pid, bootIdentity: () => readBootIdentity({}), isAlive: ({ pid }) => { try {
71
72
  process.kill(pid, 0);
72
73
  return true;
73
74
  }
@@ -76,19 +77,27 @@ function settings(options) {
76
77
  } } };
77
78
  const clock = options.clock ?? createSystemClock();
78
79
  const monotonicMs = options.monotonicMs ?? (() => clock.nowMs());
79
- return { timeoutMs, staleMs, pollMs, processPort, clock, monotonicMs, hostname: (options.hostname ?? nodeHostname)(), token: (options.token ?? randomUUID)() };
80
+ return { timeoutMs, staleMs, pollMs, processPort, clock, monotonicMs, boot: processPort.bootIdentity?.(), hostname: (options.hostname ?? nodeHostname)(), token: (options.token ?? randomUUID)() };
80
81
  }
81
82
  function ownership(s, options) {
82
- return options.format === "token" ? `${s.processPort.pid}:${s.token}` : JSON.stringify({ pid: s.processPort.pid, hostname: s.hostname, token: s.token, acquiredAt: new Date(s.clock.nowMs()).toISOString() });
83
+ return options.format === "token" ? `${s.processPort.pid}:${s.token}${s.boot ? ":" + s.boot.id : ""}` : JSON.stringify({ pid: s.processPort.pid, hostname: s.hostname, token: s.token, acquiredAt: new Date(s.clock.nowMs()).toISOString(), ...(s.boot ? { bootId: s.boot.id } : {}) });
83
84
  }
84
85
  function ownerAlive(snapshot, s) {
85
86
  const holder = snapshot.holder;
86
- if (holder)
87
- return holder.hostname === s.hostname ? s.processPort.isAlive({ pid: holder.pid }) : undefined;
87
+ if (holder) {
88
+ if (holder.hostname !== s.hostname)
89
+ return undefined;
90
+ if (s.boot && (holder.bootId !== undefined ? holder.bootId !== s.boot.id : Date.parse(holder.acquiredAt) < s.boot.startedAtMs))
91
+ return false;
92
+ return s.processPort.isAlive({ pid: holder.pid });
93
+ }
88
94
  // Legacy synchronous stores wrote pid:random rather than JSON. Malformed pids remain unknown.
89
95
  if (!/^\d+:/.test(snapshot.raw))
90
96
  return undefined;
91
- const pid = Number(snapshot.raw.split(":", 1)[0]);
97
+ const parts = snapshot.raw.split(":");
98
+ const pid = Number(parts[0]);
99
+ if (validPid(pid) && s.boot && (parts[2] ? parts[2] !== s.boot.id : snapshot.mtimeMs < s.boot.startedAtMs))
100
+ return false;
92
101
  return validPid(pid) ? s.processPort.isAlive({ pid }) : undefined;
93
102
  }
94
103
  function unchanged(a, b) { return a.ino === b.ino && a.dev === b.dev && a.raw === b.raw && a.mtimeMs === b.mtimeMs; }
@@ -5,4 +5,10 @@ export * from "./strict-containment.js";
5
5
  export * from "./guarded-reader.js";
6
6
  export * from "./durable-json.js";
7
7
  export { withFileLock, withFileLockSync, isLockStale } from "./file-lock.js";
8
+ export * from "./directory-home.js";
9
+ export * from "./directory-registry.js";
10
+ export * from "./directory-running-lock.js";
11
+ export * from "./directory-workspace.js";
12
+ export * from "./workspace-directory.js";
13
+ export * from "./verified-directory-move.js";
8
14
  //# sourceMappingURL=index.d.ts.map
package/dist/fs/index.js CHANGED
@@ -6,4 +6,10 @@ export * from "./strict-containment.js";
6
6
  export * from "./guarded-reader.js";
7
7
  export * from "./durable-json.js";
8
8
  export { withFileLock, withFileLockSync, isLockStale } from "./file-lock.js";
9
+ export * from "./directory-home.js";
10
+ export * from "./directory-registry.js";
11
+ export * from "./directory-running-lock.js";
12
+ export * from "./directory-workspace.js";
13
+ export * from "./workspace-directory.js";
14
+ export * from "./verified-directory-move.js";
9
15
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,28 @@
1
+ export interface UserDirectoryOptions {
2
+ /** Resolved host profile directory; owns config/catalog/DB/lease paths without an app-name suffix. */
3
+ appDataDir?: string;
4
+ platform?: NodeJS.Platform;
5
+ env?: NodeJS.ProcessEnv;
6
+ readText?: (file: string) => string | undefined;
7
+ windowsDocuments?: () => string | undefined;
8
+ windowsOneDriveRoots?: () => readonly string[];
9
+ }
10
+ /** Electron-free OS directory rules. Windows uses roaming app-data and the redirected Personal
11
+ * known folder; Linux honors XDG user-dirs. No directories are created by this resolver.
12
+ * env-paths is intentionally not used: its Windows data directory is LocalAppData/<app>/Data.
13
+ * @complexity O(1) bounded configuration reads.
14
+ */
15
+ export declare function resolveUserDataDirectories({ homeDir, appName }: {
16
+ homeDir: string;
17
+ appName: string;
18
+ }, { platform, env, appDataDir, readText, windowsDocuments }?: UserDirectoryOptions): {
19
+ appData: string;
20
+ documents: string;
21
+ };
22
+ /** Native reads are isolated so cross-platform fixtures never execute registry commands. */
23
+ export declare function readOptionalText({ file }: {
24
+ file: string;
25
+ }, _options?: {}): string | undefined;
26
+ /** OneDrive publishes account mount paths in HKCU; absent accounts are a normal empty result. */
27
+ export declare function readWindowsOneDriveRoots(_required: Record<string, never>, _options?: {}): readonly string[];
28
+ //# sourceMappingURL=user-data-directories.d.ts.map
@@ -0,0 +1,53 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ import { execFileSync } from "node:child_process";
4
+ /** Electron-free OS directory rules. Windows uses roaming app-data and the redirected Personal
5
+ * known folder; Linux honors XDG user-dirs. No directories are created by this resolver.
6
+ * env-paths is intentionally not used: its Windows data directory is LocalAppData/<app>/Data.
7
+ * @complexity O(1) bounded configuration reads.
8
+ */
9
+ export function resolveUserDataDirectories({ homeDir, appName }, { platform = process.platform, env = process.env, appDataDir, readText = (file) => readOptionalText({ file }), windowsDocuments = readWindowsDocuments } = {}) {
10
+ const paths = platform === "win32" ? path.win32 : path.posix;
11
+ if (appDataDir !== undefined && (!appDataDir.trim() || !paths.isAbsolute(appDataDir)))
12
+ throw new Error("appDataDir must be an absolute profile directory.");
13
+ if (platform === "win32") {
14
+ const personal = windowsDocuments();
15
+ const documents = personal?.replace(/%([^%]+)%/g, (match, key) => env[key] ?? match);
16
+ return { appData: appDataDir ?? paths.join(env.APPDATA ?? paths.join(homeDir, "AppData", "Roaming"), appName),
17
+ documents: documents ?? paths.join(homeDir, "Documents") };
18
+ }
19
+ if (platform === "darwin")
20
+ return { appData: appDataDir ?? paths.join(homeDir, "Library", "Application Support", appName), documents: paths.join(homeDir, "Documents") };
21
+ const userDirs = readText(paths.join(env.XDG_CONFIG_HOME ?? paths.join(homeDir, ".config"), "user-dirs.dirs"));
22
+ const configured = /^XDG_DOCUMENTS_DIR="([^"\n]*)"/m.exec(userDirs ?? "")?.[1]?.replace(/\$HOME/g, homeDir);
23
+ return { appData: appDataDir ?? paths.join(env.XDG_DATA_HOME ?? paths.join(homeDir, ".local", "share"), appName),
24
+ documents: configured && paths.isAbsolute(configured) ? configured : paths.join(homeDir, "Documents") };
25
+ }
26
+ /** Native reads are isolated so cross-platform fixtures never execute registry commands. */
27
+ export function readOptionalText({ file }, _options = {}) {
28
+ try {
29
+ return fs.readFileSync(file, "utf8");
30
+ }
31
+ catch (error) {
32
+ if (error.code === "ENOENT")
33
+ return undefined;
34
+ throw error;
35
+ }
36
+ }
37
+ function readWindowsDocuments() {
38
+ const output = execFileSync("reg.exe", ["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders", "/v", "Personal"], { encoding: "utf8", timeout: 3000, windowsHide: true });
39
+ return /Personal\s+REG_(?:EXPAND_)?SZ\s+(.+)/.exec(output)?.[1]?.trim();
40
+ }
41
+ /** OneDrive publishes account mount paths in HKCU; absent accounts are a normal empty result. */
42
+ export function readWindowsOneDriveRoots(_required, _options = {}) {
43
+ try {
44
+ const output = execFileSync("reg.exe", ["query", "HKCU\\Software\\Microsoft\\OneDrive\\Accounts", "/s", "/v", "UserFolder"], { encoding: "utf8", timeout: 3000, windowsHide: true });
45
+ return [...output.matchAll(/UserFolder\s+REG_(?:EXPAND_)?SZ\s+([^\r\n]+)/g)].map((match) => match[1].trim());
46
+ }
47
+ catch (error) {
48
+ if (error.status === 1)
49
+ return [];
50
+ throw error;
51
+ }
52
+ }
53
+ //# sourceMappingURL=user-data-directories.js.map
@@ -0,0 +1,37 @@
1
+ import { type VerifiedFileGroup } from "./verified-file-relocation.js";
2
+ import fs from "node:fs";
3
+ /** Fingerprint relative names, entry kinds, symlink targets and file bytes without following links.
4
+ * This is verification, never a log of file contents. Lease files are excluded by the host.
5
+ * @complexity O(bytes + entries log entries), O(entries + a fixed 64 KiB read buffer) space.
6
+ */
7
+ export declare function directoryFingerprint({ dir }: {
8
+ dir: string;
9
+ }, { excluded, filesystem }?: {
10
+ excluded?: readonly string[];
11
+ filesystem?: typeof fs;
12
+ }): string;
13
+ /** Move only into an absent target. Verify before registry commit or source deletion.
14
+ * EXDEV uses copy/verify/retire/commit; failure restores the source and removes only our new copy.
15
+ * Same-filesystem failures roll the rename back. Commit must atomically update the host registry.
16
+ * @complexity O(bytes + entries log entries), with two verification passes.
17
+ */
18
+ export declare function moveDirectoryVerified({ from, to, commit }: {
19
+ from: string;
20
+ to: string;
21
+ commit: () => void;
22
+ }, { excluded, filesystem, prepareDestination, relocateFiles, verifyFile, backupId }?: {
23
+ excluded?: readonly string[];
24
+ filesystem?: typeof fs;
25
+ prepareDestination?: (required: {
26
+ path: string;
27
+ }) => void;
28
+ relocateFiles?: (required: {
29
+ source: string;
30
+ target: string;
31
+ }) => readonly VerifiedFileGroup[];
32
+ verifyFile?: (required: {
33
+ path: string;
34
+ }) => string;
35
+ backupId?: () => string;
36
+ }): void;
37
+ //# sourceMappingURL=verified-directory-move.d.ts.map