@jordanalec/dtk 1.0.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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +730 -0
  3. package/dist/add.js +89 -0
  4. package/dist/cli.js +12 -0
  5. package/dist/init.js +20 -0
  6. package/dist/utils/patch.js +8 -0
  7. package/package.json +52 -0
  8. package/templates/init/.env.template +2 -0
  9. package/templates/init/GUIDE.md +543 -0
  10. package/templates/init/README.md +59 -0
  11. package/templates/init/jest.config.ts +19 -0
  12. package/templates/init/package.json +22 -0
  13. package/templates/init/src/lib/auth.test.ts +48 -0
  14. package/templates/init/src/lib/basic-auth.ts +6 -0
  15. package/templates/init/src/lib/bearer-token.ts +5 -0
  16. package/templates/init/src/lib/http.test.ts +197 -0
  17. package/templates/init/src/lib/http.ts +81 -0
  18. package/templates/init/src/lib/oauth.test.ts +61 -0
  19. package/templates/init/src/lib/oauth.ts +15 -0
  20. package/templates/init/src/lib/token.ts +5 -0
  21. package/templates/init/src/load-env.ts +4 -0
  22. package/templates/init/src/runbooks/example.ts +33 -0
  23. package/templates/init/src/suite.test.ts +94 -0
  24. package/templates/init/src/suite.ts +70 -0
  25. package/templates/init/src/types/http.ts +12 -0
  26. package/templates/init/src/types/oauth.ts +13 -0
  27. package/templates/init/src/types/suite.ts +37 -0
  28. package/templates/init/tsconfig.json +14 -0
  29. package/templates/init/tsconfig.test.json +8 -0
  30. package/templates/plugins/aws-dynamo/env.txt +2 -0
  31. package/templates/plugins/aws-dynamo/example.ts +75 -0
  32. package/templates/plugins/aws-dynamo/plugin.json +38 -0
  33. package/templates/plugins/aws-dynamo/service.test.ts +180 -0
  34. package/templates/plugins/aws-dynamo/service.ts +73 -0
  35. package/templates/plugins/aws-dynamo/types.ts +29 -0
  36. package/templates/plugins/aws-s3/env.txt +2 -0
  37. package/templates/plugins/aws-s3/example.ts +41 -0
  38. package/templates/plugins/aws-s3/plugin.json +38 -0
  39. package/templates/plugins/aws-s3/service.test.ts +150 -0
  40. package/templates/plugins/aws-s3/service.ts +43 -0
  41. package/templates/plugins/aws-s3/types.ts +28 -0
  42. package/templates/plugins/aws-sns/env.txt +2 -0
  43. package/templates/plugins/aws-sns/example.ts +18 -0
  44. package/templates/plugins/aws-sns/plugin.json +37 -0
  45. package/templates/plugins/aws-sns/service.test.ts +79 -0
  46. package/templates/plugins/aws-sns/service.ts +28 -0
  47. package/templates/plugins/aws-sns/types.ts +8 -0
  48. package/templates/plugins/aws-sqs/env.txt +2 -0
  49. package/templates/plugins/aws-sqs/example.ts +16 -0
  50. package/templates/plugins/aws-sqs/plugin.json +37 -0
  51. package/templates/plugins/aws-sqs/service.test.ts +63 -0
  52. package/templates/plugins/aws-sqs/service.ts +27 -0
  53. package/templates/plugins/aws-sqs/types.ts +8 -0
  54. package/templates/plugins/open-ai/env.txt +1 -0
  55. package/templates/plugins/open-ai/example.ts +27 -0
  56. package/templates/plugins/open-ai/plugin.json +36 -0
  57. package/templates/plugins/open-ai/service.test.ts +55 -0
  58. package/templates/plugins/open-ai/service.ts +26 -0
  59. package/templates/plugins/open-ai/types.ts +61 -0
  60. package/templates/plugins/tsconfig.json +11 -0
