@ontrails/cli 1.0.0-beta.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 (70) hide show
  1. package/.turbo/turbo-build.log +1 -0
  2. package/.turbo/turbo-lint.log +3 -0
  3. package/.turbo/turbo-typecheck.log +1 -0
  4. package/CHANGELOG.md +20 -0
  5. package/README.md +166 -0
  6. package/dist/build.d.ts +25 -0
  7. package/dist/build.d.ts.map +1 -0
  8. package/dist/build.js +167 -0
  9. package/dist/build.js.map +1 -0
  10. package/dist/command.d.ts +47 -0
  11. package/dist/command.d.ts.map +1 -0
  12. package/dist/command.js +9 -0
  13. package/dist/command.js.map +1 -0
  14. package/dist/commander/blaze.d.ts +31 -0
  15. package/dist/commander/blaze.d.ts.map +1 -0
  16. package/dist/commander/blaze.js +42 -0
  17. package/dist/commander/blaze.js.map +1 -0
  18. package/dist/commander/index.d.ts +5 -0
  19. package/dist/commander/index.d.ts.map +1 -0
  20. package/dist/commander/index.js +3 -0
  21. package/dist/commander/index.js.map +1 -0
  22. package/dist/commander/to-commander.d.ts +12 -0
  23. package/dist/commander/to-commander.d.ts.map +1 -0
  24. package/dist/commander/to-commander.js +148 -0
  25. package/dist/commander/to-commander.js.map +1 -0
  26. package/dist/flags.d.ts +17 -0
  27. package/dist/flags.d.ts.map +1 -0
  28. package/dist/flags.js +180 -0
  29. package/dist/flags.js.map +1 -0
  30. package/dist/index.d.ts +11 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +13 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/layers.d.ts +21 -0
  35. package/dist/layers.d.ts.map +1 -0
  36. package/dist/layers.js +156 -0
  37. package/dist/layers.js.map +1 -0
  38. package/dist/on-result.d.ts +12 -0
  39. package/dist/on-result.d.ts.map +1 -0
  40. package/dist/on-result.js +21 -0
  41. package/dist/on-result.js.map +1 -0
  42. package/dist/output.d.ts +20 -0
  43. package/dist/output.d.ts.map +1 -0
  44. package/dist/output.js +82 -0
  45. package/dist/output.js.map +1 -0
  46. package/dist/prompt.d.ts +29 -0
  47. package/dist/prompt.d.ts.map +1 -0
  48. package/dist/prompt.js +12 -0
  49. package/dist/prompt.js.map +1 -0
  50. package/package.json +29 -0
  51. package/src/__tests__/blaze.test.ts +78 -0
  52. package/src/__tests__/build.test.ts +219 -0
  53. package/src/__tests__/flags.test.ts +176 -0
  54. package/src/__tests__/layers.test.ts +218 -0
  55. package/src/__tests__/on-result.test.ts +64 -0
  56. package/src/__tests__/output.test.ts +115 -0
  57. package/src/__tests__/to-commander.test.ts +133 -0
  58. package/src/build.ts +267 -0
  59. package/src/command.ts +73 -0
  60. package/src/commander/blaze.ts +67 -0
  61. package/src/commander/index.ts +5 -0
  62. package/src/commander/to-commander.ts +186 -0
  63. package/src/flags.ts +250 -0
  64. package/src/index.ts +28 -0
  65. package/src/layers.ts +231 -0
  66. package/src/on-result.ts +27 -0
  67. package/src/output.ts +101 -0
  68. package/src/prompt.ts +40 -0
  69. package/tsconfig.json +9 -0
  70. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,218 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { Result, createTrailContext, trail } from '@ontrails/core';
