@theholocron/cli 3.49.0 → 3.50.0

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
@@ -4895,8 +4895,8 @@ function deriveBasepath(domain, repoName) {
4895
4895
  }
4896
4896
  return repoName;
4897
4897
  }
4898
- function titleCase(s) {
4899
- return s.charAt(0).toUpperCase() + s.slice(1);
4898
+ function toNavLabel(basepath) {
4899
+ return basepath.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
4900
4900
  }
4901
4901
  function extractFromJson(raw, repoName) {
4902
4902
  let config;
@@ -4909,40 +4909,31 @@ function extractFromJson(raw, repoName) {
4909
4909
  if (!providers?.wiki) return null;
4910
4910
  const wikiEntry = providers.wiki;
4911
4911
  let domain;
4912
- let subtitle;
4913
- let icon;
4914
4912
  if (Array.isArray(wikiEntry) && wikiEntry.length === 2) {
4915
4913
  const opts = wikiEntry[1];
4916
4914
  domain = typeof opts.domain === "string" ? opts.domain : void 0;
4917
- subtitle = typeof opts.subtitle === "string" ? opts.subtitle : void 0;
4918
- icon = typeof opts.icon === "string" ? opts.icon : void 0;
4919
4915
  }
4920
- if (!subtitle && typeof config.description === "string") subtitle = config.description;
4921
4916
  const basepath = deriveBasepath(domain, repoName);
4922
4917
  return {
4923
- displayName: titleCase(basepath),
4918
+ displayName: toNavLabel(basepath),
4924
4919
  basepath,
4925
- ...subtitle ? { subtitle } : {},
4926
- ...icon ? { icon } : {}
4920
+ ...domain ? { domain } : {}
4927
4921
  };
4928
4922
  }
4929
4923
  function extractFromTs(raw, repoName) {
4930
4924
  const hasWikiProvider = /providers\s*:\s*\{[^}]*\bwiki\b/s.test(raw);
4931
4925
  const hasWikiPreset = /\bwikiCapability\b|\bwiki\s*\(\s*\)/.test(raw);
4926
+ /* c8 ignore next */
4932
4927
  if (!hasWikiProvider && !hasWikiPreset) return null;
4933
4928
  const domain = raw.match(/\bdomain\s*:\s*["']([^"']+)["']/)?.[1];
4934
- let subtitle = raw.match(/\bsubtitle\s*:\s*["']([^"']+)["']/)?.[1];
4935
- const icon = raw.match(/\bicon\s*:\s*["']([^"']+)["']/)?.[1];
4936
- if (!subtitle) subtitle = raw.match(/\bdescription\s*:\s*["']([^"']+)["']/)?.[1];
4937
4929
  const basepath = deriveBasepath(domain, repoName);
4938
4930
  return {
4939
- displayName: titleCase(basepath),
4931
+ displayName: toNavLabel(basepath),
4940
4932
  basepath,
4941
- ...subtitle ? { subtitle } : {},
4942
- ...icon ? { icon } : {}
4933
+ ...domain ? { domain } : {}
4943
4934
  };
4944
4935
  }
4945
- async function discoverWikiProducts(org, token, fetchFn) {
4936
+ async function discoverWikiRepos(org, token, fetchFn) {
4946
4937
  const rest = createRestClient({
4947
4938
  baseUrl: "https://api.github.com",
4948
4939
  token,
@@ -4965,52 +4956,81 @@ async function discoverWikiProducts(org, token, fetchFn) {
4965
4956
  if (batch.length < 100) break;
4966
4957
  page++;
4967
4958
  }
4968
- const products = [];
4959
+ const repos = [];
4969
4960
  for (const repo of allRepos.filter((r) => !r.archived)) {
4970
- let product = null;
4961
+ let found = null;
4971
4962
  try {
4972
4963
  const contents = await rest.request(`/repos/${repo.full_name}/contents/holocron.config.json`);
4973
- if (contents.encoding === "base64") product = extractFromJson(Buffer.from(contents.content.replace(/\s/g, ""), "base64").toString("utf8"), repo.name);
4964
+ if (contents.encoding === "base64") found = extractFromJson(Buffer.from(contents.content.replace(/\s/g, ""), "base64").toString("utf8"), repo.name);
4974
4965
  } catch {}
4975
- if (!product) try {
4966
+ if (!found) try {
4976
4967
  const contents = await rest.request(`/repos/${repo.full_name}/contents/holocron.config.ts`);
4977
- if (contents.encoding === "base64") product = extractFromTs(Buffer.from(contents.content.replace(/\s/g, ""), "base64").toString("utf8"), repo.name);
4968
+ if (contents.encoding === "base64") found = extractFromTs(Buffer.from(contents.content.replace(/\s/g, ""), "base64").toString("utf8"), repo.name);
4978
4969
  } catch {}
4979
- if (product) products.push(product);
4970
+ if (found) repos.push(found);
4980
4971
  }
4981
- products.sort((a, b) => a.basepath.localeCompare(b.basepath));
4982
- return products;
4972
+ repos.sort((a, b) => a.basepath.localeCompare(b.basepath));
4973
+ return repos;
4983
4974
  }
4984
- function buildProductsBlock(products) {
4985
- const lines = ["products:"];
4986
- for (const p of products) {
4987
- lines.push(` - display-name: ${p.displayName}`);
4988
- if (p.subtitle) lines.push(` subtitle: ${p.subtitle}`);
4989
- if (p.icon) lines.push(` icon: ${p.icon}`);
4990
- lines.push(` href: /${p.basepath}`);
4975
+ /**
4976
+ * Builds the complete navbar-links YAML block for a given repo.
4977
+ *
4978
+ * Includes:
4979
+ * - A GitHub button linking to the repo itself (always first)
4980
+ * - Minimal nav links to every other wiki-enabled repo (excluding self)
4981
+ *
4982
+ * The block is replaced wholesale on each sync so removed wikis disappear
4983
+ * and new ones appear automatically.
4984
+ */
4985
+ function buildNavbarLinks(repos, currentBasepath, repoFullName) {
4986
+ const lines = [
4987
+ `navbar-links:`,
4988
+ ` - type: github`,
4989
+ ` value: https://github.com/${repoFullName}`
4990
+ ];
4991
+ for (const repo of repos) {
4992
+ if (repo.basepath === currentBasepath) continue;
4993
+ const url = repo.domain ? `https://${repo.domain}` : `https://wiki.theholocron.dev/${repo.basepath}`;
4994
+ lines.push(` - type: minimal`, ` value: ${url}`, ` label: ${repo.displayName}`);
4991
4995
  }
4992
4996
  return lines.join("\n");
4993
4997
  }
4994
- async function mergeProducts(docsYmlPath, products) {
4998
+ /**
4999
+ * Idempotently ensures `edit-this-page:` (inside instances) is present and
5000
+ * replaces `navbar-links:` with the provided shared block.
5001
+ *
5002
+ * Returns true when the file was modified.
5003
+ * Throws when fern/docs.yml does not exist.
5004
+ */
5005
+ async function mergeWikiConfig(docsYmlPath, config) {
4995
5006
  let content;
4996
5007
  try {
4997
5008
  content = await readFile(docsYmlPath, "utf8");
4998
5009
  } catch {
4999
5010
  throw new Error(`fern/docs.yml not found at ${docsYmlPath}`);
5000
5011
  }
5001
- const newBlock = buildProductsBlock(products);
5002
- const productBlockRe = /^products:(?:\n[ \t][^\n]*)*/m;
5003
- if (productBlockRe.test(content)) {
5004
- const updated = content.replace(productBlockRe, newBlock);
5005
- if (updated !== content) await writeFile(docsYmlPath, updated, "utf8");
5006
- return;
5007
- }
5008
- const instancesIdx = content.indexOf("\ninstances:");
5009
- if (instancesIdx !== -1) {
5010
- await writeFile(docsYmlPath, content.slice(0, instancesIdx + 1) + newBlock + "\n\n" + content.slice(instancesIdx + 1), "utf8");
5011
- return;
5012
+ let updated = content;
5013
+ if (!updated.includes("edit-this-page:")) {
5014
+ const editBlock = [
5015
+ ` edit-this-page:`,
5016
+ ` github:`,
5017
+ ` owner: ${config.owner}`,
5018
+ ` repo: ${config.repoName}`,
5019
+ ` branch: main`
5020
+ ].join("\n");
5021
+ if (/^ {4}multi-source: true$/m.test(updated)) updated = updated.replace(/^( {4}multi-source: true)$/m, `$1\n${editBlock}`);
5022
+ else if (/^ {4}custom-domain: .+$/m.test(updated)) updated = updated.replace(/^( {4}custom-domain: .+)$/m, `$1\n${editBlock}`);
5023
+ else updated = updated.replace(/^( {2}- url: .+)$/m, `$1\n${editBlock}`);
5024
+ }
5025
+ const navbarRe = /^navbar-links:(?:\n[ \t][^\n]*)*/m;
5026
+ if (navbarRe.test(updated)) updated = updated.replace(navbarRe, config.navbarBlock);
5027
+ else if (/^colors:/m.test(updated)) updated = updated.replace(/^(colors:)/m, `${config.navbarBlock}\n\n$1`);
5028
+ else updated = updated.trimEnd() + "\n\n" + config.navbarBlock + "\n";
5029
+ if (updated !== content) {
5030
+ await writeFile(docsYmlPath, updated, "utf8");
5031
+ return true;
5012
5032
  }
5013
- await writeFile(docsYmlPath, content.trimEnd() + "\n\n" + newBlock + "\n", "utf8");
5033
+ return false;
5014
5034
  }
5015
5035
  async function runSyncWiki(input) {
5016
5036
  const config = input.loaded.resolved;
@@ -5021,13 +5041,21 @@ async function runSyncWiki(input) {
5021
5041
  status: "skip",
5022
5042
  message: "no wiki provider configured"
5023
5043
  };
5024
- const org = config.org ?? input.context.repo?.split("/")[0];
5025
- if (!org) return {
5044
+ const rawRepo = input.context.repo ?? config.repo?.name;
5045
+ if (!rawRepo) return {
5046
+ capability: "local",
5047
+ step: "sync wiki",
5048
+ status: "skip",
5049
+ message: "no repo configured"
5050
+ };
5051
+ const [owner, repoName] = rawRepo.split("/");
5052
+ if (!owner || !repoName) return {
5026
5053
  capability: "local",
5027
5054
  step: "sync wiki",
5028
5055
  status: "skip",
5029
- message: "no org configured"
5056
+ message: "repo must be owner/name format"
5030
5057
  };
5058
+ const org = config.org ?? owner;
5031
5059
  const token = resolveToken(input);
5032
5060
  if (!token) return {
5033
5061
  capability: "local",
@@ -5042,26 +5070,24 @@ async function runSyncWiki(input) {
5042
5070
  };
5043
5071
  const docsYmlPath = join(input.context.repoRoot, "fern", "docs.yml");
5044
5072
  try {
5045
- const products = await discoverWikiProducts(org, token, input.fetch);
5046
- if (products.length === 0) return {
5047
- capability: "local",
5048
- step: "sync wiki",
5049
- status: "skip",
5050
- message: "no wiki-enabled repos found"
5051
- };
5052
- await mergeProducts(docsYmlPath, products);
5073
+ const repos = await discoverWikiRepos(org, token, input.fetch);
5074
+ const wikiOpts = config.providers.wiki?.tuple?.options;
5053
5075
  return {
5054
5076
  capability: "local",
5055
5077
  step: "sync wiki",
5056
5078
  status: "ok",
5057
- message: `${products.length} products`
5079
+ message: await mergeWikiConfig(docsYmlPath, {
5080
+ owner,
5081
+ repoName,
5082
+ navbarBlock: buildNavbarLinks(repos, deriveBasepath(typeof wikiOpts?.domain === "string" ? wikiOpts.domain : void 0, repoName), rawRepo)
5083
+ }) ? `updated (${repos.length} wiki repos discovered)` : "already up to date"
5058
5084
  };
5059
5085
  } catch (err) {
5060
5086
  return {
5061
5087
  capability: "local",
5062
5088
  step: "sync wiki",
5063
5089
  status: "fail",
5064
- message: err instanceof Error ? err.message : String(err)
5090
+ message: err instanceof Error ? err.message : /* c8 ignore next */ String(err)
5065
5091
  };
5066
5092
  }
5067
5093
  }
@@ -5509,7 +5535,7 @@ var security_default = "name: Security\n\non: # yamllint disable-line rule:truth
5509
5535
  var stale_default = "name: Stale\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n inputs:\n days-before-stale:\n description: Days of inactivity before an issue is marked stale\n type: number\n required: false\n default: 30\n days-before-close:\n description: Days of inactivity after stale label before closing\n type: number\n required: false\n default: 5\n\njobs:\n stale:\n name: Mark stale issues and pull requests\n permissions:\n contents: write\n issues: write\n pull-requests: write\n runs-on: ubuntu-latest\n timeout-minutes: 10\n steps:\n - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0\n name: Run Stale\n with:\n close-issue-message: >\n This issue was closed because it has been stalled for\n ${{ inputs.days-before-close }} days with no activity.\n days-before-close: ${{ inputs.days-before-close }}\n days-before-stale: ${{ inputs.days-before-stale }}\n exempt-all-pr-milestones: true\n stale-issue-label: wontfix\n stale-issue-message: >\n This issue is stale because it has been open ${{ inputs.days-before-stale }}\n days with no activity. Remove the stale label or comment, or this will be\n closed in ${{ inputs.days-before-close }} days.\n stale-pr-label: wontfix\n stale-pr-message: >\n This PR is stale because it has been open ${{ inputs.days-before-stale }}\n days with no activity. Remove the stale label or comment, or this will be\n closed in ${{ inputs.days-before-close }} days.\n";
5510
5536
  //#endregion
5511
5537
  //#region src/templates/workflows/sync.yml
5512
- var sync_default = "name: Sync\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n inputs:\n steps:\n description: >\n Sync steps to run (default: all). Valid values:\n labels, properties, teams, topics, keywords, description, homepage, readme, workflows.\n Pass a space-separated list to run a subset, e.g. \"readme\" or \"readme description\".\n type: string\n required: false\n secrets:\n HOLOCRON_ADMIN_TOKEN:\n description: Fine-grained PAT with admin scopes (labels, properties, teams).\n required: false\n HOLOCRON_DEPLOY_TOKEN:\n description: Fine-grained PAT for GitHub Pages configuration.\n required: false\n HOLOCRON_ISSUES_TOKEN:\n description: Fine-grained PAT for issue management.\n required: false\n HOLOCRON_ORG_TOKEN:\n description: Org-scoped fine-grained PAT for team sync and org properties.\n required: false\n HOLOCRON_READ_TOKEN:\n description: Fine-grained PAT for read-only GitHub API calls.\n required: false\n HOLOCRON_SYNC_TOKEN:\n required: false\n GH_TOKEN:\n description: >\n Generic GitHub token fallback for gh CLI calls. Used when\n HOLOCRON_SYNC_TOKEN is not set.\n required: false\n\njobs:\n sync:\n name: Sync repo from config\n runs-on: ubuntu-latest\n timeout-minutes: 10\n permissions:\n contents: write\n pull-requests: write\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout repository\n with:\n token: ${{ secrets.HOLOCRON_SYNC_TOKEN || secrets.GH_TOKEN || github.token }}\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n\n - name: Run holocron sync\n run: |\n if [ -n \"$STEPS\" ]; then\n # shellcheck disable=SC2086\n pnpm --workspace-root exec holocron sync --steps $STEPS\n else\n pnpm --workspace-root exec holocron sync\n fi\n env:\n HOLOCRON_ADMIN_TOKEN: ${{ secrets.HOLOCRON_ADMIN_TOKEN }}\n HOLOCRON_DEPLOY_TOKEN: ${{ secrets.HOLOCRON_DEPLOY_TOKEN }}\n HOLOCRON_ISSUES_TOKEN: ${{ secrets.HOLOCRON_ISSUES_TOKEN }}\n HOLOCRON_ORG_TOKEN: ${{ secrets.HOLOCRON_ORG_TOKEN }}\n HOLOCRON_READ_TOKEN: ${{ secrets.HOLOCRON_READ_TOKEN }}\n HOLOCRON_SYNC_TOKEN: ${{ secrets.HOLOCRON_SYNC_TOKEN }}\n STEPS: ${{ inputs.steps }}\n\n - name: Format generated files\n run: pnpm exec prettier --write README.md docs/src/content/docs/index.mdx 2>/dev/null || true\n\n - uses: theholocron/.github/.github/actions/auto-commit@main\n id: auto-commit\n name: Commit sync changes\n with:\n token: ${{ secrets.HOLOCRON_SYNC_TOKEN || secrets.GH_TOKEN || github.token }}\n branch: chore/auto-sync\n commit-message: \"chore: sync from holocron.config\"\n commit-options: \"--no-verify\"\n\n - name: Open PR if changes were committed\n if: steps.auto-commit.outputs.changes-detected == 'true'\n run: |\n gh pr create \\\n --title \"chore: sync README and repo metadata\" \\\n --body \"Automated sync triggered by changes to config or package files. Merge to apply.\" \\\n --base main \\\n --head chore/auto-sync \\\n || echo \"PR already open — branch updated.\"\n env:\n GH_TOKEN: ${{ secrets.HOLOCRON_SYNC_TOKEN || secrets.GH_TOKEN || github.token }}\n";
5538
+ var sync_default = "name: Sync\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n inputs:\n steps:\n description: >\n Sync steps to run (default: all). Valid values:\n labels, properties, teams, topics, keywords, description, homepage, readme, workflows, wiki.\n Pass a space-separated list to run a subset, e.g. \"readme\" or \"readme wiki\".\n type: string\n required: false\n secrets:\n HOLOCRON_ADMIN_TOKEN:\n description: Fine-grained PAT with admin scopes (labels, properties, teams).\n required: false\n HOLOCRON_DEPLOY_TOKEN:\n description: Fine-grained PAT for GitHub Pages configuration.\n required: false\n HOLOCRON_ISSUES_TOKEN:\n description: Fine-grained PAT for issue management.\n required: false\n HOLOCRON_ORG_TOKEN:\n description: Org-scoped fine-grained PAT for team sync and org properties.\n required: false\n HOLOCRON_READ_TOKEN:\n description: Fine-grained PAT for read-only GitHub API calls.\n required: false\n HOLOCRON_SYNC_TOKEN:\n required: false\n GH_TOKEN:\n description: >\n Generic GitHub token fallback for gh CLI calls. Used when\n HOLOCRON_SYNC_TOKEN is not set.\n required: false\n\njobs:\n sync:\n name: Sync repo from config\n runs-on: ubuntu-latest\n timeout-minutes: 10\n permissions:\n contents: write\n pull-requests: write\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout repository\n with:\n token: ${{ secrets.HOLOCRON_SYNC_TOKEN || secrets.GH_TOKEN || github.token }}\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n\n - name: Run holocron sync\n run: |\n if [ -n \"$STEPS\" ]; then\n # shellcheck disable=SC2086\n pnpm --workspace-root exec holocron sync --steps $STEPS\n else\n pnpm --workspace-root exec holocron sync\n fi\n env:\n HOLOCRON_ADMIN_TOKEN: ${{ secrets.HOLOCRON_ADMIN_TOKEN }}\n HOLOCRON_DEPLOY_TOKEN: ${{ secrets.HOLOCRON_DEPLOY_TOKEN }}\n HOLOCRON_ISSUES_TOKEN: ${{ secrets.HOLOCRON_ISSUES_TOKEN }}\n HOLOCRON_ORG_TOKEN: ${{ secrets.HOLOCRON_ORG_TOKEN }}\n HOLOCRON_READ_TOKEN: ${{ secrets.HOLOCRON_READ_TOKEN }}\n HOLOCRON_SYNC_TOKEN: ${{ secrets.HOLOCRON_SYNC_TOKEN }}\n STEPS: ${{ inputs.steps }}\n\n - name: Format generated files\n run: pnpm exec prettier --write README.md docs/src/content/docs/index.mdx 2>/dev/null || true\n\n - uses: theholocron/.github/.github/actions/auto-commit@main\n id: auto-commit\n name: Commit sync changes\n with:\n token: ${{ secrets.HOLOCRON_SYNC_TOKEN || secrets.GH_TOKEN || github.token }}\n branch: chore/auto-sync\n commit-message: \"chore: sync from holocron.config\"\n commit-options: \"--no-verify\"\n\n - name: Open PR if changes were committed\n if: steps.auto-commit.outputs.changes-detected == 'true'\n run: |\n gh pr create \\\n --title \"chore: sync README and repo metadata\" \\\n --body \"Automated sync triggered by changes to config or package files. Merge to apply.\" \\\n --base main \\\n --head chore/auto-sync \\\n || echo \"PR already open — branch updated.\"\n env:\n GH_TOKEN: ${{ secrets.HOLOCRON_SYNC_TOKEN || secrets.GH_TOKEN || github.token }}\n\n - name: Propagate wiki nav to all repos\n if: >-\n steps.auto-commit.outputs.changes-detected == 'true' &&\n (inputs.steps == '' || contains(inputs.steps, 'wiki'))\n run: |\n gh workflow run sync-dispatch.yml \\\n --repo theholocron/.github \\\n --field \"steps=wiki\"\n env:\n GH_TOKEN: ${{ secrets.HOLOCRON_SYNC_TOKEN || secrets.GH_TOKEN || github.token }}\n";
5513
5539
  //#endregion
5514
5540
  //#region src/templates/workflows/sync-dispatch.yml
5515
5541
  var sync_dispatch_default = "name: Sync Dispatch\n\non: # yamllint disable-line rule:truthy\n workflow_dispatch:\n inputs:\n steps:\n description: >\n Sync steps to pass to each repo's sync.yml. Default is \"readme\"\n (only README marker blocks are updated).\n type: string\n required: false\n default: readme\n\npermissions:\n contents: read\n\njobs:\n broadcast:\n name: Broadcast sync to all repos\n runs-on: ubuntu-latest\n timeout-minutes: 15\n steps:\n - name: Dispatch sync to all repos with sync.yml\n run: |\n gh api /orgs/theholocron/repos --paginate --jq '.[].name' \\\n | while IFS= read -r repo; do\n gh api \"/repos/theholocron/$repo/contents/.github/workflows/sync.yml\" --silent 2>/dev/null || continue\n echo \"Dispatching sync to theholocron/$repo\"\n gh workflow run sync.yml \\\n --repo \"theholocron/$repo\" \\\n --field \"steps=$STEPS\" \\\n || echo \"Warning: could not dispatch to theholocron/$repo — skipping\"\n done\n env:\n GH_TOKEN: ${{ secrets.HOLOCRON_SYNC_TOKEN }}\n STEPS: ${{ inputs.steps }}\n";