@jordanalec/dtk 1.0.6 → 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
@@ -286,6 +286,57 @@ AWS credentials are resolved from the environment via the SDK default provider c
286
286
 
287
287
  ---
288
288
 
289
+ ### redis
290
+
291
+ Reads and writes data in a Redis cache.
292
+
293
+ ```bash
294
+ dtk add redis
295
+ ```
296
+
297
+ Env vars appended to `.env.template`:
298
+
299
+ ```
300
+ REDIS_URL=redis://localhost:6379
301
+ ```
302
+
303
+ Usage:
304
+
305
+ ```ts
306
+ await suite()
307
+ .redis({ url: process.env.REDIS_URL! })
308
+ .step("write", async (ctx) => {
309
+ await ctx.services.redis.set("key", "value", 3600);
310
+ })
311
+ .step("read", async (ctx) => {
312
+ const value = await ctx.services.redis.get("key");
313
+ console.log("value:", value);
314
+ return value;
315
+ })
316
+ .step("disconnect", async (ctx) => {
317
+ await ctx.services.redis.quit();
318
+ })
319
+ .run("stopOnError");
320
+ ```
321
+
322
+ > **The `disconnect` step is required.** The Redis client holds an open TCP connection. Without calling `quit()`, the Node.js process will hang indefinitely after the runbook finishes. Always use `stopOnError` (not `throwOnError`) so that the `disconnect` step is guaranteed to run even when an earlier step fails.
323
+
324
+ Available methods on `ctx.services.redis`:
325
+
326
+ | Method | Description |
327
+ |---|---|
328
+ | `get(key)` | Returns the string value or `null` if the key does not exist |
329
+ | `set(key, value, ttlSeconds?)` | Sets a key, with an optional TTL in seconds |
330
+ | `del(key)` | Deletes a key, returns the number of keys removed |
331
+ | `exists(key)` | Returns `true` if the key exists |
332
+ | `expire(key, ttlSeconds)` | Sets a TTL on an existing key, returns `true` if applied |
333
+ | `hset(key, field, value)` | Sets a field on a hash, returns the number of new fields added |
334
+ | `hget(key, field)` | Returns a hash field value or `null` |
335
+ | `keys(pattern)` | Returns all keys matching a glob pattern (e.g. `user:*`) |
336
+ | `quit()` | Closes the connection -- must be called at the end of every runbook |
337
+
338
+ ---
339
+
289
340
  ### open-ai
290
341
 
291
342
  Lists models and sends responses via the OpenAI API.
@@ -728,4 +779,5 @@ templates/
728
779
  aws-dynamo/
729
780
  aws-s3/
730
781
  open-ai/
782
+ redis/
731
783
  ```
package/dist/add.js CHANGED
@@ -12,6 +12,7 @@ const PLUGIN_MAP = {
12
12
  'aws-dynamo': 'aws-dynamo',
13
13
  'aws-s3': 'aws-s3',
14
14
  'open-ai': 'open-ai',
15
+ 'redis': 'redis',
15
16
  };
16
17
  export const addCommand = new Command('add')
17
18
  .description('Add a service plugin to the project')
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.6",
3
+ "version": "1.1.0",
4
4
  "description": "CLI scaffolding tool for generating self-contained TypeScript runbook projects",
5
5
  "keywords": [
6
6
  "cli",
@@ -45,6 +45,7 @@
45
45
  "axios": "^1.6.0",
46
46
  "dotenv": "^16.4.0",
47
47
  "jest": "^30.3.0",
48
+ "redis": "^4.7.1",
48
49
  "ts-jest": "^29.4.9",
49
50
  "tsx": "^4.0.0",
50
51
  "typescript": "^5.0.0"
@@ -382,6 +382,57 @@ AWS credentials are picked up automatically from `AWS_ACCESS_KEY_ID` and `AWS_SE
382
382
 
383
383
  ---
384
384
 
