@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 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
@@ -0,0 +1,6 @@
1
+ export type ParsedArgs = {
2
+ words: string[];
3
+ flags: Record<string, string | true>;
4
+ };
5
+ export declare function parseArgs(argv: string[]): ParsedArgs;
6
+ export declare function stringFlag(flags: ParsedArgs["flags"], name: string): string | undefined;
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
+ }
@@ -0,0 +1,3 @@
1
+ /** Opens a URL in the default browser; resolves false when that isn't
2
+ possible (no desktop, no opener) so the caller prints the link. */
3
+ export declare function openBrowser(url: string): Promise<boolean>;
@@ -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 {};
@@ -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
+ }
@@ -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;