@driggsby/deploy 0.0.1 → 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/LICENSE +117 -0
- package/README.md +64 -2
- package/bin/driggsby-deploy.js +2 -0
- package/dist/base-url.d.ts +3 -0
- package/dist/base-url.js +46 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +16 -0
- package/dist/client.d.ts +52 -0
- package/dist/client.js +292 -0
- package/dist/deploy.d.ts +28 -0
- package/dist/deploy.js +188 -0
- package/dist/entrypoint.d.ts +8 -0
- package/dist/entrypoint.js +121 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +27 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +17 -0
- package/dist/limits.d.ts +8 -0
- package/dist/limits.js +28 -0
- package/dist/manifest.d.ts +21 -0
- package/dist/manifest.js +181 -0
- package/dist/project-config.d.ts +8 -0
- package/dist/project-config.js +145 -0
- package/dist/terminal-text.d.ts +4 -0
- package/dist/terminal-text.js +106 -0
- package/package.json +30 -3
|
@@ -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
|
|
4
|
-
"description": "Deploy static
|
|
5
|
-
"license": "
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
}
|