331 lines
9.3 KiB
JavaScript
Executable File
331 lines
9.3 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
require("dotenv").config();
|
|
|
|
const path = require("path");
|
|
const { execSync } = require("child_process");
|
|
const { PrismaClient } = require("../src/generated/client");
|
|
|
|
const BOOTSTRAP_USER_ID = "bootstrap-admin";
|
|
const DEFAULT_SYSTEM_CONFIG_ID = "default";
|
|
const backendRoot = path.resolve(__dirname, "..");
|
|
|
|
const resolveDatabaseUrl = (rawUrl) => {
|
|
const backendRoot = path.resolve(__dirname, "..");
|
|
const defaultDbPath = path.resolve(backendRoot, "prisma/dev.db");
|
|
|
|
if (!rawUrl || String(rawUrl).trim().length === 0) {
|
|
return `file:${defaultDbPath}`;
|
|
}
|
|
|
|
if (!String(rawUrl).startsWith("file:")) {
|
|
return String(rawUrl);
|
|
}
|
|
|
|
const filePath = String(rawUrl).replace(/^file:/, "");
|
|
const prismaDir = path.resolve(backendRoot, "prisma");
|
|
const normalizedRelative = filePath.replace(/^\.\/?/, "");
|
|
const hasLeadingPrismaDir =
|
|
normalizedRelative === "prisma" || normalizedRelative.startsWith("prisma/");
|
|
|
|
const absolutePath = path.isAbsolute(filePath)
|
|
? filePath
|
|
: path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative);
|
|
|
|
return `file:${absolutePath}`;
|
|
};
|
|
|
|
process.env.DATABASE_URL = resolveDatabaseUrl(process.env.DATABASE_URL);
|
|
|
|
const parseArgs = (argv) => {
|
|
const parsed = {
|
|
scenario: "",
|
|
dryRun: false,
|
|
allowProd: false,
|
|
};
|
|
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const token = argv[i];
|
|
if (token === "--scenario") {
|
|
parsed.scenario = String(argv[i + 1] || "").trim().toLowerCase();
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (token === "--dry-run") {
|
|
parsed.dryRun = true;
|
|
continue;
|
|
}
|
|
if (token === "--allow-production") {
|
|
parsed.allowProd = true;
|
|
continue;
|
|
}
|
|
if (token === "--help" || token === "-h") {
|
|
parsed.help = true;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return parsed;
|
|
};
|
|
|
|
const usage = () => {
|
|
console.log(`Usage:
|
|
node scripts/simulate-auth-onboarding.cjs --scenario fresh
|
|
node scripts/simulate-auth-onboarding.cjs --scenario migration
|
|
|
|
Options:
|
|
--dry-run Show what would change without modifying data
|
|
--allow-production Override production safety check (not recommended)
|
|
--help, -h Show this help
|
|
`);
|
|
};
|
|
|
|
const assertScenario = (scenario) => {
|
|
if (scenario !== "fresh" && scenario !== "migration") {
|
|
throw new Error("Invalid --scenario. Use 'fresh' or 'migration'.");
|
|
}
|
|
};
|
|
|
|
const nowIso = () => new Date().toISOString();
|
|
|
|
const run = async () => {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
if (args.help) {
|
|
usage();
|
|
return;
|
|
}
|
|
|
|
assertScenario(args.scenario);
|
|
|
|
const nodeEnv = process.env.NODE_ENV || "development";
|
|
if (nodeEnv === "production" && !args.allowProd) {
|
|
throw new Error(
|
|
"Refusing to run in production. Pass --allow-production only if you explicitly intend this."
|
|
);
|
|
}
|
|
|
|
// Keep migration history authoritative to avoid drift between db push and deploy.
|
|
// Includes a self-heal path for the known duplicate-column failure on
|
|
// 20260210153000_add_auth_onboarding_completed in local dev databases.
|
|
if (nodeEnv !== "production") {
|
|
const runDeploy = () =>
|
|
execSync("npx prisma migrate deploy", {
|
|
cwd: backendRoot,
|
|
stdio: "pipe",
|
|
env: {
|
|
...process.env,
|
|
DATABASE_URL: process.env.DATABASE_URL,
|
|
},
|
|
});
|
|
|
|
try {
|
|
runDeploy();
|
|
} catch (error) {
|
|
const stdout =
|
|
error && error.stdout
|
|
? Buffer.isBuffer(error.stdout)
|
|
? error.stdout.toString("utf8")
|
|
: String(error.stdout)
|
|
: "";
|
|
const stderr =
|
|
error && error.stderr
|
|
? Buffer.isBuffer(error.stderr)
|
|
? error.stderr.toString("utf8")
|
|
: String(error.stderr)
|
|
: "";
|
|
const combined = `${stdout}\n${stderr}`;
|
|
|
|
const canAutoResolve =
|
|
combined.includes("Error: P3009") &&
|
|
combined.includes("20260210153000_add_auth_onboarding_completed") &&
|
|
combined.includes("duplicate column name: authOnboardingCompleted");
|
|
|
|
if (!canAutoResolve) {
|
|
throw error;
|
|
}
|
|
|
|
execSync(
|
|
"npx prisma migrate resolve --applied 20260210153000_add_auth_onboarding_completed",
|
|
{
|
|
cwd: backendRoot,
|
|
stdio: "pipe",
|
|
env: {
|
|
...process.env,
|
|
DATABASE_URL: process.env.DATABASE_URL,
|
|
},
|
|
}
|
|
);
|
|
runDeploy();
|
|
}
|
|
}
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
try {
|
|
const before = {
|
|
activeUsers: await prisma.user.count({ where: { isActive: true } }),
|
|
users: await prisma.user.count(),
|
|
drawings: await prisma.drawing.count(),
|
|
collections: await prisma.collection.count(),
|
|
auth: await prisma.systemConfig.findUnique({
|
|
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
|
select: {
|
|
authEnabled: true,
|
|
authOnboardingCompleted: true,
|
|
registrationEnabled: true,
|
|
},
|
|
}),
|
|
};
|
|
|
|
console.log(`[simulate-auth-onboarding] DATABASE_URL=${process.env.DATABASE_URL}`);
|
|
console.log(`[simulate-auth-onboarding] NODE_ENV=${nodeEnv}`);
|
|
console.log(`[simulate-auth-onboarding] scenario=${args.scenario}`);
|
|
console.log("[simulate-auth-onboarding] before:", before);
|
|
|
|
if (args.dryRun) {
|
|
console.log("[simulate-auth-onboarding] dry-run only. No data changed.");
|
|
return;
|
|
}
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
await tx.systemConfig.upsert({
|
|
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
|
update: {
|
|
authEnabled: false,
|
|
authOnboardingCompleted: false,
|
|
registrationEnabled: false,
|
|
},
|
|
create: {
|
|
id: DEFAULT_SYSTEM_CONFIG_ID,
|
|
authEnabled: false,
|
|
authOnboardingCompleted: false,
|
|
registrationEnabled: false,
|
|
authLoginRateLimitEnabled: true,
|
|
authLoginRateLimitWindowMs: 15 * 60 * 1000,
|
|
authLoginRateLimitMax: 20,
|
|
},
|
|
});
|
|
|
|
await tx.user.updateMany({
|
|
data: {
|
|
isActive: false,
|
|
mustResetPassword: true,
|
|
},
|
|
});
|
|
|
|
await tx.user.upsert({
|
|
where: { id: BOOTSTRAP_USER_ID },
|
|
update: {
|
|
email: "bootstrap@excalidash.local",
|
|
username: null,
|
|
passwordHash: "",
|
|
name: "Bootstrap Admin",
|
|
role: "ADMIN",
|
|
mustResetPassword: true,
|
|
isActive: false,
|
|
},
|
|
create: {
|
|
id: BOOTSTRAP_USER_ID,
|
|
email: "bootstrap@excalidash.local",
|
|
username: null,
|
|
passwordHash: "",
|
|
name: "Bootstrap Admin",
|
|
role: "ADMIN",
|
|
mustResetPassword: true,
|
|
isActive: false,
|
|
},
|
|
});
|
|
|
|
if (args.scenario === "fresh") {
|
|
await tx.drawing.deleteMany({});
|
|
await tx.collection.deleteMany({});
|
|
await tx.library.deleteMany({});
|
|
await tx.user.deleteMany({
|
|
where: {
|
|
id: {
|
|
not: BOOTSTRAP_USER_ID,
|
|
},
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Migration simulation:
|
|
// 1) Reassign existing data ownership to bootstrap user
|
|
// 2) Ensure at least one drawing+collection exists so UI shows migration messaging
|
|
await tx.collection.updateMany({
|
|
data: { userId: BOOTSTRAP_USER_ID },
|
|
});
|
|
await tx.drawing.updateMany({
|
|
data: { userId: BOOTSTRAP_USER_ID },
|
|
});
|
|
|
|
const collectionCount = await tx.collection.count();
|
|
let targetCollectionId = null;
|
|
|
|
if (collectionCount === 0) {
|
|
targetCollectionId = `sim-migration-col-${Date.now()}`;
|
|
await tx.collection.create({
|
|
data: {
|
|
id: targetCollectionId,
|
|
name: "Migrated Collection",
|
|
userId: BOOTSTRAP_USER_ID,
|
|
},
|
|
});
|
|
} else {
|
|
const existing = await tx.collection.findFirst({
|
|
where: { userId: BOOTSTRAP_USER_ID },
|
|
select: { id: true },
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
targetCollectionId = existing ? existing.id : null;
|
|
}
|
|
|
|
const drawingCount = await tx.drawing.count();
|
|
if (drawingCount === 0) {
|
|
await tx.drawing.create({
|
|
data: {
|
|
id: `sim-migration-draw-${Date.now()}`,
|
|
name: "Migrated Drawing",
|
|
elements: "[]",
|
|
appState: "{}",
|
|
files: "{}",
|
|
preview: null,
|
|
version: 1,
|
|
userId: BOOTSTRAP_USER_ID,
|
|
collectionId: targetCollectionId,
|
|
},
|
|
});
|
|
}
|
|
});
|
|
|
|
const after = {
|
|
activeUsers: await prisma.user.count({ where: { isActive: true } }),
|
|
users: await prisma.user.count(),
|
|
drawings: await prisma.drawing.count(),
|
|
collections: await prisma.collection.count(),
|
|
auth: await prisma.systemConfig.findUnique({
|
|
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
|
select: {
|
|
authEnabled: true,
|
|
authOnboardingCompleted: true,
|
|
registrationEnabled: true,
|
|
},
|
|
}),
|
|
};
|
|
|
|
console.log("[simulate-auth-onboarding] after:", after);
|
|
console.log(`[simulate-auth-onboarding] completed at ${nowIso()}`);
|
|
console.log(
|
|
"[simulate-auth-onboarding] If your backend is already running, wait ~5 seconds (auth cache TTL) or restart before refreshing the UI."
|
|
);
|
|
} finally {
|
|
await prisma.$disconnect().catch(() => {});
|
|
}
|
|
};
|
|
|
|
run().catch((error) => {
|
|
console.error("simulate-auth-onboarding failed:", error.message || error);
|
|
process.exitCode = 1;
|
|
});
|