@dac-tech-team-tw/itsaplan-cli 0.1.0 → 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.
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",
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)))
package/dist/issues.d.ts CHANGED
@@ -3,6 +3,8 @@ export interface IssueOptions {
3
3
  id?: string;
4
4
  project?: string;
5
5
  comments?: boolean;
6
+ includeState?: boolean;
7
+ outputSelected?: boolean;
6
8
  limit?: string | number;
7
9
  cursor?: string;
8
10
  search?: string;
package/dist/issues.js CHANGED
@@ -627,9 +627,12 @@ export async function runIssue(ctx, action, identifier, options) {
627
627
  throw new CliError("UNSUPPORTED_FILTER_COMBINATION", "--search cannot be combined with --assignee");
628
628
  if (options.search !== undefined && !options.search.trim())
629
629
  throw new CliError("INVALID_SEARCH", "--search must not be empty");
630
- let assigneeUserId;
631
- if (options.assignee !== undefined)
632
- assigneeUserId = await assignee(ctx, await getProject(ctx, ref), options.assignee);
630
+ const setup = options.assignee !== undefined || options.includeState
631
+ ? await getProject(ctx, ref)
632
+ : undefined;
633
+ const assigneeUserId = options.assignee !== undefined && setup
634
+ ? await assignee(ctx, setup, options.assignee)
635
+ : undefined;
633
636
  const items = objects(await ctx.client.request(`/projects/${encodeURIComponent(ref)}/issues${options.search !== undefined ? "/search" : ""}`, {
634
637
  query: {
635
638
  limit,
@@ -642,14 +645,57 @@ export async function runIssue(ctx, action, identifier, options) {
642
645
  typeof entry.identifier !== "string" ||
643
646
  typeof entry.title !== "string"))
644
647
  unsupported("List response is missing issue identifiers or titles");
648
+ let selectedItems = items;
649
+ if (options.includeState && setup) {
650
+ if (items.some((entry) => !positive(entry.columnId)))
651
+ unsupported("List response is missing issue state identifiers");
652
+ const columns = new Map(setup.columns.map(({ id, name, stateType }) => [
653
+ id,
654
+ { id, name, stateType },
655
+ ]));
656
+ selectedItems = items.map((entry) => ({
657
+ ...entry,
658
+ state: columns.get(entry.columnId) ?? {
659
+ id: entry.columnId,
660
+ unavailable: true,
661
+ },
662
+ }));
663
+ }
645
664
  return {
646
- data: { items: publicData(items), nextCursor: null },
647
- meta: { limit, returned: items.length, completeness: "unknown" },
665
+ data: { items: publicData(selectedItems), nextCursor: null },
666
+ meta: {
667
+ limit,
668
+ returned: items.length,
669
+ completeness: "unknown",
670
+ ...(options.outputSelected
671
+ ? {
672
+ scope: {
673
+ projectRef: setup?.project.ref ?? ref,
674
+ ...(options.search !== undefined
675
+ ? { search: options.search }
676
+ : {}),
677
+ ...(options.assignee !== undefined
678
+ ? { assignee: options.assignee, assigneeUserId }
679
+ : {}),
680
+ },
681
+ }
682
+ : {}),
683
+ },
648
684
  };
649
685
  }
650
686
  if (!["resolve", "view", "activity", "comments"].includes(action))
651
687
  throw new CliError("UNKNOWN_ACTION", `Unsupported issue action: ${action}`);
652
688
  const { item, ref } = await resolve(ctx, identifier, options);
689
+ const selectedMeta = options.outputSelected
690
+ ? {
691
+ scope: {
692
+ issueId: item.id,
693
+ identifier: item.identifier,
694
+ ...(ref ? { projectRef: ref } : {}),
695
+ projectId: item.projectId,
696
+ },
697
+ }
698
+ : {};
653
699
  if (action === "resolve")
654
700
  return {
655
701
  data: publicData({
@@ -660,6 +706,7 @@ export async function runIssue(ctx, action, identifier, options) {
660
706
  sequenceNumber: item.sequenceNumber,
661
707
  ...(ref ? { projectRef: ref } : {}),
662
708
  }),
709
+ ...(options.outputSelected ? { meta: selectedMeta } : {}),
663
710
  };
664
711
  if (action === "activity" || action === "comments") {
665
712
  const limit = options.limit === undefined ? 25 : integer(options.limit, "--limit", 100);
@@ -673,10 +720,27 @@ export async function runIssue(ctx, action, identifier, options) {
673
720
  limit,
674
721
  returned: page.items.length,
675
722
  completeness: page.nextCursor === null ? "complete" : "partial",
723
+ ...selectedMeta,
724
+ ...(options.outputSelected
725
+ ? {
726
+ read: {
727
+ entries: {
728
+ status: "read_page",
729
+ contentRead: true,
730
+ filter: action === "comments" ? "comments" : "all",
731
+ cursor: options.cursor ?? null,
732
+ nextCursor: encodeCursor(page.nextCursor),
733
+ },
734
+ attachments: { status: "not_read", contentRead: false },
735
+ },
736
+ }
737
+ : {}),
676
738
  },
677
739
  };
678
740
  }
679
741
  const setup = await setupFor(ctx, item, ref);
742
+ if (selectedMeta.scope)
743
+ selectedMeta.scope.projectRef = String(setup.project.ref);
680
744
  const attachments = objects(await ctx.client.request(`/issues/${item.id}/attachments`), "attachments");
681
745
  if (attachments.some((entry) => typeof entry.id !== "string" ||
682
746
  typeof entry.filename !== "string" ||
@@ -685,6 +749,18 @@ export async function runIssue(ctx, action, identifier, options) {
685
749
  unsupported("Invalid attachment metadata");
686
750
  const data = object(enriched(item, setup), "enriched issue");
687
751
  data.attachments = publicData(attachments);
752
+ const read = options.outputSelected
753
+ ? {
754
+ read: {
755
+ comments: { status: "not_read" },
756
+ attachments: {
757
+ status: "metadata_only",
758
+ returned: attachments.length,
759
+ contentRead: false,
760
+ },
761
+ },
762
+ }
763
+ : {};
688
764
  if (options.comments) {
689
765
  const page = await feed(ctx, item.id, 25, undefined, true);
690
766
  data.comments = {
@@ -694,16 +770,31 @@ export async function runIssue(ctx, action, identifier, options) {
694
770
  return {
695
771
  data,
696
772
  meta: {
773
+ ...selectedMeta,
697
774
  comments: {
698
775
  limit: 25,
699
776
  returned: page.items.length,
700
777
  completeness: page.nextCursor === null ? "complete" : "partial",
701
778
  },
702
779
  attachmentsContentRead: false,
780
+ ...(options.outputSelected
781
+ ? {
782
+ read: {
783
+ ...read.read,
784
+ comments: {
785
+ status: "read_page",
786
+ nextCursor: encodeCursor(page.nextCursor),
787
+ },
788
+ },
789
+ }
790
+ : {}),
703
791
  },
704
792
  };
705
793
  }
706
- return { data, meta: { attachmentsContentRead: false } };
794
+ return {
795
+ data,
796
+ meta: { attachmentsContentRead: false, ...selectedMeta, ...read },
797
+ };
707
798
  }
708
799
  // Shared resource workflows use the same identity, scope, input and outcome rules.
709
800
  export { actorFor, readBody, me, integer, object, objects, positive, projectRef, publicData, getProject, resolve, setupFor, writeFailure, verificationFailed, };
@@ -0,0 +1,12 @@
1
+ import { type Result } from "./contracts.js";
2
+ export type OutputKind = "project.list" | "project.view" | "issue.list" | "issue.view" | "issue.resolve" | "issue.comments" | "issue.activity";
3
+ export interface OutputOptions {
4
+ summary?: boolean;
5
+ fields?: string;
6
+ }
7
+ /** Validate against command schemas even when the eventual result is empty. */
8
+ export declare function validateOutput(kind: OutputKind, options: OutputOptions): void;
9
+ /** Only opt-in issue list state output needs project state lookup enrichment. */
10
+ export declare function needsIssueStates(kind: OutputKind, options: OutputOptions): boolean;
11
+ /** Shape data only: serialization, redaction and fetching belong to callers. */
12
+ export declare function projectOutput(kind: OutputKind, result: Result, options: OutputOptions): Result;