@rebasepro/cli 0.23.0 → 0.24.1-canary.g19596ef
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -3
- package/bin/rebase.js +4 -1
- package/dist/agent-setup-B31F00DC.js +324 -0
- package/dist/agent-setup-B31F00DC.js.map +1 -0
- package/dist/api-keys-Bl1I9q7M.js +525 -0
- package/dist/api-keys-Bl1I9q7M.js.map +1 -0
- package/dist/apps-CNgQJjg5.js +340 -0
- package/dist/apps-CNgQJjg5.js.map +1 -0
- package/dist/args-BnmsEXvu.js +109 -0
- package/dist/args-BnmsEXvu.js.map +1 -0
- package/dist/atlas-dev-database-D_LP_Woq.js +62 -0
- package/dist/atlas-dev-database-D_LP_Woq.js.map +1 -0
- package/dist/auth-aySHEF45.js +414 -0
- package/dist/auth-aySHEF45.js.map +1 -0
- package/dist/branch-pointer-DvSHBjjz.js +2 -0
- package/dist/build-DIHWIqO4.js +336 -0
- package/dist/build-DIHWIqO4.js.map +1 -0
- package/dist/bundle-CqURVN-K.js +1621 -0
- package/dist/bundle-CqURVN-K.js.map +1 -0
- package/dist/bundle.d.ts +12 -3
- package/dist/cli.d.ts +23 -2
- package/dist/cloud-DjByQlkW.js +9667 -0
- package/dist/cloud-DjByQlkW.js.map +1 -0
- package/dist/collection-drift-BKj_BoMq.js +106 -0
- package/dist/collection-drift-BKj_BoMq.js.map +1 -0
- package/dist/commands/__test_support__/parser-specs.d.ts +13 -0
- package/dist/commands/agent-setup.d.ts +116 -0
- package/dist/commands/api-keys.d.ts +86 -23
- package/dist/commands/apps.d.ts +17 -0
- package/dist/commands/auth.d.ts +7 -0
- package/dist/commands/build.d.ts +5 -1
- package/dist/commands/cloud/auth.d.ts +4 -3
- package/dist/commands/cloud/bundle-deploy.d.ts +45 -0
- package/dist/commands/cloud/context.d.ts +44 -5
- package/dist/commands/cloud/cron.d.ts +53 -0
- package/dist/commands/cloud/debug.d.ts +134 -0
- package/dist/commands/cloud/index.d.ts +89 -12
- package/dist/commands/cloud/token-capabilities.d.ts +76 -0
- package/dist/commands/cloud/tokens.d.ts +23 -0
- package/dist/commands/console.d.ts +12 -0
- package/dist/commands/db.d.ts +79 -25
- package/dist/commands/dev.d.ts +181 -13
- package/dist/commands/doctor.d.ts +5 -0
- package/dist/commands/generate_sdk.d.ts +29 -2
- package/dist/commands/init.d.ts +115 -14
- package/dist/commands/schema.d.ts +6 -0
- package/dist/commands/skills.d.ts +126 -7
- package/dist/commands/start.d.ts +44 -0
- package/dist/commands/storage.d.ts +46 -0
- package/dist/commands/upgrade.d.ts +1 -0
- package/dist/consent-ClRHmcxf.js +76 -0
- package/dist/consent-ClRHmcxf.js.map +1 -0
- package/dist/console-D6SJf8k_.js +102 -0
- package/dist/console-D6SJf8k_.js.map +1 -0
- package/dist/context-BqhRydBg.js +1234 -0
- package/dist/context-BqhRydBg.js.map +1 -0
- package/dist/{daemon-entry-LTFKtpHy.js → daemon-entry-Da-D3rmH.js} +3 -4
- package/dist/daemon-entry-Da-D3rmH.js.map +1 -0
- package/dist/daemon-pzsQwDHa.js +2 -0
- package/dist/db-B7RuNuaQ.js +1312 -0
- package/dist/db-B7RuNuaQ.js.map +1 -0
- package/dist/derive-Dl0z2OWi.js +484 -0
- package/dist/derive-Dl0z2OWi.js.map +1 -0
- package/dist/dev-CYr_WXan.js +1599 -0
- package/dist/dev-CYr_WXan.js.map +1 -0
- package/dist/dev-db/atlas-dev-database.d.ts +32 -0
- package/dist/dev-db/daemon-entry.d.ts +13 -0
- package/dist/dev-db/daemon.d.ts +10 -0
- package/dist/dev-db/last-dev-database.d.ts +41 -0
- package/dist/dev-db/prepare.d.ts +29 -3
- package/dist/dev-db/pull.d.ts +90 -5
- package/dist/dev-db/resolve.d.ts +26 -2
- package/dist/dev-preflight-B-LYyBf1.js +2 -0
- package/dist/dev-server-DG8Tt-uf.js +96 -0
- package/dist/dev-server-DG8Tt-uf.js.map +1 -0
- package/dist/doctor-BP8cZ-7F.js +771 -0
- package/dist/doctor-BP8cZ-7F.js.map +1 -0
- package/dist/eject-DizdoecV.js +335 -0
- package/dist/eject-DizdoecV.js.map +1 -0
- package/dist/fold-static-BC9CYicw.js +201 -0
- package/dist/fold-static-BC9CYicw.js.map +1 -0
- package/dist/fold-static.d.ts +26 -3
- package/dist/function-portability-BIvlvR9q.js +265 -0
- package/dist/function-portability-BIvlvR9q.js.map +1 -0
- package/dist/generate_sdk-BPqdJc3Q.js +489 -0
- package/dist/generate_sdk-BPqdJc3Q.js.map +1 -0
- package/dist/index.es.js +199 -22095
- package/dist/index.es.js.map +1 -1
- package/dist/init-Utstvh2Q.js +1149 -0
- package/dist/init-Utstvh2Q.js.map +1 -0
- package/dist/last-dev-database-B58g89uA.js +2 -0
- package/dist/manifest-DRp0UVTj.js +880 -0
- package/dist/manifest-DRp0UVTj.js.map +1 -0
- package/dist/manifest.d.ts +56 -4
- package/dist/normalize-imports-X0Tgze9y.js +107 -0
- package/dist/normalize-imports-X0Tgze9y.js.map +1 -0
- package/dist/package-manager-DSEOwTqM.js +193 -0
- package/dist/package-manager-DSEOwTqM.js.map +1 -0
- package/dist/path-flags-BFgG9V6y.js +91 -0
- package/dist/path-flags-BFgG9V6y.js.map +1 -0
- package/dist/precompress.d.ts +17 -0
- package/dist/prepare-CDN6lSpz.js +1236 -0
- package/dist/prepare-CDN6lSpz.js.map +1 -0
- package/dist/{project-3WCbrrfW.js → project-Wp2zoyeN.js} +34 -184
- package/dist/project-Wp2zoyeN.js.map +1 -0
- package/dist/pull-CrGYMEsy.js +458 -0
- package/dist/pull-CrGYMEsy.js.map +1 -0
- package/dist/resolve-B2O-wKKk.js +2 -0
- package/dist/resources-FD7AfZx8.js +183 -0
- package/dist/resources-FD7AfZx8.js.map +1 -0
- package/dist/schema-DMdr431S.js +125 -0
- package/dist/schema-DMdr431S.js.map +1 -0
- package/dist/skills-DTFibT1m.js +642 -0
- package/dist/skills-DTFibT1m.js.map +1 -0
- package/dist/start-DEa-hOBh.js +267 -0
- package/dist/start-DEa-hOBh.js.map +1 -0
- package/dist/{state-C59Elrnt.js → state-Do7ZJYkF.js} +49 -4
- package/dist/state-Do7ZJYkF.js.map +1 -0
- package/dist/status-DxnJTekv.js +352 -0
- package/dist/status-DxnJTekv.js.map +1 -0
- package/dist/storage-DyVq-f_F.js +303 -0
- package/dist/storage-DyVq-f_F.js.map +1 -0
- package/dist/telemetry/identity.d.ts +10 -1
- package/dist/telemetry-C8Uq8phb.js +129 -0
- package/dist/telemetry-C8Uq8phb.js.map +1 -0
- package/dist/telemetry-EMFRVRQi.js +351 -0
- package/dist/telemetry-EMFRVRQi.js.map +1 -0
- package/dist/unknown-command-DNwDcX3w.js +98 -0
- package/dist/unknown-command-DNwDcX3w.js.map +1 -0
- package/dist/upgrade-CtZtRkTE.js +1332 -0
- package/dist/upgrade-CtZtRkTE.js.map +1 -0
- package/dist/upgrade.d.ts +38 -5
- package/dist/utils/dev-preflight.d.ts +47 -5
- package/dist/utils/dev-server.d.ts +30 -0
- package/dist/utils/path-flags.d.ts +31 -0
- package/dist/utils/project.d.ts +16 -0
- package/dist/utils/temp-archive.d.ts +9 -0
- package/dist/version-Buyyo1ut.js +58 -0
- package/dist/version-Buyyo1ut.js.map +1 -0
- package/package.json +7 -7
- package/templates/eject/Dockerfile +4 -2
- package/templates/eject/backend/src/env.ts +1 -1
- package/templates/eject/backend/src/index.ts +42 -4
- package/templates/eject/docker-compose.custom.yml +8 -4
- package/templates/generated-schemas/blank.ts +40 -0
- package/templates/generated-schemas/blog.ts +127 -0
- package/templates/generated-schemas/ecommerce.ts +93 -0
- package/templates/overlays/baas/ai-instructions.md +1 -1
- package/templates/overlays/baas/config/package.json +1 -0
- package/templates/overlays/baas/config/storage.ts +5 -3
- package/templates/overlays/baas/package.json +1 -0
- package/templates/template/.cursorrules +1 -1
- package/templates/template/.env.example +20 -10
- package/templates/template/.github/copilot-instructions.md +1 -1
- package/templates/template/.mcp.json +2 -2
- package/templates/template/.windsurfrules +1 -1
- package/templates/template/AGENTS.md +1 -1
- package/templates/template/CLAUDE.md +1 -1
- package/templates/template/README.md +21 -15
- package/templates/template/ai-instructions.md +5 -4
- package/templates/template/backend/functions/hello.ts +8 -0
- package/templates/template/backend/package.json +1 -1
- package/templates/template/config/resources.ts +7 -6
- package/templates/template/config/storage.ts +5 -3
- package/templates/template/docker-compose.yml +15 -9
- package/templates/template/frontend/vite.config.ts +13 -3
- package/templates/template/gitignore +6 -1
- package/templates/template/package.json +5 -3
- package/templates/template/scripts/example.ts +6 -4
- package/templates/template/tsconfig.json +25 -0
- package/dist/branch-pointer-dBiClJ0p.js +0 -111
- package/dist/branch-pointer-dBiClJ0p.js.map +0 -1
- package/dist/constraints-DKGbNfUW.js +0 -48
- package/dist/constraints-DKGbNfUW.js.map +0 -1
- package/dist/daemon-DR9Tk0La.js +0 -355
- package/dist/daemon-DR9Tk0La.js.map +0 -1
- package/dist/daemon-entry-LTFKtpHy.js.map +0 -1
- package/dist/dev-preflight-CNLl4rdo.js +0 -367
- package/dist/dev-preflight-CNLl4rdo.js.map +0 -1
- package/dist/project-3WCbrrfW.js.map +0 -1
- package/dist/pull-Dywm5FWP.js +0 -232
- package/dist/pull-Dywm5FWP.js.map +0 -1
- package/dist/resolve-BAeRUBhg.js +0 -74
- package/dist/resolve-BAeRUBhg.js.map +0 -1
- package/dist/state-C59Elrnt.js.map +0 -1
- package/templates/template/backend/src/schema.generated.ts +0 -13
|
@@ -0,0 +1,1149 @@
|
|
|
1
|
+
import { i as recordEvent, l as durationBucket } from "./telemetry-EMFRVRQi.js";
|
|
2
|
+
import { n as getPMCommands, t as detectPackageManager } from "./package-manager-DSEOwTqM.js";
|
|
3
|
+
import { n as parseCommandArgs, r as wantsHelp } from "./args-BnmsEXvu.js";
|
|
4
|
+
import { n as cliVersion } from "./version-Buyyo1ut.js";
|
|
5
|
+
import { AGENTS, AGENT_KEYS, resolveAgentNames } from "./skills-DTFibT1m.js";
|
|
6
|
+
import { configureAgents, printAgentSetup, promptForAgents, retargetScaffoldMcpConfig } from "./agent-setup-B31F00DC.js";
|
|
7
|
+
import { W as resolveCloudUrl, Z as writeLink } from "./context-BqhRydBg.js";
|
|
8
|
+
import { n as promptForConsent } from "./consent-ClRHmcxf.js";
|
|
9
|
+
import chalk from "chalk";
|
|
10
|
+
import fs from "fs";
|
|
11
|
+
import path from "path";
|
|
12
|
+
import crypto from "crypto";
|
|
13
|
+
import { fileURLToPath } from "url";
|
|
14
|
+
import { parseEnvBoolean } from "@rebasepro/types";
|
|
15
|
+
import inquirer from "inquirer";
|
|
16
|
+
import { execa } from "execa";
|
|
17
|
+
import net from "net";
|
|
18
|
+
import { promisify } from "util";
|
|
19
|
+
import { cp } from "fs/promises";
|
|
20
|
+
//#region src/commands/init.ts
|
|
21
|
+
var access = promisify(fs.access);
|
|
22
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
23
|
+
var __dirname = path.dirname(__filename);
|
|
24
|
+
function findParentDir(currentDir, targetName) {
|
|
25
|
+
const root = path.parse(currentDir).root;
|
|
26
|
+
while (currentDir && currentDir !== root) {
|
|
27
|
+
if (path.basename(currentDir) === targetName) return currentDir;
|
|
28
|
+
currentDir = path.dirname(currentDir);
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
var cliRoot = findParentDir(__dirname, "cli");
|
|
33
|
+
var PROJECT_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
34
|
+
/**
|
|
35
|
+
* Every scaffolded file that carries a `{{PLACEHOLDER}}`.
|
|
36
|
+
*
|
|
37
|
+
* Exported because it is the single source of truth: `init.test.ts` reads it and
|
|
38
|
+
* asserts that every template file containing `{{` appears here. It used to be a
|
|
39
|
+
* local, with the test harness keeping a *second* copy — so the two drifted, and
|
|
40
|
+
* a test built on the copy could not observe the production list at all.
|
|
41
|
+
*
|
|
42
|
+
* The drift shipped: `docker-compose.yml` arrived with the self-host work
|
|
43
|
+
* (26fd5259c) and was added to neither, so every scaffolded project got a literal
|
|
44
|
+
* `name: {{PROJECT_NAME}}`. In YAML `{{...}}` is a map, not a string, so the
|
|
45
|
+
* documented `docker compose up` path failed on the file before doing anything:
|
|
46
|
+
*
|
|
47
|
+
* yaml: unmarshal errors: line 28: cannot unmarshal !!map into string
|
|
48
|
+
*/
|
|
49
|
+
var TEMPLATE_PLACEHOLDER_FILES = [
|
|
50
|
+
"package.json",
|
|
51
|
+
"frontend/package.json",
|
|
52
|
+
"backend/package.json",
|
|
53
|
+
"config/package.json",
|
|
54
|
+
"frontend/index.html",
|
|
55
|
+
"pnpm-workspace.yaml",
|
|
56
|
+
"docker-compose.yml",
|
|
57
|
+
"README.md"
|
|
58
|
+
];
|
|
59
|
+
/** Returns an error message, or null when the name is a valid package name. */
|
|
60
|
+
function validateProjectName(name) {
|
|
61
|
+
if (!name.trim()) return "Project name is required";
|
|
62
|
+
if (!PROJECT_NAME_RE.test(name)) return "Project name must start with a lowercase letter or number and contain only lowercase letters, numbers, hyphens, dots, or underscores";
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Whether to scaffold the admin panel alongside the backend.
|
|
67
|
+
*
|
|
68
|
+
* A boolean, not a named pair. It was `--flavor cms|baas`, and neither word
|
|
69
|
+
* survived what they described: "cms" is a product category rather than a thing
|
|
70
|
+
* this tool builds, and "baas" was the same value that used to appear as
|
|
71
|
+
* `backend.mode` — which is now derived from whether collections are declared.
|
|
72
|
+
* What is left is one question: does this project get an admin UI?
|
|
73
|
+
*/
|
|
74
|
+
var HEADLESS_CHOICES = [{
|
|
75
|
+
name: "Backend + admin — API plus an admin UI, driven by collections you define (like Payload/Directus)",
|
|
76
|
+
value: false,
|
|
77
|
+
short: "Backend + admin"
|
|
78
|
+
}, {
|
|
79
|
+
name: "Backend only — headless API over your database. No collections, no UI (like Supabase)",
|
|
80
|
+
value: true,
|
|
81
|
+
short: "Backend only"
|
|
82
|
+
}];
|
|
83
|
+
var PRESET_CHOICES = [
|
|
84
|
+
{
|
|
85
|
+
name: "Blog — Posts, Authors, Tags (with markdown editor)",
|
|
86
|
+
value: "blog",
|
|
87
|
+
short: "Blog"
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: "E-commerce — Products, Categories, Orders",
|
|
91
|
+
value: "ecommerce",
|
|
92
|
+
short: "E-commerce"
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: "Blank — Empty project, just authentication",
|
|
96
|
+
value: "blank",
|
|
97
|
+
short: "Blank"
|
|
98
|
+
}
|
|
99
|
+
];
|
|
100
|
+
/**
|
|
101
|
+
* Why `input` cannot be this project's `DATABASE_URL`, or null when it can.
|
|
102
|
+
*
|
|
103
|
+
* The runtime refuses anything that is not a URL, and only a Postgres one can
|
|
104
|
+
* work, so `init` refuses the same — before the scaffold is written around it.
|
|
105
|
+
* The value itself is never repeated back: it may hold a password.
|
|
106
|
+
*/
|
|
107
|
+
function databaseUrlProblem(input) {
|
|
108
|
+
const expected = "(expected postgresql://user:password@host:5432/database)";
|
|
109
|
+
let parsed;
|
|
110
|
+
try {
|
|
111
|
+
parsed = new URL(input.trim());
|
|
112
|
+
} catch {
|
|
113
|
+
return `not a PostgreSQL connection string ${expected}`;
|
|
114
|
+
}
|
|
115
|
+
if (parsed.protocol !== "postgresql:" && parsed.protocol !== "postgres:") return `not a PostgreSQL connection string: it starts with ${parsed.protocol}// ${expected}`;
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
/** One line saying `dir` already holds files, or null when it may be scaffolded into. */
|
|
119
|
+
function scaffoldConflictLine(dir) {
|
|
120
|
+
const conflicts = scaffoldDirectoryConflicts(dir);
|
|
121
|
+
if (conflicts.length === 0) return null;
|
|
122
|
+
const shown = conflicts.slice(0, 4).join(", ") + (conflicts.length > 4 ? `, and ${conflicts.length - 4} more` : "");
|
|
123
|
+
return `"${path.basename(dir)}" already holds files a new project would overwrite or mix with (${shown}). Choose another name.`;
|
|
124
|
+
}
|
|
125
|
+
function buildInitQuestions(params) {
|
|
126
|
+
const { nameArg, templateArg, headlessArg, hasGitFlag, hasInstallFlag, pm } = params;
|
|
127
|
+
const questions = [];
|
|
128
|
+
if (!nameArg) questions.push({
|
|
129
|
+
type: "input",
|
|
130
|
+
name: "projectName",
|
|
131
|
+
message: "Project name:",
|
|
132
|
+
default: "my-app",
|
|
133
|
+
validate: (input) => validateProjectName(input) ?? scaffoldConflictLine(path.resolve(process.cwd(), input)) ?? true
|
|
134
|
+
});
|
|
135
|
+
if (headlessArg === void 0) questions.push({
|
|
136
|
+
type: "select",
|
|
137
|
+
name: "headless",
|
|
138
|
+
message: "What do you want to build?",
|
|
139
|
+
choices: HEADLESS_CHOICES,
|
|
140
|
+
default: false
|
|
141
|
+
});
|
|
142
|
+
if (!templateArg) questions.push({
|
|
143
|
+
type: "select",
|
|
144
|
+
name: "preset",
|
|
145
|
+
message: "Choose a starter template:",
|
|
146
|
+
choices: PRESET_CHOICES,
|
|
147
|
+
default: "blog",
|
|
148
|
+
when: (answers) => !(headlessArg ?? answers.headless)
|
|
149
|
+
});
|
|
150
|
+
if (!hasGitFlag) questions.push({
|
|
151
|
+
type: "confirm",
|
|
152
|
+
name: "git",
|
|
153
|
+
message: "Initialize a git repository?",
|
|
154
|
+
default: true
|
|
155
|
+
});
|
|
156
|
+
if (!hasInstallFlag) questions.push({
|
|
157
|
+
type: "confirm",
|
|
158
|
+
name: "installDeps",
|
|
159
|
+
message: `Install dependencies with ${pm}?`,
|
|
160
|
+
default: true
|
|
161
|
+
});
|
|
162
|
+
questions.push({
|
|
163
|
+
type: "input",
|
|
164
|
+
name: "databaseUrl",
|
|
165
|
+
message: "PostgreSQL connection string (blank = a managed database for this project, no setup):",
|
|
166
|
+
default: "",
|
|
167
|
+
validate: (input) => {
|
|
168
|
+
if (!input.trim()) return true;
|
|
169
|
+
if (/[\r\n]/.test(input)) return "Database URL cannot contain newline characters.";
|
|
170
|
+
const problem = databaseUrlProblem(input);
|
|
171
|
+
return problem ? `That is ${problem}. Leave it blank for the managed database.` : true;
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
questions.push({
|
|
175
|
+
type: "confirm",
|
|
176
|
+
name: "introspect",
|
|
177
|
+
message: "Would you like to introspect this database to automatically generate collections?",
|
|
178
|
+
default: true,
|
|
179
|
+
when: (answers) => !!answers.databaseUrl?.trim()
|
|
180
|
+
});
|
|
181
|
+
return questions;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The `cd` a user must type to enter the new project.
|
|
185
|
+
*
|
|
186
|
+
* Not the project's basename: `init apps/my-app` has to say `cd apps/my-app`,
|
|
187
|
+
* and `init .` returns "" because they are already in the project.
|
|
188
|
+
*/
|
|
189
|
+
function formatCdTarget(cwd, targetDirectory) {
|
|
190
|
+
return path.relative(cwd, targetDirectory);
|
|
191
|
+
}
|
|
192
|
+
/** Help for `rebase init` — the flags were previously only discoverable by
|
|
193
|
+
* triggering the non-TTY error. */
|
|
194
|
+
function printInitHelp() {
|
|
195
|
+
console.log(`
|
|
196
|
+
${chalk.bold("rebase init")} — Create a new Rebase project
|
|
197
|
+
|
|
198
|
+
${chalk.bold("Usage")}
|
|
199
|
+
rebase init ${chalk.blue("[name]")} [options]
|
|
200
|
+
|
|
201
|
+
${chalk.gray("The name may be a nested path (apps/my-app) or \".\" for the current directory.")}
|
|
202
|
+
${chalk.gray("Asks when omitted; defaults to \"my-app\", and takes it without asking under --yes.")}
|
|
203
|
+
|
|
204
|
+
${chalk.bold("Options")}
|
|
205
|
+
${chalk.blue("-t, --template")} ${chalk.gray("<preset>")} blog | ecommerce | blank ${chalk.gray("(default: blog)")}
|
|
206
|
+
${chalk.blue("--headless")} Backend only — no admin panel, no collections
|
|
207
|
+
${chalk.blue("-y, --yes")} Never prompt ${chalk.gray("(required for CI / non-TTY)")}
|
|
208
|
+
${chalk.gray("Skips git init and dependency install — the interactive")}
|
|
209
|
+
${chalk.gray("defaults say yes to both. Pass --git / --install for those.")}
|
|
210
|
+
${chalk.blue("-i, --install")} Install dependencies after scaffolding
|
|
211
|
+
${chalk.blue("--no-install")} Scaffold only, install nothing ${chalk.gray("(the default)")}
|
|
212
|
+
${chalk.blue("-g, --git")} Initialize a git repository and make an initial commit
|
|
213
|
+
${chalk.blue("--database-url")} ${chalk.gray("<url>")} Use an existing database instead of the generated one
|
|
214
|
+
${chalk.blue("--introspect")} Generate collections from that database ${chalk.gray("(implies --template blank; needs --install)")}
|
|
215
|
+
${chalk.blue("--project")} ${chalk.gray("<slug>")} Link the scaffold to a Rebase Cloud project
|
|
216
|
+
${chalk.blue("--setup-key")} ${chalk.gray("<key>")} One-time key authenticating the cloud link ${chalk.gray("(use with --project)")}
|
|
217
|
+
${chalk.blue("-a, --agent")} ${chalk.gray("<name>")} Set up an AI coding agent: Rebase skills + the MCP server.
|
|
218
|
+
${chalk.gray(`Repeatable or comma-separated: ${AGENT_KEYS.join(" | ")} | all`)}
|
|
219
|
+
${chalk.gray("Asks when omitted, pre-ticking the agents installed on this")}
|
|
220
|
+
${chalk.gray("machine; under --yes, none unless named.")}
|
|
221
|
+
|
|
222
|
+
${chalk.bold("What gets scaffolded")}
|
|
223
|
+
${chalk.gray("default")} Backend + an admin UI, driven by collections you define ${chalk.gray("(like Payload/Directus)")}
|
|
224
|
+
${chalk.blue("--headless")} Backend only, over your existing database ${chalk.gray("(like Supabase)")}
|
|
225
|
+
${chalk.gray("--template has no effect: there are no collections to seed.")}
|
|
226
|
+
|
|
227
|
+
${chalk.bold("Examples")}
|
|
228
|
+
${chalk.gray("$")} rebase init my-shop --template ecommerce --install
|
|
229
|
+
${chalk.gray("$")} rebase init my-api --headless --yes
|
|
230
|
+
${chalk.gray("$")} rebase init . --yes --git
|
|
231
|
+
${chalk.gray("$")} rebase init my-app --yes --agent claude,cursor
|
|
232
|
+
`);
|
|
233
|
+
}
|
|
234
|
+
async function createRebaseApp(rawArgs) {
|
|
235
|
+
if (wantsHelp(rawArgs)) {
|
|
236
|
+
printInitHelp();
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
console.log(`
|
|
240
|
+
${chalk.bold("Rebase")} — Create a new project 🚀
|
|
241
|
+
`);
|
|
242
|
+
await createProject(await promptForOptions(rawArgs, detectPackageManager()));
|
|
243
|
+
}
|
|
244
|
+
/** The flags `rebase init` takes. */
|
|
245
|
+
var INIT_FLAGS = {
|
|
246
|
+
"--git": Boolean,
|
|
247
|
+
"--install": Boolean,
|
|
248
|
+
"--no-install": Boolean,
|
|
249
|
+
"--database-url": String,
|
|
250
|
+
"--introspect": Boolean,
|
|
251
|
+
"--template": String,
|
|
252
|
+
"--headless": Boolean,
|
|
253
|
+
"--project": String,
|
|
254
|
+
"--setup-key": String,
|
|
255
|
+
"--agent": [String],
|
|
256
|
+
"--yes": Boolean,
|
|
257
|
+
"-a": "--agent",
|
|
258
|
+
"-g": "--git",
|
|
259
|
+
"-i": "--install",
|
|
260
|
+
"-t": "--template",
|
|
261
|
+
"-y": "--yes"
|
|
262
|
+
};
|
|
263
|
+
async function promptForOptions(rawArgs, pm) {
|
|
264
|
+
const { flags: args, positionals } = parseCommandArgs({
|
|
265
|
+
spec: INIT_FLAGS,
|
|
266
|
+
rawArgs,
|
|
267
|
+
commandWords: 1,
|
|
268
|
+
command: "init",
|
|
269
|
+
maxPositionals: 1
|
|
270
|
+
});
|
|
271
|
+
const nameArg = positionals[0];
|
|
272
|
+
const isNonInteractive = args["--yes"] || false;
|
|
273
|
+
if (nameArg) {
|
|
274
|
+
const resolvedName = path.basename(path.resolve(process.cwd(), nameArg));
|
|
275
|
+
const nameError = validateProjectName(resolvedName);
|
|
276
|
+
if (nameError) {
|
|
277
|
+
console.error(chalk.red(`Invalid project name "${resolvedName}": ${nameError}`));
|
|
278
|
+
process.exit(1);
|
|
279
|
+
}
|
|
280
|
+
const targetDirectory = path.resolve(process.cwd(), nameArg);
|
|
281
|
+
const conflicts = scaffoldDirectoryConflicts(targetDirectory);
|
|
282
|
+
if (conflicts.length > 0) {
|
|
283
|
+
for (const line of scaffoldConflictMessage(resolvedName, formatCdTarget(process.cwd(), targetDirectory), conflicts)) console.error(line);
|
|
284
|
+
process.exit(1);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const databaseUrlArg = args["--database-url"]?.trim();
|
|
288
|
+
if (databaseUrlArg) {
|
|
289
|
+
const problem = databaseUrlProblem(databaseUrlArg);
|
|
290
|
+
if (problem) {
|
|
291
|
+
console.error(chalk.red(`✗ --database-url is ${problem}.`));
|
|
292
|
+
console.error(chalk.gray(" Leave the flag out to use a managed database for this project, with no setup."));
|
|
293
|
+
process.exit(1);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const templateArg = args["--template"];
|
|
297
|
+
if (templateArg && !PRESET_CHOICES.some((p) => p.value === templateArg)) {
|
|
298
|
+
console.error(chalk.red(`Unknown template "${templateArg}". Available: ${PRESET_CHOICES.map((p) => p.value).join(", ")}`));
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
const headlessArg = args["--headless"] === true ? true : void 0;
|
|
302
|
+
const namedAgents = resolveAgentNames(args["--agent"] ?? []);
|
|
303
|
+
const noInstall = args["--no-install"] === true;
|
|
304
|
+
const installFlag = noInstall ? false : args["--install"] ?? false;
|
|
305
|
+
const hasInstallFlag = noInstall || args["--install"] === true;
|
|
306
|
+
if (isNonInteractive) {
|
|
307
|
+
const projectName = nameArg || "my-app";
|
|
308
|
+
const targetDirectory = path.resolve(process.cwd(), projectName);
|
|
309
|
+
const templateDirectory = path.resolve(cliRoot, "templates", "template");
|
|
310
|
+
const pmCommands = getPMCommands(pm);
|
|
311
|
+
return {
|
|
312
|
+
projectName: path.basename(targetDirectory),
|
|
313
|
+
git: args["--git"] ?? false,
|
|
314
|
+
installDeps: installFlag,
|
|
315
|
+
targetDirectory,
|
|
316
|
+
templateDirectory,
|
|
317
|
+
databaseUrl: args["--database-url"] || void 0,
|
|
318
|
+
introspect: args["--introspect"] || false,
|
|
319
|
+
preset: templateArg || "blog",
|
|
320
|
+
explicitPreset: !!templateArg,
|
|
321
|
+
headless: headlessArg ?? false,
|
|
322
|
+
pm,
|
|
323
|
+
pmCommands,
|
|
324
|
+
cloudProject: args["--project"] || void 0,
|
|
325
|
+
setupKey: args["--setup-key"] || void 0,
|
|
326
|
+
cloudUrl: resolveCloudUrl(rawArgs),
|
|
327
|
+
agents: namedAgents ?? [],
|
|
328
|
+
interactive: false
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
if (!process.stdin.isTTY) {
|
|
332
|
+
console.error(chalk.red("Cannot prompt: this is a non-interactive terminal (no TTY)."));
|
|
333
|
+
console.error(chalk.yellow(" Re-run with --yes to accept defaults, passing any choices as flags, e.g.:"));
|
|
334
|
+
console.error(chalk.yellow(` rebase init ${nameArg || "my-app"} --yes --template blog`));
|
|
335
|
+
console.error(chalk.gray(" Options: --template <blog|ecommerce|blank> --headless --database-url <url> --install --git --agent <name>"));
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
338
|
+
const questions = buildInitQuestions({
|
|
339
|
+
nameArg,
|
|
340
|
+
templateArg,
|
|
341
|
+
headlessArg,
|
|
342
|
+
hasGitFlag: !!args["--git"],
|
|
343
|
+
hasInstallFlag,
|
|
344
|
+
pm
|
|
345
|
+
});
|
|
346
|
+
const answers = await inquirer.prompt(questions);
|
|
347
|
+
const agents = namedAgents ?? await promptForAgents();
|
|
348
|
+
const targetDirectory = path.resolve(process.cwd(), nameArg || answers.projectName);
|
|
349
|
+
const projectName = path.basename(targetDirectory);
|
|
350
|
+
const templateDirectory = path.resolve(cliRoot, "templates", "template");
|
|
351
|
+
const pmCommands = getPMCommands(pm);
|
|
352
|
+
return {
|
|
353
|
+
projectName,
|
|
354
|
+
git: args["--git"] || answers.git || false,
|
|
355
|
+
installDeps: noInstall ? false : args["--install"] || answers.installDeps || false,
|
|
356
|
+
targetDirectory,
|
|
357
|
+
templateDirectory,
|
|
358
|
+
databaseUrl: answers.databaseUrl?.trim() || void 0,
|
|
359
|
+
introspect: answers.introspect || false,
|
|
360
|
+
preset: templateArg || answers.preset || "blog",
|
|
361
|
+
explicitPreset: !!templateArg,
|
|
362
|
+
headless: headlessArg ?? Boolean(answers.headless),
|
|
363
|
+
pm,
|
|
364
|
+
pmCommands,
|
|
365
|
+
cloudProject: args["--project"] || void 0,
|
|
366
|
+
setupKey: args["--setup-key"] || void 0,
|
|
367
|
+
cloudUrl: resolveCloudUrl(rawArgs),
|
|
368
|
+
agents,
|
|
369
|
+
interactive: true
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Redeem the one-time setup key from the console's setup page and write the
|
|
374
|
+
* `.rebase/cloud.json` link into the scaffold, so `rebase cloud deploy` etc.
|
|
375
|
+
* work in the new directory with no further flags. `--project` carries the
|
|
376
|
+
* project's slug (its subdomain, as shown in console URLs); the control plane
|
|
377
|
+
* also accepts a raw id for old copies of the command.
|
|
378
|
+
*
|
|
379
|
+
* Best-effort by design: a failed link must never fail the scaffold, so every
|
|
380
|
+
* exit path other than success is a warning plus instructions to link later.
|
|
381
|
+
*/
|
|
382
|
+
async function linkScaffoldToCloud(options) {
|
|
383
|
+
if (!options.cloudProject && !options.setupKey) return;
|
|
384
|
+
const linkLater = `Link it later with ${chalk.bold("rebase cloud login")} then ${chalk.bold("rebase cloud link")}.`;
|
|
385
|
+
if (!options.cloudProject || !options.setupKey) {
|
|
386
|
+
console.warn(chalk.yellow(" --project and --setup-key go together; skipping the cloud link."));
|
|
387
|
+
console.warn(chalk.yellow(` ${linkLater}`));
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
try {
|
|
391
|
+
const res = await fetch(`${options.cloudUrl}/api/functions/setup-key/validate`, {
|
|
392
|
+
method: "POST",
|
|
393
|
+
headers: { "Content-Type": "application/json" },
|
|
394
|
+
body: JSON.stringify({
|
|
395
|
+
projectId: options.cloudProject,
|
|
396
|
+
setupKey: options.setupKey
|
|
397
|
+
})
|
|
398
|
+
});
|
|
399
|
+
const body = await res.json().catch(() => ({}));
|
|
400
|
+
if (!res.ok || body.project?.id === void 0) {
|
|
401
|
+
console.warn(chalk.yellow(` Could not verify the setup key: ${body.error?.message || res.statusText}`));
|
|
402
|
+
console.warn(chalk.yellow(` ${linkLater}`));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
writeLink({
|
|
406
|
+
url: String(options.cloudUrl),
|
|
407
|
+
projectId: String(body.project.id),
|
|
408
|
+
slug: body.project.subdomain,
|
|
409
|
+
projectName: body.project.name
|
|
410
|
+
}, options.targetDirectory);
|
|
411
|
+
console.log("");
|
|
412
|
+
console.log(` ${chalk.green("✓")} Linked to cloud project ${chalk.bold(body.project.subdomain ?? options.cloudProject)}`);
|
|
413
|
+
} catch (e) {
|
|
414
|
+
console.warn(chalk.yellow(` Could not reach the control plane: ${e instanceof Error ? e.message : String(e)}`));
|
|
415
|
+
console.warn(chalk.yellow(` ${linkLater}`));
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Make the initial commit, after everything that writes into the project has run.
|
|
420
|
+
*
|
|
421
|
+
* It used to happen immediately after `git init`, which is before dependency
|
|
422
|
+
* installation and before introspection — so `init --git --install` ended on a
|
|
423
|
+
* dirty tree whose only untracked file was `pnpm-lock.yaml`. A lockfile is
|
|
424
|
+
* precisely the thing that should be in a project's first commit, and a brand
|
|
425
|
+
* new scaffold whose first `git status` is dirty invites the reader to conclude
|
|
426
|
+
* the lockfile is deliberately ignored and never commit it at all.
|
|
427
|
+
*
|
|
428
|
+
* Introspection has the same shape: it generates `config/collections` and
|
|
429
|
+
* `schema.generated.ts`, which belong in the commit describing the scaffold that
|
|
430
|
+
* produced them.
|
|
431
|
+
*
|
|
432
|
+
* `git init` stays where it was. Creating the repository early costs nothing and
|
|
433
|
+
* means a failed install still leaves the user a repository to commit into.
|
|
434
|
+
*/
|
|
435
|
+
async function commitScaffold(targetDirectory) {
|
|
436
|
+
try {
|
|
437
|
+
await execa("git", ["add", "-A"], { cwd: targetDirectory });
|
|
438
|
+
let identity = {};
|
|
439
|
+
try {
|
|
440
|
+
await execa("git", ["config", "user.email"], { cwd: targetDirectory });
|
|
441
|
+
} catch {
|
|
442
|
+
identity = {
|
|
443
|
+
GIT_AUTHOR_NAME: "Rebase",
|
|
444
|
+
GIT_AUTHOR_EMAIL: "noreply@rebase.pro",
|
|
445
|
+
GIT_COMMITTER_NAME: "Rebase",
|
|
446
|
+
GIT_COMMITTER_EMAIL: "noreply@rebase.pro"
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
await execa("git", [
|
|
450
|
+
"commit",
|
|
451
|
+
"-m",
|
|
452
|
+
"Initial commit from Rebase"
|
|
453
|
+
], {
|
|
454
|
+
cwd: targetDirectory,
|
|
455
|
+
env: identity
|
|
456
|
+
});
|
|
457
|
+
} catch {
|
|
458
|
+
console.warn(chalk.yellow(" Warning: Failed to create the initial commit"));
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* The "Next steps" `init` closes on, every command one that works as printed.
|
|
463
|
+
*
|
|
464
|
+
* Each flavour has its own steps, and the branch for one flavour must not run
|
|
465
|
+
* for another. The `--database-url` branch was checked before the headless one,
|
|
466
|
+
* so `init --headless --database-url` — the documented BaaS path — opened on
|
|
467
|
+
* `pnpm run db:push`: a script the headless scaffold does not have, for a
|
|
468
|
+
* schema it does not declare (its tables are the developer's own), with an
|
|
469
|
+
* Atlas it does not install. `init-next-steps.test.ts` holds every script
|
|
470
|
+
* named here to the scaffold it is printed for.
|
|
471
|
+
*/
|
|
472
|
+
function initNextSteps(input) {
|
|
473
|
+
const { headless, ownDatabase, introspect, introspected, installed, cdTarget, pm } = input;
|
|
474
|
+
const cmd = (parts) => ` ${chalk.cyan(parts.join(" "))}`;
|
|
475
|
+
const note = (text) => chalk.gray(` # ${text}`);
|
|
476
|
+
const runDev = cmd(pm.run("dev"));
|
|
477
|
+
const lines = [];
|
|
478
|
+
if (cdTarget) lines.push(` ${chalk.cyan("cd")} ${cdTarget}`);
|
|
479
|
+
if (!installed) lines.push(cmd(pm.install));
|
|
480
|
+
lines.push("");
|
|
481
|
+
if (introspected) {
|
|
482
|
+
lines.push(note("Database has been introspected & collections generated!"));
|
|
483
|
+
lines.push(note(headless ? "Start the API over it:" : "Start the development server (frontend + backend):"));
|
|
484
|
+
lines.push(runDev);
|
|
485
|
+
} else if (introspect) {
|
|
486
|
+
lines.push(note("Introspection did not run — finish it with:"));
|
|
487
|
+
lines.push(cmd(pm.exec("rebase", [
|
|
488
|
+
"schema",
|
|
489
|
+
"introspect",
|
|
490
|
+
"--force"
|
|
491
|
+
])));
|
|
492
|
+
lines.push(cmd(pm.exec("rebase", ["schema", "generate"])));
|
|
493
|
+
lines.push("");
|
|
494
|
+
lines.push(note("Then start the development server:"));
|
|
495
|
+
lines.push(runDev);
|
|
496
|
+
} else if (headless) {
|
|
497
|
+
lines.push(note(ownDatabase ? "Your database is configured in .env (DATABASE_URL)." : "A local database configuration has been generated in .env."));
|
|
498
|
+
lines.push(note(ownDatabase ? "1. Start the API over it:" : "1. Start everything — the database comes up with it:"));
|
|
499
|
+
lines.push(runDev);
|
|
500
|
+
lines.push("");
|
|
501
|
+
lines.push(note(ownDatabase ? "2. Serve your tables." : "2. Create your tables (migrations, SQL, any tool you like)."));
|
|
502
|
+
lines.push(note(" A table is served once row-level security is enabled on it, and a"));
|
|
503
|
+
lines.push(note(" policy is what opens its rows:"));
|
|
504
|
+
lines.push(cmd(["ALTER TABLE your_table ENABLE ROW LEVEL SECURITY;"]));
|
|
505
|
+
lines.push(cmd(["CREATE POLICY your_table_owner ON your_table FOR ALL USING (user_id = rebase.uid());"]));
|
|
506
|
+
lines.push(note(" The API logs any table it skips, and why."));
|
|
507
|
+
} else if (ownDatabase) {
|
|
508
|
+
lines.push(note("Your custom database is configured in .env."));
|
|
509
|
+
lines.push(note("If the database is empty, push the Rebase schema to initialize it:"));
|
|
510
|
+
lines.push(cmd(pm.run("db:push")));
|
|
511
|
+
lines.push("");
|
|
512
|
+
lines.push(note("Then start the development server:"));
|
|
513
|
+
lines.push(runDev);
|
|
514
|
+
} else {
|
|
515
|
+
lines.push(note("A local database configuration has been generated in .env."));
|
|
516
|
+
lines.push(note("Start everything — the database and schema are set up for you,"));
|
|
517
|
+
lines.push(note("with no Docker and nothing else to install:"));
|
|
518
|
+
lines.push(runDev);
|
|
519
|
+
lines.push("");
|
|
520
|
+
lines.push(note("To use a Postgres of your own instead, uncomment DATABASE_URL in .env."));
|
|
521
|
+
}
|
|
522
|
+
lines.push("");
|
|
523
|
+
lines.push(note("Sign in: the first account you register becomes the admin."));
|
|
524
|
+
lines.push(note("In production (docker compose) that window is shut and the account named by"));
|
|
525
|
+
lines.push(note("REBASE_ADMIN_EMAIL in .env is created at boot instead."));
|
|
526
|
+
lines.push("");
|
|
527
|
+
lines.push(headless ? introspected ? chalk.gray("This starts a headless API (Hono + PostgreSQL) over the collections just ") + chalk.gray("generated from your database in config/collections. Edit them to change what the ") + chalk.gray("API exposes; docs are at /api/swagger.") : chalk.gray("This starts a headless API (Hono + PostgreSQL). There are no collection files: ") + chalk.gray("the API is derived from your database schema. Once it serves a table, docs are at /api/swagger.") : chalk.gray("This starts both the backend (Hono + PostgreSQL)") + chalk.gray(" and the frontend (Vite + React) concurrently."));
|
|
528
|
+
return lines;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* The closing "AI Agent Skills" lines, each command one its reader can run.
|
|
532
|
+
*
|
|
533
|
+
* `rebase skills install` with no `--agent` prompts, and refuses without a
|
|
534
|
+
* TTY — so printing it bare at the end of `rebase init --yes`, the agent and
|
|
535
|
+
* CI path, told that reader to run a command that answers "Cannot prompt".
|
|
536
|
+
* Named agents are spelled out (a refresh must not depend on detection, which
|
|
537
|
+
* never finds Copilot); otherwise a TTY reader gets the prompt, anyone else
|
|
538
|
+
* the `--agent` form.
|
|
539
|
+
*/
|
|
540
|
+
function agentNextSteps(agents, interactive, pm) {
|
|
541
|
+
const both = (args) => ` ${chalk.cyan(["rebase", ...args].join(" "))} ${chalk.gray("or")} ${chalk.cyan(pm.exec("rebase", args).join(" "))}`;
|
|
542
|
+
if (agents.length > 0) return [
|
|
543
|
+
chalk.gray(` Set up for ${agents.map((a) => AGENTS[a].label).join(", ")}. Commit the files to share them;`),
|
|
544
|
+
chalk.gray(" after a Rebase upgrade, refresh the skills with:"),
|
|
545
|
+
"",
|
|
546
|
+
both([
|
|
547
|
+
"skills",
|
|
548
|
+
"install",
|
|
549
|
+
"--agent",
|
|
550
|
+
agents.join(",")
|
|
551
|
+
])
|
|
552
|
+
];
|
|
553
|
+
if (interactive) return [
|
|
554
|
+
chalk.gray(" Install Rebase agent skills, and the Rebase MCP server, for your AI coding assistant:"),
|
|
555
|
+
"",
|
|
556
|
+
both([
|
|
557
|
+
"skills",
|
|
558
|
+
"install",
|
|
559
|
+
"--mcp"
|
|
560
|
+
])
|
|
561
|
+
];
|
|
562
|
+
return [
|
|
563
|
+
chalk.gray(" Install Rebase agent skills, and the Rebase MCP server, for your AI coding assistant —"),
|
|
564
|
+
chalk.gray(` name it: ${AGENT_KEYS.join(", ")}, or all.`),
|
|
565
|
+
"",
|
|
566
|
+
both([
|
|
567
|
+
"skills",
|
|
568
|
+
"install",
|
|
569
|
+
"--agent",
|
|
570
|
+
"<agent>",
|
|
571
|
+
"--mcp"
|
|
572
|
+
])
|
|
573
|
+
];
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* What a directory may already hold for `rebase init` to scaffold into it.
|
|
577
|
+
*
|
|
578
|
+
* `git clone` of an empty repository, or `git init`, leaves exactly `.git` —
|
|
579
|
+
* and `rebase init . --yes --git` is the help's own example. Counting every
|
|
580
|
+
* entry as "not empty" refused the one directory that example is most likely
|
|
581
|
+
* to be typed in. The entries here are a repository's metadata, the files
|
|
582
|
+
* operating systems drop into any folder, and a licence: none of them is
|
|
583
|
+
* something the scaffold writes (`init.test.ts` holds the template to that),
|
|
584
|
+
* so tolerating them can never overwrite anything. Everything else is a
|
|
585
|
+
* conflict and is named, never merged into.
|
|
586
|
+
*/
|
|
587
|
+
var SCAFFOLD_TOLERATED_ENTRIES = /* @__PURE__ */ new Set([
|
|
588
|
+
".git",
|
|
589
|
+
".hg",
|
|
590
|
+
".svn",
|
|
591
|
+
".gitattributes",
|
|
592
|
+
".DS_Store",
|
|
593
|
+
"Thumbs.db",
|
|
594
|
+
"desktop.ini",
|
|
595
|
+
"LICENSE",
|
|
596
|
+
"LICENSE.md",
|
|
597
|
+
"LICENSE.txt"
|
|
598
|
+
]);
|
|
599
|
+
/** Entries in `dir` the scaffold would overwrite or mix with — `[]` when it may proceed. */
|
|
600
|
+
function scaffoldDirectoryConflicts(dir) {
|
|
601
|
+
if (!fs.existsSync(dir)) return [];
|
|
602
|
+
return fs.readdirSync(dir, { withFileTypes: true }).filter((entry) => !SCAFFOLD_TOLERATED_ENTRIES.has(entry.name)).map((entry) => entry.isDirectory() ? `${entry.name}/` : entry.name).sort();
|
|
603
|
+
}
|
|
604
|
+
/** The refusal for a directory that already holds files, with the two ways forward. */
|
|
605
|
+
function scaffoldConflictMessage(projectName, displayPath, conflicts) {
|
|
606
|
+
const shown = conflicts.slice(0, 8).join(", ") + (conflicts.length > 8 ? `, and ${conflicts.length - 8} more` : "");
|
|
607
|
+
return [
|
|
608
|
+
`${chalk.red.bold("ERROR")} Directory "${projectName}" already holds files a new project would overwrite or mix with: ${shown}`,
|
|
609
|
+
chalk.yellow(` Scaffold somewhere new — ${chalk.cyan("rebase init <new-directory>")} — or move those out of ${displayPath || "this directory"} and run this again.`),
|
|
610
|
+
chalk.gray(` A repository's own metadata (.git) and a LICENSE are fine to leave in place.`)
|
|
611
|
+
];
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* `git init`, or nothing when the directory is already a repository.
|
|
615
|
+
*
|
|
616
|
+
* A cloned repository keeps its branch, its remote and its history: the
|
|
617
|
+
* scaffold is committed on top of what is there. Only a repository this
|
|
618
|
+
* function created is pointed at `main`.
|
|
619
|
+
*/
|
|
620
|
+
async function initGitRepository(targetDirectory) {
|
|
621
|
+
if (fs.existsSync(path.join(targetDirectory, ".git"))) {
|
|
622
|
+
console.log(chalk.gray(" Using the existing git repository..."));
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
625
|
+
console.log(chalk.gray(" Initializing git repository..."));
|
|
626
|
+
try {
|
|
627
|
+
await execa("git", ["init"], { cwd: targetDirectory });
|
|
628
|
+
try {
|
|
629
|
+
await execa("git", [
|
|
630
|
+
"symbolic-ref",
|
|
631
|
+
"HEAD",
|
|
632
|
+
"refs/heads/main"
|
|
633
|
+
], { cwd: targetDirectory });
|
|
634
|
+
} catch {}
|
|
635
|
+
return true;
|
|
636
|
+
} catch {
|
|
637
|
+
console.warn(chalk.yellow(" Warning: Failed to initialize git repository"));
|
|
638
|
+
return false;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
async function createProject(options) {
|
|
642
|
+
const startedAt = Date.now();
|
|
643
|
+
const conflicts = scaffoldDirectoryConflicts(options.targetDirectory);
|
|
644
|
+
if (conflicts.length > 0) {
|
|
645
|
+
for (const line of scaffoldConflictMessage(options.projectName, formatCdTarget(process.cwd(), options.targetDirectory), conflicts)) console.error(line);
|
|
646
|
+
process.exit(1);
|
|
647
|
+
}
|
|
648
|
+
fs.mkdirSync(options.targetDirectory, { recursive: true });
|
|
649
|
+
try {
|
|
650
|
+
await access(options.templateDirectory, fs.constants.R_OK);
|
|
651
|
+
} catch {
|
|
652
|
+
console.error(`${chalk.red.bold("ERROR")} Template not found at ${options.templateDirectory}`);
|
|
653
|
+
process.exit(1);
|
|
654
|
+
}
|
|
655
|
+
console.log(chalk.gray(" Copying project files..."));
|
|
656
|
+
try {
|
|
657
|
+
await cp(options.templateDirectory, options.targetDirectory, {
|
|
658
|
+
recursive: true,
|
|
659
|
+
filter: (source) => {
|
|
660
|
+
const basename = path.basename(source);
|
|
661
|
+
return basename !== "node_modules" && basename !== ".DS_Store";
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
} catch (err) {
|
|
665
|
+
console.error(`${chalk.red.bold("ERROR")} Failed to copy template files: ${err instanceof Error ? err.message : String(err)}`);
|
|
666
|
+
process.exit(1);
|
|
667
|
+
}
|
|
668
|
+
for (const [from, to] of [["gitignore", ".gitignore"], ["npmrc", ".npmrc"]]) {
|
|
669
|
+
const shipped = path.join(options.targetDirectory, from);
|
|
670
|
+
if (fs.existsSync(shipped)) fs.renameSync(shipped, path.join(options.targetDirectory, to));
|
|
671
|
+
}
|
|
672
|
+
if (options.headless && options.explicitPreset) console.log(chalk.yellow(` Ignoring --template ${options.preset}: a headless project declares no collections.`));
|
|
673
|
+
if (!options.headless) {
|
|
674
|
+
if (options.introspect && options.preset !== "blank") console.log(chalk.gray(" Using the blank template: collections will come from your database."));
|
|
675
|
+
const applied = await applyPreset(options.targetDirectory, options.introspect ? "blank" : options.preset);
|
|
676
|
+
writeShippedSchema(options.targetDirectory, applied);
|
|
677
|
+
}
|
|
678
|
+
await applyHeadless(options.targetDirectory, options.headless);
|
|
679
|
+
await replacePlaceholders(options);
|
|
680
|
+
await configureEnvFile(options.targetDirectory, options.databaseUrl);
|
|
681
|
+
await writeProjectPackageManagerIntoManifest(options.targetDirectory, options.pmCommands);
|
|
682
|
+
retargetScaffoldMcpConfig(options.targetDirectory, options.pm);
|
|
683
|
+
if (options.agents.length > 0) {
|
|
684
|
+
console.log(chalk.gray(" Setting up AI coding agents..."));
|
|
685
|
+
printAgentSetup(configureAgents(options.agents, options.targetDirectory, void 0, options.pm), options.pm);
|
|
686
|
+
}
|
|
687
|
+
const gitInitialized = options.git ? await initGitRepository(options.targetDirectory) : false;
|
|
688
|
+
const { pm, pmCommands } = options;
|
|
689
|
+
const installCmd = pmCommands.install;
|
|
690
|
+
const execCmd = pmCommands.exec("rebase", [
|
|
691
|
+
"schema",
|
|
692
|
+
"introspect",
|
|
693
|
+
"--force"
|
|
694
|
+
]);
|
|
695
|
+
const generateCmd = pmCommands.exec("rebase", ["schema", "generate"]);
|
|
696
|
+
let installed = false;
|
|
697
|
+
if (options.installDeps) {
|
|
698
|
+
console.log("");
|
|
699
|
+
console.log(chalk.gray(` Installing dependencies with ${pm}...`));
|
|
700
|
+
console.log("");
|
|
701
|
+
try {
|
|
702
|
+
await execa(installCmd[0], installCmd.slice(1), {
|
|
703
|
+
cwd: options.targetDirectory,
|
|
704
|
+
stdio: "inherit"
|
|
705
|
+
});
|
|
706
|
+
installed = true;
|
|
707
|
+
} catch {
|
|
708
|
+
console.warn(chalk.yellow(` Warning: Failed to install dependencies. You may need to run \`${installCmd.join(" ")}\` manually.`));
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
let introspected = false;
|
|
712
|
+
if (options.introspect) {
|
|
713
|
+
console.log("");
|
|
714
|
+
if (options.installDeps) {
|
|
715
|
+
console.log(chalk.gray(" Introspecting database and generating collections..."));
|
|
716
|
+
console.log("");
|
|
717
|
+
try {
|
|
718
|
+
await execa(execCmd[0], execCmd.slice(1), {
|
|
719
|
+
cwd: options.targetDirectory,
|
|
720
|
+
stdio: "inherit"
|
|
721
|
+
});
|
|
722
|
+
await execa(generateCmd[0], generateCmd.slice(1), {
|
|
723
|
+
cwd: options.targetDirectory,
|
|
724
|
+
stdio: "inherit"
|
|
725
|
+
});
|
|
726
|
+
console.log(chalk.green(" Database successfully introspected!"));
|
|
727
|
+
introspected = true;
|
|
728
|
+
} catch {
|
|
729
|
+
console.warn(chalk.yellow(" Warning: Failed to introspect database automatically."));
|
|
730
|
+
console.warn(chalk.yellow(` You can run \`${execCmd.join(" ")}\` then \`${generateCmd.join(" ")}\` manually after setup.`));
|
|
731
|
+
}
|
|
732
|
+
} else {
|
|
733
|
+
console.warn(chalk.yellow(" Skipping introspection because dependencies were not installed."));
|
|
734
|
+
console.warn(chalk.yellow(` Run \`${installCmd.join(" ")}\` then \`${execCmd.join(" ")}\` manually.`));
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (gitInitialized) await commitScaffold(options.targetDirectory);
|
|
738
|
+
await linkScaffoldToCloud(options);
|
|
739
|
+
console.log("");
|
|
740
|
+
console.log(`${chalk.green.bold("✓")} Project ${chalk.bold(options.projectName)} created successfully!`);
|
|
741
|
+
console.log("");
|
|
742
|
+
console.log(chalk.bold("Next steps:"));
|
|
743
|
+
console.log("");
|
|
744
|
+
const nextSteps = initNextSteps({
|
|
745
|
+
headless: options.headless,
|
|
746
|
+
ownDatabase: Boolean(options.databaseUrl),
|
|
747
|
+
introspect: Boolean(options.introspect),
|
|
748
|
+
introspected,
|
|
749
|
+
installed,
|
|
750
|
+
cdTarget: formatCdTarget(process.cwd(), options.targetDirectory),
|
|
751
|
+
pm: pmCommands
|
|
752
|
+
});
|
|
753
|
+
for (const line of nextSteps) console.log(line);
|
|
754
|
+
console.log("");
|
|
755
|
+
console.log(chalk.gray("Docs: https://rebase.pro/docs"));
|
|
756
|
+
console.log(chalk.gray("GitHub: https://github.com/rebasepro/rebase"));
|
|
757
|
+
console.log("");
|
|
758
|
+
console.log(chalk.bold("🤖 AI Agent Skills"));
|
|
759
|
+
console.log("");
|
|
760
|
+
for (const line of agentNextSteps(options.agents, options.interactive === true, pmCommands)) console.log(line);
|
|
761
|
+
console.log("");
|
|
762
|
+
const initProperties = {
|
|
763
|
+
preset: options.headless ? "none" : options.preset,
|
|
764
|
+
headless: Boolean(options.headless),
|
|
765
|
+
package_manager: options.pm,
|
|
766
|
+
installed_deps: Boolean(options.installDeps),
|
|
767
|
+
introspected,
|
|
768
|
+
own_database: Boolean(options.databaseUrl),
|
|
769
|
+
cloud_linked: Boolean(options.cloudProject),
|
|
770
|
+
git: Boolean(options.git),
|
|
771
|
+
duration: durationBucket(Date.now() - startedAt)
|
|
772
|
+
};
|
|
773
|
+
if (await promptForConsent({ reAskDeclined: true })) await recordEvent("cli.init", initProperties, { projectRoot: options.targetDirectory });
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Apply a template preset by replacing the default collection files.
|
|
777
|
+
*
|
|
778
|
+
* The template ships with blog collections at the top level and
|
|
779
|
+
* preset alternatives under `config/collections/presets/<name>/`.
|
|
780
|
+
* This function swaps the active collection files and removes the
|
|
781
|
+
* presets directory so the final project is clean.
|
|
782
|
+
*/
|
|
783
|
+
/**
|
|
784
|
+
* Reduce the scaffolded project to the chosen shape.
|
|
785
|
+
*
|
|
786
|
+
* The base template is the full triad. `--headless` drops the frontend and the
|
|
787
|
+
* declared collections — there is nothing to define, since the server derives
|
|
788
|
+
* its API from the database — and overlays the files that differ.
|
|
789
|
+
*
|
|
790
|
+
* The config *package* stays, holding only `storageAuthorize`. Storage is not
|
|
791
|
+
* under row-level security, so a deployment with file storage enabled and no
|
|
792
|
+
* access model serves every user's files to every signed-in user; the server
|
|
793
|
+
* refuses to boot in that state. Deleting the package outright would leave a
|
|
794
|
+
* headless project with nowhere to put the hook, and the scaffold's own
|
|
795
|
+
* docker-compose.yml enables storage — so the first `docker compose up` would
|
|
796
|
+
* crash-loop.
|
|
797
|
+
*/
|
|
798
|
+
async function applyHeadless(targetDirectory, headless) {
|
|
799
|
+
if (!headless) return;
|
|
800
|
+
fs.rmSync(path.join(targetDirectory, "frontend"), {
|
|
801
|
+
recursive: true,
|
|
802
|
+
force: true
|
|
803
|
+
});
|
|
804
|
+
fs.rmSync(path.join(targetDirectory, "config", "collections"), {
|
|
805
|
+
recursive: true,
|
|
806
|
+
force: true
|
|
807
|
+
});
|
|
808
|
+
for (const stray of ["cms.d.ts", "frontend-assets.d.ts"]) fs.rmSync(path.join(targetDirectory, "config", stray), { force: true });
|
|
809
|
+
fs.rmSync(path.join(targetDirectory, "backend", "src", "schema.generated.ts"), { force: true });
|
|
810
|
+
const overlayDir = path.resolve(cliRoot, "templates", "overlays", "baas");
|
|
811
|
+
if (!fs.existsSync(overlayDir)) {
|
|
812
|
+
console.error(`${chalk.red.bold("ERROR")} BaaS template overlay not found at ${overlayDir}`);
|
|
813
|
+
process.exit(1);
|
|
814
|
+
}
|
|
815
|
+
await cp(overlayDir, targetDirectory, {
|
|
816
|
+
recursive: true,
|
|
817
|
+
force: true,
|
|
818
|
+
filter: (source) => {
|
|
819
|
+
const basename = path.basename(source);
|
|
820
|
+
return basename !== "node_modules" && basename !== ".DS_Store";
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Rewrite `rebase.json`'s static-app build command for this project's package manager.
|
|
826
|
+
*
|
|
827
|
+
* The template must spell one of them, and it spells npm. In a pnpm project
|
|
828
|
+
* that is a second package manager invoked against a lockfile it did not
|
|
829
|
+
* write — `rebase build` then either installs a second tree or fails on a
|
|
830
|
+
* workspace flag npm and pnpm spell differently.
|
|
831
|
+
*/
|
|
832
|
+
async function writeProjectPackageManagerIntoManifest(targetDirectory, pmCommands) {
|
|
833
|
+
const manifestPath = path.join(targetDirectory, "rebase.json");
|
|
834
|
+
if (!fs.existsSync(manifestPath)) return;
|
|
835
|
+
try {
|
|
836
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
837
|
+
let changed = false;
|
|
838
|
+
for (const app of Object.values(manifest.apps ?? {})) {
|
|
839
|
+
if (app.type !== "static" || !app.root || !app.build) continue;
|
|
840
|
+
const rewritten = pmCommands.runWorkspace(app.root, "build").join(" ");
|
|
841
|
+
if (rewritten !== app.build) {
|
|
842
|
+
app.build = rewritten;
|
|
843
|
+
changed = true;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
if (changed) fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 4)}\n`, "utf8");
|
|
847
|
+
} catch {}
|
|
848
|
+
}
|
|
849
|
+
/** Swap in the preset's collection files. Returns the preset actually applied. */
|
|
850
|
+
async function applyPreset(targetDirectory, preset) {
|
|
851
|
+
const collectionsDir = path.join(targetDirectory, "config", "collections");
|
|
852
|
+
const presetsDir = path.join(collectionsDir, "presets");
|
|
853
|
+
if (preset !== "blog") {
|
|
854
|
+
const presetDir = path.join(presetsDir, preset);
|
|
855
|
+
if (!fs.existsSync(presetDir)) {
|
|
856
|
+
console.warn(chalk.yellow(` Warning: Preset "${preset}" not found, falling back to blog template.`));
|
|
857
|
+
cleanupPresets(presetsDir);
|
|
858
|
+
return "blog";
|
|
859
|
+
}
|
|
860
|
+
for (const file of [
|
|
861
|
+
"posts.ts",
|
|
862
|
+
"authors.ts",
|
|
863
|
+
"tags.ts",
|
|
864
|
+
"index.ts"
|
|
865
|
+
]) {
|
|
866
|
+
const filePath = path.join(collectionsDir, file);
|
|
867
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
868
|
+
}
|
|
869
|
+
const presetFiles = fs.readdirSync(presetDir).filter((f) => f.endsWith(".ts"));
|
|
870
|
+
for (const file of presetFiles) fs.copyFileSync(path.join(presetDir, file), path.join(collectionsDir, file));
|
|
871
|
+
}
|
|
872
|
+
cleanupPresets(presetsDir);
|
|
873
|
+
return preset;
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Where the generated Drizzle schema for each preset ships, beside the template.
|
|
877
|
+
*
|
|
878
|
+
* `rebase dev` regenerates `backend/src/schema.generated.ts` on every start, and
|
|
879
|
+
* the Studio's schema editor refuses to commit over a dirty copy of it — a
|
|
880
|
+
* schema change must not sweep up somebody's work in progress. So whatever
|
|
881
|
+
* `init` writes there, and commits under `--git`, has to be byte-for-byte what
|
|
882
|
+
* that first regeneration produces. A stub (`export const tables = {}`) was
|
|
883
|
+
* rewritten on the first `rebase dev`, and every new project's first Studio
|
|
884
|
+
* edit was refused with `SCHEMA_EDIT_DIRTY_TREE` before its owner had changed
|
|
885
|
+
* a thing.
|
|
886
|
+
*
|
|
887
|
+
* The file cannot be generated here: generation loads the collection files,
|
|
888
|
+
* and they import `@rebasepro/cms-types`, which the project does not have
|
|
889
|
+
* until it is installed — `init --git` without `--install` commits first. So
|
|
890
|
+
* each preset's output is generated in this repository and shipped, and
|
|
891
|
+
* `init-generated-schema.test.ts` fails the moment one of them differs from
|
|
892
|
+
* what the generator writes for that preset's collections.
|
|
893
|
+
*
|
|
894
|
+
* Outside `templates/template` on purpose: a preset's schema must not be
|
|
895
|
+
* copied into a project that chose another preset.
|
|
896
|
+
*/
|
|
897
|
+
function shippedSchemaPath(preset) {
|
|
898
|
+
return path.resolve(cliRoot, "templates", "generated-schemas", `${preset}.ts`);
|
|
899
|
+
}
|
|
900
|
+
/** Write the preset's generated schema to `backend/src/schema.generated.ts`. */
|
|
901
|
+
function writeShippedSchema(targetDirectory, preset) {
|
|
902
|
+
const destination = path.join(targetDirectory, "backend", "src", "schema.generated.ts");
|
|
903
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
904
|
+
fs.copyFileSync(shippedSchemaPath(preset), destination);
|
|
905
|
+
}
|
|
906
|
+
function cleanupPresets(presetsDir) {
|
|
907
|
+
if (fs.existsSync(presetsDir)) fs.rmSync(presetsDir, {
|
|
908
|
+
recursive: true,
|
|
909
|
+
force: true
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
/** The package the probe asks about. Not the CLI: the CLI is what is running. */
|
|
913
|
+
var RELEASE_PROBE_PACKAGE = "@rebasepro/server";
|
|
914
|
+
async function probeRelease(version) {
|
|
915
|
+
try {
|
|
916
|
+
const { stdout } = await execa("npm", [
|
|
917
|
+
"view",
|
|
918
|
+
`${RELEASE_PROBE_PACKAGE}@${version}`,
|
|
919
|
+
"version"
|
|
920
|
+
]);
|
|
921
|
+
if (stdout.trim() === version) return { kind: "published" };
|
|
922
|
+
return {
|
|
923
|
+
kind: "gap",
|
|
924
|
+
found: stdout.trim() || "nothing"
|
|
925
|
+
};
|
|
926
|
+
} catch (error) {
|
|
927
|
+
const text = `${error.stderr ?? ""}${error.message ?? ""}`;
|
|
928
|
+
if (/E404|404 Not Found|No match(ing)? version/i.test(text)) return {
|
|
929
|
+
kind: "gap",
|
|
930
|
+
found: "nothing"
|
|
931
|
+
};
|
|
932
|
+
return {
|
|
933
|
+
kind: "unreachable",
|
|
934
|
+
reason: firstLine(text) || "the registry could not be reached"
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
function firstLine(text) {
|
|
939
|
+
return text.split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "";
|
|
940
|
+
}
|
|
941
|
+
async function replacePlaceholders(options) {
|
|
942
|
+
const filesToProcess = TEMPLATE_PLACEHOLDER_FILES;
|
|
943
|
+
const version = cliVersion();
|
|
944
|
+
const fileContents = /* @__PURE__ */ new Map();
|
|
945
|
+
const allPackages = /* @__PURE__ */ new Set();
|
|
946
|
+
for (const file of filesToProcess) {
|
|
947
|
+
const fullPath = path.resolve(options.targetDirectory, file);
|
|
948
|
+
if (!fs.existsSync(fullPath)) continue;
|
|
949
|
+
const content = fs.readFileSync(fullPath, "utf-8");
|
|
950
|
+
fileContents.set(fullPath, content);
|
|
951
|
+
const matches = [...content.matchAll(/"(@rebasepro\/[^"]+)":\s*"workspace:\*"/g)];
|
|
952
|
+
for (const match of matches) allPackages.add(match[1]);
|
|
953
|
+
}
|
|
954
|
+
const probe = parseEnvBoolean(process.env.REBASE_E2E) === true || version === "unknown" ? { kind: "published" } : await probeRelease(version);
|
|
955
|
+
if (probe.kind === "gap") throw new Error(`Rebase ${version} is not fully published to npm.\n\n${RELEASE_PROBE_PACKAGE} has no ${version} release — the registry has ${probe.found}.\nEvery @rebasepro package ships at one version, so scaffolding would pin ${allPackages.size}\ndependencies at a version that is not there, and the install would fail.\n\nThat is a release gap in Rebase itself — not a problem with your machine,
|
|
956
|
+
your network, or your package manager.
|
|
957
|
+
|
|
958
|
+
Stopped before writing dependency versions or installing anything. The
|
|
959
|
+
project directory ${path.basename(options.targetDirectory)}/ was created and is safe to delete.\nPlease report this.`);
|
|
960
|
+
if (probe.kind === "published") console.log(chalk.gray(` Pinning ${allPackages.size} @rebasepro package(s) to ${version}...`));
|
|
961
|
+
if (probe.kind === "unreachable") {
|
|
962
|
+
console.log(chalk.gray(` Pinning @rebasepro packages to ${version} (this CLI's version).`));
|
|
963
|
+
console.log(chalk.yellow(` Could not reach npm to confirm the release: ${probe.reason}`));
|
|
964
|
+
console.log(chalk.gray(" The pin is right either way — install will say so if the version is missing."));
|
|
965
|
+
}
|
|
966
|
+
for (const [fullPath, originalContent] of fileContents.entries()) {
|
|
967
|
+
let content = originalContent.replace(/\{\{PROJECT_NAME\}\}/g, options.projectName);
|
|
968
|
+
for (const pkgName of allPackages) content = content.replace(new RegExp(`"${pkgName}":\\s*"workspace:\\*"`, "g"), `"${pkgName}": "${version}"`);
|
|
969
|
+
fs.writeFileSync(fullPath, content, "utf-8");
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
/** `undefined` binds the wildcard address, which is a different question — see isPortAvailable. */
|
|
973
|
+
function canBind(port, host) {
|
|
974
|
+
return new Promise((resolve) => {
|
|
975
|
+
const server = net.createServer();
|
|
976
|
+
server.once("error", (err) => {
|
|
977
|
+
resolve(err.code === "EAFNOSUPPORT" || err.code === "EADDRNOTAVAIL");
|
|
978
|
+
});
|
|
979
|
+
server.once("listening", () => {
|
|
980
|
+
server.close(() => resolve(true));
|
|
981
|
+
});
|
|
982
|
+
if (host === void 0) server.listen(port);
|
|
983
|
+
else server.listen(port, host);
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Whether `port` is free — on the wildcard address *and* on both loopback addresses.
|
|
988
|
+
*
|
|
989
|
+
* All three, because on macOS/BSD a successful bind does not mean the port is
|
|
990
|
+
* unused. Sockets carry `SO_REUSEADDR` (Node sets it), under which a wildcard
|
|
991
|
+
* bind and a specific-address bind on the same port do not conflict — in either
|
|
992
|
+
* direction. So each probe alone has a blind spot, and they are different ones:
|
|
993
|
+
*
|
|
994
|
+
* - **Wildcard only** (what this used to do) misses a server bound to
|
|
995
|
+
* `127.0.0.1` and `[::1]` — a Homebrew or Postgres.app install, i.e. most
|
|
996
|
+
* developer machines. 5432 was reported free while it was already serving
|
|
997
|
+
* another project's database. Docker then published `*:5432` for the same
|
|
998
|
+
* reason and the container started cleanly, with no "port already allocated"
|
|
999
|
+
* error anywhere to hint at the collision. `DATABASE_URL` pointed at
|
|
1000
|
+
* `localhost:5432`, `localhost` resolves to `::1` first, and every command
|
|
1001
|
+
* reported success while reading and writing the *pre-existing* database — a
|
|
1002
|
+
* `db push` would have created tables, roles and RLS policies inside it.
|
|
1003
|
+
*
|
|
1004
|
+
* - **Loopback only** misses the opposite case: Docker Desktop publishes a
|
|
1005
|
+
* container's port on `*`, and a specific-address bind succeeds right past it.
|
|
1006
|
+
* That port is free to probe and unusable to publish, so `docker compose up -d
|
|
1007
|
+
* db` fails on "Bind for 0.0.0.0:PORT failed: port is already allocated" —
|
|
1008
|
+
* loudly, but only after the project has been generated around the bad port.
|
|
1009
|
+
*
|
|
1010
|
+
* Requiring all three costs three sockets and leaves neither gap. The wildcard
|
|
1011
|
+
* bind is the one the container itself has to make; the loopback binds are the
|
|
1012
|
+
* addresses `DATABASE_URL` will actually name.
|
|
1013
|
+
*/
|
|
1014
|
+
async function isPortAvailable(port) {
|
|
1015
|
+
return await canBind(port) && await canBind(port, "127.0.0.1") && await canBind(port, "::1");
|
|
1016
|
+
}
|
|
1017
|
+
async function findAvailablePort(startPort) {
|
|
1018
|
+
let port = startPort;
|
|
1019
|
+
while (!await isPortAvailable(port)) port++;
|
|
1020
|
+
return port;
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* The CLI's own version, which is the runtime image tag a scaffolded project
|
|
1024
|
+
* pins. They move together: the CLI, the packages and the runtime image are cut
|
|
1025
|
+
* from one release, so the version that installed the project is the version
|
|
1026
|
+
* whose runtime can boot its bundle.
|
|
1027
|
+
*
|
|
1028
|
+
* Falls back to `latest` only when the CLI cannot read its own manifest — a
|
|
1029
|
+
* floating tag is worse than a pinned one, but better than a compose file that
|
|
1030
|
+
* names a version that was never published.
|
|
1031
|
+
*/
|
|
1032
|
+
function readCliVersion() {
|
|
1033
|
+
const version = cliVersion();
|
|
1034
|
+
return version === "unknown" ? "latest" : version;
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* The runtime image tag to pin, given the version of the CLI doing the scaffolding.
|
|
1038
|
+
*
|
|
1039
|
+
* Only a stable release publishes `rebasepro/server` — a multi-arch build on
|
|
1040
|
+
* every push to main would cost minutes per commit for an image nobody pulls.
|
|
1041
|
+
* So pinning a prerelease CLI's own version writes a tag that cannot exist, and
|
|
1042
|
+
* `docker compose up` fails on `manifest unknown`, which is the same dead end
|
|
1043
|
+
* as the missing-repository bug this pinning was added to prevent.
|
|
1044
|
+
*
|
|
1045
|
+
* A prerelease therefore falls back to `latest`, which is correct rather than
|
|
1046
|
+
* merely available: a bundle's manifest declares the runtime range it needs
|
|
1047
|
+
* (`^1`), the image supplies only `@rebasepro/server`, and the framework a
|
|
1048
|
+
* bundle runs is installed from its own `deps.declared` at boot. The current
|
|
1049
|
+
* stable runtime boots a canary bundle by design.
|
|
1050
|
+
*
|
|
1051
|
+
* A floating tag is a real cost — it is what pinning exists to avoid — so say
|
|
1052
|
+
* so in the file rather than leaving a reader to discover it.
|
|
1053
|
+
*/
|
|
1054
|
+
function resolveRuntimeImageTag(cliVersion) {
|
|
1055
|
+
if (/^\d+\.\d+\.\d+-/.test(cliVersion)) return {
|
|
1056
|
+
tag: "latest",
|
|
1057
|
+
note: `# Scaffolded by a prerelease CLI (${cliVersion}), which publishes no runtime image,\n# so this floats to the newest stable runtime. Pin an exact version once you
|
|
1058
|
+
# deploy: a moving tag changes what you are running with no version changing.`
|
|
1059
|
+
};
|
|
1060
|
+
return { tag: cliVersion };
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* Set `KEY=value` in a .env body, whether the key ships set, commented out or
|
|
1064
|
+
* missing entirely.
|
|
1065
|
+
*
|
|
1066
|
+
* The missing case is not hypothetical: `configureEnvFile` runs against
|
|
1067
|
+
* whatever `.env.example` is in the target directory, which for a project
|
|
1068
|
+
* scaffolded from an older template — or from one somebody edited — may not
|
|
1069
|
+
* carry a key this version of `init` is expected to write. Silently skipping it
|
|
1070
|
+
* produces a `.env` that satisfies no `${VAR:?…}` in the compose file, which
|
|
1071
|
+
* surfaces as a stack that refuses to interpolate rather than as anything
|
|
1072
|
+
* pointing back here.
|
|
1073
|
+
*/
|
|
1074
|
+
function setEnvValue(content, key, value, header) {
|
|
1075
|
+
const line = new RegExp(`^#?\\s*${key}=.*$`, "m");
|
|
1076
|
+
if (line.test(content)) return content.replace(line, `${key}=${value}`);
|
|
1077
|
+
return `${content.trimEnd()}\n\n${header ? `${header}\n` : ""}${key}=${value}\n`;
|
|
1078
|
+
}
|
|
1079
|
+
async function configureEnvFile(targetDirectory, databaseUrl) {
|
|
1080
|
+
const envExamplePath = path.join(targetDirectory, ".env.example");
|
|
1081
|
+
const envPath = path.join(targetDirectory, ".env");
|
|
1082
|
+
if (fs.existsSync(envExamplePath) && !fs.existsSync(envPath)) {
|
|
1083
|
+
fs.copyFileSync(envExamplePath, envPath);
|
|
1084
|
+
fs.chmodSync(envPath, 384);
|
|
1085
|
+
const jwtSecret = crypto.randomBytes(32).toString("hex");
|
|
1086
|
+
const dbPassword = crypto.randomBytes(16).toString("hex");
|
|
1087
|
+
const serviceKey = crypto.randomBytes(48).toString("base64");
|
|
1088
|
+
const adminPassword = crypto.randomBytes(18).toString("base64url");
|
|
1089
|
+
let envContent = fs.readFileSync(envPath, "utf-8");
|
|
1090
|
+
envContent = envContent.replace(/^(# ║ {2})(Copy this file to \.env and fill in the values)( +║)$/m, (_match, open, old, close) => {
|
|
1091
|
+
const replacement = "Generated by `rebase init` — the secrets below are already set";
|
|
1092
|
+
const width = old.length + close.length - 1;
|
|
1093
|
+
return open + replacement.slice(0, width).padEnd(width, " ") + "║";
|
|
1094
|
+
});
|
|
1095
|
+
envContent = envContent.replace(/^JWT_SECRET=.*$/m, `JWT_SECRET=${jwtSecret}`);
|
|
1096
|
+
envContent = envContent.replace(/^#\s*REBASE_SERVICE_KEY=.*$/m, `REBASE_SERVICE_KEY=${serviceKey}`);
|
|
1097
|
+
const composeApiPort = /^PORT=(\d+)/m.exec(envContent)?.[1] ?? "3001";
|
|
1098
|
+
envContent = envContent.replace(/^#\s*CORS_ORIGINS=.*$/m, `CORS_ORIGINS=http://localhost:${composeApiPort}`);
|
|
1099
|
+
envContent = setEnvValue(envContent, "REBASE_ADMIN_EMAIL", "admin@example.com", "# ── The first admin account, in production only ──────────────────────\n# `rebase dev` ignores both of these: on a laptop the first account to\n# register becomes the admin, and it says so at boot. In production —\n# `docker compose up`, or anything with NODE_ENV=production — that\n# window is shut, because the server answers on a hostname before you\n# have typed anything. So the operator names the account here instead.\n# Created once, while the user table is empty. Change the email to\n# yours, sign in, and change the password below.");
|
|
1100
|
+
envContent = setEnvValue(envContent, "REBASE_ADMIN_PASSWORD", adminPassword);
|
|
1101
|
+
envContent = envContent.replace(/^#?\s*VITE_API_URL=.*$/m, "VITE_API_URL=");
|
|
1102
|
+
const { tag: runtimeVersion, note } = resolveRuntimeImageTag(readCliVersion());
|
|
1103
|
+
const pinned = `${note ? `${note}\n` : ""}REBASE_VERSION=${runtimeVersion}`;
|
|
1104
|
+
envContent = /^#?\s*REBASE_VERSION=.*$/m.test(envContent) ? envContent.replace(/^#?\s*REBASE_VERSION=.*$/m, pinned) : `${envContent.trimEnd()}\n\n# The Rebase runtime image tag docker-compose.yml pulls.\n# Change this and restart to upgrade; your project bundle is untouched.\n${pinned}\n`;
|
|
1105
|
+
if (databaseUrl) {
|
|
1106
|
+
if (/[\r\n]/.test(databaseUrl)) throw new Error("Invalid DATABASE_URL: multiline values are not allowed.");
|
|
1107
|
+
const { pinSearchPath } = await import("@rebasepro/server-postgres");
|
|
1108
|
+
const pinnedUrl = pinSearchPath(databaseUrl);
|
|
1109
|
+
envContent = envContent.replace(/^DATABASE_URL=.*$/m, () => `DATABASE_URL=${pinnedUrl}\nDATABASE_PASSWORD=${dbPassword}`);
|
|
1110
|
+
} else {
|
|
1111
|
+
const dbPort = await findAvailablePort(5432);
|
|
1112
|
+
envContent = envContent.replace(/^DATABASE_URL=.*$/m, [
|
|
1113
|
+
"# Commented out on purpose — that is what selects the managed database.",
|
|
1114
|
+
"# To use the docker-compose Postgres this project ships instead:",
|
|
1115
|
+
"# docker compose up -d db, then uncomment the line below.",
|
|
1116
|
+
`# DATABASE_URL=postgresql://rebase_app:${dbPassword}@127.0.0.1:${dbPort}/rebase?options=-c%20search_path%3Dpublic&sslmode=disable`,
|
|
1117
|
+
`DATABASE_PASSWORD=${dbPassword}`
|
|
1118
|
+
].join("\n"));
|
|
1119
|
+
const envExampleReference = path.join(targetDirectory, ".env.example");
|
|
1120
|
+
if (fs.existsSync(envExampleReference)) {
|
|
1121
|
+
let exampleContent = fs.readFileSync(envExampleReference, "utf-8");
|
|
1122
|
+
exampleContent = exampleContent.replace(/^DATABASE_URL=postgresql:\/\/[^\n]*$/m, [
|
|
1123
|
+
"# Commented out on purpose — that is what selects the managed database.",
|
|
1124
|
+
"# To use the docker-compose Postgres this project ships instead:",
|
|
1125
|
+
"# docker compose up -d db, then uncomment the line below in .env",
|
|
1126
|
+
"# (with DATABASE_PASSWORD from that file, not the placeholder here).",
|
|
1127
|
+
`# DATABASE_URL=postgresql://rebase_app:changeme@127.0.0.1:${dbPort}/rebase?options=-c%20search_path%3Dpublic&sslmode=disable`
|
|
1128
|
+
].join("\n"));
|
|
1129
|
+
exampleContent = exampleContent.replaceAll("@localhost:5432/rebase", `@127.0.0.1:${dbPort}/rebase`);
|
|
1130
|
+
fs.writeFileSync(envExampleReference, exampleContent, "utf-8");
|
|
1131
|
+
}
|
|
1132
|
+
const dockerComposePath = path.join(targetDirectory, "docker-compose.yml");
|
|
1133
|
+
if (fs.existsSync(dockerComposePath)) {
|
|
1134
|
+
let dockerComposeContent = fs.readFileSync(dockerComposePath, "utf-8");
|
|
1135
|
+
dockerComposeContent = dockerComposeContent.replace(/-\s*"5432:5432"/g, `- "${dbPort}:5432"`);
|
|
1136
|
+
fs.writeFileSync(dockerComposePath, dockerComposeContent, "utf-8");
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
fs.writeFileSync(envPath, envContent, {
|
|
1140
|
+
encoding: "utf-8",
|
|
1141
|
+
mode: 384
|
|
1142
|
+
});
|
|
1143
|
+
fs.chmodSync(envPath, 384);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
//#endregion
|
|
1147
|
+
export { createRebaseApp };
|
|
1148
|
+
|
|
1149
|
+
//# sourceMappingURL=init-Utstvh2Q.js.map
|