@mapled/cli 0.1.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 +95 -0
- package/dist/api.d.ts +21 -0
- package/dist/api.js +72 -0
- package/dist/args.d.ts +6 -0
- package/dist/args.js +52 -0
- package/dist/browser.d.ts +3 -0
- package/dist/browser.js +23 -0
- package/dist/commands.d.ts +27 -0
- package/dist/commands.js +211 -0
- package/dist/config.d.ts +24 -0
- package/dist/config.js +77 -0
- package/dist/credentials.d.ts +34 -0
- package/dist/credentials.js +65 -0
- package/dist/doctor.d.ts +106 -0
- package/dist/doctor.js +422 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +10 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +99 -0
- package/dist/oauth.d.ts +45 -0
- package/dist/oauth.js +192 -0
- package/dist/output.d.ts +8 -0
- package/dist/output.js +30 -0
- package/dist/schema.d.ts +44 -0
- package/dist/schema.js +35 -0
- package/dist/types.d.ts +31 -0
- package/dist/types.js +218 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# @mapled/cli
|
|
2
|
+
|
|
3
|
+
The `mapled` command line for [Mapled](https://mapled.io) — a hosted headless CMS built for sites created with AI. Sign in to a project, link the repository to it, generate TypeScript types for the content your site reads, and check the whole integration in one go.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx @mapled/cli auth login
|
|
7
|
+
npx @mapled/cli project link
|
|
8
|
+
npx @mapled/cli types generate
|
|
9
|
+
npx @mapled/cli doctor
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Node 18 or newer. No dependencies. Prefer a global install? `npm install -g @mapled/cli` puts `mapled` on your PATH, and every command below works as `mapled …`.
|
|
13
|
+
|
|
14
|
+
## Sign in
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx @mapled/cli auth login
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Your browser opens Mapled; you pick the project and authorize. The CLI is an ordinary OAuth client of Mapled, so the connection shows up in the project's **Integrations** and can be revoked there at any time. Tokens are stored in your user config directory (`~/.config/mapled/credentials.json`, readable only by you) — never in the repository.
|
|
21
|
+
|
|
22
|
+
`npx @mapled/cli auth logout` revokes the connection of the linked project (`--all` for every project on this machine).
|
|
23
|
+
|
|
24
|
+
## Link the repository
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx @mapled/cli project link
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Writes `mapled.json` next to your `package.json`:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"project": "bae66c47-…",
|
|
35
|
+
"types": "mapled-types.ts",
|
|
36
|
+
"framework": "nextjs"
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Only public values live here — the project id, where the generated types go, the framework. Commit it. With more than one signed-in project, pass `--project <id>` or pick from the list.
|
|
41
|
+
|
|
42
|
+
## Generate types
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx @mapled/cli types generate
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
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, and doc comments with the field types. Sensitive fields never reach the site and are left out.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import type { Article, MapledCollections } from "./mapled-types";
|
|
52
|
+
import { createClient } from "@mapled/next";
|
|
53
|
+
|
|
54
|
+
const mapled = createClient({ key: process.env.MAPLED_KEY! });
|
|
55
|
+
const { records } = await mapled.getRecords<Article>("articles");
|
|
56
|
+
const home = await mapled.getSingle<MapledCollections["homepage"]>("homepage");
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The file names the schema it came from; `mapled doctor` tells you when it is out of date. Run `types generate` again after the schema changes.
|
|
60
|
+
|
|
61
|
+
## Check the integration
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npx @mapled/cli doctor
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
Mapled doctor — Dolphin Landing
|
|
69
|
+
|
|
70
|
+
✓ Project link mapled.json → Dolphin Landing
|
|
71
|
+
✓ Signed in Mapled CLI • Dolphin Landing
|
|
72
|
+
✓ Generated types mapled-types.ts matches the schema (3f9a1c2b4d5e)
|
|
73
|
+
✓ Environment MAPLED_KEY and MAPLED_WEBHOOK_SECRET in .env.local
|
|
74
|
+
✓ Secrets in git No env files or Mapled secrets are tracked.
|
|
75
|
+
✓ @mapled/next 0.3.0 (current)
|
|
76
|
+
✓ mapled CLI 0.1.0 (current)
|
|
77
|
+
✓ Revalidation route app/api/mapled/revalidate/route.ts
|
|
78
|
+
⚠ Preview route app/api/mapled/preview/route.ts is missing — mount createPreviewHandler from "@mapled/next/server" so Preview from Mapled works.
|
|
79
|
+
✓ Site reads content Last read 3m ago
|
|
80
|
+
✓ Publish webhook Delivered 2h ago to dolphin-landing.example
|
|
81
|
+
✓ Preview on the site Responds at https://dolphin-landing.example/api/mapled/preview
|
|
82
|
+
✓ Bindings 12 healthy • synced 2d ago from Claude Code
|
|
83
|
+
|
|
84
|
+
1 warning.
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The repository side: the link, the sign-in, the generated types, `MAPLED_KEY` and `MAPLED_WEBHOOK_SECRET` in your env files (names only — values are never read out), env files or Mapled secrets tracked by git, the installed `@mapled/next` and CLI versions, the revalidation and preview routes of a Next.js site. The Mapled side, from the same status the AI agent's `check_integration` tool reads: whether the site has read content with the delivery key, the publish webhook and its last delivery, the preview route on the deployed site, the bindings health.
|
|
88
|
+
|
|
89
|
+
Exit code 1 when something is marked ✗; `--json` prints the checks for scripts and agents.
|
|
90
|
+
|
|
91
|
+
## Options
|
|
92
|
+
|
|
93
|
+
- `--api <origin>` — a Mapled API other than `https://api.mapled.io` (or set `MAPLED_API_URL`); `project link` remembers it in `mapled.json`
|
|
94
|
+
- `--no-browser` — print the sign-in link instead of opening a browser
|
|
95
|
+
- `MAPLED_CONFIG_DIR` — where credentials live (default: `$XDG_CONFIG_HOME/mapled` or `~/.config/mapled`)
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Connection, type CredentialsFile } from "./credentials.js";
|
|
2
|
+
import { type Fetch } from "./oauth.js";
|
|
3
|
+
/** Calls to the agent API (/v1/agent/*) with a connection's bearer token. */
|
|
4
|
+
export declare class ApiError extends Error {
|
|
5
|
+
status: number;
|
|
6
|
+
code: string | null;
|
|
7
|
+
constructor(status: number, code: string | null, message: string);
|
|
8
|
+
}
|
|
9
|
+
export declare function apiGet<T>(api: string, path: string, token: string, f: Fetch): Promise<T>;
|
|
10
|
+
/** A signed-in connection with its store: reads refresh the access
|
|
11
|
+
token when it is about to expire or when the API says it did, and
|
|
12
|
+
the rotated pair is written back before the call returns. */
|
|
13
|
+
export declare class Session {
|
|
14
|
+
store: CredentialsFile;
|
|
15
|
+
file: string;
|
|
16
|
+
conn: Connection;
|
|
17
|
+
private readonly f;
|
|
18
|
+
constructor(store: CredentialsFile, file: string, conn: Connection, f: Fetch);
|
|
19
|
+
get<T>(path: string): Promise<T>;
|
|
20
|
+
private refresh;
|
|
21
|
+
}
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { upsertConnection, writeCredentials } from "./credentials.js";
|
|
2
|
+
import { CliError } from "./errors.js";
|
|
3
|
+
import { refreshTokens } from "./oauth.js";
|
|
4
|
+
/** Calls to the agent API (/v1/agent/*) with a connection's bearer token. */
|
|
5
|
+
export class ApiError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
code;
|
|
8
|
+
constructor(status, code, message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "ApiError";
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export async function apiGet(api, path, token, f) {
|
|
16
|
+
let res;
|
|
17
|
+
try {
|
|
18
|
+
res = await f(`${api}${path}`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } });
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new CliError(`Couldn't reach ${api}. Check your connection and try again.`);
|
|
22
|
+
}
|
|
23
|
+
const data = (await res.json().catch(() => null));
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
throw new ApiError(res.status, data?.error?.code ?? null, data?.error?.message ?? `Mapled answered ${res.status}.`);
|
|
26
|
+
}
|
|
27
|
+
return data;
|
|
28
|
+
}
|
|
29
|
+
/** A signed-in connection with its store: reads refresh the access
|
|
30
|
+
token when it is about to expire or when the API says it did, and
|
|
31
|
+
the rotated pair is written back before the call returns. */
|
|
32
|
+
export class Session {
|
|
33
|
+
store;
|
|
34
|
+
file;
|
|
35
|
+
conn;
|
|
36
|
+
f;
|
|
37
|
+
constructor(store, file, conn, f) {
|
|
38
|
+
this.store = store;
|
|
39
|
+
this.file = file;
|
|
40
|
+
this.conn = conn;
|
|
41
|
+
this.f = f;
|
|
42
|
+
}
|
|
43
|
+
async get(path) {
|
|
44
|
+
if (new Date(this.conn.expiresAt).getTime() - Date.now() < 60_000)
|
|
45
|
+
await this.refresh();
|
|
46
|
+
try {
|
|
47
|
+
return await apiGet(this.conn.api, path, this.conn.accessToken, this.f);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
if (err instanceof ApiError && err.status === 401) {
|
|
51
|
+
if (err.code === "TOKEN_EXPIRED") {
|
|
52
|
+
await this.refresh();
|
|
53
|
+
return apiGet(this.conn.api, path, this.conn.accessToken, this.f);
|
|
54
|
+
}
|
|
55
|
+
throw new CliError(`The connection to ${this.conn.projectName} was revoked. Run \`mapled auth login\` to sign in again.`);
|
|
56
|
+
}
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async refresh() {
|
|
61
|
+
let tokens;
|
|
62
|
+
try {
|
|
63
|
+
tokens = await refreshTokens(this.conn.api, this.conn.clientId, this.conn.refreshToken, this.f);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new CliError(`Your sign-in to ${this.conn.projectName} expired. Run \`mapled auth login\` to sign in again.`);
|
|
67
|
+
}
|
|
68
|
+
this.conn = { ...this.conn, ...tokens };
|
|
69
|
+
this.store = upsertConnection(this.store, this.conn);
|
|
70
|
+
await writeCredentials(this.file, this.store);
|
|
71
|
+
}
|
|
72
|
+
}
|
package/dist/args.d.ts
ADDED
package/dist/args.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { CliError } from "./errors.js";
|
|
2
|
+
/** Minimal argument parsing: the leading words name the command;
|
|
3
|
+
`--flag`, `--flag=value` and `--flag value` (for the flags that take
|
|
4
|
+
one) follow. Nothing here is user content beyond the command line. */
|
|
5
|
+
const VALUE_FLAGS = new Set(["api", "project", "out"]);
|
|
6
|
+
export function parseArgs(argv) {
|
|
7
|
+
const words = [];
|
|
8
|
+
const flags = {};
|
|
9
|
+
for (let i = 0; i < argv.length; i++) {
|
|
10
|
+
const arg = argv[i];
|
|
11
|
+
if (arg === "--") {
|
|
12
|
+
words.push(...argv.slice(i + 1));
|
|
13
|
+
break;
|
|
14
|
+
}
|
|
15
|
+
if (arg.startsWith("--")) {
|
|
16
|
+
const eq = arg.indexOf("=");
|
|
17
|
+
const name = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
|
|
18
|
+
if (!name)
|
|
19
|
+
throw new CliError("Unknown option: --");
|
|
20
|
+
if (eq !== -1) {
|
|
21
|
+
flags[name] = arg.slice(eq + 1);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (VALUE_FLAGS.has(name)) {
|
|
25
|
+
const next = argv[i + 1];
|
|
26
|
+
if (next === undefined || next.startsWith("-"))
|
|
27
|
+
throw new CliError(`--${name} needs a value.`);
|
|
28
|
+
flags[name] = next;
|
|
29
|
+
i++;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
flags[name] = true;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (arg === "-h") {
|
|
36
|
+
flags.help = true;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (arg === "-v") {
|
|
40
|
+
flags.version = true;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (arg.startsWith("-") && arg.length > 1)
|
|
44
|
+
throw new CliError(`Unknown option: ${arg}`);
|
|
45
|
+
words.push(arg);
|
|
46
|
+
}
|
|
47
|
+
return { words, flags };
|
|
48
|
+
}
|
|
49
|
+
export function stringFlag(flags, name) {
|
|
50
|
+
const value = flags[name];
|
|
51
|
+
return typeof value === "string" ? value : undefined;
|
|
52
|
+
}
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
/** Opens a URL in the default browser; resolves false when that isn't
|
|
3
|
+
possible (no desktop, no opener) so the caller prints the link. */
|
|
4
|
+
export function openBrowser(url) {
|
|
5
|
+
return new Promise((resolve) => {
|
|
6
|
+
const [cmd, args] = process.platform === "darwin"
|
|
7
|
+
? ["open", [url]]
|
|
8
|
+
: process.platform === "win32"
|
|
9
|
+
? ["cmd", ["/c", "start", "", url.replace(/&/g, "^&")]]
|
|
10
|
+
: ["xdg-open", [url]];
|
|
11
|
+
try {
|
|
12
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
13
|
+
child.once("error", () => resolve(false));
|
|
14
|
+
child.once("spawn", () => {
|
|
15
|
+
child.unref();
|
|
16
|
+
resolve(true);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
resolve(false);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type ParsedArgs } from "./args.js";
|
|
2
|
+
import { type FoundConfig } from "./config.js";
|
|
3
|
+
import { type Connection } from "./credentials.js";
|
|
4
|
+
import { type Fetch } from "./oauth.js";
|
|
5
|
+
/** The four wave-1 commands (§31) plus sign-out. Everything that talks
|
|
6
|
+
to the world comes in through `Ctx`, so the commands run in tests
|
|
7
|
+
against a fake API and a temp directory. */
|
|
8
|
+
export type Ctx = {
|
|
9
|
+
cwd: string;
|
|
10
|
+
env: NodeJS.ProcessEnv;
|
|
11
|
+
fetch: Fetch;
|
|
12
|
+
openBrowser: (url: string) => Promise<boolean>;
|
|
13
|
+
out: (line: string) => void;
|
|
14
|
+
/** Picks one of `choices`; rejects when there is no one to ask. */
|
|
15
|
+
choose: (question: string, choices: string[]) => Promise<number>;
|
|
16
|
+
version: string;
|
|
17
|
+
credentialsFile: string;
|
|
18
|
+
/** How long a sign-in waits for the browser (tests shorten it). */
|
|
19
|
+
loginTimeoutMs?: number;
|
|
20
|
+
};
|
|
21
|
+
type Flags = ParsedArgs["flags"];
|
|
22
|
+
export declare function login(ctx: Ctx, flags: Flags): Promise<Connection>;
|
|
23
|
+
export declare function logout(ctx: Ctx, flags: Flags): Promise<void>;
|
|
24
|
+
export declare function link(ctx: Ctx, flags: Flags): Promise<FoundConfig>;
|
|
25
|
+
export declare function generate(ctx: Ctx, flags: Flags): Promise<void>;
|
|
26
|
+
export declare function doctor(ctx: Ctx, flags: Flags): Promise<number>;
|
|
27
|
+
export {};
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { apiGet, Session } from "./api.js";
|
|
4
|
+
import { stringFlag } from "./args.js";
|
|
5
|
+
import { CONFIG_FILE, DEFAULT_TYPES_PATH, findConfig, resolveApi, writeConfig } from "./config.js";
|
|
6
|
+
import { connectionsFor, findConnection, readCredentials, removeConnection, upsertConnection, writeCredentials, } from "./credentials.js";
|
|
7
|
+
import * as dr from "./doctor.js";
|
|
8
|
+
import { CliError } from "./errors.js";
|
|
9
|
+
import { authorize, clientKnown, exchangeCode, registerClient, revokeToken } from "./oauth.js";
|
|
10
|
+
import { formatChecks, summarize, useColor } from "./output.js";
|
|
11
|
+
import { generateTypes } from "./types.js";
|
|
12
|
+
function plural(n, word) {
|
|
13
|
+
return `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
14
|
+
}
|
|
15
|
+
/** The OAuth client id for this API — registered once per machine, and
|
|
16
|
+
again when the server no longer knows it. */
|
|
17
|
+
async function ensureClient(store, api, f) {
|
|
18
|
+
const known = store.clients[api];
|
|
19
|
+
if (known && (await clientKnown(api, known.clientId, f)))
|
|
20
|
+
return { store, clientId: known.clientId };
|
|
21
|
+
const clientId = await registerClient(api, f);
|
|
22
|
+
return {
|
|
23
|
+
store: { ...store, clients: { ...store.clients, [api]: { clientId, registeredAt: new Date().toISOString() } } },
|
|
24
|
+
clientId,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export async function login(ctx, flags) {
|
|
28
|
+
const found = await findConfig(ctx.cwd);
|
|
29
|
+
const api = resolveApi(found?.config ?? null, stringFlag(flags, "api"), ctx.env);
|
|
30
|
+
let store = await readCredentials(ctx.credentialsFile);
|
|
31
|
+
const client = await ensureClient(store, api, ctx.fetch);
|
|
32
|
+
store = client.store;
|
|
33
|
+
const grant = await authorize(api, client.clientId, {
|
|
34
|
+
openBrowser: flags["no-browser"] ? async () => false : ctx.openBrowser,
|
|
35
|
+
log: ctx.out,
|
|
36
|
+
timeoutMs: ctx.loginTimeoutMs,
|
|
37
|
+
});
|
|
38
|
+
const tokens = await exchangeCode(api, client.clientId, grant, ctx.fetch);
|
|
39
|
+
const status = await apiGet(api, "/v1/agent/integration", tokens.accessToken, ctx.fetch);
|
|
40
|
+
const conn = {
|
|
41
|
+
api,
|
|
42
|
+
projectId: status.project.id,
|
|
43
|
+
projectName: status.project.name,
|
|
44
|
+
clientId: client.clientId,
|
|
45
|
+
...tokens,
|
|
46
|
+
createdAt: new Date().toISOString(),
|
|
47
|
+
};
|
|
48
|
+
store = upsertConnection(store, conn);
|
|
49
|
+
await writeCredentials(ctx.credentialsFile, store);
|
|
50
|
+
ctx.out(`Signed in to ${conn.projectName}.`);
|
|
51
|
+
if (!found)
|
|
52
|
+
ctx.out("Next: run `mapled project link` in the site's repository.");
|
|
53
|
+
else if (found.config.project !== conn.projectId) {
|
|
54
|
+
ctx.out(`Note: ${CONFIG_FILE} here links a different project (${found.config.project}).`);
|
|
55
|
+
}
|
|
56
|
+
return conn;
|
|
57
|
+
}
|
|
58
|
+
export async function logout(ctx, flags) {
|
|
59
|
+
const found = await findConfig(ctx.cwd);
|
|
60
|
+
const api = resolveApi(found?.config ?? null, stringFlag(flags, "api"), ctx.env);
|
|
61
|
+
let store = await readCredentials(ctx.credentialsFile);
|
|
62
|
+
let targets;
|
|
63
|
+
if (flags.all) {
|
|
64
|
+
targets = [...store.connections];
|
|
65
|
+
if (targets.length === 0)
|
|
66
|
+
throw new CliError("Not signed in anywhere. Nothing to revoke.");
|
|
67
|
+
}
|
|
68
|
+
else if (found) {
|
|
69
|
+
const conn = findConnection(store, api, found.config.project);
|
|
70
|
+
if (!conn)
|
|
71
|
+
throw new CliError(`Not signed in to the project ${CONFIG_FILE} links. Nothing to revoke.`);
|
|
72
|
+
targets = [conn];
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
const list = connectionsFor(store, api);
|
|
76
|
+
if (list.length === 0)
|
|
77
|
+
throw new CliError("Not signed in. Nothing to revoke.");
|
|
78
|
+
if (list.length === 1)
|
|
79
|
+
targets = list;
|
|
80
|
+
else {
|
|
81
|
+
const i = await ctx.choose("Sign out of which project?", list.map((c) => c.projectName));
|
|
82
|
+
targets = [list[i]];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (const conn of targets) {
|
|
86
|
+
await revokeToken(conn.api, conn.refreshToken, ctx.fetch);
|
|
87
|
+
store = removeConnection(store, conn.api, conn.projectId);
|
|
88
|
+
ctx.out(`Signed out of ${conn.projectName}.`);
|
|
89
|
+
}
|
|
90
|
+
await writeCredentials(ctx.credentialsFile, store);
|
|
91
|
+
}
|
|
92
|
+
export async function link(ctx, flags) {
|
|
93
|
+
const existing = await findConfig(ctx.cwd);
|
|
94
|
+
const here = existing && existing.dir === path.resolve(ctx.cwd) ? existing : null;
|
|
95
|
+
const api = resolveApi(here?.config ?? null, stringFlag(flags, "api"), ctx.env);
|
|
96
|
+
const store = await readCredentials(ctx.credentialsFile);
|
|
97
|
+
const wanted = stringFlag(flags, "project")?.toLowerCase();
|
|
98
|
+
const list = connectionsFor(store, api);
|
|
99
|
+
let conn;
|
|
100
|
+
if (wanted) {
|
|
101
|
+
conn = list.find((c) => c.projectId === wanted);
|
|
102
|
+
if (!conn)
|
|
103
|
+
throw new CliError(`Not signed in to project ${wanted}. Run \`mapled auth login\` and pick it in the browser.`);
|
|
104
|
+
}
|
|
105
|
+
else if (list.length === 1) {
|
|
106
|
+
conn = list[0];
|
|
107
|
+
}
|
|
108
|
+
else if (list.length > 1) {
|
|
109
|
+
const i = await ctx.choose("Link this repository to which project?", list.map((c) => c.projectName));
|
|
110
|
+
conn = list[i];
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
ctx.out("Not signed in yet — sign in first.");
|
|
114
|
+
conn = await login(ctx, flags);
|
|
115
|
+
}
|
|
116
|
+
if (!conn)
|
|
117
|
+
throw new CliError("No project chosen.");
|
|
118
|
+
const framework = here?.config.framework ?? (await dr.detectFramework(ctx.cwd));
|
|
119
|
+
const config = {
|
|
120
|
+
project: conn.projectId,
|
|
121
|
+
api,
|
|
122
|
+
types: here?.config.types ?? DEFAULT_TYPES_PATH,
|
|
123
|
+
...(framework ? { framework } : {}),
|
|
124
|
+
};
|
|
125
|
+
const file = await writeConfig(ctx.cwd, config);
|
|
126
|
+
ctx.out(`Linked ${path.relative(ctx.cwd, file) || CONFIG_FILE} to ${conn.projectName}.`);
|
|
127
|
+
ctx.out("Next: `mapled types generate`, then `mapled doctor`.");
|
|
128
|
+
return { path: file, dir: path.resolve(ctx.cwd), config };
|
|
129
|
+
}
|
|
130
|
+
async function openSession(ctx, flags) {
|
|
131
|
+
const found = await findConfig(ctx.cwd);
|
|
132
|
+
if (!found)
|
|
133
|
+
throw new CliError(`No ${CONFIG_FILE} here or above. Run \`mapled project link\` first.`);
|
|
134
|
+
const api = resolveApi(found.config, stringFlag(flags, "api"), ctx.env);
|
|
135
|
+
const store = await readCredentials(ctx.credentialsFile);
|
|
136
|
+
const conn = findConnection(store, api, found.config.project);
|
|
137
|
+
if (!conn)
|
|
138
|
+
throw new CliError("Not signed in to this project. Run `mapled auth login` and pick it in the browser.");
|
|
139
|
+
return { found, session: new Session(store, ctx.credentialsFile, conn, ctx.fetch) };
|
|
140
|
+
}
|
|
141
|
+
export async function generate(ctx, flags) {
|
|
142
|
+
const { found, session } = await openSession(ctx, flags);
|
|
143
|
+
const schema = await session.get("/v1/agent/schema");
|
|
144
|
+
const generated = generateTypes(schema, { projectName: session.conn.projectName });
|
|
145
|
+
const out = stringFlag(flags, "out") ?? found.config.types ?? DEFAULT_TYPES_PATH;
|
|
146
|
+
const file = path.resolve(found.dir, out);
|
|
147
|
+
await writeFile(file, generated.text);
|
|
148
|
+
ctx.out(`Wrote ${path.relative(ctx.cwd, file) || out} — ${plural(generated.collections, "collection")}, ` +
|
|
149
|
+
`${plural(generated.singles, "single")} (schema ${generated.hash}).`);
|
|
150
|
+
}
|
|
151
|
+
export async function doctor(ctx, flags) {
|
|
152
|
+
const found = await findConfig(ctx.cwd);
|
|
153
|
+
const dir = found?.dir ?? path.resolve(ctx.cwd);
|
|
154
|
+
const api = resolveApi(found?.config ?? null, stringFlag(flags, "api"), ctx.env);
|
|
155
|
+
const store = await readCredentials(ctx.credentialsFile);
|
|
156
|
+
const conn = found ? findConnection(store, api, found.config.project) : undefined;
|
|
157
|
+
let status = null;
|
|
158
|
+
let schema = null;
|
|
159
|
+
let authError = null;
|
|
160
|
+
if (conn) {
|
|
161
|
+
const session = new Session(store, ctx.credentialsFile, conn, ctx.fetch);
|
|
162
|
+
try {
|
|
163
|
+
status = await session.get("/v1/agent/integration");
|
|
164
|
+
schema = await session.get("/v1/agent/schema");
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
authError = err instanceof Error ? err.message : String(err);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const checks = [];
|
|
171
|
+
checks.push(dr.checkLink(found ? { path: path.relative(ctx.cwd, found.path) || CONFIG_FILE } : null, status?.project.name ?? conn?.projectName ?? null));
|
|
172
|
+
checks.push(dr.checkAuth({ conn: conn ?? null, status, error: authError }));
|
|
173
|
+
const typesPath = found?.config.types ?? DEFAULT_TYPES_PATH;
|
|
174
|
+
const existing = await readFile(path.join(dir, typesPath), "utf8").catch(() => null);
|
|
175
|
+
checks.push(dr.checkTypes(existing, schema ? generateTypes(schema, { projectName: status?.project.name }) : null, typesPath));
|
|
176
|
+
checks.push(dr.checkEnv(await dr.envNames(dir), ctx.env));
|
|
177
|
+
checks.push(dr.checkSecrets(await dr.gitFacts(dir)));
|
|
178
|
+
checks.push(dr.checkSdk(await dr.installedVersion(dir, "@mapled/next"), status?.sdk["@mapled/next"]));
|
|
179
|
+
checks.push(dr.checkCli(ctx.version, status?.sdk["@mapled/cli"]));
|
|
180
|
+
const framework = found?.config.framework ?? (await dr.detectFramework(dir));
|
|
181
|
+
checks.push(dr.checkRoute("revalidate", await dr.findRoute(dir, "revalidate"), framework));
|
|
182
|
+
checks.push(dr.checkRoute("preview", await dr.findRoute(dir, "preview"), framework));
|
|
183
|
+
if (status) {
|
|
184
|
+
checks.push(dr.checkReads(status.delivery.lastReadAt));
|
|
185
|
+
checks.push(dr.checkWebhook(status.webhook));
|
|
186
|
+
let origin = null;
|
|
187
|
+
try {
|
|
188
|
+
origin = status.webhook.url ? new URL(status.webhook.url).origin : null;
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
origin = null;
|
|
192
|
+
}
|
|
193
|
+
const probe = origin ? await dr.probePreview(origin, status.previewPath, ctx.fetch) : null;
|
|
194
|
+
checks.push(dr.checkPreviewOnSite(origin, status.previewPath, probe));
|
|
195
|
+
checks.push(dr.checkBindings(status.bindings));
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
for (const [key, label] of dr.REMOTE_CHECKS)
|
|
199
|
+
checks.push({ key, label, status: "skipped", detail: "Sign in to check." });
|
|
200
|
+
}
|
|
201
|
+
if (flags.json) {
|
|
202
|
+
ctx.out(JSON.stringify({ project: status?.project ?? (found ? { id: found.config.project } : null), checks }, null, 2));
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
ctx.out(`Mapled doctor${status ? ` — ${status.project.name}` : ""}\n`);
|
|
206
|
+
ctx.out(formatChecks(checks, useColor(ctx.env)));
|
|
207
|
+
ctx.out("");
|
|
208
|
+
ctx.out(summarize(checks));
|
|
209
|
+
}
|
|
210
|
+
return checks.some((c) => c.status === "failed") ? 1 : 0;
|
|
211
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** mapled.json — the repository's link to its project. Only public,
|
|
2
|
+
non-secret values live here (§31.4): the project id, the API origin
|
|
3
|
+
when it isn't the default, the generated types path, the framework. */
|
|
4
|
+
export declare const CONFIG_FILE = "mapled.json";
|
|
5
|
+
export declare const DEFAULT_API = "https://api.mapled.io";
|
|
6
|
+
export declare const DEFAULT_TYPES_PATH = "mapled-types.ts";
|
|
7
|
+
export type ProjectConfig = {
|
|
8
|
+
project: string;
|
|
9
|
+
api?: string;
|
|
10
|
+
types?: string;
|
|
11
|
+
framework?: string;
|
|
12
|
+
};
|
|
13
|
+
export type FoundConfig = {
|
|
14
|
+
path: string;
|
|
15
|
+
dir: string;
|
|
16
|
+
config: ProjectConfig;
|
|
17
|
+
};
|
|
18
|
+
export declare function parseConfig(raw: string, file: string): ProjectConfig;
|
|
19
|
+
/** The nearest mapled.json at or above `startDir`. */
|
|
20
|
+
export declare function findConfig(startDir: string): Promise<FoundConfig | null>;
|
|
21
|
+
export declare function writeConfig(dir: string, config: ProjectConfig): Promise<string>;
|
|
22
|
+
/** Which API to talk to: the flag, then mapled.json, then the
|
|
23
|
+
environment, then the default. */
|
|
24
|
+
export declare function resolveApi(config: ProjectConfig | null, flagApi: string | undefined, env: NodeJS.ProcessEnv): string;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CliError } from "./errors.js";
|
|
4
|
+
/** mapled.json — the repository's link to its project. Only public,
|
|
5
|
+
non-secret values live here (§31.4): the project id, the API origin
|
|
6
|
+
when it isn't the default, the generated types path, the framework. */
|
|
7
|
+
export const CONFIG_FILE = "mapled.json";
|
|
8
|
+
export const DEFAULT_API = "https://api.mapled.io";
|
|
9
|
+
export const DEFAULT_TYPES_PATH = "mapled-types.ts";
|
|
10
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
11
|
+
export function parseConfig(raw, file) {
|
|
12
|
+
let data;
|
|
13
|
+
try {
|
|
14
|
+
data = JSON.parse(raw);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new CliError(`${file} isn't valid JSON.`);
|
|
18
|
+
}
|
|
19
|
+
const obj = data && typeof data === "object" && !Array.isArray(data) ? data : null;
|
|
20
|
+
if (!obj || typeof obj.project !== "string" || !UUID_RE.test(obj.project)) {
|
|
21
|
+
throw new CliError(`${file} has no valid "project" id. Run \`mapled project link\` to link this repository.`);
|
|
22
|
+
}
|
|
23
|
+
const config = { project: obj.project.toLowerCase() };
|
|
24
|
+
if (typeof obj.api === "string" && obj.api.trim())
|
|
25
|
+
config.api = obj.api.trim().replace(/\/+$/, "");
|
|
26
|
+
if (typeof obj.types === "string" && obj.types.trim())
|
|
27
|
+
config.types = obj.types.trim();
|
|
28
|
+
if (typeof obj.framework === "string" && obj.framework.trim())
|
|
29
|
+
config.framework = obj.framework.trim();
|
|
30
|
+
return config;
|
|
31
|
+
}
|
|
32
|
+
/** The nearest mapled.json at or above `startDir`. */
|
|
33
|
+
export async function findConfig(startDir) {
|
|
34
|
+
let dir = path.resolve(startDir);
|
|
35
|
+
for (;;) {
|
|
36
|
+
const file = path.join(dir, CONFIG_FILE);
|
|
37
|
+
let raw = null;
|
|
38
|
+
try {
|
|
39
|
+
raw = await readFile(file, "utf8");
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
raw = null;
|
|
43
|
+
}
|
|
44
|
+
if (raw !== null)
|
|
45
|
+
return { path: file, dir, config: parseConfig(raw, file) };
|
|
46
|
+
const parent = path.dirname(dir);
|
|
47
|
+
if (parent === dir)
|
|
48
|
+
return null;
|
|
49
|
+
dir = parent;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export async function writeConfig(dir, config) {
|
|
53
|
+
const file = path.join(dir, CONFIG_FILE);
|
|
54
|
+
const body = { project: config.project };
|
|
55
|
+
if (config.api && config.api !== DEFAULT_API)
|
|
56
|
+
body.api = config.api;
|
|
57
|
+
body.types = config.types ?? DEFAULT_TYPES_PATH;
|
|
58
|
+
if (config.framework)
|
|
59
|
+
body.framework = config.framework;
|
|
60
|
+
await writeFile(file, JSON.stringify(body, null, 2) + "\n");
|
|
61
|
+
return file;
|
|
62
|
+
}
|
|
63
|
+
/** Which API to talk to: the flag, then mapled.json, then the
|
|
64
|
+
environment, then the default. */
|
|
65
|
+
export function resolveApi(config, flagApi, env) {
|
|
66
|
+
const raw = flagApi || config?.api || env.MAPLED_API_URL || DEFAULT_API;
|
|
67
|
+
let url;
|
|
68
|
+
try {
|
|
69
|
+
url = new URL(raw);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
throw new CliError(`"${raw}" isn't a valid API origin.`);
|
|
73
|
+
}
|
|
74
|
+
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
75
|
+
throw new CliError(`"${raw}" isn't a valid API origin.`);
|
|
76
|
+
return url.origin;
|
|
77
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** The machine-local credential store: one OAuth client id per API
|
|
2
|
+
origin and one connection (project-scoped access + refresh token)
|
|
3
|
+
per project. Lives outside the repository, mode 0600 — the tokens
|
|
4
|
+
never touch the project's files (§31.4). */
|
|
5
|
+
export type Connection = {
|
|
6
|
+
api: string;
|
|
7
|
+
projectId: string;
|
|
8
|
+
projectName: string;
|
|
9
|
+
clientId: string;
|
|
10
|
+
accessToken: string;
|
|
11
|
+
refreshToken: string;
|
|
12
|
+
/** ISO time the access token stops working; refreshed on demand. */
|
|
13
|
+
expiresAt: string;
|
|
14
|
+
createdAt: string;
|
|
15
|
+
};
|
|
16
|
+
export type CredentialsFile = {
|
|
17
|
+
version: 1;
|
|
18
|
+
clients: Record<string, {
|
|
19
|
+
clientId: string;
|
|
20
|
+
registeredAt: string;
|
|
21
|
+
}>;
|
|
22
|
+
connections: Connection[];
|
|
23
|
+
};
|
|
24
|
+
export declare function configDir(env?: NodeJS.ProcessEnv): string;
|
|
25
|
+
export declare function credentialsPath(env?: NodeJS.ProcessEnv): string;
|
|
26
|
+
export declare function emptyCredentials(): CredentialsFile;
|
|
27
|
+
export declare function readCredentials(file: string): Promise<CredentialsFile>;
|
|
28
|
+
/** 0600 in a 0700 directory, through a temp file: a crash never leaves
|
|
29
|
+
a half-written store behind. */
|
|
30
|
+
export declare function writeCredentials(file: string, data: CredentialsFile): Promise<void>;
|
|
31
|
+
export declare function findConnection(data: CredentialsFile, api: string, projectId: string): Connection | undefined;
|
|
32
|
+
export declare function connectionsFor(data: CredentialsFile, api: string): Connection[];
|
|
33
|
+
export declare function upsertConnection(data: CredentialsFile, conn: Connection): CredentialsFile;
|
|
34
|
+
export declare function removeConnection(data: CredentialsFile, api: string, projectId: string): CredentialsFile;
|