@scrymore/scry-deployer 0.3.1 ā 0.4.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/README.md +25 -0
- package/bin/cli.js +15 -23
- package/lib/init.js +40 -43
- package/lib/telemetry.js +140 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -835,6 +835,31 @@ Then update the `if` condition in the notification steps:
|
|
|
835
835
|
|
|
836
836
|
---
|
|
837
837
|
|
|
838
|
+
## š Error Reporting
|
|
839
|
+
|
|
840
|
+
When a deploy fails, this CLI reports the error to Scry so we can fix it. It runs on
|
|
841
|
+
your machine, so it is worth being precise about what leaves it.
|
|
842
|
+
|
|
843
|
+
**Sent:** the error and its stack trace, the CLI and Node versions, the platform, and
|
|
844
|
+
the project id, deploy version, branch and whether analysis was enabled.
|
|
845
|
+
|
|
846
|
+
**Not sent:** your API key, presigned upload URLs and their signatures, absolute file
|
|
847
|
+
paths, your hostname or username, your component names, and your source code. Stack
|
|
848
|
+
frames are reduced to file basenames, and anything resembling a credential is redacted
|
|
849
|
+
before the report is sent ā including the signed URLs that upload errors would
|
|
850
|
+
otherwise quote in full.
|
|
851
|
+
|
|
852
|
+
Nothing is reported on a successful run.
|
|
853
|
+
|
|
854
|
+
**To opt out**, set either variable:
|
|
855
|
+
|
|
856
|
+
```bash
|
|
857
|
+
export SCRY_TELEMETRY=0 # Scry-specific
|
|
858
|
+
export DO_NOT_TRACK=1 # respected across many CLI tools
|
|
859
|
+
```
|
|
860
|
+
|
|
861
|
+
Both are honoured everywhere, including CI.
|
|
862
|
+
|
|
838
863
|
## š§ Troubleshooting the Init Command
|
|
839
864
|
|
|
840
865
|
### Command fails with "Not a git repository"
|
package/bin/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const { initTelemetry, captureCliError, flushTelemetry } = require('../lib/telemetry.js');
|
|
4
4
|
const yargs = require('yargs/yargs');
|
|
5
5
|
const { hideBin } = require('yargs/helpers');
|
|
6
6
|
const fs = require('fs');
|
|
@@ -166,21 +166,12 @@ async function handleError(error, argv) {
|
|
|
166
166
|
const logger = createLogger(argv || {});
|
|
167
167
|
logger.error(`\nā Error: ${error.message}`);
|
|
168
168
|
|
|
169
|
-
//
|
|
170
|
-
Sentry
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
project: argv.project,
|
|
174
|
-
version: argv.version,
|
|
175
|
-
command: argv._ ? argv._[0] : 'unknown',
|
|
176
|
-
});
|
|
177
|
-
scope.setExtra('argv', argv);
|
|
178
|
-
}
|
|
179
|
-
Sentry.captureException(error);
|
|
180
|
-
});
|
|
181
|
-
|
|
169
|
+
// Report with an allowlisted subset of argv. Sending argv wholesale shipped
|
|
170
|
+
// the customer's --api-key to Sentry on every error.
|
|
171
|
+
captureCliError(error, argv);
|
|
172
|
+
|
|
182
173
|
// Ensure the event is sent before the process exits
|
|
183
|
-
await
|
|
174
|
+
await flushTelemetry(2000);
|
|
184
175
|
|
|
185
176
|
if (error instanceof ApiError) {
|
|
186
177
|
if (error.statusCode === 401) {
|
|
@@ -198,12 +189,9 @@ async function handleError(error, argv) {
|
|
|
198
189
|
}
|
|
199
190
|
|
|
200
191
|
async function main() {
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
tracesSampleRate: 1.0,
|
|
205
|
-
environment: process.env.NODE_ENV || 'production',
|
|
206
|
-
});
|
|
192
|
+
// Error reporting. Opt out with SCRY_TELEMETRY=0 or DO_NOT_TRACK=1.
|
|
193
|
+
// Configuration and scrubbing live in lib/telemetry.js.
|
|
194
|
+
initTelemetry();
|
|
207
195
|
|
|
208
196
|
try {
|
|
209
197
|
const args = await yargs(hideBin(process.argv))
|
|
@@ -421,9 +409,13 @@ async function main() {
|
|
|
421
409
|
alias: 'skipGhSetup'
|
|
422
410
|
})
|
|
423
411
|
.option('commit-api-key', {
|
|
424
|
-
describe: '
|
|
412
|
+
describe: 'Write the API key into the committed config file (not recommended)',
|
|
425
413
|
type: 'boolean',
|
|
426
|
-
default
|
|
414
|
+
// False by default. The description has always said "not
|
|
415
|
+
// recommended" while the default said otherwise, and the
|
|
416
|
+
// default won: every `init` wrote a customer's key into a
|
|
417
|
+
// file it then committed.
|
|
418
|
+
default: false,
|
|
427
419
|
alias: 'commitApiKey'
|
|
428
420
|
})
|
|
429
421
|
.option('verbose', {
|
package/lib/init.js
CHANGED
|
@@ -47,19 +47,27 @@ async function runInit(argv) {
|
|
|
47
47
|
// Step 3: Create config file
|
|
48
48
|
const step3Start = Date.now();
|
|
49
49
|
logger.info('3/8: Creating configuration file...');
|
|
50
|
-
createConfigFile(argv.project, argv.apiKey, argv.apiUrl, envInfo);
|
|
50
|
+
createConfigFile(argv.project, argv.apiKey, argv.apiUrl, envInfo, argv.commitApiKey);
|
|
51
51
|
const step3Duration = Date.now() - step3Start;
|
|
52
52
|
logger.success(`ā
Created .storybook-deployer.json [${step3Duration}ms]\n`);
|
|
53
53
|
|
|
54
|
-
// Step 4:
|
|
54
|
+
// Step 4: Report what the committed config does and does not contain.
|
|
55
|
+
//
|
|
56
|
+
// This step used to add `.storybook-deployer.json` to .gitignore, which was
|
|
57
|
+
// wrong in both directions. The config now holds no credential, so ignoring
|
|
58
|
+
// it would only stop a team sharing its project id ā and the entry it wrote,
|
|
59
|
+
// `.storybook-deployer.json # Contains API key`, never matched anything:
|
|
60
|
+
// in .gitignore a `#` is only a comment at the start of a line, so the
|
|
61
|
+
// pattern included the trailing text and git ignored no file.
|
|
55
62
|
const step4Start = Date.now();
|
|
56
|
-
if (
|
|
57
|
-
logger.info('4/8:
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
if (argv.commitApiKey) {
|
|
64
|
+
logger.info('4/8: Checking credentials...');
|
|
65
|
+
logger.error(`ā ļø --commit-api-key: your API key will be written to .storybook-deployer.json and committed.
|
|
66
|
+
Git history keeps it after rotation, and CI does not need it ā the key is
|
|
67
|
+
already stored as the SCRY_API_KEY repository secret. [${Date.now() - step4Start}ms]\n`);
|
|
61
68
|
} else {
|
|
62
|
-
logger.info('4/8:
|
|
69
|
+
logger.info('4/8: Checking credentials...');
|
|
70
|
+
logger.success(`ā
.storybook-deployer.json holds no credentials ā safe to commit [${Date.now() - step4Start}ms]\n`);
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
// Step 5: Generate workflow files
|
|
@@ -94,7 +102,7 @@ async function runInit(argv) {
|
|
|
94
102
|
// Step 7: Git commit
|
|
95
103
|
const step7Start = Date.now();
|
|
96
104
|
logger.info('7/8: Committing changes...');
|
|
97
|
-
const commitResult = gitCommit(
|
|
105
|
+
const commitResult = gitCommit(logger);
|
|
98
106
|
const step7Duration = Date.now() - step7Start;
|
|
99
107
|
if (commitResult.success) {
|
|
100
108
|
logger.success(`ā
Changes committed: ${commitResult.sha} [${step7Duration}ms]\n`);
|
|
@@ -236,7 +244,7 @@ function parseGitHubRemote(remote) {
|
|
|
236
244
|
/**
|
|
237
245
|
* Create the configuration file
|
|
238
246
|
*/
|
|
239
|
-
function createConfigFile(projectId, apiKey, apiUrl, envInfo) {
|
|
247
|
+
function createConfigFile(projectId, apiKey, apiUrl, envInfo, commitApiKey) {
|
|
240
248
|
const config = {
|
|
241
249
|
apiUrl: apiUrl,
|
|
242
250
|
project: projectId,
|
|
@@ -245,10 +253,19 @@ function createConfigFile(projectId, apiKey, apiUrl, envInfo) {
|
|
|
245
253
|
verbose: false
|
|
246
254
|
};
|
|
247
255
|
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
|
|
256
|
+
// The key is deliberately absent unless explicitly asked for.
|
|
257
|
+
//
|
|
258
|
+
// This file gets committed, and CI never reads the key from it ā `init` sets
|
|
259
|
+
// SCRY_API_KEY as a GitHub *secret* and the generated workflow reads
|
|
260
|
+
// ${{ secrets.SCRY_API_KEY }}. So a copy here buys nothing and lands
|
|
261
|
+
// somewhere git history makes permanent: rotating the key afterwards does
|
|
262
|
+
// not remove it, and on a public repository it is simply disclosed.
|
|
263
|
+
//
|
|
264
|
+
// Local runs read SCRY_API_KEY from the environment, which lib/config.js
|
|
265
|
+
// already resolves.
|
|
266
|
+
if (commitApiKey) {
|
|
267
|
+
config.apiKey = apiKey;
|
|
268
|
+
}
|
|
252
269
|
|
|
253
270
|
const configPath = '.storybook-deployer.json';
|
|
254
271
|
fs.writeFileSync(
|
|
@@ -258,28 +275,6 @@ function createConfigFile(projectId, apiKey, apiUrl, envInfo) {
|
|
|
258
275
|
);
|
|
259
276
|
}
|
|
260
277
|
|
|
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
278
|
/**
|
|
284
279
|
* Generate workflow files
|
|
285
280
|
*/
|
|
@@ -367,7 +362,7 @@ Or install GitHub CLI and run:
|
|
|
367
362
|
/**
|
|
368
363
|
* Commit the changes to git
|
|
369
364
|
*/
|
|
370
|
-
function gitCommit(
|
|
365
|
+
function gitCommit(logger) {
|
|
371
366
|
try {
|
|
372
367
|
// Check if there are changes to commit
|
|
373
368
|
const status = execSync('git status --porcelain', { encoding: 'utf8' });
|
|
@@ -382,10 +377,6 @@ function gitCommit(commitApiKey, logger) {
|
|
|
382
377
|
'.storybook-deployer.json'
|
|
383
378
|
];
|
|
384
379
|
|
|
385
|
-
if (!commitApiKey) {
|
|
386
|
-
filesToAdd.push('.gitignore');
|
|
387
|
-
}
|
|
388
|
-
|
|
389
380
|
for (const file of filesToAdd) {
|
|
390
381
|
if (fs.existsSync(file)) {
|
|
391
382
|
execSync(`git add "${file}"`, { stdio: 'pipe' });
|
|
@@ -449,7 +440,7 @@ function showSuccessMessage(projectId, envInfo, apiUrl, pushed) {
|
|
|
449
440
|
Your Storybook deployment is configured and ready to go.
|
|
450
441
|
|
|
451
442
|
š¦ What was set up:
|
|
452
|
-
ā
Configuration file (.storybook-deployer.json)
|
|
443
|
+
ā
Configuration file (.storybook-deployer.json ā no credentials, safe to commit)
|
|
453
444
|
ā
GitHub Actions workflows (.github/workflows/)
|
|
454
445
|
ā
Repository variables (SCRY_PROJECT_ID, SCRY_API_URL)
|
|
455
446
|
ā
Repository secret (SCRY_API_KEY)
|
|
@@ -461,6 +452,12 @@ ${!pushed ? `
|
|
|
461
452
|
git push
|
|
462
453
|
` : ''}
|
|
463
454
|
|
|
455
|
+
š Running a deploy locally:
|
|
456
|
+
CI reads the key from the SCRY_API_KEY secret. On your own machine, export it
|
|
457
|
+
rather than writing it into the config file, which is committed:
|
|
458
|
+
|
|
459
|
+
export SCRY_API_KEY=<your key>
|
|
460
|
+
|
|
464
461
|
š Deployment:
|
|
465
462
|
Your Storybook will deploy automatically on:
|
|
466
463
|
⢠Every push to ${envInfo.currentBranch || 'main'} branch
|
|
@@ -477,4 +474,4 @@ Happy deploying! āØ
|
|
|
477
474
|
`);
|
|
478
475
|
}
|
|
479
476
|
|
|
480
|
-
module.exports = { runInit };
|
|
477
|
+
module.exports = { runInit, createConfigFile };
|
package/lib/telemetry.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
const Sentry = require('@sentry/node');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Error reporting for a CLI that runs on other people's machines.
|
|
5
|
+
*
|
|
6
|
+
* This is not a server. Everything it sends left a customer's laptop or CI
|
|
7
|
+
* runner, so the default has to be "send the minimum that makes a crash
|
|
8
|
+
* diagnosable", not "send the context and sort it out later".
|
|
9
|
+
*
|
|
10
|
+
* Three things were being sent that should not have been:
|
|
11
|
+
*
|
|
12
|
+
* 1. `scope.setExtra('argv', argv)` shipped the whole parsed argv ā which
|
|
13
|
+
* contains `--api-key` under both `apiKey` and `api-key`. Every customer
|
|
14
|
+
* error carried their project credential to a third party.
|
|
15
|
+
* 2. Upload failures embed the presigned URL in the message, query string and
|
|
16
|
+
* all: `...storybook.zip?X-Amz-Signature=645e57...`. That signature is a
|
|
17
|
+
* time-limited write credential for the bucket.
|
|
18
|
+
* 3. Absolute paths (`/home/alice/work/app`) leak usernames and, often,
|
|
19
|
+
* unreleased product names.
|
|
20
|
+
*
|
|
21
|
+
* None of that is needed to know that an upload failed with EAI_AGAIN.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const DSN =
|
|
25
|
+
'https://c66ce229a1db2289f145eebd02436d9c@o4507889391828992.ingest.us.sentry.io/4510699330732032';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* argv fields safe to attach to an error.
|
|
29
|
+
*
|
|
30
|
+
* An allowlist, not a denylist: a new option should be invisible to telemetry
|
|
31
|
+
* until someone deliberately adds it here. The reverse ā remembering to exclude
|
|
32
|
+
* each new secret ā is the failure mode that put an API key in Sentry.
|
|
33
|
+
*/
|
|
34
|
+
const SAFE_ARGV_FIELDS = ['project', 'deployVersion', 'withAnalysis', 'verbose', 'branch'];
|
|
35
|
+
|
|
36
|
+
/** Anything that looks like a credential, wherever it appears in a string. */
|
|
37
|
+
const SECRET_PATTERNS = [
|
|
38
|
+
// Presigned URL query strings. Keep the path so the failing operation is
|
|
39
|
+
// still identifiable; drop the signature and everything with it.
|
|
40
|
+
[/(https?:\/\/[^\s?]+)\?[^\s]*/g, '$1?<redacted>'],
|
|
41
|
+
[/scry_proj_[A-Za-z0-9_\-]+/g, 'scry_proj_<redacted>'],
|
|
42
|
+
[/(X-Amz-Signature=)[^&\s]+/gi, '$1<redacted>'],
|
|
43
|
+
[/(Bearer\s+)[A-Za-z0-9._\-]+/gi, '$1<redacted>'],
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function scrub(value) {
|
|
47
|
+
if (typeof value !== 'string') return value;
|
|
48
|
+
return SECRET_PATTERNS.reduce((acc, [pattern, replacement]) => acc.replace(pattern, replacement), value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whether the user has asked not to be tracked.
|
|
53
|
+
*
|
|
54
|
+
* DO_NOT_TRACK is honoured as well as our own flag ā it is the cross-tool
|
|
55
|
+
* convention (consoledonottrack.com), and a developer who has set it globally
|
|
56
|
+
* should not have to discover a Scry-specific variable to be heard.
|
|
57
|
+
*/
|
|
58
|
+
function telemetryDisabled() {
|
|
59
|
+
const off = (v) => v === '1' || v === 'true' || v === 'yes';
|
|
60
|
+
return off(process.env.DO_NOT_TRACK) || process.env.SCRY_TELEMETRY === '0' ||
|
|
61
|
+
process.env.SCRY_TELEMETRY === 'false';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Only the fields on the allowlist, and scrubbed even then. */
|
|
65
|
+
function sanitizeArgv(argv) {
|
|
66
|
+
if (!argv) return {};
|
|
67
|
+
const safe = {};
|
|
68
|
+
for (const field of SAFE_ARGV_FIELDS) {
|
|
69
|
+
if (argv[field] !== undefined) safe[field] = scrub(argv[field]);
|
|
70
|
+
}
|
|
71
|
+
return safe;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function initTelemetry() {
|
|
75
|
+
if (telemetryDisabled()) return false;
|
|
76
|
+
|
|
77
|
+
Sentry.init({
|
|
78
|
+
dsn: DSN,
|
|
79
|
+
environment: process.env.NODE_ENV || 'production',
|
|
80
|
+
|
|
81
|
+
// Errors only. Traces from a CLI describe the customer's build pipeline,
|
|
82
|
+
// which is more than is needed to fix a crash.
|
|
83
|
+
tracesSampleRate: 0,
|
|
84
|
+
|
|
85
|
+
// No usernames, IPs, or machine hostnames.
|
|
86
|
+
sendDefaultPii: false,
|
|
87
|
+
serverName: false,
|
|
88
|
+
|
|
89
|
+
beforeSend(event) {
|
|
90
|
+
if (event.message) event.message = scrub(event.message);
|
|
91
|
+
|
|
92
|
+
for (const entry of event.exception?.values ?? []) {
|
|
93
|
+
if (entry.value) entry.value = scrub(entry.value);
|
|
94
|
+
// Stack frames carry absolute paths from the customer's disk. The
|
|
95
|
+
// filename is what makes a trace useful, so keep the basename only.
|
|
96
|
+
for (const frame of entry.stacktrace?.frames ?? []) {
|
|
97
|
+
if (frame.filename) frame.filename = frame.filename.replace(/^.*[\\/]/, '');
|
|
98
|
+
delete frame.abs_path;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Belt and braces: whatever else ends up in extra, scrub its strings.
|
|
103
|
+
if (event.extra) {
|
|
104
|
+
for (const [k, v] of Object.entries(event.extra)) event.extra[k] = scrub(v);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return event;
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Report an error with only the context that is safe to leave the machine. */
|
|
115
|
+
function captureCliError(error, argv) {
|
|
116
|
+
if (telemetryDisabled()) return;
|
|
117
|
+
|
|
118
|
+
Sentry.withScope((scope) => {
|
|
119
|
+
const safe = sanitizeArgv(argv);
|
|
120
|
+
if (safe.project) scope.setTag('project', safe.project);
|
|
121
|
+
if (argv && argv._) scope.setTag('command', argv._[0] || 'deploy');
|
|
122
|
+
scope.setExtra('options', safe);
|
|
123
|
+
Sentry.captureException(error);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function flushTelemetry(ms = 2000) {
|
|
128
|
+
if (telemetryDisabled()) return;
|
|
129
|
+
await Sentry.close(ms);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
initTelemetry,
|
|
134
|
+
captureCliError,
|
|
135
|
+
flushTelemetry,
|
|
136
|
+
telemetryDisabled,
|
|
137
|
+
sanitizeArgv,
|
|
138
|
+
scrub,
|
|
139
|
+
SAFE_ARGV_FIELDS,
|
|
140
|
+
};
|