@fargowealth/fargo-ai-cli 0.6.0 → 0.7.1

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.1
4
+
5
+ - Added Worker Static Assets packaging with explicit asset directory, binding,
6
+ SPA/404 handling, HTML handling, and Worker-first routing metadata.
7
+ - Deployment artifacts now preserve Worker code and browser assets separately
8
+ so the hosted LEGO publisher can upload both through Cloudflare's formal API.
9
+
10
+ ## 0.7.0
11
+
12
+ - Added source-backup history metadata and Portal list/download support for
13
+ immutable source versions, including independent backup-only events.
14
+ - Deployment now requires a version number, version name, and upgrade notes;
15
+ history records show the deployer and expandable notes.
16
+ - Source backup remains the deploy default. Added explicit `--skip-source` for
17
+ deployments that intentionally omit a source archive.
18
+
3
19
  ## 0.6.0
4
20
 
5
21
  - Added `fargo lego deploy` to create a source snapshot and deterministic
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.6.0
12
+ npm install -g @fargowealth/fargo-ai-cli@0.7.1
13
13
  fargo version
14
14
  ```
15
15
 
@@ -47,8 +47,8 @@ fargo mcp call lego_get_app --args-file ./arguments.json
47
47
  ## LEGO source snapshots
48
48
 
49
49
  ```sh
50
- fargo lego snapshot upload --app app_xxx --source . --dry-run
51
- fargo lego snapshot upload --app app_xxx --source . --version v1
50
+ fargo lego snapshot upload --app app_xxx --source . --version 1.4.0 --name "Pre-upgrade backup" --dry-run
51
+ fargo lego snapshot upload --app app_xxx --source . --version 1.4.0 --name "Pre-upgrade backup" --notes "Before migration"
52
52
  fargo lego snapshot list --app app_xxx
53
53
  fargo lego snapshot download --app app_xxx --snapshot snap_xxx
54
54
  ```
@@ -57,6 +57,8 @@ The CLI applies secure defaults plus `.legoignore`, skips symbolic links,
57
57
  creates a deterministic ZIP, calculates SHA-256 digests, and uploads raw bytes
58
58
  over authenticated HTTP. The ZIP includes
59
59
  `.fargo-snapshot/manifest.json`; its default archive limit is 50 MB.
60
+ An upload creates a downloadable source-backup history event without deploying
61
+ or publishing the App.
60
62
 
61
63
  ## LEGO local deploy
62
64
 
@@ -67,14 +69,49 @@ npm run build
67
69
  fargo lego deploy \
68
70
  --app app_xxx \
69
71
  --source . \
72
+ --source-exclude node_modules/ \
70
73
  --deploy-dir dist \
71
74
  --target pages \
72
75
  --entrypoint index.html \
73
- --version v1
76
+ --version 1.4.0 \
77
+ --name "Navigation refresh" \
78
+ --notes-file CHANGELOG.md
74
79
  ```
75
80
 
76
81
  For Workers use `--target worker`; the default entrypoint becomes `index.js`.
77
82
  The CLI does not install dependencies, infer a framework, or run the build.
83
+ Version number, version name, and upgrade notes are required. Source upload is
84
+ the default; `--skip-source` is the explicit exception for a deployment that
85
+ must not retain a source archive.
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
+ Pass `--source-exclude <gitignore-pattern>` once or multiple times when a
112
+ release command must make additional source exclusions explicit. Secure
113
+ defaults already exclude `node_modules/`, build output, credentials, and common
114
+ generated directories; explicit rules are included in dry-run output.
78
115
 
79
116
  The command creates and uploads two immutable archives:
80
117
 
@@ -94,14 +131,37 @@ Deployment artifacts preserve binary bytes and include
94
131
  ## LEGO D1 migrations
95
132
 
96
133
  ```sh
134
+ # Schema-only migrations keep the existing flow
97
135
  fargo lego d1 migrate plan --app app_xxx --dir migrations
98
136
  fargo lego d1 migrate apply --app app_xxx --dir migrations
137
+
138
+ # Controlled data migrations: review the reported impact first
139
+ fargo lego d1 migrate plan \
140
+ --app app_xxx \
141
+ --dir migrations \
142
+ --table-prefix app_code_
143
+
144
+ # Run only after a separate explicit approval of that plan
145
+ fargo lego d1 migrate apply \
146
+ --app app_xxx \
147
+ --dir migrations \
148
+ --table-prefix app_code_ \
149
+ --allow-data-migrations
99
150
  ```
