@starci/hfs 1.0.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 (74) hide show
  1. package/README.md +38 -0
  2. package/bin/hfs.mjs +100 -0
  3. package/package.json +28 -0
  4. package/runtime/engine/runtime-root.mjs +32 -0
  5. package/runtime/engine/yaml.mjs +161 -0
  6. package/runtime/knowledge/hfs/canon-pins.yaml +212 -0
  7. package/runtime/knowledge/hfs/slots.yaml +842 -0
  8. package/runtime/modules/kernel/failure-codes.yaml +169 -0
  9. package/runtime/scripts/lib/glob.mjs +23 -0
  10. package/runtime/scripts/lib/hfs-check.mjs +305 -0
  11. package/runtime/scripts/lib/hfs-slots.mjs +675 -0
  12. package/runtime/scripts/lib/path-key.mjs +15 -0
  13. package/sync/cli.mjs +15 -0
  14. package/sync/hygiene.mjs +92 -0
  15. package/sync/index.mjs +224 -0
  16. package/sync/skeleton.mjs +54 -0
  17. package/sync/sonar-key.mjs +45 -0
  18. package/templates/be/e2e.yml +21 -0
  19. package/templates/be/gitignore +2 -0
  20. package/templates/be/pre-commit +8 -0
  21. package/templates/be/skeleton/apps/__app__/src/__app__.composition.spec.ts +31 -0
  22. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +7 -0
  23. package/templates/be/skeleton/apps/__app__/src/app.module.ts +23 -0
  24. package/templates/be/skeleton/apps/__app__/src/main.ts +20 -0
  25. package/templates/be/skeleton/src/features/system-health/index.ts +1 -0
  26. package/templates/be/skeleton/src/features/system-health/system-health.module.ts +6 -0
  27. package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.spec.ts +13 -0
  28. package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +18 -0
  29. package/templates/be/skeleton/src/features/system-health/transport/http/system-health-http.module.ts +10 -0
  30. package/templates/be/skeleton/src/modules/platform/config/env-source.spec.ts +36 -0
  31. package/templates/be/skeleton/src/modules/platform/config/env-source.ts +40 -0
  32. package/templates/be/skeleton/src/modules/platform/config/errors/config.error.ts +21 -0
  33. package/templates/be/skeleton/src/modules/platform/config/index.ts +5 -0
  34. package/templates/be/skeleton/src/modules/platform/config/server.config.spec.ts +19 -0
  35. package/templates/be/skeleton/src/modules/platform/config/server.config.ts +15 -0
  36. package/templates/be/skeleton/src/modules/platform/config/server.options.ts +8 -0
  37. package/templates/be/skeleton/src/modules/platform/errors/domain-error.spec.ts +15 -0
  38. package/templates/be/skeleton/src/modules/platform/errors/domain-error.ts +11 -0
  39. package/templates/be/skeleton/src/modules/platform/errors/error.filter.spec.ts +38 -0
  40. package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +24 -0
  41. package/templates/be/skeleton/src/modules/platform/errors/index.ts +2 -0
  42. package/templates/be/skeleton/src/modules/platform/logging/index.ts +5 -0
  43. package/templates/be/skeleton/src/modules/platform/logging/json-logger.spec.ts +33 -0
  44. package/templates/be/skeleton/src/modules/platform/logging/json-logger.ts +35 -0
  45. package/templates/be/skeleton/src/modules/platform/logging/log-id.ts +9 -0
  46. package/templates/be/skeleton/src/modules/platform/logging/logger.port.ts +19 -0
  47. package/templates/be/skeleton/src/modules/platform/logging/logging.module.ts +11 -0
  48. package/templates/be/sonar-project.properties +10 -0
  49. package/templates/be/starciwork.gitignore +39 -0
  50. package/templates/common/ci.yml +50 -0
  51. package/templates/common/codecov.yml +13 -0
  52. package/templates/common/gitignore.base +33 -0
  53. package/templates/common/pre-push +5 -0
  54. package/templates/fe/e2e.yml +22 -0
  55. package/templates/fe/gitignore +3 -0
  56. package/templates/fe/pre-commit +7 -0
  57. package/templates/fe/skeleton/apps/__app__/next.config.ts +11 -0
  58. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/error.tsx +22 -0
  59. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +31 -0
  60. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +15 -0
  61. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/page.tsx +27 -0
  62. package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +24 -0
  63. package/templates/fe/skeleton/apps/__app__/src/app/globals.css +1 -0
  64. package/templates/fe/skeleton/apps/__app__/src/app/health/live/route.spec.ts +10 -0
  65. package/templates/fe/skeleton/apps/__app__/src/app/health/live/route.ts +5 -0
  66. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/config.ts +8 -0
  67. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages/vi.json +19 -0
  68. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages.spec.ts +27 -0
  69. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/navigation.ts +5 -0
  70. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/request.ts +13 -0
  71. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/routing.spec.ts +10 -0
  72. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/routing.ts +9 -0
  73. package/templates/fe/skeleton/apps/__app__/src/proxy.ts +10 -0
  74. package/templates/fe/sonar-project.properties +11 -0
