@enactprotocol/cli 2.1.15 → 2.1.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enactprotocol/cli",
3
- "version": "2.1.15",
3
+ "version": "2.1.20",
4
4
  "description": "Command-line interface for Enact - the npm for AI tools",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -34,10 +34,10 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@clack/prompts": "^0.11.0",
37
- "@enactprotocol/api": "2.1.15",
38
- "@enactprotocol/execution": "2.1.15",
39
- "@enactprotocol/secrets": "2.1.15",
40
- "@enactprotocol/shared": "2.1.15",
37
+ "@enactprotocol/api": "2.1.20",
38
+ "@enactprotocol/execution": "2.1.20",
39
+ "@enactprotocol/secrets": "2.1.20",
40
+ "@enactprotocol/shared": "2.1.20",
41
41
  "commander": "^12.1.0",
42
42
  "picocolors": "^1.1.1"
43
43
  },
@@ -2,6 +2,7 @@
2
2
  * enact publish command
3
3
  *
4
4
  * Publish a tool to the Enact registry using v2 multipart upload.
5
+ * Supports pre-signed attestations via manifest-based signing.
5
6
  */
6
7
 
7
8
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
@@ -16,9 +17,16 @@ import {
16
17
  loadManifestFromDir,
17
18
  validateManifest,
18
19
  } from "@enactprotocol/shared";
20
+ import {
21
+ type ChecksumManifest,
22
+ type SigstoreBundle,
23
+ parseChecksumManifest,
24
+ verifyChecksumManifest,
25
+ } from "@enactprotocol/trust";
19
26
  import type { Command } from "commander";
20
27
  import type { CommandContext, GlobalOptions } from "../../types";
