@scrymore/scry-deployer 0.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/lib/init.js ADDED
@@ -0,0 +1,478 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { execSync } = require('child_process');
4
+ const { createLogger } = require('./logger');
5
+ const { getApiClient } = require('./apiClient');
6
+ const { generateMainWorkflow, generatePRWorkflow } = require('./templates');
7
+
8
+ /**
9
+ * Run the initialization wizard
10
+ */
11
+ async function runInit(argv) {
12
+ const logger = createLogger({ verbose: true });
13
+
14
+ logger.info('🚀 Scry Storybook Deployer - Setup Wizard\n');
15
+ logger.info('━'.repeat(50) + '\n');
16
+
17
+ const totalStartTime = Date.now();
18
+
19
+ try {
20
+ // Step 1: Validate credentials
21
+ const step1Start = Date.now();
22
+ logger.info('1/8: Validating credentials...');
23
+ await validateCredentials(argv.apiUrl, argv.apiKey, argv.project);
24
+ const step1Duration = Date.now() - step1Start;
25
+ logger.success(`✅ Credentials validated [${step1Duration}ms]\n`);
26
+
27
+ // Step 2: Check prerequisites
28
+ const step2Start = Date.now();
29
+ logger.info('2/8: Checking prerequisites...');
30
+ const envInfo = await checkEnvironment();
31
+ const step2Duration = Date.now() - step2Start;
32
+
33
+ if (!envInfo.isGit) {
34
+ throw new Error('Not a git repository. Please run "git init" first.');
35
+ }
36
+
37
+ if (!envInfo.githubRemote) {
38
+ logger.error('⚠️ Warning: No GitHub remote found. You\'ll need to add one before pushing.');
39
+ }
40
+
41
+ logger.success(`✅ Environment detected [${step2Duration}ms]:
42
+ • Git: ${envInfo.isGit ? '✓' : '✗'}
43
+ • GitHub: ${envInfo.githubRemote || 'Not configured'}
44
+ • Package Manager: ${envInfo.packageManager}
45
+ • Build Command: ${envInfo.storybookBuildCmd || 'build-storybook'}\n`);
46
+
47
+ // Step 3: Create config file
48
+ const step3Start = Date.now();
49
+ logger.info('3/8: Creating configuration file...');
50
+ createConfigFile(argv.project, argv.apiKey, argv.apiUrl, envInfo);
51
+ const step3Duration = Date.now() - step3Start;
52
+ logger.success(`✅ Created .storybook-deployer.json [${step3Duration}ms]\n`);
53
+
54
+ // Step 4: Add to gitignore (optional - keep API key out of git if user prefers)
55
+ const step4Start = Date.now();
56
+ if (!argv.commitApiKey) {
57
+ logger.info('4/8: Updating .gitignore...');
58
+ updateGitignore();
59
+ const step4Duration = Date.now() - step4Start;
60
+ logger.success(`✅ Updated .gitignore (API key will use env vars in CI) [${step4Duration}ms]\n`);
61
+ } else {
62
+ logger.info('4/8: Skipping .gitignore update (--commit-api-key flag set)\n');
63
+ }
64
+
65
+ // Step 5: Generate workflow files
66
+ const step5Start = Date.now();
67
+ logger.info('5/8: Generating GitHub Actions workflows...');
68
+ const workflowFiles = generateWorkflows(argv.project, argv.apiUrl, envInfo);
69
+ const step5Duration = Date.now() - step5Start;
70
+ logger.success(`✅ Created .github/workflows/deploy-storybook.yml [${step5Duration}ms]`);
71
+ logger.success('✅ Created .github/workflows/deploy-pr-preview.yml\n');
72
+
73
+ // Step 6: Setup GitHub variables (if gh CLI available)
74
+ const step6Start = Date.now();
75
+ if (!argv.skipGhSetup && isGhCliAvailable()) {
76
+ logger.info('6/8: Setting up GitHub repository variables...');
77
+ try {
78
+ await setupGitHubVariables(argv.project, argv.apiKey, argv.apiUrl, logger);
79
+ const step6Duration = Date.now() - step6Start;
80
+ logger.success(`✅ GitHub variables configured [${step6Duration}ms]\n`);
81
+ } catch (error) {
82
+ const step6Duration = Date.now() - step6Start;
83
+ logger.error(`⚠️ GitHub setup failed: ${error.message} [${step6Duration}ms]`);
84
+ logger.info('You can set these up manually later.\n');
85
+ showManualSetupInstructions(argv.project, argv.apiKey, argv.apiUrl);
86
+ }
87
+ } else {
88
+ logger.info('6/8: Skipping GitHub CLI setup\n');
89
+ if (!argv.skipGhSetup) {
90
+ showManualSetupInstructions(argv.project, argv.apiKey, argv.apiUrl);
91
+ }
92
+ }
93
+
94
+ // Step 7: Git commit
95
+ const step7Start = Date.now();
96
+ logger.info('7/8: Committing changes...');
97
+ const commitResult = gitCommit(argv.commitApiKey, logger);
98
+ const step7Duration = Date.now() - step7Start;
99
+ if (commitResult.success) {
100
+ logger.success(`✅ Changes committed: ${commitResult.sha} [${step7Duration}ms]\n`);
101
+ } else {
102
+ logger.error(`⚠️ Could not commit automatically. Please commit manually. [${step7Duration}ms]\n`);
103
+ }
104
+
105
+ // Step 8: Git push
106
+ const step8Start = Date.now();
107
+ logger.info('8/8: Pushing to GitHub...');
108
+ const pushResult = gitPush(logger);
109
+ const step8Duration = Date.now() - step8Start;
110
+ if (pushResult.success) {
111
+ logger.success(`✅ Pushed to ${pushResult.branch} [${step8Duration}ms]\n`);
112
+ } else {
113
+ logger.error(`⚠️ Could not push automatically. Please push manually with:\n git push [${step8Duration}ms]\n`);
114
+ }
115
+
116
+ // Calculate total time
117
+ const totalDuration = Date.now() - totalStartTime;
118
+ logger.info('━'.repeat(50));
119
+ logger.info(`[TIMING] Total setup time: ${totalDuration}ms (${(totalDuration / 1000).toFixed(2)}s)\n`);
120
+
121
+ showSuccessMessage(argv.project, envInfo, argv.apiUrl, pushResult.success);
122
+
123
+ } catch (error) {
124
+ logger.error(`\n❌ Setup failed: ${error.message}`);
125
+ if (error.response) {
126
+ logger.error(`API Error: ${error.response.status} - ${error.response.data || error.response.statusText}`);
127
+ }
128
+ if (error.stack && argv.verbose) {
129
+ logger.debug(error.stack);
130
+ }
131
+ process.exit(1);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Validate that the API credentials work
137
+ */
138
+ async function validateCredentials(apiUrl, apiKey, projectId) {
139
+ // For now, just verify the parameters are provided
140
+ // In the future, we could ping a /validate endpoint
141
+ if (!projectId || !apiKey) {
142
+ throw new Error('Both --project-id and --api-key are required');
143
+ }
144
+
145
+ // Basic format validation
146
+ if (projectId.length < 3) {
147
+ throw new Error('Project ID seems too short. Please check your credentials.');
148
+ }
149
+
150
+ // Could add API validation call here if endpoint exists
151
+ // const apiClient = getApiClient(apiUrl, apiKey);
152
+ // await apiClient.get('/validate');
153
+ }
154
+
155
+ /**
156
+ * Check the local environment and detect settings
157
+ */
158
+ async function checkEnvironment() {
159
+ const envInfo = {
160
+ isGit: false,
161
+ githubRemote: null,
162
+ githubRepo: null,
163
+ packageManager: 'npm',
164
+ storybookBuildCmd: null,
165
+ currentBranch: null
166
+ };
167
+
168
+ // Check if git repo
169
+ try {
170
+ execSync('git rev-parse --git-dir', { stdio: 'ignore' });
171
+ envInfo.isGit = true;
172
+
173
+ // Get current branch
174
+ try {
175
+ envInfo.currentBranch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();
176
+ } catch (e) {
177
+ // Ignore
178
+ }
179
+
180
+ // Get GitHub remote
181
+ try {
182
+ const remote = execSync('git remote get-url origin', { encoding: 'utf8' }).trim();
183
+ envInfo.githubRemote = remote;
184
+ envInfo.githubRepo = parseGitHubRemote(remote);
185
+ } catch (e) {
186
+ // No remote configured
187
+ }
188
+ } catch (e) {
189
+ // Not a git repo
190
+ }
191
+
192
+ // Detect package manager
193
+ if (fs.existsSync('pnpm-lock.yaml')) {
194
+ envInfo.packageManager = 'pnpm';
195
+ } else if (fs.existsSync('yarn.lock')) {
196
+ envInfo.packageManager = 'yarn';
197
+ } else if (fs.existsSync('bun.lockb')) {
198
+ envInfo.packageManager = 'bun';
199
+ }
200
+
201
+ // Detect Storybook build command from package.json
202
+ try {
203
+ const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
204
+ if (pkg.scripts) {
205
+ if (pkg.scripts['build-storybook']) {
206
+ envInfo.storybookBuildCmd = 'build-storybook';
207
+ } else if (pkg.scripts['storybook:build']) {
208
+ envInfo.storybookBuildCmd = 'storybook:build';
209
+ } else if (pkg.scripts['build:storybook']) {
210
+ envInfo.storybookBuildCmd = 'build:storybook';
211
+ }
212
+ }
213
+ } catch (e) {
214
+ // No package.json or can't read
215
+ }
216
+
217
+ return envInfo;
218
+ }
219
+
220
+ /**
221
+ * Parse GitHub remote URL to extract owner/repo
222
+ */
223
+ function parseGitHubRemote(remote) {
224
+ // Handle both HTTPS and SSH formats
225
+ // HTTPS: https://github.com/owner/repo.git
226
+ // SSH: git@github.com:owner/repo.git
227
+
228
+ const httpsMatch = remote.match(/github\.com[/:]([\w-]+)\/([\w-]+)/);
229
+ if (httpsMatch) {
230
+ return `${httpsMatch[1]}/${httpsMatch[2].replace('.git', '')}`;
231
+ }
232
+
233
+ return null;
234
+ }
235
+
236
+ /**
237
+ * Create the configuration file
238
+ */
239
+ function createConfigFile(projectId, apiKey, apiUrl, envInfo) {
240
+ const config = {
241
+ apiUrl: apiUrl,
242
+ project: projectId,
243
+ dir: "./storybook-static",
244
+ version: "latest",
245
+ verbose: false
246
+ };
247
+
248
+ // Only include apiKey in config if user wants it committed
249
+ // Otherwise it should be set via environment variable
250
+ // For now, we'll include it but note in .gitignore
251
+ config.apiKey = apiKey;
252
+
253
+ const configPath = '.storybook-deployer.json';
254
+ fs.writeFileSync(
255
+ configPath,
256
+ JSON.stringify(config, null, 2) + '\n',
257
+ 'utf8'
258
+ );
259
+ }
260
+
261
+ /**
262
+ * Update .gitignore to exclude sensitive files (optional)
263
+ */
264
+ function updateGitignore() {
265
+ const gitignorePath = '.gitignore';
266
+ const entries = [
267
+ '# Scry Storybook Deployer',
268
+ '.storybook-deployer.json # Contains API key'
269
+ ];
270
+
271
+ let gitignoreContent = '';
272
+ if (fs.existsSync(gitignorePath)) {
273
+ gitignoreContent = fs.readFileSync(gitignorePath, 'utf8');
274
+ }
275
+
276
+ // Check if already added
277
+ if (!gitignoreContent.includes('.storybook-deployer.json')) {
278
+ gitignoreContent += '\n' + entries.join('\n') + '\n';
279
+ fs.writeFileSync(gitignorePath, gitignoreContent, 'utf8');
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Generate workflow files
285
+ */
286
+ function generateWorkflows(projectId, apiUrl, envInfo) {
287
+ // Create .github/workflows directory
288
+ const workflowsDir = '.github/workflows';
289
+ fs.mkdirSync(workflowsDir, { recursive: true });
290
+
291
+ const buildCmd = envInfo.storybookBuildCmd || 'build-storybook';
292
+
293
+ // Generate main deployment workflow
294
+ const mainWorkflow = generateMainWorkflow(projectId, apiUrl, envInfo.packageManager, buildCmd);
295
+ const mainWorkflowPath = path.join(workflowsDir, 'deploy-storybook.yml');
296
+ fs.writeFileSync(mainWorkflowPath, mainWorkflow, 'utf8');
297
+
298
+ // Generate PR preview workflow
299
+ const prWorkflow = generatePRWorkflow(projectId, apiUrl, envInfo.packageManager, buildCmd);
300
+ const prWorkflowPath = path.join(workflowsDir, 'deploy-pr-preview.yml');
301
+ fs.writeFileSync(prWorkflowPath, prWorkflow, 'utf8');
302
+
303
+ return [mainWorkflowPath, prWorkflowPath];
304
+ }
305
+
306
+ /**
307
+ * Check if GitHub CLI is available
308
+ */
309
+ function isGhCliAvailable() {
310
+ try {
311
+ execSync('gh --version', { stdio: 'ignore' });
312
+ return true;
313
+ } catch (e) {
314
+ return false;
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Setup GitHub variables using gh CLI
320
+ */
321
+ async function setupGitHubVariables(projectId, apiKey, apiUrl, logger) {
322
+ try {
323
+ // Set variables
324
+ execSync(`gh variable set SCRY_PROJECT_ID --body "${projectId}"`, { stdio: 'pipe' });
325
+ logger.debug(' ✓ Set SCRY_PROJECT_ID');
326
+
327
+ execSync(`gh variable set SCRY_API_URL --body "${apiUrl}"`, { stdio: 'pipe' });
328
+ logger.debug(' ✓ Set SCRY_API_URL');
329
+
330
+ // Set secret (API key)
331
+ execSync(`gh secret set SCRY_API_KEY --body "${apiKey}"`, { stdio: 'pipe' });
332
+ logger.debug(' ✓ Set SCRY_API_KEY');
333
+
334
+ } catch (error) {
335
+ throw new Error(`Failed to set GitHub variables: ${error.message}`);
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Show manual setup instructions
341
+ */
342
+ function showManualSetupInstructions(projectId, apiKey, apiUrl) {
343
+ console.log(`
344
+ 📋 Manual GitHub Setup (Optional):
345
+
346
+ If you haven't already, set up these repository variables:
347
+
348
+ 1. Go to your GitHub repository Settings
349
+ 2. Navigate to: Settings → Secrets and variables → Actions
350
+
351
+ 3. Add these Variables (Variables tab):
352
+ • SCRY_PROJECT_ID = ${projectId}
353
+ • SCRY_API_URL = ${apiUrl}
354
+
355
+ 4. Add this Secret (Secrets tab):
356
+ • SCRY_API_KEY = ${apiKey}
357
+
358
+ Or install GitHub CLI and run:
359
+ gh variable set SCRY_PROJECT_ID --body "${projectId}"
360
+ gh variable set SCRY_API_URL --body "${apiUrl}"
361
+ gh secret set SCRY_API_KEY --body "${apiKey}"
362
+ `);
363
+ }
364
+
365
+ /**
366
+ * Commit the changes to git
367
+ */
368
+ function gitCommit(commitApiKey, logger) {
369
+ try {
370
+ // Check if there are changes to commit
371
+ const status = execSync('git status --porcelain', { encoding: 'utf8' });
372
+ if (!status.trim()) {
373
+ return { success: false, message: 'No changes to commit' };
374
+ }
375
+
376
+ // Add files
377
+ const filesToAdd = [
378
+ '.github/workflows/deploy-storybook.yml',
379
+ '.github/workflows/deploy-pr-preview.yml',
380
+ '.storybook-deployer.json'
381
+ ];
382
+
383
+ if (!commitApiKey) {
384
+ filesToAdd.push('.gitignore');
385
+ }
386
+
387
+ for (const file of filesToAdd) {
388
+ if (fs.existsSync(file)) {
389
+ execSync(`git add "${file}"`, { stdio: 'pipe' });
390
+ logger.debug(` ✓ Added ${file}`);
391
+ }
392
+ }
393
+
394
+ // Commit
395
+ const commitMessage = 'chore: add Scry Storybook deployment workflows';
396
+ execSync(`git commit -m "${commitMessage}"`, { stdio: 'pipe' });
397
+
398
+ // Get commit SHA
399
+ const sha = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
400
+
401
+ return { success: true, sha };
402
+ } catch (error) {
403
+ return { success: false, error: error.message };
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Push changes to remote
409
+ */
410
+ function gitPush(logger) {
411
+ try {
412
+ // Get current branch
413
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();
414
+
415
+ // Check if remote is configured
416
+ try {
417
+ execSync('git remote get-url origin', { stdio: 'ignore' });
418
+ } catch (e) {
419
+ return { success: false, error: 'No remote configured', branch };
420
+ }
421
+
422
+ // Push
423
+ execSync(`git push -u origin ${branch}`, { stdio: 'pipe' });
424
+
425
+ return { success: true, branch };
426
+ } catch (error) {
427
+ // Check if it's an authentication error
428
+ if (error.message.includes('Authentication') || error.message.includes('permission')) {
429
+ return {
430
+ success: false,
431
+ error: 'Authentication failed. Please check your git credentials.',
432
+ branch: null
433
+ };
434
+ }
435
+
436
+ return { success: false, error: error.message, branch: null };
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Show success message
442
+ */
443
+ function showSuccessMessage(projectId, envInfo, apiUrl, pushed) {
444
+ console.log(`
445
+ 🎉 ${pushed ? 'Setup Complete and Deployed!' : 'Setup Complete!'}
446
+
447
+ Your Storybook deployment is configured and ready to go.
448
+
449
+ 📦 What was set up:
450
+ ✅ Configuration file (.storybook-deployer.json)
451
+ ✅ GitHub Actions workflows (.github/workflows/)
452
+ ✅ Repository variables (SCRY_PROJECT_ID, SCRY_API_URL)
453
+ ✅ Repository secret (SCRY_API_KEY)
454
+ ${pushed ? '✅ Changes committed and pushed' : '⚠️ Manual push required'}
455
+
456
+ ${!pushed ? `
457
+ 📌 Next Step:
458
+ Push your changes to GitHub:
459
+ git push
460
+ ` : ''}
461
+
462
+ 🚀 Deployment:
463
+ Your Storybook will deploy automatically on:
464
+ • Every push to ${envInfo.currentBranch || 'main'} branch
465
+ • Every pull request (as a preview)
466
+
467
+ 🌐 Deployment URLs:
468
+ • Production: ${apiUrl}/${projectId}/latest
469
+ • PR Previews: ${apiUrl}/${projectId}/pr-{number}
470
+
471
+ 📖 Learn more: https://github.com/epinnock/scry-node
472
+ 💬 Need help? Open an issue on GitHub
473
+
474
+ Happy deploying! ✨
475
+ `);
476
+ }
477
+
478
+ module.exports = { runInit };
package/lib/logger.js ADDED
@@ -0,0 +1,48 @@
1
+ const chalk = require('chalk');
2
+
3
+ /**
4
+ * Creates a logger instance.
5
+ * The logger's behavior is controlled by the arguments passed to the CLI.
6
+ * @param {object} argv The arguments object from yargs.
7
+ * @param {boolean} argv.verbose Whether to enable verbose (debug) logging.
8
+ * @returns {{info: Function, error: Function, debug: Function, success: Function}}
9
+ */
10
+ function createLogger({ verbose = false }) {
11
+ return {
12
+ /**
13
+ * Logs an informational message.
14
+ * @param {string} message The message to log.
15
+ */
16
+ info: (message) => {
17
+ console.log(message);
18
+ },
19
+
20
+ /**
21
+ * Logs a success message, typically at the end of a process.
22
+ * @param {string} message The message to log.
23
+ */
24
+ success: (message) => {
25
+ console.log(chalk.green(message));
26
+ },
27
+
28
+ /**
29
+ * Logs an error message.
30
+ * @param {string} message The message to log.
31
+ */
32
+ error: (message) => {
33
+ console.error(chalk.red(message));
34
+ },
35
+
36
+ /**
37
+ * Logs a debug message. Only logs if verbose mode is enabled.
38
+ * @param {string} message The message to log.
39
+ */
40
+ debug: (message) => {
41
+ if (verbose) {
42
+ console.log(chalk.dim(`[debug] ${message}`));
43
+ }
44
+ },
45
+ };
46
+ }
47
+
48
+ module.exports = { createLogger };
@@ -0,0 +1,55 @@
1
+ const { execSync } = require('child_process');
2
+
3
+ /**
4
+ * Captures screenshots from a Storybook URL using storycap
5
+ * @param {string} storybookUrl - URL of the deployed Storybook
6
+ * @param {Object} options - Storycap options
7
+ * @param {string} options.chromiumPath - Path to Chromium executable (optional)
8
+ * @param {string} options.outDir - Output directory for screenshots (default: ./__screenshots__)
9
+ * @param {number} options.parallel - Number of parallel browser instances (optional)
10
+ * @param {number} options.delay - Delay between screenshots in ms (optional)
11
+ * @param {string} options.include - Include stories matching pattern (optional)
12
+ * @param {string} options.exclude - Exclude stories matching pattern (optional)
13
+ * @param {boolean} options.omitBackground - Omit background (default: true)
14
+ * @returns {Promise<void>}
15
+ */
16
+ async function captureScreenshots(storybookUrl, options = {}) {
17
+ // Build storycap command
18
+ let command = `npx storycap "${storybookUrl}"`;
19
+
20
+ if (options.chromiumPath) {
21
+ command += ` --chromiumPath "${options.chromiumPath}"`;
22
+ }
23
+
24
+ if (options.omitBackground !== false) {
25
+ command += ` --omitBackground true`;
26
+ }
27
+
28
+ if (options.outDir) {
29
+ command += ` --outDir "${options.outDir}"`;
30
+ }
31
+
32
+ if (options.parallel) {
33
+ command += ` --parallel ${options.parallel}`;
34
+ }
35
+
36
+ if (options.delay) {
37
+ command += ` --delay ${options.delay}`;
38
+ }
39
+
40
+ if (options.include) {
41
+ command += ` --include "${options.include}"`;
42
+ }
43
+
44
+ if (options.exclude) {
45
+ command += ` --exclude "${options.exclude}"`;
46
+ }
47
+
48
+ try {
49
+ execSync(command, { stdio: 'inherit' });
50
+ } catch (error) {
51
+ throw new Error(`Failed to capture screenshots: ${error.message}`);
52
+ }
53
+ }
54
+
55
+ module.exports = { captureScreenshots };