@fragments-sdk/cli 0.13.0 → 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.
Files changed (50) hide show
  1. package/dist/bin.js +163 -17
  2. package/dist/bin.js.map +1 -1
  3. package/dist/{chunk-3SOAPJDX.js → chunk-55KERLWL.js} +2 -2
  4. package/dist/{chunk-4K7EAQ5L.js → chunk-7K3VROEP.js} +2 -2
  5. package/dist/{chunk-RF3C6LGA.js → chunk-FZLPVN32.js} +5 -5
  6. package/dist/{chunk-QM7SVOGF.js → chunk-I34BC3CU.js} +10 -1
  7. package/dist/chunk-I34BC3CU.js.map +1 -0
  8. package/dist/{chunk-DXX6HADE.js → chunk-PJT5IZ37.js} +2 -2
  9. package/dist/{chunk-UV5JQV3R.js → chunk-TXFCEDOC.js} +2 -2
  10. package/dist/{chunk-FO6EBJWP.js → chunk-Z5BUXIFJ.js} +5 -5
  11. package/dist/{chunk-SM674YAS.js → chunk-ZKTFKHWN.js} +2 -2
  12. package/dist/core/index.js +1 -1
  13. package/dist/{discovery-VSGC76JN.js → discovery-VDANZAJ2.js} +3 -3
  14. package/dist/{generate-QZXOXYFW.js → generate-RYWIPDN2.js} +4 -4
  15. package/dist/index.js +6 -6
  16. package/dist/{init-XK6PRUE5.js → init-U6534EMZ.js} +5 -5
  17. package/dist/init-cloud-REQ3XLHO.js +279 -0
  18. package/dist/init-cloud-REQ3XLHO.js.map +1 -0
  19. package/dist/mcp-bin.js +2 -2
  20. package/dist/{scan-CHQHXWVD.js → scan-LE2JEIJ4.js} +6 -6
  21. package/dist/{scan-generate-U3RFVDTX.js → scan-generate-TFZVL3BT.js} +4 -4
  22. package/dist/{service-MMEKG4MZ.js → service-S5LXPKV4.js} +3 -3
  23. package/dist/{snapshot-53TUR3HW.js → snapshot-C5DYIGIV.js} +2 -2
  24. package/dist/{static-viewer-KKCR4KXR.js → static-viewer-DUVC4UIM.js} +3 -3
  25. package/dist/{test-5UCKXYSC.js → test-JW7JIDFG.js} +4 -4
  26. package/dist/{tokens-L46MK5AW.js → tokens-OPVTVITP.js} +5 -5
  27. package/dist/{viewer-M2EQQSGE.js → viewer-OBTEPVY7.js} +13 -13
  28. package/package.json +6 -6
  29. package/src/bin.ts +32 -3
  30. package/src/commands/govern.ts +158 -1
  31. package/src/commands/init-cloud.ts +354 -0
  32. package/dist/chunk-QM7SVOGF.js.map +0 -1
  33. /package/dist/{chunk-3SOAPJDX.js.map → chunk-55KERLWL.js.map} +0 -0
  34. /package/dist/{chunk-4K7EAQ5L.js.map → chunk-7K3VROEP.js.map} +0 -0
  35. /package/dist/{chunk-RF3C6LGA.js.map → chunk-FZLPVN32.js.map} +0 -0
  36. /package/dist/{chunk-DXX6HADE.js.map → chunk-PJT5IZ37.js.map} +0 -0
  37. /package/dist/{chunk-UV5JQV3R.js.map → chunk-TXFCEDOC.js.map} +0 -0
  38. /package/dist/{chunk-FO6EBJWP.js.map → chunk-Z5BUXIFJ.js.map} +0 -0
  39. /package/dist/{chunk-SM674YAS.js.map → chunk-ZKTFKHWN.js.map} +0 -0
  40. /package/dist/{discovery-VSGC76JN.js.map → discovery-VDANZAJ2.js.map} +0 -0
  41. /package/dist/{generate-QZXOXYFW.js.map → generate-RYWIPDN2.js.map} +0 -0
  42. /package/dist/{init-XK6PRUE5.js.map → init-U6534EMZ.js.map} +0 -0
  43. /package/dist/{scan-CHQHXWVD.js.map → scan-LE2JEIJ4.js.map} +0 -0
  44. /package/dist/{scan-generate-U3RFVDTX.js.map → scan-generate-TFZVL3BT.js.map} +0 -0
  45. /package/dist/{service-MMEKG4MZ.js.map → service-S5LXPKV4.js.map} +0 -0
  46. /package/dist/{snapshot-53TUR3HW.js.map → snapshot-C5DYIGIV.js.map} +0 -0
  47. /package/dist/{static-viewer-KKCR4KXR.js.map → static-viewer-DUVC4UIM.js.map} +0 -0
  48. /package/dist/{test-5UCKXYSC.js.map → test-JW7JIDFG.js.map} +0 -0
  49. /package/dist/{tokens-L46MK5AW.js.map → tokens-OPVTVITP.js.map} +0 -0
  50. /package/dist/{viewer-M2EQQSGE.js.map → viewer-OBTEPVY7.js.map} +0 -0
