@outfitter/cli 0.5.3 → 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.
- package/README.md +105 -2
- package/dist/actions.d.ts +5 -2
- package/dist/actions.js +2 -2
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +8 -1
- package/dist/command.d.ts +3 -43
- package/dist/command.js +241 -13
- package/dist/envelope.d.ts +5 -0
- package/dist/envelope.js +160 -0
- package/dist/flags.d.ts +5 -189
- package/dist/flags.js +5 -1
- package/dist/hints.d.ts +34 -0
- package/dist/hints.js +26 -0
- package/dist/index.d.ts +3 -2
- package/dist/input.d.ts +3 -124
- package/dist/input.js +14 -359
- package/dist/internal/envelope-helpers.d.ts +4 -0
- package/dist/internal/envelope-helpers.js +24 -0
- package/dist/internal/envelope-types.d.ts +3 -0
- package/dist/internal/envelope-types.js +1 -0
- package/dist/internal/flag-builders.d.ts +3 -0
- package/dist/internal/flag-builders.js +155 -0
- package/dist/internal/flag-types.d.ts +3 -0
- package/dist/internal/flag-types.js +13 -0
- package/dist/internal/hint-action-graph.d.ts +5 -0
- package/dist/internal/hint-action-graph.js +11 -0
- package/dist/internal/hint-command-tree.d.ts +5 -0
- package/dist/internal/hint-command-tree.js +9 -0
- package/dist/internal/hint-error-recovery.d.ts +2 -0
- package/dist/internal/hint-error-recovery.js +7 -0
- package/dist/internal/hint-types.d.ts +4 -0
- package/dist/internal/hint-types.js +1 -0
- package/dist/internal/input-helpers.d.ts +18 -0
- package/dist/internal/input-helpers.js +11 -0
- package/dist/internal/input-normalization.d.ts +3 -0
- package/dist/internal/input-normalization.js +9 -0
- package/dist/internal/input-parsers.d.ts +3 -0
- package/dist/internal/input-parsers.js +19 -0
- package/dist/internal/input-security.d.ts +22 -0
- package/dist/internal/input-security.js +11 -0
- package/dist/internal/output-formatting.d.ts +3 -0
- package/dist/internal/output-formatting.js +21 -0
- package/dist/internal/presets.d.ts +3 -0
- package/dist/{shared/@outfitter/cli-pdb7znbq.js → internal/presets.js} +49 -223
- package/dist/internal/schema-commands.d.ts +3 -0
- package/dist/{shared/@outfitter/cli-5vtr4bdt.js → internal/schema-commands.js} +8 -99
- package/dist/internal/schema-formatting.d.ts +2 -0
- package/dist/internal/schema-formatting.js +7 -0
- package/dist/internal/schema-types.d.ts +2 -0
- package/dist/internal/schema-types.js +1 -0
- package/dist/output.d.ts +4 -3
- package/dist/output.js +13 -166
- package/dist/pagination.d.ts +1 -1
- package/dist/query.d.ts +84 -2
- package/dist/query.js +8 -45
- package/dist/schema-input.d.ts +80 -0
- package/dist/schema-input.js +15 -0
- package/dist/schema.d.ts +4 -1
- package/dist/schema.js +1 -1
- package/dist/shared/@outfitter/cli-10wxfc78.d.ts +45 -0
- package/dist/shared/@outfitter/cli-16wg5mka.d.ts +71 -0
- package/dist/shared/@outfitter/cli-1q5redaj.js +267 -0
- package/dist/shared/@outfitter/cli-2dfxs239.js +98 -0
- package/dist/shared/@outfitter/cli-30mt7c5w.d.ts +112 -0
- package/dist/shared/@outfitter/cli-3jta1h1h.js +134 -0
- package/dist/shared/@outfitter/cli-4h85mpth.js +76 -0
- package/dist/shared/@outfitter/cli-6shkwxdc.js +28 -0
- package/dist/shared/@outfitter/cli-89335n9a.js +16 -0
- package/dist/shared/@outfitter/cli-8999qjdd.js +3 -0
- package/dist/shared/@outfitter/cli-8cfxdady.js +60 -0
- package/dist/shared/@outfitter/cli-bcajqy33.d.ts +25 -0
- package/dist/shared/@outfitter/cli-c09332vm.d.ts +39 -0
- package/dist/shared/@outfitter/cli-cgha038c.d.ts +3 -0
- package/dist/shared/@outfitter/{cli-zahqsaby.js → cli-d40m2x1d.js} +19 -3
- package/dist/shared/@outfitter/cli-dg0cz7rw.js +127 -0
- package/dist/shared/@outfitter/cli-dv8kk4jw.d.ts +24 -0
- package/dist/shared/@outfitter/cli-g43887b7.js +20 -0
- package/dist/shared/@outfitter/cli-gqtkhgw4.js +52 -0
- package/dist/shared/@outfitter/cli-h4ejpmjs.d.ts +104 -0
- package/dist/shared/@outfitter/cli-htzez8v2.js +70 -0
- package/dist/shared/@outfitter/cli-hvg2m5gf.js +79 -0
- package/dist/shared/@outfitter/cli-n54zs151.d.ts +78 -0
- package/dist/shared/@outfitter/cli-nbpgw7z7.d.ts +15 -0
- package/dist/shared/@outfitter/cli-nkt399zf.d.ts +94 -0
- package/dist/shared/@outfitter/cli-pmd04gtv.d.ts +60 -0
- package/dist/shared/@outfitter/{cli-xy3gs50c.d.ts → cli-q6csxmeh.d.ts} +19 -12
- package/dist/shared/@outfitter/cli-qcskd96y.d.ts +11 -0
- package/dist/shared/@outfitter/cli-ry7btmy4.js +118 -0
- package/dist/shared/@outfitter/cli-sy99pjyj.js +32 -0
- package/dist/shared/@outfitter/cli-tm2fzngs.d.ts +23 -0
- package/dist/shared/@outfitter/cli-vvvhjwks.js +106 -0
- package/dist/shared/@outfitter/cli-wjv7g1aq.d.ts +16 -0
- package/dist/shared/@outfitter/{cli-98aa9104.d.ts → cli-x6qr7bnd.d.ts} +338 -16
- package/dist/shared/@outfitter/cli-xde45xcc.d.ts +53 -0
- package/dist/shared/@outfitter/cli-xw8ys1je.d.ts +123 -0
- package/dist/shared/@outfitter/cli-yfewnyc2.d.ts +43 -0
- package/dist/shared/@outfitter/cli-zkzj0q4q.js +99 -0
- package/dist/shared/@outfitter/cli-zv3ah6f0.js +3 -0
- package/dist/streaming.d.ts +47 -0
- package/dist/streaming.js +13 -0
- package/dist/truncation.d.ts +104 -0
- package/dist/truncation.js +111 -0
- package/dist/types.d.ts +2 -2
- package/dist/verbs.d.ts +1 -1
- package/package.json +55 -25
- package/dist/shared/@outfitter/cli-n1k0d23k.d.ts +0 -33
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
import {
|
|
3
|
+
isSecureGlobPattern,
|
|
4
|
+
isSecurePath,
|
|
5
|
+
isWithinWorkspace
|
|
6
|
+
} from "./cli-sy99pjyj.js";
|
|
7
|
+
import {
|
|
8
|
+
isDirectory,
|
|
9
|
+
readStdin
|
|
10
|
+
} from "./cli-6shkwxdc.js";
|
|
11
|
+
|
|
12
|
+
// packages/cli/src/internal/input-parsers.ts
|
|
13
|
+
import path from "path";
|
|
14
|
+
import { ValidationError } from "@outfitter/contracts";
|
|
15
|
+
import { Err, Ok } from "better-result";
|
|
16
|
+
async function expandFileArg(input, options) {
|
|
17
|
+
const {
|
|
18
|
+
encoding: _encoding = "utf-8",
|
|
19
|
+
maxSize,
|
|
20
|
+
trim = false
|
|
21
|
+
} = options ?? {};
|
|
22
|
+
if (!input.startsWith("@")) {
|
|
23
|
+
return input;
|
|
24
|
+
}
|
|
25
|
+
const filePath = input.slice(1);
|
|
26
|
+
if (filePath === "-") {
|
|
27
|
+
let content2 = await readStdin();
|
|
28
|
+
if (trim) {
|
|
29
|
+
content2 = content2.trim();
|
|
30
|
+
}
|
|
31
|
+
return content2;
|
|
32
|
+
}
|
|
33
|
+
if (!isSecurePath(filePath, true)) {
|
|
34
|
+
throw new Error(`Security error: path traversal not allowed: ${filePath}`);
|
|
35
|
+
}
|
|
36
|
+
const file = Bun.file(filePath);
|
|
37
|
+
const exists = await file.exists();
|
|
38
|
+
if (!exists) {
|
|
39
|
+
throw new Error(`File not found: ${filePath}`);
|
|
40
|
+
}
|
|
41
|
+
if (maxSize !== undefined) {
|
|
42
|
+
const size = file.size;
|
|
43
|
+
if (size > maxSize) {
|
|
44
|
+
throw new Error(`File exceeds maximum size of ${maxSize} bytes`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
let content = await file.text();
|
|
48
|
+
if (trim) {
|
|
49
|
+
content = content.trim();
|
|
50
|
+
}
|
|
51
|
+
return content;
|
|
52
|
+
}
|
|
53
|
+
async function parseGlob(pattern, options) {
|
|
54
|
+
const {
|
|
55
|
+
cwd = process.cwd(),
|
|
56
|
+
ignore = [],
|
|
57
|
+
onlyFiles = false,
|
|
58
|
+
onlyDirectories = false,
|
|
59
|
+
followSymlinks = false
|
|
60
|
+
} = options ?? {};
|
|
61
|
+
if (!isSecureGlobPattern(pattern)) {
|
|
62
|
+
throw new Error(`Security error: glob pattern may escape workspace: ${pattern}`);
|
|
63
|
+
}
|
|
64
|
+
const resolvedCwd = path.resolve(cwd);
|
|
65
|
+
const glob = new Bun.Glob(pattern);
|
|
66
|
+
const matches = [];
|
|
67
|
+
const scanOptions = {
|
|
68
|
+
cwd,
|
|
69
|
+
followSymlinks,
|
|
70
|
+
onlyFiles: onlyFiles === true
|
|
71
|
+
};
|
|
72
|
+
for await (const match of glob.scan(scanOptions)) {
|
|
73
|
+
const fullPath = path.resolve(cwd, match);
|
|
74
|
+
if (!isWithinWorkspace(fullPath, resolvedCwd)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
let shouldIgnore = false;
|
|
78
|
+
for (const ignorePattern of ignore) {
|
|
79
|
+
const ignoreGlob = new Bun.Glob(ignorePattern);
|
|
80
|
+
if (ignoreGlob.match(match)) {
|
|
81
|
+
shouldIgnore = true;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (shouldIgnore)
|
|
86
|
+
continue;
|
|
87
|
+
if (onlyDirectories) {
|
|
88
|
+
const isDir = await isDirectory(fullPath);
|
|
89
|
+
if (!isDir)
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
matches.push(match);
|
|
93
|
+
}
|
|
94
|
+
return matches;
|
|
95
|
+
}
|
|
96
|
+
function parseKeyValue(input) {
|
|
97
|
+
const pairs = [];
|
|
98
|
+
const inputs = Array.isArray(input) ? input : [input];
|
|
99
|
+
for (const item of inputs) {
|
|
100
|
+
if (!item)
|
|
101
|
+
continue;
|
|
102
|
+
const parts = item.split(",");
|
|
103
|
+
for (const part of parts) {
|
|
104
|
+
const trimmed = part.trim();
|
|
105
|
+
if (!trimmed)
|
|
106
|
+
continue;
|
|
107
|
+
const eqIndex = trimmed.indexOf("=");
|
|
108
|
+
if (eqIndex === -1) {
|
|
109
|
+
return new Err(new ValidationError({
|
|
110
|
+
message: `Missing '=' in key-value pair: ${trimmed}`
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
114
|
+
const value = trimmed.slice(eqIndex + 1);
|
|
115
|
+
if (!key) {
|
|
116
|
+
return new Err(new ValidationError({ message: "Empty key in key-value pair" }));
|
|
117
|
+
}
|
|
118
|
+
pairs.push({ key, value });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return new Ok(pairs);
|
|
122
|
+
}
|
|
123
|
+
function parseRange(input, type) {
|
|
124
|
+
const trimmed = input.trim();
|
|
125
|
+
if (type === "date") {
|
|
126
|
+
const parts = trimmed.split("..");
|
|
127
|
+
if (parts.length === 1) {
|
|
128
|
+
const dateStr = parts[0];
|
|
129
|
+
if (dateStr === undefined) {
|
|
130
|
+
return new Err(new ValidationError({ message: "Empty date input" }));
|
|
131
|
+
}
|
|
132
|
+
const date = new Date(dateStr.trim());
|
|
133
|
+
if (Number.isNaN(date.getTime())) {
|
|
134
|
+
return new Err(new ValidationError({ message: `Invalid date format: ${dateStr}` }));
|
|
135
|
+
}
|
|
136
|
+
return new Ok({ type: "date", start: date, end: date });
|
|
137
|
+
}
|
|
138
|
+
if (parts.length === 2) {
|
|
139
|
+
const startStr = parts[0];
|
|
140
|
+
const endStr = parts[1];
|
|
141
|
+
if (startStr === undefined || endStr === undefined) {
|
|
142
|
+
return new Err(new ValidationError({ message: "Invalid date range format" }));
|
|
143
|
+
}
|
|
144
|
+
const start = new Date(startStr.trim());
|
|
145
|
+
const end = new Date(endStr.trim());
|
|
146
|
+
if (Number.isNaN(start.getTime())) {
|
|
147
|
+
return new Err(new ValidationError({ message: `Invalid date format: ${startStr}` }));
|
|
148
|
+
}
|
|
149
|
+
if (Number.isNaN(end.getTime())) {
|
|
150
|
+
return new Err(new ValidationError({ message: `Invalid date format: ${endStr}` }));
|
|
151
|
+
}
|
|
152
|
+
if (start.getTime() > end.getTime()) {
|
|
153
|
+
return new Err(new ValidationError({
|
|
154
|
+
message: "Start date must be before or equal to end date"
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
return new Ok({ type: "date", start, end });
|
|
158
|
+
}
|
|
159
|
+
return new Err(new ValidationError({ message: `Invalid date range format: ${input}` }));
|
|
160
|
+
}
|
|
161
|
+
const singleNum = Number(trimmed);
|
|
162
|
+
if (!(Number.isNaN(singleNum) || trimmed.includes("-", trimmed.startsWith("-") ? 1 : 0))) {
|
|
163
|
+
return new Ok({ type: "number", min: singleNum, max: singleNum });
|
|
164
|
+
}
|
|
165
|
+
let separatorIndex = -1;
|
|
166
|
+
for (let i = 1;i < trimmed.length; i++) {
|
|
167
|
+
const char = trimmed[i];
|
|
168
|
+
const prevChar = trimmed[i - 1];
|
|
169
|
+
if (char === "-" && prevChar !== undefined && /[\d\s]/.test(prevChar)) {
|
|
170
|
+
separatorIndex = i;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (separatorIndex === -1) {
|
|
175
|
+
return new Err(new ValidationError({ message: `Invalid numeric range format: ${input}` }));
|
|
176
|
+
}
|
|
177
|
+
const minStr = trimmed.slice(0, separatorIndex).trim();
|
|
178
|
+
const maxStr = trimmed.slice(separatorIndex + 1).trim();
|
|
179
|
+
const min = Number(minStr);
|
|
180
|
+
const max = Number(maxStr);
|
|
181
|
+
if (Number.isNaN(min)) {
|
|
182
|
+
return new Err(new ValidationError({ message: `Invalid number: ${minStr}` }));
|
|
183
|
+
}
|
|
184
|
+
if (Number.isNaN(max)) {
|
|
185
|
+
return new Err(new ValidationError({ message: `Invalid number: ${maxStr}` }));
|
|
186
|
+
}
|
|
187
|
+
if (min > max) {
|
|
188
|
+
return new Err(new ValidationError({ message: "Min must be less than or equal to max" }));
|
|
189
|
+
}
|
|
190
|
+
return new Ok({ type: "number", min, max });
|
|
191
|
+
}
|
|
192
|
+
function parseFilter(input) {
|
|
193
|
+
const trimmed = input.trim();
|
|
194
|
+
if (!trimmed) {
|
|
195
|
+
return new Ok([]);
|
|
196
|
+
}
|
|
197
|
+
const filters = [];
|
|
198
|
+
const parts = trimmed.split(",");
|
|
199
|
+
for (const part of parts) {
|
|
200
|
+
let partTrimmed = part.trim();
|
|
201
|
+
if (!partTrimmed)
|
|
202
|
+
continue;
|
|
203
|
+
let isNegated = false;
|
|
204
|
+
if (partTrimmed.startsWith("!")) {
|
|
205
|
+
isNegated = true;
|
|
206
|
+
partTrimmed = partTrimmed.slice(1).trim();
|
|
207
|
+
}
|
|
208
|
+
const colonIndex = partTrimmed.indexOf(":");
|
|
209
|
+
if (colonIndex === -1) {
|
|
210
|
+
return new Err(new ValidationError({
|
|
211
|
+
message: `Missing ':' in filter expression: ${part.trim()}`
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
const field = partTrimmed.slice(0, colonIndex).trim();
|
|
215
|
+
let value = partTrimmed.slice(colonIndex + 1).trim();
|
|
216
|
+
let operator;
|
|
217
|
+
if (isNegated) {
|
|
218
|
+
operator = "ne";
|
|
219
|
+
} else if (value.startsWith(">=")) {
|
|
220
|
+
operator = "gte";
|
|
221
|
+
value = value.slice(2).trim();
|
|
222
|
+
} else if (value.startsWith("<=")) {
|
|
223
|
+
operator = "lte";
|
|
224
|
+
value = value.slice(2).trim();
|
|
225
|
+
} else if (value.startsWith(">")) {
|
|
226
|
+
operator = "gt";
|
|
227
|
+
value = value.slice(1).trim();
|
|
228
|
+
} else if (value.startsWith("<")) {
|
|
229
|
+
operator = "lt";
|
|
230
|
+
value = value.slice(1).trim();
|
|
231
|
+
} else if (value.startsWith("~")) {
|
|
232
|
+
operator = "contains";
|
|
233
|
+
value = value.slice(1).trim();
|
|
234
|
+
}
|
|
235
|
+
filters.push(operator ? { field, operator, value } : { field, value });
|
|
236
|
+
}
|
|
237
|
+
return new Ok(filters);
|
|
238
|
+
}
|
|
239
|
+
function parseSortSpec(input) {
|
|
240
|
+
const trimmed = input.trim();
|
|
241
|
+
if (!trimmed) {
|
|
242
|
+
return new Ok([]);
|
|
243
|
+
}
|
|
244
|
+
const criteria = [];
|
|
245
|
+
const parts = trimmed.split(",");
|
|
246
|
+
for (const part of parts) {
|
|
247
|
+
const partTrimmed = part.trim();
|
|
248
|
+
if (!partTrimmed)
|
|
249
|
+
continue;
|
|
250
|
+
const colonIndex = partTrimmed.indexOf(":");
|
|
251
|
+
if (colonIndex === -1) {
|
|
252
|
+
criteria.push({ field: partTrimmed, direction: "asc" });
|
|
253
|
+
} else {
|
|
254
|
+
const field = partTrimmed.slice(0, colonIndex).trim();
|
|
255
|
+
const direction = partTrimmed.slice(colonIndex + 1).trim().toLowerCase();
|
|
256
|
+
if (direction !== "asc" && direction !== "desc") {
|
|
257
|
+
return new Err(new ValidationError({
|
|
258
|
+
message: `Invalid sort direction: ${direction}. Must be 'asc' or 'desc'.`
|
|
259
|
+
}));
|
|
260
|
+
}
|
|
261
|
+
criteria.push({ field, direction });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return new Ok(criteria);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export { expandFileArg, parseGlob, parseKeyValue, parseRange, parseFilter, parseSortSpec };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/cli/src/internal/hint-error-recovery.ts
|
|
3
|
+
import {
|
|
4
|
+
retryableMap
|
|
5
|
+
} from "@outfitter/contracts";
|
|
6
|
+
var CATEGORY_RECOVERY_MAP = {
|
|
7
|
+
validation: (cliName) => [
|
|
8
|
+
{
|
|
9
|
+
description: "Check input parameters and try again",
|
|
10
|
+
command: cliName ? `${cliName} --help` : "--help",
|
|
11
|
+
params: { retryable: retryableMap.validation }
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
not_found: (cliName) => [
|
|
15
|
+
{
|
|
16
|
+
description: "Verify the resource identifier exists",
|
|
17
|
+
command: cliName ? `${cliName} list` : "list",
|
|
18
|
+
params: { retryable: retryableMap.not_found }
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
conflict: (cliName, commandName) => {
|
|
22
|
+
const cmd = commandName || "<previous-command>";
|
|
23
|
+
return [
|
|
24
|
+
{
|
|
25
|
+
description: "Resolve the conflict and retry",
|
|
26
|
+
command: cliName ? `${cliName} ${cmd} --force` : `${cmd} --force`,
|
|
27
|
+
params: { retryable: retryableMap.conflict }
|
|
28
|
+
}
|
|
29
|
+
];
|
|
30
|
+
},
|
|
31
|
+
permission: (cliName) => [
|
|
32
|
+
{
|
|
33
|
+
description: "Check your permissions or request access",
|
|
34
|
+
command: cliName ? `${cliName} auth status` : "auth status",
|
|
35
|
+
params: { retryable: retryableMap.permission }
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
timeout: (cliName, commandName) => {
|
|
39
|
+
const cmd = commandName || "<previous-command>";
|
|
40
|
+
return [
|
|
41
|
+
{
|
|
42
|
+
description: "Retry the operation \u2014 transient timeout may resolve",
|
|
43
|
+
command: cliName ? `${cliName} ${cmd}` : cmd,
|
|
44
|
+
params: { retryable: retryableMap.timeout }
|
|
45
|
+
}
|
|
46
|
+
];
|
|
47
|
+
},
|
|
48
|
+
rate_limit: (cliName, commandName) => {
|
|
49
|
+
const cmd = commandName || "<previous-command>";
|
|
50
|
+
return [
|
|
51
|
+
{
|
|
52
|
+
description: "Wait and retry \u2014 rate limit will reset",
|
|
53
|
+
command: cliName ? `${cliName} ${cmd}` : cmd,
|
|
54
|
+
params: { retryable: retryableMap.rate_limit }
|
|
55
|
+
}
|
|
56
|
+
];
|
|
57
|
+
},
|
|
58
|
+
network: (cliName, commandName) => {
|
|
59
|
+
const cmd = commandName || "<previous-command>";
|
|
60
|
+
return [
|
|
61
|
+
{
|
|
62
|
+
description: "Retry the operation \u2014 network issue may be transient",
|
|
63
|
+
command: cliName ? `${cliName} ${cmd}` : cmd,
|
|
64
|
+
params: { retryable: retryableMap.network }
|
|
65
|
+
}
|
|
66
|
+
];
|
|
67
|
+
},
|
|
68
|
+
internal: (cliName) => [
|
|
69
|
+
{
|
|
70
|
+
description: "Report this error \u2014 unexpected internal failure",
|
|
71
|
+
command: cliName ? `${cliName} --help` : "--help",
|
|
72
|
+
params: { retryable: retryableMap.internal }
|
|
73
|
+
}
|
|
74
|
+
],
|
|
75
|
+
auth: (cliName) => [
|
|
76
|
+
{
|
|
77
|
+
description: "Authenticate or refresh your credentials",
|
|
78
|
+
command: cliName ? `${cliName} auth login` : "auth login",
|
|
79
|
+
params: { retryable: retryableMap.auth }
|
|
80
|
+
}
|
|
81
|
+
],
|
|
82
|
+
cancelled: (cliName, commandName) => {
|
|
83
|
+
const cmd = commandName || "<previous-command>";
|
|
84
|
+
return [
|
|
85
|
+
{
|
|
86
|
+
description: "Operation was cancelled \u2014 re-run to try again",
|
|
87
|
+
command: cliName ? `${cliName} ${cmd}` : cmd,
|
|
88
|
+
params: { retryable: retryableMap.cancelled }
|
|
89
|
+
}
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
function errorRecoveryHints(category, cliName, commandName) {
|
|
94
|
+
const factory = CATEGORY_RECOVERY_MAP[category];
|
|
95
|
+
return factory(cliName, commandName);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export { errorRecoveryHints };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { OutputMode } from "./cli-x6qr7bnd.js";
|
|
2
|
+
import { CLIHint, ErrorCategory, OutfitterError } from "@outfitter/contracts";
|
|
3
|
+
import { Result } from "better-result";
|
|
4
|
+
/**
|
|
5
|
+
* Structured success envelope wrapping a command result.
|
|
6
|
+
*
|
|
7
|
+
* The `hints` field is absent (not an empty array) when there are no hints.
|
|
8
|
+
* This avoids Clippy-style noise in terminal output.
|
|
9
|
+
*/
|
|
10
|
+
interface SuccessEnvelope<T = unknown> {
|
|
11
|
+
readonly ok: true;
|
|
12
|
+
readonly command: string;
|
|
13
|
+
readonly result: T;
|
|
14
|
+
readonly hints?: CLIHint[];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Structured error envelope wrapping a command failure.
|
|
18
|
+
*
|
|
19
|
+
* The `hints` field is absent (not an empty array) when there are no hints.
|
|
20
|
+
* The `retryable` field indicates whether the error is transient and safe to retry.
|
|
21
|
+
* The `retry_after` field is only present for rate_limit errors with a known delay.
|
|
22
|
+
*/
|
|
23
|
+
interface ErrorEnvelope {
|
|
24
|
+
readonly ok: false;
|
|
25
|
+
readonly command: string;
|
|
26
|
+
readonly error: {
|
|
27
|
+
readonly category: ErrorCategory;
|
|
28
|
+
readonly message: string;
|
|
29
|
+
readonly retryable: boolean;
|
|
30
|
+
readonly retry_after?: number;
|
|
31
|
+
};
|
|
32
|
+
readonly hints?: CLIHint[];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Discriminated union of success and error envelopes.
|
|
36
|
+
*
|
|
37
|
+
* Use `envelope.ok` to narrow:
|
|
38
|
+
* ```typescript
|
|
39
|
+
* if (envelope.ok) {
|
|
40
|
+
* // SuccessEnvelope — envelope.result is available
|
|
41
|
+
* } else {
|
|
42
|
+
* // ErrorEnvelope — envelope.error is available
|
|
43
|
+
* }
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
type CommandEnvelope<T = unknown> = SuccessEnvelope<T> | ErrorEnvelope;
|
|
47
|
+
/**
|
|
48
|
+
* Options for the runHandler lifecycle bridge.
|
|
49
|
+
*
|
|
50
|
+
* @typeParam TInput - Type of validated input
|
|
51
|
+
* @typeParam TOutput - Type of handler result
|
|
52
|
+
* @typeParam TContext - Type of context object
|
|
53
|
+
*/
|
|
54
|
+
interface RunHandlerOptions<
|
|
55
|
+
TInput = unknown,
|
|
56
|
+
TOutput = unknown,
|
|
57
|
+
TContext = unknown
|
|
58
|
+
> {
|
|
59
|
+
/** Command name for the envelope */
|
|
60
|
+
readonly command: string;
|
|
61
|
+
/**
|
|
62
|
+
* Handler function returning a Result.
|
|
63
|
+
*
|
|
64
|
+
* When a context factory is provided, receives (input, context).
|
|
65
|
+
* When no context factory, receives (input, undefined).
|
|
66
|
+
*/
|
|
67
|
+
readonly handler: (input: TInput, context: TContext) => Promise<Result<TOutput, OutfitterError>>;
|
|
68
|
+
/** Validated input to pass to context factory and handler */
|
|
69
|
+
readonly input?: TInput;
|
|
70
|
+
/** Output format (json, jsonl, human) */
|
|
71
|
+
readonly format?: OutputMode;
|
|
72
|
+
/**
|
|
73
|
+
* Async factory for constructing handler context.
|
|
74
|
+
* Called before the handler with the validated input.
|
|
75
|
+
*/
|
|
76
|
+
readonly contextFactory?: (input: TInput) => Promise<TContext> | TContext;
|
|
77
|
+
/** Success hint function — called with (result, input) */
|
|
78
|
+
readonly hints?: (result: unknown, input: TInput) => CLIHint[];
|
|
79
|
+
/** Error hint function — called with (error, input) */
|
|
80
|
+
readonly onError?: (error: unknown, input: TInput) => CLIHint[];
|
|
81
|
+
/**
|
|
82
|
+
* Enable NDJSON streaming mode.
|
|
83
|
+
*
|
|
84
|
+
* When `true`, the handler receives a `progress` callback via context
|
|
85
|
+
* and the CLI writes progress events as NDJSON lines to stdout.
|
|
86
|
+
* The final line is the standard command envelope (success or error).
|
|
87
|
+
* The CLI owns the initial `start` event, so handlers should emit only
|
|
88
|
+
* `step` and `progress` events through `ctx.progress`.
|
|
89
|
+
*
|
|
90
|
+
* `--stream` is orthogonal to output mode — it controls delivery, not serialization.
|
|
91
|
+
*/
|
|
92
|
+
readonly stream?: boolean;
|
|
93
|
+
/**
|
|
94
|
+
* Indicate that this is a dry-run invocation of a destructive command.
|
|
95
|
+
*
|
|
96
|
+
* When `true`, the success envelope includes a CLIHint with the command
|
|
97
|
+
* to execute without `--dry-run` (preview-then-commit pattern).
|
|
98
|
+
*
|
|
99
|
+
* The handler is responsible for checking the dry-run flag and performing
|
|
100
|
+
* preview-only logic. This option only controls hint generation in the envelope.
|
|
101
|
+
*/
|
|
102
|
+
readonly dryRun?: boolean;
|
|
103
|
+
/**
|
|
104
|
+
* Parsed argv to use for dry-run hint generation.
|
|
105
|
+
*
|
|
106
|
+
* Defaults to `process.argv.slice(2)`. Pass explicit argv when using
|
|
107
|
+
* `cli.parse(customArgv)` to ensure the dry-run hint reconstructs the
|
|
108
|
+
* correct command.
|
|
109
|
+
*/
|
|
110
|
+
readonly argv?: readonly string[];
|
|
111
|
+
}
|
|
112
|
+
export { SuccessEnvelope, ErrorEnvelope, CommandEnvelope, RunHandlerOptions };
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/cli/src/schema-input.ts
|
|
3
|
+
import { ValidationError } from "@outfitter/contracts";
|
|
4
|
+
import { Option } from "commander";
|
|
5
|
+
function camelToKebab(str) {
|
|
6
|
+
return str.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
|
|
7
|
+
}
|
|
8
|
+
function unwrapZodField(field) {
|
|
9
|
+
let current = field;
|
|
10
|
+
let description = current.description;
|
|
11
|
+
let defaultValue = undefined;
|
|
12
|
+
let hasDefault = false;
|
|
13
|
+
let isOptional = false;
|
|
14
|
+
while (true) {
|
|
15
|
+
if (description === undefined) {
|
|
16
|
+
description = current.description;
|
|
17
|
+
}
|
|
18
|
+
const def = current._zod?.def;
|
|
19
|
+
if (!def?.type)
|
|
20
|
+
break;
|
|
21
|
+
if (def.type === "default") {
|
|
22
|
+
hasDefault = true;
|
|
23
|
+
defaultValue = def.defaultValue;
|
|
24
|
+
current = def.innerType;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (def.type === "optional" || def.type === "nullable") {
|
|
28
|
+
isOptional = true;
|
|
29
|
+
current = def.innerType;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
baseType: def.type,
|
|
34
|
+
description,
|
|
35
|
+
hasDefault,
|
|
36
|
+
defaultValue,
|
|
37
|
+
isOptional,
|
|
38
|
+
enumValues: def.type === "enum" ? current.options : undefined
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
baseType: "unknown",
|
|
43
|
+
description,
|
|
44
|
+
hasDefault,
|
|
45
|
+
defaultValue,
|
|
46
|
+
isOptional,
|
|
47
|
+
enumValues: undefined
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function deriveFlags(schema, explicitLongFlags) {
|
|
51
|
+
const flags = [];
|
|
52
|
+
for (const [fieldName, field] of Object.entries(schema.shape)) {
|
|
53
|
+
const kebabName = camelToKebab(fieldName);
|
|
54
|
+
const longFlag = `--${kebabName}`;
|
|
55
|
+
if (explicitLongFlags.has(longFlag))
|
|
56
|
+
continue;
|
|
57
|
+
const info = unwrapZodField(field);
|
|
58
|
+
const desc = info.description ?? fieldName;
|
|
59
|
+
let flagString;
|
|
60
|
+
let isBoolean = false;
|
|
61
|
+
const isRequired = !info.hasDefault && !info.isOptional;
|
|
62
|
+
switch (info.baseType) {
|
|
63
|
+
case "boolean":
|
|
64
|
+
flagString = longFlag;
|
|
65
|
+
isBoolean = true;
|
|
66
|
+
break;
|
|
67
|
+
case "number":
|
|
68
|
+
flagString = `${longFlag} <n>`;
|
|
69
|
+
break;
|
|
70
|
+
case "enum":
|
|
71
|
+
flagString = `${longFlag} <value>`;
|
|
72
|
+
break;
|
|
73
|
+
default:
|
|
74
|
+
flagString = `${longFlag} <value>`;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
flags.push({
|
|
78
|
+
name: fieldName,
|
|
79
|
+
longFlag,
|
|
80
|
+
flagString,
|
|
81
|
+
description: desc,
|
|
82
|
+
defaultValue: info.hasDefault ? info.defaultValue : undefined,
|
|
83
|
+
isBoolean,
|
|
84
|
+
isRequired
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return flags;
|
|
88
|
+
}
|
|
89
|
+
function createCommanderOption(flag, schema) {
|
|
90
|
+
const option = new Option(flag.flagString, flag.description);
|
|
91
|
+
if (flag.defaultValue !== undefined) {
|
|
92
|
+
option.default(flag.defaultValue);
|
|
93
|
+
}
|
|
94
|
+
const fieldInfo = unwrapZodField(schema.shape[flag.name]);
|
|
95
|
+
if (fieldInfo.baseType === "enum" && fieldInfo.enumValues) {
|
|
96
|
+
option.choices(fieldInfo.enumValues);
|
|
97
|
+
}
|
|
98
|
+
if (fieldInfo.baseType === "number") {
|
|
99
|
+
option.argParser(Number);
|
|
100
|
+
}
|
|
101
|
+
if (flag.isRequired) {
|
|
102
|
+
option.makeOptionMandatory(true);
|
|
103
|
+
}
|
|
104
|
+
return option;
|
|
105
|
+
}
|
|
106
|
+
function validateInput(flags, schema) {
|
|
107
|
+
const input = {};
|
|
108
|
+
for (const fieldName of Object.keys(schema.shape)) {
|
|
109
|
+
if (fieldName in flags && flags[fieldName] !== undefined) {
|
|
110
|
+
input[fieldName] = flags[fieldName];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const result = schema.safeParse(input);
|
|
114
|
+
if (result.success) {
|
|
115
|
+
return result.data;
|
|
116
|
+
}
|
|
117
|
+
const rawError = result.error;
|
|
118
|
+
const rawIssues = rawError?.issues ?? [];
|
|
119
|
+
const issues = rawIssues.map((issue) => ({
|
|
120
|
+
field: (issue.path ?? []).join("."),
|
|
121
|
+
expected: issue.expected,
|
|
122
|
+
message: issue.message ?? "Unknown validation error",
|
|
123
|
+
code: issue.code
|
|
124
|
+
}));
|
|
125
|
+
const fieldNames = issues.map((i) => i.field).filter(Boolean);
|
|
126
|
+
const summary = fieldNames.length > 0 ? `Invalid input: ${fieldNames.join(", ")}` : "Invalid input";
|
|
127
|
+
const detail = issues.map((i) => i.field ? ` ${i.field}: ${i.message}` : ` ${i.message}`).join(`
|
|
128
|
+
`);
|
|
129
|
+
const message = detail ? `${summary}
|
|
130
|
+
${detail}` : summary;
|
|
131
|
+
throw ValidationError.fromMessage(message, { issues });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export { camelToKebab, unwrapZodField, deriveFlags, createCommanderOption, validateInput };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
import {
|
|
3
|
+
applyOutputTruncation,
|
|
4
|
+
cliStringify,
|
|
5
|
+
detectMode,
|
|
6
|
+
formatErrorHuman,
|
|
7
|
+
formatHuman,
|
|
8
|
+
getExitCode,
|
|
9
|
+
serializeErrorToJson,
|
|
10
|
+
writeWithBackpressure
|
|
11
|
+
} from "./cli-dg0cz7rw.js";
|
|
12
|
+
|
|
13
|
+
// packages/cli/src/output.ts
|
|
14
|
+
import { getEnvironment, getEnvironmentDefaults } from "@outfitter/config";
|
|
15
|
+
async function output(data, format, options) {
|
|
16
|
+
const mode = detectMode(format);
|
|
17
|
+
const stream = options?.stream ?? process.stdout;
|
|
18
|
+
const renderedData = applyOutputTruncation(data, options?.truncation);
|
|
19
|
+
let outputText;
|
|
20
|
+
switch (mode) {
|
|
21
|
+
case "json": {
|
|
22
|
+
const jsonData = renderedData === undefined ? null : renderedData;
|
|
23
|
+
outputText = cliStringify(jsonData, options?.pretty);
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
case "jsonl": {
|
|
27
|
+
if (Array.isArray(renderedData)) {
|
|
28
|
+
if (renderedData.length === 0) {
|
|
29
|
+
outputText = "";
|
|
30
|
+
} else {
|
|
31
|
+
outputText = renderedData.map((item) => cliStringify(item)).join(`
|
|
32
|
+
`);
|
|
33
|
+
}
|
|
34
|
+
} else {
|
|
35
|
+
outputText = cliStringify(renderedData);
|
|
36
|
+
}
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
default: {
|
|
40
|
+
outputText = formatHuman(renderedData);
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (outputText) {
|
|
45
|
+
await writeWithBackpressure(stream, `${outputText}
|
|
46
|
+
`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function exitWithError(error, format) {
|
|
50
|
+
const exitCode = getExitCode(error);
|
|
51
|
+
const mode = detectMode(format);
|
|
52
|
+
const isJsonMode = mode === "json" || mode === "jsonl";
|
|
53
|
+
if (isJsonMode) {
|
|
54
|
+
process.stderr.write(`${serializeErrorToJson(error)}
|
|
55
|
+
`);
|
|
56
|
+
} else {
|
|
57
|
+
process.stderr.write(`${formatErrorHuman(error)}
|
|
58
|
+
`);
|
|
59
|
+
}
|
|
60
|
+
process.exit(exitCode);
|
|
61
|
+
}
|
|
62
|
+
function resolveVerbose(verbose) {
|
|
63
|
+
const envVerbose = process.env["OUTFITTER_VERBOSE"];
|
|
64
|
+
if (envVerbose === "1")
|
|
65
|
+
return true;
|
|
66
|
+
if (envVerbose === "0")
|
|
67
|
+
return false;
|
|
68
|
+
if (verbose !== undefined) {
|
|
69
|
+
return verbose;
|
|
70
|
+
}
|
|
71
|
+
const env = getEnvironment();
|
|
72
|
+
const defaults = getEnvironmentDefaults(env);
|
|
73
|
+
return defaults.verbose;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export { output, exitWithError, resolveVerbose };
|