@mapled/cli 0.15.0 → 0.17.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/README.md CHANGED
@@ -30,9 +30,12 @@ The browser opens Mapled on that project and the person clicks Authorize; nothin
30
30
  npx @mapled/cli tools
31
31
  npx @mapled/cli tool get_schema
32
32
  npx @mapled/cli tool propose_setup_plan --input plan.json
33
+ npx @mapled/cli tool get_setup_run '{"runId":"…"}' --wait
33
34
  ```
34
35
 
35
- `tools` lists every tool with its input (`--json` for the schemas). `tool <name>` calls one — the same tools the [MCP server](https://www.npmjs.com/package/@mapled/mcp) offers, through the same connection: the input is a JSON object, inline or in a file with `--input`; the answer is printed as Mapled worded it, and a refusal exits 1.
36
+ `tools` lists every tool with its input (`--json` for the schemas). `tool <name>` calls one — the same tools the [MCP server](https://www.npmjs.com/package/@mapled/mcp) offers, through the same connection: the input is a JSON object, inline or in a file with `--input`; the answer is printed as Mapled worded it, and a refusal exits 1. `tool get_setup_run '{"runId":"…"}' --wait` waits while the plan is still waiting for approval and prints the run once the person has decided on the Mapled screen — the agent carries on without being asked; it waits 90 seconds by default (`--wait=<seconds>`, up to 600) and then prints the run as it stands, so the agent runs the same command again.
37
+
38
+ Images are content: a setup plan names each picture in an image field (its path in the repository or its public address). Once the plan is approved, `npx @mapled/cli media upload --run <runId>` uploads every one of them into the project's Media — `tool apply_setup_plan` does it first by itself — and the records point at the files, which the owner can replace in Mapled.
36
39
 
37
40
  Do the work yourself: the person approves on Mapled screens (the connection, the plan, a destructive change) and is never sent to a terminal or to GitHub. Never ask for a token or a key in the conversation.
38
41
 
@@ -185,6 +188,15 @@ Submissions show up in Mapled under Forms.
185
188
 
186
189
  The key comes from the name; `--key <key>` chooses it (lowercase letters, digits and dashes), `--no-spam-protection` turns the honeypot off. Creating a form changes structure, so it needs the builder plan. `forms list` shows every form with the URL the site posts to (`--json` for scripts). The component that posts to it is in the [`@mapled/next` README](https://www.npmjs.com/package/@mapled/next#forms).
187
190
 
191
+ ## Upload media
192
+
193
+ ```bash
194
+ npx @mapled/cli media upload public/products/hoodie.jpg https://example.com/cover.png
195
+ npx @mapled/cli media upload --run <runId>
196
+ ```
197
+
198
+ `media upload` puts images and files into the project's Media: a path is read from this repository (a path from the site's root, `/images/a.png`, is looked for under `public/` and `static/` too; nothing outside the repository is read), an address is downloaded by Mapled. PNG, JPEG, WebP, GIF and SVG up to 10 MB, PDF up to 25 MB; the same bytes uploaded twice are one file. Each line names the asset id an image or file field holds. `--run <runId>` uploads every file an approved setup plan names that isn't in Mapled yet, for `apply_setup_plan` to put into the records (`--json` for scripts); a file that didn't upload is named with the reason and the command exits 1.
199
+
188
200
  ## Leave a guide for the next agent
189
201
 
190
202
  ```bash
@@ -287,13 +299,26 @@ npx @mapled/cli preview articles hello-world --locale ru
287
299
 
288
300
  Prints a Preview link — the one the **Preview** button in Mapled opens: the site in draft mode, with every saved change that isn't published yet. Name a collection and a record's slug (or id) and the link lands on the record's page, the one the pushed site manifest says shows it; a collection alone opens the page that lists it, a single its page, nothing at all the home page. Mapled makes the link after checking that the site answers and mounts its preview route; it works once, for five minutes. A plain HTML site whose pages are built with the content in them has no page for a record it wasn't built with: for such a record the command says so instead of printing a link — its page appears once the site is built with it. `--locale <code>` opens the page in that language — the address Mapled builds from the manifest's `locales` (a translated slug is the address in its language); a language the project doesn't have is refused. `--open` opens it in your browser instead of printing it; `--json` prints the link with the page, the language when one was asked, and when it expires.
289
301
 
302
+ ## Put the server key on the host
303
+
304
+ ```bash
305
+ npx @mapled/cli env push --host vercel
306
+ npx @mapled/cli env push --host netlify
307
+ npx @mapled/cli env push --host cloudflare
308
+ ```
309
+
310
+ Gives the site on that host a server key of its own (Live data, the Application Data API) and sets it as `MAPLED_SERVER_KEY` there, without the key passing through you or your agent. Run it from the site's repository, with the host's CLI signed in and the directory linked (`vercel link`, `netlify link`, or a `wrangler.toml`); a CLI that isn't installed runs through `npx`. The command prints a link: an owner of the project confirms the key there, in Mapled. Once they do, the command claims the key — only the process that asked can, once, within ten minutes — and hands it to the host's CLI: Vercel for production and preview, Netlify as a site variable, Cloudflare as a Worker secret (a Worker whose configuration has environments needs `--env <name>`, the one the site is deployed to). The key is never printed, logged, written to a file or included in `--json`; you see its prefix. A new key for the same host replaces the old one, which keeps working for 24 hours while the host redeploys; the project's main key and the other hosts' keep working, and an owner revokes a host's key in Mapled. Redeploy the site afterwards. `--json` prints the host, the variable, the prefix and the confirmation.
311
+
290
312
  ## Options
291
313
 
292
314
  - `--api <origin>` — a Mapled API other than `https://api.mapled.io` (or set `MAPLED_API_URL`); `project link` remembers it in `mapled.json`
293
315
  - `--no-browser` — print the sign-in link instead of opening a browser
294
316
  - `--project <id>` — the project `connect` and `auth login` open the consent on, and the one `project link` links
295
317
  - `--input <file>` — `tool` only: the tool's input as a JSON file
