@tuanhv/op-cli 0.1.1 → 0.2.1

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.
@@ -1,12 +1,12 @@
1
1
  import { PAGED_JSON_HELP } from "../core/define.js";
2
2
  import { isoToHours, parseDuration } from "../core/duration.js";
3
3
  import { OpCliError, writeRefusal } from "../core/errors.js";
4
- import { filtersQuery, isoDate, sinceDate } from "../core/filters.js";
4
+ import { filtersQuery, isoDate, sinceDate, untilDate, } from "../core/filters.js";
5
5
  import { flattenHalRecord, formattableRaw, isFlatLink, toFormattable, } from "../core/hal.js";
6
6
  import { apiDelete, apiGet, apiPatchRaw, apiPostRaw, authenticate, } from "../core/http.js";
7
7
  import { allPageSize, halElements, parsePageSize, withPageSize } from "../core/paginate.js";
8
8
  import { isIdForm, rankByCloseness, resolveName, } from "../context/resolve.js";
9
- import { parseOptionalId, } from "../context/profile.js";
9
+ import { parseProjectOverride, } from "../context/profile.js";
10
10
  import { loadProjectVocabulary, loadProjectVocabularyById, refreshStoredMetadata, } from "../context/metadata.js";
11
11
  import { formatCell, renderTable } from "../output/table.js";
12
12
  // One flat row per time entry; the work package sits on every row so a
