@theholocron/cli 5.0.0-alpha.146 → 5.0.0-alpha.148

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,13 @@ 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";
23
+ import { readPackageJson } from "@theholocron/pkg-utils";
21
24
  import { access, copyFile, mkdir, readFile, readdir, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
22
25
  import { createHash, randomUUID } from "node:crypto";
26
+ import { findRootSync } from "@theholocron/fs-utils";
23
27
  import { generateReadme } from "@theholocron/components-doc/markdown";
24
28
  import { getClients, getConfigs, getDocs, getPlugins, getSkills, getThemes, getUtils } from "@theholocron/registry-doc";
25
29
  import { createGitHubClient } from "@theholocron/github-client";
@@ -27,7 +31,6 @@ import { PostHogSink } from "@theholocron/observability/analytics";
27
31
  import { SentrySink } from "@theholocron/observability/errors";
28
32
  import load from "@commitlint/load";
29
33
  import lint from "@commitlint/lint";
30
- import { promisify } from "node:util";
31
34
  import { ConfigFileError, loadConfigFile } from "@theholocron/datapad";
32
35
  import search from "@inquirer/search";
33
36
  //#region src/env.ts
@@ -2275,19 +2278,21 @@ async function runNew(input) {
2275
2278
  const filesPatched = patchFiles(repoDir, variants, input.description, input.homepage, input.runtimeEnvironment, print, readFn, writeFn, walkFn);
2276
2279
  print(` ${filesPatched.length} file${filesPatched.length === 1 ? "" : "s"} patched`);
2277
2280
  if (input.description !== void 0 || input.homepage !== void 0) try {
2278
- const pkg = JSON.parse(readFn(pkgJsonPath));
2281
+ const raw = readFn(pkgJsonPath);
2282
+ const pkg = JSON.parse(raw);
2279
2283
  if (input.description !== void 0) pkg["description"] = input.description;
2280
2284
  if (input.homepage !== void 0) pkg["homepage"] = input.homepage;
2281
- writeFn(pkgJsonPath, JSON.stringify(pkg, null, 2) + "\n");
2285
+ writeFn(pkgJsonPath, stringifyJson(pkg, { like: raw }));
2282
2286
  print(` ✓ package.json (description/homepage)`);
2283
2287
  } catch {}
2284
2288
  const displayName = input.name.split("-").map(cap).join(" ");
2285
2289
  const rootTsconfigPath = path.join(repoDir, "tsconfig.json");
2286
2290
  try {
2287
- const tsconfig = JSON.parse(readFn(rootTsconfigPath));
2291
+ const rawTsconfig = readFn(rootTsconfigPath);
2292
+ const tsconfig = JSON.parse(rawTsconfig);
2288
2293
  if (typeof tsconfig["display"] === "string") {
2289
2294
  tsconfig["display"] = displayName;
2290
- writeFn(rootTsconfigPath, JSON.stringify(tsconfig, null, 2) + "\n");
2295
+ writeFn(rootTsconfigPath, stringifyJson(tsconfig, { like: rawTsconfig }));
2291
2296
  print(` ✓ tsconfig.json (display)`);
2292
2297
  }
2293
2298
  } catch {}
@@ -2407,8 +2412,10 @@ async function runNpmBumpVersions(input) {
2407
2412
  }, "npm bump-versions: start");
2408
2413
  function bumpFile(absPath, label) {
2409
2414
  let pkg;
2415
+ let raw;
2410
2416
  try {
2411
- pkg = JSON.parse(readFile(absPath));
2417
+ raw = readFile(absPath);
2418
+ pkg = JSON.parse(raw);
2412
2419
  } catch {
2413
2420
  print(` ✗ could not parse ${label}`);
2414
2421
  return;
@@ -2416,7 +2423,7 @@ async function runNpmBumpVersions(input) {
2416
2423
  const old = pkg.version;
2417
2424
  if (!dryRun) {
2418
2425
  pkg.version = version;
2419
- writeFile(absPath, JSON.stringify(pkg, null, 2) + "\n");
2426
+ writeFile(absPath, stringifyJson(pkg, { like: raw }));
2420
2427
  }
2421
2428
  print(` ${dryRun ? "~" : "✓"} ${label}: ${old} → ${version}`);
2422
2429
  bumped.push(label);
@@ -4154,13 +4161,11 @@ function namesInWorkspaceDir(cwd, dirName) {
4154
4161
  });
4155
4162
  }
4156
4163
  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";
4164
+ return (await readGitRemote(cwd, { exec: async (args, options) => {
4165
+ const result = await exec("git", args, options);
4166
+ if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} exited ${result.exitCode}`);
4167
+ return result.stdout;
4168
+ } }))?.repo ?? "unknown";
4164
4169
  }
4165
4170
  function printNextSteps(print, env, packageNames, repoName) {
4166
4171
  print("");
@@ -4550,10 +4555,10 @@ function deriveCompliance(capabilities) {
4550
4555
  */
4551
4556
  async function readWorkspacePackageJsons(repoRoot, workspacePackages) {
4552
4557
  const results = [];
4553
- for (const { slug, dir = "packages" } of workspacePackages) try {
4554
- const raw = await readFile(join(repoRoot, dir, slug, "package.json"), "utf8");
4555
- results.push(JSON.parse(raw));
4556
- } catch {}
4558
+ for (const { slug, dir = "packages" } of workspacePackages) {
4559
+ const pkg = await readPackageJson(join(repoRoot, dir, slug));
4560
+ if (pkg) results.push(pkg);
4561
+ }
4557
4562
  return results;
4558
4563
  }
4559
4564
  //#endregion
@@ -4710,7 +4715,7 @@ var alexrc_default = { allow: [
4710
4715
  //#region src/templates/configs/alexjs/create-config.ts
4711
4716
  const { workflowHeader: workflowHeader$9 } = createHeader({ source: "packages/cli/src/templates/configs/alexjs/create-config.ts" });
4712
4717
  function createRcConfig() {
4713
- return JSON.stringify(alexrc_default, null, 2) + "\n";
4718
+ return stringifyJson(alexrc_default);
4714
4719
  }
4715
4720
  function createIgnoreConfig$1() {
4716
4721
  return `${workflowHeader$9()}${alexignore_default}`;
@@ -4794,7 +4799,7 @@ var editorconfig_checker_default = {
4794
4799
  * buys nothing.
4795
4800
  */
4796
4801
  function createConfig$4() {
4797
- return JSON.stringify(editorconfig_checker_default, null, 2) + "\n";
4802
+ return stringifyJson(editorconfig_checker_default);
4798
4803
  }
4799
4804
  //#endregion
4800
4805
  //#region src/templates/configs/pre-commit/pre-commit
@@ -5264,21 +5269,11 @@ async function installAgentPrompts({ repoRoot }) {
5264
5269
  const promptsDir = join(repoRoot, AGENTS_PROMPTS_ROOT);
5265
5270
  await mkdir(promptsDir, { recursive: true });
5266
5271
  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");
5272
+ await updateGitignore(join(repoRoot, ".gitignore"), {
5273
+ start: PROMPTS_GITIGNORE_START,
5274
+ end: PROMPTS_GITIGNORE_END,
5275
+ lines: [`/${AGENTS_PROMPTS_ROOT}/`]
5276
+ });
5282
5277
  return `wrote ${Object.keys(AGENT_PROMPTS).length} prompt files to ${AGENTS_PROMPTS_ROOT}/`;
5283
5278
  }
5284
5279
  //#endregion
@@ -5548,8 +5543,7 @@ async function installSkills({ agent, skills, repoRoot }) {
5548
5543
  }
5549
5544
  }
5550
5545
  const gitignorePath = join(repoRoot, ".gitignore");
5551
- const existingContent = await readFile(gitignorePath, "utf8").catch(() => "");
5552
- const previouslyInstalled = parsePreviousSkills(existingContent, symlinkFn);
5546
+ const previouslyInstalled = parsePreviousSkills(await readFile(gitignorePath, "utf8").catch(() => ""), symlinkFn);
5553
5547
  const currentSet = new Set(skills);
5554
5548
  const stale = previouslyInstalled.filter((n) => !currentSet.has(n));
5555
5549
  for (const name of stale) {
@@ -5609,7 +5603,7 @@ async function installSkills({ agent, skills, repoRoot }) {
5609
5603
  }
5610
5604
  }
5611
5605
  }
5612
- if (installed.length > 0 || stale.length > 0 || missing.length > 0 || externalFailed.length > 0) await updateSkillsGitignore(gitignorePath, existingContent, [
5606
+ if (installed.length > 0 || stale.length > 0 || missing.length > 0 || externalFailed.length > 0) await updateSkillsGitignore(gitignorePath, [
5613
5607
  ...installed,
5614
5608
  ...missing,
5615
5609
  ...externalFailed
@@ -5621,14 +5615,24 @@ async function installSkills({ agent, skills, repoRoot }) {
5621
5615
  if (missing.length > 0) parts.push(`unknown: ${missing.join(", ")}`);
5622
5616
  return parts.join("; ");
5623
5617
  }
5618
+ /**
5619
+ * The skill names a previous `holocron setup` recorded in the `.gitignore`
5620
+ * managed block. A block missing its end marker (hand-edited, truncated) runs
5621
+ * only to the next blank line — the same rule `updateGitignore` uses to
5622
+ * replace it — so user-written ignore rules further down are never mistaken
5623
+ * for installed skills and pruned.
5624
+ */
5624
5625
  function parsePreviousSkills(gitignoreContent, symlinkFn) {
5625
- if (!gitignoreContent.includes(GITIGNORE_BLOCK_START)) return [];
5626
5626
  const startIdx = gitignoreContent.indexOf(GITIGNORE_BLOCK_START);
5627
+ if (startIdx === -1) return [];
5627
5628
  const endIdx = gitignoreContent.indexOf(GITIGNORE_BLOCK_END, startIdx);
5628
- const block = endIdx !== -1 ? gitignoreContent.slice(startIdx, endIdx) : gitignoreContent.slice(startIdx);
5629
+ const blankAt = /\r?\n[ \t]*\r?\n/.exec(gitignoreContent.slice(startIdx));
5630
+ const blankIdx = blankAt ? startIdx + blankAt.index : -1;
5631
+ const blockEnd = endIdx !== -1 && (blankIdx === -1 || endIdx < blankIdx) ? endIdx : blankIdx === -1 ? gitignoreContent.length : blankIdx;
5632
+ const block = gitignoreContent.slice(startIdx, blockEnd);
5629
5633
  const placeholder = "__placeholder__";
5630
5634
  const symlinkPrefix = `/${symlinkFn(placeholder)}`.replace(placeholder, "");
5631
- return block.split("\n").filter((line) => line.startsWith(symlinkPrefix)).map((line) => line.slice(symlinkPrefix.length));
5635
+ return block.split(/\r?\n/).filter((line) => line.startsWith(symlinkPrefix)).map((line) => line.slice(symlinkPrefix.length));
5632
5636
  }
5633
5637
  async function copyDirRecursive(src, dest) {
5634
5638
  await mkdir(dest, { recursive: true });
@@ -5640,21 +5644,12 @@ async function copyDirRecursive(src, dest) {
5640
5644
  else await copyFile(srcPath, destPath);
5641
5645
  }
5642
5646
  }
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");
5647
+ async function updateSkillsGitignore(gitignorePath, skills, symlinkFn) {
5648
+ await updateGitignore(gitignorePath, {
5649
+ start: GITIGNORE_BLOCK_START,
5650
+ end: GITIGNORE_BLOCK_END,
5651
+ lines: [`/${AGENTS_SKILLS_ROOT}/`, ...skills.map((n) => `/${symlinkFn(n)}`)]
5652
+ });
5658
5653
  }
5659
5654
  //#endregion
5660
5655
  //#region src/commands/setup/run-setup.ts
@@ -6359,7 +6354,7 @@ async function ensurePrepareScript(repoRoot) {
6359
6354
  if (/(^|[\s&|;])husky($|[\s&|;])/.test(current)) return false;
6360
6355
  scripts.prepare = current ? `${current} && husky` : "husky";
6361
6356
  pkg.scripts = scripts;
6362
- await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
6357
+ await writeFile(pkgPath, stringifyJson(pkg, { like: content }), "utf8");
6363
6358
  return true;
6364
6359
  }
6365
6360
  //#endregion
@@ -6462,55 +6457,6 @@ function runSkillsUpdate(input) {
6462
6457
  return { status: exitCode === 0 ? "ok" : "fail" };
6463
6458
  }
6464
6459
  //#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
6460
  //#region src/commands/sync-readme.ts
6515
6461
  const MARKER_INSTALL_START = "<!-- holocron:installation -->";
6516
6462
  const MARKER_INSTALL_END = "<!-- /holocron:installation -->";
@@ -6917,7 +6863,10 @@ async function runSync(input) {
6917
6863
  const dryRun = input.context.dryRun ?? false;
6918
6864
  const requestedSteps = input.steps;
6919
6865
  const steps = [];
6920
- const gitRoot = findGitRoot(input.context.repoRoot);
6866
+ const gitRoot = findRootSync(input.context.repoRoot, ".git", {
6867
+ fallback: "start",
6868
+ realpath: true
6869
+ });
6921
6870
  let resolvedRepoRoot;
6922
6871
  try {
6923
6872
  resolvedRepoRoot = realpathSync(input.context.repoRoot);
@@ -7365,7 +7314,7 @@ async function writePackageJsonField(repoRoot, field, value) {
7365
7314
  }
7366
7315
  const pkg = JSON.parse(content);
7367
7316
  pkg[field] = value;
7368
- await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
7317
+ await writeFile(pkgPath, stringifyJson(pkg, { like: content }), "utf8");
7369
7318
  return true;
7370
7319
  }
7371
7320
  /**
@@ -7407,7 +7356,7 @@ async function mergePackageJsonScripts(repoRoot, desired, turboEligible) {
7407
7356
  }
7408
7357
  if (changed.length === 0) return [];
7409
7358
  pkg.scripts = scripts;
7410
- await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
7359
+ await writeFile(pkgPath, stringifyJson(pkg, { like: content }), "utf8");
7411
7360
  return changed.sort();
7412
7361
  }
7413
7362
  /**
@@ -8146,7 +8095,7 @@ function patchPackageJson(content, from, to) {
8146
8095
  changed = true;
8147
8096
  }
8148
8097
  }
8149
- return changed ? JSON.stringify(pkg, null, 2) + "\n" : null;
8098
+ return changed ? stringifyJson(pkg, { like: content }) : null;
8150
8099
  }
8151
8100
  function patchYaml(content, from, to) {
8152
8101
  const updated = content.replace(/node-version:\s+['"]?(\d+)['"]?/g, (match, ver) => ver === String(from) ? match.replace(String(from), String(to)) : match);
@@ -8369,7 +8318,6 @@ async function lintCommitMsgFile(filePath, cwd = process.cwd()) {
8369
8318
  * Probe order is TS-first (`.ts` → `.js` → `.mjs` → `.cjs` → `.json`) —
8370
8319
  * see ADR-0010 / issue #75 / #81.
8371
8320
  */
8372
- const execFileAsync = promisify(execFile);
8373
8321
  /**
8374
8322
  * Read + parse + resolve `holocron.config.*` starting from the given
8375
8323
  * directory, walking up through ancestors to the nearest `.git` boundary
@@ -8395,10 +8343,10 @@ async function deriveDefaults(configDir, raw) {
8395
8343
  const result = { ...raw };
8396
8344
  if (!result.name) result.name = await readPackageJsonName(configDir) ?? basename(configDir);
8397
8345
  if (result.repo && !result.repo.name) {
8398
- const repoName = await readGitRemote(configDir);
8399
- if (repoName) result.repo = {
8346
+ const remote = await readGitRemote(configDir);
8347
+ if (remote?.host === "github.com") result.repo = {
8400
8348
  ...result.repo,
8401
- name: repoName
8349
+ name: remote.slug
8402
8350
  };
8403
8351
  }
8404
8352
  const manifest = await loadTasksConfig(configDir);
@@ -8410,31 +8358,8 @@ async function deriveDefaults(configDir, raw) {
8410
8358
  return result;
8411
8359
  }
8412
8360
  async function readPackageJsonName(dir) {
8413
- try {
8414
- const content = await readFile(join(dir, "package.json"), "utf8");
8415
- const pkg = JSON.parse(content);
8416
- return typeof pkg.name === "string" ? pkg.name.replace(/^@[^/]+\//, "") : void 0;
8417
- } catch {
8418
- return;
8419
- }
8420
- }
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];
8361
+ const name = (await readPackageJson(dir))?.name;
8362
+ return name === void 0 ? void 0 : name.replace(/^@[^/]+\//, "");
8438
8363
  }
8439
8364
  //#endregion
8440
8365
  //#region src/interactive-menu.ts