@haposoft/cafekit 0.16.2 → 0.16.3

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Native spec-driven workflow and runtime bundle for Claude Code and Codex CLI.
4
4
 
5
- [![Version](https://img.shields.io/badge/version-0.16.2-blue.svg)](https://github.com/haposoft/cafekit)
5
+ [![Version](https://img.shields.io/badge/version-0.16.3-blue.svg)](https://github.com/haposoft/cafekit)
6
6
  [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
7
7
  [![Claude%20Code](https://img.shields.io/badge/Claude%20Code-Native-orange.svg)](https://claude.ai/code)
8
8
  [![Codex%20CLI](https://img.shields.io/badge/Codex%20CLI-Native-111111.svg)](https://developers.openai.com/codex)
@@ -126,9 +126,12 @@ AGENTS.md
126
126
  CafeKit owns only its marked block in root `AGENTS.md`. Codex discovers skills
127
127
  and custom agents natively after the repository is trusted; no project
128
128
  `config.toml` is generated. Review and trust project hooks with `/hooks`.
129
- Installed hook commands are bound to the canonical project path on every platform,
130
- so they need no Git repository, stay correct for a project nested inside a larger
131
- repository, and remain stable when a session starts in a subdirectory.
129
+ Installed Codex hook commands are bound to the canonical project path, so they need no
130
+ Git repository, stay correct for a project nested inside a larger repository, and remain
131
+ stable when a session starts in a subdirectory. They also carry a PATH floor pointing at
132
+ the Node.js that installed them, so a host launched from the macOS GUI — whose children
133
+ inherit only `/usr/bin:/bin:/usr/sbin:/sbin` — can still find the interpreter. The floor
134
+ is appended, so a version manager's current Node still wins in a normal terminal.
132
135
  CafeKit uses Codex's native status and usage UI instead of installing the
133
136
  Claude statusline.
134
137
 
@@ -2035,10 +2035,13 @@ test('Codex Windows hook launchers stay project-bound without Git from nested cw
2035
2035
  });
2036
2036
  });
2037
2037
 
2038
- // Run an installed POSIX launcher with no PATH at all, so neither `git` nor a `node` on
2039
- // PATH can rescue it. The launcher must already carry everything it needs.
2040
- function runPosixLauncher(command, cwd) {
2041
- return spawnSync(command.replace(/^node /, `"${process.execPath}" `), {
2038
+ // Run an installed POSIX launcher verbatim under the environment a macOS GUI host hands
2039
+ // its children: no `git`, and no `node` on PATH. Nothing here rewrites the command, so a
2040
+ // launcher that cannot find its own interpreter fails the way it fails in the wild.
2041
+ const GUI_PATH = '/usr/bin:/bin:/usr/sbin:/sbin';
2042
+
2043
+ function runPosixLauncher(command, cwd, pathValue = GUI_PATH) {
2044
+ return spawnSync(command, {
2042
2045
  cwd,
2043
2046
  encoding: 'utf8',
2044
2047
  input: JSON.stringify({
@@ -2047,7 +2050,7 @@ function runPosixLauncher(command, cwd) {
2047
2050
  hook_event_name: 'SessionStart',
2048
2051
  source: 'startup'
2049
2052
  }),
2050
- env: { ...process.env, PATH: '' },
2053
+ env: { PATH: pathValue, HOME: os.homedir() },
2051
2054
  shell: true
2052
2055
  });
2053
2056
  }
@@ -2076,13 +2079,13 @@ test('Codex POSIX hook launchers run without Git and outside the repository root
2076
2079
  for (const projectRoot of [plain, nested]) {
2077
2080
  const { config, command } = sessionLauncher(projectRoot);
2078
2081
  for (const { handler } of allHookLaunchers(config)) {
2079
- // `node '<single-quoted absolute path>'` and nothing else. Inside single quotes
2080
- // the shell expands nothing, so a project path may safely contain $, a backtick,
2081
- // or the word git — this fixture's own path contains it.
2082
+ // A PATH floor, then `node`, then one single-quoted absolute path. Inside single
2083
+ // quotes the shell expands nothing, so a project path may safely contain $, a
2084
+ // backtick, or the word git — this fixture's own path contains it.
2082
2085
  assert.match(
2083
2086
  handler.command,
2084
- /^node '(?:[^']|'\\'')+'$/,
2085
- `launcher must be a plain quoted path: ${handler.command}`
2087
+ /^PATH="\$PATH"(?::'(?:[^']|'\\'')+')+ node '(?:[^']|'\\'')+'$/,
2088
+ `launcher must carry a PATH floor and a quoted path: ${handler.command}`
2086
2089
  );
2087
2090
  assert.doesNotMatch(handler.command, /rev-parse/, `launcher must not consult Git: ${handler.command}`);
2088
2091
  assert.ok(
@@ -2097,38 +2100,49 @@ test('Codex POSIX hook launchers run without Git and outside the repository root
2097
2100
  });
2098
2101
  });
2099
2102
 
2100
- test('a reinstall repairs Codex launchers that resolved their path through Git', () => {
2101
- inTempProject((root) => {
2102
- assert.equal(install(root).status, 0);
2103
- const configPath = path.join(root, '.codex', 'hooks.json');
2104
-
2105
- // Exactly what an installer before 0.16.2 wrote.
2106
- const downgraded = JSON.parse(fs.readFileSync(configPath, 'utf8'));
2107
- for (const { handler } of allHookLaunchers(downgraded)) {
2108
- const script = path.basename(handler.command.replace(/'$/, ''));
2109
- handler.command = `node "$(git rev-parse --show-toplevel)/.codex/hooks/${script}"`;
2110
- }
2111
- const foreign = { type: 'command', command: 'node "$(git rev-parse --show-toplevel)/tools/mine.cjs"' };
2112
- downgraded.hooks.SessionStart[0].hooks.push(foreign);
2113
- fs.writeFileSync(configPath, `${JSON.stringify(downgraded, null, 2)}\n`);
2114
-
2115
- const again = install(root);
2116
- assert.equal(again.status, 0, again.stderr);
2117
- assert.match(again.stdout, /repaired \d+ launcher/, 'the repair must be reported, not silent');
2103
+ // A reinstall treats an already-registered script as the user's placement, so a launcher
2104
+ // an older installer wrote would otherwise keep its broken command forever.
2105
+ for (const [label, legacyCommand] of [
2106
+ ['resolved their path through Git', (script) => `node "$(git rev-parse --show-toplevel)/.codex/hooks/${script}"`],
2107
+ ['looked for node on PATH', (script, root) => `node '${path.join(fs.realpathSync(root), '.codex', 'hooks', script)}'`],
2108
+ ]) {
2109
+ test(`a reinstall rebinds Codex launchers that ${label}`, () => {
2110
+ inTempProject((root) => {
2111
+ assert.equal(install(root).status, 0);
2112
+ const configPath = path.join(root, '.codex', 'hooks.json');
2113
+
2114
+ const downgraded = JSON.parse(fs.readFileSync(configPath, 'utf8'));
2115
+ for (const { handler } of allHookLaunchers(downgraded)) {
2116
+ handler.command = legacyCommand(path.basename(handler.command.replace(/'$/, '')), root);
2117
+ }
2118
+ // A hook the user wrote, in the same shape CafeKit used to emit, must never be
2119
+ // rewritten: it points somewhere else entirely.
2120
+ const foreign = { type: 'command', command: 'node "$(git rev-parse --show-toplevel)/tools/mine.cjs"' };
2121
+ downgraded.hooks.SessionStart[0].hooks.push(foreign);
2122
+ fs.writeFileSync(configPath, `${JSON.stringify(downgraded, null, 2)}\n`);
2123
+
2124
+ const again = install(root);
2125
+ assert.equal(again.status, 0, again.stderr);
2126
+ assert.match(again.stdout, /rebound \d+ launcher/, 'the rebind must be reported, not silent');
2127
+
2128
+ const { config, command } = sessionLauncher(root);
2129
+ const commands = allHookLaunchers(config).map(({ handler }) => handler.command);
2130
+ assert.ok(commands.includes(foreign.command), 'a foreign hook must survive untouched');
2131
+ assert.equal(
2132
+ commands.filter((entry) => !entry.startsWith('PATH=')).length,
2133
+ 1,
2134
+ 'every CafeKit launcher must be rebound, leaving only the foreign one'
2135
+ );
2136
+ const launched = runPosixLauncher(command, root);
2137
+ assert.equal(launched.status, 0, launched.stderr);
2138
+ assert.match(launched.stdout, /Session startup\./);
2118
2139
 
2119
- const { config, command } = sessionLauncher(root);
2120
- const commands = allHookLaunchers(config).map(({ handler }) => handler.command);
2121
- assert.equal(
2122
- commands.filter((entry) => /rev-parse/.test(entry)).length,
2123
- 1,
2124
- 'only the hook CafeKit did not author may keep its own command'
2125
- );
2126
- assert.ok(commands.includes(foreign.command), 'a foreign hook must survive untouched');
2127
- const launched = runPosixLauncher(command, root);
2128
- assert.equal(launched.status, 0, launched.stderr);
2129
- assert.match(launched.stdout, /Session startup\./);
2140
+ // Running the installer again must change nothing.
2141
+ const third = install(root);
2142
+ assert.doesNotMatch(third.stdout, /rebound \d+ launcher/, 'rebinding must be idempotent');
2143
+ });
2130
2144
  });
2131
- });
2145
+ }
2132
2146
 
2133
2147
  test('Codex installed Specs and spec-maker reject adaptive coverage mutations', () => {
2134
2148
  inTempProject((root) => {
@@ -17,8 +17,22 @@ const { PLATFORMS } = require('../lib/context');
17
17
 
18
18
  const CODEX_SRC = path.join(__dirname, '../../src/codex');
19
19
  const HOOK_COMMAND_TEMPLATE = /^node "\.codex\/hooks\/([a-z0-9-]+\.cjs)"$/;
20
- // What an installer before 0.16.2 wrote for POSIX. Kept only to recognize and repair it.
21
- const LEGACY_GIT_COMMAND = /^node "\$\(git rev-parse --show-toplevel\)\/\.codex\/hooks\/([a-z0-9-]+\.cjs)"$/;
20
+ // Exact shapes earlier installers wrote, kept only to recognize and upgrade them. Before
21
+ // 0.16.2 the path came from Git; 0.16.2 fixed the path but still resolved `node` through
22
+ // PATH. Matching the whole command keeps a hook the user wrote from ever being rewritten.
23
+ const LEGACY_POSIX_COMMANDS = [
24
+ /^node "\$\(git rev-parse --show-toplevel\)\/\.codex\/hooks\/([a-z0-9-]+\.cjs)"$/,
25
+ /^node '(?:[^']|'\\'')*[/\\]hooks[/\\]([a-z0-9-]+\.cjs)'$/,
26
+ ];
27
+
28
+ function legacyPosixScript(command) {
29
+ if (typeof command !== 'string') return null;
30
+ for (const pattern of LEGACY_POSIX_COMMANDS) {
31
+ const match = command.match(pattern);
32
+ if (match) return match[1];
33
+ }
34
+ return null;
35
+ }
22
36
 
23
37
  /** The hook script a command runs, which is stable across installs and platforms. */
24
38
  function hookScript(command) {
@@ -46,8 +60,26 @@ function hookPathFor(script, projectRoot) {
46
60
  * directly. The literal `/hooks/<script>.cjs` segment must survive, because hookScript()
47
61
  * reads it as the identity that lets a reinstall recognize CafeKit's own entries.
48
62
  */
63
+ /**
64
+ * Directories to fall back on when PATH carries no `node`.
65
+ *
66
+ * `process.execPath` is realpath-resolved, so under Homebrew or a version manager it names
67
+ * a versioned directory that disappears on the next node upgrade. The stable aliases are
68
+ * listed after it, and a directory that does not exist costs nothing in PATH.
69
+ */
70
+ function nodeFallbackDirectories() {
71
+ const candidates = [path.dirname(process.execPath), '/usr/local/bin', '/opt/homebrew/bin'];
72
+ return [...new Set(candidates.filter(Boolean))];
73
+ }
74
+
49
75
  function posixHookCommand(script, projectRoot) {
50
- return `node ${shellQuote(hookPathFor(script, projectRoot))}`;
76
+ // `node` is resolved through PATH, and a host launched from the macOS GUI hands its
77
+ // children the minimal `/usr/bin:/bin:/usr/sbin:/sbin`. Node installed by Homebrew, nvm,
78
+ // fnm, or Volta is not on that list, so the shell answered 127 and the hook never
79
+ // started. The fallbacks are appended, never prepended, so a version manager's current
80
+ // node still wins in a normal terminal and these act only as the floor.
81
+ const fallbacks = nodeFallbackDirectories().map((dir) => `:${shellQuote(dir)}`).join('');
82
+ return `PATH="$PATH"${fallbacks} node ${shellQuote(hookPathFor(script, projectRoot))}`;
51
83
  }
52
84
 
53
85
  /**
@@ -88,12 +120,12 @@ function repairLegacyCodexLaunchers(config, projectRoot) {
88
120
  return {
89
121
  ...group,
90
122
  hooks: group.hooks.map((handler) => {
91
- const legacy = typeof handler?.command === 'string' && handler.command.match(LEGACY_GIT_COMMAND);
123
+ const legacy = legacyPosixScript(handler?.command);
92
124
  const template = typeof handler?.commandWindows === 'string'
93
125
  && handler.commandWindows.match(HOOK_COMMAND_TEMPLATE);
94
126
  if (!legacy && !template) return handler;
95
127
  const next = { ...handler };
96
- if (legacy) next.command = posixHookCommand(legacy[1], projectRoot);
128
+ if (legacy) next.command = posixHookCommand(legacy, projectRoot);
97
129
  if (template) next.commandWindows = windowsHookCommand(template[1], projectRoot);
98
130
  repaired += 1;
99
131
  return next;
@@ -166,7 +198,7 @@ function mergeCodexHooks(ctx, platformKey, projectRoot = process.cwd()) {
166
198
 
167
199
  const { config: base, repaired } = repairLegacyCodexLaunchers(pruned, projectRoot);
168
200
  if (repaired > 0) {
169
- ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Codex hooks: repaired ${repaired} launcher(s) that resolved their path through Git`);
201
+ ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Codex hooks: rebound ${repaired} launcher(s) written by an older installer`);
170
202
  ctx.results.updated++;
171
203
  }
172
204
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haposoft/cafekit",
3
- "version": "0.16.2",
3
+ "version": "0.16.3",
4
4
  "description": "Native spec-driven workflows for Claude Code and Codex CLI. Bundles CafeKit skills, runtime hooks, agents, and installer scaffolding.",
5
5
  "author": "Haposoft <nghialt@haposoft.com>",
6
6
  "license": "MIT",