actions-warden 0.3.0 → 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/src/action.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { appendFile, realpath } from 'node:fs/promises';
4
- import { basename, dirname, join, resolve } from 'node:path';
3
+ import { appendFile } from 'node:fs/promises';
4
+ import { resolve } from 'node:path';
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import { audit, renderAudit } from './commands/audit.js';
7
7
  import { pin, renderPin } from './commands/pin.js';
@@ -15,6 +15,7 @@ import { redact } from './lib/redact.js';
15
15
  import { writeFileGuarded } from './lib/writer.js';
16
16
  import { shouldFailAction } from './lib/action-status.js';
17
17
  import { formatOrganizationProgress } from './lib/org-progress.js';
18
+ import { sameFilePath } from './lib/path-equality.js';
18
19
  import {
19
20
  collectAnnotations,
20
21
  limitAnnotations,
@@ -119,7 +120,7 @@ async function main() {
119
120
  if (
120
121
  (resumeFrom ?? checkpointPath)
121
122
  && input('output-path')
122
- && await samePath(
123
+ && await sameFilePath(
123
124
  resolve(cwd, resumeFrom ?? checkpointPath),
124
125
  resolve(cwd, input('output-path')),
125
126
  )
@@ -254,18 +255,6 @@ function optionalPositiveInteger(value, name) {
254
255
  return value ? positiveInteger(value, name) : undefined;
255
256
  }
256
257
 
257
- async function samePath(left, right) {
258
- const [leftParent, rightParent] = await Promise.all([
259
- realpath(dirname(left)),
260
- realpath(dirname(right)),
261
- ]);
262
- const leftTarget = join(leftParent, basename(left));
263
- const rightTarget = join(rightParent, basename(right));
264
- return process.platform === 'win32'
265
- ? leftTarget.toLowerCase() === rightTarget.toLowerCase()
266
- : leftTarget === rightTarget;
267
- }
268
-
269
258
  async function setOutput(name, value) {
270
259
  if (!process.env.GITHUB_OUTPUT) return;
271
260
  const delimiter = `actions_warden_${randomUUID().replaceAll('-', '')}`;
package/src/cli.js CHANGED
@@ -3,20 +3,20 @@
3
3
  * actions-warden CLI entry point.
4
4
  *
5
5
  * Commands: audit, pin, upgrade, verify, report, org-scan, rules
6
- * Global flags: --format, --output, --output-path, --workflow, --token
6
+ * Common command flags include --format, --output, --output-path, and --workflow.
7
7
  *
8
8
  * Mutating operations (pin, upgrade) only write when --write is present.
9
9
  *
10
10
  * Exit codes:
11
- * 0 no findings / no errors
12
- * 1 findings reported (audit FAIL) or errors during pin/upgrade
13
- * 2 invalid arguments
11
+ * 0 completed with status OK (or displayed help/version)
12
+ * 1 completed with a structured FAIL result
13
+ * 2 invocation-level failure
14
14
  */
15
15
 
16
- import { realpath } from 'node:fs/promises';
17
- import { basename, dirname, join, resolve } from 'node:path';
16
+ import { stat } from 'node:fs/promises';
17
+ import { relative, resolve, sep } from 'node:path';
18
18
  import { createRequire } from 'node:module';
19
- import { Command, Option } from 'commander';
19
+ import { Command, CommanderError, InvalidArgumentError, Option } from 'commander';
20
20
 
21
21
  import { audit, renderAudit } from './commands/audit.js';
22
22
  import { pin, renderPin } from './commands/pin.js';
@@ -29,6 +29,8 @@ import { format as fmt } from './lib/formatter.js';
29
29
  import { writeFileGuarded } from './lib/writer.js';
30
30
  import { serializeBaseline } from './lib/baseline.js';
31
31
  import { formatOrganizationProgress } from './lib/org-progress.js';
32
+ import { resolveTargets } from './lib/targets.js';
33
+ import { sameFilePath } from './lib/path-equality.js';
32
34
  import {
33
35
  AGENT_MODE_ENVIRONMENT_VARIABLE,
34
36
  createOrganizationAgentArtifacts,
@@ -39,22 +41,56 @@ import {
39
41
  const program = new Command();
40
42
  const require = createRequire(import.meta.url);
41
43
  const { version } = require('../package.json');
44
+ const FIX_ID_RE = /^[0-9a-f]{16}$/i;
45
+ const DEFAULT_CONFIG_PATHS = ['.actions-warden.yml', '.actions-warden.yaml'];
42
46
  program
43
47
  .name('actions-warden')
44
48
  .description('Audit GitHub Actions across repositories and organizations; pin, verify, and upgrade dependencies.')
45
- .version(version);
46
-
47
- const formatOption = new Option('--format <fmt>', 'output format').choices(['toon', 'json', 'text', 'sarif']).default('toon');
48
- const outputOption = new Option('--output <dest>', 'output destination').choices(['stdout', 'file']).default('stdout');
49
-
50
- function addCommonOptions(cmd) {
51
- return cmd
52
- .option('-w, --workflow <pattern...>', 'workflow path or glob (repeatable)')
53
- .option('--cwd <dir>', 'working directory', process.cwd())
54
- .option('--token <token>', 'GitHub token (overrides GITHUB_TOKEN / GH_TOKEN)')
55
- .addOption(formatOption)
56
- .addOption(outputOption)
57
- .option('--output-path <path>', 'file path when --output=file');
49
+ .version(version)
50
+ .exitOverride(error => {
51
+ if (error.exitCode !== 0) error.exitCode = 2;
52
+ throw error;
53
+ });
54
+
55
+ function createFormatOption() {
56
+ return new Option('--format <fmt>', 'output format')
57
+ .choices(['toon', 'json', 'text', 'sarif'])
58
+ .default('toon');
59
+ }
60
+
61
+ function createOutputOption() {
62
+ return new Option('--output <dest>', 'output destination')
63
+ .choices(['stdout', 'file'])
64
+ .default('stdout');
65
+ }
66
+
67
+ function createOutputPathOption() {
68
+ return new Option('--output-path <path>', 'report path (implies --output=file)')
69
+ .argParser(value => parseNonEmpty(value, '--output-path'))
70
+ .implies({ output: 'file' });
71
+ }
72
+
73
+ function createCwdOption() {
74
+ return new Option('--cwd <dir>', 'working directory')
75
+ .argParser(value => parseNonEmpty(value, '--cwd'))
76
+ .default(process.cwd(), 'current directory');
77
+ }
78
+
79
+ function createTokenOption() {
80
+ return new Option('--token <token>', 'GitHub token override (prefer GITHUB_TOKEN or GH_TOKEN)')
81
+ .argParser(value => parseNonEmpty(value, '--token'));
82
+ }
83
+
84
+ function addCommonOptions(cmd, { includeToken = true } = {}) {
85
+ const configured = cmd
86
+ .addOption(new Option('-w, --workflow <pattern...>', 'workflow path or glob (repeatable)')
87
+ .argParser((value, previous) => collectNonEmpty(value, previous, '--workflow')))
88
+ .addOption(createCwdOption());
89
+ if (includeToken) configured.addOption(createTokenOption());
90
+ return configured
91
+ .addOption(createFormatOption())
92
+ .addOption(createOutputOption())
93
+ .addOption(createOutputPathOption());
58
94
  }
59
95
 
60
96
  async function emit(payload, opts) {
@@ -71,22 +107,27 @@ async function emit(payload, opts) {
71
107
  process.stdout.write(payload);
72
108
  }
73
109
 
74
- addCommonOptions(program.command('audit'))
110
+ addCommonOptions(program.command('audit'), { includeToken: false })
75
111
  .description('Scan workflows for security findings')
76
112
  .addOption(new Option('--severity <level>', 'minimum severity').choices(['low', 'medium', 'high', 'critical']))
77
- .option('--explain', 'include plain-English remediation hint for each finding', false)
78
- .option('--config <path>', 'repository policy file (default: .actions-warden.yml)')
79
- .option('--ignore-config', 'do not load repository policy', false)
80
- .option('--baseline <path>', 'suppress findings recorded in a baseline')
81
- .option('--create-baseline <path>', 'write the current findings as a baseline')
113
+ .option('--explain', 'include plain-English remediation hint for each finding')
114
+ .addOption(new Option('--config <path>', 'repository policy file (default: .actions-warden.yml)')
115
+ .argParser(value => parseNonEmpty(value, '--config'))
116
+ .conflicts('ignoreConfig'))
117
+ .addOption(new Option('--ignore-config', 'do not load repository policy').conflicts('config'))
118
+ .addOption(new Option('--baseline <path>', 'suppress findings recorded in a baseline')
119
+ .argParser(value => parseNonEmpty(value, '--baseline'))
120
+ .conflicts('createBaseline'))
121
+ .addOption(new Option('--create-baseline <path>', 'write the current findings as a baseline')
122
+ .argParser(value => parseNonEmpty(value, '--create-baseline'))
123
+ .conflicts('baseline'))
82
124
  .action(async (opts) => {
83
125
  const createBaseline = opts.createBaseline;
84
- if (createBaseline && opts.baseline) {
85
- throw new Error('--baseline and --create-baseline cannot be used together');
86
- }
87
- if (opts.ignoreConfig && opts.config) {
88
- throw new Error('--config and --ignore-config cannot be used together');
89
- }
126
+ const destinations = await preflightRepositoryCommand(opts, {
127
+ additionalDestinations: createBaseline
128
+ ? [{ option: '--create-baseline', path: createBaseline }]
129
+ : [],
130
+ });
90
131
  const result = await audit({
91
132
  cwd: opts.cwd,
92
133
  workflows: opts.workflow,
@@ -96,6 +137,10 @@ addCommonOptions(program.command('audit'))
96
137
  baseline: opts.baseline,
97
138
  ignoreBaseline: Boolean(createBaseline),
98
139
  });
140
+ await assertDestinationsDoNotReplaceControls(destinations, [
141
+ { label: 'active config', path: result.configPath },
142
+ ...(!createBaseline ? [{ label: 'active baseline', path: result.baseline.path }] : []),
143
+ ]);
99
144
  if (createBaseline) {
100
145
  await writeFileGuarded({
101
146
  path: resolve(opts.cwd, createBaseline),
@@ -129,15 +174,11 @@ addCommonOptions(program.command('audit'))
129
174
 
130
175
  addCommonOptions(program.command('pin'))
131
176
  .description('Pin tag/branch refs to immutable commit SHAs')
132
- .option('--write', 'apply changes (disables dry-run)', false)
133
- .option('--dry-run', 'explicitly keep dry-run mode (the default)', false)
134
- .option('--fix <id>', 'apply only the change with this id')
177
+ .addOption(new Option('--write', 'apply changes (disables dry-run)').conflicts('dryRun'))
178
+ .addOption(new Option('--dry-run', 'explicitly keep dry-run mode (the default)').conflicts('write'))
179
+ .addOption(new Option('--fix <id>', 'select only the 16-hex change id').argParser(parseFixId))
135
180
  .action(async (opts) => {
136
- if (opts.write && opts.dryRun) {
137
- process.stderr.write('error: --write and --dry-run cannot be used together\n');
138
- process.exitCode = 2;
139
- return;
140
- }
181
+ await preflightRepositoryCommand(opts);
141
182
  const dryRun = !opts.write;
142
183
  const result = await pin({
143
184
  cwd: opts.cwd,
@@ -153,24 +194,16 @@ addCommonOptions(program.command('pin'))
153
194
 
154
195
  addCommonOptions(program.command('upgrade'))
155
196
  .description('Upgrade pinned/tagged actions to a newer version')
156
- .option('--write', 'apply changes (disables dry-run)', false)
157
- .option('--dry-run', 'explicitly keep dry-run mode (the default)', false)
197
+ .addOption(new Option('--write', 'apply changes (disables dry-run)').conflicts('dryRun'))
198
+ .addOption(new Option('--dry-run', 'explicitly keep dry-run mode (the default)').conflicts('write'))
158
199
  .addOption(new Option('--mode <m>', 'upgrade scope').choices(['major', 'minor', 'patch']).default('minor'))
159
- .option('--min-age <days>', 'cooldown: only accept tags older than this many days', '7')
160
- .option('--fix <id>', 'apply only the change with this id')
200
+ .addOption(new Option('--min-age <days>', 'cooldown: only accept tags older than this many days')
201
+ .argParser(value => parseInteger(value, '--min-age', { min: 0 }))
202
+ .default(7))
203
+ .addOption(new Option('--fix <id>', 'select only the 16-hex change id').argParser(parseFixId))
161
204
  .action(async (opts) => {
162
- if (opts.write && opts.dryRun) {
163
- process.stderr.write('error: --write and --dry-run cannot be used together\n');
164
- process.exitCode = 2;
165
- return;
166
- }
205
+ await preflightRepositoryCommand(opts);
167
206
  const dryRun = !opts.write;
168
- const minAgeDays = Number.parseInt(opts.minAge, 10);
169
- if (Number.isNaN(minAgeDays) || minAgeDays < 0) {
170
- process.stderr.write('error: --min-age must be a non-negative integer\n');
171
- process.exitCode = 2;
172
- return;
173
- }
174
207
  const result = await upgrade({
175
208
  cwd: opts.cwd,
176
209
  workflows: opts.workflow,
@@ -178,7 +211,7 @@ addCommonOptions(program.command('upgrade'))
178
211
  token: opts.token,
179
212
  mode: opts.mode,
180
213
  fix: opts.fix,
181
- minAgeDays,
214
+ minAgeDays: opts.minAge,
182
215
  });
183
216
  const payload = renderUpgrade(result, { format: opts.format, dryRun, mode: opts.mode, cwd: opts.cwd });
184
217
  await emit(payload, opts);
@@ -189,22 +222,20 @@ addCommonOptions(program.command('report'))
189
222
  .description('Combined audit + pin (dry) + upgrade (dry) report')
190
223
  .addOption(new Option('--mode <m>', 'upgrade scope').choices(['major', 'minor', 'patch']).default('minor'))
191
224
  .addOption(new Option('--severity <level>', 'minimum audit severity').choices(['low', 'medium', 'high', 'critical']))
192
- .option('--explain', 'include remediation hints in audit findings', false)
193
- .option('--min-age <days>', 'cooldown for upgrades (days)', '7')
194
- .option('--offline', 'skip network calls (audit only)', false)
195
- .option('--config <path>', 'repository policy file (default: .actions-warden.yml)')
196
- .option('--ignore-config', 'do not load repository policy', false)
197
- .option('--baseline <path>', 'suppress audit findings recorded in a baseline')
225
+ .option('--explain', 'include remediation hints in audit findings')
226
+ .addOption(new Option('--min-age <days>', 'cooldown for upgrades (days)')
227
+ .argParser(value => parseInteger(value, '--min-age', { min: 0 }))
228
+ .default(7))
229
+ .addOption(new Option('--offline', 'skip network calls (audit only)')
230
+ .conflicts(['token', 'mode', 'minAge']))
231
+ .addOption(new Option('--config <path>', 'repository policy file (default: .actions-warden.yml)')
232
+ .argParser(value => parseNonEmpty(value, '--config'))
233
+ .conflicts('ignoreConfig'))
234
+ .addOption(new Option('--ignore-config', 'do not load repository policy').conflicts('config'))
235
+ .addOption(new Option('--baseline <path>', 'suppress audit findings recorded in a baseline')
236
+ .argParser(value => parseNonEmpty(value, '--baseline')))
198
237
  .action(async (opts) => {
199
- const minAgeDays = Number.parseInt(opts.minAge, 10);
200
- if (Number.isNaN(minAgeDays) || minAgeDays < 0) {
201
- process.stderr.write('error: --min-age must be a non-negative integer\n');
202
- process.exitCode = 2;
203
- return;
204
- }
205
- if (opts.ignoreConfig && opts.config) {
206
- throw new Error('--config and --ignore-config cannot be used together');
207
- }
238
+ const destinations = await preflightRepositoryCommand(opts);
208
239
  const result = await report({
209
240
  cwd: opts.cwd,
210
241
  workflows: opts.workflow,
@@ -213,10 +244,14 @@ addCommonOptions(program.command('report'))
213
244
  severity: opts.severity,
214
245
  explain: Boolean(opts.explain),
215
246
  skipResolve: Boolean(opts.offline),
216
- minAgeDays,
247
+ minAgeDays: opts.minAge,
217
248
  configPath: opts.ignoreConfig ? false : opts.config,
218
249
  baseline: opts.baseline,
219
250
  });
251
+ await assertDestinationsDoNotReplaceControls(destinations, [
252
+ { label: 'active config', path: result.audit.configPath },
253
+ { label: 'active baseline', path: result.audit.baseline.path },
254
+ ]);
220
255
  const payload = renderReport(result, { format: opts.format, mode: opts.mode, cwd: opts.cwd });
221
256
  await emit(payload, opts);
222
257
  process.exitCode = result.status === 'OK' ? 0 : 1;
@@ -225,6 +260,7 @@ addCommonOptions(program.command('report'))
225
260
  addCommonOptions(program.command('verify'))
226
261
  .description('Verify pinned SHAs and version metadata against GitHub')
227
262
  .action(async (opts) => {
263
+ await preflightRepositoryCommand(opts);
228
264
  const result = await verify({
229
265
  cwd: opts.cwd,
230
266
  workflows: opts.workflow,
@@ -237,39 +273,44 @@ addCommonOptions(program.command('verify'))
237
273
 
238
274
  program.command('org-scan <organization>')
239
275
  .description('Scan workflow security across a GitHub organization')
240
- .option('-r, --repository <pattern...>', 'repository name or glob (repeatable)')
276
+ .addOption(new Option('-r, --repository <pattern...>', 'repository name or glob (repeatable)')
277
+ .argParser((value, previous) => collectNonEmpty(value, previous, '--repository')))
241
278
  .addOption(new Option('--visibility <visibility>', 'repository visibility').choices(['all', 'public', 'private', 'internal']).default('all'))
242
- .option('--include-archived', 'include archived repositories', false)
243
- .option('--include-disabled', 'include disabled repositories', false)
244
- .option('--include-forks', 'include forked repositories', false)
245
- .option('--max-repos <count>', 'scan at most this many repositories')
246
- .option('--concurrency <count>', 'concurrent repository scans (1-16)', '4')
279
+ .option('--include-archived', 'include archived repositories')
280
+ .option('--include-disabled', 'include disabled repositories')
281
+ .option('--include-forks', 'include forked repositories')
282
+ .addOption(new Option('--max-repos <count>', 'scan at most this many repositories')
283
+ .argParser(value => parseInteger(value, '--max-repos', { min: 1 })))
284
+ .addOption(new Option('--concurrency <count>', 'concurrent repository scans (1-16)')
285
+ .argParser(value => parseInteger(value, '--concurrency', { min: 1, max: 16 }))
286
+ .default(4))
247
287
  .addOption(new Option('--severity <level>', 'minimum severity').choices(['low', 'medium', 'high', 'critical']))
248
- .option('--explain', 'include plain-English remediation hint for each finding', false)
249
- .option('--config <path>', 'organization-wide policy file (default: .actions-warden.yml)')
250
- .option('--ignore-config', 'do not load organization-wide policy', false)
251
- .option('--baseline <path>', 'suppress findings recorded in an organization baseline')
252
- .option('--checkpoint <path>', 'create or replace a resumable scan checkpoint')
253
- .option('--resume <path>', 'resume from and update an existing checkpoint')
288
+ .option('--explain', 'include plain-English remediation hint for each finding')
289
+ .addOption(new Option('--config <path>', 'organization-wide policy file (default: .actions-warden.yml)')
290
+ .argParser(value => parseNonEmpty(value, '--config'))
291
+ .conflicts('ignoreConfig'))
292
+ .addOption(new Option('--ignore-config', 'do not load organization-wide policy').conflicts('config'))
293
+ .addOption(new Option('--baseline <path>', 'suppress findings recorded in an organization baseline')
294
+ .argParser(value => parseNonEmpty(value, '--baseline')))
295
+ .addOption(new Option('--checkpoint <path>', 'create or replace a resumable scan checkpoint')
296
+ .argParser(value => parseNonEmpty(value, '--checkpoint'))
297
+ .conflicts('resume'))
298
+ .addOption(new Option('--resume <path>', 'resume from and update an existing checkpoint')
299
+ .argParser(value => parseNonEmpty(value, '--resume'))
300
+ .conflicts('checkpoint'))
254
301
  .addOption(new Option('--progress <mode>', 'live progress on stderr').choices(['auto', 'always', 'never']).default('auto'))
255
302
  .option('--agent-mode', 'use bounded AI-agent output and automatic artifacts')
256
303
  .option('--no-agent-mode', 'disable agent mode from the environment')
257
- .option('--cwd <dir>', 'working directory', process.cwd())
258
- .option('--token <token>', 'GitHub token (overrides GITHUB_TOKEN / GH_TOKEN)')
259
- .addOption(formatOption)
260
- .addOption(outputOption)
261
- .option('--output-path <path>', 'file path when --output=file')
304
+ .addOption(createCwdOption())
305
+ .addOption(createTokenOption())
306
+ .addOption(createFormatOption())
307
+ .addOption(createOutputOption())
308
+ .addOption(createOutputPathOption())
262
309
  .action(async (organization, opts, command) => {
263
- if (opts.ignoreConfig && opts.config) {
264
- throw new Error('--config and --ignore-config cannot be used together');
265
- }
266
- if (opts.checkpoint && opts.resume) {
267
- throw new Error('--checkpoint and --resume cannot be used together');
268
- }
269
- const concurrency = parsePositiveInteger(opts.concurrency, '--concurrency');
270
- const maxRepositories = opts.maxRepos === undefined
271
- ? undefined
272
- : parsePositiveInteger(opts.maxRepos, '--max-repos');
310
+ await validateWorkingDirectory(opts.cwd);
311
+ assertMutuallyExclusiveFlags('--agent-mode', '--no-agent-mode');
312
+ const concurrency = opts.concurrency;
313
+ const maxRepositories = opts.maxRepos;
273
314
  const agentMode = resolveAgentMode({
274
315
  optionValue: opts.agentMode,
275
316
  optionSource: command.getOptionValueSource('agentMode'),
@@ -301,20 +342,32 @@ program.command('org-scan <organization>')
301
342
  if (agentMode && effective.output === 'file' && !effective.outputPath) {
302
343
  effective.outputPath = agentArtifacts.reportPath;
303
344
  }
345
+ const outputDestination = await validateOutputDestination(effective);
346
+ await assertDestinationsUseSafeNames(
347
+ outputDestination ? [outputDestination] : [],
348
+ effective.cwd,
349
+ );
304
350
  let checkpointPath = effective.resume ?? effective.checkpoint;
305
351
  let resume = Boolean(effective.resume);
306
352
  if (agentMode && !checkpointPath) {
307
353
  checkpointPath = agentArtifacts.checkpointPath;
308
354
  resume = agentArtifacts.resume;
309
355
  }
356
+ const checkpointDestination = checkpointPath
357
+ ? await validateWriteDestination({
358
+ option: resume ? '--resume' : '--checkpoint',
359
+ path: checkpointPath,
360
+ cwd: effective.cwd,
361
+ })
362
+ : null;
363
+ await assertDestinationsUseSafeNames(
364
+ checkpointDestination ? [checkpointDestination] : [],
365
+ effective.cwd,
366
+ );
310
367
  if (
311
- checkpointPath
312
- && effective.output === 'file'
313
- && effective.outputPath
314
- && await samePath(
315
- resolve(effective.cwd, checkpointPath),
316
- resolve(effective.cwd, effective.outputPath),
317
- )
368
+ checkpointDestination
369
+ && outputDestination
370
+ && await sameFilePath(checkpointDestination.path, outputDestination.path)
318
371
  ) {
319
372
  throw new Error('checkpoint and report output paths must be different');
320
373
  }
@@ -342,6 +395,13 @@ program.command('org-scan <organization>')
342
395
  }
343
396
  : undefined,
344
397
  });
398
+ await assertDestinationsDoNotReplaceControls(
399
+ outputDestination ? [outputDestination] : [],
400
+ [
401
+ { label: 'active config', path: result.configPath },
402
+ { label: 'active baseline', path: result.baseline.path },
403
+ ],
404
+ );
345
405
  const payload = renderOrganizationScan(result, {
346
406
  format: effective.format,
347
407
  cwd: effective.cwd,
@@ -361,7 +421,7 @@ program.command('org-scan <organization>')
361
421
 
362
422
  program.command('rules')
363
423
  .description('List available audit rules')
364
- .addOption(formatOption)
424
+ .addOption(createFormatOption())
365
425
  .action((opts) => {
366
426
  const rules = listRules();
367
427
  const payload = fmt(opts.format, rules.map(r => ({ label: 'RULE', fields: r })), {
@@ -372,29 +432,162 @@ program.command('rules')
372
432
  });
373
433
 
374
434
  program.parseAsync(process.argv).catch((err) => {
435
+ if (err instanceof CommanderError) {
436
+ process.exitCode = err.exitCode;
437
+ return;
438
+ }
375
439
  process.stderr.write(`error: ${err.message}\n`);
376
440
  process.exitCode = 2;
377
441
  });
378
442
 
379
- function parsePositiveInteger(value, option) {
380
- if (!/^\d+$/.test(value) || Number.parseInt(value, 10) < 1) {
381
- throw new Error(`${option} must be a positive integer`);
443
+ function parseNonEmpty(value, option) {
444
+ if (value.length === 0) {
445
+ throw new InvalidArgumentError(`${option} must be non-empty`);
446
+ }
447
+ return value;
448
+ }
449
+
450
+ function collectNonEmpty(value, previous, option) {
451
+ return [...(Array.isArray(previous) ? previous : []), parseNonEmpty(value, option)];
452
+ }
453
+
454
+ function parseInteger(value, option, { min, max = Number.MAX_SAFE_INTEGER }) {
455
+ if (!/^\d+$/.test(value)) {
456
+ throw new InvalidArgumentError(`${option} must be an integer`);
457
+ }
458
+ const parsed = Number(value);
459
+ if (!Number.isSafeInteger(parsed) || parsed < min || parsed > max) {
460
+ if (min === 0 && max === Number.MAX_SAFE_INTEGER) {
461
+ throw new InvalidArgumentError(`${option} must be a non-negative integer`);
462
+ }
463
+ if (min === 1 && max === Number.MAX_SAFE_INTEGER) {
464
+ throw new InvalidArgumentError(`${option} must be a positive integer`);
465
+ }
466
+ throw new InvalidArgumentError(`${option} must be an integer from ${min} to ${max}`);
467
+ }
468
+ return parsed;
469
+ }
470
+
471
+ function parseFixId(value) {
472
+ if (!FIX_ID_RE.test(value)) {
473
+ throw new InvalidArgumentError('--fix must be a 16-character hexadecimal change id');
382
474
  }
383
- return Number.parseInt(value, 10);
475
+ return value.toLowerCase();
384
476
  }
385
477
 
386
478
  function progressEnabled(mode) {
387
479
  return mode === 'always' || (mode === 'auto' && Boolean(process.stderr.isTTY));
388
480
  }
389
481
 
390
- async function samePath(left, right) {
391
- const [leftParent, rightParent] = await Promise.all([
392
- realpath(dirname(left)),
393
- realpath(dirname(right)),
394
- ]);
395
- const leftTarget = join(leftParent, basename(left));
396
- const rightTarget = join(rightParent, basename(right));
397
- return process.platform === 'win32'
398
- ? leftTarget.toLowerCase() === rightTarget.toLowerCase()
399
- : leftTarget === rightTarget;
482
+ async function preflightRepositoryCommand(opts, { additionalDestinations = [] } = {}) {
483
+ await validateWorkingDirectory(opts.cwd);
484
+ const outputDestination = await validateOutputDestination(opts);
485
+ const destinations = outputDestination ? [outputDestination] : [];
486
+ for (const destination of additionalDestinations) {
487
+ destinations.push(await validateWriteDestination({
488
+ ...destination,
489
+ cwd: opts.cwd,
490
+ }));
491
+ }
492
+ await assertDestinationsAreDistinct(destinations);
493
+ await assertDestinationsUseSafeNames(destinations, opts.cwd);
494
+ if (destinations.length > 0) {
495
+ const workflows = await resolveTargets({ workflows: opts.workflow, cwd: opts.cwd });
496
+ await assertDestinationsDoNotReplaceControls(
497
+ destinations,
498
+ workflows.map(path => ({ label: 'selected workflow', path })),
499
+ );
500
+ }
501
+ return destinations;
502
+ }
503
+
504
+ async function assertDestinationsUseSafeNames(destinations, cwd) {
505
+ await assertDestinationsDoNotReplaceControls(
506
+ destinations,
507
+ DEFAULT_CONFIG_PATHS.map(path => ({ label: 'reserved config path', path: resolve(cwd, path) })),
508
+ );
509
+ for (const destination of destinations) {
510
+ if (isDefaultWorkflowPath(destination.path, cwd)) {
511
+ throw new Error(`${destination.option} cannot use a default workflow discovery path`);
512
+ }
513
+ }
514
+ }
515
+
516
+ async function validateWorkingDirectory(cwd) {
517
+ if (typeof cwd !== 'string' || cwd.length === 0 || cwd.includes('\0')) {
518
+ throw new Error('--cwd must be an existing directory');
519
+ }
520
+ let metadata;
521
+ try {
522
+ metadata = await stat(resolve(cwd));
523
+ } catch (error) {
524
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
525
+ throw new Error('--cwd must be an existing directory');
526
+ }
527
+ throw error;
528
+ }
529
+ if (!metadata.isDirectory()) throw new Error('--cwd must be an existing directory');
530
+ }
531
+
532
+ async function validateOutputDestination(opts) {
533
+ if (opts.outputPath && opts.output !== 'file') {
534
+ throw new Error('--output-path cannot be used with --output=stdout');
535
+ }
536
+ if (opts.output !== 'file') return null;
537
+ if (!opts.outputPath) throw new Error('--output-path is required when --output=file');
538
+ return validateWriteDestination({
539
+ option: '--output-path',
540
+ path: opts.outputPath,
541
+ cwd: opts.cwd ?? process.cwd(),
542
+ });
543
+ }
544
+
545
+ async function validateWriteDestination({ option, path, cwd }) {
546
+ if (typeof path !== 'string' || path.length === 0 || path.includes('\0')) {
547
+ throw new Error(`${option} must be a non-empty path`);
548
+ }
549
+ try {
550
+ await writeFileGuarded({ path, content: '', dryRun: true, cwd });
551
+ } catch (error) {
552
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
553
+ throw new Error(`parent directory for ${option} must exist`);
554
+ }
555
+ throw error;
556
+ }
557
+ return { option, path: resolve(cwd, path) };
558
+ }
559
+
560
+ async function assertDestinationsAreDistinct(destinations) {
561
+ for (let left = 0; left < destinations.length; left += 1) {
562
+ for (let right = left + 1; right < destinations.length; right += 1) {
563
+ if (await sameFilePath(destinations[left].path, destinations[right].path)) {
564
+ throw new Error(
565
+ `${destinations[left].option} and ${destinations[right].option} must use different paths`,
566
+ );
567
+ }
568
+ }
569
+ }
570
+ }
571
+
572
+ async function assertDestinationsDoNotReplaceControls(destinations, controls) {
573
+ for (const destination of destinations) {
574
+ for (const control of controls) {
575
+ if (control.path && await sameFilePath(destination.path, resolve(control.path))) {
576
+ throw new Error(`${destination.option} cannot replace the ${control.label}`);
577
+ }
578
+ }
579
+ }
580
+ }
581
+
582
+ function assertMutuallyExclusiveFlags(left, right) {
583
+ const args = process.argv.slice(2);
584
+ if (args.includes(left) && args.includes(right)) {
585
+ throw new Error(`${left} and ${right} cannot be used together`);
586
+ }
587
+ }
588
+
589
+ function isDefaultWorkflowPath(path, cwd) {
590
+ const localPath = relative(resolve(cwd), path).split(sep).join('/');
591
+ return /^\.github\/workflows\/[^/]+\.ya?ml$/i.test(localPath)
592
+ || /(^|\/)action\.ya?ml$/i.test(localPath);
400
593
  }