@foldspace_npm/harness 0.1.21 → 0.1.22

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/CLAUDE.md CHANGED
@@ -13,7 +13,11 @@ something they can **see on their own page** — not at "it builds".
13
13
  `npx --yes @foldspace_npm/harness@latest upgrade --check`). If
14
14
  `update.outdated` is true, say the pinned and latest versions and **ask
15
15
  before upgrading**; then `foldspace upgrade --yes` and `npm run build`. Never
16
- upgrade as a side effect of build, attach or implementing an action. If the
16
+ upgrade as a side effect of build, attach or implementing an action.
17
+ `build` and `attach` check too: behind prints one line and carries on; a
18
+ version Foldspace **withdrew** refuses to run and says why. Tell the user
19
+ the reason and ask to upgrade. Only if they decide not to, rerun with
20
+ `FOLDSPACE_SKIP_UPDATE_CHECK=1` - never set it on your own. If the
17
21
  project `CLAUDE.md` does not contain
18
22
  `@node_modules/@foldspace_npm/harness/CLAUDE.md`, ask before
19
23
  `foldspace upgrade --refresh-instructions`.
package/README.md CHANGED
@@ -151,6 +151,55 @@ foldspace upgrade --yes # only after the user agrees
151
151
  `--yes` rewrites the exact pin and runs `npm install --ignore-scripts`. Rebuild
152
152
  afterward; that is still not a deploy.
153
153
 
154
+ `foldspace build` and `foldspace attach` run the same check before they start
155
+ (`attach --status` and `--stop` do not). A project that is behind gets one line
156
+ on stderr and carries on. A project on a **withdrawn** version (below) refuses
157
+ to run and prints the reason. To build on it anyway, once the user has declined
158
+ the upgrade:
159
+
160
+ ```bash
161
+ FOLDSPACE_SKIP_UPDATE_CHECK=1 npm run build
162
+ ```
163
+
164
+ That turns the check off entirely; it does not fix the withdrawn version.
165
+
166
+ ### Withdrawing a release (maintainers)
167
+
168
+ When a published version is broken badly enough that nobody should build on
169
+ it, withdraw it with `npm deprecate`. The registry is the list of withdrawn
170
+ versions, so projects already installed on it see the withdrawal without
171
+ installing anything new. Only owners of `@foldspace_npm/harness` on npm can
172
+ run it.
173
+
174
+ 1. **Publish the fix first.** If the withdrawn version is also `latest`, the
175
+ message tells users to upgrade to a version that is itself withdrawn.
176
+ 2. **Withdraw the broken version**, with a message that says what is wrong and
177
+ which version to move to. That message is exactly what users, and the coding
178
+ agent working for them, see when `build` or `attach` refuses to run:
179
+
180
+ ```bash
181
+ npm deprecate @foldspace_npm/harness@0.1.14 "attach loads the wrong bundle; upgrade to 0.1.15"
182
+ ```
183
+
184
+ A range works too: `npm deprecate "@foldspace_npm/harness@<0.1.15" "…"`.
185
+ 3. **Check it took:**
186
+
187
+ ```bash
188
+ npm view @foldspace_npm/harness@0.1.14 deprecated
189
+ ```
190
+
191
+ To undo a withdrawal, deprecate with an empty message:
192
+
193
+ ```bash
194
+ npm deprecate @foldspace_npm/harness@0.1.14 ""
195
+ ```
196
+
197
+ **It is not instant.** Each project caches the registry's answer for 12 hours
198
+ in `.foldspace-dev/harness-update.json`. A project that checked recently keeps
199
+ its "fine" answer until that expires, so a withdrawal reaches every project
200
+ within 12 hours. It only reaches projects on a harness version that has this
201
+ check.
202
+
154
203
  ### Lint handlers before they ship
155
204
 
156
205
  `foldspace build` runs `foldspace lint` first. Errors skip bundling; warnings
package/bin/cli.mjs CHANGED
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
7
7
  import { runInit } from "../src/init.mjs";
8
8
  import {
9
9
  checkForUpdate,
10
+ gateForCommand,
10
11
  nagIfOutdated,
11
12
  resolveProjectDir,
12
13
  runUpgrade,
@@ -54,6 +55,18 @@ function runScript(scriptName, args) {
54
55
  });
55
56
  }
56
57
 
