@mapled/cli 0.16.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
@@ -35,6 +35,8 @@ npx @mapled/cli tool get_setup_run '{"runId":"…"}' --wait
35
35
 
36
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
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.
39
+
38
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.
39
41
 
40
42
  ## Sign in
@@ -186,6 +188,15 @@ Submissions show up in Mapled under Forms.
186
188
 
187
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).
188
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
+
189
200
  ## Leave a guide for the next agent
190
201
 
191
202
  ```bash
@@ -288,6 +299,16 @@ npx @mapled/cli preview articles hello-world --locale ru
288
299
 
289
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.
290
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
+
291
312
  ## Options
292
313
 
293
314
  - `--api <origin>` — a Mapled API other than `https://api.mapled.io` (or set `MAPLED_API_URL`); `project link` remembers it in `mapled.json`
@@ -295,7 +316,9 @@ Prints a Preview link — the one the **Preview** button in Mapled opens: the si
295
316
  - `--project <id>` — the project `connect` and `auth login` open the consent on, and the one `project link` links
296
317
  - `--input <file>` — `tool` only: the tool's input as a JSON file
297
318
  - `--wait[=<seconds>]` — `tool get_setup_run` only: wait while the plan awaits approval (90 seconds by default, up to 600)
298
- - `--json` — machine-readable output (`tools`, `doctor`, `integration report`, `schema diff`, `scan`, `manifest validate`, `forms list`, `preview`)
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`)
299
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
300
323
  - `MAPLED_CONFIG_DIR` — where credentials live (default: `$XDG_CONFIG_HOME/mapled` or `~/.config/mapled`)
301
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. */
@@ -19,6 +21,8 @@ export type Ctx = {
19
21
  loginTimeoutMs?: number;
20
22
  /** How often `tool get_setup_run --wait` asks again (tests shorten it). */
21
23
  pollMs?: number;
24
+ /** Runs a host's CLI for `env push` (tests replace it). */
25
+ run?: Runner;
22
26
  };
23
27
  type Flags = ParsedArgs["flags"];
24
28
  export declare function login(ctx: Ctx, flags: Flags): Promise<Connection>;
@@ -37,6 +41,16 @@ export declare function tools(ctx: Ctx, flags: Flags): Promise<void>;
37
41
  way an MCP client makes it: the answer is printed as Mapled worded
38
42
  it, a refusal exits 1. */
39
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
+ }>;
40
54
  export declare function generate(ctx: Ctx, flags: Flags): Promise<void>;
41
55
  export declare function schemaPull(ctx: Ctx, flags: Flags): Promise<void>;
42
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) {
@@ -250,7 +251,23 @@ export async function tool(ctx, flags, words) {
250
251
  if (args === null || typeof args !== "object" || Array.isArray(args))
251
252
  throw new CliError("The input must be a JSON object.");
252
253
  const wait = waitSeconds(flags, name);
253
- const { session } = await openSession(ctx, flags);
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
+ }
254
271
  const deadline = Date.now() + wait * 1000;
255
272
  let answer = await session.rpc("tools/call", { name, arguments: args });
256
273
  // the person approves on a Mapled screen, not in the agent's chat: the
@@ -265,6 +282,62 @@ export async function tool(ctx, flags, words) {
265
282
  ctx.out(part.text);
266
283
  return answer.isError ? 1 : 0;
267
284
  }
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
+ }
268
341
  /** `--wait` waits this long by default: under the two minutes an agent's
269
342
  shell gives a command unless it asks for more. */
270
343
  const WAIT_SECONDS = 90;
@@ -294,7 +367,7 @@ function awaitingApproval(answer) {
294
367
  return false;
295
368
  }
296
369
  }
297
- async function openSession(ctx, flags) {
370
+ export async function openSession(ctx, flags) {
298
371
  const found = await findConfig(ctx.cwd);
299
372
  if (!found)
300
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
@@ -28,11 +29,13 @@ Usage
28
29
  mapled bindings push [--dry-run] Push mapled/manifest.json so Mapled grades every binding
29
30
  mapled bindings pull [--force] Write the pushed manifest into mapled/manifest.json
30
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
31
33
  mapled forms create <name> [--key <key>] Create a form the site posts submissions to (--no-spam-protection to skip the honeypot)
32
34
  mapled forms list [--json] List the project's forms and where the site posts to
33
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
34
36
  mapled integration report [--json] [--verify] Show the last setup run's verification checklist and report
35
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
36
39
 
37
40
  Options
38
41
  --version, -v Print the version
@@ -127,6 +130,8 @@ async function main(argv) {
127
130
  case "md pull":
128
131
  await mdPull(ctx, flags);
129
132
  return 0;
133
+ case "media upload":
134
+ return mediaUpload(ctx, flags, words.slice(2));
130
135
  case "forms create":
131
136
  await formsCreate(ctx, flags, words.slice(2));
132
137
  return 0;
@@ -140,6 +145,8 @@ async function main(argv) {
140
145
  case "preview":
141
146
  await preview(ctx, flags, words.slice(1));
142
147
  return 0;
148
+ case "env push":
149
+ return envPush(ctx, flags);
143
150
  case "auth":
144
151
  case "project":
145
152
  case "types":
@@ -148,7 +155,9 @@ async function main(argv) {
148
155
  case "bindings":
149
156
  case "md":
150
157
  case "forms":
158
+ case "media":
151
159
  case "integration":
160
+ case "env":
152
161
  console.log(HELP);
153
162
  return 2;
154
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.16.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",