@dac-tech-team-tw/itsaplan-cli 0.0.0-stage → 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/README.md +223 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +329 -0
- package/dist/commands.d.ts +15 -0
- package/dist/commands.js +521 -0
- package/dist/config.d.ts +23 -0
- package/dist/config.js +548 -0
- package/dist/contracts.d.ts +47 -0
- package/dist/contracts.js +14 -0
- package/dist/development.d.ts +9 -0
- package/dist/development.js +220 -0
- package/dist/documents.d.ts +9 -0
- package/dist/documents.js +256 -0
- package/dist/http.d.ts +11 -0
- package/dist/http.js +306 -0
- package/dist/issues.d.ts +61 -0
- package/dist/issues.js +709 -0
- package/dist/writes.d.ts +8 -0
- package/dist/writes.js +57 -0
- package/package.json +44 -4
package/dist/commands.js
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import { access, lstat, readFile, rename, rm, writeFile, } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { input, password } from "@inquirer/prompts";
|
|
5
|
+
import { stringify } from "smol-toml";
|
|
6
|
+
import { API_BASELINE, CLI_VERSION, CliError, } from "./contracts.js";
|
|
7
|
+
import { createContext, getConfigPaths, normalizeApiUrl, readUserConfig, removeAuth, resolveApiUrl, saveAuth, } from "./config.js";
|
|
8
|
+
import { HttpClient, classifyWriteFailure, redact, validateRelativePath, } from "./http.js";
|
|
9
|
+
function object(value, label) {
|
|
10
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
11
|
+
throw new CliError("UNSUPPORTED_RESPONSE", `The server returned an unsupported ${label} response.`, 2);
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
function identity(value) {
|
|
16
|
+
const me = object(value, "identity");
|
|
17
|
+
if (me.authenticated === false)
|
|
18
|
+
throw new CliError("AUTHENTICATION_FAILED", "The API key was not authenticated.", 3);
|
|
19
|
+
if (me.authenticated !== true)
|
|
20
|
+
throw new CliError("UNSUPPORTED_RESPONSE", "The identity response lacks authenticated:true.", 2);
|
|
21
|
+
const user = object(me.user, "user");
|
|
22
|
+
if (typeof user.id !== "string" || typeof user.name !== "string") {
|
|
23
|
+
throw new CliError("UNSUPPORTED_RESPONSE", "The identity response lacks a user id or name.", 2);
|
|
24
|
+
}
|
|
25
|
+
return object(redact(user), "user");
|
|
26
|
+
}
|
|
27
|
+
function errorInfo(error) {
|
|
28
|
+
if (error instanceof CliError) {
|
|
29
|
+
const details = { ...error.details };
|
|
30
|
+
for (const field of [
|
|
31
|
+
"ok",
|
|
32
|
+
"data",
|
|
33
|
+
"meta",
|
|
34
|
+
"outcome",
|
|
35
|
+
"verification",
|
|
36
|
+
"exitCode",
|
|
37
|
+
"error",
|
|
38
|
+
])
|
|
39
|
+
delete details[field];
|
|
40
|
+
return object(redact({
|
|
41
|
+
...details,
|
|
42
|
+
code: error.code,
|
|
43
|
+
message: error.message,
|
|
44
|
+
retryable: false,
|
|
45
|
+
}), "error");
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
code: "UNEXPECTED_ERROR",
|
|
49
|
+
message: "The operation failed unexpectedly.",
|
|
50
|
+
retryable: false,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function preflightError(error) {
|
|
54
|
+
if (error instanceof CliError) {
|
|
55
|
+
throw new CliError(error.code, error.message, error.exitCode, {
|
|
56
|
+
outcome: "not_applied",
|
|
57
|
+
verification: "not_run",
|
|
58
|
+
...error.details,
|
|
59
|
+
retryable: false,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
throw new CliError("LOCAL_OPERATION_FAILED", "The local operation failed; no API write was sent.", 1, { outcome: "not_applied", verification: "not_run", retryable: false });
|
|
63
|
+
}
|
|
64
|
+
async function stdinText() {
|
|
65
|
+
const chunks = [];
|
|
66
|
+
for await (const chunk of process.stdin)
|
|
67
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
68
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
69
|
+
}
|
|
70
|
+
async function inputText(path) {
|
|
71
|
+
if (path === "-")
|
|
72
|
+
return stdinText();
|
|
73
|
+
try {
|
|
74
|
+
return await readFile(path, "utf8");
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw new CliError("INPUT_UNREADABLE", "Cannot read the specified UTF-8 input file.", 2);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function projectRef(ref, full = false) {
|
|
81
|
+
if (!ref ||
|
|
82
|
+
!/^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/.test(ref) ||
|
|
83
|
+
(full && !ref.includes("."))) {
|
|
84
|
+
throw new CliError("INVALID_PROJECT", `Provide ${full ? "a full project ref, --project <team.KEY>" : "a project key or full <team.KEY> ref"}.`, 2);
|
|
85
|
+
}
|
|
86
|
+
return ref;
|
|
87
|
+
}
|
|
88
|
+
function projectSummary(value) {
|
|
89
|
+
const project = object(value, "project");
|
|
90
|
+
if (typeof project.id !== "number" ||
|
|
91
|
+
!Number.isSafeInteger(project.id) ||
|
|
92
|
+
project.id <= 0 ||
|
|
93
|
+
typeof project.ref !== "string" ||
|
|
94
|
+
typeof project.key !== "string" ||
|
|
95
|
+
typeof project.name !== "string") {
|
|
96
|
+
throw new CliError("UNSUPPORTED_RESPONSE", "The project response lacks a valid id, ref, key or name.", 2);
|
|
97
|
+
}
|
|
98
|
+
projectRef(project.ref, true);
|
|
99
|
+
return project;
|
|
100
|
+
}
|
|
101
|
+
function projectSetup(value) {
|
|
102
|
+
const setup = object(value, "project setup");
|
|
103
|
+
projectSummary(setup.project);
|
|
104
|
+
for (const field of [
|
|
105
|
+
"columns",
|
|
106
|
+
"labels",
|
|
107
|
+
"issueTypes",
|
|
108
|
+
"assignees",
|
|
109
|
+
"customFields",
|
|
110
|
+
]) {
|
|
111
|
+
const entries = setup[field];
|
|
112
|
+
if (!Array.isArray(entries))
|
|
113
|
+
throw new CliError("UNSUPPORTED_RESPONSE", `The project setup lacks ${field}.`, 2);
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
const row = object(entry, field);
|
|
116
|
+
if (field === "assignees") {
|
|
117
|
+
if (typeof row.userId !== "string" || typeof row.name !== "string") {
|
|
118
|
+
throw new CliError("UNSUPPORTED_RESPONSE", "A project assignee lacks a user id or name.", 2);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
else if (typeof row.id !== "number" ||
|
|
122
|
+
!Number.isSafeInteger(row.id) ||
|
|
123
|
+
row.id <= 0 ||
|
|
124
|
+
typeof row.name !== "string") {
|
|
125
|
+
throw new CliError("UNSUPPORTED_RESPONSE", `A project ${field} entry lacks a valid id or name.`, 2);
|
|
126
|
+
}
|
|
127
|
+
if (field === "columns" &&
|
|
128
|
+
(typeof row.stateType !== "string" ||
|
|
129
|
+
![
|
|
130
|
+
"backlog",
|
|
131
|
+
"unstarted",
|
|
132
|
+
"started",
|
|
133
|
+
"completed",
|
|
134
|
+
"canceled",
|
|
135
|
+
].includes(row.stateType))) {
|
|
136
|
+
throw new CliError("UNSUPPORTED_RESPONSE", "A project column has an unsupported stateType.", 2);
|
|
137
|
+
}
|
|
138
|
+
if (field === "customFields" &&
|
|
139
|
+
(typeof row.fieldType !== "string" || !Array.isArray(row.options))) {
|
|
140
|
+
throw new CliError("UNSUPPORTED_RESPONSE", "A custom field lacks its type or options.", 2);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return object(redact(setup), "project setup");
|
|
145
|
+
}
|
|
146
|
+
export async function runDoctor(options) {
|
|
147
|
+
const diagnostics = {
|
|
148
|
+
cliVersion: CLI_VERSION,
|
|
149
|
+
apiBaseline: API_BASELINE,
|
|
150
|
+
serverVersion: null,
|
|
151
|
+
baselineStatus: "not_checked",
|
|
152
|
+
config: { status: "not_checked" },
|
|
153
|
+
auth: { source: "missing", tokenAvailable: false, authenticated: false },
|
|
154
|
+
checks: {},
|
|
155
|
+
};
|
|
156
|
+
const checks = diagnostics.checks;
|
|
157
|
+
const failures = [];
|
|
158
|
+
try {
|
|
159
|
+
const path = getConfigPaths().config;
|
|
160
|
+
await readUserConfig();
|
|
161
|
+
const present = await access(path).then(() => true, () => false);
|
|
162
|
+
diagnostics.config = {
|
|
163
|
+
path,
|
|
164
|
+
status: present ? "passed" : "missing",
|
|
165
|
+
optional: true,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
failures.push(error);
|
|
170
|
+
diagnostics.config = { status: "failed", error: errorInfo(error) };
|
|
171
|
+
}
|
|
172
|
+
let ctx;
|
|
173
|
+
try {
|
|
174
|
+
ctx = await createContext(options);
|
|
175
|
+
diagnostics.apiUrl = ctx.auth.apiUrl;
|
|
176
|
+
diagnostics.auth = { ...ctx.auth, authenticated: false };
|
|
177
|
+
if (!ctx.auth.tokenAvailable)
|
|
178
|
+
failures.push(new CliError("AUTHENTICATION_REQUIRED", "No API key is available; use auth login or an explicit endpoint and ITSAPLAN_API_KEY.", 3));
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
failures.push(error);
|
|
182
|
+
diagnostics.auth = {
|
|
183
|
+
source: process.env.ITSAPLAN_API_KEY
|
|
184
|
+
? "env"
|
|
185
|
+
: options.apiKey
|
|
186
|
+
? "flag"
|
|
187
|
+
: "missing",
|
|
188
|
+
tokenAvailable: Boolean(process.env.ITSAPLAN_API_KEY || options.apiKey),
|
|
189
|
+
authenticated: false,
|
|
190
|
+
error: errorInfo(error),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (ctx) {
|
|
194
|
+
const context = ctx;
|
|
195
|
+
await Promise.all([
|
|
196
|
+
(async () => {
|
|
197
|
+
try {
|
|
198
|
+
const user = identity(await context.client.request("/me"));
|
|
199
|
+
diagnostics.auth = { ...context.auth, authenticated: true, user };
|
|
200
|
+
checks.identity = { status: "passed" };
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
failures.push(error);
|
|
204
|
+
checks.identity = { status: "failed", error: errorInfo(error) };
|
|
205
|
+
}
|
|
206
|
+
})(),
|
|
207
|
+
(async () => {
|
|
208
|
+
try {
|
|
209
|
+
const version = object(await context.client.request("/settings/version"), "version");
|
|
210
|
+
if (typeof version.version !== "string" || !version.version)
|
|
211
|
+
throw new CliError("UNSUPPORTED_RESPONSE", "The server did not return a version.", 2);
|
|
212
|
+
diagnostics.serverVersion = version.version;
|
|
213
|
+
diagnostics.baselineStatus =
|
|
214
|
+
version.version.replace(/^v/, "") === API_BASELINE
|
|
215
|
+
? "tested"
|
|
216
|
+
: "unverified";
|
|
217
|
+
checks.version = { status: "passed" };
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
failures.push(error);
|
|
221
|
+
checks.version = { status: "failed", error: errorInfo(error) };
|
|
222
|
+
}
|
|
223
|
+
})(),
|
|
224
|
+
]);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
checks.identity = {
|
|
228
|
+
status: "skipped",
|
|
229
|
+
reason: "Resolve configuration and endpoint errors first.",
|
|
230
|
+
};
|
|
231
|
+
checks.version = {
|
|
232
|
+
status: "skipped",
|
|
233
|
+
reason: "Resolve configuration and endpoint errors first.",
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
if (failures.length) {
|
|
237
|
+
const first = failures[0];
|
|
238
|
+
return {
|
|
239
|
+
data: diagnostics,
|
|
240
|
+
exitCode: first instanceof CliError ? first.exitCode : 1,
|
|
241
|
+
error: {
|
|
242
|
+
code: "DOCTOR_FAILED",
|
|
243
|
+
message: "One or more diagnostics failed; inspect data for setup instructions.",
|
|
244
|
+
retryable: false,
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
return { data: diagnostics, meta: { apiVersion: diagnostics.serverVersion } };
|
|
249
|
+
}
|
|
250
|
+
export async function runAuth(action, options) {
|
|
251
|
+
try {
|
|
252
|
+
if (action === "status") {
|
|
253
|
+
const ctx = await createContext(options);
|
|
254
|
+
const status = {
|
|
255
|
+
...ctx.auth,
|
|
256
|
+
authenticated: false,
|
|
257
|
+
...(ctx.auth.source === "env"
|
|
258
|
+
? {
|
|
259
|
+
warning: "ITSAPLAN_API_KEY overrides stored credentials and --api-key.",
|
|
260
|
+
}
|
|
261
|
+
: {}),
|
|
262
|
+
};
|
|
263
|
+
try {
|
|
264
|
+
if (!ctx.auth.tokenAvailable)
|
|
265
|
+
throw new CliError("AUTHENTICATION_REQUIRED", "No API key is available; use auth login or ITSAPLAN_API_KEY with an explicit endpoint.", 3);
|
|
266
|
+
const user = identity(await ctx.client.request("/me"));
|
|
267
|
+
return { data: { ...status, authenticated: true, user } };
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
return {
|
|
271
|
+
data: status,
|
|
272
|
+
error: errorInfo(error),
|
|
273
|
+
exitCode: error instanceof CliError ? error.exitCode : 1,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (action === "logout") {
|
|
278
|
+
const apiUrl = await resolveApiUrl(options);
|
|
279
|
+
if (!apiUrl)
|
|
280
|
+
throw new CliError("MISSING_API_URL", "Select the API URL with --api-url or ITSAPLAN_URL.", 2);
|
|
281
|
+
const removed = await removeAuth(apiUrl);
|
|
282
|
+
return {
|
|
283
|
+
data: {
|
|
284
|
+
action: "logout",
|
|
285
|
+
apiUrl,
|
|
286
|
+
...removed,
|
|
287
|
+
serverRevoked: false,
|
|
288
|
+
environmentKeyStillActive: Boolean(process.env.ITSAPLAN_API_KEY),
|
|
289
|
+
warning: "Logout removes local credentials only; it does not revoke the server key or clear environment variables.",
|
|
290
|
+
},
|
|
291
|
+
outcome: "applied",
|
|
292
|
+
verification: "passed",
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
if (action !== "login")
|
|
296
|
+
throw new CliError("INVALID_ACTION", "Use auth login, status or logout.", 2);
|
|
297
|
+
if (options.storage !== undefined &&
|
|
298
|
+
options.storage !== "file" &&
|
|
299
|
+
options.storage !== "keyring")
|
|
300
|
+
throw new CliError("INVALID_STORAGE", "Choose --storage file or keyring.", 2);
|
|
301
|
+
const interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
302
|
+
if (!options.withToken && !interactive)
|
|
303
|
+
throw new CliError("INTERACTIVE_LOGIN_REQUIRED", "Non-interactive login requires --with-token and a trusted API URL.", 2);
|
|
304
|
+
let apiUrl = await resolveApiUrl(options);
|
|
305
|
+
if (!options.withToken && interactive) {
|
|
306
|
+
const answer = await input({ message: "API URL:", ...(apiUrl ? { default: apiUrl } : {}) }, { output: process.stderr });
|
|
307
|
+
apiUrl = normalizeApiUrl(answer);
|
|
308
|
+
}
|
|
309
|
+
if (!apiUrl)
|
|
310
|
+
throw new CliError("MISSING_API_URL", "Provide --api-url or ITSAPLAN_URL for non-interactive login.", 2);
|
|
311
|
+
const token = (options.withToken
|
|
312
|
+
? await stdinText()
|
|
313
|
+
: await password({ message: "Personal API key:", mask: "*" }, { output: process.stderr })).trim();
|
|
314
|
+
if (!token || /\s/.test(token))
|
|
315
|
+
throw new CliError("INVALID_TOKEN", "Provide exactly one non-empty API key.", 2);
|
|
316
|
+
const user = identity(await new HttpClient(apiUrl, token).request("/me"));
|
|
317
|
+
const storage = options.storage === "file" ? "file" : "keyring";
|
|
318
|
+
await saveAuth(apiUrl, token, storage);
|
|
319
|
+
return {
|
|
320
|
+
data: {
|
|
321
|
+
action: "login",
|
|
322
|
+
apiUrl,
|
|
323
|
+
authenticated: true,
|
|
324
|
+
storage,
|
|
325
|
+
user,
|
|
326
|
+
...(process.env.ITSAPLAN_API_KEY
|
|
327
|
+
? {
|
|
328
|
+
warning: "Login validated the supplied key. ITSAPLAN_API_KEY still overrides this stored credential for subsequent commands.",
|
|
329
|
+
}
|
|
330
|
+
: {}),
|
|
331
|
+
},
|
|
332
|
+
outcome: "applied",
|
|
333
|
+
verification: "passed",
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
if (action === "login" || action === "logout")
|
|
338
|
+
preflightError(error);
|
|
339
|
+
throw error;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
export async function runProject(ctx, action, ref, options = {}) {
|
|
343
|
+
if (action === "view") {
|
|
344
|
+
const target = projectRef(ref ?? ctx.project);
|
|
345
|
+
return {
|
|
346
|
+
data: projectSetup(await ctx.client.request(`/projects/${encodeURIComponent(target)}`)),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
if (action !== "list")
|
|
350
|
+
throw new CliError("INVALID_ACTION", "Use project list or view.", 2);
|
|
351
|
+
const limit = options.limit;
|
|
352
|
+
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1))
|
|
353
|
+
throw new CliError("INVALID_LIMIT", "Limit must be a positive safe integer.", 2);
|
|
354
|
+
const response = await ctx.client.request("/projects");
|
|
355
|
+
if (!Array.isArray(response))
|
|
356
|
+
throw new CliError("UNSUPPORTED_RESPONSE", "The server did not return a project list.", 2);
|
|
357
|
+
const all = response.map(projectSummary);
|
|
358
|
+
const items = limit === undefined ? all : all.slice(0, limit);
|
|
359
|
+
return {
|
|
360
|
+
data: { items: redact(items), nextCursor: null },
|
|
361
|
+
meta: {
|
|
362
|
+
...(limit === undefined ? {} : { limit }),
|
|
363
|
+
returned: items.length,
|
|
364
|
+
completeness: items.length < all.length ? "partial" : "complete",
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
async function validateProjectConfigTarget(path) {
|
|
369
|
+
try {
|
|
370
|
+
const stat = await lstat(path);
|
|
371
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
372
|
+
throw new CliError("UNSAFE_PROJECT_CONFIG", "Refusing to replace a symlink or non-regular .itsaplan.toml.", 2);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
if (error !== null &&
|
|
377
|
+
typeof error === "object" &&
|
|
378
|
+
"code" in error &&
|
|
379
|
+
error.code === "ENOENT")
|
|
380
|
+
return;
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
export async function runInit(ctx, project) {
|
|
385
|
+
let temporary;
|
|
386
|
+
try {
|
|
387
|
+
const target = projectRef(project, true);
|
|
388
|
+
const path = resolve(".itsaplan.toml");
|
|
389
|
+
await validateProjectConfigTarget(path);
|
|
390
|
+
const setup = projectSetup(await ctx.client.request(`/projects/${encodeURIComponent(target)}`));
|
|
391
|
+
const selected = projectSummary(setup.project);
|
|
392
|
+
if (selected.ref !== target)
|
|
393
|
+
throw new CliError("PROJECT_MISMATCH", "The API returned a different project ref; local configuration was not changed.", 2);
|
|
394
|
+
temporary = `${path}.${randomUUID()}.tmp`;
|
|
395
|
+
await writeFile(temporary, stringify({ project_ref: target }), {
|
|
396
|
+
encoding: "utf8",
|
|
397
|
+
mode: 0o600,
|
|
398
|
+
flag: "wx",
|
|
399
|
+
});
|
|
400
|
+
await validateProjectConfigTarget(path);
|
|
401
|
+
await rename(temporary, path);
|
|
402
|
+
temporary = undefined;
|
|
403
|
+
return {
|
|
404
|
+
data: { action: "init", projectRef: target, path, changed: true },
|
|
405
|
+
outcome: "applied",
|
|
406
|
+
verification: "passed",
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
return preflightError(error);
|
|
411
|
+
}
|
|
412
|
+
finally {
|
|
413
|
+
if (temporary !== undefined)
|
|
414
|
+
await rm(temporary, { force: true });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function protectedResourceWrite(path) {
|
|
418
|
+
let pathname = path.split("?")[0] ?? "";
|
|
419
|
+
for (;;) {
|
|
420
|
+
const decoded = decodeURIComponent(pathname);
|
|
421
|
+
if (decoded === pathname)
|
|
422
|
+
break;
|
|
423
|
+
pathname = decoded;
|
|
424
|
+
}
|
|
425
|
+
// Match decoded namespace boundaries, including repeated separators, before any write.
|
|
426
|
+
pathname = pathname.replace(/\/+/g, "/");
|
|
427
|
+
if (/^\/(?:issues|comments|checklists|worklogs|attachments)(?:\/|$)/i.test(pathname) ||
|
|
428
|
+
/^\/projects\/[^/]+\/issues(?:\/|$)/i.test(pathname))
|
|
429
|
+
return "issue";
|
|
430
|
+
if (/^\/projects\/[^/]+\/documents(?:\/|$)/i.test(pathname))
|
|
431
|
+
return "document";
|
|
432
|
+
return undefined;
|
|
433
|
+
}
|
|
434
|
+
export async function runApi(ctx, path, options) {
|
|
435
|
+
const method = (options.method ?? "GET").toUpperCase();
|
|
436
|
+
const write = method !== "GET" && method !== "HEAD";
|
|
437
|
+
let sent = false;
|
|
438
|
+
try {
|
|
439
|
+
if (!["GET", "HEAD", "POST", "PATCH", "PUT", "DELETE"].includes(method))
|
|
440
|
+
throw new CliError("INVALID_METHOD", "Use GET, HEAD, POST, PATCH, PUT or DELETE.", 2);
|
|
441
|
+
const safePath = validateRelativePath(path);
|
|
442
|
+
const resource = write ? protectedResourceWrite(safePath) : undefined;
|
|
443
|
+
if (resource)
|
|
444
|
+
throw new CliError(resource === "issue"
|
|
445
|
+
? "RAW_ISSUE_WRITE_FORBIDDEN"
|
|
446
|
+
: "RAW_DOCUMENT_WRITE_FORBIDDEN", resource === "issue"
|
|
447
|
+
? "Raw writes to issues and their child resources are disabled; use supported issue commands. Other issue mutations are not yet supported."
|
|
448
|
+
: "Raw Docs writes are disabled; use supported document and issue link-document commands. Other Docs mutations are not yet supported.", 2);
|
|
449
|
+
if (!write && options.input !== undefined)
|
|
450
|
+
throw new CliError("INPUT_NOT_SUPPORTED", "GET and HEAD do not accept --input.", 2);
|
|
451
|
+
let body;
|
|
452
|
+
if (options.input !== undefined) {
|
|
453
|
+
const text = await inputText(options.input);
|
|
454
|
+
try {
|
|
455
|
+
body = JSON.parse(text);
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
throw new CliError("INVALID_JSON", "The input must contain valid JSON.", 2);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (options.dryRun) {
|
|
462
|
+
const user = identity(await ctx.client.request("/me"));
|
|
463
|
+
return {
|
|
464
|
+
data: {
|
|
465
|
+
action: "api",
|
|
466
|
+
dryRun: true,
|
|
467
|
+
identity: user,
|
|
468
|
+
request: {
|
|
469
|
+
method,
|
|
470
|
+
path: safePath,
|
|
471
|
+
...(body === undefined ? {} : { body: redact(body) }),
|
|
472
|
+
},
|
|
473
|
+
warning: "No write was sent. Server-side permissions and validation are not guaranteed by this preview.",
|
|
474
|
+
},
|
|
475
|
+
...(write
|
|
476
|
+
? {
|
|
477
|
+
outcome: "not_applied",
|
|
478
|
+
verification: "not_run",
|
|
479
|
+
}
|
|
480
|
+
: {}),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
sent = true;
|
|
484
|
+
const data = await ctx.client.request(safePath, {
|
|
485
|
+
method,
|
|
486
|
+
...(body === undefined ? {} : { body }),
|
|
487
|
+
});
|
|
488
|
+
return {
|
|
489
|
+
data: redact(data),
|
|
490
|
+
...(write
|
|
491
|
+
? { outcome: "applied", verification: "passed" }
|
|
492
|
+
: {}),
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
if (!write)
|
|
497
|
+
throw error;
|
|
498
|
+
const outcome = sent ? classifyWriteFailure(error) : "not_applied";
|
|
499
|
+
const info = errorInfo(error);
|
|
500
|
+
return {
|
|
501
|
+
data: { action: "api", method },
|
|
502
|
+
outcome,
|
|
503
|
+
verification: "not_run",
|
|
504
|
+
exitCode: outcome === "unknown"
|
|
505
|
+
? 6
|
|
506
|
+
: error instanceof CliError
|
|
507
|
+
? error.exitCode
|
|
508
|
+
: 1,
|
|
509
|
+
error: {
|
|
510
|
+
...info,
|
|
511
|
+
...(outcome === "unknown"
|
|
512
|
+
? {
|
|
513
|
+
code: "WRITE_OUTCOME_UNKNOWN",
|
|
514
|
+
message: "The raw write outcome is unknown; inspect the resource before resending.",
|
|
515
|
+
}
|
|
516
|
+
: {}),
|
|
517
|
+
retryable: false,
|
|
518
|
+
},
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type Context, type GlobalOptions } from "./contracts.js";
|
|
2
|
+
export type Storage = "keyring" | "file";
|
|
3
|
+
export interface UserConfig {
|
|
4
|
+
api_url?: string;
|
|
5
|
+
credentials?: Record<string, {
|
|
6
|
+
storage: Storage;
|
|
7
|
+
}>;
|
|
8
|
+
}
|
|
9
|
+
export declare function getConfigPaths(): {
|
|
10
|
+
directory: string;
|
|
11
|
+
config: string;
|
|
12
|
+
credentials: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function normalizeApiUrl(input: string): string;
|
|
15
|
+
export declare function readUserConfig(): Promise<UserConfig>;
|
|
16
|
+
export declare function resolveApiUrl(options: GlobalOptions): Promise<string | undefined>;
|
|
17
|
+
export declare function resolveProject(options: GlobalOptions): Promise<string | undefined>;
|
|
18
|
+
export declare function saveAuth(input: string, token: string, storage?: Storage): Promise<void>;
|
|
19
|
+
export declare function removeAuth(input: string): Promise<{
|
|
20
|
+
removed: boolean;
|
|
21
|
+
storage?: string;
|
|
22
|
+
}>;
|
|
23
|
+
export declare function createContext(options: GlobalOptions): Promise<Context>;
|