@ze-norm/cli 0.10.0 → 0.11.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.
- package/dist/index.js +9 -9
- package/dist/util/self-update.d.ts +30 -12
- package/dist/util/self-update.d.ts.map +1 -1
- package/dist/util/self-update.js +97 -24
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import { initCommand } from "./commands/init.js";
|
|
|
10
10
|
import { installSkillsCommand } from "./commands/install-skills.js";
|
|
11
11
|
import { sessionCheckCommand } from "./commands/session-check.js";
|
|
12
12
|
import { getCliVersion } from "./util/package.js";
|
|
13
|
-
import {
|
|
13
|
+
import { armSelfUpdate } from "./util/self-update.js";
|
|
14
14
|
const helpText = `zenorm - ZeNorm CLI
|
|
15
15
|
|
|
16
16
|
Usage:
|
|
@@ -57,6 +57,14 @@ async function main() {
|
|
|
57
57
|
const args = rawArgs.filter((a) => !VERBOSE_FLAGS.has(a));
|
|
58
58
|
// Only parse top-level flags (--help, --version). Don't parse subcommand flags.
|
|
59
59
|
const commandName = args.find((a) => !a.startsWith("-"));
|
|
60
|
+
// Fire-and-forget background auto-upgrade. Armed on EVERY path — including
|
|
61
|
+
// --version/--help and the non-interactive `session-check` stop hook, which
|
|
62
|
+
// is the highest-frequency call for an active customer and previously never
|
|
63
|
+
// armed an update. The actual `npm i -g` is detached and never blocks this
|
|
64
|
+
// command. The interactive notice (stderr) is suppressed for `session-check`
|
|
65
|
+
// (reserves stdout for the hook protocol) and only shown when stdout is a TTY.
|
|
66
|
+
const notifyUpdate = commandName !== "session-check" && process.stdout.isTTY === true;
|
|
67
|
+
void armSelfUpdate(notifyUpdate);
|
|
60
68
|
if (args.includes("--version") || args.includes("-v")) {
|
|
61
69
|
log.plain(`zenorm ${getCliVersion()}`);
|
|
62
70
|
return;
|
|
@@ -69,14 +77,6 @@ async function main() {
|
|
|
69
77
|
if (!handler) {
|
|
70
78
|
throw new CliError(`Unknown command: ${commandName}\n\nRun \`zenorm --help\` for usage.`);
|
|
71
79
|
}
|
|
72
|
-
// Passive "newer version on npm" nudge. Skipped for `session-check`, which
|
|
73
|
-
// speaks the Claude Code Stop-hook protocol on stdout/stderr and must emit
|
|
74
|
-
// nothing else.
|
|
75
|
-
if (commandName !== "session-check") {
|
|
76
|
-
// Fire-and-forget: auto-upgrade in the background when possible, else nudge.
|
|
77
|
-
// Never blocks or fails the command.
|
|
78
|
-
void maybeSelfUpdate();
|
|
79
|
-
}
|
|
80
80
|
// Pass everything after the command name to the handler.
|
|
81
81
|
// Routed to stderr (debugErr): `session-check` reserves stdout for the
|
|
82
82
|
// Claude Code hook protocol, and this dispatch line runs for every command.
|
|
@@ -1,25 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Arm the background CLI auto-upgrade. Silent and safe on EVERY invocation
|
|
3
|
+
* path — including non-interactive ones (the Claude Code `session-check` stop
|
|
4
|
+
* hook), which is the highest-frequency call for an active customer and the
|
|
5
|
+
* path that previously never armed an update.
|
|
3
6
|
*
|
|
4
7
|
* Distribution is `npm install -g`, so there is no safe way to hot-swap the
|
|
5
|
-
* running process. The
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
8
|
+
* running process. The model is apply-on-next-run: detect a newer version,
|
|
9
|
+
* spawn a *detached* `npm i -g` that outlives this process, let the current
|
|
10
|
+
* command finish on the current version, and the upgrade is live next
|
|
11
|
+
* invocation. npm `-g` cannot hot-swap a running process — that one-run floor
|
|
12
|
+
* is irreducible regardless of mechanism.
|
|
13
|
+
*
|
|
14
|
+
* `updateCheckInterval: 0` makes update-notifier refresh its cached registry
|
|
15
|
+
* result on every call, so a publish is detected within one or two
|
|
16
|
+
* invocations rather than waiting out a daily cache. For an active user whose
|
|
17
|
+
* stop hook fires many times an hour, that lands a new version within
|
|
18
|
+
* minutes-to-an-hour.
|
|
9
19
|
*
|
|
10
20
|
* The load-bearing constraint is whether the npm global prefix is writable:
|
|
11
21
|
* - Writable (user-owned prefix: nvm/fnm/volta, or `npm config set prefix`
|
|
12
|
-
* to a home dir) -> spawn the background upgrade.
|
|
22
|
+
* to a home dir) -> spawn the background upgrade.
|
|
13
23
|
* - Not writable (system/Homebrew node, or a sudo-installed global) -> never
|
|
14
|
-
* sudo;
|
|
24
|
+
* sudo; surface a manual-upgrade nudge to interactive callers instead.
|
|
25
|
+
*
|
|
26
|
+
* A single-flight marker (keyed to the target version, with a staleness TTL)
|
|
27
|
+
* prevents the high-frequency paths from spawning concurrent `npm i -g`
|
|
28
|
+
* processes in the window between detecting an update and the install
|
|
29
|
+
* finishing.
|
|
15
30
|
*
|
|
16
31
|
* All of this is best-effort and never blocks or fails a real command. Every
|
|
17
32
|
* dependency is lazy-imported so a missing optional dep can't crash startup
|
|
18
|
-
* (the published package ships only `dist`).
|
|
33
|
+
* (the published package ships only `dist`). Opt out with
|
|
34
|
+
* ZENORM_NO_AUTO_UPDATE=1; skipped in CI and for non-global installs.
|
|
19
35
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
36
|
+
* @param notify when true (interactive, non-`session-check` commands), emit a
|
|
37
|
+
* one-line stderr notice about the in-progress upgrade or the manual command
|
|
38
|
+
* for non-writable prefixes. When false, stay completely silent — required
|
|
39
|
+
* for the stop-hook path, which reserves stdout for the hook protocol
|
|
40
|
+
* (notices go to stderr, but silence keeps the high-frequency path clean).
|
|
23
41
|
*/
|
|
24
|
-
export declare function
|
|
42
|
+
export declare function armSelfUpdate(notify: boolean): Promise<void>;
|
|
25
43
|
//# sourceMappingURL=self-update.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"self-update.d.ts","sourceRoot":"","sources":["../../src/util/self-update.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"self-update.d.ts","sourceRoot":"","sources":["../../src/util/self-update.ts"],"names":[],"mappings":"AAgBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,wBAAsB,aAAa,CAAC,MAAM,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CA+DlE"}
|
package/dist/util/self-update.js
CHANGED
|
@@ -1,64 +1,99 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { accessSync, constants } from "node:fs";
|
|
2
|
+
import { accessSync, constants, statSync, utimesSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
3
5
|
import { log } from "./logger.js";
|
|
4
6
|
import { getCliVersion } from "./package.js";
|
|
5
7
|
const PACKAGE_NAME = "@ze-norm/cli";
|
|
6
8
|
const UPGRADE_COMMAND = `npm install -g ${PACKAGE_NAME}@latest`;
|
|
7
9
|
const OPT_OUT_ENV = "ZENORM_NO_AUTO_UPDATE";
|
|
10
|
+
// How long a single-flight marker is honored before we assume the prior
|
|
11
|
+
// background install crashed/stalled and allow a fresh attempt. Bounds the
|
|
12
|
+
// blast radius of a wedged install without permanently blocking updates.
|
|
13
|
+
const SINGLE_FLIGHT_TTL_MS = 10 * 60 * 1000;
|
|
8
14
|
/**
|
|
9
|
-
*
|
|
15
|
+
* Arm the background CLI auto-upgrade. Silent and safe on EVERY invocation
|
|
16
|
+
* path — including non-interactive ones (the Claude Code `session-check` stop
|
|
17
|
+
* hook), which is the highest-frequency call for an active customer and the
|
|
18
|
+
* path that previously never armed an update.
|
|
10
19
|
*
|
|
11
20
|
* Distribution is `npm install -g`, so there is no safe way to hot-swap the
|
|
12
|
-
* running process. The
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
21
|
+
* running process. The model is apply-on-next-run: detect a newer version,
|
|
22
|
+
* spawn a *detached* `npm i -g` that outlives this process, let the current
|
|
23
|
+
* command finish on the current version, and the upgrade is live next
|
|
24
|
+
* invocation. npm `-g` cannot hot-swap a running process — that one-run floor
|
|
25
|
+
* is irreducible regardless of mechanism.
|
|
26
|
+
*
|
|
27
|
+
* `updateCheckInterval: 0` makes update-notifier refresh its cached registry
|
|
28
|
+
* result on every call, so a publish is detected within one or two
|
|
29
|
+
* invocations rather than waiting out a daily cache. For an active user whose
|
|
30
|
+
* stop hook fires many times an hour, that lands a new version within
|
|
31
|
+
* minutes-to-an-hour.
|
|
16
32
|
*
|
|
17
33
|
* The load-bearing constraint is whether the npm global prefix is writable:
|
|
18
34
|
* - Writable (user-owned prefix: nvm/fnm/volta, or `npm config set prefix`
|
|
19
|
-
* to a home dir) -> spawn the background upgrade.
|
|
35
|
+
* to a home dir) -> spawn the background upgrade.
|
|
20
36
|
* - Not writable (system/Homebrew node, or a sudo-installed global) -> never
|
|
21
|
-
* sudo;
|
|
37
|
+
* sudo; surface a manual-upgrade nudge to interactive callers instead.
|
|
38
|
+
*
|
|
39
|
+
* A single-flight marker (keyed to the target version, with a staleness TTL)
|
|
40
|
+
* prevents the high-frequency paths from spawning concurrent `npm i -g`
|
|
41
|
+
* processes in the window between detecting an update and the install
|
|
42
|
+
* finishing.
|
|
22
43
|
*
|
|
23
44
|
* All of this is best-effort and never blocks or fails a real command. Every
|
|
24
45
|
* dependency is lazy-imported so a missing optional dep can't crash startup
|
|
25
|
-
* (the published package ships only `dist`).
|
|
46
|
+
* (the published package ships only `dist`). Opt out with
|
|
47
|
+
* ZENORM_NO_AUTO_UPDATE=1; skipped in CI and for non-global installs.
|
|
26
48
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
49
|
+
* @param notify when true (interactive, non-`session-check` commands), emit a
|
|
50
|
+
* one-line stderr notice about the in-progress upgrade or the manual command
|
|
51
|
+
* for non-writable prefixes. When false, stay completely silent — required
|
|
52
|
+
* for the stop-hook path, which reserves stdout for the hook protocol
|
|
53
|
+
* (notices go to stderr, but silence keeps the high-frequency path clean).
|
|
30
54
|
*/
|
|
31
|
-
export async function
|
|
55
|
+
export async function armSelfUpdate(notify) {
|
|
32
56
|
try {
|
|
33
57
|
if (process.env[OPT_OUT_ENV])
|
|
34
58
|
return;
|
|
35
|
-
// CI
|
|
36
|
-
|
|
59
|
+
// CI: don't mutate the environment behind an automated caller. (No isTTY
|
|
60
|
+
// gate here — the stop-hook path is non-TTY and MUST still arm.)
|
|
61
|
+
if (process.env["CI"])
|
|
37
62
|
return;
|
|
38
63
|
const current = getCliVersion();
|
|
39
64
|
const { default: updateNotifier } = await import("update-notifier");
|
|
40
65
|
const notifier = updateNotifier({
|
|
41
66
|
pkg: { name: PACKAGE_NAME, version: current },
|
|
42
|
-
|
|
67
|
+
// Refresh the cached check on every call so a fresh publish is seen
|
|
68
|
+
// within one invocation instead of waiting out a daily interval.
|
|
69
|
+
updateCheckInterval: 0,
|
|
43
70
|
});
|
|
44
|
-
// `update` is populated from update-notifier's cached daily check; null
|
|
45
|
-
// when no newer version is known yet. Reusing it avoids a second registry
|
|
46
|
-
// round-trip.
|
|
47
71
|
const update = notifier.update;
|
|
48
72
|
if (!update || update.latest === current)
|
|
49
73
|
return;
|
|
50
74
|
const { default: isInstalledGlobally } = await import("is-installed-globally");
|
|
51
75
|
if (!isInstalledGlobally) {
|
|
52
76
|
// Local dep or npx: just nudge, never touch it.
|
|
53
|
-
|
|
77
|
+
if (notify)
|
|
78
|
+
printManualUpgrade(current, update.latest);
|
|
54
79
|
return;
|
|
55
80
|
}
|
|
56
81
|
const { default: globalDirectory } = await import("global-directory");
|
|
57
82
|
const prefix = globalDirectory.npm.prefix;
|
|
58
83
|
if (!prefix || !isWritable(prefix)) {
|
|
59
84
|
// System/Homebrew/sudo install — can't write without root, and we never
|
|
60
|
-
// sudo. Tell
|
|
61
|
-
|
|
85
|
+
// sudo. Tell interactive callers how to upgrade themselves.
|
|
86
|
+
if (notify)
|
|
87
|
+
printManualUpgrade(current, update.latest);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
// Single-flight: if another invocation already started installing this
|
|
91
|
+
// target version recently, don't spawn a duplicate `npm i -g`.
|
|
92
|
+
if (!claimSingleFlight(update.latest)) {
|
|
93
|
+
if (notify) {
|
|
94
|
+
log.warn(`ZeNorm CLI ${update.latest} is installing in the background. ` +
|
|
95
|
+
`It takes effect on your next command.`);
|
|
96
|
+
}
|
|
62
97
|
return;
|
|
63
98
|
}
|
|
64
99
|
// Detached, non-blocking. Outlives this process; the upgrade lands for the
|
|
@@ -68,13 +103,51 @@ export async function maybeSelfUpdate() {
|
|
|
68
103
|
stdio: "ignore",
|
|
69
104
|
});
|
|
70
105
|
child.unref();
|
|
71
|
-
|
|
72
|
-
`
|
|
106
|
+
if (notify) {
|
|
107
|
+
log.warn(`Updating ZeNorm CLI ${current} -> ${update.latest} in the background. ` +
|
|
108
|
+
`The new version takes effect on your next command.`);
|
|
109
|
+
}
|
|
73
110
|
}
|
|
74
111
|
catch {
|
|
75
112
|
// Never break a real command on an update attempt.
|
|
76
113
|
}
|
|
77
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Claim the single-flight slot for installing `targetVersion`. Returns true if
|
|
117
|
+
* this process should spawn the install, false if a recent install of the same
|
|
118
|
+
* target is already in flight.
|
|
119
|
+
*
|
|
120
|
+
* Implemented as a per-target marker file in the OS temp dir. A marker older
|
|
121
|
+
* than SINGLE_FLIGHT_TTL_MS is treated as stale (a crashed/stalled prior
|
|
122
|
+
* attempt) and reclaimed, so a failed install can't permanently wedge updates.
|
|
123
|
+
*/
|
|
124
|
+
function claimSingleFlight(targetVersion) {
|
|
125
|
+
// Sanitize the version into a safe filename component (registry versions are
|
|
126
|
+
// semver, but defend against anything odd from the registry response).
|
|
127
|
+
const safe = targetVersion.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
128
|
+
const marker = join(tmpdir(), `zenorm-cli-update-${safe}.lock`);
|
|
129
|
+
try {
|
|
130
|
+
const age = Date.now() - statSync(marker).mtimeMs;
|
|
131
|
+
if (age < SINGLE_FLIGHT_TTL_MS) {
|
|
132
|
+
return false; // fresh marker: an install is already in flight.
|
|
133
|
+
}
|
|
134
|
+
// Stale marker: refresh its mtime to re-claim and proceed.
|
|
135
|
+
utimesSync(marker, new Date(), new Date());
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// No marker yet (or stat failed): create it and claim.
|
|
140
|
+
try {
|
|
141
|
+
writeFileSync(marker, String(Date.now()));
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// Can't write a marker — fail open and allow the install rather than
|
|
146
|
+
// blocking updates on a temp-dir hiccup.
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
78
151
|
function isWritable(dir) {
|
|
79
152
|
try {
|
|
80
153
|
accessSync(dir, constants.W_OK);
|