@pixelbyte-software/pixcode 1.38.2 → 1.38.4

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.
Files changed (46) hide show
  1. package/dist/assets/index-B-OgjpDF.css +32 -0
  2. package/dist/assets/{index-BJiAbLzU.js → index-Dk77bpMj.js} +174 -174
  3. package/dist/index.html +2 -2
  4. package/dist-server/server/modules/orchestration/workflows/workflow-runner.js +76 -1
  5. package/dist-server/server/modules/orchestration/workflows/workflow-runner.js.map +1 -1
  6. package/dist-server/server/modules/orchestration/workflows/workspace-target.js +2 -0
  7. package/dist-server/server/modules/orchestration/workflows/workspace-target.js.map +1 -1
  8. package/dist-server/server/modules/providers/list/codex/codex-auth.provider.js +2 -1
  9. package/dist-server/server/modules/providers/list/codex/codex-auth.provider.js.map +1 -1
  10. package/dist-server/server/modules/providers/list/cursor/cursor-auth.provider.js +5 -2
  11. package/dist-server/server/modules/providers/list/cursor/cursor-auth.provider.js.map +1 -1
  12. package/dist-server/server/modules/providers/list/gemini/gemini-auth.provider.js +1 -1
  13. package/dist-server/server/modules/providers/list/gemini/gemini-auth.provider.js.map +1 -1
  14. package/dist-server/server/modules/providers/list/qwen/qwen-auth.provider.js +1 -1
  15. package/dist-server/server/modules/providers/list/qwen/qwen-auth.provider.js.map +1 -1
  16. package/dist-server/server/routes/git.js +136 -1
  17. package/dist-server/server/routes/git.js.map +1 -1
  18. package/dist-server/server/routes/network.js +5 -1
  19. package/dist-server/server/routes/network.js.map +1 -1
  20. package/dist-server/server/routes/taskmaster.js +31 -52
  21. package/dist-server/server/routes/taskmaster.js.map +1 -1
  22. package/dist-server/server/services/external-access.js +20 -5
  23. package/dist-server/server/services/external-access.js.map +1 -1
  24. package/dist-server/server/services/install-jobs.js +152 -17
  25. package/dist-server/server/services/install-jobs.js.map +1 -1
  26. package/package.json +1 -1
  27. package/scripts/smoke/chat-session-provider-pools.mjs +35 -0
  28. package/scripts/smoke/command-center-non-git.mjs +46 -0
  29. package/scripts/smoke/desktop-tray-icon.mjs +33 -0
  30. package/scripts/smoke/mac-desktop-runtime.mjs +43 -0
  31. package/scripts/smoke/mobile-tunnel-guidance.mjs +29 -0
  32. package/scripts/smoke/multi-project-ui.mjs +45 -0
  33. package/scripts/smoke/notification-inapp-preference.mjs +23 -0
  34. package/scripts/smoke/orchestration-permission-fallback.mjs +34 -0
  35. package/server/modules/orchestration/workflows/workflow-runner.ts +105 -1
  36. package/server/modules/orchestration/workflows/workspace-target.ts +2 -0
  37. package/server/modules/providers/list/codex/codex-auth.provider.ts +2 -1
  38. package/server/modules/providers/list/cursor/cursor-auth.provider.ts +6 -2
  39. package/server/modules/providers/list/gemini/gemini-auth.provider.ts +1 -1
  40. package/server/modules/providers/list/qwen/qwen-auth.provider.ts +1 -1
  41. package/server/routes/git.js +155 -1
  42. package/server/routes/network.js +5 -1
  43. package/server/routes/taskmaster.js +36 -57
  44. package/server/services/external-access.js +21 -5
  45. package/server/services/install-jobs.js +159 -16
  46. package/dist/assets/index-BzL2G4Sw.css +0 -32
@@ -10,6 +10,150 @@ import { spawnCursor } from '../cursor-cli.js';
10
10
 
11
11
  const router = express.Router();
12
12
  const COMMIT_DIFF_CHARACTER_LIMIT = 500_000;
