@theholocron/cli 5.0.0-alpha.145 → 5.0.0-alpha.147

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -17,9 +17,12 @@ import { errorMessage } from "@theholocron/misc-utils";
17
17
  import { createLogger, isCI, parseLogLevel, resolveAxiomFromEnv } from "@theholocron/observability/logger";
18
18
  import chalk from "chalk";
19
19
  import ora from "ora";
20
- import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
20
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
21
+ import { stringifyJson } from "@theholocron/json-utils";
22
+ import { readGitRemote, updateGitignore } from "@theholocron/git-utils";
21
23
  import { access, copyFile, mkdir, readFile, readdir, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
22
24
  import { createHash, randomUUID } from "node:crypto";
25
+ import { findRootSync } from "@theholocron/fs-utils";
23
26
  import { generateReadme } from "@theholocron/components-doc/markdown";
24
27
  import { getClients, getConfigs, getDocs, getPlugins, getSkills, getThemes, getUtils } from "@theholocron/registry-doc";
25
28
  import { createGitHubClient } from "@theholocron/github-client";
@@ -27,7 +30,6 @@ import { PostHogSink } from "@theholocron/observability/analytics";
27
30
  import { SentrySink } from "@theholocron/observability/errors";
28
31
  import load from "@commitlint/load";
29
32
  import lint from "@commitlint/lint";
30
- import { promisify } from "node:util";
31
33
  import { ConfigFileError, loadConfigFile } from "@theholocron/datapad";
32
34
  import search from "@inquirer/search";
33
35
  //#region src/env.ts
@@ -2275,19 +2277,21 @@ async function runNew(input) {
2275
2277
  const filesPatched = patchFiles(repoDir, variants, input.description, input.homepage, input.runtimeEnvironment, print, readFn, writeFn, walkFn);
2276
2278
  print(` ${filesPatched.length} file${filesPatched.length === 1 ? "" : "s"} patched`);
2277
2279
  if (input.description !== void 0 || input.homepage !== void 0) try {
2278
- const pkg = JSON.parse(readFn(pkgJsonPath));
2280
+ const raw = readFn(pkgJsonPath);
2281
+ const pkg = JSON.parse(raw);
2279
2282
  if (input.description !== void 0) pkg["description"] = input.description;
2280
2283
  if (input.homepage !== void 0) pkg["homepage"] = input.homepage;
2281
- writeFn(pkgJsonPath, JSON.stringify(pkg, null, 2) + "\n");
2284
+ writeFn(pkgJsonPath, stringifyJson(pkg, { like: raw }));
2282
2285
  print(` ✓ package.json (description/homepage)`);
2283
2286
  } catch {}
2284
2287
  const displayName = input.name.split("-").map(cap).join(" ");
2285
2288
  const rootTsconfigPath = path.join(repoDir, "tsconfig.json");
2286
2289
  try {
2287
- const tsconfig = JSON.parse(readFn(rootTsconfigPath));
2290
+ const rawTsconfig = readFn(rootTsconfigPath);
2291
+ const tsconfig = JSON.parse(rawTsconfig);
2288
2292
  if (typeof tsconfig["display"] === "string") {
2289
2293
  tsconfig["display"] = displayName;
2290
- writeFn(rootTsconfigPath, JSON.stringify(tsconfig, null, 2) + "\n");
2294
+ writeFn(rootTsconfigPath, stringifyJson(tsconfig, { like: rawTsconfig }));
2291
2295
  print(` ✓ tsconfig.json (display)`);
2292
2296
  }
2293
2297
  } catch {}
@@ -2407,8 +2411,10 @@ async function runNpmBumpVersions(input) {
2407
2411
  }, "npm bump-versions: start");
2408
2412
  function bumpFile(absPath, label) {
2409
2413
  let pkg;
2414
+ let raw;
2410
2415
  try {
2411
- pkg = JSON.parse(readFile(absPath));
2416
+ raw = readFile(absPath);
2417
+ pkg = JSON.parse(raw);
2412
2418
  } catch {
2413
2419
  print(` ✗ could not parse ${label}`);
2414
2420
  return;
@@ -2416,7 +2422,7 @@ async function runNpmBumpVersions(input) {
2416
2422
  const old = pkg.version;
2417
2423
  if (!dryRun) {
2418
2424
  pkg.version = version;
2419
- writeFile(absPath, JSON.stringify(pkg, null, 2) + "\n");
2425
+ writeFile(absPath, stringifyJson(pkg, { like: raw }));
2420
2426
  }
2421
2427
  print(` ${dryRun ? "~" : "✓"} ${label}: ${old} → ${version}`);
2422
2428
  bumped.push(label);
@@ -4154,13 +4160,11 @@ function namesInWorkspaceDir(cwd, dirName) {
4154
4160
  });
4155
4161
  }
4156
4162
  async function resolveRepoName(cwd, exec) {
4157
- const result = await exec("git", [
4158
- "remote",
4159
- "get-url",
4160
- "origin"
4161
- ], { cwd });
4162
- if (result.exitCode !== 0) return "unknown";
4163
- return /[/:]([^/:]+?)(?:\.git)?$/.exec(result.stdout.trim())?.[1] ?? "unknown";
4163
+ return (await readGitRemote(cwd, { exec: async (args, options) => {
4164
+ const result = await exec("git", args, options);
4165
+ if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} exited ${result.exitCode}`);
4166
+ return result.stdout;
4167
+ } }))?.repo ?? "unknown";
4164
4168
  }
4165
4169
  function printNextSteps(print, env, packageNames, repoName) {
4166
4170
  print("");
@@ -4710,7 +4714,7 @@ var alexrc_default = { allow: [
4710
4714
  //#region src/templates/configs/alexjs/create-config.ts
4711
4715
  const { workflowHeader: workflowHeader$9 } = createHeader({ source: "packages/cli/src/templates/configs/alexjs/create-config.ts" });
4712
4716
  function createRcConfig() {
4713
- return JSON.stringify(alexrc_default, null, 2) + "\n";
4717
+ return stringifyJson(alexrc_default);
4714
4718
  }
4715
4719
  function createIgnoreConfig$1() {
4716
4720
  return `${workflowHeader$9()}${alexignore_default}`;
@@ -4794,7 +4798,7 @@ var editorconfig_checker_default = {
4794
4798
  * buys nothing.
4795
4799
  */
4796
4800
  function createConfig$4() {
4797
- return JSON.stringify(editorconfig_checker_default, null, 2) + "\n";
4801
+ return stringifyJson(editorconfig_checker_default);
4798
4802
  }
4799
4803
  //#endregion
4800
4804
  //#region src/templates/configs/pre-commit/pre-commit
@@ -5264,21 +5268,11 @@ async function installAgentPrompts({ repoRoot }) {
5264
5268
  const promptsDir = join(repoRoot, AGENTS_PROMPTS_ROOT);
5265
5269
  await mkdir(promptsDir, { recursive: true });
5266
5270
  for (const [filename, content] of Object.entries(AGENT_PROMPTS)) await writeFile(join(promptsDir, filename), content, "utf8");
5267
- const gitignorePath = join(repoRoot, ".gitignore");
5268
- const existing = await readFile(gitignorePath, "utf8").catch(() => "");
5269
- const block = [
5270
- PROMPTS_GITIGNORE_START,
5271
- `/${AGENTS_PROMPTS_ROOT}/`,
5272
- PROMPTS_GITIGNORE_END
5273
- ].join("\n");
5274
- let updated;
5275
- if (existing.includes(PROMPTS_GITIGNORE_START)) {
5276
- const start = existing.indexOf(PROMPTS_GITIGNORE_START);
5277
- const end = existing.indexOf(PROMPTS_GITIGNORE_END, start);
5278
- const afterBlock = end !== -1 ? existing.slice(end + 41) : "\n";
5279
- updated = existing.slice(0, start) + block + afterBlock;
5280
- } else updated = (existing.trimEnd() ? existing.trimEnd() + "\n\n" : "") + block + "\n";
5281
- await writeFile(gitignorePath, updated, "utf8");
5271
+ await updateGitignore(join(repoRoot, ".gitignore"), {
5272
+ start: PROMPTS_GITIGNORE_START,
5273
+ end: PROMPTS_GITIGNORE_END,
5274
+ lines: [`/${AGENTS_PROMPTS_ROOT}/`]
5275
+ });
5282
5276
  return `wrote ${Object.keys(AGENT_PROMPTS).length} prompt files to ${AGENTS_PROMPTS_ROOT}/`;
5283
5277
  }
5284
5278
  //#endregion
@@ -5548,8 +5542,7 @@ async function installSkills({ agent, skills, repoRoot }) {
5548
5542
  }
5549
5543
  }
5550
5544
  const gitignorePath = join(repoRoot, ".gitignore");
5551
- const existingContent = await readFile(gitignorePath, "utf8").catch(() => "");
5552
- const previouslyInstalled = parsePreviousSkills(existingContent, symlinkFn);
5545
+ const previouslyInstalled = parsePreviousSkills(await readFile(gitignorePath, "utf8").catch(() => ""), symlinkFn);
5553
5546
  const currentSet = new Set(skills);
5554
5547
  const stale = previouslyInstalled.filter((n) => !currentSet.has(n));
5555
5548
  for (const name of stale) {
@@ -5609,7 +5602,7 @@ async function installSkills({ agent, skills, repoRoot }) {
5609
5602
  }
5610
5603
  }
5611
5604
  }
5612
- if (installed.length > 0 || stale.length > 0 || missing.length > 0 || externalFailed.length > 0) await updateSkillsGitignore(gitignorePath, existingContent, [
5605
+ if (installed.length > 0 || stale.length > 0 || missing.length > 0 || externalFailed.length > 0) await updateSkillsGitignore(gitignorePath, [
5613
5606
  ...installed,
5614
5607
  ...missing,
5615
5608
  ...externalFailed
@@ -5621,14 +5614,24 @@ async function installSkills({ agent, skills, repoRoot }) {
5621
5614
  if (missing.length > 0) parts.push(`unknown: ${missing.join(", ")}`);
5622
5615
  return parts.join("; ");
5623
5616
  }
5617
+ /**
5618
+ * The skill names a previous `holocron setup` recorded in the `.gitignore`
5619
+ * managed block. A block missing its end marker (hand-edited, truncated) runs
5620
+ * only to the next blank line — the same rule `updateGitignore` uses to
5621
+ * replace it — so user-written ignore rules further down are never mistaken
5622
+ * for installed skills and pruned.
5623
+ */
5624
5624
  function parsePreviousSkills(gitignoreContent, symlinkFn) {
5625
- if (!gitignoreContent.includes(GITIGNORE_BLOCK_START)) return [];
5626
5625
  const startIdx = gitignoreContent.indexOf(GITIGNORE_BLOCK_START);
5626
+ if (startIdx === -1) return [];
5627
5627
  const endIdx = gitignoreContent.indexOf(GITIGNORE_BLOCK_END, startIdx);
5628
- const block = endIdx !== -1 ? gitignoreContent.slice(startIdx, endIdx) : gitignoreContent.slice(startIdx);
5628
+ const blankAt = /\r?\n[ \t]*\r?\n/.exec(gitignoreContent.slice(startIdx));
5629
+ const blankIdx = blankAt ? startIdx + blankAt.index : -1;
5630
+ const blockEnd = endIdx !== -1 && (blankIdx === -1 || endIdx < blankIdx) ? endIdx : blankIdx === -1 ? gitignoreContent.length : blankIdx;
5631
+ const block = gitignoreContent.slice(startIdx, blockEnd);
5629
5632
  const placeholder = "__placeholder__";
5630
5633
  const symlinkPrefix = `/${symlinkFn(placeholder)}`.replace(placeholder, "");
5631
- return block.split("\n").filter((line) => line.startsWith(symlinkPrefix)).map((line) => line.slice(symlinkPrefix.length));
5634
+ return block.split(/\r?\n/).filter((line) => line.startsWith(symlinkPrefix)).map((line) => line.slice(symlinkPrefix.length));
5632
5635
  }
5633
5636
  async function copyDirRecursive(src, dest) {
5634
5637
  await mkdir(dest, { recursive: true });
@@ -5640,21 +5643,12 @@ async function copyDirRecursive(src, dest) {
5640
5643
  else await copyFile(srcPath, destPath);
5641
5644
  }
5642
5645
  }
5643
- async function updateSkillsGitignore(gitignorePath, existingContent, skills, symlinkFn) {
5644
- const entries = [`/${AGENTS_SKILLS_ROOT}/`, ...skills.map((n) => `/${symlinkFn(n)}`)];
5645
- const block = [
5646
- GITIGNORE_BLOCK_START,
5647
- ...entries,
5648
- GITIGNORE_BLOCK_END
5649
- ].join("\n");
5650
- let content;
5651
- if (existingContent.includes(GITIGNORE_BLOCK_START)) {
5652
- const start = existingContent.indexOf(GITIGNORE_BLOCK_START);
5653
- const end = existingContent.indexOf(GITIGNORE_BLOCK_END, start);
5654
- const afterBlock = end !== -1 ? existingContent.slice(end + 40) : "\n";
5655
- content = existingContent.slice(0, start) + block + afterBlock;
5656
- } else content = (existingContent.trimEnd() ? existingContent.trimEnd() + "\n\n" : "") + block + "\n";
5657
- await writeFile(gitignorePath, content, "utf8");
5646
+ async function updateSkillsGitignore(gitignorePath, skills, symlinkFn) {
5647
+ await updateGitignore(gitignorePath, {
5648
+ start: GITIGNORE_BLOCK_START,
5649
+ end: GITIGNORE_BLOCK_END,
5650
+ lines: [`/${AGENTS_SKILLS_ROOT}/`, ...skills.map((n) => `/${symlinkFn(n)}`)]
5651
+ });
5658
5652
  }
5659
5653
  //#endregion
5660
5654
  //#region src/commands/setup/run-setup.ts
@@ -6359,7 +6353,7 @@ async function ensurePrepareScript(repoRoot) {
6359
6353
  if (/(^|[\s&|;])husky($|[\s&|;])/.test(current)) return false;
6360
6354
  scripts.prepare = current ? `${current} && husky` : "husky";
6361
6355
  pkg.scripts = scripts;
6362
- await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
6356
+ await writeFile(pkgPath, stringifyJson(pkg, { like: content }), "utf8");
6363
6357
  return true;
6364
6358
  }
6365
6359
  //#endregion
@@ -6462,55 +6456,6 @@ function runSkillsUpdate(input) {
6462
6456
  return { status: exitCode === 0 ? "ok" : "fail" };
6463
6457
  }
6464
6458
  //#endregion
6465
- //#region src/utils/find-git-root.ts
6466
- /**
6467
- * Walks up from `startDir` looking for a `.git` entry — a directory for a
6468
- * normal checkout, a file for a worktree/submodule (contains a `gitdir:`
6469
- * pointer, never followed here; its presence is enough to mark the root).
6470
- *
6471
- * `--cwd` picks which `holocron.config.ts` to load, which is NOT
6472
- * necessarily the git repository root — a monorepo subpackage (e.g.
6473
- * `packages/sentinel`) has its own config but no `.git` of its own.
6474
- * Anything inherently repo-wide (`.github/workflows`, `.github/CODEOWNERS`,
6475
- * `turbo.json`, "is this repo a monorepo") must resolve against the real
6476
- * root, not `--cwd`, or it silently writes into a GitHub-invisible nested
6477
- * `.github/` instead (holocron#1014).
6478
- *
6479
- * Falls back to the resolved start dir when no `.git` is found walking all
6480
- * the way to the filesystem root — soft-skip, not a hard failure; every
6481
- * caller already treats `--cwd` itself as the root today, so an
6482
- * unexpected non-git checkout just keeps today's behavior.
6483
- *
6484
- * Resolves `startDir` to an absolute, symlink-free path before walking:
6485
- * a relative `--cwd` (e.g. `"packages/sentinel"`) would otherwise hit
6486
- * `dirname`'s fixed point at `"."` after only two steps, far short of the
6487
- * real root, and a `--cwd` reached through a symlink would walk the
6488
- * symlink's own lexical ancestors instead of the physical directory
6489
- * tree its target actually lives in — in both cases landing on the
6490
- * wrong directory, not genuinely "no `.git` found anywhere."
6491
- *
6492
- * `realpathSync` itself throws when `startDir` doesn't exist on disk at
6493
- * all (a real possibility in tests, which often use a synthetic path
6494
- * like `"/tmp/test"` that's never actually created) — caught and treated
6495
- * the same as "no `.git` found," since this function's whole contract is
6496
- * soft-skip, never a hard failure.
6497
- */
6498
- function findGitRoot(startDir) {
6499
- let resolvedStart;
6500
- try {
6501
- resolvedStart = realpathSync(startDir);
6502
- } catch {
6503
- return startDir;
6504
- }
6505
- let dir = resolvedStart;
6506
- for (;;) {
6507
- if (existsSync(`${dir}/.git`)) return dir;
6508
- const parent = dirname(dir);
6509
- if (parent === dir) return resolvedStart;
6510
- dir = parent;
6511
- }
6512
- }
6513
- //#endregion
6514
6459
  //#region src/commands/sync-readme.ts
6515
6460
  const MARKER_INSTALL_START = "<!-- holocron:installation -->";
6516
6461
  const MARKER_INSTALL_END = "<!-- /holocron:installation -->";
@@ -6917,7 +6862,10 @@ async function runSync(input) {
6917
6862
  const dryRun = input.context.dryRun ?? false;
6918
6863
  const requestedSteps = input.steps;
6919
6864
  const steps = [];
6920
- const gitRoot = findGitRoot(input.context.repoRoot);
6865
+ const gitRoot = findRootSync(input.context.repoRoot, ".git", {
6866
+ fallback: "start",
6867
+ realpath: true
6868
+ });
6921
6869
  let resolvedRepoRoot;
6922
6870
  try {
6923
6871
  resolvedRepoRoot = realpathSync(input.context.repoRoot);
@@ -7365,7 +7313,7 @@ async function writePackageJsonField(repoRoot, field, value) {
7365
7313
  }
7366
7314
  const pkg = JSON.parse(content);
7367
7315
  pkg[field] = value;
7368
- await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
7316
+ await writeFile(pkgPath, stringifyJson(pkg, { like: content }), "utf8");
7369
7317
  return true;
7370
7318
  }
7371
7319
  /**
@@ -7407,7 +7355,7 @@ async function mergePackageJsonScripts(repoRoot, desired, turboEligible) {
7407
7355
  }
7408
7356
  if (changed.length === 0) return [];
7409
7357
  pkg.scripts = scripts;
7410
- await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
7358
+ await writeFile(pkgPath, stringifyJson(pkg, { like: content }), "utf8");
7411
7359
  return changed.sort();
7412
7360
  }
7413
7361
  /**
@@ -8146,7 +8094,7 @@ function patchPackageJson(content, from, to) {
8146
8094
  changed = true;
8147
8095
  }
8148
8096
  }
8149
- return changed ? JSON.stringify(pkg, null, 2) + "\n" : null;
8097
+ return changed ? stringifyJson(pkg, { like: content }) : null;
8150
8098
  }
8151
8099
  function patchYaml(content, from, to) {
8152
8100
  const updated = content.replace(/node-version:\s+['"]?(\d+)['"]?/g, (match, ver) => ver === String(from) ? match.replace(String(from), String(to)) : match);
@@ -8369,7 +8317,6 @@ async function lintCommitMsgFile(filePath, cwd = process.cwd()) {
8369
8317
  * Probe order is TS-first (`.ts` → `.js` → `.mjs` → `.cjs` → `.json`) —
8370
8318
  * see ADR-0010 / issue #75 / #81.
8371
8319
  */
8372
- const execFileAsync = promisify(execFile);
8373
8320
  /**
8374
8321
  * Read + parse + resolve `holocron.config.*` starting from the given
8375
8322
  * directory, walking up through ancestors to the nearest `.git` boundary
@@ -8395,10 +8342,10 @@ async function deriveDefaults(configDir, raw) {
8395
8342
  const result = { ...raw };
8396
8343
  if (!result.name) result.name = await readPackageJsonName(configDir) ?? basename(configDir);
8397
8344
  if (result.repo && !result.repo.name) {
8398
- const repoName = await readGitRemote(configDir);
8399
- if (repoName) result.repo = {
8345
+ const remote = await readGitRemote(configDir);
8346
+ if (remote?.host === "github.com") result.repo = {
8400
8347
  ...result.repo,
8401
- name: repoName
8348
+ name: remote.slug
8402
8349
  };
8403
8350
  }
8404
8351
  const manifest = await loadTasksConfig(configDir);
@@ -8418,24 +8365,6 @@ async function readPackageJsonName(dir) {
8418
8365
  return;
8419
8366
  }
8420
8367
  }
8421
- async function readGitRemote(dir) {
8422
- try {
8423
- const { stdout } = await execFileAsync("git", [
8424
- "remote",
8425
- "get-url",
8426
- "origin"
8427
- ], { cwd: dir });
8428
- return parseGitRemoteUrl(stdout.trim());
8429
- } catch {
8430
- return;
8431
- }
8432
- }
8433
- function parseGitRemoteUrl(url) {
8434
- const httpsMatch = url.match(/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/);
8435
- if (httpsMatch) return httpsMatch[1];
8436
- const sshMatch = url.match(/github\.com:([^/]+\/[^/]+?)(?:\.git)?$/);
8437
- if (sshMatch) return sshMatch[1];
8438
- }
8439
8368
  //#endregion
8440
8369
  //#region src/interactive-menu.ts
8441
8370
  /**