nimiq-branding-cli 1.1.0 → 1.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.
Files changed (74) hide show
  1. package/.audit/report.json +71 -0
  2. package/.audit/report.md +33 -0
  3. package/AUDIT.md +60 -0
  4. package/LINT.md +158 -0
  5. package/README.md +47 -4
  6. package/align/canonical.json +44 -0
  7. package/audit/learnings.json +51 -0
  8. package/bin/nq.js +90 -5
  9. package/hooks/ci-additions.md +53 -0
  10. package/hooks/pre-commit +10 -0
  11. package/hooks/session-start.sh +3 -0
  12. package/hooks/stack-align.yml +75 -0
  13. package/package.json +4 -4
  14. package/registry/SKILL-BLOCK.md +20 -0
  15. package/registry/components/account-header/meta.json +11 -1
  16. package/registry/components/account-list/meta.json +9 -1
  17. package/registry/components/account-ring/meta.json +9 -1
  18. package/registry/components/address-display/html/address-display.css +17 -13
  19. package/registry/components/address-display/html/demo.html +8 -10
  20. package/registry/components/address-display/meta.json +37 -7
  21. package/registry/components/address-display/vue/AddressDisplay.vue +11 -9
  22. package/registry/components/address-input/meta.json +53 -10
  23. package/registry/components/amount/meta.json +60 -11
  24. package/registry/components/amount-input/meta.json +55 -10
  25. package/registry/components/amount-with-fee/meta.json +58 -11
  26. package/registry/components/app-showcase-card/meta.json +17 -4
  27. package/registry/components/backup-banner/meta.json +29 -5
  28. package/registry/components/balance-distribution/meta.json +25 -5
  29. package/registry/components/buttons/meta.json +38 -7
  30. package/registry/components/card/meta.json +20 -4
  31. package/registry/components/close-button/meta.json +19 -4
  32. package/registry/components/consensus-icon/meta.json +26 -5
  33. package/registry/components/copyable/meta.json +25 -5
  34. package/registry/components/fiat-amount/meta.json +49 -9
  35. package/registry/components/flag-hex/html/demo.html +55 -0
  36. package/registry/components/flag-hex/html/flag-hex.css +21 -0
  37. package/registry/components/flag-hex/html/flag-hex.html +38 -0
  38. package/registry/components/flag-hex/meta.json +43 -0
  39. package/registry/components/flag-hex/vue/FlagHex.vue +98 -0
  40. package/registry/components/hero-section/meta.json +9 -4
  41. package/registry/components/honeycomb-band/meta.json +47 -9
  42. package/registry/components/identicon/meta.json +8 -1
  43. package/registry/components/label-input/meta.json +49 -9
  44. package/registry/components/loading-spinner/meta.json +19 -4
  45. package/registry/components/page-body/meta.json +23 -5
  46. package/registry/components/page-footer/meta.json +22 -5
  47. package/registry/components/page-header/meta.json +43 -8
  48. package/registry/components/payment-info-line/meta.json +10 -1
  49. package/registry/components/price-chart/meta.json +67 -11
  50. package/registry/components/qr-code/meta.json +22 -5
  51. package/registry/components/search-bar/meta.json +25 -5
  52. package/registry/components/select-bar/meta.json +37 -7
  53. package/registry/components/slider-toggle/meta.json +45 -8
  54. package/registry/components/small-page/meta.json +19 -4
  55. package/registry/components/status-alert/meta.json +37 -7
  56. package/registry/components/status-screen/meta.json +70 -13
  57. package/registry/components/swap-balance-bar/meta.json +8 -1
  58. package/registry/components/timer/meta.json +54 -10
  59. package/registry/components/toast-notification/meta.json +49 -9
  60. package/registry/components/tooltip/meta.json +69 -13
  61. package/registry/components/transaction-list/meta.json +9 -1
  62. package/registry/index.json +10 -0
  63. package/schemas/nimiq-stack.v1.json +103 -0
  64. package/scripts/align.mjs +435 -0
  65. package/scripts/apply-safe-bump.mjs +47 -0
  66. package/scripts/audit.mjs +247 -0
  67. package/scripts/bootstrap-provenance.mjs +126 -0
  68. package/scripts/hooks.mjs +152 -0
  69. package/scripts/lint.mjs +333 -0
  70. package/scripts/scaffold.mjs +391 -0
  71. package/scripts/sync-skill.mjs +84 -0
  72. package/test/align.test.mjs +166 -0
  73. package/test/scaffold.test.mjs +103 -0
  74. package/upstream-pins.json +35 -0
