@theholocron/astromech 4.0.1 → 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
@@ -79,9 +79,30 @@ export default defineConfig({
79
79
  | `with` | per-repo overrides on the reusable-workflow channel |
80
80
  | `linters` (`lint` only) | explicit linter list; omitted → auto-detect |
81
81
 
82
- Nothing in `holocron run` reads the config yet — the manifest drives
83
- `holocron ci`, workflow generation, and script sync in later phases
84
- (epic #581).
82
+ Top-level keys: `syncScripts: false` disables the `package.json` script
83
+ writes entirely; `holocronScript` sets the command the synced `"holocron"`
84
+ script runs (default `"holocron"`).
85
+
86
+ ### Generated surfaces
87
+
88
+ ```ts
89
+ const astromech = createAstromech({ cwd, config, orgContext: { org, domain } });
90
+
91
+ astromech.thinCallers(); // Map<"<name>.yml", yaml> — one per templated, ci-enabled task
92
+ astromech.packageScripts(); // { holocron: "holocron", lint: "holocron run lint", … }
93
+ ```
94
+
95
+ `thinCallers()` returns the raw `.github/workflows/*.yml` content (no
96
+ generated-by header — the caller prefixes its own). `deploy` with
97
+ `preview:` shorthand produces the combined push-to-Pages / PR-to-preview
98
+ workflow. `packageScripts()` emits the `holocron` entry
99
+ (`holocronScript ?? "holocron"`) plus one `"<task>": "holocron run <task>"`
100
+ per runnable task; it skips `local: false` entries and tasks with no local
101
+ runner (`codeql`, `deploy`), and returns `{}` when `syncScripts: false` or
102
+ there is no config.
103
+
104
+ `holocron run` itself does not read the config yet — that (and
105
+ `holocron ci`) come in later phases (epic #581).
85
106
 
86
107
  ## Development
87
108
 
@@ -1,4 +1,4 @@
1
- import { i as normalizeTaskEntry, n as TaskEntry, r as TasksConfig, t as TaskConfigItem } from "../schema-4kyr9ILV.mjs";
1
+ import { i as normalizeTaskEntry, n as TaskEntry, r as TasksConfig, t as TaskConfigItem } from "../schema-DbpiBZCP.mjs";
2
2
  //#region src/config/define.d.ts
3
3
  /**
4
4
  * Typed identity helper for `astromech.config.ts`:
@@ -1,3 +1,4 @@
1
+ import { t as normalizeTaskEntry } from "../schema-Cf5dfaDO.mjs";
1
2
  import { createDefineConfig, loadConfigFile, mergeConfig } from "@theholocron/datapad";
2
3
  //#region src/config/define.ts
3
4
  /**
@@ -35,14 +36,4 @@ function coerce(value) {
35
36
  return Array.isArray(value) ? { tasks: value } : value;
36
37
  }
37
38
  //#endregion
38
- //#region src/config/schema.ts
39
- /** Normalise a `TaskConfigItem` to a full {@link TaskEntry} with defaults applied. */
40
- function normalizeTaskEntry(item) {
41
- return {
42
- ci: true,
43
- local: true,
44
- ...typeof item === "string" ? { name: item } : item
45
- };
46
- }
47
- //#endregion
48
39
  export { defineConfig, loadTasksConfig, normalizeTaskEntry };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { r as TasksConfig } from "./schema-4kyr9ILV.mjs";
1
+ import { r as TasksConfig } from "./schema-DbpiBZCP.mjs";
2
2
  //#region src/run.d.ts
3
3
  /**
4
4
  * `holocron run <task> [-- <passthrough>]` — run a registry task locally.
@@ -56,16 +56,119 @@ interface RunTaskReport {
56
56
  }
57
57
  declare function runTask(input: RunTaskInput): RunTaskReport;
58
58
  //#endregion
59
+ //#region src/thin-callers.d.ts
60
+ /**
61
+ * Workflow templates + thin-caller generation.
62
+ *
63
+ * `WORKFLOW_TEMPLATES` holds each reusable `.github/workflows/<name>.yml`
64
+ * (synced to `theholocron/.github`); `generateThinCallerContent` wraps one
65
+ * into the thin caller `holocron setup` / `holocron sync` write locally.
66
+ */
67
+ declare const WORKFLOW_TEMPLATES: Record<string, string>;
68
+ declare const KNOWN_WORKFLOWS: Set<string>;
69
+ /**
70
+ * GitHub check context name each CI workflow produces on a PR.
71
+ *
72
+ * The format is "{caller-workflow-name} / {reusable-job-name}". The caller
73
+ * job's own `name:` field does NOT appear in the external check name — only
74
+ * the calling workflow's top-level `name:` and the inner reusable-workflow
75
+ * job name matter. Only workflows that gate merges are listed here.
76
+ */
77
+ declare const WORKFLOW_CHECK_CONTEXTS: Partial<Record<string, string>>;
78
+ /**
79
+ * Generate the thin caller content for a workflow, optionally injecting or
80
+ * merging `with:` overrides into the jobs block.
81
+ *
82
+ * Two strategies are used depending on the template:
83
+ * - Templates that already have a `with:` block (e.g. lint):
84
+ * the override entries are merged in, replacing existing keys and appending
85
+ * new ones.
86
+ * - Templates that end with ` secrets: inherit`: a new `with:` block is
87
+ * injected immediately before `secrets: inherit`.
88
+ * If neither pattern matches the template, a warning is emitted and the
89
+ * base template is returned unchanged.
90
+ */
91
+ declare function generateThinCallerContent(name: string, withOverrides?: Record<string, unknown>, additionalPaths?: string[],
92
+ /** Optional sink for the "could not inject `with:`" warning. */
93
+ logger?: {
94
+ warn(obj: Record<string, unknown>, msg: string): void;
95
+ }): string;
96
+ interface PreviewConfig {
97
+ /** Cloudflare Pages project name shared across all repos for previews. */
98
+ project: string;
99
+ /**
100
+ * Base domain for preview URLs (e.g. `"preview.theholocron.dev"`).
101
+ * When set, `holocron setup` automatically provisions:
102
+ * - Cloudflare Pages custom domain `*.<domain>` on the project
103
+ * - DNS wildcard CNAME `*.<domain>` → `<project>.pages.dev`
104
+ *
105
+ * Preview URLs resolve as `<repo>-pr-<n>.<domain>`.
106
+ */
107
+ domain?: string;
108
+ }
109
+ /** Org-level context used to derive preview defaults from `preview: true`. */
110
+ interface OrgContext {
111
+ /** GitHub org name — becomes the prefix of the default project: `<org>-preview`. */
112
+ org?: string;
113
+ /** Org canonical domain — becomes `preview.<domain>` for the default preview domain. */
114
+ domain?: string;
115
+ /** Project name — injected as the `name` input to the deploy reusable when `docs: true`. */
116
+ repoName?: string;
117
+ }
118
+ /**
119
+ * Extract the Cloudflare Pages preview config from a deploy workflow's `with:` object.
120
+ *
121
+ * Accepts three forms:
122
+ * - `preview: true` — derive both project and domain from org context
123
+ * - `preview: { project: "..." }` — explicit project; domain derived from context if omitted
124
+ * - `preview: { project: "...", domain: "..." }` — fully explicit
125
+ *
126
+ * Returns null when `preview:` is absent, false, or can't be resolved.
127
+ */
128
+ declare function extractPreviewConfig(raw: Record<string, unknown>, ctx?: OrgContext): PreviewConfig | null;
129
+ /**
130
+ * Generate the full thin-caller YAML for a `deploy.yml` that handles both
131
+ * production (push to main → GitHub Pages) and preview (pull_request →
132
+ * Cloudflare Pages) in a single file.
133
+ *
134
+ * Both jobs receive the same docs/storybook `with:` inputs. If the per-repo
135
+ * config supplies `cloudflare-project` it is forwarded; otherwise the reusable
136
+ * falls back to the `CLOUDFLARE_PAGES_PROJECT` org variable — set that once and
137
+ * all repos with a `deploy` workflow get previews without per-repo config.
138
+ */
139
+ declare function generateCombinedDeployContent(deployWith: Record<string, unknown>, paths: string[], preview: Pick<PreviewConfig, "project">): string;
140
+ /**
141
+ * Expand structured with-values to flat GitHub Actions inputs before
142
+ * generating the thin caller. Handles:
143
+ * - deploy shorthand: docs/storybook → type + storybook-projects
144
+ * - preview: stripped (handled separately via extractPreviewConfig)
145
+ * - run-chromatic object → run-chromatic: true + chromatic-projects
146
+ * - plain arrays → JSON-stringified for YAML scalar quoting
147
+ *
148
+ * Used by both `holocron setup` and `sync-workflow-templates`.
149
+ */
150
+ declare function normalizeWorkflowWith(raw: Record<string, unknown>): Record<string, unknown>;
151
+ /**
152
+ * Derive on.push.paths entries from the deploy with: shorthand.
153
+ * Used by both `holocron setup` and `sync-workflow-templates`.
154
+ */
155
+ declare function deriveDeployPaths(raw: Record<string, unknown>): string[];
156
+ //#endregion
59
157
  //#region src/astromech.d.ts
60
158
  interface AstromechOptions {
61
159
  /** Repo root. */
62
160
  cwd: string;
63
161
  /**
64
162
  * The resolved task manifest. Optional for `run` (which is
65
- * filesystem-driven); later methods (`ci`, workflow generation) need it.
163
+ * filesystem-driven); `thinCallers` / `packageScripts` / `ci` need it.
66
164
  * Load it with `loadTasksConfig` from `@theholocron/astromech/config`.
67
165
  */
68
166
  config?: TasksConfig;
167
+ /**
168
+ * Org context for the `deploy` workflow's `preview:` shorthand — used to
169
+ * derive the Cloudflare Pages project / domain when they are not spelt out.
170
+ */
171
+ orgContext?: OrgContext;
69
172
  /** Structured-logging sink. Defaults to a no-op. */
70
173
  logger?: RunLogger;
71
174
  /** User-facing line printer. Defaults to `console.log`. */
@@ -88,6 +191,21 @@ interface RunOptions {
88
191
  interface Astromech {
89
192
  /** Run one task locally. */
90
193
  run(task: string, opts?: RunOptions): RunTaskReport;
194
+ /**
195
+ * The `.github/workflows/*.yml` thin callers for this repo's manifest —
196
+ * `filename` → YAML content (no generated-by header; the caller adds it).
197
+ * One entry per `config.tasks` item that has a workflow template and is
198
+ * not `ci: false`.
199
+ */
200
+ thinCallers(): Map<string, string>;
201
+ /**
202
+ * `package.json` scripts for this repo's manifest — the `"holocron"` entry
203
+ * (`config.holocronScript ?? "holocron"`) plus `"<task>": "holocron run
204
+ * <task>"` for every `config.tasks` item that is a runnable registry task
205
+ * and not `local: false`. Merge into `package.json`; never clobber. Empty
206
+ * when there is no config or `syncScripts: false`.
207
+ */
208
+ packageScripts(): Record<string, string>;
91
209
  }
92
210
  declare function createAstromech(options: AstromechOptions): Astromech;
93
211
  //#endregion
@@ -133,4 +251,4 @@ declare const TASKS: Record<string, TaskDef>;
133
251
  /** Every task name the registry knows. */
134
252
  declare const KNOWN_TASKS: Set<string>;
135
253
  //#endregion
136
- export { type Astromech, type AstromechOptions, type ExecFn, KNOWN_TASKS, type LocalRunner, type RunLogger, type RunOptions, type RunTaskInput, type RunTaskReport, TASKS, type TaskDef, createAstromech, runTask };
254
+ export { type Astromech, type AstromechOptions, type ExecFn, KNOWN_TASKS, KNOWN_WORKFLOWS, type LocalRunner, type OrgContext, type PreviewConfig, type RunLogger, type RunOptions, type RunTaskInput, type RunTaskReport, TASKS, type TaskDef, WORKFLOW_CHECK_CONTEXTS, WORKFLOW_TEMPLATES, createAstromech, deriveDeployPaths, extractPreviewConfig, generateCombinedDeployContent, generateThinCallerContent, normalizeWorkflowWith, runTask };
package/dist/index.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { t as normalizeTaskEntry } from "./schema-Cf5dfaDO.mjs";
1
2
  import { spawnSync } from "node:child_process";
2
3
  import { existsSync, readFileSync, readdirSync } from "node:fs";
3
4
  import { join } from "node:path";
@@ -199,6 +200,262 @@ function packageJsonScript(cwd, task, readFile, fileExists) {
199
200
  }
200
201
  }
201
202
  //#endregion
203
+ //#region src/thin-callers.ts
204
+ /**
205
+ * Workflow templates + thin-caller generation.
206
+ *
207
+ * `WORKFLOW_TEMPLATES` holds each reusable `.github/workflows/<name>.yml`
208
+ * (synced to `theholocron/.github`); `generateThinCallerContent` wraps one
209
+ * into the thin caller `holocron setup` / `holocron sync` write locally.
210
+ */
211
+ const WORKFLOW_TEMPLATES = {
212
+ 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",
213
+ 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",
214
+ 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",
215
+ 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",
216
+ 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",
217
+ 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",
218
+ 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",
219
+ 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",
220
+ 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",
221
+ 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",
222
+ 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",
223
+ 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",
224
+ 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",
225
+ 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",
226
+ 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"
227
+ };
228
+ const KNOWN_WORKFLOWS = new Set(Object.keys(WORKFLOW_TEMPLATES));
229
+ /**
230
+ * GitHub check context name each CI workflow produces on a PR.
231
+ *
232
+ * The format is "{caller-workflow-name} / {reusable-job-name}". The caller
233
+ * job's own `name:` field does NOT appear in the external check name — only
234
+ * the calling workflow's top-level `name:` and the inner reusable-workflow
235
+ * job name matter. Only workflows that gate merges are listed here.
236
+ */
237
+ const WORKFLOW_CHECK_CONTEXTS = {
238
+ lint: "Lint / Lint entire codebase",
239
+ test: "Test / Run tests and collect coverage",
240
+ typecheck: "Typecheck / tsc --noEmit"
241
+ };
242
+ /**
243
+ * Generate the thin caller content for a workflow, optionally injecting or
244
+ * merging `with:` overrides into the jobs block.
245
+ *
246
+ * Two strategies are used depending on the template:
247
+ * - Templates that already have a `with:` block (e.g. lint):
248
+ * the override entries are merged in, replacing existing keys and appending
249
+ * new ones.
250
+ * - Templates that end with ` secrets: inherit`: a new `with:` block is
251
+ * injected immediately before `secrets: inherit`.
252
+ * If neither pattern matches the template, a warning is emitted and the
253
+ * base template is returned unchanged.
254
+ */
255
+ function generateThinCallerContent(name, withOverrides, additionalPaths, logger) {
256
+ const base = WORKFLOW_TEMPLATES[name];
257
+ if (!base) return "";
258
+ const yamlScalar = (v) => {
259
+ if (v === true) return "true";
260
+ if (v === false) return "false";
261
+ const s = String(v);
262
+ return s.startsWith("[") || s.startsWith("{") ? `'${s}'` : s;
263
+ };
264
+ const fmt = (k, v) => ` ${k}: ${yamlScalar(v)}`;
265
+ let result = base;
266
+ if (additionalPaths && additionalPaths.length > 0) {
267
+ const pathsBlockRe = /( {4}paths:\n)((?:[ ]{6}- [^\n]+\n)+)/;
268
+ if (pathsBlockRe.test(result)) result = result.replace(pathsBlockRe, (_, header, existing) => {
269
+ const existingPaths = new Set([...existing.matchAll(/- (.+)/g)].map((m) => m[1]));
270
+ const newEntries = additionalPaths.filter((p) => !existingPaths.has(p)).map((p) => ` - ${p}\n`).join("");
271
+ return header + existing + newEntries;
272
+ });
273
+ else {
274
+ const pathsBlock = ` paths:\n${additionalPaths.map((p) => ` - ${p}\n`).join("")}`;
275
+ result = result.replace(/( {4}branches: \[main\]\n)/, `$1${pathsBlock}`);
276
+ }
277
+ }
278
+ if (!withOverrides || Object.keys(withOverrides).length === 0) return result;
279
+ const withBlockRe = /( {4}with:\n)((?:[ ]{6}[^\n]+\n)*)/;
280
+ const existingMatch = result.match(withBlockRe);
281
+ if (existingMatch) {
282
+ const existingEntries = new Map(existingMatch[2].split("\n").filter(Boolean).map((line) => {
283
+ const m = line.match(/^ {6}([^:]+):\s*(.*)/);
284
+ return m ? [m[1].trim(), m[2].trim()] : null;
285
+ }).filter((e) => e !== null));
286
+ for (const [k, v] of Object.entries(withOverrides)) existingEntries.set(k, yamlScalar(v));
287
+ const merged = [...existingEntries.entries()].map(([k, v]) => ` ${k}: ${v}`).join("\n");
288
+ return result.replace(withBlockRe, ` with:\n${merged}\n`);
289
+ }
290
+ const withBlock = Object.entries(withOverrides).map(([k, v]) => fmt(k, v)).join("\n");
291
+ const injected = result.replace(/ {4}secrets: inherit\n$/, ` with:\n${withBlock}\n secrets: inherit\n`);
292
+ if (injected === result) logger?.warn({ template: name }, "generateThinCallerContent: could not inject `with:` overrides");
293
+ return injected;
294
+ }
295
+ /**
296
+ * Extract the Cloudflare Pages preview config from a deploy workflow's `with:` object.
297
+ *
298
+ * Accepts three forms:
299
+ * - `preview: true` — derive both project and domain from org context
300
+ * - `preview: { project: "..." }` — explicit project; domain derived from context if omitted
301
+ * - `preview: { project: "...", domain: "..." }` — fully explicit
302
+ *
303
+ * Returns null when `preview:` is absent, false, or can't be resolved.
304
+ */
305
+ function extractPreviewConfig(raw, ctx = {}) {
306
+ const preview = raw["preview"];
307
+ if (!preview) return null;
308
+ if (preview === true) {
309
+ const project = ctx.org ? `${ctx.org}-preview` : null;
310
+ const domain = ctx.domain ? `preview.${ctx.domain}` : void 0;
311
+ if (!project) return null;
312
+ return {
313
+ project,
314
+ ...domain ? { domain } : {}
315
+ };
316
+ }
317
+ if (typeof preview !== "object") return null;
318
+ const p = preview;
319
+ const project = typeof p["project"] === "string" && p["project"] ? p["project"] : ctx.org ? `${ctx.org}-preview` : null;
320
+ if (!project) return null;
321
+ const domain = typeof p["domain"] === "string" && p["domain"] ? p["domain"] : ctx.domain ? `preview.${ctx.domain}` : void 0;
322
+ return {
323
+ project,
324
+ ...domain ? { domain } : {}
325
+ };
326
+ }
327
+ /**
328
+ * Generate the full thin-caller YAML for a `deploy.yml` that handles both
329
+ * production (push to main → GitHub Pages) and preview (pull_request →
330
+ * Cloudflare Pages) in a single file.
331
+ *
332
+ * Both jobs receive the same docs/storybook `with:` inputs. If the per-repo
333
+ * config supplies `cloudflare-project` it is forwarded; otherwise the reusable
334
+ * falls back to the `CLOUDFLARE_PAGES_PROJECT` org variable — set that once and
335
+ * all repos with a `deploy` workflow get previews without per-repo config.
336
+ */
337
+ function generateCombinedDeployContent(deployWith, paths, preview) {
338
+ const yamlScalar = (v) => {
339
+ if (v === true) return "true";
340
+ if (v === false) return "false";
341
+ const s = String(v);
342
+ return s.startsWith("[") || s.startsWith("{") ? `'${s}'` : s;
343
+ };
344
+ const withLines = (entries) => Object.entries(entries).map(([k, v]) => ` ${k}: ${yamlScalar(v)}`).join("\n");
345
+ const pathsBlock = paths.length > 0 ? ` paths:\n${paths.map((p) => ` - ${p}\n`).join("")}` : "";
346
+ const previewWith = {
347
+ ...deployWith,
348
+ "cloudflare-project": preview.project
349
+ };
350
+ const deployWithBlock = Object.keys(deployWith).length > 0 ? ` with:\n${withLines(deployWith)}\n` : "";
351
+ const previewWithBlock = ` with:\n${withLines(previewWith)}\n`;
352
+ return [
353
+ `name: Deploy`,
354
+ ``,
355
+ `on: # yamllint disable-line rule:truthy`,
356
+ ` push:`,
357
+ ` branches: [main]`,
358
+ ...pathsBlock ? [`${pathsBlock}`] : [],
359
+ ` pull_request:`,
360
+ ` branches: [main]`,
361
+ ` types: [opened, synchronize, reopened, closed]`,
362
+ ...pathsBlock ? [`${pathsBlock}`] : [],
363
+ ` workflow_dispatch:`,
364
+ ``,
365
+ `concurrency:`,
366
+ ` group: $\{{ github.event_name == 'pull_request' && format('preview-{0}', github.event.pull_request.number) || 'pages' }}`,
367
+ ` cancel-in-progress: $\{{ github.event_name == 'pull_request' && github.event.action != 'closed' }}`,
368
+ ``,
369
+ `permissions:`,
370
+ ` contents: read`,
371
+ ` deployments: write`,
372
+ ` pages: write`,
373
+ ` id-token: write`,
374
+ ` pull-requests: write`,
375
+ ``,
376
+ `jobs:`,
377
+ ` deploy:`,
378
+ ` name: Deploy`,
379
+ ` if: \${{ github.event_name != 'pull_request' }}`,
380
+ ` uses: theholocron/.github/.github/workflows/deploy.yml@main`,
381
+ ...deployWithBlock ? [deployWithBlock.trimEnd()] : [],
382
+ ` secrets: inherit`,
383
+ ``,
384
+ ` preview:`,
385
+ ` name: Preview`,
386
+ ` if: \${{ github.event_name == 'pull_request' }}`,
387
+ ` uses: theholocron/.github/.github/workflows/preview.yml@main`,
388
+ previewWithBlock.trimEnd(),
389
+ ` secrets: inherit`,
390
+ ``
391
+ ].join("\n");
392
+ }
393
+ /**
394
+ * Expand structured with-values to flat GitHub Actions inputs before
395
+ * generating the thin caller. Handles:
396
+ * - deploy shorthand: docs/storybook → type + storybook-projects
397
+ * - preview: stripped (handled separately via extractPreviewConfig)
398
+ * - run-chromatic object → run-chromatic: true + chromatic-projects
399
+ * - plain arrays → JSON-stringified for YAML scalar quoting
400
+ *
401
+ * Used by both `holocron setup` and `sync-workflow-templates`.
402
+ */
403
+ function normalizeWorkflowWith(raw) {
404
+ const result = { ...raw };
405
+ delete result["preview"];
406
+ const hasDocs = raw["docs"] === true || raw["docs"] !== null && typeof raw["docs"] === "object";
407
+ const storybookProjects = raw["storybook"];
408
+ if (hasDocs) {
409
+ result["type"] = "docs";
410
+ delete result["docs"];
411
+ }
412
+ if (Array.isArray(storybookProjects)) {
413
+ if (!hasDocs) result["type"] = "storybook";
414
+ result["storybook-projects"] = JSON.stringify(storybookProjects.map(({ name, path = "." }) => ({
415
+ name,
416
+ workingDir: path
417
+ })));
418
+ delete result["storybook"];
419
+ }
420
+ const runChromatic = raw["run-chromatic"];
421
+ if (runChromatic !== null && typeof runChromatic === "object" && "projects" in runChromatic) {
422
+ result["run-chromatic"] = true;
423
+ const projects = runChromatic.projects.map((p) => ({
424
+ ...p,
425
+ ...Array.isArray(p.untraced) ? { untraced: p.untraced.join("\n") } : {}
426
+ }));
427
+ result["chromatic-projects"] = JSON.stringify(projects);
428
+ }
429
+ for (const [k, v] of Object.entries(result)) if (Array.isArray(v)) result[k] = JSON.stringify(v);
430
+ return result;
431
+ }
432
+ /**
433
+ * Derive on.push.paths entries from the deploy with: shorthand.
434
+ * Used by both `holocron setup` and `sync-workflow-templates`.
435
+ */
436
+ function deriveDeployPaths(raw) {
437
+ const paths = [];
438
+ const docs = raw["docs"];
439
+ if (docs === true) {
440
+ paths.push("docs/**");
441
+ paths.push("astro.config.ts");
442
+ paths.push("pnpm-workspace.yaml");
443
+ paths.push("pnpm-lock.yaml");
444
+ } else if (docs !== null && typeof docs === "object" && "path" in docs) {
445
+ const p = docs.path;
446
+ if (p && p !== ".") paths.push(`${p}/**`);
447
+ }
448
+ const storybookProjects = raw["storybook"];
449
+ if (Array.isArray(storybookProjects)) for (const s of storybookProjects) {
450
+ const p = s.path || ".";
451
+ if (p === ".") {
452
+ paths.push("src/**");
453
+ paths.push(".storybook/**");
454
+ } else paths.push(`${p}/**`);
455
+ }
456
+ return paths;
457
+ }
458
+ //#endregion
202
459
  //#region src/astromech.ts
203
460
  /**
204
461
  * `createAstromech(options)` — the self-contained task runner.
@@ -224,14 +481,52 @@ function createAstromech(options) {
224
481
  fileExists: options.fileExists ?? ((path) => existsSync(path)),
225
482
  listDir: options.listDir ?? ((path) => readdirSync(path))
226
483
  };
227
- return { run: (task, opts = {}) => runTask({
228
- ...deps,
229
- task,
230
- cwd: options.cwd,
231
- passthrough: opts.passthrough ?? [],
232
- dryRun: opts.dryRun ?? false,
233
- required: opts.required ?? false
234
- }) };
484
+ const items = () => (options.config?.tasks ?? []).map((i) => normalizeTaskEntry(i));
485
+ return {
486
+ run: (task, opts = {}) => runTask({
487
+ ...deps,
488
+ task,
489
+ cwd: options.cwd,
490
+ passthrough: opts.passthrough ?? [],
491
+ dryRun: opts.dryRun ?? false,
492
+ required: opts.required ?? false
493
+ }),
494
+ thinCallers: () => {
495
+ const orgCtx = options.orgContext ?? {};
496
+ const out = /* @__PURE__ */ new Map();
497
+ for (const entry of items()) {
498
+ if (entry.ci === false || !KNOWN_WORKFLOWS.has(entry.name)) continue;
499
+ const rawWith = entry.with;
500
+ const normalized = rawWith ? normalizeWorkflowWith(rawWith) : void 0;
501
+ const withOverrides = entry.name === "lint" ? {
502
+ "enable-auto-commit": true,
503
+ ...normalized ?? {}
504
+ } : normalized;
505
+ const additionalPaths = entry.paths ?? (entry.name === "deploy" && rawWith ? deriveDeployPaths(rawWith) : void 0);
506
+ if (entry.name === "deploy" && rawWith) {
507
+ const preview = extractPreviewConfig(rawWith, orgCtx);
508
+ if (preview) {
509
+ const deployWith = normalizeWorkflowWith(rawWith);
510
+ const deployPaths = entry.paths ?? deriveDeployPaths(rawWith);
511
+ out.set("deploy.yml", generateCombinedDeployContent(deployWith, deployPaths, preview));
512
+ continue;
513
+ }
514
+ }
515
+ out.set(`${entry.name}.yml`, generateThinCallerContent(entry.name, withOverrides, additionalPaths, deps.logger));
516
+ }
517
+ return out;
518
+ },
519
+ packageScripts: () => {
520
+ const out = {};
521
+ if (!options.config || options.config.syncScripts === false) return out;
522
+ out.holocron = options.config.holocronScript ?? "holocron";
523
+ for (const entry of items()) {
524
+ if (entry.local === false || !KNOWN_TASKS.has(entry.name) || TASKS[entry.name]?.local === null) continue;
525
+ out[entry.name] = `holocron run ${entry.name}`;
526
+ }
527
+ return out;
528
+ }
529
+ };
235
530
  }
236
531
  //#endregion
237
- export { KNOWN_TASKS, TASKS, createAstromech, runTask };
532
+ export { KNOWN_TASKS, KNOWN_WORKFLOWS, TASKS, WORKFLOW_CHECK_CONTEXTS, WORKFLOW_TEMPLATES, createAstromech, deriveDeployPaths, extractPreviewConfig, generateCombinedDeployContent, generateThinCallerContent, normalizeWorkflowWith, runTask };
@@ -0,0 +1,11 @@
1
+ //#region src/config/schema.ts
2
+ /** Normalise a `TaskConfigItem` to a full {@link TaskEntry} with defaults applied. */
3
+ function normalizeTaskEntry(item) {
4
+ return {
5
+ ci: true,
6
+ local: true,
7
+ ...typeof item === "string" ? { name: item } : item
8
+ };
9
+ }
10
+ //#endregion
11
+ export { normalizeTaskEntry as t };
@@ -34,6 +34,8 @@ interface TaskEntry {
34
34
  * from the config files present.
35
35
  */
36
36
  linters?: string[];
37
+ /** Extra `on.push.paths` entries for the generated CI workflow. */
38
+ paths?: string[];
37
39
  }
38
40
  /** A task is either its bare name (all defaults) or an entry object. */
39
41
  type TaskConfigItem = string | TaskEntry;
@@ -42,6 +44,12 @@ interface TasksConfig {
42
44
  tasks?: TaskConfigItem[];
43
45
  /** Opt out of the `package.json` script writes. Default `true`. */
44
46
  syncScripts?: boolean;
47
+ /**
48
+ * The command the synced `"holocron"` `package.json` script runs. Default
49
+ * `"holocron"` (the installed bin). The source repo overrides it to run
50
+ * its own build, e.g. `"node packages/cli/dist/cli.mjs"`.
51
+ */
52
+ holocronScript?: string;
45
53
  /**
46
54
  * Required status-check contexts not backed by a task — DCO, semantic
47
55
  * PR title, …
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theholocron/astromech",
3
- "version": "4.0.1",
3
+ "version": "4.2.0",
4
4
  "description": "The Holocron task runner — one task manifest drives `holocron run`, `holocron ci`, the CI workflows, package.json scripts, linters, and required checks.",
5
5
  "keywords": [
6
6
  "ci",
@@ -37,7 +37,7 @@
37
37
  "dist"
38
38
  ],
39
39
  "dependencies": {
40
- "@theholocron/datapad": "4.0.1"
40
+ "@theholocron/datapad": "4.2.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@theholocron/eslint-config": "^8.0.0",
@@ -52,7 +52,8 @@
52
52
  "globals": "^17.11.0",
53
53
  "tsdown": "^0.22.14",
54
54
  "typescript": "^5.9.3",
55
- "vitest": "^4.1.11"
55
+ "vitest": "^4.1.11",
56
+ "@theholocron/rollup-plugin-transform-template": "4.2.0"
56
57
  },
57
58
  "engines": {
58
59
  "node": ">=22"