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
|
|
503
|
+
const path4 = url.pathname;
|
|
480
504
|
const q = url.searchParams;
|
|
481
|
-
if (
|
|
482
|
-
writeError(res, 404, `unexpected path ${
|
|
483
|
-
if (active) active.reject(new OAuthFlowError("redirect-mismatch", `unexpected callback path "${
|
|
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(
|
|
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
|
|
991
|
+
function wrapUnavailableAs(backend, fn) {
|
|
818
992
|
try {
|
|
819
993
|
return fn();
|
|
820
994
|
} catch (err) {
|
|
821
|
-
|
|
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-
|
|
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
|
|
1270
|
+
var FILENAME_UNSAFE2 = /[^A-Za-z0-9_.-]/g;
|
|
1035
1271
|
function lockPath(org) {
|
|
1036
|
-
const safe = org.replaceAll(
|
|
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
|
|
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(
|
|
1281
|
+
const fd = openSync(path4, "wx");
|
|
1046
1282
|
closeSync(fd);
|
|
1047
1283
|
return {
|
|
1048
1284
|
release: () => {
|
|
1049
1285
|
try {
|
|
1050
|
-
unlinkSync(
|
|
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-
|
|
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
|
|
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}).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "azdo-cli",
|
|
3
|
-
"version": "0.20.0-develop.
|
|
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.
|
|
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.
|
|
39
|
-
"eslint": "^10.
|
|
40
|
-
"prettier": "^3.
|
|
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.
|
|
44
|
-
"vitest": "^4.1.
|
|
44
|
+
"typescript-eslint": "^8.70.1",
|
|
45
|
+
"vitest": "^4.1.11"
|
|
45
46
|
}
|
|
46
47
|
}
|