@rebasepro/cli 0.10.0 → 0.10.1-canary.0a881d4

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 (40) hide show
  1. package/README.md +1 -1
  2. package/dist/bundle.d.ts +146 -0
  3. package/dist/commands/apps.d.ts +1 -0
  4. package/dist/commands/build.d.ts +1 -1
  5. package/dist/commands/cloud/bundle-deploy.d.ts +53 -0
  6. package/dist/commands/cloud/context.d.ts +28 -0
  7. package/dist/commands/cloud/deployments.d.ts +16 -0
  8. package/dist/commands/cloud/env.d.ts +2 -0
  9. package/dist/commands/cloud/resources.d.ts +38 -0
  10. package/dist/commands/generate_sdk.d.ts +12 -0
  11. package/dist/commands/start.d.ts +1 -1
  12. package/dist/fold-static.d.ts +46 -0
  13. package/dist/index.d.ts +3 -0
  14. package/dist/index.es.js +2567 -100
  15. package/dist/index.es.js.map +1 -1
  16. package/dist/manifest.d.ts +83 -0
  17. package/dist/utils/package-manager.d.ts +26 -2
  18. package/dist/utils/project.d.ts +11 -3
  19. package/package.json +8 -7
  20. package/runtime/dev-server.mjs +43 -0
  21. package/templates/overlays/baas/backend/src/index.ts +28 -4
  22. package/templates/overlays/baas/backend/src/storage.ts +71 -0
  23. package/templates/overlays/baas/rebase.json +14 -0
  24. package/templates/template/.env.example +16 -3
  25. package/templates/template/README.md +39 -29
  26. package/templates/template/backend/src/index.ts +28 -4
  27. package/templates/template/config/admin.d.ts +9 -0
  28. package/templates/template/config/collections/authors.ts +11 -9
  29. package/templates/template/config/collections/posts.ts +6 -4
  30. package/templates/template/config/collections/presets/ecommerce/categories.ts +7 -5
  31. package/templates/template/config/collections/presets/ecommerce/orders.ts +32 -20
  32. package/templates/template/config/collections/presets/ecommerce/products.ts +21 -13
  33. package/templates/template/config/collections/tags.ts +5 -3
  34. package/templates/template/config/collections/users.ts +34 -29
  35. package/templates/template/config/frontend-assets.d.ts +17 -0
  36. package/templates/template/config/index.ts +6 -0
  37. package/templates/template/config/package.json +26 -25
  38. package/templates/template/config/storage.ts +88 -0
  39. package/templates/template/gitignore +4 -0
  40. 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
@@ -23,21 +24,61 @@ import { createRequire } from "module";
23
24
  * the rest of the CLI never has to hardcode a specific PM.
24
25
  */
25
26
  /**
27
+ * How long to wait for `pnpm --version` before giving up on the probe.
28
+ *
29
+ * `pnpm --version` is a cold Node start, and on a machine that is busy — a
30
+ * parallel install, a full test run — it routinely takes seconds. Measured at
31
+ * 630ms, 990ms and 4293ms on three consecutive runs of one developer laptop
32
+ * under load, so the previous 3s budget was inside the normal spread rather
33
+ * than safely outside it.
34
+ */
35
+ var PNPM_PROBE_TIMEOUT_MS = 5e3;
36
+ /** Memoised result of the probe. pnpm cannot appear or vanish mid-process. */
37
+ var cachedPnpmAvailable;
38
+ /**
39
+ * Decide availability from a `spawnSync` outcome.
40
+ *
41
+ * Split out from the spawn itself so the decision is testable without starting
42
+ * a process — which is what made the old test load-sensitive and occasionally
43
+ * red for reasons that had nothing to do with the code under test.
44
+ *
45
+ * The three outcomes are distinguishable, and the old code conflated two of
46
+ * them by asking only `status === 0`:
47
+ *
48
+ * not installed status null, signal null, error.code ENOENT
49
+ * timed out status null, signal SIGTERM, error.code ETIMEDOUT
50
+ * broken install status non-zero, no error
51
+ */
52
+ function pnpmAvailabilityFromProbe(res) {
53
+ const code = res.error?.code;
54
+ if (code === "ENOENT") return false;
55
+ if (code === "ETIMEDOUT" || res.signal) return true;
56
+ if (res.error) return false;
57
+ return res.status === 0;
58
+ }
59
+ /**
26
60
  * Whether pnpm is runnable on this machine.
27
61
  *
28
62
  * Used to decide whether a fresh project can be scaffolded with pnpm. Kept
29
- * cheap and non-interactive (short timeout, output discarded) so it never
30
- * hangs detection if a corepack shim misbehaves.
63
+ * cheap and non-interactive (bounded timeout, output discarded) so it never
64
+ * hangs detection if a corepack shim misbehaves, and memoised so that repeated
65
+ * detection in one CLI run costs one process rather than one per call.
31
66
  */
32
67
  function isPnpmAvailable() {
68
+ if (cachedPnpmAvailable !== void 0) return cachedPnpmAvailable;
33
69
  try {
34
- return spawnSync("pnpm", ["--version"], {
70
+ cachedPnpmAvailable = pnpmAvailabilityFromProbe(spawnSync("pnpm", ["--version"], {
35
71
  stdio: "ignore",
36
- timeout: 3e3
37
- }).status === 0;
72
+ timeout: PNPM_PROBE_TIMEOUT_MS
73
+ }));
38
74
  } catch {
39
- return false;
75
+ cachedPnpmAvailable = false;
40
76
  }
77
+ return cachedPnpmAvailable;
78
+ }
79
+ /** Forget the memoised probe. For tests; nothing in a CLI run needs it. */
80
+ function resetPnpmAvailabilityCache() {
81
+ cachedPnpmAvailable = void 0;
41
82
  }
42
83
  /**
43
84
  * Detect the package manager for a Rebase project.
@@ -156,17 +197,26 @@ function getPMCommands(pm) {
156
197
  * These helpers locate the project root, backend directory, .env file,
157
198
  * and local binaries — used by all CLI command modules.
158
199
  */
200
+ /** The authored project manifest. Its presence alone marks a project root. */
201
+ var MANIFEST_FILENAME = "rebase.json";
159
202
  /**
160
203
  * Walk up from `startDir` to find the Rebase project root.
161
204
  *
162
- * The root is identified by a `package.json` that either:
163
- * - has `workspaces` containing "backend" or "frontend", OR
164
- * - 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.
165
214
  */
