@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.
- package/.turbo/turbo-build.log +1 -0
- package/.turbo/turbo-lint.log +3 -0
- package/.turbo/turbo-typecheck.log +1 -0
- package/CHANGELOG.md +20 -0
- package/README.md +166 -0
- package/dist/build.d.ts +25 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +167 -0
- package/dist/build.js.map +1 -0
- package/dist/command.d.ts +47 -0
- package/dist/command.d.ts.map +1 -0
- package/dist/command.js +9 -0
- package/dist/command.js.map +1 -0
- package/dist/commander/blaze.d.ts +31 -0
- package/dist/commander/blaze.d.ts.map +1 -0
- package/dist/commander/blaze.js +42 -0
- package/dist/commander/blaze.js.map +1 -0
- package/dist/commander/index.d.ts +5 -0
- package/dist/commander/index.d.ts.map +1 -0
- package/dist/commander/index.js +3 -0
- package/dist/commander/index.js.map +1 -0
- package/dist/commander/to-commander.d.ts +12 -0
- package/dist/commander/to-commander.d.ts.map +1 -0
- package/dist/commander/to-commander.js +148 -0
- package/dist/commander/to-commander.js.map +1 -0
- package/dist/flags.d.ts +17 -0
- package/dist/flags.d.ts.map +1 -0
- package/dist/flags.js +180 -0
- package/dist/flags.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/layers.d.ts +21 -0
- package/dist/layers.d.ts.map +1 -0
- package/dist/layers.js +156 -0
- package/dist/layers.js.map +1 -0
- package/dist/on-result.d.ts +12 -0
- package/dist/on-result.d.ts.map +1 -0
- package/dist/on-result.js +21 -0
- package/dist/on-result.js.map +1 -0
- package/dist/output.d.ts +20 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +82 -0
- package/dist/output.js.map +1 -0
- package/dist/prompt.d.ts +29 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +12 -0
- package/dist/prompt.js.map +1 -0
- package/package.json +29 -0
- package/src/__tests__/blaze.test.ts +78 -0
- package/src/__tests__/build.test.ts +219 -0
- package/src/__tests__/flags.test.ts +176 -0
- package/src/__tests__/layers.test.ts +218 -0
- package/src/__tests__/on-result.test.ts +64 -0
- package/src/__tests__/output.test.ts +115 -0
- package/src/__tests__/to-commander.test.ts +133 -0
- package/src/build.ts +267 -0
- package/src/command.ts +73 -0
- package/src/commander/blaze.ts +67 -0
- package/src/commander/index.ts +5 -0
- package/src/commander/to-commander.ts +186 -0
- package/src/flags.ts +250 -0
- package/src/index.ts +28 -0
- package/src/layers.ts +231 -0
- package/src/on-result.ts +27 -0
- package/src/output.ts +101 -0
- package/src/prompt.ts +40 -0
- package/tsconfig.json +9 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/flags.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flag derivation from surface-agnostic fields and reusable flag presets.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Field } from '@ontrails/core';
|
|
6
|
+
import type { z } from 'zod';
|
|
7
|
+
|
|
8
|
+
import type { CliFlag } from './command.js';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Helpers
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/** Convert camelCase to kebab-case. */
|
|
15
|
+
const toKebab = (str: string): string =>
|
|
16
|
+
str.replaceAll(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`);
|
|
17
|
+
|
|
18
|
+
interface ZodInternals {
|
|
19
|
+
readonly _zod: {
|
|
20
|
+
readonly def: Readonly<Record<string, unknown>>;
|
|
21
|
+
};
|
|
22
|
+
readonly description?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface CliFlagShape {
|
|
26
|
+
readonly choices?: string[] | undefined;
|
|
27
|
+
readonly type: CliFlag['type'];
|
|
28
|
+
readonly variadic: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const fieldTypeToCliFlag: Record<Field['type'], CliFlagShape> = {
|
|
32
|
+
boolean: { type: 'boolean', variadic: false },
|
|
33
|
+
enum: { type: 'string', variadic: false },
|
|
34
|
+
multiselect: { type: 'string[]', variadic: true },
|
|
35
|
+
number: { type: 'number', variadic: false },
|
|
36
|
+
string: { type: 'string', variadic: false },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Convert a derived field into a CLI flag descriptor. */
|
|
40
|
+
const toCliFlag = (field: Field): CliFlag => {
|
|
41
|
+
const shape = fieldTypeToCliFlag[field.type];
|
|
42
|
+
return {
|
|
43
|
+
choices: field.options?.map((option) => option.value),
|
|
44
|
+
default: field.default,
|
|
45
|
+
description: field.label,
|
|
46
|
+
name: toKebab(field.name),
|
|
47
|
+
required: field.required,
|
|
48
|
+
type: shape.type,
|
|
49
|
+
variadic: shape.variadic,
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Public API
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/** Convert derived fields to CLI flags. */
|
|
58
|
+
export const toFlags = (fields: readonly Field[]): CliFlag[] =>
|
|
59
|
+
fields.map(toCliFlag);
|
|
60
|
+
|
|
61
|
+
interface UnwrapState {
|
|
62
|
+
defaultValue: unknown;
|
|
63
|
+
description: string | undefined;
|
|
64
|
+
required: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Get the inner type from an optional or default wrapper. */
|
|
68
|
+
const getInnerType = (current: ZodInternals): ZodInternals =>
|
|
69
|
+
current._zod.def['innerType'] as ZodInternals;
|
|
70
|
+
|
|
71
|
+
/** Propagate description from an inner type. */
|
|
72
|
+
const propagateDescription = (
|
|
73
|
+
inner: ZodInternals,
|
|
74
|
+
state: UnwrapState
|
|
75
|
+
): void => {
|
|
76
|
+
if (inner.description) {
|
|
77
|
+
state.description = inner.description;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Step one unwrap level for optional/default fields. */
|
|
82
|
+
const unwrapStep = (
|
|
83
|
+
current: ZodInternals,
|
|
84
|
+
state: UnwrapState
|
|
85
|
+
): ZodInternals | null => {
|
|
86
|
+
const defType = current._zod.def['type'] as string;
|
|
87
|
+
if (defType !== 'optional' && defType !== 'default') {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
state.required = false;
|
|
91
|
+
if (defType === 'default') {
|
|
92
|
+
state.defaultValue = current._zod.def['defaultValue'];
|
|
93
|
+
}
|
|
94
|
+
const inner = getInnerType(current);
|
|
95
|
+
propagateDescription(inner, state);
|
|
96
|
+
return inner;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Unwrap optional/default wrappers, collecting metadata. */
|
|
100
|
+
const unwrap = (
|
|
101
|
+
schema: ZodInternals
|
|
102
|
+
): {
|
|
103
|
+
defaultValue: unknown;
|
|
104
|
+
description: string | undefined;
|
|
105
|
+
inner: ZodInternals;
|
|
106
|
+
required: boolean;
|
|
107
|
+
} => {
|
|
108
|
+
const state: UnwrapState = {
|
|
109
|
+
defaultValue: undefined,
|
|
110
|
+
description: schema.description,
|
|
111
|
+
required: true,
|
|
112
|
+
};
|
|
113
|
+
let current = schema;
|
|
114
|
+
|
|
115
|
+
// eslint-disable-next-line no-constant-condition
|
|
116
|
+
while (true) {
|
|
117
|
+
const next = unwrapStep(current, state);
|
|
118
|
+
if (next === null) {
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
current = next;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { ...state, inner: current };
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
interface DerivedFlagShape {
|
|
128
|
+
readonly choices?: string[] | undefined;
|
|
129
|
+
readonly type: CliFlag['type'];
|
|
130
|
+
readonly variadic: boolean;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const flagShapeByDef: Record<
|
|
134
|
+
string,
|
|
135
|
+
(schema: ZodInternals) => DerivedFlagShape
|
|
136
|
+
> = {
|
|
137
|
+
array: (schema) => {
|
|
138
|
+
const element = schema._zod.def['element'] as ZodInternals;
|
|
139
|
+
const elementType = element._zod.def['type'] as string;
|
|
140
|
+
if (elementType === 'number') {
|
|
141
|
+
return { type: 'number[]', variadic: true };
|
|
142
|
+
}
|
|
143
|
+
return { type: 'string[]', variadic: true };
|
|
144
|
+
},
|
|
145
|
+
boolean: () => ({ type: 'boolean', variadic: false }),
|
|
146
|
+
enum: (schema) => {
|
|
147
|
+
const entries = schema._zod.def['entries'] as Record<string, string>;
|
|
148
|
+
return {
|
|
149
|
+
choices: Object.values(entries),
|
|
150
|
+
type: 'string',
|
|
151
|
+
variadic: false,
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
number: () => ({ type: 'number', variadic: false }),
|
|
155
|
+
string: () => ({ type: 'string', variadic: false }),
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/** Derive the CLI flag shape for an unwrapped Zod field. */
|
|
159
|
+
const deriveFlagShape = (schema: ZodInternals): DerivedFlagShape => {
|
|
160
|
+
const defType = schema._zod.def['type'] as string;
|
|
161
|
+
const deriveShape = flagShapeByDef[defType];
|
|
162
|
+
return deriveShape
|
|
163
|
+
? deriveShape(schema)
|
|
164
|
+
: { type: 'string', variadic: false };
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/** Derive a single CLI flag from an object shape entry. */
|
|
168
|
+
const deriveFlag = (key: string, value: ZodInternals): CliFlag => {
|
|
169
|
+
const { inner, required, defaultValue, description } = unwrap(value);
|
|
170
|
+
const { choices, type, variadic } = deriveFlagShape(inner);
|
|
171
|
+
return {
|
|
172
|
+
choices,
|
|
173
|
+
default: defaultValue,
|
|
174
|
+
description: description ?? inner.description,
|
|
175
|
+
name: toKebab(key),
|
|
176
|
+
required,
|
|
177
|
+
type,
|
|
178
|
+
variadic,
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** Derive CLI flags from a Zod input schema. */
|
|
183
|
+
export const deriveFlags = (schema: z.ZodType): CliFlag[] => {
|
|
184
|
+
const zod = schema as unknown as ZodInternals;
|
|
185
|
+
if ((zod._zod.def['type'] as string) !== 'object') {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
const shape = zod._zod.def['shape'] as
|
|
189
|
+
| Record<string, ZodInternals>
|
|
190
|
+
| undefined;
|
|
191
|
+
if (!shape) {
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
return Object.entries(shape).map(([key, value]) => deriveFlag(key, value));
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// Presets
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
/** Flags for output mode selection: --output, --json, --jsonl */
|
|
202
|
+
export const outputModePreset = (): CliFlag[] => [
|
|
203
|
+
{
|
|
204
|
+
choices: ['text', 'json', 'jsonl'],
|
|
205
|
+
default: 'text',
|
|
206
|
+
description: 'Output format',
|
|
207
|
+
name: 'output',
|
|
208
|
+
required: false,
|
|
209
|
+
short: 'o',
|
|
210
|
+
type: 'string',
|
|
211
|
+
variadic: false,
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
description: 'Shorthand for --output json',
|
|
215
|
+
name: 'json',
|
|
216
|
+
required: false,
|
|
217
|
+
type: 'boolean',
|
|
218
|
+
variadic: false,
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
description: 'Shorthand for --output jsonl',
|
|
222
|
+
name: 'jsonl',
|
|
223
|
+
required: false,
|
|
224
|
+
type: 'boolean',
|
|
225
|
+
variadic: false,
|
|
226
|
+
},
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
/** Flag for working directory override: --cwd */
|
|
230
|
+
export const cwdPreset = (): CliFlag[] => [
|
|
231
|
+
{
|
|
232
|
+
description: 'Working directory override',
|
|
233
|
+
name: 'cwd',
|
|
234
|
+
required: false,
|
|
235
|
+
type: 'string',
|
|
236
|
+
variadic: false,
|
|
237
|
+
},
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
/** Flag for dry-run mode: --dry-run */
|
|
241
|
+
export const dryRunPreset = (): CliFlag[] => [
|
|
242
|
+
{
|
|
243
|
+
default: false,
|
|
244
|
+
description: 'Execute without side effects',
|
|
245
|
+
name: 'dry-run',
|
|
246
|
+
required: false,
|
|
247
|
+
type: 'boolean',
|
|
248
|
+
variadic: false,
|
|
249
|
+
},
|
|
250
|
+
];
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Command model
|
|
2
|
+
export type { AnyTrail, CliCommand, CliFlag, CliArg } from './command.js';
|
|
3
|
+
|
|
4
|
+
// Build
|
|
5
|
+
export { buildCliCommands } from './build.js';
|
|
6
|
+
export type { BuildCliCommandsOptions, ActionResultContext } from './build.js';
|
|
7
|
+
|
|
8
|
+
// Flags
|
|
9
|
+
export {
|
|
10
|
+
deriveFlags,
|
|
11
|
+
outputModePreset,
|
|
12
|
+
cwdPreset,
|
|
13
|
+
dryRunPreset,
|
|
14
|
+
} from './flags.js';
|
|
15
|
+
|
|
16
|
+
// Output
|
|
17
|
+
export { output, resolveOutputMode } from './output.js';
|
|
18
|
+
export type { OutputMode } from './output.js';
|
|
19
|
+
|
|
20
|
+
// onResult
|
|
21
|
+
export { defaultOnResult } from './on-result.js';
|
|
22
|
+
|
|
23
|
+
// Prompt
|
|
24
|
+
export { passthroughResolver, isInteractive } from './prompt.js';
|
|
25
|
+
export type { Field, InputResolver, ResolveInputOptions } from './prompt.js';
|
|
26
|
+
|
|
27
|
+
// Layers
|
|
28
|
+
export { autoIterateLayer, dateShortcutsLayer } from './layers.js';
|
package/src/layers.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-specific layers shipped with @ontrails/cli.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Implementation, Layer, Trail } from '@ontrails/core';
|
|
6
|
+
import { Result } from '@ontrails/core';
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Pagination output shape detection
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
interface ZodInternals {
|
|
13
|
+
readonly _zod: {
|
|
14
|
+
readonly def: Readonly<Record<string, unknown>>;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Check if a trail's output schema looks like a paginated response. */
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
20
|
+
const isPaginatedOutput = (trail: Trail<any, any>): boolean => {
|
|
21
|
+
if (!trail.output) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
const s = trail.output as unknown as ZodInternals;
|
|
25
|
+
const defType = s._zod.def['type'] as string;
|
|
26
|
+
if (defType !== 'object') {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const shape = s._zod.def['shape'] as Record<string, ZodInternals> | undefined;
|
|
31
|
+
if (!shape) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return 'items' in shape && 'hasMore' in shape && 'nextCursor' in shape;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Check if a trail's input schema has since/until fields. */
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
40
|
+
const hasDateRangeFields = (trail: Trail<any, any>): boolean => {
|
|
41
|
+
const s = trail.input as unknown as ZodInternals;
|
|
42
|
+
const defType = s._zod.def['type'] as string;
|
|
43
|
+
if (defType !== 'object') {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const shape = s._zod.def['shape'] as Record<string, ZodInternals> | undefined;
|
|
48
|
+
if (!shape) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return 'since' in shape || 'until' in shape;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// autoIterateLayer
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
interface PaginatedInput {
|
|
60
|
+
cursor?: string;
|
|
61
|
+
all?: boolean;
|
|
62
|
+
[key: string]: unknown;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface PaginatedOutput {
|
|
66
|
+
items: unknown[];
|
|
67
|
+
hasMore: boolean;
|
|
68
|
+
nextCursor?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Fetch one page and extract items. Returns error result or page data. */
|
|
72
|
+
const fetchPage = async <I, O>(
|
|
73
|
+
inp: PaginatedInput,
|
|
74
|
+
cursor: string | undefined,
|
|
75
|
+
implementation: Implementation<I, O>,
|
|
76
|
+
ctx: Parameters<Implementation<I, O>>[1]
|
|
77
|
+
): Promise<Result<PaginatedOutput, Error>> => {
|
|
78
|
+
const pageInput = { ...inp, cursor } as I;
|
|
79
|
+
const result = await implementation(pageInput, ctx);
|
|
80
|
+
if (result.isErr()) {
|
|
81
|
+
return result as Result<PaginatedOutput, Error>;
|
|
82
|
+
}
|
|
83
|
+
return Result.ok(result.value as PaginatedOutput);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Accumulate items from a page and return the next cursor if there are more. */
|
|
87
|
+
const accumulatePage = (
|
|
88
|
+
page: PaginatedOutput,
|
|
89
|
+
allItems: unknown[]
|
|
90
|
+
): string | undefined => {
|
|
91
|
+
allItems.push(...page.items);
|
|
92
|
+
return page.hasMore && page.nextCursor ? page.nextCursor : undefined;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Collect all pages into a single result. */
|
|
96
|
+
const collectAllPages = async <I, O>(
|
|
97
|
+
inp: PaginatedInput,
|
|
98
|
+
implementation: Implementation<I, O>,
|
|
99
|
+
ctx: Parameters<Implementation<I, O>>[1]
|
|
100
|
+
): Promise<Result<unknown, Error>> => {
|
|
101
|
+
const allItems: unknown[] = [];
|
|
102
|
+
let cursor: string | undefined;
|
|
103
|
+
|
|
104
|
+
for (;;) {
|
|
105
|
+
const pageResult = await fetchPage(inp, cursor, implementation, ctx);
|
|
106
|
+
if (pageResult.isErr()) {
|
|
107
|
+
return pageResult;
|
|
108
|
+
}
|
|
109
|
+
cursor = accumulatePage(pageResult.value as PaginatedOutput, allItems);
|
|
110
|
+
if (cursor === undefined) {
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return Result.ok({ hasMore: false, items: allItems });
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Automatically iterates paginated results when --all flag is present.
|
|
120
|
+
*
|
|
121
|
+
* When a trail's output matches the pagination pattern (items, hasMore,
|
|
122
|
+
* nextCursor) and the input contains `all: true`, this layer repeatedly
|
|
123
|
+
* calls the implementation with incrementing cursors and collects all items.
|
|
124
|
+
*/
|
|
125
|
+
export const autoIterateLayer: Layer = {
|
|
126
|
+
description: 'Auto-paginate results when --all flag is set',
|
|
127
|
+
name: 'autoIterate',
|
|
128
|
+
|
|
129
|
+
wrap<I, O>(
|
|
130
|
+
trail: Trail<I, O>,
|
|
131
|
+
implementation: Implementation<I, O>
|
|
132
|
+
): Implementation<I, O> {
|
|
133
|
+
if (!isPaginatedOutput(trail)) {
|
|
134
|
+
return implementation;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return (input, ctx) => {
|
|
138
|
+
const inp = input as PaginatedInput;
|
|
139
|
+
if (!inp.all) {
|
|
140
|
+
return implementation(input, ctx);
|
|
141
|
+
}
|
|
142
|
+
return collectAllPages(inp, implementation, ctx) as Promise<
|
|
143
|
+
Result<O, Error>
|
|
144
|
+
>;
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// dateShortcutsLayer
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
/** Build a date relative to today with a day offset. */
|
|
154
|
+
const daysAgo = (days: number): string => {
|
|
155
|
+
const now = new Date();
|
|
156
|
+
return new Date(
|
|
157
|
+
now.getFullYear(),
|
|
158
|
+
now.getMonth(),
|
|
159
|
+
now.getDate() - days
|
|
160
|
+
).toISOString();
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const dateShortcuts: Record<string, () => string> = {
|
|
164
|
+
'this-month': () => {
|
|
165
|
+
const now = new Date();
|
|
166
|
+
return new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
|
|
167
|
+
},
|
|
168
|
+
'this-week': () => {
|
|
169
|
+
const now = new Date();
|
|
170
|
+
const dayOfWeek = now.getDay();
|
|
171
|
+
const diff = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
|
172
|
+
return daysAgo(diff);
|
|
173
|
+
},
|
|
174
|
+
today: () => daysAgo(0),
|
|
175
|
+
yesterday: () => daysAgo(1),
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/** Expand a date shortcut to an ISO date string. */
|
|
179
|
+
const expandDateShortcut = (shortcut: string): string | undefined => {
|
|
180
|
+
const handler = dateShortcuts[shortcut];
|
|
181
|
+
if (handler) {
|
|
182
|
+
return handler();
|
|
183
|
+
}
|
|
184
|
+
const match = /^(\d+)d$/.exec(shortcut);
|
|
185
|
+
if (match?.[1]) {
|
|
186
|
+
return daysAgo(Number(match[1]));
|
|
187
|
+
}
|
|
188
|
+
return undefined;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/** Expand since/until shortcuts in an input record. */
|
|
192
|
+
const expandDateFields = (
|
|
193
|
+
inp: Record<string, unknown>
|
|
194
|
+
): Record<string, unknown> => {
|
|
195
|
+
const modified = { ...inp };
|
|
196
|
+
for (const field of ['since', 'until'] as const) {
|
|
197
|
+
if (typeof inp[field] === 'string') {
|
|
198
|
+
const expanded = expandDateShortcut(inp[field]);
|
|
199
|
+
if (expanded) {
|
|
200
|
+
modified[field] = expanded;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return modified;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Expands date shortcut strings into ISO date ranges.
|
|
209
|
+
*
|
|
210
|
+
* When a trail's input has `since` or `until` fields, this layer
|
|
211
|
+
* checks for shortcuts like "today", "yesterday", "7d", "30d",
|
|
212
|
+
* "this-week", "this-month" and expands them to ISO 8601 dates.
|
|
213
|
+
*/
|
|
214
|
+
export const dateShortcutsLayer: Layer = {
|
|
215
|
+
description: 'Expand date shortcuts (today, 7d, etc.) to ISO dates',
|
|
216
|
+
name: 'dateShortcuts',
|
|
217
|
+
|
|
218
|
+
wrap<I, O>(
|
|
219
|
+
trail: Trail<I, O>,
|
|
220
|
+
implementation: Implementation<I, O>
|
|
221
|
+
): Implementation<I, O> {
|
|
222
|
+
if (!hasDateRangeFields(trail)) {
|
|
223
|
+
return implementation;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return (input, ctx) => {
|
|
227
|
+
const modified = expandDateFields(input as Record<string, unknown>);
|
|
228
|
+
return implementation(modified as I, ctx);
|
|
229
|
+
};
|
|
230
|
+
},
|
|
231
|
+
};
|
package/src/on-result.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default result handler for CLI commands.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ActionResultContext } from './build.js';
|
|
6
|
+
import { output, resolveOutputMode } from './output.js';
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// defaultOnResult
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The batteries-included result handler.
|
|
14
|
+
*
|
|
15
|
+
* - On error: throws the error (lets the program's error handler produce exit code)
|
|
16
|
+
* - On success: resolves output mode from flags, pipes value through `output()`
|
|
17
|
+
*/
|
|
18
|
+
export const defaultOnResult = async (
|
|
19
|
+
ctx: ActionResultContext
|
|
20
|
+
): Promise<void> => {
|
|
21
|
+
if (ctx.result.isErr()) {
|
|
22
|
+
throw ctx.result.error;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { mode } = resolveOutputMode(ctx.flags);
|
|
26
|
+
await output(ctx.result.value, mode);
|
|
27
|
+
};
|
package/src/output.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output formatting and mode resolution for CLI output.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Types
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
export type OutputMode = 'text' | 'json' | 'jsonl';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// output()
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Write a value to stdout in the specified format.
|
|
17
|
+
*
|
|
18
|
+
* - **text**: strings written directly; objects JSON-stringified with 2-space indent
|
|
19
|
+
* - **json**: always JSON.stringify with 2-space indent
|
|
20
|
+
* - **jsonl**: arrays emit one JSON line per element; scalars emit one line
|
|
21
|
+
*/
|
|
22
|
+
const outputWriters: Record<OutputMode, (value: unknown) => void> = {
|
|
23
|
+
json: (value) => process.stdout.write(`${JSON.stringify(value, null, 2)}\n`),
|
|
24
|
+
jsonl: (value) => {
|
|
25
|
+
if (Array.isArray(value)) {
|
|
26
|
+
for (const item of value) {
|
|
27
|
+
process.stdout.write(`${JSON.stringify(item)}\n`);
|
|
28
|
+
}
|
|
29
|
+
} else {
|
|
30
|
+
process.stdout.write(`${JSON.stringify(value)}\n`);
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
text: (value) => {
|
|
34
|
+
if (typeof value === 'string') {
|
|
35
|
+
process.stdout.write(`${value}\n`);
|
|
36
|
+
} else {
|
|
37
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const output = (value: unknown, mode: OutputMode): void => {
|
|
43
|
+
const writer = outputWriters[mode];
|
|
44
|
+
writer(value);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// resolveOutputMode()
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
const VALID_MODES = new Set<OutputMode>(['text', 'json', 'jsonl']);
|
|
52
|
+
|
|
53
|
+
/** Resolve mode from flags alone (--json, --jsonl, --output). */
|
|
54
|
+
const resolveFlagMode = (
|
|
55
|
+
flags: Record<string, unknown>
|
|
56
|
+
): OutputMode | undefined => {
|
|
57
|
+
if (flags['json'] === true) {
|
|
58
|
+
return 'json';
|
|
59
|
+
}
|
|
60
|
+
if (flags['jsonl'] === true) {
|
|
61
|
+
return 'jsonl';
|
|
62
|
+
}
|
|
63
|
+
if (
|
|
64
|
+
typeof flags['output'] === 'string' &&
|
|
65
|
+
VALID_MODES.has(flags['output'] as OutputMode)
|
|
66
|
+
) {
|
|
67
|
+
return flags['output'] as OutputMode;
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Resolve mode from environment variables. */
|
|
73
|
+
const resolveEnvMode = (): OutputMode | undefined => {
|
|
74
|
+
if (process.env['TRAILS_JSON'] === '1') {
|
|
75
|
+
return 'json';
|
|
76
|
+
}
|
|
77
|
+
if (process.env['TRAILS_JSONL'] === '1') {
|
|
78
|
+
return 'jsonl';
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Determine the output mode from parsed CLI flags and environment.
|
|
85
|
+
*
|
|
86
|
+
* Resolution order (highest priority wins):
|
|
87
|
+
* 1. `flags.json === true` -> "json"
|
|
88
|
+
* 2. `flags.jsonl === true` -> "jsonl"
|
|
89
|
+
* 3. `flags.output` as string -> validate against OutputMode
|
|
90
|
+
* 4. `TRAILS_JSON=1` env var -> "json"
|
|
91
|
+
* 5. `TRAILS_JSONL=1` env var -> "jsonl"
|
|
92
|
+
* 6. Default: "text"
|
|
93
|
+
*/
|
|
94
|
+
export const resolveOutputMode = (
|
|
95
|
+
flags: Record<string, unknown>
|
|
96
|
+
): {
|
|
97
|
+
mode: OutputMode;
|
|
98
|
+
} => {
|
|
99
|
+
const mode = resolveFlagMode(flags) ?? resolveEnvMode() ?? 'text';
|
|
100
|
+
return { mode };
|
|
101
|
+
};
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surface-agnostic input resolution contracts for CLI commands.
|
|
3
|
+
*
|
|
4
|
+
* The core CLI package does not depend on any concrete prompt library.
|
|
5
|
+
* Callers can provide a resolver that gathers missing input however they want
|
|
6
|
+
* (Clack, forms, conversational UI, or no prompting at all).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Field } from '@ontrails/core';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Options passed to an input resolver.
|
|
13
|
+
*
|
|
14
|
+
* `isTTY` is provided so callers can override interactivity in tests.
|
|
15
|
+
*/
|
|
16
|
+
export interface ResolveInputOptions {
|
|
17
|
+
readonly isTTY?: boolean | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A resolver that fills in missing values for derived schema fields.
|
|
22
|
+
*
|
|
23
|
+
* The resolver receives the current input as field-name keyed values and
|
|
24
|
+
* returns a merged record with any newly gathered answers.
|
|
25
|
+
*/
|
|
26
|
+
export type InputResolver = (
|
|
27
|
+
fields: readonly Field[],
|
|
28
|
+
provided: Record<string, unknown>,
|
|
29
|
+
options?: ResolveInputOptions
|
|
30
|
+
) => Promise<Record<string, unknown>>;
|
|
31
|
+
|
|
32
|
+
/** Default passthrough resolver for non-interactive execution. */
|
|
33
|
+
export const passthroughResolver: InputResolver = async (_fields, provided) =>
|
|
34
|
+
await provided;
|
|
35
|
+
|
|
36
|
+
/** Shared TTY check for resolver implementations. */
|
|
37
|
+
export const isInteractive = (options?: ResolveInputOptions): boolean =>
|
|
38
|
+
options?.isTTY ?? process.stdin.isTTY ?? false;
|
|
39
|
+
|
|
40
|
+
export type { Field };
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"root":["./src/build.ts","./src/command.ts","./src/flags.ts","./src/index.ts","./src/layers.ts","./src/on-result.ts","./src/output.ts","./src/prompt.ts","./src/commander/blaze.ts","./src/commander/index.ts","./src/commander/to-commander.ts"],"version":"5.9.3"}
|