@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 +7 -0
- package/README.md +55 -2
- package/package.json +1 -1
- package/src/cli.mjs +26 -1
- package/src/constants.mjs +1 -1
- package/src/deployment-artifact.mjs +61 -9
- package/src/lego-deploy-artifacts.mjs +1 -0
- package/src/lego-deploy.mjs +17 -2
- package/src/lego-migrations.mjs +218 -3
- package/src/lego-snapshots.mjs +5 -1
- package/src/source-snapshot.mjs +16 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,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.
|
|
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.
|
|
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
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.
|
|
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(
|
|
143
|
+
skippedSymlinks.push(archivePath);
|
|
120
144
|
continue;
|
|
121
145
|
}
|
|
122
146
|
if (entry.isDirectory()) {
|
|
123
|
-
if (!
|
|
147
|
+
if (!activeMatcher.ignores(`${relativePath}/`)) await walk(absolutePath, relativePath, activeMatcher, archivePrefix);
|
|
124
148
|
continue;
|
|
125
149
|
}
|
|
126
|
-
if (!entry.isFile() ||
|
|
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:
|
|
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,
|
package/src/lego-deploy.mjs
CHANGED
|
@@ -9,8 +9,22 @@ export async function deployLocalLegoApp(store, input, options = {}) {
|
|
|
9
9
|
const target = requiredTarget(input.target);
|
|
10
10
|
const entrypoint = input.entrypoint || (target === "pages" ? "index.html" : "index.js");
|
|
11
11
|
const [snapshot, artifact] = await Promise.all([
|
|
12
|
-
input.skipSource ? Promise.resolve(undefined) : createSourceSnapshot(input.source || ".",
|
|
13
|
-
|
|
12
|
+
input.skipSource ? Promise.resolve(undefined) : createSourceSnapshot(input.source || ".", {
|
|
13
|
+
...options.snapshotOptions,
|
|
14
|
+
ignoreRules: [...(options.snapshotOptions?.ignoreRules ?? []), ...(input.sourceExcludes ?? [])]
|
|
15
|
+
}),
|
|
16
|
+
createDeploymentArtifact(input.deployDir, {
|
|
17
|
+
...options.artifactOptions,
|
|
18
|
+
target,
|
|
19
|
+
entrypoint,
|
|
20
|
+
assets: input.assetsDirectory ? {
|
|
21
|
+
sourceDirectory: input.assetsDirectory,
|
|
22
|
+
binding: input.assetsBinding,
|
|
23
|
+
htmlHandling: input.assetsHtmlHandling,
|
|
24
|
+
notFoundHandling: input.assetsNotFoundHandling,
|
|
25
|
+
runWorkerFirst: input.assetsRunWorkerFirst
|
|
26
|
+
} : undefined
|
|
27
|
+
})
|
|
14
28
|
]);
|
|
15
29
|
|
|
16
30
|
if (input.dryRun) {
|
|
@@ -79,6 +93,7 @@ function sourceSnapshotSummary(snapshot) {
|
|
|
79
93
|
fileCount: snapshot.fileCount,
|
|
80
94
|
root: snapshot.root,
|
|
81
95
|
skippedSymlinkCount: snapshot.skippedSymlinkCount,
|
|
96
|
+
sourceExcludes: snapshot.sourceExcludes,
|
|
82
97
|
sourceSizeBytes: snapshot.sourceSizeBytes
|
|
83
98
|
};
|
|
84
99
|
}
|
package/src/lego-migrations.mjs
CHANGED
|
@@ -12,7 +12,7 @@ export async function planLegoMigrations(store, input, options = {}) {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
async function buildMigrationPlan(store, input, options) {
|
|
15
|
-
const local = await readMigrationDirectory(input.directory);
|
|
15
|
+
const local = await readMigrationDirectory(input.directory, { tablePrefix: input.tablePrefix });
|
|
16
16
|
const remote = mcpToolPayload(await callMcpTool(store, "lego_d1_migrations_list", { appId: input.appId }, options));
|
|
17
17
|
const applied = new Map((remote.migrations ?? []).map((migration) => [String(migration.version), migration]));
|
|
18
18
|
const migrations = local.map((migration) => {
|
|
@@ -23,6 +23,7 @@ async function buildMigrationPlan(store, input, options) {
|
|
|
23
23
|
? { ...migration, status: "applied", remote: existing }
|
|
24
24
|
: { ...migration, status: "drift", remote: existing };
|
|
25
25
|
});
|
|
26
|
+
const pendingDataMigrations = migrations.filter((item) => item.status === "pending" && item.impact.dataStatementCount > 0);
|
|
26
27
|
return {
|
|
27
28
|
appId: input.appId,
|
|
28
29
|
directory: resolve(input.directory || "migrations"),
|
|
@@ -30,8 +31,13 @@ async function buildMigrationPlan(store, input, options) {
|
|
|
30
31
|
summary: {
|
|
31
32
|
applied: migrations.filter((item) => item.status === "applied").length,
|
|
32
33
|
appliedUnverified: migrations.filter((item) => item.status === "applied_unverified").length,
|
|
34
|
+
dataMigrationCount: migrations.filter((item) => item.impact.dataStatementCount > 0).length,
|
|
35
|
+
dataMutationTypes: Array.from(new Set(migrations.flatMap((item) => item.impact.dataMutationTypes))).sort(),
|
|
36
|
+
dataStatementCount: migrations.reduce((total, item) => total + item.impact.dataStatementCount, 0),
|
|
33
37
|
drift: migrations.filter((item) => item.status === "drift").length,
|
|
34
|
-
pending: migrations.filter((item) => item.status === "pending").length
|
|
38
|
+
pending: migrations.filter((item) => item.status === "pending").length,
|
|
39
|
+
pendingDataMigrationCount: pendingDataMigrations.length,
|
|
40
|
+
requiresDataMigrationApproval: pendingDataMigrations.length > 0
|
|
35
41
|
}
|
|
36
42
|
};
|
|
37
43
|
}
|
|
@@ -42,11 +48,22 @@ export async function applyLegoMigrations(store, input, options = {}) {
|
|
|
42
48
|
if (drift.length > 0) {
|
|
43
49
|
throw new Error(`Migration drift detected: ${drift.map((item) => item.fileName).join(", ")}`);
|
|
44
50
|
}
|
|
51
|
+
const pendingDataMigrations = plan.migrations.filter((migration) => migration.status === "pending" && migration.impact.dataStatementCount > 0);
|
|
52
|
+
if (pendingDataMigrations.length > 0 && !input.allowDataMigrations) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Pending data migrations require separate approval: ${pendingDataMigrations.map((item) => item.fileName).join(", ")}. ` +
|
|
55
|
+
"Review the plan, obtain explicit approval, then rerun apply with --allow-data-migrations."
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (pendingDataMigrations.length > 0 && !String(input.tablePrefix ?? "").trim()) {
|
|
59
|
+
throw new Error("Data migrations require an explicit --table-prefix so App-owned table targets can be verified locally and by MCP");
|
|
60
|
+
}
|
|
45
61
|
|
|
46
62
|
const applied = [];
|
|
47
63
|
for (const migration of plan.migrations.filter((item) => item.status === "pending")) {
|
|
48
64
|
const result = mcpToolPayload(await callMcpTool(store, "lego_d1_migrations_apply", {
|
|
49
65
|
appId: input.appId,
|
|
66
|
+
allowDataMigrations: migration.impact.dataStatementCount > 0 && input.allowDataMigrations === true,
|
|
50
67
|
contentSha256: migration.contentSha256,
|
|
51
68
|
fileName: migration.fileName,
|
|
52
69
|
sql: migration.sql,
|
|
@@ -74,7 +91,7 @@ function publicMigrationPlan(plan) {
|
|
|
74
91
|
};
|
|
75
92
|
}
|
|
76
93
|
|
|
77
|
-
async function readMigrationDirectory(directory) {
|
|
94
|
+
async function readMigrationDirectory(directory, options = {}) {
|
|
78
95
|
const root = resolve(directory || "migrations");
|
|
79
96
|
const stat = await lstat(root).catch(() => undefined);
|
|
80
97
|
if (!stat?.isDirectory()) throw new Error(`Migration directory is not a directory: ${root}`);
|
|
@@ -101,9 +118,11 @@ async function readMigrationDirectory(directory) {
|
|
|
101
118
|
const sizeBytes = Buffer.byteLength(sql);
|
|
102
119
|
if (sizeBytes === 0) throw new Error(`Migration file is empty: ${entry.name}`);
|
|
103
120
|
if (sizeBytes > MAX_MIGRATION_BYTES) throw new Error(`Migration file exceeds ${MAX_MIGRATION_BYTES} bytes: ${entry.name}`);
|
|
121
|
+
const impact = analyzeLegoMigrationSql(sql, entry.name, options);
|
|
104
122
|
migrations.push({
|
|
105
123
|
contentSha256: createHash("sha256").update(sql).digest("hex"),
|
|
106
124
|
fileName: entry.name,
|
|
125
|
+
impact,
|
|
107
126
|
sizeBytes,
|
|
108
127
|
sql,
|
|
109
128
|
version
|
|
@@ -111,3 +130,199 @@ async function readMigrationDirectory(directory) {
|
|
|
111
130
|
}
|
|
112
131
|
return migrations;
|
|
113
132
|
}
|
|
133
|
+
|
|
134
|
+
export function analyzeLegoMigrationSql(sql, fileName = "migration.sql", options = {}) {
|
|
135
|
+
const statements = parseMigrationStatements(sql);
|
|
136
|
+
if (statements.length === 0) throw new Error(`Migration file contains no SQL statements: ${fileName}`);
|
|
137
|
+
const analyses = [];
|
|
138
|
+
for (let index = 0; index < statements.length; index += 1) {
|
|
139
|
+
const statement = statements[index];
|
|
140
|
+
try {
|
|
141
|
+
analyses.push(analyzeMigrationStatement(statement, options));
|
|
142
|
+
} catch (error) {
|
|
143
|
+
const kind = statement.replace(/^\s+/, "").match(/^([A-Za-z]+)/)?.[1]?.toUpperCase() ?? "UNKNOWN";
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Unsupported App D1 migration statement in ${fileName} at statement ${index + 1} (${kind}). ` +
|
|
146
|
+
`${error instanceof Error ? error.message : "Statement failed validation"}. ` +
|
|
147
|
+
"Allowed schema statements are CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS, and ALTER TABLE ADD COLUMN. " +
|
|
148
|
+
"Controlled data statements are INSERT ... VALUES [ON CONFLICT], plus UPDATE or DELETE with WHERE.",
|
|
149
|
+
{ cause: error }
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const data = analyses.filter((item) => item.category === "data");
|
|
154
|
+
return {
|
|
155
|
+
dataMutationTypes: Array.from(new Set(data.map((item) => item.type))).sort(),
|
|
156
|
+
dataStatementCount: data.length,
|
|
157
|
+
dataTables: Array.from(new Set(data.map((item) => item.table))).sort(),
|
|
158
|
+
requiresDataMigrationApproval: data.length > 0,
|
|
159
|
+
schemaStatementCount: analyses.length - data.length,
|
|
160
|
+
statementCount: analyses.length,
|
|
161
|
+
statementTypes: analyses.map((item) => item.type)
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export const validateLegoMigrationSql = analyzeLegoMigrationSql;
|
|
166
|
+
|
|
167
|
+
function parseMigrationStatements(sql) {
|
|
168
|
+
const statements = [];
|
|
169
|
+
let current = "";
|
|
170
|
+
let quote;
|
|
171
|
+
let blockComment = false;
|
|
172
|
+
let lineComment = false;
|
|
173
|
+
|
|
174
|
+
for (let index = 0; index < sql.length; index += 1) {
|
|
175
|
+
const char = sql[index];
|
|
176
|
+
const next = sql[index + 1];
|
|
177
|
+
if (blockComment) {
|
|
178
|
+
if (char === "*" && next === "/") {
|
|
179
|
+
blockComment = false;
|
|
180
|
+
index += 1;
|
|
181
|
+
}
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (lineComment) {
|
|
185
|
+
if (char === "\n") lineComment = false;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (!quote && char === "/" && next === "*") {
|
|
189
|
+
blockComment = true;
|
|
190
|
+
current += " ";
|
|
191
|
+
index += 1;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (!quote && char === "-" && next === "-") {
|
|
195
|
+
lineComment = true;
|
|
196
|
+
current += " ";
|
|
197
|
+
index += 1;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if ((char === "'" || char === '"') && sql[index - 1] !== "\\") {
|
|
201
|
+
quote = quote === char ? undefined : quote ?? char;
|
|
202
|
+
}
|
|
203
|
+
if (!quote && char === ";") {
|
|
204
|
+
const statement = current.trim();
|
|
205
|
+
if (statement) statements.push(statement);
|
|
206
|
+
current = "";
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
current += char;
|
|
210
|
+
}
|
|
211
|
+
if (blockComment) throw new Error("Migration SQL contains an unterminated block comment");
|
|
212
|
+
|
|
213
|
+
const trailing = current.trim();
|
|
214
|
+
if (trailing) statements.push(trailing);
|
|
215
|
+
return statements;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function analyzeMigrationStatement(statement, options = {}) {
|
|
219
|
+
const normalized = statement.replace(/\s+/g, " ").trim();
|
|
220
|
+
const createTable = normalized.match(/^CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/i);
|
|
221
|
+
if (createTable?.[1]) {
|
|
222
|
+
assertMigrationIdentifier(createTable[1]);
|
|
223
|
+
assertOwnedReferences(normalized, options.tablePrefix);
|
|
224
|
+
return { category: "schema", table: createTable[1], type: "create_table" };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const createIndex = normalized.match(/^CREATE\s+INDEX\s+IF\s+NOT\s+EXISTS\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+ON\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/i);
|
|
228
|
+
if (createIndex?.[1] && createIndex[2]) {
|
|
229
|
+
assertMigrationIdentifier(createIndex[1]);
|
|
230
|
+
assertMigrationIdentifier(createIndex[2]);
|
|
231
|
+
return { category: "schema", table: createIndex[2], type: "create_index" };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const alterTable = normalized.match(/^ALTER\s+TABLE\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+ADD\s+COLUMN\s+([a-zA-Z_][a-zA-Z0-9_]*)\b/i);
|
|
235
|
+
if (alterTable?.[1]) {
|
|
236
|
+
assertMigrationIdentifier(alterTable[1]);
|
|
237
|
+
assertOwnedReferences(normalized, options.tablePrefix);
|
|
238
|
+
return { category: "schema", table: alterTable[1], type: "alter_table" };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const structure = stripQuotedSql(normalized);
|
|
242
|
+
if (/^(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+[a-zA-Z_][a-zA-Z0-9_]*\s*\./i.test(structure)) {
|
|
243
|
+
throw new Error("Data migrations cannot use qualified table names");
|
|
244
|
+
}
|
|
245
|
+
const insert = structure.match(/^INSERT\s+INTO\s+([a-zA-Z_][a-zA-Z0-9_]*)\b/i);
|
|
246
|
+
if (insert?.[1]) {
|
|
247
|
+
assertOwnedDataTable(insert[1], options.tablePrefix);
|
|
248
|
+
assertNoCrossResourceDataQuery(structure, "insert");
|
|
249
|
+
if (!/\bVALUES\s*\(/i.test(structure)) throw new Error("INSERT data migrations must use VALUES");
|
|
250
|
+
if (/\bON\s+CONFLICT\b/i.test(structure) && !/\bDO\s+(?:NOTHING|UPDATE\s+SET)\b/i.test(structure)) {
|
|
251
|
+
throw new Error("INSERT ON CONFLICT must use DO NOTHING or DO UPDATE SET");
|
|
252
|
+
}
|
|
253
|
+
return { category: "data", table: insert[1], type: "insert" };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const update = structure.match(/^UPDATE\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+SET\s+/i);
|
|
257
|
+
if (update?.[1]) {
|
|
258
|
+
assertOwnedDataTable(update[1], options.tablePrefix);
|
|
259
|
+
if (!/\bWHERE\b/i.test(structure)) throw new Error("UPDATE data migrations must include WHERE");
|
|
260
|
+
assertNoCrossResourceDataQuery(structure, "update");
|
|
261
|
+
return { category: "data", table: update[1], type: "update" };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const deleteFrom = structure.match(/^DELETE\s+FROM\s+([a-zA-Z_][a-zA-Z0-9_]*)\b/i);
|
|
265
|
+
if (deleteFrom?.[1]) {
|
|
266
|
+
assertOwnedDataTable(deleteFrom[1], options.tablePrefix);
|
|
267
|
+
if (!/\bWHERE\b/i.test(structure)) throw new Error("DELETE data migrations must include WHERE");
|
|
268
|
+
assertNoCrossResourceDataQuery(structure, "delete");
|
|
269
|
+
return { category: "data", table: deleteFrom[1], type: "delete" };
|
|
270
|
+
}
|
|
271
|
+
throw new Error("Statement type is not allowed");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function assertMigrationIdentifier(identifier) {
|
|
275
|
+
if (identifier.toLowerCase().startsWith("sqlite_")) {
|
|
276
|
+
throw new Error("reserved_migration_identifier");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function assertOwnedDataTable(identifier, tablePrefix) {
|
|
281
|
+
assertMigrationIdentifier(identifier);
|
|
282
|
+
const prefix = String(tablePrefix ?? "").trim().toLowerCase();
|
|
283
|
+
if (prefix && !identifier.toLowerCase().startsWith(prefix)) {
|
|
284
|
+
throw new Error(`Data table ${identifier} is outside App prefix ${prefix}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function assertOwnedReferences(statement, tablePrefix) {
|
|
289
|
+
for (const match of statement.matchAll(/\bREFERENCES\s+([a-zA-Z_][a-zA-Z0-9_]*)\b/gi)) {
|
|
290
|
+
if (match[1]) assertOwnedDataTable(match[1], tablePrefix);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function assertNoCrossResourceDataQuery(statement, kind) {
|
|
295
|
+
const forbidden = kind === "delete"
|
|
296
|
+
? /\b(?:SELECT|WITH|JOIN|ATTACH|DETACH|PRAGMA|DROP)\b/i
|
|
297
|
+
: /\b(?:SELECT|WITH|JOIN|FROM|ATTACH|DETACH|PRAGMA|DROP)\b/i;
|
|
298
|
+
if (forbidden.test(statement)) {
|
|
299
|
+
throw new Error("Data migrations cannot query other tables or use administrative SQL");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function stripQuotedSql(value) {
|
|
304
|
+
let result = "";
|
|
305
|
+
let quote;
|
|
306
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
307
|
+
const char = value[index];
|
|
308
|
+
if (quote) {
|
|
309
|
+
if (char === quote) {
|
|
310
|
+
if (value[index + 1] === quote) {
|
|
311
|
+
result += " ";
|
|
312
|
+
index += 1;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
quote = undefined;
|
|
316
|
+
}
|
|
317
|
+
result += " ";
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (char === "'" || char === '"') {
|
|
321
|
+
quote = char;
|
|
322
|
+
result += " ";
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
result += char;
|
|
326
|
+
}
|
|
327
|
+
return result;
|
|
328
|
+
}
|
package/src/lego-snapshots.mjs
CHANGED
|
@@ -3,7 +3,10 @@ import { authenticatedRequest, responsePayload } from "./api-client.mjs";
|
|
|
3
3
|
import { createSourceSnapshot } from "./source-snapshot.mjs";
|
|
4
4
|
|
|
5
5
|
export async function uploadLegoSourceSnapshot(store, input, options = {}) {
|
|
6
|
-
const snapshot = options.snapshot ?? await createSourceSnapshot(input.source ?? ".",
|
|
6
|
+
const snapshot = options.snapshot ?? await createSourceSnapshot(input.source ?? ".", {
|
|
7
|
+
...options.snapshotOptions,
|
|
8
|
+
ignoreRules: [...(options.snapshotOptions?.ignoreRules ?? []), ...(input.sourceExcludes ?? [])]
|
|
9
|
+
});
|
|
7
10
|
if (input.dryRun) {
|
|
8
11
|
return {
|
|
9
12
|
dryRun: true,
|
|
@@ -81,6 +84,7 @@ function snapshotSummary(snapshot) {
|
|
|
81
84
|
fileCount: snapshot.fileCount,
|
|
82
85
|
root: snapshot.root,
|
|
83
86
|
skippedSymlinkCount: snapshot.skippedSymlinkCount,
|
|
87
|
+
sourceExcludes: snapshot.sourceExcludes,
|
|
84
88
|
sourceSizeBytes: snapshot.sourceSizeBytes
|
|
85
89
|
};
|
|
86
90
|
}
|
package/src/source-snapshot.mjs
CHANGED
|
@@ -62,7 +62,8 @@ export async function createSourceSnapshot(sourceDirectory, options = {}) {
|
|
|
62
62
|
throw new Error(`Snapshot source is not a directory: ${root}`);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
const
|
|
65
|
+
const sourceExcludes = normalizeAdditionalIgnoreRules(options.ignoreRules);
|
|
66
|
+
const matcher = ignore().add(DEFAULT_IGNORE_RULES).add(sourceExcludes);
|
|
66
67
|
const customRules = await readOptionalText(join(root, ".legoignore"));
|
|
67
68
|
if (customRules) matcher.add(customRules);
|
|
68
69
|
|
|
@@ -117,6 +118,7 @@ export async function createSourceSnapshot(sourceDirectory, options = {}) {
|
|
|
117
118
|
manifest,
|
|
118
119
|
root,
|
|
119
120
|
skippedSymlinkCount: skippedSymlinks.length,
|
|
121
|
+
sourceExcludes,
|
|
120
122
|
sourceSizeBytes
|
|
121
123
|
};
|
|
122
124
|
|
|
@@ -153,6 +155,19 @@ export async function createSourceSnapshot(sourceDirectory, options = {}) {
|
|
|
153
155
|
}
|
|
154
156
|
}
|
|
155
157
|
|
|
158
|
+
function normalizeAdditionalIgnoreRules(value) {
|
|
159
|
+
if (value === undefined) return [];
|
|
160
|
+
if (!Array.isArray(value)) throw new Error("Source exclusions must be an array");
|
|
161
|
+
if (value.length > 100) throw new Error("No more than 100 --source-exclude rules are allowed");
|
|
162
|
+
return value.map((item) => {
|
|
163
|
+
const rule = String(item ?? "").trim();
|
|
164
|
+
if (!rule || rule.length > 256 || rule.includes("\0") || rule.includes("\n") || rule.includes("\r")) {
|
|
165
|
+
throw new Error("Each --source-exclude rule must be a non-empty single-line pattern of at most 256 characters");
|
|
166
|
+
}
|
|
167
|
+
return rule;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
156
171
|
function digest(value) {
|
|
157
172
|
return createHash("sha256").update(value).digest("hex");
|
|
158
173
|
}
|