@njinlabs/njin 0.10.3 → 0.11.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.
Files changed (55) hide show
  1. package/README.md +48 -0
  2. package/package.json +19 -4
  3. package/src/cli/build-assets.ts +4 -1
  4. package/src/cli/build.ts +47 -23
  5. package/src/cli/create.ts +35 -9
  6. package/src/cli/index.ts +2 -1
  7. package/src/cli/update.ts +93 -16
  8. package/src/config/module.ts +9 -0
  9. package/src/core/adapters/bun_filesystem.ts +13 -5
  10. package/src/core/adapters/s3.ts +18 -6
  11. package/src/core/admin_schema.ts +79 -0
  12. package/src/core/banner.ts +18 -4
  13. package/src/core/config.ts +58 -11
  14. package/src/core/helper.ts +4 -1
  15. package/src/core/html_page.ts +63 -0
  16. package/src/core/model/data_type/array.ts +16 -5
  17. package/src/core/model/data_type/boolean.ts +4 -1
  18. package/src/core/model/data_type/date.ts +4 -1
  19. package/src/core/model/data_type/email.ts +4 -1
  20. package/src/core/model/data_type/file.ts +17 -4
  21. package/src/core/model/data_type/multi_file.ts +9 -4
  22. package/src/core/model/data_type/numeric.ts +4 -1
  23. package/src/core/model/data_type/object.ts +8 -2
  24. package/src/core/model/data_type/relation.ts +21 -5
  25. package/src/core/model/data_type/relation_many.ts +24 -6
  26. package/src/core/model/data_type/richtext.ts +4 -1
  27. package/src/core/model/data_type/select.ts +17 -3
  28. package/src/core/model/data_type/text.ts +4 -1
  29. package/src/core/model/hooks.ts +42 -15
  30. package/src/core/model/index.ts +101 -37
  31. package/src/core/path_guard.ts +10 -3
  32. package/src/core/plugin.ts +8 -1
  33. package/src/core/public_url.ts +24 -0
  34. package/src/core/route.ts +2 -1
  35. package/src/core/vars/index.ts +22 -8
  36. package/src/core/worker.ts +22 -6
  37. package/src/models/file.ts +1 -1
  38. package/src/models/user.ts +1 -1
  39. package/src/modules/admin.ts +2 -2
  40. package/src/modules/analytics.ts +46 -26
  41. package/src/modules/api.ts +67 -95
  42. package/src/modules/auth.ts +25 -8
  43. package/src/modules/elysia.ts +1 -1
  44. package/src/modules/file.ts +50 -33
  45. package/src/modules/img.ts +33 -14
  46. package/src/modules/logger.ts +1 -1
  47. package/src/modules/mcp.ts +607 -0
  48. package/src/modules/mcp_token.ts +295 -0
  49. package/src/modules/mcp_upload.ts +313 -0
  50. package/src/modules/oauth.ts +579 -0
  51. package/src/modules/setup.ts +13 -5
  52. package/src/modules/surreal.ts +43 -13
  53. package/src/modules/users.ts +23 -5
  54. package/src/modules/vars.ts +1 -1
  55. package/src/modules/view.ts +88 -54
package/README.md CHANGED
@@ -334,6 +334,50 @@ DELETE /api/auth/logout # Bearer <token>
334
334
 
335
335
  All `/api/*` endpoints require `Authorization: Bearer <token>`.
336
336
 
