azdo-cli 0.20.0-feature-043-dpapi-credential-store.742 → 0.20.0-feature-043-dpapi-credential-store.746

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
 
@@ -947,8 +947,12 @@ function requestedStore() {
947
947
  let fromConfig;
948
948
  try {
949
949
  fromConfig = loadConfig()?.credentialStore;
950
- } catch {
951
- fromConfig = void 0;
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
+ );
952
956
  }
953
957
  return fromConfig?.trim().toLowerCase() || "keyring";
954
958
  }
@@ -966,7 +970,13 @@ function activeCredentialStore() {
966
970
  }
967
971
  }
968
972
  function probeBackend() {
969
- if (requestedStore() === "dpapi") return "windows-dpapi";
973
+ let requested;
974
+ try {
975
+ requested = requestedStore();
976
+ } catch {
977
+ return "unknown";
978
+ }
979
+ if (requested === "dpapi") return "windows-dpapi";
970
980
  switch (process.platform) {
971
981
  case "win32":
972
982
  return "windows-credential-manager";
@@ -978,14 +988,17 @@ function probeBackend() {
978
988
  return "unknown";
979
989
  }
980
990
  }
981
- function wrapUnavailable(fn) {
991
+ function wrapUnavailableAs(backend, fn) {
982
992
  try {
983
993
  return fn();
984
994
  } catch (err) {
985
995
  if (err instanceof CredentialStoreUnavailableError) throw err;
986
- throw new CredentialStoreUnavailableError(probeBackend(), err);
996
+ throw new CredentialStoreUnavailableError(backend, err);
987
997
  }
988
998
  }
999
+ function wrapUnavailable(fn) {
1000
+ return wrapUnavailableAs(probeBackend(), fn);
1001
+ }
989
1002
  function entryFor(account) {
990
1003
  if (activeCredentialStore() === "dpapi") return new DpapiEntry(account);
991
1004
  return wrapUnavailable(() => new Entry(SERVICE, account));
@@ -1165,7 +1178,7 @@ async function deletePat(org) {
1165
1178
  }
1166
1179
  try {
1167
1180
  const { unlinkSync: unlinkSync2 } = await import("fs");
1168
- const { lockPath: lockPath2 } = await import("./oauth-token-refresh-5O2DYWFR.js");
1181
+ const { lockPath: lockPath2 } = await import("./oauth-token-refresh-RUT4XIMM.js");
1169
1182
  unlinkSync2(lockPath2(org));
1170
1183
  } catch {
1171
1184
  }
@@ -1191,6 +1204,62 @@ async function listOrgsWithStoredPat() {
1191
1204
  present.sort((a, b) => a.localeCompare(b));
1192
1205
  return present;
1193
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
+ }
1194
1263
 
1195
1264
  // src/services/oauth-token-refresh.ts
1196
1265
  var DEFAULT_LOCK_WAIT_MS = 5e3;
@@ -1393,5 +1462,7 @@ export {
1393
1462
  storePat,
1394
1463
  storeOAuthCredential,
1395
1464
  deletePat,
1396
- listOrgsWithStoredPat
1465
+ listOrgsWithStoredPat,
1466
+ listKeyringCredentials,
1467
+ copyKeyringCredentialsToDpapi
1397
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-B76UX3CR.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
  }
@@ -2713,30 +2797,38 @@ function createConfigCommand() {
2713
2797
  ].join("\n")
2714
2798
  );
2715
2799
  const set = new Command4("set");
2716
- 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").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) => {
2717
2801
  try {
2718
2802
  if (options.org) {
2719
2803
  setOrgScopedValue(options.org, key, value);
2720
2804
  } else {
2721
2805
  setConfigValue(key, value);
2722
2806
  }
2723
- if (options.json) {
2724
- const output = { key, value, scope: options.org ?? "default" };
2725
- if (key === "fields") {
2726
- output.value = value.split(",").map((s) => s.trim());
2727
- }
2728
- process.stdout.write(JSON.stringify(output) + "\n");
2729
- } else {
2730
- const scopeTag = options.org ? ` (org: ${options.org})` : "";
2731
- process.stdout.write(`Set "${key}" to "${value}"${scopeTag}
2732
- `);
2733
- }
2734
2807
  } catch (err) {
2735
2808
  const message = err instanceof Error ? err.message : String(err);
2736
2809
  process.stderr.write(`Error: ${message}
2737
2810
  `);
2738
2811
  process.exit(1);
2739
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();
2740
2832
  });
2741
2833
  const get = new Command4("get");
2742
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) => {
@@ -3382,6 +3474,7 @@ function isCreateWriteError(err) {
3382
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_");
3383
3475
  }
3384
3476
  function handleUpsertError(err, id, context) {
3477
+ if (reportCredentialStoreUnavailable(err)) process.exit(EXIT_CREDENTIAL_STORE_UNAVAILABLE);
3385
3478
  if (!(err instanceof Error)) {
3386
3479
  process.stderr.write(`Error: ${String(err)}
3387
3480
  `);
@@ -4376,6 +4469,7 @@ function writeError(message, exitCode = 1) {
4376
4469
  process.exitCode = exitCode;
4377
4470
  }
4378
4471
  function handlePrCommandError(err, context, mode = "read") {
4472
+ if (reportCredentialStoreUnavailable(err)) return;
4379
4473
  const error = err instanceof Error ? err : new Error(String(err));
4380
4474
  if (isSentinel(error.message, "AUTH_FAILED")) {
4381
4475
  const scopeLabel = mode === "write" ? "Code (Read & Write)" : "Code (Read)";
@@ -6086,6 +6180,7 @@ function writeError2(message) {
6086
6180
  process.exitCode = 1;
6087
6181
  }
6088
6182
  function handlePipelineError(err, context) {
6183
+ if (reportCredentialStoreUnavailable(err)) return;
6089
6184
  const error = err instanceof Error ? err : new Error(String(err));
6090
6185
  if (isSentinel(error.message, "AUTH_FAILED")) {
6091
6186
  writeError2('Authentication failed. Check that your credential is valid and has the "Build (Read)" scope.');
@@ -7145,6 +7240,7 @@ function formatRelationsList(workItemId, relations) {
7145
7240
  return lines.join("\n");
7146
7241
  }
7147
7242
  function handleRelationError(err, id1) {
7243
+ if (reportCredentialStoreUnavailable(err)) process.exit(EXIT_CREDENTIAL_STORE_UNAVAILABLE);
7148
7244
  const msg = err instanceof Error ? err.message : String(err);
7149
7245
  if (msg === "SELF_RELATION") {
7150
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-B76UX3CR.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-feature-043-dpapi-credential-store.742",
3
+ "version": "0.20.0-feature-043-dpapi-credential-store.746",
4
4
  "description": "Azure DevOps CLI tool",
5
5
  "type": "module",
6
6
  "bin": {