@rebasepro/cli 0.10.1-canary.6f89f77 → 0.10.1-canary.7801eed

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.
Files changed (36) hide show
  1. package/dist/bundle.d.ts +167 -0
  2. package/dist/commands/apps.d.ts +1 -0
  3. package/dist/commands/build.d.ts +1 -1
  4. package/dist/commands/cloud/bundle-deploy.d.ts +53 -0
  5. package/dist/commands/cloud/context.d.ts +18 -0
  6. package/dist/commands/cloud/deploy.d.ts +48 -0
  7. package/dist/commands/generate_sdk.d.ts +25 -0
  8. package/dist/commands/start.d.ts +1 -1
  9. package/dist/fold-static.d.ts +46 -0
  10. package/dist/index.d.ts +3 -0
  11. package/dist/index.es.js +2403 -37
  12. package/dist/index.es.js.map +1 -1
  13. package/dist/manifest.d.ts +83 -0
  14. package/dist/utils/project.d.ts +11 -3
  15. package/package.json +8 -7
  16. package/runtime/dev-server.mjs +43 -0
  17. package/templates/overlays/baas/backend/src/index.ts +7 -0
  18. package/templates/overlays/baas/backend/src/storage.ts +71 -0
  19. package/templates/overlays/baas/rebase.json +14 -0
  20. package/templates/template/.env.example +5 -0
  21. package/templates/template/README.md +8 -3
  22. package/templates/template/backend/src/index.ts +7 -0
  23. package/templates/template/config/admin.d.ts +9 -0
  24. package/templates/template/config/collections/authors.ts +12 -10
  25. package/templates/template/config/collections/posts.ts +17 -13
  26. package/templates/template/config/collections/presets/ecommerce/categories.ts +8 -6
  27. package/templates/template/config/collections/presets/ecommerce/orders.ts +33 -21
  28. package/templates/template/config/collections/presets/ecommerce/products.ts +27 -18
  29. package/templates/template/config/collections/tags.ts +6 -4
  30. package/templates/template/config/collections/users.ts +35 -30
  31. package/templates/template/config/frontend-assets.d.ts +17 -0
  32. package/templates/template/config/index.ts +6 -0
  33. package/templates/template/config/package.json +26 -25
  34. package/templates/template/config/storage.ts +88 -0
  35. package/templates/template/gitignore +4 -0
  36. package/templates/template/rebase.json +20 -0
package/dist/index.es.js CHANGED
@@ -12,6 +12,7 @@ import crypto from "crypto";
12
12
  import { execSync, spawn, spawnSync } from "child_process";
13
13
  import os from "os";
14
14
  import { createRebaseClient } from "@rebasepro/client";
15
+ import { BUNDLE_FORMAT_VERSION, RUNTIME_CONTRACT_VERSION, computeSchemaVersion, deserializeCollections } from "@rebasepro/types";
15
16
  import { generateSDK } from "@rebasepro/codegen";
16
17
  import { createRequire } from "module";
17
18
  //#region src/utils/package-manager.ts
