@stonyx/utils 0.2.3-alpha.3 → 0.2.3-alpha.30

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
@@ -1,6 +1,10 @@
1
+ [![CI](https://github.com/abofs/stonyx-utils/actions/workflows/ci.yml/badge.svg)](https://github.com/abofs/stonyx-utils/actions/workflows/ci.yml)
2
+ [![npm version](https://img.shields.io/npm/v/@stonyx/utils.svg)](https://www.npmjs.com/package/@stonyx/utils)
3
+ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
4
+
1
5
  # stonyx-utils
2
6
 
3
- Utilities module for the Stonyx Framework. Provides helpers for files, objects, strings, dates, and promises.
7
+ Utilities module for the Stonyx Framework. Provides helpers for files, objects, strings, dates, promises, and prompts.
4
8
 
5
9
  ---
6
10
 
@@ -71,10 +75,16 @@ Creates a file at the given path.
71
75
 
72
76
  #### `updateFile(filePath, data, options={})`
73
77
 
74
- 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.
75
79
 
76
80
  * `options.json` — boolean, serialize as JSON.
77
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's swap file can collide with the other's. The rename-time `ENOENT` and the silent cross-caller byte clobber of [#44](https://github.com/abofs/stonyx-utils/issues/44) are both gone. (`updateFile` still throws `ENOENT` from its own precondition when the target does not exist — that is the documented contract above, and it is unrelated to concurrency.) Calls are not serialized: the surviving value is whichever caller renames last.
83
+
84
+ **File mode is preserved; other inode metadata is not.** The swap file is created with the target's permission bits and `chmod`ed to them before the rename, so a `0600` file stays `0600`. ACLs, extended attributes, hard links and ownership are *not* carried across — the target necessarily gets a new inode.
85
+
86
+ **Swap files after abnormal termination.** Cleanup runs on every failure path `updateFile` controls, but a process killed between the write and the rename leaves a `<path>.temp-<pid>-<token>` sibling behind, and no module ships a sweeper. This is a deliberate trade against the previous whole-second swap name, which was reused — and reuse is exactly the collision that caused #44, so uniqueness has to win. Be precise about what that costs: the old name only ever collapsed two orphans that were abandoned within the *same whole second*, so at any ordinary crash cadence it reclaimed nothing either (measured: five crashes 1200 ms apart leave five orphans under both names). The accumulation uniqueness genuinely adds is confined to a sub-second crash loop. Consumers that persist into a directory they scan, sync or commit should sweep or ignore `*.temp-*` siblings of their targets; an app that runs `git add` over its data directory will otherwise commit one.
87
+
78
88
  #### `copyFile(sourcePath, targetPath, options={})`
79
89
 
80
90
  Copies a file from source to target.
@@ -241,6 +251,8 @@ Prompts the user with `(y/N)` and resolves to `true` only if the answer is `"y"`
241
251
  * `options.input` — Readable stream (default: `process.stdin`).
242
252
  * `options.output` — Writable stream (default: `process.stdout`).
243
253
 
254
+ **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.
255
+
244
256
  #### `prompt(question, options={})`
245
257
 
246
258
  Prompts the user with a question and resolves to the trimmed input string.
@@ -248,6 +260,8 @@ Prompts the user with a question and resolves to the trimmed input string.
248
260
  * `options.input` — Readable stream (default: `process.stdin`).
249
261
  * `options.output` — Writable stream (default: `process.stdout`).
250
262
 
263
+ **TTY required:** Rejects with an error if `process.stdin` is not a TTY and no custom `input` stream is provided.
264
+
251
265
  ```js
252
266
  import { confirm, prompt } from '@stonyx/utils/prompt';
253
267
 
package/dist/date.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function getTimestamp(dateObject?: Date | null): number;
package/dist/date.js ADDED
@@ -0,0 +1,4 @@
1
+ export function getTimestamp(dateObject = null) {
2
+ const ts = dateObject ? dateObject.getTime() : Date.now();
3
+ return Math.floor(ts / 1000);
4
+ }
package/dist/file.d.ts ADDED
@@ -0,0 +1,79 @@
1
+ import fs from 'fs';
2
+ interface FileOptions {
3
+ json?: boolean;
4
+ }
5
+ interface ReadFileOptions extends FileOptions {
6
+ missingFileCallback?: (filePath: string) => string | Record<string, unknown>;
7
+ encoding?: BufferEncoding | null;
8
+ }
9
+ interface DeleteFileOptions {
10
+ ignoreAccessFailure?: boolean;
11
+ }
12
+ interface ForEachFileImportOptions {
13
+ ignoreAccessFailure?: boolean;
14
+ recursive?: boolean;
15
+ recursiveNaming?: boolean;
16
+ rawName?: boolean;
17
+ namePrefix?: string;
18
+ fullExport?: boolean;
19
+ }
20
+ interface FileImportMeta {
21
+ name: string;
22
+ stats: import('fs').Stats;
23
+ path: string;
24
+ }
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
+ */
59
+ export declare function updateFile(filePath: string, data: string | Record<string, unknown>, options?: FileOptions): Promise<void>;
60
+ interface CopyFileOptions {
61
+ overwrite?: boolean;
62
+ }
63
+ export declare function copyFile(sourcePath: string, targetPath: string, options?: CopyFileOptions): Promise<boolean>;
64
+ export declare function readFile(filePath: string, options: ReadFileOptions & {
65
+ encoding: null;
66
+ }): Promise<Buffer>;
67
+ export declare function readFile(filePath: string, options: ReadFileOptions & {
68
+ json: true;
69
+ }): Promise<Record<string, unknown>>;
70
+ export declare function readFile(filePath: string, options?: ReadFileOptions): Promise<string>;
71
+ export declare function deleteFile(filePath: string, options?: DeleteFileOptions): Promise<void>;
72
+ export declare function deleteFileSync(filePath: string, options?: DeleteFileOptions): void;
73
+ export declare function deleteDirectory(dir: string): Promise<void>;
74
+ export declare function createDirectory(dir: string): Promise<void>;
75
+ export declare function createReadStream(filePath: string, options?: Parameters<typeof fs.createReadStream>[1]): fs.ReadStream;
76
+ export declare function createWriteStream(filePath: string, options?: Parameters<typeof fs.createWriteStream>[1]): fs.WriteStream;
77
+ export declare function forEachFileImport(dir: string, callback: (output: unknown, meta: FileImportMeta) => void | Promise<void>, options?: ForEachFileImportOptions): Promise<void>;
78
+ export declare function fileExists(filePath: string): Promise<boolean>;
79
+ export {};
package/dist/file.js ADDED
@@ -0,0 +1,234 @@
1
+ import { kebabCaseToCamelCase } from './string.js';
2
+ import { objToJson } from './object.js';
3
+ import fs from 'fs';
4
+ import { promises as fsp } from 'fs';
5
+ import path from 'path';
6
+ import { randomBytes } from 'crypto';
7
+ function isNodeError(error) {
8
+ return error instanceof Error && 'code' in error;
9
+ }
10
+ export async function createFile(filePath, data, options = {}) {
11
+ try {
12
+ filePath = path.resolve(filePath);
13
+ await createDirectory(path.dirname(filePath));
14
+ await fsp.writeFile(filePath, options.json ? objToJson(data) : String(data), 'utf8');
15
+ }
16
+ catch (error) {
17
+ throw error instanceof Error ? error : new Error(String(error));
18
+ }
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
+ */
53
+ export async function updateFile(filePath, data, options = {}) {
54
+ try {
55
+ filePath = path.resolve(filePath);
56
+ await fsp.access(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
+ // The token is deliberately short — a full 36-char UUID pushed the name
64
+ // overhead to 48 characters and made ~210-character basenames fail
65
+ // ENAMETOOLONG on a 255-byte NAME_MAX. 6 CSPRNG bytes as base64url is 8
66
+ // characters carrying 48 bits, where the first 8 hex characters of a UUID
67
+ // would be the same width but only 32 bits; at 32 bits a 1000-sample
68
+ // distinctness check collides about once in 8,600 runs. `wx` below makes
69
+ // any residual collision loud rather than corrupting.
70
+ const swapFile = `${filePath}.temp-${process.pid}-${randomBytes(6).toString('base64url')}`;
71
+ // Which stage failed, so the catch can tell "the open collided" from "the
72
+ // open succeeded and something later failed" — see the catch.
73
+ let wrote = false;
74
+ try {
75
+ // `wx` turns any residual name collision into a loud EEXIST rather than a
76
+ // silent overwrite of another caller's swap bytes. `mode` here is masked
77
+ // by the umask, so it only narrows — the chmod below sets the exact bits.
78
+ await fsp.writeFile(swapFile, options.json ? objToJson(data) : String(data), { encoding: 'utf8', flag: 'wx', mode: targetMode });
79
+ wrote = true;
80
+ await fsp.chmod(swapFile, targetMode);
81
+ await fsp.rename(swapFile, filePath);
82
+ }
83
+ catch (swapError) {
84
+ // Leave no orphan behind, and never let the cleanup mask the real error.
85
+ //
86
+ // Exactly one case is not ours to remove: the `wx` open lost a race, so
87
+ // the swap file is another writer's and unlinking it would reintroduce
88
+ // #44. That is EEXIST *from the write stage* — both halves matter.
89
+ // - Code alone is not enough: `rename` surfaces EEXIST on Windows and
90
+ // some network filesystems, and that file is one we created.
91
+ // - Stage alone is not enough: a write that fails after the open
92
+ // succeeded (ENOSPC, EDQUOT, EIO) also lands here with `wrote` false,
93
+ // and skipping it is the orphaned partial payload this PR exists to
94
+ // close.
95
+ const lostTheOpenRace = !wrote && isNodeError(swapError) && swapError.code === 'EEXIST';
96
+ if (!lostTheOpenRace) {
97
+ await fsp.unlink(swapFile).catch(() => { });
98
+ }
99
+ throw swapError;
100
+ }
101
+ }
102
+ catch (error) {
103
+ throw error instanceof Error ? error : new Error(String(error));
104
+ }
105
+ }
106
+ export async function copyFile(sourcePath, targetPath, options = {}) {
107
+ try {
108
+ sourcePath = path.resolve(sourcePath);
109
+ targetPath = path.resolve(targetPath);
110
+ await fsp.access(sourcePath);
111
+ }
112
+ catch (error) {
113
+ throw error instanceof Error ? error : new Error(String(error));
114
+ }
115
+ try {
116
+ await fsp.access(targetPath);
117
+ if (!options.overwrite)
118
+ return false;
119
+ }
120
+ catch (error) {
121
+ if (isNodeError(error) && error.code === 'ENOENT') { /* file doesn't exist — proceed with copy */ }
122
+ else
123
+ throw error;
124
+ }
125
+ try {
126
+ await fsp.copyFile(sourcePath, targetPath);
127
+ }
128
+ catch (error) {
129
+ throw error instanceof Error ? error : new Error(String(error));
130
+ }
131
+ return true;
132
+ }
133
+ export async function readFile(filePath, options = {}) {
134
+ try {
135
+ filePath = path.resolve(filePath);
136
+ await fsp.access(filePath);
137
+ const encoding = options.encoding === undefined ? 'utf8' : options.encoding;
138
+ const fileData = await fsp.readFile(filePath, { encoding: encoding });
139
+ if (encoding === null)
140
+ return fileData;
141
+ if (options.json)
142
+ return JSON.parse(fileData);
143
+ return fileData;
144
+ }
145
+ catch (error) {
146
+ const { missingFileCallback } = options;
147
+ if (isNodeError(error) && error.code === 'ENOENT' && missingFileCallback) {
148
+ return missingFileCallback(filePath);
149
+ }
150
+ throw error instanceof Error ? error : new Error(String(error));
151
+ }
152
+ }
153
+ export async function deleteFile(filePath, options) {
154
+ try {
155
+ filePath = path.resolve(filePath);
156
+ await fsp.access(filePath);
157
+ }
158
+ catch (error) {
159
+ if (options?.ignoreAccessFailure)
160
+ return;
161
+ throw error;
162
+ }
163
+ await fsp.unlink(filePath);
164
+ }
165
+ export function deleteFileSync(filePath, options) {
166
+ try {
167
+ filePath = path.resolve(filePath);
168
+ fs.accessSync(filePath);
169
+ }
170
+ catch (error) {
171
+ if (options?.ignoreAccessFailure)
172
+ return;
173
+ throw error;
174
+ }
175
+ fs.unlinkSync(filePath);
176
+ }
177
+ export async function deleteDirectory(dir) {
178
+ await fsp.rm(dir, { recursive: true, force: true });
179
+ }
180
+ export async function createDirectory(dir) {
181
+ await fsp.mkdir(dir, { recursive: true });
182
+ }
183
+ export function createReadStream(filePath, options) {
184
+ return fs.createReadStream(path.resolve(filePath), options);
185
+ }
186
+ export function createWriteStream(filePath, options) {
187
+ return fs.createWriteStream(path.resolve(filePath), options);
188
+ }
189
+ export async function forEachFileImport(dir, callback, options = {}) {
190
+ if (typeof callback !== 'function')
191
+ throw new Error('Callback must be valid function');
192
+ try {
193
+ await fsp.access(dir);
194
+ }
195
+ catch (error) {
196
+ if (!options.ignoreAccessFailure)
197
+ throw new Error(`Unable to access directory: ${dir}`);
198
+ return;
199
+ }
200
+ const files = await fsp.readdir(dir);
201
+ for (const file of files) {
202
+ const filePath = path.join(dir, file);
203
+ const stats = await fsp.stat(filePath);
204
+ if (options.recursive && stats.isDirectory()) {
205
+ const newOptions = { ...options };
206
+ if (options.recursiveNaming) {
207
+ const pathPrefix = options.rawName ? file : `${kebabCaseToCamelCase(file)}`;
208
+ newOptions.namePrefix = options.namePrefix ? `${options.namePrefix}${pathPrefix}/` : `${pathPrefix}/`;
209
+ }
210
+ await forEachFileImport(filePath, callback, newOptions);
211
+ continue;
212
+ }
213
+ if (!stats.isFile() || !(file.endsWith('.js') || file.endsWith('.ts')))
214
+ continue;
215
+ const prefix = process.platform === 'win32' ? 'file://' : '';
216
+ const rawName = file.replace(/\.(js|ts)$/, '');
217
+ let name = options.rawName ? rawName : kebabCaseToCamelCase(rawName);
218
+ if (options.namePrefix)
219
+ name = `${options.namePrefix}${name}`;
220
+ const exported = await import(prefix + path.resolve(filePath));
221
+ const output = !options.fullExport ? exported.default : exported;
222
+ callback(output, { name, stats, path: filePath });
223
+ }
224
+ }
225
+ export async function fileExists(filePath) {
226
+ try {
227
+ filePath = path.resolve(filePath);
228
+ await fsp.access(filePath);
229
+ return true;
230
+ }
231
+ catch {
232
+ return false;
233
+ }
234
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Generic fuzzy string matching for cross-source reconciliation.
3
+ * Handles Unicode normalization, stop-word filtering, and word-set similarity scoring.
4
+ */
5
+ export interface FuzzyMatchOptions {
6
+ stopWords?: string[];
7
+ delimiter?: string;
8
+ threshold?: number;
9
+ }
10
+ export interface FuzzyMatchResult<T extends {
11
+ name: string;
12
+ }> {
13
+ entry: T;
14
+ score: number;
15
+ }
16
+ export default class FuzzyMatch {
17
+ stopWords: string[];
18
+ delimiter: string;
19
+ threshold: number;
20
+ constructor(options?: FuzzyMatchOptions);
21
+ normalize(name: string): string;
22
+ similarity(nameA: string, nameB: string): number;
23
+ findBestMatch<T extends {
24
+ name: string;
25
+ }>(nameA: string, nameB: string, entries: T[], threshold?: number): FuzzyMatchResult<T> | null;
26
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Generic fuzzy string matching for cross-source reconciliation.
3
+ * Handles Unicode normalization, stop-word filtering, and word-set similarity scoring.
4
+ */
5
+ function normalizeString(name, stopWords = []) {
6
+ let result = name
7
+ .normalize('NFD')
8
+ .replace(/[\u0300-\u036f]/g, '')
9
+ .toLowerCase()
10
+ .replace(/[^a-z0-9\s]/g, ' ');
11
+ if (stopWords.length) {
12
+ const pattern = new RegExp(`\\b(${stopWords.join('|')})\\b`, 'g');
13
+ result = result.replace(pattern, '');
14
+ }
15
+ return result.replace(/\s+/g, ' ').trim();
16
+ }
17
+ function wordSet(normalized) {
18
+ return new Set(normalized.split(' ').filter(w => w.length > 1));
19
+ }
20
+ export default class FuzzyMatch {
21
+ stopWords;
22
+ delimiter;
23
+ threshold;
24
+ constructor(options = {}) {
25
+ this.stopWords = options.stopWords || [];
26
+ this.delimiter = options.delimiter || '\u00B7';
27
+ this.threshold = options.threshold || 0.35;
28
+ }
29
+ normalize(name) {
30
+ return normalizeString(name, this.stopWords);
31
+ }
32
+ similarity(nameA, nameB) {
33
+ const aN = this.normalize(nameA);
34
+ const sN = this.normalize(nameB);
35
+ if (!aN || !sN)
36
+ return 0;
37
+ if (aN === sN)
38
+ return 1.0;
39
+ if (aN.includes(sN) || sN.includes(aN))
40
+ return 0.9;
41
+ const aWords = wordSet(aN);
42
+ const sWords = wordSet(sN);
43
+ if (aWords.size === 0 || sWords.size === 0)
44
+ return 0;
45
+ let overlap = 0;
46
+ for (const w of aWords) {
47
+ if (sWords.has(w)) {
48
+ overlap++;
49
+ }
50
+ else {
51
+ for (const sw of sWords) {
52
+ if (sw.startsWith(w) || w.startsWith(sw)) {
53
+ overlap += 0.7;
54
+ break;
55
+ }
56
+ }
57
+ }
58
+ }
59
+ return overlap / Math.max(aWords.size, sWords.size);
60
+ }
61
+ findBestMatch(nameA, nameB, entries, threshold) {
62
+ const minScore = threshold ?? this.threshold;
63
+ let bestMatch = null;
64
+ let bestScore = 0;
65
+ for (const entry of entries) {
66
+ const parts = entry.name.split(this.delimiter);
67
+ if (parts.length !== 2)
68
+ continue;
69
+ const [entryA, entryB] = parts;
70
+ const normalScore = (this.similarity(nameA, entryA) + this.similarity(nameB, entryB)) / 2;
71
+ const reversedScore = (this.similarity(nameA, entryB) + this.similarity(nameB, entryA)) / 2;
72
+ const score = Math.max(normalScore, reversedScore);
73
+ if (score > bestScore) {
74
+ bestScore = score;
75
+ bestMatch = entry;
76
+ }
77
+ }
78
+ return bestScore >= minScore && bestMatch ? { entry: bestMatch, score: bestScore } : null;
79
+ }
80
+ }
@@ -0,0 +1,14 @@
1
+ export declare function deepCopy<T>(obj: T): T;
2
+ type JsonValue = string | number | boolean | null | JsonValue[] | {
3
+ [key: string]: JsonValue;
4
+ };
5
+ export declare function objToJson(obj: JsonValue | Record<string, unknown>, format?: string | number): string;
6
+ export declare function makeArray<T>(obj: T | T[]): T[];
7
+ interface MergeOptions {
8
+ ignoreNewKeys?: boolean;
9
+ }
10
+ export declare function mergeObject(obj1: Record<string, unknown>, obj2: Record<string, unknown>, options?: MergeOptions): Record<string, unknown>;
11
+ export declare function get(obj: Record<string, unknown>, path: string): unknown;
12
+ export declare function get(obj: unknown, path?: unknown): undefined;
13
+ export declare function getOrSet<K, V>(map: Map<K, V>, key: K, defaultValue: V | (() => V)): V;
14
+ export {};
package/dist/object.js ADDED
@@ -0,0 +1,60 @@
1
+ export function deepCopy(obj) {
2
+ return JSON.parse(JSON.stringify(obj));
3
+ }
4
+ export function objToJson(obj, format = '\t') {
5
+ return JSON.stringify(obj, null, format);
6
+ }
7
+ export function makeArray(obj) {
8
+ return Array.isArray(obj) ? obj : [obj];
9
+ }
10
+ function cloneShallow(value) {
11
+ if (Array.isArray(value))
12
+ return value.slice();
13
+ if (value && typeof value === 'object')
14
+ return { ...value };
15
+ return value;
16
+ }
17
+ export function mergeObject(obj1, obj2, options = {}) {
18
+ if (Array.isArray(obj1) || Array.isArray(obj2))
19
+ throw new Error('Cannot merge arrays.');
20
+ if (obj1 === null || typeof obj1 !== 'object')
21
+ return cloneShallow(obj2);
22
+ if (obj2 === null || typeof obj2 !== 'object')
23
+ return cloneShallow(obj1);
24
+ const result = {};
25
+ for (const key of Object.keys(obj1))
26
+ result[key] = cloneShallow(obj1[key]);
27
+ for (const key of Object.keys(obj2)) {
28
+ if (options.ignoreNewKeys && !(key in obj1))
29
+ continue;
30
+ const val1 = obj1[key];
31
+ const val2 = obj2[key];
32
+ const shouldMerge = val1 && val2 && typeof val1 === 'object' && typeof val2 === 'object' && !Array.isArray(val1) && !Array.isArray(val2);
33
+ result[key] = shouldMerge ? mergeObject(val1, val2, options) : cloneShallow(val2);
34
+ }
35
+ return result;
36
+ }
37
+ export function get(obj, path) {
38
+ if (arguments.length !== 2)
39
+ return console.error('Get must be called with two arguments; an object and a property key.');
40
+ if (!obj)
41
+ return console.error(`Cannot call get with '${path}' on an undefined object.`);
42
+ if (typeof path !== 'string')
43
+ return console.error('The path provided to get must be a string.');
44
+ let current = obj;
45
+ for (const key of path.split('.')) {
46
+ if (current[key] === undefined)
47
+ return;
48
+ current = current[key];
49
+ }
50
+ return current;
51
+ }
52
+ export function getOrSet(map, key, defaultValue) {
53
+ if (!(map instanceof Map))
54
+ throw new Error('First argument to getOrSet must be a Map.');
55
+ if (!map.has(key)) {
56
+ const value = typeof defaultValue === 'function' ? defaultValue() : defaultValue;
57
+ map.set(key, value);
58
+ }
59
+ return map.get(key);
60
+ }
@@ -0,0 +1 @@
1
+ export default function pluralize(word: string): string;
@@ -0,0 +1,87 @@
1
+ // --- Irregular nouns ---
2
+ const irregular = {
3
+ person: 'people',
4
+ man: 'men',
5
+ woman: 'women',
6
+ child: 'children',
7
+ tooth: 'teeth',
8
+ foot: 'feet',
9
+ mouse: 'mice',
10
+ goose: 'geese',
11
+ ox: 'oxen',
12
+ cactus: 'cacti',
13
+ nucleus: 'nuclei',
14
+ syllabus: 'syllabi',
15
+ focus: 'foci',
16
+ fungus: 'fungi',
17
+ appendix: 'appendices',
18
+ index: 'indices',
19
+ criterion: 'criteria',
20
+ phenomenon: 'phenomena',
21
+ die: 'dice',
22
+ thesis: 'theses',
23
+ analysis: 'analyses',
24
+ crisis: 'crises',
25
+ radius: 'radii',
26
+ corpus: 'corpora',
27
+ };
28
+ // --- Uncountables ---
29
+ const uncountable = new Set([
30
+ 'sheep', 'fish', 'deer', 'series', 'species', 'news', 'information',
31
+ 'rice', 'moose', 'bison', 'salmon', 'aircraft', 'offspring'
32
+ ]);
33
+ // --- Exceptions ---
34
+ const fExceptions = new Set(['chief', 'roof', 'belief', 'chef', 'cliff', 'reef', 'proof', 'brief']);
35
+ // Keep only true irregular -o exceptions (consonant + o but take just "s")
36
+ const oExceptions = new Set(['piano', 'photo', 'halo', 'canto', 'solo']);
37
+ // --- Utility to preserve casing ---
38
+ function applyCasing(original, plural) {
39
+ if (original === original.toUpperCase())
40
+ return plural.toUpperCase();
41
+ if (original === original.toLowerCase())
42
+ return plural.toLowerCase();
43
+ if (original[0] === original[0].toUpperCase()) {
44
+ return plural.charAt(0).toUpperCase() + plural.slice(1);
45
+ }
46
+ return plural;
47
+ }
48
+ // --- Rule-based pluralization ---
49
+ const rules = [
50
+ // quiz -> quizzes, waltz -> waltzes, topaz -> topazes
51
+ [/z$/i, w => (/iz$/i.test(w) ? w + 'zes' : w + 'es')],
52
+ // bus -> buses, box -> boxes, church -> churches, but stomach -> stomachs (exclude -ach)
53
+ [/(s|x|ch|sh)$/i, w => (/ach$/i.test(w) ? w + 's' : w + 'es')],
54
+ // vowel + y -> +s (key -> keys)
55
+ [/[aeiou]y$/i, w => w + 's'],
56
+ // consonant + y -> -ies (city -> cities)
57
+ [/y$/i, w => w.slice(0, -1) + 'ies'],
58
+ // -fe -> -ves (knife -> knives), but not chief/roof/etc
59
+ [/fe$/i, w => (fExceptions.has(w) ? w + 's' : w.slice(0, -2) + 'ves')],
60
+ // -f -> -ves (wolf -> wolves), but not cliff/etc
61
+ [/f$/i, w => (fExceptions.has(w) ? w + 's' : w.slice(0, -1) + 'ves')],
62
+ // -sis -> -ses (analysis -> analyses, thesis -> theses)
63
+ [/sis$/i, w => w.slice(0, -2) + 'ses'],
64
+ // vowel + o -> +s (zoo -> zoos, video -> videos, patio -> patios)
65
+ [/[aeiou]o$/i, w => w + 's'],
66
+ // consonant + o -> usually +es, unless in oExceptions
67
+ [/o$/i, w => (oExceptions.has(w) ? w + 's' : w + 'es')],
68
+ // default: just +s
69
+ [/$/i, w => w + 's']
70
+ ];
71
+ // --- Exported pluralizer ---
72
+ export default function pluralize(word) {
73
+ if (typeof word !== 'string' || !/^[a-zA-Z]+$/.test(word))
74
+ return word;
75
+ const lower = word.toLowerCase();
76
+ if (uncountable.has(lower))
77
+ return word;
78
+ if (irregular[lower]) {
79
+ return applyCasing(word, irregular[lower]);
80
+ }
81
+ for (const [pattern, transform] of rules) {
82
+ if (pattern.test(lower)) {
83
+ return applyCasing(word, transform(lower));
84
+ }
85
+ }
86
+ return word; // fallback (shouldn't hit)
87
+ }
@@ -0,0 +1 @@
1
+ export declare function sleep(seconds: number): Promise<void>;
@@ -0,0 +1,5 @@
1
+ export async function sleep(seconds) {
2
+ return new Promise(resolve => {
3
+ setTimeout(resolve, 1000 * seconds);
4
+ });
5
+ }
@@ -0,0 +1,8 @@
1
+ import type { Readable, Writable } from 'stream';
2
+ interface PromptOptions {
3
+ input?: Readable;
4
+ output?: Writable;
5
+ }
6
+ export declare function confirm(question: string, { input, output }?: PromptOptions): Promise<boolean>;
7
+ export declare function prompt(question: string, { input, output }?: PromptOptions): Promise<string>;
8
+ export {};