@jordanalec/dtk 1.0.5 → 1.0.7

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
@@ -678,6 +678,7 @@ my-project/
678
678
  basic-auth.ts # base64 Basic auth header builder
679
679
  bearer-token.ts # Bearer token header builder
680
680
  token.ts # JWT claim decoder
681
+ file.ts # readFile / writeFile / copyFile / moveFile / deleteFile / listDir etc.
681
682
  types/
682
683
  suite.ts # StepContext, SuiteRunOption string union, auth types -- do not delete sentinel comments
683
684
  oauth.ts # OAuthConfig, TokenResponse
package/dist/cli.js CHANGED
@@ -1,12 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
+ import { createRequire } from 'module';
3
4
  import { initCommand } from './init.js';
4
5
  import { addCommand } from './add.js';
6
+ const require = createRequire(import.meta.url);
7
+ const { version } = require('../package.json');
5
8
  const program = new Command();
6
9
  program
7
10
  .name('dtk')
8
11
  .description('Developer toolkit scaffolder')
9
- .version('0.0.1');
12
+ .version(version);
10
13
  program.addCommand(initCommand);
11
14
  program.addCommand(addCommand);
12
15
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jordanalec/dtk",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "CLI scaffolding tool for generating self-contained TypeScript runbook projects",
5
5
  "keywords": [
6
6
  "cli",
@@ -512,7 +512,9 @@ All shared type definitions: `StepContext`, `StepFn`, `Step`, the `SuiteRunOptio
512
512
 
513
513
  ### `src/lib/http.ts`
514
514
 
515
- Axios wrapper. Provides `httpGet`, `httpPost`, `httpPut`, and `httpDelete`. Normalises errors into plain `Error` objects with readable messages. Use this inside service factories instead of calling axios directly.
515
+ Axios wrapper. Provides `httpGet`, `httpPost`, `httpPut`, and `httpDelete`. Normalises errors into plain `Error` objects with readable messages (`HTTP 404: ...`). Use this inside service factories instead of calling axios directly.
516
+
517
+ All four functions accept an optional `HttpOptions` argument with `headers` and `retry`. When `retry` is set, failed requests are retried up to `attempts` times with either fixed or exponential backoff (`delayMs`, `maxDelayMs`). Provide a `retryOn` predicate to control which errors trigger a retry. `httpDelete` returns the HTTP status code as a `number`.
516
518
 
517
519
  ### `src/lib/oauth.ts`
518
520
 
@@ -530,6 +532,10 @@ Returns `<prefix> <token>` (e.g. `Bearer sk-abc123`) ready to use as an `Authori
530
532
 
531
533
  `getClaimValues(token)` decodes the payload of a JWT and returns the claims as a plain object. Does not verify the signature.
532
534
 
535
+ ### `src/lib/file.ts`
536
+
537
+ File system utilities. Provides `readFile`, `readJson<T>`, `writeFile`, `writeJson`, `appendFile`, `fileExists`, `deleteFile`, `ensureDir`, `copyFile`, `moveFile`, and `listDir`. All functions wrap `node:fs/promises` and normalise errors into readable messages (`file not found`, `permission denied`, `path is a directory`). Parent directories are created automatically on write, append, and copy.
538
+
533
539
  ### `src/load-env.ts`
534
540
 
535
541
  Loads `.env` then `.env.local` (local overrides). Import this as the very first line of every runbook.
@@ -51,7 +51,7 @@ See `GUIDE.md` for full details.
51
51
  src/
52
52
  suite.ts # core runner -- extend this with custom services
53
53
  load-env.ts # dotenv bootstrap -- import first in every runbook
54
- lib/ # HTTP client, OAuth, auth helpers
54
+ lib/ # HTTP client, OAuth, auth helpers, file system utilities
55
55
  types/ # type definitions
56
56
  services/ # service factories added by plugins or custom code
57
57
  runbooks/ # your runbook scripts
@@ -0,0 +1,194 @@
1
+ import { readFile, readJson, writeFile, writeJson, appendFile, fileExists, deleteFile, ensureDir, copyFile, moveFile, listDir } from './file.js';
2
+
3
+ jest.mock('node:fs/promises');
4
+ import * as fs from 'node:fs/promises';
5
+
6
+ const mockFs = fs as jest.Mocked<typeof fs>;
7
+
8
+ beforeEach(() => {
9
+ jest.clearAllMocks();
10
+ });
11
+
12
+ describe('readFile', () => {
13
+ it('returns file content as a string', async () => {
14
+ mockFs.readFile.mockResolvedValue('hello world' as any);
15
+ const result = await readFile('/tmp/test.txt');
16
+ expect(result).toBe('hello world');
17
+ expect(mockFs.readFile).toHaveBeenCalledWith('/tmp/test.txt', 'utf-8');
18
+ });
19
+
20
+ it('throws a friendly message on ENOENT', async () => {
21
+ mockFs.readFile.mockRejectedValue(Object.assign(new Error('no such file'), { code: 'ENOENT' }));
22
+ await expect(readFile('/tmp/missing.txt')).rejects.toThrow('file not found: /tmp/missing.txt');
23
+ });
24
+
25
+ it('throws a friendly message on EACCES', async () => {
26
+ mockFs.readFile.mockRejectedValue(Object.assign(new Error('permission denied'), { code: 'EACCES' }));
27
+ await expect(readFile('/tmp/secret.txt')).rejects.toThrow('permission denied: /tmp/secret.txt');
28
+ });
29
+
30
+ it('throws a friendly message on EISDIR', async () => {
31
+ mockFs.readFile.mockRejectedValue(Object.assign(new Error('is a directory'), { code: 'EISDIR' }));
32
+ await expect(readFile('/tmp/adir')).rejects.toThrow('path is a directory: /tmp/adir');
33
+ });
34
+
35
+ it('includes the error code for unrecognised fs errors', async () => {
36
+ mockFs.readFile.mockRejectedValue(Object.assign(new Error('device full'), { code: 'ENOSPC' }));
37
+ await expect(readFile('/tmp/test.txt')).rejects.toThrow('ENOSPC:');
38
+ });
39
+
40
+ it('rethrows a plain Error unchanged when no code is present', async () => {
41
+ mockFs.readFile.mockRejectedValue(new Error('unexpected'));
42
+ await expect(readFile('/tmp/test.txt')).rejects.toThrow('unexpected');
43
+ });
44
+ });
45
+
46
+ describe('readJson', () => {
47
+ it('parses and returns JSON content', async () => {
48
+ mockFs.readFile.mockResolvedValue('{"id":1,"name":"test"}' as any);
49
+ const result = await readJson<{ id: number; name: string }>('/tmp/data.json');
50
+ expect(result).toEqual({ id: 1, name: 'test' });
51
+ });
52
+
53
+ it('throws when the file contains invalid JSON', async () => {
54
+ mockFs.readFile.mockResolvedValue('not json' as any);
55
+ await expect(readJson('/tmp/bad.json')).rejects.toThrow('invalid JSON in file: /tmp/bad.json');
56
+ });
57
+
58
+ it('propagates read errors from readFile', async () => {
59
+ mockFs.readFile.mockRejectedValue(Object.assign(new Error('no such file'), { code: 'ENOENT' }));
60
+ await expect(readJson('/tmp/missing.json')).rejects.toThrow('file not found: /tmp/missing.json');
61
+ });
62
+ });
63
+
64
+ describe('writeFile', () => {
65
+ it('creates the parent directory then writes the content', async () => {
66
+ mockFs.mkdir.mockResolvedValue(undefined);
67
+ mockFs.writeFile.mockResolvedValue(undefined);
68
+ await writeFile('/tmp/dir/test.txt', 'content');
69
+ expect(mockFs.mkdir).toHaveBeenCalledWith('/tmp/dir', { recursive: true });
70
+ expect(mockFs.writeFile).toHaveBeenCalledWith('/tmp/dir/test.txt', 'content', 'utf-8');
71
+ });
72
+
73
+ it('throws a friendly message on write error', async () => {
74
+ mockFs.mkdir.mockResolvedValue(undefined);
75
+ mockFs.writeFile.mockRejectedValue(Object.assign(new Error('permission denied'), { code: 'EACCES' }));
76
+ await expect(writeFile('/tmp/test.txt', 'content')).rejects.toThrow('permission denied: /tmp/test.txt');
77
+ });
78
+ });
79
+
80
+ describe('writeJson', () => {
81
+ it('serialises JSON with the default indent of 2', async () => {
82
+ mockFs.mkdir.mockResolvedValue(undefined);
83
+ mockFs.writeFile.mockResolvedValue(undefined);
84
+ await writeJson('/tmp/data.json', { id: 1 });
85
+ expect(mockFs.writeFile).toHaveBeenCalledWith('/tmp/data.json', JSON.stringify({ id: 1 }, null, 2), 'utf-8');
86
+ });
87
+
88
+ it('uses the provided indent value', async () => {
89
+ mockFs.mkdir.mockResolvedValue(undefined);
90
+ mockFs.writeFile.mockResolvedValue(undefined);
91
+ await writeJson('/tmp/data.json', { id: 1 }, 4);
92
+ expect(mockFs.writeFile).toHaveBeenCalledWith('/tmp/data.json', JSON.stringify({ id: 1 }, null, 4), 'utf-8');
93
+ });
94
+ });
95
+
96
+ describe('appendFile', () => {
97
+ it('creates the parent directory then appends the content', async () => {
98
+ mockFs.mkdir.mockResolvedValue(undefined);
99
+ mockFs.appendFile.mockResolvedValue(undefined);
100
+ await appendFile('/tmp/dir/log.txt', 'new line\n');
101
+ expect(mockFs.mkdir).toHaveBeenCalledWith('/tmp/dir', { recursive: true });
102
+ expect(mockFs.appendFile).toHaveBeenCalledWith('/tmp/dir/log.txt', 'new line\n', 'utf-8');
103
+ });
104
+
105
+ it('throws a friendly message on append error', async () => {
106
+ mockFs.mkdir.mockResolvedValue(undefined);
107
+ mockFs.appendFile.mockRejectedValue(Object.assign(new Error('permission denied'), { code: 'EACCES' }));
108
+ await expect(appendFile('/tmp/log.txt', 'data')).rejects.toThrow('permission denied: /tmp/log.txt');
109
+ });
110
+ });
111
+
112
+ describe('fileExists', () => {
113
+ it('returns true when the file is accessible', async () => {
114
+ mockFs.access.mockResolvedValue(undefined);
115
+ expect(await fileExists('/tmp/exists.txt')).toBe(true);
116
+ });
117
+
118
+ it('returns false when access throws', async () => {
119
+ mockFs.access.mockRejectedValue(Object.assign(new Error('no such file'), { code: 'ENOENT' }));
120
+ expect(await fileExists('/tmp/missing.txt')).toBe(false);
121
+ });
122
+ });
123
+
124
+ describe('deleteFile', () => {
125
+ it('calls rm with force: true', async () => {
126
+ mockFs.rm.mockResolvedValue(undefined);
127
+ await deleteFile('/tmp/old.txt');
128
+ expect(mockFs.rm).toHaveBeenCalledWith('/tmp/old.txt', { force: true });
129
+ });
130
+
131
+ it('throws a friendly message on rm error', async () => {
132
+ mockFs.rm.mockRejectedValue(Object.assign(new Error('permission denied'), { code: 'EACCES' }));
133
+ await expect(deleteFile('/tmp/protected.txt')).rejects.toThrow('permission denied: /tmp/protected.txt');
134
+ });
135
+ });
136
+
137
+ describe('ensureDir', () => {
138
+ it('calls mkdir with recursive: true', async () => {
139
+ mockFs.mkdir.mockResolvedValue(undefined);
140
+ await ensureDir('/tmp/new/nested/dir');
141
+ expect(mockFs.mkdir).toHaveBeenCalledWith('/tmp/new/nested/dir', { recursive: true });
142
+ });
143
+
144
+ it('throws a friendly message on mkdir error', async () => {
145
+ mockFs.mkdir.mockRejectedValue(Object.assign(new Error('permission denied'), { code: 'EACCES' }));
146
+ await expect(ensureDir('/tmp/restricted')).rejects.toThrow('permission denied: /tmp/restricted');
147
+ });
148
+ });
149
+
150
+ describe('copyFile', () => {
151
+ it('creates the dest directory then copies the file', async () => {
152
+ mockFs.mkdir.mockResolvedValue(undefined);
153
+ mockFs.copyFile.mockResolvedValue(undefined);
154
+ await copyFile('/tmp/src.txt', '/tmp/dest/copy.txt');
155
+ expect(mockFs.mkdir).toHaveBeenCalledWith('/tmp/dest', { recursive: true });
156
+ expect(mockFs.copyFile).toHaveBeenCalledWith('/tmp/src.txt', '/tmp/dest/copy.txt');
157
+ });
158
+
159
+ it('throws a friendly message when the source is not found', async () => {
160
+ mockFs.mkdir.mockResolvedValue(undefined);
161
+ mockFs.copyFile.mockRejectedValue(Object.assign(new Error('no such file'), { code: 'ENOENT' }));
162
+ await expect(copyFile('/tmp/missing.txt', '/tmp/dest.txt')).rejects.toThrow('file not found: /tmp/missing.txt');
163
+ });
164
+ });
165
+
166
+ describe('moveFile', () => {
167
+ it('creates the dest directory then renames the file', async () => {
168
+ mockFs.mkdir.mockResolvedValue(undefined);
169
+ mockFs.rename.mockResolvedValue(undefined);
170
+ await moveFile('/tmp/old.txt', '/tmp/moved/new.txt');
171
+ expect(mockFs.mkdir).toHaveBeenCalledWith('/tmp/moved', { recursive: true });
172
+ expect(mockFs.rename).toHaveBeenCalledWith('/tmp/old.txt', '/tmp/moved/new.txt');
173
+ });
174
+
175
+ it('throws a friendly message when the source is not found', async () => {
176
+ mockFs.mkdir.mockResolvedValue(undefined);
177
+ mockFs.rename.mockRejectedValue(Object.assign(new Error('no such file'), { code: 'ENOENT' }));
178
+ await expect(moveFile('/tmp/missing.txt', '/tmp/new.txt')).rejects.toThrow('file not found: /tmp/missing.txt');
179
+ });
180
+ });
181
+
182
+ describe('listDir', () => {
183
+ it('returns the directory entries as strings', async () => {
184
+ mockFs.readdir.mockResolvedValue(['a.txt', 'b.txt', 'subdir'] as any);
185
+ const result = await listDir('/tmp/mydir');
186
+ expect(result).toEqual(['a.txt', 'b.txt', 'subdir']);
187
+ expect(mockFs.readdir).toHaveBeenCalledWith('/tmp/mydir');
188
+ });
189
+
190
+ it('throws a friendly message when the directory is not found', async () => {
191
+ mockFs.readdir.mockRejectedValue(Object.assign(new Error('no such file or directory'), { code: 'ENOENT' }));
192
+ await expect(listDir('/tmp/missing')).rejects.toThrow('file not found: /tmp/missing');
193
+ });
194
+ });
@@ -0,0 +1,113 @@
1
+ import {
2
+ readFile as fsReadFile,
3
+ writeFile as fsWriteFile,
4
+ appendFile as fsAppendFile,
5
+ rm,
6
+ mkdir,
7
+ copyFile as fsCopyFile,
8
+ rename,
9
+ readdir,
10
+ access,
11
+ } from "node:fs/promises";
12
+ import { dirname } from "node:path";
13
+
14
+ function normalizeError(err: unknown, path: string): Error {
15
+ if (err instanceof Error && "code" in err) {
16
+ const code = (err as NodeJS.ErrnoException).code;
17
+ if (code === "ENOENT") return new Error(`file not found: ${path}`);
18
+ if (code === "EACCES") return new Error(`permission denied: ${path}`);
19
+ if (code === "EISDIR") return new Error(`path is a directory: ${path}`);
20
+ return new Error(`${code}: ${(err as Error).message}`);
21
+ }
22
+ return err instanceof Error ? err : new Error(String(err));
23
+ }
24
+
25
+ export async function readFile(path: string): Promise<string> {
26
+ try {
27
+ return await fsReadFile(path, "utf-8");
28
+ } catch (err) {
29
+ throw normalizeError(err, path);
30
+ }
31
+ }
32
+
33
+ export async function readJson<T = unknown>(path: string): Promise<T> {
34
+ const content = await readFile(path);
35
+ try {
36
+ return JSON.parse(content) as T;
37
+ } catch {
38
+ throw new Error(`invalid JSON in file: ${path}`);
39
+ }
40
+ }
41
+
42
+ export async function writeFile(path: string, content: string): Promise<void> {
43
+ try {
44
+ await mkdir(dirname(path), { recursive: true });
45
+ await fsWriteFile(path, content, "utf-8");
46
+ } catch (err) {
47
+ throw normalizeError(err, path);
48
+ }
49
+ }
50
+
51
+ export async function writeJson(path: string, data: unknown, indent = 2): Promise<void> {
52
+ await writeFile(path, JSON.stringify(data, null, indent));
53
+ }
54
+
55
+ export async function appendFile(path: string, content: string): Promise<void> {
56
+ try {
57
+ await mkdir(dirname(path), { recursive: true });
58
+ await fsAppendFile(path, content, "utf-8");
59
+ } catch (err) {
60
+ throw normalizeError(err, path);
61
+ }
62
+ }
63
+
64
+ export async function fileExists(path: string): Promise<boolean> {
65
+ try {
66
+ await access(path);
67
+ return true;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ export async function deleteFile(path: string): Promise<void> {
74
+ try {
75
+ await rm(path, { force: true });
76
+ } catch (err) {
77
+ throw normalizeError(err, path);
78
+ }
79
+ }
80
+
81
+ export async function ensureDir(path: string): Promise<void> {
82
+ try {
83
+ await mkdir(path, { recursive: true });
84
+ } catch (err) {
85
+ throw normalizeError(err, path);
86
+ }
87
+ }
88
+
89
+ export async function copyFile(src: string, dest: string): Promise<void> {
90
+ try {
91
+ await mkdir(dirname(dest), { recursive: true });
92
+ await fsCopyFile(src, dest);
93
+ } catch (err) {
94
+ throw normalizeError(err, src);
95
+ }
96
+ }
97
+
98
+ export async function moveFile(src: string, dest: string): Promise<void> {
99
+ try {
100
+ await mkdir(dirname(dest), { recursive: true });
101
+ await rename(src, dest);
102
+ } catch (err) {
103
+ throw normalizeError(err, src);
104
+ }
105
+ }
106
+
107
+ export async function listDir(path: string): Promise<string[]> {
108
+ try {
109
+ return await readdir(path);
110
+ } catch (err) {
111
+ throw normalizeError(err, path);
112
+ }
113
+ }
@@ -1,6 +1,7 @@
1
1
  import { clientCredentials } from "./lib/oauth.js";
2
2
  import { getClaimValues } from "./lib/token.js";
3
3
  import { httpGet, httpPost, httpPut, httpDelete } from "./lib/http.js";
4
+ import { readFile, readJson, writeFile, writeJson, appendFile, fileExists, deleteFile, ensureDir, copyFile, moveFile, listDir } from "./lib/file.js";
4
5
  import { basicAuth } from "./lib/basic-auth.js";
5
6
  import { bearerToken } from "./lib/bearer-token.js";
6
7
  // dtk:imports
@@ -42,6 +43,19 @@ class Suite {
42
43
  put: httpPut,
43
44
  delete: httpDelete,
44
45
  },
46
+ file: {
47
+ read: readFile,
48
+ readJson,
49
+ write: writeFile,
50
+ writeJson,
51
+ append: appendFile,
52
+ exists: fileExists,
53
+ delete: deleteFile,
54
+ ensureDir,
55
+ copy: copyFile,
56
+ move: moveFile,
57
+ list: listDir,
58
+ },
45
59
  services: {
46
60
  // dtk:services
47
61
  },
@@ -22,6 +22,19 @@ export interface StepContext {
22
22
  put<TBody, TResponse>(url: string, body: TBody, options?: HttpOptions): Promise<TResponse>;
23
23
  delete(url: string, options?: HttpOptions): Promise<number>;
24
24
  };
25
+ file: {
26
+ read(path: string): Promise<string>;
27
+ readJson<T = unknown>(path: string): Promise<T>;
28
+ write(path: string, content: string): Promise<void>;
29
+ writeJson(path: string, data: unknown, indent?: number): Promise<void>;
30
+ append(path: string, content: string): Promise<void>;
31
+ exists(path: string): Promise<boolean>;
32
+ delete(path: string): Promise<void>;
33
+ ensureDir(path: string): Promise<void>;
34
+ copy(src: string, dest: string): Promise<void>;
35
+ move(src: string, dest: string): Promise<void>;
36
+ list(path: string): Promise<string[]>;
37
+ };
25
38
  services: {
26
39
  // dtk:service-types
27
40
  };