sproutboat 0.52.1 → 0.53.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 +20 -0
- package/README.md +6 -2
- package/SURFACE.md +5 -4
- package/package.json +7 -7
- package/src/dev.ts +24 -24
- package/src/env-file.ts +248 -0
- package/src/main.ts +87 -10
- package/src/run.ts +168 -0
- package/src/surface.ts +14 -4
- package/src/tail-log.ts +471 -0
- package/src/tail.ts +40 -3
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,26 @@ maintained going forward by the `release` skill.
|
|
|
10
10
|
|
|
11
11
|
## Unreleased
|
|
12
12
|
|
|
13
|
+
## [0.53.0] - 2026-11-09
|
|
14
|
+
|
|
15
|
+
Built on `@sproutboat/runtime` 0.43.0, `@sproutboat/wire` 0.22.3 and `@sproutboat/toolchain` 0.24.2. The runtime change is the opt-in `SB_LOG=json` log below; without it nothing an app can observe changes, so there is no new compatibility date. Every app is recompiled on its next build, since the runtime prelude changed.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **`sproutboat tail --file <log|->` follows a standalone binary's request and error records (#268).** Run the binary with `SB_LOG=json` (runtime 0.43.0 writes them) and `tail --file` prints the last 10 matching records, then new ones as they are written, in pretty lines or one JSON record per line (`--format json`). `--status`, `--method` and `--search` filter; `--lines` sets the snapshot size and `--no-follow` prints it and exits; `--file -` reads a pipe such as `journalctl -f -o cat`. An error follows its request's filters, and errors from background work always show. A missing file is waited for, a rotated or truncated file is reread from its start, a line over 64 KiB is skipped and counted, control characters are replaced before printing, and only known fields are written, so output stays bounded and parseable. It needs no login or network and ends with exit 0 on Ctrl-C. The existing control plane `tail` (live stream, `--format json`, filters, `--recent`, `--sprout`) is unchanged. See "Logs" in `docs/standalone.md`.
|
|
20
|
+
- **`sproutboat run` launches a standalone binary with its settings supplied at launch (#275).** `sproutboat run [project-dir] --env-file prod.env --port 80 --data-dir /var/lib/app` starts `dist/<name>` (or `--binary`) with an environment built from three layers, highest first: `--port` and `--data-dir`, the `--env-file` files (repeat the flag; a later file overrides an earlier one), then the inherited environment. Like `wrangler deploy`, it fails before the binary starts when a declared secret (the config's `secrets` plus `--require`) is unset or empty; `--check` validates and prints a summary without starting it. The summary names each secret and the layer it came from, never a value, nothing is written to disk, and the binary and its artifact metadata are untouched, so the identical binary moves between development and production. SIGINT, SIGTERM and SIGHUP are passed to the binary and its exit status is returned. `examples/vars-secrets` walks through it.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **`init` scaffolds the Workers-style handler.** New projects get compatibility date `2026-09-28` and `fetch(request, env, ctx)`, so `env` and `ctx` are available without a date bump. Existing `sproutboat.jsonc` files are never rewritten, and projects on older dates keep their behavior.
|
|
25
|
+
- **`sproutboat dev` takes local secrets the way Wrangler does (#275).** `.dev.vars` or the `.env` files, never both (a warning names the ignored ones); `--env <name>` selects `.dev.vars.<name>` and the `.env.<name>` files; the `.env` files layer `.env.<name>.local`, `.env.local`, `.env.<name>`, `.env`; `CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV=false` turns them off; `--env-file <file>` (repeatable, later overrides earlier) replaces the default set. The process environment is not passed to the app, except for declared secret names when `CLOUDFLARE_INCLUDE_PROCESS_ENV=true` and no `.dev.vars` is in use, and files win over it. A declared secret with no value is a warning and `dev` still starts. Files use dotenv syntax; a line that is not an assignment is skipped with a `file:line` warning that never repeats the line.
|
|
26
|
+
- **`.dev.vars` is now parsed with dotenv syntax.** It used to be a simpler format. The differences that can matter: an unquoted value ends at a `#` (it used to keep it), quotes may be single, double or backtick, double quotes expand `\n`, and the last of a repeated name wins. A line that is not an assignment is skipped with a warning instead of silently.
|
|
27
|
+
- **`docs/standalone.md` no longer lists `<data>/secrets.json`**: a standalone binary has only ever read secrets from the environment.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- **The native conformance harnesses no longer hang on a stalled response (#276).** The broker and standalone kitchen-sink harnesses and the small-example smoke runner give every HTTP and raw-socket request a deadline (`SB_HARNESS_REQUEST_TIMEOUT_MS`, default 30 s) that covers the whole body, and bound readiness (`SB_HARNESS_READY_TIMEOUT_MS`) and compilation (`SB_HARNESS_BUILD_TIMEOUT_MS`) separately, so a slow build cannot look like a hung handler. A timeout, or a child that exits during a request, reports the step, backend, runtime and toolchain versions and the child's recent stderr with secret values removed. Only the run's own children are stopped and its temp dir removed, on failure and on SIGINT or SIGTERM. Fault-injection tests cover a server that never completes a body and a child that exits mid-request. This is repository tooling; the shipped CLI does not change.
|
|
32
|
+
|
|
13
33
|
## [0.52.1] - 2026-11-08
|
|
14
34
|
|
|
15
35
|
Built on `@sproutboat/toolchain` 0.24.2, `@sproutboat/runtime` 0.42.2 and `@sproutboat/wire` 0.22.2. The compiler output changes (four Porffor alpha 22 miscompile fixes in the toolchain), so every app is recompiled on its next build. Nothing else an app can observe changes, so there is no new compatibility date.
|
package/README.md
CHANGED
|
@@ -197,8 +197,12 @@ chunking, resume state, cleanup, and the remaining download limitation.
|
|
|
197
197
|
`sproutboat dev` runs against a real local broker, so bindings behave like
|
|
198
198
|
production without a deploy — except `secrets`, which live only in the control
|
|
199
199
|
plane. Put values in a `.dev.vars` file next to `sproutboat.jsonc`
|
|
200
|
-
(`API_KEY=whatever`, one per line, gitignored)
|
|
201
|
-
`dev`
|
|
200
|
+
(`API_KEY=whatever`, one per line, gitignored), or use the `.env` files or
|
|
201
|
+
`sproutboat dev --env-file <file>`, as in Wrangler (`.dev.vars` or `.env`, never
|
|
202
|
+
both), and `env.API_KEY` resolves under `dev` only. A declared secret with no
|
|
203
|
+
value is a warning. The rules, the dotenv syntax, and `sproutboat run` for
|
|
204
|
+
launching a standalone binary (which fails on a missing declared secret) are in
|
|
205
|
+
[docs/standalone.md](docs/standalone.md#launching-with-sproutboat-run).
|
|
202
206
|
|
|
203
207
|
## Requirements
|
|
204
208
|
|
package/SURFACE.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> Generated by `src/surface.test.ts` from `src/surface.ts` + the pinned
|
|
4
4
|
> toolchain constants. Do not edit by hand — run `UPDATE_SURFACE=1 bun test`.
|
|
5
5
|
|
|
6
|
-
**Package:** `sproutboat` 0.
|
|
6
|
+
**Package:** `sproutboat` 0.53.0
|
|
7
7
|
|
|
8
8
|
## Commands
|
|
9
9
|
|
|
@@ -12,13 +12,14 @@
|
|
|
12
12
|
| `init` | `[name]` | Scaffold sproutboat.jsonc + src/index.js in ./<name>. |
|
|
13
13
|
| `check` | `[project-dir]` | Validate the config and entry point without building. |
|
|
14
14
|
| `types` | `[project-dir]` | Write sproutboat-env.d.ts from the config, so an editor knows what `env` holds. `dev` and `deploy` refresh it when the config is newer. |
|
|
15
|
-
| `dev` | `[project-dir] [--port <n>] [--no-watch]
|
|
15
|
+
| `dev` | `[project-dir] [--port <n>] [--no-watch] [--env <name>] [--env-file <file>]...` | Run the project on this machine against a real broker, rebuilding on save. Secrets come from .dev.vars or the .env files (never both), as in Wrangler; a declared secret with no value is a warning. |
|
|
16
|
+
| `run` | `[project-dir] [--binary <file>] [--env-file <file>]... [--port <n>] [--data-dir <dir>] [--require <NAME,...>] [--check]` | Launch a standalone binary with its port, data directory and declared secrets supplied at launch, never baked in. Precedence: --port/--data-dir, then the --env-file files (later overrides earlier), then the environment. A missing declared secret fails the launch, like wrangler deploy; --check validates without starting. |
|
|
16
17
|
| `build` | `[project-dir] [--target host|linux-x64|linux-arm64] [--standalone] [--deploy-files] [--with-restore] [--emit-only]` | Cross-compile the native-fetch sprout (Porffor + Zig). `--target host` builds for this machine instead, to run locally, not deployable. `--standalone` emits one executable carrying its own bindings, with SQLite compiled in (~2 MB) and no broker process; add `--target linux-arm64` for an aarch64 box (the default is `linux-x64`) and `--deploy-files` to write a Dockerfile, a systemd socket and service, a fly.toml and a push script beside it. `--with-restore` adds an offline `APP ltx-restore --generation DIR --output NEW_DB [--txid N]` startup command to a standalone executable; it runs before app initialization. `--emit-only` stops after writing `.sproutboat/porffor-units/<shape>/sprout.generated.js`, the module Porffor would compile, and prints its path: every check still runs, nothing is compiled. |
|
|
17
18
|
| `toolchain` | `doctor [--json]` | Report the selected compiler inputs, cache locations, overrides, and host prerequisites without downloading or compiling anything. |
|
|
18
19
|
| `deploy` | `[project-dir] [--dry-run] [--artifact <dir>] [--no-wait] [--no-provision]` | Build (unless --artifact), auto-provision id-less storage bindings and pin their ids into sproutboat.jsonc, print the report, upload, wait until the URL serves. The control plane skips an upload that matches the live artifact byte-for-byte. --dry-run stops before upload; --no-wait skips the health check; --no-provision leaves id-less bindings as ephemeral deploy-scoped stores. |
|
|
19
20
|
| `versions` | `<list | view <version-id>> [project-dir]` | List the project's deployed versions, or show one version's artifact and bindings. |
|
|
20
21
|
| `rollback` | `<version-id> [project-dir]` | Re-activate a previous version. |
|
|
21
|
-
| `tail` | `[project-dir] [--format pretty|json] [--status ok|error|<code>|5xx] [--method GET,POST] [--sampling-rate <0..1>] [--search <text>] [--outcome <name>] [--recent] [--sprout]` | Stream live request events, console output and exceptions (like wrangler tail); --recent prints the last 100 request records, --sprout the running sprout + broker stdout/stderr. |
|
|
22
|
+
| `tail` | `[project-dir] [--format pretty|json] [--status ok|error|<code>|5xx] [--method GET,POST] [--sampling-rate <0..1>] [--search <text>] [--outcome <name>] [--recent] [--sprout] [--file <log|-> [--lines <n>] [--no-follow]]` | Stream live request events, console output and exceptions (like wrangler tail); --recent prints the last 100 request records, --sprout the running sprout + broker stdout/stderr. --file follows the JSON records a standalone binary writes with SB_LOG=json (no login needed). |
|
|
22
23
|
| `kv` | `<list | create <name> | info <name> | rename <name> <new> | delete <name>> | <key | bulk | export> ...` | KV namespaces. `create` prints the id to bind from sproutboat.jsonc; key and bulk operations manage contents, and export writes a restorable JSON dump. |
|
|
23
24
|
| `d1` | `<list | create <name> | info <name> | rename <name> <new> | delete <name>>` | D1 databases. `create` prints the id to bind from sproutboat.jsonc. |
|
|
24
25
|
| `r2` | `<list | create <name> | info <name> | rename <name> <new> | delete <name>>` | R2 buckets. `create` prints the id to bind from sproutboat.jsonc. |
|
|
@@ -32,7 +33,7 @@
|
|
|
32
33
|
| `complete` | `[bash | zsh | fish]` | Print a shell completion script for the top-level commands (defaults to $SHELL). |
|
|
33
34
|
|
|
34
35
|
```
|
|
35
|
-
usage: sproutboat <init [name] | check [project-dir] | types [project-dir] | dev [project-dir] [--port <n>] [--no-watch] | build [project-dir] [--target host|linux-x64|linux-arm64] [--standalone] [--deploy-files] [--with-restore] [--emit-only] | toolchain doctor [--json] | deploy [project-dir] [--dry-run] [--artifact <dir>] [--no-wait] [--no-provision] | versions <list | view <version-id>> [project-dir] | rollback <version-id> [project-dir] | tail [project-dir] [--format pretty|json] [--status ok|error|<code>|5xx] [--method GET,POST] [--sampling-rate <0..1>] [--search <text>] [--outcome <name>] [--recent] [--sprout] | kv <list | create <name> | info <name> | rename <name> <new> | delete <name>> | <key | bulk | export> ... | d1 <list | create <name> | info <name> | rename <name> <new> | delete <name>> | r2 <list | create <name> | info <name> | rename <name> <new> | delete <name>> | queues <list | create <name> | info <name> | rename <name> <new> | delete <name>> | domains <list | add <host> | verify <host> | delete <host>> [project-dir] | secrets <list | put <NAME> [--value <value>] | delete <NAME>> [project-dir] | delete [project-dir] [--name <project>] --yes | login [--api-url <url>] [--token <token>] | logout [--api-url <url>] | whoami | complete [bash | zsh | fish]>
|
|
36
|
+
usage: sproutboat <init [name] | check [project-dir] | types [project-dir] | dev [project-dir] [--port <n>] [--no-watch] [--env <name>] [--env-file <file>]... | run [project-dir] [--binary <file>] [--env-file <file>]... [--port <n>] [--data-dir <dir>] [--require <NAME,...>] [--check] | build [project-dir] [--target host|linux-x64|linux-arm64] [--standalone] [--deploy-files] [--with-restore] [--emit-only] | toolchain doctor [--json] | deploy [project-dir] [--dry-run] [--artifact <dir>] [--no-wait] [--no-provision] | versions <list | view <version-id>> [project-dir] | rollback <version-id> [project-dir] | tail [project-dir] [--format pretty|json] [--status ok|error|<code>|5xx] [--method GET,POST] [--sampling-rate <0..1>] [--search <text>] [--outcome <name>] [--recent] [--sprout] [--file <log|-> [--lines <n>] [--no-follow]] | kv <list | create <name> | info <name> | rename <name> <new> | delete <name>> | <key | bulk | export> ... | d1 <list | create <name> | info <name> | rename <name> <new> | delete <name>> | r2 <list | create <name> | info <name> | rename <name> <new> | delete <name>> | queues <list | create <name> | info <name> | rename <name> <new> | delete <name>> | domains <list | add <host> | verify <host> | delete <host>> [project-dir] | secrets <list | put <NAME> [--value <value>] | delete <NAME>> [project-dir] | delete [project-dir] [--name <project>] --yes | login [--api-url <url>] [--token <token>] | logout [--api-url <url>] | whoami | complete [bash | zsh | fish]>
|
|
36
37
|
```
|
|
37
38
|
|
|
38
39
|
## Environment variables
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sproutboat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.0",
|
|
4
4
|
"description": "Wrangler-shaped CLI for Sproutboat. Deploys workers to any control plane via --api-url / SPROUTBOAT_API_URL.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -64,9 +64,9 @@
|
|
|
64
64
|
"@sproutboat/artifact": "^0.3.0",
|
|
65
65
|
"@sproutboat/assets": "^0.3.0",
|
|
66
66
|
"@sproutboat/config": "^0.6.0",
|
|
67
|
-
"@sproutboat/runtime": "^0.
|
|
67
|
+
"@sproutboat/runtime": "^0.43.0",
|
|
68
68
|
"@sproutboat/toolchain": "^0.24.2",
|
|
69
|
-
"@sproutboat/wire": "^0.22.
|
|
69
|
+
"@sproutboat/wire": "^0.22.3"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
72
|
"@oxlint/plugins": "1.85.0",
|
|
@@ -78,10 +78,10 @@
|
|
|
78
78
|
"typescript": "7.0.2"
|
|
79
79
|
},
|
|
80
80
|
"optionalDependencies": {
|
|
81
|
-
"@sproutboat/cli-darwin-arm64": "0.
|
|
82
|
-
"@sproutboat/cli-darwin-x64": "0.
|
|
83
|
-
"@sproutboat/cli-linux-arm64": "0.
|
|
84
|
-
"@sproutboat/cli-linux-x64": "0.
|
|
81
|
+
"@sproutboat/cli-darwin-arm64": "0.53.0",
|
|
82
|
+
"@sproutboat/cli-darwin-x64": "0.53.0",
|
|
83
|
+
"@sproutboat/cli-linux-arm64": "0.53.0",
|
|
84
|
+
"@sproutboat/cli-linux-x64": "0.53.0"
|
|
85
85
|
},
|
|
86
86
|
"engines": {
|
|
87
87
|
"node": ">=18"
|
package/src/dev.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { dirname, resolve } from "node:path";
|
|
|
18
18
|
import { buildArtifact } from "./build";
|
|
19
19
|
import { createBroker, listen, listenTransfers, type Bindings, type Broker } from "./broker";
|
|
20
20
|
import { jsonObject, parseJsonValue } from "./json";
|
|
21
|
+
import { resolveDevVars } from "./env-file";
|
|
21
22
|
import { amber, dim, leaf, ok } from "./style";
|
|
22
23
|
import type { SproutboatConfig } from "./config";
|
|
23
24
|
|
|
@@ -32,6 +33,10 @@ export type DevInput = {
|
|
|
32
33
|
source: string;
|
|
33
34
|
port: number;
|
|
34
35
|
watch: boolean;
|
|
36
|
+
/** #275: `--env-file` paths (absolute), in order. Default: `.dev.vars`, else the `.env` files. */
|
|
37
|
+
envFiles?: string[];
|
|
38
|
+
/** #275: `--env <name>`, which selects `.dev.vars.<name>` and the `.env.<name>` files. */
|
|
39
|
+
envName?: string;
|
|
35
40
|
/** Re-read the complete project after a change; throws with a readable message. */
|
|
36
41
|
rebuild: () => Promise<Pick<DevInput, "config" | "sourcePath" | "source">>;
|
|
37
42
|
/** Set internally for an asset-only refresh, where recompiling is unnecessary. */
|
|
@@ -54,30 +59,25 @@ export function isAssetOnlyRefresh(
|
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
/**
|
|
57
|
-
* Secrets for local dev,
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
62
|
+
* Secrets for local dev (#275), by Wrangler's rules (env-file.ts): `.dev.vars`
|
|
63
|
+
* or the `.env` files, never both; the process environment only with
|
|
64
|
+
* CLOUDFLARE_INCLUDE_PROCESS_ENV=true. A declared secret with no value, or a
|
|
65
|
+
* skipped line, is a warning and `dev` still starts. Read on every (re)start, so
|
|
66
|
+
* an edited file is picked up.
|
|
61
67
|
*/
|
|
62
|
-
async function
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
(value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))
|
|
76
|
-
? value.slice(1, -1)
|
|
77
|
-
: value;
|
|
78
|
-
return [[trimmed.slice(0, eq).trim(), unquoted]];
|
|
79
|
-
}),
|
|
80
|
-
);
|
|
68
|
+
export async function devSecrets(
|
|
69
|
+
input: Pick<DevInput, "projectDir" | "envFiles" | "envName" | "config">,
|
|
70
|
+
inherited: Record<string, string | undefined> = process.env,
|
|
71
|
+
): Promise<Record<string, string>> {
|
|
72
|
+
const resolved = await resolveDevVars({
|
|
73
|
+
projectDir: input.projectDir,
|
|
74
|
+
envName: input.envName,
|
|
75
|
+
envFiles: input.envFiles,
|
|
76
|
+
declared: input.config.secrets ?? [],
|
|
77
|
+
inherited,
|
|
78
|
+
});
|
|
79
|
+
for (const warning of resolved.warnings) console.warn(amber(`! ${warning}`));
|
|
80
|
+
return resolved.values;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
/** `bindings.json` is written by the build whenever the project declares any. */
|
|
@@ -149,7 +149,7 @@ async function start(input: DevInput, port: number): Promise<Running> {
|
|
|
149
149
|
resourceDir: resolve(stateDir, "resources"),
|
|
150
150
|
token: "sproutboat-dev",
|
|
151
151
|
bindings: await readBindings(artifactDir),
|
|
152
|
-
secrets: await
|
|
152
|
+
secrets: await devSecrets(input),
|
|
153
153
|
sproutUrl: `http://127.0.0.1:${port}/`,
|
|
154
154
|
assetsDir: existsSync(assetsDir) ? assetsDir : undefined,
|
|
155
155
|
dispatchEnabled: () => dispatchEnabled,
|
package/src/env-file.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local secret input (#275), modelled on Wrangler so the habits carry over.
|
|
3
|
+
*
|
|
4
|
+
* `sproutboat dev` takes local secrets from files, never from the process
|
|
5
|
+
* environment unless asked:
|
|
6
|
+
*
|
|
7
|
+
* - `.dev.vars` beside the config, or the `.env` files, never both. When a
|
|
8
|
+
* `.dev.vars` exists the `.env*` files are not loaded (a warning names them).
|
|
9
|
+
* With `--env <name>`, `.dev.vars.<name>` replaces `.dev.vars` when it exists.
|
|
10
|
+
* - The `.env` files layer, most specific first: `.env.<name>.local`,
|
|
11
|
+
* `.env.local`, `.env.<name>`, `.env`. `CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV=false`
|
|
12
|
+
* turns this off. Repeated `--env-file` flags replace the default set; a later
|
|
13
|
+
* file overrides an earlier one.
|
|
14
|
+
* - The process environment reaches the app only when
|
|
15
|
+
* `CLOUDFLARE_INCLUDE_PROCESS_ENV=true` and no `.dev.vars` is in use, and then
|
|
16
|
+
* only for declared secret names. Files win over it.
|
|
17
|
+
* - A declared secret with no value is a warning, and `dev` still starts.
|
|
18
|
+
*
|
|
19
|
+
* `sproutboat run` (the production launch of a standalone binary) is stricter:
|
|
20
|
+
* a missing declared secret fails, like `wrangler deploy`. Its layers, highest
|
|
21
|
+
* first: `--port` and `--data-dir`, the `--env-file` files (later overrides
|
|
22
|
+
* earlier), the inherited environment.
|
|
23
|
+
*
|
|
24
|
+
* File syntax is dotenv's: `NAME=value`, an optional `export `, `#` comments
|
|
25
|
+
* (a whole line, or after an unquoted value), single, double and backtick quotes,
|
|
26
|
+
* `\n` and `\r` expanded inside double quotes only, quoted values may span lines,
|
|
27
|
+
* the last of a repeated name wins. A line that is not an assignment is skipped
|
|
28
|
+
* with a `file:line` warning. Warnings never repeat the text of a line, so a value
|
|
29
|
+
* cannot leak through one.
|
|
30
|
+
*/
|
|
31
|
+
import { existsSync } from "node:fs";
|
|
32
|
+
import { readFile } from "node:fs/promises";
|
|
33
|
+
import { resolve } from "node:path";
|
|
34
|
+
|
|
35
|
+
export type EnvEntry = { key: string; value: string; line: number };
|
|
36
|
+
export type EnvSource = "option" | "env file" | "environment";
|
|
37
|
+
export type ParsedEnv = { entries: EnvEntry[]; warnings: string[] };
|
|
38
|
+
|
|
39
|
+
/** A file that could not be read at all (a missing explicit file). The message is safe to print. */
|
|
40
|
+
export class EnvFileError extends Error {}
|
|
41
|
+
|
|
42
|
+
/** Declared secrets that are unset or empty, for a launch that must not start without them. Names only. */
|
|
43
|
+
export class MissingSecretsError extends Error {
|
|
44
|
+
constructor(
|
|
45
|
+
readonly names: string[],
|
|
46
|
+
where: string,
|
|
47
|
+
) {
|
|
48
|
+
super(
|
|
49
|
+
`missing declared secret${names.length === 1 ? "" : "s"}: ${names.join(", ")}. Set ${names.length === 1 ? "it" : "them"} in ${where} or in the environment.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ASSIGNMENT = /^\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*|:\s+)(.*)$/;
|
|
55
|
+
|
|
56
|
+
/** Parse dotenv text. Never throws: a bad line becomes a warning. */
|
|
57
|
+
export function parseDotenv(text: string, label: string): ParsedEnv {
|
|
58
|
+
const source = (text.startsWith("") ? text.slice(1) : text).replace(/\r\n?/g, "\n");
|
|
59
|
+
const lines = source.split("\n");
|
|
60
|
+
const byKey = new Map<string, EnvEntry>();
|
|
61
|
+
const warnings: string[] = [];
|
|
62
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
63
|
+
const line = lines[index];
|
|
64
|
+
const trimmed = line.trim();
|
|
65
|
+
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
|
66
|
+
const match = ASSIGNMENT.exec(line);
|
|
67
|
+
if (!match) {
|
|
68
|
+
warnings.push(`${label}:${index + 1}: skipped, not a NAME=value assignment`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const key = match[1];
|
|
72
|
+
const rest = match[2].trimStart();
|
|
73
|
+
const quote = rest[0];
|
|
74
|
+
let value: string;
|
|
75
|
+
if (quote === "'" || quote === '"' || quote === "`") {
|
|
76
|
+
let out = "";
|
|
77
|
+
let row = index;
|
|
78
|
+
let column = 0;
|
|
79
|
+
let current = rest.slice(1);
|
|
80
|
+
let closed = false;
|
|
81
|
+
for (;;) {
|
|
82
|
+
while (column < current.length) {
|
|
83
|
+
const char = current[column];
|
|
84
|
+
// As in dotenv, an escaped closing quote does not end the value (and the backslash stays).
|
|
85
|
+
if (char === "\\" && current[column + 1] === quote) {
|
|
86
|
+
out += char + quote;
|
|
87
|
+
column += 2;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (char === quote) {
|
|
91
|
+
closed = true;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
out += char;
|
|
95
|
+
column += 1;
|
|
96
|
+
}
|
|
97
|
+
if (closed || row + 1 >= lines.length) break;
|
|
98
|
+
row += 1;
|
|
99
|
+
current = lines[row];
|
|
100
|
+
column = 0;
|
|
101
|
+
out += "\n";
|
|
102
|
+
}
|
|
103
|
+
if (!closed) {
|
|
104
|
+
warnings.push(`${label}:${index + 1}: skipped, unterminated ${quote} quote`);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
index = row;
|
|
108
|
+
value = quote === '"' ? out.replace(/\\n/g, "\n").replace(/\\r/g, "\r") : out;
|
|
109
|
+
} else {
|
|
110
|
+
const hash = rest.indexOf("#");
|
|
111
|
+
value = (hash >= 0 ? rest.slice(0, hash) : rest).trim();
|
|
112
|
+
}
|
|
113
|
+
byKey.set(key, { key, value, line: index + 1 });
|
|
114
|
+
}
|
|
115
|
+
return { entries: [...byKey.values()], warnings };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Read and parse an env file. Throws EnvFileError when it cannot be read. */
|
|
119
|
+
export async function readEnvFile(path: string): Promise<ParsedEnv> {
|
|
120
|
+
let text: string;
|
|
121
|
+
try {
|
|
122
|
+
text = await readFile(path, "utf8");
|
|
123
|
+
} catch (error) {
|
|
124
|
+
const code = error instanceof Error && "code" in error ? error.code : undefined;
|
|
125
|
+
if (code === "ENOENT") throw new EnvFileError(`env file not found: ${path}`);
|
|
126
|
+
throw new EnvFileError(`could not read ${path}: ${code ?? "unknown error"}`);
|
|
127
|
+
}
|
|
128
|
+
return parseDotenv(text, path);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Later entries override earlier ones, by name. */
|
|
132
|
+
export function mergeEntries(layers: EnvEntry[][]): EnvEntry[] {
|
|
133
|
+
const merged = new Map<string, EnvEntry>();
|
|
134
|
+
for (const layer of layers) for (const entry of layer) merged.set(entry.key, entry);
|
|
135
|
+
return [...merged.values()];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export type EnvInput = {
|
|
139
|
+
inherited: Record<string, string | undefined>;
|
|
140
|
+
file?: EnvEntry[];
|
|
141
|
+
/** Explicit launch options as environment variables: `--port` is PORT, `--data-dir` is SB_DATA_DIR. */
|
|
142
|
+
options?: Record<string, string>;
|
|
143
|
+
};
|
|
144
|
+
export type ResolvedEnvironment = { env: Record<string, string>; sources: Map<string, EnvSource> };
|
|
145
|
+
|
|
146
|
+
/** The environment to launch with, and which layer each name came from. Values are never logged. */
|
|
147
|
+
export function resolveEnvironment(input: EnvInput): ResolvedEnvironment {
|
|
148
|
+
const env: Record<string, string> = {};
|
|
149
|
+
const sources = new Map<string, EnvSource>();
|
|
150
|
+
for (const [key, value] of Object.entries(input.inherited)) {
|
|
151
|
+
if (value === undefined) continue;
|
|
152
|
+
env[key] = value;
|
|
153
|
+
sources.set(key, "environment");
|
|
154
|
+
}
|
|
155
|
+
for (const entry of input.file ?? []) {
|
|
156
|
+
env[entry.key] = entry.value;
|
|
157
|
+
sources.set(entry.key, "env file");
|
|
158
|
+
}
|
|
159
|
+
for (const [key, value] of Object.entries(input.options ?? {})) {
|
|
160
|
+
env[key] = value;
|
|
161
|
+
sources.set(key, "option");
|
|
162
|
+
}
|
|
163
|
+
return { env, sources };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Declared secrets that are unset or empty in `env`, in declaration order. */
|
|
167
|
+
export function missingSecrets(declared: readonly string[], env: Record<string, string>): string[] {
|
|
168
|
+
return declared.filter((name) => (env[name] ?? "") === "");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export type DevVars = {
|
|
172
|
+
/** What the app sees: every name from the files, plus declared names the process environment supplies when allowed. */
|
|
173
|
+
values: Record<string, string>;
|
|
174
|
+
/** Files that were read, in order of increasing precedence. */
|
|
175
|
+
used: string[];
|
|
176
|
+
/** Everything worth telling the developer: ignored files, skipped lines, missing declared secrets. No values. */
|
|
177
|
+
warnings: string[];
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/** Local secrets for `sproutboat dev`, by the rules in the module comment. */
|
|
181
|
+
export async function resolveDevVars(input: {
|
|
182
|
+
projectDir: string;
|
|
183
|
+
/** `--env <name>`: selects `.dev.vars.<name>` and the `.env.<name>` files. */
|
|
184
|
+
envName?: string;
|
|
185
|
+
/** `--env-file` paths, absolute, in the order given. */
|
|
186
|
+
envFiles?: string[];
|
|
187
|
+
declared: readonly string[];
|
|
188
|
+
inherited: Record<string, string | undefined>;
|
|
189
|
+
}): Promise<DevVars> {
|
|
190
|
+
const warnings: string[] = [];
|
|
191
|
+
const at = (name: string) => resolve(input.projectDir, name);
|
|
192
|
+
const name = input.envName;
|
|
193
|
+
const explicit = input.envFiles ?? [];
|
|
194
|
+
const devVarsPath = [name ? at(`.dev.vars.${name}`) : undefined, at(".dev.vars")].find(
|
|
195
|
+
(path) => path !== undefined && existsSync(path),
|
|
196
|
+
);
|
|
197
|
+
const loadDotEnv = input.inherited.CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV !== "false";
|
|
198
|
+
const dotEnvCandidates =
|
|
199
|
+
explicit.length > 0
|
|
200
|
+
? explicit
|
|
201
|
+
: [
|
|
202
|
+
at(".env"),
|
|
203
|
+
...(name ? [at(`.env.${name}`)] : []),
|
|
204
|
+
at(".env.local"),
|
|
205
|
+
...(name ? [at(`.env.${name}.local`)] : []),
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
let layers: EnvEntry[][] = [];
|
|
209
|
+
const used: string[] = [];
|
|
210
|
+
if (devVarsPath !== undefined) {
|
|
211
|
+
const present = dotEnvCandidates.filter((path) => existsSync(path));
|
|
212
|
+
if (present.length > 0)
|
|
213
|
+
warnings.push(`${devVarsPath} exists, so the .env files are not loaded (ignored: ${present.join(", ")})`);
|
|
214
|
+
const parsed = await readEnvFile(devVarsPath);
|
|
215
|
+
warnings.push(...parsed.warnings);
|
|
216
|
+
layers = [parsed.entries];
|
|
217
|
+
used.push(devVarsPath);
|
|
218
|
+
} else if (!loadDotEnv) {
|
|
219
|
+
if (explicit.length > 0)
|
|
220
|
+
warnings.push("CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV=false, so the --env-file files are not loaded");
|
|
221
|
+
} else {
|
|
222
|
+
for (const path of dotEnvCandidates) {
|
|
223
|
+
// The default files are optional; a file named with --env-file is not.
|
|
224
|
+
if (explicit.length === 0 && !existsSync(path)) continue;
|
|
225
|
+
const parsed = await readEnvFile(path);
|
|
226
|
+
warnings.push(...parsed.warnings);
|
|
227
|
+
layers.push(parsed.entries);
|
|
228
|
+
used.push(path);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const values: Record<string, string> = {};
|
|
233
|
+
for (const entry of mergeEntries(layers)) values[entry.key] = entry.value;
|
|
234
|
+
// The process environment never reaches the app by default, and not when .dev.vars is in use.
|
|
235
|
+
if (devVarsPath === undefined && input.inherited.CLOUDFLARE_INCLUDE_PROCESS_ENV === "true") {
|
|
236
|
+
for (const declaredName of input.declared) {
|
|
237
|
+
const fromProcess = input.inherited[declaredName];
|
|
238
|
+
if (values[declaredName] === undefined && fromProcess !== undefined) values[declaredName] = fromProcess;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const missing = input.declared.filter((declaredName) => (values[declaredName] ?? "") === "");
|
|
242
|
+
if (missing.length > 0) {
|
|
243
|
+
warnings.push(
|
|
244
|
+
`declared secret${missing.length === 1 ? "" : "s"} with no value: ${missing.join(", ")}. A handler that reads ${missing.length === 1 ? "it" : "one"} will throw. Set ${missing.length === 1 ? "it" : "them"} in .dev.vars or a .env file.`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
return { values, used, warnings };
|
|
248
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { mkdir, open, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
-
import { rmSync } from "node:fs";
|
|
3
|
+
import { existsSync, rmSync } from "node:fs";
|
|
4
4
|
import { basename, dirname, relative, resolve } from "node:path";
|
|
5
5
|
import { isBoolean, isSafeInteger, isString, jsonObject, parseJsonValue, type JsonObject } from "./json";
|
|
6
6
|
import type { AssetFiles } from "./assets";
|
|
@@ -19,10 +19,14 @@ import { notifyIfOutdated } from "./update-check";
|
|
|
19
19
|
import { controlVersionWarning } from "./api-version";
|
|
20
20
|
import { amber, bold, dim, leaf, ok, rose } from "./style";
|
|
21
21
|
import { pathToFileURL } from "node:url";
|
|
22
|
+
import { constants as osConstants } from "node:os";
|
|
22
23
|
import { inspectToolchain } from "./toolchain";
|
|
23
24
|
import { isLinuxTarget, parseTarget, TARGET_NAMES, type BuildTarget } from "./targets";
|
|
24
25
|
import { writeDeployFiles } from "./standalone-deploy";
|
|
25
|
-
import { parseTailArgs, runTail, TAIL_USAGE, tailQuery } from "./tail";
|
|
26
|
+
import { parseTailArgs, runTail, TAIL_USAGE, tailQuery, type TailArgs } from "./tail";
|
|
27
|
+
import { followLog, LogFilter, readLogStream } from "./tail-log";
|
|
28
|
+
import { EnvFileError, MissingSecretsError } from "./env-file";
|
|
29
|
+
import { parseRunArgs, planRun, RUN_USAGE, RunError } from "./run";
|
|
26
30
|
|
|
27
31
|
// A compiled Bun executable is not a general-purpose `bun` command. Porffor
|
|
28
32
|
// is acquired after installation, so run its external ESM launcher through an
|
|
@@ -168,11 +172,11 @@ const starterConfig = (name: string) => `{
|
|
|
168
172
|
"$schema": "https://sproutboat.com/schema.json",
|
|
169
173
|
"name": "${name}",
|
|
170
174
|
"main": "src/index.js",
|
|
171
|
-
"compatibility_date": "2026-
|
|
175
|
+
"compatibility_date": "2026-09-28"
|
|
172
176
|
}
|
|
173
177
|
`;
|
|
174
178
|
const starterHandler = `export default {
|
|
175
|
-
fetch() {
|
|
179
|
+
fetch(request, env, ctx) {
|
|
176
180
|
return new Response("hello from Sproutboat");
|
|
177
181
|
}
|
|
178
182
|
};
|
|
@@ -398,13 +402,30 @@ async function emitOnly(directory: string | undefined, target: BuildTarget, stan
|
|
|
398
402
|
}
|
|
399
403
|
|
|
400
404
|
/** #62 — build for this machine, run it against a real broker, rebuild on save. */
|
|
405
|
+
const DEV_USAGE = "dev [project-dir] [--port <n>] [--no-watch] [--env <name>] [--env-file <file>]...";
|
|
406
|
+
|
|
401
407
|
async function dev(args: string[]) {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
408
|
+
let directory: string | undefined;
|
|
409
|
+
let portArg: string | undefined;
|
|
410
|
+
let envName: string | undefined;
|
|
411
|
+
const envFiles: string[] = [];
|
|
412
|
+
for (let at = 0; at < args.length; at += 1) {
|
|
413
|
+
const arg = args[at];
|
|
414
|
+
if (arg === "--no-watch") continue;
|
|
415
|
+
if (arg === "--port" || arg === "--env" || arg === "--env-file") {
|
|
416
|
+
const value = args[at + 1];
|
|
417
|
+
if (value === undefined || value.startsWith("--")) usageError(`${arg} needs a value`, DEV_USAGE);
|
|
418
|
+
at += 1;
|
|
419
|
+
if (arg === "--port") portArg = value;
|
|
420
|
+
else if (arg === "--env") envName = value;
|
|
421
|
+
else envFiles.push(resolve(value));
|
|
422
|
+
} else if (!arg.startsWith("--") && directory === undefined) directory = arg;
|
|
423
|
+
}
|
|
405
424
|
const port = Number(portArg ?? 8787);
|
|
406
|
-
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535)
|
|
407
|
-
|
|
425
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) usageError(`invalid --port: ${portArg}`, DEV_USAGE);
|
|
426
|
+
if (envName !== undefined && !/^[A-Za-z0-9_-]+$/.test(envName))
|
|
427
|
+
usageError("--env takes a name (letters, digits, - and _)", DEV_USAGE);
|
|
428
|
+
for (const file of envFiles) if (!existsSync(file)) fail(`env file not found: ${file}`);
|
|
408
429
|
const project = await readProject(directory);
|
|
409
430
|
await refreshTypes(project.directory, project.config);
|
|
410
431
|
console.log(dim(`Building ${project.config.name} for this machine (${hostTarget()})…`));
|
|
@@ -414,6 +435,8 @@ async function dev(args: string[]) {
|
|
|
414
435
|
sourcePath: project.sourcePath,
|
|
415
436
|
source: project.bundle.code,
|
|
416
437
|
port,
|
|
438
|
+
envFiles,
|
|
439
|
+
envName,
|
|
417
440
|
watch: !args.includes("--no-watch"),
|
|
418
441
|
// Re-read from disk on every rebuild: the point of watching is that the
|
|
419
442
|
// files changed, so the bundle captured at startup is stale by definition.
|
|
@@ -425,6 +448,33 @@ async function dev(args: string[]) {
|
|
|
425
448
|
});
|
|
426
449
|
}
|
|
427
450
|
|
|
451
|
+
/** #275: start a standalone binary with its port, data directory and secrets supplied at launch. */
|
|
452
|
+
async function run(args: string[]) {
|
|
453
|
+
const result = parseRunArgs(args);
|
|
454
|
+
if ("error" in result) usageError(result.error, RUN_USAGE);
|
|
455
|
+
let plan;
|
|
456
|
+
try {
|
|
457
|
+
plan = await planRun(result.args, { cwd: process.cwd(), inherited: process.env });
|
|
458
|
+
} catch (error) {
|
|
459
|
+
if (error instanceof RunError || error instanceof EnvFileError || error instanceof MissingSecretsError)
|
|
460
|
+
fail(error.message);
|
|
461
|
+
throw error;
|
|
462
|
+
}
|
|
463
|
+
for (const warning of plan.warnings) console.warn(amber(`! ${warning}`));
|
|
464
|
+
for (const line of plan.summary) console.log(dim(line));
|
|
465
|
+
if (result.args.check) {
|
|
466
|
+
console.log(ok(`ready to launch ${basename(plan.binary)} (not started: --check)`));
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
console.log(ok(`launching ${plan.binary}`));
|
|
470
|
+
const child = Bun.spawn([plan.binary], { env: plan.env, stdin: "inherit", stdout: "inherit", stderr: "inherit" });
|
|
471
|
+
// Forward termination to the binary, which is the only process this started.
|
|
472
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) process.on(signal, () => child.kill(signal));
|
|
473
|
+
const code = await child.exited;
|
|
474
|
+
const signalNumber = child.signalCode ? osConstants.signals[child.signalCode] : undefined;
|
|
475
|
+
process.exit(signalNumber === undefined ? code : 128 + signalNumber);
|
|
476
|
+
}
|
|
477
|
+
|
|
428
478
|
const PROVISION_FIELDS = [
|
|
429
479
|
["kv_namespaces", "kv"],
|
|
430
480
|
["d1_databases", "d1"],
|
|
@@ -792,10 +842,32 @@ async function rollback(args: string[]) {
|
|
|
792
842
|
console.log(deployment.url);
|
|
793
843
|
}
|
|
794
844
|
|
|
845
|
+
/** #268: follow a standalone binary's SB_LOG=json records from a file or stdin. Local only: no login. */
|
|
846
|
+
async function tailLog(parsed: TailArgs, file: string) {
|
|
847
|
+
// Ctrl-C (or a terminated pipe reader) ends the stream and exits 0.
|
|
848
|
+
const controller = new AbortController();
|
|
849
|
+
for (const signal of ["SIGINT", "SIGTERM"] as const) process.once(signal, () => controller.abort());
|
|
850
|
+
process.stdout.on("error", () => controller.abort());
|
|
851
|
+
const sink = {
|
|
852
|
+
format: parsed.format,
|
|
853
|
+
filter: new LogFilter({ status: parsed.status, method: parsed.method, search: parsed.search }),
|
|
854
|
+
signal: controller.signal,
|
|
855
|
+
stdout: (text: string) => void process.stdout.write(text),
|
|
856
|
+
stderr: (text: string) => void process.stderr.write(text),
|
|
857
|
+
};
|
|
858
|
+
try {
|
|
859
|
+
if (file === "-") await readLogStream(Bun.stdin.stream(), sink);
|
|
860
|
+
else await followLog({ ...sink, path: resolve(file), lines: parsed.lines, follow: parsed.follow });
|
|
861
|
+
} catch (error) {
|
|
862
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
795
866
|
async function tail(args: string[]) {
|
|
796
867
|
const result = parseTailArgs(args);
|
|
797
868
|
if ("error" in result) usageError(result.error, TAIL_USAGE);
|
|
798
869
|
const parsed = result.args;
|
|
870
|
+
if (parsed.file !== undefined) return tailLog(parsed, parsed.file);
|
|
799
871
|
const [project, { apiUrl, token }] = await Promise.all([readProjectConfig(parsed.dir), apiCredentials()]);
|
|
800
872
|
const base = `${apiUrl}/api/projects/${project.config.name}`;
|
|
801
873
|
if (parsed.recent || parsed.sprout) {
|
|
@@ -1359,7 +1431,9 @@ if (command === "--version" || command === "-v") {
|
|
|
1359
1431
|
}
|
|
1360
1432
|
if (args.includes("-h") || args.includes("--help")) commandHelp(command);
|
|
1361
1433
|
|
|
1362
|
-
|
|
1434
|
+
// A local log needs no network: skip the version check for it (#268).
|
|
1435
|
+
if (!(command === "tail" && args.some((arg) => arg === "--file" || arg.startsWith("--file="))))
|
|
1436
|
+
await notifyIfOutdated(CLI_VERSION);
|
|
1363
1437
|
|
|
1364
1438
|
switch (command) {
|
|
1365
1439
|
case "init":
|
|
@@ -1374,6 +1448,9 @@ switch (command) {
|
|
|
1374
1448
|
case "dev":
|
|
1375
1449
|
await dev(args);
|
|
1376
1450
|
break;
|
|
1451
|
+
case "run":
|
|
1452
|
+
await run(args);
|
|
1453
|
+
break;
|
|
1377
1454
|
case "build": {
|
|
1378
1455
|
const withRestore = args.includes("--with-restore");
|
|
1379
1456
|
if (withRestore && (!args.includes("--standalone") || args.includes("--emit-only")))
|