@tuanhv/op-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,645 @@
1
+ import { formatCell, renderTable } from "../output/table.js";
2
+ import { OpCliError, writeRefusal } from "../core/errors.js";
3
+ import { filtersQuery } from "../core/filters.js";
4
+ import { emitRows, PAGED_JSON_HELP, } from "../core/define.js";
5
+ import { flattenHalRecord, toFormattable } from "../core/hal.js";
6
+ import { apiDelete, apiGet, apiPatchRaw, apiPost, apiPostRaw, authenticate, } from "../core/http.js";
7
+ import { DEFAULT_PAGE_SIZE, allPageSize, halElements, parsePageSize, withPageSize, } from "../core/paginate.js";
8
+ import { isIdForm, rankByCloseness, resolveName, } from "../context/resolve.js";
9
+ import { parseOptionalId, } from "../context/profile.js";
10
+ export const LIST_COLUMNS = [
11
+ { title: "ID", field: "id" },
12
+ { title: "IDENTIFIER", field: "identifier" },
13
+ { title: "NAME", field: "name" },
14
+ { title: "PUBLIC", field: "public" },
15
+ { title: "FAVORITED", field: "favorited" },
16
+ ];
17
+ export const VERSION_COLUMNS = [
18
+ { title: "ID", field: "id" },
19
+ { title: "NAME", field: "name" },
20
+ { title: "STATUS", field: "status" },
21
+ ];
22
+ export const CATEGORY_COLUMNS = [
23
+ { title: "ID", field: "id" },
24
+ { title: "NAME", field: "name" },
25
+ ];
26
+ export const TYPE_COLUMNS = [
27
+ { title: "ID", field: "id" },
28
+ { title: "NAME", field: "name" },
29
+ { title: "MILESTONE", field: "isMilestone" },
30
+ ];
31
+ /** The shared opening: JSON mode first, then the profile behind the flags. */
32
+ async function connect(runtime, options) {
33
+ runtime.setJsonMode(options.json === true);
34
+ const profile = await runtime.resolve({
35
+ profile: options.profile,
36
+ project: parseOptionalId(options.project),
37
+ });
38
+ return {
39
+ getPage: (path) => apiGet(profile.instanceUrl, profile.apiKey, path),
40
+ };
41
+ }
42
+ function collectValue(value, previous) {
43
+ return [...previous, value];
44
+ }
45
+ function hitOf(element) {
46
+ const candidate = element;
47
+ return typeof candidate.id === "number"
48
+ && typeof candidate.identifier === "string"
49
+ && typeof candidate.name === "string"
50
+ ? { id: candidate.id, identifier: candidate.identifier, name: candidate.name }
51
+ : undefined;
52
+ }
53
+ const PROJECTS_COLLECTION = "/api/v3/projects";
54
+ const PRINCIPALS_COLLECTION = "/api/v3/principals";
55
+ const ROLES_COLLECTION = "/api/v3/roles";
56
+ const MEMBERSHIPS_COLLECTION = "/api/v3/memberships";
57
+ /**
58
+ * Every visible project, page by page in server order. Resolution walks
59
+ * instead of filtering server-side because the exact-match filter set
60
+ * differs between instance versions; the collection endpoint does not.
61
+ */
62
+ async function walkProjects(getPage) {
63
+ const hits = [];
64
+ for await (const element of halElements(getPage, withPageSize(PROJECTS_COLLECTION, DEFAULT_PAGE_SIZE))) {
65
+ const hit = hitOf(element);
66
+ if (hit !== undefined) {
67
+ hits.push(hit);
68
+ }
69
+ }
70
+ return hits;
71
+ }
72
+ function missingProjectError(raw, known) {
73
+ return new OpCliError("NOT_FOUND", `project "${raw}" not found.`, known.length > 0
74
+ ? `known projects, closest first: ${rankByCloseness(raw, known).slice(0, 6).join(", ")}.`
75
+ : "check the spelling or list projects with op-cli project list.");
76
+ }
77
+ /**
78
+ * Resolve one reference that may be an id, an identifier, or a name.
79
+ * All-digits means an id and is fetched directly. Anything else matches
80
+ * identifier and name exactly across every visible project; one distinct
81
+ * match wins, several distinct projects fail loudly instead of guessing.
82
+ */
83
+ async function resolveProjectRef(getPage, raw) {
84
+ if (isIdForm(raw)) {
85
+ try {
86
+ const hit = hitOf(await getPage(`${PROJECTS_COLLECTION}/${raw}`));
87
+ if (hit !== undefined) {
88
+ return hit;
89
+ }
90
+ throw new OpCliError("NOT_FOUND", `project "${raw}" not found.`, "check the id; run op-cli project list to see what is visible.");
91
+ }
92
+ catch (error) {
93
+ if (error instanceof OpCliError && error.code === "USAGE_ERROR") {
94
+ throw error;
95
+ }
96
+ if (error instanceof OpCliError && error.code === "NOT_FOUND") {
97
+ throw new OpCliError("NOT_FOUND", `project "${raw}" not found.`, "check the id; run op-cli project list to see what is visible.");
98
+ }
99
+ throw error;
100
+ }
101
+ }
102
+ const all = await walkProjects(getPage);
103
+ const matches = all.filter((hit) => hit.identifier === raw || hit.name === raw);
104
+ const distinct = [...new Map(matches.map((hit) => [hit.id, hit])).values()];
105
+ if (distinct.length === 1) {
106
+ return distinct[0];
107
+ }
108
+ if (distinct.length > 1) {
109
+ const listed = distinct.map((hit) => `${String(hit.id)} (${hit.name})`).join(", ");
110
+ throw new OpCliError("USAGE_ERROR", `project "${raw}" is ambiguous. Candidates: ${listed}.`, "repeat the value as the explicit id of one candidate.");
111
+ }
112
+ throw missingProjectError(raw, all.flatMap((hit) => [hit.identifier, hit.name]));
113
+ }
114
+ /**
115
+ * One live lookup over an uncached collection. Stored metadata has a
116
+ * refresh to re-read the disk store; here refresh simply walks again.
117
+ */
118
+ function liveSource(label, walk) {
119
+ let cache;
120
+ return {
121
+ label,
122
+ load: () => {
123
+ cache ??= walk();
124
+ return cache;
125
+ },
126
+ refresh: async () => {
127
+ cache = walk();
128
+ await cache;
129
+ },
130
+ };
131
+ }
132
+ /** Every principal of the instance, walked page by page in server order. */
133
+ async function walkPrincipals(getPage) {
134
+ const entries = [];
135
+ for await (const element of halElements(getPage, withPageSize(PRINCIPALS_COLLECTION, DEFAULT_PAGE_SIZE))) {
136
+ if (typeof element.id === "number" && typeof element.name === "string") {
137
+ entries.push({
138
+ name: element.name,
139
+ owner: "principal",
140
+ value: element.id,
141
+ });
142
+ }
143
+ }
144
+ return entries;
145
+ }
146
+ /** Every role of the instance; the API names them `title`. */
147
+ async function walkRoles(getPage) {
148
+ const entries = [];
149
+ for await (const element of halElements(getPage, withPageSize(ROLES_COLLECTION, DEFAULT_PAGE_SIZE))) {
150
+ if (typeof element.id === "number" && typeof element.title === "string") {
151
+ entries.push({ name: element.title, owner: "role", value: element.id });
152
+ }
153
+ }
154
+ return entries;
155
+ }
156
+ /**
157
+ * One membership principal, by name, id, or me. Ids pass through
158
+ * untouched; only a name costs the principals walk.
159
+ */
160
+ async function resolvePrincipalId(profile, raw) {
161
+ if (raw === "me") {
162
+ return (await authenticate(profile.instanceUrl, profile.apiKey)).id;
163
+ }
164
+ if (isIdForm(raw)) {
165
+ return Number(raw);
166
+ }
167
+ const getPage = (path) => apiGet(profile.instanceUrl, profile.apiKey, path);
168
+ return resolveName(raw, liveSource("principal", () => walkPrincipals(getPage)));
169
+ }
170
+ /** Membership roles, each by title or id; one walk serves them all. */
171
+ async function resolveRoleIds(profile, raws) {
172
+ const getPage = (path) => apiGet(profile.instanceUrl, profile.apiKey, path);
173
+ const source = liveSource("role", () => walkRoles(getPage));
174
+ const ids = [];
175
+ for (const raw of raws) {
176
+ ids.push(isIdForm(raw) ? Number(raw) : await resolveName(raw, source));
177
+ }
178
+ return ids;
179
+ }
180
+ /**
181
+ * The shared tail of every single-record command: --fields validated
182
+ * against the actual record, then the FIELD/VALUE table or picked JSON.
183
+ */
184
+ function selectedRecordFields(raw, record) {
185
+ const requested = [
186
+ ...new Set(raw.split(",").map((name) => name.trim()).filter((name) => name !== "")),
187
+ ];
188
+ const first = requested.find((name) => !(name in record));
189
+ if (first !== undefined) {
190
+ throw new OpCliError("USAGE_ERROR", `field "${first}" is not a column. Valid fields, closest first: `
191
+ + `${rankByCloseness(first, Object.keys(record)).join(", ")}.`, "run the command without --fields to list every available field.");
192
+ }
193
+ return requested;
194
+ }
195
+ function renderFieldTable(fields, record) {
196
+ return renderTable(["FIELD", "VALUE"], fields.map((field) => [field, formatCell(record[field])]));
197
+ }
198
+ function renderRecord(runtime, options, record) {
199
+ const fields = typeof options.fields === "string"
200
+ ? selectedRecordFields(options.fields, record)
201
+ : Object.keys(record);
202
+ if (options.json === true) {
203
+ const picked = {};
204
+ for (const field of fields) {
205
+ picked[field] = record[field];
206
+ }
207
+ runtime.write(`${JSON.stringify(picked)}\n`);
208
+ return;
209
+ }
210
+ runtime.write(renderFieldTable(fields, record));
211
+ }
212
+ /**
213
+ * A write whose failure to complete leaves the state unknown: timeouts
214
+ * and network errors are never retried, and exit 6 says so instead of
215
+ * inviting a duplicate. A surviving rejection maps through the closed
216
+ * catalogue: 404 stays NOT_FOUND, 5xx becomes the unknown-state
217
+ * NETWORK_ERROR, everything else carries the server's message.
218
+ */
219
+ async function projectWrite(attempt, verb) {
220
+ let response;
221
+ try {
222
+ response = await attempt();
223
+ }
224
+ catch (error) {
225
+ if (error instanceof OpCliError && error.code === "NETWORK_ERROR") {
226
+ throw new OpCliError("NETWORK_ERROR", `the ${verb} request did not complete; whether it was applied is unknown.`, verb === "update"
227
+ ? "run op-cli project get to see the stored values before repeating the command."
228
+ : "check whether the project exists before repeating the command.");
229
+ }
230
+ throw error;
231
+ }
232
+ if (response.status < 400) {
233
+ return response.body;
234
+ }
235
+ if (response.status === 404) {
236
+ throw new OpCliError("NOT_FOUND");
237
+ }
238
+ if (response.status >= 500) {
239
+ throw new OpCliError("NETWORK_ERROR", `the ${verb} failed with HTTP ${String(response.status)}; `
240
+ + "whether it was applied is unknown.", verb === "update"
241
+ ? "run op-cli project get to see the stored values before repeating the command."
242
+ : "check whether the project exists before repeating the command.");
243
+ }
244
+ throw writeRefusal(verb, response.status, response.body);
245
+ }
246
+ function requireIdentifier(options, verb) {
247
+ if (options.identifier === undefined || options.identifier === "") {
248
+ throw new OpCliError("USAGE_ERROR", `project ${verb} needs an explicit identifier.`, "pass --identifier <value>; OpenProject requires a machine-readable "
249
+ + "identifier next to the human name.");
250
+ }
251
+ return options.identifier;
252
+ }
253
+ function optionalBooleanFlag(raw, value, flag) {
254
+ if (raw !== true) {
255
+ return undefined;
256
+ }
257
+ const lowered = (value ?? "").trim().toLowerCase();
258
+ if (lowered !== "true" && lowered !== "false") {
259
+ throw new OpCliError("USAGE_ERROR", `--${flag} accepts true or false, not "${value ?? ""}".`, "pass --flag true or --flag false explicitly.");
260
+ }
261
+ return lowered === "true";
262
+ }
263
+ export function registerProjectCommands(project, runtime) {
264
+ project.description("Inspect and manage projects");
265
+ // ---------------------------------------------------------------------------
266
+ // project list
267
+ project
268
+ .command("list")
269
+ .description("List visible projects with documented filters")
270
+ .option("--search <text>", "substring match over name and identifier")
271
+ .option("--active", "only projects that are not archived")
272
+ .option("--archived", "only archived projects")
273
+ .option("--favorite", "only projects favourited by the current user")
274
+ .option("--parent <id>", "parent project id; repeat to OR values", collectValue, [])
275
+ .option("--json", PAGED_JSON_HELP)
276
+ .option("--limit <n>", "maximum number of results to show")
277
+ .option("--all", "fetch every page instead of one limited page")
278
+ .option("--profile <name>", "use this profile for this command only")
279
+ .option("--project <id>", "override the profile default project")
280
+ .action(async (options) => {
281
+ runtime.setJsonMode(options.json === true);
282
+ // Flag misuse is refused before any traffic.
283
+ if (options.active === true && options.archived === true) {
284
+ throw new OpCliError("USAGE_ERROR", "--active and --archived cannot be combined.", "pass only one of the two shorthands.");
285
+ }
286
+ for (const parent of options.parent ?? []) {
287
+ if (!isIdForm(parent)) {
288
+ throw new OpCliError("USAGE_ERROR", `--parent "${parent}" is not a project id.`, "--parent takes the numeric id of the parent project.");
289
+ }
290
+ }
291
+ const { getPage } = await connect(runtime, options);
292
+ const filters = [];
293
+ if (options.active === true || options.archived === true) {
294
+ filters.push({
295
+ name: "active",
296
+ operator: "=",
297
+ values: [options.active === true ? "t" : "f"],
298
+ });
299
+ }
300
+ if (options.favorite === true) {
301
+ filters.push({ name: "favorited", operator: "=", values: ["t"] });
302
+ }
303
+ if ((options.parent ?? []).length > 0) {
304
+ filters.push({ name: "parent_id", operator: "=", values: options.parent ?? [] });
305
+ }
306
+ if (options.search !== undefined && options.search !== "") {
307
+ filters.push({
308
+ name: "name_and_identifier",
309
+ operator: "~",
310
+ values: [options.search],
311
+ });
312
+ }
313
+ // No filter means a bare collection path, not an empty filters
314
+ // parameter: the wire stays readable and mockable.
315
+ // --all sizes pages by the instance's advertised maximum; --limit
316
+ // only ever sizes the single non-all page.
317
+ const basePath = filters.length > 0
318
+ ? `${PROJECTS_COLLECTION}?filters=${filtersQuery(filters)}`
319
+ : PROJECTS_COLLECTION;
320
+ const limit = parsePageSize(options.limit);
321
+ const startPath = withPageSize(basePath, options.all === true ? await allPageSize(getPage) : limit);
322
+ if (options.all === true) {
323
+ if (options.json === true) {
324
+ for await (const element of halElements(getPage, startPath)) {
325
+ runtime.write(`${JSON.stringify(flattenHalRecord(element))}\n`);
326
+ }
327
+ return;
328
+ }
329
+ const rows = [];
330
+ for await (const element of halElements(getPage, startPath)) {
331
+ rows.push(flattenHalRecord(element));
332
+ }
333
+ runtime.write(renderTable(LIST_COLUMNS.map((column) => column.title), rows.map((row) => LIST_COLUMNS.map((column) => formatCell(row[column.field])))));
334
+ return;
335
+ }
336
+ const page = (await getPage(startPath));
337
+ const elements = page._embedded?.elements ?? [];
338
+ const records = elements.map((element) => flattenHalRecord(element));
339
+ emitRows({ write: runtime.write, writeErr: runtime.writeErr }, LIST_COLUMNS, records, { json: options.json });
340
+ const total = typeof page.total === "number" ? page.total : elements.length;
341
+ if (total > elements.length) {
342
+ runtime.writeErr(`Showing ${records.length} of ${String(total)} projects. `
343
+ + "Pass --all to fetch every result.\n");
344
+ }
345
+ });
346
+ // ---------------------------------------------------------------------------
347
+ // project get
348
+ project
349
+ .command("get")
350
+ .description("Show one project by id, identifier, or name")
351
+ .argument("<reference>")
352
+ .option("--json", "emit a flat JSON record")
353
+ .option("--fields <list>", "comma-separated fields to show")
354
+ .option("--profile <name>", "use this profile for this command only")
355
+ .option("--project <id>", "override the profile default project")
356
+ .action(async (reference, options) => {
357
+ const { getPage } = await connect(runtime, options);
358
+ const hit = await resolveProjectRef(getPage, reference);
359
+ const record = flattenHalRecord(await getPage(`${PROJECTS_COLLECTION}/${String(hit.id)}`));
360
+ renderRecord(runtime, options, record);
361
+ });
362
+ // ---------------------------------------------------------------------------
363
+ // project create
364
+ project
365
+ .command("create")
366
+ .description("Create one project with an explicit identifier")
367
+ .argument("<name>")
368
+ .option("--identifier <value>", "machine-readable identifier of the new project")
369
+ .option("--description <text>", "markdown description of the project")
370
+ .option("--public", "make the project accessible for everybody")
371
+ .option("--parent <reference>", "parent project, by id, identifier, or name")
372
+ .option("--json", "emit a flat JSON record")
373
+ .option("--fields <list>", "comma-separated fields to show")
374
+ .option("--profile <name>", "use this profile for this command only")
375
+ .option("--project <id>", "override the profile default project")
376
+ .action(async (name, options) => {
377
+ const identifier = requireIdentifier(options, "create");
378
+ const { getPage } = await connect(runtime, options);
379
+ const profile = await runtime.resolve({
380
+ profile: options.profile,
381
+ project: parseOptionalId(options.project),
382
+ });
383
+ const payload = { name, identifier };
384
+ if (options.description !== undefined) {
385
+ payload.description = toFormattable(options.description);
386
+ }
387
+ if (options.public === true) {
388
+ payload.public = true;
389
+ }
390
+ if (options.parent !== undefined) {
391
+ const parent = await resolveProjectRef(getPage, options.parent);
392
+ payload._links = {
393
+ parent: { href: `${PROJECTS_COLLECTION}/${String(parent.id)}` },
394
+ };
395
+ }
396
+ const body = await projectWrite(() => apiPostRaw(profile.instanceUrl, profile.apiKey, PROJECTS_COLLECTION, payload), "create");
397
+ renderRecord(runtime, options, flattenHalRecord(body));
398
+ });
399
+ // ---------------------------------------------------------------------------
400
+ // project update
401
+ project
402
+ .command("update")
403
+ .description("Update one project by id, identifier, or name")
404
+ .argument("<reference>")
405
+ .option("--name <text>", "new display name")
406
+ .option("--description <text>", "new markdown description")
407
+ .option("--public <bool>", "true or false: accessibility for everybody")
408
+ .option("--json", "emit a flat JSON record")
409
+ .option("--fields <list>", "comma-separated fields to show")
410
+ .option("--profile <name>", "use this profile for this command only")
411
+ .option("--project <id>", "override the profile default project")
412
+ .action(async (reference, options) => {
413
+ const isPublic = optionalBooleanFlag(options.public !== undefined, options.public, "public");
414
+ const touchesSomething = options.name !== undefined
415
+ || options.description !== undefined
416
+ || isPublic !== undefined;
417
+ if (!touchesSomething) {
418
+ throw new OpCliError("USAGE_ERROR", "project update needs at least one value to change.", "pass --name, --description, or --public true|false.");
419
+ }
420
+ const { getPage } = await connect(runtime, options);
421
+ const profile = await runtime.resolve({
422
+ profile: options.profile,
423
+ project: parseOptionalId(options.project),
424
+ });
425
+ const hit = await resolveProjectRef(getPage, reference);
426
+ const payload = {};
427
+ if (options.name !== undefined) {
428
+ payload.name = options.name;
429
+ }
430
+ if (options.description !== undefined) {
431
+ payload.description = toFormattable(options.description);
432
+ }
433
+ if (isPublic !== undefined) {
434
+ payload.public = isPublic;
435
+ }
436
+ const body = await projectWrite(() => apiPatchRaw(profile.instanceUrl, profile.apiKey, `${PROJECTS_COLLECTION}/${String(hit.id)}`, payload), "update");
437
+ renderRecord(runtime, options, flattenHalRecord(body));
438
+ });
439
+ // ---------------------------------------------------------------------------
440
+ // project copy
441
+ project
442
+ .command("copy")
443
+ .description("Create a new project carrying the source's description, visibility, and parent")
444
+ .argument("<reference>", "source project, by id, identifier, or name")
445
+ .argument("<name>", "display name of the copy")
446
+ .option("--identifier <value>", "machine-readable identifier of the copy")
447
+ .option("--json", "emit a flat JSON record")
448
+ .option("--fields <list>", "comma-separated fields to show")
449
+ .option("--profile <name>", "use this profile for this command only")
450
+ .option("--project <id>", "override the profile default project")
451
+ .action(async (reference, name, options) => {
452
+ const identifier = requireIdentifier(options, "copy");
453
+ const { getPage } = await connect(runtime, options);
454
+ const profile = await runtime.resolve({
455
+ profile: options.profile,
456
+ project: parseOptionalId(options.project),
457
+ });
458
+ const hit = await resolveProjectRef(getPage, reference);
459
+ const source = (await getPage(`${PROJECTS_COLLECTION}/${String(hit.id)}`));
460
+ const payload = { name, identifier };
461
+ for (const attribute of ["description", "public", "active"]) {
462
+ if (source[attribute] !== undefined) {
463
+ payload[attribute] = source[attribute];
464
+ }
465
+ }
466
+ const sourceLinks = source._links;
467
+ const parentHref = sourceLinks?.parent?.href;
468
+ if (parentHref !== undefined) {
469
+ payload._links = { parent: { href: parentHref } };
470
+ }
471
+ const body = await projectWrite(() => apiPostRaw(profile.instanceUrl, profile.apiKey, PROJECTS_COLLECTION, payload), "copy");
472
+ renderRecord(runtime, options, flattenHalRecord(body));
473
+ });
474
+ // ---------------------------------------------------------------------------
475
+ // project star / unstar
476
+ project
477
+ .command("star")
478
+ .description("Mark one project as favourited for the current user")
479
+ .argument("<reference>")
480
+ .option("--profile <name>", "use this profile for this command only")
481
+ .option("--project <id>", "override the profile default project")
482
+ .action(async (reference, options) => {
483
+ const { getPage } = await connect(runtime, options);
484
+ const profile = await runtime.resolve({
485
+ profile: options.profile,
486
+ project: parseOptionalId(options.project),
487
+ });
488
+ const hit = await resolveProjectRef(getPage, reference);
489
+ await apiPost(profile.instanceUrl, profile.apiKey, `${PROJECTS_COLLECTION}/${String(hit.id)}/favorite`, {});
490
+ runtime.write(`Starred project ${hit.name} (${String(hit.id)}).\n`);
491
+ });
492
+ project
493
+ .command("unstar")
494
+ .description("Remove one project from the current user's favourites")
495
+ .argument("<reference>")
496
+ .option("--profile <name>", "use this profile for this command only")
497
+ .option("--project <id>", "override the profile default project")
498
+ .action(async (reference, options) => {
499
+ const { getPage } = await connect(runtime, options);
500
+ const profile = await runtime.resolve({
501
+ profile: options.profile,
502
+ project: parseOptionalId(options.project),
503
+ });
504
+ const hit = await resolveProjectRef(getPage, reference);
505
+ await apiDelete(profile.instanceUrl, profile.apiKey, `${PROJECTS_COLLECTION}/${String(hit.id)}/favorite`);
506
+ runtime.write(`Unstarred project ${hit.name} (${String(hit.id)}).\n`);
507
+ });
508
+ // ---------------------------------------------------------------------------
509
+ // project versions / categories / types
510
+ const vocabularyRuntime = {
511
+ write: runtime.write,
512
+ writeErr: runtime.writeErr,
513
+ setJsonMode: runtime.setJsonMode,
514
+ };
515
+ const registerVocabulary = (name, description, endpoint, columns) => project
516
+ .command(name)
517
+ .description(description)
518
+ .argument("<reference>", "project, by id, identifier, or name")
519
+ .option("--json", "emit a flat JSON array")
520
+ .option("--fields <list>", "comma-separated columns to show")
521
+ .option("--profile <name>", "use this profile for this command only")
522
+ .option("--project <id>", "override the profile default project")
523
+ .action(async (reference, options) => {
524
+ const { getPage } = await connect(runtime, options);
525
+ const hit = await resolveProjectRef(getPage, reference);
526
+ const startPath = withPageSize(endpoint(hit.id), DEFAULT_PAGE_SIZE);
527
+ const rows = [];
528
+ for await (const element of halElements(getPage, startPath)) {
529
+ rows.push(flattenHalRecord(element));
530
+ }
531
+ emitRows(vocabularyRuntime, columns, rows, options);
532
+ });
533
+ registerVocabulary("versions", "List the versions a work package in this project may use", (projectId) => `${PROJECTS_COLLECTION}/${String(projectId)}/versions`, VERSION_COLUMNS);
534
+ registerVocabulary("categories", "List the categories a work package in this project may use", (projectId) => `${PROJECTS_COLLECTION}/${String(projectId)}/categories`, CATEGORY_COLUMNS);
535
+ registerVocabulary("types", "List the work package types usable in this project", (projectId) => `${PROJECTS_COLLECTION}/${String(projectId)}/types`, TYPE_COLUMNS);
536
+ // ---------------------------------------------------------------------------
537
+ // project member add / remove
538
+ const member = project
539
+ .command("member")
540
+ .description("Manage the members of one project");
541
+ member
542
+ .command("add")
543
+ .description("Grant one principal a membership with one or more roles")
544
+ .argument("<reference>", "project, by id, identifier, or name")
545
+ .argument("<principal>", "user or group, by name, id, or me")
546
+ .argument("<role...>", "role or roles, by title or id")
547
+ .option("--json", "emit a flat JSON record")
548
+ .option("--fields <list>", "comma-separated fields to show")
549
+ .option("--profile <name>", "use this profile for this command only")
550
+ .option("--project <id>", "override the profile default project")
551
+ .action(async (reference, principal, roles, options) => {
552
+ runtime.setJsonMode(options.json === true);
553
+ const { getPage } = await connect(runtime, options);
554
+ const profile = await runtime.resolve({
555
+ profile: options.profile,
556
+ project: parseOptionalId(options.project),
557
+ });
558
+ const hit = await resolveProjectRef(getPage, reference);
559
+ const principalId = await resolvePrincipalId(profile, principal);
560
+ const roleIds = await resolveRoleIds(profile, roles);
561
+ const payload = {
562
+ _links: {
563
+ project: { href: `${PROJECTS_COLLECTION}/${String(hit.id)}` },
564
+ principal: { href: `${PRINCIPALS_COLLECTION}/${String(principalId)}` },
565
+ roles: roleIds.map((roleId) => ({
566
+ href: `${ROLES_COLLECTION}/${String(roleId)}`,
567
+ })),
568
+ },
569
+ };
570
+ const body = await projectWrite(() => apiPostRaw(profile.instanceUrl, profile.apiKey, MEMBERSHIPS_COLLECTION, payload), "member add");
571
+ renderRecord(runtime, options, flattenHalRecord(body));
572
+ });
573
+ member
574
+ .command("remove")
575
+ .description("Remove one principal's membership from the project")
576
+ .argument("<reference>", "project, by id, identifier, or name")
577
+ .argument("<principal>", "user or group, by name, id, or me")
578
+ .option("--profile <name>", "use this profile for this command only")
579
+ .option("--project <id>", "override the profile default project")
580
+ .action(async (reference, principal, options) => {
581
+ const { getPage } = await connect(runtime, options);
582
+ const profile = await runtime.resolve({
583
+ profile: options.profile,
584
+ project: parseOptionalId(options.project),
585
+ });
586
+ const hit = await resolveProjectRef(getPage, reference);
587
+ const principalId = await resolvePrincipalId(profile, principal);
588
+ // One membership per (project, principal): filter both ends and
589
+ // expect exactly one record back.
590
+ const filters = [
591
+ { name: "project", operator: "=", values: [String(hit.id)] },
592
+ { name: "principal", operator: "=", values: [String(principalId)] },
593
+ ];
594
+ const page = await apiGet(profile.instanceUrl, profile.apiKey, withPageSize(`${MEMBERSHIPS_COLLECTION}?filters=${filtersQuery(filters)}`, DEFAULT_PAGE_SIZE));
595
+ const ids = [];
596
+ for (const element of page._embedded?.elements ?? []) {
597
+ if (typeof element === "object"
598
+ && element !== null
599
+ && typeof element.id === "number") {
600
+ ids.push(element.id);
601
+ }
602
+ }
603
+ if (ids.length === 0) {
604
+ throw new OpCliError("NOT_FOUND", `"${principal}" is not a member of project ${hit.name} `
605
+ + `(${String(hit.id)}).`, "run op-cli meta members to see the current members.");
606
+ }
607
+ if (ids.length > 1) {
608
+ throw new OpCliError("API_ERROR", `the instance reports ${String(ids.length)} memberships for `
609
+ + `"${principal}" in this project; refusing to guess.`, "report this to the instance administrator.");
610
+ }
611
+ const membershipId = ids[0];
612
+ if (membershipId === undefined) {
613
+ throw new OpCliError("INTERNAL_ERROR");
614
+ }
615
+ await apiDelete(profile.instanceUrl, profile.apiKey, `${MEMBERSHIPS_COLLECTION}/${String(membershipId)}`);
616
+ runtime.write(`Removed membership ${String(membershipId)} from project `
617
+ + `${hit.name} (${String(hit.id)}).\n`);
618
+ });
619
+ // ---------------------------------------------------------------------------
620
+ // project delete
621
+ project
622
+ .command("delete")
623
+ .description("Delete one project after explicit confirmation")
624
+ .argument("<reference>", "project, by id, identifier, or name")
625
+ .option("--yes", "confirm the deletion")
626
+ .option("--profile <name>", "use this profile for this command only")
627
+ .option("--project <id>", "override the profile default project")
628
+ .action(async (reference, options) => {
629
+ // The guard fires before any resolution or traffic: without --yes
630
+ // nothing is read, nothing is sent, with or without a terminal.
631
+ if (options.yes !== true) {
632
+ throw new OpCliError("USAGE_ERROR", `project delete refuses to remove "${reference}" without confirmation; `
633
+ + "deleting a project is irreversible.", "repeat the command with --yes to confirm the deletion.");
634
+ }
635
+ const { getPage } = await connect(runtime, options);
636
+ const profile = await runtime.resolve({
637
+ profile: options.profile,
638
+ project: parseOptionalId(options.project),
639
+ });
640
+ const hit = await resolveProjectRef(getPage, reference);
641
+ await apiDelete(profile.instanceUrl, profile.apiKey, `${PROJECTS_COLLECTION}/${String(hit.id)}`);
642
+ runtime.write(`Deleted project ${hit.name} (${String(hit.id)}).\n`);
643
+ });
644
+ }
645
+ //# sourceMappingURL=project.js.map