@mapled/cli 0.16.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -71,7 +73,7 @@ Only public values live here — the project id, where the generated types go, t
71
73
  npx @mapled/cli types generate
72
74
  ```
73
75
 
74
- Reads the project's schema and writes `mapled-types.ts` (the path from `mapled.json`, or `--out <file>`): one interface per collection and single, keyed maps, a `MapledSchema` that ties them together, and doc comments with the field types. Sensitive fields never reach the site and are left out. A group field is typed by an interface of its items; the project's component types become one interface each (`HeroComponent`, with `_type: "hero"` and the item's `_key`), a `components` field a list of the union of the types it allows, and `MapledComponent` the union of them all — switch on `_type` to render an item.
76
+ Reads the project's schema and writes `mapled-types.ts` (the path from `mapled.json`, or `--out <file>`): one interface per collection and single, keyed maps, a `MapledSchema` that ties them together, and doc comments with the field types. Sensitive fields never reach the site and are left out. A group field is typed by an interface of its items; the project's component types become one interface each (`HeroComponent`, with `_type: "hero"` and the item's `_key`; a group inside a type, a list of its items' interface — `PricingTiersItem[]`), a `components` field a list of the union of the types it allows, and `MapledComponent` the union of them all — switch on `_type` to render an item.
75
77
 
76
78
  ```ts
77
79
  import type { MapledSchema } from "./mapled-types";
