@protobase/cli 0.4.1 → 0.5.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 { hostModuleIds } from "../serve/host-module-ids.js";
2
+ import { extensionImports } from "../project/extensions.js";
2
3
  import { bundleEntryCode } from "./entry-code.js";
3
4
  import { layoutCheck } from "./layout-check.js";
4
5
  import { findPackageDir, packageName } from "./packages/package-dir.js";
@@ -51,19 +52,21 @@ const servedImports = (native) => {
51
52
  }
52
53
  };
53
54
  };
54
- const buildConfigBundle = async ({ projectDir, outFile, bun }) => {
55
+ const buildConfigBundle = async ({ projectDir, outFile, bun, extensions = [] }) => {
55
56
  const file = path.join(projectDir, "protobase-bundle-entry.js");
56
57
  const native = /* @__PURE__ */ new Map();
58
+ const plugins = [
59
+ layoutCheck(),
60
+ extensionImports(projectDir, extensions),
61
+ servedImports(native),
62
+ entry(file, bundleEntryCode(projectDir, extensions))
63
+ ];
57
64
  await viteBundle({
58
65
  root: projectDir,
59
66
  entry: file,
60
67
  outFile,
61
68
  bun,
62
- plugins: [
63
- layoutCheck(),
64
- servedImports(native),
65
- entry(file, bundleEntryCode(projectDir))
66
- ]
69
+ plugins
67
70
  });
68
71
  return [...native.values()];
69
72
  };
@@ -3,6 +3,7 @@ import { buildConfigBundle } from "./config-bundle.js";
3
3
  import { protobaseVersion } from "../version/version.js";
4
4
  import { copyPackages } from "./packages/copy-packages.js";
5
5
  import { buildUiBundle } from "./ui-bundle.js";
6
+ import { checkUiMerge } from "./ui-check.js";
6
7
  import { existsSync } from "node:fs";
7
8
  import path from "node:path";
8
9
  import { readFile, rm, writeFile } from "node:fs/promises";
@@ -17,17 +18,20 @@ const removeOwnNodeModules = async (outDir) => {
17
18
  force: true
18
19
  });
19
20
  };
