@fiodos/cli 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fiodos/cli",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
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": {
package/src/index.js CHANGED
@@ -85,6 +85,7 @@
85
85
  const fs = require('fs');
86
86
  const os = require('os');
87
87
  const path = require('path');
88
+ const readline = require('readline');
88
89
  const { loadEnv, loadAppEnv } = require('./loadEnv');
89
90
  loadEnv();
90
91
 
@@ -161,6 +162,30 @@ function logUser(line) {
161
162
  console.log(`${cyan}◉${reset} ${blue}Fiodos${reset} · ${line}`);
162
163
  }
163
164
 
165
+ /**
166
+ * Ask the developer to confirm before the analysis runs. `yes` continues; any
167
+ * other answer (or `no`) cancels instantly — the command ends without analyzing.
168
+ * Non-interactive runs (no TTY: CI, pipes) proceed automatically so automation
169
+ * is never blocked waiting on stdin.
170
+ */
171
+ function askProceed() {
172
+ return new Promise((resolve) => {
173
+ if (!process.stdin.isTTY) {
174
+ resolve(true);
175
+ return;
176
+ }
177
+ const { blue, cyan, reset } = fyodosTermColors();
178
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
179
+ rl.question(
180
+ `${cyan}◉${reset} ${blue}Fiodos${reset} · Analyze and publish this project? (yes/no) `,
181
+ (answer) => {
182
+ rl.close();
183
+ resolve(/^(y|yes|s|si|sí)$/i.test((answer || '').trim()));
184
+ },
185
+ );
186
+ });
187
+ }
188
+
164
189
  /** Spinner copy: "website" for web targets, "app" for mobile/native. */
