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.
Files changed (50) hide show
  1. package/README.md +59 -0
  2. package/bin/driggsby.js +2 -0
  3. package/dist/api/base-url.js +31 -0
  4. package/dist/api/network-error.js +55 -0
  5. package/dist/args-deploy.js +94 -0
  6. package/dist/args-init.js +29 -0
  7. package/dist/args-mcp-setup.js +258 -0
  8. package/dist/args-shared.js +28 -0
  9. package/dist/args.js +177 -0
  10. package/dist/clap-suggestions.js +66 -0
  11. package/dist/cli-error.js +12 -0
  12. package/dist/credentials/clear-outcome.js +1 -0
  13. package/dist/credentials/file-store.js +77 -0
  14. package/dist/credentials/keychain.js +54 -0
  15. package/dist/credentials/secret-tool.js +48 -0
  16. package/dist/credentials/store.js +164 -0
  17. package/dist/credentials/tool-runner.js +55 -0
  18. package/dist/deploy/api-session.js +85 -0
  19. package/dist/deploy/deploy-command.js +126 -0
  20. package/dist/deploy/rollback-command.js +100 -0
  21. package/dist/deploy/versions-command.js +97 -0
  22. package/dist/dev/dev-command.js +106 -0
  23. package/dist/dev/dev-servers.js +274 -0
  24. package/dist/dev/host-page.js +147 -0
  25. package/dist/dev/mcp-broker.js +149 -0
  26. package/dist/dev/static-files.js +104 -0
  27. package/dist/dev/tool-allowlist.js +18 -0
  28. package/dist/dev/watcher.js +76 -0
  29. package/dist/help.js +180 -0
  30. package/dist/init/init-command.js +114 -0
  31. package/dist/init/templates.js +271 -0
  32. package/dist/login/claim-client.js +151 -0
  33. package/dist/login/login.js +146 -0
  34. package/dist/login/logout.js +37 -0
  35. package/dist/login/open-url.js +79 -0
  36. package/dist/main.js +57 -0
  37. package/dist/mcp-setup/classify.js +37 -0
  38. package/dist/mcp-setup/client-command.js +133 -0
  39. package/dist/mcp-setup/commands.js +53 -0
  40. package/dist/mcp-setup/known-client.js +38 -0
  41. package/dist/mcp-setup/setup.js +137 -0
  42. package/dist/mcp-setup/spawn-plan.js +91 -0
  43. package/dist/mcp-setup/text.js +83 -0
  44. package/dist/terminal-text.js +19 -0
  45. package/dist/version.js +6 -0
  46. package/package.json +22 -41
  47. package/bin/driggsby +0 -10
  48. package/install.js +0 -159
  49. package/lib/artifacts.js +0 -49
  50. package/lib/process.js +0 -65
