@pixelbyte-software/pixcode 1.40.5 → 1.40.8

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.
@@ -1,4 +1,4 @@
1
- import { chmod, mkdtemp, writeFile } from 'node:fs/promises';
1
+ import { access, chmod, mkdtemp, readFile, writeFile } from 'node:fs/promises';
2
2
  import { tmpdir } from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
@@ -9,6 +9,7 @@ const read = async (relativePath) => {
9
9
  const { readFile } = await import('node:fs/promises');
10
10
  return readFile(path.join(repoRoot, relativePath), 'utf8');
11
11
  };
12
+ const fileExists = async (filePath) => access(filePath).then(() => true, () => false);
12
13
 
13
14
  const appTypes = await read('src/types/app.ts');
14
15
  assert.ok(
@@ -113,6 +114,12 @@ assert.ok(
113
114
  managedRuntimes.includes("id === 'npm'") && managedRuntimes.includes('installNpmRuntime'),
114
115
  'Managed runtimes should include a Pixcode-owned npm runner for JavaScript projects when npm is not on PATH.',
115
116
  );
117
+ assert.ok(
118
+ managedRuntimes.includes('buildPowerShellExpandArchiveArgs')
119
+ && managedRuntimes.includes('param([string]$archive, [string]$destination)')
120
+ && !managedRuntimes.includes('LiteralPath $args[0]'),
121
+ 'Windows zip extraction should pass archive paths through a PowerShell param block instead of unreliable $args indexing.',
122
+ );
116
123
 
117
124
  const serverIndex = await read('server/index.js');
118
125
  assert.ok(
@@ -134,6 +141,7 @@ const {
134
141
  startLiveView,
135
142
  stopLiveView,
136
143
  } = await import('../../server/services/live-view.js');
144
+ const { ensureManagedRuntime, getManagedRuntimeStatus } = await import('../../server/services/managed-runtimes.js');
137
145
  const workspace = await mkdtemp(path.join(tmpdir(), 'pixcode-live-view-smoke-'));
138
146
  const staticProject = path.join(workspace, 'static');
139
147
  const viteProject = path.join(workspace, 'vite');
@@ -217,6 +225,164 @@ assert.equal(phpSystemRuntimeTarget.available, true, 'PHP projects should stay r
217
225
  assert.equal(phpSystemRuntimeTarget.command?.id, 'frankenphp-php-server', 'PHP projects should still use the Pixcode-managed runtime even when external php exists.');
218
226
  assert.equal(phpSystemRuntimeTarget.managedRuntime?.id, 'frankenphp', 'PHP projects should prefer the Pixcode-owned FrankenPHP runtime instead of external php.');
219
227
 
228
+ const tar = await import('tar');
229
+ const npmPackageRoot = path.join(workspace, 'npm-package-root');
230
+ const npmPackageDir = path.join(npmPackageRoot, 'package');
231
+ await mkdir(path.join(npmPackageDir, 'bin'), { recursive: true });
232
+ await writeFile(path.join(npmPackageDir, 'package.json'), JSON.stringify({ name: 'npm', version: '10.0.0' }));
233
+ await writeFile(path.join(npmPackageDir, 'bin', 'npm-cli.js'), '#!/usr/bin/env node\nconsole.log("npm smoke");\n');
234
+ const npmTarball = path.join(workspace, 'npm-runtime.tgz');
235
+ await tar.c({ cwd: npmPackageRoot, file: npmTarball, gzip: true }, ['package']);
236
+ const npmTarballBuffer = await readFile(npmTarball);
237
+ const originalFetch = globalThis.fetch;
238
+ const metadataAcceptHeaders = [];
239
+ globalThis.fetch = async (url, options = {}) => {
240
+ const requestUrl = String(url);
241
+ const headers = options.headers || {};
242
+ const accept = typeof headers.get === 'function' ? headers.get('Accept') : headers.Accept;
243
+ if (requestUrl === 'https://registry.test/npm/latest') {
244
+ metadataAcceptHeaders.push(String(accept || ''));
245
+ if (!String(accept || '').includes('application/json')) {
246
+ return new Response(JSON.stringify({ error: 'not acceptable' }), { status: 406 });
247
+ }
248
+ return new Response(JSON.stringify({
249
+ version: '10.0.0',
250
+ dist: { tarball: 'https://registry.test/npm/-/npm-10.0.0.tgz' },
251
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
252
+ }
253
+ if (requestUrl === 'https://registry.test/npm/-/npm-10.0.0.tgz') {
254
+ return new Response(npmTarballBuffer, { status: 200 });
255
+ }
256
+ return originalFetch(url, options);
257
+ };
258
+ try {
259
+ const npmRuntime = await ensureManagedRuntime('npm', {
260
+ preferManaged: true,
261
+ env: {
262
+ ...process.env,
263
+ PATH: '',
264
+ Path: '',
265
+ PIXCODE_MANAGED_RUNTIMES_HOME: path.join(workspace, 'managed-runtimes'),
266
+ PIXCODE_NPM_RUNTIME_REGISTRY: 'https://registry.test/npm/latest',
267
+ },
268
+ });
269
+ assert.equal(npmRuntime.status, 'installed', 'Managed npm runtime should install from npm registry metadata.');
270
+ assert.ok(
271
+ metadataAcceptHeaders.every((accept) => accept.includes('application/json')),
272
+ 'Managed npm runtime metadata requests should use an npm-compatible JSON Accept header.',
273
+ );
274
+ } finally {
275
+ globalThis.fetch = originalFetch;
276
+ }
277
+
278
+ const depsProject = path.join(workspace, 'vite-needs-install');
279
+ await mkdir(depsProject, { recursive: true });
280
+ await writeFile(path.join(depsProject, 'package.json'), JSON.stringify({
281
+ scripts: { dev: 'vite --host 127.0.0.1' },
282
+ dependencies: { vite: '^7.0.0' },
283
+ }, null, 2));
284
+ const fakeNpmCli = path.join(workspace, 'fake-npm-cli.js');
285
+ await writeFile(fakeNpmCli, [
286
+ '#!/usr/bin/env node',
287
+ 'const fs = require("node:fs");',
288
+ 'const path = require("node:path");',
289
+ 'if (process.argv[2] === "install") {',
290
+ ' fs.mkdirSync(path.join(process.cwd(), "node_modules", ".bin"), { recursive: true });',
291
+ ' fs.writeFileSync(path.join(process.cwd(), "node_modules", ".bin", process.platform === "win32" ? "vite.cmd" : "vite"), "ok");',
292
+ ' process.exit(0);',
293
+ '}',
294
+ 'process.exit(0);',
295
+ '',
296
+ ].join('\n'));
297
+ if (process.platform !== 'win32') {
298
+ await chmod(fakeNpmCli, 0o755);
299
+ }
300
+ const { preparePackageDependencies } = await import('../../server/services/live-view.js');
301
+ const prepLogs = [];
302
+ await preparePackageDependencies(
303
+ depsProject,
304
+ {
305
+ id: 'npm-dev-vite',
306
+ label: 'Vite dev server',
307
+ framework: 'Vite',
308
+ packageManager: 'npm',
309
+ scriptName: 'dev',
310
+ command: process.execPath,
311
+ args: [fakeNpmCli, 'run', 'dev'],
312
+ displayCommand: 'npm run dev',
313
+ managedRuntime: { id: 'npm', status: 'installed' },
314
+ },
315
+ process.env,
316
+ (line) => prepLogs.push(line),
317
+ );
318
+ assert.ok(
319
+ await fileExists(path.join(depsProject, 'node_modules', '.bin', process.platform === 'win32' ? 'vite.cmd' : 'vite')),
320
+ 'Live View should install missing Vite project dependencies before running npm dev.',
321
+ );
322
+ assert.ok(
323
+ prepLogs.some((line) => line.includes('Installing project dependencies')),
324
+ 'Live View should log dependency preparation before launching package scripts.',
325
+ );
326
+
327
+ const frankenPackageRoot = path.join(workspace, 'frankenphp-package-root');
328
+ const frankenPackageDir = path.join(frankenPackageRoot, 'package');
329
+ await mkdir(frankenPackageDir, { recursive: true });
330
+ await writeFile(path.join(frankenPackageDir, process.platform === 'win32' ? 'frankenphp.exe' : 'frankenphp'), '#!/bin/sh\nexit 0\n');
331
+ await writeFile(path.join(frankenPackageDir, 'sidecar-runtime.dll'), 'sidecar');
332
+ if (process.platform !== 'win32') {
333
+ await chmod(path.join(frankenPackageDir, 'frankenphp'), 0o755);
334
+ }
335
+ const frankenTarball = path.join(workspace, 'frankenphp-runtime.tgz');
336
+ await tar.c({ cwd: frankenPackageRoot, file: frankenTarball, gzip: true }, ['package']);
337
+ const frankenTarballBuffer = await readFile(frankenTarball);
338
+ globalThis.fetch = async (url) => {
339
+ if (String(url) === 'https://runtime.test/frankenphp-runtime.tgz') {
340
+ return new Response(frankenTarballBuffer, { status: 200 });
341
+ }
342
+ return originalFetch(url);
343
+ };
344
+ try {
345
+ const frankenHome = path.join(workspace, 'managed-frankenphp');
346
+ const frankenRuntime = await ensureManagedRuntime('frankenphp', {
347
+ preferManaged: true,
348
+ env: {
349
+ ...process.env,
350
+ PIXCODE_MANAGED_RUNTIMES_HOME: frankenHome,
351
+ PIXCODE_FRANKENPHP_URL: 'https://runtime.test/frankenphp-runtime.tgz',
352
+ },
353
+ });
354
+ assert.equal(frankenRuntime.status, 'installed', 'Managed FrankenPHP runtime should install from the downloaded archive.');
355
+ assert.ok(
356
+ await fileExists(path.join(frankenHome, 'frankenphp', 'current', 'sidecar-runtime.dll')),
357
+ 'Managed FrankenPHP install should preserve sidecar DLLs/files next to the executable.',
358
+ );
359
+ } finally {
360
+ globalThis.fetch = originalFetch;
361
+ }
362
+
363
+ const brokenRuntimeHome = path.join(workspace, 'broken-frankenphp');
364
+ const brokenCurrent = path.join(brokenRuntimeHome, 'frankenphp', 'current');
365
+ await mkdir(brokenCurrent, { recursive: true });
366
+ const brokenExecutable = path.join(brokenCurrent, process.platform === 'win32' ? 'frankenphp.cmd' : 'frankenphp');
367
+ await writeFile(brokenExecutable, process.platform === 'win32' ? '@echo off\r\nexit /b 1\r\n' : '#!/bin/sh\nexit 1\n');
368
+ if (process.platform !== 'win32') {
369
+ await chmod(brokenExecutable, 0o755);
370
+ }
371
+ await writeFile(path.join(brokenRuntimeHome, 'frankenphp', 'pixcode-runtime.json'), JSON.stringify({
372
+ id: 'frankenphp',
373
+ label: 'Pixcode PHP runtime',
374
+ executablePath: brokenExecutable,
375
+ version: 'broken',
376
+ }, null, 2));
377
+ const brokenStatus = await getManagedRuntimeStatus('frankenphp', {
378
+ preferManaged: true,
379
+ env: {
380
+ ...process.env,
381
+ PIXCODE_MANAGED_RUNTIMES_HOME: brokenRuntimeHome,
382
+ },
383
+ });
384
+ assert.equal(brokenStatus.status, 'missing', 'Broken managed FrankenPHP manifests should be treated as missing so Pixcode can reinstall them.');
385
+
220
386
  const staticSession = await startLiveView('static-smoke', staticProject);
221
387
  assert.equal(staticSession.status, 'running', 'Static Live View should start without a child process.');
222
388
  assert.match(staticSession.sharePath, /^\/live\/[a-f0-9]{24}\/$/, 'Live View should expose a random public share path.');
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+
3
+ import assert from 'node:assert/strict';
4
+ import { readFileSync } from 'node:fs';
5
+
6
+ const page = readFileSync('src/components/orchestration/OrchestrationPage.tsx', 'utf8');
7
+ const runner = readFileSync('server/modules/orchestration/workflows/workflow-runner.ts', 'utf8');
8
+
9
+ assert.ok(
10
+ page.includes("from '../../hooks/useProviderModels'") || page.includes('from "@/hooks/useProviderModels"'),
11
+ 'Orchestration page should use the same live provider model catalog hook as chat.',
12
+ );
13
+
14
+ assert.ok(
15
+ page.includes('providerModelCatalogs') && page.includes('sanitizeAgentModel'),
16
+ 'Orchestration agents should sanitize stale selected models against the live catalog before starting runs.',
17
+ );
18
+
19
+ assert.ok(
20
+ runner.includes('resolveWorkflowModel') && runner.includes('modelCatalogsByProvider'),
21
+ 'Workflow runner should validate provider models server-side before submitting A2A tasks.',
22
+ );
23
+
24
+ assert.ok(
25
+ runner.includes('Original user request')
26
+ && runner.indexOf('Original user request') < runner.lastIndexOf('workspaceContextPrompt(workspaceTarget)'),
27
+ 'Workflow runner should place a labeled original user request before workspace context so agents do not answer the context header.',
28
+ );
29
+
30
+ console.log('orchestration model sync smoke passed');
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+
3
+ import assert from 'node:assert/strict';
4
+ import { mkdtemp } from 'node:fs/promises';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+
8
+ const tempHome = await mkdtemp(path.join(os.tmpdir(), 'pixcode-provider-models-'));
9
+ process.env.HOME = tempHome;
10
+ process.env.OPENCODE_MODELS_URL = 'https://models.dev/test-api.json';
11
+
12
+ const calls = [];
13
+ globalThis.fetch = async (url) => {
14
+ calls.push(String(url));
15
+ return {
16
+ ok: true,
17
+ status: 200,
18
+ async json() {
19
+ return {
20
+ opencode: {
21
+ name: 'OpenCode Zen',
22
+ models: {
23
+ 'big-pickle': {
24
+ name: 'Big Pickle',
25
+ cost: { input: 0, output: 0 },
26
+ limit: { context: 128000 },
27
+ },
28
+ 'fresh-free': {
29
+ name: 'Fresh Free',
30
+ cost: { input: 0, output: 0 },
31
+ limit: { context: 64000 },
32
+ },
33
+ },
34
+ },
35
+ };
36
+ },
37
+ };
38
+ };
39
+
40
+ const { getProviderModels } = await import('../../server/services/provider-models.js');
41
+
42
+ const staleStatic = [
43
+ { value: 'opencode/hy3-preview-free', label: 'Stale Hy3 Preview' },
44
+ { value: 'opencode/ling-2.6-flash-free', label: 'Stale Ling 2.6 Flash' },
45
+ ];
46
+
47
+ const refreshed = await getProviderModels('opencode', {
48
+ forceRefresh: true,
49
+ staticList: staleStatic,
50
+ });
51
+ const refreshedValues = refreshed.models.map((model) => model.value);
52
+ assert.equal(calls.length, 1, 'force refresh should fetch the OpenCode live catalog once');
53
+ assert.ok(refreshedValues.includes('opencode/fresh-free'), 'live OpenCode models should be returned');
54
+ assert.ok(!refreshedValues.includes('opencode/hy3-preview-free'), 'stale static OpenCode models must not be merged into a successful live catalog');
55
+ assert.ok(!refreshedValues.includes('opencode/ling-2.6-flash-free'), 'stale static OpenCode models must not survive a successful live refresh');
56
+
57
+ const cached = await getProviderModels('opencode', {
58
+ staticList: staleStatic,
59
+ });
60
+ const cachedValues = cached.models.map((model) => model.value);
61
+ assert.equal(calls.length, 1, 'fresh cache should be used without another network request');
62
+ assert.ok(cachedValues.includes('opencode/fresh-free'), 'cached live OpenCode models should be returned');
63
+ assert.ok(!cachedValues.includes('opencode/hy3-preview-free'), 'cached live OpenCode catalog must stay free of stale static models');
64
+ assert.ok(!cachedValues.includes('opencode/ling-2.6-flash-free'), 'cached live OpenCode catalog must stay free of stale static models');
65
+
66
+ console.log('provider OpenCode live model catalog smoke passed');
@@ -13,6 +13,17 @@ import {
13
13
  workspaceTargetMetadata,
14
14
  } from '@/modules/orchestration/workflows/workspace-target.js';
15
15
  import { workflowStore } from '@/modules/orchestration/workflows/workflow-store.js';
16
+ // @ts-ignore — plain-JS service
17
+ import { getProviderModels } from '@/services/provider-models.js';
18
+
19
+ import {
20
+ CLAUDE_MODELS,
21
+ CODEX_MODELS,
22
+ CURSOR_MODELS,
23
+ GEMINI_MODELS,
24
+ OPENCODE_MODELS,
25
+ QWEN_MODELS,
26
+ } from '../../../../shared/modelConstants.js';
16
27
 
17
28
  const TERMINAL = new Set(['completed', 'failed', 'canceled']);
18
29
  const SKIPPED = 'skipped';
@@ -106,6 +117,34 @@ type AgentAssignment = {
106
117
 
107
118
  type KnownAgentRole = typeof KNOWN_AGENT_ROLES[number];
108
119
  type AgentRole = string;
120
+ type ProviderId = 'claude' | 'cursor' | 'codex' | 'gemini' | 'qwen' | 'opencode';
121
+ type ProviderModel = {
122
+ value: string;
123
+ label?: string;
124
+ source?: 'static' | 'api';
125
+ free?: boolean;
126
+ };
127
+
128
+ const adapterProviderMap: Record<string, ProviderId | undefined> = {
129
+ 'claude-code': 'claude',
130
+ cursor: 'cursor',
131
+ codex: 'codex',
132
+ gemini: 'gemini',
133
+ qwen: 'qwen',
134
+ opencode: 'opencode',
135
+ };
136
+
137
+ const modelCatalogsByProvider: Record<ProviderId, {
138
+ staticList: ProviderModel[];
139
+ defaultModel?: string;
140
+ }> = {
141
+ claude: { staticList: CLAUDE_MODELS.OPTIONS, defaultModel: CLAUDE_MODELS.DEFAULT },
142
+ cursor: { staticList: CURSOR_MODELS.OPTIONS, defaultModel: CURSOR_MODELS.DEFAULT },
143
+ codex: { staticList: CODEX_MODELS.OPTIONS, defaultModel: CODEX_MODELS.DEFAULT },
144
+ gemini: { staticList: GEMINI_MODELS.OPTIONS, defaultModel: GEMINI_MODELS.DEFAULT },
145
+ qwen: { staticList: QWEN_MODELS.OPTIONS, defaultModel: QWEN_MODELS.DEFAULT },
146
+ opencode: { staticList: OPENCODE_MODELS.OPTIONS, defaultModel: OPENCODE_MODELS.DEFAULT },
147
+ };
109
148
 
110
149
  function readAgentRole(value: unknown): AgentRole | undefined {
111
150
  return typeof value === 'string' && value.trim() && value.trim() !== 'auto'
@@ -133,6 +172,44 @@ function readBoolean(value: unknown): boolean | undefined {
133
172
  return typeof value === 'boolean' ? value : undefined;
134
173
  }
135
174
 
175
+ function modelValueSet(models: ProviderModel[]): Set<string> {
176
+ return new Set(models.map((model) => model.value).filter(Boolean));
177
+ }
178
+
179
+ function preferredFallbackModel(models: ProviderModel[], defaultModel?: string): string | undefined {
180
+ const values = modelValueSet(models);
181
+ if (defaultModel && values.has(defaultModel)) return defaultModel;
182
+ return models.find((model) => model.source === 'api' && model.free)?.value
183
+ ?? models.find((model) => model.source === 'api')?.value
184
+ ?? models.find((model) => model.free)?.value
185
+ ?? models[0]?.value
186
+ ?? defaultModel;
187
+ }
188
+
189
+ async function resolveWorkflowModel(adapterId: string, requestedModel?: string): Promise<string | undefined> {
190
+ const provider = adapterProviderMap[adapterId];
191
+ if (!provider) return requestedModel;
192
+
193
+ const catalog = modelCatalogsByProvider[provider];
194
+ if (!requestedModel) return catalog.defaultModel;
195
+
196
+ try {
197
+ const result = await getProviderModels(provider, {
198
+ staticList: catalog.staticList,
199
+ });
200
+ const models = Array.isArray(result?.models) ? result.models as ProviderModel[] : [];
201
+ if (modelValueSet(models).has(requestedModel)) {
202
+ return requestedModel;
203
+ }
204
+ return preferredFallbackModel(models, catalog.defaultModel) ?? requestedModel;
205
+ } catch {
206
+ const staticValues = modelValueSet(catalog.staticList);
207
+ return staticValues.has(requestedModel)
208
+ ? requestedModel
209
+ : preferredFallbackModel(catalog.staticList, catalog.defaultModel) ?? requestedModel;
210
+ }
211
+ }
212
+
136
213
  function readIsolation(value: unknown): 'host' | 'worktree' | 'docker' | undefined {
137
214
  return value === 'host' || value === 'worktree' || value === 'docker' ? value : undefined;
138
215
  }
@@ -1408,15 +1485,42 @@ class WorkflowRunner {
1408
1485
 
1409
1486
  const inputContext = node.inputs.map((input) => outputs.get(input)).filter(Boolean).join('\n\n');
1410
1487
  const workspaceTarget = resolveWorkflowWorkspace(run.metadata);
1411
- const prompt = [workspaceContextPrompt(workspaceTarget), run.input, inputContext, node.prompt]
1412
- .filter(Boolean)
1413
- .join('\n\n');
1488
+ const prompt = [
1489
+ 'Original user request (primary task; answer this directly even if the workspace is empty):',
1490
+ run.input?.trim() || '(No original user request was provided.)',
1491
+ inputContext
1492
+ ? `Upstream workflow context from prior agents:\n${inputContext}`
1493
+ : '',
1494
+ `Current workflow step instructions:\n${node.prompt}`,
1495
+ workspaceContextPrompt(workspaceTarget),
1496
+ ].filter(Boolean).join('\n\n');
1414
1497
  const settings = getMetadataRecord(run.metadata, 'settings');
1415
1498
  const projectPath = workspaceTarget.projectPath;
1416
1499
  const isolation = readIsolation(settings.isolation) ?? node.isolation ?? 'host';
1417
1500
  const keepAfterCompletion = readBoolean(settings.keepWorkspace) ?? true;
1418
1501
  const baseRef = readString(settings.baseRef) ?? 'HEAD';
1419
1502
  const effectivePermissionMode = resolveNodePermissionMode(node, workspaceTarget);
1503
+ const effectiveModel = await resolveWorkflowModel(node.adapterId, node.model);
1504
+ if (effectiveModel !== node.model) {
1505
+ nodeRun.model = effectiveModel;
1506
+ const modelFallbackEvents = Array.isArray(run.metadata?.modelFallbackEvents)
1507
+ ? run.metadata.modelFallbackEvents
1508
+ : [];
1509
+ run.metadata = {
1510
+ ...run.metadata,
1511
+ modelFallbackEvents: [
1512
+ ...modelFallbackEvents,
1513
+ {
1514
+ nodeId: node.id,
1515
+ adapterId: node.adapterId,
1516
+ requestedModel: node.model,
1517
+ effectiveModel,
1518
+ changedAt: Date.now(),
1519
+ },
1520
+ ],
1521
+ };
1522
+ workflowStore.setRun(run);
1523
+ }
1420
1524
  let body: { id?: string; error?: { message?: string } };
1421
1525
  try {
1422
1526
  const submit = await fetch(`${localA2ABaseUrl()}/tasks`, {
@@ -1436,7 +1540,7 @@ class WorkflowRunner {
1436
1540
  agentInstanceId: node.agentInstanceId,
1437
1541
  agentLabel: node.agentLabel,
1438
1542
  assignment: node.assignment,
1439
- model: node.model,
1543
+ model: effectiveModel,
1440
1544
  permissionMode: effectivePermissionMode,
1441
1545
  toolsSettings: node.toolsSettings,
1442
1546
  projectPath,
@@ -208,6 +208,120 @@ function buildManagedPackageCommand(command, runtimeStatus) {
208
208
  };
209
209
  }
210
210
 
211
+ function packageBinCandidates(binName) {
212
+ return process.platform === 'win32'
213
+ ? [binName, `${binName}.cmd`, `${binName}.ps1`, `${binName}.exe`]
214
+ : [binName];
215
+ }
216
+
217
+ function expectedPackageBin(command) {
218
+ if (command.framework === 'Vite' || command.id === 'npm-dev-vite') return 'vite';
219
+ if (command.framework === 'Next.js' || command.id === 'npm-dev-next') return 'next';
220
+ if (command.framework === 'Nuxt' || command.id === 'npm-dev-nuxt') return 'nuxt';
221
+ if (command.framework === 'Astro' || command.id === 'npm-dev-astro') return 'astro';
222
+ return null;
223
+ }
224
+
225
+ async function packageDependenciesReady(projectPath, command) {
226
+ if (!command?.scriptName && !command?.packageManager) return true;
227
+ if (!(await dirExists(path.join(projectPath, 'node_modules')))) return false;
228
+
229
+ const binName = expectedPackageBin(command);
230
+ if (!binName) return true;
231
+
232
+ const binDir = path.join(projectPath, 'node_modules', '.bin');
233
+ for (const candidate of packageBinCandidates(binName)) {
234
+ if (await fileExists(path.join(binDir, candidate))) return true;
235
+ }
236
+ return false;
237
+ }
238
+
239
+ function packageInstallInvocation(command) {
240
+ if (!command || command.packageManager !== 'npm') return null;
241
+
242
+ if (
243
+ command.managedRuntime?.id === 'npm'
244
+ && command.args?.[0]
245
+ && String(command.args[0]).endsWith('npm-cli.js')
246
+ ) {
247
+ return {
248
+ command: command.command,
249
+ args: [command.args[0], 'install', '--no-audit', '--no-fund'],
250
+ displayCommand: 'npm install --no-audit --no-fund',
251
+ };
252
+ }
253
+
254
+ if (command.command === 'npm' || path.basename(command.command || '').startsWith('npm')) {
255
+ return {
256
+ command: command.command,
257
+ args: ['install', '--no-audit', '--no-fund'],
258
+ displayCommand: 'npm install --no-audit --no-fund',
259
+ };
260
+ }
261
+
262
+ return null;
263
+ }
264
+
265
+ function runPrepProcess(command, args, options = {}) {
266
+ return new Promise((resolve, reject) => {
267
+ let settled = false;
268
+ const child = spawn(command, args, {
269
+ cwd: options.cwd,
270
+ env: options.env,
271
+ shell: shouldUseShell({ command }),
272
+ stdio: ['ignore', 'pipe', 'pipe'],
273
+ windowsHide: true,
274
+ });
275
+ const timeoutMs = Number.parseInt(process.env.PIXCODE_LIVE_VIEW_INSTALL_TIMEOUT_MS || '', 10) || 300000;
276
+ const finish = (callback, value) => {
277
+ if (settled) return;
278
+ settled = true;
279
+ clearTimeout(timer);
280
+ callback(value);
281
+ };
282
+ const timer = setTimeout(() => {
283
+ try {
284
+ child.kill();
285
+ } catch {
286
+ // Process may already be gone.
287
+ }
288
+ finish(reject, new Error(`Dependency install timed out after ${Math.round(timeoutMs / 1000)}s.`));
289
+ }, timeoutMs);
290
+
291
+ child.stdout.on('data', (chunk) => {
292
+ chunk.toString().split(/\r?\n/).forEach((line) => options.onLog?.(line));
293
+ });
294
+ child.stderr.on('data', (chunk) => {
295
+ chunk.toString().split(/\r?\n/).forEach((line) => options.onLog?.(line));
296
+ });
297
+ child.on('error', (error) => finish(reject, error));
298
+ child.on('exit', (code, signal) => {
299
+ if (code === 0) {
300
+ finish(resolve);
301
+ return;
302
+ }
303
+ finish(reject, new Error(`Dependency install exited with ${signal || `code ${code}`}.`));
304
+ });
305
+ });
306
+ }
307
+
308
+ export async function preparePackageDependencies(projectPath, command, env = process.env, onLog = () => {}) {
309
+ if (!command || command.packageManager !== 'npm') return false;
310
+ if (await packageDependenciesReady(projectPath, command)) return false;
311
+
312
+ const install = packageInstallInvocation(command);
313
+ if (!install) return false;
314
+
315
+ onLog(`Installing project dependencies: ${install.displayCommand}`);
316
+ await runPrepProcess(install.command, install.args, {
317
+ cwd: projectPath,
318
+ env,
319
+ onLog,
320
+ });
321
+ onLog('Project dependencies are ready.');
322
+ return true;
323
+ }
324
+
211
325
  function buildManagedPhpCommand(runtimeStatus) {
212
326
  const executable = runtimeStatus?.executablePath || 'frankenphp';
213
327
  return {
@@ -718,6 +832,20 @@ export async function startLiveView(projectName, projectPath, options = {}) {
718
832
  BROWSER: 'none',
719
833
  NEXT_TELEMETRY_DISABLED: '1',
720
834
  };
835
+
836
+ sessionsByProject.set(projectName, session);
837
+ sessionsByShareId.set(shareId, session);
838
+
839
+ try {
840
+ await preparePackageDependencies(projectPath, command, env, (line) => appendLog(session, line));
841
+ } catch (error) {
842
+ session.status = 'error';
843
+ session.stoppedAt = new Date().toISOString();
844
+ session.error = error instanceof Error ? error.message : String(error);
845
+ appendLog(session, session.error);
846
+ return publicSession(session);
847
+ }
848
+
721
849
  const child = spawn(command.command, command.args, {
722
850
  cwd: projectPath,
723
851
  env,
@@ -726,8 +854,6 @@ export async function startLiveView(projectName, projectPath, options = {}) {
726
854
  });
727
855
 
728
856
  session.child = child;
729
- sessionsByProject.set(projectName, session);
730
- sessionsByShareId.set(shareId, session);
731
857
 
732
858
  child.stdout.on('data', (chunk) => {
733
859
  chunk.toString().split(/\r?\n/).forEach((line) => appendLog(session, line));