@enactprotocol/cli 1.2.13 → 2.0.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.
Files changed (73) hide show
  1. package/README.md +88 -0
  2. package/package.json +34 -38
  3. package/src/commands/auth/index.ts +940 -0
  4. package/src/commands/cache/index.ts +361 -0
  5. package/src/commands/config/README.md +239 -0
  6. package/src/commands/config/index.ts +164 -0
  7. package/src/commands/env/README.md +197 -0
  8. package/src/commands/env/index.ts +392 -0
  9. package/src/commands/exec/README.md +110 -0
  10. package/src/commands/exec/index.ts +195 -0
  11. package/src/commands/get/index.ts +198 -0
  12. package/src/commands/index.ts +30 -0
  13. package/src/commands/inspect/index.ts +264 -0
  14. package/src/commands/install/README.md +146 -0
  15. package/src/commands/install/index.ts +682 -0
  16. package/src/commands/list/README.md +115 -0
  17. package/src/commands/list/index.ts +138 -0
  18. package/src/commands/publish/index.ts +350 -0
  19. package/src/commands/report/index.ts +366 -0
  20. package/src/commands/run/README.md +124 -0
  21. package/src/commands/run/index.ts +686 -0
  22. package/src/commands/search/index.ts +368 -0
  23. package/src/commands/setup/index.ts +274 -0
  24. package/src/commands/sign/index.ts +652 -0
  25. package/src/commands/trust/README.md +214 -0
  26. package/src/commands/trust/index.ts +453 -0
  27. package/src/commands/unyank/index.ts +107 -0
  28. package/src/commands/yank/index.ts +143 -0
  29. package/src/index.ts +96 -0
  30. package/src/types.ts +81 -0
  31. package/src/utils/errors.ts +409 -0
  32. package/src/utils/exit-codes.ts +159 -0
  33. package/src/utils/ignore.ts +147 -0
  34. package/src/utils/index.ts +107 -0
  35. package/src/utils/output.ts +242 -0
  36. package/src/utils/spinner.ts +214 -0
  37. package/tests/commands/auth.test.ts +217 -0
  38. package/tests/commands/cache.test.ts +286 -0
  39. package/tests/commands/config.test.ts +277 -0
  40. package/tests/commands/env.test.ts +293 -0
  41. package/tests/commands/exec.test.ts +112 -0
  42. package/tests/commands/get.test.ts +179 -0
  43. package/tests/commands/inspect.test.ts +201 -0
  44. package/tests/commands/install-integration.test.ts +343 -0
  45. package/tests/commands/install.test.ts +288 -0
  46. package/tests/commands/list.test.ts +160 -0
  47. package/tests/commands/publish.test.ts +186 -0
  48. package/tests/commands/report.test.ts +194 -0
  49. package/tests/commands/run.test.ts +231 -0
  50. package/tests/commands/search.test.ts +131 -0
  51. package/tests/commands/sign.test.ts +164 -0
  52. package/tests/commands/trust.test.ts +236 -0
  53. package/tests/commands/unyank.test.ts +114 -0
  54. package/tests/commands/yank.test.ts +154 -0
  55. package/tests/e2e.test.ts +554 -0
  56. package/tests/fixtures/calculator/enact.yaml +34 -0
  57. package/tests/fixtures/echo-tool/enact.md +31 -0
  58. package/tests/fixtures/env-tool/enact.yaml +19 -0
  59. package/tests/fixtures/greeter/enact.yaml +18 -0
  60. package/tests/fixtures/invalid-tool/enact.yaml +4 -0
  61. package/tests/index.test.ts +8 -0
  62. package/tests/types.test.ts +84 -0
  63. package/tests/utils/errors.test.ts +303 -0
  64. package/tests/utils/exit-codes.test.ts +189 -0
  65. package/tests/utils/ignore.test.ts +461 -0
  66. package/tests/utils/output.test.ts +126 -0
  67. package/tsconfig.json +17 -0
  68. package/tsconfig.tsbuildinfo +1 -0
  69. package/dist/index.js +0 -231612
  70. package/dist/index.js.bak +0 -231611
  71. package/dist/web/static/app.js +0 -663
  72. package/dist/web/static/index.html +0 -117
  73. package/dist/web/static/style.css +0 -291
