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
61 changes: 0 additions & 61 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions docs/plans/decisions-log.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Decisions Log

## 2026-03-30: Replace `fs-extra` and `execa` in the CLI scaffold

### Context
The scaffold CLI depended on `fs-extra` for directory and file helpers and declared `execa` without using it. The package already runs on modern Node/Bun runtimes, so Node's built-in filesystem APIs cover the needed behavior.

### Decision
Replace `fs-extra` usage with `node:fs/promises`, remove the unused `execa` dependency, and add template `.dockerignore` generation alongside the existing deployment files.

### Rationale
This keeps the same scaffold behavior while reducing direct dependencies and aligns with the package replacement guidance from the JavaScript bloat review.

### Consequences
Comment on lines +5 to +14
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

Add blank lines around headings to fix markdown linting warnings.

Static analysis flagged MD022 violations on lines 5, 8, 11, and 14.

Suggested fix
 ### Context
+
 The scaffold CLI depended on `fs-extra` for directory and file helpers and declared `execa` without using it. The package already runs on modern Node/Bun runtimes, so Node's built-in filesystem APIs cover the needed behavior.

 ### Decision
+
 Replace `fs-extra` usage with `node:fs/promises`, remove the unused `execa` dependency, and add template `.dockerignore` generation alongside the existing deployment files.

 ### Rationale
+
 This keeps the same scaffold behavior while reducing direct dependencies and aligns with the package replacement guidance from the JavaScript bloat review.

 ### Consequences
+
 The CLI depends on fewer packages, generated deployment templates now include `.dockerignore`, and deployment-disabled scaffolds remove that file together with `Dockerfile` and `fly.toml`.
🧰 Tools
🪛 markdownlint-cli2 (0.22.0)

[warning] 5-5: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 8-8: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 11-11: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 14-14: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/plans/decisions-log.md` around lines 5 - 14, Add blank lines before and
after the top-level headings "Context", "Decision", "Rationale", and
"Consequences" in docs/plans/decisions-log.md to satisfy MD022; locate the
heading lines that currently read "### Context", "### Decision", "###
Rationale", and "### Consequences" and ensure there is an empty line above and
below each heading so markdown linting no longer flags those lines.

The CLI depends on fewer packages, generated deployment templates now include `.dockerignore`, and deployment-disabled scaffolds remove that file together with `Dockerfile` and `fly.toml`.
3 changes: 0 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,9 @@
},
"dependencies": {
"@clack/prompts": "^0.10.0",
"execa": "^9.5.0",
"fs-extra": "^11.3.0",
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/fs-extra": "^11.0.4",
"@types/node": "^22.15.21",
"typescript": "^5.8.3"
}
Expand Down
16 changes: 12 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
#!/usr/bin/env node
import * as p from "@clack/prompts";
import pc from "picocolors";
import { access, rm } from "node:fs/promises";
import path from "node:path";
import fs from "fs-extra";
import { runPrompts } from "./prompts.js";
import { scaffoldProject } from "./scaffold.js";

Expand All @@ -15,8 +15,7 @@ async function main() {

const targetDir = path.resolve(process.cwd(), config.name);

// Check if directory exists
if (await fs.pathExists(targetDir)) {
if (await pathExists(targetDir)) {
const overwrite = await p.confirm({
message: `Directory ${pc.cyan(config.name)} already exists. Overwrite?`,
initialValue: false,
Expand All @@ -27,7 +26,7 @@ async function main() {
return;
}

await fs.remove(targetDir);
await rm(targetDir, { force: true, recursive: true });
}

const s = p.spinner();
Expand Down Expand Up @@ -55,4 +54,13 @@ async function main() {
}
}

async function pathExists(targetPath: string): Promise<boolean> {
try {
await access(targetPath);
return true;
} catch {
return false;
}
}
Comment on lines +57 to +64
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Duplicate pathExists helper exists in both index.ts and scaffold.ts.

This function is identical to the one in scaffold.ts. Consider extracting it to a shared utility (e.g., utils.ts) to avoid duplication.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/index.ts` around lines 57 - 64, The pathExists helper is duplicated in
index.ts and scaffold.ts; extract it into a shared utility module (e.g., create
utils.ts exporting async function pathExists(targetPath: string):
Promise<boolean>), replace the local implementations in both index.ts and
scaffold.ts with imports from that utils module (import { pathExists } from
'./utils'), and remove the duplicate function declarations so both files use the
single exported pathExists implementation.


