@fargowealth/fargo-ai-cli 0.5.1 → 0.6.0

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ - Added `fargo lego deploy` to create a source snapshot and deterministic
6
+ Pages/Worker deployment ZIP, upload both through authenticated binary data
7
+ paths, and create a ready deployment through the hosted MCP contract.
8
+ - Deployment artifacts now preserve text and binary files, carry per-file and
9
+ aggregate SHA-256 provenance, support `.legodeployignore`, and are linked to
10
+ deployment records by `deployArtifactId`.
11
+ - Added `fargo lego d1 migrate plan|apply` for ordered local SQL files with
12
+ server-verified hashes, idempotent retries, and content-drift protection.
13
+ - Kept publication and migrations independent from deployment, and kept R2,
14
+ Cloudflare credentials, permission checks, and D1 execution server-side.
15
+
3
16
  ## 0.5.1
4
17
 
5
18
  - Exclude the conventional `.build` generated-output directory from LEGO
package/README.md CHANGED
@@ -1,21 +1,20 @@
1
1
  # Fargo AI CLI
2
2
 
3
3
  `fargo` is the Agent-neutral command-line client for Fargo AI. Codex, Claude
4
- Code, Cursor, WorkBuddy, and any Agent that can execute shell commands share
5
- the same Fargo identity, hosted MCP contracts, and LEGO project operations.
4
+ Code, Cursor, WorkBuddy, and future shell-capable Agents share the same Fargo
5
+ identity, hosted MCP contracts, and deterministic LEGO project operations.
6
6
 
7
7
  Requires Node.js 20.19 or newer.
8
8
 
9
9
  ## Install or upgrade
10
10
 
11
11
  ```sh
12
- npm install -g @fargowealth/fargo-ai-cli@0.5.1
12
+ npm install -g @fargowealth/fargo-ai-cli@0.6.0
13
13
  fargo version
14
14
  ```
15
15
 
16
- Use the native remote Fargo MCP connection as the default online tool surface.
17
- Authenticate the CLI separately only when an Agent needs to package or upload
18
- files from its local workspace:
16
+ Use native hosted Fargo MCP for ordinary online tools. Authenticate the CLI
17
+ only when an Agent first needs local files:
19
18
 
20
19
  ```sh
21
20
  fargo auth login --agent codex
@@ -26,20 +25,18 @@ fargo auth status
26
25
 
27
26
  Aliases `cc` and `claude` resolve to `claude-code`. Login uses OAuth
28
27
  Authorization Code + PKCE over an ephemeral IPv4 loopback callback. The CLI
29
- validates OAuth state, uses PKCE S256, closes the listener after callback, and
30
- never prints access or refresh credentials.
28
+ validates state, uses PKCE S256, closes the listener, and never prints tokens.
31
29
 
32
30
  ## MCP boundary
33
31
 
34
- - MCP is the default for online Fargo resources, status, deployment,
35
- publication, rollback, and other business operations.
36
- - CLI is used when the operation needs the Agent's local filesystem or binary
37
- transfer: source walking, exclusions, ZIP creation, hashing, upload, and
38
- download.
39
- - A future `fargo lego deploy --source .` command may compose the two, but it
40
- will remain a client-side workflow over the same server deployment contract.
41
-
42
- Agents without native MCP support can use the CLI fallback:
32
+ - Hosted MCP owns Fargo resources, permission checks, status, publication,
33
+ rollback, Cloudflare writes, and D1 execution.
34
+ - CLI owns local walking, exclusions, ZIP creation, hashing, binary transfer,
35
+ deploy composition, and migration-file ordering.
36
+ - CLI has no generic R2 or Cloudflare administration commands.
37
+ - `fargo mcp serve` is the local stdio bridge for WorkBuddy.
38
+ - `fargo mcp tools` and `fargo mcp call` are fallbacks for Agents without a
39
+ native remote MCP client.
43
40
 
44
41
  ```sh
45
42
  fargo mcp tools
@@ -47,10 +44,6 @@ fargo mcp call lego_get_app --args '{"appId":"app_xxx"}'
47
44
  fargo mcp call lego_get_app --args-file ./arguments.json
48
45
  ```
49
46
 
50
- `fargo mcp serve` remains the local stdio bridge for hosts such as WorkBuddy
51
- that require one. Codex, Claude Code, and Cursor should prefer the remote MCP
52
- URL and its native OAuth support.
53
-
54
47
  ## LEGO source snapshots
55
48
 
56
49
  ```sh
