@foldspace_npm/harness 0.1.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.
package/bin/inject.mjs ADDED
@@ -0,0 +1,524 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Local Foldspace injector.
4
+ *
5
+ * Generates a throwaway Chrome extension for a target app and launches Chrome
6
+ * with it loaded, so you can exercise agent actions against a live site without
7
+ * touching the committed production extension in extension/.
8
+ *
9
+ * npm run inject # default target
10
+ * npm run inject -- --target app
11
+ * npm run inject -- --url https://app.other.com --product ABC123 --agent other-agent
12
+ * npm run inject -- --remote # use deployed actions, not localhost
13
+ * npm run inject -- --print # generate only, don't launch
14
+ */
15
+ import fs from "fs";
16
+ import path from "path";
17
+ import os from "os";
18
+ import { spawn } from "child_process";
19
+ import { fileURLToPath } from "url";
20
+
21
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
+ // Resolve the CONSUMING repo, not this package. FOLDSPACE_PROJECT_DIR lets a
23
+ // hosted builder point the harness at a workspace it controls.
24
+ const root = process.env.FOLDSPACE_PROJECT_DIR || process.cwd();
25
+ const workDir = path.join(root, ".foldspace-dev");
26
+ const extDir = path.join(workDir, "extension");
27
+ const themeDir = path.join(workDir, "theme-extension");
28
+ const profileDir = path.join(workDir, "chrome-profile");
29
+
30
+ // ---------------------------------------------------------------- args
31
+
32
+ function parseArgs(argv) {
33
+ const out = { flags: new Set() };
34
+ for (let i = 0; i < argv.length; i++) {
35
+ const a = argv[i];
36
+ if (a === "--remote") out.flags.add("remote");
37
+ else if (a === "--print") out.flags.add("print");
38
+ else if (a === "--embedded") out.flags.add("embedded");
39
+ else if (a.startsWith("--")) out[a.slice(2)] = argv[++i];
40
+ }
41
+ return out;
42
+ }
43
+ const args = parseArgs(process.argv.slice(2));
44
+
45
+ // ---------------------------------------------------------------- config
46
+
47
+ const configPath = path.join(root, "foldspace.dev.json");
48
+ if (!fs.existsSync(configPath)) {
49
+ console.error("Missing foldspace.dev.json at project root.");
50
+ process.exit(1);
51
+ }
52
+ const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
53
+ const targetName = args.target || cfg.defaultTarget;
54
+ const base = cfg.targets[targetName] || {};
55
+
56
+ if (!args.url && !base.startUrl) {
57
+ console.error(
58
+ `Unknown target "${targetName}". Add it to foldspace.dev.json, or pass --url/--product/--agent.`
59
+ );
60
+ process.exit(1);
61
+ }
62
+
63
+ const startUrl = args.url || base.startUrl;
64
+
65
+ // Derive host patterns from the URL when not configured — registrable domain + wildcard.
66
+ function hostsFor(urlStr, configured) {
67
+ if (configured && configured.length) return configured;
68
+ const { hostname } = new URL(urlStr);
69
+ const parts = hostname.split(".");
70
+ const apex = parts.length > 2 ? parts.slice(-2).join(".") : hostname;
71
+ return [apex, `*.${apex}`];
72
+ }
73
+
74
+ // A --url with no --target means we are pointing at an app the config does not
75
+ // describe, so the named target's ids and host patterns must not leak in.
76
+ // Passing --target as well keeps that target's config and just moves the start URL.
77
+ const explicitUrl = Boolean(args.url) && !args.target;
78
+ const configuredHosts = args.hosts
79
+ ? args.hosts.split(",")
80
+ : explicitUrl
81
+ ? null
82
+ : base.hosts;
83
+
84
+ const target = {
85
+ name: explicitUrl && !args.target ? new URL(startUrl).hostname : targetName,
86
+ startUrl,
87
+ productId: args.product || (explicitUrl ? null : base.productId),
88
+ agentApiName: args.agent || (explicitUrl ? null : base.agentApiName),
89
+ hosts: hostsFor(startUrl, configuredHosts),
90
+ mode: args.flags.has("embedded") ? "EMBEDDED" : base.mode || "OVERLAY",
91
+ loadLocally: args.flags.has("remote") ? false : base.loadLocally !== false,
92
+ overrideKey: args.key || base.overrideKey || null,
93
+ remoteActionsEnv: args.env || base.remoteActionsEnv || "PROD",
94
+ };
95
+
96
+ for (const required of ["productId", "agentApiName"]) {
97
+ if (!target[required]) {
98
+ console.error(`Missing ${required}. Pass --product / --agent or add it to foldspace.dev.json.`);
99
+ process.exit(1);
100
+ }
101
+ }
102
+
103
+ const sdkUrl = cfg.sdkUrl;
104
+ const localActionsUrl = cfg.localActionsUrl || "http://localhost:3007/dist/index.js";
105
+ const matches = target.hosts.map((h) => `*://${h}/*`);
106
+ const requestDomains = [...new Set(target.hosts.map((h) => h.replace(/^\*\./, "")))];
107
+ const scriptHost = new URL(sdkUrl).hostname;
108
+
109
+ // ---------------------------------------------------------------- generate
110
+
111
+ fs.rmSync(extDir, { recursive: true, force: true });
112
+ fs.mkdirSync(extDir, { recursive: true });
113
+
114
+ // A theme-only extension, regenerated each run so it survives `rm -rf
115
+ // .foldspace-dev`. Purely cosmetic: it paints the dev browser Foldspace blue so
116
+ // the window is never mistaken for the user's own Chrome. Chrome ignores
117
+ // hand-edited theme keys in Preferences, but honours a theme extension.
118
+ fs.rmSync(themeDir, { recursive: true, force: true });
119
+ fs.mkdirSync(themeDir, { recursive: true });
120
+ fs.writeFileSync(
121
+ path.join(themeDir, "manifest.json"),
122
+ JSON.stringify({
123
+ manifest_version: 3,
124
+ name: "Foldspace Dev Theme",
125
+ version: "1.0",
126
+ description: "Paints the dev browser Foldspace blue.",
127
+ theme: {
128
+ colors: {
129
+ frame: [50, 71, 242],
130
+ frame_inactive: [70, 89, 235],
131
+ toolbar: [50, 71, 242],
132
+ tab_background_text: [214, 220, 255],
133
+ tab_text: [255, 255, 255],
134
+ bookmark_text: [255, 255, 255],
135
+ ntp_background: [16, 22, 60],
136
+ ntp_text: [255, 255, 255],
137
+ omnibox_background: [28, 38, 110],
138
+ omnibox_text: [255, 255, 255],
139
+ },
140
+ tints: { buttons: [-1.0, -1.0, 1.0] },
141
+ },
142
+ }, null, 2),
143
+ );
144
+
145
+ const manifest = {
146
+ manifest_version: 3,
147
+ name: `Foldspace Dev — ${target.name}`,
148
+ version: "0.0.1",
149
+ description: `Local Foldspace injection for ${target.agentApiName}`,
150
+ permissions: [
151
+ "scripting",
152
+ "activeTab",
153
+ "tabs",
154
+ "declarativeNetRequest",
155
+ "declarativeNetRequestWithHostAccess",
156
+ ],
157
+ host_permissions: [...matches, "http://localhost/*", `https://${scriptHost}/*`],
158
+ declarative_net_request: {
159
+ rule_resources: [{ id: "csp_bypass_rules", enabled: true, path: "csp_rules.json" }],
160
+ },
161
+ ...(fs.existsSync(path.join(root, "extension", "logo.png"))
162
+ ? { icons: { 16: "logo.png", 32: "logo.png", 48: "logo.png", 128: "logo.png" } }
163
+ : {}),
164
+ background: { service_worker: "background.js", type: "module" },
165
+ web_accessible_resources: [{ resources: ["index.js"], matches }],
166
+ content_scripts: [
167
+ { matches, js: ["contentScript.js"], run_at: "document_start", all_frames: false },
168
+ ],
169
+ };
170
+
171
+ // Strip CSP + frame guards on the target, and allow the target to pull from
172
+ // localhost and the SDK host.
173
+ const rules = [
174
+ {
175
+ id: 1,
176
+ priority: 1,
177
+ action: {
178
+ type: "modifyHeaders",
179
+ responseHeaders: [
180
+ { operation: "remove", header: "content-security-policy" },
181
+ { operation: "remove", header: "content-security-policy-report-only" },
182
+ { operation: "remove", header: "x-frame-options" },
183
+ ],
184
+ },
185
+ condition: {
186
+ requestDomains,
187
+ resourceTypes: [
188
+ "main_frame", "sub_frame", "stylesheet", "script", "xmlhttprequest",
189
+ "font", "object", "image", "media", "websocket", "other",
190
+ ],
191
+ },
192
+ },
193
+ {
194
+ id: 2,
195
+ priority: 1,
196
+ action: {
197
+ type: "modifyHeaders",
198
+ responseHeaders: [
199
+ { operation: "set", header: "access-control-allow-origin", value: new URL(startUrl).origin },
200
+ { operation: "set", header: "access-control-allow-methods", value: "GET, POST, PUT, DELETE, OPTIONS, HEAD, PATCH" },
201
+ { operation: "set", header: "access-control-allow-headers", value: "*" },
202
+ { operation: "set", header: "access-control-allow-credentials", value: "true" },
203
+ ],
204
+ },
205
+ condition: {
206
+ requestDomains: ["localhost", scriptHost],
207
+ initiatorDomains: requestDomains,
208
+ resourceTypes: ["script", "xmlhttprequest", "stylesheet", "font", "image", "media", "websocket", "other"],
209
+ },
210
+ },
211
+ ];
212
+
213
+ const indexJs = `// GENERATED by scripts/inject.mjs — do not edit. Target: ${target.name}
214
+ const LOAD_LOCALLY = ${target.loadLocally};
215
+ const SDK_URL = ${JSON.stringify(sdkUrl)};
216
+ const LOCAL_ACTIONS_URL = ${JSON.stringify(localActionsUrl)};
217
+ const NAMESPACE = "foldspace";
218
+ const PRODUCT_ID = ${JSON.stringify(target.productId)};
219
+ const AGENT_API_NAME = ${JSON.stringify(target.agentApiName)};
220
+ const REMOTE_ACTIONS_ENV = ${JSON.stringify(target.remoteActionsEnv)};
221
+ const MODE = ${JSON.stringify(target.mode)};
222
+ const OVERRIDE_KEY = ${JSON.stringify(target.overrideKey)};
223
+
224
+ function attachActions() {
225
+ const actions = window.__FOLDSPACE_REMOTE_ACTIONS__;
226
+ const agent = window.__FOLDSPACE_AGENT__;
227
+ if (!actions || !agent) {
228
+ console.error("[foldspace-dev] actions or agent missing");
229
+ return;
230
+ }
231
+ delete window.__FOLDSPACE_REMOTE_ACTIONS__;
232
+ agent.addActionHandlers(actions);
233
+ console.log("[foldspace-dev] actions attached:", Object.keys(actions).length);
234
+ }
235
+
236
+ function loadRemoteActionsLocally() {
237
+ const script = document.createElement("script");
238
+ script.type = "text/javascript";
239
+ script.async = true;
240
+ script.src = LOCAL_ACTIONS_URL + "?t=" + Date.now();
241
+ script.onload = attachActions;
242
+ script.onerror = () => {
243
+ // Direct fetch blocked — fall back to the service worker.
244
+ window.addEventListener(
245
+ "message",
246
+ (event) => {
247
+ if (event.data.type === "INJECT_FOLDSPACE_REMOTE_ACTIONS_RESPONSE" && event.data.ok) {
248
+ attachActions();
249
+ }
250
+ },
251
+ { once: true }
252
+ );
253
+ window.postMessage({ type: "INJECT_FOLDSPACE_REMOTE_ACTIONS", src: LOCAL_ACTIONS_URL }, "*");
254
+ console.warn("[foldspace-dev] direct load failed, using background fetch. Is 'npm run dev' running?");
255
+ };
256
+ document.head.appendChild(script);
257
+ }
258
+
259
+ // The real SDK has loaded when it has replaced the stub with its own methods.
260
+ // The stub only ever has q and k.
261
+ function sdkLoaded() {
262
+ return typeof window[NAMESPACE] === "function" && typeof window[NAMESPACE].agent === "function";
263
+ }
264
+
265
+ function appendSdkScript() {
266
+ const w = window, d = document, n = NAMESPACE;
267
+ const k = OVERRIDE_KEY || ("EU-" + PRODUCT_ID + "-1-1");
268
+ w[n] = w[n] || function () { (w[n].q = w[n].q || []).push(arguments); };
269
+ w.__FOLD_SPACE__ = n;
270
+ w[n].k = k;
271
+ if (d.querySelector("script[data-foldspace-sdk]")) return;
272
+ // Wait for a real body. The SDK mounts its widget container on load, and
273
+ // appendChild into a null body throws inside eucera.js — the widget then
274
+ // never renders even though the SDK "loaded". Throwing here is deliberate:
275
+ // the retry loop keeps trying until the document is ready for it.
276
+ if (!d.body) throw new Error("document.body not ready");
277
+ const s = d.createElement("script");
278
+ s.async = true;
279
+ s.src = SDK_URL + "?k=" + k;
280
+ s.setAttribute("data-foldspace-sdk", "1");
281
+ const h = d.getElementsByTagName("script")[0];
282
+ if (h && h.parentNode) h.parentNode.insertBefore(s, h);
283
+ else (d.head || d.documentElement).appendChild(s);
284
+ }
285
+
286
+ function initFoldspaceSDK() {
287
+ // At document-start there may be no head and no documentElement yet, so the
288
+ // first append can throw. Schedule the retry BEFORE trying, or a throw here
289
+ // leaves the stub on window with nothing ever loading the real SDK — which
290
+ // looks identical to a working page until you ask for the agent.
291
+ let tries = 0;
292
+ const retry = setInterval(() => {
293
+ if (sdkLoaded() || ++tries > 60) { clearInterval(retry); return; }
294
+ tryAppendSdkScript();
295
+ }, 250);
296
+ document.addEventListener("DOMContentLoaded", tryAppendSdkScript);
297
+ tryAppendSdkScript();
298
+ }
299
+
300
+ function tryAppendSdkScript() {
301
+ try { appendSdkScript(); } catch (e) { /* DOM not ready yet; the retry covers it */ }
302
+ }
303
+
304
+ function init() {
305
+ const win = window;
306
+ win.foldspace("when", "ready", async () => {
307
+ const configuration = { enableDebugLogs: true };
308
+
309
+ if (MODE === "EMBEDDED") {
310
+ const container = document.createElement("div");
311
+ container.id = "foldspace-container";
312
+ document.body.appendChild(container);
313
+ configuration.embeddedConfiguration = { container };
314
+ }
315
+
316
+ if (!LOAD_LOCALLY) {
317
+ configuration.remoteActionsSettings = { enabled: true, environment: REMOTE_ACTIONS_ENV };
318
+ console.log("[foldspace-dev] remote actions (" + REMOTE_ACTIONS_ENV + ")");
319
+ } else {
320
+ console.log("[foldspace-dev] local actions from " + LOCAL_ACTIONS_URL);
321
+ }
322
+
323
+ const agent = win.foldspace.agent({ apiName: AGENT_API_NAME, mode: MODE, configuration });
324
+ win.__FOLDSPACE_AGENT__ = agent;
325
+
326
+ agent.on("*", (e) => {
327
+ if (e.eventName === "agent.ready" && LOAD_LOCALLY) {
328
+ // attach.mjs injects the bundle at document-start, so the global is
329
+ // usually already here — no network, no dev server, and it works
330
+ // against a browser that cannot reach this machine at all.
331
+ if (window.__FOLDSPACE_REMOTE_ACTIONS__) attachActions();
332
+ else loadRemoteActionsLocally();
333
+ }
334
+ });
335
+
336
+ // Ctrl+Shift+R reloads local actions without a page refresh.
337
+ window.addEventListener("keydown", (e) => {
338
+ if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "r" && LOAD_LOCALLY) {
339
+ e.preventDefault();
340
+ console.log("[foldspace-dev] reloading actions...");
341
+ loadRemoteActionsLocally();
342
+ }
343
+ });
344
+ });
345
+ }
346
+
347
+ if (window.top !== window.self) {
348
+ // Third-party iframes (Zendesk, Chameleon, ...) must not run this.
349
+ } else {
350
+ // Guard on the loaded SDK, not on the stub: window.foldspace being truthy
351
+ // only means some copy of this script got as far as defining the queue.
352
+ if (!sdkLoaded()) initFoldspaceSDK();
353
+ init();
354
+ }
355
+ function addDevBadge() {
356
+ if (document.getElementById("foldspace-dev-badge")) return;
357
+ const b = document.createElement("div");
358
+ b.id = "foldspace-dev-badge";
359
+ b.textContent = "FOLDSPACE DEV \u00b7 " + AGENT_API_NAME;
360
+ b.style.cssText = [
361
+ "position:fixed", "top:0", "left:0", "z-index:2147483647",
362
+ "background:#3247F2", "color:#fff",
363
+ "font:700 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace",
364
+ "letter-spacing:.12em", "padding:5px 10px",
365
+ "border-bottom-right-radius:4px", "pointer-events:none",
366
+ "box-shadow:0 1px 6px rgba(0,0,0,.35)",
367
+ ].join(";");
368
+ (document.body || document.documentElement).appendChild(b);
369
+ }
370
+ if (window.top === window.self) {
371
+ // The host app is a SPA and will re-render over us, so keep re-asserting.
372
+ const keepBadge = () => { if (document.body) addDevBadge(); };
373
+ keepBadge();
374
+ document.addEventListener("DOMContentLoaded", keepBadge);
375
+ setInterval(keepBadge, 2000);
376
+ }
377
+
378
+ console.log("[foldspace-dev] injected", { agent: AGENT_API_NAME, product: PRODUCT_ID, mode: MODE });
379
+ `;
380
+
381
+ fs.writeFileSync(path.join(extDir, "manifest.json"), JSON.stringify(manifest, null, 2));
382
+ fs.writeFileSync(path.join(extDir, "csp_rules.json"), JSON.stringify(rules, null, 2));
383
+ fs.writeFileSync(path.join(extDir, "index.js"), indexJs);
384
+ for (const f of ["contentScript.js", "background.js"]) {
385
+ fs.copyFileSync(path.join(root, "extension", f), path.join(extDir, f));
386
+ }
387
+ const logoSrc = path.join(root, "extension", "logo.png");
388
+ if (fs.existsSync(logoSrc)) fs.copyFileSync(logoSrc, path.join(extDir, "logo.png"));
389
+
390
+ console.log(`Generated ${path.relative(root, extDir)}/`);
391
+ console.log(` target ${target.name}`);
392
+ console.log(` agent ${target.agentApiName} (product ${target.productId})`);
393
+ console.log(` hosts ${target.hosts.join(", ")}`);
394
+ console.log(` actions ${target.loadLocally ? localActionsUrl : "remote " + target.remoteActionsEnv}`);
395
+ console.log(` url ${startUrl}`);
396
+
397
+ if (args.flags.has("print")) process.exit(0);
398
+
399
+ // ---------------------------------------------------------------- launch
400
+
401
+ function findChrome() {
402
+ const candidates =
403
+ os.platform() === "darwin"
404
+ ? [
405
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
406
+ "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
407
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
408
+ ]
409
+ : ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium"];
410
+ return candidates.find((p) => fs.existsSync(p));
411
+ }
412
+
413
+ const chrome = process.env.CHROME_PATH || findChrome();
414
+ if (!chrome) {
415
+ console.error("\nChrome not found. Set CHROME_PATH=/path/to/chrome and retry.");
416
+ process.exit(1);
417
+ }
418
+
419
+ fs.mkdirSync(profileDir, { recursive: true });
420
+
421
+ const debugPort = args.port || "9222";
422
+
423
+ // Record it so attach does not have to be told again. inject and attach
424
+ // disagreeing about the port is how you end up attached to a different
425
+ // browser than the one you launched.
426
+ fs.mkdirSync(workDir, { recursive: true });
427
+ fs.writeFileSync(
428
+ path.join(workDir, "state.json"),
429
+ JSON.stringify({ debugPort, target: targetName }, null, 2),
430
+ );
431
+
432
+ // Name the profile so this window is identifiable among other Chrome windows.
433
+ function nameProfile() {
434
+ const writeMerged = (file, mutate) => {
435
+ let data = {};
436
+ try { data = JSON.parse(fs.readFileSync(file, "utf8")); } catch {}
437
+ mutate(data);
438
+ fs.mkdirSync(path.dirname(file), { recursive: true });
439
+ fs.writeFileSync(file, JSON.stringify(data));
440
+ };
441
+ const label = `Foldspace Dev \u2014 ${target.name}`;
442
+ writeMerged(path.join(profileDir, "Local State"), (d) => {
443
+ d.profile = d.profile || {};
444
+ d.profile.info_cache = d.profile.info_cache || {};
445
+ d.profile.info_cache.Default = {
446
+ ...(d.profile.info_cache.Default || {}),
447
+ name: label,
448
+ is_using_default_name: false,
449
+ avatar_icon: "chrome://theme/IDR_PROFILE_AVATAR_26",
450
+ is_using_default_avatar: false,
451
+ };
452
+ d.profile.last_used = "Default";
453
+ });
454
+ writeMerged(path.join(profileDir, "Default", "Preferences"), (d) => {
455
+ d.profile = d.profile || {};
456
+ d.profile.name = label;
457
+ });
458
+ }
459
+ nameProfile();
460
+
461
+ const chromeArgs = [
462
+ `--user-data-dir=${profileDir}`,
463
+ `--remote-debugging-port=${debugPort}`,
464
+ // The dev extension plus a theme-only extension that paints the browser
465
+ // Foldspace blue — so this window is never mistaken for your own Chrome.
466
+ `--load-extension=${extDir},${themeDir}`,
467
+ `--disable-extensions-except=${extDir},${themeDir}`,
468
+ // Chrome 136+ dropped --load-extension. Keep the legacy switch for older
469
+ // builds, and enable the CDP Extensions domain so we can load it at runtime.
470
+ "--disable-features=DisableLoadExtensionCommandLineSwitch",
471
+ "--enable-unsafe-extension-debugging",
472
+ "--no-first-run",
473
+ "--no-default-browser-check",
474
+ "--test-type",
475
+ startUrl,
476
+ ];
477
+
478
+ console.log(`\nLaunching Chrome \u2014 profile ${path.relative(root, profileDir)}, devtools on :${debugPort}.`);
479
+ console.log("Log in to the app once — the profile persists between runs.\n");
480
+
481
+ const child = spawn(chrome, chromeArgs, { detached: true, stdio: "ignore" });
482
+ child.unref();
483
+
484
+ // --- theme -------------------------------------------------------------
485
+ //
486
+ // Chrome 151 ignores --load-extension entirely: launching with it produces a
487
+ // profile with ZERO installed extensions. That is also why the generated dev
488
+ // extension has never actually loaded — its content script, service worker and
489
+ // CSP rules have all been inert. Verified 2026-08-25 by reading
490
+ // Default/Preferences after launch.
491
+ //
492
+ // The CDP Extensions domain still works (that is what
493
+ // --enable-unsafe-extension-debugging is for), so load the theme that way.
494
+ // Only the theme: the SDK and actions arrive over CDP from attach.mjs, and the
495
+ // dev extension is not required for them.
496
+ async function loadTheme() {
497
+ const endpoint = `http://127.0.0.1:${debugPort}/json/version`;
498
+ for (let i = 0; i < 40; i++) {
499
+ try {
500
+ const version = await (await fetch(endpoint)).json();
501
+ const ws = new WebSocket(version.webSocketDebuggerUrl);
502
+ await new Promise((resolve, reject) => {
503
+ ws.addEventListener("open", resolve);
504
+ ws.addEventListener("error", reject);
505
+ });
506
+ const done = new Promise((resolve) => {
507
+ ws.addEventListener("message", (event) => {
508
+ const message = JSON.parse(event.data);
509
+ if (message.id === 1) resolve(message);
510
+ });
511
+ });
512
+ ws.send(JSON.stringify({ id: 1, method: "Extensions.loadUnpacked", params: { path: themeDir } }));
513
+ const result = await done;
514
+ ws.close();
515
+ if (result.error) console.log(` theme not applied: ${result.error.message}`);
516
+ return;
517
+ } catch {
518
+ await new Promise((resolve) => setTimeout(resolve, 500));
519
+ }
520
+ }
521
+ console.log(" theme not applied: Chrome did not expose CDP in time");
522
+ }
523
+
524
+ await loadTheme();
@@ -0,0 +1,29 @@
1
+ import { execFileSync } from "child_process";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const projectDir = process.env.FOLDSPACE_PROJECT_DIR || process.cwd();
8
+ const projectName = path.basename(projectDir);
9
+ const extensionDir = path.join(projectDir, "extension");
10
+ const zipPath = path.join(projectDir, `${projectName}-extension.zip`);
11
+
12
+ if (!fs.existsSync(extensionDir)) {
13
+ console.error("packageExtension: extension/ directory not found");
14
+ process.exit(1);
15
+ }
16
+
17
+ try {
18
+ if (fs.existsSync(zipPath)) {
19
+ fs.rmSync(zipPath);
20
+ }
21
+ execFileSync("zip", ["-r", zipPath, "."], {
22
+ cwd: extensionDir,
23
+ stdio: "inherit",
24
+ });
25
+ console.log(`packageExtension: created ${zipPath}`);
26
+ } catch {
27
+ console.error("packageExtension: failed to create extension.zip");
28
+ process.exit(1);
29
+ }
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@foldspace_npm/harness",
3
+ "version": "0.1.0",
4
+ "description": "Build, inject and verify Foldspace agent experiences against a live app.",
5
+ "type": "module",
6
+ "bin": {
7
+ "foldspace": "bin/cli.mjs",
8
+ "foldspace-build": "bin/build-cli.mjs",
9
+ "foldspace-inject": "bin/inject.mjs",
10
+ "foldspace-attach": "bin/attach.mjs",
11
+ "foldspace-deploy": "bin/deploy.mjs",
12
+ "foldspace-package-extension": "bin/packageExtension.mjs"
13
+ },
14
+ "files": ["bin", "src", "templates", "README.md"],
15
+ "dependencies": {
16
+ "esbuild": "^0.20.0",
17
+ "tsx": "^4.7.0"
18
+ },
19
+ "engines": { "node": ">=20" },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ }
23
+ }