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.
- package/README.md +59 -0
- package/bin/driggsby.js +2 -0
- package/dist/api/base-url.js +31 -0
- package/dist/api/network-error.js +55 -0
- package/dist/args-deploy.js +94 -0
- package/dist/args-init.js +29 -0
- package/dist/args-mcp-setup.js +258 -0
- package/dist/args-shared.js +28 -0
- package/dist/args.js +177 -0
- package/dist/clap-suggestions.js +66 -0
- package/dist/cli-error.js +12 -0
- package/dist/credentials/clear-outcome.js +1 -0
- package/dist/credentials/file-store.js +77 -0
- package/dist/credentials/keychain.js +54 -0
- package/dist/credentials/secret-tool.js +48 -0
- package/dist/credentials/store.js +164 -0
- package/dist/credentials/tool-runner.js +55 -0
- package/dist/deploy/api-session.js +85 -0
- package/dist/deploy/deploy-command.js +126 -0
- package/dist/deploy/rollback-command.js +100 -0
- package/dist/deploy/versions-command.js +97 -0
- package/dist/dev/dev-command.js +106 -0
- package/dist/dev/dev-servers.js +274 -0
- package/dist/dev/host-page.js +147 -0
- package/dist/dev/mcp-broker.js +149 -0
- package/dist/dev/static-files.js +104 -0
- package/dist/dev/tool-allowlist.js +18 -0
- package/dist/dev/watcher.js +76 -0
- package/dist/help.js +180 -0
- package/dist/init/init-command.js +114 -0
- package/dist/init/templates.js +271 -0
- package/dist/login/claim-client.js +151 -0
- package/dist/login/login.js +146 -0
- package/dist/login/logout.js +37 -0
- package/dist/login/open-url.js +79 -0
- package/dist/main.js +57 -0
- package/dist/mcp-setup/classify.js +37 -0
- package/dist/mcp-setup/client-command.js +133 -0
- package/dist/mcp-setup/commands.js +53 -0
- package/dist/mcp-setup/known-client.js +38 -0
- package/dist/mcp-setup/setup.js +137 -0
- package/dist/mcp-setup/spawn-plan.js +91 -0
- package/dist/mcp-setup/text.js +83 -0
- package/dist/terminal-text.js +19 -0
- package/dist/version.js +6 -0
- package/package.json +22 -41
- package/bin/driggsby +0 -10
- package/install.js +0 -159
- package/lib/artifacts.js +0 -49
- package/lib/process.js +0 -65
|
@@ -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
|
+
}
|