wood-fired-tasks 1.18.1 → 1.18.2

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/CHANGELOG.md CHANGED
@@ -13,6 +13,38 @@ vulnerabilities, supply-chain pinning) are always called out under `Security`.
13
13
 
14
14
  _No changes yet._
15
15
 
16
+ ## [v1.18.2] - 2026-06-06
17
+
18
+ A patch release: a Windows `self-update` fix plus two install-experience
19
+ follow-ups.
20
+
21
+ ### Fixed
22
+ - **`wood-fired-tasks self-update` no longer crashes on Windows** (#793). It
23
+ spawned `npm` (which is `npm.cmd`) without a shell; since the CVE-2024-27980
24
+ hardening, Node refuses to spawn a `.cmd`/`.bat` directly and threw
25
+ `spawn EINVAL` (errno -4071). The npm spawn now passes `shell: true` on
26
+ Windows only (its args are constant — no quoting/injection hazard), with a
27
+ win32 regression test. Workaround on 1.18.0/1.18.1: run
28
+ `npm i -g wood-fired-tasks@latest` directly.
29
+
30
+ ### Added
31
+ - **PATH remediation hint after global install** (#792). A child process can't
32
+ change the parent shell's PATH, so a fresh `npm i -g` is sometimes not
33
+ resolvable until a new shell. `setup` and the postinstall notice now detect
34
+ this (npm global bin dir vs the process PATH — no `which`/`where` shell-out)
35
+ and print a copy-pasteable fix per platform/shell: `hash -r` (bash, dir on
36
+ PATH but stale cache), `export PATH=…` + persist (posix, dir off PATH), or the
37
+ PowerShell `$env:Path` refresh (Windows). The postinstall path is
38
+ try/catch-guarded so it can never fail an install.
39
+
40
+ ### Changed
41
+ - **Documented the harmless npm deprecation warnings** (#789). `prebuild-install`
42
+ (via `better-sqlite3`, latest still uses it) and `lodash.get`/`lodash.isequal`
43
+ (via `umzug` → `@rushstack/ts-command-line` → `z-schema@5`) are upstream
44
+ transitives — install succeeds and `npm audit` is clean. Recorded in
45
+ `docs/SETUP.md` with the dependency chains; eliminating the lodash pair via a
46
+ `z-schema@12` override is deferred (it must not risk the migration path).
47
+
16
48
  ## [v1.18.1] - 2026-06-06
17
49
 
18
50
  A patch release fixing two regressions shipped in v1.18.0.
@@ -64,6 +64,12 @@ function runNpmInstall(spawn) {
64
64
  let settled = false;
65
65
  const child = spawn(NPM_BIN, ['i', '-g', `${PACKAGE_NAME}@latest`], {
66
66
  stdio: ['ignore', 'inherit', 'pipe'],
67
+ // Windows: npm is `npm.cmd` (a batch file), and since the
68
+ // CVE-2024-27980 hardening Node refuses to spawn a `.cmd`/`.bat`
69
+ // directly without a shell (throws spawn EINVAL). Run through the shell
70
+ // on win32. Safe here: every arg is a constant with no spaces or shell
71
+ // metacharacters, so there is no quoting/injection hazard.
72
+ shell: process.platform === 'win32',
67
73
  });
68
74
  const settle = (code, error) => {
69
75
  if (settled)
@@ -6,6 +6,7 @@ import { execFileSync } from 'node:child_process';
6
6
  import { mergeClaudeJson } from '../../setup/claude-json.js';
7
7
  import { resolveAssetPath } from '../../assets/resolve.js';
8
8
  import { configDir as defaultConfigDir } from '../../config/paths.js';
9
+ import { resolvePathHint } from '../util/path-hint.js';
9
10
  /**
10
11
  * `tasks setup` (task #737).
11
12
  *
@@ -292,6 +293,18 @@ export function runSetup(options = {}) {
292
293
  log,
293
294
  });
294
295
  }
296
+ // Task #792: best-effort PATH remediation hint. If the npm global bin dir is
297
+ // not resolvable on the current PATH (or a POSIX shell may have a stale
298
+ // command-hash cache), print the exact one-liner to fix it. Non-fatal: a
299
+ // child process cannot mutate the parent shell's PATH, so this is advice only.
300
+ try {
301
+ const hint = resolvePathHint();
302
+ if (hint !== null)
303
+ log(hint);
304
+ }
305
+ catch {
306
+ /* best-effort: never block setup on a hint failure */
307
+ }
295
308
  return {
296
309
  claudeJsonPath,
297
310
  claudeJsonChanged: !merge.unchanged,
@@ -0,0 +1,61 @@
1
+ /**
2
+ * PATH remediation hint (task #792).
3
+ *
4
+ * After `npm i -g wood-fired-tasks`, the npm global bin directory may not be on
5
+ * the CURRENT shell's PATH (the user must open a new shell) or — even when it
6
+ * IS on PATH — a POSIX shell may have a stale command-hash cache that resolves
7
+ * the old absence. A child process cannot mutate the parent shell's PATH, but
8
+ * we CAN detect the condition and print the exact one-liner to fix it.
9
+ *
10
+ * The core {@link pathHint} is PURE: every input is injected, there is no I/O,
11
+ * no env reads, no process spawning. The {@link resolvePathHint} resolver
12
+ * gathers real inputs for production callers WITHOUT shelling out to
13
+ * `npm`/`which`/`where` (slow at postinstall time, and `which` would mask the
14
+ * hash-cache case).
15
+ */
16
+ export interface PathHintInput {
17
+ /** Target platform (`process.platform`). */
18
+ platform: NodeJS.Platform;
19
+ /** Raw `PATH` env value (may be undefined). */
20
+ pathEnv: string | undefined;
21
+ /** Absolute npm global bin directory the CLI was installed into. */
22
+ npmBinDir: string;
23
+ /** Current shell (e.g. `process.env.SHELL`); used to pick an rc file. */
24
+ shell?: string | undefined;
25
+ }
26
+ /**
27
+ * Returns a remediation hint string when the npm global bin dir is NOT
28
+ * resolvable in the current PATH (or, on POSIX, when it IS present but a stale
29
+ * shell command-hash cache may hide it), else null (no message needed).
30
+ *
31
+ * PURE: no I/O, no env reads, no spawning — all inputs injected.
32
+ */
33
+ export declare function pathHint(input: PathHintInput): string | null;
34
+ /**
35
+ * Best-effort resolution of the npm global bin directory WITHOUT shelling out.
36
+ *
37
+ * Strategy (in order):
38
+ * 1. `process.env.npm_config_prefix` (set during npm lifecycle scripts and
39
+ * when the user configured a prefix) → bin dir per platform convention.
40
+ * 2. The location of the running module: a global install lands under
41
+ * `<prefix>/lib/node_modules/wood-fired-tasks` (POSIX) or
42
+ * `<prefix>/node_modules/wood-fired-tasks` (win32). Walk up to recover
43
+ * `<prefix>` and derive the bin dir.
44
+ *
45
+ * Returns null when it cannot resolve a prefix confidently (better to print no
46
+ * hint than a misleading one).
47
+ *
48
+ * On POSIX the bin dir is `<prefix>/bin`; on win32 the global bins live in the
49
+ * prefix directory itself.
50
+ */
51
+ export declare function resolveNpmBinDir(opts?: {
52
+ platform?: NodeJS.Platform;
53
+ npmConfigPrefix?: string | undefined;
54
+ moduleDir?: string;
55
+ }): string | null;
56
+ /**
57
+ * Production convenience: resolve real inputs and return the hint (or null).
58
+ * Never throws — wraps resolution defensively so callers (setup, postinstall)
59
+ * can call it best-effort.
60
+ */
61
+ export declare function resolvePathHint(): string | null;
@@ -0,0 +1,173 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ /** Path-list delimiter for the given platform. */
4
+ function pathDelimiter(platform) {
5
+ return platform === 'win32' ? ';' : ':';
6
+ }
7
+ /**
8
+ * Normalize a single PATH entry for comparison. On win32 we compare
9
+ * case-insensitively and tolerate a trailing slash/backslash; on POSIX the
10
+ * comparison is exact (modulo a trailing-slash trim, which is always safe).
11
+ */
12
+ function normalizeEntry(entry, platform) {
13
+ let e = entry.trim();
14
+ // Strip a single trailing path separator (both kinds, defensively).
15
+ e = e.replace(/[/\\]+$/, '');
16
+ if (platform === 'win32') {
17
+ e = e.toLowerCase();
18
+ }
19
+ return e;
20
+ }
21
+ /** True when `npmBinDir` is present in `pathEnv` after normalization. */
22
+ function isOnPath(input) {
23
+ if (input.pathEnv === undefined || input.pathEnv.length === 0)
24
+ return false;
25
+ const delim = pathDelimiter(input.platform);
26
+ const target = normalizeEntry(input.npmBinDir, input.platform);
27
+ if (target.length === 0)
28
+ return false;
29
+ return input.pathEnv
30
+ .split(delim)
31
+ .map((p) => normalizeEntry(p, input.platform))
32
+ .some((p) => p.length > 0 && p === target);
33
+ }
34
+ /**
35
+ * Pick the shell rc file to mention for persistence on POSIX. zsh → ~/.zshrc;
36
+ * otherwise stay generic (~/.bashrc or ~/.profile) since we cannot be certain.
37
+ */
38
+ function posixRcHint(shell) {
39
+ if (typeof shell === 'string' && /zsh/i.test(shell)) {
40
+ return '~/.zshrc';
41
+ }
42
+ return '~/.bashrc (or ~/.profile)';
43
+ }
44
+ /**
45
+ * Returns a remediation hint string when the npm global bin dir is NOT
46
+ * resolvable in the current PATH (or, on POSIX, when it IS present but a stale
47
+ * shell command-hash cache may hide it), else null (no message needed).
48
+ *
49
+ * PURE: no I/O, no env reads, no spawning — all inputs injected.
50
+ */
51
+ export function pathHint(input) {
52
+ // Cannot resolve a confident hint without a bin dir.
53
+ if (typeof input.npmBinDir !== 'string' || input.npmBinDir.trim().length === 0) {
54
+ return null;
55
+ }
56
+ const onPath = isOnPath(input);
57
+ const isWin = input.platform === 'win32';
58
+ if (onPath) {
59
+ if (isWin) {
60
+ // PowerShell/cmd don't maintain a bash-style command-hash cache; a dir on
61
+ // PATH resolves. Nothing actionable to print.
62
+ return null;
63
+ }
64
+ // POSIX: dir is on PATH, but the running shell may have cached the command's
65
+ // absence (hash table). `hash -r` clears it.
66
+ return (`'${input.npmBinDir}' is on your PATH but your shell may have a stale ` +
67
+ `command cache.\n` +
68
+ `If 'wood-fired-tasks' / 'wft' / 'tasks' is "command not found", run:\n` +
69
+ ` hash -r\n` +
70
+ `(or open a new terminal).`);
71
+ }
72
+ // NOT on PATH.
73
+ if (isWin) {
74
+ return (`The install directory is not on this session's PATH:\n` +
75
+ ` ${input.npmBinDir}\n` +
76
+ `Refresh the current PowerShell session:\n` +
77
+ ` $env:Path = [Environment]::GetEnvironmentVariable('Path','Machine') + ';' + ` +
78
+ `[Environment]::GetEnvironmentVariable('Path','User')\n` +
79
+ `cmd.exe users: open a new terminal.`);
80
+ }
81
+ const rc = posixRcHint(input.shell);
82
+ return (`The npm global bin directory is not on your PATH:\n` +
83
+ ` ${input.npmBinDir}\n` +
84
+ `Add it for the current shell now:\n` +
85
+ ` export PATH="${input.npmBinDir}:$PATH"\n` +
86
+ `Persist it by adding that line to ${rc}, then 'source' it (or open a new terminal).`);
87
+ }
88
+ /**
89
+ * Best-effort resolution of the npm global bin directory WITHOUT shelling out.
90
+ *
91
+ * Strategy (in order):
92
+ * 1. `process.env.npm_config_prefix` (set during npm lifecycle scripts and
93
+ * when the user configured a prefix) → bin dir per platform convention.
94
+ * 2. The location of the running module: a global install lands under
95
+ * `<prefix>/lib/node_modules/wood-fired-tasks` (POSIX) or
96
+ * `<prefix>/node_modules/wood-fired-tasks` (win32). Walk up to recover
97
+ * `<prefix>` and derive the bin dir.
98
+ *
99
+ * Returns null when it cannot resolve a prefix confidently (better to print no
100
+ * hint than a misleading one).
101
+ *
102
+ * On POSIX the bin dir is `<prefix>/bin`; on win32 the global bins live in the
103
+ * prefix directory itself.
104
+ */
105
+ export function resolveNpmBinDir(opts) {
106
+ const platform = opts?.platform ?? process.platform;
107
+ // Distinguish "caller injected a prefix (possibly undefined to mean none)"
108
+ // from "caller said nothing, read the env". `'npmConfigPrefix' in opts` is the
109
+ // discriminator so tests can inject `undefined` to exercise the fallback path
110
+ // without the ambient `npm_config_prefix` env leaking in.
111
+ const npmConfigPrefix = opts !== undefined && 'npmConfigPrefix' in opts
112
+ ? opts.npmConfigPrefix
113
+ : process.env['npm_config_prefix'];
114
+ const binFromPrefix = (prefix) => platform === 'win32' ? prefix : path.join(prefix, 'bin');
115
+ // 1) Explicit prefix from the npm environment.
116
+ if (typeof npmConfigPrefix === 'string' && npmConfigPrefix.trim().length > 0) {
117
+ return binFromPrefix(npmConfigPrefix.trim());
118
+ }
119
+ // 2) Derive from the running module's location.
120
+ let moduleDir = opts?.moduleDir;
121
+ if (moduleDir === undefined) {
122
+ try {
123
+ moduleDir = path.dirname(fileURLToPath(import.meta.url));
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ }
129
+ // Find a `node_modules` segment and treat its parent as the prefix root.
130
+ // POSIX: <prefix>/lib/node_modules/... → parent of node_modules is `lib`,
131
+ // whose parent is <prefix>.
132
+ // win32: <prefix>/node_modules/... → parent of node_modules is <prefix>.
133
+ const segments = moduleDir.split(/[/\\]+/);
134
+ const nmIdx = segments.lastIndexOf('node_modules');
135
+ if (nmIdx <= 0)
136
+ return null;
137
+ const parentOfNm = segments.slice(0, nmIdx).join(path.sep);
138
+ if (parentOfNm.length === 0)
139
+ return null;
140
+ let prefix;
141
+ if (platform === 'win32') {
142
+ prefix = parentOfNm;
143
+ }
144
+ else {
145
+ // Expect the parent of node_modules to be `lib`; strip it to get <prefix>.
146
+ prefix = path.basename(parentOfNm) === 'lib' ? path.dirname(parentOfNm) : parentOfNm;
147
+ }
148
+ if (prefix.length === 0)
149
+ return null;
150
+ return binFromPrefix(prefix);
151
+ }
152
+ /**
153
+ * Production convenience: resolve real inputs and return the hint (or null).
154
+ * Never throws — wraps resolution defensively so callers (setup, postinstall)
155
+ * can call it best-effort.
156
+ */
157
+ export function resolvePathHint() {
158
+ try {
159
+ const npmBinDir = resolveNpmBinDir();
160
+ if (npmBinDir === null)
161
+ return null;
162
+ return pathHint({
163
+ platform: process.platform,
164
+ pathEnv: process.env['PATH'],
165
+ npmBinDir,
166
+ shell: process.env['SHELL'],
167
+ });
168
+ }
169
+ catch {
170
+ return null;
171
+ }
172
+ }
173
+ //# sourceMappingURL=path-hint.js.map
package/docs/SETUP.md CHANGED
@@ -58,6 +58,19 @@ npm i -g wood-fired-tasks
58
58
  After that, `npm i -g` and `wood-fired-tasks self-update` both work without sudo
59
59
  forever.
60
60
 
61
+ ### Deprecation warnings on install (harmless)
62
+
63
+ `npm i -g wood-fired-tasks` prints a few `npm warn deprecated` lines. They are
64
+ **expected, harmless, and come from upstream transitive dependencies** — not
65
+ from wood-fired-tasks itself. The install succeeds and `npm audit` is clean.
66
+
67
+ | Warning | Where it comes from | Status |
68
+ | --- | --- | --- |
69
+ | `prebuild-install@7.x` | `better-sqlite3` (the SQLite driver) still depends on it, including the latest release | Upstream-owned; nothing to do until better-sqlite3 drops it |
70
+ | `lodash.get@4.x`, `lodash.isequal@4.x` | `umzug` (migrations) → `@rushstack/ts-command-line` → `z-schema@5` | Upstream-owned; `z-schema@12` dropped them but forcing that major override under rushstack is deferred (it must not risk the migration path) |
71
+
72
+ You can safely ignore them. Tracking: project-37 #789.
73
+
61
74
  ### `wood-fired-tasks setup` — local Claude Code wiring
62
75
 
63
76
  `setup` does three things, all idempotent (re-runnable; a file is only rewritten
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wood-fired-tasks",
3
- "version": "1.18.1",
3
+ "version": "1.18.2",
4
4
  "description": "Network-wide task tracking system for Wood Fired Games",
5
5
  "keywords": [
6
6
  "task-tracker",
@@ -19,3 +19,111 @@ process.stdout.write(
19
19
  'wood-fired-tasks installed. Run `wood-fired-tasks setup` to register the ' +
20
20
  'MCP server and copy skills into ~/.claude.\n',
21
21
  );
22
+
23
+ /**
24
+ * Task #792: PATH remediation hint.
25
+ *
26
+ * After `npm i -g`, the npm global bin dir may not be on the CURRENT shell's
27
+ * PATH (needs a new shell), or — on POSIX — may be on PATH but hidden by a
28
+ * stale command-hash cache. A child process can't mutate the parent shell's
29
+ * PATH, but we can DETECT and print the exact fix.
30
+ *
31
+ * This is a SELF-CONTAINED mirror of `src/cli/util/path-hint.ts` rather than an
32
+ * import of the ESM dist helper. postinstall.cjs is CommonJS and must (#752)
33
+ * stay self-contained + side-effect-free, and must run even if `dist/` is weird
34
+ * or absent — importing the built helper would couple this to a successful
35
+ * build and to ESM/CJS interop. The logic is short, so we inline it and keep
36
+ * the two copies in sync. Everything is wrapped in try/catch so a hint failure
37
+ * NEVER breaks `npm install` (postinstall must always exit 0).
38
+ */
39
+ try {
40
+ const path = require('node:path');
41
+ const platform = process.platform;
42
+ const isWin = platform === 'win32';
43
+
44
+ /** Resolve the npm global bin dir without shelling out (no npm/which/where). */
45
+ function resolveNpmBinDir() {
46
+ const binFromPrefix = (prefix) => (isWin ? prefix : path.join(prefix, 'bin'));
47
+ const prefixEnv = process.env.npm_config_prefix;
48
+ if (typeof prefixEnv === 'string' && prefixEnv.trim().length > 0) {
49
+ return binFromPrefix(prefixEnv.trim());
50
+ }
51
+ // Derive from this module's location: a global install lands under
52
+ // <prefix>/lib/node_modules/wood-fired-tasks (POSIX) or
53
+ // <prefix>/node_modules/wood-fired-tasks (win32).
54
+ const segments = __dirname.split(/[/\\]+/);
55
+ const nmIdx = segments.lastIndexOf('node_modules');
56
+ if (nmIdx <= 0) return null;
57
+ const parentOfNm = segments.slice(0, nmIdx).join(path.sep);
58
+ if (parentOfNm.length === 0) return null;
59
+ let prefix;
60
+ if (isWin) {
61
+ prefix = parentOfNm;
62
+ } else {
63
+ prefix = path.basename(parentOfNm) === 'lib' ? path.dirname(parentOfNm) : parentOfNm;
64
+ }
65
+ if (prefix.length === 0) return null;
66
+ return binFromPrefix(prefix);
67
+ }
68
+
69
+ function normalizeEntry(entry) {
70
+ let e = entry.trim().replace(/[/\\]+$/, '');
71
+ if (isWin) e = e.toLowerCase();
72
+ return e;
73
+ }
74
+
75
+ function isOnPath(npmBinDir) {
76
+ const pathEnv = process.env.PATH;
77
+ if (typeof pathEnv !== 'string' || pathEnv.length === 0) return false;
78
+ const delim = isWin ? ';' : ':';
79
+ const target = normalizeEntry(npmBinDir);
80
+ if (target.length === 0) return false;
81
+ return pathEnv
82
+ .split(delim)
83
+ .map(normalizeEntry)
84
+ .some((p) => p.length > 0 && p === target);
85
+ }
86
+
87
+ function pathHint(npmBinDir) {
88
+ if (typeof npmBinDir !== 'string' || npmBinDir.trim().length === 0) return null;
89
+ const onPath = isOnPath(npmBinDir);
90
+ if (onPath) {
91
+ if (isWin) return null;
92
+ return (
93
+ `'${npmBinDir}' is on your PATH but your shell may have a stale ` +
94
+ `command cache.\n` +
95
+ `If 'wood-fired-tasks' / 'wft' / 'tasks' is "command not found", run:\n` +
96
+ ` hash -r\n` +
97
+ `(or open a new terminal).`
98
+ );
99
+ }
100
+ if (isWin) {
101
+ return (
102
+ `The install directory is not on this session's PATH:\n` +
103
+ ` ${npmBinDir}\n` +
104
+ `Refresh the current PowerShell session:\n` +
105
+ ` $env:Path = [Environment]::GetEnvironmentVariable('Path','Machine') + ';' + ` +
106
+ `[Environment]::GetEnvironmentVariable('Path','User')\n` +
107
+ `cmd.exe users: open a new terminal.`
108
+ );
109
+ }
110
+ const shell = process.env.SHELL;
111
+ const rc =
112
+ typeof shell === 'string' && /zsh/i.test(shell) ? '~/.zshrc' : '~/.bashrc (or ~/.profile)';
113
+ return (
114
+ `The npm global bin directory is not on your PATH:\n` +
115
+ ` ${npmBinDir}\n` +
116
+ `Add it for the current shell now:\n` +
117
+ ` export PATH="${npmBinDir}:$PATH"\n` +
118
+ `Persist it by adding that line to ${rc}, then 'source' it (or open a new terminal).`
119
+ );
120
+ }
121
+
122
+ const npmBinDir = resolveNpmBinDir();
123
+ if (npmBinDir !== null) {
124
+ const hint = pathHint(npmBinDir);
125
+ if (hint !== null) process.stdout.write(hint + '\n');
126
+ }
127
+ } catch {
128
+ /* best-effort: a PATH hint must NEVER fail `npm install`. */
129
+ }