@aotter/mantle 0.2.0-alpha.9 → 0.2.1-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -5
- package/dist/auth/adapter.js +1 -28
- package/dist/auth/admin.js +2 -5
- package/dist/auth/caller.js +0 -27
- package/dist/auth/consent.js +100 -0
- package/dist/auth/hardening.js +0 -7
- package/dist/auth/identity.js +0 -11
- package/dist/auth/oauth.js +0 -32
- package/dist/auth/resource-seeds.js +0 -2
- package/dist/auth/route-http.js +33 -0
- package/dist/auth/routes.js +5 -151
- package/dist/build/compiler.d.ts +19 -609
- package/dist/build/compiler.js +6 -6896
- package/dist/build/spec/domain/model/ManifestGrammar.d.ts +39 -1
- package/dist/build/spec/domain/model/RuntimePlan.d.ts +16 -3
- package/dist/build/spec/domain/model/SqlIr.d.ts +32 -0
- package/dist/build/spec/domain/model/UserFields.d.ts +1 -0
- package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
- package/dist/bun/assets.js +0 -2
- package/dist/domain/adminOrder.js +0 -2
- package/dist/domain/columns.js +7 -20
- package/dist/domain/describe/chunk-P6PP6VVV.js +1 -0
- package/dist/domain/describe/index.d.ts +1 -0
- package/dist/domain/describe/index.js +1 -115
- package/dist/domain/describe/match.d.ts +19 -0
- package/dist/domain/describe/target-hash.js +1 -12
- package/dist/domain/entry.js +0 -14
- package/dist/domain/generated-tools.js +0 -1
- package/dist/domain/input.js +0 -2
- package/dist/domain/paging.js +0 -17
- package/dist/domain/recipes.js +10 -7
- package/dist/domain/userReferences.js +0 -1
- package/dist/domain/wireScalars.js +0 -6
- package/dist/infrastructure/adminEntries.js +27 -8
- package/dist/infrastructure/adminPaging.js +55 -57
- package/dist/infrastructure/artifacts.js +0 -4
- package/dist/infrastructure/authSchema.js +0 -9
- package/dist/infrastructure/bundle.js +45 -38
- package/dist/infrastructure/cli.js +26 -12
- package/dist/infrastructure/generate.js +26 -19
- package/dist/infrastructure/generateAuth.js +1 -9
- package/dist/infrastructure/help.js +55 -0
- package/dist/infrastructure/init.js +50 -0
- package/dist/infrastructure/inputs.js +0 -9
- package/dist/infrastructure/mounts.js +22 -0
- package/dist/infrastructure/wrangler.js +0 -6
- package/dist/kernel/authorization.js +0 -1
- package/dist/kernel/binds.d.ts +34 -7
- package/dist/kernel/binds.js +5 -11
- package/dist/kernel/caller-identity.d.ts +0 -3
- package/dist/kernel/caller-identity.js +0 -15
- package/dist/kernel/caller-mode.d.ts +4 -0
- package/dist/kernel/caller-mode.js +14 -0
- package/dist/kernel/contracts.d.ts +13 -10
- package/dist/kernel/contracts.js +14 -41
- package/dist/kernel/cron.js +0 -4
- package/dist/kernel/dialect/common.js +0 -3
- package/dist/kernel/dialect/postgres.js +0 -2
- package/dist/kernel/dialect/sqlite.js +1 -6
- package/dist/kernel/drain.js +2 -8
- package/dist/kernel/driver/bun-sqlite.js +3 -7
- package/dist/kernel/driver/d1.d.ts +1 -1
- package/dist/kernel/driver/d1.js +6 -4
- package/dist/kernel/driver/postgres.d.ts +2 -1
- package/dist/kernel/driver/postgres.js +12 -22
- package/dist/kernel/driver/transaction-context.js +0 -1
- package/dist/kernel/email.js +0 -4
- package/dist/kernel/host-access.js +2 -3
- package/dist/kernel/host-caller.d.ts +1 -1
- package/dist/kernel/host-caller.js +3 -6
- package/dist/kernel/json-body.js +0 -2
- package/dist/kernel/mcp-apps.js +0 -5
- package/dist/kernel/mcp-call.js +0 -1
- package/dist/kernel/mcp-handler.js +4 -27
- package/dist/kernel/mcp.js +0 -5
- package/dist/kernel/media.js +0 -18
- package/dist/kernel/paging.js +1 -9
- package/dist/kernel/publishing.js +3 -4
- package/dist/kernel/r2-media.js +0 -7
- package/dist/kernel/rest-dispatch.js +0 -1
- package/dist/kernel/rest-router.js +5 -8
- package/dist/kernel/rest.d.ts +2 -1
- package/dist/kernel/rest.js +1 -3
- package/dist/kernel/route-pattern.d.ts +26 -0
- package/dist/kernel/route-pattern.js +34 -0
- package/dist/kernel/scalars.js +1 -9
- package/dist/kernel/search.js +0 -1
- package/dist/kernel/site.js +0 -2
- package/dist/kernel/store.d.ts +26 -2
- package/dist/kernel/store.js +69 -49
- package/dist/kernel/surface-failure.d.ts +10 -0
- package/dist/kernel/surface-failure.js +29 -0
- package/dist/kernel/surface.js +0 -3
- package/dist/kernel/sweep.js +0 -1
- package/dist/kernel/translations.js +0 -1
- package/dist/kernel/user-caller.js +2 -8
- package/dist/kernel/user-fields.d.ts +3 -2
- package/dist/kernel/user-fields.js +9 -9
- package/dist/kernel/validation.js +0 -8
- package/dist/kernel/web-gateway.js +0 -2
- package/dist/kernel/web-head.js +0 -1
- package/dist/kernel/web-index.js +0 -1
- package/dist/kernel/web-response.js +0 -1
- package/dist/kernel/web-serve.js +97 -0
- package/dist/kernel/web.d.ts +1 -1
- package/dist/kernel/web.js +10 -91
- package/dist/kernel/where.js +0 -6
- package/dist/usecase/adminBindings.js +32 -23
- package/dist/usecase/adminClient.js +4 -22
- package/dist/usecase/adminDescriptors.js +2 -9
- package/dist/usecase/generate.js +14 -17
- package/dist/usecase/handlers.js +1 -4
- package/dist/usecase/mcp-media.js +0 -1
- package/dist/usecase/mcp.js +0 -4
- package/dist/usecase/paging.js +1 -3
- package/dist/usecase/publishing.js +20 -11
- package/dist/usecase/rest.js +5 -6
- package/dist/usecase/site.js +0 -1
- package/dist/usecase/stores.js +12 -17
- package/dist/usecase/tool-version.js +0 -3
- package/dist/usecase/translations.js +0 -2
- package/dist/usecase/validation.js +0 -20
- package/docs/examples/README.md +13 -5
- package/docs/examples/app-shell/README.md +91 -12
- package/docs/examples/app-shell/accept.mjs +91 -0
- package/docs/examples/app-shell/gitignore +7 -0
- package/docs/examples/app-shell/package.json +10 -9
- package/docs/examples/app-shell/smoke-kit.mjs +29 -15
- package/docs/examples/app-shell/src/routes.ts +6 -3
- package/docs/examples/app-shell/src/service.ts +2 -1
- package/docs/examples/club-events/README.md +7 -2
- package/docs/examples/club-events/manifests/club-events.yaml +17 -0
- package/docs/examples/club-events/smoke.mjs +7 -1
- package/docs/examples/generated-web/seed.sql +5 -4
- package/docs/examples/purchasing/README.md +12 -3
- package/docs/examples/purchasing/manifests/purchasing.yaml +24 -4
- package/docs/examples/purchasing/smoke.mjs +14 -0
- package/docs/examples/recipes/README.md +5 -3
- package/docs/handbook/cloudflare/media-r2.md +6 -3
- package/docs/handbook/cloudflare/service-entry.md +5 -0
- package/docs/handbook/concepts/lifecycle-and-locales.md +21 -1
- package/docs/handbook/examples/hub.md +1 -1
- package/docs/handbook/guides/admin-ui.md +1 -1
- package/docs/handbook/guides/agent-setup.md +20 -10
- package/docs/handbook/guides/agent-workflow-acceptance.md +225 -105
- package/docs/handbook/guides/local-testing.md +85 -0
- package/docs/handbook/guides/native-admin.md +242 -16
- package/docs/handbook/guides/native-execution.md +10 -8
- package/docs/handbook/guides/public-pages.md +44 -4
- package/docs/handbook/guides/revisions.md +584 -0
- package/docs/handbook/guides/size-and-startup.md +140 -0
- package/docs/handbook/guides/sql-patterns.md +312 -0
- package/docs/handbook/guides/typed-queries.md +146 -3
- package/docs/handbook/navigation.json +20 -0
- package/docs/handbook/reference/admin-descriptors.md +4 -1
- package/docs/handbook/reference/authorization.md +3 -1
- package/docs/handbook/reference/describe.md +42 -0
- package/docs/handbook/reference/diagnostics.md +16 -2
- package/docs/handbook/reference/procedure.md +6 -6
- package/docs/handbook/reference/schema.md +79 -9
- package/docs/handbook/reference/sql.md +65 -0
- package/docs/handbook/reference/surface.md +8 -4
- package/docs/handbook/reference/trigger.md +12 -0
- package/docs/handbook/reference/view.md +11 -3
- package/docs/handbook/releases/index.md +71 -7
- package/docs/handbook/start/agents.md +49 -0
- package/docs/handbook/start/project-and-cli.md +10 -0
- package/docs/handbook/start/quickstart-worker.md +1 -1
- package/docs/skills/develop/SKILL.md +19 -6
- package/docs/skills/integrate/SKILL.md +3 -1
- package/docs/skills/performance/SKILL.md +169 -0
- package/docs/skills/update/SKILL.md +10 -4
- package/docs/upgrade-0.1-to-0.2.md +151 -39
- package/package.json +31 -11
- package/skills/README.md +6 -2
- package/skills/mantle/SKILL.md +53 -120
- package/dist/domain/describe/chunk-ZVXBGP7Y.js +0 -424
- package/skills/mantle/references/local-testing.md +0 -20
- package/skills/mantle/references/web.md +0 -41
- package/skills/mantle/references/website.md +0 -51
|
@@ -4,7 +4,6 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod
|
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
5
|
import { build } from "esbuild";
|
|
6
6
|
import { INPUTS_FILE, authTableSql, extraRoles, generatedTarget, staleInputs } from "./inputs.js";
|
|
7
|
-
/** Only static imports run at startup; dynamic imports remain separate uploaded modules. */
|
|
8
7
|
export function startupGraph(metafile, entry) {
|
|
9
8
|
const visited = new Set();
|
|
10
9
|
const visit = (name) => {
|
|
@@ -25,23 +24,19 @@ export function startupGraph(metafile, entry) {
|
|
|
25
24
|
const refused = [
|
|
26
25
|
[/(?:^|\/)node_modules\/(?:better-auth(?:\/|$)|@better-auth\/)/, "Load it through authLoader(() => import(...)) to keep Better Auth out of startup."],
|
|
27
26
|
[/(?:^|\/)node_modules\/@modelcontextprotocol\//, "Load MCP through a request-time import(...)."],
|
|
28
|
-
// the compiler: the package's own generator layers (installed, or workspace dist/source) and its parser, YAML and SQL dependencies
|
|
29
27
|
[/(?:^|\/)node_modules\/(?:@aotter\/mantle\/dist\/(?:build|infrastructure)\/|yaml\/|pgsql-deparser\/|libpg-query\/)|(?:^|\/)mantle\/(?:dist|src)\/(?:build|infrastructure)\//, "Generator code must not run in a Worker; import runtime entry points such as @aotter/mantle/rest."],
|
|
30
|
-
// Admin's UI is built into <assets>/admin by the app's admin.tsx; the Worker serves it, never runs it.
|
|
31
28
|
[/(?:^|\/)node_modules\/@aotter\/mantle-admin\/dist\/admin-app\/|(?:^|\/)mantle-admin\/(?:dist\/admin-app|admin\/src)\//, "Admin's UI (@aotter/mantle-admin/app, /hooks) runs in the browser: import it only from src/admin.tsx, which `mantle bundle` builds into the assets directory."],
|
|
32
29
|
];
|
|
33
30
|
export function guardStartup(metafile, entry) {
|
|
34
31
|
for (const name of startupGraph(metafile, entry)) {
|
|
35
32
|
const output = metafile.outputs[name];
|
|
36
33
|
for (const input of Object.keys(output.inputs)) {
|
|
37
|
-
// shortcut: vendored auth outside node_modules is not recognized; use package metadata if vendoring is supported.
|
|
38
34
|
const hit = refused.find(([pattern]) => pattern.test(input.replaceAll("\\", "/")));
|
|
39
35
|
if (hit)
|
|
40
36
|
throw new Error(`Startup module ${name} contains ${input}. ${hit[1]}`);
|
|
41
37
|
}
|
|
42
38
|
}
|
|
43
39
|
}
|
|
44
|
-
/** The physical spelling of a path, including a nonexistent tail below its nearest existing ancestor. */
|
|
45
40
|
const physical = (input) => {
|
|
46
41
|
let found = resolve(input);
|
|
47
42
|
const tail = [];
|
|
@@ -54,53 +49,62 @@ const physical = (input) => {
|
|
|
54
49
|
}
|
|
55
50
|
return resolve(realpathSync(found), ...tail);
|
|
56
51
|
};
|
|
57
|
-
/** Whether `inner` is `outer` or inside it. */
|
|
58
52
|
const within = (outer, inner) => {
|
|
59
53
|
const path = relative(physical(outer), physical(inner));
|
|
60
54
|
return path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path));
|
|
61
55
|
};
|
|
62
|
-
/** A directory a build empties must not be the working directory or hold the source entry. */
|
|
63
56
|
function assertCleanable(label, dir, entry) {
|
|
64
57
|
if (physical(dir) === physical(process.cwd()))
|
|
65
58
|
throw new Error(`${label} must not be the working directory: it is cleaned before building.`);
|
|
66
59
|
if (within(dir, entry))
|
|
67
60
|
throw new Error(`${label} must not contain the source entry: it is cleaned before building.`);
|
|
68
61
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
* (default `<outdir>/client`) when the Worker serves it (`@aotter/mantle-admin/server`; `/mcp` and the other subpaths alone do not).
|
|
72
|
-
* A server whose generated output records a `bun-*` target runs as TypeScript, so only its Admin is built.
|
|
73
|
-
*/
|
|
74
|
-
export async function bundleApp(entry, outdir, assets = join(outdir, "client")) {
|
|
62
|
+
export const ownModules = async (entry) => (await build({ entryPoints: [entry], bundle: true, write: false, metafile: true, packages: "external", format: "esm", platform: "neutral", logLevel: "silent", absWorkingDir: process.cwd() })).metafile;
|
|
63
|
+
export async function bundleApp(entry, outdir, assets = join(outdir, "client"), options = {}) {
|
|
75
64
|
const source = realpathSync(entry);
|
|
76
|
-
// what is cleaned lies under these two, so neither may be the working directory or hold the source
|
|
77
65
|
assertCleanable("Bundle outdir", outdir, source);
|
|
78
66
|
assertCleanable("The assets directory", assets, source);
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
67
|
+
const own = await ownModules(entry);
|
|
68
|
+
const worker = join(outdir, "worker");
|
|
69
|
+
const apart = () => {
|
|
70
|
+
if (within(worker, assets) || within(assets, worker))
|
|
71
|
+
throw new Error(`The assets directory ${assets} must not overlap the Worker directory ${worker}.`);
|
|
72
|
+
};
|
|
73
|
+
const refuse = (error) => {
|
|
74
|
+
if (!options.watch || !(error instanceof StaleGeneratedError))
|
|
75
|
+
throw error;
|
|
76
|
+
apart();
|
|
77
|
+
rmSync(worker, { recursive: true, force: true });
|
|
78
|
+
console.error(`${error.summary} Run your generate script (mantle generate ...). ${worker} is removed, so Wrangler reports its entry missing; a running wrangler dev holds requests and rebuilds when ${error.dir} changes.`);
|
|
79
|
+
};
|
|
80
|
+
let dirs;
|
|
81
|
+
try {
|
|
82
|
+
dirs = generatedDirs(own);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
refuse(error);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (dirs.some(dir => generatedTarget(join(dir, "generated.ts"))?.startsWith("bun-"))) {
|
|
83
89
|
if (Object.values(own.inputs).some(input => input.imports.some(imported => /^@aotter\/mantle-admin\/server(?:\/|$)/.test(imported.path))))
|
|
84
90
|
await bundleAdmin(source, outdir, join(assets, "admin"));
|
|
85
91
|
else
|
|
86
92
|
console.warn("note: no Admin built: the entry's own modules do not import @aotter/mantle-admin/server (an import through another package is not seen).");
|
|
87
93
|
return;
|
|
88
94
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
95
|
+
apart();
|
|
96
|
+
let metafile;
|
|
97
|
+
try {
|
|
98
|
+
metafile = await bundle(entry, worker);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
refuse(error);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
94
104
|
if (Object.keys(metafile.inputs).some(input => /(?:^|\/)mantle-admin\/(?:dist|src)\/server\/index\.[jt]s$/.test(input.replaceAll("\\", "/")))) {
|
|
95
105
|
await bundleAdmin(source, outdir, join(assets, "admin"));
|
|
96
106
|
}
|
|
97
107
|
}
|
|
98
|
-
/**
|
|
99
|
-
* Admin from `<dirname(entry)>/admin.tsx`, or the built-in entry; an optional `admin.mcp.tsx` selects the host-safe graph.
|
|
100
|
-
* Uses `@aotter/mantle-admin/build` as the app installed
|
|
101
|
-
* it. The built-in entry is skipped when the installed Admin and its direct dependencies are unchanged since the last
|
|
102
|
-
* build; an application entry always builds, because what it imports is only known by building it.
|
|
103
|
-
*/
|
|
104
108
|
async function bundleAdmin(entry, outdir, out) {
|
|
105
109
|
const source = join(dirname(entry), "admin.tsx"), custom = existsSync(source);
|
|
106
110
|
const mcpSource = join(dirname(entry), "admin.mcp.tsx"), customMcp = existsSync(mcpSource);
|
|
@@ -114,7 +118,6 @@ async function bundleAdmin(entry, outdir, out) {
|
|
|
114
118
|
throw new Error(`The Worker serves Admin, but @aotter/mantle-admin/build does not resolve from ${relative(process.cwd(), entry)}. Install @aotter/mantle-admin at the version of @aotter/mantle-admin/server.`);
|
|
115
119
|
}
|
|
116
120
|
const stamp = join(outdir, ".admin-inputs");
|
|
117
|
-
// the installed path too: a patched or linked Admin keeps its version but not its path
|
|
118
121
|
const manifest = realpathSync(lookup(require, "@aotter/mantle-admin") ?? require.resolve("@aotter/mantle-admin/package.json"));
|
|
119
122
|
const key = custom || customMcp ? undefined : JSON.stringify({ out: resolve(out), path: dirname(manifest), packages: installed(manifest) });
|
|
120
123
|
if (key && existsSync(join(out, "index.html")) && existsSync(join(out, "mcp-app.html")) && existsSync(stamp) && readFileSync(stamp, "utf8") === key) {
|
|
@@ -127,9 +130,7 @@ async function bundleAdmin(entry, outdir, out) {
|
|
|
127
130
|
if (key)
|
|
128
131
|
writeFileSync(stamp, key);
|
|
129
132
|
}
|
|
130
|
-
/** The package.json a bare import of `name` finds, read from disk (`exports` may hide it, and require caches realpaths). */
|
|
131
133
|
const lookup = (require, name) => require.resolve.paths(name)?.map(dir => join(dir, name, "package.json")).find(file => existsSync(file));
|
|
132
|
-
/** The package's version and the installed version of each of its dependencies and peers (React comes from the application). */
|
|
133
134
|
function installed(manifest) {
|
|
134
135
|
const { name, version, dependencies = {}, peerDependencies = {} } = JSON.parse(readFileSync(manifest, "utf8"));
|
|
135
136
|
const require = createRequire(manifest);
|
|
@@ -140,13 +141,22 @@ function installed(manifest) {
|
|
|
140
141
|
}
|
|
141
142
|
return versions;
|
|
142
143
|
}
|
|
143
|
-
|
|
144
|
+
export class StaleGeneratedError extends Error {
|
|
145
|
+
dir;
|
|
146
|
+
summary;
|
|
147
|
+
constructor(dir, reasons) {
|
|
148
|
+
const summary = `Generated output in ${dir} is stale (${reasons.join("; ")}).`;
|
|
149
|
+
super(`${summary} Run your generate script (mantle generate ...) and rebuild.`);
|
|
150
|
+
this.dir = dir;
|
|
151
|
+
this.summary = summary;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
144
154
|
function generatedDirs(metafile) {
|
|
145
155
|
const dirs = [...new Set(Object.keys(metafile.inputs).map(input => resolve(input, "..")).filter(dir => existsSync(resolve(dir, INPUTS_FILE))))];
|
|
146
156
|
for (const dir of dirs) {
|
|
147
|
-
const
|
|
157
|
+
const stale = staleInputs(resolve(dir, "generated.ts"));
|
|
148
158
|
if (stale.length)
|
|
149
|
-
throw new
|
|
159
|
+
throw new StaleGeneratedError(relative(process.cwd(), dir), stale);
|
|
150
160
|
}
|
|
151
161
|
return dirs;
|
|
152
162
|
}
|
|
@@ -163,15 +173,12 @@ export async function bundle(entry, outdir) {
|
|
|
163
173
|
if (args.pluginData?.optionalPeer)
|
|
164
174
|
return;
|
|
165
175
|
const resolved = await builder.resolve(args.path, { importer: args.importer, resolveDir: args.resolveDir, kind: args.kind, pluginData: { optionalPeer: true } });
|
|
166
|
-
// Unselected request paths may reference absent peers; installed peers still bundle normally.
|
|
167
176
|
return resolved.errors.length ? { path: args.path, external: true } : resolved;
|
|
168
177
|
});
|
|
169
178
|
} }],
|
|
170
179
|
});
|
|
171
180
|
if (Object.keys(result.metafile.inputs).some(input => /(?:^|\/)mantle\/(?:src|dist)\/testing\//.test(resolve(input).replaceAll("\\", "/"))))
|
|
172
181
|
throw new Error("@aotter/mantle/testing is only for tests and cannot be bundled for production.");
|
|
173
|
-
// A build is what `wrangler dev` and deploys run: generated code must match the manifests it came from.
|
|
174
|
-
// Any bundled module from a generated directory finds its fingerprint (type-only imports leave generated.ts out).
|
|
175
182
|
generatedDirs(result.metafile);
|
|
176
183
|
const appSources = Object.keys(result.metafile.inputs).filter(input => !/node_modules|(?:^|\/)generated\/|^\.\.\//.test(input) && /\.[cm]?[jt]s$/.test(input)).map(input => [input, readFileSync(input, "utf8")]);
|
|
177
184
|
for (const [input, source] of appSources) {
|
|
@@ -7,7 +7,6 @@ import { parseArgs } from "node:util";
|
|
|
7
7
|
import { configureWrangler } from "./wrangler.js";
|
|
8
8
|
import { writeArtifacts } from "./artifacts.js";
|
|
9
9
|
import { INPUTS_FILE, inputsFingerprint } from "./inputs.js";
|
|
10
|
-
/** Option modules are often .ts, which older Node rejects with a bare "Unknown file extension". */
|
|
11
10
|
async function load(path) {
|
|
12
11
|
try {
|
|
13
12
|
return await import(pathToFileURL(resolve(path)).href);
|
|
@@ -19,13 +18,27 @@ async function load(path) {
|
|
|
19
18
|
}
|
|
20
19
|
}
|
|
21
20
|
const targets = ["bun-sqlite", "bun-postgres", "cloudflare-d1", "cloudflare-postgres"];
|
|
22
|
-
const usage = "Usage: mantle generate <target> <output-directory> <manifest>... [--check] [--admin] [--site <module>] [--auth <module>]... (one per Better Auth instance) [--wrangler <path>] [--entry <worker entry>] [--outdir <bundle directory>] [--assets <static assets directory>]\n mantle bundle <entry> <outdir> [--assets <static assets directory>]\n mantle describe <target> <manifest>... [--diff <old.json>]";
|
|
21
|
+
const usage = "Usage: mantle help [topic] (start here: the installed docs, one topic at a time)\n mantle init [directory] [--dry-run]\n mantle generate <target> <output-directory> <manifest>... [--check] [--admin] [--site <module>] [--auth <module>]... (one per Better Auth instance) [--wrangler <path>] [--entry <worker entry>] [--outdir <bundle directory>] [--assets <static assets directory>]\n mantle bundle <entry> <outdir> [--assets <static assets directory>]\n mantle describe <target> <manifest>... [--diff <old.json>]";
|
|
23
22
|
try {
|
|
24
23
|
const { values, positionals } = parseArgs({ allowPositionals: true, options: {
|
|
25
|
-
diff: { type: "string" }, check: { type: "boolean" }, admin: { type: "boolean" }, site: { type: "string" }, auth: { type: "string", multiple: true }, wrangler: { type: "string" }, entry: { type: "string", default: "src/worker.ts" }, outdir: { type: "string", default: "dist" }, assets: { type: "string" },
|
|
24
|
+
diff: { type: "string" }, check: { type: "boolean" }, admin: { type: "boolean" }, site: { type: "string" }, auth: { type: "string", multiple: true }, wrangler: { type: "string" }, entry: { type: "string", default: "src/worker.ts" }, outdir: { type: "string", default: "dist" }, assets: { type: "string" }, "dry-run": { type: "boolean" }, help: { type: "boolean", short: "h" },
|
|
26
25
|
} });
|
|
27
|
-
const
|
|
28
|
-
|
|
26
|
+
const named = ["bundle", "generate", "describe", "help", "init"].includes(positionals[0]) ? positionals.shift() : undefined;
|
|
27
|
+
const command = values.help ? "usage" : named ?? (process.argv.length > 2 ? "generate" : "help");
|
|
28
|
+
if (command === "usage")
|
|
29
|
+
console.log(`${usage}\n\nStart with mantle help: the installed docs, one topic at a time.`);
|
|
30
|
+
else if (command === "help") {
|
|
31
|
+
const { help, topicList } = await import("./help.js");
|
|
32
|
+
if (positionals.length > 1)
|
|
33
|
+
throw new Error(usage);
|
|
34
|
+
console.log(positionals[0] === "topics" ? topicList() : help(positionals[0]));
|
|
35
|
+
}
|
|
36
|
+
else if (command === "init") {
|
|
37
|
+
if (positionals.length > 1)
|
|
38
|
+
throw new Error(usage);
|
|
39
|
+
console.log((await import("./init.js")).init(positionals[0] ?? ".", values["dry-run"]));
|
|
40
|
+
}
|
|
41
|
+
else if (command === "describe") {
|
|
29
42
|
const target = positionals.shift();
|
|
30
43
|
if (!target || !targets.includes(target) || !positionals.length)
|
|
31
44
|
throw new Error(usage);
|
|
@@ -47,15 +60,16 @@ try {
|
|
|
47
60
|
const [entry, outdir] = positionals;
|
|
48
61
|
if (!entry || !outdir || positionals.length !== 2 || values.admin || values.site || values.auth || values.wrangler || values.check)
|
|
49
62
|
throw new Error(usage);
|
|
50
|
-
await (await import("./bundle.js")).bundleApp(entry, outdir, values.assets);
|
|
63
|
+
await (await import("./bundle.js")).bundleApp(entry, outdir, values.assets, { watch: process.env.WRANGLER_COMMAND === "dev" });
|
|
51
64
|
}
|
|
52
65
|
else {
|
|
53
66
|
const [target, destination, ...paths] = positionals;
|
|
54
67
|
if (!target || !targets.includes(target) || !destination || !paths.length)
|
|
55
68
|
throw new Error(usage);
|
|
69
|
+
for (const path of paths)
|
|
70
|
+
if (/[*?]/.test(path) && !existsSync(path))
|
|
71
|
+
throw new Error(`No manifest matches ${path}. Add one (mantle help four-atoms, mantle help manifest), then run generate again.`);
|
|
56
72
|
const sources = await Promise.all(paths.map(async (path) => ({ sourceId: path, text: await readFile(path, "utf8") })));
|
|
57
|
-
// Read plain options without constructing Better Auth, which can touch the database.
|
|
58
|
-
// One --auth per Better Auth instance; their tables are merged into one migration.
|
|
59
73
|
const auth = await Promise.all((values.auth ?? []).map(async (path) => {
|
|
60
74
|
const loaded = await load(path);
|
|
61
75
|
const options = loaded.authOptions?.(new Proxy({}, { get: () => undefined })) ?? loaded.auth?.options;
|
|
@@ -74,6 +88,8 @@ try {
|
|
|
74
88
|
console.warn(`warning: ${warning}`);
|
|
75
89
|
const read = [...paths, ...(values.auth ?? []), ...(values.site ? [values.site] : [])];
|
|
76
90
|
const changed = await writeArtifacts(destination, { ...result.files, [INPUTS_FILE]: inputsFingerprint(destination, read, target) }, values.check);
|
|
91
|
+
for (const warning of await (await import("./mounts.js")).unmountedWarnings(values.entry, destination, result.files))
|
|
92
|
+
console.warn(`warning: ${warning}`);
|
|
77
93
|
if (target.startsWith("cloudflare-")) {
|
|
78
94
|
const wrangler = values.wrangler ?? ["wrangler.jsonc", "wrangler.json", "wrangler.toml"].find(existsSync) ?? "wrangler.jsonc";
|
|
79
95
|
const assets = values.admin ? values.assets ?? join(values.outdir, "client") : undefined;
|
|
@@ -85,10 +101,8 @@ try {
|
|
|
85
101
|
throw new Error(`Generated output is stale. Run mantle generate with the same options:\n${changed.join("\n")}`);
|
|
86
102
|
if (!values.check)
|
|
87
103
|
console.log(`Commit generated output and Wrangler config edits; keep dist/ untracked. Add to .gitattributes: ${JSON.stringify(relative(process.cwd(), resolve(destination)).replaceAll("\\", "/") + "/**")} linguist-generated=true`);
|
|
88
|
-
|
|
89
|
-
console.log(`Ref
|
|
90
|
-
if (result.refProcedures.length)
|
|
91
|
-
console.log("Next: run tsc --noEmit; pass the handlers at store(db, caller, { handlers }).");
|
|
104
|
+
if (!values.check && result.refProcedures.length)
|
|
105
|
+
console.log(`Ref Procedures need these Handlers keys (tsc --noEmit checks them; pass them at store(db, caller, { handlers })): ${result.refProcedures.map(name => JSON.stringify(name)).join(", ")}`);
|
|
92
106
|
}
|
|
93
107
|
}
|
|
94
108
|
catch (error) {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { describeApp } from "../domain/describe/index.js";
|
|
2
|
-
|
|
3
|
-
import { compilePlan, viewOutputs, sqliteCompiler, postgresCompiler, d1Dialect, postgresDialect, lowerPlan, sqliteSchema, postgresSchema } from "../build/compiler.js";
|
|
2
|
+
import { compilePlan, lockCondition, viewOutputs, sqliteCompiler, postgresCompiler, d1Dialect, postgresDialect, lowerPlan, sqliteSchema, postgresSchema } from "../build/compiler.js";
|
|
4
3
|
import { wireReturning, wireScalars } from "../domain/wireScalars.js";
|
|
5
4
|
import { boundaryType, outputType } from "../domain/columns.js";
|
|
6
5
|
import { castCursor, provePaging } from "../domain/paging.js";
|
|
@@ -12,12 +11,11 @@ import { publishingSql } from "../usecase/publishing.js";
|
|
|
12
11
|
import { searchDiagnostics } from "../usecase/search.js";
|
|
13
12
|
import { generate } from "../usecase/generate.js";
|
|
14
13
|
import { generatedFiles } from "../domain/entry.js";
|
|
15
|
-
|
|
14
|
+
const coreLocks = (dialect, schemas) => Object.fromEntries(Object.entries(schemas).flatMap(([name, s]) => s.locks?.length ? [[name, s.locks.map(l => ({
|
|
15
|
+
name: l.name, on: l.on, message: l.message,
|
|
16
|
+
sql: dialect.print({ SelectStmt: { targetList: [{ ResTarget: { val: lockCondition(l.when, schemas, dialect.lowering) } }], limitOption: "LIMIT_OPTION_DEFAULT", op: "SETOP_NONE" } }, schemas).replace(/^SELECT /, ""),
|
|
17
|
+
}))]] : []));
|
|
16
18
|
export const coreNewId = (dialect, schemas) => dialect.print({ SelectStmt: { targetList: [{ ResTarget: { val: dialect.lowering.newId() } }], limitOption: "LIMIT_OPTION_DEFAULT", op: "SETOP_NONE" } }, schemas).replace(/^SELECT /, "");
|
|
17
|
-
// JSON, date-time and date inputs are driver-ready text. Declaring their parameter as text makes every driver send it verbatim:
|
|
18
|
-
// Bun.SQL would JSON-encode a string bound to jsonb a second time, and postgres.js turns one bound to timestamptz or date into a
|
|
19
|
-
// JavaScript Date, which loses microseconds. Core prints `CAST($n AS <type>)`; the PostgreSQL dialect spells what replaces it (the
|
|
20
|
-
// same cast the Store writes with). Any other use of such a parameter is refused.
|
|
21
19
|
const CORE_CAST = { json: "jsonb", timestamptz: "timestamptz", date: "date" };
|
|
22
20
|
function castInputs(statement, path, refusals) {
|
|
23
21
|
let sql = statement.sql;
|
|
@@ -32,20 +30,26 @@ function castInputs(statement, path, refusals) {
|
|
|
32
30
|
}
|
|
33
31
|
Object.assign(statement, { sql });
|
|
34
32
|
}
|
|
35
|
-
/** Operational records are added in Admin only through a Procedure placed with `uiSchema.collectionAction`; name the Schemas left without one. */
|
|
36
33
|
function adminCreateWarnings(plan) {
|
|
37
34
|
const placed = new Set(Object.values(plan.procedures).map(p => p.uiSchema?.["collectionAction"]).filter((name) => typeof name === "string").map(name => name.toLowerCase()));
|
|
38
35
|
return Object.values(plan.schemas).filter(s => {
|
|
39
36
|
const required = new Set(s.schema.required ?? []);
|
|
40
|
-
|
|
41
|
-
const folded = Object.entries(s.schema.properties ?? {}).some(([name, property]) => required.has(name) && typeof property?.["x-mantle-ref"] === "string");
|
|
42
|
-
// Field-scoped rows belong to their members, who create them.
|
|
37
|
+
const folded = Object.entries(s.schema.properties ?? {}).some(([name, property]) => required.has(name) && property?.["x-mantle-ref"] != null);
|
|
43
38
|
return !s.publishing && s.schema.readOnly !== true && !s.scope && !s.translates && !folded && !placed.has(s.name.toLowerCase());
|
|
44
|
-
}).map(s => `Schema '${s.name}' is operational and writable, but no Procedure places a create action on it, so Admin cannot add its records. Add a Procedure with uiSchema.collectionAction: ${s.name} and an admin MCP Trigger, or mark the Schema readOnly
|
|
39
|
+
}).map(s => `Schema '${s.name}' is operational and writable, but no Procedure places a create action on it, so Admin cannot add its records. Add a Procedure with uiSchema.collectionAction: ${s.name} and an admin MCP Trigger, or mark the Schema readOnly.${s.userScopes?.length ? ` That Procedure takes ${s.userScopes.map(u => `'${u.column}'`).join(', ')} as an optional input for callers holding { all: true } (field scope in the Schema reference).` : ''}`).concat(Object.values(plan.schemas)
|
|
40
|
+
.filter(s => s.schema.readOnly !== true && !placed.has(s.name.toLowerCase()))
|
|
41
|
+
.flatMap(s => Object.entries(s.schema.properties ?? {})
|
|
45
42
|
.filter(([name, property]) => property?.readOnly === true && (s.schema.required ?? []).includes(name) && property.default === undefined)
|
|
46
|
-
.map(([name]) => `Schema '${s.name}' field '${name}' is required and readOnly without a default, so Admin
|
|
43
|
+
.map(([name]) => `Schema '${s.name}' field '${name}' is required and readOnly without a default, so Admin's generic create cannot fill it. Give it a default (Admin stores it on create), or create the records through a Procedure with uiSchema.collectionAction: ${s.name}.`)));
|
|
44
|
+
}
|
|
45
|
+
function adminSearchWarnings(plan) {
|
|
46
|
+
return Object.values(plan.schemas).filter(s => !s.translates && !s.search?.length).flatMap(s => {
|
|
47
|
+
const properties = (s.schema.properties ?? {});
|
|
48
|
+
const primary = s.uiSchema?.["list"]?.primaryField;
|
|
49
|
+
const field = [primary, "title"].find((f) => typeof f === "string" && [properties[f]?.type].flat().includes("string") && !["date", "date-time"].includes(String(properties[f]?.format)) && properties[f]?.["x-mantle-ref"] == null);
|
|
50
|
+
return field ? [`Schema '${s.name}' declares no searchableFields, so Admin's list search matches the entry id only and a search for its '${field}' finds nothing. Add searchableFields: [${field}] to search it.`] : [];
|
|
51
|
+
});
|
|
47
52
|
}
|
|
48
|
-
/** Warn about SQL's case folding without changing the authored output names. */
|
|
49
53
|
function outputNameWarnings(plan) {
|
|
50
54
|
const warnings = [];
|
|
51
55
|
const inspect = (kind, name, stmts) => {
|
|
@@ -113,8 +117,6 @@ export async function generateFromSources(request, schema) {
|
|
|
113
117
|
const refusals = [], paging = {};
|
|
114
118
|
const lower = (boundary) => lowerPlan(compiled.plan, dialect, {
|
|
115
119
|
pageQuery(ast, name) {
|
|
116
|
-
// Core visits public/caller before trusted. Prove the real caller equality prefixes;
|
|
117
|
-
// a trusted all-scope read does not acquire a fictional scope predicate.
|
|
118
120
|
if (!Object.hasOwn(paging, name))
|
|
119
121
|
paging[name] = provePaging(compiled.plan, ast);
|
|
120
122
|
return ast;
|
|
@@ -149,6 +151,8 @@ export async function generateFromSources(request, schema) {
|
|
|
149
151
|
if (!seen.has(statement)) {
|
|
150
152
|
seen.add(statement);
|
|
151
153
|
castInputs(statement, `${kind}/${name}`, refusals);
|
|
154
|
+
if (statement.probe)
|
|
155
|
+
castInputs(statement.probe, `${kind}/${name}`, refusals);
|
|
152
156
|
if ("paged" in statement)
|
|
153
157
|
for (const page of Object.values(statement.paged)) {
|
|
154
158
|
const inputPage = { ...statement, sql: page.sql };
|
|
@@ -163,14 +167,17 @@ export async function generateFromSources(request, schema) {
|
|
|
163
167
|
const adminReads = request.admin ? await adminBindingsOf(compiled.plan, dialect, paging, wire, refusals) : undefined;
|
|
164
168
|
const { adminPages, adminEntries } = adminReads ?? {};
|
|
165
169
|
const report = await describeApp(compiled.plan);
|
|
166
|
-
const result = generate({ description: report, target: request.target, admin: request.admin, adminPages, adminEntries, site: request.site, plan: compiled.plan, lowered, wireLowered, viewKeys, newId: coreNewId(dialect, compiled.plan.schemas), paging });
|
|
170
|
+
const result = generate({ description: report, target: request.target, admin: request.admin, adminPages, adminEntries, site: request.site, plan: compiled.plan, lowered, wireLowered, viewKeys, newId: coreNewId(dialect, compiled.plan.schemas), locks: coreLocks(dialect, compiled.plan.schemas), paging });
|
|
167
171
|
if (refusals.length) {
|
|
168
172
|
const diagnostics = result.ok ? [] : result.diagnostics.filter(d => !searchProblems.some(p => p.path === d.path) ||
|
|
169
173
|
!["Every reachable visibility mode must have canonical lowered SQL.", "Both caller and trusted SQL must be lowered."].includes(d.message));
|
|
170
174
|
return { ok: false, diagnostics: [...refusals, ...diagnostics] };
|
|
171
175
|
}
|
|
172
176
|
if (!result.ok)
|
|
173
|
-
return result
|
|
177
|
+
return { ...result, diagnostics: result.diagnostics.map(d => {
|
|
178
|
+
const why = d.message === "Both caller and trusted SQL must be lowered." ? warnings.find(w => w.includes(`${d.path} (caller)`) || w.includes(`${d.path} (admin)`)) : undefined;
|
|
179
|
+
return why ? { ...d, message: `${d.message} ${why.slice(why.indexOf("): ") + 3)}` } : d;
|
|
180
|
+
}) };
|
|
174
181
|
const accessTargets = Object.fromEntries(report.targets.map(({ _meta: m }) => {
|
|
175
182
|
const policy = ({ opaque, requires }) => ({ opaque, ...(requires.guard ? { guard: requires.guard } : {}) });
|
|
176
183
|
return [m.id, { targetHash: m.targetHash, ...policy(m), ...(m.variants.length ? { variants: m.variants.map(v => ({ id: v.id, ...policy(v) })) } : {}) }];
|
|
@@ -178,5 +185,5 @@ export async function generateFromSources(request, schema) {
|
|
|
178
185
|
const gatewayFiles = generatedFiles({ "access-targets.ts": `import type { AccessTarget } from "@aotter/mantle/contracts";\nexport const targets: Readonly<Record<string, AccessTarget>> = ${JSON.stringify(accessTargets)};\n` });
|
|
179
186
|
const ddl = [...(postgres ? postgresSchema : sqliteSchema)(compiled.plan.schemas), ...publishingSql(compiled.plan.schemas, !postgres), ...(request.admin ? adminIndexes(compiled.plan.schemas, !postgres) : []), ...(schema?.() ?? [])];
|
|
180
187
|
const { "site-schema.sql": siteSql = "", ...files } = result.files;
|
|
181
|
-
return { ...result, warnings: [...missingMcpWarnings(compiled.plan), ...outputNameWarnings(compiled.plan), ...(request.admin ? adminCreateWarnings(compiled.plan) : []), ...(compiled.plan.access?.warnings ?? [])], files: { ...files, ...gatewayFiles, ...generatedFiles({ "schema.sql": ddl.join(";\n\n") + ";\n" + siteSql }) } };
|
|
188
|
+
return { ...result, warnings: [...missingMcpWarnings(compiled.plan), ...outputNameWarnings(compiled.plan), ...(request.admin ? [...adminCreateWarnings(compiled.plan), ...adminSearchWarnings(compiled.plan)] : []), ...(compiled.plan.access?.warnings ?? [])], files: { ...files, ...gatewayFiles, ...generatedFiles({ "schema.sql": ddl.join(";\n\n") + ";\n" + siteSql }) } };
|
|
182
189
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { generateFromSources as generate } from "./generate.js";
|
|
2
2
|
import { admin, anonymous, jwt, phoneNumber, siwe, twoFactor } from "better-auth/plugins";
|
|
3
3
|
import { authSchemaSql } from "./authSchema.js";
|
|
4
|
-
/** One or more Better Auth instances (each with its own table names); their DDL is merged. */
|
|
5
4
|
export function generateFromSources(request) {
|
|
6
5
|
const instances = request.auth ? [request.auth].flat() : [];
|
|
7
6
|
return generate(request, instances.length ? () => authSql(instances, request.target.endsWith("postgres") ? "postgres" : "sqlite") : undefined);
|
|
@@ -9,29 +8,23 @@ export function generateFromSources(request) {
|
|
|
9
8
|
function authSql(instances, dialect) {
|
|
10
9
|
for (const options of instances) {
|
|
11
10
|
const ids = (options.plugins ?? []).map(plugin => plugin.id);
|
|
12
|
-
|
|
13
|
-
if (ids.includes("oauth-provider") && !ids.includes("mantle-oauth-grant-revocation") /* oauthGrantRevocation().id */)
|
|
11
|
+
if (ids.includes("oauth-provider") && !ids.includes("mantle-oauth-grant-revocation") )
|
|
14
12
|
throw new Error("Better Auth has an OAuth provider (oauthProvider or mcp) without oauthGrantRevocation(): add it from @aotter/mantle/auth/oauth to the plugins, so a password reset, an admin ban or session revocation ends that user's OAuth grants.");
|
|
15
13
|
}
|
|
16
14
|
if (instances.length > 1)
|
|
17
15
|
refuseSharedPluginMappings(instances);
|
|
18
16
|
const statements = [...new Set(instances.flatMap(options => authSchemaSql(options, dialect)))];
|
|
19
|
-
// CREATE TABLE IF NOT EXISTS would silently keep the first of two different definitions.
|
|
20
|
-
// Identical statements are already merged, so a repeated table name means two different definitions.
|
|
21
17
|
const tables = statements.map(statement => /^CREATE TABLE IF NOT EXISTS ("(?:[^"]|"")+")/.exec(statement)?.[1]).filter(Boolean);
|
|
22
18
|
const twice = tables.find((table, i) => tables.indexOf(table) !== i);
|
|
23
19
|
if (twice)
|
|
24
20
|
throw new Error(`Better Auth instances define table ${twice} differently; give each instance its own table names (user.modelName, session.modelName, ...).`);
|
|
25
21
|
return statements;
|
|
26
22
|
}
|
|
27
|
-
// Plugins whose factory hands back one module-level schema object (mergeSchema writes `schema` options into it); no options leaves it untouched.
|
|
28
23
|
const sharedSchema = {
|
|
29
24
|
jwt: () => jwt().schema, admin: () => admin().schema, anonymous: () => anonymous().schema,
|
|
30
25
|
"phone-number": () => phoneNumber({}).schema, siwe: () => siwe({}).schema, "two-factor": () => twoFactor().schema,
|
|
31
26
|
};
|
|
32
|
-
// Plugins that share field objects (device-authorization) or copy per call (oauth-provider, listed on request) are only checked for equal resolved names.
|
|
33
27
|
const checkedPlugins = new Set([...Object.keys(sharedSchema), "device-authorization", "oauth-provider"]);
|
|
34
|
-
/** Names a plugin's tables and fields resolve to, keyed `table` / `table.field`; only mappings that differ from the key are listed. */
|
|
35
28
|
function mapped(schema) {
|
|
36
29
|
const names = new Map();
|
|
37
30
|
for (const [table, model] of Object.entries(schema)) {
|
|
@@ -45,7 +38,6 @@ function mapped(schema) {
|
|
|
45
38
|
}
|
|
46
39
|
return names;
|
|
47
40
|
}
|
|
48
|
-
/** Better Auth plugin factories share their schema object, so differing plugin field/model mappings across instances in one Worker override each other. */
|
|
49
41
|
function refuseSharedPluginMappings(instances) {
|
|
50
42
|
const byPlugin = new Map();
|
|
51
43
|
instances.forEach((options, index) => (options.plugins ?? []).forEach(plugin => {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, relative, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
export const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
5
|
+
export function docsRoot() {
|
|
6
|
+
const root = [resolve(packageRoot, "docs"), resolve(packageRoot, "../../docs")].find(path => existsSync(resolve(path, "handbook/navigation.json")));
|
|
7
|
+
if (!root)
|
|
8
|
+
throw new Error(`Mantle docs are missing from ${packageRoot}; reinstall @aotter/mantle.`);
|
|
9
|
+
return root;
|
|
10
|
+
}
|
|
11
|
+
export function topics(root = docsRoot()) {
|
|
12
|
+
const nav = JSON.parse(readFileSync(resolve(root, "handbook/navigation.json"), "utf8"));
|
|
13
|
+
const pages = nav.groups.flatMap(group => group.items.map(item => ({ name: item.link.slice(1), title: item.text, file: resolve(root, `handbook${item.link}.md`), group: group.text })));
|
|
14
|
+
const listed = new Set(pages.map(page => page.file));
|
|
15
|
+
const unlisted = readdirSync(resolve(root, "handbook"), { recursive: true }).filter(path => path.endsWith(".md")).sort()
|
|
16
|
+
.map(path => resolve(root, "handbook", path)).filter(file => !listed.has(file))
|
|
17
|
+
.map(file => ({ name: relative(resolve(root, "handbook"), file).replaceAll("\\", "/").slice(0, -3), title: readFileSync(file, "utf8").match(/^# (.+)$/m)?.[1] ?? basename(file), file, group: "More pages" }));
|
|
18
|
+
const skills = (existsSync(resolve(root, "skills")) ? readdirSync(resolve(root, "skills")) : []).filter(name => existsSync(resolve(root, "skills", name, "SKILL.md")))
|
|
19
|
+
.map(name => ({ name: `skills/${name}`, title: `Package skill: ${name}`, file: resolve(root, "skills", name, "SKILL.md"), group: "Package skills" }));
|
|
20
|
+
const examples = resolve(root, "examples");
|
|
21
|
+
const shipped = readdirSync(examples, { withFileTypes: true }).filter(entry => entry.isDirectory() && existsSync(resolve(examples, entry.name, "README.md")));
|
|
22
|
+
return [...pages, ...unlisted, ...skills, { name: "examples", title: "Examples and how to combine them", file: resolve(examples, "README.md"), group: "Examples" },
|
|
23
|
+
...shipped.map(entry => ({ name: `examples/${entry.name}`, title: `Example: ${entry.name}`, file: resolve(examples, entry.name, "README.md"), group: "Examples" }))];
|
|
24
|
+
}
|
|
25
|
+
function find(all, name) {
|
|
26
|
+
const exact = all.find(topic => topic.name === name);
|
|
27
|
+
if (exact)
|
|
28
|
+
return exact;
|
|
29
|
+
const tail = all.filter(topic => basename(topic.name) === name);
|
|
30
|
+
return tail.length === 1 ? tail[0] : undefined;
|
|
31
|
+
}
|
|
32
|
+
export function help(name, root = docsRoot()) {
|
|
33
|
+
const all = topics(root);
|
|
34
|
+
const topic = find(all, name ?? "start/agents");
|
|
35
|
+
if (!topic) {
|
|
36
|
+
const near = all.filter(t => name && t.name.includes(name)).map(t => ` mantle help ${t.name}`);
|
|
37
|
+
throw new Error(`No help topic ${JSON.stringify(name)}.${near.length ? `\nDid you mean:\n${near.join("\n")}` : ""}\nRun mantle help topics for the list.`);
|
|
38
|
+
}
|
|
39
|
+
const text = readFileSync(topic.file, "utf8").replace(/^---\n[\s\S]*?\n---\n/, "").trim();
|
|
40
|
+
const linked = [...new Set([...text.matchAll(/\]\(([^)#\s]+\.md)(?:#[^)]*)?\)/g)].map(match => resolve(dirname(topic.file), match[1])))]
|
|
41
|
+
.flatMap(file => all.filter(t => t.file === file && t !== topic));
|
|
42
|
+
const index = all.indexOf(topic), next = all[index + 1]?.group === topic.group ? all[index + 1] : undefined;
|
|
43
|
+
const lines = [text, "", `Source: ${relative(process.cwd(), topic.file) || topic.file}`];
|
|
44
|
+
if (next)
|
|
45
|
+
lines.push(`Next: mantle help ${next.name} (${next.title})`);
|
|
46
|
+
if (linked.length)
|
|
47
|
+
lines.push("Linked:", ...linked.map(t => ` mantle help ${t.name} (${t.title})`));
|
|
48
|
+
if (!name)
|
|
49
|
+
lines.push("", topicList(all));
|
|
50
|
+
return lines.join("\n");
|
|
51
|
+
}
|
|
52
|
+
export function topicList(all = topics()) {
|
|
53
|
+
const groups = [...new Set(all.map(topic => topic.group))];
|
|
54
|
+
return ["Topics:", ...groups.flatMap(group => [` ${group}`, ...all.filter(t => t.group === group).map(t => ` ${t.name.padEnd(34)} ${t.title}`)])].join("\n");
|
|
55
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { docsRoot, packageRoot } from "./help.js";
|
|
4
|
+
const LOCAL = new Set(["node_modules", ".wrangler", ".dev.vars", "pnpm-lock.yaml", "generated", "dist"]);
|
|
5
|
+
function files(directory) {
|
|
6
|
+
return readdirSync(directory, { withFileTypes: true }).flatMap(entry => LOCAL.has(entry.name) ? []
|
|
7
|
+
: entry.isDirectory() ? files(join(directory, entry.name)).map(file => join(entry.name, file)) : [entry.name]);
|
|
8
|
+
}
|
|
9
|
+
export function init(destination, dryRun = false) {
|
|
10
|
+
const shell = resolve(docsRoot(), "examples/app-shell");
|
|
11
|
+
const sdk = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
|
|
12
|
+
const target = resolve(destination);
|
|
13
|
+
const all = files(shell).map(file => file === "gitignore" ? ".gitignore" : file);
|
|
14
|
+
const source = (file) => file === ".gitignore" && !existsSync(join(shell, file)) ? join(shell, "gitignore") : join(shell, file);
|
|
15
|
+
const existing = all.filter(file => existsSync(join(target, file)));
|
|
16
|
+
if (existing.length)
|
|
17
|
+
throw new Error(`mantle init writes the app shell once and never overwrites. These files already exist in ${target}:\n${existing.map(f => ` ${f}`).join("\n")}\nMove them aside or run mantle init in an empty directory; to use generated code inside an existing app, skip init and run mantle help typed-queries.`);
|
|
18
|
+
const notes = [];
|
|
19
|
+
for (const file of all) {
|
|
20
|
+
let text = readFileSync(source(file), "utf8");
|
|
21
|
+
if (file === "package.json") {
|
|
22
|
+
const pkg = JSON.parse(text);
|
|
23
|
+
for (const name of Object.keys(pkg.dependencies))
|
|
24
|
+
if (name.startsWith("@aotter/mantle"))
|
|
25
|
+
pkg.dependencies[name] = sdk.version;
|
|
26
|
+
text = JSON.stringify(pkg, null, 2) + "\n";
|
|
27
|
+
}
|
|
28
|
+
if (!file.endsWith(".md"))
|
|
29
|
+
text.split("\n").forEach((line, index) => { if (line.includes("AGENT:"))
|
|
30
|
+
notes.push(` ${file}:${index + 1}: ${line.slice(line.indexOf("AGENT:") + 6).trim()}`); });
|
|
31
|
+
if (dryRun)
|
|
32
|
+
continue;
|
|
33
|
+
mkdirSync(dirname(join(target, file)), { recursive: true });
|
|
34
|
+
writeFileSync(join(target, file), text, { flag: "wx" });
|
|
35
|
+
}
|
|
36
|
+
if (!dryRun)
|
|
37
|
+
mkdirSync(join(target, "manifests"), { recursive: true });
|
|
38
|
+
const where = relative(process.cwd(), target) || ".";
|
|
39
|
+
return [
|
|
40
|
+
`${dryRun ? "Would write" : "Wrote"} the Mantle ${sdk.version} app shell to ${where}:`,
|
|
41
|
+
...all.map(file => ` ${file}`), " manifests/",
|
|
42
|
+
"",
|
|
43
|
+
"AGENT: the app must fill in these before it is done (each is marked in the file):",
|
|
44
|
+
" manifests/*.yaml: the app's Schemas, Views, Procedures and Triggers (mantle help four-atoms)",
|
|
45
|
+
...notes,
|
|
46
|
+
"",
|
|
47
|
+
`Next: cd ${where} && pnpm install; then follow README.md. mantle help explains each step;`,
|
|
48
|
+
"mantle help examples shows the patterns to take for each need. Accept with mantle help agent-workflow-acceptance before reporting done.",
|
|
49
|
+
].join("\n");
|
|
50
|
+
}
|
|
@@ -1,23 +1,17 @@
|
|
|
1
|
-
/** `mantle generate` fingerprints its inputs next to the output; `mantle bundle` refuses output whose inputs changed since. */
|
|
2
1
|
import { createHash } from "node:crypto";
|
|
3
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
3
|
import { dirname, relative, resolve } from "node:path";
|
|
5
4
|
export const INPUTS_FILE = ".mantle-inputs.json";
|
|
6
|
-
/** The installed `@aotter/mantle` version (dist/infrastructure → package root). */
|
|
7
5
|
const MANTLE_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
|
|
8
|
-
// Line endings are normalized so a CRLF checkout (core.autocrlf) matches the LF fingerprint.
|
|
9
6
|
const digest = (path) => existsSync(path) ? createHash("sha256").update(readFileSync(path, "utf8").replaceAll("\r\n", "\n")).digest("hex") : null;
|
|
10
|
-
/** The target and the manifests and option modules (`--auth`, `--site`) one generate run read, by path relative to its output. */
|
|
11
7
|
export function inputsFingerprint(destination, paths, target) {
|
|
12
8
|
const inputs = Object.fromEntries(paths.map(path => [relative(resolve(destination), resolve(path)).replaceAll("\\", "/"), digest(path)]));
|
|
13
9
|
return JSON.stringify({ mantle: MANTLE_VERSION, target, inputs }, null, 2) + "\n";
|
|
14
10
|
}
|
|
15
|
-
/** The generate target recorded beside `generatedModule` (undefined without a fingerprint, or one written before targets were recorded). */
|
|
16
11
|
export function generatedTarget(generatedModule) {
|
|
17
12
|
const file = resolve(dirname(generatedModule), INPUTS_FILE);
|
|
18
13
|
return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")).target : undefined;
|
|
19
14
|
}
|
|
20
|
-
/** Why the generated output that holds `generatedModule` no longer matches its inputs (empty when it does, or when it has no fingerprint). */
|
|
21
15
|
export function staleInputs(generatedModule) {
|
|
22
16
|
const directory = dirname(generatedModule), file = resolve(directory, INPUTS_FILE);
|
|
23
17
|
if (!existsSync(file))
|
|
@@ -29,13 +23,10 @@ export function staleInputs(generatedModule) {
|
|
|
29
23
|
stale.push(`${relative(process.cwd(), resolve(directory, path))} changed`);
|
|
30
24
|
return stale;
|
|
31
25
|
}
|
|
32
|
-
/** Better Auth roles an app declares beyond `admin` and `user` (`<name>: ac.newRole(...)`), for a one-line design note. */
|
|
33
26
|
export function extraRoles(source) {
|
|
34
27
|
return [...source.matchAll(/["']?([A-Za-z][\w-]*)["']?\s*:\s*ac\.newRole\(/g)].map(m => m[1]).filter(role => role !== "admin" && role !== "user");
|
|
35
28
|
}
|
|
36
|
-
/** Better Auth tables an app's own code queries with SQL (`FROM "user"`, `JOIN session` ...), for a one-line note. */
|
|
37
29
|
export function authTableSql(source) {
|
|
38
|
-
// Only lines that read like SQL statements, so prose such as "take into account" is not a finding.
|
|
39
30
|
const sql = source.split("\n").filter(line => /\b(?:SELECT|INSERT|UPDATE|DELETE)\b/.test(line)).join("\n");
|
|
40
31
|
return [...new Set([...sql.matchAll(/\b(?:FROM|JOIN|UPDATE|INTO)\s+"?(user|session|account|verification)"?(?=[\s;),`'"]|$)/gi)].map(m => m[1].toLowerCase()))];
|
|
41
32
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { relative, resolve } from "node:path";
|
|
3
|
+
import { ownModules } from "./bundle.js";
|
|
4
|
+
const SURFACES = [
|
|
5
|
+
{ module: "rest.ts", selected: files => files["rest.ts"]?.includes("pattern: new RegExp") ?? false, what: "REST routes", topic: "service-entry" },
|
|
6
|
+
{ module: "mcp.ts", selected: files => "mcp.ts" in files, what: "MCP tools", topic: "service-entry" },
|
|
7
|
+
{ module: "admin-bindings.ts", selected: files => "admin-bindings.ts" in files, what: "Admin bindings", topic: "native-admin" },
|
|
8
|
+
];
|
|
9
|
+
export async function unmountedWarnings(entry, destination, files) {
|
|
10
|
+
const selected = SURFACES.filter(surface => surface.selected(files));
|
|
11
|
+
if (!selected.length || !existsSync(entry))
|
|
12
|
+
return [];
|
|
13
|
+
let inputs;
|
|
14
|
+
try {
|
|
15
|
+
inputs = Object.keys((await ownModules(entry)).inputs).map(path => resolve(path));
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
const first = error.errors?.[0]?.text ?? String(error);
|
|
19
|
+
return [`skipped checking which generated surfaces ${entry} serves: could not follow its imports (${first}).`];
|
|
20
|
+
}
|
|
21
|
+
return selected.filter(surface => !inputs.includes(resolve(destination, surface.module))).map(surface => `${surface.what} are generated in ${relative(process.cwd(), resolve(destination, surface.module))}, but ${entry} never imports it, so no request reaches them. Mount it in the entry (mantle help ${surface.topic}), or pass --entry if the app starts elsewhere.`);
|
|
22
|
+
}
|