driggsby 0.1.41 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -0
- package/bin/driggsby.js +2 -0
- package/dist/api/base-url.js +31 -0
- package/dist/api/network-error.js +55 -0
- package/dist/args-deploy.js +94 -0
- package/dist/args-init.js +29 -0
- package/dist/args-mcp-setup.js +258 -0
- package/dist/args-shared.js +28 -0
- package/dist/args.js +177 -0
- package/dist/clap-suggestions.js +66 -0
- package/dist/cli-error.js +12 -0
- package/dist/credentials/clear-outcome.js +1 -0
- package/dist/credentials/file-store.js +77 -0
- package/dist/credentials/keychain.js +54 -0
- package/dist/credentials/secret-tool.js +48 -0
- package/dist/credentials/store.js +164 -0
- package/dist/credentials/tool-runner.js +55 -0
- package/dist/deploy/api-session.js +85 -0
- package/dist/deploy/deploy-command.js +126 -0
- package/dist/deploy/rollback-command.js +100 -0
- package/dist/deploy/versions-command.js +97 -0
- package/dist/dev/dev-command.js +106 -0
- package/dist/dev/dev-servers.js +274 -0
- package/dist/dev/host-page.js +147 -0
- package/dist/dev/mcp-broker.js +149 -0
- package/dist/dev/static-files.js +104 -0
- package/dist/dev/tool-allowlist.js +18 -0
- package/dist/dev/watcher.js +76 -0
- package/dist/help.js +180 -0
- package/dist/init/init-command.js +114 -0
- package/dist/init/templates.js +271 -0
- package/dist/login/claim-client.js +151 -0
- package/dist/login/login.js +146 -0
- package/dist/login/logout.js +37 -0
- package/dist/login/open-url.js +79 -0
- package/dist/main.js +57 -0
- package/dist/mcp-setup/classify.js +37 -0
- package/dist/mcp-setup/client-command.js +133 -0
- package/dist/mcp-setup/commands.js +53 -0
- package/dist/mcp-setup/known-client.js +38 -0
- package/dist/mcp-setup/setup.js +137 -0
- package/dist/mcp-setup/spawn-plan.js +91 -0
- package/dist/mcp-setup/text.js +83 -0
- package/dist/terminal-text.js +19 -0
- package/dist/version.js +6 -0
- package/package.json +22 -41
- package/bin/driggsby +0 -10
- package/install.js +0 -159
- package/lib/artifacts.js +0 -49
- package/lib/process.js +0 -65
package/dist/args.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Hand-rolled argv parsing for the small fixed command tree. The root and mcp
|
|
2
|
+
// levels (and the whole mcp setup surface, in args-mcp-setup.ts) byte-match
|
|
3
|
+
// the original clap 4.6 behavior: help routing, exit codes, error text, "did
|
|
4
|
+
// you mean" tips (via the ported Jaro similarity in clap-suggestions.ts),
|
|
5
|
+
// "--" end-of-options, and short-flag cluster dispatch. Newer commands
|
|
6
|
+
// (login, logout) keep the same behavior and exit codes without chasing
|
|
7
|
+
// clap's byte-level quirks.
|
|
8
|
+
import { parseDeploy, parseRollback, parseVersions } from "./args-deploy.js";
|
|
9
|
+
import { parseInit } from "./args-init.js";
|
|
10
|
+
import { parseMcpSetup } from "./args-mcp-setup.js";
|
|
11
|
+
import { removeDashesTip, similarArgumentTip, unexpectedArgument, unexpectedFlagValue, } from "./args-shared.js";
|
|
12
|
+
import { didYouMean } from "./clap-suggestions.js";
|
|
13
|
+
import { CliError } from "./cli-error.js";
|
|
14
|
+
import { DEV_HELP, LOGIN_HELP, LOGOUT_HELP, MCP_HELP, ROOT_HELP } from "./help.js";
|
|
15
|
+
import { sanitizeForTerminal } from "./terminal-text.js";
|
|
16
|
+
export {} from "./args-shared.js";
|
|
17
|
+
const ROOT_USAGE = "Usage: npx driggsby@latest <COMMAND>";
|
|
18
|
+
const MCP_USAGE = "Usage: npx driggsby@latest mcp <COMMAND>";
|
|
19
|
+
const ROOT_LEVEL = {
|
|
20
|
+
helpText: ROOT_HELP,
|
|
21
|
+
usage: ROOT_USAGE,
|
|
22
|
+
subcommands: ["mcp", "login", "logout", "init", "dev", "deploy", "rollback", "versions"],
|
|
23
|
+
longFlags: ["help", "version"],
|
|
24
|
+
subcommandFlags: [
|
|
25
|
+
{ name: "mcp", longFlags: ["help"] },
|
|
26
|
+
{ name: "login", longFlags: ["help"] },
|
|
27
|
+
{ name: "logout", longFlags: ["help"] },
|
|
28
|
+
{ name: "init", longFlags: ["help"] },
|
|
29
|
+
{ name: "dev", longFlags: ["help"] },
|
|
30
|
+
{ name: "deploy", longFlags: ["preview", "help"] },
|
|
31
|
+
{ name: "rollback", longFlags: ["to", "help"] },
|
|
32
|
+
{ name: "versions", longFlags: ["help"] },
|
|
33
|
+
],
|
|
34
|
+
usagePrefix: "npx driggsby@latest",
|
|
35
|
+
hasVersion: true,
|
|
36
|
+
};
|
|
37
|
+
const MCP_LEVEL = {
|
|
38
|
+
helpText: MCP_HELP,
|
|
39
|
+
usage: MCP_USAGE,
|
|
40
|
+
subcommands: ["setup"],
|
|
41
|
+
longFlags: ["help"],
|
|
42
|
+
subcommandFlags: [{ name: "setup", longFlags: ["print", "help"] }],
|
|
43
|
+
usagePrefix: "npx driggsby@latest mcp",
|
|
44
|
+
hasVersion: false,
|
|
45
|
+
};
|
|
46
|
+
export function parseArgv(argv) {
|
|
47
|
+
return parseLevel(ROOT_LEVEL, argv);
|
|
48
|
+
}
|
|
49
|
+
function parseLevel(level, argv) {
|
|
50
|
+
const first = argv[0];
|
|
51
|
+
if (first === undefined) {
|
|
52
|
+
return { kind: "print-help", text: level.helpText, stream: "stderr", exitCode: 2 };
|
|
53
|
+
}
|
|
54
|
+
if (first === "-h" || first === "--help") {
|
|
55
|
+
return { kind: "print-help", text: level.helpText, stream: "stdout", exitCode: 0 };
|
|
56
|
+
}
|
|
57
|
+
if (level.hasVersion && (first === "-V" || first === "--version")) {
|
|
58
|
+
return { kind: "print-version" };
|
|
59
|
+
}
|
|
60
|
+
if (level.subcommands.includes(first)) {
|
|
61
|
+
return dispatchSubcommand(first, argv.slice(1));
|
|
62
|
+
}
|
|
63
|
+
if (first === "--") {
|
|
64
|
+
return parseLevelAfterEndOfOptions(level, argv[1]);
|
|
65
|
+
}
|
|
66
|
+
if (first.startsWith("--help=")) {
|
|
67
|
+
throw unexpectedFlagValue("--help", first.slice("--help=".length), levelFlagUsage(level, "help"));
|
|
68
|
+
}
|
|
69
|
+
if (level.hasVersion && first.startsWith("--version=")) {
|
|
70
|
+
throw unexpectedFlagValue("--version", first.slice("--version=".length), levelFlagUsage(level, "version"));
|
|
71
|
+
}
|
|
72
|
+
if (first.startsWith("--")) {
|
|
73
|
+
throw unknownLongFlagAtLevel(level, first, argv.slice(1));
|
|
74
|
+
}
|
|
75
|
+
if (first.startsWith("-") && first.length > 1) {
|
|
76
|
+
return dispatchShortCluster(level, first);
|
|
77
|
+
}
|
|
78
|
+
throw unrecognizedSubcommand(level, first);
|
|
79
|
+
}
|
|
80
|
+
function dispatchSubcommand(name, rest) {
|
|
81
|
+
switch (name) {
|
|
82
|
+
case "mcp":
|
|
83
|
+
return parseLevel(MCP_LEVEL, rest);
|
|
84
|
+
case "login":
|
|
85
|
+
return parseBareCommand({ kind: "login" }, LOGIN_HELP, "Usage: npx driggsby@latest login", rest);
|
|
86
|
+
case "logout":
|
|
87
|
+
return parseBareCommand({ kind: "logout" }, LOGOUT_HELP, "Usage: npx driggsby@latest logout", rest);
|
|
88
|
+
case "init":
|
|
89
|
+
return parseInit(rest);
|
|
90
|
+
case "dev":
|
|
91
|
+
return parseBareCommand({ kind: "dev" }, DEV_HELP, "Usage: npx driggsby@latest dev", rest);
|
|
92
|
+
case "deploy":
|
|
93
|
+
return parseDeploy(rest);
|
|
94
|
+
case "rollback":
|
|
95
|
+
return parseRollback(rest);
|
|
96
|
+
case "versions":
|
|
97
|
+
return parseVersions(rest);
|
|
98
|
+
case "setup":
|
|
99
|
+
return parseMcpSetup(rest);
|
|
100
|
+
default:
|
|
101
|
+
// Every name in a CommandLevel's subcommands list must have a case
|
|
102
|
+
// above; reaching here is a wiring bug, not a user error.
|
|
103
|
+
throw new Error(`unhandled subcommand dispatch: ${name}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Parsing for commands that take no arguments beyond help: login and logout.
|
|
107
|
+
// Same exit codes and error shape as the rest of the tree, no clap quirks.
|
|
108
|
+
function parseBareCommand(command, helpText, usage, argv) {
|
|
109
|
+
let optionsEnded = false;
|
|
110
|
+
for (const token of argv) {
|
|
111
|
+
if (!optionsEnded && token === "--") {
|
|
112
|
+
optionsEnded = true;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (!optionsEnded && (token === "-h" || token === "--help")) {
|
|
116
|
+
return { kind: "print-help", text: helpText, stream: "stdout", exitCode: 0 };
|
|
117
|
+
}
|
|
118
|
+
throw unexpectedArgument(token, usage);
|
|
119
|
+
}
|
|
120
|
+
return command;
|
|
121
|
+
}
|
|
122
|
+
function levelFlagUsage(level, flagName) {
|
|
123
|
+
return `Usage: ${level.usagePrefix} --${flagName} <COMMAND>`;
|
|
124
|
+
}
|
|
125
|
+
// After "--", everything is positional — and these levels take no
|
|
126
|
+
// positionals, so clap reports the token as unexpected (with a targeted tip
|
|
127
|
+
// when it names a real subcommand of this level).
|
|
128
|
+
function parseLevelAfterEndOfOptions(level, next) {
|
|
129
|
+
if (next === undefined) {
|
|
130
|
+
return { kind: "print-help", text: level.helpText, stream: "stderr", exitCode: 2 };
|
|
131
|
+
}
|
|
132
|
+
if (level.subcommands.includes(next)) {
|
|
133
|
+
throw unexpectedArgument(next, level.usage, removeDashesTip(next));
|
|
134
|
+
}
|
|
135
|
+
throw unrecognizedSubcommand(level, next);
|
|
136
|
+
}
|
|
137
|
+
// clap dispatches a short-flag cluster on its first character: -hx prints
|
|
138
|
+
// help, -Vx prints the version, and anything else errors naming just the
|
|
139
|
+
// first short (-xy → '-x'). "Character" means one code point, like Rust's
|
|
140
|
+
// char — never half of a surrogate pair.
|
|
141
|
+
function dispatchShortCluster(level, token) {
|
|
142
|
+
const firstShort = Array.from(token.slice(1))[0] ?? "";
|
|
143
|
+
if (firstShort === "h") {
|
|
144
|
+
return { kind: "print-help", text: level.helpText, stream: "stdout", exitCode: 0 };
|
|
145
|
+
}
|
|
146
|
+
if (level.hasVersion && firstShort === "V") {
|
|
147
|
+
return { kind: "print-version" };
|
|
148
|
+
}
|
|
149
|
+
throw unexpectedArgument(`-${firstShort}`, level.usage);
|
|
150
|
+
}
|
|
151
|
+
function unknownLongFlagAtLevel(level, token, remainingArgs) {
|
|
152
|
+
const flagName = token.slice(2).split("=", 1)[0] ?? "";
|
|
153
|
+
const shown = `--${flagName}`;
|
|
154
|
+
const similar = didYouMean(flagName, level.longFlags);
|
|
155
|
+
if (similar !== undefined) {
|
|
156
|
+
const usage = `Usage: ${level.usagePrefix} --${similar} <COMMAND>`;
|
|
157
|
+
return unexpectedArgument(shown, usage, similarArgumentTip(similar));
|
|
158
|
+
}
|
|
159
|
+
// clap's cross-level fallback: when a LATER argv token names one of this
|
|
160
|
+
// level's subcommands and that subcommand has a similar flag, point the
|
|
161
|
+
// user at the flag's real home ("tip: 'setup --print' exists").
|
|
162
|
+
for (const subcommand of level.subcommandFlags) {
|
|
163
|
+
if (!remainingArgs.includes(subcommand.name)) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const subcommandSimilar = didYouMean(flagName, subcommand.longFlags);
|
|
167
|
+
if (subcommandSimilar !== undefined) {
|
|
168
|
+
return unexpectedArgument(shown, level.usage, ` tip: '${subcommand.name} --${subcommandSimilar}' exists`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return unexpectedArgument(shown, level.usage);
|
|
172
|
+
}
|
|
173
|
+
function unrecognizedSubcommand(level, subcommand) {
|
|
174
|
+
const similar = didYouMean(subcommand, level.subcommands);
|
|
175
|
+
const tip = similar === undefined ? "" : ` tip: a similar subcommand exists: '${similar}'\n\n`;
|
|
176
|
+
return new CliError(`error: unrecognized subcommand '${sanitizeForTerminal(subcommand)}'\n\n${tip}${level.usage}\n\nFor more information, try '--help'.`, 2);
|
|
177
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// A faithful port of clap 4.6.0's "did you mean" suggestion logic so typo
|
|
2
|
+
// tips match the retired Rust CLI byte-for-byte: strsim 0.11.1's Jaro
|
|
3
|
+
// similarity (clap deliberately avoids jaro_winkler — strsim GH #4660), a
|
|
4
|
+
// strict > 0.7 threshold, and clap's tie behavior (a later candidate wins a
|
|
5
|
+
// similarity tie).
|
|
6
|
+
// strsim::jaro over Unicode code points.
|
|
7
|
+
export function jaroSimilarity(left, right) {
|
|
8
|
+
// Array.from splits on Unicode code points, matching strsim's char iteration.
|
|
9
|
+
const a = Array.from(left);
|
|
10
|
+
const b = Array.from(right);
|
|
11
|
+
if (a.length === 0 && b.length === 0) {
|
|
12
|
+
return 1;
|
|
13
|
+
}
|
|
14
|
+
if (a.length === 0 || b.length === 0) {
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
const searchRange = Math.max(0, Math.floor(Math.max(a.length, b.length) / 2) - 1);
|
|
18
|
+
const aFlags = new Array(a.length).fill(false);
|
|
19
|
+
const bFlags = new Array(b.length).fill(false);
|
|
20
|
+
let matches = 0;
|
|
21
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
22
|
+
const minBound = i > searchRange ? i - searchRange : 0;
|
|
23
|
+
const maxBound = Math.min(b.length, i + searchRange + 1);
|
|
24
|
+
for (let j = minBound; j < maxBound; j += 1) {
|
|
25
|
+
if (a[i] === b[j] && !bFlags[j]) {
|
|
26
|
+
aFlags[i] = true;
|
|
27
|
+
bFlags[j] = true;
|
|
28
|
+
matches += 1;
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (matches === 0) {
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
let transpositions = 0;
|
|
37
|
+
let j = 0;
|
|
38
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
39
|
+
if (!aFlags[i]) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
while (!bFlags[j]) {
|
|
43
|
+
j += 1;
|
|
44
|
+
}
|
|
45
|
+
if (a[i] !== b[j]) {
|
|
46
|
+
transpositions += 1;
|
|
47
|
+
}
|
|
48
|
+
j += 1;
|
|
49
|
+
}
|
|
50
|
+
transpositions = Math.floor(transpositions / 2);
|
|
51
|
+
return ((matches / a.length + matches / b.length + (matches - transpositions) / matches) / 3);
|
|
52
|
+
}
|
|
53
|
+
// clap's did_you_mean(...).pop(): the single most similar candidate above
|
|
54
|
+
// the threshold, or undefined. Ties go to the later candidate.
|
|
55
|
+
export function didYouMean(input, candidates) {
|
|
56
|
+
let best;
|
|
57
|
+
let bestConfidence = 0;
|
|
58
|
+
for (const candidate of candidates) {
|
|
59
|
+
const confidence = jaroSimilarity(input, candidate);
|
|
60
|
+
if (confidence > 0.7 && confidence >= bestConfidence) {
|
|
61
|
+
best = candidate;
|
|
62
|
+
bestConfidence = confidence;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return best;
|
|
66
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// A user-facing CLI failure. `exitCode` mirrors the original Rust CLI:
|
|
2
|
+
// 2 for argument/usage errors (clap's convention), 1 for command errors
|
|
3
|
+
// such as an unsupported client name. The message is printed to stderr
|
|
4
|
+
// verbatim; it must already be consumer-safe.
|
|
5
|
+
export class CliError extends Error {
|
|
6
|
+
exitCode;
|
|
7
|
+
constructor(message, exitCode) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "CliError";
|
|
10
|
+
this.exitCode = exitCode;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Fallback credential storage: ~/.driggsby/credentials.json, written
|
|
2
|
+
// atomically with owner-only permissions. This is the only backend on
|
|
3
|
+
// Windows (no readable credential CLI exists there; the profile directory's
|
|
4
|
+
// default user-only ACL is the boundary) and the fallback everywhere else.
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import {} from "./clear-outcome.js";
|
|
9
|
+
export function credentialsFilePath(homeDirectory) {
|
|
10
|
+
return join(homeDirectory, ".driggsby", "credentials.json");
|
|
11
|
+
}
|
|
12
|
+
export async function readFileToken(homeDirectory) {
|
|
13
|
+
let raw;
|
|
14
|
+
try {
|
|
15
|
+
raw = await readFile(credentialsFilePath(homeDirectory), "utf8");
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
const parsed = JSON.parse(raw);
|
|
22
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const token = parsed.app_token;
|
|
26
|
+
return typeof token === "string" && token !== "" ? token : null;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function writeFileToken(homeDirectory, token) {
|
|
33
|
+
const directory = join(homeDirectory, ".driggsby");
|
|
34
|
+
// mode applies only when the directory is created; an existing ~/.driggsby
|
|
35
|
+
// keeps whatever permissions the user already gave it.
|
|
36
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
37
|
+
const finalPath = credentialsFilePath(homeDirectory);
|
|
38
|
+
const temporaryPath = join(directory, `credentials.json.${randomUUID()}.tmp`);
|
|
39
|
+
const body = `${JSON.stringify({ app_token: token }, null, 2)}\n`;
|
|
40
|
+
try {
|
|
41
|
+
await writeFile(temporaryPath, body, { mode: 0o600 });
|
|
42
|
+
await rename(temporaryPath, finalPath);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
await rm(temporaryPath, { force: true });
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export async function clearFileToken(homeDirectory) {
|
|
50
|
+
let outcome;
|
|
51
|
+
try {
|
|
52
|
+
await rm(credentialsFilePath(homeDirectory));
|
|
53
|
+
outcome = "cleared";
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
outcome = error.code === "ENOENT" ? "absent" : "failed";
|
|
57
|
+
}
|
|
58
|
+
// A crash between writeFileToken's temp write and its rename can strand a
|
|
59
|
+
// readable token in a `credentials.json.<uuid>.tmp` file; removal must not
|
|
60
|
+
// leave one behind while reporting the token gone.
|
|
61
|
+
try {
|
|
62
|
+
for (const entry of await readdir(join(homeDirectory, ".driggsby"))) {
|
|
63
|
+
if (entry.startsWith("credentials.json.") && entry.endsWith(".tmp")) {
|
|
64
|
+
try {
|
|
65
|
+
await rm(join(homeDirectory, ".driggsby", entry), { force: true });
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
outcome = "failed";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// No ~/.driggsby directory at all: nothing was ever stored here.
|
|
75
|
+
}
|
|
76
|
+
return outcome;
|
|
77
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// macOS keychain backend, via the system `security` CLI. The service name
|
|
2
|
+
// `driggsby-cli` is distinct from the legacy Rust-era `driggsby.cli` items,
|
|
3
|
+
// so old installs are never touched. `security` is invoked by its absolute
|
|
4
|
+
// system path, never resolved through PATH, so a lookalike binary earlier on
|
|
5
|
+
// PATH can never receive the token; tests override the path to a fake.
|
|
6
|
+
import {} from "./clear-outcome.js";
|
|
7
|
+
import { runCredentialTool } from "./tool-runner.js";
|
|
8
|
+
const SECURITY_PROGRAM = "/usr/bin/security";
|
|
9
|
+
const SERVICE = "driggsby-cli";
|
|
10
|
+
const ACCOUNT = "app-token";
|
|
11
|
+
// errSecItemNotFound, the exit code `security` uses for a missing item.
|
|
12
|
+
const SECURITY_ITEM_NOT_FOUND = 44;
|
|
13
|
+
// Every keychain call resolves `security` through this one function, and it
|
|
14
|
+
// is exported so a test can pin the absolute-path invariant (no PATH lookup)
|
|
15
|
+
// without ever invoking the real binary.
|
|
16
|
+
export function resolveSecurityProgram(options) {
|
|
17
|
+
return options.securityProgram ?? SECURITY_PROGRAM;
|
|
18
|
+
}
|
|
19
|
+
export async function readKeychainToken(options = {}) {
|
|
20
|
+
const result = await runCredentialTool(resolveSecurityProgram(options), ["find-generic-password", "-s", SERVICE, "-a", ACCOUNT, "-w"], options);
|
|
21
|
+
if (result.kind !== "output") {
|
|
22
|
+
return { kind: "unavailable" };
|
|
23
|
+
}
|
|
24
|
+
if (result.exitCode === SECURITY_ITEM_NOT_FOUND) {
|
|
25
|
+
return { kind: "absent" };
|
|
26
|
+
}
|
|
27
|
+
if (result.exitCode !== 0) {
|
|
28
|
+
return { kind: "unavailable" };
|
|
29
|
+
}
|
|
30
|
+
const token = result.stdout.replace(/\r?\n$/, "");
|
|
31
|
+
return token === "" ? { kind: "absent" } : { kind: "token", token };
|
|
32
|
+
}
|
|
33
|
+
// -U updates the existing item in place, so re-login replaces the token.
|
|
34
|
+
// The token rides on the argument list here: `security` offers no stdin path
|
|
35
|
+
// for -w, and on macOS another user cannot read this process's arguments —
|
|
36
|
+
// the same tradeoff every keychain-writing CLI accepts.
|
|
37
|
+
export async function writeKeychainToken(token, options = {}) {
|
|
38
|
+
const result = await runCredentialTool(resolveSecurityProgram(options), ["add-generic-password", "-U", "-s", SERVICE, "-a", ACCOUNT, "-w", token], options);
|
|
39
|
+
return result.kind === "output" && result.exitCode === 0;
|
|
40
|
+
}
|
|
41
|
+
export async function clearKeychainToken(options = {}) {
|
|
42
|
+
const result = await runCredentialTool(resolveSecurityProgram(options), ["delete-generic-password", "-s", SERVICE, "-a", ACCOUNT], options);
|
|
43
|
+
if (result.kind === "not-found") {
|
|
44
|
+
// No `security` binary means no keychain this CLI could have written to.
|
|
45
|
+
return "absent";
|
|
46
|
+
}
|
|
47
|
+
if (result.kind !== "output") {
|
|
48
|
+
return "failed";
|
|
49
|
+
}
|
|
50
|
+
if (result.exitCode === 0) {
|
|
51
|
+
return "cleared";
|
|
52
|
+
}
|
|
53
|
+
return result.exitCode === SECURITY_ITEM_NOT_FOUND ? "absent" : "failed";
|
|
54
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Linux keyring backend, via `secret-tool` (libsecret) when it is on PATH.
|
|
2
|
+
// The token is written over stdin, never on the argument list, because Linux
|
|
3
|
+
// exposes every process's arguments to every local user via /proc.
|
|
4
|
+
import {} from "./clear-outcome.js";
|
|
5
|
+
import { runCredentialTool } from "./tool-runner.js";
|
|
6
|
+
const SERVICE_ATTRIBUTE = ["service", "driggsby-cli"];
|
|
7
|
+
export async function readKeyringToken(options = {}) {
|
|
8
|
+
const result = await runCredentialTool("secret-tool", ["lookup", ...SERVICE_ATTRIBUTE], options);
|
|
9
|
+
if (result.kind !== "output") {
|
|
10
|
+
return { kind: "unavailable" };
|
|
11
|
+
}
|
|
12
|
+
if (result.exitCode !== 0) {
|
|
13
|
+
// secret-tool exits 1 both for "no such secret" and for real errors; the
|
|
14
|
+
// stderr text is not a stable contract, so treat every non-zero lookup as
|
|
15
|
+
// absent and let the caller fall back to the file store on read.
|
|
16
|
+
return { kind: "absent" };
|
|
17
|
+
}
|
|
18
|
+
const token = result.stdout.replace(/\r?\n$/, "");
|
|
19
|
+
return token === "" ? { kind: "absent" } : { kind: "token", token };
|
|
20
|
+
}
|
|
21
|
+
export async function writeKeyringToken(token, options = {}) {
|
|
22
|
+
const result = await runCredentialTool("secret-tool", ["store", "--label", "Driggsby CLI", ...SERVICE_ATTRIBUTE], { ...options, stdinText: token });
|
|
23
|
+
return result.kind === "output" && result.exitCode === 0;
|
|
24
|
+
}
|
|
25
|
+
// `secret-tool clear` exits 0 whether or not anything matched, so existence
|
|
26
|
+
// is checked first: without that, "removed" and "there was never anything to
|
|
27
|
+
// remove" would be indistinguishable. A lookup that cannot answer (no
|
|
28
|
+
// session bus, locked keyring, timeout) must NOT collapse into "absent":
|
|
29
|
+
// that would tell the user a token is gone while it keeps working. The
|
|
30
|
+
// clear's own exit status is the tiebreaker — it exits 0 whenever the
|
|
31
|
+
// keyring is reachable (match or no match) and non-zero when it is not.
|
|
32
|
+
export async function clearKeyringToken(options = {}) {
|
|
33
|
+
const lookup = await runCredentialTool("secret-tool", ["lookup", ...SERVICE_ATTRIBUTE], options);
|
|
34
|
+
if (lookup.kind === "not-found") {
|
|
35
|
+
// No secret-tool binary means no keyring this CLI could have written to.
|
|
36
|
+
return "absent";
|
|
37
|
+
}
|
|
38
|
+
if (lookup.kind !== "output") {
|
|
39
|
+
// The tool crashed or hung; the keyring may still hold a live token.
|
|
40
|
+
return "failed";
|
|
41
|
+
}
|
|
42
|
+
const hadToken = lookup.exitCode === 0 && lookup.stdout.replace(/\r?\n$/, "") !== "";
|
|
43
|
+
const result = await runCredentialTool("secret-tool", ["clear", ...SERVICE_ATTRIBUTE], options);
|
|
44
|
+
if (result.kind !== "output" || result.exitCode !== 0) {
|
|
45
|
+
return "failed";
|
|
46
|
+
}
|
|
47
|
+
return hadToken ? "cleared" : "absent";
|
|
48
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// The credential store facade: one read/save/clear surface over the
|
|
2
|
+
// platform backends. Precedence on read: the DRIGGSBY_TOKEN environment
|
|
3
|
+
// variable (the CI/sandbox path), then the platform keyring (macOS keychain
|
|
4
|
+
// or Linux secret-tool), then the ~/.driggsby/credentials.json file. Token
|
|
5
|
+
// values never appear in output or errors; callers describe locations, not
|
|
6
|
+
// contents.
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { CliError } from "../cli-error.js";
|
|
9
|
+
import { wrapProse } from "../terminal-text.js";
|
|
10
|
+
import {} from "./clear-outcome.js";
|
|
11
|
+
import { clearFileToken, readFileToken, writeFileToken } from "./file-store.js";
|
|
12
|
+
import { clearKeychainToken, readKeychainToken, writeKeychainToken, } from "./keychain.js";
|
|
13
|
+
import { clearKeyringToken, readKeyringToken, writeKeyringToken } from "./secret-tool.js";
|
|
14
|
+
export function defaultCredentialEnvironment() {
|
|
15
|
+
return { platform: process.platform, env: process.env, homeDirectory: homedir() };
|
|
16
|
+
}
|
|
17
|
+
function keychainOptions(environment) {
|
|
18
|
+
return { spawnEnv: environment.spawnEnv, securityProgram: environment.securityProgram };
|
|
19
|
+
}
|
|
20
|
+
function keyringOptions(environment) {
|
|
21
|
+
return { spawnEnv: environment.spawnEnv };
|
|
22
|
+
}
|
|
23
|
+
// The DRIGGSBY_TOKEN override, or null when unset or blank. One definition,
|
|
24
|
+
// so "a blank env token is ignored" holds everywhere it is consulted.
|
|
25
|
+
export function environmentToken(environment) {
|
|
26
|
+
const token = environment.env.DRIGGSBY_TOKEN?.trim();
|
|
27
|
+
return token === undefined || token === "" ? null : token;
|
|
28
|
+
}
|
|
29
|
+
export async function readStoredToken(environment = defaultCredentialEnvironment()) {
|
|
30
|
+
const envToken = environmentToken(environment);
|
|
31
|
+
if (envToken !== null) {
|
|
32
|
+
return { token: envToken, source: "env" };
|
|
33
|
+
}
|
|
34
|
+
if (environment.platform === "darwin") {
|
|
35
|
+
const result = await readKeychainToken(keychainOptions(environment));
|
|
36
|
+
if (result.kind === "token") {
|
|
37
|
+
return { token: result.token, source: "keychain" };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
else if (environment.platform === "linux") {
|
|
41
|
+
const result = await readKeyringToken(keyringOptions(environment));
|
|
42
|
+
if (result.kind === "token") {
|
|
43
|
+
return { token: result.token, source: "keyring" };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const fileToken = await readFileToken(environment.homeDirectory);
|
|
47
|
+
return fileToken === null ? null : { token: fileToken, source: "file" };
|
|
48
|
+
}
|
|
49
|
+
// Saves the token to the preferred backend for this platform and returns
|
|
50
|
+
// where it landed. A failed platform keyring falls back to the file store —
|
|
51
|
+
// but never silently: the keyring outranks the file on read, so an old
|
|
52
|
+
// keyring token that cannot be removed would shadow the token being saved.
|
|
53
|
+
// A readable shadowing token is a hard failure; an unverifiable keyring
|
|
54
|
+
// (locked, unreachable) falls back with a loud warning through `warn`.
|
|
55
|
+
export async function saveToken(token, environment, warn) {
|
|
56
|
+
if (environment.platform === "darwin") {
|
|
57
|
+
if (await writeKeychainToken(token, keychainOptions(environment))) {
|
|
58
|
+
// A stale file copy must not shadow or outlive the keychain token.
|
|
59
|
+
await clearFileToken(environment.homeDirectory);
|
|
60
|
+
return "keychain";
|
|
61
|
+
}
|
|
62
|
+
await guardAgainstShadowingToken({
|
|
63
|
+
clearOutcome: await clearKeychainToken(keychainOptions(environment)),
|
|
64
|
+
presence: async () => {
|
|
65
|
+
const read = await readKeychainToken(keychainOptions(environment));
|
|
66
|
+
if (read.kind === "token") {
|
|
67
|
+
return "present";
|
|
68
|
+
}
|
|
69
|
+
// `security` answers a definitive "no such item" (exit 44) distinctly
|
|
70
|
+
// from errors, so an absent answer here is trustworthy.
|
|
71
|
+
return read.kind === "absent" ? "absent" : "unknown";
|
|
72
|
+
},
|
|
73
|
+
problem: "an older token is stored in your macOS keychain and couldn't be replaced or removed. If your keychain is locked, unlock it, then sign in again",
|
|
74
|
+
unverifiedWarning: "Note: we couldn't check your macOS keychain for an older Driggsby app token.\n" +
|
|
75
|
+
"If one is still there, driggsby commands will use it instead of the token\n" +
|
|
76
|
+
"saved just now. To fix that, unlock your keychain, then sign out and back in:\n" +
|
|
77
|
+
" npx driggsby@latest logout\n" +
|
|
78
|
+
" npx driggsby@latest login\n",
|
|
79
|
+
warn,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
else if (environment.platform === "linux") {
|
|
83
|
+
if (await writeKeyringToken(token, keyringOptions(environment))) {
|
|
84
|
+
await clearFileToken(environment.homeDirectory);
|
|
85
|
+
return "keyring";
|
|
86
|
+
}
|
|
87
|
+
await guardAgainstShadowingToken({
|
|
88
|
+
clearOutcome: await clearKeyringToken(keyringOptions(environment)),
|
|
89
|
+
presence: async () => {
|
|
90
|
+
const read = await readKeyringToken(keyringOptions(environment));
|
|
91
|
+
// secret-tool's non-zero lookup exit is ambiguous (no match, or the
|
|
92
|
+
// keyring is unreachable), so a non-token answer never counts as a
|
|
93
|
+
// definitive absence here.
|
|
94
|
+
return read.kind === "token" ? "present" : "unknown";
|
|
95
|
+
},
|
|
96
|
+
problem: "an older token is stored in your system keyring and couldn't be replaced or removed. Check that your keyring is unlocked, then sign in again",
|
|
97
|
+
unverifiedWarning: "Note: we couldn't check your system keyring for an older Driggsby app token.\n" +
|
|
98
|
+
"If one is still there, driggsby commands will use it instead of the token\n" +
|
|
99
|
+
"saved just now. To fix that, make sure your keyring is unlocked, then sign\n" +
|
|
100
|
+
"out and back in:\n" +
|
|
101
|
+
" npx driggsby@latest logout\n" +
|
|
102
|
+
" npx driggsby@latest login\n",
|
|
103
|
+
warn,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
await writeFileToken(environment.homeDirectory, token);
|
|
107
|
+
return "file";
|
|
108
|
+
}
|
|
109
|
+
// Called when a keyring write failed and the save is about to fall back to
|
|
110
|
+
// the file store. If an older keyring token exists and cannot be cleared, it
|
|
111
|
+
// would win over the file copy on every future read, so the save fails
|
|
112
|
+
// loudly instead of storing a token that will never be used. When the
|
|
113
|
+
// keyring cannot even be read — the same brokenness that failed the write —
|
|
114
|
+
// the fallback proceeds, but with a warning: silence here would let a stale
|
|
115
|
+
// token quietly win once the keyring becomes readable again. A definitive
|
|
116
|
+
// "absent" answer needs neither: there is nothing to shadow.
|
|
117
|
+
async function guardAgainstShadowingToken(check) {
|
|
118
|
+
if (check.clearOutcome !== "failed") {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const presence = await check.presence();
|
|
122
|
+
if (presence === "present") {
|
|
123
|
+
throw new CliError(`${wrapProse(`We couldn't save your Driggsby app token: ${check.problem}:`)}\n npx driggsby@latest login`, 1);
|
|
124
|
+
}
|
|
125
|
+
if (presence === "unknown") {
|
|
126
|
+
check.warn(check.unverifiedWarning);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export async function clearStoredToken(environment = defaultCredentialEnvironment()) {
|
|
130
|
+
const clearedSources = [];
|
|
131
|
+
const failedSources = [];
|
|
132
|
+
const record = (source, outcome) => {
|
|
133
|
+
if (outcome === "cleared") {
|
|
134
|
+
clearedSources.push(source);
|
|
135
|
+
}
|
|
136
|
+
else if (outcome === "failed") {
|
|
137
|
+
failedSources.push(source);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
if (environment.platform === "darwin") {
|
|
141
|
+
record("keychain", await clearKeychainToken(keychainOptions(environment)));
|
|
142
|
+
}
|
|
143
|
+
else if (environment.platform === "linux") {
|
|
144
|
+
record("keyring", await clearKeyringToken(keyringOptions(environment)));
|
|
145
|
+
}
|
|
146
|
+
record("file", await clearFileToken(environment.homeDirectory));
|
|
147
|
+
return {
|
|
148
|
+
clearedSources,
|
|
149
|
+
failedSources,
|
|
150
|
+
envTokenStillSet: environmentToken(environment) !== null,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
export function describeStorageLocation(source) {
|
|
154
|
+
switch (source) {
|
|
155
|
+
case "env":
|
|
156
|
+
return "the DRIGGSBY_TOKEN environment variable";
|
|
157
|
+
case "keychain":
|
|
158
|
+
return "your macOS keychain";
|
|
159
|
+
case "keyring":
|
|
160
|
+
return "your system keyring";
|
|
161
|
+
case "file":
|
|
162
|
+
return "the ~/.driggsby/credentials.json file";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Runs a platform credential tool (macOS `security`, Linux `secret-tool`)
|
|
2
|
+
// with a hard timeout, optional stdin, and captured output. POSIX-only by
|
|
3
|
+
// construction: Windows uses the file store and never reaches this module,
|
|
4
|
+
// so there is no .cmd shim handling here. Programs are fixed constants named
|
|
5
|
+
// by the calling backend; nothing user-controlled selects the program.
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
const CREDENTIAL_TOOL_TIMEOUT_MS = 15_000;
|
|
8
|
+
export function runCredentialTool(program, args, options = {}) {
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
const child = spawn(program, [...args], {
|
|
11
|
+
stdio: [options.stdinText === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
|
12
|
+
env: options.spawnEnv ?? process.env,
|
|
13
|
+
});
|
|
14
|
+
let stdout = "";
|
|
15
|
+
let stderr = "";
|
|
16
|
+
let settled = false;
|
|
17
|
+
const settle = (result) => {
|
|
18
|
+
if (settled) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
settled = true;
|
|
22
|
+
clearTimeout(timer);
|
|
23
|
+
resolve(result);
|
|
24
|
+
};
|
|
25
|
+
const timer = setTimeout(() => {
|
|
26
|
+
child.kill();
|
|
27
|
+
// A tool that ignores SIGTERM must not linger with the token; the
|
|
28
|
+
// follow-up SIGKILL is unref'd so it never holds the process open.
|
|
29
|
+
setTimeout(() => child.kill("SIGKILL"), 2_000).unref();
|
|
30
|
+
settle({ kind: "timed-out" });
|
|
31
|
+
}, options.timeoutMs ?? CREDENTIAL_TOOL_TIMEOUT_MS);
|
|
32
|
+
child.stdout?.setEncoding("utf8");
|
|
33
|
+
child.stdout?.on("data", (chunk) => {
|
|
34
|
+
stdout += chunk;
|
|
35
|
+
});
|
|
36
|
+
child.stderr?.setEncoding("utf8");
|
|
37
|
+
child.stderr?.on("data", (chunk) => {
|
|
38
|
+
stderr += chunk;
|
|
39
|
+
});
|
|
40
|
+
child.on("error", (error) => {
|
|
41
|
+
settle(error.code === "ENOENT" ? { kind: "not-found" } : { kind: "spawn-error" });
|
|
42
|
+
});
|
|
43
|
+
child.on("close", (code) => {
|
|
44
|
+
settle({ kind: "output", exitCode: code ?? 1, stdout, stderr });
|
|
45
|
+
});
|
|
46
|
+
if (options.stdinText !== undefined && child.stdin !== null) {
|
|
47
|
+
// A tool that exits without reading stdin can EPIPE the write; that is
|
|
48
|
+
// its own failure signal, never a crash of ours.
|
|
49
|
+
child.stdin.on("error", () => {
|
|
50
|
+
// Swallowed deliberately; the tool's exit code is the signal.
|
|
51
|
+
});
|
|
52
|
+
child.stdin.end(options.stdinText);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|