@theholocron/cli 4.1.0 → 4.2.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/README.md CHANGED
@@ -75,6 +75,24 @@ Additional `repo` fields recognised by `holocron setup`:
75
75
  | `repo.protection` | `"balanced" \| "strict" \| "none"` | Branch-protection preset applied by `holocron setup`. |
76
76
  | `repo.properties` | `RepoProperties` | Org-level custom property values synced to the GitHub dashboard. |
77
77
 
78
+ ### Task scripts
79
+
80
+ `holocron sync` reconciles `package.json#scripts` from the `tasks` manifest
81
+ (merge, never clobber — only the managed keys are touched):
82
+
83
+ ```ts
84
+ export default defineConfig({
85
+ tasks: ["test", "lint", "typecheck"],
86
+ // syncScripts: false, // disable the step
87
+ // holocronScript: "node packages/cli/dist/cli.mjs", // override the "holocron" entry
88
+ providers: { source: "github" },
89
+ });
90
+ ```
91
+
92
+ Writes `"holocron": "<holocronScript ?? 'holocron'>"` plus one
93
+ `"<task>": "holocron run <task>"` per runnable task. `syncScripts: false`
94
+ opts out entirely.
95
+
78
96
  ### Skills installer
79
97
 
80
98
  `holocron setup` can install shared skills from `@theholocron/skills` into the local repo:
package/dist/cli.mjs CHANGED
@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
4
  import path, { basename, dirname, join, relative, resolve } from "node:path";
5
5
  import { checkbox, input, select } from "@inquirer/prompts";
6
- import { createAstromech } from "@theholocron/astromech";
6
+ import { KNOWN_WORKFLOWS, WORKFLOW_CHECK_CONTEXTS, WORKFLOW_TEMPLATES, createAstromech, deriveDeployPaths, extractPreviewConfig, generateCombinedDeployContent, generateThinCallerContent, normalizeWorkflowWith } from "@theholocron/astromech";
7
7
  import yargs from "yargs";
8
8
  import { hideBin } from "yargs/helpers";
9
9
  import { AuthError, ProviderApiError, ProviderApiError as ProviderApiError$1, createRestClient } from "@theholocron/http-client";
