unitup 0.1.4 → 0.2.0

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.
@@ -0,0 +1,495 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import {
6
+ PermissionRequiredError,
7
+ ServiceAlreadyExistsError,
8
+ ServiceNotFoundError,
9
+ ServiceStartError,
10
+ ServiceStopError
11
+ } from '../errors.js';
12
+ import { readServiceLogs } from '../logs.js';
13
+ import {
14
+ deleteAppMetadata,
15
+ formatRelativeTime,
16
+ getAppsDir,
17
+ getUnitupDir,
18
+ readAppMetadata,
19
+ sanitizeServiceName,
20
+ saveAppMetadata
21
+ } from '../utils.js';
22
+ import { ServiceAdapter } from './base.js';
23
+
24
+ const __filename = fileURLToPath(import.meta.url);
25
+ const __dirname = path.dirname(__filename);
26
+ const WINDOWS_HOST_PATH = path.resolve(__dirname, 'windows-host.js');
27
+
28
+ /**
29
+ * Windows Service Adapter using Windows SCM (sc.exe) and Unitup Windows Service Host.
30
+ */
31
+ export class WindowsAdapter extends ServiceAdapter {
32
+ get name() {
33
+ return 'windows';
34
+ }
35
+
36
+ get capabilities() {
37
+ return {
38
+ serviceManager: 'windows',
39
+ supports: {
40
+ install: true,
41
+ uninstall: true,
42
+ start: true,
43
+ stop: true,
44
+ restart: true,
45
+ enable: true,
46
+ disable: true,
47
+ status: true,
48
+ logs: true,
49
+ restartPolicy: true,
50
+ userServices: false,
51
+ systemServices: true,
52
+ memoryLimits: false,
53
+ schedule: false
54
+ }
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Returns the Windows service name.
60
+ * @param {string} name
61
+ * @returns {string}
62
+ */
63
+ getServiceName(name) {
64
+ const safeName = sanitizeServiceName(name);
65
+ return `unitup-${safeName}`;
66
+ }
67
+
68
+ /**
69
+ * Generates Windows service creation command parameters.
70
+ *
71
+ * @param {object} config - NormalizedServiceConfig
72
+ * @returns {object}
73
+ */
74
+ generateService(config) {
75
+ const safeName = sanitizeServiceName(config.name);
76
+ const serviceName = this.getServiceName(safeName);
77
+ const nodeExec = process.execPath;
78
+ const binPath = `"${nodeExec}" "${WINDOWS_HOST_PATH}" "${safeName}"`;
79
+ const startType = config.autostart !== false ? 'auto' : 'demand';
80
+
81
+ return {
82
+ serviceName,
83
+ displayName: config.displayName && config.displayName !== safeName ? config.displayName : `Unitup - ${safeName}`,
84
+ description: config.description || `unitup service: ${safeName}`,
85
+ binPath,
86
+ startType,
87
+ command: config.command,
88
+ args: config.args,
89
+ cwd: config.cwd,
90
+ env: config.env
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Installs a Windows Service.
96
+ *
97
+ * @param {object} config - NormalizedServiceConfig
98
+ * @param {object} [options]
99
+ * @returns {Promise<object>}
100
+ */
101
+ async install(config, options = {}) {
102
+ const safeName = sanitizeServiceName(config.name);
103
+ const serviceName = this.getServiceName(safeName);
104
+ const generated = this.generateService(config);
105
+
106
+ const isExisting = await this.isInstalled(safeName);
107
+ let wasRunning = false;
108
+
109
+ if (isExisting) {
110
+ const currentStatus = await this.status(safeName);
111
+ if (currentStatus.state === 'running') {
112
+ wasRunning = true;
113
+ if (!options.force && !config.raw?.force) {
114
+ throw new ServiceAlreadyExistsError(safeName);
115
+ }
116
+ await this.stop(safeName);
117
+ }
118
+ await this.uninstall(safeName, { force: true });
119
+ }
120
+
121
+ // Save app metadata before creating service
122
+ saveAppMetadata({
123
+ ...config,
124
+ name: safeName,
125
+ serviceName,
126
+ hostPath: WINDOWS_HOST_PATH,
127
+ installedAt: new Date().toISOString()
128
+ });
129
+
130
+ const scArgs = [
131
+ 'create',
132
+ serviceName,
133
+ `binPath= ${generated.binPath}`,
134
+ `displayName= ${generated.displayName}`,
135
+ `start= ${generated.startType}`
136
+ ];
137
+
138
+ const res = await this.run('sc.exe', scArgs);
139
+ if (res.code !== 0) {
140
+ if (res.stderr.includes('Access is denied') || res.stdout.includes('Access is denied') || res.code === 5) {
141
+ throw new PermissionRequiredError(
142
+ `Installing a Windows service requires Administrator privileges.\n\nPlease open PowerShell or Command Prompt as Administrator and run:\n unitup install ${config.name}`,
143
+ 'install'
144
+ );
145
+ }
146
+ throw new Error(`Failed to create Windows service "${serviceName}": ${res.stderr || res.stdout}`);
147
+ }
148
+
149
+ // Set description if supported
150
+ try {
151
+ await this.run('sc.exe', ['description', serviceName, generated.description]);
152
+ } catch {
153
+ // ignore
154
+ }
155
+
156
+ if (options.start || config.raw?.start || wasRunning) {
157
+ await this.start(safeName);
158
+ }
159
+
160
+ return {
161
+ name: safeName,
162
+ serviceName,
163
+ displayName: generated.displayName,
164
+ unitPath: generated.binPath
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Uninstalls a Windows Service.
170
+ *
171
+ * @param {string} name
172
+ * @param {object} [options]
173
+ * @returns {Promise<boolean>}
174
+ */
175
+ async uninstall(name, options = {}) {
176
+ const safeName = sanitizeServiceName(name);
177
+ const serviceName = this.getServiceName(safeName);
178
+
179
+ const isExisting = await this.isInstalled(safeName);
180
+ if (!isExisting) {
181
+ throw new ServiceNotFoundError(safeName);
182
+ }
183
+
184
+ const currentStatus = await this.status(safeName);
185
+ if (currentStatus.state === 'running' && !options.force) {
186
+ throw new Error(
187
+ `Service "${safeName}" is currently running.\n` +
188
+ `Use --force (-f) to remove running services, or stop it first:\n` +
189
+ ` unitup stop ${safeName}`
190
+ );
191
+ }
192
+
193
+ if (currentStatus.state === 'running') {
194
+ try {
195
+ await this.stop(safeName);
196
+ } catch {
197
+ // ignore
198
+ }
199
+ }
200
+
201
+ const res = await this.run('sc.exe', ['delete', serviceName]);
202
+ if (res.code !== 0) {
203
+ if (res.stderr.includes('Access is denied') || res.stdout.includes('Access is denied') || res.code === 5) {
204
+ throw new PermissionRequiredError(
205
+ `Uninstalling a Windows service requires Administrator privileges.`,
206
+ 'uninstall'
207
+ );
208
+ }
209
+ throw new Error(`Failed to delete Windows service "${serviceName}": ${res.stderr || res.stdout}`);
210
+ }
211
+
212
+ deleteAppMetadata(safeName);
213
+ return true;
214
+ }
215
+
216
+ /**
217
+ * Starts a Windows Service.
218
+ *
219
+ * @param {string} name
220
+ * @param {object} [options]
221
+ * @returns {Promise<boolean>}
222
+ */
223
+ async start(name, options = {}) {
224
+ const safeName = sanitizeServiceName(name);
225
+ const serviceName = this.getServiceName(safeName);
226
+
227
+ const res = await this.run('sc.exe', ['start', serviceName]);
228
+ if (res.code !== 0) {
229
+ if (res.stdout.includes('already running') || res.stdout.includes('1056')) {
230
+ return true;
231
+ }
232
+ if (res.stderr.includes('Access is denied') || res.stdout.includes('Access is denied')) {
233
+ throw new PermissionRequiredError('Starting a Windows service requires Administrator privileges.', 'start');
234
+ }
235
+ throw new ServiceStartError(safeName, res.stderr || res.stdout);
236
+ }
237
+ return true;
238
+ }
239
+
240
+ /**
241
+ * Stops a Windows Service.
242
+ *
243
+ * @param {string} name
244
+ * @param {object} [options]
245
+ * @returns {Promise<boolean>}
246
+ */
247
+ async stop(name, options = {}) {
248
+ const safeName = sanitizeServiceName(name);
249
+ const serviceName = this.getServiceName(safeName);
250
+
251
+ const res = await this.run('sc.exe', ['stop', serviceName]);
252
+ if (res.code !== 0) {
253
+ if (res.stdout.includes('not started') || res.stdout.includes('1062')) {
254
+ return true;
255
+ }
256
+ if (res.stderr.includes('Access is denied') || res.stdout.includes('Access is denied')) {
257
+ throw new PermissionRequiredError('Stopping a Windows service requires Administrator privileges.', 'stop');
258
+ }
259
+ throw new ServiceStopError(safeName, res.stderr || res.stdout);
260
+ }
261
+ return true;
262
+ }
263
+
264
+ /**
265
+ * Restarts a Windows Service.
266
+ *
267
+ * @param {string} name
268
+ * @param {object} [options]
269
+ * @returns {Promise<boolean>}
270
+ */
271
+ async restart(name, options = {}) {
272
+ await this.stop(name, options);
273
+ // Give SCM a brief pause
274
+ await new Promise((r) => setTimeout(r, 1000));
275
+ return this.start(name, options);
276
+ }
277
+
278
+ /**
279
+ * Enables automatic startup for a Windows service.
280
+ *
281
+ * @param {string} name
282
+ * @param {object} [options]
283
+ * @returns {Promise<boolean>}
284
+ */
285
+ async enable(name, options = {}) {
286
+ const safeName = sanitizeServiceName(name);
287
+ const serviceName = this.getServiceName(safeName);
288
+
289
+ const res = await this.run('sc.exe', ['config', serviceName, 'start=', 'auto']);
290
+ return res.code === 0;
291
+ }
292
+
293
+ /**
294
+ * Disables automatic startup for a Windows service.
295
+ *
296
+ * @param {string} name
297
+ * @param {object} [options]
298
+ * @returns {Promise<boolean>}
299
+ */
300
+ async disable(name, options = {}) {
301
+ const safeName = sanitizeServiceName(name);
302
+ const serviceName = this.getServiceName(safeName);
303
+
304
+ const res = await this.run('sc.exe', ['config', serviceName, 'start=', 'disabled']);
305
+ return res.code === 0;
306
+ }
307
+
308
+ /**
309
+ * Gets normalized status for a Windows service.
310
+ *
311
+ * @param {string} name
312
+ * @param {object} [options]
313
+ * @returns {Promise<object>}
314
+ */
315
+ async status(name, options = {}) {
316
+ const safeName = sanitizeServiceName(name);
317
+ const serviceName = this.getServiceName(safeName);
318
+ const meta = readAppMetadata(safeName) || {};
319
+
320
+ let state = 'stopped';
321
+ const pid = meta.pid || '-';
322
+ let installed = false;
323
+ const details = {};
324
+
325
+ const res = await this.run('sc.exe', ['query', serviceName]);
326
+ if (res.code === 0 && res.stdout) {
327
+ installed = true;
328
+ details.raw = res.stdout;
329
+
330
+ if (res.stdout.includes('RUNNING')) {
331
+ state = 'running';
332
+ } else if (res.stdout.includes('STOP_PENDING')) {
333
+ state = 'stopping';
334
+ } else if (res.stdout.includes('START_PENDING')) {
335
+ state = 'starting';
336
+ } else if (res.stdout.includes('STOPPED')) {
337
+ state = 'stopped';
338
+ }
339
+ } else {
340
+ // Check if metadata exists even if sc query failed
341
+ if (meta && Object.keys(meta).length > 0) {
342
+ installed = true;
343
+ state = 'stopped';
344
+ }
345
+ }
346
+
347
+ const command = meta.command || process.execPath;
348
+ const argsList = meta.args || (meta.script ? [meta.script] : []);
349
+
350
+ return {
351
+ name: safeName,
352
+ installed,
353
+ state,
354
+ status: state,
355
+ enabled: installed,
356
+ pid,
357
+ restarts: meta.restarts || '0',
358
+ started: meta.started ? formatRelativeTime(meta.started) : state === 'running' ? 'active' : 'never',
359
+ startedRaw: meta.started || null,
360
+ command,
361
+ arguments: argsList.join(' '),
362
+ args: argsList,
363
+ script: meta.script || argsList[0] || command,
364
+ cwd: meta.cwd || process.cwd(),
365
+ unitFile: serviceName,
366
+ unitPath: serviceName,
367
+ platform: 'win32',
368
+ manager: 'windows',
369
+ details
370
+ };
371
+ }
372
+
373
+ /**
374
+ * Inspects detailed configuration and runtime info for a service.
375
+ *
376
+ * @param {string} name
377
+ * @param {object} [options]
378
+ * @returns {Promise<object>}
379
+ */
380
+ async inspect(name, options = {}) {
381
+ const statusObj = await this.status(name, options);
382
+ const meta = readAppMetadata(name) || {};
383
+ return {
384
+ ...statusObj,
385
+ runtime: meta.runtime || 'node',
386
+ group: meta.group || 'default',
387
+ node: statusObj.command
388
+ };
389
+ }
390
+
391
+ /**
392
+ * Retrieves logs for a Windows service.
393
+ *
394
+ * @param {string} name
395
+ * @param {object} [options]
396
+ * @returns {Promise<any>}
397
+ */
398
+ async logs(name, options = {}) {
399
+ const safeName = sanitizeServiceName(name);
400
+ const meta = readAppMetadata(safeName);
401
+ const defaultStdout = path.join(getUnitupDir(), 'logs', `${safeName}.log`);
402
+ const defaultStderr = path.join(getUnitupDir(), 'logs', `${safeName}-error.log`);
403
+
404
+ const stdoutPath = meta?.logs?.stdout || defaultStdout;
405
+ const stderrPath = meta?.logs?.stderr || defaultStderr;
406
+
407
+ let paths = [stdoutPath, stderrPath];
408
+ if (options.output === 'stdout' || options.stdout) {
409
+ paths = [stdoutPath];
410
+ } else if (options.output === 'stderr' || options.stderr) {
411
+ paths = [stderrPath];
412
+ }
413
+
414
+ return readServiceLogs(paths, options);
415
+ }
416
+
417
+ /**
418
+ * Lists all unitup Windows services.
419
+ *
420
+ * @param {object} [options]
421
+ * @returns {Promise<Array<object>>}
422
+ */
423
+ async list(options = {}) {
424
+ const appsDir = getAppsDir();
425
+ if (!fs.existsSync(appsDir)) return [];
426
+
427
+ const files = fs.readdirSync(appsDir).filter((f) => f.endsWith('.json'));
428
+ const result = [];
429
+ const targetGroup = options.group
430
+ ? (options.group.startsWith('@') ? options.group.slice(1) : options.group).toLowerCase()
431
+ : null;
432
+
433
+ for (const file of files) {
434
+ const serviceName = file.replace(/\.json$/, '');
435
+ const meta = readAppMetadata(serviceName);
436
+ if (!meta) continue;
437
+
438
+ const group = meta.group || 'default';
439
+ if (targetGroup && group.toLowerCase() !== targetGroup) {
440
+ continue;
441
+ }
442
+
443
+ try {
444
+ const stat = await this.status(serviceName);
445
+ const cmdBase = path.basename(stat.command || 'node');
446
+ const argsStr = (stat.args || []).map((a) => path.basename(a)).join(' ');
447
+ const commandSummary = (cmdBase + (argsStr ? ` ${argsStr}` : '')).trim();
448
+
449
+ result.push({
450
+ name: serviceName,
451
+ runtime: meta.runtime || 'node',
452
+ group,
453
+ status: stat.state,
454
+ enabled: stat.enabled ? 'yes' : 'no',
455
+ pid: stat.pid,
456
+ command: commandSummary,
457
+ uptime: stat.started,
458
+ restarts: stat.restarts || '0',
459
+ platform: 'win32',
460
+ manager: 'windows'
461
+ });
462
+ } catch {
463
+ result.push({
464
+ name: serviceName,
465
+ runtime: meta.runtime || 'node',
466
+ group,
467
+ status: 'unknown',
468
+ enabled: 'unknown',
469
+ pid: '-',
470
+ command: 'unknown',
471
+ uptime: 'never',
472
+ restarts: '0',
473
+ platform: 'win32',
474
+ manager: 'windows'
475
+ });
476
+ }
477
+ }
478
+
479
+ return result;
480
+ }
481
+
482
+ async isInstalled(name, options = {}) {
483
+ const safeName = sanitizeServiceName(name);
484
+ const serviceName = this.getServiceName(safeName);
485
+ const res = await this.run('sc.exe', ['query', serviceName]);
486
+ if (res.code === 0) return true;
487
+ const meta = readAppMetadata(safeName);
488
+ return !!meta;
489
+ }
490
+
491
+ async failures(options = {}) {
492
+ const all = await this.list(options);
493
+ return all.filter((i) => i.status === 'failed');
494
+ }
495
+ }
@@ -1,13 +1,14 @@
1
- import path from 'node:path';
2
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
3
3
  import { findRuntimeExecutable, getBinaryVersion } from './common.js';
4
4
 
5
5
  export async function createBunAdapter(opts = {}) {
6
+ const baseDir = opts.cwd || process.cwd();
6
7
  const customPath = opts.command;
7
8
  let execPath = null;
8
9
 
9
10
  if (customPath) {
10
- const absPath = path.resolve(process.cwd(), customPath);
11
+ const absPath = path.resolve(baseDir, customPath);
11
12
  if (fs.existsSync(absPath)) {
12
13
  try {
13
14
  fs.accessSync(absPath, fs.constants.X_OK);
@@ -27,7 +28,7 @@ export async function createBunAdapter(opts = {}) {
27
28
  }
28
29
 
29
30
  const version = await getBinaryVersion(execPath, '-v');
30
- const scriptPath = opts.script ? path.resolve(process.cwd(), opts.script) : null;
31
+ const scriptPath = opts.script ? path.resolve(baseDir, opts.script) : null;
31
32
  const runtimeArgs = Array.isArray(opts.runtimeArgs) ? opts.runtimeArgs : [];
32
33
  const scriptArgs = Array.isArray(opts.args) ? opts.args : [];
33
34
 
@@ -1,22 +1,40 @@
1
- import path from 'node:path';
2
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
3
3
  import { runCommand } from '../systemd.js';
4
4
 
5
5
  export function findExecutableInPath(binaryName) {
6
6
  // 1. Check standard PATH env
7
7
  const envPath = process.env.PATH || '';
8
8
  const dirs = envPath.split(path.delimiter).filter(Boolean);
9
-
9
+ const isWin = process.platform === 'win32';
10
+ const pathext = isWin ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean) : [''];
11
+
10
12
  for (const dir of dirs) {
11
- const full = path.join(dir, binaryName);
13
+ const directFull = path.join(dir, binaryName);
12
14
  try {
13
- if (fs.existsSync(full)) {
14
- fs.accessSync(full, fs.constants.X_OK);
15
- return full;
15
+ if (fs.existsSync(directFull)) {
16
+ if (!isWin) fs.accessSync(directFull, fs.constants.X_OK);
17
+ return directFull;
16
18
  }
17
19
  } catch {
18
20
  // ignore
19
21
  }
22
+
23
+ if (isWin) {
24
+ for (const ext of pathext) {
25
+ const withExt = path.join(
26
+ dir,
27
+ binaryName.toLowerCase().endsWith(ext.toLowerCase()) ? binaryName : `${binaryName}${ext.toLowerCase()}`
28
+ );
29
+ try {
30
+ if (fs.existsSync(withExt)) {
31
+ return withExt;
32
+ }
33
+ } catch {
34
+ // ignore
35
+ }
36
+ }
37
+ }
20
38
  }
21
39
  return null;
22
40
  }
@@ -1,13 +1,14 @@
1
- import path from 'node:path';
2
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
3
3
  import { findRuntimeExecutable, getBinaryVersion } from './common.js';
4
4
 
5
5
  export async function createDenoAdapter(opts = {}) {
6
+ const baseDir = opts.cwd || process.cwd();
6
7
  const customPath = opts.command;
7
8
  let execPath = null;
8
9
 
9
10
  if (customPath) {
10
- const absPath = path.resolve(process.cwd(), customPath);
11
+ const absPath = path.resolve(baseDir, customPath);
11
12
  if (fs.existsSync(absPath)) {
12
13
  try {
13
14
  fs.accessSync(absPath, fs.constants.X_OK);
@@ -27,7 +28,7 @@ export async function createDenoAdapter(opts = {}) {
27
28
  }
28
29
 
29
30
  const version = await getBinaryVersion(execPath, '--version');
30
- const scriptPath = opts.script ? path.resolve(process.cwd(), opts.script) : null;
31
+ const scriptPath = opts.script ? path.resolve(baseDir, opts.script) : null;
31
32
  const runtimeArgs = Array.isArray(opts.runtimeArgs) ? opts.runtimeArgs : [];
32
33
  const scriptArgs = Array.isArray(opts.args) ? opts.args : [];
33
34
 
@@ -1,13 +1,14 @@
1
- import path from 'node:path';
2
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
3
3
  import { findRuntimeExecutable, getBinaryVersion } from './common.js';
4
4
 
5
5
  export async function createElixirAdapter(opts = {}) {
6
+ const baseDir = opts.cwd || process.cwd();
6
7
  const customPath = opts.command;
7
8
  let execPath = null;
8
9
 
9
10
  if (customPath) {
10
- const absPath = path.resolve(process.cwd(), customPath);
11
+ const absPath = path.resolve(baseDir, customPath);
11
12
  if (fs.existsSync(absPath)) {
12
13
  try {
13
14
  fs.accessSync(absPath, fs.constants.X_OK);
@@ -27,7 +28,7 @@ export async function createElixirAdapter(opts = {}) {
27
28
  }
28
29
 
29
30
  const version = await getBinaryVersion(execPath, '-v');
30
- const scriptPath = opts.script ? path.resolve(process.cwd(), opts.script) : null;
31
+ const scriptPath = opts.script ? path.resolve(baseDir, opts.script) : null;
31
32
  const runtimeArgs = Array.isArray(opts.runtimeArgs) ? opts.runtimeArgs : [];
32
33
  const scriptArgs = Array.isArray(opts.args) ? opts.args : [];
33
34
 
@@ -1,13 +1,14 @@
1
- import path from 'node:path';
2
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
3
3
  import { findRuntimeExecutable, getBinaryVersion } from './common.js';
4
4
 
5
5
  export async function createGoAdapter(opts = {}) {
6
+ const baseDir = opts.cwd || process.cwd();
6
7
  const customPath = opts.command;
7
8
  let execPath = null;
8
9
 
9
10
  if (customPath) {
10
- const absPath = path.resolve(process.cwd(), customPath);
11
+ const absPath = path.resolve(baseDir, customPath);
11
12
  if (fs.existsSync(absPath)) {
12
13
  try {
13
14
  fs.accessSync(absPath, fs.constants.X_OK);
@@ -27,7 +28,7 @@ export async function createGoAdapter(opts = {}) {
27
28
  }
28
29
 
29
30
  const version = await getBinaryVersion(execPath, 'version');
30
- const scriptPath = opts.script ? path.resolve(process.cwd(), opts.script) : null;
31
+ const scriptPath = opts.script ? path.resolve(baseDir, opts.script) : null;
31
32
  const runtimeArgs = Array.isArray(opts.runtimeArgs) ? opts.runtimeArgs : [];
32
33
  const scriptArgs = Array.isArray(opts.args) ? opts.args : [];
33
34
 
@@ -1,17 +1,17 @@
1
- import path from 'node:path';
2
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createBunAdapter } from './bun.js';
4
+ import { createDenoAdapter } from './deno.js';
5
+ import { createElixirAdapter } from './elixir.js';
6
+ import { createGoAdapter } from './go.js';
7
+ import { createNativeAdapter } from './native.js';
3
8
  import { createNodeAdapter } from './node.js';
9
+ import { createPhpAdapter } from './php.js';
4
10
  import { createPythonAdapter } from './python.js';
5
11
  import { createRubyAdapter } from './ruby.js';
6
- import { createPhpAdapter } from './php.js';
7
- import { createBunAdapter } from './bun.js';
8
- import { createDenoAdapter } from './deno.js';
9
12
  import { createShellAdapter } from './shell.js';
10
- import { createNativeAdapter } from './native.js';
11
- import { createGoAdapter } from './go.js';
12
- import { createElixirAdapter } from './elixir.js';
13
13
 
14
- export function detectRuntime(filepath) {
14
+ export function detectRuntime(filepath, opts = {}) {
15
15
  if (!filepath) {
16
16
  throw new Error('Script path or command is required for runtime detection.');
17
17
  }
@@ -42,7 +42,8 @@ export function detectRuntime(filepath) {
42
42
  }
43
43
 
44
44
  // Inspect shebang header if file exists
45
- const absPath = path.resolve(process.cwd(), filepath);
45
+ const baseDir = opts.cwd || process.cwd();
46
+ const absPath = path.resolve(baseDir, filepath);
46
47
  if (fs.existsSync(absPath)) {
47
48
  try {
48
49
  const fd = fs.openSync(absPath, 'r');
@@ -85,9 +86,11 @@ export function detectRuntime(filepath) {
85
86
  }
86
87
 
87
88
  export async function resolveRuntimeConfig(opts = {}) {
89
+ const baseDir = opts.cwd || process.cwd();
90
+
88
91
  // 1. If explicit --command is provided, bypass runtime auto-detection
89
92
  if (opts.command) {
90
- const absCommand = path.resolve(process.cwd(), opts.command);
93
+ const absCommand = path.resolve(baseDir, opts.command);
91
94
  if (!fs.existsSync(absCommand)) {
92
95
  // If binary doesn't exist as exact file path, check if it's in PATH or native executable
93
96
  const { findRuntimeExecutable } = await import('./common.js');
@@ -116,7 +119,7 @@ export async function resolveRuntimeConfig(opts = {}) {
116
119
  // 2. Resolve target runtime name
117
120
  let runtimeName = opts.runtime;
118
121
  if (!runtimeName) {
119
- runtimeName = detectRuntime(opts.script);
122
+ runtimeName = detectRuntime(opts.script, opts);
120
123
  }
121
124
 
122
125
  const cleanRuntime = runtimeName.toLowerCase();