@fiodos/cli 0.1.11 → 0.1.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fiodos/cli",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "Fiodos CLI — analyzes your app's source code and generates the in-app voice-agent manifest, then wires the orb. Powers `npx @fiodos/cli analyze`.",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -19,7 +19,7 @@
19
19
  "node": ">=18"
20
20
  },
21
21
  "dependencies": {
22
- "@fiodos/core": "^0.1.1",
22
+ "@fiodos/core": "^0.1.2",
23
23
  "esbuild": "^0.28.1",
24
24
  "jsdom": "^24.0.0",
25
25
  "typescript": "^5.6.0"
package/src/index.js CHANGED
@@ -525,7 +525,20 @@ async function main() {
525
525
  apiKey &&
526
526
  (result.status === 'added' || result.status === 'already')
527
527
  ) {
528
- await postOrbWired({ apiKey, apiUrl, file: result.file || '' });
528
+ const proof = await postOrbWired({ apiKey, apiUrl, file: result.file || '' });
529
+ // The dashboard's onboarding orb check completes on THIS install proof
530
+ // (no need to run the app). If the backend could not record it (e.g. an
531
+ // outdated deployment without the orb-wired endpoint), say so plainly so
532
+ // the developer knows the check will otherwise wait for a runtime ping.
533
+ if (proof && proof.ok === false && proof.reason !== 'no-api-key') {
534
+ const why = proof.status === 404
535
+ ? 'the backend does not expose /v1/developer/orb-wired yet (update the backend deployment)'
536
+ : `the backend rejected the install proof (HTTP ${proof.status || '?'})`;
537
+ logUser(
538
+ `Note: the orb is wired into your app, but ${why}. ` +
539
+ 'The dashboard install check will complete once the orb connects from your running app.',
540
+ );
541
+ }
529
542
  }
530
543
  }
531
544
 
@@ -846,7 +859,7 @@ async function publishManifest(manifest, meta, analysisToken = null, opts = {})
846
859
  * already succeeded); a failed re-fetch is surfaced as an actionable warning.
847
860
  */
