opctl 0.1.6 → 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 +55 -4
- package/dist/cli.js +640 -62
- 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) => {
|
|
@@ -799,6 +1179,7 @@ var DEFAULT_CHECK_FIELDS = [
|
|
|
799
1179
|
"subject",
|
|
800
1180
|
"status",
|
|
801
1181
|
"assignee",
|
|
1182
|
+
"responsible",
|
|
802
1183
|
"shortDescription",
|
|
803
1184
|
"attachmentsCount"
|
|
804
1185
|
];
|
|
@@ -808,8 +1189,10 @@ var DETAIL_FIELDS = [
|
|
|
808
1189
|
"status",
|
|
809
1190
|
"type",
|
|
810
1191
|
"assignee",
|
|
1192
|
+
"responsible",
|
|
811
1193
|
"project",
|
|
812
1194
|
"href",
|
|
1195
|
+
"browserUrl",
|
|
813
1196
|
"updatedAt",
|
|
814
1197
|
"description",
|
|
815
1198
|
"shortDescription",
|
|
@@ -819,11 +1202,14 @@ var DETAIL_FIELDS = [
|
|
|
819
1202
|
var SUPPORTED_FIELDS = {
|
|
820
1203
|
assignee: true,
|
|
821
1204
|
attachmentsCount: true,
|
|
1205
|
+
browserUrl: true,
|
|
822
1206
|
description: true,
|
|
823
1207
|
href: true,
|
|
824
1208
|
id: true,
|
|
825
1209
|
lockVersion: true,
|
|
1210
|
+
priority: true,
|
|
826
1211
|
project: true,
|
|
1212
|
+
responsible: true,
|
|
827
1213
|
shortDescription: true,
|
|
828
1214
|
status: true,
|
|
829
1215
|
subject: true,
|
|
@@ -877,16 +1263,18 @@ function isSupportedField(field) {
|
|
|
877
1263
|
//#region src/commands/workPackages.ts
|
|
878
1264
|
function registerWorkPackages(program, context) {
|
|
879
1265
|
const wp = program.command("wp").description("Work package commands");
|
|
880
|
-
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) => {
|
|
881
1267
|
const numericIds = parseIds(ids, options.ids);
|
|
882
1268
|
if (numericIds.length === 0) throw new OpctlError("at least one work package id is required", EXIT_CODES.validation);
|
|
883
1269
|
const mode = parseOutputMode(options);
|
|
884
|
-
if (mode === "rawJson" && numericIds.length !== 1) throw new OpctlError("--raw-json supports exactly one work package id", EXIT_CODES.validation);
|
|
885
1270
|
if (mode !== "rawJson") parseFields(options.fields, mode === "compact" ? DEFAULT_COMPACT_FIELDS : DETAIL_FIELDS);
|
|
886
1271
|
const client = createClient$1(context, command);
|
|
887
1272
|
const token = resolvedEnv(context, command).OPENPROJECT_TOKEN;
|
|
888
1273
|
if (mode === "rawJson") {
|
|
889
|
-
|
|
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");
|
|
890
1278
|
return;
|
|
891
1279
|
}
|
|
892
1280
|
const workPackages = [];
|
|
@@ -903,12 +1291,113 @@ function registerWorkPackages(program, context) {
|
|
|
903
1291
|
for (const id of numericIds) workPackages.push(await client.getWorkPackage(id));
|
|
904
1292
|
writeWorkPackageOutput(context, workPackages, options, mode, false, DEFAULT_CHECK_FIELDS, resolvedEnv(context, command).OPENPROJECT_TOKEN);
|
|
905
1293
|
});
|
|
906
|
-
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) => {
|
|
907
|
-
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);
|
|
908
1296
|
});
|
|
909
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) => {
|
|
910
1298
|
writeCollectionOutput(context, await createClient$1(context, command).mine(options), options, resolvedEnv(context, command).OPENPROJECT_TOKEN);
|
|
911
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
|
+
});
|
|
912
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) => {
|
|
913
1402
|
const result = await createClient$1(context, command).commentWorkPackage(parseId(id), messageParts.join(" "), Boolean(options.dryRun));
|
|
914
1403
|
writeOutput(context, result, Boolean(options.json), () => renderKeyValue(result), resolvedEnv(context, command).OPENPROJECT_TOKEN);
|
|
@@ -933,6 +1422,122 @@ function writeCollectionOutput(context, result, options, token) {
|
|
|
933
1422
|
}
|
|
934
1423
|
context.stdout.write(renderTable(elements, fields));
|
|
935
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
|
+
}
|
|
936
1541
|
function writeWorkPackageOutput(context, workPackages, options, mode, singleObject, defaultFields, token) {
|
|
937
1542
|
const fields = parseFields(options.fields, mode === "compact" ? DEFAULT_COMPACT_FIELDS : defaultFields);
|
|
938
1543
|
const projected = options.fields || mode === "table" || mode === "compact" || mode === "jsonl" ? projectRows(workPackages, fields) : workPackages;
|
|
@@ -971,6 +1576,11 @@ function parseId(id) {
|
|
|
971
1576
|
if (!Number.isInteger(parsed) || parsed < 1) throw new OpctlError("work package id must be a positive integer", EXIT_CODES.validation);
|
|
972
1577
|
return parsed;
|
|
973
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
|
+
}
|
|
974
1584
|
//#endregion
|
|
975
1585
|
//#region src/commands/profile.ts
|
|
976
1586
|
function registerProfile(program, context) {
|
|
@@ -1023,44 +1633,6 @@ function registerProfile(program, context) {
|
|
|
1023
1633
|
context.stdout.write(options.json ? stableJson(output) : `unset profile: ${name}\n`);
|
|
1024
1634
|
});
|
|
1025
1635
|
}
|
|
1026
|
-
var package_default = {
|
|
1027
|
-
name: "opctl",
|
|
1028
|
-
version: "0.1.6",
|
|
1029
|
-
description: "Conservative local CLI bridge for OpenProject API v3",
|
|
1030
|
-
type: "module",
|
|
1031
|
-
repository: {
|
|
1032
|
-
"type": "git",
|
|
1033
|
-
"url": "git+https://github.com/hewel/op-cli.git"
|
|
1034
|
-
},
|
|
1035
|
-
bin: { "opctl": "dist/cli.js" },
|
|
1036
|
-
files: ["dist"],
|
|
1037
|
-
scripts: {
|
|
1038
|
-
"dev": "tsx src/cli.ts",
|
|
1039
|
-
"build": "npm run typecheck && vite build",
|
|
1040
|
-
"typecheck": "tsc --noEmit",
|
|
1041
|
-
"test": "vitest run",
|
|
1042
|
-
"openapi:pull": "tsx scripts/pull-openapi-spec.ts",
|
|
1043
|
-
"openapi:generate": "tsx scripts/generate-openapi-types.ts",
|
|
1044
|
-
"openapi:update": "npm run openapi:pull && npm run openapi:generate"
|
|
1045
|
-
},
|
|
1046
|
-
keywords: ["openproject", "cli"],
|
|
1047
|
-
author: "",
|
|
1048
|
-
license: "ISC",
|
|
1049
|
-
dependencies: {
|
|
1050
|
-
"commander": "latest",
|
|
1051
|
-
"openapi-fetch": "latest"
|
|
1052
|
-
},
|
|
1053
|
-
devDependencies: {
|
|
1054
|
-
"@types/node": "latest",
|
|
1055
|
-
"openapi-typescript": "latest",
|
|
1056
|
-
"tsx": "latest",
|
|
1057
|
-
"typescript": "latest",
|
|
1058
|
-
"vite": "latest",
|
|
1059
|
-
"vitest": "latest",
|
|
1060
|
-
"yaml": "^2.9.0"
|
|
1061
|
-
},
|
|
1062
|
-
packageManager: "pnpm@11.1.3"
|
|
1063
|
-
};
|
|
1064
1636
|
//#endregion
|
|
1065
1637
|
//#region src/cli.ts
|
|
1066
1638
|
function buildProgram(context) {
|
|
@@ -1073,8 +1645,10 @@ function buildProgram(context) {
|
|
|
1073
1645
|
registerApiRoot(program, context);
|
|
1074
1646
|
registerProjects(program, context);
|
|
1075
1647
|
registerWorkPackages(program, context);
|
|
1648
|
+
registerLookups(program, context);
|
|
1076
1649
|
registerProfile(program, context);
|
|
1077
1650
|
registerSpec(program, context);
|
|
1651
|
+
registerDoctor(program, context);
|
|
1078
1652
|
return program;
|
|
1079
1653
|
}
|
|
1080
1654
|
async function run(argv, context) {
|
|
@@ -1083,11 +1657,14 @@ async function run(argv, context) {
|
|
|
1083
1657
|
return EXIT_CODES.success;
|
|
1084
1658
|
} catch (error) {
|
|
1085
1659
|
const opctlError = toOpctlError(error);
|
|
1086
|
-
if (argv.includes("--json"))
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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`);
|
|
1091
1668
|
return opctlError.exitCode;
|
|
1092
1669
|
}
|
|
1093
1670
|
}
|
|
@@ -1103,7 +1680,8 @@ if (isCliEntrypoint(import.meta.url)) {
|
|
|
1103
1680
|
const exitCode = await run(process.argv, {
|
|
1104
1681
|
stdout: process.stdout,
|
|
1105
1682
|
stderr: process.stderr,
|
|
1106
|
-
env: process.env
|
|
1683
|
+
env: process.env,
|
|
1684
|
+
stdin: process.stdin
|
|
1107
1685
|
});
|
|
1108
1686
|
process.exitCode = exitCode;
|
|
1109
1687
|
}
|