@sandro-sikic/maker 1.0.11 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,13 +47,13 @@ loading.succeed('Project created! 🎉');
47
47
 
48
48
  ### Core Functions
49
49
 
50
- | Function | Description |
51
- | -------------------- | -------------------------------------------------- |
52
- | `init()` | Validates interactive terminal environment |
53
- | `run(command, opts)` | Executes shell commands with streaming output |
54
- | `onExit(callback)` | Registers cleanup function for graceful shutdown |
55
- | `prompt.*` | Interactive prompts (input, select, confirm, etc.) |
56
- | `spinner(text)` | Creates terminal loading indicators |
50
+ | Function | Description |
51
+ | -------------------- | ------------------------------------------------------------------------------------------------------- |
52
+ | `init(opts?)` | Validates interactive terminal environment; accepts optional options object (`{ configPath?: string }`) |
53
+ | `run(command, opts)` | Executes shell commands with streaming output |
54
+ | `onExit(callback)` | Registers cleanup function for graceful shutdown |
55
+ | `prompt.*` | Interactive prompts (input, select, confirm, etc.) |
56
+ | `spinner(text)` | Creates terminal loading indicators |
57
57
 
58
58
  ### Example: Simple Build Tool
59
59
 
@@ -100,12 +100,18 @@ build();
100
100
 
101
101
  ### `init()`
102
102
 
103
- Ensures your CLI is running in an interactive terminal. Always call this first.
103
+ Initializes CLI environment and validates your process is running in an interactive terminal. Call first in your CLI app. Accepts an optional options object: `{ configPath?: string }`.
104
104
 
105
105
  ```javascript
106
+ // default
106
107
  maker.init();
108
+
109
+ // override config file location
110
+ maker.init({ configPath: '/path/to/config.cfg' });
107
111
  ```
108
112
 
113
+ Note: passing a plain string to `init()` is not supported — use an options object instead.
114
+
109
115
  ### `run(command, opts)`
110
116
 
111
117
  Execute shell commands with real-time output streaming.
package/docs/API.md CHANGED
@@ -6,13 +6,17 @@ Quick reference guide for `@sandro-sikic/maker` functions.
6
6
 
7
7
  ## `init()`
8
8
 
9
- Validates interactive terminal environment. **Call first** in your CLI app.
9
+ Validates interactive terminal environment. **Call first** in your CLI app. Accepts an optional options object `{ configPath?: string }` to override the location of `config.cfg`.
10
10
 
11
11
  ```javascript
12
+ // default
12
13
  maker.init();
14
+
15
+ // with options
16
+ maker.init({ configPath: '/custom/path/config.cfg' });
13
17
  ```
14
18
 
15
- Exits with code 1 if not running in an interactive terminal (TTY).
19
+ Exits with code 1 if not running in an interactive terminal (TTY). Passing a plain string is not supported.
16
20
 
17
21
  ---
18
22
 
package/docs/USAGE.md CHANGED
@@ -28,18 +28,22 @@ loading.succeed('Installation complete!');
28
28
 
29
29
  ### `init()`
30
30
 
31
- Initializes the CLI environment and validates that the application is running in an interactive terminal (TTY). This should be called at the start of your CLI application.
31
+ Initializes the CLI environment and validates that the application is running in an interactive terminal (TTY). This should be called at the start of your CLI application. You may pass an optional options object `{ configPath?: string }` to change where `config.cfg` is read/written.
32
32
 
33
33
  **Returns:** `void`
34
34
 
35
35
  **Throws:** Exits with code 1 if not running in an interactive terminal
36
36
 
37
- **Example:**
37
+ **Examples:**
38
38
 
39
39
  ```javascript
40
40
  import { init } from '@sandro-sikic/maker';
41
41
 
42
- init(); // Call this first in your CLI app
42
+ // default
43
+ init();
44
+
45
+ // override config file
46
+ init({ configPath: '/custom/path/config.cfg' });
43
47
  ```
44
48
 
45
49
  **Why use it?**
package/example.ts ADDED
@@ -0,0 +1,24 @@
1
+ import * as maker from './index.js';
2
+
3
+ (async () => {
4
+ maker.init();
5
+
6
+ maker.save('test key', 'test.txt');
7
+ maker.save('key test', 'test.txt');
8
+
9
+ const answer = await maker.prompt.confirm({
10
+ message: 'Do you want to continue?',
11
+ });
12
+
13
+ const processing = await maker.spinner('Processing...').start();
14
+ console.log('Answer:', answer);
15
+
16
+ await new Promise((resolve) => setTimeout(resolve, 1000));
17
+
18
+ await maker.run('echo "Hello, World!"');
19
+
20
+ processing.succeed();
21
+
22
+ console.log(maker.load('key test'));
23
+ console.log(maker.load('test key'));
24
+ })();
package/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
- export function init(): void;
1
+ /// <reference path="./storage.generated.d.ts" />
2
+
3
+ export function init(opts?: { configPath?: string }): void;
2
4
 
