muse-crew 0.7.9 → 0.7.11

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.
package/lib/see-act.js ADDED
@@ -0,0 +1,327 @@
1
+ // see-act.js — single-step browser driver for experiential QA.
2
+ //
3
+ // The agent closes the OODA loop: run one step, READ the screenshot or aria
4
+ // output, decide the next step. Each invocation launches a fresh browser,
5
+ // performs exactly one action, prints one JSON line to stdout, and exits.
6
+ //
7
+ // Self-contained: spawns its own loopback forward proxy on an ephemeral port
8
+ // (Chromium's Local Network Access checks block direct loopback navigation),
9
+ // so the caller needs no proxy infrastructure.
10
+ //
11
+ // Usage:
12
+ // node see-act.js --url <url> [--viewport desktop|mobile] [--timeout-ms <n>] <action> [args]
13
+ //
14
+ // Actions:
15
+ // aria print interactive elements as JSON
16
+ // shot --out <png> [--full] screenshot the viewport (or full page) to <png>
17
+ // click --out <png> --selector <css> click, then screenshot to <png>
18
+ // scroll --out <png> --y <px|bottom> scroll window, then screenshot
19
+ // type --out <png> --selector <css> --text <text>
20
+ //
21
+ // Archival: when SEE_ACT_ARCHIVE_DIR is set, every screenshot is also saved
22
+ // there as <NNN>-<action>-<viewport>.png (sequential, zero-padded) and the
23
+ // JSON reports it. --out becomes optional: omit it and the screenshot goes
24
+ // straight to the archive. This funnels every captured image through one
25
+ // evidence dir no matter which phase (QA, repro, hunt) took it, so the agent
26
+ // cannot forget to save a frame.
27
+ //
28
+ // Exit codes: 0 = ok, 2 = usage/action error, 3 = NOT POSSIBLE (environment).
29
+ // Stdout is always one JSON object: {ok:true,...} or {ok:false,error|not_possible}.
30
+ // Determinism: no wall-clock reads, no randomness. Archive names are sequential.
31
+ //
32
+ // Dependencies: playwright-core (resolved below), a Chromium binary.
33
+ "use strict";
34
+
35
+ const { createServer, request: httpRequest } = require("node:http");
36
+ const { request: httpsRequest } = require("node:https");
37
+ const { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } = require("node:fs");
38
+ const { join, dirname, resolve } = require("node:path");
39
+
40
+ const VIEWPORTS = {
41
+ desktop: { width: 1440, height: 900 },
42
+ mobile: { width: 390, height: 844 },
43
+ };
44
+ const DEFAULT_TIMEOUT_MS = 30000;
45
+ const SETTLE_MS = 1500;
46
+
47
+ function fail(code, obj) {
48
+ process.stdout.write(JSON.stringify(obj) + "\n");
49
+ process.exit(code);
50
+ }
51
+
52
+ function parseArgs(argv) {
53
+ const out = { action: null, url: null, viewport: "desktop", timeoutMs: DEFAULT_TIMEOUT_MS };
54
+ const rest = [];
55
+ for (let i = 0; i < argv.length; i++) {
56
+ const a = argv[i];
57
+ if (a === "--url") out.url = argv[++i];
58
+ else if (a === "--viewport") out.viewport = argv[++i];
59
+ else if (a === "--timeout-ms") out.timeoutMs = parseInt(argv[++i], 10);
60
+ else if (a === "--out") out.out = argv[++i];
61
+ else if (a === "--selector") out.selector = argv[++i];
62
+ else if (a === "--y") out.y = argv[++i];
63
+ else if (a === "--text") out.text = argv[++i];
64
+ else if (a === "--full") out.full = true;
65
+ else if (!a.startsWith("--") && out.action === null) out.action = a;
66
+ else rest.push(a);
67
+ }
68
+ out.extra = rest;
69
+ return out;
70
+ }
71
+
72
+ function loadPlaywright() {
73
+ try {
74
+ return require("playwright-core");
75
+ } catch (e) { /* fall through */ }
76
+ const envDir = (process.env.PLAYWRIGHT_CORE_DIR || "").trim();
77
+ if (envDir) {
78
+ try {
79
+ return require(join(envDir, "playwright-core"));
80
+ } catch (e) { /* fall through */ }
81
+ try {
82
+ return require(envDir);
83
+ } catch (e) { /* fall through */ }
84
+ }
85
+ const conventional = "/home/hatch/workspace/crew-tools/node_modules/playwright-core";
86
+ if (existsSync(conventional)) {
87
+ try {
88
+ return require(conventional);
89
+ } catch (e) { /* fall through */ }
90
+ }
91
+ fail(3, {
92
+ ok: false,
93
+ not_possible: "NOT POSSIBLE: playwright-core is not installed or not resolvable. " +
94
+ "Install it (npm install playwright-core) or set PLAYWRIGHT_CORE_DIR.",
95
+ });
96
+ }
97
+
98
+ function findChromium() {
99
+ const envPath = (process.env.CHROME_PATH || "").trim();
100
+ if (envPath && existsSync(envPath)) return envPath;
101
+ const sysPath = "/opt/meta-chromium/chrome";
102
+ if (existsSync(sysPath)) return sysPath;
103
+ fail(3, {
104
+ ok: false,
105
+ not_possible: "NOT POSSIBLE: no Chromium binary found. " +
106
+ "Set CHROME_PATH to a Chromium/Chrome executable.",
107
+ });
108
+ }
109
+
110
+ // Archive dir: every screenshot is funneled here when SEE_ACT_ARCHIVE_DIR is
111
+ // set, regardless of phase. Returns the dir, or null when not archiving.
112
+ // A missing dir is created; an unusable dir is NOT POSSIBLE (exit 3) — lost
113
+ // evidence must fail loudly, never silently.
114
+ function resolveArchiveDir() {
115
+ const d = (process.env.SEE_ACT_ARCHIVE_DIR || "").trim();
116
+ if (!d) return null;
117
+ const abs = resolve(d);
118
+ try {
119
+ mkdirSync(abs, { recursive: true });
120
+ } catch (e) {
121
+ fail(3, {
122
+ ok: false,
123
+ not_possible: "NOT POSSIBLE: cannot create archive dir " + abs + ": " + ((e && e.message) || String(e)),
124
+ });
125
+ }
126
+ return abs;
127
+ }
128
+
129
+ // Next sequential archive path. The counter lives in <dir>/.seq so names are
130
+ // stable across the driver's one-process-per-invocation shape. No wall-clock,
131
+ // no randomness — purely sequential.
132
+ function nextArchivePath(dir, action, viewport) {
133
+ const seqFile = join(dir, ".seq");
134
+ let n = 0;
135
+ try {
136
+ const raw = readFileSync(seqFile, "utf8").trim();
137
+ const parsed = parseInt(raw, 10);
138
+ if (Number.isFinite(parsed) && parsed >= 0) n = parsed;
139
+ } catch (e) { /* first capture */ }
140
+ n += 1;
141
+ try {
142
+ writeFileSync(seqFile, String(n) + "\n");
143
+ } catch (e) {
144
+ fail(3, {
145
+ ok: false,
146
+ not_possible: "NOT POSSIBLE: archive dir not writable: " + dir,
147
+ });
148
+ }
149
+ return join(dir, String(n).padStart(3, "0") + "-" + action + "-" + viewport + ".png");
150
+ }
151
+
152
+ // Minimal forward proxy: Chromium -> proxy (loopback, allowed) -> target
153
+ // (loopback, made by us so the Local Network Access check never fires).
154
+ function startProxy() {
155
+ return new Promise((resolve, reject) => {
156
+ const server = createServer((clientReq, clientRes) => {
157
+ let url;
158
+ try {
159
+ url = new URL(clientReq.url);
160
+ } catch {
161
+ clientRes.writeHead(400);
162
+ clientRes.end("bad url");
163
+ return;
164
+ }
165
+ const lib = url.protocol === "https:" ? httpsRequest : httpRequest;
166
+ const headers = Object.assign({}, clientReq.headers);
167
+ headers.host = url.host;
168
+ const proxyReq = lib(
169
+ {
170
+ hostname: url.hostname,
171
+ port: url.port || (url.protocol === "https:" ? 443 : 80),
172
+ path: url.pathname + url.search,
173
+ method: clientReq.method,
174
+ headers: headers,
175
+ },
176
+ (proxyRes) => {
177
+ clientRes.writeHead(proxyRes.statusCode, proxyRes.headers);
178
+ proxyRes.pipe(clientRes);
179
+ }
180
+ );
181
+ proxyReq.on("error", (e) => {
182
+ clientRes.writeHead(502);
183
+ clientRes.end("proxy error: " + e.message);
184
+ });
185
+ clientReq.pipe(proxyReq);
186
+ });
187
+ server.on("error", reject);
188
+ server.listen(0, "127.0.0.1", () => {
189
+ resolve({ server: server, port: server.address().port });
190
+ });
191
+ });
192
+ }
193
+
194
+ async function main() {
195
+ const args = parseArgs(process.argv.slice(2));
196
+ if (!args.url) {
197
+ fail(2, { ok: false, error: "usage: node see-act.js --url <url> [--viewport desktop|mobile] <aria|shot|click|scroll|type> [args]" });
198
+ }
199
+ if (!VIEWPORTS[args.viewport]) {
200
+ fail(2, { ok: false, error: "unknown viewport: " + args.viewport + " (desktop|mobile)" });
201
+ }
202
+ const validActions = { aria: 1, shot: 1, click: 1, scroll: 1, type: 1 };
203
+ if (!validActions[args.action]) {
204
+ fail(2, { ok: false, error: "unknown action: " + args.action + " (aria|shot|click|scroll|type)" });
205
+ }
206
+ const needsShot = args.action !== "aria";
207
+ if (needsShot && !args.out && !process.env.SEE_ACT_ARCHIVE_DIR) {
208
+ fail(2, { ok: false, error: "action " + args.action + " requires --out <png> (or set SEE_ACT_ARCHIVE_DIR to archive automatically)" });
209
+ }
210
+ if (args.action === "click" && !args.selector) {
211
+ fail(2, { ok: false, error: "click requires --selector <css>" });
212
+ }
213
+ if (args.action === "type" && (!args.selector || args.text === undefined)) {
214
+ fail(2, { ok: false, error: "type requires --selector <css> --text <text>" });
215
+ }
216
+
217
+ const { chromium } = loadPlaywright();
218
+ const exePath = findChromium();
219
+ const proxy = await startProxy();
220
+
221
+ // Archival is resolved before the browser launches so an unusable dir
222
+ // fails fast (exit 3) instead of wasting a browser session.
223
+ const archiveDir = resolveArchiveDir();
224
+ const archivePath = (archiveDir && needsShot) ? nextArchivePath(archiveDir, args.action, args.viewport) : null;
225
+
226
+ const result = { ok: true, action: args.action, viewport: args.viewport };
227
+
228
+ // Capture helper: screenshot to the primary destination, then funnel a
229
+ // copy into the archive when one is configured. result.screenshot is the
230
+ // single path the agent should read.
231
+ async function capture(page, opts) {
232
+ const dest = args.out || archivePath;
233
+ await page.screenshot(Object.assign({ path: dest }, opts));
234
+ if (args.out) result.out = args.out;
235
+ result.screenshot = dest;
236
+ if (archivePath && resolve(dest) !== archivePath) {
237
+ copyFileSync(dest, archivePath);
238
+ result.archived = archivePath;
239
+ } else if (archivePath) {
240
+ result.archived = archivePath;
241
+ }
242
+ }
243
+
244
+ let browser = null;
245
+ try {
246
+ browser = await chromium.launch({
247
+ executablePath: exePath,
248
+ args: ["--no-sandbox"],
249
+ proxy: { server: "http://127.0.0.1:" + proxy.port },
250
+ });
251
+ const page = await browser.newPage({ viewport: VIEWPORTS[args.viewport] });
252
+ const consoleErrors = [];
253
+ page.on("console", (msg) => {
254
+ if (msg.type() === "error") consoleErrors.push(msg.text().slice(0, 300));
255
+ });
256
+ page.on("pageerror", (err) => {
257
+ consoleErrors.push(String((err && err.message) || err).slice(0, 300));
258
+ });
259
+
260
+ await page.goto(args.url, { waitUntil: "domcontentloaded", timeout: args.timeoutMs });
261
+ await page.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => {});
262
+ await page.waitForTimeout(SETTLE_MS);
263
+
264
+ if (args.action === "aria") {
265
+ const els = await page.evaluate(() => {
266
+ const out = [];
267
+ document.querySelectorAll("button, a, [role=button], [role=tab], input, select, textarea, h1, h2").forEach((el) => {
268
+ const t = (el.innerText || el.value || el.getAttribute("aria-label") || "").trim().slice(0, 80).replace(/\s+/g, " ");
269
+ if (t) out.push(el.tagName.toLowerCase() + ": " + t);
270
+ });
271
+ document.querySelectorAll("footer").forEach((el) => {
272
+ const t = (el.innerText || "").trim().slice(0, 300).replace(/\s+/g, " ");
273
+ if (t) out.push("footer: " + t);
274
+ });
275
+ return out.slice(0, 60);
276
+ });
277
+ result.aria = els;
278
+ } else if (args.action === "shot") {
279
+ await capture(page, { fullPage: !!args.full });
280
+ result.full_page = !!args.full;
281
+ } else if (args.action === "click") {
282
+ await page.click(args.selector, { timeout: args.timeoutMs });
283
+ await page.waitForTimeout(SETTLE_MS);
284
+ await capture(page, {});
285
+ result.selector = args.selector;
286
+ } else if (args.action === "scroll") {
287
+ const y = args.y === undefined || args.y === "bottom" ? "bottom" : String(parseInt(args.y, 10));
288
+ await page.evaluate((yy) => {
289
+ const wantBottom = yy === "bottom";
290
+ const px = wantBottom ? 0 : parseInt(yy, 10);
291
+ // Scroll the real scroller: the body, or the tallest scrollable
292
+ // container when the body itself does not scroll (app shells).
293
+ let el = document.scrollingElement || document.body;
294
+ if (el.scrollHeight <= el.clientHeight + 1) {
295
+ let best = null, bestH = 0;
296
+ document.querySelectorAll("*").forEach((e) => {
297
+ if (e.scrollHeight > e.clientHeight + 50 && e.scrollHeight > bestH) { best = e; bestH = e.scrollHeight; }
298
+ });
299
+ if (best) el = best;
300
+ }
301
+ el.scrollTo(0, wantBottom ? el.scrollHeight : px);
302
+ }, y);
303
+ await page.waitForTimeout(1000);
304
+ await capture(page, {});
305
+ result.y = y;
306
+ } else if (args.action === "type") {
307
+ await page.fill(args.selector, args.text, { timeout: args.timeoutMs });
308
+ await page.waitForTimeout(800);
309
+ await capture(page, {});
310
+ result.selector = args.selector;
311
+ }
312
+ result.console_errors = consoleErrors.slice(0, 10);
313
+ result.console_error_count = consoleErrors.length;
314
+ } catch (e) {
315
+ await browser.close().catch(() => {});
316
+ proxy.server.close();
317
+ fail(2, { ok: false, error: "action failed: " + ((e && e.message) || String(e)).slice(0, 500) });
318
+ }
319
+ await browser.close().catch(() => {});
320
+ proxy.server.close();
321
+ process.stdout.write(JSON.stringify(result) + "\n");
322
+ process.exit(0);
323
+ }
324
+
325
+ main().catch((e) => {
326
+ fail(2, { ok: false, error: "driver error: " + ((e && e.message) || String(e)).slice(0, 500) });
327
+ });
@@ -0,0 +1,203 @@
1
+ // serve-artifact.js — serve a built TS space locally for experiential QA.
2
+ //
3
+ // Serves <space-dir>/client/dist statically and dispatches POST */actions
4
+ // {action, args} to the compiled server actions with a locally-built Ctx,
5
+ // so a QA agent can drive the artifact in a real browser exactly as the
6
+ // shipped server code behaves. Read-only w.r.t. the space directory: never
7
+ // writes into it.
8
+ //
9
+ // Usage:
10
+ // node serve-artifact.js --space-dir <path> [--port <n>]
11
+ // Prints "READY port=<n>" on stdout, then serves until killed.
12
+ // --port 0 (default) picks an ephemeral port.
13
+ //
14
+ // Fidelity notes (what this is and isn't):
15
+ // - The served client and action handlers are the artifact's own built code.
16
+ // - The Ctx is locally built: privileged handlers run from the space's own
17
+ // server/dist/privileged.js when present; blobs are stored in a per-run
18
+ // temp dir and served back at /__blobs/<key>.
19
+ // - Environment (CREW_HOME and friends) is inherited from the caller — export
20
+ // what the artifact's server needs before starting this.
21
+ //
22
+ // Determinism: no wall-clock reads, no randomness.
23
+ "use strict";
24
+
25
+ const { createServer } = require("node:http");
26
+ const { join, normalize, dirname, sep } = require("node:path");
27
+ const { existsSync } = require("node:fs");
28
+ const { readFile, writeFile, mkdir, mkdtemp } = require("node:fs/promises");
29
+ const { tmpdir } = require("node:os");
30
+
31
+ function parseArgs(argv) {
32
+ const out = { spaceDir: null, port: 0, tag: null };
33
+ for (let i = 0; i < argv.length; i++) {
34
+ if (argv[i] === "--space-dir") out.spaceDir = argv[++i];
35
+ else if (argv[i] === "--port") out.port = parseInt(argv[++i], 10);
36
+ else if (argv[i] === "--tag") out.tag = argv[++i];
37
+ }
38
+ return out;
39
+ }
40
+
41
+ function failNotPossible(reason) {
42
+ process.stdout.write(JSON.stringify({ ok: false, not_possible: "NOT POSSIBLE: " + reason }) + "\n");
43
+ process.exit(3);
44
+ }
45
+
46
+ const CONTENT_TYPES = {
47
+ ".html": "text/html; charset=utf-8",
48
+ ".js": "text/javascript; charset=utf-8",
49
+ ".css": "text/css; charset=utf-8",
50
+ ".json": "application/json; charset=utf-8",
51
+ ".png": "image/png",
52
+ ".svg": "image/svg+xml",
53
+ ".ico": "image/x-icon",
54
+ ".woff2": "font/woff2",
55
+ ".woff": "font/woff",
56
+ ".ttf": "font/ttf",
57
+ };
58
+
59
+ async function main() {
60
+ const args = parseArgs(process.argv.slice(2));
61
+ if (!args.spaceDir) {
62
+ process.stderr.write("serve-artifact.js: --space-dir <path> is required\n");
63
+ process.exit(2);
64
+ }
65
+ if (!existsSync(args.spaceDir)) {
66
+ failNotPossible("no built artifact at " + args.spaceDir + " (deploy slug has no local build)");
67
+ }
68
+ const DIST = join(args.spaceDir, "client", "dist");
69
+ const ACTIONS_PATH = join(args.spaceDir, "server", "dist", "actions.js");
70
+ const PRIV_PATH = join(args.spaceDir, "server", "dist", "privileged.js");
71
+ if (!existsSync(DIST) || !existsSync(ACTIONS_PATH)) {
72
+ failNotPossible(args.spaceDir + " is not a built TS space (need client/dist and server/dist/actions.js)");
73
+ }
74
+
75
+ const { Actions } = await import(ACTIONS_PATH);
76
+ let privilegedByName = new Map();
77
+ if (existsSync(PRIV_PATH)) {
78
+ const { privilegedHandlers } = await import(PRIV_PATH);
79
+ privilegedByName = new Map(privilegedHandlers.entries.map((e) => [e.contract.name, e.handler]));
80
+ }
81
+
82
+ // Local blob store (outside the space dir — the server stays read-only
83
+ // w.r.t. the space). Keys are path-safe segments; anything else is rejected.
84
+ const BLOB_DIR = await mkdtemp(join(tmpdir(), "serve-artifact-blobs-"));
85
+ function safeBlobPath(key) {
86
+ if (typeof key !== "string" || !key || key.includes("..")) return null;
87
+ const parts = key.split("/").filter((s) => s && s !== "." && s !== "..");
88
+ if (parts.length === 0 || parts.some((s) => s.includes(sep))) return null;
89
+ const p = normalize(join(BLOB_DIR, ...parts));
90
+ if (!p.startsWith(BLOB_DIR + sep)) return null;
91
+ return p;
92
+ }
93
+
94
+ function makeCtx(def) {
95
+ const declared = new Set(((def && def.privileged) || []).map((c) => c.name));
96
+ return {
97
+ async executePrivileged(contract, actionArgs) {
98
+ if (!contract || typeof contract.name !== "string") throw new Error("executePrivileged: bad contract descriptor");
99
+ if (!declared.has(contract.name)) throw new Error("executePrivileged(" + contract.name + ") was not declared by this action");
100
+ const handler = privilegedByName.get(contract.name);
101
+ if (!handler) throw new Error("no privileged handler: " + contract.name);
102
+ const parsed = contract.request.parse(actionArgs);
103
+ const result = await handler(parsed);
104
+ return contract.response.parse(result);
105
+ },
106
+ invalidateQueries() {},
107
+ blobs: {
108
+ async head(key) {
109
+ const p = safeBlobPath(key);
110
+ if (!p || !existsSync(p)) return null;
111
+ return { key: key };
112
+ },
113
+ async put(key, bytes, opts) {
114
+ const p = safeBlobPath(key);
115
+ if (!p) throw new Error("blobs.put: invalid key");
116
+ await mkdir(dirname(p), { recursive: true });
117
+ await writeFile(p, Buffer.from(bytes));
118
+ return { key: key };
119
+ },
120
+ async getUrl(key) {
121
+ return "/__blobs/" + key.split("/").map(encodeURIComponent).join("/");
122
+ },
123
+ },
124
+ };
125
+ }
126
+
127
+ const server = createServer(async (req, res) => {
128
+ const url = new URL(req.url, "http://local");
129
+ if (req.method === "GET" && url.pathname.startsWith("/__blobs/")) {
130
+ const key = url.pathname.slice("/__blobs/".length).split("/").map(decodeURIComponent).join("/");
131
+ const p = safeBlobPath(key);
132
+ if (!p || !existsSync(p)) {
133
+ res.writeHead(404);
134
+ res.end("not found");
135
+ return;
136
+ }
137
+ try {
138
+ const data = await readFile(p);
139
+ const ext = p.slice(p.lastIndexOf("."));
140
+ res.writeHead(200, { "content-type": CONTENT_TYPES[ext] || "application/octet-stream" });
141
+ res.end(data);
142
+ } catch {
143
+ res.writeHead(404);
144
+ res.end("not found");
145
+ }
146
+ return;
147
+ }
148
+ if (req.method === "POST" && url.pathname.endsWith("/actions")) {
149
+ let body;
150
+ try {
151
+ const chunks = [];
152
+ for await (const chunk of req) chunks.push(chunk);
153
+ body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
154
+ } catch {
155
+ res.writeHead(400, { "content-type": "application/json" });
156
+ res.end(JSON.stringify({ error: "bad json" }));
157
+ return;
158
+ }
159
+ const def = Actions[body.action];
160
+ if (!def) {
161
+ res.writeHead(404, { "content-type": "application/json" });
162
+ res.end(JSON.stringify({ error: "unknown action: " + body.action }));
163
+ return;
164
+ }
165
+ try {
166
+ const parsedArgs = def.request.parse(body.args || {});
167
+ const result = await def.handler(makeCtx(def), parsedArgs);
168
+ res.writeHead(200, { "content-type": "application/json" });
169
+ res.end(JSON.stringify({ data: def.response.parse(result) }));
170
+ } catch (err) {
171
+ process.stderr.write("action 500: " + body.action + " :: " + String((err && err.message) || err).slice(0, 300) + "\n");
172
+ res.writeHead(500, { "content-type": "application/json" });
173
+ res.end(JSON.stringify({ error: String((err && err.message) || err) }));
174
+ }
175
+ return;
176
+ }
177
+ let p = normalize(join(DIST, url.pathname === "/" ? "index.html" : url.pathname.slice(1)));
178
+ if (!p.startsWith(DIST)) {
179
+ res.writeHead(403);
180
+ res.end("forbidden");
181
+ return;
182
+ }
183
+ if (!existsSync(p)) p = join(DIST, "index.html");
184
+ const ext = p.slice(p.lastIndexOf("."));
185
+ try {
186
+ const data = await readFile(p);
187
+ res.writeHead(200, { "content-type": CONTENT_TYPES[ext] || "application/octet-stream" });
188
+ res.end(data);
189
+ } catch {
190
+ res.writeHead(404);
191
+ res.end("not found");
192
+ }
193
+ });
194
+
195
+ server.listen(args.port, "127.0.0.1", () => {
196
+ process.stdout.write("READY port=" + server.address().port + "\n");
197
+ });
198
+ }
199
+
200
+ main().catch((e) => {
201
+ process.stderr.write("serve-artifact.js: " + ((e && e.message) || String(e)) + "\n");
202
+ process.exit(2);
203
+ });