@@ -61,14 +54,54 @@ fargo lego snapshot download --app app_xxx --snapshot snap_xxx
61
54
  ```
62
55
 
63
56
  The CLI applies secure defaults plus `.legoignore`, skips symbolic links,
64
- creates a deterministic ZIP, calculates SHA-256 digests, and uploads the bytes
65
- over an authenticated HTTP data path. Archive bytes are not embedded in MCP
66
- JSON-RPC. The default archive limit is 50 MB.
57
+ creates a deterministic ZIP, calculates SHA-256 digests, and uploads raw bytes
58
+ over authenticated HTTP. The ZIP includes
59
+ `.fargo-snapshot/manifest.json`; its default archive limit is 50 MB.
60
+
61
+ ## LEGO local deploy
62
+
63
+ Run the project build explicitly, then give Fargo CLI the completed output:
64
+
65
+ ```sh
66
+ npm run build
67
+ fargo lego deploy \
68
+ --app app_xxx \
69
+ --source . \
70
+ --deploy-dir dist \
71
+ --target pages \
72
+ --entrypoint index.html \
73
+ --version v1
74
+ ```
75
+
76
+ For Workers use `--target worker`; the default entrypoint becomes `index.js`.
77
+ The CLI does not install dependencies, infer a framework, or run the build.
78
+
79
+ The command creates and uploads two immutable archives:
80
+
81
+ 1. a filtered source snapshot for recovery and provenance;
82
+ 2. a deterministic deployment ZIP from `--deploy-dir` using
83
+ `.legodeployignore`.
84
+
85
+ It then sends only `sourceSnapshotId` and `deployArtifactId` through the hosted
86
+ MCP deployment contract. The result is `ready`, never automatically published.
87
+ Use `--dry-run` to validate both local packages without authentication or
88
+ network writes.
89
+
90
+ Deployment artifacts preserve binary bytes and include
91
+ `.fargo-deploy/manifest.json`. Default limits are 1,000 files, 25 MB total,
92
+ 20 MB per file, and a 25 MB ZIP.
93
+
94
+ ## LEGO D1 migrations
95
+
96
+ ```sh
97
+ fargo lego d1 migrate plan --app app_xxx --dir migrations
98
+ fargo lego d1 migrate apply --app app_xxx --dir migrations
99
+ ```
67
100
 
68
- The ZIP includes `.fargo-snapshot/manifest.json`. Dependencies, VCS data,
69
- generated output, caches, secrets, previous archives, large video/audio, and
70
- design-source files are excluded by default. Add project-specific gitignore
71
- syntax to `.legoignore`.
101
+ Migration files are top-level, lexically ordered `*.sql` files. The filename
102
+ without `.sql` is the version. `plan` compares local SHA-256 values with the
103
+ production registry; `apply` stops on content drift and submits only pending
104
+ files. Migration execution remains server-side and is never coupled to deploy.
72
105
 
73
106
  ## WorkBuddy adapter
74
107
 
@@ -77,8 +110,7 @@ npx -y @fargowealth/fargo-ai-cli@latest workbuddy install --open-browser=always
77
110
  ```
78
111
 
79
112
  The installer preserves existing MCP servers and adds the local Fargo stdio
80
- bridge. Device Authorization Grant remains available only as an explicit
81
- WorkBuddy fallback:
113
+ bridge. Device Authorization Grant remains an explicit fallback:
82
114
 
