@doubledigit/cli 0.1.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 +21 -0
- package/dist/codegen.d.ts +12 -0
- package/dist/codegen.d.ts.map +1 -0
- package/dist/codegen.js +107 -0
- package/dist/commands/add.d.ts +26 -0
- package/dist/commands/add.d.ts.map +1 -0
- package/dist/commands/add.js +548 -0
- package/dist/commands/browse.d.ts +8 -0
- package/dist/commands/browse.d.ts.map +1 -0
- package/dist/commands/browse.js +116 -0
- package/dist/commands/create.d.ts +12 -0
- package/dist/commands/create.d.ts.map +1 -0
- package/dist/commands/create.js +218 -0
- package/dist/commands/db.d.ts +2 -0
- package/dist/commands/db.d.ts.map +1 -0
- package/dist/commands/db.js +64 -0
- package/dist/commands/disable.d.ts +5 -0
- package/dist/commands/disable.d.ts.map +1 -0
- package/dist/commands/disable.js +29 -0
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +88 -0
- package/dist/commands/enable.d.ts +5 -0
- package/dist/commands/enable.d.ts.map +1 -0
- package/dist/commands/enable.js +29 -0
- package/dist/commands/info.d.ts +8 -0
- package/dist/commands/info.d.ts.map +1 -0
- package/dist/commands/info.js +84 -0
- package/dist/commands/list.d.ts +5 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +44 -0
- package/dist/commands/marketplace.d.ts +11 -0
- package/dist/commands/marketplace.d.ts.map +1 -0
- package/dist/commands/marketplace.js +205 -0
- package/dist/commands/onboard.d.ts +2 -0
- package/dist/commands/onboard.d.ts.map +1 -0
- package/dist/commands/onboard.js +58 -0
- package/dist/commands/outdated.d.ts +8 -0
- package/dist/commands/outdated.d.ts.map +1 -0
- package/dist/commands/outdated.js +107 -0
- package/dist/commands/reconcile.d.ts +12 -0
- package/dist/commands/reconcile.d.ts.map +1 -0
- package/dist/commands/reconcile.js +175 -0
- package/dist/commands/run.d.ts +2 -0
- package/dist/commands/run.d.ts.map +1 -0
- package/dist/commands/run.js +37 -0
- package/dist/commands/sync.d.ts +5 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +34 -0
- package/dist/commands/uninstall.d.ts +14 -0
- package/dist/commands/uninstall.d.ts.map +1 -0
- package/dist/commands/uninstall.js +190 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +37 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +181 -0
- package/dist/lib/github-auth.d.ts +8 -0
- package/dist/lib/github-auth.d.ts.map +1 -0
- package/dist/lib/github-auth.js +30 -0
- package/dist/lib/lock-file.d.ts +67 -0
- package/dist/lib/lock-file.d.ts.map +1 -0
- package/dist/lib/lock-file.js +117 -0
- package/dist/lib/marketplace-schema.d.ts +607 -0
- package/dist/lib/marketplace-schema.d.ts.map +1 -0
- package/dist/lib/marketplace-schema.js +111 -0
- package/dist/lib/marketplace.d.ts +57 -0
- package/dist/lib/marketplace.d.ts.map +1 -0
- package/dist/lib/marketplace.js +270 -0
- package/dist/lib/onboarding.d.ts +84 -0
- package/dist/lib/onboarding.d.ts.map +1 -0
- package/dist/lib/onboarding.js +1004 -0
- package/dist/lib/rewrite-extension-tsconfig.d.ts +22 -0
- package/dist/lib/rewrite-extension-tsconfig.d.ts.map +1 -0
- package/dist/lib/rewrite-extension-tsconfig.js +80 -0
- package/dist/lib/source-parser.d.ts +35 -0
- package/dist/lib/source-parser.d.ts.map +1 -0
- package/dist/lib/source-parser.js +121 -0
- package/dist/lib/validators.d.ts +73 -0
- package/dist/lib/validators.d.ts.map +1 -0
- package/dist/lib/validators.js +435 -0
- package/dist/paths.d.ts +46 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +85 -0
- package/dist/scanner.d.ts +41 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +100 -0
- package/package.json +49 -0
|
@@ -0,0 +1,1004 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import { createInterface } from 'node:readline/promises';
|
|
9
|
+
import { pathToFileURL } from 'node:url';
|
|
10
|
+
export const DEFAULT_DATABASE_URL = 'postgresql://doubledigit:doubledigit@localhost:5432/doubledigit';
|
|
11
|
+
export const DEFAULT_APP_URL = 'http://localhost:3000';
|
|
12
|
+
export const DEFAULT_EMBEDDED_POSTGRES_PORT = 54329;
|
|
13
|
+
export const DEFAULT_EMBEDDED_POSTGRES_INSTANCE_ID = 'default';
|
|
14
|
+
export const DEFAULT_EMBEDDED_POSTGRES_HOME = path.join(os.homedir(), '.doubledigit');
|
|
15
|
+
export const DEFAULT_EMBEDDED_POSTGRES_DATA_DIR = path.join(DEFAULT_EMBEDDED_POSTGRES_HOME, 'instances', DEFAULT_EMBEDDED_POSTGRES_INSTANCE_ID, 'db');
|
|
16
|
+
const DEFAULT_DOCKER_POSTGRES_PORT = 5432;
|
|
17
|
+
const EMBEDDED_POSTGRES_LOOKAHEAD = 20;
|
|
18
|
+
const EMBEDDED_POSTGRES_USER = 'doubledigit';
|
|
19
|
+
const EMBEDDED_POSTGRES_PASSWORD = 'doubledigit';
|
|
20
|
+
const EMBEDDED_POSTGRES_DATABASE = 'doubledigit';
|
|
21
|
+
const EMBEDDED_LOG_BUFFER_LIMIT = 40;
|
|
22
|
+
const DEFAULT_APP_PORT = 3000;
|
|
23
|
+
const APP_PORT_LOOKAHEAD = 20;
|
|
24
|
+
const INITIAL_DATABASE_URL = process.env.DATABASE_URL?.trim();
|
|
25
|
+
const INITIAL_DATABASE_MODE = process.env.DD_DATABASE_MODE?.trim();
|
|
26
|
+
/**
|
|
27
|
+
* Snapshot of shell-originated environment at module load time.
|
|
28
|
+
* Values present here must never be overwritten by .env file loading —
|
|
29
|
+
* explicit runtime env always wins over file-based defaults.
|
|
30
|
+
*/
|
|
31
|
+
const SHELL_ENV_SNAPSHOT = {};
|
|
32
|
+
for (const key of Object.keys(process.env)) {
|
|
33
|
+
if (process.env[key] !== undefined) {
|
|
34
|
+
SHELL_ENV_SNAPSHOT[key] = process.env[key];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const REQUIRED_ENV_KEYS = [
|
|
38
|
+
'DATABASE_URL',
|
|
39
|
+
'PAYLOAD_SECRET',
|
|
40
|
+
'BETTER_AUTH_SECRET',
|
|
41
|
+
'BETTER_AUTH_URL',
|
|
42
|
+
];
|
|
43
|
+
export function getEnvPath(paths) {
|
|
44
|
+
return path.join(paths.mainAppDir, '.env');
|
|
45
|
+
}
|
|
46
|
+
export function getEnvExamplePath(paths) {
|
|
47
|
+
return path.join(paths.root, '.env.example');
|
|
48
|
+
}
|
|
49
|
+
export function getGeneratedTypesPath(paths) {
|
|
50
|
+
return path.join(paths.root, 'packages', 'shared', 'src', 'types', 'payload-types.ts');
|
|
51
|
+
}
|
|
52
|
+
export function commandExists(command) {
|
|
53
|
+
const lookup = process.platform === 'win32' ? 'where' : 'which';
|
|
54
|
+
try {
|
|
55
|
+
execFileSync(lookup, [command], { stdio: 'pipe' });
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function dockerAvailable() {
|
|
63
|
+
if (!commandExists('docker')) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
execFileSync('docker', ['info'], { stdio: 'pipe' });
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function runChecked(command, args, cwd, label, envOverrides = {}) {
|
|
75
|
+
try {
|
|
76
|
+
execFileSync(command, args, {
|
|
77
|
+
cwd,
|
|
78
|
+
stdio: 'inherit',
|
|
79
|
+
env: {
|
|
80
|
+
...process.env,
|
|
81
|
+
...envOverrides,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
throw new Error(`${label} failed.`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function captureCommand(command, args, cwd, envOverrides = {}) {
|
|
90
|
+
try {
|
|
91
|
+
const output = execFileSync(command, args, {
|
|
92
|
+
cwd,
|
|
93
|
+
stdio: 'pipe',
|
|
94
|
+
encoding: 'utf8',
|
|
95
|
+
env: {
|
|
96
|
+
...process.env,
|
|
97
|
+
...envOverrides,
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
return { ok: true, output };
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
const stdout = typeof error.stdout === 'string'
|
|
104
|
+
? error.stdout
|
|
105
|
+
: '';
|
|
106
|
+
const stderr = typeof error.stderr === 'string'
|
|
107
|
+
? error.stderr
|
|
108
|
+
: '';
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
output: [stdout, stderr].filter(Boolean).join('\n').trim(),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export function readEnvFile(filePath) {
|
|
116
|
+
if (!fs.existsSync(filePath)) {
|
|
117
|
+
return {};
|
|
118
|
+
}
|
|
119
|
+
const result = {};
|
|
120
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
121
|
+
for (const line of content.split(/\r?\n/)) {
|
|
122
|
+
const trimmed = line.trim();
|
|
123
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const separator = trimmed.indexOf('=');
|
|
127
|
+
if (separator === -1) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const key = trimmed.slice(0, separator).trim();
|
|
131
|
+
const value = trimmed.slice(separator + 1).trim();
|
|
132
|
+
result[key] = value;
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
function escapeRegExp(value) {
|
|
137
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
138
|
+
}
|
|
139
|
+
function upsertEnvValue(content, key, value) {
|
|
140
|
+
const pattern = new RegExp(`^${escapeRegExp(key)}=.*$`, 'm');
|
|
141
|
+
if (pattern.test(content)) {
|
|
142
|
+
return content.replace(pattern, `${key}=${value}`);
|
|
143
|
+
}
|
|
144
|
+
const trimmed = content.trimEnd();
|
|
145
|
+
return `${trimmed}\n${key}=${value}\n`;
|
|
146
|
+
}
|
|
147
|
+
function removeEnvValue(content, key) {
|
|
148
|
+
const pattern = new RegExp(`^${escapeRegExp(key)}=.*(?:\\r?\\n)?`, 'm');
|
|
149
|
+
return content.replace(pattern, '');
|
|
150
|
+
}
|
|
151
|
+
function generateSecret() {
|
|
152
|
+
return randomBytes(32).toString('base64');
|
|
153
|
+
}
|
|
154
|
+
function buildManagedDatabaseUrl(port, database = EMBEDDED_POSTGRES_DATABASE) {
|
|
155
|
+
return `postgresql://${EMBEDDED_POSTGRES_USER}:${EMBEDDED_POSTGRES_PASSWORD}@127.0.0.1:${port}/${database}`;
|
|
156
|
+
}
|
|
157
|
+
function parsePositiveInt(value) {
|
|
158
|
+
if (!value?.trim()) {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
const parsed = Number.parseInt(value.trim(), 10);
|
|
162
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
return parsed;
|
|
166
|
+
}
|
|
167
|
+
function expandHomePrefix(value) {
|
|
168
|
+
if (value === '~') {
|
|
169
|
+
return os.homedir();
|
|
170
|
+
}
|
|
171
|
+
if (value.startsWith('~/')) {
|
|
172
|
+
return path.resolve(os.homedir(), value.slice(2));
|
|
173
|
+
}
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
176
|
+
function resolveEmbeddedDataDir(env) {
|
|
177
|
+
return path.resolve(expandHomePrefix(env.DD_EMBEDDED_POSTGRES_DATA_DIR?.trim() || DEFAULT_EMBEDDED_POSTGRES_DATA_DIR));
|
|
178
|
+
}
|
|
179
|
+
function resolveEmbeddedPort(env) {
|
|
180
|
+
return parsePositiveInt(env.DD_EMBEDDED_POSTGRES_PORT) || DEFAULT_EMBEDDED_POSTGRES_PORT;
|
|
181
|
+
}
|
|
182
|
+
function resolveDockerPort(env) {
|
|
183
|
+
return parsePositiveInt(env.DD_DOCKER_POSTGRES_PORT) || DEFAULT_DOCKER_POSTGRES_PORT;
|
|
184
|
+
}
|
|
185
|
+
function applyEnvToProcess(env) {
|
|
186
|
+
for (const [key, value] of Object.entries(env)) {
|
|
187
|
+
// Never overwrite values that were explicitly set in the shell environment.
|
|
188
|
+
// Explicit runtime env (export DATABASE_URL=... / DATABASE_URL=... pnpm dev)
|
|
189
|
+
// must always take precedence over .env file values.
|
|
190
|
+
if (key in SHELL_ENV_SNAPSHOT) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
process.env[key] = value;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function isLegacyLocalDatabaseUrl(value) {
|
|
197
|
+
return value?.trim() === DEFAULT_DATABASE_URL;
|
|
198
|
+
}
|
|
199
|
+
function isLoopbackHost(host) {
|
|
200
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
201
|
+
}
|
|
202
|
+
function hasEmbeddedRuntimeHints(env) {
|
|
203
|
+
return Boolean(env.DD_EMBEDDED_POSTGRES_PORT?.trim()
|
|
204
|
+
|| env.DD_EMBEDDED_POSTGRES_DATA_DIR?.trim());
|
|
205
|
+
}
|
|
206
|
+
function hasDockerRuntimeHints(env) {
|
|
207
|
+
return Boolean(env.DD_DATABASE_MODE?.trim() === 'docker'
|
|
208
|
+
|| env.DD_DOCKER_POSTGRES_PORT?.trim());
|
|
209
|
+
}
|
|
210
|
+
function canonicalManagedDatabaseUrl(databaseUrl) {
|
|
211
|
+
try {
|
|
212
|
+
const url = new URL(databaseUrl);
|
|
213
|
+
if (url.protocol !== 'postgresql:' && url.protocol !== 'postgres:') {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
if (!isLoopbackHost(url.hostname) || url.search || url.hash) {
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
const port = parsePositiveInt(url.port) || DEFAULT_DOCKER_POSTGRES_PORT;
|
|
220
|
+
const database = url.pathname.replace(/^\/+/, '');
|
|
221
|
+
const username = decodeURIComponent(url.username);
|
|
222
|
+
const password = decodeURIComponent(url.password);
|
|
223
|
+
if (username !== EMBEDDED_POSTGRES_USER
|
|
224
|
+
|| password !== EMBEDDED_POSTGRES_PASSWORD
|
|
225
|
+
|| database !== EMBEDDED_POSTGRES_DATABASE) {
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
return buildManagedDatabaseUrl(port, database);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function isEmbeddedManagedDatabaseUrl(databaseUrl, env) {
|
|
235
|
+
if (!databaseUrl || !hasEmbeddedRuntimeHints(env)) {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
return canonicalManagedDatabaseUrl(databaseUrl) === buildManagedDatabaseUrl(resolveEmbeddedPort(env));
|
|
239
|
+
}
|
|
240
|
+
function isDockerManagedDatabaseUrl(databaseUrl, env) {
|
|
241
|
+
if (!databaseUrl || !hasDockerRuntimeHints(env)) {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
return canonicalManagedDatabaseUrl(databaseUrl) === buildManagedDatabaseUrl(resolveDockerPort(env));
|
|
245
|
+
}
|
|
246
|
+
function hasExplicitExternalDatabaseUrl(databaseUrl, env) {
|
|
247
|
+
const trimmed = databaseUrl?.trim();
|
|
248
|
+
return Boolean(trimmed
|
|
249
|
+
&& !isLegacyLocalDatabaseUrl(trimmed)
|
|
250
|
+
&& !isEmbeddedManagedDatabaseUrl(trimmed, env)
|
|
251
|
+
&& !isDockerManagedDatabaseUrl(trimmed, env));
|
|
252
|
+
}
|
|
253
|
+
export function isPlaceholderValue(key, value) {
|
|
254
|
+
if (!value) {
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
if (value.includes('change-me')) {
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
export function ensureLocalEnv(paths, options = {}) {
|
|
263
|
+
const envPath = getEnvPath(paths);
|
|
264
|
+
const envExamplePath = getEnvExamplePath(paths);
|
|
265
|
+
let created = false;
|
|
266
|
+
if (!fs.existsSync(envPath)) {
|
|
267
|
+
if (!fs.existsSync(envExamplePath)) {
|
|
268
|
+
throw new Error(`Missing ${path.relative(paths.root, envExamplePath)}.`);
|
|
269
|
+
}
|
|
270
|
+
fs.mkdirSync(path.dirname(envPath), { recursive: true });
|
|
271
|
+
fs.copyFileSync(envExamplePath, envPath);
|
|
272
|
+
created = true;
|
|
273
|
+
}
|
|
274
|
+
let content = fs.readFileSync(envPath, 'utf-8');
|
|
275
|
+
const current = readEnvFile(envPath);
|
|
276
|
+
const updates = {};
|
|
277
|
+
const removals = new Set();
|
|
278
|
+
const currentDatabaseMode = current.DD_DATABASE_MODE?.trim();
|
|
279
|
+
const currentDatabaseUrl = current.DATABASE_URL?.trim();
|
|
280
|
+
if (!options.databaseMode && currentDatabaseMode === 'external') {
|
|
281
|
+
removals.add('DD_DATABASE_MODE');
|
|
282
|
+
}
|
|
283
|
+
if (!options.databaseUrl
|
|
284
|
+
&& (isEmbeddedManagedDatabaseUrl(currentDatabaseUrl, current)
|
|
285
|
+
|| isDockerManagedDatabaseUrl(currentDatabaseUrl, current))) {
|
|
286
|
+
removals.add('DATABASE_URL');
|
|
287
|
+
}
|
|
288
|
+
if (!options.databaseMode
|
|
289
|
+
&& isEmbeddedManagedDatabaseUrl(current.DATABASE_URL, current)
|
|
290
|
+
&& currentDatabaseMode !== 'embedded') {
|
|
291
|
+
updates.DD_DATABASE_MODE = 'embedded';
|
|
292
|
+
}
|
|
293
|
+
if (!options.databaseMode
|
|
294
|
+
&& currentDatabaseMode
|
|
295
|
+
&& currentDatabaseMode !== 'external'
|
|
296
|
+
&& hasExplicitExternalDatabaseUrl(current.DATABASE_URL, current)) {
|
|
297
|
+
removals.add('DD_DATABASE_MODE');
|
|
298
|
+
}
|
|
299
|
+
const normalizedCurrentDatabaseMode = removals.has('DD_DATABASE_MODE')
|
|
300
|
+
? undefined
|
|
301
|
+
: currentDatabaseMode;
|
|
302
|
+
const effectiveDatabaseMode = options.databaseMode || updates.DD_DATABASE_MODE || normalizedCurrentDatabaseMode;
|
|
303
|
+
if (isPlaceholderValue('PAYLOAD_SECRET', current.PAYLOAD_SECRET)) {
|
|
304
|
+
updates.PAYLOAD_SECRET = generateSecret();
|
|
305
|
+
}
|
|
306
|
+
if (isPlaceholderValue('BETTER_AUTH_SECRET', current.BETTER_AUTH_SECRET)) {
|
|
307
|
+
updates.BETTER_AUTH_SECRET = generateSecret();
|
|
308
|
+
}
|
|
309
|
+
if (isPlaceholderValue('BETTER_AUTH_URL', current.BETTER_AUTH_URL)) {
|
|
310
|
+
updates.BETTER_AUTH_URL = DEFAULT_APP_URL;
|
|
311
|
+
}
|
|
312
|
+
if (!effectiveDatabaseMode && (!current.DATABASE_URL?.trim() || isLegacyLocalDatabaseUrl(current.DATABASE_URL))) {
|
|
313
|
+
updates.DD_DATABASE_MODE = 'embedded';
|
|
314
|
+
}
|
|
315
|
+
if (options.databaseMode) {
|
|
316
|
+
updates.DD_DATABASE_MODE = options.databaseMode;
|
|
317
|
+
}
|
|
318
|
+
const resolvedDatabaseMode = options.databaseMode || updates.DD_DATABASE_MODE || currentDatabaseMode;
|
|
319
|
+
if (resolvedDatabaseMode === 'embedded') {
|
|
320
|
+
if (options.embeddedPort) {
|
|
321
|
+
updates.DD_EMBEDDED_POSTGRES_PORT = String(options.embeddedPort);
|
|
322
|
+
}
|
|
323
|
+
else if (!current.DD_EMBEDDED_POSTGRES_PORT?.trim()) {
|
|
324
|
+
updates.DD_EMBEDDED_POSTGRES_PORT = String(DEFAULT_EMBEDDED_POSTGRES_PORT);
|
|
325
|
+
}
|
|
326
|
+
if (options.embeddedDataDir) {
|
|
327
|
+
updates.DD_EMBEDDED_POSTGRES_DATA_DIR = options.embeddedDataDir;
|
|
328
|
+
}
|
|
329
|
+
else if (!current.DD_EMBEDDED_POSTGRES_DATA_DIR?.trim()) {
|
|
330
|
+
updates.DD_EMBEDDED_POSTGRES_DATA_DIR = DEFAULT_EMBEDDED_POSTGRES_DATA_DIR;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (resolvedDatabaseMode === 'docker') {
|
|
334
|
+
if (options.dockerPort) {
|
|
335
|
+
updates.DD_DOCKER_POSTGRES_PORT = String(options.dockerPort);
|
|
336
|
+
}
|
|
337
|
+
else if (!current.DD_DOCKER_POSTGRES_PORT?.trim()) {
|
|
338
|
+
updates.DD_DOCKER_POSTGRES_PORT = String(DEFAULT_DOCKER_POSTGRES_PORT);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (options.databaseUrl) {
|
|
342
|
+
updates.DATABASE_URL = options.databaseUrl;
|
|
343
|
+
}
|
|
344
|
+
for (const key of removals) {
|
|
345
|
+
content = removeEnvValue(content, key);
|
|
346
|
+
}
|
|
347
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
348
|
+
content = upsertEnvValue(content, key, value);
|
|
349
|
+
}
|
|
350
|
+
if (Object.keys(updates).length > 0) {
|
|
351
|
+
fs.writeFileSync(envPath, content, 'utf-8');
|
|
352
|
+
}
|
|
353
|
+
const env = readEnvFile(envPath);
|
|
354
|
+
applyEnvToProcess(env);
|
|
355
|
+
return {
|
|
356
|
+
envPath,
|
|
357
|
+
created,
|
|
358
|
+
updatedKeys: Object.keys(updates),
|
|
359
|
+
env,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
export function inspectLocalEnv(paths) {
|
|
363
|
+
const envPath = getEnvPath(paths);
|
|
364
|
+
return {
|
|
365
|
+
envPath,
|
|
366
|
+
exists: fs.existsSync(envPath),
|
|
367
|
+
env: readEnvFile(envPath),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
export function parseDatabaseUrl(databaseUrl) {
|
|
371
|
+
const normalized = databaseUrl.replace(/^postgres:\/\//, 'postgresql://');
|
|
372
|
+
const parsed = new URL(normalized);
|
|
373
|
+
return {
|
|
374
|
+
host: parsed.hostname || 'localhost',
|
|
375
|
+
port: parsed.port ? Number(parsed.port) : 5432,
|
|
376
|
+
database: parsed.pathname.replace(/^\//, ''),
|
|
377
|
+
username: parsed.username || undefined,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
export async function checkDatabaseReachability(databaseUrl) {
|
|
381
|
+
let info;
|
|
382
|
+
try {
|
|
383
|
+
info = parseDatabaseUrl(databaseUrl);
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
return await new Promise((resolve) => {
|
|
389
|
+
const socket = net.createConnection({ host: info.host, port: info.port }, () => {
|
|
390
|
+
socket.end();
|
|
391
|
+
resolve(true);
|
|
392
|
+
});
|
|
393
|
+
socket.setTimeout(1500);
|
|
394
|
+
socket.on('error', () => resolve(false));
|
|
395
|
+
socket.on('timeout', () => {
|
|
396
|
+
socket.destroy();
|
|
397
|
+
resolve(false);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
export async function confirmYesNo(message) {
|
|
402
|
+
if (!process.stdin.isTTY || process.env.CI === 'true') {
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
const rl = createInterface({
|
|
406
|
+
input: process.stdin,
|
|
407
|
+
output: process.stdout,
|
|
408
|
+
});
|
|
409
|
+
try {
|
|
410
|
+
const answer = await rl.question(`${message} [Y/n] `);
|
|
411
|
+
const normalized = answer.trim().toLowerCase();
|
|
412
|
+
return normalized === '' || normalized === 'y' || normalized === 'yes';
|
|
413
|
+
}
|
|
414
|
+
finally {
|
|
415
|
+
rl.close();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
export async function waitForDockerDatabase(paths, envOverrides = {}) {
|
|
419
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
420
|
+
const result = captureCommand('docker', ['compose', 'exec', 'db', 'pg_isready', '-U', 'doubledigit', '-d', 'doubledigit', '-q'], paths.root, envOverrides);
|
|
421
|
+
if (result.ok) {
|
|
422
|
+
return true;
|
|
423
|
+
}
|
|
424
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
425
|
+
}
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
export async function startDevServer(paths, envOverrides = {}) {
|
|
429
|
+
const preferredPort = parsePositiveInt(process.env.APP_PORT || envOverrides.APP_PORT) || DEFAULT_APP_PORT;
|
|
430
|
+
let port;
|
|
431
|
+
try {
|
|
432
|
+
port = await findAvailablePort(preferredPort, APP_PORT_LOOKAHEAD);
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
throw new Error(`Could not find a free port starting from ${preferredPort}. Free a port or set APP_PORT=<port>.`);
|
|
436
|
+
}
|
|
437
|
+
if (port !== preferredPort) {
|
|
438
|
+
console.log(`⚠ Port ${preferredPort} is busy — using port ${port} instead.`);
|
|
439
|
+
}
|
|
440
|
+
const betterAuthUrl = `http://localhost:${port}`;
|
|
441
|
+
const child = spawn('pnpm', ['--dir', 'apps/main-app', 'exec', 'next', 'dev', '--turbo', '--port', String(port)], {
|
|
442
|
+
cwd: paths.root,
|
|
443
|
+
stdio: 'inherit',
|
|
444
|
+
env: {
|
|
445
|
+
...process.env,
|
|
446
|
+
...envOverrides,
|
|
447
|
+
PORT: String(port),
|
|
448
|
+
BETTER_AUTH_URL: betterAuthUrl,
|
|
449
|
+
},
|
|
450
|
+
shell: process.platform === 'win32',
|
|
451
|
+
});
|
|
452
|
+
child.on('exit', (code) => {
|
|
453
|
+
process.exit(code ?? 0);
|
|
454
|
+
});
|
|
455
|
+
process.on('SIGINT', () => {
|
|
456
|
+
child.kill('SIGINT');
|
|
457
|
+
});
|
|
458
|
+
process.on('SIGTERM', () => {
|
|
459
|
+
child.kill('SIGTERM');
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
export function printNextSteps() {
|
|
463
|
+
console.log('\nNext steps:\n');
|
|
464
|
+
console.log(' pnpm dev');
|
|
465
|
+
console.log(' open http://localhost:3000');
|
|
466
|
+
console.log(' open http://localhost:3000/admin');
|
|
467
|
+
console.log('');
|
|
468
|
+
}
|
|
469
|
+
function createEmbeddedPostgresLogBuffer(limit = EMBEDDED_LOG_BUFFER_LIMIT) {
|
|
470
|
+
const recentLogs = [];
|
|
471
|
+
return {
|
|
472
|
+
append(message) {
|
|
473
|
+
const text = typeof message === 'string'
|
|
474
|
+
? message
|
|
475
|
+
: message instanceof Error
|
|
476
|
+
? message.message
|
|
477
|
+
: String(message ?? '');
|
|
478
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
479
|
+
const line = rawLine.trim();
|
|
480
|
+
if (!line) {
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
recentLogs.push(line);
|
|
484
|
+
if (recentLogs.length > limit) {
|
|
485
|
+
recentLogs.splice(0, recentLogs.length - limit);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
},
|
|
489
|
+
getRecentLogs() {
|
|
490
|
+
return [...recentLogs];
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
function summarizeEmbeddedPostgresLogs(recentLogs) {
|
|
495
|
+
if (recentLogs.length === 0) {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
return recentLogs
|
|
499
|
+
.slice(-8)
|
|
500
|
+
.map((line) => line.trim())
|
|
501
|
+
.filter(Boolean)
|
|
502
|
+
.join(' | ');
|
|
503
|
+
}
|
|
504
|
+
function formatEmbeddedPostgresError(error, fallbackMessage, recentLogs) {
|
|
505
|
+
const base = error instanceof Error
|
|
506
|
+
? error.message
|
|
507
|
+
: `${fallbackMessage}: ${String(error ?? fallbackMessage)}`;
|
|
508
|
+
const parts = [base];
|
|
509
|
+
const haystack = recentLogs.join('\n').toLowerCase();
|
|
510
|
+
if (haystack.includes('could not create shared memory segment')) {
|
|
511
|
+
parts.push('Embedded PostgreSQL could not allocate shared memory. Stop other local PostgreSQL servers or raise the host shared-memory limits, then retry.');
|
|
512
|
+
}
|
|
513
|
+
const recentSummary = summarizeEmbeddedPostgresLogs(recentLogs);
|
|
514
|
+
if (recentSummary) {
|
|
515
|
+
parts.push(`Recent embedded Postgres logs: ${recentSummary}`);
|
|
516
|
+
}
|
|
517
|
+
return new Error(parts.join(' '));
|
|
518
|
+
}
|
|
519
|
+
export function resolveDatabasePreference(env) {
|
|
520
|
+
const shellDatabaseUrl = INITIAL_DATABASE_URL;
|
|
521
|
+
if (shellDatabaseUrl) {
|
|
522
|
+
return {
|
|
523
|
+
mode: 'external',
|
|
524
|
+
reason: 'DATABASE_URL',
|
|
525
|
+
databaseUrl: shellDatabaseUrl,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
const modeHint = INITIAL_DATABASE_MODE || env.DD_DATABASE_MODE?.trim();
|
|
529
|
+
const configuredDatabaseUrl = env.DATABASE_URL?.trim();
|
|
530
|
+
const embeddedManagedDatabaseUrl = isEmbeddedManagedDatabaseUrl(configuredDatabaseUrl, env);
|
|
531
|
+
const configuredExternalDatabaseUrl = hasExplicitExternalDatabaseUrl(configuredDatabaseUrl, env);
|
|
532
|
+
if (configuredExternalDatabaseUrl) {
|
|
533
|
+
return {
|
|
534
|
+
mode: 'external',
|
|
535
|
+
reason: 'apps/main-app/.env',
|
|
536
|
+
databaseUrl: configuredDatabaseUrl,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
if (modeHint === 'external' && (configuredDatabaseUrl || INITIAL_DATABASE_MODE)) {
|
|
540
|
+
if (!INITIAL_DATABASE_MODE && embeddedManagedDatabaseUrl) {
|
|
541
|
+
return {
|
|
542
|
+
mode: 'embedded',
|
|
543
|
+
reason: 'embedded-managed-url',
|
|
544
|
+
databaseUrl: configuredDatabaseUrl,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
return {
|
|
548
|
+
mode: 'external',
|
|
549
|
+
reason: 'DD_DATABASE_MODE',
|
|
550
|
+
databaseUrl: configuredDatabaseUrl,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
if (modeHint === 'docker') {
|
|
554
|
+
return {
|
|
555
|
+
mode: 'docker',
|
|
556
|
+
reason: 'DD_DATABASE_MODE',
|
|
557
|
+
databaseUrl: configuredDatabaseUrl || buildManagedDatabaseUrl(resolveDockerPort(env)),
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
if (modeHint === 'embedded') {
|
|
561
|
+
return {
|
|
562
|
+
mode: 'embedded',
|
|
563
|
+
reason: 'DD_DATABASE_MODE',
|
|
564
|
+
databaseUrl: configuredDatabaseUrl || buildManagedDatabaseUrl(resolveEmbeddedPort(env)),
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
if (embeddedManagedDatabaseUrl) {
|
|
568
|
+
return {
|
|
569
|
+
mode: 'embedded',
|
|
570
|
+
reason: 'embedded-managed-url',
|
|
571
|
+
databaseUrl: configuredDatabaseUrl,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
if (configuredDatabaseUrl && !isLegacyLocalDatabaseUrl(configuredDatabaseUrl)) {
|
|
575
|
+
return {
|
|
576
|
+
mode: 'external',
|
|
577
|
+
reason: 'apps/main-app/.env',
|
|
578
|
+
databaseUrl: configuredDatabaseUrl,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
return {
|
|
582
|
+
mode: 'embedded',
|
|
583
|
+
reason: configuredDatabaseUrl ? 'legacy-default' : 'no-database-url',
|
|
584
|
+
databaseUrl: configuredDatabaseUrl || buildManagedDatabaseUrl(resolveEmbeddedPort(env)),
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
function getEmbeddedPostgresPlatformPackageName() {
|
|
588
|
+
switch (process.platform) {
|
|
589
|
+
case 'darwin':
|
|
590
|
+
if (process.arch === 'arm64') {
|
|
591
|
+
return '@embedded-postgres/darwin-arm64';
|
|
592
|
+
}
|
|
593
|
+
if (process.arch === 'x64') {
|
|
594
|
+
return '@embedded-postgres/darwin-x64';
|
|
595
|
+
}
|
|
596
|
+
break;
|
|
597
|
+
case 'linux':
|
|
598
|
+
if (process.arch === 'arm') {
|
|
599
|
+
return '@embedded-postgres/linux-arm';
|
|
600
|
+
}
|
|
601
|
+
if (process.arch === 'arm64') {
|
|
602
|
+
return '@embedded-postgres/linux-arm64';
|
|
603
|
+
}
|
|
604
|
+
if (process.arch === 'ia32') {
|
|
605
|
+
return '@embedded-postgres/linux-ia32';
|
|
606
|
+
}
|
|
607
|
+
if (process.arch === 'ppc64') {
|
|
608
|
+
return '@embedded-postgres/linux-ppc64';
|
|
609
|
+
}
|
|
610
|
+
if (process.arch === 'x64') {
|
|
611
|
+
return '@embedded-postgres/linux-x64';
|
|
612
|
+
}
|
|
613
|
+
break;
|
|
614
|
+
case 'win32':
|
|
615
|
+
if (process.arch === 'x64') {
|
|
616
|
+
return '@embedded-postgres/windows-x64';
|
|
617
|
+
}
|
|
618
|
+
break;
|
|
619
|
+
default:
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
622
|
+
throw new Error(`Embedded PostgreSQL is not supported on ${process.platform}/${process.arch}.`);
|
|
623
|
+
}
|
|
624
|
+
function hydrateEmbeddedPostgresSymlinks() {
|
|
625
|
+
const resolver = createRequire(import.meta.url);
|
|
626
|
+
const embeddedEntry = resolver.resolve('embedded-postgres');
|
|
627
|
+
const embeddedRequire = createRequire(embeddedEntry);
|
|
628
|
+
const packageEntryPath = embeddedRequire.resolve(getEmbeddedPostgresPlatformPackageName());
|
|
629
|
+
const packageRoot = path.dirname(path.dirname(packageEntryPath));
|
|
630
|
+
const symlinkFile = path.join(packageRoot, 'native', 'pg-symlinks.json');
|
|
631
|
+
if (!fs.existsSync(symlinkFile)) {
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
const symlinks = JSON.parse(fs.readFileSync(symlinkFile, 'utf8'));
|
|
635
|
+
for (const { source, target } of symlinks) {
|
|
636
|
+
const sourcePath = path.resolve(packageRoot, source);
|
|
637
|
+
const targetPath = path.resolve(packageRoot, target);
|
|
638
|
+
if (fs.existsSync(targetPath)) {
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
642
|
+
const relativeSource = path.relative(path.dirname(targetPath), sourcePath);
|
|
643
|
+
fs.symlinkSync(relativeSource, targetPath);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
async function loadEmbeddedPostgresBinaries() {
|
|
647
|
+
hydrateEmbeddedPostgresSymlinks();
|
|
648
|
+
const resolver = createRequire(import.meta.url);
|
|
649
|
+
const embeddedEntry = resolver.resolve('embedded-postgres');
|
|
650
|
+
const embeddedRequire = createRequire(embeddedEntry);
|
|
651
|
+
const packageEntryPath = embeddedRequire.resolve(getEmbeddedPostgresPlatformPackageName());
|
|
652
|
+
const packageRoot = path.dirname(path.dirname(packageEntryPath));
|
|
653
|
+
const mod = await import(pathToFileURL(packageEntryPath).href);
|
|
654
|
+
return {
|
|
655
|
+
packageRoot,
|
|
656
|
+
initdb: mod.initdb,
|
|
657
|
+
pgCtl: mod.pg_ctl,
|
|
658
|
+
postgres: mod.postgres,
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
async function createPgClient(connectionString) {
|
|
662
|
+
const pgMod = await import('pg');
|
|
663
|
+
const pg = 'default' in pgMod ? pgMod.default : pgMod;
|
|
664
|
+
return new pg.Client({ connectionString });
|
|
665
|
+
}
|
|
666
|
+
function ensureExecutable(filePath) {
|
|
667
|
+
const executableBits = 0o555;
|
|
668
|
+
const stat = fs.statSync(filePath);
|
|
669
|
+
if ((stat.mode & executableBits) !== executableBits) {
|
|
670
|
+
fs.chmodSync(filePath, stat.mode | executableBits);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
function createEmbeddedPasswordFile() {
|
|
674
|
+
const passwordFile = path.join(os.tmpdir(), `dd-embedded-postgres-${randomBytes(6).toString('hex')}.txt`);
|
|
675
|
+
fs.writeFileSync(passwordFile, `${EMBEDDED_POSTGRES_PASSWORD}\n`, { mode: 0o600 });
|
|
676
|
+
return passwordFile;
|
|
677
|
+
}
|
|
678
|
+
function appendEmbeddedOutput(logBuffer, output) {
|
|
679
|
+
if (output.trim()) {
|
|
680
|
+
logBuffer.append(output);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
function getEmbeddedPostgresLogPath(dataDir) {
|
|
684
|
+
const logsDir = path.join(path.dirname(dataDir), 'logs');
|
|
685
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
686
|
+
return path.join(logsDir, 'postgres.log');
|
|
687
|
+
}
|
|
688
|
+
async function getPostgresDataDirectory(connectionString) {
|
|
689
|
+
const client = await createPgClient(connectionString);
|
|
690
|
+
await client.connect();
|
|
691
|
+
try {
|
|
692
|
+
const result = await client.query(`SELECT current_setting('data_directory') AS data_directory`);
|
|
693
|
+
const dataDirectory = result.rows[0]?.data_directory;
|
|
694
|
+
return typeof dataDirectory === 'string' ? dataDirectory : null;
|
|
695
|
+
}
|
|
696
|
+
finally {
|
|
697
|
+
await client.end().catch(() => undefined);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
async function ensurePostgresDatabase(adminConnectionString, databaseName) {
|
|
701
|
+
const client = await createPgClient(adminConnectionString);
|
|
702
|
+
await client.connect();
|
|
703
|
+
try {
|
|
704
|
+
const lookup = await client.query('SELECT 1 FROM pg_database WHERE datname = $1 LIMIT 1', [databaseName]);
|
|
705
|
+
if (lookup.rowCount > 0) {
|
|
706
|
+
return 'existing';
|
|
707
|
+
}
|
|
708
|
+
const safeDatabaseName = databaseName.replace(/"/g, '""');
|
|
709
|
+
await client.query(`CREATE DATABASE "${safeDatabaseName}"`);
|
|
710
|
+
return 'created';
|
|
711
|
+
}
|
|
712
|
+
finally {
|
|
713
|
+
await client.end().catch(() => undefined);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
function readRunningPostmasterPid(postmasterPidFile) {
|
|
717
|
+
if (!fs.existsSync(postmasterPidFile)) {
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
try {
|
|
721
|
+
const pid = Number(fs.readFileSync(postmasterPidFile, 'utf8').split('\n')[0]?.trim());
|
|
722
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
process.kill(pid, 0);
|
|
726
|
+
return pid;
|
|
727
|
+
}
|
|
728
|
+
catch {
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
function readPostmasterPort(postmasterPidFile) {
|
|
733
|
+
if (!fs.existsSync(postmasterPidFile)) {
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
try {
|
|
737
|
+
const port = Number(fs.readFileSync(postmasterPidFile, 'utf8').split('\n')[3]?.trim());
|
|
738
|
+
return Number.isInteger(port) && port > 0 ? port : null;
|
|
739
|
+
}
|
|
740
|
+
catch {
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
async function isPortInUse(port) {
|
|
745
|
+
return await new Promise((resolve) => {
|
|
746
|
+
const server = net.createServer();
|
|
747
|
+
server.unref();
|
|
748
|
+
server.once('error', (error) => {
|
|
749
|
+
resolve(error.code === 'EADDRINUSE');
|
|
750
|
+
});
|
|
751
|
+
server.listen(port, '127.0.0.1', () => {
|
|
752
|
+
server.close();
|
|
753
|
+
resolve(false);
|
|
754
|
+
});
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
async function findAvailablePort(startPort, lookahead = EMBEDDED_POSTGRES_LOOKAHEAD) {
|
|
758
|
+
let port = startPort;
|
|
759
|
+
for (let attempt = 0; attempt < lookahead; attempt += 1, port += 1) {
|
|
760
|
+
if (!(await isPortInUse(port))) {
|
|
761
|
+
return port;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
throw new Error(`Could not find a free local port from ${startPort} to ${startPort + lookahead - 1}.`);
|
|
765
|
+
}
|
|
766
|
+
async function ensureEmbeddedDatabaseRuntime(env) {
|
|
767
|
+
const binaries = await loadEmbeddedPostgresBinaries();
|
|
768
|
+
const dataDir = resolveEmbeddedDataDir(env);
|
|
769
|
+
const preferredPort = resolveEmbeddedPort(env);
|
|
770
|
+
const postmasterPidFile = path.join(dataDir, 'postmaster.pid');
|
|
771
|
+
const pgVersionFile = path.join(dataDir, 'PG_VERSION');
|
|
772
|
+
const runningPid = readRunningPostmasterPid(postmasterPidFile);
|
|
773
|
+
const runningPort = readPostmasterPort(postmasterPidFile);
|
|
774
|
+
const adminUrl = buildManagedDatabaseUrl(preferredPort, 'postgres');
|
|
775
|
+
const logBuffer = createEmbeddedPostgresLogBuffer();
|
|
776
|
+
fs.mkdirSync(path.dirname(dataDir), { recursive: true });
|
|
777
|
+
if (!runningPid && fs.existsSync(pgVersionFile)) {
|
|
778
|
+
try {
|
|
779
|
+
const actualDataDir = await getPostgresDataDirectory(adminUrl);
|
|
780
|
+
if (actualDataDir && path.resolve(actualDataDir) === path.resolve(dataDir)) {
|
|
781
|
+
await ensurePostgresDatabase(adminUrl, EMBEDDED_POSTGRES_DATABASE);
|
|
782
|
+
return {
|
|
783
|
+
mode: 'embedded',
|
|
784
|
+
databaseUrl: buildManagedDatabaseUrl(preferredPort),
|
|
785
|
+
source: `embedded-postgres@${preferredPort}`,
|
|
786
|
+
reachable: true,
|
|
787
|
+
embeddedDataDir: dataDir,
|
|
788
|
+
embeddedPort: preferredPort,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
catch {
|
|
793
|
+
// Fall through to a managed embedded start.
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
if (runningPid) {
|
|
797
|
+
const port = runningPort || preferredPort;
|
|
798
|
+
const runningAdminUrl = buildManagedDatabaseUrl(port, 'postgres');
|
|
799
|
+
await ensurePostgresDatabase(runningAdminUrl, EMBEDDED_POSTGRES_DATABASE);
|
|
800
|
+
return {
|
|
801
|
+
mode: 'embedded',
|
|
802
|
+
databaseUrl: buildManagedDatabaseUrl(port),
|
|
803
|
+
source: `embedded-postgres@${port}`,
|
|
804
|
+
reachable: true,
|
|
805
|
+
embeddedDataDir: dataDir,
|
|
806
|
+
embeddedPort: port,
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
const selectedPort = await findAvailablePort(preferredPort);
|
|
810
|
+
ensureExecutable(binaries.initdb);
|
|
811
|
+
ensureExecutable(binaries.pgCtl);
|
|
812
|
+
if (!fs.existsSync(pgVersionFile)) {
|
|
813
|
+
const passwordFile = createEmbeddedPasswordFile();
|
|
814
|
+
try {
|
|
815
|
+
const initResult = captureCommand(binaries.initdb, [
|
|
816
|
+
`--pgdata=${dataDir}`,
|
|
817
|
+
'--auth=password',
|
|
818
|
+
`--username=${EMBEDDED_POSTGRES_USER}`,
|
|
819
|
+
`--pwfile=${passwordFile}`,
|
|
820
|
+
'--lc-messages=C',
|
|
821
|
+
'--encoding=UTF8',
|
|
822
|
+
'--locale=C',
|
|
823
|
+
], binaries.packageRoot, {
|
|
824
|
+
LC_MESSAGES: 'C',
|
|
825
|
+
});
|
|
826
|
+
appendEmbeddedOutput(logBuffer, initResult.output);
|
|
827
|
+
if (!initResult.ok) {
|
|
828
|
+
throw new Error(initResult.output || 'initdb failed');
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
catch (error) {
|
|
832
|
+
throw formatEmbeddedPostgresError(error, `Failed to initialize embedded PostgreSQL in ${dataDir}`, logBuffer.getRecentLogs());
|
|
833
|
+
}
|
|
834
|
+
finally {
|
|
835
|
+
fs.rmSync(passwordFile, { force: true });
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
if (fs.existsSync(postmasterPidFile)) {
|
|
839
|
+
fs.rmSync(postmasterPidFile, { force: true });
|
|
840
|
+
}
|
|
841
|
+
const logPath = getEmbeddedPostgresLogPath(dataDir);
|
|
842
|
+
try {
|
|
843
|
+
const startResult = captureCommand(binaries.pgCtl, ['-D', dataDir, '-l', logPath, '-w', 'start', '-o', `-p ${selectedPort}`], binaries.packageRoot, {
|
|
844
|
+
LC_MESSAGES: 'C',
|
|
845
|
+
});
|
|
846
|
+
appendEmbeddedOutput(logBuffer, startResult.output);
|
|
847
|
+
if (fs.existsSync(logPath)) {
|
|
848
|
+
appendEmbeddedOutput(logBuffer, fs.readFileSync(logPath, 'utf8'));
|
|
849
|
+
}
|
|
850
|
+
if (!startResult.ok) {
|
|
851
|
+
throw new Error(startResult.output || 'pg_ctl start failed');
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
catch (error) {
|
|
855
|
+
throw formatEmbeddedPostgresError(error, `Failed to start embedded PostgreSQL on port ${selectedPort}`, logBuffer.getRecentLogs());
|
|
856
|
+
}
|
|
857
|
+
await ensurePostgresDatabase(buildManagedDatabaseUrl(selectedPort, 'postgres'), EMBEDDED_POSTGRES_DATABASE);
|
|
858
|
+
return {
|
|
859
|
+
mode: 'embedded',
|
|
860
|
+
databaseUrl: buildManagedDatabaseUrl(selectedPort),
|
|
861
|
+
source: `embedded-postgres@${selectedPort}`,
|
|
862
|
+
reachable: true,
|
|
863
|
+
embeddedDataDir: dataDir,
|
|
864
|
+
embeddedPort: selectedPort,
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
async function ensureDockerDatabaseRuntime(paths, env) {
|
|
868
|
+
const desiredPort = resolveDockerPort(env);
|
|
869
|
+
const selectedPort = await findAvailablePort(desiredPort);
|
|
870
|
+
const envOverrides = {
|
|
871
|
+
DB_PORT: String(selectedPort),
|
|
872
|
+
};
|
|
873
|
+
runChecked('docker', ['compose', 'up', '-d', 'db'], paths.root, 'Docker db startup', envOverrides);
|
|
874
|
+
const ready = await waitForDockerDatabase(paths, envOverrides);
|
|
875
|
+
if (!ready) {
|
|
876
|
+
throw new Error('Docker PostgreSQL did not become ready in time.');
|
|
877
|
+
}
|
|
878
|
+
return {
|
|
879
|
+
mode: 'docker',
|
|
880
|
+
databaseUrl: buildManagedDatabaseUrl(selectedPort),
|
|
881
|
+
source: 'docker-compose',
|
|
882
|
+
reachable: true,
|
|
883
|
+
dockerPort: selectedPort,
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
export async function probeEmbeddedPostgresSupport() {
|
|
887
|
+
try {
|
|
888
|
+
await loadEmbeddedPostgresBinaries();
|
|
889
|
+
return { supported: true };
|
|
890
|
+
}
|
|
891
|
+
catch (error) {
|
|
892
|
+
return {
|
|
893
|
+
supported: false,
|
|
894
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
export async function ensureDatabaseReady(paths, options = {}) {
|
|
899
|
+
const env = readEnvFile(getEnvPath(paths));
|
|
900
|
+
const preference = resolveDatabasePreference(env);
|
|
901
|
+
if (preference.mode === 'external') {
|
|
902
|
+
if (!preference.databaseUrl) {
|
|
903
|
+
throw new Error('DD_DATABASE_MODE=external requires DATABASE_URL to be set.');
|
|
904
|
+
}
|
|
905
|
+
const reachable = await checkDatabaseReachability(preference.databaseUrl);
|
|
906
|
+
if (!reachable) {
|
|
907
|
+
throw new Error('Database is not reachable. Start PostgreSQL or update apps/main-app/.env first.');
|
|
908
|
+
}
|
|
909
|
+
return {
|
|
910
|
+
mode: 'external',
|
|
911
|
+
databaseUrl: preference.databaseUrl,
|
|
912
|
+
source: preference.reason,
|
|
913
|
+
reachable,
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
if (preference.mode === 'docker') {
|
|
917
|
+
if (preference.databaseUrl && await checkDatabaseReachability(preference.databaseUrl)) {
|
|
918
|
+
return {
|
|
919
|
+
mode: 'docker',
|
|
920
|
+
databaseUrl: preference.databaseUrl,
|
|
921
|
+
source: preference.reason,
|
|
922
|
+
reachable: true,
|
|
923
|
+
dockerPort: resolveDockerPort(env),
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
if (!dockerAvailable()) {
|
|
927
|
+
throw new Error('Docker is not available, so the configured Docker database cannot be started.');
|
|
928
|
+
}
|
|
929
|
+
return ensureDockerDatabaseRuntime(paths, env);
|
|
930
|
+
}
|
|
931
|
+
if (preference.databaseUrl && isLegacyLocalDatabaseUrl(preference.databaseUrl)) {
|
|
932
|
+
const reachable = await checkDatabaseReachability(preference.databaseUrl);
|
|
933
|
+
if (reachable) {
|
|
934
|
+
return {
|
|
935
|
+
mode: 'external',
|
|
936
|
+
databaseUrl: preference.databaseUrl,
|
|
937
|
+
source: 'legacy-localhost',
|
|
938
|
+
reachable: true,
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
try {
|
|
943
|
+
return await ensureEmbeddedDatabaseRuntime(env);
|
|
944
|
+
}
|
|
945
|
+
catch (error) {
|
|
946
|
+
if (options.allowDockerFallback && dockerAvailable()) {
|
|
947
|
+
const useDocker = options.yes
|
|
948
|
+
? true
|
|
949
|
+
: await confirmYesNo('Embedded PostgreSQL failed. Start PostgreSQL via Docker instead?');
|
|
950
|
+
if (useDocker) {
|
|
951
|
+
return ensureDockerDatabaseRuntime(paths, env);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
throw error;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
export function localDependenciesInstalled(paths) {
|
|
958
|
+
return fs.existsSync(path.join(paths.root, 'packages', 'cli', 'node_modules', 'embedded-postgres'))
|
|
959
|
+
&& fs.existsSync(path.join(paths.root, 'packages', 'cli', 'node_modules', 'pg'));
|
|
960
|
+
}
|
|
961
|
+
export function databaseRuntimeToEnvOptions(runtime) {
|
|
962
|
+
const options = {};
|
|
963
|
+
if (runtime.mode === 'embedded') {
|
|
964
|
+
options.databaseMode = runtime.mode;
|
|
965
|
+
options.embeddedDataDir = runtime.embeddedDataDir;
|
|
966
|
+
options.embeddedPort = runtime.embeddedPort;
|
|
967
|
+
return options;
|
|
968
|
+
}
|
|
969
|
+
if (runtime.mode === 'docker') {
|
|
970
|
+
options.databaseMode = runtime.mode;
|
|
971
|
+
options.dockerPort = runtime.dockerPort;
|
|
972
|
+
return options;
|
|
973
|
+
}
|
|
974
|
+
if (runtime.source !== 'DATABASE_URL') {
|
|
975
|
+
options.databaseUrl = runtime.databaseUrl;
|
|
976
|
+
}
|
|
977
|
+
return options;
|
|
978
|
+
}
|
|
979
|
+
export function applyRuntimeDatabaseUrl(env, runtime) {
|
|
980
|
+
process.env.DATABASE_URL = runtime.databaseUrl;
|
|
981
|
+
return {
|
|
982
|
+
...env,
|
|
983
|
+
DATABASE_URL: runtime.databaseUrl,
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
export function trimOutput(output, maxLines = 12) {
|
|
987
|
+
const lines = output
|
|
988
|
+
.split(/\r?\n/)
|
|
989
|
+
.map((line) => line.trimEnd())
|
|
990
|
+
.filter(Boolean);
|
|
991
|
+
if (lines.length <= maxLines) {
|
|
992
|
+
return lines.join('\n');
|
|
993
|
+
}
|
|
994
|
+
return lines.slice(-maxLines).join('\n');
|
|
995
|
+
}
|
|
996
|
+
export function requiredEnvKeysMissing(env) {
|
|
997
|
+
const databasePreference = resolveDatabasePreference(env);
|
|
998
|
+
return REQUIRED_ENV_KEYS.filter((key) => {
|
|
999
|
+
if (key === 'DATABASE_URL') {
|
|
1000
|
+
return !env[key] && databasePreference.mode !== 'embedded' && databasePreference.mode !== 'docker';
|
|
1001
|
+
}
|
|
1002
|
+
return !env[key];
|
|
1003
|
+
});
|
|
1004
|
+
}
|