848
861
  async function postOrbWired({ apiKey, apiUrl, file }) {
849
- if (!apiKey) return;
862
+ if (!apiKey) return { ok: false, reason: 'no-api-key' };
850
863
  try {
851
864
  const res = await fetch(`${apiUrl}/v1/developer/orb-wired`, {
852
865
  method: 'POST',
@@ -855,11 +868,13 @@ async function postOrbWired({ apiKey, apiUrl, file }) {
855
868
  });
856
869
  if (res.ok) {
857
870
  logDev(`[orb-wired] install proof recorded (${file || 'web'})`);
858
- } else {
859
- logDev(`[orb-wired] could not record install proof: HTTP ${res.status}`);
871
+ return { ok: true };
860
872
  }
873
+ logDev(`[orb-wired] could not record install proof: HTTP ${res.status}`);
874
+ return { ok: false, status: res.status };
861
875
  } catch (e) {
862
876
  logDev(`[orb-wired] skipped: ${e.message || e}`);
877
+ return { ok: false, reason: e.message || String(e) };
863
878
  }
864
879
  }
865
880
  async function verifyOrbCanFetch({ apiKey, apiUrl }) {
@@ -48,6 +48,46 @@ function isVerboseProbe() {
48
48
  return process.argv.includes('--verbose') || process.argv.includes('-v') || process.argv.includes('--debug');
49
49
  }
50
50
 
51
+ /**
52
+ * Render-probe teardown guard.
53
+ *
54
+ * The probe mounts the user's real React component in jsdom, then unmounts and
55
+ * tears the DOM down. React 18 schedules passive-effect cleanup ASYNCHRONOUSLY
56
+ * (a scheduler timer/microtask). If any cleanup fires AFTER we removed the
57
+ * jsdom globals, React throws "The `document` global was defined when React was
58
+ * initialized, but is not defined anymore". On Node ≥ 21/25 that async throw is
59
+ * an uncaughtException that kills the ENTIRE CLI — long after the probe returned
60
+ * (e.g. while the CLI is awaiting the ".env" prompt). The probe is an OPTIONAL
61
+ * effect check; it must never crash an otherwise-successful install.
62
+ *
63
+ * We install (once) a narrow process-level filter that swallows ONLY this jsdom/
64
+ * React teardown error class and lets every other error crash as usual.
65
+ */
66
+ let teardownGuardInstalled = false;
67
+ function isProbeTeardownError(err) {
68
+ const msg = String((err && (err.stack || err.message)) || err || '');
69
+ return (
70
+ /`document` global was defined when React was initialized/.test(msg) ||
71
+ /\bdocument is not defined\b/.test(msg) ||
72
+ /Cannot read properties of (?:null|undefined) \(reading '(?:document|ownerDocument|defaultView|createElement|body)'\)/.test(msg) ||
73
+ (/jsdom/i.test(msg) && /not defined|closed|null/i.test(msg))
74
+ );
75
+ }
76
+ function installProbeTeardownGuard() {
77
+ if (teardownGuardInstalled) return;
78
+ teardownGuardInstalled = true;
79
+ process.on('uncaughtException', (err) => {
80
+ if (isProbeTeardownError(err)) return; // late probe passive-effect cleanup — safe to drop
81
+ // Not ours: preserve Node's default fail-fast behavior for real errors.
82
+ console.error(err && err.stack ? err.stack : err);
83
+ process.exit(1);
84
+ });
85
+ process.on('unhandledRejection', (reason) => {
86
+ if (isProbeTeardownError(reason)) return;
87
+ throw reason;
88
+ });
89
+ }
90
+
51
91
  /**
52
92
  * Capture (swallow) noisy global console output during a probe — React error
53
93
  * boundary logs ("The above error occurred in <X>", "Consider adding an error
@@ -150,8 +190,18 @@ async function probeReactEffect(appRoot, entry, ctx) {
150
190
  `import { flushSync } from 'react-dom';\n` +
151
191
  `export const probe = { React: ReactNS.default || ReactNS, createRoot, flushSync, Comp, registry: fyodosGeneratedRegistries };\n`;
152
192
 
193
+ // A late React passive-effect cleanup that fires after we tear down jsdom must
194
+ // never crash the CLI (see installProbeTeardownGuard).
195
+ installProbeTeardownGuard();
196
+
153
197
  let restore = () => {};
154
198
  let cap = { restore() {} };
199
+ // Hoisted so the finally block can drain React (synchronously unmount + settle)
200
+ // BEFORE the DOM globals are removed — the only reliable way to stop React from
201
+ // running cleanup against a closed jsdom.
202
+ let root = null;
203
+ let flushSync = null;
204
+ const settle = () => new Promise((r) => setTimeout(r, 0));
155
205
  try {
156
206
  fs.writeFileSync(harnessAbs, harness);
157
207
  await esbuild.build({
@@ -173,14 +223,13 @@ async function probeReactEffect(appRoot, entry, ctx) {
173
223
  restore = installDom(jsdomMod, { storageKeys: ctx.storageKeys, kind: ctx.kind });
174
224
  delete require.cache[outAbs];
175
225
  const mod = require(outAbs);
176
- const { React, createRoot, flushSync, Comp, registry } = mod.probe;
226
+ const { React, createRoot, Comp, registry } = mod.probe;
227
+ flushSync = mod.probe.flushSync;
177
228
  const Component = pickComponent(Comp);
178
229
  if (!Component) return { status: 'unverifiable', detail: `did not find a mountable React component exported by '${entry.bridge.file}' — real effect not verifiable, test by hand` };
179
230
 
180
231
  const container = document.createElement('div');
181
232
  document.body.appendChild(container);
182
- let root;
183
- const settle = () => new Promise((r) => setTimeout(r, 0));
184
233
  try {
185
234
  flushSync(() => { root = createRoot(container); root.render(React.createElement(Component)); });
186
235
  await settle();
@@ -192,12 +241,11 @@ async function probeReactEffect(appRoot, entry, ctx) {
192
241
  const beforeHTML = captureSnapshot(container);
193
242
  const beforeText = container.textContent || '';
194
243
  const handler = registry.handlers[entry.handler];
195
- if (typeof handler !== 'function') { try { root.unmount(); } catch {} return { status: 'fail', detail: `el registro no expone el handler '${entry.handler}'` }; }
244
+ if (typeof handler !== 'function') return { status: 'fail', detail: `el registro no expone el handler '${entry.handler}'` };
196
245
 
197
246
  if (ctx.sensitive) {
198
247
  // Do NOT fire the effect; we proved it mounts and the handler resolves to a
199
248
  // live bridge function. Verify the bridge has the method, nothing more.
200
- try { root.unmount(); } catch {}
201
249
  return { status: 'unverifiable', detail: 'confirmation action: its effect is not fired in the test render (security); wiring verified up to the confirmation point' };
202
250
  }
203
251
 
@@ -205,15 +253,25 @@ async function probeReactEffect(appRoot, entry, ctx) {
205
253
  try { await handler(ctx.params); } catch (e) { invokeErr = e; }
206
254
  await settle();
207
255
  try { flushSync(() => {}); } catch {}
208
- if (invokeErr) { try { root.unmount(); } catch {} return { status: 'fail', detail: `invoking the handler, the real app threw: ${short(invokeErr)}` }; }
256
+ if (invokeErr) return { status: 'fail', detail: `invoking the handler, the real app threw: ${short(invokeErr)}` };
209
257
 
210
258
  const afterHTML = captureSnapshot(container);
211
259
  const afterText = container.textContent || '';
212
- try { root.unmount(); } catch {}
213
260
  return decide(ctx.kind, { beforeHTML, beforeText, afterHTML, afterText });
214
261
  } catch (err) {
215
262
  return { status: 'unverifiable', detail: `could not prepare the test render (${short(err)}); real effect not automatically verifiable — test by hand` };
216
263
  } finally {
264
+ // Drain React WHILE the DOM is still alive: a synchronous unmount runs every
265
+ // pending passive-effect cleanup now, so nothing fires after we remove the
266
+ // jsdom globals below. Two settle ticks flush the scheduler's macro/microtasks.
267
+ try {
268
+ if (root) {
269
+ if (flushSync) flushSync(() => { try { root.unmount(); } catch { /* ignore */ } });
270
+ else { try { root.unmount(); } catch { /* ignore */ } }
271
+ await settle();
272
+ await settle();
273
+ }
274
+ } catch { /* ignore */ }
217
275
  try { cap.restore(); } catch {}
