@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 +24 -1
- package/dist/args.js +1 -1
- package/dist/commands.d.ts +14 -0
- package/dist/commands.js +75 -2
- package/dist/envPush.d.ts +44 -0
- package/dist/envPush.js +285 -0
- package/dist/index.js +10 -1
- package/dist/media.d.ts +37 -0
- package/dist/media.js +131 -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
|
|
@@ -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
|
-
- `--
|
|
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 = {};
|
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
|
@@ -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>;
|
package/dist/envPush.js
ADDED
|
@@ -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:
|
package/dist/media.d.ts
ADDED
|
@@ -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.
|
|
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",
|