@driggsby/deploy 0.0.1 → 0.2.1

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/dist/deploy.js ADDED
@@ -0,0 +1,188 @@
1
+ // Runs one whole deploy against the API: offer the manifest, upload the
2
+ // blobs the server doesn't have, finalize. Bounded concurrency, one retry on
3
+ // a 503 upload, and one recovery round if finalize reports missing blobs —
4
+ // then it stops and reports rather than looping.
5
+ import { createHash } from "node:crypto";
6
+ import { readFile } from "node:fs/promises";
7
+ import { createApp, createVersion, finalizeVersion, uploadBlob, } from "./client.js";
8
+ import { DeployApiError, DeployError } from "./errors.js";
9
+ import {} from "./manifest.js";
10
+ import { writeAssignedSlug } from "./project-config.js";
11
+ import { quotedForTerminal } from "./terminal-text.js";
12
+ const UPLOAD_CONCURRENCY = 4;
13
+ // Deploys a project whose driggsby.json slug may not be an app yet. Apps
14
+ // are created explicitly with a server-assigned slug (the readable name
15
+ // plus a unique ending), so when the version post answers app_not_found,
16
+ // this creates the app, persists the assigned slug into driggsby.json
17
+ // BEFORE uploading anything (a deploy that dies mid-upload must never
18
+ // strand an app the project forgot), and deploys to the assigned slug.
19
+ export async function deployProjectFiles(api, projectDirectory, slug, collected, options) {
20
+ try {
21
+ const outcome = await deployCollectedFiles(api, slug, collected, options);
22
+ return { outcome, createdApp: null };
23
+ }
24
+ catch (error) {
25
+ if (!(error instanceof DeployApiError) || error.code !== "app_not_found") {
26
+ throw error;
27
+ }
28
+ }
29
+ const created = await createApp(api, slug, options.appName);
30
+ await writeAssignedSlug(projectDirectory, created.appSlug);
31
+ options.onAppCreated?.(created);
32
+ const outcome = await deployCollectedFiles(api, created.appSlug, collected, options);
33
+ return { outcome, createdApp: created };
34
+ }
35
+ export async function deployCollectedFiles(api, slug, collected, options) {
36
+ const created = await createVersion(api, slug, collected.files, options.appName);
37
+ const fileBySha = new Map();
38
+ for (const file of collected.files) {
39
+ if (!fileBySha.has(file.sha256)) {
40
+ fileBySha.set(file.sha256, file);
41
+ }
42
+ }
43
+ const uploadedShas = new Set();
44
+ let uploadedBytes = 0;
45
+ const uploadRound = async (shas) => {
46
+ // The server's missing-blob list is deduplicated defensively: a
47
+ // misbehaving server repeating one sha must not make the client re-PUT
48
+ // the same file over and over.
49
+ const toUpload = [...new Set(shas)].map((sha) => {
50
+ const file = fileBySha.get(sha);
51
+ if (file === undefined) {
52
+ throw new DeployError("The Driggsby deploy API asked for a file this deploy never offered.\n" +
53
+ "Please deploy again from the start.");
54
+ }
55
+ return file;
56
+ });
57
+ await runWithConcurrencyLimit(toUpload, UPLOAD_CONCURRENCY, async (file, signal) => {
58
+ const bytes = await readFile(file.absolutePath);
59
+ assertUnchangedSinceHashing(file, bytes);
60
+ await uploadBlobWithOneRetry(api, file.sha256, bytes, signal);
61
+ if (!uploadedShas.has(file.sha256)) {
62
+ uploadedShas.add(file.sha256);
63
+ uploadedBytes += file.byteSize;
64
+ }
65
+ });
66
+ };
67
+ await uploadRound(created.missingBlobSha256s);
68
+ let outcome = await finalizeVersion(api, created.versionId, options.live);
69
+ if (outcome.kind === "blobs-missing") {
70
+ // The server can evict a pending blob between upload and finalize. One
71
+ // recovery round is enough for that; anything more means something is
72
+ // genuinely wrong and retrying would just loop.
73
+ await uploadRound(outcome.missingBlobSha256s);
74
+ outcome = await finalizeVersion(api, created.versionId, options.live);
75
+ }
76
+ if (outcome.kind === "blobs-missing") {
77
+ throw new DeployError("We uploaded this deploy's files, but the Driggsby deploy API kept\n" +
78
+ "reporting some as missing. Please deploy again in a minute.");
79
+ }
80
+ // Counted from what actually went over the wire, so blobs re-sent during
81
+ // the recovery round never read as "already on Driggsby".
82
+ const unchangedFileCount = collected.files.filter((file) => !uploadedShas.has(file.sha256)).length;
83
+ return {
84
+ appSlug: outcome.appSlug,
85
+ versionNumber: outcome.versionNumber,
86
+ live: outcome.live,
87
+ url: outcome.url,
88
+ fileCount: collected.files.length,
89
+ uploadedBlobCount: uploadedShas.size,
90
+ uploadedBytes,
91
+ unchangedFileCount,
92
+ };
93
+ }
94
+ // The manifest hashed this file during the walk; the upload re-reads it
95
+ // from disk. If the bytes changed in between (a build still writing into
96
+ // the serve folder), uploading them under the walk-time hash would publish
97
+ // content nothing ever hashed — fail with the fix instead.
98
+ function assertUnchangedSinceHashing(file, bytes) {
99
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
100
+ if (sha256 !== file.sha256) {
101
+ throw new DeployError(`${quotedForTerminal(file.path, 80)} changed while this deploy was running — a build may\n` +
102
+ "still have been writing into the serve folder. Let it finish and\n" +
103
+ "deploy again.");
104
+ }
105
+ }
106
+ // A 503 from blob storage is transient and the PUT is idempotent, so one
107
+ // retry is safe. Without server guidance the retry waits a short default
108
+ // pause, since an instant re-PUT usually lands on storage that is still
109
+ // unhealthy and wastes the only retry the design allows. An explicit
110
+ // Retry-After is taken at its word — including 0, the server saying "now" —
111
+ // and capped so a bad header can't stall the deploy. A second 503 propagates
112
+ // with the server's message.
113
+ const DEFAULT_RETRY_DELAY_MS = 1_000;
114
+ const MAX_RETRY_DELAY_MS = 5_000;
115
+ export function retryDelayMs(error) {
116
+ if (error.retryAfterSeconds === null) {
117
+ return DEFAULT_RETRY_DELAY_MS;
118
+ }
119
+ return Math.min(error.retryAfterSeconds * 1_000, MAX_RETRY_DELAY_MS);
120
+ }
121
+ async function uploadBlobWithOneRetry(api, sha256, bytes, signal) {
122
+ try {
123
+ await uploadBlob(api, sha256, bytes, signal);
124
+ }
125
+ catch (error) {
126
+ if (error instanceof DeployApiError && error.status === 503 && signal?.aborted !== true) {
127
+ await sleep(retryDelayMs(error), signal);
128
+ await uploadBlob(api, sha256, bytes, signal);
129
+ return;
130
+ }
131
+ throw error;
132
+ }
133
+ }
134
+ // Resolves early if the signal aborts mid-pause — a worker sleeping out a
135
+ // Retry-After must not keep the process alive after a sibling has already
136
+ // failed the deploy. (The retry PUT after an early wake is refused
137
+ // immediately by its aborted signal, so nothing extra reaches the wire.)
138
+ function sleep(milliseconds, signal) {
139
+ if (milliseconds <= 0 || signal?.aborted === true) {
140
+ return Promise.resolve();
141
+ }
142
+ return new Promise((resolve) => {
143
+ const finish = () => {
144
+ clearTimeout(timer);
145
+ signal?.removeEventListener("abort", finish);
146
+ resolve();
147
+ };
148
+ const timer = setTimeout(finish, milliseconds);
149
+ signal?.addEventListener("abort", finish, { once: true });
150
+ });
151
+ }
152
+ // A small worker pool: at most `limit` workers in flight, results in input
153
+ // order, first failure rejects the whole run AND aborts the rest. Without
154
+ // the abort, the failure's message would print while surviving uploads keep
155
+ // running for minutes — a "deploy failed" line followed by a silent hang.
156
+ // Workers receive the shared signal so they can cancel their in-flight
157
+ // request; the pool itself also stops handing out new items once aborted.
158
+ export async function runWithConcurrencyLimit(items, limit, worker) {
159
+ const results = new Array(items.length);
160
+ const queue = items.map((item, index) => ({ item, index }));
161
+ let cursor = 0;
162
+ const abort = new AbortController();
163
+ const runWorker = async () => {
164
+ for (;;) {
165
+ if (abort.signal.aborted) {
166
+ return;
167
+ }
168
+ const entry = queue[cursor];
169
+ if (entry === undefined) {
170
+ return;
171
+ }
172
+ cursor += 1;
173
+ try {
174
+ results[entry.index] = await worker(entry.item, abort.signal);
175
+ }
176
+ catch (error) {
177
+ abort.abort();
178
+ throw error;
179
+ }
180
+ }
181
+ };
182
+ const workerCount = Math.min(limit, items.length);
183
+ // Promise.all rejects with the FIRST failure — the real one — and it
184
+ // attaches handlers to every worker, so the aborted siblings' rejections
185
+ // are observed, not unhandled.
186
+ await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
187
+ return results;
188
+ }
@@ -0,0 +1,8 @@
1
+ export interface EntrypointIo {
2
+ argv: string[];
3
+ cwd: string;
4
+ env: NodeJS.ProcessEnv;
5
+ out: (text: string) => void;
6
+ error: (text: string) => void;
7
+ }
8
+ export declare function runDeployEntrypoint(io: EntrypointIo): Promise<number>;
@@ -0,0 +1,121 @@
1
+ // The minimal `npx @driggsby/deploy` entrypoint for Node-only sandboxes:
2
+ // deploy the current directory's driggsby.json project, authenticating with
3
+ // the DRIGGSBY_TOKEN environment variable. The full experience (saved
4
+ // sign-in, previews, rollback) lives in the driggsby CLI; this exists for
5
+ // environments that can only run one command with an injected token.
6
+ import { readFile } from "node:fs/promises";
7
+ import { resolveBaseUrl } from "./base-url.js";
8
+ import { deployProjectFiles } from "./deploy.js";
9
+ import { DeployApiError, DeployError } from "./errors.js";
10
+ import { collectDeployFiles, formatBytes } from "./manifest.js";
11
+ import { readProjectConfig } from "./project-config.js";
12
+ import { capForTerminal, quotedForTerminal, wrapProse } from "./terminal-text.js";
13
+ // Server-supplied error text is bounded like every other string this bin did
14
+ // not author (same cap as the driggsby CLI's deploy commands).
15
+ const MAX_ERROR_DESCRIPTION_CHARS = 300;
16
+ const USAGE = "Deploys the Driggsby app in the current directory, live.\n" +
17
+ "\n" +
18
+ "Usage:\n" +
19
+ " npx @driggsby/deploy\n" +
20
+ "\n" +
21
+ "This is the whole interface: a bare invocation reads driggsby.json in the\n" +
22
+ 'current directory, uploads its "serve" folder, and makes the result live\n' +
23
+ "at your app's address. It authenticates only through the DRIGGSBY_TOKEN\n" +
24
+ "environment variable.\n" +
25
+ "\n" +
26
+ "Previews, version history, rollback, and saved sign-in live in the full\n" +
27
+ "CLI:\n" +
28
+ " npx driggsby@latest --help\n";
29
+ export async function runDeployEntrypoint(io) {
30
+ // A bare invocation is the only one that deploys. Anything else — a help
31
+ // flag, a guessed --preview, a typo — must never trigger a live publish,
32
+ // because an agent probing an unfamiliar bin starts with `--help`.
33
+ if (io.argv.includes("-h") || io.argv.includes("--help")) {
34
+ io.out(USAGE);
35
+ return 0;
36
+ }
37
+ if (io.argv.includes("--version")) {
38
+ io.out(`${await packageVersion()}\n`);
39
+ return 0;
40
+ }
41
+ if (io.argv.length > 0) {
42
+ io.error(`Unrecognized argument: ${quotedForTerminal(io.argv[0] ?? "", 80)}\n` +
43
+ `Nothing was deployed.\n\n${USAGE}`);
44
+ return 2;
45
+ }
46
+ try {
47
+ const token = io.env.DRIGGSBY_TOKEN?.trim();
48
+ if (token === undefined || token === "") {
49
+ io.error("DRIGGSBY_TOKEN isn't set. This command authenticates only through that\n" +
50
+ "environment variable. On your own machine, use the full CLI instead:\n" +
51
+ "sign in once with npx driggsby@latest login, then run\n" +
52
+ "npx driggsby@latest deploy.\n");
53
+ return 1;
54
+ }
55
+ const baseUrl = resolveBaseUrl(io.env);
56
+ const config = await readProjectConfig(io.cwd);
57
+ const collected = await collectDeployFiles(config.serveDirectory);
58
+ const { outcome, createdApp } = await deployProjectFiles({ baseUrl, token }, io.cwd, config.slug, collected, { live: true });
59
+ if (createdApp !== null) {
60
+ // The assigned slug is server text, so it prints quoted and bounded
61
+ // like every other string this bin did not author; the whole sentence
62
+ // wraps because a maximum-length slug pushes it past 80 columns. The
63
+ // commit reminder matters most here: this bin targets ephemeral
64
+ // sandboxes, and a checkout that discards the rewritten driggsby.json
65
+ // creates a brand-new app at a new address on every run.
66
+ io.out(`${wrapProse(`Created your app as ${quotedForTerminal(createdApp.appSlug, 80)} — Driggsby assigns the final name (yours plus a unique ending) and saved it in driggsby.json. Commit that updated driggsby.json: it is the only record of your app's address, and without it the next deploy creates a second app.`)}\n`);
67
+ }
68
+ const uploadedNote = outcome.uploadedBlobCount === 0
69
+ ? "nothing new to upload — every file was already on Driggsby"
70
+ : `uploaded ${formatBytes(outcome.uploadedBytes)}`;
71
+ // The slug and URL come back from the server, so they are sanitized and
72
+ // length-bounded like every other string this bin did not author — and
73
+ // the slug prints quoted so it reads as a name, never as this bin's own
74
+ // sentence. The bounds never truncate legitimate values: a slug tops
75
+ // out at 63 characters and the app URL at 84.
76
+ io.out(`Deployed ${quotedForTerminal(outcome.appSlug, 80)} (v${outcome.versionNumber}, ${uploadedNote}).\n`);
77
+ if (outcome.url !== null) {
78
+ io.out(`Live at:\n\n ${capForTerminal(outcome.url, 200)}\n`);
79
+ }
80
+ return 0;
81
+ }
82
+ catch (error) {
83
+ if (error instanceof DeployApiError) {
84
+ // A 503's server description talks to the protocol client ("retry the
85
+ // same PUT"), so it gets copy written for the person or agent running
86
+ // this bin instead.
87
+ if (error.status === 503) {
88
+ io.error("Driggsby is briefly unavailable just now. Please run this command\n" +
89
+ "again in a minute.\n");
90
+ return 1;
91
+ }
92
+ const description = capForTerminal(error.message, MAX_ERROR_DESCRIPTION_CHARS).trim();
93
+ io.error(description === ""
94
+ ? "The Driggsby deploy API refused this request without saying why.\nPlease try again in a minute.\n"
95
+ : `${wrapProse(description)}\n`);
96
+ return 1;
97
+ }
98
+ if (error instanceof DeployError) {
99
+ // Locally-authored message; any interpolated filesystem names were
100
+ // already sanitized where the message was built.
101
+ io.error(`${error.message}\n`);
102
+ return 1;
103
+ }
104
+ io.error("We weren't able to finish this deploy. Please try again in a minute.\n");
105
+ return 1;
106
+ }
107
+ }
108
+ // --version runs outside the deploy try block, so a failure here would
109
+ // escape as a raw stack trace; an unreadable own-package.json — or one
110
+ // missing its version field (an impossible install, effectively) — degrades
111
+ // to "unknown" instead.
112
+ async function packageVersion() {
113
+ try {
114
+ const raw = await readFile(new URL("../package.json", import.meta.url), "utf8");
115
+ const version = JSON.parse(raw).version;
116
+ return typeof version === "string" ? version : "unknown";
117
+ }
118
+ catch {
119
+ return "unknown";
120
+ }
121
+ }
@@ -0,0 +1,9 @@
1
+ export declare class DeployError extends Error {
2
+ }
3
+ export declare class DeployApiError extends DeployError {
4
+ readonly status: number;
5
+ readonly code: string;
6
+ readonly description: string;
7
+ readonly retryAfterSeconds: number | null;
8
+ constructor(status: number, code: string, description: string, retryAfterSeconds?: number | null);
9
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,27 @@
1
+ // Error types the deploy package throws. Messages are written to be shown to
2
+ // a person or agent as-is: plain English, naming the fix, no internal detail.
3
+ // A local problem (bad driggsby.json, an undeployable file) is a DeployError;
4
+ // a response the deploy API refused is a DeployApiError carrying the server's
5
+ // own user-facing description.
6
+ import { quotedForTerminal } from "./terminal-text.js";
7
+ export class DeployError extends Error {
8
+ }
9
+ export class DeployApiError extends DeployError {
10
+ status;
11
+ code;
12
+ description;
13
+ // Whole seconds from the response's Retry-After header, when it sent one.
14
+ retryAfterSeconds;
15
+ constructor(status, code, description, retryAfterSeconds = null) {
16
+ // The code is a server-supplied string, so the fallback message quotes
17
+ // and bounds it like every other text this package did not author (a
18
+ // real code is a short snake_case word).
19
+ super(description === ""
20
+ ? `The Driggsby deploy API refused this request (${quotedForTerminal(code, 40)}).`
21
+ : description);
22
+ this.status = status;
23
+ this.code = code;
24
+ this.description = description;
25
+ this.retryAfterSeconds = retryAfterSeconds;
26
+ }
27
+ }
@@ -0,0 +1,8 @@
1
+ export { DeployApiError, DeployError } from "./errors.ts";
2
+ export { PUBLIC_BASE_URL, isLoopbackBaseUrl, resolveBaseUrl } from "./base-url.ts";
3
+ export { MAX_FILE_BYTES, MAX_FILE_COUNT, MAX_PATH_LENGTH, MAX_TOTAL_BYTES, slugProblem, } from "./limits.ts";
4
+ export { type ProjectConfig, readProjectConfig, writeAssignedSlug } from "./project-config.ts";
5
+ export { type CollectedDeploy, type DeployFile, collectDeployFiles, formatBytes, } from "./manifest.ts";
6
+ export { type BlobsMissing, type CreatedApp, type CreatedVersion, type DeployApi, type FinalizedVersion, type ManifestFile, type VersionList, type VersionSummary, createApp, createVersion, finalizeVersion, listVersions, setLiveVersion, uploadBlob, } from "./client.ts";
7
+ export { type DeployOptions, type DeployOutcome, type ProjectDeployOptions, type ProjectDeployResult, deployCollectedFiles, deployProjectFiles, } from "./deploy.ts";
8
+ export { capForTerminal, quotedForTerminal, sanitizeForTerminal, wrapProse, } from "./terminal-text.ts";
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ // @driggsby/deploy: the Driggsby deploy protocol as a typed library.
2
+ // The driggsby CLI's deploy/rollback/versions commands build on these
3
+ // exports; Node-only sandboxes can use them directly.
4
+ export { DeployApiError, DeployError } from "./errors.js";
5
+ // The base-URL policy: which origin the token travels to, and what
6
+ // DRIGGSBY_BASE_URL may override it with. One implementation for both this
7
+ // package's bin and the driggsby CLI.
8
+ export { PUBLIC_BASE_URL, isLoopbackBaseUrl, resolveBaseUrl } from "./base-url.js";
9
+ export { MAX_FILE_BYTES, MAX_FILE_COUNT, MAX_PATH_LENGTH, MAX_TOTAL_BYTES, slugProblem, } from "./limits.js";
10
+ export { readProjectConfig, writeAssignedSlug } from "./project-config.js";
11
+ export { collectDeployFiles, formatBytes, } from "./manifest.js";
12
+ export { createApp, createVersion, finalizeVersion, listVersions, setLiveVersion, uploadBlob, } from "./client.js";
13
+ export { deployCollectedFiles, deployProjectFiles, } from "./deploy.js";
14
+ // Terminal-safety helpers for anything that echoes untrusted text (file
15
+ // names, server strings) into CLI output. See terminal-text.ts for what the
16
+ // sanitizer strips and the deliberate boundary of that defense.
17
+ export { capForTerminal, quotedForTerminal, sanitizeForTerminal, wrapProse, } from "./terminal-text.js";
@@ -0,0 +1,8 @@
1
+ export declare const MAX_FILE_BYTES = 26214400;
2
+ export declare const MAX_TOTAL_BYTES = 52428800;
3
+ export declare const MAX_FILE_COUNT = 2000;
4
+ export declare const MAX_PATH_LENGTH = 512;
5
+ export declare const PATH_SEGMENT_PATTERN: RegExp;
6
+ export declare const SLUG_PATTERN: RegExp;
7
+ export declare const SLUG_PUNYCODE_PATTERN: RegExp;
8
+ export declare function slugProblem(slug: string): string | null;
package/dist/limits.js ADDED
@@ -0,0 +1,28 @@
1
+ // Server-enforced deploy limits, mirrored client-side so a bad deploy fails
2
+ // fast with a clear local message instead of a round trip. Values match the
3
+ // Driggsby deploy API; the server remains the authority.
4
+ export const MAX_FILE_BYTES = 26_214_400; // 25 MiB per file (prints as 26.2 MB)
5
+ export const MAX_TOTAL_BYTES = 52_428_800; // 50 MiB per version (prints as 52.4 MB)
6
+ export const MAX_FILE_COUNT = 2_000;
7
+ export const MAX_PATH_LENGTH = 512;
8
+ // A path segment the deploy API accepts: ASCII letters, digits, dot, dash,
9
+ // underscore. No spaces, no path tricks, nothing percent-encoded.
10
+ export const PATH_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/;
11
+ // App slugs: 3-63 chars, lowercase letters/digits/dashes, no leading or
12
+ // trailing dash, and no "xn--"-style label (dashes in positions 3 and 4).
13
+ // There is no reserved-name list to mirror: Driggsby assigns every app's
14
+ // final address by adding a unique ending to the name you pick, so any
15
+ // readable name works and names never collide.
16
+ export const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/;
17
+ export const SLUG_PUNYCODE_PATTERN = /^..--/;
18
+ // Returns a human-readable problem with the slug, or null when it is valid.
19
+ export function slugProblem(slug) {
20
+ if (!SLUG_PATTERN.test(slug)) {
21
+ return ("an app slug must be 3-63 characters of lowercase letters, digits, and\n" +
22
+ "dashes, and must start and end with a letter or digit");
23
+ }
24
+ if (SLUG_PUNYCODE_PATTERN.test(slug)) {
25
+ return "an app slug can't have dashes in its third and fourth characters";
26
+ }
27
+ return null;
28
+ }
@@ -0,0 +1,21 @@
1
+ export interface DeployFile {
2
+ path: string;
3
+ absolutePath: string;
4
+ byteSize: number;
5
+ sha256: string;
6
+ }
7
+ export interface CollectedDeploy {
8
+ files: DeployFile[];
9
+ totalBytes: number;
10
+ skippedSymlinks: string[];
11
+ skippedNodeModules: string[];
12
+ }
13
+ export interface CollectOptions {
14
+ limitOverridesForTests?: {
15
+ maxFileBytes?: number;
16
+ maxTotalBytes?: number;
17
+ maxFileCount?: number;
18
+ };
19
+ }
20
+ export declare function collectDeployFiles(serveDirectory: string, options?: CollectOptions): Promise<CollectedDeploy>;
21
+ export declare function formatBytes(bytes: number): string;
@@ -0,0 +1,181 @@
1
+ // Walks the serve directory into the deploy manifest: every deployable file
2
+ // with its SHA-256 and byte size. The exclusions here are a safety boundary,
3
+ // not tidiness — the deploy API serves whatever it is given, so dotfiles
4
+ // (.env, .git) at any depth and the serve root's own driggsby.json must
5
+ // never leave this machine. (A nested driggsby.json is an ordinary file: it
6
+ // belongs to whatever subfolder carries it, not to this deploy.)
7
+ import { createHash } from "node:crypto";
8
+ import { createReadStream } from "node:fs";
9
+ import { readdir, stat } from "node:fs/promises";
10
+ import { join } from "node:path";
11
+ import { pipeline } from "node:stream/promises";
12
+ import { DeployError } from "./errors.js";
13
+ import { quotedForTerminal } from "./terminal-text.js";
14
+ import { MAX_FILE_BYTES, MAX_FILE_COUNT, MAX_PATH_LENGTH, MAX_TOTAL_BYTES, PATH_SEGMENT_PATTERN, } from "./limits.js";
15
+ export async function collectDeployFiles(serveDirectory, options = {}) {
16
+ const maxFileBytes = options.limitOverridesForTests?.maxFileBytes ?? MAX_FILE_BYTES;
17
+ const maxTotalBytes = options.limitOverridesForTests?.maxTotalBytes ?? MAX_TOTAL_BYTES;
18
+ const maxFileCount = options.limitOverridesForTests?.maxFileCount ?? MAX_FILE_COUNT;
19
+ const files = [];
20
+ const skippedSymlinks = [];
21
+ const skippedNodeModules = [];
22
+ // Count and total-size limits are enforced INSIDE the walk, before each
23
+ // file is hashed — a limit must fail as soon as it is crossed, not after
24
+ // reading and SHA-256ing hundreds of megabytes that were never going to
25
+ // deploy.
26
+ const limits = { maxFileBytes, maxTotalBytes, maxFileCount };
27
+ // silentlySkippedCount covers the entries the walk drops without reporting
28
+ // them to the caller (dotfiles, dot-directories, the top-level
29
+ // driggsby.json) — the empty-folder message below must still know they
30
+ // were there.
31
+ const tally = { totalBytes: 0, silentlySkippedCount: 0 };
32
+ await walkDirectory(serveDirectory, [], files, {
33
+ skippedSymlinks,
34
+ skippedNodeModules,
35
+ limits,
36
+ tally,
37
+ });
38
+ files.sort((a, b) => (a.path < b.path ? -1 : 1));
39
+ if (files.length === 0) {
40
+ // When the folder isn't actually empty, saying "has no files" would
41
+ // contradict what the user sees in front of them — name what happened.
42
+ if (skippedSymlinks.length > 0 ||
43
+ skippedNodeModules.length > 0 ||
44
+ tally.silentlySkippedCount > 0) {
45
+ throw new DeployError("There's nothing to deploy — everything in the serve folder was\n" +
46
+ "skipped (symlinks, node_modules, dotfiles, and driggsby.json itself\n" +
47
+ "never deploy). Add your app's files (starting with index.html) and\n" +
48
+ "deploy again.");
49
+ }
50
+ throw new DeployError("There's nothing to deploy — the serve folder has no files. Add your\n" +
51
+ "app's files (starting with index.html) and deploy again.");
52
+ }
53
+ requireIndexHtml(files);
54
+ const totalBytes = files.reduce((sum, file) => sum + file.byteSize, 0);
55
+ return { files, totalBytes, skippedSymlinks, skippedNodeModules };
56
+ }
57
+ async function walkDirectory(absoluteDirectory, segments, files, state) {
58
+ let entries;
59
+ try {
60
+ entries = await readdir(absoluteDirectory, { withFileTypes: true });
61
+ }
62
+ catch {
63
+ throw new DeployError(segments.length === 0
64
+ ? 'We couldn\'t read the serve folder named in driggsby.json. Check its\n"serve" value and try again.'
65
+ : `We couldn't read the folder ${quotedForTerminal(segments.join("/"), 80)} inside the\nserve folder. Check it and try again.`);
66
+ }
67
+ for (const entry of entries) {
68
+ // Dotfiles and dot-directories never deploy: they are where secrets and
69
+ // repo internals live (.env, .git), and the deployed app serves every
70
+ // uploaded file to the internet.
71
+ if (entry.name.startsWith(".")) {
72
+ state.tally.silentlySkippedCount += 1;
73
+ continue;
74
+ }
75
+ if (segments.length === 0 && entry.name === "driggsby.json") {
76
+ state.tally.silentlySkippedCount += 1;
77
+ continue;
78
+ }
79
+ const relativePath = [...segments, entry.name].join("/");
80
+ // Symlinks never deploy: following one would publish content from
81
+ // outside the serve folder. (Hard links are indistinguishable from
82
+ // regular files and deploy normally — build tools legitimately
83
+ // hard-link outputs, and anyone able to create one inside this folder
84
+ // could just as well copy the file here.)
85
+ if (entry.isSymbolicLink()) {
86
+ state.skippedSymlinks.push(relativePath);
87
+ continue;
88
+ }
89
+ if (entry.isDirectory()) {
90
+ // node_modules never deploys: Driggsby apps are served as static
91
+ // files, so a dependency tree is dev-machine clutter that would blow
92
+ // the file-count limit — and its scoped-package folders (@types, ...)
93
+ // would otherwise trip the character check with a "rename it" message
94
+ // that is exactly wrong for the most common first-run mistake
95
+ // (pointing "serve" at a project root).
96
+ if (entry.name === "node_modules") {
97
+ state.skippedNodeModules.push(relativePath);
98
+ continue;
99
+ }
100
+ validateSegment(entry.name, relativePath, segments.length === 0);
101
+ await walkDirectory(join(absoluteDirectory, entry.name), [...segments, entry.name], files, state);
102
+ continue;
103
+ }
104
+ if (!entry.isFile()) {
105
+ continue;
106
+ }
107
+ validateSegment(entry.name, relativePath, segments.length === 0);
108
+ if (relativePath.length > MAX_PATH_LENGTH) {
109
+ throw new DeployError(`One file's path is too long to deploy (over ${MAX_PATH_LENGTH} characters):\n` +
110
+ `${quotedForTerminal(relativePath, 80)}...\nShorten the folder or file names and deploy again.`);
111
+ }
112
+ const absolutePath = join(absoluteDirectory, entry.name);
113
+ const info = await stat(absolutePath);
114
+ if (info.size > state.limits.maxFileBytes) {
115
+ throw new DeployError(`${quotedForTerminal(relativePath, 80)} is ${formatBytes(info.size)}, and a single file can\n` +
116
+ `be at most ${formatBytes(state.limits.maxFileBytes)}. Shrink or remove it and deploy again.`);
117
+ }
118
+ if (files.length >= state.limits.maxFileCount) {
119
+ throw new DeployError(`This deploy has more than ${state.limits.maxFileCount} files, and that's as many as a\n` +
120
+ "Driggsby app can hold. Remove what the app doesn't need and deploy\nagain.");
121
+ }
122
+ state.tally.totalBytes += info.size;
123
+ if (state.tally.totalBytes > state.limits.maxTotalBytes) {
124
+ throw new DeployError(`This deploy is over ${formatBytes(state.limits.maxTotalBytes)} in total, and that's as much as a\n` +
125
+ "Driggsby app can hold. Slim it down and deploy again.");
126
+ }
127
+ files.push({
128
+ path: relativePath,
129
+ absolutePath,
130
+ byteSize: info.size,
131
+ sha256: await sha256File(absolutePath),
132
+ });
133
+ }
134
+ }
135
+ function validateSegment(name, relativePath, isTopLevel) {
136
+ // "-" is a valid character but a reserved top-level name: Driggsby serves
137
+ // its own pages under the app's /-/ namespace. Deeper down, "-" is an
138
+ // ordinary name the deploy API accepts.
139
+ if (isTopLevel && name === "-") {
140
+ throw new DeployError(`${quotedForTerminal(relativePath, 80)} can't be deployed: "-" is a name Driggsby reserves\n` +
141
+ "for its own pages on your app's address. Rename it and deploy again.");
142
+ }
143
+ if (!PATH_SEGMENT_PATTERN.test(name) || name === "." || name === "..") {
144
+ throw new DeployError(`${quotedForTerminal(relativePath, 80)} can't be deployed: file and folder names may only\n` +
145
+ "use letters, digits, dots, dashes, and underscores (no spaces). Rename\n" +
146
+ "it and deploy again.");
147
+ }
148
+ }
149
+ function requireIndexHtml(files) {
150
+ if (files.some((file) => file.path === "index.html")) {
151
+ return;
152
+ }
153
+ const hasPackageJson = files.some((file) => file.path === "package.json");
154
+ const buildHint = hasPackageJson
155
+ ? "\n\nThis looks like a project that needs a build step. Run your build, then\n" +
156
+ 'point "serve" in driggsby.json at the build output folder (often "dist"\n' +
157
+ 'or "build") — Driggsby apps deploy as static files.'
158
+ : "";
159
+ throw new DeployError("The serve folder has no index.html at its top level, so the deployed app\n" +
160
+ "would show an empty page. Add an index.html (or point \"serve\" in\n" +
161
+ `driggsby.json at the folder that has one) and deploy again.${buildHint}`);
162
+ }
163
+ async function sha256File(absolutePath) {
164
+ const hash = createHash("sha256");
165
+ await pipeline(createReadStream(absolutePath), hash);
166
+ return hash.digest("hex");
167
+ }
168
+ export function formatBytes(bytes) {
169
+ if (bytes < 1_000) {
170
+ return `${bytes} B`;
171
+ }
172
+ if (bytes < 1_000_000) {
173
+ return `${trimmedFixed(bytes / 1_000)} KB`;
174
+ }
175
+ return `${trimmedFixed(bytes / 1_000_000)} MB`;
176
+ }
177
+ // 6.2, but 42 (not 42.0) — one decimal only when it says something.
178
+ function trimmedFixed(value) {
179
+ const rounded = value >= 100 ? Math.round(value).toString() : value.toFixed(1);
180
+ return rounded.endsWith(".0") ? rounded.slice(0, -2) : rounded;
181
+ }
@@ -0,0 +1,8 @@
1
+ export interface ProjectConfig {
2
+ slug: string;
3
+ serveDirectory: string;
4
+ serve: string;
5
+ devCommand: string | null;
6
+ }
7
+ export declare function readProjectConfig(projectDirectory: string): Promise<ProjectConfig>;
8
+ export declare function writeAssignedSlug(projectDirectory: string, assignedSlug: string): Promise<void>;