@@ -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 and 3D models (GLB, or glTF as one file) 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 = {};
@@ -0,0 +1,50 @@
1
+ /** The page the browser shows at the end of `mapled auth login` — the
2
+ loopback redirect on 127.0.0.1. One template for every outcome, drawn
3
+ after the S-56 service pages of the app (Figma 570:9128): the logo in
4
+ the top corner of a page panel, a centred heading and subtitle, at
5
+ most one primary action. The page loads nothing — no fonts, no
6
+ images, no scripts from anywhere: styles and the logo are inline, the
7
+ one script carries this response's nonce. The values are the app's
8
+ tokens (apps/web/src/app/globals.css), copied: the page can't read
9
+ them from here. Dark only, as the app. */
10
+ export type CallbackView =
11
+ /** Signed in; `url` is the project in Mapled, where the tab goes on. */
12
+ {
13
+ kind: "signed-in";
14
+ project: string;
15
+ url: string | null;
16
+ }
17
+ /** The code came, but the sign-in didn't complete (exchange, API). */
18
+ | {
19
+ kind: "failed";
20
+ }
21
+ /** The person cancelled or denied on the consent screen. */
22
+ | {
23
+ kind: "cancelled";
24
+ }
25
+ /** A redirect that isn't the running command's — an older sign-in. */
26
+ | {
27
+ kind: "expired";
28
+ }
29
+ /** A redirect without its code. */
30
+ | {
31
+ kind: "incomplete";
32
+ }
33
+ /** Nobody told the page how the sign-in ended — the command stopped
34
+ waiting for it (`HOLD_MS` in oauth.ts). */
35
+ | {
36
+ kind: "handed-over";
37
+ };
38
+ /** Seconds before a signed-in tab opens the project by itself. */
39
+ export declare const REDIRECT_SECONDS = 5;
40
+ /** Only a web address goes into the page: Mapled's answer names it, and
41
+ anything else (`javascript:`, a bare word) leaves the tab where it is. */
42
+ export declare function safeUrl(value: unknown): string | null;
43
+ export type RenderedPage = {
44
+ status: number;
45
+ html: string;
46
+ csp: string;
47
+ };
48
+ /** The whole response for a view. The project's name is the person's own
49
+ text and is escaped like everything else that goes in. */
50
+ export declare function callbackPage(view: CallbackView): RenderedPage;
@@ -0,0 +1,141 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { LOGO_SVG } from "./logo.js";
3
+ /** Seconds before a signed-in tab opens the project by itself. */
4
+ export const REDIRECT_SECONDS = 5;
5
+ const text = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
6
+ /** Only a web address goes into the page: Mapled's answer names it, and
7
+ anything else (`javascript:`, a bare word) leaves the tab where it is. */
8
+ export function safeUrl(value) {
9
+ if (typeof value !== "string")
10
+ return null;
11
+ try {
12
+ const url = new URL(value);
13
+ return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : null;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ function copyOf(view) {
20
+ switch (view.kind) {
21
+ case "signed-in":
22
+ return view.url
23
+ ? {
24
+ status: 200,
25
+ title: "You're signed in",
26
+ body: `Your AI agent is connected to ${view.project}. Follow the setup in Mapled.`,
27
+ action: { label: "Open setup status", href: view.url },
28
+ }
29
+ : {
30
+ status: 200,
31
+ title: "You're signed in",
32
+ body: `Your AI agent is connected to ${view.project}. You can close this tab and go back to your agent.`,
33
+ };
34
+ case "failed":
35
+ return {
36
+ status: 200,
37
+ title: "Sign-in didn't finish",
38
+ body: "Mapled couldn't complete the sign-in. Go back to your AI agent and try again.",
39
+ };
40
+ case "cancelled":
41
+ return {
42
+ status: 200,
43
+ title: "Sign-in cancelled",
44
+ body: "Your AI agent isn't connected. You can close this tab and go back to your agent.",
45
+ };
46
+ case "expired":
47
+ return {
48
+ status: 400,
49
+ title: "This sign-in link has expired",
50
+ body: "It belongs to an earlier sign-in. Go back to your AI agent and start again.",
51
+ };
52
+ case "incomplete":
53
+ return {
54
+ status: 400,
55
+ title: "This sign-in link is incomplete",
56
+ body: "Go back to your AI agent and start the sign-in again.",
57
+ };
58
+ case "handed-over":
59
+ return {
60
+ status: 200,
61
+ title: "Back to your AI agent",
62
+ body: "Your AI agent finishes the sign-in. You can close this tab.",
63
+ };
64
+ }
65
+ }
66
+ /* The app's tokens, by name: --bg-page #010101, --surface-page #0a0a0a,
67
+ --text-primary #fefefe, --text-secondary #989898, --accent #eb5c1a,
68
+ --accent-hover #d54908, --shadow-bevel, --focus-glow, --radius-panel 16,
69
+ --control-lg 48; heading 32 Medium / 1 / -0.04em, subtitle 16 / 1.48 /
70
+ -0.02em, note 14 (FormHeading, accept.note). */
71
+ const STYLE = "*{box-sizing:border-box;margin:0}" +
72
+ ":root{color-scheme:dark}" +
73
+ "html,body{min-height:100%}" +
74
+ "body{min-height:100vh;min-height:100dvh;display:flex;padding:16px;background:#010101;color:#fefefe;" +
75
+ "font-family:Inter,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;-webkit-font-smoothing:antialiased}" +
76
+ "main{flex:1;min-width:0;display:flex;flex-direction:column;padding:20px;border-radius:16px;background:#0a0a0a}" +
77
+ "header{display:flex;align-items:center;min-height:32px}" +
78
+ "header svg{display:block;width:122px;height:32px}" +
79
+ ".box{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:40px;" +
80
+ "width:100%;max-width:528px;margin:0 auto;padding:0 8px 32px;text-align:center}" +
81
+ ".heading{display:flex;flex-direction:column;gap:8px}" +
82
+ "h1{font-size:32px;font-weight:500;line-height:1;letter-spacing:-0.04em;text-wrap:balance}" +
83
+ "p{font-size:16px;line-height:1.48;letter-spacing:-0.02em;color:#989898;text-wrap:pretty;overflow-wrap:break-word}" +
84
+ ".group{display:flex;flex-direction:column;align-items:center;gap:16px}" +
85
+ ".button{display:inline-flex;align-items:center;justify-content:center;min-height:48px;padding:0 24px;" +
86
+ "border-radius:1000px;background:#eb5c1a;color:#fefefe;font-size:14px;font-weight:500;line-height:1;" +
87
+ "text-decoration:none;white-space:nowrap;transition:background-color .2s ease;" +
88
+ "box-shadow:inset 0 0 0 1px rgb(255 255 255/8%),inset -2px -2px 2px 0 rgb(255 255 255/25%),inset 2px 2px 2px 0 rgb(255 255 255/25%)}" +
89
+ ".button:hover{background:#d54908}" +
90
+ ".button:focus-visible{outline:0;box-shadow:inset 0 0 0 1px rgb(255 255 255/8%),inset -2px -2px 2px 0 rgb(255 255 255/25%)," +
91
+ "inset 2px 2px 2px 0 rgb(255 255 255/25%),0 0 4px 0 #2d82cc}" +
92
+ ".note{font-size:14px;line-height:normal;letter-spacing:0}" +
93
+ "@media (max-width:600px){h1{font-size:28px}}" +
94
+ "@media (prefers-reduced-motion:reduce){.button{transition:none}}";
95
+ /** The countdown: one line updated each second, then the same address
96
+ the button has. `replace`: Back doesn't land on the loopback, which is
97
+ gone by then. */
98
+ const SCRIPT = "(function(){var s=document.currentScript,to=s.getAttribute('data-to'),n=Number(s.getAttribute('data-seconds')),c=document.getElementById('count');" +
99
+ "function tick(){if(n<=0){location.replace(to);return}if(c)c.textContent=n+(n===1?' second':' seconds');n-=1;setTimeout(tick,1000)}tick()})()";
100
+ const seconds = (n) => `${n} ${n === 1 ? "second" : "seconds"}`;
101
+ /** The whole response for a view. The project's name is the person's own
102
+ text and is escaped like everything else that goes in. */
103
+ export function callbackPage(view) {
104
+ const copy = copyOf(view);
105
+ const nonce = randomBytes(16).toString("base64");
106
+ const action = copy.action;
107
+ const html = "<!doctype html>" +
108
+ '<html lang="en"><head><meta charset="utf-8">' +
109
+ '<meta name="viewport" content="width=device-width, initial-scale=1">' +
110
+ '<meta name="robots" content="noindex, nofollow">' +
111
+ '<meta name="referrer" content="no-referrer">' +
112
+ `<title>${text(copy.title)} · Mapled</title>` +
113
+ // without scripts the tab still moves on, after the same pause
114
+ (action ? `<noscript><meta http-equiv="refresh" content="${REDIRECT_SECONDS};url=${text(action.href)}"></noscript>` : "") +
115
+ `<style>${STYLE}</style>` +
116
+ "</head><body><main>" +
117
+ `<header>${LOGO_SVG}</header>` +
118
+ '<div class="box">' +
119
+ `<div class="heading"><h1>${text(copy.title)}</h1><p>${text(copy.body)}</p></div>` +
120
+ (action
121
+ ? '<div class="group">' +
122
+ `<a class="button" href="${text(action.href)}">${text(action.label)}</a>` +
123
+ `<p class="note">Opening automatically in <span id="count">${seconds(REDIRECT_SECONDS)}</span></p>` +
124
+ "</div>"
125
+ : "") +
126
+ "</div></main>" +
127
+ (action
128
+ ? `<script nonce="${nonce}" data-to="${text(action.href)}" data-seconds="${REDIRECT_SECONDS}">${SCRIPT}</script>`
129
+ : "") +
130
+ "</body></html>";
131
+ const csp = [
132
+ "default-src 'none'",
133
+ `script-src 'nonce-${nonce}'`,
134
+ "style-src 'unsafe-inline'",
135
+ "img-src data:",
136
+ "base-uri 'none'",
137
+ "form-action 'none'",
138
+ "frame-ancestors 'none'",
139
+ ].join("; ");
140
+ return { status: copy.status, html, csp };
141
+ }
@@ -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
@@ -7,6 +7,7 @@ import { connectionsFor, findConnection, readCredentials, removeConnection, upse
7
7
  import * as dr from "./doctor.js";
8
8
  import { CliError, SignInNeeded } from "./errors.js";
9
9
  import { checkManifest, compareManifests, fileExistsIn, MANIFEST_FILE, manifestSummary, orderManifest, parseManifest, plural, readManifestFile, writeManifestFile, } from "./manifest.js";
10
+ import { safeUrl } from "./callbackPage.js";
10
11
  import { authorize, clientKnown, exchangeCode, registerClient, revokeToken } from "./oauth.js";
11
12
  import { formatChecks, GLYPH, paint, summarize, useColor } from "./output.js";
12
13
  import { diffSchema, formatChanges, pinSchema, readPin, SCHEMA_FILE, summarizeChanges, writePin } from "./pin.js";
@@ -16,6 +17,7 @@ import { capabilitiesOf, findLive, liveState } from "./live.js";
16
17
  import { loadTypeScript, mergeManifest, scanRepository } from "./scan.js";
17
18
  import { scanHtml } from "./scanHtml.js";
18
19
  import { generateTypes } from "./types.js";
20
+ import { pendingOf, uploadAll, uploadLine, uploadSource } from "./media.js";
19
21
  /** The OAuth client id for this API — registered once per machine, and
20
22
  again when the server no longer knows it. */
21
23
  async function ensureClient(store, api, f) {
@@ -48,18 +50,31 @@ export async function login(ctx, flags) {
48
50
  timeoutMs: ctx.loginTimeoutMs,
49
51
  project: projectFlag(flags),
50
52
  });
51
- const tokens = await exchangeCode(api, client.clientId, grant, ctx.fetch);
52
- const status = await apiGet(api, "/v1/agent/integration", tokens.accessToken, ctx.fetch);
53
- const conn = {
54
- api,
55
- projectId: status.project.id,
56
- projectName: status.project.name,
57
- clientId: client.clientId,
58
- ...tokens,
59
- createdAt: new Date().toISOString(),
60
- };
61
- store = upsertConnection(store, conn);
62
- await writeCredentials(ctx.credentialsFile, store);
53
+ // the tab says "signed in" only once the tokens are kept: until then
54
+ // anything that fails (exchange, API, the credentials file) is a
55
+ // sign-in that didn't finish
56
+ let conn;
57
+ let status;
58
+ try {
59
+ const tokens = await exchangeCode(api, client.clientId, grant, ctx.fetch);
60
+ status = await apiGet(api, "/v1/agent/integration", tokens.accessToken, ctx.fetch);
61
+ conn = {
62
+ api,
63
+ projectId: status.project.id,
64
+ projectName: status.project.name,
65
+ clientId: client.clientId,
66
+ ...tokens,
67
+ createdAt: new Date().toISOString(),
68
+ };
69
+ store = upsertConnection(store, conn);
70
+ await writeCredentials(ctx.credentialsFile, store);
71
+ }
72
+ catch (err) {
73
+ grant.tab.show({ kind: "failed" });
74
+ throw err;
75
+ }
76
+ // the tab goes on to the project in Mapled, where the setup shows
77
+ grant.tab.show({ kind: "signed-in", project: status.project.name, url: safeUrl(status.project.dashboardUrl) });
63
78
  ctx.out(`Signed in to ${clean(conn.projectName, 80)}.`);
64
79
  if (!found)
65
80
  ctx.out("Next: run `mapled project link` in the site's repository.");
@@ -250,7 +265,23 @@ export async function tool(ctx, flags, words) {
250
265
  if (args === null || typeof args !== "object" || Array.isArray(args))
251
266
  throw new CliError("The input must be a JSON object.");
252
267
  const wait = waitSeconds(flags, name);
253
- const { session } = await openSession(ctx, flags);
268
+ const { found, session } = await openSession(ctx, flags);
269
+ const input = args;
270
+ // a file on this machine: the hosted tool can't read it, the CLI uploads it
271
+ if (name === "upload_media" && typeof input.path === "string" && input.url === undefined) {
272
+ const runId = typeof input.runId === "string" ? input.runId : undefined;
273
+ const done = await uploadSource(session, ctx.fetch, found.dir, typeof input.source === "string" ? input.source : input.path, runId);
274
+ if (!done.ok) {
275
+ ctx.out(`${done.source}: ${done.error}`);
276
+ return 1;
277
+ }
278
+ ctx.out(JSON.stringify({ asset: done.asset, deduplicated: done.deduplicated }, null, 2));
279
+ return 0;
280
+ }
281
+ // the plan's media goes up before the apply that puts it into the records
282
+ if (name === "apply_setup_plan" && typeof input.runId === "string" && input.skipMissingMedia !== true) {
283
+ await uploadRunMedia(ctx, session, found.dir, input.runId);
284
+ }
254
285
  const deadline = Date.now() + wait * 1000;
255
286
  let answer = await session.rpc("tools/call", { name, arguments: args });
256
287
  // the person approves on a Mapled screen, not in the agent's chat: the
@@ -265,6 +296,62 @@ export async function tool(ctx, flags, words) {
265
296
  ctx.out(part.text);
266
297
  return answer.isError ? 1 : 0;
267
298
  }
299
+ /** The media a setup run's plan names and Mapled doesn't have yet, from
300
+ this repository or the public addresses it names; a line per file.
301
+ Nothing to do unless the plan is approved. */
302
+ async function uploadRunMedia(ctx, session, root, runId) {
303
+ const { status, pending } = await pendingOf(session, runId);
304
+ if (status !== "approved" || pending.length === 0)
305
+ return { failed: 0, done: 0 };
306
+ ctx.out(`Uploading ${plural(pending.length, "media file")} of the plan to Mapled…`);
307
+ const results = await uploadAll(session, ctx.fetch, root, pending, runId);
308
+ for (const r of results)
309
+ ctx.out(uploadLine(r));
310
+ const failed = results.filter((r) => !r.ok).length;
311
+ return { failed, done: results.length - failed };
312
+ }
313
+ /** `mapled media upload [<path | url>…] [--run <runId>] [--json]` — files
314
+ of this repository or public addresses into the project's Media.
315
+ With --run, every media file of that approved setup plan (or the ones
316
+ named) is uploaded as the plan's, for apply_setup_plan to put into
317
+ the records. */
318
+ export async function mediaUpload(ctx, flags, sources) {
319
+ const runId = stringFlag(flags, "run");
320
+ 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)) {
321
+ throw new CliError("--run takes the id of a setup run — the runId propose_setup_plan answered.", 2);
322
+ }
323
+ if (runId === undefined && sources.length === 0) {
324
+ 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);
325
+ }
326
+ const { found, session } = await openSession(ctx, flags);
327
+ let list = sources;
328
+ if (runId !== undefined && list.length === 0) {
329
+ const run = await pendingOf(session, runId);
330
+ if (run.status !== "approved") {
331
+ throw new CliError(run.status === "awaiting_approval"
332
+ ? "The plan is still waiting for approval. Upload its media once the person approves it."
333
+ : "This run takes no more media: its plan isn't approved or was applied already.");
334
+ }
335
+ if (run.pending.length === 0) {
336
+ 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.`);
337
+ return 0;
338
+ }
339
+ list = run.pending;
340
+ }
341
+ const results = await uploadAll(session, ctx.fetch, found.dir, list, runId);
342
+ if (flags.json) {
343
+ ctx.out(JSON.stringify(results, null, 2));
344
+ }
345
+ else {
346
+ for (const r of results)
347
+ ctx.out(uploadLine(r));
348
+ const failed = results.filter((r) => !r.ok).length;
349
+ ctx.out(failed === 0
350
+ ? `Uploaded ${plural(results.length, "file")} to Mapled's Media.${runId ? " Apply the plan next." : ""}`
351
+ : `${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" : ""}.`);
352
+ }
353
+ return results.every((r) => r.ok) ? 0 : 1;
354
+ }
268
355
  /** `--wait` waits this long by default: under the two minutes an agent's
269
356
  shell gives a command unless it asks for more. */
270
357
  const WAIT_SECONDS = 90;
@@ -294,7 +381,7 @@ function awaitingApproval(answer) {
294
381
  return false;
295
382
  }
296
383
  }
