@dac-tech-team-tw/itsaplan-cli 0.1.0 → 0.3.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/dist/commands.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { access, lstat, readFile, rename, rm, writeFile, } from "node:fs/promises";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { resolve } from "node:path";
4
- import { input, password } from "@inquirer/prompts";
4
+ import { input, password, select } from "@inquirer/prompts";
5
5
  import { stringify } from "smol-toml";
6
6
  import { API_BASELINE, CLI_VERSION, CliError, } from "./contracts.js";
7
7
  import { createContext, getConfigPaths, normalizeApiUrl, readUserConfig, removeAuth, resolveApiUrl, saveAuth, } from "./config.js";
@@ -247,26 +247,142 @@ export async function runDoctor(options) {
247
247
  }
248
248
  return { data: diagnostics, meta: { apiVersion: diagnostics.serverVersion } };
249
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
+ }
250
295
  export async function runAuth(action, options) {
251
296
  try {
252
297
  if (action === "status") {
253
- const ctx = await createContext(options);
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
+ };
254
330
  const status = {
255
- ...ctx.auth,
331
+ source: effective.source,
332
+ apiUrl: null,
333
+ tokenAvailable: effective.tokenAvailable,
256
334
  authenticated: false,
257
- ...(ctx.auth.source === "env"
258
- ? {
259
- warning: "ITSAPLAN_API_KEY overrides stored credentials and --api-key.",
260
- }
261
- : {}),
335
+ saved,
336
+ effective,
337
+ ...(envDefined ? { warning: environmentAuthWarning() } : {}),
262
338
  };
339
+ let checkingSaved = false;
340
+ let verificationStarted = false;
263
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;
264
356
  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);
357
+ throw new CliError("AUTHENTICATION_REQUIRED", "沒有可用的 API key;請執行 auth login,或為 ITSAPLAN_API_KEY 明確指定 API 服務網址。", 3);
358
+ verificationStarted = true;
266
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
+ });
267
372
  return { data: { ...status, authenticated: true, user } };
268
373
  }
269
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
+ });
270
386
  return {
271
387
  data: status,
272
388
  error: errorInfo(error),
@@ -277,7 +393,7 @@ export async function runAuth(action, options) {
277
393
  if (action === "logout") {
278
394
  const apiUrl = await resolveApiUrl(options);
279
395
  if (!apiUrl)
280
- throw new CliError("MISSING_API_URL", "Select the API URL with --api-url or ITSAPLAN_URL.", 2);
396
+ throw new CliError("MISSING_API_URL", "請以 --api-url 或 ITSAPLAN_URL 指定 API 服務網址。", 2);
281
397
  const removed = await removeAuth(apiUrl);
282
398
  return {
283
399
  data: {
@@ -286,34 +402,70 @@ export async function runAuth(action, options) {
286
402
  ...removed,
287
403
  serverRevoked: false,
288
404
  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.",
405
+ warning: "登出只移除本機憑證,不會撤銷伺服器 key 或清除環境變數。",
290
406
  },
291
407
  outcome: "applied",
292
408
  verification: "passed",
293
409
  };
294
410
  }
295
411
  if (action !== "login")
296
- throw new CliError("INVALID_ACTION", "Use auth login, status or logout.", 2);
412
+ throw new CliError("INVALID_ACTION", "請使用 auth login、status 或 logout。", 2);
297
413
  if (options.storage !== undefined &&
298
414
  options.storage !== "file" &&
299
415
  options.storage !== "keyring")
300
- throw new CliError("INVALID_STORAGE", "Choose --storage file or keyring.", 2);
416
+ throw new CliError("INVALID_STORAGE", "請選擇 --storage file 或 keyring。", 2);
301
417
  const interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY);
302
418
  if (!options.withToken && !interactive)
303
- throw new CliError("INTERACTIVE_LOGIN_REQUIRED", "Non-interactive login requires --with-token and a trusted API URL.", 2);
419
+ throw new CliError("INTERACTIVE_LOGIN_REQUIRED", "非互動登入須使用 --with-token,並指定可信的 API 服務網址。", 2);
304
420
  let apiUrl = await resolveApiUrl(options);
305
421
  if (!options.withToken && interactive) {
306
- const answer = await input({ message: "API URL:", ...(apiUrl ? { default: apiUrl } : {}) }, { output: process.stderr });
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 });
307
431
  apiUrl = normalizeApiUrl(answer);
308
432
  }
309
433
  if (!apiUrl)
