@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.
- package/LICENSE +21 -0
- package/NOTICE +2 -2
- package/README.md +20 -7
- package/dist/bin.js +18 -0
- package/dist/commands/doctor.js +5 -2
- package/dist/commands/meta.js +29 -14
- package/dist/commands/project.js +22 -87
- package/dist/commands/time.js +50 -27
- package/dist/commands/update.js +199 -0
- package/dist/commands/version.js +1 -1
- package/dist/commands/wp.js +155 -26
- package/dist/context/metadata.js +1 -1
- package/dist/context/profile.js +37 -9
- package/dist/context/projectref.js +84 -0
- package/dist/core/define.js +1 -1
- package/dist/core/filters.js +43 -13
- package/dist/io/node-io.js +23 -1
- package/dist/output/table.js +111 -6
- package/dist/run.js +56 -11
- package/package.json +2 -1
package/dist/commands/time.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
.
|
|
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:
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
package/dist/commands/version.js
CHANGED
|
@@ -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);
|