@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.
package/dist/index.cjs CHANGED
@@ -6,6 +6,7 @@ var path = require('path');
6
6
  var chromaterm = require('chromaterm');
7
7
  var prompts = require('@inquirer/prompts');
8
8
  var core = require('@attest-it/core');
9
+ var tabtab2 = require('@pnpm/tabtab');
9
10
  var child_process = require('child_process');
10
11
  var os = require('os');
11
12
  var shellQuote = require('shell-quote');
@@ -18,6 +19,8 @@ var yaml = require('yaml');
18
19
  var url = require('url');
19
20
 
20
21
  var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
22
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
23
+
21
24
  function _interopNamespace(e) {
22
25
  if (e && e.__esModule) return e;
23
26
  var n = Object.create(null);
@@ -38,6 +41,7 @@ function _interopNamespace(e) {
38
41
 
39
42
  var fs__namespace = /*#__PURE__*/_interopNamespace(fs);
40
43
  var path__namespace = /*#__PURE__*/_interopNamespace(path);
44
+ var tabtab2__default = /*#__PURE__*/_interopDefault(tabtab2);
41
45
  var os__namespace = /*#__PURE__*/_interopNamespace(os);
42
46
  var React7__namespace = /*#__PURE__*/_interopNamespace(React7);
43
47
 
@@ -193,6 +197,87 @@ var ExitCode = {
193
197
  /** Missing required key file */
194
198
  MISSING_KEY: 5
195
199
  };
200
+ var PROGRAM_NAME = "attest-it";
201
+ var PROGRAM_ALIAS = "attest";
202
+ function detectCurrentShell() {
203
+ const shellPath = process.env.SHELL ?? "";
204
+ if (shellPath.endsWith("/bash") || shellPath.endsWith("/bash.exe")) {
205
+ return "bash";
206
+ }
207
+ if (shellPath.endsWith("/zsh") || shellPath.endsWith("/zsh.exe")) {
208
+ return "zsh";
209
+ }
210
+ if (shellPath.endsWith("/fish") || shellPath.endsWith("/fish.exe")) {
211
+ return "fish";
212
+ }
213
+ return null;
214
+ }
215
+ function getSourceCommand(shell) {
216
+ switch (shell) {
217
+ case "bash":
218
+ return "source ~/.bashrc";
219
+ case "zsh":
220
+ return "source ~/.zshrc";
221
+ case "fish":
222
+ return "source ~/.config/fish/config.fish";
223
+ }
224
+ }
225
+ async function offerCompletionInstall() {
226
+ try {
227
+ const prefs = await core.loadPreferences();
228
+ if (prefs.cliExperience?.declinedCompletionInstall) {
229
+ return false;
230
+ }
231
+ const shell = detectCurrentShell();
232
+ if (!shell) {
233
+ return false;
234
+ }
235
+ log("");
236
+ const shouldInstall = await prompts.confirm({
237
+ message: `Would you like to enable shell completions for ${shell}?`,
238
+ default: true
239
+ });
240
+ if (!shouldInstall) {
241
+ await core.savePreferences({
242
+ ...prefs,
243
+ cliExperience: {
244
+ ...prefs.cliExperience,
245
+ declinedCompletionInstall: true
246
+ }
247
+ });
248
+ log("");
249
+ info("No problem! If you change your mind, you can run:");
250
+ log(" attest-it completion install");
251
+ log("");
252
+ return false;
253
+ }
254
+ await tabtab2__default.default.install({
255
+ name: PROGRAM_NAME,
256
+ completer: PROGRAM_NAME,
257
+ shell
258
+ });
259
+ await tabtab2__default.default.install({
260
+ name: PROGRAM_ALIAS,
261
+ completer: PROGRAM_ALIAS,
262
+ shell
263
+ });
264
+ log("");
265
+ success(`Shell completions installed for ${shell}!`);
266
+ info(`Completions enabled for both "${PROGRAM_NAME}" and "${PROGRAM_ALIAS}" commands.`);
267
+ log("");
268
+ info("Restart your shell or run:");
269
+ log(` ${getSourceCommand(shell)}`);
270
+ log("");
271
+ return true;
272
+ } catch (err) {
273
+ error(`Failed to install completions: ${err instanceof Error ? err.message : String(err)}`);
274
+ log("");
275
+ info("You can try again later with:");
276
+ log(" attest-it completion install");
277
+ log("");
278
+ return false;
279
+ }
280
+ }
196
281
 
197
282
  // src/commands/init.ts
198
283
  var initCommand = new commander.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) => {
@@ -256,6 +341,7 @@ async function runInit(options) {
256
341
  log(` 1. Edit ${options.path} to define your test suites`);
257
342
  log(" 2. Run: attest-it keygen");
258
343
  log(" 3. Run: attest-it status");
344
+ await offerCompletionInstall();
259
345
  } catch (err) {
260
346
  if (err instanceof Error) {
261
347
  error(err.message);
@@ -1546,6 +1632,15 @@ function createKeyProviderFromIdentity(identity) {
1546
1632
  field: privateKey.field
1547
1633
  }
1548
1634
  });
1635
+ case "yubikey":
1636
+ return core.KeyProviderRegistry.create({
1637
+ type: "yubikey",
1638
+ options: {
1639
+ encryptedKeyPath: privateKey.encryptedKeyPath,
1640
+ slot: privateKey.slot,
1641
+ serial: privateKey.serial
1642
+ }
1643
+ });
1549
1644
  default: {
1550
1645
  const _exhaustiveCheck = privateKey;
1551
1646
  throw new Error(`Unsupported private key type: ${String(_exhaustiveCheck)}`);
@@ -1561,6 +1656,8 @@ function getKeyRefFromIdentity(identity) {
1561
1656
  return `${privateKey.service}:${privateKey.account}`;
1562
1657
  case "1password":
1563
1658
  return privateKey.item;
1659
+ case "yubikey":
1660
+ return privateKey.encryptedKeyPath;
1564
1661
  default: {
1565
1662
  const _exhaustiveCheck = privateKey;
1566
1663
  throw new Error(`Unsupported private key type: ${String(_exhaustiveCheck)}`);
@@ -2404,6 +2501,15 @@ function createKeyProviderFromIdentity2(identity) {
2404
2501
  field: privateKey.field
2405
2502
  }
2406
2503
  });
2504
+ case "yubikey":
2505
+ return core.KeyProviderRegistry.create({
2506
+ type: "yubikey",
2507
+ options: {
2508
+ encryptedKeyPath: privateKey.encryptedKeyPath,
2509
+ slot: privateKey.slot,
2510
+ serial: privateKey.serial
2511
+ }
2512
+ });
2407
2513
  default: {
2408
2514
  const _exhaustiveCheck = privateKey;
2409
2515
  throw new Error(`Unsupported private key type: ${String(_exhaustiveCheck)}`);
@@ -2419,6 +2525,8 @@ function getKeyRefFromIdentity2(identity) {
2419
2525
  return privateKey.service;
2420
2526
  case "1password":
2421
2527
  return privateKey.item;
2528
+ case "yubikey":
2529
+ return privateKey.encryptedKeyPath;
2422
2530
  default: {
2423
2531
  const _exhaustiveCheck = privateKey;
2424
2532
  throw new Error(`Unsupported private key type: ${String(_exhaustiveCheck)}`);
@@ -2458,6 +2566,9 @@ async function runList() {
2458
2566
  case "1password":
2459
2567
  keyType = "1password";
2460
2568
  break;
2569
+ case "yubikey":
2570
+ keyType = "yubikey";
2571
+ break;
2461
2572
  }
2462
2573
  log(`${marker} ${theme3.blue(slug)}`);
2463
2574
  log(` Name: ${nameDisplay}`);
@@ -2486,31 +2597,46 @@ async function runList() {
2486
2597
  process.exit(ExitCode.CONFIG_ERROR);
2487
2598
  }
2488
2599
  }
2600
+
2601
+ // src/commands/identity/validation.ts
2602
+ function validateSlug(value, existingIdentities) {
2603
+ const trimmed = value.trim();
2604
+ if (!trimmed) {
2605
+ return "Slug cannot be empty";
2606
+ }
2607
+ if (!/^[a-z0-9-]+$/.test(trimmed)) {
2608
+ return "Slug must contain only lowercase letters, numbers, and hyphens";
2609
+ }
2610
+ if (existingIdentities?.[trimmed]) {
2611
+ return `Identity "${trimmed}" already exists`;
2612
+ }
2613
+ return true;
2614
+ }
2615
+ var EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2616
+ function validateEmail(value, required = false) {
2617
+ const trimmed = value.trim();
2618
+ if (!trimmed) {
2619
+ return required ? "Email cannot be empty" : true;
2620
+ }
2621
+ if (!EMAIL_REGEX.test(trimmed)) {
2622
+ return "Please enter a valid email address";
2623
+ }
2624
+ return true;
2625
+ }
2489
2626
  var createCommand = new commander.Command("create").description("Create a new identity with Ed25519 keypair").action(async () => {
2490
2627
  await runCreate();
2491
2628
  });
2492
2629
  async function runCreate() {
2493
2630
  try {
2494
- const theme3 = getTheme2();
2631
+ const theme3 = getTheme();
2495
2632
  log("");
2496
2633
  log(theme3.blue.bold()("Create New Identity"));
2497
2634
  log("");
2498
2635
  const existingConfig = await core.loadLocalConfig();
2499
- const slug = await prompts.input({
2636
+ const slug = (await prompts.input({
2500
2637
  message: "Identity slug (unique identifier):",
2501
- validate: (value) => {
2502
- if (!value || value.trim().length === 0) {
2503
- return "Slug cannot be empty";
2504
- }
2505
- if (!/^[a-z0-9-]+$/.test(value)) {
2506
- return "Slug must contain only lowercase letters, numbers, and hyphens";
2507
- }
2508
- if (existingConfig?.identities[value]) {
2509
- return `Identity "${value}" already exists`;
2510
- }
2511
- return true;
2512
- }
2513
- });
2638
+ validate: (value) => validateSlug(value, existingConfig?.identities)
2639
+ })).trim();
2514
2640
  const name = await prompts.input({
2515
2641
  message: "Display name:",
2516
2642
  validate: (value) => {
@@ -2520,21 +2646,37 @@ async function runCreate() {
2520
2646
  return true;
2521
2647
  }
2522
2648
  });
2523
- const email = await prompts.input({
2649
+ const email = (await prompts.input({
2524
2650
  message: "Email (optional):",
2525
- default: ""
2526
- });
2651
+ default: "",
2652
+ validate: validateEmail
2653
+ })).trim();
2527
2654
  const github = await prompts.input({
2528
2655
  message: "GitHub username (optional):",
2529
2656
  default: ""
2530
2657
  });
2658
+ info("Checking available key storage providers...");
2659
+ const opAvailable = await core.OnePasswordKeyProvider.isInstalled();
2660
+ const keychainAvailable = core.MacOSKeychainKeyProvider.isAvailable();
2661
+ const yubikeyInstalled = await core.YubiKeyProvider.isInstalled();
2662
+ const yubikeyConnected = yubikeyInstalled ? await core.YubiKeyProvider.isConnected() : false;
2663
+ const configDir = core.getAttestItConfigDir();
2664
+ const storageChoices = [
2665
+ { name: `File system (${path.join(configDir, "keys")})`, value: "file" }
2666
+ ];
2667
+ if (keychainAvailable) {
2668
+ storageChoices.push({ name: "macOS Keychain", value: "keychain" });
2669
+ }
2670
+ if (opAvailable) {
2671
+ storageChoices.push({ name: "1Password", value: "1password" });
2672
+ }
2673
+ if (yubikeyInstalled) {
2674
+ const yubikeyLabel = yubikeyConnected ? "YubiKey (encrypted with challenge-response)" : "YubiKey (not connected - insert YubiKey first)";
2675
+ storageChoices.push({ name: yubikeyLabel, value: "yubikey" });
2676
+ }
2531
2677
  const keyStorageType = await prompts.select({
2532
2678
  message: "Where should the private key be stored?",
2533
- choices: [
2534
- { name: "File system (~/.config/attest-it/keys/)", value: "file" },
2535
- { name: "macOS Keychain", value: "keychain" },
2536
- { name: "1Password", value: "1password" }
2537
- ]
2679
+ choices: storageChoices
2538
2680
  });
2539
2681
  log("");
2540
2682
  log("Generating Ed25519 keypair...");
@@ -2543,7 +2685,7 @@ async function runCreate() {
2543
2685
  let keyStorageDescription;
2544
2686
  switch (keyStorageType) {
2545
2687
  case "file": {
2546
- const keysDir = path.join(os.homedir(), ".config", "attest-it", "keys");
2688
+ const keysDir = path.join(core.getAttestItConfigDir(), "keys");
2547
2689
  await promises.mkdir(keysDir, { recursive: true });
2548
2690
  const keyPath = path.join(keysDir, `${slug}.pem`);
2549
2691
  await promises.writeFile(keyPath, keyPair.privateKey, { mode: 384 });
@@ -2552,44 +2694,138 @@ async function runCreate() {
2552
2694
  break;
2553
2695
  }
2554
2696
  case "keychain": {
2555
- const { MacOSKeychainKeyProvider: MacOSKeychainKeyProvider3 } = await import('@attest-it/core');
2556
- if (!MacOSKeychainKeyProvider3.isAvailable()) {
2697
+ if (!core.MacOSKeychainKeyProvider.isAvailable()) {
2557
2698
  error("macOS Keychain is not available on this system");
2558
2699
  process.exit(ExitCode.CONFIG_ERROR);
2559
2700
  }
2701
+ const keychains = await core.MacOSKeychainKeyProvider.listKeychains();
2702
+ if (keychains.length === 0) {
2703
+ throw new Error("No keychains found on this system");
2704
+ }
2705
+ const formatKeychainChoice = (kc) => {
2706
+ return `${theme3.blue.bold()(kc.name)} ${theme3.muted(`(${kc.path})`)}`;
2707
+ };
2708
+ let selectedKeychain;
2709
+ if (keychains.length === 1 && keychains[0]) {
2710
+ selectedKeychain = keychains[0];
2711
+ info(`Using keychain: ${formatKeychainChoice(selectedKeychain)}`);
2712
+ } else {
2713
+ const selectedPath = await prompts.select({
2714
+ message: "Select keychain:",
2715
+ choices: keychains.map((kc) => ({
2716
+ name: formatKeychainChoice(kc),
2717
+ value: kc.path
2718
+ }))
2719
+ });
2720
+ const foundKeychain = keychains.find((kc) => kc.path === selectedPath);
2721
+ if (!foundKeychain) {
2722
+ throw new Error("Selected keychain not found");
2723
+ }
2724
+ selectedKeychain = foundKeychain;
2725
+ }
2726
+ const keychainItemName = await prompts.input({
2727
+ message: "Keychain item name:",
2728
+ default: `attest-it-${slug}`,
2729
+ validate: (value) => {
2730
+ if (!value || value.trim().length === 0) {
2731
+ return "Item name cannot be empty";
2732
+ }
2733
+ return true;
2734
+ }
2735
+ });
2560
2736
  const { execFile } = await import('child_process');
2561
2737
  const { promisify } = await import('util');
2562
2738
  const execFileAsync = promisify(execFile);
2563
2739
  const encodedKey = Buffer.from(keyPair.privateKey).toString("base64");
2564
2740
  try {
2565
- await execFileAsync("security", [
2741
+ const addArgs = [
2566
2742
  "add-generic-password",
2567
2743
  "-a",
2568
2744
  "attest-it",
2569
2745
  "-s",
2570
- slug,
2746
+ keychainItemName,
2571
2747
  "-w",
2572
2748
  encodedKey,
2573
- "-U"
2574
- ]);
2749
+ "-U",
2750
+ selectedKeychain.path
2751
+ ];
2752
+ await execFileAsync("security", addArgs);
2575
2753
  } catch (err) {
2576
2754
  throw new Error(
2577
2755
  `Failed to store key in macOS Keychain: ${err instanceof Error ? err.message : String(err)}`
2578
2756
  );
2579
2757
  }
2580
- privateKeyRef = { type: "keychain", service: slug, account: "attest-it" };
2581
- keyStorageDescription = "macOS Keychain (" + slug + "/attest-it)";
2758
+ privateKeyRef = {
2759
+ type: "keychain",
2760
+ service: keychainItemName,
2761
+ account: "attest-it",
2762
+ keychain: selectedKeychain.path
2763
+ };
2764
+ keyStorageDescription = `macOS Keychain: ${selectedKeychain.name}/${keychainItemName}`;
2582
2765
  break;
2583
2766
  }
2584
2767
  case "1password": {
2585
- const vault = await prompts.input({
2586
- message: "1Password vault name:",
2587
- validate: (value) => {
2588
- if (!value || value.trim().length === 0) {
2589
- return "Vault name cannot be empty";
2768
+ const accounts = await core.OnePasswordKeyProvider.listAccounts();
2769
+ if (accounts.length === 0) {
2770
+ throw new Error(
2771
+ '1Password CLI is installed but no accounts are signed in. Run "op signin" first.'
2772
+ );
2773
+ }
2774
+ const { execFile } = await import('child_process');
2775
+ const { promisify } = await import('util');
2776
+ const execFileAsync = promisify(execFile);
2777
+ const accountDetails = await Promise.all(
2778
+ accounts.map(async (acc) => {
2779
+ try {
2780
+ const { stdout } = await execFileAsync("op", [
2781
+ "account",
2782
+ "get",
2783
+ "--account",
2784
+ acc.user_uuid,
2785
+ "--format=json"
2786
+ ]);
2787
+ const details = JSON.parse(stdout);
2788
+ const name2 = details !== null && typeof details === "object" && "name" in details && typeof details.name === "string" ? details.name : acc.url;
2789
+ return {
2790
+ url: acc.url,
2791
+ email: acc.email,
2792
+ name: name2
2793
+ };
2794
+ } catch {
2795
+ return {
2796
+ url: acc.url,
2797
+ email: acc.email,
2798
+ name: acc.url
2799
+ };
2590
2800
  }
2591
- return true;
2592
- }
2801
+ })
2802
+ );
2803
+ const formatAccountChoice = (acc) => {
2804
+ return `${theme3.blue.bold()(acc.name)} ${theme3.muted(`(${acc.url})`)}`;
2805
+ };
2806
+ let selectedAccount;
2807
+ if (accountDetails.length === 1 && accountDetails[0]) {
2808
+ selectedAccount = accountDetails[0].url;
2809
+ info(`Using 1Password account: ${formatAccountChoice(accountDetails[0])}`);
2810
+ } else {
2811
+ selectedAccount = await prompts.select({
2812
+ message: "Select 1Password account:",
2813
+ choices: accountDetails.map((acc) => ({
2814
+ name: formatAccountChoice(acc),
2815
+ value: acc.url
2816
+ }))
2817
+ });
2818
+ }
2819
+ const vaults = await core.OnePasswordKeyProvider.listVaults(selectedAccount);
2820
+ if (vaults.length === 0) {
2821
+ throw new Error(`No vaults found in 1Password account: ${selectedAccount}`);
2822
+ }
2823
+ const selectedVault = await prompts.select({
2824
+ message: "Select vault for private key storage:",
2825
+ choices: vaults.map((v) => ({
2826
+ name: v.name,
2827
+ value: v.name
2828
+ }))
2593
2829
  });
2594
2830
  const item = await prompts.input({
2595
2831
  message: "1Password item name:",
@@ -2601,26 +2837,109 @@ async function runCreate() {
2601
2837
  return true;
2602
2838
  }
2603
2839
  });
2604
- const { execFile } = await import('child_process');
2605
- const { promisify } = await import('util');
2606
- const execFileAsync = promisify(execFile);
2840
+ const { tmpdir } = await import('os');
2841
+ const tempDir = path.join(tmpdir(), `attest-it-${String(Date.now())}`);
2842
+ await promises.mkdir(tempDir, { recursive: true });
2843
+ const tempPrivatePath = path.join(tempDir, "private.pem");
2607
2844
  try {
2608
- await execFileAsync("op", [
2609
- "item",
2845
+ await promises.writeFile(tempPrivatePath, keyPair.privateKey, { mode: 384 });
2846
+ const { execFile: execFile2 } = await import('child_process');
2847
+ const { promisify: promisify2 } = await import('util');
2848
+ const execFileAsync2 = promisify2(execFile2);
2849
+ const opArgs = [
2850
+ "document",
2610
2851
  "create",
2611
- "--category=SecureNote",
2852
+ tempPrivatePath,
2853
+ "--title",
2854
+ item,
2612
2855
  "--vault",
2613
- vault,
2614
- `--title=${item}`,
2615
- `privateKey[password]=${keyPair.privateKey}`
2616
- ]);
2617
- } catch (err) {
2618
- throw new Error(
2619
- `Failed to store key in 1Password: ${err instanceof Error ? err.message : String(err)}`
2620
- );
2856
+ selectedVault
2857
+ ];
2858
+ if (selectedAccount) {
2859
+ opArgs.push("--account", selectedAccount);
2860
+ }
2861
+ await execFileAsync2("op", opArgs);
2862
+ } finally {
2863
+ const { rm } = await import('fs/promises');
2864
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {
2865
+ });
2621
2866
  }
2622
- privateKeyRef = { type: "1password", vault, item, field: "privateKey" };
2623
- keyStorageDescription = `1Password (${vault}/${item})`;
2867
+ privateKeyRef = {
2868
+ type: "1password",
2869
+ vault: selectedVault,
2870
+ item,
2871
+ ...selectedAccount && { account: selectedAccount }
2872
+ };
2873
+ keyStorageDescription = `1Password (${selectedVault}/${item})`;
2874
+ break;
2875
+ }
2876
+ case "yubikey": {
2877
+ if (!await core.YubiKeyProvider.isConnected()) {
2878
+ error("No YubiKey detected. Please insert your YubiKey and try again.");
2879
+ process.exit(ExitCode.CONFIG_ERROR);
2880
+ }
2881
+ const yubikeys = await core.YubiKeyProvider.listDevices();
2882
+ if (yubikeys.length === 0) {
2883
+ throw new Error("No YubiKeys detected. Please insert a YubiKey and try again.");
2884
+ }
2885
+ const formatYubiKeyChoice = (yk) => {
2886
+ return `${theme3.blue.bold()(yk.type)} ${theme3.muted(`(Serial: ${yk.serial}, FW: ${yk.firmware})`)}`;
2887
+ };
2888
+ let selectedSerial;
2889
+ if (yubikeys.length === 1 && yubikeys[0]) {
2890
+ selectedSerial = yubikeys[0].serial;
2891
+ info(`Using YubiKey: ${formatYubiKeyChoice(yubikeys[0])}`);
2892
+ } else {
2893
+ selectedSerial = await prompts.select({
2894
+ message: "Select YubiKey:",
2895
+ choices: yubikeys.map((yk) => ({
2896
+ name: formatYubiKeyChoice(yk),
2897
+ value: yk.serial
2898
+ }))
2899
+ });
2900
+ }
2901
+ const slot = 2;
2902
+ const isChallengeResponseConfigured = await core.YubiKeyProvider.isChallengeResponseConfigured(
2903
+ slot,
2904
+ selectedSerial
2905
+ );
2906
+ if (!isChallengeResponseConfigured) {
2907
+ log("");
2908
+ error(`YubiKey slot ${String(slot)} is not configured for HMAC challenge-response.`);
2909
+ log("");
2910
+ log("To configure it, run:");
2911
+ log(theme3.blue(` ykman otp chalresp --generate ${String(slot)}`));
2912
+ log("");
2913
+ log("This will configure slot 2 with a randomly generated secret.");
2914
+ log(theme3.muted("Note: Make sure to back up the secret if needed for recovery."));
2915
+ process.exit(ExitCode.CONFIG_ERROR);
2916
+ }
2917
+ const encryptedKeyName = await prompts.input({
2918
+ message: "Encrypted key file name:",
2919
+ default: `${slug}.enc`,
2920
+ validate: (value) => {
2921
+ if (!value || value.trim().length === 0) {
2922
+ return "File name cannot be empty";
2923
+ }
2924
+ return true;
2925
+ }
2926
+ });
2927
+ const keysDir = path.join(core.getAttestItConfigDir(), "keys");
2928
+ await promises.mkdir(keysDir, { recursive: true });
2929
+ const encryptedKeyPath = path.join(keysDir, encryptedKeyName);
2930
+ const result = await core.YubiKeyProvider.encryptPrivateKey({
2931
+ privateKey: keyPair.privateKey,
2932
+ encryptedKeyPath,
2933
+ slot,
2934
+ serial: selectedSerial
2935
+ });
2936
+ privateKeyRef = {
2937
+ type: "yubikey",
2938
+ encryptedKeyPath: result.encryptedKeyPath,
2939
+ slot,
2940
+ serial: selectedSerial
2941
+ };
2942
+ keyStorageDescription = result.storageDescription;
2624
2943
  break;
2625
2944
  }
2626
2945
  default:
@@ -2672,6 +2991,7 @@ async function runCreate() {
2672
2991
  log(`To use this identity, run: attest-it identity use ${slug}`);
2673
2992
  log("");
2674
2993
  }
2994
+ await offerCompletionInstall();
2675
2995
  } catch (err) {
2676
2996
  if (err instanceof Error) {
2677
2997
  error(err.message);
@@ -2932,6 +3252,37 @@ async function runEdit(slug) {
2932
3252
  process.exit(ExitCode.CONFIG_ERROR);
2933
3253
  }
2934
3254
  }
3255
+
3256
+ // src/utils/format-key-location.ts
3257
+ function formatKeyLocation(privateKey) {
3258
+ const theme3 = getTheme();
3259
+ switch (privateKey.type) {
3260
+ case "file":
3261
+ return `${theme3.blue.bold()("File")}: ${theme3.muted(privateKey.path)}`;
3262
+ case "keychain": {
3263
+ let keychainName = "default";
3264
+ if (privateKey.keychain) {
3265
+ const filename = privateKey.keychain.split("/").pop() ?? privateKey.keychain;
3266
+ keychainName = filename.replace(/\.keychain(-db)?$/, "");
3267
+ }
3268
+ return `${theme3.blue.bold()("macOS Keychain")}: ${theme3.muted(`${keychainName}/${privateKey.service}`)}`;
3269
+ }
3270
+ case "1password": {
3271
+ const parts = [privateKey.vault, privateKey.item];
3272
+ if (privateKey.account) {
3273
+ parts.unshift(privateKey.account);
3274
+ }
3275
+ return `${theme3.blue.bold()("1Password")}: ${theme3.muted(parts.join("/"))}`;
3276
+ }
3277
+ case "yubikey": {
3278
+ const slotInfo = privateKey.slot ? ` (slot ${String(privateKey.slot)})` : "";
3279
+ const serialInfo = privateKey.serial ? ` [${privateKey.serial}]` : "";
3280
+ return `${theme3.blue.bold()("YubiKey")}${serialInfo}${slotInfo}: ${theme3.muted(privateKey.encryptedKeyPath)}`;
3281
+ }
3282
+ default:
3283
+ return "Unknown storage";
3284
+ }
3285
+ }
2935
3286
  var removeCommand = new commander.Command("remove").description("Delete identity and optionally delete private key").argument("<slug>", "Identity slug to remove").action(async (slug) => {
2936
3287
  await runRemove(slug);
2937
3288
  });
@@ -2947,7 +3298,7 @@ async function runRemove(slug) {
2947
3298
  error(`Identity "${slug}" not found`);
2948
3299
  process.exit(ExitCode.CONFIG_ERROR);
2949
3300
  }
2950
- const theme3 = getTheme2();
3301
+ const theme3 = getTheme();
2951
3302
  log("");
2952
3303
  log(theme3.blue.bold()(`Remove Identity: ${slug}`));
2953
3304
  log("");
@@ -2964,6 +3315,9 @@ async function runRemove(slug) {
2964
3315
  log("Cancelled");
2965
3316
  process.exit(ExitCode.CANCELLED);
2966
3317
  }
3318
+ const keyLocation = formatKeyLocation(identity.privateKey);
3319
+ log(` Private key: ${keyLocation}`);
3320
+ log("");
2967
3321
  const deletePrivateKey = await prompts.confirm({
2968
3322
  message: "Also delete the private key from storage?",
2969
3323
  default: false
@@ -2986,13 +3340,17 @@ async function runRemove(slug) {
2986
3340
  const { promisify } = await import('util');
2987
3341
  const execFileAsync = promisify(execFile);
2988
3342
  try {
2989
- await execFileAsync("security", [
3343
+ const deleteArgs = [
2990
3344
  "delete-generic-password",
2991
3345
  "-s",
2992
3346
  identity.privateKey.service,
2993
3347
  "-a",
2994
3348
  identity.privateKey.account
2995
- ]);
3349
+ ];
3350
+ if (identity.privateKey.keychain) {
3351
+ deleteArgs.push(identity.privateKey.keychain);
3352
+ }
3353
+ await execFileAsync("security", deleteArgs);
2996
3354
  log(` Deleted private key from macOS Keychain`);
2997
3355
  } catch (err) {
2998
3356
  if (err instanceof Error && !err.message.includes("could not be found") && !err.message.includes("does not exist")) {
@@ -3130,17 +3488,20 @@ async function runWhoami() {
3130
3488
  error("Active identity not found");
3131
3489
  process.exit(ExitCode.CONFIG_ERROR);
3132
3490
  }
3133
- const theme3 = getTheme2();
3491
+ const theme3 = getTheme();
3492
+ log("");
3493
+ log(theme3.blue.bold()("Active Identity"));
3134
3494
  log("");
3135
- log(theme3.green.bold()(identity.name));
3495
+ log(` Slug: ${theme3.green.bold()(config.activeIdentity)}`);
3496
+ log(` Name: ${identity.name}`);
3136
3497
  if (identity.email) {
3137
- log(theme3.muted(identity.email));
3498
+ log(` Email: ${theme3.muted(identity.email)}`);
3138
3499
  }
3139
3500
  if (identity.github) {
3140
- log(theme3.muted("@" + identity.github));
3501
+ log(` GitHub: ${theme3.muted("@" + identity.github)}`);
3141
3502
  }
3142
- log("");
3143
- log(`Identity: ${theme3.blue(config.activeIdentity)}`);
3503
+ log(` Public Key: ${theme3.muted(identity.publicKey.slice(0, 24) + "...")}`);
3504
+ log(` Key Store: ${formatKeyLocation(identity.privateKey)}`);
3144
3505
  log("");
3145
3506
  } catch (err) {
3146
3507
  if (err instanceof Error) {
@@ -3591,6 +3952,266 @@ async function runRemove2(slug, options) {
3591
3952
 
3592
3953
  // src/commands/team/index.ts
3593
3954
  var teamCommand = new commander.Command("team").description("Manage team members and authorizations").addCommand(listCommand2).addCommand(addCommand).addCommand(editCommand2).addCommand(removeCommand2);
3955
+ var PROGRAM_NAME2 = "attest-it";
3956
+ var PROGRAM_ALIAS2 = "attest";
3957
+ var PROGRAM_NAMES = [PROGRAM_NAME2, PROGRAM_ALIAS2];
3958
+ function isSupportedShell(value) {
3959
+ return value === "bash" || value === "zsh" || value === "fish";
3960
+ }
3961
+ async function getCompletions(env) {
3962
+ let shell;
3963
+ try {
3964
+ const detectedShell = tabtab2__default.default.getShellFromEnv(process.env);
3965
+ shell = detectedShell === "pwsh" ? "bash" : detectedShell;
3966
+ } catch {
3967
+ shell = "bash";
3968
+ }
3969
+ const commands = [
3970
+ { name: "init", description: "Initialize a new config file" },
3971
+ { name: "status", description: "Show status of all gates" },
3972
+ { name: "run", description: "Run test suites interactively" },
3973
+ { name: "verify", description: "Verify all seals are valid" },
3974
+ { name: "seal", description: "Create a seal for a gate" },
3975
+ { name: "keygen", description: "Generate a new keypair" },
3976
+ { name: "prune", description: "Remove stale attestations" },
3977
+ { name: "identity", description: "Manage identities" },
3978
+ { name: "team", description: "Manage team members" },
3979
+ { name: "whoami", description: "Show active identity" },
3980
+ { name: "completion", description: "Shell completion commands" }
3981
+ ];
3982
+ const globalOptions2 = [
3983
+ { name: "--help", description: "Show help" },
3984
+ { name: "--version", description: "Show version" },
3985
+ { name: "--verbose", description: "Verbose output" },
3986
+ { name: "--quiet", description: "Minimal output" },
3987
+ { name: "--config", description: "Path to config file" }
3988
+ ];
3989
+ const identitySubcommands = [
3990
+ { name: "create", description: "Create a new identity" },
3991
+ { name: "list", description: "List all identities" },
3992
+ { name: "use", description: "Switch active identity" },
3993
+ { name: "remove", description: "Remove an identity" }
3994
+ ];
3995
+ const teamSubcommands = [
3996
+ { name: "add", description: "Add yourself to the team" },
3997
+ { name: "list", description: "List team members" },
3998
+ { name: "remove", description: "Remove a team member" }
3999
+ ];
4000
+ const completionSubcommands = [
4001
+ { name: "install", description: "Install shell completion" },
4002
+ { name: "uninstall", description: "Uninstall shell completion" }
4003
+ ];
4004
+ const words = env.line.split(/\s+/).filter(Boolean);
4005
+ const lastWord = env.last;
4006
+ const prevWord = env.prev;
4007
+ if (prevWord === "--config" || prevWord === "-c") {
4008
+ tabtab2__default.default.logFiles();
4009
+ return;
4010
+ }
4011
+ if (lastWord.startsWith("-")) {
4012
+ tabtab2__default.default.log(globalOptions2, shell, console.log);
4013
+ return;
4014
+ }
4015
+ const commandIndex = words.findIndex(
4016
+ (w) => !w.startsWith("-") && !PROGRAM_NAMES.includes(w) && w !== "npx"
4017
+ );
4018
+ const currentCommand = commandIndex >= 0 ? words[commandIndex] ?? null : null;
4019
+ if (currentCommand === "identity") {
4020
+ const subcommandIndex = words.findIndex(
4021
+ (w, i) => i > commandIndex && !w.startsWith("-")
4022
+ );
4023
+ const subcommand = subcommandIndex >= 0 ? words[subcommandIndex] ?? null : null;
4024
+ if (subcommand === "use" || subcommand === "remove") {
4025
+ const identities = await getIdentitySlugs();
4026
+ if (identities.length > 0) {
4027
+ tabtab2__default.default.log(identities, shell, console.log);
4028
+ return;
4029
+ }
4030
+ }
4031
+ if (!subcommand || subcommandIndex < 0) {
4032
+ tabtab2__default.default.log(identitySubcommands, shell, console.log);
4033
+ return;
4034
+ }
4035
+ }
4036
+ if (currentCommand === "team") {
4037
+ const subcommandIndex = words.findIndex(
4038
+ (w, i) => i > commandIndex && !w.startsWith("-")
4039
+ );
4040
+ const subcommand = subcommandIndex >= 0 ? words[subcommandIndex] ?? null : null;
4041
+ if (!subcommand || subcommandIndex < 0) {
4042
+ tabtab2__default.default.log(teamSubcommands, shell, console.log);
4043
+ return;
4044
+ }
4045
+ }
4046
+ if (currentCommand === "completion") {
4047
+ const subcommandIndex = words.findIndex(
4048
+ (w, i) => i > commandIndex && !w.startsWith("-")
4049
+ );
4050
+ const subcommand = subcommandIndex >= 0 ? words[subcommandIndex] ?? null : null;
4051
+ if (subcommand === "install") {
4052
+ tabtab2__default.default.log(["bash", "zsh", "fish"], shell, console.log);
4053
+ return;
4054
+ }
4055
+ if (!subcommand || subcommandIndex < 0) {
4056
+ tabtab2__default.default.log(completionSubcommands, shell, console.log);
4057
+ return;
4058
+ }
4059
+ }
4060
+ if (currentCommand === "status" || currentCommand === "verify" || currentCommand === "seal") {
4061
+ const gates = await getGateNames();
4062
+ if (gates.length > 0) {
4063
+ tabtab2__default.default.log(gates, shell, console.log);
4064
+ return;
4065
+ }
4066
+ }
4067
+ if (currentCommand === "run") {
4068
+ const suites = await getSuiteNames();
4069
+ if (suites.length > 0) {
4070
+ tabtab2__default.default.log(suites, shell, console.log);
4071
+ return;
4072
+ }
4073
+ }
4074
+ const knownCommands = [
4075
+ "init",
4076
+ "status",
4077
+ "run",
4078
+ "verify",
4079
+ "seal",
4080
+ "keygen",
4081
+ "prune",
4082
+ "identity",
4083
+ "team",
4084
+ "whoami",
4085
+ "completion"
4086
+ ];
4087
+ if (!currentCommand || !knownCommands.includes(currentCommand)) {
4088
+ tabtab2__default.default.log([...commands, ...globalOptions2], shell, console.log);
4089
+ }
4090
+ }
4091
+ async function getIdentitySlugs() {
4092
+ try {
4093
+ const config = await core.loadLocalConfig();
4094
+ if (config?.identities) {
4095
+ return Object.keys(config.identities);
4096
+ }
4097
+ } catch {
4098
+ }
4099
+ return [];
4100
+ }
4101
+ async function getGateNames() {
4102
+ try {
4103
+ const config = await core.loadConfig();
4104
+ if (config.gates) {
4105
+ return Object.keys(config.gates);
4106
+ }
4107
+ } catch {
4108
+ }
4109
+ return [];
4110
+ }
4111
+ async function getSuiteNames() {
4112
+ try {
4113
+ const config = await core.loadConfig();
4114
+ return Object.keys(config.suites);
4115
+ } catch {
4116
+ }
4117
+ return [];
4118
+ }
4119
+ var completionCommand = new commander.Command("completion").description("Shell completion commands");
4120
+ function detectCurrentShell2() {
4121
+ const shellPath = process.env.SHELL ?? "";
4122
+ if (shellPath.endsWith("/bash") || shellPath.endsWith("/bash.exe")) {
4123
+ return "bash";
4124
+ }
4125
+ if (shellPath.endsWith("/zsh") || shellPath.endsWith("/zsh.exe")) {
4126
+ return "zsh";
4127
+ }
4128
+ if (shellPath.endsWith("/fish") || shellPath.endsWith("/fish.exe")) {
4129
+ return "fish";
4130
+ }
4131
+ return null;
4132
+ }
4133
+ function getSourceCommand2(shell) {
4134
+ switch (shell) {
4135
+ case "bash":
4136
+ return "source ~/.bashrc";
4137
+ case "zsh":
4138
+ return "source ~/.zshrc";
4139
+ case "fish":
4140
+ return "source ~/.config/fish/config.fish";
4141
+ }
4142
+ }
4143
+ completionCommand.command("install [shell]").description("Install shell completion (auto-detects shell, or specify bash/zsh/fish)").action(async (shellArg) => {
4144
+ try {
4145
+ let shell;
4146
+ if (shellArg !== void 0) {
4147
+ if (!isSupportedShell(shellArg)) {
4148
+ error(`Shell "${shellArg}" is not supported. Use bash, zsh, or fish.`);
4149
+ process.exit(ExitCode.CONFIG_ERROR);
4150
+ }
4151
+ shell = shellArg;
4152
+ } else {
4153
+ const detected = detectCurrentShell2();
4154
+ if (!detected) {
4155
+ error(
4156
+ "Could not detect your shell. Please specify: attest-it completion install <bash|zsh|fish>"
4157
+ );
4158
+ process.exit(ExitCode.CONFIG_ERROR);
4159
+ }
4160
+ shell = detected;
4161
+ info(`Detected shell: ${shell}`);
4162
+ }
4163
+ await tabtab2__default.default.install({
4164
+ name: PROGRAM_NAME2,
4165
+ completer: PROGRAM_NAME2,
4166
+ shell
4167
+ });
4168
+ await tabtab2__default.default.install({
4169
+ name: PROGRAM_ALIAS2,
4170
+ completer: PROGRAM_ALIAS2,
4171
+ shell
4172
+ });
4173
+ log("");
4174
+ success(`Shell completion installed for ${shell}!`);
4175
+ info(`Completions enabled for both "${PROGRAM_NAME2}" and "${PROGRAM_ALIAS2}" commands.`);
4176
+ log("");
4177
+ info("Restart your shell or run:");
4178
+ log(` ${getSourceCommand2(shell)}`);
4179
+ log("");
4180
+ } catch (err) {
4181
+ error(`Failed to install completion: ${err instanceof Error ? err.message : String(err)}`);
4182
+ process.exit(ExitCode.CONFIG_ERROR);
4183
+ }
4184
+ });
4185
+ completionCommand.command("uninstall").description("Uninstall shell completion").action(async () => {
4186
+ try {
4187
+ await tabtab2__default.default.uninstall({
4188
+ name: PROGRAM_NAME2
4189
+ });
4190
+ await tabtab2__default.default.uninstall({
4191
+ name: PROGRAM_ALIAS2
4192
+ });
4193
+ log("");
4194
+ success("Shell completion uninstalled!");
4195
+ log("");
4196
+ } catch (err) {
4197
+ error(`Failed to uninstall completion: ${err instanceof Error ? err.message : String(err)}`);
4198
+ process.exit(ExitCode.CONFIG_ERROR);
4199
+ }
4200
+ });
4201
+ completionCommand.command("server", { hidden: true }).description("Completion server (internal)").action(async () => {
4202
+ const env = tabtab2__default.default.parseEnv(process.env);
4203
+ if (env.complete) {
4204
+ await getCompletions(env);
4205
+ }
4206
+ });
4207
+ function createCompletionServerCommand() {
4208
+ return new commander.Command("completion-server").allowUnknownOption().allowExcessArguments(true).action(async () => {
4209
+ const env = tabtab2__default.default.parseEnv(process.env);
4210
+ if (env.complete) {
4211
+ await getCompletions(env);
4212
+ }
4213
+ });
4214
+ }
3594
4215
  function hasVersion(data) {
3595
4216
  return typeof data === "object" && data !== null && "version" in data && // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
3596
4217
  typeof data.version === "string";
@@ -3634,12 +4255,28 @@ program.addCommand(sealCommand);
3634
4255
  program.addCommand(identityCommand);
3635
4256
  program.addCommand(teamCommand);
3636
4257
  program.addCommand(whoamiCommand);
4258
+ program.addCommand(completionCommand);
4259
+ program.addCommand(createCompletionServerCommand(), { hidden: true });
4260
+ function processHomeDirOption() {
4261
+ const homeDirIndex = process.argv.indexOf("--home-dir");
4262
+ if (homeDirIndex !== -1 && homeDirIndex + 1 < process.argv.length) {
4263
+ const homeDir = process.argv[homeDirIndex + 1];
4264
+ if (homeDir && !homeDir.startsWith("-")) {
4265
+ core.setAttestItHomeDir(homeDir);
4266
+ process.argv.splice(homeDirIndex, 2);
4267
+ }
4268
+ }
4269
+ }
3637
4270
  async function run() {
4271
+ processHomeDirOption();
3638
4272
  if (process.argv.includes("--version") || process.argv.includes("-V")) {
3639
4273
  console.log(getPackageVersion());
3640
4274
  process.exit(0);
3641
4275
  }
3642
- await initTheme();
4276
+ const isCompletionServer = process.argv.includes("completion-server");
4277
+ if (!isCompletionServer) {
4278
+ await initTheme();
4279
+ }
3643
4280
  program.parse();
3644
4281
  const options = program.opts();
3645
4282
  const outputOptions = {};