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/README.md +73 -8
- package/dist/cli.js +652 -75
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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 =
|
|
366
|
+
const filters = await this.buildResolvedWorkPackageFilters(options);
|
|
295
367
|
if (filters.length > 0) params.set("filters", JSON.stringify(filters));
|
|
296
|
-
|
|
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
|
-
|
|
348
|
-
|
|
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/
|
|
677
|
-
function
|
|
678
|
-
program.command("
|
|
679
|
-
const
|
|
680
|
-
|
|
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
|
|
717
|
-
|
|
718
|
-
const
|
|
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 (
|
|
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,
|
|
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("
|
|
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.
|
|
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
|
|
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:
|
|
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;
|
|
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
|
-
|
|
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"))
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
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
|
}
|