@rebasepro/cli 0.17.3 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +0 -1
- package/README.md +21 -3
- package/bin/rebase.js +93 -6
- package/dist/branch-pointer-dBiClJ0p.js +111 -0
- package/dist/branch-pointer-dBiClJ0p.js.map +1 -0
- package/dist/bundle.d.ts +80 -3
- package/dist/cli.d.ts +22 -0
- package/dist/commands/auth.d.ts +13 -0
- package/dist/commands/cloud/action-help.d.ts +0 -10
- package/dist/commands/cloud/auth.d.ts +37 -0
- package/dist/commands/cloud/context.d.ts +90 -2
- package/dist/commands/cloud/databases.d.ts +0 -2
- package/dist/commands/cloud/declared-resources.d.ts +3 -0
- package/dist/commands/cloud/deploy.d.ts +76 -6
- package/dist/commands/cloud/deployments.d.ts +9 -2
- package/dist/commands/cloud/index.d.ts +36 -0
- package/dist/commands/cloud/link.d.ts +1 -1
- package/dist/commands/cloud/orgs.d.ts +6 -0
- package/dist/commands/cloud/resources.d.ts +81 -3
- package/dist/commands/cloud/settings.d.ts +7 -0
- package/dist/commands/db.d.ts +119 -9
- package/dist/commands/dev.d.ts +216 -1
- package/dist/commands/doctor.d.ts +51 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/skills.d.ts +82 -13
- package/dist/commands/status.d.ts +1 -0
- package/dist/commands/telemetry.d.ts +0 -8
- package/dist/{daemon-Bdl4lrdt.js → daemon-YDZK9NZ1.js} +88 -9
- package/dist/daemon-YDZK9NZ1.js.map +1 -0
- package/dist/{daemon-entry-CmJn83zu.js → daemon-entry-LTFKtpHy.js} +117 -44
- package/dist/daemon-entry-LTFKtpHy.js.map +1 -0
- package/dist/dev-db/branch-pointer.d.ts +71 -0
- package/dist/dev-db/daemon.d.ts +27 -0
- package/dist/dev-db/prepare.d.ts +62 -0
- package/dist/dev-db/pull.d.ts +28 -0
- package/dist/dev-db/resolve.d.ts +45 -6
- package/dist/dev-db/state.d.ts +21 -0
- package/dist/dev-preflight-CNLl4rdo.js +367 -0
- package/dist/dev-preflight-CNLl4rdo.js.map +1 -0
- package/dist/doctor-environment.d.ts +131 -0
- package/dist/function-portability.d.ts +7 -0
- package/dist/index.d.ts +25 -15
- package/dist/index.es.js +5594 -1741
- package/dist/index.es.js.map +1 -1
- package/dist/project-3WCbrrfW.js +499 -0
- package/dist/project-3WCbrrfW.js.map +1 -0
- package/dist/{pull-DqPRu1te.js → pull-Bj0XnH5s.js} +31 -2
- package/dist/pull-Bj0XnH5s.js.map +1 -0
- package/dist/resolve-Y56osuQH.js +74 -0
- package/dist/resolve-Y56osuQH.js.map +1 -0
- package/dist/resources/derive.d.ts +67 -1
- package/dist/resources/status.d.ts +128 -0
- package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
- package/dist/{state-c0CJ6Kwb.js → state-C59Elrnt.js} +24 -15
- package/dist/state-C59Elrnt.js.map +1 -0
- package/dist/utils/args.d.ts +18 -0
- package/dist/utils/command-words.d.ts +45 -0
- package/dist/utils/dev-preflight.d.ts +46 -0
- package/dist/utils/package-manager.d.ts +23 -0
- package/dist/utils/project.d.ts +66 -0
- package/dist/utils/spawn-error.d.ts +1 -0
- package/dist/utils/unknown-command.d.ts +26 -0
- package/dist/utils/version.d.ts +10 -0
- package/package.json +36 -28
- package/templates/eject/backend/src/env.ts +9 -0
- package/templates/eject/backend/src/index.ts +46 -19
- package/templates/eject/config/resources.ts +1 -1
- package/templates/overlays/baas/README.md +31 -11
- package/templates/overlays/baas/backend/package.json +2 -2
- package/templates/overlays/baas/backend/tsconfig.json +1 -1
- package/templates/overlays/baas/package.json +5 -2
- package/templates/overlays/baas/pnpm-workspace.yaml +13 -0
- package/templates/template/.cursorrules +1 -0
- package/templates/template/.env.example +91 -8
- package/templates/template/.github/copilot-instructions.md +1 -0
- package/templates/template/.mcp.json +11 -0
- package/templates/template/.windsurfrules +1 -0
- package/templates/template/AGENTS.md +1 -0
- package/templates/template/CLAUDE.md +2 -1
- package/templates/template/README.md +59 -28
- package/templates/template/ai-instructions.md +29 -12
- package/templates/template/backend/functions/hello.ts +5 -1
- package/templates/template/backend/package.json +2 -2
- package/templates/template/backend/src/schema.generated.ts +8 -3
- package/templates/template/backend/tsconfig.json +1 -1
- package/templates/template/config/cms.d.ts +6 -0
- package/templates/template/config/package.json +1 -1
- package/templates/template/config/resources.ts +42 -3
- package/templates/template/config/storage.ts +2 -1
- package/templates/template/docker-compose.yml +50 -9
- package/templates/template/frontend/index.html +0 -1
- package/templates/template/frontend/package.json +1 -2
- package/templates/template/frontend/src/App.tsx +8 -2
- package/templates/template/frontend/vite.config.ts +8 -0
- package/templates/template/gitignore +19 -1
- package/templates/template/npmrc +13 -4
- package/templates/template/package.json +10 -1
- package/templates/template/pnpm-workspace.yaml +17 -0
- package/templates/template/scripts/example.ts +4 -1
- package/dist/daemon-Bdl4lrdt.js.map +0 -1
- package/dist/daemon-entry-CmJn83zu.js.map +0 -1
- package/dist/pull-DqPRu1te.js.map +0 -1
- package/dist/state-c0CJ6Kwb.js.map +0 -1
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import { execSync, spawnSync } from "child_process";
|
|
6
|
+
import dotenv from "dotenv";
|
|
7
|
+
//#region src/utils/package-manager.ts
|
|
8
|
+
/**
|
|
9
|
+
* Package manager detection and command abstraction.
|
|
10
|
+
*
|
|
11
|
+
* Detects whether the user is running pnpm or npm and provides
|
|
12
|
+
* a unified interface for common package-manager operations so
|
|
13
|
+
* the rest of the CLI never has to hardcode a specific PM.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* How long to wait for `pnpm --version` before giving up on the probe.
|
|
17
|
+
*
|
|
18
|
+
* `pnpm --version` is a cold Node start, and on a machine that is busy — a
|
|
19
|
+
* parallel install, a full test run — it routinely takes seconds. Measured at
|
|
20
|
+
* 630ms, 990ms and 4293ms on three consecutive runs of one developer laptop
|
|
21
|
+
* under load, so the previous 3s budget was inside the normal spread rather
|
|
22
|
+
* than safely outside it.
|
|
23
|
+
*/
|
|
24
|
+
var PNPM_PROBE_TIMEOUT_MS = 5e3;
|
|
25
|
+
/** Memoised result of the probe. pnpm cannot appear or vanish mid-process. */
|
|
26
|
+
var cachedPnpmAvailable;
|
|
27
|
+
/**
|
|
28
|
+
* Decide availability from a `spawnSync` outcome.
|
|
29
|
+
*
|
|
30
|
+
* Split out from the spawn itself so the decision is testable without starting
|
|
31
|
+
* a process — which is what made the old test load-sensitive and occasionally
|
|
32
|
+
* red for reasons that had nothing to do with the code under test.
|
|
33
|
+
*
|
|
34
|
+
* The three outcomes are distinguishable, and the old code conflated two of
|
|
35
|
+
* them by asking only `status === 0`:
|
|
36
|
+
*
|
|
37
|
+
* not installed status null, signal null, error.code ENOENT
|
|
38
|
+
* timed out status null, signal SIGTERM, error.code ETIMEDOUT
|
|
39
|
+
* broken install status non-zero, no error
|
|
40
|
+
*/
|
|
41
|
+
function pnpmAvailabilityFromProbe(res) {
|
|
42
|
+
const code = res.error?.code;
|
|
43
|
+
if (code === "ENOENT") return false;
|
|
44
|
+
if (code === "ETIMEDOUT" || res.signal) return true;
|
|
45
|
+
if (res.error) return false;
|
|
46
|
+
return res.status === 0;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Whether pnpm is runnable on this machine.
|
|
50
|
+
*
|
|
51
|
+
* Used to decide whether a fresh project can be scaffolded with pnpm. Kept
|
|
52
|
+
* cheap and non-interactive (bounded timeout, output discarded) so it never
|
|
53
|
+
* hangs detection if a corepack shim misbehaves, and memoised so that repeated
|
|
54
|
+
* detection in one CLI run costs one process rather than one per call.
|
|
55
|
+
*/
|
|
56
|
+
function isPnpmAvailable() {
|
|
57
|
+
if (cachedPnpmAvailable !== void 0) return cachedPnpmAvailable;
|
|
58
|
+
try {
|
|
59
|
+
cachedPnpmAvailable = pnpmAvailabilityFromProbe(spawnSync("pnpm", ["--version"], {
|
|
60
|
+
stdio: "ignore",
|
|
61
|
+
timeout: PNPM_PROBE_TIMEOUT_MS
|
|
62
|
+
}));
|
|
63
|
+
} catch {
|
|
64
|
+
cachedPnpmAvailable = false;
|
|
65
|
+
}
|
|
66
|
+
return cachedPnpmAvailable;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Detect the package manager for a Rebase project.
|
|
70
|
+
*
|
|
71
|
+
* Rebase recommends pnpm, so detection prefers it. Crucially, *how the CLI was
|
|
72
|
+
* invoked* (`npx` vs `pnpm dlx`, i.e. `npm_config_user_agent`) is deliberately
|
|
73
|
+
* ignored: running `npx @rebasepro/cli init` says nothing about how the user
|
|
74
|
+
* wants to manage the project they're creating, and letting it pin the scaffold
|
|
75
|
+
* to npm is what made every `npx`-invoked project an npm project.
|
|
76
|
+
*
|
|
77
|
+
* Detection order:
|
|
78
|
+
* 1. An existing lock file — an explicit choice we always respect
|
|
79
|
+
* (`pnpm-lock.yaml` wins over `package-lock.json` when both are present).
|
|
80
|
+
* 2. pnpm, whenever it is installed.
|
|
81
|
+
* 3. npm, only as a fallback when pnpm is genuinely unavailable.
|
|
82
|
+
*/
|
|
83
|
+
function detectPackageManager(targetDir) {
|
|
84
|
+
const dirs = [targetDir, process.cwd()].filter((d) => !!d);
|
|
85
|
+
for (const dir of dirs) {
|
|
86
|
+
if (fs.existsSync(path.join(dir, "pnpm-lock.yaml"))) return "pnpm";
|
|
87
|
+
if (fs.existsSync(path.join(dir, "package-lock.json"))) return "npm";
|
|
88
|
+
for (const [lock, name] of [
|
|
89
|
+
["yarn.lock", "yarn"],
|
|
90
|
+
["bun.lockb", "bun"],
|
|
91
|
+
["bun.lock", "bun"]
|
|
92
|
+
]) {
|
|
93
|
+
if (!fs.existsSync(path.join(dir, lock))) continue;
|
|
94
|
+
console.warn(`[rebase] Found ${lock}, but Rebase scaffolds pnpm or npm workspaces only — ${name} is not supported yet. Continuing with ${isPnpmAvailable() ? "pnpm" : "npm"}.`);
|
|
95
|
+
return isPnpmAvailable() ? "pnpm" : "npm";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (isPnpmAvailable()) return "pnpm";
|
|
99
|
+
return "npm";
|
|
100
|
+
}
|
|
101
|
+
/** Build the command helpers for a given package manager. */
|
|
102
|
+
function getPMCommands(pm) {
|
|
103
|
+
if (pm === "npm") return {
|
|
104
|
+
name: "npm",
|
|
105
|
+
install: ["npm", "install"],
|
|
106
|
+
run: (script) => [
|
|
107
|
+
"npm",
|
|
108
|
+
"run",
|
|
109
|
+
script
|
|
110
|
+
],
|
|
111
|
+
exec: (bin, args) => [
|
|
112
|
+
"npx",
|
|
113
|
+
bin,
|
|
114
|
+
...args
|
|
115
|
+
],
|
|
116
|
+
view: (pkg, field) => [
|
|
117
|
+
"npm",
|
|
118
|
+
"view",
|
|
119
|
+
pkg,
|
|
120
|
+
field
|
|
121
|
+
],
|
|
122
|
+
runAll: (script) => [
|
|
123
|
+
"npm",
|
|
124
|
+
"run",
|
|
125
|
+
script,
|
|
126
|
+
"--workspaces",
|
|
127
|
+
"--if-present"
|
|
128
|
+
],
|
|
129
|
+
runWorkspace: (workspace, script) => [
|
|
130
|
+
"npm",
|
|
131
|
+
"run",
|
|
132
|
+
script,
|
|
133
|
+
"-w",
|
|
134
|
+
workspace
|
|
135
|
+
],
|
|
136
|
+
dlx: (pkg, args) => [
|
|
137
|
+
"npx",
|
|
138
|
+
"-y",
|
|
139
|
+
pkg,
|
|
140
|
+
...args
|
|
141
|
+
],
|
|
142
|
+
workspaceProtocol: "*"
|
|
143
|
+
};
|
|
144
|
+
return {
|
|
145
|
+
name: "pnpm",
|
|
146
|
+
install: ["pnpm", "install"],
|
|
147
|
+
run: (script) => [
|
|
148
|
+
"pnpm",
|
|
149
|
+
"run",
|
|
150
|
+
script
|
|
151
|
+
],
|
|
152
|
+
exec: (bin, args) => [
|
|
153
|
+
"pnpm",
|
|
154
|
+
"exec",
|
|
155
|
+
bin,
|
|
156
|
+
...args
|
|
157
|
+
],
|
|
158
|
+
view: (pkg, field) => [
|
|
159
|
+
"pnpm",
|
|
160
|
+
"view",
|
|
161
|
+
pkg,
|
|
162
|
+
field
|
|
163
|
+
],
|
|
164
|
+
runAll: (script) => [
|
|
165
|
+
"pnpm",
|
|
166
|
+
"-r",
|
|
167
|
+
"run",
|
|
168
|
+
script
|
|
169
|
+
],
|
|
170
|
+
runWorkspace: (workspace, script) => [
|
|
171
|
+
"pnpm",
|
|
172
|
+
"--filter",
|
|
173
|
+
`./${workspace}`,
|
|
174
|
+
"run",
|
|
175
|
+
script
|
|
176
|
+
],
|
|
177
|
+
dlx: (pkg, args) => [
|
|
178
|
+
"pnpm",
|
|
179
|
+
"dlx",
|
|
180
|
+
pkg,
|
|
181
|
+
...args
|
|
182
|
+
],
|
|
183
|
+
workspaceProtocol: "workspace:*"
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
//#endregion
|
|
187
|
+
//#region src/utils/project.ts
|
|
188
|
+
/**
|
|
189
|
+
* Project discovery utilities for the Rebase CLI.
|
|
190
|
+
*
|
|
191
|
+
* These helpers locate the project root, backend directory, .env file,
|
|
192
|
+
* and local binaries — used by all CLI command modules.
|
|
193
|
+
*/
|
|
194
|
+
var project_exports = /* @__PURE__ */ __exportAll({
|
|
195
|
+
MANIFEST_FILENAME: () => MANIFEST_FILENAME,
|
|
196
|
+
dependenciesNotInstalled: () => dependenciesNotInstalled,
|
|
197
|
+
exitDependenciesNotInstalled: () => exitDependenciesNotInstalled,
|
|
198
|
+
failAsJson: () => failAsJson,
|
|
199
|
+
findBackendDir: () => findBackendDir,
|
|
200
|
+
findEnvFile: () => findEnvFile,
|
|
201
|
+
findFrontendDir: () => findFrontendDir,
|
|
202
|
+
findProjectRoot: () => findProjectRoot,
|
|
203
|
+
getActiveBackendPlugin: () => getActiveBackendPlugin,
|
|
204
|
+
readEnvFile: () => readEnvFile,
|
|
205
|
+
requireBackendDir: () => requireBackendDir,
|
|
206
|
+
requireProjectRoot: () => requireProjectRoot,
|
|
207
|
+
resolveLocalBin: () => resolveLocalBin,
|
|
208
|
+
resolvePluginCliScript: () => resolvePluginCliScript,
|
|
209
|
+
resolveTsx: () => resolveTsx,
|
|
210
|
+
validateTsxInstallation: () => validateTsxInstallation,
|
|
211
|
+
wantsJsonOutput: () => wantsJsonOutput
|
|
212
|
+
});
|
|
213
|
+
/** The authored project manifest. Its presence alone marks a project root. */
|
|
214
|
+
var MANIFEST_FILENAME = "rebase.json";
|
|
215
|
+
/**
|
|
216
|
+
* Walk up from `startDir` to find the Rebase project root.
|
|
217
|
+
*
|
|
218
|
+
* A directory is the root when it holds a `rebase.json`, or when it holds a
|
|
219
|
+
* `package.json` that either lists `backend` as a workspace or sits beside both
|
|
220
|
+
* `backend/` and `config/`.
|
|
221
|
+
*
|
|
222
|
+
* `rebase.json` is checked first and needs no `package.json` beside it, because
|
|
223
|
+
* the conventions below all describe a repository that *contains the backend*.
|
|
224
|
+
* A repository holding only a frontend — the normal shape once a project's apps
|
|
225
|
+
* live in separate repositories — matches none of them, so without this the
|
|
226
|
+
* tooling could not run there at all.
|
|
227
|
+
*/
|
|
228
|
+
function findProjectRoot(startDir = process.cwd()) {
|
|
229
|
+
let dir = path.resolve(startDir);
|
|
230
|
+
const root = path.parse(dir).root;
|
|
231
|
+
while (dir !== root) {
|
|
232
|
+
if (fs.existsSync(path.join(dir, "rebase.json"))) return dir;
|
|
233
|
+
const pkgPath = path.join(dir, "package.json");
|
|
234
|
+
if (fs.existsSync(pkgPath)) {
|
|
235
|
+
try {
|
|
236
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
237
|
+
if (pkg.workspaces && Array.isArray(pkg.workspaces)) {
|
|
238
|
+
if (pkg.workspaces.some((w) => w === "backend")) return dir;
|
|
239
|
+
}
|
|
240
|
+
} catch {}
|
|
241
|
+
if (fs.existsSync(path.join(dir, "backend")) && fs.existsSync(path.join(dir, "config"))) return dir;
|
|
242
|
+
}
|
|
243
|
+
dir = path.dirname(dir);
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Locate the backend directory within the project root.
|
|
249
|
+
*/
|
|
250
|
+
function findBackendDir(projectRoot) {
|
|
251
|
+
const backendDir = path.join(projectRoot, "backend");
|
|
252
|
+
return fs.existsSync(backendDir) ? backendDir : null;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Detect the active backend plugin (e.g. @rebasepro/server-postgres) from the backend's package.json.
|
|
256
|
+
*/
|
|
257
|
+
function getActiveBackendPlugin(backendDir) {
|
|
258
|
+
const pkgPath = path.join(backendDir, "package.json");
|
|
259
|
+
if (!fs.existsSync(pkgPath)) return null;
|
|
260
|
+
try {
|
|
261
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
262
|
+
const deps = {
|
|
263
|
+
...pkg.dependencies,
|
|
264
|
+
...pkg.devDependencies
|
|
265
|
+
};
|
|
266
|
+
const candidates = Object.keys(deps).filter((dep) => dep.startsWith("@rebasepro/server-") && dep !== "@rebasepro/server");
|
|
267
|
+
if (candidates.length === 0) return null;
|
|
268
|
+
if (candidates.includes("@rebasepro/server-postgres")) return "@rebasepro/server-postgres";
|
|
269
|
+
for (const candidate of candidates) if (resolvePluginCliScript(backendDir, candidate)) return candidate;
|
|
270
|
+
return candidates[0];
|
|
271
|
+
} catch {}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Resolve the active plugin's CLI script.
|
|
276
|
+
*/
|
|
277
|
+
function resolvePluginCliScript(backendDir, pluginName) {
|
|
278
|
+
const candidates = [];
|
|
279
|
+
let dir = path.resolve(backendDir);
|
|
280
|
+
const fsRoot = path.parse(dir).root;
|
|
281
|
+
while (dir !== fsRoot) {
|
|
282
|
+
candidates.push(path.join(dir, "node_modules", pluginName, "src", "cli.ts"), path.join(dir, "node_modules", pluginName, "dist", "cli.js"));
|
|
283
|
+
dir = path.dirname(dir);
|
|
284
|
+
}
|
|
285
|
+
candidates.push(path.resolve(backendDir, "..", "..", "..", "packages", pluginName.replace("@rebasepro/", ""), "src", "cli.ts"), path.resolve(backendDir, "..", "..", "packages", pluginName.replace("@rebasepro/", ""), "src", "cli.ts"), path.resolve(backendDir, "..", "packages", pluginName.replace("@rebasepro/", ""), "src", "cli.ts"));
|
|
286
|
+
for (const candidate of candidates) if (fs.existsSync(candidate)) return candidate;
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Locate the frontend directory within the project root.
|
|
291
|
+
*/
|
|
292
|
+
function findFrontendDir(projectRoot) {
|
|
293
|
+
const frontendDir = path.join(projectRoot, "frontend");
|
|
294
|
+
return fs.existsSync(frontendDir) ? frontendDir : null;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Find the .env file. Checks the project root first, then backend.
|
|
298
|
+
*/
|
|
299
|
+
function findEnvFile(projectRoot) {
|
|
300
|
+
const candidates = [path.join(projectRoot, ".env"), path.join(projectRoot, "backend", ".env")];
|
|
301
|
+
for (const candidate of candidates) if (fs.existsSync(candidate)) return candidate;
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Read the project's `.env` into a plain object.
|
|
306
|
+
*
|
|
307
|
+
* One reader, because there were four: `dotenv` in `start`, a hand-rolled
|
|
308
|
+
* `indexOf("=")` loop in `api-keys`, a single-key regex in `auth`, and its own
|
|
309
|
+
* splitting in `cloud env`. `dotenv` is a declared dependency of this package,
|
|
310
|
+
* so the other three existed for no reason and disagreed with the correct one
|
|
311
|
+
* on the two things people actually write in a `.env`:
|
|
312
|
+
*
|
|
313
|
+
* - `export KEY=value`, which the hand-rolled parser keyed as
|
|
314
|
+
* `export KEY` — so the command reported the key as unset while it was
|
|
315
|
+
* right there in the file;
|
|
316
|
+
* - `KEY=value # comment`, whose comment travelled into the value and then
|
|
317
|
+
* into an `Authorization` header, coming back as a 401 with nothing
|
|
318
|
+
* pointing at the cause.
|
|
319
|
+
*
|
|
320
|
+
* Returns `{}` when the project has no `.env`, so callers can treat "absent"
|
|
321
|
+
* and "empty" alike.
|
|
322
|
+
*/
|
|
323
|
+
function readEnvFile(projectRoot) {
|
|
324
|
+
const envFile = findEnvFile(projectRoot);
|
|
325
|
+
if (!envFile || !fs.existsSync(envFile)) return {};
|
|
326
|
+
try {
|
|
327
|
+
return dotenv.parse(fs.readFileSync(envFile, "utf-8"));
|
|
328
|
+
} catch {
|
|
329
|
+
return {};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Resolve a binary from the project's node_modules/.bin.
|
|
334
|
+
* Checks backend, root, parent monorepo root, then falls back to PATH.
|
|
335
|
+
*/
|
|
336
|
+
function resolveLocalBin(projectRoot, binName) {
|
|
337
|
+
const candidates = [path.join(projectRoot, "backend", "node_modules", ".bin", binName), path.join(projectRoot, "node_modules", ".bin", binName)];
|
|
338
|
+
let parent = path.dirname(projectRoot);
|
|
339
|
+
const rootDir = path.parse(parent).root;
|
|
340
|
+
while (parent !== rootDir) {
|
|
341
|
+
candidates.push(path.join(parent, "node_modules", ".bin", binName));
|
|
342
|
+
parent = path.dirname(parent);
|
|
343
|
+
}
|
|
344
|
+
for (const candidate of candidates) if (fs.existsSync(candidate)) return candidate;
|
|
345
|
+
try {
|
|
346
|
+
const globalPath = execSync(`which ${binName}`, { encoding: "utf-8" }).trim();
|
|
347
|
+
if (globalPath && fs.existsSync(globalPath)) return globalPath;
|
|
348
|
+
} catch {}
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Resolve the tsx binary. Checks backend node_modules first, then root.
|
|
353
|
+
*/
|
|
354
|
+
function resolveTsx(projectRoot) {
|
|
355
|
+
return resolveLocalBin(projectRoot, "tsx");
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Validate that a resolved tsx binary actually has an intact installation.
|
|
359
|
+
*
|
|
360
|
+
* `resolveLocalBin` only checks whether `node_modules/.bin/tsx` (a symlink)
|
|
361
|
+
* exists. If the pnpm content-addressable store was cleaned or a previous
|
|
362
|
+
* install was interrupted, the symlink can exist while critical files inside
|
|
363
|
+
* the tsx package (e.g. `dist/preflight.cjs`) are missing — causing a
|
|
364
|
+
* confusing MODULE_NOT_FOUND error at runtime.
|
|
365
|
+
*
|
|
366
|
+
* This function follows the symlink, walks up to find the tsx package root
|
|
367
|
+
* (`package.json` with `name: "tsx"`), and verifies that `dist/preflight.cjs`
|
|
368
|
+
* is present. Returns `null` when the installation looks healthy, or an
|
|
369
|
+
* error description string when it appears corrupted.
|
|
370
|
+
*/
|
|
371
|
+
function validateTsxInstallation(tsxBinPath) {
|
|
372
|
+
try {
|
|
373
|
+
const realPath = fs.realpathSync(tsxBinPath);
|
|
374
|
+
let dir = path.dirname(realPath);
|
|
375
|
+
const fsRoot = path.parse(dir).root;
|
|
376
|
+
for (let depth = 0; depth < 10 && dir !== fsRoot; depth++) {
|
|
377
|
+
const pkgPath = path.join(dir, "package.json");
|
|
378
|
+
if (fs.existsSync(pkgPath)) try {
|
|
379
|
+
if (JSON.parse(fs.readFileSync(pkgPath, "utf-8")).name === "tsx") {
|
|
380
|
+
const preflightPath = path.join(dir, "dist", "preflight.cjs");
|
|
381
|
+
if (!fs.existsSync(preflightPath)) return `tsx package at ${dir} is missing dist/preflight.cjs`;
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
} catch {}
|
|
385
|
+
dir = path.dirname(dir);
|
|
386
|
+
}
|
|
387
|
+
return null;
|
|
388
|
+
} catch (err) {
|
|
389
|
+
return `tsx binary symlink is broken: ${err instanceof Error ? err.message : String(err)}`;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* The one sentence a command says when the project's dependencies are missing.
|
|
394
|
+
*
|
|
395
|
+
* Six commands each had their own wording for the same state, and none of them
|
|
396
|
+
* named the remedy:
|
|
397
|
+
*
|
|
398
|
+
* ✗ Could not find CLI entry point for @rebasepro/server-postgres.
|
|
399
|
+
* ✗ Could not find tsx binary.
|
|
400
|
+
* ✗ Could not find tsx binary for backend.
|
|
401
|
+
*
|
|
402
|
+
* All three mean "you have not installed yet" — `getActiveBackendPlugin` has
|
|
403
|
+
* already read the driver out of `backend/package.json` by the time the first
|
|
404
|
+
* one fires, so the package is declared and simply not on disk, and `tsx` is a
|
|
405
|
+
* devDependency of every scaffold. But they read as *Rebase* being broken, and
|
|
406
|
+
* they name an internal path or a binary the developer never asked for rather
|
|
407
|
+
* than the command that fixes it. That is the whole of the failure someone sees
|
|
408
|
+
* on a fresh clone, where `node_modules/` is the one thing a checkout does not
|
|
409
|
+
* carry.
|
|
410
|
+
*
|
|
411
|
+
* So: one sentence, naming the package manager this project uses and the
|
|
412
|
+
* directory to run it in — the working directory is usually neither.
|
|
413
|
+
*/
|
|
414
|
+
function dependenciesNotInstalled(projectRoot) {
|
|
415
|
+
return `Dependencies are not installed — run \`${getPMCommands(detectPackageManager(projectRoot)).install.join(" ")}\` in ${projectRoot}`;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Print {@link dependenciesNotInstalled} and exit 1.
|
|
419
|
+
*
|
|
420
|
+
* For the five commands that report and stop. `db.ts` throws instead, because
|
|
421
|
+
* its caller adds the `✗` and the exit itself.
|
|
422
|
+
*/
|
|
423
|
+
function exitDependenciesNotInstalled(projectRoot) {
|
|
424
|
+
console.error(chalk.red(`✗ ${dependenciesNotInstalled(projectRoot)}`));
|
|
425
|
+
process.exit(1);
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* The refusal envelope, for a caller that asked for JSON.
|
|
429
|
+
*
|
|
430
|
+
* Same shape as the cloud family's `fail()` — `{ error: { message, code, hint } }`
|
|
431
|
+
* — because there is one CLI and a caller should not have to know which half of
|
|
432
|
+
* it answered. `code` is what a caller branches on and is never absent; the
|
|
433
|
+
* cloud family's reasoning about that applies here unchanged. `issues` carries
|
|
434
|
+
* the per-problem detail a human reader gets as a bullet list, because a
|
|
435
|
+
* refusal that says "2 problem(s)" and does not say which two is not
|
|
436
|
+
* machine-readable in any useful sense.
|
|
437
|
+
*
|
|
438
|
+
* On **stdout**, like every other `--json` result: the contract those commands
|
|
439
|
+
* make is that stdout holds one JSON value, and a caller that pipes stdout to a
|
|
440
|
+
* parser must get a parseable refusal rather than an empty stream and a
|
|
441
|
+
* human sentence it never sees. That contract used to hold for exactly one
|
|
442
|
+
* failure of each command — the one that goes through `requireProjectRoot` —
|
|
443
|
+
* while every other exit of the *same* command wrote human text to stderr and
|
|
444
|
+
* left stdout empty. So `rebase status --json` was parseable outside a project
|
|
445
|
+
* and unparseable inside a broken one, which is the case a caller actually has
|
|
446
|
+
* to handle.
|
|
447
|
+
*
|
|
448
|
+
* The flag is read off `process.argv` because the failure happens inside a
|
|
449
|
+
* helper the command calls before it has parsed anything. Coarse — a literal
|
|
450
|
+
* `--json` as some other flag's value would count — and worth it: the failure
|
|
451
|
+
* mode of being coarse is a JSON error where a human one was wanted, and the
|
|
452
|
+
* failure mode of not doing it is an unparseable stream.
|
|
453
|
+
*/
|
|
454
|
+
function failAsJson(message, code, hint, issues) {
|
|
455
|
+
const error = {
|
|
456
|
+
message,
|
|
457
|
+
code
|
|
458
|
+
};
|
|
459
|
+
if (hint !== void 0) error.hint = hint;
|
|
460
|
+
if (issues !== void 0) error.issues = issues;
|
|
461
|
+
console.log(JSON.stringify({ error }, null, 2));
|
|
462
|
+
process.exit(1);
|
|
463
|
+
}
|
|
464
|
+
/** Did the command line ask for machine-readable output? */
|
|
465
|
+
function wantsJsonOutput(argv = process.argv) {
|
|
466
|
+
return argv.includes("--json");
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Require the project root or exit with a helpful error.
|
|
470
|
+
*/
|
|
471
|
+
function requireProjectRoot() {
|
|
472
|
+
const root = findProjectRoot();
|
|
473
|
+
if (!root) {
|
|
474
|
+
if (wantsJsonOutput()) failAsJson("Could not find a Rebase project root.", "no_project_root", `Looked in this directory and every parent for a ${MANIFEST_FILENAME}, a package.json with a "backend" workspace, or a backend/ next to a config/.`);
|
|
475
|
+
console.error(chalk.red("✗ Could not find a Rebase project root."));
|
|
476
|
+
console.error(chalk.gray(` Looked in this directory and every parent for a ${MANIFEST_FILENAME},`));
|
|
477
|
+
console.error(chalk.gray(" a package.json with a \"backend\" workspace, or a backend/ next to a config/."));
|
|
478
|
+
console.error(chalk.gray(" Run this from inside a project, or create one with `rebase init`."));
|
|
479
|
+
process.exit(1);
|
|
480
|
+
}
|
|
481
|
+
return root;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Require the backend directory or exit with a helpful error.
|
|
485
|
+
*/
|
|
486
|
+
function requireBackendDir(projectRoot) {
|
|
487
|
+
const backendDir = findBackendDir(projectRoot);
|
|
488
|
+
if (!backendDir) {
|
|
489
|
+
if (wantsJsonOutput()) failAsJson("Could not find a backend/ directory.", "no_backend_dir", `Expected at: ${path.join(projectRoot, "backend")}`);
|
|
490
|
+
console.error(chalk.red("✗ Could not find a backend/ directory."));
|
|
491
|
+
console.error(chalk.gray(` Expected at: ${path.join(projectRoot, "backend")}`));
|
|
492
|
+
process.exit(1);
|
|
493
|
+
}
|
|
494
|
+
return backendDir;
|
|
495
|
+
}
|
|
496
|
+
//#endregion
|
|
497
|
+
export { validateTsxInstallation as _, findBackendDir as a, findProjectRoot as c, readEnvFile as d, requireBackendDir as f, resolveTsx as g, resolvePluginCliScript as h, failAsJson as i, getActiveBackendPlugin as l, resolveLocalBin as m, dependenciesNotInstalled as n, findEnvFile as o, requireProjectRoot as p, exitDependenciesNotInstalled as r, findFrontendDir as s, MANIFEST_FILENAME as t, project_exports as u, detectPackageManager as v, getPMCommands as y };
|
|
498
|
+
|
|
499
|
+
//# sourceMappingURL=project-3WCbrrfW.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"project-3WCbrrfW.js","names":[],"sources":["../src/utils/package-manager.ts","../src/utils/project.ts"],"sourcesContent":["/**\n * Package manager detection and command abstraction.\n *\n * Detects whether the user is running pnpm or npm and provides\n * a unified interface for common package-manager operations so\n * the rest of the CLI never has to hardcode a specific PM.\n */\nimport fs from \"fs\";\nimport path from \"path\";\nimport { spawnSync } from \"child_process\";\n\n/**\n * The two package managers the CLI can scaffold for.\n *\n * Yarn and bun are deliberately absent, and this is the note that says so\n * rather than leaving it to be discovered. A yarn or bun user gets an npm\n * project: `detectPackageManager` finds no `pnpm-lock.yaml` and no\n * `package-lock.json`, falls through to the pnpm probe, and lands on npm —\n * silently, and with a `yarn.lock` or `bun.lockb` sitting right there in the\n * directory it was asked about.\n *\n * The reason is not indifference. Every generated project is a workspace, and\n * the scaffold writes one protocol for the workspace links —\n * `workspaceProtocol` below is `\"workspace:*\"` or `\"*\"`. Yarn understands\n * `workspace:*` and bun does not; the two also disagree with pnpm about where\n * `node_modules` goes, which is what `rebase dev` walks to find the backend.\n * Supporting them means a fourth and fifth scaffold shape, each needing its own\n * end-to-end test, and neither exists.\n *\n * So the honest position is: pnpm (recommended) or npm. A yarn or bun user is\n * better served knowing they will get an npm workspace than finding out from\n * the lockfile that appears after their first install. See\n * {@link detectPackageManager}, which now says so out loud.\n */\nexport type PackageManager = \"pnpm\" | \"npm\";\n\nexport interface PMCommands {\n /** The binary name (\"pnpm\" | \"npm\"). */\n name: PackageManager;\n /** Install all dependencies — e.g. `pnpm install` / `npm install`. */\n install: string[];\n /** Run a script — e.g. `pnpm run dev` / `npm run dev`. */\n run: (script: string) => string[];\n /** Execute a local bin — e.g. `pnpm exec rebase ...` / `npx rebase ...`. */\n exec: (bin: string, args: string[]) => string[];\n /** Query the registry — e.g. `pnpm view <pkg> version` / `npm view <pkg> version`. */\n view: (pkg: string, field: string) => string[];\n /** Run all workspace scripts — e.g. `pnpm -r run build` / `npm run build --workspaces`. */\n runAll: (script: string) => string[];\n /** Run a script in a specific workspace — e.g. `pnpm --filter \"*-backend\" start` / `npm run start -w backend`. */\n runWorkspace: (workspace: string, script: string) => string[];\n /** Execute a one-off package — e.g. `pnpm dlx skills ...` / `npx -y skills ...`. */\n dlx: (pkg: string, args: string[]) => string[];\n /** The workspace dependency protocol: `\"workspace:*\"` for pnpm, `\"*\"` for npm. */\n workspaceProtocol: string;\n}\n\n/**\n * How long to wait for `pnpm --version` before giving up on the probe.\n *\n * `pnpm --version` is a cold Node start, and on a machine that is busy — a\n * parallel install, a full test run — it routinely takes seconds. Measured at\n * 630ms, 990ms and 4293ms on three consecutive runs of one developer laptop\n * under load, so the previous 3s budget was inside the normal spread rather\n * than safely outside it.\n */\nconst PNPM_PROBE_TIMEOUT_MS = 5000;\n\n/** Memoised result of the probe. pnpm cannot appear or vanish mid-process. */\nlet cachedPnpmAvailable: boolean | undefined;\n\n/**\n * Decide availability from a `spawnSync` outcome.\n *\n * Split out from the spawn itself so the decision is testable without starting\n * a process — which is what made the old test load-sensitive and occasionally\n * red for reasons that had nothing to do with the code under test.\n *\n * The three outcomes are distinguishable, and the old code conflated two of\n * them by asking only `status === 0`:\n *\n * not installed status null, signal null, error.code ENOENT\n * timed out status null, signal SIGTERM, error.code ETIMEDOUT\n * broken install status non-zero, no error\n */\nexport function pnpmAvailabilityFromProbe(res: {\n status: number | null;\n signal?: NodeJS.Signals | null;\n error?: { code?: string } | Error;\n}): boolean {\n const code = (res.error as { code?: string } | undefined)?.code;\n\n // The binary is not on PATH. Genuinely absent.\n if (code === \"ENOENT\") return false;\n\n // We killed it for taking too long. That means it WAS found and did start,\n // so pnpm is installed — it was merely slow, which on a loaded machine is\n // routine rather than exceptional. Reporting \"absent\" here is what silently\n // scaffolded npm projects for developers who had pnpm all along.\n //\n // The trade-off, stated plainly: a pnpm that hangs forever (a misbehaving\n // corepack shim) now resolves to pnpm instead of falling back to npm. That\n // is the rarer and more visible failure — the user sees their next command\n // hang — whereas the case this fixes was silent and produced a project\n // pinned to the wrong package manager. The timeout still bounds how long\n // detection itself waits, which was always its real job.\n if (code === \"ETIMEDOUT\" || res.signal) return true;\n\n // Any other spawn error: treat as unavailable rather than guess.\n if (res.error) return false;\n\n return res.status === 0;\n}\n\n/**\n * Whether pnpm is runnable on this machine.\n *\n * Used to decide whether a fresh project can be scaffolded with pnpm. Kept\n * cheap and non-interactive (bounded timeout, output discarded) so it never\n * hangs detection if a corepack shim misbehaves, and memoised so that repeated\n * detection in one CLI run costs one process rather than one per call.\n */\nexport function isPnpmAvailable(): boolean {\n if (cachedPnpmAvailable !== undefined) return cachedPnpmAvailable;\n try {\n const res = spawnSync(\"pnpm\", [\"--version\"], {\n stdio: \"ignore\",\n timeout: PNPM_PROBE_TIMEOUT_MS\n });\n cachedPnpmAvailable = pnpmAvailabilityFromProbe(res);\n } catch {\n cachedPnpmAvailable = false;\n }\n return cachedPnpmAvailable;\n}\n\n/** Forget the memoised probe. For tests; nothing in a CLI run needs it. */\nexport function resetPnpmAvailabilityCache(): void {\n cachedPnpmAvailable = undefined;\n}\n\n/**\n * Detect the package manager for a Rebase project.\n *\n * Rebase recommends pnpm, so detection prefers it. Crucially, *how the CLI was\n * invoked* (`npx` vs `pnpm dlx`, i.e. `npm_config_user_agent`) is deliberately\n * ignored: running `npx @rebasepro/cli init` says nothing about how the user\n * wants to manage the project they're creating, and letting it pin the scaffold\n * to npm is what made every `npx`-invoked project an npm project.\n *\n * Detection order:\n * 1. An existing lock file — an explicit choice we always respect\n * (`pnpm-lock.yaml` wins over `package-lock.json` when both are present).\n * 2. pnpm, whenever it is installed.\n * 3. npm, only as a fallback when pnpm is genuinely unavailable.\n */\nexport function detectPackageManager(targetDir?: string): PackageManager {\n // 1. Respect an existing project's lock file.\n const dirs = [targetDir, process.cwd()].filter((d): d is string => !!d);\n for (const dir of dirs) {\n if (fs.existsSync(path.join(dir, \"pnpm-lock.yaml\"))) return \"pnpm\";\n if (fs.existsSync(path.join(dir, \"package-lock.json\"))) return \"npm\";\n\n // A yarn or bun lockfile is a stated choice this CLI cannot honour —\n // see the note on `PackageManager`. Say so once, here, rather than\n // scaffolding an npm workspace beside their lockfile without comment.\n for (const [lock, name] of [[\"yarn.lock\", \"yarn\"], [\"bun.lockb\", \"bun\"], [\"bun.lock\", \"bun\"]] as const) {\n if (!fs.existsSync(path.join(dir, lock))) continue;\n console.warn(\n `[rebase] Found ${lock}, but Rebase scaffolds pnpm or npm workspaces only — ` +\n `${name} is not supported yet. Continuing with ` +\n `${isPnpmAvailable() ? \"pnpm\" : \"npm\"}.`\n );\n return isPnpmAvailable() ? \"pnpm\" : \"npm\";\n }\n }\n\n // 2. Prefer pnpm whenever it's installed.\n if (isPnpmAvailable()) return \"pnpm\";\n\n // 3. Fall back to npm only when pnpm is genuinely unavailable.\n return \"npm\";\n}\n\n/** Build the command helpers for a given package manager. */\nexport function getPMCommands(pm: PackageManager): PMCommands {\n if (pm === \"npm\") {\n return {\n name: \"npm\",\n install: [\"npm\", \"install\"],\n run: (script) => [\"npm\", \"run\", script],\n exec: (bin, args) => [\"npx\", bin, ...args],\n view: (pkg, field) => [\"npm\", \"view\", pkg, field],\n runAll: (script) => [\"npm\", \"run\", script, \"--workspaces\", \"--if-present\"],\n runWorkspace: (workspace, script) => [\"npm\", \"run\", script, \"-w\", workspace],\n dlx: (pkg, args) => [\"npx\", \"-y\", pkg, ...args],\n workspaceProtocol: \"*\"\n };\n }\n\n return {\n name: \"pnpm\",\n install: [\"pnpm\", \"install\"],\n run: (script) => [\"pnpm\", \"run\", script],\n exec: (bin, args) => [\"pnpm\", \"exec\", bin, ...args],\n view: (pkg, field) => [\"pnpm\", \"view\", pkg, field],\n runAll: (script) => [\"pnpm\", \"-r\", \"run\", script],\n // Filter by directory (`./backend`), not name: pnpm's `--filter` matches\n // the package *name* (e.g. `my-app-backend`), so a bare `backend` matches\n // nothing. npm's `-w` is path-based, which is why this only bites pnpm.\n runWorkspace: (workspace, script) => [\"pnpm\", \"--filter\", `./${workspace}`, \"run\", script],\n dlx: (pkg, args) => [\"pnpm\", \"dlx\", pkg, ...args],\n workspaceProtocol: \"workspace:*\"\n };\n}\n","/**\n * Project discovery utilities for the Rebase CLI.\n *\n * These helpers locate the project root, backend directory, .env file,\n * and local binaries — used by all CLI command modules.\n */\nimport fs from \"fs\";\nimport path from \"path\";\nimport { execSync } from \"child_process\";\nimport dotenv from \"dotenv\";\nimport chalk from \"chalk\";\nimport { detectPackageManager, getPMCommands } from \"./package-manager\";\n\n/** The authored project manifest. Its presence alone marks a project root. */\nexport const MANIFEST_FILENAME = \"rebase.json\";\n\n/**\n * Walk up from `startDir` to find the Rebase project root.\n *\n * A directory is the root when it holds a `rebase.json`, or when it holds a\n * `package.json` that either lists `backend` as a workspace or sits beside both\n * `backend/` and `config/`.\n *\n * `rebase.json` is checked first and needs no `package.json` beside it, because\n * the conventions below all describe a repository that *contains the backend*.\n * A repository holding only a frontend — the normal shape once a project's apps\n * live in separate repositories — matches none of them, so without this the\n * tooling could not run there at all.\n */\nexport function findProjectRoot(startDir: string = process.cwd()): string | null {\n let dir = path.resolve(startDir);\n const root = path.parse(dir).root;\n\n while (dir !== root) {\n if (fs.existsSync(path.join(dir, MANIFEST_FILENAME))) {\n return dir;\n }\n\n const pkgPath = path.join(dir, \"package.json\");\n\n if (fs.existsSync(pkgPath)) {\n try {\n const pkg = JSON.parse(fs.readFileSync(pkgPath, \"utf-8\"));\n // Check for workspace-based project (monorepo root)\n if (pkg.workspaces && Array.isArray(pkg.workspaces)) {\n const hasBackend = pkg.workspaces.some((w: string) =>\n w === \"backend\"\n );\n if (hasBackend) return dir;\n }\n } catch {\n // ignore parse errors\n }\n\n // Check for sibling backend directory\n if (fs.existsSync(path.join(dir, \"backend\")) && fs.existsSync(path.join(dir, \"config\"))) {\n return dir;\n }\n }\n\n dir = path.dirname(dir);\n }\n\n return null;\n}\n\n/**\n * Locate the backend directory within the project root.\n */\nexport function findBackendDir(projectRoot: string): string | null {\n const backendDir = path.join(projectRoot, \"backend\");\n return fs.existsSync(backendDir) ? backendDir : null;\n}\n\n/**\n * Detect the active backend plugin (e.g. @rebasepro/server-postgres) from the backend's package.json.\n */\nexport function getActiveBackendPlugin(backendDir: string): string | null {\n const pkgPath = path.join(backendDir, \"package.json\");\n if (!fs.existsSync(pkgPath)) return null;\n\n try {\n const pkg = JSON.parse(fs.readFileSync(pkgPath, \"utf-8\"));\n const deps = { ...pkg.dependencies,\n...pkg.devDependencies };\n\n // Collect all @rebasepro/server-* driver plugins (exclude server itself)\n const candidates = Object.keys(deps).filter(\n dep => dep.startsWith(\"@rebasepro/server-\") && dep !== \"@rebasepro/server\"\n );\n\n if (candidates.length === 0) return null;\n\n // Prefer server-postgres — it's the primary supported driver\n if (candidates.includes(\"@rebasepro/server-postgres\")) {\n return \"@rebasepro/server-postgres\";\n }\n\n // Fallback: return the first candidate that actually has a CLI entry point\n for (const candidate of candidates) {\n if (resolvePluginCliScript(backendDir, candidate)) {\n return candidate;\n }\n }\n\n // Last resort: return whatever we found\n return candidates[0];\n } catch {\n // Ignore parse errors\n }\n return null;\n}\n\n/**\n * Resolve the active plugin's CLI script.\n */\nexport function resolvePluginCliScript(backendDir: string, pluginName: string): string | null {\n const candidates: string[] = [];\n\n // Walk up from the backend dir: pnpm links the plugin into\n // backend/node_modules, while npm workspaces hoist it to the project (or an\n // enclosing monorepo) root.\n let dir = path.resolve(backendDir);\n const fsRoot = path.parse(dir).root;\n while (dir !== fsRoot) {\n candidates.push(\n path.join(dir, \"node_modules\", pluginName, \"src\", \"cli.ts\"),\n path.join(dir, \"node_modules\", pluginName, \"dist\", \"cli.js\")\n );\n dir = path.dirname(dir);\n }\n\n candidates.push(\n // For monorepo dev mode:\n path.resolve(backendDir, \"..\", \"..\", \"..\", \"packages\", pluginName.replace(\"@rebasepro/\", \"\"), \"src\", \"cli.ts\"),\n path.resolve(backendDir, \"..\", \"..\", \"packages\", pluginName.replace(\"@rebasepro/\", \"\"), \"src\", \"cli.ts\"),\n path.resolve(backendDir, \"..\", \"packages\", pluginName.replace(\"@rebasepro/\", \"\"), \"src\", \"cli.ts\")\n );\n\n for (const candidate of candidates) {\n if (fs.existsSync(candidate)) return candidate;\n }\n return null;\n}\n\n/**\n * Locate the frontend directory within the project root.\n */\nexport function findFrontendDir(projectRoot: string): string | null {\n const frontendDir = path.join(projectRoot, \"frontend\");\n return fs.existsSync(frontendDir) ? frontendDir : null;\n}\n\n/**\n * Find the .env file. Checks the project root first, then backend.\n */\nexport function findEnvFile(projectRoot: string): string | null {\n const candidates = [\n path.join(projectRoot, \".env\"),\n path.join(projectRoot, \"backend\", \".env\")\n ];\n\n for (const candidate of candidates) {\n if (fs.existsSync(candidate)) return candidate;\n }\n\n return null;\n}\n\n/**\n * Read the project's `.env` into a plain object.\n *\n * One reader, because there were four: `dotenv` in `start`, a hand-rolled\n * `indexOf(\"=\")` loop in `api-keys`, a single-key regex in `auth`, and its own\n * splitting in `cloud env`. `dotenv` is a declared dependency of this package,\n * so the other three existed for no reason and disagreed with the correct one\n * on the two things people actually write in a `.env`:\n *\n * - `export KEY=value`, which the hand-rolled parser keyed as\n * `export KEY` — so the command reported the key as unset while it was\n * right there in the file;\n * - `KEY=value # comment`, whose comment travelled into the value and then\n * into an `Authorization` header, coming back as a 401 with nothing\n * pointing at the cause.\n *\n * Returns `{}` when the project has no `.env`, so callers can treat \"absent\"\n * and \"empty\" alike.\n */\nexport function readEnvFile(projectRoot: string): Record<string, string> {\n const envFile = findEnvFile(projectRoot);\n if (!envFile || !fs.existsSync(envFile)) return {};\n try {\n return dotenv.parse(fs.readFileSync(envFile, \"utf-8\"));\n } catch {\n // An unreadable or malformed file is not worth failing a command over;\n // the caller reports the missing value it was looking for.\n return {};\n }\n}\n\n/**\n * Resolve a binary from the project's node_modules/.bin.\n * Checks backend, root, parent monorepo root, then falls back to PATH.\n */\nexport function resolveLocalBin(projectRoot: string, binName: string): string | null {\n const candidates = [\n path.join(projectRoot, \"backend\", \"node_modules\", \".bin\", binName),\n path.join(projectRoot, \"node_modules\", \".bin\", binName)\n ];\n\n // Also check parent directories (for monorepo setups where app/ is nested)\n let parent = path.dirname(projectRoot);\n const rootDir = path.parse(parent).root;\n while (parent !== rootDir) {\n candidates.push(path.join(parent, \"node_modules\", \".bin\", binName));\n parent = path.dirname(parent);\n }\n\n for (const candidate of candidates) {\n if (fs.existsSync(candidate)) return candidate;\n }\n\n // Fall back to globally installed binary via which\n try {\n const globalPath = execSync(`which ${binName}`, { encoding: \"utf-8\" }).trim();\n if (globalPath && fs.existsSync(globalPath)) return globalPath;\n } catch {\n // not found globally\n }\n\n return null;\n}\n\n/**\n * Resolve the tsx binary. Checks backend node_modules first, then root.\n */\nexport function resolveTsx(projectRoot: string): string | null {\n return resolveLocalBin(projectRoot, \"tsx\");\n}\n\n/**\n * Validate that a resolved tsx binary actually has an intact installation.\n *\n * `resolveLocalBin` only checks whether `node_modules/.bin/tsx` (a symlink)\n * exists. If the pnpm content-addressable store was cleaned or a previous\n * install was interrupted, the symlink can exist while critical files inside\n * the tsx package (e.g. `dist/preflight.cjs`) are missing — causing a\n * confusing MODULE_NOT_FOUND error at runtime.\n *\n * This function follows the symlink, walks up to find the tsx package root\n * (`package.json` with `name: \"tsx\"`), and verifies that `dist/preflight.cjs`\n * is present. Returns `null` when the installation looks healthy, or an\n * error description string when it appears corrupted.\n */\nexport function validateTsxInstallation(tsxBinPath: string): string | null {\n try {\n // Follow the symlink chain to the real tsx entry script\n const realPath = fs.realpathSync(tsxBinPath);\n\n // Walk up from the real binary to locate the tsx package root\n let dir = path.dirname(realPath);\n const fsRoot = path.parse(dir).root;\n for (let depth = 0; depth < 10 && dir !== fsRoot; depth++) {\n const pkgPath = path.join(dir, \"package.json\");\n if (fs.existsSync(pkgPath)) {\n try {\n const pkg = JSON.parse(fs.readFileSync(pkgPath, \"utf-8\"));\n if (pkg.name === \"tsx\") {\n // Found the tsx package root — verify critical preload file\n const preflightPath = path.join(dir, \"dist\", \"preflight.cjs\");\n if (!fs.existsSync(preflightPath)) {\n return `tsx package at ${dir} is missing dist/preflight.cjs`;\n }\n return null; // Installation looks healthy\n }\n } catch {\n // Malformed package.json — keep walking\n }\n }\n dir = path.dirname(dir);\n }\n\n // Could not determine tsx root — don't block, assume valid\n return null;\n } catch (err) {\n // realpathSync throws if the symlink target is completely gone\n return `tsx binary symlink is broken: ${err instanceof Error ? err.message : String(err)}`;\n }\n}\n\n/**\n * The one sentence a command says when the project's dependencies are missing.\n *\n * Six commands each had their own wording for the same state, and none of them\n * named the remedy:\n *\n * ✗ Could not find CLI entry point for @rebasepro/server-postgres.\n * ✗ Could not find tsx binary.\n * ✗ Could not find tsx binary for backend.\n *\n * All three mean \"you have not installed yet\" — `getActiveBackendPlugin` has\n * already read the driver out of `backend/package.json` by the time the first\n * one fires, so the package is declared and simply not on disk, and `tsx` is a\n * devDependency of every scaffold. But they read as *Rebase* being broken, and\n * they name an internal path or a binary the developer never asked for rather\n * than the command that fixes it. That is the whole of the failure someone sees\n * on a fresh clone, where `node_modules/` is the one thing a checkout does not\n * carry.\n *\n * So: one sentence, naming the package manager this project uses and the\n * directory to run it in — the working directory is usually neither.\n */\nexport function dependenciesNotInstalled(projectRoot: string): string {\n const install = getPMCommands(detectPackageManager(projectRoot)).install.join(\" \");\n return `Dependencies are not installed — run \\`${install}\\` in ${projectRoot}`;\n}\n\n/**\n * Print {@link dependenciesNotInstalled} and exit 1.\n *\n * For the five commands that report and stop. `db.ts` throws instead, because\n * its caller adds the `✗` and the exit itself.\n */\nexport function exitDependenciesNotInstalled(projectRoot: string): never {\n console.error(chalk.red(`✗ ${dependenciesNotInstalled(projectRoot)}`));\n process.exit(1);\n}\n\n/** One problem inside a refusal, when the command found several. */\nexport interface JsonFailureIssue {\n /** Where it is — a config path, a manifest key, a file. */\n path?: string;\n message: string;\n}\n\n/**\n * The refusal envelope, for a caller that asked for JSON.\n *\n * Same shape as the cloud family's `fail()` — `{ error: { message, code, hint } }`\n * — because there is one CLI and a caller should not have to know which half of\n * it answered. `code` is what a caller branches on and is never absent; the\n * cloud family's reasoning about that applies here unchanged. `issues` carries\n * the per-problem detail a human reader gets as a bullet list, because a\n * refusal that says \"2 problem(s)\" and does not say which two is not\n * machine-readable in any useful sense.\n *\n * On **stdout**, like every other `--json` result: the contract those commands\n * make is that stdout holds one JSON value, and a caller that pipes stdout to a\n * parser must get a parseable refusal rather than an empty stream and a\n * human sentence it never sees. That contract used to hold for exactly one\n * failure of each command — the one that goes through `requireProjectRoot` —\n * while every other exit of the *same* command wrote human text to stderr and\n * left stdout empty. So `rebase status --json` was parseable outside a project\n * and unparseable inside a broken one, which is the case a caller actually has\n * to handle.\n *\n * The flag is read off `process.argv` because the failure happens inside a\n * helper the command calls before it has parsed anything. Coarse — a literal\n * `--json` as some other flag's value would count — and worth it: the failure\n * mode of being coarse is a JSON error where a human one was wanted, and the\n * failure mode of not doing it is an unparseable stream.\n */\nexport function failAsJson(\n message: string,\n code: string,\n hint?: string,\n issues?: JsonFailureIssue[]\n): never {\n const error: Record<string, unknown> = { message, code };\n if (hint !== undefined) error.hint = hint;\n if (issues !== undefined) error.issues = issues;\n console.log(JSON.stringify({ error }, null, 2));\n process.exit(1);\n}\n\n/** Did the command line ask for machine-readable output? */\nexport function wantsJsonOutput(argv: readonly string[] = process.argv): boolean {\n return argv.includes(\"--json\");\n}\n\n/**\n * Require the project root or exit with a helpful error.\n */\nexport function requireProjectRoot(): string {\n const root = findProjectRoot();\n if (!root) {\n if (wantsJsonOutput()) {\n // `rebase status --json` outside a project wrote four grey lines to\n // stderr and nothing at all to stdout, so the agent or CI step that\n // asked for JSON got an empty parse and no reason.\n failAsJson(\n \"Could not find a Rebase project root.\",\n \"no_project_root\",\n `Looked in this directory and every parent for a ${MANIFEST_FILENAME}, `\n + \"a package.json with a \\\"backend\\\" workspace, or a backend/ next to a config/.\"\n );\n }\n // Name what is actually looked for, in the order `findProjectRoot`\n // looks for it. The old wording (\"backend/, frontend/, and config/\")\n // described neither the manifest — which is the primary marker and the\n // only one `rebase init` writes — nor a `--headless` project, which has\n // no frontend at all and would read this as \"you are in the wrong\n // place\" while standing in the right one.\n console.error(chalk.red(\"✗ Could not find a Rebase project root.\"));\n console.error(chalk.gray(` Looked in this directory and every parent for a ${MANIFEST_FILENAME},`));\n console.error(chalk.gray(\" a package.json with a \\\"backend\\\" workspace, or a backend/ next to a config/.\"));\n console.error(chalk.gray(\" Run this from inside a project, or create one with `rebase init`.\"));\n process.exit(1);\n }\n return root;\n}\n\n/**\n * Require the backend directory or exit with a helpful error.\n */\nexport function requireBackendDir(projectRoot: string): string {\n const backendDir = findBackendDir(projectRoot);\n if (!backendDir) {\n if (wantsJsonOutput()) {\n failAsJson(\n \"Could not find a backend/ directory.\",\n \"no_backend_dir\",\n `Expected at: ${path.join(projectRoot, \"backend\")}`\n );\n }\n console.error(chalk.red(\"✗ Could not find a backend/ directory.\"));\n console.error(chalk.gray(` Expected at: ${path.join(projectRoot, \"backend\")}`));\n process.exit(1);\n }\n return backendDir;\n}\n"],"mappings":"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"}
|
|
@@ -105,6 +105,35 @@ function anonymizeStatements(columns) {
|
|
|
105
105
|
return `UPDATE ${table.split(".").map((part) => `"${part}"`).join(".")} SET ${assignments};`;
|
|
106
106
|
});
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Schemas the restored copy needs the app role provisioned on.
|
|
110
|
+
*
|
|
111
|
+
* `pg_dump --no-privileges` strips every GRANT, so a pulled database arrives
|
|
112
|
+
* with its RLS policies and its `FORCE ROW LEVEL SECURITY` intact and no
|
|
113
|
+
* privileges behind them. Measured on a 30-table project: 68 policies restored,
|
|
114
|
+
* 14 tables with RLS on, and **0** grants to `rebase_user` — where the source
|
|
115
|
+
* had 60. Reading one table as the role Rebase serves every request through:
|
|
116
|
+
*
|
|
117
|
+
* source: 6
|
|
118
|
+
* copy: ERROR: permission denied for table leads
|
|
119
|
+
*
|
|
120
|
+
* The dump flags are right and stay: without `--no-owner`/`--no-privileges`
|
|
121
|
+
* every `ALTER … OWNER TO` and `GRANT … TO <prod role>` in the dump fails
|
|
122
|
+
* against roles that do not exist on a laptop, and buries the real output. The
|
|
123
|
+
* repair belongs after the restore, and belongs to `ensureAppRole` — the same
|
|
124
|
+
* routine boot and `rebase db push` call — rather than to a second list of
|
|
125
|
+
* grants written here.
|
|
126
|
+
*
|
|
127
|
+
* Which schemas: every non-system schema the restored database actually has.
|
|
128
|
+
* Boot knows its `managedSchemas` from the collections; a restore knows only
|
|
129
|
+
* what arrived, and a source may carry schemas this project does not declare.
|
|
130
|
+
* `pg_catalog`, `information_schema` and the `pg_*` internals are never ours to
|
|
131
|
+
* grant on and PostgreSQL would refuse anyway.
|
|
132
|
+
*/
|
|
133
|
+
function provisionableSchemas(rows) {
|
|
134
|
+
const skip = (name) => name === "information_schema" || name.startsWith("pg_");
|
|
135
|
+
return [...new Set(rows.map((row) => row.schema))].filter((name) => !skip(name)).sort();
|
|
136
|
+
}
|
|
108
137
|
/** Host and database of a connection string, with no credentials in it. */
|
|
109
138
|
function describeTarget(connectionString) {
|
|
110
139
|
try {
|
|
@@ -162,6 +191,6 @@ async function findPgDump() {
|
|
|
162
191
|
}
|
|
163
192
|
}
|
|
164
193
|
//#endregion
|
|
165
|
-
export { anonymizeStatements, describeTarget, dumpArgs, findPgDump, restoreArgs };
|
|
194
|
+
export { anonymizeStatements, describeTarget, dumpArgs, findPgDump, provisionableSchemas, restoreArgs };
|
|
166
195
|
|
|
167
|
-
//# sourceMappingURL=pull-
|
|
196
|
+
//# sourceMappingURL=pull-Bj0XnH5s.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pull-Bj0XnH5s.js","names":[],"sources":["../src/dev-db/pull.ts"],"sourcesContent":["/**\n * `rebase db pull` — copy a database's contents into local development.\n *\n * The common case is production into local, and the reason it exists is that\n * the alternative is worse: without it people hand-roll a `pg_dump | psql` and\n * get the flags wrong in ways that either fail loudly at 2am or, more often,\n * quietly restore half a schema.\n *\n * Three things this command insists on, because copying a production database\n * onto a laptop is a data-protection event whether or not anyone calls it one:\n *\n * 1. **It says what it is about to do, in full, before doing it** — which\n * database it will read, which one it will overwrite, and where the data will\n * come to rest on disk. The target path matters: people forget that\n * `.rebase/pgdata` is a directory their backup software may be indexing.\n *\n * 2. **It refuses to run unattended without being told to.** The target is\n * destroyed, so a mistyped `--from` with no confirmation would take the\n * developer's working database with it.\n *\n * 3. **It will not write to a remote database.** The target is always the local\n * development database; there is no flag that makes this push. A tool that\n * can copy in both directions eventually copies in the wrong one.\n *\n * Anonymization is opt-in (`--anonymize`), which is a deliberate choice and not\n * an obviously safe one — the flag nobody types is the flag nobody gets. It is\n * a best-effort pass over columns whose *names* look like personal data, and\n * {@link ANONYMIZE_PATTERNS} says exactly which. It cannot find personal data in\n * a column called `notes`, and this file says so rather than implying a\n * guarantee it cannot keep.\n */\n\nimport { execa } from \"execa\";\n\n/**\n * Column-name patterns the anonymizer overwrites.\n *\n * Names, not contents: inspecting values would be slower, and would still miss\n * the same things. This is a reasonable-effort measure for making a local copy\n * less dangerous, and it is not a compliance control.\n */\nexport const ANONYMIZE_PATTERNS: readonly { pattern: RegExp; replacement: string }[] = [\n { pattern: /^(.*_)?e?mail(_.*)?$/i, replacement: \"concat('user', id::text, '@example.invalid')\" },\n { pattern: /^(.*_)?(phone|mobile|tel|telephone)(_.*)?$/i, replacement: \"'+10000000000'\" },\n { pattern: /^(.*_)?(first_name|last_name|full_name|surname|given_name)(_.*)?$/i, replacement: \"'Redacted'\" },\n { pattern: /^(.*_)?(address|street|postcode|zip|zipcode)(_.*)?$/i, replacement: \"'Redacted'\" },\n { pattern: /^(.*_)?(ssn|tax_id|national_id|passport)(_.*)?$/i, replacement: \"'REDACTED'\" },\n { pattern: /^(.*_)?(password|password_hash|secret|token|api_key|access_token|refresh_token)(_.*)?$/i, replacement: \"'REDACTED'\" },\n { pattern: /^(.*_)?(ip|ip_address|user_agent)(_.*)?$/i, replacement: \"'REDACTED'\" }\n];\n\nexport function shouldAnonymize(columnName: string): boolean {\n return ANONYMIZE_PATTERNS.some((rule) => rule.pattern.test(columnName));\n}\n\nexport function replacementFor(columnName: string): string | null {\n return ANONYMIZE_PATTERNS.find((rule) => rule.pattern.test(columnName))?.replacement ?? null;\n}\n\n/** A text-ish column the anonymizer can overwrite without a type error. */\nexport interface ColumnRef {\n schema: string;\n table: string;\n column: string;\n dataType: string;\n}\n\n/**\n * Anonymizable columns: name looks personal, and the type can hold the\n * replacement.\n *\n * The type check is what stops this generating `UPDATE … SET user_id =\n * 'Redacted'` for an integer column called `user_id_email_seq` and failing the\n * whole pass on a technicality.\n */\nexport function anonymizableColumns(columns: readonly ColumnRef[]): ColumnRef[] {\n const textual = new Set([\"text\", \"character varying\", \"varchar\", \"character\", \"char\", \"citext\"]);\n\n return columns.filter((column) => shouldAnonymize(column.column) && textual.has(column.dataType.toLowerCase()));\n}\n\n/** `UPDATE` statements for one anonymization pass, in a stable order. */\nexport function anonymizeStatements(columns: readonly ColumnRef[]): string[] {\n const byTable = new Map<string, ColumnRef[]>();\n for (const column of anonymizableColumns(columns)) {\n const key = `${column.schema}.${column.table}`;\n byTable.set(key, [...(byTable.get(key) ?? []), column]);\n }\n\n return [...byTable.entries()]\n .sort(([a], [b]) => a.localeCompare(b))\n .map(([table, cols]) => {\n const assignments = [...cols]\n .sort((a, b) => a.column.localeCompare(b.column))\n .map((column) => `\"${column.column}\" = ${replacementFor(column.column)}`)\n .join(\", \");\n\n return `UPDATE ${table.split(\".\").map((part) => `\"${part}\"`).join(\".\")} SET ${assignments};`;\n });\n}\n\n/**\n * Schemas the restored copy needs the app role provisioned on.\n *\n * `pg_dump --no-privileges` strips every GRANT, so a pulled database arrives\n * with its RLS policies and its `FORCE ROW LEVEL SECURITY` intact and no\n * privileges behind them. Measured on a 30-table project: 68 policies restored,\n * 14 tables with RLS on, and **0** grants to `rebase_user` — where the source\n * had 60. Reading one table as the role Rebase serves every request through:\n *\n * source: 6\n * copy: ERROR: permission denied for table leads\n *\n * The dump flags are right and stay: without `--no-owner`/`--no-privileges`\n * every `ALTER … OWNER TO` and `GRANT … TO <prod role>` in the dump fails\n * against roles that do not exist on a laptop, and buries the real output. The\n * repair belongs after the restore, and belongs to `ensureAppRole` — the same\n * routine boot and `rebase db push` call — rather than to a second list of\n * grants written here.\n *\n * Which schemas: every non-system schema the restored database actually has.\n * Boot knows its `managedSchemas` from the collections; a restore knows only\n * what arrived, and a source may carry schemas this project does not declare.\n * `pg_catalog`, `information_schema` and the `pg_*` internals are never ours to\n * grant on and PostgreSQL would refuse anyway.\n */\nexport function provisionableSchemas(rows: readonly { schema: string }[]): string[] {\n const skip = (name: string) => name === \"information_schema\" || name.startsWith(\"pg_\");\n\n return [...new Set(rows.map((row) => row.schema))].filter((name) => !skip(name)).sort();\n}\n\n/** Host and database of a connection string, with no credentials in it. */\nexport function describeTarget(connectionString: string): string {\n try {\n const url = new URL(connectionString);\n const database = url.pathname.replace(/^\\//, \"\") || \"(default)\";\n\n return `${url.hostname}${url.port ? `:${url.port}` : \"\"}/${database}`;\n } catch {\n // Never echo the raw string: it carries a password, and this line is\n // printed to a terminal people paste into issues.\n return \"(unparseable connection string)\";\n }\n}\n\nexport interface PullPlan {\n /** Where the data comes from. */\n source: string;\n /** Where it lands. Always local. */\n target: string;\n anonymize: boolean;\n /** Schemas to copy. Empty means every non-system schema. */\n schemas: string[];\n}\n\n/**\n * `pg_dump` arguments for the source.\n *\n * `--no-owner` and `--no-acl` because the roles on a production server do not\n * exist locally, and without them every `ALTER … OWNER TO` in the dump fails and\n * buries the real output in noise. `--format=custom` so `pg_restore` can be told\n * to continue past errors selectively rather than all-or-nothing.\n */\nexport function dumpArgs(plan: PullPlan): string[] {\n const args = [\"--format=custom\", \"--no-owner\", \"--no-acl\", \"--no-privileges\"];\n for (const schema of plan.schemas) args.push(\"--schema\", schema);\n args.push(\"--dbname\", plan.source);\n\n return args;\n}\n\n/**\n * `pg_restore` arguments for the target.\n *\n * `--clean --if-exists` because a pull replaces what is there: restoring into a\n * database that already has the tables would otherwise fail on every one of\n * them. `--no-owner` for the same reason as the dump.\n */\nexport function restoreArgs(plan: PullPlan, dumpFile: string): string[] {\n return [\"--clean\", \"--if-exists\", \"--no-owner\", \"--no-privileges\", \"--dbname\", plan.target, dumpFile];\n}\n\n/** Is `pg_dump` on PATH, and what version? Checked before anything destructive. */\nexport async function findPgDump(): Promise<string | null> {\n try {\n const { stdout } = await execa(\"pg_dump\", [\"--version\"]);\n\n return stdout.trim();\n } catch {\n return null;\n }\n}\n"],"mappings":"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"}
|