package/src/bin.ts CHANGED
@@ -40,7 +40,7 @@ import { perf } from './commands/perf.js';
40
40
  import { doctor } from './commands/doctor.js';
41
41
  import { setup } from './commands/setup.js';
42
42
  import { sync } from './commands/sync.js';
43
- import { governCheck, governInit, governReport } from './commands/govern.js';
43
+ import { governCheck, governInit, governReport, governConnect } from './commands/govern.js';
44
44
 
45
45
  // Import existing commands that were already extracted
46
46
  import { runScreenshotCommand } from './screenshot.js';
@@ -828,6 +828,11 @@ program
828
828
  .description('Initialize fragments in a project (zero-config by default)')
829
829
  .option('--force', 'Overwrite existing config')
830
830
  .option('-y, --yes', 'Non-interactive mode (now the default)')
831
+ .option('--cloud', 'Set up Fragments Cloud governance (zero-config browser auth)')
832
+ .option('--cloud-url <url>', 'Cloud dashboard URL (default: https://app.usefragments.com)')
833
+ .option('--port <port>', 'Localhost port for auth callback (default: 9876)')
834
+ .option('--auth-only', 'Only authenticate, skip project setup')
835
+ .option('--skip-check', 'Skip running the first governance check')
831
836
  .option('--configure', 'Interactive mode for theme seeds, snapshots, etc.')
832
837
  .option('--scan <path>', 'Scan a TypeScript component directory and generate fragment files')
833
838
  .option('--enrich', 'Use AI to fill knowledge fields during --scan (requires API key)')
@@ -837,6 +842,18 @@ program
837
842
  .option('--model <model>', 'Override AI model for enrichment')
