@litfamily/litclaude 1.0.13 → 1.0.15

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.
@@ -5,6 +5,7 @@ import { compareStableSemver } from "./update-notifier.mjs";
5
5
 
6
6
  const stableVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u;
7
7
  const receiptName = ".litclaude-install-receipt.json";
8
+ const hostUseMarker = ".in_use";
8
9
  // Exact pristine trees installed from the registry-published litclaude-ai@0.4.6 tarball.
9
10
  const legacyVersion = "0.4.6";
10
11
  const legacy046 = {
@@ -44,6 +45,7 @@ function treeHash(root) {
44
45
  for (const name of readdirSync(path).sort()) {
45
46
  if (!prefix && name === receiptName) continue;
46
47
  const next = join(path, name), rel = prefix ? `${prefix}/${name}` : name, value = lstatSync(next);
48
+ if (!prefix && name === hostUseMarker && value.isDirectory() && readdirSync(next).length === 0) continue;
47
49
  if (value.isDirectory()) { entries.push([rel, "directory"]); walk(next, rel); }
48
50
  else if (value.isFile() && value.nlink === 1) entries.push([rel, "file", createHash("sha256").update(readFileSync(next)).digest("hex")]);
49
51
  else conflict(next);
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.13"><title>release: 1.0.13</title><g shape-rendering="crispEdges"><rect width="49" height="20" fill="#080d14"/><rect x="49" width="97" height="20" fill="#ff6337"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="255" y="140" textLength="390" transform="scale(.1)">release</text><text x="965" y="140" textLength="870" transform="scale(.1)" fill="#080D14">1.0.13</text></g></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.15"><title>release: 1.0.15</title><g shape-rendering="crispEdges"><rect width="49" height="20" fill="#080d14"/><rect x="49" width="97" height="20" fill="#ff6337"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="255" y="140" textLength="390" transform="scale(.1)">release</text><text x="965" y="140" textLength="870" transform="scale(.1)" fill="#080D14">1.0.15</text></g></svg>
package/docs/hooks.md CHANGED
@@ -244,19 +244,22 @@ would be without the feature. The first such failure in a session shows one `sys
244
244
  Per-session counters live in `.litclaude/jev/session-<id>.json` under the project state
245
245
  root, with the current turn's hinted skill ID and latency in milliseconds; the prompt hook
246
246
  clears that pair at the start of every turn while the flag is on. The optional HUD status line
247
- (`plugins/litclaude/bin/litclaude-hud.js`) reads it and ends with `Jev ✓` while the hint is
248
- on, `Jev ✓ <skill> <latency>` on a hinted turn, or `Jev ⚠ key missing` when the flag is on
249
- without a key; it shows nothing while the flag is off and never shows the key or its length.
250
- While on, the HUD paints `Jev` as a rainbow whose starting hue follows the clock, so each refresh
251
- shifts it slightly (truecolor, 256- and 16-colour tiers; plain under `NO_COLOR`, the HUD's
252
- no-colour switch, or a light or unknown appearance). The session file also records that the
247
+ (`plugins/litclaude/bin/litclaude-hud.js`) reads it and puts a badge right after the model
248
+ label: `✦Jev` while the hint is on, `✦Jev → <skill>` on a hinted turn, or `✦Jev ⚠ key` when
249
+ the flag is on without a key; it shows nothing while the flag is off, never shows the latency,
250
+ and never shows the key or its length. While on, the HUD paints `✦` gold, the hinted skill green
251
+ and the missing-key badge amber, and paints `Jev` as a rainbow whose starting hue follows the
252
+ clock, so each refresh shifts it slightly (truecolor, 256- and 16-colour tiers; plain under
253
+ `NO_COLOR` or the HUD's no-colour switch; a light or unknown appearance keeps the words on the
254
+ default foreground and colours only the marks). The session file also records that the
253
255
  once-per-session `✦ Jev skill hint ON ✦` `systemMessage` was shown; the prompt hook writes that
254
256
  mark before it emits the line on the first turn with the flag and key set, puts the line ahead
255
257
  of any other visible message that turn, and shows no line when the mark cannot be written. The opt-in trace, `.litclaude/jev/trace.jsonl`, records the timestamp, the SHA-256 of the
256
258
  redacted `state` (never of the raw prompt), chosen ID, confidence, latency, HTTP status, and fallback reason; it holds no prompt text,
257
259
  key, or response body. Session and trace files are opened with `O_NOFOLLOW` (an `lstat` check
258
260
  where the platform lacks it), so a symlink planted at either path is refused rather than written
259
- through. Because `TYPESAFE_API_KEY` is exported in the shell that starts Claude Code, the
261
+ through. The `.litclaude` and `.litclaude/jev` folders must be real folders too: when either one
262
+ is a symlink, the hint reads and writes nothing there, sends no request, and shows no hint or note. Because `TYPESAFE_API_KEY` is exported in the shell that starts Claude Code, the
260
263
  agent's own tools can read it too; use a key dedicated to this feature, with a low spend limit.
261
264
  `litclaude doctor` prints `Jev skill hint: off`, `Jev skill hint: on`,
262
265
  or `Jev skill hint: flag on but TYPESAFE_API_KEY missing`. The offline regression tests,
package/docs/privacy.md CHANGED
@@ -43,8 +43,8 @@ ledgers, saved knowledge, and other feature-specific records. SessionStart
43
43
  context-pressure detection can inspect a supplied transcript path up to 256 KiB
44
44
  for a fixed set of pressure markers; it returns only a bounded advisory. No skill
45
45
  review reads transcript excerpts or sends them to a model. The optional Jev skill hint keeps
46
- per-session request counts, the current turn's hinted skill ID and latency (shown by the
47
- HUD), and whether the once-per-session "on" line was shown in `.litclaude/jev/` and, only with `LITCLAUDE_JEV_TRACE=1`, a trace
46
+ per-session request counts, the current turn's hinted skill ID (shown by the HUD) and
47
+ latency, and whether the once-per-session "on" line was shown in `.litclaude/jev/` and, only with `LITCLAUDE_JEV_TRACE=1`, a trace
48
48
  of hashes of the redacted prompt, timings, and status codes without prompt text. Existing
49
49
  `pending-review.json` and `skill-loop-state.json` files are inert after the
50
50
  automatic review feature's removal and may be deleted. No other state is affected.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litfamily/litclaude",
3
- "version": "1.0.13",
3
+ "version": "1.0.15",
4
4
  "description": "Claude Code-native workflow distribution.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -53,6 +53,9 @@
53
53
  ],
54
54
  "scripts": {
55
55
  "prepublishOnly": "node scripts/prepublish-test-gate.mjs",
56
+ "prepack": "node tools/readme-for-npm.mjs apply",
57
+ "postpack": "node tools/readme-for-npm.mjs restore",
58
+ "check:npm-readme": "node tools/readme-for-npm.mjs check",
56
59
  "postinstall": "node scripts/postinstall.mjs",
57
60
  "test": "node --test --test-concurrency=1 --import ./test/helpers/motion-offline-prewarm.mjs test/*.test.mjs",
58
61
  "validate:plugin": "node scripts/validate-plugin.mjs",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "litclaude",
3
3
  "description": "Claude Code-native workflow plugin with a bounded-authority start-work lifecycle.",
4
- "version": "1.0.13",
4
+ "version": "1.0.15",
5
5
  "author": {
6
6
  "name": "LitClaude contributors"
7
7
  },
@@ -11,10 +11,11 @@ import {
11
11
  litBrandPrefix,
12
12
  normalizeHudAppearance,
13
13
  rainbowText,
14
+ toneText,
14
15
  } from "../lib/hud-accent-palette.mjs";
15
16
  import { latestUsageTokens } from "../lib/cache-measurement.mjs";
16
17
  import { readIgnitionState, renderIgnitionSegment } from "../lib/hud-ignition.mjs";
17
- import { formatJevLatency, jevHudState } from "../lib/jev-skill-hint.mjs";
18
+ import { jevHudState } from "../lib/jev-skill-hint.mjs";
18
19
  import { resolveProjectStateRoot } from "../lib/project-state-root.mjs";
19
20
 
20
21
  const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -246,8 +247,14 @@ const formatUsageSegment = (label, value, reset) => {
246
247
  return `${labelText} ${accent("[")}${makeBlockBar(pct, 2)}${accent("]")} ${pctText}${resetText ? ` ${text(resetText)}` : ""}`;
247
248
  };
248
249
 
249
- // Optional Jev skill hint: nothing while LITCLAUDE_JEV is off, and never the key or its length.
250
- const jevSegment = (status) => {
250
+ // Optional Jev skill hint badge, attached to the model label: nothing while LITCLAUDE_JEV is
251
+ // off, and never the key or its length.
252
+ const JEV_GOLD = [255, 215, 90];
253
+ const JEV_GREEN = [110, 235, 120];
254
+ const JEV_AMBER = [255, 175, 60];
255
+ const tone = (value, rgb, ansi16) => toneText(value, rgb, { depth: colorDepth, ansi16 });
256
+
257
+ const jevBadge = (status) => {
251
258
  let state;
252
259
  try {
253
260
  const cwd = typeof status.cwd === "string" && status.cwd ? status.cwd : null;
@@ -260,14 +267,19 @@ const jevSegment = (status) => {
260
267
  return "";
261
268
  }
262
269
  if (!state) return "";
263
- if (state.keyMissing) return `${text("Jev")} ${colors.yellow}⚠${colors.reset} ${text("key missing")}`;
270
+ // Light and unknown appearances keep the words on the default foreground; only the marks
271
+ // keep their colour, as the rest of the HUD does.
272
+ if (state.keyMissing) {
273
+ return coloredText ? tone("✦Jev ⚠ key", JEV_AMBER, 33) : `${tone("✦", JEV_AMBER, 33)}Jev ${tone("⚠", JEV_AMBER, 33)} key`;
274
+ }
264
275
  // While on, the word shimmers: each refresh starts the rainbow at a hue taken from the clock.
265
- // Light and unknown appearances keep it on the default foreground like the rest of the text.
266
276
  const now = Number(process.env.LITCLAUDE_HUD_TEST_NOW);
267
277
  const phase = Math.floor((Number.isFinite(now) && now > 0 ? now : Date.now()) / 100) % 360;
268
- const word = coloredText ? rainbowText("Jev", { depth: colorDepth, phase }) : text("Jev");
269
- const mark = `${word} ${colors.green}✓${colors.reset}`;
270
- return state.last ? `${mark} ${text(state.last.skill)} ${text(formatJevLatency(state.last.latency_ms))}` : mark;
278
+ const word = coloredText ? rainbowText("Jev", { depth: colorDepth, phase }) : "Jev";
279
+ const badge = `${tone("✦", JEV_GOLD, 93)}${word}`;
280
+ if (!state.last) return badge;
281
+ const skill = coloredText ? tone(state.last.skill, JEV_GREEN, 92) : state.last.skill;
282
+ return `${badge} ${colors.dim}→${colors.reset} ${skill}`;
271
283
  };
272
284
 
273
285
  const main = async () => {
@@ -294,8 +306,8 @@ const main = async () => {
294
306
  const ignition = renderIgnitionSegment(readIgnitionState(status.session_id), {
295
307
  depth: plainOutput ? "plain" : colorDepth,
296
308
  });
297
- const jev = jevSegment(status);
298
- const line = `${prefix}${ignition ? ` ${ignition}` : ""} ${accent("|")} ${text(model)} ${accent(sep)} ${contextText}${usageText}${git ? ` ${accent(sep)} ${text("git")} ${text(git)}` : ""}${jev ? ` ${accent(sep)} ${jev}` : ""}`;
309
+ const jev = jevBadge(status);
310
+ const line = `${prefix}${ignition ? ` ${ignition}` : ""} ${accent("|")} ${text(model)}${jev ? ` ${jev}` : ""} ${accent(sep)} ${contextText}${usageText}${git ? ` ${accent(sep)} ${text("git")} ${text(git)}` : ""}`;
299
311
  process.stdout.write(`${line}\n`);
300
312
 
301
313
  const lastMessage = latestUserMessage(status.transcript_path);
@@ -9,7 +9,7 @@ import {
9
9
  } from "../lib/wikify-knowledge.mjs";
10
10
 
11
11
  const protocolVersion = "2024-11-05";
12
- const serverVersion = "1.0.13";
12
+ const serverVersion = "1.0.15";
13
13
 
14
14
  const publicSourceReadTool = {
15
15
  name: "public_source_read",
@@ -14,7 +14,7 @@ export const canonicalSkillResourceManifest = Object.freeze(
14
14
  ["lib/office-runtime-lock/package-lock.json", "4b9552dba69bf2803cecdf71996d26de0497004c00d1e1f5753a5b299f85d8ba"],
15
15
  ["lib/office-runtime-lock/package.json", "edf7fee2ba78d6ad42bf5761678b2a2210adb7282108864360f83c46adb59708"],
16
16
  ["lib/office-runtime-lock/requirements.lock", "7c63ce0de1ae9a912d1e546e9c01dd99a705d4a50435f36040d351cc3e6e6014"],
17
- ["lib/office-runtime.mjs", "463248cf341d0115da5d49c6d30fcb16e2d6e118f4d1e9d739844d6ff0c8cbdc"],
17
+ ["lib/office-runtime.mjs", "268e8ca9bb6a08dcb43ea2b40c899e776db850adfcb284a89d315d3c1a535438"],
18
18
  ["lib/office_data.py", "0f4c2449622b6d59630e60508594d181279052c41918df4370e11a68df1ee239"],
19
19
  ["lib/office_runtime_bootstrap.py", "28812ef485f73f413c6d08cfff326290b301a0d7358260a2a0496edd8f71f4e9"],
20
20
  ["lib/ooxml_integrity.py", "85e5cba28aa3b9f4f4c28f59c6118439a985fe4f29e25274ae63e9ef1edf15e3"],
@@ -32,7 +32,7 @@ export const canonicalSkillResourceManifest = Object.freeze(
32
32
  ["lib/rules/ordering.mjs", "c6da1ef0b228b8f0932b54422d2bc9744eb6bfbb8f5bb70b30b8f1a798765d1c"],
33
33
  ["lib/rules/scanner.mjs", "c62d672a66af25f29fe1d05b09288583425a2bf55f20d788f81d1578035f79f9"],
34
34
  ["lib/rules/session-state.mjs", "ad660cadf147b102437da95a771bae19ee2140bf39c58915b8cb498e8d04f2af"],
35
- ["lib/secret-shapes.mjs", "73733bb5927d6d1e00c529acc847b79d8664d10f94100bcc7452824615ab87ba"],
35
+ ["lib/secret-shapes.mjs", "7dc58f1caa6d5fc9dc43797a703911e1ec26689a082bb6972f6ed71722a01d57"],
36
36
  ["lib/secure-path-read.mjs", "c2991d0279f7399c3022c78babe28e653260f2a2e9ff4a5df692883ccf8807fd"],
37
37
  ["lib/strict-json.mjs", "d941f327aabce5ffc212770852cfbc7e08dd03471e4e9bc9560eff54f06d489a"],
38
38
  ["lib/wikify-knowledge-cli.mjs", "6835f8d6d98a3dd26266409366c23b2da437c0cd41e63537f0f200667655132b"],
@@ -699,7 +699,7 @@ export const canonicalSkillResourceManifest = Object.freeze(
699
699
  ["skills/lit-typographic-motion/engine/node/pipeline.mjs", "fef77c5190c604fbdf360bd1d38d750587562ca0974a8b1aacccb6ad57411206"],
700
700
  ["skills/lit-typographic-motion/engine/node/png.mjs", "fa34ab36a17f614064d8f00e3540c3bd13b06fc61036981d44fe57ba8ec86d4a"],
701
701
  ["skills/lit-typographic-motion/engine/node/report.mjs", "6de1d1a1e81963a5d97da4c71f4d22a379d145ac3d3fb5aa7a7ba6b044811329"],
702
- ["skills/lit-typographic-motion/engine/node/runtime.mjs", "47a585b4b8762474ae1db30dcd8b25b3093da6da0f05ff0dd20643ae669a3a27"],
702
+ ["skills/lit-typographic-motion/engine/node/runtime.mjs", "e246f0b00b6859ee6793faf3927740d8d04e8c94f62b091c81e121ce6aa8b707"],
703
703
  ["skills/lit-typographic-motion/engine/node/session.mjs", "e013dfd801ba8503038233efd4625d78f4e48331564612eecb2034e9062c414f"],
704
704
  ["skills/lit-typographic-motion/engine/node/sound-cli.mjs", "e10cf22b8ec9d761e3e6e9279330520ed33b1b2b9e68fd46d7672ac8e98b5eae"],
705
705
  ["skills/lit-typographic-motion/engine/node/sound.mjs", "7b585b47f36c58cf4959310e59363fb321e04586193430b3ad09a9efd5bc9bd3"],
@@ -129,6 +129,21 @@ const hueToRgb = (hue) => {
129
129
  return [r, g, b].map((channel) => Math.round(255 * (1 - s + s * channel)));
130
130
  };
131
131
 
132
+ // Nearest 6x6x6 cube entry of the 256-colour palette.
133
+ const rgbTo256 = ([r, g, b]) => {
134
+ const cube = (value) => Math.round((value / 255) * 5);
135
+ return 16 + 36 * cube(r) + 6 * cube(g) + cube(b);
136
+ };
137
+
138
+ // One fixed colour for `text`: the exact RGB on truecolor, its cube entry on 256 colours, the
139
+ // given ANSI code on 16 colours, and the text untouched on plain.
140
+ export const toneText = (text, rgb, { depth = "plain", ansi16 = 37 } = {}) => {
141
+ if (depth === "truecolor") return `\x1b[38;2;${rgb.join(";")}m${text}\x1b[0m`;
142
+ if (depth === "256") return `\x1b[38;5;${rgbTo256(rgb)}m${text}\x1b[0m`;
143
+ if (depth === "16") return `\x1b[${ansi16}m${text}\x1b[0m`;
144
+ return text;
145
+ };
146
+
132
147
  export const rainbowText = (text, { depth = "plain", phase = 0 } = {}) => {
133
148
  if (!["truecolor", "256", "16"].includes(depth)) return text;
134
149
  const chars = [...text];
@@ -138,10 +153,7 @@ export const rainbowText = (text, { depth = "plain", phase = 0 } = {}) => {
138
153
  const hue = (((phase + index * step) % 360) + 360) % 360;
139
154
  if (depth === "16") return `\x1b[${RAINBOW_ANSI16[Math.floor(((hue + 30) % 360) / 60)]}m${char}`;
140
155
  const [r, g, b] = hueToRgb(hue);
141
- if (depth === "256") {
142
- const cube = (value) => Math.round((value / 255) * 5);
143
- return `\x1b[38;5;${16 + 36 * cube(r) + 6 * cube(g) + cube(b)}m${char}`;
144
- }
156
+ if (depth === "256") return `\x1b[38;5;${rgbTo256([r, g, b])}m${char}`;
145
157
  return `\x1b[38;2;${r};${g};${b}m${char}`;
146
158
  });
147
159
  return `\x1b[1m${painted.join("")}\x1b[0m`;
@@ -232,7 +232,29 @@ const safeSessionId = (sessionId) => {
232
232
  return cleaned || "unknown";
233
233
  };
234
234
 
235
- const jevStateDirectory = (stateRoot) => join(stateRoot, ".litclaude", "jev");
235
+ // The `.litclaude` folder and its `jev` folder must both be real folders inside the project. A
236
+ // symlink at either level would carry every read and write outside it, so the whole state path is
237
+ // refused and the caller falls back to its silent no-hint path. `create` makes missing levels.
238
+ const jevStateDirectory = (stateRoot, { create = false } = {}) => {
239
+ let directory = stateRoot;
240
+ for (const part of [".litclaude", "jev"]) {
241
+ directory = join(directory, part);
242
+ let stat;
243
+ try {
244
+ stat = lstatSync(directory);
245
+ } catch (error) {
246
+ if (error?.code !== "ENOENT" || !create) throw error;
247
+ try {
248
+ mkdirSync(directory, { mode: 0o700 });
249
+ } catch (mkdirError) {
250
+ if (mkdirError?.code !== "EEXIST") throw mkdirError;
251
+ }
252
+ stat = lstatSync(directory);
253
+ }
254
+ if (!stat.isDirectory()) throw new Error("state folder is not a real folder");
255
+ }
256
+ return directory;
257
+ };
236
258
 
237
259
  // Opening with O_NOFOLLOW refuses a symlink planted at a state or trace path, so a write never
238
260
  // lands outside the state folder. Where the platform lacks the flag, lstat refuses it instead.
@@ -274,8 +296,7 @@ const readSession = (stateRoot, sessionId) => {
274
296
 
275
297
  const writeSession = (stateRoot, sessionId, session) => {
276
298
  try {
277
- const directory = jevStateDirectory(stateRoot);
278
- mkdirSync(directory, { recursive: true, mode: 0o700 });
299
+ const directory = jevStateDirectory(stateRoot, { create: true });
279
300
  const target = join(directory, `session-${safeSessionId(sessionId)}.json`);
280
301
  const temporary = `${target}.${process.pid}.tmp`;
281
302
  refuseSymlink(temporary);
@@ -293,8 +314,7 @@ const writeSession = (stateRoot, sessionId, session) => {
293
314
 
294
315
  const writeTrace = (stateRoot, record) => {
295
316
  try {
296
- const directory = jevStateDirectory(stateRoot);
297
- mkdirSync(directory, { recursive: true, mode: 0o700 });
317
+ const directory = jevStateDirectory(stateRoot, { create: true });
298
318
  const target = join(directory, "trace.jsonl");
299
319
  refuseSymlink(target);
300
320
  const fd = openSync(target, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | NO_FOLLOW, 0o600);
@@ -341,8 +361,6 @@ export const jevHudState = ({ env = process.env, stateRoot, sessionId } = {}) =>
341
361
  return { keyMissing: false, last: stateRoot ? readSession(stateRoot, sessionId).last : null };
342
362
  };
343
363
 
344
- export const formatJevLatency = (latencyMs) => `${(latencyMs / 1000).toFixed(2)}s`;
345
-
346
364
  /** Host-side eligibility that needs no router: a leading command character or too little text. */
347
365
  export const isJevEligibleText = (text) => {
348
366
  if (typeof text !== "string") return false;
@@ -42,6 +42,19 @@ const HOST_TOOLS = [
42
42
 
43
43
  const run = (command, args, options = {}) => spawnSync(command, args, { encoding: "utf8", ...options });
44
44
 
45
+ // npm exports its own settings as npm_config_* to every script it runs, so a caller started under
46
+ // `npm install -g` would hand `global=true` to this child, and npm refuses `npm ci` in global mode
47
+ // (ECIGLOBAL). Drop the inherited settings that change where or what `npm ci` installs; registry,
48
+ // proxy and cache settings pass through.
49
+ const NPM_INSTALL_SHAPE_SETTINGS = new Set([
50
+ "global", "location", "prefix", "dry_run", "package_lock_only", "include", "production",
51
+ "workspace", "workspaces", "include_workspace_root", "install_strategy", "global_style", "legacy_bundling",
52
+ ]);
53
+ const npmChildEnv = (env) => Object.fromEntries(Object.entries(env).filter(([key]) => {
54
+ const setting = /^npm_config_(.+)$/iu.exec(key)?.[1].toLowerCase().replaceAll("-", "_");
55
+ return !NPM_INSTALL_SHAPE_SETTINGS.has(setting);
56
+ }));
57
+
45
58
  export function pythonCommand() {
46
59
  return process.env.LITCLAUDE_OFFICE_PYTHON || "python3";
47
60
  }
@@ -136,6 +149,7 @@ export function ensureNode() {
136
149
  const result = run(npm, ["ci", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund", "--loglevel=error"], {
137
150
  cwd: status.dir,
138
151
  stdio: ["ignore", "pipe", "pipe"],
152
+ env: npmChildEnv(process.env),
139
153
  });
140
154
  if (result.error || result.status !== 0) {
141
155
  throw new Error(`OFFICE_RUNTIME_NODE_INSTALL_FAILED: npm ci exited ${result.status}: ${(result.stderr || result.error?.message || "").trim().slice(-600)}`);
@@ -24,7 +24,11 @@ export const SECRET_SHAPE_PATTERNS = Object.freeze([
24
24
  /(?<![A-Za-z0-9_])SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}(?![A-Za-z0-9_])/u,
25
25
  /(?<![A-Za-z0-9_])(?:glpat|gldt|glrt)-[A-Za-z0-9_-]{8,}(?![A-Za-z0-9_])/u,
26
26
  /(?<![A-Za-z0-9_])eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?![A-Za-z0-9_])/u,
27
- /\b(?:bearer|basic)\s+[A-Za-z0-9._~+/=-]{12,}(?=$|[^A-Za-z0-9._~+/=-])/iu,
27
+ /\bbearer\s+[A-Za-z0-9._~+/=-]{12,}(?=$|[^A-Za-z0-9._~+/=-])/iu,
28
+ // A Basic credential is standard base64 of at least 8 characters in whole 4-character groups,
29
+ // carrying a digit, `+`, `/`, padding, or mixed case with an inner capital. The scheme matches
30
+ // in any case; the credential is case-sensitive, so "basic understanding" is not a credential.
31
+ /\b[Bb][Aa][Ss][Ii][Cc]\s+(?=[A-Za-z0-9+/=]{8})(?=[A-Za-z0-9+/=]*[0-9+/=]|(?=[A-Za-z0-9+/=]*[a-z])[A-Za-z0-9+/=]+[A-Z])(?:[A-Za-z0-9+/]{4})+(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?(?![A-Za-z0-9._~+/=-])/u,
28
32
  /(?<![A-Za-z0-9-])(?:proxy-)?authorization\s*:\s*[!#$%&'*+\-.^_`|~0-9A-Za-z]+\s+(?:[!#$%&'*+\-.^_`|~0-9A-Za-z]+\s*=\s*\S+|\S{12,})/iu,
29
33
  /\b(?:password|passwd|secret|token|api[_-]?key|access[_-]?token)\s*[:=]\s*[^\s,;]+/iu,
30
34
  /\b[A-Za-z][A-Za-z0-9]*(?:[_-][A-Za-z0-9]+)*[_-](?:token|secret|key)(?:[_-][A-Za-z0-9]+)*\s*[:=]\s*[^\s,;]+/iu,
@@ -237,9 +237,22 @@ export async function installFonts({ env = process.env, log = () => {} } = {}) {
237
237
  return done;
238
238
  }
239
239
 
240
- function npm(args, options) {
240
+ // npm exports its own settings to lifecycle scripts as npm_config_*, so an install started by the
241
+ // postinstall of `npm install -g` would hand `global=true` to this child, and npm refuses `npm ci`
242
+ // in global mode (ECIGLOBAL). Drop the inherited settings that change where or what `npm ci`
243
+ // installs; registry, proxy and cache settings pass through.
244
+ const NPM_INSTALL_SHAPE_SETTINGS = new Set([
245
+ "global", "location", "prefix", "dry_run", "package_lock_only", "include", "production",
246
+ "workspace", "workspaces", "include_workspace_root", "install_strategy", "global_style", "legacy_bundling",
247
+ ]);
248
+ const npmChildEnv = (env) => Object.fromEntries(Object.entries(env).filter(([key]) => {
249
+ const setting = /^npm_config_(.+)$/iu.exec(key)?.[1].toLowerCase().replaceAll("-", "_");
250
+ return !NPM_INSTALL_SHAPE_SETTINGS.has(setting);
251
+ }));
252
+
253
+ function npm(args, { env, ...options }) {
241
254
  const bin = process.platform === "win32" ? "npm.cmd" : "npm";
242
- return spawnSync(bin, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...options });
255
+ return spawnSync(bin, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...options, env: npmChildEnv(env) });
243
256
  }
244
257
 
245
258
  export async function installNode({ env = process.env, log = () => {} } = {}) {
@@ -264,12 +277,12 @@ export async function installNode({ env = process.env, log = () => {} } = {}) {
264
277
  if (createHash("sha512").update(bytes).digest("hex") !== integrity) throw new Error(`integrity mismatch for ${name}`);
265
278
  const tarball = path.join(cache, path.basename(new URL(pkg.resolved).pathname));
266
279
  writeAtomic(tarball, bytes);
267
- const add = npm(["cache", "add", tarball, "--cache", cache], { env: { ...env } });
280
+ const add = npm(["cache", "add", tarball, "--cache", cache], { env });
268
281
  if (add.status !== 0) throw new Error(`npm cache add failed: ${add.stderr.trim().slice(-400)}`);
269
282
  }
270
283
  args.push("--offline", "--cache", cache);
271
284
  }
272
- const result = npm(args, { cwd: dir, env: { ...env } });
285
+ const result = npm(args, { cwd: dir, env });
273
286
  if (cache) rmSync(cache, { recursive: true, force: true });
274
287
  if (result.error || result.status !== 0) {
275
288
  rmSync(dir, { recursive: true, force: true });