driggsby 0.1.42 → 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,85 @@
1
+ // Shared plumbing for the deploy command family: resolve the saved app token
2
+ // and map every failure to one clear, fix-naming message. The deploy package
3
+ // signs requests; this layer decides what a person or agent reads when
4
+ // something goes wrong.
5
+ import { DeployApiError, DeployError } from "@driggsby/deploy";
6
+ import { apiBaseUrl, apiHost, baseUrlMayReceiveSavedSignIn } from "../api/base-url.js";
7
+ import { blockedNetworkError } from "../api/network-error.js";
8
+ import { CliError } from "../cli-error.js";
9
+ import { readStoredToken, } from "../credentials/store.js";
10
+ import { capForTerminal, wrapProse } from "../terminal-text.js";
11
+ const LOGIN_COMMAND = "npx driggsby@latest login";
12
+ const MAX_ERROR_DESCRIPTION_CHARS = 300;
13
+ // Server-supplied display strings go through capForTerminal (sanitize plus
14
+ // a hard length bound) with bounds sized to each string's real contract so
15
+ // a legitimate value is never truncated: a slug tops out at 63 characters,
16
+ // while the app URL wraps the slug in a scheme and domain
17
+ // (https://<slug>.driggsby.dev, up to 84 characters). Anything longer is a
18
+ // server bug and gets cut rather than allowed to garble the output.
19
+ export const MAX_SERVER_TEXT_CHARS = 80;
20
+ export const MAX_SERVER_URL_CHARS = 200;
21
+ export async function requireDeploySession(environment) {
22
+ const baseUrl = apiBaseUrl(environment.env);
23
+ const stored = await readStoredToken(environment);
24
+ if (stored === null) {
25
+ throw new CliError("You're not signed in on this machine yet. Sign in first — it takes\n" +
26
+ `about a minute:\n ${LOGIN_COMMAND}`, 1);
27
+ }
28
+ // The saved sign-in unlocks this person's financial data, so it only ever
29
+ // travels to Driggsby itself (plus loopback for local development). A
30
+ // DRIGGSBY_BASE_URL pointing anywhere else must bring its own token via
31
+ // DRIGGSBY_TOKEN (source "env") — without this pin, setting that one
32
+ // environment variable would redirect the saved credential to an
33
+ // arbitrary host.
34
+ if (stored.source !== "env" && !baseUrlMayReceiveSavedSignIn(baseUrl)) {
35
+ throw new CliError(wrapProse("Your saved Driggsby sign-in is only ever sent to Driggsby itself (or a local dev server on this machine), and DRIGGSBY_BASE_URL points somewhere else. Unset DRIGGSBY_BASE_URL, or set DRIGGSBY_TOKEN to a token that belongs to that server."), 1);
36
+ }
37
+ return { baseUrl, token: stored.token };
38
+ }
39
+ // Turns any failure from a deploy-family command into the CliError the user
40
+ // sees. `retryCommand` names the exact command to run again.
41
+ export function deployFailure(error, baseUrl, retryCommand) {
42
+ if (error instanceof CliError) {
43
+ return error;
44
+ }
45
+ // A DeployApiError is a real HTTP response from the server, never a
46
+ // connection failure — and its `code` is a server-supplied string, so it
47
+ // could collide with an errno name like ECONNRESET and turn a genuine
48
+ // refusal into firewall advice if blockedNetworkError saw it first.
49
+ if (error instanceof DeployApiError) {
50
+ return apiFailure(error, retryCommand);
51
+ }
52
+ if (error instanceof DeployError) {
53
+ return new CliError(error.message, 1);
54
+ }
55
+ const blocked = blockedNetworkError(error, { host: apiHost(baseUrl), retryCommand });
56
+ if (blocked !== null) {
57
+ return blocked;
58
+ }
59
+ return new CliError(`We weren't able to finish this just now. Please try again:\n ${retryCommand}`, 1);
60
+ }
61
+ function apiFailure(error, retryCommand) {
62
+ // A 503's server description talks to the protocol client ("retry the same
63
+ // PUT"), so it gets CLI-authored copy naming the actual re-run instead.
64
+ // The copy stays neutral about what failed: a 503 can come from any
65
+ // endpoint, including ones that upload nothing.
66
+ if (error.status === 503) {
67
+ return new CliError(`Driggsby is briefly unavailable just now. Please try again:\n ${retryCommand}`, 1);
68
+ }
69
+ if (error.status === 401) {
70
+ return new CliError("Your sign-in on this machine isn't valid anymore. Sign in again:\n" +
71
+ ` ${LOGIN_COMMAND}`, 1);
72
+ }
73
+ if (error.code === "deploy_scope_required") {
74
+ return new CliError("Your saved sign-in can read your Driggsby data but wasn't approved for\n" +
75
+ `deploys. Sign in again and approve deploy access:\n ${LOGIN_COMMAND}`, 1);
76
+ }
77
+ // The server's error_description gets the same treatment as every other
78
+ // string this CLI did not author: stripped of terminal control bytes and
79
+ // length-bounded before it reaches the terminal.
80
+ const description = capForTerminal(error.message, MAX_ERROR_DESCRIPTION_CHARS).trim();
81
+ if (description === "") {
82
+ return new CliError("The Driggsby deploy API refused this request without saying why. Please\ntry again in a minute.", 1);
83
+ }
84
+ return new CliError(wrapProse(description), 1);
85
+ }
@@ -0,0 +1,126 @@
1
+ // driggsby deploy: read driggsby.json, hash the serve folder, upload what
2
+ // Driggsby doesn't have, and make the new version live (or hold it as a
3
+ // ready-but-not-live version with --preview).
4
+ import { collectDeployFiles, deployProjectFiles, formatBytes, readProjectConfig, } from "@driggsby/deploy";
5
+ import { apiBaseUrl } from "../api/base-url.js";
6
+ import { defaultCredentialEnvironment, } from "../credentials/store.js";
7
+ import { capForTerminal, quotedForTerminal, wrapProse } from "../terminal-text.js";
8
+ import { deployFailure, MAX_SERVER_TEXT_CHARS, MAX_SERVER_URL_CHARS, requireDeploySession, } from "./api-session.js";
9
+ const DEPLOY_RETRY_COMMAND = "npx driggsby@latest deploy";
10
+ function defaultDeployIo() {
11
+ return {
12
+ out: (text) => {
13
+ process.stdout.write(text);
14
+ },
15
+ now: () => Date.now(),
16
+ };
17
+ }
18
+ export async function runDeploy(options, environment = defaultCredentialEnvironment(), io = defaultDeployIo()) {
19
+ const projectDirectory = options.projectDirectory ?? process.cwd();
20
+ // Resolved before the try so an invalid DRIGGSBY_BASE_URL reports its own
21
+ // message instead of being re-wrapped as a deploy failure.
22
+ const baseUrl = apiBaseUrl(environment.env);
23
+ try {
24
+ const config = await readProjectConfig(projectDirectory);
25
+ const session = await requireDeploySession(environment);
26
+ // The serve value is arbitrary text from driggsby.json (a cloned or
27
+ // agent-generated file), so it prints through quotedForTerminal —
28
+ // sanitized, length-bounded, and inside quotes the value can't break out
29
+ // of, so it reads as a name, never as the CLI's own sentence. The slug
30
+ // needs no quotes: readProjectConfig already validated it against the
31
+ // strict slug rules. The whole line is wrapped because a maximum-length
32
+ // slug pushes it past 80 columns.
33
+ io.out(`${wrapProse(`Deploying ${config.slug} from ${quotedForTerminal(config.serve, MAX_SERVER_TEXT_CHARS)}${options.preview ? " (preview)" : ""}`)}\n\n`);
34
+ const collected = await collectDeployFiles(config.serveDirectory);
35
+ io.out(`${hashedLine(collected.files.length)}\n`);
36
+ const startedAt = io.now();
37
+ const { outcome, createdApp } = await deployProjectFiles({ baseUrl: session.baseUrl, token: session.token }, projectDirectory, config.slug, collected, {
38
+ live: !options.preview,
39
+ // The assigned slug is server text, so it prints quoted and
40
+ // length-bounded like every other string this CLI did not author.
41
+ // A validated slug tops out at 63 characters, so the line never
42
+ // passes 80 columns; the closing note explains where it is saved.
43
+ onAppCreated: (created) => {
44
+ io.out(`✓ Created ${quotedForTerminal(created.appSlug, MAX_SERVER_TEXT_CHARS)}\n`);
45
+ },
46
+ });
47
+ const seconds = (io.now() - startedAt) / 1_000;
48
+ io.out(`${changedNote(outcome)}\n`);
49
+ io.out(`${uploadedLine(outcome, seconds)}\n`);
50
+ io.out(`${resultLine(outcome)}\n`);
51
+ if (outcome.url !== null) {
52
+ io.out(`\n ${capForTerminal(outcome.url, MAX_SERVER_URL_CHARS)}\n`);
53
+ }
54
+ if (collected.skippedNodeModules.length > 0) {
55
+ io.out(`\n${wrapProse("Note: node_modules doesn't deploy — Driggsby serves your app as static files, so it isn't needed.")}\n`);
56
+ }
57
+ if (collected.skippedSymlinks.length > 0) {
58
+ io.out(`\n${symlinkNote(collected.skippedSymlinks)}\n`);
59
+ }
60
+ if (createdApp !== null) {
61
+ io.out(`\n${wrapProse(`This deploy created your app. Its address is assigned by Driggsby — the name you picked plus a unique ending — and it's saved in driggsby.json, so every later deploy targets it. If this project lives in git, commit the updated driggsby.json: it's the only record of your app's address, and a fresh checkout without it would create a second app.`)}\n`);
62
+ }
63
+ io.out(`\n${nextSection(outcome)}`);
64
+ return 0;
65
+ }
66
+ catch (error) {
67
+ throw deployFailure(error, baseUrl, DEPLOY_RETRY_COMMAND);
68
+ }
69
+ }
70
+ function hashedLine(fileCount) {
71
+ return `✓ Hashed ${fileCount} ${fileCount === 1 ? "file" : "files"}`;
72
+ }
73
+ // What the manifest exchange revealed: how much of the app Driggsby already
74
+ // had. Printed as its own line right after hashing.
75
+ function changedNote(outcome) {
76
+ const changed = outcome.fileCount - outcome.unchangedFileCount;
77
+ if (outcome.unchangedFileCount === 0) {
78
+ return "✓ Compared all new to Driggsby";
79
+ }
80
+ if (changed === 0) {
81
+ return "✓ Compared no file changed since the last deploy";
82
+ }
83
+ return `✓ Compared ${changed} changed · ${outcome.unchangedFileCount} already on Driggsby`;
84
+ }
85
+ function uploadedLine(outcome, seconds) {
86
+ if (outcome.uploadedBlobCount === 0) {
87
+ return "✓ Uploaded nothing — Driggsby already had every file";
88
+ }
89
+ return `✓ Uploaded ${formatBytes(outcome.uploadedBytes)} in ${formatSeconds(seconds)}`;
90
+ }
91
+ function resultLine(outcome) {
92
+ if (outcome.live) {
93
+ // "at:" only when the URL that line promises actually follows.
94
+ return outcome.url === null
95
+ ? `✓ Live v${outcome.versionNumber}`
96
+ : `✓ Live v${outcome.versionNumber}, at:`;
97
+ }
98
+ return `✓ Ready v${outcome.versionNumber} is uploaded but not live — what visitors see is unchanged`;
99
+ }
100
+ function symlinkNote(skipped) {
101
+ // Symlink names come straight off the filesystem and skip the manifest's
102
+ // character validation, so each one prints through quotedForTerminal —
103
+ // sanitized, length-bounded, and inside quotes the name can't break out
104
+ // of, so it reads as a name, not as part of the CLI's own sentence.
105
+ const shown = skipped
106
+ .slice(0, 3)
107
+ .map((name) => quotedForTerminal(name, 40))
108
+ .join(", ");
109
+ const more = skipped.length > 3 ? ` and ${skipped.length - 3} more` : "";
110
+ return wrapProse(`Note: symlinks don't deploy — skipped ${shown}${more}.`);
111
+ }
112
+ function nextSection(outcome) {
113
+ if (!outcome.live) {
114
+ return ("Next:\n" +
115
+ ` Make v${outcome.versionNumber} live with npx driggsby@latest rollback --to ${outcome.versionNumber}\n`);
116
+ }
117
+ return ("Next:\n" +
118
+ " Deploy again any time — only changed files upload. Switch back to an\n" +
119
+ " earlier version with npx driggsby@latest rollback.\n");
120
+ }
121
+ function formatSeconds(seconds) {
122
+ if (seconds < 0.05) {
123
+ return "under a second";
124
+ }
125
+ return seconds >= 10 ? `${Math.round(seconds)}s` : `${seconds.toFixed(1)}s`;
126
+ }
@@ -0,0 +1,100 @@
1
+ // driggsby rollback: switch which kept version of the app is live. Nothing
2
+ // re-uploads — the switch is a pointer change on Driggsby's side. With --to N
3
+ // it acts directly; without it, it shows the choices and asks (or, when not
4
+ // attached to a person, explains how to re-run with --to).
5
+ import { setLiveVersion, readProjectConfig } from "@driggsby/deploy";
6
+ import { apiBaseUrl } from "../api/base-url.js";
7
+ import { CliError } from "../cli-error.js";
8
+ import { defaultCredentialEnvironment, } from "../credentials/store.js";
9
+ import { capForTerminal, wrapProse } from "../terminal-text.js";
10
+ import { deployFailure, MAX_SERVER_URL_CHARS, requireDeploySession, } from "./api-session.js";
11
+ import { fetchVersionList, renderVersionRows } from "./versions-command.js";
12
+ const ROLLBACK_RETRY_COMMAND = "npx driggsby@latest rollback";
13
+ function defaultRollbackIo() {
14
+ return {
15
+ out: (text) => {
16
+ process.stdout.write(text);
17
+ },
18
+ ask: async (question) => {
19
+ const { createInterface } = await import("node:readline/promises");
20
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
21
+ try {
22
+ return await readline.question(question);
23
+ }
24
+ finally {
25
+ readline.close();
26
+ }
27
+ },
28
+ interactive: process.stdin.isTTY && process.stdout.isTTY,
29
+ };
30
+ }
31
+ export async function runRollback(options, environment = defaultCredentialEnvironment(), io = defaultRollbackIo()) {
32
+ const projectDirectory = options.projectDirectory ?? process.cwd();
33
+ const baseUrl = apiBaseUrl(environment.env);
34
+ try {
35
+ const config = await readProjectConfig(projectDirectory);
36
+ const session = await requireDeploySession(environment);
37
+ const api = { baseUrl: session.baseUrl, token: session.token };
38
+ const list = await fetchVersionList(api, config.slug);
39
+ const readyNumbers = list.versions
40
+ .filter((version) => version.status === "ready")
41
+ .map((version) => version.number);
42
+ if (readyNumbers.length === 0) {
43
+ throw new CliError(`${wrapProse(`${config.slug} has no completed versions to switch between yet. Deploy one first:`)}\n` +
44
+ " npx driggsby@latest deploy", 1);
45
+ }
46
+ const target = options.toVersion ?? (await chooseVersion(list, readyNumbers, io));
47
+ if (target === list.liveVersionNumber) {
48
+ io.out(`v${target} is already live — nothing changed.\n\n` +
49
+ "Next:\n See every version with npx driggsby@latest versions\n");
50
+ return 0;
51
+ }
52
+ if (!readyNumbers.includes(target)) {
53
+ throw new CliError(`${wrapProse(`v${target} isn't one of ${config.slug}'s ready versions. Ready: ${readyNumbers.map((number) => `v${number}`).join(", ")}.`)}\n\n` +
54
+ "See the full list with:\n npx driggsby@latest versions", 1);
55
+ }
56
+ const previousLive = list.liveVersionNumber;
57
+ const result = await setLiveVersion(api, config.slug, target);
58
+ const was = previousLive === null ? "" : ` (was v${previousLive})`;
59
+ // "at:" only when the URL that line promises actually follows; the URL
60
+ // is a server-supplied string, sanitized before printing.
61
+ const atSuffix = result.url === null ? "" : ", at:";
62
+ io.out(`✓ Live v${result.versionNumber} is what visitors see now${was}${atSuffix}\n`);
63
+ if (result.url !== null) {
64
+ io.out(`\n ${capForTerminal(result.url, MAX_SERVER_URL_CHARS)}\n`);
65
+ }
66
+ io.out(`\n${wrapProse(`Nothing re-uploaded — Driggsby already had v${result.versionNumber} in full.`)}\n` +
67
+ "\nNext:\n See every version with npx driggsby@latest versions\n");
68
+ return 0;
69
+ }
70
+ catch (error) {
71
+ throw deployFailure(error, baseUrl, ROLLBACK_RETRY_COMMAND);
72
+ }
73
+ }
74
+ // Without --to: show the same rows `versions` prints, then ask (a person) or
75
+ // explain the exact re-run (an agent or script).
76
+ async function chooseVersion(list, readyNumbers, io) {
77
+ // Only "ready" versions can go live, so only those are offered here — an
78
+ // "incomplete" row under this heading would list a choice that gets
79
+ // refused.
80
+ const rows = renderVersionRows({
81
+ ...list,
82
+ versions: list.versions.filter((version) => version.status === "ready"),
83
+ });
84
+ if (!io.interactive) {
85
+ throw new CliError(`These versions can go live:\n\n${rows}\n\n` +
86
+ "This terminal can't prompt for a choice, so name one directly:\n" +
87
+ " npx driggsby@latest rollback --to <VERSION>", 2);
88
+ }
89
+ io.out(`${rows}\n\n`);
90
+ const answer = (await io.ask("Make which version live? (a number like 2) ")).trim();
91
+ const normalized = /^[vV]/.test(answer) ? answer.slice(1) : answer;
92
+ const parsed = Number.parseInt(normalized, 10);
93
+ // Same guard as --to: a digit string past Number.MAX_SAFE_INTEGER must be
94
+ // refused here too, not rendered back as "vInfinity isn't one of ...".
95
+ if (!/^[0-9]+$/.test(normalized) || !Number.isSafeInteger(parsed)) {
96
+ throw new CliError(`${wrapProse(`That wasn't a version number. Choose one of: ${readyNumbers.map((number) => `v${number}`).join(", ")}, or run:`)}\n` +
97
+ " npx driggsby@latest rollback --to <VERSION>", 1);
98
+ }
99
+ return parsed;
100
+ }
@@ -0,0 +1,97 @@
1
+ // driggsby versions: list the deployed app's kept versions, which one is
2
+ // live, and the numbers rollback accepts. rollback reuses the fetch and the
3
+ // row rendering here so both commands describe versions identically.
4
+ import { DeployApiError, formatBytes, listVersions, readProjectConfig, } from "@driggsby/deploy";
5
+ import { apiBaseUrl } from "../api/base-url.js";
6
+ import { CliError } from "../cli-error.js";
7
+ import { defaultCredentialEnvironment, } from "../credentials/store.js";
8
+ import { capForTerminal, quotedForTerminal, wrapProse } from "../terminal-text.js";
9
+ import { deployFailure, MAX_SERVER_TEXT_CHARS, MAX_SERVER_URL_CHARS, requireDeploySession, } from "./api-session.js";
10
+ function defaultVersionsIo() {
11
+ return {
12
+ out: (text) => {
13
+ process.stdout.write(text);
14
+ },
15
+ };
16
+ }
17
+ export async function runVersions(options, environment = defaultCredentialEnvironment(), io = defaultVersionsIo()) {
18
+ const projectDirectory = options.projectDirectory ?? process.cwd();
19
+ const baseUrl = apiBaseUrl(environment.env);
20
+ try {
21
+ const config = await readProjectConfig(projectDirectory);
22
+ const session = await requireDeploySession(environment);
23
+ const list = await fetchVersionList({ baseUrl: session.baseUrl, token: session.token }, config.slug);
24
+ // The slug and URL are server-supplied strings, sanitized and
25
+ // length-bounded like every other text this CLI did not author — and
26
+ // the slug prints through quotedForTerminal, inside quotes it can't
27
+ // break out of, so it reads as a name, never as the CLI's own sentence.
28
+ const count = list.versions.length;
29
+ io.out(`${wrapProse(`${quotedForTerminal(list.appSlug, MAX_SERVER_TEXT_CHARS)} — ${count} ${count === 1 ? "version" : "versions"}`)}\n\n`);
30
+ io.out(`${renderVersionRows(list)}\n`);
31
+ if (list.liveVersionNumber !== null) {
32
+ io.out(`\nYour app:\n\n ${capForTerminal(list.url, MAX_SERVER_URL_CHARS)}\n`);
33
+ }
34
+ else {
35
+ io.out("\nNothing is live yet, so your app's address shows nothing.\n");
36
+ }
37
+ io.out("\nNext:\n" +
38
+ ' Make any "ready" version live with npx driggsby@latest rollback --to <VERSION>\n');
39
+ return 0;
40
+ }
41
+ catch (error) {
42
+ throw deployFailure(error, baseUrl, "npx driggsby@latest versions");
43
+ }
44
+ }
45
+ // A 404 here means the app has never been deployed from this account, and
46
+ // the fix is a deploy — not a retry.
47
+ export async function fetchVersionList(api, slug) {
48
+ try {
49
+ return await listVersions(api, slug);
50
+ }
51
+ catch (error) {
52
+ if (error instanceof DeployApiError && error.status === 404) {
53
+ throw new CliError(`${wrapProse(`There's no app named ${slug} on your Driggsby account yet. Its first deploy creates it:`)}\n` +
54
+ " npx driggsby@latest deploy", 1);
55
+ }
56
+ throw error;
57
+ }
58
+ }
59
+ export function renderVersionRows(list) {
60
+ const rows = list.versions.map((version) => ({
61
+ label: `v${version.number}`,
62
+ status: version.live ? "live now" : version.status === "ready" ? "ready" : "incomplete",
63
+ files: `${version.fileCount} ${version.fileCount === 1 ? "file" : "files"}`,
64
+ size: formatBytes(version.totalBytes),
65
+ date: formatCreatedAt(version.createdAt),
66
+ }));
67
+ if (rows.length === 0) {
68
+ return " (no versions yet)";
69
+ }
70
+ const labelWidth = widestLength(rows.map((row) => row.label));
71
+ const statusWidth = widestLength(rows.map((row) => row.status));
72
+ const filesWidth = widestLength(rows.map((row) => row.files));
73
+ const sizeWidth = widestLength(rows.map((row) => row.size));
74
+ return rows
75
+ .map((row) => ` ${row.label.padEnd(labelWidth)} ${row.status.padEnd(statusWidth)} ` +
76
+ `${row.files.padStart(filesWidth)} ${row.size.padStart(sizeWidth)} ${row.date}`)
77
+ .join("\n");
78
+ }
79
+ // reduce, not Math.max(...spread): the server controls this array's length,
80
+ // and a spread call throws once the argument list gets long enough.
81
+ function widestLength(values) {
82
+ return values.reduce((widest, value) => Math.max(widest, value.length), 0);
83
+ }
84
+ // "2026-08-21T17:04:00.000Z" -> "2026-08-21 17:04 UTC". The API always sends
85
+ // ISO-8601 UTC; anything unexpected is shown as-is (sanitized and capped)
86
+ // rather than guessed at. Real values keep the row inside 80 columns; this
87
+ // bound keeps a garbage timestamp from stretching it without limit — a real
88
+ // timestamp is at most 24 characters.
89
+ const MAX_CREATED_AT_CELL_CHARS = 30;
90
+ function formatCreatedAt(createdAt) {
91
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(createdAt);
92
+ if (match === null) {
93
+ return capForTerminal(createdAt, MAX_CREATED_AT_CELL_CHARS);
94
+ }
95
+ const [, date, time] = match;
96
+ return `${date ?? ""} ${time ?? ""} UTC`;
97
+ }
@@ -0,0 +1,106 @@
1
+ // driggsby dev: preview the app in this folder with the developer's real
2
+ // Driggsby data, in the exact topology it will live in after deploy — the
3
+ // app on its own local origin, embedded by a local host page, talking
4
+ // through the frozen postMessage protocol. The app token stays in this
5
+ // process; the pages carry no credentials.
6
+ import { readFile } from "node:fs/promises";
7
+ import { createRequire } from "node:module";
8
+ import { readProjectConfig } from "@driggsby/deploy";
9
+ import { apiBaseUrl, apiHost } from "../api/base-url.js";
10
+ import { blockedNetworkError } from "../api/network-error.js";
11
+ import { CliError } from "../cli-error.js";
12
+ import { defaultCredentialEnvironment, } from "../credentials/store.js";
13
+ import { requireDeploySession } from "../deploy/api-session.js";
14
+ import { tryOpenUrl } from "../login/open-url.js";
15
+ import { wrapProse } from "../terminal-text.js";
16
+ import { startDevServers } from "./dev-servers.js";
17
+ import { McpBroker } from "./mcp-broker.js";
18
+ import { watchDirectory } from "./watcher.js";
19
+ export const DEV_HOST_PORT = 4111;
20
+ export const DEV_APP_PORT = 4112;
21
+ const DEV_RETRY_COMMAND = "npx driggsby@latest dev";
22
+ function defaultDevIo() {
23
+ return {
24
+ out: (text) => {
25
+ process.stdout.write(text);
26
+ },
27
+ openUrl: tryOpenUrl,
28
+ waitForShutdown: async () => {
29
+ await new Promise((resolve) => {
30
+ process.once("SIGINT", () => {
31
+ resolve();
32
+ });
33
+ process.once("SIGTERM", () => {
34
+ resolve();
35
+ });
36
+ });
37
+ },
38
+ };
39
+ }
40
+ export async function runDev(options = {}, environment = defaultCredentialEnvironment(), io = defaultDevIo()) {
41
+ const projectDirectory = options.projectDirectory ?? process.cwd();
42
+ const baseUrl = apiBaseUrl(environment.env);
43
+ const config = await readProjectConfig(projectDirectory);
44
+ if (config.devCommand !== null) {
45
+ throw new CliError(`${wrapProse('This driggsby.json sets "dev_command", which driggsby dev doesn\'t support yet. Remove it to preview the folder\'s files directly, or run your own dev server and deploy with:')}\n` +
46
+ " npx driggsby@latest deploy", 1);
47
+ }
48
+ const session = await requireDeploySession(environment);
49
+ const sdkBundle = await loadSdkBundle();
50
+ const broker = new McpBroker({ baseUrl: session.baseUrl, token: session.token });
51
+ let servers;
52
+ try {
53
+ servers = await startDevServers({
54
+ slug: config.slug,
55
+ serveDirectory: config.serveDirectory,
56
+ sdkBundle,
57
+ runToolCall: (tool, argumentsObject) => broker.runToolCall(tool, argumentsObject),
58
+ hostPort: options.hostPort ?? DEV_HOST_PORT,
59
+ appPort: options.appPort ?? DEV_APP_PORT,
60
+ });
61
+ }
62
+ catch (error) {
63
+ throw devStartFailure(error, baseUrl);
64
+ }
65
+ const stopWatching = watchDirectory(config.serveDirectory, () => {
66
+ servers.notifyChange();
67
+ });
68
+ try {
69
+ const opened = await io.openUrl(servers.hostOrigin);
70
+ io.out(`✓ Ready ${config.slug} is running with your live Driggsby data${opened ? ", at:" : ":"}\n\n` +
71
+ ` ${servers.hostOrigin}\n\n` +
72
+ `${wrapProse("Edit the app's files and the page reloads on save. Press Ctrl+C to stop.")}\n` +
73
+ "\nNext:\n Put it live on driggsby.dev with npx driggsby@latest deploy\n");
74
+ await io.waitForShutdown();
75
+ return 0;
76
+ }
77
+ finally {
78
+ stopWatching();
79
+ await servers.close();
80
+ }
81
+ }
82
+ // The Driggsby SDK bundle ships inside @driggsby/sdk; dev serves it on the
83
+ // app origin at the same path production serves it, so the scaffold's
84
+ // script tag works identically in both places.
85
+ async function loadSdkBundle() {
86
+ const require = createRequire(import.meta.url);
87
+ const bundlePath = require.resolve("@driggsby/sdk/driggsby-sdk.js");
88
+ return await readFile(bundlePath, "utf8");
89
+ }
90
+ function devStartFailure(error, baseUrl) {
91
+ if (error instanceof CliError) {
92
+ return error;
93
+ }
94
+ if (error.code === "EADDRINUSE") {
95
+ return new CliError(`${wrapProse(`Ports ${String(DEV_HOST_PORT)} and ${String(DEV_APP_PORT)} are how driggsby dev serves the preview, and something on this machine is already using one of them. Stop that program (often another driggsby dev) and try again:`)}\n` +
96
+ ` ${DEV_RETRY_COMMAND}`, 1);
97
+ }
98
+ const blocked = blockedNetworkError(error, {
99
+ host: apiHost(baseUrl),
100
+ retryCommand: DEV_RETRY_COMMAND,
101
+ });
102
+ if (blocked !== null) {
103
+ return blocked;
104
+ }
105
+ return new CliError(`We weren't able to start the preview just now. Please try again:\n ${DEV_RETRY_COMMAND}`, 1);
106
+ }