13
+ const FILESYSTEM_SCAN_MAX_FILES = 5_000;
14
+ const FILESYSTEM_SCAN_MAX_DEPTH = 10;
15
+ const filesystemChangeSnapshots = new Map();
16
+ const FILESYSTEM_SCAN_EXCLUDED_DIRS = new Set([
17
+ '.git',
18
+ '.hg',
19
+ '.svn',
20
+ 'node_modules',
21
+ 'dist',
22
+ 'dist-server',
23
+ 'build',
24
+ '.next',
25
+ '.nuxt',
26
+ '.svelte-kit',
27
+ 'coverage',
28
+ '.turbo',
29
+ '.cache',
30
+ '.pixcode-dev',
31
+ ]);
32
+
33
+ function isNotGitRepositoryMessage(message = '') {
34
+ return message.includes('Not a git repository')
35
+ || message.includes('not a git repository')
36
+ || message.includes('Project directory is not a git repository');
37
+ }
38
+
39
+ function shouldSkipFilesystemEntry(entryName) {
40
+ return FILESYSTEM_SCAN_EXCLUDED_DIRS.has(entryName)
41
+ || entryName.endsWith('.log')
42
+ || entryName === '.DS_Store';
43
+ }
44
+
45
+ function toProjectRelativePath(projectPath, filePath) {
46
+ return path.relative(projectPath, filePath).replace(/\\/g, '/');
47
+ }
48
+
49
+ async function collectFilesystemSnapshot(projectPath) {
50
+ const snapshot = new Map();
51
+ let limitReached = false;
52
+
53
+ async function walk(directoryPath, depth) {
54
+ if (limitReached || depth > FILESYSTEM_SCAN_MAX_DEPTH) {
55
+ return;
56
+ }
57
+
58
+ let entries = [];
59
+ try {
60
+ entries = await fs.readdir(directoryPath, { withFileTypes: true });
61
+ } catch {
62
+ return;
63
+ }
64
+
65
+ for (const entry of entries) {
66
+ if (limitReached || shouldSkipFilesystemEntry(entry.name)) {
67
+ continue;
68
+ }
69
+
70
+ const absolutePath = path.join(directoryPath, entry.name);
71
+
72
+ if (entry.isDirectory()) {
73
+ await walk(absolutePath, depth + 1);
74
+ continue;
75
+ }
76
+
77
+ if (!entry.isFile()) {
78
+ continue;
79
+ }
80
+
81
+ try {
82
+ const stat = await fs.stat(absolutePath);
83
+ snapshot.set(toProjectRelativePath(projectPath, absolutePath), {
84
+ mtimeMs: Math.round(stat.mtimeMs),
85
+ size: stat.size,
86
+ });
87
+ } catch {
88
+ continue;
89
+ }
90
+
91
+ if (snapshot.size >= FILESYSTEM_SCAN_MAX_FILES) {
92
+ limitReached = true;
93
+ break;
94
+ }
95
+ }
96
+ }
97
+
98
+ await walk(projectPath, 0);
99
+ return { snapshot, limitReached };
100
+ }
101
+
102
+ function diffFilesystemSnapshots(previousSnapshot, nextSnapshot) {
103
+ if (!previousSnapshot) {
104
+ return { modified: [], added: [], deleted: [] };
105
+ }
106
+
107
+ const modified = [];
108
+ const added = [];
109
+ const deleted = [];
110
+
111
+ for (const [filePath, nextMeta] of nextSnapshot.entries()) {
112
+ const previousMeta = previousSnapshot.get(filePath);
113
+ if (!previousMeta) {
114
+ added.push(filePath);
115
+ continue;
116
+ }
117
+
118
+ if (previousMeta.mtimeMs !== nextMeta.mtimeMs || previousMeta.size !== nextMeta.size) {
119
+ modified.push(filePath);
120
+ }
121
+ }
122
+
123
+ for (const filePath of previousSnapshot.keys()) {
124
+ if (!nextSnapshot.has(filePath)) {
125
+ deleted.push(filePath);
126
+ }
127
+ }
128
+
129
+ return {
130
+ modified: modified.sort(),
131
+ added: added.sort(),
132
+ deleted: deleted.sort(),
133
+ };
134
+ }
135
+
136
+ async function buildFilesystemStatus(projectPath) {
137
+ const normalizedProjectPath = path.resolve(projectPath);
138
+ const previousSnapshot = filesystemChangeSnapshots.get(normalizedProjectPath) ?? null;
139
+ const { snapshot, limitReached } = await collectFilesystemSnapshot(normalizedProjectPath);
140
+ filesystemChangeSnapshots.set(normalizedProjectPath, snapshot);
141
+ const { modified, added, deleted } = diffFilesystemSnapshots(previousSnapshot, snapshot);
142
+
143
+ return {
144
+ isGitRepository: false,
145
+ trackingMode: 'filesystem',
146
+ branch: null,
147
+ hasCommits: false,
148
+ modified,
149
+ added,
150
+ deleted,
151
+ untracked: [],
152
+ snapshotReady: Boolean(previousSnapshot),
153
+ fileCount: snapshot.size,
154
+ scanLimitReached: limitReached,
155
+ };
156
+ }
13
157
 