@@ -196,17 +197,26 @@ function getPMCommands(pm) {
196
197
  * These helpers locate the project root, backend directory, .env file,
197
198
  * and local binaries — used by all CLI command modules.
198
199
  */
200
+ /** The authored project manifest. Its presence alone marks a project root. */
201
+ var MANIFEST_FILENAME = "rebase.json";
199
202
  /**
200
203
  * Walk up from `startDir` to find the Rebase project root.
201
204
  *
202
- * The root is identified by a `package.json` that either:
203
- * - has `workspaces` containing "backend" or "frontend", OR
204
- * - has a sibling `backend/` directory
205
+ * A directory is the root when it holds a `rebase.json`, or when it holds a
206
+ * `package.json` that either lists `backend` as a workspace or sits beside both
207
+ * `backend/` and `config/`.
208
+ *
209
+ * `rebase.json` is checked first and needs no `package.json` beside it, because
210
+ * the conventions below all describe a repository that *contains the backend*.
211
+ * A repository holding only a frontend — the normal shape once a project's apps
212
+ * live in separate repositories — matches none of them, so without this the
213
+ * tooling could not run there at all.
205
214
  */
206
215
  function findProjectRoot(startDir = process.cwd()) {
207
216
  let dir = path.resolve(startDir);
208
217
  const root = path.parse(dir).root;
209
218
  while (dir !== root) {
219
+ if (fs.existsSync(path.join(dir, "rebase.json"))) return dir;
210
220
  const pkgPath = path.join(dir, "package.json");
211
221
  if (fs.existsSync(pkgPath)) {
212
222
  try {
@@ -1600,21 +1610,179 @@ function writeFiles(outputDir, files) {
1600
1610
  fs.writeFileSync(filePath, file.content, "utf-8");
1601
1611
  }
1602
1612
  }
1613
+ function printSdkHelp() {
1614
+ console.log(`
1615
+ ${chalk.bold("rebase generate-sdk")} — generate a typed client from a project's schema
1616
+
1617
+ ${chalk.bold("Usage")}
1618
+ rebase generate-sdk [options]
1619
+
1620
+ ${chalk.bold("Options")}
1621
+ -c, --collections-dir <dir> Local collections directory (default: ./config/collections)
1622
+ -o, --output <dir> Where to write the SDK (default: ./generated/sdk)
1623
+ --from <link|url> Fetch the schema from a running project instead of
1624
+ local source. "link" uses this checkout's linked project.
1625
+ --token <token> Bearer token for the contract endpoint
1626
+ (default: $REBASE_SERVICE_KEY)
1627
+ -h, --help Show this help
1628
+
1629
+ ${chalk.bold("Examples")}
1630
+ rebase generate-sdk From local collections
1631
+ rebase generate-sdk --from link From the linked project
1632
+ rebase generate-sdk --from https://api.acme.com From any Rebase backend
1633
+ `.trim());
1634
+ }
1635
+ /**
1636
+ * Fetch collections from a running project's contract endpoint.
1637
+ *
1638
+ * The payload replaces relation `target` functions with slug references, so it
1639
+ * has to be rehydrated before the generator sees it — the generator *calls*
1640
+ * `target()` to decide whether a foreign key is a string or a number, and a
1641
+ * missing target silently degrades that to a union rather than failing.
1642
+ */
1643
+ async function fetchRemoteCollections(baseUrl, token) {
1644
+ const url = `${baseUrl.replace(/\/+$/, "")}/api/meta/contract`;
1645
+ const headers = { accept: "application/json" };
1646
+ if (token) headers.authorization = `Bearer ${token}`;
1647
+ let response;
1648
+ try {
1649
+ response = await fetch(url, { headers });
1650
+ } catch (err) {
1651
+ console.log(chalk.red(` ✗ Could not reach ${url}`));
1652
+ console.log(chalk.gray(` ${err instanceof Error ? err.message : String(err)}`));
1653
+ process.exit(1);
1654
+ }
1655
+ if (response.status === 401 || response.status === 403) {
1656
+ console.log(chalk.red(` ✗ Not authorized to read the project contract (${response.status}).`));
1657
+ console.log(chalk.gray(" The contract describes every table and relation, so it is admin-only."));
1658
+ console.log(chalk.gray(" Pass --token, or set REBASE_SERVICE_KEY."));
1659
+ process.exit(1);
1660
+ }
1661
+ if (response.status === 404) {
1662
+ console.log(chalk.red(" ✗ This server has no contract endpoint."));
1663
+ console.log(chalk.gray(" It needs to be running Rebase 0.11 or newer."));
1664
+ process.exit(1);
1665
+ }
1666
+ if (!response.ok) {
1667
+ console.log(chalk.red(` ✗ Contract request failed with ${response.status}.`));
1668
+ process.exit(1);
1669
+ }
1670
+ const contract = await response.json();
1671
+ if (!Array.isArray(contract.collections)) {
1672
+ console.log(chalk.red(" ✗ The contract response did not contain collections."));
1673
+ process.exit(1);
1674
+ }
1675
+ return {
1676
+ collections: deserializeCollections(contract.collections),
1677
+ schemaVersion: contract.schemaVersion ?? "unknown"
1678
+ };
1679
+ }
1680
+ /**
1681
+ * Decide whether the ambient service key may be sent to this host.
1682
+ *
1683
+ * `REBASE_SERVICE_KEY` grants full admin bypass. Attaching it to whatever URL
1684
+ * happened to be passed — or, worse, to whatever a committed `.rebase/cloud.json`
1685
+ * points at — would hand the project's most powerful credential to a host nobody
1686
+ * vetted. An explicit `--token` is a decision the caller made; the ambient
1687
+ * variable is not, so it only travels to the project this checkout is linked to.
1688
+ */
1689
+ function mayUseAmbientKey(target, cwd) {
1690
+ const link = readLink(findProjectRoot(cwd) ?? cwd);
1691
+ if (!link?.apiUrl) return false;
1692
+ try {
1693
+ return new URL(link.apiUrl).origin === new URL(target).origin;
1694
+ } catch {
1695
+ return false;
1696
+ }
1697
+ }
1698
+ /**
1699
+ * The base URL to show in the printed usage example.
1700
+ *
1701
+ * `rebase dev` binds a port derived from the project path, not 3001, and writes
1702
+ * the one it actually got to `.rebase/state.json`. Printing a hardcoded
1703
+ * `localhost:3001` sent people to a port nothing was listening on — or, with
1704
+ * several projects on one machine, to a different project's backend. Prefer the
1705
+ * port this project last ran on; fall back to the literal only when the project
1706
+ * has never been started.
1707
+ */
1708
+ function resolveExampleBaseUrl(cwd) {
1709
+ const projectRoot = findProjectRoot(cwd) ?? cwd;
1710
+ try {
1711
+ const state = JSON.parse(fs.readFileSync(path.join(projectRoot, ".rebase", "state.json"), "utf-8"));
1712
+ if (typeof state.baseUrl === "string" && state.baseUrl) return state.baseUrl;
1713
+ if (typeof state.port === "number") return `http://localhost:${state.port}`;
1714
+ } catch {}
1715
+ return "http://localhost:3001";
1716
+ }
1717
+ /** Whether a slug can be written as `rebase.data.<slug>` rather than a lookup. */
1718
+ function isIdentifierLike(slug) {
1719
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(slug);
1720
+ }
1721
+ /** Resolve `--from` into a base URL, following the link file when asked. */
1722
+ function resolveSchemaSource(from, cwd) {
1723
+ if (from !== "link") {
1724
+ let parsed;
1725
+ try {
1726
+ parsed = new URL(from);
1727
+ } catch {
1728
+ console.log(chalk.red(` ✗ "${from}" is not a valid URL.`));
1729
+ console.log(chalk.gray(" Pass a full URL, e.g. https://api.example.com, or \"link\"."));
1730
+ process.exit(1);
1731
+ }
1732
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1733
+ console.log(chalk.red(" ✗ The project URL must be http or https."));
1734
+ process.exit(1);
1735
+ }
1736
+ return from;
1737
+ }
1738
+ const link = readLink(findProjectRoot(cwd) ?? cwd);
1739
+ if (!link) {
1740
+ console.log(chalk.red(" ✗ This checkout is not linked to a project."));
1741
+ console.log(chalk.gray(" Run `rebase link <url>`, or pass --from <url>."));
1742
+ process.exit(1);
1743
+ }
1744
+ const apiUrl = link.apiUrl;
1745
+ if (!apiUrl) {
1746
+ console.log(chalk.red(" ✗ The project link has no API URL."));
1747
+ console.log(chalk.gray(" Re-link with `rebase link <url>` to record one."));
1748
+ process.exit(1);
1749
+ }
1750
+ return apiUrl;
1751
+ }
1603
1752
  /**
1604
1753
  * Main entry point for the generate-sdk command.
1605
1754
  */
1606
1755
  async function generateSdkCommand(args) {
1607
1756
  const { collectionsDir, output, cwd } = args;
1757
+ if (args.help) {
1758
+ printSdkHelp();
1759
+ return;
1760
+ }
1608
1761
  const resolvedCollectionsDir = path.isAbsolute(collectionsDir) ? collectionsDir : path.join(cwd, collectionsDir);
1609
1762
  const resolvedOutput = path.isAbsolute(output) ? output : path.join(cwd, output);
1610
1763
  console.log("");
1611
1764
  console.log(chalk.bold(" 🔧 Rebase SDK Generator"));
1612
1765
  console.log("");
1613
- console.log(` ${chalk.gray("Collections:")} ${resolvedCollectionsDir}`);
1614
- console.log(` ${chalk.gray("Output:")} ${resolvedOutput}`);
1615
- console.log("");
1616
- console.log(chalk.cyan(" → Loading collection definitions..."));
1617
- const collections = await loadCollections(resolvedCollectionsDir);
1766
+ let collections;
1767
+ let remoteSchemaVersion;
1768
+ if (args.from) {
1769
+ const baseUrl = resolveSchemaSource(args.from, cwd);
1770
+ console.log(` ${chalk.gray("Project:")} ${baseUrl}`);
1771
+ console.log(` ${chalk.gray("Output:")} ${resolvedOutput}`);
1772
+ console.log("");
1773
+ console.log(chalk.cyan(" → Fetching the project contract..."));
1774
+ const ambient = mayUseAmbientKey(baseUrl, cwd) ? process.env.REBASE_SERVICE_KEY : void 0;
1775
+ if (!args.token && !ambient && process.env.REBASE_SERVICE_KEY) console.log(chalk.dim(" (not sending REBASE_SERVICE_KEY — this host is not the linked project; pass --token to override)"));
1776
+ const remote = await fetchRemoteCollections(baseUrl, args.token || ambient);
1777
+ collections = remote.collections;
1778
+ remoteSchemaVersion = remote.schemaVersion;
1779
+ } else {
1780
+ console.log(` ${chalk.gray("Collections:")} ${resolvedCollectionsDir}`);
1781
+ console.log(` ${chalk.gray("Output:")} ${resolvedOutput}`);
1782
+ console.log("");
1783
+ console.log(chalk.cyan(" → Loading collection definitions..."));
1784
+ collections = await loadCollections(resolvedCollectionsDir);
1785
+ }
1618
1786
  collections.sort((a, b) => a.slug.localeCompare(b.slug));
1619
1787
  if (collections.length === 0) {
1620
1788
  console.log(chalk.red(" ✗ No collections found. Nothing to generate."));
@@ -1624,22 +1792,41 @@ async function generateSdkCommand(args) {
1624
1792
  console.log("");
1625
1793
  console.log(chalk.cyan(" → Generating SDK files..."));
1626
1794
  const files = generateSDK(collections);
1795
+ const schemaVersion = remoteSchemaVersion ?? computeSchemaVersion(collections);
1796
+ files.push({
1797
+ path: "schema.meta.ts",
1798
+ content: `// Auto-generated by \`rebase generate-sdk\`. Do not edit.
1799
+ //
1800
+ // The schema version this SDK was generated from. Compare it against the
1801
+ // project's current version to detect drift:
1802
+ //
1803
+ // curl -s <api-url>/api/meta/schema-version
1804
+ //
1805
+ export const SCHEMA_VERSION = ${JSON.stringify(schemaVersion)};
1806
+ export const GENERATED_AT = ${JSON.stringify((/* @__PURE__ */ new Date()).toISOString())};
1807
+ `
1808
+ });
1627
1809
  console.log(chalk.green(` ✓ Generated ${files.length} file(s)`));
1810
+ console.log(chalk.gray(` schema ${schemaVersion}`));
1628
1811
  console.log(chalk.cyan(` → Writing to ${resolvedOutput}...`));
1629
1812
  writeFiles(resolvedOutput, files);
1630
1813
  console.log("");
1631
1814
  console.log(chalk.green.bold(" ✓ SDK generated successfully!"));
1632
1815
  console.log("");
1816
+ const typesImport = `./${path.relative(cwd, path.join(resolvedOutput, "database.types"))}`;
1817
+ const exampleSlug = collections[0]?.slug || "my_collection";
1633
1818
  console.log(chalk.gray(" Usage:"));
1634
1819
  console.log(chalk.gray(" import { createRebaseClient } from '@rebasepro/client';"));
1635
- console.log(chalk.gray(` import type { Database } from './${path.relative(cwd, path.join(resolvedOutput, "database.types"))}';`));
1820
+ console.log(chalk.gray(` import { collectionsDictionary, type Database } from '${typesImport}';`));
1636
1821
  console.log("");
1637
1822
  console.log(chalk.gray(" const rebase = createRebaseClient<Database>({"));
1638
- console.log(chalk.gray(" baseUrl: 'http://localhost:3001',"));
1823
+ console.log(chalk.gray(` baseUrl: '${resolveExampleBaseUrl(cwd)}',`));
1824
+ console.log(chalk.gray(" collections: collectionsDictionary,"));
1639
1825
  console.log(chalk.gray(" // token: 'your-jwt-token',"));
1640
1826
  console.log(chalk.gray(" });"));
1641
1827
  console.log("");
1642
- console.log(chalk.gray(` const { data } = await rebase.collection('${collections[0]?.slug || "my_collection"}').find();`));
1828
+ console.log(chalk.gray(` const { data } = await rebase.data.collection('${exampleSlug}').find();`));
1829
+ if (isIdentifierLike(exampleSlug)) console.log(chalk.gray(` // …or in property style: rebase.data.${exampleSlug}.find()`));
1643
1830
  console.log("");
1644
1831
  }
1645
1832
  //#endregion
@@ -1794,6 +1981,367 @@ ${chalk.green.bold("Examples")}
1794
1981
  `);
1795
1982
  }
1796
1983
  //#endregion
1984
+ //#region src/manifest.ts
1985
+ /**
1986
+ * Loading, validating and synthesizing `rebase.json`.
1987
+ *
1988
+ * The manifest declares *topology*: which runtime major a project targets and
1989
+ * which apps this repository contributes. It is deliberately small — schema,
1990
+ * security rules, hooks and functions stay in TypeScript, where a type system
1991
+ * can check them.
1992
+ *
1993
+ * Two properties matter more than the file format itself:
1994
+ *
1995
+ * - **A missing manifest is never an error.** Every project that exists today
1996
+ * predates this file. One is synthesized from the conventions the template
1997
+ * already follows, so nothing breaks and nobody is forced to migrate.
1998
+ * - **Validation reports every problem at once**, with the path to each. A
1999
+ * config file that surfaces its mistakes one run at a time is a bad config
2000
+ * file.
2001
+ */
2002
+ /** Runtime range written into new manifests. */
2003
+ var CURRENT_RUNTIME_RANGE = "^1";
2004
+ /** Conventional locations, matching what `rebase init` scaffolds. */
2005
+ var DEFAULT_CONFIG_DIR = "config";
2006
+ var DEFAULT_FUNCTIONS_DIR = "backend/functions";
2007
+ var DEFAULT_CRONS_DIR = "backend/crons";
2008
+ var DEFAULT_SCHEMA_FILE = "backend/src/schema.generated.ts";
2009
+ var ManifestError = class extends Error {
2010
+ issues;
2011
+ constructor(message, issues = []) {
2012
+ super(message);
2013
+ this.issues = issues;
2014
+ this.name = "ManifestError";
2015
+ }
2016
+ };
2017
+ var APP_TYPES = [
2018
+ "backend",
2019
+ "static",
2020
+ "admin",
2021
+ "mobile",
2022
+ "custom"
2023
+ ];
2024
+ /** Reserved because they name things in URLs and CLI output. */
2025
+ var RESERVED_APP_NAMES = new Set([
2026
+ "api",
2027
+ "health",
2028
+ "metrics",
2029
+ "livez",
2030
+ "_rebase"
2031
+ ]);
2032
+ function isRecord(value) {
2033
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2034
+ }
2035
+ /**
2036
+ * Reject paths that escape the repository.
2037
+ *
2038
+ * A manifest is committed and reviewed, so this is not a security boundary so
2039
+ * much as a guard against `../../` typos that would otherwise have `rebase build`
2040
+ * writing outside the project.
2041
+ */
2042
+ function checkRelativePath(value, fieldPath, issues, { required }) {
2043
+ if (value === void 0) {
2044
+ if (required) issues.push({
2045
+ path: fieldPath,
2046
+ message: "is required"
2047
+ });
2048
+ return;
2049
+ }
2050
+ if (typeof value !== "string" || value.trim() === "") {
2051
+ issues.push({
2052
+ path: fieldPath,
2053
+ message: "must be a non-empty string"
2054
+ });
2055
+ return;
2056
+ }
2057
+ if (path.isAbsolute(value)) {
2058
+ issues.push({
2059
+ path: fieldPath,
2060
+ message: "must be a relative path, not absolute"
2061
+ });
2062
+ return;
2063
+ }
2064
+ const normalized = path.normalize(value);
2065
+ if (normalized === ".." || normalized.startsWith(`..${path.sep}`)) {
2066
+ issues.push({
2067
+ path: fieldPath,
2068
+ message: "must stay inside the project directory"
2069
+ });
2070
+ return;
2071
+ }
2072
+ return value;
2073
+ }
2074
+ function validateApp(name, raw, issues) {
2075
+ const base = `apps.${name}`;
2076
+ if (!isRecord(raw)) {
2077
+ issues.push({
2078
+ path: base,
2079
+ message: "must be an object"
2080
+ });
2081
+ return;
2082
+ }
2083
+ const type = raw.type;
2084
+ if (typeof type !== "string" || !APP_TYPES.includes(type)) {
2085
+ issues.push({
2086
+ path: `${base}.type`,
2087
+ message: `must be one of: ${APP_TYPES.join(", ")}`
2088
+ });
2089
+ return;
2090
+ }
2091
+ switch (type) {
2092
+ case "backend":
2093
+ checkRelativePath(raw.config, `${base}.config`, issues, { required: false });
2094
+ checkRelativePath(raw.functions, `${base}.functions`, issues, { required: false });
2095
+ checkRelativePath(raw.crons, `${base}.crons`, issues, { required: false });
2096
+ checkRelativePath(raw.schema, `${base}.schema`, issues, { required: false });
2097
+ checkRelativePath(raw.usersCollection, `${base}.usersCollection`, issues, { required: false });
2098
+ if (raw.mode !== void 0 && raw.mode !== "cms" && raw.mode !== "baas") issues.push({
2099
+ path: `${base}.mode`,
2100
+ message: "must be \"cms\" or \"baas\""
2101
+ });
2102
+ return raw;
2103
+ case "static":
2104
+ checkRelativePath(raw.root, `${base}.root`, issues, { required: true });
2105
+ checkRelativePath(raw.output, `${base}.output`, issues, { required: true });
2106
+ if (raw.build !== void 0 && typeof raw.build !== "string") issues.push({
2107
+ path: `${base}.build`,
2108
+ message: "must be a string command"
2109
+ });
2110
+ if (raw.spa !== void 0 && typeof raw.spa !== "boolean") issues.push({
2111
+ path: `${base}.spa`,
2112
+ message: "must be a boolean"
2113
+ });
2114
+ return raw;
2115
+ case "admin": {
2116
+ const mode = raw.mode ?? "hosted";
2117
+ if (mode !== "hosted" && mode !== "bundled") {
2118
+ issues.push({
2119
+ path: `${base}.mode`,
2120
+ message: "must be \"hosted\" or \"bundled\""
2121
+ });
2122
+ return;
2123
+ }
2124
+ if (mode === "bundled") {
2125
+ checkRelativePath(raw.root, `${base}.root`, issues, { required: true });
2126
+ checkRelativePath(raw.output, `${base}.output`, issues, { required: true });
2127
+ }
2128
+ return raw;
2129
+ }
2130
+ case "mobile": {
2131
+ const platform = raw.platform;
2132
+ if (platform !== "ios" && platform !== "android" && platform !== "other") issues.push({
2133
+ path: `${base}.platform`,
2134
+ message: "must be \"ios\", \"android\" or \"other\""
2135
+ });
2136
+ return raw;
2137
+ }
2138
+ case "custom":
2139
+ checkRelativePath(raw.dockerfile, `${base}.dockerfile`, issues, { required: false });
2140
+ checkRelativePath(raw.context, `${base}.context`, issues, { required: false });
2141
+ if (raw.port !== void 0 && (typeof raw.port !== "number" || !Number.isInteger(raw.port))) issues.push({
2142
+ path: `${base}.port`,
2143
+ message: "must be an integer"
2144
+ });
2145
+ return raw;
2146
+ default: return;
2147
+ }
2148
+ }
2149
+ /**
2150
+ * Validate a parsed manifest, collecting every problem.
2151
+ */
2152
+ function validateManifest(raw) {
2153
+ const issues = [];
2154
+ if (!isRecord(raw)) return { issues: [{
2155
+ path: "",
2156
+ message: `${MANIFEST_FILENAME} must contain a JSON object`
2157
+ }] };
2158
+ if (typeof raw.runtime !== "string" || raw.runtime.trim() === "") issues.push({
2159
+ path: "runtime",
2160
+ message: `is required, e.g. "^1"`
2161
+ });
2162
+ if (!isRecord(raw.apps)) {
2163
+ issues.push({
2164
+ path: "apps",
2165
+ message: "is required and must be an object"
2166
+ });
2167
+ return { issues };
2168
+ }
2169
+ const apps = {};
2170
+ let backendCount = 0;
2171
+ for (const [name, value] of Object.entries(raw.apps)) {
2172
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
2173
+ issues.push({
2174
+ path: `apps.${name}`,
2175
+ message: "name must be lowercase alphanumeric with dashes (it appears in URLs)"
2176
+ });
2177
+ continue;
2178
+ }
2179
+ if (RESERVED_APP_NAMES.has(name)) {
2180
+ issues.push({
2181
+ path: `apps.${name}`,
2182
+ message: "name is reserved"
2183
+ });
2184
+ continue;
2185
+ }
2186
+ const app = validateApp(name, value, issues);
2187
+ if (!app) continue;
2188
+ if (app.type === "backend") backendCount++;
2189
+ apps[name] = app;
2190
+ }
2191
+ if (backendCount > 1) issues.push({
2192
+ path: "apps",
2193
+ message: "a project may declare at most one backend app"
2194
+ });
2195
+ if (issues.length > 0) return { issues };
2196
+ return {
2197
+ manifest: {
2198
+ $schema: typeof raw.$schema === "string" ? raw.$schema : void 0,
2199
+ runtime: raw.runtime,
2200
+ apps
2201
+ },
2202
+ issues
2203
+ };
2204
+ }
2205
+ /**
2206
+ * Infer a manifest from a directory that does not have one.
2207
+ *
2208
+ * This mirrors exactly what the template scaffolds, which is what makes adopting
2209
+ * the manifest a no-op for existing projects: the synthesized result is what
2210
+ * they would have written by hand.
2211
+ *
2212
+ * An ejected backend — one with its own `src/index.ts` entrypoint — is reported
2213
+ * as a `custom` app rather than a `backend` app. That is not a downgrade; it is
2214
+ * an accurate description, and it is what keeps such a project deploying exactly
2215
+ * as it does today.
2216
+ */
2217
+ function synthesizeManifest(projectRoot) {
2218
+ const exists = (relative) => fs.existsSync(path.join(projectRoot, relative));
2219
+ const apps = {};
2220
+ const hasConfig = exists(DEFAULT_CONFIG_DIR);
2221
+ const hasBackend = exists("backend");
2222
+ const backendEntry = exists("backend/src/index.ts");
2223
+ if (hasBackend && backendEntry) apps.backend = {
2224
+ type: "custom",
2225
+ dockerfile: exists("backend/Dockerfile") ? "backend/Dockerfile" : void 0,
2226
+ context: "."
2227
+ };
2228
+ else if (hasBackend || hasConfig) {
2229
+ const backend = { type: "backend" };
2230
+ if (!hasConfig) backend.mode = "baas";
2231
+ if (exists("backend/functions")) backend.functions = DEFAULT_FUNCTIONS_DIR;
2232
+ if (exists("backend/crons")) backend.crons = DEFAULT_CRONS_DIR;
2233
+ apps.backend = backend;
2234
+ }
2235
+ if (exists("frontend")) apps.web = {
2236
+ type: "static",
2237
+ root: "frontend",
2238
+ build: "npm run build --workspace frontend",
2239
+ output: "frontend/dist",
2240
+ spa: true
2241
+ };
2242
+ return {
2243
+ runtime: "^1",
2244
+ apps
2245
+ };
2246
+ }
2247
+ function manifestPath(projectRoot) {
2248
+ return path.join(projectRoot, MANIFEST_FILENAME);
2249
+ }
2250
+ function manifestExists(projectRoot) {
2251
+ return fs.existsSync(manifestPath(projectRoot));
2252
+ }
2253
+ /**
2254
+ * Read the manifest, falling back to a synthesized one.
2255
+ *
2256
+ * A malformed manifest throws — unlike a missing one. Silently ignoring a file
2257
+ * the developer wrote, and building something else instead, is the worst
2258
+ * available behaviour.
2259
+ */
2260
+ function loadManifest(projectRoot) {
2261
+ const filePath = manifestPath(projectRoot);
2262
+ if (!fs.existsSync(filePath)) return {
2263
+ manifest: synthesizeManifest(projectRoot),
2264
+ source: "synthesized"
2265
+ };
2266
+ let parsed;
2267
+ try {
2268
+ parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
2269
+ } catch (err) {
2270
+ throw new ManifestError(`${MANIFEST_FILENAME} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
2271
+ }
2272
+ const { manifest, issues } = validateManifest(parsed);
2273
+ if (!manifest) throw new ManifestError(`${MANIFEST_FILENAME} is invalid`, issues);
2274
+ return {
2275
+ manifest,
2276
+ source: "file",
2277
+ filePath
2278
+ };
2279
+ }
2280
+ /** Write a manifest, with a trailing newline so it plays well with other tools. */
2281
+ function writeManifest(projectRoot, manifest) {
2282
+ const filePath = manifestPath(projectRoot);
2283
+ const ordered = {
2284
+ $schema: manifest.$schema ?? "https://rebase.pro/schemas/rebase.json",
2285
+ runtime: manifest.runtime,
2286
+ apps: manifest.apps
2287
+ };
2288
+ fs.writeFileSync(filePath, `${JSON.stringify(ordered, null, 4)}\n`, "utf8");
2289
+ return filePath;
2290
+ }
2291
+ /** Find the single backend app, if this repository declares one. */
2292
+ function findBackendApp(manifest) {
2293
+ for (const [name, app] of Object.entries(manifest.apps)) if (app.type === "backend") return {
2294
+ name,
2295
+ app
2296
+ };
2297
+ }
2298
+ /** Apps that produce build output, in the order they should be built. */
2299
+ function buildableApps(manifest) {
2300
+ const entries = Object.entries(manifest.apps).map(([name, app]) => ({
2301
+ name,
2302
+ app
2303
+ }));
2304
+ const rank = (app) => {
2305
+ if (app.type === "backend") return 0;
2306
+ if (app.type === "admin") return 1;
2307
+ if (app.type === "static") return 2;
2308
+ return 3;
2309
+ };
2310
+ return entries.filter(({ app }) => app.type !== "mobile").sort((a, b) => rank(a.app) - rank(b.app));
2311
+ }
2312
+ /**
2313
+ * Decide whether a project can run on the managed runtime, and say why not.
2314
+ *
2315
+ * "Not eligible" is never a dead end — it selects the custom-runtime path, which
2316
+ * still deploys. The reasons exist so the answer is actionable rather than a
2317
+ * verdict.
2318
+ */
2319
+ function assessManagedCompatibility(manifest) {
2320
+ const reasons = [];
2321
+ const backend = findBackendApp(manifest);
2322
+ if (!backend) {
2323
+ const custom = Object.entries(manifest.apps).find(([, app]) => app.type === "custom");
2324
+ if (custom) reasons.push(`App "${custom[0]}" is a custom container. The managed runtime runs the platform image with your bundle, so a project that builds its own image uses the custom runtime instead.`);
2325
+ else reasons.push("No backend app is declared in this repository. Only the repository that declares the backend selects the runtime.");
2326
+ }
2327
+ for (const [name, app] of Object.entries(manifest.apps)) if (app.type === "custom") reasons.push(`App "${name}" is a custom container image.`);
2328
+ return {
2329
+ eligible: reasons.length === 0 && Boolean(backend),
2330
+ reasons
2331
+ };
2332
+ }
2333
+ /** Resolve a backend app's directories against the conventions it omits. */
2334
+ function resolveBackendPaths(app) {
2335
+ return {
2336
+ config: app.config ?? "config",
2337
+ functions: app.functions ?? "backend/functions",
2338
+ crons: app.crons ?? "backend/crons",
2339
+ schema: app.schema ?? "backend/src/schema.generated.ts",
2340
+ usersCollection: app.usersCollection ?? "collections/users",
2341
+ mode: app.mode ?? "cms"
2342
+ };
2343
+ }
2344
+ //#endregion
1797
2345
  //#region src/commands/dev.ts
1798
2346
  /**
1799
2347
  * CLI command: rebase dev
@@ -1811,6 +2359,66 @@ ${chalk.green.bold("Examples")}
1811
2359
  * Each project gets a deterministic default port derived from the project
1812
2360
  * root path, so multiple Rebase instances never collide.
1813
2361
  */
2362
+ /**
2363
+ * Quote a path for the shell `execa` runs the backend through.
2364
+ *
2365
+ * The dev runtime's path is absolute and therefore contains whatever the
2366
+ * developer's directories are called. Double quotes do not neutralize `$`,
2367
+ * backticks or backslashes in a POSIX shell, so a checkout under a directory
2368
+ * named `$(...)` would execute it. Single quotes disable all expansion; on
2369
+ * Windows, `cmd.exe` performs no such expansion and wants double quotes.
2370
+ */
2371
+ function quoteForShell(value) {
2372
+ if (process.platform === "win32") return `"${value.replace(/"/g, "\\\"")}"`;
2373
+ return `'${value.replace(/'/g, "'\\''")}'`;
2374
+ }
2375
+ /**
2376
+ * Locate the dev runtime shim shipped with the CLI.
2377
+ *
2378
+ * Published under `runtime/` in the package rather than compiled into `dist/`,
2379
+ * because tsx executes it as a file and it must exist on disk at a stable path.
2380
+ */
2381
+ function resolveDevRuntimeEntry() {
2382
+ let dir = path.dirname(fileURLToPath(import.meta.url));
2383
+ for (let i = 0; i < 5; i++) {
2384
+ const candidate = path.join(dir, "runtime", "dev-server.mjs");
2385
+ if (fs.existsSync(candidate)) return candidate;
2386
+ const parent = path.dirname(dir);
2387
+ if (parent === dir) break;
2388
+ dir = parent;
2389
+ }
2390
+ throw new Error("Could not find the Rebase dev runtime (runtime/dev-server.mjs). Reinstall @rebasepro/cli, or add a backend/src/index.ts to run your own entrypoint.");
2391
+ }
2392
+ /**
2393
+ * Tell the dev runtime where this project keeps its parts.
2394
+ *
2395
+ * Read from `rebase.json` when there is one, so a project that moved its config
2396
+ * directory is honoured; otherwise the conventional layout.
2397
+ */
2398
+ function devRuntimeEnv(projectRoot) {
2399
+ const result = {
2400
+ REBASE_DEV_PROJECT_ROOT: projectRoot,
2401
+ REBASE_DEV_CONFIG: "config",
2402
+ REBASE_DEV_FUNCTIONS: "backend/functions",
2403
+ REBASE_DEV_CRONS: "backend/crons",
2404
+ REBASE_DEV_SCHEMA: "backend/src/schema.generated.ts",
2405
+ REBASE_DEV_MODE: "cms"
2406
+ };
2407
+ try {
2408
+ const backend = findBackendApp(loadManifest(projectRoot).manifest);
2409
+ if (backend) {
2410
+ const paths = resolveBackendPaths(backend.app);
2411
+ result.REBASE_DEV_CONFIG = paths.config;
2412
+ result.REBASE_DEV_FUNCTIONS = paths.functions;
2413
+ result.REBASE_DEV_CRONS = paths.crons;
2414
+ result.REBASE_DEV_SCHEMA = paths.schema;
2415
+ result.REBASE_DEV_MODE = paths.mode;
2416
+ result.REBASE_DEV_APP = backend.name;
2417
+ }
2418
+ } catch {}
2419
+ if (!fs.existsSync(path.join(projectRoot, result.REBASE_DEV_CONFIG))) result.REBASE_DEV_MODE = "baas";
2420
+ return result;
2421
+ }
1814
2422
  /** Well-known filename the backend writes its actual port to. */
1815
2423
  var DEV_PORT_FILENAME = ".rebase-dev-port";
1816
2424
  /**
@@ -2061,11 +2669,15 @@ async function devCommand(rawArgs) {
2061
2669
  });
2062
2670
  }
2063
2671
  }
2672
+ const ejectedEntry = path.join(backendDir, "src", "index.ts");
2673
+ const usesStockRuntime = !fs.existsSync(ejectedEntry);
2674
+ const entryTarget = usesStockRuntime ? resolveDevRuntimeEntry() : "src/index.ts";
2675
+ if (usesStockRuntime) Object.assign(env, devRuntimeEnv(projectRoot));
2064
2676
  const watchArgs = [
2065
2677
  "watch",
2066
2678
  "--conditions",
2067
2679
  "development",
2068
- "src/index.ts"
2680
+ quoteForShell(entryTarget)
2069
2681
  ];
2070
2682
  if (!shouldGenerate) {
2071
2683
  watchArgs.splice(1, 0, `--watch="${path.join("..", "config", "**", "*")}"`);
@@ -2194,17 +2806,1084 @@ ${chalk.green.bold("Description")}
2194
2806
  in your environment to enable it.
2195
2807
  `);
2196
2808
  }
2197
- //#endregion
2198
- //#region src/commands/build.ts
2199
- /**
2200
- * CLI command: rebase build
2201
- *
2202
- * Runs the build script in all workspace packages.
2203
- * Automatically detects the package manager (pnpm or npm) and
2204
- * uses the correct workspace-aware command.
2205
- */
2206
- async function buildCommand() {
2207
- const projectRoot = requireProjectRoot();
2809
+ //#endregion
2810
+ //#region src/bundle.ts
2811
+ /**
2812
+ * Building a project bundle.
2813
+ *
2814
+ * A bundle is the deployable form of a project: compiled collections, functions,
2815
+ * crons and schema, plus a generated manifest describing exactly what it needs
2816
+ * to run. It contains no Dockerfile and no repository — the runtime is supplied
2817
+ * separately, which is what allows a project to be moved onto a patched runtime
2818
+ * without being rebuilt.
2819
+ *
2820
+ * Compilation runs through a generated tsconfig rooted at the project directory,
2821
+ * so the output mirrors the source layout (`config/…`, `backend/functions/…`)
2822
+ * and every path in the manifest is predictable. Letting each workspace package
2823
+ * emit into its own `dist/` would have meant guessing at three different
2824
+ * layouts, since `rootDir` differs between the template flavours.
2825
+ */
2826
+ var DEFAULT_BUNDLE_DIR = "dist-bundle";
2827
+ /** Packages whose presence means the bundle cannot run on a stock runtime image. */
2828
+ var KNOWN_NATIVE_PACKAGES = new Set([
2829
+ "sharp",
2830
+ "canvas",
2831
+ "bcrypt",
2832
+ "argon2",
2833
+ "node-sass",
2834
+ "sqlite3",
2835
+ "better-sqlite3",
2836
+ "grpc",
2837
+ "@grpc/grpc-js-native",
2838
+ "re2",
2839
+ "sodium-native",
2840
+ "libpq",
2841
+ "pg-native"
2842
+ ]);
2843
+ /** Dependencies supplied by the runtime image itself, not by the bundle. */
2844
+ var RUNTIME_PROVIDED = new Set([
2845
+ "@rebasepro/server",
2846
+ "@rebasepro/types",
2847
+ "@rebasepro/client",
2848
+ "@rebasepro/common",
2849
+ "@rebasepro/utils",
2850
+ "hono",
2851
+ "@hono/node-server",
2852
+ "typescript",
2853
+ "tsx"
2854
+ ]);
2855
+ function log(options, message) {
2856
+ (options.log ?? ((m) => console.log(m)))(message);
2857
+ }
2858
+ /**
2859
+ * Every `node_modules/@types` directory the project can see.
2860
+ *
2861
+ * Type roots normally resolve by walking up from the tsconfig's own directory,
2862
+ * which breaks here for two reasons: the generated config lives in `.rebase/`,
2863
+ * and a pnpm workspace puts `@types/node` inside the *package* that depends on
2864
+ * it (`config/node_modules/@types`) rather than at the project root. Listing them
2865
+ * explicitly, as absolute paths, sidesteps both.
2866
+ */
2867
+ function discoverTypeRoots(projectRoot) {
2868
+ const candidates = [];
2869
+ for (const relative of [
2870
+ ".",
2871
+ "config",
2872
+ "backend",
2873
+ "frontend"
2874
+ ]) candidates.push(path.join(projectRoot, relative, "node_modules", "@types"));
2875
+ let dir = projectRoot;
2876
+ for (let i = 0; i < 4; i++) {
2877
+ const parent = path.dirname(dir);
2878
+ if (parent === dir) break;
2879
+ candidates.push(path.join(parent, "node_modules", "@types"));
2880
+ dir = parent;
2881
+ }
2882
+ return candidates.filter((candidate) => fs.existsSync(candidate));
2883
+ }
2884
+ /**
2885
+ * Read a tsconfig's own `compilerOptions`.
2886
+ *
2887
+ * Parsed with the project's own TypeScript, because a tsconfig is not JSON: it
2888
+ * permits comments and trailing commas. Hand-rolled comment stripping gets this
2889
+ * wrong in a way that is easy to miss — a `paths` entry like
2890
+ * `"@acme/types/*": ["src/*"]` contains the character sequence that opens a
2891
+ * block comment, so a regex happily eats the rest of the file and the result
2892
+ * parses as *something*, just not the config the developer wrote.
2893
+ *
2894
+ * One level only, and only `paths` is used from it.
2895
+ */
2896
+ async function readCompilerOptions(projectRoot, file) {
2897
+ if (!fs.existsSync(file)) return void 0;
2898
+ const text = fs.readFileSync(file, "utf8");
2899
+ try {
2900
+ const { config } = createRequire(path.join(projectRoot, "package.json"))("typescript").parseConfigFileTextToJson(file, text);
2901
+ return config?.compilerOptions;
2902
+ } catch {
2903
+ try {
2904
+ return JSON.parse(text.replace(/^\s*\/\/.*$/gm, "")).compilerOptions;
2905
+ } catch {
2906
+ return;
2907
+ }
2908
+ }
2909
+ }
2910
+ /**
2911
+ * Drop path aliases that resolve outside the project.
2912
+ *
2913
+ * A monorepo commonly aliases its workspace packages to their **source**
2914
+ * (`"@acme/types": ["packages/types/src/index.ts"]`) so editors jump to real
2915
+ * files. That is right for developing the monorepo and wrong for building a
2916
+ * bundle: it drags foreign `.ts` files into the program, none of which are under
2917
+ * the project's `rootDir`, and the compile fails on files the developer never
2918
+ * asked to build.
2919
+ *
2920
+ * A bundle is built against *installed packages*. Aliases pointing inside the
2921
+ * project are kept, because those are the project's own code.
2922
+ */
2923
+ function filterProjectPaths(baseDir, projectRoot, paths, baseUrl) {
2924
+ const kept = {};
2925
+ const dropped = [];
2926
+ for (const [alias, targets] of Object.entries(paths)) {
2927
+ if (!Array.isArray(targets)) continue;
2928
+ const resolved = targets.map((target) => path.resolve(baseUrl, target));
2929
+ if (resolved.every((target) => {
2930
+ const relative = path.relative(projectRoot, target);
2931
+ return relative === "" || !relative.startsWith("..") && !path.isAbsolute(relative);
2932
+ })) kept[alias] = resolved.map((target) => {
2933
+ return path.relative(baseDir, target).split(path.sep).join("/");
2934
+ });
2935
+ else dropped.push(alias);
2936
+ }
2937
+ return {
2938
+ kept,
2939
+ dropped
2940
+ };
2941
+ }
2942
+ /**
2943
+ * Compose the tsconfig used to compile the bundle.
2944
+ *
2945
+ * Extends the config package's own tsconfig when there is one, so the project's
2946
+ * choices about target, JSX and strictness are respected. It has to be `extends`
2947
+ * rather than a copy of `compilerOptions`: TypeScript resolves relative paths
2948
+ * against the file they were written in, so copying a value like
2949
+ * `baseUrl: "../../"` into a config in a different directory silently repoints
2950
+ * it at the wrong place.
2951
+ */
2952
+ async function writeBundleTsconfig(projectRoot, outDir, includes, skipTypeCheck) {
2953
+ const tsconfigDir = path.join(projectRoot, ".rebase");
2954
+ const fromTsconfig = (target) => {
2955
+ return path.relative(tsconfigDir, path.resolve(projectRoot, target)).split(path.sep).join("/");
2956
+ };
2957
+ const configTsconfigPath = path.join(projectRoot, "config", "tsconfig.json");
2958
+ const extendsFrom = fs.existsSync(configTsconfigPath) ? fromTsconfig(path.join("config", "tsconfig.json")) : void 0;
2959
+ let pathOverrides = {};
2960
+ const baseOptions = await readCompilerOptions(projectRoot, configTsconfigPath);
2961
+ if (baseOptions?.paths && typeof baseOptions.paths === "object") {
2962
+ const baseDir = path.dirname(configTsconfigPath);
2963
+ const baseUrl = path.resolve(baseDir, typeof baseOptions.baseUrl === "string" ? baseOptions.baseUrl : ".");
2964
+ const { kept, dropped } = filterProjectPaths(tsconfigDir, projectRoot, baseOptions.paths, baseUrl);
2965
+ pathOverrides = {
2966
+ baseUrl: fromTsconfig("."),
2967
+ paths: kept
2968
+ };
2969
+ if (dropped.length > 0) console.log(chalk.dim(` ignoring ${dropped.length} path alias(es) pointing outside the project (${dropped.join(", ")}) — resolving those from node_modules instead`));
2970
+ }
2971
+ const compilerOptions = {
2972
+ target: "ES2022",
2973
+ module: "ESNext",
2974
+ moduleResolution: "bundler",
2975
+ lib: ["ES2022"],
2976
+ jsx: "react-jsx",
2977
+ allowSyntheticDefaultImports: true,
2978
+ esModuleInterop: true,
2979
+ resolveJsonModule: true,
2980
+ forceConsistentCasingInFileNames: true,
2981
+ rootDir: fromTsconfig("."),
2982
+ outDir: fromTsconfig(path.relative(projectRoot, outDir) || "."),
2983
+ typeRoots: discoverTypeRoots(projectRoot),
2984
+ ...pathOverrides,
2985
+ declaration: false,
2986
+ declarationMap: false,
2987
+ sourceMap: true,
2988
+ noEmit: false,
2989
+ skipLibCheck: true,
2990
+ allowJs: true,
2991
+ ...skipTypeCheck ? { noCheck: true } : {}
2992
+ };
2993
+ const tsconfig = {
2994
+ ...extendsFrom ? { extends: extendsFrom } : {},
2995
+ compilerOptions,
2996
+ include: includes.map(fromTsconfig),
2997
+ exclude: [
2998
+ "node_modules",
2999
+ "**/*.test.ts",
3000
+ "**/*.spec.ts",
3001
+ "**/dist/**",
3002
+ DEFAULT_BUNDLE_DIR
3003
+ ].map((pattern) => pattern.startsWith("**") ? pattern : fromTsconfig(pattern))
3004
+ };
3005
+ fs.mkdirSync(tsconfigDir, { recursive: true });
3006
+ const tsconfigPath = path.join(tsconfigDir, "tsconfig.bundle.json");
3007
+ fs.writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, 2), "utf8");
3008
+ return tsconfigPath;
3009
+ }
3010
+ /**
3011
+ * Whether the compiled config package exports a `storageAuthorize` hook.
3012
+ *
3013
+ * Recorded in the manifest so a host can refuse a deploy that would enable file
3014
+ * storage with no access model, rather than let the runtime's boot guard turn it
3015
+ * into a crash loop the developer cannot read.
3016
+ *
3017
+ * Read from the *compiled* index, deliberately: that is the exact module the
3018
+ * runtime imports and reads the export off, so this cannot disagree with what
3019
+ * actually happens at boot. It is a textual check rather than an import because
3020
+ * a freshly built bundle cannot resolve its own dependencies until it is
3021
+ * deployed — the same reason schema hashing reads source.
3022
+ *
3023
+ * Errs toward `false`: a missed detection costs a deploy rejection whose message
3024
+ * says exactly how to proceed, while a false positive would hand back the crash
3025
+ * loop this exists to prevent.
3026
+ */
3027
+ function detectStorageAuthorize(compiledConfigDir) {
3028
+ const indexPath = [
3029
+ ".js",
3030
+ ".mjs",
3031
+ ".ts"
3032
+ ].map((ext) => path.join(compiledConfigDir, `index${ext}`)).find((candidate) => fs.existsSync(candidate));
3033
+ if (!indexPath) return false;
3034
+ let source;
3035
+ try {
3036
+ source = fs.readFileSync(indexPath, "utf8");
3037
+ } catch {
3038
+ return false;
3039
+ }
3040
+ if (/\bexport\s+(?:async\s+)?(?:const|let|var|function)\s+storageAuthorize\b/.test(source)) return true;
3041
+ for (const clause of source.matchAll(/\bexport\s*\{([^}]*)\}/g)) if (clause[1].split(",").map((entry) => {
3042
+ const parts = entry.split(/\bas\b/);
3043
+ return parts[parts.length - 1].trim();
3044
+ }).includes("storageAuthorize")) return true;
3045
+ return false;
3046
+ }
3047
+ /**
3048
+ * Detect native code in the dependency closure.
3049
+ *
3050
+ * Walks declared runtime dependencies breadth-first through `node_modules`,
3051
+ * flagging anything with a `binding.gyp`, a prebuilt `.node` binary, or an
3052
+ * install script that builds one. The managed runtime cannot run these: a
3053
+ * binary compiled for one image will not load in another, and finding that out
3054
+ * at deploy time is far better than in a crash loop.
3055
+ *
3056
+ * The walk is bounded. A dependency graph can be enormous, and this is a
3057
+ * heuristic gate whose false negatives are caught at deploy time anyway.
3058
+ */
3059
+ function detectNativeDependencies(projectRoot, declared, limit = 2e3) {
3060
+ const found = [];
3061
+ const seen = /* @__PURE__ */ new Set();
3062
+ const queue = Object.keys(declared);
3063
+ let visited = 0;
3064
+ const searchRoots = [
3065
+ path.join(projectRoot, "node_modules"),
3066
+ path.join(projectRoot, "backend", "node_modules"),
3067
+ path.join(projectRoot, "config", "node_modules")
3068
+ ].filter((dir) => fs.existsSync(dir));
3069
+ while (queue.length > 0 && visited < limit) {
3070
+ const name = queue.shift();
3071
+ if (seen.has(name)) continue;
3072
+ seen.add(name);
3073
+ visited++;
3074
+ if (KNOWN_NATIVE_PACKAGES.has(name)) {
3075
+ found.push({
3076
+ name,
3077
+ reason: "known native module"
3078
+ });
3079
+ continue;
3080
+ }
3081
+ const packageDir = searchRoots.map((root) => path.join(root, ...name.split("/"))).find((dir) => fs.existsSync(path.join(dir, "package.json")));
3082
+ if (!packageDir) continue;
3083
+ let pkg;
3084
+ try {
3085
+ pkg = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf8"));
3086
+ } catch {
3087
+ continue;
3088
+ }
3089
+ if (pkg.gypfile || fs.existsSync(path.join(packageDir, "binding.gyp"))) {
3090
+ found.push({
3091
+ name,
3092
+ reason: "builds a native addon (binding.gyp)"
3093
+ });
3094
+ continue;
3095
+ }
3096
+ const install = `${pkg.scripts?.install ?? ""} ${pkg.scripts?.preinstall ?? ""} ${pkg.scripts?.postinstall ?? ""}`;
3097
+ if (/node-gyp|prebuild|node-pre-gyp|cmake-js/.test(install)) {
3098
+ found.push({
3099
+ name,
3100
+ reason: "install script compiles native code"
3101
+ });
3102
+ continue;
3103
+ }
3104
+ if (hasNodeBinary(packageDir)) {
3105
+ found.push({
3106
+ name,
3107
+ reason: "ships a prebuilt .node binary"
3108
+ });
3109
+ continue;
3110
+ }
3111
+ for (const dep of Object.keys(pkg.dependencies ?? {})) if (!seen.has(dep)) queue.push(dep);
3112
+ }
3113
+ return found;
3114
+ }
3115
+ /** Shallow scan for `.node` binaries — deep enough for the usual `build/Release`. */
3116
+ function hasNodeBinary(dir, depth = 0) {
3117
+ if (depth > 3) return false;
3118
+ let entries;
3119
+ try {
3120
+ entries = fs.readdirSync(dir, { withFileTypes: true });
3121
+ } catch {
3122
+ return false;
3123
+ }
3124
+ for (const entry of entries) {
3125
+ if (entry.isFile() && entry.name.endsWith(".node")) return true;
3126
+ if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== ".bin") {
3127
+ if (hasNodeBinary(path.join(dir, entry.name), depth + 1)) return true;
3128
+ }
3129
+ }
3130
+ return false;
3131
+ }
3132
+ /**
3133
+ * Whether a dependency name resolves to a package *inside this repository* — a
3134
+ * workspace package rather than a registry one.
3135
+ *
3136
+ * The bundle's declared deps are installed with `npm install` from the public
3137
+ * registry beside the bundle at boot. A workspace package is not there, so
3138
+ * declaring it guarantees a boot-time install failure. The most common case is
3139
+ * the standard `config` package: the backend depends on it by name, but it is
3140
+ * *carried in the bundle* (as `entry.config`), so it must never also be an npm
3141
+ * dependency. Projects often express this as a `workspace:` range — caught
3142
+ * separately — but a plain `"*"` against a workspace symlink is just as common
3143
+ * and looks like a registry range, so the symlink is what actually settles it.
3144
+ *
3145
+ * Detection: the installed `node_modules/<name>` is a symlink whose real path is
3146
+ * inside the project and not within a pnpm virtual store (`.pnpm`). That is
3147
+ * exactly a workspace link and nothing else.
3148
+ */
3149
+ function resolvesToWorkspacePackage(projectRoot, name) {
3150
+ let realRoot;
3151
+ try {
3152
+ realRoot = fs.realpathSync(projectRoot);
3153
+ } catch {
3154
+ realRoot = projectRoot;
3155
+ }
3156
+ for (const base of [
3157
+ projectRoot,
3158
+ path.join(projectRoot, "backend"),
3159
+ path.join(projectRoot, "config")
3160
+ ]) {
3161
+ const link = path.join(base, "node_modules", name);
3162
+ try {
3163
+ if (!fs.lstatSync(link).isSymbolicLink()) continue;
3164
+ const real = fs.realpathSync(link);
3165
+ const insideRepo = real.startsWith(realRoot + path.sep);
3166
+ const inStore = real.includes(`${path.sep}.pnpm${path.sep}`) || real.includes(`${path.sep}node_modules${path.sep}`);
3167
+ if (insideRepo && !inStore) return true;
3168
+ } catch {}
3169
+ }
3170
+ return false;
3171
+ }
3172
+ /**
3173
+ * Collect the runtime dependencies a bundle needs installed beside it.
3174
+ *
3175
+ * Packages the runtime image already provides are excluded — reinstalling a
3176
+ * second copy of the server next to the one running the process is at best
3177
+ * wasted space and at worst a version conflict. Workspace packages are excluded
3178
+ * too: they are not on the registry the runtime installs from, and the project's
3179
+ * own config package already travels inside the bundle.
3180
+ */
3181
+ function collectDeclaredDependencies(projectRoot) {
3182
+ const declared = {};
3183
+ for (const relative of [
3184
+ "backend/package.json",
3185
+ "config/package.json",
3186
+ "package.json"
3187
+ ]) {
3188
+ const file = path.join(projectRoot, relative);
3189
+ if (!fs.existsSync(file)) continue;
3190
+ try {
3191
+ const pkg = JSON.parse(fs.readFileSync(file, "utf8"));
3192
+ for (const [name, version] of Object.entries(pkg.dependencies ?? {})) {
3193
+ if (RUNTIME_PROVIDED.has(name)) continue;
3194
+ if (typeof version === "string" && version.startsWith("workspace:")) continue;
3195
+ if (resolvesToWorkspacePackage(projectRoot, name)) continue;
3196
+ declared[name] = version;
3197
+ }
3198
+ } catch {}
3199
+ }
3200
+ return declared;
3201
+ }
3202
+ /**
3203
+ * Rewrite relative import specifiers in emitted JavaScript so Node can resolve them.
3204
+ *
3205
+ * TypeScript deliberately does not touch specifiers: `moduleResolution: "bundler"`
3206
+ * lets a project write `from "./posts"` or `from "./collections"`, and TypeScript
3207
+ * emits them unchanged on the assumption that a bundler will finish the job.
3208
+ * Nothing bundles a Rebase bundle — the runtime imports these files directly with
3209
+ * Node's ESM loader, which requires a full path with an extension and refuses
3210
+ * directory imports outright.
3211
+ *
3212
+ * Without this, adopting the bundle would mean asking every project written in
3213
+ * the (extremely common) extensionless style to rewrite all of its imports. The
3214
+ * rewrite is mechanical and verifiable: only relative specifiers are touched, and
3215
+ * only when the target file actually exists on disk.
3216
+ */
3217
+ function normalizeEsmSpecifiers(outDir) {
3218
+ const unresolved = [];
3219
+ let rewritten = 0;
3220
+ const SPECIFIER = /(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(["'])(\.[^"']*)\2/g;
3221
+ const walk = (dir) => {
3222
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
3223
+ const full = path.join(dir, entry.name);
3224
+ if (entry.isDirectory()) {
3225
+ if (entry.name === "node_modules") continue;
3226
+ walk(full);
3227
+ } else if (entry.isFile() && entry.name.endsWith(".js")) rewriteFile(full);
3228
+ }
3229
+ };
3230
+ const rewriteFile = (file) => {
3231
+ const original = fs.readFileSync(file, "utf8");
3232
+ const dir = path.dirname(file);
3233
+ const updated = original.replace(SPECIFIER, (match, prefix, quote, specifier) => {
3234
+ if (/\.(js|mjs|cjs|json|node)$/.test(specifier)) return match;
3235
+ const target = path.resolve(dir, specifier);
3236
+ if (fs.existsSync(`${target}.js`)) {
3237
+ rewritten++;
3238
+ return `${prefix}${quote}${specifier}.js${quote}`;
3239
+ }
3240
+ if (fs.existsSync(path.join(target, "index.js"))) {
3241
+ rewritten++;
3242
+ return `${prefix}${quote}${specifier}${specifier.endsWith("/") ? "index.js" : "/index.js"}${quote}`;
3243
+ }
3244
+ if (specifier.endsWith(".ts") && fs.existsSync(`${target.slice(0, -3)}.js`)) {
3245
+ rewritten++;
3246
+ return `${prefix}${quote}${specifier.slice(0, -3)}.js${quote}`;
3247
+ }
3248
+ unresolved.push(`${path.basename(file)} → ${specifier}`);
3249
+ return match;
3250
+ });
3251
+ if (updated !== original) fs.writeFileSync(file, updated, "utf8");
3252
+ };
3253
+ if (fs.existsSync(outDir)) walk(outDir);
3254
+ return {
3255
+ rewritten,
3256
+ unresolved
3257
+ };
3258
+ }
3259
+ /**
3260
+ * Remove a previous build so stale output cannot masquerade as current.
3261
+ *
3262
+ * The containment check matters because this is a recursive force-delete of a
3263
+ * path that came from a command-line flag: `rebase build --out ../..` would
3264
+ * otherwise erase the parent of the project. The manifest's own paths are
3265
+ * checked the same way; a flag deserves no less.
3266
+ */
3267
+ function cleanOutDir(projectRoot, outDir) {
3268
+ const relative = path.relative(projectRoot, outDir);
3269
+ if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Refusing to build into "${outDir}": the output directory must be inside the project.`);
3270
+ if (fs.existsSync(outDir)) fs.rmSync(outDir, {
3271
+ recursive: true,
3272
+ force: true
3273
+ });
3274
+ fs.mkdirSync(outDir, { recursive: true });
3275
+ }
3276
+ /**
3277
+ * Regenerate the Drizzle schema from the collections.
3278
+ *
3279
+ * Delegated to the database driver's own CLI — the same code `rebase schema
3280
+ * generate` runs — so there is one implementation of what a schema is. When no
3281
+ * driver is resolvable the build continues with a warning rather than failing:
3282
+ * a `baas` project has no schema to generate, and a project mid-install should
3283
+ * get a clear message rather than a hard stop.
3284
+ */
3285
+ async function regenerateSchema(projectRoot, configDir, options) {
3286
+ const backendDir = path.join(projectRoot, "backend");
3287
+ if (!fs.existsSync(backendDir)) return;
3288
+ const plugin = getActiveBackendPlugin(backendDir);
3289
+ const script = plugin ? resolvePluginCliScript(backendDir, plugin) : null;
3290
+ if (!script) {
3291
+ log(options, chalk.dim(" (no database driver found — skipping schema generation)"));
3292
+ return;
3293
+ }
3294
+ const runner = script.endsWith(".ts") ? resolveTsx(projectRoot) : "node";
3295
+ if (!runner) {
3296
+ log(options, chalk.dim(" (tsx not installed — skipping schema generation)"));
3297
+ return;
3298
+ }
3299
+ const collectionsPath = path.join("..", configDir, "collections");
3300
+ try {
3301
+ await execa(runner, [
3302
+ script,
3303
+ "schema",
3304
+ "generate",
3305
+ "--collections",
3306
+ collectionsPath
3307
+ ], {
3308
+ cwd: backendDir,
3309
+ stdio: "pipe"
3310
+ });
3311
+ log(options, chalk.dim(" regenerated database schema from collections"));
3312
+ } catch (err) {
3313
+ const detail = err instanceof Error ? err.message : String(err);
3314
+ throw new Error(`Schema generation failed, so the bundle was not written.\n${detail}\nRun \`rebase schema generate\` to see the full output, or pass --skip-schema if the committed schema is deliberately hand-maintained.`);
3315
+ }
3316
+ }
3317
+ /**
3318
+ * A hand-written server entrypoint that a bundle does not use.
3319
+ *
3320
+ * `rebase dev` runs `backend/src/index.ts` whenever a project has one, so for
3321
+ * the whole of local development that file *is* the server and every route
3322
+ * written in it works. A bundle has no entrypoint of its own: the runtime boots
3323
+ * the bundle and mounts what the manifest points at — the config package,
3324
+ * functions, crons and the schema. The file is not compiled, not shipped, and
3325
+ * never imported.
3326
+ *
3327
+ * Nothing said so. A project with custom routes in its entrypoint built clean,
3328
+ * deployed green, and answered 404 on every one of them, with the file still
3329
+ * sitting in the repository looking exactly like the server.
3330
+ *
3331
+ * A project that means to keep its own entrypoint declares the app as
3332
+ * `"type": "custom"`, which builds the repository's Dockerfile instead — which
3333
+ * is what {@link synthesizeManifest} already infers for a manifest-less repo
3334
+ * carrying one. The warning names that route rather than implying the file is
3335
+ * a mistake.
3336
+ */
3337
+ function findUnusedServerEntry(projectRoot, functionsDir) {
3338
+ const found = [path.join("backend", "src", "index.ts"), path.join(path.dirname(functionsDir), "src", "index.ts")].find((candidate) => fs.existsSync(path.join(projectRoot, candidate)));
3339
+ return found ? found.split(path.sep).join("/") : void 0;
3340
+ }
3341
+ /**
3342
+ * Compile and assemble a bundle.
3343
+ */
3344
+ async function buildBundle(options) {
3345
+ const { projectRoot, app, appName } = options;
3346
+ const paths = resolveBackendPaths(app);
3347
+ const outDir = path.resolve(projectRoot, options.outDir ?? "dist-bundle");
3348
+ const includes = [];
3349
+ const addIfExists = (relative, pattern) => {
3350
+ if (fs.existsSync(path.join(projectRoot, relative))) includes.push(pattern);
3351
+ };
3352
+ if (paths.mode === "cms") addIfExists(paths.config, `${paths.config}/**/*.ts`);
3353
+ addIfExists(paths.functions, `${paths.functions}/**/*.ts`);
3354
+ addIfExists(paths.crons, `${paths.crons}/**/*.ts`);
3355
+ if (fs.existsSync(path.join(projectRoot, paths.schema))) includes.push(paths.schema);
3356
+ if (includes.length === 0) throw new Error(`Nothing to build for app "${appName}". Expected a config directory at "${paths.config}" or functions at "${paths.functions}".`);
3357
+ if (paths.mode === "cms" && options.skipSchema !== true) await regenerateSchema(projectRoot, paths.config, options);
3358
+ const unusedEntry = findUnusedServerEntry(projectRoot, paths.functions);
3359
+ if (unusedEntry) {
3360
+ const parts = [
3361
+ ...paths.mode === "cms" ? [`${paths.config}/`] : [],
3362
+ `${paths.functions}/`,
3363
+ "the schema"
3364
+ ];
3365
+ const compiled = `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
3366
+ console.log(chalk.yellow(` ⚠ ${unusedEntry} is not the bundle's entry point — it is not compiled or shipped.`));
3367
+ console.log(chalk.dim(` The runtime boots the bundle itself and mounts ${compiled}.`));
3368
+ console.log(chalk.dim(` Routes defined there will not exist once deployed: move them to ${paths.functions}/,`));
3369
+ console.log(chalk.dim(` or declare this app as "type": "custom" in rebase.json to keep your own entrypoint.`));
3370
+ }
3371
+ log(options, chalk.dim(` compiling ${includes.length} source group(s) → ${path.relative(projectRoot, outDir)}/`));
3372
+ cleanOutDir(projectRoot, outDir);
3373
+ const tsconfigPath = await writeBundleTsconfig(projectRoot, outDir, includes, options.skipTypeCheck === true);
3374
+ const tsc = resolveLocalBin(projectRoot, "tsc");
3375
+ if (!tsc) throw new Error("TypeScript is not installed in this project. Run your package manager's install first.");
3376
+ try {
3377
+ await execa(tsc, ["-p", tsconfigPath], {
3378
+ cwd: projectRoot,
3379
+ stdio: "inherit"
3380
+ });
3381
+ } catch {
3382
+ throw new Error("TypeScript compilation failed — the bundle was not written.");
3383
+ }
3384
+ const normalized = normalizeEsmSpecifiers(outDir);
3385
+ if (normalized.rewritten > 0) log(options, chalk.dim(` resolved ${normalized.rewritten} relative import(s) for Node ESM`));
3386
+ if (normalized.unresolved.length > 0) {
3387
+ console.log(chalk.yellow(` ⚠ ${normalized.unresolved.length} import(s) could not be resolved to a file:`));
3388
+ for (const item of normalized.unresolved.slice(0, 5)) console.log(chalk.dim(` ${item}`));
3389
+ if (normalized.unresolved.length > 5) console.log(chalk.dim(` … and ${normalized.unresolved.length - 5} more`));
3390
+ }
3391
+ const compiledConfigDir = path.join(outDir, paths.config);
3392
+ const compiledCollectionsDir = path.join(compiledConfigDir, "collections");
3393
+ let collections = [];
3394
+ if (paths.mode === "cms") {
3395
+ collections = await loadSourceCollections(path.join(projectRoot, paths.config, "collections"));
3396
+ if (collections.length === 0) throw new Error(`No collections were found in ${path.join(paths.config, "collections")}. A cms-mode project must define at least one collection.`);
3397
+ if (!fs.existsSync(compiledCollectionsDir)) throw new Error(`Compilation produced no collections directory at ${path.relative(projectRoot, compiledCollectionsDir)}.`);
3398
+ }
3399
+ const declared = collectDeclaredDependencies(projectRoot);
3400
+ const nativeModules = detectNativeDependencies(projectRoot, declared);
3401
+ const declaresStorageAuthorize = detectStorageAuthorize(path.join(outDir, paths.config));
3402
+ const schemaOut = paths.schema.replace(/\.ts$/, ".js");
3403
+ const relative = (target) => fs.existsSync(path.join(outDir, target)) ? target : void 0;
3404
+ const manifest = {
3405
+ bundleFormat: BUNDLE_FORMAT_VERSION,
3406
+ runtime: {
3407
+ range: options.runtimeRange,
3408
+ builtAgainst: resolveServerVersion(projectRoot),
3409
+ contract: RUNTIME_CONTRACT_VERSION
3410
+ },
3411
+ schemaVersion: paths.mode === "baas" ? "" : computeSchemaVersion(collections),
3412
+ app: appName,
3413
+ mode: paths.mode,
3414
+ entry: {
3415
+ config: paths.mode === "cms" ? relative(paths.config) : void 0,
3416
+ collections: paths.mode === "cms" ? relative(path.join(paths.config, "collections")) : void 0,
3417
+ functions: relative(paths.functions),
3418
+ crons: relative(paths.crons),
3419
+ schema: relative(schemaOut),
3420
+ usersCollection: paths.mode === "cms" ? relative(path.join(paths.config, `${paths.usersCollection}.js`)) : void 0
3421
+ },
3422
+ collections: collections.map((collection) => collection.slug).filter((slug) => Boolean(slug)).sort(),
3423
+ hooks: {
3424
+ native: nativeModules.length > 0,
3425
+ nativeModules: nativeModules.length > 0 ? nativeModules : void 0
3426
+ },
3427
+ storage: { authorize: declaresStorageAuthorize },
3428
+ deps: { declared },
3429
+ build: {
3430
+ cli: resolveCliVersion(),
3431
+ node: process.versions.node.split(".")[0],
3432
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
3433
+ }
3434
+ };
3435
+ fs.writeFileSync(path.join(outDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
3436
+ fs.writeFileSync(path.join(outDir, "package.json"), `${JSON.stringify({
3437
+ name: "rebase-bundle",
3438
+ private: true,
3439
+ type: "module",
3440
+ dependencies: declared
3441
+ }, null, 2)}\n`, "utf8");
3442
+ return {
3443
+ outDir,
3444
+ manifest,
3445
+ collectionCount: collections.length
3446
+ };
3447
+ }
3448
+ /**
3449
+ * Package a built static app (a `static` or bundled-`admin` app) into a bundle.
3450
+ *
3451
+ * A static bundle is the counterpart to a backend bundle: the same shape, the
3452
+ * same runtime image runs it, but its manifest says `mode: "static"` and it
3453
+ * carries only the built assets under `static/`. That is what lets a frontend or
3454
+ * admin app be its own deployable, scalable unit rather than something baked into
3455
+ * the backend container.
3456
+ *
3457
+ * `assetsDir` is the app's built output (e.g. `frontend/dist`), already produced
3458
+ * by its own build command. This copies it into the bundle and writes the
3459
+ * manifest — no compilation, no dependency closure (a static bundle installs
3460
+ * nothing at boot).
3461
+ */
3462
+ /**
3463
+ * Fold a built static app into a backend bundle, so one runtime serves both.
3464
+ *
3465
+ * ## Why this exists
3466
+ *
3467
+ * A managed tenant runs one pod, and `bootFromBundle` on the backend path already
3468
+ * knows how to serve a SPA — it looks for `entry.static` and mounts `serveSPA`
3469
+ * last, behind `REBASE_SERVE_STATIC`. What was missing was anything putting the
3470
+ * assets there.
3471
+ *
3472
+ * The consequence was not subtle. A project whose custom image served its website
3473
+ * at `/` and its API at `/api` — the shape the scaffolded template produces — lost
3474
+ * the website the moment it moved to the managed runtime: the API answered
3475
+ * perfectly and every page 404'd. Managed could not be a drop-in replacement for
3476
+ * custom while the frontend simply vanished.
3477
+ *
3478
+ * Folding restores parity with the container it replaces, which is the only
3479
+ * honest baseline. It is deliberately the FIRST implementation and not the last:
3480
+ * a static app on its own bucket behind a CDN is better for cache behaviour and
3481
+ * lets the frontend deploy independently. But that needs infrastructure that does
3482
+ * not exist yet, and "your site is gone" is not an acceptable state to leave a
3483
+ * project in while it gets built.
3484
+ *
3485
+ * The trade it makes, stated plainly: frontend and backend now deploy together
3486
+ * and the bundle carries the built assets. For a project that was shipping both
3487
+ * in one image already, that is exactly what it had.
3488
+ */
3489
+ function foldStaticIntoBundle(options) {
3490
+ const { bundleDir, assetsDir } = options;
3491
+ const manifestPath = path.join(bundleDir, "manifest.json");
3492
+ if (!fs.existsSync(manifestPath)) throw new Error(`No manifest at ${manifestPath} — build the backend bundle first.`);
3493
+ if (!fs.existsSync(assetsDir)) throw new Error(`No built assets at ${assetsDir}.`);
3494
+ const staticOut = path.join(bundleDir, "static");
3495
+ fs.rmSync(staticOut, {
3496
+ recursive: true,
3497
+ force: true
3498
+ });
3499
+ fs.mkdirSync(staticOut, { recursive: true });
3500
+ fs.cpSync(assetsDir, staticOut, { recursive: true });
3501
+ let fileCount = 0;
3502
+ const count = (dir) => {
3503
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) if (entry.isDirectory()) count(path.join(dir, entry.name));
3504
+ else fileCount++;
3505
+ };
3506
+ count(staticOut);
3507
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
3508
+ manifest.entry = {
3509
+ ...manifest.entry,
3510
+ static: "static"
3511
+ };
3512
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
3513
+ return { fileCount };
3514
+ }
3515
+ function buildStaticBundle(options) {
3516
+ const { projectRoot, appName, assetsDir, outDir, runtimeRange } = options;
3517
+ cleanOutDir(projectRoot, outDir);
3518
+ const staticOut = path.join(outDir, "static");
3519
+ fs.mkdirSync(staticOut, { recursive: true });
3520
+ fs.cpSync(assetsDir, staticOut, { recursive: true });
3521
+ let fileCount = 0;
3522
+ const count = (dir) => {
3523
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) if (entry.isDirectory()) count(path.join(dir, entry.name));
3524
+ else fileCount++;
3525
+ };
3526
+ count(staticOut);
3527
+ const manifest = {
3528
+ bundleFormat: BUNDLE_FORMAT_VERSION,
3529
+ runtime: {
3530
+ range: runtimeRange,
3531
+ builtAgainst: resolveServerVersion(projectRoot),
3532
+ contract: RUNTIME_CONTRACT_VERSION
3533
+ },
3534
+ schemaVersion: "",
3535
+ app: appName,
3536
+ mode: "static",
3537
+ entry: { static: "static" },
3538
+ hooks: { native: false },
3539
+ deps: { declared: {} },
3540
+ build: {
3541
+ cli: resolveCliVersion(),
3542
+ node: process.versions.node.split(".")[0],
3543
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
3544
+ }
3545
+ };
3546
+ fs.writeFileSync(path.join(outDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
3547
+ fs.writeFileSync(path.join(outDir, "package.json"), `${JSON.stringify({
3548
+ name: "rebase-bundle",
3549
+ private: true,
3550
+ type: "module",
3551
+ dependencies: {}
3552
+ }, null, 2)}\n`, "utf8");
3553
+ return {
3554
+ outDir,
3555
+ manifest,
3556
+ fileCount
3557
+ };
3558
+ }
3559
+ /**
3560
+ * Which files in a collections directory are collections.
3561
+ *
3562
+ * Mirrors the runtime loader's rules exactly, and must keep mirroring them: the
3563
+ * set of files counted here decides the schema version, and the runtime decides
3564
+ * what it serves the same way. A divergence would show up as a client that is
3565
+ * permanently "out of date" against a server that agrees with it.
3566
+ *
3567
+ * (`._*` guards macOS AppleDouble files, which look like sources and are not.)
3568
+ */
3569
+ function isCollectionSourceFile(name) {
3570
+ if (name.startsWith(".")) return false;
3571
+ if (name.includes(".test.") || name.includes(".spec.")) return false;
3572
+ if (name.endsWith(".d.ts")) return false;
3573
+ if (name === "index.ts" || name === "index.js") return false;
3574
+ return name.endsWith(".ts") || name.endsWith(".js");
3575
+ }
3576
+ /**
3577
+ * Load collections from **source**, for hashing and for the manifest's slug list.
3578
+ *
3579
+ * Deliberately not the compiled output. A compiled bundle imports its
3580
+ * dependencies from beside itself — that is the whole point of shipping a
3581
+ * `package.json` with it — but at build time nothing has been installed there
3582
+ * yet, and under pnpm the project's own `node_modules` lives one directory per
3583
+ * package, so the emitted files genuinely cannot resolve their imports until
3584
+ * they are deployed.
3585
+ *
3586
+ * Reading source costs nothing in fidelity: compilation erases types, it does
3587
+ * not change the values a collection module exports, so the hash is the same
3588
+ * either way.
3589
+ */
3590
+ async function loadSourceCollections(collectionsDir) {
3591
+ if (!fs.existsSync(collectionsDir)) return [];
3592
+ const { createJiti } = await import("jiti");
3593
+ const jiti = createJiti(path.join(collectionsDir, "index.ts"), {
3594
+ interopDefault: true,
3595
+ esmResolve: true
3596
+ });
3597
+ const files = fs.readdirSync(collectionsDir).filter(isCollectionSourceFile).sort();
3598
+ const collections = [];
3599
+ const failures = [];
3600
+ for (const file of files) try {
3601
+ const mod = await jiti.import(path.join(collectionsDir, file));
3602
+ const collection = mod.default ?? mod;
3603
+ if (collection && typeof collection === "object" && "slug" in collection) collections.push(collection);
3604
+ else failures.push(`${file}: no default-exported collection`);
3605
+ } catch (err) {
3606
+ failures.push(`${file}: ${err instanceof Error ? err.message : String(err)}`);
3607
+ }
3608
+ if (failures.length > 0) throw new Error(`Could not read ${failures.length} collection file(s):\n` + failures.map((f) => ` • ${f}`).join("\n"));
3609
+ return collections;
3610
+ }
3611
+ /** The `@rebasepro/server` version the project resolves — what it was built against. */
3612
+ function resolveServerVersion(projectRoot) {
3613
+ const candidates = [path.join(projectRoot, "node_modules", "@rebasepro", "server", "package.json"), path.join(projectRoot, "backend", "node_modules", "@rebasepro", "server", "package.json")];
3614
+ for (const candidate of candidates) {
3615
+ if (!fs.existsSync(candidate)) continue;
3616
+ try {
3617
+ return JSON.parse(fs.readFileSync(candidate, "utf8")).version;
3618
+ } catch {}
3619
+ }
3620
+ return "unknown";
3621
+ }
3622
+ function resolveCliVersion() {
3623
+ try {
3624
+ let dir = path.dirname(new URL(import.meta.url).pathname);
3625
+ for (let i = 0; i < 5; i++) {
3626
+ const candidate = path.join(dir, "package.json");
3627
+ if (fs.existsSync(candidate)) {
3628
+ const pkg = JSON.parse(fs.readFileSync(candidate, "utf8"));
3629
+ if (pkg.name === "@rebasepro/cli" && pkg.version) return pkg.version;
3630
+ }
3631
+ dir = path.dirname(dir);
3632
+ }
3633
+ } catch {}
3634
+ return "unknown";
3635
+ }
3636
+ //#endregion
3637
+ //#region src/fold-static.ts
3638
+ /**
3639
+ * Folding a project's frontend into its backend bundle.
3640
+ *
3641
+ * Shared by `rebase build` and `rebase cloud deploy` deliberately. It lived in
3642
+ * the build *command* first, and `deploy` rebuilds the bundle itself — so a
3643
+ * deploy silently produced a bundle without the frontend, packed 164 KB where
3644
+ * 39 MB was expected, and the site 404'd on the managed runtime exactly as if
3645
+ * folding had never been written. Two callers building the same artefact must
3646
+ * share the step that completes it.
3647
+ *
3648
+ * Why fold at all: `bootFromBundle` already serves a SPA from `entry.static`
3649
+ * behind `REBASE_SERVE_STATIC` (default on). A managed tenant runs one pod, so
3650
+ * putting the built site in the bundle gives it the shape a custom container
3651
+ * already had — site at `/`, API at `/api` — which is the only honest baseline
3652
+ * for calling the managed runtime a drop-in replacement.
3653
+ */
3654
+ /**
3655
+ * Which static app, if any, should be served by the backend.
3656
+ *
3657
+ * Exactly one `static` app is folded. With several, folding would have to choose,
3658
+ * and silently picking one of two websites is worse than doing nothing — so it
3659
+ * declines and names what it saw. Pure, so the decision is testable without a
3660
+ * filesystem.
3661
+ */
3662
+ function selectFoldableApp(manifest) {
3663
+ const statics = Object.entries(manifest.apps ?? {}).filter(([, app]) => app?.type === "static").map(([name, app]) => ({
3664
+ name,
3665
+ build: app?.build,
3666
+ output: app?.output
3667
+ }));
3668
+ if (statics.length === 0) return {};
3669
+ if (statics.length > 1) return { reason: `${statics.length} static apps (${statics.map((s) => s.name).join(", ")}) — none folded in. Pick one to serve from the backend, or host them separately.` };
3670
+ const only = statics[0];
3671
+ if (!only.output) return { reason: `"${only.name}" declares no output directory — not folded in.` };
3672
+ return { app: only };
3673
+ }
3674
+ /**
3675
+ * Build the project's frontend and fold it into the backend bundle.
3676
+ *
3677
+ * Throws rather than exiting, so the caller decides whether a missing frontend
3678
+ * should fail its command — a `build` may reasonably want to stop, and so should
3679
+ * a deploy, but that is not this function's call to make.
3680
+ */
3681
+ async function foldFrontendIntoBundle(options) {
3682
+ const { projectRoot, manifest, bundleDir, skipBuild } = options;
3683
+ const log = options.log ?? ((m) => console.log(m));
3684
+ const { app, reason } = selectFoldableApp(manifest);
3685
+ if (reason) {
3686
+ log(chalk.yellow(` ⚠ ${reason}`));
3687
+ return null;
3688
+ }
3689
+ if (!app) return null;
3690
+ if (app.build && !skipBuild) await execa(app.build, {
3691
+ cwd: projectRoot,
3692
+ stdio: "inherit",
3693
+ shell: true
3694
+ });
3695
+ const assetsDir = path.join(projectRoot, app.output);
3696
+ if (!fs.existsSync(assetsDir)) throw new Error(`"${app.name}" declared output "${app.output}" does not exist after building — the bundle would ship without a frontend.`);
3697
+ const { fileCount } = foldStaticIntoBundle({
3698
+ bundleDir,
3699
+ assetsDir
3700
+ });
3701
+ return {
3702
+ appName: app.name,
3703
+ fileCount
3704
+ };
3705
+ }
3706
+ //#endregion
3707
+ //#region src/commands/build.ts
3708
+ /**
3709
+ * CLI command: rebase build [app...]
3710
+ *
3711
+ * Builds the apps a repository declares in `rebase.json`.
3712
+ *
3713
+ * For a `backend` app this produces a **bundle** — compiled collections,
3714
+ * functions and schema plus a manifest — which is the artifact the runtime
3715
+ * loads. For `static` and bundled `admin` apps it runs the declared build
3716
+ * command and reports where the output landed.
3717
+ *
3718
+ * A project with no manifest, or one whose backend has been ejected to its own
3719
+ * entrypoint, falls back to the previous behaviour: run every workspace's own
3720
+ * `build` script. Nothing that built before stops building.
3721
+ */
3722
+ function printHelp$3() {
3723
+ console.log(`
3724
+ ${chalk.bold("rebase build")} — build the apps declared in rebase.json
3725
+
3726
+ ${chalk.bold("Usage")}
3727
+ rebase build [app...] Build the named apps (default: all)
3728
+
3729
+ ${chalk.bold("Options")}
3730
+ --out <dir> Bundle output directory (default: ${DEFAULT_BUNDLE_DIR})
3731
+ --skip-type-check Compile without type checking (faster; use for iteration only)
3732
+ --skip-schema Do not regenerate the database schema from collections
3733
+ --legacy Run every workspace's own build script instead
3734
+ -h, --help Show this help
3735
+
3736
+ ${chalk.bold("Examples")}
3737
+ rebase build Build every app in this repository
3738
+ rebase build backend Build only the backend bundle
3739
+ rebase build web Build only the "web" static app
3740
+ `.trim());
3741
+ }
3742
+ async function buildCommand(rawArgs = []) {
3743
+ const args = arg({
3744
+ "--out": String,
3745
+ "--skip-type-check": Boolean,
3746
+ "--skip-schema": Boolean,
3747
+ "--no-static": Boolean,
3748
+ "--skip-static-build": Boolean,
3749
+ "--legacy": Boolean,
3750
+ "--help": Boolean,
3751
+ "-h": "--help"
3752
+ }, {
3753
+ argv: rawArgs.slice(3),
3754
+ permissive: true
3755
+ });
3756
+ if (args["--help"]) {
3757
+ printHelp$3();
3758
+ return;
3759
+ }
3760
+ const projectRoot = requireProjectRoot();
3761
+ if (args["--legacy"]) {
3762
+ await runWorkspaceBuilds(projectRoot);
3763
+ return;
3764
+ }
3765
+ let loaded;
3766
+ try {
3767
+ loaded = loadManifest(projectRoot);
3768
+ } catch (err) {
3769
+ if (err instanceof ManifestError) {
3770
+ console.error(chalk.red(`✗ ${err.message}`));
3771
+ for (const issue of err.issues) console.error(chalk.red(` ${issue.path ? `${issue.path}: ` : ""}${issue.message}`));
3772
+ process.exit(1);
3773
+ }
3774
+ throw err;
3775
+ }
3776
+ const { manifest, source } = loaded;
3777
+ const requested = args._.filter((a) => !a.startsWith("-"));
3778
+ let targets = buildableApps(manifest);
3779
+ if (requested.length > 0) {
3780
+ const known = new Set(targets.map((t) => t.name));
3781
+ const unknown = requested.filter((name) => !known.has(name));
3782
+ if (unknown.length > 0) {
3783
+ console.error(chalk.red(`✗ Unknown app(s): ${unknown.join(", ")}`));
3784
+ console.error(chalk.dim(` This repository declares: ${targets.map((t) => t.name).join(", ") || "(none)"}`));
3785
+ process.exit(1);
3786
+ }
3787
+ targets = targets.filter((t) => requested.includes(t.name));
3788
+ }
3789
+ if (targets.length === 0) {
3790
+ console.log(chalk.yellow("No buildable apps declared. Nothing to do."));
3791
+ return;
3792
+ }
3793
+ if (!findBackendApp(manifest) && source === "synthesized") {
3794
+ console.log(chalk.dim("No rebase.json found — building workspace packages.\n"));
3795
+ await runWorkspaceBuilds(projectRoot);
3796
+ return;
3797
+ }
3798
+ console.log(`${chalk.bold("Rebase")} — building ${targets.length} app(s)\n`);
3799
+ for (const { name, app } of targets) {
3800
+ console.log(chalk.cyan(`▸ ${name}`) + chalk.dim(` (${app.type})`));
3801
+ if (app.type === "backend") {
3802
+ const result = await buildBundle({
3803
+ projectRoot,
3804
+ appName: name,
3805
+ app,
3806
+ outDir: args["--out"],
3807
+ runtimeRange: manifest.runtime,
3808
+ skipTypeCheck: args["--skip-type-check"],
3809
+ skipSchema: args["--skip-schema"]
3810
+ });
3811
+ const rel = path.relative(projectRoot, result.outDir);
3812
+ console.log(chalk.green(` ✓ bundle → ${rel}/`));
3813
+ console.log(chalk.dim(` ${result.collectionCount} collection(s), schema ${result.manifest.schemaVersion}`));
3814
+ if (result.manifest.hooks.native) {
3815
+ const names = (result.manifest.hooks.nativeModules ?? []).map((m) => m.name).join(", ");
3816
+ console.log(chalk.yellow(` ⚠ native dependencies detected: ${names}`));
3817
+ console.log(chalk.dim(" These cannot run on the managed runtime. See `rebase doctor`."));
3818
+ }
3819
+ if (!args["--no-static"]) {
3820
+ const folded = await foldFrontendIntoBundle({
3821
+ projectRoot,
3822
+ manifest,
3823
+ bundleDir: result.outDir,
3824
+ skipBuild: args["--skip-static-build"] === true,
3825
+ log: (m) => console.log(m)
3826
+ }).catch((err) => {
3827
+ console.error(chalk.red(` ✗ ${err instanceof Error ? err.message : String(err)}`));
3828
+ process.exit(1);
3829
+ });
3830
+ if (folded) console.log(chalk.green(` ✓ ${folded.appName} folded in`) + chalk.dim(` (${folded.fileCount} file(s) → served at /)`));
3831
+ }
3832
+ } else if (app.type === "static" || app.type === "admin") await buildAssetApp(projectRoot, name, app, manifest.runtime, args["--out"]);
3833
+ else if (app.type === "custom") console.log(chalk.dim(" custom container — built at deploy time from its Dockerfile"));
3834
+ console.log("");
3835
+ }
3836
+ console.log(chalk.green("✓ Build complete."));
3837
+ }
3838
+ /**
3839
+ * Build a static or bundled-admin app and package it into a static bundle.
3840
+ *
3841
+ * Runs the app's own build command, checks it produced the declared output, then
3842
+ * packages that output into a `static`-mode bundle — the same deployable shape as
3843
+ * a backend bundle, so a frontend or admin app deploys through the identical
3844
+ * path and runs on the identical image, just serving files instead of an API.
3845
+ */
3846
+ async function buildAssetApp(projectRoot, name, app, runtimeRange, outOverride) {
3847
+ const asset = app;
3848
+ if (app.type === "admin" && app.mode !== "bundled") {
3849
+ console.log(chalk.dim(" hosted admin panel — nothing to build"));
3850
+ return;
3851
+ }
3852
+ if (!asset.build) {
3853
+ console.log(chalk.dim(" no build command declared — skipping"));
3854
+ return;
3855
+ }
3856
+ try {
3857
+ await execa(asset.build, {
3858
+ cwd: projectRoot,
3859
+ stdio: "inherit",
3860
+ shell: true
3861
+ });
3862
+ } catch {
3863
+ console.error(chalk.red(` ✗ build command failed for "${name}"`));
3864
+ process.exit(1);
3865
+ }
3866
+ if (!asset.output) {
3867
+ console.log(chalk.yellow(" no output directory declared — built, but nothing to bundle"));
3868
+ return;
3869
+ }
3870
+ const outputPath = path.join(projectRoot, asset.output);
3871
+ if (!fs.existsSync(outputPath)) {
3872
+ console.error(chalk.red(` ✗ declared output "${asset.output}" does not exist after building`));
3873
+ process.exit(1);
3874
+ }
3875
+ const result = buildStaticBundle({
3876
+ projectRoot,
3877
+ appName: name,
3878
+ assetsDir: outputPath,
3879
+ outDir: outOverride ? path.resolve(process.cwd(), outOverride) : path.join(projectRoot, `dist-bundle-${name}`),
3880
+ runtimeRange
3881
+ });
3882
+ const rel = path.relative(projectRoot, result.outDir);
3883
+ console.log(chalk.green(` ✓ static bundle → ${rel}/`) + chalk.dim(` (${result.fileCount} file(s))`));
3884
+ }
3885
+ /** The pre-manifest behaviour: build every workspace package. */
3886
+ async function runWorkspaceBuilds(projectRoot) {
2208
3887
  const pm = detectPackageManager(projectRoot);
2209
3888
  const buildCmd = getPMCommands(pm).runAll("build");
2210
3889
  console.log(`${chalk.bold("Rebase")} — Building all workspaces with ${chalk.cyan(pm)}...\n`);
@@ -2223,16 +3902,124 @@ async function buildCommand() {
2223
3902
  /**
2224
3903
  * CLI command: rebase start
2225
3904
  *
2226
- * Starts the backend server in production mode.
2227
- * Automatically detects the package manager (pnpm or npm) and
2228
- * runs the start script in the backend workspace.
3905
+ * Runs a built bundle through the Rebase runtime — the same path the official
3906
+ * container image takes, so what you test locally is what a deployment runs.
3907
+ *
3908
+ * When there is no bundle (an ejected backend, or a project that has not adopted
3909
+ * `rebase.json`) this falls back to the backend workspace's own `start` script,
3910
+ * which is what such a project has always used.
2229
3911
  */
2230
- async function startCommand() {
3912
+ function printHelp$2() {
3913
+ console.log(`
3914
+ ${chalk.bold("rebase start")} — run a built bundle
3915
+
3916
+ ${chalk.bold("Usage")}
3917
+ rebase start [options]
3918
+
3919
+ ${chalk.bold("Options")}
3920
+ --bundle <dir> Bundle directory (default: ${DEFAULT_BUNDLE_DIR})
3921
+ --legacy Run the backend workspace's own start script
3922
+ -h, --help Show this help
3923
+
3924
+ Build first with ${chalk.cyan("rebase build")}.
3925
+ `.trim());
3926
+ }
3927
+ async function startCommand(rawArgs = []) {
3928
+ const args = arg({
3929
+ "--bundle": String,
3930
+ "--legacy": Boolean,
3931
+ "--help": Boolean,
3932
+ "-h": "--help"
3933
+ }, {
3934
+ argv: rawArgs.slice(3),
3935
+ permissive: true
3936
+ });
3937
+ if (args["--help"]) {
3938
+ printHelp$2();
3939
+ return;
3940
+ }
2231
3941
  const projectRoot = requireProjectRoot();
2232
- const startCmd = getPMCommands(detectPackageManager(projectRoot)).runWorkspace("backend", "start");
2233
3942
  const envFile = findEnvFile(projectRoot);
2234
3943
  const env = { ...process.env };
2235
3944
  if (envFile) env.DOTENV_CONFIG_PATH = envFile;
3945
+ const bundleDir = path.resolve(projectRoot, args["--bundle"] ?? "dist-bundle");
3946
+ const hasBundle = fs.existsSync(path.join(bundleDir, "manifest.json"));
3947
+ if (args["--legacy"] || !hasBundle) {
3948
+ if (!args["--legacy"] && !hasBundle) console.log(chalk.dim(`No bundle at ${path.relative(projectRoot, bundleDir)}/ — starting the backend workspace instead.
3949
+ `));
3950
+ await startWorkspaceBackend(projectRoot, env);
3951
+ return;
3952
+ }
3953
+ ensureBundleDependencies(projectRoot, bundleDir);
3954
+ console.log(`${chalk.bold("Rebase")} — starting runtime from ${chalk.cyan(path.relative(projectRoot, bundleDir))}/\n`);
3955
+ if (envFile && fs.existsSync(envFile)) (await import("dotenv")).config({ path: envFile });
3956
+ process.env.REBASE_BUNDLE = bundleDir;
3957
+ try {
3958
+ const { runFromBundle } = await import("@rebasepro/server");
3959
+ await runFromBundle({ bundleDir });
3960
+ } catch (err) {
3961
+ console.error(chalk.red("\n✗ Failed to start the runtime."));
3962
+ console.error(err instanceof Error ? err.message : String(err));
3963
+ process.exit(1);
3964
+ }
3965
+ }
3966
+ /**
3967
+ * Make a bundle's imports resolvable for a local run.
3968
+ *
3969
+ * Node resolves a module by walking up from the *importing file*, so compiled
3970
+ * code sitting in `dist-bundle/` no longer sees the per-package `node_modules`
3971
+ * its source could: pnpm and npm both install a workspace package's
3972
+ * dependencies inside that package, and the bundle is not inside any of them.
3973
+ *
3974
+ * A deployment solves this by installing the bundle's own `package.json` beside
3975
+ * it — that is what the generated `package.json` is for. Locally, doing a second
3976
+ * install to run code whose dependencies are already on disk would be wasteful,
3977
+ * so this links what is already there instead.
3978
+ *
3979
+ * Only ever created when absent, and only under the bundle directory, so a real
3980
+ * install always wins and nothing here is ever uploaded (`rebase build` cleans
3981
+ * the directory and never writes this).
3982
+ */
3983
+ function ensureBundleDependencies(projectRoot, bundleDir) {
3984
+ const target = path.join(bundleDir, "node_modules");
3985
+ if (fs.existsSync(target)) return;
3986
+ const sources = [
3987
+ "backend/node_modules",
3988
+ "config/node_modules",
3989
+ "node_modules"
3990
+ ].map((relative) => path.join(projectRoot, relative)).filter((dir) => fs.existsSync(dir));
3991
+ if (sources.length === 0) return;
3992
+ let linked = 0;
3993
+ fs.mkdirSync(target, { recursive: true });
3994
+ const linkInto = (sourceDir, targetDir) => {
3995
+ let entries;
3996
+ try {
3997
+ entries = fs.readdirSync(sourceDir, { withFileTypes: true });
3998
+ } catch {
3999
+ return;
4000
+ }
4001
+ for (const entry of entries) {
4002
+ if (entry.name === ".bin" || entry.name.startsWith(".")) continue;
4003
+ const from = path.join(sourceDir, entry.name);
4004
+ const to = path.join(targetDir, entry.name);
4005
+ if (entry.name.startsWith("@") && entry.isDirectory()) {
4006
+ fs.mkdirSync(to, { recursive: true });
4007
+ linkInto(from, to);
4008
+ continue;
4009
+ }
4010
+ if (fs.existsSync(to)) continue;
4011
+ try {
4012
+ fs.symlinkSync(fs.realpathSync(from), to, "junction");
4013
+ linked++;
4014
+ } catch {}
4015
+ }
4016
+ };
4017
+ for (const source of sources) linkInto(source, target);
4018
+ if (linked > 0) console.log(chalk.dim(` linked ${linked} package(s) into the bundle for this local run\n (a deployment installs the bundle's package.json instead)
4019
+ `));
4020
+ }
4021
+ async function startWorkspaceBackend(projectRoot, env) {
4022
+ const startCmd = getPMCommands(detectPackageManager(projectRoot)).runWorkspace("backend", "start");
2236
4023
  console.log(`${chalk.bold("Rebase")} — Starting backend server...\n`);
2237
4024
  try {
2238
4025
  await execa(startCmd[0], startCmd.slice(1), {
@@ -3109,6 +4896,55 @@ async function whoamiCommand(rawArgs) {
3109
4896
  * `link` associates the current directory with a cloud project by writing
3110
4897
  * `.rebase/cloud.json`; deploy/logs/status then operate on it with no flags.
3111
4898
  */
4899
+ /**
4900
+ * Link this checkout straight at a running backend.
4901
+ *
4902
+ * No control plane, no authentication, no project id — just the URL of a Rebase
4903
+ * API. This is what makes the multi-repo workflow available to self-hosters: a
4904
+ * frontend repository links to `https://api.example.com` and then generates its
4905
+ * typed SDK from that project exactly as a cloud-linked repository would.
4906
+ *
4907
+ * The URL is verified before it is written. Recording an unreachable address and
4908
+ * failing later, in a different command, would be a worse experience than
4909
+ * failing here where the user can see what they typed.
4910
+ */
4911
+ async function linkDirect(target, rawArgs) {
4912
+ let base;
4913
+ try {
4914
+ base = new URL(target);
4915
+ } catch {
4916
+ fail(`"${target}" is not a valid URL.`);
4917
+ return;
4918
+ }
4919
+ if (base.protocol !== "http:" && base.protocol !== "https:") fail("A project URL must be http or https.");
4920
+ const apiUrl = base.toString().replace(/\/+$/, "");
4921
+ const probe = `${apiUrl}/api/meta/schema-version`;
4922
+ let reachable = false;
4923
+ let detail = "";
4924
+ try {
4925
+ const response = await fetch(probe, { headers: { accept: "application/json" } });
4926
+ reachable = response.ok;
4927
+ if (!response.ok) detail = `responded ${response.status}`;
4928
+ } catch (err) {
4929
+ detail = err instanceof Error ? err.message : String(err);
4930
+ }
4931
+ if (!reachable) {
4932
+ console.log(chalk.yellow(`⚠ Could not reach ${probe}${detail ? ` (${detail})` : ""}.`));
4933
+ console.log(chalk.dim(" Linking anyway — the server may not be running yet."));
4934
+ console.log(chalk.dim(" It must be a Rebase backend of version 0.11 or newer."));
4935
+ }
4936
+ writeLink({
4937
+ url: apiUrl,
4938
+ projectId: "",
4939
+ apiUrl,
4940
+ mode: "direct",
4941
+ projectName: base.host
4942
+ });
4943
+ success(`Linked to ${apiUrl}`);
4944
+ console.log(chalk.dim(` Written to ${projectLinkPath()}`));
4945
+ console.log("");
4946
+ console.log(`Next: ${chalk.cyan("rebase generate-sdk --from link")}`);
4947
+ }
3112
4948
  async function linkCommand(rawArgs) {
3113
4949
  const args = arg({
3114
4950
  "--project": String,
@@ -3117,6 +4953,11 @@ async function linkCommand(rawArgs) {
3117
4953
  argv: rawArgs.slice(3),
3118
4954
  permissive: true
3119
4955
  });
4956
+ const positional = args._.find((value) => /^https?:\/\//i.test(value));
4957
+ if (positional) {
4958
+ await linkDirect(positional, rawArgs);
4959
+ return;
4960
+ }
3120
4961
  const { client, url } = await requireClient(rawArgs);
3121
4962
  try {
3122
4963
  let project;
@@ -3132,7 +4973,7 @@ async function linkCommand(rawArgs) {
3132
4973
  })).data;
3133
4974
  if (projects.length === 0) fail("No projects found for your account.", `Create one with ${chalk.bold("rebase cloud projects create")}.`);
3134
4975
  const { picked } = await inquirer.prompt([{
3135
- type: "list",
4976
+ type: "select",
3136
4977
  name: "picked",
3137
4978
  message: "Select a project to link:",
3138
4979
  choices: projects.map((p) => ({
@@ -3176,7 +5017,7 @@ async function selectOrgCommand(rawArgs) {
3176
5017
  let chosen = target ? orgs.find((o) => String(o.id) === target || o.slug === target) : void 0;
3177
5018
  if (!chosen && !target) {
3178
5019
  const { picked } = await inquirer.prompt([{
3179
- type: "list",
5020
+ type: "select",
3180
5021
  name: "picked",
3181
5022
  message: "Select the active organization:",
3182
5023
  choices: orgs.map((o) => ({
@@ -3412,6 +5253,121 @@ function fmtDate(value) {
3412
5253
  return isNaN(d.getTime()) ? value : d.toLocaleString();
3413
5254
  }
3414
5255
  //#endregion
5256
+ //#region src/commands/cloud/bundle-deploy.ts
5257
+ /**
5258
+ * Deploying a project as a managed **bundle** rather than a source build.
5259
+ *
5260
+ * `rebase cloud deploy --bundle` builds the bundle, tars it, uploads it to the
5261
+ * control plane's bundle endpoint, and triggers a deploy carrying the bundle id
5262
+ * and its generated manifest. The control plane resolves a runtime from the
5263
+ * manifest's range and runs the platform image with this bundle — the managed
5264
+ * path. A project not in managed mode, or one whose bundle fails intake, is told
5265
+ * so by the control plane; this side just packages and hands it over.
5266
+ *
5267
+ * The pieces here are separated from the network calls so they can be tested: the
5268
+ * manifest read, the tar packaging, and the request body assembly are pure enough
5269
+ * to check without a control plane.
5270
+ */
5271
+ /** Read and shallow-validate a built bundle's manifest. */
5272
+ function readBundleManifest(bundleDir) {
5273
+ const manifestPath = path.join(bundleDir, "manifest.json");
5274
+ if (!fs.existsSync(manifestPath)) throw new Error(`No manifest.json in ${bundleDir}. Run \`rebase build\` first.`);
5275
+ let manifest;
5276
+ try {
5277
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
5278
+ } catch (err) {
5279
+ throw new Error(`${manifestPath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
5280
+ }
5281
+ if (typeof manifest.bundleFormat !== "number" || !manifest.runtime?.range) throw new Error(`${manifestPath} is not a valid bundle manifest.`);
5282
+ return manifest;
5283
+ }
5284
+ /**
5285
+ * Tar a built bundle into a gzipped archive.
5286
+ *
5287
+ * `node_modules` is excluded on purpose: the bundle ships a `package.json`, and
5288
+ * the managed runtime installs the declared dependencies at boot. Uploading an
5289
+ * installed `node_modules` would bloat the archive and could carry a
5290
+ * platform-specific build that will not run on the runtime image.
5291
+ */
5292
+ function packBundle(bundleDir, outPath) {
5293
+ return new Promise((resolve, reject) => {
5294
+ const child = spawn("tar", [
5295
+ "-czf",
5296
+ outPath,
5297
+ "--no-xattrs",
5298
+ "--exclude",
5299
+ "node_modules",
5300
+ "-C",
5301
+ bundleDir,
5302
+ "."
5303
+ ], {
5304
+ stdio: "inherit",
5305
+ env: {
5306
+ ...process.env,
5307
+ COPYFILE_DISABLE: "1"
5308
+ }
5309
+ });
5310
+ child.on("error", reject);
5311
+ child.on("close", (code) => code === 0 ? resolve() : reject(/* @__PURE__ */ new Error(`tar exited ${code}`)));
5312
+ });
5313
+ }
5314
+ /**
5315
+ * Assemble the deploy-trigger body for a bundle deploy.
5316
+ *
5317
+ * The manifest travels with the trigger so the control plane can validate intake
5318
+ * without unpacking the uploaded archive first — a rejection (native deps, no
5319
+ * matching runtime) is then a fast, cheap answer.
5320
+ */
5321
+ function bundleDeployBody(input) {
5322
+ return {
5323
+ projectId: input.projectId,
5324
+ bundleId: input.bundleId,
5325
+ bundleManifest: input.manifest,
5326
+ app: input.app ?? input.manifest.app ?? "backend",
5327
+ client: "cli",
5328
+ frameworkVersion: input.manifest.runtime?.builtAgainst,
5329
+ ...input.declaredApps?.length ? { declaredApps: input.declaredApps } : {},
5330
+ ...input.message ? { message: input.message } : {}
5331
+ };
5332
+ }
5333
+ /**
5334
+ * The apps a project manifest declares.
5335
+ *
5336
+ * A deploy only ever ships ONE app's bundle, so the trigger alone could never
5337
+ * tell the platform that the repository also contains a web frontend and an
5338
+ * admin panel — and the Apps page, whose whole job is to show the set, listed a
5339
+ * single entry called "backend". Sending the declared set fixes that without
5340
+ * pretending the others are deployed: the platform registers them, and their
5341
+ * status says what is actually true.
5342
+ */
5343
+ function declaredAppsFrom(manifest) {
5344
+ const apps = manifest?.apps;
5345
+ if (!apps || typeof apps !== "object") return [];
5346
+ return Object.entries(apps).filter(([name]) => name.trim().length > 0).map(([name, value]) => ({
5347
+ name,
5348
+ type: String(value?.type ?? "custom")
5349
+ }));
5350
+ }
5351
+ /** Upload a bundle archive; returns the control-plane bundle id. */
5352
+ async function uploadBundle(url, token, projectId, tarPath) {
5353
+ const bytes = fs.readFileSync(tarPath);
5354
+ const res = await fetch(`${url}/api/functions/deploy/bundle/upload?projectId=${encodeURIComponent(projectId)}`, {
5355
+ method: "POST",
5356
+ headers: {
5357
+ Authorization: `Bearer ${token}`,
5358
+ "Content-Type": "application/gzip"
5359
+ },
5360
+ body: bytes
5361
+ });
5362
+ if (!res.ok) {
5363
+ const body = await res.text().catch(() => "");
5364
+ throw new Error(`Bundle upload failed (${res.status}): ${body || res.statusText}`);
5365
+ }
5366
+ const data = await res.json();
5367
+ if (!data.bundleId) throw new Error("Bundle upload endpoint did not return a bundle id.");
5368
+ return data.bundleId;
5369
+ }
5370
+ //#endregion
3415
5371
  //#region src/commands/cloud/deploy.ts
3416
5372
  /**
3417
5373
  * `rebase cloud deploy` and `rebase cloud logs`.
@@ -3419,6 +5375,12 @@ function fmtDate(value) {
3419
5375
  * `deploy` triggers the control-plane `deploy` function, then tails the build
3420
5376
  * logs from the deployment record until it succeeds or fails. `logs` shows the
3421
5377
  * latest build log, or runtime logs with `--runtime`.
5378
+ *
5379
+ * There are three deploys behind the one verb, and which one runs depends on the
5380
+ * flags: `--bundle` builds and uploads a managed bundle, `--source .` uploads
5381
+ * this directory as a build context, and the bare form uploads nothing and asks
5382
+ * the control plane to rebuild what it already holds. That last one is the
5383
+ * dangerous one — see `planBareDeploy`.
3422
5384
  */
3423
5385
  var POLL_INTERVAL_MS = 1500;
3424
5386
  var POLL_TIMEOUT_MS = 900 * 1e3;
@@ -3520,11 +5482,183 @@ async function uploadSource(url, token, projectId, tarPath) {
3520
5482
  if (!data.source) fail("Upload endpoint did not return a source reference.");
3521
5483
  return data.source;
3522
5484
  }
5485
+ /**
5486
+ * Build, upload and deploy a project as a managed bundle.
5487
+ *
5488
+ * Builds the backend app into `dist-bundle` (unless one is pointed at with
5489
+ * `--bundle-dir`), packs it without `node_modules`, uploads it, and triggers a
5490
+ * deploy carrying the manifest so the control plane can validate intake fast.
5491
+ */
5492
+ async function deployBundle(opts) {
5493
+ const { client, url, projectId, projectRef } = opts;
5494
+ const projectRoot = requireProjectRoot();
5495
+ let bundleDir = opts.bundleDir ? path.resolve(process.cwd(), opts.bundleDir) : path.join(projectRoot, "dist-bundle");
5496
+ if (!opts.bundleDir) {
5497
+ const loaded = loadManifest(projectRoot);
5498
+ const backend = findBackendApp(loaded.manifest);
5499
+ if (!backend) fail("This repository declares no backend app to deploy as a bundle.", "A managed deploy runs the backend; declare one in rebase.json, or deploy from the backend's repository.");
5500
+ console.log(chalk.gray(" Building bundle..."));
5501
+ bundleDir = (await buildBundle({
5502
+ projectRoot,
5503
+ appName: backend.name,
5504
+ app: backend.app,
5505
+ runtimeRange: loaded.manifest.runtime,
5506
+ skipTypeCheck: opts.skipTypeCheck,
5507
+ log: (m) => console.log(chalk.gray(m))
5508
+ })).outDir;
5509
+ try {
5510
+ const folded = await foldFrontendIntoBundle({
5511
+ projectRoot,
5512
+ manifest: loaded.manifest,
5513
+ bundleDir,
5514
+ log: (m) => console.log(m)
5515
+ });
5516
+ if (folded) console.log(chalk.gray(` folded ${folded.appName} in (${folded.fileCount} file(s), served at /)`));
5517
+ } catch (err) {
5518
+ fail(err instanceof Error ? err.message : String(err), "Fix the frontend build, or pass --no-static to deploy the API alone.");
5519
+ }
5520
+ }
5521
+ const manifest = readBundleManifest(bundleDir);
5522
+ if (manifest.hooks?.native) {
5523
+ const names = (manifest.hooks.nativeModules ?? []).map((m) => m.name).join(", ");
5524
+ fail(`This bundle depends on native modules${names ? ` (${names})` : ""}, which the managed runtime cannot run.`, "Remove the native dependency, or deploy on the custom runtime.");
5525
+ }
5526
+ const tarPath = path.join(os.tmpdir(), `rebase-bundle-${Date.now()}.tar.gz`);
5527
+ const token = client.auth.getSession()?.accessToken;
5528
+ if (!token) fail("Not authenticated.", "Run `rebase cloud login`.");
5529
+ let bundleId;
5530
+ try {
5531
+ await packBundle(bundleDir, tarPath);
5532
+ const sizeMb = (fs.statSync(tarPath).size / 1024 / 1024).toFixed(1);
5533
+ console.log(chalk.gray(` Uploading bundle (${sizeMb} MB)...`));
5534
+ bundleId = await uploadBundle(url, token, projectId, tarPath);
5535
+ } catch (e) {
5536
+ fail(e instanceof Error ? e.message : String(e));
5537
+ return;
5538
+ } finally {
5539
+ fs.rmSync(tarPath, { force: true });
5540
+ }
5541
+ console.log("");
5542
+ console.log(` 🚀 Triggering managed deployment for ${chalk.bold(projectRef)} (schema ${manifest.schemaVersion})...`);
5543
+ let declaredApps = [];
5544
+ try {
5545
+ declaredApps = declaredAppsFrom(loadManifest(process.cwd()).manifest);
5546
+ } catch {}
5547
+ const body = bundleDeployBody({
5548
+ projectId,
5549
+ bundleId,
5550
+ manifest,
5551
+ message: opts.message,
5552
+ declaredApps
5553
+ });
5554
+ try {
5555
+ const res = await client.functions.invoke("deploy", body);
5556
+ if (!res?.deployment?.id) fail("Control plane did not return a deployment id.");
5557
+ if (isJsonMode()) printJson({
5558
+ success: true,
5559
+ deploymentId: String(res.deployment.id),
5560
+ managed: res.managed === true
5561
+ });
5562
+ else {
5563
+ console.log(chalk.green(` ✓ Managed deploy started (deployment ${res.deployment.id}).`));
5564
+ console.log(chalk.gray(" Track it with `rebase cloud logs` or in the console."));
5565
+ }
5566
+ } catch (e) {
5567
+ reportError(e, "Managed deploy failed to start");
5568
+ }
5569
+ }
5570
+ function pick(row, ...keys) {
5571
+ for (const key of keys) {
5572
+ const raw = row?.[key];
5573
+ if (typeof raw === "string" && raw.trim() !== "") return raw.trim();
5574
+ }
5575
+ }
5576
+ /** Rough age of a timestamp, for "…uploaded 6d ago". Undefined if unreadable. */
5577
+ function timeAgo(value, now) {
5578
+ if (value === void 0) return void 0;
5579
+ const then = value instanceof Date ? value.getTime() : new Date(value).getTime();
5580
+ if (Number.isNaN(then)) return void 0;
5581
+ const ms = now.getTime() - then;
5582
+ if (ms < 0) return void 0;
5583
+ const minutes = Math.floor(ms / 6e4);
5584
+ if (minutes < 1) return "just now";
5585
+ if (minutes < 60) return `${minutes}m ago`;
5586
+ const hours = Math.floor(minutes / 60);
5587
+ if (hours < 24) return `${hours}h ago`;
5588
+ return `${Math.floor(hours / 24)}d ago`;
5589
+ }
5590
+ /**
5591
+ * Whether this project runs on the managed runtime.
5592
+ *
5593
+ * `runtimeMode` on the project row is the authority — the control plane writes
5594
+ * it. The bundle-id fallback covers a control plane that does not return the
5595
+ * field: a successful deploy that served a bundle only happens on the managed
5596
+ * path.
5597
+ */
5598
+ function isManagedProject(project, latest) {
5599
+ if (pick(project, "runtimeMode", "runtime_mode") === "managed") return true;
5600
+ return latest?.status === "success" && pick(latest, "bundleId", "bundle_id") !== void 0;
5601
+ }
5602
+ /** What a `deploy` with nothing attached will build, in the words to print. */
5603
+ function planBareDeploy(project, latest, now) {
5604
+ const projectRow = project;
5605
+ const deploymentRow = latest;
5606
+ const managed = isManagedProject(project, latest);
5607
+ const repo = pick(projectRow, "gitRepoUrl", "git_repo_url");
5608
+ if (repo) {
5609
+ const branch = pick(projectRow, "gitBranch", "git_branch");
5610
+ return {
5611
+ managed,
5612
+ source: "git",
5613
+ lines: [`Building from git: ${repo}${branch ? ` (${branch})` : ""}.`]
5614
+ };
5615
+ }
5616
+ if (pick(deploymentRow, "sourceRef", "source_ref")) {
5617
+ const age = timeAgo(latest?.createdAt ?? latest?.created_at, now);
5618
+ return {
5619
+ managed,
5620
+ source: "snapshot",
5621
+ lines: [`Rebuilding the stored source archive${latest?.id !== void 0 ? ` from deployment ${latest.id}` : ""}${age ? `, uploaded ${age}` : ""}.`, "This directory is NOT uploaded — pass `--source .` to build what is on disk."]
5622
+ };
5623
+ }
5624
+ return {
5625
+ managed,
5626
+ source: "none",
5627
+ lines: ["This project has no git repository configured and no stored source archive to rebuild.", "Upload this directory with `--source .`, or set a repository URL in the project settings."]
5628
+ };
5629
+ }
5630
+ /** The one sentence that says a source build undoes `runtimeMode: managed`. */
5631
+ function ejectWarning(projectRef) {
5632
+ return `⚠ ${projectRef} runs on the managed runtime — a source build ejects it to a custom container.`;
5633
+ }
5634
+ /**
5635
+ * Read the two rows the preflight needs.
5636
+ *
5637
+ * Best effort by construction: a preflight that cannot read is a preflight that
5638
+ * says nothing, never a deploy that fails. The managed refusal rides on the same
5639
+ * read, so an unreadable project falls through to the old behaviour rather than
5640
+ * blocking a deploy on a lookup.
5641
+ */
5642
+ async function readDeployContext(client, projectId) {
5643
+ try {
5644
+ const [project, latest] = await Promise.all([client.data.collection("projects").findById(projectId), latestDeployment(client, projectId)]);
5645
+ return {
5646
+ project,
5647
+ latest
5648
+ };
5649
+ } catch {
5650
+ return {};
5651
+ }
5652
+ }
3523
5653
  async function deployCommand(rawArgs, projectRef) {
3524
5654
  const args = arg({
3525
5655
  "--no-follow": Boolean,
3526
5656
  "--source": String,
3527
5657
  "--message": String,
5658
+ "--bundle": Boolean,
5659
+ "--bundle-dir": String,
5660
+ "--skip-type-check": Boolean,
5661
+ "--force": Boolean,
3528
5662
  "-m": "--message"
3529
5663
  }, {
3530
5664
  argv: rawArgs.slice(2),
@@ -3532,6 +5666,33 @@ async function deployCommand(rawArgs, projectRef) {
3532
5666
  });
3533
5667
  const { client, url } = await requireClient(rawArgs);
3534
5668
  const projectId = await resolveProjectRef(projectRef, client);
5669
+ if (args["--bundle"]) {
5670
+ if (args["--source"]) fail("--bundle and --source cannot be combined: one is a managed bundle, the other a source build.");
5671
+ await deployBundle({
5672
+ client,
5673
+ url,
5674
+ projectId,
5675
+ projectRef,
5676
+ bundleDir: args["--bundle-dir"],
5677
+ message: args["--message"],
5678
+ skipTypeCheck: args["--skip-type-check"] === true
5679
+ });
5680
+ return;
5681
+ }
5682
+ const { project, latest } = await readDeployContext(client, projectId);
5683
+ const plan = planBareDeploy(project, latest, /* @__PURE__ */ new Date());
5684
+ if (!args["--source"]) {
5685
+ if (plan.managed && args["--force"] !== true) fail(`${projectRef} runs on the managed runtime, and a plain \`rebase cloud deploy\` builds a container image instead — ejecting it from managed, from source the control plane already holds rather than this directory.`, "Redeploy it with `rebase cloud deploy --bundle`. To eject on purpose, pass `--source .` to build this directory, or `--force` to build what the control plane holds.", "managed_project");
5686
+ if (!isJsonMode()) {
5687
+ console.log("");
5688
+ if (plan.managed) console.log(chalk.yellow(` ${ejectWarning(projectRef)}`));
5689
+ for (const line of plan.lines) console.log(chalk.gray(` ${line}`));
5690
+ }
5691
+ } else if (plan.managed && !isJsonMode()) {
5692
+ console.log("");
5693
+ console.log(chalk.yellow(` ${ejectWarning(projectRef)}`));
5694
+ console.log(chalk.gray(" Use `rebase cloud deploy --bundle` to stay on managed."));
5695
+ }
3535
5696
  let source;
3536
5697
  if (args["--source"]) {
3537
5698
  const tarPath = await createSourceTarball(args["--source"]);
@@ -3911,7 +6072,7 @@ async function createDatabase(rawArgs) {
3911
6072
  let type = args["--type"];
3912
6073
  if (!type) {
3913
6074
  const { picked } = await inquirer.prompt([{
3914
- type: "list",
6075
+ type: "select",
3915
6076
  name: "picked",
3916
6077
  message: "Database type:",
3917
6078
  choices: [{
@@ -6743,7 +8904,7 @@ ${chalk.green.bold("Projects")}
6743
8904
  ${chalk.blue.bold("projects delete")} ${chalk.gray("[id]")} Delete a project
6744
8905
 
6745
8906
  ${chalk.green.bold("Deploy & observe")}
6746
- ${chalk.blue.bold("deploy")} ${chalk.gray("[--source .] [-m msg]")} Deploy the linked project + stream build logs
8907
+ ${chalk.blue.bold("deploy")} ${chalk.gray("[--bundle|--source .]")} Deploy the linked project + stream build logs
6747
8908
  ${chalk.blue.bold("logs")} ${chalk.gray("[--runtime] [-f]")} Show build (or runtime) logs
6748
8909
  ${chalk.blue.bold("deployments list")} ${chalk.gray("[--limit N|--all]")} Deployment history ${chalk.gray("(status, duration, trigger)")}
6749
8910
  ${chalk.blue.bold("rollback")} ${chalk.gray("[id] [-y]")} Roll back to a successful deploy
@@ -6786,6 +8947,199 @@ ${chalk.gray("Docs: https://rebase.pro/docs")}
6786
8947
  `);
6787
8948
  }
6788
8949
  //#endregion
8950
+ //#region src/commands/apps.ts
8951
+ /**
8952
+ * CLI command: rebase apps
8953
+ *
8954
+ * Inspect the apps this repository contributes to a project, adopt a
8955
+ * `rebase.json` for a project that predates it, and print the client bootstrap
8956
+ * an app needs to reach its backend.
8957
+ *
8958
+ * The distinction that runs through all of this: a *repository* declares apps,
8959
+ * a *project* owns them. Two repositories can contribute to the same project and
8960
+ * never know about each other, which is what makes a separate frontend repo — or
8961
+ * a mobile app with no repo relationship at all — an ordinary thing rather than
8962
+ * a special case.
8963
+ */
8964
+ function printHelp$1() {
8965
+ console.log(`
8966
+ ${chalk.bold("rebase apps")} — the apps this repository contributes
8967
+
8968
+ ${chalk.bold("Usage")}
8969
+ rebase apps list List declared apps and their build outputs
8970
+ rebase apps init Write a rebase.json inferred from this project
8971
+ rebase apps config <app> Print the client configuration for an app
8972
+
8973
+ ${chalk.bold("Options")}
8974
+ --json Machine-readable output
8975
+ --force Overwrite an existing rebase.json (apps init)
8976
+ -h, --help Show this help
8977
+ `.trim());
8978
+ }
8979
+ async function appsCommand(subcommand, rawArgs = []) {
8980
+ const args = arg({
8981
+ "--json": Boolean,
8982
+ "--force": Boolean,
8983
+ "--help": Boolean,
8984
+ "-h": "--help"
8985
+ }, {
8986
+ argv: rawArgs.slice(3),
8987
+ permissive: true
8988
+ });
8989
+ if (args["--help"] || !subcommand || subcommand === "--help") {
8990
+ printHelp$1();
8991
+ return;
8992
+ }
8993
+ switch (subcommand) {
8994
+ case "list":
8995
+ await listApps(Boolean(args["--json"]));
8996
+ break;
8997
+ case "init":
8998
+ await initManifest(Boolean(args["--force"]));
8999
+ break;
9000
+ case "config":
9001
+ await printAppConfig(args._[1], Boolean(args["--json"]));
9002
+ break;
9003
+ default:
9004
+ console.error(chalk.red(`Unknown subcommand: ${subcommand}`));
9005
+ console.log("");
9006
+ printHelp$1();
9007
+ process.exit(1);
9008
+ }
9009
+ }
9010
+ function describeApp(app) {
9011
+ switch (app.type) {
9012
+ case "backend": return `config: ${app.config ?? "config"}, mode: ${app.mode ?? "cms"}`;
9013
+ case "static": return `${app.root} → ${app.output}`;
9014
+ case "admin": return app.mode === "bundled" ? `bundled → ${app.output ?? "?"}` : "hosted by the platform";
9015
+ case "mobile": return app.platform;
9016
+ case "custom": return app.dockerfile ?? "Dockerfile";
9017
+ default: return "";
9018
+ }
9019
+ }
9020
+ async function listApps(asJson) {
9021
+ const loaded = loadManifestOrExit(requireProjectRoot());
9022
+ const compatibility = assessManagedCompatibility(loaded.manifest);
9023
+ if (asJson) {
9024
+ console.log(JSON.stringify({
9025
+ source: loaded.source,
9026
+ runtime: loaded.manifest.runtime,
9027
+ apps: loaded.manifest.apps,
9028
+ managed: compatibility
9029
+ }, null, 2));
9030
+ return;
9031
+ }
9032
+ if (loaded.source === "synthesized") {
9033
+ console.log(chalk.dim("No rebase.json — showing the layout inferred from this project."));
9034
+ console.log(chalk.dim(`Run ${chalk.cyan("rebase apps init")} to write it down.\n`));
9035
+ }
9036
+ console.log(chalk.bold(`Runtime ${loaded.manifest.runtime}`));
9037
+ console.log("");
9038
+ const entries = Object.entries(loaded.manifest.apps);
9039
+ if (entries.length === 0) {
9040
+ console.log(chalk.yellow("No apps declared."));
9041
+ return;
9042
+ }
9043
+ const width = Math.max(...entries.map(([name]) => name.length));
9044
+ for (const [name, app] of entries) console.log(` ${chalk.cyan(name.padEnd(width))} ${chalk.dim(app.type.padEnd(8))} ${describeApp(app)}`);
9045
+ console.log("");
9046
+ if (compatibility.eligible) console.log(chalk.green("✓ Eligible for the managed runtime."));
9047
+ else {
9048
+ console.log(chalk.yellow("• Uses the custom runtime:"));
9049
+ for (const reason of compatibility.reasons) console.log(chalk.dim(` ${reason}`));
9050
+ }
9051
+ }
9052
+ async function initManifest(force) {
9053
+ const projectRoot = requireProjectRoot();
9054
+ if (manifestExists(projectRoot) && !force) {
9055
+ console.error(chalk.red("✗ rebase.json already exists."));
9056
+ console.error(chalk.dim(" Pass --force to overwrite it."));
9057
+ process.exit(1);
9058
+ }
9059
+ const manifest = synthesizeManifest(projectRoot);
9060
+ const filePath = writeManifest(projectRoot, manifest);
9061
+ console.log(chalk.green(`✓ Wrote ${path.relative(projectRoot, filePath)}`));
9062
+ console.log("");
9063
+ for (const [name, app] of Object.entries(manifest.apps)) console.log(` ${chalk.cyan(name)} ${chalk.dim(`(${app.type})`)}`);
9064
+ const compatibility = assessManagedCompatibility(manifest);
9065
+ if (!compatibility.eligible) {
9066
+ console.log("");
9067
+ console.log(chalk.yellow("This project will use the custom runtime:"));
9068
+ for (const reason of compatibility.reasons) console.log(chalk.dim(` ${reason}`));
9069
+ }
9070
+ }
9071
+ /**
9072
+ * Print what a client needs to reach this project.
9073
+ *
9074
+ * Never prints a secret. The API URL and an app's publishable identity are meant
9075
+ * to ship inside a client bundle; anything that is not safe there does not belong
9076
+ * in output that will inevitably be pasted into a `.env` that gets committed.
9077
+ */
9078
+ async function printAppConfig(appName, asJson) {
9079
+ const projectRoot = requireProjectRoot();
9080
+ const loaded = loadManifestOrExit(projectRoot);
9081
+ if (!appName) {
9082
+ console.error(chalk.red("✗ Which app? Usage: rebase apps config <app>"));
9083
+ process.exit(1);
9084
+ }
9085
+ const app = loaded.manifest.apps[appName];
9086
+ if (!app) {
9087
+ console.error(chalk.red(`✗ No app named "${appName}" in rebase.json.`));
9088
+ console.error(chalk.dim(` Declared: ${Object.keys(loaded.manifest.apps).join(", ") || "(none)"}`));
9089
+ process.exit(1);
9090
+ }
9091
+ const link = readLink(projectRoot);
9092
+ const apiUrl = resolveApiUrl(projectRoot, link);
9093
+ const config = {
9094
+ app: appName,
9095
+ type: app.type,
9096
+ apiUrl: apiUrl ?? null,
9097
+ project: link?.projectId ?? link?.slug ?? null
9098
+ };
9099
+ if (asJson) {
9100
+ console.log(JSON.stringify(config, null, 2));
9101
+ return;
9102
+ }
9103
+ if (!apiUrl) {
9104
+ console.log(chalk.yellow("This checkout is not linked to a project yet."));
9105
+ console.log(chalk.dim(` Run ${chalk.cyan("rebase link")} (cloud) or ${chalk.cyan("rebase link <url>")} (self-hosted).`));
9106
+ console.log("");
9107
+ }
9108
+ console.log(chalk.bold(`# ${appName}`));
9109
+ console.log("");
9110
+ console.log(`VITE_API_URL=${apiUrl ?? "http://localhost:3001"}`);
9111
+ console.log("");
9112
+ console.log(chalk.dim("Then, in the app:"));
9113
+ console.log(chalk.dim(" const rebase = createRebaseClient({ baseUrl: import.meta.env.VITE_API_URL });"));
9114
+ }
9115
+ /**
9116
+ * Work out the API base URL for this checkout.
9117
+ *
9118
+ * Prefers an explicit link, then the dev server's own record of where it bound.
9119
+ * The dev port is chosen dynamically, so a hardcoded default would be wrong on
9120
+ * any machine running more than one project.
9121
+ */
9122
+ function resolveApiUrl(projectRoot, link) {
9123
+ if (link?.apiUrl) return link.apiUrl;
9124
+ const statePath = path.join(projectRoot, ".rebase", "state.json");
9125
+ if (fs.existsSync(statePath)) try {
9126
+ const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
9127
+ if (state.baseUrl) return state.baseUrl;
9128
+ } catch {}
9129
+ }
9130
+ function loadManifestOrExit(projectRoot) {
9131
+ try {
9132
+ return loadManifest(projectRoot);
9133
+ } catch (err) {
9134
+ if (err instanceof ManifestError) {
9135
+ console.error(chalk.red(`✗ ${err.message}`));
9136
+ for (const issue of err.issues) console.error(chalk.red(` ${issue.path ? `${issue.path}: ` : ""}${issue.message}`));
9137
+ process.exit(1);
9138
+ }
9139
+ throw err;
9140
+ }
9141
+ }
9142
+ //#endregion
6789
9143
  //#region src/cli.ts
6790
9144
  var __filename = fileURLToPath(import.meta.url);
6791
9145
  var __dirname = path.dirname(__filename);
@@ -6823,7 +9177,9 @@ async function entry(args) {
6823
9177
  "doctor",
6824
9178
  "skills",
6825
9179
  "api-keys",
6826
- "cloud"
9180
+ "cloud",
9181
+ "apps",
9182
+ "generate-sdk"
6827
9183
  ].includes(command)) {
6828
9184
  printHelp();
6829
9185
  return;
@@ -6837,8 +9193,12 @@ async function entry(args) {
6837
9193
  const sdkArgs = arg({
6838
9194
  "--collections-dir": String,
6839
9195
  "--output": String,
9196
+ "--from": String,
9197
+ "--token": String,
9198
+ "--help": Boolean,
6840
9199
  "-c": "--collections-dir",
6841
- "-o": "--output"
9200
+ "-o": "--output",
9201
+ "-h": "--help"
6842
9202
  }, {
6843
9203
  argv: args.slice(3),
6844
9204
  permissive: true
@@ -6846,6 +9206,9 @@ async function entry(args) {
6846
9206
  await generateSdkCommand({
6847
9207
  collectionsDir: sdkArgs["--collections-dir"] || "./config/collections",
6848
9208
  output: sdkArgs["--output"] || "./generated/sdk",
9209
+ from: sdkArgs["--from"],
9210
+ token: sdkArgs["--token"],
9211
+ help: sdkArgs["--help"],
6849
9212
  cwd: process.cwd()
6850
9213
  });
6851
9214
  break;
@@ -6860,10 +9223,13 @@ async function entry(args) {
6860
9223
  await devCommand(args);
6861
9224
  break;
6862
9225
  case "build":
6863
- await buildCommand();
9226
+ await buildCommand(args);
6864
9227
  break;
6865
9228
  case "start":
6866
- await startCommand();
9229
+ await startCommand(args);
9230
+ break;
9231
+ case "apps":
9232
+ await appsCommand(effectiveSubcommand, args);
6867
9233
  break;
6868
9234
  case "auth":
6869
9235
  await authCommand(effectiveSubcommand, args);
@@ -6944,6 +9310,6 @@ ${chalk.gray("Documentation: https://rebase.pro/docs")}
6944
9310
  `);
6945
9311
  }
6946
9312
  //#endregion
6947
- export { authCommand, buildCommand, buildInitQuestions, cloudCommand, configureEnvFile, createRebaseApp, dbCommand, detectPackageManager, devCommand, doctorCommand, entry, findBackendDir, findEnvFile, findFrontendDir, findProjectRoot, formatCdTarget, generateSdkCommand, getActiveBackendPlugin, getPMCommands, isPnpmAvailable, pnpmAvailabilityFromProbe, printInitHelp, requireBackendDir, requireProjectRoot, resetPnpmAvailabilityCache, resolveLocalBin, resolvePluginCliScript, resolveTsx, schemaCommand, startCommand, validateProjectName, validateTsxInstallation };
9313
+ export { CURRENT_RUNTIME_RANGE, DEFAULT_BUNDLE_DIR, DEFAULT_CONFIG_DIR, DEFAULT_CRONS_DIR, DEFAULT_FUNCTIONS_DIR, DEFAULT_SCHEMA_FILE, MANIFEST_FILENAME, ManifestError, appsCommand, assessManagedCompatibility, authCommand, buildBundle, buildCommand, buildInitQuestions, buildStaticBundle, buildableApps, cloudCommand, collectDeclaredDependencies, configureEnvFile, createRebaseApp, dbCommand, detectNativeDependencies, detectPackageManager, detectStorageAuthorize, devCommand, doctorCommand, entry, findBackendApp, findBackendDir, findEnvFile, findFrontendDir, findProjectRoot, findUnusedServerEntry, foldStaticIntoBundle, formatCdTarget, generateSdkCommand, getActiveBackendPlugin, getPMCommands, isIdentifierLike, isPnpmAvailable, loadManifest, manifestExists, manifestPath, normalizeEsmSpecifiers, pnpmAvailabilityFromProbe, printInitHelp, requireBackendDir, requireProjectRoot, resetPnpmAvailabilityCache, resolveBackendPaths, resolveExampleBaseUrl, resolveLocalBin, resolvePluginCliScript, resolveTsx, schemaCommand, startCommand, synthesizeManifest, validateManifest, validateProjectName, validateTsxInstallation, writeManifest };
6948
9314
 
6949
9315
  //# sourceMappingURL=index.es.js.map