cli4ai 0.9.2 → 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.
@@ -1,139 +0,0 @@
1
- /**
2
- * Tests for routine-engine.ts
3
- */
4
-
5
- import { describe, test, expect, beforeEach, afterEach } from 'vitest';
6
- import { mkdtempSync, rmSync, writeFileSync } from 'fs';
7
- import { join } from 'path';
8
- import { tmpdir } from 'os';
9
- import {
10
- dryRunRoutine,
11
- loadRoutineDefinition,
12
- runRoutine,
13
- RoutineParseError,
14
- RoutineTemplateError,
15
- RoutineValidationError,
16
- type RoutineDefinition
17
- } from './routine-engine.js';
18
-
19
- describe('routine-engine', () => {
20
- let tempDir: string;
21
-
22
- beforeEach(() => {
23
- tempDir = mkdtempSync(join(tmpdir(), 'cli4ai-routine-engine-test-'));
24
- });
25
-
26
- afterEach(() => {
27
- rmSync(tempDir, { recursive: true, force: true });
28
- });
29
-
30
- test('loadRoutineDefinition throws RoutineParseError on invalid JSON', () => {
31
- const path = join(tempDir, 'bad.routine.json');
32
- writeFileSync(path, '{not json');
33
- expect(() => loadRoutineDefinition(path)).toThrow(RoutineParseError);
34
- });
35
-
36
- test('loadRoutineDefinition throws RoutineValidationError on duplicate step ids', () => {
37
- const path = join(tempDir, 'dup.routine.json');
38
- writeFileSync(
39
- path,
40
- JSON.stringify({
41
- version: 1,
42
- name: 'dup',
43
- steps: [
44
- { id: 'a', type: 'set', vars: {} },
45
- { id: 'a', type: 'set', vars: {} }
46
- ]
47
- })
48
- );
49
- expect(() => loadRoutineDefinition(path)).toThrow(RoutineValidationError);
50
- });
51
-
52
- test('runRoutine executes exec+set steps and renders object result templates', async () => {
53
- const def: RoutineDefinition = {
54
- version: 1,
55
- name: 'demo',
56
- vars: {
57
- lang: { default: 'rust' }
58
- },
59
- steps: [
60
- {
61
- id: 'hello',
62
- type: 'exec',
63
- cmd: 'bash',
64
- args: ['-lc', "echo '{\"x\":1,\"lang\":\"{{vars.lang}}\"}'"],
65
- capture: 'json'
66
- },
67
- {
68
- id: 'setvars',
69
- type: 'set',
70
- vars: {
71
- lang2: '{{steps.hello.json.lang}}',
72
- x: '{{steps.hello.json.x}}'
73
- }
74
- },
75
- {
76
- id: 'echo',
77
- type: 'exec',
78
- cmd: 'bash',
79
- args: ['-lc', "echo '{{vars.lang2}} {{vars.x}}'"],
80
- capture: 'text'
81
- }
82
- ],
83
- result: {
84
- lang: '{{vars.lang2}}',
85
- x: '{{vars.x}}',
86
- raw: '{{steps.echo.stdout}}'
87
- }
88
- };
89
-
90
- const summary = await runRoutine(def, { lang: 'typescript' }, tempDir);
91
- expect(summary.status).toBe('success');
92
- expect(summary.exitCode).toBe(0);
93
- expect(summary.result).toEqual({
94
- lang: 'typescript',
95
- x: 1,
96
- raw: 'typescript 1\n'
97
- });
98
- });
99
-
100
- test('runRoutine fails fast on missing template path', async () => {
101
- const def: RoutineDefinition = {
102
- version: 1,
103
- name: 'bad-template',
104
- steps: [
105
- {
106
- id: 'set',
107
- type: 'set',
108
- vars: {
109
- x: '{{steps.nope.json.0}}'
110
- }
111
- }
112
- ]
113
- };
114
-
115
- await expect(runRoutine(def, {}, tempDir)).rejects.toThrow(RoutineTemplateError);
116
- });
117
-
118
- test('dryRunRoutine is lenient for missing step paths', async () => {
119
- const def: RoutineDefinition = {
120
- version: 1,
121
- name: 'dry',
122
- steps: [
123
- {
124
- id: 'set',
125
- type: 'set',
126
- vars: {
127
- x: '{{steps.nope.json.0}}'
128
- }
129
- }
130
- ],
131
- result: { x: '{{vars.x}}' }
132
- };
133
-
134
- const plan = await dryRunRoutine(def, {}, tempDir);
135
- expect(plan.vars.x).toBe('{{steps.nope.json.0}}');
136
- expect(plan.result).toEqual({ x: '{{steps.nope.json.0}}' });
137
- });
138
- });
139
-
@@ -1,291 +0,0 @@
1
- /**
2
- * Tests for scheduler core functionality.
3
- */
4
-
5
- import { describe, test, expect, beforeEach, afterEach } from 'vitest';
6
- import { rmSync, existsSync, readdirSync } from 'fs';
7
- import { join } from 'path';
8
- import {
9
- parseInterval,
10
- getNextRunTime,
11
- isDaemonRunning,
12
- getDaemonPid,
13
- writeDaemonPid,
14
- removeDaemonPid,
15
- loadSchedulerState,
16
- saveSchedulerState,
17
- saveRunRecord,
18
- getRunHistory,
19
- Scheduler,
20
- SCHEDULER_PID_FILE,
21
- SCHEDULER_STATE_FILE,
22
- SCHEDULER_RUNS_DIR,
23
- type SchedulerState,
24
- type SchedulerRunRecord
25
- } from './scheduler.js';
26
- import { validateScheduleConfig, type RoutineSchedule } from './routine-engine.js';
27
-
28
- // ═══════════════════════════════════════════════════════════════════════════
29
- // INTERVAL PARSING TESTS
30
- // ═══════════════════════════════════════════════════════════════════════════
31
-
32
- describe('parseInterval', () => {
33
- test('parses seconds', () => {
34
- expect(parseInterval('30s')).toBe(30 * 1000);
35
- expect(parseInterval('1s')).toBe(1000);
36
- expect(parseInterval('120s')).toBe(120 * 1000);
37
- });
38
-
39
- test('parses minutes', () => {
40
- expect(parseInterval('5m')).toBe(5 * 60 * 1000);
41
- expect(parseInterval('1m')).toBe(60 * 1000);
42
- expect(parseInterval('60m')).toBe(60 * 60 * 1000);
43
- });
44
-
45
- test('parses hours', () => {
46
- expect(parseInterval('1h')).toBe(60 * 60 * 1000);
47
- expect(parseInterval('24h')).toBe(24 * 60 * 60 * 1000);
48
- });
49
-
50
- test('parses days', () => {
51
- expect(parseInterval('1d')).toBe(24 * 60 * 60 * 1000);
52
- expect(parseInterval('7d')).toBe(7 * 24 * 60 * 60 * 1000);
53
- });
54
-
55
- test('throws on invalid format', () => {
56
- expect(() => parseInterval('10')).toThrow();
57
- expect(() => parseInterval('10x')).toThrow();
58
- expect(() => parseInterval('abc')).toThrow();
59
- expect(() => parseInterval('')).toThrow();
60
- });
61
- });
62
-
63
- // ═══════════════════════════════════════════════════════════════════════════
64
- // NEXT RUN TIME TESTS
65
- // ═══════════════════════════════════════════════════════════════════════════
66
-
67
- describe('getNextRunTime', () => {
68
- test('calculates next run from interval', () => {
69
- const now = new Date('2024-01-01T10:00:00Z');
70
- const schedule: RoutineSchedule = { interval: '1h' };
71
-
72
- const nextRun = getNextRunTime(schedule, now);
73
-
74
- expect(nextRun).not.toBeNull();
75
- expect(nextRun!.getTime()).toBe(now.getTime() + (60 * 60 * 1000));
76
- });
77
-
78
- test('calculates next run from cron', () => {
79
- const now = new Date('2024-01-01T10:30:00Z');
80
- const schedule: RoutineSchedule = { cron: '0 * * * *' }; // Every hour at :00
81
-
82
- const nextRun = getNextRunTime(schedule, now);
83
-
84
- expect(nextRun).not.toBeNull();
85
- expect(nextRun!.getUTCMinutes()).toBe(0);
86
- expect(nextRun!.getUTCHours()).toBe(11); // Next hour in UTC
87
- });
88
-
89
- test('returns earliest when both cron and interval specified', () => {
90
- const now = new Date('2024-01-01T10:55:00Z');
91
- const schedule: RoutineSchedule = {
92
- cron: '0 * * * *', // 5 minutes until :00
93
- interval: '30m' // 30 minutes
94
- };
95
-
96
- const nextRun = getNextRunTime(schedule, now);
97
-
98
- expect(nextRun).not.toBeNull();
99
- // Should be the cron time (5 min) not interval (30 min)
100
- expect(nextRun!.getMinutes()).toBe(0);
101
- });
102
-
103
- test('returns null for invalid schedule', () => {
104
- const schedule: RoutineSchedule = {} as RoutineSchedule;
105
- const nextRun = getNextRunTime(schedule);
106
- expect(nextRun).toBeNull();
107
- });
108
- });
109
-
110
- // ═══════════════════════════════════════════════════════════════════════════
111
- // SCHEDULE VALIDATION TESTS
112
- // ═══════════════════════════════════════════════════════════════════════════
113
-
114
- describe('validateScheduleConfig', () => {
115
- test('validates interval schedule', () => {
116
- const schedule = validateScheduleConfig({ interval: '1h' });
117
- expect(schedule.interval).toBe('1h');
118
- });
119
-
120
- test('validates cron schedule', () => {
121
- const schedule = validateScheduleConfig({ cron: '0 9 * * *' });
122
- expect(schedule.cron).toBe('0 9 * * *');
123
- });
124
-
125
- test('validates all optional fields', () => {
126
- const schedule = validateScheduleConfig({
127
- interval: '1h',
128
- timezone: 'Pacific/Auckland',
129
- enabled: true,
130
- retries: 3,
131
- retryDelayMs: 60000,
132
- concurrency: 'skip'
133
- });
134
-
135
- expect(schedule.timezone).toBe('Pacific/Auckland');
136
- expect(schedule.enabled).toBe(true);
137
- expect(schedule.retries).toBe(3);
138
- expect(schedule.retryDelayMs).toBe(60000);
139
- expect(schedule.concurrency).toBe('skip');
140
- });
141
-
142
- test('throws when missing cron and interval', () => {
143
- expect(() => validateScheduleConfig({})).toThrow('Schedule must have "cron" or "interval"');
144
- });
145
-
146
- test('throws on invalid interval format', () => {
147
- expect(() => validateScheduleConfig({ interval: 'invalid' })).toThrow('must be like "30s"');
148
- });
149
-
150
- test('throws on invalid cron format', () => {
151
- expect(() => validateScheduleConfig({ cron: '* *' })).toThrow('must have 5 or 6 fields');
152
- });
153
-
154
- test('throws on invalid retries', () => {
155
- expect(() => validateScheduleConfig({ interval: '1h', retries: -1 })).toThrow('non-negative integer');
156
- expect(() => validateScheduleConfig({ interval: '1h', retries: 'abc' })).toThrow('non-negative integer');
157
- });
158
-
159
- test('throws on invalid concurrency', () => {
160
- expect(() => validateScheduleConfig({ interval: '1h', concurrency: 'invalid' })).toThrow('"skip" or "queue"');
161
- });
162
- });
163
-
164
- // ═══════════════════════════════════════════════════════════════════════════
165
- // STATE MANAGEMENT TESTS
166
- // ═══════════════════════════════════════════════════════════════════════════
167
-
168
- describe('state management', () => {
169
- // Note: These tests use the actual ~/.cli4ai/scheduler directory
170
- // because paths are resolved at module import time
171
-
172
- test('saves and loads scheduler state', () => {
173
- // Clean up first
174
- if (existsSync(SCHEDULER_STATE_FILE)) {
175
- rmSync(SCHEDULER_STATE_FILE);
176
- }
177
-
178
- const state: SchedulerState = {
179
- version: 1,
180
- startedAt: new Date().toISOString(),
181
- routines: {
182
- 'test-routine': {
183
- name: 'test-routine',
184
- path: '/path/to/routine.json',
185
- schedule: { interval: '1h' },
186
- nextRunAt: new Date().toISOString(),
187
- lastRunAt: null,
188
- lastStatus: null,
189
- running: false,
190
- retryCount: 0
191
- }
192
- }
193
- };
194
-
195
- saveSchedulerState(state);
196
- const loaded = loadSchedulerState();
197
-
198
- expect(loaded).not.toBeNull();
199
- expect(loaded!.version).toBe(1);
200
- expect(loaded!.routines['test-routine'].name).toBe('test-routine');
201
-
202
- // Clean up
203
- rmSync(SCHEDULER_STATE_FILE);
204
- });
205
-
206
- test('returns null when no state file exists', () => {
207
- // Ensure no state file exists
208
- if (existsSync(SCHEDULER_STATE_FILE)) {
209
- rmSync(SCHEDULER_STATE_FILE);
210
- }
211
-
212
- const state = loadSchedulerState();
213
- expect(state).toBeNull();
214
- });
215
-
216
- test('saves and retrieves run records', () => {
217
- // Clean up existing run files for this test
218
- if (existsSync(SCHEDULER_RUNS_DIR)) {
219
- const files = readdirSync(SCHEDULER_RUNS_DIR);
220
- for (const f of files) {
221
- if (f.startsWith('test-routine-')) {
222
- rmSync(join(SCHEDULER_RUNS_DIR, f));
223
- }
224
- }
225
- }
226
-
227
- const record: SchedulerRunRecord = {
228
- routine: 'test-routine',
229
- startedAt: new Date().toISOString(),
230
- finishedAt: new Date().toISOString(),
231
- status: 'success',
232
- exitCode: 0,
233
- durationMs: 1234,
234
- retryAttempt: 0
235
- };
236
-
237
- saveRunRecord(record);
238
- const history = getRunHistory('test-routine');
239
-
240
- expect(history.length).toBeGreaterThanOrEqual(1);
241
- expect(history[0].routine).toBe('test-routine');
242
- expect(history[0].status).toBe('success');
243
- });
244
- });
245
-
246
- // ═══════════════════════════════════════════════════════════════════════════
247
- // PID FILE TESTS
248
- // ═══════════════════════════════════════════════════════════════════════════
249
-
250
- describe('daemon PID management', () => {
251
- // Note: These tests use the actual ~/.cli4ai/scheduler directory
252
- // because paths are resolved at module import time
253
-
254
- beforeEach(() => {
255
- // Clean up any existing PID file
256
- if (existsSync(SCHEDULER_PID_FILE)) {
257
- rmSync(SCHEDULER_PID_FILE);
258
- }
259
- });
260
-
261
- afterEach(() => {
262
- // Clean up
263
- if (existsSync(SCHEDULER_PID_FILE)) {
264
- rmSync(SCHEDULER_PID_FILE);
265
- }
266
- });
267
-
268
- test('writes and reads PID file', () => {
269
- writeDaemonPid(12345);
270
- const pid = getDaemonPid();
271
- expect(pid).toBe(12345);
272
- });
273
-
274
- test('removes PID file', () => {
275
- writeDaemonPid(12345);
276
- removeDaemonPid();
277
- const pid = getDaemonPid();
278
- expect(pid).toBeNull();
279
- });
280
-
281
- test('returns null when no PID file', () => {
282
- const pid = getDaemonPid();
283
- expect(pid).toBeNull();
284
- });
285
-
286
- test('isDaemonRunning returns false for non-existent process', () => {
287
- writeDaemonPid(999999); // Unlikely to be running
288
- const running = isDaemonRunning();
289
- expect(running).toBe(false);
290
- });
291
- });
@@ -1,79 +0,0 @@
1
- /**
2
- * Tests for secrets.ts
3
- */
4
-
5
- import { describe, test, expect, beforeEach, afterEach } from 'vitest';
6
- import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from 'fs';
7
- import { join } from 'path';
8
- import { tmpdir, hostname, userInfo } from 'os';
9
- import { createCipheriv, createHash, randomBytes } from 'crypto';
10
- import { getSecret, listSecretKeys } from './secrets.js';
11
-
12
- const ALGORITHM = 'aes-256-gcm';
13
-
14
- function legacyEncrypt(value: string): string {
15
- const machineId = `${hostname()}-${userInfo().username}-cli4ai-secrets`;
16
- const key = createHash('sha256').update(machineId).digest();
17
- const iv = randomBytes(16);
18
- const cipher = createCipheriv(ALGORITHM, key, iv);
19
-
20
- let encrypted = cipher.update(value, 'utf8', 'hex');
21
- encrypted += cipher.final('hex');
22
-
23
- const authTag = cipher.getAuthTag();
24
-
25
- return JSON.stringify({
26
- iv: iv.toString('hex'),
27
- encrypted,
28
- authTag: authTag.toString('hex')
29
- });
30
- }
31
-
32
- describe('secrets', () => {
33
- let tempDir: string;
34
- let secretsFile: string;
35
- let saltFile: string;
36
- let originalSecretsFileEnv: string | undefined;
37
- let originalSaltFileEnv: string | undefined;
38
-
39
- beforeEach(() => {
40
- tempDir = mkdtempSync(join(tmpdir(), 'cli4ai-secrets-test-'));
41
- secretsFile = join(tempDir, 'secrets.json');
42
- saltFile = join(tempDir, 'secrets.salt');
43
-
44
- originalSecretsFileEnv = process.env.C4AI_SECRETS_FILE;
45
- originalSaltFileEnv = process.env.C4AI_SECRETS_SALT_FILE;
46
-
47
- process.env.C4AI_SECRETS_FILE = secretsFile;
48
- process.env.C4AI_SECRETS_SALT_FILE = saltFile;
49
- });
50
-
51
- afterEach(() => {
52
- if (originalSecretsFileEnv !== undefined) process.env.C4AI_SECRETS_FILE = originalSecretsFileEnv;
53
- else delete process.env.C4AI_SECRETS_FILE;
54
-
55
- if (originalSaltFileEnv !== undefined) process.env.C4AI_SECRETS_SALT_FILE = originalSaltFileEnv;
56
- else delete process.env.C4AI_SECRETS_SALT_FILE;
57
-
58
- rmSync(tempDir, { recursive: true, force: true });
59
- });
60
-
61
- test('decrypts legacy secrets and migrates to salted format', () => {
62
- writeFileSync(
63
- secretsFile,
64
- JSON.stringify({ SLACK_BOT_TOKEN: legacyEncrypt('xoxb-test') }, null, 2),
65
- { mode: 0o600 }
66
- );
67
-
68
- expect(getSecret('SLACK_BOT_TOKEN')).toBe('xoxb-test');
69
- expect(listSecretKeys()).toEqual(['SLACK_BOT_TOKEN']);
70
-
71
- // Migration should create a salt file for the new scheme.
72
- expect(existsSync(saltFile)).toBe(true);
73
- expect(readFileSync(saltFile).length).toBe(32);
74
-
75
- // Subsequent reads should still work.
76
- expect(getSecret('SLACK_BOT_TOKEN')).toBe('xoxb-test');
77
- });
78
- });
79
-
@@ -1,132 +0,0 @@
1
- /**
2
- * Tests for mcp/adapter.ts
3
- */
4
-
5
- import { describe, test, expect } from 'vitest';
6
- import {
7
- manifestToMcpTools,
8
- commandToMcpTool,
9
- type McpTool
10
- } from './adapter.js';
11
- import type { Manifest, CommandDef } from '../core/manifest.js';
12
-
13
- describe('mcp/adapter', () => {
14
- describe('commandToMcpTool', () => {
15
- test('converts simple command', () => {
16
- const manifest: Manifest = {
17
- name: 'github',
18
- version: '1.0.0',
19
- entry: 'run.ts'
20
- };
21
- const cmd: CommandDef = {
22
- description: 'Get notifications'
23
- };
24
-
25
- const tool = commandToMcpTool(manifest, 'notifs', cmd);
26
-
27
- expect(tool.name).toBe('github_notifs');
28
- expect(tool.description).toContain('Get notifications');
29
- expect(tool.inputSchema.type).toBe('object');
30
- expect(tool.inputSchema.properties).toEqual({});
31
- expect(tool.inputSchema.required).toEqual([]);
32
- });
33
-
34
- test('converts command with args', () => {
35
- const manifest: Manifest = {
36
- name: 'search',
37
- version: '1.0.0',
38
- entry: 'run.ts'
39
- };
40
- const cmd: CommandDef = {
41
- description: 'Find items',
42
- args: [
43
- { name: 'query', description: 'Search query', required: true },
44
- { name: 'limit', description: 'Max results', required: false }
45
- ]
46
- };
47
-
48
- const tool = commandToMcpTool(manifest, 'find', cmd);
49
-
50
- expect(tool.inputSchema.properties.query).toBeDefined();
51
- expect(tool.inputSchema.properties.query.description).toBe('Search query');
52
- expect(tool.inputSchema.properties.limit).toBeDefined();
53
- expect(tool.inputSchema.required).toEqual(['query']);
54
- });
55
-
56
- test('uses manifest name as prefix', () => {
57
- const manifest: Manifest = {
58
- name: 'my-tool',
59
- version: '1.0.0',
60
- entry: 'run.ts'
61
- };
62
- const cmd: CommandDef = { description: 'Do action' };
63
-
64
- const tool = commandToMcpTool(manifest, 'action', cmd);
65
-
66
- expect(tool.name).toBe('my-tool_action');
67
- });
68
- });
69
-
70
- describe('manifestToMcpTools', () => {
71
- test('returns empty array when no commands', () => {
72
- const manifest: Manifest = {
73
- name: 'tool',
74
- version: '1.0.0',
75
- entry: 'run.ts'
76
- };
77
-
78
- const tools = manifestToMcpTools(manifest);
79
- expect(tools).toEqual([]);
80
- });
81
-
82
- test('returns empty array for empty commands', () => {
83
- const manifest: Manifest = {
84
- name: 'tool',
85
- version: '1.0.0',
86
- entry: 'run.ts',
87
- commands: {}
88
- };
89
-
90
- const tools = manifestToMcpTools(manifest);
91
- expect(tools).toEqual([]);
92
- });
93
-
94
- test('converts single command', () => {
95
- const manifest: Manifest = {
96
- name: 'github',
97
- version: '1.0.0',
98
- entry: 'run.ts',
99
- commands: {
100
- notifs: { description: 'Get notifications' }
101
- }
102
- };
103
-
104
- const tools = manifestToMcpTools(manifest);
105
-
106
- expect(tools).toHaveLength(1);
107
- expect(tools[0].name).toBe('github_notifs');
108
- });
109
-
110
- test('converts multiple commands', () => {
111
- const manifest: Manifest = {
112
- name: 'api',
113
- version: '1.0.0',
114
- entry: 'run.ts',
115
- commands: {
116
- list: { description: 'List items' },
117
- get: { description: 'Get item' },
118
- create: { description: 'Create item' }
119
- }
120
- };
121
-
122
- const tools = manifestToMcpTools(manifest);
123
-
124
- expect(tools).toHaveLength(3);
125
- expect(tools.map(t => t.name).sort()).toEqual([
126
- 'api_create',
127
- 'api_get',
128
- 'api_list'
129
- ]);
130
- });
131
- });
132
- });