@njinlabs/njin 0.10.2 → 0.11.0-beta.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 +31 -0
- package/package.json +19 -4
- package/src/cli/build-assets.ts +4 -1
- package/src/cli/build.ts +47 -23
- package/src/cli/create.ts +35 -9
- package/src/cli/index.ts +2 -1
- package/src/cli/update.ts +93 -16
- package/src/config/module.ts +9 -0
- package/src/core/adapters/bun_filesystem.ts +13 -5
- package/src/core/adapters/s3.ts +18 -6
- package/src/core/admin_schema.ts +54 -0
- package/src/core/banner.ts +18 -4
- package/src/core/config.ts +49 -11
- package/src/core/helper.ts +4 -1
- package/src/core/html_page.ts +63 -0
- package/src/core/model/data_type/array.ts +16 -5
- package/src/core/model/data_type/boolean.ts +4 -1
- package/src/core/model/data_type/date.ts +4 -1
- package/src/core/model/data_type/email.ts +4 -1
- package/src/core/model/data_type/file.ts +17 -4
- package/src/core/model/data_type/multi_file.ts +9 -4
- package/src/core/model/data_type/numeric.ts +4 -1
- package/src/core/model/data_type/object.ts +8 -2
- package/src/core/model/data_type/relation.ts +21 -5
- package/src/core/model/data_type/relation_many.ts +24 -6
- package/src/core/model/data_type/richtext.ts +4 -1
- package/src/core/model/data_type/select.ts +17 -3
- package/src/core/model/data_type/text.ts +4 -1
- package/src/core/model/hooks.ts +42 -15
- package/src/core/model/index.ts +101 -37
- package/src/core/path_guard.ts +10 -3
- package/src/core/plugin.ts +8 -1
- package/src/core/public_url.ts +24 -0
- package/src/core/route.ts +2 -1
- package/src/core/vars/index.ts +22 -8
- package/src/core/worker.ts +22 -6
- package/src/models/file.ts +1 -1
- package/src/models/user.ts +1 -1
- package/src/modules/admin.ts +2 -2
- package/src/modules/analytics.ts +60 -25
- package/src/modules/api.ts +67 -95
- package/src/modules/auth.ts +25 -8
- package/src/modules/elysia.ts +1 -1
- package/src/modules/file.ts +50 -33
- package/src/modules/img.ts +33 -14
- package/src/modules/logger.ts +1 -1
- package/src/modules/mcp.ts +437 -0
- package/src/modules/mcp_token.ts +295 -0
- package/src/modules/mcp_upload.ts +313 -0
- package/src/modules/oauth.ts +550 -0
- package/src/modules/setup.ts +13 -5
- package/src/modules/surreal.ts +43 -13
- package/src/modules/users.ts +23 -5
- package/src/modules/vars.ts +1 -1
- package/src/modules/view.ts +88 -54
package/README.md
CHANGED
|
@@ -334,6 +334,33 @@ 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
|
+
**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.
|
|
342
|
+
|
|
343
|
+
| Tool | |
|
|
344
|
+
| --- | --- |
|
|
345
|
+
| `list_models` | Every model and `vars` group with its JSON schema — agents call this first |
|
|
346
|
+
| `read_records`, `get_record` | Search, filter, sort, paginate, populate |
|
|
347
|
+
| `create_record`, `update_record`, `delete_record` | Validated through the model's own schema, so hooks and transforms run exactly as over REST |
|
|
348
|
+
| `get_vars`, `update_vars` | Settings groups |
|
|
349
|
+
| `create_upload_url`, `check_upload` | Add files (below) |
|
|
350
|
+
| `list_files`, `get_file`, `delete_file` | Manage uploaded files |
|
|
351
|
+
|
|
352
|
+
**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.
|
|
353
|
+
|
|
354
|
+
**Managing connections.** Every connected agent is a token you can revoke:
|
|
355
|
+
|
|
356
|
+
```bash
|
|
357
|
+
GET /api/mcp-token # connected agents (OAuth) and manually created tokens
|
|
358
|
+
DELETE /api/mcp-token/:id # revoke — takes effect immediately
|
|
359
|
+
POST /api/mcp-token # { name } → a long-lived token, shown once, for clients that can send an Authorization header
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
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).
|
|
363
|
+
|
|
337
364
|
## Admin panel
|
|
338
365
|
|
|
339
366
|
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 +376,7 @@ import bunFilesystemAdapter from "@njinlabs/njin/adapters/bun_filesystem";
|
|
|
349
376
|
|
|
350
377
|
export default defineConfig({
|
|
351
378
|
port: Number(process.env.PORT ?? 3000),
|
|
379
|
+
publicUrl: "https://your-site.com", // optional — only needed behind a proxy; used by the MCP/OAuth discovery documents
|
|
352
380
|
db: {
|
|
353
381
|
path: process.env.DB_PATH ?? "rocksdb://data",
|
|
354
382
|
namespace: process.env.DB_NAMESPACE ?? "general",
|
|
@@ -404,8 +432,11 @@ bunx njin dev # Start dev server (Elysia + Vite HMR)
|
|
|
404
432
|
bunx njin build # Build for production -> ./out (public/, _admin/, views/, compiled server)
|
|
405
433
|
bunx njin start # Run from source in production mode (no compile)
|
|
406
434
|
bunx njin update # Update @njinlabs/njin to the latest version and refresh _admin/
|
|
435
|
+
bunx njin update --beta # Follow the beta channel instead (or --tag <name>)
|
|
407
436
|
```
|
|
408
437
|
|
|
438
|
+
**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`.
|
|
439
|
+
|
|
409
440
|
Wire these as `package.json` scripts (`"dev": "njin dev"`, etc.) if you'd rather run `bun run dev`.
|
|
410
441
|
|
|
411
442
|
## License
|
package/package.json
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@njinlabs/njin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0-beta.1",
|
|
4
4
|
"description": "A modern framework for building company profiles, landing pages, and content-driven websites.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"keywords": [
|
|
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": [
|
|
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",
|
package/src/cli/build-assets.ts
CHANGED
|
@@ -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 (
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
123
|
-
|
|
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(
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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(
|
|
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 = {
|
|
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 =
|
|
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(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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 = {
|
|
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(
|
|
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 = {
|
|
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(
|
|
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(
|
|
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
|
|
58
|
+
const distTag = requestedTag ?? "latest";
|
|
29
59
|
|
|
30
|
-
console.log(
|
|
60
|
+
console.log(
|
|
61
|
+
`Checking ${distTag === "latest" ? "latest" : `"${distTag}"`} njin version...\n`,
|
|
62
|
+
);
|
|
31
63
|
|
|
32
|
-
const registryRes = await fetch(
|
|
64
|
+
const registryRes = await fetch(
|
|
65
|
+
`https://registry.npmjs.org/@njinlabs/njin/${encodeURIComponent(distTag)}`,
|
|
66
|
+
);
|
|
33
67
|
if (!registryRes.ok) {
|
|
34
|
-
console.error(
|
|
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(
|
|
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
|
-
|
|
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"], {
|
|
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(
|
|
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(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
package/src/config/module.ts
CHANGED
|
@@ -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 = ({
|
|
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(
|
|
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) =>
|
|
45
|
+
unlink: (file) =>
|
|
46
|
+
Bun.file(join(resolve(getConfig().rootDir, dir), file.name)).delete(),
|
|
39
47
|
};
|
|
40
48
|
};
|
|
41
49
|
|
package/src/core/adapters/s3.ts
CHANGED
|
@@ -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 = (
|
|
26
|
-
|
|
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)
|
|
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 = (
|
|
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, {
|
|
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),
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type z from "zod";
|
|
2
|
+
|
|
3
|
+
// Removes fields marked hideForm: true from a JSON schema's properties/required,
|
|
4
|
+
// recursing into nested object/array shapes so hidden fields never reach the admin panel.
|
|
5
|
+
const stripHiddenFields = (node: any): void => {
|
|
6
|
+
if (!node || typeof node !== "object") return;
|
|
7
|
+
|
|
8
|
+
if (node.properties) {
|
|
9
|
+
for (const [key, prop] of Object.entries(
|
|
10
|
+
node.properties as Record<string, any>,
|
|
11
|
+
)) {
|
|
12
|
+
if (prop?.hideForm) {
|
|
13
|
+
delete node.properties[key];
|
|
14
|
+
if (Array.isArray(node.required)) {
|
|
15
|
+
node.required = node.required.filter((r: string) => r !== key);
|
|
16
|
+
}
|
|
17
|
+
} else {
|
|
18
|
+
stripHiddenFields(prop);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (node.items) stripHiddenFields(node.items);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Shared by both models and vars groups on the /api/schema endpoint — strips
|
|
27
|
+
// non-JSON-representable renderAs types (relation/multi_relation/file) into
|
|
28
|
+
// plain JSON-schema shapes the admin panel can render a form from, and drops
|
|
29
|
+
// any field marked hideForm: true so it never reaches the admin panel.
|
|
30
|
+
export const toAdminSchema = (schema: z.ZodObject) => {
|
|
31
|
+
const jsonSchema = schema.toJSONSchema({
|
|
32
|
+
unrepresentable: "any",
|
|
33
|
+
override: (ctx) => {
|
|
34
|
+
if (ctx.jsonSchema.renderAs === "relation") {
|
|
35
|
+
ctx.jsonSchema.type = "object";
|
|
36
|
+
ctx.jsonSchema.properties = {
|
|
37
|
+
id: {
|
|
38
|
+
type: "string",
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
ctx.jsonSchema.required = ["id"];
|
|
42
|
+
ctx.jsonSchema.additionalProperties = {};
|
|
43
|
+
} else if (ctx.jsonSchema.renderAs === "multi_relation") {
|
|
44
|
+
(ctx.jsonSchema.items! as any).type = "string";
|
|
45
|
+
} else if (ctx.jsonSchema.renderAs === "file") {
|
|
46
|
+
ctx.jsonSchema.type = "string";
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
stripHiddenFields(jsonSchema);
|
|
52
|
+
|
|
53
|
+
return jsonSchema;
|
|
54
|
+
};
|