@@ -0,0 +1,197 @@
1
+ import { httpGet, httpPost, httpPut, httpDelete } from './http.js';
2
+
3
+ jest.mock('axios');
4
+ import axios from 'axios';
5
+
6
+ const mockAxios = axios as jest.Mocked<typeof axios>;
7
+
8
+ beforeEach(() => {
9
+ jest.clearAllMocks();
10
+ mockAxios.isAxiosError.mockReturnValue(false);
11
+ });
12
+
13
+ describe('httpGet', () => {
14
+ it('returns the response data on success', async () => {
15
+ mockAxios.get.mockResolvedValue({ data: { id: 1, name: 'item' } });
16
+ const result = await httpGet<{ id: number; name: string }>('https://api.example.com/item');
17
+ expect(result).toEqual({ id: 1, name: 'item' });
18
+ });
19
+
20
+ it('passes headers through to axios', async () => {
21
+ mockAxios.get.mockResolvedValue({ data: {} });
22
+ await httpGet('https://api.example.com/item', { headers: { Authorization: 'Bearer tok' } });
23
+ expect(mockAxios.get).toHaveBeenCalledWith('https://api.example.com/item', {
24
+ headers: { Authorization: 'Bearer tok' },
25
+ });
26
+ });
27
+
28
+ it('normalizes an axios error using response.data.Detail', async () => {
29
+ const axiosError = { response: { status: 404, data: { Detail: 'Not found' } }, message: 'Request failed' };
30
+ mockAxios.get.mockRejectedValue(axiosError);
31
+ mockAxios.isAxiosError.mockReturnValue(true);
32
+ await expect(httpGet('https://api.example.com/item')).rejects.toThrow('HTTP 404: Not found');
33
+ });
34
+
35
+ it('normalizes an axios error using response.data.message when Detail is absent', async () => {
36
+ const axiosError = { response: { status: 500, data: { message: 'Internal error' } }, message: 'Request failed' };
37
+ mockAxios.get.mockRejectedValue(axiosError);
38
+ mockAxios.isAxiosError.mockReturnValue(true);
39
+ await expect(httpGet('https://api.example.com/item')).rejects.toThrow('HTTP 500: Internal error');
40
+ });
41
+
42
+ it('falls back to err.message when response data has no recognised error field', async () => {
43
+ const axiosError = { response: { status: 503, data: {} }, message: 'Service unavailable' };
44
+ mockAxios.get.mockRejectedValue(axiosError);
45
+ mockAxios.isAxiosError.mockReturnValue(true);
46
+ await expect(httpGet('https://api.example.com/item')).rejects.toThrow('HTTP 503: Service unavailable');
47
+ });
48
+
49
+ it('rethrows a plain Error unchanged when it is not an axios error', async () => {
50
+ const err = new Error('network failure');
51
+ mockAxios.get.mockRejectedValue(err);
52
+ await expect(httpGet('https://api.example.com/item')).rejects.toThrow('network failure');
53
+ });
54
+ });
55
+
56
+ describe('httpPost', () => {
57
+ it('returns the response data on success', async () => {
58
+ mockAxios.post.mockResolvedValue({ data: { created: true } });
59
+ const result = await httpPost('https://api.example.com/items', { name: 'new' });
60
+ expect(result).toEqual({ created: true });
61
+ });
62
+
63
+ it('passes the request body and headers to axios', async () => {
64
+ mockAxios.post.mockResolvedValue({ data: {} });
65
+ await httpPost('https://api.example.com/items', { key: 'value' }, { headers: { 'Content-Type': 'application/json' } });
66
+ expect(mockAxios.post).toHaveBeenCalledWith(
67
+ 'https://api.example.com/items',
68
+ { key: 'value' },
69
+ { headers: { 'Content-Type': 'application/json' } }
70
+ );
71
+ });
72
+
73
+ });
74
+
75
+ describe('httpPut', () => {
76
+ it('returns the response data on success', async () => {
77
+ mockAxios.put.mockResolvedValue({ data: { updated: true } });
78
+ const result = await httpPut('https://api.example.com/items/1', { name: 'updated' });
79
+ expect(result).toEqual({ updated: true });
80
+ });
81
+
82
+ it('passes the request body and headers to axios', async () => {
83
+ mockAxios.put.mockResolvedValue({ data: {} });
84
+ await httpPut('https://api.example.com/items/1', { key: 'value' }, { headers: { 'Content-Type': 'application/json' } });
85
+ expect(mockAxios.put).toHaveBeenCalledWith(
86
+ 'https://api.example.com/items/1',
87
+ { key: 'value' },
88
+ { headers: { 'Content-Type': 'application/json' } }
89
+ );
90
+ });
91
+
92
+ });
93
+
94
+ describe('httpDelete', () => {
95
+ it('returns the response status code on success', async () => {
96
+ mockAxios.delete.mockResolvedValue({ status: 204 });
97
+ const status = await httpDelete('https://api.example.com/item/1');
98
+ expect(status).toBe(204);
99
+ });
100
+ });
101
+
102
+ describe('retry', () => {
103
+ it('retries and succeeds when retryOn returns true', async () => {
104
+ mockAxios.get
105
+ .mockRejectedValueOnce(new Error('transient'))
106
+ .mockResolvedValueOnce({ data: { ok: true } });
107
+
108
+ const result = await httpGet('https://api.example.com/item', {
109
+ retry: { attempts: 1, delayMs: 0, retryOn: () => true },
110
+ });
111
+
112
+ expect(result).toEqual({ ok: true });
113
+ expect(mockAxios.get).toHaveBeenCalledTimes(2);
114
+ });
115
+
116
+ it('does not retry when retryOn returns false', async () => {
117
+ mockAxios.get.mockRejectedValue(new Error('fatal'));
118
+
119
+ await expect(
120
+ httpGet('https://api.example.com/item', {
121
+ retry: { attempts: 3, delayMs: 0, retryOn: () => false },
122
+ })
123
+ ).rejects.toThrow('fatal');
124
+
125
+ expect(mockAxios.get).toHaveBeenCalledTimes(1);
126
+ });
127
+
128
+ it('exhausts all attempts and throws when retryOn always returns true', async () => {
129
+ mockAxios.get.mockRejectedValue(new Error('always fails'));
130
+
131
+ await expect(
132
+ httpGet('https://api.example.com/item', {
133
+ retry: { attempts: 2, delayMs: 0, retryOn: () => true },
134
+ })
135
+ ).rejects.toThrow('always fails');
136
+
137
+ expect(mockAxios.get).toHaveBeenCalledTimes(3);
138
+ });
139
+
140
+ it('does not retry when no retryOn predicate is provided', async () => {
141
+ mockAxios.get.mockRejectedValue(new Error('no predicate'));
142
+
143
+ await expect(
144
+ httpGet('https://api.example.com/item', {
145
+ retry: { attempts: 3, delayMs: 0 },
146
+ })
147
+ ).rejects.toThrow('no predicate');
148
+
149
+ expect(mockAxios.get).toHaveBeenCalledTimes(1);
150
+ });
151
+
152
+ it('passes the raw error to the retryOn predicate', async () => {
153
+ const err = new Error('check me');
154
+ mockAxios.get.mockRejectedValue(err);
155
+ const retryOn = jest.fn().mockReturnValue(false);
156
+
157
+ await expect(
158
+ httpGet('https://api.example.com/item', {
159
+ retry: { attempts: 1, delayMs: 0, retryOn },
160
+ })
161
+ ).rejects.toThrow();
162
+
163
+ expect(retryOn).toHaveBeenCalledWith(err);
164
+ });
165
+
166
+ it('respects exponential backoff and maxDelayMs across multiple retries', async () => {
167
+ mockAxios.get
168
+ .mockRejectedValueOnce(new Error('transient'))
169
+ .mockRejectedValueOnce(new Error('transient'))
170
+ .mockResolvedValueOnce({ data: 'recovered' });
171
+
172
+ const result = await httpGet('https://api.example.com/item', {
173
+ retry: {
174
+ attempts: 2,
175
+ delayMs: 0,
176
+ backoff: 'exponential',
177
+ maxDelayMs: 100,
178
+ retryOn: () => true,
179
+ },
180
+ });
181
+
182
+ expect(result).toBe('recovered');
183
+ expect(mockAxios.get).toHaveBeenCalledTimes(3);
184
+ });
185
+
186
+ it('does not retry when attempts is 0', async () => {
187
+ mockAxios.get.mockRejectedValue(new Error('fail'));
188
+
189
+ await expect(
190
+ httpGet('https://api.example.com/item', {
191
+ retry: { attempts: 0, delayMs: 0, retryOn: () => true },
192
+ })
193
+ ).rejects.toThrow('fail');
194
+
195
+ expect(mockAxios.get).toHaveBeenCalledTimes(1);
196
+ });
197
+ });
@@ -0,0 +1,81 @@
1
+ import axios from "axios";
2
+ import type { HttpOptions, RetryConfig } from "../types/http.js";
3
+
4
+ export type { HttpOptions };
5
+
6
+ function normalizeError(err: unknown): Error {
7
+ if (axios.isAxiosError(err)) {
8
+ const status = err.response?.status;
9
+ const data = err.response?.data;
10
+ const detail = data?.Detail ?? data?.message ?? err.message;
11
+ return new Error(`HTTP ${status}: ${detail}`);
12
+ }
13
+ return err instanceof Error ? err : new Error(String(err));
14
+ }
15
+
16
+ function resolveDelay(attempt: number, config: RetryConfig): number {
17
+ const base = config.delayMs ?? 1000;
18
+ const delay =
19
+ config.backoff === "exponential" ? base * Math.pow(2, attempt) : base;
20
+ return config.maxDelayMs !== undefined
21
+ ? Math.min(delay, config.maxDelayMs)
22
+ : delay;
23
+ }
24
+
25
+ async function executeWithRetry<T>(
26
+ fn: () => Promise<T>,
27
+ retry?: RetryConfig
28
+ ): Promise<T> {
29
+ const maxAttempts = retry && retry.attempts > 0 ? retry.attempts : 0;
30
+ let lastErr: unknown;
31
+
32
+ for (let attempt = 0; attempt <= maxAttempts; attempt++) {
33
+ try {
34
+ return await fn();
35
+ } catch (err) {
36
+ lastErr = err;
37
+ const isLast = attempt === maxAttempts;
38
+ const shouldRetry = !isLast && !!retry?.retryOn?.(err);
39
+ if (!shouldRetry) break;
40
+ await new Promise((r) => setTimeout(r, resolveDelay(attempt, retry!)));
41
+ }
42
+ }
43
+
44
+ throw normalizeError(lastErr);
45
+ }
46
+
47
+ export async function httpGet<T>(url: string, options?: HttpOptions): Promise<T> {
48
+ return executeWithRetry(
49
+ () => axios.get<T>(url, { headers: options?.headers }).then((r) => r.data),
50
+ options?.retry
51
+ );
52
+ }
53
+
54
+ export async function httpPost<TBody, TResponse>(
55
+ url: string,
56
+ body: TBody,
57
+ options?: HttpOptions
58
+ ): Promise<TResponse> {
59
+ return executeWithRetry(
60
+ () => axios.post<TResponse>(url, body, { headers: options?.headers }).then((r) => r.data),
61
+ options?.retry
62
+ );
63
+ }
64
+
65
+ export async function httpPut<TBody, TResponse>(
66
+ url: string,
67
+ body: TBody,
68
+ options?: HttpOptions
69
+ ): Promise<TResponse> {
70
+ return executeWithRetry(
71
+ () => axios.put<TResponse>(url, body, { headers: options?.headers }).then((r) => r.data),
72
+ options?.retry
73
+ );
74
+ }
75
+
76
+ export async function httpDelete(url: string, options?: HttpOptions): Promise<number> {
77
+ return executeWithRetry(
78
+ () => axios.delete(url, { headers: options?.headers }).then((r) => r.status),
79
+ options?.retry
80
+ );
81
+ }
@@ -0,0 +1,61 @@
1
+ import { clientCredentials } from './oauth.js';
2
+
3
+ jest.mock('./http.js');
4
+ import { httpPost } from './http.js';
5
+
6
+ const mockHttpPost = jest.mocked(httpPost);
7
+
8
+ beforeEach(() => {
9
+ jest.clearAllMocks();
10
+ });
11
+
12
+ describe('clientCredentials', () => {
13
+ const config = {
14
+ clientId: 'my-client',
15
+ clientSecret: 'my-secret',
16
+ tokenUrl: 'https://auth.example.com/token',
17
+ };
18
+
19
+ const tokenResponse = {
20
+ access_token: 'tok123',
21
+ token_type: 'Bearer',
22
+ expires_in: 3600,
23
+ };
24
+
25
+ it('posts to the tokenUrl and returns the token response', async () => {
26
+ mockHttpPost.mockResolvedValue(tokenResponse);
27
+ const result = await clientCredentials(config);
28
+ expect(result).toEqual(tokenResponse);
29
+ expect(mockHttpPost).toHaveBeenCalledWith(
30
+ config.tokenUrl,
31
+ expect.any(URLSearchParams),
32
+ { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
33
+ );
34
+ });
35
+
36
+ it('sends client_id, client_secret, and grant_type in the body', async () => {
37
+ mockHttpPost.mockResolvedValue(tokenResponse);
38
+ await clientCredentials(config);
39
+
40
+ const body = mockHttpPost.mock.calls[0][1] as URLSearchParams;
41
+ expect(body.get('grant_type')).toBe('client_credentials');
42
+ expect(body.get('client_id')).toBe('my-client');
43
+ expect(body.get('client_secret')).toBe('my-secret');
44
+ });
45
+
46
+ it('includes scope in the body when provided', async () => {
47
+ mockHttpPost.mockResolvedValue(tokenResponse);
48
+ await clientCredentials({ ...config, scope: 'openid profile' });
49
+
50
+ const body = mockHttpPost.mock.calls[0][1] as URLSearchParams;
51
+ expect(body.get('scope')).toBe('openid profile');
52
+ });
53
+
54
+ it('omits scope from the body when not provided', async () => {
55
+ mockHttpPost.mockResolvedValue(tokenResponse);
56
+ await clientCredentials(config);
57
+
58
+ const body = mockHttpPost.mock.calls[0][1] as URLSearchParams;
59
+ expect(body.has('scope')).toBe(false);
60
+ });
61
+ });
@@ -0,0 +1,15 @@
1
+ import { httpPost } from "./http.js";
2
+ import type { OAuthConfig, TokenResponse } from "../types/oauth.js";
3
+
4
+ export async function clientCredentials(config: OAuthConfig): Promise<TokenResponse> {
5
+ const params = new URLSearchParams({
6
+ grant_type: "client_credentials",
7
+ client_id: config.clientId,
8
+ client_secret: config.clientSecret,
9
+ ...(config.scope ? { scope: config.scope } : {}),
10
+ });
11
+
12
+ return httpPost<URLSearchParams, TokenResponse>(config.tokenUrl, params, {
13
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
14
+ });
15
+ }
@@ -0,0 +1,5 @@
1
+ export function getClaimValues(token: string): Record<string, string> {
2
+ const payload = token.split(".")[1];
3
+ const decoded = Buffer.from(payload, "base64").toString("utf-8");
4
+ return JSON.parse(decoded);
5
+ }
@@ -0,0 +1,4 @@
1
+ import * as dotenv from "dotenv";
2
+
3
+ dotenv.config({ path: ".env" });
4
+ dotenv.config({ path: ".env.local", override: true });
@@ -0,0 +1,33 @@
1
+ import axios from 'axios';
2
+ import "../load-env.js";
3
+ import { suite } from "../suite.js";
4
+
5
+ interface GitHubUser {
6
+ login: string;
7
+ name: string;
8
+ public_repos: number;
9
+ followers: number;
10
+ }
11
+
12
+ await suite()
13
+ .step("fetch-github-user", async (ctx) => {
14
+ const user = await ctx.http.get<GitHubUser>(
15
+ "https://api.github.com/users/torvalds",
16
+ {
17
+ headers: { "User-Agent": "dtk-runbook" },
18
+ retry: {
19
+ attempts: 3,
20
+ backoff: "exponential",
21
+ delayMs: 500,
22
+ maxDelayMs: 5000,
23
+ retryOn: (err) =>
24
+ axios.isAxiosError(err) &&
25
+ [429, 503].includes(err.response?.status ?? 0),
26
+ },
27
+ }
28
+ );
29
+ console.log(`login: ${user.login}`);
30
+ console.log(`name: ${user.name}`);
31
+ return user;
32
+ })
33
+ .run("throwOnError");
@@ -0,0 +1,94 @@
1
+ import { suite } from './suite.js';
2
+
3
+ // suppress the [OK] / [FAIL] console output during tests
4
+ beforeEach(() => {
5
+ jest.spyOn(console, 'log').mockImplementation(() => {});
6
+ jest.spyOn(console, 'error').mockImplementation(() => {});
7
+ });
8
+
9
+ afterEach(() => {
10
+ jest.restoreAllMocks();
11
+ });
12
+
13
+ describe('suite runner', () => {
14
+ it('executes steps in the order they were defined', async () => {
15
+ const order: number[] = [];
16
+ await suite()
17
+ .step('first', async () => { order.push(1); })
18
+ .step('second', async () => { order.push(2); })
19
+ .step('third', async () => { order.push(3); })
20
+ .run("stopOnError");
21
+ expect(order).toEqual([1, 2, 3]);
22
+ });
23
+
24
+ it('stores each step return value in ctx.outputs under the step name', async () => {
25
+ let captured: unknown;
26
+ await suite()
27
+ .step('produce', async () => ({ value: 42 }))
28
+ .step('consume', async (ctx) => { captured = ctx.outputs['produce']; })
29
+ .run("throwOnError");
30
+ expect(captured).toEqual({ value: 42 });
31
+ });
32
+
33
+ it('makes all prior outputs available to each step', async () => {
34
+ const seen: unknown[] = [];
35
+ await suite()
36
+ .step('a', async () => 'alpha')
37
+ .step('b', async () => 'beta')
38
+ .step('c', async (ctx) => { seen.push(ctx.outputs['a'], ctx.outputs['b']); })
39
+ .run("throwOnError");
40
+ expect(seen).toEqual(['alpha', 'beta']);
41
+ });
42
+
43
+ describe('ThrowOnError', () => {
44
+ it('throws when a step fails', async () => {
45
+ await expect(
46
+ suite()
47
+ .step('fail', async () => { throw new Error('boom'); })
48
+ .run("throwOnError")
49
+ ).rejects.toThrow('boom');
50
+ });
51
+
52
+ it('does not execute steps after the failure', async () => {
53
+ const ran: string[] = [];
54
+ await suite()
55
+ .step('ok', async () => { ran.push('ok'); })
56
+ .step('fail', async () => { throw new Error('boom'); })
57
+ .step('skipped', async () => { ran.push('skipped'); })
58
+ .run("throwOnError")
59
+ .catch(() => {});
60
+ expect(ran).toEqual(['ok']);
61
+ });
62
+ });
63
+
64
+ describe('StopOnError', () => {
65
+ it('does not throw when a step fails', async () => {
66
+ await expect(
67
+ suite()
68
+ .step('fail', async () => { throw new Error('non-fatal'); })
69
+ .run("stopOnError")
70
+ ).resolves.toBeUndefined();
71
+ });
72
+
73
+ it('stops executing after the failure (does not skip to next step)', async () => {
74
+ const ran: string[] = [];
75
+ await suite()
76
+ .step('ok', async () => { ran.push('ok'); })
77
+ .step('fail', async () => { throw new Error('stop'); })
78
+ .step('skipped', async () => { ran.push('skipped'); })
79
+ .run("stopOnError");
80
+ expect(ran).toEqual(['ok']);
81
+ expect(ran).not.toContain('skipped');
82
+ });
83
+
84
+ it('logs the failure to console.error', async () => {
85
+ await suite()
86
+ .step('fail', async () => { throw new Error('something broke'); })
87
+ .run("stopOnError");
88
+ expect(console.error).toHaveBeenCalledWith(
89
+ expect.stringContaining('something broke')
90
+ );
91
+ });
92
+ });
93
+
94
+ });
@@ -0,0 +1,70 @@
1
+ import { clientCredentials } from "./lib/oauth.js";
2
+ import { getClaimValues } from "./lib/token.js";
3
+ import { httpGet, httpPost, httpPut, httpDelete } from "./lib/http.js";
4
+ import { basicAuth } from "./lib/basic-auth.js";
5
+ import { bearerToken } from "./lib/bearer-token.js";
6
+ // dtk:imports
7
+
8
+ import type { OAuthConfig, BasicAuthConfig, BearerTokenConfig, StepContext, StepFn, Step, SuiteRunOption } from "./types/suite.js";
9
+
10
+ export type { SuiteRunOption };
11
+
12
+ class Suite {
13
+ private steps: Step[] = [];
14
+ private oauthConfig?: OAuthConfig;
15
+ private basicAuthConfig?: BasicAuthConfig;
16
+ private bearerTokenConfig?: BearerTokenConfig;
17
+ // dtk:configs
18
+
19
+ oauth(config: OAuthConfig): this { this.oauthConfig = config; return this; }
20
+ basicAuth(config: BasicAuthConfig): this { this.basicAuthConfig = config; return this; }
21
+ bearerToken(config: BearerTokenConfig): this { this.bearerTokenConfig = config; return this; }
22
+ // dtk:methods
23
+
24
+ step(name: string, fn: StepFn): this {
25
+ this.steps.push({ name, fn });
26
+ return this;
27
+ }
28
+
29
+ private buildContext(outputs: Record<string, unknown>): StepContext {
30
+ const oauthConfig = this.oauthConfig;
31
+ return {
32
+ outputs,
33
+ auth: {
34
+ clientCredentials: (config?) => clientCredentials(config ?? oauthConfig!),
35
+ getClaimValues,
36
+ basicAuth: (config?) => basicAuth(config ?? this.basicAuthConfig!),
37
+ bearerToken: (config?) => bearerToken(config ?? this.bearerTokenConfig!),
38
+ },
39
+ http: {
40
+ get: httpGet,
41
+ post: httpPost,
42
+ put: httpPut,
43
+ delete: httpDelete,
44
+ },
45
+ services: {
46
+ // dtk:services
47
+ },
48
+ };
49
+ }
50
+
51
+ async run(option: SuiteRunOption): Promise<void> {
52
+ const outputs: Record<string, unknown> = {};
53
+ const ctx = this.buildContext(outputs);
54
+ for (const step of this.steps) {
55
+ try {
56
+ outputs[step.name] = await step.fn(ctx);
57
+ console.log(`[OK] ${step.name}`);
58
+ } catch (err) {
59
+ const message = err instanceof Error ? err.message : String(err);
60
+ console.error(`[FAIL] ${step.name}: ${message}`);
61
+ if (option === "throwOnError") throw err;
62
+ else break;
63
+ }
64
+ }
65
+ }
66
+ }
67
+
68
+ export function suite(): Suite {
69
+ return new Suite();
70
+ }
@@ -0,0 +1,12 @@
1
+ export interface RetryConfig {
2
+ attempts: number;
3
+ backoff?: "fixed" | "exponential";
4
+ delayMs?: number;
5
+ maxDelayMs?: number;
6
+ retryOn?: (err: unknown) => boolean;
7
+ }
8
+
9
+ export interface HttpOptions {
10
+ headers?: Record<string, string>;
11
+ retry?: RetryConfig;
12
+ }
@@ -0,0 +1,13 @@
1
+ export interface OAuthConfig {
2
+ clientId: string;
3
+ clientSecret: string;
4
+ tokenUrl: string;
5
+ scope?: string;
6
+ }
7
+
8
+ export interface TokenResponse {
9
+ access_token: string;
10
+ token_type: string;
11
+ expires_in?: number;
12
+ scope?: string;
13
+ }
@@ -0,0 +1,37 @@
1
+ import type { HttpOptions } from "./http.js";
2
+ import type { OAuthConfig, TokenResponse } from "./oauth.js";
3
+ // dtk:type-imports
4
+
5
+ export type { HttpOptions };
6
+
7
+ export interface BasicAuthConfig { username: string; password: string; }
8
+ export interface BearerTokenConfig { token: string; prefix: string; }
9
+ export type { OAuthConfig, TokenResponse };
10
+
11
+ export interface StepContext {
12
+ outputs: Record<string, unknown>;
13
+ auth: {
14
+ clientCredentials(config?: OAuthConfig): Promise<TokenResponse>;
15
+ getClaimValues(token: string): Record<string, string>;
16
+ basicAuth(config?: BasicAuthConfig): Promise<string>;
17
+ bearerToken(config?: BearerTokenConfig): Promise<string>;
18
+ };
19
+ http: {
20
+ get<T>(url: string, options?: HttpOptions): Promise<T>;
21
+ post<TBody, TResponse>(url: string, body: TBody, options?: HttpOptions): Promise<TResponse>;
22
+ put<TBody, TResponse>(url: string, body: TBody, options?: HttpOptions): Promise<TResponse>;
23
+ delete(url: string, options?: HttpOptions): Promise<number>;
24
+ };
25
+ services: {
26
+ // dtk:service-types
27
+ };
28
+ }
29
+
30
+ export type StepFn = (ctx: StepContext) => Promise<unknown>;
31
+
32
+ export interface Step {
33
+ name: string;
34
+ fn: StepFn;
35
+ }
36
+
37
+ export type SuiteRunOption = "throwOnError" | "stopOnError";
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "outDir": "./dist",
8
+ "rootDir": "./src",
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "types": ["node", "jest"]
12
+ },
13
+ "include": ["src"]
14
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "module": "CommonJS",
5
+ "moduleResolution": "node",
6
+ "types": ["jest", "node"]
7
+ }
8
+ }
@@ -0,0 +1,2 @@
1
+ DYNAMO_TABLE_NAME=
2
+ AWS_REGION=