@usegraft/cli 0.0.0-canary-20260831153011
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/LICENSE +21 -0
- package/README.md +64 -0
- package/dist/add-ASXTMYCX.js +61 -0
- package/dist/approvals-OEEQQN75.js +14 -0
- package/dist/asset-DMNX5JBZ.js +49 -0
- package/dist/branch-IDY42TAV.js +75 -0
- package/dist/chunk-2NH6KYTL.js +193 -0
- package/dist/chunk-3CP4IY73.js +68 -0
- package/dist/chunk-6Z27SGVO.js +173 -0
- package/dist/chunk-GDTOV3DU.js +73 -0
- package/dist/chunk-GPA66AP5.js +22 -0
- package/dist/chunk-WKRIEXBQ.js +328 -0
- package/dist/compilations-72DJ2VEP.js +29 -0
- package/dist/compile-TCBGZZAU.js +11 -0
- package/dist/content-VLKNYIHL.js +44 -0
- package/dist/db-WACSH3HN.js +48 -0
- package/dist/dev-4YSMWIAS.js +124 -0
- package/dist/harden-QASY2KMI.js +43 -0
- package/dist/index.js +437 -0
- package/dist/init-TRPSGUYW.js +117 -0
- package/dist/mcp-DKFT2M4Q.js +74 -0
- package/dist/merge-XMTFPLHO.js +176 -0
- package/dist/migrate-QB44VL6R.js +10 -0
- package/dist/serve-5ABBWZU2.js +16 -0
- package/dist/studio-6ZO3FMDR.js +109 -0
- package/package.json +63 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
findConfig,
|
|
4
|
+
loadConfig,
|
|
5
|
+
loadProjectEnv,
|
|
6
|
+
requireDatabaseUrl
|
|
7
|
+
} from "./chunk-2NH6KYTL.js";
|
|
8
|
+
|
|
9
|
+
// src/commands/migrate.ts
|
|
10
|
+
import { existsSync, readdirSync, statSync } from "fs";
|
|
11
|
+
import { basename, join } from "path";
|
|
12
|
+
import { GraftError } from "@usegraft/contracts";
|
|
13
|
+
import {
|
|
14
|
+
runContentMigration
|
|
15
|
+
} from "@usegraft/content-migrations";
|
|
16
|
+
import { runDataMigration } from "@usegraft/core";
|
|
17
|
+
import { createJiti } from "jiti";
|
|
18
|
+
async function discoverMigrations(migrationsDir) {
|
|
19
|
+
if (!existsSync(migrationsDir) || !statSync(migrationsDir).isDirectory()) return [];
|
|
20
|
+
const files = readdirSync(migrationsDir).filter((name) => /\.(ts|mts|js|mjs)$/.test(name)).sort().map((name) => join(migrationsDir, name));
|
|
21
|
+
const jiti = createJiti(migrationsDir, { moduleCache: false });
|
|
22
|
+
const discovered = [];
|
|
23
|
+
for (const file of files) {
|
|
24
|
+
const id = basename(file).replace(/\.(ts|mts|js|mjs)$/, "");
|
|
25
|
+
let mod;
|
|
26
|
+
try {
|
|
27
|
+
mod = await jiti.import(file);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
throw new GraftError({
|
|
30
|
+
code: "MIGRATION_FAILED",
|
|
31
|
+
message: `Migration file ${basename(file)} failed to load: ${error instanceof Error ? error.message : String(error)}`,
|
|
32
|
+
fix: "Fix the error in the file \u2014 it must be importable and default-export defineContentMigration(\u2026) or defineDataMigration(\u2026).",
|
|
33
|
+
details: { file }
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const migration = mod.default;
|
|
37
|
+
if (!migration || migration.kind !== "content" && migration.kind !== "data" || typeof migration.transform !== "function") {
|
|
38
|
+
throw new GraftError({
|
|
39
|
+
code: "MIGRATION_FAILED",
|
|
40
|
+
message: `${basename(file)} does not default-export a migration.`,
|
|
41
|
+
fix: `Add \`export default defineContentMigration({ \u2026 })\` (from "@usegraft/content-migrations") or \`export default defineDataMigration({ \u2026 })\` (from "@usegraft/core").`,
|
|
42
|
+
details: { file, exports: Object.keys(mod) }
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
discovered.push({ id, file, migration });
|
|
46
|
+
}
|
|
47
|
+
return discovered;
|
|
48
|
+
}
|
|
49
|
+
async function migrateCommand(options) {
|
|
50
|
+
loadProjectEnv(options.cwd);
|
|
51
|
+
const config = await loadConfig(findConfig(options.cwd));
|
|
52
|
+
const branchId = options.branchId ?? "main";
|
|
53
|
+
const migrations = await discoverMigrations(config.migrationsDir);
|
|
54
|
+
if (migrations.length === 0) {
|
|
55
|
+
console.log(
|
|
56
|
+
`No migrations found in ${config.migrationsDir}.
|
|
57
|
+
Author one as migrations/0001-<name>.ts default-exporting defineContentMigration or defineDataMigration.`
|
|
58
|
+
);
|
|
59
|
+
return { applied: [], pending: [], didApply: false };
|
|
60
|
+
}
|
|
61
|
+
const url = requireDatabaseUrl();
|
|
62
|
+
const [
|
|
63
|
+
{ compile, resolveGitSha },
|
|
64
|
+
{
|
|
65
|
+
createDb,
|
|
66
|
+
listAppliedMigrations,
|
|
67
|
+
recordAppliedMigration,
|
|
68
|
+
resolveBranchHandle,
|
|
69
|
+
scopeWriteBranch
|
|
70
|
+
}
|
|
71
|
+
] = await Promise.all([import("@usegraft/compiler"), import("@usegraft/db")]);
|
|
72
|
+
const control = createDb(url);
|
|
73
|
+
const branch = await resolveBranchHandle(control.db, branchId, { databaseUrl: url });
|
|
74
|
+
const writeBranch = scopeWriteBranch(branch.scope);
|
|
75
|
+
try {
|
|
76
|
+
const appliedRows = await listAppliedMigrations(branch.db, writeBranch);
|
|
77
|
+
const appliedIds = new Set(appliedRows.map((row) => row.migrationId));
|
|
78
|
+
const applied = migrations.filter((m) => appliedIds.has(m.id)).map((m) => m.id);
|
|
79
|
+
const pending = migrations.filter((m) => !appliedIds.has(m.id));
|
|
80
|
+
if (applied.length > 0) {
|
|
81
|
+
console.log(`Already applied on "${branchId}": ${applied.join(", ")}`);
|
|
82
|
+
}
|
|
83
|
+
if (pending.length === 0) {
|
|
84
|
+
console.log("Nothing pending \u2014 the branch is up to date.");
|
|
85
|
+
return { applied, pending: [], didApply: false };
|
|
86
|
+
}
|
|
87
|
+
const gitSha = resolveGitSha(config.projectDir);
|
|
88
|
+
const outcomes = [];
|
|
89
|
+
for (const { id, migration } of pending) {
|
|
90
|
+
const label = `${id} [${migration.kind}] ${migration.collection.name} \u2014 ${migration.description}`;
|
|
91
|
+
if (migration.kind === "content") {
|
|
92
|
+
const report = await runContentMigration({
|
|
93
|
+
contentDir: config.contentDir,
|
|
94
|
+
migration,
|
|
95
|
+
apply: options.apply
|
|
96
|
+
});
|
|
97
|
+
if (options.apply) {
|
|
98
|
+
const compiled = await compile({
|
|
99
|
+
db: branch.db,
|
|
100
|
+
contentDir: config.contentDir,
|
|
101
|
+
collections: config.collections,
|
|
102
|
+
mdxTrust: config.mdxTrust,
|
|
103
|
+
branchId: writeBranch,
|
|
104
|
+
gitSha
|
|
105
|
+
});
|
|
106
|
+
await recordAppliedMigration(branch.db, {
|
|
107
|
+
branchId: writeBranch,
|
|
108
|
+
migrationId: id,
|
|
109
|
+
kind: "content",
|
|
110
|
+
collection: migration.collection.name,
|
|
111
|
+
docCount: report.changed,
|
|
112
|
+
gitSha
|
|
113
|
+
});
|
|
114
|
+
console.log(
|
|
115
|
+
`applied ${label}
|
|
116
|
+
${report.changed} file(s) rewritten, ${report.unchanged} unchanged; compiled: +${compiled.changes.added.length} ~${compiled.changes.changed.length} -${compiled.changes.removed.length}`
|
|
117
|
+
);
|
|
118
|
+
} else {
|
|
119
|
+
console.log(
|
|
120
|
+
`pending ${label}
|
|
121
|
+
would rewrite ${report.changed} file(s) (${report.unchanged} unchanged)`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
outcomes.push({
|
|
125
|
+
id,
|
|
126
|
+
kind: "content",
|
|
127
|
+
collection: migration.collection.name,
|
|
128
|
+
description: migration.description,
|
|
129
|
+
report
|
|
130
|
+
});
|
|
131
|
+
} else {
|
|
132
|
+
const report = await runDataMigration({
|
|
133
|
+
db: branch.db,
|
|
134
|
+
migration,
|
|
135
|
+
migrationId: id,
|
|
136
|
+
branchId: writeBranch,
|
|
137
|
+
gitSha,
|
|
138
|
+
apply: options.apply
|
|
139
|
+
});
|
|
140
|
+
console.log(
|
|
141
|
+
options.apply ? `applied ${label}
|
|
142
|
+
${report.changed} row(s) updated, ${report.unchanged} unchanged` : `pending ${label}
|
|
143
|
+
would update ${report.changed} of ${report.rows} row(s)`
|
|
144
|
+
);
|
|
145
|
+
outcomes.push({
|
|
146
|
+
id,
|
|
147
|
+
kind: "data",
|
|
148
|
+
collection: migration.collection.name,
|
|
149
|
+
description: migration.description,
|
|
150
|
+
report
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (!options.apply) {
|
|
155
|
+
console.log(`
|
|
156
|
+
Dry run \u2014 nothing was written. Run \`graft migrate --apply\` to execute.`);
|
|
157
|
+
} else {
|
|
158
|
+
console.log(
|
|
159
|
+
`
|
|
160
|
+
${outcomes.length} migration(s) applied. Commit the changes \u2014 git is the version history.`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return { applied, pending: outcomes, didApply: options.apply === true };
|
|
164
|
+
} finally {
|
|
165
|
+
await branch.close();
|
|
166
|
+
await control.close();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export {
|
|
171
|
+
discoverMigrations,
|
|
172
|
+
migrateCommand
|
|
173
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
formatCompileResult
|
|
4
|
+
} from "./chunk-GPA66AP5.js";
|
|
5
|
+
import {
|
|
6
|
+
findConfig,
|
|
7
|
+
loadConfig,
|
|
8
|
+
loadProjectEnv,
|
|
9
|
+
requireDatabaseUrl
|
|
10
|
+
} from "./chunk-2NH6KYTL.js";
|
|
11
|
+
|
|
12
|
+
// src/commands/compile.ts
|
|
13
|
+
import { GraftError } from "@usegraft/contracts";
|
|
14
|
+
function assertNoStaticBranch(branchId) {
|
|
15
|
+
if (branchId === void 0 || branchId === "main") return;
|
|
16
|
+
throw new GraftError({
|
|
17
|
+
code: "NEEDS_DATABASE",
|
|
18
|
+
message: `--branch ${branchId} needs the Postgres index: this project runs in static mode, where a "branch" is just your git branch.`,
|
|
19
|
+
fix: `Check out the git branch and compile (each checkout builds its own artifact), or upgrade to Postgres for DB branching: set DATABASE_URL and \`export const index = "postgres"\` in graft.config.`,
|
|
20
|
+
details: { branchId }
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
async function compileCommand(options) {
|
|
24
|
+
loadProjectEnv(options.cwd);
|
|
25
|
+
const config = await loadConfig(findConfig(options.cwd));
|
|
26
|
+
if (config.index.driver === "static") {
|
|
27
|
+
assertNoStaticBranch(options.branchId);
|
|
28
|
+
const { compileStatic } = await import("@usegraft/compiler");
|
|
29
|
+
const result = await compileStatic({
|
|
30
|
+
contentDir: config.contentDir,
|
|
31
|
+
collections: config.collections,
|
|
32
|
+
mdxTrust: config.mdxTrust,
|
|
33
|
+
indexPath: config.index.path
|
|
34
|
+
});
|
|
35
|
+
console.log(formatCompileResult(result));
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
const url = requireDatabaseUrl();
|
|
39
|
+
const [{ compile }, { createDb, resolveBranchHandle, scopeWriteBranch }] = await Promise.all([
|
|
40
|
+
import("@usegraft/compiler"),
|
|
41
|
+
import("@usegraft/db")
|
|
42
|
+
]);
|
|
43
|
+
const control = createDb(url);
|
|
44
|
+
try {
|
|
45
|
+
const branch = await resolveBranchHandle(control.db, options.branchId ?? "main", {
|
|
46
|
+
databaseUrl: url
|
|
47
|
+
});
|
|
48
|
+
try {
|
|
49
|
+
if (branch.scope.kind === "physical") {
|
|
50
|
+
console.log(`neon branch "${branch.name}" \u2014 compiling into its own database`);
|
|
51
|
+
}
|
|
52
|
+
const result = await compile({
|
|
53
|
+
db: branch.db,
|
|
54
|
+
contentDir: config.contentDir,
|
|
55
|
+
collections: config.collections,
|
|
56
|
+
mdxTrust: config.mdxTrust,
|
|
57
|
+
branchId: scopeWriteBranch(branch.scope),
|
|
58
|
+
pruneUnknown: options.pruneUnknown
|
|
59
|
+
});
|
|
60
|
+
console.log(formatCompileResult(result));
|
|
61
|
+
return result;
|
|
62
|
+
} finally {
|
|
63
|
+
await branch.close();
|
|
64
|
+
}
|
|
65
|
+
} finally {
|
|
66
|
+
await control.close();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export {
|
|
71
|
+
assertNoStaticBranch,
|
|
72
|
+
compileCommand
|
|
73
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/report.ts
|
|
4
|
+
function printGraftError(error) {
|
|
5
|
+
console.error(`error ${error.code}: ${error.message}`);
|
|
6
|
+
if (error.fix) console.error(` fix: ${error.fix}`);
|
|
7
|
+
if (error.details) console.error(` details: ${JSON.stringify(error.details)}`);
|
|
8
|
+
}
|
|
9
|
+
function formatCompileResult(result) {
|
|
10
|
+
const { added, changed, removed, unchanged } = result.changes;
|
|
11
|
+
const lines = [
|
|
12
|
+
`Compiled ${result.count} doc(s) @ ${result.gitSha?.slice(0, 7) ?? "no-git"}: +${added.length} added, ~${changed.length} changed, -${removed.length} removed, ${unchanged} unchanged`
|
|
13
|
+
];
|
|
14
|
+
for (const key of [...added, ...changed]) lines.push(` upserted ${key}`);
|
|
15
|
+
for (const key of removed) lines.push(` removed ${key}`);
|
|
16
|
+
return lines.join("\n");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
printGraftError,
|
|
21
|
+
formatCompileResult
|
|
22
|
+
};
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
findConfig,
|
|
4
|
+
loadConfig,
|
|
5
|
+
loadProjectEnv,
|
|
6
|
+
requireDatabaseUrl
|
|
7
|
+
} from "./chunk-2NH6KYTL.js";
|
|
8
|
+
|
|
9
|
+
// src/commands/serve.ts
|
|
10
|
+
import { execFileSync } from "child_process";
|
|
11
|
+
import { createServer } from "http";
|
|
12
|
+
import { createActorResolver } from "@usegraft/auth";
|
|
13
|
+
import { GraftError } from "@usegraft/contracts";
|
|
14
|
+
import { setRequestPeer } from "@usegraft/core";
|
|
15
|
+
function createServeRouter(routes) {
|
|
16
|
+
return async (request) => {
|
|
17
|
+
const { pathname } = new URL(request.url);
|
|
18
|
+
if (pathname === "/healthz") return routes.health(request);
|
|
19
|
+
if (pathname === "/api/mcp") return routes.mcp(request);
|
|
20
|
+
if (pathname === "/api/fn" || pathname.startsWith("/api/fn/")) return routes.fn(request);
|
|
21
|
+
if (pathname === "/api/content/v1" || pathname.startsWith("/api/content/v1/")) {
|
|
22
|
+
return routes.content(request);
|
|
23
|
+
}
|
|
24
|
+
if (routes.studio && (pathname === "/studio" || pathname.startsWith("/studio/") || pathname.startsWith("/api/studio/"))) {
|
|
25
|
+
return routes.studio(request);
|
|
26
|
+
}
|
|
27
|
+
const studioHint = routes.studio ? ", GET /studio (opt-in Studio)" : " \u2014 or pass --studio / GRAFT_STUDIO=1 for the opt-in Studio UI";
|
|
28
|
+
const error = new GraftError({
|
|
29
|
+
code: "ROUTE_NOT_FOUND",
|
|
30
|
+
message: `Nothing is mounted at ${pathname}.`,
|
|
31
|
+
fix: `Use POST /api/fn/<name> (typed functions), POST /api/mcp (MCP Streamable HTTP), GET /api/content/v1/documents (authored content), GET /api/content/v1/search, or GET /healthz${studioHint}.`,
|
|
32
|
+
details: { pathname }
|
|
33
|
+
});
|
|
34
|
+
return new Response(JSON.stringify(error.toJSON()), {
|
|
35
|
+
status: 404,
|
|
36
|
+
headers: { "content-type": "application/json" }
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
var HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
41
|
+
"connection",
|
|
42
|
+
"keep-alive",
|
|
43
|
+
"transfer-encoding",
|
|
44
|
+
"content-length",
|
|
45
|
+
"upgrade",
|
|
46
|
+
"proxy-connection"
|
|
47
|
+
]);
|
|
48
|
+
function allowedHostsFor(host) {
|
|
49
|
+
if (host === "0.0.0.0" || host === "::" || host === "") return void 0;
|
|
50
|
+
if (host === "127.0.0.1" || host === "localhost" || host === "::1") {
|
|
51
|
+
return ["127.0.0.1", "localhost", "[::1]"];
|
|
52
|
+
}
|
|
53
|
+
return [host];
|
|
54
|
+
}
|
|
55
|
+
function hostname(host) {
|
|
56
|
+
const trimmed = host.trim().toLowerCase();
|
|
57
|
+
if (trimmed.startsWith("[")) return trimmed.slice(0, trimmed.indexOf("]") + 1);
|
|
58
|
+
const colon = trimmed.lastIndexOf(":");
|
|
59
|
+
return colon === -1 ? trimmed : trimmed.slice(0, colon);
|
|
60
|
+
}
|
|
61
|
+
function createNodeListener(handler, options = {}) {
|
|
62
|
+
const allowed = options.allowedHosts?.map(hostname);
|
|
63
|
+
return async (req, res) => {
|
|
64
|
+
try {
|
|
65
|
+
if (allowed && !allowed.includes(hostname(req.headers.host ?? ""))) {
|
|
66
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
67
|
+
res.end(
|
|
68
|
+
JSON.stringify({
|
|
69
|
+
error: "INPUT_VALIDATION_FAILED",
|
|
70
|
+
message: `This server does not answer to Host "${req.headers.host ?? ""}".`,
|
|
71
|
+
fix: `Reach it at one of: ${allowed.join(", ")}. A mismatched Host is either a misconfigured proxy or a DNS-rebinding attempt.`
|
|
72
|
+
})
|
|
73
|
+
);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const chunks = [];
|
|
77
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
78
|
+
const body = Buffer.concat(chunks);
|
|
79
|
+
const headers = new Headers();
|
|
80
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
81
|
+
if (value === void 0 || HOP_BY_HOP.has(key)) continue;
|
|
82
|
+
if (Array.isArray(value)) for (const v of value) headers.append(key, v);
|
|
83
|
+
else headers.set(key, value);
|
|
84
|
+
}
|
|
85
|
+
const method = req.method ?? "GET";
|
|
86
|
+
const request = new Request(`http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`, {
|
|
87
|
+
method,
|
|
88
|
+
headers,
|
|
89
|
+
body: method === "GET" || method === "HEAD" || body.length === 0 ? void 0 : body
|
|
90
|
+
});
|
|
91
|
+
const peer = req.socket.remoteAddress;
|
|
92
|
+
if (peer) setRequestPeer(request, peer);
|
|
93
|
+
const response = await handler(request);
|
|
94
|
+
const outHeaders = {};
|
|
95
|
+
response.headers.forEach((value, key) => {
|
|
96
|
+
if (!HOP_BY_HOP.has(key)) outHeaders[key] = value;
|
|
97
|
+
});
|
|
98
|
+
res.writeHead(response.status, outHeaders);
|
|
99
|
+
res.end(Buffer.from(await response.arrayBuffer()));
|
|
100
|
+
} catch (error) {
|
|
101
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
102
|
+
res.end(
|
|
103
|
+
JSON.stringify({
|
|
104
|
+
error: "FUNCTION_EXECUTION_FAILED",
|
|
105
|
+
message: `graft serve failed to relay the request: ${error instanceof Error ? error.message : String(error)}`,
|
|
106
|
+
fix: "Check the server logs; if this reproduces, file it \u2014 the adapter should never throw."
|
|
107
|
+
})
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function localGitSha(cwd) {
|
|
113
|
+
try {
|
|
114
|
+
return execFileSync("git", ["rev-parse", "HEAD"], {
|
|
115
|
+
cwd,
|
|
116
|
+
encoding: "utf8",
|
|
117
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
118
|
+
}).trim();
|
|
119
|
+
} catch {
|
|
120
|
+
return void 0;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function trustedIssuersFromEnv() {
|
|
124
|
+
return (process.env.GRAFT_TRUSTED_ISSUERS ?? "").split(/[,\s]+/).map((issuer) => issuer.trim()).filter(Boolean).map((issuer) => ({ issuer }));
|
|
125
|
+
}
|
|
126
|
+
async function startServe(options) {
|
|
127
|
+
loadProjectEnv(options.cwd);
|
|
128
|
+
const config = await loadConfig(findConfig(options.cwd));
|
|
129
|
+
const url = requireDatabaseUrl();
|
|
130
|
+
const enableStudio = options.studio === true || process.env.GRAFT_STUDIO === "1";
|
|
131
|
+
const [
|
|
132
|
+
{ createFunctionsHandler },
|
|
133
|
+
{ createGraftMcpHandler },
|
|
134
|
+
{ createContentApiHandler },
|
|
135
|
+
{ createDb, createDbIndexReader, resolveBranchHandle, scopeWriteBranch, sql },
|
|
136
|
+
studioMod
|
|
137
|
+
] = await Promise.all([
|
|
138
|
+
import("@usegraft/core"),
|
|
139
|
+
import("@usegraft/mcp"),
|
|
140
|
+
import("@usegraft/content-api"),
|
|
141
|
+
import("@usegraft/db"),
|
|
142
|
+
enableStudio ? import("@usegraft/studio") : Promise.resolve(null)
|
|
143
|
+
]);
|
|
144
|
+
const control = createDb(url);
|
|
145
|
+
const branchName = options.branchId ?? "main";
|
|
146
|
+
const branch = await resolveBranchHandle(control.db, branchName, { databaseUrl: url });
|
|
147
|
+
const writeBranch = scopeWriteBranch(branch.scope);
|
|
148
|
+
const devToken = process.env.GRAFT_DEV_TOKEN;
|
|
149
|
+
const scopes = (process.env.GRAFT_DEV_SCOPES ?? "").split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
|
|
150
|
+
const issuers = trustedIssuersFromEnv();
|
|
151
|
+
const resolveActor = createActorResolver({
|
|
152
|
+
issuers,
|
|
153
|
+
devTokens: devToken ? { [devToken]: { kind: "agent", id: "graft-serve", scopes } } : void 0
|
|
154
|
+
});
|
|
155
|
+
const approvalPolicy = process.env.GRAFT_APPROVAL_POLICY === "human" ? "human" : process.env.GRAFT_APPROVAL_POLICY === "unattended" ? "unattended" : "none";
|
|
156
|
+
const host = options.host ?? process.env.HOST ?? "127.0.0.1";
|
|
157
|
+
const loopback = host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
158
|
+
const anonymousOptIn = process.env.GRAFT_MCP_ALLOW_ANONYMOUS === "1";
|
|
159
|
+
const allowAnonymousMcp = loopback || anonymousOptIn;
|
|
160
|
+
const functions = config.functions ?? {};
|
|
161
|
+
const gitSha = localGitSha(options.cwd);
|
|
162
|
+
const fnHandler = createFunctionsHandler({
|
|
163
|
+
functions,
|
|
164
|
+
db: branch.db,
|
|
165
|
+
branch: writeBranch,
|
|
166
|
+
actor: resolveActor,
|
|
167
|
+
approvalPolicy,
|
|
168
|
+
// Backstop; per-function rateLimit overrides it.
|
|
169
|
+
rateLimit: { limit: 60, windowSeconds: 60 },
|
|
170
|
+
gitSha
|
|
171
|
+
});
|
|
172
|
+
const mcpHandler = createGraftMcpHandler({
|
|
173
|
+
name: "graft-serve",
|
|
174
|
+
contentDir: config.contentDir,
|
|
175
|
+
collections: config.collections,
|
|
176
|
+
functions,
|
|
177
|
+
mdxTrust: config.mdxTrust,
|
|
178
|
+
db: branch.db,
|
|
179
|
+
branchId: writeBranch,
|
|
180
|
+
scope: branch.scope,
|
|
181
|
+
actor: resolveActor,
|
|
182
|
+
allowAnonymous: allowAnonymousMcp
|
|
183
|
+
});
|
|
184
|
+
const allowedOriginsRaw = process.env.GRAFT_CONTENT_ALLOWED_ORIGINS?.trim();
|
|
185
|
+
const allowedOrigins = allowedOriginsRaw === void 0 || allowedOriginsRaw === "" ? void 0 : allowedOriginsRaw === "*" ? "*" : allowedOriginsRaw.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
186
|
+
const contentHandler = createContentApiHandler({
|
|
187
|
+
collections: Object.keys(config.collections),
|
|
188
|
+
branch: writeBranch,
|
|
189
|
+
index: createDbIndexReader(branch.db),
|
|
190
|
+
...allowedOrigins === void 0 ? {} : { allowedOrigins },
|
|
191
|
+
// The same backstop the functions handler gets, for the same reason. These
|
|
192
|
+
// routes authenticate nobody and run database listings and full-text
|
|
193
|
+
// searches, so without a limit here one anonymous caller can keep the index
|
|
194
|
+
// busy indefinitely — and `graft serve` is the mount most likely to face
|
|
195
|
+
// the open internet.
|
|
196
|
+
rateLimit: { limit: 60, windowSeconds: 60 }
|
|
197
|
+
});
|
|
198
|
+
const health = async () => {
|
|
199
|
+
try {
|
|
200
|
+
await branch.db.execute(sql`select 1`);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
return new Response(
|
|
203
|
+
JSON.stringify({
|
|
204
|
+
ok: false,
|
|
205
|
+
error: error instanceof Error ? error.message : String(error)
|
|
206
|
+
}),
|
|
207
|
+
{ status: 503, headers: { "content-type": "application/json" } }
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
return new Response(
|
|
211
|
+
JSON.stringify({
|
|
212
|
+
ok: true,
|
|
213
|
+
branch: branchName,
|
|
214
|
+
collections: Object.keys(config.collections).length,
|
|
215
|
+
functions: Object.keys(functions).length,
|
|
216
|
+
gitSha: gitSha ?? null
|
|
217
|
+
}),
|
|
218
|
+
{ status: 200, headers: { "content-type": "application/json" } }
|
|
219
|
+
);
|
|
220
|
+
};
|
|
221
|
+
const requestedPort = options.port ?? Number(process.env.PORT ?? 3903);
|
|
222
|
+
if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65535) {
|
|
223
|
+
await branch.close();
|
|
224
|
+
await control.close();
|
|
225
|
+
throw new GraftError({
|
|
226
|
+
code: "INPUT_VALIDATION_FAILED",
|
|
227
|
+
message: `"${options.port ?? process.env.PORT}" is not a valid port.`,
|
|
228
|
+
fix: "Pass --port <0-65535> (0 picks a free port) or set PORT."
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
if (!loopback && anonymousOptIn) {
|
|
232
|
+
console.warn(
|
|
233
|
+
"[graft serve] WARNING: GRAFT_MCP_ALLOW_ANONYMOUS=1 while bound beyond loopback \u2014 unauthenticated callers can write content, upload assets and decide approvals. Only do this behind an auth proxy you control."
|
|
234
|
+
);
|
|
235
|
+
} else if (!loopback && issuers.length === 0 && !devToken) {
|
|
236
|
+
console.warn(
|
|
237
|
+
"[graft serve] WARNING: binding beyond loopback with no identity configured \u2014 MCP will refuse every caller, because there is nothing to authenticate them against. Set GRAFT_DEV_TOKEN or GRAFT_TRUSTED_ISSUERS."
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
if (approvalPolicy === "unattended") {
|
|
241
|
+
console.warn(
|
|
242
|
+
"[graft serve] WARNING: GRAFT_APPROVAL_POLICY=unattended \u2014 destructive functions run without human approval. Deletes are not recoverable from git: deleteRecord removes rows outright and the asset store keeps no history. Audit rows are still written. This applies to POST /api/fn only; run_function over /api/mcp stays gated, because the policy is not part of the MCP surface."
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
let studioHandler;
|
|
246
|
+
if (studioMod) {
|
|
247
|
+
const authenticate = !loopback && (devToken || issuers.length > 0) ? async (request) => {
|
|
248
|
+
const actor = await resolveActor(request);
|
|
249
|
+
if (actor.kind === "anonymous" || !actor.id) return null;
|
|
250
|
+
return { kind: actor.kind, id: actor.id, scopes: actor.scopes ?? [] };
|
|
251
|
+
} : !loopback ? () => null : void 0;
|
|
252
|
+
studioHandler = studioMod.createStudioHandler({
|
|
253
|
+
db: branch.db,
|
|
254
|
+
collections: config.collections,
|
|
255
|
+
contentDir: config.contentDir,
|
|
256
|
+
mdxTrust: config.mdxTrust,
|
|
257
|
+
defaultBranch: writeBranch,
|
|
258
|
+
// Only reached on a loopback mount, where there is no caller identity to
|
|
259
|
+
// attribute to. Off loopback the authenticated principal is used instead.
|
|
260
|
+
decider: { kind: "agent", id: "studio-serve" },
|
|
261
|
+
uiBasePath: "/studio",
|
|
262
|
+
authenticate
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
const server = createServer(
|
|
266
|
+
createNodeListener(
|
|
267
|
+
createServeRouter({
|
|
268
|
+
fn: fnHandler,
|
|
269
|
+
mcp: mcpHandler,
|
|
270
|
+
content: contentHandler,
|
|
271
|
+
health,
|
|
272
|
+
studio: studioHandler
|
|
273
|
+
}),
|
|
274
|
+
{ allowedHosts: allowedHostsFor(host) }
|
|
275
|
+
)
|
|
276
|
+
);
|
|
277
|
+
await new Promise((resolve, reject) => {
|
|
278
|
+
server.once("error", reject);
|
|
279
|
+
server.listen(requestedPort, host, () => resolve());
|
|
280
|
+
});
|
|
281
|
+
const address = server.address();
|
|
282
|
+
const port = typeof address === "object" && address ? address.port : requestedPort;
|
|
283
|
+
return {
|
|
284
|
+
port,
|
|
285
|
+
host,
|
|
286
|
+
branch: branchName,
|
|
287
|
+
close: async () => {
|
|
288
|
+
await new Promise((resolve, reject) => {
|
|
289
|
+
server.close((err) => err ? reject(err) : resolve());
|
|
290
|
+
});
|
|
291
|
+
await branch.close();
|
|
292
|
+
await control.close();
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
async function serveCommand(options) {
|
|
297
|
+
const running = await startServe(options);
|
|
298
|
+
const base = `http://${running.host}:${running.port}`;
|
|
299
|
+
const studioOn = options.studio === true || process.env.GRAFT_STUDIO === "1";
|
|
300
|
+
console.log(
|
|
301
|
+
[
|
|
302
|
+
`graft serve \u2014 branch "${running.branch}"`,
|
|
303
|
+
` functions POST ${base}/api/fn/<name>`,
|
|
304
|
+
` mcp POST ${base}/api/mcp`,
|
|
305
|
+
` documents GET ${base}/api/content/v1/documents`,
|
|
306
|
+
` search GET ${base}/api/content/v1/search`,
|
|
307
|
+
` health GET ${base}/healthz`,
|
|
308
|
+
...studioOn ? [
|
|
309
|
+
` studio GET ${base}/studio`,
|
|
310
|
+
` openapi GET ${base}/api/studio/v1/openapi.json`
|
|
311
|
+
] : []
|
|
312
|
+
].join("\n")
|
|
313
|
+
);
|
|
314
|
+
await new Promise((resolve) => {
|
|
315
|
+
const stop = () => resolve();
|
|
316
|
+
process.once("SIGINT", stop);
|
|
317
|
+
process.once("SIGTERM", stop);
|
|
318
|
+
});
|
|
319
|
+
await running.close();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export {
|
|
323
|
+
createServeRouter,
|
|
324
|
+
allowedHostsFor,
|
|
325
|
+
createNodeListener,
|
|
326
|
+
startServe,
|
|
327
|
+
serveCommand
|
|
328
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
loadProjectEnv,
|
|
4
|
+
requireDatabaseUrl
|
|
5
|
+
} from "./chunk-2NH6KYTL.js";
|
|
6
|
+
|
|
7
|
+
// src/commands/compilations.ts
|
|
8
|
+
async function compilationsListCommand(options) {
|
|
9
|
+
loadProjectEnv(options.cwd);
|
|
10
|
+
const url = requireDatabaseUrl();
|
|
11
|
+
const { createDb, listCompilations } = await import("@usegraft/db");
|
|
12
|
+
const handle = createDb(url);
|
|
13
|
+
try {
|
|
14
|
+
return await listCompilations(handle.db, {
|
|
15
|
+
branchId: options.branchId,
|
|
16
|
+
limit: options.limit
|
|
17
|
+
});
|
|
18
|
+
} finally {
|
|
19
|
+
await handle.close();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function formatCompilation(row) {
|
|
23
|
+
const sha = row.gitSha ? row.gitSha.slice(0, 7) : "no-sha";
|
|
24
|
+
return `${row.createdAt.toISOString()} ${row.branchId} ${sha} docs=${row.docCount} +${row.added} ~${row.changed} -${row.removed} ${row.id}`;
|
|
25
|
+
}
|
|
26
|
+
export {
|
|
27
|
+
compilationsListCommand,
|
|
28
|
+
formatCompilation
|
|
29
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
findConfig,
|
|
4
|
+
loadConfig,
|
|
5
|
+
loadProjectEnv,
|
|
6
|
+
requireDatabaseUrl
|
|
7
|
+
} from "./chunk-2NH6KYTL.js";
|
|
8
|
+
|
|
9
|
+
// src/commands/content.ts
|
|
10
|
+
async function contentListCommand(options) {
|
|
11
|
+
loadProjectEnv(options.cwd);
|
|
12
|
+
const config = await loadConfig(findConfig(options.cwd));
|
|
13
|
+
const url = requireDatabaseUrl();
|
|
14
|
+
const { createDb, readContent, resolveBranchScope } = await import("@usegraft/db");
|
|
15
|
+
const handle = createDb(url);
|
|
16
|
+
const branch = options.branchId ?? "main";
|
|
17
|
+
try {
|
|
18
|
+
const scope = await resolveBranchScope(handle.db, branch);
|
|
19
|
+
const lines = [];
|
|
20
|
+
for (const name of Object.keys(config.collections).sort()) {
|
|
21
|
+
const rows = await readContent(handle.db, scope, { collection: name });
|
|
22
|
+
for (const row of rows) {
|
|
23
|
+
const title = typeof row.data.title === "string" ? row.data.title : typeof row.data.name === "string" ? row.data.name : void 0;
|
|
24
|
+
lines.push({
|
|
25
|
+
collection: name,
|
|
26
|
+
slug: row.slug,
|
|
27
|
+
sourcePath: row.sourcePath,
|
|
28
|
+
...title ? { title } : {}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { branch, lines };
|
|
33
|
+
} finally {
|
|
34
|
+
await handle.close();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function formatContentLine(line) {
|
|
38
|
+
const label = line.title ? `${line.slug} (${line.title})` : line.slug;
|
|
39
|
+
return `${line.collection}/${label} ${line.sourcePath}`;
|
|
40
|
+
}
|
|
41
|
+
export {
|
|
42
|
+
contentListCommand,
|
|
43
|
+
formatContentLine
|
|
44
|
+
};
|