opctl 0.1.5 → 0.1.7

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/cli.js CHANGED
@@ -3,7 +3,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFile
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { Command } from "commander";
5
5
  import createClient from "openapi-fetch";
6
- import { dirname, join, resolve } from "node:path";
6
+ import { basename, dirname, join, resolve } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
  import { mkdir, writeFile } from "node:fs/promises";
9
9
  //#region src/client/auth.ts
@@ -173,8 +173,10 @@ function normalizeWorkPackageSummary(resource) {
173
173
  subject: stringField(object.subject),
174
174
  status: getLink(object, "status")?.title,
175
175
  assignee: getLink(object, "assignee")?.title,
176
+ responsible: getLink(object, "responsible")?.title,
176
177
  project: getLink(object, "project")?.title,
177
178
  type: getLink(object, "type")?.title,
179
+ priority: getLink(object, "priority")?.title,
178
180
  href: getLink(object, "self")?.href,
179
181
  updatedAt: stringField(object.updatedAt),
180
182
  shortDescription: shortDescription(extractDescription(object.description)),
@@ -192,6 +194,21 @@ function normalizeWorkPackageDetail(resource) {
192
194
  actions: actionLinks(object)
193
195
  };
194
196
  }
197
+ function normalizeAttachment(resource) {
198
+ const object = asObject(resource) ?? {};
199
+ return {
200
+ id: numberField(object.id),
201
+ fileName: stringField(object.fileName),
202
+ contentType: stringField(object.contentType),
203
+ fileSize: numberField(object.fileSize) ?? numberField(object.filesize),
204
+ description: extractDescription(object.description),
205
+ href: getLink(object, "self")?.href,
206
+ downloadHref: getLink(object, "staticDownloadLocation")?.href ?? getLink(object, "downloadLocation")?.href,
207
+ containerHref: getLink(object, "container")?.href,
208
+ author: getLink(object, "author")?.title,
209
+ createdAt: stringField(object.createdAt)
210
+ };
211
+ }
195
212
  function actionLinks(resource) {
196
213
  const links = asObject(asObject(resource)?._links) ?? {};
197
214
  const actions = {};
@@ -235,6 +252,43 @@ function stringField(value) {
235
252
  function numberField(value) {
236
253
  return typeof value === "number" ? value : void 0;
237
254
  }
255
+ function normalizeType(resource) {
256
+ const object = asObject(resource) ?? {};
257
+ return {
258
+ id: numberField(object.id),
259
+ name: stringField(object.name),
260
+ href: requireLinkHref(resource, "self"),
261
+ position: numberField(object.position),
262
+ isDefault: booleanField(object.isDefault),
263
+ isMilestone: booleanField(object.isMilestone)
264
+ };
265
+ }
266
+ function normalizeStatus(resource) {
267
+ const object = asObject(resource) ?? {};
268
+ return {
269
+ id: numberField(object.id),
270
+ name: stringField(object.name),
271
+ href: requireLinkHref(resource, "self"),
272
+ position: numberField(object.position),
273
+ isClosed: booleanField(object.isClosed),
274
+ isDefault: booleanField(object.isDefault),
275
+ isReadonly: booleanField(object.isReadonly)
276
+ };
277
+ }
278
+ function normalizePriority(resource) {
279
+ const object = asObject(resource) ?? {};
280
+ return {
281
+ id: numberField(object.id),
282
+ name: stringField(object.name),
283
+ href: requireLinkHref(resource, "self"),
284
+ position: numberField(object.position),
285
+ isDefault: booleanField(object.isDefault),
286
+ isActive: booleanField(object.isActive)
287
+ };
288
+ }
289
+ function booleanField(value) {
290
+ return typeof value === "boolean" ? value : void 0;
291
+ }
238
292
  //#endregion
239
293
  //#region src/output/text.ts
240
294
  function renderKeyValue(value) {
@@ -258,6 +312,20 @@ function normalizeCollection(resource, mapper) {
258
312
  };
259
313
  }
260
314
  //#endregion
315
+ //#region src/client/urls.ts
316
+ /**
317
+ * Convert an API href like `/api/v3/work_packages/23732` (or with instance
318
+ * prefix `/openproject/api/v3/work_packages/23732`) into a browser-facing URL
319
+ * by stripping the `/api/v3` segment and combining with the instance base URL.
320
+ */
321
+ function apiHrefToBrowserUrl(baseUrl, href) {
322
+ if (!href) return void 0;
323
+ const markerIndex = href.indexOf("/api/v3/");
324
+ if (markerIndex === -1) return void 0;
325
+ const pathAfterApi = href.slice(markerIndex + 8).split("?")[0].split("#")[0];
326
+ return `${baseUrl.replace(/\/+$/, "")}/${pathAfterApi}`;
327
+ }
328
+ //#endregion
261
329
  //#region src/client/openProjectClient.ts
262
330
  var OpenProjectClient = class {
263
331
  config;
@@ -285,15 +353,28 @@ var OpenProjectClient = class {
285
353
  return this.request("GET", `/api/v3/work_packages/${encodeURIComponent(String(id))}`);
286
354
  }
287
355
  async getWorkPackage(id) {
288
- return normalizeWorkPackageDetail(await this.getWorkPackageRaw(id));
356
+ const detail = normalizeWorkPackageDetail(await this.getWorkPackageRaw(id));
357
+ return {
358
+ ...detail,
359
+ browserUrl: apiHrefToBrowserUrl(this.config.baseUrl, detail.href)
360
+ };
289
361
  }
290
362
  async searchWorkPackages(options) {
291
363
  const effectiveProject = options.project ?? this.config.defaultProject;
292
364
  const basePath = effectiveProject ? `/api/v3/projects/${encodeURIComponent(effectiveProject)}/work_packages` : "/api/v3/work_packages";
293
365
  const params = new URLSearchParams({ pageSize: String(normalizePageSize(options.pageSize)) });
294
- const filters = buildWorkPackageFilters(options);
366
+ const filters = await this.buildResolvedWorkPackageFilters(options);
295
367
  if (filters.length > 0) params.set("filters", JSON.stringify(filters));
296
- return normalizeCollection(await this.request("GET", `${basePath}?${params}`), normalizeWorkPackageSummary);
368
+ const sortBy = buildSortBy(options.sort);
369
+ if (sortBy.length > 0) params.set("sortBy", JSON.stringify(sortBy));
370
+ const baseUrl = this.config.baseUrl;
371
+ return normalizeCollection(await this.request("GET", `${basePath}?${params}`), (raw) => {
372
+ const wp = normalizeWorkPackageSummary(raw);
373
+ return {
374
+ ...wp,
375
+ browserUrl: apiHrefToBrowserUrl(baseUrl, wp.href)
376
+ };
377
+ });
297
378
  }
298
379
  async mine(options) {
299
380
  await this.getMe();
@@ -303,6 +384,21 @@ var OpenProjectClient = class {
303
384
  open: true
304
385
  });
305
386
  }
387
+ async accountable(options) {
388
+ await this.getMe();
389
+ return this.searchWorkPackages({
390
+ ...options,
391
+ responsibleMe: true,
392
+ open: true
393
+ });
394
+ }
395
+ async listWorkPackageAttachments(id) {
396
+ return normalizeCollection(await this.request("GET", `/api/v3/work_packages/${encodeURIComponent(String(id))}/attachments`), normalizeAttachment);
397
+ }
398
+ async downloadAttachment(attachment) {
399
+ if (!attachment.downloadHref) throw new OpctlError(`attachment ${attachment.id ?? attachment.fileName ?? "unknown"} has no download href`, EXIT_CODES.validation);
400
+ return (await this.fetchRaw("GET", attachment.downloadHref)).arrayBuffer();
401
+ }
306
402
  async commentWorkPackage(id, message, dryRun) {
307
403
  if (!this.config.allowWrite) throw new WriteBlockedError();
308
404
  if (message.trim() === "") throw new OpctlError("comment message must not be empty", EXIT_CODES.validation);
@@ -329,8 +425,103 @@ var OpenProjectClient = class {
329
425
  link: getLink(response, "self")?.href ?? requireLinkHref(raw, "self")
330
426
  };
331
427
  }
428
+ async listTypes(options = {}) {
429
+ const path = options.project ? `/api/v3/projects/${encodeURIComponent(options.project)}/types` : "/api/v3/types";
430
+ return normalizeCollection(await this.request("GET", path), normalizeType);
431
+ }
432
+ async listStatuses() {
433
+ return normalizeCollection(await this.request("GET", "/api/v3/statuses"), normalizeStatus);
434
+ }
435
+ async listPriorities() {
436
+ return normalizeCollection(await this.request("GET", "/api/v3/priorities"), normalizePriority);
437
+ }
438
+ async createWorkPackage(options) {
439
+ if (!this.config.allowWrite) throw new WriteBlockedError();
440
+ const project = options.project.trim();
441
+ const subject = options.subject.trim();
442
+ if (!project) throw new OpctlError("project must not be empty", EXIT_CODES.validation);
443
+ if (!options.type || !options.type.trim()) throw new OpctlError("type must not be empty", EXIT_CODES.validation);
444
+ if (!subject) throw new OpctlError("subject must not be empty", EXIT_CODES.validation);
445
+ const typeCollection = await this.listTypes({ project });
446
+ const resolvedTypeHref = resolveResourceHref("type", options.type, typeCollection.elements, "opctl types [--project <project>]");
447
+ let resolvedStatusHref;
448
+ if (options.status && options.status.trim()) {
449
+ const statusCollection = await this.listStatuses();
450
+ resolvedStatusHref = resolveResourceHref("status", options.status, statusCollection.elements, "opctl statuses");
451
+ }
452
+ let resolvedPriorityHref;
453
+ if (options.priority && options.priority.trim()) {
454
+ const priorityCollection = await this.listPriorities();
455
+ resolvedPriorityHref = resolveResourceHref("priority", options.priority, priorityCollection.elements, "opctl priorities");
456
+ }
457
+ const payload = {
458
+ subject,
459
+ ...options.description !== void 0 ? { description: {
460
+ format: "markdown",
461
+ raw: options.description
462
+ } } : {},
463
+ _links: {
464
+ project: { href: `/api/v3/projects/${project}` },
465
+ type: { href: resolvedTypeHref },
466
+ ...resolvedStatusHref ? { status: { href: resolvedStatusHref } } : {},
467
+ ...resolvedPriorityHref ? { priority: { href: resolvedPriorityHref } } : {}
468
+ }
469
+ };
470
+ const validationErrors = extractFormValidationErrors(await this.request("POST", "/api/v3/work_packages/form", payload));
471
+ const errorKeys = Object.keys(validationErrors);
472
+ if (errorKeys.length > 0) throw new OpctlError(`work package create validation failed: ${errorKeys.map((key) => `${key}: ${validationErrors[key]}`).join("; ")}`, EXIT_CODES.validation, validationErrors);
473
+ if (options.dryRun) return {
474
+ subject,
475
+ status: "dry-run",
476
+ request: {
477
+ method: "POST",
478
+ path: "/api/v3/work_packages",
479
+ payload
480
+ }
481
+ };
482
+ const detail = normalizeWorkPackageDetail(await this.request("POST", "/api/v3/work_packages", payload));
483
+ return {
484
+ id: detail.id,
485
+ subject: detail.subject,
486
+ status: "created",
487
+ href: detail.href,
488
+ browserUrl: apiHrefToBrowserUrl(this.config.baseUrl, detail.href)
489
+ };
490
+ }
491
+ async buildResolvedWorkPackageFilters(options) {
492
+ const filters = [...buildWorkPackageFilters(options)];
493
+ if (options.notStatus) for (const status of options.notStatus) filters.push({ status: {
494
+ operator: "!",
495
+ values: [await this.resolveStatusFilterValue(status)]
496
+ } });
497
+ if (options.filters) for (const filter of options.filters) filters.push({ [filter.field]: {
498
+ operator: filter.operator,
499
+ values: filter.values
500
+ } });
501
+ return filters;
502
+ }
503
+ async resolveStatusFilterValue(raw) {
504
+ const trimmed = raw.trim();
505
+ if (!trimmed) throw new OpctlError("status filter value must not be empty", EXIT_CODES.validation);
506
+ const idFromHref = idFromApiHref(trimmed, "/api/v3/statuses/");
507
+ if (idFromHref) return idFromHref;
508
+ if (/^\d+$/.test(trimmed)) return trimmed;
509
+ const statuses = await this.listStatuses();
510
+ const lower = trimmed.toLowerCase();
511
+ const matches = statuses.elements.filter((status) => status.name?.toLowerCase() === lower);
512
+ if (matches.length === 0) throw new OpctlError(`unknown status '${trimmed}'; run opctl statuses to list valid values`, EXIT_CODES.validation);
513
+ if (matches.length > 1) throw new OpctlError(`ambiguous status '${trimmed}'; matches: ${matches.map((status) => `${status.name} (${status.href})`).join(", ")}`, EXIT_CODES.validation);
514
+ const href = matches[0]?.href;
515
+ const id = idFromApiHref(href, "/api/v3/statuses/");
516
+ if (!id) throw new OpctlError(`status '${trimmed}' has no usable href`, EXIT_CODES.validation);
517
+ return id;
518
+ }
332
519
  async request(method, pathOrHref, body) {
520
+ return parseResponse(await this.fetchRaw(method, pathOrHref, body));
521
+ }
522
+ async fetchRaw(method, pathOrHref, body) {
333
523
  const url = pathOrHref.startsWith("http://") || pathOrHref.startsWith("https://") ? pathOrHref : `${this.config.baseUrl}${pathOrHref.startsWith("/") ? "" : "/"}${pathOrHref}`;
524
+ const includeAuthorization = !pathOrHref.startsWith("http://") && !pathOrHref.startsWith("https://") || url.startsWith(`${this.config.baseUrl}/`) || url === this.config.baseUrl;
334
525
  const controller = new AbortController();
335
526
  const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
336
527
  try {
@@ -340,13 +531,12 @@ var OpenProjectClient = class {
340
531
  headers: {
341
532
  Accept: "application/hal+json, application/json",
342
533
  ...body === void 0 ? {} : { "Content-Type": "application/json" },
343
- Authorization: createAuthorizationHeader(this.config.authMode, this.config.token)
534
+ ...includeAuthorization ? { Authorization: createAuthorizationHeader(this.config.authMode, this.config.token) } : {}
344
535
  },
345
536
  ...body === void 0 ? {} : { body: JSON.stringify(body) }
346
537
  });
347
- const parsed = await parseResponse(response);
348
- if (!response.ok) throw new OpenProjectHttpError(response.status, parsed);
349
- return parsed;
538
+ if (!response.ok) throw new OpenProjectHttpError(response.status, await parseResponse(response));
539
+ return response;
350
540
  } catch (error) {
351
541
  if (error instanceof OpenProjectHttpError || error instanceof OpctlError) throw error;
352
542
  if (error instanceof Error && error.name === "AbortError") throw new NetworkError("OpenProject request timed out");
@@ -366,6 +556,14 @@ function buildWorkPackageFilters(options) {
366
556
  operator: "=",
367
557
  values: ["me"]
368
558
  } });
559
+ if (options.responsibleMe) filters.push({ responsible: {
560
+ operator: "=",
561
+ values: ["me"]
562
+ } });
563
+ if (options.responsible && options.responsible.trim() !== "") filters.push({ responsible: {
564
+ operator: "=",
565
+ values: [normalizeUserFilterValue(options.responsible)]
566
+ } });
369
567
  const status = options.status?.trim();
370
568
  if (options.open || status === "open") filters.push({ status: {
371
569
  operator: "o",
@@ -377,6 +575,18 @@ function buildWorkPackageFilters(options) {
377
575
  } });
378
576
  return filters;
379
577
  }
578
+ function buildSortBy(sort) {
579
+ return (sort ?? []).map((criterion) => [criterion.field, criterion.direction]);
580
+ }
581
+ function normalizeUserFilterValue(raw) {
582
+ const trimmed = raw.trim();
583
+ if (!trimmed) throw new OpctlError("user filter value must not be empty", EXIT_CODES.validation);
584
+ if (trimmed === "me") return "me";
585
+ const id = idFromApiHref(trimmed, "/api/v3/users/");
586
+ if (id) return id;
587
+ if (/^\d+$/.test(trimmed)) return trimmed;
588
+ throw new OpctlError("--responsible supports me, numeric user ids, or /api/v3/users/<id> hrefs", EXIT_CODES.validation);
589
+ }
380
590
  function findCommentHref(resource) {
381
591
  for (const name of [
382
592
  "addComment",
@@ -400,6 +610,33 @@ async function parseResponse(response) {
400
610
  }
401
611
  return { message: text };
402
612
  }
613
+ function resolveResourceHref(kind, raw, collection, commandHint) {
614
+ const trimmed = raw.trim();
615
+ if (!trimmed) throw new OpctlError(`${kind} must not be empty`, EXIT_CODES.validation);
616
+ if (trimmed.startsWith("/api/v3/") || trimmed.startsWith("http://") || trimmed.startsWith("https://")) return trimmed;
617
+ if (/^\d+$/.test(trimmed)) return `${kind === "type" ? "/api/v3/types" : kind === "status" ? "/api/v3/statuses" : "/api/v3/priorities"}/${trimmed}`;
618
+ const lowerName = trimmed.toLowerCase();
619
+ const matches = collection.filter((item) => item.name?.toLowerCase() === lowerName);
620
+ if (matches.length === 0) throw new OpctlError(`unknown ${kind} '${trimmed}'; run ${commandHint} to list valid values`, EXIT_CODES.validation);
621
+ if (matches.length > 1) throw new OpctlError(`ambiguous ${kind} '${trimmed}'; matches: ${matches.map((m) => `${m.name} (${m.href})`).join(", ")}`, EXIT_CODES.validation);
622
+ return matches[0].href;
623
+ }
624
+ function extractFormValidationErrors(form) {
625
+ const embedded = typeof form === "object" && form !== null ? form._embedded : void 0;
626
+ const raw = typeof embedded === "object" && embedded !== null ? embedded.validationErrors : void 0;
627
+ if (!raw || typeof raw !== "object") return {};
628
+ const result = {};
629
+ for (const [key, value] of Object.entries(raw)) if (typeof value === "object" && value !== null && "message" in value && typeof value.message === "string") result[key] = value.message;
630
+ else result[key] = JSON.stringify(value);
631
+ return result;
632
+ }
633
+ function idFromApiHref(value, prefix) {
634
+ if (!value) return void 0;
635
+ const index = value.indexOf(prefix);
636
+ if (index === -1) return void 0;
637
+ const rest = value.slice(index + prefix.length).split(/[/?#]/)[0];
638
+ return rest && /^\d+$/.test(rest) ? rest : void 0;
639
+ }
403
640
  //#endregion
404
641
  //#region src/config/envFile.ts
405
642
  var ALLOWED_ENV_KEYS = {
@@ -672,14 +909,114 @@ function compactLinks(root) {
672
909
  }
673
910
  return output;
674
911
  }
912
+ var package_default = {
913
+ name: "opctl",
914
+ version: "0.1.7",
915
+ description: "Conservative local CLI bridge for OpenProject API v3",
916
+ type: "module",
917
+ repository: {
918
+ "type": "git",
919
+ "url": "git+https://github.com/hewel/op-cli.git"
920
+ },
921
+ bin: { "opctl": "dist/cli.js" },
922
+ files: ["dist"],
923
+ scripts: {
924
+ "dev": "tsx src/cli.ts",
925
+ "build": "npm run typecheck && vite build",
926
+ "typecheck": "tsc --noEmit",
927
+ "test": "vitest run",
928
+ "openapi:pull": "tsx scripts/pull-openapi-spec.ts",
929
+ "openapi:generate": "tsx scripts/generate-openapi-types.ts",
930
+ "openapi:update": "npm run openapi:pull && npm run openapi:generate"
931
+ },
932
+ keywords: ["openproject", "cli"],
933
+ author: "",
934
+ license: "ISC",
935
+ dependencies: {
936
+ "commander": "latest",
937
+ "openapi-fetch": "latest"
938
+ },
939
+ devDependencies: {
940
+ "@types/node": "latest",
941
+ "openapi-typescript": "latest",
942
+ "tsx": "latest",
943
+ "typescript": "latest",
944
+ "vite": "latest",
945
+ "vitest": "latest",
946
+ "yaml": "^2.9.0"
947
+ },
948
+ packageManager: "pnpm@11.1.3"
949
+ };
675
950
  //#endregion
676
- //#region src/commands/me.ts
677
- function registerMe(program, context) {
678
- program.command("me").description("Show the authenticated OpenProject user").option("--json", "emit JSON").action(async (options, command) => {
679
- const me = await createClient$1(context, command).getMe();
680
- writeOutput(context, me, Boolean(options.json), () => renderKeyValue(me));
951
+ //#region src/commands/doctor.ts
952
+ function registerDoctor(program, context) {
953
+ program.command("doctor").description("Show redacted OpenProject and opctl diagnostics").option("--json", "emit JSON").action(async (options, command) => {
954
+ const configOptions = globalConfigOptions(command);
955
+ let currentUser;
956
+ let currentUserError;
957
+ let configSummary;
958
+ try {
959
+ const config = loadResolvedConfig(context.env, {
960
+ ...configOptions,
961
+ ...context.cwd ? { cwd: context.cwd } : {}
962
+ });
963
+ configSummary = {
964
+ url: config.baseUrl,
965
+ authMode: config.authMode,
966
+ defaultProject: config.defaultProject,
967
+ writeEnabled: config.allowWrite,
968
+ hasToken: true
969
+ };
970
+ try {
971
+ currentUser = await createClient$1(context, command).getMe();
972
+ } catch (error) {
973
+ currentUserError = (error instanceof OpctlError ? error : new OpctlError(error instanceof Error ? error.message : "current user lookup failed")).message;
974
+ }
975
+ } catch (error) {
976
+ configSummary = {
977
+ error: (error instanceof OpctlError ? error : new OpctlError(error instanceof Error ? error.message : "doctor failed")).message,
978
+ hasToken: Boolean(context.env.OPENPROJECT_TOKEN)
979
+ };
980
+ }
981
+ const payload = {
982
+ version: package_default.version,
983
+ config: configSummary,
984
+ currentUser,
985
+ currentUserError,
986
+ commands: listCommandNames(program),
987
+ wpCommands: listCommandNames(program.commands.find((child) => child.name() === "wp"))
988
+ };
989
+ if (options.json) {
990
+ context.stdout.write(stableJson(payload, context.env.OPENPROJECT_TOKEN));
991
+ return;
992
+ }
993
+ context.stdout.write(renderKeyValue({
994
+ version: payload.version,
995
+ url: payload.config.url,
996
+ authMode: payload.config.authMode,
997
+ defaultProject: payload.config.defaultProject,
998
+ writeEnabled: payload.config.writeEnabled,
999
+ hasToken: payload.config.hasToken,
1000
+ currentUser: userLabel(currentUser),
1001
+ currentUserError: payload.currentUserError,
1002
+ commands: payload.commands.join(","),
1003
+ wpCommands: payload.wpCommands.join(","),
1004
+ error: payload.config.error
1005
+ }));
681
1006
  });
682
1007
  }
1008
+ function listCommandNames(command) {
1009
+ return command?.commands.map((child) => child.name()).sort() ?? [];
1010
+ }
1011
+ function userLabel(value) {
1012
+ if (!value || typeof value !== "object") return void 0;
1013
+ const user = value;
1014
+ return [
1015
+ user.name,
1016
+ user.login,
1017
+ user.id
1018
+ ].filter((part) => part !== void 0).join(" ");
1019
+ }
683
1020
  //#endregion
684
1021
  //#region src/output/table.ts
685
1022
  function renderTable(rows, columns) {
@@ -695,6 +1032,49 @@ function cell(value) {
695
1032
  return String(value);
696
1033
  }
697
1034
  //#endregion
1035
+ //#region src/commands/lookups.ts
1036
+ function registerLookups(program, context) {
1037
+ program.command("types").description("List work package types").option("--project <identifier-or-id>", "project to scope types").option("--json", "emit JSON").action(async (options, command) => {
1038
+ const result = await createClient$1(context, command).listTypes(options.project ? { project: options.project } : {});
1039
+ writeOutput(context, result, Boolean(options.json), () => renderTable(result.elements, [
1040
+ "id",
1041
+ "name",
1042
+ "href",
1043
+ "isDefault",
1044
+ "isMilestone"
1045
+ ]));
1046
+ });
1047
+ program.command("statuses").description("List work package statuses").option("--json", "emit JSON").action(async (options, command) => {
1048
+ const result = await createClient$1(context, command).listStatuses();
1049
+ writeOutput(context, result, Boolean(options.json), () => renderTable(result.elements, [
1050
+ "id",
1051
+ "name",
1052
+ "href",
1053
+ "isClosed",
1054
+ "isDefault",
1055
+ "isReadonly"
1056
+ ]));
1057
+ });
1058
+ program.command("priorities").description("List work package priorities").option("--json", "emit JSON").action(async (options, command) => {
1059
+ const result = await createClient$1(context, command).listPriorities();
1060
+ writeOutput(context, result, Boolean(options.json), () => renderTable(result.elements, [
1061
+ "id",
1062
+ "name",
1063
+ "href",
1064
+ "isDefault",
1065
+ "isActive"
1066
+ ]));
1067
+ });
1068
+ }
1069
+ //#endregion
1070
+ //#region src/commands/me.ts
1071
+ function registerMe(program, context) {
1072
+ program.command("me").description("Show the authenticated OpenProject user").option("--json", "emit JSON").action(async (options, command) => {
1073
+ const me = await createClient$1(context, command).getMe();
1074
+ writeOutput(context, me, Boolean(options.json), () => renderKeyValue(me));
1075
+ });
1076
+ }
1077
+ //#endregion
698
1078
  //#region src/commands/projects.ts
699
1079
  function registerProjects(program, context) {
700
1080
  program.command("projects").description("List visible OpenProject projects").option("--json", "emit JSON").option("--page-size <n>", "page size", Number).action(async (options, command) => {
@@ -707,18 +1087,15 @@ function registerProjects(program, context) {
707
1087
  ]));
708
1088
  });
709
1089
  }
710
- //#endregion
711
- //#region scripts/pull-openapi-spec.ts
712
1090
  async function pullOpenApiSpec(options = {}) {
713
1091
  const env = options.env ?? process.env;
714
1092
  const outputPath = options.outputPath ?? "openapi/openproject.json";
715
1093
  const fetchImpl = options.fetchImpl ?? fetch;
716
- const rawUrl = env.OPENPROJECT_URL;
717
- if (!rawUrl || rawUrl.trim() === "") throw new OpenApiGenerationError("OPENPROJECT_URL is required to pull the OpenProject spec");
718
- const baseUrl = normalizeBaseUrl(rawUrl);
719
- const authMode = parseAuthMode(env.OPENPROJECT_AUTH_MODE);
1094
+ const baseUrl = normalizeBaseUrl(options.sourceBaseUrl ?? env.OPENPROJECT_SPEC_URL ?? "https://community.openproject.org");
1095
+ const specToken = env.OPENPROJECT_SPEC_TOKEN;
1096
+ const authMode = parseAuthMode(env.OPENPROJECT_SPEC_AUTH_MODE);
720
1097
  const headers = { Accept: "application/json" };
721
- if (env.OPENPROJECT_TOKEN && env.OPENPROJECT_TOKEN.trim() !== "") headers.Authorization = createAuthorizationHeader(authMode, env.OPENPROJECT_TOKEN);
1098
+ if (specToken && specToken.trim() !== "") headers.Authorization = createAuthorizationHeader(authMode, specToken);
722
1099
  const specUrl = `${baseUrl}/api/v3/spec.json`;
723
1100
  const controller = new AbortController();
724
1101
  const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 15e3);
@@ -729,7 +1106,7 @@ async function pullOpenApiSpec(options = {}) {
729
1106
  signal: controller.signal
730
1107
  });
731
1108
  } catch (error) {
732
- throw new OpenApiGenerationError(`failed to download OpenProject spec from ${new URL(baseUrl).host}: ${error instanceof Error ? redactSecrets(error.message, env.OPENPROJECT_TOKEN) : "network error"}`);
1109
+ throw new OpenApiGenerationError(`failed to download OpenProject spec from ${new URL(baseUrl).host}: ${error instanceof Error ? redactSecrets(error.message, specToken) : "network error"}`);
733
1110
  } finally {
734
1111
  clearTimeout(timeout);
735
1112
  }
@@ -760,21 +1137,23 @@ function parseAuthMode(raw) {
760
1137
  const normalized = raw?.trim().toLowerCase() ?? "";
761
1138
  if (normalized === "" || normalized === "bearer") return "bearer";
762
1139
  if (normalized === "basic") return "basic";
763
- throw new OpenApiGenerationError("OPENPROJECT_AUTH_MODE must be bearer or basic");
1140
+ throw new OpenApiGenerationError("OPENPROJECT_SPEC_AUTH_MODE must be bearer or basic");
764
1141
  }
765
1142
  if (import.meta.url === `file://${process.argv[1]}`) pullOpenApiSpec({ stdout: process.stdout }).catch((error) => {
766
1143
  const message = error instanceof Error ? error.message : "failed to pull OpenProject spec";
767
- process.stderr.write(`${redactSecrets(message, process.env.OPENPROJECT_TOKEN)}\n`);
1144
+ process.stderr.write(`${redactSecrets(message, process.env.OPENPROJECT_SPEC_TOKEN)}\n`);
768
1145
  process.exitCode = 8;
769
1146
  });
770
1147
  //#endregion
771
1148
  //#region src/commands/spec.ts
772
1149
  function registerSpec(program, context) {
773
- program.command("spec").description("OpenAPI spec utilities").command("pull").description("Download OpenProject /api/v3/spec.json safely").action(async (_options, command) => {
1150
+ program.command("spec").description("OpenAPI spec utilities").command("pull").description("Download OpenProject /api/v3/spec.json (defaults to the public community spec)").option("--url <url>", "spec source base URL (overrides OPENPROJECT_SPEC_URL)").option("--output <path>", "output file path").action(async (opts) => {
774
1151
  await pullOpenApiSpec({
775
- env: resolvedEnv(context, command),
1152
+ env: context.env,
776
1153
  stdout: context.stdout,
777
- ...context.fetchImpl ? { fetchImpl: context.fetchImpl } : {}
1154
+ ...context.fetchImpl ? { fetchImpl: context.fetchImpl } : {},
1155
+ ...opts.url ? { sourceBaseUrl: opts.url } : {},
1156
+ ...opts.output ? { outputPath: opts.output } : {}
778
1157
  });
779
1158
  });
780
1159
  }
@@ -800,6 +1179,7 @@ var DEFAULT_CHECK_FIELDS = [
800
1179
  "subject",
801
1180
  "status",
802
1181
  "assignee",
1182
+ "responsible",
803
1183
  "shortDescription",
804
1184
  "attachmentsCount"
805
1185
  ];
@@ -809,8 +1189,10 @@ var DETAIL_FIELDS = [
809
1189
  "status",
810
1190
  "type",
811
1191
  "assignee",
1192
+ "responsible",
812
1193
  "project",
813
1194
  "href",
1195
+ "browserUrl",
814
1196
  "updatedAt",
815
1197
  "description",
816
1198
  "shortDescription",
@@ -820,11 +1202,14 @@ var DETAIL_FIELDS = [
820
1202
  var SUPPORTED_FIELDS = {
821
1203
  assignee: true,
822
1204
  attachmentsCount: true,
1205
+ browserUrl: true,
823
1206
  description: true,
824
1207
  href: true,
825
1208
  id: true,
826
1209
  lockVersion: true,
1210
+ priority: true,
827
1211
  project: true,
1212
+ responsible: true,
828
1213
  shortDescription: true,
829
1214
  status: true,
830
1215
  subject: true,
@@ -878,16 +1263,18 @@ function isSupportedField(field) {
878
1263
  //#region src/commands/workPackages.ts
879
1264
  function registerWorkPackages(program, context) {
880
1265
  const wp = program.command("wp").description("Work package commands");
881
- wp.command("get").description("Get one or more work packages").argument("[ids...]", "work package ids").option("--ids <csv>", "comma-separated work package ids").option("--fields <csv>", "comma-separated output fields").option("--table", "emit table output").option("--compact", "emit compact triage table output").option("--json", "emit normalized JSON").option("--jsonl", "emit one JSON object per line").option("--raw-json", "emit raw OpenProject JSON; single work package only").action(async (ids, options, command) => {
1266
+ wp.command("get").description("Get one or more work packages").argument("[ids...]", "work package ids").option("--ids <csv>", "comma-separated work package ids").option("--fields <csv>", "comma-separated output fields").option("--table", "emit table output").option("--compact", "emit compact triage table output").option("--json", "emit normalized JSON").option("--jsonl", "emit one JSON object per line").option("--raw-json", "emit raw OpenProject JSON; JSONL for multiple ids").action(async (ids, options, command) => {
882
1267
  const numericIds = parseIds(ids, options.ids);
883
1268
  if (numericIds.length === 0) throw new OpctlError("at least one work package id is required", EXIT_CODES.validation);
884
1269
  const mode = parseOutputMode(options);
885
- if (mode === "rawJson" && numericIds.length !== 1) throw new OpctlError("--raw-json supports exactly one work package id", EXIT_CODES.validation);
886
1270
  if (mode !== "rawJson") parseFields(options.fields, mode === "compact" ? DEFAULT_COMPACT_FIELDS : DETAIL_FIELDS);
887
1271
  const client = createClient$1(context, command);
888
1272
  const token = resolvedEnv(context, command).OPENPROJECT_TOKEN;
889
1273
  if (mode === "rawJson") {
890
- context.stdout.write(stableJson(await client.getWorkPackageRaw(numericIds[0]), token));
1274
+ const rawWorkPackages = [];
1275
+ for (const id of numericIds) rawWorkPackages.push(await client.getWorkPackageRaw(id));
1276
+ if (rawWorkPackages.length === 1) context.stdout.write(stableJson(rawWorkPackages[0], token));
1277
+ else context.stdout.write(rawWorkPackages.map((wp) => redactSecrets(JSON.stringify(wp), token)).join("\n") + "\n");
891
1278
  return;
892
1279
  }
893
1280
  const workPackages = [];
@@ -904,12 +1291,113 @@ function registerWorkPackages(program, context) {
904
1291
  for (const id of numericIds) workPackages.push(await client.getWorkPackage(id));
905
1292
  writeWorkPackageOutput(context, workPackages, options, mode, false, DEFAULT_CHECK_FIELDS, resolvedEnv(context, command).OPENPROJECT_TOKEN);
906
1293
  });
907
- wp.command("search").description("Search work packages").option("--json", "emit JSON").option("--jsonl", "emit one JSON object per line").option("--table", "emit table output").option("--compact", "emit compact table output").option("--fields <csv>", "comma-separated output fields").option("--project <identifier-or-id>", "project identifier or id").option("--subject <text>", "subject contains text").option("--assignee-me", "filter to current user").option("--status <id-or-open>", "status id, or open").option("--open", "filter to open work packages").option("--page-size <n>", "page size", Number).action(async (options, command) => {
908
- writeCollectionOutput(context, await createClient$1(context, command).searchWorkPackages(options), options, resolvedEnv(context, command).OPENPROJECT_TOKEN);
1294
+ wp.command("search").description("Search work packages").option("--json", "emit JSON").option("--jsonl", "emit one JSON object per line").option("--table", "emit table output").option("--compact", "emit compact table output").option("--fields <csv>", "comma-separated output fields").option("--project <identifier-or-id>", "project identifier or id").option("--subject <text>", "subject contains text").option("--assignee-me", "filter to current user").option("--responsible-me", "filter to work packages responsible to current user").option("--responsible <me-or-id-or-href>", "filter by responsible user; supports me, numeric ids, or /api/v3/users/<id>").option("--status <id-or-open>", "status id, or open").option("--not-status <name-or-id-or-href>", "exclude status by name, id, or href", collect).option("--open", "filter to open work packages").option("--filter <field=operator:value>", "generic OpenProject filter; field=value means equals, field=o means unary operator o", collect).option("--sort <field:asc|desc>", "sort criterion, repeatable", collect).option("--page-size <n>", "page size", Number).action(async (options, command) => {
1295
+ writeCollectionOutput(context, await createClient$1(context, command).searchWorkPackages(resolveSearchOptions(options)), options, resolvedEnv(context, command).OPENPROJECT_TOKEN);
909
1296
  });
910
1297
  wp.command("mine").description("List open work packages assigned to the authenticated user").option("--json", "emit JSON").option("--jsonl", "emit one JSON object per line").option("--table", "emit table output").option("--compact", "emit compact table output").option("--fields <csv>", "comma-separated output fields").option("--project <identifier-or-id>", "project identifier or id").option("--open", "filter to open work packages").option("--page-size <n>", "page size", Number).action(async (options, command) => {
911
1298
  writeCollectionOutput(context, await createClient$1(context, command).mine(options), options, resolvedEnv(context, command).OPENPROJECT_TOKEN);
912
1299
  });
1300
+ wp.command("accountable").description("List open work packages responsible to the authenticated user").option("--json", "emit JSON").option("--jsonl", "emit one JSON object per line").option("--table", "emit table output").option("--compact", "emit compact table output").option("--fields <csv>", "comma-separated output fields").option("--project <identifier-or-id>", "project identifier or id").option("--open", "filter to open work packages").option("--page-size <n>", "page size", Number).action(async (options, command) => {
1301
+ writeCollectionOutput(context, await createClient$1(context, command).accountable(options), options, resolvedEnv(context, command).OPENPROJECT_TOKEN);
1302
+ });
1303
+ wp.command("attachments").description("List attachment metadata for a work package").argument("<id>", "work package id").option("--json", "emit JSON").option("--jsonl", "emit one JSON object per line").option("--table", "emit table output").action(async (id, options, command) => {
1304
+ const mode = parseAttachmentOutputMode(options);
1305
+ writeAttachmentOutput(context, (await createClient$1(context, command).listWorkPackageAttachments(parseId(id))).elements, mode, resolvedEnv(context, command).OPENPROJECT_TOKEN);
1306
+ });
1307
+ wp.command("download-attachments").description("Download all attachments for a work package").argument("<id>", "work package id").requiredOption("--dir <path>", "directory to save attachments into").option("--overwrite", "replace files that already exist").option("--json", "emit JSON").action(async (id, options, command) => {
1308
+ if (!options.dir || options.dir.trim() === "") throw new OpctlError("--dir is required", EXIT_CODES.validation);
1309
+ const client = createClient$1(context, command);
1310
+ const outputDir = resolve(context.cwd ?? process.cwd(), options.dir);
1311
+ mkdirSync(outputDir, { recursive: true });
1312
+ const attachments = (await client.listWorkPackageAttachments(parseId(id))).elements;
1313
+ const saved = [];
1314
+ for (const attachment of attachments) {
1315
+ const savedPath = uniqueAttachmentPath(outputDir, safeAttachmentFileName(attachment, saved.length + 1), Boolean(options.overwrite));
1316
+ const content = await client.downloadAttachment(attachment);
1317
+ writeFileSync(savedPath, Buffer.from(content));
1318
+ saved.push({
1319
+ ...attachment,
1320
+ savedPath
1321
+ });
1322
+ }
1323
+ if (options.json) {
1324
+ context.stdout.write(stableJson({
1325
+ workPackageId: parseId(id),
1326
+ directory: outputDir,
1327
+ attachments: saved
1328
+ }, resolvedEnv(context, command).OPENPROJECT_TOKEN));
1329
+ return;
1330
+ }
1331
+ context.stdout.write(renderTable(saved, [
1332
+ "id",
1333
+ "fileName",
1334
+ "contentType",
1335
+ "fileSize",
1336
+ "savedPath"
1337
+ ]));
1338
+ });
1339
+ wp.command("create").description("Create a work package; requires OPENPROJECT_ALLOW_WRITE=1").option("--project <identifier-or-id>", "project identifier or id; defaults to OPENPROJECT_DEFAULT_PROJECT").option("--type <name-or-id-or-href>", "work package type name, id, or href").option("--subject <text>", "work package subject").option("--description <text>", "inline multiline description").option("--description-file <path>", "read description from UTF-8 file; - for stdin").option("--status <name-or-id-or-href>", "status name, id, or href").option("--priority <name-or-id-or-href>", "priority name, id, or href").option("--template <name>", "use a description template (user-story)").option("--dry-run", "validate and print intended mutation without creating").option("--json", "emit JSON").action(async (options, command) => {
1340
+ const template = options.template?.trim();
1341
+ if (template && template !== "user-story") throw new OpctlError(`unknown template '${template}'. Supported templates: user-story`, EXIT_CODES.validation);
1342
+ const templateText = `# User story\n\nAs a <user>\nI want <capability>\nSo that <outcome>\n\n## Acceptance criteria\n\n- [ ]`;
1343
+ const hasCreateFields = options.project || options.type || options.subject || options.description || options.descriptionFile || options.status || options.priority;
1344
+ if (template === "user-story" && !hasCreateFields) {
1345
+ context.stdout.write(`${templateText}\n`);
1346
+ return;
1347
+ }
1348
+ let description;
1349
+ if (options.description !== void 0 && options.descriptionFile !== void 0) throw new OpctlError("--description and --description-file cannot both be provided", EXIT_CODES.validation);
1350
+ if (template && (options.description !== void 0 || options.descriptionFile !== void 0)) throw new OpctlError("--template cannot be combined with --description or --description-file", EXIT_CODES.validation);
1351
+ if (options.description !== void 0) description = options.description;
1352
+ else if (options.descriptionFile !== void 0) if (options.descriptionFile === "-") description = await readStdin(context);
1353
+ else {
1354
+ const filePath = resolve(context.cwd ?? process.cwd(), options.descriptionFile);
1355
+ try {
1356
+ description = readFileSync(filePath, "utf-8");
1357
+ } catch (err) {
1358
+ throw new OpctlError(`unable to read --description-file '${options.descriptionFile}': ${err.message}`, EXIT_CODES.validation);
1359
+ }
1360
+ }
1361
+ else if (template === "user-story") description = templateText;
1362
+ else if (context.stdin && context.stdin.isTTY === false) description = await readStdin(context);
1363
+ const env = resolvedEnv(context, command);
1364
+ const project = options.project?.trim() || env.OPENPROJECT_DEFAULT_PROJECT?.trim();
1365
+ if (!project) throw new OpctlError("--project is required when OPENPROJECT_DEFAULT_PROJECT is not set", EXIT_CODES.validation);
1366
+ if (!options.type || !options.type.trim()) throw new OpctlError("--type is required", EXIT_CODES.validation);
1367
+ if (!options.subject || !options.subject.trim()) throw new OpctlError("--subject is required", EXIT_CODES.validation);
1368
+ const token = env.OPENPROJECT_TOKEN;
1369
+ const result = await createClient$1(context, command).createWorkPackage({
1370
+ project,
1371
+ type: options.type,
1372
+ subject: options.subject,
1373
+ description,
1374
+ status: options.status,
1375
+ priority: options.priority,
1376
+ dryRun: Boolean(options.dryRun)
1377
+ });
1378
+ if (options.json) {
1379
+ context.stdout.write(stableJson(result, token));
1380
+ return;
1381
+ }
1382
+ if (result.status === "dry-run") {
1383
+ const lines = [
1384
+ `status: dry-run`,
1385
+ `method: POST`,
1386
+ `path: /api/v3/work_packages`,
1387
+ `payload: ${JSON.stringify(result.request?.payload)}`
1388
+ ];
1389
+ if (result.subject) lines.splice(1, 0, `subject: ${result.subject}`);
1390
+ context.stdout.write(`${lines.join("\n")}\n`);
1391
+ return;
1392
+ }
1393
+ const output = {};
1394
+ if (result.id !== void 0) output.id = result.id;
1395
+ if (result.subject !== void 0) output.subject = result.subject;
1396
+ output.status = result.status;
1397
+ if (result.href !== void 0) output.href = result.href;
1398
+ if (result.browserUrl !== void 0) output.browserUrl = result.browserUrl;
1399
+ context.stdout.write(renderKeyValue(output));
1400
+ });
913
1401
  wp.command("comment").description("Add a comment to a work package; requires OPENPROJECT_ALLOW_WRITE=1").argument("<id>", "work package id").argument("[message...]", "comment message").option("--dry-run", "print intended mutation without posting").option("--json", "emit JSON").action(async (id, messageParts, options, command) => {
914
1402
  const result = await createClient$1(context, command).commentWorkPackage(parseId(id), messageParts.join(" "), Boolean(options.dryRun));
915
1403
  writeOutput(context, result, Boolean(options.json), () => renderKeyValue(result), resolvedEnv(context, command).OPENPROJECT_TOKEN);
@@ -934,6 +1422,122 @@ function writeCollectionOutput(context, result, options, token) {
934
1422
  }
935
1423
  context.stdout.write(renderTable(elements, fields));
936
1424
  }
1425
+ function writeAttachmentOutput(context, attachments, mode, token) {
1426
+ const fields = [
1427
+ "id",
1428
+ "fileName",
1429
+ "contentType",
1430
+ "fileSize",
1431
+ "description",
1432
+ "downloadHref"
1433
+ ];
1434
+ if (mode === "json") {
1435
+ context.stdout.write(stableJson({
1436
+ total: attachments.length,
1437
+ elements: attachments
1438
+ }, token));
1439
+ return;
1440
+ }
1441
+ if (mode === "jsonl") {
1442
+ context.stdout.write(attachments.map((attachment) => redactSecrets(JSON.stringify(attachment), token)).join("\n") + (attachments.length > 0 ? "\n" : ""));
1443
+ return;
1444
+ }
1445
+ context.stdout.write(renderTable(attachments, fields));
1446
+ }
1447
+ function parseAttachmentOutputMode(options) {
1448
+ if ([
1449
+ options.json,
1450
+ options.jsonl,
1451
+ options.table
1452
+ ].filter(Boolean).length > 1) throw new OpctlError("choose only one output mode flag", EXIT_CODES.validation);
1453
+ if (options.json) return "json";
1454
+ if (options.jsonl) return "jsonl";
1455
+ return "table";
1456
+ }
1457
+ function resolveSearchOptions(options) {
1458
+ const filters = parseGenericFilters(options.filter);
1459
+ const sort = parseSortCriteria(options.sort);
1460
+ return {
1461
+ ...options.project !== void 0 ? { project: options.project } : {},
1462
+ ...options.subject !== void 0 ? { subject: options.subject } : {},
1463
+ ...options.assigneeMe !== void 0 ? { assigneeMe: options.assigneeMe } : {},
1464
+ ...options.responsibleMe !== void 0 ? { responsibleMe: options.responsibleMe } : {},
1465
+ ...options.responsible !== void 0 ? { responsible: options.responsible } : {},
1466
+ ...options.status !== void 0 ? { status: options.status } : {},
1467
+ ...options.notStatus !== void 0 ? { notStatus: options.notStatus } : {},
1468
+ ...options.open !== void 0 ? { open: options.open } : {},
1469
+ ...filters.length > 0 ? { filters } : {},
1470
+ ...sort.length > 0 ? { sort } : {},
1471
+ ...options.pageSize !== void 0 ? { pageSize: options.pageSize } : {}
1472
+ };
1473
+ }
1474
+ var UNARY_FILTER_OPERATORS = new Set([
1475
+ "o",
1476
+ "c",
1477
+ "*",
1478
+ "!*"
1479
+ ]);
1480
+ function parseGenericFilters(rawFilters) {
1481
+ return (rawFilters ?? []).map((raw) => {
1482
+ const eqIndex = raw.indexOf("=");
1483
+ if (eqIndex <= 0) throw new OpctlError(`invalid --filter '${raw}'. Expected field=value or field=operator:value`, EXIT_CODES.validation);
1484
+ const field = raw.slice(0, eqIndex).trim();
1485
+ const expression = raw.slice(eqIndex + 1).trim();
1486
+ if (!field || !/^[A-Za-z][A-Za-z0-9_]*$/.test(field)) throw new OpctlError(`invalid --filter field '${field}'`, EXIT_CODES.validation);
1487
+ if (!expression) throw new OpctlError(`invalid --filter '${raw}'. Filter value must not be empty`, EXIT_CODES.validation);
1488
+ const colonIndex = expression.indexOf(":");
1489
+ if (colonIndex > 0) return {
1490
+ field,
1491
+ operator: expression.slice(0, colonIndex).trim(),
1492
+ values: splitCsv(expression.slice(colonIndex + 1)).map((value) => normalizeFilterValue(field, value))
1493
+ };
1494
+ if (UNARY_FILTER_OPERATORS.has(expression)) return {
1495
+ field,
1496
+ operator: expression,
1497
+ values: []
1498
+ };
1499
+ return {
1500
+ field,
1501
+ operator: "=",
1502
+ values: [normalizeFilterValue(field, expression)]
1503
+ };
1504
+ });
1505
+ }
1506
+ function normalizeFilterValue(field, value) {
1507
+ return field === "responsible" || field === "assignee" ? normalizeUserFilterValue(value) : value;
1508
+ }
1509
+ function parseSortCriteria(rawSort) {
1510
+ return (rawSort ?? []).map((raw) => {
1511
+ const [field, direction, extra] = raw.split(":");
1512
+ if (!field || !direction || extra !== void 0) throw new OpctlError(`invalid --sort '${raw}'. Expected field:asc or field:desc`, EXIT_CODES.validation);
1513
+ const normalizedDirection = direction.toLowerCase();
1514
+ if (normalizedDirection !== "asc" && normalizedDirection !== "desc") throw new OpctlError(`invalid --sort direction '${direction}'. Expected asc or desc`, EXIT_CODES.validation);
1515
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(field)) throw new OpctlError(`invalid --sort field '${field}'`, EXIT_CODES.validation);
1516
+ return {
1517
+ field,
1518
+ direction: normalizedDirection
1519
+ };
1520
+ });
1521
+ }
1522
+ function safeAttachmentFileName(attachment, index) {
1523
+ const fallback = attachment.id ? `attachment-${attachment.id}` : `attachment-${index}`;
1524
+ return basename(attachment.fileName || fallback).replace(/[\\/:*?"<>|\u0000-\u001F]/g, "_").trim() || fallback;
1525
+ }
1526
+ function uniqueAttachmentPath(dir, fileName, overwrite) {
1527
+ const first = join(dir, fileName);
1528
+ if (overwrite || !existsSync(first)) return first;
1529
+ const dot = fileName.lastIndexOf(".");
1530
+ const stem = dot > 0 ? fileName.slice(0, dot) : fileName;
1531
+ const ext = dot > 0 ? fileName.slice(dot) : "";
1532
+ for (let i = 2; i < 1e4; i += 1) {
1533
+ const candidate = join(dir, `${stem}-${i}${ext}`);
1534
+ if (!existsSync(candidate)) return candidate;
1535
+ }
1536
+ throw new OpctlError(`unable to choose a unique filename for '${fileName}'`, EXIT_CODES.validation);
1537
+ }
1538
+ function collect(value, previous) {
1539
+ return [...previous ?? [], value];
1540
+ }
937
1541
  function writeWorkPackageOutput(context, workPackages, options, mode, singleObject, defaultFields, token) {
938
1542
  const fields = parseFields(options.fields, mode === "compact" ? DEFAULT_COMPACT_FIELDS : defaultFields);
939
1543
  const projected = options.fields || mode === "table" || mode === "compact" || mode === "jsonl" ? projectRows(workPackages, fields) : workPackages;
@@ -972,6 +1576,11 @@ function parseId(id) {
972
1576
  if (!Number.isInteger(parsed) || parsed < 1) throw new OpctlError("work package id must be a positive integer", EXIT_CODES.validation);
973
1577
  return parsed;
974
1578
  }
1579
+ async function readStdin(context) {
1580
+ const chunks = [];
1581
+ for await (const chunk of context.stdin) chunks.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk));
1582
+ return chunks.join("");
1583
+ }
975
1584
  //#endregion
976
1585
  //#region src/commands/profile.ts
977
1586
  function registerProfile(program, context) {
@@ -1024,44 +1633,6 @@ function registerProfile(program, context) {
1024
1633
  context.stdout.write(options.json ? stableJson(output) : `unset profile: ${name}\n`);
1025
1634
  });
1026
1635
  }
1027
- var package_default = {
1028
- name: "opctl",
1029
- version: "0.1.5",
1030
- description: "Conservative local CLI bridge for OpenProject API v3",
1031
- type: "module",
1032
- repository: {
1033
- "type": "git",
1034
- "url": "git+https://github.com/hewel/op-cli.git"
1035
- },
1036
- bin: { "opctl": "dist/cli.js" },
1037
- files: ["dist"],
1038
- scripts: {
1039
- "dev": "tsx src/cli.ts",
1040
- "build": "npm run typecheck && vite build",
1041
- "typecheck": "tsc --noEmit",
1042
- "test": "vitest run",
1043
- "openapi:pull": "tsx scripts/pull-openapi-spec.ts",
1044
- "openapi:generate": "tsx scripts/generate-openapi-types.ts",
1045
- "openapi:update": "npm run openapi:pull && npm run openapi:generate"
1046
- },
1047
- keywords: ["openproject", "cli"],
1048
- author: "",
1049
- license: "ISC",
1050
- dependencies: {
1051
- "commander": "latest",
1052
- "openapi-fetch": "latest"
1053
- },
1054
- devDependencies: {
1055
- "@types/node": "latest",
1056
- "openapi-typescript": "latest",
1057
- "tsx": "latest",
1058
- "typescript": "latest",
1059
- "vite": "latest",
1060
- "vitest": "latest",
1061
- "yaml": "^2.9.0"
1062
- },
1063
- packageManager: "pnpm@11.1.3"
1064
- };
1065
1636
  //#endregion
1066
1637
  //#region src/cli.ts
1067
1638
  function buildProgram(context) {
@@ -1074,8 +1645,10 @@ function buildProgram(context) {
1074
1645
  registerApiRoot(program, context);
1075
1646
  registerProjects(program, context);
1076
1647
  registerWorkPackages(program, context);
1648
+ registerLookups(program, context);
1077
1649
  registerProfile(program, context);
1078
1650
  registerSpec(program, context);
1651
+ registerDoctor(program, context);
1079
1652
  return program;
1080
1653
  }
1081
1654
  async function run(argv, context) {
@@ -1084,11 +1657,14 @@ async function run(argv, context) {
1084
1657
  return EXIT_CODES.success;
1085
1658
  } catch (error) {
1086
1659
  const opctlError = toOpctlError(error);
1087
- if (argv.includes("--json")) context.stderr.write(stableJson({
1088
- error: opctlError.message,
1089
- exitCode: opctlError.exitCode
1090
- }, context.env.OPENPROJECT_TOKEN));
1091
- else context.stderr.write(`${redactSecrets(opctlError.message, context.env.OPENPROJECT_TOKEN)}\n`);
1660
+ if (argv.includes("--json")) {
1661
+ const payload = {
1662
+ error: opctlError.message,
1663
+ exitCode: opctlError.exitCode
1664
+ };
1665
+ if (opctlError.details !== void 0) payload.details = opctlError.details;
1666
+ context.stderr.write(stableJson(payload, context.env.OPENPROJECT_TOKEN));
1667
+ } else context.stderr.write(`${redactSecrets(opctlError.message, context.env.OPENPROJECT_TOKEN)}\n`);
1092
1668
  return opctlError.exitCode;
1093
1669
  }
1094
1670
  }
@@ -1104,7 +1680,8 @@ if (isCliEntrypoint(import.meta.url)) {
1104
1680
  const exitCode = await run(process.argv, {
1105
1681
  stdout: process.stdout,
1106
1682
  stderr: process.stderr,
1107
- env: process.env
1683
+ env: process.env,
1684
+ stdin: process.stdin
1108
1685
  });
1109
1686
  process.exitCode = exitCode;
1110
1687
  }