@protobase/cli 0.66.8 → 0.66.9
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/dist/cli/package.js +1 -1
- package/dist/dev/api-forward.js +3 -2
- package/dist/dev/app-files.js +11 -0
- package/dist/dev/check-remote-api.js +18 -0
- package/dist/dev/remote-api.js +58 -0
- package/dist/dev/run-ui.js +34 -0
- package/dist/dev/run.js +3 -7
- package/dist/main.js +23 -12
- package/package.json +7 -7
- package/skills/protobase/references/features/build-and-run.md +1 -0
- package/skills/protobase/references/reference/cli.md +12 -0
package/dist/cli/package.js
CHANGED
package/dist/dev/api-forward.js
CHANGED
|
@@ -9,7 +9,8 @@ import { request } from "node:http";
|
|
|
9
9
|
* discovery documents MCP clients read (`oauthWellKnownPaths`), below which a resource's own one is, and the push worker
|
|
10
10
|
* (`withDevServiceWorker`). Vite serves the rest.
|
|
11
11
|
*/
|
|
12
|
-
const
|
|
12
|
+
const apiRequestPattern = /^(\/api(\/|\?|$)|\/\.well-known\/webauthn(\?|$)|\/\.well-known\/oauth-(authorization-server|protected-resource)(\/|\?|$)|\/protobase\.sw\.js(\?|$))/;
|
|
13
|
+
const isApiRequest = (url) => apiRequestPattern.test(url);
|
|
13
14
|
const hopByHop = /* @__PURE__ */ new Set([
|
|
14
15
|
"connection",
|
|
15
16
|
"keep-alive",
|
|
@@ -67,4 +68,4 @@ const apiForward = (port) => ({
|
|
|
67
68
|
}
|
|
68
69
|
});
|
|
69
70
|
//#endregion
|
|
70
|
-
export { apiForward, isApiRequest };
|
|
71
|
+
export { apiForward, apiRequestPattern, isApiRequest };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { adminAppMain } from "../project/admin-app.js";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
//#region src/dev/app-files.ts
|
|
5
|
+
/** Fails, naming them, when the admin app in `appDir` lacks the files `protobase dev` serves it from. */
|
|
6
|
+
const requireAppFiles = (appDir) => {
|
|
7
|
+
const missing = ["index.html", adminAppMain].filter((file) => !existsSync(path.join(appDir, file)));
|
|
8
|
+
if (missing.length > 0) throw new Error(`The admin app is missing ${missing.join(", ")} in ${appDir}; \`protobase dev\` serves the app from there`);
|
|
9
|
+
};
|
|
10
|
+
//#endregion
|
|
11
|
+
export { requireAppFiles };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//#region src/dev/check-remote-api.ts
|
|
2
|
+
/**
|
|
3
|
+
* Fails, before the dev server starts, when the instance at `origin` cannot be reached or does not answer `/api/meta`
|
|
4
|
+
* as a Protobase API does, with the X-Meta-Version header.
|
|
5
|
+
*/
|
|
6
|
+
const checkRemoteApi = async (origin) => {
|
|
7
|
+
const meta = `${origin}/api/meta`;
|
|
8
|
+
const answer = await fetch(meta, {
|
|
9
|
+
signal: AbortSignal.timeout(1e4),
|
|
10
|
+
redirect: "manual"
|
|
11
|
+
}).catch((error) => {
|
|
12
|
+
throw new Error(`Cannot reach the API at ${meta}: ${error.cause?.message ?? error.message}`);
|
|
13
|
+
});
|
|
14
|
+
await answer.body?.cancel();
|
|
15
|
+
if (!answer.headers.has("x-meta-version")) throw new Error(`${origin} answered ${meta} with ${answer.status} and no X-Meta-Version header; is it a Protobase app?`);
|
|
16
|
+
};
|
|
17
|
+
//#endregion
|
|
18
|
+
export { checkRemoteApi };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { isAllowedHost } from "./allowed-host.js";
|
|
2
|
+
import { apiRequestPattern } from "./api-forward.js";
|
|
3
|
+
//#region src/dev/remote-api.ts
|
|
4
|
+
/**
|
|
5
|
+
* The origin of the Protobase instance `protobase dev --api` hands the API to, from the URL given: http or https, and
|
|
6
|
+
* no path, since an instance serves its API at /api of its origin.
|
|
7
|
+
*/
|
|
8
|
+
const remoteApiOrigin = (value) => {
|
|
9
|
+
const url = URL.parse(value);
|
|
10
|
+
if (!url || url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`--api expects an http or https URL, such as https://app.example.com, not "${value}"`);
|
|
11
|
+
if (url.pathname !== "/" || url.search) throw new Error(`--api expects the instance's origin, such as ${url.origin}, without a path`);
|
|
12
|
+
return url.origin;
|
|
13
|
+
};
|
|
14
|
+
const ofDevServer = (value, request) => {
|
|
15
|
+
const url = value === void 0 ? null : URL.parse(value);
|
|
16
|
+
return url?.host === request.headers.host ? url : void 0;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Hands the requests the API process answers in `protobase dev` (`isApiRequest`), and the API's WebSockets, to the
|
|
20
|
+
* instance at `origin` through Vite's proxy. The instance sees its own Host, and its own origin in the Origin and Referer
|
|
21
|
+
* of a page of this dev server, so its sign-in and its checks of a request's origin take them; a request from any other
|
|
22
|
+
* page keeps the origin it came with. Its cookies come back without their Domain, for localhost to keep, and a
|
|
23
|
+
* redirect to its own address comes back as a path on this server. A socket whose Host fails Vite's host check is
|
|
24
|
+
* refused, as Vite refuses such a request.
|
|
25
|
+
*/
|
|
26
|
+
const remoteApi = (origin) => {
|
|
27
|
+
let allowedHosts = [];
|
|
28
|
+
return {
|
|
29
|
+
name: "protobase-remote-api",
|
|
30
|
+
config: () => ({ server: { proxy: { [apiRequestPattern.source]: {
|
|
31
|
+
target: origin,
|
|
32
|
+
changeOrigin: true,
|
|
33
|
+
ws: true,
|
|
34
|
+
rewriteWsOrigin: true,
|
|
35
|
+
cookieDomainRewrite: { "*": "" },
|
|
36
|
+
bypass: (request, response) => {
|
|
37
|
+
if (!(response === void 0)) return void 0;
|
|
38
|
+
return isAllowedHost(request.headers.host, allowedHosts) ? void 0 : false;
|
|
39
|
+
},
|
|
40
|
+
configure: (proxy) => {
|
|
41
|
+
proxy.on("proxyReq", (upstream, request) => {
|
|
42
|
+
if (ofDevServer(request.headers.origin, request)) upstream.setHeader("origin", origin);
|
|
43
|
+
const referer = ofDevServer(request.headers.referer, request);
|
|
44
|
+
if (referer) upstream.setHeader("referer", `${origin}${referer.pathname}${referer.search}`);
|
|
45
|
+
});
|
|
46
|
+
proxy.on("proxyRes", (answer) => {
|
|
47
|
+
const location = answer.headers.location;
|
|
48
|
+
if (location?.startsWith(`${origin}/`)) answer.headers.location = location.slice(origin.length);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
} } } }),
|
|
52
|
+
configResolved: (config) => {
|
|
53
|
+
allowedHosts = config.server.allowedHosts;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
//#endregion
|
|
58
|
+
export { remoteApi, remoteApiOrigin };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { adminAppDir } from "../project/admin-app.js";
|
|
2
|
+
import { alsoOnIpv6, checkPortFree } from "./loopback.js";
|
|
3
|
+
import { remoteApi } from "./remote-api.js";
|
|
4
|
+
import { requireAppFiles } from "./app-files.js";
|
|
5
|
+
import { checkRemoteApi } from "./check-remote-api.js";
|
|
6
|
+
//#region src/dev/run-ui.ts
|
|
7
|
+
/**
|
|
8
|
+
* `protobase dev --api`: the admin app with the project's UI, theme and icons, as `runDev` serves it, with its API
|
|
9
|
+
* requests handed to a running instance instead of an API process of its own; no database and no project config are read.
|
|
10
|
+
*/
|
|
11
|
+
const runUiDev = async (options, out) => {
|
|
12
|
+
const appDir = options.appDir ?? adminAppDir;
|
|
13
|
+
requireAppFiles(appDir);
|
|
14
|
+
await checkRemoteApi(options.api);
|
|
15
|
+
if (options.port !== 0) await checkPortFree(options.port);
|
|
16
|
+
const { createAppServer } = await import("./app-server.js");
|
|
17
|
+
const server = await createAppServer({
|
|
18
|
+
...options,
|
|
19
|
+
appDir,
|
|
20
|
+
extensions: options.extensions ?? []
|
|
21
|
+
}, [remoteApi(options.api)]);
|
|
22
|
+
const closeAndThrow = async (error) => {
|
|
23
|
+
await server.close();
|
|
24
|
+
throw error;
|
|
25
|
+
};
|
|
26
|
+
await server.listen().catch(closeAndThrow);
|
|
27
|
+
const address = server.httpServer?.address();
|
|
28
|
+
const port = typeof address === "object" && address ? address.port : options.port;
|
|
29
|
+
if (server.httpServer) await alsoOnIpv6(server.httpServer, port).catch(closeAndThrow);
|
|
30
|
+
out(`protobase dev ready at http://localhost:${port}\n project ${options.projectDir} (UI only)\n api ${options.api}/api/v1, through http://localhost:${port}/api/v1\n login sign in as a user of ${options.api}\n`);
|
|
31
|
+
return server;
|
|
32
|
+
};
|
|
33
|
+
//#endregion
|
|
34
|
+
export { runUiDev };
|
package/dist/dev/run.js
CHANGED
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
import { loadNearestEnvFile } from "./env.js";
|
|
2
|
-
import { adminAppDir
|
|
2
|
+
import { adminAppDir } from "../project/admin-app.js";
|
|
3
3
|
import { alsoOnIpv6, checkPortFree } from "./loopback.js";
|
|
4
|
+
import { apiForward } from "./api-forward.js";
|
|
5
|
+
import { requireAppFiles } from "./app-files.js";
|
|
4
6
|
import { checkDatabase } from "./check-database.js";
|
|
5
7
|
import { authDriftLines } from "./auth-drift-lines.js";
|
|
6
8
|
import { loginLine } from "./login-line.js";
|
|
7
|
-
import { apiForward } from "./api-forward.js";
|
|
8
9
|
import { forkApiChild } from "./api-child.js";
|
|
9
|
-
import { existsSync } from "node:fs";
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
//#region src/dev/run.ts
|
|
12
|
-
const requireAppFiles = (appDir) => {
|
|
13
|
-
const missing = ["index.html", adminAppMain].filter((file) => !existsSync(path.join(appDir, file)));
|
|
14
|
-
if (missing.length > 0) throw new Error(`The admin app is missing ${missing.join(", ")} in ${appDir}; \`protobase dev\` serves the app from there`);
|
|
15
|
-
};
|
|
16
12
|
const runDev = async (options, out) => {
|
|
17
13
|
loadNearestEnvFile(options.projectDir);
|
|
18
14
|
const url = process.env[options.envVar];
|
package/dist/main.js
CHANGED
|
@@ -60,23 +60,34 @@ program.command("doctor").description("Check a config folder against the databas
|
|
|
60
60
|
}, out);
|
|
61
61
|
if (errors > 0) process.exitCode = 1;
|
|
62
62
|
});
|
|
63
|
-
|
|
64
|
-
const [{
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
const runUiDevAgainst = async (api, options) => {
|
|
64
|
+
const [{ runUiDev }, { remoteApiOrigin }] = await Promise.all([import("./dev/run-ui.js"), import("./dev/remote-api.js")]);
|
|
65
|
+
return runUiDev({
|
|
66
|
+
...options,
|
|
67
|
+
api: remoteApiOrigin(api)
|
|
68
|
+
}, out);
|
|
69
|
+
};
|
|
70
|
+
const runFullDev = async (envVar, jobs, options) => {
|
|
71
|
+
const { runDev } = await import("./dev/run.js");
|
|
70
72
|
const server = await runDev({
|
|
73
|
+
...options,
|
|
74
|
+
envVar,
|
|
75
|
+
jobs
|
|
76
|
+
}, out);
|
|
77
|
+
warn(placeholderSecretWarning(process.env));
|
|
78
|
+
return server;
|
|
79
|
+
};
|
|
80
|
+
program.command("dev").description("Serve the admin app and API for the project in the current directory").option("--port <port>", "port for the app and API", "5173").option("--env <variable>", "environment variable holding the database URL", "DATABASE_URL").option("--allowed-hosts <hosts>", "extra Host headers to accept, comma separated; a leading dot matches subdomains (.trycloudflare.com)").option("--cache-dir <dir>", "Vite's dependency cache, so parallel dev servers do not share one").option("--extend <dir>", "merge the protobase.config.ts and protobase.ui.tsx in this folder before the project's, repeatable", collect, []).option("--no-jobs", "run none of the app's jobs and deliver none of its events, such as against its hosted database").option("--api <url>", "serve the admin app alone, with the API of the running Protobase instance at this URL; no database or API process").action(async (opts) => {
|
|
81
|
+
const [{ resolveExtensions }, { writeProjectTypes }] = await Promise.all([import("./project/extensions.js"), import("./project/project-types.js")]);
|
|
82
|
+
await writeProjectTypes(process.cwd());
|
|
83
|
+
const shared = {
|
|
71
84
|
extensions: resolveExtensions(opts.extend),
|
|
72
85
|
port: Number(opts.port),
|
|
73
|
-
envVar: opts.env,
|
|
74
86
|
projectDir: process.cwd(),
|
|
75
87
|
cacheDir: opts.cacheDir && path.resolve(opts.cacheDir),
|
|
76
|
-
allowedHosts: parseHosts(opts.allowedHosts)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
warn(placeholderSecretWarning(process.env));
|
|
88
|
+
allowedHosts: parseHosts(opts.allowedHosts)
|
|
89
|
+
};
|
|
90
|
+
const server = opts.api ? await runUiDevAgainst(opts.api, shared) : await runFullDev(opts.env, opts.jobs, shared);
|
|
80
91
|
process.once("SIGINT", () => void server.close().then(() => process.exit(0)));
|
|
81
92
|
process.once("SIGTERM", () => void server.close().then(() => process.exit(0)));
|
|
82
93
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/cli",
|
|
3
|
-
"version": "0.66.
|
|
3
|
+
"version": "0.66.9",
|
|
4
4
|
"description": "The protobase command: scaffold, doctor, dev, build and serve a Protobase project.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -43,12 +43,12 @@
|
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@better-auth/core": "^1.7.12",
|
|
45
45
|
"@hono/node-server": "^2.1.3",
|
|
46
|
-
"@protobase/config": "0.66.
|
|
47
|
-
"@protobase/layout": "0.66.
|
|
48
|
-
"@protobase/query": "0.66.
|
|
49
|
-
"@protobase/schema": "0.66.
|
|
50
|
-
"@protobase/server": "0.66.
|
|
51
|
-
"@protobase/ui": "0.66.
|
|
46
|
+
"@protobase/config": "0.66.9",
|
|
47
|
+
"@protobase/layout": "0.66.9",
|
|
48
|
+
"@protobase/query": "0.66.9",
|
|
49
|
+
"@protobase/schema": "0.66.9",
|
|
50
|
+
"@protobase/server": "0.66.9",
|
|
51
|
+
"@protobase/ui": "0.66.9",
|
|
52
52
|
"@react-email/render": "^2.1.0",
|
|
53
53
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
54
54
|
"@sentry/vite-plugin": "^11.10.0",
|
|
@@ -7,6 +7,7 @@ Develop, build and run an app, on servers, in containers and on phones.
|
|
|
7
7
|
- [Node and Bun](../reference/cli.md#how-it-runs): The `protobase` command runs on Node 22.12 or later or on Bun, and a bundle runs on both unless it is built for Bun only.
|
|
8
8
|
- [Dev server](../reference/cli.md#dev): The admin app and the API on one port, with changes shown without a restart.
|
|
9
9
|
- [Dev server without jobs](../reference/cli.md#dev): `protobase dev --no-jobs` runs no jobs and delivers no events, webhooks or pushes, to develop against a hosted app's database without doing its work twice.
|
|
10
|
+
- [UI-only dev server](../reference/cli.md#ui-only) (beta): `protobase dev --api <url>` serves the app's UI from the project with the API of an app already running, such as its staging deploy, with no database.
|
|
10
11
|
- [Editor types](../reference/cli.md#types): `protobase types` gives the editor and `tsc` the types of the CLI that builds the app, with nothing installed.
|
|
11
12
|
- [Project scripts](../reference/cli.md#run): `protobase run` runs a seed or a migration script on the CLI's packages.
|
|
12
13
|
- [Migrate command](../reference/cli.md#migrate): `protobase migrate` applies the project's SQL migrations in one transaction, with nothing installed.
|
|
@@ -134,6 +134,7 @@ Exit code is 1 when there are errors. Run it after the project's migrations and
|
|
|
134
134
|
|
|
135
135
|
```sh
|
|
136
136
|
protobase dev [--port 5173] [--env DATABASE_URL] [--cache-dir <dir>] [--allowed-hosts <hosts>] [--extend <dir>] [--no-jobs] # run inside a project, e.g. bun run --cwd tests/apps/erp dev
|
|
137
|
+
protobase dev --api <url> [--port 5173] [--cache-dir <dir>] [--allowed-hosts <hosts>] [--extend <dir>] # the UI alone, on the API at <url>
|
|
137
138
|
```
|
|
138
139
|
|
|
139
140
|
One port, two processes: Vite serves the admin app from `@protobase/ui`'s `src/app/` (`dist/app/` when installed; `index.html` and `main.tsx`, or `main.js`; the command fails with a clear message when they are missing) and hands `/api`, supabase-js's `/api/sb` included, to the Hono app from `createAdmin`, which runs in a process of its own; the CLI's process never loads `@protobase/server`. Open `http://localhost:<port>`; the API reference is at `/api/docs`.
|
|
@@ -154,6 +155,17 @@ One port, two processes: Vite serves the admin app from `@protobase/ui`'s `src/a
|
|
|
154
155
|
- **Extensions:** `--extend <dir>` merges an extension in, its UI built into the dev page beside the project's, to try one out; its files reload like the project's.
|
|
155
156
|
- **Hot reload:** server code, data configs, `ui.ts` views, layouts and the config are loaded through Vite's SSR module graph, and the config is read again with each rebuild. A change inside the project (or, in this repository, in `packages/server`, `packages/query`, `packages/schema`) rebuilds the API on the next request without a restart; `/meta` and `X-Meta-Version` change, so open tabs refetch. The database pool survives reloads. Newly added `ui.ts` files appear after the next change inside the project.
|
|
156
157
|
|
|
158
|
+
### UI only
|
|
159
|
+
|
|
160
|
+
:::caution[Beta]
|
|
161
|
+
Sign-in with a passkey or an outside provider, such as Google, belongs to the instance's own address and does not return to the dev server; sign in with an emailed code or a password. Safari keeps no cookie of an `https` instance on `http://localhost`; use Chrome or Firefox.
|
|
162
|
+
:::
|
|
163
|
+
|
|
164
|
+
`--api <url>` serves the admin app with the project's UI, theme, icons and `--extend` folders, hot reloaded as above, and hands every request the API process would answer (`/api`, `/.well-known/webauthn`, the OAuth discovery documents and `/protobase.sw.js`), its WebSockets included, to the Protobase app already running at `<url>`, such as `https://staging.example.com` or another `protobase dev`. Nothing reads a database or the project's config: no `DATABASE_URL`, no `PROTOBASE_SECRET`, no migration checks and no jobs, and resources, roles and data are the instance's. Change the UI here and the server code where the instance runs.
|
|
165
|
+
|
|
166
|
+
- **Checked at start:** the instance must answer `/api/meta` with an `X-Meta-Version` header, as every Protobase app does; otherwise the command names the URL and what came back. `<url>` is an origin, without a path.
|
|
167
|
+
- **Sign-in:** sign in as a user of the instance. Requests from the dev server's own pages reach it with its own Host, Origin and Referer, so its origin checks pass; a request from any other site keeps its own origin, which the instance refuses. Its cookies come back without their `Domain`, for `localhost` to keep, and a redirect to its own address becomes a path on the dev server.
|
|
168
|
+
|
|
157
169
|
### Extending a config
|
|
158
170
|
|
|
159
171
|
`--extend <dir>` (repeatable) merges an extension's folder into the app before the project: its config and the code its paths find, the same as the project's. How each part merges, and what a name defined twice does, is in [Extensions](config.md#extensions).
|