@protobase/cli 0.7.0 → 0.8.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.
@@ -1,4 +1,5 @@
1
1
  import { moduleFile } from "../module-file.js";
2
+ import { functionFiles } from "../project/functions-folder.js";
2
3
  import { existsSync, readdirSync } from "node:fs";
3
4
  import path from "node:path";
4
5
  //#region src/build/entry-code.ts
@@ -16,8 +17,10 @@ const bundleEntryCode = (projectDir, extensions = []) => {
16
17
  const dirs = uiDirs(configDir);
17
18
  lines.push(`import { conventionConfig } from ${JSON.stringify(conventionModule)}`, `import * as index from ${JSON.stringify(index)}`, ...dirs.map((dir, i) => `import * as ui${i} from ${JSON.stringify(path.join(configDir, dir, "ui.ts"))}`), `const convention = conventionConfig(index, [${dirs.map((dir, i) => `[${JSON.stringify(dir)}, ui${i}]`).join(", ")}])`);
18
19
  } else lines.push("const convention = undefined");
20
+ const functions = functionFiles(projectDir);
21
+ lines.push(...functions.map(({ file }, i) => `import * as function${i} from ${JSON.stringify(file)}`), `const functions = ${functions.length === 0 ? "undefined" : `{ ${functions.map(({ name }, i) => `${JSON.stringify(name)}: function${i}.default`).join(", ")} }`}`);
19
22
  const configs = extensions.flatMap((extension) => extension.config ? [extension.config] : []);
20
- lines.push(`import { mergeConfig } from '@protobase/server'`, ...configs.map((file, i) => `import * as extension${i} from ${JSON.stringify(file)}`), "const named = (config, file) => ({ ...config, name: config.name ?? file })", `export default mergeConfig(${[...configs.map((file, i) => `named(extension${i}.default ?? {}, ${JSON.stringify(file)})`), `named({ ...project, config: project.config ?? convention }, ${JSON.stringify(hasCustom ? "protobase.config.ts" : "config/index.ts")})`].join(", ")})`);
23
+ lines.push(`import { mergeConfig } from '@protobase/server'`, ...configs.map((file, i) => `import * as extension${i} from ${JSON.stringify(file)}`), "const named = (config, file) => ({ ...config, name: config.name ?? file })", `export default mergeConfig(${[...configs.map((file, i) => `named(extension${i}.default ?? {}, ${JSON.stringify(file)})`), `named({ ...project, config: project.config ?? convention, functions: project.functions ?? functions }, ${JSON.stringify(hasCustom ? "protobase.config.ts" : "config/index.ts")})`].join(", ")})`);
21
24
  return `${lines.join("\n")}\n`;
22
25
  };
23
26
  //#endregion
