@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 +25 -2
- package/dist/args.js +1 -1
- package/dist/callbackPage.d.ts +50 -0
- package/dist/callbackPage.js +141 -0
- package/dist/commands.d.ts +14 -0
- package/dist/commands.js +104 -16
- package/dist/doctor.d.ts +2 -0
- package/dist/envPush.d.ts +44 -0
- package/dist/envPush.js +285 -0
- package/dist/index.js +10 -1
- package/dist/logo.d.ts +4 -0
- package/dist/logo.js +4 -0
- package/dist/manifest.d.ts +34 -7
- package/dist/manifest.js +86 -18
- package/dist/media.d.ts +37 -0
- package/dist/media.js +133 -0
- package/dist/oauth.d.ts +14 -2
- package/dist/oauth.js +34 -11
- package/dist/pin.js +63 -43
- package/dist/scanHtml.d.ts +11 -4
- package/dist/scanHtml.js +343 -35
- package/dist/schema.d.ts +17 -1
- package/dist/schema.js +20 -0
- package/dist/types.js +25 -5
- package/dist/vanillaDigests.js +1 -0
- package/package.json +1 -1
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
|
-
- `--
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
+
}
|
package/dist/commands.d.ts
CHANGED
|
@@ -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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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>;
|