@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.
@@ -0,0 +1,145 @@
1
+ // Reads and validates the project's driggsby.json. This is the one file that
2
+ // names the app (slug) and what to publish (serve). Validation mirrors the
3
+ // deploy API's slug rules so mistakes fail fast, locally, with the fix.
4
+ import { readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
5
+ import { randomUUID } from "node:crypto";
6
+ import { join, relative, resolve, isAbsolute } from "node:path";
7
+ import { DeployError } from "./errors.js";
8
+ import { slugProblem } from "./limits.js";
9
+ import { quotedForTerminal } from "./terminal-text.js";
10
+ const MISSING_CONFIG_MESSAGE = "No driggsby.json found in this directory. Create one next to the files you\n" +
11
+ "want to deploy:\n\n" +
12
+ ' { "slug": "your-app-name", "serve": "." }\n\n' +
13
+ '"slug" names your app — the first deploy gives it a unique address like\n' +
14
+ 'your-app-name-x7k2qf.driggsby.dev and saves that assigned name back into\n' +
15
+ 'this file — and "serve" is the folder to publish, relative to\n' +
16
+ "driggsby.json.";
17
+ export async function readProjectConfig(projectDirectory) {
18
+ const configPath = join(projectDirectory, "driggsby.json");
19
+ let raw;
20
+ try {
21
+ raw = await readFile(configPath, "utf8");
22
+ }
23
+ catch {
24
+ throw new DeployError(MISSING_CONFIG_MESSAGE);
25
+ }
26
+ let parsed;
27
+ try {
28
+ parsed = JSON.parse(raw);
29
+ }
30
+ catch {
31
+ throw new DeployError("driggsby.json isn't valid JSON. It should look like:\n\n" +
32
+ ' { "slug": "your-app-name", "serve": "." }');
33
+ }
34
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
35
+ throw new DeployError('driggsby.json must be a JSON object with a "slug" field.');
36
+ }
37
+ const config = parsed;
38
+ const slug = config.slug;
39
+ if (typeof slug !== "string" || slug === "") {
40
+ throw new DeployError('driggsby.json is missing its "slug" field. Add one:\n\n' +
41
+ ' { "slug": "your-app-name", "serve": "." }');
42
+ }
43
+ const problem = slugProblem(slug);
44
+ if (problem !== null) {
45
+ throw new DeployError(`The "slug" in driggsby.json won't work: ${problem}.`);
46
+ }
47
+ // Only a genuinely absent "serve" defaults to "." — `?? "."` would let an
48
+ // explicit `"serve": null` silently publish the whole project root, while
49
+ // every other malformed value fails closed below.
50
+ const serve = config.serve === undefined ? "." : config.serve;
51
+ if (typeof serve !== "string" || serve === "") {
52
+ throw new DeployError('The "serve" field in driggsby.json must be a folder path relative to\n' +
53
+ 'driggsby.json, like "." or "dist".');
54
+ }
55
+ const serveDirectory = await resolveServeDirectory(projectDirectory, serve);
56
+ // dev_command is attacker-controlled text from a cloned or generated
57
+ // driggsby.json. Nothing executes it today; any future consumer must spawn
58
+ // it without a shell and never let it reach one.
59
+ const devCommand = config.dev_command;
60
+ return {
61
+ slug,
62
+ serveDirectory,
63
+ serve,
64
+ devCommand: typeof devCommand === "string" && devCommand !== "" ? devCommand : null,
65
+ };
66
+ }
67
+ // Persists the server-assigned slug into driggsby.json, keeping every other
68
+ // field as-is. This runs after the app is created and BEFORE any bytes
69
+ // upload, so a deploy that dies mid-upload never strands an app whose name
70
+ // the project forgot. A failed write aborts the deploy with the manual fix,
71
+ // because deploying on without saving the name would create a second app on
72
+ // the next run.
73
+ export async function writeAssignedSlug(projectDirectory, assignedSlug) {
74
+ const configPath = join(projectDirectory, "driggsby.json");
75
+ let config;
76
+ try {
77
+ const parsed = JSON.parse(await readFile(configPath, "utf8"));
78
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
79
+ throw new Error("driggsby.json is not a JSON object");
80
+ }
81
+ config = parsed;
82
+ config.slug = assignedSlug;
83
+ // Temp-file-plus-rename, so a crash mid-write can never leave a
84
+ // truncated driggsby.json holding half of a name only the server knows.
85
+ // The temp name starts with a dot: the deploy walk excludes dotfiles at
86
+ // every depth, so a leftover from a crash between write and rename can
87
+ // never be swept into a later deploy and published.
88
+ const temporaryPath = join(projectDirectory, `.driggsby.json.${randomUUID()}.tmp`);
89
+ try {
90
+ await writeFile(temporaryPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
91
+ await rename(temporaryPath, configPath);
92
+ }
93
+ catch (error) {
94
+ await rm(temporaryPath, { force: true });
95
+ throw error;
96
+ }
97
+ }
98
+ catch {
99
+ // The assigned slug came from the server, so it prints through
100
+ // quotedForTerminal like every other string this package did not author.
101
+ throw new DeployError(`Driggsby created your app as ${quotedForTerminal(assignedSlug, 80)}, but we couldn't save\n` +
102
+ 'that name into driggsby.json. Update its "slug" field to exactly that\n' +
103
+ "value, then deploy again.");
104
+ }
105
+ }
106
+ const SERVE_ESCAPES_PROJECT_MESSAGE = 'The "serve" folder in driggsby.json must stay inside the project —\n' +
107
+ 'a value like "." or "dist", not a path outside it.';
108
+ // Everything under the serve folder is uploaded and served publicly, so the
109
+ // stay-inside-the-project boundary must hold on real paths, not just the
110
+ // written value: checking the string alone would let a serve folder that is
111
+ // (or sits under) a symlink pointing outside the project leak outside files
112
+ // into the deploy. A serve folder that doesn't exist yet skips the realpath
113
+ // check — versions and rollback never read it, and the deploy walk reports
114
+ // that case with its own message. The returned path stays as written (only
115
+ // the check uses real paths), so paths shown to the user match their config.
116
+ async function resolveServeDirectory(projectDirectory, serve) {
117
+ const projectRoot = resolve(projectDirectory);
118
+ const serveDirectory = resolve(projectRoot, serve);
119
+ if (escapesRoot(relative(projectRoot, serveDirectory))) {
120
+ throw new DeployError(SERVE_ESCAPES_PROJECT_MESSAGE);
121
+ }
122
+ let realProjectRoot;
123
+ let realServeDirectory;
124
+ try {
125
+ realProjectRoot = await realpath(projectRoot);
126
+ realServeDirectory = await realpath(serveDirectory);
127
+ }
128
+ catch (error) {
129
+ // Only "it doesn't exist yet" may skip the check; any other resolution
130
+ // failure (permissions, symlink loops) fails closed rather than walking
131
+ // an unverified path.
132
+ if (error.code === "ENOENT") {
133
+ return serveDirectory;
134
+ }
135
+ throw new DeployError('We couldn\'t check the "serve" folder named in driggsby.json. Check its\n' +
136
+ '"serve" value and try again.');
137
+ }
138
+ if (escapesRoot(relative(realProjectRoot, realServeDirectory))) {
139
+ throw new DeployError(SERVE_ESCAPES_PROJECT_MESSAGE);
140
+ }
141
+ return serveDirectory;
142
+ }
143
+ function escapesRoot(relativePath) {
144
+ return relativePath.startsWith("..") || isAbsolute(relativePath);
145
+ }
@@ -0,0 +1,4 @@
1
+ export declare function sanitizeForTerminal(value: string): string;
2
+ export declare function capForTerminal(value: string, maxChars: number): string;
3
+ export declare function quotedForTerminal(value: string, maxChars: number): string;
4
+ export declare function wrapProse(text: string, width?: number): string;
@@ -0,0 +1,106 @@
1
+ // File and folder names from the serve tree get echoed back in error
2
+ // messages, and terminals interpret control bytes (ANSI/OSC escapes,
3
+ // carriage returns, C1 controls), while bidi or zero-width characters can
4
+ // visually reorder or hide parts of a line. Strip them before interpolating
5
+ // any name this package did not author -- an agent-generated or cloned app
6
+ // folder is a realistic path for such bytes to reach a terminal. This file
7
+ // is the single home of these helpers: the driggsby CLI re-exports them
8
+ // (this package itself ships with zero dependencies).
9
+ //
10
+ // The deliberate boundary of this defense: stripping stops untrusted text
11
+ // from corrupting the terminal (escapes, control bytes) or hiding content
12
+ // (zero-width, bidi, default-ignorable code points). It does NOT try to
13
+ // stop a reader from trusting VISIBLE words inside a filename or server
14
+ // string -- no output transform can, because the text is displayed either
15
+ // way. Callers make provenance obvious instead: untrusted values print
16
+ // inside quotes with a hard length cap (quotedForTerminal), so they read as
17
+ // names, not as the CLI's own voice.
18
+ // The strip set covers C0/DEL/C1 controls, U+061C ARABIC LETTER MARK (the
19
+ // remaining Bidi_Control code point), zero-width joiners/marks, the
20
+ // deprecated bidi embeddings/overrides, and the bidi isolates.
21
+ // C0 whitespace (tab, newlines, carriage return) and the Unicode line and
22
+ // paragraph separators (U+2028/U+2029, which some renderers break on)
23
+ // become a space rather than vanishing: deleting a newline glues its
24
+ // neighboring words together ("tryagain"), while a space keeps the text
25
+ // readable and lets wrapProse re-flow it. The strip set also drops the
26
+ // soft hyphen, the Mongolian vowel separator, the interlinear annotation
27
+ // controls (U+FFF9-U+FFFB), the word joiner and invisible operators
28
+ // (U+2060-U+2069, which also covers the bidi isolates), and U+FEFF
29
+ // (ZWNBSP/BOM). The final pass strips Unicode's ENTIRE default-ignorable
30
+ // set -- the Tags block, variation selectors, Mongolian selectors, musical
31
+ // format controls, Hangul fillers, and friends -- because enumerated
32
+ // blocks kept leaving smuggling channels for invisible instructions into
33
+ // an agent's transcript; the property escape closes the whole class at
34
+ // once. (This also strips U+FE0F, so emoji lose their explicit
35
+ // presentation selector -- an acceptable cost.) Invisible or
36
+ // renderer-dependent code points have no place in CLI output.
37
+ export function sanitizeForTerminal(value) {
38
+ return (value
39
+ .replace(/[\t\n\v\f\r\u2028\u2029]/g, " ")
40
+ // eslint-disable-next-line no-control-regex
41
+ .replace(/[\u0000-\u001F\u007F-\u009F\u00AD\u061C\u180E\u200B-\u200F\u202A-\u202E\u2060-\u2069\uFEFF\uFFF9-\uFFFB]/g, "")
42
+ .replace(/\p{Default_Ignorable_Code_Point}/gu, ""));
43
+ }
44
+ // Sanitize plus a hard length bound, dropping an orphaned trailing high
45
+ // surrogate so the cut never emits a lone half of a surrogate pair. For any
46
+ // untrusted string headed to a terminal whose legitimate length is known.
47
+ export function capForTerminal(value, maxChars) {
48
+ return sanitizeForTerminal(value).slice(0, maxChars).replace(/[\uD800-\uDBFF]$/, "");
49
+ }
50
+ // capForTerminal with the provenance quotes built in. The quotes are what
51
+ // mark a value as untrusted, so the helper owns them: a double quote inside
52
+ // the value (or one produced by capping) would close the quotation early and
53
+ // let the rest of the payload read as the CLI's own sentence. Double quotes
54
+ // become single quotes — lossless enough for a name — so the rendered value
55
+ // always carries exactly one balanced pair. Every untrusted value printed
56
+ // inside quotes goes through here, never through hand-placed quote marks.
57
+ export function quotedForTerminal(value, maxChars) {
58
+ return `"${capForTerminal(value, maxChars).replaceAll('"', "'")}"`;
59
+ }
60
+ // Hard-wraps one paragraph of prose at the given width so lines built from
61
+ // dynamic parts (server-supplied error descriptions) never overflow an
62
+ // 80-column terminal. A single word longer than the width is hard-split at
63
+ // the width -- a space-free server string must not become one overflowing
64
+ // line. The input must be a single paragraph with no embedded newlines --
65
+ // only U+0020 spaces are treated as break points.
66
+ export function wrapProse(text, width = 76) {
67
+ const lines = [];
68
+ let line = "";
69
+ for (const word of text.split(" ").flatMap((long) => splitLongWord(long, width))) {
70
+ if (line === "") {
71
+ line = word;
72
+ }
73
+ else if (line.length + 1 + word.length <= width) {
74
+ line = `${line} ${word}`;
75
+ }
76
+ else {
77
+ lines.push(line);
78
+ line = word;
79
+ }
80
+ }
81
+ if (line !== "") {
82
+ lines.push(line);
83
+ }
84
+ return lines.join("\n");
85
+ }
86
+ function splitLongWord(word, width) {
87
+ if (word.length <= width) {
88
+ return [word];
89
+ }
90
+ // Split by code point, not code unit: a surrogate pair (an emoji in a
91
+ // server error description) straddling the boundary must never be cut
92
+ // into two lone surrogates.
93
+ const pieces = [];
94
+ let piece = "";
95
+ for (const codePoint of word) {
96
+ if (piece.length + codePoint.length > width) {
97
+ pieces.push(piece);
98
+ piece = "";
99
+ }
100
+ piece += codePoint;
101
+ }
102
+ if (piece !== "") {
103
+ pieces.push(piece);
104
+ }
105
+ return pieces;
106
+ }
package/package.json CHANGED
@@ -1,10 +1,37 @@
1
1
  {
2
2
  "name": "@driggsby/deploy",
3
- "version": "0.0.1",
4
- "description": "Deploy static apps to Driggsby. This version is a name reservation; the real package arrives with the next release.",
5
- "license": "MIT",
3
+ "version": "0.2.1",
4
+ "description": "Deploy static Driggsby apps: the deploy protocol client used by the driggsby CLI.",
5
+ "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/driggsby/driggsby.git"
9
+ },
10
+ "homepage": "https://driggsby.com",
11
+ "type": "module",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ }
17
+ },
18
+ "bin": {
19
+ "driggsby-deploy": "bin/driggsby-deploy.js"
20
+ },
21
+ "files": [
22
+ "bin",
23
+ "dist",
24
+ "!dist/.tsbuildinfo",
25
+ "LICENSE",
26
+ "README.md"
27
+ ],
28
+ "scripts": {
29
+ "build": "tsc -p tsconfig.build.json",
30
+ "typecheck": "tsc -p tsconfig.json --noEmit",
31
+ "pretest": "npm run build",
32
+ "test": "node --test \"src/**/*.test.ts\""
33
+ },
34
+ "engines": {
35
+ "node": ">=18"
9
36
  }
10
37
  }