Skip to content

Commit bfc84f4

Browse files
committed
feat(cli): plan project initialization without executing config
1 parent 141ad85 commit bfc84f4

5 files changed

Lines changed: 345 additions & 0 deletions

File tree

‎.changeset/tidy-init-plan.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@typed-sql/cli": minor
3+
---
4+
5+
Add a nonexecuting init planning command with explicit grammar/package selection, package-manager conflict detection, deterministic file proposals, and separately identified installation and editor prerequisites.

‎docs/getting-started/installation.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,25 @@ pageType: how-to
99
Install one grammar for the database used by the application. Add the stable CLI as a development
1010
dependency, then select a database driver explicitly—grammar packages never install or own one.
1111

12+
## Inspect a setup plan
13+
14+
`typed-sql init` prints a JSON setup plan without writing project files, importing configuration,
15+
installing dependencies, or connecting to a database:
16+
17+
```sh
18+
pnpm exec typed-sql init --grammar postgres --dry-run
19+
pnpm exec typed-sql init --cwd . --package apps/api --grammar mysql --editor vscode --json
20+
```
21+
22+
Choose `postgres`, `mysql`, or `sqlite` explicitly. Workspace roots require `--package` to select
23+
an application. The planner detects package-manager declarations and lockfiles and refuses
24+
conflicting choices. Existing configuration and dependency ranges are preserved, not certified
25+
compatible. Proposed files, missing dependencies, scripts, and pending actions are separate fields.
26+
27+
An editor selection records prerequisites; it does not install an extension or claim the editor
28+
is ready. Supply a real schema snapshot before generation. `init` does not fabricate database
29+
metadata, execute lifecycle scripts, or grant workspace trust.
30+
1231
## Prerequisites
1332

1433
typed-sql requires Node.js 22.11 or newer. The compiler uses an exact supported TypeScript version;

‎packages/cli/src/cli.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,15 @@ import {
4343
parseSchemaSnapshot,
4444
writeArtifactFiles,
4545
} from "@typed-sql/schema";
46+
import { planInit } from "./init-plan.js";
4647

4748
interface ParsedArguments {
4849
readonly command?: string;
4950
readonly options: Readonly<Record<string, string>>;
5051
}
5152