21
28
  import {
29
+ confirm,
22
30
  dim,
23
31
  error,
24
32
  extractNamespace,
@@ -208,6 +216,88 @@ async function publishHandler(
208
216
  header(`Publishing ${toolName}@${version}`);
209
217
  newline();
210
218
 
219
+ // Check for pre-signed attestation (manifest-based signing)
220
+ const checksumManifestPath = join(toolDir, ".enact-manifest.json");
221
+ const sigstoreBundlePath = join(toolDir, ".sigstore-bundle.json");
222
+
223
+ let checksumManifest: ChecksumManifest | undefined;
224
+ let sigstoreBundle: SigstoreBundle | undefined;
225
+ let hasPreSignedAttestation = false;
226
+
227
+ if (existsSync(checksumManifestPath) && existsSync(sigstoreBundlePath)) {
228
+ info("Found pre-signed attestation files");
229
+
230
+ try {
231
+ // Load and parse the checksum manifest
232
+ const manifestContent = readFileSync(checksumManifestPath, "utf-8");
233
+ checksumManifest = parseChecksumManifest(manifestContent);
234
+
235
+ // Load the sigstore bundle
236
+ const bundleContent = readFileSync(sigstoreBundlePath, "utf-8");
237
+ sigstoreBundle = JSON.parse(bundleContent) as SigstoreBundle;
238
+
239
+ // Verify the checksum manifest matches current files
240
+ const ignorePatterns = loadGitignore(toolDir);
241
+ const verification = await verifyChecksumManifest(toolDir, checksumManifest, {
242
+ ignorePatterns,
243
+ });
244
+
245
+ if (!verification.valid) {
246
+ newline();
247
+ warning("Pre-signed attestation is outdated - files have changed since signing:");
248
+ if (verification.modifiedFiles?.length) {
249
+ for (const file of verification.modifiedFiles) {
250
+ dim(` • Modified: ${file}`);
251
+ }
252
+ }
253
+ if (verification.missingFiles?.length) {
254
+ for (const file of verification.missingFiles) {
255
+ dim(` • Missing: ${file}`);
256
+ }
257
+ }
258
+ if (verification.extraFiles?.length) {
259
+ for (const file of verification.extraFiles) {
260
+ dim(` • New file: ${file}`);
261
+ }
262
+ }
263
+ newline();
264
+
265
+ if (ctx.isInteractive) {
266
+ const continueWithoutAttestation = await confirm(
267
+ "Continue publishing without the pre-signed attestation?",
268
+ false
269
+ );
270
+ if (!continueWithoutAttestation) {
271
+ info("Publishing cancelled. Please re-sign with 'enact sign .' after making changes.");
272
+ return;
273
+ }
274
+ // Clear the attestation since it's outdated
275
+ checksumManifest = undefined;
276
+ sigstoreBundle = undefined;
277
+ } else {
278
+ error("Pre-signed attestation does not match current files.");
279
+ dim(
280
+ "Please re-sign with 'enact sign .' or remove .enact-manifest.json and .sigstore-bundle.json"
281
+ );
282
+ process.exit(1);
283
+ }
284
+ } else {
285
+ hasPreSignedAttestation = true;
286
+ keyValue("Attestation", "Pre-signed (valid)");
287
+ keyValue("Manifest hash", `${checksumManifest.manifestHash.digest.slice(0, 16)}...`);
288
+ keyValue("Files in attestation", String(checksumManifest.files.length));
289
+ }
290
+ } catch (err) {
291
+ warning("Failed to load pre-signed attestation:");
292
+ if (err instanceof Error) {
293
+ dim(` ${err.message}`);
294
+ }
295
+ dim("Continuing without attestation...");
296
+ checksumManifest = undefined;
297
+ sigstoreBundle = undefined;
298
+ }
299
+ }
300
+
211
301
  // Determine visibility (private by default for security)
212
302
  const visibility: ToolVisibility = options.public
213
303
  ? "public"
@@ -341,12 +431,22 @@ async function publishHandler(
341
431
  bundle,
342
432
  rawManifest: rawManifestContent,
343
433
  visibility,
434
+ // Include pre-signed attestation if available (cast to Record for API compatibility)
435
+ checksumManifest: hasPreSignedAttestation
436
+ ? (checksumManifest as unknown as Record<string, unknown>)
437
+ : undefined,
438
+ sigstoreBundle: hasPreSignedAttestation
439
+ ? (sigstoreBundle as unknown as Record<string, unknown>)
440
+ : undefined,
344
441
  });
345
442
  });
346
443
 
347
444
  // JSON output
348
445
  if (options.json) {
349
- json(result);
446
+ json({
447
+ ...result,
448
+ hasAttestation: hasPreSignedAttestation,
449
+ });
350
450
  return;
351
451
  }
352
452
 
