@dac-tech-team-tw/itsaplan-cli 0.0.0-stage → 0.2.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.
@@ -0,0 +1,681 @@
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, select } 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
+ function environmentAuthWarning() {
251
+ const endpoint = process.env.ITSAPLAN_URL
252
+ ? "後續仍使用 ITSAPLAN_URL 指向的環境憑證,身份可能與已存登入不同。"
253
+ : "缺少 ITSAPLAN_URL,後續未帶 --api-url 的指令仍會失敗(EXPLICIT_ENDPOINT_REQUIRED)。";
254
+ const remedy = process.platform === "win32"
255
+ ? "如要使用已存登入,請在目前 shell 移除 ITSAPLAN_API_KEY 與 ITSAPLAN_URL,再執行 iap auth status。"
256
+ : "如要暫時使用已存登入:env -u ITSAPLAN_API_KEY -u ITSAPLAN_URL iap auth status";
257
+ return `ITSAPLAN_API_KEY 仍優先於已儲存的登入。\n${endpoint}\n${remedy}`;
258
+ }
259
+ async function loginEffectiveAuth(apiUrl, user) {
260
+ const effective = {
261
+ source: process.env.ITSAPLAN_API_KEY !== undefined ? "env" : "unresolved",
262
+ apiUrl: null,
263
+ tokenAvailable: process.env.ITSAPLAN_API_KEY !== undefined
264
+ ? Boolean(process.env.ITSAPLAN_API_KEY)
265
+ : null,
266
+ authenticated: false,
267
+ usable: null,
268
+ verification: "not_run",
269
+ };
270
+ try {
271
+ // Describe the next unqualified command, not the flags used for this login.
272
+ if (process.env.ITSAPLAN_URL !== undefined)
273
+ effective.apiUrl = normalizeApiUrl(process.env.ITSAPLAN_URL);
274
+ const ctx = await createContext({});
275
+ Object.assign(effective, ctx.auth);
276
+ if (!ctx.auth.tokenAvailable) {
277
+ effective.usable = false;
278
+ effective.error = errorInfo(new CliError("AUTHENTICATION_REQUIRED", "目前生效的 API 服務網址沒有可用的 API key。", 3));
279
+ }
280
+ else if (ctx.auth.source === "config" && ctx.auth.apiUrl === apiUrl) {
281
+ Object.assign(effective, {
282
+ authenticated: true,
283
+ usable: true,
284
+ verification: "passed",
285
+ user,
286
+ });
287
+ }
288
+ }
289
+ catch (error) {
290
+ effective.usable = false;
291
+ effective.error = errorInfo(error);
292
+ }
293
+ return effective;
294
+ }
295
+ export async function runAuth(action, options) {
296
+ try {
297
+ if (action === "status") {
298
+ const config = await readUserConfig();
299
+ const storage = config.api_url
300
+ ? config.credentials?.[config.api_url]?.storage
301
+ : undefined;
302
+ const saved = {
303
+ apiUrl: config.api_url ?? null,
304
+ configured: storage !== undefined,
305
+ storage: storage ?? null,
306
+ tokenAvailable: null,
307
+ authenticated: false,
308
+ verification: "not_run",
309
+ credentials: Object.entries(config.credentials ?? {}).map(([apiUrl, entry]) => ({ apiUrl, storage: entry.storage })),
310
+ };
311
+ const envDefined = process.env.ITSAPLAN_API_KEY !== undefined;
312
+ const effective = {
313
+ source: envDefined
314
+ ? "env"
315
+ : options.apiKey !== undefined
316
+ ? "flag"
317
+ : storage
318
+ ? "config"
319
+ : "missing",
320
+ apiUrl: null,
321
+ tokenAvailable: envDefined
322
+ ? Boolean(process.env.ITSAPLAN_API_KEY)
323
+ : options.apiKey !== undefined
324
+ ? Boolean(options.apiKey)
325
+ : null,
326
+ authenticated: false,
327
+ usable: false,
328
+ verification: "not_run",
329
+ };
330
+ const status = {
331
+ source: effective.source,
332
+ apiUrl: null,
333
+ tokenAvailable: effective.tokenAvailable,
334
+ authenticated: false,
335
+ saved,
336
+ effective,
337
+ ...(envDefined ? { warning: environmentAuthWarning() } : {}),
338
+ };
339
+ let checkingSaved = false;
340
+ let verificationStarted = false;
341
+ try {
342
+ const explicitUrl = options.apiUrl ?? process.env.ITSAPLAN_URL;
343
+ if (explicitUrl !== undefined)
344
+ effective.apiUrl = normalizeApiUrl(explicitUrl);
345
+ else if (!envDefined && options.apiKey === undefined)
346
+ effective.apiUrl = config.api_url ?? null;
347
+ const ctx = await createContext(options);
348
+ Object.assign(effective, ctx.auth);
349
+ Object.assign(status, ctx.auth);
350
+ checkingSaved =
351
+ ctx.auth.source === "config" && ctx.auth.apiUrl === saved.apiUrl;
352
+ if (!envDefined &&
353
+ ctx.auth.apiUrl === saved.apiUrl &&
354
+ ctx.auth.storage === saved.storage)
355
+ saved.tokenAvailable = ctx.auth.tokenAvailable;
356
+ if (!ctx.auth.tokenAvailable)
357
+ throw new CliError("AUTHENTICATION_REQUIRED", "沒有可用的 API key;請執行 auth login,或為 ITSAPLAN_API_KEY 明確指定 API 服務網址。", 3);
358
+ verificationStarted = true;
359
+ const user = identity(await ctx.client.request("/me"));
360
+ Object.assign(effective, {
361
+ authenticated: true,
362
+ usable: true,
363
+ verification: "passed",
364
+ user,
365
+ });
366
+ if (checkingSaved)
367
+ Object.assign(saved, {
368
+ authenticated: true,
369
+ verification: "passed",
370
+ user,
371
+ });
372
+ return { data: { ...status, authenticated: true, user } };
373
+ }
374
+ catch (error) {
375
+ effective.error = errorInfo(error);
376
+ if (verificationStarted) {
377
+ effective.verification = "failed";
378
+ if (checkingSaved)
379
+ saved.verification = "failed";
380
+ }
381
+ Object.assign(status, {
382
+ source: effective.source,
383
+ apiUrl: effective.apiUrl,
384
+ tokenAvailable: effective.tokenAvailable,
385
+ });
386
+ return {
387
+ data: status,
388
+ error: errorInfo(error),
389
+ exitCode: error instanceof CliError ? error.exitCode : 1,
390
+ };
391
+ }
392
+ }
393
+ if (action === "logout") {
394
+ const apiUrl = await resolveApiUrl(options);
395
+ if (!apiUrl)
396
+ throw new CliError("MISSING_API_URL", "請以 --api-url 或 ITSAPLAN_URL 指定 API 服務網址。", 2);
397
+ const removed = await removeAuth(apiUrl);
398
+ return {
399
+ data: {
400
+ action: "logout",
401
+ apiUrl,
402
+ ...removed,
403
+ serverRevoked: false,
404
+ environmentKeyStillActive: Boolean(process.env.ITSAPLAN_API_KEY),
405
+ warning: "登出只移除本機憑證,不會撤銷伺服器 key 或清除環境變數。",
406
+ },
407
+ outcome: "applied",
408
+ verification: "passed",
409
+ };
410
+ }
411
+ if (action !== "login")
412
+ throw new CliError("INVALID_ACTION", "請使用 auth login、status 或 logout。", 2);
413
+ if (options.storage !== undefined &&
414
+ options.storage !== "file" &&
415
+ options.storage !== "keyring")
416
+ throw new CliError("INVALID_STORAGE", "請選擇 --storage file 或 keyring。", 2);
417
+ const interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY);
418
+ if (!options.withToken && !interactive)
419
+ throw new CliError("INTERACTIVE_LOGIN_REQUIRED", "非互動登入須使用 --with-token,並指定可信的 API 服務網址。", 2);
420
+ let apiUrl = await resolveApiUrl(options);
421
+ if (!options.withToken && interactive) {
422
+ const source = options.apiUrl !== undefined
423
+ ? "--api-url"
424
+ : process.env.ITSAPLAN_URL !== undefined
425
+ ? "ITSAPLAN_URL"
426
+ : "已儲存設定";
427
+ const answer = await input({
428
+ message: `API 服務網址(API 部署網域,非網頁網址;含 https://${apiUrl ? `;預設來自 ${source}` : ""}):`,
429
+ ...(apiUrl ? { default: apiUrl } : {}),
430
+ }, { output: process.stderr });
431
+ apiUrl = normalizeApiUrl(answer);
432
+ }
433
+ if (!apiUrl)
434
+ throw new CliError("MISSING_API_URL", "非互動登入須以 --api-url 或 ITSAPLAN_URL 指定 API 服務網址。", 2);
435
+ if (!options.withToken &&
436
+ interactive &&
437
+ process.env.ITSAPLAN_API_KEY !== undefined) {
438
+ const proceed = await select({
439
+ message: environmentAuthWarning(),
440
+ choices: [
441
+ {
442
+ name: "取消,不讀取或儲存新的 key",
443
+ value: false,
444
+ },
445
+ {
446
+ name: "繼續儲存,但不改變環境憑證的優先序",
447
+ value: true,
448
+ },
449
+ ],
450
+ default: false,
451
+ }, { output: process.stderr });
452
+ if (!proceed)
453
+ throw new CliError("LOGIN_CANCELLED", "已取消登入;未讀取或儲存 key,也未變更環境設定。", 2);
454
+ }
455
+ const token = (options.withToken
456
+ ? await stdinText()
457
+ : await password({ message: "個人 API key:", mask: "*" }, { output: process.stderr })).trim();
458
+ if (!token || /\s/.test(token))
459
+ throw new CliError("INVALID_TOKEN", "請提供一組非空白且不含空白字元的 API key。", 2);
460
+ let user;
461
+ try {
462
+ user = identity(await new HttpClient(apiUrl, token).request("/me"));
463
+ }
464
+ catch (error) {
465
+ if (error instanceof CliError && error.code === "REDIRECT_REJECTED")
466
+ throw new CliError(error.code, "此網址回傳重新導向,可能是網頁登入網址,而非 API 服務網址。CLI 不會跟隨重新導向,key 也未轉送。請向管理者確認 API 部署網域,再以 iap auth login --api-url <API_URL> 重試。這不代表 key 無效;同一網頁網址即使手動輸入仍會被拒絕。", error.exitCode, error.details);
467
+ throw error;
468
+ }
469
+ const storage = options.storage === "file" ? "file" : "keyring";
470
+ await saveAuth(apiUrl, token, storage);
471
+ return {
472
+ data: {
473
+ action: "login",
474
+ apiUrl,
475
+ authenticated: true,
476
+ storage,
477
+ user,
478
+ saved: {
479
+ apiUrl,
480
+ configured: true,
481
+ storage,
482
+ tokenAvailable: true,
483
+ authenticated: true,
484
+ verification: "passed",
485
+ user,
486
+ },
487
+ effective: await loginEffectiveAuth(apiUrl, user),
488
+ ...(process.env.ITSAPLAN_API_KEY !== undefined
489
+ ? { warning: environmentAuthWarning() }
490
+ : {}),
491
+ },
492
+ outcome: "applied",
493
+ verification: "passed",
494
+ };
495
+ }
496
+ catch (error) {
497
+ if (action === "login" || action === "logout")
498
+ preflightError(error);
499
+ throw error;
500
+ }
501
+ }
502
+ export async function runProject(ctx, action, ref, options = {}) {
503
+ if (action === "view") {
504
+ const target = projectRef(ref ?? ctx.project);
505
+ return {
506
+ data: projectSetup(await ctx.client.request(`/projects/${encodeURIComponent(target)}`)),
507
+ };
508
+ }
509
+ if (action !== "list")
510
+ throw new CliError("INVALID_ACTION", "Use project list or view.", 2);
511
+ const limit = options.limit;
512
+ if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1))
513
+ throw new CliError("INVALID_LIMIT", "Limit must be a positive safe integer.", 2);
514
+ const response = await ctx.client.request("/projects");
515
+ if (!Array.isArray(response))
516
+ throw new CliError("UNSUPPORTED_RESPONSE", "The server did not return a project list.", 2);
517
+ const all = response.map(projectSummary);
518
+ const items = limit === undefined ? all : all.slice(0, limit);
519
+ return {
520
+ data: { items: redact(items), nextCursor: null },
521
+ meta: {
522
+ ...(limit === undefined ? {} : { limit }),
523
+ returned: items.length,
524
+ completeness: items.length < all.length ? "partial" : "complete",
525
+ },
526
+ };
527
+ }
528
+ async function validateProjectConfigTarget(path) {
529
+ try {
530
+ const stat = await lstat(path);
531
+ if (!stat.isFile() || stat.isSymbolicLink()) {
532
+ throw new CliError("UNSAFE_PROJECT_CONFIG", "Refusing to replace a symlink or non-regular .itsaplan.toml.", 2);
533
+ }
534
+ }
535
+ catch (error) {
536
+ if (error !== null &&
537
+ typeof error === "object" &&
538
+ "code" in error &&
539
+ error.code === "ENOENT")
540
+ return;
541
+ throw error;
542
+ }
543
+ }
544
+ export async function runInit(ctx, project) {
545
+ let temporary;
546
+ try {
547
+ const target = projectRef(project, true);
548
+ const path = resolve(".itsaplan.toml");
549
+ await validateProjectConfigTarget(path);
550
+ const setup = projectSetup(await ctx.client.request(`/projects/${encodeURIComponent(target)}`));
551
+ const selected = projectSummary(setup.project);
552
+ if (selected.ref !== target)
553
+ throw new CliError("PROJECT_MISMATCH", "The API returned a different project ref; local configuration was not changed.", 2);
554
+ temporary = `${path}.${randomUUID()}.tmp`;
555
+ await writeFile(temporary, stringify({ project_ref: target }), {
556
+ encoding: "utf8",
557
+ mode: 0o600,
558
+ flag: "wx",
559
+ });
560
+ await validateProjectConfigTarget(path);
561
+ await rename(temporary, path);
562
+ temporary = undefined;
563
+ return {
564
+ data: { action: "init", projectRef: target, path, changed: true },
565
+ outcome: "applied",
566
+ verification: "passed",
567
+ };
568
+ }
569
+ catch (error) {
570
+ return preflightError(error);
571
+ }
572
+ finally {
573
+ if (temporary !== undefined)
574
+ await rm(temporary, { force: true });
575
+ }
576
+ }
577
+ function protectedResourceWrite(path) {
578
+ let pathname = path.split("?")[0] ?? "";
579
+ for (;;) {
580
+ const decoded = decodeURIComponent(pathname);
581
+ if (decoded === pathname)
582
+ break;
583
+ pathname = decoded;
584
+ }
585
+ // Match decoded namespace boundaries, including repeated separators, before any write.
586
+ pathname = pathname.replace(/\/+/g, "/");
587
+ if (/^\/(?:issues|comments|checklists|worklogs|attachments)(?:\/|$)/i.test(pathname) ||
588
+ /^\/projects\/[^/]+\/issues(?:\/|$)/i.test(pathname))
589
+ return "issue";
590
+ if (/^\/projects\/[^/]+\/documents(?:\/|$)/i.test(pathname))
591
+ return "document";
592
+ return undefined;
593
+ }
594
+ export async function runApi(ctx, path, options) {
595
+ const method = (options.method ?? "GET").toUpperCase();
596
+ const write = method !== "GET" && method !== "HEAD";
597
+ let sent = false;
598
+ try {
599
+ if (!["GET", "HEAD", "POST", "PATCH", "PUT", "DELETE"].includes(method))
600
+ throw new CliError("INVALID_METHOD", "Use GET, HEAD, POST, PATCH, PUT or DELETE.", 2);
601
+ const safePath = validateRelativePath(path);
602
+ const resource = write ? protectedResourceWrite(safePath) : undefined;
603
+ if (resource)
604
+ throw new CliError(resource === "issue"
605
+ ? "RAW_ISSUE_WRITE_FORBIDDEN"
606
+ : "RAW_DOCUMENT_WRITE_FORBIDDEN", resource === "issue"
607
+ ? "Raw writes to issues and their child resources are disabled; use supported issue commands. Other issue mutations are not yet supported."
608
+ : "Raw Docs writes are disabled; use supported document and issue link-document commands. Other Docs mutations are not yet supported.", 2);
609
+ if (!write && options.input !== undefined)
610
+ throw new CliError("INPUT_NOT_SUPPORTED", "GET and HEAD do not accept --input.", 2);
611
+ let body;
612
+ if (options.input !== undefined) {
613
+ const text = await inputText(options.input);
614
+ try {
615
+ body = JSON.parse(text);
616
+ }
617
+ catch {
618
+ throw new CliError("INVALID_JSON", "The input must contain valid JSON.", 2);
619
+ }
620
+ }
621
+ if (options.dryRun) {
622
+ const user = identity(await ctx.client.request("/me"));
623
+ return {
624
+ data: {
625
+ action: "api",
626
+ dryRun: true,
627
+ identity: user,
628
+ request: {
629
+ method,
630
+ path: safePath,
631
+ ...(body === undefined ? {} : { body: redact(body) }),
632
+ },
633
+ warning: "No write was sent. Server-side permissions and validation are not guaranteed by this preview.",
634
+ },
635
+ ...(write
636
+ ? {
637
+ outcome: "not_applied",
638
+ verification: "not_run",
639
+ }
640
+ : {}),
641
+ };
642
+ }
643
+ sent = true;
644
+ const data = await ctx.client.request(safePath, {
645
+ method,
646
+ ...(body === undefined ? {} : { body }),
647
+ });
648
+ return {
649
+ data: redact(data),
650
+ ...(write
651
+ ? { outcome: "applied", verification: "passed" }
652
+ : {}),
653
+ };
654
+ }
655
+ catch (error) {
656
+ if (!write)
657
+ throw error;
658
+ const outcome = sent ? classifyWriteFailure(error) : "not_applied";
659
+ const info = errorInfo(error);
660
+ return {
661
+ data: { action: "api", method },
662
+ outcome,
663
+ verification: "not_run",
664
+ exitCode: outcome === "unknown"
665
+ ? 6
666
+ : error instanceof CliError
667
+ ? error.exitCode
668
+ : 1,
669
+ error: {
670
+ ...info,
671
+ ...(outcome === "unknown"
672
+ ? {
673
+ code: "WRITE_OUTCOME_UNKNOWN",
674
+ message: "The raw write outcome is unknown; inspect the resource before resending.",
675
+ }
676
+ : {}),
677
+ retryable: false,
678
+ },
679
+ };
680
+ }
681
+ }