@crouter/api 0.3.386 → 0.3.388

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 (130) hide show
  1. package/dist/api/__tests__/integration/client.test.js +97 -0
  2. package/dist/api/client.d.ts +7 -0
  3. package/dist/api/client.js +40 -21
  4. package/dist/core/asset-root.d.ts +7 -0
  5. package/dist/core/asset-root.js +18 -0
  6. package/dist/core/canvas/boot-id.d.ts +6 -0
  7. package/dist/core/canvas/boot-id.js +26 -0
  8. package/dist/core/canvas/paths.d.ts +72 -0
  9. package/dist/core/canvas/paths.js +163 -0
  10. package/dist/core/canvas/pid.d.ts +391 -0
  11. package/dist/core/canvas/pid.js +948 -0
  12. package/dist/core/command-plugins/bundle.d.ts +149 -0
  13. package/dist/core/command-plugins/bundle.js +588 -0
  14. package/dist/core/command-plugins/endpoint.d.ts +24 -0
  15. package/dist/core/command-plugins/endpoint.js +51 -0
  16. package/dist/core/config.d.ts +233 -0
  17. package/dist/core/config.js +1120 -0
  18. package/dist/core/env-name.d.ts +6 -0
  19. package/dist/core/env-name.js +9 -0
  20. package/dist/core/errors.d.ts +38 -0
  21. package/dist/core/errors.js +90 -0
  22. package/dist/core/events/emit.d.ts +6 -0
  23. package/dist/core/events/emit.js +42 -0
  24. package/dist/core/events/envelope.d.ts +2 -0
  25. package/dist/core/events/envelope.js +84 -0
  26. package/dist/core/events/errors.d.ts +4 -0
  27. package/dist/core/events/errors.js +69 -0
  28. package/dist/core/events/operation-id.d.ts +4 -0
  29. package/dist/core/events/operation-id.js +24 -0
  30. package/dist/core/events/serialize.d.ts +4 -0
  31. package/dist/core/events/serialize.js +199 -0
  32. package/dist/core/events/source.d.ts +16 -0
  33. package/dist/core/events/source.js +31 -0
  34. package/dist/core/events/types.d.ts +68 -0
  35. package/dist/core/events/types.js +11 -0
  36. package/dist/core/exclusive-lock.d.ts +34 -0
  37. package/dist/core/exclusive-lock.js +197 -0
  38. package/dist/core/fs-utils.d.ts +44 -0
  39. package/dist/core/fs-utils.js +208 -0
  40. package/dist/core/help.d.ts +309 -0
  41. package/dist/core/help.js +406 -0
  42. package/dist/core/human/page-catalog.d.ts +57 -0
  43. package/dist/core/human/page-catalog.js +172 -0
  44. package/dist/core/installed-plugins.d.ts +2 -0
  45. package/dist/core/installed-plugins.js +79 -0
  46. package/dist/core/io.d.ts +122 -0
  47. package/dist/core/io.js +373 -0
  48. package/dist/core/keybindings/attach-control.d.ts +49 -0
  49. package/dist/core/keybindings/attach-control.js +42 -0
  50. package/dist/core/keybindings/catalog.d.ts +18 -0
  51. package/dist/core/keybindings/catalog.js +257 -0
  52. package/dist/core/keybindings/types.d.ts +42 -0
  53. package/dist/core/keybindings/types.js +1 -0
  54. package/dist/core/layout.d.ts +26 -0
  55. package/dist/core/layout.js +94 -0
  56. package/dist/core/locked-file.d.ts +27 -0
  57. package/dist/core/locked-file.js +118 -0
  58. package/dist/core/log.d.ts +9 -0
  59. package/dist/core/log.js +89 -0
  60. package/dist/core/manifest.d.ts +5 -0
  61. package/dist/core/manifest.js +15 -0
  62. package/dist/core/plugin-env.d.ts +8 -0
  63. package/dist/core/plugin-env.js +31 -0
  64. package/dist/core/plugin-extensions.d.ts +29 -0
  65. package/dist/core/plugin-extensions.js +191 -0
  66. package/dist/core/plugin-swap-lock.d.ts +9 -0
  67. package/dist/core/plugin-swap-lock.js +31 -0
  68. package/dist/core/preview-result-path.d.ts +4 -0
  69. package/dist/core/preview-result-path.js +26 -0
  70. package/dist/core/profiles/env-store.d.ts +22 -0
  71. package/dist/core/profiles/env-store.js +163 -0
  72. package/dist/core/profiles/fuzzy-match.d.ts +19 -0
  73. package/dist/core/profiles/fuzzy-match.js +92 -0
  74. package/dist/core/profiles/manifest.d.ts +120 -0
  75. package/dist/core/profiles/manifest.js +529 -0
  76. package/dist/core/rate-limit-scope.d.ts +25 -0
  77. package/dist/core/rate-limit-scope.js +64 -0
  78. package/dist/core/render.d.ts +12 -0
  79. package/dist/core/render.js +138 -0
  80. package/dist/core/resolver.d.ts +14 -0
  81. package/dist/core/resolver.js +111 -0
  82. package/dist/core/runtime/branded-host.d.ts +25 -0
  83. package/dist/core/runtime/branded-host.js +264 -0
  84. package/dist/core/runtime/broker/daemon-ops.d.ts +65 -0
  85. package/dist/core/runtime/broker/daemon-ops.js +177 -0
  86. package/dist/core/runtime/broker/signal-stream.d.ts +30 -0
  87. package/dist/core/runtime/broker/signal-stream.js +149 -0
  88. package/dist/core/scope.d.ts +32 -0
  89. package/dist/core/scope.js +184 -0
  90. package/dist/core/scoped-state/db.d.ts +17 -0
  91. package/dist/core/scoped-state/db.js +247 -0
  92. package/dist/core/scoped-state/migrate.d.ts +8 -0
  93. package/dist/core/scoped-state/migrate.js +187 -0
  94. package/dist/core/scoped-state/paths.d.ts +9 -0
  95. package/dist/core/scoped-state/paths.js +27 -0
  96. package/dist/core/scoped-state/profiles.d.ts +27 -0
  97. package/dist/core/scoped-state/profiles.js +93 -0
  98. package/dist/core/scoped-state/providers.d.ts +24 -0
  99. package/dist/core/scoped-state/providers.js +19 -0
  100. package/dist/core/scoped-state/schema.d.ts +6 -0
  101. package/dist/core/scoped-state/schema.js +43 -0
  102. package/dist/core/scoped-state/settings.d.ts +28 -0
  103. package/dist/core/scoped-state/settings.js +83 -0
  104. package/dist/core/spaces/open-beneath.d.ts +71 -0
  105. package/dist/core/spaces/open-beneath.js +581 -0
  106. package/dist/core/sqlite-statements.d.ts +4 -0
  107. package/dist/core/sqlite-statements.js +17 -0
  108. package/dist/core/subscription-state.d.ts +121 -0
  109. package/dist/core/subscription-state.js +287 -0
  110. package/dist/core/user-settings.d.ts +377 -0
  111. package/dist/core/user-settings.js +458 -0
  112. package/dist/daemon/broker-signals/bus.d.ts +30 -0
  113. package/dist/daemon/broker-signals/bus.js +87 -0
  114. package/dist/daemon/manage.d.ts +176 -0
  115. package/dist/daemon/manage.js +664 -0
  116. package/dist/daemon/pidfile.d.ts +8 -0
  117. package/dist/daemon/pidfile.js +37 -0
  118. package/dist/daemon/startup-policy.d.ts +1 -0
  119. package/dist/daemon/startup-policy.js +1 -0
  120. package/dist/native/linux.d.ts +29 -0
  121. package/dist/native/linux.js +20 -0
  122. package/dist/shared/env.d.ts +116 -0
  123. package/dist/shared/env.js +271 -0
  124. package/dist/shared/inbox-entry-body.d.ts +22 -0
  125. package/dist/shared/inbox-entry-body.js +116 -0
  126. package/dist/shared/working-activity.d.ts +9 -0
  127. package/dist/shared/working-activity.js +27 -0
  128. package/dist/types.d.ts +562 -0
  129. package/dist/types.js +186 -0
  130. package/package.json +1 -1