5253
const commands = new Set([
54+
"init",
5355
"capabilities",
5456
"check",
5557
"compat",
@@ -86,6 +88,7 @@ Usage:
8688
typed-sql <command> [options]
8789
8890
Commands:
91+
init Inspect a project and print a nonexecuting setup plan
8992
capabilities Report versioned grammar support from the generated snapshot
9093
check Infer SQL result types and verify them with TypeScript 7
9194
compat Analyze rolling-deployment compatibility from two snapshots and manifests
@@ -125,6 +128,7 @@ function parseArguments(args: readonly string[]): ParsedArguments {
125128
if (
126129
argument === "--live" ||
127130
argument === "--json" ||
131+
argument === "--dry-run" ||
128132
argument === "--support-bundle-preview" ||
129133
argument === "--confirm-support-bundle"
130134
) {
@@ -356,6 +360,10 @@ async function main(): Promise<void> {
356360
if (parsed.command === undefined || !commands.has(parsed.command)) {
357361
throw new Error(`Unknown command ${parsed.command ?? "<none>"}. Run typed-sql --help for usage.`);
358362
}
363+
if (parsed.command === "init") {
364+
process.stdout.write(`${JSON.stringify(await planInit(parsed.options, version), null, 2)}\n`);
365+
return;
366+
}
359367
const loaded = await loadConfig({ ...(parsed.options.config === undefined ? {} : { file: parsed.options.config }) });
360368
const config = loaded.config;
361369
const dialect = config.dialect;

‎packages/cli/src/init-plan.ts‎

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { execFile } from "node:child_process";
2+
import { mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { dirname, join } from "node:path";
5+
import { fileURLToPath } from "node:url";
6+
import { promisify } from "node:util";
7+
import { describe, it, strict } from "poku";
8+
import { planInit } from "../src/init-plan.js";
9+
10+
async function fixture(run: (root: string, boundary: string) => Promise<void>) {
11+
const boundary = await mkdtemp(join(tmpdir(), "typed-sql-init-"));
12+
const root = join(boundary, "app");
13+
try {
14+
await mkdir(root);
15+
await writeFile(join(boundary, "package.json"), JSON.stringify({ workspaces: ["app"] }));
16+
await run(root, boundary);
17+
} finally {
18+
await rm(boundary, { recursive: true, force: true });
19+
}
20+
}
21+
22+
await describe("nonexecuting init planner", async () => {
23+
await it("is deterministic, never writes, and does not invent a schema", async () => {
24+
await fixture(async (root) => {
25+
for (const grammar of ["postgres", "mysql", "sqlite"]) {
26+
const plan = await planInit({ cwd: root, grammar, editor: "vscode", "dry-run": "true" }, "2.1.0");
27+
strict.deepEqual(await planInit({ cwd: root, grammar, editor: "vscode", "dry-run": "true" }, "2.1.0"), plan);
28+
strict.equal(plan.packageManager, "npm");
29+
strict.equal(plan.files.length, 3);
30+
strict.ok(plan.files.every((file) => file.beforeHash === null && file.path.startsWith(`${root}/`)));
31+
strict.ok(!plan.files.some((file) => file.path.endsWith("schema.json")));
32+
strict.equal(plan.dependencies[`@typed-sql/${grammar}`], "^2.1.0");
33+
strict.ok(plan.pending.some((item) => item.includes("separate approval")));
34+
strict.deepEqual(await readdir(root), []);
35+
}
36+
});
37+
});
38+
39+
await it("preserves existing files and never imports executable config", async () => {
40+
await fixture(async (root) => {
41+
const manifest = '{"dependencies":{"@typed-sql/postgres":"workspace:*"},"scripts":{"check":"custom"}}';
42+
await writeFile(join(root, "package.json"), manifest);
43+
await writeFile(join(root, "typed-sql.config.ts"), 'throw new Error("must not execute");');
44+
await writeFile(join(root, "tsconfig.json"), '{ // preserved JSONC\n "extends":"./base.json" }');
45+
const plan = await planInit({ cwd: root, grammar: "postgres" }, "2.1.0");
46+
strict.equal(plan.files.length, 0);
47+
strict.equal(plan.dependencies["@typed-sql/postgres"], undefined);
48+
strict.equal(await readFile(join(root, "package.json"), "utf8"), manifest);
49+
strict.match(plan.warnings.join(" "), /not executed/u);
50+
const cli = join(dirname(fileURLToPath(import.meta.url)), "../src/cli.ts");
51+
const result = await promisify(execFile)(
52+
process.execPath,
53+
["--import", fileURLToPath(import.meta.resolve("tsx")), cli, "init", "--grammar", "postgres", "--dry-run"],
54+
{ cwd: root },
55+
);
56+
strict.equal(JSON.parse(result.stdout).mode, "plan");
57+
strict.equal(result.stderr, "");
58+
});
59+
});
60+
61+
await it("requires workspace selection and rejects manager conflicts", async () => {
62+
await fixture(async (root, boundary) => {
63+
await strict.rejects(planInit({ cwd: boundary, grammar: "sqlite" }, "2.1.0"), /--package/u);
64+
await writeFile(join(boundary, "pnpm-lock.yaml"), "lockfileVersion: '9.0'");
65+
strict.equal(
66+
(await planInit({ cwd: boundary, package: "app", grammar: "sqlite" }, "2.1.0")).packageManager,
67+
"pnpm",
68+
);
69+
await writeFile(join(root, "package-lock.json"), "{}");
70+
await strict.rejects(planInit({ cwd: root, grammar: "sqlite" }, "2.1.0"), /Conflicting package managers/u);
71+
});
72+
});
73+
74+
await it("fails closed for unsafe selections, malformed inputs and ambiguous configs", async () => {
75+
await fixture(async (root, boundary) => {
76+
for (const options of [
77+
{},
78+
{ grammar: "unknown" },
79+
{ grammar: "postgres", yes: "true" },
80+
{ grammar: "mysql", "dry-run": "false" },
81+
{ grammar: "sqlite", editor: "vim" },
82+
{ grammar: "postgres", package: "../escape" },
83+
])
84+
await strict.rejects(planInit({ cwd: root, ...options }, "2.1.0"));
85+
await symlink(root, join(boundary, "linked"));
86+
await strict.rejects(planInit({ cwd: boundary, package: "linked", grammar: "postgres" }, "2.1.0"), /symlinks/u);
87+
await writeFile(join(root, "package.json"), "null");
88+
await strict.rejects(planInit({ cwd: root, grammar: "postgres" }, "2.1.0"), /must be an object/u);
89+
await writeFile(join(root, "package.json"), "{}");
90+
await writeFile(join(root, "typed-sql.config.ts"), "");
91+
await writeFile(join(root, "typed-sql.config.mjs"), "");
92+
await strict.rejects(planInit({ cwd: root, grammar: "postgres" }, "2.1.0"), /Multiple typed-sql configurations/u);
93+
});
94+
});
95+
});

0 commit comments

Comments
 (0)