Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/npm-min-release-age.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@proofkit/cli": patch
---

Write npm min-release-age config during npm project scaffolding.
5 changes: 5 additions & 0 deletions .changeset/ultracite-init.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@proofkit/cli": patch
---

Run Ultracite and TanStack Intent setup during project scaffolding.
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"package.json"
],
"engines": {
"node": "^20.0.0 || ^22.0.0"
"node": "^22.0.0 || ^24.0.0 || ^26.0.0"
},
"scripts": {
"typecheck": "node ./scripts/write-cli-version.mjs && tsc",
Expand Down
71 changes: 70 additions & 1 deletion packages/cli/src/cli/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,24 @@ import fs from "fs-extra";
import type { PackageJson } from "type-fest";

import { DEFAULT_APP_NAME } from "~/consts.js";
import { createPnpmWorkspaceFileContent } from "~/core/planInit.js";
import { addAuth } from "~/generators/auth.js";
import { runCodegenCommand } from "~/generators/fmdapi.js";
import { debugOption, nonInteractiveOption } from "~/globalOptions.js";
import { createBareProject } from "~/helpers/createProject.js";
import { initializeGit } from "~/helpers/git.js";
import { installDependencies } from "~/helpers/installDependencies.js";
import { getIntentInstallCommand } from "~/helpers/intent.js";
import { logNextSteps } from "~/helpers/logNextSteps.js";
import { setImportAlias } from "~/helpers/setImportAlias.js";
import { getBrowserOxlintConfig, getUltraciteInitCommand } from "~/helpers/ultracite.js";
import { buildPkgInstallerMap } from "~/installers/index.js";
import { initProgramState, isNonInteractiveMode, state } from "~/state.js";
import { getVersion } from "~/utils/getProofKitVersion.js";
import { getUserPkgManager } from "~/utils/getUserPkgManager.js";
import { parseNameAndPath } from "~/utils/parseNameAndPath.js";
import { type Settings, setSettings } from "~/utils/parseSettings.js";
import { formatPackageManagerCommand } from "~/utils/projectFiles.js";
import { validateAppName } from "~/utils/validateAppName.js";
import { promptForFileMakerDataSource } from "./add/data-source/filemaker.js";
import { select, text } from "./prompts.js";
Expand Down Expand Up @@ -132,9 +136,19 @@ type ProofKitPackageJSON = PackageJson & {
version: string;
onFail: "download";
};
runtime: {
name: "node";
version: string;
onFail: "download";
};
};
engines?: {
node: string;
};
};

const NODE_RUNTIME_VERSION = "^24.11.0";