385
+ ### redis
386
+
387
+ Reads and writes data in a Redis cache.
388
+
389
+ ```bash
390
+ dtk add redis
391
+ ```
392
+
393
+ Required env vars:
394
+
395
+ ```
396
+ REDIS_URL=redis://localhost:6379
397
+ ```
398
+
399
+ Usage:
400
+
401
+ ```ts
402
+ await suite()
403
+ .redis({ url: process.env.REDIS_URL! })
404
+ .step("write", async (ctx) => {
405
+ await ctx.services.redis.set("key", "value", 3600);
406
+ })
407
+ .step("read", async (ctx) => {
408
+ const value = await ctx.services.redis.get("key");
409
+ console.log("value:", value);
410
+ return value;
411
+ })
412
+ .step("disconnect", async (ctx) => {
413
+ await ctx.services.redis.quit();
414
+ })
415
+ .run("stopOnError");
416
+ ```
417
+
418
+ > **The `disconnect` step is required.** The Redis client holds an open TCP connection. Without calling `quit()`, the Node.js process will hang indefinitely after the runbook finishes. Always use `stopOnError` (not `throwOnError`) so that the `disconnect` step is guaranteed to run even when an earlier step fails.
419
+
420
+ Available methods on `ctx.services.redis`:
421
+
422
+ | Method | Description |
423
+ |---|---|
424
+ | `get(key)` | Returns the string value or `null` if the key does not exist |
425
+ | `set(key, value, ttlSeconds?)` | Sets a key, with an optional TTL in seconds |
426
+ | `del(key)` | Deletes a key, returns the number of keys removed |
427
+ | `exists(key)` | Returns `true` if the key exists |
428
+ | `expire(key, ttlSeconds)` | Sets a TTL on an existing key, returns `true` if applied |
429
+ | `hset(key, field, value)` | Sets a field on a hash, returns the number of new fields added |
430
+ | `hget(key, field)` | Returns a hash field value or `null` |
431
+ | `keys(pattern)` | Returns all keys matching a glob pattern (e.g. `user:*`) |
432
+ | `quit()` | Closes the connection -- must be called at the end of every runbook |
433
+
434
+ ---
435
+
385
436
  ### open-ai
386
437
 
387
438
  Lists models and sends responses via the OpenAI API.
