@protobase/cli 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/bin/protobase.mjs +4 -0
- package/dist/build/config-bundle.js +70 -0
- package/dist/build/deploy-bundle.js +42 -0
- package/dist/build/entry-code.js +23 -0
- package/dist/build/layout-check.js +50 -0
- package/dist/build/packages/binary-platform.js +81 -0
- package/dist/build/packages/copy-packages.js +59 -0
- package/dist/build/packages/host-packages.js +37 -0
- package/dist/build/packages/native-package.js +22 -0
- package/dist/build/packages/package-dir.js +32 -0
- package/dist/build/packages/package-tree.js +53 -0
- package/dist/build/packages/targets.js +38 -0
- package/dist/build/serve-runtime.js +16 -0
- package/dist/build/ui-bundle.js +33 -0
- package/dist/build/vite-bundle.js +36 -0
- package/dist/bundle/manifest.js +35 -0
- package/dist/db/connect.js +18 -0
- package/dist/db/introspect.js +47 -0
- package/dist/db/keys.js +15 -0
- package/dist/db/model.js +4 -0
- package/dist/db/queries.js +62 -0
- package/dist/dev/check-database.js +38 -0
- package/dist/dev/create-db.js +13 -0
- package/dist/dev/entry.d.ts +4 -0
- package/dist/dev/entry.js +32 -0
- package/dist/dev/env.js +15 -0
- package/dist/dev/hosts.js +4 -0
- package/dist/dev/project.js +39 -0
- package/dist/dev/run.js +88 -0
- package/dist/doctor/check.js +76 -0
- package/dist/doctor/load-config.js +10 -0
- package/dist/doctor/run.js +22 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/infer/check-values.js +14 -0
- package/dist/infer/compat.js +17 -0
- package/dist/infer/default-column.js +12 -0
- package/dist/infer/defaults.js +28 -0
- package/dist/infer/names.js +28 -0
- package/dist/infer/proposal.js +68 -0
- package/dist/infer/render-field.js +26 -0
- package/dist/infer/tenant.js +5 -0
- package/dist/infer/type-map.js +52 -0
- package/dist/main.d.ts +4 -0
- package/dist/main.js +84 -0
- package/dist/module-file.js +6 -0
- package/dist/package.js +4 -0
- package/dist/project/admin-app.js +9 -0
- package/dist/project/convention.js +8 -0
- package/dist/project/import-project-file.js +6 -0
- package/dist/project/pg-db.js +11 -0
- package/dist/project/project-ui.js +17 -0
- package/dist/scaffold/data-file.js +44 -0
- package/dist/scaffold/drift.js +30 -0
- package/dist/scaffold/existing-config.js +95 -0
- package/dist/scaffold/index-file.js +18 -0
- package/dist/scaffold/plan.js +128 -0
- package/dist/scaffold/project.js +13 -0
- package/dist/scaffold/prompt.js +70 -0
- package/dist/scaffold/resource-names.js +19 -0
- package/dist/scaffold/resource-plan.js +33 -0
- package/dist/scaffold/run.js +53 -0
- package/dist/scaffold/targets.js +15 -0
- package/dist/scaffold/ui-fields.js +30 -0
- package/dist/scaffold/ui-file.js +40 -0
- package/dist/serve/app.js +30 -0
- package/dist/serve/bundle-version.js +16 -0
- package/dist/serve/env.js +13 -0
- package/dist/serve/host-module-ids.js +21 -0
- package/dist/serve/host-modules.js +49 -0
- package/dist/serve/lifecycle.js +39 -0
- package/dist/serve/load-bundle.js +17 -0
- package/dist/serve/main.d.ts +1 -0
- package/dist/serve/main.js +13 -0
- package/dist/serve/serve-bundle.js +21 -0
- package/dist/serve/serve-dir.js +15 -0
- package/dist/serve/signals.js +11 -0
- package/dist/serve/static-site.js +46 -0
- package/dist/users/commands.js +31 -0
- package/dist/users/load-auth.js +15 -0
- package/dist/users/manage.js +21 -0
- package/dist/users/password.js +64 -0
- package/dist/users/register.js +94 -0
- package/dist/users/ttl.js +16 -0
- package/dist/version/compatibility.js +31 -0
- package/dist/version/version.js +5 -0
- package/package.json +55 -3
- package/README.md +0 -3
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createPgDb } from "../project/pg-db.js";
|
|
2
|
+
import { createServeApp } from "./app.js";
|
|
3
|
+
import { createAdaptorServer } from "@hono/node-server";
|
|
4
|
+
import pg from "pg";
|
|
5
|
+
//#region src/serve/lifecycle.ts
|
|
6
|
+
const listen = (server, port) => new Promise((resolve, reject) => {
|
|
7
|
+
server.once("error", reject);
|
|
8
|
+
server.listen(port, () => {
|
|
9
|
+
server.off("error", reject);
|
|
10
|
+
resolve();
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
const closeServer = (server) => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
14
|
+
const startServe = async ({ project, env, createDb = (url) => createPgDb(pg, url), site }) => {
|
|
15
|
+
if (!project.db && !env.databaseUrl) throw new Error("No database: set DATABASE_URL or export `db` from protobase.config.ts");
|
|
16
|
+
const ownDb = project.db ? void 0 : createDb(env.databaseUrl);
|
|
17
|
+
const db = project.db ?? ownDb;
|
|
18
|
+
const app = createServeApp({
|
|
19
|
+
project,
|
|
20
|
+
db,
|
|
21
|
+
requestLog: env.requestLog,
|
|
22
|
+
site
|
|
23
|
+
});
|
|
24
|
+
const server = createAdaptorServer({ fetch: app.fetch });
|
|
25
|
+
await listen(server, env.port).catch(async (error) => {
|
|
26
|
+
await ownDb?.destroy();
|
|
27
|
+
throw error;
|
|
28
|
+
});
|
|
29
|
+
const close = async () => {
|
|
30
|
+
await closeServer(server);
|
|
31
|
+
await ownDb?.destroy();
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
port: server.address().port,
|
|
35
|
+
close
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
//#endregion
|
|
39
|
+
export { startServe };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { bundleBasePath } from "../bundle/manifest.js";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
//#region src/serve/load-bundle.ts
|
|
5
|
+
const isObject = (value) => typeof value === "object" && value !== null;
|
|
6
|
+
const servedProject = (module, file) => {
|
|
7
|
+
const project = module.default;
|
|
8
|
+
if (!isObject(project)) throw new Error(`${file} has no default export; build it with \`protobase build\``);
|
|
9
|
+
if (!isObject(project.config)) throw new Error(`${file} has no \`config\`: export it from protobase.config.ts or add config/index.ts`);
|
|
10
|
+
if (typeof project.authenticate !== "function") throw new Error("No authenticator: export `authenticate` (and `auth`) from protobase.config.ts; see https://docs.protobase.net/reference/auth/");
|
|
11
|
+
const basePath = isObject(project.options) ? project.options.basePath : void 0;
|
|
12
|
+
if (basePath !== void 0 && basePath !== "/api/v1") throw new Error(`options.basePath is ${JSON.stringify(basePath)}; a bundle's UI and manifest expect the API at ${bundleBasePath}, so remove it (see https://docs.protobase.net/reference/cli/#the-manifest)`);
|
|
13
|
+
return project;
|
|
14
|
+
};
|
|
15
|
+
const loadBundle = async (file) => servedProject(await import(pathToFileURL(path.resolve(file)).href), file);
|
|
16
|
+
//#endregion
|
|
17
|
+
export { loadBundle, servedProject };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { serveBundle } from "./serve-bundle.js";
|
|
2
|
+
import { provideHostModules } from "./host-modules.js";
|
|
3
|
+
//#region src/serve/main.ts
|
|
4
|
+
const fail = (message) => {
|
|
5
|
+
process.stderr.write(`error: ${message}\n`);
|
|
6
|
+
process.exit(1);
|
|
7
|
+
};
|
|
8
|
+
provideHostModules();
|
|
9
|
+
const [file, ...extra] = process.argv.slice(2);
|
|
10
|
+
if (!file || extra.length > 0) fail("usage: protobase-serve.js <bundle.js>");
|
|
11
|
+
await serveBundle(file, process.env, (text) => process.stdout.write(text)).catch((error) => fail(error.message));
|
|
12
|
+
//#endregion
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { checkBundleVersion } from "./bundle-version.js";
|
|
2
|
+
import { readServeEnv } from "./env.js";
|
|
3
|
+
import { startServe } from "./lifecycle.js";
|
|
4
|
+
import { loadBundle } from "./load-bundle.js";
|
|
5
|
+
import { closeOnSignals } from "./signals.js";
|
|
6
|
+
//#region src/serve/serve-bundle.ts
|
|
7
|
+
const serveBundle = async (file, env, out, site) => {
|
|
8
|
+
const serveEnv = readServeEnv(env);
|
|
9
|
+
await checkBundleVersion(file);
|
|
10
|
+
const project = await loadBundle(file);
|
|
11
|
+
const server = await startServe({
|
|
12
|
+
project,
|
|
13
|
+
env: serveEnv,
|
|
14
|
+
site
|
|
15
|
+
});
|
|
16
|
+
closeOnSignals(server.close);
|
|
17
|
+
out(`protobase serve listening on port ${server.port}\n`);
|
|
18
|
+
return server;
|
|
19
|
+
};
|
|
20
|
+
//#endregion
|
|
21
|
+
export { serveBundle };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { readManifest } from "../bundle/manifest.js";
|
|
2
|
+
import { serveBundle } from "./serve-bundle.js";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
//#region src/serve/serve-dir.ts
|
|
5
|
+
const serveBundleDir = async (dir, env, out) => {
|
|
6
|
+
const manifest = await readManifest(dir);
|
|
7
|
+
const site = {
|
|
8
|
+
publicDir: path.resolve(dir, manifest.public),
|
|
9
|
+
spa: manifest.spa,
|
|
10
|
+
api: manifest.api
|
|
11
|
+
};
|
|
12
|
+
return serveBundle(path.join(dir, manifest.server), env, out, site);
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { serveBundleDir };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/serve/signals.ts
|
|
2
|
+
const signals = ["SIGTERM", "SIGINT"];
|
|
3
|
+
const closeOnSignals = (close, exit = (code) => process.exit(code)) => {
|
|
4
|
+
const shutdown = () => {
|
|
5
|
+
signals.forEach((signal) => process.off(signal, shutdown));
|
|
6
|
+
close().then(() => exit(0));
|
|
7
|
+
};
|
|
8
|
+
signals.forEach((signal) => process.on(signal, shutdown));
|
|
9
|
+
};
|
|
10
|
+
//#endregion
|
|
11
|
+
export { closeOnSignals };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { getMimeType } from "hono/utils/mime";
|
|
4
|
+
//#region src/serve/static-site.ts
|
|
5
|
+
const missing = ["ENOENT", "ENOTDIR"];
|
|
6
|
+
const statOrUndefined = (file) => stat(file).catch((error) => {
|
|
7
|
+
if (missing.includes(error.code ?? "")) return void 0;
|
|
8
|
+
throw error;
|
|
9
|
+
});
|
|
10
|
+
const decodePath = (pathname) => {
|
|
11
|
+
try {
|
|
12
|
+
return decodeURIComponent(pathname);
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (error instanceof URIError) return void 0;
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
const fileFor = async (publicDir, pathname) => {
|
|
19
|
+
const decoded = decodePath(pathname);
|
|
20
|
+
if (decoded === void 0 || decoded.includes("\0")) return void 0;
|
|
21
|
+
const file = path.join(publicDir, decoded);
|
|
22
|
+
if (file !== publicDir && !file.startsWith(`${publicDir}${path.sep}`)) return void 0;
|
|
23
|
+
const info = await statOrUndefined(file);
|
|
24
|
+
if (info?.isFile()) return file;
|
|
25
|
+
if (!info?.isDirectory()) return void 0;
|
|
26
|
+
const index = path.join(file, "index.html");
|
|
27
|
+
return (await statOrUndefined(index))?.isFile() ? index : void 0;
|
|
28
|
+
};
|
|
29
|
+
const cacheControl = (publicDir, file) => file.startsWith(path.join(publicDir, "assets", path.sep)) ? "public, max-age=31536000, immutable" : "no-cache";
|
|
30
|
+
const fileResponse = async (publicDir, file) => new Response(await readFile(file), { headers: {
|
|
31
|
+
"content-type": getMimeType(file) ?? "application/octet-stream",
|
|
32
|
+
"cache-control": cacheControl(publicDir, file)
|
|
33
|
+
} });
|
|
34
|
+
const isNavigation = (request) => (request.headers.get("accept") ?? "").includes("text/html");
|
|
35
|
+
const serveStaticSite = async ({ publicDir, spa }, request) => {
|
|
36
|
+
if (request.method !== "GET" && request.method !== "HEAD") return new Response("Method Not Allowed", {
|
|
37
|
+
status: 405,
|
|
38
|
+
headers: { allow: "GET, HEAD" }
|
|
39
|
+
});
|
|
40
|
+
const file = await fileFor(publicDir, new URL(request.url).pathname);
|
|
41
|
+
if (file) return fileResponse(publicDir, file);
|
|
42
|
+
if (isNavigation(request)) return fileResponse(publicDir, path.join(publicDir, spa));
|
|
43
|
+
return new Response("Not Found", { status: 404 });
|
|
44
|
+
};
|
|
45
|
+
//#endregion
|
|
46
|
+
export { serveStaticSite };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { resolvePassword } from "./password.js";
|
|
2
|
+
//#region src/users/commands.ts
|
|
3
|
+
const createUserCommand = async (api, options, deps, out) => {
|
|
4
|
+
if (!options.email.includes("@")) throw new Error(`"${options.email}" is not an email address`);
|
|
5
|
+
const { password, generated } = await resolvePassword(options, deps);
|
|
6
|
+
const role = !await api.hasUsers() ? "admin" : options.role;
|
|
7
|
+
const user = await api.createUser({
|
|
8
|
+
email: options.email,
|
|
9
|
+
password,
|
|
10
|
+
...role && { role },
|
|
11
|
+
...options.name && { name: options.name }
|
|
12
|
+
});
|
|
13
|
+
out(`Created ${user.role} ${user.email}\n`);
|
|
14
|
+
if (generated) out(`Password (shown once): ${password}\n`);
|
|
15
|
+
};
|
|
16
|
+
const listUsersCommand = async (api, out) => {
|
|
17
|
+
const users = await api.listUsers();
|
|
18
|
+
if (users.length === 0) {
|
|
19
|
+
out("No users yet. Create the first admin with: protobase users create you@example.com\n");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const width = Math.max(...users.map((user) => user.email.length), 5);
|
|
23
|
+
out(`${"EMAIL".padEnd(width)} ${"ROLE".padEnd(8)} CREATED\n`);
|
|
24
|
+
for (const user of users) out(`${user.email.padEnd(width)} ${user.role.padEnd(8)} ${user.createdAt}${user.banned ? " disabled" : ""}\n`);
|
|
25
|
+
};
|
|
26
|
+
const listRolesCommand = async (api, out) => {
|
|
27
|
+
const { roles, defaultRole } = await api.roles();
|
|
28
|
+
for (const role of roles) out(`${role}${role === defaultRole ? " (default for new users)" : ""}\n`);
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
export { createUserCommand, listRolesCommand, listUsersCommand };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { loadNearestEnvFile } from "../dev/env.js";
|
|
2
|
+
import { importProjectFile } from "../project/import-project-file.js";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
//#region src/users/load-auth.ts
|
|
6
|
+
const loadProjectAuth = async (projectDir) => {
|
|
7
|
+
loadNearestEnvFile(projectDir);
|
|
8
|
+
const file = path.join(projectDir, "protobase.config.ts");
|
|
9
|
+
if (!existsSync(file)) throw new Error(`No protobase.config.ts in ${projectDir}; run this inside a project`);
|
|
10
|
+
const auth = (await importProjectFile(file)).default?.auth;
|
|
11
|
+
if (!auth) throw new Error("protobase.config.ts does not export `auth`; users are managed by Better Auth");
|
|
12
|
+
return auth;
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { loadProjectAuth };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
//#region src/users/manage.ts
|
|
2
|
+
const deleteUserCommand = async (api, options, confirm, out) => {
|
|
3
|
+
if (!options.yes) {
|
|
4
|
+
if ((await confirm(`This permanently deletes ${options.email}. Type the email to confirm: `)).trim() !== options.email) throw new Error("The typed email does not match; nothing was deleted");
|
|
5
|
+
}
|
|
6
|
+
await api.deleteUser(options.email);
|
|
7
|
+
out(`Deleted ${options.email}\n`);
|
|
8
|
+
};
|
|
9
|
+
const setRoleCommand = async (api, options, out) => {
|
|
10
|
+
const user = await api.setUserRole(options);
|
|
11
|
+
out(`${user.email} is now ${user.role}\n`);
|
|
12
|
+
};
|
|
13
|
+
const setDisabledCommand = async (api, options, out) => {
|
|
14
|
+
await api.setUserBanned({
|
|
15
|
+
email: options.email,
|
|
16
|
+
banned: options.disabled
|
|
17
|
+
});
|
|
18
|
+
out(options.disabled ? `Disabled ${options.email}: sessions revoked; tokens already issued stop working within 15 minutes\n` : `Enabled ${options.email}\n`);
|
|
19
|
+
};
|
|
20
|
+
//#endregion
|
|
21
|
+
export { deleteUserCommand, setDisabledCommand, setRoleCommand };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
//#region src/users/password.ts
|
|
3
|
+
const rejectPasswordArgument = (value) => {
|
|
4
|
+
if (value === void 0) return;
|
|
5
|
+
throw new Error("Passwords are never accepted as arguments (they end up in shell history); type it at the prompt, or use --password-stdin or --generate-password");
|
|
6
|
+
};
|
|
7
|
+
const generatePassword = () => randomBytes(18).toString("base64url");
|
|
8
|
+
const readPasswordFromStream = async (input) => {
|
|
9
|
+
const chunks = [];
|
|
10
|
+
for await (const chunk of input) chunks.push(Buffer.from(chunk));
|
|
11
|
+
const password = Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
|
|
12
|
+
if (!password) throw new Error("No password on stdin");
|
|
13
|
+
return password;
|
|
14
|
+
};
|
|
15
|
+
const readHidden = (question) => new Promise((resolve, reject) => {
|
|
16
|
+
const { stdin, stdout } = process;
|
|
17
|
+
if (!stdin.isTTY) {
|
|
18
|
+
reject(/* @__PURE__ */ new Error("A password prompt needs a terminal; use --password-stdin or --generate-password"));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
stdout.write(question);
|
|
22
|
+
stdin.setRawMode(true);
|
|
23
|
+
stdin.resume();
|
|
24
|
+
let value = "";
|
|
25
|
+
const onData = (chunk) => {
|
|
26
|
+
for (const char of chunk.toString("utf8")) {
|
|
27
|
+
if (char === "") {
|
|
28
|
+
stdin.setRawMode(false);
|
|
29
|
+
stdin.off("data", onData);
|
|
30
|
+
reject(/* @__PURE__ */ new Error("Cancelled"));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (char === "\r" || char === "\n") {
|
|
34
|
+
stdin.setRawMode(false);
|
|
35
|
+
stdin.pause();
|
|
36
|
+
stdin.off("data", onData);
|
|
37
|
+
stdout.write("\n");
|
|
38
|
+
resolve(value);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
value = char === "" ? value.slice(0, -1) : value + char;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
stdin.on("data", onData);
|
|
45
|
+
});
|
|
46
|
+
const resolvePassword = async (options, deps) => {
|
|
47
|
+
if (options.passwordStdin && options.generatePassword) throw new Error("Use either --password-stdin or --generate-password, not both");
|
|
48
|
+
if (options.generatePassword) return {
|
|
49
|
+
password: generatePassword(),
|
|
50
|
+
generated: true
|
|
51
|
+
};
|
|
52
|
+
if (options.passwordStdin) return {
|
|
53
|
+
password: await readPasswordFromStream(deps.stdin),
|
|
54
|
+
generated: false
|
|
55
|
+
};
|
|
56
|
+
const password = await deps.prompt("Password: ");
|
|
57
|
+
if (password !== await deps.prompt("Repeat password: ")) throw new Error("The passwords do not match");
|
|
58
|
+
return {
|
|
59
|
+
password,
|
|
60
|
+
generated: false
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
//#endregion
|
|
64
|
+
export { generatePassword, readHidden, readPasswordFromStream, rejectPasswordArgument, resolvePassword };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { readHidden, rejectPasswordArgument } from "./password.js";
|
|
2
|
+
import { createUserCommand, listRolesCommand, listUsersCommand } from "./commands.js";
|
|
3
|
+
import { deleteUserCommand, setDisabledCommand, setRoleCommand } from "./manage.js";
|
|
4
|
+
import { loadProjectAuth } from "./load-auth.js";
|
|
5
|
+
import { parseTtl } from "./ttl.js";
|
|
6
|
+
import { Option } from "commander";
|
|
7
|
+
import { createInterface } from "node:readline/promises";
|
|
8
|
+
import { createUser, deleteUser, hasUsers, issueToken, listUsers, roleChoices, setUserBanned, setUserRole } from "@protobase/server";
|
|
9
|
+
//#region src/users/register.ts
|
|
10
|
+
const out = (text) => process.stdout.write(text);
|
|
11
|
+
const usersApi = (auth) => ({
|
|
12
|
+
hasUsers: () => hasUsers(auth),
|
|
13
|
+
roles: async () => ({
|
|
14
|
+
roles: roleChoices(auth),
|
|
15
|
+
defaultRole: auth.defaultRole
|
|
16
|
+
}),
|
|
17
|
+
createUser: (input) => createUser(auth, input),
|
|
18
|
+
listUsers: () => listUsers(auth),
|
|
19
|
+
deleteUser: (email) => deleteUser(auth, email),
|
|
20
|
+
setUserRole: (input) => setUserRole(auth, input),
|
|
21
|
+
setUserBanned: (input) => setUserBanned(auth, input)
|
|
22
|
+
});
|
|
23
|
+
const projectApi = async () => usersApi(await loadProjectAuth(process.cwd()));
|
|
24
|
+
const ask = async (question) => {
|
|
25
|
+
if (!process.stdin.isTTY) throw new Error("Confirmation needs a terminal; pass --yes to skip it");
|
|
26
|
+
const rl = createInterface({
|
|
27
|
+
input: process.stdin,
|
|
28
|
+
output: process.stdout
|
|
29
|
+
});
|
|
30
|
+
const answer = await rl.question(question);
|
|
31
|
+
rl.close();
|
|
32
|
+
return answer;
|
|
33
|
+
};
|
|
34
|
+
const finish = (code) => process.exit(code);
|
|
35
|
+
const registerUsers = (program) => {
|
|
36
|
+
const users = program.command("users").description("Manage the admin users of the project in the current directory");
|
|
37
|
+
users.command("create").description("Create a user; the first user is always an admin").argument("<email>").option("--name <name>", "display name").option("--role <role>", "role for users after the first (default: the project's default role; see `users roles`)").option("--password-stdin", "read the password from stdin", false).option("--generate-password", "generate a strong password and print it once", false).addOption(new Option("--password <value>").hideHelp()).action(async (email, opts) => {
|
|
38
|
+
rejectPasswordArgument(opts.password);
|
|
39
|
+
await createUserCommand(await projectApi(), {
|
|
40
|
+
email,
|
|
41
|
+
name: opts.name,
|
|
42
|
+
role: opts.role,
|
|
43
|
+
passwordStdin: opts.passwordStdin,
|
|
44
|
+
generatePassword: opts.generatePassword
|
|
45
|
+
}, {
|
|
46
|
+
stdin: process.stdin,
|
|
47
|
+
prompt: readHidden
|
|
48
|
+
}, out);
|
|
49
|
+
finish(0);
|
|
50
|
+
});
|
|
51
|
+
users.command("delete").description("Delete a user (refuses the last admin)").argument("<email>").option("--yes", "skip the confirmation", false).action(async (email, opts) => {
|
|
52
|
+
await deleteUserCommand(await projectApi(), {
|
|
53
|
+
email,
|
|
54
|
+
yes: opts.yes
|
|
55
|
+
}, ask, out);
|
|
56
|
+
finish(0);
|
|
57
|
+
});
|
|
58
|
+
users.command("set-role").description("Change a user's role, or several comma separated (refuses to demote the last admin)").argument("<email>").argument("<role>").action(async (email, role) => {
|
|
59
|
+
await setRoleCommand(await projectApi(), {
|
|
60
|
+
email,
|
|
61
|
+
role
|
|
62
|
+
}, out);
|
|
63
|
+
finish(0);
|
|
64
|
+
});
|
|
65
|
+
for (const disabled of [true, false]) users.command(disabled ? "disable" : "enable").description(disabled ? "Ban a user and revoke their sessions" : "Lift a ban").argument("<email>").action(async (email) => {
|
|
66
|
+
await setDisabledCommand(await projectApi(), {
|
|
67
|
+
email,
|
|
68
|
+
disabled
|
|
69
|
+
}, out);
|
|
70
|
+
finish(0);
|
|
71
|
+
});
|
|
72
|
+
users.command("roles").description("List the roles of the project (the first is admin)").action(async () => {
|
|
73
|
+
await listRolesCommand(await projectApi(), out);
|
|
74
|
+
finish(0);
|
|
75
|
+
});
|
|
76
|
+
users.command("list").description("List users: email, role, creation date").action(async () => {
|
|
77
|
+
await listUsersCommand(await projectApi(), out);
|
|
78
|
+
finish(0);
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
const registerToken = (program) => {
|
|
82
|
+
program.command("token").description("Print a bearer token (JWT) for an existing user").argument("<email>").option("--ttl <duration>", "lifetime: 30s, 15m, up to 24h", "15m").action(async (email, opts) => {
|
|
83
|
+
const ttlSeconds = parseTtl(opts.ttl);
|
|
84
|
+
const auth = await loadProjectAuth(process.cwd());
|
|
85
|
+
const { token } = await issueToken(auth, {
|
|
86
|
+
email,
|
|
87
|
+
ttlSeconds
|
|
88
|
+
});
|
|
89
|
+
out(`${token}\n`);
|
|
90
|
+
finish(0);
|
|
91
|
+
});
|
|
92
|
+
};
|
|
93
|
+
//#endregion
|
|
94
|
+
export { registerToken, registerUsers, usersApi };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//#region src/users/ttl.ts
|
|
2
|
+
const units = {
|
|
3
|
+
s: 1,
|
|
4
|
+
m: 60,
|
|
5
|
+
h: 3600
|
|
6
|
+
};
|
|
7
|
+
const maxTtlSeconds = 86400;
|
|
8
|
+
const parseTtl = (text) => {
|
|
9
|
+
const match = /^(\d+)([smh])$/.exec(text);
|
|
10
|
+
if (!match) throw new Error(`Invalid --ttl "${text}", expected a number with s, m or h, for example 15m`);
|
|
11
|
+
const seconds = Number(match[1]) * units[match[2]];
|
|
12
|
+
if (seconds < 1 || seconds > 86400) throw new Error("--ttl must be between 1s and 24h");
|
|
13
|
+
return seconds;
|
|
14
|
+
};
|
|
15
|
+
//#endregion
|
|
16
|
+
export { maxTtlSeconds, parseTtl };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
//#region src/version/compatibility.ts
|
|
2
|
+
const versionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
3
|
+
const parseVersion = (value) => {
|
|
4
|
+
const match = typeof value === "string" ? versionPattern.exec(value) : null;
|
|
5
|
+
if (!match) return void 0;
|
|
6
|
+
return {
|
|
7
|
+
major: Number(match[1]),
|
|
8
|
+
minor: Number(match[2]),
|
|
9
|
+
patch: Number(match[3])
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
const line = (version) => `${version.major}.${version.minor}.x`;
|
|
13
|
+
const servedRange = (version) => version.major === 0 ? line(version) : `${version.major}.0.x to ${line(version)}`;
|
|
14
|
+
const neededRuntime = (version) => version.major === 0 ? line(version) : `${version.major}.${version.minor} or a newer ${version.major}.x`;
|
|
15
|
+
/**
|
|
16
|
+
* Why a runtime of Protobase `runtime` cannot serve a bundle built by Protobase `bundle`, or undefined when it can.
|
|
17
|
+
* It can when both have the same major and the runtime's minor is the bundle's or newer; below 1.0, where a minor
|
|
18
|
+
* may break, the minors must be the same. Patches never matter.
|
|
19
|
+
*/
|
|
20
|
+
const bundleVersionProblem = (runtime, bundle) => {
|
|
21
|
+
const own = parseVersion(runtime);
|
|
22
|
+
if (!own) throw new Error(`The runtime's own Protobase version ${JSON.stringify(runtime)} is no x.y.z version`);
|
|
23
|
+
const rebuild = `rebuild the bundle with Protobase ${line(own)}`;
|
|
24
|
+
if (bundle === void 0) return `the bundle names no Protobase version, so it was built before 0.1.0; ${rebuild} to serve it on this runtime (${runtime})`;
|
|
25
|
+
const built = parseVersion(bundle);
|
|
26
|
+
if (!built) return `the bundle's Protobase version ${JSON.stringify(bundle)} is no x.y.z version; ${rebuild} to serve it on this runtime (${runtime})`;
|
|
27
|
+
if (built.major === own.major && (own.major === 0 ? built.minor === own.minor : built.minor <= own.minor)) return void 0;
|
|
28
|
+
return `the bundle was built by Protobase ${bundle} and this runtime is Protobase ${runtime}, which serves bundles built by ${servedRange(own)}; ${rebuild} or serve it with a runtime of ${neededRuntime(built)}`;
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
export { bundleVersionProblem };
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "The protobase command: scaffold, doctor, dev, build and serve a Protobase project.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/PrototeamHQ/protobase.git",
|
|
9
|
+
"directory": "packages/cli"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://docs.protobase.net",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"sideEffects": [
|
|
14
|
+
"./dist/dev/entry.js",
|
|
15
|
+
"./dist/serve/main.js"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22.12.0"
|
|
19
|
+
},
|
|
20
|
+
"bin": {
|
|
21
|
+
"protobase": "./bin/protobase.mjs"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"bin/protobase.mjs",
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@hono/node-server": "^2.1.3",
|
|
32
|
+
"@hono/vite-dev-server": "^0.26.1",
|
|
33
|
+
"@scalar/hono-api-reference": "^0.12.10",
|
|
34
|
+
"@tailwindcss/vite": "^4.3.3",
|
|
35
|
+
"@vitejs/plugin-react": "^6.1.2",
|
|
36
|
+
"aip-parsers": "^1.0.0",
|
|
37
|
+
"better-auth": "^1.7.7",
|
|
38
|
+
"commander": "^15.0.0",
|
|
39
|
+
"diff": "^9.0.0",
|
|
40
|
+
"hono": "^4.13.13",
|
|
41
|
+
"jose": "^6.2.12",
|
|
42
|
+
"kysely": "^0.29.6",
|
|
43
|
+
"pg": "^8.23.1",
|
|
44
|
+
"postgres": "^3.4.9",
|
|
45
|
+
"ts-morph": "^28.0.0",
|
|
46
|
+
"tsx": "^4.23.15",
|
|
47
|
+
"vite": "^8.3.3",
|
|
48
|
+
"zod": "^4.6.5",
|
|
49
|
+
"@protobase/layout": "0.1.1",
|
|
50
|
+
"@protobase/server": "0.1.1",
|
|
51
|
+
"@protobase/ui": "0.1.1",
|
|
52
|
+
"@protobase/schema": "0.1.1",
|
|
53
|
+
"@protobase/query": "0.1.1"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "tsdown"
|
|
57
|
+
}
|
|
6
58
|
}
|
package/README.md
DELETED