297
- async function openSession(ctx, flags) {
384
+ export async function openSession(ctx, flags) {
298
385
  const found = await findConfig(ctx.cwd);
299
386
  if (!found)
300
387
  throw new CliError(`No ${CONFIG_FILE} here or above. Run \`mapled project link\` first.`);
@@ -393,8 +480,9 @@ async function loadManifest(dir) {
393
480
  keeps one, otherwise the live schema of a signed-in project. */
394
481
  async function schemaForChecks(ctx, flags, found) {
395
482
  const pin = await readPin(found.dir);
483
+ // the pin's component types too: the blocks' templates and bindings name their sub-fields
396
484
  if (pin)
397
- return { schema: { collections: pin.pin.collections }, note: null };
485
+ return { schema: { collections: pin.pin.collections, ...(pin.pin.componentTypes ? { componentTypes: pin.pin.componentTypes } : {}) }, note: null };
398
486
  try {
399
487
  const { session } = await openSession(ctx, flags);
400
488
  return { schema: await session.get("/v1/agent/schema"), note: null };
@@ -870,7 +958,7 @@ export async function doctor(ctx, flags) {
870
958
  checks.push(dr.checkTypes(existing, schema ? generateTypes(schema, { projectName: status?.project.name, sdk: dr.sdkOf(framework) }) : null, typesPath, framework));
871
959
  const pin = found ? await readPin(dir).catch(() => null) : null;
872
960
  const livePin = schema && found ? pinSchema(schema, found.config.project) : null;
873
- const pinChanges = pin && livePin ? diffSchema(pin.pin.collections, livePin.collections) : null;
961
+ const pinChanges = pin && livePin ? diffSchema(pin.pin.collections, livePin.collections, { before: pin.pin.componentTypes, after: livePin.componentTypes }) : null;
874
962
  checks.push(dr.checkSchemaPin(pin?.pin ?? null, livePin?.hash ?? null, pinChanges));
875
963
  const localManifest = await loadManifest(dir).catch(() => null);
876
964
  let remoteManifest = "unknown";
package/dist/doctor.d.ts CHANGED
@@ -18,11 +18,13 @@ export type Check = {
18
18
  };
19
19
  /** GET /v1/agent/integration */
20
20
  export type IntegrationStatus = {
21
+ /** `dashboardUrl` — the project in Mapled; missing from an older API */
21
22
  project: {
22
23
  id: string;
23
24
  name: string;
24
25
  slug: string;
25
26
  productionDomain: string | null;
27
+ dashboardUrl?: string;
26
28
  };
27
29
  connection: {
28
30
  id: string;
@@ -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>;