@stonyx/utils 0.2.3-alpha.24 → 0.2.3-alpha.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -75,10 +75,12 @@ Creates a file at the given path.
75
75
 
76
76
  #### `updateFile(filePath, data, options={})`
77
77
 
78
- Updates a file atomically by writing to a temporary file first.
78
+ Updates a file atomically by writing to a sibling swap file first, then renaming it over the target. Throws if the file does not exist.
79
79
 
80
80
  * `options.json` — boolean, serialize as JSON.
81
81
 
82
+ **Concurrency: last writer wins.** Concurrent `updateFile` calls on the same path are safe — each uses a swap file unique to its process and call, so neither caller can throw `ENOENT` or persist the other's bytes — but they are not serialized. The surviving value is whichever caller renames last.
83
+
82
84
  #### `copyFile(sourcePath, targetPath, options={})`
83
85
 
84
86
  Copies a file from source to target.
@@ -245,6 +247,8 @@ Prompts the user with `(y/N)` and resolves to `true` only if the answer is `"y"`
245
247
  * `options.input` — Readable stream (default: `process.stdin`).
246
248
  * `options.output` — Writable stream (default: `process.stdout`).
247
249
 
250
+ **TTY required:** Rejects with an error if `process.stdin` is not a TTY and no custom `input` stream is provided. For headless/container deployments, use `autoMigrate` config or provide a custom input stream.
251
+
248
252
  #### `prompt(question, options={})`
249
253
 
250
254
  Prompts the user with a question and resolves to the trimmed input string.
@@ -252,6 +256,8 @@ Prompts the user with a question and resolves to the trimmed input string.
252
256
  * `options.input` — Readable stream (default: `process.stdin`).
253
257
  * `options.output` — Writable stream (default: `process.stdout`).
254
258
 
