@pixelbyte-software/pixcode 1.40.3 → 1.40.5

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.
@@ -4,6 +4,9 @@ import { promises as fs } from 'node:fs';
4
4
  import net from 'node:net';
5
5
  import path from 'node:path';
6
6
 
7
+ import { buildCliSpawnEnv } from './install-jobs.js';
8
+ import { ensureManagedRuntime, getManagedRuntimeStatus } from './managed-runtimes.js';
9
+
7
10
  const sessionsByProject = new Map();
8
11
  const sessionsByShareId = new Map();
9
12
  const READY_TIMEOUT_MS = 12000;
@@ -92,17 +95,17 @@ function isPathLikeCommand(command) {
92
95
  }
93
96
 
94
97
  function runtimeMissingReason(command, framework) {
95
- const base = `${command} executable was not found in PATH.`;
98
+ const base = `${command} is not available on this machine.`;
96
99
  if (framework === 'PHP' || command === 'php') {
97
- return `${base} Install PHP and add php.exe to PATH, or use a custom command with the full PHP executable path.`;
100
+ return 'Pixcode can prepare a local PHP runtime automatically before starting this project.';
98
101
  }
99
102
  if (command === 'npm' || command === 'pnpm' || command === 'yarn' || command === 'bun') {
100
- return `${base} Install Node.js/package manager support or use a custom command with the full executable path.`;
103
+ return `${base} Pixcode can prepare a local Node package runner automatically before starting this project.`;
101
104
  }
102
105
  if (command === 'python' || command === 'python3') {
103
- return `${base} Install Python and add it to PATH, or use a custom command with the full Python executable path.`;
106
+ return `${base} Pixcode does not have a managed Python runtime for this stack yet.`;
104
107
  }
105
- return `${base} Install ${framework || command} or use a custom command with the full executable path.`;
108
+ return `${base} Pixcode does not have a managed ${framework || command} runtime for this stack yet.`;
106
109
  }
107
110
 
108
111
  async function checkCommandAvailability(command, env = process.env) {
@@ -167,12 +170,63 @@ function buildPackageCommand(packageManager, scriptName, id, label, framework, e
167
170
  id,
168
171
  label,
169
172
  framework,
173
+ packageManager,
174
+ scriptName,
175
+ extraArgs,
170
176
  command: packageManager,
171
177
  args,
172
178
  displayCommand: buildDisplayCommand(packageManager, args),
173
179
  };
174
180
  }
175
181
 
182
+ function isPackageManagerCommand(command) {
183
+ return command === 'npm' || command === 'pnpm' || command === 'yarn' || command === 'bun';
184
+ }
185
+
186
+ function buildManagedPackageCommand(command, runtimeStatus) {
187
+ const npmArgs = command.scriptName
188
+ ? packageRunArgs('npm', command.scriptName, command.extraArgs || [])
189
+ : command.args;
190
+ const runtimeExecutable = runtimeStatus?.executablePath || null;
191
+ const commandExecutable = runtimeExecutable
192
+ ? (runtimeStatus?.runner === 'node' || runtimeExecutable.endsWith('.js') ? process.execPath : runtimeExecutable)
193
+ : command.command;
194
+ const args = runtimeExecutable && (runtimeStatus?.runner === 'node' || runtimeExecutable.endsWith('.js'))
195
+ ? [runtimeExecutable, ...npmArgs]
196
+ : npmArgs;
197
+
198
+ return {
199
+ ...command,
200
+ packageManager: 'npm',
201
+ command: commandExecutable,
202
+ args,
203
+ displayCommand: buildDisplayCommand('npm', npmArgs),
204
+ managedRuntime: {
205
+ id: 'npm',
206
+ status: runtimeStatus?.status || 'missing',
207
+ },
208
+ };
209
+ }
210
+
211
+ function buildManagedPhpCommand(runtimeStatus) {
212
+ const executable = runtimeStatus?.executablePath || 'frankenphp';
213
+ return {
214
+ id: 'frankenphp-php-server',
215
+ label: 'Pixcode PHP runtime',
216
+ framework: 'PHP',
217
+ command: executable,
218
+ args: ['php-server', '-r', '.'],
219
+ displayCommand: `${executable} php-server -r .`,
220
+ env: {
221
+ SERVER_NAME: 'http://127.0.0.1:$PORT',
222
+ },
223
+ managedRuntime: {
224
+ id: 'frankenphp',
225
+ status: runtimeStatus?.status || 'missing',
226
+ },
227
+ };
228
+ }
229
+
176
230
  function detectPackageCommand(packageJson, packageManager) {
177
231
  const scripts = packageJson.scripts || {};
178
232
  const devScript = String(scripts.dev || '');
@@ -243,9 +297,22 @@ function withPort(command, port) {
243
297
  ...command,
244
298
  args: command.args.map((arg) => arg.replaceAll('$PORT', String(port))),
245
299
  displayCommand: command.displayCommand.replaceAll('$PORT', String(port)),
300
+ env: command.env
301
+ ? Object.fromEntries(Object.entries(command.env).map(([key, value]) => [
302
+ key,
303
+ String(value).replaceAll('$PORT', String(port)),
304
+ ]))
305
+ : undefined,
246
306
  };
247
307
  }
