driggsby 0.6.2 → 0.7.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 +7 -5
- package/dist/args-login.js +41 -0
- package/dist/args.js +4 -3
- package/dist/device.js +2 -15
- package/dist/help.js +7 -2
- package/dist/login/await-token.js +260 -0
- package/dist/login/claim-client.js +55 -6
- package/dist/login/login.js +204 -41
- package/dist/login/loopback.js +153 -0
- package/dist/login/pending.js +68 -0
- package/dist/login/pkce.js +14 -0
- package/dist/login/sleep.js +21 -0
- package/dist/login/terminal-prompt.js +51 -0
- package/dist/main.js +2 -2
- package/dist/mcp-setup/classify.js +4 -1
- package/dist/mcp-setup/commands.js +3 -7
- package/dist/mcp-setup/setup.js +1 -6
- package/dist/mcp-setup/spawn-plan.js +4 -6
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -22,11 +22,9 @@ npx driggsby@latest mcp setup other
|
|
|
22
22
|
After setup, authenticate Driggsby through your client — run `/mcp` in
|
|
23
23
|
Claude Code, or sign in through the browser window Codex opens.
|
|
24
24
|
|
|
25
|
-
So your Driggsby MCP page can tell your
|
|
25
|
+
So your Driggsby MCP page can tell your CLI sign-ins apart, `login` sends
|
|
26
26
|
Driggsby this computer's name and operating system version (for example
|
|
27
|
-
`mbp-studio` and `macOS 15.6`) with its sign-in
|
|
28
|
-
`mcp setup claude-code` saves them as headers on the Driggsby entry it
|
|
29
|
-
adds to Claude Code, which sends them with each request.
|
|
27
|
+
`mbp-studio` and `macOS 15.6`) with its sign-in.
|
|
30
28
|
|
|
31
29
|
For another MCP client, choose Other. Driggsby currently supports only
|
|
32
30
|
OAuth-based remote MCP at:
|
|
@@ -37,7 +35,9 @@ https://app.driggsby.com/mcp
|
|
|
37
35
|
|
|
38
36
|
Some driggsby commands work with your Driggsby account directly and need
|
|
39
37
|
an app token saved on your machine: run
|
|
40
|
-
`npx driggsby@latest login` and approve the page it opens
|
|
38
|
+
`npx driggsby@latest login` and approve the page it opens (approving in a
|
|
39
|
+
browser on another computer shows a code to paste, or to finish with
|
|
40
|
+
`npx driggsby@latest login --code <CODE>`). The token is
|
|
41
41
|
stored in your platform keychain when one is available, otherwise in
|
|
42
42
|
`~/.driggsby/credentials.json`, and is never printed.
|
|
43
43
|
`npx driggsby@latest logout` removes it.
|
|
@@ -69,6 +69,8 @@ can't reach it, but anything running locally can, so stop it when you're
|
|
|
69
69
|
done: press Ctrl+C, or run `npx driggsby@latest dev --stop` in the app's
|
|
70
70
|
folder. It also stops on its own after 30 minutes with no page open.
|
|
71
71
|
Previews for several apps can run at once; each prints its own addresses.
|
|
72
|
+
Each data call the page makes prints one line in the terminal, after any
|
|
73
|
+
retries: a check and how long it took, or a cross and the reason.
|
|
72
74
|
|
|
73
75
|
## Deploying an app
|
|
74
76
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Argv parsing for driggsby login: help, and --code to finish a sign-in that
|
|
2
|
+
// is waiting on this computer with the code its approval page showed. Same
|
|
3
|
+
// contract as the rest of the tree: usage errors exit 2, echoed argv is
|
|
4
|
+
// sanitized.
|
|
5
|
+
import { helpCommand, unexpectedArgument } from "./args-shared.js";
|
|
6
|
+
import { CliError } from "./cli-error.js";
|
|
7
|
+
import { LOGIN_HELP } from "./help.js";
|
|
8
|
+
const LOGIN_USAGE = "Usage: npx driggsby@latest login [--code <CODE>]";
|
|
9
|
+
export function parseLogin(argv) {
|
|
10
|
+
let code = null;
|
|
11
|
+
let optionsEnded = false;
|
|
12
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
13
|
+
const token = argv[index] ?? "";
|
|
14
|
+
if (!optionsEnded && token === "--") {
|
|
15
|
+
optionsEnded = true;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (!optionsEnded && (token === "-h" || token === "--help")) {
|
|
19
|
+
return helpCommand(LOGIN_HELP);
|
|
20
|
+
}
|
|
21
|
+
if (!optionsEnded && code === null && token === "--code") {
|
|
22
|
+
code = requireCode(argv[index + 1]);
|
|
23
|
+
index += 1;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (!optionsEnded && code === null && token.startsWith("--code=")) {
|
|
27
|
+
code = requireCode(token.slice("--code=".length));
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
throw unexpectedArgument(token, LOGIN_USAGE);
|
|
31
|
+
}
|
|
32
|
+
return { kind: "login", code };
|
|
33
|
+
}
|
|
34
|
+
// A code is never empty or flag-like; a pasted one may carry spaces, which
|
|
35
|
+
// Driggsby reads past.
|
|
36
|
+
function requireCode(value) {
|
|
37
|
+
if (value === undefined || value.trim() === "" || value.startsWith("-")) {
|
|
38
|
+
throw new CliError(`error: a value is required for '--code <CODE>' but none was supplied\n\n${LOGIN_USAGE}\n\nFor more information, try '--help'.`, 2);
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
package/dist/args.js
CHANGED
|
@@ -10,13 +10,14 @@ import { parseDeploy, parseRollback, parseVersions } from "./args-deploy.js";
|
|
|
10
10
|
import { parseDev } from "./args-dev.js";
|
|
11
11
|
import { parseDelete } from "./args-delete.js";
|
|
12
12
|
import { parseInit } from "./args-init.js";
|
|
13
|
+
import { parseLogin } from "./args-login.js";
|
|
13
14
|
import { parseMcpSetup } from "./args-mcp-setup.js";
|
|
14
15
|
import { parseQuery } from "./args-query.js";
|
|
15
16
|
import { parseRules } from "./args-rules.js";
|
|
16
17
|
import { removeDashesTip, similarArgumentTip, unexpectedArgument, unexpectedFlagValue, } from "./args-shared.js";
|
|
17
18
|
import { didYouMean } from "./clap-suggestions.js";
|
|
18
19
|
import { CliError } from "./cli-error.js";
|
|
19
|
-
import {
|
|
20
|
+
import { LOGOUT_HELP, MCP_HELP, ROOT_HELP } from "./help.js";
|
|
20
21
|
import { sanitizeForTerminal } from "./terminal-text.js";
|
|
21
22
|
export {} from "./args-shared.js";
|
|
22
23
|
const ROOT_USAGE = "Usage: npx driggsby@latest <COMMAND>";
|
|
@@ -90,7 +91,7 @@ function dispatchSubcommand(name, rest) {
|
|
|
90
91
|
case "mcp":
|
|
91
92
|
return parseLevel(MCP_LEVEL, rest);
|
|
92
93
|
case "login":
|
|
93
|
-
return
|
|
94
|
+
return parseLogin(rest);
|
|
94
95
|
case "logout":
|
|
95
96
|
return parseBareCommand({ kind: "logout" }, LOGOUT_HELP, "Usage: npx driggsby@latest logout", rest);
|
|
96
97
|
case "init":
|
|
@@ -117,7 +118,7 @@ function dispatchSubcommand(name, rest) {
|
|
|
117
118
|
throw new Error(`unhandled subcommand dispatch: ${name}`);
|
|
118
119
|
}
|
|
119
120
|
}
|
|
120
|
-
// Parsing for commands that take no arguments beyond help:
|
|
121
|
+
// Parsing for commands that take no arguments beyond help: logout.
|
|
121
122
|
// Same exit codes and error shape as the rest of the tree, no clap quirks.
|
|
122
123
|
function parseBareCommand(command, helpText, usage, argv) {
|
|
123
124
|
let optionsEnded = false;
|
package/dist/device.js
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { isIP } from "node:net";
|
|
3
3
|
import { hostname, platform, release, version } from "node:os";
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// headers. They must never admit % ! ^ & | < > " \ : or a control
|
|
7
|
-
// character; device.test.ts checks each.
|
|
4
|
+
// These allowlists keep what leaves the CLI plain text: they never admit
|
|
5
|
+
// % ! ^ & | < > " \ : or a control character (device.test.ts checks each).
|
|
8
6
|
const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$/;
|
|
9
7
|
const SYSTEM_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 ._/-]{0,63}$/;
|
|
10
8
|
const MAC_VERSION_FILE = "/System/Library/CoreServices/SystemVersion.plist";
|
|
@@ -38,17 +36,6 @@ export function knownDevice(device) {
|
|
|
38
36
|
};
|
|
39
37
|
return Object.keys(known).length === 0 ? null : known;
|
|
40
38
|
}
|
|
41
|
-
// The Driggsby headers `mcp setup` gives Claude Code, one per known value.
|
|
42
|
-
export function deviceHeaders(device) {
|
|
43
|
-
const headers = [];
|
|
44
|
-
if (device.name !== null) {
|
|
45
|
-
headers.push(`X-Driggsby-Device-Name: ${device.name}`);
|
|
46
|
-
}
|
|
47
|
-
if (device.system !== null) {
|
|
48
|
-
headers.push(`X-Driggsby-Device-System: ${device.system}`);
|
|
49
|
-
}
|
|
50
|
-
return headers;
|
|
51
|
-
}
|
|
52
39
|
// "mbp-studio.local" and "devbox-02.corp.example.test" are mbp-studio and
|
|
53
40
|
// devbox-02: the first label is the name people give their computer. An
|
|
54
41
|
// address or localhost names no computer.
|
package/dist/help.js
CHANGED
|
@@ -64,10 +64,15 @@ app token that lets this machine read your Driggsby data, deploy Driggsby
|
|
|
64
64
|
apps, and manage your transaction rules. The token is saved locally for
|
|
65
65
|
other driggsby commands and is never printed.
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
If you approve in a browser on another computer, the page shows a code
|
|
68
|
+
instead. Paste it where login is waiting, or finish with --code.
|
|
69
|
+
|
|
70
|
+
Usage: npx driggsby@latest login [--code <CODE>]
|
|
68
71
|
|
|
69
72
|
Options:
|
|
70
|
-
-
|
|
73
|
+
--code <CODE> Finish the sign-in waiting on this machine with the
|
|
74
|
+
code its approval page showed
|
|
75
|
+
-h, --help Print help
|
|
71
76
|
`;
|
|
72
77
|
export const LOGOUT_HELP = `Remove the saved Driggsby app token.
|
|
73
78
|
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// The wait inside driggsby login, once the approval page is in front of the
|
|
2
|
+
// person: the first of the loopback's code, a pasted code, or the claim
|
|
3
|
+
// going away (declined or expired) ends it. Every code is traded with the
|
|
4
|
+
// verifier; only that trade returns the token.
|
|
5
|
+
import { CliError } from "../cli-error.js";
|
|
6
|
+
import { pollClaimRequest, tradeCode } from "./claim-client.js";
|
|
7
|
+
import {} from "./loopback.js";
|
|
8
|
+
export const LOGIN_RETRY_COMMAND = "npx driggsby@latest login";
|
|
9
|
+
export const CODE_COMMAND = "npx driggsby@latest login --code <CODE>";
|
|
10
|
+
// Approving leaves at least this long to trade the code, even past the
|
|
11
|
+
// link's own lifetime, so a late approval can still finish.
|
|
12
|
+
export const CODE_WINDOW_MS = 300_000;
|
|
13
|
+
// Driggsby's codes are four groups of five; a paste longer than this is
|
|
14
|
+
// not one (the approval link, say), and is refused here without a trade.
|
|
15
|
+
const MAX_CODE_CHARS = 64;
|
|
16
|
+
// A trade that hits a momentary hiccup is tried again this many times.
|
|
17
|
+
const TRADE_ATTEMPTS = 3;
|
|
18
|
+
const TRADE_RETRY_MS = 2_000;
|
|
19
|
+
// A code from the loopback is the only copy there is: when Driggsby can't
|
|
20
|
+
// be reached, the wait keeps trying it this often until the wait ends.
|
|
21
|
+
const LOOPBACK_RETRY_MS = 5_000;
|
|
22
|
+
const CODE_SHAPE_REFUSED = "That doesn't look like a sign-in code. Copy the code the approval page\nshows.";
|
|
23
|
+
const UNREACHABLE = "We couldn't reach Driggsby to finish signing in just now.";
|
|
24
|
+
// The claim went away: declined, expired, or already spent. Distinct, so
|
|
25
|
+
// login can tell it from other failures (another command may have spent it).
|
|
26
|
+
export class ClaimGone extends CliError {
|
|
27
|
+
}
|
|
28
|
+
// The trades in flight. The trade that spends the claim makes the poll
|
|
29
|
+
// read gone before the trade's own answer arrives; the poll waits for any
|
|
30
|
+
// trade in flight so that answer speaks first.
|
|
31
|
+
class Trades {
|
|
32
|
+
#inFlight = 0;
|
|
33
|
+
#started = 0;
|
|
34
|
+
#idle = [];
|
|
35
|
+
get started() {
|
|
36
|
+
return this.#started;
|
|
37
|
+
}
|
|
38
|
+
busy() {
|
|
39
|
+
return this.#inFlight > 0;
|
|
40
|
+
}
|
|
41
|
+
async run(work) {
|
|
42
|
+
this.#inFlight += 1;
|
|
43
|
+
this.#started += 1;
|
|
44
|
+
try {
|
|
45
|
+
return await work();
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
this.#inFlight -= 1;
|
|
49
|
+
if (this.#inFlight === 0) {
|
|
50
|
+
for (const resolve of this.#idle.splice(0)) {
|
|
51
|
+
resolve();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
idle() {
|
|
57
|
+
return this.#inFlight === 0 ? Promise.resolve() : new Promise((resolve) => this.#idle.push(resolve));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export async function awaitToken(sign, clock) {
|
|
61
|
+
const stop = new AbortController();
|
|
62
|
+
const trades = new Trades();
|
|
63
|
+
try {
|
|
64
|
+
const outcome = await Promise.race([
|
|
65
|
+
fromLoopback(sign, clock, trades, stop.signal),
|
|
66
|
+
fromPaste(sign, clock, trades, stop.signal),
|
|
67
|
+
fromPoll(sign, clock, trades, stop.signal),
|
|
68
|
+
]);
|
|
69
|
+
if (outcome.kind === "error") {
|
|
70
|
+
throw outcome.error;
|
|
71
|
+
}
|
|
72
|
+
return outcome.appToken;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
stop.abort();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// A browser on this computer handed over a code, or Driggsby's denial. Its
|
|
79
|
+
// request waits on the loopback until the trade is done, so it lands on a
|
|
80
|
+
// page that already says how it went. Any page on this computer can reach
|
|
81
|
+
// the loopback, so a callback proves nothing by itself: a code that doesn't
|
|
82
|
+
// trade is refused without saying where the approval page is, and a denial
|
|
83
|
+
// counts only once Driggsby confirms the claim is gone.
|
|
84
|
+
async function fromLoopback(sign, clock, trades, signal) {
|
|
85
|
+
if (sign.loopback === null) {
|
|
86
|
+
return never();
|
|
87
|
+
}
|
|
88
|
+
for (;;) {
|
|
89
|
+
const callback = await sign.loopback.next();
|
|
90
|
+
if (stopped(signal)) {
|
|
91
|
+
// The sign-in already ended; the page says how.
|
|
92
|
+
callback.answer(sign.landingUrl);
|
|
93
|
+
return never();
|
|
94
|
+
}
|
|
95
|
+
if (callback.kind === "denied") {
|
|
96
|
+
const mark = trades.started;
|
|
97
|
+
const poll = await pollLettingTradesSpeak(sign, trades, mark);
|
|
98
|
+
if (poll.kind === "approved") {
|
|
99
|
+
// A server older than PKCE handed this poll the token; it counts,
|
|
100
|
+
// as it does in fromPoll.
|
|
101
|
+
callback.answer(sign.landingUrl);
|
|
102
|
+
return { kind: "token", appToken: poll.appToken };
|
|
103
|
+
}
|
|
104
|
+
if (poll.kind === "gone") {
|
|
105
|
+
// A trade in flight may be what spent the claim; it speaks first.
|
|
106
|
+
await settleTrades(trades, mark);
|
|
107
|
+
callback.answer(sign.landingUrl);
|
|
108
|
+
return stopped(signal) ? never() : { kind: "error", error: declined() };
|
|
109
|
+
}
|
|
110
|
+
callback.refuse();
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
for (;;) {
|
|
114
|
+
const result = await trades.run(() => tradeWithRetries(sign, callback.code, clock, signal));
|
|
115
|
+
if (stopped(signal)) {
|
|
116
|
+
callback.refuse();
|
|
117
|
+
return never();
|
|
118
|
+
}
|
|
119
|
+
if (result.kind === "approved") {
|
|
120
|
+
callback.answer(sign.landingUrl);
|
|
121
|
+
return { kind: "token", appToken: result.appToken };
|
|
122
|
+
}
|
|
123
|
+
if (result.kind === "rejected") {
|
|
124
|
+
callback.refuse();
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
// Unreachable: the code may still work, and it's the only copy.
|
|
128
|
+
await clock.sleep(LOOPBACK_RETRY_MS, signal);
|
|
129
|
+
if (stopped(signal)) {
|
|
130
|
+
return never();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// A code pasted at the prompt. A refused code asks again, so a typo costs
|
|
136
|
+
// nothing; the claim's own expiry still ends the wait.
|
|
137
|
+
async function fromPaste(sign, clock, trades, signal) {
|
|
138
|
+
if (sign.prompt === null) {
|
|
139
|
+
return never();
|
|
140
|
+
}
|
|
141
|
+
let question = sign.promptQuestion;
|
|
142
|
+
for (;;) {
|
|
143
|
+
const line = await sign.prompt.ask(question);
|
|
144
|
+
if (stopped(signal) || line === null) {
|
|
145
|
+
return never();
|
|
146
|
+
}
|
|
147
|
+
const code = line.trim();
|
|
148
|
+
if (code === "") {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const result = await trades.run(() => tradeWithRetries(sign, code, clock, signal));
|
|
152
|
+
if (stopped(signal)) {
|
|
153
|
+
return never();
|
|
154
|
+
}
|
|
155
|
+
if (result.kind === "approved") {
|
|
156
|
+
return { kind: "token", appToken: result.appToken };
|
|
157
|
+
}
|
|
158
|
+
question =
|
|
159
|
+
result.kind === "unreachable"
|
|
160
|
+
? `${result.message}\nPaste the code again to retry: `
|
|
161
|
+
: `${result.message}\nCheck the code and paste it again: `;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// The claim's own state: declined or expired ends the wait. An approval
|
|
165
|
+
// waiting for its code still polls as pending, so the wait runs past the
|
|
166
|
+
// link's lifetime by up to the code window, until Driggsby says it's gone.
|
|
167
|
+
// Driggsby never hands a PKCE claim's token to the poll; a server that did
|
|
168
|
+
// (one older than PKCE) is still handing it to the CLI that made the claim,
|
|
169
|
+
// so it counts.
|
|
170
|
+
async function fromPoll(sign, clock, trades, signal) {
|
|
171
|
+
const ceiling = sign.deadline + CODE_WINDOW_MS;
|
|
172
|
+
while (clock.now() < ceiling) {
|
|
173
|
+
await clock.sleep(clock.pollIntervalMs, signal);
|
|
174
|
+
if (stopped(signal)) {
|
|
175
|
+
return never();
|
|
176
|
+
}
|
|
177
|
+
const mark = trades.started;
|
|
178
|
+
const result = await pollLettingTradesSpeak(sign, trades, mark);
|
|
179
|
+
if (stopped(signal)) {
|
|
180
|
+
return never();
|
|
181
|
+
}
|
|
182
|
+
if (result.kind === "approved") {
|
|
183
|
+
return { kind: "token", appToken: result.appToken };
|
|
184
|
+
}
|
|
185
|
+
if (result.kind === "gone") {
|
|
186
|
+
await settleTrades(trades, mark);
|
|
187
|
+
if (stopped(signal)) {
|
|
188
|
+
return never();
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
kind: "error",
|
|
192
|
+
error: new ClaimGone("This sign-in link is no longer active — it may have been declined or timed out.\n\n" +
|
|
193
|
+
`Start a fresh sign-in:\n ${LOGIN_RETRY_COMMAND}`, 1),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// A trade still in flight at the ceiling may be the one that spends it.
|
|
198
|
+
await settleTrades(trades, trades.started);
|
|
199
|
+
if (stopped(signal)) {
|
|
200
|
+
return never();
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
kind: "error",
|
|
204
|
+
error: new CliError(`The sign-in link expired before it was approved.\n\nStart a fresh sign-in:\n ${LOGIN_RETRY_COMMAND}`, 1),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
// The claim's state. A poll that fails outright (an answer Driggsby never
|
|
208
|
+
// gives) ends the wait, but only after any trade in flight speaks: that
|
|
209
|
+
// trade may be what spent the claim, and its token must not be dropped.
|
|
210
|
+
function pollLettingTradesSpeak(sign, trades, mark) {
|
|
211
|
+
return pollClaimRequest(sign.baseUrl, { claimRequestId: sign.claimRequestId, pollSecret: sign.pollSecret }).catch(async (error) => {
|
|
212
|
+
await settleTrades(trades, mark);
|
|
213
|
+
throw error;
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
// Waits out any trade that is in flight, or that started since mark, and
|
|
217
|
+
// then one more turn, so a trade that spent the claim settles the race
|
|
218
|
+
// before a "gone" or "expired" does.
|
|
219
|
+
async function settleTrades(trades, mark) {
|
|
220
|
+
if (!trades.busy() && trades.started === mark) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
await trades.idle();
|
|
224
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
225
|
+
}
|
|
226
|
+
export async function tradeWithRetries(sign, code, clock, signal) {
|
|
227
|
+
if (code === "" || code.length > MAX_CODE_CHARS) {
|
|
228
|
+
return { kind: "rejected", message: CODE_SHAPE_REFUSED };
|
|
229
|
+
}
|
|
230
|
+
for (let attempt = 1;; attempt += 1) {
|
|
231
|
+
if (attempt > 1 && signal !== undefined && stopped(signal)) {
|
|
232
|
+
return { kind: "unreachable", message: UNREACHABLE };
|
|
233
|
+
}
|
|
234
|
+
const result = await tradeCode(sign.baseUrl, {
|
|
235
|
+
claimRequestId: sign.claimRequestId,
|
|
236
|
+
code,
|
|
237
|
+
codeVerifier: sign.codeVerifier,
|
|
238
|
+
});
|
|
239
|
+
if (result.kind !== "transient") {
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
if (attempt >= TRADE_ATTEMPTS || (signal !== undefined && stopped(signal))) {
|
|
243
|
+
return { kind: "unreachable", message: UNREACHABLE };
|
|
244
|
+
}
|
|
245
|
+
await clock.sleep(TRADE_RETRY_MS, signal);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// A confirmed denial is the claim gone too, so a login whose sign-in
|
|
249
|
+
// another command finished still tells that apart.
|
|
250
|
+
function declined() {
|
|
251
|
+
return new ClaimGone(`This sign-in was declined.\n\nStart a fresh sign-in:\n ${LOGIN_RETRY_COMMAND}`, 1);
|
|
252
|
+
}
|
|
253
|
+
// Read through a call: the signal flips while this code awaits, which a
|
|
254
|
+
// property check narrowed before the await would not see.
|
|
255
|
+
function stopped(signal) {
|
|
256
|
+
return signal.aborted;
|
|
257
|
+
}
|
|
258
|
+
function never() {
|
|
259
|
+
return new Promise(() => undefined);
|
|
260
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// The app-token claim flow's HTTP client, against Driggsby's public
|
|
2
|
-
// app-token claim API: create a pending claim
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// app-token claim API: create a pending claim carrying a PKCE challenge,
|
|
3
|
+
// send the user to claim_url, and trade the one-time code the approval
|
|
4
|
+
// produced, with the verifier, for the app token. The poll only tells a
|
|
5
|
+
// waiting sign-in that its claim is gone (denied or expired); Driggsby
|
|
6
|
+
// hands a PKCE claim's token only to the code trade.
|
|
6
7
|
import { CliError } from "../cli-error.js";
|
|
7
8
|
import { knownDevice } from "../device.js";
|
|
8
9
|
import { sanitizeForTerminal } from "../terminal-text.js";
|
|
@@ -26,11 +27,18 @@ export const SIGN_IN_START_FAILURE = "We weren't able to start a Driggsby sign-i
|
|
|
26
27
|
const SIGN_IN_FINISH_FAILURE = "We weren't able to finish your Driggsby sign-in just now. Please try again\nin a minute.";
|
|
27
28
|
// device: this computer's name and system (device.ts), so the sign-in shows
|
|
28
29
|
// up on Driggsby's MCP page as the computer it is; only known values go.
|
|
29
|
-
export async function createClaimRequest(baseUrl, device = { name: null, system: null }) {
|
|
30
|
+
export async function createClaimRequest(baseUrl, pkce, device = { name: null, system: null }) {
|
|
30
31
|
const response = await fetch(`${baseUrl}/app-tokens/claim-requests`, {
|
|
31
32
|
method: "POST",
|
|
32
33
|
headers: { "Content-Type": "application/json" },
|
|
33
|
-
body: JSON.stringify({
|
|
34
|
+
body: JSON.stringify({
|
|
35
|
+
app_name: CLAIM_APP_NAME,
|
|
36
|
+
scope: CLAIM_SCOPE,
|
|
37
|
+
code_challenge: pkce.challenge,
|
|
38
|
+
code_challenge_method: "S256",
|
|
39
|
+
...(pkce.redirectUri === null ? {} : { redirect_uri: pkce.redirectUri }),
|
|
40
|
+
...deviceField(device),
|
|
41
|
+
}),
|
|
34
42
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
35
43
|
// Never follow a redirect: a 307/308 would replay this claim flow —
|
|
36
44
|
// and, on the poll below, the poll secret — to whatever origin a
|
|
@@ -119,6 +127,47 @@ export async function pollClaimRequest(baseUrl, credentials) {
|
|
|
119
127
|
return { kind: "transient" };
|
|
120
128
|
}
|
|
121
129
|
}
|
|
130
|
+
const CODE_REJECTED = "That sign-in code didn't work. It may have been mistyped, used\nalready, or expired.";
|
|
131
|
+
export async function tradeCode(baseUrl, trade) {
|
|
132
|
+
let response;
|
|
133
|
+
try {
|
|
134
|
+
response = await fetch(`${baseUrl}/app-tokens/claim-requests/token`, {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: { "Content-Type": "application/json" },
|
|
137
|
+
body: JSON.stringify({
|
|
138
|
+
claim_request_id: trade.claimRequestId,
|
|
139
|
+
code: trade.code,
|
|
140
|
+
code_verifier: trade.codeVerifier,
|
|
141
|
+
}),
|
|
142
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
143
|
+
// The verifier and code must never follow a redirect to another origin.
|
|
144
|
+
redirect: "error",
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return { kind: "transient" };
|
|
149
|
+
}
|
|
150
|
+
const body = await readJsonBody(response);
|
|
151
|
+
if (response.status === 200) {
|
|
152
|
+
const appToken = stringField(asRecord(body), "app_token");
|
|
153
|
+
if (appToken === null) {
|
|
154
|
+
throw new CliError(SIGN_IN_FINISH_FAILURE, 1);
|
|
155
|
+
}
|
|
156
|
+
return { kind: "approved", appToken };
|
|
157
|
+
}
|
|
158
|
+
// The CLI says what to do next itself (paste again, or start over), so a
|
|
159
|
+
// refused code reads in its own words.
|
|
160
|
+
// 422 too: a paste Driggsby reads as blank (a Unicode space the CLI
|
|
161
|
+
// doesn't trim) is a wrong code, never the end of the sign-in.
|
|
162
|
+
if (response.status === 400 || response.status === 422) {
|
|
163
|
+
return { kind: "rejected", message: CODE_REJECTED };
|
|
164
|
+
}
|
|
165
|
+
if (response.status === 429 || response.status >= 500) {
|
|
166
|
+
return { kind: "transient" };
|
|
167
|
+
}
|
|
168
|
+
// Any other answer means our request is wrong, which a retry can't fix.
|
|
169
|
+
throw new CliError(errorDescription(body) ?? SIGN_IN_FINISH_FAILURE, 1);
|
|
170
|
+
}
|
|
122
171
|
async function readJsonBody(response) {
|
|
123
172
|
let text;
|
|
124
173
|
try {
|
package/dist/login/login.js
CHANGED
|
@@ -1,42 +1,151 @@
|
|
|
1
|
-
// driggsby login:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// driggsby login: sign in with PKCE. The CLI keeps a verifier, listens on
|
|
2
|
+
// its loopback when it can, and opens Driggsby's approval page itself.
|
|
3
|
+
// Approving there hands the one-time code to this CLI's loopback;
|
|
4
|
+
// approving from any other browser shows the code, to paste here or to
|
|
5
|
+
// finish with `driggsby login --code <CODE>`. Only the code and the
|
|
6
|
+
// verifier together mint the token, which goes straight into the
|
|
7
|
+
// credential store and is shown to no one.
|
|
5
8
|
import { apiBaseUrl, apiHost, baseUrlMayReceiveSavedSignIn } from "../api/base-url.js";
|
|
6
9
|
import { blockedNetworkError } from "../api/network-error.js";
|
|
7
10
|
import { CliError } from "../cli-error.js";
|
|
8
11
|
import { describeDevice } from "../device.js";
|
|
9
12
|
import { defaultCredentialEnvironment, describeStorageLocation, environmentToken, saveToken, } from "../credentials/store.js";
|
|
10
13
|
import { sanitizeForTerminal, wrapProse } from "../terminal-text.js";
|
|
11
|
-
import {
|
|
14
|
+
import { awaitToken, ClaimGone, CODE_COMMAND, CODE_WINDOW_MS, LOGIN_RETRY_COMMAND, tradeWithRetries, } from "./await-token.js";
|
|
15
|
+
import { createClaimRequest, SIGN_IN_START_FAILURE } from "./claim-client.js";
|
|
16
|
+
import { startLoopback } from "./loopback.js";
|
|
12
17
|
import { tryOpenUrl } from "./open-url.js";
|
|
13
|
-
|
|
18
|
+
import { clearPendingLogin, markPendingLoginFinished, readPendingLogin, savePendingLogin, } from "./pending.js";
|
|
19
|
+
import { createPkcePair } from "./pkce.js";
|
|
20
|
+
import { abortableSleep } from "./sleep.js";
|
|
21
|
+
import { terminalCodePrompt } from "./terminal-prompt.js";
|
|
14
22
|
const DEFAULT_POLL_INTERVAL_MS = 4_000;
|
|
23
|
+
// How long a waiting login gives `login --code` to mark a spent claim.
|
|
24
|
+
export const FINISH_MARK_WAIT_MS = 3_000;
|
|
25
|
+
// Where a browser opened "here" may really run on another computer (an SSH
|
|
26
|
+
// session, a cloud dev box forwarding its opener), so its approval could
|
|
27
|
+
// never reach this loopback: the page shows the code to paste instead.
|
|
28
|
+
const REMOTE_SESSION_VARIABLES = ["SSH_CONNECTION", "SSH_TTY", "CODESPACES", "GITPOD_WORKSPACE_ID"];
|
|
15
29
|
function defaultLoginIo() {
|
|
16
30
|
return {
|
|
17
31
|
out: (text) => {
|
|
18
32
|
process.stdout.write(text);
|
|
19
33
|
},
|
|
20
34
|
openUrl: tryOpenUrl,
|
|
21
|
-
sleep:
|
|
35
|
+
sleep: abortableSleep,
|
|
22
36
|
now: () => Date.now(),
|
|
23
37
|
pollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
|
|
38
|
+
startLoopback,
|
|
39
|
+
codePrompt: terminalCodePrompt,
|
|
24
40
|
};
|
|
25
41
|
}
|
|
26
42
|
export async function runLogin(environment = defaultCredentialEnvironment(), io = defaultLoginIo()) {
|
|
27
43
|
const baseUrl = apiBaseUrl(environment.env);
|
|
28
44
|
io.out("Sign in to Driggsby\n\n");
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
const pkce = createPkcePair();
|
|
46
|
+
const remoteSession = REMOTE_SESSION_VARIABLES.some((name) => (environment.env[name] ?? "") !== "");
|
|
47
|
+
const loopback = remoteSession ? null : await io.startLoopback();
|
|
48
|
+
let prompt = null;
|
|
49
|
+
try {
|
|
50
|
+
const claim = await createClaim(baseUrl, { challenge: pkce.challenge, redirectUri: loopback?.redirectUri ?? null });
|
|
51
|
+
const claimUrl = approvedClaimUrl(claim.claimUrl, baseUrl);
|
|
52
|
+
const expiresAt = io.now() + claim.expiresInSeconds * 1_000;
|
|
53
|
+
const pending = {
|
|
54
|
+
baseUrl,
|
|
55
|
+
claimRequestId: claim.claimRequestId,
|
|
56
|
+
codeVerifier: pkce.verifier,
|
|
57
|
+
expiresAt: expiresAt + CODE_WINDOW_MS,
|
|
58
|
+
finished: false,
|
|
59
|
+
};
|
|
60
|
+
const remembered = await rememberPendingLogin(environment, pending);
|
|
61
|
+
prompt = io.codePrompt();
|
|
62
|
+
// With no one at a prompt and no loopback, only `login --code` can
|
|
63
|
+
// finish, and it needs the record: refuse before a page opens that
|
|
64
|
+
// nothing could finish.
|
|
65
|
+
if (prompt === null && loopback === null && !remembered) {
|
|
66
|
+
throw cantFinishHere();
|
|
67
|
+
}
|
|
68
|
+
// Only a page this CLI opened itself may hand its code to the
|
|
69
|
+
// loopback; the printed link, opened anywhere, shows the code instead.
|
|
70
|
+
const browserOpened = await io.openUrl(loopback === null ? claimUrl : handoffUrl(claimUrl));
|
|
71
|
+
const minutes = approximateMinutes(claim.expiresInSeconds);
|
|
72
|
+
const minutesWord = minutes === 1 ? "minute" : "minutes";
|
|
73
|
+
// No one at a prompt, and no page this CLI opened can hand the code to
|
|
74
|
+
// the loopback: an agent, or a remote session. Nothing in this process
|
|
75
|
+
// could ever receive the code, so it doesn't wait: the page shows the
|
|
76
|
+
// code, and `login --code` finishes with the record.
|
|
77
|
+
const finishesElsewhere = prompt === null && (loopback === null || !browserOpened);
|
|
78
|
+
if (finishesElsewhere && !remembered) {
|
|
79
|
+
throw cantFinishHere();
|
|
80
|
+
}
|
|
81
|
+
io.out(browserOpened
|
|
82
|
+
? "Your browser should open a Driggsby approval page. If it doesn't, open\nthis link:\n\n"
|
|
83
|
+
: "Open this link in your browser to approve access for this machine:\n\n");
|
|
84
|
+
io.out(` ${claimUrl}\n\n`);
|
|
85
|
+
if (finishesElsewhere) {
|
|
86
|
+
io.out(`After you approve, the page shows a code. Finish signing in with:\n ${CODE_COMMAND}\n\n`);
|
|
87
|
+
io.out(`The link is good for about ${minutes} ${minutesWord}.\n`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
io.out(`Waiting for your approval (the link is good for about ${minutes} ${minutesWord})...\n`);
|
|
91
|
+
if (prompt === null && remembered) {
|
|
92
|
+
io.out(`If the page shows a code instead, finish with:\n ${CODE_COMMAND}\n`);
|
|
93
|
+
}
|
|
94
|
+
const finish = await awaitTokenOrElsewhere(pending, awaitToken({
|
|
95
|
+
baseUrl,
|
|
96
|
+
claimRequestId: claim.claimRequestId,
|
|
97
|
+
pollSecret: claim.pollSecret,
|
|
98
|
+
codeVerifier: pkce.verifier,
|
|
99
|
+
deadline: expiresAt,
|
|
100
|
+
landingUrl: claimUrl,
|
|
101
|
+
loopback,
|
|
102
|
+
prompt,
|
|
103
|
+
promptQuestion: "If the page shows a code instead, paste it here: ",
|
|
104
|
+
}, io), remembered, environment, io);
|
|
105
|
+
prompt?.close();
|
|
106
|
+
prompt = null;
|
|
107
|
+
// The claim is spent either way; only this sign-in's own record goes.
|
|
108
|
+
await forgetPendingLogin(environment, pending, io);
|
|
109
|
+
if (finish.kind === "elsewhere") {
|
|
110
|
+
io.out("\nThis sign-in was finished by npx driggsby@latest login --code in\n" +
|
|
111
|
+
"another command; its output says where the token was saved.\n");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
await finishSignIn(finish.appToken, environment, io);
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
prompt?.close();
|
|
118
|
+
loopback?.close();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// driggsby login --code <CODE>: finish the sign-in waiting on this computer
|
|
122
|
+
// with the code its approval page showed.
|
|
123
|
+
export async function runLoginWithCode(code, environment = defaultCredentialEnvironment(), io = defaultLoginIo()) {
|
|
124
|
+
const baseUrl = apiBaseUrl(environment.env);
|
|
125
|
+
const pending = await readPendingLogin(environment.homeDirectory, io.now());
|
|
126
|
+
if (pending?.baseUrl !== baseUrl) {
|
|
127
|
+
throw new CliError("No sign-in is waiting for a code on this computer. It may have expired.\n\n" +
|
|
128
|
+
`Start a fresh sign-in:\n ${LOGIN_RETRY_COMMAND}`, 1);
|
|
129
|
+
}
|
|
130
|
+
if (pending.finished) {
|
|
131
|
+
throw new CliError(`This sign-in already finished.\n\nTo sign in again:\n ${LOGIN_RETRY_COMMAND}`, 1);
|
|
132
|
+
}
|
|
133
|
+
const result = await tradeWithRetries(pending, code.trim(), io);
|
|
134
|
+
if (result.kind === "unreachable") {
|
|
135
|
+
throw new CliError(`${result.message}\n\nYour code may still work: run the same command again in a minute.\n` +
|
|
136
|
+
`Or start a fresh sign-in:\n ${LOGIN_RETRY_COMMAND}`, 1);
|
|
137
|
+
}
|
|
138
|
+
if (result.kind === "rejected") {
|
|
139
|
+
// One record per computer: a newer `login` replaced an older one's.
|
|
140
|
+
throw new CliError(`${result.message}\n\nCheck the code and run the command again. Only the code from the latest\n` +
|
|
141
|
+
`sign-in started on this computer works here. Or start a fresh sign-in:\n ${LOGIN_RETRY_COMMAND}`, 1);
|
|
142
|
+
}
|
|
143
|
+
// Marked once the claim is spent, before the token is saved: a login
|
|
144
|
+
// still waiting on this claim sees it finished here, not failed.
|
|
145
|
+
await markFinished(environment, pending, io);
|
|
146
|
+
await finishSignIn(result.appToken, environment, io);
|
|
147
|
+
}
|
|
148
|
+
async function finishSignIn(appToken, environment, io) {
|
|
40
149
|
// Warnings are held back and printed after the "Approved." line — and when
|
|
41
150
|
// one fires, it replaces the all-clear closer: the last thing the user
|
|
42
151
|
// reads must never contradict a warning they were just shown.
|
|
@@ -53,6 +162,81 @@ export async function runLogin(environment = defaultCredentialEnvironment(), io
|
|
|
53
162
|
}
|
|
54
163
|
io.out("\nNext:\n You're all set — driggsby commands will use this saved token\n automatically. Deploy an app with npx driggsby@latest deploy.\n");
|
|
55
164
|
}
|
|
165
|
+
// The record `login --code` finishes from. Best effort: without it, the
|
|
166
|
+
// loopback and the prompt still work. True once it is saved.
|
|
167
|
+
async function rememberPendingLogin(environment, pending) {
|
|
168
|
+
try {
|
|
169
|
+
await savePendingLogin(environment.homeDirectory, pending);
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// The credential save reports an unwritable home directory itself.
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// The wait's token, or "elsewhere" when `login --code` in another command
|
|
178
|
+
// finished this very sign-in meanwhile: the claim reads gone, and this
|
|
179
|
+
// claim's record says finished. Any other failure also ends this sign-in
|
|
180
|
+
// for good, so its record goes with it.
|
|
181
|
+
async function awaitTokenOrElsewhere(pending, wait, remembered, environment, io) {
|
|
182
|
+
try {
|
|
183
|
+
return { kind: "token", appToken: await wait };
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
if (remembered && error instanceof ClaimGone && (await finishedElsewhere(environment, pending, io))) {
|
|
187
|
+
return { kind: "elsewhere" };
|
|
188
|
+
}
|
|
189
|
+
await forgetPendingLogin(environment, pending, io);
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// `login --code` marks the record just after its trade spends the claim,
|
|
194
|
+
// so a poll can read gone a moment before the mark lands: a record still
|
|
195
|
+
// this claim's but not yet finished is read once more after a pause.
|
|
196
|
+
async function finishedElsewhere(environment, pending, io) {
|
|
197
|
+
for (let look = 1;; look += 1) {
|
|
198
|
+
let current;
|
|
199
|
+
try {
|
|
200
|
+
current = await readPendingLogin(environment.homeDirectory, io.now());
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
if (current?.claimRequestId !== pending.claimRequestId) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
if (current.finished || look >= 2) {
|
|
209
|
+
return current.finished;
|
|
210
|
+
}
|
|
211
|
+
await io.sleep(FINISH_MARK_WAIT_MS);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async function forgetPendingLogin(environment, pending, io) {
|
|
215
|
+
try {
|
|
216
|
+
await clearPendingLogin(environment.homeDirectory, pending.claimRequestId, io.now());
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// It expires on its own.
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async function markFinished(environment, pending, io) {
|
|
223
|
+
try {
|
|
224
|
+
await markPendingLoginFinished(environment.homeDirectory, pending.claimRequestId, io.now());
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
// Only a login still waiting on this claim reads it, and it then
|
|
228
|
+
// reports the claim gone: the token below is saved all the same.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function cantFinishHere() {
|
|
232
|
+
return new CliError("We couldn't save this sign-in on this machine to finish it later, so it\n" +
|
|
233
|
+
`can't finish here. Run it in a terminal instead:\n ${LOGIN_RETRY_COMMAND}`, 1);
|
|
234
|
+
}
|
|
235
|
+
function handoffUrl(claimUrl) {
|
|
236
|
+
const url = new URL(claimUrl);
|
|
237
|
+
url.searchParams.set("handoff", "loopback");
|
|
238
|
+
return url.href;
|
|
239
|
+
}
|
|
56
240
|
// The one-time token is already consumed by the time saving starts, so a
|
|
57
241
|
// storage failure must say clearly that a fresh sign-in is needed — never
|
|
58
242
|
// the generic "something went wrong".
|
|
@@ -68,12 +252,12 @@ async function storeApprovedToken(appToken, environment, warn) {
|
|
|
68
252
|
`on this machine. Please sign in again:\n ${LOGIN_RETRY_COMMAND}`, 1);
|
|
69
253
|
}
|
|
70
254
|
}
|
|
71
|
-
async function createClaim(baseUrl) {
|
|
255
|
+
async function createClaim(baseUrl, pkce) {
|
|
72
256
|
try {
|
|
73
257
|
// This computer's name goes only to Driggsby (or a local run), never
|
|
74
258
|
// to another host a base-URL override names.
|
|
75
259
|
const device = baseUrlMayReceiveSavedSignIn(baseUrl) ? describeDevice() : { name: null, system: null };
|
|
76
|
-
return await createClaimRequest(baseUrl, device);
|
|
260
|
+
return await createClaimRequest(baseUrl, pkce, device);
|
|
77
261
|
}
|
|
78
262
|
catch (error) {
|
|
79
263
|
const blocked = blockedNetworkError(error, {
|
|
@@ -112,27 +296,6 @@ function approvedClaimUrl(rawClaimUrl, baseUrl) {
|
|
|
112
296
|
}
|
|
113
297
|
return sanitizeForTerminal(parsed.href);
|
|
114
298
|
}
|
|
115
|
-
async function pollUntilApproved(baseUrl, claim, io) {
|
|
116
|
-
const deadline = io.now() + claim.expiresInSeconds * 1_000;
|
|
117
|
-
const credentials = { claimRequestId: claim.claimRequestId, pollSecret: claim.pollSecret };
|
|
118
|
-
while (io.now() < deadline) {
|
|
119
|
-
await io.sleep(io.pollIntervalMs);
|
|
120
|
-
const result = await pollClaimRequest(baseUrl, credentials);
|
|
121
|
-
switch (result.kind) {
|
|
122
|
-
case "approved":
|
|
123
|
-
// The token is delivered exactly once; from here on, never poll again.
|
|
124
|
-
return result.appToken;
|
|
125
|
-
case "gone":
|
|
126
|
-
throw new CliError("This sign-in link is no longer active — it may have been declined or timed out.\n\n" +
|
|
127
|
-
`Start a fresh sign-in:\n ${LOGIN_RETRY_COMMAND}`, 1);
|
|
128
|
-
case "pending":
|
|
129
|
-
case "transient":
|
|
130
|
-
break;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
throw new CliError("The sign-in link expired before it was approved.\n\n" +
|
|
134
|
-
`Start a fresh sign-in:\n ${LOGIN_RETRY_COMMAND}`, 1);
|
|
135
|
-
}
|
|
136
299
|
// DRIGGSBY_TOKEN outranks every saved token, so a login that saved one has
|
|
137
300
|
// not actually changed which token commands use. The warning joins the same
|
|
138
301
|
// held-back channel as storage warnings: after the "Approved." line, in
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// The loopback listener for driggsby login (RFC 8252). When this CLI opens
|
|
2
|
+
// Driggsby's approval page itself, approving there sends the browser to
|
|
3
|
+
// http://127.0.0.1:<port>/callback with the one-time code (or
|
|
4
|
+
// error=access_denied). A browser on any other computer lands on its own
|
|
5
|
+
// loopback, where nothing listens, so the code reaches only this machine.
|
|
6
|
+
//
|
|
7
|
+
// Any page in any browser on this computer can also reach the loopback, so
|
|
8
|
+
// a callback proves nothing by itself: the caller trades each code (only
|
|
9
|
+
// the approval's real code works with this CLI's verifier) and confirms a
|
|
10
|
+
// denial with Driggsby before believing it. Each callback's browser waits
|
|
11
|
+
// until the caller answers it: a real one goes back to the Driggsby page
|
|
12
|
+
// that says how the sign-in went, and a refused one learns nothing, not
|
|
13
|
+
// even where that page is. Once the sign-in has ended, a callback taken
|
|
14
|
+
// before the listener closes goes back to that page, which by then can only
|
|
15
|
+
// say it is over; one still held when it closes is told it can close the
|
|
16
|
+
// tab.
|
|
17
|
+
import { createServer } from "node:http";
|
|
18
|
+
const HOST = "127.0.0.1";
|
|
19
|
+
const CALLBACK_PATH = "/callback";
|
|
20
|
+
// Driggsby's codes are four groups of five; anything else is not one.
|
|
21
|
+
const CODE_PATTERN = /^[A-Za-z0-9-]{1,64}$/;
|
|
22
|
+
// A browser is never left hanging if a callback goes unanswered.
|
|
23
|
+
const HELD_RESPONSE_MS = 30_000;
|
|
24
|
+
// Callbacks waiting for the caller; more than this is someone knocking.
|
|
25
|
+
const MAX_QUEUED = 32;
|
|
26
|
+
const HEADERS = { "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" };
|
|
27
|
+
// null when this environment can't listen on the loopback (a sandbox that
|
|
28
|
+
// forbids it): the approval page then shows the code to paste instead.
|
|
29
|
+
export function startLoopback() {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
const queued = [];
|
|
32
|
+
const waiting = [];
|
|
33
|
+
// Each waiting browser, with the timer that answers it if no one does.
|
|
34
|
+
const held = new Map();
|
|
35
|
+
let port = 0;
|
|
36
|
+
const server = createServer((request, response) => {
|
|
37
|
+
const parsed = readCallback(request, port);
|
|
38
|
+
if (parsed === null || queued.length >= MAX_QUEUED) {
|
|
39
|
+
response.writeHead(404, { ...HEADERS, "Content-Type": "text/plain; charset=utf-8" });
|
|
40
|
+
response.end("Not found\n");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const settle = (landingUrl, refused) => {
|
|
44
|
+
clearTimeout(held.get(response));
|
|
45
|
+
held.delete(response);
|
|
46
|
+
answer(response, landingUrl, refused);
|
|
47
|
+
};
|
|
48
|
+
held.set(response, setTimeout(() => {
|
|
49
|
+
settle(null, false);
|
|
50
|
+
}, HELD_RESPONSE_MS));
|
|
51
|
+
const reply = {
|
|
52
|
+
answer: (landingUrl) => {
|
|
53
|
+
settle(landingUrl, false);
|
|
54
|
+
},
|
|
55
|
+
refuse: () => {
|
|
56
|
+
settle(null, true);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
const callback = parsed.kind === "code" ? { kind: "code", code: parsed.code, ...reply } : { kind: "denied", ...reply };
|
|
60
|
+
const taker = waiting.shift();
|
|
61
|
+
if (taker === undefined) {
|
|
62
|
+
queued.push(callback);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
taker(callback);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
let stopped = false;
|
|
69
|
+
const stop = () => {
|
|
70
|
+
if (stopped) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
stopped = true;
|
|
74
|
+
for (const [response, timer] of held) {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
answer(response, null, false);
|
|
77
|
+
}
|
|
78
|
+
held.clear();
|
|
79
|
+
server.close();
|
|
80
|
+
server.closeAllConnections();
|
|
81
|
+
};
|
|
82
|
+
// Before listening, an error means this CLI can't listen here; after,
|
|
83
|
+
// any error (an accept failure, say) stops the listener rather than
|
|
84
|
+
// the command.
|
|
85
|
+
let listening = false;
|
|
86
|
+
server.on("error", () => {
|
|
87
|
+
if (listening) {
|
|
88
|
+
stop();
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
resolve(null);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
server.listen(0, HOST, () => {
|
|
95
|
+
const address = server.address();
|
|
96
|
+
if (address === null || typeof address === "string") {
|
|
97
|
+
stop();
|
|
98
|
+
resolve(null);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
port = address.port;
|
|
102
|
+
listening = true;
|
|
103
|
+
resolve({
|
|
104
|
+
redirectUri: `http://${HOST}:${port}${CALLBACK_PATH}`,
|
|
105
|
+
next: () => {
|
|
106
|
+
const ready = queued.shift();
|
|
107
|
+
return ready === undefined ? new Promise((take) => waiting.push(take)) : Promise.resolve(ready);
|
|
108
|
+
},
|
|
109
|
+
close: stop,
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
// Only a GET for /callback, addressed to this very listener (a Host header
|
|
115
|
+
// naming anything else is a DNS-rebinding page, not Driggsby's redirect),
|
|
116
|
+
// carrying a code or Driggsby's denial. Everything else is ignored.
|
|
117
|
+
function readCallback(request, port) {
|
|
118
|
+
if (request.method !== "GET" || request.headers.host !== `${HOST}:${port}`) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
let url;
|
|
122
|
+
try {
|
|
123
|
+
url = new URL(request.url ?? "", `http://${HOST}:${port}`);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
const code = url.searchParams.get("code");
|
|
132
|
+
if (code !== null && CODE_PATTERN.test(code)) {
|
|
133
|
+
return { kind: "code", code };
|
|
134
|
+
}
|
|
135
|
+
return url.searchParams.get("error") === "access_denied" ? { kind: "denied" } : null;
|
|
136
|
+
}
|
|
137
|
+
function answer(response, landingUrl, refused) {
|
|
138
|
+
if (response.writableEnded) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (refused) {
|
|
142
|
+
response.writeHead(400, { ...HEADERS, "Content-Type": "text/plain; charset=utf-8" });
|
|
143
|
+
response.end("This sign-in callback wasn't accepted.\n");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (landingUrl === null) {
|
|
147
|
+
response.writeHead(200, { ...HEADERS, "Content-Type": "text/plain; charset=utf-8" });
|
|
148
|
+
response.end("You can close this tab and go back to your terminal.\n");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
response.writeHead(303, { ...HEADERS, Location: landingUrl });
|
|
152
|
+
response.end();
|
|
153
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The sign-in waiting for its code, kept at ~/.driggsby/pending-login.json
|
|
2
|
+
// (owner-only, like the saved token) so `driggsby login --code <CODE>` can
|
|
3
|
+
// finish it from another command: an agent without a terminal prompt runs
|
|
4
|
+
// login, the person approves in a browser elsewhere, and the agent finishes
|
|
5
|
+
// with the code the page showed. The verifier alone mints nothing, and a
|
|
6
|
+
// record is ignored once it expires.
|
|
7
|
+
//
|
|
8
|
+
// Only the command that started a sign-in removes its record. `login
|
|
9
|
+
// --code` marks it finished instead, so a login still waiting on the same
|
|
10
|
+
// claim can tell the sign-in finished there rather than failed.
|
|
11
|
+
import { readFile, rm } from "node:fs/promises";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { driggsbyDirectory, writeOwnerOnlyFile } from "../owner-only-file.js";
|
|
14
|
+
const PENDING_FILE_NAME = "pending-login.json";
|
|
15
|
+
export async function savePendingLogin(homeDirectory, pending) {
|
|
16
|
+
const body = {
|
|
17
|
+
base_url: pending.baseUrl,
|
|
18
|
+
claim_request_id: pending.claimRequestId,
|
|
19
|
+
code_verifier: pending.codeVerifier,
|
|
20
|
+
expires_at: pending.expiresAt,
|
|
21
|
+
finished: pending.finished,
|
|
22
|
+
};
|
|
23
|
+
await writeOwnerOnlyFile(homeDirectory, PENDING_FILE_NAME, `${JSON.stringify(body, null, 2)}\n`);
|
|
24
|
+
}
|
|
25
|
+
// null when nothing is waiting, the record is unreadable, or it expired.
|
|
26
|
+
export async function readPendingLogin(homeDirectory, now) {
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
parsed = JSON.parse(await readFile(pendingPath(homeDirectory), "utf8"));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const record = parsed;
|
|
38
|
+
const { base_url: baseUrl, claim_request_id: claimRequestId, code_verifier: codeVerifier } = record;
|
|
39
|
+
const expiresAt = record.expires_at;
|
|
40
|
+
if (typeof baseUrl !== "string" ||
|
|
41
|
+
typeof claimRequestId !== "string" ||
|
|
42
|
+
typeof codeVerifier !== "string" ||
|
|
43
|
+
typeof expiresAt !== "number" ||
|
|
44
|
+
expiresAt <= now) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return { baseUrl, claimRequestId, codeVerifier, expiresAt, finished: record.finished === true };
|
|
48
|
+
}
|
|
49
|
+
// Removes the record only while it is still this claim's: a newer sign-in
|
|
50
|
+
// may have replaced it.
|
|
51
|
+
export async function clearPendingLogin(homeDirectory, claimRequestId, now) {
|
|
52
|
+
const current = await readPendingLogin(homeDirectory, now);
|
|
53
|
+
if (current === null || current.claimRequestId === claimRequestId) {
|
|
54
|
+
await rm(pendingPath(homeDirectory), { force: true });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Marks the record finished only while it is still this claim's (a newer
|
|
58
|
+
// sign-in may have replaced it), and drops the spent verifier with it.
|
|
59
|
+
export async function markPendingLoginFinished(homeDirectory, claimRequestId, now) {
|
|
60
|
+
const current = await readPendingLogin(homeDirectory, now);
|
|
61
|
+
if (current?.claimRequestId !== claimRequestId) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
await savePendingLogin(homeDirectory, { ...current, codeVerifier: "", finished: true });
|
|
65
|
+
}
|
|
66
|
+
function pendingPath(homeDirectory) {
|
|
67
|
+
return join(driggsbyDirectory(homeDirectory), PENDING_FILE_NAME);
|
|
68
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// PKCE for driggsby login (RFC 7636, S256). The verifier never leaves this
|
|
2
|
+
// process except to trade the one-time code the approval page produced;
|
|
3
|
+
// Driggsby mints the token only for the code and the verifier together, so
|
|
4
|
+
// whoever else holds the approval link gets nothing from someone's approval.
|
|
5
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
6
|
+
export function createPkcePair() {
|
|
7
|
+
// 32 random bytes are 43 base64url characters: the RFC's minimum length
|
|
8
|
+
// and 256 bits of entropy.
|
|
9
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
10
|
+
return { verifier, challenge: challengeFor(verifier) };
|
|
11
|
+
}
|
|
12
|
+
export function challengeFor(verifier) {
|
|
13
|
+
return createHash("sha256").update(verifier, "ascii").digest("base64url");
|
|
14
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// A sleep that ends early, quietly, once its signal aborts, so a finished
|
|
2
|
+
// wait never holds the process open. Its abort listener goes when the
|
|
3
|
+
// timer fires too: a wait that sleeps every few seconds on one signal
|
|
4
|
+
// must not pile listeners onto it (Node warns past ten).
|
|
5
|
+
export function abortableSleep(ms, signal) {
|
|
6
|
+
return new Promise((resolve) => {
|
|
7
|
+
if (signal?.aborted === true) {
|
|
8
|
+
resolve();
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const onAbort = () => {
|
|
12
|
+
clearTimeout(timer);
|
|
13
|
+
resolve();
|
|
14
|
+
};
|
|
15
|
+
const timer = setTimeout(() => {
|
|
16
|
+
signal?.removeEventListener("abort", onAbort);
|
|
17
|
+
resolve();
|
|
18
|
+
}, ms);
|
|
19
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The code prompt for driggsby login on a real terminal: null when stdin or
|
|
2
|
+
// stdout isn't one (an agent running the command). One close listener
|
|
3
|
+
// settles whichever question is open, and closing mid-question ends its
|
|
4
|
+
// line, so what prints next starts on a fresh one. Ctrl-C at the prompt
|
|
5
|
+
// ends the command, as it does everywhere else.
|
|
6
|
+
import { createInterface } from "node:readline";
|
|
7
|
+
import {} from "./await-token.js";
|
|
8
|
+
export function terminalCodePrompt() {
|
|
9
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
const lines = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
13
|
+
let closed = false;
|
|
14
|
+
let open = null;
|
|
15
|
+
lines.on("close", () => {
|
|
16
|
+
closed = true;
|
|
17
|
+
const settle = open;
|
|
18
|
+
open = null;
|
|
19
|
+
settle?.(null);
|
|
20
|
+
});
|
|
21
|
+
lines.on("SIGINT", () => {
|
|
22
|
+
if (open !== null) {
|
|
23
|
+
process.stdout.write("\n");
|
|
24
|
+
}
|
|
25
|
+
lines.close();
|
|
26
|
+
process.kill(process.pid, "SIGINT");
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
ask: (question) => {
|
|
30
|
+
if (closed) {
|
|
31
|
+
return Promise.resolve(null);
|
|
32
|
+
}
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
open = resolve;
|
|
35
|
+
lines.question(question, (answer) => {
|
|
36
|
+
open = null;
|
|
37
|
+
resolve(answer);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
close: () => {
|
|
42
|
+
if (closed) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (open !== null) {
|
|
46
|
+
process.stdout.write("\n");
|
|
47
|
+
}
|
|
48
|
+
lines.close();
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -12,7 +12,7 @@ import { runRollback } from "./deploy/rollback-command.js";
|
|
|
12
12
|
import { runVersions } from "./deploy/versions-command.js";
|
|
13
13
|
import { runQuery } from "./query/query-command.js";
|
|
14
14
|
import { runRules } from "./rules/rules-command.js";
|
|
15
|
-
import { runLogin } from "./login/login.js";
|
|
15
|
+
import { runLogin, runLoginWithCode } from "./login/login.js";
|
|
16
16
|
import { runLogout } from "./login/logout.js";
|
|
17
17
|
import { runMcpSetup } from "./mcp-setup/setup.js";
|
|
18
18
|
import { VERSION } from "./version.js";
|
|
@@ -31,7 +31,7 @@ async function run(argv) {
|
|
|
31
31
|
await runMcpSetup({ client: command.client, print: command.print, scope: command.scope });
|
|
32
32
|
return 0;
|
|
33
33
|
case "login":
|
|
34
|
-
await runLogin();
|
|
34
|
+
await (command.code === null ? runLogin() : runLoginWithCode(command.code));
|
|
35
35
|
return 0;
|
|
36
36
|
case "logout":
|
|
37
37
|
return await runLogout();
|
|
@@ -19,9 +19,12 @@ function reportsMissingConfig(text) {
|
|
|
19
19
|
function matchesExpectedConfig(client, scope, text) {
|
|
20
20
|
switch (client) {
|
|
21
21
|
case "claude-code":
|
|
22
|
+
// An entry an earlier version gave device headers doesn't match:
|
|
23
|
+
// Claude Code doesn't send its sign-in on requests to it.
|
|
22
24
|
return (hasLine(text, "Type: http") &&
|
|
23
25
|
hasLine(text, `URL: ${DRIGGSBY_MCP_URL}`) &&
|
|
24
|
-
hasClaudeScope(text, scope ?? "user")
|
|
26
|
+
hasClaudeScope(text, scope ?? "user") &&
|
|
27
|
+
!text.includes("X-Driggsby-Device-"));
|
|
25
28
|
case "codex":
|
|
26
29
|
return (text.includes('"enabled": true') &&
|
|
27
30
|
text.includes('"type": "streamable_http"') &&
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import {} from "./known-client.js";
|
|
2
2
|
export const DRIGGSBY_MCP_URL = "https://app.driggsby.com/mcp";
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
// swallow it); Codex has no header option, so it never gets any. The
|
|
7
|
-
// commands printed for a person to run carry none.
|
|
8
|
-
export function buildInstallerCommand(client, scope, headers = []) {
|
|
3
|
+
// The plain entry, with no custom headers: Claude Code doesn't send its
|
|
4
|
+
// sign-in on requests to an entry that carries them.
|
|
5
|
+
export function buildInstallerCommand(client, scope) {
|
|
9
6
|
switch (client) {
|
|
10
7
|
case "claude-code":
|
|
11
8
|
return {
|
|
@@ -19,7 +16,6 @@ export function buildInstallerCommand(client, scope, headers = []) {
|
|
|
19
16
|
scope ?? "user",
|
|
20
17
|
"driggsby",
|
|
21
18
|
DRIGGSBY_MCP_URL,
|
|
22
|
-
...headers.flatMap((header) => ["--header", header]),
|
|
23
19
|
],
|
|
24
20
|
};
|
|
25
21
|
case "codex":
|
package/dist/mcp-setup/setup.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { createInterface } from "node:readline/promises";
|
|
2
2
|
import { CliError } from "../cli-error.js";
|
|
3
|
-
import { describeDevice, deviceHeaders } from "../device.js";
|
|
4
3
|
import { trimLikeRust } from "../terminal-text.js";
|
|
5
4
|
import { runClientCommand } from "./client-command.js";
|
|
6
5
|
import { classifyExistingMcpConfig } from "./classify.js";
|
|
@@ -25,11 +24,7 @@ export async function runMcpSetup(options) {
|
|
|
25
24
|
return;
|
|
26
25
|
}
|
|
27
26
|
write(`Adding Driggsby to ${displayName(client)} MCP config...\n`);
|
|
28
|
-
|
|
29
|
-
// commands it prints for a person stay the plain, portable ones.
|
|
30
|
-
const headers = installClient === "claude-code" ? deviceHeaders(describeDevice()) : [];
|
|
31
|
-
const runInstaller = buildInstallerCommand(installClient, options.scope, headers);
|
|
32
|
-
const result = await runClientCommand(runInstaller, streamConfigOutput(client));
|
|
27
|
+
const result = await runClientCommand(installer, streamConfigOutput(client));
|
|
33
28
|
switch (result.kind) {
|
|
34
29
|
case "output":
|
|
35
30
|
if (result.output.succeeded) {
|
|
@@ -5,9 +5,8 @@
|
|
|
5
5
|
// PATH + PATHEXT (never the current directory, which Windows shells would
|
|
6
6
|
// search first) and run shims through an explicit, absolute cmd.exe
|
|
7
7
|
// invocation with our own quoting. Real commands only ever carry fixed
|
|
8
|
-
// constant arguments (see commands.ts)
|
|
9
|
-
//
|
|
10
|
-
// user-controlled may ever flow into a spawn plan.
|
|
8
|
+
// constant arguments (see commands.ts); nothing user-controlled may ever
|
|
9
|
+
// flow into a spawn plan.
|
|
11
10
|
import { existsSync } from "node:fs";
|
|
12
11
|
import { isAbsolute, join } from "node:path";
|
|
13
12
|
import {} from "./commands.js";
|
|
@@ -81,9 +80,8 @@ export function resolveWindowsProgram(program, lookup = defaultLookup()) {
|
|
|
81
80
|
// (the cmd convention) and trailing backslashes doubled (so a path ending
|
|
82
81
|
// in \ cannot escape the closing quote for the child's own parser). The
|
|
83
82
|
// safe set covers every constant argument the CLI actually runs, including
|
|
84
|
-
// the MCP URL; only fixed constants
|
|
85
|
-
//
|
|
86
|
-
// characters) may reach this function.
|
|
83
|
+
// the MCP URL; only fixed constants and PATH-resolved program paths may
|
|
84
|
+
// reach this function.
|
|
87
85
|
export function quoteForCmd(value) {
|
|
88
86
|
if (/^[A-Za-z0-9_\-.:\\/=]+$/.test(value)) {
|
|
89
87
|
return value;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "driggsby",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Set up and build on Driggsby, the MCP server for personal finance.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"node": ">=18"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@driggsby/deploy": "0.
|
|
32
|
-
"@driggsby/sdk": "0.
|
|
31
|
+
"@driggsby/deploy": "0.7.0",
|
|
32
|
+
"@driggsby/sdk": "0.7.0"
|
|
33
33
|
}
|
|
34
34
|
}
|