165
190
  function surfaceNoun(platform) {
166
191
  return platform === 'web' ? 'website' : 'app';
@@ -226,6 +251,14 @@ async function withSpinner(label, work) {
226
251
  }
227
252
 
228
253
  async function main() {
254
+ // Consent gate: confirm before doing anything. `yes` continues; `no` (or any
255
+ // other answer) cancels instantly — no analysis is run, the command just ends.
256
+ const proceed = await askProceed();
257
+ if (!proceed) {
258
+ logUser('Cancelled — no analysis was run.');
259
+ return;
260
+ }
261
+
229
262
  // Accept an optional leading `analyze` subcommand: `fiodos analyze <dir>`.
230
263
  let positionals = positionalArgs();
231
264
  if (positionals[0] === 'analyze') positionals = positionals.slice(1);
@@ -19,8 +19,13 @@
19
19
  const fs = require('fs');
20
20
  const path = require('path');
21
21
 
22
- const ORB_START = '{/* FYODOS:ORB:START (auto-generated safe to remove) */}';
23
- const ORB_END = '{/* FYODOS:ORB:END */}';
22
+ // Markers are JS LINE comments, never JSX `{/* */}` comment-expressions. A
23
+ // `return ( … )` accepts exactly ONE root JSX node, and a `{/* */}` is itself an
24
+ // expression node: placing it as a sibling of the wrapper ("{/* */} <Orb>…")
25
+ // makes the return have multiple roots → "Unexpected token, expected ','".
26
+ // Line comments are pure trivia, valid before/after the single root element.
27
+ const ORB_START = '// FYODOS:ORB:START (auto-generated — safe to remove)';
28
+ const ORB_END = '// FYODOS:ORB:END';
24
29
  const IMPORT_START = '// FYODOS:ORB:START (auto-generated — safe to remove)';
25
30
  const IMPORT_END = '// FYODOS:ORB:END';
26
31
 
@@ -31,6 +36,23 @@ const LAYOUT_CANDIDATES = [
31
36
  'src/app/_layout.jsx',
32
37
  ];
33
38
 
39
+ /**
40
+ * Last-line defense: NEVER write a layout that won't parse. esbuild is already a
41
+ * CLI dependency; we use it to validate the generated TSX/JSX. On any doubt we
42
+ * return false and the caller falls back to the (non-destructive) snippet path.
43
+ */
44
+ function parsesAsTsx(source, layoutRel) {
45
+ try {
46
+ const esbuild = require('esbuild');
47
+ const loader = layoutRel && layoutRel.endsWith('.jsx') ? 'jsx' : 'tsx';
48
+ esbuild.transformSync(source, { loader });
49
+ return true;
50
+ } catch (e) {
51
+ if (e && e.code === 'MODULE_NOT_FOUND') return true; // esbuild unavailable → don't block
52
+ return false;
53
+ }
54
+ }
55
+
34
56
  function findRootLayout(appRoot) {
35
57
  for (const rel of LAYOUT_CANDIDATES) {
36
58
  const abs = path.join(appRoot, rel);
@@ -131,6 +153,29 @@ function buildWiredLayout(source, manifestSpecifier) {
131
153
  return insertImportsAfterLastImport(withWrap, importBlock);
132
154
  }
133
155
 
156
+ /**
157
+ * Strip a previous orb wrap so we can re-wire cleanly. Removes our markers (both
158
+ * the current line-comment form and the legacy broken JSX-comment form), our
159
+ * imports, and the single-line FiodosExpoAgent open/close tags — leaving the
160
+ * user's original tree (possibly over-indented, which is harmless / re-wrapped).
161
+ */
162
+ function unwireOrb(source) {
163
+ const drop = (line) => {
164
+ const t = line.trim();
165
+ if (/FYODOS:ORB/.test(line)) return true; // `// …` or `{/* … */}` markers
166
+ if (/^import\s+\{?\s*FiodosExpoAgent\b/.test(t)) return true;
167
+ if (/^import\s+fyodosManifest\b/.test(t)) return true;
168
+ if (/^<FiodosExpoAgent\b[^]*>$/.test(t)) return true; // our single-line open tag
169
+ if (/^<\/FiodosExpoAgent>$/.test(t)) return true;
170
+ return false;
171
+ };
172
+ return source
173
+ .split('\n')
174
+ .filter((line) => !drop(line))
175
+ .join('\n')
176
+ .replace(/\n{3,}/g, '\n\n');
177
+ }
178
+
134
179
  /** The copy-paste snippet shown when we can't safely auto-wrap. */
135
180
  function mountSnippet(manifestSpecifier) {
136
181
  return (
@@ -174,8 +219,19 @@ function wireReactNativeOrb(appRoot, { manifest, colors = {}, noWire = false } =
174
219
  }
175
220
 
176
221
  if (source.includes('FiodosExpoAgent') || source.includes('FYODOS:ORB')) {
177
- if (!noWire && manifest) writeManifestFile(appRoot, manifest);
178
- return { status: 'already', file: layout.rel };
222
+ // Already wired AND still valid → nothing to do.
223
+ if (parsesAsTsx(source, layout.rel)) {
224
+ if (!noWire && manifest) writeManifestFile(appRoot, manifest);
225
+ return { status: 'already', file: layout.rel };
226
+ }
227
+ // A previous CLI version may have left an UNPARSEABLE wrap (e.g. JSX
228
+ // `{/* */}` markers as siblings of the return root). Self-heal: strip our
229
+ // own markers/imports and re-wire cleanly below.
230
+ if (!noWire) {
231
+ source = unwireOrb(source);
232
+ } else {
233
+ return { status: 'skipped', reason: 'no-wire' };
234
+ }
179
235
  }
180
236
 
181
237
  if (noWire) return { status: 'skipped', reason: 'no-wire' };
@@ -187,6 +243,13 @@ function wireReactNativeOrb(appRoot, { manifest, colors = {}, noWire = false } =
187
243
  return { status: 'printed', file: layout.rel, reason: 'unrecognized-layout' };
188
244
  }
189
245
 
246
+ // Safety net: if the auto-wrap would produce code that doesn't parse, do NOT
247
+ // write it. Leave the file untouched and show the manual snippet instead.
248
+ if (!parsesAsTsx(wired, layout.rel)) {
249
+ printSnippet(colors, layout.rel, manifestSpecifier, 'auto-wrap would not parse — left untouched');
250
+ return { status: 'printed', file: layout.rel, reason: 'wrap-unparseable' };
251
+ }
252
+
190
253
  try {
191
254
  fs.writeFileSync(layout.abs, wired);
192
255
  } catch (e) {
@@ -210,6 +273,8 @@ module.exports = {
210
273
  reportReactNativeWire,
211
274
  // exported for tests
212
275
  buildWiredLayout,
276
+ unwireOrb,
277
+ parsesAsTsx,
213
278
  findReturnParenSpan,
214
279
  writeManifestFile,
215
280
  manifestImportSpecifier,