@@ -0,0 +1 @@
1
+ REDIS_URL=
@@ -0,0 +1,37 @@
1
+ import "../load-env.js";
2
+ import { suite } from "../suite.js";
3
+
4
+ await suite()
5
+ .redis({
6
+ url: process.env.REDIS_URL!,
7
+ })
8
+ .step("write-session", async (ctx) => {
9
+ await ctx.services.redis.set("session:abc123", JSON.stringify({ userId: 42, role: "admin" }), 3600);
10
+ console.log("session written with 1h TTL");
11
+ })
12
+ .step("read-session", async (ctx) => {
13
+ const raw = await ctx.services.redis.get("session:abc123");
14
+ const session = raw ? JSON.parse(raw) : null;
15
+ console.log("session:", session);
16
+ return session;
17
+ })
18
+ .step("check-existence", async (ctx) => {
19
+ const exists = await ctx.services.redis.exists("session:abc123");
20
+ console.log("session exists:", exists);
21
+ return exists;
22
+ })
23
+ .step("hash-metadata", async (ctx) => {
24
+ await ctx.services.redis.hset("user:42", "lastSeen", new Date().toISOString());
25
+ const lastSeen = await ctx.services.redis.hget("user:42", "lastSeen");
26
+ console.log("user:42 lastSeen:", lastSeen);
27
+ return lastSeen;
28
+ })
29
+ .step("list-session-keys", async (ctx) => {
30
+ const keys = await ctx.services.redis.keys("session:*");
31
+ console.log("active sessions:", keys);
32
+ return keys;
33
+ })
34
+ .step("disconnect", async (ctx) => {
35
+ await ctx.services.redis.quit();
36
+ })
37
+ .run("stopOnError");
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "redis",
3
+ "description": "Redis -- get, set, del, exists, expire, hset, hget, keys",
4
+ "dependencies": {
5
+ "redis": "^4.7.0"
6
+ },
7
+ "files": [
8
+ { "src": "service.ts", "dest": "src/services/redis.ts" },
9
+ { "src": "types.ts", "dest": "src/types/redis.ts" },
10
+ { "src": "service.test.ts", "dest": "src/services/redis.test.ts" }
11
+ ],
12
+ "env": "env.txt",
13
+ "example": "example.ts",
14
+ "transforms": {
15
+ "service.ts": [
16
+ { "from": "./types.js", "to": "../types/redis.js" }
17
+ ],
18
+ "service.test.ts": [
19
+ { "from": "./service.js", "to": "./redis.js" }
20
+ ]
21
+ },
22
+ "patches": {
23
+ "src/suite.ts": {
24
+ "imports": [
25
+ "import { createRedisService } from \"./services/redis.js\";",
26
+ "import type { RedisConfig } from \"./types/redis.js\";"
27
+ ],
28
+ "configs": " private redisConfig?: RedisConfig;",
29
+ "methods": " redis(config: RedisConfig): this { this.redisConfig = config; return this; }",
30
+ "services": " redis: createRedisService(this.redisConfig),"
31
+ },
32
+ "src/types/suite.ts": {
33
+ "service-types": " redis: { get(key: string): Promise<string | null>; set(key: string, value: string, ttlSeconds?: number): Promise<void>; del(key: string): Promise<number>; exists(key: string): Promise<boolean>; expire(key: string, ttlSeconds: number): Promise<boolean>; hset(key: string, field: string, value: string): Promise<number>; hget(key: string, field: string): Promise<string | null>; keys(pattern: string): Promise<string[]>; quit(): Promise<void>; };"
34
+ }
35
+ }
36
+ }
@@ -0,0 +1,149 @@
1
+ import { createRedisService } from './service.js';
2
+
3
+ jest.mock('redis');
4
+ import { createClient } from 'redis';
5
+
6
+ const mockClient = {
7
+ connect: jest.fn().mockResolvedValue(undefined),
8
+ get: jest.fn(),
9
+ set: jest.fn().mockResolvedValue('OK'),
10
+ del: jest.fn(),
11
+ exists: jest.fn(),
12
+ expire: jest.fn(),
13
+ hSet: jest.fn(),
14
+ hGet: jest.fn(),
15
+ keys: jest.fn(),
16
+ quit: jest.fn().mockResolvedValue(undefined),
17
+ };
18
+
19
+ beforeEach(() => {
20
+ jest.clearAllMocks();
21
+ (createClient as jest.Mock).mockReturnValue(mockClient);
22
+ mockClient.connect.mockResolvedValue(undefined);
23
+ });
24
+
25
+ describe('createRedisService', () => {
26
+ const config = { url: 'redis://localhost:6379' };
27
+
28
+ it('connects to redis using the provided url', async () => {
29
+ mockClient.get.mockResolvedValue('value');
30
+ const redis = createRedisService(config);
31
+ await redis.get('key');
32
+ expect(createClient).toHaveBeenCalledWith({ url: config.url });
33
+ expect(mockClient.connect).toHaveBeenCalledTimes(1);
34
+ });
35
+
36
+ it('reuses the same client across multiple calls', async () => {
37
+ mockClient.get.mockResolvedValue('value');
38
+ const redis = createRedisService(config);
39
+ await redis.get('key1');
40
+ await redis.get('key2');
41
+ expect(mockClient.connect).toHaveBeenCalledTimes(1);
42
+ });
43
+
44
+ it('get returns the value for an existing key', async () => {
45
+ mockClient.get.mockResolvedValue('hello');
46
+ const redis = createRedisService(config);
47
+ const result = await redis.get('greeting');
48
+ expect(result).toBe('hello');
49
+ expect(mockClient.get).toHaveBeenCalledWith('greeting');
50
+ });
51
+
52
+ it('get returns null for a missing key', async () => {
53
+ mockClient.get.mockResolvedValue(null);
54
+ const redis = createRedisService(config);
55
+ const result = await redis.get('missing');
56
+ expect(result).toBeNull();
57
+ });
58
+
59
+ it('set calls client.set without EX when no ttl is given', async () => {
60
+ const redis = createRedisService(config);
61
+ await redis.set('key', 'value');
62
+ expect(mockClient.set).toHaveBeenCalledWith('key', 'value');
63
+ });
64
+
65
+ it('set calls client.set with EX option when ttl is given', async () => {
66
+ const redis = createRedisService(config);
67
+ await redis.set('key', 'value', 60);
68
+ expect(mockClient.set).toHaveBeenCalledWith('key', 'value', { EX: 60 });
69
+ });
70
+
71
+ it('del returns the number of deleted keys', async () => {
72
+ mockClient.del.mockResolvedValue(1);
73
+ const redis = createRedisService(config);
74
+ const result = await redis.del('key');
75
+ expect(result).toBe(1);
76
+ expect(mockClient.del).toHaveBeenCalledWith('key');
77
+ });
78
+
79
+ it('exists returns true when key is present', async () => {
80
+ mockClient.exists.mockResolvedValue(1);
81
+ const redis = createRedisService(config);
82
+ const result = await redis.exists('key');
83
+ expect(result).toBe(true);
84
+ });
85
+
86
+ it('exists returns false when key is absent', async () => {
87
+ mockClient.exists.mockResolvedValue(0);
88
+ const redis = createRedisService(config);
89
+ const result = await redis.exists('missing');
90
+ expect(result).toBe(false);
91
+ });
92
+
93
+ it('expire returns true when the timeout was set', async () => {
94
+ mockClient.expire.mockResolvedValue(true);
95
+ const redis = createRedisService(config);
96
+ const result = await redis.expire('key', 300);
97
+ expect(result).toBe(true);
98
+ expect(mockClient.expire).toHaveBeenCalledWith('key', 300);
99
+ });
100
+
101
+ it('hset returns the number of new fields added', async () => {
102
+ mockClient.hSet.mockResolvedValue(1);
103
+ const redis = createRedisService(config);
104
+ const result = await redis.hset('hash', 'field', 'value');
105
+ expect(result).toBe(1);
106
+ expect(mockClient.hSet).toHaveBeenCalledWith('hash', 'field', 'value');
107
+ });
108
+
109
+ it('hget returns the field value', async () => {
110
+ mockClient.hGet.mockResolvedValue('stored');
111
+ const redis = createRedisService(config);
112
+ const result = await redis.hget('hash', 'field');
113
+ expect(result).toBe('stored');
114
+ });
115
+
116
+ it('hget returns null when field is undefined', async () => {
117
+ mockClient.hGet.mockResolvedValue(undefined);
118
+ const redis = createRedisService(config);
119
+ const result = await redis.hget('hash', 'missing');
120
+ expect(result).toBeNull();
121
+ });
122
+
123
+ it('keys returns matching key names', async () => {
124
+ mockClient.keys.mockResolvedValue(['user:1', 'user:2']);
125
+ const redis = createRedisService(config);
126
+ const result = await redis.keys('user:*');
127
+ expect(result).toEqual(['user:1', 'user:2']);
128
+ expect(mockClient.keys).toHaveBeenCalledWith('user:*');
129
+ });
130
+
131
+ it('quit disconnects the client and clears it', async () => {
132
+ mockClient.get.mockResolvedValue('value');
133
+ mockClient.quit = jest.fn().mockResolvedValue(undefined);
134
+ const redis = createRedisService(config);
135
+ await redis.get('key');
136
+ await redis.quit();
137
+ expect(mockClient.quit).toHaveBeenCalledTimes(1);
138
+ });
139
+
140
+ it('quit is a no-op when client was never connected', async () => {
141
+ const redis = createRedisService(config);
142
+ await expect(redis.quit()).resolves.toBeUndefined();
143
+ });
144
+
145
+ it('throws when any method is called without config', async () => {
146
+ const redis = createRedisService();
147
+ await expect(redis.get('key')).rejects.toThrow('redis service is not configured');
148
+ });
149
+ });
@@ -0,0 +1,66 @@
1
+ import { createClient } from "redis";
2
+ import type { RedisConfig } from "./types.js";
3
+
4
+ export function createRedisService(config?: RedisConfig) {
5
+ const ensureConfig = () => {
6
+ if (!config) throw new Error("redis service is not configured -- call .redis(config) on the suite");
7
+ };
8
+
9
+ let client: ReturnType<typeof createClient> | null = null;
10
+
11
+ const getClient = async () => {
12
+ ensureConfig();
13
+ if (!client) {
14
+ client = createClient({ url: config!.url });
15
+ await client.connect();
16
+ }
17
+ return client;
18
+ };
19
+
20
+ return {
21
+ get: async (key: string): Promise<string | null> => {
22
+ const c = await getClient();
23
+ return c.get(key);
24
+ },
25
+ set: async (key: string, value: string, ttlSeconds?: number): Promise<void> => {
26
+ const c = await getClient();
27
+ if (ttlSeconds !== undefined) {
28
+ await c.set(key, value, { EX: ttlSeconds });
29
+ } else {
30
+ await c.set(key, value);
31
+ }
32
+ },
33
+ del: async (key: string): Promise<number> => {
34
+ const c = await getClient();
35
+ return c.del(key);
36
+ },
37
+ exists: async (key: string): Promise<boolean> => {
38
+ const c = await getClient();
39
+ const count = await c.exists(key);
40
+ return count > 0;
41
+ },
42
+ expire: async (key: string, ttlSeconds: number): Promise<boolean> => {
43
+ const c = await getClient();
44
+ return c.expire(key, ttlSeconds);
45
+ },
46
+ hset: async (key: string, field: string, value: string): Promise<number> => {
47
+ const c = await getClient();
48
+ return c.hSet(key, field, value);
49
+ },
50
+ hget: async (key: string, field: string): Promise<string | null> => {
51
+ const c = await getClient();
52
+ const result = await c.hGet(key, field);
53
+ return result ?? null;
54
+ },
55
+ keys: async (pattern: string): Promise<string[]> => {
56
+ const c = await getClient();
57
+ return c.keys(pattern);
58
+ },
59
+ quit: async (): Promise<void> => {
60
+ if (client) {
61
+ await client.quit();
62
+ client = null;
63
+ }
64
+ },
65
+ };
66
+ }
@@ -0,0 +1,3 @@
1
+ export interface RedisConfig {
2
+ url: string;
3
+ }