@fargowealth/fargo-ai-cli 0.5.1 → 0.7.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,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0
4
+
5
+ - Added source-backup history metadata and Portal list/download support for
6
+ immutable source versions, including independent backup-only events.
7
+ - Deployment now requires a version number, version name, and upgrade notes;
8
+ history records show the deployer and expandable notes.
9
+ - Source backup remains the deploy default. Added explicit `--skip-source` for
10
+ deployments that intentionally omit a source archive.
11
+
12
+ ## 0.6.0
13
+
14
+ - Added `fargo lego deploy` to create a source snapshot and deterministic
15
+ Pages/Worker deployment ZIP, upload both through authenticated binary data
16
+ paths, and create a ready deployment through the hosted MCP contract.
17
+ - Deployment artifacts now preserve text and binary files, carry per-file and
18
+ aggregate SHA-256 provenance, support `.legodeployignore`, and are linked to
19
+ deployment records by `deployArtifactId`.
20
+ - Added `fargo lego d1 migrate plan|apply` for ordered local SQL files with
21
+ server-verified hashes, idempotent retries, and content-drift protection.
22
+ - Kept publication and migrations independent from deployment, and kept R2,
23
+ Cloudflare credentials, permission checks, and D1 execution server-side.
24
+
3
25
  ## 0.5.1
4
26
 
5
27
  - 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.7.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,28 +44,71 @@ 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
57
- fargo lego snapshot upload --app app_xxx --source . --dry-run
58
- fargo lego snapshot upload --app app_xxx --source . --version v1
50
+ fargo lego snapshot upload --app app_xxx --source . --version 1.4.0 --name "Pre-upgrade backup" --dry-run
51
+ fargo lego snapshot upload --app app_xxx --source . --version 1.4.0 --name "Pre-upgrade backup" --notes "Before migration"
59
52
  fargo lego snapshot list --app app_xxx
60
53
  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
+ An upload creates a downloadable source-backup history event without deploying
61
+ or publishing the App.
62
+
63
+ ## LEGO local deploy
64
+
65
+ Run the project build explicitly, then give Fargo CLI the completed output:
66
+
67
+ ```sh
68
+ npm run build
69
+ fargo lego deploy \
70
+ --app app_xxx \
71
+ --source . \
72
+ --deploy-dir dist \
73
+ --target pages \
74
+ --entrypoint index.html \
75
+ --version 1.4.0 \
76
+ --name "Navigation refresh" \
77
+ --notes-file CHANGELOG.md
78
+ ```
79
+
80
+ For Workers use `--target worker`; the default entrypoint becomes `index.js`.
81
+ The CLI does not install dependencies, infer a framework, or run the build.
82
+ Version number, version name, and upgrade notes are required. Source upload is
83
+ the default; `--skip-source` is the explicit exception for a deployment that
84
+ must not retain a source archive.
85
+
86
+ The command creates and uploads two immutable archives:
87
+
88
+ 1. a filtered source snapshot for recovery and provenance;
89
+ 2. a deterministic deployment ZIP from `--deploy-dir` using
90
+ `.legodeployignore`.
91
+
92
+ It then sends only `sourceSnapshotId` and `deployArtifactId` through the hosted
93
+ MCP deployment contract. The result is `ready`, never automatically published.
94
+ Use `--dry-run` to validate both local packages without authentication or
95
+ network writes.
96
+
97
+ Deployment artifacts preserve binary bytes and include
98
+ `.fargo-deploy/manifest.json`. Default limits are 1,000 files, 25 MB total,
99
+ 20 MB per file, and a 25 MB ZIP.
100
+
101
+ ## LEGO D1 migrations
102
+
103
+ ```sh
104
+ fargo lego d1 migrate plan --app app_xxx --dir migrations
105
+ fargo lego d1 migrate apply --app app_xxx --dir migrations
106
+ ```
67
107
 
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`.
108
+ Migration files are top-level, lexically ordered `*.sql` files. The filename
109
+ without `.sql` is the version. `plan` compares local SHA-256 values with the
110
+ production registry; `apply` stops on content drift and submits only pending
111
+ files. Migration execution remains server-side and is never coupled to deploy.
72
112
 
73
113
  ## WorkBuddy adapter
74
114
 
@@ -77,8 +117,7 @@ npx -y @fargowealth/fargo-ai-cli@latest workbuddy install --open-browser=always
77
117
  ```