838
843
  .action(async (options) => {
839
844
  try {
845
+ // Cloud init — separate flow
846
+ if (options.cloud) {
847
+ const { initCloud } = await import('./commands/init-cloud.js');
848
+ await initCloud({
849
+ url: options.cloudUrl,
850
+ port: options.port ? Number(options.port) : undefined,
851
+ authOnly: options.authOnly,
852
+ skipCheck: options.skipCheck,
853
+ });
854
+ return;
855
+ }
856
+
840
857
  const { init } = await import('./commands/init.js');
841
858
  const result = await init({
842
859
  projectRoot: process.cwd(),
@@ -1147,8 +1164,8 @@ governCmd
1147
1164
 
1148
1165
  governCmd
1149
1166
  .command('init')
1150
- .description('Generate a govern.config.ts template')
1151
- .option('-o, --output <path>', 'Output path', 'govern.config.ts')
1167
+ .description('Generate a fragments.config.ts with govern section')
1168
+ .option('-o, --output <path>', 'Output path', 'fragments.config.ts')
1152
1169
  .action(async (options) => {
1153
1170
  try {
1154
1171
  await governInit({ output: options.output });
@@ -1170,5 +1187,17 @@ governCmd
1170
1187
  }
1171
1188
  });
1172
1189
 
1190
+ governCmd
1191
+ .command('connect')
1192
+ .description('Connect your project to the Fragments Govern cloud dashboard')
1193
+ .action(async () => {
1194
+ try {
1195
+ await governConnect();
1196
+ } catch (error) {
1197
+ console.error(pc.red('Error:'), error instanceof Error ? error.message : error);
1198
+ process.exit(1);
1199
+ }
1200
+ });
1201
+
1173
1202
  // Parse command line arguments
1174
1203
  program.parse();
@@ -65,13 +65,170 @@ export async function governInit(options: GovernInitOptions = {}): Promise<void>
65
65
  const { resolve } = await import('node:path');
66
66
  const { generateConfigTemplate } = await import('@fragments-sdk/govern');
67
67
 
68
- const outputPath = resolve(options.output ?? 'govern.config.ts');
68
+ const outputPath = resolve(options.output ?? 'fragments.config.ts');
69
69
  const template = generateConfigTemplate();
70
70
 
71
71
  await writeFile(outputPath, template, 'utf-8');
72
72
  console.log(pc.green(`✓ Created ${outputPath}\n`));
73
73
  }
74
74
 
75
+ // ---------------------------------------------------------------------------
76
+ // connect
77
+ // ---------------------------------------------------------------------------
78
+
79
+ export async function governConnect(): Promise<void> {
80
+ const { readFile, writeFile, appendFile } = await import('node:fs/promises');
81
+ const { existsSync } = await import('node:fs');
82
+ const { resolve } = await import('node:path');
83
+ const { platform } = await import('node:os');
84
+ const { exec } = await import('node:child_process');
85
+ const { password, confirm } = await import('@inquirer/prompts');
86
+
87
+ const cloudUrl = process.env.FRAGMENTS_URL ?? 'https://app.usefragments.com';
88
+
89
+ console.log(pc.cyan(`\n ${BRAND.name} — Connect to Cloud\n`));
90
+ console.log(
91
+ pc.dim(' This will connect your project to the Fragments dashboard\n') +
92
+ pc.dim(' for centralized audit tracking and team visibility.\n'),
93
+ );
94
+
95
+ // ── Step 1: Get API key ──────────────────────────────────────────────────
96
+ console.log(pc.bold(' Step 1 of 3: Get your API key\n'));
97
+
98
+ const dashboardUrl = `${cloudUrl}/dashboard/settings`;
99
+ console.log(pc.dim(` → Opening the dashboard in your browser...`));
100
+ console.log(pc.dim(` Copy your API key from Settings → API Keys\n`));
101
+
102
+ // Open browser (best-effort)
103
+ const os = platform();
104
+ const openCmd = os === 'darwin'
105
+ ? `open "${dashboardUrl}"`
106
+ : os === 'win32'
107
+ ? `start "" "${dashboardUrl}"`
108
+ : `xdg-open "${dashboardUrl}"`;
109
+ exec(openCmd);
110
+
111
+ let apiKey: string;
112
+ let orgName: string;
113
+
114
+ // eslint-disable-next-line no-constant-condition
115
+ while (true) {
116
+ apiKey = await password({
117
+ message: 'Paste your API key:',
118
+ mask: '*',
119
+ });
120
+
121
+ if (!apiKey.trim()) {
122
+ console.log(pc.yellow('\n API key cannot be empty. Please try again.\n'));
123
+ continue;
124
+ }
125
+
126
+ // Verify key against cloud
127
+ console.log(pc.dim('\n Verifying...'));
128
+ try {
129
+ const response = await fetch(`${cloudUrl}/api/verify`, {
130
+ headers: { Authorization: `Bearer ${apiKey.trim()}` },
131
+ });
132
+
133
+ if (!response.ok) {
134
+ console.log(pc.red(`\n ✗ Invalid API key (HTTP ${response.status}). Please try again.\n`));
135
+ continue;
136
+ }
137
+
138
+ const data = (await response.json()) as { valid: boolean; orgName?: string };
139
+ if (!data.valid) {
140
+ console.log(pc.red('\n ✗ API key not recognized. Please try again.\n'));
141
+ continue;
142
+ }
143
+
144
+ orgName = data.orgName ?? 'your organization';
145
+ console.log(pc.green(`\n ✓ Connected to "${orgName}" (verified)\n`));
146
+ break;
147
+ } catch (error) {
148
+ console.log(
149
+ pc.red('\n ✗ Could not reach the dashboard.'),
150
+ );
151
+ console.log(
152
+ pc.dim(` ${error instanceof Error ? error.message : 'Network error'}\n`),
153
+ );
154
+ continue;
155
+ }
156
+ }
157
+
158
+ // ── Step 2: Save configuration ──────────────────────────────────────────
159
+ console.log(pc.bold(' Step 2 of 3: Save configuration\n'));
160
+
161
+ const saveToEnv = await confirm({
162
+ message: 'Save API key to .env file?',
163
+ default: true,
164
+ });
165
+
166
+ if (saveToEnv) {
167
+ const envPath = resolve('.env');
168
+ const envEntry = `FRAGMENTS_API_KEY=${apiKey.trim()}`;
169
+
170
+ if (existsSync(envPath)) {
171
+ const envContent = await readFile(envPath, 'utf-8');
172
+ if (envContent.includes('FRAGMENTS_API_KEY=')) {
173
+ // Replace existing entry
174
+ const updated = envContent.replace(
175
+ /^FRAGMENTS_API_KEY=.*$/m,
176
+ envEntry,
177
+ );
178
+ await writeFile(envPath, updated, 'utf-8');
179
+ console.log(pc.green(' ✓ Updated FRAGMENTS_API_KEY in .env'));
180
+ } else {
181
+ await appendFile(envPath, `\n${envEntry}\n`, 'utf-8');
182
+ console.log(pc.green(' ✓ Added FRAGMENTS_API_KEY to .env'));
183
+ }
184
+ } else {
185
+ await writeFile(envPath, `${envEntry}\n`, 'utf-8');
186
+ console.log(pc.green(' ✓ Created .env with FRAGMENTS_API_KEY'));
187
+ }
188
+
189
+ // Write FRAGMENTS_URL only if non-default
190
+ if (cloudUrl !== 'https://app.usefragments.com') {
191
+ const envContent = await readFile(envPath, 'utf-8');
192
+ if (!envContent.includes('FRAGMENTS_URL=')) {
193
+ await appendFile(envPath, `FRAGMENTS_URL=${cloudUrl}\n`, 'utf-8');
194
+ console.log(pc.green(` ✓ Added FRAGMENTS_URL to .env`));
195
+ }
196
+ }
197
+
198
+ // Ensure .env is in .gitignore
199
+ const gitignorePath = resolve('.gitignore');
200
+ if (existsSync(gitignorePath)) {
201
+ const gitignore = await readFile(gitignorePath, 'utf-8');
202
+ if (!gitignore.split('\n').some((line) => line.trim() === '.env')) {
203
+ await appendFile(gitignorePath, '\n.env\n', 'utf-8');
204
+ console.log(pc.green(' ✓ Added .env to .gitignore'));
205
+ }
206
+ } else {
207
+ await writeFile(gitignorePath, '.env\n', 'utf-8');
208
+ console.log(pc.green(' ✓ Created .gitignore with .env entry'));
209
+ }
210
+ }
211
+
212
+ // ── Step 3: Config check ────────────────────────────────────────────────
213
+ console.log(pc.bold('\n Step 3 of 3: Config check\n'));
214
+
215
+ const { findGovernConfig } = await import('@fragments-sdk/govern');
216
+ const configPath = findGovernConfig();
217
+
218
+ if (configPath) {
219
+ console.log(pc.green(` ✓ Found govern config: ${configPath}`));
220
+ } else {
221
+ console.log(
222
+ pc.yellow(' No govern config found — run `fragments govern init` to create one'),
223
+ );
224
+ }
225
+
226
+ // ── Done ────────────────────────────────────────────────────────────────
227
+ console.log(pc.dim('\n ─────────────────────────────────────\n'));
228
+ console.log(pc.green(' ✓ All set!') + ' Run `fragments govern check` to send your first audit.\n');
229
+ console.log(pc.dim(` Dashboard: ${cloudUrl}/dashboard\n`));
230
+ }
231
+
75
232
  // ---------------------------------------------------------------------------
76
233
  // report
77
234
  // ---------------------------------------------------------------------------
@@ -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">&#10003;</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
+ }