@@ -0,0 +1,15 @@
1
+ // path-key.mjs — spelling and comparing paths the way this host's filesystem does.
2
+ import path from 'node:path';
3
+
4
+ const WIN = process.platform === 'win32';
5
+
6
+ /** `p` with every backslash turned into a forward slash; null and undefined read as ''. */
7
+ export const slash = (p) => String(p ?? '').replaceAll('\\', '/');
8
+ /** A relative path in git's spelling: forward slashes, no leading './'. */
9
+ export const posixPath = (p) => slash(p).replace(/^\.\//, '');
10
+ /** `p` case-folded where the filesystem ignores case (Windows). */
11
+ export const foldCase = (p) => (WIN ? p.toLowerCase() : p);
12
+ /** Whether two spellings name the same path on this host's filesystem. */
13
+ export const samePath = (a, b) => foldCase(a) === foldCase(b);
14
+ /** A path's comparable identity: absolute, forward slashes, no trailing slash, case-folded on Windows. */
15
+ export const pathKey = (p) => foldCase(slash(path.resolve(p)).replace(/\/+$/, ''));
package/sync/cli.mjs ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ // Entry for the sync-side commands: `sync` and `work-hygiene`. The @starci/hfs bin delegates here.
3
+ import { pathToFileURL } from 'node:url';
4
+ import { runSync } from './index.mjs';
5
+ import { runWorkHygiene } from './hygiene.mjs';
6
+
7
+ export async function main(argv) {
8
+ const [command, ...rest] = argv;
9
+ if (command === 'sync') return runSync(rest);
10
+ if (command === 'work-hygiene') return runWorkHygiene();
11
+ process.stdout.write('usage: hfs sync (--check | --write | --init) [--root <dir>] | hfs work-hygiene\n');
12
+ return 2;
13
+ }
14
+
15
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.exitCode = await main(process.argv.slice(2));
@@ -0,0 +1,92 @@
1
+ // hfs work-hygiene: the guard for the two trees a back end tracks besides source. A file under .starciwork must be
2
+ // product content (the .starciwork/.gitignore allowlist admits it, so agent output is refused), and a file under
3
+ // .starcistacks must not be a plaintext secret (only *.enc is sealed). The pre-commit hook judges the staged files;
4
+ // scripts/checks/check-hfs-sync.mjs judges every tracked file.
5
+ //
6
+ // When this package runs from inside its own runtime checkout (packages/hfs lives 3 directories under the repo
7
+ // root), it also reports the state-root ledger findings of scripts/lib/hk-orphan-ledgers.mjs: LEDGER_ORPHAN_STATE_ROOT
8
+ // and LEDGER_LEGACY_WORK_SQLITE (COOK-BRIEF F4 handover, incident 2026-09-30). Installed standalone in a product
9
+ // repository with no such checkout, that section is silently absent — never a crash, never a false negative claimed.
10
+ import { execFileSync } from 'node:child_process';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { fileURLToPath, pathToFileURL } from 'node:url';
14
+
15
+ const PLAINTEXT_NAME = /(^|\/)(\.env(\..*)?|[^/]*\.(pem|key|identity|age))$/;
16
+ const GUARDED = file => file.startsWith('.starciwork/') || file.startsWith('.starcistacks/');
17
+
18
+ /** The subset of `files` git ignores (as if untracked), asked in one call: a Set of paths. */
19
+ export function ignoredAmong(cwd, files) {
20
+ if (files.length === 0) return new Set();
21
+ try {
22
+ const out = execFileSync('git', ['check-ignore', '--no-index', '-z', '--stdin'], { cwd, input: files.join('\0'), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
23
+ return new Set(out.split('\0').filter(Boolean));
24
+ } catch (error) {
25
+ if (error.status === 1) return new Set();
26
+ throw error;
27
+ }
28
+ }
29
+
30
+ /** Violations among `files` (repository-relative, '/'-separated): [{ file, code, message }]. `ignored` is a Set of ignored paths. */
31
+ export function hygieneFindings(files, ignored) {
32
+ const findings = [];
33
+ for (const file of files) {
34
+ if (file.startsWith('.starciwork/') && ignored.has(file)) {
35
+ findings.push({ file, code: 'HFS_WORK_AGENT_DATA', message: 'is agent output, which .starciwork/.gitignore refuses; keep it in the scratchpad or the blob store' });
36
+ }
37
+ if (file.startsWith('.starcistacks/') && !file.endsWith('.enc') && !file.endsWith('.env.example')) {
38
+ if (file.includes('/secrets/')) findings.push({ file, code: 'HFS_STACKS_PLAINTEXT', message: 'sits under secrets/ but is not sealed; only <slug>.enc may be tracked' });
39
+ else if (PLAINTEXT_NAME.test(file)) findings.push({ file, code: 'HFS_STACKS_PLAINTEXT', message: 'is a plaintext secret; seal it to .starcistacks/<env>/secrets/<slug>.enc' });
40
+ }
41
+ }
42
+ return findings;
43
+ }
44
+
45
+ const gitList = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).split('\0').filter(Boolean);
46
+
47
+ /** Files staged for the next commit (added, copied, modified, renamed). */
48
+ export const stagedFiles = cwd => gitList(cwd, ['diff', '--cached', '--name-only', '--diff-filter=ACMR', '-z']);
49
+
50
+ /** Every tracked file. */
51
+ export const trackedFiles = cwd => gitList(cwd, ['ls-files', '-z']);
52
+
53
+ /** Findings for `files` after keeping only the guarded trees. */
54
+ export function judge(cwd, files) {
55
+ const guarded = files.filter(GUARDED);
56
+ return { checked: guarded.length, findings: hygieneFindings(guarded, ignoredAmong(cwd, guarded.filter(file => file.startsWith('.starciwork/')))) };
57
+ }
58
+
59
+ // The sibling scripts/checks/ledger-hygiene.mjs, 3 directories up from this file when it runs inside its own full
60
+ // runtime checkout (packages/hfs/sync/ -> ../../.. is the repo root, the same computation
61
+ // packages/hfs/scripts/sync-runtime.mjs uses). Installed standalone (no such checkout), it does not exist and this
62
+ // section is silently absent — never a crash, never a false claim about a store this install cannot see.
63
+ const RUNTIME_LEDGER_HYGIENE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'scripts', 'checks', 'ledger-hygiene.mjs');
64
+
65
+ /**
66
+ * The state-root ledger findings (LEDGER_ORPHAN_STATE_ROOT, LEDGER_LEGACY_WORK_SQLITE) as {code, file, message}
67
+ * entries, when this install can reach the sibling runtime script; [] otherwise. Never throws: a report failure is
68
+ * one HFS_LEDGER_HYGIENE_UNAVAILABLE finding, not a crash of `hfs work-hygiene`.
69
+ */
70
+ export async function ledgerHygieneFindings() {
71
+ if (!fs.existsSync(RUNTIME_LEDGER_HYGIENE)) return [];
72
+ try {
73
+ const { ledgerHygieneReport } = await import(pathToFileURL(RUNTIME_LEDGER_HYGIENE).href);
74
+ const report = await ledgerHygieneReport({ apply: false });
75
+ return [
76
+ ...report.orphans.map(o => ({ code: o.code, file: o.ledgerId, message: `ledger ${o.name ?? o.ledgerId} - ${o.reason}; source roots: ${o.sourceRoots.join(', ') || '(none)'}` })),
77
+ ...report.legacy.map(l => ({ code: l.code, file: l.repoRoot, message: `${l.files.length} legacy file(s) still in the repo: ${l.files.join(', ')}` })),
78
+ ];
79
+ } catch (error) {
80
+ return [{ code: 'HFS_LEDGER_HYGIENE_UNAVAILABLE', file: RUNTIME_LEDGER_HYGIENE, message: `could not run: ${String(error?.message ?? error).slice(0, 200)}` }];
81
+ }
82
+ }
83
+
84
+ /** `hfs work-hygiene`: checks the staged files, plus the state-root ledger findings when reachable; returns the exit code. */
85
+ export async function runWorkHygiene({ cwd = process.cwd(), out = line => process.stdout.write(`${line}\n`), files } = {}) {
86
+ const { checked, findings } = judge(cwd, files ?? stagedFiles(cwd));
87
+ const ledgerFindings = await ledgerHygieneFindings();
88
+ const all = [...findings, ...ledgerFindings];
89
+ for (const finding of all) out(`${finding.code} ${finding.file} ${finding.message}`);
90
+ out(`hfs work-hygiene: ${checked} staged file(s) checked, ${findings.length} finding(s), ${ledgerFindings.length} ledger finding(s)`);
91
+ return all.length ? 1 : 0;
92
+ }
package/sync/index.mjs ADDED
@@ -0,0 +1,224 @@
1
+ // hfs sync: render, verify and write the files a product repository cannot `extends`.
2
+ //
3
+ // The generated set (target table below) is husky, both GitHub workflows, .gitignore, sonar-project.properties,
4
+ // codecov.yml and, for a back end, .starciwork/.gitignore. Every file is rendered from templates/ with the
5
+ // repository's hfs.json (profile and apps) and, for the coverage denominators, the same jest/vitest preset the
6
+ // repository installs. `--check` compares the sha256 of the rendered content with the file on disk and fails on any
7
+ // drift; `--write` rewrites the drifted files. `.gitignore` is the one shared file: only the marked block is managed
8
+ // and the repository's own lines around it are left alone.
9
+ import { createHash } from 'node:crypto';
10
+ import { createRequire } from 'node:module';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { pathToFileURL } from 'node:url';
14
+ import { readDeclaredSonarKey } from './sonar-key.mjs';
15
+
16
+ export const TEMPLATES_DIR = path.join(import.meta.dirname, '..', 'templates');
17
+ export const NODE_MAJOR = 22;
18
+ export const PROFILES = Object.freeze(['be', 'fe']);
19
+ export const BLOCK_BEGIN = '# >>> hfs sync (managed block; do not edit) >>>';
20
+ export const BLOCK_END = '# <<< hfs sync <<<';
21
+ const HEADER = profile => `# Generated by hfs sync (profile ${profile}). Do not edit: run "npx hfs sync --write".\n`;
22
+
23
+ export class SyncError extends Error {
24
+ constructor(code, message) {
25
+ super(`${code}: ${message}`);
26
+ this.code = code;
27
+ }
28
+ }
29
+
30
+ // mode: file = whole file, block = the marked block inside a file the repository also writes to.
31
+ // header: whether the generated-by comment leads the content (.starciwork/.gitignore is byte-exact instead).
32
+ export const TARGETS = Object.freeze([
33
+ { path: '.husky/pre-commit', template: { be: 'be/pre-commit', fe: 'fe/pre-commit' }, mode: 'file', header: true },
34
+ { path: '.husky/pre-push', template: { be: 'common/pre-push', fe: 'common/pre-push' }, mode: 'file', header: true },
35
+ { path: '.github/workflows/ci.yml', template: { be: 'common/ci.yml', fe: 'common/ci.yml' }, mode: 'file', header: true },
36
+ { path: '.github/workflows/e2e.yml', template: { be: 'be/e2e.yml', fe: 'fe/e2e.yml' }, mode: 'file', header: true },
37
+ { path: '.gitignore', template: { be: 'be/gitignore', fe: 'fe/gitignore' }, mode: 'block', header: false },
38
+ { path: 'sonar-project.properties', template: { be: 'be/sonar-project.properties', fe: 'fe/sonar-project.properties' }, mode: 'file', header: true },
39
+ { path: 'codecov.yml', template: { be: 'common/codecov.yml', fe: 'common/codecov.yml' }, mode: 'file', header: true },
40
+ { path: '.starciwork/.gitignore', template: { be: 'be/starciwork.gitignore' }, mode: 'file', header: false },
41
+ ].map(Object.freeze));
42
+
43
+ const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/;
44
+ const lf = text => text.replace(/\r\n/g, '\n');
45
+ export const hashOf = text => createHash('sha256').update(lf(text)).digest('hex');
46
+
47
+ /** The hfs.json shape sync reads: hfs 2, profile, project and a non-empty apps list. */
48
+ export function validateHfs(hfs) {
49
+ const bad = message => { throw new SyncError('HFS_SYNC_HFS_INVALID', message); };
50
+ if (!hfs || typeof hfs !== 'object') bad('hfs.json is not an object');
51
+ if (hfs.hfs !== 2) bad('hfs must be 2');
52
+ if (!PROFILES.includes(hfs.profile)) bad(`profile must be one of ${PROFILES.join(', ')}`);
53
+ if (typeof hfs.project !== 'string' || !KEBAB.test(hfs.project)) bad('project must be a kebab-case name');
54
+ if (!Array.isArray(hfs.apps) || hfs.apps.length === 0) bad('apps must list at least one app');
55
+ if (hfs.stacks !== undefined) {
56
+ if (hfs.profile !== 'fe') bad('stacks is a front-end field: a back-end repository owns its own .starcistacks declaration');
57
+ if (typeof hfs.stacks !== 'string' || !hfs.stacks || path.isAbsolute(hfs.stacks)) bad('stacks must be a relative path to the sibling back-end repository that owns .starcistacks');
58
+ }
59
+ const seen = new Set();
60
+ for (const app of hfs.apps) {
61
+ if (!app || typeof app.name !== 'string' || !KEBAB.test(app.name)) bad('every app needs a kebab-case name');
62
+ if (seen.has(app.name)) bad(`app ${app.name} is listed twice`);
63
+ seen.add(app.name);
64
+ }
65
+ return hfs;
66
+ }
67
+
68
+ function readBundled(name) {
69
+ return lf(fs.readFileSync(path.join(TEMPLATES_DIR, name), 'utf8'));
70
+ }
71
+
72
+ /** Fill `{{name}}` from vars and expand `{{> partial}}` lines. An unknown name is an error, never an empty string. */
73
+ export function render(text, vars, readTemplate = readBundled) {
74
+ const withPartials = text.replace(/^\{\{> ([\w./-]+)\}\}\n/gm, (_, name) => render(readTemplate(name), vars, readTemplate).replace(/\n*$/, '\n'));
75
+ return withPartials.replace(/\{\{([A-Za-z]\w*)\}\}/g, (_, key) => {
76
+ if (!Object.hasOwn(vars, key)) throw new SyncError('HFS_SYNC_TEMPLATE_VARIABLE', `template names {{${key}}}, which sync does not provide`);
77
+ return vars[key];
78
+ });
79
+ }
80
+
81
+ /** Coverage denominators from the preset the repository installs: { sonarExclusions, sonarCoverageExclusions }. */
82
+ export async function loadPresets(root, profile) {
83
+ const name = profile === 'be' ? '@starci/jest-preset' : '@starci/vitest-preset';
84
+ const require = createRequire(path.join(root, 'package.json'));
85
+ let resolved;
86
+ try {
87
+ resolved = require.resolve(name);
88
+ } catch {
89
+ throw new SyncError('HFS_SYNC_PRESET_MISSING', `${name} is not installed under ${root}; set it to the exact version in knowledge/hfs/canon-pins.yaml and reinstall`);
90
+ }
91
+ const preset = profile === 'be' ? require(resolved) : await import(pathToFileURL(resolved).href);
92
+ return { sonarExclusions: preset.sonarExclusions(), sonarCoverageExclusions: preset.sonarCoverageExclusions() };
93
+ }
94
+
95
+ /** Every value a template can name, derived from hfs.json and the presets. */
96
+ export function variables(hfs, presets, sonarKey) {
97
+ const globs = [...presets.sonarExclusions.split(','), ...presets.sonarCoverageExclusions.split(',')];
98
+ return {
99
+ profile: hfs.profile,
100
+ nodeMajor: String(NODE_MAJOR),
101
+ sonarKey: sonarKey ?? `${hfs.project}-${hfs.profile === 'be' ? 'backend' : 'fe'}`,
102
+ sonarExclusions: presets.sonarExclusions,
103
+ sonarCoverageExclusions: presets.sonarCoverageExclusions,
104
+ codecovIgnore: globs.map(glob => JSON.stringify(glob)).join('\n - '),
105
+ tsconfigPaths: hfs.apps.map(app => `apps/${app.name}/tsconfig.json`).join(','),
106
+ };
107
+ }
108
+
109
+ /** The managed content of every target this profile owns: [{ path, mode, content, hash }]. */
110
+ /** `sonarKey` is the key the repository's stack declaration names; without one the key is derived from hfs.json. */
111
+ export function renderTargets(hfs, presets, { sonarKey, readTemplate = readBundled } = {}) {
112
+ validateHfs(hfs);
113
+ const vars = variables(hfs, presets, sonarKey);
114
+ return TARGETS.filter(target => target.template[hfs.profile]).map(target => {
115
+ const body = render(readTemplate(target.template[hfs.profile]), vars, readTemplate);
116
+ const content = target.header ? HEADER(hfs.profile) + body : body;
117
+ const managed = target.mode === 'block' ? `${BLOCK_BEGIN}\n${content.replace(/\n*$/, '\n')}${BLOCK_END}\n` : content;
118
+ return { path: target.path, mode: target.mode, content: managed, hash: hashOf(managed) };
119
+ });
120
+ }
121
+
122
+ // The part of a file sync manages: all of it, or the marked block.
123
+ function managedPart(mode, text) {
124
+ if (mode === 'file') return text;
125
+ const start = text.indexOf(BLOCK_BEGIN);
126
+ const end = text.indexOf(BLOCK_END);
127
+ if (start < 0 || end < start) return null;
128
+ const stop = text.indexOf('\n', end);
129
+ return text.slice(start, stop < 0 ? text.length : stop + 1);
130
+ }
131
+
132
+ function firstDifference(expected, actual) {
133
+ const want = expected.split('\n'), have = actual.split('\n');
134
+ for (let index = 0; index < Math.max(want.length, have.length); index += 1) {
135
+ if (want[index] !== have[index]) return { line: index + 1, expected: want[index] ?? '(end of file)', actual: have[index] ?? '(end of file)' };
136
+ }
137
+ return null;
138
+ }
139
+
140
+ /** Compare every rendered target with the disk: [{ path, status: ok | missing | drift, expectedHash, actualHash?, difference? }]. */
141
+ export function checkTargets(root, targets) {
142
+ return targets.map(target => {
143
+ const file = path.join(root, target.path);
144
+ if (!fs.existsSync(file)) return { path: target.path, status: 'missing', expectedHash: target.hash };
145
+ const part = managedPart(target.mode, lf(fs.readFileSync(file, 'utf8')));
146
+ if (part === null) return { path: target.path, status: 'missing', expectedHash: target.hash };
147
+ const actualHash = hashOf(part);
148
+ if (actualHash === target.hash) return { path: target.path, status: 'ok', expectedHash: target.hash, actualHash };
149
+ return { path: target.path, status: 'drift', expectedHash: target.hash, actualHash, difference: firstDifference(target.content, part) };
150
+ });
151
+ }
152
+
153
+ /** Rewrite the missing or drifted targets; returns the paths written. */
154
+ export function writeTargets(root, targets) {
155
+ const written = [];
156
+ for (const result of checkTargets(root, targets)) {
157
+ if (result.status === 'ok') continue;
158
+ const target = targets.find(candidate => candidate.path === result.path);
159
+ const file = path.join(root, target.path);
160
+ const existing = fs.existsSync(file) ? lf(fs.readFileSync(file, 'utf8')) : '';
161
+ let next = target.content;
162
+ if (target.mode === 'block' && existing) {
163
+ const current = managedPart('block', existing);
164
+ next = current === null ? `${target.content}\n${existing}` : existing.replace(current, () => target.content);
165
+ }
166
+ fs.mkdirSync(path.dirname(file), { recursive: true });
167
+ fs.writeFileSync(file, next);
168
+ written.push(target.path);
169
+ }
170
+ return written;
171
+ }
172
+
173
+ export function loadHfs(root) {
174
+ const file = path.join(root, 'hfs.json');
175
+ if (!fs.existsSync(file)) throw new SyncError('HFS_SYNC_HFS_INVALID', `${file} does not exist`);
176
+ try {
177
+ return validateHfs(JSON.parse(fs.readFileSync(file, 'utf8')));
178
+ } catch (error) {
179
+ if (error instanceof SyncError) throw error;
180
+ throw new SyncError('HFS_SYNC_HFS_INVALID', `${file} is not valid JSON: ${error.message}`);
181
+ }
182
+ }
183
+
184
+ /** `hfs sync --check | --write [--root <dir>]`; returns the exit code (0 clean, 1 drift or error, 2 usage). */
185
+ export async function runSync(argv, { cwd = process.cwd(), out = line => process.stdout.write(`${line}\n`), presets, parseYaml } = {}) {
186
+ const check = argv.includes('--check'), write = argv.includes('--write'), init = argv.includes('--init');
187
+ const rootAt = argv.indexOf('--root');
188
+ if ([check, write, init].filter(Boolean).length !== 1) {
189
+ out('usage: hfs sync (--check | --write | --init) [--root <dir>]');
190
+ return 2;
191
+ }
192
+ const root = rootAt >= 0 ? path.resolve(cwd, argv[rootAt + 1] ?? '') : cwd;
193
+ try {
194
+ const hfs = loadHfs(root);
195
+ if (init) {
196
+ const { initSkeleton } = await import('./skeleton.mjs');
197
+ const { created, skipped } = initSkeleton(root, hfs);
198
+ for (const file of created) out(`created ${file}`);
199
+ out(`hfs sync --init: ${created.length} created, ${skipped.length} already exist and were left alone`);
200
+ return 0;
201
+ }
202
+ const sonarKey = await readDeclaredSonarKey(root, { parseYaml, stacks: hfs.stacks, fail: message => { throw new SyncError('HFS_SYNC_SONAR_KEY', message); } });
203
+ const targets = renderTargets(hfs, presets ?? await loadPresets(root, hfs.profile), { sonarKey });
204
+ if (write) {
205
+ const written = writeTargets(root, targets);
206
+ for (const file of written) out(`wrote ${file}`);
207
+ out(`hfs sync: ${written.length} written, ${targets.length - written.length} already in sync`);
208
+ return 0;
209
+ }
210
+ const results = checkTargets(root, targets);
211
+ const bad = results.filter(item => item.status !== 'ok');
212
+ for (const result of bad) {
213
+ const found = result.actualHash ? `, found ${result.actualHash.slice(0, 12)}` : '';
214
+ const where = result.difference ? ` (line ${result.difference.line}: expected ${JSON.stringify(result.difference.expected)}, found ${JSON.stringify(result.difference.actual)})` : '';
215
+ out(`HFS_SYNC_DRIFT ${result.path}: ${result.status}, expected sha256 ${result.expectedHash.slice(0, 12)}${found}${where}`);
216
+ }
217
+ out(`hfs sync --check: ${results.length - bad.length} of ${results.length} in sync${bad.length ? '; run "npx hfs sync --write"' : ''}`);
218
+ return bad.length ? 1 : 0;
219
+ } catch (error) {
220
+ if (!(error instanceof SyncError)) throw error;
221
+ out(error.message);
222
+ return 1;
223
+ }
224
+ }
@@ -0,0 +1,54 @@
1
+ // hfs sync --init: the first source tree of a new repository (entrypoint, platform config/logging/errors, the health
2
+ // endpoint; for a front end the next-intl [locale] shell with vi default, as-needed prefix and proxy.ts).
3
+ // Unlike the managed files it is written once and never overwritten: an existing file is skipped, so re-running --init
4
+ // on a repository that already grew its own source changes nothing.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { TEMPLATES_DIR, SyncError, render, validateHfs } from './index.mjs';
8
+
9
+ const APP_DIR = '__app__';
10
+ const pascal = name => name.split('-').map(part => part[0].toUpperCase() + part.slice(1)).join('');
11
+
12
+ function listFiles(dir, base = dir) {
13
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
14
+ const full = path.join(dir, entry.name);
15
+ return entry.isDirectory() ? listFiles(full, base) : [path.relative(base, full).split(path.sep).join('/')];
16
+ });
17
+ }
18
+
19
+ /** The apps that get a skeleton: a back end's `api` apps, every front-end app. */
20
+ export const skeletonApps = hfs => hfs.apps.filter(app => hfs.profile === 'fe' || app.kind === 'api');
21
+
22
+ /** Every skeleton file for this repository: [{ path, content }], the shared ones once and the per-app ones per app. */
23
+ export function skeletonFiles(hfs, dir = path.join(TEMPLATES_DIR, hfs.profile, 'skeleton')) {
24
+ validateHfs(hfs);
25
+ if (!fs.existsSync(dir)) throw new SyncError('HFS_SYNC_SKELETON_MISSING', `no skeleton templates for profile ${hfs.profile}`);
26
+ const files = [];
27
+ for (const rel of listFiles(dir)) {
28
+ const source = fs.readFileSync(path.join(dir, rel), 'utf8').replace(/\r\n/g, '\n');
29
+ if (!rel.includes(APP_DIR)) {
30
+ files.push({ path: rel, content: render(source, {}) });
31
+ continue;
32
+ }
33
+ for (const app of skeletonApps(hfs)) {
34
+ files.push({ path: rel.split(APP_DIR).join(app.name), content: render(source, { app: app.name, appPascal: pascal(app.name) }) });
35
+ }
36
+ }
37
+ return files;
38
+ }
39
+
40
+ /** Writes the skeleton under `root`, skipping every file that exists: { created, skipped } path lists. */
41
+ export function initSkeleton(root, hfs) {
42
+ const created = [], skipped = [];
43
+ for (const file of skeletonFiles(hfs)) {
44
+ const target = path.join(root, file.path);
45
+ if (fs.existsSync(target)) {
46
+ skipped.push(file.path);
47
+ continue;
48
+ }
49
+ fs.mkdirSync(path.dirname(target), { recursive: true });
50
+ fs.writeFileSync(target, file.content);
51
+ created.push(file.path);
52
+ }
53
+ return { created, skipped };
54
+ }
@@ -0,0 +1,45 @@
1
+ // The Sonar project key of a repository: read from its stack declaration (.starcistacks/application-stacks.yaml,
2
+ // services.sonar.projects[]) when one exists, so there is one source of the key. Only when no declaration names this
3
+ // repository does sync derive `<project>-backend` / `<project>-fe`.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { parseYaml as bundledParseYaml } from '../runtime/engine/yaml.mjs';
7
+
8
+ export const DECLARATION = path.join('.starcistacks', 'application-stacks.yaml');
9
+
10
+ /** The repository name the declaration lists its projects under: package.json name, else the folder name. */
11
+ export function repositoryName(root) {
12
+ try {
13
+ const name = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).name;
14
+ if (typeof name === 'string' && name) return name;
15
+ } catch {
16
+ // no readable package.json: the folder name stands in
17
+ }
18
+ return path.basename(path.resolve(root));
19
+ }
20
+
21
+ /** The keys services.sonar declares for `repository` in a parsed declaration (empty when it declares none). */
22
+ export function declaredSonarKeys(declaration, repository) {
23
+ const projects = declaration?.services?.sonar?.projects;
24
+ if (!Array.isArray(projects)) return [];
25
+ return projects.filter(project => project?.repository === repository && typeof project.key === 'string' && project.key).map(project => project.key);
26
+ }
27
+
28
+ /**
29
+ * The declared key, or null when the repository has no declaration or the declaration names none for it.
30
+ * `stacks` is hfs.json's optional path to the sibling repository that owns the declaration (a front-end repository has
31
+ * none of its own); the key is still looked up under THIS repository's name. A `stacks` path with no declaration behind it
32
+ * is refused, because the key would silently fall back to a derived one.
33
+ * `parseYaml(text)` is injected; the default is the YAML parser bundled in this package (the package installs with no node_modules).
34
+ */
35
+ export async function readDeclaredSonarKey(root, { parseYaml, fail, stacks }) {
36
+ const file = path.join(stacks === undefined ? root : path.resolve(root, stacks), DECLARATION);
37
+ if (!fs.existsSync(file)) {
38
+ if (stacks !== undefined) fail(`hfs.json stacks points at ${stacks}, which has no ${DECLARATION}`);
39
+ return null;
40
+ }
41
+ const parse = parseYaml ?? bundledParseYaml;
42
+ const keys = declaredSonarKeys(parse(fs.readFileSync(file, 'utf8')), repositoryName(root));
43
+ if (keys.length > 1) fail(`${DECLARATION} declares ${keys.length} Sonar projects for ${repositoryName(root)} (${keys.join(', ')}); one repository has one key`);
44
+ return keys[0] ?? null;
45
+ }
@@ -0,0 +1,21 @@
1
+ # End-to-end runs are manual: nothing triggers this workflow but a person dispatching it.
2
+ name: e2e
3
+
4
+ on:
5
+ workflow_dispatch:
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ e2e:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-node@v4
16
+ with:
17
+ node-version: {{nodeMajor}}
18
+ cache: npm
19
+ - run: npm ci
20
+ - name: e2e
21
+ run: npm run test:e2e
@@ -0,0 +1,2 @@
1
+ {{> common/gitignore.base}}
2
+ schema.gql
@@ -0,0 +1,8 @@
1
+ # Commit gate: staged lint, types, the unit specs the staged files touch, work hygiene. Never e2e.
2
+ npx lint-staged
3
+ npm run typecheck
4
+ staged=$(git diff --cached --name-only --diff-filter=ACMR -- '*.ts' '*.tsx' | grep -v -E '(^|/)e2e/|\.e2e-spec\.ts$' || true)
5
+ if [ -n "$staged" ]; then
6
+ npx jest --selectProjects unit --passWithNoTests --findRelatedTests $staged
7
+ fi
8
+ npx hfs work-hygiene
@@ -0,0 +1,31 @@
1
+ import { INestApplication } from "@nestjs/common"
2
+ import { Test } from "@nestjs/testing"
3
+ import request from "supertest"
4
+ import { SERVER_OPTIONS } from "@modules/platform/config"
5
+ import { Logger } from "@modules/platform/logging"
6
+ import { AppModule } from "./app.module"
7
+
8
+ describe("{{app}} composition", () => {
9
+ let app: INestApplication
10
+
11
+ beforeAll(async () => {
12
+ const moduleRef = await Test.createTestingModule({ imports: [AppModule.register({ server: { port: 3000 } })] }).compile()
13
+ app = moduleRef.createNestApplication()
14
+ await app.init()
15
+ })
16
+
17
+ afterAll(async () => {
18
+ await app.close()
19
+ })
20
+
21
+ it("boots the real AppModule and resolves its providers", () => {
22
+ expect(app.get(Logger)).toBeInstanceOf(Logger)
23
+ expect(app.get(SERVER_OPTIONS)).toEqual({ port: 3000 })
24
+ })
25
+
26
+ it("answers GET /health/live with the { status, info, error, details } shape", async () => {
27
+ const response = await request(app.getHttpServer()).get("/health/live")
28
+ expect(response.status).toBe(200)
29
+ expect(response.body).toEqual({ status: "ok", info: {}, error: {}, details: {} })
30
+ })
31
+ })
@@ -0,0 +1,7 @@
1
+ import type { ServerOptions } from "@modules/platform/config"
2
+
3
+ /** Everything the {{app}} app needs from its environment, parsed once by `main.ts` and handed to `AppModule.register`. */
4
+ export interface {{appPascal}}Options {
5
+ /** The HTTP listener. */
6
+ readonly server: ServerOptions
7
+ }
@@ -0,0 +1,23 @@
1
+ import { DynamicModule, Module } from "@nestjs/common"
2
+ import { APP_FILTER } from "@nestjs/core"
3
+ import { SystemHealthModule } from "@features/system-health"
4
+ import { SERVER_OPTIONS } from "@modules/platform/config"
5
+ import { ErrorFilter } from "@modules/platform/errors"
6
+ import { LoggingModule } from "@modules/platform/logging"
7
+ import type { {{appPascal}}Options } from "./{{app}}.options"
8
+
9
+ /** Composition root of the {{app}} app: the features it runs, the platform it needs and its one error filter. */
10
+ @Module({})
11
+ export class AppModule {
12
+ /** Builds the module from options parsed once at boot; nothing here reads the environment. */
13
+ static register(options: {{appPascal}}Options): DynamicModule {
14
+ return {
15
+ module: AppModule,
16
+ imports: [LoggingModule, SystemHealthModule],
17
+ providers: [
18
+ { provide: SERVER_OPTIONS, useValue: options.server },
19
+ { provide: APP_FILTER, useClass: ErrorFilter },
20
+ ],
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,20 @@
1
+ import { NestFactory } from "@nestjs/core"
2
+ import { EnvSource, parseServerConfig } from "@modules/platform/config"
3
+ import { createJsonLogger, LogId } from "@modules/platform/logging"
4
+ import { AppModule } from "./app.module"
5
+ import type { {{appPascal}}Options } from "./{{app}}.options"
6
+
7
+ /** Reads the environment once, composes the app from the parsed options and serves until a shutdown signal. */
8
+ const bootstrap = async (): Promise<void> => {
9
+ const env = EnvSource.fromProcess()
10
+ const options: {{appPascal}}Options = { server: parseServerConfig(env) }
11
+ const app = await NestFactory.create(AppModule.register(options))
12
+ app.enableShutdownHooks()
13
+ await app.listen(options.server.port)
14
+ }
15
+
16
+ const logger = createJsonLogger()
17
+ bootstrap().catch((cause: unknown) => {
18
+ logger.error(LogId.StartupFailed, { failure: cause instanceof Error ? { name: cause.name, message: cause.message } : { name: typeof cause } })
19
+ process.exitCode = 1
20
+ })
@@ -0,0 +1 @@
1
+ export { SystemHealthModule } from "./system-health.module"
@@ -0,0 +1,6 @@
1
+ import { Module } from "@nestjs/common"
2
+ import { SystemHealthHttpModule } from "./transport/http/system-health-http.module"
3
+
4
+ /** Application module of the health feature: process-local liveness now, readiness with the design that declares dependencies. */
5
+ @Module({ imports: [SystemHealthHttpModule] })
6
+ export class SystemHealthModule {}
@@ -0,0 +1,13 @@
1
+ import { HealthCheckService } from "@nestjs/terminus"
2
+ import { mock } from "@starci/jest-preset/mock"
3
+ import { LiveController } from "./live.controller"
4
+
5
+ describe("LiveController", () => {
6
+ it("runs Terminus with no indicator, so liveness never depends on a dependency", async () => {
7
+ const health = mock<HealthCheckService>()
8
+ const result = { status: "ok" as const, info: {}, error: {}, details: {} }
9
+ health.check.mockResolvedValue(result)
10
+ await expect(new LiveController(health).live()).resolves.toEqual(result)
11
+ expect(health.check).toHaveBeenCalledWith([])
12
+ })
13
+ })
@@ -0,0 +1,18 @@
1
+ import { Controller, Get } from "@nestjs/common"
2
+ import { HealthCheck, HealthCheckResult, HealthCheckService } from "@nestjs/terminus"
3
+
4
+ /** Liveness probe: answers `{ status, info, error, details }` from process-local state and never touches a dependency. */
5
+ @Controller("health")
6
+ export class LiveController {
7
+ constructor(
8
+ /** Terminus executor; it runs no indicator, so a dependency outage never restarts the process. */
9
+ private readonly health: HealthCheckService,
10
+ ) {}
11
+
12
+ /** Reports the process alive while its event loop still answers requests. */
13
+ @Get("live")
14
+ @HealthCheck()
15
+ live(): Promise<HealthCheckResult> {
16
+ return this.health.check([])
17
+ }
18
+ }
@@ -0,0 +1,10 @@
1
+ import { Module } from "@nestjs/common"
2
+ import { TerminusModule } from "@nestjs/terminus"
3
+ import { LiveController } from "./live.controller"
4
+
5
+ /** The HTTP transport of the health feature. */
6
+ @Module({
7
+ imports: [TerminusModule],
8
+ controllers: [LiveController],
9
+ })
10
+ export class SystemHealthHttpModule {}