@@ -113,8 +113,13 @@ function splitList(raws) {
113
113
  * `project` leads when a project is in context: there is no
114
114
  * project-scoped time-entry collection to address instead, so the clause
115
115
  * is the only way `--project` reaches the query at all (#19).
116
+ *
117
+ * `from`/`to` bound `spent_on`, a pure date column: the API's `<>d`
118
+ * includes BOTH bound dates (verified against a live instance on
119
+ * 2026-08-24; the #24 half-open rule is a timestamp-column behaviour,
120
+ * not this one). An open end is spelled "".
116
121
  */
117
- function buildTimeFilters(project, wps, users, from, now) {
122
+ function buildTimeFilters(project, wps, users, from, to, now) {
118
123
  const filters = [];
119
124
  if (project !== undefined) {
120
125
  filters.push({ name: "project", operator: "=", values: [String(project)] });
@@ -126,16 +131,34 @@ function buildTimeFilters(project, wps, users, from, now) {
126
131
  if (users.length > 0) {
127
132
  filters.push({ name: "user_id", operator: "=", values: [...users] });
128
133
  }
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);
134
+ if (from !== undefined && to !== undefined) {
135
+ const since = sinceDate(from, now, "--from");
136
+ const until = untilDate(to, now);
137
+ if (until < since) {
138
+ throw new OpCliError("USAGE_ERROR", `--to ${until} is before --from ${since}.`, "pass the end of the period after its start.");
139
+ }
140
+ filters.push({
141
+ name: "spent_on",
142
+ operator: "<>d",
143
+ values: [since, until],
144
+ });
145
+ }
146
+ else if (from !== undefined) {
147
+ const since = sinceDate(from, now, "--from");
133
148
  filters.push({
134
149
  name: "spent_on",
135
150
  operator: "<>d",
151
+ // The empty upper bound keeps the window open-ended.
136
152
  values: [since, ""],
137
153
  });
138
154
  }
155
+ else if (to !== undefined) {
156
+ filters.push({
157
+ name: "spent_on",
158
+ operator: "<>d",
159
+ values: ["", untilDate(to, now)],
160
+ });
161
+ }
139
162
  return filters;
140
163
  }
141
164
  function membersSource(env, profile) {
@@ -143,7 +166,7 @@ function membersSource(env, profile) {
143
166
  label: "user",
144
167
  load: async () => {
145
168
  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.");
169
+ throw new OpCliError("USAGE_ERROR", "--user needs a project to look member names up in.", "pass --project <name-or-id> or set a default project on the profile.");
147
170
  }
148
171
  const select = (vocabulary) => vocabulary.members.map((member) => ({
149
172
  name: member.name,
@@ -286,12 +309,12 @@ export function registerTimeCommands(time, runtime) {
286
309
  .argument("<id>", "work package id")
287
310
  .requiredOption("--hours <value>", "decimal hours such as 1.5, a compound form such as 1h30m, "
288
311
  + "or an ISO 8601 duration such as PT1H30M")
289
- .option("--activity <name-or-id>", "activity of the project, by name or id")
312
+ .requiredOption("--activity <name-or-id>", "activity of the project, by name or id")
290
313
  .option("--spent-on <date>", "calendar date the hours were spent, YYYY-MM-DD; defaults to today")
291
314
  .option("--json", "emit a flat JSON record")
292
315
  .option("--fields <list>", "comma-separated columns to show")
293
316
  .option("--profile <name>", "use this profile for this command only")
294
- .option("--project <id>", "override the profile default project")
317
+ .option("--project <name-or-id>", "override the profile default project")
295
318
  .action(async (reference, options) => {
296
319
  runtime.setJsonMode(options.json === true);
297
320
  requireId(reference, "work package");
@@ -303,7 +326,7 @@ export function registerTimeCommands(time, runtime) {
303
326
  : requireIsoDate(options.spentOn);
304
327
  const profile = await runtime.resolve({
305
328
  profile: options.profile,
306
- project: parseOptionalId(options.project),
329
+ project: parseProjectOverride(options.project),
307
330
  });
308
331
  // The activity vocabulary hangs off the project the work package
309
332
  // belongs to, not off the profile default.
@@ -312,15 +335,13 @@ export function registerTimeCommands(time, runtime) {
312
335
  if (projectId === null) {
313
336
  throw new OpCliError("API_ERROR", `work package ${reference} carries no project link.`, "check the work package on the instance.");
314
337
  }
315
- const activityHref = options.activity === undefined
316
- ? undefined
317
- : await resolveActivityHref(runtime, profile, projectId, options.activity);
338
+ const activityHref = await resolveActivityHref(runtime, profile, projectId, options.activity);
318
339
  const response = await postEntry(profile, {
319
340
  hours: duration.iso,
320
341
  spentOn,
321
342
  _links: {
322
343
  workPackage: { href: `/api/v3/work_packages/${reference}` },
323
- ...(activityHref === undefined ? {} : { activity: { href: activityHref } }),
344
+ activity: { href: activityHref },
324
345
  },
325
346
  });
326
347
  if (response.status < 200
@@ -336,11 +357,12 @@ export function registerTimeCommands(time, runtime) {
336
357
  .option("--wp <id>", "work package id; repeat or comma-separate to OR values", collectValue, [])
337
358
  .option("--user <name>", "user name, id, or me; repeat to OR values", collectValue, [])
338
359
  .option("--from <date>", "today, yesterday, days back such as 7d, or YYYY-MM-DD")
360
+ .option("--to <date>", "close the period on this day, inclusive; same date forms")
339
361
  .option("--json", PAGED_JSON_HELP)
340
362
  .option("--limit <n>", "maximum number of results to show")
341
363
  .option("--all", "fetch every page instead of one limited page")
342
364
  .option("--profile <name>", "use this profile for this command only")
343
- .option("--project <id>", "override the profile default project")
365
+ .option("--project <name-or-id>", "override the profile default project")
344
366
  .action(async (options) => {
345
367
  runtime.setJsonMode(options.json === true);
346
368
  // Refuse impossible input before any traffic or resolution.
@@ -350,10 +372,10 @@ export function registerTimeCommands(time, runtime) {
350
372
  }
351
373
  const profile = await runtime.resolve({
352
374
  profile: options.profile,
353
- project: parseOptionalId(options.project),
375
+ project: parseProjectOverride(options.project),
354
376
  });
355
377
  const users = await resolveUserValues(runtime.env, profile, splitList(options.user ?? []));
356
- const filters = buildTimeFilters(profile.project, wps, users, options.from, new Date());
378
+ const filters = buildTimeFilters(profile.project, wps, users, options.from, options.to, new Date());
357
379
  const limit = parsePageSize(options.limit);
358
380
  const getPage = (path) => apiGet(profile.instanceUrl, profile.apiKey, path);
359
381
  // --all sizes pages by the instance's advertised maximum; --limit
@@ -394,13 +416,13 @@ export function registerTimeCommands(time, runtime) {
394
416
  .option("--json", "emit a flat JSON record")
395
417
  .option("--fields <list>", "comma-separated columns to show")
396
418
  .option("--profile <name>", "use this profile for this command only")
397
- .option("--project <id>", "override the profile default project")
419
+ .option("--project <name-or-id>", "override the profile default project")
398
420
  .action(async (reference, options) => {
399
421
  runtime.setJsonMode(options.json === true);
400
422
  requireId(reference, "time entry");
401
423
  const profile = await runtime.resolve({
402
424
  profile: options.profile,
403
- project: parseOptionalId(options.project),
425
+ project: parseProjectOverride(options.project),
404
426
  });
405
427
  const record = timeEntryRecord(await apiGet(profile.instanceUrl, profile.apiKey, `/api/v3/time_entries/${reference}`));
406
428
  renderRecord(runtime, options, record);
@@ -416,7 +438,7 @@ export function registerTimeCommands(time, runtime) {
416
438
  .option("--json", "emit a flat JSON record")
417
439
  .option("--fields <list>", "comma-separated columns to show")
418
440
  .option("--profile <name>", "use this profile for this command only")
419
- .option("--project <id>", "override the profile default project")
441
+ .option("--project <name-or-id>", "override the profile default project")
420
442
  .action(async (reference, options) => {
421
443
  runtime.setJsonMode(options.json === true);
422
444
  requireId(reference, "time entry");
@@ -436,7 +458,7 @@ export function registerTimeCommands(time, runtime) {
436
458
  }
437
459
  const profile = await runtime.resolve({
438
460
  profile: options.profile,
439
- project: parseOptionalId(options.project),
461
+ project: parseProjectOverride(options.project),
440
462
  });
441
463
  const payload = {};
442
464
  if (hours !== undefined) {
@@ -475,7 +497,7 @@ export function registerTimeCommands(time, runtime) {
475
497
  .argument("<id>", "time entry id")
476
498
  .option("--yes", "confirm the deletion")
477
499
  .option("--profile <name>", "use this profile for this command only")
478
- .option("--project <id>", "override the profile default project")
500
+ .option("--project <name-or-id>", "override the profile default project")
479
501
  .action(async (reference, options) => {
480
502
  // The guard fires before any resolution or traffic: without --yes
481
503
  // nothing is read, nothing is sent, with or without a terminal.
@@ -485,7 +507,7 @@ export function registerTimeCommands(time, runtime) {
485
507
  requireId(reference, "time entry");
486
508
  const profile = await runtime.resolve({
487
509
  profile: options.profile,
488
- project: parseOptionalId(options.project),
510
+ project: parseProjectOverride(options.project),
489
511
  });
490
512
  await deleteEntry(profile, reference);
491
513
  runtime.write(`Deleted time entry ${reference}.\n`);
@@ -495,9 +517,10 @@ export function registerTimeCommands(time, runtime) {
495
517
  .option("--wp <id>", "work package id; repeat or comma-separate to OR values", collectValue, [])
496
518
  .option("--user <name>", "user name, id, or me; repeat to OR values", collectValue, [])
497
519
  .option("--from <date>", "today, yesterday, days back such as 7d, or YYYY-MM-DD")
520
+ .option("--to <date>", "close the period on this day, inclusive; same date forms")
498
521
  .option("--json", "emit a flat JSON array of per-work-package groups")
499
522
  .option("--profile <name>", "use this profile for this command only")
500
- .option("--project <id>", "override the profile default project")
523
+ .option("--project <name-or-id>", "override the profile default project")
501
524
  .action(async (options) => {
502
525
  runtime.setJsonMode(options.json === true);
503
526
  // Refuse impossible input before any traffic or resolution.
@@ -507,10 +530,10 @@ export function registerTimeCommands(time, runtime) {
507
530
  }
508
531
  const profile = await runtime.resolve({
509
532
  profile: options.profile,
510
- project: parseOptionalId(options.project),
533
+ project: parseProjectOverride(options.project),
511
534
  });
512
535
  const users = await resolveUserValues(runtime.env, profile, splitList(options.user ?? []));
513
- const filters = buildTimeFilters(profile.project, wps, users, options.from, new Date());
536
+ const filters = buildTimeFilters(profile.project, wps, users, options.from, options.to, new Date());
514
537
  // A total is only honest over the whole filtered set, so report
515
538
  // always walks every page; there is no --limit to half-count by.
516
539
  const startPath = withPageSize(`/api/v3/time_entries?filters=${filtersQuery(filters)}`, parsePageSize(undefined));
@@ -0,0 +1,199 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { configDirectory } from "../context/profile.js";
4
+ import { OpCliError } from "../core/errors.js";
5
+ import { cliVersion } from "./version.js";
6
+ const PACKAGE_SPEC = "@tuanhv/op-cli@latest";
7
+ // Scoped package names must escape the slash for a registry path segment.
8
+ const REGISTRY_URL = "https://registry.npmjs.org/@tuanhv%2Fop-cli/latest";
9
+ // The bare name `npm ls -g` answers ownership with: exit 0 exactly when
10
+ // npm's global tree holds the package.
11
+ const PACKAGE_NAME = "@tuanhv/op-cli";
12
+ // The notice is a convenience, never a delay: its lookup gets a budget far
13
+ // below the API's own, and every failure path stays silent.
14
+ const NOTICE_TIMEOUT_MS = 1_500;
15
+ const NOTICE_TTL_MS = 24 * 60 * 60 * 1000;
16
+ function isHomebrewPath(path) {
17
+ // `npm prefix -g` reports the bare prefix ("/opt/homebrew"), while a
18
+ // resolved binary lands deeper ("/opt/homebrew/bin/op-cli"); the
19
+ // substring catches both without claiming plain /usr/local, which a
20
+ // stock Node install shares with Intel Homebrew.
21
+ return path.includes("/opt/homebrew")
22
+ || path.includes("/Cellar/")
23
+ || path.includes("/usr/local/Homebrew");
24
+ }
25
+ // An install another manager owns would be overwritten by npm today and
26
+ // reverted by that manager's next upgrade, so the exact command is
27
+ // printed for the human to run instead. Ownership is asked of npm
28
+ // itself: `npm ls -g` exits 0 exactly when npm's global tree holds the
29
+ // package, so a Homebrew-built Node with an npm -g install stays npm's.
30
+ // A Homebrew *prefix* only names Node's installer; treating it as the
31
+ // package's manager misroutes every npm -g install on such a Node.
32
+ // Homebrew is claimed only when npm does not own the package and the
33
+ // binary or prefix lands inside a Homebrew tree.
34
+ async function detectManagingInstall(runtime) {
35
+ const capture = runtime.captureExternal;
36
+ if (capture === undefined) {
37
+ return undefined;
38
+ }
39
+ const prefix = (await capture("npm", ["prefix", "-g"]))?.trim() ?? "";
40
+ if (prefix.includes("volta")) {
41
+ return { label: "Volta", command: `volta install ${PACKAGE_SPEC}` };
42
+ }
43
+ const bin = (await capture("which", ["op-cli"]))?.trim() ?? "";
44
+ if (bin.includes("volta")) {
45
+ return { label: "Volta", command: `volta install ${PACKAGE_SPEC}` };
46
+ }
47
+ // The exit code is the answer; the listing text itself is not read.
48
+ const ownedByNpm = await capture("npm", ["ls", "-g", PACKAGE_NAME]);
49
+ if (ownedByNpm !== undefined) {
50
+ return undefined;
51
+ }
52
+ if (isHomebrewPath(bin) || isHomebrewPath(prefix)) {
53
+ return { label: "Homebrew", command: "brew upgrade op-cli" };
54
+ }
55
+ return undefined;
56
+ }
57
+ export function registerUpdateCommand(parent, runtime) {
58
+ parent.action(async () => {
59
+ const managed = await detectManagingInstall(runtime);
60
+ if (managed !== undefined) {
61
+ runtime.write(`${managed.label} manages this install. Run instead:\n ${managed.command}\n`);
62
+ return;
63
+ }
64
+ if (runtime.runExternal === undefined) {
65
+ throw new OpCliError("INTERNAL_ERROR");
66
+ }
67
+ const code = await runtime.runExternal("npm", ["install", "-g", PACKAGE_SPEC]);
68
+ if (code !== 0) {
69
+ runtime.writeErr(`The installer exited with code ${code}.\n`);
70
+ runtime.setExitCode(code);
71
+ }
72
+ });
73
+ }
74
+ function noticeCachePath(env) {
75
+ const directory = configDirectory(env);
76
+ return directory === undefined ? undefined : join(directory, "update-check.json");
77
+ }
78
+ async function readNoticeCache(env) {
79
+ const path = noticeCachePath(env);
80
+ if (path === undefined) {
81
+ return undefined;
82
+ }
83
+ try {
84
+ const parsed = JSON.parse(await readFile(path, "utf8"));
85
+ return typeof parsed.checkedAt === "number" && typeof parsed.latest === "string"
86
+ ? { checkedAt: parsed.checkedAt, latest: parsed.latest }
87
+ : undefined;
88
+ }
89
+ catch {
90
+ return undefined;
91
+ }
92
+ }
93
+ async function writeNoticeCache(env, cache) {
94
+ const path = noticeCachePath(env);
95
+ if (path === undefined) {
96
+ return;
97
+ }
98
+ try {
99
+ await mkdir(dirname(path), { recursive: true });
100
+ await writeFile(path, `${JSON.stringify(cache, null, 2)}\n`);
101
+ }
102
+ catch {
103
+ // A read-only config directory must never fail the command over a hint.
104
+ }
105
+ }
106
+ async function fetchLatestRelease() {
107
+ try {
108
+ const response = await fetch(REGISTRY_URL, {
109
+ headers: { accept: "application/json" },
110
+ signal: AbortSignal.timeout(NOTICE_TIMEOUT_MS),
111
+ });
112
+ if (!response.ok) {
113
+ return undefined;
114
+ }
115
+ const body = await response.json();
116
+ return typeof body.version === "string" ? body.version : undefined;
117
+ }
118
+ catch {
119
+ // Unreachable registry means no notice, not an error.
120
+ return undefined;
121
+ }
122
+ }
123
+ function releaseSegments(version) {
124
+ const core = version.split("+")[0]?.split("-")[0];
125
+ const parts = core?.split(".") ?? [];
126
+ const segments = [];
127
+ for (const part of parts) {
128
+ if (!/^\d+$/.test(part)) {
129
+ return undefined;
130
+ }
131
+ segments.push(Number(part));
132
+ }
133
+ return segments.length > 0 ? segments : undefined;
134
+ }
135
+ // Only a numerically newer release is news; anything else (older, equal,
136
+ // or unparsable) stays quiet so the notice never cries wolf.
137
+ function isNewerRelease(latest, running) {
138
+ const candidate = releaseSegments(latest);
139
+ const current = releaseSegments(running);
140
+ if (candidate === undefined || current === undefined) {
141
+ return false;
142
+ }
143
+ const depth = Math.max(candidate.length, current.length);
144
+ for (let index = 0; index < depth; index += 1) {
145
+ const left = candidate[index] ?? 0;
146
+ const right = current[index] ?? 0;
147
+ if (left !== right) {
148
+ return left > right;
149
+ }
150
+ }
151
+ return false;
152
+ }
153
+ async function resolveNoticeLine(env) {
154
+ const running = await cliVersion();
155
+ if (running === "unknown") {
156
+ return undefined;
157
+ }
158
+ const now = Date.now();
159
+ let latest;
160
+ const cached = await readNoticeCache(env);
161
+ if (cached !== undefined && now - cached.checkedAt < NOTICE_TTL_MS) {
162
+ latest = cached.latest;
163
+ }
164
+ else {
165
+ latest = await fetchLatestRelease();
166
+ if (latest !== undefined) {
167
+ // Only a successful lookup refreshes the stamp: an unreachable
168
+ // registry retries on the next command instead of waiting a day.
169
+ await writeNoticeCache(env, { checkedAt: now, latest });
170
+ }
171
+ }
172
+ if (latest === undefined || !isNewerRelease(latest, running)) {
173
+ return undefined;
174
+ }
175
+ return `op-cli ${latest} available (running ${running}); run op-cli update\n`;
176
+ }
177
+ // Commands whose own story already answers "which version": the notice
178
+ // would only repeat them, and --version must stay offline by contract.
179
+ const NOTICE_EXEMPT = ["update", "--version", "--help", "-h"];
180
+ /**
181
+ * Starts the new-version notice and resolves with its single stderr line,
182
+ * or undefined when now is not the time for one. Gating is the contract
183
+ * from issue #38: scripted use (stderr not a TTY) never pays for a lookup,
184
+ * OP_CLI_NO_UPDATE_CHECK=1 opts out entirely, and every failure inside
185
+ * stays silent. The promise never rejects.
186
+ */
187
+ export function startVersionNotice(argv, env, io) {
188
+ if (env.OP_CLI_NO_UPDATE_CHECK === "1") {
189
+ return Promise.resolve(undefined);
190
+ }
191
+ if (io.stderrIsTTY !== true) {
192
+ return Promise.resolve(undefined);
193
+ }
194
+ if (NOTICE_EXEMPT.includes(argv[0] ?? "")) {
195
+ return Promise.resolve(undefined);
196
+ }
197
+ return resolveNoticeLine(env);
198
+ }
199
+ //# sourceMappingURL=update.js.map
@@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { readStoredMetadata } from "../context/metadata.js";
4
4
  import { peekActiveProfile } from "../context/profile.js";
5
- async function cliVersion() {
5
+ export async function cliVersion() {
6
6
  try {
7
7
  const raw = await readFile(fileURLToPath(new URL("../../package.json", import.meta.url)), "utf8");
8
8
  const pkg = JSON.parse(raw);