@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.
- package/dist/bin.js +163 -17
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-3SOAPJDX.js → chunk-55KERLWL.js} +2 -2
- package/dist/{chunk-4K7EAQ5L.js → chunk-7K3VROEP.js} +2 -2
- package/dist/{chunk-RF3C6LGA.js → chunk-FZLPVN32.js} +5 -5
- package/dist/{chunk-QM7SVOGF.js → chunk-I34BC3CU.js} +10 -1
- package/dist/chunk-I34BC3CU.js.map +1 -0
- package/dist/{chunk-DXX6HADE.js → chunk-PJT5IZ37.js} +2 -2
- package/dist/{chunk-UV5JQV3R.js → chunk-TXFCEDOC.js} +2 -2
- package/dist/{chunk-FO6EBJWP.js → chunk-Z5BUXIFJ.js} +5 -5
- package/dist/{chunk-SM674YAS.js → chunk-ZKTFKHWN.js} +2 -2
- package/dist/core/index.js +1 -1
- package/dist/{discovery-VSGC76JN.js → discovery-VDANZAJ2.js} +3 -3
- package/dist/{generate-QZXOXYFW.js → generate-RYWIPDN2.js} +4 -4
- package/dist/index.js +6 -6
- package/dist/{init-XK6PRUE5.js → init-U6534EMZ.js} +5 -5
- package/dist/init-cloud-REQ3XLHO.js +279 -0
- package/dist/init-cloud-REQ3XLHO.js.map +1 -0
- package/dist/mcp-bin.js +2 -2
- package/dist/{scan-CHQHXWVD.js → scan-LE2JEIJ4.js} +6 -6
- package/dist/{scan-generate-U3RFVDTX.js → scan-generate-TFZVL3BT.js} +4 -4
- package/dist/{service-MMEKG4MZ.js → service-S5LXPKV4.js} +3 -3
- package/dist/{snapshot-53TUR3HW.js → snapshot-C5DYIGIV.js} +2 -2
- package/dist/{static-viewer-KKCR4KXR.js → static-viewer-DUVC4UIM.js} +3 -3
- package/dist/{test-5UCKXYSC.js → test-JW7JIDFG.js} +4 -4
- package/dist/{tokens-L46MK5AW.js → tokens-OPVTVITP.js} +5 -5
- package/dist/{viewer-M2EQQSGE.js → viewer-OBTEPVY7.js} +13 -13
- package/package.json +6 -6
- package/src/bin.ts +32 -3
- package/src/commands/govern.ts +158 -1
- package/src/commands/init-cloud.ts +354 -0
- package/dist/chunk-QM7SVOGF.js.map +0 -1
- /package/dist/{chunk-3SOAPJDX.js.map → chunk-55KERLWL.js.map} +0 -0
- /package/dist/{chunk-4K7EAQ5L.js.map → chunk-7K3VROEP.js.map} +0 -0
- /package/dist/{chunk-RF3C6LGA.js.map → chunk-FZLPVN32.js.map} +0 -0
- /package/dist/{chunk-DXX6HADE.js.map → chunk-PJT5IZ37.js.map} +0 -0
- /package/dist/{chunk-UV5JQV3R.js.map → chunk-TXFCEDOC.js.map} +0 -0
- /package/dist/{chunk-FO6EBJWP.js.map → chunk-Z5BUXIFJ.js.map} +0 -0
- /package/dist/{chunk-SM674YAS.js.map → chunk-ZKTFKHWN.js.map} +0 -0
- /package/dist/{discovery-VSGC76JN.js.map → discovery-VDANZAJ2.js.map} +0 -0
- /package/dist/{generate-QZXOXYFW.js.map → generate-RYWIPDN2.js.map} +0 -0
- /package/dist/{init-XK6PRUE5.js.map → init-U6534EMZ.js.map} +0 -0
- /package/dist/{scan-CHQHXWVD.js.map → scan-LE2JEIJ4.js.map} +0 -0
- /package/dist/{scan-generate-U3RFVDTX.js.map → scan-generate-TFZVL3BT.js.map} +0 -0
- /package/dist/{service-MMEKG4MZ.js.map → service-S5LXPKV4.js.map} +0 -0
- /package/dist/{snapshot-53TUR3HW.js.map → snapshot-C5DYIGIV.js.map} +0 -0
- /package/dist/{static-viewer-KKCR4KXR.js.map → static-viewer-DUVC4UIM.js.map} +0 -0
- /package/dist/{test-5UCKXYSC.js.map → test-JW7JIDFG.js.map} +0 -0
- /package/dist/{tokens-L46MK5AW.js.map → tokens-OPVTVITP.js.map} +0 -0
- /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
|
|
1151
|
-
.option('-o, --output <path>', 'Output path', '
|
|
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();
|
package/src/commands/govern.ts
CHANGED
|
@@ -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 ?? '
|
|
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">✓</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
|
+
}
|