@ze-norm/cli 0.10.0 → 0.11.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/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 { maybeSelfUpdate } from "./util/self-update.js";
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
- * Auto-upgrade the CLI in the background when a newer version is on npm.
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 workable model (used by codex, qwen-code, pi, etc.) is
6
- * apply-on-next-run: detect a newer version, spawn a *detached* `npm i -g`
7
- * that outlives this process, let the current command finish on the current
8
- * version, and the upgrade is live next invocation.
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. This is the silent path.
22
+ * to a home dir) -> spawn the background upgrade.
13
23
  * - Not writable (system/Homebrew node, or a sudo-installed global) -> never
14
- * sudo; fall back to printing the manual upgrade command.
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
- * Opt out with ZENORM_NO_AUTO_UPDATE=1. Skipped in CI and non-interactive
21
- * shells, and for non-global installs (a local dep / npx invocation — updating
22
- * someone's project dependency from under them would be wrong).
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 maybeSelfUpdate(): Promise<void>;
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":"AASA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAsB,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC,CAiDrD"}
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"}
@@ -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
- * Auto-upgrade the CLI in the background when a newer version is on npm.
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 workable model (used by codex, qwen-code, pi, etc.) is
13
- * apply-on-next-run: detect a newer version, spawn a *detached* `npm i -g`
14
- * that outlives this process, let the current command finish on the current
15
- * version, and the upgrade is live next invocation.
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. This is the silent path.
35
+ * to a home dir) -> spawn the background upgrade.
20
36
  * - Not writable (system/Homebrew node, or a sudo-installed global) -> never
21
- * sudo; fall back to printing the manual upgrade command.
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
- * Opt out with ZENORM_NO_AUTO_UPDATE=1. Skipped in CI and non-interactive
28
- * shells, and for non-global installs (a local dep / npx invocation — updating
29
- * someone's project dependency from under them would be wrong).
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 maybeSelfUpdate() {
55
+ export async function armSelfUpdate(notify) {
32
56
  try {
33
57
  if (process.env[OPT_OUT_ENV])
34
58
  return;
35
- // CI and non-TTY: don't mutate the environment behind an automated caller.
36
- if (process.env["CI"] || !process.stdout.isTTY)
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
- updateCheckInterval: 1000 * 60 * 60 * 24,
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
- printManualUpgrade(current, update.latest);
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 the user how to upgrade themselves.
61
- printManualUpgrade(current, update.latest);
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
- log.warn(`Updating ZeNorm CLI ${current} -> ${update.latest} in the background. ` +
72
- `The new version takes effect on your next command.`);
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);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ze-norm/cli",
3
3
  "private": false,
4
- "version": "0.10.0",
4
+ "version": "0.11.1",
5
5
  "license": "SEE LICENSE IN README.md",
6
6
  "type": "module",
7
7
  "repository": {