100
151
 
101
152
  Migration files are top-level, lexically ordered `*.sql` files. The filename
102
153
  without `.sql` is the version. `plan` compares local SHA-256 values with the
103
154
  production registry; `apply` stops on content drift and submits only pending
104
- files. Migration execution remains server-side and is never coupled to deploy.
155
+ files. The plan reports statement types, data-mutation types, target tables,
156
+ and whether separate data-migration approval is required.
157
+
158
+ Schema DDL remains the default. The opt-in data mode accepts `INSERT ... VALUES`
159
+ with optional `ON CONFLICT`, plus `UPDATE` and `DELETE` only when they contain a
160
+ `WHERE` clause. Data targets must use the server-derived App table prefix;
161
+ system tables, cross-table queries, `PRAGMA`, `ATTACH`, and `DROP` remain
162
+ blocked. `--table-prefix` is a local assertion and the service rejects it if it
163
+ does not match the App's authoritative prefix. Migration execution remains
164
+ server-side and is never coupled to deploy.
105
165
 
106
166
  ## WorkBuddy adapter
107
167
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fargowealth/fargo-ai-cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
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
@@ -58,14 +58,30 @@ export async function runCli(args, options = {}) {
58
58
  return;
59
59
  }
60
60
  if (args[0] === "lego" && args[1] === "deploy") {
61
+ const appId = requiredOption(args, "--app");
62
+ const deployDir = requiredOption(args, "--deploy-dir");
63
+ const target = requiredOption(args, "--target");
64
+ const version = requiredVersionOption(args, "--version", 80);
65
+ const versionName = requiredTextOption(args, "--name", 160);
66
+ const releaseNotes = await requiredNotesOption(args);
67
+ const workerFirstRoutes = optionValues(args, "--assets-run-worker-first-route").filter(Boolean);
61
68
  const result = await deployLocalLegoApp(store, {
62
- appId: requiredOption(args, "--app"),
63
- deployDir: requiredOption(args, "--deploy-dir"),
69
+ appId,
70
+ assetsBinding: optionValue(args, "--assets-binding"),
71
+ assetsDirectory: optionValue(args, "--assets-dir"),
72
+ assetsHtmlHandling: optionValue(args, "--assets-html-handling"),
73
+ assetsNotFoundHandling: optionValue(args, "--assets-not-found-handling"),
74
+ assetsRunWorkerFirst: workerFirstRoutes.length > 0 ? workerFirstRoutes : args.includes("--assets-run-worker-first"),
75
+ deployDir,
64
76
  dryRun: args.includes("--dry-run"),
65
77
  entrypoint: optionValue(args, "--entrypoint"),
78
+ releaseNotes,
79
+ skipSource: args.includes("--skip-source"),
66
80
  source: optionValue(args, "--source") ?? ".",
67
- target: requiredOption(args, "--target"),
68
- version: optionValue(args, "--version")
81
+ sourceExcludes: optionValues(args, "--source-exclude"),
82
+ target,
83
+ version,
84
+ versionName
69
85
  }, options);
70
86
  stdout.write(`${JSON.stringify(result)}\n`);
71
87
  return;
@@ -73,6 +89,7 @@ export async function runCli(args, options = {}) {
73
89
  if (args[0] === "lego" && args[1] === "d1" && args[2] === "migrate") {
74
90
  const action = args[3];
75
91
  const input = {
92
+ allowDataMigrations: args.includes("--allow-data-migrations"),
76
93
  appId: requiredOption(args, "--app"),
77
94
  directory: optionValue(args, "--dir") ?? "migrations",
78
95
  tablePrefix: optionValue(args, "--table-prefix")
@@ -85,7 +102,7 @@ export async function runCli(args, options = {}) {
85
102
  stdout.write(`${JSON.stringify(await applyLegoMigrations(store, input, options))}\n`);
86
103
  return;
87
104
  }
88
- throw new Error("Usage: fargo lego d1 migrate plan|apply --app <appId> [--dir migrations]");
105
+ throw new Error("Usage: fargo lego d1 migrate plan|apply --app <appId> [--dir migrations] [--table-prefix app_prefix_] [--allow-data-migrations]");
89
106
  }
90
107
  if (args[0] === "workbuddy" && args[1] === "install") {
91
108
  let credential = await store.load();
@@ -138,8 +155,12 @@ async function runLegoSnapshotCommand(args, stdout, store, options) {
138
155
  const result = await uploadLegoSourceSnapshot(store, {
139
156
  appId,
140
157
  dryRun: args.includes("--dry-run"),
158
+ notes: await optionalNotesOption(args),
159
+ purpose: "backup",
141
160
  source: optionValue(args, "--source") ?? ".",
142
- version: optionValue(args, "--version")
161
+ sourceExcludes: optionValues(args, "--source-exclude"),
162
+ version: requiredVersionOption(args, "--version", 120),
163
+ versionName: requiredTextOption(args, "--name", 160)
143
164
  }, options);
144
165
  stdout.write(`${JSON.stringify(result)}\n`);
145
166
  return;
@@ -160,7 +181,25 @@ async function runLegoSnapshotCommand(args, stdout, store, options) {
160
181
  stdout.write(`${JSON.stringify({ archiveSha256: result.archiveSha256, outputPath, sizeBytes: result.archive.byteLength, snapshotId })}\n`);
161
182
  return;
162
183
  }
163
- throw new Error("Usage: fargo lego snapshot upload|list|download --app <appId>");
184
+ throw new Error("Usage: fargo lego snapshot upload --app <appId> --version <number> --name <name> [--notes TEXT | --notes-file PATH] | list|download --app <appId>");
185
+ }
186
+
187
+ async function requiredNotesOption(args) {
188
+ const notes = await optionalNotesOption(args);
189
+ if (!notes) throw new Error("Missing release notes; pass --notes <text> or --notes-file <path>");
190
+ if (notes.length > 4_000) throw new Error("Release notes must not exceed 4000 characters");
191
+ return notes;
192
+ }
193
+
194
+ async function optionalNotesOption(args) {
195
+ const inline = optionValue(args, "--notes");
196
+ const file = optionValue(args, "--notes-file");
197
+ if (inline !== undefined && file !== undefined) throw new Error("Use only one of --notes or --notes-file");
198
+ const notes = file !== undefined
199
+ ? (await readFile(resolve(file), "utf8")).trim()
200
+ : inline?.trim() ?? "";
201
+ if (notes.length > 4_000) throw new Error("Notes must not exceed 4000 characters");
202
+ return notes;
164
203
  }
165
204
 
166
205
  async function readToolArgs(args) {
@@ -192,7 +231,21 @@ async function pathExists(path) {
192
231
 
193
232
  function requiredOption(args, name) {
194
233
  const value = optionValue(args, name)?.trim();
195
- if (!value) throw new Error(`Missing required option ${name}`);
234
+ if (!value || value.startsWith("--")) throw new Error(`Missing required option ${name}`);
235
+ return value;
236
+ }
237
+
238
+ function requiredTextOption(args, name, maxLength) {
239
+ const value = requiredOption(args, name);
240
+ if (value.length > maxLength) throw new Error(`${name} must not exceed ${maxLength} characters`);
241
+ return value;
242
+ }
243
+
244
+ function requiredVersionOption(args, name, maxLength) {
245
+ const value = requiredTextOption(args, name, maxLength);
246
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) {
247
+ throw new Error(`${name} must use letters, numbers, dot, underscore, or hyphen`);
248
+ }
196
249
  return value;
197
250
  }
198
251
 
@@ -218,3 +271,19 @@ function optionValue(args, name) {
218
271
  const index = args.indexOf(name);
219
272
  return index >= 0 ? args[index + 1] : undefined;
220
273
  }
274
+
275
+ function optionValues(args, name) {
276
+ const values = [];
277
+ for (let index = 0; index < args.length; index += 1) {
278
+ const value = args[index];
279
+ if (value.startsWith(`${name}=`)) {
280
+ values.push(value.slice(name.length + 1));
281
+ continue;
282
+ }
283
+ if (value === name) {
284
+ values.push(args[index + 1] ?? "");
285
+ index += 1;
286
+ }
287
+ }
288
+ return values;
289
+ }
package/src/constants.mjs CHANGED
@@ -1,4 +1,4 @@
1
- export const CLI_VERSION = "0.6.0";
1
+ export const CLI_VERSION = "0.7.1";
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";
@@ -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,25 @@ 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);
39
41
  const matcher = ignore().add(DEFAULT_IGNORE_RULES);
40
42
  const customRules = await readOptionalText(join(root, ".legodeployignore"));
41
43
  if (customRules) matcher.add(customRules);
42
44
 
43
45
  const candidates = [];
44
46
  const skippedSymlinks = [];
45
- await walk(root, "");
47
+ await walk(root, "", matcher, "");
48
+ if (assets) {
49
+ const assetsRoot = resolve(assets.sourceDirectory);
50
+ const assetsRootStat = await lstat(assetsRoot).catch(() => undefined);
51
+ if (!assetsRootStat?.isDirectory()) {
52
+ throw new Error(`Worker assets output is not a directory: ${assetsRoot}`);
53
+ }
54
+ const assetsMatcher = ignore().add(DEFAULT_IGNORE_RULES);
55
+ const assetsIgnore = await readOptionalText(join(assetsRoot, ".assetsignore"));
56
+ if (assetsIgnore) assetsMatcher.add(assetsIgnore);
57
+ await walk(assetsRoot, "", assetsMatcher, WORKER_ASSET_ARCHIVE_DIRECTORY);
58
+ }
46
59
  candidates.sort((left, right) => compareText(left.path, right.path));
47
60
  if (candidates.length === 0) {
48
61
  throw new Error("No deployment files remain after applying deployment exclusions");
@@ -75,7 +88,7 @@ export async function createDeploymentArtifact(deployDirectory, options = {}) {
75
88
 
76
89
  const contentSha256 = contentHash.digest("hex");
77
90
  const manifest = {
78
- schemaVersion: 1,
91
+ schemaVersion: assets ? 2 : 1,
79
92
  kind: "fargo-lego-deployment-artifact",
80
93
  archiveFormat: "zip",
81
94
  target,
@@ -83,7 +96,16 @@ export async function createDeploymentArtifact(deployDirectory, options = {}) {
83
96
  contentSha256,
84
97
  fileCount: files.length,
85
98
  contentSizeBytes,
86
- files
99
+ files,
100
+ ...(assets ? {
101
+ assets: {
102
+ binding: assets.binding,
103
+ directory: WORKER_ASSET_ARCHIVE_DIRECTORY,
104
+ htmlHandling: assets.htmlHandling,
105
+ notFoundHandling: assets.notFoundHandling,
106
+ runWorkerFirst: assets.runWorkerFirst
107
+ }
108
+ } : {})
87
109
  };
88
110
  archiveEntries[MANIFEST_PATH] = [strToU8(`${JSON.stringify(manifest, null, 2)}\n`), { level: 6, mtime: FIXED_ZIP_DATE }];
89
111
  const archive = zipSync(archiveEntries, { level: 6, mtime: FIXED_ZIP_DATE });
@@ -103,10 +125,11 @@ export async function createDeploymentArtifact(deployDirectory, options = {}) {
103
125
  manifest,
104
126
  root,
105
127
  skippedSymlinkCount: skippedSymlinks.length,
106
- target
128
+ target,
129
+ assets: manifest.assets
107
130
  };
108
131
 
109
- async function walk(absoluteDirectory, relativeDirectory) {
132
+ async function walk(absoluteDirectory, relativeDirectory, activeMatcher, archivePrefix) {
110
133
  const directory = await opendir(absoluteDirectory);
111
134
  const entries = [];
112
135
  for await (const entry of directory) entries.push(entry);
@@ -115,21 +138,22 @@ export async function createDeploymentArtifact(deployDirectory, options = {}) {
115
138
  for (const entry of entries) {
116
139
  const relativePath = normalizePath(relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name);
117
140
  const absolutePath = join(absoluteDirectory, entry.name);
141
+ const archivePath = normalizePath(archivePrefix ? `${archivePrefix}/${relativePath}` : relativePath);
118
142
  if (entry.isSymbolicLink()) {
119
- skippedSymlinks.push(relativePath);
143
+ skippedSymlinks.push(archivePath);
120
144
  continue;
121
145
  }
122
146
  if (entry.isDirectory()) {
123
- if (!matcher.ignores(`${relativePath}/`)) await walk(absolutePath, relativePath);
147
+ if (!activeMatcher.ignores(`${relativePath}/`)) await walk(absolutePath, relativePath, activeMatcher, archivePrefix);
124
148
  continue;
125
149
  }
126
- if (!entry.isFile() || matcher.ignores(relativePath) || relativePath === ".legodeployignore") continue;
150
+ if (!entry.isFile() || activeMatcher.ignores(relativePath) || relativePath === ".legodeployignore" || relativePath === ".assetsignore") continue;
127
151
  const stat = await lstat(absolutePath);
128
152
  const maxFileBytes = options.maxFileBytes ?? MAX_DEPLOY_FILE_BYTES;
129
153
  if (stat.size > maxFileBytes) {
130
154
  throw new Error(`Deployment file '${relativePath}' is ${formatBytes(stat.size)}, above the ${formatBytes(maxFileBytes)} per-file limit`);
131
155
  }
132
- candidates.push({ absolutePath, path: relativePath });
156
+ candidates.push({ absolutePath, path: archivePath });
133
157
  if (candidates.length > (options.maxFileCount ?? MAX_DEPLOY_FILE_COUNT)) {
134
158
  throw new Error(`Deployment output contains more than ${options.maxFileCount ?? MAX_DEPLOY_FILE_COUNT} files`);
135
159
  }
@@ -137,6 +161,34 @@ export async function createDeploymentArtifact(deployDirectory, options = {}) {
137
161
  }
138
162
  }
139
163
 
164
+ function normalizeWorkerAssets(value, target) {
165
+ if (!value?.sourceDirectory) return undefined;
166
+ if (target !== "worker") throw new Error("Static assets are supported only for Worker deployments");
167
+ const binding = String(value.binding || "ASSETS").trim();
168
+ if (!/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(binding)) throw new Error("Invalid Worker assets binding");
169
+ const notFoundHandling = String(value.notFoundHandling || "none").trim();
170
+ if (!["none", "404-page", "single-page-application"].includes(notFoundHandling)) {
171
+ throw new Error("Invalid Worker assets not-found handling");
172
+ }
173
+ const htmlHandling = String(value.htmlHandling || "auto-trailing-slash").trim();
174
+ if (!["auto-trailing-slash", "force-trailing-slash", "drop-trailing-slash", "none"].includes(htmlHandling)) {
175
+ throw new Error("Invalid Worker assets HTML handling");
176
+ }
177
+ const runWorkerFirst = Array.isArray(value.runWorkerFirst)
178
+ ? value.runWorkerFirst.map((route) => String(route).trim()).filter(Boolean)
179
+ : value.runWorkerFirst === true;
180
+ if (Array.isArray(runWorkerFirst) && runWorkerFirst.length === 0) {
181
+ throw new Error("Worker-first asset routes must not be empty");
182
+ }
183
+ return {
184
+ binding,
185
+ htmlHandling,
186
+ notFoundHandling,
187
+ runWorkerFirst,
188
+ sourceDirectory: value.sourceDirectory
189
+ };
190
+ }
191
+
140
192
  export function contentTypeForPath(path) {
141
193
  const extension = extname(path).toLowerCase();
142
194
  const known = {
@@ -31,6 +31,7 @@ export function deploymentArtifactSummary(artifact) {
31
31
  archiveName: artifact.archiveName,
32
32
  archiveSha256: artifact.archiveSha256,
33
33
  archiveSizeBytes: artifact.archive.byteLength,
34
+ assets: artifact.assets,
34
35
  contentSha256: artifact.contentSha256,
35
36
  contentSizeBytes: artifact.contentSizeBytes,
36
37
  entrypoint: artifact.entrypoint,
@@ -9,25 +9,48 @@ 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
- 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) {
17
31
  return {
18
32
  dryRun: true,
19
- sourceSnapshot: sourceSnapshotSummary(snapshot),
33
+ release: {
34
+ notes: input.releaseNotes,
35
+ version: input.version,
36
+ versionName: input.versionName
37
+ },
38
+ sourceSnapshot: snapshot ? sourceSnapshotSummary(snapshot) : undefined,
39
+ sourceSnapshotSkipped: !snapshot,
20
40
  deployArtifact: deploymentArtifactSummary(artifact)
21
41
  };
22
42
  }
23
43
 
24
- const uploadedSnapshot = await uploadLegoSourceSnapshot(store, {
44
+ const uploadedSnapshot = snapshot ? await uploadLegoSourceSnapshot(store, {
25
45
  appId: input.appId,
46
+ notes: input.releaseNotes,
47
+ purpose: "deployment",
26
48
  source: input.source || ".",
27
- version: input.version
28
- }, { ...options, snapshot });
29
- const sourceSnapshotId = uploadedSnapshot.sourceSnapshot?.id;
30
- if (!sourceSnapshotId) throw new Error("Source snapshot upload did not return sourceSnapshot.id");
49
+ version: input.version,
50
+ versionName: input.versionName
51
+ }, { ...options, snapshot }) : undefined;
52
+ const sourceSnapshotId = uploadedSnapshot?.sourceSnapshot?.id;
53
+ if (snapshot && !sourceSnapshotId) throw new Error("Source snapshot upload did not return sourceSnapshot.id");
31
54
 
32
55
  const uploadedArtifact = await uploadLegoDeployArtifact(store, {
33
56
  appId: input.appId,
@@ -38,18 +61,22 @@ export async function deployLocalLegoApp(store, input, options = {}) {
38
61
  if (!deployArtifactId) throw new Error("Deployment artifact upload did not return deployArtifact.id");
39
62
 
40
63
  const tool = target === "pages" ? "lego_pages_deploy" : "lego_worker_deploy";
41
- const deployment = mcpToolPayload(await callMcpTool(store, tool, {
64
+ const deploymentInput = {
42
65
  appId: input.appId,
43
66
  deployArtifactId,
44
67
  entrypoint: artifact.entrypoint,
45
- sourceSnapshotId,
46
- version: input.version
47
- }, options));
68
+ releaseNotes: input.releaseNotes,
69
+ version: input.version,
70
+ versionName: input.versionName,
71
+ ...(sourceSnapshotId ? { sourceSnapshotId } : {})
72
+ };
73
+ const deployment = mcpToolPayload(await callMcpTool(store, tool, deploymentInput, options));
48
74
 
49
75
  return {
50
76
  appId: input.appId,
51
77
  target,
52
- sourceSnapshot: uploadedSnapshot.sourceSnapshot,
78
+ sourceSnapshot: uploadedSnapshot?.sourceSnapshot,
79
+ sourceSnapshotSkipped: !snapshot,
53
80
  deployArtifact: uploadedArtifact.deployArtifact,
54
81
  deployment: deployment.deployment,
55
82
  sourceVersion: deployment.sourceVersion,
@@ -66,6 +93,7 @@ function sourceSnapshotSummary(snapshot) {
66
93
  fileCount: snapshot.fileCount,
67
94
  root: snapshot.root,
68
95
  skippedSymlinkCount: snapshot.skippedSymlinkCount,
96
+ sourceExcludes: snapshot.sourceExcludes,
69
97
  sourceSizeBytes: snapshot.sourceSizeBytes
70
98
  };
71
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,13 +3,31 @@ 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
- return { dryRun: true, snapshot: snapshotSummary(snapshot) };
11
+ return {
12
+ dryRun: true,
13
+ metadata: {
14
+ notes: input.notes ?? "",
15
+ purpose: input.purpose ?? "backup",
16
+ version: input.version ?? "",
17
+ versionName: input.versionName ?? ""
18
+ },
19
+ snapshot: snapshotSummary(snapshot)
20
+ };
9
21
  }
10
22
 
11
23
  const url = snapshotCollectionUrl(input.appId, options);
12
24
  if (input.version) url.searchParams.set("version", input.version);
25
+ const metadata = Buffer.from(JSON.stringify({
26
+ notes: input.notes ?? "",
27
+ purpose: input.purpose ?? "backup",
28
+ version: input.version ?? "",
29
+ versionName: input.versionName ?? ""
30
+ }), "utf8").toString("base64url");
13
31
  const response = await authenticatedRequest(store, url, {
14
32
  method: "POST",
15
33
  headers: {
@@ -19,6 +37,7 @@ export async function uploadLegoSourceSnapshot(store, input, options = {}) {
19
37
  "X-Fargo-Source-Archive-Sha256": snapshot.archiveSha256,
20
38
  "X-Fargo-Source-Content-Sha256": snapshot.contentSha256,
21
39
  "X-Fargo-Source-File-Count": String(snapshot.fileCount),
40
+ "X-Fargo-Source-Metadata": metadata,
22
41
  "X-Fargo-Source-Size": String(snapshot.sourceSizeBytes)
23
42
  },
24
43
  body: snapshot.archive
@@ -65,6 +84,7 @@ function snapshotSummary(snapshot) {
65
84
  fileCount: snapshot.fileCount,
66
85
  root: snapshot.root,
67
86
  skippedSymlinkCount: snapshot.skippedSymlinkCount,
87
+ sourceExcludes: snapshot.sourceExcludes,
68
88
  sourceSizeBytes: snapshot.sourceSizeBytes
69
89
  };
70
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
  }