58
+ // build and attach are where a stale or withdrawn harness does its damage, so
59
+ // they check before running. Returns false when the command must not run.
60
+ function passesVersionGate() {
61
+ const gate = gateForCommand(checkForUpdate(resolveProjectDir())) || {};
62
+ if (gate.message) console.error(`foldspace: ${gate.message}`);
63
+ if (gate.block) {
64
+ process.exitCode = 1;
65
+ return false;
66
+ }
67
+ return true;
68
+ }
69
+
57
70
  function printHelp(topic, json) {
58
71
  const document = helpDocument(registry, topic);
59
72
  if (!document) {
@@ -184,8 +197,14 @@ if (!command || command === "--help" || command === "-h") {
184
197
  try {
185
198
  const definition = commandByName(command);
186
199
  const normalized = normalizeCommandArgs(definition, args);
187
- if (command === "attach") {
188
- const control = classifyAttachControl(normalized);
200
+ const control =
201
+ command === "attach" ? classifyAttachControl(normalized) : null;
202
+ const gated =
203
+ command === "build" ||
204
+ (command === "attach" && (!control || control === "daemon"));
205
+ if (gated && !passesVersionGate()) {
206
+ // gate already reported why
207
+ } else if (command === "attach") {
189
208
  if (control) {
190
209
  Promise.resolve()
191
210
  .then(() =>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldspace_npm/harness",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "Build and verify portable Foldspace action artifacts against a live app.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -112,12 +112,13 @@ export const CLI_COMMANDS = Object.freeze([
112
112
  usage: "foldspace build [--watch]",
113
113
  risk: "local-write",
114
114
  environment: "node",
115
- environmentVariables: ["FOLDSPACE_PROJECT_DIR"],
115
+ environmentVariables: ["FOLDSPACE_PROJECT_DIR", "FOLDSPACE_SKIP_UPDATE_CHECK"],
116
116
  capabilities: ["artifact.build"],
117
117
  positionals: [],
118
118
  options: [flag("--watch", "Rebuild when action source changes")],
119
119
  prerequisites: ["agent/actions/index.ts"],
120
120
  effects: [
121
+ "Checks the harness version first: behind prints one line, a withdrawn version refuses to run (FOLDSPACE_SKIP_UPDATE_CHECK=1 skips the check)",
121
122
  "Runs foldspace lint first; errors skip bundling",
122
123
  "Writes dist/index.js when lint has no errors",
123
124
  ],
@@ -201,6 +202,7 @@ export const CLI_COMMANDS = Object.freeze([
201
202
  "FOLDSPACE_PROJECT_DIR",
202
203
  "CDP_PORT",
203
204
  "AGENT_API_NAME",
205
+ "FOLDSPACE_SKIP_UPDATE_CHECK",
204
206
  ],
205
207
  capabilities: [
206
208
  "browser.cdp",
@@ -260,6 +262,7 @@ export const CLI_COMMANDS = Object.freeze([
260
262
  "sdkUrl in foldspace.dev.json when using --bootstrap",
261
263
  ],
262
264
  effects: [
265
+ "Checks the harness version first, except --status and --stop: behind prints one line, a withdrawn version refuses to run (FOLDSPACE_SKIP_UPDATE_CHECK=1 skips the check)",
263
266
  "May reload and instrument matching target pages",
264
267
  "Refuses a Chrome that is not the profile inject launched",
265
268
  "Test mode is off unless --test-mode is passed: conversations appear in the dashboard",
@@ -464,6 +467,7 @@ export function createCliRegistry({ packageName, packageVersion, env = process.e
464
467
  "attach diagnostics are attach-internal; interpret them from the lifecycle log, not as CLI commands.",
465
468
  "Coding agents should run attach --daemon; foreground attach is for humans watching the terminal.",
466
469
  "If update.outdated is true, ask the user before foldspace upgrade --yes.",
470
+ "If update.broken is true, this version was withdrawn and build and attach refuse to run. Ask the user to upgrade; set FOLDSPACE_SKIP_UPDATE_CHECK=1 only if they decline.",
467
471
  "observe, ask and run are read-only second clients on the test window and work while attach runs.",
468
472
  "There is no customer-run publication step: putting actions in front of end users is done with Foldspace's team.",
469
473
  ],
package/src/upgrade.mjs CHANGED
@@ -227,10 +227,10 @@ function readCache(projectDir, now) {
227
227
  }
228
228
  }
229
229
 
230
- function writeCache(projectDir, latest, now) {
230
+ function writeCache(projectDir, entry, now) {
231
231
  const dir = path.dirname(cachePath(projectDir));
232
232
  fs.mkdirSync(dir, { recursive: true });
233
- writeJson(cachePath(projectDir), { checkedAt: now, latest });
233
+ writeJson(cachePath(projectDir), { checkedAt: now, ...entry });
234
234
  }
235
235
 
236
236
  export function fetchLatestFromNpm(options = {}) {
@@ -244,7 +244,27 @@ export function fetchLatestFromNpm(options = {}) {
244
244
  return normalizeVersion(result.stdout);
245
245
  }
246
246
 
247
+ /**
248
+ * A version we have withdrawn is marked with `npm deprecate`, so the registry
249
+ * is the list of known-broken versions and an old install can still read it.
250
+ * Returns the deprecation message, "" when the version is not deprecated, or
251
+ * null when the registry could not be asked.
252
+ */
253
+ export function fetchDeprecationFromNpm(version, options = {}) {
254
+ const spawn = options.spawnSyncFn || spawnSync;
255
+ const result = spawn("npm", ["view", `${HARNESS_PACKAGE}@${version}`, "deprecated"], {
256
+ encoding: "utf8",
257
+ timeout: options.timeoutMs ?? NPM_VIEW_TIMEOUT_MS,
258
+ env: options.env || process.env,
259
+ });
260
+ if (result.error || result.status !== 0) return null;
261
+ return String(result.stdout || "").trim();
262
+ }
263
+
247
264
  function nextAction(report) {
265
+ if (report.broken) {
266
+ return `This harness version was withdrawn: ${report.deprecated}. ${UPGRADE_NEXT}`;
267
+ }
248
268
  if (report.status === "outdated") {
249
269
  return report.instructionsImport === false
250
270
  ? `${UPGRADE_NEXT}. If CLAUDE.md is still a snapshot, also pass --refresh-instructions`
@@ -275,7 +295,11 @@ export function checkForUpdate(projectDir, options = {}) {
275
295
  (options.skipCheck !== false && env.FOLDSPACE_SKIP_UPDATE_CHECK === "1");
276
296
  const consumer = !harnessSource && Boolean(pinned || installed);
277
297
 
298
+ // The version that actually runs is the installed one; the pin is a fallback
299
+ // for a project that has not been installed yet.
300
+ const running = installed || pinned;
278
301
  let latest = null;
302
+ let deprecated = null;
279
303
  let status = "current";
280
304
 
281
305
  if (!consumer) {
@@ -284,16 +308,30 @@ export function checkForUpdate(projectDir, options = {}) {
284
308
  status = "check_skipped";
285
309
  } else {
286
310
  const cached = options.cache === false ? null : readCache(projectDir, now);
287
- if (cached?.latest) {
311
+ const cachedDeprecation =
312
+ cached?.deprecation?.version === running ? cached.deprecation : null;
313
+ if (cached?.latest && cachedDeprecation) {
288
314
  latest = cached.latest;
315
+ deprecated = cachedDeprecation.message || null;
289
316
  } else {
290
317
  const fetched =
291
318
  typeof options.fetchLatest === "function"
292
319
  ? options.fetchLatest()
293
320
  : fetchLatestFromNpm(options);
294
321
  latest = normalizeVersion(fetched);
322
+ const message =
323
+ typeof options.fetchDeprecation === "function"
324
+ ? options.fetchDeprecation(running)
325
+ : fetchDeprecationFromNpm(running, options);
326
+ deprecated = message || null;
295
327
  if (latest && options.cache !== false) {
296
- writeCache(projectDir, latest, now);
328
+ const entry = { latest };
329
+ // Only cache an answer the registry actually gave, so an offline
330
+ // run is asked again next time instead of remembered as "fine".
331
+ if (typeof message === "string") {
332
+ entry.deprecation = { version: running, message };
333
+ }
334
+ writeCache(projectDir, entry, now);
297
335
  }
298
336
  }
299
337
  if (!latest) {
@@ -311,6 +349,8 @@ export function checkForUpdate(projectDir, options = {}) {
311
349
  installed,
312
350
  latest,
313
351
  outdated: status === "outdated",
352
+ deprecated,
353
+ broken: Boolean(deprecated),
314
354
  status,
315
355
  instructionsImport,
316
356
  next: null,
@@ -319,6 +359,33 @@ export function checkForUpdate(projectDir, options = {}) {
319
359
  return report;
320
360
  }
321
361
 
362
+ /**
363
+ * What `build` and `attach` do with an update report before they run:
364
+ * `block: true` stops the command, `message` (if any) is printed to stderr.
365
+ * Never upgrades - that stays the user's decision (CLAUDE.md, step 1).
366
+ * The escape hatch is FOLDSPACE_SKIP_UPDATE_CHECK=1: the report comes back
367
+ * check_skipped, so nothing here blocks.
368
+ */
369
+ export function gateForCommand(report) {
370
+ // A check that could not run never stops a build.
371
+ if (!report) return { block: false, message: null };
372
+ if (report.broken) {
373
+ return {
374
+ block: true,
375
+ message:
376
+ `${HARNESS_PACKAGE} ${report.installed || report.pinned} was withdrawn: ${report.deprecated}. ` +
377
+ `${UPGRADE_NEXT}. If the user declines, rerun with FOLDSPACE_SKIP_UPDATE_CHECK=1 to build on this version anyway.`,
378
+ };
379
+ }
380
+ if (report.outdated) {
381
+ return {
382
+ block: false,
383
+ message: `${HARNESS_PACKAGE} ${report.installed || report.pinned} is behind ${report.latest}. ${UPGRADE_NEXT}.`,
384
+ };
385
+ }
386
+ return { block: false, message: null };
387
+ }
388
+
322
389
  export function writeExactPin(projectDir, version) {
323
390
  const manifestPath = path.join(projectDir, "package.json");
324
391
  const manifest = readPackageManifest(projectDir);
@@ -14,8 +14,10 @@ How to re-run any probe below:
14
14
 
15
15
  ```bash
16
16
  npm run inject
17
- # Sign in. Capture from chrome-devtools MCP, or a page-context fetch,
18
- # before foldspace attach owns the debug port.
17
+ # Sign in, then read the running app with foldspace observe. It reads the
18
+ # window you signed in to, and works while foldspace attach runs.
19
+ npx foldspace observe screen --click "<a label from observe menu>" --match "<words for the data>"
20
+ npx foldspace observe read "<the GET path screen returned>"
19
21
  ```
20
22
 
21
23
  Start URL: `{{START_URL}}`