83
115
  ```sh
84
116
  fargo auth device --open-browser=always --output=jsonl
@@ -89,12 +121,10 @@ fargo auth device --open-browser=always --output=jsonl
89
121
  - macOS and Linux: `~/.fargo/credentials.json`
90
122
  - Windows: `%APPDATA%\Fargo AI\credentials.json`
91
123
 
92
- On macOS and Linux, the directory is forced to `0700` and the credential file
93
- to `0600`. Windows inherits the current user's NTFS ACL from `%APPDATA%`.
94
- Writes use an `fsync`-backed temporary file followed by atomic replacement,
95
- symbolic-link reads are rejected, and refresh-token rotation is serialized
96
- across local CLI processes.
124
+ macOS/Linux use a `0700` directory and `0600` file. Windows inherits the
125
+ current user's NTFS ACL. Writes use an `fsync`-backed temporary file and atomic
126
+ replacement, symbolic-link reads are rejected, and refresh-token rotation is
127
+ serialized across CLI processes.
97
128
 
98
- The file contains an OAuth refresh credential. Never copy, upload, commit, or
99
- print it. Use `fargo auth logout` to remove it and revoke the Fargo connection
100
- when a device is no longer trusted.
129
+ Never copy, upload, commit, or print the credential file. Use
130
+ `fargo auth logout` when a device is no longer trusted.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fargowealth/fargo-ai-cli",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
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
@@ -4,6 +4,8 @@ import { createCredentialStore } from "./credential-store.mjs";
4
4
  import { authorizePkce, currentCredential, openBrowser, pollDeviceAuthorization, startDeviceAuthorization } from "./auth.mjs";
5
5
  import { CLI_VERSION } from "./constants.mjs";
6
6
  import { downloadLegoSourceSnapshot, listLegoSourceSnapshots, uploadLegoSourceSnapshot } from "./lego-snapshots.mjs";
7
+ import { deployLocalLegoApp } from "./lego-deploy.mjs";
8
+ import { applyLegoMigrations, planLegoMigrations } from "./lego-migrations.mjs";
7
9
  import { callMcpTool, listMcpTools } from "./mcp-client.mjs";
8
10
  import { runMcpProxy } from "./mcp-proxy.mjs";
9
11
  import { installWorkBuddyMcp } from "./workbuddy-install.mjs";
@@ -55,6 +57,36 @@ export async function runCli(args, options = {}) {
55
57
  await runLegoSnapshotCommand(args, stdout, store, options);
56
58
  return;
57
59
  }
60
+ if (args[0] === "lego" && args[1] === "deploy") {
61
+ const result = await deployLocalLegoApp(store, {
62
+ appId: requiredOption(args, "--app"),
63
+ deployDir: requiredOption(args, "--deploy-dir"),
64
+ dryRun: args.includes("--dry-run"),
65
+ entrypoint: optionValue(args, "--entrypoint"),
66
+ source: optionValue(args, "--source") ?? ".",
67
+ target: requiredOption(args, "--target"),
68
+ version: optionValue(args, "--version")
69
+ }, options);
70
+ stdout.write(`${JSON.stringify(result)}\n`);
71
+ return;
72
+ }
73
+ if (args[0] === "lego" && args[1] === "d1" && args[2] === "migrate") {
74
+ const action = args[3];
75
+ const input = {
76
+ appId: requiredOption(args, "--app"),
77
+ directory: optionValue(args, "--dir") ?? "migrations",
78
+ tablePrefix: optionValue(args, "--table-prefix")
79
+ };
80
+ if (action === "plan") {
81
+ stdout.write(`${JSON.stringify(await planLegoMigrations(store, input, options))}\n`);
82
+ return;
83
+ }
84
+ if (action === "apply") {
85
+ stdout.write(`${JSON.stringify(await applyLegoMigrations(store, input, options))}\n`);
86
+ return;
87
+ }
88
+ throw new Error("Usage: fargo lego d1 migrate plan|apply --app <appId> [--dir migrations]");
89
+ }
58
90
  if (args[0] === "workbuddy" && args[1] === "install") {
59
91
  let credential = await store.load();
60
92
  if (credential) {
@@ -72,7 +104,7 @@ export async function runCli(args, options = {}) {
72
104
  stdout.write(`${JSON.stringify(await installWorkBuddyMcp(options))}\n`);
73
105
  return;
74
106
  }
75
- throw new Error("Usage: fargo auth login|device|status|logout | fargo mcp serve|tools|call | fargo lego snapshot upload|list|download | fargo workbuddy install | fargo version");
107
+ throw new Error("Usage: fargo auth login|device|status|logout | fargo mcp serve|tools|call | fargo lego snapshot upload|list|download | fargo lego deploy | fargo lego d1 migrate plan|apply | fargo workbuddy install | fargo version");
76
108
  } catch (error) {
77
109
  stderr.write(`fargo: ${error instanceof Error ? error.message : "unknown error"}\n`);
78
110
  if (options.throwOnError) throw error;
package/src/constants.mjs CHANGED
@@ -1,4 +1,4 @@
1
- export const CLI_VERSION = "0.5.1";
1
+ export const CLI_VERSION = "0.6.0";
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";
@@ -0,0 +1,207 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, opendir, readFile } from "node:fs/promises";
3
+ import { basename, extname, join, resolve, sep } from "node:path";
4
+ import { strToU8, zipSync } from "fflate";
5
+ import ignore from "ignore";
6
+
7
+ export const MAX_DEPLOY_ARCHIVE_BYTES = 25_000_000;
8
+ export const MAX_DEPLOY_FILE_BYTES = 20_000_000;
9
+ export const MAX_DEPLOY_FILE_COUNT = 1_000;
10
+ export const MAX_DEPLOY_CONTENT_BYTES = 25_000_000;
11
+
12
+ const FIXED_ZIP_DATE = new Date(1980, 0, 1, 0, 0, 0, 0);
13
+ const MANIFEST_PATH = ".fargo-deploy/manifest.json";
14
+ const DEFAULT_IGNORE_RULES = [
15
+ ".git/",
16
+ "node_modules/",
17
+ ".fargo-deploy/",
18
+ ".DS_Store",
19
+ "Thumbs.db",
20
+ ".env",
21
+ ".env.*",
22
+ ".npmrc",
23
+ ".pypirc",
24
+ "*.pem",
25
+ "*.key",
26
+ "id_rsa*",
27
+ "credentials*.json"
28
+ ];
29
+
30
+ export async function createDeploymentArtifact(deployDirectory, options = {}) {
31
+ const root = resolve(deployDirectory || ".");
32
+ const rootStat = await lstat(root).catch(() => undefined);
33
+ if (!rootStat?.isDirectory()) {
34
+ throw new Error(`Deployment output is not a directory: ${root}`);
35
+ }
36
+
37
+ const target = deploymentTarget(options.target);
38
+ const entrypoint = normalizePath(options.entrypoint || (target === "pages" ? "index.html" : "index.js"));
39
+ const matcher = ignore().add(DEFAULT_IGNORE_RULES);
40
+ const customRules = await readOptionalText(join(root, ".legodeployignore"));
41
+ if (customRules) matcher.add(customRules);
42
+
43
+ const candidates = [];
44
+ const skippedSymlinks = [];
45
+ await walk(root, "");
46
+ candidates.sort((left, right) => compareText(left.path, right.path));
47
+ if (candidates.length === 0) {
48
+ throw new Error("No deployment files remain after applying deployment exclusions");
49
+ }
50
+ if (!candidates.some((file) => file.path === entrypoint)) {
51
+ throw new Error(`Deployment entrypoint is missing from output: ${entrypoint}`);
52
+ }
53
+
54
+ const files = [];
55
+ const archiveEntries = {};
56
+ const contentHash = createHash("sha256");
57
+ let contentSizeBytes = 0;
58
+ for (const file of candidates) {
59
+ const bytes = await readFile(file.absolutePath);
60
+ const sha256 = digest(bytes);
61
+ const contentType = contentTypeForPath(file.path);
62
+ contentSizeBytes += bytes.byteLength;
63
+ if (contentSizeBytes > (options.maxContentBytes ?? MAX_DEPLOY_CONTENT_BYTES)) {
64
+ throw new Error(`Deployment output exceeds ${formatBytes(options.maxContentBytes ?? MAX_DEPLOY_CONTENT_BYTES)}`);
65
+ }
66
+ const item = { path: file.path, sha256, sizeBytes: bytes.byteLength, contentType };
67
+ files.push(item);
68
+ contentHash
69
+ .update(item.path).update("\0")
70
+ .update(String(item.sizeBytes)).update("\0")
71
+ .update(item.sha256).update("\0")
72
+ .update(item.contentType).update("\n");
73
+ archiveEntries[item.path] = [bytes, { level: 6, mtime: FIXED_ZIP_DATE }];
74
+ }
75
+
76
+ const contentSha256 = contentHash.digest("hex");
77
+ const manifest = {
78
+ schemaVersion: 1,
79
+ kind: "fargo-lego-deployment-artifact",
80
+ archiveFormat: "zip",
81
+ target,
82
+ entrypoint,
83
+ contentSha256,
84
+ fileCount: files.length,
85
+ contentSizeBytes,
86
+ files
87
+ };
88
+ archiveEntries[MANIFEST_PATH] = [strToU8(`${JSON.stringify(manifest, null, 2)}\n`), { level: 6, mtime: FIXED_ZIP_DATE }];
89
+ const archive = zipSync(archiveEntries, { level: 6, mtime: FIXED_ZIP_DATE });
90
+ const maxArchiveBytes = options.maxArchiveBytes ?? MAX_DEPLOY_ARCHIVE_BYTES;
91
+ if (archive.byteLength > maxArchiveBytes) {
92
+ throw new Error(`Deployment artifact is ${formatBytes(archive.byteLength)}, above the ${formatBytes(maxArchiveBytes)} limit`);
93
+ }
94
+
95
+ return {
96
+ archive,
97
+ archiveName: `${safeFileName(basename(root))}-${contentSha256.slice(0, 12)}.zip`,
98
+ archiveSha256: digest(archive),
99
+ contentSha256,
100
+ contentSizeBytes,
101
+ entrypoint,
102
+ fileCount: files.length,
103
+ manifest,
104
+ root,
105
+ skippedSymlinkCount: skippedSymlinks.length,
106
+ target
107
+ };
108
+
109
+ async function walk(absoluteDirectory, relativeDirectory) {
110
+ const directory = await opendir(absoluteDirectory);
111
+ const entries = [];
112
+ for await (const entry of directory) entries.push(entry);
113
+ entries.sort((left, right) => compareText(left.name, right.name));
114
+
115
+ for (const entry of entries) {
116
+ const relativePath = normalizePath(relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name);
117
+ const absolutePath = join(absoluteDirectory, entry.name);
118
+ if (entry.isSymbolicLink()) {
119
+ skippedSymlinks.push(relativePath);
120
+ continue;
121
+ }
122
+ if (entry.isDirectory()) {
123
+ if (!matcher.ignores(`${relativePath}/`)) await walk(absolutePath, relativePath);
124
+ continue;
125
+ }
126
+ if (!entry.isFile() || matcher.ignores(relativePath) || relativePath === ".legodeployignore") continue;
127
+ const stat = await lstat(absolutePath);
128
+ const maxFileBytes = options.maxFileBytes ?? MAX_DEPLOY_FILE_BYTES;
129
+ if (stat.size > maxFileBytes) {
130
+ throw new Error(`Deployment file '${relativePath}' is ${formatBytes(stat.size)}, above the ${formatBytes(maxFileBytes)} per-file limit`);
131
+ }
132
+ candidates.push({ absolutePath, path: relativePath });
133
+ if (candidates.length > (options.maxFileCount ?? MAX_DEPLOY_FILE_COUNT)) {
134
+ throw new Error(`Deployment output contains more than ${options.maxFileCount ?? MAX_DEPLOY_FILE_COUNT} files`);
135
+ }
136
+ }
137
+ }
138
+ }
139
+
140
+ export function contentTypeForPath(path) {
141
+ const extension = extname(path).toLowerCase();
142
+ const known = {
143
+ ".avif": "image/avif",
144
+ ".css": "text/css; charset=utf-8",
145
+ ".csv": "text/csv; charset=utf-8",
146
+ ".gif": "image/gif",
147
+ ".html": "text/html; charset=utf-8",
148
+ ".ico": "image/x-icon",
149
+ ".jpeg": "image/jpeg",
150
+ ".jpg": "image/jpeg",
151
+ ".js": "application/javascript; charset=utf-8",
152
+ ".json": "application/json; charset=utf-8",
153
+ ".map": "application/json; charset=utf-8",
154
+ ".mjs": "application/javascript; charset=utf-8",
155
+ ".pdf": "application/pdf",
156
+ ".png": "image/png",
157
+ ".svg": "image/svg+xml; charset=utf-8",
158
+ ".txt": "text/plain; charset=utf-8",
159
+ ".wasm": "application/wasm",
160
+ ".webp": "image/webp",
161
+ ".woff": "font/woff",
162
+ ".woff2": "font/woff2",
163
+ ".xml": "application/xml; charset=utf-8"
164
+ };
165
+ return known[extension] ?? "application/octet-stream";
166
+ }
167
+
168
+ function deploymentTarget(value) {
169
+ const target = String(value || "").trim().toLowerCase();
170
+ if (target === "pages" || target === "worker") return target;
171
+ throw new Error("Deployment target must be 'pages' or 'worker'");
172
+ }
173
+
174
+ function digest(value) {
175
+ return createHash("sha256").update(value).digest("hex");
176
+ }
177
+
178
+ function compareText(left, right) {
179
+ return left < right ? -1 : left > right ? 1 : 0;
180
+ }
181
+
182
+ function normalizePath(value) {
183
+ const path = String(value || "").split(sep).join("/").replace(/^\.\//, "").replace(/^\/+/, "");
184
+ if (!path || path.length > 240 || path.includes("\\") || path.split("/").some((part) => !part || part === "." || part === "..")) {
185
+ throw new Error(`Invalid deployment path: ${value}`);
186
+ }
187
+ return path;
188
+ }
189
+
190
+ async function readOptionalText(path) {
191
+ try {
192
+ return await readFile(path, "utf8");
193
+ } catch (error) {
194
+ if (error?.code === "ENOENT") return "";
195
+ throw error;
196
+ }
197
+ }
198
+
199
+ function safeFileName(value) {
200
+ return value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "deploy";
201
+ }
202
+
203
+ function formatBytes(value) {
204
+ if (value < 1_000) return `${value} B`;
205
+ if (value < 1_000_000) return `${(value / 1_000).toFixed(1)} KB`;
206
+ return `${(value / 1_000_000).toFixed(1)} MB`;
207
+ }
@@ -0,0 +1,47 @@
1
+ import { authenticatedRequest, responsePayload } from "./api-client.mjs";
2
+
3
+ export async function uploadLegoDeployArtifact(store, input, options = {}) {
4
+ const artifact = input.artifact;
5
+ if (!artifact?.archive) throw new Error("Deployment artifact archive is required");
6
+ const url = deployArtifactCollectionUrl(input.appId, options);
7
+ if (input.version) url.searchParams.set("version", input.version);
8
+ const response = await authenticatedRequest(store, url, {
9
+ method: "POST",
10
+ headers: {
11
+ Accept: "application/json",
12
+ "Content-Type": "application/zip",
13
+ "Content-Length": String(artifact.archive.byteLength),
14
+ "X-Fargo-Deploy-Archive-Sha256": artifact.archiveSha256,
15
+ "X-Fargo-Deploy-Content-Sha256": artifact.contentSha256,
16
+ "X-Fargo-Deploy-Content-Size": String(artifact.contentSizeBytes),
17
+ "X-Fargo-Deploy-Entrypoint": artifact.entrypoint,
18
+ "X-Fargo-Deploy-File-Count": String(artifact.fileCount),
19
+ "X-Fargo-Deploy-Target": artifact.target
20
+ },
21
+ body: artifact.archive
22
+ }, options);
23
+ return {
24
+ ...(await responsePayload(response)),
25
+ local: deploymentArtifactSummary(artifact)
26
+ };
27
+ }
28
+
29
+ export function deploymentArtifactSummary(artifact) {
30
+ return {
31
+ archiveName: artifact.archiveName,
32
+ archiveSha256: artifact.archiveSha256,
33
+ archiveSizeBytes: artifact.archive.byteLength,
34
+ contentSha256: artifact.contentSha256,
35
+ contentSizeBytes: artifact.contentSizeBytes,
36
+ entrypoint: artifact.entrypoint,
37
+ fileCount: artifact.fileCount,
38
+ root: artifact.root,
39
+ skippedSymlinkCount: artifact.skippedSymlinkCount,
40
+ target: artifact.target
41
+ };
42
+ }
43
+
44
+ function deployArtifactCollectionUrl(appId, options) {
45
+ const apiUrl = (options.apiUrl ?? process.env.FARGO_API_URL ?? "https://mcp.fargowealth.app").replace(/\/+$/, "");
46
+ return new URL(`${apiUrl}/api/lego/apps/${encodeURIComponent(appId)}/deploy-artifacts`);
47
+ }
@@ -0,0 +1,77 @@
1
+ import { createDeploymentArtifact } from "./deployment-artifact.mjs";
2
+ import { uploadLegoDeployArtifact, deploymentArtifactSummary } from "./lego-deploy-artifacts.mjs";
3
+ import { uploadLegoSourceSnapshot } from "./lego-snapshots.mjs";
4
+ import { callMcpTool } from "./mcp-client.mjs";
5
+ import { mcpToolPayload } from "./mcp-result.mjs";
6
+ import { createSourceSnapshot } from "./source-snapshot.mjs";
7
+
8
+ export async function deployLocalLegoApp(store, input, options = {}) {
9
+ const target = requiredTarget(input.target);
10
+ const entrypoint = input.entrypoint || (target === "pages" ? "index.html" : "index.js");
11
+ const [snapshot, artifact] = await Promise.all([
12
+ createSourceSnapshot(input.source || ".", options.snapshotOptions),
13
+ createDeploymentArtifact(input.deployDir, { ...options.artifactOptions, target, entrypoint })
14
+ ]);
15
+
16
+ if (input.dryRun) {
17
+ return {
18
+ dryRun: true,
19
+ sourceSnapshot: sourceSnapshotSummary(snapshot),
20
+ deployArtifact: deploymentArtifactSummary(artifact)
21
+ };
22
+ }
23
+
24
+ const uploadedSnapshot = await uploadLegoSourceSnapshot(store, {
25
+ appId: input.appId,
26
+ 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");
31
+
32
+ const uploadedArtifact = await uploadLegoDeployArtifact(store, {
33
+ appId: input.appId,
34
+ artifact,
35
+ version: input.version
36
+ }, options);
37
+ const deployArtifactId = uploadedArtifact.deployArtifact?.id;
38
+ if (!deployArtifactId) throw new Error("Deployment artifact upload did not return deployArtifact.id");
39
+
40
+ const tool = target === "pages" ? "lego_pages_deploy" : "lego_worker_deploy";
41
+ const deployment = mcpToolPayload(await callMcpTool(store, tool, {
42
+ appId: input.appId,
43
+ deployArtifactId,
44
+ entrypoint: artifact.entrypoint,
45
+ sourceSnapshotId,
46
+ version: input.version
47
+ }, options));
48
+
49
+ return {
50
+ appId: input.appId,
51
+ target,
52
+ sourceSnapshot: uploadedSnapshot.sourceSnapshot,
53
+ deployArtifact: uploadedArtifact.deployArtifact,
54
+ deployment: deployment.deployment,
55
+ sourceVersion: deployment.sourceVersion,
56
+ security: deployment.security
57
+ };
58
+ }
59
+
60
+ function sourceSnapshotSummary(snapshot) {
61
+ return {
62
+ archiveName: snapshot.archiveName,
63
+ archiveSha256: snapshot.archiveSha256,
64
+ archiveSizeBytes: snapshot.archive.byteLength,
65
+ contentSha256: snapshot.contentSha256,
66
+ fileCount: snapshot.fileCount,
67
+ root: snapshot.root,
68
+ skippedSymlinkCount: snapshot.skippedSymlinkCount,
69
+ sourceSizeBytes: snapshot.sourceSizeBytes
70
+ };
71
+ }
72
+
73
+ function requiredTarget(value) {
74
+ const target = String(value || "").trim().toLowerCase();
75
+ if (target === "pages" || target === "worker") return target;
76
+ throw new Error("Missing or invalid --target; use pages or worker");
77
+ }
@@ -0,0 +1,113 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, opendir, readFile } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ import { callMcpTool } from "./mcp-client.mjs";
5
+ import { mcpToolPayload } from "./mcp-result.mjs";
6
+
7
+ const MAX_MIGRATION_BYTES = 80_000;
8
+ const MAX_MIGRATION_FILES = 500;
9
+
10
+ export async function planLegoMigrations(store, input, options = {}) {
11
+ return publicMigrationPlan(await buildMigrationPlan(store, input, options));
12
+ }
13
+
14
+ async function buildMigrationPlan(store, input, options) {
15
+ const local = await readMigrationDirectory(input.directory);
16
+ const remote = mcpToolPayload(await callMcpTool(store, "lego_d1_migrations_list", { appId: input.appId }, options));
17
+ const applied = new Map((remote.migrations ?? []).map((migration) => [String(migration.version), migration]));
18
+ const migrations = local.map((migration) => {
19
+ const existing = applied.get(migration.version);
20
+ if (!existing) return { ...migration, status: "pending" };
21
+ if (!existing.contentSha256) return { ...migration, status: "applied_unverified", remote: existing };
22
+ return existing.contentSha256 === migration.contentSha256
23
+ ? { ...migration, status: "applied", remote: existing }
24
+ : { ...migration, status: "drift", remote: existing };
25
+ });
26
+ return {
27
+ appId: input.appId,
28
+ directory: resolve(input.directory || "migrations"),
29
+ migrations,
30
+ summary: {
31
+ applied: migrations.filter((item) => item.status === "applied").length,
32
+ appliedUnverified: migrations.filter((item) => item.status === "applied_unverified").length,
33
+ drift: migrations.filter((item) => item.status === "drift").length,
34
+ pending: migrations.filter((item) => item.status === "pending").length
35
+ }
36
+ };
37
+ }
38
+
39
+ export async function applyLegoMigrations(store, input, options = {}) {
40
+ const plan = await buildMigrationPlan(store, input, options);
41
+ const drift = plan.migrations.filter((migration) => migration.status === "drift");
42
+ if (drift.length > 0) {
43
+ throw new Error(`Migration drift detected: ${drift.map((item) => item.fileName).join(", ")}`);
44
+ }
45
+
46
+ const applied = [];
47
+ for (const migration of plan.migrations.filter((item) => item.status === "pending")) {
48
+ const result = mcpToolPayload(await callMcpTool(store, "lego_d1_migrations_apply", {
49
+ appId: input.appId,
50
+ contentSha256: migration.contentSha256,
51
+ fileName: migration.fileName,
52
+ sql: migration.sql,
53
+ tablePrefix: input.tablePrefix,
54
+ version: migration.version
55
+ }, options));
56
+ applied.push(result.migration);
57
+ }
58
+
59
+ return {
60
+ appId: input.appId,
61
+ applied,
62
+ appliedCount: applied.length,
63
+ plan: {
64
+ directory: plan.directory,
65
+ summary: plan.summary
66
+ }
67
+ };
68
+ }
69
+
70
+ function publicMigrationPlan(plan) {
71
+ return {
72
+ ...plan,
73
+ migrations: plan.migrations.map(({ sql: _sql, ...migration }) => migration)
74
+ };
75
+ }
76
+
77
+ async function readMigrationDirectory(directory) {
78
+ const root = resolve(directory || "migrations");
79
+ const stat = await lstat(root).catch(() => undefined);
80
+ if (!stat?.isDirectory()) throw new Error(`Migration directory is not a directory: ${root}`);
81
+ const handle = await opendir(root);
82
+ const entries = [];
83
+ for await (const entry of handle) entries.push(entry);
84
+ const sqlFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith(".sql"));
85
+ const unexpectedLinks = entries.filter((entry) => entry.isSymbolicLink());
86
+ if (unexpectedLinks.length > 0) throw new Error(`Migration directory contains symbolic links: ${unexpectedLinks.map((entry) => entry.name).join(", ")}`);
87
+ sqlFiles.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
88
+ if (sqlFiles.length === 0) throw new Error(`Migration directory contains no .sql files: ${root}`);
89
+ if (sqlFiles.length > MAX_MIGRATION_FILES) throw new Error(`Migration directory contains more than ${MAX_MIGRATION_FILES} SQL files`);
90
+
91
+ const migrations = [];
92
+ const versions = new Set();
93
+ for (const entry of sqlFiles) {
94
+ const version = entry.name.slice(0, -4);
95
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(version)) {
96
+ throw new Error(`Invalid migration filename: ${entry.name}`);
97
+ }
98
+ if (versions.has(version)) throw new Error(`Duplicate migration version: ${version}`);
99
+ versions.add(version);
100
+ const sql = await readFile(join(root, entry.name), "utf8");
101
+ const sizeBytes = Buffer.byteLength(sql);
102
+ if (sizeBytes === 0) throw new Error(`Migration file is empty: ${entry.name}`);
103
+ if (sizeBytes > MAX_MIGRATION_BYTES) throw new Error(`Migration file exceeds ${MAX_MIGRATION_BYTES} bytes: ${entry.name}`);
104
+ migrations.push({
105
+ contentSha256: createHash("sha256").update(sql).digest("hex"),
106
+ fileName: entry.name,
107
+ sizeBytes,
108
+ sql,
109
+ version
110
+ });
111
+ }
112
+ return migrations;
113
+ }
@@ -3,7 +3,7 @@ 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 = await createSourceSnapshot(input.source ?? ".", options.snapshotOptions);
6
+ const snapshot = options.snapshot ?? await createSourceSnapshot(input.source ?? ".", options.snapshotOptions);
7
7
  if (input.dryRun) {
8
8
  return { dryRun: true, snapshot: snapshotSummary(snapshot) };
9
9
  }
@@ -0,0 +1,14 @@
1
+ export function mcpToolPayload(result) {
2
+ if (result?.isError) {
3
+ const code = result.structuredContent?.error?.code;
4
+ const text = result.content?.map((item) => item?.text).filter(Boolean).join("\n");
5
+ throw new Error(code || text || "Fargo MCP tool failed");
6
+ }
7
+ const text = result?.content?.find((item) => item?.type === "text" && typeof item.text === "string")?.text;
8
+ if (!text) return result ?? {};
9
+ try {
10
+ return JSON.parse(text);
11
+ } catch {
12
+ return { text };
13
+ }
14
+ }