@attest-it/cli 0.0.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -3,15 +3,16 @@
3
3
  var commander = require('commander');
4
4
  var fs = require('fs');
5
5
  var path = require('path');
6
- var YAML = require('yaml');
7
- var pc = require('picocolors');
6
+ var chromaterm = require('chromaterm');
8
7
  var prompts = require('@inquirer/prompts');
9
8
  var core = require('@attest-it/core');
10
9
  var child_process = require('child_process');
11
10
  var os = require('os');
12
11
  var shellQuote = require('shell-quote');
13
-
14
- function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
12
+ var React7 = require('react');
13
+ var ink = require('ink');
14
+ var jsxRuntime = require('react/jsx-runtime');
15
+ var promises = require('fs/promises');
15
16
 
16
17
  function _interopNamespace(e) {
17
18
  if (e && e.__esModule) return e;
@@ -33,12 +34,33 @@ function _interopNamespace(e) {
33
34
 
34
35
  var fs__namespace = /*#__PURE__*/_interopNamespace(fs);
35
36
  var path__namespace = /*#__PURE__*/_interopNamespace(path);
36
- var YAML__default = /*#__PURE__*/_interopDefault(YAML);
37
- var pc__default = /*#__PURE__*/_interopDefault(pc);
38
37
  var os__namespace = /*#__PURE__*/_interopNamespace(os);
38
+ var React7__namespace = /*#__PURE__*/_interopNamespace(React7);
39
39
 
40
40
  // src/index.ts
41
41
  var globalOptions = {};
42
+ var theme;
43
+ async function initTheme() {
44
+ theme = await chromaterm.detectTheme();
45
+ }
46
+ function getTheme() {
47
+ if (!theme) {
48
+ const noopFn = (str) => str;
49
+ const chainable = () => noopFn;
50
+ theme = {
51
+ red: Object.assign(noopFn, { bold: chainable, dim: chainable }),
52
+ green: Object.assign(noopFn, { bold: chainable, dim: chainable }),
53
+ yellow: Object.assign(noopFn, { bold: chainable, dim: chainable }),
54
+ blue: Object.assign(noopFn, { bold: chainable, dim: chainable }),
55
+ success: noopFn,
56
+ error: noopFn,
57
+ warning: noopFn,
58
+ info: noopFn,
59
+ muted: noopFn
60
+ };
61
+ }
62
+ return theme;
63
+ }
42
64
  function setOutputOptions(options) {
43
65
  globalOptions = options;
44
66
  }
@@ -49,22 +71,22 @@ function log(message) {
49
71
  }
50
72
  function verbose(message) {
51
73
  if (globalOptions.verbose && !globalOptions.quiet) {
52
- console.log(pc__default.default.dim(message));
74
+ console.log(getTheme().muted(message));
53
75
  }
54
76
  }
55
77
  function success(message) {
56
- log(pc__default.default.green("\u2713 " + message));
78
+ log(getTheme().success("\u2713 " + message));
57
79
  }
58
80
  function error(message) {
59
- console.error(pc__default.default.red("\u2717 " + message));
81
+ console.error(getTheme().error("\u2717 " + message));
60
82
  }
61
83
  function warn(message) {
62
84
  if (!globalOptions.quiet) {
63
- console.warn(pc__default.default.yellow("\u26A0 " + message));
85
+ console.warn(getTheme().warning("\u26A0 " + message));
64
86
  }
65
87
  }
66
88
  function info(message) {
67
- log(pc__default.default.blue("\u2139 " + message));
89
+ log(getTheme().info("\u2139 " + message));
68
90
  }
69
91
  function formatTable(rows) {
70
92
  const headers = ["Suite", "Status", "Fingerprint", "Age"];
@@ -93,17 +115,18 @@ function formatTable(rows) {
93
115
  return lines.join("\n");
94
116
  }
95
117
  function colorizeStatus(status) {
118
+ const t = getTheme();
96
119
  switch (status) {
97
120
  case "VALID":
98
- return pc__default.default.green(status);
121
+ return t.green(status);
99
122
  case "NEEDS_ATTESTATION":
100
123
  case "FINGERPRINT_CHANGED":
101
- return pc__default.default.yellow(status);
124
+ return t.yellow(status);
102
125
  case "EXPIRED":
103
126
  case "INVALIDATED_BY_PARENT":
104
- return pc__default.default.red(status);
127
+ return t.red(status);
105
128
  case "SIGNATURE_INVALID":
106
- return pc__default.default.red(pc__default.default.bold(status));
129
+ return t.red.bold()(status);
107
130
  default:
108
131
  return status;
109
132
  }
@@ -117,24 +140,6 @@ async function confirmAction(options) {
117
140
  default: options.default ?? false
118
141
  });
119
142
  }
120
- async function selectOption(options) {
121
- return prompts.select({
122
- message: options.message,
123
- choices: options.choices
124
- });
125
- }
126
- async function getInput(options) {
127
- const inputConfig = {
128
- message: options.message
129
- };
130
- if (options.default !== void 0) {
131
- inputConfig.default = options.default;
132
- }
133
- if (options.validate !== void 0) {
134
- inputConfig.validate = options.validate;
135
- }
136
- return prompts.input(inputConfig);
137
- }
138
143
 
139
144
  // src/utils/exit-codes.ts
140
145
  var ExitCode = {
@@ -142,18 +147,56 @@ var ExitCode = {
142
147
  SUCCESS: 0,
143
148
  /** Tests failed or attestation invalid */
144
149
  FAILURE: 1,
150
+ /** Nothing needed attestation */
151
+ NO_WORK: 2,
145
152
  /** Configuration or validation error */
146
- CONFIG_ERROR: 2,
153
+ CONFIG_ERROR: 3,
147
154
  /** User cancelled the operation */
148
- CANCELLED: 3,
155
+ CANCELLED: 4,
149
156
  /** Missing required key file */
150
- MISSING_KEY: 4
157
+ MISSING_KEY: 5
151
158
  };
152
159
 
153
160
  // src/commands/init.ts
154
- var initCommand = new commander.Command("init").description("Initialize attest-it configuration").option("-p, --path <path>", "Config file path", ".attest-it/config.yaml").option("-f, --force", "Overwrite existing config").option("--json", "Output JSON instead of YAML").action(async (options) => {
161
+ var initCommand = new commander.Command("init").description("Initialize attest-it configuration").option("-p, --path <path>", "Config file path", ".attest-it/config.yaml").option("-f, --force", "Overwrite existing config").action(async (options) => {
155
162
  await runInit(options);
156
163
  });
164
+ var CONFIG_TEMPLATE = `# attest-it configuration
165
+ # See https://github.com/attest-it/attest-it for documentation
166
+
167
+ version: 1
168
+
169
+ settings:
170
+ # How long attestations remain valid (in days)
171
+ maxAgeDays: 30
172
+ # Path to the public key used for signature verification
173
+ publicKeyPath: .attest-it/pubkey.pem
174
+ # Path to the attestations file
175
+ attestationsPath: .attest-it/attestations.json
176
+ # Signing algorithm
177
+ algorithm: rsa
178
+
179
+ # Define your test suites below. Each suite groups tests that require
180
+ # human verification before their attestations are accepted.
181
+ #
182
+ # Example:
183
+ #
184
+ # suites:
185
+ # visual-tests:
186
+ # description: Visual regression tests requiring human review
187
+ # packages:
188
+ # - packages/ui
189
+ # - packages/components
190
+ # command: pnpm vitest packages/ui packages/components
191
+ #
192
+ # integration:
193
+ # description: Integration tests with external services
194
+ # packages:
195
+ # - packages/api
196
+ # command: pnpm vitest packages/api --project=integration
197
+
198
+ suites: {}
199
+ `;
157
200
  async function runInit(options) {
158
201
  try {
159
202
  const configPath = path__namespace.resolve(options.path);
@@ -168,102 +211,14 @@ async function runInit(options) {
168
211
  process.exit(ExitCode.CANCELLED);
169
212
  }
170
213
  }
171
- log("");
172
- info("Welcome to attest-it!");
173
- log("This will create a configuration file for human-gated test attestations.");
174
- log("");
175
- const maxAgeDays = await getInput({
176
- message: "Maximum attestation age (days):",
177
- default: "30",
178
- validate: (v) => {
179
- const n = parseInt(v, 10);
180
- return !isNaN(n) && n > 0 ? true : "Must be a positive number";
181
- }
182
- });
183
- const algorithm = await selectOption({
184
- message: "Signing algorithm:",
185
- choices: [
186
- {
187
- value: "ed25519",
188
- name: "Ed25519 (Recommended)",
189
- description: "Fast, modern, secure"
190
- },
191
- { value: "rsa", name: "RSA", description: "Broader compatibility" }
192
- ]
193
- });
194
- const suites = [];
195
- let addMore = true;
196
- log("");
197
- info("Now configure your test suites.");
198
- log("Suites are groups of tests that require human verification.");
199
- log("");
200
- while (addMore) {
201
- const suiteName = await getInput({
202
- message: "Suite name:",
203
- validate: (v) => v.length > 0 ? true : "Required"
204
- });
205
- const description = await getInput({
206
- message: "Description (optional):"
207
- });
208
- const packagesInput = await getInput({
209
- message: "Package paths (comma-separated):",
210
- default: `packages/${suiteName}`,
211
- validate: (v) => v.length > 0 ? true : "At least one package required"
212
- });
213
- const command = await getInput({
214
- message: "Test command:",
215
- default: `pnpm vitest ${packagesInput.split(",")[0]?.trim() ?? ""}`
216
- });
217
- suites.push({
218
- name: suiteName,
219
- description,
220
- packages: packagesInput.split(",").map((p) => p.trim()).filter(Boolean),
221
- command
222
- });
223
- addMore = await confirmAction({
224
- message: "Add another suite?",
225
- default: false
226
- });
227
- }
228
- if (suites.length === 0) {
229
- error("At least one suite is required");
230
- process.exit(ExitCode.CONFIG_ERROR);
231
- }
232
- const config = {
233
- version: 1,
234
- settings: {
235
- maxAgeDays: parseInt(maxAgeDays, 10),
236
- publicKeyPath: ".attest-it/pubkey.pem",
237
- attestationsPath: ".attest-it/attestations.json",
238
- algorithm
239
- },
240
- suites: Object.fromEntries(
241
- suites.map((s) => {
242
- const suite = {
243
- packages: s.packages,
244
- command: s.command
245
- };
246
- if (s.description) {
247
- suite.description = s.description;
248
- }
249
- return [s.name, suite];
250
- })
251
- )
252
- };
253
214
  await fs__namespace.promises.mkdir(configDir, { recursive: true });
254
- const content = options.json ? JSON.stringify(config, null, 2) : YAML__default.default.stringify(config, { indent: 2 });
255
- await fs__namespace.promises.writeFile(configPath, content, "utf-8");
256
- const attestDir = path__namespace.dirname(
257
- path__namespace.resolve(path__namespace.dirname(configPath), config.settings.attestationsPath)
258
- );
259
- await fs__namespace.promises.mkdir(attestDir, { recursive: true });
215
+ await fs__namespace.promises.writeFile(configPath, CONFIG_TEMPLATE, "utf-8");
260
216
  success(`Configuration created at ${configPath}`);
261
217
  log("");
262
218
  log("Next steps:");
263
- log(" 1. Review and edit the configuration as needed");
219
+ log(` 1. Edit ${options.path} to define your test suites`);
264
220
  log(" 2. Run: attest-it keygen");
265
- log(" 3. Run: attest-it run --suite <suite-name>");
266
- log(" 4. Commit the attestation file");
221
+ log(" 3. Run: attest-it status");
267
222
  } catch (err) {
268
223
  if (err instanceof Error) {
269
224
  error(err.message);
@@ -393,92 +348,797 @@ function formatAge(result) {
393
348
  }
394
349
  return "-";
395
350
  }
396
- var runCommand = new commander.Command("run").description("Execute tests and create attestation").option("-s, --suite <name>", "Run specific suite (required unless --all)").option("-a, --all", "Run all suites needing attestation").option("--no-attest", "Run tests without creating attestation").option("-y, --yes", "Skip confirmation prompt").action(async (options) => {
397
- await runTests(options);
398
- });
399
- async function runTests(options) {
400
- try {
401
- if (!options.suite && !options.all) {
402
- error("Either --suite or --all is required");
403
- process.exit(ExitCode.CONFIG_ERROR);
351
+ function Header({ pendingCount }) {
352
+ const message = `${pendingCount.toString()} suite${pendingCount === 1 ? "" : "s"} need${pendingCount === 1 ? "s" : ""} attestation`;
353
+ return /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { borderStyle: "single", paddingX: 1, children: /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: message }) });
354
+ }
355
+ function StatusBadge({ status }) {
356
+ const statusConfig = getStatusConfig(status);
357
+ if (statusConfig.bold) {
358
+ return /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: statusConfig.color, bold: true, children: statusConfig.text });
359
+ }
360
+ return /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: statusConfig.color, children: statusConfig.text });
361
+ }
362
+ function getStatusConfig(status) {
363
+ switch (status) {
364
+ case "VALID":
365
+ return { text: "\u2713 VALID", color: "green" };
366
+ case "NEEDS_ATTESTATION":
367
+ return { text: "MISSING", color: "yellow" };
368
+ case "FINGERPRINT_CHANGED":
369
+ return { text: "CHANGED", color: "yellow" };
370
+ case "EXPIRED":
371
+ return { text: "STALE", color: "red" };
372
+ case "SIGNATURE_INVALID":
373
+ return { text: "INVALID", color: "red", bold: true };
374
+ case "INVALIDATED_BY_PARENT":
375
+ return { text: "INVALIDATED", color: "red" };
376
+ default: {
377
+ const _exhaustive = status;
378
+ return { text: String(_exhaustive), color: "yellow" };
404
379
  }
405
- const config = await core.loadConfig();
406
- const suitesToRun = options.all ? Object.keys(config.suites) : options.suite ? [options.suite] : [];
407
- if (options.suite && !config.suites[options.suite]) {
408
- error(`Suite "${options.suite}" not found in config`);
409
- process.exit(ExitCode.CONFIG_ERROR);
380
+ }
381
+ }
382
+ function SuiteTable({
383
+ suites,
384
+ selectable = false,
385
+ selected = /* @__PURE__ */ new Set()
386
+ }) {
387
+ const columnWidths = calculateColumnWidths(suites);
388
+ return /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "column", children: [
389
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { children: [
390
+ selectable && /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: " ".repeat(4) }),
391
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { bold: true, children: padEnd("Status", columnWidths.status) }),
392
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: " " }),
393
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { bold: true, children: padEnd("Suite", columnWidths.suite) }),
394
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: " " }),
395
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { bold: true, children: "Reason" })
396
+ ] }),
397
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { children: /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: "gray", children: "\u2500".repeat(
398
+ (selectable ? 4 : 0) + columnWidths.status + columnWidths.suite + columnWidths.reason
399
+ ) }) }),
400
+ suites.map((suite) => /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { children: [
401
+ selectable && /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: selected.has(suite.name) ? "[\u2713] " : "[ ] " }),
402
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { width: columnWidths.status, children: /* @__PURE__ */ jsxRuntime.jsx(StatusBadge, { status: suite.status }) }),
403
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: " " }),
404
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: padEnd(suite.name, columnWidths.suite) }),
405
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: " " }),
406
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: "gray", children: suite.reason })
407
+ ] }, suite.name))
408
+ ] });
409
+ }
410
+ function calculateColumnWidths(suites, _selectable) {
411
+ const statusHeader = "Status";
412
+ const suiteHeader = "Suite";
413
+ const reasonHeader = "Reason";
414
+ const statusWidth = Math.max(statusHeader.length, 13);
415
+ const suiteWidth = Math.max(suiteHeader.length, ...suites.map((s) => s.name.length));
416
+ const reasonWidth = Math.max(reasonHeader.length, ...suites.map((s) => s.reason.length));
417
+ return {
418
+ status: statusWidth,
419
+ suite: suiteWidth,
420
+ reason: reasonWidth
421
+ };
422
+ }
423
+ function padEnd(str, width) {
424
+ return str.padEnd(width, " ");
425
+ }
426
+ function SelectionPrompt({
427
+ message,
428
+ options,
429
+ onSelect,
430
+ groups
431
+ }) {
432
+ ink.useInput((input) => {
433
+ const matchedOption = options.find((opt) => opt.hint === input);
434
+ if (matchedOption) {
435
+ onSelect(matchedOption.value);
436
+ return;
410
437
  }
411
- const isDirty = await checkDirtyWorkingTree();
412
- if (isDirty) {
413
- error("Working tree has uncommitted changes. Please commit or stash before attesting.");
414
- process.exit(ExitCode.CONFIG_ERROR);
438
+ if (groups) {
439
+ const matchedGroup = groups.find((group) => group.name === input);
440
+ if (matchedGroup) {
441
+ onSelect(matchedGroup.name);
442
+ }
415
443
  }
416
- for (const suiteName of suitesToRun) {
417
- const suiteConfig = config.suites[suiteName];
418
- if (!suiteConfig) continue;
419
- log(`
420
- === Running suite: ${suiteName} ===
421
- `);
422
- const fingerprintOptions = {
423
- packages: suiteConfig.packages,
424
- ...suiteConfig.ignore && { ignore: suiteConfig.ignore }
425
- };
426
- const fingerprintResult = await core.computeFingerprint(fingerprintOptions);
427
- verbose(`Fingerprint: ${fingerprintResult.fingerprint}`);
428
- verbose(`Files: ${String(fingerprintResult.fileCount)}`);
429
- const command = buildCommand(config, suiteConfig.command, suiteConfig.files);
430
- log(`Running: ${command}`);
431
- log("");
432
- const exitCode = await executeCommand(command);
433
- if (exitCode !== 0) {
434
- error(`Tests failed with exit code ${String(exitCode)}`);
435
- process.exit(ExitCode.FAILURE);
444
+ });
445
+ return /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "column", children: [
446
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { bold: true, children: message }),
447
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { marginTop: 1, gap: 2, children: options.map((option) => /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { children: [
448
+ option.hint && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
449
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { color: "cyan", children: [
450
+ "[",
451
+ option.hint,
452
+ "]"
453
+ ] }),
454
+ " "
455
+ ] }),
456
+ option.label
457
+ ] }, option.value)) }),
458
+ groups && groups.length > 0 && /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { marginTop: 1, gap: 2, children: groups.map((group) => /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { children: [
459
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { color: "cyan", children: [
460
+ "[",
461
+ group.name,
462
+ "]"
463
+ ] }),
464
+ " ",
465
+ group.label
466
+ ] }, group.name)) })
467
+ ] });
468
+ }
469
+ function SuiteSelector({
470
+ pendingSuites,
471
+ validSuites,
472
+ groups,
473
+ onSelect,
474
+ onExit
475
+ }) {
476
+ const [selectedSuites, setSelectedSuites] = React7__namespace.useState(/* @__PURE__ */ new Set());
477
+ const [cursorIndex, setCursorIndex] = React7__namespace.useState(0);
478
+ const toggleSuite = React7__namespace.useCallback((suiteName) => {
479
+ setSelectedSuites((prev) => {
480
+ const next = new Set(prev);
481
+ if (next.has(suiteName)) {
482
+ next.delete(suiteName);
483
+ } else {
484
+ next.add(suiteName);
436
485
  }
437
- success("Tests passed!");
438
- if (options.attest === false) {
439
- log("Skipping attestation (--no-attest)");
440
- continue;
486
+ return next;
487
+ });
488
+ }, []);
489
+ ink.useInput((input, key) => {
490
+ if (input === "a") {
491
+ setSelectedSuites(new Set(pendingSuites.map((s) => s.name)));
492
+ return;
493
+ }
494
+ if (input === "n") {
495
+ onExit();
496
+ return;
497
+ }
498
+ if (/^[1-9]$/.test(input)) {
499
+ const idx = parseInt(input, 10) - 1;
500
+ if (idx < pendingSuites.length) {
501
+ const suite = pendingSuites[idx];
502
+ if (suite) {
503
+ toggleSuite(suite.name);
504
+ }
441
505
  }
442
- const shouldAttest = options.yes ?? await confirmAction({
443
- message: "Create attestation?",
444
- default: true
445
- });
446
- if (!shouldAttest) {
447
- warn("Attestation cancelled");
448
- process.exit(ExitCode.CANCELLED);
506
+ return;
507
+ }
508
+ if (input.startsWith("g") && groups) {
509
+ const groupIdx = parseInt(input.slice(1), 10) - 1;
510
+ const groupNames = Object.keys(groups);
511
+ if (groupIdx >= 0 && groupIdx < groupNames.length) {
512
+ const groupName = groupNames[groupIdx];
513
+ if (groupName) {
514
+ const groupSuites = groups[groupName] ?? [];
515
+ const newSelected = new Set(selectedSuites);
516
+ groupSuites.forEach((s) => newSelected.add(s));
517
+ setSelectedSuites(newSelected);
518
+ }
449
519
  }
450
- const attestation = core.createAttestation({
451
- suite: suiteName,
452
- fingerprint: fingerprintResult.fingerprint,
453
- command,
454
- attestedBy: os__namespace.userInfo().username
455
- });
456
- const attestationsPath = config.settings.attestationsPath;
457
- const existingFile = await core.readAttestations(attestationsPath);
458
- const existingAttestations = existingFile?.attestations ?? [];
459
- const newAttestations = core.upsertAttestation(existingAttestations, attestation);
460
- const privateKeyPath = core.getDefaultPrivateKeyPath();
461
- if (!fs__namespace.existsSync(privateKeyPath)) {
462
- error(`Private key not found: ${privateKeyPath}`);
463
- error('Run "attest-it keygen" first to generate a keypair.');
464
- process.exit(ExitCode.MISSING_KEY);
520
+ return;
521
+ }
522
+ if (key.return) {
523
+ onSelect(Array.from(selectedSuites));
524
+ return;
525
+ }
526
+ if (input === " ") {
527
+ const currentSuite = pendingSuites[cursorIndex];
528
+ if (currentSuite) {
529
+ toggleSuite(currentSuite.name);
465
530
  }
466
- await core.writeSignedAttestations({
467
- filePath: attestationsPath,
468
- attestations: newAttestations,
469
- privateKeyPath
470
- });
471
- success(`Attestation created for ${suiteName}`);
472
- log(` Fingerprint: ${fingerprintResult.fingerprint}`);
473
- log(` Attested by: ${attestation.attestedBy}`);
474
- log(` Attested at: ${attestation.attestedAt}`);
531
+ return;
475
532
  }
476
- log("");
477
- success("All suites completed!");
478
- log(
479
- `
480
- To commit: git add ${config.settings.attestationsPath} && git commit -m "Update attestations"`
533
+ if (key.upArrow) {
534
+ setCursorIndex(Math.max(0, cursorIndex - 1));
535
+ return;
536
+ }
537
+ if (key.downArrow) {
538
+ setCursorIndex(Math.min(pendingSuites.length - 1, cursorIndex + 1));
539
+ return;
540
+ }
541
+ });
542
+ return /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "column", children: [
543
+ /* @__PURE__ */ jsxRuntime.jsx(Header, { pendingCount: pendingSuites.length }),
544
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { marginY: 1, children: /* @__PURE__ */ jsxRuntime.jsx(SuiteTable, { suites: pendingSuites, selectable: true, selected: selectedSuites }) }),
545
+ validSuites.length > 0 && /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { marginY: 1, flexDirection: "column", children: [
546
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Already valid:" }),
547
+ validSuites.map((s) => /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
548
+ " ",
549
+ "\u2713 ",
550
+ s.name,
551
+ " (attested ",
552
+ String(s.age ?? 0),
553
+ " days ago)"
554
+ ] }, s.name))
555
+ ] }),
556
+ /* @__PURE__ */ jsxRuntime.jsx(
557
+ SelectionPrompt,
558
+ {
559
+ message: "Select suites to run:",
560
+ options: [
561
+ { label: "All pending", value: "all", hint: "a" },
562
+ { label: "By number", value: "number", hint: "1-9" },
563
+ { label: "None/exit", value: "none", hint: "n" }
564
+ ],
565
+ groups: groups ? Object.keys(groups).map((name, i) => ({
566
+ name: `g${String(i + 1)}`,
567
+ label: name
568
+ })) : void 0,
569
+ onSelect: () => {
570
+ }
571
+ }
572
+ ),
573
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { color: "cyan", children: [
574
+ selectedSuites.size,
575
+ " selected. Press Enter to confirm."
576
+ ] })
577
+ ] });
578
+ }
579
+ function ProgressSummary({
580
+ completed,
581
+ remaining,
582
+ failed,
583
+ skipped
584
+ }) {
585
+ return /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { borderStyle: "single", paddingX: 1, children: /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { children: [
586
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { color: "green", children: [
587
+ "Completed: ",
588
+ completed
589
+ ] }),
590
+ " ",
591
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { color: "yellow", children: [
592
+ "Remaining: ",
593
+ remaining
594
+ ] }),
595
+ " ",
596
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { color: "red", children: [
597
+ "Failed: ",
598
+ failed
599
+ ] }),
600
+ " ",
601
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { color: "gray", children: [
602
+ "Skipped: ",
603
+ skipped
604
+ ] })
605
+ ] }) });
606
+ }
607
+ function TestRunner({
608
+ suites,
609
+ executeTest,
610
+ createAttestation: createAttestation3,
611
+ onComplete
612
+ }) {
613
+ const [currentIndex, setCurrentIndex] = React7__namespace.useState(0);
614
+ const [phase, setPhase] = React7__namespace.useState("running");
615
+ const [results, setResults] = React7__namespace.useState({
616
+ completed: [],
617
+ failed: [],
618
+ skipped: []
619
+ });
620
+ const [_testPassed, setTestPassed] = React7__namespace.useState(false);
621
+ const resultsRef = React7__namespace.useRef(results);
622
+ React7__namespace.useEffect(() => {
623
+ resultsRef.current = results;
624
+ }, [results]);
625
+ React7__namespace.useEffect(() => {
626
+ if (phase !== "running") return;
627
+ const currentSuite2 = suites[currentIndex];
628
+ if (!currentSuite2) {
629
+ onComplete(resultsRef.current);
630
+ setPhase("complete");
631
+ return;
632
+ }
633
+ let cancelled = false;
634
+ executeTest(currentSuite2).then((passed) => {
635
+ if (cancelled) return;
636
+ setTestPassed(passed);
637
+ if (passed) {
638
+ setPhase("confirming");
639
+ } else {
640
+ setResults((prev) => ({
641
+ ...prev,
642
+ failed: [...prev.failed, currentSuite2]
643
+ }));
644
+ setCurrentIndex((prev) => prev + 1);
645
+ }
646
+ }).catch(() => {
647
+ if (cancelled) return;
648
+ setResults((prev) => ({
649
+ ...prev,
650
+ failed: [...prev.failed, currentSuite2]
651
+ }));
652
+ setCurrentIndex((prev) => prev + 1);
653
+ });
654
+ return () => {
655
+ cancelled = true;
656
+ };
657
+ }, [currentIndex, phase, suites, executeTest, onComplete]);
658
+ ink.useInput(
659
+ (input, key) => {
660
+ if (phase !== "confirming") return;
661
+ const currentSuite2 = suites[currentIndex];
662
+ if (!currentSuite2) return;
663
+ if (input.toLowerCase() === "y" || key.return) {
664
+ createAttestation3(currentSuite2).then(() => {
665
+ setResults((prev) => ({
666
+ ...prev,
667
+ completed: [...prev.completed, currentSuite2]
668
+ }));
669
+ setCurrentIndex((prev) => prev + 1);
670
+ setPhase("running");
671
+ }).catch(() => {
672
+ setResults((prev) => ({
673
+ ...prev,
674
+ skipped: [...prev.skipped, currentSuite2]
675
+ }));
676
+ setCurrentIndex((prev) => prev + 1);
677
+ setPhase("running");
678
+ });
679
+ }
680
+ if (input.toLowerCase() === "n") {
681
+ setResults((prev) => ({
682
+ ...prev,
683
+ skipped: [...prev.skipped, currentSuite2]
684
+ }));
685
+ setCurrentIndex((prev) => prev + 1);
686
+ setPhase("running");
687
+ }
688
+ },
689
+ { isActive: phase === "confirming" }
690
+ );
691
+ const currentSuite = suites[currentIndex];
692
+ return /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "column", children: [
693
+ /* @__PURE__ */ jsxRuntime.jsx(
694
+ ProgressSummary,
695
+ {
696
+ completed: results.completed.length,
697
+ failed: results.failed.length,
698
+ remaining: suites.length - currentIndex,
699
+ skipped: results.skipped.length
700
+ }
701
+ ),
702
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { marginY: 1, children: [
703
+ phase === "running" && currentSuite && /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { children: [
704
+ /* @__PURE__ */ jsxRuntime.jsx(SimpleSpinner, {}),
705
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { children: [
706
+ " Running ",
707
+ currentSuite,
708
+ "..."
709
+ ] })
710
+ ] }),
711
+ phase === "confirming" && currentSuite && /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "column", children: [
712
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: "green", children: "\u2713 Tests passed!" }),
713
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { children: [
714
+ "Create attestation for ",
715
+ currentSuite,
716
+ "? [Y/n]: "
717
+ ] })
718
+ ] }),
719
+ phase === "complete" && /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "column", children: [
720
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: "green", children: "\u2713 All suites processed" }),
721
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { children: [
722
+ "Completed: ",
723
+ results.completed.length,
724
+ ", Failed: ",
725
+ results.failed.length,
726
+ ", Skipped:",
727
+ " ",
728
+ results.skipped.length
729
+ ] })
730
+ ] })
731
+ ] })
732
+ ] });
733
+ }
734
+ function SimpleSpinner() {
735
+ const [frame, setFrame] = React7__namespace.useState(0);
736
+ const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
737
+ React7__namespace.useEffect(() => {
738
+ const timer = setInterval(() => {
739
+ setFrame((f) => (f + 1) % frames.length);
740
+ }, 80);
741
+ return () => {
742
+ clearInterval(timer);
743
+ };
744
+ }, []);
745
+ return /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: "cyan", children: frames[frame] });
746
+ }
747
+ function InteractiveRun({
748
+ allSuites,
749
+ config,
750
+ executeTest,
751
+ createAttestation: createAttestation3,
752
+ saveSession: saveSession2,
753
+ preSelected
754
+ }) {
755
+ const { exit } = ink.useApp();
756
+ const [phase, setPhase] = React7__namespace.useState(preSelected ? "running" : "selecting");
757
+ const [selectedSuites, setSelectedSuites] = React7__namespace.useState(preSelected ?? []);
758
+ const [results, setResults] = React7__namespace.useState({
759
+ completed: [],
760
+ failed: [],
761
+ skipped: []
762
+ });
763
+ const pendingSuites = React7__namespace.useMemo(
764
+ () => allSuites.filter((s) => s.status !== "VALID"),
765
+ [allSuites]
766
+ );
767
+ const validSuites = React7__namespace.useMemo(
768
+ () => allSuites.filter((s) => s.status === "VALID"),
769
+ [allSuites]
770
+ );
771
+ const handleSelect = React7__namespace.useCallback(
772
+ (selected) => {
773
+ if (selected.length === 0) {
774
+ exit();
775
+ return;
776
+ }
777
+ setSelectedSuites(selected);
778
+ setPhase("running");
779
+ },
780
+ [exit]
781
+ );
782
+ const handleRunComplete = React7__namespace.useCallback(
783
+ (runResults) => {
784
+ void (async () => {
785
+ setResults(runResults);
786
+ if (runResults.failed.length === 0 && runResults.skipped.length === 0) {
787
+ await saveSession2([], [], []);
788
+ } else {
789
+ await saveSession2(runResults.completed, runResults.failed, []);
790
+ }
791
+ setPhase("complete");
792
+ })();
793
+ },
794
+ [saveSession2]
795
+ );
796
+ if (pendingSuites.length === 0) {
797
+ return /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "column", children: [
798
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: "green", children: "\u2713 All suites are valid. Nothing to run." }),
799
+ validSuites.length > 0 && /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
800
+ validSuites.length,
801
+ " suite(s) already attested."
802
+ ] })
803
+ ] });
804
+ }
805
+ return /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "column", children: [
806
+ phase === "selecting" && /* @__PURE__ */ jsxRuntime.jsx(
807
+ SuiteSelector,
808
+ {
809
+ pendingSuites,
810
+ validSuites,
811
+ groups: config.groups,
812
+ onSelect: handleSelect,
813
+ onExit: () => {
814
+ exit();
815
+ }
816
+ }
817
+ ),
818
+ phase === "running" && /* @__PURE__ */ jsxRuntime.jsx(
819
+ TestRunner,
820
+ {
821
+ suites: selectedSuites,
822
+ executeTest,
823
+ createAttestation: createAttestation3,
824
+ onComplete: handleRunComplete
825
+ }
826
+ ),
827
+ phase === "complete" && /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "column", children: [
828
+ /* @__PURE__ */ jsxRuntime.jsx(
829
+ ProgressSummary,
830
+ {
831
+ completed: results.completed.length,
832
+ failed: results.failed.length,
833
+ remaining: 0,
834
+ skipped: results.skipped.length
835
+ }
836
+ ),
837
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { marginY: 1, children: results.failed.length === 0 && results.skipped.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: "green", children: "\u2713 All suites attested successfully!" }) : /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "column", children: [
838
+ results.failed.length > 0 && /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { color: "red", children: [
839
+ "\u2717 ",
840
+ results.failed.length,
841
+ " suite(s) failed: ",
842
+ results.failed.join(", ")
843
+ ] }),
844
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: "Run `attest-it run` again to continue with remaining suites." })
845
+ ] }) })
846
+ ] })
847
+ ] });
848
+ }
849
+ function determineStatus2(attestation, currentFingerprint, maxAgeDays) {
850
+ if (!attestation) {
851
+ return "NEEDS_ATTESTATION";
852
+ }
853
+ if (attestation.fingerprint !== currentFingerprint) {
854
+ return "FINGERPRINT_CHANGED";
855
+ }
856
+ const attestedAt = new Date(attestation.attestedAt);
857
+ const ageInDays = Math.floor((Date.now() - attestedAt.getTime()) / (1e3 * 60 * 60 * 24));
858
+ if (ageInDays > maxAgeDays) {
859
+ return "EXPIRED";
860
+ }
861
+ return "VALID";
862
+ }
863
+ async function getAllSuiteStatuses(config) {
864
+ let attestationsFile = null;
865
+ try {
866
+ attestationsFile = await core.readAttestations(config.settings.attestationsPath);
867
+ } catch (err) {
868
+ if (err instanceof Error && !err.message.includes("ENOENT")) {
869
+ throw err;
870
+ }
871
+ }
872
+ const attestations = attestationsFile?.attestations ?? [];
873
+ const results = [];
874
+ for (const [suiteName, suiteConfig] of Object.entries(config.suites)) {
875
+ const fingerprintResult = await core.computeFingerprint({
876
+ packages: suiteConfig.packages,
877
+ ...suiteConfig.ignore && { ignore: suiteConfig.ignore }
878
+ });
879
+ const attestation = core.findAttestation(
880
+ { schemaVersion: "1", attestations, signature: "" },
881
+ suiteName
882
+ );
883
+ const status = determineStatus2(
884
+ attestation,
885
+ fingerprintResult.fingerprint,
886
+ config.settings.maxAgeDays
481
887
  );
888
+ let age;
889
+ if (attestation) {
890
+ const attestedAt = new Date(attestation.attestedAt);
891
+ age = Math.floor((Date.now() - attestedAt.getTime()) / (1e3 * 60 * 60 * 24));
892
+ }
893
+ results.push({
894
+ name: suiteName,
895
+ status,
896
+ reason: formatStatusReason(status, age, config.settings.maxAgeDays),
897
+ currentFingerprint: fingerprintResult.fingerprint,
898
+ attestedFingerprint: attestation?.fingerprint,
899
+ attestedAt: attestation?.attestedAt,
900
+ age
901
+ });
902
+ }
903
+ return results;
904
+ }
905
+ function formatStatusReason(status, age, maxAgeDays) {
906
+ switch (status) {
907
+ case "VALID":
908
+ return `Attested ${String(age ?? 0)} days ago`;
909
+ case "NEEDS_ATTESTATION":
910
+ return "No attestation found";
911
+ case "FINGERPRINT_CHANGED":
912
+ return "Source files modified";
913
+ case "EXPIRED":
914
+ return `${String(age ?? 0)} days old (max: ${String(maxAgeDays ?? 30)})`;
915
+ case "SIGNATURE_INVALID":
916
+ return "Signature verification failed";
917
+ case "INVALIDATED_BY_PARENT":
918
+ return "Invalidated by parent suite";
919
+ default:
920
+ return status;
921
+ }
922
+ }
923
+ function getSessionPath() {
924
+ return path.join(process.cwd(), ".attest-it", "session.json");
925
+ }
926
+ async function loadSession() {
927
+ try {
928
+ const content = await promises.readFile(getSessionPath(), "utf-8");
929
+ const data = JSON.parse(content);
930
+ if (!isValidSession(data)) {
931
+ return null;
932
+ }
933
+ return data;
934
+ } catch {
935
+ return null;
936
+ }
937
+ }
938
+ async function saveSession(session) {
939
+ const sessionPath = getSessionPath();
940
+ const dir = path.dirname(sessionPath);
941
+ await promises.mkdir(dir, { recursive: true });
942
+ await promises.writeFile(sessionPath, JSON.stringify(session, null, 2), "utf-8");
943
+ }
944
+ async function clearSession() {
945
+ try {
946
+ await promises.unlink(getSessionPath());
947
+ } catch {
948
+ }
949
+ }
950
+ function isValidSession(data) {
951
+ if (typeof data !== "object" || data === null) {
952
+ return false;
953
+ }
954
+ const obj = data;
955
+ return typeof obj.started === "string" && Array.isArray(obj.selected) && obj.selected.every((item) => typeof item === "string") && Array.isArray(obj.completed) && obj.completed.every((item) => typeof item === "string") && Array.isArray(obj.failed) && obj.failed.every((item) => typeof item === "string") && Array.isArray(obj.remaining) && obj.remaining.every((item) => typeof item === "string");
956
+ }
957
+ async function runInteractive(options) {
958
+ const config = await core.loadConfig();
959
+ const allSuites = await getAllSuiteStatuses(config);
960
+ let preSelected;
961
+ if (options.continue) {
962
+ const session = await loadSession();
963
+ if (session && session.remaining.length > 0) {
964
+ preSelected = session.remaining;
965
+ log(`Resuming session with ${String(preSelected.length)} remaining suite(s)`);
966
+ }
967
+ }
968
+ if (options.dryRun) {
969
+ handleDryRun(allSuites, config, options.filter);
970
+ return;
971
+ }
972
+ const pendingSuites = allSuites.filter((s) => s.status !== "VALID");
973
+ if (pendingSuites.length === 0) {
974
+ log("All suites are valid. Nothing to run.");
975
+ process.exit(ExitCode.NO_WORK);
976
+ }
977
+ const isDirty = await checkDirtyWorkingTree();
978
+ if (isDirty) {
979
+ error("Working tree has uncommitted changes. Please commit or stash before attesting.");
980
+ process.exit(ExitCode.CONFIG_ERROR);
981
+ }
982
+ const executeTest = createTestExecutor(config);
983
+ const createAttestationFn = createAttestationCreator(config);
984
+ const saveSessionFn = createSessionSaver();
985
+ const interactiveRunProps = {
986
+ allSuites,
987
+ config,
988
+ executeTest,
989
+ createAttestation: createAttestationFn,
990
+ saveSession: saveSessionFn,
991
+ ...preSelected !== void 0 && { preSelected }
992
+ };
993
+ const { waitUntilExit } = ink.render(/* @__PURE__ */ jsxRuntime.jsx(InteractiveRun, { ...interactiveRunProps }));
994
+ await waitUntilExit();
995
+ }
996
+ function handleDryRun(allSuites, config, filterPattern) {
997
+ let pendingSuites = allSuites.filter((s) => s.status !== "VALID");
998
+ if (filterPattern) {
999
+ const regex = new RegExp("^" + filterPattern.replace(/\*/g, ".*") + "$", "i");
1000
+ pendingSuites = pendingSuites.filter((s) => regex.test(s.name));
1001
+ }
1002
+ if (pendingSuites.length === 0) {
1003
+ log("No suites would run (all valid or filtered out).");
1004
+ process.exit(ExitCode.NO_WORK);
1005
+ }
1006
+ log(`Would run ${String(pendingSuites.length)} suite(s):`);
1007
+ pendingSuites.forEach((s, i) => {
1008
+ log(` ${String(i + 1)}. ${s.name} (${s.status})`);
1009
+ });
1010
+ log("");
1011
+ log("Use `attest-it run` to execute.");
1012
+ process.exit(ExitCode.SUCCESS);
1013
+ }
1014
+ function createTestExecutor(config) {
1015
+ return async (suiteName) => {
1016
+ const suiteConfig = config.suites[suiteName];
1017
+ if (!suiteConfig) {
1018
+ error(`Suite "${suiteName}" not found in config`);
1019
+ return false;
1020
+ }
1021
+ let command = suiteConfig.command ?? config.settings.defaultCommand;
1022
+ if (!command) {
1023
+ error(`No command specified for suite "${suiteName}"`);
1024
+ return false;
1025
+ }
1026
+ if (command.includes("${files}") && suiteConfig.files) {
1027
+ command = command.replaceAll("${files}", suiteConfig.files.join(" "));
1028
+ }
1029
+ log(`Running: ${command}`);
1030
+ const exitCode = await executeCommand(command);
1031
+ return exitCode === 0;
1032
+ };
1033
+ }
1034
+ function createAttestationCreator(config) {
1035
+ return async (suiteName) => {
1036
+ const suiteConfig = config.suites[suiteName];
1037
+ if (!suiteConfig) {
1038
+ throw new Error(`Suite "${suiteName}" not found`);
1039
+ }
1040
+ const fingerprintResult = await core.computeFingerprint({
1041
+ packages: suiteConfig.packages,
1042
+ ...suiteConfig.ignore && { ignore: suiteConfig.ignore }
1043
+ });
1044
+ const attestation = core.createAttestation({
1045
+ suite: suiteName,
1046
+ fingerprint: fingerprintResult.fingerprint,
1047
+ command: suiteConfig.command ?? config.settings.defaultCommand ?? "",
1048
+ attestedBy: os__namespace.userInfo().username
1049
+ });
1050
+ const attestationsPath = config.settings.attestationsPath;
1051
+ const existingFile = await core.readAttestations(attestationsPath).catch(() => null);
1052
+ const existingAttestations = existingFile?.attestations ?? [];
1053
+ const newAttestations = core.upsertAttestation(existingAttestations, attestation);
1054
+ const privateKeyPath = core.getDefaultPrivateKeyPath();
1055
+ if (!fs__namespace.existsSync(privateKeyPath)) {
1056
+ throw new Error(`Private key not found: ${privateKeyPath}. Run "attest-it keygen" first.`);
1057
+ }
1058
+ await core.writeSignedAttestations({
1059
+ filePath: attestationsPath,
1060
+ attestations: newAttestations,
1061
+ privateKeyPath
1062
+ });
1063
+ log(`\u2713 Attestation created for ${suiteName}`);
1064
+ };
1065
+ }
1066
+ function createSessionSaver() {
1067
+ return async (completed, failed, remaining) => {
1068
+ if (completed.length === 0 && failed.length === 0 && remaining.length === 0) {
1069
+ await clearSession();
1070
+ } else {
1071
+ await saveSession({
1072
+ started: (/* @__PURE__ */ new Date()).toISOString(),
1073
+ selected: [...completed, ...failed, ...remaining],
1074
+ completed,
1075
+ failed,
1076
+ remaining
1077
+ });
1078
+ }
1079
+ };
1080
+ }
1081
+ async function executeCommand(command) {
1082
+ return new Promise((resolve2) => {
1083
+ const parsed = shellQuote.parse(command);
1084
+ const stringArgs = parsed.filter((t) => typeof t === "string");
1085
+ if (stringArgs.length === 0) {
1086
+ error("Empty command");
1087
+ resolve2(1);
1088
+ return;
1089
+ }
1090
+ const [executable, ...args] = stringArgs;
1091
+ if (!executable) {
1092
+ resolve2(1);
1093
+ return;
1094
+ }
1095
+ const child = child_process.spawn(executable, args, { stdio: "inherit" });
1096
+ child.on("close", (code) => {
1097
+ resolve2(code ?? 1);
1098
+ });
1099
+ child.on("error", (err) => {
1100
+ error(`Command failed: ${err.message}`);
1101
+ resolve2(1);
1102
+ });
1103
+ });
1104
+ }
1105
+ async function checkDirtyWorkingTree() {
1106
+ return new Promise((resolve2) => {
1107
+ const child = child_process.spawn("git", ["status", "--porcelain"], {
1108
+ stdio: ["ignore", "pipe", "pipe"]
1109
+ });
1110
+ let output = "";
1111
+ child.stdout.on("data", (data) => {
1112
+ output += data.toString();
1113
+ });
1114
+ child.on("close", () => {
1115
+ resolve2(output.trim().length > 0);
1116
+ });
1117
+ child.on("error", () => {
1118
+ resolve2(false);
1119
+ });
1120
+ });
1121
+ }
1122
+
1123
+ // src/commands/run.ts
1124
+ var runCommand = new commander.Command("run").description("Execute tests and create attestation").option("-s, --suite <name>", "Run specific suite (required unless --all or interactive mode)").option("-a, --all", "Run all suites needing attestation").option("--no-attest", "Run tests without creating attestation").option("-y, --yes", "Skip confirmation prompt").option("--dry-run", "Show what would run without executing").option("-c, --continue", "Resume interrupted session").option("--filter <pattern>", "Filter suites by pattern (glob-style)").action(async (options) => {
1125
+ await runTests(options);
1126
+ });
1127
+ async function runTests(options) {
1128
+ try {
1129
+ if (options.suite) {
1130
+ await runDirectMode(options);
1131
+ return;
1132
+ }
1133
+ if (options.all) {
1134
+ await runAllPending(options);
1135
+ return;
1136
+ }
1137
+ await runInteractive({
1138
+ dryRun: options.dryRun,
1139
+ continue: options.continue,
1140
+ filter: options.filter
1141
+ });
482
1142
  } catch (err) {
483
1143
  if (err instanceof Error) {
484
1144
  error(err.message);
@@ -514,7 +1174,7 @@ function parseCommand(command) {
514
1174
  }
515
1175
  return { executable, args };
516
1176
  }
517
- async function executeCommand(command) {
1177
+ async function executeCommand2(command) {
518
1178
  return new Promise((resolve2) => {
519
1179
  let parsed;
520
1180
  try {
@@ -541,7 +1201,7 @@ async function executeCommand(command) {
541
1201
  });
542
1202
  });
543
1203
  }
544
- async function checkDirtyWorkingTree() {
1204
+ async function checkDirtyWorkingTree2() {
545
1205
  return new Promise((resolve2) => {
546
1206
  const child = child_process.spawn("git", ["status", "--porcelain"], {
547
1207
  stdio: ["ignore", "pipe", "pipe"]
@@ -558,6 +1218,131 @@ async function checkDirtyWorkingTree() {
558
1218
  });
559
1219
  });
560
1220
  }
1221
+ async function runDirectMode(options) {
1222
+ if (!options.suite) {
1223
+ error("Suite name is required for direct mode");
1224
+ process.exit(ExitCode.CONFIG_ERROR);
1225
+ }
1226
+ const config = await core.loadConfig();
1227
+ if (!config.suites[options.suite]) {
1228
+ error(`Suite "${options.suite}" not found in config`);
1229
+ process.exit(ExitCode.CONFIG_ERROR);
1230
+ }
1231
+ const isDirty = await checkDirtyWorkingTree2();
1232
+ if (isDirty) {
1233
+ error("Working tree has uncommitted changes. Please commit or stash before attesting.");
1234
+ process.exit(ExitCode.CONFIG_ERROR);
1235
+ }
1236
+ await runSingleSuite(options.suite, config, options);
1237
+ log("");
1238
+ success("Suite completed!");
1239
+ log(
1240
+ `
1241
+ To commit: git add ${config.settings.attestationsPath} && git commit -m "Update attestations"`
1242
+ );
1243
+ }
1244
+ async function runAllPending(options) {
1245
+ const config = await core.loadConfig();
1246
+ const allSuites = await getAllSuiteStatuses(config);
1247
+ const pendingSuites = allSuites.filter((s) => s.status !== "VALID");
1248
+ if (pendingSuites.length === 0) {
1249
+ log("All suites are valid. Nothing to run.");
1250
+ process.exit(ExitCode.NO_WORK);
1251
+ }
1252
+ let suitesToRun = pendingSuites;
1253
+ if (options.filter) {
1254
+ const regex = new RegExp("^" + options.filter.replace(/\*/g, ".*") + "$", "i");
1255
+ suitesToRun = pendingSuites.filter((s) => regex.test(s.name));
1256
+ if (suitesToRun.length === 0) {
1257
+ log(`No suites match filter: ${options.filter}`);
1258
+ process.exit(ExitCode.NO_WORK);
1259
+ }
1260
+ }
1261
+ if (options.dryRun) {
1262
+ log(`Would run ${String(suitesToRun.length)} suite(s):`);
1263
+ suitesToRun.forEach((s, i) => {
1264
+ log(` ${String(i + 1)}. ${s.name} (${s.status})`);
1265
+ });
1266
+ process.exit(ExitCode.SUCCESS);
1267
+ }
1268
+ const isDirty = await checkDirtyWorkingTree2();
1269
+ if (isDirty) {
1270
+ error("Working tree has uncommitted changes. Please commit or stash before attesting.");
1271
+ process.exit(ExitCode.CONFIG_ERROR);
1272
+ }
1273
+ for (const suite of suitesToRun) {
1274
+ await runSingleSuite(suite.name, config, options);
1275
+ }
1276
+ log("");
1277
+ success("All suites completed!");
1278
+ log(
1279
+ `
1280
+ To commit: git add ${config.settings.attestationsPath} && git commit -m "Update attestations"`
1281
+ );
1282
+ }
1283
+ async function runSingleSuite(suiteName, config, options) {
1284
+ const suiteConfig = config.suites[suiteName];
1285
+ if (!suiteConfig) {
1286
+ error(`Suite "${suiteName}" not found in config`);
1287
+ process.exit(ExitCode.CONFIG_ERROR);
1288
+ }
1289
+ log(`
1290
+ === Running suite: ${suiteName} ===
1291
+ `);
1292
+ const fingerprintOptions = {
1293
+ packages: suiteConfig.packages,
1294
+ ...suiteConfig.ignore && { ignore: suiteConfig.ignore }
1295
+ };
1296
+ const fingerprintResult = await core.computeFingerprint(fingerprintOptions);
1297
+ verbose(`Fingerprint: ${fingerprintResult.fingerprint}`);
1298
+ verbose(`Files: ${String(fingerprintResult.fileCount)}`);
1299
+ const command = buildCommand(config, suiteConfig.command, suiteConfig.files);
1300
+ log(`Running: ${command}`);
1301
+ log("");
1302
+ const exitCode = await executeCommand2(command);
1303
+ if (exitCode !== 0) {
1304
+ error(`Tests failed with exit code ${String(exitCode)}`);
1305
+ process.exit(ExitCode.FAILURE);
1306
+ }
1307
+ success("Tests passed!");
1308
+ if (options.attest === false) {
1309
+ log("Skipping attestation (--no-attest)");
1310
+ return;
1311
+ }
1312
+ const shouldAttest = options.yes ?? await confirmAction({
1313
+ message: "Create attestation?",
1314
+ default: true
1315
+ });
1316
+ if (!shouldAttest) {
1317
+ warn("Attestation cancelled");
1318
+ process.exit(ExitCode.CANCELLED);
1319
+ }
1320
+ const attestation = core.createAttestation({
1321
+ suite: suiteName,
1322
+ fingerprint: fingerprintResult.fingerprint,
1323
+ command,
1324
+ attestedBy: os__namespace.userInfo().username
1325
+ });
1326
+ const attestationsPath = config.settings.attestationsPath;
1327
+ const existingFile = await core.readAttestations(attestationsPath);
1328
+ const existingAttestations = existingFile?.attestations ?? [];
1329
+ const newAttestations = core.upsertAttestation(existingAttestations, attestation);
1330
+ const privateKeyPath = core.getDefaultPrivateKeyPath();
1331
+ if (!fs__namespace.existsSync(privateKeyPath)) {
1332
+ error(`Private key not found: ${privateKeyPath}`);
1333
+ error('Run "attest-it keygen" first to generate a keypair.');
1334
+ process.exit(ExitCode.MISSING_KEY);
1335
+ }
1336
+ await core.writeSignedAttestations({
1337
+ filePath: attestationsPath,
1338
+ attestations: newAttestations,
1339
+ privateKeyPath
1340
+ });
1341
+ success(`Attestation created for ${suiteName}`);
1342
+ log(` Fingerprint: ${fingerprintResult.fingerprint}`);
1343
+ log(` Attested by: ${attestation.attestedBy}`);
1344
+ log(` Attested at: ${attestation.attestedAt}`);
1345
+ }
561
1346
  var keygenCommand = new commander.Command("keygen").description("Generate a new RSA keypair for signing attestations").option("-o, --output <path>", "Private key output path").option("-p, --public <path>", "Public key output path").option("-f, --force", "Overwrite existing keys").action(async (options) => {
562
1347
  await runKeygen(options);
563
1348
  });
@@ -706,7 +1491,7 @@ async function runPrune(options) {
706
1491
  } else {
707
1492
  error("Unknown error occurred");
708
1493
  }
709
- process.exit(2);
1494
+ process.exit(ExitCode.CONFIG_ERROR);
710
1495
  return;
711
1496
  }
712
1497
  }
@@ -767,7 +1552,7 @@ async function runVerify(options) {
767
1552
  } else {
768
1553
  error("Unknown error occurred");
769
1554
  }
770
- process.exit(2);
1555
+ process.exit(ExitCode.CONFIG_ERROR);
771
1556
  }
772
1557
  }
773
1558
  function displayResults(result, maxAgeDays, strict) {
@@ -853,7 +1638,8 @@ program.addCommand(runCommand);
853
1638
  program.addCommand(keygenCommand);
854
1639
  program.addCommand(pruneCommand);
855
1640
  program.addCommand(verifyCommand);
856
- function run() {
1641
+ async function run() {
1642
+ await initTheme();
857
1643
  program.parse();
858
1644
  const options = program.opts();
859
1645
  const outputOptions = {};