@attest-it/cli 0.1.0 → 0.3.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 +8 -4
- package/dist/bin/attest-it.js +995 -99
- package/dist/bin/attest-it.js.map +1 -1
- package/dist/index.cjs +995 -102
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +994 -98
- package/dist/index.js.map +1 -1
- package/package.json +12 -4
package/dist/bin/attest-it.js
CHANGED
|
@@ -1,15 +1,44 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import * as fs from 'fs';
|
|
4
|
+
import { readFileSync } from 'fs';
|
|
4
5
|
import * as path from 'path';
|
|
5
|
-
import
|
|
6
|
+
import { dirname, join } from 'path';
|
|
7
|
+
import { detectTheme } from 'chromaterm';
|
|
6
8
|
import { confirm } from '@inquirer/prompts';
|
|
7
9
|
import { loadConfig, readAttestations, computeFingerprint, findAttestation, createAttestation, upsertAttestation, getDefaultPrivateKeyPath, writeSignedAttestations, checkOpenSSL, getDefaultPublicKeyPath, generateKeyPair, setKeyPermissions, verifyAttestations, toAttestItConfig } from '@attest-it/core';
|
|
8
10
|
import { spawn } from 'child_process';
|
|
9
11
|
import * as os from 'os';
|
|
10
12
|
import { parse } from 'shell-quote';
|
|
13
|
+
import * as React7 from 'react';
|
|
14
|
+
import { render, useApp, Box, Text, useInput } from 'ink';
|
|
15
|
+
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
|
|
16
|
+
import { readFile, unlink, mkdir, writeFile } from 'fs/promises';
|
|
17
|
+
import { fileURLToPath } from 'url';
|
|
11
18
|
|
|
12
19
|
var globalOptions = {};
|
|
20
|
+
var theme;
|
|
21
|
+
async function initTheme() {
|
|
22
|
+
theme = await detectTheme();
|
|
23
|
+
}
|
|
24
|
+
function getTheme() {
|
|
25
|
+
if (!theme) {
|
|
26
|
+
const noopFn = (str) => str;
|
|
27
|
+
const chainable = () => noopFn;
|
|
28
|
+
theme = {
|
|
29
|
+
red: Object.assign(noopFn, { bold: chainable, dim: chainable }),
|
|
30
|
+
green: Object.assign(noopFn, { bold: chainable, dim: chainable }),
|
|
31
|
+
yellow: Object.assign(noopFn, { bold: chainable, dim: chainable }),
|
|
32
|
+
blue: Object.assign(noopFn, { bold: chainable, dim: chainable }),
|
|
33
|
+
success: noopFn,
|
|
34
|
+
error: noopFn,
|
|
35
|
+
warning: noopFn,
|
|
36
|
+
info: noopFn,
|
|
37
|
+
muted: noopFn
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return theme;
|
|
41
|
+
}
|
|
13
42
|
function setOutputOptions(options) {
|
|
14
43
|
globalOptions = options;
|
|
15
44
|
}
|
|
@@ -20,22 +49,22 @@ function log(message) {
|
|
|
20
49
|
}
|
|
21
50
|
function verbose(message) {
|
|
22
51
|
if (globalOptions.verbose && !globalOptions.quiet) {
|
|
23
|
-
console.log(
|
|
52
|
+
console.log(getTheme().muted(message));
|
|
24
53
|
}
|
|
25
54
|
}
|
|
26
55
|
function success(message) {
|
|
27
|
-
log(
|
|
56
|
+
log(getTheme().success("\u2713 " + message));
|
|
28
57
|
}
|
|
29
58
|
function error(message) {
|
|
30
|
-
console.error(
|
|
59
|
+
console.error(getTheme().error("\u2717 " + message));
|
|
31
60
|
}
|
|
32
61
|
function warn(message) {
|
|
33
62
|
if (!globalOptions.quiet) {
|
|
34
|
-
console.warn(
|
|
63
|
+
console.warn(getTheme().warning("\u26A0 " + message));
|
|
35
64
|
}
|
|
36
65
|
}
|
|
37
66
|
function info(message) {
|
|
38
|
-
log(
|
|
67
|
+
log(getTheme().info("\u2139 " + message));
|
|
39
68
|
}
|
|
40
69
|
function formatTable(rows) {
|
|
41
70
|
const headers = ["Suite", "Status", "Fingerprint", "Age"];
|
|
@@ -64,17 +93,18 @@ function formatTable(rows) {
|
|
|
64
93
|
return lines.join("\n");
|
|
65
94
|
}
|
|
66
95
|
function colorizeStatus(status) {
|
|
96
|
+
const t = getTheme();
|
|
67
97
|
switch (status) {
|
|
68
98
|
case "VALID":
|
|
69
|
-
return
|
|
99
|
+
return t.green(status);
|
|
70
100
|
case "NEEDS_ATTESTATION":
|
|
71
101
|
case "FINGERPRINT_CHANGED":
|
|
72
|
-
return
|
|
102
|
+
return t.yellow(status);
|
|
73
103
|
case "EXPIRED":
|
|
74
104
|
case "INVALIDATED_BY_PARENT":
|
|
75
|
-
return
|
|
105
|
+
return t.red(status);
|
|
76
106
|
case "SIGNATURE_INVALID":
|
|
77
|
-
return
|
|
107
|
+
return t.red.bold()(status);
|
|
78
108
|
default:
|
|
79
109
|
return status;
|
|
80
110
|
}
|
|
@@ -95,12 +125,14 @@ var ExitCode = {
|
|
|
95
125
|
SUCCESS: 0,
|
|
96
126
|
/** Tests failed or attestation invalid */
|
|
97
127
|
FAILURE: 1,
|
|
128
|
+
/** Nothing needed attestation */
|
|
129
|
+
NO_WORK: 2,
|
|
98
130
|
/** Configuration or validation error */
|
|
99
|
-
CONFIG_ERROR:
|
|
131
|
+
CONFIG_ERROR: 3,
|
|
100
132
|
/** User cancelled the operation */
|
|
101
|
-
CANCELLED:
|
|
133
|
+
CANCELLED: 4,
|
|
102
134
|
/** Missing required key file */
|
|
103
|
-
MISSING_KEY:
|
|
135
|
+
MISSING_KEY: 5
|
|
104
136
|
};
|
|
105
137
|
|
|
106
138
|
// src/commands/init.ts
|
|
@@ -294,92 +326,797 @@ function formatAge(result) {
|
|
|
294
326
|
}
|
|
295
327
|
return "-";
|
|
296
328
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
329
|
+
function Header({ pendingCount }) {
|
|
330
|
+
const message = `${pendingCount.toString()} suite${pendingCount === 1 ? "" : "s"} need${pendingCount === 1 ? "s" : ""} attestation`;
|
|
331
|
+
return /* @__PURE__ */ jsx(Box, { borderStyle: "single", paddingX: 1, children: /* @__PURE__ */ jsx(Text, { children: message }) });
|
|
332
|
+
}
|
|
333
|
+
function StatusBadge({ status }) {
|
|
334
|
+
const statusConfig = getStatusConfig(status);
|
|
335
|
+
if (statusConfig.bold) {
|
|
336
|
+
return /* @__PURE__ */ jsx(Text, { color: statusConfig.color, bold: true, children: statusConfig.text });
|
|
337
|
+
}
|
|
338
|
+
return /* @__PURE__ */ jsx(Text, { color: statusConfig.color, children: statusConfig.text });
|
|
339
|
+
}
|
|
340
|
+
function getStatusConfig(status) {
|
|
341
|
+
switch (status) {
|
|
342
|
+
case "VALID":
|
|
343
|
+
return { text: "\u2713 VALID", color: "green" };
|
|
344
|
+
case "NEEDS_ATTESTATION":
|
|
345
|
+
return { text: "MISSING", color: "yellow" };
|
|
346
|
+
case "FINGERPRINT_CHANGED":
|
|
347
|
+
return { text: "CHANGED", color: "yellow" };
|
|
348
|
+
case "EXPIRED":
|
|
349
|
+
return { text: "STALE", color: "red" };
|
|
350
|
+
case "SIGNATURE_INVALID":
|
|
351
|
+
return { text: "INVALID", color: "red", bold: true };
|
|
352
|
+
case "INVALIDATED_BY_PARENT":
|
|
353
|
+
return { text: "INVALIDATED", color: "red" };
|
|
354
|
+
default: {
|
|
355
|
+
const _exhaustive = status;
|
|
356
|
+
return { text: String(_exhaustive), color: "yellow" };
|
|
305
357
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function SuiteTable({
|
|
361
|
+
suites,
|
|
362
|
+
selectable = false,
|
|
363
|
+
selected = /* @__PURE__ */ new Set()
|
|
364
|
+
}) {
|
|
365
|
+
const columnWidths = calculateColumnWidths(suites);
|
|
366
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
367
|
+
/* @__PURE__ */ jsxs(Box, { children: [
|
|
368
|
+
selectable && /* @__PURE__ */ jsx(Text, { children: " ".repeat(4) }),
|
|
369
|
+
/* @__PURE__ */ jsx(Text, { bold: true, children: padEnd("Status", columnWidths.status) }),
|
|
370
|
+
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
371
|
+
/* @__PURE__ */ jsx(Text, { bold: true, children: padEnd("Suite", columnWidths.suite) }),
|
|
372
|
+
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
373
|
+
/* @__PURE__ */ jsx(Text, { bold: true, children: "Reason" })
|
|
374
|
+
] }),
|
|
375
|
+
/* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Text, { color: "gray", children: "\u2500".repeat(
|
|
376
|
+
(selectable ? 4 : 0) + columnWidths.status + columnWidths.suite + columnWidths.reason
|
|
377
|
+
) }) }),
|
|
378
|
+
suites.map((suite) => /* @__PURE__ */ jsxs(Box, { children: [
|
|
379
|
+
selectable && /* @__PURE__ */ jsx(Text, { children: selected.has(suite.name) ? "[\u2713] " : "[ ] " }),
|
|
380
|
+
/* @__PURE__ */ jsx(Box, { width: columnWidths.status, children: /* @__PURE__ */ jsx(StatusBadge, { status: suite.status }) }),
|
|
381
|
+
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
382
|
+
/* @__PURE__ */ jsx(Text, { children: padEnd(suite.name, columnWidths.suite) }),
|
|
383
|
+
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
384
|
+
/* @__PURE__ */ jsx(Text, { color: "gray", children: suite.reason })
|
|
385
|
+
] }, suite.name))
|
|
386
|
+
] });
|
|
387
|
+
}
|
|
388
|
+
function calculateColumnWidths(suites, _selectable) {
|
|
389
|
+
const statusHeader = "Status";
|
|
390
|
+
const suiteHeader = "Suite";
|
|
391
|
+
const reasonHeader = "Reason";
|
|
392
|
+
const statusWidth = Math.max(statusHeader.length, 13);
|
|
393
|
+
const suiteWidth = Math.max(suiteHeader.length, ...suites.map((s) => s.name.length));
|
|
394
|
+
const reasonWidth = Math.max(reasonHeader.length, ...suites.map((s) => s.reason.length));
|
|
395
|
+
return {
|
|
396
|
+
status: statusWidth,
|
|
397
|
+
suite: suiteWidth,
|
|
398
|
+
reason: reasonWidth
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
function padEnd(str, width) {
|
|
402
|
+
return str.padEnd(width, " ");
|
|
403
|
+
}
|
|
404
|
+
function SelectionPrompt({
|
|
405
|
+
message,
|
|
406
|
+
options,
|
|
407
|
+
onSelect,
|
|
408
|
+
groups
|
|
409
|
+
}) {
|
|
410
|
+
useInput((input) => {
|
|
411
|
+
const matchedOption = options.find((opt) => opt.hint === input);
|
|
412
|
+
if (matchedOption) {
|
|
413
|
+
onSelect(matchedOption.value);
|
|
414
|
+
return;
|
|
311
415
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
416
|
+
if (groups) {
|
|
417
|
+
const matchedGroup = groups.find((group) => group.name === input);
|
|
418
|
+
if (matchedGroup) {
|
|
419
|
+
onSelect(matchedGroup.name);
|
|
420
|
+
}
|
|
316
421
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
422
|
+
});
|
|
423
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
424
|
+
/* @__PURE__ */ jsx(Text, { bold: true, children: message }),
|
|
425
|
+
/* @__PURE__ */ jsx(Box, { marginTop: 1, gap: 2, children: options.map((option) => /* @__PURE__ */ jsxs(Text, { children: [
|
|
426
|
+
option.hint && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
427
|
+
/* @__PURE__ */ jsxs(Text, { color: "cyan", children: [
|
|
428
|
+
"[",
|
|
429
|
+
option.hint,
|
|
430
|
+
"]"
|
|
431
|
+
] }),
|
|
432
|
+
" "
|
|
433
|
+
] }),
|
|
434
|
+
option.label
|
|
435
|
+
] }, option.value)) }),
|
|
436
|
+
groups && groups.length > 0 && /* @__PURE__ */ jsx(Box, { marginTop: 1, gap: 2, children: groups.map((group) => /* @__PURE__ */ jsxs(Text, { children: [
|
|
437
|
+
/* @__PURE__ */ jsxs(Text, { color: "cyan", children: [
|
|
438
|
+
"[",
|
|
439
|
+
group.name,
|
|
440
|
+
"]"
|
|
441
|
+
] }),
|
|
442
|
+
" ",
|
|
443
|
+
group.label
|
|
444
|
+
] }, group.name)) })
|
|
445
|
+
] });
|
|
446
|
+
}
|
|
447
|
+
function SuiteSelector({
|
|
448
|
+
pendingSuites,
|
|
449
|
+
validSuites,
|
|
450
|
+
groups,
|
|
451
|
+
onSelect,
|
|
452
|
+
onExit
|
|
453
|
+
}) {
|
|
454
|
+
const [selectedSuites, setSelectedSuites] = React7.useState(/* @__PURE__ */ new Set());
|
|
455
|
+
const [cursorIndex, setCursorIndex] = React7.useState(0);
|
|
456
|
+
const toggleSuite = React7.useCallback((suiteName) => {
|
|
457
|
+
setSelectedSuites((prev) => {
|
|
458
|
+
const next = new Set(prev);
|
|
459
|
+
if (next.has(suiteName)) {
|
|
460
|
+
next.delete(suiteName);
|
|
461
|
+
} else {
|
|
462
|
+
next.add(suiteName);
|
|
337
463
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
464
|
+
return next;
|
|
465
|
+
});
|
|
466
|
+
}, []);
|
|
467
|
+
useInput((input, key) => {
|
|
468
|
+
if (input === "a") {
|
|
469
|
+
setSelectedSuites(new Set(pendingSuites.map((s) => s.name)));
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (input === "n") {
|
|
473
|
+
onExit();
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (/^[1-9]$/.test(input)) {
|
|
477
|
+
const idx = parseInt(input, 10) - 1;
|
|
478
|
+
if (idx < pendingSuites.length) {
|
|
479
|
+
const suite = pendingSuites[idx];
|
|
480
|
+
if (suite) {
|
|
481
|
+
toggleSuite(suite.name);
|
|
482
|
+
}
|
|
342
483
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (input.startsWith("g") && groups) {
|
|
487
|
+
const groupIdx = parseInt(input.slice(1), 10) - 1;
|
|
488
|
+
const groupNames = Object.keys(groups);
|
|
489
|
+
if (groupIdx >= 0 && groupIdx < groupNames.length) {
|
|
490
|
+
const groupName = groupNames[groupIdx];
|
|
491
|
+
if (groupName) {
|
|
492
|
+
const groupSuites = groups[groupName] ?? [];
|
|
493
|
+
const newSelected = new Set(selectedSuites);
|
|
494
|
+
groupSuites.forEach((s) => newSelected.add(s));
|
|
495
|
+
setSelectedSuites(newSelected);
|
|
496
|
+
}
|
|
350
497
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
const privateKeyPath = getDefaultPrivateKeyPath();
|
|
362
|
-
if (!fs.existsSync(privateKeyPath)) {
|
|
363
|
-
error(`Private key not found: ${privateKeyPath}`);
|
|
364
|
-
error('Run "attest-it keygen" first to generate a keypair.');
|
|
365
|
-
process.exit(ExitCode.MISSING_KEY);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (key.return) {
|
|
501
|
+
onSelect(Array.from(selectedSuites));
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (input === " ") {
|
|
505
|
+
const currentSuite = pendingSuites[cursorIndex];
|
|
506
|
+
if (currentSuite) {
|
|
507
|
+
toggleSuite(currentSuite.name);
|
|
366
508
|
}
|
|
367
|
-
|
|
368
|
-
filePath: attestationsPath,
|
|
369
|
-
attestations: newAttestations,
|
|
370
|
-
privateKeyPath
|
|
371
|
-
});
|
|
372
|
-
success(`Attestation created for ${suiteName}`);
|
|
373
|
-
log(` Fingerprint: ${fingerprintResult.fingerprint}`);
|
|
374
|
-
log(` Attested by: ${attestation.attestedBy}`);
|
|
375
|
-
log(` Attested at: ${attestation.attestedAt}`);
|
|
509
|
+
return;
|
|
376
510
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
511
|
+
if (key.upArrow) {
|
|
512
|
+
setCursorIndex(Math.max(0, cursorIndex - 1));
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (key.downArrow) {
|
|
516
|
+
setCursorIndex(Math.min(pendingSuites.length - 1, cursorIndex + 1));
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
521
|
+
/* @__PURE__ */ jsx(Header, { pendingCount: pendingSuites.length }),
|
|
522
|
+
/* @__PURE__ */ jsx(Box, { marginY: 1, children: /* @__PURE__ */ jsx(SuiteTable, { suites: pendingSuites, selectable: true, selected: selectedSuites }) }),
|
|
523
|
+
validSuites.length > 0 && /* @__PURE__ */ jsxs(Box, { marginY: 1, flexDirection: "column", children: [
|
|
524
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: "Already valid:" }),
|
|
525
|
+
validSuites.map((s) => /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
|
|
526
|
+
" ",
|
|
527
|
+
"\u2713 ",
|
|
528
|
+
s.name,
|
|
529
|
+
" (attested ",
|
|
530
|
+
String(s.age ?? 0),
|
|
531
|
+
" days ago)"
|
|
532
|
+
] }, s.name))
|
|
533
|
+
] }),
|
|
534
|
+
/* @__PURE__ */ jsx(
|
|
535
|
+
SelectionPrompt,
|
|
536
|
+
{
|
|
537
|
+
message: "Select suites to run:",
|
|
538
|
+
options: [
|
|
539
|
+
{ label: "All pending", value: "all", hint: "a" },
|
|
540
|
+
{ label: "By number", value: "number", hint: "1-9" },
|
|
541
|
+
{ label: "None/exit", value: "none", hint: "n" }
|
|
542
|
+
],
|
|
543
|
+
groups: groups ? Object.keys(groups).map((name, i) => ({
|
|
544
|
+
name: `g${String(i + 1)}`,
|
|
545
|
+
label: name
|
|
546
|
+
})) : void 0,
|
|
547
|
+
onSelect: () => {
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
),
|
|
551
|
+
/* @__PURE__ */ jsxs(Text, { color: "cyan", children: [
|
|
552
|
+
selectedSuites.size,
|
|
553
|
+
" selected. Press Enter to confirm."
|
|
554
|
+
] })
|
|
555
|
+
] });
|
|
556
|
+
}
|
|
557
|
+
function ProgressSummary({
|
|
558
|
+
completed,
|
|
559
|
+
remaining,
|
|
560
|
+
failed,
|
|
561
|
+
skipped
|
|
562
|
+
}) {
|
|
563
|
+
return /* @__PURE__ */ jsx(Box, { borderStyle: "single", paddingX: 1, children: /* @__PURE__ */ jsxs(Text, { children: [
|
|
564
|
+
/* @__PURE__ */ jsxs(Text, { color: "green", children: [
|
|
565
|
+
"Completed: ",
|
|
566
|
+
completed
|
|
567
|
+
] }),
|
|
568
|
+
" ",
|
|
569
|
+
/* @__PURE__ */ jsxs(Text, { color: "yellow", children: [
|
|
570
|
+
"Remaining: ",
|
|
571
|
+
remaining
|
|
572
|
+
] }),
|
|
573
|
+
" ",
|
|
574
|
+
/* @__PURE__ */ jsxs(Text, { color: "red", children: [
|
|
575
|
+
"Failed: ",
|
|
576
|
+
failed
|
|
577
|
+
] }),
|
|
578
|
+
" ",
|
|
579
|
+
/* @__PURE__ */ jsxs(Text, { color: "gray", children: [
|
|
580
|
+
"Skipped: ",
|
|
581
|
+
skipped
|
|
582
|
+
] })
|
|
583
|
+
] }) });
|
|
584
|
+
}
|
|
585
|
+
function TestRunner({
|
|
586
|
+
suites,
|
|
587
|
+
executeTest,
|
|
588
|
+
createAttestation: createAttestation3,
|
|
589
|
+
onComplete
|
|
590
|
+
}) {
|
|
591
|
+
const [currentIndex, setCurrentIndex] = React7.useState(0);
|
|
592
|
+
const [phase, setPhase] = React7.useState("running");
|
|
593
|
+
const [results, setResults] = React7.useState({
|
|
594
|
+
completed: [],
|
|
595
|
+
failed: [],
|
|
596
|
+
skipped: []
|
|
597
|
+
});
|
|
598
|
+
const [_testPassed, setTestPassed] = React7.useState(false);
|
|
599
|
+
const resultsRef = React7.useRef(results);
|
|
600
|
+
React7.useEffect(() => {
|
|
601
|
+
resultsRef.current = results;
|
|
602
|
+
}, [results]);
|
|
603
|
+
React7.useEffect(() => {
|
|
604
|
+
if (phase !== "running") return;
|
|
605
|
+
const currentSuite2 = suites[currentIndex];
|
|
606
|
+
if (!currentSuite2) {
|
|
607
|
+
onComplete(resultsRef.current);
|
|
608
|
+
setPhase("complete");
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
let cancelled = false;
|
|
612
|
+
executeTest(currentSuite2).then((passed) => {
|
|
613
|
+
if (cancelled) return;
|
|
614
|
+
setTestPassed(passed);
|
|
615
|
+
if (passed) {
|
|
616
|
+
setPhase("confirming");
|
|
617
|
+
} else {
|
|
618
|
+
setResults((prev) => ({
|
|
619
|
+
...prev,
|
|
620
|
+
failed: [...prev.failed, currentSuite2]
|
|
621
|
+
}));
|
|
622
|
+
setCurrentIndex((prev) => prev + 1);
|
|
623
|
+
}
|
|
624
|
+
}).catch(() => {
|
|
625
|
+
if (cancelled) return;
|
|
626
|
+
setResults((prev) => ({
|
|
627
|
+
...prev,
|
|
628
|
+
failed: [...prev.failed, currentSuite2]
|
|
629
|
+
}));
|
|
630
|
+
setCurrentIndex((prev) => prev + 1);
|
|
631
|
+
});
|
|
632
|
+
return () => {
|
|
633
|
+
cancelled = true;
|
|
634
|
+
};
|
|
635
|
+
}, [currentIndex, phase, suites, executeTest, onComplete]);
|
|
636
|
+
useInput(
|
|
637
|
+
(input, key) => {
|
|
638
|
+
if (phase !== "confirming") return;
|
|
639
|
+
const currentSuite2 = suites[currentIndex];
|
|
640
|
+
if (!currentSuite2) return;
|
|
641
|
+
if (input.toLowerCase() === "y" || key.return) {
|
|
642
|
+
createAttestation3(currentSuite2).then(() => {
|
|
643
|
+
setResults((prev) => ({
|
|
644
|
+
...prev,
|
|
645
|
+
completed: [...prev.completed, currentSuite2]
|
|
646
|
+
}));
|
|
647
|
+
setCurrentIndex((prev) => prev + 1);
|
|
648
|
+
setPhase("running");
|
|
649
|
+
}).catch(() => {
|
|
650
|
+
setResults((prev) => ({
|
|
651
|
+
...prev,
|
|
652
|
+
skipped: [...prev.skipped, currentSuite2]
|
|
653
|
+
}));
|
|
654
|
+
setCurrentIndex((prev) => prev + 1);
|
|
655
|
+
setPhase("running");
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
if (input.toLowerCase() === "n") {
|
|
659
|
+
setResults((prev) => ({
|
|
660
|
+
...prev,
|
|
661
|
+
skipped: [...prev.skipped, currentSuite2]
|
|
662
|
+
}));
|
|
663
|
+
setCurrentIndex((prev) => prev + 1);
|
|
664
|
+
setPhase("running");
|
|
665
|
+
}
|
|
666
|
+
},
|
|
667
|
+
{ isActive: phase === "confirming" }
|
|
668
|
+
);
|
|
669
|
+
const currentSuite = suites[currentIndex];
|
|
670
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
671
|
+
/* @__PURE__ */ jsx(
|
|
672
|
+
ProgressSummary,
|
|
673
|
+
{
|
|
674
|
+
completed: results.completed.length,
|
|
675
|
+
failed: results.failed.length,
|
|
676
|
+
remaining: suites.length - currentIndex,
|
|
677
|
+
skipped: results.skipped.length
|
|
678
|
+
}
|
|
679
|
+
),
|
|
680
|
+
/* @__PURE__ */ jsxs(Box, { marginY: 1, children: [
|
|
681
|
+
phase === "running" && currentSuite && /* @__PURE__ */ jsxs(Box, { children: [
|
|
682
|
+
/* @__PURE__ */ jsx(SimpleSpinner, {}),
|
|
683
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
684
|
+
" Running ",
|
|
685
|
+
currentSuite,
|
|
686
|
+
"..."
|
|
687
|
+
] })
|
|
688
|
+
] }),
|
|
689
|
+
phase === "confirming" && currentSuite && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
690
|
+
/* @__PURE__ */ jsx(Text, { color: "green", children: "\u2713 Tests passed!" }),
|
|
691
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
692
|
+
"Create attestation for ",
|
|
693
|
+
currentSuite,
|
|
694
|
+
"? [Y/n]: "
|
|
695
|
+
] })
|
|
696
|
+
] }),
|
|
697
|
+
phase === "complete" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
698
|
+
/* @__PURE__ */ jsx(Text, { color: "green", children: "\u2713 All suites processed" }),
|
|
699
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
700
|
+
"Completed: ",
|
|
701
|
+
results.completed.length,
|
|
702
|
+
", Failed: ",
|
|
703
|
+
results.failed.length,
|
|
704
|
+
", Skipped:",
|
|
705
|
+
" ",
|
|
706
|
+
results.skipped.length
|
|
707
|
+
] })
|
|
708
|
+
] })
|
|
709
|
+
] })
|
|
710
|
+
] });
|
|
711
|
+
}
|
|
712
|
+
function SimpleSpinner() {
|
|
713
|
+
const [frame, setFrame] = React7.useState(0);
|
|
714
|
+
const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
715
|
+
React7.useEffect(() => {
|
|
716
|
+
const timer = setInterval(() => {
|
|
717
|
+
setFrame((f) => (f + 1) % frames.length);
|
|
718
|
+
}, 80);
|
|
719
|
+
return () => {
|
|
720
|
+
clearInterval(timer);
|
|
721
|
+
};
|
|
722
|
+
}, []);
|
|
723
|
+
return /* @__PURE__ */ jsx(Text, { color: "cyan", children: frames[frame] });
|
|
724
|
+
}
|
|
725
|
+
function InteractiveRun({
|
|
726
|
+
allSuites,
|
|
727
|
+
config,
|
|
728
|
+
executeTest,
|
|
729
|
+
createAttestation: createAttestation3,
|
|
730
|
+
saveSession: saveSession2,
|
|
731
|
+
preSelected
|
|
732
|
+
}) {
|
|
733
|
+
const { exit } = useApp();
|
|
734
|
+
const [phase, setPhase] = React7.useState(preSelected ? "running" : "selecting");
|
|
735
|
+
const [selectedSuites, setSelectedSuites] = React7.useState(preSelected ?? []);
|
|
736
|
+
const [results, setResults] = React7.useState({
|
|
737
|
+
completed: [],
|
|
738
|
+
failed: [],
|
|
739
|
+
skipped: []
|
|
740
|
+
});
|
|
741
|
+
const pendingSuites = React7.useMemo(
|
|
742
|
+
() => allSuites.filter((s) => s.status !== "VALID"),
|
|
743
|
+
[allSuites]
|
|
744
|
+
);
|
|
745
|
+
const validSuites = React7.useMemo(
|
|
746
|
+
() => allSuites.filter((s) => s.status === "VALID"),
|
|
747
|
+
[allSuites]
|
|
748
|
+
);
|
|
749
|
+
const handleSelect = React7.useCallback(
|
|
750
|
+
(selected) => {
|
|
751
|
+
if (selected.length === 0) {
|
|
752
|
+
exit();
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
setSelectedSuites(selected);
|
|
756
|
+
setPhase("running");
|
|
757
|
+
},
|
|
758
|
+
[exit]
|
|
759
|
+
);
|
|
760
|
+
const handleRunComplete = React7.useCallback(
|
|
761
|
+
(runResults) => {
|
|
762
|
+
void (async () => {
|
|
763
|
+
setResults(runResults);
|
|
764
|
+
if (runResults.failed.length === 0 && runResults.skipped.length === 0) {
|
|
765
|
+
await saveSession2([], [], []);
|
|
766
|
+
} else {
|
|
767
|
+
await saveSession2(runResults.completed, runResults.failed, []);
|
|
768
|
+
}
|
|
769
|
+
setPhase("complete");
|
|
770
|
+
})();
|
|
771
|
+
},
|
|
772
|
+
[saveSession2]
|
|
773
|
+
);
|
|
774
|
+
if (pendingSuites.length === 0) {
|
|
775
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
776
|
+
/* @__PURE__ */ jsx(Text, { color: "green", children: "\u2713 All suites are valid. Nothing to run." }),
|
|
777
|
+
validSuites.length > 0 && /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
|
|
778
|
+
validSuites.length,
|
|
779
|
+
" suite(s) already attested."
|
|
780
|
+
] })
|
|
781
|
+
] });
|
|
782
|
+
}
|
|
783
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
784
|
+
phase === "selecting" && /* @__PURE__ */ jsx(
|
|
785
|
+
SuiteSelector,
|
|
786
|
+
{
|
|
787
|
+
pendingSuites,
|
|
788
|
+
validSuites,
|
|
789
|
+
groups: config.groups,
|
|
790
|
+
onSelect: handleSelect,
|
|
791
|
+
onExit: () => {
|
|
792
|
+
exit();
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
),
|
|
796
|
+
phase === "running" && /* @__PURE__ */ jsx(
|
|
797
|
+
TestRunner,
|
|
798
|
+
{
|
|
799
|
+
suites: selectedSuites,
|
|
800
|
+
executeTest,
|
|
801
|
+
createAttestation: createAttestation3,
|
|
802
|
+
onComplete: handleRunComplete
|
|
803
|
+
}
|
|
804
|
+
),
|
|
805
|
+
phase === "complete" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
806
|
+
/* @__PURE__ */ jsx(
|
|
807
|
+
ProgressSummary,
|
|
808
|
+
{
|
|
809
|
+
completed: results.completed.length,
|
|
810
|
+
failed: results.failed.length,
|
|
811
|
+
remaining: 0,
|
|
812
|
+
skipped: results.skipped.length
|
|
813
|
+
}
|
|
814
|
+
),
|
|
815
|
+
/* @__PURE__ */ jsx(Box, { marginY: 1, children: results.failed.length === 0 && results.skipped.length === 0 ? /* @__PURE__ */ jsx(Text, { color: "green", children: "\u2713 All suites attested successfully!" }) : /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
816
|
+
results.failed.length > 0 && /* @__PURE__ */ jsxs(Text, { color: "red", children: [
|
|
817
|
+
"\u2717 ",
|
|
818
|
+
results.failed.length,
|
|
819
|
+
" suite(s) failed: ",
|
|
820
|
+
results.failed.join(", ")
|
|
821
|
+
] }),
|
|
822
|
+
/* @__PURE__ */ jsx(Text, { children: "Run `attest-it run` again to continue with remaining suites." })
|
|
823
|
+
] }) })
|
|
824
|
+
] })
|
|
825
|
+
] });
|
|
826
|
+
}
|
|
827
|
+
function determineStatus2(attestation, currentFingerprint, maxAgeDays) {
|
|
828
|
+
if (!attestation) {
|
|
829
|
+
return "NEEDS_ATTESTATION";
|
|
830
|
+
}
|
|
831
|
+
if (attestation.fingerprint !== currentFingerprint) {
|
|
832
|
+
return "FINGERPRINT_CHANGED";
|
|
833
|
+
}
|
|
834
|
+
const attestedAt = new Date(attestation.attestedAt);
|
|
835
|
+
const ageInDays = Math.floor((Date.now() - attestedAt.getTime()) / (1e3 * 60 * 60 * 24));
|
|
836
|
+
if (ageInDays > maxAgeDays) {
|
|
837
|
+
return "EXPIRED";
|
|
838
|
+
}
|
|
839
|
+
return "VALID";
|
|
840
|
+
}
|
|
841
|
+
async function getAllSuiteStatuses(config) {
|
|
842
|
+
let attestationsFile = null;
|
|
843
|
+
try {
|
|
844
|
+
attestationsFile = await readAttestations(config.settings.attestationsPath);
|
|
845
|
+
} catch (err) {
|
|
846
|
+
if (err instanceof Error && !err.message.includes("ENOENT")) {
|
|
847
|
+
throw err;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
const attestations = attestationsFile?.attestations ?? [];
|
|
851
|
+
const results = [];
|
|
852
|
+
for (const [suiteName, suiteConfig] of Object.entries(config.suites)) {
|
|
853
|
+
const fingerprintResult = await computeFingerprint({
|
|
854
|
+
packages: suiteConfig.packages,
|
|
855
|
+
...suiteConfig.ignore && { ignore: suiteConfig.ignore }
|
|
856
|
+
});
|
|
857
|
+
const attestation = findAttestation(
|
|
858
|
+
{ schemaVersion: "1", attestations, signature: "" },
|
|
859
|
+
suiteName
|
|
860
|
+
);
|
|
861
|
+
const status = determineStatus2(
|
|
862
|
+
attestation,
|
|
863
|
+
fingerprintResult.fingerprint,
|
|
864
|
+
config.settings.maxAgeDays
|
|
382
865
|
);
|
|
866
|
+
let age;
|
|
867
|
+
if (attestation) {
|
|
868
|
+
const attestedAt = new Date(attestation.attestedAt);
|
|
869
|
+
age = Math.floor((Date.now() - attestedAt.getTime()) / (1e3 * 60 * 60 * 24));
|
|
870
|
+
}
|
|
871
|
+
results.push({
|
|
872
|
+
name: suiteName,
|
|
873
|
+
status,
|
|
874
|
+
reason: formatStatusReason(status, age, config.settings.maxAgeDays),
|
|
875
|
+
currentFingerprint: fingerprintResult.fingerprint,
|
|
876
|
+
attestedFingerprint: attestation?.fingerprint,
|
|
877
|
+
attestedAt: attestation?.attestedAt,
|
|
878
|
+
age
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
return results;
|
|
882
|
+
}
|
|
883
|
+
function formatStatusReason(status, age, maxAgeDays) {
|
|
884
|
+
switch (status) {
|
|
885
|
+
case "VALID":
|
|
886
|
+
return `Attested ${String(age ?? 0)} days ago`;
|
|
887
|
+
case "NEEDS_ATTESTATION":
|
|
888
|
+
return "No attestation found";
|
|
889
|
+
case "FINGERPRINT_CHANGED":
|
|
890
|
+
return "Source files modified";
|
|
891
|
+
case "EXPIRED":
|
|
892
|
+
return `${String(age ?? 0)} days old (max: ${String(maxAgeDays ?? 30)})`;
|
|
893
|
+
case "SIGNATURE_INVALID":
|
|
894
|
+
return "Signature verification failed";
|
|
895
|
+
case "INVALIDATED_BY_PARENT":
|
|
896
|
+
return "Invalidated by parent suite";
|
|
897
|
+
default:
|
|
898
|
+
return status;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
function getSessionPath() {
|
|
902
|
+
return join(process.cwd(), ".attest-it", "session.json");
|
|
903
|
+
}
|
|
904
|
+
async function loadSession() {
|
|
905
|
+
try {
|
|
906
|
+
const content = await readFile(getSessionPath(), "utf-8");
|
|
907
|
+
const data = JSON.parse(content);
|
|
908
|
+
if (!isValidSession(data)) {
|
|
909
|
+
return null;
|
|
910
|
+
}
|
|
911
|
+
return data;
|
|
912
|
+
} catch {
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
async function saveSession(session) {
|
|
917
|
+
const sessionPath = getSessionPath();
|
|
918
|
+
const dir = dirname(sessionPath);
|
|
919
|
+
await mkdir(dir, { recursive: true });
|
|
920
|
+
await writeFile(sessionPath, JSON.stringify(session, null, 2), "utf-8");
|
|
921
|
+
}
|
|
922
|
+
async function clearSession() {
|
|
923
|
+
try {
|
|
924
|
+
await unlink(getSessionPath());
|
|
925
|
+
} catch {
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
function isValidSession(data) {
|
|
929
|
+
if (typeof data !== "object" || data === null) {
|
|
930
|
+
return false;
|
|
931
|
+
}
|
|
932
|
+
const obj = data;
|
|
933
|
+
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");
|
|
934
|
+
}
|
|
935
|
+
async function runInteractive(options) {
|
|
936
|
+
const config = await loadConfig();
|
|
937
|
+
const allSuites = await getAllSuiteStatuses(config);
|
|
938
|
+
let preSelected;
|
|
939
|
+
if (options.continue) {
|
|
940
|
+
const session = await loadSession();
|
|
941
|
+
if (session && session.remaining.length > 0) {
|
|
942
|
+
preSelected = session.remaining;
|
|
943
|
+
log(`Resuming session with ${String(preSelected.length)} remaining suite(s)`);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
if (options.dryRun) {
|
|
947
|
+
handleDryRun(allSuites, config, options.filter);
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
const pendingSuites = allSuites.filter((s) => s.status !== "VALID");
|
|
951
|
+
if (pendingSuites.length === 0) {
|
|
952
|
+
log("All suites are valid. Nothing to run.");
|
|
953
|
+
process.exit(ExitCode.NO_WORK);
|
|
954
|
+
}
|
|
955
|
+
const isDirty = await checkDirtyWorkingTree();
|
|
956
|
+
if (isDirty) {
|
|
957
|
+
error("Working tree has uncommitted changes. Please commit or stash before attesting.");
|
|
958
|
+
process.exit(ExitCode.CONFIG_ERROR);
|
|
959
|
+
}
|
|
960
|
+
const executeTest = createTestExecutor(config);
|
|
961
|
+
const createAttestationFn = createAttestationCreator(config);
|
|
962
|
+
const saveSessionFn = createSessionSaver();
|
|
963
|
+
const interactiveRunProps = {
|
|
964
|
+
allSuites,
|
|
965
|
+
config,
|
|
966
|
+
executeTest,
|
|
967
|
+
createAttestation: createAttestationFn,
|
|
968
|
+
saveSession: saveSessionFn,
|
|
969
|
+
...preSelected !== void 0 && { preSelected }
|
|
970
|
+
};
|
|
971
|
+
const { waitUntilExit } = render(/* @__PURE__ */ jsx(InteractiveRun, { ...interactiveRunProps }));
|
|
972
|
+
await waitUntilExit();
|
|
973
|
+
}
|
|
974
|
+
function handleDryRun(allSuites, config, filterPattern) {
|
|
975
|
+
let pendingSuites = allSuites.filter((s) => s.status !== "VALID");
|
|
976
|
+
if (filterPattern) {
|
|
977
|
+
const regex = new RegExp("^" + filterPattern.replace(/\*/g, ".*") + "$", "i");
|
|
978
|
+
pendingSuites = pendingSuites.filter((s) => regex.test(s.name));
|
|
979
|
+
}
|
|
980
|
+
if (pendingSuites.length === 0) {
|
|
981
|
+
log("No suites would run (all valid or filtered out).");
|
|
982
|
+
process.exit(ExitCode.NO_WORK);
|
|
983
|
+
}
|
|
984
|
+
log(`Would run ${String(pendingSuites.length)} suite(s):`);
|
|
985
|
+
pendingSuites.forEach((s, i) => {
|
|
986
|
+
log(` ${String(i + 1)}. ${s.name} (${s.status})`);
|
|
987
|
+
});
|
|
988
|
+
log("");
|
|
989
|
+
log("Use `attest-it run` to execute.");
|
|
990
|
+
process.exit(ExitCode.SUCCESS);
|
|
991
|
+
}
|
|
992
|
+
function createTestExecutor(config) {
|
|
993
|
+
return async (suiteName) => {
|
|
994
|
+
const suiteConfig = config.suites[suiteName];
|
|
995
|
+
if (!suiteConfig) {
|
|
996
|
+
error(`Suite "${suiteName}" not found in config`);
|
|
997
|
+
return false;
|
|
998
|
+
}
|
|
999
|
+
let command = suiteConfig.command ?? config.settings.defaultCommand;
|
|
1000
|
+
if (!command) {
|
|
1001
|
+
error(`No command specified for suite "${suiteName}"`);
|
|
1002
|
+
return false;
|
|
1003
|
+
}
|
|
1004
|
+
if (command.includes("${files}") && suiteConfig.files) {
|
|
1005
|
+
command = command.replaceAll("${files}", suiteConfig.files.join(" "));
|
|
1006
|
+
}
|
|
1007
|
+
log(`Running: ${command}`);
|
|
1008
|
+
const exitCode = await executeCommand(command);
|
|
1009
|
+
return exitCode === 0;
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
function createAttestationCreator(config) {
|
|
1013
|
+
return async (suiteName) => {
|
|
1014
|
+
const suiteConfig = config.suites[suiteName];
|
|
1015
|
+
if (!suiteConfig) {
|
|
1016
|
+
throw new Error(`Suite "${suiteName}" not found`);
|
|
1017
|
+
}
|
|
1018
|
+
const fingerprintResult = await computeFingerprint({
|
|
1019
|
+
packages: suiteConfig.packages,
|
|
1020
|
+
...suiteConfig.ignore && { ignore: suiteConfig.ignore }
|
|
1021
|
+
});
|
|
1022
|
+
const attestation = createAttestation({
|
|
1023
|
+
suite: suiteName,
|
|
1024
|
+
fingerprint: fingerprintResult.fingerprint,
|
|
1025
|
+
command: suiteConfig.command ?? config.settings.defaultCommand ?? "",
|
|
1026
|
+
attestedBy: os.userInfo().username
|
|
1027
|
+
});
|
|
1028
|
+
const attestationsPath = config.settings.attestationsPath;
|
|
1029
|
+
const existingFile = await readAttestations(attestationsPath).catch(() => null);
|
|
1030
|
+
const existingAttestations = existingFile?.attestations ?? [];
|
|
1031
|
+
const newAttestations = upsertAttestation(existingAttestations, attestation);
|
|
1032
|
+
const privateKeyPath = getDefaultPrivateKeyPath();
|
|
1033
|
+
if (!fs.existsSync(privateKeyPath)) {
|
|
1034
|
+
throw new Error(`Private key not found: ${privateKeyPath}. Run "attest-it keygen" first.`);
|
|
1035
|
+
}
|
|
1036
|
+
await writeSignedAttestations({
|
|
1037
|
+
filePath: attestationsPath,
|
|
1038
|
+
attestations: newAttestations,
|
|
1039
|
+
privateKeyPath
|
|
1040
|
+
});
|
|
1041
|
+
log(`\u2713 Attestation created for ${suiteName}`);
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
function createSessionSaver() {
|
|
1045
|
+
return async (completed, failed, remaining) => {
|
|
1046
|
+
if (completed.length === 0 && failed.length === 0 && remaining.length === 0) {
|
|
1047
|
+
await clearSession();
|
|
1048
|
+
} else {
|
|
1049
|
+
await saveSession({
|
|
1050
|
+
started: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1051
|
+
selected: [...completed, ...failed, ...remaining],
|
|
1052
|
+
completed,
|
|
1053
|
+
failed,
|
|
1054
|
+
remaining
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
async function executeCommand(command) {
|
|
1060
|
+
return new Promise((resolve2) => {
|
|
1061
|
+
const parsed = parse(command);
|
|
1062
|
+
const stringArgs = parsed.filter((t) => typeof t === "string");
|
|
1063
|
+
if (stringArgs.length === 0) {
|
|
1064
|
+
error("Empty command");
|
|
1065
|
+
resolve2(1);
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
const [executable, ...args] = stringArgs;
|
|
1069
|
+
if (!executable) {
|
|
1070
|
+
resolve2(1);
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
const child = spawn(executable, args, { stdio: "inherit" });
|
|
1074
|
+
child.on("close", (code) => {
|
|
1075
|
+
resolve2(code ?? 1);
|
|
1076
|
+
});
|
|
1077
|
+
child.on("error", (err) => {
|
|
1078
|
+
error(`Command failed: ${err.message}`);
|
|
1079
|
+
resolve2(1);
|
|
1080
|
+
});
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
async function checkDirtyWorkingTree() {
|
|
1084
|
+
return new Promise((resolve2) => {
|
|
1085
|
+
const child = spawn("git", ["status", "--porcelain"], {
|
|
1086
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1087
|
+
});
|
|
1088
|
+
let output = "";
|
|
1089
|
+
child.stdout.on("data", (data) => {
|
|
1090
|
+
output += data.toString();
|
|
1091
|
+
});
|
|
1092
|
+
child.on("close", () => {
|
|
1093
|
+
resolve2(output.trim().length > 0);
|
|
1094
|
+
});
|
|
1095
|
+
child.on("error", () => {
|
|
1096
|
+
resolve2(false);
|
|
1097
|
+
});
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// src/commands/run.ts
|
|
1102
|
+
var runCommand = new 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) => {
|
|
1103
|
+
await runTests(options);
|
|
1104
|
+
});
|
|
1105
|
+
async function runTests(options) {
|
|
1106
|
+
try {
|
|
1107
|
+
if (options.suite) {
|
|
1108
|
+
await runDirectMode(options);
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
if (options.all) {
|
|
1112
|
+
await runAllPending(options);
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
await runInteractive({
|
|
1116
|
+
dryRun: options.dryRun,
|
|
1117
|
+
continue: options.continue,
|
|
1118
|
+
filter: options.filter
|
|
1119
|
+
});
|
|
383
1120
|
} catch (err) {
|
|
384
1121
|
if (err instanceof Error) {
|
|
385
1122
|
error(err.message);
|
|
@@ -415,7 +1152,7 @@ function parseCommand(command) {
|
|
|
415
1152
|
}
|
|
416
1153
|
return { executable, args };
|
|
417
1154
|
}
|
|
418
|
-
async function
|
|
1155
|
+
async function executeCommand2(command) {
|
|
419
1156
|
return new Promise((resolve2) => {
|
|
420
1157
|
let parsed;
|
|
421
1158
|
try {
|
|
@@ -442,7 +1179,7 @@ async function executeCommand(command) {
|
|
|
442
1179
|
});
|
|
443
1180
|
});
|
|
444
1181
|
}
|
|
445
|
-
async function
|
|
1182
|
+
async function checkDirtyWorkingTree2() {
|
|
446
1183
|
return new Promise((resolve2) => {
|
|
447
1184
|
const child = spawn("git", ["status", "--porcelain"], {
|
|
448
1185
|
stdio: ["ignore", "pipe", "pipe"]
|
|
@@ -459,6 +1196,131 @@ async function checkDirtyWorkingTree() {
|
|
|
459
1196
|
});
|
|
460
1197
|
});
|
|
461
1198
|
}
|
|
1199
|
+
async function runDirectMode(options) {
|
|
1200
|
+
if (!options.suite) {
|
|
1201
|
+
error("Suite name is required for direct mode");
|
|
1202
|
+
process.exit(ExitCode.CONFIG_ERROR);
|
|
1203
|
+
}
|
|
1204
|
+
const config = await loadConfig();
|
|
1205
|
+
if (!config.suites[options.suite]) {
|
|
1206
|
+
error(`Suite "${options.suite}" not found in config`);
|
|
1207
|
+
process.exit(ExitCode.CONFIG_ERROR);
|
|
1208
|
+
}
|
|
1209
|
+
const isDirty = await checkDirtyWorkingTree2();
|
|
1210
|
+
if (isDirty) {
|
|
1211
|
+
error("Working tree has uncommitted changes. Please commit or stash before attesting.");
|
|
1212
|
+
process.exit(ExitCode.CONFIG_ERROR);
|
|
1213
|
+
}
|
|
1214
|
+
await runSingleSuite(options.suite, config, options);
|
|
1215
|
+
log("");
|
|
1216
|
+
success("Suite completed!");
|
|
1217
|
+
log(
|
|
1218
|
+
`
|
|
1219
|
+
To commit: git add ${config.settings.attestationsPath} && git commit -m "Update attestations"`
|
|
1220
|
+
);
|
|
1221
|
+
}
|
|
1222
|
+
async function runAllPending(options) {
|
|
1223
|
+
const config = await loadConfig();
|
|
1224
|
+
const allSuites = await getAllSuiteStatuses(config);
|
|
1225
|
+
const pendingSuites = allSuites.filter((s) => s.status !== "VALID");
|
|
1226
|
+
if (pendingSuites.length === 0) {
|
|
1227
|
+
log("All suites are valid. Nothing to run.");
|
|
1228
|
+
process.exit(ExitCode.NO_WORK);
|
|
1229
|
+
}
|
|
1230
|
+
let suitesToRun = pendingSuites;
|
|
1231
|
+
if (options.filter) {
|
|
1232
|
+
const regex = new RegExp("^" + options.filter.replace(/\*/g, ".*") + "$", "i");
|
|
1233
|
+
suitesToRun = pendingSuites.filter((s) => regex.test(s.name));
|
|
1234
|
+
if (suitesToRun.length === 0) {
|
|
1235
|
+
log(`No suites match filter: ${options.filter}`);
|
|
1236
|
+
process.exit(ExitCode.NO_WORK);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
if (options.dryRun) {
|
|
1240
|
+
log(`Would run ${String(suitesToRun.length)} suite(s):`);
|
|
1241
|
+
suitesToRun.forEach((s, i) => {
|
|
1242
|
+
log(` ${String(i + 1)}. ${s.name} (${s.status})`);
|
|
1243
|
+
});
|
|
1244
|
+
process.exit(ExitCode.SUCCESS);
|
|
1245
|
+
}
|
|
1246
|
+
const isDirty = await checkDirtyWorkingTree2();
|
|
1247
|
+
if (isDirty) {
|
|
1248
|
+
error("Working tree has uncommitted changes. Please commit or stash before attesting.");
|
|
1249
|
+
process.exit(ExitCode.CONFIG_ERROR);
|
|
1250
|
+
}
|
|
1251
|
+
for (const suite of suitesToRun) {
|
|
1252
|
+
await runSingleSuite(suite.name, config, options);
|
|
1253
|
+
}
|
|
1254
|
+
log("");
|
|
1255
|
+
success("All suites completed!");
|
|
1256
|
+
log(
|
|
1257
|
+
`
|
|
1258
|
+
To commit: git add ${config.settings.attestationsPath} && git commit -m "Update attestations"`
|
|
1259
|
+
);
|
|
1260
|
+
}
|
|
1261
|
+
async function runSingleSuite(suiteName, config, options) {
|
|
1262
|
+
const suiteConfig = config.suites[suiteName];
|
|
1263
|
+
if (!suiteConfig) {
|
|
1264
|
+
error(`Suite "${suiteName}" not found in config`);
|
|
1265
|
+
process.exit(ExitCode.CONFIG_ERROR);
|
|
1266
|
+
}
|
|
1267
|
+
log(`
|
|
1268
|
+
=== Running suite: ${suiteName} ===
|
|
1269
|
+
`);
|
|
1270
|
+
const fingerprintOptions = {
|
|
1271
|
+
packages: suiteConfig.packages,
|
|
1272
|
+
...suiteConfig.ignore && { ignore: suiteConfig.ignore }
|
|
1273
|
+
};
|
|
1274
|
+
const fingerprintResult = await computeFingerprint(fingerprintOptions);
|
|
1275
|
+
verbose(`Fingerprint: ${fingerprintResult.fingerprint}`);
|
|
1276
|
+
verbose(`Files: ${String(fingerprintResult.fileCount)}`);
|
|
1277
|
+
const command = buildCommand(config, suiteConfig.command, suiteConfig.files);
|
|
1278
|
+
log(`Running: ${command}`);
|
|
1279
|
+
log("");
|
|
1280
|
+
const exitCode = await executeCommand2(command);
|
|
1281
|
+
if (exitCode !== 0) {
|
|
1282
|
+
error(`Tests failed with exit code ${String(exitCode)}`);
|
|
1283
|
+
process.exit(ExitCode.FAILURE);
|
|
1284
|
+
}
|
|
1285
|
+
success("Tests passed!");
|
|
1286
|
+
if (options.attest === false) {
|
|
1287
|
+
log("Skipping attestation (--no-attest)");
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
const shouldAttest = options.yes ?? await confirmAction({
|
|
1291
|
+
message: "Create attestation?",
|
|
1292
|
+
default: true
|
|
1293
|
+
});
|
|
1294
|
+
if (!shouldAttest) {
|
|
1295
|
+
warn("Attestation cancelled");
|
|
1296
|
+
process.exit(ExitCode.CANCELLED);
|
|
1297
|
+
}
|
|
1298
|
+
const attestation = createAttestation({
|
|
1299
|
+
suite: suiteName,
|
|
1300
|
+
fingerprint: fingerprintResult.fingerprint,
|
|
1301
|
+
command,
|
|
1302
|
+
attestedBy: os.userInfo().username
|
|
1303
|
+
});
|
|
1304
|
+
const attestationsPath = config.settings.attestationsPath;
|
|
1305
|
+
const existingFile = await readAttestations(attestationsPath);
|
|
1306
|
+
const existingAttestations = existingFile?.attestations ?? [];
|
|
1307
|
+
const newAttestations = upsertAttestation(existingAttestations, attestation);
|
|
1308
|
+
const privateKeyPath = getDefaultPrivateKeyPath();
|
|
1309
|
+
if (!fs.existsSync(privateKeyPath)) {
|
|
1310
|
+
error(`Private key not found: ${privateKeyPath}`);
|
|
1311
|
+
error('Run "attest-it keygen" first to generate a keypair.');
|
|
1312
|
+
process.exit(ExitCode.MISSING_KEY);
|
|
1313
|
+
}
|
|
1314
|
+
await writeSignedAttestations({
|
|
1315
|
+
filePath: attestationsPath,
|
|
1316
|
+
attestations: newAttestations,
|
|
1317
|
+
privateKeyPath
|
|
1318
|
+
});
|
|
1319
|
+
success(`Attestation created for ${suiteName}`);
|
|
1320
|
+
log(` Fingerprint: ${fingerprintResult.fingerprint}`);
|
|
1321
|
+
log(` Attested by: ${attestation.attestedBy}`);
|
|
1322
|
+
log(` Attested at: ${attestation.attestedAt}`);
|
|
1323
|
+
}
|
|
462
1324
|
var keygenCommand = new 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) => {
|
|
463
1325
|
await runKeygen(options);
|
|
464
1326
|
});
|
|
@@ -607,7 +1469,7 @@ async function runPrune(options) {
|
|
|
607
1469
|
} else {
|
|
608
1470
|
error("Unknown error occurred");
|
|
609
1471
|
}
|
|
610
|
-
process.exit(
|
|
1472
|
+
process.exit(ExitCode.CONFIG_ERROR);
|
|
611
1473
|
return;
|
|
612
1474
|
}
|
|
613
1475
|
}
|
|
@@ -668,7 +1530,7 @@ async function runVerify(options) {
|
|
|
668
1530
|
} else {
|
|
669
1531
|
error("Unknown error occurred");
|
|
670
1532
|
}
|
|
671
|
-
process.exit(
|
|
1533
|
+
process.exit(ExitCode.CONFIG_ERROR);
|
|
672
1534
|
}
|
|
673
1535
|
}
|
|
674
1536
|
function displayResults(result, maxAgeDays, strict) {
|
|
@@ -744,17 +1606,51 @@ function hasWarnings(result, maxAgeDays) {
|
|
|
744
1606
|
(s) => s.status === "VALID" && (s.age ?? 0) > maxAgeDays - warningThreshold
|
|
745
1607
|
);
|
|
746
1608
|
}
|
|
747
|
-
|
|
748
|
-
//
|
|
1609
|
+
function hasVersion(data) {
|
|
1610
|
+
return typeof data === "object" && data !== null && "version" in data && // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
1611
|
+
typeof data.version === "string";
|
|
1612
|
+
}
|
|
1613
|
+
var cachedVersion;
|
|
1614
|
+
function getPackageVersion() {
|
|
1615
|
+
if (cachedVersion !== void 0) {
|
|
1616
|
+
return cachedVersion;
|
|
1617
|
+
}
|
|
1618
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
1619
|
+
const __dirname = dirname(__filename);
|
|
1620
|
+
const possiblePaths = [join(__dirname, "../package.json"), join(__dirname, "../../package.json")];
|
|
1621
|
+
for (const packageJsonPath of possiblePaths) {
|
|
1622
|
+
try {
|
|
1623
|
+
const content = readFileSync(packageJsonPath, "utf-8");
|
|
1624
|
+
const packageJsonData = JSON.parse(content);
|
|
1625
|
+
if (!hasVersion(packageJsonData)) {
|
|
1626
|
+
throw new Error(`Invalid package.json at ${packageJsonPath}: missing version field`);
|
|
1627
|
+
}
|
|
1628
|
+
cachedVersion = packageJsonData.version;
|
|
1629
|
+
return cachedVersion;
|
|
1630
|
+
} catch (error2) {
|
|
1631
|
+
if (error2 instanceof Error && "code" in error2 && error2.code === "ENOENT") {
|
|
1632
|
+
continue;
|
|
1633
|
+
}
|
|
1634
|
+
throw error2;
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
throw new Error("Could not find package.json");
|
|
1638
|
+
}
|
|
749
1639
|
var program = new Command();
|
|
750
|
-
program.name("attest-it").description("Human-gated test attestation system").
|
|
1640
|
+
program.name("attest-it").description("Human-gated test attestation system").option("-c, --config <path>", "Path to config file").option("-v, --verbose", "Verbose output").option("-q, --quiet", "Minimal output");
|
|
1641
|
+
program.option("-V, --version", "output the version number");
|
|
751
1642
|
program.addCommand(initCommand);
|
|
752
1643
|
program.addCommand(statusCommand);
|
|
753
1644
|
program.addCommand(runCommand);
|
|
754
1645
|
program.addCommand(keygenCommand);
|
|
755
1646
|
program.addCommand(pruneCommand);
|
|
756
1647
|
program.addCommand(verifyCommand);
|
|
757
|
-
function run() {
|
|
1648
|
+
async function run() {
|
|
1649
|
+
if (process.argv.includes("--version") || process.argv.includes("-V")) {
|
|
1650
|
+
console.log(getPackageVersion());
|
|
1651
|
+
process.exit(0);
|
|
1652
|
+
}
|
|
1653
|
+
await initTheme();
|
|
758
1654
|
program.parse();
|
|
759
1655
|
const options = program.opts();
|
|
760
1656
|
const outputOptions = {};
|
|
@@ -768,6 +1664,6 @@ function run() {
|
|
|
768
1664
|
}
|
|
769
1665
|
|
|
770
1666
|
// bin/attest-it.ts
|
|
771
|
-
run();
|
|
1667
|
+
void run();
|
|
772
1668
|
//# sourceMappingURL=attest-it.js.map
|
|
773
1669
|
//# sourceMappingURL=attest-it.js.map
|