azdo-cli 0.20.0-feature-042-update-notice-command.739 → 0.20.0-feature-043-dpapi-credential-store.744
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.
|
@@ -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,147 @@ 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 {
|
|
951
|
+
fromConfig = void 0;
|
|
952
|
+
}
|
|
953
|
+
return fromConfig?.trim().toLowerCase() || "keyring";
|
|
954
|
+
}
|
|
955
|
+
function activeCredentialStore() {
|
|
956
|
+
const requested = requestedStore();
|
|
957
|
+
try {
|
|
958
|
+
return parseCredentialStore(requested);
|
|
959
|
+
} catch (err) {
|
|
960
|
+
const backend = requested === "dpapi" ? "windows-dpapi" : "unknown";
|
|
961
|
+
throw new CredentialStoreUnavailableError(
|
|
962
|
+
backend,
|
|
963
|
+
err,
|
|
964
|
+
`${err.message} Set it with \`azdo config set credentialStore <keyring|dpapi>\` or AZDO_CREDENTIAL_STORE.`
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
805
968
|
function probeBackend() {
|
|
969
|
+
if (requestedStore() === "dpapi") return "windows-dpapi";
|
|
806
970
|
switch (process.platform) {
|
|
807
971
|
case "win32":
|
|
808
972
|
return "windows-credential-manager";
|
|
@@ -814,14 +978,19 @@ function probeBackend() {
|
|
|
814
978
|
return "unknown";
|
|
815
979
|
}
|
|
816
980
|
}
|
|
817
|
-
function
|
|
981
|
+
function wrapUnavailableAs(backend, fn) {
|
|
818
982
|
try {
|
|
819
983
|
return fn();
|
|
820
984
|
} catch (err) {
|
|
821
|
-
|
|
985
|
+
if (err instanceof CredentialStoreUnavailableError) throw err;
|
|
986
|
+
throw new CredentialStoreUnavailableError(backend, err);
|
|
822
987
|
}
|
|
823
988
|
}
|
|
989
|
+
function wrapUnavailable(fn) {
|
|
990
|
+
return wrapUnavailableAs(probeBackend(), fn);
|
|
991
|
+
}
|
|
824
992
|
function entryFor(account) {
|
|
993
|
+
if (activeCredentialStore() === "dpapi") return new DpapiEntry(account);
|
|
825
994
|
return wrapUnavailable(() => new Entry(SERVICE, account));
|
|
826
995
|
}
|
|
827
996
|
var legacyUnsetNoticeEmitted = false;
|
|
@@ -897,6 +1066,7 @@ function serializeCredential(cred) {
|
|
|
897
1066
|
return JSON.stringify(cred);
|
|
898
1067
|
}
|
|
899
1068
|
async function maybeMigrateLegacy(targetOrg) {
|
|
1069
|
+
if (activeCredentialStore() === "dpapi") return null;
|
|
900
1070
|
const config = loadConfig();
|
|
901
1071
|
if (!config.org || config.org !== targetOrg) {
|
|
902
1072
|
if (!config.org) {
|
|
@@ -998,7 +1168,7 @@ async function deletePat(org) {
|
|
|
998
1168
|
}
|
|
999
1169
|
try {
|
|
1000
1170
|
const { unlinkSync: unlinkSync2 } = await import("fs");
|
|
1001
|
-
const { lockPath: lockPath2 } = await import("./oauth-token-refresh-
|
|
1171
|
+
const { lockPath: lockPath2 } = await import("./oauth-token-refresh-M6L5EUZW.js");
|
|
1002
1172
|
unlinkSync2(lockPath2(org));
|
|
1003
1173
|
} catch {
|
|
1004
1174
|
}
|
|
@@ -1024,6 +1194,62 @@ async function listOrgsWithStoredPat() {
|
|
|
1024
1194
|
present.sort((a, b) => a.localeCompare(b));
|
|
1025
1195
|
return present;
|
|
1026
1196
|
}
|
|
1197
|
+
function keyringEntry(account) {
|
|
1198
|
+
return new Entry(SERVICE, account);
|
|
1199
|
+
}
|
|
1200
|
+
function knownOrgs() {
|
|
1201
|
+
const orgs = /* @__PURE__ */ new Set();
|
|
1202
|
+
for (const ev of readAuditEvents()) {
|
|
1203
|
+
if (ev.event === "auth.store" || ev.event === "oauth-login-success") orgs.add(ev.org);
|
|
1204
|
+
}
|
|
1205
|
+
try {
|
|
1206
|
+
const config = loadConfig();
|
|
1207
|
+
if (config.org) orgs.add(config.org);
|
|
1208
|
+
for (const name of Object.keys(config.organizations ?? {})) orgs.add(name);
|
|
1209
|
+
} catch {
|
|
1210
|
+
}
|
|
1211
|
+
return [...orgs].sort((a, b) => a.localeCompare(b));
|
|
1212
|
+
}
|
|
1213
|
+
function listKeyringCredentials() {
|
|
1214
|
+
const present = [];
|
|
1215
|
+
for (const org of knownOrgs()) {
|
|
1216
|
+
const value = wrapUnavailableAs(
|
|
1217
|
+
"windows-credential-manager",
|
|
1218
|
+
() => keyringEntry(accountFor(org)).getPassword()
|
|
1219
|
+
);
|
|
1220
|
+
if (value !== null) present.push(org);
|
|
1221
|
+
}
|
|
1222
|
+
return present;
|
|
1223
|
+
}
|
|
1224
|
+
function copyKeyringCredentialsToDpapi(orgs) {
|
|
1225
|
+
const result = { copied: [], skipped: [], failed: [] };
|
|
1226
|
+
for (const org of orgs) {
|
|
1227
|
+
try {
|
|
1228
|
+
const raw = wrapUnavailableAs(
|
|
1229
|
+
"windows-credential-manager",
|
|
1230
|
+
() => keyringEntry(accountFor(org)).getPassword()
|
|
1231
|
+
);
|
|
1232
|
+
if (raw === null) continue;
|
|
1233
|
+
const target = new DpapiEntry(accountFor(org));
|
|
1234
|
+
if (target.getPassword() !== null) {
|
|
1235
|
+
result.skipped.push(org);
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
const cred = parseStoredValue(raw);
|
|
1239
|
+
target.setPassword(raw);
|
|
1240
|
+
appendAuthAuditEvent({
|
|
1241
|
+
event: "auth.store",
|
|
1242
|
+
org,
|
|
1243
|
+
backend: "windows-dpapi",
|
|
1244
|
+
...cred.kind === "pat" ? { masked_pat: maskedDisplay(cred.token) } : { accountId: cred.accountId }
|
|
1245
|
+
});
|
|
1246
|
+
result.copied.push(org);
|
|
1247
|
+
} catch (err) {
|
|
1248
|
+
result.failed.push({ org, message: err.message });
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
return result;
|
|
1252
|
+
}
|
|
1027
1253
|
|
|
1028
1254
|
// src/services/oauth-token-refresh.ts
|
|
1029
1255
|
var DEFAULT_LOCK_WAIT_MS = 5e3;
|
|
@@ -1031,23 +1257,23 @@ var inFlight = /* @__PURE__ */ new Map();
|
|
|
1031
1257
|
function locksDir() {
|
|
1032
1258
|
return join(homedir(), ".azdo", ".locks");
|
|
1033
1259
|
}
|
|
1034
|
-
var
|
|
1260
|
+
var FILENAME_UNSAFE2 = /[^A-Za-z0-9_.-]/g;
|
|
1035
1261
|
function lockPath(org) {
|
|
1036
|
-
const safe = org.replaceAll(
|
|
1262
|
+
const safe = org.replaceAll(FILENAME_UNSAFE2, "_");
|
|
1037
1263
|
return join(locksDir(), `${safe}.refresh`);
|
|
1038
1264
|
}
|
|
1039
1265
|
async function defaultAcquireLock(org, waitMs = DEFAULT_LOCK_WAIT_MS) {
|
|
1040
|
-
const
|
|
1266
|
+
const path4 = lockPath(org);
|
|
1041
1267
|
mkdirSync(locksDir(), { recursive: true, mode: 448 });
|
|
1042
1268
|
const deadline = Date.now() + waitMs;
|
|
1043
1269
|
for (; ; ) {
|
|
1044
1270
|
try {
|
|
1045
|
-
const fd = openSync(
|
|
1271
|
+
const fd = openSync(path4, "wx");
|
|
1046
1272
|
closeSync(fd);
|
|
1047
1273
|
return {
|
|
1048
1274
|
release: () => {
|
|
1049
1275
|
try {
|
|
1050
|
-
unlinkSync(
|
|
1276
|
+
unlinkSync(path4);
|
|
1051
1277
|
} catch {
|
|
1052
1278
|
}
|
|
1053
1279
|
}
|
|
@@ -1226,5 +1452,7 @@ export {
|
|
|
1226
1452
|
storePat,
|
|
1227
1453
|
storeOAuthCredential,
|
|
1228
1454
|
deletePat,
|
|
1229
|
-
listOrgsWithStoredPat
|
|
1455
|
+
listOrgsWithStoredPat,
|
|
1456
|
+
listKeyringCredentials,
|
|
1457
|
+
copyKeyringCredentialsToDpapi
|
|
1230
1458
|
};
|
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-RG45CRKX.js";
|
|
41
43
|
|
|
42
44
|
// src/program.ts
|
|
43
45
|
import { Command as Command19 } from "commander";
|
|
@@ -2607,6 +2609,56 @@ not be resolved, 4 OS credential store unavailable. stdout stays empty on all.
|
|
|
2607
2609
|
// src/commands/config.ts
|
|
2608
2610
|
import { Command as Command4 } from "commander";
|
|
2609
2611
|
import { createInterface as createInterface2 } from "readline";
|
|
2612
|
+
|
|
2613
|
+
// src/services/dpapi-copy.ts
|
|
2614
|
+
var RERUN = "azdo config set credentialStore dpapi --copy-credentials";
|
|
2615
|
+
function isYes(answer) {
|
|
2616
|
+
const a = answer.trim().toLowerCase();
|
|
2617
|
+
return a === "" || a === "y" || a === "yes";
|
|
2618
|
+
}
|
|
2619
|
+
function empty(found, keyringUnavailable) {
|
|
2620
|
+
return { found, keyringUnavailable, copied: [], skipped: [], failed: [] };
|
|
2621
|
+
}
|
|
2622
|
+
async function offerKeyringToDpapiCopy(choice, io) {
|
|
2623
|
+
if (choice === "no") return empty([], false);
|
|
2624
|
+
let found;
|
|
2625
|
+
try {
|
|
2626
|
+
found = listKeyringCredentials();
|
|
2627
|
+
} catch {
|
|
2628
|
+
io.notice(
|
|
2629
|
+
`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.
|
|
2630
|
+
`
|
|
2631
|
+
);
|
|
2632
|
+
return empty([], true);
|
|
2633
|
+
}
|
|
2634
|
+
if (found.length === 0) return empty(found, false);
|
|
2635
|
+
const list = found.join(", ");
|
|
2636
|
+
if (choice === "ask") {
|
|
2637
|
+
if (!io.interactive) {
|
|
2638
|
+
io.notice(`Credential Manager holds credentials for ${list}. Run \`${RERUN}\` to copy them to the DPAPI store.
|
|
2639
|
+
`);
|
|
2640
|
+
return empty(found, false);
|
|
2641
|
+
}
|
|
2642
|
+
const answer = await io.ask(`Copy the credentials for ${list} from Credential Manager to the DPAPI store? [Y/n] `);
|
|
2643
|
+
if (!isYes(answer)) {
|
|
2644
|
+
io.notice("Nothing copied. Run `azdo auth login` for each org, or re-run with --copy-credentials.\n");
|
|
2645
|
+
return empty(found, false);
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
const result = copyKeyringCredentialsToDpapi(found);
|
|
2649
|
+
for (const org of result.copied) io.notice(`Copied the credential for ${org} to the DPAPI store.
|
|
2650
|
+
`);
|
|
2651
|
+
for (const org of result.skipped) io.notice(`Kept the existing DPAPI credential for ${org} (not overwritten).
|
|
2652
|
+
`);
|
|
2653
|
+
for (const { org, message } of result.failed) io.notice(`Could not copy the credential for ${org}: ${message}
|
|
2654
|
+
`);
|
|
2655
|
+
if (result.copied.length > 0) {
|
|
2656
|
+
io.notice("The Credential Manager entries were left in place; `credentialStore keyring` still finds them.\n");
|
|
2657
|
+
}
|
|
2658
|
+
return { found, keyringUnavailable: false, ...result };
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
// src/commands/config.ts
|
|
2610
2662
|
function formatConfigValue(value, unsetFallback = "") {
|
|
2611
2663
|
if (value === void 0) {
|
|
2612
2664
|
return unsetFallback;
|
|
@@ -2661,6 +2713,25 @@ function writeConfigList(cfg) {
|
|
|
2661
2713
|
);
|
|
2662
2714
|
}
|
|
2663
2715
|
}
|
|
2716
|
+
async function offerCopy(options) {
|
|
2717
|
+
let choice = "ask";
|
|
2718
|
+
if (options.copyCredentials === true) choice = "yes";
|
|
2719
|
+
else if (options.copyCredentials === false) choice = "no";
|
|
2720
|
+
const interactive = !options.json && Boolean(process.stdin.isTTY) && Boolean(process.stderr.isTTY);
|
|
2721
|
+
let rl;
|
|
2722
|
+
try {
|
|
2723
|
+
return await offerKeyringToDpapiCopy(choice, {
|
|
2724
|
+
interactive,
|
|
2725
|
+
ask: (prompt) => {
|
|
2726
|
+
rl ??= createInterface2({ input: process.stdin, output: process.stderr });
|
|
2727
|
+
return createAsk(rl)(prompt);
|
|
2728
|
+
},
|
|
2729
|
+
notice: (message) => process.stderr.write(message)
|
|
2730
|
+
});
|
|
2731
|
+
} finally {
|
|
2732
|
+
rl?.close();
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2664
2735
|
function createAsk(rl) {
|
|
2665
2736
|
return (prompt) => new Promise((resolve2) => rl.question(prompt, resolve2));
|
|
2666
2737
|
}
|
|
@@ -2702,7 +2773,8 @@ function createConfigCommand() {
|
|
|
2702
2773
|
"",
|
|
2703
2774
|
"Credentials are NOT stored in the configuration file. They are resolved in this order:",
|
|
2704
2775
|
" 1. the AZDO_PAT environment variable (wins over everything below)",
|
|
2705
|
-
" 2. the OS credential store, per organization (see `azdo auth login`)",
|
|
2776
|
+
" 2. the OS credential store, per organization (see `azdo auth login`) \u2014 or, with",
|
|
2777
|
+
" `credentialStore` set to dpapi (Windows), DPAPI-encrypted files in ~/.azdo/credentials",
|
|
2706
2778
|
" 3. a .env file with AZDO_PAT, searched upwards from the working directory",
|
|
2707
2779
|
"",
|
|
2708
2780
|
"Only AZDO_PAT is read \u2014 AZURE_DEVOPS_PAT, AZURE_DEVOPS_EXT_PAT and AZDO_TOKEN are ignored.",
|
|
@@ -2712,33 +2784,41 @@ function createConfigCommand() {
|
|
|
2712
2784
|
].join("\n")
|
|
2713
2785
|
);
|
|
2714
2786
|
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) => {
|
|
2787
|
+
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
2788
|
try {
|
|
2717
2789
|
if (options.org) {
|
|
2718
2790
|
setOrgScopedValue(options.org, key, value);
|
|
2719
2791
|
} else {
|
|
2720
2792
|
setConfigValue(key, value);
|
|
2721
2793
|
}
|
|
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
2794
|
} catch (err) {
|
|
2734
2795
|
const message = err instanceof Error ? err.message : String(err);
|
|
2735
2796
|
process.stderr.write(`Error: ${message}
|
|
2736
2797
|
`);
|
|
2737
2798
|
process.exit(1);
|
|
2738
2799
|
}
|
|
2800
|
+
const output = { key, value, scope: options.org ?? "default" };
|
|
2801
|
+
if (key === "fields") {
|
|
2802
|
+
output.value = value.split(",").map((s) => s.trim());
|
|
2803
|
+
}
|
|
2804
|
+
if (!options.json) {
|
|
2805
|
+
const scopeTag = options.org ? ` (org: ${options.org})` : "";
|
|
2806
|
+
process.stdout.write(`Set "${key}" to "${value}"${scopeTag}
|
|
2807
|
+
`);
|
|
2808
|
+
}
|
|
2809
|
+
const writeJson = () => {
|
|
2810
|
+
if (options.json) process.stdout.write(JSON.stringify(output) + "\n");
|
|
2811
|
+
};
|
|
2812
|
+
if (!options.org && key === "credentialStore" && loadConfig().credentialStore === "dpapi") {
|
|
2813
|
+
return offerCopy(options).then((outcome) => {
|
|
2814
|
+
output.credentialsCopied = outcome.copied;
|
|
2815
|
+
writeJson();
|
|
2816
|
+
});
|
|
2817
|
+
}
|
|
2818
|
+
writeJson();
|
|
2739
2819
|
});
|
|
2740
2820
|
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) => {
|
|
2821
|
+
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
2822
|
try {
|
|
2743
2823
|
const value = options.org ? getOrgScopedValue(options.org, key) : getConfigValue(key);
|
|
2744
2824
|
if (options.json) {
|
|
@@ -2771,7 +2851,7 @@ function createConfigCommand() {
|
|
|
2771
2851
|
writeConfigList(cfg);
|
|
2772
2852
|
});
|
|
2773
2853
|
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) => {
|
|
2854
|
+
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
2855
|
try {
|
|
2776
2856
|
if (options.org) {
|
|
2777
2857
|
unsetOrgScopedValue(options.org, key);
|
|
@@ -2809,6 +2889,7 @@ function createConfigCommand() {
|
|
|
2809
2889
|
process.stderr.write("Azure DevOps CLI - Configuration Wizard\n");
|
|
2810
2890
|
process.stderr.write("=======================================\n\n");
|
|
2811
2891
|
for (const setting of SETTINGS) {
|
|
2892
|
+
if (setting.key === "credentialStore") continue;
|
|
2812
2893
|
await promptForSetting(cfg, setting, ask);
|
|
2813
2894
|
}
|
|
2814
2895
|
rl.close();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "azdo-cli",
|
|
3
|
-
"version": "0.20.0-feature-
|
|
3
|
+
"version": "0.20.0-feature-043-dpapi-credential-store.744",
|
|
4
4
|
"description": "Azure DevOps CLI tool",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"license": "MIT",
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@napi-rs/keyring": "^1.2.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"
|