296
- - `--json` — machine-readable output (`tools`, `doctor`, `integration report`, `schema diff`, `scan`, `manifest validate`, `forms list`, `preview`)
318
+ - `--wait[=<seconds>]` — `tool get_setup_run` only: wait while the plan awaits approval (90 seconds by default, up to 600)
319
+ - `--host <vercel|netlify|cloudflare>` — `env push` only: the host the site runs on
320
+ - `--env <name>` — `env push --host cloudflare` only: the Wrangler environment the Worker is deployed as
321
+ - `--json` — machine-readable output (`tools`, `doctor`, `integration report`, `schema diff`, `scan`, `manifest validate`, `forms list`, `media upload`, `preview`, `env push`)
297
322
  - `--load-config` — `doctor` only: let the site's installed Next load a next.config doctor can't read; runs the site's code, as `next build` does
298
323
  - `MAPLED_CONFIG_DIR` — where credentials live (default: `$XDG_CONFIG_HOME/mapled` or `~/.config/mapled`)
299
324
 
package/dist/args.js CHANGED
@@ -2,7 +2,7 @@ import { CliError } from "./errors.js";
2
2
  /** Minimal argument parsing: the leading words name the command;
3
3
  `--flag`, `--flag=value` and `--flag value` (for the flags that take
4
4
  one) follow. Nothing here is user content beyond the command line. */
