@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.
- package/README.md +48 -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 +79 -0
- package/src/core/banner.ts +18 -4
- package/src/core/config.ts +58 -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 +46 -26
- 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 +607 -0
- package/src/modules/mcp_token.ts +295 -0
- package/src/modules/mcp_upload.ts +313 -0
- package/src/modules/oauth.ts +579 -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,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.
|
|
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": [
|
|
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),
|