azdo-cli 0.20.0-develop.741 → 0.20.0-develop.751

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/README.md CHANGED
@@ -19,7 +19,7 @@ Azure DevOps CLI focused on work item read/write workflows.
19
19
  - Feed long PR titles, descriptions and comment bodies from a file or a pipe instead of the shell (`--title-file`, `--description-file`, `--file`; `-` reads standard input)
20
20
  - Persist org/project/default fields in local config (`config`)
21
21
  - List all fields of a work item (`list-fields`)
22
- - Authenticate per Azure DevOps organization with `azdo auth login` — OAuth (Microsoft Entra) by default, or a Personal Access Token via `--use-pat` (or the `AZDO_PAT` env var). Credentials are stored in the OS credential store. Inspect with `azdo auth status`, remove with `azdo auth logout`. Diagnose auth problems with `azdo auth diagnose`. Print the credential the CLI itself would use, for API calls the CLI does not yet wrap, with `azdo auth token`. See [docs/authentication.md](docs/authentication.md).
22
+ - Authenticate per Azure DevOps organization with `azdo auth login` — OAuth (Microsoft Entra) by default, or a Personal Access Token via `--use-pat` (or the `AZDO_PAT` env var). Credentials are stored in the OS credential store — or, on Windows over SSH where Credential Manager is unreachable, in DPAPI-encrypted files under `~/.azdo` (`azdo config set credentialStore dpapi`, which offers to copy existing Credential Manager credentials; `--copy-credentials` / `--no-copy-credentials` skip the prompt). Inspect with `azdo auth status`, remove with `azdo auth logout`. Diagnose auth problems with `azdo auth diagnose`. Print the credential the CLI itself would use, for API calls the CLI does not yet wrap, with `azdo auth token`. See [docs/authentication.md](docs/authentication.md).
23
23
  - Trace all HTTP requests to a local file with `--trace <filepath>` (sensitive headers and tokens are automatically redacted).
24
24
 
25
25
  ## Installation
@@ -38,6 +38,10 @@ azdo auth login --org myorg
38
38
  azdo config set org myorg
39
39
  azdo config set project myproject
40
40
 
41
+ # Windows over SSH: keep credentials in DPAPI files instead of Credential Manager
42
+ # (run once from a console to copy the existing credentials across)
43
+ azdo config set credentialStore dpapi --copy-credentials
44
+
41
45
  # Read a work item
42
46
  azdo get-item 12345
43
47
 
@@ -34,6 +34,21 @@ var CODE_CHALLENGE_METHOD = "S256";
34
34
  import fs from "fs";
35
35
  import path from "path";
36
36
  import os from "os";