14
158
  function spawnAsync(command, args, options = {}) {
15
159
  return new Promise((resolve, reject) => {
@@ -297,8 +441,9 @@ router.get('/status', async (req, res) => {
297
441
  return res.status(400).json({ error: 'Project name is required' });
298
442
  }
299
443
 
444
+ let projectPath;
300
445
  try {
301
- const projectPath = await getActualProjectPath(project);
446
+ projectPath = await getActualProjectPath(project);
302
447
 
303
448
  // Validate git repository
304
449
  await validateGitRepository(projectPath);
@@ -340,6 +485,15 @@ router.get('/status', async (req, res) => {
340
485
  untracked
341
486
  });
342
487
  } catch (error) {
488
+ if (projectPath && isNotGitRepositoryMessage(error.message)) {
489
+ try {
490
+ res.json(await buildFilesystemStatus(projectPath));
491
+ return;
492
+ } catch (fallbackError) {
493
+ console.error('Filesystem status fallback error:', fallbackError);
494
+ }
495
+ }
496
+
343
497
  console.error('Git status error:', error);
344
498
  res.json({
345
499
  error: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')
@@ -104,7 +104,11 @@ router.post('/tunnel', async (req, res) => {
104
104
  // required external binary is missing" — it tells the UI to show the
105
105
  // "install cloudflared/ngrok" hint rather than a generic server error.
106
106
  const status = error?.code === 'ENOENT_TUNNEL' ? 424 : 502;
107
- res.status(status).json({ error: error?.message || 'Tunnel start failed', tunnel: getTunnelState() });
107
+ res.status(status).json({
108
+ error: error?.message || 'Tunnel start failed',
109
+ installHint: error?.installHint,
110
+ tunnel: getTunnelState(),
111
+ });
108
112
  }
109
113
  });
110
114
 
@@ -13,13 +13,16 @@ import path from 'path';
13
13
  import { spawn } from 'child_process';
14
14
 
15
15
  import express from 'express';
16
+ import crossSpawn from 'cross-spawn';
16
17
 
17
18
  import { orchestrationTaskService } from '@/modules/orchestration/tasks/orchestration-task.service.js';
18
19
 
19
20
  import { extractProjectDirectory } from '../projects.js';
20
21
  import {
21
22
  cancelInstallJob,
23
+ buildCliSpawnEnv,
22
24
  createInstallJob,
25
+ findExecutableOnPath,
23
26
  getInstallJob,
24
27
  snapshotDonePayload
25
28
  } from '../services/install-jobs.js';
@@ -34,70 +37,46 @@ const router = express.Router();
34
37
  */
35
38
  async function checkTaskMasterInstallation() {
36
39
  return new Promise((resolve) => {
37
- // Check if task-master command is available
38
- const child = spawn('which', ['task-master'], {
40
+ const env = buildCliSpawnEnv();
41
+ const taskMasterPath = findExecutableOnPath('task-master', env);
42
+
43
+ if (!taskMasterPath) {
44
+ resolve({
45
+ isInstalled: false,
46
+ installPath: null,
47
+ version: null,
48
+ reason: 'TaskMaster CLI not found in PATH'
49
+ });
50
+ return;
51
+ }
52
+
53
+ const versionChild = crossSpawn(taskMasterPath, ['--version'], {
39
54
  stdio: ['ignore', 'pipe', 'pipe'],
40
- shell: true
55
+ env,
56
+ windowsHide: true,
41
57
  });
42
-
43
- let output = '';
44
- let errorOutput = '';
45
-
46
- child.stdout.on('data', (data) => {
47
- output += data.toString();
48
- });
49
-
50
- child.stderr.on('data', (data) => {
51
- errorOutput += data.toString();
52
- });
53
-
54
- child.on('close', (code) => {
55
- if (code === 0 && output.trim()) {
56
- // TaskMaster is installed, get version
57
- const versionChild = spawn('task-master', ['--version'], {
58
- stdio: ['ignore', 'pipe', 'pipe'],
59
- shell: true
60
- });
61
-
62
- let versionOutput = '';
63
58
 
64
- versionChild.stdout.on('data', (data) => {
65
- versionOutput += data.toString();
66
- });
59
+ let versionOutput = '';
67
60
 
68
- versionChild.on('close', (versionCode) => {
69
- resolve({
70
- isInstalled: true,
71
- installPath: output.trim(),
72
- version: versionCode === 0 ? versionOutput.trim() : 'unknown',
73
- reason: null
74
- });
75
- });
61
+ versionChild.stdout?.on('data', (data) => {
62
+ versionOutput += data.toString();
63
+ });
76
64
 
77
- versionChild.on('error', () => {
78
- resolve({
79
- isInstalled: true,
80
- installPath: output.trim(),
81
- version: 'unknown',
82
- reason: null
83
- });
84
- });
85
- } else {
86
- resolve({
87
- isInstalled: false,
88
- installPath: null,
89
- version: null,
90
- reason: 'TaskMaster CLI not found in PATH'
91
- });
92
- }
65
+ versionChild.on('close', (versionCode) => {
66
+ resolve({
67
+ isInstalled: true,
68
+ installPath: taskMasterPath,
69
+ version: versionCode === 0 ? versionOutput.trim() : 'unknown',
70
+ reason: null
71
+ });
93
72
  });
94
-
95
- child.on('error', (error) => {
73
+
74
+ versionChild.on('error', () => {
96
75
  resolve({
97
- isInstalled: false,
98
- installPath: null,
99
- version: null,
100
- reason: `Error checking installation: ${error.message}`
76
+ isInstalled: true,
77
+ installPath: taskMasterPath,
78
+ version: 'unknown',
79
+ reason: null
101
80
  });
102
81
  });
103
82
  });
@@ -37,6 +37,7 @@ let tunnelState = {
37
37
  binary: null, // 'cloudflared' | 'ngrok'
38
38
  url: null,
39
39
  error: null,
40
+ installHint: null,
40
41
  log: [],
41
42
  };
42
43
 
@@ -66,6 +67,18 @@ const detectBinary = async () => {
66
67
  return null;
67
68
  };
68
69
 
70
+ const createTunnelInstallHint = () => ({
71
+ title: 'Tunnel binary required',
72
+ message: 'Install cloudflared or ngrok to create a public mobile URL. Local LAN QR codes still work on the same Wi-Fi/network.',
73
+ commands: [
74
+ 'macOS: brew install cloudflared',
75
+ 'Windows: winget install Cloudflare.cloudflared',
76
+ 'Linux: install cloudflared from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/',
77
+ 'Alternative: install and authenticate ngrok from https://ngrok.com/download',
78
+ ],
79
+ docsUrl: 'https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/',
80
+ });
81
+
69
82
  const cloudflareUrlRegex = /https?:\/\/[a-z0-9.-]+trycloudflare\.com/i;
70
83
  const ngrokUrlRegex = /https?:\/\/[a-z0-9.-]+\.ngrok(-free)?\.(app|io)/i;
71
84
 
@@ -90,16 +103,18 @@ export const startTunnel = async ({ port }) => {
90
103
 
91
104
  const binary = await detectBinary();
92
105
  if (!binary) {
93
- tunnelState = { running: false, binary: null, url: null, error: 'No tunnel binary found', log: [] };
106
+ const installHint = createTunnelInstallHint();
107
+ tunnelState = { running: false, binary: null, url: null, error: 'No tunnel binary found', installHint, log: [] };
94
108
  const err = new Error('No tunnel binary found (tried cloudflared, ngrok)');
95
109
  err.code = 'ENOENT_TUNNEL';
110
+ err.installHint = installHint;
96
111
  throw err;
97
112
  }
98
113
 
99
114
  const args = buildTunnelArgs(binary, port);
100
115
  const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'] });
101
116
  tunnelProc = child;
102
- tunnelState = { running: true, binary, url: null, error: null, log: [] };
117
+ tunnelState = { running: true, binary, url: null, error: null, installHint: null, log: [] };
103
118
 
104
119
  const handleChunk = (chunk) => {
105
120
  const text = chunk.toString();
@@ -119,6 +134,7 @@ export const startTunnel = async ({ port }) => {
119
134
  binary,
120
135
  url: null,
121
136
  error: code === 0 ? null : `Tunnel exited with code ${code}`,
137
+ installHint: null,
122
138
  log: tunnelState.log,
123
139
  };
124
140
  });
@@ -138,7 +154,7 @@ export const startTunnel = async ({ port }) => {
138
154
  // If we never captured a URL, kill the child so we don't leak it.
139
155
  try { child.kill(); } catch { /* ignore */ }
140
156
  tunnelProc = null;
141
- tunnelState = { ...tunnelState, running: false, error: 'Tunnel did not report a public URL' };
157
+ tunnelState = { ...tunnelState, running: false, error: 'Tunnel did not report a public URL', installHint: null };
142
158
  throw new Error(tunnelState.error);
143
159
  }
144
160
 
@@ -147,7 +163,7 @@ export const startTunnel = async ({ port }) => {
147
163
 
148
164
  export const stopTunnel = async () => {
149
165
  if (!tunnelProc) {
150
- tunnelState = { running: false, binary: null, url: null, error: null, log: [] };
166
+ tunnelState = { running: false, binary: null, url: null, error: null, installHint: null, log: [] };
151
167
  return tunnelState;
152
168
  }
153
169
  try {
@@ -156,7 +172,7 @@ export const stopTunnel = async () => {
156
172
  // already dead
157
173
  }
158
174
  tunnelProc = null;
159
- tunnelState = { running: false, binary: null, url: null, error: null, log: [] };
175
+ tunnelState = { running: false, binary: null, url: null, error: null, installHint: null, log: [] };
160
176
  return tunnelState;
161
177
  };
162
178
 
@@ -48,6 +48,11 @@ import spawn from 'cross-spawn';
48
48
  const jobs = new Map();
49
49
  const FINISHED_TTL_MS = 10 * 60 * 1000;
50
50
  const HARD_TIMEOUT_MS = 10 * 60 * 1000;
51
+ const USER_SHELL_PATH_CACHE_TTL_MS = 5 * 60 * 1000;
52
+ const userShellPathCache = {
53
+ value: null,
54
+ readAt: 0,
55
+ };
51
56
 
52
57
  export const CLI_HOME = path.join(os.homedir(), '.pixcode', 'cli-bin');
53
58
  export const CLI_BIN_DIR = path.join(CLI_HOME, 'node_modules', '.bin');
@@ -94,13 +99,9 @@ function ensureCliHome() {
94
99
  */
95
100
  export function primeCliBinPath(env = process.env) {
96
101
  ensureCliHome();
97
- const sep = process.platform === 'win32' ? ';' : ':';
98
- const current = env.PATH || env.Path || '';
99
- if (!current.split(sep).some((entry) => path.resolve(entry || '') === path.resolve(CLI_BIN_DIR))) {
100
- const next = current ? `${CLI_BIN_DIR}${sep}${current}` : CLI_BIN_DIR;
101
- env.PATH = next;
102
- if ('Path' in env) env.Path = next;
103
- }
102
+ const augmentedEnv = buildCliSpawnEnv(env);
103
+ env.PATH = augmentedEnv.PATH;
104
+ if ('Path' in env || augmentedEnv.Path) env.Path = augmentedEnv.Path || augmentedEnv.PATH;
104
105
  // Once PATH is ready, resolve any well-known provider binaries to absolute
105
106
  // paths and export them as *_CLI_PATH env vars. This side-steps a Windows
106
107
  // gotcha: `child_process.spawn('claude', …)` does NOT auto-resolve .cmd /
@@ -141,6 +142,135 @@ export function resolveProviderExecutables(env = process.env) {
141
142
  }
142
143
  }
143
144
 
145
+ function pathSeparator() {
146
+ return process.platform === 'win32' ? ';' : ':';
147
+ }
148
+
149
+ function splitPathList(value) {
150
+ return String(value || '').split(pathSeparator()).map((entry) => entry.trim()).filter(Boolean);
151
+ }
152
+
153
+ function collectNvmNodeBins(home) {
154
+ const versionsDir = path.join(home, '.nvm', 'versions', 'node');
155
+ try {
156
+ return fs.readdirSync(versionsDir)
157
+ .map((version) => path.join(versionsDir, version, 'bin'))
158
+ .filter((candidate) => {
159
+ try {
160
+ return fs.statSync(candidate).isDirectory();
161
+ } catch {
162
+ return false;
163
+ }
164
+ })
165
+ .sort()
166
+ .reverse();
167
+ } catch {
168
+ return [];
169
+ }
170
+ }
171
+
172
+ function collectKnownUserBinDirs(env = process.env) {
173
+ const home = os.homedir();
174
+ if (process.platform === 'win32') {
175
+ return [
176
+ path.join(env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'npm'),
177
+ path.join(env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'Programs', 'nodejs'),
178
+ ];
179
+ }
180
+
181
+ return [
182
+ CLI_BIN_DIR,
183
+ path.dirname(process.execPath),
184
+ ...collectNvmNodeBins(home),
185
+ path.join(home, '.volta', 'bin'),
186
+ path.join(home, '.asdf', 'shims'),
187
+ path.join(home, '.bun', 'bin'),
188
+ path.join(home, '.local', 'bin'),
189
+ path.join(home, '.npm-global', 'bin'),
190
+ '/opt/homebrew/bin',
191
+ '/opt/homebrew/sbin',
192
+ '/usr/local/bin',
193
+ '/usr/local/sbin',
194
+ '/usr/bin',
195
+ '/bin',
196
+ ];
197
+ }
198
+
199
+ export function collectUserShellPath(env = process.env) {
200
+ if (process.platform === 'win32') return [];
201
+
202
+ const now = Date.now();
203
+ if (userShellPathCache.value && now - userShellPathCache.readAt < USER_SHELL_PATH_CACHE_TTL_MS) {
204
+ return userShellPathCache.value;
205
+ }
206
+
207
+ const shells = [env.SHELL, '/bin/zsh', '/bin/bash']
208
+ .filter(Boolean)
209
+ .filter((candidate, index, list) => list.indexOf(candidate) === index)
210
+ .filter((candidate) => {
211
+ try {
212
+ return fs.existsSync(candidate);
213
+ } catch {
214
+ return false;
215
+ }
216
+ });
217
+
218
+ const marker = '__PIXCODE_LOGIN_PATH__=';
219
+ for (const shell of shells) {
220
+ try {
221
+ const output = execFileSync(shell, ['-lc', `printf '\\n${marker}%s\\n' "$PATH"`], {
222
+ encoding: 'utf8',
223
+ env,
224
+ timeout: 2500,
225
+ stdio: ['ignore', 'pipe', 'ignore'],
226
+ });
227
+ const line = output.split(/\r?\n/).reverse().find((part) => part.startsWith(marker));
228
+ const shellPath = line?.slice(marker.length);
229
+ if (shellPath) {
230
+ const entries = splitPathList(shellPath);
231
+ userShellPathCache.value = entries;
232
+ userShellPathCache.readAt = now;
233
+ return entries;
234
+ }
235
+ } catch {
236
+ // GUI-launched macOS apps often have a tiny PATH. If the user's
237
+ // shell startup files are noisy or slow, fall back to known bins.
238
+ }
239
+ }
240
+
241
+ userShellPathCache.value = [];
242
+ userShellPathCache.readAt = now;
243
+ return [];
244
+ }
245
+
246
+ function mergePathEntries(env, preferredEntries) {
247
+ const existing = splitPathList(env.PATH || env.Path || '');
248
+ const seen = new Set();
249
+ const merged = [];
250
+
251
+ for (const entry of [...preferredEntries, ...existing]) {
252
+ if (!entry) continue;
253
+ const key = path.resolve(entry);
254
+ if (seen.has(key)) continue;
255
+ seen.add(key);
256
+ merged.push(entry);
257
+ }
258
+
259
+ const nextPath = merged.join(pathSeparator());
260
+ env.PATH = nextPath;
261
+ if ('Path' in env) env.Path = nextPath;
262
+ return env;
263
+ }
264
+
265
+ export function buildCliSpawnEnv(baseEnv = process.env) {
266
+ const env = { ...baseEnv };
267
+ return mergePathEntries(env, [
268
+ CLI_BIN_DIR,
269
+ ...collectUserShellPath(baseEnv),
270
+ ...collectKnownUserBinDirs(baseEnv),
271
+ ]);
272
+ }
273
+
144
274
  /**
145
275
  * Cross-platform lookup for the Claude Code CLI executable. The
146
276
  * @anthropic-ai/claude-agent-sdk SDK spawns its target with plain
@@ -301,10 +431,8 @@ export function findExecutableOnPath(name, env = process.env) {
301
431
  paths.push(path.join(env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'Programs', `${name}-code`));
302
432
  paths.push(path.join(env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'AnthropicClaude'));
303
433
  } else {
304
- paths.push(path.join(home, '.local', 'bin'));
305
- paths.push(path.join(home, '.npm-global', 'bin'));
306
- paths.push('/usr/local/bin');
307
- paths.push('/opt/homebrew/bin');
434
+ paths.push(...collectUserShellPath(env));
435
+ paths.push(...collectKnownUserBinDirs(env));
308
436
  }
309
437
 
310
438
  const exts = isWindows
@@ -331,7 +459,7 @@ export function findExecutableOnPath(name, env = process.env) {
331
459
  * more reliable than trusting PATH — when Pixcode runs as a daemon, PATH
332
460
  * is often minimal and doesn't include the user's node install.
333
461
  */
334
- function resolveNpmCommand() {
462
+ function resolveNpmCommand(env = process.env) {
335
463
  const nodeDir = path.dirname(process.execPath);
336
464
  const isWindows = process.platform === 'win32';
337
465
  const candidates = isWindows
@@ -348,8 +476,11 @@ function resolveNpmCommand() {
348
476
  return siblingNpm; // we'll invoke `node <npm-cli.js>`
349
477
  }
350
478
  }
351
- // Fall back to bare name and let the shell resolve.
352
- return isWindows ? 'npm.cmd' : 'npm';
479
+
480
+ const resolvedFromPath = findExecutableOnPath('npm', env);
481
+ if (resolvedFromPath) return resolvedFromPath;
482
+
483
+ return null;
353
484
  }
354
485
 
355
486
  function packageFromCommand(installCmd) {
@@ -406,7 +537,19 @@ export function createInstallJob({ provider, installCmd, packageName }) {
406
537
  appendLog('meta', `Installing ${pkg} into ${CLI_HOME}\n`);
407
538
  appendLog('meta', `(sandboxed — no sudo / admin required)\n`);
408
539
 
409
- const npmCmd = resolveNpmCommand();
540
+ const installEnv = buildCliSpawnEnv(process.env);
541
+ const npmCmd = resolveNpmCommand(installEnv);
542
+ if (!npmCmd) {
543
+ job.status = 'error';
544
+ job.error = 'npm was not found. Install Node.js/npm or add it to your macOS login shell PATH, then click Refresh.';
545
+ job.finishedAt = new Date().toISOString();
546
+ appendLog('stderr', job.error + '\n');
547
+ emitter.emit('done', buildDonePayload(job));
548
+ scheduleCleanup(job);
549
+ jobs.set(id, job);
550
+ return job;
551
+ }
552
+
410
553
  const useNodeRunner = npmCmd.endsWith('.js');
411
554
 
412
555
  const cmd = useNodeRunner ? process.execPath : npmCmd;
@@ -420,7 +563,7 @@ export function createInstallJob({ provider, installCmd, packageName }) {
420
563
  try {
421
564
  child = spawn(cmd, args, {
422
565
  cwd: CLI_HOME,
423
- env: { ...process.env, npm_config_yes: 'true' },
566
+ env: { ...installEnv, npm_config_yes: 'true' },
424
567
  stdio: ['ignore', 'pipe', 'pipe'],
425
568
  windowsHide: true,
426
569
  // cross-spawn handles .cmd/.bat resolution itself — no shell