310
- throw new CliError("MISSING_API_URL", "Provide --api-url or ITSAPLAN_URL for non-interactive login.", 2);
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
+ }
311
455
  const token = (options.withToken
312
456
  ? await stdinText()
313
- : await password({ message: "Personal API key:", mask: "*" }, { output: process.stderr })).trim();
457
+ : await password({ message: "個人 API key:", mask: "*" }, { output: process.stderr })).trim();
314
458
  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"));
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
+ }
317
469
  const storage = options.storage === "file" ? "file" : "keyring";
318
470
  await saveAuth(apiUrl, token, storage);
319
471
  return {
@@ -323,10 +475,18 @@ export async function runAuth(action, options) {
323
475
  authenticated: true,
324
476
  storage,
325
477
  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
- }
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() }
330
490
  : {}),
331
491
  },
332
492
  outcome: "applied",
@@ -429,6 +589,9 @@ function protectedResourceWrite(path) {
429
589
  return "issue";
430
590
  if (/^\/projects\/[^/]+\/documents(?:\/|$)/i.test(pathname))
431
591
  return "document";
592
+ if (/^\/(?:initiatives|initiative-attachments)(?:\/|$)/i.test(pathname) ||
593
+ /^\/projects\/[^/]+\/initiatives(?:\/|$)/i.test(pathname))
594
+ return "initiative";
432
595
  return undefined;
433
596
  }