3
5
  export type RunResult = {
4
6
  output: string;
@@ -16,8 +18,24 @@ export function run(
16
18
 
17
19
  export function onExit(cb: () => void | Promise<void>): () => void;
18
20
 
21
+ export function save<K extends keyof StorageSchema>(
22
+ key: K,
23
+ value: StorageSchema[K],
24
+ ): void;
25
+
26
+ export function save(key: string, value: unknown): void;
27
+
28
+ export function load<K extends keyof StorageSchema>(key: K): StorageSchema[K];
29
+
30
+ /**
31
+ * Overload for unknown string keys.
32
+ * @deprecated Key not found in `StorageSchema`. The call is allowed and returns `unknown`.
33
+ */
34
+ export function load(key: string): unknown;
35
+
19
36
  export const prompt: typeof import('@inquirer/prompts');
20
- export const spinner: typeof import('ora');
37
+
38
+ export const spinner: typeof import('ora').default;
21
39
 
22
40
  export type { Ora } from 'ora';
23
41
  export * from '@inquirer/prompts';
package/index.js CHANGED
@@ -1,8 +1,49 @@
1
1
  import * as inquirer from '@inquirer/prompts';
2
2
  import * as ora from 'ora';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ // filename for runtime-generated storage types
8
+ const GENERATED_TYPES_FILENAME = 'storage.generated.d.ts';
9
+
10
+ let _overrideConfigPath = null;
11
+
12
+ function init(opts) {
13
+ if (opts !== undefined) {
14
+ if (!opts || typeof opts !== 'object' || Array.isArray(opts)) {
15
+ throw new TypeError(
16
+ 'init(opts) expects an options object (e.g. { configPath?: string })',
17
+ );
18
+ }
19
+
20
+ if (Object.prototype.hasOwnProperty.call(opts, 'configPath')) {
21
+ if (typeof opts.configPath !== 'string') {
22
+ throw new TypeError('init(opts).configPath must be a string');
23
+ }
24
+ _overrideConfigPath = opts.configPath;
25
+ }
26
+ }
27
+
28
+ // Clear the auto-generated storage types file so previously-generated keys are removed
29
+ try {
30
+ const dir = path.dirname(fileURLToPath(import.meta.url));
31
+ const gen = path.join(dir, GENERATED_TYPES_FILENAME);
32
+ try {
33
+ fs.unlinkSync(gen);
34
+ } catch (err) {
35
+ // ignore if file does not exist or cannot be removed
36
+ }
37
+ } catch (err) {
38
+ /* non-fatal; don't prevent init from continuing */
39
+ try {
40
+ console.warn(
41
+ 'maker: failed to clear generated storage types:',
42
+ err && err.message ? err.message : err,
43
+ );
44
+ } catch (__) {}
45
+ }
3
46
 
4
- function init() {
5
- // Ensure we're running in an interactive terminal
6
47
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
7
48
  console.error(
8
49
  '\n⚠ This TUI requires an interactive terminal. Run this in a terminal (not the Node REPL or a non-interactive/debug console).\n',
@@ -145,7 +186,156 @@ function onExit(cb) {
145
186
  };
146
187
  }
147
188
 
148
- const prompt = inquirer.default ?? inquirer;
149
- const spinner = ora.default ?? ora;
189
+ /**
190
+ * Resolve path to the `config.cfg` file placed next to this module.
191
+ * If an override was provided to `init(opts)` return that path instead.
192
+ * @returns {string}
193
+ */
194
+ function _configPath() {
195
+ if (_overrideConfigPath) return _overrideConfigPath;
196
+ const dir = path.dirname(fileURLToPath(import.meta.url));
197
+ return path.join(dir, 'config.json');
198
+ }
199
+
200
+ /**
201
+ * Save a value under `key` in `config.cfg` (creates file if missing).
202
+ * - Overwrites existing keys
203
+ * - Accepts objects and primitives (stored as JSON)
204
+ * - Additionally generates `storage.generated.d.ts` (next-time autocomplete)
205
+ *
206
+ * @param {string} key
207
+ * @param {*} value
208
+ * @returns {void}
209
+ */
210
+ function save(key, value) {
211
+ if (typeof key !== 'string' || !key) {
212
+ throw new TypeError('save(key, value) requires a non-empty string key');
213
+ }
214
+
215
+ const file = _configPath();
216
+ let cfg = {};
217
+ try {
218
+ const txt = fs.readFileSync(file, 'utf8');
219
+ cfg = txt.trim() ? JSON.parse(txt) : {};
220
+ } catch (err) {
221
+ if (err && err.code !== 'ENOENT') throw err;
222
+ cfg = {};
223
+ }
224
+
225
+ cfg[key] = value;
226
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2), 'utf8');
227
+
228
+ // Generate (or update) `storage.generated.d.ts` so consumers get autocomplete
229
+ try {
230
+ _writeGeneratedStorageTypes(file);
231
+ } catch (err) {
232
+ // non-fatal: don't break save() if typegen fails
233
+ /* istanbul ignore next */
234
+ try {
235
+ console.warn(
236
+ 'maker: failed to generate storage types:',
237
+ err && err.message ? err.message : err,
238
+ );
239
+ } catch (__) {}
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Generate `storage.generated.d.ts` from the current config file.
245
+ * This writes a declaration that augments `StorageSchema` with all keys
246
+ * currently present in the config file so that TypeScript autocompletes
247
+ * on subsequent runs.
248
+ */
249
+ function _writeGeneratedStorageTypes(configFile) {
250
+ const outDir = path.dirname(fileURLToPath(import.meta.url));
251
+ const outPath = path.join(outDir, GENERATED_TYPES_FILENAME);
252
+ let txt = '';
253
+ try {
254
+ txt = fs.readFileSync(configFile, 'utf8');
255
+ } catch (err) {
256
+ if (err && err.code === 'ENOENT') {
257
+ // nothing saved yet — remove generated file if it exists
258
+ try {
259
+ fs.unlinkSync(outPath);
260
+ } catch (__) {}
261
+ return;
262
+ }
263
+ throw err;
264
+ }
265
+
266
+ const cfg = txt.trim() ? JSON.parse(txt) : {};
267
+ const keys = Object.keys(cfg).sort();
268
+
269
+ function tsTypeForValue(v) {
270
+ if (v === null) return 'null';
271
+ const t = typeof v;
272
+ if (t === 'string') return 'string';
273
+ if (t === 'number') return 'number';
274
+ if (t === 'boolean') return 'boolean';
275
+ if (Array.isArray(v)) {
276
+ if (v.length === 0) return 'unknown[]';
277
+ const types = Array.from(new Set(v.map(tsTypeForValue)));
278
+ if (types.length === 1) return `${types[0]}[]`;
279
+ return `(${types.join(' | ')})[]`;
280
+ }
281
+ if (t === 'object') {
282
+ // shallow object -> prefer a loose index signature to avoid fragile typings
283
+ const props = Object.keys(v);
284
+ if (props.length === 0) return 'Record<string, unknown>';
285
+ // construct a shallow literal type for better DX when shape is simple
286
+ const parts = props.map((p) => {
287
+ const pn = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(p) ? p : JSON.stringify(p);
288
+ return `${pn}: ${tsTypeForValue(v[p])}`;
289
+ });
290
+ return `{ ${parts.join('; ')} }`;
291
+ }
292
+ return 'unknown';
293
+ }
294
+
295
+ const lines = keys.map((k) => {
296
+ const v = cfg[k];
297
+ const safeKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(k)
298
+ ? k
299
+ : JSON.stringify(k);
300
+ return ` ${safeKey}: ${tsTypeForValue(v)};`;
301
+ });
302
+
303
+ const content =
304
+ `/* Auto-generated by maker — do not edit. */\n` +
305
+ `interface StorageSchema {\n` +
306
+ (lines.length ? lines.join('\n') + '\n' : '') +
307
+ `}\n`;
308
+
309
+ fs.writeFileSync(outPath, content, 'utf8');
310
+ }
311
+
312
+ /**
313
+ * Load a value by `key` from `config.cfg` located next to this module.
314
+ * Returns undefined when the file or key does not exist.
315
+ *
316
+ * @param {string} key
317
+ * @returns {*|undefined}
318
+ */
319
+ function load(key) {
320
+ if (typeof key !== 'string' || !key) {
321
+ throw new TypeError('load(key) requires a non-empty string key');
322
+ }
323
+
324
+ const file = _configPath();
325
+ try {
326
+ const txt = fs.readFileSync(file, 'utf8');
327
+ if (!txt.trim()) return undefined;
328
+ const cfg = JSON.parse(txt);
329
+ return Object.prototype.hasOwnProperty.call(cfg, key)
330
+ ? cfg[key]
331
+ : undefined;
332
+ } catch (err) {
333
+ if (err && err.code === 'ENOENT') return undefined;
334
+ throw err;
335
+ }
336
+ }
337
+
338
+ const prompt = inquirer;
339
+ const spinner = ora.default;
150
340
 
151
- export { init, run, onExit, prompt, spinner };
341
+ export { init, run, onExit, prompt, spinner, save, load };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sandro-sikic/maker",
3
- "version": "1.0.11",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "description": "A lightweight library for building interactive command-line tools with prompts, command execution, spinners, and graceful shutdown handling",
6
6
  "main": "index.js",
@@ -1,12 +1,34 @@
1
- import { describe, it, expect, vi } from 'vitest';
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
2
 
3
3
  vi.mock('ora', () => ({
4
4
  default: () => ({ start: () => ({ stop: () => {} }) }),
5
5
  }));
6
6
 
7
- import { run, onExit, init, prompt, spinner } from '../index.js';
7
+ import { promises as fs } from 'fs';
8
+ import path from 'path';
9
+ import { fileURLToPath } from 'url';
10
+
11
+ import { run, onExit, init, prompt, spinner, save, load } from '../index.js';
8
12
 
9
13
  describe('run()', () => {
14
+ it('throws TypeError when command is not a string', async () => {
15
+ await expect(run(null)).rejects.toThrow(TypeError);
16
+ await expect(run(undefined)).rejects.toThrow(TypeError);
17
+ await expect(run(123)).rejects.toThrow(TypeError);
18
+ await expect(run({})).rejects.toThrow(TypeError);
19
+ await expect(run([])).rejects.toThrow(TypeError);
20
+ });
21
+
22
+ it('throws TypeError when command is an empty string', async () => {
23
+ await expect(run('')).rejects.toThrow(TypeError);
24
+ await expect(run('')).rejects.toThrow(/non-empty string/);
25
+ });
26
+
27
+ it('throws TypeError when command is only whitespace', async () => {
28
+ await expect(run(' ')).rejects.toThrow(TypeError);
29
+ await expect(run('\t\n')).rejects.toThrow(TypeError);
30
+ });
31
+
10
32
  it('captures stdout for successful commands (mocked)', async () => {
11
33
  vi.resetModules();
12
34
  vi.doMock('child_process', () => ({
@@ -285,6 +307,25 @@ describe('init()', () => {
285
307
  process.stdout.isTTY = origStdoutTTY;
286
308
  });
287
309
 
310
+ it('throws when passed a non-object argument', () => {
311
+ expect(() => init('some/path/config.cfg')).toThrow(TypeError);
312
+ expect(() => init(123)).toThrow(TypeError);
313
+ expect(() => init(true)).toThrow(TypeError);
314
+ expect(() => init(null)).toThrow(TypeError);
315
+ });
316
+
317
+ it('throws when passed an array instead of object', () => {
318
+ expect(() => init([])).toThrow(TypeError);
319
+ expect(() => init(['path'])).toThrow(TypeError);
320
+ });
321
+
322
+ it('throws TypeError when configPath is not a string', () => {
323
+ expect(() => init({ configPath: 123 })).toThrow(TypeError);
324
+ expect(() => init({ configPath: {} })).toThrow(TypeError);
325
+ expect(() => init({ configPath: null })).toThrow(TypeError);
326
+ expect(() => init({ configPath: true })).toThrow(TypeError);
327
+ });
328
+
288
329
  it('exits with code 1 and logs error when not in a TTY', () => {
289
330
  const origStdinTTY = process.stdin.isTTY;
290
331
  const origStdoutTTY = process.stdout.isTTY;
@@ -302,6 +343,38 @@ describe('init()', () => {
302
343
  process.stdin.isTTY = origStdinTTY;
303
344
  process.stdout.isTTY = origStdoutTTY;
304
345
  });
346
+
347
+ it('clears generated storage types file when present', async () => {
348
+ const genPath = path.join(
349
+ path.dirname(fileURLToPath(new URL('../index.js', import.meta.url).href)),
350
+ 'storage.generated.d.ts',
351
+ );
352
+
353
+ // create a dummy generated file
354
+ await fs.writeFile(genPath, '/* generated */', 'utf8');
355
+
356
+ const origStdinTTY = process.stdin.isTTY;
357
+ const origStdoutTTY = process.stdout.isTTY;
358
+ process.stdin.isTTY = true;
359
+ process.stdout.isTTY = true;
360
+
361
+ try {
362
+ init();
363
+ let exists = true;
364
+ try {
365
+ await fs.access(genPath);
366
+ } catch (e) {
367
+ exists = false;
368
+ }
369
+ expect(exists).toBe(false);
370
+ } finally {
371
+ process.stdin.isTTY = origStdinTTY;
372
+ process.stdout.isTTY = origStdoutTTY;
373
+ try {
374
+ await fs.rm(genPath);
375
+ } catch (e) {}
376
+ }
377
+ });
305
378
  });
306
379
 
307
380
  describe('exports', () => {
@@ -314,6 +387,311 @@ describe('exports', () => {
314
387
  });
315
388
  });
316
389
 
390
+ describe('config: save/load', () => {
391
+ const cfgPath = path.join(
392
+ path.dirname(fileURLToPath(new URL('../index.js', import.meta.url).href)),
393
+ 'config.json',
394
+ );
395
+
396
+ afterEach(async () => {
397
+ try {
398
+ await fs.rm(cfgPath);
399
+ } catch (e) {
400
+ // ignore
401
+ }
402
+ });
403
+
404
+ it('creates file and saves/loads primitive value', async () => {
405
+ save('foo', 'bar');
406
+ expect(load('foo')).toBe('bar');
407
+ const txt = await fs.readFile(cfgPath, 'utf8');
408
+ expect(JSON.parse(txt)).toEqual({ foo: 'bar' });
409
+ });
410
+
411
+ it('throws TypeError when save() key is not a string', () => {
412
+ expect(() => save(null, 'value')).toThrow(TypeError);
413
+ expect(() => save(undefined, 'value')).toThrow(TypeError);
414
+ expect(() => save(123, 'value')).toThrow(TypeError);
415
+ expect(() => save({}, 'value')).toThrow(TypeError);
416
+ expect(() => save([], 'value')).toThrow(TypeError);
417
+ });
418
+
419
+ it('throws TypeError when save() key is an empty string', () => {
420
+ expect(() => save('', 'value')).toThrow(TypeError);
421
+ expect(() => save('', 'value')).toThrow(/non-empty string key/);
422
+ });
423
+
424
+ it('throws TypeError when load() key is not a string', () => {
425
+ expect(() => load(null)).toThrow(TypeError);
426
+ expect(() => load(undefined)).toThrow(TypeError);
427
+ expect(() => load(123)).toThrow(TypeError);
428
+ expect(() => load({})).toThrow(TypeError);
429
+ expect(() => load([])).toThrow(TypeError);
430
+ });
431
+
432
+ it('throws TypeError when load() key is an empty string', () => {
433
+ expect(() => load('')).toThrow(TypeError);
434
+ expect(() => load('')).toThrow(/non-empty string key/);
435
+ });
436
+
437
+ it('saves and loads null values', () => {
438
+ save('nullKey', null);
439
+ expect(load('nullKey')).toBeNull();
440
+ });
441
+
442
+ it('saves and loads boolean values', () => {
443
+ save('trueKey', true);
444
+ save('falseKey', false);
445
+ expect(load('trueKey')).toBe(true);
446
+ expect(load('falseKey')).toBe(false);
447
+ });
448
+
449
+ it('saves and loads number values including zero and negative', () => {
450
+ save('zero', 0);
451
+ save('negative', -42);
452
+ save('float', 3.14);
453
+ expect(load('zero')).toBe(0);
454
+ expect(load('negative')).toBe(-42);
455
+ expect(load('float')).toBe(3.14);
456
+ });
457
+
458
+ it('saves and loads deeply nested objects', () => {
459
+ const nested = {
460
+ level1: {
461
+ level2: {
462
+ level3: {
463
+ value: 'deep',
464
+ array: [1, 2, { nested: true }],
465
+ },
466
+ },
467
+ },
468
+ };
469
+ save('nested', nested);
470
+ expect(load('nested')).toEqual(nested);
471
+ });
472
+
473
+ it('saves and loads arrays with mixed types', () => {
474
+ const mixed = [1, 'two', true, null, { key: 'value' }, [1, 2]];
475
+ save('mixedArray', mixed);
476
+ expect(load('mixedArray')).toEqual(mixed);
477
+ });
478
+
479
+ it('saves and loads empty arrays and objects', () => {
480
+ save('emptyArray', []);
481
+ save('emptyObject', {});
482
+ expect(load('emptyArray')).toEqual([]);
483
+ expect(load('emptyObject')).toEqual({});
484
+ });
485
+
486
+ it('handles keys with special characters', () => {
487
+ save('key-with-dashes', 'value1');
488
+ save('key.with.dots', 'value2');
489
+ save('key_with_underscores', 'value3');
490
+ save('key with spaces', 'value4');
491
+ expect(load('key-with-dashes')).toBe('value1');
492
+ expect(load('key.with.dots')).toBe('value2');
493
+ expect(load('key_with_underscores')).toBe('value3');
494
+ expect(load('key with spaces')).toBe('value4');
495
+ });
496
+
497
+ it('handles corrupted JSON file gracefully in load()', async () => {
498
+ const cfgPath = path.join(
499
+ path.dirname(fileURLToPath(new URL('../index.js', import.meta.url).href)),
500
+ 'config.json',
501
+ );
502
+ await fs.writeFile(cfgPath, '{invalid json', 'utf8');
503
+ expect(() => load('anyKey')).toThrow(SyntaxError);
504
+ await fs.rm(cfgPath);
505
+ });
506
+
507
+ it('preserves all keys when saving multiple values', () => {
508
+ save('key1', 'value1');
509
+ save('key2', 'value2');
510
+ save('key3', 'value3');
511
+ expect(load('key1')).toBe('value1');
512
+ expect(load('key2')).toBe('value2');
513
+ expect(load('key3')).toBe('value3');
514
+ });
515
+
516
+ it('accepts and returns object values', async () => {
517
+ save('obj', { a: 1, b: 'x' });
518
+ expect(load('obj')).toEqual({ a: 1, b: 'x' });
519
+ });
520
+
521
+ it('overwrites existing key', async () => {
522
+ save('k', 'v1');
523
+ save('k', 'v2');
524
+ expect(load('k')).toBe('v2');
525
+ });
526
+
527
+ it('returns undefined for missing key or missing file', async () => {
528
+ try {
529
+ await fs.rm(cfgPath);
530
+ } catch (e) {}
531
+ expect(load('nope')).toBeUndefined();
532
+ });
533
+
534
+ it('respects config path passed to init(opts)', async () => {
535
+ const origStdinTTY = process.stdin.isTTY;
536
+ const origStdoutTTY = process.stdout.isTTY;
537
+ process.stdin.isTTY = true;
538
+ process.stdout.isTTY = true;
539
+
540
+ vi.resetModules();
541
+ const { init: freshInit, save: freshSave } = await import('../index.js');
542
+ const customPath = path.join(
543
+ path.dirname(fileURLToPath(new URL('../index.js', import.meta.url).href)),
544
+ 'custom-config.cfg',
545
+ );
546
+ try {
547
+ await fs.rm(customPath);
548
+ } catch (e) {}
549
+ freshInit({ configPath: customPath });
550
+ freshSave('customKey', 'value');
551
+ const txt = await fs.readFile(customPath, 'utf8');
552
+ expect(JSON.parse(txt)).toEqual({ customKey: 'value' });
553
+ await fs.rm(customPath);
554
+
555
+ process.stdin.isTTY = origStdinTTY;
556
+ process.stdout.isTTY = origStdoutTTY;
557
+ });
558
+
559
+ it('handles empty config file in load()', async () => {
560
+ const cfgPath = path.join(
561
+ path.dirname(fileURLToPath(new URL('../index.js', import.meta.url).href)),
562
+ 'config.json',
563
+ );
564
+ await fs.writeFile(cfgPath, '', 'utf8');
565
+ expect(load('anyKey')).toBeUndefined();
566
+ await fs.rm(cfgPath);
567
+ });
568
+
569
+ it('handles whitespace-only config file in load()', async () => {
570
+ const cfgPath = path.join(
571
+ path.dirname(fileURLToPath(new URL('../index.js', import.meta.url).href)),
572
+ 'config.json',
573
+ );
574
+ await fs.writeFile(cfgPath, ' \n\t ', 'utf8');
575
+ expect(load('anyKey')).toBeUndefined();
576
+ await fs.rm(cfgPath);
577
+ });
578
+ });
579
+
580
+ describe('type generation (storage.generated.d.ts)', () => {
581
+ const cfgPath = path.join(
582
+ path.dirname(fileURLToPath(new URL('../index.js', import.meta.url).href)),
583
+ 'config.json',
584
+ );
585
+ const genPath = path.join(
586
+ path.dirname(fileURLToPath(new URL('../index.js', import.meta.url).href)),
587
+ 'storage.generated.d.ts',
588
+ );
589
+
590
+ afterEach(async () => {
591
+ try {
592
+ await fs.rm(cfgPath);
593
+ } catch (e) {}
594
+ try {
595
+ await fs.rm(genPath);
596
+ } catch (e) {}
597
+ });
598
+
599
+ it('generates TypeScript types for string values', async () => {
600
+ save('name', 'John');
601
+ const content = await fs.readFile(genPath, 'utf8');
602
+ expect(content).toContain('interface StorageSchema');
603
+ expect(content).toContain('name: string');
604
+ });
605
+
606
+ it('generates TypeScript types for number values', async () => {
607
+ save('age', 42);
608
+ save('price', 19.99);
609
+ const content = await fs.readFile(genPath, 'utf8');
610
+ expect(content).toContain('age: number');
611
+ expect(content).toContain('price: number');
612
+ });
613
+
614
+ it('generates TypeScript types for boolean values', async () => {
615
+ save('enabled', true);
616
+ save('disabled', false);
617
+ const content = await fs.readFile(genPath, 'utf8');
618
+ expect(content).toContain('enabled: boolean');
619
+ expect(content).toContain('disabled: boolean');
620
+ });
621
+
622
+ it('generates TypeScript types for null values', async () => {
623
+ save('nullValue', null);
624
+ const content = await fs.readFile(genPath, 'utf8');
625
+ expect(content).toContain('nullValue: null');
626
+ });
627
+
628
+ it('generates TypeScript types for empty arrays', async () => {
629
+ save('emptyArr', []);
630
+ const content = await fs.readFile(genPath, 'utf8');
631
+ expect(content).toContain('emptyArr: unknown[]');
632
+ });
633
+
634
+ it('generates TypeScript types for homogeneous arrays', async () => {
635
+ save('numbers', [1, 2, 3]);
636
+ save('strings', ['a', 'b', 'c']);
637
+ const content = await fs.readFile(genPath, 'utf8');
638
+ expect(content).toContain('numbers: number[]');
639
+ expect(content).toContain('strings: string[]');
640
+ });
641
+
642
+ it('generates TypeScript types for arrays with mixed types', async () => {
643
+ save('mixed', [1, 'two', true]);
644
+ const content = await fs.readFile(genPath, 'utf8');
645
+ expect(content).toMatch(/mixed: \(.*\)\[\]/);
646
+ expect(content).toContain('number');
647
+ expect(content).toContain('string');
648
+ expect(content).toContain('boolean');
649
+ });
650
+
651
+ it('generates TypeScript types for empty objects', async () => {
652
+ save('emptyObj', {});
653
+ const content = await fs.readFile(genPath, 'utf8');
654
+ expect(content).toContain('emptyObj: Record<string, unknown>');
655
+ });
656
+
657
+ it('generates TypeScript types for shallow objects', async () => {
658
+ save('user', { name: 'Alice', age: 30, active: true });
659
+ const content = await fs.readFile(genPath, 'utf8');
660
+ expect(content).toContain('user:');
661
+ expect(content).toContain('name: string');
662
+ expect(content).toContain('age: number');
663
+ expect(content).toContain('active: boolean');
664
+ });
665
+
666
+ it('handles keys with special characters in generated types', async () => {
667
+ save('key-with-dash', 'value');
668
+ save('key with space', 'value');
669
+ const content = await fs.readFile(genPath, 'utf8');
670
+ // Keys that aren't valid identifiers should be quoted
671
+ expect(content).toContain('"key-with-dash": string');
672
+ expect(content).toContain('"key with space": string');
673
+ });
674
+
675
+ it('sorts keys alphabetically in generated types', async () => {
676
+ save('zebra', 1);
677
+ save('apple', 2);
678
+ save('banana', 3);
679
+ const content = await fs.readFile(genPath, 'utf8');
680
+ const appleIdx = content.indexOf('apple');
681
+ const bananaIdx = content.indexOf('banana');
682
+ const zebraIdx = content.indexOf('zebra');
683
+ expect(appleIdx).toBeLessThan(bananaIdx);
684
+ expect(bananaIdx).toBeLessThan(zebraIdx);
685
+ });
686
+
687
+ it('includes auto-generated comment header', async () => {
688
+ save('test', 'value');
689
+ const content = await fs.readFile(genPath, 'utf8');
690
+ expect(content).toContain('Auto-generated by maker');
691
+ expect(content).toContain('do not edit');
692
+ });
693
+ });
694
+
317
695
  describe('run() - additional edge cases', () => {
318
696
  it('combines stdout and stderr in output field', async () => {
319
697
  vi.resetModules();
@@ -353,39 +731,13 @@ describe('run() - additional edge cases', () => {
353
731
  vi.resetModules();
354
732
  });
355
733
 
356
- it('handles null stdout stream gracefully', async () => {
734
+ it('handles null stdout and stderr streams gracefully', async () => {
357
735
  vi.resetModules();
358
736
  vi.doMock('child_process', () => ({
359
737
  spawn: () => {
360
738
  let closeCb;
361
739
  const child = {
362
740
  stdout: null,
363
- stderr: { on: () => {} },
364
- on: (ev, cb) => {
365
- if (ev === 'close') closeCb = cb;
366
- },
367
- };
368
- process.nextTick(() => {
369
- if (closeCb) closeCb(0);
370
- });
371
- return child;
372
- },
373
- }));
374
- const { run: mockedRun } = await import('../index.js');
375
- const res = await mockedRun('anything');
376
- expect(res.code).toBe(0);
377
- expect(res.stdout).toBe('');
378
- expect(res.isError).toBe(false);
379
- vi.resetModules();
380
- });
381
-
382
- it('handles null stderr stream gracefully', async () => {
383
- vi.resetModules();
384
- vi.doMock('child_process', () => ({
385
- spawn: () => {
386
- let closeCb;
387
- const child = {
388
- stdout: { on: () => {} },
389
741
  stderr: null,
390
742
  on: (ev, cb) => {
391
743
  if (ev === 'close') closeCb = cb;
@@ -400,12 +752,13 @@ describe('run() - additional edge cases', () => {
400
752
  const { run: mockedRun } = await import('../index.js');
401
753
  const res = await mockedRun('anything');
402
754
  expect(res.code).toBe(0);
755
+ expect(res.stdout).toBe('');
403
756
  expect(res.stderr).toBe('');
404
757
  expect(res.isError).toBe(false);
405
758
  vi.resetModules();
406
759
  });
407
760
 
408
- it('forwards opts directly to spawn (no alias)', async () => {
761
+ it('forwards opts including cwd, env, and shell to spawn', async () => {
409
762
  vi.resetModules();
410
763
  let captured;
411
764
  vi.doMock('child_process', () => ({
@@ -425,40 +778,14 @@ describe('run() - additional edge cases', () => {
425
778
  const res = await mockedRun('anything', {
426
779
  cwd: '/tmp',
427
780
  env: { FOO: 'bar' },
781
+ maxLines: 5, // Should be filtered out
782
+ shell: false,
428
783
  });
429
784
  expect(captured.cwd).toBe('/tmp');
430
785
  expect(captured.env).toEqual(expect.objectContaining({ FOO: 'bar' }));
431
- expect(captured.shell).toBe(true);
432
- expect(captured.stdio).toBe('pipe');
433
- expect(res.code).toBe(0);
434
- vi.resetModules();
435
- });
436
-
437
- it('forwards top-level spawn options (maxLines is capture-only)', async () => {
438
- vi.resetModules();
439
- let captured;
440
- vi.doMock('child_process', () => ({
441
- spawn: (cmd, spawnOpts) => {
442
- captured = spawnOpts;
443
- const child = {
444
- stdout: { on: () => {} },
445
- stderr: { on: () => {} },
446
- on: (ev, cb) => {
447
- if (ev === 'close') cb(0);
448
- },
449
- };
450
- return child;
451
- },
452
- }));
453
- const { run: mockedRun } = await import('../index.js');
454
- const res = await mockedRun('anything', {
455
- cwd: '/cwd',
456
- maxLines: 5,
457
- shell: false,
458
- });
459
- expect(captured.cwd).toBe('/cwd');
460
- expect(captured.maxLines).toBeUndefined();
461
786
  expect(captured.shell).toBe(false);
787
+ expect(captured.stdio).toBe('pipe');
788
+ expect(captured.maxLines).toBeUndefined(); // maxLines should not be forwarded
462
789
  expect(res.code).toBe(0);
463
790
  vi.resetModules();
464
791
  });
@@ -1104,18 +1431,6 @@ describe('run() - enhanced edge cases', () => {
1104
1431
  });
1105
1432
 
1106
1433
  describe('onExit() - enhanced edge cases', () => {
1107
- it('handles callback that returns a value (not a promise)', async () => {
1108
- const cb = vi.fn(() => 42);
1109
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
1110
- const off = onExit(cb);
1111
- process.emit('SIGINT');
1112
- await new Promise((r) => setTimeout(r, 10));
1113
- expect(cb).toHaveBeenCalled();
1114
- expect(exitSpy).toHaveBeenCalledWith(0);
1115
- off();
1116
- exitSpy.mockRestore();
1117
- });
1118
-
1119
1434
  it('handles callback throwing synchronously', async () => {
1120
1435
  const cb = vi.fn(() => {
1121
1436
  throw new Error('sync error');
@@ -1175,7 +1490,7 @@ describe('onExit() - enhanced edge cases', () => {
1175
1490
  });
1176
1491
  const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
1177
1492
  const off = onExit(cb);
1178
- process.emit('SIGINT');
1493
+ process.emit('SIGTERM'); // Test different signal
1179
1494
  await new Promise((r) => setTimeout(r, 15));
1180
1495
  expect(cb).toHaveBeenCalled();
1181
1496
  expect(exitSpy).toHaveBeenCalledWith(0);
@@ -1183,6 +1498,18 @@ describe('onExit() - enhanced edge cases', () => {
1183
1498
  exitSpy.mockRestore();
1184
1499
  });
1185
1500
 
1501
+ it('responds to SIGQUIT signal', async () => {
1502
+ const cb = vi.fn(async () => {});
1503
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
1504
+ const off = onExit(cb);
1505
+ process.emit('SIGQUIT');
1506
+ await new Promise((r) => setTimeout(r, 10));
1507
+ expect(cb).toHaveBeenCalled();
1508
+ expect(exitSpy).toHaveBeenCalledWith(0);
1509
+ off();
1510
+ exitSpy.mockRestore();
1511
+ });
1512
+
1186
1513
  it('unsubscribe can be called multiple times safely', () => {
1187
1514
  const cb = vi.fn();
1188
1515
  const off = onExit(cb);
package/example.js DELETED
@@ -1,13 +0,0 @@
1
- import * as maker from './index.js';
2
-
3
- (async () => {
4
- maker.init();
5
-
6
- const spinner = await maker.spinner('Testing maker.run()...').start();
7
-
8
- await new Promise((resolve) => setTimeout(resolve, 1000));
9
-
10
- await maker.run('echo "Hello, World!"');
11
-
12
- spinner.succeed('Done!');
13
- })();