@shipfoundry/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +44 -0
- package/bin/shipfoundry.mjs +5 -0
- package/package.json +28 -0
- package/src/commands.mjs +730 -0
- package/src/config.mjs +102 -0
- package/src/http.mjs +143 -0
- package/src/main.mjs +236 -0
- package/src/output.mjs +140 -0
package/src/config.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
6
|
+
|
|
7
|
+
export function resolveBaseUrl(explicit) {
|
|
8
|
+
const raw =
|
|
9
|
+
explicit || process.env.SHIPFOUNDRY_BASE_URL || readProfileBaseUrl();
|
|
10
|
+
if (!raw) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
"Set --base-url, SHIPFOUNDRY_BASE_URL, or a profile baseUrl before making requests."
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
const url = new URL(raw);
|
|
16
|
+
if (
|
|
17
|
+
url.username ||
|
|
18
|
+
url.password ||
|
|
19
|
+
url.search ||
|
|
20
|
+
url.hash ||
|
|
21
|
+
url.pathname !== "/"
|
|
22
|
+
) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
"Base URL must be an origin without credentials, a path, query, or fragment."
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (url.protocol === "http:" && !LOOPBACK_HOSTS.has(url.hostname)) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`Refusing plain HTTP for non-loopback host ${url.hostname}. Use HTTPS or a loopback development endpoint.`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
35
|
+
throw new Error(`Unsupported base URL protocol: ${url.protocol}.`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return url.origin;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isLoopbackOrigin(origin) {
|
|
42
|
+
try {
|
|
43
|
+
return LOOPBACK_HOSTS.has(new URL(origin).hostname);
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveApiKey(explicit) {
|
|
50
|
+
return (
|
|
51
|
+
explicit ||
|
|
52
|
+
process.env.SHIPFOUNDRY_API_KEY ||
|
|
53
|
+
process.env.SHIPFOUNDRY_MCP_TOKEN ||
|
|
54
|
+
null
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function keySource(explicit) {
|
|
59
|
+
if (explicit) {
|
|
60
|
+
return "option";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (process.env.SHIPFOUNDRY_API_KEY) {
|
|
64
|
+
return "SHIPFOUNDRY_API_KEY";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (process.env.SHIPFOUNDRY_MCP_TOKEN) {
|
|
68
|
+
return "SHIPFOUNDRY_MCP_TOKEN (legacy)";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return "none";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function profilePath() {
|
|
75
|
+
const dir = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
76
|
+
return join(dir, "shipfoundry", "config.json");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readProfileBaseUrl() {
|
|
80
|
+
try {
|
|
81
|
+
const path = profilePath();
|
|
82
|
+
|
|
83
|
+
if (!existsSync(path)) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
88
|
+
const candidate =
|
|
89
|
+
parsed && Object(parsed) === parsed && parsed.baseUrl
|
|
90
|
+
? String(parsed.baseUrl)
|
|
91
|
+
: null;
|
|
92
|
+
|
|
93
|
+
if (!candidate) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
new URL(candidate);
|
|
98
|
+
return candidate;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/http.mjs
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
export const EXIT = {
|
|
2
|
+
OK: 0,
|
|
3
|
+
USAGE: 2,
|
|
4
|
+
AUTH: 3,
|
|
5
|
+
FORBIDDEN: 4,
|
|
6
|
+
NOT_FOUND: 5,
|
|
7
|
+
CONFLICT: 6,
|
|
8
|
+
QUOTA: 7,
|
|
9
|
+
TRANSIENT: 8,
|
|
10
|
+
TIMEOUT: 9
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const STATUS_TO_EXIT = new Map([
|
|
14
|
+
[400, EXIT.USAGE],
|
|
15
|
+
[401, EXIT.AUTH],
|
|
16
|
+
[403, EXIT.FORBIDDEN],
|
|
17
|
+
[404, EXIT.NOT_FOUND],
|
|
18
|
+
[409, EXIT.CONFLICT],
|
|
19
|
+
[429, EXIT.QUOTA],
|
|
20
|
+
[502, EXIT.TRANSIENT],
|
|
21
|
+
[503, EXIT.TRANSIENT]
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export class CliApiError extends Error {
|
|
25
|
+
constructor({ code, detail, message, recovery, requestId, status }) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.detail = detail;
|
|
29
|
+
this.recovery = recovery;
|
|
30
|
+
this.requestId = requestId;
|
|
31
|
+
this.status = status;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function exitForStatus(status) {
|
|
36
|
+
return STATUS_TO_EXIT.get(status) ?? EXIT.TRANSIENT;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function apiFetch({
|
|
40
|
+
baseUrl,
|
|
41
|
+
key,
|
|
42
|
+
method = "GET",
|
|
43
|
+
path,
|
|
44
|
+
query,
|
|
45
|
+
body,
|
|
46
|
+
idempotencyKey,
|
|
47
|
+
timeoutMs = 30000
|
|
48
|
+
}) {
|
|
49
|
+
let url = `${baseUrl}/api/v1${path}`;
|
|
50
|
+
|
|
51
|
+
if (query) {
|
|
52
|
+
const params = new URLSearchParams();
|
|
53
|
+
for (const [name, value] of Object.entries(query)) {
|
|
54
|
+
if (value !== undefined && value !== null && value !== "") {
|
|
55
|
+
params.append(name, String(value));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const suffix = params.toString();
|
|
59
|
+
if (suffix) {
|
|
60
|
+
url += `?${suffix}`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const headers = { Accept: "application/json" };
|
|
65
|
+
|
|
66
|
+
if (key) {
|
|
67
|
+
headers.Authorization = `Bearer ${key}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let payload;
|
|
71
|
+
if (body !== undefined) {
|
|
72
|
+
headers["Content-Type"] = "application/json";
|
|
73
|
+
payload = JSON.stringify(body);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (idempotencyKey) {
|
|
77
|
+
headers["X-Idempotency-Key"] = idempotencyKey;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const response = await fetch(url, {
|
|
81
|
+
body: payload,
|
|
82
|
+
headers,
|
|
83
|
+
method,
|
|
84
|
+
redirect: "manual",
|
|
85
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (response.status >= 300 && response.status < 400) {
|
|
89
|
+
const location = response.headers.get("location");
|
|
90
|
+
throw new CliApiError({
|
|
91
|
+
code: "unsafe_redirect",
|
|
92
|
+
detail: { location },
|
|
93
|
+
message: `Refusing redirect to ${location ?? "unknown target"} while credentials are configured.`,
|
|
94
|
+
recovery: "Check SHIPFOUNDRY_BASE_URL for typos or trailing paths.",
|
|
95
|
+
requestId: response.headers.get("x-request-id"),
|
|
96
|
+
status: 0
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const requestId = response.headers.get("x-request-id");
|
|
101
|
+
const text = await response.text();
|
|
102
|
+
const parsed = parseJsonBody(text);
|
|
103
|
+
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
const error = parsed?.error ?? {};
|
|
106
|
+
throw new CliApiError({
|
|
107
|
+
code: error.code ?? `http_${response.status}`,
|
|
108
|
+
detail: parsed?.data ?? null,
|
|
109
|
+
message: error.message ?? `Request failed with HTTP ${response.status}.`,
|
|
110
|
+
recovery:
|
|
111
|
+
error.recovery ?? "Retry once; report the request ID if it persists.",
|
|
112
|
+
requestId: error.requestId ?? requestId,
|
|
113
|
+
status: response.status
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { data: parsed, requestId };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function parseJsonBody(text) {
|
|
121
|
+
if (!text.trim()) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
return JSON.parse(text);
|
|
127
|
+
} catch {
|
|
128
|
+
throw new CliApiError({
|
|
129
|
+
code: "invalid_response",
|
|
130
|
+
detail: null,
|
|
131
|
+
message: "Server returned a non-JSON response.",
|
|
132
|
+
recovery:
|
|
133
|
+
"Check SHIPFOUNDRY_BASE_URL points at a ShipFoundry application.",
|
|
134
|
+
requestId: null,
|
|
135
|
+
status: 0
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function newIdempotencyKey() {
|
|
141
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
142
|
+
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
143
|
+
}
|
package/src/main.mjs
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { keySource, resolveApiKey, resolveBaseUrl } from "./config.mjs";
|
|
2
|
+
import { commandSpecs } from "./commands.mjs";
|
|
3
|
+
import { EXIT } from "./http.mjs";
|
|
4
|
+
import { emit, emitError, exitCodeFor } from "./output.mjs";
|
|
5
|
+
|
|
6
|
+
const GLOBAL_FLAGS = new Set([
|
|
7
|
+
"json",
|
|
8
|
+
"base-url",
|
|
9
|
+
"api-key",
|
|
10
|
+
"yes",
|
|
11
|
+
"timeout",
|
|
12
|
+
"help"
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
export async function main(argv) {
|
|
16
|
+
const { positionals, flags } = parseArgs(argv);
|
|
17
|
+
|
|
18
|
+
if (flags.help || positionals.length === 0) {
|
|
19
|
+
printHelp();
|
|
20
|
+
return EXIT.OK;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const command = matchCommand(positionals);
|
|
24
|
+
|
|
25
|
+
if (!command) {
|
|
26
|
+
emitError(
|
|
27
|
+
{
|
|
28
|
+
code: "usage",
|
|
29
|
+
message: `Unknown command: ${positionals.join(" ")}.`,
|
|
30
|
+
recovery: "Run shipfoundry --help."
|
|
31
|
+
},
|
|
32
|
+
{ json: flags.json === true }
|
|
33
|
+
);
|
|
34
|
+
return EXIT.USAGE;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const json = flags.json === true;
|
|
38
|
+
let baseUrl;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
baseUrl =
|
|
42
|
+
command.name === "auth login"
|
|
43
|
+
? undefined
|
|
44
|
+
: resolveBaseUrl(flags["base-url"]);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
emitError(
|
|
47
|
+
{
|
|
48
|
+
code: "usage",
|
|
49
|
+
message: error.message,
|
|
50
|
+
recovery: "Set a valid SHIPFOUNDRY_BASE_URL."
|
|
51
|
+
},
|
|
52
|
+
{ json }
|
|
53
|
+
);
|
|
54
|
+
return EXIT.USAGE;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const key = resolveApiKey(flags["api-key"]);
|
|
58
|
+
const context = {
|
|
59
|
+
baseUrl,
|
|
60
|
+
key,
|
|
61
|
+
keySource: keySource(flags["api-key"]),
|
|
62
|
+
timeoutMs: Number(flags.timeout ?? 30) * 1000
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const result = await command.run(context, command.rest, flags);
|
|
67
|
+
emit(result, { json });
|
|
68
|
+
return EXIT.OK;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
emitError(error, { json });
|
|
71
|
+
|
|
72
|
+
if (error.code === "wait_timeout") {
|
|
73
|
+
return EXIT.TIMEOUT;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (
|
|
77
|
+
error.code === "usage" ||
|
|
78
|
+
error.code === "confirmation_required" ||
|
|
79
|
+
error.code === "invalid_response"
|
|
80
|
+
) {
|
|
81
|
+
return EXIT.USAGE;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return exitCodeFor(error);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseArgs(argv) {
|
|
89
|
+
const positionals = [];
|
|
90
|
+
const flags = {};
|
|
91
|
+
let onlyPositionals = false;
|
|
92
|
+
|
|
93
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
94
|
+
const token = argv[index];
|
|
95
|
+
|
|
96
|
+
if (onlyPositionals) {
|
|
97
|
+
positionals.push(token);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (token === "--") {
|
|
102
|
+
onlyPositionals = true;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (token.startsWith("--")) {
|
|
107
|
+
const [name, inline] = token.slice(2).split("=", 2);
|
|
108
|
+
const normalized = name.toLowerCase();
|
|
109
|
+
|
|
110
|
+
if (!GLOBAL_FLAGS.has(normalized) && !isCommandFlag(normalized)) {
|
|
111
|
+
const error = new Error(`Unknown option: --${name}.`);
|
|
112
|
+
error.code = "usage";
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (inline !== undefined) {
|
|
117
|
+
flags[normalized] = coerceFlag(normalized, inline);
|
|
118
|
+
} else if (
|
|
119
|
+
normalized === "yes" ||
|
|
120
|
+
normalized === "json" ||
|
|
121
|
+
normalized === "help" ||
|
|
122
|
+
normalized === "force-new" ||
|
|
123
|
+
normalized === "restore" ||
|
|
124
|
+
normalized === "retry" ||
|
|
125
|
+
normalized === "wait"
|
|
126
|
+
) {
|
|
127
|
+
flags[normalized] = true;
|
|
128
|
+
} else {
|
|
129
|
+
index += 1;
|
|
130
|
+
flags[normalized] = coerceFlag(normalized, argv[index]);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
positionals.push(token);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { flags, positionals };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isCommandFlag(name) {
|
|
143
|
+
return [
|
|
144
|
+
"name",
|
|
145
|
+
"description",
|
|
146
|
+
"domain",
|
|
147
|
+
"repo-url",
|
|
148
|
+
"summary",
|
|
149
|
+
"project",
|
|
150
|
+
"workspace",
|
|
151
|
+
"status",
|
|
152
|
+
"priority",
|
|
153
|
+
"query",
|
|
154
|
+
"limit",
|
|
155
|
+
"cursor",
|
|
156
|
+
"question-hash",
|
|
157
|
+
"answer",
|
|
158
|
+
"decision",
|
|
159
|
+
"dismiss-reason",
|
|
160
|
+
"reason",
|
|
161
|
+
"mode",
|
|
162
|
+
"brief-id",
|
|
163
|
+
"recommendation-ids",
|
|
164
|
+
"selection-ids",
|
|
165
|
+
"names",
|
|
166
|
+
"match",
|
|
167
|
+
"force-new",
|
|
168
|
+
"restore",
|
|
169
|
+
"retry",
|
|
170
|
+
"wait",
|
|
171
|
+
"idempotency-key",
|
|
172
|
+
"file",
|
|
173
|
+
"outcome",
|
|
174
|
+
"format"
|
|
175
|
+
].includes(name);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function coerceFlag(name, raw) {
|
|
179
|
+
if (raw === undefined) {
|
|
180
|
+
const error = new Error(`Missing value for --${name}.`);
|
|
181
|
+
error.code = "usage";
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (name === "timeout" || name === "limit") {
|
|
186
|
+
const parsed = Number(raw);
|
|
187
|
+
|
|
188
|
+
if (!Number.isFinite(parsed)) {
|
|
189
|
+
const error = new Error(`Invalid numeric value for --${name}.`);
|
|
190
|
+
error.code = "usage";
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return parsed;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return raw;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function matchCommand(positionals) {
|
|
201
|
+
for (let length = 2; length >= 1; length -= 1) {
|
|
202
|
+
const head = positionals.slice(0, length).join(" ");
|
|
203
|
+
const spec = commandSpecs.find((candidate) => candidate.name === head);
|
|
204
|
+
|
|
205
|
+
if (spec) {
|
|
206
|
+
return { ...spec, rest: positionals.slice(length) };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function printHelp() {
|
|
214
|
+
const lines = [
|
|
215
|
+
"shipfoundry — operate ShipFoundry without the dashboard.",
|
|
216
|
+
"",
|
|
217
|
+
"Usage: shipfoundry <command> [args] [--json] [--base-url ...] [--api-key ...] [--yes]",
|
|
218
|
+
"",
|
|
219
|
+
"Global: --json (structured stdout, diagnostics on stderr), --yes (confirm writes),",
|
|
220
|
+
" --base-url, --api-key, --timeout seconds. Reads never prompt. Writes require --yes.",
|
|
221
|
+
" Config: SHIPFOUNDRY_API_KEY (or legacy SHIPFOUNDRY_MCP_TOKEN), SHIPFOUNDRY_BASE_URL.",
|
|
222
|
+
" Key setup is dashboard-bootstrapped; see `shipfoundry auth login`.",
|
|
223
|
+
"",
|
|
224
|
+
"Commands:"
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
for (const spec of commandSpecs) {
|
|
228
|
+
lines.push(` ${spec.usage}\n ${spec.summary}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
lines.push(
|
|
232
|
+
"",
|
|
233
|
+
"Exit codes: 0 ok, 2 usage, 3 auth, 4 forbidden, 5 not found, 6 conflict, 7 quota, 8 transient, 9 wait timeout."
|
|
234
|
+
);
|
|
235
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
236
|
+
}
|
package/src/output.mjs
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { EXIT } from "./http.mjs";
|
|
2
|
+
|
|
3
|
+
export function emit(data, { json }) {
|
|
4
|
+
if (json) {
|
|
5
|
+
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
process.stdout.write(`${summarize(data)}\n`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function emitError(error, { json }) {
|
|
13
|
+
const payload = {
|
|
14
|
+
code: error.code ?? "unknown",
|
|
15
|
+
message: error.message,
|
|
16
|
+
recovery: error.recovery ?? null,
|
|
17
|
+
requestId: error.requestId ?? null
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
if (json) {
|
|
21
|
+
process.stderr.write(
|
|
22
|
+
`${JSON.stringify({ error: payload, ok: false }, null, 2)}\n`
|
|
23
|
+
);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const suffix = payload.requestId ? ` (request ${payload.requestId})` : "";
|
|
28
|
+
const hint = payload.recovery ? `\nRecovery: ${payload.recovery}` : "";
|
|
29
|
+
process.stderr.write(
|
|
30
|
+
`Error [${payload.code}]: ${payload.message}${suffix}${hint}\n`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function exitCodeFor(error) {
|
|
35
|
+
if (Number.isInteger(error.status) && error.status > 0) {
|
|
36
|
+
return exitForKnownStatus(error.status);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return EXIT.TRANSIENT;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function exitForKnownStatus(status) {
|
|
43
|
+
if (status === 400) {
|
|
44
|
+
return EXIT.USAGE;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (status === 401) {
|
|
48
|
+
return EXIT.AUTH;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (status === 403) {
|
|
52
|
+
return EXIT.FORBIDDEN;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (status === 404) {
|
|
56
|
+
return EXIT.NOT_FOUND;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (status === 409) {
|
|
60
|
+
return EXIT.CONFLICT;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (status === 429) {
|
|
64
|
+
return EXIT.QUOTA;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return EXIT.TRANSIENT;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isRecord(value) {
|
|
71
|
+
return Object(value) === value && !Array.isArray(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function summarize(data) {
|
|
75
|
+
if (data === null || data === undefined) {
|
|
76
|
+
return "No result.";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!isRecord(data) && !Array.isArray(data)) {
|
|
80
|
+
return String(data);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const envelope = data;
|
|
84
|
+
const raw = isRecord(envelope) ? envelope.message : null;
|
|
85
|
+
const message =
|
|
86
|
+
raw === null || raw === undefined || Object(raw) === raw
|
|
87
|
+
? null
|
|
88
|
+
: String(raw);
|
|
89
|
+
const payload = isRecord(envelope) ? (envelope.data ?? envelope) : envelope;
|
|
90
|
+
|
|
91
|
+
if (message) {
|
|
92
|
+
const detail = summarizeValue(payload);
|
|
93
|
+
return detail ? `${message}\n${detail}` : message;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return summarizeValue(payload);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function summarizeValue(value) {
|
|
100
|
+
if (value === null || value === undefined) {
|
|
101
|
+
return "";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (Array.isArray(value)) {
|
|
105
|
+
return value
|
|
106
|
+
.slice(0, 20)
|
|
107
|
+
.map((item) => `- ${oneLine(item)}`)
|
|
108
|
+
.join("\n");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (isRecord(value)) {
|
|
112
|
+
const lines = [];
|
|
113
|
+
for (const [name, entry] of Object.entries(value).slice(0, 12)) {
|
|
114
|
+
lines.push(`${name}: ${oneLine(entry)}`);
|
|
115
|
+
}
|
|
116
|
+
return lines.join("\n");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return String(value);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function oneLine(value) {
|
|
123
|
+
if (value === null || value === undefined) {
|
|
124
|
+
return "none";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (Array.isArray(value)) {
|
|
128
|
+
return `${value.length} item(s)`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (isRecord(value)) {
|
|
132
|
+
const name = value.name ?? value.title ?? value.id ?? value.status;
|
|
133
|
+
return name === undefined || isRecord(name) || Array.isArray(name)
|
|
134
|
+
? "object"
|
|
135
|
+
: String(name);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const text = String(value);
|
|
139
|
+
return text.length > 120 ? `${text.slice(0, 117)}...` : text;
|
|
140
|
+
}
|