434
597
  export async function runApi(ctx, path, options) {
@@ -443,9 +606,13 @@ export async function runApi(ctx, path, options) {
443
606
  if (resource)
444
607
  throw new CliError(resource === "issue"
445
608
  ? "RAW_ISSUE_WRITE_FORBIDDEN"
446
- : "RAW_DOCUMENT_WRITE_FORBIDDEN", resource === "issue"
609
+ : resource === "document"
610
+ ? "RAW_DOCUMENT_WRITE_FORBIDDEN"
611
+ : "RAW_INITIATIVE_WRITE_FORBIDDEN", resource === "issue"
447
612
  ? "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);
613
+ : resource === "document"
614
+ ? "Raw Docs writes are disabled; use supported document and issue link-document commands. Other Docs mutations are not yet supported."
615
+ : "Raw initiative writes are disabled; use supported initiative commands. Other initiative mutations are not yet supported.", 2);
449
616
  if (!write && options.input !== undefined)
450
617
  throw new CliError("INPUT_NOT_SUPPORTED", "GET and HEAD do not accept --input.", 2);
451
618
  let body;
package/dist/config.d.ts CHANGED
@@ -5,6 +5,7 @@ export interface UserConfig {
5
5
  credentials?: Record<string, {
6
6
  storage: Storage;
7
7
  }>;
8
+ project_defaults?: Record<string, string>;
8
9
  }
9
10
  export declare function getConfigPaths(): {
10
11
  directory: string;
@@ -15,6 +16,14 @@ export declare function normalizeApiUrl(input: string): string;
15
16
  export declare function readUserConfig(): Promise<UserConfig>;
16
17
  export declare function resolveApiUrl(options: GlobalOptions): Promise<string | undefined>;
17
18
  export declare function resolveProject(options: GlobalOptions): Promise<string | undefined>;
19
+ export interface ProjectSelection {
20
+ projectRef?: string;
21
+ source: "flag" | "env" | "directory" | "user" | "missing";
22
+ }
23
+ export declare function resolveProjectSelection(options: GlobalOptions): Promise<ProjectSelection>;
24
+ export declare function saveUserProject(input: string, projectRef: string | undefined): Promise<{
25
+ changed: boolean;
26
+ }>;
18
27
  export declare function saveAuth(input: string, token: string, storage?: Storage): Promise<void>;
19
28
  export declare function removeAuth(input: string): Promise<{
20
29
  removed: boolean;
package/dist/config.js CHANGED
@@ -99,6 +99,20 @@ export async function readUserConfig() {
99
99
  config.credentials[normalized] = { storage: entry.storage };
100
100
  }
101
101
  }
102
+ if (raw.project_defaults !== undefined) {
103
+ if (!object(raw.project_defaults))
104
+ throw new CliError("INVALID_CONFIG", "Project defaults must be a table.");
105
+ config.project_defaults = {};
106
+ for (const [url, ref] of Object.entries(raw.project_defaults)) {
107
+ if (typeof ref !== "string" ||
108
+ !/^[A-Za-z0-9_-]+\.[A-Za-z][A-Za-z0-9_-]*$/u.test(ref))
109
+ throw new CliError("INVALID_CONFIG", "Saved project defaults must be full team.KEY refs.");
110
+ const normalized = normalizeApiUrl(url);
111
+ if (Object.hasOwn(config.project_defaults, normalized))
112
+ throw new CliError("INVALID_CONFIG", "Duplicate normalized API project endpoints.");
113
+ config.project_defaults[normalized] = ref;
114
+ }
115
+ }
102
116
  return config;
103
117
  }
104
118
  export async function resolveApiUrl(options) {
@@ -119,6 +133,9 @@ async function exists(path) {
119
133
  }
120
134
  }
121
135
  export async function resolveProject(options) {
136
+ return (await resolveProjectSelection(options)).projectRef;
137
+ }
138
+ export async function resolveProjectSelection(options) {
122
139
  const cwd = process.cwd();
123
140
  const home = resolve(homedir());
124
141
  let boundary = cwd;
@@ -159,11 +176,25 @@ export async function resolveProject(options) {
159
176
  break;
160
177
  current = dirname(current);
161
178
  }
162
- const chosen = options.project ?? process.env.ITSAPLAN_PROJECT ?? project;
179
+ let chosen = options.project ?? process.env.ITSAPLAN_PROJECT ?? project;
180
+ let source = options.project !== undefined
181
+ ? "flag"
182
+ : process.env.ITSAPLAN_PROJECT !== undefined
183
+ ? "env"
184
+ : project !== undefined
185
+ ? "directory"
186
+ : "missing";
187
+ if (chosen === undefined) {
188
+ const apiUrl = await resolveApiUrl(options);
189
+ if (apiUrl !== undefined)
190
+ chosen = (await readUserConfig()).project_defaults?.[apiUrl];
191
+ if (chosen !== undefined)
192
+ source = "user";
193
+ }
163
194
  if (chosen !== undefined &&
164
195
  !/^(?:[A-Za-z0-9_-]+\.)?[A-Za-z][A-Za-z0-9_-]*$/u.test(chosen))
165
196
  throw new CliError("INVALID_PROJECT", "Use a project key or team.KEY ref.");
166
- return chosen;
197
+ return { projectRef: chosen, source };
167
198
  }
168
199
  async function windowsSid() {
169
200
  try {
@@ -275,6 +306,35 @@ async function atomicWrite(path, content) {
275
306
  await prepared.discard();
276
307
  }
277
308
  }
309
+ export async function saveUserProject(input, projectRef) {
310
+ const apiUrl = normalizeApiUrl(input);
311
+ if (projectRef !== undefined &&
312
+ !/^[A-Za-z0-9_-]+\.[A-Za-z][A-Za-z0-9_-]*$/u.test(projectRef))
313
+ throw new CliError("INVALID_PROJECT", "Use a full team.KEY project ref.");
314
+ const config = await readUserConfig();
315
+ if (config.project_defaults?.[apiUrl] === projectRef)
316
+ return { changed: false };
317
+ if (projectRef === undefined) {
318
+ delete config.project_defaults?.[apiUrl];
319
+ if (Object.keys(config.project_defaults ?? {}).length === 0)
320
+ delete config.project_defaults;
321
+ }
322
+ else
323
+ config.project_defaults = {
324
+ ...config.project_defaults,
325
+ [apiUrl]: projectRef,
326
+ };
327
+ await prepareDirectory();
328
+ const prepared = await prepareAtomicWrite(getConfigPaths().config, stringify(config));
329
+ try {
330
+ await prepared.commit();
331
+ }
332
+ catch (error) {
333
+ await prepared.discard().catch(() => { });
334
+ throw error;
335
+ }
336
+ return { changed: true };
337
+ }
278
338
  async function readCredentials() {
279
339
  const { directory, credentials } = getConfigPaths();
280
340
  if (!(await exists(credentials)))
@@ -44,4 +44,4 @@ export interface Context {
44
44
  };
45
45
  }
46
46
  export declare const CLI_VERSION: string;
47
- export declare const API_BASELINE = "1.9.0";
47
+ export declare const API_BASELINE = "1.10.0";
package/dist/contracts.js CHANGED
@@ -11,4 +11,4 @@ export class CliError extends Error {
11
11
  }
12
12
  }
13
13
  export const CLI_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
14
- export const API_BASELINE = "1.9.0";
14
+ export const API_BASELINE = "1.10.0";
@@ -0,0 +1,16 @@
1
+ import { type Context, type Result } from "./contracts.js";
2
+ import { type IssueOptions } from "./issues.js";
3
+ export interface InitiativeOptions extends IssueOptions {
4
+ page?: string | number;
5
+ status?: string;
6
+ sort?: string;
7
+ dir?: string;
8
+ owner?: string;
9
+ removeOwner?: boolean;
10
+ startDate?: string;
11
+ removeStartDate?: boolean;
12
+ targetDate?: string;
13
+ removeTargetDate?: boolean;
14
+ label?: string[];
15
+ }
16
+ export declare function runInitiative(ctx: Context, action: string, initiativeId: string | undefined, options: InitiativeOptions): Promise<Result>;