337
+ ## MCP — manage the site with AI agents
338
+
339
+ njin exposes a remote [MCP](https://modelcontextprotocol.io) server at `POST /mcp` (Streamable HTTP), so an agent such as Claude Cowork or Claude Desktop can read and edit content, change settings and upload files.
340
+
341
+ **Other AI clients.** Any MCP client that speaks Streamable HTTP and OAuth with dynamic client registration can connect. These OAuth callbacks are accepted out of the box: Claude, ChatGPT, Cursor, VS Code, and any loopback address (`localhost` / `127.0.0.1` — what Gemini CLI, Claude Code, Codex CLI, MCP Inspector and similar desktop clients use). For a client with its own hosted callback, allow it in `config.ts` — and only for clients you trust, since it is where the sign-in code is sent:
342
+
343
+ ```ts
344
+ export default defineConfig({
345
+ mcp: {
346
+ redirectUris: [
347
+ "https://app.example.com/oauth/callback", // exact
348
+ "https://client.example.com/connectors/*", // path prefix
349
+ ],
350
+ },
351
+ });
352
+ ```
353
+
354
+ A client that can't do OAuth but can send a header can use a token from `POST /api/mcp-token` instead.
355
+
356
+ **Connecting Claude:** add a custom connector and enter only the URL, `https://your-site.com/mcp`. Claude opens an njin sign-in page, you log in with your normal njin account and click **Allow** — no token to copy. The site must be reachable from the internet over HTTPS; set `publicUrl` in `config.ts` if it sits behind a proxy that rewrites the host.
357
+
358
+ | Tool | |
359
+ | --- | --- |
360
+ | `list_models` | Every model and `vars` group with its JSON schema — agents call this first |
361
+ | `read_records`, `get_record` | Search, filter, sort, paginate, populate |
362
+ | `create_record`, `update_record`, `delete_record` | Validated through the model's own schema, so hooks and transforms run exactly as over REST |
363
+ | `get_vars`, `update_vars` | Settings groups |
364
+ | `create_upload_url`, `check_upload` | Add files (below) |
365
+ | `list_files`, `get_file`, `delete_file` | Manage uploaded files |
366
+
367
+ **Links.** A relation or file field takes the related record's id as a plain string, and that is what `list_models` tells the agent. If an agent sends an object instead (`{ "id": "..." }`, or a whole record read earlier) it is reduced to the id before saving — otherwise njin would store a dead embedded copy rather than a link. Created and updated records come back with their relations expanded, with a `_warnings` entry if a link points at nothing, and reads flag records that already hold an embedded `{ id }` copy so they can be repaired.
368
+
369
+ **Files.** File bytes can't go through a tool call, so `create_upload_url` returns a short-lived link (10 minutes, up to 10 files of 10 MB). The agent uploads with `curl -F file=@photo.jpg <url>`, or — if it has no shell or network — shows the link so you can open it and drop the file. `check_upload` then returns the file ids to put in a model's file field. Only safe types are accepted (images except SVG, PDF, Office documents, audio, video, zip, fonts); HTML, SVG and scripts are rejected because `/uploads` is served from the site's own origin.
370
+
371
+ **Managing connections.** Every connected agent is a token you can revoke:
372
+
373
+ ```bash
374
+ GET /api/mcp-token # connected agents (OAuth) and manually created tokens
375
+ DELETE /api/mcp-token/:id # revoke — takes effect immediately
376
+ POST /api/mcp-token # { name } → a long-lived token, shown once, for clients that can send an Authorization header
377
+ ```
378
+
379
+ These need an admin session token, never an MCP token. There is no per-agent permission scoping yet: an agent can do anything an admin can in the content API (it cannot touch users or sessions).
380
+
337
381
  ## Admin panel
338
382
 
339
383
  njin doesn't bundle an admin panel — `_admin/` at your project root is just a static folder the server looks for at startup (`GET /_admin`). Drop a built admin SPA's `index.html`/assets into it (your own, or one shared by the community) and it's served automatically; if it's missing, the startup banner just notes it wasn't found.
@@ -349,6 +393,7 @@ import bunFilesystemAdapter from "@njinlabs/njin/adapters/bun_filesystem";
349
393
 
350
394
  export default defineConfig({
351
395
  port: Number(process.env.PORT ?? 3000),
396
+ publicUrl: "https://your-site.com", // optional — only needed behind a proxy; used by the MCP/OAuth discovery documents
352
397
  db: {
353
398
  path: process.env.DB_PATH ?? "rocksdb://data",
354
399
  namespace: process.env.DB_NAMESPACE ?? "general",
@@ -404,8 +449,11 @@ bunx njin dev # Start dev server (Elysia + Vite HMR)
404
449
  bunx njin build # Build for production -> ./out (public/, _admin/, views/, compiled server)
405
450
  bunx njin start # Run from source in production mode (no compile)
406
451
  bunx njin update # Update @njinlabs/njin to the latest version and refresh _admin/
452
+ bunx njin update --beta # Follow the beta channel instead (or --tag <name>)
407
453
  ```
408
454
 
455
+ **Trying a beta.** Prereleases never become `latest`, so a normal install or update never picks one up. New project: `bunx @njinlabs/njin@beta create my-web`. Existing project: `bunx njin update --beta` (the dependency is pinned to that exact version). To go back to stable, set `@njinlabs/njin` to a stable version in `package.json` and run `bunx njin update`.
456
+
409
457
  Wire these as `package.json` scripts (`"dev": "njin dev"`, etc.) if you'd rather run `bun run dev`.
410
458
 
411
459
  ## License
package/package.json CHANGED
@@ -1,9 +1,16 @@
1
1
  {
2
2
  "name": "@njinlabs/njin",
3
- "version": "0.10.3",
3
+ "version": "0.11.0",
4
4
  "description": "A modern framework for building company profiles, landing pages, and content-driven websites.",
5
5
  "type": "module",
6
- "keywords": ["bun", "elysia", "surrealdb", "edgejs", "cms", "framework"],
6
+ "keywords": [
7
+ "bun",
8
+ "elysia",
9
+ "surrealdb",
10
+ "edgejs",
11
+ "cms",
12
+ "framework"
13
+ ],
7
14
  "license": "MIT",
8
15
  "repository": {
9
16
  "type": "git",
@@ -16,10 +23,16 @@
16
23
  "engines": {
17
24
  "bun": ">=1.3.0"
18
25
  },
19
- "files": ["src", "LICENSE"],
26
+ "files": [
27
+ "src",
28
+ "LICENSE"
29
+ ],
20
30
  "scripts": {
21
31
  "test": "bun test --isolate",
22
- "test:coverage": "bun test --isolate --coverage"
32
+ "test:coverage": "bun test --isolate --coverage",
33
+ "lint": "biome check .",
34
+ "lint:fix": "biome check --write .",
35
+ "format": "biome format --write ."
23
36
  },
24
37
  "bin": {
25
38
  "njin": "./src/cli/index.ts"
@@ -33,6 +46,7 @@
33
46
  "./adapters/*": "./src/core/adapters/*.ts"
34
47
  },
35
48
  "devDependencies": {
49
+ "@biomejs/biome": "^2.5.13",
36
50
  "@tailwindcss/vite": "^4.3.0",
37
51
  "@types/alpinejs": "^3.13.11",
38
52
  "@types/bun": "latest",
@@ -51,6 +65,7 @@
51
65
  "dependencies": {
52
66
  "@elysia/bearer": "^1.4.4",
53
67
  "@elysia/cors": "^1.4.2",
68
+ "@modelcontextprotocol/sdk": "^1.32.0",
54
69
  "@paralleldrive/cuid2": "^3.3.0",
55
70
  "@surrealdb/node": "^3.0.3",
56
71
  "edge.js": "^6.5.1",
@@ -5,7 +5,10 @@ import { join } from "node:path";
5
5
  // Shared staging steps between `njin build` (compiled binary) and `njin build:worker`
6
6
  // (bundled JS entry) — both produce the same `out/public`, `out/src/views`, `out/_admin`
7
7
  // layout; only the server artifact itself differs.
8
- export const stageBuildAssets = async (root: string, outDir: string): Promise<void> => {
8
+ export const stageBuildAssets = async (
9
+ root: string,
10
+ outDir: string,
11
+ ): Promise<void> => {
9
12
  await rm(outDir, { recursive: true, force: true });
10
13
  await mkdir(outDir, { recursive: true });
11
14
 
package/src/cli/build.ts CHANGED
@@ -1,7 +1,7 @@
1
- import type { BunPlugin } from "bun";
2
1
  import { cp, mkdir, rm } from "node:fs/promises";
3
2
  import { dirname, join } from "node:path";
4
3
  import { fileURLToPath } from "node:url";
4
+ import type { BunPlugin } from "bun";
5
5
  import { stageBuildAssets } from "./build-assets";
6
6
 
7
7
  const root = process.cwd();
@@ -53,15 +53,21 @@ const NATIVE_FILE: Record<string, string> = {
53
53
 
54
54
  let surrealNativePlugin: BunPlugin | undefined;
55
55
  if (isRemoteBuild) {
56
- console.log("• Remote db.path detected — skipping @surrealdb/node native binding");
56
+ console.log(
57
+ "• Remote db.path detected — skipping @surrealdb/node native binding",
58
+ );
57
59
  } else {
58
60
  const nativeFile = NATIVE_FILE[`${process.platform}-${process.arch}`];
59
61
  if (!nativeFile) {
60
- console.error(`\n✗ Unsupported platform for @surrealdb/node: ${process.platform}-${process.arch}`);
62
+ console.error(
63
+ `\n✗ Unsupported platform for @surrealdb/node: ${process.platform}-${process.arch}`,
64
+ );
61
65
  process.exit(1);
62
66
  }
63
67
 
64
- const surrealNodeDistDir = dirname(fileURLToPath(import.meta.resolve("@surrealdb/node")));
68
+ const surrealNodeDistDir = dirname(
69
+ fileURLToPath(import.meta.resolve("@surrealdb/node")),
70
+ );
65
71
  const bindingsDir = join(outDir, "bindings");
66
72
  await mkdir(bindingsDir, { recursive: true });
67
73
  await cp(join(surrealNodeDistDir, nativeFile), join(bindingsDir, nativeFile));
@@ -74,7 +80,9 @@ if (isRemoteBuild) {
74
80
  const original = await Bun.file(args.path).text();
75
81
  const marker = "const require = createRequire(import.meta.url);";
76
82
  if (!original.includes(marker)) {
77
- throw new Error("@surrealdb/node's loader shape changed — expected createRequire marker not found");
83
+ throw new Error(
84
+ "@surrealdb/node's loader shape changed — expected createRequire marker not found",
85
+ );
78
86
  }
79
87
  // Wrap `require` so the host platform's own .node lookup is redirected to the copy in
80
88
  // out/bindings/; every other platform's file and the optional sibling npm packages fall
@@ -119,28 +127,42 @@ const geoipLibDir = dirname(fileURLToPath(import.meta.resolve("geoip-lite")));
119
127
  const geoipDataDir = join(geoipLibDir, "..", "data");
120
128
  const outGeoipDataDir = join(outDir, "geoip-data");
121
129
  await mkdir(outGeoipDataDir, { recursive: true });
122
- await cp(join(geoipDataDir, "geoip-country.dat"), join(outGeoipDataDir, "geoip-country.dat"));
123
- await cp(join(geoipDataDir, "geoip-country6.dat"), join(outGeoipDataDir, "geoip-country6.dat"));
130
+ await cp(
131
+ join(geoipDataDir, "geoip-country.dat"),
132
+ join(outGeoipDataDir, "geoip-country.dat"),
133
+ );
134
+ await cp(
135
+ join(geoipDataDir, "geoip-country6.dat"),
136
+ join(outGeoipDataDir, "geoip-country6.dat"),
137
+ );
124
138
  console.log("✓ Copied geoip-lite country data -> out/geoip-data");
125
139
 
126
140
  const geoipDataPlugin: BunPlugin = {
127
141
  name: "geoip-lite-data-dir",
128
142
  setup(build) {
129
- build.onLoad({ filter: /geoip-lite[\\/]lib[\\/]geoip\.js$/ }, async (args) => {
130
- const original = await Bun.file(args.path).text();
131
- const marker = "global.geodatadir || process.env.GEODATADIR || '../data/'";
132
- if (!original.includes(marker)) {
133
- throw new Error("geoip-lite's data dir resolution changed — expected marker not found");
134
- }
135
- const patched = original.replace(
136
- "var geodatadir = path.resolve(\n\t__dirname,\n\t" + marker + "\n);",
137
- "var geodatadir = require('path').join(require('path').dirname(process.execPath), 'geoip-data');",
138
- );
139
- if (patched === original) {
140
- throw new Error("geoip-lite's geodatadir block didn't match expected shape — patch did not apply");
141
- }
142
- return { contents: patched, loader: "js" };
143
- });
143
+ build.onLoad(
144
+ { filter: /geoip-lite[\\/]lib[\\/]geoip\.js$/ },
145
+ async (args) => {
146
+ const original = await Bun.file(args.path).text();
147
+ const marker =
148
+ "global.geodatadir || process.env.GEODATADIR || '../data/'";
149
+ if (!original.includes(marker)) {
150
+ throw new Error(
151
+ "geoip-lite's data dir resolution changed — expected marker not found",
152
+ );
153
+ }
154
+ const patched = original.replace(
155
+ "var geodatadir = path.resolve(\n\t__dirname,\n\t" + marker + "\n);",
156
+ "var geodatadir = require('path').join(require('path').dirname(process.execPath), 'geoip-data');",
157
+ );
158
+ if (patched === original) {
159
+ throw new Error(
160
+ "geoip-lite's geodatadir block didn't match expected shape — patch did not apply",
161
+ );
162
+ }
163
+ return { contents: patched, loader: "js" };
164
+ },
165
+ );
144
166
  },
145
167
  };
146
168
 
@@ -173,7 +195,9 @@ printBanner({ mode: "production" });
173
195
  // reasoning for @surrealdb/node on a remote build — surreal.ts's dynamic import of it is
174
196
  // unreachable when db.path is remote, so it's safe to leave unresolved.
175
197
  external: isRemoteBuild ? ["vite", "@surrealdb/node"] : ["vite"],
176
- plugins: [surrealNativePlugin, geoipDataPlugin].filter((p): p is BunPlugin => p !== undefined),
198
+ plugins: [surrealNativePlugin, geoipDataPlugin].filter(
199
+ (p): p is BunPlugin => p !== undefined,
200
+ ),
177
201
  // The CLI's `bun build --compile` implicitly inlines process.env.NODE_ENV as "production";
178
202
  // the Bun.build() JS API doesn't, so view.ts's top-level `isDev` check would otherwise read
179
203
  // undefined and take the dev branch (importing the externalized, not-on-disk `vite`).
package/src/cli/create.ts CHANGED
@@ -1,11 +1,20 @@
1
1
  import { existsSync, mkdirSync, readdirSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
 
4
- const c = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", cyan: "\x1b[36m", green: "\x1b[32m" };
4
+ const c = {
5
+ reset: "\x1b[0m",
6
+ bold: "\x1b[1m",
7
+ dim: "\x1b[2m",
8
+ cyan: "\x1b[36m",
9
+ green: "\x1b[32m",
10
+ };
5
11
 
6
12
  const targetArg = process.argv[3] ?? ".";
7
13
  const targetDir = resolve(process.cwd(), targetArg);
8
- const projectName = targetArg === "." ? "njin-app" : targetArg.split(/[\\/]/).filter(Boolean).pop()!;
14
+ const projectName =
15
+ targetArg === "."
16
+ ? "njin-app"
17
+ : targetArg.split(/[\\/]/).filter(Boolean).pop()!;
9
18
 
10
19
  if (existsSync(targetDir) && readdirSync(targetDir).length > 0) {
11
20
  console.error(`✖ Directory "${targetArg}" already exists and is not empty.`);
@@ -35,11 +44,23 @@ mkdirSync(targetDir, { recursive: true });
35
44
 
36
45
  // `--strip-components=2` drops the `njin-<version>/template/` prefix baked into
37
46
  // GitHub's tag-tarball layout, so template/ contents land directly in targetDir.
38
- const tar = Bun.spawn(["tar", "-xz", "-f", "-", "--strip-components=2", "-C", targetDir, `njin-${pkg.version}/template`], {
39
- stdin: res.body,
40
- stdout: "inherit",
41
- stderr: "inherit",
42
- });
47
+ const tar = Bun.spawn(
48
+ [
49
+ "tar",
50
+ "-xz",
51
+ "-f",
52
+ "-",
53
+ "--strip-components=2",
54
+ "-C",
55
+ targetDir,
56
+ `njin-${pkg.version}/template`,
57
+ ],
58
+ {
59
+ stdin: res.body,
60
+ stdout: "inherit",
61
+ stderr: "inherit",
62
+ },
63
+ );
43
64
 
44
65
  const tarExitCode = await tar.exited;
45
66
 
@@ -54,7 +75,10 @@ if (tarExitCode !== 0) {
54
75
  const pkgPath = join(targetDir, "package.json");
55
76
  const scaffoldedPkg = await Bun.file(pkgPath).json();
56
77
  scaffoldedPkg.name = projectName;
57
- scaffoldedPkg.dependencies = { "@njinlabs/njin": `^${pkg.version}`, ...scaffoldedPkg.dependencies };
78
+ scaffoldedPkg.dependencies = {
79
+ "@njinlabs/njin": `^${pkg.version}`,
80
+ ...scaffoldedPkg.dependencies,
81
+ };
58
82
  await Bun.write(pkgPath, JSON.stringify(scaffoldedPkg, null, 2) + "\n");
59
83
 
60
84
  console.log("Installing dependencies...\n");
@@ -69,7 +93,9 @@ const installExitCode = await install.exited;
69
93
 
70
94
  if (installExitCode !== 0) {
71
95
  console.error(`\n✖ "bun install" failed (exit code ${installExitCode}).`);
72
- console.error(` The project was scaffolded at ${targetDir}, but dependencies are not installed.`);
96
+ console.error(
97
+ ` The project was scaffolded at ${targetDir}, but dependencies are not installed.`,
98
+ );
73
99
  console.error(` Run "cd ${targetArg} && bun install" manually to retry.\n`);
74
100
  process.exit(installExitCode);
75
101
  }
package/src/cli/index.ts CHANGED
@@ -28,6 +28,7 @@ switch (command) {
28
28
  build Build for production -> ./out (public/, _admin/, views/, server)
29
29
  build:worker Build a headless worker for production -> ./out (public/, _admin/, views/, worker.js)
30
30
  start Run from source in production mode (no compile)
31
- update Update @njinlabs/njin to the latest version and refresh _admin/`);
31
+ update Update @njinlabs/njin to the latest version and refresh _admin/
32
+ (--beta, or --tag <name>, follows a prerelease channel instead)`);
32
33
  process.exit(command ? 1 : 0);
33
34
  }
package/src/cli/update.ts CHANGED
@@ -3,13 +3,21 @@ import { existsSync } from "node:fs";
3
3
  import { mkdir, rm } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
 
6
- const c = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", cyan: "\x1b[36m", green: "\x1b[32m" };
6
+ const c = {
7
+ reset: "\x1b[0m",
8
+ bold: "\x1b[1m",
9
+ dim: "\x1b[2m",
10
+ cyan: "\x1b[36m",
11
+ green: "\x1b[32m",
12
+ };
7
13
 
8
14
  const root = process.cwd();
9
15
  const pkgPath = join(root, "package.json");
10
16
 
11
17
  if (!existsSync(pkgPath)) {
12
- console.error(`✖ No package.json found in ${root} — run this from an njin project root.`);
18
+ console.error(
19
+ `✖ No package.json found in ${root} — run this from an njin project root.`,
20
+ );
13
21
  process.exit(1);
14
22
  }
15
23
 
@@ -21,41 +29,98 @@ const depKey = pkg.dependencies?.["@njinlabs/njin"]
21
29
  : undefined;
22
30
 
23
31
  if (!depKey) {
24
- console.error(`✖ "@njinlabs/njin" is not listed in this project's package.json — run this from an njin project root.`);
32
+ console.error(
33
+ `✖ "@njinlabs/njin" is not listed in this project's package.json — run this from an njin project root.`,
34
+ );
35
+ process.exit(1);
36
+ }
37
+
38
+ const currentPinned = String(pkg[depKey]["@njinlabs/njin"]).replace(
39
+ /^[\^~]/,
40
+ "",
41
+ );
42
+
43
+ // `--beta` is shorthand for `--tag beta`. Without either, only the stable channel (`latest`)
44
+ // is followed — a prerelease is published under its own dist-tag and never becomes `latest`.
45
+ const args = process.argv.slice(3);
46
+ const tagIndex = args.indexOf("--tag");
47
+ const requestedTag = args.includes("--beta")
48
+ ? "beta"
49
+ : tagIndex !== -1
50
+ ? args[tagIndex + 1]
51
+ : undefined;
52
+
53
+ if (tagIndex !== -1 && (!requestedTag || requestedTag.startsWith("-"))) {
54
+ console.error(`✖ --tag needs a dist-tag name, e.g. "--tag beta".`);
25
55
  process.exit(1);
26
56
  }
27
57
 
28
- const currentPinned = String(pkg[depKey]["@njinlabs/njin"]).replace(/^[\^~]/, "");
58
+ const distTag = requestedTag ?? "latest";
29
59
 
30
- console.log("Checking latest njin version...\n");
60
+ console.log(
61
+ `Checking ${distTag === "latest" ? "latest" : `"${distTag}"`} njin version...\n`,
62
+ );
31
63
 
32
- const registryRes = await fetch("https://registry.npmjs.org/@njinlabs/njin/latest");
64
+ const registryRes = await fetch(
65
+ `https://registry.npmjs.org/@njinlabs/njin/${encodeURIComponent(distTag)}`,
66
+ );
33
67
  if (!registryRes.ok) {
34
- console.error(`✖ Could not reach the npm registry (HTTP ${registryRes.status}).`);
68
+ console.error(
69
+ registryRes.status === 404
70
+ ? `✖ There is no "${distTag}" release of njin on npm.`
71
+ : `✖ Could not reach the npm registry (HTTP ${registryRes.status}).`,
72
+ );
35
73
  process.exit(1);
36
74
  }
37
75
 
38
76
  const { version: latest } = (await registryRes.json()) as { version: string };
39
77
  const adminDir = join(root, "_admin");
40
78
 
79
+ // A project already on a prerelease would otherwise be moved *back* to the older stable
80
+ // version by a plain `njin update` — only an explicit --beta/--tag may change channel.
81
+ const isBehindCurrent = (() => {
82
+ try {
83
+ return Bun.semver.order(latest, currentPinned) === -1;
84
+ } catch {
85
+ return false;
86
+ }
87
+ })();
88
+
89
+ if (!requestedTag && isBehindCurrent) {
90
+ console.log(
91
+ `${c.green}✓${c.reset} This project is on ${currentPinned}, ahead of the latest stable (${latest}) — nothing to update. Use "--beta" to follow the beta channel.`,
92
+ );
93
+ process.exit(0);
94
+ }
95
+
41
96
  if (currentPinned === latest && existsSync(adminDir)) {
42
- console.log(`${c.green}✓${c.reset} Already on the latest version (${latest}) — nothing to update.`);
97
+ console.log(
98
+ `${c.green}✓${c.reset} Already on the latest version (${latest}) — nothing to update.`,
99
+ );
43
100
  process.exit(0);
44
101
  }
45
102
 
46
103
  console.log(`Updating njin ${currentPinned} -> ${latest}...\n`);
47
104
 
48
- pkg[depKey]["@njinlabs/njin"] = `^${latest}`;
105
+ // A prerelease is pinned exactly: "^0.11.0-beta.1" would also accept later stable 0.x releases
106
+ // on the next install, silently leaving the beta channel the user chose.
107
+ pkg[depKey]["@njinlabs/njin"] = distTag === "latest" ? `^${latest}` : latest;
49
108
  await Bun.write(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
50
109
 
51
110
  console.log("Installing dependencies...\n");
52
111
 
53
- const install = Bun.spawn(["bun", "install"], { cwd: root, stdout: "inherit", stderr: "inherit" });
112
+ const install = Bun.spawn(["bun", "install"], {
113
+ cwd: root,
114
+ stdout: "inherit",
115
+ stderr: "inherit",
116
+ });
54
117
  const installExitCode = await install.exited;
55
118
 
56
119
  if (installExitCode !== 0) {
57
120
  console.error(`\n✖ "bun install" failed (exit code ${installExitCode}).`);
58
- console.error(` package.json now points at njin ${latest}; run "bun install" manually to retry.`);
121
+ console.error(
122
+ ` package.json now points at njin ${latest}; run "bun install" manually to retry.`,
123
+ );
59
124
  process.exit(installExitCode);
60
125
  }
61
126
 
@@ -82,11 +147,23 @@ await mkdir(adminDir, { recursive: true });
82
147
 
83
148
  // --strip-components=3 drops the `njin-<version>/template/_admin/` prefix baked into GitHub's
84
149
  // tag-tarball layout, so _admin/'s own contents land directly in adminDir.
85
- const tar = Bun.spawn(["tar", "-xz", "-f", "-", "--strip-components=3", "-C", adminDir, `njin-${latest}/template/_admin`], {
86
- stdin: res.body,
87
- stdout: "inherit",
88
- stderr: "inherit",
89
- });
150
+ const tar = Bun.spawn(
151
+ [
152
+ "tar",
153
+ "-xz",
154
+ "-f",
155
+ "-",
156
+ "--strip-components=3",
157
+ "-C",
158
+ adminDir,
159
+ `njin-${latest}/template/_admin`,
160
+ ],
161
+ {
162
+ stdin: res.body,
163
+ stdout: "inherit",
164
+ stderr: "inherit",
165
+ },
166
+ );
90
167
 
91
168
  const tarExitCode = await tar.exited;
92
169
 
@@ -7,6 +7,10 @@ import elysia from "../modules/elysia";
7
7
  import file from "../modules/file";
8
8
  import img from "../modules/img";
9
9
  import logger from "../modules/logger";
10
+ import mcp from "../modules/mcp";
11
+ import mcpToken from "../modules/mcp_token";
12
+ import mcpUpload from "../modules/mcp_upload";
13
+ import oauth from "../modules/oauth";
10
14
  import plugin from "../modules/plugin";
11
15
  import setup from "../modules/setup";
12
16
  import surreal from "../modules/surreal";
@@ -31,9 +35,14 @@ const modules = [
31
35
  await plugin.init(),
32
36
  await file.init(),
33
37
  await auth.init(),
38
+ await mcpToken.init(),
34
39
  await setup.init(),
35
40
  await api.init(),
36
41
  await vars.init(),
42
+ // After api/vars so models' hooks and events are already registered (api.init drains them).
43
+ await mcp.init(),
44
+ await mcpUpload.init(),
45
+ await oauth.init(),
37
46
  await img.init(),
38
47
  await analytics.init(),
39
48
  await users.init(),
@@ -1,7 +1,7 @@
1
- import type { FileAdapter } from "../../modules/file";
2
- import { init } from "@paralleldrive/cuid2";
3
1
  import { join, resolve } from "node:path";
2
+ import { init } from "@paralleldrive/cuid2";
4
3
  import z from "zod";
4
+ import type { FileAdapter } from "../../modules/file";
5
5
  import { getConfig } from "../config";
6
6
  import { sanitizeFileName } from "../path_guard";
7
7
 
@@ -13,7 +13,11 @@ const createId = init({
13
13
 
14
14
  const meta = z.null();
15
15
 
16
- const bunFilesystemAdapter = ({ dir = "./uploads" }: { dir?: string } = {}): FileAdapter<typeof meta> => {
16
+ const bunFilesystemAdapter = ({
17
+ dir = "./uploads",
18
+ }: {
19
+ dir?: string;
20
+ } = {}): FileAdapter<typeof meta> => {
17
21
  return {
18
22
  meta,
19
23
  dir,
@@ -25,7 +29,10 @@ const bunFilesystemAdapter = ({ dir = "./uploads" }: { dir?: string } = {}): Fil
25
29
  // resolve(), not join() — dir is normally project-relative ("./uploads"), but an
26
30
  // already-absolute dir (as tests pass directly) must win outright rather than get
27
31
  // nested under rootDir.
28
- await Bun.write(join(resolve(getConfig().rootDir, dir), name), await file.arrayBuffer());
32
+ await Bun.write(
33
+ join(resolve(getConfig().rootDir, dir), name),
34
+ await file.arrayBuffer(),
35
+ );
29
36
 
30
37
  return {
31
38
  meta: null,
@@ -35,7 +42,8 @@ const bunFilesystemAdapter = ({ dir = "./uploads" }: { dir?: string } = {}): Fil
35
42
  url: `/uploads/${name}`,
36
43
  };
37
44
  },
38
- unlink: (file) => Bun.file(join(resolve(getConfig().rootDir, dir), file.name)).delete(),
45
+ unlink: (file) =>
46
+ Bun.file(join(resolve(getConfig().rootDir, dir), file.name)).delete(),
39
47
  };
40
48
  };
41
49
 
@@ -1,7 +1,7 @@
1
- import type { FileAdapter } from "../../modules/file";
2
1
  import { init } from "@paralleldrive/cuid2";
3
2
  import type { S3Options } from "bun";
4
3
  import z from "zod";
4
+ import type { FileAdapter } from "../../modules/file";
5
5
  import { sanitizeFileName } from "../path_guard";
6
6
 
7
7
  const createId = init({
@@ -22,10 +22,15 @@ type ResolveUrlOptions = {
22
22
  // Path-style works for AWS S3 and S3-compatible services alike (R2, Spaces, MinIO).
23
23
  // No process.env fallback here on purpose — the consuming project's config.ts is the
24
24
  // single place that decides whether a value comes from an env var or is hardcoded.
25
- export const resolveUrl = (key: string, options: ResolveUrlOptions = {}): string => {
26
- if (options.publicUrl) return `${options.publicUrl.replace(/\/$/, "")}/${key}`;
25
+ export const resolveUrl = (
26
+ key: string,
27
+ options: ResolveUrlOptions = {},
28
+ ): string => {
29
+ if (options.publicUrl)
30
+ return `${options.publicUrl.replace(/\/$/, "")}/${key}`;
27
31
 
28
- if (options.endpoint) return `${options.endpoint.replace(/\/$/, "")}/${options.bucket}/${key}`;
32
+ if (options.endpoint)
33
+ return `${options.endpoint.replace(/\/$/, "")}/${options.bucket}/${key}`;
29
34
 
30
35
  const region = options.region ?? "us-east-1";
31
36
  return `https://${options.bucket}.s3.${region}.amazonaws.com/${key}`;
@@ -34,7 +39,9 @@ export const resolveUrl = (key: string, options: ResolveUrlOptions = {}): string
34
39
  // `bucket` is required (unlike Bun.S3Client itself, which would otherwise fall back to
35
40
  // S3_BUCKET/AWS_BUCKET env vars) — resolveUrl() needs to know it regardless, and reading
36
41
  // it here directly would bypass the project's own config.ts as the single source of truth.
37
- const s3Adapter = (options: S3Options & { bucket: string; publicUrl?: string }): FileAdapter<typeof meta> => {
42
+ const s3Adapter = (
43
+ options: S3Options & { bucket: string; publicUrl?: string },
44
+ ): FileAdapter<typeof meta> => {
38
45
  const { publicUrl, ...s3Options } = options;
39
46
  const bucket = new Bun.S3Client(s3Options);
40
47
 
@@ -51,7 +58,12 @@ const s3Adapter = (options: S3Options & { bucket: string; publicUrl?: string }):
51
58
  name: key,
52
59
  size: file.size,
53
60
  type: file.type,
54
- url: resolveUrl(key, { publicUrl, endpoint: s3Options.endpoint, bucket: s3Options.bucket, region: s3Options.region }),
61
+ url: resolveUrl(key, {
62
+ publicUrl,
63
+ endpoint: s3Options.endpoint,
64
+ bucket: s3Options.bucket,
65
+ region: s3Options.region,
66
+ }),
55
67
  };
56
68
  },
57
69
  unlink: (file) => bucket.delete(file.name),