@@ -355,6 +455,9 @@ async function publishHandler(
355
455
  success(`Published ${result.name}@${result.version} (${visibility})`);
356
456
  keyValue("Bundle Hash", result.bundleHash);
357
457
  keyValue("Published At", result.publishedAt.toISOString());
458
+ if (hasPreSignedAttestation) {
459
+ keyValue("Attestation", "Included (pre-signed)");
460
+ }
358
461
  newline();
359
462
  if (visibility === "private") {
360
463
  dim("This tool is private - only you can access it.");
@@ -362,6 +465,13 @@ async function publishHandler(
362
465
  dim("This tool is unlisted - accessible via direct link, not searchable.");
363
466
  }
364
467
  dim(`Install with: enact install ${toolName}`);
468
+
469
+ if (!hasPreSignedAttestation) {
470
+ newline();
471
+ info("Tip: Sign your tool before publishing for verified attestations:");
472
+ dim(` 1. enact sign ${pathArg} # Create pre-signed attestation`);
473
+ dim(` 2. enact publish ${pathArg} # Publish with attestation`);
474
+ }
365
475
  }
366
476
 
367
477
  /**
@@ -2,8 +2,13 @@
2
2
  * enact sign command
3
3
  *
4
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.
5
+ * Creates an in-toto attestation based on a checksum manifest,
6
+ * logs to Rekor transparency log, and optionally submits to the registry.
7
+ *
8
+ * Uses manifest-based signing (per Sigstore team recommendation):
9
+ * - Creates deterministic checksum manifest of all files
10
+ * - Signs the manifest hash (not tar.gz bundle hash)
11
+ * - Enables pre-publish signing workflow
7
12
  *
8
13
  * Supports both local paths and remote tool references:
9
14
  * - Local: enact sign ./my-tool
@@ -11,7 +16,7 @@
11
16
  * - Remote: enact sign author/tool@1.0.0 (specific version)
12
17
  */
13
18
 
14
- import { readFileSync, writeFileSync } from "node:fs";
19
+ import { existsSync, writeFileSync } from "node:fs";
15
20
  import { dirname, join, resolve } from "node:path";
16
21
  import {
17
22
  createApiClient,
@@ -30,10 +35,13 @@ import {
30
35
  validateManifest,
31
36
  } from "@enactprotocol/shared";
32
37
  import {
38
+ type ChecksumManifest,
33
39
  type EnactToolAttestationOptions,
34
40
  type SigstoreBundle,
41
+ createChecksumManifest,
35
42
  createEnactToolStatement,
36
43
  extractCertificateFromBundle,
44
+ serializeChecksumManifest,
37
45
  signAttestation,
38
46
  } from "@enactprotocol/trust";
39
47
  import type { Command } from "commander";
@@ -54,6 +62,7 @@ import {
54
62
  warning,
55
63
  withSpinner,
56
64
  } from "../../utils";
65
+ import { loadGitignore } from "../../utils/ignore";
57
66
 
58
67
  /** Auth namespace for token storage */
59
68
  const AUTH_NAMESPACE = "enact:auth";
@@ -66,8 +75,9 @@ interface SignOptions extends GlobalOptions {
66
75
  local?: boolean;
67
76
  }
68
77
 
69
- /** Default output filename for the signature bundle */
78
+ /** Default output filenames for signing artifacts */
70
79
  const DEFAULT_BUNDLE_FILENAME = ".sigstore-bundle.json";
80
+ const DEFAULT_MANIFEST_FILENAME = ".enact-manifest.json";
71
81
 
72
82
  /**
73
83
  * Parse a remote tool reference like "author/tool@1.0.0" or "author/tool"
@@ -140,32 +150,44 @@ function findManifestPath(pathArg: string): { manifestPath: string; manifestDir:
140
150
  function displayDryRun(
141
151
  manifestPath: string,
142
152
  manifest: { name: string; version?: string; description?: string },
143
- outputPath: string,
153
+ manifestDir: string,
144
154
  options: SignOptions
145
155
  ): void {
156
+ const bundlePath = join(manifestDir, DEFAULT_BUNDLE_FILENAME);
157
+ const checksumManifestPath = join(manifestDir, DEFAULT_MANIFEST_FILENAME);
158
+
146
159
  newline();
147
- info(colors.bold("Dry Run Preview - Signing"));
160
+ info(colors.bold("Dry Run Preview - Manifest-Based Signing"));
148
161
  newline();
149
162
 
150
163
  keyValue("Tool", manifest.name);
151
164
  keyValue("Version", manifest.version ?? "unversioned");
152
165
  keyValue("Manifest", manifestPath);
153
- keyValue("Output", outputPath);
166
+ keyValue("Checksum manifest output", checksumManifestPath);
167
+ keyValue("Sigstore bundle output", bundlePath);
154
168
  keyValue("Submit to registry", options.local ? "No (local only)" : "Yes");
155
169
  newline();
156
170
 
157
171
  info("Actions that would be performed:");
158
- dim(" 1. Authenticate via OIDC (browser-based OAuth flow)");
159
- dim(" 2. Create in-toto attestation for tool manifest");
160
- dim(" 3. Request signing certificate from Fulcio");
161
- dim(" 4. Sign attestation with ephemeral keypair");
162
- dim(" 5. Log signature to Rekor transparency log");
163
- dim(` 6. Write bundle to ${outputPath}`);
172
+ dim(" 1. Scan tool directory and compute file checksums");
173
+ dim(" 2. Create checksum manifest (.enact-manifest.json)");
174
+ dim(" 3. Authenticate via OIDC (browser-based OAuth flow)");
175
+ dim(" 4. Create in-toto attestation for manifest hash");
176
+ dim(" 5. Request signing certificate from Fulcio");
177
+ dim(" 6. Sign attestation with ephemeral keypair");
178
+ dim(" 7. Log signature to Rekor transparency log");
179
+ dim(` 8. Write Sigstore bundle to ${bundlePath}`);
164
180
  if (!options.local) {
165
- dim(" 7. Submit attestation to Enact registry");
181
+ dim(" 9. Submit attestation to Enact registry");
166
182
  }
167
183
  newline();
168
184
 
185
+ info("This enables pre-publish signing:");
186
+ dim(" • File checksums are deterministic (unlike tar.gz bundles)");
187
+ dim(" • Sign locally, then publish with pre-signed attestation");
188
+ dim(" • Server verifies manifest matches uploaded bundle");
189
+ newline();
190
+
169
191
  warning("Note: Actual signing requires OIDC authentication.");
170
192
  dim("You will be prompted to authenticate in your browser.");
171
193
  }
@@ -236,7 +258,9 @@ async function promptAddToTrustList(
236
258
  */
237
259
  function displayResult(
238
260
  bundle: SigstoreBundle,
239
- outputPath: string,
261
+ bundlePath: string,
262
+ manifestPath: string,
263
+ checksumManifest: ChecksumManifest,
240
264
  manifest: { name: string; version?: string },
241
265
  options: SignOptions,
242
266
  registryResult?: { auditor: string; rekorLogIndex: number | undefined }
@@ -246,7 +270,10 @@ function displayResult(
246
270
  success: true,
247
271
  tool: manifest.name,
248
272
  version: manifest.version ?? "unversioned",
249
- bundlePath: outputPath,
273
+ checksumManifestPath: manifestPath,
274
+ sigstoreBundlePath: bundlePath,
275
+ manifestHash: checksumManifest.manifestHash.digest,
276
+ fileCount: checksumManifest.files.length,
250
277
  bundle,
251
278
  registry: registryResult
252
279
  ? {
@@ -263,7 +290,10 @@ function displayResult(
263
290
  success(`Successfully signed ${manifest.name}@${manifest.version ?? "unversioned"}`);
264
291
  newline();
265
292
 
266
- keyValue("Bundle saved to", outputPath);
293
+ keyValue("Checksum manifest", manifestPath);
294
+ keyValue("Sigstore bundle", bundlePath);
295
+ keyValue("Manifest hash", `${checksumManifest.manifestHash.digest.slice(0, 16)}...`);
296
+ keyValue("Files signed", String(checksumManifest.files.length));
267
297
 
268
298
  // Show some bundle details
269
299
  if (bundle.verificationMaterial?.tlogEntries?.[0]) {
@@ -286,7 +316,10 @@ function displayResult(
286
316
  newline();
287
317
  if (options.local) {
288
318
  info("Note: Attestation saved locally only (--local flag)");
289
- dim(" • Run 'enact sign .' without --local to submit to registry");
319
+ dim(" • Run 'enact publish .' to publish with this pre-signed attestation");
320
+ } else {
321
+ info("Next step:");
322
+ dim(" • Run 'enact publish .' to publish with this pre-signed attestation");
290
323
  }
291
324
  }
292
325
 
@@ -571,7 +604,7 @@ async function signRemoteTool(
571
604
  }
572
605
 
573
606
  /**
574
- * Sign command handler (local files)
607
+ * Sign command handler (local files) - uses manifest-based signing
575
608
  */
576
609
  async function signLocalTool(
577
610
  pathArg: string,
@@ -580,7 +613,6 @@ async function signLocalTool(
580
613
  ): Promise<void> {
581
614
  // Find manifest
582
615
  const { manifestPath, manifestDir } = findManifestPath(pathArg);
583
- const manifestContent = readFileSync(manifestPath, "utf-8");
584
616
 
585
617
  // Load and validate manifest
586
618
  const loaded = tryLoadManifest(manifestPath);
@@ -591,28 +623,6 @@ async function signLocalTool(
591
623
 
592
624
  const manifest = loaded.manifest;
593
625
 
594
- // Warn about local signing workflow - attestation hash won't match published bundle
595
- if (_ctx.isInteractive && !options.dryRun) {
596
- newline();
597
- warning("Local signing creates an attestation for the manifest content hash.");
598
- dim("If you plan to publish this tool, the published bundle will have a different hash.");
599
- dim("The attestation won't match and verification will fail.");
600
- newline();
601
- info("Recommended workflow:");
602
- dim(` 1. ${colors.command(`enact publish ${pathArg}`)} # Publish first`);
603
- dim(
604
- ` 2. ${colors.command(`enact sign ${manifest.name}@${manifest.version ?? "1.0.0"}`)} # Then sign the published version`
605
- );
606
- newline();
607
-
608
- const shouldContinue = await confirm("Continue with local signing anyway?", false);
609
- if (!shouldContinue) {
610
- info("Signing cancelled. Use the recommended workflow above.");
611
- return;
612
- }
613
- newline();
614
- }
615
-
616
626
  // Validate manifest
617
627
  const validation = validateManifest(manifest);
618
628
  if (!validation.valid && validation.errors) {
@@ -623,17 +633,66 @@ async function signLocalTool(
623
633
  process.exit(1);
624
634
  }
625
635
 
626
- // Determine output path
627
- const outputPath = options.output
636
+ // Output paths
637
+ const bundlePath = options.output
628
638
  ? resolve(options.output)
629
639
  : join(manifestDir, DEFAULT_BUNDLE_FILENAME);
640
+ const checksumManifestPath = join(manifestDir, DEFAULT_MANIFEST_FILENAME);
630
641
 
631
642
  // Dry run mode
632
643
  if (options.dryRun) {
633
- displayDryRun(manifestPath, manifest, outputPath, options);
644
+ displayDryRun(manifestPath, manifest, manifestDir, options);
634
645
  return;
635
646
  }
636
647
 
648
+ // Check for existing pre-signed attestation
649
+ if (existsSync(checksumManifestPath) && existsSync(bundlePath)) {
650
+ newline();
651
+ warning("Existing signature files found:");
652
+ dim(` • ${checksumManifestPath}`);
653
+ dim(` • ${bundlePath}`);
654
+ newline();
655
+
656
+ if (_ctx.isInteractive) {
657
+ const shouldOverwrite = await confirm("Overwrite existing signature?", false);
658
+ if (!shouldOverwrite) {
659
+ info("Signing cancelled. Existing signature preserved.");
660
+ return;
661
+ }
662
+ } else {
663
+ info("Overwriting existing signature (non-interactive mode).");
664
+ }
665
+ newline();
666
+ }
667
+
668
+ // Load gitignore patterns for manifest creation
669
+ const ignorePatterns = loadGitignore(manifestDir);
670
+
671
+ // Create checksum manifest
672
+ info("Creating checksum manifest...");
673
+ const checksumManifest = await withSpinner(
674
+ "Scanning files and computing checksums...",
675
+ async () => {
676
+ return await createChecksumManifest(manifestDir, manifest.name, manifest.version ?? "1.0.0", {
677
+ ignorePatterns,
678
+ onProgress: options.verbose ? (file) => dim(` Hashing: ${file}`) : undefined,
679
+ });
680
+ }
681
+ );
682
+
683
+ if (options.verbose) {
684
+ newline();
685
+ info(`Checksum manifest created with ${checksumManifest.files.length} files:`);
686
+ for (const file of checksumManifest.files) {
687
+ dim(` ${file.path} (${file.sha256.slice(0, 12)}...)`);
688
+ }
689
+ newline();
690
+ }
691
+
692
+ keyValue("Files to sign", String(checksumManifest.files.length));
693
+ keyValue("Manifest hash", `${checksumManifest.manifestHash.digest.slice(0, 16)}...`);
694
+ newline();
695
+
637
696
  // Prepare attestation options
638
697
  const attestationOptions: EnactToolAttestationOptions = {
639
698
  name: manifest.name,
@@ -641,6 +700,8 @@ async function signLocalTool(
641
700
  publisher: options.identity ?? "unknown",
642
701
  description: manifest.description,
643
702
  buildTimestamp: new Date(),
703
+ // Use manifest hash as the bundle hash for attestation
704
+ bundleHash: checksumManifest.manifestHash.digest,
644
705
  };
645
706
 
646
707
  // Check for git repository for source info
@@ -664,8 +725,11 @@ async function signLocalTool(
664
725
  }
665
726
  }
666
727
 
667
- // Create in-toto attestation statement
668
- const statement = createEnactToolStatement(manifestContent, attestationOptions);
728
+ // Create in-toto attestation statement using manifest hash as the content identifier
729
+ const statement = createEnactToolStatement(
730
+ checksumManifest.manifestHash.digest,
731
+ attestationOptions
732
+ );
669
733
 
670
734
  if (options.verbose) {
671
735
  info("Created attestation statement:");
@@ -707,8 +771,11 @@ async function signLocalTool(
707
771
  }
708
772
  });
709
773
 
710
- // Save the bundle locally
711
- writeFileSync(outputPath, JSON.stringify(result.bundle, null, 2));
774
+ // Save the checksum manifest
775
+ writeFileSync(checksumManifestPath, serializeChecksumManifest(checksumManifest));
776
+
777
+ // Save the Sigstore bundle
778
+ writeFileSync(bundlePath, JSON.stringify(result.bundle, null, 2));
712
779
 
713
780
  // Submit attestation to registry (unless --local)
714
781
  let registryResult: { auditor: string; rekorLogIndex: number | undefined } | undefined;
@@ -719,7 +786,7 @@ async function signLocalTool(
719
786
 
720
787
  if (!authToken) {
721
788
  warning("Not authenticated with registry - attestation saved locally only");
722
- dim("Run 'enact auth login' to authenticate, then sign again to submit");
789
+ dim("Run 'enact auth login' to authenticate, then publish with pre-signed attestation");
723
790
  } else {
724
791
  const client = createApiClient();
725
792
  client.setAuthToken(authToken);
@@ -756,13 +823,21 @@ async function signLocalTool(
756
823
  dim(` ${err.message}`);
757
824
  }
758
825
  dim("The attestation was saved locally and logged to Rekor.");
759
- dim("You can try submitting again later.");
826
+ dim("You can publish with the pre-signed attestation using 'enact publish .'");
760
827
  }
761
828
  }
762
829
  }
763
830
 
764
831
  // Display result
765
- displayResult(result.bundle, outputPath, manifest, options, registryResult);
832
+ displayResult(
833
+ result.bundle,
834
+ bundlePath,
835
+ checksumManifestPath,
836
+ checksumManifest,
837
+ manifest,
838
+ options,
839
+ registryResult
840
+ );
766
841
  }
767
842
 
768
843
  /**
package/src/index.ts CHANGED
@@ -34,7 +34,7 @@ import {
34
34
  } from "./commands";
35
35
  import { error, formatError } from "./utils";
36
36
 
37
- export const version = "2.1.15";
37
+ export const version = "2.1.20";
38
38
 
39
39
  // Export types for external use
40
40
  export type { GlobalOptions, CommandContext } from "./types";