@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,528 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { halRefId } from "../core/hal.js";
5
+ import { apiGet, apiPost } from "../core/http.js";
6
+ import { halElements } from "../core/paginate.js";
7
+ import { OpCliError } from "../core/errors.js";
8
+ import { ENV_PROFILE_NAME } from "./profile.js";
9
+ function cacheRoot(env) {
10
+ const root = env.OP_CLI_CACHE_DIR ??
11
+ (env.HOME !== undefined ? join(env.HOME, ".cache", "op-cli") : undefined);
12
+ if (root === undefined) {
13
+ throw new OpCliError("INTERNAL_ERROR");
14
+ }
15
+ return root;
16
+ }
17
+ export function metadataPath(env, profile) {
18
+ const key = profile.name === ENV_PROFILE_NAME
19
+ ? `env-${createHash("sha1").update(profile.instanceUrl).digest("hex")}`
20
+ : profile.name;
21
+ return join(cacheRoot(env), key, "metadata.json");
22
+ }
23
+ export async function readStoredMetadata(env, profile) {
24
+ try {
25
+ return JSON.parse(await readFile(metadataPath(env, profile), "utf8"));
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ }
31
+ async function persistMetadata(env, profile, metadata) {
32
+ const path = metadataPath(env, profile);
33
+ await mkdir(dirname(path), { recursive: true });
34
+ await writeFile(path, `${JSON.stringify(metadata, null, 2)}\n`);
35
+ }
36
+ function asString(value) {
37
+ return typeof value === "string" ? value : "";
38
+ }
39
+ function asBoolean(value) {
40
+ return value === true;
41
+ }
42
+ function sortByName(entries) {
43
+ return [...entries].sort((a, b) => a.name.localeCompare(b.name));
44
+ }
45
+ function toStoredType(element) {
46
+ return {
47
+ id: Number(element.id),
48
+ name: asString(element.name),
49
+ is_milestone: asBoolean(element.isMilestone),
50
+ };
51
+ }
52
+ function toStoredStatus(element) {
53
+ return {
54
+ id: Number(element.id),
55
+ name: asString(element.name),
56
+ is_closed: asBoolean(element.isClosed),
57
+ is_default: asBoolean(element.isDefault),
58
+ };
59
+ }
60
+ function toStoredPriority(element) {
61
+ return {
62
+ id: Number(element.id),
63
+ name: asString(element.name),
64
+ is_default: asBoolean(element.isDefault),
65
+ };
66
+ }
67
+ function hrefId(value) {
68
+ const href = asString(value?.href);
69
+ const match = /(\d+)\/?$/.exec(href);
70
+ return match === null ? 0 : Number(match[1]);
71
+ }
72
+ function principalKind(element) {
73
+ switch (asString(element._type)) {
74
+ case "Group":
75
+ return "Group";
76
+ case "PlaceholderUser":
77
+ case "Placeholder":
78
+ return "Placeholder";
79
+ default:
80
+ return "User";
81
+ }
82
+ }
83
+ function toStoredMember(element) {
84
+ const links = (element._links ?? {});
85
+ const embedded = (element._embedded ?? {});
86
+ const principal = (embedded.principal ?? {});
87
+ const principalLink = (links.principal ?? {});
88
+ const roles = Array.isArray(links.roles) ? links.roles : [];
89
+ return {
90
+ membership_id: Number(element.id),
91
+ user_id: typeof principal.id === "number"
92
+ ? principal.id
93
+ : hrefId(principalLink),
94
+ name: asString(principal.name) || asString(principalLink.title),
95
+ type: principalKind(principal),
96
+ roles: roles.map((role) => ({
97
+ id: hrefId(role),
98
+ title: asString(role.title),
99
+ })),
100
+ };
101
+ }
102
+ function toStoredVersion(element) {
103
+ return {
104
+ id: Number(element.id),
105
+ name: asString(element.name),
106
+ status: asString(element.status),
107
+ };
108
+ }
109
+ function toStoredCategory(element) {
110
+ return {
111
+ id: Number(element.id),
112
+ name: asString(element.name),
113
+ };
114
+ }
115
+ // Above this size an allowed-values list is noise in the store, not a
116
+ // usable dropdown; the field keeps working, just without the list.
117
+ const MAX_ALLOWED_VALUES = 50;
118
+ /**
119
+ * The selectable values of one schema property, in either shape an
120
+ * instance uses: full resources under `_embedded.allowedValues`, which is
121
+ * what the create form returns, or bare links under `_links.allowedValues`,
122
+ * which is all the schema endpoint ever carries. Anything without a
123
+ * readable id is dropped rather than resolved to a fabricated one.
124
+ */
125
+ function allowedOptionsOf(property) {
126
+ const embedded = (property._embedded ?? {});
127
+ const resources = Array.isArray(embedded.allowedValues)
128
+ ? embedded.allowedValues
129
+ : [];
130
+ if (resources.length > 0) {
131
+ return resources.flatMap((value) => {
132
+ const option = value;
133
+ const id = Number(option.id);
134
+ if (!Number.isFinite(id)) {
135
+ return [];
136
+ }
137
+ return [{
138
+ id,
139
+ // A CustomOption spells its text "value"; every other resource
140
+ // spells it "name".
141
+ name: asString(option.value ?? option.name),
142
+ }];
143
+ });
144
+ }
145
+ const links = (property._links ?? {});
146
+ const values = Array.isArray(links.allowedValues)
147
+ ? links.allowedValues
148
+ : [];
149
+ return values.flatMap((value) => {
150
+ const link = value;
151
+ const id = halRefId(typeof link.href === "string" ? link.href : null);
152
+ return id === null ? [] : [{ id, name: asString(link.title) }];
153
+ });
154
+ }
155
+ /**
156
+ * The allowed values of one custom field as humans read them: the names of
157
+ * a list field's options, or the values the schema listed for any other
158
+ * kind. A vocabulary longer than MAX_ALLOWED_VALUES teaches nothing in a
159
+ * table cell, so it renders as nothing.
160
+ */
161
+ export function customFieldAllowedNames(field) {
162
+ const names = field.allowed_options === undefined
163
+ ? field.allowed_values ?? []
164
+ : field.allowed_options.map((option) => option.name);
165
+ return names.length > MAX_ALLOWED_VALUES ? [] : names;
166
+ }
167
+ function customFieldEntries(schema) {
168
+ const fields = [];
169
+ for (const [key, property] of Object.entries(schema)) {
170
+ if (!/^customField\d+$/.test(key)) {
171
+ continue;
172
+ }
173
+ // The schema `type` decides input validation downstream; anything
174
+ // beyond Boolean/User/CustomOption stays unmarked and passes through
175
+ // as text. A multi-valued field spells its kind "[]Kind".
176
+ const kind = asString(property.type).toLowerCase();
177
+ const single = kind.replace(/^\[\]/, "");
178
+ const isUser = kind === "user";
179
+ // A user field's allowed values are the project's members, which
180
+ // meta members already lists; keeping them here would only bloat the
181
+ // store.
182
+ const options = isUser ? [] : allowedOptionsOf(property);
183
+ const isList = single === "customoption";
184
+ fields.push({
185
+ key,
186
+ id: Number(key.slice("customField".length)),
187
+ name: asString(property.name),
188
+ ...(property.required === true
189
+ ? { is_required: true }
190
+ : {}),
191
+ ...(!isList && options.length > 0 && options.length <= MAX_ALLOWED_VALUES
192
+ ? { allowed_values: options.map((option) => option.name) }
193
+ : {}),
194
+ ...(kind === "boolean" ? { is_boolean: true } : {}),
195
+ ...(isUser ? { is_user: true } : {}),
196
+ ...(isList ? { is_list: true, allowed_options: options } : {}),
197
+ ...(kind.startsWith("[]") ? { is_multi: true } : {}),
198
+ });
199
+ }
200
+ return fields;
201
+ }
202
+ async function fetchMembers(profile, projectId) {
203
+ const filters = encodeURIComponent(JSON.stringify([{ project: { operator: "=", values: [String(projectId)] } }]));
204
+ const members = await fetchVocabulary(profile, `/api/v3/memberships?filters=${filters}`, toStoredMember);
205
+ return sortByName(members);
206
+ }
207
+ async function fetchVersions(profile, projectId) {
208
+ const versions = await fetchVocabulary(profile, `/api/v3/projects/${String(projectId)}/versions`, toStoredVersion);
209
+ return sortByName(versions);
210
+ }
211
+ async function fetchCategories(profile, projectId) {
212
+ const categories = await fetchVocabulary(profile, `/api/v3/projects/${String(projectId)}/categories`, toStoredCategory);
213
+ return sortByName(categories);
214
+ }
215
+ // The bare schema endpoint builds its values from TimeEntry.new, so its
216
+ // activity list is never populated. Only the create form (form_embedded)
217
+ // fills the activities that are active in the referenced project; its
218
+ // payload carries linked resources under _links, and the embedded schema
219
+ // lists full activity representations under activity/_embedded/allowedValues.
220
+ async function fetchActivities(profile, projectId) {
221
+ const form = (await apiPost(profile.instanceUrl, profile.apiKey, "/api/v3/time_entries/form", { _links: { project: { href: `/api/v3/projects/${String(projectId)}` } } }));
222
+ const formEmbedded = (form._embedded ?? {});
223
+ const schema = (formEmbedded.schema ?? {});
224
+ const activity = (schema.activity ?? {});
225
+ const activityEmbedded = (activity._embedded ?? {});
226
+ const values = Array.isArray(activityEmbedded.allowedValues)
227
+ ? activityEmbedded.allowedValues
228
+ : [];
229
+ const activities = values.map((option) => ({
230
+ id: Number(option.id),
231
+ name: asString(option.name),
232
+ is_default: option.default === true,
233
+ }));
234
+ return sortByName(activities);
235
+ }
236
+ /**
237
+ * The schema of one project-and-type pair, from the richest source that
238
+ * answers. The bare schema endpoint names every custom field but leaves a
239
+ * list field's allowed values empty; only the create form fills them, so
240
+ * the form is asked first. A caller who may not open a create form still
241
+ * gets every field, just without the options.
242
+ */
243
+ async function fetchTypeSchema(profile, projectId, typeId) {
244
+ try {
245
+ const form = (await apiPost(profile.instanceUrl, profile.apiKey, `/api/v3/projects/${String(projectId)}/work_packages/form`, { _links: { type: { href: `/api/v3/types/${String(typeId)}` } } }));
246
+ const embedded = (form._embedded ?? {});
247
+ const schema = embedded.schema;
248
+ if (typeof schema === "object" && schema !== null && !Array.isArray(schema)) {
249
+ return schema;
250
+ }
251
+ }
252
+ catch {
253
+ // The form is an enrichment, never the only source.
254
+ }
255
+ return (await apiGet(profile.instanceUrl, profile.apiKey, `/api/v3/work_packages/schemas/${String(projectId)}-${String(typeId)}`));
256
+ }
257
+ async function fetchCustomFields(profile, projectId) {
258
+ const typeIds = await fetchVocabulary(profile, `/api/v3/projects/${String(projectId)}/types`, (element) => Number(element.id));
259
+ const byType = {};
260
+ for (const typeId of typeIds) {
261
+ const schema = await fetchTypeSchema(profile, projectId, typeId);
262
+ byType[String(typeId)] = sortByName(customFieldEntries(schema));
263
+ }
264
+ return byType;
265
+ }
266
+ async function fetchVocabulary(profile, path, convert) {
267
+ const elements = [];
268
+ for await (const element of halElements((page) => apiGet(profile.instanceUrl, profile.apiKey, page), path)) {
269
+ elements.push(convert(element));
270
+ }
271
+ return elements;
272
+ }
273
+ async function fetchInstanceInfo(profile) {
274
+ let apiVersion = null;
275
+ let coreVersion = null;
276
+ try {
277
+ const root = (await apiGet(profile.instanceUrl, profile.apiKey, "/api/v3/"));
278
+ apiVersion = typeof root.apiVersion === "string" ? root.apiVersion : null;
279
+ coreVersion = typeof root.coreVersion === "string" ? root.coreVersion : null;
280
+ }
281
+ catch {
282
+ // The root probe only enriches the store; stock instances may omit it.
283
+ }
284
+ return {
285
+ url: profile.instanceUrl,
286
+ api_version: apiVersion,
287
+ core_version: coreVersion,
288
+ fetched_at: new Date().toISOString(),
289
+ };
290
+ }
291
+ async function fetchProjectRef(profile, projectId) {
292
+ const project = (await apiGet(profile.instanceUrl, profile.apiKey, `/api/v3/projects/${String(projectId)}`));
293
+ return {
294
+ id: Number(project.id),
295
+ identifier: asString(project.identifier),
296
+ name: asString(project.name),
297
+ };
298
+ }
299
+ export async function fetchStoredMetadata(profile) {
300
+ const types = sortByName(await fetchVocabulary(profile, "/api/v3/types", toStoredType));
301
+ const statuses = sortByName(await fetchVocabulary(profile, "/api/v3/statuses", toStoredStatus));
302
+ const priorities = sortByName(await fetchVocabulary(profile, "/api/v3/priorities", toStoredPriority));
303
+ const instance = await fetchInstanceInfo(profile);
304
+ const project = profile.project === undefined
305
+ ? undefined
306
+ : await fetchProjectRef(profile, profile.project);
307
+ return {
308
+ types,
309
+ statuses,
310
+ priorities,
311
+ instance,
312
+ ...(project === undefined ? {} : { project }),
313
+ };
314
+ }
315
+ export async function loadStoredMetadata(env, profile) {
316
+ const stored = await readStoredMetadata(env, profile);
317
+ if (stored !== undefined) {
318
+ return stored;
319
+ }
320
+ const fetched = await fetchStoredMetadata(profile);
321
+ await persistMetadata(env, profile, fetched);
322
+ return fetched;
323
+ }
324
+ export async function refreshStoredMetadata(env, profile) {
325
+ const previous = await readStoredMetadata(env, profile);
326
+ const next = await fetchStoredMetadata(profile);
327
+ const previousScoped = previous?.projectScoped ?? {};
328
+ const projectScoped = {};
329
+ const dropped = [];
330
+ for (const projectId of Object.keys(previousScoped)) {
331
+ try {
332
+ projectScoped[projectId] = await fetchProjectVocabulary(profile, Number(projectId));
333
+ }
334
+ catch (error) {
335
+ // A project deleted on the instance must not wedge the refresh for
336
+ // good: drop its stored vocabulary, report it, and keep refreshing
337
+ // everything else. Any other failure is still the caller's to see.
338
+ if (!(error instanceof OpCliError && error.code === "NOT_FOUND")) {
339
+ throw error;
340
+ }
341
+ dropped.push({
342
+ section: "project",
343
+ kind: "removed",
344
+ id: Number(projectId),
345
+ detail: `removed cached vocabulary of project ${projectId}`,
346
+ });
347
+ }
348
+ }
349
+ await persistMetadata(env, profile, {
350
+ ...next,
351
+ ...(Object.keys(projectScoped).length > 0 ? { projectScoped } : {}),
352
+ });
353
+ const changes = [
354
+ ...diffEntries("types", previous?.types, next.types),
355
+ ...diffEntries("statuses", previous?.statuses, next.statuses),
356
+ ...diffEntries("priorities", previous?.priorities, next.priorities),
357
+ ...diffProject(previous?.project, next.project),
358
+ ...dropped,
359
+ ...diffInstance(previous?.instance, next.instance),
360
+ ];
361
+ for (const projectId of Object.keys(projectScoped)) {
362
+ changes.push(...diffVocabulary(previousScoped[projectId], projectScoped[projectId]));
363
+ }
364
+ return changes;
365
+ }
366
+ export async function fetchProjectVocabulary(profile, projectId) {
367
+ return {
368
+ project_id: projectId,
369
+ fetched_at: new Date().toISOString(),
370
+ members: await fetchMembers(profile, projectId),
371
+ versions: await fetchVersions(profile, projectId),
372
+ categories: await fetchCategories(profile, projectId),
373
+ activities: await fetchActivities(profile, projectId),
374
+ custom_fields: await fetchCustomFields(profile, projectId),
375
+ };
376
+ }
377
+ export async function loadProjectVocabulary(env, profile) {
378
+ if (profile.project === undefined) {
379
+ throw new OpCliError("USAGE_ERROR");
380
+ }
381
+ return loadProjectVocabularyById(env, profile, profile.project);
382
+ }
383
+ /**
384
+ * The same cache-and-fetch flow for an explicit project id, used when the
385
+ * project is not the profile default but a resource's own (a time entry
386
+ * resolves its activity against the project of its work package).
387
+ */
388
+ export async function loadProjectVocabularyById(env, profile, projectId) {
389
+ const stored = await readStoredMetadata(env, profile);
390
+ const cached = stored?.projectScoped?.[String(projectId)];
391
+ if (cached !== undefined) {
392
+ return cached;
393
+ }
394
+ const base = stored ?? (await fetchStoredMetadata(profile));
395
+ const vocabulary = await fetchProjectVocabulary(profile, projectId);
396
+ await persistMetadata(env, profile, {
397
+ ...base,
398
+ projectScoped: {
399
+ ...base.projectScoped,
400
+ [String(projectId)]: vocabulary,
401
+ },
402
+ });
403
+ return vocabulary;
404
+ }
405
+ export async function clearStoredMetadata(env, profile) {
406
+ await rm(metadataPath(env, profile), { force: true });
407
+ }
408
+ function formatValue(value) {
409
+ if (value === null) {
410
+ return "null";
411
+ }
412
+ return String(value);
413
+ }
414
+ function fieldDiffs(before, after) {
415
+ const beforeRecord = before;
416
+ const afterRecord = after;
417
+ const keys = new Set([
418
+ ...Object.keys(beforeRecord),
419
+ ...Object.keys(afterRecord),
420
+ ]);
421
+ // Arrays (roles, allowed values) arrive as fresh objects on every fetch;
422
+ // compare them by value or every refresh would flag them as changed.
423
+ const sameValue = (before, after) => Array.isArray(before) || Array.isArray(after)
424
+ ? JSON.stringify(before) === JSON.stringify(after)
425
+ : Object.is(before, after);
426
+ const diffs = [];
427
+ for (const key of keys) {
428
+ if (!sameValue(beforeRecord[key], afterRecord[key])) {
429
+ diffs.push(`${key}: ${formatValue(beforeRecord[key])} -> ${formatValue(afterRecord[key])}`);
430
+ }
431
+ }
432
+ return diffs.sort();
433
+ }
434
+ function diffEntries(section, before, after) {
435
+ const changes = [];
436
+ const beforeById = new Map((before ?? []).map((entry) => [entry.id, entry]));
437
+ const afterIds = new Set(after.map((entry) => entry.id));
438
+ for (const entry of after) {
439
+ const previous = beforeById.get(entry.id);
440
+ if (previous === undefined) {
441
+ changes.push({
442
+ section,
443
+ kind: "added",
444
+ id: entry.id,
445
+ detail: `added ${entry.name} (${String(entry.id)})`,
446
+ });
447
+ continue;
448
+ }
449
+ const diffs = fieldDiffs(previous, entry);
450
+ if (diffs.length > 0) {
451
+ changes.push({
452
+ section,
453
+ kind: "changed",
454
+ id: entry.id,
455
+ detail: `${diffs.join("; ")} (${String(entry.id)})`,
456
+ });
457
+ }
458
+ }
459
+ for (const entry of before ?? []) {
460
+ if (!afterIds.has(entry.id)) {
461
+ changes.push({
462
+ section,
463
+ kind: "removed",
464
+ id: entry.id,
465
+ detail: `removed ${entry.name} (${String(entry.id)})`,
466
+ });
467
+ }
468
+ }
469
+ return changes;
470
+ }
471
+ function diffProject(before, after) {
472
+ if (before === undefined && after !== undefined) {
473
+ return [{
474
+ section: "project",
475
+ kind: "added",
476
+ id: after.id,
477
+ detail: `added ${after.name} (${String(after.id)})`,
478
+ }];
479
+ }
480
+ if (before !== undefined && after === undefined) {
481
+ return [{
482
+ section: "project",
483
+ kind: "removed",
484
+ id: before.id,
485
+ detail: `removed ${before.name} (${String(before.id)})`,
486
+ }];
487
+ }
488
+ if (before === undefined || after === undefined) {
489
+ return [];
490
+ }
491
+ const diffs = fieldDiffs(before, after);
492
+ return diffs.length === 0
493
+ ? []
494
+ : [{ section: "project", kind: "changed", id: after.id, detail: diffs.join("; ") }];
495
+ }
496
+ function diffInstance(before, after) {
497
+ if (before === undefined) {
498
+ return [];
499
+ }
500
+ const comparable = (info) => ({
501
+ api_version: info.api_version,
502
+ core_version: info.core_version,
503
+ });
504
+ const diffs = fieldDiffs(comparable(before), comparable(after));
505
+ return diffs.length === 0
506
+ ? []
507
+ : [{ section: "instance", kind: "changed", id: null, detail: diffs.join("; ") }];
508
+ }
509
+ function diffVocabulary(before, after) {
510
+ if (after === undefined) {
511
+ return [];
512
+ }
513
+ // diffEntries keys on id; members identify by their membership id.
514
+ const memberEntry = (member) => ({
515
+ id: member.membership_id,
516
+ name: member.name,
517
+ roles: member.roles,
518
+ });
519
+ const fields = (vocabulary) => Object.values(vocabulary?.custom_fields ?? {}).flat();
520
+ return [
521
+ ...diffEntries("members", before?.members.map(memberEntry), after.members.map(memberEntry)),
522
+ ...diffEntries("versions", before?.versions, after.versions),
523
+ ...diffEntries("categories", before?.categories, after.categories),
524
+ ...diffEntries("activities", before?.activities, after.activities),
525
+ ...diffEntries("fields", fields(before), fields(after)),
526
+ ];
527
+ }
528
+ //# sourceMappingURL=metadata.js.map