@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/dist/index.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { parseArgs } from "./args.js";
|
|
5
|
+
import { openBrowser } from "./browser.js";
|
|
6
|
+
import { doctor, generate, link, login, logout } from "./commands.js";
|
|
7
|
+
import { credentialsPath } from "./credentials.js";
|
|
8
|
+
import { CliError } from "./errors.js";
|
|
9
|
+
/** `mapled` — sign in, link a repository to its project, generate types
|
|
10
|
+
for the content, check the integration (§31, wave 1). */
|
|
11
|
+
const HELP = `mapled — the Mapled CLI
|
|
12
|
+
|
|
13
|
+
Usage
|
|
14
|
+
mapled auth login [--api <origin>] [--no-browser] Sign in to a project in your browser
|
|
15
|
+
mapled auth logout [--all] Revoke this machine's access to the linked project
|
|
16
|
+
mapled project link [--project <id>] Write mapled.json for this repository
|
|
17
|
+
mapled types generate [--out <file>] Generate TypeScript types for the content
|
|
18
|
+
mapled doctor [--json] Check the integration end to end
|
|
19
|
+
|
|
20
|
+
Options
|
|
21
|
+
--version, -v Print the version
|
|
22
|
+
--help, -h Show this help
|
|
23
|
+
|
|
24
|
+
Credentials stay in your user config directory, never in the repository.
|
|
25
|
+
Run it as \`npx @mapled/cli <command>\`, or install it globally with \`npm install -g @mapled/cli\` to get \`mapled\` on your PATH.`;
|
|
26
|
+
function version() {
|
|
27
|
+
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
28
|
+
}
|
|
29
|
+
async function choose(question, choices) {
|
|
30
|
+
if (!process.stdin.isTTY) {
|
|
31
|
+
throw new CliError(`${question} Pass --project <id> to choose without a prompt.`);
|
|
32
|
+
}
|
|
33
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
34
|
+
try {
|
|
35
|
+
for (;;) {
|
|
36
|
+
console.log(question);
|
|
37
|
+
choices.forEach((c, i) => console.log(` ${i + 1}. ${c}`));
|
|
38
|
+
const answer = (await rl.question("> ")).trim();
|
|
39
|
+
const n = Number(answer);
|
|
40
|
+
if (Number.isInteger(n) && n >= 1 && n <= choices.length)
|
|
41
|
+
return n - 1;
|
|
42
|
+
console.log("Enter a number from the list.");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
rl.close();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function main(argv) {
|
|
50
|
+
const { words, flags } = parseArgs(argv);
|
|
51
|
+
if (flags.version) {
|
|
52
|
+
console.log(version());
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
const [group, sub] = words;
|
|
56
|
+
if (flags.help || !group) {
|
|
57
|
+
console.log(HELP);
|
|
58
|
+
return flags.help || group ? 0 : 2;
|
|
59
|
+
}
|
|
60
|
+
const ctx = {
|
|
61
|
+
cwd: process.cwd(),
|
|
62
|
+
env: process.env,
|
|
63
|
+
fetch,
|
|
64
|
+
openBrowser,
|
|
65
|
+
out: (line) => console.log(line),
|
|
66
|
+
choose,
|
|
67
|
+
version: version(),
|
|
68
|
+
credentialsFile: credentialsPath(),
|
|
69
|
+
};
|
|
70
|
+
switch (`${group} ${sub ?? ""}`.trim()) {
|
|
71
|
+
case "auth login":
|
|
72
|
+
await login(ctx, flags);
|
|
73
|
+
return 0;
|
|
74
|
+
case "auth logout":
|
|
75
|
+
await logout(ctx, flags);
|
|
76
|
+
return 0;
|
|
77
|
+
case "project link":
|
|
78
|
+
await link(ctx, flags);
|
|
79
|
+
return 0;
|
|
80
|
+
case "types generate":
|
|
81
|
+
await generate(ctx, flags);
|
|
82
|
+
return 0;
|
|
83
|
+
case "doctor":
|
|
84
|
+
return doctor(ctx, flags);
|
|
85
|
+
case "auth":
|
|
86
|
+
case "project":
|
|
87
|
+
case "types":
|
|
88
|
+
console.log(HELP);
|
|
89
|
+
return 2;
|
|
90
|
+
default:
|
|
91
|
+
throw new CliError(`Unknown command "${words.join(" ")}". Run \`mapled --help\`.`, 2);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
main(process.argv.slice(2)).then((code) => {
|
|
95
|
+
process.exitCode = code;
|
|
96
|
+
}, (err) => {
|
|
97
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
98
|
+
process.exitCode = err instanceof CliError ? err.exitCode : 1;
|
|
99
|
+
});
|
package/dist/oauth.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** The CLI signs in as an OAuth 2.1 public client of the Mapled API —
|
|
2
|
+
the same authorization server the remote MCP uses (S-54): dynamic
|
|
3
|
+
registration, PKCE, consent on app.mapled.io, a loopback redirect
|
|
4
|
+
(RFC 8252) and rotating refresh tokens. The access token is scoped
|
|
5
|
+
to the one project picked on the consent screen. */
|
|
6
|
+
export declare const CLIENT_NAME = "Mapled CLI";
|
|
7
|
+
export declare const REDIRECT_PATH = "/callback";
|
|
8
|
+
export declare const SCOPE = "mapled";
|
|
9
|
+
export type Fetch = typeof fetch;
|
|
10
|
+
export type TokenSet = {
|
|
11
|
+
accessToken: string;
|
|
12
|
+
refreshToken: string;
|
|
13
|
+
expiresAt: string;
|
|
14
|
+
};
|
|
15
|
+
export type AuthorizationCode = {
|
|
16
|
+
code: string;
|
|
17
|
+
verifier: string;
|
|
18
|
+
redirectUri: string;
|
|
19
|
+
};
|
|
20
|
+
export declare function pkce(): {
|
|
21
|
+
verifier: string;
|
|
22
|
+
challenge: string;
|
|
23
|
+
};
|
|
24
|
+
/** Dynamic registration (RFC 7591) of this machine's CLI as a public
|
|
25
|
+
client whose loopback redirect may use any port. */
|
|
26
|
+
export declare function registerClient(api: string, f: Fetch): Promise<string>;
|
|
27
|
+
/** Whether the authorization server still knows a stored client id: a
|
|
28
|
+
request with an unsupported response type is answered with a
|
|
29
|
+
redirect for a known client and 400 for an unknown one — and
|
|
30
|
+
creates nothing on the server. */
|
|
31
|
+
export declare function clientKnown(api: string, clientId: string, f: Fetch): Promise<boolean>;
|
|
32
|
+
export type AuthorizeDeps = {
|
|
33
|
+
openBrowser: (url: string) => Promise<boolean>;
|
|
34
|
+
log: (line: string) => void;
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
};
|
|
37
|
+
/** The authorization code flow with a loopback redirect: a one-off
|
|
38
|
+
server on 127.0.0.1 receives the code once the person approves on
|
|
39
|
+
app.mapled.io. Rejects on denial, on timeout, and never on a state
|
|
40
|
+
that isn't this run's. */
|
|
41
|
+
export declare function authorize(api: string, clientId: string, deps: AuthorizeDeps): Promise<AuthorizationCode>;
|
|
42
|
+
export declare function exchangeCode(api: string, clientId: string, grant: AuthorizationCode, f: Fetch): Promise<TokenSet>;
|
|
43
|
+
export declare function refreshTokens(api: string, clientId: string, refreshToken: string, f: Fetch): Promise<TokenSet>;
|
|
44
|
+
/** Ends the connection (RFC 7009). Best effort — the local copy goes either way. */
|
|
45
|
+
export declare function revokeToken(api: string, token: string, f: Fetch): Promise<void>;
|
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { CliError } from "./errors.js";
|
|
4
|
+
/** The CLI signs in as an OAuth 2.1 public client of the Mapled API —
|
|
5
|
+
the same authorization server the remote MCP uses (S-54): dynamic
|
|
6
|
+
registration, PKCE, consent on app.mapled.io, a loopback redirect
|
|
7
|
+
(RFC 8252) and rotating refresh tokens. The access token is scoped
|
|
8
|
+
to the one project picked on the consent screen. */
|
|
9
|
+
export const CLIENT_NAME = "Mapled CLI";
|
|
10
|
+
export const REDIRECT_PATH = "/callback";
|
|
11
|
+
export const SCOPE = "mapled";
|
|
12
|
+
export function pkce() {
|
|
13
|
+
const verifier = randomBytes(48).toString("base64url");
|
|
14
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
15
|
+
return { verifier, challenge };
|
|
16
|
+
}
|
|
17
|
+
function unreachable(api) {
|
|
18
|
+
return new CliError(`Couldn't reach ${api}. Check your connection and try again.`);
|
|
19
|
+
}
|
|
20
|
+
async function oauthPost(api, path, params, f) {
|
|
21
|
+
let res;
|
|
22
|
+
try {
|
|
23
|
+
res = await f(`${api}${path}`, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
26
|
+
body: new URLSearchParams(params).toString(),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw unreachable(api);
|
|
31
|
+
}
|
|
32
|
+
const data = (await res.json().catch(() => ({})));
|
|
33
|
+
if (!res.ok) {
|
|
34
|
+
throw new CliError(String(data.error_description ?? data.error ?? `Mapled answered ${res.status}.`));
|
|
35
|
+
}
|
|
36
|
+
return data;
|
|
37
|
+
}
|
|
38
|
+
/** Dynamic registration (RFC 7591) of this machine's CLI as a public
|
|
39
|
+
client whose loopback redirect may use any port. */
|
|
40
|
+
export async function registerClient(api, f) {
|
|
41
|
+
let res;
|
|
42
|
+
try {
|
|
43
|
+
res = await f(`${api}/oauth/register`, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
46
|
+
body: JSON.stringify({
|
|
47
|
+
client_name: CLIENT_NAME,
|
|
48
|
+
client_uri: "https://mapled.io",
|
|
49
|
+
redirect_uris: [`http://127.0.0.1${REDIRECT_PATH}`],
|
|
50
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
51
|
+
response_types: ["code"],
|
|
52
|
+
token_endpoint_auth_method: "none",
|
|
53
|
+
scope: SCOPE,
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
throw unreachable(api);
|
|
59
|
+
}
|
|
60
|
+
const data = (await res.json().catch(() => ({})));
|
|
61
|
+
if (!res.ok || typeof data.client_id !== "string") {
|
|
62
|
+
throw new CliError(`Couldn't register the CLI with ${api}: ${data.error_description ?? data.error ?? `status ${res.status}`}.`);
|
|
63
|
+
}
|
|
64
|
+
return data.client_id;
|
|
65
|
+
}
|
|
66
|
+
/** Whether the authorization server still knows a stored client id: a
|
|
67
|
+
request with an unsupported response type is answered with a
|
|
68
|
+
redirect for a known client and 400 for an unknown one — and
|
|
69
|
+
creates nothing on the server. */
|
|
70
|
+
export async function clientKnown(api, clientId, f) {
|
|
71
|
+
try {
|
|
72
|
+
const url = new URL(`${api}/oauth/authorize`);
|
|
73
|
+
url.search = new URLSearchParams({
|
|
74
|
+
client_id: clientId,
|
|
75
|
+
redirect_uri: `http://127.0.0.1${REDIRECT_PATH}`,
|
|
76
|
+
response_type: "none",
|
|
77
|
+
state: "probe",
|
|
78
|
+
}).toString();
|
|
79
|
+
const res = await f(url.toString(), { redirect: "manual" });
|
|
80
|
+
return res.status === 302;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function page(res, status, text) {
|
|
87
|
+
// Fixed strings of our own — nothing from the query string is rendered.
|
|
88
|
+
res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
89
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>Mapled</title>` +
|
|
90
|
+
`<p style="font:16px system-ui,sans-serif;margin:40px">${text}</p>`);
|
|
91
|
+
}
|
|
92
|
+
/** The authorization code flow with a loopback redirect: a one-off
|
|
93
|
+
server on 127.0.0.1 receives the code once the person approves on
|
|
94
|
+
app.mapled.io. Rejects on denial, on timeout, and never on a state
|
|
95
|
+
that isn't this run's. */
|
|
96
|
+
export function authorize(api, clientId, deps) {
|
|
97
|
+
const { verifier, challenge } = pkce();
|
|
98
|
+
const state = randomBytes(16).toString("base64url");
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
let redirectUri = "";
|
|
101
|
+
let settled = false;
|
|
102
|
+
const server = createServer((req, res) => {
|
|
103
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
104
|
+
if (url.pathname !== REDIRECT_PATH) {
|
|
105
|
+
res.writeHead(404).end();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (url.searchParams.get("state") !== state) {
|
|
109
|
+
// Not this run's redirect — keep waiting for the real one.
|
|
110
|
+
page(res, 400, "This sign-in link doesn't belong to the running command.");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const error = url.searchParams.get("error");
|
|
114
|
+
if (error) {
|
|
115
|
+
page(res, 200, "Sign-in was cancelled. You can close this tab.");
|
|
116
|
+
finish(() => reject(new CliError(error === "access_denied"
|
|
117
|
+
? "Sign-in was denied in the browser."
|
|
118
|
+
: `Sign-in failed: ${url.searchParams.get("error_description") ?? error}`)));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const code = url.searchParams.get("code");
|
|
122
|
+
if (!code) {
|
|
123
|
+
page(res, 400, "The sign-in link is missing its code.");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
page(res, 200, "Signed in to Mapled. You can close this tab and return to the terminal.");
|
|
127
|
+
finish(() => resolve({ code, verifier, redirectUri }));
|
|
128
|
+
});
|
|
129
|
+
const timer = setTimeout(() => finish(() => reject(new CliError("Timed out waiting for the browser. Run `mapled auth login` again."))), deps.timeoutMs ?? 5 * 60_000);
|
|
130
|
+
function finish(then) {
|
|
131
|
+
if (settled)
|
|
132
|
+
return;
|
|
133
|
+
settled = true;
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
server.close();
|
|
136
|
+
then();
|
|
137
|
+
}
|
|
138
|
+
server.once("error", (err) => finish(() => reject(err)));
|
|
139
|
+
server.listen(0, "127.0.0.1", () => {
|
|
140
|
+
const port = server.address().port;
|
|
141
|
+
redirectUri = `http://127.0.0.1:${port}${REDIRECT_PATH}`;
|
|
142
|
+
const url = new URL(`${api}/oauth/authorize`);
|
|
143
|
+
url.search = new URLSearchParams({
|
|
144
|
+
response_type: "code",
|
|
145
|
+
client_id: clientId,
|
|
146
|
+
redirect_uri: redirectUri,
|
|
147
|
+
code_challenge: challenge,
|
|
148
|
+
code_challenge_method: "S256",
|
|
149
|
+
state,
|
|
150
|
+
scope: SCOPE,
|
|
151
|
+
resource: `${api}/mcp`,
|
|
152
|
+
}).toString();
|
|
153
|
+
deps.log(`Opening your browser to sign in to Mapled. If it doesn't open, visit:\n ${url.toString()}`);
|
|
154
|
+
deps
|
|
155
|
+
.openBrowser(url.toString())
|
|
156
|
+
.then((opened) => {
|
|
157
|
+
if (!opened)
|
|
158
|
+
deps.log("Couldn't open a browser here — use the link above.");
|
|
159
|
+
})
|
|
160
|
+
.catch(() => { });
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
function toTokenSet(data) {
|
|
165
|
+
const access = data.access_token;
|
|
166
|
+
const refresh = data.refresh_token;
|
|
167
|
+
if (typeof access !== "string" || typeof refresh !== "string") {
|
|
168
|
+
throw new CliError("Mapled returned an incomplete token response. Try again.");
|
|
169
|
+
}
|
|
170
|
+
const expiresIn = Number(data.expires_in);
|
|
171
|
+
const ttl = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600;
|
|
172
|
+
return { accessToken: access, refreshToken: refresh, expiresAt: new Date(Date.now() + ttl * 1000).toISOString() };
|
|
173
|
+
}
|
|
174
|
+
export async function exchangeCode(api, clientId, grant, f) {
|
|
175
|
+
const data = await oauthPost(api, "/oauth/token", {
|
|
176
|
+
grant_type: "authorization_code",
|
|
177
|
+
code: grant.code,
|
|
178
|
+
code_verifier: grant.verifier,
|
|
179
|
+
redirect_uri: grant.redirectUri,
|
|
180
|
+
client_id: clientId,
|
|
181
|
+
resource: `${api}/mcp`,
|
|
182
|
+
}, f);
|
|
183
|
+
return toTokenSet(data);
|
|
184
|
+
}
|
|
185
|
+
export async function refreshTokens(api, clientId, refreshToken, f) {
|
|
186
|
+
const data = await oauthPost(api, "/oauth/token", { grant_type: "refresh_token", refresh_token: refreshToken, client_id: clientId }, f);
|
|
187
|
+
return toTokenSet(data);
|
|
188
|
+
}
|
|
189
|
+
/** Ends the connection (RFC 7009). Best effort — the local copy goes either way. */
|
|
190
|
+
export async function revokeToken(api, token, f) {
|
|
191
|
+
await oauthPost(api, "/oauth/revoke", { token }, f).catch(() => undefined);
|
|
192
|
+
}
|
package/dist/output.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Check, CheckStatus } from "./doctor.js";
|
|
2
|
+
/** Terminal output: the glyphs of the Verification checklist (S-42),
|
|
3
|
+
colour only on a TTY that hasn't set NO_COLOR. */
|
|
4
|
+
export declare const GLYPH: Record<CheckStatus, string>;
|
|
5
|
+
export declare function useColor(env?: NodeJS.ProcessEnv): boolean;
|
|
6
|
+
export declare function paint(status: CheckStatus, text: string, color: boolean): string;
|
|
7
|
+
export declare function formatChecks(checks: Check[], color: boolean): string;
|
|
8
|
+
export declare function summarize(checks: Check[]): string;
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Terminal output: the glyphs of the Verification checklist (S-42),
|
|
2
|
+
colour only on a TTY that hasn't set NO_COLOR. */
|
|
3
|
+
export const GLYPH = { passed: "✓", failed: "✗", warning: "⚠", skipped: "○" };
|
|
4
|
+
const COLOR = { passed: "32", failed: "31", warning: "33", skipped: "2" };
|
|
5
|
+
const ESC = "[";
|
|
6
|
+
export function useColor(env = process.env) {
|
|
7
|
+
return Boolean(process.stdout.isTTY) && !env.NO_COLOR && env.TERM !== "dumb";
|
|
8
|
+
}
|
|
9
|
+
export function paint(status, text, color) {
|
|
10
|
+
return color ? `${ESC}${COLOR[status]}m${text}${ESC}0m` : text;
|
|
11
|
+
}
|
|
12
|
+
export function formatChecks(checks, color) {
|
|
13
|
+
const width = checks.reduce((w, c) => Math.max(w, c.label.length), 0);
|
|
14
|
+
return checks
|
|
15
|
+
.map((c) => `${paint(c.status, GLYPH[c.status], color)} ${c.label.padEnd(width)} ${c.detail}`)
|
|
16
|
+
.join("\n");
|
|
17
|
+
}
|
|
18
|
+
export function summarize(checks) {
|
|
19
|
+
const failed = checks.filter((c) => c.status === "failed").length;
|
|
20
|
+
const warned = checks.filter((c) => c.status === "warning").length;
|
|
21
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
22
|
+
if (failed === 0 && warned === 0)
|
|
23
|
+
return "Everything checks out.";
|
|
24
|
+
const parts = [];
|
|
25
|
+
if (failed > 0)
|
|
26
|
+
parts.push(plural(failed, "problem"));
|
|
27
|
+
if (warned > 0)
|
|
28
|
+
parts.push(plural(warned, "warning"));
|
|
29
|
+
return `${parts.join(", ")}.${failed > 0 ? " Fix the problems and run `mapled doctor` again." : ""}`;
|
|
30
|
+
}
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** The project schema as GET /v1/agent/schema returns it (the subset the
|
|
2
|
+
generator reads). Everything in it is content the project's editors
|
|
3
|
+
and agents typed — names and keys are data, escaped on output. */
|
|
4
|
+
export type SchemaSubField = {
|
|
5
|
+
key: string;
|
|
6
|
+
displayName: string;
|
|
7
|
+
type: string;
|
|
8
|
+
required: boolean;
|
|
9
|
+
helpText?: string | null;
|
|
10
|
+
options?: string[] | null;
|
|
11
|
+
sensitive?: boolean;
|
|
12
|
+
};
|
|
13
|
+
export type SchemaField = {
|
|
14
|
+
key: string;
|
|
15
|
+
displayName: string;
|
|
16
|
+
type: string;
|
|
17
|
+
required: boolean;
|
|
18
|
+
helpText: string | null;
|
|
19
|
+
options: string[] | null;
|
|
20
|
+
relation: {
|
|
21
|
+
target: string;
|
|
22
|
+
cardinality: "one" | "many";
|
|
23
|
+
onDelete?: string;
|
|
24
|
+
} | null;
|
|
25
|
+
sensitive: boolean;
|
|
26
|
+
group: {
|
|
27
|
+
fields: SchemaSubField[];
|
|
28
|
+
repeatable: boolean;
|
|
29
|
+
maxItems?: number;
|
|
30
|
+
} | null;
|
|
31
|
+
};
|
|
32
|
+
export type SchemaCollection = {
|
|
33
|
+
key: string;
|
|
34
|
+
displayName: string;
|
|
35
|
+
kind: "collection" | "single";
|
|
36
|
+
accessClass: string;
|
|
37
|
+
mode: string;
|
|
38
|
+
fields: SchemaField[];
|
|
39
|
+
};
|
|
40
|
+
export type Schema = {
|
|
41
|
+
collections: SchemaCollection[];
|
|
42
|
+
};
|
|
43
|
+
/** Twelve hex characters over everything the generated types depend on. */
|
|
44
|
+
export declare function schemaHash(schema: Schema): string;
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/** Twelve hex characters over everything the generated types depend on. */
|
|
3
|
+
export function schemaHash(schema) {
|
|
4
|
+
const projection = schema.collections.map((c) => ({
|
|
5
|
+
key: c.key,
|
|
6
|
+
displayName: c.displayName,
|
|
7
|
+
kind: c.kind,
|
|
8
|
+
accessClass: c.accessClass,
|
|
9
|
+
mode: c.mode,
|
|
10
|
+
fields: c.fields.map((f) => ({
|
|
11
|
+
key: f.key,
|
|
12
|
+
displayName: f.displayName,
|
|
13
|
+
type: f.type,
|
|
14
|
+
required: f.required,
|
|
15
|
+
options: f.options ?? null,
|
|
16
|
+
relation: f.relation ? { target: f.relation.target, cardinality: f.relation.cardinality } : null,
|
|
17
|
+
sensitive: f.sensitive === true,
|
|
18
|
+
group: f.group
|
|
19
|
+
? {
|
|
20
|
+
repeatable: f.group.repeatable,
|
|
21
|
+
maxItems: f.group.maxItems ?? null,
|
|
22
|
+
fields: f.group.fields.map((s) => ({
|
|
23
|
+
key: s.key,
|
|
24
|
+
displayName: s.displayName,
|
|
25
|
+
type: s.type,
|
|
26
|
+
required: s.required,
|
|
27
|
+
options: s.options ?? null,
|
|
28
|
+
sensitive: s.sensitive === true,
|
|
29
|
+
})),
|
|
30
|
+
}
|
|
31
|
+
: null,
|
|
32
|
+
})),
|
|
33
|
+
}));
|
|
34
|
+
return createHash("sha256").update(JSON.stringify(projection)).digest("hex").slice(0, 12);
|
|
35
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type Schema, type SchemaCollection } from "./schema.js";
|
|
2
|
+
/** TypeScript types for the content a site reads through @mapled/next:
|
|
3
|
+
one interface per collection and single, plus keyed maps. Sensitive
|
|
4
|
+
fields never reach the site, so they are left out. */
|
|
5
|
+
export type Generated = {
|
|
6
|
+
/** The whole file. */
|
|
7
|
+
text: string;
|
|
8
|
+
/** The file without its header — what `mapled doctor` compares. */
|
|
9
|
+
body: string;
|
|
10
|
+
hash: string;
|
|
11
|
+
collections: number;
|
|
12
|
+
singles: number;
|
|
13
|
+
};
|
|
14
|
+
/** A conservative singular: regular English plurals only. */
|
|
15
|
+
export declare function singular(word: string): string;
|
|
16
|
+
export declare function pascal(words: string[]): string;
|
|
17
|
+
/** `blog-posts` → `BlogPost`; singles keep their name (`homepage` → `Homepage`). */
|
|
18
|
+
export declare function typeName(key: string, kind: "collection" | "single"): string;
|
|
19
|
+
/** Unique type names: the singular PascalCase of the key; the raw
|
|
20
|
+
PascalCase when two keys meet there; a numeric suffix as a last resort. */
|
|
21
|
+
export declare function typeNames(collections: SchemaCollection[]): Map<string, string>;
|
|
22
|
+
export declare function generateTypes(schema: Schema, opts?: {
|
|
23
|
+
projectName?: string;
|
|
24
|
+
}): Generated;
|
|
25
|
+
/** Splits a generated file into its header and body, with the hash the
|
|
26
|
+
header names when it has one. */
|
|
27
|
+
export declare function splitGenerated(text: string): {
|
|
28
|
+
header: string;
|
|
29
|
+
body: string;
|
|
30
|
+
hash: string | null;
|
|
31
|
+
};
|