@@ -0,0 +1,151 @@
1
+ // The app-token claim flow's HTTP client, against Driggsby's public
2
+ // app-token claim API: create a pending claim, send the user to claim_url,
3
+ // poll with the claim id + poll secret until the user approves. The app
4
+ // token is returned by exactly one poll; callers must never poll again after
5
+ // an approval.
6
+ import { CliError } from "../cli-error.js";
7
+ import { sanitizeForTerminal } from "../terminal-text.js";
8
+ const CLAIM_SCOPE = "driggsby.app_deploy";
9
+ const CLAIM_APP_NAME = "Driggsby CLI";
10
+ const REQUEST_TIMEOUT_MS = 15_000;
11
+ // The server names its own claim lifetime; this cap keeps a buggy or hostile
12
+ // expires_in from making the CLI wait (and poll) effectively forever.
13
+ const MAX_EXPIRES_IN_SECONDS = 1_800;
14
+ // Both endpoints answer with small JSON objects; anything enormous is not a
15
+ // real Driggsby response. The cap bounds what gets parsed and surfaced, not
16
+ // what the network buffers — the request timeout bounds that.
17
+ const MAX_RESPONSE_BODY_CHARS = 1_000_000;
18
+ // Server error_description fields are one or two sentences; a bound keeps a
19
+ // misbehaving origin from flooding the terminal through an error message.
20
+ const MAX_ERROR_DESCRIPTION_CHARS = 300;
21
+ export const SIGN_IN_START_FAILURE = "We weren't able to start a Driggsby sign-in just now. Please try again in\na minute.";
22
+ // Poll failures happen after the user may already have approved, so "start a
23
+ // sign-in" would be the wrong stage to describe.
24
+ const SIGN_IN_FINISH_FAILURE = "We weren't able to finish your Driggsby sign-in just now. Please try again\nin a minute.";
25
+ export async function createClaimRequest(baseUrl) {
26
+ const response = await fetch(`${baseUrl}/app-tokens/claim-requests`, {
27
+ method: "POST",
28
+ headers: { "Content-Type": "application/json" },
29
+ body: JSON.stringify({ app_name: CLAIM_APP_NAME, scope: CLAIM_SCOPE }),
30
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
31
+ // Never follow a redirect: a 307/308 would replay this claim flow —
32
+ // and, on the poll below, the poll secret — to whatever origin a
33
+ // Location header names. These endpoints never legitimately redirect.
34
+ redirect: "error",
35
+ });
36
+ const body = await readJsonBody(response);
37
+ if (response.status !== 201) {
38
+ // The server's error_description fields are written to be user-facing;
39
+ // errorDescription surfaces them sanitized and length-bounded.
40
+ throw new CliError(errorDescription(body) ?? SIGN_IN_START_FAILURE, 1);
41
+ }
42
+ const record = asRecord(body);
43
+ const claimRequestId = stringField(record, "claim_request_id");
44
+ const claimUrl = stringField(record, "claim_url");
45
+ const pollSecret = stringField(record, "poll_secret");
46
+ const expiresIn = record?.expires_in;
47
+ if (claimRequestId === null ||
48
+ claimUrl === null ||
49
+ pollSecret === null ||
50
+ typeof expiresIn !== "number" ||
51
+ !Number.isFinite(expiresIn) ||
52
+ expiresIn <= 0) {
53
+ throw new CliError(SIGN_IN_START_FAILURE, 1);
54
+ }
55
+ return {
56
+ claimRequestId,
57
+ claimUrl,
58
+ pollSecret,
59
+ expiresInSeconds: Math.min(expiresIn, MAX_EXPIRES_IN_SECONDS),
60
+ };
61
+ }
62
+ export async function pollClaimRequest(baseUrl, credentials) {
63
+ let response;
64
+ try {
65
+ response = await fetch(`${baseUrl}/app-tokens/claim-requests/poll`, {
66
+ method: "POST",
67
+ headers: { "Content-Type": "application/json" },
68
+ body: JSON.stringify({
69
+ claim_request_id: credentials.claimRequestId,
70
+ poll_secret: credentials.pollSecret,
71
+ }),
72
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
73
+ // Same as the create call: a redirect must never carry the poll secret
74
+ // (or accept a token from) another origin.
75
+ redirect: "error",
76
+ });
77
+ }
78
+ catch {
79
+ return { kind: "transient" };
80
+ }
81
+ const body = await readJsonBody(response);
82
+ const record = asRecord(body);
83
+ if (response.status === 404 || response.status === 410) {
84
+ // The claim no longer exists server-side — the same end state as an
85
+ // explicit "gone" answer; retrying the same claim can never succeed.
86
+ return { kind: "gone" };
87
+ }
88
+ if (response.status === 429) {
89
+ // Rate limiting from an edge layer is momentary; the poll loop's own
90
+ // interval is the backoff.
91
+ return { kind: "transient" };
92
+ }
93
+ if (response.status >= 400 && response.status < 500) {
94
+ // A 4xx (invalid_poll_request and friends) means our request is wrong —
95
+ // a bug, not something a retry can fix — so fail now instead of polling
96
+ // uselessly until the claim expires.
97
+ throw new CliError(errorDescription(body) ?? SIGN_IN_FINISH_FAILURE, 1);
98
+ }
99
+ if (response.status !== 200) {
100
+ return { kind: "transient" };
101
+ }
102
+ switch (record?.status) {
103
+ case "pending":
104
+ return { kind: "pending" };
105
+ case "approved": {
106
+ const appToken = stringField(record, "app_token");
107
+ if (appToken === null) {
108
+ throw new CliError(SIGN_IN_FINISH_FAILURE, 1);
109
+ }
110
+ return { kind: "approved", appToken };
111
+ }
112
+ case "gone":
113
+ return { kind: "gone" };
114
+ default:
115
+ return { kind: "transient" };
116
+ }
117
+ }
118
+ async function readJsonBody(response) {
119
+ let text;
120
+ try {
121
+ text = await response.text();
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ if (text.length > MAX_RESPONSE_BODY_CHARS) {
127
+ return null;
128
+ }
129
+ try {
130
+ return JSON.parse(text);
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ }
136
+ function asRecord(value) {
137
+ return typeof value === "object" && value !== null ? value : null;
138
+ }
139
+ function stringField(record, field) {
140
+ const value = record?.[field];
141
+ return typeof value === "string" && value !== "" ? value : null;
142
+ }
143
+ // The server's user-facing error text, stripped of terminal control bytes
144
+ // like every other string this CLI did not author, and length-bounded.
145
+ function errorDescription(body) {
146
+ const raw = stringField(asRecord(body), "error_description");
147
+ if (raw === null) {
148
+ return null;
149
+ }
150
+ return sanitizeForTerminal(raw.slice(0, MAX_ERROR_DESCRIPTION_CHARS));
151
+ }
@@ -0,0 +1,146 @@
1
+ // driggsby login: create an app-token claim, send the user to the approval
2
+ // page, poll until they approve, and store the token. The token is shown to
3
+ // no one — it goes straight from the poll response into the credential
4
+ // store.
5
+ import { apiBaseUrl, apiHost } from "../api/base-url.js";
6
+ import { blockedNetworkError } from "../api/network-error.js";
7
+ import { CliError } from "../cli-error.js";
8
+ import { defaultCredentialEnvironment, describeStorageLocation, environmentToken, saveToken, } from "../credentials/store.js";
9
+ import { sanitizeForTerminal, wrapProse } from "../terminal-text.js";
10
+ import { createClaimRequest, pollClaimRequest, SIGN_IN_START_FAILURE, } from "./claim-client.js";
11
+ import { tryOpenUrl } from "./open-url.js";
12
+ const LOGIN_RETRY_COMMAND = "npx driggsby@latest login";
13
+ const DEFAULT_POLL_INTERVAL_MS = 4_000;
14
+ function defaultLoginIo() {
15
+ return {
16
+ out: (text) => {
17
+ process.stdout.write(text);
18
+ },
19
+ openUrl: tryOpenUrl,
20
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
21
+ now: () => Date.now(),
22
+ pollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
23
+ };
24
+ }
25
+ export async function runLogin(environment = defaultCredentialEnvironment(), io = defaultLoginIo()) {
26
+ const baseUrl = apiBaseUrl(environment.env);
27
+ io.out("Sign in to Driggsby\n\n");
28
+ const claim = await createClaim(baseUrl);
29
+ const claimUrl = approvedClaimUrl(claim.claimUrl, baseUrl);
30
+ const browserOpened = await io.openUrl(claimUrl);
31
+ io.out(browserOpened
32
+ ? "Your browser should open a Driggsby approval page. If it doesn't, open\nthis link:\n\n"
33
+ : "Open this link in your browser to approve access for this machine:\n\n");
34
+ io.out(` ${claimUrl}\n\n`);
35
+ const minutes = approximateMinutes(claim.expiresInSeconds);
36
+ const minutesWord = minutes === 1 ? "minute" : "minutes";
37
+ io.out(`Waiting for your approval (the link is good for about ${minutes} ${minutesWord})...\n`);
38
+ const appToken = await pollUntilApproved(baseUrl, claim, io);
39
+ // Warnings are held back and printed after the "Approved." line — and when
40
+ // one fires, it replaces the all-clear closer: the last thing the user
41
+ // reads must never contradict a warning they were just shown.
42
+ const warnings = [];
43
+ const storedIn = await storeApprovedToken(appToken, environment, (text) => warnings.push(text));
44
+ const envTokenWarning = shadowingEnvTokenWarning(environment);
45
+ if (envTokenWarning !== null) {
46
+ warnings.push(envTokenWarning);
47
+ }
48
+ io.out(`\n${wrapProse(`Approved. Your Driggsby app token is now saved in ${describeStorageLocation(storedIn)}.`)}\n`);
49
+ if (warnings.length > 0) {
50
+ io.out(`\n${warnings.join("\n")}`);
51
+ return;
52
+ }
53
+ 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");
54
+ }
55
+ // The one-time token is already consumed by the time saving starts, so a
56
+ // storage failure must say clearly that a fresh sign-in is needed — never
57
+ // the generic "something went wrong".
58
+ async function storeApprovedToken(appToken, environment, warn) {
59
+ try {
60
+ return await saveToken(appToken, environment, warn);
61
+ }
62
+ catch (error) {
63
+ if (error instanceof CliError) {
64
+ throw error;
65
+ }
66
+ throw new CliError("Your sign-in was approved, but we couldn't save the Driggsby app token\n" +
67
+ `on this machine. Please sign in again:\n ${LOGIN_RETRY_COMMAND}`, 1);
68
+ }
69
+ }
70
+ async function createClaim(baseUrl) {
71
+ try {
72
+ return await createClaimRequest(baseUrl);
73
+ }
74
+ catch (error) {
75
+ const blocked = blockedNetworkError(error, {
76
+ host: apiHost(baseUrl),
77
+ retryCommand: LOGIN_RETRY_COMMAND,
78
+ });
79
+ if (blocked !== null) {
80
+ throw blocked;
81
+ }
82
+ if (error instanceof CliError) {
83
+ throw error;
84
+ }
85
+ throw new CliError(SIGN_IN_START_FAILURE, 1);
86
+ }
87
+ }
88
+ // The approval page must live on the origin the CLI is talking to. Anything
89
+ // else means a broken or untrustworthy response, and the CLI refuses to
90
+ // print or open it. What comes back is the PARSER'S normalized href with
91
+ // terminal control bytes stripped — the raw server string is never printed
92
+ // or handed to the browser opener.
93
+ function approvedClaimUrl(rawClaimUrl, baseUrl) {
94
+ let parsed;
95
+ try {
96
+ parsed = new URL(rawClaimUrl);
97
+ }
98
+ catch {
99
+ throw new CliError(SIGN_IN_START_FAILURE, 1);
100
+ }
101
+ if (parsed.origin !== new URL(baseUrl).origin) {
102
+ throw new CliError(SIGN_IN_START_FAILURE, 1);
103
+ }
104
+ // URL.origin ignores userinfo, so user:pass@ would pass the origin check
105
+ // while confusing the printed link. Claim URLs never carry credentials.
106
+ if (parsed.username !== "" || parsed.password !== "") {
107
+ throw new CliError(SIGN_IN_START_FAILURE, 1);
108
+ }
109
+ return sanitizeForTerminal(parsed.href);
110
+ }
111
+ async function pollUntilApproved(baseUrl, claim, io) {
112
+ const deadline = io.now() + claim.expiresInSeconds * 1_000;
113
+ const credentials = { claimRequestId: claim.claimRequestId, pollSecret: claim.pollSecret };
114
+ while (io.now() < deadline) {
115
+ await io.sleep(io.pollIntervalMs);
116
+ const result = await pollClaimRequest(baseUrl, credentials);
117
+ switch (result.kind) {
118
+ case "approved":
119
+ // The token is delivered exactly once; from here on, never poll again.
120
+ return result.appToken;
121
+ case "gone":
122
+ throw new CliError("This sign-in link is no longer active — it may have been declined or timed out.\n\n" +
123
+ `Start a fresh sign-in:\n ${LOGIN_RETRY_COMMAND}`, 1);
124
+ case "pending":
125
+ case "transient":
126
+ break;
127
+ }
128
+ }
129
+ throw new CliError("The sign-in link expired before it was approved.\n\n" +
130
+ `Start a fresh sign-in:\n ${LOGIN_RETRY_COMMAND}`, 1);
131
+ }
132
+ // DRIGGSBY_TOKEN outranks every saved token, so a login that saved one has
133
+ // not actually changed which token commands use. The warning joins the same
134
+ // held-back channel as storage warnings: after the "Approved." line, in
135
+ // place of the all-clear closer.
136
+ function shadowingEnvTokenWarning(environment) {
137
+ if (environmentToken(environment) === null) {
138
+ return null;
139
+ }
140
+ return ("Note: DRIGGSBY_TOKEN is set in this environment, and driggsby commands\n" +
141
+ "use it instead of the token saved just now. Unset DRIGGSBY_TOKEN to\n" +
142
+ "switch to the saved token.\n");
143
+ }
144
+ function approximateMinutes(seconds) {
145
+ return Math.max(1, Math.round(seconds / 60));
146
+ }
@@ -0,0 +1,37 @@
1
+ // driggsby logout: remove the app token that login saved, everywhere it
2
+ // could live on this machine. A removal that fails is reported as a failure,
3
+ // never as "nothing to remove" — the token may still work, and the user must
4
+ // know that.
5
+ import { clearStoredToken, defaultCredentialEnvironment, describeStorageLocation, } from "../credentials/store.js";
6
+ import { wrapProse } from "../terminal-text.js";
7
+ function writeToStdout(text) {
8
+ process.stdout.write(text);
9
+ }
10
+ export async function runLogout(environment = defaultCredentialEnvironment(), out = writeToStdout) {
11
+ const result = await clearStoredToken(environment);
12
+ const cleared = result.clearedSources.map(describeStorageLocation).join(" and ");
13
+ const failed = result.failedSources.map(describeStorageLocation).join(" and ");
14
+ if (result.failedSources.length > 0) {
15
+ if (result.clearedSources.length > 0) {
16
+ out(`${wrapProse(`Your Driggsby app token was removed from ${cleared}.`)}\n\n`);
17
+ }
18
+ const keychainHint = result.failedSources.includes("keychain")
19
+ ? " If your macOS keychain is locked, unlock it first."
20
+ : result.failedSources.includes("keyring")
21
+ ? " If your system keyring is locked or unreachable, unlock it first."
22
+ : "";
23
+ const failure = `We weren't able to remove your Driggsby app token from ${failed}, ` +
24
+ `so it may still be saved there.${keychainHint}`;
25
+ out(`${wrapProse(failure)}\n\nPlease try again:\n npx driggsby@latest logout\n`);
26
+ }
27
+ else if (result.clearedSources.length === 0) {
28
+ out("No saved Driggsby app token was found on this machine, so there was\nnothing to remove.\n");
29
+ }
30
+ else {
31
+ out(`${wrapProse(`Signed out. Your Driggsby app token was removed from ${cleared}.`)}\n`);
32
+ }
33
+ if (result.envTokenStillSet) {
34
+ out("\nNote: DRIGGSBY_TOKEN is set in this environment and still works. Unset it\nto finish signing out here.\n");
35
+ }
36
+ return result.failedSources.length > 0 ? 1 : 0;
37
+ }
@@ -0,0 +1,79 @@
1
+ // Best-effort browser opening for the consent URL. Failure is always fine —
2
+ // the caller prints the URL either way — so nothing here throws to the user.
3
+ // Only https URLs (plus http on loopback, for the local-server dev override)
4
+ // are ever handed to a platform opener, and the URL is the sole,
5
+ // pre-validated argument: no shell interprets it on POSIX, and the Windows
6
+ // cmd.exe path quotes it with the same rules as the MCP setup spawns.
7
+ import { spawn } from "node:child_process";
8
+ import { win32 } from "node:path";
9
+ import { quoteForCmd } from "../mcp-setup/spawn-plan.js";
10
+ export function openUrlSpawnPlan(url, platform = process.platform, systemRoot = process.env.SystemRoot ?? "C:\\Windows") {
11
+ if (!isOpenableUrl(url)) {
12
+ return null;
13
+ }
14
+ if (platform === "darwin") {
15
+ // Absolute path, like /usr/bin/security in the keychain backend: `open`
16
+ // is a fixed system binary and must not be resolvable off a hijacked
17
+ // PATH. xdg-open below stays PATH-resolved — its location legitimately
18
+ // varies across Linux distributions.
19
+ return { program: "/usr/bin/open", args: [url], windowsVerbatimArguments: false };
20
+ }
21
+ if (platform === "win32") {
22
+ // `start` is a cmd.exe builtin; its first quoted argument is the window
23
+ // title, so an empty "" keeps the URL from being eaten as the title.
24
+ const commandLine = ["start", '""', quoteForCmd(url)].join(" ");
25
+ return {
26
+ program: win32.join(systemRoot, "System32", "cmd.exe"),
27
+ args: ["/d", "/s", "/c", `"${commandLine}"`],
28
+ windowsVerbatimArguments: true,
29
+ };
30
+ }
31
+ return { program: "xdg-open", args: [url], windowsVerbatimArguments: false };
32
+ }
33
+ // The plain RFC 3986 character set, with `%` and `!` deliberately excluded:
34
+ // cmd.exe expands %VAR% even inside quoted arguments, and expands !VAR! the
35
+ // same way on machines with delayed expansion enabled, so a URL carrying
36
+ // either could pull environment values into the URL the Windows opener
37
+ // launches. Claim URLs never contain them; a URL that does is simply not
38
+ // opened (the caller prints the link instead), on every platform for
39
+ // consistency.
40
+ const OPENABLE_URL_CHARACTERS = /^[A-Za-z0-9\-._~:/?#[\]@$&'()*+,;=]*$/;
41
+ function isOpenableUrl(url) {
42
+ if (!OPENABLE_URL_CHARACTERS.test(url)) {
43
+ return false;
44
+ }
45
+ let parsed;
46
+ try {
47
+ parsed = new URL(url);
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ if (parsed.protocol === "https:") {
53
+ return true;
54
+ }
55
+ return (parsed.protocol === "http:" &&
56
+ (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1"));
57
+ }
58
+ // Resolves true once the opener process starts; the page itself may still
59
+ // fail to open, which is why callers always print the URL too.
60
+ export function tryOpenUrl(url, platform = process.platform) {
61
+ const plan = openUrlSpawnPlan(url, platform);
62
+ if (plan === null) {
63
+ return Promise.resolve(false);
64
+ }
65
+ return new Promise((resolve) => {
66
+ const child = spawn(plan.program, plan.args, {
67
+ stdio: "ignore",
68
+ detached: platform !== "win32",
69
+ windowsVerbatimArguments: plan.windowsVerbatimArguments,
70
+ });
71
+ child.on("error", () => {
72
+ resolve(false);
73
+ });
74
+ child.on("spawn", () => {
75
+ child.unref();
76
+ resolve(true);
77
+ });
78
+ });
79
+ }
package/dist/main.js ADDED
@@ -0,0 +1,57 @@
1
+ // CLI entry point. The bin stub (bin/driggsby.js) imports this module; it is
2
+ // never imported as a library.
3
+ import { parseArgv } from "./args.js";
4
+ import { CliError } from "./cli-error.js";
5
+ import { runDeploy } from "./deploy/deploy-command.js";
6
+ import { runDev } from "./dev/dev-command.js";
7
+ import { runInit } from "./init/init-command.js";
8
+ import { runRollback } from "./deploy/rollback-command.js";
9
+ import { runVersions } from "./deploy/versions-command.js";
10
+ import { runLogin } from "./login/login.js";
11
+ import { runLogout } from "./login/logout.js";
12
+ import { runMcpSetup } from "./mcp-setup/setup.js";
13
+ import { VERSION } from "./version.js";
14
+ async function run(argv) {
15
+ const command = parseArgv(argv);
16
+ switch (command.kind) {
17
+ case "print-help": {
18
+ const stream = command.stream === "stdout" ? process.stdout : process.stderr;
19
+ stream.write(command.text);
20
+ return command.exitCode;
21
+ }
22
+ case "print-version":
23
+ process.stdout.write(`driggsby ${VERSION}\n`);
24
+ return 0;
25
+ case "mcp-setup":
26
+ await runMcpSetup({ client: command.client, print: command.print, scope: command.scope });
27
+ return 0;
28
+ case "login":
29
+ await runLogin();
30
+ return 0;
31
+ case "logout":
32
+ return await runLogout();
33
+ case "init":
34
+ return await runInit({ slug: command.slug });
35
+ case "dev":
36
+ return await runDev();
37
+ case "deploy":
38
+ return await runDeploy({ preview: command.preview });
39
+ case "rollback":
40
+ return await runRollback({ toVersion: command.toVersion });
41
+ case "versions":
42
+ return await runVersions({});
43
+ }
44
+ }
45
+ try {
46
+ process.exitCode = await run(process.argv.slice(2));
47
+ }
48
+ catch (error) {
49
+ if (error instanceof CliError) {
50
+ process.stderr.write(`${error.message}\n`);
51
+ process.exitCode = error.exitCode;
52
+ }
53
+ else {
54
+ process.stderr.write("Something went wrong running driggsby. Please try again.\n");
55
+ process.exitCode = 1;
56
+ }
57
+ }
@@ -0,0 +1,37 @@
1
+ import { DRIGGSBY_MCP_URL } from "./commands.js";
2
+ import {} from "./known-client.js";
3
+ // Classifies what `claude mcp get driggsby` / `codex mcp get driggsby --json`
4
+ // reported so setup can be idempotent: an entry that already matches is left
5
+ // alone, a different entry gets remove+add remediation, and anything
6
+ // unreadable is treated as unknown so setup just proceeds.
7
+ export function classifyExistingMcpConfig(client, scope, output) {
8
+ const text = `${output.stdout}\n${output.stderr}`;
9
+ if (!output.succeeded) {
10
+ return reportsMissingConfig(text) ? "missing" : "unknown";
11
+ }
12
+ return matchesExpectedConfig(client, scope, text) ? "matches" : "differs";
13
+ }
14
+ function reportsMissingConfig(text) {
15
+ return (text.includes("No MCP server found") ||
16
+ text.includes("No MCP server named 'driggsby' found") ||
17
+ text.includes("No MCP server found with name"));
18
+ }
19
+ function matchesExpectedConfig(client, scope, text) {
20
+ switch (client) {
21
+ case "claude-code":
22
+ return (hasLine(text, "Type: http") &&
23
+ hasLine(text, `URL: ${DRIGGSBY_MCP_URL}`) &&
24
+ hasClaudeScope(text, scope ?? "user"));
25
+ case "codex":
26
+ return (text.includes('"enabled": true') &&
27
+ text.includes('"type": "streamable_http"') &&
28
+ text.includes(`"url": "${DRIGGSBY_MCP_URL}"`));
29
+ }
30
+ }
31
+ function hasClaudeScope(text, scope) {
32
+ const expected = scope === "local" ? "Scope: Local config" : "Scope: User config";
33
+ return text.split("\n").some((line) => line.trim().startsWith(expected));
34
+ }
35
+ function hasLine(text, expected) {
36
+ return text.split("\n").some((line) => line.trim() === expected);
37
+ }
@@ -0,0 +1,133 @@
1
+ import { spawn } from "node:child_process";
2
+ import {} from "./classify.js";
3
+ import {} from "./commands.js";
4
+ import { planSpawn } from "./spawn-plan.js";
5
+ const CLIENT_CONFIG_COMMAND_TIMEOUT_MS = 30_000;
6
+ // After the child exits, wait at most this long for its output pipes to
7
+ // close: a backgrounded grandchild that inherited the pipes must not be able
8
+ // to hang the CLI.
9
+ const OUTPUT_DRAIN_GRACE_MS = 250;
10
+ const LOOPBACK_REDIRECT_NEEDLE = "redirect_uri=http%3A%2F%2F127.0.0.1%3A";
11
+ const BROWSER_LAUNCH_FAILED_NEEDLE = "Browser launch failed";
12
+ const REMOTE_SIGN_IN_HINT_RECENT_CHARS = 512;
13
+ export const REMOTE_SIGN_IN_HINT = `
14
+ Remote sign-in note:
15
+ The URL above redirects to 127.0.0.1 on this machine.
16
+ If you opened this terminal over SSH, use SSH local port forwarding before
17
+ opening the URL in your local browser.
18
+
19
+ If sign-in times out, run:
20
+ codex mcp login driggsby
21
+ `;
22
+ // Watches streamed Codex output for the signature of a headless/SSH session
23
+ // (loopback OAuth redirect plus a failed browser launch) and fires exactly
24
+ // once, over a rolling window so the needles can span chunk boundaries.
25
+ export class RemoteSignInHintState {
26
+ #recentOutput = "";
27
+ #sawLoopbackRedirect = false;
28
+ #sawBrowserLaunchFailed = false;
29
+ #printed = false;
30
+ observe(text) {
31
+ this.#recentOutput += text;
32
+ if (this.#recentOutput.includes(LOOPBACK_REDIRECT_NEEDLE)) {
33
+ this.#sawLoopbackRedirect = true;
34
+ }
35
+ if (this.#recentOutput.includes(BROWSER_LAUNCH_FAILED_NEEDLE)) {
36
+ this.#sawBrowserLaunchFailed = true;
37
+ }
38
+ if (this.#recentOutput.length > REMOTE_SIGN_IN_HINT_RECENT_CHARS) {
39
+ this.#recentOutput = this.#recentOutput.slice(-REMOTE_SIGN_IN_HINT_RECENT_CHARS);
40
+ }
41
+ if (this.#printed || !this.#sawLoopbackRedirect || !this.#sawBrowserLaunchFailed) {
42
+ return false;
43
+ }
44
+ this.#printed = true;
45
+ return true;
46
+ }
47
+ }
48
+ // Runs a client CLI command (claude/codex) with a hard timeout. When
49
+ // `streamOutput` is set (Codex, whose OAuth happens in-band) the child's
50
+ // output is echoed live to our own stdout/stderr while still being captured.
51
+ export function runClientCommand(command, streamOutput, timeoutMs = CLIENT_CONFIG_COMMAND_TIMEOUT_MS) {
52
+ return new Promise((resolve) => {
53
+ // No shell anywhere: spawn-plan.ts resolves Windows .cmd shims through an
54
+ // explicit cmd.exe invocation and reports a missing program itself
55
+ // (PATH-only lookup — the current directory is never searched).
56
+ const plan = planSpawn(command);
57
+ if (plan === null) {
58
+ resolve({ kind: "not-found" });
59
+ return;
60
+ }
61
+ const child = spawn(plan.program, plan.args, {
62
+ stdio: ["ignore", "pipe", "pipe"],
63
+ windowsVerbatimArguments: plan.windowsVerbatimArguments,
64
+ });
65
+ let settled = false;
66
+ const settle = (result) => {
67
+ if (settled)
68
+ return;
69
+ settled = true;
70
+ clearTimeout(timer);
71
+ // Tear down our ends of the pipes and drop the child from the event
72
+ // loop: a backgrounded grandchild that inherited the pipes must not be
73
+ // able to keep this process alive after the result is decided.
74
+ child.stdout.destroy();
75
+ child.stderr.destroy();
76
+ child.unref();
77
+ resolve(result);
78
+ };
79
+ const hint = streamOutput ? new RemoteSignInHintState() : null;
80
+ const stdoutChunks = [];
81
+ const stderrChunks = [];
82
+ const stdoutDone = collectStream(child.stdout, stdoutChunks, streamOutput ? "stdout" : null, hint);
83
+ const stderrDone = collectStream(child.stderr, stderrChunks, streamOutput ? "stderr" : null, hint);
84
+ const timer = setTimeout(() => {
85
+ child.kill("SIGKILL");
86
+ settle({ kind: "timed-out" });
87
+ }, timeoutMs);
88
+ timer.unref();
89
+ child.once("error", (error) => {
90
+ settle(error.code === "ENOENT" ? { kind: "not-found" } : { kind: "spawn-error" });
91
+ });
92
+ child.once("exit", (code) => {
93
+ void (async () => {
94
+ // Race the pipes closing against the drain grace, and cancel the
95
+ // grace timer as soon as the race settles so it can't hold the
96
+ // process (or add dead wait) after a fast, clean exit.
97
+ let drainTimer;
98
+ const drainGrace = new Promise((resolveGrace) => {
99
+ drainTimer = setTimeout(resolveGrace, OUTPUT_DRAIN_GRACE_MS);
100
+ });
101
+ await Promise.race([Promise.all([stdoutDone, stderrDone]), drainGrace]);
102
+ clearTimeout(drainTimer);
103
+ const stdout = Buffer.concat(stdoutChunks).toString("utf8");
104
+ const stderr = Buffer.concat(stderrChunks).toString("utf8");
105
+ settle({ kind: "output", output: { succeeded: code === 0, stdout, stderr } });
106
+ })();
107
+ });
108
+ });
109
+ }
110
+ function collectStream(stream, chunks, echoTo, hint) {
111
+ if (stream === null) {
112
+ return Promise.resolve();
113
+ }
114
+ return new Promise((resolve) => {
115
+ stream.on("data", (chunk) => {
116
+ chunks.push(chunk);
117
+ if (echoTo !== null) {
118
+ (echoTo === "stdout" ? process.stdout : process.stderr).write(chunk);
119
+ }
120
+ if (hint?.observe(chunk.toString("utf8")) === true) {
121
+ process.stdout.write(REMOTE_SIGN_IN_HINT);
122
+ }
123
+ });
124
+ stream.once("close", () => {
125
+ resolve();
126
+ });
127
+ // A torn-down pipe (e.g. the SIGKILL timeout path on Windows) must end
128
+ // the collection quietly, never surface as an uncaught stream error.
129
+ stream.on("error", () => {
130
+ resolve();
131
+ });
132
+ });
133
+ }