5
- const VALUE_FLAGS = new Set(["api", "project", "out", "key", "locale", "input"]);
5
+ const VALUE_FLAGS = new Set(["api", "project", "out", "key", "locale", "input", "host", "env"]);
6
6
  export function parseArgs(argv) {
7
7
  const words = [];
8
8
  const flags = {};
@@ -1,7 +1,9 @@
1
+ import { Session } from "./api.js";
1
2
  import { type ParsedArgs } from "./args.js";
2
3
  import { type FoundConfig } from "./config.js";
3
4
  import { type Connection } from "./credentials.js";
4
5
  import { type Fetch } from "./oauth.js";
6
+ import type { Runner } from "./envPush.js";
5
7
  /** The commands of waves 1 and 2 (§31), MAPLED.md (§21.3) and forms.
6
8
  Everything that talks to the world comes in through `Ctx`, so the
7
9
  commands run in tests against a fake API and a temp directory. */
@@ -17,6 +19,10 @@ export type Ctx = {
17
19
  credentialsFile: string;
18
20
  /** How long a sign-in waits for the browser (tests shorten it). */
19
21
  loginTimeoutMs?: number;
22
+ /** How often `tool get_setup_run --wait` asks again (tests shorten it). */
23
+ pollMs?: number;
24
+ /** Runs a host's CLI for `env push` (tests replace it). */
25
+ run?: Runner;
20
26
  };
21
27
  type Flags = ParsedArgs["flags"];
22
28
  export declare function login(ctx: Ctx, flags: Flags): Promise<Connection>;
@@ -35,6 +41,16 @@ export declare function tools(ctx: Ctx, flags: Flags): Promise<void>;
35
41
  way an MCP client makes it: the answer is printed as Mapled worded
36
42
  it, a refusal exits 1. */
37
43
  export declare function tool(ctx: Ctx, flags: Flags, words: string[]): Promise<number>;
44
+ /** `mapled media upload [<path | url>…] [--run <runId>] [--json]` — files
45
+ of this repository or public addresses into the project's Media.
46
+ With --run, every media file of that approved setup plan (or the ones
47
+ named) is uploaded as the plan's, for apply_setup_plan to put into
48
+ the records. */
49
+ export declare function mediaUpload(ctx: Ctx, flags: Flags, sources: string[]): Promise<number>;
50
+ export declare function openSession(ctx: Ctx, flags: Flags): Promise<{
51
+ found: FoundConfig;
52
+ session: Session;
53
+ }>;
38
54
  export declare function generate(ctx: Ctx, flags: Flags): Promise<void>;
39
55
  export declare function schemaPull(ctx: Ctx, flags: Flags): Promise<void>;
40
56
  export declare function schemaDiff(ctx: Ctx, flags: Flags): Promise<number>;
package/dist/commands.js CHANGED
@@ -16,6 +16,7 @@ import { capabilitiesOf, findLive, liveState } from "./live.js";
16
16
  import { loadTypeScript, mergeManifest, scanRepository } from "./scan.js";
17
17
  import { scanHtml } from "./scanHtml.js";
18
18
  import { generateTypes } from "./types.js";
19
+ import { pendingOf, uploadAll, uploadLine, uploadSource } from "./media.js";
19
20
  /** The OAuth client id for this API — registered once per machine, and
20
21
  again when the server no longer knows it. */
21
22
  async function ensureClient(store, api, f) {
@@ -249,14 +250,124 @@ export async function tool(ctx, flags, words) {
249
250
  }
250
251
  if (args === null || typeof args !== "object" || Array.isArray(args))
251
252
  throw new CliError("The input must be a JSON object.");
252
- const { session } = await openSession(ctx, flags);
253
- const answer = await session.rpc("tools/call", { name, arguments: args });
253
+ const wait = waitSeconds(flags, name);
254
+ const { found, session } = await openSession(ctx, flags);
255
+ const input = args;
256
+ // a file on this machine: the hosted tool can't read it, the CLI uploads it
257
+ if (name === "upload_media" && typeof input.path === "string" && input.url === undefined) {
258
+ const runId = typeof input.runId === "string" ? input.runId : undefined;
259
+ const done = await uploadSource(session, ctx.fetch, found.dir, typeof input.source === "string" ? input.source : input.path, runId);
260
+ if (!done.ok) {
261
+ ctx.out(`${done.source}: ${done.error}`);
262
+ return 1;
263
+ }
264
+ ctx.out(JSON.stringify({ asset: done.asset, deduplicated: done.deduplicated }, null, 2));
265
+ return 0;
266
+ }
267
+ // the plan's media goes up before the apply that puts it into the records
268
+ if (name === "apply_setup_plan" && typeof input.runId === "string" && input.skipMissingMedia !== true) {
269
+ await uploadRunMedia(ctx, session, found.dir, input.runId);
270
+ }
271
+ const deadline = Date.now() + wait * 1000;
272
+ let answer = await session.rpc("tools/call", { name, arguments: args });
273
+ // the person approves on a Mapled screen, not in the agent's chat: the
274
+ // agent blocks here instead of ending its turn to wait for a word that
275
+ // never comes
276
+ while (wait > 0 && !answer.isError && awaitingApproval(answer) && Date.now() < deadline) {
277
+ await new Promise((resolve) => setTimeout(resolve, Math.min(ctx.pollMs ?? WAIT_POLL_MS, Math.max(0, deadline - Date.now()))));
278
+ answer = await session.rpc("tools/call", { name, arguments: args });
279
+ }
254
280
  for (const part of answer.content ?? [])
255
281
  if (part.type === "text" && part.text !== undefined)
256
282
  ctx.out(part.text);
257
283
  return answer.isError ? 1 : 0;
258
284
  }
259
- async function openSession(ctx, flags) {
285
+ /** The media a setup run's plan names and Mapled doesn't have yet, from
286
+ this repository or the public addresses it names; a line per file.
287
+ Nothing to do unless the plan is approved. */
288
+ async function uploadRunMedia(ctx, session, root, runId) {
289
+ const { status, pending } = await pendingOf(session, runId);
290
+ if (status !== "approved" || pending.length === 0)
291
+ return { failed: 0, done: 0 };
292
+ ctx.out(`Uploading ${plural(pending.length, "media file")} of the plan to Mapled…`);
293
+ const results = await uploadAll(session, ctx.fetch, root, pending, runId);
294
+ for (const r of results)
295
+ ctx.out(uploadLine(r));
296
+ const failed = results.filter((r) => !r.ok).length;
297
+ return { failed, done: results.length - failed };
298
+ }
299
+ /** `mapled media upload [<path | url>…] [--run <runId>] [--json]` — files
300
+ of this repository or public addresses into the project's Media.
301
+ With --run, every media file of that approved setup plan (or the ones
302
+ named) is uploaded as the plan's, for apply_setup_plan to put into
303
+ the records. */
304
+ export async function mediaUpload(ctx, flags, sources) {
305
+ const runId = stringFlag(flags, "run");
306
+ if (runId !== undefined && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(runId)) {
307
+ throw new CliError("--run takes the id of a setup run — the runId propose_setup_plan answered.", 2);
308
+ }
309
+ if (runId === undefined && sources.length === 0) {
310
+ throw new CliError("What to upload? Name files of this repository or public addresses, or pass --run <runId> for a setup plan's media.", 2);
311
+ }
312
+ const { found, session } = await openSession(ctx, flags);
313
+ let list = sources;
314
+ if (runId !== undefined && list.length === 0) {
315
+ const run = await pendingOf(session, runId);
316
+ if (run.status !== "approved") {
317
+ throw new CliError(run.status === "awaiting_approval"
318
+ ? "The plan is still waiting for approval. Upload its media once the person approves it."
319
+ : "This run takes no more media: its plan isn't approved or was applied already.");
320
+ }
321
+ if (run.pending.length === 0) {
322
+ ctx.out(run.total === 0 ? "The plan names no media." : `Every media file of the plan is in Mapled (${run.total}). Apply the plan next.`);
323
+ return 0;
324
+ }
325
+ list = run.pending;
326
+ }
327
+ const results = await uploadAll(session, ctx.fetch, found.dir, list, runId);
328
+ if (flags.json) {
329
+ ctx.out(JSON.stringify(results, null, 2));
330
+ }
331
+ else {
332
+ for (const r of results)
333
+ ctx.out(uploadLine(r));
334
+ const failed = results.filter((r) => !r.ok).length;
335
+ ctx.out(failed === 0
336
+ ? `Uploaded ${plural(results.length, "file")} to Mapled's Media.${runId ? " Apply the plan next." : ""}`
337
+ : `${plural(failed, "file")} didn't upload. Fix ${failed === 1 ? "it" : "them"} and run this again${runId ? ", or apply with skipMissingMedia: true to leave those fields empty" : ""}.`);
338
+ }
339
+ return results.every((r) => r.ok) ? 0 : 1;
340
+ }
341
+ /** `--wait` waits this long by default: under the two minutes an agent's
342
+ shell gives a command unless it asks for more. */
343
+ const WAIT_SECONDS = 90;
344
+ const WAIT_MAX_SECONDS = 600;
345
+ const WAIT_POLL_MS = 3000;
346
+ /** Seconds `tool <name> --wait[=<seconds>]` may wait; 0 without it. */
347
+ function waitSeconds(flags, name) {
348
+ const value = flags.wait;
349
+ if (value === undefined)
350
+ return 0;
351
+ if (name !== "get_setup_run")
352
+ throw new CliError("--wait works with get_setup_run only.", 2);
353
+ if (value === true)
354
+ return WAIT_SECONDS;
355
+ const seconds = Number(value);
356
+ if (!Number.isInteger(seconds) || seconds < 1 || seconds > WAIT_MAX_SECONDS) {
357
+ throw new CliError(`--wait takes whole seconds from 1 to ${WAIT_MAX_SECONDS}.`, 2);
358
+ }
359
+ return seconds;
360
+ }
361
+ function awaitingApproval(answer) {
362
+ const text = answer.content?.find((part) => part.type === "text")?.text;
363
+ try {
364
+ return JSON.parse(text ?? "").run?.status === "awaiting_approval";
365
+ }
366
+ catch {
367
+ return false;
368
+ }
369
+ }
370
+ export async function openSession(ctx, flags) {
260
371
  const found = await findConfig(ctx.cwd);
261
372
  if (!found)
262
373
  throw new CliError(`No ${CONFIG_FILE} here or above. Run \`mapled project link\` first.`);
@@ -0,0 +1,44 @@
1
+ import { type ParsedArgs } from "./args.js";
2
+ import { type Ctx } from "./commands.js";
3
+ /** `mapled env push --host vercel|netlify|cloudflare` — the site's server
4
+ key (Live data, the Application Data API) put on its host without
5
+ passing through the agent that runs this. The command asks Mapled for
6
+ a key of that host with the SHA-256 of a verifier it keeps in memory,
7
+ prints the link an owner of the project confirms on (the trusted
8
+ screen, S-44), waits for the decision, claims the key with the
9
+ verifier and hands it to the host's own CLI — on stdin where the CLI
10
+ takes it so, as an argument only where it has no other form.
11
+
12
+ The key is never printed, logged, written to disk, put in an
13
+ environment or included in `--json`: the host CLI's output is kept and
14
+ shown only when it fails, every occurrence of the key replaced by its
15
+ prefix. The agent sees the link, the decision and the prefix. */
16
+ export type Host = "vercel" | "netlify" | "cloudflare";
17
+ export declare const ENV_NAME = "MAPLED_SERVER_KEY";
18
+ /** A process run to its end: its exit code and what it wrote. `missing` —
19
+ the program isn't there to run (ENOENT). */
20
+ export type RunResult = {
21
+ code: number;
22
+ stdout: string;
23
+ stderr: string;
24
+ missing?: boolean;
25
+ };
26
+ export type Runner = (cmd: string, args: string[], opts: {
27
+ cwd: string;
28
+ input?: string;
29
+ }) => Promise<RunResult>;
30
+ /** The default runner: no shell (but on Windows, where the CLIs are
31
+ `.cmd` shims), the parent's environment untouched, stdin closed after
32
+ `input`, output kept — never streamed to the terminal. */
33
+ export declare const runProcess: Runner;
34
+ /** Every occurrence of the key in a text, as its prefix and an ellipsis. */
35
+ export declare function redact(text: string, key: string, prefix: string): string;
36
+ /** The environments wrangler.toml names, in each form TOML allows:
37
+ `[env.<name>]` headers, keys of an `[env]` table (`<name> = {…}`) and
38
+ dotted keys (`env.<name>.vars = …`). `opaque` — it has an `env` this
39
+ reading can't name (`env = {…}` inline). */
40
+ export declare function tomlEnvs(toml: string): {
41
+ names: string[];
42
+ opaque: boolean;
43
+ };
44
+ export declare function envPush(ctx: Ctx, flags: ParsedArgs["flags"], run?: Runner): Promise<number>;
@@ -0,0 +1,285 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { access, readFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { ApiError } from "./api.js";
6
+ import { stringFlag } from "./args.js";
7
+ import { openSession } from "./commands.js";
8
+ import { CliError } from "./errors.js";
9
+ const HOSTS = ["vercel", "netlify", "cloudflare"];
10
+ const HOST_NAMES = { vercel: "Vercel", netlify: "Netlify", cloudflare: "Cloudflare" };
11
+ /** The host's CLI as installed (on PATH) or through npx. */
12
+ const HOST_CLI = {
13
+ vercel: { bin: "vercel", pkg: "vercel" },
14
+ netlify: { bin: "netlify", pkg: "netlify-cli" },
15
+ cloudflare: { bin: "wrangler", pkg: "wrangler" },
16
+ };
17
+ export const ENV_NAME = "MAPLED_SERVER_KEY";
18
+ const RUN_TIMEOUT_MS = 180_000;
19
+ /** The default runner: no shell (but on Windows, where the CLIs are
20
+ `.cmd` shims), the parent's environment untouched, stdin closed after
21
+ `input`, output kept — never streamed to the terminal. */
22
+ export const runProcess = (cmd, args, opts) => new Promise((resolve) => {
23
+ let stdout = "";
24
+ let stderr = "";
25
+ let settled = false;
26
+ const done = (r) => {
27
+ if (settled)
28
+ return;
29
+ settled = true;
30
+ clearTimeout(timer);
31
+ resolve(r);
32
+ };
33
+ const child = spawn(cmd, args, { cwd: opts.cwd, stdio: ["pipe", "pipe", "pipe"], shell: process.platform === "win32", windowsHide: true });
34
+ const timer = setTimeout(() => {
35
+ child.kill();
36
+ done({ code: 124, stdout, stderr: `${stderr}\n(timed out)` });
37
+ }, RUN_TIMEOUT_MS);
38
+ child.stdout.on("data", (d) => (stdout += d.toString("utf8")));
39
+ child.stderr.on("data", (d) => (stderr += d.toString("utf8")));
40
+ child.on("error", (err) => done({ code: 127, stdout, stderr: err.message, missing: err.code === "ENOENT" }));
41
+ child.on("close", (code) => done({ code: code ?? 1, stdout, stderr }));
42
+ child.stdin.on("error", () => { });
43
+ child.stdin.end(opts.input ?? "");
44
+ });
45
+ /** Every occurrence of the key in a text, as its prefix and an ellipsis. */
46
+ export function redact(text, key, prefix) {
47
+ return key ? text.split(key).join(`${prefix}…`) : text;
48
+ }
49
+ async function hostCli(ctx, run, host, cwd) {
50
+ const { bin, pkg } = HOST_CLI[host];
51
+ const probe = await run(bin, ["--version"], { cwd });
52
+ if (!probe.missing)
53
+ return { run: (args, input) => run(bin, args, { cwd, input }), shown: bin };
54
+ const viaNpx = await run("npx", ["-y", pkg, "--version"], { cwd });
55
+ if (viaNpx.missing || viaNpx.code !== 0) {
56
+ throw new CliError(`${HOST_NAMES[host]}'s CLI isn't installed and npx couldn't run it. Install it with \`npm install -g ${pkg}\`, then run this again.`);
57
+ }
58
+ ctx.out(`Using ${pkg} through npx.`);
59
+ return { run: (args, input) => run("npx", ["-y", pkg, ...args], { cwd, input }), shown: `npx ${pkg}` };
60
+ }
61
+ const exists = async (file) => access(file).then(() => true, () => false);
62
+ /** The host's CLI is signed in, and this directory is the site's there. */
63
+ async function preflight(host, cli, cwd) {
64
+ if (host === "vercel") {
65
+ if ((await cli.run(["whoami"])).code !== 0) {
66
+ throw new CliError(`Vercel's CLI isn't signed in. Run \`${cli.shown} login\`, then run this again.`);
67
+ }
68
+ if (!(await exists(path.join(cwd, ".vercel", "project.json")))) {
69
+ throw new CliError(`This directory isn't linked to a Vercel project. Run \`${cli.shown} link\` here, then run this again.`);
70
+ }
71
+ return;
72
+ }
73
+ if (host === "netlify") {
74
+ const status = await cli.run(["status"]);
75
+ if (status.code !== 0 || /not logged in|log in/i.test(`${status.stdout}\n${status.stderr}`)) {
76
+ throw new CliError(`Netlify's CLI isn't signed in. Run \`${cli.shown} login\`, then run this again.`);
77
+ }
78
+ if (!(await exists(path.join(cwd, ".netlify", "state.json")))) {
79
+ throw new CliError(`This directory isn't linked to a Netlify site. Run \`${cli.shown} link\` here, then run this again.`);
80
+ }
81
+ return;
82
+ }
83
+ const who = await cli.run(["whoami"]);
84
+ if (who.code !== 0 || /not authenticated/i.test(`${who.stdout}\n${who.stderr}`)) {
85
+ throw new CliError(`Wrangler isn't signed in to Cloudflare. Run \`${cli.shown} login\`, then run this again.`);
86
+ }
87
+ const configs = ["wrangler.toml", "wrangler.json", "wrangler.jsonc"];
88
+ if (!(await Promise.all(configs.map((f) => exists(path.join(cwd, f))))).some(Boolean)) {
89
+ throw new CliError("No wrangler.toml here. Run this from the Worker's directory, where Wrangler finds its configuration.");
90
+ }
91
+ }
92
+ function stepsFor(host, key, workerEnv) {
93
+ if (host === "vercel") {
94
+ const steps = [];
95
+ // one overwrite per environment, never a remove then an add: a
96
+ // deployment that starts between the two would build without the key
97
+ for (const env of ["production", "preview"]) {
98
+ steps.push({ args: ["env", "add", ENV_NAME, env, "--force"], input: `${key}\n`, what: `set the ${env} value` });
99
+ }
100
+ return { steps, where: " (production, preview)" };
101
+ }
102
+ if (host === "netlify") {
103
+ // the Netlify CLI takes a value as an argument only
104
+ return { steps: [{ args: ["env:set", ENV_NAME, key], what: "set the value" }], where: "" };
105
+ }
106
+ // a named Wrangler environment is a Worker of its own: the secret goes to the one deployed
107
+ const args = ["secret", "put", ENV_NAME, ...(workerEnv ? ["--env", workerEnv] : [])];
108
+ return { steps: [{ args, input: `${key}\n`, what: "put the secret" }], where: workerEnv ? ` (Worker secret, ${workerEnv})` : " (Worker secret)" };
109
+ }
110
+ /** A TOML key path (`env.production`, `"env"."live"`, `env . a`) as its
111
+ parts; null when the text is no key path. */
112
+ function keyPath(text) {
113
+ const parts = [];
114
+ const re = /\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|([A-Za-z0-9_-]+))\s*(\.|$)/y;
115
+ let at = 0;
116
+ while (at < text.length) {
117
+ re.lastIndex = at;
118
+ const m = re.exec(text);
119
+ if (!m)
120
+ return null;
121
+ parts.push(m[1] ?? m[2] ?? m[3]);
122
+ at = re.lastIndex;
123
+ if (m[4] !== ".")
124
+ break;
125
+ }
126
+ return at === text.length && parts.length ? parts : null;
127
+ }
128
+ /** The environments wrangler.toml names, in each form TOML allows:
129
+ `[env.<name>]` headers, keys of an `[env]` table (`<name> = {…}`) and
130
+ dotted keys (`env.<name>.vars = …`). `opaque` — it has an `env` this
131
+ reading can't name (`env = {…}` inline). */
132
+ export function tomlEnvs(toml) {
133
+ const names = new Set();
134
+ let opaque = false;
135
+ let table = [];
136
+ for (const raw of toml.split(/\r?\n/)) {
137
+ // the line up to a comment that isn't inside a string
138
+ const line = (/^(?:[^"'#]|"(?:[^"\\]|\\.)*"|'[^']*')*/.exec(raw)?.[0] ?? "").trim();
139
+ const header = /^\[\[?([^\[\]]*)\]\]?$/.exec(line);
140
+ if (header) {
141
+ table = keyPath(header[1]) ?? [];
142
+ if (table[0] === "env" && table[1] !== undefined)
143
+ names.add(table[1]);
144
+ continue;
145
+ }
146
+ const eq = /^((?:[^="']|"(?:[^"\\]|\\.)*"|'[^']*')+)=/.exec(line);
147
+ const key = eq ? keyPath(eq[1].trim()) : null;
148
+ if (!key)
149
+ continue;
150
+ const full = [...table, ...key];
151
+ if (full[0] !== "env")
152
+ continue;
153
+ if (full[1] !== undefined)
154
+ names.add(full[1]);
155
+ else
156
+ opaque = true;
157
+ }
158
+ return { names: [...names], opaque };
159
+ }
160
+ /** The environments a Worker's configuration names (wrangler.toml —
161
+ `tomlEnvs`; `"env": {…}` in wrangler.json(c)); none when it names none
162
+ or can't be read. `opaque` — it has environments whose names this
163
+ reading doesn't get. */
164
+ async function wranglerEnvs(cwd) {
165
+ const names = new Set();
166
+ let opaque = false;
167
+ const toml = await readFile(path.join(cwd, "wrangler.toml"), "utf8").catch(() => null);
168
+ if (toml !== null) {
169
+ const found = tomlEnvs(toml);
170
+ for (const name of found.names)
171
+ names.add(name);
172
+ opaque = found.opaque;
173
+ }
174
+ for (const file of ["wrangler.json", "wrangler.jsonc"]) {
175
+ const text = await readFile(path.join(cwd, file), "utf8").catch(() => null);
176
+ if (text === null)
177
+ continue;
178
+ try {
179
+ // comments and trailing commas of jsonc, outside strings
180
+ const plain = text.replace(/("(?:[^"\\]|\\.)*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (_, str) => str ?? "")
181
+ .replace(/,(\s*[}\]])/g, "$1");
182
+ const env = JSON.parse(plain).env;
183
+ if (env && typeof env === "object" && !Array.isArray(env))
184
+ for (const name of Object.keys(env))
185
+ names.add(name);
186
+ }
187
+ catch {
188
+ // unreadable: Wrangler says what is wrong when it runs
189
+ }
190
+ }
191
+ return { names: [...names], opaque };
192
+ }
193
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
194
+ /** Until the request is decided: applied, or a CliError saying why not. */
195
+ async function decision(ctx, session, asked) {
196
+ const deadline = asked.expiresAt ? new Date(asked.expiresAt).getTime() : Date.now() + 60 * 60_000;
197
+ let current = asked;
198
+ for (;;) {
199
+ if (current.status === "applied")
200
+ return;
201
+ if (current.status === "denied")
202
+ throw new CliError("An owner denied the request in Mapled. Nothing was set.");
203
+ if (current.status === "expired")
204
+ throw new CliError("The request expired before an owner confirmed it. Run this again.");
205
+ if (current.status === "failed")
206
+ throw new CliError(current.error ?? "The request failed in Mapled. Run this again.");
207
+ if (Date.now() >= deadline)
208
+ throw new CliError("No owner confirmed the request in time. Run this again.");
209
+ await sleep(Math.min(ctx.pollMs ?? 2000, Math.max(0, deadline - Date.now())));
210
+ current = (await session.get(`/v1/agent/confirmations/${current.id}`)).confirmation;
211
+ }
212
+ }
213
+ export async function envPush(ctx, flags, run = ctx.run ?? runProcess) {
214
+ const host = stringFlag(flags, "host");
215
+ if (!host || !HOSTS.includes(host)) {
216
+ throw new CliError(`Name the site's host: \`mapled env push --host ${HOSTS.join("|")}\`.`, 2);
217
+ }
218
+ const which = host;
219
+ const name = HOST_NAMES[which];
220
+ const { found, session } = await openSession(ctx, flags);
221
+ const cwd = ctx.cwd;
222
+ const workerEnv = stringFlag(flags, "env") ?? null;
223
+ if (workerEnv !== null && (which !== "cloudflare" || !/^[A-Za-z0-9_-]+$/.test(workerEnv))) {
224
+ throw new CliError(which === "cloudflare" ? "--env takes the name of an environment in the Worker's configuration." : "--env names a Wrangler environment: use it with --host cloudflare.", 2);
225
+ }
226
+ const cli = await hostCli(ctx, run, which, cwd);
227
+ await preflight(which, cli, cwd);
228
+ if (which === "cloudflare") {
229
+ const { names: envs, opaque } = await wranglerEnvs(cwd);
230
+ if (workerEnv === null && (envs.length > 0 || opaque)) {
231
+ const listed = envs.length ? ` (${envs.join(", ")})` : "";
232
+ throw new CliError(`This Worker's configuration has environments${listed}. Name the one the site is deployed to: \`mapled env push --host cloudflare --env ${envs[0] ?? "<name>"}\`.`, 2);
233
+ }
234
+ if (workerEnv !== null && !opaque && !envs.includes(workerEnv)) {
235
+ throw new CliError(`The Worker's configuration has no environment ${workerEnv}${envs.length ? ` (it has ${envs.join(", ")})` : ""}.`, 2);
236
+ }
237
+ }
238
+ // the verifier stays in this process: Mapled gets its hash, the key goes only to the one who holds it
239
+ const verifier = randomBytes(32).toString("hex");
240
+ const challenge = createHash("sha256").update(verifier).digest("hex");
241
+ let asked;
242
+ try {
243
+ asked = await session.send("POST", "/v1/agent/server-keys", { host: which, challenge });
244
+ }
245
+ catch (err) {
246
+ if (err instanceof ApiError)
247
+ throw new CliError(err.message);
248
+ throw err;
249
+ }
250
+ ctx.out(`Give the person this link to confirm: ${asked.reviewUrl}`);
251
+ ctx.out(`Waiting for an owner of ${session.conn.projectName} to confirm in Mapled…`);
252
+ await decision(ctx, session, asked.confirmation);
253
+ let claimed;
254
+ try {
255
+ claimed = await session.send("POST", "/v1/agent/server-keys/claim", {
256
+ confirmationId: asked.confirmation.id,
257
+ verifier,
258
+ });
259
+ }
260
+ catch (err) {
261
+ if (err instanceof ApiError)
262
+ throw new CliError(err.message);
263
+ throw err;
264
+ }
265
+ const { key, prefix } = claimed;
266
+ const shown = `${prefix}…`;
267
+ const { steps, where } = stepsFor(which, key, workerEnv);
268
+ for (const step of steps) {
269
+ const result = await cli.run(step.args, step.input);
270
+ if (result.code === 0 || step.optional)
271
+ continue;
272
+ const output = redact(`${result.stdout}\n${result.stderr}`, key, prefix).trim();
273
+ if (output)
274
+ for (const line of output.split("\n"))
275
+ ctx.out(` ${line}`);
276
+ throw new CliError(`${name}'s CLI couldn't ${step.what} of ${ENV_NAME} (exit ${result.code}). Mapled made the key ${shown}; run this again to make another one once the host's CLI works.`);
277
+ }
278
+ if (flags.json) {
279
+ ctx.out(JSON.stringify({ host: which, variable: ENV_NAME, prefix, confirmationId: asked.confirmation.id, project: found.config.project }, null, 2));
280
+ }
281
+ else {
282
+ ctx.out(`✓ ${ENV_NAME} set on ${name}${where} — ${shown}. Redeploy the site for it to apply.`);
283
+ }
284
+ return 0;
285
+ }
package/dist/index.js CHANGED
@@ -3,8 +3,9 @@ import { readFileSync } from "node:fs";
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { parseArgs } from "./args.js";
5
5
  import { openBrowser } from "./browser.js";
6
- import { bindingsPull, bindingsPush, doctor, formsCreate, formsList, generate, integrationReport, link, login, logout, manifestValidate, mdPull, preview, scan, connect, tool, tools, schemaDiff, schemaPull, } from "./commands.js";
6
+ import { bindingsPull, bindingsPush, doctor, formsCreate, formsList, generate, integrationReport, link, login, logout, manifestValidate, mdPull, mediaUpload, preview, scan, connect, tool, tools, schemaDiff, schemaPull, } from "./commands.js";
7
7
  import { credentialsPath } from "./credentials.js";
8
+ import { envPush } from "./envPush.js";
8
9
  import { CliError } from "./errors.js";
9
10
  /** `mapled` — sign in, link a repository to its project, generate types
10
11
  for the content, keep the schema pin, the site manifest and MAPLED.md
@@ -16,6 +17,7 @@ Usage
16
17
  mapled connect [--project <id>] Connect this repository: sign in, write mapled.json, print the guide for AI agents
17
18
  mapled tools [--json] List the Mapled tools an agent can call
18
19
  mapled tool <name> [<json> | --input <file>] Call one tool — the same ones the MCP server offers
20
+ get_setup_run --wait[=<s>] waits (90 s by default) for the plan's approval
19
21
  mapled auth login [--api <origin>] [--no-browser] Sign in to a project in your browser
20
22
  mapled auth logout [--all] Revoke this machine's access to the linked project
21
23
  mapled project link [--project <id>] Write mapled.json for this repository
@@ -27,11 +29,13 @@ Usage
27
29
  mapled bindings push [--dry-run] Push mapled/manifest.json so Mapled grades every binding
28
30
  mapled bindings pull [--force] Write the pushed manifest into mapled/manifest.json
29
31
  mapled md pull [--force] Write MAPLED.md, the guide for the next agent, from the project
32
+ mapled media upload [<path | url>…] [--run <runId>] [--json] Upload images and files into Media; --run uploads every one a setup plan names
30
33
  mapled forms create <name> [--key <key>] Create a form the site posts submissions to (--no-spam-protection to skip the honeypot)
31
34
  mapled forms list [--json] List the project's forms and where the site posts to
32
35
  mapled doctor [--json] [--load-config] Check the integration end to end; --load-config runs a next.config doctor can't read, as next build does
33
36
  mapled integration report [--json] [--verify] Show the last setup run's verification checklist and report
34
37
  mapled preview [<collection> [<slug>]] [--locale <code>] [--open] Print a one-time Preview link that shows the drafts on the site
38
+ mapled env push --host <vercel|netlify|cloudflare> [--env <name>] [--json] Put the site's server key on its host once an owner confirms it in Mapled (--env: the Wrangler environment); the key is never printed
35
39
 
36
40
  Options
37
41
  --version, -v Print the version
@@ -126,6 +130,8 @@ async function main(argv) {
126
130
  case "md pull":
127
131
  await mdPull(ctx, flags);
128
132
  return 0;
133
+ case "media upload":
134
+ return mediaUpload(ctx, flags, words.slice(2));
129
135
  case "forms create":
130
136
  await formsCreate(ctx, flags, words.slice(2));
131
137
  return 0;
@@ -139,6 +145,8 @@ async function main(argv) {
139
145
  case "preview":
140
146
  await preview(ctx, flags, words.slice(1));
141
147
  return 0;
148
+ case "env push":
149
+ return envPush(ctx, flags);
142
150
  case "auth":
143
151
  case "project":
144
152
  case "types":
@@ -147,7 +155,9 @@ async function main(argv) {
147
155
  case "bindings":
148
156
  case "md":
149
157
  case "forms":
158
+ case "media":
150
159
  case "integration":
160
+ case "env":
151
161
  console.log(HELP);
152
162
  return 2;
153
163
  default:
@@ -0,0 +1,37 @@
1
+ import { type Session } from "./api.js";
2
+ import type { Fetch } from "./oauth.js";
3
+ export type Asset = {
4
+ id: string;
5
+ url: string;
6
+ fileName: string;
7
+ mime: string;
8
+ sizeBytes: number;
9
+ };
10
+ export type Uploaded = {
11
+ source: string;
12
+ ok: true;
13
+ asset: Asset;
14
+ deduplicated: boolean;
15
+ } | {
16
+ source: string;
17
+ ok: false;
18
+ error: string;
19
+ };
20
+ /** The file a source names in the repository: its path from the root —
21
+ or, written from the site's root (`/images/a.png`), under `public/`
22
+ or `static/` as the frameworks serve it. Never a file outside the
23
+ repository, links included. */
24
+ export declare function locate(root: string, source: string): Promise<string | null>;
25
+ /** One source into Media: a file of the repository at `root`, or a
26
+ public address. */
27
+ export declare function uploadSource(session: Session, f: Fetch, root: string, source: string, runId?: string): Promise<Uploaded>;
28
+ /** Every source, a few at a time, in the order given. */
29
+ export declare function uploadAll(session: Session, f: Fetch, root: string, sources: string[], runId?: string): Promise<Uploaded[]>;
30
+ /** The sources of a setup run's plan that aren't in Mapled yet. */
31
+ export declare function pendingOf(session: Session, runId: string): Promise<{
32
+ status: string;
33
+ pending: string[];
34
+ total: number;
35
+ }>;
36
+ /** One line per upload: what it became, or why it didn't. */
37
+ export declare function uploadLine(r: Uploaded): string;
package/dist/media.js ADDED
@@ -0,0 +1,131 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, realpath, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { ApiError } from "./api.js";
5
+ import { CliError } from "./errors.js";
6
+ /** `mapled media upload` (F3): the site's images and files into the
7
+ project's Media. A file of the repository goes sign → PUT → complete
8
+ (`/v1/agent/media/*`); a public address Mapled downloads itself
9
+ (`import`). With a setup run, each upload is a media source of its
10
+ approved plan, and the apply puts the assets into the records. */
11
+ /** The types Media takes, by extension. */
12
+ const TYPES = {
13
+ ".png": "image/png",
14
+ ".jpg": "image/jpeg",
15
+ ".jpeg": "image/jpeg",
16
+ ".webp": "image/webp",
17
+ ".gif": "image/gif",
18
+ ".svg": "image/svg+xml",
19
+ ".pdf": "application/pdf",
20
+ };
21
+ /** What Media's upload says: "Up to 10 MB per image"; a PDF up to 25 MB. */
22
+ const IMAGE_LIMIT = 10 * 1024 * 1024;
23
+ const FILE_LIMIT = 25 * 1024 * 1024;
24
+ /** Uploads in flight at once. */
25
+ const PARALLEL = 4;
26
+ const isAddress = (source) => /^https?:\/\//i.test(source);
27
+ /** The file a source names in the repository: its path from the root —
28
+ or, written from the site's root (`/images/a.png`), under `public/`
29
+ or `static/` as the frameworks serve it. Never a file outside the
30
+ repository, links included. */
31
+ export async function locate(root, source) {
32
+ const realRoot = await realpath(root);
33
+ const rel = source.replace(/^\.\//, "");
34
+ const tries = rel.startsWith("/")
35
+ ? [rel.slice(1), `public${rel}`, `static${rel}`]
36
+ : [rel, `public/${rel}`];
37
+ for (const candidate of tries) {
38
+ let real;
39
+ try {
40
+ real = await realpath(path.resolve(realRoot, candidate));
41
+ }
42
+ catch {
43
+ continue;
44
+ }
45
+ if (!real.startsWith(realRoot + path.sep))
46
+ continue;
47
+ if ((await stat(real)).isFile())
48
+ return real;
49
+ }
50
+ return null;
51
+ }
52
+ /** Why an upload failed, as Mapled said it. */
53
+ const why = (err) => (err instanceof ApiError || err instanceof CliError ? err.message : "the upload didn't go through. Try again.");
54
+ async function uploadFile(session, f, root, source, run) {
55
+ const file = await locate(root, source);
56
+ if (!file)
57
+ return { source, ok: false, error: "not found in the repository." };
58
+ const mime = TYPES[path.extname(file).toLowerCase()];
59
+ if (!mime)
60
+ return { source, ok: false, error: "isn't a file Media takes: PNG, JPEG, WebP, GIF, SVG or PDF." };
61
+ const bytes = await readFile(file);
62
+ const limit = mime === "application/pdf" ? FILE_LIMIT : IMAGE_LIMIT;
63
+ if (bytes.length > limit)
64
+ return { source, ok: false, error: `is larger than ${limit / 1024 / 1024} MB. Compress it and try again.` };
65
+ if (bytes.length === 0)
66
+ return { source, ok: false, error: "is empty." };
67
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
68
+ try {
69
+ const signed = await session.send("POST", "/v1/agent/media/sign", { fileName: path.basename(file), mime, sizeBytes: bytes.length, sha256, ...run });
70
+ if (signed.asset)
71
+ return { source, ok: true, asset: signed.asset, deduplicated: true };
72
+ // R2's presigned address, or the API's own sink in development
73
+ const target = new URL(signed.uploadUrl, session.conn.api).href;
74
+ let put;
75
+ try {
76
+ put = await f(target, { method: "PUT", headers: signed.headers ?? { "Content-Type": mime }, body: new Uint8Array(bytes) });
77
+ }
78
+ catch {
79
+ return { source, ok: false, error: "the upload didn't reach storage. Check your connection and try again." };
80
+ }
81
+ if (!put.ok)
82
+ return { source, ok: false, error: `storage refused the upload (${put.status}). Try again.` };
83
+ const done = await session.send("POST", "/v1/agent/media/complete", { ticket: signed.ticket, ...run });
84
+ return { source, ok: true, asset: done.asset, deduplicated: done.deduplicated };
85
+ }
86
+ catch (err) {
87
+ return { source, ok: false, error: why(err) };
88
+ }
89
+ }
90
+ /** One source into Media: a file of the repository at `root`, or a
91
+ public address. */
92
+ export async function uploadSource(session, f, root, source, runId) {
93
+ const run = runId ? { runId, source } : null;
94
+ if (!isAddress(source))
95
+ return uploadFile(session, f, root, source, run);
96
+ try {
97
+ const done = await session.send("POST", "/v1/agent/media/import", { url: source, ...run });
98
+ return { source, ok: true, asset: done.asset, deduplicated: done.deduplicated };
99
+ }
100
+ catch (err) {
101
+ return { source, ok: false, error: why(err) };
102
+ }
103
+ }
104
+ /** Every source, a few at a time, in the order given. */
105
+ export async function uploadAll(session, f, root, sources, runId) {
106
+ const results = new Array(sources.length);
107
+ let next = 0;
108
+ const worker = async () => {
109
+ while (next < sources.length) {
110
+ const i = next++;
111
+ results[i] = await uploadSource(session, f, root, sources[i], runId);
112
+ }
113
+ };
114
+ await Promise.all(Array.from({ length: Math.min(PARALLEL, sources.length) }, worker));
115
+ return results;
116
+ }
117
+ /** The sources of a setup run's plan that aren't in Mapled yet. */
118
+ export async function pendingOf(session, runId) {
119
+ const answer = await session.get(`/v1/agent/runs/${encodeURIComponent(runId)}`);
120
+ return {
121
+ status: answer.run?.status ?? "",
122
+ pending: answer.run?.media?.pending ?? [],
123
+ total: answer.run?.media?.total ?? 0,
124
+ };
125
+ }
126
+ /** One line per upload: what it became, or why it didn't. */
127
+ export function uploadLine(r) {
128
+ return r.ok
129
+ ? `✓ ${r.source} → ${r.asset.id}${r.deduplicated ? " (already in Media)" : ""}`
130
+ : `✗ ${r.source}: ${r.error}`;
131
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mapled/cli",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "Mapled CLI — sign in, link a project, generate TypeScript types for its content, create forms, preview drafts and check the site's integration.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://mapled.io",