@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 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.0
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. Migration execution remains server-side and is never coupled to deploy.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fargowealth/fargo-ai-cli",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Agent-neutral Fargo AI CLI, MCP client, and LEGO project tooling",
5
5
  "type": "module",
6
6
  "bin": {
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.0";
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(relativePath);
150
+ skippedSymlinks.push(archivePath);
120
151
  continue;
121
152
  }
122
153
  if (entry.isDirectory()) {
123
- if (!matcher.ignores(`${relativePath}/`)) await walk(absolutePath, relativePath);
154
+ if (!activeMatcher.ignores(`${relativePath}/`)) await walk(absolutePath, relativePath, activeMatcher, archivePrefix);
124
155
  continue;
125
156
  }
126
- if (!entry.isFile() || matcher.ignores(relativePath) || relativePath === ".legodeployignore") continue;
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: relativePath });
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
  }
@@ -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 || ".", options.snapshotOptions),
13
- createDeploymentArtifact(input.deployDir, { ...options.artifactOptions, target, entrypoint })
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
  }
@@ -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
+ }
@@ -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 ?? ".", options.snapshotOptions);
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
  }
@@ -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 matcher = ignore().add(DEFAULT_IGNORE_RULES);
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
  }