@homeflare/config 0.5.1 → 0.8.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.
Files changed (50) hide show
  1. package/README.md +46 -0
  2. package/bin/hooks.ts +19 -0
  3. package/dist/hooks/gates.d.ts +19 -0
  4. package/dist/hooks/gates.d.ts.map +1 -0
  5. package/dist/hooks/install.d.ts +20 -0
  6. package/dist/hooks/install.d.ts.map +1 -0
  7. package/dist/hooks/report.d.ts +33 -0
  8. package/dist/hooks/report.d.ts.map +1 -0
  9. package/dist/hooks/staged.d.ts +19 -0
  10. package/dist/hooks/staged.d.ts.map +1 -0
  11. package/dist/hooks.d.ts +15 -0
  12. package/dist/hooks.d.ts.map +1 -0
  13. package/dist/hooks.js +202 -0
  14. package/dist/hooks.js.map +14 -0
  15. package/dist/repo-shape/ci.d.ts +20 -0
  16. package/dist/repo-shape/ci.d.ts.map +1 -0
  17. package/dist/repo-shape/companions.d.ts +39 -0
  18. package/dist/repo-shape/companions.d.ts.map +1 -0
  19. package/dist/repo-shape/drift.d.ts +29 -0
  20. package/dist/repo-shape/drift.d.ts.map +1 -0
  21. package/dist/repo-shape/refresh.d.ts +18 -0
  22. package/dist/repo-shape/refresh.d.ts.map +1 -0
  23. package/dist/repo-shape/render.d.ts +39 -0
  24. package/dist/repo-shape/render.d.ts.map +1 -0
  25. package/dist/repo-shape/security.d.ts +22 -0
  26. package/dist/repo-shape/security.d.ts.map +1 -0
  27. package/dist/repo-shape/shape.d.ts +153 -0
  28. package/dist/repo-shape/shape.d.ts.map +1 -0
  29. package/dist/repo-shape/yaml.d.ts +18 -0
  30. package/dist/repo-shape/yaml.d.ts.map +1 -0
  31. package/dist/repo-shape.d.ts +43 -0
  32. package/dist/repo-shape.d.ts.map +1 -0
  33. package/dist/repo-shape.js +615 -0
  34. package/dist/repo-shape.js.map +17 -0
  35. package/docs/repo-shape.md +199 -0
  36. package/package.json +11 -1
  37. package/src/hooks/gates.ts +104 -0
  38. package/src/hooks/install.ts +95 -0
  39. package/src/hooks/report.ts +96 -0
  40. package/src/hooks/staged.ts +68 -0
  41. package/src/hooks.ts +48 -0
  42. package/src/repo-shape/ci.ts +215 -0
  43. package/src/repo-shape/companions.ts +151 -0
  44. package/src/repo-shape/drift.ts +130 -0
  45. package/src/repo-shape/refresh.ts +113 -0
  46. package/src/repo-shape/render.ts +88 -0
  47. package/src/repo-shape/security.ts +115 -0
  48. package/src/repo-shape/shape.ts +229 -0
  49. package/src/repo-shape/yaml.ts +100 -0
  50. package/src/repo-shape.ts +69 -0
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The smallest YAML writer that renders a job's steps, and nothing else.
3
+ *
4
+ * ★ NOT A YAML LIBRARY, DELIBERATELY. A general serializer would take a dependency every
5
+ * consumer of `@homeflare/config` inherits, and it would strip the comments — which in
6
+ * this house are the product. The rendered workflows are written as text with holes;
7
+ * only the step lists, whose shape varies per repository, go through here.
8
+ *
9
+ * ⚠️ Bun can PARSE YAML natively (`Bun.YAML.parse`) but does not stringify it, measured
10
+ * against Bun 1.4.0 on 2026-09-22. The tests parse what this writes with `Bun.YAML.parse`
11
+ * and compare structures, so a malformed emission fails rather than shipping.
12
+ */
13
+ import type { JobStep } from './shape.ts';
14
+
15
+ /** Two spaces per level, the house indent and GitHub's own. */
16
+ const INDENT = ' ';
17
+
18
+ function indent(depth: number): string {
19
+ return INDENT.repeat(depth);
20
+ }
21
+
22
+ /**
23
+ * ⛔ QUOTE ANYTHING YAML WOULD RE-READ AS SOMETHING ELSE. `09:00` is a sexagesimal number
24
+ * in YAML 1.1 and `on`/`no` are booleans; an unquoted value that looks like either
25
+ * reaches GitHub as the wrong type, and the symptom is a schedule that never fires
26
+ * rather than an error.
27
+ */
28
+ const PLAIN = /^[A-Za-z0-9_./][A-Za-z0-9_ ./@:+-]*$/;
29
+ const YAML_KEYWORD =
30
+ /^(y|Y|n|N|on|On|ON|no|No|NO|yes|Yes|YES|true|True|TRUE|false|False|FALSE|null|Null|NULL|~)$/;
31
+
32
+ function scalar(value: string | number): string {
33
+ if (typeof value === 'number') return String(value);
34
+ if (value === '') return "''";
35
+ const plain =
36
+ PLAIN.test(value) &&
37
+ !YAML_KEYWORD.test(value) &&
38
+ !value.includes(': ') &&
39
+ !value.includes(' #') &&
40
+ // ⚠️ A LEADING DIGIT PLUS A COLON IS A YAML 1.1 SEXAGESIMAL NUMBER, not a string:
41
+ // `09:00` would reach GitHub as an integer, and a schedule set to an integer
42
+ // simply never fires. `bun run build:web` is safe because it does not start with
43
+ // a digit, which is why this is narrower than "contains a colon".
44
+ !/^\d.*:/.test(value) &&
45
+ value.trimEnd() === value;
46
+ return plain ? value : `'${value.replaceAll("'", "''")}'`;
47
+ }
48
+
49
+ function renderMapping(
50
+ entries: Readonly<Record<string, string | number>>,
51
+ depth: number,
52
+ ): string[] {
53
+ return Object.entries(entries).map(([key, value]) => `${indent(depth)}${key}: ${scalar(value)}`);
54
+ }
55
+
56
+ /**
57
+ * ★ BLOCK SCALAR FOR EVERY MULTI-LINE `run:`. A folded or quoted form would join the
58
+ * lines, and a shell script whose `if` and `then` end up on one line is a syntax error
59
+ * at job time rather than at lint time. `|` keeps them exactly as written.
60
+ */
61
+ function renderRun(command: string, depth: number): string[] {
62
+ const lines = command.replace(/\n+$/, '').split('\n');
63
+ if (lines.length === 1) return [`${indent(depth)}run: ${scalar(lines[0] ?? '')}`];
64
+ return [`${indent(depth)}run: |`, ...lines.map((line) => `${indent(depth + 1)}${line}`)];
65
+ }
66
+
67
+ /** One step, as the lines of a `steps:` list item at `depth`. */
68
+ export function renderStep(step: JobStep, depth: number): string[] {
69
+ const lines: string[] = [];
70
+ const body =
71
+ step.uses === undefined
72
+ ? renderRun(step.run ?? '', depth + 1)
73
+ : [`${indent(depth + 1)}uses: ${step.uses}`];
74
+
75
+ // ★ `name:` then `if:` then the body, because that is the order a reader scans: what
76
+ // this step is, whether it runs, what it does. All three are ordinary mapping keys
77
+ // to GitHub, so the order is for the person reading the diff, not the parser.
78
+ const head: string[] = [];
79
+ if (step.name !== undefined) head.push(`${indent(depth + 1)}name: ${scalar(step.name)}`);
80
+ if (step.if !== undefined) head.push(`${indent(depth + 1)}if: ${scalar(step.if)}`);
81
+
82
+ // ⚠️ The first line of the item carries the dash, whichever key it turns out to be —
83
+ // an unnamed, unconditional step still inlines its `run:` or `uses:` after the dash
84
+ // and lets any block body follow at its own indent.
85
+ const [first = '', ...rest] = [...head, ...body];
86
+ lines.push(`${indent(depth)}- ${first.trimStart()}`, ...rest);
87
+
88
+ if (step.with !== undefined && Object.keys(step.with).length > 0) {
89
+ lines.push(`${indent(depth + 1)}with:`, ...renderMapping(step.with, depth + 2));
90
+ }
91
+ if (step.env !== undefined && Object.keys(step.env).length > 0) {
92
+ lines.push(`${indent(depth + 1)}env:`, ...renderMapping(step.env, depth + 2));
93
+ }
94
+ return lines;
95
+ }
96
+
97
+ /** A whole `steps:` list, already indented for a job at `depth`. */
98
+ export function renderSteps(steps: readonly JobStep[], depth: number): string {
99
+ return steps.flatMap((step) => renderStep(step, depth)).join('\n');
100
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `@homeflare/config/repo-shape` — the standard HomeFlare repository, as one declaration.
3
+ *
4
+ * A repository keeps a `repo-shape.ts` at its root:
5
+ *
6
+ * import { except, repoShapeCli, type RepoShape } from '@homeflare/config/repo-shape';
7
+ *
8
+ * export const shape: RepoShape = {
9
+ * owner: 'taslabs-net',
10
+ * repository: 'homeflare-proxmox',
11
+ * runner: 'mini',
12
+ * publishes: false,
13
+ * };
14
+ *
15
+ * if (import.meta.main) process.exit(await repoShapeCli(import.meta.dir, shape, Bun.argv.slice(2)));
16
+ *
17
+ * That one file gives the repository:
18
+ *
19
+ * · its FILES — `bun run repo-shape:refresh` writes ci.yml, security.yml,
20
+ * actionlint.yaml, dependabot.yml and the changeset config;
21
+ * · its DRIFT GATE — a `bun:test` calling `driftInRepoShape` fails on a hand edit;
22
+ * · its SETTINGS — `renderRepoShape(shape).policy` is the options object
23
+ * `@homeflare/alchemy`'s `declareRepoPolicy` takes, so the ruleset
24
+ * requires exactly the checks these workflows report.
25
+ *
26
+ * ⛔ A DEVIATION IS DECLARED OR IT FAILS. `except({ file, reason, since })` refuses an
27
+ * empty reason and refuses a reason read from a variable — the text has to be written
28
+ * at the exception, in the diff, where someone will read it. Same for `extraJob()`.
29
+ *
30
+ * ★ WHY IT LIVES IN `@homeflare/config` RATHER THAN `@homeflare/alchemy`. Measured
31
+ * 2026-09-22: `@homeflare/config` is a dependency of 13 of 14 estate repositories and
32
+ * `@homeflare/alchemy` of 4. The drift check has to run in every repository's own CI,
33
+ * including the ones with no Alchemy stack, so it belongs to the package they all
34
+ * already have — and it takes no dependency on Alchemy or Effect to get there.
35
+ */
36
+ export { renderCi, ACTIONLINT_VERSION, BUN_VERSION } from './repo-shape/ci.ts';
37
+ export {
38
+ renderActionlintConfig,
39
+ renderChangesetConfig,
40
+ renderDependabot,
41
+ } from './repo-shape/companions.ts';
42
+ export {
43
+ type DriftReport,
44
+ type Problem,
45
+ driftInRepoShape,
46
+ exceptionSummary,
47
+ REFRESH_COMMAND,
48
+ } from './repo-shape/drift.ts';
49
+ export { type RefreshResult, refreshRepoShape, repoShapeCli } from './repo-shape/refresh.ts';
50
+ export {
51
+ type RenderedRepo,
52
+ type RepoShapePolicy,
53
+ RENDERED_PATHS,
54
+ renderRepoShape,
55
+ } from './repo-shape/render.ts';
56
+ export { renderSecurity } from './repo-shape/security.ts';
57
+ export {
58
+ type ExtraJob,
59
+ type JobStep,
60
+ type RenderedPath,
61
+ type RepoRunner,
62
+ type RepoShape,
63
+ type RepoShapeException,
64
+ type Stated,
65
+ except,
66
+ extraJob,
67
+ isExcepted,
68
+ runsOn,
69
+ } from './repo-shape/shape.ts';