@@ -280,6 +280,8 @@ function resolveConfig(raw) {
280
280
  domain: raw.domain,
281
281
  repo: raw.repo,
282
282
  tasks: raw.tasks,
283
+ syncScripts: raw.syncScripts,
284
+ holocronScript: raw.holocronScript,
283
285
  providers,
284
286
  apps: raw.apps ?? [],
285
287
  doctor: raw.doctor ?? {},
@@ -3494,262 +3496,6 @@ var dependabot_default = "version: 2\nupdates:\n - package-ecosystem: npm\n
3494
3496
  //#region src/templates/labeler.yml
3495
3497
  var labeler_default = "bug:\n - '^fix'\n\nchore:\n - '^chore(?!\\(deps)'\n\nci:\n - '^ci'\n\ndependencies:\n - '^chore\\(deps'\n\ndocumentation:\n - '^docs'\n\nenhancement:\n - '^feat'\n\nperformance:\n - '^perf'\n\nrefactor:\n - '^refactor'\n\ntest:\n - '^test'\n";
3496
3498
  //#endregion
3497
- //#region src/commands/setup-workflows/index.ts
3498
- /**
3499
- * Thin workflow wrapper templates for `holocron setup`.
3500
- *
3501
- * Each entry is a complete `.github/workflows/<name>.yml` that delegates
3502
- * to the corresponding reusable `ci-<name>.yml` in `theholocron/.github`.
3503
- * Files are overwritten on each setup run — they are generated artifacts.
3504
- */
3505
- const WORKFLOW_TEMPLATES = {
3506
- lint: "name: Lint\n\non: # yamllint disable-line rule:truthy\n push:\n branches: [main, alpha]\n pull_request:\n\nconcurrency:\n group: lint-${{ github.ref }}\n cancel-in-progress: true\n\npermissions:\n contents: write\n issues: write\n statuses: write\n\njobs:\n lint:\n name: Lint\n uses: theholocron/.github/.github/workflows/lint.yml@main\n secrets: inherit\n",
3507
- test: "name: Test\n\non: # yamllint disable-line rule:truthy\n push:\n branches: [main, alpha]\n pull_request:\n\nconcurrency:\n group: test-${{ github.ref }}\n cancel-in-progress: true\n\npermissions:\n contents: read\n id-token: write\n statuses: write\n\njobs:\n test:\n name: Test\n uses: theholocron/.github/.github/workflows/test.yml@main\n with:\n run-unit: true\n secrets: inherit\n",
3508
- typecheck: "name: Typecheck\n\non: # yamllint disable-line rule:truthy\n push:\n branches: [main, alpha]\n pull_request:\n\nconcurrency:\n group: typecheck-${{ github.ref }}\n cancel-in-progress: true\n\npermissions:\n contents: read\n\njobs:\n typecheck:\n name: Typecheck\n uses: theholocron/.github/.github/workflows/typecheck.yml@main\n secrets: inherit\n",
3509
- security: "name: Security\n\non: # yamllint disable-line rule:truthy\n push:\n branches:\n - main\n pull_request:\n branches:\n - main\n schedule:\n - cron: \"0 0 * * 1\"\n\npermissions:\n actions: read\n contents: read\n security-events: write\n\njobs:\n security:\n uses: theholocron/.github/.github/workflows/security.yml@main\n secrets: inherit\n",
3510
- preview: "name: Preview\n\non: # yamllint disable-line rule:truthy\n pull_request:\n branches: [main]\n\nconcurrency:\n group: preview-${{ github.event.pull_request.number }}\n cancel-in-progress: true\n\npermissions:\n contents: read\n deployments: write\n pull-requests: write\n\njobs:\n preview:\n name: Preview\n uses: theholocron/.github/.github/workflows/preview.yml@main\n secrets: inherit\n",
3511
- review: "name: Review\n\non: # yamllint disable-line rule:truthy\n pull_request:\n\nconcurrency:\n group: review-${{ github.ref }}\n cancel-in-progress: true\n\npermissions:\n contents: read\n checks: write\n pull-requests: write\n\njobs:\n review:\n name: Review\n uses: theholocron/.github/.github/workflows/review.yml@main\n secrets: inherit\n",
3512
- release: "name: Release\n\non: # yamllint disable-line rule:truthy\n push:\n branches:\n - main\n - alpha\n workflow_dispatch:\n inputs:\n dry_run:\n description: >\n Dry run — analyze commits and preview the release without git writes\n or publish. Push-triggered runs always run fully; this only applies\n to manual workflow_dispatch triggers.\n required: false\n default: true\n type: boolean\n\npermissions:\n contents: write\n id-token: write\n issues: write\n pull-requests: write\n\nconcurrency:\n group: ${{ github.workflow }}-${{ github.ref }}\n cancel-in-progress: false\n\njobs:\n release:\n uses: theholocron/.github/.github/workflows/release.yml@main\n with:\n dry-run: ${{ inputs.dry_run == true }}\n secrets: inherit\n",
3513
- stale: "name: Stale\n\non: # yamllint disable-line rule:truthy\n schedule:\n - cron: \"30 1 * * *\"\n\npermissions:\n contents: write\n issues: write\n pull-requests: write\n\njobs:\n stale:\n uses: theholocron/.github/.github/workflows/stale.yml@main\n with:\n exempt-issue-labels: \"in-progress,wip\"\n exempt-all-issue-milestones: true\n exempt-all-issue-projects: true\n exempt-all-pr-projects: true\n secrets: inherit\n",
3514
- sync: "name: Sync\n\non: # yamllint disable-line rule:truthy\n push:\n branches: [main]\n paths:\n - holocron.config.ts\n - package.json\n - pnpm-workspace.yaml\n workflow_dispatch:\n inputs:\n steps:\n description: \"Sync steps to run (default: all)\"\n type: string\n required: false\n\nconcurrency:\n group: sync-${{ github.ref }}\n cancel-in-progress: true\n\npermissions:\n contents: write\n pull-requests: write\n\njobs:\n sync:\n name: Sync\n uses: theholocron/.github/.github/workflows/sync.yml@main\n with:\n steps: ${{ inputs.steps }}\n secrets: inherit\n",
3515
- greetings: "name: Greetings\n\non: # yamllint disable-line rule:truthy\n pull_request:\n issues:\n\npermissions:\n issues: write\n pull-requests: write\n\njobs:\n greetings:\n uses: theholocron/.github/.github/workflows/greetings.yml@main\n secrets: inherit\n",
3516
- dependencies: "name: Dependencies\n\non: # yamllint disable-line rule:truthy\n pull_request:\n\npermissions:\n contents: write\n pull-requests: write\n\njobs:\n dependencies:\n uses: theholocron/.github/.github/workflows/dependencies.yml@main\n secrets: inherit\n",
3517
- bookkeeping: "name: Bookkeeping\n\non: # yamllint disable-line rule:truthy\n pull_request:\n types:\n - opened\n - edited\n\npermissions:\n contents: read\n issues: write\n pull-requests: write\n\njobs:\n bookkeeping:\n uses: theholocron/.github/.github/workflows/bookkeeping.yml@main\n secrets: inherit\n",
3518
- audit: "name: Audit\n\non: # yamllint disable-line rule:truthy\n push:\n branches: [main, alpha]\n pull_request:\n\npermissions:\n contents: read\n\njobs:\n audit:\n uses: theholocron/.github/.github/workflows/audit.yml@main\n secrets: inherit\n",
3519
- deploy: "name: Deploy\n\non: # yamllint disable-line rule:truthy\n push:\n branches: [main]\n workflow_dispatch:\n\nconcurrency:\n group: pages\n cancel-in-progress: false\n\npermissions:\n contents: read\n pages: write\n id-token: write\n\njobs:\n deploy:\n name: Deploy\n uses: theholocron/.github/.github/workflows/deploy.yml@main\n secrets: inherit\n",
3520
- wiki: "name: Wiki\n\non: # yamllint disable-line rule:truthy\n push:\n branches: [main]\n pull_request:\n branches: [main]\n\nconcurrency:\n group: ${{ github.event_name == 'pull_request' && format('wiki-preview-{0}', github.event.pull_request.number) || 'wiki' }}\n cancel-in-progress: ${{ github.event_name == 'pull_request' }}\n\npermissions:\n contents: read\n deployments: write\n\njobs:\n publish:\n name: Publish\n if: ${{ github.event_name != 'pull_request' }}\n uses: theholocron/.github/.github/workflows/wiki.yml@main\n secrets: inherit\n\n preview:\n name: Preview\n if: ${{ github.event_name == 'pull_request' }}\n uses: theholocron/.github/.github/workflows/wiki.yml@main\n with:\n preview: true\n preview-id: pr-${{ github.event.pull_request.number }}\n secrets: inherit\n"
3521
- };
3522
- const KNOWN_WORKFLOWS = new Set(Object.keys(WORKFLOW_TEMPLATES));
3523
- /**
3524
- * GitHub check context name each CI workflow produces on a PR.
3525
- *
3526
- * The format is "{caller-workflow-name} / {reusable-job-name}". The caller
3527
- * job's own `name:` field does NOT appear in the external check name — only
3528
- * the calling workflow's top-level `name:` and the inner reusable-workflow
3529
- * job name matter. Only workflows that gate merges are listed here.
3530
- */
3531
- const WORKFLOW_CHECK_CONTEXTS = {
3532
- lint: "Lint / Lint entire codebase",
3533
- test: "Test / Run tests and collect coverage",
3534
- typecheck: "Typecheck / tsc --noEmit"
3535
- };
3536
- /**
3537
- * Generate the thin caller content for a workflow, optionally injecting or
3538
- * merging `with:` overrides into the jobs block.
3539
- *
3540
- * Two strategies are used depending on the template:
3541
- * - Templates that already have a `with:` block (e.g. lint):
3542
- * the override entries are merged in, replacing existing keys and appending
3543
- * new ones.
3544
- * - Templates that end with ` secrets: inherit`: a new `with:` block is
3545
- * injected immediately before `secrets: inherit`.
3546
- * If neither pattern matches the template, a warning is emitted and the
3547
- * base template is returned unchanged.
3548
- */
3549
- function generateThinCallerContent(name, withOverrides, additionalPaths) {
3550
- const base = WORKFLOW_TEMPLATES[name];
3551
- if (!base) return "";
3552
- const yamlScalar = (v) => {
3553
- if (v === true) return "true";
3554
- if (v === false) return "false";
3555
- const s = String(v);
3556
- return s.startsWith("[") || s.startsWith("{") ? `'${s}'` : s;
3557
- };
3558
- const fmt = (k, v) => ` ${k}: ${yamlScalar(v)}`;
3559
- let result = base;
3560
- if (additionalPaths && additionalPaths.length > 0) {
3561
- const pathsBlockRe = /( {4}paths:\n)((?:[ ]{6}- [^\n]+\n)+)/;
3562
- if (pathsBlockRe.test(result)) result = result.replace(pathsBlockRe, (_, header, existing) => {
3563
- const existingPaths = new Set([...existing.matchAll(/- (.+)/g)].map((m) => m[1]));
3564
- const newEntries = additionalPaths.filter((p) => !existingPaths.has(p)).map((p) => ` - ${p}\n`).join("");
3565
- return header + existing + newEntries;
3566
- });
3567
- else {
3568
- const pathsBlock = ` paths:\n${additionalPaths.map((p) => ` - ${p}\n`).join("")}`;
3569
- result = result.replace(/( {4}branches: \[main\]\n)/, `$1${pathsBlock}`);
3570
- }
3571
- }
3572
- if (!withOverrides || Object.keys(withOverrides).length === 0) return result;
3573
- const withBlockRe = /( {4}with:\n)((?:[ ]{6}[^\n]+\n)*)/;
3574
- const existingMatch = result.match(withBlockRe);
3575
- if (existingMatch) {
3576
- const existingEntries = new Map(existingMatch[2].split("\n").filter(Boolean).map((line) => {
3577
- const m = line.match(/^ {6}([^:]+):\s*(.*)/);
3578
- return m ? [m[1].trim(), m[2].trim()] : null;
3579
- }).filter((e) => e !== null));
3580
- for (const [k, v] of Object.entries(withOverrides)) existingEntries.set(k, yamlScalar(v));
3581
- const merged = [...existingEntries.entries()].map(([k, v]) => ` ${k}: ${v}`).join("\n");
3582
- return result.replace(withBlockRe, ` with:\n${merged}\n`);
3583
- }
3584
- const withBlock = Object.entries(withOverrides).map(([k, v]) => fmt(k, v)).join("\n");
3585
- const injected = result.replace(/ {4}secrets: inherit\n$/, ` with:\n${withBlock}\n secrets: inherit\n`);
3586
- if (injected === result) getLogger().warn({ template: name }, "generateThinCallerContent: could not inject `with:` overrides");
3587
- return injected;
3588
- }
3589
- /**
3590
- * Extract the Cloudflare Pages preview config from a deploy workflow's `with:` object.
3591
- *
3592
- * Accepts three forms:
3593
- * - `preview: true` — derive both project and domain from org context
3594
- * - `preview: { project: "..." }` — explicit project; domain derived from context if omitted
3595
- * - `preview: { project: "...", domain: "..." }` — fully explicit
3596
- *
3597
- * Returns null when `preview:` is absent, false, or can't be resolved.
3598
- */
3599
- function extractPreviewConfig(raw, ctx = {}) {
3600
- const preview = raw["preview"];
3601
- if (!preview) return null;
3602
- if (preview === true) {
3603
- const project = ctx.org ? `${ctx.org}-preview` : null;
3604
- const domain = ctx.domain ? `preview.${ctx.domain}` : void 0;
3605
- if (!project) return null;
3606
- return {
3607
- project,
3608
- ...domain ? { domain } : {}
3609
- };
3610
- }
3611
- if (typeof preview !== "object") return null;
3612
- const p = preview;
3613
- const project = typeof p["project"] === "string" && p["project"] ? p["project"] : ctx.org ? `${ctx.org}-preview` : null;
3614
- if (!project) return null;
3615
- const domain = typeof p["domain"] === "string" && p["domain"] ? p["domain"] : ctx.domain ? `preview.${ctx.domain}` : void 0;
3616
- return {
3617
- project,
3618
- ...domain ? { domain } : {}
3619
- };
3620
- }
3621
- /**
3622
- * Generate the full thin-caller YAML for a `deploy.yml` that handles both
3623
- * production (push to main → GitHub Pages) and preview (pull_request →
3624
- * Cloudflare Pages) in a single file.
3625
- *
3626
- * Both jobs receive the same docs/storybook `with:` inputs. If the per-repo
3627
- * config supplies `cloudflare-project` it is forwarded; otherwise the reusable
3628
- * falls back to the `CLOUDFLARE_PAGES_PROJECT` org variable — set that once and
3629
- * all repos with a `deploy` workflow get previews without per-repo config.
3630
- */
3631
- function generateCombinedDeployContent(deployWith, paths, preview) {
3632
- const yamlScalar = (v) => {
3633
- if (v === true) return "true";
3634
- if (v === false) return "false";
3635
- const s = String(v);
3636
- return s.startsWith("[") || s.startsWith("{") ? `'${s}'` : s;
3637
- };
3638
- const withLines = (entries) => Object.entries(entries).map(([k, v]) => ` ${k}: ${yamlScalar(v)}`).join("\n");
3639
- const pathsBlock = paths.length > 0 ? ` paths:\n${paths.map((p) => ` - ${p}\n`).join("")}` : "";
3640
- const previewWith = {
3641
- ...deployWith,
3642
- "cloudflare-project": preview.project
3643
- };
3644
- const deployWithBlock = Object.keys(deployWith).length > 0 ? ` with:\n${withLines(deployWith)}\n` : "";
3645
- const previewWithBlock = ` with:\n${withLines(previewWith)}\n`;
3646
- return [
3647
- `name: Deploy`,
3648
- ``,
3649
- `on: # yamllint disable-line rule:truthy`,
3650
- ` push:`,
3651
- ` branches: [main]`,
3652
- ...pathsBlock ? [`${pathsBlock}`] : [],
3653
- ` pull_request:`,
3654
- ` branches: [main]`,
3655
- ` types: [opened, synchronize, reopened, closed]`,
3656
- ...pathsBlock ? [`${pathsBlock}`] : [],
3657
- ` workflow_dispatch:`,
3658
- ``,
3659
- `concurrency:`,
3660
- ` group: $\{{ github.event_name == 'pull_request' && format('preview-{0}', github.event.pull_request.number) || 'pages' }}`,
3661
- ` cancel-in-progress: $\{{ github.event_name == 'pull_request' && github.event.action != 'closed' }}`,
3662
- ``,
3663
- `permissions:`,
3664
- ` contents: read`,
3665
- ` deployments: write`,
3666
- ` pages: write`,
3667
- ` id-token: write`,
3668
- ` pull-requests: write`,
3669
- ``,
3670
- `jobs:`,
3671
- ` deploy:`,
3672
- ` name: Deploy`,
3673
- ` if: \${{ github.event_name != 'pull_request' }}`,
3674
- ` uses: theholocron/.github/.github/workflows/deploy.yml@main`,
3675
- ...deployWithBlock ? [deployWithBlock.trimEnd()] : [],
3676
- ` secrets: inherit`,
3677
- ``,
3678
- ` preview:`,
3679
- ` name: Preview`,
3680
- ` if: \${{ github.event_name == 'pull_request' }}`,
3681
- ` uses: theholocron/.github/.github/workflows/preview.yml@main`,
3682
- previewWithBlock.trimEnd(),
3683
- ` secrets: inherit`,
3684
- ``
3685
- ].join("\n");
3686
- }
3687
- /**
3688
- * Expand structured with-values to flat GitHub Actions inputs before
3689
- * generating the thin caller. Handles:
3690
- * - deploy shorthand: docs/storybook → type + storybook-projects
3691
- * - preview: stripped (handled separately via extractPreviewConfig)
3692
- * - run-chromatic object → run-chromatic: true + chromatic-projects
3693
- * - plain arrays → JSON-stringified for YAML scalar quoting
3694
- *
3695
- * Used by both `holocron setup` and `sync-workflow-templates`.
3696
- */
3697
- function normalizeWorkflowWith(raw) {
3698
- const result = { ...raw };
3699
- delete result["preview"];
3700
- const hasDocs = raw["docs"] === true || raw["docs"] !== null && typeof raw["docs"] === "object";
3701
- const storybookProjects = raw["storybook"];
3702
- if (hasDocs) {
3703
- result["type"] = "docs";
3704
- delete result["docs"];
3705
- }
3706
- if (Array.isArray(storybookProjects)) {
3707
- if (!hasDocs) result["type"] = "storybook";
3708
- result["storybook-projects"] = JSON.stringify(storybookProjects.map(({ name, path = "." }) => ({
3709
- name,
3710
- workingDir: path
3711
- })));
3712
- delete result["storybook"];
3713
- }
3714
- const runChromatic = raw["run-chromatic"];
3715
- if (runChromatic !== null && typeof runChromatic === "object" && "projects" in runChromatic) {
3716
- result["run-chromatic"] = true;
3717
- const projects = runChromatic.projects.map((p) => ({
3718
- ...p,
3719
- ...Array.isArray(p.untraced) ? { untraced: p.untraced.join("\n") } : {}
3720
- }));
3721
- result["chromatic-projects"] = JSON.stringify(projects);
3722
- }
3723
- for (const [k, v] of Object.entries(result)) if (Array.isArray(v)) result[k] = JSON.stringify(v);
3724
- return result;
3725
- }
3726
- /**
3727
- * Derive on.push.paths entries from the deploy with: shorthand.
3728
- * Used by both `holocron setup` and `sync-workflow-templates`.
3729
- */
3730
- function deriveDeployPaths(raw) {
3731
- const paths = [];
3732
- const docs = raw["docs"];
3733
- if (docs === true) {
3734
- paths.push("docs/**");
3735
- paths.push("astro.config.ts");
3736
- paths.push("pnpm-workspace.yaml");
3737
- paths.push("pnpm-lock.yaml");
3738
- } else if (docs !== null && typeof docs === "object" && "path" in docs) {
3739
- const p = docs.path;
3740
- if (p && p !== ".") paths.push(`${p}/**`);
3741
- }
3742
- const storybookProjects = raw["storybook"];
3743
- if (Array.isArray(storybookProjects)) for (const s of storybookProjects) {
3744
- const p = s.path || ".";
3745
- if (p === ".") {
3746
- paths.push("src/**");
3747
- paths.push(".storybook/**");
3748
- } else paths.push(`${p}/**`);
3749
- }
3750
- return paths;
3751
- }
3752
- //#endregion
3753
3499
  //#region src/commands/setup/agent-prompts-data.ts
3754
3500
  /**
3755
3501
  * Canonical AI engineering workflow role prompts.
@@ -5580,6 +5326,7 @@ const SYNC_STEPS = [
5580
5326
  "homepage",
5581
5327
  "readme",
5582
5328
  "workflows",
5329
+ "scripts",
5583
5330
  "wiki"
5584
5331
  ];
5585
5332
  const LOCAL_STEPS = /* @__PURE__ */ new Set([
@@ -5588,6 +5335,7 @@ const LOCAL_STEPS = /* @__PURE__ */ new Set([
5588
5335
  "homepage",
5589
5336
  "readme",
5590
5337
  "workflows",
5338
+ "scripts",
5591
5339
  "wiki"
5592
5340
  ]);
5593
5341
  async function runSync(input) {
@@ -5735,6 +5483,7 @@ async function runSync(input) {
5735
5483
  "homepage",
5736
5484
  "readme",
5737
5485
  "workflows",
5486
+ "scripts",
5738
5487
  "wiki"
5739
5488
  ]) {
5740
5489
  if (requestedSteps !== void 0 && !requestedSteps.includes(stepName)) continue;
@@ -5867,6 +5616,29 @@ async function runSync(input) {
5867
5616
  print(formatSyncStep(steps[steps.length - 1]));
5868
5617
  }
5869
5618
  }
5619
+ if (stepName === "scripts") if (config.syncScripts === false) {
5620
+ steps.push({
5621
+ capability: "local",
5622
+ step: "sync scripts",
5623
+ status: "skip",
5624
+ message: "syncScripts: false"
5625
+ });
5626
+ print(formatSyncStep(steps[steps.length - 1]));
5627
+ } else {
5628
+ const tasksConfig = { tasks: config.tasks };
5629
+ if (config.holocronScript !== void 0) tasksConfig.holocronScript = config.holocronScript;
5630
+ const desired = createAstromech({
5631
+ cwd: input.context.repoRoot,
5632
+ config: tasksConfig
5633
+ }).packageScripts();
5634
+ steps.push(await runSyncStep("local", "sync scripts", dryRun, async () => {
5635
+ const changed = await mergePackageJsonScripts(input.context.repoRoot, desired);
5636
+ if (changed === null) return "no package.json";
5637
+ if (changed.length === 0) return `${Object.keys(desired).length} scripts already current`;
5638
+ return `${changed.join(", ")} set`;
5639
+ }));
5640
+ print(formatSyncStep(steps[steps.length - 1]));
5641
+ }
5870
5642
  if (stepName === "wiki") {
5871
5643
  const result = await runSyncWiki({
5872
5644
  loaded: input.loaded,
@@ -5944,6 +5716,31 @@ async function writePackageJsonField(repoRoot, field, value) {
5944
5716
  await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
5945
5717
  return true;
5946
5718
  }
5719
+ /**
5720
+ * Merge `desired` script entries into `package.json#scripts` — sets only the
5721
+ * listed keys, never touches the rest. Returns the sorted list of keys that
5722
+ * drifted (empty when all current), or `null` when there is no `package.json`.
5723
+ */
5724
+ async function mergePackageJsonScripts(repoRoot, desired) {
5725
+ const pkgPath = join(repoRoot, "package.json");
5726
+ let content;
5727
+ try {
5728
+ content = await readFile(pkgPath, "utf8");
5729
+ } catch {
5730
+ return null;
5731
+ }
5732
+ const pkg = JSON.parse(content);
5733
+ const scripts = pkg.scripts ?? {};
5734
+ const changed = [];
5735
+ for (const [name, command] of Object.entries(desired)) if (scripts[name] !== command) {
5736
+ scripts[name] = command;
5737
+ changed.push(name);
5738
+ }
5739
+ if (changed.length === 0) return [];
5740
+ pkg.scripts = scripts;
5741
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
5742
+ return changed.sort();
5743
+ }
5947
5744
  const README_DESC_START = "<!-- holocron:description -->";
5948
5745
  const README_DESC_END = "<!-- /holocron:description -->";
5949
5746
  async function updateReadmeDescription(repoRoot, description) {
@@ -7206,10 +7003,10 @@ try {
7206
7003
  dryRun: argv.dryRun,
7207
7004
  ...argv.otp ? { otp: argv.otp } : {}
7208
7005
  })).status === "fail") process.exitCode = 1;
7209
- }).demandCommand(1, "Run `holocron npm --help` to see available npm subcommands."), () => {}).command("sync [steps..]", "Sync state from config to the provider and local files (labels, properties, topics, keywords, description, homepage, readme, workflows)", (y) => y.positional("steps", {
7006
+ }).demandCommand(1, "Run `holocron npm --help` to see available npm subcommands."), () => {}).command("sync [steps..]", "Sync state from config to the provider and local files (labels, properties, teams, topics, keywords, description, homepage, readme, workflows, scripts, wiki)", (y) => y.positional("steps", {
7210
7007
  type: "string",
7211
7008
  array: true,
7212
- describe: "Steps to run: labels, properties, topics, keywords, description, homepage, readme, workflows (default: all)"
7009
+ describe: "Steps to run: labels, properties, teams, topics, keywords, description, homepage, readme, workflows, scripts, wiki (default: all)"
7213
7010
  }).option("repo", {
7214
7011
  type: "string",
7215
7012
  describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."