@protobase/cli 0.7.0 → 0.8.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/dist/build/entry-code.js +4 -1
- package/dist/dev/entry.js +5 -1
- package/dist/dev/project.js +8 -1
- package/dist/files/cleanup-command.js +11 -0
- package/dist/files/load-project.js +47 -0
- package/dist/files/register.js +19 -0
- package/dist/infer/compat.js +1 -1
- package/dist/main.js +6 -0
- package/dist/migration/load-project.js +41 -0
- package/dist/migration/parts.js +14 -0
- package/dist/migration/register.js +22 -0
- package/dist/migration/write-migrations.js +32 -0
- package/dist/organizations/commands.js +92 -0
- package/dist/organizations/register.js +70 -0
- package/dist/package.js +1 -1
- package/dist/project/functions-folder.js +27 -0
- package/dist/serve/app.js +3 -0
- package/dist/serve/cleanup-timer.js +22 -0
- package/dist/serve/env.js +4 -1
- package/dist/serve/host-module-ids.js +3 -0
- package/dist/serve/host-modules.js +6 -0
- package/dist/serve/lifecycle.js +11 -2
- package/dist/users/register.js +11 -2
- package/package.json +6 -6
package/dist/build/entry-code.js
CHANGED
|
@@ -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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { loadProject, projectDb } from "./project.js";
|
|
2
|
-
import { checkAuthSchema, configExports, createAdmin, hasUsers } from "@protobase/server";
|
|
2
|
+
import { checkAuthSchema, checkChanges, configExports, createAdmin, hasUsers } from "@protobase/server";
|
|
3
3
|
import { Hono } from "hono";
|
|
4
4
|
//#region src/dev/entry.ts
|
|
5
5
|
const projectDir = process.env.PROTOBASE_PROJECT;
|
|
@@ -12,6 +12,7 @@ if (project.auth && !store.__protobaseAuthSchemaChecked) {
|
|
|
12
12
|
await checkAuthSchema(project.auth);
|
|
13
13
|
store.__protobaseAuthSchemaChecked = true;
|
|
14
14
|
}
|
|
15
|
+
await checkChanges(db, resources.map((resource) => resource.toModel()), project.events);
|
|
15
16
|
const admin = createAdmin({
|
|
16
17
|
resources,
|
|
17
18
|
views,
|
|
@@ -20,6 +21,9 @@ const admin = createAdmin({
|
|
|
20
21
|
db,
|
|
21
22
|
authenticate: project.authenticate,
|
|
22
23
|
...project.auth && { auth: project.auth },
|
|
24
|
+
...project.functions && { functions: project.functions },
|
|
25
|
+
...project.files && { files: project.files },
|
|
26
|
+
...project.events && { events: project.events },
|
|
23
27
|
options: {
|
|
24
28
|
onUnhandledError: (error) => console.error(error),
|
|
25
29
|
...project.options
|
package/dist/dev/project.js
CHANGED
|
@@ -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 };
|
package/dist/infer/compat.js
CHANGED
|
@@ -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,9 @@ 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";
|
|
16
|
+
import { registerMigration } from "./migration/register.js";
|
|
14
17
|
import { registerToken, registerUsers } from "./users/register.js";
|
|
15
18
|
import { statSync } from "node:fs";
|
|
16
19
|
import path from "node:path";
|
|
@@ -84,8 +87,11 @@ program.command("serve").description("Serve a bundle written by `protobase build
|
|
|
84
87
|
else await serveBundle(bundle, process.env, out);
|
|
85
88
|
});
|
|
86
89
|
registerUsers(program);
|
|
90
|
+
registerOrganizations(program);
|
|
87
91
|
registerToken(program);
|
|
88
92
|
registerAuth(program);
|
|
93
|
+
registerMigration(program);
|
|
94
|
+
registerFiles(program);
|
|
89
95
|
const run = (argv) => program.parseAsync(argv).catch((error) => {
|
|
90
96
|
process.stderr.write(`error: ${error.message}\n`);
|
|
91
97
|
process.exitCode = 1;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { loadNearestEnvFile } from "../dev/env.js";
|
|
2
|
+
import { importProjectFile } from "../project/import-project-file.js";
|
|
3
|
+
import { createPgDb } from "../project/pg-db.js";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { configExports, mergeConfig } from "@protobase/server";
|
|
7
|
+
import pg from "pg";
|
|
8
|
+
//#region src/migration/load-project.ts
|
|
9
|
+
const configOf = async (file) => (await importProjectFile(file)).default ?? {};
|
|
10
|
+
/**
|
|
11
|
+
* The project in `dir` as `protobase dev` merges it, for what its database needs: protobase.config.ts, or
|
|
12
|
+
* config/index.ts when that has no `config`, after the configs of `extensions` and the configs it extends. `close`
|
|
13
|
+
* ends the pool it opened; a `db` the project exports stays the project's.
|
|
14
|
+
*/
|
|
15
|
+
const loadMigrationProject = async (dir, extensions = []) => {
|
|
16
|
+
loadNearestEnvFile(dir);
|
|
17
|
+
const file = path.join(dir, "protobase.config.ts");
|
|
18
|
+
const custom = existsSync(file) ? await configOf(file) : {};
|
|
19
|
+
const index = path.join(dir, "config", "index.ts");
|
|
20
|
+
if (!custom.config && !existsSync(index)) throw new Error(`No config/index.ts or protobase.config.ts in ${dir}; run this inside a project`);
|
|
21
|
+
const own = {
|
|
22
|
+
...custom,
|
|
23
|
+
config: custom.config ?? await importProjectFile(index)
|
|
24
|
+
};
|
|
25
|
+
const extended = await Promise.all(extensions.flatMap((extension) => extension.config ? [configOf(extension.config)] : []));
|
|
26
|
+
const project = mergeConfig(...extended, own);
|
|
27
|
+
const url = process.env.DATABASE_URL;
|
|
28
|
+
if (!project.db && !url) throw new Error("No database: set DATABASE_URL or export `db` from protobase.config.ts");
|
|
29
|
+
const ownDb = project.db ? void 0 : createPgDb(pg, url);
|
|
30
|
+
return {
|
|
31
|
+
models: configExports(project.config ?? {}).resources.map((source) => source.toModel()),
|
|
32
|
+
db: project.db ?? ownDb,
|
|
33
|
+
...project.auth && { auth: project.auth },
|
|
34
|
+
...project.events && { events: project.events },
|
|
35
|
+
close: async () => {
|
|
36
|
+
await ownDb?.destroy();
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
//#endregion
|
|
41
|
+
export { loadMigrationProject };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { authSchemaMigration, changesMigration } from "@protobase/server";
|
|
2
|
+
//#region src/migration/parts.ts
|
|
3
|
+
/** Every feature the project's config enables, in the order their files are written. */
|
|
4
|
+
const migrationParts = (project) => [...project.auth ? [{
|
|
5
|
+
name: "auth",
|
|
6
|
+
title: "The auth schema changes this version of Protobase needs",
|
|
7
|
+
sql: () => authSchemaMigration(project.auth)
|
|
8
|
+
}] : [], {
|
|
9
|
+
name: "changes",
|
|
10
|
+
title: "The change capture the resources' events need",
|
|
11
|
+
sql: () => changesMigration(project.db, project.models, project.events)
|
|
12
|
+
}];
|
|
13
|
+
//#endregion
|
|
14
|
+
export { migrationParts };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { resolveExtensions } from "../project/extensions.js";
|
|
2
|
+
import { loadMigrationProject } from "./load-project.js";
|
|
3
|
+
import { migrationParts } from "./parts.js";
|
|
4
|
+
import { writeMigrations } from "./write-migrations.js";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
//#region src/migration/register.ts
|
|
7
|
+
const out = (text) => process.stdout.write(text);
|
|
8
|
+
const collect = (value, previous) => [...previous, value];
|
|
9
|
+
const registerMigration = (program) => {
|
|
10
|
+
program.command("migration").description("Write what the database still needs for every feature the config enables (auth, events) as the project's next SQL migrations").option("--dir <dir>", "migrations folder", "db/migrations").option("--extend <dir>", "merge the protobase.config.ts in this folder before the project's, as dev and build do, repeatable", collect, []).action(async (opts) => {
|
|
11
|
+
const project = await loadMigrationProject(process.cwd(), resolveExtensions(opts.extend));
|
|
12
|
+
try {
|
|
13
|
+
const files = await writeMigrations(migrationParts(project), path.resolve(opts.dir));
|
|
14
|
+
out(files.length > 0 ? files.map((file) => `wrote ${path.relative(process.cwd(), file)}\n`).join("") + "apply them with the project's migrations\n" : "the database is up to date; nothing to write\n");
|
|
15
|
+
} finally {
|
|
16
|
+
await project.close();
|
|
17
|
+
}
|
|
18
|
+
process.exit(0);
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
//#endregion
|
|
22
|
+
export { registerMigration };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { nextMigrationName } from "../auth/migration-file.js";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { mkdir, readdir, writeFile } from "node:fs/promises";
|
|
4
|
+
//#region src/migration/write-migrations.ts
|
|
5
|
+
/** A part's file: what wrote it and why, then its statements. */
|
|
6
|
+
const partText = (title, sql) => `-- ${title}, written by \`protobase migration\`.\n-- Applied with the project's other migrations; edit or remove statements the project handles differently.\n\n${sql}\n`;
|
|
7
|
+
/**
|
|
8
|
+
* Writes what each part still needs as the next migrations in `dir`, one file per part, and returns their paths; none
|
|
9
|
+
* when the database is current. Every part is compared with the database before any file is written, so the
|
|
10
|
+
* project's earlier migrations must be applied to it first.
|
|
11
|
+
*/
|
|
12
|
+
const writeMigrations = async (parts, dir) => {
|
|
13
|
+
const pending = [];
|
|
14
|
+
for (const part of parts) {
|
|
15
|
+
const sql = await part.sql();
|
|
16
|
+
if (sql !== void 0) pending.push({
|
|
17
|
+
part,
|
|
18
|
+
sql
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
if (pending.length === 0) return [];
|
|
22
|
+
await mkdir(dir, { recursive: true });
|
|
23
|
+
const files = [];
|
|
24
|
+
for (const { part, sql } of pending) {
|
|
25
|
+
const file = path.join(dir, nextMigrationName(await readdir(dir), part.name));
|
|
26
|
+
await writeFile(file, partText(part.title, sql), { flag: "wx" });
|
|
27
|
+
files.push(file);
|
|
28
|
+
}
|
|
29
|
+
return files;
|
|
30
|
+
};
|
|
31
|
+
//#endregion
|
|
32
|
+
export { partText, writeMigrations };
|
|
@@ -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
|
@@ -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,9 @@ 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 },
|
|
19
|
+
...project.events && { events: project.events },
|
|
17
20
|
options: {
|
|
18
21
|
onUnhandledError: (error) => console.error(error),
|
|
19
22
|
...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
|
|
@@ -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,
|
package/dist/serve/lifecycle.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createPgDb } from "../project/pg-db.js";
|
|
2
2
|
import { createServeApp } from "./app.js";
|
|
3
|
-
import {
|
|
3
|
+
import { startCleanupTimer } from "./cleanup-timer.js";
|
|
4
|
+
import { checkAuthSchema, checkChanges, 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
|
|
@@ -17,6 +18,7 @@ const startServe = async ({ project, env, createDb = (url) => createPgDb(pg, url
|
|
|
17
18
|
if (project.auth) await checkAuthSchema(project.auth);
|
|
18
19
|
const ownDb = project.db ? void 0 : createDb(env.databaseUrl);
|
|
19
20
|
const db = project.db ?? ownDb;
|
|
21
|
+
const { resources } = configExports(project.config);
|
|
20
22
|
const app = createServeApp({
|
|
21
23
|
project,
|
|
22
24
|
db,
|
|
@@ -24,12 +26,19 @@ const startServe = async ({ project, env, createDb = (url) => createPgDb(pg, url
|
|
|
24
26
|
site
|
|
25
27
|
});
|
|
26
28
|
const server = createAdaptorServer({ fetch: app.fetch });
|
|
27
|
-
await listen(server, env.port).catch(async (error) => {
|
|
29
|
+
await checkChanges(db, resources.map((resource) => resource.toModel()), project.events).then(() => listen(server, env.port)).catch(async (error) => {
|
|
28
30
|
await ownDb?.destroy();
|
|
29
31
|
throw error;
|
|
30
32
|
});
|
|
33
|
+
const cleanup = createFileCleanup({
|
|
34
|
+
resources,
|
|
35
|
+
db,
|
|
36
|
+
...project.files && { files: project.files }
|
|
37
|
+
});
|
|
38
|
+
const timer = cleanup && env.filesCleanupMinutes > 0 ? startCleanupTimer(cleanup.run, env.filesCleanupMinutes, (error) => console.error(error)) : void 0;
|
|
31
39
|
const close = async () => {
|
|
32
40
|
await closeServer(server);
|
|
41
|
+
await timer?.stop();
|
|
33
42
|
await ownDb?.destroy();
|
|
34
43
|
};
|
|
35
44
|
return {
|
package/dist/users/register.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
3
|
+
"version": "0.8.1",
|
|
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.
|
|
38
|
-
"@protobase/query": "0.
|
|
39
|
-
"@protobase/schema": "0.
|
|
40
|
-
"@protobase/server": "0.
|
|
41
|
-
"@protobase/ui": "0.
|
|
37
|
+
"@protobase/layout": "0.8.1",
|
|
38
|
+
"@protobase/query": "0.8.1",
|
|
39
|
+
"@protobase/schema": "0.8.1",
|
|
40
|
+
"@protobase/server": "0.8.1",
|
|
41
|
+
"@protobase/ui": "0.8.1",
|
|
42
42
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
43
43
|
"@tailwindcss/vite": "^4.3.3",
|
|
44
44
|
"@vitejs/plugin-react": "^6.1.2",
|