sproutboat 0.53.1 → 0.55.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/CHANGELOG.md +69 -1
- package/README.md +122 -9
- package/SURFACE.md +42 -22
- package/package.json +20 -12
- package/scripts/embed-templates.ts +87 -0
- package/src/account-field.ts +100 -0
- package/src/build.ts +13 -2
- package/src/cloud-bindings.ts +227 -0
- package/src/cloud-deploy.ts +794 -0
- package/src/cloud-domains.ts +55 -0
- package/src/cloud-fake.ts +719 -0
- package/src/cloud-secrets.ts +121 -0
- package/src/cloud.ts +375 -0
- package/src/compile.ts +34 -18
- package/src/credentials.ts +455 -52
- package/src/d1-cli.ts +455 -0
- package/src/data-errors.ts +139 -0
- package/src/deployments-cli.ts +184 -0
- package/src/dev.ts +67 -4
- package/src/fake-login.ts +28 -0
- package/src/global-flags.ts +113 -0
- package/src/init-package.ts +2 -1
- package/src/init.ts +328 -0
- package/src/main.ts +1247 -193
- package/src/options.ts +242 -0
- package/src/r2-objects.ts +365 -0
- package/src/run.ts +12 -6
- package/src/secrets-bulk.ts +51 -0
- package/src/sql-split.ts +68 -0
- package/src/standalone-build.ts +21 -3
- package/src/surface.ts +192 -64
- package/src/target.ts +95 -0
- package/src/templates-embedded.ts +72 -0
- package/src/templates.ts +53 -0
- package/src/toolchain.ts +3 -0
- package/src/types-cmd.ts +193 -0
- package/src/types-gen.ts +104 -68
- package/types/cloudflare-workers.d.ts +43 -0
- package/types/sproutboat.d.ts +160 -3
package/src/options.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each command accepts after the global flags are out (global-flags.ts), checked once, before the command runs.
|
|
3
|
+
* A flag a command does not know is an error, never a quiet no-op: a typo or a flag from another tool must not turn
|
|
4
|
+
* into a different action (`deploy --api-url=<url>` used to be ignored and deployed to the saved endpoint).
|
|
5
|
+
*
|
|
6
|
+
* `--flag=value` is rewritten to `--flag value`, so every command parses one form. A positional that looks like a
|
|
7
|
+
* URL is refused for the commands that take a project directory or a name, so `deploy https://dash.example.com` is
|
|
8
|
+
* never read as a folder.
|
|
9
|
+
*
|
|
10
|
+
* The table is documentation too: `surface.test.ts` fails when a flag in a command's grammar (surface.ts) is not here.
|
|
11
|
+
*/
|
|
12
|
+
export type Options = {
|
|
13
|
+
/** Flags that take the next argument (or `=value`) as their value. */
|
|
14
|
+
readonly value?: readonly string[];
|
|
15
|
+
/** Flags that stand alone. */
|
|
16
|
+
readonly flag?: readonly string[];
|
|
17
|
+
/**
|
|
18
|
+
* Subcommands with options of their own, keyed by their leading words (`"execute"`, `"migrations apply"`): when the
|
|
19
|
+
* arguments start with one, its entry is what is checked, and the command's own `value`/`flag` are not.
|
|
20
|
+
* Arguments that start with none of them are checked against the command's own options.
|
|
21
|
+
*/
|
|
22
|
+
readonly sub?: Readonly<Record<string, Options>>;
|
|
23
|
+
/**
|
|
24
|
+
* The command's subcommands parse (and refuse) their own options, so only the plain verbs are checked, where the
|
|
25
|
+
* command itself does it (`storage` in main.ts).
|
|
26
|
+
*/
|
|
27
|
+
readonly own?: true;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const COMMAND_OPTIONS = {
|
|
31
|
+
// As in create-cloudflare: --template picks a starter, --git and --install (and their --no-) decide the two steps
|
|
32
|
+
// after scaffolding, -y takes every default. --list-templates prints the starters and exits.
|
|
33
|
+
init: {
|
|
34
|
+
value: ["--template"],
|
|
35
|
+
flag: ["--list-templates", "--git", "--no-git", "--install", "--no-install", "--yes", "-y", "--accept-defaults"],
|
|
36
|
+
},
|
|
37
|
+
check: {},
|
|
38
|
+
// The booleans take `--strict-vars=false` as in Wrangler (types-cmd.ts reads the pair); `--no-...` is the short way.
|
|
39
|
+
types: {
|
|
40
|
+
value: ["--env-interface", "--strict-vars", "--include-runtime", "--include-env"],
|
|
41
|
+
flag: ["--check", "--no-strict-vars", "--no-include-runtime", "--no-include-env"],
|
|
42
|
+
},
|
|
43
|
+
dev: { value: ["--port", "--ip"], flag: ["--no-watch"] },
|
|
44
|
+
run: { value: ["--binary", "--port", "--data-dir", "--require"], flag: ["--check"] },
|
|
45
|
+
build: {
|
|
46
|
+
value: ["--target"],
|
|
47
|
+
flag: ["--standalone", "--release", "--deploy-files", "--with-restore", "--emit-only"],
|
|
48
|
+
},
|
|
49
|
+
toolchain: { flag: ["--json"] },
|
|
50
|
+
deploy: {
|
|
51
|
+
value: ["--account", "--artifact", "--module", "--sproutd", "--generation", "--secrets-file"],
|
|
52
|
+
flag: ["--dry-run", "--no-wait", "--no-provision"],
|
|
53
|
+
},
|
|
54
|
+
versions: {},
|
|
55
|
+
deployments: {
|
|
56
|
+
sub: {
|
|
57
|
+
list: { value: ["--account"], flag: ["--json"] },
|
|
58
|
+
status: { value: ["--account"], flag: ["--json"] },
|
|
59
|
+
tail: { value: ["--account"] },
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
rollback: {},
|
|
63
|
+
tail: {
|
|
64
|
+
value: ["--format", "--status", "--method", "--sampling-rate", "--search", "--outcome", "--file", "--lines"],
|
|
65
|
+
flag: ["--recent", "--sprout", "--no-follow"],
|
|
66
|
+
},
|
|
67
|
+
delete: { value: ["--name"], flag: ["--yes", "-y"] },
|
|
68
|
+
domains: { value: ["--account"] },
|
|
69
|
+
// `secret` is the same command (surface.ts); `list | put | delete | bulk` share these, `--value` is put's.
|
|
70
|
+
secrets: { value: ["--value", "--account"], flag: ["--no-deploy"] },
|
|
71
|
+
kv: {
|
|
72
|
+
value: ["--output", "--path", "--prefix", "--cursor", "--limit"],
|
|
73
|
+
flag: ["--force", "--text", "--yes"],
|
|
74
|
+
},
|
|
75
|
+
d1: {
|
|
76
|
+
sub: {
|
|
77
|
+
execute: {
|
|
78
|
+
value: ["--command", "--file", "--persist-to", "--account"],
|
|
79
|
+
flag: ["--json", "--local", "--remote", "--yes", "-y"],
|
|
80
|
+
},
|
|
81
|
+
"migrations create": {},
|
|
82
|
+
"migrations list": { value: ["--persist-to", "--account"], flag: ["--local", "--remote"] },
|
|
83
|
+
"migrations apply": { value: ["--persist-to", "--account"], flag: ["--local", "--remote"] },
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
// The Wrangler flags that are not supported yet (--local, --jurisdiction, -J, --storage-class, -s, --persist-to) are
|
|
87
|
+
// listed so the command answers with its own reason instead of "unknown option".
|
|
88
|
+
r2: {
|
|
89
|
+
sub: {
|
|
90
|
+
"object get": {
|
|
91
|
+
value: ["--file", "-f", "--account", "--jurisdiction", "-J", "--storage-class", "-s", "--persist-to"],
|
|
92
|
+
flag: ["--pipe", "-p", "--remote", "--local"],
|
|
93
|
+
},
|
|
94
|
+
"object put": {
|
|
95
|
+
value: [
|
|
96
|
+
"--file",
|
|
97
|
+
"-f",
|
|
98
|
+
"--content-type",
|
|
99
|
+
"--ct",
|
|
100
|
+
"--content-disposition",
|
|
101
|
+
"--cd",
|
|
102
|
+
"--content-encoding",
|
|
103
|
+
"--ce",
|
|
104
|
+
"--content-language",
|
|
105
|
+
"--cl",
|
|
106
|
+
"--cache-control",
|
|
107
|
+
"--cc",
|
|
108
|
+
"--expires",
|
|
109
|
+
"--account",
|
|
110
|
+
"--jurisdiction",
|
|
111
|
+
"-J",
|
|
112
|
+
"--storage-class",
|
|
113
|
+
"-s",
|
|
114
|
+
"--persist-to",
|
|
115
|
+
],
|
|
116
|
+
flag: ["--pipe", "-p", "--remote", "--local"],
|
|
117
|
+
},
|
|
118
|
+
"object delete": {
|
|
119
|
+
value: ["--account", "--jurisdiction", "-J", "--storage-class", "-s", "--persist-to"],
|
|
120
|
+
flag: ["--remote", "--local"],
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
queues: {},
|
|
125
|
+
login: { value: ["--token", "--account"] },
|
|
126
|
+
logout: { value: ["--account"] },
|
|
127
|
+
whoami: { value: ["--account"] },
|
|
128
|
+
profile: {},
|
|
129
|
+
complete: {},
|
|
130
|
+
} as const satisfies Record<string, Options>;
|
|
131
|
+
|
|
132
|
+
/** The commands that talk to a control plane, so `--api-url` means something to them. */
|
|
133
|
+
export const ENDPOINT_COMMANDS: ReadonlySet<string> = new Set([
|
|
134
|
+
"deploy",
|
|
135
|
+
"versions",
|
|
136
|
+
"deployments",
|
|
137
|
+
"rollback",
|
|
138
|
+
"tail",
|
|
139
|
+
"delete",
|
|
140
|
+
"domains",
|
|
141
|
+
"secrets",
|
|
142
|
+
"kv",
|
|
143
|
+
"d1",
|
|
144
|
+
"r2",
|
|
145
|
+
"queues",
|
|
146
|
+
"login",
|
|
147
|
+
"logout",
|
|
148
|
+
"whoami",
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
/** Commands whose positionals are never URL-valued (`kv key put <key> <value>` may take one, so `kv` is not here). */
|
|
152
|
+
const URL_REFUSED = new Set(Object.keys(COMMAND_OPTIONS).filter((name) => name !== "kv"));
|
|
153
|
+
|
|
154
|
+
const URL_WITH_SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
155
|
+
|
|
156
|
+
export type OptionsCheck = { args: string[] } | { error: string };
|
|
157
|
+
|
|
158
|
+
const flagsOf = (spec: Options) => [...(spec.value ?? []), ...(spec.flag ?? [])];
|
|
159
|
+
|
|
160
|
+
/** The subcommand's options when `args` start with one of the command's `sub` entries (one or two words). */
|
|
161
|
+
function subOptions(spec: Options, args: readonly string[]): Options | undefined {
|
|
162
|
+
if (!spec.sub) return undefined;
|
|
163
|
+
const named = [args.slice(0, 2).join(" "), args.slice(0, 1).join(" ")];
|
|
164
|
+
for (const key of named) if (Object.hasOwn(spec.sub, key)) return spec.sub[key];
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Every flag a command takes in any of its forms: its own and its subcommands'. */
|
|
169
|
+
export function allFlags(spec: Options): string[] {
|
|
170
|
+
return [...new Set([...flagsOf(spec), ...Object.values(spec.sub ?? {}).flatMap(flagsOf)])];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The closest known flag to a mistyped one, when one is near enough to be a typo. */
|
|
174
|
+
function suggest(name: string, known: readonly string[]): string | undefined {
|
|
175
|
+
const distance = (a: string, b: string) => {
|
|
176
|
+
const row = Array.from({ length: b.length + 1 }, (_, at) => at);
|
|
177
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
178
|
+
let diagonal = row[0]!;
|
|
179
|
+
row[0] = i;
|
|
180
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
181
|
+
const above = row[j]!;
|
|
182
|
+
row[j] = Math.min(row[j]! + 1, row[j - 1]! + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
183
|
+
diagonal = above;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return row[b.length]!;
|
|
187
|
+
};
|
|
188
|
+
const best = known
|
|
189
|
+
.map((candidate) => ({ candidate, away: distance(name, candidate) }))
|
|
190
|
+
.sort((left, right) => left.away - right.away)[0];
|
|
191
|
+
return best && best.away <= 2 ? best.candidate : undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Check `args` (what is left after the global flags) for `command`. Returns them with `--flag=value` split in two, or
|
|
196
|
+
* the first problem. A command that is not in the table is not checked here.
|
|
197
|
+
*/
|
|
198
|
+
export function checkOptions(command: string, args: readonly string[]): OptionsCheck {
|
|
199
|
+
if (!Object.hasOwn(COMMAND_OPTIONS, command)) return { args: [...args] };
|
|
200
|
+
// SAFETY: `command` is a key of the table, checked above.
|
|
201
|
+
const top: Options = COMMAND_OPTIONS[command as keyof typeof COMMAND_OPTIONS];
|
|
202
|
+
if (top.own) return { args: [...args] };
|
|
203
|
+
const spec = subOptions(top, args) ?? top;
|
|
204
|
+
const values = new Set(spec.value);
|
|
205
|
+
const flags = new Set(spec.flag);
|
|
206
|
+
const out: string[] = [];
|
|
207
|
+
for (let at = 0; at < args.length; at += 1) {
|
|
208
|
+
const arg = args[at]!;
|
|
209
|
+
if (arg === "--") {
|
|
210
|
+
out.push(...args.slice(at));
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
if (!arg.startsWith("-") || arg === "-") {
|
|
214
|
+
if (URL_REFUSED.has(command) && URL_WITH_SCHEME.test(arg))
|
|
215
|
+
return {
|
|
216
|
+
error: `"${arg}" looks like a URL, not a project directory or a name. To pick the control plane use --api-url <url>, --profile <name> or SPROUTBOAT_API_URL`,
|
|
217
|
+
};
|
|
218
|
+
out.push(arg);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const equals = arg.startsWith("--") ? arg.indexOf("=") : -1;
|
|
222
|
+
const name = equals > 0 ? arg.slice(0, equals) : arg;
|
|
223
|
+
if (values.has(name)) {
|
|
224
|
+
out.push(name);
|
|
225
|
+
if (equals > 0) out.push(arg.slice(equals + 1));
|
|
226
|
+
else if (at + 1 < args.length) out.push(args[(at += 1)]!);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (flags.has(name)) {
|
|
230
|
+
if (equals > 0) return { error: `${name} takes no value` };
|
|
231
|
+
out.push(name);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const near = suggest(name, flagsOf(spec));
|
|
235
|
+
return {
|
|
236
|
+
error: `unknown option ${name}${near ? ` (did you mean ${near}?)` : ""}${
|
|
237
|
+
flagsOf(spec).length === 0 ? `; \`${command}\` takes no options` : ""
|
|
238
|
+
}`,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
return { args: out };
|
|
242
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sproutboat r2 object get | put | delete <bucket>/<key>`, with Wrangler's flags. On a Cloud endpoint an object lives in the
|
|
3
|
+
* app's own bucket on its host (`/v1/apps/<app>/r2/<BUCKET>/objects/<key>`, docs/host-data.md in the cloud repo), and
|
|
4
|
+
* `<bucket>` is the binding name from `r2_buckets`. Self-hosted Community can read an object (`/api/r2/<id>/objects/<key>`)
|
|
5
|
+
* but has no route to write or delete one: its 404 is reported as not supported yet.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname, resolve } from "node:path";
|
|
9
|
+
import { authHeaders, CloudError, explainApiFailure, parseBody } from "./cloud";
|
|
10
|
+
import { dataErrorMessage } from "./data-errors";
|
|
11
|
+
import { isString } from "./json";
|
|
12
|
+
import { dim, ok } from "./style";
|
|
13
|
+
|
|
14
|
+
export const GET_USAGE = "r2 object get <bucket>/<key> [--file <path> | --pipe]";
|
|
15
|
+
export const PUT_USAGE =
|
|
16
|
+
"r2 object put <bucket>/<key> (--file <path> | --pipe) [--content-type <type>] [--cache-control <v>] [--content-disposition <v>] [--content-encoding <v>] [--content-language <v>] [--expires <date>]";
|
|
17
|
+
export const DELETE_USAGE = "r2 object delete <bucket>/<key>";
|
|
18
|
+
/** What one object may be through the Cloud API (a PUT is refused over it before any host is called). */
|
|
19
|
+
export const OBJECT_MAX_BYTES = 64 * 1024 * 1024;
|
|
20
|
+
/** The longest key, in bytes of UTF-8. */
|
|
21
|
+
export const KEY_MAX_BYTES = 1024;
|
|
22
|
+
|
|
23
|
+
export class R2UsageError extends Error {
|
|
24
|
+
constructor(
|
|
25
|
+
message: string,
|
|
26
|
+
readonly usage: string,
|
|
27
|
+
) {
|
|
28
|
+
super(message);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ObjectFlags = {
|
|
33
|
+
file?: string;
|
|
34
|
+
pipe: boolean;
|
|
35
|
+
contentType?: string;
|
|
36
|
+
contentDisposition?: string;
|
|
37
|
+
contentEncoding?: string;
|
|
38
|
+
contentLanguage?: string;
|
|
39
|
+
cacheControl?: string;
|
|
40
|
+
expires?: string;
|
|
41
|
+
account?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** `{bucket}/{key}`: the bucket is everything before the first slash, the key everything after it. */
|
|
45
|
+
export type ObjectPath = { bucket: string; key: string };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The rules the Cloud applies to a key (docs/host-data.md, "R2 keys"), so a bad one is refused here with a reason
|
|
49
|
+
* instead of coming back as a 400: 1 to 1024 bytes of UTF-8, no control character (C0, DEL, C1), no backslash, and no
|
|
50
|
+
* empty, `.` or `..` segment. Returns the problem, or undefined when the key is fine.
|
|
51
|
+
*/
|
|
52
|
+
export function keyProblem(key: string): string | undefined {
|
|
53
|
+
if (key.length === 0) return "the key is empty";
|
|
54
|
+
try {
|
|
55
|
+
encodeURIComponent(key); // throws on a lone surrogate, which has no UTF-8 form
|
|
56
|
+
} catch {
|
|
57
|
+
return "the key is not valid UTF-8";
|
|
58
|
+
}
|
|
59
|
+
const bytes = new TextEncoder().encode(key).length;
|
|
60
|
+
if (bytes > KEY_MAX_BYTES) return `the key is ${bytes} bytes; at most ${KEY_MAX_BYTES}`;
|
|
61
|
+
for (let at = 0; at < key.length; at += 1) {
|
|
62
|
+
const code = key.charCodeAt(at);
|
|
63
|
+
if (code <= 0x1f || (code >= 0x7f && code <= 0x9f))
|
|
64
|
+
return `the key has a control character (U+${code.toString(16).toUpperCase().padStart(4, "0")}) at position ${at + 1}`;
|
|
65
|
+
if (code === 0x5c) return "the key has a backslash; use / between folders";
|
|
66
|
+
}
|
|
67
|
+
const segments = key.split("/");
|
|
68
|
+
if (segments.some((part) => part === "")) return 'the key has an empty segment: no leading, trailing or doubled "/"';
|
|
69
|
+
const dots = segments.find((part) => part === "." || part === "..");
|
|
70
|
+
if (dots) return `the key has a "${dots}" segment; a key is a name, not a path`;
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function parseObjectPath(path: string | undefined, usage: string): ObjectPath {
|
|
75
|
+
const slash = path?.indexOf("/") ?? -1;
|
|
76
|
+
if (path === undefined || slash <= 0) throw new R2UsageError("the object path must be <bucket>/<key>", usage);
|
|
77
|
+
const key = path.slice(slash + 1);
|
|
78
|
+
const problem = keyProblem(key);
|
|
79
|
+
if (problem)
|
|
80
|
+
throw new R2UsageError(
|
|
81
|
+
`${path.slice(0, slash)}/${key.length > 60 ? `${key.slice(0, 57)}...` : key}: ${problem}`,
|
|
82
|
+
usage,
|
|
83
|
+
);
|
|
84
|
+
return { bucket: path.slice(0, slash), key };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Read stdin up to `max` bytes; a longer input stops the read instead of filling memory. */
|
|
88
|
+
export async function readCapped(
|
|
89
|
+
stream: ReadableStream<Uint8Array>,
|
|
90
|
+
max: number,
|
|
91
|
+
tooLong: () => Error,
|
|
92
|
+
): Promise<Uint8Array<ArrayBuffer>> {
|
|
93
|
+
const chunks: Uint8Array[] = [];
|
|
94
|
+
let size = 0;
|
|
95
|
+
for await (const chunk of stream) {
|
|
96
|
+
size += chunk.byteLength;
|
|
97
|
+
if (size > max) throw tooLong();
|
|
98
|
+
chunks.push(chunk);
|
|
99
|
+
}
|
|
100
|
+
const out = new Uint8Array(size);
|
|
101
|
+
let at = 0;
|
|
102
|
+
for (const chunk of chunks) {
|
|
103
|
+
out.set(chunk, at);
|
|
104
|
+
at += chunk.byteLength;
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function parseObjectArgs(args: readonly string[], usage: string) {
|
|
110
|
+
const flags: ObjectFlags = { pipe: false };
|
|
111
|
+
const positional: string[] = [];
|
|
112
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
113
|
+
const arg = args[index]!;
|
|
114
|
+
const equals = arg.startsWith("--") ? arg.indexOf("=") : -1;
|
|
115
|
+
const name = equals > 0 ? arg.slice(0, equals) : arg;
|
|
116
|
+
const inline = equals > 0 ? arg.slice(equals + 1) : undefined;
|
|
117
|
+
const value = () => {
|
|
118
|
+
const next = inline ?? args[(index += 1)];
|
|
119
|
+
if (next === undefined || (inline === undefined && next.startsWith("--")))
|
|
120
|
+
throw new R2UsageError(`${name} needs a value`, usage);
|
|
121
|
+
return next;
|
|
122
|
+
};
|
|
123
|
+
switch (name) {
|
|
124
|
+
case "--file":
|
|
125
|
+
case "-f":
|
|
126
|
+
flags.file = value();
|
|
127
|
+
break;
|
|
128
|
+
case "--pipe":
|
|
129
|
+
case "-p":
|
|
130
|
+
flags.pipe = true;
|
|
131
|
+
break;
|
|
132
|
+
case "--content-type":
|
|
133
|
+
case "--ct":
|
|
134
|
+
flags.contentType = value();
|
|
135
|
+
break;
|
|
136
|
+
case "--content-disposition":
|
|
137
|
+
case "--cd":
|
|
138
|
+
flags.contentDisposition = value();
|
|
139
|
+
break;
|
|
140
|
+
case "--content-encoding":
|
|
141
|
+
case "--ce":
|
|
142
|
+
flags.contentEncoding = value();
|
|
143
|
+
break;
|
|
144
|
+
case "--content-language":
|
|
145
|
+
case "--cl":
|
|
146
|
+
flags.contentLanguage = value();
|
|
147
|
+
break;
|
|
148
|
+
case "--cache-control":
|
|
149
|
+
case "--cc":
|
|
150
|
+
flags.cacheControl = value();
|
|
151
|
+
break;
|
|
152
|
+
case "--expires":
|
|
153
|
+
flags.expires = value();
|
|
154
|
+
break;
|
|
155
|
+
case "--account":
|
|
156
|
+
flags.account = value();
|
|
157
|
+
break;
|
|
158
|
+
case "--remote":
|
|
159
|
+
break;
|
|
160
|
+
case "--local":
|
|
161
|
+
throw new R2UsageError(
|
|
162
|
+
"--local is not supported for R2 objects yet (dev keeps them in its broker store); objects are read and written on the Cloud, so --remote is the only mode",
|
|
163
|
+
usage,
|
|
164
|
+
);
|
|
165
|
+
case "--jurisdiction":
|
|
166
|
+
case "-J":
|
|
167
|
+
case "--storage-class":
|
|
168
|
+
case "-s":
|
|
169
|
+
case "--persist-to":
|
|
170
|
+
throw new R2UsageError(`${name} is not supported`, usage);
|
|
171
|
+
default:
|
|
172
|
+
if (arg.startsWith("-")) throw new R2UsageError(`unknown option ${name}`, usage);
|
|
173
|
+
positional.push(arg);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { positional, flags };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The headers a PUT carries: the object's HTTP metadata, as the Cloud route reads them. */
|
|
180
|
+
/** The object metadata headers a PUT carries; `x-object-cache-control` is the object's Cache-Control (docs/host-data.md). */
|
|
181
|
+
export type ObjectHeaders = {
|
|
182
|
+
"content-type"?: string;
|
|
183
|
+
"content-disposition"?: string;
|
|
184
|
+
"content-encoding"?: string;
|
|
185
|
+
"content-language"?: string;
|
|
186
|
+
"x-object-cache-control"?: string;
|
|
187
|
+
expires?: string;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
export function metadataHeaders(flags: ObjectFlags, fileType: string | undefined): ObjectHeaders {
|
|
191
|
+
const headers: ObjectHeaders = {};
|
|
192
|
+
const type = flags.contentType ?? fileType;
|
|
193
|
+
if (type) headers["content-type"] = type;
|
|
194
|
+
if (flags.contentDisposition) headers["content-disposition"] = flags.contentDisposition;
|
|
195
|
+
if (flags.contentEncoding) headers["content-encoding"] = flags.contentEncoding;
|
|
196
|
+
if (flags.contentLanguage) headers["content-language"] = flags.contentLanguage;
|
|
197
|
+
if (flags.cacheControl) headers["x-object-cache-control"] = flags.cacheControl;
|
|
198
|
+
if (flags.expires) {
|
|
199
|
+
const time = Date.parse(flags.expires);
|
|
200
|
+
if (Number.isNaN(time)) throw new R2UsageError(`--expires: "${flags.expires}" is not a date`, PUT_USAGE);
|
|
201
|
+
headers.expires = new Date(time).toUTCString();
|
|
202
|
+
}
|
|
203
|
+
return headers;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export type ObjectIo = {
|
|
207
|
+
log(line: string): void;
|
|
208
|
+
note(line: string): void;
|
|
209
|
+
stdout: { write(bytes: Uint8Array): void };
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/** How the command reaches the objects of one bucket. */
|
|
213
|
+
export type ObjectTarget = {
|
|
214
|
+
community: boolean;
|
|
215
|
+
bucket: string;
|
|
216
|
+
app: string;
|
|
217
|
+
request(
|
|
218
|
+
method: "GET" | "PUT" | "DELETE",
|
|
219
|
+
key: string,
|
|
220
|
+
init?: { body?: Uint8Array<ArrayBuffer>; headers?: ObjectHeaders },
|
|
221
|
+
): Promise<Response>;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
async function explain(target: ObjectTarget, response: Response, method: string, key: string): Promise<never> {
|
|
225
|
+
const text = await response.text().catch(() => "");
|
|
226
|
+
const body = parseBody(text);
|
|
227
|
+
if (target.community) {
|
|
228
|
+
if (response.status === 404) {
|
|
229
|
+
if (method !== "GET")
|
|
230
|
+
throw new CloudError(
|
|
231
|
+
`${method === "PUT" ? "writing" : "deleting"} R2 objects is not supported on self-hosted Community yet (this endpoint has no such route); read-only: r2 object get`,
|
|
232
|
+
);
|
|
233
|
+
throw new CloudError(isString(body?.error) ? `${key}: not found` : `no R2 bucket "${target.bucket}"`);
|
|
234
|
+
}
|
|
235
|
+
throw new CloudError(explainApiFailure(response.status, text, false));
|
|
236
|
+
}
|
|
237
|
+
const message = dataErrorMessage(
|
|
238
|
+
{ status: response.status, body, headers: response.headers },
|
|
239
|
+
{ project: target.app, product: "R2", noun: "bucket", name: target.bucket, write: method !== "GET" },
|
|
240
|
+
);
|
|
241
|
+
if (message) throw new CloudError(message);
|
|
242
|
+
if (response.status === 404) {
|
|
243
|
+
if (isString(body?.error) && /no such app/i.test(body.error))
|
|
244
|
+
throw new CloudError(`${target.app} is not deployed yet, so it has no bucket: run \`sproutboat deploy\` first`);
|
|
245
|
+
throw new CloudError(`${key}: not found`);
|
|
246
|
+
}
|
|
247
|
+
throw new CloudError(explainApiFailure(response.status, text, false));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The object's own type: the `x-object-content-type` header, present only when one was stored (`content-type` is always octet-stream). */
|
|
251
|
+
export function storedContentType(headers: Headers): string | undefined {
|
|
252
|
+
return headers.get("x-object-content-type") ?? undefined;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Where `get` writes: --file, stdout with --pipe, otherwise ./<key> (Wrangler's default), creating folders. */
|
|
256
|
+
export async function getObject(
|
|
257
|
+
target: ObjectTarget,
|
|
258
|
+
key: string,
|
|
259
|
+
flags: ObjectFlags,
|
|
260
|
+
io: ObjectIo,
|
|
261
|
+
cwd: string,
|
|
262
|
+
): Promise<void> {
|
|
263
|
+
if (flags.file !== undefined && flags.pipe) throw new R2UsageError("pass --file or --pipe, not both", GET_USAGE);
|
|
264
|
+
const response = await target.request("GET", key);
|
|
265
|
+
if (!response.ok) return explain(target, response, "GET", key);
|
|
266
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
267
|
+
if (flags.pipe) {
|
|
268
|
+
io.stdout.write(bytes);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const path = resolve(cwd, flags.file ?? key);
|
|
272
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
273
|
+
writeFileSync(path, bytes);
|
|
274
|
+
// The response's own content-type is always application/octet-stream; the object's type is x-object-content-type.
|
|
275
|
+
const type = storedContentType(response.headers);
|
|
276
|
+
io.note(
|
|
277
|
+
`${ok(`Downloaded "${key}" from "${target.bucket}"`)} ${dim(`to ${path} (${bytes.byteLength} bytes${type ? `, ${type}` : ""})`)}`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function putObject(
|
|
282
|
+
target: ObjectTarget,
|
|
283
|
+
key: string,
|
|
284
|
+
flags: ObjectFlags,
|
|
285
|
+
io: ObjectIo,
|
|
286
|
+
cwd: string,
|
|
287
|
+
readStdin: () => Promise<Uint8Array<ArrayBuffer>>,
|
|
288
|
+
): Promise<void> {
|
|
289
|
+
if ((flags.file === undefined) === !flags.pipe)
|
|
290
|
+
throw new R2UsageError("you must provide either --file or --pipe", PUT_USAGE);
|
|
291
|
+
let bytes: Uint8Array<ArrayBuffer>;
|
|
292
|
+
let fileType: string | undefined;
|
|
293
|
+
if (flags.file !== undefined) {
|
|
294
|
+
const path = resolve(cwd, flags.file);
|
|
295
|
+
if (!existsSync(path) || !statSync(path).isFile()) throw new CloudError(`${flags.file}: no such file`);
|
|
296
|
+
if (statSync(path).size > OBJECT_MAX_BYTES)
|
|
297
|
+
throw new CloudError(
|
|
298
|
+
`${flags.file} is larger than ${OBJECT_MAX_BYTES / 1024 / 1024} MiB, the most the Cloud takes`,
|
|
299
|
+
);
|
|
300
|
+
bytes = new Uint8Array(readFileSync(path));
|
|
301
|
+
fileType = Bun.file(path).type.split(";")[0];
|
|
302
|
+
if (fileType === "application/octet-stream") fileType = undefined;
|
|
303
|
+
} else {
|
|
304
|
+
bytes = await readStdin();
|
|
305
|
+
if (bytes.byteLength > OBJECT_MAX_BYTES)
|
|
306
|
+
throw new CloudError(
|
|
307
|
+
`the piped object is larger than ${OBJECT_MAX_BYTES / 1024 / 1024} MiB, the most the Cloud takes`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
const headers = metadataHeaders(flags, fileType);
|
|
311
|
+
const response = await target.request("PUT", key, { body: bytes, headers });
|
|
312
|
+
if (!response.ok) return explain(target, response, "PUT", key);
|
|
313
|
+
const answer = parseBody(await response.text().catch(() => ""));
|
|
314
|
+
const etag = isString(answer?.etag) ? `, etag ${answer.etag}` : "";
|
|
315
|
+
io.note(`${ok(`Uploaded "${key}" to "${target.bucket}"`)} ${dim(`(${bytes.byteLength} bytes${etag})`)}`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export async function deleteObject(target: ObjectTarget, key: string, io: ObjectIo): Promise<void> {
|
|
319
|
+
const response = await target.request("DELETE", key);
|
|
320
|
+
if (!response.ok) return explain(target, response, "DELETE", key);
|
|
321
|
+
await response.body?.cancel();
|
|
322
|
+
io.note(ok(`Deleted "${key}" from "${target.bucket}"`));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* A Cloud app's bucket: `/v1/apps/<app>/r2/<BUCKET>/objects/<key>` with the key's slashes kept. A key never holds a dot
|
|
327
|
+
* segment, a backslash or a control character (checked before this), so `encodeURIComponent` never writes the `%2e`,
|
|
328
|
+
* `%2f` or `%5c` the Cloud refuses. The body is whole bytes, so the request always carries `content-length` (an
|
|
329
|
+
* `x-declared-length` is only for a chunked body, which this never sends). A GET is not decompressed: the bytes written are
|
|
330
|
+
* the bytes stored, whatever the transport says.
|
|
331
|
+
*/
|
|
332
|
+
export function cloudTarget(apiUrl: string, token: string, app: string, bucket: string): ObjectTarget {
|
|
333
|
+
return {
|
|
334
|
+
community: false,
|
|
335
|
+
bucket,
|
|
336
|
+
app,
|
|
337
|
+
request: (method, key, init = {}) => {
|
|
338
|
+
const headers = new Headers({ ...authHeaders(token), ...init.headers });
|
|
339
|
+
if (init.body !== undefined) headers.set("content-length", String(init.body.byteLength));
|
|
340
|
+
const path = key.split("/").map(encodeURIComponent).join("/");
|
|
341
|
+
return fetch(`${apiUrl}/v1/apps/${app}/r2/${encodeURIComponent(bucket)}/objects/${path}`, {
|
|
342
|
+
method,
|
|
343
|
+
headers,
|
|
344
|
+
body: init.body,
|
|
345
|
+
decompress: false,
|
|
346
|
+
});
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** A Community bucket by its resource id: the read route exists, the write and delete routes do not (their 404 says so). */
|
|
352
|
+
export function communityTarget(apiUrl: string, token: string, app: string, bucket: string, id: string): ObjectTarget {
|
|
353
|
+
return {
|
|
354
|
+
community: true,
|
|
355
|
+
bucket,
|
|
356
|
+
app,
|
|
357
|
+
request: (method, key, init = {}) =>
|
|
358
|
+
fetch(`${apiUrl}/api/r2/${id}/objects/${encodeURIComponent(key)}`, {
|
|
359
|
+
method,
|
|
360
|
+
headers: { "x-api-key": token, ...init.headers },
|
|
361
|
+
body: init.body === undefined ? undefined : init.body,
|
|
362
|
+
decompress: false,
|
|
363
|
+
}),
|
|
364
|
+
};
|
|
365
|
+
}
|
package/src/run.ts
CHANGED
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
import { existsSync } from "node:fs";
|
|
23
23
|
import { readFile } from "node:fs/promises";
|
|
24
24
|
import { resolve } from "node:path";
|
|
25
|
-
import {
|
|
25
|
+
import { parseProjectConfig } from "./account-field";
|
|
26
|
+
import { locateProject } from "./global-flags";
|
|
26
27
|
import {
|
|
27
28
|
mergeEntries,
|
|
28
29
|
MissingSecretsError,
|
|
@@ -108,20 +109,25 @@ export type RunPlan = {
|
|
|
108
109
|
/** Resolve what to launch and with which environment, or throw. Starts nothing. */
|
|
109
110
|
export async function planRun(
|
|
110
111
|
args: RunArgs,
|
|
111
|
-
io: { cwd: string; inherited: Record<string, string | undefined
|
|
112
|
+
io: { cwd: string; inherited: Record<string, string | undefined>; config?: string },
|
|
112
113
|
): Promise<RunPlan> {
|
|
113
|
-
|
|
114
|
-
|
|
114
|
+
let located: { directory: string; configPath: string };
|
|
115
|
+
try {
|
|
116
|
+
located = locateProject(args.dir, io.config, io.cwd);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
throw new RunError(error instanceof Error ? error.message : String(error));
|
|
119
|
+
}
|
|
120
|
+
const { directory, configPath } = located;
|
|
115
121
|
let name: string | undefined;
|
|
116
122
|
const declared: string[] = [];
|
|
117
123
|
if (existsSync(configPath)) {
|
|
118
|
-
const parsed =
|
|
124
|
+
const parsed = parseProjectConfig(await readFile(configPath, "utf8"));
|
|
119
125
|
if (!parsed.ok) throw new RunError(parsed.errors.join("\n"));
|
|
120
126
|
name = parsed.value.name;
|
|
121
127
|
declared.push(...(parsed.value.secrets ?? []));
|
|
122
128
|
} else if (args.binary === undefined) {
|
|
123
129
|
throw new RunError(
|
|
124
|
-
`no sproutboat.jsonc found in ${directory}; pass --binary <file> to run a binary without a project`,
|
|
130
|
+
`no ${io.config === undefined ? "sproutboat.jsonc" : "config file"} found in ${io.config === undefined ? directory : configPath}; pass --binary <file> to run a binary without a project`,
|
|
125
131
|
);
|
|
126
132
|
}
|
|
127
133
|
for (const required of args.require) if (!declared.includes(required)) declared.push(required);
|