@@ -0,0 +1,652 @@
1
+ /**
2
+ * enact sign command
3
+ *
4
+ * Cryptographically sign a tool using Sigstore keyless signing.
5
+ * Creates an in-toto attestation, logs to Rekor transparency log,
6
+ * and submits the attestation to the Enact registry.
7
+ *
8
+ * Supports both local paths and remote tool references:
9
+ * - Local: enact sign ./my-tool
10
+ * - Remote: enact sign author/tool@1.0.0
11
+ */
12
+
13
+ import { readFileSync, writeFileSync } from "node:fs";
14
+ import { dirname, join, resolve } from "node:path";
15
+ import {
16
+ createApiClient,
17
+ getToolVersion,
18
+ submitAttestation as submitAttestationToRegistry,
19
+ } from "@enactprotocol/api";
20
+ import { getSecret } from "@enactprotocol/secrets";
21
+ import {
22
+ addTrustedAuditor,
23
+ emailToProviderIdentity,
24
+ getTrustedAuditors,
25
+ loadConfig,
26
+ loadManifestFromDir,
27
+ tryLoadManifest,
28
+ validateManifest,
29
+ } from "@enactprotocol/shared";
30
+ import {
31
+ type EnactToolAttestationOptions,
32
+ type SigstoreBundle,
33
+ createEnactToolStatement,
34
+ signAttestation,
35
+ } from "@enactprotocol/trust";
36
+ import type { Command } from "commander";
37
+ import type { CommandContext, GlobalOptions } from "../../types";
38
+ import {
39
+ colors,
40
+ confirm,
41
+ dim,
42
+ error,
43
+ formatError,
44
+ info,
45
+ json,
46
+ keyValue,
47
+ newline,
48
+ success,
49
+ symbols,
50
+ warning,
51
+ withSpinner,
52
+ } from "../../utils";
53
+
54
+ /** Auth namespace for token storage */
55
+ const AUTH_NAMESPACE = "enact:auth";
56
+ const ACCESS_TOKEN_KEY = "access_token";
57
+
58
+ interface SignOptions extends GlobalOptions {
59
+ identity?: string;
60
+ output?: string;
61
+ dryRun?: boolean;
62
+ local?: boolean;
63
+ }
64
+
65
+ /** Default output filename for the signature bundle */
66
+ const DEFAULT_BUNDLE_FILENAME = ".sigstore-bundle.json";
67
+
68
+ /**
69
+ * Parse a remote tool reference like "author/tool@1.0.0"
70
+ * Returns null if not a valid remote reference
71
+ */
72
+ function parseRemoteToolRef(ref: string): { name: string; version: string } | null {
73
+ // Remote refs look like: author/tool@version or org/author/tool@version
74
+ // They don't start with . or / and contain @ for version
75
+ if (ref.startsWith(".") || ref.startsWith("/") || ref.startsWith("~")) {
76
+ return null;
77
+ }
78
+
79
+ const atIndex = ref.lastIndexOf("@");
80
+ if (atIndex === -1 || atIndex === 0) {
81
+ return null;
82
+ }
83
+
84
+ const name = ref.substring(0, atIndex);
85
+ const version = ref.substring(atIndex + 1);
86
+
87
+ // Must have at least one / in the name (author/tool)
88
+ if (!name.includes("/") || !version) {
89
+ return null;
90
+ }
91
+
92
+ return { name, version };
93
+ }
94
+
95
+ /**
96
+ * Find the manifest file in a directory or at a path
97
+ */
98
+ function findManifestPath(pathArg: string): { manifestPath: string; manifestDir: string } {
99
+ const absolutePath = resolve(pathArg);
100
+
101
+ // Check if it's a directory or file
102
+ try {
103
+ // Try loading from directory first
104
+ const loaded = loadManifestFromDir(absolutePath);
105
+ return {
106
+ manifestPath: loaded.filePath,
107
+ manifestDir: absolutePath,
108
+ };
109
+ } catch {
110
+ // Try as a direct file path
111
+ const loaded = tryLoadManifest(absolutePath);
112
+ if (loaded) {
113
+ return {
114
+ manifestPath: absolutePath,
115
+ manifestDir: dirname(absolutePath),
116
+ };
117
+ }
118
+ throw new Error(`No manifest found at: ${pathArg}`);
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Display signing preview (dry run)
124
+ */
125
+ function displayDryRun(
126
+ manifestPath: string,
127
+ manifest: { name: string; version?: string; description?: string },
128
+ outputPath: string,
129
+ options: SignOptions
130
+ ): void {
131
+ newline();
132
+ info(colors.bold("Dry Run Preview - Signing"));
133
+ newline();
134
+
135
+ keyValue("Tool", manifest.name);
136
+ keyValue("Version", manifest.version ?? "unversioned");
137
+ keyValue("Manifest", manifestPath);
138
+ keyValue("Output", outputPath);
139
+ keyValue("Submit to registry", options.local ? "No (local only)" : "Yes");
140
+ newline();
141
+
142
+ info("Actions that would be performed:");
143
+ dim(" 1. Authenticate via OIDC (browser-based OAuth flow)");
144
+ dim(" 2. Create in-toto attestation for tool manifest");
145
+ dim(" 3. Request signing certificate from Fulcio");
146
+ dim(" 4. Sign attestation with ephemeral keypair");
147
+ dim(" 5. Log signature to Rekor transparency log");
148
+ dim(` 6. Write bundle to ${outputPath}`);
149
+ if (!options.local) {
150
+ dim(" 7. Submit attestation to Enact registry");
151
+ }
152
+ newline();
153
+
154
+ warning("Note: Actual signing requires OIDC authentication.");
155
+ dim("You will be prompted to authenticate in your browser.");
156
+ }
157
+
158
+ /**
159
+ * Prompt user to add themselves to trusted auditors list (local config)
160
+ */
161
+ async function promptAddToTrustList(
162
+ auditorEmail: string,
163
+ isInteractive: boolean
164
+ ): Promise<boolean> {
165
+ if (!isInteractive) {
166
+ return false;
167
+ }
168
+
169
+ try {
170
+ // Convert email to provider:identity format (e.g., github:alice)
171
+ const providerIdentity = emailToProviderIdentity(auditorEmail);
172
+
173
+ // Check if already in local trust list
174
+ const trustedAuditors = getTrustedAuditors();
175
+ if (trustedAuditors.includes(providerIdentity)) {
176
+ // Already trusted
177
+ return false;
178
+ }
179
+
180
+ newline();
181
+ info(colors.command("Trust Configuration"));
182
+ newline();
183
+ dim(`You signed this tool with: ${colors.bold(auditorEmail)}`);
184
+ dim(`Identity format: ${colors.bold(providerIdentity)}`);
185
+ dim("This identity is not currently in your local trusted auditors list.");
186
+ newline();
187
+
188
+ const shouldAdd = await confirm(
189
+ "Would you like to add this identity to ~/.enact/config.yaml?",
190
+ true
191
+ );
192
+
193
+ if (!shouldAdd) {
194
+ return false;
195
+ }
196
+
197
+ // Add to local config file
198
+ const added = addTrustedAuditor(providerIdentity);
199
+
200
+ if (added) {
201
+ newline();
202
+ success(`Added ${providerIdentity} to ~/.enact/config.yaml`);
203
+ dim("This tool (and others you sign) will now be automatically trusted");
204
+ return true;
205
+ }
206
+
207
+ return false;
208
+ } catch (err) {
209
+ // Silently fail if trust update fails - don't block signing
210
+ if (err instanceof Error) {
211
+ dim(`Note: Could not update trust list: ${err.message}`);
212
+ }
213
+ return false;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Display signing result
219
+ */
220
+ function displayResult(
221
+ bundle: SigstoreBundle,
222
+ outputPath: string,
223
+ manifest: { name: string; version?: string },
224
+ options: SignOptions,
225
+ registryResult?: { auditor: string; rekorLogIndex: number | undefined }
226
+ ): void {
227
+ if (options.json) {
228
+ json({
229
+ success: true,
230
+ tool: manifest.name,
231
+ version: manifest.version ?? "unversioned",
232
+ bundlePath: outputPath,
233
+ bundle,
234
+ registry: registryResult
235
+ ? {
236
+ submitted: true,
237
+ auditor: registryResult.auditor,
238
+ rekorLogIndex: registryResult.rekorLogIndex,
239
+ }
240
+ : { submitted: false },
241
+ });
242
+ return;
243
+ }
244
+
245
+ newline();
246
+ success(`Successfully signed ${manifest.name}@${manifest.version ?? "unversioned"}`);
247
+ newline();
248
+
249
+ keyValue("Bundle saved to", outputPath);
250
+
251
+ // Show some bundle details
252
+ if (bundle.verificationMaterial?.tlogEntries?.[0]) {
253
+ const entry = bundle.verificationMaterial.tlogEntries[0];
254
+ if (entry.logIndex !== undefined) {
255
+ keyValue("Rekor log index", String(entry.logIndex));
256
+ }
257
+ }
258
+
259
+ // Show registry submission result
260
+ if (registryResult) {
261
+ newline();
262
+ success("Attestation submitted to registry");
263
+ keyValue("Auditor identity", registryResult.auditor);
264
+ } else if (!options.local) {
265
+ newline();
266
+ warning("Attestation was not submitted to registry (use --local to suppress this warning)");
267
+ }
268
+
269
+ newline();
270
+ if (options.local) {
271
+ info("Note: Attestation saved locally only (--local flag)");
272
+ dim(" • Run 'enact sign .' without --local to submit to registry");
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Sign a remote tool from the registry
278
+ */
279
+ async function signRemoteTool(
280
+ toolRef: { name: string; version: string },
281
+ options: SignOptions,
282
+ _ctx: CommandContext
283
+ ): Promise<void> {
284
+ const config = loadConfig();
285
+ const registryUrl =
286
+ process.env.ENACT_REGISTRY_URL ??
287
+ config.registry?.url ??
288
+ "https://siikwkfgsmouioodghho.supabase.co/functions/v1";
289
+ const client = createApiClient({ baseUrl: registryUrl });
290
+
291
+ // Fetch tool info from registry
292
+ info(`Fetching ${toolRef.name}@${toolRef.version} from registry...`);
293
+
294
+ let toolInfo: Awaited<ReturnType<typeof getToolVersion>>;
295
+ try {
296
+ toolInfo = await getToolVersion(client, toolRef.name, toolRef.version);
297
+ } catch (err) {
298
+ error(`Tool not found: ${toolRef.name}@${toolRef.version}`);
299
+ if (err instanceof Error) {
300
+ dim(` ${err.message}`);
301
+ }
302
+ process.exit(1);
303
+ }
304
+
305
+ newline();
306
+ keyValue("Tool", toolInfo.name);
307
+ keyValue("Version", toolInfo.version);
308
+ keyValue("Bundle hash", toolInfo.bundle.hash);
309
+ keyValue("Published by", toolInfo.publishedBy.username);
310
+
311
+ // Show existing attestations
312
+ if (toolInfo.attestations.length > 0) {
313
+ newline();
314
+ info("Existing attestations:");
315
+ for (const att of toolInfo.attestations) {
316
+ dim(` • ${att.auditor} (${att.auditorProvider})`);
317
+ }
318
+ }
319
+
320
+ // Dry run mode
321
+ if (options.dryRun) {
322
+ newline();
323
+ info(colors.bold("Dry Run - Would perform:"));
324
+ dim(" 1. Authenticate via OIDC (browser-based OAuth flow)");
325
+ dim(" 2. Create in-toto attestation for bundle hash");
326
+ dim(" 3. Request signing certificate from Fulcio");
327
+ dim(" 4. Sign attestation with ephemeral keypair");
328
+ dim(" 5. Log signature to Rekor transparency log");
329
+ dim(" 6. Submit attestation to registry");
330
+ newline();
331
+ warning("Note: Actual signing requires OIDC authentication.");
332
+ return;
333
+ }
334
+
335
+ // Check auth before doing anything - remote signing always submits to registry
336
+ const authToken = await getSecret(AUTH_NAMESPACE, ACCESS_TOKEN_KEY);
337
+ if (!authToken) {
338
+ error("Not authenticated with registry");
339
+ dim("Run 'enact auth login' to authenticate before signing remote tools");
340
+ process.exit(1);
341
+ }
342
+
343
+ // Confirm signing
344
+ if (_ctx.isInteractive) {
345
+ newline();
346
+ const shouldSign = await confirm(
347
+ `Sign ${toolInfo.name}@${toolInfo.version} with your identity?`,
348
+ true
349
+ );
350
+ if (!shouldSign) {
351
+ info("Signing cancelled");
352
+ return;
353
+ }
354
+ }
355
+
356
+ // Sign the attestation (using bundle hash as the artifact)
357
+ const attestationOptions: EnactToolAttestationOptions = {
358
+ name: toolInfo.name,
359
+ version: toolInfo.version,
360
+ publisher: options.identity ?? "unknown",
361
+ description: toolInfo.description,
362
+ buildTimestamp: new Date(),
363
+ bundleHash: toolInfo.bundle.hash,
364
+ };
365
+
366
+ // Create the in-toto statement - use bundle hash as the "content" for remote tools
367
+ const statement = createEnactToolStatement(toolInfo.bundle.hash, attestationOptions);
368
+
369
+ // Sign it
370
+ const result = await withSpinner("Signing attestation...", async () => {
371
+ try {
372
+ return await signAttestation(statement as unknown as Record<string, unknown>, {
373
+ timeout: 120000, // 2 minutes for OIDC flow
374
+ });
375
+ } catch (err) {
376
+ if (err instanceof Error && err.message.includes("cancelled")) {
377
+ throw new Error("Signing cancelled by user");
378
+ }
379
+ throw err;
380
+ }
381
+ });
382
+
383
+ // Submit to registry
384
+ client.setAuthToken(authToken);
385
+
386
+ try {
387
+ const attestationResult = await withSpinner(
388
+ "Submitting attestation to registry...",
389
+ async () => {
390
+ return await submitAttestationToRegistry(client, {
391
+ name: toolInfo.name,
392
+ version: toolInfo.version,
393
+ sigstoreBundle: result.bundle as unknown as Record<string, unknown>,
394
+ });
395
+ }
396
+ );
397
+
398
+ newline();
399
+ success(`Signed ${toolInfo.name}@${toolInfo.version}`);
400
+ keyValue("Auditor identity", attestationResult.auditor);
401
+ if (attestationResult.rekorLogIndex) {
402
+ keyValue("Rekor log index", String(attestationResult.rekorLogIndex));
403
+ }
404
+
405
+ // Prompt to add to trust list
406
+ if (_ctx.isInteractive && !options.json) {
407
+ await promptAddToTrustList(attestationResult.auditor, _ctx.isInteractive);
408
+ }
409
+
410
+ if (options.json) {
411
+ json({
412
+ success: true,
413
+ tool: toolInfo.name,
414
+ version: toolInfo.version,
415
+ auditor: attestationResult.auditor,
416
+ rekorLogIndex: attestationResult.rekorLogIndex,
417
+ });
418
+ }
419
+ } catch (err) {
420
+ error("Failed to submit attestation to registry");
421
+ if (err instanceof Error) {
422
+ dim(` ${err.message}`);
423
+ }
424
+ process.exit(1);
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Sign command handler (local files)
430
+ */
431
+ async function signLocalTool(
432
+ pathArg: string,
433
+ options: SignOptions,
434
+ _ctx: CommandContext
435
+ ): Promise<void> {
436
+ // Find manifest
437
+ const { manifestPath, manifestDir } = findManifestPath(pathArg);
438
+ const manifestContent = readFileSync(manifestPath, "utf-8");
439
+
440
+ // Load and validate manifest
441
+ const loaded = tryLoadManifest(manifestPath);
442
+ if (!loaded) {
443
+ error(`Failed to load manifest from: ${manifestPath}`);
444
+ process.exit(1);
445
+ }
446
+
447
+ const manifest = loaded.manifest;
448
+
449
+ // Validate manifest
450
+ const validation = validateManifest(manifest);
451
+ if (!validation.valid && validation.errors) {
452
+ error("Manifest validation failed:");
453
+ for (const err of validation.errors) {
454
+ dim(` ${symbols.cross} ${err.path}: ${err.message}`);
455
+ }
456
+ process.exit(1);
457
+ }
458
+
459
+ // Determine output path
460
+ const outputPath = options.output
461
+ ? resolve(options.output)
462
+ : join(manifestDir, DEFAULT_BUNDLE_FILENAME);
463
+
464
+ // Dry run mode
465
+ if (options.dryRun) {
466
+ displayDryRun(manifestPath, manifest, outputPath, options);
467
+ return;
468
+ }
469
+
470
+ // Prepare attestation options
471
+ const attestationOptions: EnactToolAttestationOptions = {
472
+ name: manifest.name,
473
+ version: manifest.version ?? "1.0.0",
474
+ publisher: options.identity ?? "unknown",
475
+ description: manifest.description,
476
+ buildTimestamp: new Date(),
477
+ };
478
+
479
+ // Check for git repository for source info
480
+ try {
481
+ const { execSync } = await import("node:child_process");
482
+ const gitCommit = execSync("git rev-parse HEAD", {
483
+ cwd: manifestDir,
484
+ encoding: "utf-8",
485
+ }).trim();
486
+ attestationOptions.sourceCommit = gitCommit;
487
+
488
+ const remoteUrl = execSync("git remote get-url origin", {
489
+ cwd: manifestDir,
490
+ encoding: "utf-8",
491
+ }).trim();
492
+ attestationOptions.repository = remoteUrl;
493
+ } catch {
494
+ // Not a git repository or git not available
495
+ if (options.verbose) {
496
+ dim("Note: Not a git repository, skipping source commit info");
497
+ }
498
+ }
499
+
500
+ // Create in-toto attestation statement
501
+ const statement = createEnactToolStatement(manifestContent, attestationOptions);
502
+
503
+ if (options.verbose) {
504
+ info("Created attestation statement:");
505
+ dim(JSON.stringify(statement, null, 2));
506
+ newline();
507
+ }
508
+
509
+ // Sign the attestation
510
+ info("Starting OIDC signing flow...");
511
+ dim("A browser window will open for authentication.");
512
+ newline();
513
+
514
+ const result = await withSpinner("Signing attestation...", async () => {
515
+ try {
516
+ // Cast statement to Record<string, unknown> for signAttestation
517
+ return await signAttestation(statement as unknown as Record<string, unknown>, {
518
+ timeout: 120000, // 2 minutes for OIDC flow
519
+ });
520
+ } catch (err) {
521
+ // Re-throw with more context
522
+ if (err instanceof Error) {
523
+ if (err.message.includes("OIDC") || err.message.includes("token")) {
524
+ throw new Error(
525
+ `OIDC authentication failed: ${err.message}\nMake sure you complete the browser authentication flow.`
526
+ );
527
+ }
528
+ if (err.message.includes("Fulcio") || err.message.includes("certificate")) {
529
+ throw new Error(
530
+ `Certificate issuance failed: ${err.message}\nThis may be a temporary issue with the Sigstore infrastructure.`
531
+ );
532
+ }
533
+ if (err.message.includes("Rekor") || err.message.includes("transparency")) {
534
+ throw new Error(
535
+ `Transparency log failed: ${err.message}\nThis may be a temporary issue with the Sigstore infrastructure.`
536
+ );
537
+ }
538
+ }
539
+ throw err;
540
+ }
541
+ });
542
+
543
+ // Save the bundle locally
544
+ writeFileSync(outputPath, JSON.stringify(result.bundle, null, 2));
545
+
546
+ // Submit attestation to registry (unless --local)
547
+ let registryResult: { auditor: string; rekorLogIndex: number | undefined } | undefined;
548
+
549
+ if (!options.local) {
550
+ // Check for auth token from keyring
551
+ const authToken = await getSecret(AUTH_NAMESPACE, ACCESS_TOKEN_KEY);
552
+
553
+ if (!authToken) {
554
+ warning("Not authenticated with registry - attestation saved locally only");
555
+ dim("Run 'enact auth login' to authenticate, then sign again to submit");
556
+ } else {
557
+ const client = createApiClient();
558
+ client.setAuthToken(authToken);
559
+
560
+ try {
561
+ const attestationResult = await withSpinner(
562
+ "Submitting attestation to registry...",
563
+ async () => {
564
+ // Submit the Sigstore bundle directly (v2 API)
565
+ return await submitAttestationToRegistry(client, {
566
+ name: manifest.name,
567
+ version: manifest.version ?? "1.0.0",
568
+ sigstoreBundle: result.bundle as unknown as Record<string, unknown>,
569
+ });
570
+ }
571
+ );
572
+
573
+ registryResult = {
574
+ auditor: attestationResult.auditor,
575
+ rekorLogIndex: attestationResult.rekorLogIndex,
576
+ };
577
+
578
+ // Prompt to add auditor to trust list (if interactive and not in JSON mode)
579
+ if (!options.json && _ctx.isInteractive) {
580
+ await promptAddToTrustList(attestationResult.auditor, _ctx.isInteractive);
581
+ }
582
+ } catch (err) {
583
+ warning("Failed to submit attestation to registry");
584
+ if (err instanceof Error) {
585
+ dim(` ${err.message}`);
586
+ }
587
+ dim("The attestation was saved locally and logged to Rekor.");
588
+ dim("You can try submitting again later.");
589
+ }
590
+ }
591
+ }
592
+
593
+ // Display result
594
+ displayResult(result.bundle, outputPath, manifest, options, registryResult);
595
+ }
596
+
597
+ /**
598
+ * Main sign command handler - routes to local or remote
599
+ */
600
+ async function signHandler(
601
+ pathArg: string,
602
+ options: SignOptions,
603
+ ctx: CommandContext
604
+ ): Promise<void> {
605
+ // Check if this is a remote tool reference (author/tool@version)
606
+ const remoteRef = parseRemoteToolRef(pathArg);
607
+
608
+ if (remoteRef) {
609
+ // Sign remote tool from registry
610
+ await signRemoteTool(remoteRef, options, ctx);
611
+ } else {
612
+ // Sign local tool
613
+ await signLocalTool(pathArg, options, ctx);
614
+ }
615
+ }
616
+
617
+ /**
618
+ * Configure the sign command
619
+ */
620
+ export function configureSignCommand(program: Command): void {
621
+ program
622
+ .command("sign")
623
+ .description("Cryptographically sign a tool and submit attestation to registry")
624
+ .argument(
625
+ "<path>",
626
+ "Path to tool directory, manifest file, or remote tool (author/tool@version)"
627
+ )
628
+ .option("-i, --identity <email>", "Sign with specific identity (uses OAuth)")
629
+ .option("-o, --output <path>", "Output path for signature bundle (local only)")
630
+ .option("--dry-run", "Show what would be signed without signing")
631
+ .option("--local", "Save signature locally only, do not submit to registry")
632
+ .option("-v, --verbose", "Show detailed output")
633
+ .option("--json", "Output result as JSON")
634
+ .action(async (pathArg: string, options: SignOptions) => {
635
+ const ctx: CommandContext = {
636
+ cwd: process.cwd(),
637
+ options,
638
+ isCI: Boolean(process.env.CI),
639
+ isInteractive: process.stdout.isTTY ?? false,
640
+ };
641
+
642
+ try {
643
+ await signHandler(pathArg, options, ctx);
644
+ } catch (err) {
645
+ error(formatError(err));
646
+ if (options.verbose && err instanceof Error && err.stack) {
647
+ dim(err.stack);
648
+ }
649
+ process.exit(1);
650
+ }
651
+ });
652
+ }