@attest-it/cli 0.4.0 → 0.6.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.
@@ -6,10 +6,10 @@ import * as path from 'path';
6
6
  import { join, dirname } from 'path';
7
7
  import { detectTheme } from 'chromaterm';
8
8
  import { input, select, confirm, checkbox } from '@inquirer/prompts';
9
- import { loadConfig, toAttestItConfig, readSealsSync, computeFingerprintSync, verifyGateSeal, verifyAllSeals, computeFingerprint, createAttestation, readAttestations, upsertAttestation, KeyProviderRegistry, getDefaultPrivateKeyPath, FilesystemKeyProvider, writeSignedAttestations, loadLocalConfigSync, getActiveIdentity, isAuthorizedSigner, createSeal, writeSealsSync, checkOpenSSL, getDefaultPublicKeyPath, OnePasswordKeyProvider, MacOSKeychainKeyProvider, generateKeyPair, setKeyPermissions, getGate, loadLocalConfig, generateEd25519KeyPair, saveLocalConfig, findConfigPath, findAttestation } from '@attest-it/core';
9
+ import { loadConfig, toAttestItConfig, readSealsSync, computeFingerprintSync, verifyGateSeal, verifyAllSeals, computeFingerprint, createAttestation, readAttestations, upsertAttestation, KeyProviderRegistry, getDefaultPrivateKeyPath, FilesystemKeyProvider, writeSignedAttestations, loadLocalConfigSync, getActiveIdentity, isAuthorizedSigner, createSeal, writeSealsSync, checkOpenSSL, getDefaultPublicKeyPath, OnePasswordKeyProvider, MacOSKeychainKeyProvider, generateKeyPair, setKeyPermissions, getGate, loadLocalConfig, YubiKeyProvider, getAttestItConfigDir, generateEd25519KeyPair, saveLocalConfig, findConfigPath, loadPreferences, savePreferences, findAttestation, setAttestItHomeDir } from '@attest-it/core';
10
+ import tabtab2 from '@pnpm/tabtab';
10
11
  import { spawn } from 'child_process';
11
12
  import * as os from 'os';
12
- import { homedir } from 'os';
13
13
  import { parse } from 'shell-quote';
14
14
  import * as React7 from 'react';
15
15
  import { useState, useEffect } from 'react';
@@ -171,6 +171,87 @@ var ExitCode = {
171
171
  /** Missing required key file */
172
172
  MISSING_KEY: 5
173
173
  };
174
+ var PROGRAM_NAME = "attest-it";
175
+ var PROGRAM_ALIAS = "attest";
176
+ function detectCurrentShell() {
177
+ const shellPath = process.env.SHELL ?? "";
178
+ if (shellPath.endsWith("/bash") || shellPath.endsWith("/bash.exe")) {
179
+ return "bash";
180
+ }
181
+ if (shellPath.endsWith("/zsh") || shellPath.endsWith("/zsh.exe")) {
182
+ return "zsh";
183
+ }
184
+ if (shellPath.endsWith("/fish") || shellPath.endsWith("/fish.exe")) {
185
+ return "fish";
186
+ }
187
+ return null;
188
+ }
189
+ function getSourceCommand(shell) {
190
+ switch (shell) {
191
+ case "bash":
192
+ return "source ~/.bashrc";
193
+ case "zsh":
194
+ return "source ~/.zshrc";
195
+ case "fish":
196
+ return "source ~/.config/fish/config.fish";
197
+ }
198
+ }
199
+ async function offerCompletionInstall() {
200
+ try {
201
+ const prefs = await loadPreferences();
202
+ if (prefs.cliExperience?.declinedCompletionInstall) {
203
+ return false;
204
+ }
205
+ const shell = detectCurrentShell();
206
+ if (!shell) {
207
+ return false;
208
+ }
209
+ log("");
210
+ const shouldInstall = await confirm({
211
+ message: `Would you like to enable shell completions for ${shell}?`,
212
+ default: true
213
+ });
214
+ if (!shouldInstall) {
215
+ await savePreferences({
216
+ ...prefs,
217
+ cliExperience: {
218
+ ...prefs.cliExperience,
219
+ declinedCompletionInstall: true
220
+ }
221
+ });
222
+ log("");
223
+ info("No problem! If you change your mind, you can run:");
224
+ log(" attest-it completion install");
225
+ log("");
226
+ return false;
227
+ }
228
+ await tabtab2.install({
229
+ name: PROGRAM_NAME,
230
+ completer: PROGRAM_NAME,
231
+ shell
232
+ });
233
+ await tabtab2.install({
234
+ name: PROGRAM_ALIAS,
235
+ completer: PROGRAM_ALIAS,
236
+ shell
237
+ });
238
+ log("");
239
+ success(`Shell completions installed for ${shell}!`);
240
+ info(`Completions enabled for both "${PROGRAM_NAME}" and "${PROGRAM_ALIAS}" commands.`);
241
+ log("");
242
+ info("Restart your shell or run:");
243
+ log(` ${getSourceCommand(shell)}`);
244
+ log("");
245
+ return true;
246
+ } catch (err) {
247
+ error(`Failed to install completions: ${err instanceof Error ? err.message : String(err)}`);
248
+ log("");
249
+ info("You can try again later with:");
250
+ log(" attest-it completion install");
251
+ log("");
252
+ return false;
253
+ }
254
+ }
174
255
 
175
256
  // src/commands/init.ts