37
+
38
+ // src/services/credential-store-kind.ts
39
+ var CREDENTIAL_STORES = ["keyring", "dpapi"];
40
+ function parseCredentialStore(value, platform = process.platform) {
41
+ const normalised = value.trim().toLowerCase();
42
+ if (!CREDENTIAL_STORES.includes(normalised)) {
43
+ throw new Error(`Invalid value "${value}" for credentialStore. Must be one of: ${CREDENTIAL_STORES.join(", ")}.`);
44
+ }
45
+ if (normalised === "dpapi" && platform !== "win32") {
46
+ throw new Error('credentialStore "dpapi" is only available on Windows.');
47
+ }
48
+ return normalised;
49
+ }
50
+
51
+ // src/services/config-store.ts
37
52
  var SETTINGS = [
38
53
  {
39
54
  key: "org",
@@ -62,6 +77,13 @@ var SETTINGS = [
62
77
  type: "boolean",
63
78
  example: "true",
64
79
  required: false
80
+ },
81
+ {
82
+ key: "credentialStore",
83
+ description: 'Where credentials are stored: "keyring" (OS vault, default) or "dpapi" (Windows only, ~/.azdo/credentials)',
84
+ type: "string",
85
+ example: "dpapi",
86
+ required: false
65
87
  }
66
88
  ];
67
89
  var VALID_KEYS = SETTINGS.map((s) => s.key);
@@ -128,6 +150,8 @@ function setConfigValue(key, value) {
128
150
  throw new Error(`Invalid value "${value}" for markdown. Must be "true" or "false".`);
129
151
  }
130
152
  config.markdown = value === "true";
153
+ } else if (key === "credentialStore") {
154
+ config.credentialStore = parseCredentialStore(value);
131
155
  } else if (key === "fields") {
132
156
  config.fields = value.split(",").map((s) => s.trim());
133
157
  } else {
@@ -209,7 +233,7 @@ function getOrgScopedValue(org, key) {
209
233
  }
210
234
  function readScope(config, name) {
211
235
  if (name === "default") {
212
- const { org: _o, organizations: _orgs, ...defaults } = config;
236
+ const { org: _o, organizations: _orgs, credentialStore: _cs, ...defaults } = config;
213
237
  return defaults;
214
238
  }
215
239
  return config.organizations?.[name.toLowerCase()] ?? {};
@@ -476,11 +500,11 @@ function handleCallback(req, res, active) {
476
500
  return;
477
501
  }
478
502
  const url = new URL(req.url, "http://127.0.0.1");
479
- const path3 = url.pathname;
503
+ const path4 = url.pathname;
480
504
  const q = url.searchParams;
481
- if (path3 !== "/callback" && path3 !== "/") {
482
- writeError(res, 404, `unexpected path ${path3}`);
483
- if (active) active.reject(new OAuthFlowError("redirect-mismatch", `unexpected callback path "${path3}"`));
505
+ if (path4 !== "/callback" && path4 !== "/") {
506
+ writeError(res, 404, `unexpected path ${path4}`);
507
+ if (active) active.reject(new OAuthFlowError("redirect-mismatch", `unexpected callback path "${path4}"`));
484
508
  return;
485
509
  }
486
510
  if (!active) {
@@ -746,10 +770,16 @@ function readAuditEvents() {
746
770
  import { Entry } from "@napi-rs/keyring";
747
771
 
748
772
  // src/types/credential.ts
773
+ function defaultUnavailableMessage(backend) {
774
+ const base = `OS secret backend unavailable (${backend}). Install the platform's credential service and try again.`;
775
+ if (backend !== "windows-credential-manager") return base;
776
+ return `${base}
777
+ Over SSH, Windows Credential Manager is not reachable. Store credentials with DPAPI in ~/.azdo instead: \`azdo config set credentialStore dpapi\` (or AZDO_CREDENTIAL_STORE=dpapi), then \`azdo auth login\` again.`;
778
+ }
749
779
  var CredentialStoreUnavailableError = class extends Error {
750
780
  backend;
751
- constructor(backend, cause) {
752
- super(`OS secret backend unavailable (${backend}). Install the platform's credential service and try again.`);
781
+ constructor(backend, cause, message) {
782
+ super(message ?? defaultUnavailableMessage(backend));
753
783
  this.name = "CredentialStoreUnavailableError";
754
784
  this.backend = backend;
755
785
  if (cause instanceof Error) {
@@ -796,13 +826,157 @@ function normalizePat(rawPat) {
796
826
  return trimmedPat.length > 0 ? trimmedPat : null;
797
827
  }
798
828
 
829
+ // src/services/dpapi-store.ts
830
+ import fs3 from "fs";
831
+ import os3 from "os";
832
+ import path3 from "path";
833
+
834
+ // src/services/dpapi-binding.ts
835
+ import { createRequire } from "module";
836
+ var cached = null;
837
+ function loadDpapi() {
838
+ if (cached) return cached;
839
+ const require2 = createRequire(import.meta.url);
840
+ const mod = require2("@primno/dpapi");
841
+ if (!mod.isPlatformSupported) {
842
+ throw new Error("the @primno/dpapi native binary is not available for this platform");
843
+ }
844
+ cached = mod.Dpapi;
845
+ return cached;
846
+ }
847
+
848
+ // src/services/dpapi-store.ts
849
+ var BACKEND = "windows-dpapi";
850
+ var FILENAME_UNSAFE = /[^A-Za-z0-9_.-]/g;
851
+ function dpapiCredentialsDir() {
852
+ return path3.join(os3.homedir(), ".azdo", "credentials");
853
+ }
854
+ function dpapiFilePath(account) {
855
+ const safe = account.replaceAll(FILENAME_UNSAFE, (ch) => `_${ch.codePointAt(0).toString(16)}`);
856
+ return path3.join(dpapiCredentialsDir(), `${safe}.dpapi`);
857
+ }
858
+ function entropyFor(account) {
859
+ return Buffer.from(`azdo-cli:${account}`, "utf8");
860
+ }
861
+ function unavailable(cause, message) {
862
+ return new CredentialStoreUnavailableError(BACKEND, cause, message);
863
+ }
864
+ function binding() {
865
+ try {
866
+ return loadDpapi();
867
+ } catch (err) {
868
+ throw unavailable(
869
+ err,
870
+ `DPAPI credential store unavailable: ${err.message}. Reinstall azdo-cli, or switch back with \`azdo config set credentialStore keyring\`.`
871
+ );
872
+ }
873
+ }
874
+ function isNotFound(err) {
875
+ return err.code === "ENOENT";
876
+ }
877
+ var DpapiEntry = class {
878
+ constructor(account) {
879
+ this.account = account;
880
+ this.file = dpapiFilePath(account);
881
+ }
882
+ account;
883
+ file;
884
+ getPassword() {
885
+ let blob;
886
+ try {
887
+ blob = fs3.readFileSync(this.file);
888
+ } catch (err) {
889
+ if (isNotFound(err)) return null;
890
+ throw unavailable(err, `Could not read the DPAPI credential file ${this.file}: ${err.message}`);
891
+ }
892
+ try {
893
+ const plain = binding().unprotectData(blob, entropyFor(this.account), "CurrentUser");
894
+ return Buffer.from(plain).toString("utf8");
895
+ } catch (err) {
896
+ if (err instanceof CredentialStoreUnavailableError) throw err;
897
+ throw unavailable(
898
+ err,
899
+ `Could not decrypt the DPAPI credential file ${this.file}: ${err.message}. It was written by another Windows user, on another machine, or from a logon session without access to your DPAPI master key. Run \`azdo auth login\` again from this session to replace it.`
900
+ );
901
+ }
902
+ }
903
+ setPassword(value) {
904
+ const dpapi = binding();
905
+ const entropy = entropyFor(this.account);
906
+ let blob;
907
+ try {
908
+ blob = dpapi.protectData(Buffer.from(value, "utf8"), entropy, "CurrentUser");
909
+ const check = Buffer.from(dpapi.unprotectData(blob, entropy, "CurrentUser")).toString("utf8");
910
+ if (check !== value) throw new Error("decrypted value does not match");
911
+ } catch (err) {
912
+ throw unavailable(
913
+ err,
914
+ `DPAPI could not protect the credential in this session: ${err.message}. Nothing was stored. Over SSH this usually means the logon has no access to your DPAPI master key (common with public-key authentication); log in with a password, or use AZDO_PAT for this session.`
915
+ );
916
+ }
917
+ const tmp = `${this.file}.${process.pid}.tmp`;
918
+ try {
919
+ fs3.mkdirSync(dpapiCredentialsDir(), { recursive: true, mode: 448 });
920
+ fs3.writeFileSync(tmp, blob, { mode: 384 });
921
+ fs3.renameSync(tmp, this.file);
922
+ } catch (err) {
923
+ fs3.rmSync(tmp, { force: true });
924
+ throw unavailable(err, `Could not write the DPAPI credential file ${this.file}: ${err.message}`);
925
+ }
926
+ }
927
+ deletePassword() {
928
+ try {
929
+ fs3.unlinkSync(this.file);
930
+ return true;
931
+ } catch (err) {
932
+ if (isNotFound(err)) return false;
933
+ throw unavailable(err, `Could not delete the DPAPI credential file ${this.file}: ${err.message}`);
934
+ }
935
+ }
936
+ };
937
+
799
938
  // src/services/credential-store.ts
800
939
  var SERVICE = "azdo-cli";
801
940
  var LEGACY_ACCOUNT = "pat";
802
941
  function accountFor(org) {
803
942
  return `pat:${org}`;
804
943
  }
944
+ function requestedStore() {
945
+ const fromEnv = process.env.AZDO_CREDENTIAL_STORE?.trim();
946
+ if (fromEnv) return fromEnv.toLowerCase();
947
+ let fromConfig;
948
+ try {
949
+ fromConfig = loadConfig()?.credentialStore;
950
+ } catch (err) {
951
+ throw new CredentialStoreUnavailableError(
952
+ "unknown",
953
+ err,
954
+ `Could not read the azdo config file to determine the credential store: ${err.message}. Fix the file, or set AZDO_CREDENTIAL_STORE=<keyring|dpapi> for this session.`
955
+ );
956
+ }
957
+ return fromConfig?.trim().toLowerCase() || "keyring";
958
+ }
959
+ function activeCredentialStore() {
960
+ const requested = requestedStore();
961
+ try {
962
+ return parseCredentialStore(requested);
963
+ } catch (err) {
964
+ const backend = requested === "dpapi" ? "windows-dpapi" : "unknown";
965
+ throw new CredentialStoreUnavailableError(
966
+ backend,
967
+ err,
968
+ `${err.message} Set it with \`azdo config set credentialStore <keyring|dpapi>\` or AZDO_CREDENTIAL_STORE.`
969
+ );
970
+ }
971
+ }
805
972
  function probeBackend() {
973
+ let requested;
974
+ try {
975
+ requested = requestedStore();
976
+ } catch {
977
+ return "unknown";
978
+ }
979
+ if (requested === "dpapi") return "windows-dpapi";
806
980
  switch (process.platform) {
807
981
  case "win32":
808
982
  return "windows-credential-manager";
@@ -814,14 +988,19 @@ function probeBackend() {
814
988
  return "unknown";
815
989
  }
816
990
  }
817
- function wrapUnavailable(fn) {
991
+ function wrapUnavailableAs(backend, fn) {
818
992
  try {
819
993
  return fn();
820
994
  } catch (err) {
821
- throw new CredentialStoreUnavailableError(probeBackend(), err);
995
+ if (err instanceof CredentialStoreUnavailableError) throw err;
996
+ throw new CredentialStoreUnavailableError(backend, err);
822
997
  }
823
998
  }
999
+ function wrapUnavailable(fn) {
1000
+ return wrapUnavailableAs(probeBackend(), fn);
1001
+ }
824
1002
  function entryFor(account) {
1003
+ if (activeCredentialStore() === "dpapi") return new DpapiEntry(account);
825
1004
  return wrapUnavailable(() => new Entry(SERVICE, account));
826
1005
  }
827
1006
  var legacyUnsetNoticeEmitted = false;
@@ -897,6 +1076,7 @@ function serializeCredential(cred) {
897
1076
  return JSON.stringify(cred);
898
1077
  }
899
1078
  async function maybeMigrateLegacy(targetOrg) {
1079
+ if (activeCredentialStore() === "dpapi") return null;
900
1080
  const config = loadConfig();
901
1081
  if (!config.org || config.org !== targetOrg) {
902
1082
  if (!config.org) {
@@ -998,7 +1178,7 @@ async function deletePat(org) {
998
1178
  }
999
1179
  try {
1000
1180
  const { unlinkSync: unlinkSync2 } = await import("fs");
1001
- const { lockPath: lockPath2 } = await import("./oauth-token-refresh-PXUQE5JE.js");
1181
+ const { lockPath: lockPath2 } = await import("./oauth-token-refresh-RUT4XIMM.js");
1002
1182
  unlinkSync2(lockPath2(org));
1003
1183
  } catch {
1004
1184
  }
@@ -1024,6 +1204,62 @@ async function listOrgsWithStoredPat() {
1024
1204
  present.sort((a, b) => a.localeCompare(b));
1025
1205
  return present;
1026
1206
  }
1207
+ function keyringEntry(account) {
1208
+ return new Entry(SERVICE, account);
1209
+ }
1210
+ function knownOrgs() {
1211
+ const orgs = /* @__PURE__ */ new Set();
1212
+ for (const ev of readAuditEvents()) {
1213
+ if (ev.event === "auth.store" || ev.event === "oauth-login-success") orgs.add(ev.org);
1214
+ }
1215
+ try {
1216
+ const config = loadConfig();
1217
+ if (config.org) orgs.add(config.org);
1218
+ for (const name of Object.keys(config.organizations ?? {})) orgs.add(name);
1219
+ } catch {
1220
+ }
1221
+ return [...orgs].sort((a, b) => a.localeCompare(b));
1222
+ }
1223
+ function listKeyringCredentials() {
1224
+ const present = [];
1225
+ for (const org of knownOrgs()) {
1226
+ const value = wrapUnavailableAs(
1227
+ "windows-credential-manager",
1228
+ () => keyringEntry(accountFor(org)).getPassword()
1229
+ );
1230
+ if (value !== null) present.push(org);
1231
+ }
1232
+ return present;
1233
+ }
1234
+ function copyKeyringCredentialsToDpapi(orgs) {
1235
+ const result = { copied: [], skipped: [], failed: [] };
1236
+ for (const org of orgs) {
1237
+ try {
1238
+ const raw = wrapUnavailableAs(
1239
+ "windows-credential-manager",
1240
+ () => keyringEntry(accountFor(org)).getPassword()
1241
+ );
1242
+ if (raw === null) continue;
1243
+ const target = new DpapiEntry(accountFor(org));
1244
+ if (target.getPassword() !== null) {
1245
+ result.skipped.push(org);
1246
+ continue;
1247
+ }
1248
+ const cred = parseStoredValue(raw);
1249
+ target.setPassword(raw);
1250
+ appendAuthAuditEvent({
1251
+ event: "auth.store",
1252
+ org,
1253
+ backend: "windows-dpapi",
1254
+ ...cred.kind === "pat" ? { masked_pat: maskedDisplay(cred.token) } : { accountId: cred.accountId }
1255
+ });
1256
+ result.copied.push(org);
1257
+ } catch (err) {
1258
+ result.failed.push({ org, message: err.message });
1259
+ }
1260
+ }
1261
+ return result;
1262
+ }
1027
1263
 
1028
1264
  // src/services/oauth-token-refresh.ts
1029
1265
  var DEFAULT_LOCK_WAIT_MS = 5e3;
@@ -1031,23 +1267,23 @@ var inFlight = /* @__PURE__ */ new Map();
1031
1267
  function locksDir() {
1032
1268
  return join(homedir(), ".azdo", ".locks");
1033
1269
  }
1034
- var FILENAME_UNSAFE = /[^A-Za-z0-9_.-]/g;
1270
+ var FILENAME_UNSAFE2 = /[^A-Za-z0-9_.-]/g;
1035
1271
  function lockPath(org) {
1036
- const safe = org.replaceAll(FILENAME_UNSAFE, "_");
1272
+ const safe = org.replaceAll(FILENAME_UNSAFE2, "_");
1037
1273
  return join(locksDir(), `${safe}.refresh`);
1038
1274
  }
1039
1275
  async function defaultAcquireLock(org, waitMs = DEFAULT_LOCK_WAIT_MS) {
1040
- const path3 = lockPath(org);
1276
+ const path4 = lockPath(org);
1041
1277
  mkdirSync(locksDir(), { recursive: true, mode: 448 });
1042
1278
  const deadline = Date.now() + waitMs;
1043
1279
  for (; ; ) {
1044
1280
  try {
1045
- const fd = openSync(path3, "wx");
1281
+ const fd = openSync(path4, "wx");
1046
1282
  closeSync(fd);
1047
1283
  return {
1048
1284
  release: () => {
1049
1285
  try {
1050
- unlinkSync(path3);
1286
+ unlinkSync(path4);
1051
1287
  } catch {
1052
1288
  }
1053
1289
  }
@@ -1226,5 +1462,7 @@ export {
1226
1462
  storePat,
1227
1463
  storeOAuthCredential,
1228
1464
  deletePat,
1229
- listOrgsWithStoredPat
1465
+ listOrgsWithStoredPat,
1466
+ listKeyringCredentials,
1467
+ copyKeyringCredentialsToDpapi
1230
1468
  };
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  SETTINGS,
8
8
  appendAuthAuditEvent,
9
9
  buildScopeString,
10
+ copyKeyringCredentialsToDpapi,
10
11
  copyOrgScope,
11
12
  defaultScopes,
12
13
  deleteOrgScope,
@@ -16,6 +17,7 @@ import {
16
17
  getOrgScopedValue,
17
18
  getPat,
18
19
  getStoredCredential,
20
+ listKeyringCredentials,
19
21
  listOrgsWithStoredPat,
20
22
  loadConfig,
21
23
  maskedDisplay,
@@ -37,7 +39,7 @@ import {
37
39
  tokenResponseToCredential,
38
40
  unsetConfigValue,
39
41
  unsetOrgScopedValue
40
- } from "./chunk-K2CKMY72.js";
42
+ } from "./chunk-W4EVKARR.js";
41
43
 
42
44
  // src/program.ts
43
45
  import { Command as Command19 } from "commander";
@@ -141,6 +143,14 @@ function getActiveTraceWriter() {
141
143
  }
142
144
 
143
145
  // src/services/command-helpers.ts
146
+ var EXIT_CREDENTIAL_STORE_UNAVAILABLE = 4;
147
+ function reportCredentialStoreUnavailable(err) {
148
+ if (!(err instanceof CredentialStoreUnavailableError)) return false;
149
+ process.stderr.write(`Error: ${err.message}
150
+ `);
151
+ process.exitCode = EXIT_CREDENTIAL_STORE_UNAVAILABLE;
152
+ return true;
153
+ }
144
154
  function isSentinel(message, sentinel) {
145
155
  return message === sentinel || message.startsWith(`${sentinel}: `);
146
156
  }
@@ -219,6 +229,10 @@ function formatCreateError(err) {
219
229
  return `Create rejected: ${message}`;
220
230
  }
221
231
  function handleCommandError(err, id, context, scope = "write", exit = true) {
232
+ if (reportCredentialStoreUnavailable(err)) {
233
+ if (exit) process.exit(EXIT_CREDENTIAL_STORE_UNAVAILABLE);
234
+ return;
235
+ }
222
236
  const error = err instanceof Error ? err : new Error(String(err));
223
237
  const msg = error.message;
224
238
  const scopeLabel = scope === "read" ? "Work Items (read)" : "Work Items (Read & Write)";
@@ -2607,6 +2621,56 @@ not be resolved, 4 OS credential store unavailable. stdout stays empty on all.
2607
2621
  // src/commands/config.ts
2608
2622
  import { Command as Command4 } from "commander";
2609
2623
  import { createInterface as createInterface2 } from "readline";
2624
+
2625
+ // src/services/dpapi-copy.ts
2626
+ var RERUN = "azdo config set credentialStore dpapi --copy-credentials";
2627
+ function isYes(answer) {
2628
+ const a = answer.trim().toLowerCase();
2629
+ return a === "" || a === "y" || a === "yes";
2630
+ }
2631
+ function empty(found, keyringUnavailable) {
2632
+ return { found, keyringUnavailable, copied: [], skipped: [], failed: [] };
2633
+ }
2634
+ async function offerKeyringToDpapiCopy(choice, io) {
2635
+ if (choice === "no") return empty([], false);
2636
+ let found;
2637
+ try {
2638
+ found = listKeyringCredentials();
2639
+ } catch {
2640
+ io.notice(
2641
+ `Credential Manager is not reachable from this session (typical over SSH), so no existing credentials were copied. To bring them over, run \`${RERUN}\` once from a console session.
2642
+ `
2643
+ );
2644
+ return empty([], true);
2645
+ }
2646
+ if (found.length === 0) return empty(found, false);
2647
+ const list = found.join(", ");
2648
+ if (choice === "ask") {
2649
+ if (!io.interactive) {
2650
+ io.notice(`Credential Manager holds credentials for ${list}. Run \`${RERUN}\` to copy them to the DPAPI store.
2651
+ `);
2652
+ return empty(found, false);
2653
+ }
2654
+ const answer = await io.ask(`Copy the credentials for ${list} from Credential Manager to the DPAPI store? [Y/n] `);
2655
+ if (!isYes(answer)) {
2656
+ io.notice("Nothing copied. Run `azdo auth login` for each org, or re-run with --copy-credentials.\n");
2657
+ return empty(found, false);
2658
+ }
2659
+ }
2660
+ const result = copyKeyringCredentialsToDpapi(found);
2661
+ for (const org of result.copied) io.notice(`Copied the credential for ${org} to the DPAPI store.
2662
+ `);
2663
+ for (const org of result.skipped) io.notice(`Kept the existing DPAPI credential for ${org} (not overwritten).
2664
+ `);
2665
+ for (const { org, message } of result.failed) io.notice(`Could not copy the credential for ${org}: ${message}
2666
+ `);
2667
+ if (result.copied.length > 0) {
2668
+ io.notice("The Credential Manager entries were left in place; `credentialStore keyring` still finds them.\n");
2669
+ }
2670
+ return { found, keyringUnavailable: false, ...result };
2671
+ }
2672
+
2673
+ // src/commands/config.ts
2610
2674
  function formatConfigValue(value, unsetFallback = "") {
2611
2675
  if (value === void 0) {
2612
2676
  return unsetFallback;
@@ -2627,7 +2691,8 @@ function buildConfigListEntries(cfg) {
2627
2691
  return entries;
2628
2692
  }
2629
2693
  function writeConfigList(cfg) {
2630
- const keyWidth = 10;
2694
+ const orgKeys = Object.values(cfg.organizations ?? {}).flatMap((scope) => Object.keys(scope ?? {}));
2695
+ const keyWidth = Math.max(10, ...SETTINGS.map((s) => s.key.length + 1), ...orgKeys.map((k) => k.length + 1));
2631
2696
  const valueWidth = 30;
2632
2697
  const scopeWidth = 12;
2633
2698
  process.stdout.write(
@@ -2661,6 +2726,25 @@ function writeConfigList(cfg) {
2661
2726
  );
2662
2727
  }
2663
2728
  }
2729
+ async function offerCopy(options) {
2730
+ let choice = "ask";
2731
+ if (options.copyCredentials === true) choice = "yes";
2732
+ else if (options.copyCredentials === false) choice = "no";
2733
+ const interactive = !options.json && Boolean(process.stdin.isTTY) && Boolean(process.stderr.isTTY);
2734
+ let rl;
2735
+ try {
2736
+ return await offerKeyringToDpapiCopy(choice, {
2737
+ interactive,
2738
+ ask: (prompt) => {
2739
+ rl ??= createInterface2({ input: process.stdin, output: process.stderr });
2740
+ return createAsk(rl)(prompt);
2741
+ },
2742
+ notice: (message) => process.stderr.write(message)
2743
+ });
2744
+ } finally {
2745
+ rl?.close();
2746
+ }
2747
+ }
2664
2748
  function createAsk(rl) {
2665
2749
  return (prompt) => new Promise((resolve2) => rl.question(prompt, resolve2));
2666
2750
  }
@@ -2702,7 +2786,8 @@ function createConfigCommand() {
2702
2786
  "",
2703
2787
  "Credentials are NOT stored in the configuration file. They are resolved in this order:",
2704
2788
  " 1. the AZDO_PAT environment variable (wins over everything below)",
2705
- " 2. the OS credential store, per organization (see `azdo auth login`)",
2789
+ " 2. the OS credential store, per organization (see `azdo auth login`) \u2014 or, with",
2790
+ " `credentialStore` set to dpapi (Windows), DPAPI-encrypted files in ~/.azdo/credentials",
2706
2791
  " 3. a .env file with AZDO_PAT, searched upwards from the working directory",
2707
2792
  "",
2708
2793
  "Only AZDO_PAT is read \u2014 AZURE_DEVOPS_PAT, AZURE_DEVOPS_EXT_PAT and AZDO_TOKEN are ignored.",
@@ -2712,33 +2797,41 @@ function createConfigCommand() {
2712
2797
  ].join("\n")
2713
2798
  );
2714
2799
  const set = new Command4("set");
2715
- set.description("Set a configuration value").argument("<key>", "setting key (org, project, fields, markdown)").argument("<value>", "setting value").option("--org <org>", "set value in an org-scoped configuration").option("--json", "output in JSON format").action((key, value, options) => {
2800
+ set.description("Set a configuration value").argument("<key>", "setting key (org, project, fields, markdown, credentialStore)").argument("<value>", "setting value").option("--org <org>", "set value in an org-scoped configuration").option("--json", "output in JSON format").option("--copy-credentials", "with credentialStore dpapi: copy Credential Manager credentials without asking").option("--no-copy-credentials", "with credentialStore dpapi: do not offer to copy Credential Manager credentials").action((key, value, options) => {
2716
2801
  try {
2717
2802
  if (options.org) {
2718
2803
  setOrgScopedValue(options.org, key, value);
2719
2804
  } else {
2720
2805
  setConfigValue(key, value);
2721
2806
  }
2722
- if (options.json) {
2723
- const output = { key, value, scope: options.org ?? "default" };
2724
- if (key === "fields") {
2725
- output.value = value.split(",").map((s) => s.trim());
2726
- }
2727
- process.stdout.write(JSON.stringify(output) + "\n");
2728
- } else {
2729
- const scopeTag = options.org ? ` (org: ${options.org})` : "";
2730
- process.stdout.write(`Set "${key}" to "${value}"${scopeTag}
2731
- `);
2732
- }
2733
2807
  } catch (err) {
2734
2808
  const message = err instanceof Error ? err.message : String(err);
2735
2809
  process.stderr.write(`Error: ${message}
2736
2810
  `);
2737
2811
  process.exit(1);
2738
2812
  }
2813
+ const output = { key, value, scope: options.org ?? "default" };
2814
+ if (key === "fields") {
2815
+ output.value = value.split(",").map((s) => s.trim());
2816
+ }
2817
+ if (!options.json) {
2818
+ const scopeTag = options.org ? ` (org: ${options.org})` : "";
2819
+ process.stdout.write(`Set "${key}" to "${value}"${scopeTag}
2820
+ `);
2821
+ }
2822
+ const writeJson = () => {
2823
+ if (options.json) process.stdout.write(JSON.stringify(output) + "\n");
2824
+ };
2825
+ if (!options.org && key === "credentialStore" && loadConfig().credentialStore === "dpapi") {
2826
+ return offerCopy(options).then((outcome) => {
2827
+ output.credentialsCopied = outcome.copied;
2828
+ writeJson();
2829
+ });
2830
+ }
2831
+ writeJson();
2739
2832
  });
2740
2833
  const get = new Command4("get");
2741
- get.description("Get a configuration value").argument("<key>", "setting key (org, project, fields, markdown)").option("--org <org>", "read from an org-scoped configuration").option("--json", "output in JSON format").action((key, options) => {
2834
+ get.description("Get a configuration value").argument("<key>", "setting key (org, project, fields, markdown, credentialStore)").option("--org <org>", "read from an org-scoped configuration").option("--json", "output in JSON format").action((key, options) => {
2742
2835
  try {
2743
2836
  const value = options.org ? getOrgScopedValue(options.org, key) : getConfigValue(key);
2744
2837
  if (options.json) {
@@ -2771,7 +2864,7 @@ function createConfigCommand() {
2771
2864
  writeConfigList(cfg);
2772
2865
  });
2773
2866
  const unset = new Command4("unset");
2774
- unset.description("Remove a configuration value").argument("<key>", "setting key (org, project, fields, markdown)").option("--org <org>", "remove from an org-scoped configuration").option("--json", "output in JSON format").action((key, options) => {
2867
+ unset.description("Remove a configuration value").argument("<key>", "setting key (org, project, fields, markdown, credentialStore)").option("--org <org>", "remove from an org-scoped configuration").option("--json", "output in JSON format").action((key, options) => {
2775
2868
  try {
2776
2869
  if (options.org) {
2777
2870
  unsetOrgScopedValue(options.org, key);
@@ -2809,6 +2902,7 @@ function createConfigCommand() {
2809
2902
  process.stderr.write("Azure DevOps CLI - Configuration Wizard\n");
2810
2903
  process.stderr.write("=======================================\n\n");
2811
2904
  for (const setting of SETTINGS) {
2905
+ if (setting.key === "credentialStore") continue;
2812
2906
  await promptForSetting(cfg, setting, ask);
2813
2907
  }
2814
2908
  rl.close();
@@ -3380,6 +3474,7 @@ function isCreateWriteError(err) {
3380
3474
  return isSentinel(err.message, "AUTH_FAILED") || isSentinel(err.message, "PERMISSION_DENIED") || err.message === "NETWORK_ERROR" || err.message.startsWith("BAD_REQUEST:") || err.message.startsWith("HTTP_");
3381
3475
  }
3382
3476
  function handleUpsertError(err, id, context) {
3477
+ if (reportCredentialStoreUnavailable(err)) process.exit(EXIT_CREDENTIAL_STORE_UNAVAILABLE);
3383
3478
  if (!(err instanceof Error)) {
3384
3479
  process.stderr.write(`Error: ${String(err)}
3385
3480
  `);
@@ -4374,6 +4469,7 @@ function writeError(message, exitCode = 1) {
4374
4469
  process.exitCode = exitCode;
4375
4470
  }
4376
4471
  function handlePrCommandError(err, context, mode = "read") {
4472
+ if (reportCredentialStoreUnavailable(err)) return;
4377
4473
  const error = err instanceof Error ? err : new Error(String(err));
4378
4474
  if (isSentinel(error.message, "AUTH_FAILED")) {
4379
4475
  const scopeLabel = mode === "write" ? "Code (Read & Write)" : "Code (Read)";
@@ -6084,6 +6180,7 @@ function writeError2(message) {
6084
6180
  process.exitCode = 1;
6085
6181
  }
6086
6182
  function handlePipelineError(err, context) {
6183
+ if (reportCredentialStoreUnavailable(err)) return;
6087
6184
  const error = err instanceof Error ? err : new Error(String(err));
6088
6185
  if (isSentinel(error.message, "AUTH_FAILED")) {
6089
6186
  writeError2('Authentication failed. Check that your credential is valid and has the "Build (Read)" scope.');
@@ -7143,6 +7240,7 @@ function formatRelationsList(workItemId, relations) {
7143
7240
  return lines.join("\n");
7144
7241
  }
7145
7242
  function handleRelationError(err, id1) {
7243
+ if (reportCredentialStoreUnavailable(err)) process.exit(EXIT_CREDENTIAL_STORE_UNAVAILABLE);
7146
7244
  const msg = err instanceof Error ? err.message : String(err);
7147
7245
  if (msg === "SELF_RELATION") {
7148
7246
  process.stderr.write(`Error: cannot relate a work item to itself (#${id1}).
@@ -5,7 +5,7 @@ import {
5
5
  lockPath,
6
6
  locksDir,
7
7
  refreshIfNeeded
8
- } from "./chunk-K2CKMY72.js";
8
+ } from "./chunk-W4EVKARR.js";
9
9
  export {
10
10
  _resetInFlight,
11
11
  classifyRefreshFailure,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azdo-cli",
3
- "version": "0.20.0-develop.741",
3
+ "version": "0.20.0-develop.751",
4
4
  "description": "Azure DevOps CLI tool",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,7 +25,8 @@
25
25
  },
26
26
  "license": "MIT",
27
27
  "dependencies": {
28
- "@napi-rs/keyring": "^1.2.0",
28
+ "@napi-rs/keyring": "^1.3.0",
29
+ "@primno/dpapi": "^2.0.1",
29
30
  "commander": "^14.0.3",
30
31
  "jimp": "^1.6.1",
31
32
  "node-html-markdown": "^2.0.0"
@@ -35,12 +36,12 @@
35
36
  },
36
37
  "devDependencies": {
37
38
  "@eslint/js": "^10.0.1",
38
- "@types/node": "^25.5.0",
39
- "eslint": "^10.1.0",
40
- "prettier": "^3.8.1",
39
+ "@types/node": "^25.9.8",
40
+ "eslint": "^10.11.0",
41
+ "prettier": "^3.9.9",
41
42
  "tsup": "^8.5.1",
42
43
  "typescript": "^5.9.3",
43
- "typescript-eslint": "^8.57.2",
44
- "vitest": "^4.1.2"
44
+ "typescript-eslint": "^8.70.1",
45
+ "vitest": "^4.1.11"
45
46
  }
46
47
  }