@replynodes/brand-kit 0.1.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1
4
+
5
+ - Migration target: publish the package as the public npm package `@replynodes/brand-kit`.
6
+ - npm publication is the v0.1.1 release target and has not yet occurred.
7
+
8
+ ## 0.1.0
9
+
10
+ - Initial public dependency-free CLI and deterministic six-file brand package.
@@ -0,0 +1,21 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
6
+
7
+ ## Our Standards
8
+
9
+ Examples of behavior that contributes to a positive environment include kindness, respect, constructive feedback, and accepting responsibility. Unacceptable behavior includes harassment, trolling, insults, discrimination, public or private harassment, and publishing others' private information without permission.
10
+
11
+ ## Enforcement Responsibilities
12
+
13
+ Community leaders are responsible for clarifying and enforcing standards and may take fair corrective action. They will respond appropriately and fairly to reports.
14
+
15
+ ## Scope and Enforcement
16
+
17
+ This Code applies in community spaces and when an individual officially represents the community. Report unacceptable behavior privately through the repository's GitHub Security Advisory form: https://github.com/replynodes/brand-kit/security/advisories/new. Do not post personal information or sensitive incident details in a public issue or pull request.
18
+
19
+ ## Attribution
20
+
21
+ Adapted from Contributor Covenant version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
@@ -0,0 +1,15 @@
1
+ # Contributing
2
+
3
+ Contributions are welcome through GitHub issues and pull requests in `replynodes/brand-kit`. Open or find an issue first, explain the proposed change, and reference it from the PR. `main` is protected: changes require maintainer review and the required checks before merge. This project does not require a CLA.
4
+
5
+ Keep the CLI dependency-free, deterministic, and within the public client contract. Before opening a PR, run the exact checks below:
6
+
7
+ ```sh
8
+ node --check src/cli.mjs
9
+ node --check bin/brand-kit.mjs
10
+ node --check scripts/smoke.mjs
11
+ npm run package-shape
12
+ npm run smoke
13
+ ```
14
+
15
+ The smoke command is the real black-box check against the public service. Do not add unit, white-box, per-function, or coverage-only tests; add a black-box smoke probe only when a behavior cannot be covered by the existing smoke. Do not add credentials, fixtures, telemetry, or backend changes.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ReplyNodes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE.md ADDED
@@ -0,0 +1 @@
1
+ This repository is the MIT-licensed open-source client. The hosted ReplyNodes service is proprietary and separate, and its runtime endpoint is https://brand.replynodes.com/{bare-domain}. The client emits logo and backdrop references only; it does not download logo binaries.
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # brand-kit
2
+
3
+ Turn a public domain into a deterministic, agent-ready brand context package in one command.
4
+
5
+ ## 10-second demo
6
+
7
+ ```sh
8
+ npx @replynodes/brand-kit linear.app
9
+ cat brand/brand.json
10
+ ```
11
+
12
+ ## Install and run
13
+
14
+ ```sh
15
+ npm install -g @replynodes/brand-kit
16
+ brand-kit example.com
17
+ ```
18
+
19
+ The package is scoped as `@replynodes/brand-kit`, while the installed executable remains `brand-kit`.
20
+
21
+ Pass exactly one bare domain. The client supports Node.js `>=20.19.0` on Linux x64/arm64, macOS x64/arm64, and Windows x64. CI verifies Node 20.x and 22.x; that does not claim every OS combination is tested.
22
+
23
+ ## Approved use cases
24
+
25
+ - Agent-ready brand context: `npx @replynodes/brand-kit linear.app`
26
+ - On-brand landing-page bootstrap: `npx @replynodes/brand-kit vercel.com`
27
+ - CSS/token handoff: `npx @replynodes/brand-kit stripe.com`
28
+ - Reusable reference package for slides/reports/email: `npx @replynodes/brand-kit github.com`
29
+ - Portable design handoff/audit: `npx @replynodes/brand-kit notion.so`
30
+
31
+ ## Artifacts
32
+
33
+ The command creates exactly six files in `./brand/`: `brand.json`, `colors.json`, `fonts.json`, `logos.json`, `tokens.css`, and `DESIGN.md`. Output is staged, then committed only after `./brand/` is exclusively reserved; existing destinations and files are never overwritten.
34
+
35
+ ## Limitations and non-goals
36
+
37
+ This is a references-only client: it does not download or decode logos, fonts, or other binaries; scrape pages; follow redirects; sign up; accept API keys; send cookies; make per-capability requests; or provide video, Tailwind, MCP, or telemetry features. Optional source signals can be unavailable. The hosted service remains authoritative for public-address and SSRF checks.
38
+
39
+ ## How it works
40
+
41
+ The CLI validates one normalized ASCII bare domain, makes one timed HTTPS GET to `https://brand.replynodes.com/{domain}`, normalizes the aggregate response, and writes stable JSON, CSS, and Markdown artifacts. The client uses only Node.js built-ins.
42
+
43
+ ## Attribution and service boundary
44
+
45
+ The client in this repository is MIT-licensed. The hosted aggregate service at `brand.replynodes.com` is a proprietary ReplyNodes service with its own availability and terms; it is not bundled with, relicensed by, or made open source through this repository. See `NOTICE.md`.
46
+
47
+ ## Contribution and release notes
48
+
49
+ See `CONTRIBUTING.md` for checks and contribution boundaries. CI runs smoke/contract checks on Node 20.x and 22.x. The v0.1.1 release target is publication of the scoped package to npm as a public package using trusted publishing with provenance; it has not yet been published.
package/SECURITY.md ADDED
@@ -0,0 +1,7 @@
1
+ # Security
2
+
3
+ Report suspected vulnerabilities privately through the GitHub Security Advisory form for this repository: https://github.com/replynodes/brand-kit/security/advisories/new. Do not open a public issue with exploit details.
4
+
5
+ Include the affected version or commit, a concise impact description, reproduction steps or a minimal proof of concept, and any relevant logs with secrets removed. Avoid passwords, tokens, private keys, personal data, and other credentials. Never request credentials in a public issue or report, and do not send credentials in an advisory.
6
+
7
+ The client makes one HTTPS GET, follows no redirects, sends no credentials or cookies, and writes only to a newly staged local `brand/` directory.
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.mjs';
3
+
4
+ main().catch(() => {
5
+ process.stderr.write('brand-kit: the brand service could not complete the request; try again later\n');
6
+ process.exitCode = 3;
7
+ });
package/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "@replynodes/brand-kit",
3
+ "version": "0.1.1",
4
+ "description": "Generate a deterministic, agent-ready brand context package from a public brand aggregate.",
5
+ "type": "module",
6
+ "bin": { "brand-kit": "bin/brand-kit.mjs" },
7
+ "engines": { "node": ">=20.19.0" },
8
+ "files": ["bin", "src", "scripts", "README.md", "LICENSE", "NOTICE.md", "CONTRIBUTING.md", "SECURITY.md", "CODE_OF_CONDUCT.md", "CHANGELOG.md"],
9
+ "scripts": {
10
+ "smoke": "node scripts/smoke.mjs",
11
+ "package-shape": "npm pack --dry-run"
12
+ },
13
+ "license": "MIT"
14
+ }
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { spawn } from 'node:child_process';
7
+
8
+ const expectedFiles = ['DESIGN.md', 'brand.json', 'colors.json', 'fonts.json', 'logos.json', 'tokens.css'];
9
+ const SMOKE_ERROR = 'brand-kit smoke: verification failed\n';
10
+
11
+ async function runCli(root, cwd, domain) {
12
+ return await new Promise((resolve, reject) => {
13
+ const child = spawn(process.execPath, [path.join(root, 'bin/brand-kit.mjs'), domain], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
14
+ let stdout = '', stderr = '';
15
+ child.stdout.on('data', chunk => stdout += chunk);
16
+ child.stderr.on('data', chunk => stderr += chunk);
17
+ child.on('error', reject);
18
+ child.on('close', code => resolve({ code, stdout, stderr }));
19
+ });
20
+ }
21
+
22
+ async function assertExistingDestinationRejected(root, temp, setup, verify) {
23
+ await fs.rm(path.join(temp, 'brand'), { recursive: true, force: true });
24
+ await setup();
25
+ const result = await runCli(root, temp, 'linear.app');
26
+ assert.equal(result.code, 4);
27
+ assert.equal(result.stdout, '');
28
+ assert.equal(result.stderr, 'brand-kit: destination ./brand already exists; remove it or choose an empty working directory\n');
29
+ await verify();
30
+ }
31
+
32
+ async function run() {
33
+ const args = process.argv.slice(2);
34
+ if (args.length > 1) {
35
+ process.stderr.write('brand-kit smoke: expected at most one positional domain\n');
36
+ return 2;
37
+ }
38
+ const domain = args[0] ?? process.env.BRAND_KIT_SMOKE_DOMAIN ?? 'linear.app';
39
+ const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'brand-kit-smoke-'));
40
+ const root = path.resolve(import.meta.dirname, '..');
41
+ try {
42
+ await assertExistingDestinationRejected(root, temp,
43
+ async () => { await fs.mkdir(path.join(temp, 'brand')); await fs.writeFile(path.join(temp, 'brand', 'keep.txt'), 'keep\n'); },
44
+ async () => assert.equal(await fs.readFile(path.join(temp, 'brand', 'keep.txt'), 'utf8'), 'keep\n'));
45
+ await assertExistingDestinationRejected(root, temp,
46
+ async () => { await fs.writeFile(path.join(temp, 'brand'), 'keep\n'); },
47
+ async () => assert.equal(await fs.readFile(path.join(temp, 'brand'), 'utf8'), 'keep\n'));
48
+ const danglingTarget = path.join(temp, 'missing-target');
49
+ await assertExistingDestinationRejected(root, temp,
50
+ async () => { await fs.symlink(danglingTarget, path.join(temp, 'brand')); },
51
+ async () => assert.equal((await fs.lstat(path.join(temp, 'brand'))).isSymbolicLink(), true));
52
+
53
+ await fs.rm(path.join(temp, 'brand'), { recursive: true, force: true });
54
+ const result = await runCli(root, temp, domain);
55
+ assert.equal(result.code, 0);
56
+ assert.equal(result.stdout, 'Brand kit written to ./brand/ (6 files)\n');
57
+ assert.equal(result.stderr, '');
58
+ const names = (await fs.readdir(path.join(temp, 'brand'))).sort();
59
+ assert.deepEqual(names, expectedFiles);
60
+ const jsonFiles = ['brand.json', 'colors.json', 'fonts.json', 'logos.json'];
61
+ const documents = Object.fromEntries(await Promise.all(jsonFiles.map(async file => [file, JSON.parse(await fs.readFile(path.join(temp, 'brand', file), 'utf8'))])));
62
+ assert.equal(documents['brand.json'].schema_version, '0.1');
63
+ assert.equal(typeof documents['brand.json'].domain, 'string');
64
+ assert.equal(typeof documents['brand.json'].url, 'string');
65
+ assert.equal(documents['colors.json'].schema_version, '0.1');
66
+ assert.ok(Array.isArray(documents['colors.json'].colors));
67
+ assert.equal(documents['fonts.json'].schema_version, '0.1');
68
+ assert.ok(Array.isArray(documents['fonts.json'].fonts));
69
+ assert.equal(documents['logos.json'].schema_version, '0.1');
70
+ assert.ok(Array.isArray(documents['logos.json'].logos));
71
+ for (const file of names) assert.ok((await fs.stat(path.join(temp, 'brand', file))).isFile());
72
+ assert.equal((await fs.readdir(path.join(temp, 'brand'))).filter(name => /\.(png|jpe?g|gif|webp|woff2?|ttf|otf)$/i.test(name)).length, 0);
73
+ console.log(`Smoke passed for ${domain} (Node ${process.versions.node}; this is a live endpoint check, not an all-OS matrix claim).`);
74
+ return 0;
75
+ } finally {
76
+ await fs.rm(temp, { recursive: true, force: true });
77
+ }
78
+ }
79
+
80
+ try {
81
+ process.exitCode = await run();
82
+ } catch {
83
+ process.stderr.write(SMOKE_ERROR);
84
+ process.exitCode = 1;
85
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,235 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ const ARG_ERROR = 'brand-kit: provide exactly one bare domain, for example example.com';
5
+ const SERVICE_ERROR = 'brand-kit: the brand service could not complete the request; try again later';
6
+ const DEST_ERROR = 'brand-kit: destination ./brand already exists; remove it or choose an empty working directory';
7
+ const endpoint = 'https://brand.replynodes.com/';
8
+
9
+ const isString = value => typeof value === 'string';
10
+ const scalar = value => isString(value) ? value : null;
11
+ const cmpText = (a, b) => {
12
+ const aa = [...String(a)], bb = [...String(b)];
13
+ for (let i = 0; i < Math.min(aa.length, bb.length); i++) {
14
+ const c = aa[i].codePointAt(0) - bb[i].codePointAt(0);
15
+ if (c) return c;
16
+ }
17
+ return aa.length - bb.length;
18
+ };
19
+ const json = value => JSON.stringify(value, null, 2) + '\n';
20
+
21
+ function canonical(value) {
22
+ if (Array.isArray(value)) {
23
+ const values = value.map(canonical);
24
+ const seen = new Set();
25
+ return values.filter(item => {
26
+ const key = JSON.stringify(item);
27
+ if (seen.has(key)) return false;
28
+ seen.add(key); return true;
29
+ }).sort((a, b) => Buffer.from(JSON.stringify(a)).compare(Buffer.from(JSON.stringify(b))));
30
+ }
31
+ if (value && typeof value === 'object') {
32
+ return Object.fromEntries(Object.keys(value).sort(cmpText).map(key => [key, canonical(value[key])]));
33
+ }
34
+ return value;
35
+ }
36
+
37
+ function normalizeDomain(raw) {
38
+ if (!isString(raw)) return null;
39
+ let value = raw.trim();
40
+ if (!value || /[^\x00-\x7f]/.test(value) || /[\x00-\x20\x7f]/.test(value)) return null;
41
+ value = value.toLowerCase();
42
+ if (value.startsWith('www.')) value = value.slice(4);
43
+ if (value.endsWith('.')) value = value.slice(0, -1);
44
+ if (!value || value.includes(':') || value.includes('/') || value.includes('\\') || value.includes('@') || value.includes('?') || value.includes('#')) return null;
45
+ if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(value) || /^\[[^\]]+\]$/.test(value)) return null;
46
+ if (value.length > 253) return null;
47
+ const labels = value.split('.');
48
+ if (labels.length < 2 || labels.some(label => !label || label.length > 63 || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label))) return null;
49
+ return value;
50
+ }
51
+
52
+ function list(value) { return Array.isArray(value) ? value : []; }
53
+
54
+ function normalizeSocial(value) {
55
+ return [...new Set(list(value).filter(isString))].sort(cmpText);
56
+ }
57
+
58
+ function normalizeStyleguide(value) {
59
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
60
+ const result = {};
61
+ for (const key of ['domain', 'url', 'status']) if (key in value) result[key] = scalar(value[key]);
62
+ for (const key of ['mode', 'colors', 'typography', 'buttons', 'card', 'spacing', 'shadows', 'radii']) {
63
+ if (key in value) result[key] = value[key];
64
+ }
65
+ return Object.keys(result).length ? canonical(result) : null;
66
+ }
67
+
68
+ function normalizeColors(value) {
69
+ const items = list(value).map(item => {
70
+ const source = item && typeof item === 'object' && !Array.isArray(item) ? item : {};
71
+ return { hex: scalar(source.hex), usage: scalar(source.usage), count: Number.isInteger(source.count) ? source.count : null };
72
+ });
73
+ const key = item => [item.hex === null ? 0 : 1, item.hex === null ? '' : item.hex.toLowerCase(), item.hex ?? '', item.usage === null ? 0 : 1, item.usage === null ? '' : item.usage.toLowerCase(), item.usage ?? '', item.count === null ? 1 : 0, item.count === null ? 0 : -item.count, JSON.stringify(item)];
74
+ items.sort((a, b) => { const ka = key(a), kb = key(b); for (let i = 0; i < ka.length; i++) { const av = ka[i], bv = kb[i]; const c = typeof av === 'number' && typeof bv === 'number' ? av - bv : cmpText(String(av), String(bv)); if (c) return c; } return 0; });
75
+ return items.filter((item, index) => index === 0 || JSON.stringify(item) !== JSON.stringify(items[index - 1]));
76
+ }
77
+
78
+ function normalizeFonts(value) {
79
+ const fonts = [...new Set(list(value).filter(isString))];
80
+ return fonts.sort((a, b) => cmpText(a.toLocaleLowerCase('en-US'), b.toLocaleLowerCase('en-US')) || cmpText(a, b));
81
+ }
82
+
83
+ function sourceValue(value) { return isString(value) ? value : null; }
84
+ function normalizeAssetList(value) {
85
+ const assets = list(value).map(item => {
86
+ const source = item && typeof item === 'object' && !Array.isArray(item) ? item : {};
87
+ return { url: sourceValue(source.url), kind: sourceValue(source.kind) };
88
+ });
89
+ const compare = (a, b) => {
90
+ const fields = [['url', true], ['kind', true]];
91
+ for (const [field, nullFirst] of fields) {
92
+ const av = a[field], bv = b[field];
93
+ if (av === null || bv === null) { if (av === bv) continue; return av === null === nullFirst ? -1 : 1; }
94
+ const c = cmpText(String(av), String(bv)); if (c) return c;
95
+ }
96
+ return cmpText(JSON.stringify(a), JSON.stringify(b));
97
+ };
98
+ assets.sort(compare);
99
+ return assets.filter((item, i) => i === 0 || JSON.stringify(item) !== JSON.stringify(assets[i - 1]));
100
+ }
101
+
102
+ function normalizeResponse(data, domain) {
103
+ if (!data || typeof data !== 'object' || Array.isArray(data) || !isString(data.domain) || !isString(data.url) || !data.meta || typeof data.meta !== 'object' || Array.isArray(data.meta)) return null;
104
+ const stableMeta = {};
105
+ for (const key of ['domain', 'cache_ttl_seconds', 'source', 'docs']) if (key in data.meta && key !== 'cached' && key !== 'fetched_at') stableMeta[key] = canonical(data.meta[key]);
106
+ const colors = normalizeColors(data.colors);
107
+ const fonts = normalizeFonts(data.fonts);
108
+ const assets = normalizeAssetList(data.logos);
109
+ const backdrops = normalizeAssetList(data.backdrops);
110
+ return {
111
+ brand: { schema_version: '0.1', domain: data.domain, url: data.url, name: scalar(data.name), description: scalar(data.description), favicon: scalar(data.favicon), og_image: scalar(data.og_image), social_links: normalizeSocial(data.social_links), styleguide: normalizeStyleguide(data.styleguide), meta: stableMeta },
112
+ colors: { schema_version: '0.1', colors },
113
+ fonts: { schema_version: '0.1', fonts },
114
+ logos: { schema_version: '0.1', primary_logo: sourceValue(data.primary_logo), logos: assets, backdrops },
115
+ domain,
116
+ sourceUrl: data.url
117
+ };
118
+ }
119
+
120
+ function cssString(value) {
121
+ return '"' + [...value].map(char => { const code = char.codePointAt(0); if (char === '\\') return '\\\\'; if (char === '"') return '\\"'; if (code < 0x20 || code === 0x7f) return `\\${code.toString(16)} `; return char; }).join('') + '"';
122
+ }
123
+
124
+ function tokens(data) {
125
+ const lines = ['/* Brand Kit tokens v0.1: generated from available source signals. */'];
126
+ const vars = [];
127
+ const seenColors = new Set();
128
+ let colorIndex = 0;
129
+ for (const item of data.colors.colors) {
130
+ if (item.hex === null) continue;
131
+ const colorKey = item.hex.toLowerCase();
132
+ if (seenColors.has(colorKey)) continue;
133
+ seenColors.add(colorKey);
134
+ colorIndex += 1;
135
+ vars.push(` --brand-color-${colorIndex}: ${/^#[0-9a-fA-F]{3,8}$/.test(item.hex) ? item.hex : cssString(item.hex)};`);
136
+ }
137
+ for (let i = 0; i < data.fonts.fonts.length; i++) vars.push(` --brand-font-${i + 1}: ${cssString(data.fonts.fonts[i])};`);
138
+ if (!vars.length) lines.push('/* No color or font tokens were available. */');
139
+ lines.push(':root {', ...vars, '}');
140
+ return lines.join('\n') + '\n';
141
+ }
142
+
143
+ function markdown(data) {
144
+ const b = data.brand, style = b.styleguide;
145
+ const lines = [`# Design notes for ${data.domain}`, '', '## Summary', '', b.description ?? 'Unavailable.', '', '## Colors', ''];
146
+ if (data.colors.colors.length) for (const item of data.colors.colors) lines.push(`- ${item.hex ?? 'Unavailable'} — ${item.usage ?? 'usage unavailable'}${item.count === null ? '' : ` (${item.count})`}`); else lines.push('- Unavailable.');
147
+ lines.push('', '## Typography', '');
148
+ if (data.fonts.fonts.length) for (const font of data.fonts.fonts) lines.push(`- ${font}`); else lines.push('- Unavailable.');
149
+ lines.push('', '## Logos and assets', '', `- Primary logo: ${data.logos.primary_logo ?? 'Unavailable.'}`);
150
+ if (data.logos.logos.length) for (const item of data.logos.logos) lines.push(`- Logo: ${item.url ?? 'Unavailable'} (${item.kind ?? 'kind unavailable'})`);
151
+ if (data.logos.backdrops.length) for (const item of data.logos.backdrops) lines.push(`- Backdrop: ${item.url ?? 'Unavailable'} (${item.kind ?? 'kind unavailable'})`);
152
+ if (!data.logos.logos.length && !data.logos.backdrops.length) lines.push('- Additional assets unavailable.');
153
+ lines.push('', '## Styleguide', '', style ? `- Status: ${style.status ?? 'available'}` : '- Unavailable or omitted by the source.', '');
154
+ if (style) for (const key of ['mode', 'colors', 'typography', 'buttons', 'card', 'spacing', 'shadows', 'radii']) if (key in style) lines.push(`- ${key}: ${JSON.stringify(style[key])}`);
155
+ lines.push('', '## Provenance and limitations', '', `- Aggregate URL: ${b.url}`, '- Source URLs and assets are references only; no logo binary, font, or other asset was fetched.', '- The package reflects available public source signals. Optional fields may be unavailable; no recommendations or volatile metadata are included.', '');
156
+ return lines.join('\n');
157
+ }
158
+
159
+ async function request(domain) {
160
+ const controller = new AbortController();
161
+ const timer = setTimeout(() => controller.abort(), 15000);
162
+ try {
163
+ const response = await fetch(endpoint + encodeURIComponent(domain), { method: 'GET', signal: controller.signal, redirect: 'error' });
164
+ if (!response.ok) throw new Error('status');
165
+ const data = await response.json();
166
+ return normalizeResponse(data, domain);
167
+ } finally { clearTimeout(timer); }
168
+ }
169
+
170
+ async function exists(file) { try { await fs.lstat(file); return true; } catch { return false; } }
171
+
172
+ function sameIdentity(actual, expected) {
173
+ return actual.dev === expected.dev && actual.ino === expected.ino;
174
+ }
175
+
176
+ async function removeOwnedDestination(destination, destinationIdentity, files) {
177
+ for (const [file, identity] of files.reverse()) {
178
+ try {
179
+ const current = await fs.lstat(file);
180
+ if (sameIdentity(current, identity)) await fs.unlink(file);
181
+ } catch {}
182
+ }
183
+ try {
184
+ const current = await fs.lstat(destination);
185
+ if (sameIdentity(current, destinationIdentity)) await fs.rmdir(destination);
186
+ } catch {}
187
+ }
188
+
189
+ export async function main() {
190
+ const args = process.argv.slice(2);
191
+ if (args.length !== 1) { process.stderr.write(ARG_ERROR + '\n'); process.exitCode = 2; return; }
192
+ const domain = normalizeDomain(args[0]);
193
+ if (!domain) { process.stderr.write(ARG_ERROR + '\n'); process.exitCode = 2; return; }
194
+ const destination = path.resolve('brand');
195
+ if (await exists(destination)) { process.stderr.write(DEST_ERROR + '\n'); process.exitCode = 4; return; }
196
+ let data;
197
+ try { data = await request(domain); if (!data) throw new Error('malformed'); } catch { process.stderr.write(SERVICE_ERROR + '\n'); process.exitCode = 3; return; }
198
+ const stage = await fs.mkdtemp(path.resolve('.brand-stage-'));
199
+ let reserved = false;
200
+ let destinationIdentity;
201
+ let destinationConflict = false;
202
+ const committed = [];
203
+ try {
204
+ const files = { 'brand.json': json(data.brand), 'colors.json': json(data.colors), 'fonts.json': json(data.fonts), 'logos.json': json(data.logos), 'tokens.css': tokens(data), 'DESIGN.md': markdown(data) };
205
+ for (const [name, content] of Object.entries(files)) await fs.writeFile(path.join(stage, name), content, 'utf8');
206
+ try {
207
+ await fs.mkdir(destination);
208
+ reserved = true;
209
+ destinationIdentity = await fs.lstat(destination);
210
+ } catch (error) {
211
+ if (error?.code === 'EEXIST') {
212
+ await fs.rm(stage, { recursive: true, force: true }).catch(() => {});
213
+ process.stderr.write(DEST_ERROR + '\n'); process.exitCode = 4; return;
214
+ }
215
+ throw error;
216
+ }
217
+ for (const name of Object.keys(files)) {
218
+ const target = path.join(destination, name);
219
+ try {
220
+ await fs.copyFile(path.join(stage, name), target, fs.constants.COPYFILE_EXCL);
221
+ } catch (error) {
222
+ if (error?.code === 'EEXIST') destinationConflict = true;
223
+ throw error;
224
+ }
225
+ committed.push([target, await fs.lstat(target)]);
226
+ }
227
+ } catch {
228
+ await fs.rm(stage, { recursive: true, force: true }).catch(() => {});
229
+ if (reserved) await removeOwnedDestination(destination, destinationIdentity, committed);
230
+ process.stderr.write(destinationConflict ? DEST_ERROR + '\n' : SERVICE_ERROR + '\n');
231
+ process.exitCode = destinationConflict ? 4 : 3; return;
232
+ }
233
+ await fs.rm(stage, { recursive: true, force: true });
234
+ process.stdout.write('Brand kit written to ./brand/ (6 files)\n');
235
+ }