becky-cli 0.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -0
- package/dist/ai/openai.d.ts +17 -0
- package/dist/ai/openai.js +67 -0
- package/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.js +30 -0
- package/dist/commands/connect.d.ts +11 -0
- package/dist/commands/connect.js +36 -0
- package/dist/commands/doctor.d.ts +10 -0
- package/dist/commands/doctor.js +166 -0
- package/dist/commands/migrate.d.ts +9 -0
- package/dist/commands/migrate.js +208 -0
- package/dist/commands/postgres-setup.d.ts +12 -0
- package/dist/commands/postgres-setup.js +132 -0
- package/dist/commands/up.d.ts +13 -0
- package/dist/commands/up.js +58 -0
- package/dist/config/store.d.ts +19 -0
- package/dist/config/store.js +65 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +197 -0
- package/dist/platform/detect.d.ts +10 -0
- package/dist/platform/detect.js +47 -0
- package/dist/platform/linux-ubuntu.d.ts +2 -0
- package/dist/platform/linux-ubuntu.js +48 -0
- package/dist/postgres/admin.d.ts +19 -0
- package/dist/postgres/admin.js +77 -0
- package/dist/project/alembicScaffold.d.ts +7 -0
- package/dist/project/alembicScaffold.js +161 -0
- package/dist/project/env.d.ts +25 -0
- package/dist/project/env.js +124 -0
- package/dist/project/fastapiPins.d.ts +3 -0
- package/dist/project/fastapiPins.js +13 -0
- package/dist/project/identity.d.ts +13 -0
- package/dist/project/identity.js +37 -0
- package/dist/project/load.d.ts +27 -0
- package/dist/project/load.js +67 -0
- package/dist/util/exec.d.ts +15 -0
- package/dist/util/exec.js +45 -0
- package/dist/util/log.d.ts +6 -0
- package/dist/util/log.js +27 -0
- package/dist/util/prompt.d.ts +2 -0
- package/dist/util/prompt.js +22 -0
- package/package.json +49 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { commandExists, run, runOrThrow } from "../util/exec.js";
|
|
2
|
+
import { detail, info, success, warn } from "../util/log.js";
|
|
3
|
+
export async function getPostgresStatus() {
|
|
4
|
+
const hasPsql = await commandExists("psql");
|
|
5
|
+
if (!hasPsql) {
|
|
6
|
+
return { installed: false, serviceActive: false };
|
|
7
|
+
}
|
|
8
|
+
let version;
|
|
9
|
+
try {
|
|
10
|
+
const out = await run("psql", ["--version"]);
|
|
11
|
+
version = out.stdout.trim() || out.stderr.trim();
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
version = undefined;
|
|
15
|
+
}
|
|
16
|
+
const service = await run("systemctl", ["is-active", "postgresql"]);
|
|
17
|
+
const serviceActive = service.stdout.trim() === "active";
|
|
18
|
+
return { installed: true, version, serviceActive };
|
|
19
|
+
}
|
|
20
|
+
export function sqlLiteral(value) {
|
|
21
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
22
|
+
}
|
|
23
|
+
export async function postgresQuery(sql) {
|
|
24
|
+
return runOrThrow("sudo", ["-u", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-tAc", sql], {}, "PostgreSQL query failed");
|
|
25
|
+
}
|
|
26
|
+
export async function roleExists(name) {
|
|
27
|
+
const out = await postgresQuery(`SELECT 1 FROM pg_roles WHERE rolname=${sqlLiteral(name)}`);
|
|
28
|
+
return out.trim() === "1";
|
|
29
|
+
}
|
|
30
|
+
export async function databaseExists(name) {
|
|
31
|
+
const out = await postgresQuery(`SELECT 1 FROM pg_database WHERE datname=${sqlLiteral(name)}`);
|
|
32
|
+
return out.trim() === "1";
|
|
33
|
+
}
|
|
34
|
+
export async function setupDatabaseUser(options) {
|
|
35
|
+
const { user, password, database, grantPrivileges = true } = options;
|
|
36
|
+
if (await roleExists(user)) {
|
|
37
|
+
warn(`Role "${user}" already exists — updating password`);
|
|
38
|
+
await postgresQuery(`ALTER ROLE ${quoteIdent(user)} WITH LOGIN PASSWORD ${sqlLiteral(password)}`);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
info(`Creating role "${user}"`);
|
|
42
|
+
await postgresQuery(`CREATE ROLE ${quoteIdent(user)} WITH LOGIN PASSWORD ${sqlLiteral(password)} CREATEDB`);
|
|
43
|
+
success(`Role "${user}" created`);
|
|
44
|
+
}
|
|
45
|
+
if (await databaseExists(database)) {
|
|
46
|
+
warn(`Database "${database}" already exists — skipping create`);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
info(`Creating database "${database}"`);
|
|
50
|
+
await postgresQuery(`CREATE DATABASE ${quoteIdent(database)} OWNER ${quoteIdent(user)} ENCODING 'UTF8'`);
|
|
51
|
+
success(`Database "${database}" created`);
|
|
52
|
+
}
|
|
53
|
+
if (grantPrivileges) {
|
|
54
|
+
await postgresQuery(`GRANT ALL PRIVILEGES ON DATABASE ${quoteIdent(database)} TO ${quoteIdent(user)}`);
|
|
55
|
+
detail(`Granted privileges on "${database}" to "${user}"`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function quoteIdent(identifier) {
|
|
59
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
60
|
+
}
|
|
61
|
+
export function buildDatabaseUrl(user, password, database, host = "localhost", port = 5432) {
|
|
62
|
+
const encodedUser = encodeURIComponent(user);
|
|
63
|
+
const encodedPassword = encodeURIComponent(password);
|
|
64
|
+
return `postgresql://${encodedUser}:${encodedPassword}@${host}:${port}/${database}`;
|
|
65
|
+
}
|
|
66
|
+
export async function ensureServiceRunning() {
|
|
67
|
+
const status = await getPostgresStatus();
|
|
68
|
+
if (!status.installed)
|
|
69
|
+
return;
|
|
70
|
+
if (status.serviceActive) {
|
|
71
|
+
detail("PostgreSQL service is running");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
info("Starting PostgreSQL service");
|
|
75
|
+
await runOrThrow("sudo", ["systemctl", "enable", "--now", "postgresql"], {}, "Failed to start PostgreSQL");
|
|
76
|
+
success("PostgreSQL service started");
|
|
77
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { LoadedProject } from "./load.js";
|
|
2
|
+
export declare const ALEMBIC_INI = "[alembic]\nscript_location = alembic\nprepend_sys_path = .\nversion_path_separator = os\n\n[loggers]\nkeys = root,sqlalchemy,alembic\n\n[handlers]\nkeys = console\n\n[formatters]\nkeys = generic\n\n[logger_root]\nlevel = WARN\nhandlers = console\nqualname =\n\n[logger_sqlalchemy]\nlevel = WARN\nhandlers =\nqualname = sqlalchemy.engine\n\n[logger_alembic]\nlevel = INFO\nhandlers =\nqualname = alembic\n\n[handler_console]\nclass = StreamHandler\nargs = (sys.stderr,)\nlevel = NOTSET\nformatter = generic\n\n[formatter_generic]\nformat = %(levelname)-5.5s [%(name)s] %(message)s\ndatefmt = %H:%M:%S\n";
|
|
3
|
+
export declare const ALEMBIC_ENV_PY = "import os\nfrom configparser import ConfigParser\nfrom logging.config import fileConfig\n\nfrom alembic import context\nfrom sqlalchemy import engine_from_config, pool\n\nconfig = context.config\nif config.config_file_name is not None:\n _ini = ConfigParser()\n _ini.read(config.config_file_name)\n if _ini.has_section(\"formatters\"):\n fileConfig(config.config_file_name)\n\nfrom db.session import Base # noqa: E402\n\ntarget_metadata = Base.metadata\n\n\ndef get_url() -> str:\n return os.environ[\"DATABASE_URL\"]\n\n\ndef run_migrations_offline() -> None:\n context.configure(\n url=get_url(),\n target_metadata=target_metadata,\n literal_binds=True,\n dialect_opts={\"paramstyle\": \"named\"},\n )\n with context.begin_transaction():\n context.run_migrations()\n\n\ndef run_migrations_online() -> None:\n configuration = config.get_section(config.config_ini_section) or {}\n configuration[\"sqlalchemy.url\"] = get_url()\n connectable = engine_from_config(configuration, prefix=\"sqlalchemy.\", poolclass=pool.NullPool)\n with connectable.connect() as connection:\n context.configure(connection=connection, target_metadata=target_metadata)\n with context.begin_transaction():\n context.run_migrations()\n\n\nif context.is_offline_mode():\n run_migrations_offline()\nelse:\n run_migrations_online()\n";
|
|
4
|
+
export declare const ALEMBIC_SCRIPT_MAKO = "\"\"\"\\${message}\n\nRevision ID: \\${up_revision}\nRevises: \\${down_revision | comma,n}\nCreate Date: \\${create_date}\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\n\\${imports if imports else \"\"}\n\nrevision = \\${repr(up_revision)}\ndown_revision = \\${repr(down_revision)}\nbranch_labels = \\${repr(branch_labels)}\ndepends_on = \\${repr(depends_on)}\n\n\ndef upgrade():\n \\${upgrades if upgrades else \"pass\"}\n\n\ndef downgrade():\n \\${downgrades if downgrades else \"pass\"}\n";
|
|
5
|
+
export declare function migrateShContent(stackRoot: string): string;
|
|
6
|
+
/** Write or repair alembic.ini + env.py when the AI scaffold omitted or truncated them. */
|
|
7
|
+
export declare function ensureAlembicScaffold(project: LoadedProject): void;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { info } from "../util/log.js";
|
|
4
|
+
export const ALEMBIC_INI = `[alembic]
|
|
5
|
+
script_location = alembic
|
|
6
|
+
prepend_sys_path = .
|
|
7
|
+
version_path_separator = os
|
|
8
|
+
|
|
9
|
+
[loggers]
|
|
10
|
+
keys = root,sqlalchemy,alembic
|
|
11
|
+
|
|
12
|
+
[handlers]
|
|
13
|
+
keys = console
|
|
14
|
+
|
|
15
|
+
[formatters]
|
|
16
|
+
keys = generic
|
|
17
|
+
|
|
18
|
+
[logger_root]
|
|
19
|
+
level = WARN
|
|
20
|
+
handlers = console
|
|
21
|
+
qualname =
|
|
22
|
+
|
|
23
|
+
[logger_sqlalchemy]
|
|
24
|
+
level = WARN
|
|
25
|
+
handlers =
|
|
26
|
+
qualname = sqlalchemy.engine
|
|
27
|
+
|
|
28
|
+
[logger_alembic]
|
|
29
|
+
level = INFO
|
|
30
|
+
handlers =
|
|
31
|
+
qualname = alembic
|
|
32
|
+
|
|
33
|
+
[handler_console]
|
|
34
|
+
class = StreamHandler
|
|
35
|
+
args = (sys.stderr,)
|
|
36
|
+
level = NOTSET
|
|
37
|
+
formatter = generic
|
|
38
|
+
|
|
39
|
+
[formatter_generic]
|
|
40
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
41
|
+
datefmt = %H:%M:%S
|
|
42
|
+
`;
|
|
43
|
+
export const ALEMBIC_ENV_PY = `import os
|
|
44
|
+
from configparser import ConfigParser
|
|
45
|
+
from logging.config import fileConfig
|
|
46
|
+
|
|
47
|
+
from alembic import context
|
|
48
|
+
from sqlalchemy import engine_from_config, pool
|
|
49
|
+
|
|
50
|
+
config = context.config
|
|
51
|
+
if config.config_file_name is not None:
|
|
52
|
+
_ini = ConfigParser()
|
|
53
|
+
_ini.read(config.config_file_name)
|
|
54
|
+
if _ini.has_section("formatters"):
|
|
55
|
+
fileConfig(config.config_file_name)
|
|
56
|
+
|
|
57
|
+
from db.session import Base # noqa: E402
|
|
58
|
+
|
|
59
|
+
target_metadata = Base.metadata
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_url() -> str:
|
|
63
|
+
return os.environ["DATABASE_URL"]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def run_migrations_offline() -> None:
|
|
67
|
+
context.configure(
|
|
68
|
+
url=get_url(),
|
|
69
|
+
target_metadata=target_metadata,
|
|
70
|
+
literal_binds=True,
|
|
71
|
+
dialect_opts={"paramstyle": "named"},
|
|
72
|
+
)
|
|
73
|
+
with context.begin_transaction():
|
|
74
|
+
context.run_migrations()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def run_migrations_online() -> None:
|
|
78
|
+
configuration = config.get_section(config.config_ini_section) or {}
|
|
79
|
+
configuration["sqlalchemy.url"] = get_url()
|
|
80
|
+
connectable = engine_from_config(configuration, prefix="sqlalchemy.", poolclass=pool.NullPool)
|
|
81
|
+
with connectable.connect() as connection:
|
|
82
|
+
context.configure(connection=connection, target_metadata=target_metadata)
|
|
83
|
+
with context.begin_transaction():
|
|
84
|
+
context.run_migrations()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if context.is_offline_mode():
|
|
88
|
+
run_migrations_offline()
|
|
89
|
+
else:
|
|
90
|
+
run_migrations_online()
|
|
91
|
+
`;
|
|
92
|
+
export const ALEMBIC_SCRIPT_MAKO = `"""\\\${message}
|
|
93
|
+
|
|
94
|
+
Revision ID: \\\${up_revision}
|
|
95
|
+
Revises: \\\${down_revision | comma,n}
|
|
96
|
+
Create Date: \\\${create_date}
|
|
97
|
+
"""
|
|
98
|
+
from alembic import op
|
|
99
|
+
import sqlalchemy as sa
|
|
100
|
+
\\\${imports if imports else ""}
|
|
101
|
+
|
|
102
|
+
revision = \\\${repr(up_revision)}
|
|
103
|
+
down_revision = \\\${repr(down_revision)}
|
|
104
|
+
branch_labels = \\\${repr(branch_labels)}
|
|
105
|
+
depends_on = \\\${repr(depends_on)}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def upgrade():
|
|
109
|
+
\\\${upgrades if upgrades else "pass"}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def downgrade():
|
|
113
|
+
\\\${downgrades if downgrades else "pass"}
|
|
114
|
+
`;
|
|
115
|
+
export function migrateShContent(stackRoot) {
|
|
116
|
+
return `#!/usr/bin/env bash
|
|
117
|
+
set -euo pipefail
|
|
118
|
+
cd "${stackRoot}"
|
|
119
|
+
if [ -d .venv ]; then source .venv/bin/activate; fi
|
|
120
|
+
python -m alembic upgrade head
|
|
121
|
+
`;
|
|
122
|
+
}
|
|
123
|
+
/** Logging sections fileConfig() requires; alembic.ini without them raises KeyError. */
|
|
124
|
+
const ALEMBIC_LOGGING_SECTIONS = ALEMBIC_INI.slice(ALEMBIC_INI.indexOf("[loggers]"));
|
|
125
|
+
/** Write or repair alembic.ini + env.py when the AI scaffold omitted or truncated them. */
|
|
126
|
+
export function ensureAlembicScaffold(project) {
|
|
127
|
+
const ini = join(project.stackRoot, "alembic.ini");
|
|
128
|
+
const envPy = join(project.stackRoot, "alembic", "env.py");
|
|
129
|
+
const mako = join(project.stackRoot, "alembic", "script.py.mako");
|
|
130
|
+
const versions = join(project.stackRoot, "alembic", "versions");
|
|
131
|
+
if (!existsSync(ini)) {
|
|
132
|
+
writeFileSync(ini, ALEMBIC_INI, "utf8");
|
|
133
|
+
info(`Created ${ini}`);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
const current = readFileSync(ini, "utf8");
|
|
137
|
+
if (!current.includes("[formatters]")) {
|
|
138
|
+
writeFileSync(ini, `${current.replace(/\n+$/, "")}\n\n${ALEMBIC_LOGGING_SECTIONS}`, "utf8");
|
|
139
|
+
info("Added missing logging sections to alembic.ini");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!existsSync(envPy)) {
|
|
143
|
+
mkdirSync(join(project.stackRoot, "alembic"), { recursive: true });
|
|
144
|
+
writeFileSync(envPy, ALEMBIC_ENV_PY, "utf8");
|
|
145
|
+
info(`Created ${envPy}`);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
const current = readFileSync(envPy, "utf8");
|
|
149
|
+
if (current.includes("fileConfig(config.config_file_name)") && !current.includes("has_section")) {
|
|
150
|
+
writeFileSync(envPy, ALEMBIC_ENV_PY, "utf8");
|
|
151
|
+
info("Rewrote alembic/env.py with a logging-safe config loader");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (!existsSync(mako)) {
|
|
155
|
+
mkdirSync(join(project.stackRoot, "alembic"), { recursive: true });
|
|
156
|
+
writeFileSync(mako, ALEMBIC_SCRIPT_MAKO, "utf8");
|
|
157
|
+
}
|
|
158
|
+
if (!existsSync(versions)) {
|
|
159
|
+
mkdirSync(versions, { recursive: true });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type StoredCredentials } from "../config/store.js";
|
|
2
|
+
import type { LoadedProject } from "../project/load.js";
|
|
3
|
+
export type ConnectionTarget = {
|
|
4
|
+
host: string;
|
|
5
|
+
port: number;
|
|
6
|
+
user: string;
|
|
7
|
+
password: string;
|
|
8
|
+
database: string;
|
|
9
|
+
databaseUrl: string;
|
|
10
|
+
};
|
|
11
|
+
export type ResolveConnectionOptions = {
|
|
12
|
+
user?: string;
|
|
13
|
+
password?: string;
|
|
14
|
+
database?: string;
|
|
15
|
+
host?: string;
|
|
16
|
+
port?: number;
|
|
17
|
+
promptIfMissing?: boolean;
|
|
18
|
+
};
|
|
19
|
+
export declare function resolveConnection(project: LoadedProject, options?: ResolveConnectionOptions): Promise<ConnectionTarget>;
|
|
20
|
+
/** Prisma reads .env next to schema.prisma (e.g. server/.env), not always project root. */
|
|
21
|
+
export declare function envFilePaths(project: LoadedProject): string[];
|
|
22
|
+
export declare function writeEnvFile(project: LoadedProject, connection: ConnectionTarget, force?: boolean): string[];
|
|
23
|
+
export declare function testDatabaseConnection(connection: ConnectionTarget): Promise<void>;
|
|
24
|
+
export declare function credentialsSummary(creds: StoredCredentials): string;
|
|
25
|
+
export declare function confirmPrompt(message: string, yes: boolean): Promise<boolean>;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { loadCredentials } from "../config/store.js";
|
|
4
|
+
import { buildDatabaseUrl } from "../postgres/admin.js";
|
|
5
|
+
import { resolveDbIdentity } from "../project/identity.js";
|
|
6
|
+
import { promptHidden, promptText } from "../util/prompt.js";
|
|
7
|
+
import { detail, info, success, warn } from "../util/log.js";
|
|
8
|
+
export async function resolveConnection(project, options = {}) {
|
|
9
|
+
const saved = loadCredentials();
|
|
10
|
+
const { user, database } = resolveDbIdentity(project, {
|
|
11
|
+
user: options.user,
|
|
12
|
+
database: options.database,
|
|
13
|
+
});
|
|
14
|
+
const host = options.host?.trim() || saved?.host || "localhost";
|
|
15
|
+
const port = options.port ?? saved?.port ?? 5432;
|
|
16
|
+
// Reuse password only when it matches this project user+db (or same db with matching user)
|
|
17
|
+
const savedPassword = saved &&
|
|
18
|
+
saved.database === database &&
|
|
19
|
+
(saved.user === user || saved.user === "app")
|
|
20
|
+
? saved.password
|
|
21
|
+
: saved?.password && saved.user === user
|
|
22
|
+
? saved.password
|
|
23
|
+
: undefined;
|
|
24
|
+
let password = options.password ?? savedPassword;
|
|
25
|
+
if (!password && options.promptIfMissing !== false) {
|
|
26
|
+
password = await promptHidden(`Database password for user "${user}"`);
|
|
27
|
+
}
|
|
28
|
+
if (!password) {
|
|
29
|
+
throw new Error("No database password found. Run `becky postgres setup` first, or pass --password.");
|
|
30
|
+
}
|
|
31
|
+
if (saved?.user === "app" && user !== "app") {
|
|
32
|
+
warn(`Ignoring legacy credentials user "app" — using project user "${user}"`);
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
host,
|
|
36
|
+
port,
|
|
37
|
+
user,
|
|
38
|
+
password,
|
|
39
|
+
database,
|
|
40
|
+
databaseUrl: buildDatabaseUrl(user, password, database, host, port),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function envFileContent(connection, template) {
|
|
44
|
+
const lines = [
|
|
45
|
+
`# Generated by becky connect`,
|
|
46
|
+
`POSTGRES_USER=${connection.user}`,
|
|
47
|
+
`POSTGRES_PASSWORD=${connection.password}`,
|
|
48
|
+
`POSTGRES_DB=${connection.database}`,
|
|
49
|
+
`DATABASE_URL=${connection.databaseUrl}`,
|
|
50
|
+
];
|
|
51
|
+
if (template) {
|
|
52
|
+
const preserved = [];
|
|
53
|
+
for (const line of template.split("\n")) {
|
|
54
|
+
const trimmed = line.trim();
|
|
55
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
56
|
+
continue;
|
|
57
|
+
const key = trimmed.split("=")[0];
|
|
58
|
+
if (!key)
|
|
59
|
+
continue;
|
|
60
|
+
if (["POSTGRES_USER", "POSTGRES_PASSWORD", "POSTGRES_DB", "DATABASE_URL"].includes(key)) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
preserved.push(line);
|
|
64
|
+
}
|
|
65
|
+
if (preserved.length) {
|
|
66
|
+
lines.push("", "# From .env.example", ...preserved);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return `${lines.join("\n")}\n`;
|
|
70
|
+
}
|
|
71
|
+
/** Prisma reads .env next to schema.prisma (e.g. server/.env), not always project root. */
|
|
72
|
+
export function envFilePaths(project) {
|
|
73
|
+
const rootEnv = join(project.root, ".env");
|
|
74
|
+
const stackEnv = join(project.stackRoot, ".env");
|
|
75
|
+
return stackEnv === rootEnv ? [rootEnv] : [stackEnv, rootEnv];
|
|
76
|
+
}
|
|
77
|
+
export function writeEnvFile(project, connection, force = false) {
|
|
78
|
+
const template = existsSync(join(project.root, ".env.example"))
|
|
79
|
+
? readFileSync(join(project.root, ".env.example"), "utf8")
|
|
80
|
+
: existsSync(join(project.stackRoot, ".env.example"))
|
|
81
|
+
? readFileSync(join(project.stackRoot, ".env.example"), "utf8")
|
|
82
|
+
: "";
|
|
83
|
+
const content = envFileContent(connection, template);
|
|
84
|
+
const written = [];
|
|
85
|
+
for (const envPath of envFilePaths(project)) {
|
|
86
|
+
if (existsSync(envPath) && !force) {
|
|
87
|
+
warn(`.env already exists at ${envPath} — use --force to overwrite`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
writeFileSync(envPath, content, "utf8");
|
|
91
|
+
success(`Wrote ${envPath}`);
|
|
92
|
+
written.push(envPath);
|
|
93
|
+
}
|
|
94
|
+
if (!written.length) {
|
|
95
|
+
throw new Error("No .env files written — existing files blocked connect (use --force)");
|
|
96
|
+
}
|
|
97
|
+
return written;
|
|
98
|
+
}
|
|
99
|
+
export async function testDatabaseConnection(connection) {
|
|
100
|
+
const { run } = await import("../util/exec.js");
|
|
101
|
+
info("Testing database connection…");
|
|
102
|
+
const result = await run("psql", [
|
|
103
|
+
connection.databaseUrl,
|
|
104
|
+
"-v",
|
|
105
|
+
"ON_ERROR_STOP=1",
|
|
106
|
+
"-tAc",
|
|
107
|
+
"SELECT 1",
|
|
108
|
+
]);
|
|
109
|
+
if (result.code !== 0) {
|
|
110
|
+
const detailText = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n");
|
|
111
|
+
throw new Error(`Could not connect to Postgres:\n${detailText}`);
|
|
112
|
+
}
|
|
113
|
+
success("Database connection OK");
|
|
114
|
+
detail(`${connection.user}@${connection.host}:${connection.port}/${connection.database}`);
|
|
115
|
+
}
|
|
116
|
+
export function credentialsSummary(creds) {
|
|
117
|
+
return `${creds.user}@${creds.host}:${creds.port}/${creds.database}`;
|
|
118
|
+
}
|
|
119
|
+
export async function confirmPrompt(message, yes) {
|
|
120
|
+
if (yes)
|
|
121
|
+
return true;
|
|
122
|
+
const answer = await promptText(`${message} (y/N)`, "n");
|
|
123
|
+
return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes";
|
|
124
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/** Python deps that install cleanly on Ubuntu + Python 3.11–3.13. */
|
|
2
|
+
export declare const FASTAPI_REQUIREMENTS = "fastapi>=0.115.0\nuvicorn[standard]>=0.32.0\nsqlalchemy>=2.0.0\nalembic>=1.14.0\npsycopg2-binary>=2.9.9\npython-dotenv>=1.0.0\n";
|
|
3
|
+
export declare function isLegacyFastapiRequirements(content: string): boolean;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Python deps that install cleanly on Ubuntu + Python 3.11–3.13. */
|
|
2
|
+
export const FASTAPI_REQUIREMENTS = `fastapi>=0.115.0
|
|
3
|
+
uvicorn[standard]>=0.32.0
|
|
4
|
+
sqlalchemy>=2.0.0
|
|
5
|
+
alembic>=1.14.0
|
|
6
|
+
psycopg2-binary>=2.9.9
|
|
7
|
+
python-dotenv>=1.0.0
|
|
8
|
+
`;
|
|
9
|
+
export function isLegacyFastapiRequirements(content) {
|
|
10
|
+
return (/fastapi==0\.(9|[0-8])\./.test(content) ||
|
|
11
|
+
/uvicorn==0\.(2[0-1]|1[0-9]|[0-9])\./.test(content) ||
|
|
12
|
+
/sqlalchemy==1\./.test(content));
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { LoadedProject } from "./load.js";
|
|
2
|
+
export type DbIdentityOptions = {
|
|
3
|
+
user?: string;
|
|
4
|
+
database?: string;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Resolve Postgres role + database for a generated project.
|
|
8
|
+
* Project slug always wins over legacy "app" (unless --user is explicit).
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolveDbIdentity(project: LoadedProject, options?: DbIdentityOptions): {
|
|
11
|
+
user: string;
|
|
12
|
+
database: string;
|
|
13
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { warn } from "../util/log.js";
|
|
4
|
+
function readEnvExampleValue(project, key) {
|
|
5
|
+
for (const dir of [project.root, project.stackRoot]) {
|
|
6
|
+
const path = join(dir, ".env.example");
|
|
7
|
+
if (!existsSync(path))
|
|
8
|
+
continue;
|
|
9
|
+
const match = readFileSync(path, "utf8").match(new RegExp(`^${key}=(.+)$`, "m"));
|
|
10
|
+
const value = match?.[1]?.trim();
|
|
11
|
+
if (value && value !== "app" && !value.includes("{"))
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Resolve Postgres role + database for a generated project.
|
|
18
|
+
* Project slug always wins over legacy "app" (unless --user is explicit).
|
|
19
|
+
*/
|
|
20
|
+
export function resolveDbIdentity(project, options = {}) {
|
|
21
|
+
const database = options.database?.trim() || project.databaseName;
|
|
22
|
+
const explicitUser = options.user?.trim();
|
|
23
|
+
let user = explicitUser || project.databaseUser || database;
|
|
24
|
+
if (!explicitUser) {
|
|
25
|
+
const fromExample = readEnvExampleValue(project, "POSTGRES_USER");
|
|
26
|
+
if (fromExample)
|
|
27
|
+
user = fromExample;
|
|
28
|
+
if (user === "app") {
|
|
29
|
+
const replacement = project.databaseUser || database;
|
|
30
|
+
if (replacement !== "app") {
|
|
31
|
+
warn(`Replacing legacy DB user "app" with project user "${replacement}"`);
|
|
32
|
+
user = replacement;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { user, database };
|
|
37
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type Stack = "fastapi-postgres" | "express-postgres" | "nest-postgres";
|
|
2
|
+
export type ProjectSpec = {
|
|
3
|
+
projectName: string;
|
|
4
|
+
stack: Stack;
|
|
5
|
+
auth?: string;
|
|
6
|
+
entities?: unknown[];
|
|
7
|
+
outputs?: {
|
|
8
|
+
docker?: boolean;
|
|
9
|
+
migrations?: boolean;
|
|
10
|
+
openapi?: boolean;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
export type LoadedProject = {
|
|
14
|
+
root: string;
|
|
15
|
+
specPath: string;
|
|
16
|
+
spec: ProjectSpec;
|
|
17
|
+
stackRoot: string;
|
|
18
|
+
databaseName: string;
|
|
19
|
+
/** Postgres role — same slug as the database by default */
|
|
20
|
+
databaseUser: string;
|
|
21
|
+
};
|
|
22
|
+
/** Safe Postgres identifier from project name (e.g. bicycle-marketplace → bicycle_marketplace) */
|
|
23
|
+
export declare function projectDbName(projectName: string): string;
|
|
24
|
+
/** DB role defaults to the same name as the database */
|
|
25
|
+
export declare function projectDbUser(projectName: string): string;
|
|
26
|
+
export declare function findProjectSpec(dir: string): string | null;
|
|
27
|
+
export declare function loadProject(dir?: string): LoadedProject;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
const STACK_ROOT = {
|
|
4
|
+
"fastapi-postgres": "backend",
|
|
5
|
+
"express-postgres": "server",
|
|
6
|
+
"nest-postgres": "api",
|
|
7
|
+
};
|
|
8
|
+
/** Safe Postgres identifier from project name (e.g. bicycle-marketplace → bicycle_marketplace) */
|
|
9
|
+
export function projectDbName(projectName) {
|
|
10
|
+
const slug = projectName
|
|
11
|
+
.trim()
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
14
|
+
.replace(/^-+|-+$/g, "");
|
|
15
|
+
const name = (slug || "my-backend").replace(/-/g, "_");
|
|
16
|
+
// Postgres identifiers max 63 chars; must start with a letter
|
|
17
|
+
const trimmed = name.slice(0, 63);
|
|
18
|
+
return /^[a-z]/.test(trimmed) ? trimmed : `app_${trimmed}`;
|
|
19
|
+
}
|
|
20
|
+
/** DB role defaults to the same name as the database */
|
|
21
|
+
export function projectDbUser(projectName) {
|
|
22
|
+
return projectDbName(projectName);
|
|
23
|
+
}
|
|
24
|
+
export function findProjectSpec(dir) {
|
|
25
|
+
const absolute = resolve(dir);
|
|
26
|
+
const candidates = [join(absolute, "project-spec.json")];
|
|
27
|
+
// Also accept path that points directly at the file
|
|
28
|
+
if (basename(absolute) === "project-spec.json" && existsSync(absolute)) {
|
|
29
|
+
return absolute;
|
|
30
|
+
}
|
|
31
|
+
for (const path of candidates) {
|
|
32
|
+
if (existsSync(path))
|
|
33
|
+
return path;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
export function loadProject(dir = ".") {
|
|
38
|
+
const absolute = resolve(dir);
|
|
39
|
+
const specPath = findProjectSpec(absolute);
|
|
40
|
+
if (!specPath) {
|
|
41
|
+
throw new Error(`No project-spec.json found in ${absolute}. Unzip a Becky project first, or pass the project directory.`);
|
|
42
|
+
}
|
|
43
|
+
const raw = JSON.parse(readFileSync(specPath, "utf8"));
|
|
44
|
+
if (!raw.projectName?.trim() || !raw.stack) {
|
|
45
|
+
throw new Error(`Invalid project-spec.json at ${specPath}`);
|
|
46
|
+
}
|
|
47
|
+
const stack = raw.stack;
|
|
48
|
+
if (!STACK_ROOT[stack]) {
|
|
49
|
+
throw new Error(`Unknown stack in project-spec.json: ${raw.stack}`);
|
|
50
|
+
}
|
|
51
|
+
const root = dirname(specPath);
|
|
52
|
+
const databaseName = projectDbName(raw.projectName);
|
|
53
|
+
return {
|
|
54
|
+
root,
|
|
55
|
+
specPath,
|
|
56
|
+
spec: {
|
|
57
|
+
projectName: raw.projectName.trim(),
|
|
58
|
+
stack,
|
|
59
|
+
auth: raw.auth,
|
|
60
|
+
entities: raw.entities,
|
|
61
|
+
outputs: raw.outputs,
|
|
62
|
+
},
|
|
63
|
+
stackRoot: join(root, STACK_ROOT[stack]),
|
|
64
|
+
databaseName,
|
|
65
|
+
databaseUser: projectDbUser(raw.projectName),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type ExecResult = {
|
|
2
|
+
code: number;
|
|
3
|
+
stdout: string;
|
|
4
|
+
stderr: string;
|
|
5
|
+
};
|
|
6
|
+
type RunOptions = {
|
|
7
|
+
sudo?: boolean;
|
|
8
|
+
input?: string;
|
|
9
|
+
cwd?: string;
|
|
10
|
+
env?: NodeJS.ProcessEnv;
|
|
11
|
+
};
|
|
12
|
+
export declare function run(cmd: string, args?: string[], options?: RunOptions): Promise<ExecResult>;
|
|
13
|
+
export declare function commandExists(name: string): Promise<boolean>;
|
|
14
|
+
export declare function runOrThrow(cmd: string, args?: string[], options?: RunOptions, hint?: string): Promise<string>;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
export function run(cmd, args = [], options = {}) {
|
|
3
|
+
return new Promise((resolve, reject) => {
|
|
4
|
+
const executable = options.sudo ? "sudo" : cmd;
|
|
5
|
+
const executableArgs = options.sudo ? [cmd, ...args] : args;
|
|
6
|
+
const child = spawn(executable, executableArgs, {
|
|
7
|
+
cwd: options.cwd,
|
|
8
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
9
|
+
env: options.env ?? process.env,
|
|
10
|
+
});
|
|
11
|
+
let stdout = "";
|
|
12
|
+
let stderr = "";
|
|
13
|
+
child.stdout.on("data", (chunk) => {
|
|
14
|
+
stdout += chunk.toString();
|
|
15
|
+
});
|
|
16
|
+
child.stderr.on("data", (chunk) => {
|
|
17
|
+
stderr += chunk.toString();
|
|
18
|
+
});
|
|
19
|
+
child.on("error", reject);
|
|
20
|
+
child.on("close", (code) => {
|
|
21
|
+
resolve({ code: code ?? 1, stdout, stderr });
|
|
22
|
+
});
|
|
23
|
+
if (options.input) {
|
|
24
|
+
child.stdin.write(options.input);
|
|
25
|
+
}
|
|
26
|
+
child.stdin.end();
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export async function commandExists(name) {
|
|
30
|
+
const result = await run("sh", ["-c", `command -v ${name} >/dev/null 2>&1; echo $?`]);
|
|
31
|
+
return result.stdout.trim() === "0";
|
|
32
|
+
}
|
|
33
|
+
export async function runOrThrow(cmd, args = [], options = {}, hint) {
|
|
34
|
+
const result = await run(cmd, args, options);
|
|
35
|
+
if (result.code !== 0) {
|
|
36
|
+
const detail = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n");
|
|
37
|
+
const msg = hint
|
|
38
|
+
? detail
|
|
39
|
+
? `${hint}\n${detail}`
|
|
40
|
+
: hint
|
|
41
|
+
: `Command failed: ${cmd} ${args.join(" ")}\n${detail}`;
|
|
42
|
+
throw new Error(msg);
|
|
43
|
+
}
|
|
44
|
+
return result.stdout.trim();
|
|
45
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function info(message: string): void;
|
|
2
|
+
export declare function success(message: string): void;
|
|
3
|
+
export declare function warn(message: string): void;
|
|
4
|
+
export declare function error(message: string): void;
|
|
5
|
+
export declare function step(title: string): void;
|
|
6
|
+
export declare function detail(message: string): void;
|
package/dist/util/log.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const colors = {
|
|
2
|
+
reset: "\x1b[0m",
|
|
3
|
+
dim: "\x1b[2m",
|
|
4
|
+
green: "\x1b[32m",
|
|
5
|
+
yellow: "\x1b[33m",
|
|
6
|
+
red: "\x1b[31m",
|
|
7
|
+
cyan: "\x1b[36m",
|
|
8
|
+
bold: "\x1b[1m",
|
|
9
|
+
};
|
|
10
|
+
export function info(message) {
|
|
11
|
+
console.log(`${colors.cyan}→${colors.reset} ${message}`);
|
|
12
|
+
}
|
|
13
|
+
export function success(message) {
|
|
14
|
+
console.log(`${colors.green}✓${colors.reset} ${message}`);
|
|
15
|
+
}
|
|
16
|
+
export function warn(message) {
|
|
17
|
+
console.log(`${colors.yellow}!${colors.reset} ${message}`);
|
|
18
|
+
}
|
|
19
|
+
export function error(message) {
|
|
20
|
+
console.error(`${colors.red}✗${colors.reset} ${message}`);
|
|
21
|
+
}
|
|
22
|
+
export function step(title) {
|
|
23
|
+
console.log(`\n${colors.bold}${title}${colors.reset}`);
|
|
24
|
+
}
|
|
25
|
+
export function detail(message) {
|
|
26
|
+
console.log(`${colors.dim} ${message}${colors.reset}`);
|
|
27
|
+
}
|