|
| 1 | +import { createHash } from "node:crypto"; |
| 2 | +import { lstat, readFile, realpath } from "node:fs/promises"; |
| 3 | +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; |
| 4 | + |
| 5 | +const grammars = { |
| 6 | + postgres: { package: "@typed-sql/postgres", factory: "postgres" }, |
| 7 | + mysql: { package: "@typed-sql/mysql", factory: "mysql" }, |
| 8 | + sqlite: { package: "@typed-sql/sqlite", factory: "sqlite" }, |
| 9 | +} as const; |
| 10 | +const managers = ["npm", "pnpm", "yarn", "bun"] as const; |
| 11 | +type Manager = (typeof managers)[number]; |
| 12 | +type JsonObject = Record<string, unknown>; |
| 13 | + |
| 14 | +export interface InitFile { |
| 15 | + readonly path: string; |
| 16 | + readonly beforeHash: string | null; |
| 17 | + readonly content: string; |
| 18 | +} |
| 19 | + |
| 20 | +export interface InitPlan { |
| 21 | + readonly formatVersion: 1; |
| 22 | + readonly mode: "plan"; |
| 23 | + readonly root: string; |
| 24 | + readonly packageManager: Manager; |
| 25 | + readonly grammar: keyof typeof grammars; |
| 26 | + readonly editor: "none" | "vscode" | "zed"; |
| 27 | + readonly files: readonly InitFile[]; |
| 28 | + readonly dependencies: Readonly<Record<string, string>>; |
| 29 | + readonly scripts: Readonly<Record<string, string>>; |
| 30 | + readonly detected: readonly string[]; |
| 31 | + readonly pending: readonly string[]; |
| 32 | + readonly warnings: readonly string[]; |
| 33 | +} |
| 34 | + |
| 35 | +function object(value: unknown, label: string): JsonObject { |
| 36 | + if (value === null || typeof value !== "object" || Array.isArray(value)) |
| 37 | + throw new Error(`${label} must be an object`); |
| 38 | + return value as JsonObject; |
| 39 | +} |
| 40 | + |
| 41 | +async function read(path: string): Promise<string | undefined> { |
| 42 | + try { |
| 43 | + const stat = await lstat(path); |
| 44 | + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Expected a regular non-symlink file: ${path}`); |
| 45 | + return await readFile(path, "utf8"); |
| 46 | + } catch (error) { |
| 47 | + if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined; |
| 48 | + throw error; |
| 49 | + } |
| 50 | +} |
| 51 | + |
| 52 | +function manager(value: string): Manager { |
| 53 | + if (!managers.includes(value as Manager)) throw new Error(`Unsupported package manager: ${value}`); |
| 54 | + return value as Manager; |
| 55 | +} |
| 56 | + |
| 57 | +export async function planInit(options: Readonly<Record<string, string>>, version: string): Promise<InitPlan> { |
| 58 | + const allowed = new Set(["cwd", "package", "grammar", "editor", "package-manager", "dry-run", "json"]); |
| 59 | + for (const [key, value] of Object.entries(options)) { |
| 60 | + if (!allowed.has(key)) throw new Error(`Unknown init option --${key}`); |
| 61 | + if ((key === "dry-run" || key === "json") && value !== "true") throw new Error(`--${key} does not accept a value`); |
| 62 | + } |
| 63 | + const grammar = options.grammar as keyof typeof grammars; |
| 64 | + if (!Object.hasOwn(grammars, grammar ?? "")) throw new Error("init requires --grammar postgres|mysql|sqlite"); |
| 65 | + const editor = options.editor ?? "none"; |
| 66 | + if (editor !== "none" && editor !== "vscode" && editor !== "zed") |
| 67 | + throw new Error("--editor must be none, vscode, or zed"); |
| 68 | + const base = await realpath(resolve(options.cwd ?? process.cwd())); |
| 69 | + let root = base; |
| 70 | + if (options.package !== undefined) { |
| 71 | + const target = resolve(base, options.package); |
| 72 | + const path = relative(base, target); |
| 73 | + if (isAbsolute(options.package) || path.startsWith("..") || isAbsolute(path)) |
| 74 | + throw new Error("--package must select a directory inside --cwd"); |
| 75 | + let current = base; |
| 76 | + for (const part of path.split(/[\\/]/u).filter(Boolean)) { |
| 77 | + current = join(current, part); |
| 78 | + const stat = await lstat(current); |
| 79 | + if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("--package cannot traverse symlinks or files"); |
| 80 | + } |
| 81 | + root = target; |
| 82 | + } |
| 83 | + const manifestText = await read(join(root, "package.json")); |
| 84 | + const manifest = |
| 85 | + manifestText === undefined ? { private: true, type: "module" } : object(JSON.parse(manifestText), "package.json"); |
| 86 | + if ( |
| 87 | + (manifest.workspaces !== undefined || (await read(join(root, "pnpm-workspace.yaml"))) !== undefined) && |
| 88 | + options.package === undefined |
| 89 | + ) |
| 90 | + throw new Error("Workspace root is ambiguous; select an application with --package"); |
| 91 | + const detected: string[] = []; |
| 92 | + const candidates = new Set<Manager>(); |
| 93 | + for (let directory = root; ; directory = dirname(directory)) { |
| 94 | + const parentText = directory === root ? manifestText : await read(join(directory, "package.json")); |
| 95 | + const parent = parentText === undefined ? {} : object(JSON.parse(parentText), "package.json"); |
| 96 | + if (parent.packageManager !== undefined) { |
| 97 | + if (typeof parent.packageManager !== "string" || !/^(npm|pnpm|yarn|bun)@[^\s]+$/u.test(parent.packageManager)) |
| 98 | + throw new Error(`Invalid packageManager in ${directory}`); |
| 99 | + candidates.add(manager(parent.packageManager.split("@")[0]!)); |
| 100 | + } |
| 101 | + for (const [file, name] of [ |
| 102 | + ["package-lock.json", "npm"], |
| 103 | + ["npm-shrinkwrap.json", "npm"], |
| 104 | + ["pnpm-lock.yaml", "pnpm"], |
| 105 | + ["yarn.lock", "yarn"], |
| 106 | + ["bun.lock", "bun"], |
| 107 | + ["bun.lockb", "bun"], |
| 108 | + ] as const) { |
| 109 | + if ((await read(join(directory, file))) !== undefined) { |
| 110 | + candidates.add(name); |
| 111 | + detected.push(join(directory, file)); |
| 112 | + } |
| 113 | + } |
| 114 | + if ( |
| 115 | + parent.workspaces !== undefined || |
| 116 | + (await read(join(directory, "pnpm-workspace.yaml"))) !== undefined || |
| 117 | + dirname(directory) === directory |
| 118 | + ) |
| 119 | + break; |
| 120 | + } |
| 121 | + if (options["package-manager"] !== undefined) candidates.add(manager(options["package-manager"])); |
| 122 | + if (candidates.size > 1) throw new Error(`Conflicting package managers: ${[...candidates].sort().join(", ")}`); |
| 123 | + const packageManager = [...candidates][0] ?? "npm"; |
| 124 | + const dependencies: Record<string, string> = {}; |
| 125 | + const existing = { |
| 126 | + ...object(manifest.devDependencies ?? {}, "devDependencies"), |
| 127 | + ...object(manifest.dependencies ?? {}, "dependencies"), |
| 128 | + }; |
| 129 | + for (const [name, range] of [ |
| 130 | + ["@typed-sql/cli", `^${version}`], |
| 131 | + ["@typed-sql/core", "^2.1.0"], |
| 132 | + [grammars[grammar].package, "^2.1.0"], |
| 133 | + ["typescript", "~7.0.2"], |
| 134 | + ] as const) { |
| 135 | + const value = existing[name]; |
| 136 | + if (value !== undefined && typeof value !== "string") throw new Error(`Invalid dependency ${name}`); |
| 137 | + if (value === undefined) dependencies[name] = range; |
| 138 | + } |
| 139 | + const files: InitFile[] = []; |
| 140 | + const add = (path: string, content: string, before?: string) => |
| 141 | + files.push({ |
| 142 | + path: join(root, path), |
| 143 | + beforeHash: before === undefined ? null : createHash("sha256").update(before).digest("hex"), |
| 144 | + content, |
| 145 | + }); |
| 146 | + const warnings: string[] = []; |
| 147 | + if (Object.keys(existing).length > 0) |
| 148 | + warnings.push( |
| 149 | + "Existing dependency ranges are preserved; compatibility and registry availability have not been checked.", |
| 150 | + ); |
| 151 | + const scripts: Record<string, string> = {}; |
| 152 | + const existingScripts = object(manifest.scripts ?? {}, "scripts"); |
| 153 | + for (const [name, command] of [ |
| 154 | + ["typed-sql:generate", "typed-sql generate"], |
| 155 | + ["typed-sql:check", "typed-sql check"], |
| 156 | + ] as const) { |
| 157 | + if (existingScripts[name] === undefined) scripts[name] = command; |
| 158 | + else if (existingScripts[name] !== command) |
| 159 | + warnings.push(`Preserve existing script ${name}; proposed command is ${command}.`); |
| 160 | + } |
| 161 | + const pending = [ |
| 162 | + "Supply or introspect a real schema snapshot, then run typed-sql generate and doctor. No database access is authorized by this plan.", |
| 163 | + ]; |
| 164 | + if (Object.keys(dependencies).length > 0) |
| 165 | + pending.push( |
| 166 | + `Install the listed dependencies with ${packageManager}; installation and lifecycle scripts require separate approval.`, |
| 167 | + ); |
| 168 | + if (manifestText === undefined) add("package.json", `${JSON.stringify(manifest, null, 2)}\n`); |
| 169 | + else detected.push(join(root, "package.json")); |
| 170 | + const configs = [ |
| 171 | + "typed-sql.config.ts", |
| 172 | + "typed-sql.config.mts", |
| 173 | + "typed-sql.config.cts", |
| 174 | + "typed-sql.config.js", |
| 175 | + "typed-sql.config.mjs", |
| 176 | + "typed-sql.config.cjs", |
| 177 | + ]; |
| 178 | + const found: string[] = []; |
| 179 | + for (const file of configs) if ((await read(join(root, file))) !== undefined) found.push(file); |
| 180 | + if (found.length > 1) throw new Error(`Multiple typed-sql configurations: ${found.join(", ")}`); |
| 181 | + if (found.length === 0) |
| 182 | + add( |
| 183 | + "typed-sql.config.ts", |
| 184 | + `import { ${grammars[grammar].factory} } from ${JSON.stringify(grammars[grammar].package)};\n\nexport default {\n dialect: ${grammars[grammar].factory}(),\n schema: { file: "schema.json" },\n outDir: "generated",\n projects: ["tsconfig.json"],\n};\n`, |
| 185 | + ); |
| 186 | + else { |
| 187 | + detected.push(join(root, found[0]!)); |
| 188 | + warnings.push( |
| 189 | + "Existing typed-sql configuration is preserved and was not executed; its grammar and paths are unverified.", |
| 190 | + ); |
| 191 | + } |
| 192 | + for (const file of ["tsconfig.json", "schema.json"]) { |
| 193 | + if ((await read(join(root, file))) !== undefined) detected.push(join(root, file)); |
| 194 | + else if (file === "tsconfig.json") |
| 195 | + add( |
| 196 | + file, |
| 197 | + `${JSON.stringify({ compilerOptions: { strict: true, noEmit: true, target: "ES2024", module: "NodeNext", jsx: "preserve", skipLibCheck: true }, include: ["src/**/*.ts", "src/**/*.tsx"] }, null, 2)}\n`, |
| 198 | + ); |
| 199 | + } |
| 200 | + if (editor !== "none") |
| 201 | + pending.push( |
| 202 | + `${editor}: choose a verified TypeScript provider mode and explicitly approve experimental language-server installation. No editor settings or extensions are changed.`, |
| 203 | + ); |
| 204 | + return { |
| 205 | + formatVersion: 1, |
| 206 | + mode: "plan", |
| 207 | + root, |
| 208 | + packageManager, |
| 209 | + grammar, |
| 210 | + editor, |
| 211 | + files, |
| 212 | + dependencies, |
| 213 | + scripts, |
| 214 | + detected: detected.sort(), |
| 215 | + pending, |
| 216 | + warnings, |
| 217 | + }; |
| 218 | +} |
0 commit comments