@@ -0,0 +1,247 @@
1
+ // nq audit — the self-learning branding-drift engine.
2
+ //
3
+ // Branding accuracy has two axes:
4
+ // (1) our vendored upstream pin == the LIVE Nimiq design (this file)
5
+ // (2) our port == the vendored upstream truth render (scripts/verify.mjs)
6
+ // `nq verify` only covers (2) against a FROZEN snapshot. This audit covers (1):
7
+ // it asks "has the live Nimiq source moved away from what we ported?", attributes
8
+ // any drift to the exact components it touches (via meta.json `source.files`),
9
+ // classifies each change through an accumulated learnings store, runs the pixel
10
+ // verify, and emits a verdict the weekly workflow acts on (auto-PR safe / alert risky).
11
+ //
12
+ // Works locally (uses the gitignored upstream/<repo> clones) AND in CI (no clones:
13
+ // `git ls-remote` for live tips + the GitHub compare API for changed files).
14
+ //
15
+ // Usage: node scripts/audit.mjs [--skip-verify] [--json]
16
+ import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
17
+ import { existsSync } from 'node:fs';
18
+ import { execFileSync } from 'node:child_process';
19
+ import { dirname, join, resolve } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
23
+ const COMPONENTS = join(ROOT, 'registry', 'components');
24
+ const SKIP_VERIFY = process.argv.includes('--skip-verify');
25
+ const JSON_ONLY = process.argv.includes('--json');
26
+
27
+ // ---------- tiny helpers ----------
28
+ function sh(cmd, args, opts = {}) {
29
+ return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], ...opts }).trim();
30
+ }
31
+ function try_(fn, fallback = null) { try { return fn(); } catch { return fallback; } }
32
+
33
+ // glob ("wallet:**/*Sentry*") -> RegExp. Supports ** (any incl. /), * (any excl. /).
34
+ function globToRe(glob) {
35
+ let re = '';
36
+ for (let i = 0; i < glob.length; i++) {
37
+ const c = glob[i];
38
+ if (c === '*') {
39
+ if (glob[i + 1] === '*') { re += '.*'; i++; if (glob[i + 1] === '/') i++; }
40
+ else re += '[^/]*';
41
+ } else if ('.+?^${}()|[]\\'.includes(c)) re += '\\' + c;
42
+ else re += c;
43
+ }
44
+ return new RegExp('^' + re + '$');
45
+ }
46
+
47
+ // ---------- inputs ----------
48
+ async function loadPins() {
49
+ return JSON.parse(await readFile(join(ROOT, 'upstream-pins.json'), 'utf8')).repos;
50
+ }
51
+ async function loadLearnings() {
52
+ const p = join(ROOT, 'audit', 'learnings.json');
53
+ if (!existsSync(p)) return { rules: [] };
54
+ return JSON.parse(await readFile(p, 'utf8'));
55
+ }
56
+ // "repo:path" -> [componentName...] and componentName -> {repo, files}
57
+ async function loadProvenance() {
58
+ const index = {}; const byComponent = {};
59
+ for (const name of (await readdir(COMPONENTS)).sort()) {
60
+ const meta = JSON.parse(await readFile(join(COMPONENTS, name, 'meta.json'), 'utf8'));
61
+ const src = meta.source ?? { repo: 'unknown', files: [] };
62
+ byComponent[name] = src;
63
+ for (const f of src.files ?? []) (index[f] ??= []).push(name);
64
+ }
65
+ return { index, byComponent };
66
+ }
67
+
68
+ // ---------- upstream drift ----------
69
+ function repoSlug(url) { // https://github.com/nimiq/wallet.git -> nimiq/wallet
70
+ const m = url.match(/github\.com[:/]+([^/]+\/[^/.]+)/);
71
+ return m ? m[1] : null;
72
+ }
73
+ function liveTip(repo, info) {
74
+ const local = join(ROOT, 'upstream', repo);
75
+ if (existsSync(join(local, '.git'))) {
76
+ try_(() => sh('git', ['-C', local, 'fetch', '--quiet', 'origin', info.branch]));
77
+ const tip = try_(() => sh('git', ['-C', local, 'rev-parse', `origin/${info.branch}`]));
78
+ if (tip) return { tip, via: 'local' };
79
+ }
80
+ const ls = try_(() => sh('git', ['ls-remote', info.url, `refs/heads/${info.branch}`]));
81
+ if (ls) return { tip: ls.split(/\s+/)[0], via: 'ls-remote' };
82
+ return { tip: null, via: 'unreachable' };
83
+ }
84
+ function changedFiles(repo, info, base, head) {
85
+ const local = join(ROOT, 'upstream', repo);
86
+ // local clone: direct diff (most reliable, offline)
87
+ if (existsSync(join(local, '.git'))) {
88
+ const out = try_(() => sh('git', ['-C', local, 'diff', '--name-only', `${base}..${head}`]));
89
+ if (out !== null) return out ? out.split('\n') : [];
90
+ }
91
+ // CI: GitHub compare API. Prefer authenticated gh (higher rate limit), else fetch.
92
+ const slug = repoSlug(info.url);
93
+ if (slug) {
94
+ const viaGh = try_(() => sh('gh', ['api', `repos/${slug}/compare/${base}...${head}`, '--jq', '.files[].filename']));
95
+ if (viaGh !== null) return viaGh ? viaGh.split('\n') : [];
96
+ }
97
+ return null; // unknown — drift detected but file list unavailable
98
+ }
99
+
100
+ function classify(repoPath, learnings) {
101
+ for (const rule of learnings.rules ?? []) {
102
+ if (globToRe(rule.pattern).test(repoPath)) return rule;
103
+ }
104
+ return null; // unknown -> triage
105
+ }
106
+
107
+ // ---------- main ----------
108
+ const pins = await loadPins();
109
+ const learnings = await loadLearnings();
110
+ const { index: provIndex, byComponent } = await loadProvenance();
111
+
112
+ const report = {
113
+ generatedAtNote: 'timestamp stamped by caller',
114
+ verdict: 'clean',
115
+ repos: {},
116
+ driftedComponents: [], // components whose upstream source changed
117
+ tokenDrift: [], // framework-level css/scss/token changes
118
+ unknownPaths: [], // changed paths with no learnings rule -> need triage
119
+ verify: { ran: false },
120
+ newComponents: [], // upstream components not yet in the registry
121
+ summary: '',
122
+ };
123
+
124
+ let anyDrift = false, riskyDrift = false;
125
+
126
+ for (const [repo, info] of Object.entries(pins)) {
127
+ const { tip, via } = liveTip(repo, info);
128
+ const base = info.pinned;
129
+ const drifted = tip && tip !== base;
130
+ const entry = { branch: info.branch, pinned: base.slice(0, 8), live: tip ? tip.slice(0, 8) : null, pinnedFull: base, liveFull: tip, via, drifted: !!drifted };
131
+ if (drifted) {
132
+ anyDrift = true;
133
+ const files = changedFiles(repo, info, base, tip);
134
+ entry.changedFileCount = files ? files.length : null;
135
+ entry.changedFiles = files ? files.slice(0, 500) : null;
136
+ for (const path of files ?? []) {
137
+ const repoPath = `${repo}:${path}`;
138
+ // does this file feed a registry component?
139
+ const touchedComponents = new Set();
140
+ for (const [provFile, comps] of Object.entries(provIndex)) {
141
+ if (provFile === repoPath) comps.forEach(c => touchedComponents.add(c));
142
+ }
143
+ const rule = classify(repoPath, learnings);
144
+ if (touchedComponents.size) {
145
+ riskyDrift = true; // a real component source moved -> needs re-port review
146
+ report.driftedComponents.push({ repoPath, components: [...touchedComponents], verdict: rule?.verdict ?? 'branding' });
147
+ } else if (rule?.verdict === 'branding' || rule?.verdict === 'token') {
148
+ riskyDrift = true;
149
+ report.tokenDrift.push({ repoPath, rule: rule.pattern, reason: rule.reason });
150
+ } else if (rule?.verdict === 'ignore') {
151
+ // benign churn (deps, telemetry, CI) — known noise, no action
152
+ } else {
153
+ report.unknownPaths.push(repoPath); // no rule -> triage (treated risky)
154
+ riskyDrift = true;
155
+ }
156
+ }
157
+ if (files === null) { entry.note = 'drift detected but changed-file list unavailable (no clone + compare API failed)'; riskyDrift = true; }
158
+ }
159
+ report.repos[repo] = entry;
160
+ }
161
+
162
+ // ---------- new-component radar (best-effort, needs the vue-components clone) ----------
163
+ {
164
+ const vc = join(ROOT, 'upstream', 'vue-components', 'src', 'components');
165
+ if (existsSync(vc)) {
166
+ const ported = new Set(Object.values(byComponent).flatMap(s => (s.files ?? []).filter(f => f.startsWith('vue-components:')).map(f => f.split(':')[1])));
167
+ for (const f of (await readdir(vc)).filter(f => f.endsWith('.vue'))) {
168
+ const path = `src/components/${f}`;
169
+ if (!ported.has(path)) report.newComponents.push(`vue-components:${path}`);
170
+ }
171
+ } else {
172
+ report.newComponents = ['(skipped — vue-components clone not present; see FEATURES.md roadmap)'];
173
+ }
174
+ }
175
+
176
+ // ---------- port fidelity (pixel verify) ----------
177
+ if (!SKIP_VERIFY) {
178
+ try {
179
+ const { verify } = await import(join(ROOT, 'scripts', 'verify.mjs'));
180
+ const names = (await readdir(COMPONENTS)).sort();
181
+ let pass = 0, fail = 0, skip = 0; const failed = [];
182
+ for (const n of names) {
183
+ const r = await verify(n);
184
+ if (r.status === 'pass') pass++;
185
+ else if (r.status === 'skip') skip++;
186
+ else { fail++; failed.push({ name: n, diffPct: r.diffPct }); }
187
+ }
188
+ report.verify = { ran: true, pass, fail, skip, failed };
189
+ if (fail) riskyDrift = true;
190
+ } catch (e) {
191
+ report.verify = { ran: false, error: e.message };
192
+ }
193
+ }
194
+
195
+ // ---------- verdict ----------
196
+ // clean : nothing moved, ports green
197
+ // safe : upstream moved but ONLY in benign (learnings-classified "ignore") files,
198
+ // no component/token touched, no unknown paths, verify green -> auto-PR pin bump
199
+ // risky : a component source moved, token-relevant change, unknown paths, or verify failed -> alert
200
+ if (!anyDrift && (!report.verify.ran || report.verify.fail === 0)) report.verdict = 'clean';
201
+ else if (anyDrift && !riskyDrift) report.verdict = 'safe';
202
+ else report.verdict = 'risky';
203
+
204
+ report.summary = [
205
+ `verdict: ${report.verdict}`,
206
+ `${Object.values(report.repos).filter(r => r.drifted).length}/${Object.keys(report.repos).length} upstream(s) drifted`,
207
+ `${report.driftedComponents.length} component(s) upstream-touched`,
208
+ `${report.tokenDrift.length} token-drift`,
209
+ `${report.unknownPaths.length} unknown path(s)`,
210
+ report.verify.ran ? `verify ${report.verify.pass}✓/${report.verify.fail}✗/${report.verify.skip}·` : 'verify skipped',
211
+ ].join(' · ');
212
+
213
+ // ---------- write report ----------
214
+ await mkdir(join(ROOT, '.audit'), { recursive: true });
215
+ await writeFile(join(ROOT, '.audit', 'report.json'), JSON.stringify(report, null, 2) + '\n');
216
+ await writeFile(join(ROOT, '.audit', 'report.md'), renderMd(report));
217
+
218
+ if (JSON_ONLY) console.log(JSON.stringify(report, null, 2));
219
+ else {
220
+ console.log(`\nnq audit — ${report.summary}\n`);
221
+ for (const [repo, r] of Object.entries(report.repos)) {
222
+ console.log(` ${r.drifted ? '⚠' : '✓'} ${repo.padEnd(15)} ${r.pinned} → ${r.live ?? '?'}${r.drifted ? ` (${r.changedFileCount ?? '?'} files, ${r.via})` : ' up-to-date'}`);
223
+ }
224
+ if (report.driftedComponents.length) { console.log('\n upstream-touched components (need re-port review):'); report.driftedComponents.forEach(d => console.log(` ✗ ${d.components.join(', ')} ← ${d.repoPath}`)); }
225
+ if (report.tokenDrift.length) { console.log('\n token / framework drift:'); report.tokenDrift.forEach(d => console.log(` ! ${d.repoPath} (${d.reason})`)); }
226
+ if (report.unknownPaths.length) { console.log(`\n unknown paths needing triage: ${report.unknownPaths.length} (see .audit/report.md)`); }
227
+ if (report.newComponents.length) { console.log(`\n new-component radar: ${report.newComponents.length} upstream component(s) not in registry`); }
228
+ console.log(`\n full report: .audit/report.md\n`);
229
+ }
230
+
231
+ // verdict drives the workflow; non-zero only on risky so a CI step can branch on it too
232
+ process.exitCode = report.verdict === 'risky' ? 2 : 0;
233
+
234
+ function renderMd(r) {
235
+ const L = [];
236
+ L.push(`# nq audit report`, ``, `**${r.summary}**`, ``);
237
+ L.push(`## Upstream pins vs live`, ``, `| repo | branch | pinned | live | drifted | changed files |`, `|---|---|---|---|---|---|`);
238
+ for (const [repo, e] of Object.entries(r.repos)) L.push(`| ${repo} | ${e.branch} | \`${e.pinned}\` | \`${e.live ?? '?'}\` | ${e.drifted ? '**yes**' : 'no'} | ${e.drifted ? (e.changedFileCount ?? '?') : '—'} |`);
239
+ L.push(``);
240
+ if (r.driftedComponents.length) { L.push(`## ⚠ Components whose upstream source changed (re-port review)`, ``); for (const d of r.driftedComponents) L.push(`- \`${d.repoPath}\` → **${d.components.join(', ')}**`); L.push(``); }
241
+ if (r.tokenDrift.length) { L.push(`## Token / framework drift`, ``); for (const d of r.tokenDrift) L.push(`- \`${d.repoPath}\` — ${d.reason}`); L.push(``); }
242
+ if (r.unknownPaths.length) { L.push(`## Unknown changed paths (need triage → add a learnings rule)`, ``); for (const p of r.unknownPaths) L.push(`- \`${p}\``); L.push(``); }
243
+ if (r.verify.ran) { L.push(`## Port fidelity (pixel verify)`, ``, `${r.verify.pass} pass · ${r.verify.fail} fail · ${r.verify.skip} skip`, ``); if (r.verify.failed?.length) for (const f of r.verify.failed) L.push(`- ✗ ${f.name} (${f.diffPct}%)`); L.push(``); }
244
+ if (r.newComponents.length) { L.push(`## New-component radar`, ``, `Upstream components with no registry port yet (candidates — cross-ref FEATURES.md):`, ``); for (const n of r.newComponents) L.push(`- \`${n}\``); L.push(``); }
245
+ L.push(`---`, ``, `Verdicts: **clean** (nothing moved) · **safe** (only learnings-"ignore" churn → auto-PR pin bump) · **risky** (component/token/unknown/verify-fail → human review).`);
246
+ return L.join('\n') + '\n';
247
+ }
@@ -0,0 +1,126 @@
1
+ // One-time (idempotent) bootstrap: write a structured `source` provenance block
2
+ // into every registry component's meta.json. The audit (scripts/audit.mjs) reads
3
+ // this to attribute an upstream diff to the exact components it touches.
4
+ //
5
+ // `source` schema:
6
+ // { "repo": "<primary repo|original|nimiq-com>",
7
+ // "ref": "<short sha of the primary repo's vendored pin at bootstrap>",
8
+ // "files": ["<repo>:<path within upstream/<repo>>", ...] }
9
+ //
10
+ // `files` entries are "repo:path" strings (primary repo first) so the audit can
11
+ // split on ":" and the format matches audit/learnings.json patterns.
12
+ //
13
+ // Run: node scripts/bootstrap-provenance.mjs [--check]
14
+ // --check : report only, do not write.
15
+ import { readFile, writeFile } from 'node:fs/promises';
16
+ import { existsSync } from 'node:fs';
17
+ import { execFileSync } from 'node:child_process';
18
+ import { dirname, join, resolve } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
22
+ const COMPONENTS = join(ROOT, 'registry', 'components');
23
+ const CHECK = process.argv.includes('--check');
24
+
25
+ // Primary repo + the upstream source file(s) each component was ported from.
26
+ // Derived from meta.notes citations, the @nimiq/vue-components PascalCase
27
+ // mapping, and the FEATURES.md "Built from:" survey. Marketing components are
28
+ // calibrated to nimiq.com screenshots/assets (no vendored git repo → "nimiq-com");
29
+ // flag-hex is an original brand composition ("original").
30
+ const MAP = {
31
+ // --- @nimiq/vue-components ports (upstream/vue-components/src/components/*.vue) ---
32
+ 'account-list': { repo: 'vue-components', files: ['vue-components:src/components/AccountList.vue', 'vue-components:src/components/Identicon.vue'] },
33
+ 'account-ring': { repo: 'vue-components', files: ['vue-components:src/components/AccountRing.vue', 'vue-components:src/components/Identicon.vue'] },
34
+ 'address-display': { repo: 'vue-components', files: ['vue-components:src/components/AddressDisplay.vue'] },
35
+ 'address-input': { repo: 'vue-components', files: ['vue-components:src/components/AddressInput.vue'] },
36
+ 'amount': { repo: 'vue-components', files: ['vue-components:src/components/Amount.vue'] },
37
+ 'amount-input': { repo: 'vue-components', files: ['vue-components:src/components/AmountInput.vue'] },
38
+ 'amount-with-fee': { repo: 'vue-components', files: ['vue-components:src/components/AmountWithFee.vue', 'vue-components:src/components/AmountInput.vue'] },
39
+ 'close-button': { repo: 'vue-components', files: ['vue-components:src/components/CloseButton.vue'] },
40
+ 'copyable': { repo: 'vue-components', files: ['vue-components:src/components/Copyable.vue'] },
41
+ 'fiat-amount': { repo: 'vue-components', files: ['vue-components:src/components/FiatAmount.vue'] },
42
+ 'identicon': { repo: 'vue-components', files: ['vue-components:src/components/Identicon.vue'] },
43
+ 'label-input': { repo: 'vue-components', files: ['vue-components:src/components/LabelInput.vue'] },
44
+ 'loading-spinner': { repo: 'vue-components', files: ['vue-components:src/components/LoadingSpinner.vue'] },
45
+ 'page-body': { repo: 'vue-components', files: ['vue-components:src/components/PageBody.vue'] },
46
+ 'page-footer': { repo: 'vue-components', files: ['vue-components:src/components/PageFooter.vue'] },
47
+ 'page-header': { repo: 'vue-components', files: ['vue-components:src/components/PageHeader.vue'] },
48
+ 'payment-info-line': { repo: 'vue-components', files: ['vue-components:src/components/PaymentInfoLine.vue', 'vue-components:src/components/Timer.vue', 'vue-components:src/components/Identicon.vue'] },
49
+ 'qr-code': { repo: 'vue-components', files: ['vue-components:src/components/QrCode.vue'] },
50
+ 'select-bar': { repo: 'vue-components', files: ['vue-components:src/components/SelectBar.vue'] },
51
+ 'slider-toggle': { repo: 'vue-components', files: ['vue-components:src/components/SliderToggle.vue'] },
52
+ 'small-page': { repo: 'vue-components', files: ['vue-components:src/components/SmallPage.vue'] },
53
+ 'timer': { repo: 'vue-components', files: ['vue-components:src/components/Timer.vue'] },
54
+ 'tooltip': { repo: 'vue-components', files: ['vue-components:src/components/Tooltip.vue'] },
55
+
56
+ // --- CSS-framework components (upstream/nimiq-style) ---
57
+ 'buttons': { repo: 'nimiq-style', files: ['nimiq-style:src/buttons.css', 'nimiq-style:nimiq-style.min.css'] },
58
+ 'card': { repo: 'nimiq-style', files: ['nimiq-style:src/layout.css', 'nimiq-style:nimiq-style.min.css'] },
59
+
60
+ // --- wallet ports (upstream/wallet/src/components) ---
61
+ 'account-header': { repo: 'wallet', files: ['wallet:src/components/layouts/AddressOverview.vue', 'wallet:src/components/SearchBar.vue', 'wallet:src/components/staking/StakingButton.vue', 'wallet:src/components/icons/Staking/StakingIcon.vue'] },
62
+ 'backup-banner': { repo: 'wallet', files: ['wallet:src/components/layouts/AccountOverview.vue'] },
63
+ 'balance-distribution': { repo: 'wallet', files: ['wallet:src/components/BalanceDistribution.vue'] },
64
+ 'consensus-icon': { repo: 'wallet', files: ['wallet:src/components/ConsensusIcon.vue', 'wallet:src/components/icons/WorldCheckIcon.vue'] },
65
+ 'price-chart': { repo: 'wallet', files: ['wallet:src/components/PriceChart.vue', 'wallet:src/components/LineChart.vue'] },
66
+ 'search-bar': { repo: 'wallet', files: ['wallet:src/components/SearchBar.vue'] },
67
+ 'swap-balance-bar': { repo: 'wallet', files: ['wallet:src/components/swap/SwapBalanceBar.vue'] },
68
+ 'toast-notification': { repo: 'wallet', files: ['wallet:src/components/swap/SwapNotification.vue'] },
69
+ 'transaction-list': { repo: 'wallet', files: ['wallet:src/components/TransactionListItem.vue', 'wallet:src/components/TransactionList.vue'] },
70
+
71
+ // --- hub ---
72
+ 'status-screen': { repo: 'hub', files: ['hub:src/components/StatusScreen.vue'] },
73
+
74
+ // --- nimiq-ui (docs theme) ---
75
+ 'status-alert': { repo: 'nimiq-ui', files: ['nimiq-ui:packages/nimiq-vitepress-theme/src/assets/github-callouts.css'] },
76
+
77
+ // --- marketing: calibrated to nimiq.com (no vendored git repo) ---
78
+ 'app-showcase-card': { repo: 'nimiq-com', files: [] },
79
+ 'hero-section': { repo: 'nimiq-com', files: [] },
80
+ 'honeycomb-band': { repo: 'nimiq-com', files: [] },
81
+
82
+ // --- original brand composition ---
83
+ 'flag-hex': { repo: 'original', files: [] },
84
+ };
85
+
86
+ const VIRTUAL = new Set(['original', 'nimiq-com']); // not vendored git repos
87
+
88
+ function shortHead(repo) {
89
+ if (VIRTUAL.has(repo)) return null;
90
+ try {
91
+ return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
92
+ cwd: join(ROOT, 'upstream', repo), encoding: 'utf8',
93
+ }).trim();
94
+ } catch { return null; }
95
+ }
96
+
97
+ function fileExists(spec) {
98
+ const [repo, ...rest] = spec.split(':');
99
+ const path = rest.join(':');
100
+ if (VIRTUAL.has(repo)) return true;
101
+ return existsSync(join(ROOT, 'upstream', repo, path));
102
+ }
103
+
104
+ const { readdir } = await import('node:fs/promises');
105
+ const names = (await readdir(COMPONENTS)).sort();
106
+ let missing = [], unresolved = [], wrote = 0;
107
+
108
+ for (const name of names) {
109
+ const entry = MAP[name];
110
+ if (!entry) { missing.push(name); continue; }
111
+ for (const f of entry.files) if (!fileExists(f)) unresolved.push(`${name} → ${f}`);
112
+
113
+ const metaPath = join(COMPONENTS, name, 'meta.json');
114
+ const meta = JSON.parse(await readFile(metaPath, 'utf8'));
115
+ meta.source = { repo: entry.repo, ref: shortHead(entry.repo), files: entry.files };
116
+ if (!CHECK) {
117
+ await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
118
+ wrote++;
119
+ }
120
+ }
121
+
122
+ console.log(`components: ${names.length}, mapped: ${names.length - missing.length}, ${CHECK ? 'checked' : `wrote ${wrote}`}`);
123
+ if (missing.length) console.log(`UNMAPPED (no MAP entry): ${missing.join(', ')}`);
124
+ if (unresolved.length) { console.log(`UNRESOLVED upstream paths:`); unresolved.forEach(u => console.log(' ✗ ' + u)); }
125
+ else console.log('all upstream paths resolve ✓');
126
+ process.exitCode = (missing.length || unresolved.length) ? 1 : 0;
@@ -0,0 +1,152 @@
1
+ // nq hooks install — drift hooks that keep an app on the canonical stack.
2
+ //
3
+ // git pre-commit → `nq align --fail-on=settlement,styling` (blocks a commit that
4
+ // introduces the broken light-client path or off-brand styling)
5
+ // SessionStart → `nq align --quiet` advisory drift banner (one line)
6
+ // weekly GH Action → `nq align --all` → PR safe fixes / file a rolling drift issue,
7
+ // reusing the existing nq audit weekly machinery.
8
+ //
9
+ // `nq hooks install [target]` writes the git pre-commit hook into the target repo (cwd
10
+ // by default) and prints the SessionStart + workflow snippets (with --write to drop the
11
+ // workflow file too). Artifacts also live under hooks/ in this repo for copy/paste.
12
+ import { mkdir, writeFile, chmod, readFile, copyFile } from 'node:fs/promises';
13
+ import { existsSync } from 'node:fs';
14
+ import { dirname, join, resolve } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const HERE = dirname(fileURLToPath(import.meta.url));
18
+ const ROOT = resolve(HERE, '..');
19
+
20
+ export const PRE_COMMIT = `#!/usr/bin/env bash
21
+ # nq align pre-commit gate — installed by \`nq hooks install\`.
22
+ # Blocks a commit that introduces the broken @nimiq/core light-client path
23
+ # (Client.create / @nimiq/core/web / waitForConsensusEstablished) or off-brand styling.
24
+ set -euo pipefail
25
+ if command -v nq >/dev/null 2>&1; then
26
+ nq align --fail-on=settlement,styling
27
+ else
28
+ npx -y github:Andjroo111/nimiq-branding-cli align --fail-on=settlement,styling
29
+ fi
30
+ `;
31
+
32
+ export const SESSION_START = `# nq align — SessionStart advisory drift banner.
33
+ # Add to .claude/settings.json hooks.SessionStart, or run at the top of a session.
34
+ nq align --quiet 2>/dev/null || true
35
+ `;
36
+
37
+ export const WEEKLY_WORKFLOW = `name: stack-align
38
+
39
+ # Weekly fleet stack-alignment audit — the sibling of branding-audit.yml. Runs
40
+ # \`nq align --all\` across the repo, PRs safe manifest fixes (nq align --fix), and
41
+ # files/updates a rolling stack-drift issue on any risky-fail (the broken light-client
42
+ # path, off-brand styling). Reuses the same verdict-driven shape as audit.yml.
43
+ on:
44
+ schedule:
45
+ - cron: '30 9 * * 1' # Mondays 09:30 UTC (after branding-audit at 09:00)
46
+ workflow_dispatch:
47
+
48
+ permissions:
49
+ contents: write
50
+ pull-requests: write
51
+ issues: write
52
+
53
+ concurrency:
54
+ group: stack-align
55
+ cancel-in-progress: false
56
+
57
+ jobs:
58
+ align:
59
+ runs-on: ubuntu-latest
60
+ timeout-minutes: 15
61
+ env:
62
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
63
+ steps:
64
+ - uses: actions/checkout@v4
65
+ - uses: actions/setup-node@v4
66
+ with:
67
+ node-version: 20
68
+
69
+ - name: Run nq align
70
+ id: align
71
+ run: |
72
+ npx -y github:Andjroo111/nimiq-branding-cli align --all . --json > align.json || true
73
+ node -e "const r=require('./align.json').results.filter(x=>!x.exempt); const bad=r.filter(x=>x.overall==='risky-fail'); const drift=r.filter(x=>x.overall==='safe-drift'); console.log('risky='+bad.length); console.log('drift='+drift.length); process.stdout.write('');"
74
+ risky=\$(node -e "console.log(require('./align.json').results.filter(x=>!x.exempt&&x.overall==='risky-fail').length)")
75
+ drift=\$(node -e "console.log(require('./align.json').results.filter(x=>!x.exempt&&x.overall==='safe-drift').length)")
76
+ echo "risky=\$risky" >> "\$GITHUB_OUTPUT"
77
+ echo "drift=\$drift" >> "\$GITHUB_OUTPUT"
78
+
79
+ # ---- SAFE: only manifest drift → autofix the manifests + open a PR ----
80
+ - name: PR safe manifest fixes
81
+ if: steps.align.outputs.risky == '0' && steps.align.outputs.drift != '0'
82
+ run: |
83
+ npx -y github:Andjroo111/nimiq-branding-cli align --all . --fix || true
84
+ git config user.name "nq-align-bot"
85
+ git config user.email "github-actions[bot]@users.noreply.github.com"
86
+ branch="align/auto-manifest-fix"
87
+ git checkout -B "\$branch"
88
+ git add -A '*nimiq-stack.json'
89
+ if git diff --cached --quiet; then echo "nothing to fix"; exit 0; fi
90
+ git commit -m "align: safe manifest fixes (nq align --fix)"
91
+ git push -f origin "\$branch"
92
+ if [ "\$(gh pr list --head "\$branch" --state open --json number --jq 'length')" = "0" ]; then
93
+ gh pr create --head "\$branch" --base main \\
94
+ --title "align: safe manifest fixes" \\
95
+ --body "Automated \\\`nq align --fix\\\` — manifest-only, no source changes."
96
+ fi
97
+
98
+ # ---- RISKY: a load-bearing axis failed (light client / off-brand) → rolling issue ----
99
+ - name: Open/update stack-drift issue
100
+ if: steps.align.outputs.risky != '0'
101
+ run: |
102
+ gh label create stack-drift --color B91C1C \\
103
+ --description "an app drifted off the canonical Nimiq fleet stack" 2>/dev/null || true
104
+ body="$(npx -y github:Andjroo111/nimiq-branding-cli align --all . 2>&1 || true)"
105
+ existing=\$(gh issue list --state open --label stack-drift --json number --jq '.[0].number')
106
+ if [ -n "\$existing" ]; then
107
+ printf '%s\\n' "\$body" | gh issue comment "\$existing" --body-file -
108
+ else
109
+ printf '%s\\n' "\$body" | gh issue create --title "⚙️ Nimiq stack drift needs review" \\
110
+ --label stack-drift --body-file -
111
+ fi
112
+ `;
113
+
114
+ export async function installHooks(target, opts = {}) {
115
+ const repo = resolve(target ?? '.');
116
+ const out = { wrote: [], printed: [] };
117
+
118
+ // 1) git pre-commit
119
+ const gitDir = join(repo, '.git');
120
+ if (existsSync(gitDir)) {
121
+ const hookDir = join(gitDir, 'hooks');
122
+ await mkdir(hookDir, { recursive: true });
123
+ const hookPath = join(hookDir, 'pre-commit');
124
+ await writeFile(hookPath, PRE_COMMIT);
125
+ await chmod(hookPath, 0o755);
126
+ out.wrote.push(hookPath);
127
+ } else {
128
+ out.printed.push(`(no .git in ${repo} — skipped pre-commit; init git then re-run)`);
129
+ }
130
+
131
+ // 2) weekly workflow (write only with --write)
132
+ if (opts.write) {
133
+ const wfDir = join(repo, '.github', 'workflows');
134
+ await mkdir(wfDir, { recursive: true });
135
+ const wfPath = join(wfDir, 'stack-align.yml');
136
+ await writeFile(wfPath, WEEKLY_WORKFLOW);
137
+ out.wrote.push(wfPath);
138
+ }
139
+
140
+ return out;
141
+ }
142
+
143
+ // write the committed artifacts under hooks/ in THIS repo (used by build/CI, not the app)
144
+ export async function emitArtifacts() {
145
+ const dir = join(ROOT, 'hooks');
146
+ await mkdir(dir, { recursive: true });
147
+ await writeFile(join(dir, 'pre-commit'), PRE_COMMIT);
148
+ await chmod(join(dir, 'pre-commit'), 0o755);
149
+ await writeFile(join(dir, 'session-start.sh'), SESSION_START);
150
+ await writeFile(join(dir, 'stack-align.yml'), WEEKLY_WORKFLOW);
151
+ return dir;
152
+ }