78
118
 
79
119
  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:
120
+ bridge. Device Authorization Grant remains an explicit fallback:
82
121
 
83
122
  ```sh
84
123
  fargo auth device --open-browser=always --output=jsonl
@@ -89,12 +128,10 @@ fargo auth device --open-browser=always --output=jsonl
89
128
  - macOS and Linux: `~/.fargo/credentials.json`
90
129
  - Windows: `%APPDATA%\Fargo AI\credentials.json`
91
130
 
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.
131
+ macOS/Linux use a `0700` directory and `0600` file. Windows inherits the
132
+ current user's NTFS ACL. Writes use an `fsync`-backed temporary file and atomic
133
+ replacement, symbolic-link reads are rejected, and refresh-token rotation is
134
+ serialized across CLI processes.
97
135
 
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.
136
+ Never copy, upload, commit, or print the credential file. Use
137
+ `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.7.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,45 @@ 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 appId = requiredOption(args, "--app");
62
+ const deployDir = requiredOption(args, "--deploy-dir");
63
+ const target = requiredOption(args, "--target");
64
+ const version = requiredVersionOption(args, "--version", 80);
65
+ const versionName = requiredTextOption(args, "--name", 160);
66
+ const releaseNotes = await requiredNotesOption(args);
67
+ const result = await deployLocalLegoApp(store, {
68
+ appId,
69
+ deployDir,
70
+ dryRun: args.includes("--dry-run"),
71
+ entrypoint: optionValue(args, "--entrypoint"),
72
+ releaseNotes,
73
+ skipSource: args.includes("--skip-source"),
74
+ source: optionValue(args, "--source") ?? ".",
75
+ target,
76
+ version,
77
+ versionName
78
+ }, options);
79
+ stdout.write(`${JSON.stringify(result)}\n`);
80
+ return;
81
+ }
82
+ if (args[0] === "lego" && args[1] === "d1" && args[2] === "migrate") {
83
+ const action = args[3];
84
+ const input = {
85
+ appId: requiredOption(args, "--app"),
86
+ directory: optionValue(args, "--dir") ?? "migrations",
87
+ tablePrefix: optionValue(args, "--table-prefix")
88
+ };
89
+ if (action === "plan") {
90
+ stdout.write(`${JSON.stringify(await planLegoMigrations(store, input, options))}\n`);
91
+ return;
92
+ }
93
+ if (action === "apply") {
94
+ stdout.write(`${JSON.stringify(await applyLegoMigrations(store, input, options))}\n`);
95
+ return;
96
+ }
97
+ throw new Error("Usage: fargo lego d1 migrate plan|apply --app <appId> [--dir migrations]");
98
+ }
58
99
  if (args[0] === "workbuddy" && args[1] === "install") {
59
100
  let credential = await store.load();
60
101
  if (credential) {
@@ -72,7 +113,7 @@ export async function runCli(args, options = {}) {
72
113
  stdout.write(`${JSON.stringify(await installWorkBuddyMcp(options))}\n`);
73
114
  return;
74
115
  }
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");
116
+ 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
117
  } catch (error) {
77
118
  stderr.write(`fargo: ${error instanceof Error ? error.message : "unknown error"}\n`);
78
119
  if (options.throwOnError) throw error;
@@ -106,8 +147,11 @@ async function runLegoSnapshotCommand(args, stdout, store, options) {
106
147
  const result = await uploadLegoSourceSnapshot(store, {
107
148
  appId,
108
149
  dryRun: args.includes("--dry-run"),
150
+ notes: await optionalNotesOption(args),
151
+ purpose: "backup",
109
152
  source: optionValue(args, "--source") ?? ".",
110
- version: optionValue(args, "--version")
153
+ version: requiredVersionOption(args, "--version", 120),
154
+ versionName: requiredTextOption(args, "--name", 160)
111
155
  }, options);
112
156
  stdout.write(`${JSON.stringify(result)}\n`);
113
157
  return;
@@ -128,7 +172,25 @@ async function runLegoSnapshotCommand(args, stdout, store, options) {
128
172
  stdout.write(`${JSON.stringify({ archiveSha256: result.archiveSha256, outputPath, sizeBytes: result.archive.byteLength, snapshotId })}\n`);
129
173
  return;
130
174
  }
131
- throw new Error("Usage: fargo lego snapshot upload|list|download --app <appId>");
175
+ throw new Error("Usage: fargo lego snapshot upload --app <appId> --version <number> --name <name> [--notes TEXT | --notes-file PATH] | list|download --app <appId>");
176
+ }
177
+
178
+ async function requiredNotesOption(args) {
179
+ const notes = await optionalNotesOption(args);
180
+ if (!notes) throw new Error("Missing release notes; pass --notes <text> or --notes-file <path>");
181
+ if (notes.length > 4_000) throw new Error("Release notes must not exceed 4000 characters");
182
+ return notes;
183
+ }
184
+
185
+ async function optionalNotesOption(args) {
186
+ const inline = optionValue(args, "--notes");
187
+ const file = optionValue(args, "--notes-file");
188
+ if (inline !== undefined && file !== undefined) throw new Error("Use only one of --notes or --notes-file");
189
+ const notes = file !== undefined
190
+ ? (await readFile(resolve(file), "utf8")).trim()
191
+ : inline?.trim() ?? "";
192
+ if (notes.length > 4_000) throw new Error("Notes must not exceed 4000 characters");
193
+ return notes;
132
194
  }
133
195
 
134
196
  async function readToolArgs(args) {
@@ -160,7 +222,21 @@ async function pathExists(path) {
160
222
 
161
223
  function requiredOption(args, name) {
162
224
  const value = optionValue(args, name)?.trim();
163
- if (!value) throw new Error(`Missing required option ${name}`);
225
+ if (!value || value.startsWith("--")) throw new Error(`Missing required option ${name}`);
226
+ return value;
227
+ }
228
+
229
+ function requiredTextOption(args, name, maxLength) {
230
+ const value = requiredOption(args, name);
231
+ if (value.length > maxLength) throw new Error(`${name} must not exceed ${maxLength} characters`);
232
+ return value;
233
+ }
234
+
235
+ function requiredVersionOption(args, name, maxLength) {
236
+ const value = requiredTextOption(args, name, maxLength);
237
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) {
238
+ throw new Error(`${name} must use letters, numbers, dot, underscore, or hyphen`);
239
+ }
164
240
  return value;
165
241
  }
166
242
 
package/src/constants.mjs CHANGED
@@ -1,4 +1,4 @@
1
- export const CLI_VERSION = "0.5.1";
1
+ export const CLI_VERSION = "0.7.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,90 @@
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
+ input.skipSource ? Promise.resolve(undefined) : 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
+ release: {
20
+ notes: input.releaseNotes,
21
+ version: input.version,
22
+ versionName: input.versionName
23
+ },
24
+ sourceSnapshot: snapshot ? sourceSnapshotSummary(snapshot) : undefined,
25
+ sourceSnapshotSkipped: !snapshot,
26
+ deployArtifact: deploymentArtifactSummary(artifact)
27
+ };
28
+ }
29
+
30
+ const uploadedSnapshot = snapshot ? await uploadLegoSourceSnapshot(store, {
31
+ appId: input.appId,
32
+ notes: input.releaseNotes,
33
+ purpose: "deployment",
34
+ source: input.source || ".",
35
+ version: input.version,
36
+ versionName: input.versionName
37
+ }, { ...options, snapshot }) : undefined;
38
+ const sourceSnapshotId = uploadedSnapshot?.sourceSnapshot?.id;
39
+ if (snapshot && !sourceSnapshotId) throw new Error("Source snapshot upload did not return sourceSnapshot.id");
40
+
41
+ const uploadedArtifact = await uploadLegoDeployArtifact(store, {
42
+ appId: input.appId,
43
+ artifact,
44
+ version: input.version
45
+ }, options);
46
+ const deployArtifactId = uploadedArtifact.deployArtifact?.id;
47
+ if (!deployArtifactId) throw new Error("Deployment artifact upload did not return deployArtifact.id");
48
+
49
+ const tool = target === "pages" ? "lego_pages_deploy" : "lego_worker_deploy";
50
+ const deploymentInput = {
51
+ appId: input.appId,
52
+ deployArtifactId,
53
+ entrypoint: artifact.entrypoint,
54
+ releaseNotes: input.releaseNotes,
55
+ version: input.version,
56
+ versionName: input.versionName,
57
+ ...(sourceSnapshotId ? { sourceSnapshotId } : {})
58
+ };
59
+ const deployment = mcpToolPayload(await callMcpTool(store, tool, deploymentInput, options));
60
+
61
+ return {
62
+ appId: input.appId,
63
+ target,
64
+ sourceSnapshot: uploadedSnapshot?.sourceSnapshot,
65
+ sourceSnapshotSkipped: !snapshot,
66
+ deployArtifact: uploadedArtifact.deployArtifact,
67
+ deployment: deployment.deployment,
68
+ sourceVersion: deployment.sourceVersion,
69
+ security: deployment.security
70
+ };
71
+ }
72
+
73
+ function sourceSnapshotSummary(snapshot) {
74
+ return {
75
+ archiveName: snapshot.archiveName,
76
+ archiveSha256: snapshot.archiveSha256,
77
+ archiveSizeBytes: snapshot.archive.byteLength,
78
+ contentSha256: snapshot.contentSha256,
79
+ fileCount: snapshot.fileCount,
80
+ root: snapshot.root,
81
+ skippedSymlinkCount: snapshot.skippedSymlinkCount,
82
+ sourceSizeBytes: snapshot.sourceSizeBytes
83
+ };
84
+ }
85
+
86
+ function requiredTarget(value) {
87
+ const target = String(value || "").trim().toLowerCase();
88
+ if (target === "pages" || target === "worker") return target;
89
+ throw new Error("Missing or invalid --target; use pages or worker");
90
+ }
@@ -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,13 +3,28 @@ 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
- return { dryRun: true, snapshot: snapshotSummary(snapshot) };
8
+ return {
9
+ dryRun: true,
10
+ metadata: {
11
+ notes: input.notes ?? "",
12
+ purpose: input.purpose ?? "backup",
13
+ version: input.version ?? "",
14
+ versionName: input.versionName ?? ""
15
+ },
16
+ snapshot: snapshotSummary(snapshot)
17
+ };
9
18
  }
10
19
 
11
20
  const url = snapshotCollectionUrl(input.appId, options);
12
21
  if (input.version) url.searchParams.set("version", input.version);
22
+ const metadata = Buffer.from(JSON.stringify({
23
+ notes: input.notes ?? "",
24
+ purpose: input.purpose ?? "backup",
25
+ version: input.version ?? "",
26
+ versionName: input.versionName ?? ""
27
+ }), "utf8").toString("base64url");
13
28
  const response = await authenticatedRequest(store, url, {
14
29
  method: "POST",
15
30
  headers: {
@@ -19,6 +34,7 @@ export async function uploadLegoSourceSnapshot(store, input, options = {}) {
19
34
  "X-Fargo-Source-Archive-Sha256": snapshot.archiveSha256,
20
35
  "X-Fargo-Source-Content-Sha256": snapshot.contentSha256,
21
36
  "X-Fargo-Source-File-Count": String(snapshot.fileCount),
37
+ "X-Fargo-Source-Metadata": metadata,
22
38
  "X-Fargo-Source-Size": String(snapshot.sourceSizeBytes)
23
39
  },
24
40
  body: snapshot.archive
@@ -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
+ }