main().catch(console.error);
47 changes: 28 additions & 19 deletions src/scaffold.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,28 @@
import fs from "fs-extra";
import { access, chmod, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { execa } from "execa";
import type { ProjectConfig } from "./types.js";
import { getTemplatesDir, renderTemplate } from "./utils.js";

export async function scaffoldProject(config: ProjectConfig, targetDir: string): Promise<void> {
const templatesDir = getTemplatesDir();

// Create target directory
await fs.ensureDir(targetDir);
await mkdir(targetDir, { recursive: true });

// Copy base template
await copyTemplate(path.join(templatesDir, "base"), targetDir, {
name: config.name,
});

// Create apps and packages directories
await fs.ensureDir(path.join(targetDir, "apps"));
await fs.ensureDir(path.join(targetDir, "packages"));
await mkdir(path.join(targetDir, "apps"), { recursive: true });
await mkdir(path.join(targetDir, "packages"), { recursive: true });

// Copy selected apps
for (const app of config.apps) {
const appSrc = path.join(templatesDir, "apps", app);
const appDest = path.join(targetDir, "apps", app);

if (await fs.pathExists(appSrc)) {
if (await pathExists(appSrc)) {
await copyTemplate(appSrc, appDest, { name: config.name });
}
}
Expand All @@ -34,7 +32,7 @@ export async function scaffoldProject(config: ProjectConfig, targetDir: string):
const apiSrc = path.join(templatesDir, "apps", `api-${config.api}`);
const apiDest = path.join(targetDir, "apps", "api");

if (await fs.pathExists(apiSrc)) {
if (await pathExists(apiSrc)) {
await copyTemplate(apiSrc, apiDest, { name: config.name });
}
}
Expand All @@ -44,21 +42,22 @@ export async function scaffoldProject(config: ProjectConfig, targetDir: string):
const pkgSrc = path.join(templatesDir, "packages", pkg);
const pkgDest = path.join(targetDir, "packages", pkg);

if (await fs.pathExists(pkgSrc)) {
if (await pathExists(pkgSrc)) {
await copyTemplate(pkgSrc, pkgDest, { name: config.name });
}
}

// Remove deployment files if not enabled
if (!config.deployment) {
await fs.remove(path.join(targetDir, "Dockerfile"));
await fs.remove(path.join(targetDir, "fly.toml"));
await rm(path.join(targetDir, "Dockerfile"), { force: true, recursive: true });
await rm(path.join(targetDir, "fly.toml"), { force: true, recursive: true });
await rm(path.join(targetDir, ".dockerignore"), { force: true, recursive: true });
Comment on lines +52 to +54
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

{ recursive: true } is unnecessary when removing single files.

rm with { force: true } alone is sufficient for Dockerfile, fly.toml, and .dockerignore since they are regular files. The recursive option only matters for directories.

Suggested simplification
-    await rm(path.join(targetDir, "Dockerfile"), { force: true, recursive: true });
-    await rm(path.join(targetDir, "fly.toml"), { force: true, recursive: true });
-    await rm(path.join(targetDir, ".dockerignore"), { force: true, recursive: true });
+    await rm(path.join(targetDir, "Dockerfile"), { force: true });
+    await rm(path.join(targetDir, "fly.toml"), { force: true });
+    await rm(path.join(targetDir, ".dockerignore"), { force: true });
📝 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
await rm(path.join(targetDir, "Dockerfile"), { force: true, recursive: true });
await rm(path.join(targetDir, "fly.toml"), { force: true, recursive: true });
await rm(path.join(targetDir, ".dockerignore"), { force: true, recursive: true });
await rm(path.join(targetDir, "Dockerfile"), { force: true });
await rm(path.join(targetDir, "fly.toml"), { force: true });
await rm(path.join(targetDir, ".dockerignore"), { force: true });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/scaffold.ts` around lines 52 - 54, Remove the unnecessary recursive
option from the three file deletion calls: update the rm calls that target
"Dockerfile", "fly.toml", and ".dockerignore" (the calls using
rm(path.join(targetDir, "Dockerfile"), ...), rm(path.join(targetDir,
"fly.toml"), ...), and rm(path.join(targetDir, ".dockerignore"), ...)) to use
only { force: true } since these are regular files and do not require {
recursive: true }.

}

// Make husky pre-commit executable
const preCommitPath = path.join(targetDir, ".husky", "pre-commit");
if (await fs.pathExists(preCommitPath)) {
await fs.chmod(preCommitPath, 0o755);
if (await pathExists(preCommitPath)) {
await chmod(preCommitPath, 0o755);
}
}

Expand All @@ -67,31 +66,41 @@ async function copyTemplate(
destDir: string,
vars: Record<string, string>
): Promise<void> {
await fs.ensureDir(destDir);
const entries = await fs.readdir(srcDir, { withFileTypes: true });
await mkdir(destDir, { recursive: true });
const entries = await readdir(srcDir, { withFileTypes: true });

for (const entry of entries) {
const srcPath = path.join(srcDir, entry.name);
let destName = entry.name.replace(/\.hbs$/, "");
// Rename gitignore to .gitignore (npm excludes .gitignore files from packages)
if (destName === "gitignore") {
destName = ".gitignore";
} else if (destName === "dockerignore") {
destName = ".dockerignore";
}
const destPath = path.join(destDir, destName);

if (entry.isDirectory()) {
await fs.ensureDir(destPath);
await mkdir(destPath, { recursive: true });
await copyTemplate(srcPath, destPath, vars);
} else {
let content = await fs.readFile(srcPath, "utf-8");
let content = await readFile(srcPath, "utf-8");

// Render handlebars-style variables in template files
const renderExtensions = [".hbs", ".json", ".tsx", ".ts", ".md", ".toml"];
if (renderExtensions.some((ext) => entry.name.endsWith(ext))) {
content = renderTemplate(content, vars);
}

await fs.writeFile(destPath, content);
await writeFile(destPath, content);
}
}
}

async function pathExists(targetPath: string): Promise<boolean> {
try {
await access(targetPath);
return true;
} catch {
return false;
}
}
Comment on lines +99 to +106
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Duplicate pathExists helper—same as in index.ts.

As noted earlier, consider extracting this to utils.ts for reuse.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/scaffold.ts` around lines 99 - 106, The helper function pathExists in
src/scaffold.ts is duplicated (same implementation exists in index.ts); remove
the duplicate and import the shared implementation from a single utility module
(create or use utils.ts). Specifically, move the pathExists implementation into
utils.ts (exported as pathExists), then update src/scaffold.ts to remove its
local pathExists and add an import for pathExists from utils, ensuring any
callers (pathExists in scaffold) continue to work; also update index.ts to
import the same exported function if it isn’t already.

8 changes: 8 additions & 0 deletions templates/base/dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
node_modules
.git
.turbo
.next
.output
dist
coverage
.DS_Store
Comment on lines +1 to +8
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider adding .env* to prevent secrets from leaking into Docker build context.

The current patterns cover common build artifacts. However, .env files containing secrets could inadvertently be included in the Docker build context if not ignored.

Suggested addition
 node_modules
 .git
 .turbo
 .next
 .output
 dist
 coverage
 .DS_Store
+.env*
📝 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
node_modules
.git
.turbo
.next
.output
dist
coverage
.DS_Store
node_modules
.git
.turbo
.next
.output
dist
coverage
.DS_Store
.env*
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@templates/base/dockerignore` around lines 1 - 8, Add a pattern to the
.dockerignore content to exclude environment files (use .env* to cover .env,
.env.local, .env.production, etc.) so secrets don’t get sent to the Docker build
context; locate the existing ignore list (entries like node_modules, .git,
.next, dist) and append the .env* pattern to it.