@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,555 @@
1
+ import { PAGED_JSON_HELP } from "../core/define.js";
2
+ import { isoToHours, parseDuration } from "../core/duration.js";
3
+ import { OpCliError, writeRefusal } from "../core/errors.js";
4
+ import { filtersQuery, isoDate, sinceDate } from "../core/filters.js";
5
+ import { flattenHalRecord, formattableRaw, isFlatLink, toFormattable, } from "../core/hal.js";
6
+ import { apiDelete, apiGet, apiPatchRaw, apiPostRaw, authenticate, } from "../core/http.js";
7
+ import { 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
+ import { loadProjectVocabulary, loadProjectVocabularyById, refreshStoredMetadata, } from "../context/metadata.js";
11
+ import { formatCell, renderTable } from "../output/table.js";
12
+ // One flat row per time entry; the work package sits on every row so a
13
+ // multi-wp listing stays a single table instead of a nested grouping.
14
+ export const LIST_COLUMNS = [
15
+ { title: "ID", field: "id" },
16
+ { title: "WORK PACKAGE", field: "wp" },
17
+ { title: "HOURS", field: "hours" },
18
+ { title: "SPENT ON", field: "spentOn" },
19
+ { title: "USER", field: "user" },
20
+ { title: "ACTIVITY", field: "activity" },
21
+ ];
22
+ // Canonical field order of a single-record view; hours always reports
23
+ // decimal, with the ISO wire form kept beside it under hours_iso.
24
+ const RECORD_FIELDS = [
25
+ "id",
26
+ "wp",
27
+ "hours",
28
+ "hours_iso",
29
+ "spentOn",
30
+ "user",
31
+ "activity",
32
+ "project",
33
+ "comment",
34
+ ];
35
+ /**
36
+ * The flat caller-facing record of one HAL time entry. Links shrink to
37
+ * {id, name}, hours becomes a decimal number, and the entity_type /
38
+ * entity_id filter machinery never appears anywhere.
39
+ */
40
+ export function timeEntryRecord(element) {
41
+ const flat = flattenHalRecord(element);
42
+ const iso = typeof flat.hours === "string" ? flat.hours : null;
43
+ return {
44
+ id: typeof flat.id === "number" ? flat.id : null,
45
+ wp: flat.workPackage ?? null,
46
+ hours: iso === null ? null : isoToHours(iso),
47
+ hours_iso: iso,
48
+ spentOn: typeof flat.spentOn === "string" ? flat.spentOn : null,
49
+ user: flat.user ?? null,
50
+ activity: flat.activity ?? null,
51
+ project: flat.project ?? null,
52
+ comment: formattableRaw(flat.comment),
53
+ };
54
+ }
55
+ function renderTimeEntries(records) {
56
+ return renderTable(LIST_COLUMNS.map((column) => column.title), records.map((record) => LIST_COLUMNS.map((column) => formatCell(record[column.field]))));
57
+ }
58
+ /**
59
+ * The shared tail of every single-record command on this surface: --fields
60
+ * validated against the known columns plus the two shapes, a FIELD/VALUE
61
+ * table by default.
62
+ */
63
+ function renderRecord(runtime, options, record,
64
+ /**
65
+ * What already happened when the record is the result of a completed
66
+ * write. A bad --fields name is still misuse, but the message has to
67
+ * say the write landed or it invites a repeat that logs twice.
68
+ */
69
+ done) {
70
+ const fields = options.fields === undefined
71
+ ? [...RECORD_FIELDS]
72
+ : options.fields.split(",").map((name) => name.trim()).filter((name) => name !== "");
73
+ const first = fields.find((name) => !(name in record));
74
+ if (first !== undefined) {
75
+ throw new OpCliError("USAGE_ERROR", `${done === undefined ? "" : `${done}; `}`
76
+ + `field "${first}" is not a column. Valid fields, closest first: `
77
+ + `${rankByCloseness(first, [...RECORD_FIELDS]).join(", ")}.`, "run the command without --fields to list every available column.");
78
+ }
79
+ if (options.json === true) {
80
+ const picked = {};
81
+ for (const field of fields) {
82
+ picked[field] = record[field];
83
+ }
84
+ runtime.write(`${JSON.stringify(picked)}\n`);
85
+ return;
86
+ }
87
+ runtime.write(renderTable(["FIELD", "VALUE"], fields.map((field) => [field, formatCell(record[field])])));
88
+ }
89
+ /** Work packages and time entries are addressed by numeric id everywhere here. */
90
+ function requireId(reference, noun) {
91
+ if (!isIdForm(reference)) {
92
+ throw new OpCliError("USAGE_ERROR", `${noun} "${reference}" is not an id.`, `${noun}s are addressed by their numeric id.`);
93
+ }
94
+ }
95
+ function collectValue(value, previous) {
96
+ return [...previous, value];
97
+ }
98
+ /** Accept both repeated flags ("--wp 675 --wp 598") and lists ("675,598"). */
99
+ function splitList(raws) {
100
+ return [
101
+ ...new Set(raws
102
+ .flatMap((raw) => raw.split(","))
103
+ .map((raw) => raw.trim())
104
+ .filter((raw) => raw !== "")),
105
+ ];
106
+ }
107
+ /**
108
+ * Build the exact filter JSON the time-entries endpoint expects. The trap
109
+ * the Python predecessor documented lives here and nowhere else: the API
110
+ * scopes by entity type and entity id, and this function is the only
111
+ * place those names exist.
112
+ *
113
+ * `project` leads when a project is in context: there is no
114
+ * project-scoped time-entry collection to address instead, so the clause
115
+ * is the only way `--project` reaches the query at all (#19).
116
+ */
117
+ function buildTimeFilters(project, wps, users, from, now) {
118
+ const filters = [];
119
+ if (project !== undefined) {
120
+ filters.push({ name: "project", operator: "=", values: [String(project)] });
121
+ }
122
+ if (wps.length > 0) {
123
+ filters.push({ name: "entity_type", operator: "=", values: ["WorkPackage"] });
124
+ filters.push({ name: "entity_id", operator: "=", values: [...wps] });
125
+ }
126
+ if (users.length > 0) {
127
+ filters.push({ name: "user_id", operator: "=", values: [...users] });
128
+ }
129
+ if (from !== undefined) {
130
+ // Same date grammar as --updated-after on `wp list`, applied to
131
+ // spent_on instead, open upper bound included (#24).
132
+ const since = sinceDate(from, now);
133
+ filters.push({
134
+ name: "spent_on",
135
+ operator: "<>d",
136
+ values: [since, ""],
137
+ });
138
+ }
139
+ return filters;
140
+ }
141
+ function membersSource(env, profile) {
142
+ return {
143
+ label: "user",
144
+ load: async () => {
145
+ if (profile.project === undefined) {
146
+ throw new OpCliError("USAGE_ERROR", "--user needs a project to look member names up in.", "pass --project <id> or set a default project on the profile.");
147
+ }
148
+ const select = (vocabulary) => vocabulary.members.map((member) => ({
149
+ name: member.name,
150
+ owner: member.type,
151
+ value: member.user_id,
152
+ }));
153
+ return select(await loadProjectVocabulary(env, profile));
154
+ },
155
+ refresh: async () => {
156
+ await refreshStoredMetadata(env, profile);
157
+ },
158
+ };
159
+ }
160
+ async function resolveUserValues(env, profile, raws) {
161
+ let authenticatedId;
162
+ const ids = [];
163
+ const push = (id) => {
164
+ const key = String(id);
165
+ if (!ids.includes(key)) {
166
+ ids.push(key);
167
+ }
168
+ };
169
+ for (const raw of raws) {
170
+ if (raw.toLowerCase() === "me") {
171
+ authenticatedId ??= (await authenticate(profile.instanceUrl, profile.apiKey)).id;
172
+ push(authenticatedId);
173
+ }
174
+ else if (isIdForm(raw)) {
175
+ push(raw);
176
+ }
177
+ else {
178
+ push(await resolveName(raw, membersSource(env, profile)));
179
+ }
180
+ }
181
+ return ids;
182
+ }
183
+ /**
184
+ * Activities are scoped to the project of the logged work package (#5),
185
+ * sourced from the POST /api/v3/time_entries/form payload.
186
+ */
187
+ function activitiesSource(env, profile, projectId) {
188
+ return {
189
+ label: "activity",
190
+ load: async () => (await loadProjectVocabularyById(env, profile, projectId)).activities.map((activity) => ({ name: activity.name, owner: "activity", value: activity.id })),
191
+ refresh: async () => {
192
+ await refreshStoredMetadata(env, profile);
193
+ },
194
+ };
195
+ }
196
+ /**
197
+ * Catalogue mapping for a create that survived without retrying: 404
198
+ * stays NOT_FOUND, 5xx means the state is unknown, everything else is
199
+ * the shared refusal mapping.
200
+ */
201
+ function logRejection(status, body) {
202
+ if (status === 404) {
203
+ return new OpCliError("NOT_FOUND");
204
+ }
205
+ if (status >= 500) {
206
+ return new OpCliError("NETWORK_ERROR", `the request failed with HTTP ${String(status)}; whether the entry was recorded is unknown.`, "check op-cli time list before repeating the command.");
207
+ }
208
+ return writeRefusal("entry", status, body);
209
+ }
210
+ /**
211
+ * A create whose failure leaves the recorded state unknown: timeouts and
212
+ * network errors are never retried on writes, and exit 6 says so instead
213
+ * of inviting a duplicate entry.
214
+ */
215
+ async function postEntry(profile, payload) {
216
+ try {
217
+ return await apiPostRaw(profile.instanceUrl, profile.apiKey, "/api/v3/time_entries", payload);
218
+ }
219
+ catch (error) {
220
+ if (error instanceof OpCliError && error.code === "NETWORK_ERROR") {
221
+ throw new OpCliError("NETWORK_ERROR", "the request did not complete; whether the entry was recorded is unknown.", "check op-cli time list before repeating the command.");
222
+ }
223
+ throw error;
224
+ }
225
+ }
226
+ /**
227
+ * Catalogue mapping for an update that survived without retrying: 404
228
+ * stays NOT_FOUND, 5xx means the state is unknown, everything else is
229
+ * the shared refusal mapping.
230
+ */
231
+ function updateRejection(status, body) {
232
+ if (status === 404) {
233
+ return new OpCliError("NOT_FOUND");
234
+ }
235
+ if (status >= 500) {
236
+ return new OpCliError("NETWORK_ERROR", `the request failed with HTTP ${String(status)}; whether the change was applied is unknown.`, "check op-cli time get before repeating the command.");
237
+ }
238
+ return writeRefusal("change", status, body);
239
+ }
240
+ /** A patch whose failure leaves the stored state unknown: never retried. */
241
+ async function patchEntry(profile, id, payload) {
242
+ try {
243
+ return await apiPatchRaw(profile.instanceUrl, profile.apiKey, `/api/v3/time_entries/${id}`, payload);
244
+ }
245
+ catch (error) {
246
+ if (error instanceof OpCliError && error.code === "NETWORK_ERROR") {
247
+ throw new OpCliError("NETWORK_ERROR", "the request did not complete; whether the change was applied is unknown.", "check op-cli time get before repeating the command.");
248
+ }
249
+ throw error;
250
+ }
251
+ }
252
+ /** A delete whose failure leaves the stored state unknown: never retried. */
253
+ async function deleteEntry(profile, id) {
254
+ try {
255
+ await apiDelete(profile.instanceUrl, profile.apiKey, `/api/v3/time_entries/${id}`);
256
+ }
257
+ catch (error) {
258
+ if (error instanceof OpCliError && error.code === "NETWORK_ERROR") {
259
+ throw new OpCliError("NETWORK_ERROR", "the request did not complete; whether the entry was removed is unknown.", "check op-cli time list before repeating the command.");
260
+ }
261
+ throw error;
262
+ }
263
+ }
264
+ /** Strict calendar date for --spent-on: the wire form is YYYY-MM-DD. */
265
+ function requireIsoDate(value) {
266
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
267
+ throw new OpCliError("USAGE_ERROR", `--spent-on "${value}" is not a calendar date.`, "give YYYY-MM-DD, for example 2026-08-21.");
268
+ }
269
+ return value;
270
+ }
271
+ /**
272
+ * One shared resolution path for --activity on log and update alike:
273
+ * id form passes through, a name resolves against the vocabulary of the
274
+ * project the work belongs to (#5).
275
+ */
276
+ async function resolveActivityHref(runtime, profile, projectId, raw) {
277
+ const activityId = isIdForm(raw)
278
+ ? Number(raw)
279
+ : await resolveName(raw, activitiesSource(runtime.env, profile, projectId));
280
+ return `/api/v3/time_entries/activities/${String(activityId)}`;
281
+ }
282
+ export function registerTimeCommands(time, runtime) {
283
+ time.description("Track and inspect time entries");
284
+ time.command("log")
285
+ .description("Record time spent on one work package")
286
+ .argument("<id>", "work package id")
287
+ .requiredOption("--hours <value>", "decimal hours such as 1.5, a compound form such as 1h30m, "
288
+ + "or an ISO 8601 duration such as PT1H30M")
289
+ .option("--activity <name-or-id>", "activity of the project, by name or id")
290
+ .option("--spent-on <date>", "calendar date the hours were spent, YYYY-MM-DD; defaults to today")
291
+ .option("--json", "emit a flat JSON record")
292
+ .option("--fields <list>", "comma-separated columns to show")
293
+ .option("--profile <name>", "use this profile for this command only")
294
+ .option("--project <id>", "override the profile default project")
295
+ .action(async (reference, options) => {
296
+ runtime.setJsonMode(options.json === true);
297
+ requireId(reference, "work package");
298
+ const duration = parseDuration(options.hours);
299
+ // OpenProject refuses a time entry without a date, so the wire
300
+ // always carries one: today unless --spent-on says otherwise.
301
+ const spentOn = options.spentOn === undefined
302
+ ? isoDate(new Date())
303
+ : requireIsoDate(options.spentOn);
304
+ const profile = await runtime.resolve({
305
+ profile: options.profile,
306
+ project: parseOptionalId(options.project),
307
+ });
308
+ // The activity vocabulary hangs off the project the work package
309
+ // belongs to, not off the profile default.
310
+ const flatWp = flattenHalRecord(await apiGet(profile.instanceUrl, profile.apiKey, `/api/v3/work_packages/${reference}`));
311
+ const projectId = flatWp.project?.id ?? null;
312
+ if (projectId === null) {
313
+ throw new OpCliError("API_ERROR", `work package ${reference} carries no project link.`, "check the work package on the instance.");
314
+ }
315
+ const activityHref = options.activity === undefined
316
+ ? undefined
317
+ : await resolveActivityHref(runtime, profile, projectId, options.activity);
318
+ const response = await postEntry(profile, {
319
+ hours: duration.iso,
320
+ spentOn,
321
+ _links: {
322
+ workPackage: { href: `/api/v3/work_packages/${reference}` },
323
+ ...(activityHref === undefined ? {} : { activity: { href: activityHref } }),
324
+ },
325
+ });
326
+ if (response.status < 200
327
+ || response.status >= 300
328
+ || response.body === undefined) {
329
+ throw logRejection(response.status, response.body);
330
+ }
331
+ const entry = timeEntryRecord(response.body);
332
+ renderRecord(runtime, options, entry, `time entry ${String(entry.id)} was logged`);
333
+ });
334
+ time.command("list")
335
+ .description("List time entries with the work package on every row")
336
+ .option("--wp <id>", "work package id; repeat or comma-separate to OR values", collectValue, [])
337
+ .option("--user <name>", "user name, id, or me; repeat to OR values", collectValue, [])
338
+ .option("--from <date>", "today, yesterday, days back such as 7d, or YYYY-MM-DD")
339
+ .option("--json", PAGED_JSON_HELP)
340
+ .option("--limit <n>", "maximum number of results to show")
341
+ .option("--all", "fetch every page instead of one limited page")
342
+ .option("--profile <name>", "use this profile for this command only")
343
+ .option("--project <id>", "override the profile default project")
344
+ .action(async (options) => {
345
+ runtime.setJsonMode(options.json === true);
346
+ // Refuse impossible input before any traffic or resolution.
347
+ const wps = splitList(options.wp ?? []);
348
+ for (const wp of wps) {
349
+ requireId(wp, "work package");
350
+ }
351
+ const profile = await runtime.resolve({
352
+ profile: options.profile,
353
+ project: parseOptionalId(options.project),
354
+ });
355
+ const users = await resolveUserValues(runtime.env, profile, splitList(options.user ?? []));
356
+ const filters = buildTimeFilters(profile.project, wps, users, options.from, new Date());
357
+ const limit = parsePageSize(options.limit);
358
+ const getPage = (path) => apiGet(profile.instanceUrl, profile.apiKey, path);
359
+ // --all sizes pages by the instance's advertised maximum; --limit
360
+ // only ever sizes the single non-all page.
361
+ const startPath = withPageSize(`/api/v3/time_entries?filters=${filtersQuery(filters)}`, options.all === true ? await allPageSize(getPage) : limit);
362
+ if (options.all === true) {
363
+ if (options.json === true) {
364
+ for await (const element of halElements(getPage, startPath)) {
365
+ runtime.write(`${JSON.stringify(timeEntryRecord(element))}\n`);
366
+ }
367
+ return;
368
+ }
369
+ const rows = [];
370
+ for await (const element of halElements(getPage, startPath)) {
371
+ rows.push(timeEntryRecord(element));
372
+ }
373
+ runtime.write(renderTimeEntries(rows));
374
+ return;
375
+ }
376
+ const page = (await getPage(startPath));
377
+ const elements = page._embedded?.elements ?? [];
378
+ const rows = elements.map((element) => timeEntryRecord(element));
379
+ if (options.json === true) {
380
+ runtime.write(`${JSON.stringify(rows)}\n`);
381
+ }
382
+ else {
383
+ runtime.write(renderTimeEntries(rows));
384
+ }
385
+ const total = typeof page.total === "number" ? page.total : rows.length;
386
+ if (total > rows.length) {
387
+ runtime.writeErr(`Showing ${rows.length} of ${total} time entries. `
388
+ + "Pass --all to fetch every result.\n");
389
+ }
390
+ });
391
+ time.command("get")
392
+ .description("Show one time entry")
393
+ .argument("<id>", "time entry id")
394
+ .option("--json", "emit a flat JSON record")
395
+ .option("--fields <list>", "comma-separated columns to show")
396
+ .option("--profile <name>", "use this profile for this command only")
397
+ .option("--project <id>", "override the profile default project")
398
+ .action(async (reference, options) => {
399
+ runtime.setJsonMode(options.json === true);
400
+ requireId(reference, "time entry");
401
+ const profile = await runtime.resolve({
402
+ profile: options.profile,
403
+ project: parseOptionalId(options.project),
404
+ });
405
+ const record = timeEntryRecord(await apiGet(profile.instanceUrl, profile.apiKey, `/api/v3/time_entries/${reference}`));
406
+ renderRecord(runtime, options, record);
407
+ });
408
+ time.command("update")
409
+ .description("Update one time entry")
410
+ .argument("<id>", "time entry id")
411
+ .option("--hours <value>", "decimal hours such as 1.5, a compound form such as 1h30m, "
412
+ + "or an ISO 8601 duration such as PT1H30M")
413
+ .option("--activity <name-or-id>", "activity of the project, by name or id")
414
+ .option("--comment <text>", "replace the comment")
415
+ .option("--spent-on <date>", "move the entry to this day, YYYY-MM-DD")
416
+ .option("--json", "emit a flat JSON record")
417
+ .option("--fields <list>", "comma-separated columns to show")
418
+ .option("--profile <name>", "use this profile for this command only")
419
+ .option("--project <id>", "override the profile default project")
420
+ .action(async (reference, options) => {
421
+ runtime.setJsonMode(options.json === true);
422
+ requireId(reference, "time entry");
423
+ // Refuse impossible input before any traffic or resolution.
424
+ const hours = options.hours === undefined
425
+ ? undefined
426
+ : parseDuration(options.hours);
427
+ const spentOn = options.spentOn === undefined
428
+ ? undefined
429
+ : requireIsoDate(options.spentOn);
430
+ const touchesSomething = hours !== undefined
431
+ || options.activity !== undefined
432
+ || options.comment !== undefined
433
+ || spentOn !== undefined;
434
+ if (!touchesSomething) {
435
+ throw new OpCliError("USAGE_ERROR", "time update needs at least one value to change.", "pass --hours, --activity, --comment, or --spent-on.");
436
+ }
437
+ const profile = await runtime.resolve({
438
+ profile: options.profile,
439
+ project: parseOptionalId(options.project),
440
+ });
441
+ const payload = {};
442
+ if (hours !== undefined) {
443
+ payload.hours = hours.iso;
444
+ }
445
+ if (spentOn !== undefined) {
446
+ payload.spentOn = spentOn;
447
+ }
448
+ if (options.comment !== undefined) {
449
+ payload.comment = toFormattable(options.comment);
450
+ }
451
+ if (options.activity !== undefined) {
452
+ // The activity vocabulary hangs off the project the entry's own
453
+ // work package belongs to, not off the profile default.
454
+ const flat = flattenHalRecord(await apiGet(profile.instanceUrl, profile.apiKey, `/api/v3/time_entries/${reference}`));
455
+ const projectId = flat.project?.id ?? null;
456
+ if (projectId === null) {
457
+ throw new OpCliError("API_ERROR", `time entry ${reference} carries no project link.`, "check the time entry on the instance.");
458
+ }
459
+ payload._links = {
460
+ activity: {
461
+ href: await resolveActivityHref(runtime, profile, projectId, options.activity),
462
+ },
463
+ };
464
+ }
465
+ const response = await patchEntry(profile, reference, payload);
466
+ if (response.status < 200
467
+ || response.status >= 300
468
+ || response.body === undefined) {
469
+ throw updateRejection(response.status, response.body);
470
+ }
471
+ renderRecord(runtime, options, timeEntryRecord(response.body));
472
+ });
473
+ time.command("delete")
474
+ .description("Delete one time entry after explicit confirmation")
475
+ .argument("<id>", "time entry id")
476
+ .option("--yes", "confirm the deletion")
477
+ .option("--profile <name>", "use this profile for this command only")
478
+ .option("--project <id>", "override the profile default project")
479
+ .action(async (reference, options) => {
480
+ // The guard fires before any resolution or traffic: without --yes
481
+ // nothing is read, nothing is sent, with or without a terminal.
482
+ if (options.yes !== true) {
483
+ throw new OpCliError("USAGE_ERROR", `time delete refuses to remove time entry ${reference} without confirmation.`, "repeat the command with --yes to confirm the deletion.");
484
+ }
485
+ requireId(reference, "time entry");
486
+ const profile = await runtime.resolve({
487
+ profile: options.profile,
488
+ project: parseOptionalId(options.project),
489
+ });
490
+ await deleteEntry(profile, reference);
491
+ runtime.write(`Deleted time entry ${reference}.\n`);
492
+ });
493
+ time.command("report")
494
+ .description("Sum logged hours over the same filters as time list")
495
+ .option("--wp <id>", "work package id; repeat or comma-separate to OR values", collectValue, [])
496
+ .option("--user <name>", "user name, id, or me; repeat to OR values", collectValue, [])
497
+ .option("--from <date>", "today, yesterday, days back such as 7d, or YYYY-MM-DD")
498
+ .option("--json", "emit a flat JSON array of per-work-package groups")
499
+ .option("--profile <name>", "use this profile for this command only")
500
+ .option("--project <id>", "override the profile default project")
501
+ .action(async (options) => {
502
+ runtime.setJsonMode(options.json === true);
503
+ // Refuse impossible input before any traffic or resolution.
504
+ const wps = splitList(options.wp ?? []);
505
+ for (const wp of wps) {
506
+ requireId(wp, "work package");
507
+ }
508
+ const profile = await runtime.resolve({
509
+ profile: options.profile,
510
+ project: parseOptionalId(options.project),
511
+ });
512
+ const users = await resolveUserValues(runtime.env, profile, splitList(options.user ?? []));
513
+ const filters = buildTimeFilters(profile.project, wps, users, options.from, new Date());
514
+ // A total is only honest over the whole filtered set, so report
515
+ // always walks every page; there is no --limit to half-count by.
516
+ const startPath = withPageSize(`/api/v3/time_entries?filters=${filtersQuery(filters)}`, parsePageSize(undefined));
517
+ const getPage = (path) => apiGet(profile.instanceUrl, profile.apiKey, path);
518
+ const groups = new Map();
519
+ let totalMs = 0;
520
+ let totalCount = 0;
521
+ for await (const element of halElements(getPage, startPath)) {
522
+ const record = timeEntryRecord(element);
523
+ const wp = isFlatLink(record.wp) ? record.wp : null;
524
+ const key = wp === null ? "none" : String(wp.id);
525
+ const group = groups.get(key) ?? { wp, count: 0, ms: 0 };
526
+ group.count += 1;
527
+ // Millisecond integers keep every partial sum exact; only the
528
+ // final render divides into decimal hours.
529
+ const ms = typeof record.hours === "number"
530
+ ? Math.round(record.hours * 3600000)
531
+ : 0;
532
+ group.ms += ms;
533
+ totalMs += ms;
534
+ totalCount += 1;
535
+ groups.set(key, group);
536
+ }
537
+ if (options.json === true) {
538
+ runtime.write(`${JSON.stringify([...groups.values()].map((group) => ({
539
+ wp: group.wp,
540
+ entries: group.count,
541
+ hours: group.ms / 3600000,
542
+ })))}\n`);
543
+ return;
544
+ }
545
+ runtime.write(renderTable(["WORK PACKAGE", "ENTRIES", "HOURS"], [
546
+ ...[...groups.values()].map((group) => [
547
+ group.wp === null || group.wp.name === null ? "-" : group.wp.name,
548
+ String(group.count),
549
+ String(group.ms / 3600000),
550
+ ]),
551
+ ["TOTAL", String(totalCount), String(totalMs / 3600000)],
552
+ ]));
553
+ });
554
+ }
555
+ //# sourceMappingURL=time.js.map
@@ -0,0 +1,47 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import { readStoredMetadata } from "../context/metadata.js";
4
+ import { peekActiveProfile } from "../context/profile.js";
5
+ async function cliVersion() {
6
+ try {
7
+ const raw = await readFile(fileURLToPath(new URL("../../package.json", import.meta.url)), "utf8");
8
+ const pkg = JSON.parse(raw);
9
+ return typeof pkg.version === "string" ? pkg.version : "unknown";
10
+ }
11
+ catch {
12
+ return "unknown";
13
+ }
14
+ }
15
+ // Builds the --version output: the CLI version always, plus the stored
16
+ // instance versions when cached metadata is available. Deliberately runs
17
+ // without credentials and without any network access; it only reads files.
18
+ export async function buildVersionOutput(env) {
19
+ const lines = [`op-cli ${await cliVersion()}`];
20
+ try {
21
+ const active = await peekActiveProfile(env);
22
+ if (active !== undefined) {
23
+ const stored = await readStoredMetadata(env, {
24
+ name: active.name,
25
+ instanceUrl: active.instanceUrl,
26
+ apiKey: "",
27
+ project: undefined,
28
+ });
29
+ const versions = [
30
+ typeof stored?.instance.api_version === "string"
31
+ ? `api ${stored.instance.api_version}`
32
+ : undefined,
33
+ typeof stored?.instance.core_version === "string"
34
+ ? `core ${stored.instance.core_version}`
35
+ : undefined,
36
+ ].filter((part) => part !== undefined);
37
+ if (versions.length > 0) {
38
+ lines.push(`instance ${active.instanceUrl} (${versions.join(", ")})`);
39
+ }
40
+ }
41
+ }
42
+ catch {
43
+ // Version output must survive a broken config or cache directory.
44
+ }
45
+ return lines.join("\n");
46
+ }
47
+ //# sourceMappingURL=version.js.map