218
276
  try { fs.existsSync(harnessAbs) && fs.rmSync(harnessAbs); } catch {}
219
277
  try { fs.existsSync(outAbs) && fs.rmSync(outAbs); } catch {}
@@ -357,6 +415,9 @@ function installDom(jsdomMod, { storageKeys = [], kind } = {}) {
357
415
  * `sources` are the project's source files (for storage-key detection, etc.).
358
416
  */
359
417
  async function probeComponentEffect(appRoot, entry, opts = {}) {
418
+ // Defense-in-depth: a late headless-render teardown error (React/Vue passive
419
+ // cleanup firing after jsdom is closed) must never crash the CLI.
420
+ installProbeTeardownGuard();
360
421
  const framework = (opts.framework || 'web').toLowerCase();
361
422
  const kind = actionKind(entry);
362
423
  const params = probeParams(entry, kind);
@@ -376,4 +437,4 @@ async function probeComponentEffect(appRoot, entry, opts = {}) {
376
437
  return { status: 'unverifiable', detail: `framework '${framework}' has no render-probe; real effect not automatically verifiable` };
377
438
  }
378
439
 
379
- module.exports = { probeComponentEffect, actionKind, decide, SENTINEL, SEED_ID, installDom, pickComponent, detectStorageKeys, probeParams, captureSnapshot, short, loadEsbuild, loadJsdom, reqFrom, emptyAssetsPlugin, captureConsole, isVerboseProbe };
440
+ module.exports = { probeComponentEffect, actionKind, decide, SENTINEL, SEED_ID, installDom, pickComponent, detectStorageKeys, probeParams, captureSnapshot, short, loadEsbuild, loadJsdom, reqFrom, emptyAssetsPlugin, captureConsole, isVerboseProbe, installProbeTeardownGuard, isProbeTeardownError };