@fargowealth/fargo-ai-cli 0.7.0 → 0.7.2
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 +17 -0
- package/README.md +67 -2
- package/package.json +1 -1
- package/src/cli.mjs +112 -1
- package/src/constants.mjs +1 -1
- package/src/deployment-artifact.mjs +89 -10
- package/src/lego-deploy-artifacts.mjs +9 -0
- package/src/lego-deploy.mjs +17 -2
- package/src/lego-migrations.mjs +218 -3
- package/src/lego-snapshots.mjs +5 -1
- package/src/source-snapshot.mjs +16 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.2
|
|
4
|
+
|
|
5
|
+
- Added `fargo lego deploy --help` with the complete Worker Static Assets
|
|
6
|
+
contract and an executable full-stack example.
|
|
7
|
+
- Worker deploys now fail early for manual `.fargo-assets/`, frontend
|
|
8
|
+
`index.html` files inside `--deploy-dir`, overlapping module/assets
|
|
9
|
+
directories, and asset options missing `--assets-dir`.
|
|
10
|
+
- Dry-run deployment summaries now show manifest schema version and separate
|
|
11
|
+
Worker-module and Static Asset file counts.
|
|
12
|
+
|
|
13
|
+
## 0.7.1
|
|
14
|
+
|
|
15
|
+
- Added Worker Static Assets packaging with explicit asset directory, binding,
|
|
16
|
+
SPA/404 handling, HTML handling, and Worker-first routing metadata.
|
|
17
|
+
- Deployment artifacts now preserve Worker code and browser assets separately
|
|
18
|
+
so the hosted LEGO publisher can upload both through Cloudflare's formal API.
|
|
19
|
+
|
|
3
20
|
## 0.7.0
|
|
4
21
|
|
|
5
22
|
- Added source-backup history metadata and Portal list/download support for
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Requires Node.js 20.19 or newer.
|
|
|
9
9
|
## Install or upgrade
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
|
-
npm install -g @fargowealth/fargo-ai-cli@0.7.
|
|
12
|
+
npm install -g @fargowealth/fargo-ai-cli@0.7.2
|
|
13
13
|
fargo version
|
|
14
14
|
```
|
|
15
15
|
|
|
@@ -69,6 +69,7 @@ npm run build
|
|
|
69
69
|
fargo lego deploy \
|
|
70
70
|
--app app_xxx \
|
|
71
71
|
--source . \
|
|
72
|
+
--source-exclude node_modules/ \
|
|
72
73
|
--deploy-dir dist \
|
|
73
74
|
--target pages \
|
|
74
75
|
--entrypoint index.html \
|
|
@@ -83,6 +84,47 @@ Version number, version name, and upgrade notes are required. Source upload is
|
|
|
83
84
|
the default; `--skip-source` is the explicit exception for a deployment that
|
|
84
85
|
must not retain a source archive.
|
|
85
86
|
|
|
87
|
+
Full-stack Workers can include Cloudflare Worker Static Assets without giving
|
|
88
|
+
the local CLI Cloudflare credentials:
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
fargo lego deploy \
|
|
92
|
+
--app app_xxx \
|
|
93
|
+
--source . \
|
|
94
|
+
--deploy-dir .build/worker \
|
|
95
|
+
--target worker \
|
|
96
|
+
--entrypoint index.js \
|
|
97
|
+
--assets-dir dist \
|
|
98
|
+
--assets-binding ASSETS \
|
|
99
|
+
--assets-not-found-handling single-page-application \
|
|
100
|
+
--assets-run-worker-first \
|
|
101
|
+
--version 1.4.0 \
|
|
102
|
+
--name "Full-stack release" \
|
|
103
|
+
--notes-file CHANGELOG.md
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Use repeated `--assets-run-worker-first-route <glob>` options instead of the
|
|
107
|
+
boolean flag when only selected routes should invoke the Worker first. The
|
|
108
|
+
hosted LEGO publisher uploads the assets and attaches the binding; Cloudflare
|
|
109
|
+
credentials remain server-side.
|
|
110
|
+
|
|
111
|
+
`.fargo-assets/` is an internal archive directory reserved for Fargo CLI. Do
|
|
112
|
+
not create it, copy frontend files into it, or place browser output inside the
|
|
113
|
+
Worker `--deploy-dir`. Always provide the original frontend build directory
|
|
114
|
+
through `--assets-dir`; the CLI packages it under `.fargo-assets/` and emits the
|
|
115
|
+
required schema v2 metadata. Worker deploys fail early when they contain a
|
|
116
|
+
manual `.fargo-assets/`, a root `index.html`, overlapping deploy/assets
|
|
117
|
+
directories, or asset options without `--assets-dir`.
|
|
118
|
+
|
|
119
|
+
Run `fargo lego deploy --help` for the complete option list and a full-stack
|
|
120
|
+
Worker example. A dry run reports the manifest schema version and separate
|
|
121
|
+
Worker-module and Static Asset file counts.
|
|
122
|
+
|
|
123
|
+
Pass `--source-exclude <gitignore-pattern>` once or multiple times when a
|
|
124
|
+
release command must make additional source exclusions explicit. Secure
|
|
125
|
+
defaults already exclude `node_modules/`, build output, credentials, and common
|
|
126
|
+
generated directories; explicit rules are included in dry-run output.
|
|
127
|
+
|
|
86
128
|
The command creates and uploads two immutable archives:
|
|
87
129
|
|
|
88
130
|
1. a filtered source snapshot for recovery and provenance;
|
|
@@ -101,14 +143,37 @@ Deployment artifacts preserve binary bytes and include
|
|
|
101
143
|
## LEGO D1 migrations
|
|
102
144
|
|
|
103
145
|
```sh
|
|
146
|
+
# Schema-only migrations keep the existing flow
|
|
104
147
|
fargo lego d1 migrate plan --app app_xxx --dir migrations
|
|
105
148
|
fargo lego d1 migrate apply --app app_xxx --dir migrations
|
|
149
|
+
|
|
150
|
+
# Controlled data migrations: review the reported impact first
|
|
151
|
+
fargo lego d1 migrate plan \
|
|
152
|
+
--app app_xxx \
|
|
153
|
+
--dir migrations \
|
|
154
|
+
--table-prefix app_code_
|
|
155
|
+
|
|
156
|
+
# Run only after a separate explicit approval of that plan
|
|
157
|
+
fargo lego d1 migrate apply \
|
|
158
|
+
--app app_xxx \
|
|
159
|
+
--dir migrations \
|
|
160
|
+
--table-prefix app_code_ \
|
|
161
|
+
--allow-data-migrations
|
|
106
162
|
```
|
|
107
163
|
|
|
108
164
|
Migration files are top-level, lexically ordered `*.sql` files. The filename
|
|
109
165
|
without `.sql` is the version. `plan` compares local SHA-256 values with the
|
|
110
166
|
production registry; `apply` stops on content drift and submits only pending
|
|
111
|
-
files.
|
|
167
|
+
files. The plan reports statement types, data-mutation types, target tables,
|
|
168
|
+
and whether separate data-migration approval is required.
|
|
169
|
+
|
|
170
|
+
Schema DDL remains the default. The opt-in data mode accepts `INSERT ... VALUES`
|
|
171
|
+
with optional `ON CONFLICT`, plus `UPDATE` and `DELETE` only when they contain a
|
|
172
|
+
`WHERE` clause. Data targets must use the server-derived App table prefix;
|
|
173
|
+
system tables, cross-table queries, `PRAGMA`, `ATTACH`, and `DROP` remain
|
|
174
|
+
blocked. `--table-prefix` is a local assertion and the service rejects it if it
|
|
175
|
+
does not match the App's authoritative prefix. Migration execution remains
|
|
176
|
+
server-side and is never coupled to deploy.
|
|
112
177
|
|
|
113
178
|
## WorkBuddy adapter
|
|
114
179
|
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -15,6 +15,14 @@ export async function runCli(args, options = {}) {
|
|
|
15
15
|
const stderr = options.stderr ?? process.stderr;
|
|
16
16
|
const store = options.store ?? createCredentialStore(options.storeOptions);
|
|
17
17
|
try {
|
|
18
|
+
if (isHelpOption(args) && args[0] === "lego" && args[1] === "deploy") {
|
|
19
|
+
stdout.write(LEGO_DEPLOY_HELP);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if ((isHelpOption(args) && args.length === 1) || (args[0] === "help" && args.length === 1)) {
|
|
23
|
+
stdout.write(ROOT_HELP);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
18
26
|
if (args[0] === "version" || args[0] === "--version" || args[0] === "-v") {
|
|
19
27
|
stdout.write(`fargo-cli version ${CLI_VERSION}\n`);
|
|
20
28
|
return;
|
|
@@ -59,19 +67,28 @@ export async function runCli(args, options = {}) {
|
|
|
59
67
|
}
|
|
60
68
|
if (args[0] === "lego" && args[1] === "deploy") {
|
|
61
69
|
const appId = requiredOption(args, "--app");
|
|
70
|
+
const assetsDirectory = optionValue(args, "--assets-dir")?.trim();
|
|
71
|
+
assertAssetsDirectoryProvided(args, assetsDirectory);
|
|
62
72
|
const deployDir = requiredOption(args, "--deploy-dir");
|
|
63
73
|
const target = requiredOption(args, "--target");
|
|
64
74
|
const version = requiredVersionOption(args, "--version", 80);
|
|
65
75
|
const versionName = requiredTextOption(args, "--name", 160);
|
|
66
76
|
const releaseNotes = await requiredNotesOption(args);
|
|
77
|
+
const workerFirstRoutes = optionValues(args, "--assets-run-worker-first-route").filter(Boolean);
|
|
67
78
|
const result = await deployLocalLegoApp(store, {
|
|
68
79
|
appId,
|
|
80
|
+
assetsBinding: optionValue(args, "--assets-binding"),
|
|
81
|
+
assetsDirectory,
|
|
82
|
+
assetsHtmlHandling: optionValue(args, "--assets-html-handling"),
|
|
83
|
+
assetsNotFoundHandling: optionValue(args, "--assets-not-found-handling"),
|
|
84
|
+
assetsRunWorkerFirst: workerFirstRoutes.length > 0 ? workerFirstRoutes : args.includes("--assets-run-worker-first"),
|
|
69
85
|
deployDir,
|
|
70
86
|
dryRun: args.includes("--dry-run"),
|
|
71
87
|
entrypoint: optionValue(args, "--entrypoint"),
|
|
72
88
|
releaseNotes,
|
|
73
89
|
skipSource: args.includes("--skip-source"),
|
|
74
90
|
source: optionValue(args, "--source") ?? ".",
|
|
91
|
+
sourceExcludes: optionValues(args, "--source-exclude"),
|
|
75
92
|
target,
|
|
76
93
|
version,
|
|
77
94
|
versionName
|
|
@@ -82,6 +99,7 @@ export async function runCli(args, options = {}) {
|
|
|
82
99
|
if (args[0] === "lego" && args[1] === "d1" && args[2] === "migrate") {
|
|
83
100
|
const action = args[3];
|
|
84
101
|
const input = {
|
|
102
|
+
allowDataMigrations: args.includes("--allow-data-migrations"),
|
|
85
103
|
appId: requiredOption(args, "--app"),
|
|
86
104
|
directory: optionValue(args, "--dir") ?? "migrations",
|
|
87
105
|
tablePrefix: optionValue(args, "--table-prefix")
|
|
@@ -94,7 +112,7 @@ export async function runCli(args, options = {}) {
|
|
|
94
112
|
stdout.write(`${JSON.stringify(await applyLegoMigrations(store, input, options))}\n`);
|
|
95
113
|
return;
|
|
96
114
|
}
|
|
97
|
-
throw new Error("Usage: fargo lego d1 migrate plan|apply --app <appId> [--dir migrations]");
|
|
115
|
+
throw new Error("Usage: fargo lego d1 migrate plan|apply --app <appId> [--dir migrations] [--table-prefix app_prefix_] [--allow-data-migrations]");
|
|
98
116
|
}
|
|
99
117
|
if (args[0] === "workbuddy" && args[1] === "install") {
|
|
100
118
|
let credential = await store.load();
|
|
@@ -121,6 +139,82 @@ export async function runCli(args, options = {}) {
|
|
|
121
139
|
}
|
|
122
140
|
}
|
|
123
141
|
|
|
142
|
+
const ROOT_HELP = `Usage: fargo <command> [options]
|
|
143
|
+
|
|
144
|
+
Commands:
|
|
145
|
+
auth login|device|status|logout
|
|
146
|
+
mcp serve|tools|call
|
|
147
|
+
lego snapshot upload|list|download
|
|
148
|
+
lego deploy
|
|
149
|
+
lego d1 migrate plan|apply
|
|
150
|
+
workbuddy install
|
|
151
|
+
version
|
|
152
|
+
|
|
153
|
+
Run "fargo lego deploy --help" for deployment options.
|
|
154
|
+
`;
|
|
155
|
+
|
|
156
|
+
const LEGO_DEPLOY_HELP = `Usage: fargo lego deploy [options]
|
|
157
|
+
|
|
158
|
+
Required:
|
|
159
|
+
--app <appId> LEGO App ID
|
|
160
|
+
--deploy-dir <directory> Built Pages files or Worker modules
|
|
161
|
+
--target <pages|worker> Deployment target
|
|
162
|
+
--version <version> Release version
|
|
163
|
+
--name <name> Release name
|
|
164
|
+
--notes <text> Release notes (or --notes-file <path>)
|
|
165
|
+
|
|
166
|
+
Source and execution:
|
|
167
|
+
--source <directory> Source snapshot root (default: .)
|
|
168
|
+
--source-exclude <pattern> Repeatable source exclusion
|
|
169
|
+
--entrypoint <path> Default: index.html for Pages, index.js for Worker
|
|
170
|
+
--skip-source Do not upload a source snapshot
|
|
171
|
+
--dry-run Validate and summarize without network writes
|
|
172
|
+
|
|
173
|
+
Worker Static Assets:
|
|
174
|
+
--assets-dir <directory> Built browser assets; required for Worker frontends
|
|
175
|
+
--assets-binding <name> Assets binding (default: ASSETS)
|
|
176
|
+
--assets-html-handling <mode> auto-trailing-slash, force-trailing-slash,
|
|
177
|
+
drop-trailing-slash, or none
|
|
178
|
+
--assets-not-found-handling <mode>
|
|
179
|
+
none, 404-page, or single-page-application
|
|
180
|
+
--assets-run-worker-first Run the Worker before static asset routing
|
|
181
|
+
--assets-run-worker-first-route <glob>
|
|
182
|
+
Repeatable Worker-first route
|
|
183
|
+
|
|
184
|
+
Example:
|
|
185
|
+
fargo lego deploy --app app_xxx --source . \\
|
|
186
|
+
--deploy-dir .build/worker --target worker --entrypoint index.js \\
|
|
187
|
+
--assets-dir dist --assets-binding ASSETS \\
|
|
188
|
+
--assets-not-found-handling single-page-application \\
|
|
189
|
+
--assets-run-worker-first --version 1.4.0 --name "Full-stack release" \\
|
|
190
|
+
--notes-file CHANGELOG.md
|
|
191
|
+
|
|
192
|
+
.fargo-assets/ is an internal archive directory reserved for Fargo CLI.
|
|
193
|
+
Do not create or copy it manually; always pass browser output with --assets-dir.
|
|
194
|
+
`;
|
|
195
|
+
|
|
196
|
+
function isHelpOption(args) {
|
|
197
|
+
return args.includes("--help") || args.includes("-h");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function assertAssetsDirectoryProvided(args, assetsDirectory) {
|
|
201
|
+
const assetOptions = [
|
|
202
|
+
"--assets-binding",
|
|
203
|
+
"--assets-dir",
|
|
204
|
+
"--assets-html-handling",
|
|
205
|
+
"--assets-not-found-handling",
|
|
206
|
+
"--assets-run-worker-first",
|
|
207
|
+
"--assets-run-worker-first-route"
|
|
208
|
+
];
|
|
209
|
+
if (!assetsDirectory && assetOptions.some((name) => hasOption(args, name))) {
|
|
210
|
+
throw new Error("Worker Static Assets options require --assets-dir <directory>; .fargo-assets/ is reserved for Fargo CLI and must not be created manually");
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function hasOption(args, name) {
|
|
215
|
+
return args.some((value) => value === name || value.startsWith(`${name}=`));
|
|
216
|
+
}
|
|
217
|
+
|
|
124
218
|
async function authorizeWithPkce(args, stdout, store, options) {
|
|
125
219
|
const browserMode = optionValue(args, "--open-browser") ?? "auto";
|
|
126
220
|
await authorizePkce(store, {
|
|
@@ -150,6 +244,7 @@ async function runLegoSnapshotCommand(args, stdout, store, options) {
|
|
|
150
244
|
notes: await optionalNotesOption(args),
|
|
151
245
|
purpose: "backup",
|
|
152
246
|
source: optionValue(args, "--source") ?? ".",
|
|
247
|
+
sourceExcludes: optionValues(args, "--source-exclude"),
|
|
153
248
|
version: requiredVersionOption(args, "--version", 120),
|
|
154
249
|
versionName: requiredTextOption(args, "--name", 160)
|
|
155
250
|
}, options);
|
|
@@ -262,3 +357,19 @@ function optionValue(args, name) {
|
|
|
262
357
|
const index = args.indexOf(name);
|
|
263
358
|
return index >= 0 ? args[index + 1] : undefined;
|
|
264
359
|
}
|
|
360
|
+
|
|
361
|
+
function optionValues(args, name) {
|
|
362
|
+
const values = [];
|
|
363
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
364
|
+
const value = args[index];
|
|
365
|
+
if (value.startsWith(`${name}=`)) {
|
|
366
|
+
values.push(value.slice(name.length + 1));
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (value === name) {
|
|
370
|
+
values.push(args[index + 1] ?? "");
|
|
371
|
+
index += 1;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return values;
|
|
375
|
+
}
|
package/src/constants.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const CLI_VERSION = "0.7.
|
|
1
|
+
export const CLI_VERSION = "0.7.2";
|
|
2
2
|
export const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
3
3
|
export const DEFAULT_AUTH_URL = "https://auth.fargowealth.app";
|
|
4
4
|
export const DEFAULT_API_URL = "https://mcp.fargowealth.app";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { lstat, opendir, readFile } from "node:fs/promises";
|
|
3
|
-
import { basename, extname, join, resolve, sep } from "node:path";
|
|
3
|
+
import { basename, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { strToU8, zipSync } from "fflate";
|
|
5
5
|
import ignore from "ignore";
|
|
6
6
|
|
|
@@ -11,6 +11,7 @@ export const MAX_DEPLOY_CONTENT_BYTES = 25_000_000;
|
|
|
11
11
|
|
|
12
12
|
const FIXED_ZIP_DATE = new Date(1980, 0, 1, 0, 0, 0, 0);
|
|
13
13
|
const MANIFEST_PATH = ".fargo-deploy/manifest.json";
|
|
14
|
+
const WORKER_ASSET_ARCHIVE_DIRECTORY = ".fargo-assets";
|
|
14
15
|
const DEFAULT_IGNORE_RULES = [
|
|
15
16
|
".git/",
|
|
16
17
|
"node_modules/",
|
|
@@ -36,13 +37,32 @@ export async function createDeploymentArtifact(deployDirectory, options = {}) {
|
|
|
36
37
|
|
|
37
38
|
const target = deploymentTarget(options.target);
|
|
38
39
|
const entrypoint = normalizePath(options.entrypoint || (target === "pages" ? "index.html" : "index.js"));
|
|
40
|
+
const assets = normalizeWorkerAssets(options.assets, target);
|
|
41
|
+
await assertReservedArchiveDirectoryAbsent(root);
|
|
39
42
|
const matcher = ignore().add(DEFAULT_IGNORE_RULES);
|
|
40
43
|
const customRules = await readOptionalText(join(root, ".legodeployignore"));
|
|
41
44
|
if (customRules) matcher.add(customRules);
|
|
42
45
|
|
|
43
46
|
const candidates = [];
|
|
44
47
|
const skippedSymlinks = [];
|
|
45
|
-
await walk(root, "");
|
|
48
|
+
await walk(root, "", matcher, "");
|
|
49
|
+
if (target === "worker" && candidates.some((file) => file.path === "index.html")) {
|
|
50
|
+
throw new Error("Worker deployment output contains browser entry file 'index.html'. Keep Worker modules in --deploy-dir and pass frontend output separately with --assets-dir <directory>");
|
|
51
|
+
}
|
|
52
|
+
if (assets) {
|
|
53
|
+
const assetsRoot = resolve(assets.sourceDirectory);
|
|
54
|
+
const assetsRootStat = await lstat(assetsRoot).catch(() => undefined);
|
|
55
|
+
if (!assetsRootStat?.isDirectory()) {
|
|
56
|
+
throw new Error(`Worker assets output is not a directory: ${assetsRoot}`);
|
|
57
|
+
}
|
|
58
|
+
if (directoriesOverlap(root, assetsRoot)) {
|
|
59
|
+
throw new Error("--deploy-dir and --assets-dir must be separate, non-overlapping directories so browser assets are not uploaded as Worker modules");
|
|
60
|
+
}
|
|
61
|
+
const assetsMatcher = ignore().add(DEFAULT_IGNORE_RULES);
|
|
62
|
+
const assetsIgnore = await readOptionalText(join(assetsRoot, ".assetsignore"));
|
|
63
|
+
if (assetsIgnore) assetsMatcher.add(assetsIgnore);
|
|
64
|
+
await walk(assetsRoot, "", assetsMatcher, WORKER_ASSET_ARCHIVE_DIRECTORY);
|
|
65
|
+
}
|
|
46
66
|
candidates.sort((left, right) => compareText(left.path, right.path));
|
|
47
67
|
if (candidates.length === 0) {
|
|
48
68
|
throw new Error("No deployment files remain after applying deployment exclusions");
|
|
@@ -75,7 +95,7 @@ export async function createDeploymentArtifact(deployDirectory, options = {}) {
|
|
|
75
95
|
|
|
76
96
|
const contentSha256 = contentHash.digest("hex");
|
|
77
97
|
const manifest = {
|
|
78
|
-
schemaVersion: 1,
|
|
98
|
+
schemaVersion: assets ? 2 : 1,
|
|
79
99
|
kind: "fargo-lego-deployment-artifact",
|
|
80
100
|
archiveFormat: "zip",
|
|
81
101
|
target,
|
|
@@ -83,7 +103,16 @@ export async function createDeploymentArtifact(deployDirectory, options = {}) {
|
|
|
83
103
|
contentSha256,
|
|
84
104
|
fileCount: files.length,
|
|
85
105
|
contentSizeBytes,
|
|
86
|
-
files
|
|
106
|
+
files,
|
|
107
|
+
...(assets ? {
|
|
108
|
+
assets: {
|
|
109
|
+
binding: assets.binding,
|
|
110
|
+
directory: WORKER_ASSET_ARCHIVE_DIRECTORY,
|
|
111
|
+
htmlHandling: assets.htmlHandling,
|
|
112
|
+
notFoundHandling: assets.notFoundHandling,
|
|
113
|
+
runWorkerFirst: assets.runWorkerFirst
|
|
114
|
+
}
|
|
115
|
+
} : {})
|
|
87
116
|
};
|
|
88
117
|
archiveEntries[MANIFEST_PATH] = [strToU8(`${JSON.stringify(manifest, null, 2)}\n`), { level: 6, mtime: FIXED_ZIP_DATE }];
|
|
89
118
|
const archive = zipSync(archiveEntries, { level: 6, mtime: FIXED_ZIP_DATE });
|
|
@@ -103,10 +132,11 @@ export async function createDeploymentArtifact(deployDirectory, options = {}) {
|
|
|
103
132
|
manifest,
|
|
104
133
|
root,
|
|
105
134
|
skippedSymlinkCount: skippedSymlinks.length,
|
|
106
|
-
target
|
|
135
|
+
target,
|
|
136
|
+
assets: manifest.assets
|
|
107
137
|
};
|
|
108
138
|
|
|
109
|
-
async function walk(absoluteDirectory, relativeDirectory) {
|
|
139
|
+
async function walk(absoluteDirectory, relativeDirectory, activeMatcher, archivePrefix) {
|
|
110
140
|
const directory = await opendir(absoluteDirectory);
|
|
111
141
|
const entries = [];
|
|
112
142
|
for await (const entry of directory) entries.push(entry);
|
|
@@ -115,21 +145,22 @@ export async function createDeploymentArtifact(deployDirectory, options = {}) {
|
|
|
115
145
|
for (const entry of entries) {
|
|
116
146
|
const relativePath = normalizePath(relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name);
|
|
117
147
|
const absolutePath = join(absoluteDirectory, entry.name);
|
|
148
|
+
const archivePath = normalizePath(archivePrefix ? `${archivePrefix}/${relativePath}` : relativePath);
|
|
118
149
|
if (entry.isSymbolicLink()) {
|
|
119
|
-
skippedSymlinks.push(
|
|
150
|
+
skippedSymlinks.push(archivePath);
|
|
120
151
|
continue;
|
|
121
152
|
}
|
|
122
153
|
if (entry.isDirectory()) {
|
|
123
|
-
if (!
|
|
154
|
+
if (!activeMatcher.ignores(`${relativePath}/`)) await walk(absolutePath, relativePath, activeMatcher, archivePrefix);
|
|
124
155
|
continue;
|
|
125
156
|
}
|
|
126
|
-
if (!entry.isFile() ||
|
|
157
|
+
if (!entry.isFile() || activeMatcher.ignores(relativePath) || relativePath === ".legodeployignore" || relativePath === ".assetsignore") continue;
|
|
127
158
|
const stat = await lstat(absolutePath);
|
|
128
159
|
const maxFileBytes = options.maxFileBytes ?? MAX_DEPLOY_FILE_BYTES;
|
|
129
160
|
if (stat.size > maxFileBytes) {
|
|
130
161
|
throw new Error(`Deployment file '${relativePath}' is ${formatBytes(stat.size)}, above the ${formatBytes(maxFileBytes)} per-file limit`);
|
|
131
162
|
}
|
|
132
|
-
candidates.push({ absolutePath, path:
|
|
163
|
+
candidates.push({ absolutePath, path: archivePath });
|
|
133
164
|
if (candidates.length > (options.maxFileCount ?? MAX_DEPLOY_FILE_COUNT)) {
|
|
134
165
|
throw new Error(`Deployment output contains more than ${options.maxFileCount ?? MAX_DEPLOY_FILE_COUNT} files`);
|
|
135
166
|
}
|
|
@@ -137,6 +168,54 @@ export async function createDeploymentArtifact(deployDirectory, options = {}) {
|
|
|
137
168
|
}
|
|
138
169
|
}
|
|
139
170
|
|
|
171
|
+
async function assertReservedArchiveDirectoryAbsent(root) {
|
|
172
|
+
const reservedPath = join(root, WORKER_ASSET_ARCHIVE_DIRECTORY);
|
|
173
|
+
const reservedStat = await lstat(reservedPath).catch((error) => {
|
|
174
|
+
if (error?.code === "ENOENT") return undefined;
|
|
175
|
+
throw error;
|
|
176
|
+
});
|
|
177
|
+
if (reservedStat) {
|
|
178
|
+
throw new Error("Deployment output contains reserved path '.fargo-assets'. Do not create it manually; pass browser output with --assets-dir <directory> and Fargo CLI will package it");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function directoriesOverlap(left, right) {
|
|
183
|
+
return isSameOrInside(left, right) || isSameOrInside(right, left);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isSameOrInside(parent, candidate) {
|
|
187
|
+
const path = relative(parent, candidate);
|
|
188
|
+
return path === "" || (!path.startsWith("..") && !isAbsolute(path));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function normalizeWorkerAssets(value, target) {
|
|
192
|
+
if (!value?.sourceDirectory) return undefined;
|
|
193
|
+
if (target !== "worker") throw new Error("Static assets are supported only for Worker deployments");
|
|
194
|
+
const binding = String(value.binding || "ASSETS").trim();
|
|
195
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(binding)) throw new Error("Invalid Worker assets binding");
|
|
196
|
+
const notFoundHandling = String(value.notFoundHandling || "none").trim();
|
|
197
|
+
if (!["none", "404-page", "single-page-application"].includes(notFoundHandling)) {
|
|
198
|
+
throw new Error("Invalid Worker assets not-found handling");
|
|
199
|
+
}
|
|
200
|
+
const htmlHandling = String(value.htmlHandling || "auto-trailing-slash").trim();
|
|
201
|
+
if (!["auto-trailing-slash", "force-trailing-slash", "drop-trailing-slash", "none"].includes(htmlHandling)) {
|
|
202
|
+
throw new Error("Invalid Worker assets HTML handling");
|
|
203
|
+
}
|
|
204
|
+
const runWorkerFirst = Array.isArray(value.runWorkerFirst)
|
|
205
|
+
? value.runWorkerFirst.map((route) => String(route).trim()).filter(Boolean)
|
|
206
|
+
: value.runWorkerFirst === true;
|
|
207
|
+
if (Array.isArray(runWorkerFirst) && runWorkerFirst.length === 0) {
|
|
208
|
+
throw new Error("Worker-first asset routes must not be empty");
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
binding,
|
|
212
|
+
htmlHandling,
|
|
213
|
+
notFoundHandling,
|
|
214
|
+
runWorkerFirst,
|
|
215
|
+
sourceDirectory: value.sourceDirectory
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
140
219
|
export function contentTypeForPath(path) {
|
|
141
220
|
const extension = extname(path).toLowerCase();
|
|
142
221
|
const known = {
|
|
@@ -27,16 +27,25 @@ export async function uploadLegoDeployArtifact(store, input, options = {}) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export function deploymentArtifactSummary(artifact) {
|
|
30
|
+
const assetPrefix = artifact.assets ? `${artifact.assets.directory}/` : undefined;
|
|
31
|
+
const staticAssetFileCount = assetPrefix
|
|
32
|
+
? artifact.manifest.files.filter((file) => file.path.startsWith(assetPrefix)).length
|
|
33
|
+
: 0;
|
|
30
34
|
return {
|
|
31
35
|
archiveName: artifact.archiveName,
|
|
32
36
|
archiveSha256: artifact.archiveSha256,
|
|
33
37
|
archiveSizeBytes: artifact.archive.byteLength,
|
|
38
|
+
assets: artifact.assets,
|
|
34
39
|
contentSha256: artifact.contentSha256,
|
|
35
40
|
contentSizeBytes: artifact.contentSizeBytes,
|
|
36
41
|
entrypoint: artifact.entrypoint,
|
|
42
|
+
fileBreakdown: artifact.target === "worker"
|
|
43
|
+
? { staticAssets: staticAssetFileCount, workerModules: artifact.fileCount - staticAssetFileCount }
|
|
44
|
+
: { pagesFiles: artifact.fileCount },
|
|
37
45
|
fileCount: artifact.fileCount,
|
|
38
46
|
root: artifact.root,
|
|
39
47
|
skippedSymlinkCount: artifact.skippedSymlinkCount,
|
|
48
|
+
schemaVersion: artifact.manifest.schemaVersion,
|
|
40
49
|
target: artifact.target
|
|
41
50
|
};
|
|
42
51
|
}
|
package/src/lego-deploy.mjs
CHANGED
|
@@ -9,8 +9,22 @@ export async function deployLocalLegoApp(store, input, options = {}) {
|
|
|
9
9
|
const target = requiredTarget(input.target);
|
|
10
10
|
const entrypoint = input.entrypoint || (target === "pages" ? "index.html" : "index.js");
|
|
11
11
|
const [snapshot, artifact] = await Promise.all([
|
|
12
|
-
input.skipSource ? Promise.resolve(undefined) : createSourceSnapshot(input.source || ".",
|
|
13
|
-
|
|
12
|
+
input.skipSource ? Promise.resolve(undefined) : createSourceSnapshot(input.source || ".", {
|
|
13
|
+
...options.snapshotOptions,
|
|
14
|
+
ignoreRules: [...(options.snapshotOptions?.ignoreRules ?? []), ...(input.sourceExcludes ?? [])]
|
|
15
|
+
}),
|
|
16
|
+
createDeploymentArtifact(input.deployDir, {
|
|
17
|
+
...options.artifactOptions,
|
|
18
|
+
target,
|
|
19
|
+
entrypoint,
|
|
20
|
+
assets: input.assetsDirectory ? {
|
|
21
|
+
sourceDirectory: input.assetsDirectory,
|
|
22
|
+
binding: input.assetsBinding,
|
|
23
|
+
htmlHandling: input.assetsHtmlHandling,
|
|
24
|
+
notFoundHandling: input.assetsNotFoundHandling,
|
|
25
|
+
runWorkerFirst: input.assetsRunWorkerFirst
|
|
26
|
+
} : undefined
|
|
27
|
+
})
|
|
14
28
|
]);
|
|
15
29
|
|
|
16
30
|
if (input.dryRun) {
|
|
@@ -79,6 +93,7 @@ function sourceSnapshotSummary(snapshot) {
|
|
|
79
93
|
fileCount: snapshot.fileCount,
|
|
80
94
|
root: snapshot.root,
|
|
81
95
|
skippedSymlinkCount: snapshot.skippedSymlinkCount,
|
|
96
|
+
sourceExcludes: snapshot.sourceExcludes,
|
|
82
97
|
sourceSizeBytes: snapshot.sourceSizeBytes
|
|
83
98
|
};
|
|
84
99
|
}
|
package/src/lego-migrations.mjs
CHANGED
|
@@ -12,7 +12,7 @@ export async function planLegoMigrations(store, input, options = {}) {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
async function buildMigrationPlan(store, input, options) {
|
|
15
|
-
const local = await readMigrationDirectory(input.directory);
|
|
15
|
+
const local = await readMigrationDirectory(input.directory, { tablePrefix: input.tablePrefix });
|
|
16
16
|
const remote = mcpToolPayload(await callMcpTool(store, "lego_d1_migrations_list", { appId: input.appId }, options));
|
|
17
17
|
const applied = new Map((remote.migrations ?? []).map((migration) => [String(migration.version), migration]));
|
|
18
18
|
const migrations = local.map((migration) => {
|
|
@@ -23,6 +23,7 @@ async function buildMigrationPlan(store, input, options) {
|
|
|
23
23
|
? { ...migration, status: "applied", remote: existing }
|
|
24
24
|
: { ...migration, status: "drift", remote: existing };
|
|
25
25
|
});
|
|
26
|
+
const pendingDataMigrations = migrations.filter((item) => item.status === "pending" && item.impact.dataStatementCount > 0);
|
|
26
27
|
return {
|
|
27
28
|
appId: input.appId,
|
|
28
29
|
directory: resolve(input.directory || "migrations"),
|
|
@@ -30,8 +31,13 @@ async function buildMigrationPlan(store, input, options) {
|
|
|
30
31
|
summary: {
|
|
31
32
|
applied: migrations.filter((item) => item.status === "applied").length,
|
|
32
33
|
appliedUnverified: migrations.filter((item) => item.status === "applied_unverified").length,
|
|
34
|
+
dataMigrationCount: migrations.filter((item) => item.impact.dataStatementCount > 0).length,
|
|
35
|
+
dataMutationTypes: Array.from(new Set(migrations.flatMap((item) => item.impact.dataMutationTypes))).sort(),
|
|
36
|
+
dataStatementCount: migrations.reduce((total, item) => total + item.impact.dataStatementCount, 0),
|
|
33
37
|
drift: migrations.filter((item) => item.status === "drift").length,
|
|
34
|
-
pending: migrations.filter((item) => item.status === "pending").length
|
|
38
|
+
pending: migrations.filter((item) => item.status === "pending").length,
|
|
39
|
+
pendingDataMigrationCount: pendingDataMigrations.length,
|
|
40
|
+
requiresDataMigrationApproval: pendingDataMigrations.length > 0
|
|
35
41
|
}
|
|
36
42
|
};
|
|
37
43
|
}
|
|
@@ -42,11 +48,22 @@ export async function applyLegoMigrations(store, input, options = {}) {
|
|
|
42
48
|
if (drift.length > 0) {
|
|
43
49
|
throw new Error(`Migration drift detected: ${drift.map((item) => item.fileName).join(", ")}`);
|
|
44
50
|
}
|
|
51
|
+
const pendingDataMigrations = plan.migrations.filter((migration) => migration.status === "pending" && migration.impact.dataStatementCount > 0);
|
|
52
|
+
if (pendingDataMigrations.length > 0 && !input.allowDataMigrations) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Pending data migrations require separate approval: ${pendingDataMigrations.map((item) => item.fileName).join(", ")}. ` +
|
|
55
|
+
"Review the plan, obtain explicit approval, then rerun apply with --allow-data-migrations."
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (pendingDataMigrations.length > 0 && !String(input.tablePrefix ?? "").trim()) {
|
|
59
|
+
throw new Error("Data migrations require an explicit --table-prefix so App-owned table targets can be verified locally and by MCP");
|
|
60
|
+
}
|
|
45
61
|
|
|
46
62
|
const applied = [];
|
|
47
63
|
for (const migration of plan.migrations.filter((item) => item.status === "pending")) {
|
|
48
64
|
const result = mcpToolPayload(await callMcpTool(store, "lego_d1_migrations_apply", {
|
|
49
65
|
appId: input.appId,
|
|
66
|
+
allowDataMigrations: migration.impact.dataStatementCount > 0 && input.allowDataMigrations === true,
|
|
50
67
|
contentSha256: migration.contentSha256,
|
|
51
68
|
fileName: migration.fileName,
|
|
52
69
|
sql: migration.sql,
|
|
@@ -74,7 +91,7 @@ function publicMigrationPlan(plan) {
|
|
|
74
91
|
};
|
|
75
92
|
}
|
|
76
93
|
|
|
77
|
-
async function readMigrationDirectory(directory) {
|
|
94
|
+
async function readMigrationDirectory(directory, options = {}) {
|
|
78
95
|
const root = resolve(directory || "migrations");
|
|
79
96
|
const stat = await lstat(root).catch(() => undefined);
|
|
80
97
|
if (!stat?.isDirectory()) throw new Error(`Migration directory is not a directory: ${root}`);
|
|
@@ -101,9 +118,11 @@ async function readMigrationDirectory(directory) {
|
|
|
101
118
|
const sizeBytes = Buffer.byteLength(sql);
|
|
102
119
|
if (sizeBytes === 0) throw new Error(`Migration file is empty: ${entry.name}`);
|
|
103
120
|
if (sizeBytes > MAX_MIGRATION_BYTES) throw new Error(`Migration file exceeds ${MAX_MIGRATION_BYTES} bytes: ${entry.name}`);
|
|
121
|
+
const impact = analyzeLegoMigrationSql(sql, entry.name, options);
|
|
104
122
|
migrations.push({
|
|
105
123
|
contentSha256: createHash("sha256").update(sql).digest("hex"),
|
|
106
124
|
fileName: entry.name,
|
|
125
|
+
impact,
|
|
107
126
|
sizeBytes,
|
|
108
127
|
sql,
|
|
109
128
|
version
|
|
@@ -111,3 +130,199 @@ async function readMigrationDirectory(directory) {
|
|
|
111
130
|
}
|
|
112
131
|
return migrations;
|
|
113
132
|
}
|
|
133
|
+
|
|
134
|
+
export function analyzeLegoMigrationSql(sql, fileName = "migration.sql", options = {}) {
|
|
135
|
+
const statements = parseMigrationStatements(sql);
|
|
136
|
+
if (statements.length === 0) throw new Error(`Migration file contains no SQL statements: ${fileName}`);
|
|
137
|
+
const analyses = [];
|
|
138
|
+
for (let index = 0; index < statements.length; index += 1) {
|
|
139
|
+
const statement = statements[index];
|
|
140
|
+
try {
|
|
141
|
+
analyses.push(analyzeMigrationStatement(statement, options));
|
|
142
|
+
} catch (error) {
|
|
143
|
+
const kind = statement.replace(/^\s+/, "").match(/^([A-Za-z]+)/)?.[1]?.toUpperCase() ?? "UNKNOWN";
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Unsupported App D1 migration statement in ${fileName} at statement ${index + 1} (${kind}). ` +
|
|
146
|
+
`${error instanceof Error ? error.message : "Statement failed validation"}. ` +
|
|
147
|
+
"Allowed schema statements are CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS, and ALTER TABLE ADD COLUMN. " +
|
|
148
|
+
"Controlled data statements are INSERT ... VALUES [ON CONFLICT], plus UPDATE or DELETE with WHERE.",
|
|
149
|
+
{ cause: error }
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const data = analyses.filter((item) => item.category === "data");
|
|
154
|
+
return {
|
|
155
|
+
dataMutationTypes: Array.from(new Set(data.map((item) => item.type))).sort(),
|
|
156
|
+
dataStatementCount: data.length,
|
|
157
|
+
dataTables: Array.from(new Set(data.map((item) => item.table))).sort(),
|
|
158
|
+
requiresDataMigrationApproval: data.length > 0,
|
|
159
|
+
schemaStatementCount: analyses.length - data.length,
|
|
160
|
+
statementCount: analyses.length,
|
|
161
|
+
statementTypes: analyses.map((item) => item.type)
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export const validateLegoMigrationSql = analyzeLegoMigrationSql;
|
|
166
|
+
|
|
167
|
+
function parseMigrationStatements(sql) {
|
|
168
|
+
const statements = [];
|
|
169
|
+
let current = "";
|
|
170
|
+
let quote;
|
|
171
|
+
let blockComment = false;
|
|
172
|
+
let lineComment = false;
|
|
173
|
+
|
|
174
|
+
for (let index = 0; index < sql.length; index += 1) {
|
|
175
|
+
const char = sql[index];
|
|
176
|
+
const next = sql[index + 1];
|
|
177
|
+
if (blockComment) {
|
|
178
|
+
if (char === "*" && next === "/") {
|
|
179
|
+
blockComment = false;
|
|
180
|
+
index += 1;
|
|
181
|
+
}
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (lineComment) {
|
|
185
|
+
if (char === "\n") lineComment = false;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (!quote && char === "/" && next === "*") {
|
|
189
|
+
blockComment = true;
|
|
190
|
+
current += " ";
|
|
191
|
+
index += 1;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (!quote && char === "-" && next === "-") {
|
|
195
|
+
lineComment = true;
|
|
196
|
+
current += " ";
|
|
197
|
+
index += 1;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if ((char === "'" || char === '"') && sql[index - 1] !== "\\") {
|
|
201
|
+
quote = quote === char ? undefined : quote ?? char;
|
|
202
|
+
}
|
|
203
|
+
if (!quote && char === ";") {
|
|
204
|
+
const statement = current.trim();
|
|
205
|
+
if (statement) statements.push(statement);
|
|
206
|
+
current = "";
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
current += char;
|
|
210
|
+
}
|
|
211
|
+
if (blockComment) throw new Error("Migration SQL contains an unterminated block comment");
|
|
212
|
+
|
|
213
|
+
const trailing = current.trim();
|
|
214
|
+
if (trailing) statements.push(trailing);
|
|
215
|
+
return statements;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function analyzeMigrationStatement(statement, options = {}) {
|
|
219
|
+
const normalized = statement.replace(/\s+/g, " ").trim();
|
|
220
|
+
const createTable = normalized.match(/^CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/i);
|
|
221
|
+
if (createTable?.[1]) {
|
|
222
|
+
assertMigrationIdentifier(createTable[1]);
|
|
223
|
+
assertOwnedReferences(normalized, options.tablePrefix);
|
|
224
|
+
return { category: "schema", table: createTable[1], type: "create_table" };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const createIndex = normalized.match(/^CREATE\s+INDEX\s+IF\s+NOT\s+EXISTS\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+ON\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/i);
|
|
228
|
+
if (createIndex?.[1] && createIndex[2]) {
|
|
229
|
+
assertMigrationIdentifier(createIndex[1]);
|
|
230
|
+
assertMigrationIdentifier(createIndex[2]);
|
|
231
|
+
return { category: "schema", table: createIndex[2], type: "create_index" };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const alterTable = normalized.match(/^ALTER\s+TABLE\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+ADD\s+COLUMN\s+([a-zA-Z_][a-zA-Z0-9_]*)\b/i);
|
|
235
|
+
if (alterTable?.[1]) {
|
|
236
|
+
assertMigrationIdentifier(alterTable[1]);
|
|
237
|
+
assertOwnedReferences(normalized, options.tablePrefix);
|
|
238
|
+
return { category: "schema", table: alterTable[1], type: "alter_table" };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const structure = stripQuotedSql(normalized);
|
|
242
|
+
if (/^(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+[a-zA-Z_][a-zA-Z0-9_]*\s*\./i.test(structure)) {
|
|
243
|
+
throw new Error("Data migrations cannot use qualified table names");
|
|
244
|
+
}
|
|
245
|
+
const insert = structure.match(/^INSERT\s+INTO\s+([a-zA-Z_][a-zA-Z0-9_]*)\b/i);
|
|
246
|
+
if (insert?.[1]) {
|
|
247
|
+
assertOwnedDataTable(insert[1], options.tablePrefix);
|
|
248
|
+
assertNoCrossResourceDataQuery(structure, "insert");
|
|
249
|
+
if (!/\bVALUES\s*\(/i.test(structure)) throw new Error("INSERT data migrations must use VALUES");
|
|
250
|
+
if (/\bON\s+CONFLICT\b/i.test(structure) && !/\bDO\s+(?:NOTHING|UPDATE\s+SET)\b/i.test(structure)) {
|
|
251
|
+
throw new Error("INSERT ON CONFLICT must use DO NOTHING or DO UPDATE SET");
|
|
252
|
+
}
|
|
253
|
+
return { category: "data", table: insert[1], type: "insert" };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const update = structure.match(/^UPDATE\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+SET\s+/i);
|
|
257
|
+
if (update?.[1]) {
|
|
258
|
+
assertOwnedDataTable(update[1], options.tablePrefix);
|
|
259
|
+
if (!/\bWHERE\b/i.test(structure)) throw new Error("UPDATE data migrations must include WHERE");
|
|
260
|
+
assertNoCrossResourceDataQuery(structure, "update");
|
|
261
|
+
return { category: "data", table: update[1], type: "update" };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const deleteFrom = structure.match(/^DELETE\s+FROM\s+([a-zA-Z_][a-zA-Z0-9_]*)\b/i);
|
|
265
|
+
if (deleteFrom?.[1]) {
|
|
266
|
+
assertOwnedDataTable(deleteFrom[1], options.tablePrefix);
|
|
267
|
+
if (!/\bWHERE\b/i.test(structure)) throw new Error("DELETE data migrations must include WHERE");
|
|
268
|
+
assertNoCrossResourceDataQuery(structure, "delete");
|
|
269
|
+
return { category: "data", table: deleteFrom[1], type: "delete" };
|
|
270
|
+
}
|
|
271
|
+
throw new Error("Statement type is not allowed");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function assertMigrationIdentifier(identifier) {
|
|
275
|
+
if (identifier.toLowerCase().startsWith("sqlite_")) {
|
|
276
|
+
throw new Error("reserved_migration_identifier");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function assertOwnedDataTable(identifier, tablePrefix) {
|
|
281
|
+
assertMigrationIdentifier(identifier);
|
|
282
|
+
const prefix = String(tablePrefix ?? "").trim().toLowerCase();
|
|
283
|
+
if (prefix && !identifier.toLowerCase().startsWith(prefix)) {
|
|
284
|
+
throw new Error(`Data table ${identifier} is outside App prefix ${prefix}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function assertOwnedReferences(statement, tablePrefix) {
|
|
289
|
+
for (const match of statement.matchAll(/\bREFERENCES\s+([a-zA-Z_][a-zA-Z0-9_]*)\b/gi)) {
|
|
290
|
+
if (match[1]) assertOwnedDataTable(match[1], tablePrefix);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function assertNoCrossResourceDataQuery(statement, kind) {
|
|
295
|
+
const forbidden = kind === "delete"
|
|
296
|
+
? /\b(?:SELECT|WITH|JOIN|ATTACH|DETACH|PRAGMA|DROP)\b/i
|
|
297
|
+
: /\b(?:SELECT|WITH|JOIN|FROM|ATTACH|DETACH|PRAGMA|DROP)\b/i;
|
|
298
|
+
if (forbidden.test(statement)) {
|
|
299
|
+
throw new Error("Data migrations cannot query other tables or use administrative SQL");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function stripQuotedSql(value) {
|
|
304
|
+
let result = "";
|
|
305
|
+
let quote;
|
|
306
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
307
|
+
const char = value[index];
|
|
308
|
+
if (quote) {
|
|
309
|
+
if (char === quote) {
|
|
310
|
+
if (value[index + 1] === quote) {
|
|
311
|
+
result += " ";
|
|
312
|
+
index += 1;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
quote = undefined;
|
|
316
|
+
}
|
|
317
|
+
result += " ";
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (char === "'" || char === '"') {
|
|
321
|
+
quote = char;
|
|
322
|
+
result += " ";
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
result += char;
|
|
326
|
+
}
|
|
327
|
+
return result;
|
|
328
|
+
}
|
package/src/lego-snapshots.mjs
CHANGED
|
@@ -3,7 +3,10 @@ import { authenticatedRequest, responsePayload } from "./api-client.mjs";
|
|
|
3
3
|
import { createSourceSnapshot } from "./source-snapshot.mjs";
|
|
4
4
|
|
|
5
5
|
export async function uploadLegoSourceSnapshot(store, input, options = {}) {
|
|
6
|
-
const snapshot = options.snapshot ?? await createSourceSnapshot(input.source ?? ".",
|
|
6
|
+
const snapshot = options.snapshot ?? await createSourceSnapshot(input.source ?? ".", {
|
|
7
|
+
...options.snapshotOptions,
|
|
8
|
+
ignoreRules: [...(options.snapshotOptions?.ignoreRules ?? []), ...(input.sourceExcludes ?? [])]
|
|
9
|
+
});
|
|
7
10
|
if (input.dryRun) {
|
|
8
11
|
return {
|
|
9
12
|
dryRun: true,
|
|
@@ -81,6 +84,7 @@ function snapshotSummary(snapshot) {
|
|
|
81
84
|
fileCount: snapshot.fileCount,
|
|
82
85
|
root: snapshot.root,
|
|
83
86
|
skippedSymlinkCount: snapshot.skippedSymlinkCount,
|
|
87
|
+
sourceExcludes: snapshot.sourceExcludes,
|
|
84
88
|
sourceSizeBytes: snapshot.sourceSizeBytes
|
|
85
89
|
};
|
|
86
90
|
}
|
package/src/source-snapshot.mjs
CHANGED
|
@@ -62,7 +62,8 @@ export async function createSourceSnapshot(sourceDirectory, options = {}) {
|
|
|
62
62
|
throw new Error(`Snapshot source is not a directory: ${root}`);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
const
|
|
65
|
+
const sourceExcludes = normalizeAdditionalIgnoreRules(options.ignoreRules);
|
|
66
|
+
const matcher = ignore().add(DEFAULT_IGNORE_RULES).add(sourceExcludes);
|
|
66
67
|
const customRules = await readOptionalText(join(root, ".legoignore"));
|
|
67
68
|
if (customRules) matcher.add(customRules);
|
|
68
69
|
|
|
@@ -117,6 +118,7 @@ export async function createSourceSnapshot(sourceDirectory, options = {}) {
|
|
|
117
118
|
manifest,
|
|
118
119
|
root,
|
|
119
120
|
skippedSymlinkCount: skippedSymlinks.length,
|
|
121
|
+
sourceExcludes,
|
|
120
122
|
sourceSizeBytes
|
|
121
123
|
};
|
|
122
124
|
|
|
@@ -153,6 +155,19 @@ export async function createSourceSnapshot(sourceDirectory, options = {}) {
|
|
|
153
155
|
}
|
|
154
156
|
}
|
|
155
157
|
|
|
158
|
+
function normalizeAdditionalIgnoreRules(value) {
|
|
159
|
+
if (value === undefined) return [];
|
|
160
|
+
if (!Array.isArray(value)) throw new Error("Source exclusions must be an array");
|
|
161
|
+
if (value.length > 100) throw new Error("No more than 100 --source-exclude rules are allowed");
|
|
162
|
+
return value.map((item) => {
|
|
163
|
+
const rule = String(item ?? "").trim();
|
|
164
|
+
if (!rule || rule.length > 256 || rule.includes("\0") || rule.includes("\n") || rule.includes("\r")) {
|
|
165
|
+
throw new Error("Each --source-exclude rule must be a non-empty single-line pattern of at most 256 characters");
|
|
166
|
+
}
|
|
167
|
+
return rule;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
156
171
|
function digest(value) {
|
|
157
172
|
return createHash("sha256").update(value).digest("hex");
|
|
158
173
|
}
|