166
215
  function findProjectRoot(startDir = process.cwd()) {
167
216
  let dir = path.resolve(startDir);
168
217
  const root = path.parse(dir).root;
169
218
  while (dir !== root) {
219
+ if (fs.existsSync(path.join(dir, "rebase.json"))) return dir;
170
220
  const pkgPath = path.join(dir, "package.json");
171
221
  if (fs.existsSync(pkgPath)) {
172
222
  try {
@@ -636,9 +686,17 @@ var ANSI_RE = /\[[0-9;]*m/g;
636
686
  function stripAnsi(s) {
637
687
  return s.replace(ANSI_RE, "");
638
688
  }
639
- /** Write one JSON value to stdout, followed by a newline. */
689
+ /**
690
+ * Write one JSON value to stdout, followed by a newline.
691
+ *
692
+ * Indented, because the overwhelmingly common reader is a person or an agent
693
+ * looking at a terminal — JSON mode is entered automatically whenever stdout is
694
+ * not a TTY, so `rebase cloud deployments list` piped anywhere at all produced
695
+ * a project's entire deployment history as one unwrapped line. `JSON.parse`
696
+ * does not care about the whitespace; everything else does.
697
+ */
640
698
  function printJson(value) {
641
- process.stdout.write(JSON.stringify(value) + "\n");
699
+ process.stdout.write(JSON.stringify(value, null, 2) + "\n");
642
700
  }
643
701
  /**
644
702
  * The one output primitive every new command uses: in JSON mode emit `json`
@@ -1552,21 +1610,156 @@ function writeFiles(outputDir, files) {
1552
1610
  fs.writeFileSync(filePath, file.content, "utf-8");
1553
1611
  }
1554
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
+ /** Resolve `--from` into a base URL, following the link file when asked. */
1699
+ function resolveSchemaSource(from, cwd) {
1700
+ if (from !== "link") {
1701
+ let parsed;
1702
+ try {
1703
+ parsed = new URL(from);
1704
+ } catch {
1705
+ console.log(chalk.red(` ✗ "${from}" is not a valid URL.`));
1706
+ console.log(chalk.gray(" Pass a full URL, e.g. https://api.example.com, or \"link\"."));
1707
+ process.exit(1);
1708
+ }
1709
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1710
+ console.log(chalk.red(" ✗ The project URL must be http or https."));
1711
+ process.exit(1);
1712
+ }
1713
+ return from;
1714
+ }
1715
+ const link = readLink(findProjectRoot(cwd) ?? cwd);
1716
+ if (!link) {
1717
+ console.log(chalk.red(" ✗ This checkout is not linked to a project."));
1718
+ console.log(chalk.gray(" Run `rebase link <url>`, or pass --from <url>."));
1719
+ process.exit(1);
1720
+ }
1721
+ const apiUrl = link.apiUrl;
1722
+ if (!apiUrl) {
1723
+ console.log(chalk.red(" ✗ The project link has no API URL."));
1724
+ console.log(chalk.gray(" Re-link with `rebase link <url>` to record one."));
1725
+ process.exit(1);
1726
+ }
1727
+ return apiUrl;
1728
+ }
1555
1729
  /**
1556
1730
  * Main entry point for the generate-sdk command.
1557
1731
  */
1558
1732
  async function generateSdkCommand(args) {
1559
1733
  const { collectionsDir, output, cwd } = args;
1734
+ if (args.help) {
1735
+ printSdkHelp();
1736
+ return;
1737
+ }
1560
1738
  const resolvedCollectionsDir = path.isAbsolute(collectionsDir) ? collectionsDir : path.join(cwd, collectionsDir);
1561
1739
  const resolvedOutput = path.isAbsolute(output) ? output : path.join(cwd, output);
1562
1740
  console.log("");
1563
1741
  console.log(chalk.bold(" 🔧 Rebase SDK Generator"));
1564
1742
  console.log("");
1565
- console.log(` ${chalk.gray("Collections:")} ${resolvedCollectionsDir}`);
1566
- console.log(` ${chalk.gray("Output:")} ${resolvedOutput}`);
1567
- console.log("");
1568
- console.log(chalk.cyan(" → Loading collection definitions..."));
1569
- const collections = await loadCollections(resolvedCollectionsDir);
1743
+ let collections;
1744
+ let remoteSchemaVersion;
1745
+ if (args.from) {
1746
+ const baseUrl = resolveSchemaSource(args.from, cwd);
1747
+ console.log(` ${chalk.gray("Project:")} ${baseUrl}`);
1748
+ console.log(` ${chalk.gray("Output:")} ${resolvedOutput}`);
1749
+ console.log("");
1750
+ console.log(chalk.cyan(" → Fetching the project contract..."));
1751
+ const ambient = mayUseAmbientKey(baseUrl, cwd) ? process.env.REBASE_SERVICE_KEY : void 0;
1752
+ 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)"));
1753
+ const remote = await fetchRemoteCollections(baseUrl, args.token || ambient);
1754
+ collections = remote.collections;
1755
+ remoteSchemaVersion = remote.schemaVersion;
1756
+ } else {
1757
+ console.log(` ${chalk.gray("Collections:")} ${resolvedCollectionsDir}`);
1758
+ console.log(` ${chalk.gray("Output:")} ${resolvedOutput}`);
1759
+ console.log("");
1760
+ console.log(chalk.cyan(" → Loading collection definitions..."));
1761
+ collections = await loadCollections(resolvedCollectionsDir);
1762
+ }
1570
1763
  collections.sort((a, b) => a.slug.localeCompare(b.slug));
1571
1764
  if (collections.length === 0) {
1572
1765
  console.log(chalk.red(" ✗ No collections found. Nothing to generate."));
@@ -1576,7 +1769,22 @@ async function generateSdkCommand(args) {
1576
1769
  console.log("");
1577
1770
  console.log(chalk.cyan(" → Generating SDK files..."));
1578
1771
  const files = generateSDK(collections);
1772
+ const schemaVersion = remoteSchemaVersion ?? computeSchemaVersion(collections);
1773
+ files.push({
1774
+ path: "schema.meta.ts",
1775
+ content: `// Auto-generated by \`rebase generate-sdk\`. Do not edit.
1776
+ //
1777
+ // The schema version this SDK was generated from. Compare it against the
1778
+ // project's current version to detect drift:
1779
+ //
1780
+ // curl -s <api-url>/api/meta/schema-version
1781
+ //
1782
+ export const SCHEMA_VERSION = ${JSON.stringify(schemaVersion)};
1783
+ export const GENERATED_AT = ${JSON.stringify((/* @__PURE__ */ new Date()).toISOString())};
1784
+ `
1785
+ });
1579
1786
  console.log(chalk.green(` ✓ Generated ${files.length} file(s)`));
1787
+ console.log(chalk.gray(` schema ${schemaVersion}`));
1580
1788
  console.log(chalk.cyan(` → Writing to ${resolvedOutput}...`));
1581
1789
  writeFiles(resolvedOutput, files);
1582
1790
  console.log("");
@@ -1746,6 +1954,367 @@ ${chalk.green.bold("Examples")}
1746
1954
  `);
1747
1955
  }
1748
1956
  //#endregion
1957
+ //#region src/manifest.ts
1958
+ /**
1959
+ * Loading, validating and synthesizing `rebase.json`.
1960
+ *
1961
+ * The manifest declares *topology*: which runtime major a project targets and
1962
+ * which apps this repository contributes. It is deliberately small — schema,
1963
+ * security rules, hooks and functions stay in TypeScript, where a type system
1964
+ * can check them.
1965
+ *
1966
+ * Two properties matter more than the file format itself:
1967
+ *
1968
+ * - **A missing manifest is never an error.** Every project that exists today
1969
+ * predates this file. One is synthesized from the conventions the template
1970
+ * already follows, so nothing breaks and nobody is forced to migrate.
1971
+ * - **Validation reports every problem at once**, with the path to each. A
1972
+ * config file that surfaces its mistakes one run at a time is a bad config
1973
+ * file.
1974
+ */
1975
+ /** Runtime range written into new manifests. */
1976
+ var CURRENT_RUNTIME_RANGE = "^1";
1977
+ /** Conventional locations, matching what `rebase init` scaffolds. */
1978
+ var DEFAULT_CONFIG_DIR = "config";
1979
+ var DEFAULT_FUNCTIONS_DIR = "backend/functions";
1980
+ var DEFAULT_CRONS_DIR = "backend/crons";
1981
+ var DEFAULT_SCHEMA_FILE = "backend/src/schema.generated.ts";
1982
+ var ManifestError = class extends Error {
1983
+ issues;
1984
+ constructor(message, issues = []) {
1985
+ super(message);
1986
+ this.issues = issues;
1987
+ this.name = "ManifestError";
1988
+ }
1989
+ };
1990
+ var APP_TYPES = [
1991
+ "backend",
1992
+ "static",
1993
+ "admin",
1994
+ "mobile",
1995
+ "custom"
1996
+ ];
1997
+ /** Reserved because they name things in URLs and CLI output. */
1998
+ var RESERVED_APP_NAMES = new Set([
1999
+ "api",
2000
+ "health",
2001
+ "metrics",
2002
+ "livez",
2003
+ "_rebase"
2004
+ ]);
2005
+ function isRecord(value) {
2006
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2007
+ }
2008
+ /**
2009
+ * Reject paths that escape the repository.
2010
+ *
2011
+ * A manifest is committed and reviewed, so this is not a security boundary so
2012
+ * much as a guard against `../../` typos that would otherwise have `rebase build`
2013
+ * writing outside the project.
2014
+ */
2015
+ function checkRelativePath(value, fieldPath, issues, { required }) {
2016
+ if (value === void 0) {
2017
+ if (required) issues.push({
2018
+ path: fieldPath,
2019
+ message: "is required"
2020
+ });
2021
+ return;
2022
+ }
2023
+ if (typeof value !== "string" || value.trim() === "") {
2024
+ issues.push({
2025
+ path: fieldPath,
2026
+ message: "must be a non-empty string"
2027
+ });
2028
+ return;
2029
+ }
2030
+ if (path.isAbsolute(value)) {
2031
+ issues.push({
2032
+ path: fieldPath,
2033
+ message: "must be a relative path, not absolute"
2034
+ });
2035
+ return;
2036
+ }
2037
+ const normalized = path.normalize(value);
2038
+ if (normalized === ".." || normalized.startsWith(`..${path.sep}`)) {
2039
+ issues.push({
2040
+ path: fieldPath,
2041
+ message: "must stay inside the project directory"
2042
+ });
2043
+ return;
2044
+ }
2045
+ return value;
2046
+ }
2047
+ function validateApp(name, raw, issues) {
2048
+ const base = `apps.${name}`;
2049
+ if (!isRecord(raw)) {
2050
+ issues.push({
2051
+ path: base,
2052
+ message: "must be an object"
2053
+ });
2054
+ return;
2055
+ }
2056
+ const type = raw.type;
2057
+ if (typeof type !== "string" || !APP_TYPES.includes(type)) {
2058
+ issues.push({
2059
+ path: `${base}.type`,
2060
+ message: `must be one of: ${APP_TYPES.join(", ")}`
2061
+ });
2062
+ return;
2063
+ }
2064
+ switch (type) {
2065
+ case "backend":
2066
+ checkRelativePath(raw.config, `${base}.config`, issues, { required: false });
2067
+ checkRelativePath(raw.functions, `${base}.functions`, issues, { required: false });
2068
+ checkRelativePath(raw.crons, `${base}.crons`, issues, { required: false });
2069
+ checkRelativePath(raw.schema, `${base}.schema`, issues, { required: false });
2070
+ checkRelativePath(raw.usersCollection, `${base}.usersCollection`, issues, { required: false });
2071
+ if (raw.mode !== void 0 && raw.mode !== "cms" && raw.mode !== "baas") issues.push({
2072
+ path: `${base}.mode`,
2073
+ message: "must be \"cms\" or \"baas\""
2074
+ });
2075
+ return raw;
2076
+ case "static":
2077
+ checkRelativePath(raw.root, `${base}.root`, issues, { required: true });
2078
+ checkRelativePath(raw.output, `${base}.output`, issues, { required: true });
2079
+ if (raw.build !== void 0 && typeof raw.build !== "string") issues.push({
2080
+ path: `${base}.build`,
2081
+ message: "must be a string command"
2082
+ });
2083
+ if (raw.spa !== void 0 && typeof raw.spa !== "boolean") issues.push({
2084
+ path: `${base}.spa`,
2085
+ message: "must be a boolean"
2086
+ });
2087
+ return raw;
2088
+ case "admin": {
2089
+ const mode = raw.mode ?? "hosted";
2090
+ if (mode !== "hosted" && mode !== "bundled") {
2091
+ issues.push({
2092
+ path: `${base}.mode`,
2093
+ message: "must be \"hosted\" or \"bundled\""
2094
+ });
2095
+ return;
2096
+ }
2097
+ if (mode === "bundled") {
2098
+ checkRelativePath(raw.root, `${base}.root`, issues, { required: true });
2099
+ checkRelativePath(raw.output, `${base}.output`, issues, { required: true });
2100
+ }
2101
+ return raw;
2102
+ }
2103
+ case "mobile": {
2104
+ const platform = raw.platform;
2105
+ if (platform !== "ios" && platform !== "android" && platform !== "other") issues.push({
2106
+ path: `${base}.platform`,
2107
+ message: "must be \"ios\", \"android\" or \"other\""
2108
+ });
2109
+ return raw;
2110
+ }
2111
+ case "custom":
2112
+ checkRelativePath(raw.dockerfile, `${base}.dockerfile`, issues, { required: false });
2113
+ checkRelativePath(raw.context, `${base}.context`, issues, { required: false });
2114
+ if (raw.port !== void 0 && (typeof raw.port !== "number" || !Number.isInteger(raw.port))) issues.push({
2115
+ path: `${base}.port`,
2116
+ message: "must be an integer"
2117
+ });
2118
+ return raw;
2119
+ default: return;
2120
+ }
2121
+ }
2122
+ /**
2123
+ * Validate a parsed manifest, collecting every problem.
2124
+ */
2125
+ function validateManifest(raw) {
2126
+ const issues = [];
2127
+ if (!isRecord(raw)) return { issues: [{
2128
+ path: "",
2129
+ message: `${MANIFEST_FILENAME} must contain a JSON object`
2130
+ }] };
2131
+ if (typeof raw.runtime !== "string" || raw.runtime.trim() === "") issues.push({
2132
+ path: "runtime",
2133
+ message: `is required, e.g. "^1"`
2134
+ });
2135
+ if (!isRecord(raw.apps)) {
2136
+ issues.push({
2137
+ path: "apps",
2138
+ message: "is required and must be an object"
2139
+ });
2140
+ return { issues };
2141
+ }
2142
+ const apps = {};
2143
+ let backendCount = 0;
2144
+ for (const [name, value] of Object.entries(raw.apps)) {
2145
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
2146
+ issues.push({
2147
+ path: `apps.${name}`,
2148
+ message: "name must be lowercase alphanumeric with dashes (it appears in URLs)"
2149
+ });
2150
+ continue;
2151
+ }
2152
+ if (RESERVED_APP_NAMES.has(name)) {
2153
+ issues.push({
2154
+ path: `apps.${name}`,
2155
+ message: "name is reserved"
2156
+ });
2157
+ continue;
2158
+ }
2159
+ const app = validateApp(name, value, issues);
2160
+ if (!app) continue;
2161
+ if (app.type === "backend") backendCount++;
2162
+ apps[name] = app;
2163
+ }
2164
+ if (backendCount > 1) issues.push({
2165
+ path: "apps",
2166
+ message: "a project may declare at most one backend app"
2167
+ });
2168
+ if (issues.length > 0) return { issues };
2169
+ return {
2170
+ manifest: {
2171
+ $schema: typeof raw.$schema === "string" ? raw.$schema : void 0,
2172
+ runtime: raw.runtime,
2173
+ apps
2174
+ },
2175
+ issues
2176
+ };
2177
+ }
2178
+ /**
2179
+ * Infer a manifest from a directory that does not have one.
2180
+ *
2181
+ * This mirrors exactly what the template scaffolds, which is what makes adopting
2182
+ * the manifest a no-op for existing projects: the synthesized result is what
2183
+ * they would have written by hand.
2184
+ *
2185
+ * An ejected backend — one with its own `src/index.ts` entrypoint — is reported
2186
+ * as a `custom` app rather than a `backend` app. That is not a downgrade; it is
2187
+ * an accurate description, and it is what keeps such a project deploying exactly
2188
+ * as it does today.
2189
+ */
2190
+ function synthesizeManifest(projectRoot) {
2191
+ const exists = (relative) => fs.existsSync(path.join(projectRoot, relative));
2192
+ const apps = {};
2193
+ const hasConfig = exists(DEFAULT_CONFIG_DIR);
2194
+ const hasBackend = exists("backend");
2195
+ const backendEntry = exists("backend/src/index.ts");
2196
+ if (hasBackend && backendEntry) apps.backend = {
2197
+ type: "custom",
2198
+ dockerfile: exists("backend/Dockerfile") ? "backend/Dockerfile" : void 0,
2199
+ context: "."
2200
+ };
2201
+ else if (hasBackend || hasConfig) {
2202
+ const backend = { type: "backend" };
2203
+ if (!hasConfig) backend.mode = "baas";
2204
+ if (exists("backend/functions")) backend.functions = DEFAULT_FUNCTIONS_DIR;
2205
+ if (exists("backend/crons")) backend.crons = DEFAULT_CRONS_DIR;
2206
+ apps.backend = backend;
2207
+ }
2208
+ if (exists("frontend")) apps.web = {
2209
+ type: "static",
2210
+ root: "frontend",
2211
+ build: "npm run build --workspace frontend",
2212
+ output: "frontend/dist",
2213
+ spa: true
2214
+ };
2215
+ return {
2216
+ runtime: "^1",
2217
+ apps
2218
+ };
2219
+ }
2220
+ function manifestPath(projectRoot) {
2221
+ return path.join(projectRoot, MANIFEST_FILENAME);
2222
+ }
2223
+ function manifestExists(projectRoot) {
2224
+ return fs.existsSync(manifestPath(projectRoot));
2225
+ }
2226
+ /**
2227
+ * Read the manifest, falling back to a synthesized one.
2228
+ *
2229
+ * A malformed manifest throws — unlike a missing one. Silently ignoring a file
2230
+ * the developer wrote, and building something else instead, is the worst
2231
+ * available behaviour.
2232
+ */
2233
+ function loadManifest(projectRoot) {
2234
+ const filePath = manifestPath(projectRoot);
2235
+ if (!fs.existsSync(filePath)) return {
2236
+ manifest: synthesizeManifest(projectRoot),
2237
+ source: "synthesized"
2238
+ };
2239
+ let parsed;
2240
+ try {
2241
+ parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
2242
+ } catch (err) {
2243
+ throw new ManifestError(`${MANIFEST_FILENAME} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
2244
+ }
2245
+ const { manifest, issues } = validateManifest(parsed);
2246
+ if (!manifest) throw new ManifestError(`${MANIFEST_FILENAME} is invalid`, issues);
2247
+ return {
2248
+ manifest,
2249
+ source: "file",
2250
+ filePath
2251
+ };
2252
+ }
2253
+ /** Write a manifest, with a trailing newline so it plays well with other tools. */
2254
+ function writeManifest(projectRoot, manifest) {
2255
+ const filePath = manifestPath(projectRoot);
2256
+ const ordered = {
2257
+ $schema: manifest.$schema ?? "https://rebase.pro/schemas/rebase.json",
2258
+ runtime: manifest.runtime,
2259
+ apps: manifest.apps
2260
+ };
2261
+ fs.writeFileSync(filePath, `${JSON.stringify(ordered, null, 4)}\n`, "utf8");
2262
+ return filePath;
2263
+ }
2264
+ /** Find the single backend app, if this repository declares one. */
2265
+ function findBackendApp(manifest) {
2266
+ for (const [name, app] of Object.entries(manifest.apps)) if (app.type === "backend") return {
2267
+ name,
2268
+ app
2269
+ };
2270
+ }
2271
+ /** Apps that produce build output, in the order they should be built. */
2272
+ function buildableApps(manifest) {
2273
+ const entries = Object.entries(manifest.apps).map(([name, app]) => ({
2274
+ name,
2275
+ app
2276
+ }));
2277
+ const rank = (app) => {
2278
+ if (app.type === "backend") return 0;
2279
+ if (app.type === "admin") return 1;
2280
+ if (app.type === "static") return 2;
2281
+ return 3;
2282
+ };
2283
+ return entries.filter(({ app }) => app.type !== "mobile").sort((a, b) => rank(a.app) - rank(b.app));
2284
+ }
2285
+ /**
2286
+ * Decide whether a project can run on the managed runtime, and say why not.
2287
+ *
2288
+ * "Not eligible" is never a dead end — it selects the custom-runtime path, which
2289
+ * still deploys. The reasons exist so the answer is actionable rather than a
2290
+ * verdict.
2291
+ */
2292
+ function assessManagedCompatibility(manifest) {
2293
+ const reasons = [];
2294
+ const backend = findBackendApp(manifest);
2295
+ if (!backend) {
2296
+ const custom = Object.entries(manifest.apps).find(([, app]) => app.type === "custom");
2297
+ 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.`);
2298
+ else reasons.push("No backend app is declared in this repository. Only the repository that declares the backend selects the runtime.");
2299
+ }
2300
+ for (const [name, app] of Object.entries(manifest.apps)) if (app.type === "custom") reasons.push(`App "${name}" is a custom container image.`);
2301
+ return {
2302
+ eligible: reasons.length === 0 && Boolean(backend),
2303
+ reasons
2304
+ };
2305
+ }
2306
+ /** Resolve a backend app's directories against the conventions it omits. */
2307
+ function resolveBackendPaths(app) {
2308
+ return {
2309
+ config: app.config ?? "config",
2310
+ functions: app.functions ?? "backend/functions",
2311
+ crons: app.crons ?? "backend/crons",
2312
+ schema: app.schema ?? "backend/src/schema.generated.ts",
2313
+ usersCollection: app.usersCollection ?? "collections/users",
2314
+ mode: app.mode ?? "cms"
2315
+ };
2316
+ }
2317
+ //#endregion
1749
2318
  //#region src/commands/dev.ts
1750
2319
  /**
1751
2320
  * CLI command: rebase dev
@@ -1763,6 +2332,66 @@ ${chalk.green.bold("Examples")}
1763
2332
  * Each project gets a deterministic default port derived from the project
1764
2333
  * root path, so multiple Rebase instances never collide.
1765
2334
  */
2335
+ /**
2336
+ * Quote a path for the shell `execa` runs the backend through.
2337
+ *
2338
+ * The dev runtime's path is absolute and therefore contains whatever the
2339
+ * developer's directories are called. Double quotes do not neutralize `$`,
2340
+ * backticks or backslashes in a POSIX shell, so a checkout under a directory
2341
+ * named `$(...)` would execute it. Single quotes disable all expansion; on
2342
+ * Windows, `cmd.exe` performs no such expansion and wants double quotes.
2343
+ */
2344
+ function quoteForShell(value) {
2345
+ if (process.platform === "win32") return `"${value.replace(/"/g, "\\\"")}"`;
2346
+ return `'${value.replace(/'/g, "'\\''")}'`;
2347
+ }
2348
+ /**
2349
+ * Locate the dev runtime shim shipped with the CLI.
2350
+ *
2351
+ * Published under `runtime/` in the package rather than compiled into `dist/`,
2352
+ * because tsx executes it as a file and it must exist on disk at a stable path.
2353
+ */
2354
+ function resolveDevRuntimeEntry() {
2355
+ let dir = path.dirname(fileURLToPath(import.meta.url));
2356
+ for (let i = 0; i < 5; i++) {
2357
+ const candidate = path.join(dir, "runtime", "dev-server.mjs");
2358
+ if (fs.existsSync(candidate)) return candidate;
2359
+ const parent = path.dirname(dir);
2360
+ if (parent === dir) break;
2361
+ dir = parent;
2362
+ }
2363
+ 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.");
2364
+ }
2365
+ /**
2366
+ * Tell the dev runtime where this project keeps its parts.
2367
+ *
2368
+ * Read from `rebase.json` when there is one, so a project that moved its config
2369
+ * directory is honoured; otherwise the conventional layout.
2370
+ */
2371
+ function devRuntimeEnv(projectRoot) {
2372
+ const result = {
2373
+ REBASE_DEV_PROJECT_ROOT: projectRoot,
2374
+ REBASE_DEV_CONFIG: "config",
2375
+ REBASE_DEV_FUNCTIONS: "backend/functions",
2376
+ REBASE_DEV_CRONS: "backend/crons",
2377
+ REBASE_DEV_SCHEMA: "backend/src/schema.generated.ts",
2378
+ REBASE_DEV_MODE: "cms"
2379
+ };
2380
+ try {
2381
+ const backend = findBackendApp(loadManifest(projectRoot).manifest);
2382
+ if (backend) {
2383
+ const paths = resolveBackendPaths(backend.app);
2384
+ result.REBASE_DEV_CONFIG = paths.config;
2385
+ result.REBASE_DEV_FUNCTIONS = paths.functions;
2386
+ result.REBASE_DEV_CRONS = paths.crons;
2387
+ result.REBASE_DEV_SCHEMA = paths.schema;
2388
+ result.REBASE_DEV_MODE = paths.mode;
2389
+ result.REBASE_DEV_APP = backend.name;
2390
+ }
2391
+ } catch {}
2392
+ if (!fs.existsSync(path.join(projectRoot, result.REBASE_DEV_CONFIG))) result.REBASE_DEV_MODE = "baas";
2393
+ return result;
2394
+ }
1766
2395
  /** Well-known filename the backend writes its actual port to. */
1767
2396
  var DEV_PORT_FILENAME = ".rebase-dev-port";
1768
2397
  /**
@@ -2013,11 +2642,15 @@ async function devCommand(rawArgs) {
2013
2642
  });
2014
2643
  }
2015
2644
  }
2645
+ const ejectedEntry = path.join(backendDir, "src", "index.ts");
2646
+ const usesStockRuntime = !fs.existsSync(ejectedEntry);
2647
+ const entryTarget = usesStockRuntime ? resolveDevRuntimeEntry() : "src/index.ts";
2648
+ if (usesStockRuntime) Object.assign(env, devRuntimeEnv(projectRoot));
2016
2649
  const watchArgs = [
2017
2650
  "watch",
2018
2651
  "--conditions",
2019
2652
  "development",
2020
- "src/index.ts"
2653
+ quoteForShell(entryTarget)
2021
2654
  ];
2022
2655
  if (!shouldGenerate) {
2023
2656
  watchArgs.splice(1, 0, `--watch="${path.join("..", "config", "**", "*")}"`);
@@ -2147,44 +2780,1182 @@ ${chalk.green.bold("Description")}
2147
2780
  `);
2148
2781
  }
2149
2782
  //#endregion
2150
- //#region src/commands/build.ts
2783
+ //#region src/bundle.ts
2151
2784
  /**
2152
- * CLI command: rebase build
2785
+ * Building a project bundle.
2153
2786
  *
2154
- * Runs the build script in all workspace packages.
2155
- * Automatically detects the package manager (pnpm or npm) and
2156
- * uses the correct workspace-aware command.
2787
+ * A bundle is the deployable form of a project: compiled collections, functions,
2788
+ * crons and schema, plus a generated manifest describing exactly what it needs
2789
+ * to run. It contains no Dockerfile and no repository — the runtime is supplied
2790
+ * separately, which is what allows a project to be moved onto a patched runtime
2791
+ * without being rebuilt.
2792
+ *
2793
+ * Compilation runs through a generated tsconfig rooted at the project directory,
2794
+ * so the output mirrors the source layout (`config/…`, `backend/functions/…`)
2795
+ * and every path in the manifest is predictable. Letting each workspace package
2796
+ * emit into its own `dist/` would have meant guessing at three different
2797
+ * layouts, since `rootDir` differs between the template flavours.
2157
2798
  */
2158
- async function buildCommand() {
2159
- const projectRoot = requireProjectRoot();
2160
- const pm = detectPackageManager(projectRoot);
2161
- const buildCmd = getPMCommands(pm).runAll("build");
2162
- console.log(`${chalk.bold("Rebase")} — Building all workspaces with ${chalk.cyan(pm)}...\n`);
2799
+ var DEFAULT_BUNDLE_DIR = "dist-bundle";
2800
+ /** Packages whose presence means the bundle cannot run on a stock runtime image. */
2801
+ var KNOWN_NATIVE_PACKAGES = new Set([
2802
+ "sharp",
2803
+ "canvas",
2804
+ "bcrypt",
2805
+ "argon2",
2806
+ "node-sass",
2807
+ "sqlite3",
2808
+ "better-sqlite3",
2809
+ "grpc",
2810
+ "@grpc/grpc-js-native",
2811
+ "re2",
2812
+ "sodium-native",
2813
+ "libpq",
2814
+ "pg-native"
2815
+ ]);
2816
+ /** Dependencies supplied by the runtime image itself, not by the bundle. */
2817
+ var RUNTIME_PROVIDED = new Set([
2818
+ "@rebasepro/server",
2819
+ "@rebasepro/types",
2820
+ "@rebasepro/client",
2821
+ "@rebasepro/common",
2822
+ "@rebasepro/utils",
2823
+ "hono",
2824
+ "@hono/node-server",
2825
+ "typescript",
2826
+ "tsx"
2827
+ ]);
2828
+ function log(options, message) {
2829
+ (options.log ?? ((m) => console.log(m)))(message);
2830
+ }
2831
+ /**
2832
+ * Every `node_modules/@types` directory the project can see.
2833
+ *
2834
+ * Type roots normally resolve by walking up from the tsconfig's own directory,
2835
+ * which breaks here for two reasons: the generated config lives in `.rebase/`,
2836
+ * and a pnpm workspace puts `@types/node` inside the *package* that depends on
2837
+ * it (`config/node_modules/@types`) rather than at the project root. Listing them
2838
+ * explicitly, as absolute paths, sidesteps both.
2839
+ */
2840
+ function discoverTypeRoots(projectRoot) {
2841
+ const candidates = [];
2842
+ for (const relative of [
2843
+ ".",
2844
+ "config",
2845
+ "backend",
2846
+ "frontend"
2847
+ ]) candidates.push(path.join(projectRoot, relative, "node_modules", "@types"));
2848
+ let dir = projectRoot;
2849
+ for (let i = 0; i < 4; i++) {
2850
+ const parent = path.dirname(dir);
2851
+ if (parent === dir) break;
2852
+ candidates.push(path.join(parent, "node_modules", "@types"));
2853
+ dir = parent;
2854
+ }
2855
+ return candidates.filter((candidate) => fs.existsSync(candidate));
2856
+ }
2857
+ /**
2858
+ * Read a tsconfig's own `compilerOptions`.
2859
+ *
2860
+ * Parsed with the project's own TypeScript, because a tsconfig is not JSON: it
2861
+ * permits comments and trailing commas. Hand-rolled comment stripping gets this
2862
+ * wrong in a way that is easy to miss — a `paths` entry like
2863
+ * `"@acme/types/*": ["src/*"]` contains the character sequence that opens a
2864
+ * block comment, so a regex happily eats the rest of the file and the result
2865
+ * parses as *something*, just not the config the developer wrote.
2866
+ *
2867
+ * One level only, and only `paths` is used from it.
2868
+ */
2869
+ async function readCompilerOptions(projectRoot, file) {
2870
+ if (!fs.existsSync(file)) return void 0;
2871
+ const text = fs.readFileSync(file, "utf8");
2163
2872
  try {
2164
- await execa(buildCmd[0], buildCmd.slice(1), {
2165
- cwd: projectRoot,
2166
- stdio: "inherit"
2873
+ const { config } = createRequire(path.join(projectRoot, "package.json"))("typescript").parseConfigFileTextToJson(file, text);
2874
+ return config?.compilerOptions;
2875
+ } catch {
2876
+ try {
2877
+ return JSON.parse(text.replace(/^\s*\/\/.*$/gm, "")).compilerOptions;
2878
+ } catch {
2879
+ return;
2880
+ }
2881
+ }
2882
+ }
2883
+ /**
2884
+ * Drop path aliases that resolve outside the project.
2885
+ *
2886
+ * A monorepo commonly aliases its workspace packages to their **source**
2887
+ * (`"@acme/types": ["packages/types/src/index.ts"]`) so editors jump to real
2888
+ * files. That is right for developing the monorepo and wrong for building a
2889
+ * bundle: it drags foreign `.ts` files into the program, none of which are under
2890
+ * the project's `rootDir`, and the compile fails on files the developer never
2891
+ * asked to build.
2892
+ *
2893
+ * A bundle is built against *installed packages*. Aliases pointing inside the
2894
+ * project are kept, because those are the project's own code.
2895
+ */
2896
+ function filterProjectPaths(baseDir, projectRoot, paths, baseUrl) {
2897
+ const kept = {};
2898
+ const dropped = [];
2899
+ for (const [alias, targets] of Object.entries(paths)) {
2900
+ if (!Array.isArray(targets)) continue;
2901
+ const resolved = targets.map((target) => path.resolve(baseUrl, target));
2902
+ if (resolved.every((target) => {
2903
+ const relative = path.relative(projectRoot, target);
2904
+ return relative === "" || !relative.startsWith("..") && !path.isAbsolute(relative);
2905
+ })) kept[alias] = resolved.map((target) => {
2906
+ return path.relative(baseDir, target).split(path.sep).join("/");
2167
2907
  });
2908
+ else dropped.push(alias);
2909
+ }
2910
+ return {
2911
+ kept,
2912
+ dropped
2913
+ };
2914
+ }
2915
+ /**
2916
+ * Compose the tsconfig used to compile the bundle.
2917
+ *
2918
+ * Extends the config package's own tsconfig when there is one, so the project's
2919
+ * choices about target, JSX and strictness are respected. It has to be `extends`
2920
+ * rather than a copy of `compilerOptions`: TypeScript resolves relative paths
2921
+ * against the file they were written in, so copying a value like
2922
+ * `baseUrl: "../../"` into a config in a different directory silently repoints
2923
+ * it at the wrong place.
2924
+ */
2925
+ async function writeBundleTsconfig(projectRoot, outDir, includes, skipTypeCheck) {
2926
+ const tsconfigDir = path.join(projectRoot, ".rebase");
2927
+ const fromTsconfig = (target) => {
2928
+ return path.relative(tsconfigDir, path.resolve(projectRoot, target)).split(path.sep).join("/");
2929
+ };
2930
+ const configTsconfigPath = path.join(projectRoot, "config", "tsconfig.json");
2931
+ const extendsFrom = fs.existsSync(configTsconfigPath) ? fromTsconfig(path.join("config", "tsconfig.json")) : void 0;
2932
+ let pathOverrides = {};
2933
+ const baseOptions = await readCompilerOptions(projectRoot, configTsconfigPath);
2934
+ if (baseOptions?.paths && typeof baseOptions.paths === "object") {
2935
+ const baseDir = path.dirname(configTsconfigPath);
2936
+ const baseUrl = path.resolve(baseDir, typeof baseOptions.baseUrl === "string" ? baseOptions.baseUrl : ".");
2937
+ const { kept, dropped } = filterProjectPaths(tsconfigDir, projectRoot, baseOptions.paths, baseUrl);
2938
+ pathOverrides = {
2939
+ baseUrl: fromTsconfig("."),
2940
+ paths: kept
2941
+ };
2942
+ 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`));
2943
+ }
2944
+ const compilerOptions = {
2945
+ target: "ES2022",
2946
+ module: "ESNext",
2947
+ moduleResolution: "bundler",
2948
+ lib: ["ES2022"],
2949
+ jsx: "react-jsx",
2950
+ allowSyntheticDefaultImports: true,
2951
+ esModuleInterop: true,
2952
+ resolveJsonModule: true,
2953
+ forceConsistentCasingInFileNames: true,
2954
+ rootDir: fromTsconfig("."),
2955
+ outDir: fromTsconfig(path.relative(projectRoot, outDir) || "."),
2956
+ typeRoots: discoverTypeRoots(projectRoot),
2957
+ ...pathOverrides,
2958
+ declaration: false,
2959
+ declarationMap: false,
2960
+ sourceMap: true,
2961
+ noEmit: false,
2962
+ skipLibCheck: true,
2963
+ allowJs: true,
2964
+ ...skipTypeCheck ? { noCheck: true } : {}
2965
+ };
2966
+ const tsconfig = {
2967
+ ...extendsFrom ? { extends: extendsFrom } : {},
2968
+ compilerOptions,
2969
+ include: includes.map(fromTsconfig),
2970
+ exclude: [
2971
+ "node_modules",
2972
+ "**/*.test.ts",
2973
+ "**/*.spec.ts",
2974
+ "**/dist/**",
2975
+ DEFAULT_BUNDLE_DIR
2976
+ ].map((pattern) => pattern.startsWith("**") ? pattern : fromTsconfig(pattern))
2977
+ };
2978
+ fs.mkdirSync(tsconfigDir, { recursive: true });
2979
+ const tsconfigPath = path.join(tsconfigDir, "tsconfig.bundle.json");
2980
+ fs.writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, 2), "utf8");
2981
+ return tsconfigPath;
2982
+ }
2983
+ /**
2984
+ * Whether the compiled config package exports a `storageAuthorize` hook.
2985
+ *
2986
+ * Recorded in the manifest so a host can refuse a deploy that would enable file
2987
+ * storage with no access model, rather than let the runtime's boot guard turn it
2988
+ * into a crash loop the developer cannot read.
2989
+ *
2990
+ * Read from the *compiled* index, deliberately: that is the exact module the
2991
+ * runtime imports and reads the export off, so this cannot disagree with what
2992
+ * actually happens at boot. It is a textual check rather than an import because
2993
+ * a freshly built bundle cannot resolve its own dependencies until it is
2994
+ * deployed — the same reason schema hashing reads source.
2995
+ *
2996
+ * Errs toward `false`: a missed detection costs a deploy rejection whose message
2997
+ * says exactly how to proceed, while a false positive would hand back the crash
2998
+ * loop this exists to prevent.
2999
+ */
3000
+ function detectStorageAuthorize(compiledConfigDir) {
3001
+ const indexPath = [
3002
+ ".js",
3003
+ ".mjs",
3004
+ ".ts"
3005
+ ].map((ext) => path.join(compiledConfigDir, `index${ext}`)).find((candidate) => fs.existsSync(candidate));
3006
+ if (!indexPath) return false;
3007
+ let source;
3008
+ try {
3009
+ source = fs.readFileSync(indexPath, "utf8");
2168
3010
  } catch {
2169
- console.error(chalk.red("\n✗ Build failed."));
2170
- process.exit(1);
3011
+ return false;
2171
3012
  }
3013
+ if (/\bexport\s+(?:async\s+)?(?:const|let|var|function)\s+storageAuthorize\b/.test(source)) return true;
3014
+ for (const clause of source.matchAll(/\bexport\s*\{([^}]*)\}/g)) if (clause[1].split(",").map((entry) => {
3015
+ const parts = entry.split(/\bas\b/);
3016
+ return parts[parts.length - 1].trim();
3017
+ }).includes("storageAuthorize")) return true;
3018
+ return false;
2172
3019
  }
2173
- //#endregion
2174
- //#region src/commands/start.ts
2175
3020
  /**
2176
- * CLI command: rebase start
3021
+ * Detect native code in the dependency closure.
3022
+ *
3023
+ * Walks declared runtime dependencies breadth-first through `node_modules`,
3024
+ * flagging anything with a `binding.gyp`, a prebuilt `.node` binary, or an
3025
+ * install script that builds one. The managed runtime cannot run these: a
3026
+ * binary compiled for one image will not load in another, and finding that out
3027
+ * at deploy time is far better than in a crash loop.
2177
3028
  *
2178
- * Starts the backend server in production mode.
2179
- * Automatically detects the package manager (pnpm or npm) and
2180
- * runs the start script in the backend workspace.
3029
+ * The walk is bounded. A dependency graph can be enormous, and this is a
3030
+ * heuristic gate whose false negatives are caught at deploy time anyway.
2181
3031
  */
2182
- async function startCommand() {
2183
- const projectRoot = requireProjectRoot();
3032
+ function detectNativeDependencies(projectRoot, declared, limit = 2e3) {
3033
+ const found = [];
3034
+ const seen = /* @__PURE__ */ new Set();
3035
+ const queue = Object.keys(declared);
3036
+ let visited = 0;
3037
+ const searchRoots = [
3038
+ path.join(projectRoot, "node_modules"),
3039
+ path.join(projectRoot, "backend", "node_modules"),
3040
+ path.join(projectRoot, "config", "node_modules")
3041
+ ].filter((dir) => fs.existsSync(dir));
3042
+ while (queue.length > 0 && visited < limit) {
3043
+ const name = queue.shift();
3044
+ if (seen.has(name)) continue;
3045
+ seen.add(name);
3046
+ visited++;
3047
+ if (KNOWN_NATIVE_PACKAGES.has(name)) {
3048
+ found.push({
3049
+ name,
3050
+ reason: "known native module"
3051
+ });
3052
+ continue;
3053
+ }
3054
+ const packageDir = searchRoots.map((root) => path.join(root, ...name.split("/"))).find((dir) => fs.existsSync(path.join(dir, "package.json")));
3055
+ if (!packageDir) continue;
3056
+ let pkg;
3057
+ try {
3058
+ pkg = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf8"));
3059
+ } catch {
3060
+ continue;
3061
+ }
3062
+ if (pkg.gypfile || fs.existsSync(path.join(packageDir, "binding.gyp"))) {
3063
+ found.push({
3064
+ name,
3065
+ reason: "builds a native addon (binding.gyp)"
3066
+ });
3067
+ continue;
3068
+ }
3069
+ const install = `${pkg.scripts?.install ?? ""} ${pkg.scripts?.preinstall ?? ""} ${pkg.scripts?.postinstall ?? ""}`;
3070
+ if (/node-gyp|prebuild|node-pre-gyp|cmake-js/.test(install)) {
3071
+ found.push({
3072
+ name,
3073
+ reason: "install script compiles native code"
3074
+ });
3075
+ continue;
3076
+ }
3077
+ if (hasNodeBinary(packageDir)) {
3078
+ found.push({
3079
+ name,
3080
+ reason: "ships a prebuilt .node binary"
3081
+ });
3082
+ continue;
3083
+ }
3084
+ for (const dep of Object.keys(pkg.dependencies ?? {})) if (!seen.has(dep)) queue.push(dep);
3085
+ }
3086
+ return found;
3087
+ }
3088
+ /** Shallow scan for `.node` binaries — deep enough for the usual `build/Release`. */
3089
+ function hasNodeBinary(dir, depth = 0) {
3090
+ if (depth > 3) return false;
3091
+ let entries;
3092
+ try {
3093
+ entries = fs.readdirSync(dir, { withFileTypes: true });
3094
+ } catch {
3095
+ return false;
3096
+ }
3097
+ for (const entry of entries) {
3098
+ if (entry.isFile() && entry.name.endsWith(".node")) return true;
3099
+ if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== ".bin") {
3100
+ if (hasNodeBinary(path.join(dir, entry.name), depth + 1)) return true;
3101
+ }
3102
+ }
3103
+ return false;
3104
+ }
3105
+ /**
3106
+ * Whether a dependency name resolves to a package *inside this repository* — a
3107
+ * workspace package rather than a registry one.
3108
+ *
3109
+ * The bundle's declared deps are installed with `npm install` from the public
3110
+ * registry beside the bundle at boot. A workspace package is not there, so
3111
+ * declaring it guarantees a boot-time install failure. The most common case is
3112
+ * the standard `config` package: the backend depends on it by name, but it is
3113
+ * *carried in the bundle* (as `entry.config`), so it must never also be an npm
3114
+ * dependency. Projects often express this as a `workspace:` range — caught
3115
+ * separately — but a plain `"*"` against a workspace symlink is just as common
3116
+ * and looks like a registry range, so the symlink is what actually settles it.
3117
+ *
3118
+ * Detection: the installed `node_modules/<name>` is a symlink whose real path is
3119
+ * inside the project and not within a pnpm virtual store (`.pnpm`). That is
3120
+ * exactly a workspace link and nothing else.
3121
+ */
3122
+ function resolvesToWorkspacePackage(projectRoot, name) {
3123
+ let realRoot;
3124
+ try {
3125
+ realRoot = fs.realpathSync(projectRoot);
3126
+ } catch {
3127
+ realRoot = projectRoot;
3128
+ }
3129
+ for (const base of [
3130
+ projectRoot,
3131
+ path.join(projectRoot, "backend"),
3132
+ path.join(projectRoot, "config")
3133
+ ]) {
3134
+ const link = path.join(base, "node_modules", name);
3135
+ try {
3136
+ if (!fs.lstatSync(link).isSymbolicLink()) continue;
3137
+ const real = fs.realpathSync(link);
3138
+ const insideRepo = real.startsWith(realRoot + path.sep);
3139
+ const inStore = real.includes(`${path.sep}.pnpm${path.sep}`) || real.includes(`${path.sep}node_modules${path.sep}`);
3140
+ if (insideRepo && !inStore) return true;
3141
+ } catch {}
3142
+ }
3143
+ return false;
3144
+ }
3145
+ /**
3146
+ * Collect the runtime dependencies a bundle needs installed beside it.
3147
+ *
3148
+ * Packages the runtime image already provides are excluded — reinstalling a
3149
+ * second copy of the server next to the one running the process is at best
3150
+ * wasted space and at worst a version conflict. Workspace packages are excluded
3151
+ * too: they are not on the registry the runtime installs from, and the project's
3152
+ * own config package already travels inside the bundle.
3153
+ */
3154
+ function collectDeclaredDependencies(projectRoot) {
3155
+ const declared = {};
3156
+ for (const relative of [
3157
+ "backend/package.json",
3158
+ "config/package.json",
3159
+ "package.json"
3160
+ ]) {
3161
+ const file = path.join(projectRoot, relative);
3162
+ if (!fs.existsSync(file)) continue;
3163
+ try {
3164
+ const pkg = JSON.parse(fs.readFileSync(file, "utf8"));
3165
+ for (const [name, version] of Object.entries(pkg.dependencies ?? {})) {
3166
+ if (RUNTIME_PROVIDED.has(name)) continue;
3167
+ if (typeof version === "string" && version.startsWith("workspace:")) continue;
3168
+ if (resolvesToWorkspacePackage(projectRoot, name)) continue;
3169
+ declared[name] = version;
3170
+ }
3171
+ } catch {}
3172
+ }
3173
+ return declared;
3174
+ }
3175
+ /**
3176
+ * Rewrite relative import specifiers in emitted JavaScript so Node can resolve them.
3177
+ *
3178
+ * TypeScript deliberately does not touch specifiers: `moduleResolution: "bundler"`
3179
+ * lets a project write `from "./posts"` or `from "./collections"`, and TypeScript
3180
+ * emits them unchanged on the assumption that a bundler will finish the job.
3181
+ * Nothing bundles a Rebase bundle — the runtime imports these files directly with
3182
+ * Node's ESM loader, which requires a full path with an extension and refuses
3183
+ * directory imports outright.
3184
+ *
3185
+ * Without this, adopting the bundle would mean asking every project written in
3186
+ * the (extremely common) extensionless style to rewrite all of its imports. The
3187
+ * rewrite is mechanical and verifiable: only relative specifiers are touched, and
3188
+ * only when the target file actually exists on disk.
3189
+ */
3190
+ function normalizeEsmSpecifiers(outDir) {
3191
+ const unresolved = [];
3192
+ let rewritten = 0;
3193
+ const SPECIFIER = /(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(["'])(\.[^"']*)\2/g;
3194
+ const walk = (dir) => {
3195
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
3196
+ const full = path.join(dir, entry.name);
3197
+ if (entry.isDirectory()) {
3198
+ if (entry.name === "node_modules") continue;
3199
+ walk(full);
3200
+ } else if (entry.isFile() && entry.name.endsWith(".js")) rewriteFile(full);
3201
+ }
3202
+ };
3203
+ const rewriteFile = (file) => {
3204
+ const original = fs.readFileSync(file, "utf8");
3205
+ const dir = path.dirname(file);
3206
+ const updated = original.replace(SPECIFIER, (match, prefix, quote, specifier) => {
3207
+ if (/\.(js|mjs|cjs|json|node)$/.test(specifier)) return match;
3208
+ const target = path.resolve(dir, specifier);
3209
+ if (fs.existsSync(`${target}.js`)) {
3210
+ rewritten++;
3211
+ return `${prefix}${quote}${specifier}.js${quote}`;
3212
+ }
3213
+ if (fs.existsSync(path.join(target, "index.js"))) {
3214
+ rewritten++;
3215
+ return `${prefix}${quote}${specifier}${specifier.endsWith("/") ? "index.js" : "/index.js"}${quote}`;
3216
+ }
3217
+ if (specifier.endsWith(".ts") && fs.existsSync(`${target.slice(0, -3)}.js`)) {
3218
+ rewritten++;
3219
+ return `${prefix}${quote}${specifier.slice(0, -3)}.js${quote}`;
3220
+ }
3221
+ unresolved.push(`${path.basename(file)} → ${specifier}`);
3222
+ return match;
3223
+ });
3224
+ if (updated !== original) fs.writeFileSync(file, updated, "utf8");
3225
+ };
3226
+ if (fs.existsSync(outDir)) walk(outDir);
3227
+ return {
3228
+ rewritten,
3229
+ unresolved
3230
+ };
3231
+ }
3232
+ /**
3233
+ * Remove a previous build so stale output cannot masquerade as current.
3234
+ *
3235
+ * The containment check matters because this is a recursive force-delete of a
3236
+ * path that came from a command-line flag: `rebase build --out ../..` would
3237
+ * otherwise erase the parent of the project. The manifest's own paths are
3238
+ * checked the same way; a flag deserves no less.
3239
+ */
3240
+ function cleanOutDir(projectRoot, outDir) {
3241
+ const relative = path.relative(projectRoot, outDir);
3242
+ if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Refusing to build into "${outDir}": the output directory must be inside the project.`);
3243
+ if (fs.existsSync(outDir)) fs.rmSync(outDir, {
3244
+ recursive: true,
3245
+ force: true
3246
+ });
3247
+ fs.mkdirSync(outDir, { recursive: true });
3248
+ }
3249
+ /**
3250
+ * Regenerate the Drizzle schema from the collections.
3251
+ *
3252
+ * Delegated to the database driver's own CLI — the same code `rebase schema
3253
+ * generate` runs — so there is one implementation of what a schema is. When no
3254
+ * driver is resolvable the build continues with a warning rather than failing:
3255
+ * a `baas` project has no schema to generate, and a project mid-install should
3256
+ * get a clear message rather than a hard stop.
3257
+ */
3258
+ async function regenerateSchema(projectRoot, configDir, options) {
3259
+ const backendDir = path.join(projectRoot, "backend");
3260
+ if (!fs.existsSync(backendDir)) return;
3261
+ const plugin = getActiveBackendPlugin(backendDir);
3262
+ const script = plugin ? resolvePluginCliScript(backendDir, plugin) : null;
3263
+ if (!script) {
3264
+ log(options, chalk.dim(" (no database driver found — skipping schema generation)"));
3265
+ return;
3266
+ }
3267
+ const runner = script.endsWith(".ts") ? resolveTsx(projectRoot) : "node";
3268
+ if (!runner) {
3269
+ log(options, chalk.dim(" (tsx not installed — skipping schema generation)"));
3270
+ return;
3271
+ }
3272
+ const collectionsPath = path.join("..", configDir, "collections");
3273
+ try {
3274
+ await execa(runner, [
3275
+ script,
3276
+ "schema",
3277
+ "generate",
3278
+ "--collections",
3279
+ collectionsPath
3280
+ ], {
3281
+ cwd: backendDir,
3282
+ stdio: "pipe"
3283
+ });
3284
+ log(options, chalk.dim(" regenerated database schema from collections"));
3285
+ } catch (err) {
3286
+ const detail = err instanceof Error ? err.message : String(err);
3287
+ 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.`);
3288
+ }
3289
+ }
3290
+ /**
3291
+ * Compile and assemble a bundle.
3292
+ */
3293
+ async function buildBundle(options) {
3294
+ const { projectRoot, app, appName } = options;
3295
+ const paths = resolveBackendPaths(app);
3296
+ const outDir = path.resolve(projectRoot, options.outDir ?? "dist-bundle");
3297
+ const includes = [];
3298
+ const addIfExists = (relative, pattern) => {
3299
+ if (fs.existsSync(path.join(projectRoot, relative))) includes.push(pattern);
3300
+ };
3301
+ if (paths.mode === "cms") addIfExists(paths.config, `${paths.config}/**/*.ts`);
3302
+ addIfExists(paths.functions, `${paths.functions}/**/*.ts`);
3303
+ addIfExists(paths.crons, `${paths.crons}/**/*.ts`);
3304
+ if (fs.existsSync(path.join(projectRoot, paths.schema))) includes.push(paths.schema);
3305
+ 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}".`);
3306
+ if (paths.mode === "cms" && options.skipSchema !== true) await regenerateSchema(projectRoot, paths.config, options);
3307
+ log(options, chalk.dim(` compiling ${includes.length} source group(s) → ${path.relative(projectRoot, outDir)}/`));
3308
+ cleanOutDir(projectRoot, outDir);
3309
+ const tsconfigPath = await writeBundleTsconfig(projectRoot, outDir, includes, options.skipTypeCheck === true);
3310
+ const tsc = resolveLocalBin(projectRoot, "tsc");
3311
+ if (!tsc) throw new Error("TypeScript is not installed in this project. Run your package manager's install first.");
3312
+ try {
3313
+ await execa(tsc, ["-p", tsconfigPath], {
3314
+ cwd: projectRoot,
3315
+ stdio: "inherit"
3316
+ });
3317
+ } catch {
3318
+ throw new Error("TypeScript compilation failed — the bundle was not written.");
3319
+ }
3320
+ const normalized = normalizeEsmSpecifiers(outDir);
3321
+ if (normalized.rewritten > 0) log(options, chalk.dim(` resolved ${normalized.rewritten} relative import(s) for Node ESM`));
3322
+ if (normalized.unresolved.length > 0) {
3323
+ console.log(chalk.yellow(` ⚠ ${normalized.unresolved.length} import(s) could not be resolved to a file:`));
3324
+ for (const item of normalized.unresolved.slice(0, 5)) console.log(chalk.dim(` ${item}`));
3325
+ if (normalized.unresolved.length > 5) console.log(chalk.dim(` … and ${normalized.unresolved.length - 5} more`));
3326
+ }
3327
+ const compiledConfigDir = path.join(outDir, paths.config);
3328
+ const compiledCollectionsDir = path.join(compiledConfigDir, "collections");
3329
+ let collections = [];
3330
+ if (paths.mode === "cms") {
3331
+ collections = await loadSourceCollections(path.join(projectRoot, paths.config, "collections"));
3332
+ 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.`);
3333
+ if (!fs.existsSync(compiledCollectionsDir)) throw new Error(`Compilation produced no collections directory at ${path.relative(projectRoot, compiledCollectionsDir)}.`);
3334
+ }
3335
+ const declared = collectDeclaredDependencies(projectRoot);
3336
+ const nativeModules = detectNativeDependencies(projectRoot, declared);
3337
+ const declaresStorageAuthorize = detectStorageAuthorize(path.join(outDir, paths.config));
3338
+ const schemaOut = paths.schema.replace(/\.ts$/, ".js");
3339
+ const relative = (target) => fs.existsSync(path.join(outDir, target)) ? target : void 0;
3340
+ const manifest = {
3341
+ bundleFormat: BUNDLE_FORMAT_VERSION,
3342
+ runtime: {
3343
+ range: options.runtimeRange,
3344
+ builtAgainst: resolveServerVersion(projectRoot),
3345
+ contract: RUNTIME_CONTRACT_VERSION
3346
+ },
3347
+ schemaVersion: paths.mode === "baas" ? "" : computeSchemaVersion(collections),
3348
+ app: appName,
3349
+ mode: paths.mode,
3350
+ entry: {
3351
+ config: paths.mode === "cms" ? relative(paths.config) : void 0,
3352
+ collections: paths.mode === "cms" ? relative(path.join(paths.config, "collections")) : void 0,
3353
+ functions: relative(paths.functions),
3354
+ crons: relative(paths.crons),
3355
+ schema: relative(schemaOut),
3356
+ usersCollection: paths.mode === "cms" ? relative(path.join(paths.config, `${paths.usersCollection}.js`)) : void 0
3357
+ },
3358
+ collections: collections.map((collection) => collection.slug).filter((slug) => Boolean(slug)).sort(),
3359
+ hooks: {
3360
+ native: nativeModules.length > 0,
3361
+ nativeModules: nativeModules.length > 0 ? nativeModules : void 0
3362
+ },
3363
+ storage: { authorize: declaresStorageAuthorize },
3364
+ deps: { declared },
3365
+ build: {
3366
+ cli: resolveCliVersion(),
3367
+ node: process.versions.node.split(".")[0],
3368
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
3369
+ }
3370
+ };
3371
+ fs.writeFileSync(path.join(outDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
3372
+ fs.writeFileSync(path.join(outDir, "package.json"), `${JSON.stringify({
3373
+ name: "rebase-bundle",
3374
+ private: true,
3375
+ type: "module",
3376
+ dependencies: declared
3377
+ }, null, 2)}\n`, "utf8");
3378
+ return {
3379
+ outDir,
3380
+ manifest,
3381
+ collectionCount: collections.length
3382
+ };
3383
+ }
3384
+ /**
3385
+ * Package a built static app (a `static` or bundled-`admin` app) into a bundle.
3386
+ *
3387
+ * A static bundle is the counterpart to a backend bundle: the same shape, the
3388
+ * same runtime image runs it, but its manifest says `mode: "static"` and it
3389
+ * carries only the built assets under `static/`. That is what lets a frontend or
3390
+ * admin app be its own deployable, scalable unit rather than something baked into
3391
+ * the backend container.
3392
+ *
3393
+ * `assetsDir` is the app's built output (e.g. `frontend/dist`), already produced
3394
+ * by its own build command. This copies it into the bundle and writes the
3395
+ * manifest — no compilation, no dependency closure (a static bundle installs
3396
+ * nothing at boot).
3397
+ */
3398
+ /**
3399
+ * Fold a built static app into a backend bundle, so one runtime serves both.
3400
+ *
3401
+ * ## Why this exists
3402
+ *
3403
+ * A managed tenant runs one pod, and `bootFromBundle` on the backend path already
3404
+ * knows how to serve a SPA — it looks for `entry.static` and mounts `serveSPA`
3405
+ * last, behind `REBASE_SERVE_STATIC`. What was missing was anything putting the
3406
+ * assets there.
3407
+ *
3408
+ * The consequence was not subtle. A project whose custom image served its website
3409
+ * at `/` and its API at `/api` — the shape the scaffolded template produces — lost
3410
+ * the website the moment it moved to the managed runtime: the API answered
3411
+ * perfectly and every page 404'd. Managed could not be a drop-in replacement for
3412
+ * custom while the frontend simply vanished.
3413
+ *
3414
+ * Folding restores parity with the container it replaces, which is the only
3415
+ * honest baseline. It is deliberately the FIRST implementation and not the last:
3416
+ * a static app on its own bucket behind a CDN is better for cache behaviour and
3417
+ * lets the frontend deploy independently. But that needs infrastructure that does
3418
+ * not exist yet, and "your site is gone" is not an acceptable state to leave a
3419
+ * project in while it gets built.
3420
+ *
3421
+ * The trade it makes, stated plainly: frontend and backend now deploy together
3422
+ * and the bundle carries the built assets. For a project that was shipping both
3423
+ * in one image already, that is exactly what it had.
3424
+ */
3425
+ function foldStaticIntoBundle(options) {
3426
+ const { bundleDir, assetsDir } = options;
3427
+ const manifestPath = path.join(bundleDir, "manifest.json");
3428
+ if (!fs.existsSync(manifestPath)) throw new Error(`No manifest at ${manifestPath} — build the backend bundle first.`);
3429
+ if (!fs.existsSync(assetsDir)) throw new Error(`No built assets at ${assetsDir}.`);
3430
+ const staticOut = path.join(bundleDir, "static");
3431
+ fs.rmSync(staticOut, {
3432
+ recursive: true,
3433
+ force: true
3434
+ });
3435
+ fs.mkdirSync(staticOut, { recursive: true });
3436
+ fs.cpSync(assetsDir, staticOut, { recursive: true });
3437
+ let fileCount = 0;
3438
+ const count = (dir) => {
3439
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) if (entry.isDirectory()) count(path.join(dir, entry.name));
3440
+ else fileCount++;
3441
+ };
3442
+ count(staticOut);
3443
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
3444
+ manifest.entry = {
3445
+ ...manifest.entry,
3446
+ static: "static"
3447
+ };
3448
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
3449
+ return { fileCount };
3450
+ }
3451
+ function buildStaticBundle(options) {
3452
+ const { projectRoot, appName, assetsDir, outDir, runtimeRange } = options;
3453
+ cleanOutDir(projectRoot, outDir);
3454
+ const staticOut = path.join(outDir, "static");
3455
+ fs.mkdirSync(staticOut, { recursive: true });
3456
+ fs.cpSync(assetsDir, staticOut, { recursive: true });
3457
+ let fileCount = 0;
3458
+ const count = (dir) => {
3459
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) if (entry.isDirectory()) count(path.join(dir, entry.name));
3460
+ else fileCount++;
3461
+ };
3462
+ count(staticOut);
3463
+ const manifest = {
3464
+ bundleFormat: BUNDLE_FORMAT_VERSION,
3465
+ runtime: {
3466
+ range: runtimeRange,
3467
+ builtAgainst: resolveServerVersion(projectRoot),
3468
+ contract: RUNTIME_CONTRACT_VERSION
3469
+ },
3470
+ schemaVersion: "",
3471
+ app: appName,
3472
+ mode: "static",
3473
+ entry: { static: "static" },
3474
+ hooks: { native: false },
3475
+ deps: { declared: {} },
3476
+ build: {
3477
+ cli: resolveCliVersion(),
3478
+ node: process.versions.node.split(".")[0],
3479
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
3480
+ }
3481
+ };
3482
+ fs.writeFileSync(path.join(outDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
3483
+ fs.writeFileSync(path.join(outDir, "package.json"), `${JSON.stringify({
3484
+ name: "rebase-bundle",
3485
+ private: true,
3486
+ type: "module",
3487
+ dependencies: {}
3488
+ }, null, 2)}\n`, "utf8");
3489
+ return {
3490
+ outDir,
3491
+ manifest,
3492
+ fileCount
3493
+ };
3494
+ }
3495
+ /**
3496
+ * Which files in a collections directory are collections.
3497
+ *
3498
+ * Mirrors the runtime loader's rules exactly, and must keep mirroring them: the
3499
+ * set of files counted here decides the schema version, and the runtime decides
3500
+ * what it serves the same way. A divergence would show up as a client that is
3501
+ * permanently "out of date" against a server that agrees with it.
3502
+ *
3503
+ * (`._*` guards macOS AppleDouble files, which look like sources and are not.)
3504
+ */
3505
+ function isCollectionSourceFile(name) {
3506
+ if (name.startsWith(".")) return false;
3507
+ if (name.includes(".test.") || name.includes(".spec.")) return false;
3508
+ if (name.endsWith(".d.ts")) return false;
3509
+ if (name === "index.ts" || name === "index.js") return false;
3510
+ return name.endsWith(".ts") || name.endsWith(".js");
3511
+ }
3512
+ /**
3513
+ * Load collections from **source**, for hashing and for the manifest's slug list.
3514
+ *
3515
+ * Deliberately not the compiled output. A compiled bundle imports its
3516
+ * dependencies from beside itself — that is the whole point of shipping a
3517
+ * `package.json` with it — but at build time nothing has been installed there
3518
+ * yet, and under pnpm the project's own `node_modules` lives one directory per
3519
+ * package, so the emitted files genuinely cannot resolve their imports until
3520
+ * they are deployed.
3521
+ *
3522
+ * Reading source costs nothing in fidelity: compilation erases types, it does
3523
+ * not change the values a collection module exports, so the hash is the same
3524
+ * either way.
3525
+ */
3526
+ async function loadSourceCollections(collectionsDir) {
3527
+ if (!fs.existsSync(collectionsDir)) return [];
3528
+ const { createJiti } = await import("jiti");
3529
+ const jiti = createJiti(path.join(collectionsDir, "index.ts"), {
3530
+ interopDefault: true,
3531
+ esmResolve: true
3532
+ });
3533
+ const files = fs.readdirSync(collectionsDir).filter(isCollectionSourceFile).sort();
3534
+ const collections = [];
3535
+ const failures = [];
3536
+ for (const file of files) try {
3537
+ const mod = await jiti.import(path.join(collectionsDir, file));
3538
+ const collection = mod.default ?? mod;
3539
+ if (collection && typeof collection === "object" && "slug" in collection) collections.push(collection);
3540
+ else failures.push(`${file}: no default-exported collection`);
3541
+ } catch (err) {
3542
+ failures.push(`${file}: ${err instanceof Error ? err.message : String(err)}`);
3543
+ }
3544
+ if (failures.length > 0) throw new Error(`Could not read ${failures.length} collection file(s):\n` + failures.map((f) => ` • ${f}`).join("\n"));
3545
+ return collections;
3546
+ }
3547
+ /** The `@rebasepro/server` version the project resolves — what it was built against. */
3548
+ function resolveServerVersion(projectRoot) {
3549
+ const candidates = [path.join(projectRoot, "node_modules", "@rebasepro", "server", "package.json"), path.join(projectRoot, "backend", "node_modules", "@rebasepro", "server", "package.json")];
3550
+ for (const candidate of candidates) {
3551
+ if (!fs.existsSync(candidate)) continue;
3552
+ try {
3553
+ return JSON.parse(fs.readFileSync(candidate, "utf8")).version;
3554
+ } catch {}
3555
+ }
3556
+ return "unknown";
3557
+ }
3558
+ function resolveCliVersion() {
3559
+ try {
3560
+ let dir = path.dirname(new URL(import.meta.url).pathname);
3561
+ for (let i = 0; i < 5; i++) {
3562
+ const candidate = path.join(dir, "package.json");
3563
+ if (fs.existsSync(candidate)) {
3564
+ const pkg = JSON.parse(fs.readFileSync(candidate, "utf8"));
3565
+ if (pkg.name === "@rebasepro/cli" && pkg.version) return pkg.version;
3566
+ }
3567
+ dir = path.dirname(dir);
3568
+ }
3569
+ } catch {}
3570
+ return "unknown";
3571
+ }
3572
+ //#endregion
3573
+ //#region src/fold-static.ts
3574
+ /**
3575
+ * Folding a project's frontend into its backend bundle.
3576
+ *
3577
+ * Shared by `rebase build` and `rebase cloud deploy` deliberately. It lived in
3578
+ * the build *command* first, and `deploy` rebuilds the bundle itself — so a
3579
+ * deploy silently produced a bundle without the frontend, packed 164 KB where
3580
+ * 39 MB was expected, and the site 404'd on the managed runtime exactly as if
3581
+ * folding had never been written. Two callers building the same artefact must
3582
+ * share the step that completes it.
3583
+ *
3584
+ * Why fold at all: `bootFromBundle` already serves a SPA from `entry.static`
3585
+ * behind `REBASE_SERVE_STATIC` (default on). A managed tenant runs one pod, so
3586
+ * putting the built site in the bundle gives it the shape a custom container
3587
+ * already had — site at `/`, API at `/api` — which is the only honest baseline
3588
+ * for calling the managed runtime a drop-in replacement.
3589
+ */
3590
+ /**
3591
+ * Which static app, if any, should be served by the backend.
3592
+ *
3593
+ * Exactly one `static` app is folded. With several, folding would have to choose,
3594
+ * and silently picking one of two websites is worse than doing nothing — so it
3595
+ * declines and names what it saw. Pure, so the decision is testable without a
3596
+ * filesystem.
3597
+ */
3598
+ function selectFoldableApp(manifest) {
3599
+ const statics = Object.entries(manifest.apps ?? {}).filter(([, app]) => app?.type === "static").map(([name, app]) => ({
3600
+ name,
3601
+ build: app?.build,
3602
+ output: app?.output
3603
+ }));
3604
+ if (statics.length === 0) return {};
3605
+ 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.` };
3606
+ const only = statics[0];
3607
+ if (!only.output) return { reason: `"${only.name}" declares no output directory — not folded in.` };
3608
+ return { app: only };
3609
+ }
3610
+ /**
3611
+ * Build the project's frontend and fold it into the backend bundle.
3612
+ *
3613
+ * Throws rather than exiting, so the caller decides whether a missing frontend
3614
+ * should fail its command — a `build` may reasonably want to stop, and so should
3615
+ * a deploy, but that is not this function's call to make.
3616
+ */
3617
+ async function foldFrontendIntoBundle(options) {
3618
+ const { projectRoot, manifest, bundleDir, skipBuild } = options;
3619
+ const log = options.log ?? ((m) => console.log(m));
3620
+ const { app, reason } = selectFoldableApp(manifest);
3621
+ if (reason) {
3622
+ log(chalk.yellow(` ⚠ ${reason}`));
3623
+ return null;
3624
+ }
3625
+ if (!app) return null;
3626
+ if (app.build && !skipBuild) await execa(app.build, {
3627
+ cwd: projectRoot,
3628
+ stdio: "inherit",
3629
+ shell: true
3630
+ });
3631
+ const assetsDir = path.join(projectRoot, app.output);
3632
+ 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.`);
3633
+ const { fileCount } = foldStaticIntoBundle({
3634
+ bundleDir,
3635
+ assetsDir
3636
+ });
3637
+ return {
3638
+ appName: app.name,
3639
+ fileCount
3640
+ };
3641
+ }
3642
+ //#endregion
3643
+ //#region src/commands/build.ts
3644
+ /**
3645
+ * CLI command: rebase build [app...]
3646
+ *
3647
+ * Builds the apps a repository declares in `rebase.json`.
3648
+ *
3649
+ * For a `backend` app this produces a **bundle** — compiled collections,
3650
+ * functions and schema plus a manifest — which is the artifact the runtime
3651
+ * loads. For `static` and bundled `admin` apps it runs the declared build
3652
+ * command and reports where the output landed.
3653
+ *
3654
+ * A project with no manifest, or one whose backend has been ejected to its own
3655
+ * entrypoint, falls back to the previous behaviour: run every workspace's own
3656
+ * `build` script. Nothing that built before stops building.
3657
+ */
3658
+ function printHelp$3() {
3659
+ console.log(`
3660
+ ${chalk.bold("rebase build")} — build the apps declared in rebase.json
3661
+
3662
+ ${chalk.bold("Usage")}
3663
+ rebase build [app...] Build the named apps (default: all)
3664
+
3665
+ ${chalk.bold("Options")}
3666
+ --out <dir> Bundle output directory (default: ${DEFAULT_BUNDLE_DIR})
3667
+ --skip-type-check Compile without type checking (faster; use for iteration only)
3668
+ --skip-schema Do not regenerate the database schema from collections
3669
+ --legacy Run every workspace's own build script instead
3670
+ -h, --help Show this help
3671
+
3672
+ ${chalk.bold("Examples")}
3673
+ rebase build Build every app in this repository
3674
+ rebase build backend Build only the backend bundle
3675
+ rebase build web Build only the "web" static app
3676
+ `.trim());
3677
+ }
3678
+ async function buildCommand(rawArgs = []) {
3679
+ const args = arg({
3680
+ "--out": String,
3681
+ "--skip-type-check": Boolean,
3682
+ "--skip-schema": Boolean,
3683
+ "--no-static": Boolean,
3684
+ "--skip-static-build": Boolean,
3685
+ "--legacy": Boolean,
3686
+ "--help": Boolean,
3687
+ "-h": "--help"
3688
+ }, {
3689
+ argv: rawArgs.slice(3),
3690
+ permissive: true
3691
+ });
3692
+ if (args["--help"]) {
3693
+ printHelp$3();
3694
+ return;
3695
+ }
3696
+ const projectRoot = requireProjectRoot();
3697
+ if (args["--legacy"]) {
3698
+ await runWorkspaceBuilds(projectRoot);
3699
+ return;
3700
+ }
3701
+ let loaded;
3702
+ try {
3703
+ loaded = loadManifest(projectRoot);
3704
+ } catch (err) {
3705
+ if (err instanceof ManifestError) {
3706
+ console.error(chalk.red(`✗ ${err.message}`));
3707
+ for (const issue of err.issues) console.error(chalk.red(` ${issue.path ? `${issue.path}: ` : ""}${issue.message}`));
3708
+ process.exit(1);
3709
+ }
3710
+ throw err;
3711
+ }
3712
+ const { manifest, source } = loaded;
3713
+ const requested = args._.filter((a) => !a.startsWith("-"));
3714
+ let targets = buildableApps(manifest);
3715
+ if (requested.length > 0) {
3716
+ const known = new Set(targets.map((t) => t.name));
3717
+ const unknown = requested.filter((name) => !known.has(name));
3718
+ if (unknown.length > 0) {
3719
+ console.error(chalk.red(`✗ Unknown app(s): ${unknown.join(", ")}`));
3720
+ console.error(chalk.dim(` This repository declares: ${targets.map((t) => t.name).join(", ") || "(none)"}`));
3721
+ process.exit(1);
3722
+ }
3723
+ targets = targets.filter((t) => requested.includes(t.name));
3724
+ }
3725
+ if (targets.length === 0) {
3726
+ console.log(chalk.yellow("No buildable apps declared. Nothing to do."));
3727
+ return;
3728
+ }
3729
+ if (!findBackendApp(manifest) && source === "synthesized") {
3730
+ console.log(chalk.dim("No rebase.json found — building workspace packages.\n"));
3731
+ await runWorkspaceBuilds(projectRoot);
3732
+ return;
3733
+ }
3734
+ console.log(`${chalk.bold("Rebase")} — building ${targets.length} app(s)\n`);
3735
+ for (const { name, app } of targets) {
3736
+ console.log(chalk.cyan(`▸ ${name}`) + chalk.dim(` (${app.type})`));
3737
+ if (app.type === "backend") {
3738
+ const result = await buildBundle({
3739
+ projectRoot,
3740
+ appName: name,
3741
+ app,
3742
+ outDir: args["--out"],
3743
+ runtimeRange: manifest.runtime,
3744
+ skipTypeCheck: args["--skip-type-check"],
3745
+ skipSchema: args["--skip-schema"]
3746
+ });
3747
+ const rel = path.relative(projectRoot, result.outDir);
3748
+ console.log(chalk.green(` ✓ bundle → ${rel}/`));
3749
+ console.log(chalk.dim(` ${result.collectionCount} collection(s), schema ${result.manifest.schemaVersion}`));
3750
+ if (result.manifest.hooks.native) {
3751
+ const names = (result.manifest.hooks.nativeModules ?? []).map((m) => m.name).join(", ");
3752
+ console.log(chalk.yellow(` ⚠ native dependencies detected: ${names}`));
3753
+ console.log(chalk.dim(" These cannot run on the managed runtime. See `rebase doctor`."));
3754
+ }
3755
+ if (!args["--no-static"]) {
3756
+ const folded = await foldFrontendIntoBundle({
3757
+ projectRoot,
3758
+ manifest,
3759
+ bundleDir: result.outDir,
3760
+ skipBuild: args["--skip-static-build"] === true,
3761
+ log: (m) => console.log(m)
3762
+ }).catch((err) => {
3763
+ console.error(chalk.red(` ✗ ${err instanceof Error ? err.message : String(err)}`));
3764
+ process.exit(1);
3765
+ });
3766
+ if (folded) console.log(chalk.green(` ✓ ${folded.appName} folded in`) + chalk.dim(` (${folded.fileCount} file(s) → served at /)`));
3767
+ }
3768
+ } else if (app.type === "static" || app.type === "admin") await buildAssetApp(projectRoot, name, app, manifest.runtime, args["--out"]);
3769
+ else if (app.type === "custom") console.log(chalk.dim(" custom container — built at deploy time from its Dockerfile"));
3770
+ console.log("");
3771
+ }
3772
+ console.log(chalk.green("✓ Build complete."));
3773
+ }
3774
+ /**
3775
+ * Build a static or bundled-admin app and package it into a static bundle.
3776
+ *
3777
+ * Runs the app's own build command, checks it produced the declared output, then
3778
+ * packages that output into a `static`-mode bundle — the same deployable shape as
3779
+ * a backend bundle, so a frontend or admin app deploys through the identical
3780
+ * path and runs on the identical image, just serving files instead of an API.
3781
+ */
3782
+ async function buildAssetApp(projectRoot, name, app, runtimeRange, outOverride) {
3783
+ const asset = app;
3784
+ if (app.type === "admin" && app.mode !== "bundled") {
3785
+ console.log(chalk.dim(" hosted admin panel — nothing to build"));
3786
+ return;
3787
+ }
3788
+ if (!asset.build) {
3789
+ console.log(chalk.dim(" no build command declared — skipping"));
3790
+ return;
3791
+ }
3792
+ try {
3793
+ await execa(asset.build, {
3794
+ cwd: projectRoot,
3795
+ stdio: "inherit",
3796
+ shell: true
3797
+ });
3798
+ } catch {
3799
+ console.error(chalk.red(` ✗ build command failed for "${name}"`));
3800
+ process.exit(1);
3801
+ }
3802
+ if (!asset.output) {
3803
+ console.log(chalk.yellow(" no output directory declared — built, but nothing to bundle"));
3804
+ return;
3805
+ }
3806
+ const outputPath = path.join(projectRoot, asset.output);
3807
+ if (!fs.existsSync(outputPath)) {
3808
+ console.error(chalk.red(` ✗ declared output "${asset.output}" does not exist after building`));
3809
+ process.exit(1);
3810
+ }
3811
+ const result = buildStaticBundle({
3812
+ projectRoot,
3813
+ appName: name,
3814
+ assetsDir: outputPath,
3815
+ outDir: outOverride ? path.resolve(process.cwd(), outOverride) : path.join(projectRoot, `dist-bundle-${name}`),
3816
+ runtimeRange
3817
+ });
3818
+ const rel = path.relative(projectRoot, result.outDir);
3819
+ console.log(chalk.green(` ✓ static bundle → ${rel}/`) + chalk.dim(` (${result.fileCount} file(s))`));
3820
+ }
3821
+ /** The pre-manifest behaviour: build every workspace package. */
3822
+ async function runWorkspaceBuilds(projectRoot) {
3823
+ const pm = detectPackageManager(projectRoot);
3824
+ const buildCmd = getPMCommands(pm).runAll("build");
3825
+ console.log(`${chalk.bold("Rebase")} — Building all workspaces with ${chalk.cyan(pm)}...\n`);
3826
+ try {
3827
+ await execa(buildCmd[0], buildCmd.slice(1), {
3828
+ cwd: projectRoot,
3829
+ stdio: "inherit"
3830
+ });
3831
+ } catch {
3832
+ console.error(chalk.red("\n✗ Build failed."));
3833
+ process.exit(1);
3834
+ }
3835
+ }
3836
+ //#endregion
3837
+ //#region src/commands/start.ts
3838
+ /**
3839
+ * CLI command: rebase start
3840
+ *
3841
+ * Runs a built bundle through the Rebase runtime — the same path the official
3842
+ * container image takes, so what you test locally is what a deployment runs.
3843
+ *
3844
+ * When there is no bundle (an ejected backend, or a project that has not adopted
3845
+ * `rebase.json`) this falls back to the backend workspace's own `start` script,
3846
+ * which is what such a project has always used.
3847
+ */
3848
+ function printHelp$2() {
3849
+ console.log(`
3850
+ ${chalk.bold("rebase start")} — run a built bundle
3851
+
3852
+ ${chalk.bold("Usage")}
3853
+ rebase start [options]
3854
+
3855
+ ${chalk.bold("Options")}
3856
+ --bundle <dir> Bundle directory (default: ${DEFAULT_BUNDLE_DIR})
3857
+ --legacy Run the backend workspace's own start script
3858
+ -h, --help Show this help
3859
+
3860
+ Build first with ${chalk.cyan("rebase build")}.
3861
+ `.trim());
3862
+ }
3863
+ async function startCommand(rawArgs = []) {
3864
+ const args = arg({
3865
+ "--bundle": String,
3866
+ "--legacy": Boolean,
3867
+ "--help": Boolean,
3868
+ "-h": "--help"
3869
+ }, {
3870
+ argv: rawArgs.slice(3),
3871
+ permissive: true
3872
+ });
3873
+ if (args["--help"]) {
3874
+ printHelp$2();
3875
+ return;
3876
+ }
3877
+ const projectRoot = requireProjectRoot();
3878
+ const envFile = findEnvFile(projectRoot);
3879
+ const env = { ...process.env };
3880
+ if (envFile) env.DOTENV_CONFIG_PATH = envFile;
3881
+ const bundleDir = path.resolve(projectRoot, args["--bundle"] ?? "dist-bundle");
3882
+ const hasBundle = fs.existsSync(path.join(bundleDir, "manifest.json"));
3883
+ if (args["--legacy"] || !hasBundle) {
3884
+ if (!args["--legacy"] && !hasBundle) console.log(chalk.dim(`No bundle at ${path.relative(projectRoot, bundleDir)}/ — starting the backend workspace instead.
3885
+ `));
3886
+ await startWorkspaceBackend(projectRoot, env);
3887
+ return;
3888
+ }
3889
+ ensureBundleDependencies(projectRoot, bundleDir);
3890
+ console.log(`${chalk.bold("Rebase")} — starting runtime from ${chalk.cyan(path.relative(projectRoot, bundleDir))}/\n`);
3891
+ if (envFile && fs.existsSync(envFile)) (await import("dotenv")).config({ path: envFile });
3892
+ process.env.REBASE_BUNDLE = bundleDir;
3893
+ try {
3894
+ const { runFromBundle } = await import("@rebasepro/server");
3895
+ await runFromBundle({ bundleDir });
3896
+ } catch (err) {
3897
+ console.error(chalk.red("\n✗ Failed to start the runtime."));
3898
+ console.error(err instanceof Error ? err.message : String(err));
3899
+ process.exit(1);
3900
+ }
3901
+ }
3902
+ /**
3903
+ * Make a bundle's imports resolvable for a local run.
3904
+ *
3905
+ * Node resolves a module by walking up from the *importing file*, so compiled
3906
+ * code sitting in `dist-bundle/` no longer sees the per-package `node_modules`
3907
+ * its source could: pnpm and npm both install a workspace package's
3908
+ * dependencies inside that package, and the bundle is not inside any of them.
3909
+ *
3910
+ * A deployment solves this by installing the bundle's own `package.json` beside
3911
+ * it — that is what the generated `package.json` is for. Locally, doing a second
3912
+ * install to run code whose dependencies are already on disk would be wasteful,
3913
+ * so this links what is already there instead.
3914
+ *
3915
+ * Only ever created when absent, and only under the bundle directory, so a real
3916
+ * install always wins and nothing here is ever uploaded (`rebase build` cleans
3917
+ * the directory and never writes this).
3918
+ */
3919
+ function ensureBundleDependencies(projectRoot, bundleDir) {
3920
+ const target = path.join(bundleDir, "node_modules");
3921
+ if (fs.existsSync(target)) return;
3922
+ const sources = [
3923
+ "backend/node_modules",
3924
+ "config/node_modules",
3925
+ "node_modules"
3926
+ ].map((relative) => path.join(projectRoot, relative)).filter((dir) => fs.existsSync(dir));
3927
+ if (sources.length === 0) return;
3928
+ let linked = 0;
3929
+ fs.mkdirSync(target, { recursive: true });
3930
+ const linkInto = (sourceDir, targetDir) => {
3931
+ let entries;
3932
+ try {
3933
+ entries = fs.readdirSync(sourceDir, { withFileTypes: true });
3934
+ } catch {
3935
+ return;
3936
+ }
3937
+ for (const entry of entries) {
3938
+ if (entry.name === ".bin" || entry.name.startsWith(".")) continue;
3939
+ const from = path.join(sourceDir, entry.name);
3940
+ const to = path.join(targetDir, entry.name);
3941
+ if (entry.name.startsWith("@") && entry.isDirectory()) {
3942
+ fs.mkdirSync(to, { recursive: true });
3943
+ linkInto(from, to);
3944
+ continue;
3945
+ }
3946
+ if (fs.existsSync(to)) continue;
3947
+ try {
3948
+ fs.symlinkSync(fs.realpathSync(from), to, "junction");
3949
+ linked++;
3950
+ } catch {}
3951
+ }
3952
+ };
3953
+ for (const source of sources) linkInto(source, target);
3954
+ 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)
3955
+ `));
3956
+ }
3957
+ async function startWorkspaceBackend(projectRoot, env) {
2184
3958
  const startCmd = getPMCommands(detectPackageManager(projectRoot)).runWorkspace("backend", "start");
2185
- const envFile = findEnvFile(projectRoot);
2186
- const env = { ...process.env };
2187
- if (envFile) env.DOTENV_CONFIG_PATH = envFile;
2188
3959
  console.log(`${chalk.bold("Rebase")} — Starting backend server...\n`);
2189
3960
  try {
2190
3961
  await execa(startCmd[0], startCmd.slice(1), {
@@ -3061,6 +4832,55 @@ async function whoamiCommand(rawArgs) {
3061
4832
  * `link` associates the current directory with a cloud project by writing
3062
4833
  * `.rebase/cloud.json`; deploy/logs/status then operate on it with no flags.
3063
4834
  */
4835
+ /**
4836
+ * Link this checkout straight at a running backend.
4837
+ *
4838
+ * No control plane, no authentication, no project id — just the URL of a Rebase
4839
+ * API. This is what makes the multi-repo workflow available to self-hosters: a
4840
+ * frontend repository links to `https://api.example.com` and then generates its
4841
+ * typed SDK from that project exactly as a cloud-linked repository would.
4842
+ *
4843
+ * The URL is verified before it is written. Recording an unreachable address and
4844
+ * failing later, in a different command, would be a worse experience than
4845
+ * failing here where the user can see what they typed.
4846
+ */
4847
+ async function linkDirect(target, rawArgs) {
4848
+ let base;
4849
+ try {
4850
+ base = new URL(target);
4851
+ } catch {
4852
+ fail(`"${target}" is not a valid URL.`);
4853
+ return;
4854
+ }
4855
+ if (base.protocol !== "http:" && base.protocol !== "https:") fail("A project URL must be http or https.");
4856
+ const apiUrl = base.toString().replace(/\/+$/, "");
4857
+ const probe = `${apiUrl}/api/meta/schema-version`;
4858
+ let reachable = false;
4859
+ let detail = "";
4860
+ try {
4861
+ const response = await fetch(probe, { headers: { accept: "application/json" } });
4862
+ reachable = response.ok;
4863
+ if (!response.ok) detail = `responded ${response.status}`;
4864
+ } catch (err) {
4865
+ detail = err instanceof Error ? err.message : String(err);
4866
+ }
4867
+ if (!reachable) {
4868
+ console.log(chalk.yellow(`⚠ Could not reach ${probe}${detail ? ` (${detail})` : ""}.`));
4869
+ console.log(chalk.dim(" Linking anyway — the server may not be running yet."));
4870
+ console.log(chalk.dim(" It must be a Rebase backend of version 0.11 or newer."));
4871
+ }
4872
+ writeLink({
4873
+ url: apiUrl,
4874
+ projectId: "",
4875
+ apiUrl,
4876
+ mode: "direct",
4877
+ projectName: base.host
4878
+ });
4879
+ success(`Linked to ${apiUrl}`);
4880
+ console.log(chalk.dim(` Written to ${projectLinkPath()}`));
4881
+ console.log("");
4882
+ console.log(`Next: ${chalk.cyan("rebase generate-sdk --from link")}`);
4883
+ }
3064
4884
  async function linkCommand(rawArgs) {
3065
4885
  const args = arg({
3066
4886
  "--project": String,
@@ -3069,6 +4889,11 @@ async function linkCommand(rawArgs) {
3069
4889
  argv: rawArgs.slice(3),
3070
4890
  permissive: true
3071
4891
  });
4892
+ const positional = args._.find((value) => /^https?:\/\//i.test(value));
4893
+ if (positional) {
4894
+ await linkDirect(positional, rawArgs);
4895
+ return;
4896
+ }
3072
4897
  const { client, url } = await requireClient(rawArgs);
3073
4898
  try {
3074
4899
  let project;
@@ -3084,7 +4909,7 @@ async function linkCommand(rawArgs) {
3084
4909
  })).data;
3085
4910
  if (projects.length === 0) fail("No projects found for your account.", `Create one with ${chalk.bold("rebase cloud projects create")}.`);
3086
4911
  const { picked } = await inquirer.prompt([{
3087
- type: "list",
4912
+ type: "select",
3088
4913
  name: "picked",
3089
4914
  message: "Select a project to link:",
3090
4915
  choices: projects.map((p) => ({
@@ -3128,7 +4953,7 @@ async function selectOrgCommand(rawArgs) {
3128
4953
  let chosen = target ? orgs.find((o) => String(o.id) === target || o.slug === target) : void 0;
3129
4954
  if (!chosen && !target) {
3130
4955
  const { picked } = await inquirer.prompt([{
3131
- type: "list",
4956
+ type: "select",
3132
4957
  name: "picked",
3133
4958
  message: "Select the active organization:",
3134
4959
  choices: orgs.map((o) => ({
@@ -3364,6 +5189,121 @@ function fmtDate(value) {
3364
5189
  return isNaN(d.getTime()) ? value : d.toLocaleString();
3365
5190
  }
3366
5191
  //#endregion
5192
+ //#region src/commands/cloud/bundle-deploy.ts
5193
+ /**
5194
+ * Deploying a project as a managed **bundle** rather than a source build.
5195
+ *
5196
+ * `rebase cloud deploy --bundle` builds the bundle, tars it, uploads it to the
5197
+ * control plane's bundle endpoint, and triggers a deploy carrying the bundle id
5198
+ * and its generated manifest. The control plane resolves a runtime from the
5199
+ * manifest's range and runs the platform image with this bundle — the managed
5200
+ * path. A project not in managed mode, or one whose bundle fails intake, is told
5201
+ * so by the control plane; this side just packages and hands it over.
5202
+ *
5203
+ * The pieces here are separated from the network calls so they can be tested: the
5204
+ * manifest read, the tar packaging, and the request body assembly are pure enough
5205
+ * to check without a control plane.
5206
+ */
5207
+ /** Read and shallow-validate a built bundle's manifest. */
5208
+ function readBundleManifest(bundleDir) {
5209
+ const manifestPath = path.join(bundleDir, "manifest.json");
5210
+ if (!fs.existsSync(manifestPath)) throw new Error(`No manifest.json in ${bundleDir}. Run \`rebase build\` first.`);
5211
+ let manifest;
5212
+ try {
5213
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
5214
+ } catch (err) {
5215
+ throw new Error(`${manifestPath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
5216
+ }
5217
+ if (typeof manifest.bundleFormat !== "number" || !manifest.runtime?.range) throw new Error(`${manifestPath} is not a valid bundle manifest.`);
5218
+ return manifest;
5219
+ }
5220
+ /**
5221
+ * Tar a built bundle into a gzipped archive.
5222
+ *
5223
+ * `node_modules` is excluded on purpose: the bundle ships a `package.json`, and
5224
+ * the managed runtime installs the declared dependencies at boot. Uploading an
5225
+ * installed `node_modules` would bloat the archive and could carry a
5226
+ * platform-specific build that will not run on the runtime image.
5227
+ */
5228
+ function packBundle(bundleDir, outPath) {
5229
+ return new Promise((resolve, reject) => {
5230
+ const child = spawn("tar", [
5231
+ "-czf",
5232
+ outPath,
5233
+ "--no-xattrs",
5234
+ "--exclude",
5235
+ "node_modules",
5236
+ "-C",
5237
+ bundleDir,
5238
+ "."
5239
+ ], {
5240
+ stdio: "inherit",
5241
+ env: {
5242
+ ...process.env,
5243
+ COPYFILE_DISABLE: "1"
5244
+ }
5245
+ });
5246
+ child.on("error", reject);
5247
+ child.on("close", (code) => code === 0 ? resolve() : reject(/* @__PURE__ */ new Error(`tar exited ${code}`)));
5248
+ });
5249
+ }
5250
+ /**
5251
+ * Assemble the deploy-trigger body for a bundle deploy.
5252
+ *
5253
+ * The manifest travels with the trigger so the control plane can validate intake
5254
+ * without unpacking the uploaded archive first — a rejection (native deps, no
5255
+ * matching runtime) is then a fast, cheap answer.
5256
+ */
5257
+ function bundleDeployBody(input) {
5258
+ return {
5259
+ projectId: input.projectId,
5260
+ bundleId: input.bundleId,
5261
+ bundleManifest: input.manifest,
5262
+ app: input.app ?? input.manifest.app ?? "backend",
5263
+ client: "cli",
5264
+ frameworkVersion: input.manifest.runtime?.builtAgainst,
5265
+ ...input.declaredApps?.length ? { declaredApps: input.declaredApps } : {},
5266
+ ...input.message ? { message: input.message } : {}
5267
+ };
5268
+ }
5269
+ /**
5270
+ * The apps a project manifest declares.
5271
+ *
5272
+ * A deploy only ever ships ONE app's bundle, so the trigger alone could never
5273
+ * tell the platform that the repository also contains a web frontend and an
5274
+ * admin panel — and the Apps page, whose whole job is to show the set, listed a
5275
+ * single entry called "backend". Sending the declared set fixes that without
5276
+ * pretending the others are deployed: the platform registers them, and their
5277
+ * status says what is actually true.
5278
+ */
5279
+ function declaredAppsFrom(manifest) {
5280
+ const apps = manifest?.apps;
5281
+ if (!apps || typeof apps !== "object") return [];
5282
+ return Object.entries(apps).filter(([name]) => name.trim().length > 0).map(([name, value]) => ({
5283
+ name,
5284
+ type: String(value?.type ?? "custom")
5285
+ }));
5286
+ }
5287
+ /** Upload a bundle archive; returns the control-plane bundle id. */
5288
+ async function uploadBundle(url, token, projectId, tarPath) {
5289
+ const bytes = fs.readFileSync(tarPath);
5290
+ const res = await fetch(`${url}/api/functions/deploy/bundle/upload?projectId=${encodeURIComponent(projectId)}`, {
5291
+ method: "POST",
5292
+ headers: {
5293
+ Authorization: `Bearer ${token}`,
5294
+ "Content-Type": "application/gzip"
5295
+ },
5296
+ body: bytes
5297
+ });
5298
+ if (!res.ok) {
5299
+ const body = await res.text().catch(() => "");
5300
+ throw new Error(`Bundle upload failed (${res.status}): ${body || res.statusText}`);
5301
+ }
5302
+ const data = await res.json();
5303
+ if (!data.bundleId) throw new Error("Bundle upload endpoint did not return a bundle id.");
5304
+ return data.bundleId;
5305
+ }
5306
+ //#endregion
3367
5307
  //#region src/commands/cloud/deploy.ts
3368
5308
  /**
3369
5309
  * `rebase cloud deploy` and `rebase cloud logs`.
@@ -3421,6 +5361,35 @@ async function createSourceTarball(sourceDir) {
3421
5361
  }
3422
5362
  return tarPath;
3423
5363
  }
5364
+ /**
5365
+ * The `@rebasepro/*` version this source directory actually resolves.
5366
+ *
5367
+ * Recorded on the deployment so a row in Deployment History says which
5368
+ * framework build shipped. Nothing else on the platform knows: an app that
5369
+ * links the framework locally pins it at package time, and a silent bump is
5370
+ * invisible afterwards — it has already cost one debugging session.
5371
+ *
5372
+ * `@rebasepro/server` first, because that is what the deployed backend runs;
5373
+ * `@rebasepro/client` is the fallback for a frontend-only bundle. Resolution is
5374
+ * a plain walk up from the source directory rather than `require.resolve`,
5375
+ * which would answer for the CLI's own install tree instead of the app's.
5376
+ *
5377
+ * Best effort by construction: a version that cannot be read is simply not
5378
+ * recorded. Nothing about a deploy should fail over a bookkeeping string.
5379
+ */
5380
+ function resolveFrameworkVersion(sourceDir) {
5381
+ let dir = path.resolve(sourceDir);
5382
+ for (;;) {
5383
+ for (const pkg of ["@rebasepro/server", "@rebasepro/client"]) try {
5384
+ const manifest = path.join(dir, "node_modules", ...pkg.split("/"), "package.json");
5385
+ const version = JSON.parse(fs.readFileSync(manifest, "utf8")).version;
5386
+ if (typeof version === "string" && version.trim() !== "") return version.trim();
5387
+ } catch {}
5388
+ const parent = path.dirname(dir);
5389
+ if (parent === dir) return void 0;
5390
+ dir = parent;
5391
+ }
5392
+ }
3424
5393
  /** Upload a build-context tarball; returns the opaque `source` ref for deploy. */
3425
5394
  async function uploadSource(url, token, projectId, tarPath) {
3426
5395
  const bytes = fs.readFileSync(tarPath);
@@ -3443,16 +5412,116 @@ async function uploadSource(url, token, projectId, tarPath) {
3443
5412
  if (!data.source) fail("Upload endpoint did not return a source reference.");
3444
5413
  return data.source;
3445
5414
  }
5415
+ /**
5416
+ * Build, upload and deploy a project as a managed bundle.
5417
+ *
5418
+ * Builds the backend app into `dist-bundle` (unless one is pointed at with
5419
+ * `--bundle-dir`), packs it without `node_modules`, uploads it, and triggers a
5420
+ * deploy carrying the manifest so the control plane can validate intake fast.
5421
+ */
5422
+ async function deployBundle(opts) {
5423
+ const { client, url, projectId, projectRef } = opts;
5424
+ const projectRoot = requireProjectRoot();
5425
+ let bundleDir = opts.bundleDir ? path.resolve(process.cwd(), opts.bundleDir) : path.join(projectRoot, "dist-bundle");
5426
+ if (!opts.bundleDir) {
5427
+ const loaded = loadManifest(projectRoot);
5428
+ const backend = findBackendApp(loaded.manifest);
5429
+ 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.");
5430
+ console.log(chalk.gray(" Building bundle..."));
5431
+ bundleDir = (await buildBundle({
5432
+ projectRoot,
5433
+ appName: backend.name,
5434
+ app: backend.app,
5435
+ runtimeRange: loaded.manifest.runtime,
5436
+ log: (m) => console.log(chalk.gray(m))
5437
+ })).outDir;
5438
+ try {
5439
+ const folded = await foldFrontendIntoBundle({
5440
+ projectRoot,
5441
+ manifest: loaded.manifest,
5442
+ bundleDir,
5443
+ log: (m) => console.log(m)
5444
+ });
5445
+ if (folded) console.log(chalk.gray(` folded ${folded.appName} in (${folded.fileCount} file(s), served at /)`));
5446
+ } catch (err) {
5447
+ fail(err instanceof Error ? err.message : String(err), "Fix the frontend build, or pass --no-static to deploy the API alone.");
5448
+ }
5449
+ }
5450
+ const manifest = readBundleManifest(bundleDir);
5451
+ if (manifest.hooks?.native) {
5452
+ const names = (manifest.hooks.nativeModules ?? []).map((m) => m.name).join(", ");
5453
+ 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.");
5454
+ }
5455
+ const tarPath = path.join(os.tmpdir(), `rebase-bundle-${Date.now()}.tar.gz`);
5456
+ const token = client.auth.getSession()?.accessToken;
5457
+ if (!token) fail("Not authenticated.", "Run `rebase cloud login`.");
5458
+ let bundleId;
5459
+ try {
5460
+ await packBundle(bundleDir, tarPath);
5461
+ const sizeMb = (fs.statSync(tarPath).size / 1024 / 1024).toFixed(1);
5462
+ console.log(chalk.gray(` Uploading bundle (${sizeMb} MB)...`));
5463
+ bundleId = await uploadBundle(url, token, projectId, tarPath);
5464
+ } catch (e) {
5465
+ fail(e instanceof Error ? e.message : String(e));
5466
+ return;
5467
+ } finally {
5468
+ fs.rmSync(tarPath, { force: true });
5469
+ }
5470
+ console.log("");
5471
+ console.log(` 🚀 Triggering managed deployment for ${chalk.bold(projectRef)} (schema ${manifest.schemaVersion})...`);
5472
+ let declaredApps = [];
5473
+ try {
5474
+ declaredApps = declaredAppsFrom(loadManifest(process.cwd()).manifest);
5475
+ } catch {}
5476
+ const body = bundleDeployBody({
5477
+ projectId,
5478
+ bundleId,
5479
+ manifest,
5480
+ message: opts.message,
5481
+ declaredApps
5482
+ });
5483
+ try {
5484
+ const res = await client.functions.invoke("deploy", body);
5485
+ if (!res?.deployment?.id) fail("Control plane did not return a deployment id.");
5486
+ if (isJsonMode()) printJson({
5487
+ success: true,
5488
+ deploymentId: String(res.deployment.id),
5489
+ managed: res.managed === true
5490
+ });
5491
+ else {
5492
+ console.log(chalk.green(` ✓ Managed deploy started (deployment ${res.deployment.id}).`));
5493
+ console.log(chalk.gray(" Track it with `rebase cloud logs` or in the console."));
5494
+ }
5495
+ } catch (e) {
5496
+ reportError(e, "Managed deploy failed to start");
5497
+ }
5498
+ }
3446
5499
  async function deployCommand(rawArgs, projectRef) {
3447
5500
  const args = arg({
3448
5501
  "--no-follow": Boolean,
3449
- "--source": String
5502
+ "--source": String,
5503
+ "--message": String,
5504
+ "--bundle": Boolean,
5505
+ "--bundle-dir": String,
5506
+ "-m": "--message"
3450
5507
  }, {
3451
5508
  argv: rawArgs.slice(2),
3452
5509
  permissive: true
3453
5510
  });
3454
5511
  const { client, url } = await requireClient(rawArgs);
3455
5512
  const projectId = await resolveProjectRef(projectRef, client);
5513
+ if (args["--bundle"]) {
5514
+ if (args["--source"]) fail("--bundle and --source cannot be combined: one is a managed bundle, the other a source build.");
5515
+ await deployBundle({
5516
+ client,
5517
+ url,
5518
+ projectId,
5519
+ projectRef,
5520
+ bundleDir: args["--bundle-dir"],
5521
+ message: args["--message"]
5522
+ });
5523
+ return;
5524
+ }
3456
5525
  let source;
3457
5526
  if (args["--source"]) {
3458
5527
  const tarPath = await createSourceTarball(args["--source"]);
@@ -3466,32 +5535,87 @@ async function deployCommand(rawArgs, projectRef) {
3466
5535
  }
3467
5536
  console.log("");
3468
5537
  console.log(` 🚀 Triggering deployment for project ${chalk.bold(projectRef)}${source ? " from uploaded source" : ""}...`);
3469
- let deploymentId;
5538
+ const body = { projectId };
5539
+ if (source) body.source = source;
5540
+ if (args["--message"]) body.message = args["--message"];
5541
+ body.client = "cli";
5542
+ const frameworkVersion = resolveFrameworkVersion(args["--source"] ?? process.cwd());
5543
+ if (frameworkVersion) body.frameworkVersion = frameworkVersion;
5544
+ let triggered;
3470
5545
  try {
3471
- const res = await client.functions.invoke("deploy", source ? {
3472
- projectId,
3473
- source
3474
- } : { projectId });
5546
+ const res = await client.functions.invoke("deploy", body);
3475
5547
  if (!res?.deployment?.id) fail("Control plane did not return a deployment id.");
3476
- deploymentId = String(res.deployment.id);
5548
+ triggered = {
5549
+ deploymentId: String(res.deployment.id),
5550
+ deduplicated: res.deduplicated === true
5551
+ };
3477
5552
  } catch (e) {
3478
- const err = e;
3479
- if (err?.status === 409) fail("A deployment is already in progress for this project.");
3480
- if (err?.status === 402) fail(err.message || "Payment required before deploying.", "Attach a card once with `rebase cloud billing setup`, then deploy again.");
3481
- reportError(e, "Failed to trigger deployment");
5553
+ triggered = resolveTriggerFailure(e);
3482
5554
  }
3483
- console.log(chalk.gray(` Deployment ${deploymentId} created.`));
5555
+ const { deploymentId, deduplicated } = triggered;
5556
+ if (!isJsonMode()) console.log(chalk.gray(deduplicated ? ` Deployment ${deploymentId} is already running — following it.` : ` Deployment ${deploymentId} created.${frameworkVersion ? ` (@rebasepro/* ${frameworkVersion})` : ""}`));
3484
5557
  if (args["--no-follow"]) {
3485
- console.log(chalk.gray(" Not following logs (--no-follow). Check status with `rebase cloud logs`."));
3486
- console.log("");
5558
+ emit(() => {
5559
+ console.log(chalk.gray(" Not following logs (--no-follow). Check status with `rebase cloud logs`."));
5560
+ console.log("");
5561
+ }, {
5562
+ deploymentId,
5563
+ deduplicated,
5564
+ frameworkVersion: frameworkVersion ?? null,
5565
+ following: false
5566
+ });
3487
5567
  return;
3488
5568
  }
3489
- console.log(chalk.gray(" Streaming build logs (Ctrl-C to stop watching — the build keeps running):"));
3490
- console.log("");
3491
- await streamBuildLogs(client, deploymentId);
5569
+ if (!isJsonMode()) {
5570
+ console.log(chalk.gray(" Streaming build logs (Ctrl-C to stop watching — the build keeps running):"));
5571
+ console.log("");
5572
+ }
5573
+ const status = await streamBuildLogs(client, deploymentId, { quiet: isJsonMode() });
5574
+ emit(() => {}, {
5575
+ deploymentId,
5576
+ deduplicated,
5577
+ frameworkVersion: frameworkVersion ?? null,
5578
+ following: true,
5579
+ status
5580
+ });
5581
+ }
5582
+ /**
5583
+ * Turn a failed trigger into either a deployment to follow, or an exit.
5584
+ *
5585
+ * The 409 is the interesting one. A deploy trigger can reach the control plane
5586
+ * twice without anybody asking twice — the SDK transport replays a request once
5587
+ * after refreshing an expired token, and any lost response has the same effect
5588
+ * — so "a deployment is already in progress" was routinely describing the
5589
+ * deployment this very command had just created. With no id in the message the
5590
+ * only available reading was "someone else is deploying, back off", and the
5591
+ * build stream was lost either way.
5592
+ *
5593
+ * So: if the control plane says the blocking deployment is ours, we attach to
5594
+ * it. If it is not ours, we still name it, because "which one, since when, from
5595
+ * where" is the difference between an actionable refusal and a dead end.
5596
+ */
5597
+ function resolveTriggerFailure(e) {
5598
+ const err = e;
5599
+ if (err?.status === 409) {
5600
+ const blocking = err.details?.deployment;
5601
+ if (blocking?.id && blocking.mine) return {
5602
+ deploymentId: String(blocking.id),
5603
+ deduplicated: true
5604
+ };
5605
+ fail(blocking?.id ? `Deployment ${blocking.id} is already in progress for this project${blocking.triggerSource && blocking.triggerSource !== "unknown" ? `, triggered from the ${blocking.triggerSource}` : ""}${blocking.createdAt ? ` at ${fmtDate(blocking.createdAt)}` : ""}.` : "A deployment is already in progress for this project.", blocking?.id ? `Follow it with \`rebase cloud logs -f\`, or stop it with \`rebase cloud cancel ${blocking.id}\`.` : "Follow it with `rebase cloud logs -f`.", "deploy_in_progress");
5606
+ }
5607
+ if (err?.status === 402) fail(err.message || "Payment required before deploying.", "Attach a card once with `rebase cloud billing setup`, then deploy again.", "payment_required");
5608
+ reportError(e, "Failed to trigger deployment");
3492
5609
  }
3493
- /** Poll a deployment record and print new log output as it arrives. */
3494
- async function streamBuildLogs(client, deploymentId) {
5610
+ /**
5611
+ * Poll a deployment record and print new log output as it arrives. Returns the
5612
+ * terminal status; a non-success still exits non-zero, as it always has.
5613
+ *
5614
+ * `quiet` follows without printing — JSON mode, where the log stream would
5615
+ * corrupt the one object the caller is parsing.
5616
+ */
5617
+ async function streamBuildLogs(client, deploymentId, opts = {}) {
5618
+ const quiet = opts.quiet === true;
3495
5619
  let printed = 0;
3496
5620
  const started = Date.now();
3497
5621
  for (;;) {
@@ -3501,26 +5625,37 @@ async function streamBuildLogs(client, deploymentId) {
3501
5625
  } catch (e) {
3502
5626
  reportError(e, "Failed to read deployment status");
3503
5627
  }
3504
- if (!dep) fail(`Deployment ${deploymentId} disappeared.`);
5628
+ if (!dep) fail(`Deployment ${deploymentId} disappeared.`, void 0, "not_found");
3505
5629
  const logs = dep.logs ?? "";
3506
- if (logs.length > printed) {
3507
- process.stdout.write(logs.slice(printed));
3508
- printed = logs.length;
3509
- }
5630
+ if (!quiet && logs.length > printed) process.stdout.write(logs.slice(printed));
5631
+ printed = logs.length;
3510
5632
  if (dep.status && dep.status !== "deploying") {
3511
- console.log("");
3512
- if (dep.status === "success") console.log(chalk.bold.green(" ✓ Deployment succeeded"));
3513
- else {
5633
+ if (dep.status !== "success") {
5634
+ if (quiet) {
5635
+ printJson({ error: {
5636
+ message: `Deployment ${deploymentId} ${dep.status}.`,
5637
+ code: "deploy_failed",
5638
+ status: null,
5639
+ deploymentId,
5640
+ logs
5641
+ } });
5642
+ process.exit(1);
5643
+ }
5644
+ console.log("");
3514
5645
  console.log(chalk.bold.red(` ✗ Deployment ${dep.status}`));
3515
5646
  console.log("");
3516
5647
  process.exit(1);
3517
5648
  }
3518
- console.log("");
3519
- return;
5649
+ if (!quiet) {
5650
+ console.log("");
5651
+ console.log(chalk.bold.green(" ✓ Deployment succeeded"));
5652
+ console.log("");
5653
+ }
5654
+ return dep.status;
3520
5655
  }
3521
5656
  if (Date.now() - started > POLL_TIMEOUT_MS) {
3522
- console.log("");
3523
- fail("Timed out waiting for the build to finish.", "The deployment may still be running — check `rebase cloud logs`.");
5657
+ if (!quiet) console.log("");
5658
+ fail("Timed out waiting for the build to finish.", "The deployment may still be running — check `rebase cloud logs`.", "timeout");
3524
5659
  }
3525
5660
  await sleep(POLL_INTERVAL_MS);
3526
5661
  }
@@ -3766,7 +5901,7 @@ async function createDatabase(rawArgs) {
3766
5901
  let type = args["--type"];
3767
5902
  if (!type) {
3768
5903
  const { picked } = await inquirer.prompt([{
3769
- type: "list",
5904
+ type: "select",
3770
5905
  name: "picked",
3771
5906
  message: "Database type:",
3772
5907
  choices: [{
@@ -4233,9 +6368,35 @@ function parseEnvAssignment(operands) {
4233
6368
  value: operands[1] ?? ""
4234
6369
  };
4235
6370
  }
6371
+ /**
6372
+ * Prefixes whose variables are read by a BUNDLER at build time, not by the
6373
+ * process at run time.
6374
+ *
6375
+ * These are the ones this command cannot deliver. A project's environment is
6376
+ * applied at rollout — after Kaniko has already built the image — so a
6377
+ * `VITE_API_URL` set here is present in the running container and absent from
6378
+ * the JavaScript that was compiled minutes earlier. Nothing fails: the variable
6379
+ * exists, the deploy succeeds, and the bundle carries `undefined` where the
6380
+ * value should be. The bug then presents in the browser as missing
6381
+ * configuration, which is several steps away from the cause.
6382
+ *
6383
+ * `import.meta.env` inlining is Vite's; `NEXT_PUBLIC_`/`PUBLIC_`/`REACT_APP_`
6384
+ * are the same contract in Next, Astro/SvelteKit and CRA.
6385
+ */
6386
+ var BUILD_TIME_ENV_PREFIXES = [
6387
+ "VITE_",
6388
+ "NEXT_PUBLIC_",
6389
+ "PUBLIC_",
6390
+ "REACT_APP_"
6391
+ ];
6392
+ /** The prefix that makes `key` a build-time variable, or undefined. */
6393
+ function buildTimeEnvPrefix(key) {
6394
+ return BUILD_TIME_ENV_PREFIXES.find((prefix) => key.toUpperCase().startsWith(prefix));
6395
+ }
4236
6396
  async function setEnv(rawArgs) {
4237
6397
  const args = arg({
4238
6398
  "--secret": Boolean,
6399
+ "--force": Boolean,
4239
6400
  "--project": String,
4240
6401
  "-p": "--project"
4241
6402
  }, {
@@ -4247,6 +6408,8 @@ async function setEnv(rawArgs) {
4247
6408
  displayProjectRef(rawArgs);
4248
6409
  const parsed = parseEnvAssignment(cloudPositionals(rawArgs).slice(2));
4249
6410
  if (!parsed || !parsed.key) fail("Usage: rebase cloud env set KEY=VALUE [--secret]", void 0, "usage");
6411
+ const buildTimePrefix = buildTimeEnvPrefix(parsed.key);
6412
+ if (buildTimePrefix && !args["--force"]) fail(`${parsed.key} is read by your bundler at BUILD time, and project variables are applied at rollout — after the image is built. Setting it here would not reach the bundle.`, `Put ${buildTimePrefix}* variables in the source you deploy (a committed .env, or your build config), then \`rebase cloud deploy\`. Pass --force if your build genuinely reads this at run time.`, "build_time_variable");
4250
6413
  const body = {
4251
6414
  key: parsed.key,
4252
6415
  value: parsed.value
@@ -4403,10 +6566,13 @@ ${chalk.green.bold("Commands")}
4403
6566
 
4404
6567
  ${chalk.green.bold("Options")}
4405
6568
  ${chalk.blue("--secret")} Mark a variable write-only ${chalk.gray("(set)")}
6569
+ ${chalk.blue("--force")} Set a build-time key anyway ${chalk.gray("(set)")}
4406
6570
  ${chalk.blue("--json")} Machine-readable output
4407
6571
  ${chalk.blue("--project, -p")} Project slug ${chalk.gray("(defaults to the linked project)")}
4408
6572
 
4409
6573
  ${chalk.gray("Values are encrypted at rest (AES-256-GCM) and only decrypted at deploy time.")}
6574
+ ${chalk.gray("VITE_* / NEXT_PUBLIC_* / PUBLIC_* / REACT_APP_* are read by your bundler at BUILD time;")}
6575
+ ${chalk.gray("these are applied at rollout, after the image is built, so they never reach the bundle.")}
4410
6576
  `);
4411
6577
  }
4412
6578
  function printEnvHelpJson() {
@@ -5002,6 +7168,8 @@ function deploymentView(dep) {
5002
7168
  isRollback: str(dep, "rollbackOf", "rollback_of") !== null,
5003
7169
  rollbackable: isRollbackable(dep),
5004
7170
  trigger: triggerInfo(dep),
7171
+ message: str(dep, "deployMessage", "deploy_message"),
7172
+ frameworkVersion: str(dep, "frameworkVersion", "framework_version"),
5005
7173
  commit: {
5006
7174
  hash: str(dep, "gitCommitHash", "gitCommitHash"),
5007
7175
  message: str(dep, "gitCommitMessage", "gitCommitMessage")
@@ -5015,12 +7183,31 @@ async function fetchDeployments(client, projectId, limit = 100) {
5015
7183
  limit
5016
7184
  })).data;
5017
7185
  }
7186
+ /** Hard ceiling on `--limit`, matching the backend's own page size. */
7187
+ var MAX_DEPLOYMENTS_LIMIT = 100;
7188
+ /** `--limit N`, bounded. A garbage value is a refusal, never a silent default. */
7189
+ function parseDeploymentsLimit(raw) {
7190
+ if (raw === void 0) return 20;
7191
+ if (!Number.isInteger(raw) || raw < 1 || raw > MAX_DEPLOYMENTS_LIMIT) fail(`--limit must be a whole number between 1 and ${MAX_DEPLOYMENTS_LIMIT}.`, void 0, "usage");
7192
+ return raw;
7193
+ }
5018
7194
  async function deploymentsListCommand(rawArgs) {
7195
+ const args = arg({
7196
+ "--limit": Number,
7197
+ "--all": Boolean,
7198
+ "--project": String,
7199
+ "-p": "--project"
7200
+ }, {
7201
+ argv: rawArgs.slice(2),
7202
+ permissive: true
7203
+ });
7204
+ const limit = args["--all"] ? MAX_DEPLOYMENTS_LIMIT : parseDeploymentsLimit(args["--limit"]);
5019
7205
  const { client } = await requireClient(rawArgs);
5020
7206
  const projectId = await requireProject(rawArgs, client);
5021
7207
  const projectRef = displayProjectRef(rawArgs);
5022
7208
  try {
5023
- const views = (await fetchDeployments(client, projectId)).map(deploymentView);
7209
+ const views = (await fetchDeployments(client, projectId, limit)).map(deploymentView);
7210
+ const truncated = views.length === limit;
5024
7211
  emit(() => {
5025
7212
  console.log("");
5026
7213
  console.log(chalk.bold(` 🚀 Deployments — project ${projectRef}`));
@@ -5035,10 +7222,18 @@ async function deploymentsListCommand(rawArgs) {
5035
7222
  const trig = v.trigger.source;
5036
7223
  const roll = v.rollbackable ? chalk.green(" ↺ rollbackable") : "";
5037
7224
  console.log(` ${chalk.gray(`[${v.id}]`)} ${colorStatus(v.status)} ${chalk.gray(String(v.createdAt ?? "—"))} ${dur} ${chalk.gray(trig)}${roll}`);
7225
+ const label = [v.message, v.frameworkVersion ? `@rebasepro/* ${v.frameworkVersion}` : null].filter(Boolean).join(" · ");
7226
+ if (label) console.log(` ${chalk.gray(label)}`);
7227
+ }
7228
+ if (truncated) {
7229
+ console.log("");
7230
+ console.log(chalk.gray(` Showing the ${limit} most recent. Use \`--limit N\` or \`--all\` for more.`));
5038
7231
  }
5039
7232
  console.log("");
5040
7233
  }, {
5041
7234
  projectId,
7235
+ limit,
7236
+ truncated,
5042
7237
  deployments: views
5043
7238
  });
5044
7239
  } catch (e) {
@@ -5938,29 +8133,95 @@ ${chalk.gray("so it works in a deploy script. To restart a workload, use `rebase
5938
8133
  * `rebase cloud` resource subcommands: status, metrics, webhooks, storage,
5939
8134
  * clusters, billing.
5940
8135
  */
8136
+ /**
8137
+ * One line describing this project's storage — or `undefined` when the control
8138
+ * plane could not be asked, which prints as a blank rather than a guess.
8139
+ *
8140
+ * `status` used to render the `storages` row and nothing else, so a project
8141
+ * whose bucket is configured through its own `STORAGE_TYPE`/`S3_*` variables —
8142
+ * the supported path, and the one `mergeStorageEnv` deliberately lets WIN over
8143
+ * the row — was reported as `Storage: none` while its pod logged `Initialized
8144
+ * storage backends count: 1` against a live bucket. Storage is the thing an app
8145
+ * refuses to boot without, so that false negative sends someone off to
8146
+ * provision a bucket they already have. The row is not the answer; the tenant's
8147
+ * resolved environment is, and the control plane computes it with the same two
8148
+ * functions the build log uses.
8149
+ */
8150
+ function describeStorageState(state) {
8151
+ const verdict = state?.effective;
8152
+ if (!verdict?.kind) return void 0;
8153
+ const via = state?.overridden ? chalk.gray(" · from env vars") : "";
8154
+ switch (verdict.kind) {
8155
+ case "durable": return `${chalk.green("durable")}${verdict.summary ? ` · ${verdict.summary}` : ""}${via}`;
8156
+ case "ephemeral": return `${chalk.yellow("ephemeral")} ${chalk.gray("· uploads are lost on restart")}`;
8157
+ case "incomplete": return `${chalk.red("incomplete")} ${chalk.gray(`· missing ${(verdict.missing ?? []).join(", ")}`)}`;
8158
+ case "unrecognized": return `${chalk.red("unrecognized")} ${chalk.gray(`· STORAGE_TYPE=${verdict.storageType ?? "?"}`)}`;
8159
+ default: return;
8160
+ }
8161
+ }
8162
+ /**
8163
+ * One line describing the database.
8164
+ *
8165
+ * `connectionStatus` is written `"untested"` at creation and only ever changed
8166
+ * by `rebase cloud db test`, so `managed (untested)` was reporting the absence
8167
+ * of a manual test as though it were the database's condition — on a project
8168
+ * that had just deployed against it. A never-tested database says only its
8169
+ * type; the verdict appears once there is one.
8170
+ */
8171
+ function describeDatabaseState(db) {
8172
+ if (!db) return void 0;
8173
+ const type = typeof db.type === "string" ? db.type : "database";
8174
+ const connection = db.connectionStatus;
8175
+ if (connection === "connected" || connection === "failed") return `${type} (${colorStatus(connection)})`;
8176
+ return `${type} ${chalk.gray("· not tested (`rebase cloud db test`)")}`;
8177
+ }
5941
8178
  async function statusCommand(rawArgs) {
5942
8179
  const { client, url } = await requireClient(rawArgs);
5943
8180
  const projectId = await requireProject(rawArgs, client);
5944
8181
  try {
5945
8182
  const project = await client.data.collection("projects").findById(projectId);
5946
- if (!project) fail(`Project ${displayProjectRef(rawArgs)} not found.`);
8183
+ if (!project) fail(`Project ${displayProjectRef(rawArgs)} not found.`, void 0, "not_found");
5947
8184
  const [db, storage, deploy, baseDomain] = await Promise.all([
5948
8185
  firstRow(client, "databases", projectId),
5949
- firstRow(client, "storages", projectId),
8186
+ client.functions.invoke("storage-provision", void 0, {
8187
+ method: "GET",
8188
+ path: projectId
8189
+ }).catch(() => void 0),
5950
8190
  latestDeployment(client, projectId),
5951
8191
  fetchTenantBaseDomain(client, url)
5952
8192
  ]);
5953
- console.log("");
5954
- console.log(` ${chalk.bold(project.name ?? project.subdomain ?? "")} ${chalk.gray(`[${project.subdomain ?? displayProjectRef(rawArgs)}]`)} ${colorStatus(project.status)}`);
5955
- console.log("");
5956
- keyValues([
5957
- ["URL", projectHost(project, baseDomain)],
5958
- ["Branch", project.gitBranch],
5959
- ["Last deploy", deploy ? `${colorStatus(deploy.status)} · ${fmtDate(deploy.createdAt)}` : "never"],
5960
- ["Database", db ? `${db.type} (${colorStatus(db.connectionStatus)})` : "none"],
5961
- ["Storage", storage ? `${storage.type} (${colorStatus(storage.status)})` : "none"]
5962
- ]);
5963
- console.log("");
8193
+ const storageLine = describeStorageState(storage);
8194
+ const databaseLine = describeDatabaseState(db);
8195
+ emit(() => {
8196
+ console.log("");
8197
+ console.log(` ${chalk.bold(project.name ?? project.subdomain ?? "")} ${chalk.gray(`[${project.subdomain ?? displayProjectRef(rawArgs)}]`)} ${colorStatus(project.status)}`);
8198
+ console.log("");
8199
+ keyValues([
8200
+ ["URL", projectHost(project, baseDomain)],
8201
+ ["Branch", project.gitBranch],
8202
+ ["Last deploy", deploy ? `${colorStatus(deploy.status)} · ${fmtDate(deploy.createdAt)}` : "never"],
8203
+ ["Database", databaseLine],
8204
+ ["Storage", storageLine]
8205
+ ]);
8206
+ console.log("");
8207
+ }, {
8208
+ projectId: String(project.id),
8209
+ name: project.name ?? null,
8210
+ subdomain: project.subdomain ?? null,
8211
+ status: project.status ?? null,
8212
+ url: projectHost(project, baseDomain) ?? null,
8213
+ branch: project.gitBranch ?? null,
8214
+ lastDeploy: deploy ? {
8215
+ id: String(deploy.id),
8216
+ status: deploy.status ?? null,
8217
+ createdAt: deploy.createdAt ?? null
8218
+ } : null,
8219
+ database: db ? {
8220
+ type: db.type ?? null,
8221
+ connectionStatus: db.connectionStatus ?? null
8222
+ } : null,
8223
+ storage: storage ?? null
8224
+ });
5964
8225
  } catch (e) {
5965
8226
  reportError(e, "Failed to load status");
5966
8227
  }
@@ -6089,8 +8350,9 @@ function printStorageHelp() {
6089
8350
  console.log(chalk.gray(" --region <region> Region"));
6090
8351
  console.log(chalk.gray(" --force-path-style Required by MinIO and some gateways"));
6091
8352
  console.log("");
6092
- console.log(chalk.gray(" Without either, a tenant falls back to the container filesystem and"));
6093
- console.log(chalk.gray(" loses uploaded files on its next restart."));
8353
+ console.log(chalk.gray(" Without either, file storage stays off: uploads are refused with"));
8354
+ console.log(chalk.gray(" 501 STORAGE_NOT_CONFIGURED rather than written to a container"));
8355
+ console.log(chalk.gray(" filesystem that is erased on the next restart."));
6094
8356
  console.log("");
6095
8357
  }
6096
8358
  async function storageCreateCommand(rawArgs) {
@@ -6471,9 +8733,9 @@ ${chalk.green.bold("Projects")}
6471
8733
  ${chalk.blue.bold("projects delete")} ${chalk.gray("[id]")} Delete a project
6472
8734
 
6473
8735
  ${chalk.green.bold("Deploy & observe")}
6474
- ${chalk.blue.bold("deploy")} ${chalk.gray("[--source .]")} Deploy the linked project + stream build logs
8736
+ ${chalk.blue.bold("deploy")} ${chalk.gray("[--source .] [-m msg]")} Deploy the linked project + stream build logs
6475
8737
  ${chalk.blue.bold("logs")} ${chalk.gray("[--runtime] [-f]")} Show build (or runtime) logs
6476
- ${chalk.blue.bold("deployments list")} Deployment history ${chalk.gray("(status, duration, trigger)")}
8738
+ ${chalk.blue.bold("deployments list")} ${chalk.gray("[--limit N|--all]")} Deployment history ${chalk.gray("(status, duration, trigger)")}
6477
8739
  ${chalk.blue.bold("rollback")} ${chalk.gray("[id] [-y]")} Roll back to a successful deploy
6478
8740
  ${chalk.blue.bold("cancel")} ${chalk.gray("[-y]")} Cancel the in-flight build
6479
8741
  ${chalk.blue.bold("start|stop|restart")} ${chalk.gray("[-y]")} Power ops ${chalk.gray("(stop/restart need -y)")}
@@ -6514,6 +8776,199 @@ ${chalk.gray("Docs: https://rebase.pro/docs")}
6514
8776
  `);
6515
8777
  }
6516
8778
  //#endregion
8779
+ //#region src/commands/apps.ts
8780
+ /**
8781
+ * CLI command: rebase apps
8782
+ *
8783
+ * Inspect the apps this repository contributes to a project, adopt a
8784
+ * `rebase.json` for a project that predates it, and print the client bootstrap
8785
+ * an app needs to reach its backend.
8786
+ *
8787
+ * The distinction that runs through all of this: a *repository* declares apps,
8788
+ * a *project* owns them. Two repositories can contribute to the same project and
8789
+ * never know about each other, which is what makes a separate frontend repo — or
8790
+ * a mobile app with no repo relationship at all — an ordinary thing rather than
8791
+ * a special case.
8792
+ */
8793
+ function printHelp$1() {
8794
+ console.log(`
8795
+ ${chalk.bold("rebase apps")} — the apps this repository contributes
8796
+
8797
+ ${chalk.bold("Usage")}
8798
+ rebase apps list List declared apps and their build outputs
8799
+ rebase apps init Write a rebase.json inferred from this project
8800
+ rebase apps config <app> Print the client configuration for an app
8801
+
8802
+ ${chalk.bold("Options")}
8803
+ --json Machine-readable output
8804
+ --force Overwrite an existing rebase.json (apps init)
8805
+ -h, --help Show this help
8806
+ `.trim());
8807
+ }
8808
+ async function appsCommand(subcommand, rawArgs = []) {
8809
+ const args = arg({
8810
+ "--json": Boolean,
8811
+ "--force": Boolean,
8812
+ "--help": Boolean,
8813
+ "-h": "--help"
8814
+ }, {
8815
+ argv: rawArgs.slice(3),
8816
+ permissive: true
8817
+ });
8818
+ if (args["--help"] || !subcommand || subcommand === "--help") {
8819
+ printHelp$1();
8820
+ return;
8821
+ }
8822
+ switch (subcommand) {
8823
+ case "list":
8824
+ await listApps(Boolean(args["--json"]));
8825
+ break;
8826
+ case "init":
8827
+ await initManifest(Boolean(args["--force"]));
8828
+ break;
8829
+ case "config":
8830
+ await printAppConfig(args._[1], Boolean(args["--json"]));
8831
+ break;
8832
+ default:
8833
+ console.error(chalk.red(`Unknown subcommand: ${subcommand}`));
8834
+ console.log("");
8835
+ printHelp$1();
8836
+ process.exit(1);
8837
+ }
8838
+ }
8839
+ function describeApp(app) {
8840
+ switch (app.type) {
8841
+ case "backend": return `config: ${app.config ?? "config"}, mode: ${app.mode ?? "cms"}`;
8842
+ case "static": return `${app.root} → ${app.output}`;
8843
+ case "admin": return app.mode === "bundled" ? `bundled → ${app.output ?? "?"}` : "hosted by the platform";
8844
+ case "mobile": return app.platform;
8845
+ case "custom": return app.dockerfile ?? "Dockerfile";
8846
+ default: return "";
8847
+ }
8848
+ }
8849
+ async function listApps(asJson) {
8850
+ const loaded = loadManifestOrExit(requireProjectRoot());
8851
+ const compatibility = assessManagedCompatibility(loaded.manifest);
8852
+ if (asJson) {
8853
+ console.log(JSON.stringify({
8854
+ source: loaded.source,
8855
+ runtime: loaded.manifest.runtime,
8856
+ apps: loaded.manifest.apps,
8857
+ managed: compatibility
8858
+ }, null, 2));
8859
+ return;
8860
+ }
8861
+ if (loaded.source === "synthesized") {
8862
+ console.log(chalk.dim("No rebase.json — showing the layout inferred from this project."));
8863
+ console.log(chalk.dim(`Run ${chalk.cyan("rebase apps init")} to write it down.\n`));
8864
+ }
8865
+ console.log(chalk.bold(`Runtime ${loaded.manifest.runtime}`));
8866
+ console.log("");
8867
+ const entries = Object.entries(loaded.manifest.apps);
8868
+ if (entries.length === 0) {
8869
+ console.log(chalk.yellow("No apps declared."));
8870
+ return;
8871
+ }
8872
+ const width = Math.max(...entries.map(([name]) => name.length));
8873
+ for (const [name, app] of entries) console.log(` ${chalk.cyan(name.padEnd(width))} ${chalk.dim(app.type.padEnd(8))} ${describeApp(app)}`);
8874
+ console.log("");
8875
+ if (compatibility.eligible) console.log(chalk.green("✓ Eligible for the managed runtime."));
8876
+ else {
8877
+ console.log(chalk.yellow("• Uses the custom runtime:"));
8878
+ for (const reason of compatibility.reasons) console.log(chalk.dim(` ${reason}`));
8879
+ }
8880
+ }
8881
+ async function initManifest(force) {
8882
+ const projectRoot = requireProjectRoot();
8883
+ if (manifestExists(projectRoot) && !force) {
8884
+ console.error(chalk.red("✗ rebase.json already exists."));
8885
+ console.error(chalk.dim(" Pass --force to overwrite it."));
8886
+ process.exit(1);
8887
+ }
8888
+ const manifest = synthesizeManifest(projectRoot);
8889
+ const filePath = writeManifest(projectRoot, manifest);
8890
+ console.log(chalk.green(`✓ Wrote ${path.relative(projectRoot, filePath)}`));
8891
+ console.log("");
8892
+ for (const [name, app] of Object.entries(manifest.apps)) console.log(` ${chalk.cyan(name)} ${chalk.dim(`(${app.type})`)}`);
8893
+ const compatibility = assessManagedCompatibility(manifest);
8894
+ if (!compatibility.eligible) {
8895
+ console.log("");
8896
+ console.log(chalk.yellow("This project will use the custom runtime:"));
8897
+ for (const reason of compatibility.reasons) console.log(chalk.dim(` ${reason}`));
8898
+ }
8899
+ }
8900
+ /**
8901
+ * Print what a client needs to reach this project.
8902
+ *
8903
+ * Never prints a secret. The API URL and an app's publishable identity are meant
8904
+ * to ship inside a client bundle; anything that is not safe there does not belong
8905
+ * in output that will inevitably be pasted into a `.env` that gets committed.
8906
+ */
8907
+ async function printAppConfig(appName, asJson) {
8908
+ const projectRoot = requireProjectRoot();
8909
+ const loaded = loadManifestOrExit(projectRoot);
8910
+ if (!appName) {
8911
+ console.error(chalk.red("✗ Which app? Usage: rebase apps config <app>"));
8912
+ process.exit(1);
8913
+ }
8914
+ const app = loaded.manifest.apps[appName];
8915
+ if (!app) {
8916
+ console.error(chalk.red(`✗ No app named "${appName}" in rebase.json.`));
8917
+ console.error(chalk.dim(` Declared: ${Object.keys(loaded.manifest.apps).join(", ") || "(none)"}`));
8918
+ process.exit(1);
8919
+ }
8920
+ const link = readLink(projectRoot);
8921
+ const apiUrl = resolveApiUrl(projectRoot, link);
8922
+ const config = {
8923
+ app: appName,
8924
+ type: app.type,
8925
+ apiUrl: apiUrl ?? null,
8926
+ project: link?.projectId ?? link?.slug ?? null
8927
+ };
8928
+ if (asJson) {
8929
+ console.log(JSON.stringify(config, null, 2));
8930
+ return;
8931
+ }
8932
+ if (!apiUrl) {
8933
+ console.log(chalk.yellow("This checkout is not linked to a project yet."));
8934
+ console.log(chalk.dim(` Run ${chalk.cyan("rebase link")} (cloud) or ${chalk.cyan("rebase link <url>")} (self-hosted).`));
8935
+ console.log("");
8936
+ }
8937
+ console.log(chalk.bold(`# ${appName}`));
8938
+ console.log("");
8939
+ console.log(`VITE_API_URL=${apiUrl ?? "http://localhost:3001"}`);
8940
+ console.log("");
8941
+ console.log(chalk.dim("Then, in the app:"));
8942
+ console.log(chalk.dim(" const rebase = createRebaseClient({ baseUrl: import.meta.env.VITE_API_URL });"));
8943
+ }
8944
+ /**
8945
+ * Work out the API base URL for this checkout.
8946
+ *
8947
+ * Prefers an explicit link, then the dev server's own record of where it bound.
8948
+ * The dev port is chosen dynamically, so a hardcoded default would be wrong on
8949
+ * any machine running more than one project.
8950
+ */
8951
+ function resolveApiUrl(projectRoot, link) {
8952
+ if (link?.apiUrl) return link.apiUrl;
8953
+ const statePath = path.join(projectRoot, ".rebase", "state.json");
8954
+ if (fs.existsSync(statePath)) try {
8955
+ const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
8956
+ if (state.baseUrl) return state.baseUrl;
8957
+ } catch {}
8958
+ }
8959
+ function loadManifestOrExit(projectRoot) {
8960
+ try {
8961
+ return loadManifest(projectRoot);
8962
+ } catch (err) {
8963
+ if (err instanceof ManifestError) {
8964
+ console.error(chalk.red(`✗ ${err.message}`));
8965
+ for (const issue of err.issues) console.error(chalk.red(` ${issue.path ? `${issue.path}: ` : ""}${issue.message}`));
8966
+ process.exit(1);
8967
+ }
8968
+ throw err;
8969
+ }
8970
+ }
8971
+ //#endregion
6517
8972
  //#region src/cli.ts
6518
8973
  var __filename = fileURLToPath(import.meta.url);
6519
8974
  var __dirname = path.dirname(__filename);
@@ -6551,7 +9006,9 @@ async function entry(args) {
6551
9006
  "doctor",
6552
9007
  "skills",
6553
9008
  "api-keys",
6554
- "cloud"
9009
+ "cloud",
9010
+ "apps",
9011
+ "generate-sdk"
6555
9012
  ].includes(command)) {
6556
9013
  printHelp();
6557
9014
  return;
@@ -6565,8 +9022,12 @@ async function entry(args) {
6565
9022
  const sdkArgs = arg({
6566
9023
  "--collections-dir": String,
6567
9024
  "--output": String,
9025
+ "--from": String,
9026
+ "--token": String,
9027
+ "--help": Boolean,
6568
9028
  "-c": "--collections-dir",
6569
- "-o": "--output"
9029
+ "-o": "--output",
9030
+ "-h": "--help"
6570
9031
  }, {
6571
9032
  argv: args.slice(3),
6572
9033
  permissive: true
@@ -6574,6 +9035,9 @@ async function entry(args) {
6574
9035
  await generateSdkCommand({
6575
9036
  collectionsDir: sdkArgs["--collections-dir"] || "./config/collections",
6576
9037
  output: sdkArgs["--output"] || "./generated/sdk",
9038
+ from: sdkArgs["--from"],
9039
+ token: sdkArgs["--token"],
9040
+ help: sdkArgs["--help"],
6577
9041
  cwd: process.cwd()
6578
9042
  });
6579
9043
  break;
@@ -6588,10 +9052,13 @@ async function entry(args) {
6588
9052
  await devCommand(args);
6589
9053
  break;
6590
9054
  case "build":
6591
- await buildCommand();
9055
+ await buildCommand(args);
6592
9056
  break;
6593
9057
  case "start":
6594
- await startCommand();
9058
+ await startCommand(args);
9059
+ break;
9060
+ case "apps":
9061
+ await appsCommand(effectiveSubcommand, args);
6595
9062
  break;
6596
9063
  case "auth":
6597
9064
  await authCommand(effectiveSubcommand, args);
@@ -6672,6 +9139,6 @@ ${chalk.gray("Documentation: https://rebase.pro/docs")}
6672
9139
  `);
6673
9140
  }
6674
9141
  //#endregion
6675
- export { authCommand, buildCommand, buildInitQuestions, cloudCommand, configureEnvFile, createRebaseApp, dbCommand, detectPackageManager, devCommand, doctorCommand, entry, findBackendDir, findEnvFile, findFrontendDir, findProjectRoot, formatCdTarget, generateSdkCommand, getActiveBackendPlugin, getPMCommands, isPnpmAvailable, printInitHelp, requireBackendDir, requireProjectRoot, resolveLocalBin, resolvePluginCliScript, resolveTsx, schemaCommand, startCommand, validateProjectName, validateTsxInstallation };
9142
+ 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, foldStaticIntoBundle, formatCdTarget, generateSdkCommand, getActiveBackendPlugin, getPMCommands, isPnpmAvailable, loadManifest, manifestExists, manifestPath, normalizeEsmSpecifiers, pnpmAvailabilityFromProbe, printInitHelp, requireBackendDir, requireProjectRoot, resetPnpmAvailabilityCache, resolveBackendPaths, resolveLocalBin, resolvePluginCliScript, resolveTsx, schemaCommand, startCommand, synthesizeManifest, validateManifest, validateProjectName, validateTsxInstallation, writeManifest };
6676
9143
 
6677
9144
  //# sourceMappingURL=index.es.js.map