259
+ **TTY required:** Rejects with an error if `process.stdin` is not a TTY and no custom `input` stream is provided.
260
+
255
261
  ```js
256
262
  import { confirm, prompt } from '@stonyx/utils/prompt';
257
263
 
package/dist/file.d.ts CHANGED
@@ -23,6 +23,39 @@ interface FileImportMeta {
23
23
  path: string;
24
24
  }
25
25
  export declare function createFile(filePath: string, data: string | Record<string, unknown>, options?: FileOptions): Promise<void>;
26
+ /**
27
+ * Atomically replace the contents of an existing file.
28
+ *
29
+ * Writes to a swap file that is a **sibling** of the target — so `rename` stays
30
+ * on one filesystem and therefore stays atomic — then renames it over the
31
+ * target. The swap name carries `process.pid` and a short random token rather
32
+ * than a timestamp: a whole-second token collided between concurrent callers,
33
+ * which made one caller throw `ENOENT` and the other silently persist bytes it
34
+ * had not written (abofs/stonyx-utils#44).
35
+ *
36
+ * Concurrency contract: **last writer wins**. Unique swap names remove the
37
+ * `ENOENT` and the byte-level clobber, but overlapping calls on one path still
38
+ * race on which value lands last. `updateFile` does not serialize, and callers
39
+ * needing a serialization guarantee must supply their own — an in-process queue
40
+ * would not provide one across processes anyway.
41
+ *
42
+ * The target's mode is read before the write and reapplied to the swap file, so
43
+ * the rename does not widen a deliberately restrictive file (a `0600` database
44
+ * would otherwise become `0666 & ~umask`). Other inode-bound metadata — ACLs,
45
+ * extended attributes, hard links, ownership — is *not* carried across; the
46
+ * target gets a new inode by construction.
47
+ *
48
+ * `rename` is atomic against concurrent *readers*, not against power loss:
49
+ * there is no `fsync`, so this is namespace atomicity, not crash durability.
50
+ * If the target is a symlink it is replaced by a regular file — the link's
51
+ * destination is never written through.
52
+ *
53
+ * Swap files are cleaned up on every failure path this function controls, but
54
+ * a process killed between the write and the rename leaves a permanently
55
+ * distinct `<path>.temp-<pid>-<token>` sibling that nothing reclaims. No
56
+ * sweeper ships with this module; `*.temp-*` siblings of a target are safe for
57
+ * a consumer to delete.
58
+ */
26
59
  export declare function updateFile(filePath: string, data: string | Record<string, unknown>, options?: FileOptions): Promise<void>;
27
60
  interface CopyFileOptions {
28
61
  overwrite?: boolean;
package/dist/file.js CHANGED
@@ -1,9 +1,9 @@
1
- import { getTimestamp } from './date.js';
2
1
  import { kebabCaseToCamelCase } from './string.js';
3
2
  import { objToJson } from './object.js';
4
3
  import fs from 'fs';
5
4
  import { promises as fsp } from 'fs';
6
5
  import path from 'path';
6
+ import { randomUUID } from 'crypto';
7
7
  function isNodeError(error) {
8
8
  return error instanceof Error && 'code' in error;
9
9
  }
@@ -17,12 +17,72 @@ export async function createFile(filePath, data, options = {}) {
17
17
  throw error instanceof Error ? error : new Error(String(error));
18
18
  }
19
19
  }
20
+ /**
21
+ * Atomically replace the contents of an existing file.
22
+ *
23
+ * Writes to a swap file that is a **sibling** of the target — so `rename` stays
24
+ * on one filesystem and therefore stays atomic — then renames it over the
25
+ * target. The swap name carries `process.pid` and a short random token rather
26
+ * than a timestamp: a whole-second token collided between concurrent callers,
27
+ * which made one caller throw `ENOENT` and the other silently persist bytes it
28
+ * had not written (abofs/stonyx-utils#44).
29
+ *
30
+ * Concurrency contract: **last writer wins**. Unique swap names remove the
31
+ * `ENOENT` and the byte-level clobber, but overlapping calls on one path still
32
+ * race on which value lands last. `updateFile` does not serialize, and callers
33
+ * needing a serialization guarantee must supply their own — an in-process queue
34
+ * would not provide one across processes anyway.
35
+ *
36
+ * The target's mode is read before the write and reapplied to the swap file, so
37
+ * the rename does not widen a deliberately restrictive file (a `0600` database
38
+ * would otherwise become `0666 & ~umask`). Other inode-bound metadata — ACLs,
39
+ * extended attributes, hard links, ownership — is *not* carried across; the
40
+ * target gets a new inode by construction.
41
+ *
42
+ * `rename` is atomic against concurrent *readers*, not against power loss:
43
+ * there is no `fsync`, so this is namespace atomicity, not crash durability.
44
+ * If the target is a symlink it is replaced by a regular file — the link's
45
+ * destination is never written through.
46
+ *
47
+ * Swap files are cleaned up on every failure path this function controls, but
48
+ * a process killed between the write and the rename leaves a permanently
49
+ * distinct `<path>.temp-<pid>-<token>` sibling that nothing reclaims. No
50
+ * sweeper ships with this module; `*.temp-*` siblings of a target are safe for
51
+ * a consumer to delete.
52
+ */
20
53
  export async function updateFile(filePath, data, options = {}) {
21
54
  try {
55
+ filePath = path.resolve(filePath);
22
56
  await fsp.access(filePath);
23
- const swapFile = `${filePath}.temp-${getTimestamp()}`;
24
- await fsp.writeFile(swapFile, options.json ? objToJson(data) : String(data), 'utf8');
25
- await fsp.rename(swapFile, filePath);
57
+ // The swap file becomes the target's inode, so it has to carry the target's
58
+ // permission bits or a routine save silently widens them.
59
+ const { mode } = await fsp.stat(filePath);
60
+ const targetMode = mode & 0o7777;
61
+ // pid + random token, never a timestamp: the swap name is a uniqueness
62
+ // token, and whole seconds cannot discriminate between concurrent callers.
63
+ // Truncated to 8 hex chars deliberately — a full 36-char UUID pushed the
64
+ // name overhead to 48 characters and made ~210-character basenames fail
65
+ // ENAMETOOLONG on a 255-byte NAME_MAX. `wx` below makes any residual
66
+ // collision loud, so the shortened token costs no safety.
67
+ const swapFile = `${filePath}.temp-${process.pid}-${randomUUID().slice(0, 8)}`;
68
+ try {
69
+ // `wx` turns any residual name collision into a loud EEXIST rather than a
70
+ // silent overwrite of another caller's swap bytes. `mode` here is masked
71
+ // by the umask, so it only narrows — the chmod below sets the exact bits.
72
+ await fsp.writeFile(swapFile, options.json ? objToJson(data) : String(data), { encoding: 'utf8', flag: 'wx', mode: targetMode });
73
+ await fsp.chmod(swapFile, targetMode);
74
+ await fsp.rename(swapFile, filePath);
75
+ }
76
+ catch (swapError) {
77
+ // Leave no orphan behind, and never let the cleanup mask the real error.
78
+ // EEXIST is the one code that must NOT be cleaned up: it means this call
79
+ // did not create the path, so the file belongs to another writer and
80
+ // unlinking it would reintroduce #44.
81
+ if (!(isNodeError(swapError) && swapError.code === 'EEXIST')) {
82
+ await fsp.unlink(swapFile).catch(() => { });
83
+ }
84
+ throw swapError;
85
+ }
26
86
  }
27
87
  catch (error) {
28
88
  throw error instanceof Error ? error : new Error(String(error));
package/dist/prompt.js CHANGED
@@ -1,5 +1,9 @@
1
1
  import { createInterface } from 'readline';
2
2
  export function confirm(question, { input, output } = {}) {
3
+ if (!input && !process.stdin.isTTY) {
4
+ return Promise.reject(new Error('Interactive confirm() requires a TTY on stdin. ' +
5
+ 'For headless/container deployments, use the autoMigrate config option instead.'));
6
+ }
3
7
  const rl = createInterface({
4
8
  input: input ?? process.stdin,
5
9
  output: output ?? process.stdout,
@@ -12,6 +16,10 @@ export function confirm(question, { input, output } = {}) {
12
16
  });
13
17
  }
14
18
  export function prompt(question, { input, output } = {}) {
19
+ if (!input && !process.stdin.isTTY) {
20
+ return Promise.reject(new Error('Interactive prompt() requires a TTY on stdin. ' +
21
+ 'For headless/container deployments, configure non-interactive alternatives instead.'));
22
+ }
15
23
  const rl = createInterface({
16
24
  input: input ?? process.stdin,
17
25
  output: output ?? process.stdout,
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "keywords": [
4
4
  "stonyx-module"
5
5
  ],
6
- "version": "0.2.3-alpha.24",
6
+ "version": "0.2.3-alpha.26",
7
7
  "description": "Utils module for Stonyx Framework",
8
8
  "repository": {
9
9
  "type": "git",