@@ -0,0 +1,68 @@
1
+ export type EventLevel = 'error' | 'warn' | 'info' | 'debug';
2
+ export type EventComponent = 'daemon' | 'broker' | 'control-plane' | 'front' | 'sentinel';
3
+ export type EventOutcome = 'succeeded' | 'failed' | 'skipped';
4
+ export type RetryDisposition = 'auto' | 'manual' | 'fatal';
5
+ export type OperationId = string & {
6
+ readonly __operationId: unique symbol;
7
+ };
8
+ export declare const errorClassCodes: readonly ["rate_limit", "overloaded", "connection", "auth", "protocol", "context_overflow", "wedged", "model_not_found", "unknown"];
9
+ export type ErrorClassCode = (typeof errorClassCodes)[number];
10
+ export interface ErrorClass {
11
+ class: ErrorClassCode;
12
+ status?: number;
13
+ provider?: string;
14
+ retry_disposition?: RetryDisposition;
15
+ }
16
+ export interface EventError {
17
+ type: string;
18
+ message: string;
19
+ stack?: string;
20
+ cause?: string;
21
+ }
22
+ export type EventField = null | boolean | number | string | readonly EventField[] | {
23
+ readonly [key: string]: EventField;
24
+ };
25
+ export interface EventEnvelope {
26
+ v: 1;
27
+ ts: string;
28
+ level: EventLevel;
29
+ event: string;
30
+ component: EventComponent;
31
+ operation_id: OperationId;
32
+ node_id?: string;
33
+ stream_id?: string;
34
+ duration_ms?: number;
35
+ outcome?: EventOutcome;
36
+ attempt?: number;
37
+ error_class?: ErrorClass;
38
+ error?: EventError;
39
+ fields?: Readonly<Record<string, EventField>>;
40
+ record_truncated?: true;
41
+ original_bytes?: number;
42
+ dropped_fields_count?: number;
43
+ }
44
+ export interface BuildEnvelopeInput {
45
+ ts?: string;
46
+ level: EventLevel;
47
+ event: string;
48
+ component: EventComponent;
49
+ operation_id?: string;
50
+ node_id?: string;
51
+ stream_id?: string;
52
+ duration_ms?: number;
53
+ outcome?: EventOutcome;
54
+ attempt?: number;
55
+ error_class?: ErrorClass;
56
+ error?: unknown;
57
+ fields?: Readonly<Record<string, unknown>>;
58
+ }
59
+ export interface ErrorClassHints {
60
+ status?: number;
61
+ provider?: string;
62
+ retry_disposition?: RetryDisposition;
63
+ }
64
+ export interface OperationIdContext {
65
+ current(): OperationId | undefined;
66
+ run<T>(operationId: string, callback: () => T): T;
67
+ fresh<T>(callback: (operationId: OperationId) => T): T;
68
+ }
@@ -0,0 +1,11 @@
1
+ export const errorClassCodes = [
2
+ 'rate_limit',
3
+ 'overloaded',
4
+ 'connection',
5
+ 'auth',
6
+ 'protocol',
7
+ 'context_overflow',
8
+ 'wedged',
9
+ 'model_not_found',
10
+ 'unknown',
11
+ ];
@@ -0,0 +1,34 @@
1
+ /** The pid currently holding this lock, read straight off the marker name (the
2
+ * token opens with the owner's pid). Null when the lock is free, unowned, or
3
+ * names a token we cannot parse. Lets a caller tell "my child is doing the
4
+ * work" apart from "my child is queued behind someone else's work" without
5
+ * any new bookkeeping. */
6
+ export declare function exclusiveLockOwnerPid(path: string): number | null;
7
+ /**
8
+ * Block until `path` is no longer held by a LIVE other process, or the wait
9
+ * runs out. Unlike `withExclusiveDirectoryLock*` this never CLAIMS the lock —
10
+ * it is for a reader that only needs the holder's transition to be over before
11
+ * it looks at the directory the holder is rewriting.
12
+ *
13
+ * Returns immediately when the lock is free, when its marker names this very
14
+ * process (a caller waiting on its own lock would otherwise deadlock), or when
15
+ * the named owner is gone — a dead holder's transition is already over, however
16
+ * it ended.
17
+ */
18
+ export declare function awaitExclusiveLockRelease(path: string, timeoutMs: number): void;
19
+ export interface ExclusiveLockOptions {
20
+ /** Grace before a lock that names no owner at all is cleared. */
21
+ staleMs?: number;
22
+ timeoutMs?: number;
23
+ /** The error thrown when the wait runs out, so callers keep their own remediation. */
24
+ timeoutError?: () => Error;
25
+ }
26
+ /**
27
+ * Run a short filesystem transition under a token-checked, crash-reclaimable
28
+ * directory lock. A holder that dies releases immediately — the next contender
29
+ * sees a dead pid in the marker name — while a live holder is never stolen from,
30
+ * however long its operation runs.
31
+ */
32
+ export declare function withExclusiveDirectoryLock<T>(path: string, operation: () => T, options?: ExclusiveLockOptions): T;
33
+ /** Run an awaited operation under the same token-checked directory lock. */
34
+ export declare function withExclusiveDirectoryLockAsync<T>(path: string, operation: () => Promise<T>, options?: ExclusiveLockOptions): Promise<T>;
@@ -0,0 +1,197 @@
1
+ import { existsSync, mkdirSync, readdirSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ const MARKER_PREFIX = 'owner.';
4
+ // Contention backoff. The first polls stay tight so an uncontended hand-off is
5
+ // still effectively immediate; the interval then doubles to a ceiling so a lock
6
+ // held across a long operation (a corpus migration, say) cannot turn a crowd of
7
+ // waiters into a filesystem hot loop — N waiters at a fixed 10ms is N*100
8
+ // tryAcquire() syscall bursts per second on one directory.
9
+ const POLL_MS = 10;
10
+ const POLL_MAX_MS = 250;
11
+ const DEFAULT_STALE_MS = 30_000;
12
+ const DEFAULT_TIMEOUT_MS = 5_000;
13
+ function nextPollMs(current) { return Math.min(current * 2, POLL_MAX_MS); }
14
+ function pause(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
15
+ function pauseAsync(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
16
+ function markerPath(path, token) { return `${path}/${MARKER_PREFIX}${token}`; }
17
+ function observedMarkerToken(path) {
18
+ try {
19
+ return readdirSync(path).find((entry) => entry.startsWith(MARKER_PREFIX))?.slice(MARKER_PREFIX.length) ?? null;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ /** The token opens with the owner's pid, so liveness reads off the name. */
26
+ function ownerIsAlive(token) {
27
+ const pid = Number(token.split('.', 1)[0]);
28
+ if (!Number.isSafeInteger(pid) || pid <= 0)
29
+ return false;
30
+ try {
31
+ process.kill(pid, 0);
32
+ return true;
33
+ }
34
+ catch (error) {
35
+ return error.code === 'EPERM';
36
+ }
37
+ }
38
+ function isEmptyDirectory(path) {
39
+ try {
40
+ return readdirSync(path).length === 0;
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ function ageMs(path) {
47
+ try {
48
+ return Date.now() - statSync(path).mtimeMs;
49
+ }
50
+ catch {
51
+ return 0;
52
+ }
53
+ }
54
+ // Reclamation is bound to the exact instance it observed: it unlinks ONLY that
55
+ // token's marker, then removes the now-empty directory with an empty-guarded
56
+ // rmdir. A successor holds a different random token — a different marker name —
57
+ // so a lagging reclaimer that saw the old dead lock can never strip a live
58
+ // successor: its unlink targets a name that no longer exists.
59
+ function reclaimIfOwnerGone(path, staleMs) {
60
+ const token = observedMarkerToken(path);
61
+ if (token !== null) {
62
+ if (ownerIsAlive(token))
63
+ return;
64
+ try {
65
+ unlinkSync(markerPath(path, token));
66
+ }
67
+ catch (error) {
68
+ if (error.code === 'ENOENT')
69
+ return;
70
+ throw error;
71
+ }
72
+ }
73
+ else {
74
+ // Nothing names an owner. A live holder always has its marker, so this is a
75
+ // crash between mkdir and the marker write, or a lock left in some older
76
+ // shape; age is the only evidence, and an empty-guarded rmdir still cannot
77
+ // take a successor that has since claimed the name.
78
+ if (ageMs(path) <= staleMs)
79
+ return;
80
+ if (!isEmptyDirectory(path)) {
81
+ rmSync(path, { recursive: true, force: true });
82
+ return;
83
+ }
84
+ }
85
+ try {
86
+ rmdirSync(path);
87
+ }
88
+ catch (error) {
89
+ const code = error.code;
90
+ if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST')
91
+ throw error;
92
+ }
93
+ }
94
+ /** The pid currently holding this lock, read straight off the marker name (the
95
+ * token opens with the owner's pid). Null when the lock is free, unowned, or
96
+ * names a token we cannot parse. Lets a caller tell "my child is doing the
97
+ * work" apart from "my child is queued behind someone else's work" without
98
+ * any new bookkeeping. */
99
+ export function exclusiveLockOwnerPid(path) {
100
+ const token = observedMarkerToken(path);
101
+ if (token === null)
102
+ return null;
103
+ const pid = Number(token.split('.', 1)[0]);
104
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
105
+ }
106
+ /**
107
+ * Block until `path` is no longer held by a LIVE other process, or the wait
108
+ * runs out. Unlike `withExclusiveDirectoryLock*` this never CLAIMS the lock —
109
+ * it is for a reader that only needs the holder's transition to be over before
110
+ * it looks at the directory the holder is rewriting.
111
+ *
112
+ * Returns immediately when the lock is free, when its marker names this very
113
+ * process (a caller waiting on its own lock would otherwise deadlock), or when
114
+ * the named owner is gone — a dead holder's transition is already over, however
115
+ * it ended.
116
+ */
117
+ export function awaitExclusiveLockRelease(path, timeoutMs) {
118
+ const deadline = Date.now() + timeoutMs;
119
+ let pollMs = POLL_MS;
120
+ for (;;) {
121
+ const token = observedMarkerToken(path);
122
+ if (token === null)
123
+ return;
124
+ if (Number(token.split('.', 1)[0]) === process.pid)
125
+ return;
126
+ if (!ownerIsAlive(token))
127
+ return;
128
+ const remaining = deadline - Date.now();
129
+ if (remaining <= 0)
130
+ return;
131
+ pause(Math.min(pollMs, remaining));
132
+ pollMs = nextPollMs(pollMs);
133
+ }
134
+ }
135
+ function tryAcquire(path, staleMs) {
136
+ const token = `${process.pid}.${randomUUID()}`;
137
+ try {
138
+ mkdirSync(path, { mode: 0o700 });
139
+ writeFileSync(markerPath(path, token), '', { flag: 'wx', mode: 0o600 });
140
+ return { path, token };
141
+ }
142
+ catch (error) {
143
+ if (error.code !== 'EEXIST')
144
+ throw error;
145
+ reclaimIfOwnerGone(path, staleMs);
146
+ return null;
147
+ }
148
+ }
149
+ /**
150
+ * Run a short filesystem transition under a token-checked, crash-reclaimable
151
+ * directory lock. A holder that dies releases immediately — the next contender
152
+ * sees a dead pid in the marker name — while a live holder is never stolen from,
153
+ * however long its operation runs.
154
+ */
155
+ export function withExclusiveDirectoryLock(path, operation, options = {}) {
156
+ const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
157
+ const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
158
+ let pollMs = POLL_MS;
159
+ let lock = tryAcquire(path, staleMs);
160
+ while (lock === null) {
161
+ if (Date.now() >= deadline) {
162
+ throw options.timeoutError?.() ?? new Error(`timed out waiting for the exclusive lock: ${path}`);
163
+ }
164
+ pause(Math.min(pollMs, Math.max(0, deadline - Date.now())));
165
+ pollMs = nextPollMs(pollMs);
166
+ lock = tryAcquire(path, staleMs);
167
+ }
168
+ try {
169
+ return operation();
170
+ }
171
+ finally {
172
+ if (existsSync(markerPath(lock.path, lock.token)))
173
+ rmSync(lock.path, { recursive: true, force: true });
174
+ }
175
+ }
176
+ /** Run an awaited operation under the same token-checked directory lock. */
177
+ export async function withExclusiveDirectoryLockAsync(path, operation, options = {}) {
178
+ const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
179
+ const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
180
+ let pollMs = POLL_MS;
181
+ let lock = tryAcquire(path, staleMs);
182
+ while (lock === null) {
183
+ if (Date.now() >= deadline) {
184
+ throw options.timeoutError?.() ?? new Error(`timed out waiting for the exclusive lock: ${path}`);
185
+ }
186
+ await pauseAsync(Math.min(pollMs, Math.max(0, deadline - Date.now())));
187
+ pollMs = nextPollMs(pollMs);
188
+ lock = tryAcquire(path, staleMs);
189
+ }
190
+ try {
191
+ return await operation();
192
+ }
193
+ finally {
194
+ if (existsSync(markerPath(lock.path, lock.token)))
195
+ rmSync(lock.path, { recursive: true, force: true });
196
+ }
197
+ }
@@ -0,0 +1,44 @@
1
+ /** `realpathSync`, tolerant of a path that doesn't exist or can't be resolved. */
2
+ export declare function realpathOrSelf(p: string): string;
3
+ /** Expand a leading `~` against `homeDir` (pi's own convention). Other forms
4
+ * — absolute, relative, `~user` — pass through as pi leaves them. */
5
+ export declare function expandTilde(p: string, homeDir?: string): string;
6
+ /** Collapse the home prefix to `~` so a path reads short. The inverse of
7
+ * `expandTilde`, and the ONE collapse every surface prints paths through. */
8
+ export declare function tildify(p: string, homeDir?: string): string;
9
+ export declare function ensureDir(dir: string): void;
10
+ export declare function writeJson(path: string, data: unknown): void;
11
+ export interface AtomicWriteOptions {
12
+ /** Force this mode regardless of any existing file's mode. Omitted: preserve the existing file's mode, or default a new file to `0o600`. */
13
+ mode?: number;
14
+ /** Require the parent to exist, rather than recreate a removed project root. */
15
+ createParent?: boolean;
16
+ }
17
+ export declare function atomicWriteJson(path: string, data: unknown, opts?: AtomicWriteOptions): void;
18
+ /** Force a file's written bytes to stable storage. */
19
+ export declare function syncFile(path: string): void;
20
+ /** Force a directory's entry changes to stable storage. */
21
+ export declare function syncDirectory(path: string): void;
22
+ /** Replace one file's contents in a single rename, so a concurrent reader — the
23
+ * tmux inbox watcher, or crtrd serving a ticket read — sees either the old
24
+ * bytes or the new ones and never a truncated file mid-write. */
25
+ export declare function atomicWriteText(path: string, content: string | Uint8Array, opts?: AtomicWriteOptions): void;
26
+ export declare function readJson<T = unknown>(path: string): T;
27
+ export declare function readJsonIfExists<T = unknown>(path: string): T | null;
28
+ /** Missing or corrupt reads as absent, so a crash artifact never throws. */
29
+ export declare function readJsonOrNull<T = unknown>(path: string): T | null;
30
+ export declare function readTextIfExists(path: string): string | null;
31
+ export declare function readText(path: string): string;
32
+ export declare function writeText(path: string, content: string): void;
33
+ export declare function isDir(path: string): boolean;
34
+ export declare function isSymlink(path: string): boolean;
35
+ export declare function pathExists(path: string): boolean;
36
+ export declare function listDirs(path: string): string[];
37
+ export declare function listEntries(path: string): string[];
38
+ export declare function removePath(path: string): void;
39
+ export declare function linkOrCopy(target: string, linkPath: string, opts?: {
40
+ noSymlink?: boolean;
41
+ }): 'symlink' | 'copy';
42
+ export declare function readSymlinkTarget(path: string): string | null;
43
+ export declare function walkFiles(root: string, predicate?: (name: string) => boolean, skipDir?: (name: string) => boolean): string[];
44
+ export declare function nowIso(): string;
@@ -0,0 +1,208 @@
1
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync, cpSync, readlinkSync, renameSync, chmodSync, realpathSync, closeSync, fsyncSync, openSync, } from 'node:fs';
2
+ import { dirname, join, relative, sep } from 'node:path';
3
+ import { homedir, platform } from 'node:os';
4
+ /** `realpathSync`, tolerant of a path that doesn't exist or can't be resolved. */
5
+ export function realpathOrSelf(p) {
6
+ try {
7
+ return realpathSync(p);
8
+ }
9
+ catch {
10
+ return p;
11
+ }
12
+ }
13
+ /** Expand a leading `~` against `homeDir` (pi's own convention). Other forms
14
+ * — absolute, relative, `~user` — pass through as pi leaves them. */
15
+ export function expandTilde(p, homeDir = homedir()) {
16
+ if (p === '~')
17
+ return homeDir;
18
+ if (p.startsWith('~/') || (platform() === 'win32' && p.startsWith('~\\')))
19
+ return join(homeDir, p.slice(2));
20
+ return p;
21
+ }
22
+ /** Collapse the home prefix to `~` so a path reads short. The inverse of
23
+ * `expandTilde`, and the ONE collapse every surface prints paths through. */
24
+ export function tildify(p, homeDir = homedir()) {
25
+ if (p === homeDir)
26
+ return '~';
27
+ if (p.startsWith(homeDir + sep))
28
+ return '~' + p.slice(homeDir.length);
29
+ return p;
30
+ }
31
+ export function ensureDir(dir) {
32
+ mkdirSync(dir, { recursive: true });
33
+ }
34
+ export function writeJson(path, data) {
35
+ ensureDir(dirname(path));
36
+ writeFileSync(path, JSON.stringify(data, null, 2) + '\n', 'utf8');
37
+ }
38
+ export function atomicWriteJson(path, data, opts) {
39
+ atomicWriteText(path, JSON.stringify(data, null, 2) + '\n', opts);
40
+ }
41
+ /** Force a file's written bytes to stable storage. */
42
+ export function syncFile(path) {
43
+ const fd = openSync(path, 'r');
44
+ try {
45
+ fsyncSync(fd);
46
+ }
47
+ finally {
48
+ closeSync(fd);
49
+ }
50
+ }
51
+ /** Force a directory's entry changes to stable storage. */
52
+ export function syncDirectory(path) {
53
+ const fd = openSync(path, 'r');
54
+ try {
55
+ fsyncSync(fd);
56
+ }
57
+ finally {
58
+ closeSync(fd);
59
+ }
60
+ }
61
+ /** Replace one file's contents in a single rename, so a concurrent reader — the
62
+ * tmux inbox watcher, or crtrd serving a ticket read — sees either the old
63
+ * bytes or the new ones and never a truncated file mid-write. */
64
+ export function atomicWriteText(path, content, opts) {
65
+ if (opts?.createParent !== false)
66
+ ensureDir(dirname(path));
67
+ const temp = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
68
+ const mode = opts?.mode ?? (existsSync(path) ? statSync(path).mode & 0o777 : 0o600);
69
+ try {
70
+ writeFileSync(temp, content, { mode });
71
+ chmodSync(temp, mode);
72
+ renameSync(temp, path);
73
+ }
74
+ finally {
75
+ if (existsSync(temp))
76
+ rmSync(temp, { force: true });
77
+ }
78
+ }
79
+ export function readJson(path) {
80
+ return JSON.parse(readFileSync(path, 'utf8'));
81
+ }
82
+ export function readJsonIfExists(path) {
83
+ try {
84
+ return readJson(path);
85
+ }
86
+ catch (error) {
87
+ if (error.code === 'ENOENT')
88
+ return null;
89
+ throw error;
90
+ }
91
+ }
92
+ /** Missing or corrupt reads as absent, so a crash artifact never throws. */
93
+ export function readJsonOrNull(path) {
94
+ try {
95
+ return JSON.parse(readFileSync(path, 'utf8'));
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ export function readTextIfExists(path) {
102
+ if (!existsSync(path))
103
+ return null;
104
+ return readFileSync(path, 'utf8');
105
+ }
106
+ export function readText(path) {
107
+ return readFileSync(path, 'utf8');
108
+ }
109
+ export function writeText(path, content) {
110
+ ensureDir(dirname(path));
111
+ writeFileSync(path, content, 'utf8');
112
+ }
113
+ export function isDir(path) {
114
+ try {
115
+ return statSync(path).isDirectory();
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
121
+ export function isSymlink(path) {
122
+ try {
123
+ return lstatSync(path).isSymbolicLink();
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ }
129
+ export function pathExists(path) {
130
+ return existsSync(path);
131
+ }
132
+ export function listDirs(path) {
133
+ if (!existsSync(path))
134
+ return [];
135
+ return readdirSync(path, { withFileTypes: true })
136
+ .filter((d) => d.isDirectory() || d.isSymbolicLink())
137
+ .map((d) => d.name);
138
+ }
139
+ export function listEntries(path) {
140
+ if (!existsSync(path))
141
+ return [];
142
+ return readdirSync(path);
143
+ }
144
+ export function removePath(path) {
145
+ if (!existsSync(path) && !isSymlink(path))
146
+ return;
147
+ rmSync(path, { recursive: true, force: true });
148
+ }
149
+ export function linkOrCopy(target, linkPath, opts = {}) {
150
+ ensureDir(dirname(linkPath));
151
+ removePath(linkPath);
152
+ const isWindows = platform() === 'win32';
153
+ if (!opts.noSymlink && !isWindows) {
154
+ // The kernel resolves a relative link against the link's *physical*
155
+ // directory, so both ends must be realpaths: computing `..` hops from a
156
+ // lexical path whose ancestor is itself a symlink (macOS /tmp →
157
+ // /private/tmp) walks up the wrong chain and silently creates a dangling
158
+ // link, since symlinkSync never checks the target.
159
+ const rel = relative(realpathOrSelf(dirname(linkPath)), realpathOrSelf(target));
160
+ try {
161
+ symlinkSync(rel, linkPath, isDir(target) ? 'dir' : 'file');
162
+ return 'symlink';
163
+ }
164
+ catch {
165
+ cpSync(target, linkPath, { recursive: true });
166
+ return 'copy';
167
+ }
168
+ }
169
+ cpSync(target, linkPath, { recursive: true });
170
+ return 'copy';
171
+ }
172
+ export function readSymlinkTarget(path) {
173
+ try {
174
+ return readlinkSync(path);
175
+ }
176
+ catch {
177
+ return null;
178
+ }
179
+ }
180
+ export function walkFiles(root, predicate = () => true, skipDir = () => false) {
181
+ const out = [];
182
+ if (!existsSync(root))
183
+ return out;
184
+ const stack = [root];
185
+ while (stack.length) {
186
+ const dir = stack.pop();
187
+ let entries;
188
+ try {
189
+ entries = readdirSync(dir, { withFileTypes: true });
190
+ }
191
+ catch {
192
+ continue;
193
+ }
194
+ for (const e of entries) {
195
+ const full = join(dir, e.name);
196
+ if (e.isDirectory()) {
197
+ if (!skipDir(e.name))
198
+ stack.push(full);
199
+ }
200
+ else if (e.isFile() && predicate(e.name))
201
+ out.push(full);
202
+ }
203
+ }
204
+ return out;
205
+ }
206
+ export function nowIso() {
207
+ return new Date().toISOString();
208
+ }