@fragments-sdk/cli 0.13.1 → 0.14.0
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/dist/bin.js +11 -1
- package/dist/bin.js.map +1 -1
- package/dist/init-cloud-REQ3XLHO.js +279 -0
- package/dist/init-cloud-REQ3XLHO.js.map +1 -0
- package/package.json +3 -3
- package/src/bin.ts +17 -0
- package/src/commands/init-cloud.ts +354 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fragments init --cloud
|
|
3
|
+
*
|
|
4
|
+
* Zero-config cloud setup: opens browser for auth, receives API key
|
|
5
|
+
* via localhost callback, detects project, installs deps, creates config,
|
|
6
|
+
* runs first check.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
10
|
+
import { randomBytes } from 'node:crypto';
|
|
11
|
+
import { execSync, exec } from 'node:child_process';
|
|
12
|
+
import { readFileSync, writeFileSync, existsSync, appendFileSync } from 'node:fs';
|
|
13
|
+
import { resolve, join } from 'node:path';
|
|
14
|
+
import { platform } from 'node:os';
|
|
15
|
+
import pc from 'picocolors';
|
|
16
|
+
import { BRAND } from '../core/index.js';
|
|
17
|
+
|
|
18
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
interface AuthResult {
|
|
21
|
+
apiKey: string;
|
|
22
|
+
orgName: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface InitCloudOptions {
|
|
26
|
+
/** Cloud dashboard URL */
|
|
27
|
+
url?: string;
|
|
28
|
+
/** Port for localhost callback server */
|
|
29
|
+
port?: number;
|
|
30
|
+
/** Timeout in ms for auth */
|
|
31
|
+
timeout?: number;
|
|
32
|
+
/** Skip project detection and setup */
|
|
33
|
+
authOnly?: boolean;
|
|
34
|
+
/** Skip the first governance check */
|
|
35
|
+
skipCheck?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── Utilities ──────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
function detectPackageManager(): 'pnpm' | 'yarn' | 'bun' | 'npm' {
|
|
41
|
+
// Check current dir and parent dirs (for monorepos)
|
|
42
|
+
let dir = process.cwd();
|
|
43
|
+
const root = resolve('/');
|
|
44
|
+
while (dir !== root) {
|
|
45
|
+
if (existsSync(join(dir, 'bun.lockb')) || existsSync(join(dir, 'bun.lock'))) return 'bun';
|
|
46
|
+
if (existsSync(join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
|
|
47
|
+
if (existsSync(join(dir, 'yarn.lock'))) return 'yarn';
|
|
48
|
+
const parent = resolve(dir, '..');
|
|
49
|
+
if (parent === dir) break;
|
|
50
|
+
dir = parent;
|
|
51
|
+
}
|
|
52
|
+
return 'npm';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function detectFramework(): string {
|
|
56
|
+
try {
|
|
57
|
+
const pkg = JSON.parse(readFileSync(resolve('package.json'), 'utf-8'));
|
|
58
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
59
|
+
|
|
60
|
+
if (allDeps['next']) return 'Next.js';
|
|
61
|
+
if (allDeps['nuxt']) return 'Nuxt';
|
|
62
|
+
if (allDeps['@sveltejs/kit']) return 'SvelteKit';
|
|
63
|
+
if (allDeps['svelte']) return 'Svelte';
|
|
64
|
+
if (allDeps['vue']) return 'Vue';
|
|
65
|
+
if (allDeps['astro']) return 'Astro';
|
|
66
|
+
if (allDeps['react']) return 'React';
|
|
67
|
+
return 'Unknown';
|
|
68
|
+
} catch {
|
|
69
|
+
return 'Unknown';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function inferInputGlob(framework: string): string | string[] {
|
|
74
|
+
switch (framework) {
|
|
75
|
+
case 'Next.js':
|
|
76
|
+
return ['./app/**/*.{tsx,jsx}', './components/**/*.{tsx,jsx}'];
|
|
77
|
+
case 'Nuxt':
|
|
78
|
+
case 'Vue':
|
|
79
|
+
return ['./**/*.vue'];
|
|
80
|
+
case 'SvelteKit':
|
|
81
|
+
case 'Svelte':
|
|
82
|
+
return ['./src/**/*.svelte'];
|
|
83
|
+
case 'Astro':
|
|
84
|
+
return ['./src/**/*.{astro,tsx,jsx}'];
|
|
85
|
+
default:
|
|
86
|
+
return './src/**/*.{tsx,jsx}';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function openBrowser(url: string): void {
|
|
91
|
+
const os = platform();
|
|
92
|
+
const cmd =
|
|
93
|
+
os === 'darwin' ? 'open' :
|
|
94
|
+
os === 'win32' ? 'start ""' :
|
|
95
|
+
'xdg-open';
|
|
96
|
+
|
|
97
|
+
exec(`${cmd} "${url}"`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function installCommand(pm: string): string {
|
|
101
|
+
switch (pm) {
|
|
102
|
+
case 'pnpm': return 'pnpm add';
|
|
103
|
+
case 'yarn': return 'yarn add';
|
|
104
|
+
case 'bun': return 'bun add';
|
|
105
|
+
default: return 'npm install';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isMonorepoWorkspaceDep(): boolean {
|
|
110
|
+
try {
|
|
111
|
+
const pkg = JSON.parse(readFileSync(resolve('package.json'), 'utf-8'));
|
|
112
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
113
|
+
return (
|
|
114
|
+
allDeps['@fragments-sdk/govern']?.startsWith('workspace:') ||
|
|
115
|
+
allDeps['@fragments-sdk/cli']?.startsWith('workspace:')
|
|
116
|
+
);
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Localhost Auth Server ──────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
function waitForAuth(
|
|
125
|
+
cloudUrl: string,
|
|
126
|
+
port: number,
|
|
127
|
+
timeoutMs: number,
|
|
128
|
+
): Promise<AuthResult> {
|
|
129
|
+
const nonce = randomBytes(16).toString('hex');
|
|
130
|
+
|
|
131
|
+
return new Promise<AuthResult>((resolve, reject) => {
|
|
132
|
+
const timeout = setTimeout(() => {
|
|
133
|
+
server.close();
|
|
134
|
+
reject(new Error('Authentication timed out. Please try again.'));
|
|
135
|
+
}, timeoutMs);
|
|
136
|
+
|
|
137
|
+
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
138
|
+
const url = new URL(req.url!, `http://localhost:${port}`);
|
|
139
|
+
|
|
140
|
+
if (url.pathname === '/callback') {
|
|
141
|
+
const key = url.searchParams.get('key');
|
|
142
|
+
const org = url.searchParams.get('org');
|
|
143
|
+
const returnedNonce = url.searchParams.get('nonce');
|
|
144
|
+
|
|
145
|
+
if (returnedNonce !== nonce) {
|
|
146
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
147
|
+
res.end('Nonce mismatch — please try again.');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!key) {
|
|
152
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
153
|
+
res.end('Missing API key in callback.');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Show "you can close this tab" page
|
|
158
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
159
|
+
res.end(`<!DOCTYPE html>
|
|
160
|
+
<html><head><title>Fragments CLI</title>
|
|
161
|
+
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#0a0a0f;color:#e5e5e5}
|
|
162
|
+
.box{text-align:center;padding:48px}.check{font-size:48px;margin-bottom:16px}p{color:#888;margin-top:8px}</style>
|
|
163
|
+
</head><body><div class="box"><div class="check">✓</div><h2>CLI Authorized</h2><p>You can close this tab and return to your terminal.</p></div>
|
|
164
|
+
<script>setTimeout(()=>window.close(),3000)</script>
|
|
165
|
+
</body></html>`);
|
|
166
|
+
|
|
167
|
+
clearTimeout(timeout);
|
|
168
|
+
server.close();
|
|
169
|
+
resolve({ apiKey: key, orgName: org ?? 'your organization' });
|
|
170
|
+
} else {
|
|
171
|
+
res.writeHead(404);
|
|
172
|
+
res.end();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
server.on('error', (err: NodeJS.ErrnoException) => {
|
|
177
|
+
if (err.code === 'EADDRINUSE') {
|
|
178
|
+
clearTimeout(timeout);
|
|
179
|
+
reject(new Error(`Port ${port} is in use. Try: fragments init --cloud --port ${port + 1}`));
|
|
180
|
+
} else {
|
|
181
|
+
clearTimeout(timeout);
|
|
182
|
+
reject(err);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
server.listen(port, '127.0.0.1', () => {
|
|
187
|
+
const authUrl = `${cloudUrl}/cli-auth?port=${port}&nonce=${nonce}`;
|
|
188
|
+
openBrowser(authUrl);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ─── Save to .env ───────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
function saveApiKey(apiKey: string, cloudUrl: string): void {
|
|
196
|
+
const envPath = resolve('.env');
|
|
197
|
+
const entry = `FRAGMENTS_API_KEY=${apiKey}`;
|
|
198
|
+
|
|
199
|
+
if (existsSync(envPath)) {
|
|
200
|
+
const content = readFileSync(envPath, 'utf-8');
|
|
201
|
+
if (content.includes('FRAGMENTS_API_KEY=')) {
|
|
202
|
+
const updated = content.replace(/^FRAGMENTS_API_KEY=.*$/m, entry);
|
|
203
|
+
writeFileSync(envPath, updated, 'utf-8');
|
|
204
|
+
} else {
|
|
205
|
+
appendFileSync(envPath, `\n${entry}\n`, 'utf-8');
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
writeFileSync(envPath, `${entry}\n`, 'utf-8');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Add FRAGMENTS_URL if non-default
|
|
212
|
+
if (cloudUrl !== 'https://app.usefragments.com') {
|
|
213
|
+
const content = readFileSync(envPath, 'utf-8');
|
|
214
|
+
if (!content.includes('FRAGMENTS_URL=')) {
|
|
215
|
+
appendFileSync(envPath, `FRAGMENTS_URL=${cloudUrl}\n`, 'utf-8');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Ensure .env is in .gitignore
|
|
220
|
+
const gitignorePath = resolve('.gitignore');
|
|
221
|
+
if (existsSync(gitignorePath)) {
|
|
222
|
+
const gitignore = readFileSync(gitignorePath, 'utf-8');
|
|
223
|
+
if (!gitignore.split('\n').some((line) => line.trim() === '.env')) {
|
|
224
|
+
appendFileSync(gitignorePath, '\n.env\n', 'utf-8');
|
|
225
|
+
}
|
|
226
|
+
} else {
|
|
227
|
+
writeFileSync(gitignorePath, '.env\n', 'utf-8');
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ─── Write governance config ────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
function writeGovernConfig(input: string | string[]): void {
|
|
234
|
+
const configPath = resolve(BRAND.configFile);
|
|
235
|
+
if (existsSync(configPath)) return; // Don't overwrite
|
|
236
|
+
|
|
237
|
+
const inputStr = Array.isArray(input)
|
|
238
|
+
? `[${input.map((p) => `'${p}'`).join(', ')}]`
|
|
239
|
+
: `'${input}'`;
|
|
240
|
+
|
|
241
|
+
const template = `import { defineConfig } from '@fragments-sdk/govern';
|
|
242
|
+
|
|
243
|
+
export default defineConfig({
|
|
244
|
+
cloud: true,
|
|
245
|
+
checks: ['accessibility', 'consistency', 'responsive'],
|
|
246
|
+
input: ${inputStr},
|
|
247
|
+
});
|
|
248
|
+
`;
|
|
249
|
+
|
|
250
|
+
writeFileSync(configPath, template, 'utf-8');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
export async function initCloud(options: InitCloudOptions = {}): Promise<void> {
|
|
256
|
+
const cloudUrl = options.url ?? process.env.FRAGMENTS_URL ?? 'https://app.usefragments.com';
|
|
257
|
+
const port = options.port ?? 9876;
|
|
258
|
+
const timeoutMs = options.timeout ?? 120_000;
|
|
259
|
+
|
|
260
|
+
console.log(pc.bold(`\n ${BRAND.name}\n`));
|
|
261
|
+
|
|
262
|
+
// ── 1. Detect project ─────────────────────────────────────────────
|
|
263
|
+
if (!options.authOnly) {
|
|
264
|
+
if (!existsSync(resolve('package.json'))) {
|
|
265
|
+
console.log(pc.red(' No package.json found. Run this from a project directory.\n'));
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const pm = detectPackageManager();
|
|
270
|
+
const framework = detectFramework();
|
|
271
|
+
console.log(pc.dim(` Project: ${framework}`));
|
|
272
|
+
console.log(pc.dim(` Package manager: ${pm}\n`));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── 2. Authenticate ───────────────────────────────────────────────
|
|
276
|
+
console.log(pc.dim(' Opening browser to sign in...\n'));
|
|
277
|
+
|
|
278
|
+
let auth: AuthResult;
|
|
279
|
+
try {
|
|
280
|
+
auth = await waitForAuth(cloudUrl, port, timeoutMs);
|
|
281
|
+
} catch (err) {
|
|
282
|
+
console.log(pc.red(`\n ${err instanceof Error ? err.message : 'Auth failed'}\n`));
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
console.log(pc.green(` ✓ Authenticated — ${auth.orgName}\n`));
|
|
287
|
+
|
|
288
|
+
// ── 3. Save API key ──────────────────────────────────────────────
|
|
289
|
+
saveApiKey(auth.apiKey, cloudUrl);
|
|
290
|
+
console.log(pc.green(' ✓ API key saved to .env'));
|
|
291
|
+
|
|
292
|
+
if (options.authOnly) {
|
|
293
|
+
console.log(pc.green('\n ✓ All set!\n'));
|
|
294
|
+
console.log(pc.dim(` Dashboard: ${cloudUrl}\n`));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ── 4. Install dependencies ───────────────────────────────────────
|
|
299
|
+
const pm = detectPackageManager();
|
|
300
|
+
|
|
301
|
+
if (!isMonorepoWorkspaceDep()) {
|
|
302
|
+
const deps = '@fragments-sdk/govern @fragments-sdk/cli';
|
|
303
|
+
console.log(pc.dim(`\n Installing ${deps}...`));
|
|
304
|
+
try {
|
|
305
|
+
execSync(`${installCommand(pm)} ${deps}`, {
|
|
306
|
+
stdio: 'pipe',
|
|
307
|
+
cwd: process.cwd(),
|
|
308
|
+
});
|
|
309
|
+
console.log(pc.green(' ✓ Dependencies installed'));
|
|
310
|
+
} catch (err) {
|
|
311
|
+
console.log(pc.yellow(' ⚠ Install failed — you may need to install manually:'));
|
|
312
|
+
console.log(pc.dim(` ${installCommand(pm)} ${deps}\n`));
|
|
313
|
+
}
|
|
314
|
+
} else {
|
|
315
|
+
console.log(pc.dim('\n Workspace deps detected — skipping install'));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ── 5. Create governance config ───────────────────────────────────
|
|
319
|
+
const framework = detectFramework();
|
|
320
|
+
const input = inferInputGlob(framework);
|
|
321
|
+
const configPath = resolve(BRAND.configFile);
|
|
322
|
+
|
|
323
|
+
if (existsSync(configPath)) {
|
|
324
|
+
console.log(pc.dim(` Config already exists: ${BRAND.configFile}`));
|
|
325
|
+
} else {
|
|
326
|
+
writeGovernConfig(input);
|
|
327
|
+
console.log(pc.green(` ✓ Created ${BRAND.configFile}`));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── 6. Run first check ────────────────────────────────────────────
|
|
331
|
+
if (!options.skipCheck) {
|
|
332
|
+
console.log(pc.dim('\n Running first governance check...\n'));
|
|
333
|
+
try {
|
|
334
|
+
const output = execSync('npx fragments govern check --cloud', {
|
|
335
|
+
stdio: 'pipe',
|
|
336
|
+
cwd: process.cwd(),
|
|
337
|
+
env: { ...process.env, FRAGMENTS_API_KEY: auth.apiKey },
|
|
338
|
+
});
|
|
339
|
+
console.log(output.toString());
|
|
340
|
+
} catch (err: any) {
|
|
341
|
+
// Check may "fail" with violations — that's OK
|
|
342
|
+
if (err.stdout) {
|
|
343
|
+
console.log(err.stdout.toString());
|
|
344
|
+
}
|
|
345
|
+
console.log(pc.dim(' (check completed with violations — see dashboard for details)'));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ── 7. Done ───────────────────────────────────────────────────────
|
|
350
|
+
console.log(pc.green('\n ✓ All set!') + ' Your project is connected to Fragments Cloud.\n');
|
|
351
|
+
console.log(pc.dim(` Dashboard: ${cloudUrl}`));
|
|
352
|
+
console.log(pc.dim(' Run checks: fragments govern check --cloud'));
|
|
353
|
+
console.log(pc.dim(' View config: fragments.config.ts\n'));
|
|
354
|
+
}
|