4a5edeef3e
CI / Unit Tests (pull_request) Successful in 5m46s
CI / Lint (pull_request) Failing after 3m49s
CI / E2E Tests (pull_request) Has been skipped
CI / Fresh-Linux Docker Deploy (pull_request) Has been skipped
CI / Assistant Split Regression (pull_request) Failing after 35s
CI / Architecture Guardrails (pull_request) Failing after 2m14s
CI / Typecheck (pull_request) Successful in 4m22s
CI / Build (pull_request) Has been skipped
CI / Release Images (pull_request) Has been skipped
- @capakraken/* → @nexus/* across 12 packages (root + 11 workspaces),
1551 import lines migrated via codemod
- User-visible brand strings renamed (emails, page titles, PWA
manifest, mobile header, MFA backup-codes header, tooltips, signin
page, invite page, weekly digest, install prompt)
- TOTP issuer "CapaKraken" → "Nexus" (existing secrets still valid;
re-enrollment relabels them in users' authenticator apps)
- Function rename: assertCapaKrakenDbTarget → assertNexusDbTarget
- LocalStorage migration shim in apps/web/src/app/layout.tsx copies
capakraken_* → nexus_* on first load (guarded by nexus_migrated_v1
sentinel; runs once per browser, then never again)
- Service-worker cache name capakraken-v2 → nexus-v2 with one-time
caches.delete('capakraken-v2') from the same shim
- Email-domain fixtures @capakraken.{dev,app} → @nexus.{dev,app} in
seed data, e2e specs, SMTP default fallback
- Dockerfile.dev / Dockerfile.prod / all .github/workflows/*.yml
pnpm --filter @capakraken/* → @nexus/*
- README, CLAUDE.md, LEARNINGS.md, all docs/*.md, .env.example,
tooling/deploy/.env.production.example brand sweep
Phase 1 deliberately leaves untouched (handled in Phase 3 cutover):
- PostgreSQL DB name "capakraken" and POSTGRES_USER "capakraken"
- Volume names capakraken_pgdata etc.
- Compose project name "capakraken" / "capakraken-prod"
- db-target-guard default expectedDatabase
- env-var CAPAKRAKEN_EXPECTED_DB_NAME
- Container DNS names in docker-compose.ci.yml
Quality gates green: pnpm typecheck (7/7), pnpm test:unit (7/7),
pnpm lint (0 errors), check:exports/imports/architecture all pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
214 lines
5.6 KiB
TypeScript
214 lines
5.6 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import { mkdirSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import { hash } from "@node-rs/argon2";
|
|
import { SystemRole } from "@nexus/shared";
|
|
import { PrismaClient } from "@prisma/client";
|
|
import { assertDestructiveDbAllowed } from "./destructive-db-guard.js";
|
|
import { loadWorkspaceEnv, resolveWorkspacePath } from "./load-workspace-env.js";
|
|
import { buildSystemRoleConfigSeedData } from "./system-role-config-defaults.js";
|
|
|
|
loadWorkspaceEnv();
|
|
|
|
const prisma = new PrismaClient();
|
|
const DEFAULT_BACKUP_DIR = resolveWorkspacePath("packages", "db", "backups");
|
|
|
|
interface ResetOptions {
|
|
force: boolean;
|
|
skipBackup: boolean;
|
|
backupDir: string;
|
|
adminEmail: string;
|
|
adminPassword: string;
|
|
adminName: string;
|
|
}
|
|
|
|
function parseArgs(argv: string[]): ResetOptions {
|
|
const options: ResetOptions = {
|
|
force: false,
|
|
skipBackup: false,
|
|
backupDir: DEFAULT_BACKUP_DIR,
|
|
adminEmail: "admin@nexus.dev",
|
|
adminPassword: "admin123",
|
|
adminName: "Nexus Admin",
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const argument = argv[index];
|
|
|
|
if (argument === "--force") {
|
|
options.force = true;
|
|
continue;
|
|
}
|
|
|
|
if (argument === "--skip-backup") {
|
|
options.skipBackup = true;
|
|
continue;
|
|
}
|
|
|
|
if (argument === "--backup-dir") {
|
|
options.backupDir = resolve(argv[index + 1] ?? DEFAULT_BACKUP_DIR);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (argument === "--admin-email") {
|
|
options.adminEmail = argv[index + 1] ?? options.adminEmail;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (argument === "--admin-password") {
|
|
options.adminPassword = argv[index + 1] ?? options.adminPassword;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (argument === "--admin-name") {
|
|
options.adminName = argv[index + 1] ?? options.adminName;
|
|
index += 1;
|
|
}
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
function createTimestamp() {
|
|
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
}
|
|
|
|
function quoteIdentifier(identifier: string): string {
|
|
return `"${identifier.replace(/"/g, '""')}"`;
|
|
}
|
|
|
|
function createDatabaseBackup(databaseUrl: string, backupDir: string): string {
|
|
mkdirSync(backupDir, { recursive: true });
|
|
|
|
const backupPath = resolve(backupDir, `dispo-reset-${createTimestamp()}.dump`);
|
|
execFileSync("pg_dump", ["--format=custom", "--file", backupPath, databaseUrl], {
|
|
stdio: "inherit",
|
|
env: process.env,
|
|
});
|
|
|
|
return backupPath;
|
|
}
|
|
|
|
async function listPublicTables() {
|
|
return prisma.$queryRaw<Array<{ tablename: string }>>`
|
|
SELECT tablename
|
|
FROM pg_tables
|
|
WHERE schemaname = 'public'
|
|
AND tablename <> '_prisma_migrations'
|
|
ORDER BY tablename ASC
|
|
`;
|
|
}
|
|
|
|
async function truncatePublicTables() {
|
|
const tables = await listPublicTables();
|
|
if (tables.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const quotedTables = tables.map((table) => quoteIdentifier(table.tablename)).join(", ");
|
|
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${quotedTables} RESTART IDENTITY CASCADE`);
|
|
return tables.map((table) => table.tablename);
|
|
}
|
|
|
|
async function bootstrapPlatform(adminEmail: string, adminPassword: string, adminName: string) {
|
|
const passwordHash = await hash(adminPassword);
|
|
|
|
const admin = await prisma.user.create({
|
|
data: {
|
|
email: adminEmail,
|
|
name: adminName,
|
|
passwordHash,
|
|
systemRole: SystemRole.ADMIN,
|
|
},
|
|
});
|
|
|
|
await prisma.systemSettings.upsert({
|
|
where: { id: "singleton" },
|
|
update: {
|
|
vacationDefaultDays: 28,
|
|
},
|
|
create: {
|
|
id: "singleton",
|
|
vacationDefaultDays: 28,
|
|
},
|
|
});
|
|
|
|
for (const config of buildSystemRoleConfigSeedData()) {
|
|
await prisma.systemRoleConfig.upsert({
|
|
where: { role: config.role },
|
|
update: {
|
|
label: config.label,
|
|
description: config.description,
|
|
defaultPermissions: config.defaultPermissions,
|
|
color: config.color,
|
|
sortOrder: config.sortOrder,
|
|
},
|
|
create: config,
|
|
});
|
|
}
|
|
|
|
return admin;
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
const target = assertDestructiveDbAllowed({
|
|
commandName: "db:reset:dispo",
|
|
allowedDatabaseNames: ["capakraken_test", "capakraken_e2e", "capakraken_ci"],
|
|
});
|
|
const databaseUrl = process.env.DATABASE_URL;
|
|
|
|
if (!options.force) {
|
|
throw new Error("Refusing to reset the database without --force.");
|
|
}
|
|
|
|
if (!databaseUrl) {
|
|
throw new Error("DATABASE_URL is not configured.");
|
|
}
|
|
|
|
console.warn(`Resetting disposable database '${target.databaseName}'.`);
|
|
|
|
let backupPath: string | null = null;
|
|
|
|
if (options.skipBackup) {
|
|
console.warn("Skipping pg_dump backup because --skip-backup was provided.");
|
|
} else {
|
|
try {
|
|
backupPath = createDatabaseBackup(databaseUrl, options.backupDir);
|
|
console.log(`Backup created at ${backupPath}`);
|
|
} catch (error) {
|
|
throw new Error(
|
|
`Backup failed. Install pg_dump or rerun with --skip-backup if this is an intentional disposable environment.\n${String(error)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const truncatedTables = await truncatePublicTables();
|
|
console.log(`Truncated ${truncatedTables.length} public tables.`);
|
|
|
|
const admin = await bootstrapPlatform(
|
|
options.adminEmail,
|
|
options.adminPassword,
|
|
options.adminName,
|
|
);
|
|
console.log(`Bootstrap admin created: ${admin.email}`);
|
|
|
|
if (backupPath) {
|
|
console.log(`Database backup: ${backupPath}`);
|
|
}
|
|
|
|
console.log("Dispo import reset/bootstrap complete.");
|
|
}
|
|
|
|
main()
|
|
.catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
})
|
|
.finally(async () => {
|
|
await prisma.$disconnect();
|
|
});
|