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