Skill Tracer
Skills › Archify › archify › open-artifact.mjs

open-artifact.mjs

146 lines · archify/bin/open-artifact.mjs at a406594

1import { spawnSync } from 'node:child_process';
2import path from 'node:path';
3
4const OPENERS = {
5 darwin: {
6 command: 'open',
7 method: 'open',
8 timeoutMs: 5000,
9 args: (target) => [target],
10 },
11 linux: {
12 command: 'xdg-open',
13 method: 'xdg-open',
14 timeoutMs: 5000,
15 args: (target) => [target],
16 },
17 win32: {
18 command: 'powershell.exe',
19 method: 'powershell',
20 // PowerShell cold starts can approach five seconds on hosted Windows
21 // runners. Keep the launch bounded without treating normal startup as a
22 // timeout.
23 timeoutMs: 15000,
24 // Keep the command constant and pass the target through a child-only
25 // environment variable. Paths are never interpolated into executable source.
26 args: () => [
27 '-NoProfile',
28 '-NonInteractive',
29 '-Command',
30 'Start-Process -FilePath $env:ARCHIFY_OPEN_TARGET',
31 ],
32 },
33};
35function failureDetails(result, opener, timeoutMs) {
36 const error = result?.error;
37 if (error?.code === 'ENOENT') {
38 return {
39 code: 'opener/unavailable',
40 reason: `Could not find ${opener.command}. Install or enable the platform opener, then open the target manually.`,
41 systemCode: 'ENOENT',
42 };
43 }
44 if (error?.code === 'ETIMEDOUT') {
45 return {
46 code: 'opener/timeout',
47 reason: `${opener.command} did not finish within ${timeoutMs}ms. Open the target manually or retry when the system is less busy.`,
48 systemCode: 'ETIMEDOUT',
49 timeoutMs,
50 };
51 }
52 if (error) {
53 return {
54 code: 'opener/spawn-failed',
55 reason: error.message || `${opener.command} could not be started. Open the target manually.`,
56 ...(error.code ? { systemCode: String(error.code) } : {}),
57 };
58 }
59 if (result?.signal) {
60 return {
61 code: 'opener/signaled',
62 reason: `${opener.command} was terminated by ${result.signal}. Open the target manually.`,
63 signal: result.signal,
64 };
65 }
66 if (result?.status !== 0) {
67 return {
68 code: 'opener/nonzero-exit',
69 reason: `${opener.command} exited with status ${result?.status ?? 'unknown'}. Open the target manually.`,
70 exitCode: result?.status ?? null,
71 };
72 }
73 return null;
76function launchTimeout(value, fallback) {
77 return Number.isSafeInteger(value) && value > 0 ? value : fallback;
80function launchTarget(target, options = {}) {
81 const platform = options.platform || process.platform;
82 const opener = OPENERS[platform];
83 if (!opener) {
84 return {
85 requested: true,
86 status: 'unsupported',
87 target,
88 method: null,
89 };
90 }
92 const spawn = options.spawn || spawnSync;
93 const timeoutMs = launchTimeout(options.timeoutMs, opener.timeoutMs);
94 let result;
95 try {
96 const spawnOptions = {
97 encoding: 'utf8',
98 shell: false,
99 stdio: 'ignore',
100 timeout: timeoutMs,
101 windowsHide: true,
102 };
103 if (platform === 'win32') {
104 spawnOptions.env = {
105 ...process.env,
106 ARCHIFY_OPEN_TARGET: target,
107 };
108 }
109 result = spawn(opener.command, opener.args(target), spawnOptions);
110 } catch (error) {
111 result = { error };
112 }
114 const failure = failureDetails(result, opener, timeoutMs);
115 let status = 'opened';
116 if (failure?.code === 'opener/unavailable') status = 'unsupported';
117 else if (failure) status = 'failed';
119 return {
120 requested: true,
121 status,
122 target,
123 method: opener.method,
124 ...(failure ? { failure } : {}),
125 };
128export function openArtifact(target, options = {}) {
129 return launchTarget(path.resolve(target), options);
132export function openLoopbackUrl(target, options = {}) {
133 let url;
134 try {
135 url = new URL(target);
136 } catch {
137 throw new TypeError('Preview URL must be a valid loopback HTTP URL.');
138 }
139 if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port) {
140 throw new TypeError('Preview URL must be a loopback URL using http://127.0.0.1:<port>.');
141 }
142 if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
143 throw new TypeError('Preview URL must target the loopback preview root.');
144 }
145 return launchTarget(url.href, options);