Skip to content
Merged
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
97 changes: 93 additions & 4 deletions src/lib/init/local-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,61 @@ import type {
WizardOptions,
} from "./types.js";

/** Whitespace characters used for JSON indentation. */
const Indenter = {
SPACE: " ",
TAB: "\t",
} as const;

/** Describes the indentation style of a JSON file. */
type JsonIndent = {
/** The whitespace character used for indentation. */
replacer: (typeof Indenter)[keyof typeof Indenter];
/** How many times the replacer is repeated per indent level. */
length: number;
};

const DEFAULT_JSON_INDENT: JsonIndent = {
replacer: Indenter.SPACE,
length: 2,
};

/** Matches the first indented line in a string to detect whitespace style. */
const INDENT_PATTERN = /^(\s+)/m;

/**
* Detect the indentation style of a JSON string by inspecting the first
* indented line. Returns a default of 2 spaces if no indentation is found.
*/
function detectJsonIndent(content: string): JsonIndent {
const match = content.match(INDENT_PATTERN);
if (!match?.[1]) {
return DEFAULT_JSON_INDENT;
}
const indent = match[1];
if (indent.includes("\t")) {
return { replacer: Indenter.TAB, length: indent.length };
}
return { replacer: Indenter.SPACE, length: indent.length };
}

/** Build the third argument for `JSON.stringify` from a `JsonIndent`. */
function jsonIndentArg(indent: JsonIndent): string {
return indent.replacer.repeat(indent.length);
}

/**
* Pretty-print a JSON string using the given indentation style.
* Returns the original string if it cannot be parsed as valid JSON.
*/
function prettyPrintJson(content: string, indent: JsonIndent): string {
try {
return `${JSON.stringify(JSON.parse(content), null, jsonIndentArg(indent))}\n`;
} catch {
return content;
}
}

/**
* Shell metacharacters that enable chaining, piping, substitution, or redirection.
* All legitimate install commands are simple single commands that don't need these.
Expand Down Expand Up @@ -336,6 +391,7 @@ function readFiles(payload: ReadFilesPayload): LocalOpResult {
try {
const absPath = safePath(cwd, filePath);
const stat = fs.statSync(absPath);
let content: string;
if (stat.size > maxBytes) {
// Read only up to maxBytes
const buffer = Buffer.alloc(maxBytes);
Expand All @@ -345,10 +401,21 @@ function readFiles(payload: ReadFilesPayload): LocalOpResult {
} finally {
fs.closeSync(fd);
}
files[filePath] = buffer.toString("utf-8");
content = buffer.toString("utf-8");
} else {
files[filePath] = fs.readFileSync(absPath, "utf-8");
content = fs.readFileSync(absPath, "utf-8");
}

// Minify JSON files by stripping whitespace/formatting
if (filePath.endsWith(".json")) {
try {
content = JSON.stringify(JSON.parse(content));
} catch {
// Not valid JSON (truncated, JSONC, etc.) — send as-is
}
}

files[filePath] = content;
} catch {
files[filePath] = null;
}
Expand Down Expand Up @@ -499,6 +566,26 @@ function applyPatchsetDryRun(payload: ApplyPatchsetPayload): LocalOpResult {
return { ok: true, data: { applied } };
}

/**
* Resolve the final file content for a patch, pretty-printing JSON files
* to preserve readable formatting. For `modify` actions, the existing file's
* indentation style is detected and preserved. For `create` actions, a default
* of 2-space indentation is used.
*/
function resolvePatchContent(
absPath: string,
patch: ApplyPatchsetPayload["params"]["patches"][number]
): string {
if (!patch.path.endsWith(".json")) {
return patch.patch;
}
if (patch.action === "modify") {
const existing = fs.readFileSync(absPath, "utf-8");
return prettyPrintJson(patch.patch, detectJsonIndent(existing));
}
return prettyPrintJson(patch.patch, DEFAULT_JSON_INDENT);
}

function applyPatchset(
payload: ApplyPatchsetPayload,
dryRun?: boolean
Expand Down Expand Up @@ -530,7 +617,8 @@ function applyPatchset(
case "create": {
const dir = path.dirname(absPath);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(absPath, patch.patch, "utf-8");
const content = resolvePatchContent(absPath, patch);
fs.writeFileSync(absPath, content, "utf-8");
applied.push({ path: patch.path, action: "create" });
break;
}
Expand All @@ -542,7 +630,8 @@ function applyPatchset(
data: { applied },
};
}
fs.writeFileSync(absPath, patch.patch, "utf-8");
const content = resolvePatchContent(absPath, patch);
fs.writeFileSync(absPath, content, "utf-8");
applied.push({ path: patch.path, action: "modify" });
break;
}
Expand Down
Loading