@foldspace_npm/harness 0.1.9 → 0.1.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.
@@ -0,0 +1,241 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { createCdpRequestManager } from "./cdp-request-manager.mjs";
6
+
7
+ export const SENTINEL_FILE = ".foldspace-sentinel";
8
+
9
+ export function chromeProfileDir(root) {
10
+ return path.join(root, ".foldspace-dev", "chrome-profile");
11
+ }
12
+
13
+ export function sentinelPath(profileDir) {
14
+ return path.join(profileDir, SENTINEL_FILE);
15
+ }
16
+
17
+ function present(value) {
18
+ if (value == null) return null;
19
+ const text = String(value).trim();
20
+ return text ? text : null;
21
+ }
22
+
23
+ function resolvedPath(filePath) {
24
+ try {
25
+ return fs.realpathSync(filePath);
26
+ } catch {
27
+ return path.resolve(filePath);
28
+ }
29
+ }
30
+
31
+ export function writeLaunchSentinel(profileDir) {
32
+ const token = crypto.randomBytes(16).toString("hex");
33
+ fs.mkdirSync(profileDir, { recursive: true });
34
+ fs.writeFileSync(sentinelPath(profileDir), `${token}\n`, "utf8");
35
+ return token;
36
+ }
37
+
38
+ export function readLaunchSentinel(profileDir) {
39
+ try {
40
+ return present(fs.readFileSync(sentinelPath(profileDir), "utf8"));
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ export function verifyLaunchSentinel({ profileDir, token } = {}) {
47
+ const onDisk = readLaunchSentinel(profileDir);
48
+ const expected = present(token);
49
+ if (!expected || !onDisk || onDisk !== expected) {
50
+ return { ok: false, reason: "sentinel_mismatch" };
51
+ }
52
+ return { ok: true };
53
+ }
54
+
55
+ export function parseChromeVersionText(text) {
56
+ const body = String(text || "");
57
+ const profilePath = present(body.match(/Profile Path\s+(.+)/i)?.[1]);
58
+ const commandLine = present(
59
+ body.match(/Command Line\s+(.+?)(?=\n[A-Z][A-Za-z ]+\s|\n{2}|$)/s)?.[1],
60
+ );
61
+ const userDataMatch = commandLine?.match(
62
+ /--user-data-dir(?:=|\s+)(?:"([^"]+)"|(\S+))/,
63
+ );
64
+ return {
65
+ profilePath,
66
+ commandLine,
67
+ userDataDir: present(userDataMatch?.[1] || userDataMatch?.[2]),
68
+ };
69
+ }
70
+
71
+ export function reportedProfileMatches(profileDir, reported = {}) {
72
+ const expected = resolvedPath(profileDir);
73
+ if (reported.userDataDir) {
74
+ return resolvedPath(reported.userDataDir) === expected;
75
+ }
76
+ if (reported.profilePath) {
77
+ return resolvedPath(path.dirname(reported.profilePath)) === expected;
78
+ }
79
+ return false;
80
+ }
81
+
82
+ export function verifyCdpOwnership({ profileDir, token, reported } = {}) {
83
+ const sentinel = verifyLaunchSentinel({ profileDir, token });
84
+ if (!sentinel.ok) return sentinel;
85
+ if (!reportedProfileMatches(profileDir, reported)) {
86
+ return { ok: false, reason: "profile_mismatch" };
87
+ }
88
+ return { ok: true };
89
+ }
90
+
91
+ export function ownershipErrorMessage(
92
+ reason,
93
+ { port, profileDir, root } = {},
94
+ ) {
95
+ const profile =
96
+ root && profileDir
97
+ ? path.relative(root, profileDir) || profileDir
98
+ : profileDir || ".foldspace-dev/chrome-profile";
99
+ const hints = {
100
+ sentinel_mismatch:
101
+ "Launch sentinel is missing or does not match. Run foldspace inject again.",
102
+ profile_mismatch: `The browser on :${port} is using a different user-data-dir than ${profile}.`,
103
+ cdp_inspect_failed: `Could not read the profile path from Chrome on :${port}.`,
104
+ cdp_not_ready: `Chrome did not expose CDP on :${port}.`,
105
+ };
106
+ return (
107
+ `Chrome on :${port} is not the Foldspace profile.\n` +
108
+ ` ${hints[reason] || reason}\n` +
109
+ ` Close the other debugging Chrome, or pass --port <free-port>.`
110
+ );
111
+ }
112
+
113
+ export async function fetchCdpVersion(port) {
114
+ const response = await fetch(`http://127.0.0.1:${port}/json/version`);
115
+ if (!response.ok) {
116
+ throw new Error(`CDP /json/version returned ${response.status}`);
117
+ }
118
+ return response.json();
119
+ }
120
+
121
+ async function openCdpSocket(webSocketDebuggerUrl) {
122
+ const cdp = createCdpRequestManager({ timeoutMs: 8_000 });
123
+ const ws = new WebSocket(webSocketDebuggerUrl);
124
+ await new Promise((resolve, reject) => {
125
+ ws.addEventListener("open", resolve);
126
+ ws.addEventListener("error", reject);
127
+ });
128
+ ws.addEventListener("message", (event) => {
129
+ cdp.handleMessage(JSON.parse(event.data));
130
+ });
131
+ return {
132
+ ws,
133
+ call: (method, params, sessionId) => cdp.call(ws, method, params, sessionId),
134
+ close() {
135
+ cdp.failAll();
136
+ ws.close();
137
+ },
138
+ };
139
+ }
140
+
141
+ export async function inspectCdpUserDataDir(webSocketDebuggerUrl) {
142
+ const session = await openCdpSocket(webSocketDebuggerUrl);
143
+ try {
144
+ const { targetId } = await session.call("Target.createTarget", {
145
+ url: "chrome://version/",
146
+ });
147
+ const attached = await session.call("Target.attachToTarget", {
148
+ targetId,
149
+ flatten: true,
150
+ });
151
+ const sessionId = attached.sessionId;
152
+ await session.call("Runtime.enable", {}, sessionId);
153
+ await session.call("Page.enable", {}, sessionId);
154
+
155
+ let text = "";
156
+ for (let attempt = 0; attempt < 20; attempt++) {
157
+ const evaluation = await session.call(
158
+ "Runtime.evaluate",
159
+ {
160
+ expression: "document.body ? document.body.innerText : ''",
161
+ returnByValue: true,
162
+ },
163
+ sessionId,
164
+ );
165
+ text = evaluation?.result?.value || "";
166
+ if (/Profile Path|Command Line/i.test(text)) break;
167
+ await new Promise((resolve) => setTimeout(resolve, 150));
168
+ }
169
+
170
+ try {
171
+ await session.call("Target.closeTarget", { targetId });
172
+ } catch {
173
+ // Best-effort: ownership can still be decided from the text we got.
174
+ }
175
+
176
+ const reported = parseChromeVersionText(text);
177
+ if (!reported.userDataDir && !reported.profilePath) {
178
+ throw new Error("chrome://version did not expose a profile path");
179
+ }
180
+ return reported;
181
+ } finally {
182
+ session.close();
183
+ }
184
+ }
185
+
186
+ export async function assertOwnedCdp({
187
+ profileDir,
188
+ port,
189
+ token,
190
+ fetchVersion = fetchCdpVersion,
191
+ inspectUserDataDir = inspectCdpUserDataDir,
192
+ } = {}) {
193
+ const sentinel = verifyLaunchSentinel({ profileDir, token });
194
+ if (!sentinel.ok) return sentinel;
195
+
196
+ let version;
197
+ try {
198
+ version = await fetchVersion(port);
199
+ } catch {
200
+ return { ok: false, reason: "cdp_not_ready", version: null };
201
+ }
202
+
203
+ let reported;
204
+ try {
205
+ reported = await inspectUserDataDir(version.webSocketDebuggerUrl);
206
+ } catch {
207
+ return { ok: false, reason: "cdp_inspect_failed", version };
208
+ }
209
+
210
+ const result = verifyCdpOwnership({ profileDir, token, reported });
211
+ return { ...result, version, reported };
212
+ }
213
+
214
+ export async function waitForOwnedCdp({
215
+ profileDir,
216
+ port,
217
+ token,
218
+ timeoutMs = 20_000,
219
+ fetchVersion = fetchCdpVersion,
220
+ inspectUserDataDir = inspectCdpUserDataDir,
221
+ now = Date.now,
222
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
223
+ } = {}) {
224
+ const started = now();
225
+ let last = { ok: false, reason: "cdp_not_ready", version: null };
226
+ while (now() - started < timeoutMs) {
227
+ last = await assertOwnedCdp({
228
+ profileDir,
229
+ port,
230
+ token,
231
+ fetchVersion,
232
+ inspectUserDataDir,
233
+ });
234
+ if (last.ok) return last;
235
+ if (last.reason !== "cdp_not_ready" && last.reason !== "cdp_inspect_failed") {
236
+ return last;
237
+ }
238
+ await sleep(250);
239
+ }
240
+ return last;
241
+ }
package/src/cli-help.mjs CHANGED
@@ -46,6 +46,7 @@ export function renderGeneralHelp(registry) {
46
46
  " foldspace help --json Print the machine-readable CLI contract",
47
47
  " foldspace help <command> --json Print one command contract",
48
48
  " foldspace --version Show package and protocol versions",
49
+ " foldspace upgrade --check Compare the installed pin to npm latest",
49
50
  "",
50
51
  "attach loads local actions and observes the normal agent experience.",
51
52
  "deploy is a separate remote publication step.",
@@ -21,7 +21,7 @@ export const CLI_COMMANDS = Object.freeze([
21
21
  group: "start",
22
22
  summary: "Create a configured Foldspace actions project",
23
23
  usage:
24
- "foldspace init [<directory>] [--product-id <id>] [--agent-key <key>] [--domain <host>] [--name <display-name>]",
24
+ "foldspace init [<directory>] [--product-id <id>] [--agent-key <key>] [--app-domain <host>] [--name <display-name>]",
25
25
  risk: "local-write",
26
26
  environment: "node",
27
27
  environmentVariables: [],
@@ -41,8 +41,9 @@ export const CLI_COMMANDS = Object.freeze([
41
41
  required: "non-interactive",
42
42
  deprecatedAliases: ["--agent-api-name"],
43
43
  }),
44
- value("--domain", "host", "Target hostname or HTTP(S) URL", {
44
+ value("--app-domain", "host", "Live app hostname or HTTP(S) URL", {
45
45
  required: "non-interactive",
46
+ deprecatedAliases: ["--domain"],
46
47
  }),
47
48
  value("--name", "display-name", "Display name", {
48
49
  default: "directory name",
@@ -51,7 +52,7 @@ export const CLI_COMMANDS = Object.freeze([
51
52
  prerequisites: [
52
53
  "Node 20 or newer",
53
54
  "A target directory that does not already exist",
54
- "Non-interactive use requires directory, product ID, Agent Key, and domain",
55
+ "Non-interactive use requires directory, product ID, Agent Key, and app domain",
55
56
  ],
56
57
  effects: ["Creates a new local project; never initializes Git"],
57
58
  next: [
@@ -61,6 +62,48 @@ export const CLI_COMMANDS = Object.freeze([
61
62
  "Run foldspace attach",
62
63
  ],
63
64
  }),
65
+ Object.freeze({
66
+ name: "upgrade",
67
+ entry: null,
68
+ group: "start",
69
+ summary: "Check for a newer harness and update the exact pin after asking",
70
+ usage:
71
+ "foldspace upgrade [--check | --yes] [--refresh-instructions]",
72
+ risk: "local-write",
73
+ environment: "node",
74
+ environmentVariables: [
75
+ "FOLDSPACE_PROJECT_DIR",
76
+ "FOLDSPACE_SKIP_UPDATE_CHECK",
77
+ ],
78
+ capabilities: ["project.upgrade"],
79
+ positionals: [],
80
+ options: [
81
+ flag("--check", "Compare versions and print JSON; never install"),
82
+ flag(
83
+ "--yes",
84
+ "Install the latest exact pin after the user has already agreed",
85
+ ),
86
+ flag(
87
+ "--refresh-instructions",
88
+ "Replace CLAUDE.md with an import of the package instructions",
89
+ ),
90
+ ],
91
+ prerequisites: [
92
+ "A consumer project with package.json",
93
+ "An exact @foldspace_npm/harness dependency unless only refreshing instructions",
94
+ ],
95
+ effects: [
96
+ "Without --yes, writes nothing except an optional update cache",
97
+ "With --yes, rewrites the exact harness pin and runs npm install --ignore-scripts",
98
+ "Does not rebuild dist/index.js or deploy",
99
+ "--refresh-instructions copies CLAUDE.md to CLAUDE.md.bak then writes the stub",
100
+ ],
101
+ next: [
102
+ "If outdated, ask the user, then run foldspace upgrade --yes",
103
+ "Run npm run build after a pin bump",
104
+ "Deploy only if product users should receive the rebuilt runtime",
105
+ ],
106
+ }),
64
107
  Object.freeze({
65
108
  name: "build",
66
109
  entry: "build-cli.mjs",
@@ -194,7 +237,7 @@ export const CLI_COMMANDS = Object.freeze([
194
237
  value(
195
238
  "--port",
196
239
  "number",
197
- "CDP port: CLI, CDP_PORT, saved launch state, then 9222",
240
+ "CDP port: CLI, CDP_PORT, or the port inject recorded",
198
241
  ),
199
242
  value("--agent", "api-name", "Override the configured agent API name"),
200
243
  flag(
@@ -217,6 +260,7 @@ export const CLI_COMMANDS = Object.freeze([
217
260
  ],
218
261
  effects: [
219
262
  "May reload and instrument matching target pages",
263
+ "Refuses a Chrome that is not the profile inject launched",
220
264
  "Test mode is enabled unless --no-test-mode is passed",
221
265
  "Never directly invokes an action handler",
222
266
  "An empty local action registry is valid; named actions are not required",
@@ -271,6 +315,7 @@ export const CLI_COMMANDS = Object.freeze([
271
315
 
272
316
  export const CAPABILITY_CATALOGUE = Object.freeze([
273
317
  ["project.scaffold", "Create a new configured project on local disk"],
318
+ ["project.upgrade", "Update the consuming project's exact harness pin"],
274
319
  ["artifact.build", "Build the portable dist/index.js action artifact"],
275
320
  ["browser.launch", "Launch an isolated local Chrome profile"],
276
321
  ["browser.cdp", "Connect to local Chrome through CDP"],
@@ -341,6 +386,7 @@ export function createCliRegistry({ packageName, packageVersion }) {
341
386
  "The harness bin alias is equivalent to foldspace.",
342
387
  "attach diagnostics are attach-internal; interpret them from the lifecycle log, not as CLI commands.",
343
388
  "Coding agents should run attach --daemon; foreground attach is for humans watching the terminal.",
389
+ "If update.outdated is true, ask the user before foldspace upgrade --yes.",
344
390
  ],
345
391
  };
346
392
  }
package/src/init.mjs CHANGED
@@ -13,6 +13,7 @@ const allowedFlags = new Set([
13
13
  "product-id",
14
14
  "agent-key",
15
15
  "agent-api-name",
16
+ "app-domain",
16
17
  "domain",
17
18
  ]);
18
19
  const tokenPattern = /\{\{([A-Z0-9_]+)\}\}/g;
@@ -86,13 +87,18 @@ export function parseInitArgs(argv) {
86
87
  "Use --agent-key only; --agent-api-name is its deprecated alias.",
87
88
  );
88
89
  }
90
+ if (flags["app-domain"] && flags.domain) {
91
+ throw new Error(
92
+ "Use --app-domain only; --domain is its deprecated alias.",
93
+ );
94
+ }
89
95
 
90
96
  return {
91
97
  directory: positional[0] || null,
92
98
  displayName: flags.name || null,
93
99
  productId: flags["product-id"] || null,
94
100
  agentApiName: flags["agent-key"] || flags["agent-api-name"] || null,
95
- domain: flags.domain || null,
101
+ domain: flags["app-domain"] || flags.domain || null,
96
102
  };
97
103
  }
98
104
 
@@ -101,7 +107,7 @@ function missingInitFields(parsed) {
101
107
  if (!parsed.directory) missing.push("directory");
102
108
  if (!parsed.productId) missing.push("product-id");
103
109
  if (!parsed.agentApiName) missing.push("agent-key");
104
- if (!parsed.domain) missing.push("domain");
110
+ if (!parsed.domain) missing.push("app-domain");
105
111
  return missing;
106
112
  }
107
113
 
@@ -134,14 +140,14 @@ function validateProductId(value) {
134
140
  function normalizeTarget(value) {
135
141
  const raw = value.trim();
136
142
  if (!raw || raw.includes("*")) {
137
- throw new Error("Domain must be a concrete hostname without a wildcard.");
143
+ throw new Error("App domain must be a concrete hostname without a wildcard.");
138
144
  }
139
145
 
140
146
  let url;
141
147
  try {
142
148
  url = new URL(raw.includes("://") ? raw : `https://${raw}`);
143
149
  } catch {
144
- throw new Error(`Invalid domain: ${value}`);
150
+ throw new Error(`Invalid app domain: ${value}`);
145
151
  }
146
152
 
147
153
  if (
@@ -153,12 +159,12 @@ function normalizeTarget(value) {
153
159
  url.search ||
154
160
  url.hash
155
161
  ) {
156
- throw new Error("Domain must contain only an HTTP(S) hostname, without credentials, a port, path, query, or fragment.");
162
+ throw new Error("App domain must contain only an HTTP(S) hostname, without credentials, a port, path, query, or fragment.");
157
163
  }
158
164
 
159
165
  const domain = url.hostname.toLowerCase().replace(/\.$/, "");
160
166
  if (!domain || domain.includes("..")) {
161
- throw new Error(`Invalid domain: ${value}`);
167
+ throw new Error(`Invalid app domain: ${value}`);
162
168
  }
163
169
 
164
170
  return {
@@ -227,7 +233,7 @@ async function promptForMissingFields(parsed, options = {}) {
227
233
 
228
234
  if (!next.domain) {
229
235
  next.domain = await askUntilValid(
230
- "Domain",
236
+ "App domain",
231
237
  (value) => {
232
238
  normalizeTarget(value);
233
239
  return value.trim();
@@ -255,7 +261,7 @@ function finalizeInitConfig(parsed) {
255
261
  for (const [key, label] of [
256
262
  ["productId", "product-id"],
257
263
  ["agentApiName", "agent-key"],
258
- ["domain", "domain"],
264
+ ["domain", "app-domain"],
259
265
  ]) {
260
266
  if (!parsed[key]) {
261
267
  throw new Error(`Missing required option: --${label}\nUsage: ${initUsage}`);
@@ -304,7 +310,9 @@ function createTemplateValues(config, harnessVersion) {
304
310
  DISPLAY_NAME: displayName,
305
311
  PACKAGE_NAME: packageName,
306
312
  PACKAGE_DESCRIPTION_JSON: JSON.stringify(`Foldspace browser actions for ${displayName}.`),
313
+ PRODUCT_ID: productId,
307
314
  PRODUCT_ID_JSON: JSON.stringify(productId),
315
+ AGENT_API_NAME: agentApiName,
308
316
  AGENT_API_NAME_JSON: JSON.stringify(agentApiName),
309
317
  APP_DOMAIN: target.domain,
310
318
  APP_DOMAIN_JSON: JSON.stringify(target.domain),