@loracle-js/cli 1.0.2

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 ADDED
@@ -0,0 +1,69 @@
1
+ # Loracle CLI
2
+
3
+ Publishes your Storybook to Loracle so your AI tools always have up-to-date component context.
4
+
5
+ <a href="https://www.npmjs.com/package/@loracle-js/cli">
6
+ <img src="https://badgen.net/npm/v/@loracle-js/cli" alt="Published on npm">
7
+ </a>
8
+
9
+ ## Documentation
10
+
11
+ 👉 Read the [Loracle CLI docs](https://getloracle.com/docs/cli)
12
+
13
+ ## Requirements
14
+
15
+ - Node.js 18+
16
+ - Storybook 7+
17
+ - A Loracle API key — get one at [getloracle.com](https://getloracle.com)
18
+
19
+ ## Usage
20
+
21
+ ```sh
22
+ npx @loracle-js/cli publish <storybook-url> --version <semver>
23
+ ```
24
+
25
+ **Example:**
26
+
27
+ ```sh
28
+ LORACLE_API_KEY=your_api_key npx @loracle-js/cli publish https://storybook.example.com --version 1.2.0
29
+ ```
30
+
31
+ ### Options
32
+
33
+ | Option | Description |
34
+ |---|---|
35
+ | `-v, --version` | Semantic version for this build (required) |
36
+ | `--dry-run` | Validate without uploading |
37
+
38
+ ### Environment variables
39
+
40
+ | Variable | Description |
41
+ |---|---|
42
+ | `LORACLE_API_KEY` | Your Loracle API key (required) |
43
+
44
+ ## CI
45
+
46
+ ### GitHub Actions
47
+
48
+ ```yaml
49
+ - name: Install Playwright browsers
50
+ run: npx playwright install chromium --with-deps
51
+
52
+ - name: Publish to Loracle
53
+ run: npx @loracle-js/cli publish ${{ vars.STORYBOOK_URL }} --version ${{ github.ref_name }}
54
+ env:
55
+ LORACLE_API_KEY: ${{ secrets.LORACLE_API_KEY }}
56
+ ```
57
+
58
+ ### GitHub Actions (official Playwright image)
59
+
60
+ ```yaml
61
+ container:
62
+ image: mcr.microsoft.com/playwright:latest
63
+
64
+ steps:
65
+ - name: Publish to Loracle
66
+ run: npx @loracle-js/cli publish ${{ vars.STORYBOOK_URL }} --version ${{ github.ref_name }}
67
+ env:
68
+ LORACLE_API_KEY: ${{ secrets.LORACLE_API_KEY }}
69
+ ```
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { publish } from '../publish.js';
4
+ import * as dotenv from 'dotenv';
5
+ dotenv.config();
6
+ const program = new Command();
7
+ program
8
+ .name('loracle')
9
+ .description('Loracle CLI - Publish your design system to Loracle')
10
+ .version('1.0.0', '-V, --cli-version', 'output the CLI version');
11
+ program
12
+ .command('publish')
13
+ .description('Scrape and publish Storybook components to Loracle')
14
+ .argument('<url>', 'Storybook URL to publish')
15
+ .requiredOption('-v, --version <version>', 'Semantic version for this build (e.g., 1.0.0)')
16
+ .option('--dry-run', 'Scrape and validate without uploading')
17
+ .action(async (url, options) => {
18
+ await publish({
19
+ url,
20
+ version: options.version,
21
+ dryRun: options.dryRun,
22
+ });
23
+ });
24
+ program.parse();
@@ -0,0 +1,162 @@
1
+ import { StorybookScraper } from './scraper.js';
2
+ import { initBuild, uploadToS3 } from './uploader.js';
3
+ import { promises as fs } from 'fs';
4
+ import path from 'path';
5
+ import os from 'os';
6
+ import { intro, outro, cancel, error, success, createSpinner, link, ErrorMessages } from './ui.js';
7
+ // Semver validation (from Portal API)
8
+ const semverRegex = /^\d+\.\d+\.\d+(-[\w.-]+)?(\+[\w.-]+)?$/;
9
+ const API_URL = 'https://getloracle.com';
10
+ export async function publish(options) {
11
+ const { url, version } = options;
12
+ const startTime = Date.now();
13
+ // Step 1: Validate API key (skip for dry-run)
14
+ const apiKey = options.apiKey || process.env.LORACLE_API_KEY;
15
+ if (!apiKey && !options.dryRun) {
16
+ error(ErrorMessages.missingApiKey.title, ErrorMessages.missingApiKey.details);
17
+ cancel('Missing API key');
18
+ process.exit(1);
19
+ }
20
+ // Step 2: Validate version format
21
+ if (!semverRegex.test(version)) {
22
+ const err = ErrorMessages.invalidVersion(version);
23
+ error(err.title, err.details);
24
+ cancel('Invalid version');
25
+ process.exit(1);
26
+ }
27
+ // Show intro
28
+ const dryRunLabel = options.dryRun ? ' (dry run)' : '';
29
+ intro('publish', `v${version}${dryRunLabel}`);
30
+ // Step 3: Connect to Storybook
31
+ const connectSpinner = createSpinner();
32
+ connectSpinner.start('Connecting to Storybook...');
33
+ let componentCount = 0;
34
+ try {
35
+ const baseUrl = url.endsWith('/') ? url : url + '/';
36
+ const indexUrl = baseUrl + 'index.json';
37
+ const response = await fetch(indexUrl, { signal: AbortSignal.timeout(10000) });
38
+ if (!response.ok) {
39
+ throw new Error(`HTTP ${response.status}`);
40
+ }
41
+ const indexData = await response.json();
42
+ // Count unique components (group by component path)
43
+ const components = new Set();
44
+ if (indexData.entries) {
45
+ for (const entry of Object.values(indexData.entries)) {
46
+ const e = entry;
47
+ if (e.type === 'docs' || e.type === 'story') {
48
+ if (e.title) {
49
+ components.add(e.title);
50
+ }
51
+ }
52
+ }
53
+ }
54
+ componentCount = components.size;
55
+ connectSpinner.stop(`Connected · ${componentCount} components found`);
56
+ }
57
+ catch (_error) {
58
+ connectSpinner.error('Could not connect to Storybook');
59
+ const err = ErrorMessages.connectionFailed(url);
60
+ error(err.title, err.details);
61
+ cancel('Connection failed');
62
+ process.exit(1);
63
+ }
64
+ // Step 4: Scrape components
65
+ const extractSpinner = createSpinner();
66
+ extractSpinner.start('Extracting components...');
67
+ // Create temp directory for output
68
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'loracle-'));
69
+ const outputDir = path.join(tempDir, 'output');
70
+ const componentsDir = path.join(outputDir, 'components');
71
+ await fs.mkdir(componentsDir, { recursive: true });
72
+ await fs.mkdir(path.join(outputDir, 'screenshots'), { recursive: true });
73
+ let scrapedComponents = [];
74
+ try {
75
+ const scraper = new StorybookScraper({
76
+ url,
77
+ outputDir,
78
+ headless: true,
79
+ verbose: false,
80
+ onProgress: (event) => {
81
+ switch (event.type) {
82
+ case 'component_start':
83
+ extractSpinner.message(`Extracting · ${event.name}`);
84
+ break;
85
+ case 'component_done':
86
+ extractSpinner.message(`Extracting · ${event.current}/${event.total}`);
87
+ break;
88
+ }
89
+ },
90
+ });
91
+ scrapedComponents = await scraper.scrape();
92
+ extractSpinner.stop(`Extracted ${scrapedComponents.length} components`);
93
+ }
94
+ catch (err) {
95
+ extractSpinner.error('Extraction failed');
96
+ error('Extraction failed', [err instanceof Error ? err.message : String(err)]);
97
+ cancel('Extraction failed');
98
+ await fs.rm(tempDir, { recursive: true, force: true });
99
+ process.exit(1);
100
+ }
101
+ // Step 5: Bundle and upload
102
+ const uploadSpinner = createSpinner();
103
+ uploadSpinner.start(options.dryRun ? 'Bundling components...' : 'Uploading to Loracle...');
104
+ try {
105
+ // Build bundle directly from scraped data
106
+ const bundle = { version, components: scrapedComponents };
107
+ const uploadedCount = bundle.components.length;
108
+ // Count variations
109
+ let variationCount = 0;
110
+ for (const component of bundle.components) {
111
+ variationCount += component.examples?.length || 0;
112
+ }
113
+ // Dry-run: skip upload, show summary
114
+ if (options.dryRun) {
115
+ uploadSpinner.stop('Dry run complete');
116
+ await fs.rm(tempDir, { recursive: true, force: true });
117
+ success('Dry run passed', [
118
+ `${uploadedCount} components · ${variationCount} variations`,
119
+ 'Ready to publish \u2014 remove --dry-run to upload.'
120
+ ]);
121
+ outro(startTime);
122
+ return;
123
+ }
124
+ // Initialize build
125
+ const { buildId, uploadUrl, projectId } = await initBuild(API_URL, apiKey, version, uploadedCount);
126
+ // Complete bundle with build metadata
127
+ const fullBundle = {
128
+ buildId,
129
+ projectId,
130
+ version,
131
+ components: bundle.components,
132
+ };
133
+ // Upload to S3
134
+ await uploadToS3(uploadUrl, fullBundle);
135
+ uploadSpinner.stop('Upload complete');
136
+ // Cleanup temp directory
137
+ await fs.rm(tempDir, { recursive: true, force: true });
138
+ // Show success
139
+ const trackingLink = link('Track progress', `https://getloracle.com/projects/${projectId}`);
140
+ success('Build submitted', [
141
+ `${uploadedCount} components · ${variationCount} variations`,
142
+ trackingLink
143
+ ]);
144
+ outro(startTime);
145
+ }
146
+ catch (err) {
147
+ uploadSpinner.error(options.dryRun ? 'Dry run failed' : 'Upload failed');
148
+ const errorMessage = err instanceof Error ? err.message : String(err);
149
+ if (errorMessage.includes('Insufficient credits')) {
150
+ error(ErrorMessages.insufficientCredits.title, ErrorMessages.insufficientCredits.details);
151
+ }
152
+ else if (errorMessage.includes('401') || errorMessage.includes('Unauthorized')) {
153
+ error(ErrorMessages.invalidApiKey.title, ErrorMessages.invalidApiKey.details);
154
+ }
155
+ else {
156
+ error(options.dryRun ? 'Dry run failed' : 'Upload failed', [errorMessage]);
157
+ }
158
+ cancel('Failed');
159
+ await fs.rm(tempDir, { recursive: true, force: true });
160
+ process.exit(1);
161
+ }
162
+ }
@@ -0,0 +1,688 @@
1
+ import { chromium } from 'playwright';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import pLimit from 'p-limit';
5
+ export class StorybookScraper {
6
+ constructor(options) {
7
+ this.browser = null;
8
+ this.context = null;
9
+ this.processedComponents = new Set();
10
+ this.cachedIndexData = null;
11
+ this.options = options;
12
+ this.concurrency = options.concurrency ?? 8;
13
+ }
14
+ emit(event) {
15
+ if (this.options.onProgress) {
16
+ // Skip debug/warn events unless verbose
17
+ if ((event.type === 'debug' || event.type === 'warn') && !this.options.verbose)
18
+ return;
19
+ this.options.onProgress(event);
20
+ }
21
+ }
22
+ async scrape() {
23
+ const results = [];
24
+ try {
25
+ await this.initialize();
26
+ this.emit({ type: 'debug', message: 'Discovering component documentation pages from index.json...' });
27
+ const storyInfos = await this.discoverStoryPages();
28
+ const isStoryMode = storyInfos.length > 0 && storyInfos[0].viewMode === 'story';
29
+ const uniqueComponents = new Set(storyInfos.map(s => s.kind)).size;
30
+ this.emit({ type: 'discovery', total: isStoryMode ? uniqueComponents : storyInfos.length });
31
+ if (isStoryMode) {
32
+ this.emit({ type: 'debug', message: `Found ${storyInfos.length} stories across ${uniqueComponents} components (story-only mode)` });
33
+ }
34
+ else {
35
+ this.emit({ type: 'debug', message: `Found ${storyInfos.length} potential pages to process` });
36
+ }
37
+ this.emit({ type: 'debug', message: `Running with ${this.concurrency} parallel workers` });
38
+ let processedCount = 0;
39
+ let successCount = 0;
40
+ const totalCount = storyInfos.length;
41
+ // Create a limiter for parallel processing
42
+ const limit = pLimit(this.concurrency);
43
+ if (isStoryMode) {
44
+ // Story-only mode: scrape each story individually, then group by component
45
+ const storyResults = new Map();
46
+ const tasks = storyInfos.map((storyInfo) => limit(async () => {
47
+ const page = await this.context.newPage();
48
+ page.setDefaultTimeout(15000);
49
+ try {
50
+ const example = await this.scrapeStoryPage(storyInfo, page);
51
+ if (example) {
52
+ // Group into component
53
+ const existing = storyResults.get(storyInfo.kind);
54
+ if (existing && existing.examples) {
55
+ existing.examples.push(example);
56
+ }
57
+ else {
58
+ storyResults.set(storyInfo.kind, {
59
+ name: storyInfo.kind.split('/').pop()?.trim() || storyInfo.kind,
60
+ componentName: storyInfo.kind.split('/').pop()?.trim() || storyInfo.kind,
61
+ examples: [example],
62
+ metadata: { storyId: storyInfo.storyId, framework: 'react', kind: storyInfo.kind },
63
+ });
64
+ }
65
+ successCount++;
66
+ }
67
+ }
68
+ catch (error) {
69
+ this.emit({ type: 'debug', message: `Failed to process ${storyInfo.storyId}: ${error}` });
70
+ }
71
+ finally {
72
+ await page.close();
73
+ }
74
+ processedCount++;
75
+ this.emit({ type: 'component_done', current: processedCount, total: totalCount, success: true });
76
+ }));
77
+ await Promise.all(tasks);
78
+ // Save grouped components
79
+ for (const componentData of storyResults.values()) {
80
+ await this.saveComponent(componentData);
81
+ results.push(componentData);
82
+ }
83
+ }
84
+ else {
85
+ // Docs mode: one page per component (original behavior)
86
+ const tasks = storyInfos.map((storyInfo) => limit(async () => {
87
+ this.emit({ type: 'component_start', name: storyInfo.kind });
88
+ const page = await this.context.newPage();
89
+ page.setDefaultTimeout(15000);
90
+ let success = false;
91
+ try {
92
+ const componentData = await this.scrapeComponentWithPage(storyInfo, page);
93
+ if (componentData) {
94
+ await this.saveComponent(componentData);
95
+ results.push(componentData);
96
+ successCount++;
97
+ success = true;
98
+ }
99
+ }
100
+ catch (error) {
101
+ this.emit({ type: 'debug', message: `Failed to process ${storyInfo.kind}: ${error}` });
102
+ }
103
+ finally {
104
+ await page.close();
105
+ }
106
+ processedCount++;
107
+ this.emit({ type: 'component_done', current: processedCount, total: totalCount, success });
108
+ }));
109
+ await Promise.all(tasks);
110
+ }
111
+ const savedComponents = isStoryMode ? new Set(storyInfos.map(s => s.kind)).size : successCount;
112
+ this.emit({ type: 'complete', successCount: savedComponents, totalCount: successCount });
113
+ }
114
+ finally {
115
+ await this.cleanup();
116
+ }
117
+ return results;
118
+ }
119
+ async initialize() {
120
+ this.emit({ type: 'browser_launch' });
121
+ this.browser = await chromium.launch({
122
+ headless: this.options.headless,
123
+ args: ['--no-sandbox', '--disable-dev-shm-usage']
124
+ });
125
+ this.context = await this.browser.newContext();
126
+ // Ensure output directories exist (once, not per-component)
127
+ const componentsDir = path.join(this.options.outputDir, 'components');
128
+ const screenshotsDir = path.join(this.options.outputDir, 'screenshots');
129
+ await fs.mkdir(componentsDir, { recursive: true });
130
+ await fs.mkdir(screenshotsDir, { recursive: true });
131
+ }
132
+ async fetchIndexJson() {
133
+ // Return cached data if available
134
+ if (this.cachedIndexData)
135
+ return this.cachedIndexData;
136
+ try {
137
+ const baseUrl = this.options.url.endsWith('/') ? this.options.url : this.options.url + '/';
138
+ const indexUrl = baseUrl + 'index.json';
139
+ this.emit({ type: 'debug', message: `Fetching index from: ${indexUrl}` });
140
+ const response = await fetch(indexUrl);
141
+ if (!response.ok) {
142
+ this.emit({ type: 'warn', message: `Failed to fetch index.json (${response.status}). Falling back to DOM navigation.` });
143
+ return null;
144
+ }
145
+ const data = await response.json();
146
+ if (!data.entries || typeof data.entries !== 'object') {
147
+ this.emit({ type: 'warn', message: 'Invalid index.json structure. Falling back to DOM navigation.' });
148
+ return null;
149
+ }
150
+ this.cachedIndexData = data;
151
+ return data;
152
+ }
153
+ catch (error) {
154
+ this.emit({ type: 'warn', message: `Error fetching index.json: ${error.message}. Falling back to DOM navigation.` });
155
+ return null;
156
+ }
157
+ }
158
+ async discoverStoryPagesFromIndex() {
159
+ const indexData = await this.fetchIndexJson();
160
+ if (!indexData) {
161
+ // Fallback to DOM navigation
162
+ return this.discoverStoryPagesFromDOM();
163
+ }
164
+ const storyInfos = [];
165
+ const baseUrl = this.options.url.endsWith('/') ? this.options.url : this.options.url + '/';
166
+ // Filter for docs pages with actual stories
167
+ for (const [id, entry] of Object.entries(indexData.entries)) {
168
+ if (entry.type === 'docs') {
169
+ // Skip pure documentation pages without associated stories
170
+ const hasNoStories = !entry.storiesImports || entry.storiesImports.length === 0;
171
+ const isUnattachedMdx = entry.tags?.includes('unattached-mdx');
172
+ if (hasNoStories && isUnattachedMdx) {
173
+ this.emit({ type: 'debug', message: `Skipping documentation page: ${entry.title}` });
174
+ continue;
175
+ }
176
+ // Navigate directly to iframe.html, bypassing the Storybook manager UI
177
+ const url = `${baseUrl}iframe.html?viewMode=docs&id=${id}`;
178
+ storyInfos.push({
179
+ url,
180
+ componentId: id,
181
+ kind: entry.title,
182
+ viewMode: 'docs',
183
+ });
184
+ }
185
+ }
186
+ this.emit({ type: 'debug', message: `Found ${storyInfos.length} docs pages from index.json` });
187
+ // Fallback: if no docs entries found, emit one StoryInfo per individual story
188
+ if (storyInfos.length === 0) {
189
+ this.emit({ type: 'debug', message: 'No docs entries found, falling back to story-only mode' });
190
+ for (const [id, entry] of Object.entries(indexData.entries)) {
191
+ if (entry.type === 'story') {
192
+ storyInfos.push({
193
+ url: `${baseUrl}iframe.html?viewMode=story&id=${id}`,
194
+ componentId: entry.title.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
195
+ kind: entry.title,
196
+ viewMode: 'story',
197
+ storyId: id,
198
+ storyName: entry.name,
199
+ });
200
+ }
201
+ }
202
+ this.emit({ type: 'debug', message: `Found ${storyInfos.length} stories across ${new Set(storyInfos.map(s => s.kind)).size} components` });
203
+ }
204
+ // Filter by kinds if specified
205
+ if (this.options.kinds && this.options.kinds.length > 0) {
206
+ return storyInfos.filter(story => this.options.kinds.some(kind => story.kind.includes(kind)));
207
+ }
208
+ return storyInfos;
209
+ }
210
+ async discoverStoryPages() {
211
+ // Try index.json first
212
+ return this.discoverStoryPagesFromIndex();
213
+ }
214
+ async discoverStoryPagesFromDOM() {
215
+ const page = await this.context.newPage();
216
+ try {
217
+ await page.goto(this.options.url);
218
+ // Wait for Storybook to load
219
+ await page.waitForSelector('nav', { timeout: 10000 });
220
+ this.emit({ type: 'debug', message: 'Expanding sidebar sections...' });
221
+ await this.expandAllSections(page);
222
+ this.emit({ type: 'debug', message: 'Collecting component links...' });
223
+ const storyInfos = await this.collectComponentLinks(page);
224
+ // Filter by kinds if specified
225
+ if (this.options.kinds && this.options.kinds.length > 0) {
226
+ return storyInfos.filter(story => this.options.kinds.some(kind => story.kind.includes(kind)));
227
+ }
228
+ return storyInfos;
229
+ }
230
+ finally {
231
+ await page.close();
232
+ }
233
+ }
234
+ async expandAllSections(page) {
235
+ // Iteratively expand sections until no more can be expanded
236
+ let previousCount = 0;
237
+ let currentCount = 0;
238
+ let attempts = 0;
239
+ const maxAttempts = 10; // Prevent infinite loops
240
+ do {
241
+ previousCount = currentCount;
242
+ // Find all currently collapsed buttons
243
+ const collapsedButtons = await page.locator('nav button[aria-expanded="false"]').all();
244
+ currentCount = collapsedButtons.length;
245
+ this.emit({ type: 'debug', message: `Found ${currentCount} collapsed sections to expand` });
246
+ // Try to expand each collapsed button
247
+ for (let i = 0; i < collapsedButtons.length; i++) {
248
+ try {
249
+ // Re-query the button to avoid stale element issues
250
+ const button = page.locator('nav button[aria-expanded="false"]').nth(i);
251
+ // Check if button is still available and visible
252
+ if (await button.count() > 0 && await button.isVisible()) {
253
+ await button.click({ timeout: 1000 });
254
+ await page.waitForTimeout(200); // Allow time for expansion
255
+ }
256
+ }
257
+ catch (error) {
258
+ // Silently continue - button might have disappeared or become unclickable
259
+ this.emit({ type: 'debug', message: `Could not expand button ${i}: ${error.message}` });
260
+ }
261
+ }
262
+ attempts++;
263
+ // Small delay before checking again
264
+ await page.waitForTimeout(300);
265
+ } while (attempts < maxAttempts && currentCount > 0 && currentCount !== previousCount);
266
+ this.emit({ type: 'debug', message: `Expansion completed after ${attempts} attempts` });
267
+ }
268
+ async collectComponentLinks(page) {
269
+ // Find all links in the navigation that point to docs pages
270
+ const links = await page.locator('nav a[href*="/docs/"]').all();
271
+ const storyInfos = [];
272
+ const baseUrl = this.options.url.endsWith('/') ? this.options.url : this.options.url + '/';
273
+ for (const link of links) {
274
+ try {
275
+ const href = await link.getAttribute('href');
276
+ const text = await link.textContent();
277
+ if (href && text && href.includes('--docs')) {
278
+ // Extract kind from the URL pattern
279
+ const kindMatch = href.match(/\/docs\/(.+)--docs/);
280
+ if (kindMatch) {
281
+ const componentId = kindMatch[1];
282
+ const kind = text.trim();
283
+ // Use direct iframe URL
284
+ storyInfos.push({
285
+ url: `${baseUrl}iframe.html?viewMode=docs&id=${componentId}--docs`,
286
+ componentId,
287
+ kind,
288
+ viewMode: 'docs',
289
+ });
290
+ }
291
+ }
292
+ }
293
+ catch (error) {
294
+ // Ignore errors for individual links
295
+ this.emit({ type: 'debug', message: `Could not process link: ${error}` });
296
+ }
297
+ }
298
+ this.emit({ type: 'debug', message: `Found ${storyInfos.length} component documentation links` });
299
+ return storyInfos;
300
+ }
301
+ async scrapeComponentWithPage(storyInfo, page) {
302
+ // Skip if already processed (thread-safe check)
303
+ if (this.processedComponents.has(storyInfo.componentId)) {
304
+ return null;
305
+ }
306
+ // Mark as processing to avoid duplicates in parallel execution
307
+ this.processedComponents.add(storyInfo.componentId);
308
+ // Navigate directly to iframe.html — no manager UI, no nested iframe
309
+ await page.goto(storyInfo.url, { waitUntil: 'load' });
310
+ try {
311
+ // Wait for Storybook preview to be ready with at least one story render
312
+ await page.waitForFunction(() => {
313
+ const preview = window.__STORYBOOK_PREVIEW__;
314
+ if (!preview?.currentSelection?.viewMode)
315
+ return false;
316
+ if (!preview.storyRenders)
317
+ return false;
318
+ const renders = Object.values(preview.storyRenders);
319
+ return renders.some((r) => r?.story);
320
+ }, { timeout: 15000, polling: 200 });
321
+ // Check if this is a docs page
322
+ const viewMode = await page.evaluate(() => {
323
+ return window.__STORYBOOK_PREVIEW__?.currentSelection.viewMode;
324
+ });
325
+ if (viewMode !== 'docs' && viewMode !== 'story') {
326
+ this.emit({ type: 'debug', message: `Skipping ${storyInfo.kind} - unexpected viewMode: ${viewMode}` });
327
+ return null;
328
+ }
329
+ this.emit({ type: 'debug', message: `Processing ${viewMode} page: ${storyInfo.kind}` });
330
+ // Extract component data directly from page (no iframe indirection)
331
+ const componentData = await this.extractComponentData(page, storyInfo);
332
+ return componentData;
333
+ }
334
+ catch (error) {
335
+ const errorMsg = error instanceof Error ? error.message : String(error);
336
+ this.emit({ type: 'debug', message: `Skipping ${storyInfo.kind}: ${errorMsg}` });
337
+ return null;
338
+ }
339
+ }
340
+ /**
341
+ * Story-only mode: navigate to a single story, extract data + screenshot from #storybook-root.
342
+ * No DOM lookups for story-- elements (they don't exist in story mode).
343
+ */
344
+ async scrapeStoryPage(storyInfo, page) {
345
+ await page.goto(storyInfo.url, { waitUntil: 'load' });
346
+ try {
347
+ await page.waitForFunction(() => {
348
+ const preview = window.__STORYBOOK_PREVIEW__;
349
+ if (!preview?.currentSelection?.viewMode)
350
+ return false;
351
+ if (!preview.storyRenders)
352
+ return false;
353
+ const renders = Object.values(preview.storyRenders);
354
+ return renders.some((r) => r?.story);
355
+ }, { timeout: 15000, polling: 200 });
356
+ }
357
+ catch {
358
+ this.emit({ type: 'debug', message: `Preview timeout for ${storyInfo.storyId}` });
359
+ return null;
360
+ }
361
+ // Extract story metadata from the preview API
362
+ const storyData = await page.evaluate(() => {
363
+ const preview = window.__STORYBOOK_PREVIEW__;
364
+ const storyRenders = preview?.storyRenders ? Object.values(preview.storyRenders) : [];
365
+ for (const render of storyRenders) {
366
+ const story = render?.story;
367
+ if (story) {
368
+ return {
369
+ name: story.name || 'Unknown',
370
+ code: story.parameters?.docs?.source?.code ||
371
+ story.parameters?.docs?.source?.originalSource ||
372
+ story.parameters?.storySource?.source || '',
373
+ description: story.parameters?.docs?.description?.story || '',
374
+ metadata: {
375
+ storyId: story.id,
376
+ framework: story.parameters?.framework || 'react',
377
+ kind: story.kind || story.title,
378
+ },
379
+ };
380
+ }
381
+ }
382
+ return null;
383
+ });
384
+ if (!storyData)
385
+ return null;
386
+ // Screenshot #storybook-root directly
387
+ let screenshot = '';
388
+ try {
389
+ const root = page.locator('#storybook-root').first();
390
+ const screenshotBuffer = await root.screenshot({ type: 'png', timeout: 5000 });
391
+ screenshot = screenshotBuffer.toString('base64');
392
+ // Save screenshot to file (non-blocking)
393
+ const screenshotPath = path.join(this.options.outputDir, 'screenshots', `${storyInfo.componentId}_${storyData.name.replace(/[^a-zA-Z0-9]/g, '_')}.png`);
394
+ fs.writeFile(screenshotPath, screenshotBuffer).catch(() => { });
395
+ }
396
+ catch {
397
+ this.emit({ type: 'warn', message: `Screenshot failed for ${storyInfo.storyId}` });
398
+ }
399
+ return {
400
+ name: storyData.name,
401
+ code: storyData.code,
402
+ description: storyData.description,
403
+ screenshot,
404
+ metadata: {
405
+ ...storyData.metadata,
406
+ storyId: storyInfo.storyId,
407
+ source: 'index.json',
408
+ },
409
+ };
410
+ }
411
+ async extractComponentData(page, storyInfo) {
412
+ // Execute extraction logic directly on the page (no iframe needed)
413
+ const extractedData = await page.evaluate(() => {
414
+ const preview = window.__STORYBOOK_PREVIEW__;
415
+ if (!preview) {
416
+ throw new Error('Storybook preview not available');
417
+ }
418
+ // Try to find current story from storyRenders or currentSelection
419
+ let currentStory;
420
+ // Get all story renders
421
+ const storyRenders = preview.storyRenders ? Object.values(preview.storyRenders) : [];
422
+ if (storyRenders.length > 0) {
423
+ // Use first story render that has a story object
424
+ for (const render of storyRenders) {
425
+ if (render?.story) {
426
+ currentStory = render.story;
427
+ break;
428
+ }
429
+ }
430
+ }
431
+ // If still no story, create minimal fallback from URL
432
+ if (!currentStory) {
433
+ const url = window.location.href;
434
+ const urlMatch = url.match(/id=([^&]+)/);
435
+ const componentId = urlMatch ? urlMatch[1] : 'unknown';
436
+ const titleMatch = componentId.match(/^(.+?)--/);
437
+ const title = titleMatch ? titleMatch[1].replace(/-/g, ' ') : componentId;
438
+ currentStory = {
439
+ id: componentId,
440
+ title: title,
441
+ name: 'Default',
442
+ parameters: {},
443
+ argTypes: {}
444
+ };
445
+ }
446
+ // Extract component-level data
447
+ const componentName = currentStory.component?.displayName ||
448
+ currentStory.component?.name ||
449
+ currentStory.title?.split('/').pop() ||
450
+ 'Unknown Component';
451
+ const description = currentStory.component?.__docgenInfo?.description ||
452
+ currentStory.parameters?.docs?.description?.component ||
453
+ '';
454
+ // Extract props from argTypes
455
+ const props = [];
456
+ if (currentStory.argTypes) {
457
+ for (const [name, argType] of Object.entries(currentStory.argTypes)) {
458
+ // Transform table to match PropData structure (summary must be defined if present)
459
+ const table = argType.table ? {
460
+ type: argType.table.type?.summary ? { summary: argType.table.type.summary } : undefined,
461
+ defaultValue: argType.table.defaultValue?.summary ? { summary: argType.table.defaultValue.summary } : undefined,
462
+ category: argType.table.category
463
+ } : undefined;
464
+ props.push({
465
+ name,
466
+ description: argType.description,
467
+ type: argType.table?.type?.summary ?? argType.type?.name,
468
+ required: argType.type?.required,
469
+ options: argType.options,
470
+ defaultValue: argType.table?.defaultValue?.summary ? {
471
+ value: argType.table.defaultValue.summary,
472
+ computed: false
473
+ } : undefined,
474
+ control: argType.control,
475
+ table
476
+ });
477
+ }
478
+ }
479
+ // Extract stories from storyRenders
480
+ const stories = [];
481
+ const allRenders = preview.storyRenders ? Object.values(preview.storyRenders) : [];
482
+ for (let i = 0; i < allRenders.length; i++) {
483
+ const render = allRenders[i];
484
+ if (render && render.story) {
485
+ const story = render.story;
486
+ // Get source code if available.
487
+ // Priority: `docs.source.code` (user overrides / transforms / dynamic render sources)
488
+ // falls back to `docs.source.originalSource` (CSF enrichment default)
489
+ // falls back to legacy `storySource.source` (addon-storysource).
490
+ let code = '';
491
+ if (story.parameters?.docs?.source?.code) {
492
+ code = story.parameters.docs.source.code;
493
+ }
494
+ else if (story.parameters?.docs?.source?.originalSource) {
495
+ code = story.parameters.docs.source.originalSource;
496
+ }
497
+ else if (story.parameters?.storySource?.source) {
498
+ code = story.parameters.storySource.source;
499
+ }
500
+ const storyData = {
501
+ name: story.name || `Story ${i + 1}`,
502
+ code: code,
503
+ description: story.parameters?.docs?.description?.story || '',
504
+ storyIndex: i, // We'll use this to match with screenshots
505
+ metadata: {
506
+ storyId: story.id,
507
+ framework: story.parameters?.framework || 'react',
508
+ kind: story.kind || story.title
509
+ }
510
+ };
511
+ stories.push(storyData);
512
+ }
513
+ }
514
+ return {
515
+ componentName,
516
+ description,
517
+ props,
518
+ stories,
519
+ metadata: {
520
+ storyId: currentStory.id,
521
+ framework: currentStory.parameters?.framework || 'react',
522
+ kind: currentStory.kind || currentStory.title
523
+ }
524
+ };
525
+ });
526
+ // INDEX-GUIDED APPROACH: Use cached index.json as source of truth
527
+ const indexData = await this.fetchIndexJson();
528
+ const validStoryIds = [];
529
+ if (indexData) {
530
+ for (const [id, entry] of Object.entries(indexData.entries)) {
531
+ if (entry.type === 'story' && entry.title === storyInfo.kind) {
532
+ validStoryIds.push(id);
533
+ }
534
+ }
535
+ }
536
+ // Fallback to DOM-first if index.json unavailable or no stories found
537
+ if (validStoryIds.length === 0) {
538
+ this.emit({ type: 'warn', message: 'No stories found in index.json, falling back to DOM-first approach' });
539
+ validStoryIds.push(...extractedData.stories.map(s => s.metadata?.storyId).filter(Boolean));
540
+ }
541
+ this.emit({ type: 'debug', message: `Screenshot strategy: Index-guided (${validStoryIds.length} stories from index.json)` });
542
+ // Capture screenshots by iterating over story IDs from index.json (source of truth)
543
+ const examples = [];
544
+ // Wait for first story element to ensure page is fully rendered before starting screenshots
545
+ if (validStoryIds.length > 0) {
546
+ const firstStoryId = validStoryIds[0];
547
+ await page.waitForSelector(`[id="story--${firstStoryId}"]`, { timeout: 8000 }).catch(() => {
548
+ this.emit({ type: 'warn', message: `First story element not found: ${firstStoryId}` });
549
+ });
550
+ }
551
+ for (let i = 0; i < validStoryIds.length; i++) {
552
+ const storyId = validStoryIds[i];
553
+ let screenshot = '';
554
+ let storyMetadata = null;
555
+ try {
556
+ // Direct page access — no iframe indirection needed
557
+ const storyElement = page.locator(`[id="story--${storyId}"]`).first();
558
+ try {
559
+ await storyElement.waitFor({ state: 'visible', timeout: 3000 });
560
+ }
561
+ catch {
562
+ // Element didn't become visible in time, continue anyway
563
+ }
564
+ // Check if story element exists in DOM
565
+ const storyElementCount = await storyElement.count().catch(() => 0);
566
+ if (storyElementCount === 0) {
567
+ this.emit({ type: 'warn', message: `Story from index.json not found in DOM: ${storyId}` });
568
+ continue;
569
+ }
570
+ // Get the .docs-story container (what we want to screenshot)
571
+ const docsStoryContainer = storyElement.locator('xpath=ancestor::*[contains(@class, "docs-story")]').first();
572
+ // Check if container exists
573
+ const containerCount = await docsStoryContainer.count().catch(() => 0);
574
+ const domElement = containerCount > 0 ? docsStoryContainer : storyElement;
575
+ if (containerCount === 0) {
576
+ this.emit({ type: 'warn', message: `No .docs-story container found for ${storyId}, using story element directly` });
577
+ }
578
+ // Get story metadata from API (exact match guaranteed since we're using index.json IDs)
579
+ storyMetadata = await page.evaluate((targetStoryId) => {
580
+ const preview = window.__STORYBOOK_PREVIEW__;
581
+ const storyRenders = preview?.storyRenders ? Object.values(preview.storyRenders) : [];
582
+ // Find the story render matching this ID
583
+ for (const render of storyRenders) {
584
+ if (render?.story?.id === targetStoryId) {
585
+ const story = render.story;
586
+ return {
587
+ name: story.name || 'Unknown',
588
+ code: story.parameters?.docs?.source?.code ||
589
+ story.parameters?.docs?.source?.originalSource ||
590
+ story.parameters?.storySource?.source || '',
591
+ description: story.parameters?.docs?.description?.story || '',
592
+ metadata: {
593
+ storyId: story.id,
594
+ framework: story.parameters?.framework || 'react',
595
+ kind: story.kind || story.title
596
+ }
597
+ };
598
+ }
599
+ }
600
+ // Fallback: return minimal metadata
601
+ return {
602
+ name: targetStoryId.split('--').pop() || 'Unknown',
603
+ code: '',
604
+ description: '',
605
+ metadata: {
606
+ storyId: targetStoryId,
607
+ framework: 'react',
608
+ kind: ''
609
+ }
610
+ };
611
+ }, storyId);
612
+ // Take screenshot
613
+ const isVisible = await domElement.isVisible().catch(() => false);
614
+ if (!isVisible) {
615
+ await domElement.scrollIntoViewIfNeeded({ timeout: 2000 }).catch(() => { });
616
+ }
617
+ const screenshotBuffer = await domElement.screenshot({
618
+ type: 'png',
619
+ omitBackground: false,
620
+ timeout: 5000
621
+ });
622
+ screenshot = screenshotBuffer.toString('base64');
623
+ // Save screenshot to file (non-blocking)
624
+ const screenshotPath = path.join(this.options.outputDir, 'screenshots', `${storyInfo.componentId}_${storyMetadata.name.replace(/[^a-zA-Z0-9]/g, '_')}.png`);
625
+ fs.writeFile(screenshotPath, screenshotBuffer).catch(() => { });
626
+ this.emit({ type: 'debug', message: `Screenshot captured for ${storyMetadata.name} (${storyId})` });
627
+ examples.push({
628
+ name: storyMetadata.name,
629
+ code: storyMetadata.code,
630
+ description: storyMetadata.description,
631
+ screenshot,
632
+ metadata: {
633
+ ...storyMetadata.metadata,
634
+ storyId: storyId,
635
+ source: 'index.json'
636
+ }
637
+ });
638
+ }
639
+ catch (screenshotError) {
640
+ const errorMsg = screenshotError.message;
641
+ if (errorMsg.includes('detached') || errorMsg.includes('Target closed')) {
642
+ this.emit({ type: 'debug', message: `Screenshot skipped for ${storyId}: ${errorMsg}` });
643
+ }
644
+ else {
645
+ this.emit({ type: 'warn', message: `Screenshot failed for ${storyId}: ${errorMsg}` });
646
+ }
647
+ // Still push the example with metadata even if screenshot fails
648
+ if (storyMetadata) {
649
+ examples.push({
650
+ name: storyMetadata.name,
651
+ code: storyMetadata.code,
652
+ description: storyMetadata.description,
653
+ screenshot: '',
654
+ metadata: storyMetadata.metadata
655
+ });
656
+ }
657
+ }
658
+ }
659
+ // Validate we captured the expected number of stories
660
+ if (examples.length === validStoryIds.length) {
661
+ this.emit({ type: 'debug', message: `Captured all ${examples.length} stories from index.json` });
662
+ }
663
+ else {
664
+ this.emit({ type: 'warn', message: `Count mismatch: Expected ${validStoryIds.length} stories, captured ${examples.length}` });
665
+ }
666
+ return {
667
+ name: extractedData.componentName,
668
+ componentName: extractedData.componentName,
669
+ description: extractedData.description,
670
+ props: extractedData.props,
671
+ examples,
672
+ metadata: extractedData.metadata
673
+ };
674
+ }
675
+ async saveComponent(componentData) {
676
+ // Directories are already created in initialize()
677
+ const componentsDir = path.join(this.options.outputDir, 'components');
678
+ const filename = `${componentData.componentName || componentData.name}.json`;
679
+ const filepath = path.join(componentsDir, filename);
680
+ await fs.writeFile(filepath, JSON.stringify(componentData, null, 2));
681
+ this.emit({ type: 'debug', message: `Saved component data to ${filepath}` });
682
+ }
683
+ async cleanup() {
684
+ if (this.browser) {
685
+ await this.browser.close();
686
+ }
687
+ }
688
+ }
package/dist/types.js ADDED
@@ -0,0 +1,30 @@
1
+ // Helper function to process prop types
2
+ export function processPropType(prop) {
3
+ if (prop.type?.name === "enum") {
4
+ return {
5
+ name: "enum",
6
+ value: prop.type.value.map((v) =>
7
+ // Handle both literal enum values and identifier references
8
+ v.value || v.name || String(v)),
9
+ required: !!prop.required,
10
+ };
11
+ }
12
+ return {
13
+ name: prop.type?.name || "any",
14
+ value: prop.type?.value,
15
+ required: !!prop.required,
16
+ defaultValue: prop.defaultValue?.value,
17
+ };
18
+ }
19
+ // Helper function to process component props into API documentation
20
+ export function processComponentAPI(props) {
21
+ return {
22
+ props: props.map((prop) => ({
23
+ name: prop.name,
24
+ description: prop.description,
25
+ type: processPropType(prop),
26
+ required: !!prop.required,
27
+ defaultValue: prop.defaultValue?.value,
28
+ })),
29
+ };
30
+ }
package/dist/ui.js ADDED
@@ -0,0 +1,68 @@
1
+ import * as clack from '@clack/prompts';
2
+ import terminalLink from 'terminal-link';
3
+ export function intro(command, detail) {
4
+ const title = detail ? `loracle ${command} · ${detail}` : `loracle ${command}`;
5
+ clack.intro(title);
6
+ }
7
+ export function outro(startTime) {
8
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
9
+ clack.outro(`Done in ${elapsed}s`);
10
+ }
11
+ export function cancel(message) {
12
+ clack.cancel(message);
13
+ }
14
+ export function step(message) {
15
+ clack.log.step(message);
16
+ }
17
+ export function error(title, details) {
18
+ const lines = details ? `${title}\n${details.join('\n')}` : title;
19
+ clack.log.error(lines);
20
+ }
21
+ export function success(title, details) {
22
+ const lines = details ? `${title}\n${details.join('\n')}` : title;
23
+ clack.log.success(lines);
24
+ }
25
+ export function warn(message) {
26
+ clack.log.warn(message);
27
+ }
28
+ export function createSpinner() {
29
+ return clack.spinner({ indicator: 'dots' });
30
+ }
31
+ export function link(text, url) {
32
+ return terminalLink(text, url, { fallback: (t, u) => `${t} (${u})` });
33
+ }
34
+ export const ErrorMessages = {
35
+ missingApiKey: {
36
+ title: 'Missing API key',
37
+ details: [
38
+ 'Set LORACLE_API_KEY environment variable.',
39
+ `Get your key at ${link('getloracle.com/settings/api-keys', 'https://getloracle.com/settings/api-keys')}`
40
+ ]
41
+ },
42
+ invalidApiKey: {
43
+ title: 'Invalid API key',
44
+ details: [
45
+ `Check your API key at ${link('getloracle.com/settings/api-keys', 'https://getloracle.com/settings/api-keys')}`
46
+ ]
47
+ },
48
+ invalidVersion: (version) => ({
49
+ title: `Invalid version "${version}"`,
50
+ details: [
51
+ 'Use semantic versioning (e.g., 1.0.0, 2.1.0-beta.1)'
52
+ ]
53
+ }),
54
+ connectionFailed: (url) => ({
55
+ title: 'Could not connect to Storybook',
56
+ details: [
57
+ 'Make sure:',
58
+ ` - Storybook is running at ${url}`,
59
+ ' - The URL is accessible from this machine'
60
+ ]
61
+ }),
62
+ insufficientCredits: {
63
+ title: 'Insufficient credits',
64
+ details: [
65
+ `Add credits at ${link('getloracle.com/billing', 'https://getloracle.com/billing')}`
66
+ ]
67
+ }
68
+ };
@@ -0,0 +1,92 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ /**
4
+ * Bundle all component JSON files into a single payload
5
+ */
6
+ export async function bundleComponents(componentsDir, version) {
7
+ const files = await fs.readdir(componentsDir);
8
+ const jsonFiles = files.filter(f => f.endsWith('.json'));
9
+ const components = [];
10
+ for (const file of jsonFiles) {
11
+ const filePath = path.join(componentsDir, file);
12
+ const content = await fs.readFile(filePath, 'utf-8');
13
+ const component = JSON.parse(content);
14
+ components.push(component);
15
+ }
16
+ return {
17
+ version,
18
+ components,
19
+ };
20
+ }
21
+ /**
22
+ * Initialize a build via the API
23
+ */
24
+ export async function initBuild(apiUrl, apiKey, version, componentCount) {
25
+ const response = await fetch(`${apiUrl}/trpc/builds.initBuild`, {
26
+ method: 'POST',
27
+ headers: {
28
+ 'Authorization': `Bearer ${apiKey}`,
29
+ 'Content-Type': 'application/json',
30
+ 'X-Loracle-Client': 'cli/0.1.0',
31
+ },
32
+ body: JSON.stringify({ version, componentCount }),
33
+ });
34
+ if (!response.ok) {
35
+ const text = await response.text();
36
+ // Check for 402-style insufficient credits error
37
+ try {
38
+ const errorData = JSON.parse(text);
39
+ if (errorData.error?.data?.cause?.code === 'INSUFFICIENT_CREDITS') {
40
+ const cause = errorData.error.data.cause;
41
+ throw new Error(`Insufficient credits: need ${cause.required}, have ${cause.available}. ` +
42
+ `Buy more at ${cause.billingUrl}`);
43
+ }
44
+ }
45
+ catch (e) {
46
+ if (e instanceof Error && e.message.includes('Insufficient credits'))
47
+ throw e;
48
+ }
49
+ throw new Error(`API request failed (${response.status}): ${text}`);
50
+ }
51
+ const data = await response.json();
52
+ if (data.error) {
53
+ throw new Error(`API error: ${data.error.message}`);
54
+ }
55
+ const result = data.result.data;
56
+ return result;
57
+ }
58
+ /**
59
+ * Upload bundle to S3 using presigned URL
60
+ */
61
+ export async function uploadToS3(uploadUrl, bundle) {
62
+ const response = await fetch(uploadUrl, {
63
+ method: 'PUT',
64
+ headers: {
65
+ 'Content-Type': 'application/json',
66
+ },
67
+ body: JSON.stringify(bundle),
68
+ });
69
+ if (!response.ok) {
70
+ const text = await response.text();
71
+ throw new Error(`S3 upload failed (${response.status}): ${text}`);
72
+ }
73
+ }
74
+ /**
75
+ * Main upload flow
76
+ */
77
+ export async function uploadComponents(options) {
78
+ const { componentsDir, version, apiUrl, apiKey } = options;
79
+ // 1. Bundle components first to get count
80
+ const partialBundle = await bundleComponents(componentsDir, version);
81
+ const componentCount = partialBundle.components.length;
82
+ // 2. Initialize build with component count (triggers credit check)
83
+ const { buildId, uploadUrl, projectId } = await initBuild(apiUrl, apiKey, version, componentCount);
84
+ // 3. Complete bundle with buildId and projectId
85
+ const bundle = {
86
+ buildId,
87
+ projectId,
88
+ ...partialBundle,
89
+ };
90
+ // 4. Upload to S3
91
+ await uploadToS3(uploadUrl, bundle);
92
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@loracle-js/cli",
3
+ "version": "1.0.2",
4
+ "description": "Loracle CLI - Publish your design system to Loracle",
5
+ "author": "Loracle <team@getloracle.com>",
6
+ "license": "Apache-2.0",
7
+ "homepage": "https://getloracle.com",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/getloracle/loracle.git",
11
+ "directory": "packages/storybook-scraper-v2"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/getloracle/loracle/issues"
15
+ },
16
+ "type": "module",
17
+ "bin": {
18
+ "loracle": "./dist/bin/loracle.js"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "README.md"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "start": "tsx src/bin/loracle.ts",
29
+ "build": "tsc"
30
+ },
31
+ "keywords": [
32
+ "storybook",
33
+ "loracle",
34
+ "design-system",
35
+ "llm",
36
+ "cli"
37
+ ],
38
+ "dependencies": {
39
+ "@clack/prompts": "^1.1.0",
40
+ "commander": "^12.0.0",
41
+ "dotenv": "^16.6.1",
42
+ "p-limit": "^7.1.1",
43
+ "playwright": "^1.47.0",
44
+ "terminal-link": "^5.0.0"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^22.13.10",
48
+ "tsx": "^4.19.3",
49
+ "typescript": "^5.8.2"
50
+ }
51
+ }