package/dist/dev/entry.js CHANGED
@@ -20,6 +20,8 @@ const admin = createAdmin({
20
20
  db,
21
21
  authenticate: project.authenticate,
22
22
  ...project.auth && { auth: project.auth },
23
+ ...project.functions && { functions: project.functions },
24
+ ...project.files && { files: project.files },
23
25
  options: {
24
26
  onUnhandledError: (error) => console.error(error),
25
27
  ...project.options
@@ -1,3 +1,4 @@
1
+ import { functionFiles } from "../project/functions-folder.js";
1
2
  import { conventionConfig } from "../project/convention.js";
2
3
  import { sharedDb } from "./create-db.js";
3
4
  import { existsSync } from "node:fs";
@@ -22,6 +23,11 @@ const conventionExports = async (projectDir) => {
22
23
  }
23
24
  return conventionConfig(indexExports, uiModules);
24
25
  };
26
+ const conventionFunctions = async (projectDir) => {
27
+ const files = functionFiles(projectDir);
28
+ if (files.length === 0) return void 0;
29
+ return Object.fromEntries(await Promise.all(files.map(async ({ name, file }) => [name, (await load(file)).default])));
30
+ };
25
31
  const named = (config, file) => ({
26
32
  ...config,
27
33
  name: config.name ?? file
@@ -33,7 +39,8 @@ const loadProject = async (projectDir, extensions = []) => {
33
39
  for (const extension of extensions) if (extension.config) extended.push(named((await load(extension.config)).default ?? {}, extension.config));
34
40
  const own = named({
35
41
  ...custom,
36
- config: custom.config ?? await conventionExports(projectDir)
42
+ config: custom.config ?? await conventionExports(projectDir),
43
+ functions: custom.functions ?? await conventionFunctions(projectDir)
37
44
  }, existsSync(file) ? "protobase.config.ts" : "config/index.ts");
38
45
  const project = mergeConfig(...extended, own);
39
46
  return {
@@ -0,0 +1,11 @@
1
+ //#region src/files/cleanup-command.ts
2
+ const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
3
+ /** Runs the scheduled file deletes that are due and says what happened to each file. */
4
+ const cleanupCommand = async (run, out) => {
5
+ const { deleted, kept } = await run();
6
+ if (deleted.length === 0 && kept.length === 0) return out("No file deletes were due\n");
7
+ if (deleted.length > 0) out(`Deleted ${plural(deleted.length, "file")}:\n${deleted.map((uri) => ` ${uri}\n`).join("")}`);
8
+ if (kept.length > 0) out(`Kept ${plural(kept.length, "file")} a row references again:\n${kept.map((uri) => ` ${uri}\n`).join("")}`);
9
+ };
10
+ //#endregion
11
+ export { cleanupCommand };
@@ -0,0 +1,47 @@
1
+ import { loadNearestEnvFile } from "../dev/env.js";
2
+ import { importProjectFile } from "../project/import-project-file.js";
3
+ import { readManifest } from "../bundle/manifest.js";
4
+ import { createPgDb } from "../project/pg-db.js";
5
+ import { loadBundle } from "../serve/load-bundle.js";
6
+ import { existsSync, statSync } from "node:fs";
7
+ import path from "node:path";
8
+ import { configExports } from "@protobase/server";
9
+ import pg from "pg";
10
+ //#region src/files/load-project.ts
11
+ const bundleProject = async (bundle) => {
12
+ if (!statSync(bundle).isDirectory()) return loadBundle(bundle);
13
+ const manifest = await readManifest(bundle);
14
+ return loadBundle(path.join(bundle, manifest.server));
15
+ };
16
+ const sourceProject = async (dir) => {
17
+ const file = path.join(dir, "protobase.config.ts");
18
+ const custom = existsSync(file) ? (await importProjectFile(file)).default ?? {} : {};
19
+ if (custom.config) return custom;
20
+ const index = path.join(dir, "config", "index.ts");
21
+ if (!existsSync(index)) throw new Error(`No config/index.ts or protobase.config.ts in ${dir}; run this inside a project, or name a bundle`);
22
+ return {
23
+ ...custom,
24
+ config: await importProjectFile(index)
25
+ };
26
+ };
27
+ /**
28
+ * What a command over a project's files needs: its resources, its files options and a database, from a bundle or from
29
+ * the project in `dir`. `close` ends the pool it opened; a `db` the project exports stays the project's.
30
+ */
31
+ const loadFilesProject = async (bundle, dir) => {
32
+ loadNearestEnvFile(dir);
33
+ const project = bundle ? await bundleProject(bundle) : await sourceProject(dir);
34
+ const url = process.env.DATABASE_URL;
35
+ if (!project.db && !url) throw new Error("No database: set DATABASE_URL or export `db` from protobase.config.ts");
36
+ const ownDb = project.db ? void 0 : createPgDb(pg, url);
37
+ return {
38
+ resources: configExports(project.config ?? {}).resources,
39
+ db: project.db ?? ownDb,
40
+ ...project.files && { files: project.files },
41
+ close: async () => {
42
+ await ownDb?.destroy();
43
+ }
44
+ };
45
+ };
46
+ //#endregion
47
+ export { loadFilesProject };
@@ -0,0 +1,19 @@
1
+ import { cleanupCommand } from "./cleanup-command.js";
2
+ import { loadFilesProject } from "./load-project.js";
3
+ import { createFileCleanup } from "@protobase/server";
4
+ //#region src/files/register.ts
5
+ const out = (text) => process.stdout.write(text);
6
+ const registerFiles = (program) => {
7
+ program.command("files").description("Manage the stored files of the project in the current directory").command("cleanup").description("Delete the files whose scheduled delete is due and that no row references, such as replaced files and unused uploads").argument("[bundle]", "a bundle from `protobase build` (its folder or config module); default: the project in the current directory").action(async (bundle) => {
8
+ const project = await loadFilesProject(bundle, process.cwd());
9
+ try {
10
+ const cleanup = createFileCleanup(project);
11
+ if (cleanup) await cleanupCommand(cleanup.run, out);
12
+ else out("The project has no file fields\n");
13
+ } finally {
14
+ await project.close();
15
+ }
16
+ });
17
+ };
18
+ //#endregion
19
+ export { registerFiles };
@@ -8,7 +8,7 @@ const relationTargets = [
8
8
  const typesCompatible = (declared, base) => {
9
9
  if (declared === base) return true;
10
10
  if (declared === "relation") return relationTargets.includes(base);
11
- if (declared === "currency" || declared === "country") return base === "text";
11
+ if (declared === "currency" || declared === "country" || declared === "file") return base === "text";
12
12
  if (declared === "enum") return base === "text";
13
13
  if (declared === "text") return base === "enum";
14
14
  return false;
package/dist/main.js CHANGED
@@ -11,6 +11,8 @@ import { runDoctor } from "./doctor/run.js";
11
11
  import { runScaffold } from "./scaffold/run.js";
12
12
  import { serveBundle } from "./serve/serve-bundle.js";
13
13
  import { serveBundleDir } from "./serve/serve-dir.js";
14
+ import { registerOrganizations } from "./organizations/register.js";
15
+ import { registerFiles } from "./files/register.js";
14
16
  import { registerToken, registerUsers } from "./users/register.js";
15
17
  import { statSync } from "node:fs";
16
18
  import path from "node:path";
@@ -84,8 +86,10 @@ program.command("serve").description("Serve a bundle written by `protobase build
84
86
  else await serveBundle(bundle, process.env, out);
85
87
  });
86
88
  registerUsers(program);
89
+ registerOrganizations(program);
87
90
  registerToken(program);
88
91
  registerAuth(program);
92
+ registerFiles(program);
89
93
  const run = (argv) => program.parseAsync(argv).catch((error) => {
90
94
  process.stderr.write(`error: ${error.message}\n`);
91
95
  process.exitCode = 1;
@@ -0,0 +1,92 @@
1
+ //#region src/organizations/commands.ts
2
+ /** A slug from a name: lowercase ASCII words joined by dashes; diacritics are dropped, so Müller becomes muller. */
3
+ const slugOf = (name) => name.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
4
+ /** `a,b` as a list of roles; an empty text is none. */
5
+ const parseRoleList = (text) => text === void 0 ? void 0 : text.split(",").map((name) => name.trim()).filter(Boolean);
6
+ const listOrganizationsCommand = async (api, out) => {
7
+ const organizations = await api.listOrganizations();
8
+ if (organizations.length === 0) {
9
+ out("No organizations yet. Create one with: protobase organizations create \"Acme\" --owner you@example.com\n");
10
+ return;
11
+ }
12
+ for (const organization of organizations) {
13
+ out(`${organization.slug} ${organization.name} (${organization.id})\n`);
14
+ for (const member of organization.members) out(` ${member.email} ${member.role}${member.appRoles.length > 0 ? ` ${member.appRoles.join(",")}` : ""}\n`);
15
+ }
16
+ };
17
+ /**
18
+ * Creates an organization owned by `owner`. With `--members all`, for an app that ran as one organization: superusers
19
+ * join as owners with every app role and stay superusers; everyone else joins as a member, and their global roles move
20
+ * onto the membership, since a global role would let them into every organization once there is a second.
21
+ */
22
+ const createOrganizationCommand = async (api, options, out) => {
23
+ if (options.members !== void 0 && options.members !== "all") throw new Error(`--members takes "all", not "${options.members}"`);
24
+ const slug = options.slug ?? slugOf(options.name);
25
+ const { organization } = await api.createOrganization({
26
+ name: options.name,
27
+ slug,
28
+ owner: options.owner,
29
+ ...options.id && { id: options.id }
30
+ });
31
+ out(`Created organization ${organization.slug} (${organization.id}), owned by ${options.owner}\n`);
32
+ if (options.members !== "all") return;
33
+ const holdable = api.membershipRoles();
34
+ for (const user of await api.listUsers()) {
35
+ if (user.email.toLowerCase() === options.owner.toLowerCase()) continue;
36
+ const global = user.role.split(",").map((name) => name.trim()).filter(Boolean);
37
+ if (global.includes("admin")) {
38
+ await api.addMember({
39
+ organization: organization.id,
40
+ email: user.email,
41
+ role: "owner",
42
+ appRoles: holdable
43
+ });
44
+ out(` ${user.email}: owner, with every app role\n`);
45
+ continue;
46
+ }
47
+ const appRoles = global.filter((name) => holdable.includes(name));
48
+ await api.addMember({
49
+ organization: organization.id,
50
+ email: user.email,
51
+ role: "member",
52
+ appRoles
53
+ });
54
+ await api.setUserRole({
55
+ email: user.email,
56
+ role: "user"
57
+ });
58
+ out(` ${user.email}: member${appRoles.length > 0 ? `, with ${appRoles.join(",")}` : ""}\n`);
59
+ }
60
+ };
61
+ const addMemberCommand = async (api, options, out) => {
62
+ const appRoles = parseRoleList(options.appRoles);
63
+ await api.addMember({
64
+ organization: options.organization,
65
+ email: options.email,
66
+ ...options.orgRole && { role: options.orgRole },
67
+ ...appRoles && { appRoles }
68
+ });
69
+ out(`Added ${options.email} to ${options.organization}\n`);
70
+ };
71
+ const setMemberRolesCommand = async (api, options, out) => {
72
+ const appRoles = parseRoleList(options.appRoles);
73
+ if (options.orgRole === void 0 && appRoles === void 0) throw new Error("Pass --org-role, --app-roles or both");
74
+ await api.setMemberRoles({
75
+ organization: options.organization,
76
+ email: options.email,
77
+ ...options.orgRole && { role: options.orgRole },
78
+ ...appRoles && { appRoles }
79
+ });
80
+ out(`Changed the roles of ${options.email} in ${options.organization}\n`);
81
+ };
82
+ const removeMemberCommand = async (api, options, out) => {
83
+ await api.removeMember(options);
84
+ out(`Removed ${options.email} from ${options.organization}\n`);
85
+ };
86
+ const addAppRoleCommand = async (api, options, out) => {
87
+ if (options.to !== "owners") throw new Error(`--to takes "owners", not "${options.to}"`);
88
+ const changed = await api.addAppRoleToOwners(options.role);
89
+ out(`Gave ${options.role} to ${changed} owner${changed === 1 ? "" : "s"}\n`);
90
+ };
91
+ //#endregion
92
+ export { addAppRoleCommand, addMemberCommand, createOrganizationCommand, listOrganizationsCommand, parseRoleList, removeMemberCommand, setMemberRolesCommand, slugOf };
@@ -0,0 +1,70 @@
1
+ import { loadProjectAuth } from "../users/load-auth.js";
2
+ import { addAppRoleCommand, addMemberCommand, createOrganizationCommand, listOrganizationsCommand, removeMemberCommand, setMemberRolesCommand } from "./commands.js";
3
+ import { addAppRoleToOwners, addMember, createOrganization, listOrganizations, listUsers, removeMember, setMemberRoles, setUserRole } from "@protobase/server";
4
+ //#region src/organizations/register.ts
5
+ const out = (text) => process.stdout.write(text);
6
+ const organizationsApi = (auth) => ({
7
+ listOrganizations: () => listOrganizations(auth),
8
+ createOrganization: (input) => createOrganization(auth, input),
9
+ addMember: (input) => addMember(auth, input),
10
+ setMemberRoles: (input) => setMemberRoles(auth, input),
11
+ removeMember: (input) => removeMember(auth, input),
12
+ addAppRoleToOwners: (appRole) => addAppRoleToOwners(auth, appRole),
13
+ listUsers: () => listUsers(auth),
14
+ setUserRole: (input) => setUserRole(auth, input),
15
+ membershipRoles: () => auth.roleDefinitions.names.filter((name) => name !== "admin" && name !== "user")
16
+ });
17
+ const projectApi = async () => organizationsApi(await loadProjectAuth(process.cwd()));
18
+ const finish = (code) => process.exit(code);
19
+ const memberOptions = (command) => command.argument("<organization>", "its slug or id").argument("<email>").option("--org-role <role>", "owner, admin or member: who manages the organization").option("--app-roles <roles>", "app roles in this organization, comma separated (see `users roles`)");
20
+ const registerOrganizations = (program) => {
21
+ const organizations = program.command("organizations").description("Manage the organizations of the project in the current directory (with `organizations` in createAuth)");
22
+ organizations.command("list").description("List organizations with their members and roles").action(async () => {
23
+ await listOrganizationsCommand(await projectApi(), out);
24
+ finish(0);
25
+ });
26
+ organizations.command("create").description("Create an organization; its owner holds every app role").argument("<name>").requiredOption("--owner <email>", "the user who owns it").option("--slug <slug>", "default: from the name").option("--id <id>", "keep an existing tenant value, for an app that ran as one organization").option("--members <all>", "\"all\": every other user joins; their global roles move here, superusers become owners").action(async (name, opts) => {
27
+ await createOrganizationCommand(await projectApi(), {
28
+ name,
29
+ owner: opts.owner,
30
+ slug: opts.slug,
31
+ id: opts.id,
32
+ members: opts.members
33
+ }, out);
34
+ finish(0);
35
+ });
36
+ memberOptions(organizations.command("add-member").description("Add a user to an organization (organization role member unless given)")).action(async (organization, email, opts) => {
37
+ await addMemberCommand(await projectApi(), {
38
+ organization,
39
+ email,
40
+ orgRole: opts.orgRole,
41
+ appRoles: opts.appRoles
42
+ }, out);
43
+ finish(0);
44
+ });
45
+ memberOptions(organizations.command("set-roles").description("Change a member's organization role, app roles, or both")).action(async (organization, email, opts) => {
46
+ await setMemberRolesCommand(await projectApi(), {
47
+ organization,
48
+ email,
49
+ orgRole: opts.orgRole,
50
+ appRoles: opts.appRoles
51
+ }, out);
52
+ finish(0);
53
+ });
54
+ organizations.command("remove-member").description("Remove a member (refuses the last owner)").argument("<organization>", "its slug or id").argument("<email>").action(async (organization, email) => {
55
+ await removeMemberCommand(await projectApi(), {
56
+ organization,
57
+ email
58
+ }, out);
59
+ finish(0);
60
+ });
61
+ organizations.command("add-app-role").description("Give an app role to every owner of every organization, for a role added after they were made").argument("<role>").requiredOption("--to <owners>", "\"owners\"").action(async (role, opts) => {
62
+ await addAppRoleCommand(await projectApi(), {
63
+ role,
64
+ to: opts.to
65
+ }, out);
66
+ finish(0);
67
+ });
68
+ };
69
+ //#endregion
70
+ export { organizationsApi, registerOrganizations };
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.7.0";
2
+ var version = "0.8.0";
3
3
  //#endregion
4
4
  export { version };
@@ -0,0 +1,27 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import path from "node:path";
3
+ //#region src/project/functions-folder.ts
4
+ const skipped = (entry) => entry.startsWith("_") || entry.startsWith(".") || /\.(test|spec|d)\.ts$/.test(entry);
5
+ const functionFiles = (projectDir) => {
6
+ const dir = path.join(projectDir, "functions");
7
+ if (!existsSync(dir)) return [];
8
+ const found = /* @__PURE__ */ new Map();
9
+ const add = (name, file) => {
10
+ if (found.has(name)) throw new Error(`The function "${name}" is both ${found.get(name)} and ${file}; keep one of them`);
11
+ found.set(name, file);
12
+ };
13
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
14
+ if (skipped(entry.name)) continue;
15
+ if (entry.isDirectory()) {
16
+ const index = path.join(dir, entry.name, "index.ts");
17
+ if (!existsSync(index)) throw new Error(`functions/${entry.name} has no index.ts; a folder of shared code is named with a leading _, such as functions/_${entry.name}`);
18
+ add(entry.name, index);
19
+ } else if (entry.name.endsWith(".ts")) add(entry.name.slice(0, -3), path.join(dir, entry.name));
20
+ }
21
+ return [...found].map(([name, file]) => ({
22
+ name,
23
+ file
24
+ })).sort((a, b) => a.name.localeCompare(b.name));
25
+ };
26
+ //#endregion
27
+ export { functionFiles };
package/dist/serve/app.js CHANGED
@@ -14,6 +14,8 @@ const createServeApp = ({ project, db, requestLog, site }) => {
14
14
  db,
15
15
  authenticate: project.authenticate,
16
16
  ...project.auth && { auth: project.auth },
17
+ ...project.functions && { functions: project.functions },
18
+ ...project.files && { files: project.files },
17
19
  options: {
18
20
  onUnhandledError: (error) => console.error(error),
19
21
  ...project.options
@@ -0,0 +1,22 @@
1
+ //#region src/serve/cleanup-timer.ts
2
+ /**
3
+ * Runs `run` every `minutes`, never two at once; a failed run is reported and the next one still comes. `stop` ends the
4
+ * timer and waits for a run in progress.
5
+ */
6
+ const startCleanupTimer = (run, minutes, report) => {
7
+ let running;
8
+ const tick = () => {
9
+ if (running) return;
10
+ running = run().then(() => void 0, report).finally(() => {
11
+ running = void 0;
12
+ });
13
+ };
14
+ const timer = setInterval(tick, minutes * 6e4);
15
+ timer.unref?.();
16
+ return { stop: async () => {
17
+ clearInterval(timer);
18
+ await running;
19
+ } };
20
+ };
21
+ //#endregion
22
+ export { startCleanupTimer };
package/dist/serve/env.js CHANGED
@@ -3,10 +3,13 @@ const defaultPort = 8787;
3
3
  const readServeEnv = (env) => {
4
4
  const port = env.PORT ? Number(env.PORT) : defaultPort;
5
5
  if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`PORT must be a port number, not "${env.PORT}"`);
6
+ const minutes = env.PROTOBASE_FILES_CLEANUP_MINUTES ? Number(env.PROTOBASE_FILES_CLEANUP_MINUTES) : 60;
7
+ if (!Number.isInteger(minutes) || minutes < 0) throw new Error(`PROTOBASE_FILES_CLEANUP_MINUTES must be a whole number of minutes, not "${env.PROTOBASE_FILES_CLEANUP_MINUTES}"`);
6
8
  return {
7
9
  port,
8
10
  databaseUrl: env.DATABASE_URL || void 0,
9
- requestLog: Boolean(env.REQUEST_LOG)
11
+ requestLog: Boolean(env.REQUEST_LOG),
12
+ filesCleanupMinutes: minutes
10
13
  };
11
14
  };
12
15
  //#endregion
@@ -11,6 +11,9 @@ const hostModuleIds = [
11
11
  "postgres",
12
12
  "better-auth",
13
13
  "hono",
14
+ "hono/cors",
15
+ "hono/http-exception",
16
+ "hono/validator",
14
17
  "jose",
15
18
  "zod",
16
19
  "aip-parsers",
@@ -8,6 +8,9 @@ import * as hono from "hono";
8
8
  import * as apiReference from "@scalar/hono-api-reference";
9
9
  import * as aipParsers from "aip-parsers";
10
10
  import * as betterAuth from "better-auth";
11
+ import * as honoCors from "hono/cors";
12
+ import * as honoHttpException from "hono/http-exception";
13
+ import * as honoValidator from "hono/validator";
11
14
  import * as jose from "jose";
12
15
  import * as kyselyPostgres from "kysely/helpers/postgres";
13
16
  import * as zod from "zod";
@@ -26,6 +29,9 @@ const hostModules = {
26
29
  postgres: postgres$1,
27
30
  "better-auth": betterAuth,
28
31
  hono,
32
+ "hono/cors": honoCors,
33
+ "hono/http-exception": honoHttpException,
34
+ "hono/validator": honoValidator,
29
35
  jose,
30
36
  zod,
31
37
  "aip-parsers": aipParsers,
@@ -1,6 +1,7 @@
1
1
  import { createPgDb } from "../project/pg-db.js";
2
2
  import { createServeApp } from "./app.js";
3
- import { checkAuthSchema } from "@protobase/server";
3
+ import { startCleanupTimer } from "./cleanup-timer.js";
4
+ import { checkAuthSchema, configExports, createFileCleanup } from "@protobase/server";
4
5
  import { createAdaptorServer } from "@hono/node-server";
5
6
  import pg from "pg";
6
7
  //#region src/serve/lifecycle.ts
@@ -28,8 +29,15 @@ const startServe = async ({ project, env, createDb = (url) => createPgDb(pg, url
28
29
  await ownDb?.destroy();
29
30
  throw error;
30
31
  });
32
+ const cleanup = createFileCleanup({
33
+ resources: configExports(project.config).resources,
34
+ db,
35
+ ...project.files && { files: project.files }
36
+ });
37
+ const timer = cleanup && env.filesCleanupMinutes > 0 ? startCleanupTimer(cleanup.run, env.filesCleanupMinutes, (error) => console.error(error)) : void 0;
31
38
  const close = async () => {
32
39
  await closeServer(server);
40
+ await timer?.stop();
33
41
  await ownDb?.destroy();
34
42
  };
35
43
  return {
@@ -1,4 +1,6 @@
1
1
  import { loadProjectAuth } from "./load-auth.js";
2
+ import { addMemberCommand } from "../organizations/commands.js";
3
+ import { organizationsApi } from "../organizations/register.js";
2
4
  import { readHidden, rejectPasswordArgument } from "./password.js";
3
5
  import { createUserCommand, listRolesCommand, listUsersCommand } from "./commands.js";
4
6
  import { deleteUserCommand, setDisabledCommand, setRoleCommand } from "./manage.js";
@@ -34,9 +36,10 @@ const ask = async (question) => {
34
36
  const finish = (code) => process.exit(code);
35
37
  const registerUsers = (program) => {
36
38
  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).option("--github-id <id>", "link the user to this GitHub user id, so signing in with GitHub finds them whatever their address").addOption(new Option("--password <value>").hideHelp()).action(async (email, opts) => {
39
+ 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).option("--github-id <id>", "link the user to this GitHub user id, so signing in with GitHub finds them whatever their address").option("--organization <organization>", "with organizations: add the user to this one (its slug or id)").option("--org-role <role>", "with --organization: owner, admin or member (default member)").option("--app-roles <roles>", "with --organization: app roles there, comma separated").addOption(new Option("--password <value>").hideHelp()).action(async (email, opts) => {
38
40
  rejectPasswordArgument(opts.password);
39
- await createUserCommand(await projectApi(), {
41
+ const auth = await loadProjectAuth(process.cwd());
42
+ await createUserCommand(usersApi(auth), {
40
43
  email,
41
44
  name: opts.name,
42
45
  role: opts.role,
@@ -47,6 +50,12 @@ const registerUsers = (program) => {
47
50
  stdin: process.stdin,
48
51
  prompt: readHidden
49
52
  }, out);
53
+ if (opts.organization) await addMemberCommand(organizationsApi(auth), {
54
+ organization: opts.organization,
55
+ email,
56
+ orgRole: opts.orgRole,
57
+ appRoles: opts.appRoles
58
+ }, out);
50
59
  finish(0);
51
60
  });
52
61
  users.command("delete").description("Delete a user (refuses the last admin)").argument("<email>").option("--yes", "skip the confirmation", false).action(async (email, opts) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "The protobase command: scaffold, doctor, dev, build and serve a Protobase project.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -34,11 +34,11 @@
34
34
  "dependencies": {
35
35
  "@hono/node-server": "^2.1.3",
36
36
  "@hono/vite-dev-server": "^0.26.1",
37
- "@protobase/layout": "0.7.0",
38
- "@protobase/query": "0.7.0",
39
- "@protobase/schema": "0.7.0",
40
- "@protobase/server": "0.7.0",
41
- "@protobase/ui": "0.7.0",
37
+ "@protobase/layout": "0.8.0",
38
+ "@protobase/query": "0.8.0",
39
+ "@protobase/schema": "0.8.0",
40
+ "@protobase/server": "0.8.0",
41
+ "@protobase/ui": "0.8.0",
42
42
  "@scalar/hono-api-reference": "^0.12.10",
43
43
  "@tailwindcss/vite": "^4.3.3",
44
44
  "@vitejs/plugin-react": "^6.1.2",