20
- const buildDeployBundle = async ({ projectDir, outDir, bun }) => {
21
+ const buildDeployBundle = async ({ projectDir, outDir, bun, extensions = [] }) => {
21
22
  await removeOwnNodeModules(outDir);
23
+ if (extensions.some((extension) => extension.ui)) await checkUiMerge(projectDir, extensions);
22
24
  const native = await buildConfigBundle({
23
25
  projectDir,
24
26
  outFile: path.join(outDir, bundleLayout.server),
25
- bun
27
+ bun,
28
+ extensions
26
29
  });
27
30
  const packages = native.length > 0 ? await copyPackages(native, path.join(outDir, bundleNodeModules)) : [];
28
31
  await buildUiBundle({
29
32
  outDir: path.join(outDir, bundleLayout.public),
30
- projectDir
33
+ projectDir,
34
+ extensions
31
35
  });
32
36
  const manifest = {
33
37
  version: 2,
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  //#region src/build/entry-code.ts
5
5
  const conventionModule = moduleFile("../project/convention", import.meta.url);
6
6
  const uiDirs = (configDir) => readdirSync(configDir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && existsSync(path.join(configDir, entry.name, "ui.ts"))).map((entry) => entry.name).sort();
7
- const bundleEntryCode = (projectDir) => {
7
+ const bundleEntryCode = (projectDir, extensions = []) => {
8
8
  const custom = path.join(projectDir, "protobase.config.ts");
9
9
  const configDir = path.join(projectDir, "config");
10
10
  const index = path.join(configDir, "index.ts");
@@ -16,7 +16,8 @@ const bundleEntryCode = (projectDir) => {
16
16
  const dirs = uiDirs(configDir);
17
17
  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
18
  } else lines.push("const convention = undefined");
19
- lines.push("export default { ...project, config: project.config ?? convention }");
19
+ 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(", ")})`);
20
21
  return `${lines.join("\n")}\n`;
21
22
  };
22
23
  //#endregion
@@ -1,10 +1,11 @@
1
1
  import { adminAppDir } from "../project/admin-app.js";
2
+ import { extensionImports, extensionStyles } from "../project/extensions.js";
2
3
  import { projectUiPlugin } from "../project/project-ui.js";
3
4
  import { build } from "vite";
4
5
  import tailwindcss from "@tailwindcss/vite";
5
6
  import react from "@vitejs/plugin-react";
6
7
  //#region src/build/ui-bundle.ts
7
- const buildUiBundle = async ({ outDir, projectDir }) => {
8
+ const buildUiBundle = async ({ outDir, projectDir, extensions = [] }) => {
8
9
  const nodeEnv = process.env.NODE_ENV;
9
10
  process.env.NODE_ENV = "production";
10
11
  await build({
@@ -15,7 +16,9 @@ const buildUiBundle = async ({ outDir, projectDir }) => {
15
16
  publicDir: false,
16
17
  logLevel: "warn",
17
18
  plugins: [
18
- projectUiPlugin(projectDir),
19
+ projectUiPlugin(projectDir, extensions),
20
+ ...projectDir === void 0 ? [] : [extensionImports(projectDir, extensions)],
21
+ extensionStyles(extensions),
19
22
  react(),
20
23
  tailwindcss()
21
24
  ],
@@ -0,0 +1,30 @@
1
+ import { extensionImports } from "../project/extensions.js";
2
+ import { viteBundle } from "./vite-bundle.js";
3
+ import { mergedUiFile, projectUiPlugin } from "../project/project-ui.js";
4
+ import path from "node:path";
5
+ import { mkdtemp, rm } from "node:fs/promises";
6
+ import { pathToFileURL } from "node:url";
7
+ import { tmpdir } from "node:os";
8
+ //#region src/build/ui-check.ts
9
+ const checkUiMerge = async (projectDir, extensions) => {
10
+ const scratch = await mkdtemp(path.join(tmpdir(), "protobase-ui-check-"));
11
+ const outFile = path.join(scratch, "ui-check.js");
12
+ try {
13
+ const plugins = [projectUiPlugin(projectDir, extensions), extensionImports(projectDir, extensions)];
14
+ await viteBundle({
15
+ root: projectDir,
16
+ entry: mergedUiFile(projectDir),
17
+ outFile,
18
+ plugins,
19
+ logLevel: "error"
20
+ });
21
+ await import(pathToFileURL(outFile).href);
22
+ } finally {
23
+ await rm(scratch, {
24
+ recursive: true,
25
+ force: true
26
+ });
27
+ }
28
+ };
29
+ //#endregion
30
+ export { checkUiMerge };
@@ -4,12 +4,12 @@ import { mkdtemp, rm } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { build } from "vite";
6
6
  //#region src/build/vite-bundle.ts
7
- const viteBuild = async ({ root, entry, outFile, plugins }) => {
7
+ const viteBuild = async ({ root, entry, outFile, plugins, logLevel = "warn" }) => {
8
8
  await build({
9
9
  configFile: false,
10
10
  root,
11
11
  publicDir: false,
12
- logLevel: "warn",
12
+ logLevel,
13
13
  plugins,
14
14
  oxc: { jsx: { development: false } },
15
15
  ssr: {
package/dist/dev/entry.js CHANGED
@@ -3,7 +3,7 @@ import { Hono } from "hono";
3
3
  import { configExports, createAdmin, hasUsers } from "@protobase/server";
4
4
  //#region src/dev/entry.ts
5
5
  const projectDir = process.env.PROTOBASE_PROJECT;
6
- const project = await loadProject(projectDir);
6
+ const project = await loadProject(projectDir, JSON.parse(process.env.PROTOBASE_EXTENSIONS ?? "[]"));
7
7
  const db = projectDb(project, projectDir);
8
8
  const { resources, views, pages, userMenu } = configExports(project.exports);
9
9
  if (!project.authenticate) throw new Error("No authenticator: export `authenticate` (and `auth`) from protobase.config.ts; see https://docs.protobase.net/reference/auth/");
@@ -3,6 +3,7 @@ import { sharedDb } from "./create-db.js";
3
3
  import { existsSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { readdir } from "node:fs/promises";
6
+ import { mergeConfig } from "@protobase/server";
6
7
  //#region src/dev/project.ts
7
8
  const load = (file) => import(
8
9
  /* @vite-ignore */
@@ -21,12 +22,23 @@ const conventionExports = async (projectDir) => {
21
22
  }
22
23
  return conventionConfig(indexExports, uiModules);
23
24
  };
24
- const loadProject = async (projectDir) => {
25
+ const named = (config, file) => ({
26
+ ...config,
27
+ name: config.name ?? file
28
+ });
29
+ const loadProject = async (projectDir, extensions = []) => {
25
30
  const file = path.join(projectDir, "protobase.config.ts");
26
31
  const custom = existsSync(file) ? (await load(file)).default ?? {} : {};
27
- return {
32
+ const extended = [];
33
+ for (const extension of extensions) if (extension.config) extended.push(named((await load(extension.config)).default ?? {}, extension.config));
34
+ const own = named({
28
35
  ...custom,
29
- exports: custom.config ?? await conventionExports(projectDir)
36
+ config: custom.config ?? await conventionExports(projectDir)
37
+ }, existsSync(file) ? "protobase.config.ts" : "config/index.ts");
38
+ const project = mergeConfig(...extended, own);
39
+ return {
40
+ ...project,
41
+ exports: project.config ?? {}
30
42
  };
31
43
  };
32
44
  const projectDb = (project, projectDir) => {
package/dist/dev/run.js CHANGED
@@ -1,6 +1,7 @@
1
+ import { adminAppDir, adminAppMain, uiPackageDir } from "../project/admin-app.js";
2
+ import { extensionImports, extensionStyles } from "../project/extensions.js";
1
3
  import { moduleFile } from "../module-file.js";
2
4
  import { layoutCheck } from "../build/layout-check.js";
3
- import { adminAppDir, adminAppMain, uiPackageDir } from "../project/admin-app.js";
4
5
  import { projectUiPlugin } from "../project/project-ui.js";
5
6
  import { loadNearestEnvFile } from "./env.js";
6
7
  import { checkDatabase } from "./check-database.js";
@@ -16,10 +17,10 @@ const requireAppFiles = (appDir) => {
16
17
  const missing = ["index.html", adminAppMain].filter((file) => !existsSync(path.join(appDir, file)));
17
18
  if (missing.length > 0) throw new Error(`The admin app is missing ${missing.join(", ")} in ${appDir}; \`protobase dev\` serves the app from there`);
18
19
  };
19
- const reloadOnProjectChange = (projectDir) => ({
20
+ const reloadOnProjectChange = (dirs) => ({
20
21
  name: "protobase-project-reload",
21
22
  handleHotUpdate: ({ file, server }) => {
22
- if (file.startsWith(`${projectDir}${path.sep}`) && !file.includes(`${path.sep}node_modules${path.sep}`)) server.environments.ssr.moduleGraph.invalidateAll();
23
+ if (dirs.some((dir) => file.startsWith(`${dir}${path.sep}`)) && !file.includes(`${path.sep}node_modules${path.sep}`)) server.environments.ssr.moduleGraph.invalidateAll();
23
24
  }
24
25
  });
25
26
  const appDependencies = [
@@ -42,7 +43,9 @@ const runDev = async (options, out) => {
42
43
  const appDir = options.appDir ?? adminAppDir;
43
44
  requireAppFiles(appDir);
44
45
  await checkDatabase(url, options.projectDir);
46
+ const extensions = options.extensions ?? [];
45
47
  process.env.PROTOBASE_PROJECT = options.projectDir;
48
+ process.env.PROTOBASE_EXTENSIONS = JSON.stringify(extensions);
46
49
  process.env.PROTOBASE_DATABASE_URL = url;
47
50
  const server = await createServer({
48
51
  root: appDir,
@@ -56,12 +59,15 @@ const runDev = async (options, out) => {
56
59
  fs: { allow: [
57
60
  searchForWorkspaceRoot(options.projectDir),
58
61
  uiPackageDir,
59
- options.projectDir
62
+ options.projectDir,
63
+ ...extensions.map((extension) => extension.dir)
60
64
  ] }
61
65
  },
62
66
  plugins: [
63
- reloadOnProjectChange(options.projectDir),
64
- projectUiPlugin(options.projectDir),
67
+ reloadOnProjectChange([options.projectDir, ...extensions.map((extension) => extension.dir)]),
68
+ projectUiPlugin(options.projectDir, extensions),
69
+ extensionImports(options.projectDir, extensions),
70
+ extensionStyles(extensions),
65
71
  layoutCheck(),
66
72
  react(),
67
73
  tailwindcss(),
package/dist/main.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { manifestFile } from "./bundle/manifest.js";
2
+ import { resolveExtensions } from "./project/extensions.js";
2
3
  import { buildDeployBundle } from "./build/deploy-bundle.js";
3
4
  import { buildServeRuntime } from "./build/serve-runtime.js";
4
5
  import { resolveConnectionUrl } from "./db/connect.js";
@@ -46,8 +47,9 @@ program.command("doctor").description("Check a config folder against the databas
46
47
  }, out);
47
48
  if (errors > 0) process.exitCode = 1;
48
49
  });
49
- program.command("dev").description("Serve the admin app and API for the project in the current directory").option("--port <port>", "port for the app and API", "5173").option("--env <variable>", "environment variable holding the database URL", "DATABASE_URL").option("--allowed-hosts <hosts>", "extra Host headers to accept, comma separated; a leading dot matches subdomains (.trycloudflare.com)").option("--cache-dir <dir>", "Vite's dependency cache, so parallel dev servers do not share one").action(async (opts) => {
50
+ program.command("dev").description("Serve the admin app and API for the project in the current directory").option("--port <port>", "port for the app and API", "5173").option("--env <variable>", "environment variable holding the database URL", "DATABASE_URL").option("--allowed-hosts <hosts>", "extra Host headers to accept, comma separated; a leading dot matches subdomains (.trycloudflare.com)").option("--cache-dir <dir>", "Vite's dependency cache, so parallel dev servers do not share one").option("--extend <dir>", "merge the protobase.config.ts and protobase.ui.tsx in this folder before the project's, repeatable", collect, []).action(async (opts) => {
50
51
  const server = await runDev({
52
+ extensions: resolveExtensions(opts.extend),
51
53
  port: Number(opts.port),
52
54
  envVar: opts.env,
53
55
  projectDir: process.cwd(),
@@ -57,11 +59,13 @@ program.command("dev").description("Serve the admin app and API for the project
57
59
  process.once("SIGINT", () => void server.close().then(() => process.exit(0)));
58
60
  process.once("SIGTERM", () => void server.close().then(() => process.exit(0)));
59
61
  });
60
- program.command("build").description("Build the deploy bundle of the project in the current directory: its config module, admin UI and manifest").option("--out <dir>", "output folder", "dist").option("--bun", "have Bun's bundler write the config module, for a bundle only Bun serves (needs bun on PATH)", false).action(async (opts) => {
62
+ program.command("build").description("Build the deploy bundle of the project in the current directory: its config module, admin UI and manifest").option("--out <dir>", "output folder", "dist").option("--bun", "have Bun's bundler write the config module, for a bundle only Bun serves (needs bun on PATH)", false).option("--extend <dir>", "merge the protobase.config.ts and protobase.ui.tsx in this folder before the project's, repeatable", collect, []).action(async (opts) => {
63
+ const extensions = resolveExtensions(opts.extend);
61
64
  const manifest = await buildDeployBundle({
62
65
  projectDir: process.cwd(),
63
66
  outDir: path.resolve(opts.out),
64
- bun: opts.bun
67
+ bun: opts.bun,
68
+ extensions
65
69
  });
66
70
  const packages = manifest.nodeModules ? `, ${manifest.nodeModules}/` : "";
67
71
  out(`built ${opts.out}: ${manifest.server}${packages}, ${manifest.public}/ and ${manifestFile} (api ${manifest.api.join(", ")})\n`);
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.4.1";
2
+ var version = "0.5.0";
3
3
  //#endregion
4
4
  export { version };
@@ -0,0 +1,46 @@
1
+ import { adminAppDir } from "./admin-app.js";
2
+ import { existsSync, realpathSync } from "node:fs";
3
+ import path from "node:path";
4
+ //#region src/project/extensions.ts
5
+ const extensionConfigFile = "protobase.config.ts";
6
+ const extensionUiFile = "protobase.ui.tsx";
7
+ const resolveExtensions = (dirs) => dirs.map((given) => {
8
+ const resolved = path.resolve(given);
9
+ if (!existsSync(resolved)) throw new Error(`--extend ${given}: there is no folder ${resolved}`);
10
+ const dir = realpathSync(resolved);
11
+ const config = path.join(dir, extensionConfigFile);
12
+ const ui = path.join(dir, extensionUiFile);
13
+ const extension = {
14
+ dir,
15
+ ...existsSync(config) && { config },
16
+ ...existsSync(ui) && { ui }
17
+ };
18
+ if (!extension.config && !extension.ui) throw new Error(`--extend ${given}: ${dir} has neither ${extensionConfigFile} nor ${extensionUiFile}`);
19
+ return extension;
20
+ });
21
+ const shared = /^(@protobase\/[^/]+|react|react-dom)(\/.*)?$/;
22
+ const inside = (file, dir) => file === dir || file.startsWith(`${dir}${path.sep}`);
23
+ const extensionImports = (projectDir, extensions) => ({
24
+ name: "protobase-extension-imports",
25
+ enforce: "pre",
26
+ async resolveId(id, importer, options) {
27
+ if (!importer || !shared.test(id)) return void 0;
28
+ const from = importer.replace(/^\0/, "").replace(/\?.*$/, "");
29
+ if (!extensions.some((extension) => inside(from, extension.dir))) return void 0;
30
+ return this.resolve(id, path.join(projectDir, extensionConfigFile), {
31
+ ...options,
32
+ skipSelf: true
33
+ });
34
+ }
35
+ });
36
+ const appStyles = path.join(path.dirname(adminAppDir), "styles.css");
37
+ const extensionStyles = (extensions) => ({
38
+ name: "protobase-extension-styles",
39
+ enforce: "pre",
40
+ transform: (code, id) => {
41
+ if (extensions.length === 0 || id.replace(/\?.*$/, "") !== appStyles) return void 0;
42
+ return `${code}\n${extensions.map((extension) => `@source ${JSON.stringify(extension.dir)};`).join("\n")}\n`;
43
+ }
44
+ });
45
+ //#endregion
46
+ export { extensionConfigFile, extensionImports, extensionStyles, extensionUiFile, resolveExtensions };
@@ -4,14 +4,35 @@ import path from "node:path";
4
4
  const projectUiId = "virtual:protobase-project-ui";
5
5
  const empty = `\0${projectUiId}`;
6
6
  const projectUiFile = (projectDir) => path.join(projectDir, "protobase.ui.tsx");
7
- const projectUiPlugin = (projectDir) => ({
8
- name: "protobase-project-ui",
9
- resolveId: (id) => {
10
- if (id !== "virtual:protobase-project-ui") return void 0;
11
- const file = projectDir === void 0 ? void 0 : projectUiFile(projectDir);
12
- return file && existsSync(file) ? file : empty;
13
- },
14
- load: (id) => id === empty ? "export default {}\n" : void 0
15
- });
7
+ const mergedUiFile = (projectDir) => path.join(projectDir, "protobase-ui-entry.js");
8
+ const mergedUiCode = (projectDir, extensions) => {
9
+ const files = extensions.flatMap((extension) => extension.ui ? [extension.ui] : []);
10
+ const own = projectUiFile(projectDir);
11
+ const hasOwn = existsSync(own);
12
+ return [
13
+ `import { mergeUi } from '@protobase/ui'`,
14
+ ...files.map((file, i) => `import * as extension${i} from ${JSON.stringify(file)}`),
15
+ ...hasOwn ? [`import * as project from ${JSON.stringify(own)}`] : [],
16
+ "const named = (ui, file) => ({ ...ui, name: ui.name ?? file })",
17
+ `export default mergeUi(${[...files.map((file, i) => `named(extension${i}.default ?? {}, ${JSON.stringify(file)})`), ...hasOwn ? [`named(project.default ?? {}, "protobase.ui.tsx")`] : []].join(", ")})`,
18
+ ""
19
+ ].join("\n");
20
+ };
21
+ const projectUiPlugin = (projectDir, extensions = []) => {
22
+ const merged = projectDir !== void 0 && extensions.some((extension) => extension.ui) ? `\0${mergedUiFile(projectDir)}` : void 0;
23
+ return {
24
+ name: "protobase-project-ui",
25
+ resolveId: (id) => {
26
+ if (merged && (id === "virtual:protobase-project-ui" || id === mergedUiFile(projectDir))) return merged;
27
+ if (id !== "virtual:protobase-project-ui") return void 0;
28
+ const file = projectDir === void 0 ? void 0 : projectUiFile(projectDir);
29
+ return file && existsSync(file) ? file : empty;
30
+ },
31
+ load: (id) => {
32
+ if (id === empty) return "export default {}\n";
33
+ if (id === merged) return mergedUiCode(projectDir, extensions);
34
+ }
35
+ };
36
+ };
16
37
  //#endregion
17
- export { projectUiFile, projectUiId, projectUiPlugin };
38
+ export { mergedUiCode, mergedUiFile, projectUiFile, projectUiId, projectUiPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/cli",
3
- "version": "0.4.1",
3
+ "version": "0.5.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.4.1",
38
- "@protobase/query": "0.4.1",
39
- "@protobase/schema": "0.4.1",
40
- "@protobase/server": "0.4.1",
41
- "@protobase/ui": "0.4.1",
37
+ "@protobase/layout": "0.5.0",
38
+ "@protobase/query": "0.5.0",
39
+ "@protobase/schema": "0.5.0",
40
+ "@protobase/server": "0.5.0",
41
+ "@protobase/ui": "0.5.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",