176
257
  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) => {
@@ -234,6 +315,7 @@ async function runInit(options) {
234
315
  log(` 1. Edit ${options.path} to define your test suites`);
235
316
  log(" 2. Run: attest-it keygen");
236
317
  log(" 3. Run: attest-it status");
318
+ await offerCompletionInstall();
237
319
  } catch (err) {
238
320
  if (err instanceof Error) {
239
321
  error(err.message);
@@ -1524,6 +1606,15 @@ function createKeyProviderFromIdentity(identity) {
1524
1606
  field: privateKey.field
1525
1607
  }
1526
1608
  });
1609
+ case "yubikey":
1610
+ return KeyProviderRegistry.create({
1611
+ type: "yubikey",
1612
+ options: {
1613
+ encryptedKeyPath: privateKey.encryptedKeyPath,
1614
+ slot: privateKey.slot,
1615
+ serial: privateKey.serial
1616
+ }
1617
+ });
1527
1618
  default: {
1528
1619
  const _exhaustiveCheck = privateKey;
1529
1620
  throw new Error(`Unsupported private key type: ${String(_exhaustiveCheck)}`);
@@ -1539,6 +1630,8 @@ function getKeyRefFromIdentity(identity) {
1539
1630
  return `${privateKey.service}:${privateKey.account}`;
1540
1631
  case "1password":
1541
1632
  return privateKey.item;
1633
+ case "yubikey":
1634
+ return privateKey.encryptedKeyPath;
1542
1635
  default: {
1543
1636
  const _exhaustiveCheck = privateKey;
1544
1637
  throw new Error(`Unsupported private key type: ${String(_exhaustiveCheck)}`);
@@ -2382,6 +2475,15 @@ function createKeyProviderFromIdentity2(identity) {
2382
2475
  field: privateKey.field
2383
2476
  }
2384
2477
  });
2478
+ case "yubikey":
2479
+ return KeyProviderRegistry.create({
2480
+ type: "yubikey",
2481
+ options: {
2482
+ encryptedKeyPath: privateKey.encryptedKeyPath,
2483
+ slot: privateKey.slot,
2484
+ serial: privateKey.serial
2485
+ }
2486
+ });
2385
2487
  default: {
2386
2488
  const _exhaustiveCheck = privateKey;
2387
2489
  throw new Error(`Unsupported private key type: ${String(_exhaustiveCheck)}`);
@@ -2397,6 +2499,8 @@ function getKeyRefFromIdentity2(identity) {
2397
2499
  return privateKey.service;
2398
2500
  case "1password":
2399
2501
  return privateKey.item;
2502
+ case "yubikey":
2503
+ return privateKey.encryptedKeyPath;
2400
2504
  default: {
2401
2505
  const _exhaustiveCheck = privateKey;
2402
2506
  throw new Error(`Unsupported private key type: ${String(_exhaustiveCheck)}`);
@@ -2436,6 +2540,9 @@ async function runList() {
2436
2540
  case "1password":
2437
2541
  keyType = "1password";
2438
2542
  break;
2543
+ case "yubikey":
2544
+ keyType = "yubikey";
2545
+ break;
2439
2546
  }
2440
2547
  log(`${marker} ${theme3.blue(slug)}`);
2441
2548
  log(` Name: ${nameDisplay}`);
@@ -2464,31 +2571,46 @@ async function runList() {
2464
2571
  process.exit(ExitCode.CONFIG_ERROR);
2465
2572
  }
2466
2573
  }
2574
+
2575
+ // src/commands/identity/validation.ts
2576
+ function validateSlug(value, existingIdentities) {
2577
+ const trimmed = value.trim();
2578
+ if (!trimmed) {
2579
+ return "Slug cannot be empty";
2580
+ }
2581
+ if (!/^[a-z0-9-]+$/.test(trimmed)) {
2582
+ return "Slug must contain only lowercase letters, numbers, and hyphens";
2583
+ }
2584
+ if (existingIdentities?.[trimmed]) {
2585
+ return `Identity "${trimmed}" already exists`;
2586
+ }
2587
+ return true;
2588
+ }
2589
+ var EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2590
+ function validateEmail(value, required = false) {
2591
+ const trimmed = value.trim();
2592
+ if (!trimmed) {
2593
+ return required ? "Email cannot be empty" : true;
2594
+ }
2595
+ if (!EMAIL_REGEX.test(trimmed)) {
2596
+ return "Please enter a valid email address";
2597
+ }
2598
+ return true;
2599
+ }
2467
2600
  var createCommand = new Command("create").description("Create a new identity with Ed25519 keypair").action(async () => {
2468
2601
  await runCreate();
2469
2602
  });
2470
2603
  async function runCreate() {
2471
2604
  try {
2472
- const theme3 = getTheme2();
2605
+ const theme3 = getTheme();
2473
2606
  log("");
2474
2607
  log(theme3.blue.bold()("Create New Identity"));
2475
2608
  log("");
2476
2609
  const existingConfig = await loadLocalConfig();
2477
- const slug = await input({
2610
+ const slug = (await input({
2478
2611
  message: "Identity slug (unique identifier):",
2479
- validate: (value) => {
2480
- if (!value || value.trim().length === 0) {
2481
- return "Slug cannot be empty";
2482
- }
2483
- if (!/^[a-z0-9-]+$/.test(value)) {
2484
- return "Slug must contain only lowercase letters, numbers, and hyphens";
2485
- }
2486
- if (existingConfig?.identities[value]) {
2487
- return `Identity "${value}" already exists`;
2488
- }
2489
- return true;
2490
- }
2491
- });
2612
+ validate: (value) => validateSlug(value, existingConfig?.identities)
2613
+ })).trim();
2492
2614
  const name = await input({
2493
2615
  message: "Display name:",
2494
2616
  validate: (value) => {
@@ -2498,21 +2620,37 @@ async function runCreate() {
2498
2620
  return true;
2499
2621
  }
2500
2622
  });
2501
- const email = await input({
2623
+ const email = (await input({
2502
2624
  message: "Email (optional):",
2503
- default: ""
2504
- });
2625
+ default: "",
2626
+ validate: validateEmail
2627
+ })).trim();
2505
2628
  const github = await input({
2506
2629
  message: "GitHub username (optional):",
2507
2630
  default: ""
2508
2631
  });
2632
+ info("Checking available key storage providers...");
2633
+ const opAvailable = await OnePasswordKeyProvider.isInstalled();
2634
+ const keychainAvailable = MacOSKeychainKeyProvider.isAvailable();
2635
+ const yubikeyInstalled = await YubiKeyProvider.isInstalled();
2636
+ const yubikeyConnected = yubikeyInstalled ? await YubiKeyProvider.isConnected() : false;
2637
+ const configDir = getAttestItConfigDir();
2638
+ const storageChoices = [
2639
+ { name: `File system (${join(configDir, "keys")})`, value: "file" }
2640
+ ];
2641
+ if (keychainAvailable) {
2642
+ storageChoices.push({ name: "macOS Keychain", value: "keychain" });
2643
+ }
2644
+ if (opAvailable) {
2645
+ storageChoices.push({ name: "1Password", value: "1password" });
2646
+ }
2647
+ if (yubikeyInstalled) {
2648
+ const yubikeyLabel = yubikeyConnected ? "YubiKey (encrypted with challenge-response)" : "YubiKey (not connected - insert YubiKey first)";
2649
+ storageChoices.push({ name: yubikeyLabel, value: "yubikey" });
2650
+ }
2509
2651
  const keyStorageType = await select({
2510
2652
  message: "Where should the private key be stored?",
2511
- choices: [
2512
- { name: "File system (~/.config/attest-it/keys/)", value: "file" },
2513
- { name: "macOS Keychain", value: "keychain" },
2514
- { name: "1Password", value: "1password" }
2515
- ]
2653
+ choices: storageChoices
2516
2654
  });
2517
2655
  log("");
2518
2656
  log("Generating Ed25519 keypair...");
@@ -2521,7 +2659,7 @@ async function runCreate() {
2521
2659
  let keyStorageDescription;
2522
2660
  switch (keyStorageType) {
2523
2661
  case "file": {
2524
- const keysDir = join(homedir(), ".config", "attest-it", "keys");
2662
+ const keysDir = join(getAttestItConfigDir(), "keys");
2525
2663
  await mkdir(keysDir, { recursive: true });
2526
2664
  const keyPath = join(keysDir, `${slug}.pem`);
2527
2665
  await writeFile(keyPath, keyPair.privateKey, { mode: 384 });
@@ -2530,44 +2668,138 @@ async function runCreate() {
2530
2668
  break;
2531
2669
  }
2532
2670
  case "keychain": {
2533
- const { MacOSKeychainKeyProvider: MacOSKeychainKeyProvider3 } = await import('@attest-it/core');
2534
- if (!MacOSKeychainKeyProvider3.isAvailable()) {
2671
+ if (!MacOSKeychainKeyProvider.isAvailable()) {
2535
2672
  error("macOS Keychain is not available on this system");
2536
2673
  process.exit(ExitCode.CONFIG_ERROR);
2537
2674
  }
2675
+ const keychains = await MacOSKeychainKeyProvider.listKeychains();
2676
+ if (keychains.length === 0) {
2677
+ throw new Error("No keychains found on this system");
2678
+ }
2679
+ const formatKeychainChoice = (kc) => {
2680
+ return `${theme3.blue.bold()(kc.name)} ${theme3.muted(`(${kc.path})`)}`;
2681
+ };
2682
+ let selectedKeychain;
2683
+ if (keychains.length === 1 && keychains[0]) {
2684
+ selectedKeychain = keychains[0];
2685
+ info(`Using keychain: ${formatKeychainChoice(selectedKeychain)}`);
2686
+ } else {
2687
+ const selectedPath = await select({
2688
+ message: "Select keychain:",
2689
+ choices: keychains.map((kc) => ({
2690
+ name: formatKeychainChoice(kc),
2691
+ value: kc.path
2692
+ }))
2693
+ });
2694
+ const foundKeychain = keychains.find((kc) => kc.path === selectedPath);
2695
+ if (!foundKeychain) {
2696
+ throw new Error("Selected keychain not found");
2697
+ }
2698
+ selectedKeychain = foundKeychain;
2699
+ }
2700
+ const keychainItemName = await input({
2701
+ message: "Keychain item name:",
2702
+ default: `attest-it-${slug}`,
2703
+ validate: (value) => {
2704
+ if (!value || value.trim().length === 0) {
2705
+ return "Item name cannot be empty";
2706
+ }
2707
+ return true;
2708
+ }
2709
+ });
2538
2710
  const { execFile } = await import('child_process');
2539
2711
  const { promisify } = await import('util');
2540
2712
  const execFileAsync = promisify(execFile);
2541
2713
  const encodedKey = Buffer.from(keyPair.privateKey).toString("base64");
2542
2714
  try {
2543
- await execFileAsync("security", [
2715
+ const addArgs = [
2544
2716
  "add-generic-password",
2545
2717
  "-a",
2546
2718
  "attest-it",
2547
2719
  "-s",
2548
- slug,
2720
+ keychainItemName,
2549
2721
  "-w",
2550
2722
  encodedKey,
2551
- "-U"
2552
- ]);
2723
+ "-U",
2724
+ selectedKeychain.path
2725
+ ];
2726
+ await execFileAsync("security", addArgs);
2553
2727
  } catch (err) {
2554
2728
  throw new Error(
2555
2729
  `Failed to store key in macOS Keychain: ${err instanceof Error ? err.message : String(err)}`
2556
2730
  );
2557
2731
  }
2558
- privateKeyRef = { type: "keychain", service: slug, account: "attest-it" };
2559
- keyStorageDescription = "macOS Keychain (" + slug + "/attest-it)";
2732
+ privateKeyRef = {
2733
+ type: "keychain",
2734
+ service: keychainItemName,
2735
+ account: "attest-it",
2736
+ keychain: selectedKeychain.path
2737
+ };
2738
+ keyStorageDescription = `macOS Keychain: ${selectedKeychain.name}/${keychainItemName}`;
2560
2739
  break;
2561
2740
  }
2562
2741
  case "1password": {
2563
- const vault = await input({
2564
- message: "1Password vault name:",
2565
- validate: (value) => {
2566
- if (!value || value.trim().length === 0) {
2567
- return "Vault name cannot be empty";
2742
+ const accounts = await OnePasswordKeyProvider.listAccounts();
2743
+ if (accounts.length === 0) {
2744
+ throw new Error(
2745
+ '1Password CLI is installed but no accounts are signed in. Run "op signin" first.'
2746
+ );
2747
+ }
2748
+ const { execFile } = await import('child_process');
2749
+ const { promisify } = await import('util');
2750
+ const execFileAsync = promisify(execFile);
2751
+ const accountDetails = await Promise.all(
2752
+ accounts.map(async (acc) => {
2753
+ try {
2754
+ const { stdout } = await execFileAsync("op", [
2755
+ "account",
2756
+ "get",
2757
+ "--account",
2758
+ acc.user_uuid,
2759
+ "--format=json"
2760
+ ]);
2761
+ const details = JSON.parse(stdout);
2762
+ const name2 = details !== null && typeof details === "object" && "name" in details && typeof details.name === "string" ? details.name : acc.url;
2763
+ return {
2764
+ url: acc.url,
2765
+ email: acc.email,
2766
+ name: name2
2767
+ };
2768
+ } catch {
2769
+ return {
2770
+ url: acc.url,
2771
+ email: acc.email,
2772
+ name: acc.url
2773
+ };
2568
2774
  }
2569
- return true;
2570
- }
2775
+ })
2776
+ );
2777
+ const formatAccountChoice = (acc) => {
2778
+ return `${theme3.blue.bold()(acc.name)} ${theme3.muted(`(${acc.url})`)}`;
2779
+ };
2780
+ let selectedAccount;
2781
+ if (accountDetails.length === 1 && accountDetails[0]) {
2782
+ selectedAccount = accountDetails[0].url;
2783
+ info(`Using 1Password account: ${formatAccountChoice(accountDetails[0])}`);
2784
+ } else {
2785
+ selectedAccount = await select({
2786
+ message: "Select 1Password account:",
2787
+ choices: accountDetails.map((acc) => ({
2788
+ name: formatAccountChoice(acc),
2789
+ value: acc.url
2790
+ }))
2791
+ });
2792
+ }
2793
+ const vaults = await OnePasswordKeyProvider.listVaults(selectedAccount);
2794
+ if (vaults.length === 0) {
2795
+ throw new Error(`No vaults found in 1Password account: ${selectedAccount}`);
2796
+ }
2797
+ const selectedVault = await select({
2798
+ message: "Select vault for private key storage:",
2799
+ choices: vaults.map((v) => ({
2800
+ name: v.name,
2801
+ value: v.name
2802
+ }))
2571
2803
  });
2572
2804
  const item = await input({
2573
2805
  message: "1Password item name:",
@@ -2579,26 +2811,109 @@ async function runCreate() {
2579
2811
  return true;
2580
2812
  }
2581
2813
  });
2582
- const { execFile } = await import('child_process');
2583
- const { promisify } = await import('util');
2584
- const execFileAsync = promisify(execFile);
2814
+ const { tmpdir } = await import('os');
2815
+ const tempDir = join(tmpdir(), `attest-it-${String(Date.now())}`);
2816
+ await mkdir(tempDir, { recursive: true });
2817
+ const tempPrivatePath = join(tempDir, "private.pem");
2585
2818
  try {
2586
- await execFileAsync("op", [
2587
- "item",
2819
+ await writeFile(tempPrivatePath, keyPair.privateKey, { mode: 384 });
2820
+ const { execFile: execFile2 } = await import('child_process');
2821
+ const { promisify: promisify2 } = await import('util');
2822
+ const execFileAsync2 = promisify2(execFile2);
2823
+ const opArgs = [
2824
+ "document",
2588
2825
  "create",
2589
- "--category=SecureNote",
2826
+ tempPrivatePath,
2827
+ "--title",
2828
+ item,
2590
2829
  "--vault",
2591
- vault,
2592
- `--title=${item}`,
2593
- `privateKey[password]=${keyPair.privateKey}`
2594
- ]);
2595
- } catch (err) {
2596
- throw new Error(
2597
- `Failed to store key in 1Password: ${err instanceof Error ? err.message : String(err)}`
2598
- );
2830
+ selectedVault
2831
+ ];
2832
+ if (selectedAccount) {
2833
+ opArgs.push("--account", selectedAccount);
2834
+ }
2835
+ await execFileAsync2("op", opArgs);
2836
+ } finally {
2837
+ const { rm } = await import('fs/promises');
2838
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {
2839
+ });
2840
+ }
2841
+ privateKeyRef = {
2842
+ type: "1password",
2843
+ vault: selectedVault,
2844
+ item,
2845
+ ...selectedAccount && { account: selectedAccount }
2846
+ };
2847
+ keyStorageDescription = `1Password (${selectedVault}/${item})`;
2848
+ break;
2849
+ }
2850
+ case "yubikey": {
2851
+ if (!await YubiKeyProvider.isConnected()) {
2852
+ error("No YubiKey detected. Please insert your YubiKey and try again.");
2853
+ process.exit(ExitCode.CONFIG_ERROR);
2854
+ }
2855
+ const yubikeys = await YubiKeyProvider.listDevices();
2856
+ if (yubikeys.length === 0) {
2857
+ throw new Error("No YubiKeys detected. Please insert a YubiKey and try again.");
2858
+ }
2859
+ const formatYubiKeyChoice = (yk) => {
2860
+ return `${theme3.blue.bold()(yk.type)} ${theme3.muted(`(Serial: ${yk.serial}, FW: ${yk.firmware})`)}`;
2861
+ };
2862
+ let selectedSerial;
2863
+ if (yubikeys.length === 1 && yubikeys[0]) {
2864
+ selectedSerial = yubikeys[0].serial;
2865
+ info(`Using YubiKey: ${formatYubiKeyChoice(yubikeys[0])}`);
2866
+ } else {
2867
+ selectedSerial = await select({
2868
+ message: "Select YubiKey:",
2869
+ choices: yubikeys.map((yk) => ({
2870
+ name: formatYubiKeyChoice(yk),
2871
+ value: yk.serial
2872
+ }))
2873
+ });
2599
2874
  }
2600
- privateKeyRef = { type: "1password", vault, item, field: "privateKey" };
2601
- keyStorageDescription = `1Password (${vault}/${item})`;
2875
+ const slot = 2;
2876
+ const isChallengeResponseConfigured = await YubiKeyProvider.isChallengeResponseConfigured(
2877
+ slot,
2878
+ selectedSerial
2879
+ );
2880
+ if (!isChallengeResponseConfigured) {
2881
+ log("");
2882
+ error(`YubiKey slot ${String(slot)} is not configured for HMAC challenge-response.`);
2883
+ log("");
2884
+ log("To configure it, run:");
2885
+ log(theme3.blue(` ykman otp chalresp --generate ${String(slot)}`));
2886
+ log("");
2887
+ log("This will configure slot 2 with a randomly generated secret.");
2888
+ log(theme3.muted("Note: Make sure to back up the secret if needed for recovery."));
2889
+ process.exit(ExitCode.CONFIG_ERROR);
2890
+ }
2891
+ const encryptedKeyName = await input({
2892
+ message: "Encrypted key file name:",
2893
+ default: `${slug}.enc`,
2894
+ validate: (value) => {
2895
+ if (!value || value.trim().length === 0) {
2896
+ return "File name cannot be empty";
2897
+ }
2898
+ return true;
2899
+ }
2900
+ });
2901
+ const keysDir = join(getAttestItConfigDir(), "keys");
2902
+ await mkdir(keysDir, { recursive: true });
2903
+ const encryptedKeyPath = join(keysDir, encryptedKeyName);
2904
+ const result = await YubiKeyProvider.encryptPrivateKey({
2905
+ privateKey: keyPair.privateKey,
2906
+ encryptedKeyPath,
2907
+ slot,
2908
+ serial: selectedSerial
2909
+ });
2910
+ privateKeyRef = {
2911
+ type: "yubikey",
2912
+ encryptedKeyPath: result.encryptedKeyPath,
2913
+ slot,
2914
+ serial: selectedSerial
2915
+ };
2916
+ keyStorageDescription = result.storageDescription;
2602
2917
  break;
2603
2918
  }
2604
2919
  default:
@@ -2650,6 +2965,7 @@ async function runCreate() {
2650
2965
  log(`To use this identity, run: attest-it identity use ${slug}`);
2651
2966
  log("");
2652
2967
  }
2968
+ await offerCompletionInstall();
2653
2969
  } catch (err) {
2654
2970
  if (err instanceof Error) {
2655
2971
  error(err.message);
@@ -2910,6 +3226,37 @@ async function runEdit(slug) {
2910
3226
  process.exit(ExitCode.CONFIG_ERROR);
2911
3227
  }
2912
3228
  }
3229
+
3230
+ // src/utils/format-key-location.ts
3231
+ function formatKeyLocation(privateKey) {
3232
+ const theme3 = getTheme();
3233
+ switch (privateKey.type) {
3234
+ case "file":
3235
+ return `${theme3.blue.bold()("File")}: ${theme3.muted(privateKey.path)}`;
3236
+ case "keychain": {
3237
+ let keychainName = "default";
3238
+ if (privateKey.keychain) {
3239
+ const filename = privateKey.keychain.split("/").pop() ?? privateKey.keychain;
3240
+ keychainName = filename.replace(/\.keychain(-db)?$/, "");
3241
+ }
3242
+ return `${theme3.blue.bold()("macOS Keychain")}: ${theme3.muted(`${keychainName}/${privateKey.service}`)}`;
3243
+ }
3244
+ case "1password": {
3245
+ const parts = [privateKey.vault, privateKey.item];
3246
+ if (privateKey.account) {
3247
+ parts.unshift(privateKey.account);
3248
+ }
3249
+ return `${theme3.blue.bold()("1Password")}: ${theme3.muted(parts.join("/"))}`;
3250
+ }
3251
+ case "yubikey": {
3252
+ const slotInfo = privateKey.slot ? ` (slot ${String(privateKey.slot)})` : "";
3253
+ const serialInfo = privateKey.serial ? ` [${privateKey.serial}]` : "";
3254
+ return `${theme3.blue.bold()("YubiKey")}${serialInfo}${slotInfo}: ${theme3.muted(privateKey.encryptedKeyPath)}`;
3255
+ }
3256
+ default:
3257
+ return "Unknown storage";
3258
+ }
3259
+ }
2913
3260
  var removeCommand = new Command("remove").description("Delete identity and optionally delete private key").argument("<slug>", "Identity slug to remove").action(async (slug) => {
2914
3261
  await runRemove(slug);
2915
3262
  });
@@ -2925,7 +3272,7 @@ async function runRemove(slug) {
2925
3272
  error(`Identity "${slug}" not found`);
2926
3273
  process.exit(ExitCode.CONFIG_ERROR);
2927
3274
  }
2928
- const theme3 = getTheme2();
3275
+ const theme3 = getTheme();
2929
3276
  log("");
2930
3277
  log(theme3.blue.bold()(`Remove Identity: ${slug}`));
2931
3278
  log("");
@@ -2942,6 +3289,9 @@ async function runRemove(slug) {
2942
3289
  log("Cancelled");
2943
3290
  process.exit(ExitCode.CANCELLED);
2944
3291
  }
3292
+ const keyLocation = formatKeyLocation(identity.privateKey);
3293
+ log(` Private key: ${keyLocation}`);
3294
+ log("");
2945
3295
  const deletePrivateKey = await confirm({
2946
3296
  message: "Also delete the private key from storage?",
2947
3297
  default: false
@@ -2964,13 +3314,17 @@ async function runRemove(slug) {
2964
3314
  const { promisify } = await import('util');
2965
3315
  const execFileAsync = promisify(execFile);
2966
3316
  try {
2967
- await execFileAsync("security", [
3317
+ const deleteArgs = [
2968
3318
  "delete-generic-password",
2969
3319
  "-s",
2970
3320
  identity.privateKey.service,
2971
3321
  "-a",
2972
3322
  identity.privateKey.account
2973
- ]);
3323
+ ];
3324
+ if (identity.privateKey.keychain) {
3325
+ deleteArgs.push(identity.privateKey.keychain);
3326
+ }
3327
+ await execFileAsync("security", deleteArgs);
2974
3328
  log(` Deleted private key from macOS Keychain`);
2975
3329
  } catch (err) {
2976
3330
  if (err instanceof Error && !err.message.includes("could not be found") && !err.message.includes("does not exist")) {
@@ -3108,17 +3462,20 @@ async function runWhoami() {
3108
3462
  error("Active identity not found");
3109
3463
  process.exit(ExitCode.CONFIG_ERROR);
3110
3464
  }
3111
- const theme3 = getTheme2();
3465
+ const theme3 = getTheme();
3466
+ log("");
3467
+ log(theme3.blue.bold()("Active Identity"));
3112
3468
  log("");
3113
- log(theme3.green.bold()(identity.name));
3469
+ log(` Slug: ${theme3.green.bold()(config.activeIdentity)}`);
3470
+ log(` Name: ${identity.name}`);
3114
3471
  if (identity.email) {
3115
- log(theme3.muted(identity.email));
3472
+ log(` Email: ${theme3.muted(identity.email)}`);
3116
3473
  }
3117
3474
  if (identity.github) {
3118
- log(theme3.muted("@" + identity.github));
3475
+ log(` GitHub: ${theme3.muted("@" + identity.github)}`);
3119
3476
  }
3120
- log("");
3121
- log(`Identity: ${theme3.blue(config.activeIdentity)}`);
3477
+ log(` Public Key: ${theme3.muted(identity.publicKey.slice(0, 24) + "...")}`);
3478
+ log(` Key Store: ${formatKeyLocation(identity.privateKey)}`);
3122
3479
  log("");
3123
3480
  } catch (err) {
3124
3481
  if (err instanceof Error) {
@@ -3569,6 +3926,266 @@ async function runRemove2(slug, options) {
3569
3926
 
3570
3927
  // src/commands/team/index.ts
3571
3928
  var teamCommand = new Command("team").description("Manage team members and authorizations").addCommand(listCommand2).addCommand(addCommand).addCommand(editCommand2).addCommand(removeCommand2);
3929
+ var PROGRAM_NAME2 = "attest-it";
3930
+ var PROGRAM_ALIAS2 = "attest";
3931
+ var PROGRAM_NAMES = [PROGRAM_NAME2, PROGRAM_ALIAS2];
3932
+ function isSupportedShell(value) {
3933
+ return value === "bash" || value === "zsh" || value === "fish";
3934
+ }
3935
+ async function getCompletions(env) {
3936
+ let shell;
3937
+ try {
3938
+ const detectedShell = tabtab2.getShellFromEnv(process.env);
3939
+ shell = detectedShell === "pwsh" ? "bash" : detectedShell;
3940
+ } catch {
3941
+ shell = "bash";
3942
+ }
3943
+ const commands = [
3944
+ { name: "init", description: "Initialize a new config file" },
3945
+ { name: "status", description: "Show status of all gates" },
3946
+ { name: "run", description: "Run test suites interactively" },
3947
+ { name: "verify", description: "Verify all seals are valid" },
3948
+ { name: "seal", description: "Create a seal for a gate" },
3949
+ { name: "keygen", description: "Generate a new keypair" },
3950
+ { name: "prune", description: "Remove stale attestations" },
3951
+ { name: "identity", description: "Manage identities" },
3952
+ { name: "team", description: "Manage team members" },
3953
+ { name: "whoami", description: "Show active identity" },
3954
+ { name: "completion", description: "Shell completion commands" }
3955
+ ];
3956
+ const globalOptions2 = [
3957
+ { name: "--help", description: "Show help" },
3958
+ { name: "--version", description: "Show version" },
3959
+ { name: "--verbose", description: "Verbose output" },
3960
+ { name: "--quiet", description: "Minimal output" },
3961
+ { name: "--config", description: "Path to config file" }
3962
+ ];
3963
+ const identitySubcommands = [
3964
+ { name: "create", description: "Create a new identity" },
3965
+ { name: "list", description: "List all identities" },
3966
+ { name: "use", description: "Switch active identity" },
3967
+ { name: "remove", description: "Remove an identity" }
3968
+ ];
3969
+ const teamSubcommands = [
3970
+ { name: "add", description: "Add yourself to the team" },
3971
+ { name: "list", description: "List team members" },
3972
+ { name: "remove", description: "Remove a team member" }
3973
+ ];
3974
+ const completionSubcommands = [
3975
+ { name: "install", description: "Install shell completion" },
3976
+ { name: "uninstall", description: "Uninstall shell completion" }
3977
+ ];
3978
+ const words = env.line.split(/\s+/).filter(Boolean);
3979
+ const lastWord = env.last;
3980
+ const prevWord = env.prev;
3981
+ if (prevWord === "--config" || prevWord === "-c") {
3982
+ tabtab2.logFiles();
3983
+ return;
3984
+ }
3985
+ if (lastWord.startsWith("-")) {
3986
+ tabtab2.log(globalOptions2, shell, console.log);
3987
+ return;
3988
+ }
3989
+ const commandIndex = words.findIndex(
3990
+ (w) => !w.startsWith("-") && !PROGRAM_NAMES.includes(w) && w !== "npx"
3991
+ );
3992
+ const currentCommand = commandIndex >= 0 ? words[commandIndex] ?? null : null;
3993
+ if (currentCommand === "identity") {
3994
+ const subcommandIndex = words.findIndex(
3995
+ (w, i) => i > commandIndex && !w.startsWith("-")
3996
+ );
3997
+ const subcommand = subcommandIndex >= 0 ? words[subcommandIndex] ?? null : null;
3998
+ if (subcommand === "use" || subcommand === "remove") {
3999
+ const identities = await getIdentitySlugs();
4000
+ if (identities.length > 0) {
4001
+ tabtab2.log(identities, shell, console.log);
4002
+ return;
4003
+ }
4004
+ }
4005
+ if (!subcommand || subcommandIndex < 0) {
4006
+ tabtab2.log(identitySubcommands, shell, console.log);
4007
+ return;
4008
+ }
4009
+ }
4010
+ if (currentCommand === "team") {
4011
+ const subcommandIndex = words.findIndex(
4012
+ (w, i) => i > commandIndex && !w.startsWith("-")
4013
+ );
4014
+ const subcommand = subcommandIndex >= 0 ? words[subcommandIndex] ?? null : null;
4015
+ if (!subcommand || subcommandIndex < 0) {
4016
+ tabtab2.log(teamSubcommands, shell, console.log);
4017
+ return;
4018
+ }
4019
+ }
4020
+ if (currentCommand === "completion") {
4021
+ const subcommandIndex = words.findIndex(
4022
+ (w, i) => i > commandIndex && !w.startsWith("-")
4023
+ );
4024
+ const subcommand = subcommandIndex >= 0 ? words[subcommandIndex] ?? null : null;
4025
+ if (subcommand === "install") {
4026
+ tabtab2.log(["bash", "zsh", "fish"], shell, console.log);
4027
+ return;
4028
+ }
4029
+ if (!subcommand || subcommandIndex < 0) {
4030
+ tabtab2.log(completionSubcommands, shell, console.log);
4031
+ return;
4032
+ }
4033
+ }
4034
+ if (currentCommand === "status" || currentCommand === "verify" || currentCommand === "seal") {
4035
+ const gates = await getGateNames();
4036
+ if (gates.length > 0) {
4037
+ tabtab2.log(gates, shell, console.log);
4038
+ return;
4039
+ }
4040
+ }
4041
+ if (currentCommand === "run") {
4042
+ const suites = await getSuiteNames();
4043
+ if (suites.length > 0) {
4044
+ tabtab2.log(suites, shell, console.log);
4045
+ return;
4046
+ }
4047
+ }
4048
+ const knownCommands = [
4049
+ "init",
4050
+ "status",
4051
+ "run",
4052
+ "verify",
4053
+ "seal",
4054
+ "keygen",
4055
+ "prune",
4056
+ "identity",
4057
+ "team",
4058
+ "whoami",
4059
+ "completion"
4060
+ ];
4061
+ if (!currentCommand || !knownCommands.includes(currentCommand)) {
4062
+ tabtab2.log([...commands, ...globalOptions2], shell, console.log);
4063
+ }
4064
+ }
4065
+ async function getIdentitySlugs() {
4066
+ try {
4067
+ const config = await loadLocalConfig();
4068
+ if (config?.identities) {
4069
+ return Object.keys(config.identities);
4070
+ }
4071
+ } catch {
4072
+ }
4073
+ return [];
4074
+ }
4075
+ async function getGateNames() {
4076
+ try {
4077
+ const config = await loadConfig();
4078
+ if (config.gates) {
4079
+ return Object.keys(config.gates);
4080
+ }
4081
+ } catch {
4082
+ }
4083
+ return [];
4084
+ }
4085
+ async function getSuiteNames() {
4086
+ try {
4087
+ const config = await loadConfig();
4088
+ return Object.keys(config.suites);
4089
+ } catch {
4090
+ }
4091
+ return [];
4092
+ }
4093
+ var completionCommand = new Command("completion").description("Shell completion commands");
4094
+ function detectCurrentShell2() {
4095
+ const shellPath = process.env.SHELL ?? "";
4096
+ if (shellPath.endsWith("/bash") || shellPath.endsWith("/bash.exe")) {
4097
+ return "bash";
4098
+ }
4099
+ if (shellPath.endsWith("/zsh") || shellPath.endsWith("/zsh.exe")) {
4100
+ return "zsh";
4101
+ }
4102
+ if (shellPath.endsWith("/fish") || shellPath.endsWith("/fish.exe")) {
4103
+ return "fish";
4104
+ }
4105
+ return null;
4106
+ }
4107
+ function getSourceCommand2(shell) {
4108
+ switch (shell) {
4109
+ case "bash":
4110
+ return "source ~/.bashrc";
4111
+ case "zsh":
4112
+ return "source ~/.zshrc";
4113
+ case "fish":
4114
+ return "source ~/.config/fish/config.fish";
4115
+ }
4116
+ }
4117
+ completionCommand.command("install [shell]").description("Install shell completion (auto-detects shell, or specify bash/zsh/fish)").action(async (shellArg) => {
4118
+ try {
4119
+ let shell;
4120
+ if (shellArg !== void 0) {
4121
+ if (!isSupportedShell(shellArg)) {
4122
+ error(`Shell "${shellArg}" is not supported. Use bash, zsh, or fish.`);
4123
+ process.exit(ExitCode.CONFIG_ERROR);
4124
+ }
4125
+ shell = shellArg;
4126
+ } else {
4127
+ const detected = detectCurrentShell2();
4128
+ if (!detected) {
4129
+ error(
4130
+ "Could not detect your shell. Please specify: attest-it completion install <bash|zsh|fish>"
4131
+ );
4132
+ process.exit(ExitCode.CONFIG_ERROR);
4133
+ }
4134
+ shell = detected;
4135
+ info(`Detected shell: ${shell}`);
4136
+ }
4137
+ await tabtab2.install({
4138
+ name: PROGRAM_NAME2,
4139
+ completer: PROGRAM_NAME2,
4140
+ shell
4141
+ });
4142
+ await tabtab2.install({
4143
+ name: PROGRAM_ALIAS2,
4144
+ completer: PROGRAM_ALIAS2,
4145
+ shell
4146
+ });
4147
+ log("");
4148
+ success(`Shell completion installed for ${shell}!`);
4149
+ info(`Completions enabled for both "${PROGRAM_NAME2}" and "${PROGRAM_ALIAS2}" commands.`);
4150
+ log("");
4151
+ info("Restart your shell or run:");
4152
+ log(` ${getSourceCommand2(shell)}`);
4153
+ log("");
4154
+ } catch (err) {
4155
+ error(`Failed to install completion: ${err instanceof Error ? err.message : String(err)}`);
4156
+ process.exit(ExitCode.CONFIG_ERROR);
4157
+ }
4158
+ });
4159
+ completionCommand.command("uninstall").description("Uninstall shell completion").action(async () => {
4160
+ try {
4161
+ await tabtab2.uninstall({
4162
+ name: PROGRAM_NAME2
4163
+ });
4164
+ await tabtab2.uninstall({
4165
+ name: PROGRAM_ALIAS2
4166
+ });
4167
+ log("");
4168
+ success("Shell completion uninstalled!");
4169
+ log("");
4170
+ } catch (err) {
4171
+ error(`Failed to uninstall completion: ${err instanceof Error ? err.message : String(err)}`);
4172
+ process.exit(ExitCode.CONFIG_ERROR);
4173
+ }
4174
+ });
4175
+ completionCommand.command("server", { hidden: true }).description("Completion server (internal)").action(async () => {
4176
+ const env = tabtab2.parseEnv(process.env);
4177
+ if (env.complete) {
4178
+ await getCompletions(env);
4179
+ }
4180
+ });
4181
+ function createCompletionServerCommand() {
4182
+ return new Command("completion-server").allowUnknownOption().allowExcessArguments(true).action(async () => {
4183
+ const env = tabtab2.parseEnv(process.env);
4184
+ if (env.complete) {
4185
+ await getCompletions(env);
4186
+ }
4187
+ });
4188
+ }
3572
4189
  function hasVersion(data) {
3573
4190
  return typeof data === "object" && data !== null && "version" in data && // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
3574
4191
  typeof data.version === "string";
@@ -3612,12 +4229,28 @@ program.addCommand(sealCommand);
3612
4229
  program.addCommand(identityCommand);
3613
4230
  program.addCommand(teamCommand);
3614
4231
  program.addCommand(whoamiCommand);
4232
+ program.addCommand(completionCommand);
4233
+ program.addCommand(createCompletionServerCommand(), { hidden: true });
4234
+ function processHomeDirOption() {
4235
+ const homeDirIndex = process.argv.indexOf("--home-dir");
4236
+ if (homeDirIndex !== -1 && homeDirIndex + 1 < process.argv.length) {
4237
+ const homeDir = process.argv[homeDirIndex + 1];
4238
+ if (homeDir && !homeDir.startsWith("-")) {
4239
+ setAttestItHomeDir(homeDir);
4240
+ process.argv.splice(homeDirIndex, 2);
4241
+ }
4242
+ }
4243
+ }
3615
4244
  async function run() {
4245
+ processHomeDirOption();
3616
4246
  if (process.argv.includes("--version") || process.argv.includes("-V")) {
3617
4247
  console.log(getPackageVersion());
3618
4248
  process.exit(0);
3619
4249
  }
3620
- await initTheme();
4250
+ const isCompletionServer = process.argv.includes("completion-server");
4251
+ if (!isCompletionServer) {
4252
+ await initTheme();
4253
+ }
3621
4254
  program.parse();
3622
4255
  const options = program.opts();
3623
4256
  const outputOptions = {};