4
+ import type { Implementation, Trail } from '@ontrails/core';
5
+ import { z } from 'zod';
6
+
7
+ import { autoIterateLayer, dateShortcutsLayer } from '../layers.js';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Helpers
11
+ // ---------------------------------------------------------------------------
12
+
13
+ const makeCtx = () => createTrailContext();
14
+
15
+ interface DateInput {
16
+ readonly since?: string | undefined;
17
+ readonly until?: string | undefined;
18
+ }
19
+
20
+ const captureDateShortcutInput = async (
21
+ dateTrail: Trail<DateInput, DateInput>,
22
+ input: DateInput
23
+ ): Promise<DateInput> => {
24
+ let receivedInput: DateInput | undefined;
25
+ const impl: Implementation<DateInput, DateInput> = (value) => {
26
+ receivedInput = value;
27
+ return Promise.resolve(Result.ok(value));
28
+ };
29
+
30
+ const wrapped = dateShortcutsLayer.wrap(dateTrail, impl);
31
+ await wrapped(input, makeCtx());
32
+
33
+ expect(receivedInput).toBeDefined();
34
+ return receivedInput ?? {};
35
+ };
36
+
37
+ const expectSameDate = (value: string | undefined, expected: Date) => {
38
+ expect(value).toBeDefined();
39
+ const actual = new Date(value ?? '');
40
+ expect(actual.getFullYear()).toBe(expected.getFullYear());
41
+ expect(actual.getMonth()).toBe(expected.getMonth());
42
+ expect(actual.getDate()).toBe(expected.getDate());
43
+ };
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // autoIterateLayer
47
+ // ---------------------------------------------------------------------------
48
+
49
+ describe('autoIterateLayer', () => {
50
+ const paginatedTrail = trail('list-items', {
51
+ implementation: () => Result.ok({ hasMore: false, items: [] }),
52
+ input: z.object({
53
+ all: z.boolean().optional(),
54
+ cursor: z.string().optional(),
55
+ }),
56
+ output: z.object({
57
+ hasMore: z.boolean(),
58
+ items: z.array(z.string()),
59
+ nextCursor: z.string().optional(),
60
+ }),
61
+ });
62
+
63
+ test('collects paginated results with --all flag', async () => {
64
+ interface PageResult {
65
+ items: string[];
66
+ hasMore: boolean;
67
+ nextCursor?: string | undefined;
68
+ }
69
+ const pages: PageResult[] = [
70
+ { hasMore: true, items: ['a', 'b'], nextCursor: 'page2' },
71
+ { hasMore: false, items: ['c'] },
72
+ ];
73
+ let callCount = 0;
74
+ const impl: Implementation<
75
+ { cursor?: string | undefined; all?: boolean | undefined },
76
+ { items: string[]; hasMore: boolean; nextCursor?: string | undefined }
77
+ > = () => {
78
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- index bounded by page count
79
+ const page = pages[callCount]!;
80
+ callCount += 1;
81
+ return Promise.resolve(Result.ok(page));
82
+ };
83
+
84
+ const wrapped = autoIterateLayer.wrap(paginatedTrail, impl);
85
+ const result = await wrapped({ all: true }, makeCtx());
86
+
87
+ expect(result.isOk()).toBe(true);
88
+ expect(result.unwrap().items).toEqual(['a', 'b', 'c']);
89
+ expect(result.unwrap().hasMore).toBe(false);
90
+ expect(callCount).toBe(2);
91
+ });
92
+
93
+ test('passes through when --all is not set', async () => {
94
+ let callCount = 0;
95
+ const impl: Implementation<
96
+ { cursor?: string | undefined; all?: boolean | undefined },
97
+ { items: string[]; hasMore: boolean; nextCursor?: string | undefined }
98
+ > = () => {
99
+ callCount += 1;
100
+ return Promise.resolve(
101
+ Result.ok({ hasMore: true, items: ['a'], nextCursor: 'x' })
102
+ );
103
+ };
104
+
105
+ const wrapped = autoIterateLayer.wrap(paginatedTrail, impl);
106
+ const result = await wrapped({}, makeCtx());
107
+
108
+ expect(callCount).toBe(1);
109
+ expect(result.isOk()).toBe(true);
110
+ expect(result.unwrap().items).toEqual(['a']);
111
+ });
112
+
113
+ test('ignores non-paginated trails', () => {
114
+ const simpleTrail = trail('simple', {
115
+ implementation: (input: { name: string }) => Result.ok(input.name),
116
+ input: z.object({ name: z.string() }),
117
+ });
118
+
119
+ const impl: Implementation<{ name: string }, string> = async (input) =>
120
+ await Promise.resolve(Result.ok(input.name));
121
+
122
+ // Should return the same implementation (no wrapping)
123
+ const wrapped = autoIterateLayer.wrap(simpleTrail, impl);
124
+ // Reference equality may not hold with async wrapper, just verify it works
125
+ expect(wrapped).toBe(impl);
126
+ });
127
+ });
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // dateShortcutsLayer
131
+ // ---------------------------------------------------------------------------
132
+
133
+ describe('dateShortcutsLayer', () => {
134
+ const dateTrail = trail('events', {
135
+ implementation: (input: {
136
+ since?: string | undefined;
137
+ until?: string | undefined;
138
+ }) => Result.ok(input),
139
+ input: z.object({
140
+ since: z.string().optional(),
141
+ until: z.string().optional(),
142
+ }),
143
+ });
144
+
145
+ test("expands 'today' to correct date", async () => {
146
+ const received = await captureDateShortcutInput(dateTrail, {
147
+ since: 'today',
148
+ });
149
+ expectSameDate(received.since, new Date());
150
+ });
151
+
152
+ test("expands '7d' to 7-day range", async () => {
153
+ const now = new Date();
154
+ const expectedDate = new Date(
155
+ now.getFullYear(),
156
+ now.getMonth(),
157
+ now.getDate() - 7
158
+ );
159
+ const received = await captureDateShortcutInput(dateTrail, { since: '7d' });
160
+ expectSameDate(received.since, expectedDate);
161
+ });
162
+
163
+ test('passes through non-shortcut values', async () => {
164
+ let receivedInput: unknown;
165
+ const impl: Implementation<
166
+ { since?: string | undefined; until?: string | undefined },
167
+ { since?: string | undefined; until?: string | undefined }
168
+ > = (input) => {
169
+ receivedInput = input;
170
+ return Promise.resolve(Result.ok(input));
171
+ };
172
+
173
+ const isoDate = '2025-01-15T00:00:00.000Z';
174
+ const wrapped = dateShortcutsLayer.wrap(dateTrail, impl);
175
+ await wrapped({ since: isoDate }, makeCtx());
176
+
177
+ const received = receivedInput as { since?: string };
178
+ expect(received.since).toBe(isoDate);
179
+ });
180
+
181
+ test('ignores trails without date range fields', () => {
182
+ const noDateTrail = trail('no-dates', {
183
+ implementation: (input: { name: string }) => Result.ok(input.name),
184
+ input: z.object({ name: z.string() }),
185
+ });
186
+
187
+ const impl: Implementation<{ name: string }, string> = async (input) =>
188
+ await Promise.resolve(Result.ok(input.name));
189
+
190
+ const wrapped = dateShortcutsLayer.wrap(noDateTrail, impl);
191
+ expect(wrapped).toBe(impl);
192
+ });
193
+
194
+ test("expands 'yesterday'", async () => {
195
+ let receivedInput: unknown;
196
+ const impl: Implementation<
197
+ { since?: string | undefined; until?: string | undefined },
198
+ { since?: string | undefined; until?: string | undefined }
199
+ > = (input) => {
200
+ receivedInput = input;
201
+ return Promise.resolve(Result.ok(input));
202
+ };
203
+
204
+ const wrapped = dateShortcutsLayer.wrap(dateTrail, impl);
205
+ await wrapped({ since: 'yesterday' }, makeCtx());
206
+
207
+ const received = receivedInput as { since?: string };
208
+ expect(received.since).toBeDefined();
209
+ const date = new Date(received.since as string);
210
+ const now = new Date();
211
+ const expected = new Date(
212
+ now.getFullYear(),
213
+ now.getMonth(),
214
+ now.getDate() - 1
215
+ );
216
+ expect(date.getDate()).toBe(expected.getDate());
217
+ });
218
+ });
@@ -0,0 +1,64 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+
3
+ import { Result } from '@ontrails/core';
4
+
5
+ import type { ActionResultContext } from '../build.js';
6
+ import { defaultOnResult } from '../on-result.js';
7
+
8
+ // Minimal trail stub for testing
9
+ const stubTrail = () =>
10
+ ({ id: 'test', kind: 'trail' }) as ActionResultContext['trail'];
11
+
12
+ describe('defaultOnResult', () => {
13
+ let written: string[];
14
+ let originalWrite: typeof process.stdout.write;
15
+
16
+ beforeEach(() => {
17
+ written = [];
18
+ originalWrite = process.stdout.write;
19
+ process.stdout.write = ((chunk: string) => {
20
+ written.push(chunk);
21
+ return true;
22
+ }) as typeof process.stdout.write;
23
+ });
24
+
25
+ afterEach(() => {
26
+ process.stdout.write = originalWrite;
27
+ });
28
+
29
+ test('outputs success values in resolved mode (text)', async () => {
30
+ const ctx: ActionResultContext = {
31
+ args: {},
32
+ flags: {},
33
+ input: { name: 'test' },
34
+ result: Result.ok('hello'),
35
+ trail: stubTrail(),
36
+ };
37
+ await defaultOnResult(ctx);
38
+ expect(written.join('')).toBe('hello\n');
39
+ });
40
+
41
+ test('outputs success values in json mode', async () => {
42
+ const ctx: ActionResultContext = {
43
+ args: {},
44
+ flags: { json: true },
45
+ input: {},
46
+ result: Result.ok({ id: 1 }),
47
+ trail: stubTrail(),
48
+ };
49
+ await defaultOnResult(ctx);
50
+ expect(written.join('')).toBe(`${JSON.stringify({ id: 1 }, null, 2)}\n`);
51
+ });
52
+
53
+ test('throws on error results', () => {
54
+ const error = new Error('something broke');
55
+ const ctx: ActionResultContext = {
56
+ args: {},
57
+ flags: {},
58
+ input: {},
59
+ result: Result.err(error),
60
+ trail: stubTrail(),
61
+ };
62
+ expect(defaultOnResult(ctx)).rejects.toThrow('something broke');
63
+ });
64
+ });
@@ -0,0 +1,115 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+
3
+ import { output, resolveOutputMode } from '../output.js';
4
+
5
+ describe('output', () => {
6
+ let written: string[];
7
+ let originalWrite: typeof process.stdout.write;
8
+
9
+ beforeEach(() => {
10
+ written = [];
11
+ originalWrite = process.stdout.write;
12
+ process.stdout.write = ((chunk: string) => {
13
+ written.push(chunk);
14
+ return true;
15
+ }) as typeof process.stdout.write;
16
+ });
17
+
18
+ afterEach(() => {
19
+ process.stdout.write = originalWrite;
20
+ });
21
+
22
+ test('text mode writes string directly', async () => {
23
+ await output('hello world', 'text');
24
+ expect(written.join('')).toBe('hello world\n');
25
+ });
26
+
27
+ test('text mode JSON-stringifies objects', async () => {
28
+ await output({ key: 'value' }, 'text');
29
+ expect(written.join('')).toBe(
30
+ `${JSON.stringify({ key: 'value' }, null, 2)}\n`
31
+ );
32
+ });
33
+
34
+ test('json mode writes JSON with indentation', async () => {
35
+ await output({ a: 1 }, 'json');
36
+ expect(written.join('')).toBe(`${JSON.stringify({ a: 1 }, null, 2)}\n`);
37
+ });
38
+
39
+ test('jsonl mode writes each array element as a line', async () => {
40
+ await output([{ id: 1 }, { id: 2 }], 'jsonl');
41
+ expect(written).toEqual([
42
+ `${JSON.stringify({ id: 1 })}\n`,
43
+ `${JSON.stringify({ id: 2 })}\n`,
44
+ ]);
45
+ });
46
+
47
+ test('jsonl mode writes single object as one line', async () => {
48
+ await output({ id: 1 }, 'jsonl');
49
+ expect(written.join('')).toBe(`${JSON.stringify({ id: 1 })}\n`);
50
+ });
51
+ });
52
+
53
+ describe('resolveOutputMode', () => {
54
+ let originalEnv: NodeJS.ProcessEnv;
55
+
56
+ beforeEach(() => {
57
+ originalEnv = { ...process.env };
58
+ });
59
+
60
+ afterEach(() => {
61
+ process.env = originalEnv;
62
+ });
63
+
64
+ describe('flag precedence', () => {
65
+ test('--json flag returns json', () => {
66
+ const result = resolveOutputMode({ json: true });
67
+ expect(result.mode).toBe('json');
68
+ });
69
+
70
+ test('--jsonl flag returns jsonl', () => {
71
+ const result = resolveOutputMode({ jsonl: true });
72
+ expect(result.mode).toBe('jsonl');
73
+ });
74
+
75
+ test('--json takes priority over --jsonl', () => {
76
+ const result = resolveOutputMode({ json: true, jsonl: true });
77
+ expect(result.mode).toBe('json');
78
+ });
79
+
80
+ test('--output flag returns specified mode', () => {
81
+ const result = resolveOutputMode({ output: 'jsonl' });
82
+ expect(result.mode).toBe('jsonl');
83
+ });
84
+
85
+ test('--json takes priority over --output', () => {
86
+ const result = resolveOutputMode({ json: true, output: 'text' });
87
+ expect(result.mode).toBe('json');
88
+ });
89
+ });
90
+
91
+ describe('environment fallback', () => {
92
+ test('TRAILS_JSON=1 env var returns json', () => {
93
+ process.env['TRAILS_JSON'] = '1';
94
+ const result = resolveOutputMode({});
95
+ expect(result.mode).toBe('json');
96
+ });
97
+
98
+ test('TRAILS_JSONL=1 env var returns jsonl', () => {
99
+ process.env['TRAILS_JSONL'] = '1';
100
+ const result = resolveOutputMode({});
101
+ expect(result.mode).toBe('jsonl');
102
+ });
103
+
104
+ test('flags take priority over env vars', () => {
105
+ process.env['TRAILS_JSON'] = '1';
106
+ const result = resolveOutputMode({ jsonl: true });
107
+ expect(result.mode).toBe('jsonl');
108
+ });
109
+
110
+ test('defaults to text when nothing specified', () => {
111
+ const result = resolveOutputMode({});
112
+ expect(result.mode).toBe('text');
113
+ });
114
+ });
115
+ });
@@ -0,0 +1,133 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { Result, trail, topo } from '@ontrails/core';
4
+ import { z } from 'zod';
5
+
6
+ import { buildCliCommands } from '../build.js';
7
+ import type { AnyTrail } from '../command.js';
8
+ import { toCommander } from '../commander/to-commander.js';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Helpers
12
+ // ---------------------------------------------------------------------------
13
+
14
+ const makeApp = (...trails: AnyTrail[]) => {
15
+ const mod: Record<string, unknown> = {};
16
+ for (const t of trails) {
17
+ mod[t.id] = t;
18
+ }
19
+ return topo('test-app', mod);
20
+ };
21
+
22
+ const requireCommand = (
23
+ program: ReturnType<typeof toCommander>,
24
+ name: string
25
+ ) => {
26
+ const command = program.commands.find((entry) => entry.name() === name);
27
+ expect(command).toBeDefined();
28
+ if (!command) {
29
+ throw new Error(`Expected command: ${name}`);
30
+ }
31
+ return command;
32
+ };
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Tests
36
+ // ---------------------------------------------------------------------------
37
+
38
+ describe('toCommander', () => {
39
+ test('creates a Commander program with correct commands', () => {
40
+ const t = trail('greet', {
41
+ implementation: (input: { name: string }) =>
42
+ Result.ok(`Hello, ${input.name}`),
43
+ input: z.object({ name: z.string() }),
44
+ });
45
+ const app = makeApp(t);
46
+ const commands = buildCliCommands(app);
47
+ const program = toCommander(commands, { name: 'test-cli' });
48
+
49
+ expect(program.name()).toBe('test-cli');
50
+ // Should have the greet subcommand
51
+ const sub = program.commands.find((c) => c.name() === 'greet');
52
+ expect(sub).toBeDefined();
53
+ });
54
+
55
+ test('grouped commands create parent/subcommand structure', () => {
56
+ const show = trail('entity.show', {
57
+ implementation: () => Result.ok({}),
58
+ input: z.object({ id: z.string() }),
59
+ });
60
+ const add = trail('entity.add', {
61
+ implementation: () => Result.ok({}),
62
+ input: z.object({ name: z.string() }),
63
+ });
64
+ const app = makeApp(show, add);
65
+ const commands = buildCliCommands(app);
66
+ const program = toCommander(commands);
67
+
68
+ // Should have an "entity" parent command
69
+ const entityCmd = program.commands.find((c) => c.name() === 'entity');
70
+ expect(entityCmd).toBeDefined();
71
+ // With "show" and "add" subcommands
72
+ const subNames = entityCmd?.commands.map((c) => c.name());
73
+ expect(subNames).toContain('show');
74
+ expect(subNames).toContain('add');
75
+ });
76
+
77
+ test('flag types map correctly to Commander options', () => {
78
+ const t = trail('search', {
79
+ implementation: () => Result.ok([]),
80
+ input: z.object({
81
+ format: z.enum(['json', 'text']).optional(),
82
+ limit: z.number().optional(),
83
+ query: z.string(),
84
+ tags: z.array(z.string()).optional(),
85
+ verbose: z.boolean().optional(),
86
+ }),
87
+ });
88
+ const app = makeApp(t);
89
+ const commands = buildCliCommands(app);
90
+ const program = toCommander(commands);
91
+
92
+ const opts = requireCommand(program, 'search').options;
93
+ expect(opts.length).toBeGreaterThanOrEqual(5);
94
+
95
+ const formatOpt = opts.find((entry) => entry.long === '--format');
96
+ expect(formatOpt).toBeDefined();
97
+ expect(formatOpt?.argChoices).toEqual(['json', 'text']);
98
+ });
99
+
100
+ test('sets version when provided', () => {
101
+ const t = trail('ping', {
102
+ implementation: () => Result.ok('pong'),
103
+ input: z.object({}),
104
+ });
105
+ const app = makeApp(t);
106
+ const commands = buildCliCommands(app);
107
+ const program = toCommander(commands, {
108
+ description: 'A test app',
109
+ name: 'myapp',
110
+ version: '1.2.3',
111
+ });
112
+
113
+ expect(program.name()).toBe('myapp');
114
+ expect(program.version()).toBe('1.2.3');
115
+ expect(program.description()).toBe('A test app');
116
+ });
117
+
118
+ test('error handling maps categories to exit codes', () => {
119
+ // This test verifies the error handling structure exists.
120
+ // Full integration would need process.exit mocking.
121
+ const t = trail('fail', {
122
+ implementation: () => Result.ok('ok'),
123
+ input: z.object({}),
124
+ });
125
+ const app = makeApp(t);
126
+ const commands = buildCliCommands(app);
127
+ const program = toCommander(commands);
128
+
129
+ // Verify the program was created (error handling is wired in action)
130
+ expect(program).toBeDefined();
131
+ expect(program.commands).toHaveLength(1);
132
+ });
133
+ });