const missingTypegenCommandPatterns = [
/ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL[\s\S]*Command\s+["'`]typegen["'`]\s+not found/i,
/Command\s+["'`]typegen["'`]\s+not found/i,
Expand Down Expand Up @@ -289,23 +303,40 @@ export const runInit = async (name?: string, opts?: CliFlags) => {
pkgJson.proofkitMetadata = { initVersion: getVersion() };

// ? Bun doesn't support this field (yet)
let pkgManagerVersion: string | undefined;
if (pkgManager !== "bun") {
const { stdout } = await execa(pkgManager, ["-v"], {
cwd: projectDir,
});
pkgManagerVersion = stdout.trim();
pkgJson.packageManager = undefined;
pkgJson.devEngines = {
packageManager: {
name: pkgManager,
version: stdout.trim(),
version: pkgManagerVersion,
onFail: "download",
},
runtime: {
name: "node",
version: NODE_RUNTIME_VERSION,
onFail: "download",
},
};
}
pkgJson.engines = {
node: NODE_RUNTIME_VERSION,
};

fs.writeJSONSync(path.join(projectDir, "package.json"), pkgJson, {
spaces: 2,
});
if (pkgManager === "pnpm") {
fs.writeFileSync(
path.join(projectDir, "pnpm-workspace.yaml"),
createPnpmWorkspaceFileContent(state.appType ?? "browser"),
"utf8",
);
}

// Ensure proofkit.json exists with shadcn settings
const initialSettings: Settings = {
Expand Down Expand Up @@ -366,6 +397,24 @@ export const runInit = async (name?: string, opts?: CliFlags) => {
await installDependencies({ projectDir });
}

const ultraciteCommand = getUltraciteInitCommand({
appType: state.appType ?? "browser",
packageManager: pkgManager,
skipInstall: noInstall,
});
await execa(ultraciteCommand.command, ultraciteCommand.args, {
cwd: projectDir,
stdio: "pipe",
});
if ((state.appType ?? "browser") === "browser") {
fs.writeFileSync(path.join(projectDir, "oxlint.config.ts"), getBrowserOxlintConfig(), "utf8");
}
const intentCommand = getIntentInstallCommand(pkgManager);
await execa(intentCommand.command, intentCommand.args, {
cwd: projectDir,
stdio: "pipe",
});

if (dataSource === "filemaker") {
const shouldRunInitialCodegen = state.appType === "webviewer" && !(nonInteractive && !hasExplicitFileMakerInputs);

Expand All @@ -382,6 +431,26 @@ export const runInit = async (name?: string, opts?: CliFlags) => {
}
}

if (!noInstall) {
const [fixCommand, ...fixArgs] = formatPackageManagerCommand(pkgManager, "fix").split(" ");
if (!fixCommand) {
throw new Error(`Unable to resolve fix command for ${pkgManager}.`);
Comment on lines +435 to +437
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Command string splitting may break with complex arguments.

Lines 435 and 444 split formatPackageManagerCommand() result on space. If the formatted command contains quoted arguments with spaces, this will incorrectly parse the command and args.

This is the same issue as in executeInitPlan.ts line 89 and ultracite.ts line 11. Consider extracting a shared helper that handles this splitting consistently, or document the assumption that formatted commands never contain complex arguments.

Also applies to: 444-446

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/cli/init.ts` around lines 435 - 437, The current split on
space of the string returned by formatPackageManagerCommand (used to derive
fixCommand and fixArgs) will break when the command includes quoted or complex
arguments; update the implementation so you either (A) change
formatPackageManagerCommand to return an array of argv (e.g.,
['cmd','arg1',...]) and use that directly where fixCommand/fixArgs are assigned,
or (B) add a shared helper (e.g., parseCommandString) that tokenizes a
shell-style command string honoring quotes/escaping and use it in init.ts (the
fixCommand/fixArgs assignment), executeInitPlan.ts and ultracite.ts to
consistently produce a command and args array; ensure callers use the parsed
array rather than String.prototype.split(" ").

}
await execa(fixCommand, fixArgs, {
cwd: projectDir,
stdio: "pipe",
}).catch(() => undefined);

const [lintCommand, ...lintArgs] = formatPackageManagerCommand(pkgManager, "lint").split(" ");
if (!lintCommand) {
throw new Error(`Unable to resolve lint command for ${pkgManager}.`);
}
await execa(lintCommand, lintArgs, {
cwd: projectDir,
stdio: "pipe",
});
}

if (!noGit) {
await initializeGit(projectDir);
}
Expand Down
7 changes: 3 additions & 4 deletions packages/cli/src/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@ export const cliName = "proofkit";
export const npmName = "@proofkit/cli";
export const DOCS_URL = "https://proofkit.proof.sh";

export const AGENT_INSTRUCTIONS = [
"Use the ProofKit docs as the primary reference for this project: https://proofkit.proof.sh/docs",
"Before doing any AI-assisted development here, run `npx @tanstack/intent@latest install` in the project root to load skills relevant to this project",
].join("\n");
export function getAgentInstructions() {
return "Use the ProofKit docs as the primary reference for this project: https://proofkit.proof.sh/docs";
}

// Registry URL is injected at build time via tsdown define.
declare const __REGISTRY_URL__: string;
Expand Down
80 changes: 69 additions & 11 deletions packages/cli/src/core/executeInitPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Chalk } from "chalk";
import { Cause, Effect, Exit } from "effect";
import { getOrUndefined } from "effect/Option";

import { AGENT_INSTRUCTIONS } from "~/consts.js";
import { getAgentInstructions } from "~/consts.js";
import {
CliContext,
CodegenService,
Expand All @@ -19,7 +19,14 @@ import {
import { DirectoryConflictError, FileSystemError, isCliError, UserCancelledError } from "~/core/errors.js";
import { applyPackageJsonMutations } from "~/core/planInit.js";
import type { InitPlan } from "~/core/types.js";
import { normalizeImportAlias, replaceTextInFiles, updateTypegenConfig } from "~/utils/projectFiles.js";
import { getIntentInstallCommand } from "~/helpers/intent.js";
import { getBrowserOxlintConfig, getUltraciteInitCommand } from "~/helpers/ultracite.js";
import {
formatPackageManagerCommand,
normalizeImportAlias,
replaceTextInFiles,
updateTypegenConfig,
} from "~/utils/projectFiles.js";
import { sortPackageJson } from "~/utils/sortPackageJson.js";

const AGENT_METADATA_DIRS = new Set([".agents", ".claude", ".clawed", ".clinerules", ".cursor", ".windsurf"]);
Expand Down Expand Up @@ -78,6 +85,14 @@ function renderNextSteps(plan: InitPlan, additionalSteps: string[] = []) {
return lines.join("\n");
}

function getPackageScriptCommand(plan: InitPlan, scriptName: string) {
const [command, ...args] = formatPackageManagerCommand(plan.request.packageManager, scriptName).split(" ");
if (!command) {
throw new Error(`Unable to resolve ${scriptName} command for ${plan.request.packageManager}.`);
}
return { command, args };
}
Comment on lines +88 to +94
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Splitting formatted command on space may fail with complex arguments.

Line 89 splits the result of formatPackageManagerCommand() on a single space. If the formatted command includes arguments with embedded spaces (even quoted), this will incorrectly split the command and args array.

🛡️ Suggested defensive approach

Consider documenting the assumption or using a more robust parser:

 function getPackageScriptCommand(plan: InitPlan, scriptName: string) {
+  // Note: assumes formatPackageManagerCommand returns space-delimited tokens without quoted args
   const [command, ...args] = formatPackageManagerCommand(plan.request.packageManager, scriptName).split(" ");
   if (!command) {
     throw new Error(`Unable to resolve ${scriptName} command for ${plan.request.packageManager}.`);
   }
   return { command, args };
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function getPackageScriptCommand(plan: InitPlan, scriptName: string) {
const [command, ...args] = formatPackageManagerCommand(plan.request.packageManager, scriptName).split(" ");
if (!command) {
throw new Error(`Unable to resolve ${scriptName} command for ${plan.request.packageManager}.`);
}
return { command, args };
}
function getPackageScriptCommand(plan: InitPlan, scriptName: string) {
// Note: assumes formatPackageManagerCommand returns space-delimited tokens without quoted args
const [command, ...args] = formatPackageManagerCommand(plan.request.packageManager, scriptName).split(" ");
if (!command) {
throw new Error(`Unable to resolve ${scriptName} command for ${plan.request.packageManager}.`);
}
return { command, args };
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/core/executeInitPlan.ts` around lines 88 - 94, The code in
getPackageScriptCommand incorrectly splits the output of
formatPackageManagerCommand(plan.request.packageManager, scriptName) by a single
space which breaks when arguments contain spaces or quotes; update this by
either (a) changing formatPackageManagerCommand to return a structured value
(e.g., {command, args: string[]}) and use that directly in
getPackageScriptCommand, or (b) parse the returned string with a robust
shell-aware tokenizer (e.g., a shell-words/shell-quote style parser) to properly
handle quoted and spaced arguments; ensure getPackageScriptCommand returns the
original command token as command and the remaining tokens as args and preserve
existing error behavior when no command is found.


function getMeaningfulDirectoryEntries(entries: string[]) {
return entries.filter((entry) => {
if (AGENT_METADATA_DIRS.has(entry)) {
Expand Down Expand Up @@ -275,7 +290,7 @@ export const executeInitPlan = (plan: InitPlan) =>
}),
});
yield* Effect.tryPromise({
try: () => replaceTextInFiles(projectFilesFs, plan.targetDir, "__AGENT_INSTRUCTIONS__", AGENT_INSTRUCTIONS),
try: () => replaceTextInFiles(projectFilesFs, plan.targetDir, "__AGENT_INSTRUCTIONS__", getAgentInstructions()),
catch: (cause) =>
new FileSystemError({
message: "Unable to rewrite scaffold placeholders.",
Expand Down Expand Up @@ -380,14 +395,6 @@ export const executeInitPlan = (plan: InitPlan) =>
}

if (plan.tasks.runInstall) {
if (plan.request.packageManager === "pnpm") {
yield* processService.run("pnpm", ["self-update", "11"], {
cwd: plan.targetDir,
stdout: "pipe",
stderr: "pipe",
});
}

let installArgs: string[] = ["install"];
if (plan.request.packageManager === "yarn") {
installArgs = [];
Expand All @@ -399,10 +406,61 @@ export const executeInitPlan = (plan: InitPlan) =>
});
}

if (plan.tasks.runUltraciteInit) {
const ultraciteCommand = getUltraciteInitCommand({
appType: plan.request.appType,
packageManager: plan.request.packageManager,
skipInstall: plan.request.noInstall,
});
yield* processService.run(ultraciteCommand.command, ultraciteCommand.args, {
cwd: plan.targetDir,
stdout: "pipe",
stderr: "pipe",
});

if (plan.request.appType === "browser") {
yield* fs.writeFile(path.join(plan.targetDir, "oxlint.config.ts"), getBrowserOxlintConfig());
}
}

if (plan.tasks.runIntentInstall) {
const intentCommand = getIntentInstallCommand(plan.request.packageManager);
yield* processService.run(intentCommand.command, intentCommand.args, {
cwd: plan.targetDir,
stdout: "pipe",
stderr: "pipe",
});
}

if (plan.tasks.runInitialCodegen) {
yield* codegenService.runInitial(plan.targetDir, plan.request.packageManager);
}

if (plan.tasks.runFix) {
const fixCommand = getPackageScriptCommand(plan, "fix");
yield* Effect.either(
processService.run(fixCommand.command, fixCommand.args, {
cwd: plan.targetDir,
stdout: "pipe",
stderr: "pipe",
}),
);
Comment on lines +439 to +447
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't swallow runFix failures.

Line 441 converts the command failure into an Either, but the result is ignored. That makes a broken fix step invisible and still lets init report success.

Suggested fix
 if (plan.tasks.runFix) {
   const fixCommand = getPackageScriptCommand(plan, "fix");
-  yield* Effect.either(
-    processService.run(fixCommand.command, fixCommand.args, {
-      cwd: plan.targetDir,
-      stdout: "pipe",
-      stderr: "pipe",
-    }),
-  );
+  yield* processService.run(fixCommand.command, fixCommand.args, {
+    cwd: plan.targetDir,
+    stdout: "pipe",
+    stderr: "pipe",
+  });
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/core/executeInitPlan.ts` around lines 439 - 447, The runFix
branch currently calls getPackageScriptCommand and yields
Effect.either(processService.run(...)) but ignores the Either result, swallowing
failures; change this so the Either is inspected and any Left (process failure)
is propagated as a failing effect (or turned into a thrown error) so init does
not report success on a broken fix step—i.e., after calling processService.run
from executeInitPlan.ts (the runFix branch that references plan.tasks.runFix,
getPackageScriptCommand, and processService.run) handle the Either return value
and return/fail the surrounding Effect when the process failed instead of
discarding it.

}

if (plan.tasks.runLint) {
const fixCommand = getPackageScriptCommand(plan, "fix");
const result = yield* Effect.either(
processService.run(fixCommand.command, fixCommand.args, {
cwd: plan.targetDir,
stdout: "pipe",
stderr: "pipe",
}),
);
if (result._tag === "Left") {
consoleService.warn("Lint fix did not succeed; continuing setup.");
Comment on lines +450 to +460
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Run the lint script here, not fix.

Line 451 resolves "fix" again, so this block never executes the lint script. In the common case where both tasks are enabled, setup runs fix twice and skips lint entirely.

Suggested fix
 if (plan.tasks.runLint) {
-  const fixCommand = getPackageScriptCommand(plan, "fix");
+  const lintCommand = getPackageScriptCommand(plan, "lint");
   const result = yield* Effect.either(
-    processService.run(fixCommand.command, fixCommand.args, {
+    processService.run(lintCommand.command, lintCommand.args, {
       cwd: plan.targetDir,
       stdout: "pipe",
       stderr: "pipe",
     }),
   );
   if (result._tag === "Left") {
-    consoleService.warn("Lint fix did not succeed; continuing setup.");
+    consoleService.warn("Lint did not succeed; continuing setup.");
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (plan.tasks.runLint) {
const fixCommand = getPackageScriptCommand(plan, "fix");
const result = yield* Effect.either(
processService.run(fixCommand.command, fixCommand.args, {
cwd: plan.targetDir,
stdout: "pipe",
stderr: "pipe",
}),
);
if (result._tag === "Left") {
consoleService.warn("Lint fix did not succeed; continuing setup.");
if (plan.tasks.runLint) {
const lintCommand = getPackageScriptCommand(plan, "lint");
const result = yield* Effect.either(
processService.run(lintCommand.command, lintCommand.args, {
cwd: plan.targetDir,
stdout: "pipe",
stderr: "pipe",
}),
);
if (result._tag === "Left") {
consoleService.warn("Lint did not succeed; continuing setup.");
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/core/executeInitPlan.ts` around lines 450 - 460, The block
guarded by plan.tasks.runLint currently calls getPackageScriptCommand(plan,
"fix") then runs that command; change it to resolve and run the lint script
instead by calling getPackageScriptCommand(plan, "lint") so the lint task
actually executes (the surrounding symbols are plan.tasks.runLint,
getPackageScriptCommand, and processService.run); update the variable name
(e.g., fixCommand → lintCommand) and the subsequent processService.run
invocation to use the lint command and args so lint runs rather than fix being
executed twice.

}
}

if (plan.tasks.initializeGit) {
yield* gitService.initialize(plan.targetDir);
}
Expand Down
Loading
Loading