@fiodos/cli 0.1.0 → 0.1.3
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/README.md +17 -0
- package/package.json +1 -1
- package/src/index.js +82 -4
- package/src/postWireTest.js +3 -3
- package/src/renderProbe.js +68 -22
- package/src/renderProbeVue.js +12 -8
- package/src/verifyWire.js +7 -7
- package/src/wireHandlers.js +179 -180
- package/src/wireWeb.js +43 -8
- package/src/writeEnv.js +230 -0
package/src/wireWeb.js
CHANGED
|
@@ -206,7 +206,7 @@ function printMountPreview(plan, colors) {
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
async function wireWebOrb(appRoot, opts = {}) {
|
|
209
|
-
const { colors = {}, assumeYes = false, noWire = false } = opts;
|
|
209
|
+
const { colors = {}, assumeYes = false, noWire = false, runTest = true } = opts;
|
|
210
210
|
if (noWire) return { status: 'skipped' };
|
|
211
211
|
|
|
212
212
|
const framework = detectFramework(appRoot);
|
|
@@ -232,20 +232,53 @@ async function wireWebOrb(appRoot, opts = {}) {
|
|
|
232
232
|
return { status: 'declined', file: plan.rel };
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
const build = async () => {
|
|
236
|
+
try {
|
|
237
|
+
return await runPostWireTest(appRoot, { timeoutMs: 180000 });
|
|
238
|
+
} catch (e) {
|
|
239
|
+
return { ok: false, stage: 'runner', command: '(runner)', output: String((e && e.message) || e) };
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
235
243
|
const backups = backupFiles(plan.files);
|
|
236
244
|
try {
|
|
237
245
|
applyMount(plan);
|
|
238
246
|
if (!verifyMounted(plan)) {
|
|
239
|
-
|
|
247
|
+
revertFiles(backups);
|
|
248
|
+
printSnippet(target, framework, colors, appRoot, 'post-edit verification failed — FiodosAgent not found in edited files');
|
|
249
|
+
return { status: 'failed', file: plan.rel };
|
|
240
250
|
}
|
|
241
|
-
|
|
242
|
-
|
|
251
|
+
|
|
252
|
+
// The orb is a single, trivial component mount — it must APPEAR. We therefore
|
|
253
|
+
// only revert when WE are demonstrably the cause: the app built fine on its
|
|
254
|
+
// own but breaks once the orb is mounted (e.g. the SDK package isn't installed
|
|
255
|
+
// yet). A pre-existing build failure, a timeout, or a missing toolchain must
|
|
256
|
+
// NOT make the orb vanish — that was the silent "orb never shows up" bug.
|
|
257
|
+
// Fast path: one build. Only when it fails do we spend a second build (on the
|
|
258
|
+
// reverted original) to tell "we broke it" from "it was already broken".
|
|
259
|
+
if (runTest) {
|
|
260
|
+
const test = await build();
|
|
261
|
+
if (test.ok || test.stage === 'skipped-no-deps') {
|
|
262
|
+
writeConsentDoc(appRoot, plan);
|
|
263
|
+
return { status: 'added', file: plan.rel, test };
|
|
264
|
+
}
|
|
265
|
+
// Build failed with the orb mounted — was the app building WITHOUT it?
|
|
243
266
|
revertFiles(backups);
|
|
244
|
-
|
|
245
|
-
|
|
267
|
+
const baseline = await build();
|
|
268
|
+
if (baseline.ok) {
|
|
269
|
+
// Built before, fails now → the mount is the regression. Keep it reverted.
|
|
270
|
+
printSnippet(target, framework, colors, appRoot, `mount caused a build regression — reverted (${test.command})`);
|
|
271
|
+
return { status: 'reverted', file: plan.rel, test, reason: test.output };
|
|
272
|
+
}
|
|
273
|
+
// It was already not building (or not verifiable) before us → keep the orb.
|
|
274
|
+
applyMount(plan);
|
|
275
|
+
verifyMounted(plan);
|
|
276
|
+
writeConsentDoc(appRoot, plan);
|
|
277
|
+
return { status: 'added', file: plan.rel, test, preexistingFailure: true };
|
|
246
278
|
}
|
|
279
|
+
|
|
247
280
|
writeConsentDoc(appRoot, plan);
|
|
248
|
-
return { status: 'added', file: plan.rel
|
|
281
|
+
return { status: 'added', file: plan.rel };
|
|
249
282
|
} catch (err) {
|
|
250
283
|
revertFiles(backups);
|
|
251
284
|
printSnippet(target, framework, colors, appRoot, err.message || String(err));
|
|
@@ -259,7 +292,9 @@ function reportWireResult(result, colors = {}) {
|
|
|
259
292
|
switch (result.status) {
|
|
260
293
|
case 'added':
|
|
261
294
|
console.error(`${tag} · ✓ orb added to ${result.file}. Start your web app (e.g. \`npm run dev\`) to see it.`);
|
|
262
|
-
if (result.
|
|
295
|
+
if (result.preexistingFailure) {
|
|
296
|
+
console.error(`${tag} · ${dim || ''}Note: your app was ALREADY not building before the orb was added (${result.test && result.test.stage}); not caused by Fiodos — the orb is kept so it appears once your build is fixed.${reset || ''}`);
|
|
297
|
+
} else if (result.test && result.test.stage === 'skipped-no-deps') {
|
|
263
298
|
console.error(`${tag} · ${dim || ''}build not verified (no node_modules) — run npm install && npm run build locally.${reset || ''}`);
|
|
264
299
|
} else if (result.test && result.test.ok) {
|
|
265
300
|
console.error(`${tag} · ✓ ${result.test.command} passed after mount.`);
|
package/src/writeEnv.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* writeEnv — optional, consent-based convenience that ties the app's RUNTIME
|
|
3
|
+
* key/URL to the SAME key/URL the CLI just published with.
|
|
4
|
+
*
|
|
5
|
+
* The #1 silent-failure cause is a mismatch: you publish with key A against
|
|
6
|
+
* production, but your app runs with key B against localhost, so the orb fetches
|
|
7
|
+
* its manifest from the wrong place, finds nothing, and renders invisibly. This
|
|
8
|
+
* removes the second manual copy of the key by offering to write it into the
|
|
9
|
+
* correct env file for the detected framework.
|
|
10
|
+
*
|
|
11
|
+
* SAFEGUARDS (mirrors the handler-wiring philosophy — never act blindly):
|
|
12
|
+
* - Never overwrites silently: if the var exists and DIFFERS, it warns and asks.
|
|
13
|
+
* - Always asks for consent (unless --yes), showing the exact file/var/value.
|
|
14
|
+
* - Warns to keep the key out of git (suggests .gitignore) for committed files.
|
|
15
|
+
* - On unknown framework / no package.json / Angular (no .env convention), it
|
|
16
|
+
* does NOT write — it prints manual instructions instead.
|
|
17
|
+
*/
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const readline = require('readline');
|
|
23
|
+
|
|
24
|
+
function readJsonSafe(file) {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Decide which env file + variable names the detected framework uses. Returns
|
|
34
|
+
* null when there is no safe .env convention to write (Angular, or unknown).
|
|
35
|
+
*/
|
|
36
|
+
function resolveEnvPlan(appRoot) {
|
|
37
|
+
const pkg = readJsonSafe(path.join(appRoot, 'package.json'));
|
|
38
|
+
const has = (rel) => fs.existsSync(path.join(appRoot, rel));
|
|
39
|
+
if (!pkg && !has('angular.json')) {
|
|
40
|
+
return { framework: null, file: null, keyVar: null, urlVar: null };
|
|
41
|
+
}
|
|
42
|
+
const deps = pkg ? { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) } : {};
|
|
43
|
+
|
|
44
|
+
if (deps.next) {
|
|
45
|
+
return { framework: 'next', file: '.env.local', keyVar: 'NEXT_PUBLIC_FYODOS_API_KEY', urlVar: 'NEXT_PUBLIC_FYODOS_API_URL' };
|
|
46
|
+
}
|
|
47
|
+
if (deps['@angular/core'] || has('angular.json')) {
|
|
48
|
+
// Angular has no .env convention (values live in environment.ts), so we
|
|
49
|
+
// never write a file blindly — the caller prints manual guidance instead.
|
|
50
|
+
return { framework: 'angular', file: null, keyVar: null, urlVar: null };
|
|
51
|
+
}
|
|
52
|
+
if (
|
|
53
|
+
deps['@sveltejs/kit'] ||
|
|
54
|
+
deps.svelte ||
|
|
55
|
+
deps.vue ||
|
|
56
|
+
deps['@vitejs/plugin-vue'] ||
|
|
57
|
+
deps.vite ||
|
|
58
|
+
deps['@vitejs/plugin-react'] ||
|
|
59
|
+
deps['react-scripts']
|
|
60
|
+
) {
|
|
61
|
+
return { framework: 'vite', file: '.env', keyVar: 'VITE_FYODOS_API_KEY', urlVar: 'VITE_FYODOS_API_URL' };
|
|
62
|
+
}
|
|
63
|
+
return { framework: null, file: null, keyVar: null, urlVar: null };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Find the value of VAR in the parsed lines, or undefined if not present. */
|
|
67
|
+
function readEnvVar(lines, name) {
|
|
68
|
+
const re = new RegExp(`^\\s*${name}\\s*=(.*)$`);
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
const m = line.match(re);
|
|
71
|
+
if (m) return stripQuotes(m[1].trim());
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function stripQuotes(v) {
|
|
77
|
+
return v.replace(/^['"]/, '').replace(/['"]$/, '');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Upsert VAR=value: replace the existing line, or append a new one. */
|
|
81
|
+
function upsertEnvVar(lines, name, value) {
|
|
82
|
+
const re = new RegExp(`^\\s*${name}\\s*=`);
|
|
83
|
+
const idx = lines.findIndex((l) => re.test(l));
|
|
84
|
+
if (idx >= 0) {
|
|
85
|
+
lines[idx] = `${name}=${value}`;
|
|
86
|
+
return lines;
|
|
87
|
+
}
|
|
88
|
+
lines.push(`${name}=${value}`);
|
|
89
|
+
return lines;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function maskKey(key) {
|
|
93
|
+
if (!key) return '';
|
|
94
|
+
return key.length <= 14 ? key : `${key.slice(0, 12)}…`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function askYesNo(question) {
|
|
98
|
+
return new Promise((resolve) => {
|
|
99
|
+
if (!process.stdin.isTTY) {
|
|
100
|
+
resolve(false);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
104
|
+
rl.question(question, (answer) => {
|
|
105
|
+
rl.close();
|
|
106
|
+
resolve(/^(y|yes|s|si|sí)/i.test((answer || '').trim()));
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isGitIgnored(appRoot, fileName) {
|
|
112
|
+
const gi = path.join(appRoot, '.gitignore');
|
|
113
|
+
if (!fs.existsSync(gi)) return false;
|
|
114
|
+
try {
|
|
115
|
+
const content = fs.readFileSync(gi, 'utf8');
|
|
116
|
+
const patterns = content.split('\n').map((l) => l.trim());
|
|
117
|
+
return patterns.some(
|
|
118
|
+
(p) => p === fileName || p === `/${fileName}` || p === '.env*' || p === '*.local' || p === '.env.local',
|
|
119
|
+
);
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Offer to write key/URL to the app's env file. `log` is the branded logUser.
|
|
127
|
+
* Returns a short status string for the final report.
|
|
128
|
+
*/
|
|
129
|
+
async function offerWriteEnv({ appRoot, apiKey, apiUrl, defaultApiUrl, assumeYes, log }) {
|
|
130
|
+
if (!apiKey) return 'skipped (no API key)';
|
|
131
|
+
|
|
132
|
+
const plan = resolveEnvPlan(appRoot);
|
|
133
|
+
if (!plan.file) {
|
|
134
|
+
if (plan.framework === 'angular') {
|
|
135
|
+
log(
|
|
136
|
+
'Angular does not use .env: put these values in src/environments/environment.ts → ' +
|
|
137
|
+
`fyodosApiKey: '${maskKey(apiKey)}', fyodosApiUrl: '${apiUrl}'`,
|
|
138
|
+
);
|
|
139
|
+
return 'manual (angular)';
|
|
140
|
+
}
|
|
141
|
+
log(
|
|
142
|
+
'Could not identify the framework with confidence; add by hand to your .env: ' +
|
|
143
|
+
`(NEXT_PUBLIC_/VITE_)FYODOS_API_KEY=${maskKey(apiKey)} and the API URL ${apiUrl}`,
|
|
144
|
+
);
|
|
145
|
+
return 'manual (unknown framework)';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const filePath = path.join(appRoot, plan.file);
|
|
149
|
+
const exists = fs.existsSync(filePath);
|
|
150
|
+
const raw = exists ? fs.readFileSync(filePath, 'utf8') : '';
|
|
151
|
+
const lines = raw.length ? raw.replace(/\n$/, '').split('\n') : [];
|
|
152
|
+
|
|
153
|
+
const currentKey = readEnvVar(lines, plan.keyVar);
|
|
154
|
+
const currentUrl = readEnvVar(lines, plan.urlVar);
|
|
155
|
+
|
|
156
|
+
// Only write a URL var when it differs from the public default (the SDK
|
|
157
|
+
// already defaults to production). But if a STALE url var exists and differs
|
|
158
|
+
// from what we published against, we must offer to fix it — that is exactly
|
|
159
|
+
// the localhost-vs-production mismatch that hides the orb.
|
|
160
|
+
const wantUrl = apiUrl && apiUrl !== defaultApiUrl ? apiUrl : null;
|
|
161
|
+
const urlNeedsFix = currentUrl !== undefined && currentUrl !== apiUrl;
|
|
162
|
+
|
|
163
|
+
const keyMatches = currentKey === apiKey;
|
|
164
|
+
const urlOk = !urlNeedsFix && (wantUrl ? currentUrl === wantUrl : true);
|
|
165
|
+
if (keyMatches && urlOk) {
|
|
166
|
+
log(`✓ Your ${plan.file} already uses the same key/URL as the publication. Nothing to change.`);
|
|
167
|
+
return 'already aligned';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Build the change description.
|
|
171
|
+
const changes = [];
|
|
172
|
+
if (!keyMatches) {
|
|
173
|
+
changes.push(
|
|
174
|
+
currentKey === undefined
|
|
175
|
+
? `add ${plan.keyVar}=${maskKey(apiKey)}`
|
|
176
|
+
: `update ${plan.keyVar} (was ${maskKey(currentKey)}) → ${maskKey(apiKey)}`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (wantUrl && currentUrl !== wantUrl) {
|
|
180
|
+
changes.push(
|
|
181
|
+
currentUrl === undefined
|
|
182
|
+
? `add ${plan.urlVar}=${wantUrl}`
|
|
183
|
+
: `update ${plan.urlVar} (was ${currentUrl}) → ${wantUrl}`,
|
|
184
|
+
);
|
|
185
|
+
} else if (urlNeedsFix && !wantUrl) {
|
|
186
|
+
// The app points somewhere (e.g. localhost) but we published against the
|
|
187
|
+
// public default; the var is misleading. Offer to align it explicitly.
|
|
188
|
+
changes.push(`update ${plan.urlVar} (was ${currentUrl}) → ${apiUrl}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!changes.length) {
|
|
192
|
+
log(`✓ Your ${plan.file} is already aligned.`);
|
|
193
|
+
return 'already aligned';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Loud warning when we are about to CHANGE an existing, different value.
|
|
197
|
+
const isOverwrite = (currentKey !== undefined && !keyMatches) || urlNeedsFix;
|
|
198
|
+
log(
|
|
199
|
+
`${isOverwrite ? '⚠︎ ' : ''}For the orb to find its manifest, your app should use the SAME ` +
|
|
200
|
+
`key/URL you just published with. Proposed in ${plan.file}: ${changes.join('; ')}.`,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
let proceed = assumeYes;
|
|
204
|
+
if (!proceed) {
|
|
205
|
+
proceed = await askYesNo(
|
|
206
|
+
`◉ Fiodos · Write/update ${plan.file} with the published key/URL? [yes/no] `,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (!proceed) {
|
|
210
|
+
log(`Left your ${plan.file} untouched. Do it by hand: ${changes.join('; ')}.`);
|
|
211
|
+
return 'declined by user';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let out = lines.slice();
|
|
215
|
+
if (!keyMatches) out = upsertEnvVar(out, plan.keyVar, apiKey);
|
|
216
|
+
if (wantUrl) {
|
|
217
|
+
out = upsertEnvVar(out, plan.urlVar, wantUrl);
|
|
218
|
+
} else if (urlNeedsFix) {
|
|
219
|
+
out = upsertEnvVar(out, plan.urlVar, apiUrl);
|
|
220
|
+
}
|
|
221
|
+
fs.writeFileSync(filePath, `${out.join('\n')}\n`);
|
|
222
|
+
log(`✓ ${plan.file} updated (${changes.join('; ')}).`);
|
|
223
|
+
|
|
224
|
+
if (!isGitIgnored(appRoot, plan.file)) {
|
|
225
|
+
log(`Reminder: add ${plan.file} to .gitignore so you don't commit your API key.`);
|
|
226
|
+
}
|
|
227
|
+
return 'written';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
module.exports = { offerWriteEnv, resolveEnvPlan };
|