@fargowealth/fargo-ai-cli 0.7.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,12 @@
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
+
3
10
  ## 0.7.0
4
11
 
5
12
  - 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.1
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,35 @@ 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
+ 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.
115
+
86
116
  The command creates and uploads two immutable archives:
87
117
 
88
118
  1. a filtered source snapshot for recovery and provenance;
@@ -101,14 +131,37 @@ Deployment artifacts preserve binary bytes and include
101
131
  ## LEGO D1 migrations
102
132
 
103
133
  ```sh
134
+ # Schema-only migrations keep the existing flow
104
135
  fargo lego d1 migrate plan --app app_xxx --dir migrations
105
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
106
150
  ```
107
151
 
108
152
  Migration files are top-level, lexically ordered `*.sql` files. The filename
109
153
  without `.sql` is the version. `plan` compares local SHA-256 values with the
110
154
  production registry; `apply` stops on content drift and submits only pending
111
- 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.
112
165
 
113
166
  ## WorkBuddy adapter
114
167
 
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.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
@@ -64,14 +64,21 @@ export async function runCli(args, options = {}) {
64
64
  const version = requiredVersionOption(args, "--version", 80);
65
65
  const versionName = requiredTextOption(args, "--name", 160);
66
66
  const releaseNotes = await requiredNotesOption(args);
67
+ const workerFirstRoutes = optionValues(args, "--assets-run-worker-first-route").filter(Boolean);
67
68
  const result = await deployLocalLegoApp(store, {
68
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"),
69
75
  deployDir,
70
76
  dryRun: args.includes("--dry-run"),
71
77
  entrypoint: optionValue(args, "--entrypoint"),
72
78
  releaseNotes,
73
79
  skipSource: args.includes("--skip-source"),
74
80
  source: optionValue(args, "--source") ?? ".",
81
+ sourceExcludes: optionValues(args, "--source-exclude"),
75
82
  target,
76
83
  version,
77
84
  versionName
@@ -82,6 +89,7 @@ export async function runCli(args, options = {}) {
82
89
  if (args[0] === "lego" && args[1] === "d1" && args[2] === "migrate") {
83
90
  const action = args[3];
84
91
  const input = {
92
+ allowDataMigrations: args.includes("--allow-data-migrations"),
85
93
  appId: requiredOption(args, "--app"),
86
94
  directory: optionValue(args, "--dir") ?? "migrations",
87
95
  tablePrefix: optionValue(args, "--table-prefix")
@@ -94,7 +102,7 @@ export async function runCli(args, options = {}) {
94
102
  stdout.write(`${JSON.stringify(await applyLegoMigrations(store, input, options))}\n`);
95
103
  return;
96
104
  }
97
- 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]");
98
106
  }
99
107
  if (args[0] === "workbuddy" && args[1] === "install") {
100
108
  let credential = await store.load();
@@ -150,6 +158,7 @@ async function runLegoSnapshotCommand(args, stdout, store, options) {
150
158
  notes: await optionalNotesOption(args),
151
159
  purpose: "backup",
152
160
  source: optionValue(args, "--source") ?? ".",
161
+ sourceExcludes: optionValues(args, "--source-exclude"),
153
162
  version: requiredVersionOption(args, "--version", 120),
154
163
  versionName: requiredTextOption(args, "--name", 160)
155
164
  }, options);
@@ -262,3 +271,19 @@ function optionValue(args, name) {
262
271
  const index = args.indexOf(name);
263
272
  return index >= 0 ? args[index + 1] : undefined;
264
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.7.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,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
  }