248
308
 
309
+ function shouldUseShell(command) {
310
+ if (command.shell) return true;
311
+ if (process.platform !== 'win32') return false;
312
+ if (path.isAbsolute(command.command) && command.command.toLowerCase().endsWith('.exe')) return false;
313
+ return true;
314
+ }
315
+
249
316
  async function detectStaticRoot(projectPath) {
250
317
  const candidates = [
251
318
  projectPath,
@@ -380,6 +447,44 @@ export async function detectLiveViewTarget(projectPath, options = {}) {
380
447
 
381
448
  const processCommand = await detectProcessCommand(projectPath);
382
449
  if (processCommand) {
450
+ if (isPackageManagerCommand(processCommand.command)) {
451
+ const managedRuntime = await getManagedRuntimeStatus('npm', {
452
+ env: options.env || process.env,
453
+ preferManaged: true,
454
+ });
455
+ const command = buildManagedPackageCommand(processCommand, managedRuntime);
456
+ return {
457
+ available: true,
458
+ kind: 'process',
459
+ label: processCommand.label,
460
+ framework: processCommand.framework,
461
+ command,
462
+ managedRuntime,
463
+ reason: managedRuntime.status === 'missing'
464
+ ? 'Pixcode will prepare a local Node package runner automatically before starting this project.'
465
+ : 'Pixcode will run this project with its managed Node package runner.',
466
+ };
467
+ }
468
+
469
+ if (processCommand.framework === 'PHP' || processCommand.command === 'php') {
470
+ const managedRuntime = await getManagedRuntimeStatus('frankenphp', {
471
+ env: options.env || process.env,
472
+ preferManaged: true,
473
+ });
474
+ const command = buildManagedPhpCommand(managedRuntime);
475
+ return {
476
+ available: true,
477
+ kind: 'process',
478
+ label: command.label,
479
+ framework: command.framework,
480
+ command,
481
+ managedRuntime,
482
+ reason: managedRuntime.status === 'missing'
483
+ ? 'Pixcode will prepare a local PHP runtime automatically before starting this project.'
484
+ : 'Pixcode will run this project with its managed PHP runtime.',
485
+ };
486
+ }
487
+
383
488
  const runtimeAvailable = await checkCommandAvailability(processCommand.command, options.env || process.env);
384
489
  if (!runtimeAvailable) {
385
490
  return {
@@ -483,6 +588,7 @@ function publicSession(session) {
483
588
  label: session.command.label,
484
589
  displayCommand: session.command.displayCommand,
485
590
  } : null,
591
+ managedRuntime: session.managedRuntime || null,
486
592
  port: session.port,
487
593
  upstreamUrl: session.upstreamUrl,
488
594
  startedAt: session.startedAt,
@@ -564,7 +670,20 @@ export async function startLiveView(projectName, projectPath, options = {}) {
564
670
  }
565
671
 
566
672
  const port = await findFreePort();
567
- const command = withPort(target.command, port);
673
+ let runtimeStatus = target.managedRuntime || target.command?.managedRuntime || null;
674
+ let targetCommand = target.command;
675
+ if (runtimeStatus?.id && runtimeStatus.status !== 'system' && runtimeStatus.status !== 'installed') {
676
+ runtimeStatus = await ensureManagedRuntime(runtimeStatus.id, {
677
+ preferManaged: runtimeStatus.id === 'frankenphp' || runtimeStatus.id === 'npm',
678
+ });
679
+ if (runtimeStatus.id === 'frankenphp') {
680
+ targetCommand = buildManagedPhpCommand(runtimeStatus);
681
+ } else if (runtimeStatus.id === 'npm') {
682
+ targetCommand = buildManagedPackageCommand(targetCommand, runtimeStatus);
683
+ }
684
+ }
685
+
686
+ const command = withPort(targetCommand, port);
568
687
  const session = {
569
688
  projectName,
570
689
  projectPath,
@@ -574,6 +693,7 @@ export async function startLiveView(projectName, projectPath, options = {}) {
574
693
  framework: target.framework,
575
694
  label: target.label,
576
695
  command,
696
+ managedRuntime: runtimeStatus,
577
697
  port,
578
698
  host: '127.0.0.1',
579
699
  upstreamUrl: `http://127.0.0.1:${port}`,
@@ -588,7 +708,9 @@ export async function startLiveView(projectName, projectPath, options = {}) {
588
708
  };
589
709
 
590
710
  const env = {
591
- ...process.env,
711
+ ...buildCliSpawnEnv(process.env),
712
+ ...(command.env || {}),
713
+ ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}),
592
714
  PORT: String(port),
593
715
  HOST: '127.0.0.1',
594
716
  VITE_HOST: '127.0.0.1',
@@ -599,7 +721,7 @@ export async function startLiveView(projectName, projectPath, options = {}) {
599
721
  const child = spawn(command.command, command.args, {
600
722
  cwd: projectPath,
601
723
  env,
602
- shell: Boolean(command.shell) || process.platform === 'win32',
724
+ shell: shouldUseShell(command),
603
725
  stdio: ['ignore', 'pipe', 'pipe'],
604
726
  });
605
727
 
@@ -0,0 +1,439 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { promises as fs } from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ import * as tar from 'tar';
7
+
8
+ import { buildCliSpawnEnv, findExecutableOnPath, resolveNpmCommand } from './install-jobs.js';
9
+
10
+ const DEFAULT_RUNTIMES_HOME = path.join(os.homedir(), '.pixcode', 'runtimes');
11
+ const MANIFEST_FILE = 'pixcode-runtime.json';
12
+ const installLocks = new Map();
13
+
14
+ function runtimesHome(env = process.env) {
15
+ return env.PIXCODE_MANAGED_RUNTIMES_HOME || DEFAULT_RUNTIMES_HOME;
16
+ }
17
+
18
+ function runtimeDir(id, env = process.env) {
19
+ return path.join(runtimesHome(env), id);
20
+ }
21
+
22
+ function manifestPath(id, env = process.env) {
23
+ return path.join(runtimeDir(id, env), MANIFEST_FILE);
24
+ }
25
+
26
+ async function fileExists(filePath) {
27
+ try {
28
+ const stats = await fs.stat(filePath);
29
+ return stats.isFile();
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ async function readManifest(id, env = process.env) {
36
+ try {
37
+ const content = await fs.readFile(manifestPath(id, env), 'utf8');
38
+ const manifest = JSON.parse(content);
39
+ if (manifest?.executablePath && await fileExists(manifest.executablePath)) {
40
+ return manifest;
41
+ }
42
+ } catch {
43
+ // Missing or malformed manifests are treated as not installed.
44
+ }
45
+ return null;
46
+ }
47
+
48
+ function platformTokens() {
49
+ if (process.platform === 'win32') return ['windows'];
50
+ if (process.platform === 'darwin') return ['mac', 'darwin'];
51
+ if (process.platform === 'linux') return ['linux'];
52
+ return [process.platform];
53
+ }
54
+
55
+ function archTokens() {
56
+ if (process.arch === 'x64') return ['x86_64', 'amd64', 'x64'];
57
+ if (process.arch === 'arm64') return ['aarch64', 'arm64'];
58
+ return [process.arch];
59
+ }
60
+
61
+ function scoreFrankenPhpAsset(assetName) {
62
+ const name = assetName.toLowerCase();
63
+ if (!name.includes('frankenphp')) return -1;
64
+ if (name.includes('debug')) return -1;
65
+ if (!platformTokens().some((token) => name.includes(token))) return -1;
66
+ if (!archTokens().some((token) => name.includes(token))) return -1;
67
+
68
+ let score = 10;
69
+ if (process.platform === 'win32' && name.endsWith('.zip')) score += 10;
70
+ if (process.platform !== 'win32' && !name.endsWith('.zip')) score += 10;
71
+ if (!name.includes('gnu')) score += 2;
72
+ if (name.endsWith('.tar.gz') || name.endsWith('.tgz')) score += 1;
73
+ return score;
74
+ }
75
+
76
+ function selectFrankenPhpAsset(release) {
77
+ const assets = Array.isArray(release?.assets) ? release.assets : [];
78
+ const candidates = assets
79
+ .map((asset) => ({ asset, score: scoreFrankenPhpAsset(asset.name || '') }))
80
+ .filter((entry) => entry.score >= 0)
81
+ .sort((a, b) => b.score - a.score);
82
+
83
+ return candidates[0]?.asset || null;
84
+ }
85
+
86
+ async function fetchJson(url, env = process.env) {
87
+ const headers = {
88
+ Accept: 'application/vnd.github+json',
89
+ 'User-Agent': 'Pixcode Live View',
90
+ };
91
+ if (env.GITHUB_TOKEN) headers.Authorization = `Bearer ${env.GITHUB_TOKEN}`;
92
+
93
+ const response = await fetch(url, { headers });
94
+ if (!response.ok) {
95
+ throw new Error(`Runtime metadata request failed with HTTP ${response.status}`);
96
+ }
97
+ return response.json();
98
+ }
99
+
100
+ async function downloadFile(url, targetFile, env = process.env) {
101
+ const headers = { 'User-Agent': 'Pixcode Live View' };
102
+ if (env.GITHUB_TOKEN && url.includes('github.com')) headers.Authorization = `Bearer ${env.GITHUB_TOKEN}`;
103
+
104
+ const response = await fetch(url, { headers });
105
+ if (!response.ok) {
106
+ throw new Error(`Runtime download failed with HTTP ${response.status}`);
107
+ }
108
+ const buffer = Buffer.from(await response.arrayBuffer());
109
+ await fs.writeFile(targetFile, buffer);
110
+ }
111
+
112
+ function runProcess(command, args, options = {}) {
113
+ return new Promise((resolve, reject) => {
114
+ let stderr = '';
115
+ const child = spawn(command, args, {
116
+ ...options,
117
+ stdio: ['ignore', 'ignore', 'pipe'],
118
+ windowsHide: true,
119
+ });
120
+ child.stderr.on('data', (chunk) => {
121
+ stderr += chunk.toString();
122
+ });
123
+ child.on('error', reject);
124
+ child.on('close', (code) => {
125
+ if (code === 0) {
126
+ resolve();
127
+ return;
128
+ }
129
+ reject(new Error(`${command} exited with code ${code}${stderr ? `: ${stderr.trim()}` : ''}`));
130
+ });
131
+ });
132
+ }
133
+
134
+ async function extractZip(archivePath, targetDir, env = process.env) {
135
+ if (process.platform === 'win32') {
136
+ const shell = env.ComSpec || process.env.ComSpec || 'powershell.exe';
137
+ const isCmd = shell.toLowerCase().endsWith('cmd.exe');
138
+ if (isCmd) {
139
+ await runProcess('powershell.exe', [
140
+ '-NoProfile',
141
+ '-ExecutionPolicy',
142
+ 'Bypass',
143
+ '-Command',
144
+ 'Expand-Archive -Force -LiteralPath $args[0] -DestinationPath $args[1]',
145
+ archivePath,
146
+ targetDir,
147
+ ], { env });
148
+ return;
149
+ }
150
+ await runProcess(shell, [
151
+ '-NoProfile',
152
+ '-ExecutionPolicy',
153
+ 'Bypass',
154
+ '-Command',
155
+ 'Expand-Archive -Force -LiteralPath $args[0] -DestinationPath $args[1]',
156
+ archivePath,
157
+ targetDir,
158
+ ], { env });
159
+ return;
160
+ }
161
+
162
+ await runProcess('unzip', ['-q', archivePath, '-d', targetDir], { env });
163
+ }
164
+
165
+ async function extractTarGz(archivePath, targetDir, env = process.env) {
166
+ void env;
167
+ await tar.x({
168
+ file: archivePath,
169
+ cwd: targetDir,
170
+ });
171
+ }
172
+
173
+ async function findRuntimeExecutable(searchRoot, binaryName) {
174
+ const expectedNames = process.platform === 'win32'
175
+ ? [`${binaryName}.exe`, binaryName]
176
+ : [binaryName];
177
+ const stack = [searchRoot];
178
+
179
+ while (stack.length > 0) {
180
+ const current = stack.pop();
181
+ const entries = await fs.readdir(current, { withFileTypes: true }).catch(() => []);
182
+ for (const entry of entries) {
183
+ const full = path.join(current, entry.name);
184
+ if (entry.isDirectory()) {
185
+ stack.push(full);
186
+ continue;
187
+ }
188
+ if (entry.isFile() && expectedNames.includes(entry.name)) {
189
+ return full;
190
+ }
191
+ }
192
+ }
193
+
194
+ return null;
195
+ }
196
+
197
+ async function installFrankenPhp(env = process.env) {
198
+ const releaseApiUrl = env.PIXCODE_FRANKENPHP_RELEASE_API
199
+ || 'https://api.github.com/repos/php/frankenphp/releases/latest';
200
+ const release = env.PIXCODE_FRANKENPHP_URL ? null : await fetchJson(releaseApiUrl, env);
201
+ const asset = env.PIXCODE_FRANKENPHP_URL
202
+ ? {
203
+ name: path.basename(new URL(env.PIXCODE_FRANKENPHP_URL).pathname),
204
+ browser_download_url: env.PIXCODE_FRANKENPHP_URL,
205
+ }
206
+ : selectFrankenPhpAsset(release);
207
+
208
+ if (!asset?.browser_download_url) {
209
+ throw new Error('No FrankenPHP binary is available for this operating system and CPU architecture.');
210
+ }
211
+
212
+ const baseDir = runtimeDir('frankenphp', env);
213
+ const stagingDir = path.join(baseDir, `.staging-${Date.now()}`);
214
+ const currentDir = path.join(baseDir, 'current');
215
+ await fs.mkdir(stagingDir, { recursive: true });
216
+
217
+ const archivePath = path.join(stagingDir, asset.name || 'frankenphp');
218
+ await downloadFile(asset.browser_download_url, archivePath, env);
219
+
220
+ let executablePath = archivePath;
221
+ const assetName = (asset.name || '').toLowerCase();
222
+ if (assetName.endsWith('.zip')) {
223
+ await extractZip(archivePath, stagingDir, env);
224
+ executablePath = await findRuntimeExecutable(stagingDir, 'frankenphp');
225
+ if (!executablePath) {
226
+ throw new Error('Downloaded FrankenPHP archive did not contain a frankenphp executable.');
227
+ }
228
+ } else if (assetName.endsWith('.tar.gz') || assetName.endsWith('.tgz')) {
229
+ await extractTarGz(archivePath, stagingDir, env);
230
+ executablePath = await findRuntimeExecutable(stagingDir, 'frankenphp');
231
+ if (!executablePath) {
232
+ throw new Error('Downloaded FrankenPHP archive did not contain a frankenphp executable.');
233
+ }
234
+ }
235
+
236
+ if (process.platform !== 'win32') {
237
+ await fs.chmod(executablePath, 0o755).catch(() => undefined);
238
+ }
239
+
240
+ await fs.rm(currentDir, { recursive: true, force: true });
241
+ await fs.mkdir(currentDir, { recursive: true });
242
+
243
+ const finalName = process.platform === 'win32' ? 'frankenphp.exe' : 'frankenphp';
244
+ const finalExecutable = path.join(currentDir, finalName);
245
+ await fs.copyFile(executablePath, finalExecutable);
246
+ if (process.platform !== 'win32') {
247
+ await fs.chmod(finalExecutable, 0o755).catch(() => undefined);
248
+ }
249
+
250
+ const manifest = {
251
+ id: 'frankenphp',
252
+ label: 'Pixcode PHP runtime',
253
+ provider: 'FrankenPHP',
254
+ version: release?.tag_name || 'custom',
255
+ executablePath: finalExecutable,
256
+ sourceUrl: asset.browser_download_url,
257
+ installedAt: new Date().toISOString(),
258
+ };
259
+ await fs.writeFile(manifestPath('frankenphp', env), JSON.stringify(manifest, null, 2));
260
+ await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
261
+ return manifest;
262
+ }
263
+
264
+ async function installNpmRuntime(env = process.env) {
265
+ const registryUrl = env.PIXCODE_NPM_RUNTIME_REGISTRY
266
+ || 'https://registry.npmjs.org/npm/latest';
267
+ const metadata = await fetchJson(registryUrl, env);
268
+ const tarballUrl = metadata?.dist?.tarball;
269
+ if (!tarballUrl) {
270
+ throw new Error('No npm runtime tarball is available from the npm registry.');
271
+ }
272
+
273
+ const baseDir = runtimeDir('npm', env);
274
+ const stagingDir = path.join(baseDir, `.staging-${Date.now()}`);
275
+ const currentDir = path.join(baseDir, 'current');
276
+ await fs.mkdir(stagingDir, { recursive: true });
277
+
278
+ const archivePath = path.join(stagingDir, 'npm-runtime.tgz');
279
+ await downloadFile(tarballUrl, archivePath, env);
280
+ await extractTarGz(archivePath, stagingDir, env);
281
+
282
+ const packageDir = path.join(stagingDir, 'package');
283
+ const executablePath = path.join(packageDir, 'bin', 'npm-cli.js');
284
+ if (!(await fileExists(executablePath))) {
285
+ throw new Error('Downloaded npm runtime did not contain bin/npm-cli.js.');
286
+ }
287
+
288
+ await fs.rm(currentDir, { recursive: true, force: true });
289
+ await fs.cp(packageDir, currentDir, { recursive: true, force: true });
290
+
291
+ const finalExecutable = path.join(currentDir, 'bin', 'npm-cli.js');
292
+ const manifest = {
293
+ id: 'npm',
294
+ label: 'Pixcode Node package runner',
295
+ provider: 'npm',
296
+ version: metadata?.version || 'latest',
297
+ executablePath: finalExecutable,
298
+ runner: 'node',
299
+ sourceUrl: tarballUrl,
300
+ installedAt: new Date().toISOString(),
301
+ };
302
+ await fs.writeFile(manifestPath('npm', env), JSON.stringify(manifest, null, 2));
303
+ await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
304
+ return manifest;
305
+ }
306
+
307
+ export async function getManagedRuntimeStatus(id, options = {}) {
308
+ const env = options.env || process.env;
309
+ const preferManaged = Boolean(options.preferManaged);
310
+ if (id !== 'frankenphp' && id !== 'npm') {
311
+ return {
312
+ id,
313
+ status: 'unsupported',
314
+ installable: false,
315
+ reason: 'Pixcode does not have a managed runtime for this stack yet.',
316
+ };
317
+ }
318
+
319
+ if (id === 'npm') {
320
+ if (!preferManaged) {
321
+ const spawnEnv = buildCliSpawnEnv(env);
322
+ const npmExecutable = resolveNpmCommand(spawnEnv);
323
+ if (npmExecutable) {
324
+ return {
325
+ id,
326
+ label: 'npm',
327
+ status: 'system',
328
+ installable: true,
329
+ executablePath: npmExecutable,
330
+ runner: npmExecutable.endsWith('.js') ? 'node' : undefined,
331
+ };
332
+ }
333
+ }
334
+
335
+ const manifest = await readManifest(id, env);
336
+ if (manifest) {
337
+ return {
338
+ id,
339
+ label: manifest.label || 'Pixcode Node package runner',
340
+ status: 'installed',
341
+ installable: true,
342
+ executablePath: manifest.executablePath,
343
+ runner: manifest.runner || 'node',
344
+ version: manifest.version,
345
+ };
346
+ }
347
+
348
+ return {
349
+ id,
350
+ label: 'Pixcode Node package runner',
351
+ provider: 'npm',
352
+ status: 'missing',
353
+ installable: true,
354
+ reason: 'Pixcode will prepare a local Node package runner automatically before starting this project.',
355
+ };
356
+ }
357
+
358
+ if (!preferManaged) {
359
+ const spawnEnv = buildCliSpawnEnv(env);
360
+ const systemExecutable = findExecutableOnPath('frankenphp', spawnEnv);
361
+ if (systemExecutable) {
362
+ return {
363
+ id,
364
+ label: 'FrankenPHP',
365
+ status: 'system',
366
+ installable: true,
367
+ executablePath: systemExecutable,
368
+ };
369
+ }
370
+ }
371
+
372
+ const manifest = await readManifest(id, env);
373
+ if (manifest) {
374
+ return {
375
+ id,
376
+ label: manifest.label || 'Pixcode PHP runtime',
377
+ status: 'installed',
378
+ installable: true,
379
+ executablePath: manifest.executablePath,
380
+ version: manifest.version,
381
+ };
382
+ }
383
+
384
+ return {
385
+ id,
386
+ label: 'Pixcode PHP runtime',
387
+ provider: 'FrankenPHP',
388
+ status: 'missing',
389
+ installable: true,
390
+ reason: 'Pixcode will prepare a local PHP runtime automatically before starting this project.',
391
+ };
392
+ }
393
+
394
+ export async function ensureManagedRuntime(id, options = {}) {
395
+ const env = options.env || process.env;
396
+ const status = await getManagedRuntimeStatus(id, {
397
+ env,
398
+ preferManaged: options.preferManaged,
399
+ });
400
+ if (status.executablePath) return status;
401
+ if (!status.installable) {
402
+ throw new Error(status.reason || 'This runtime cannot be prepared automatically.');
403
+ }
404
+
405
+ const lockKey = `${runtimesHome(env)}:${id}`;
406
+ if (installLocks.has(lockKey)) return installLocks.get(lockKey);
407
+
408
+ const installPromise = (async () => {
409
+ if (id === 'frankenphp') {
410
+ const manifest = await installFrankenPhp(env);
411
+ return {
412
+ id,
413
+ label: manifest.label,
414
+ status: 'installed',
415
+ installable: true,
416
+ executablePath: manifest.executablePath,
417
+ version: manifest.version,
418
+ };
419
+ }
420
+ if (id === 'npm') {
421
+ const manifest = await installNpmRuntime(env);
422
+ return {
423
+ id,
424
+ label: manifest.label,
425
+ status: 'installed',
426
+ installable: true,
427
+ executablePath: manifest.executablePath,
428
+ runner: manifest.runner || 'node',
429
+ version: manifest.version,
430
+ };
431
+ }
432
+ throw new Error(`Unsupported managed runtime: ${id}`);
433
+ })().finally(() => {
434
+ installLocks.delete(lockKey);
435
+ });
436
+
437
+ installLocks.set(lockKey, installPromise);
438
+ return installPromise;
439
+ }