logomarks 1.1.0 → 2.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 (73) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +10 -2
  3. package/docs/api.md +9 -2
  4. package/docs/commands.md +19 -3
  5. package/docs/data.md +84 -0
  6. package/docs/mcp.md +3 -3
  7. package/package.json +5 -5
  8. package/src/brand/mark.js +2 -2
  9. package/src/cli/commands/combine.d.ts +2 -0
  10. package/src/cli/commands/combine.js +3 -0
  11. package/src/cli/commands/cover.d.ts +2 -0
  12. package/src/cli/commands/cover.js +3 -0
  13. package/src/cli/commands/edit.d.ts +2 -0
  14. package/src/cli/commands/edit.js +3 -0
  15. package/src/cli/commands/find.d.ts +2 -0
  16. package/src/cli/commands/find.js +4 -1
  17. package/src/cli/commands/get.d.ts +2 -0
  18. package/src/cli/commands/get.js +4 -1
  19. package/src/cli/commands/glitch.d.ts +2 -0
  20. package/src/cli/commands/glitch.js +3 -0
  21. package/src/cli/commands/index.d.ts +5 -3
  22. package/src/cli/commands/index.js +2 -1
  23. package/src/cli/commands/kit.d.ts +2 -0
  24. package/src/cli/commands/kit.js +3 -0
  25. package/src/cli/commands/mcp.d.ts +3 -2
  26. package/src/cli/commands/mcp.js +7 -3
  27. package/src/cli/commands/mockup.d.ts +2 -0
  28. package/src/cli/commands/mockup.js +3 -0
  29. package/src/cli/commands/parts.d.ts +2 -0
  30. package/src/cli/commands/parts.js +3 -0
  31. package/src/cli/commands/sheet.d.ts +2 -0
  32. package/src/cli/commands/sheet.js +3 -0
  33. package/src/cli/commands/similar.d.ts +2 -0
  34. package/src/cli/commands/similar.js +5 -2
  35. package/src/cli/commands/social.d.ts +2 -0
  36. package/src/cli/commands/social.js +3 -0
  37. package/src/cli/commands/update.d.ts +10 -0
  38. package/src/cli/commands/update.js +54 -0
  39. package/src/cli/help.js +1 -0
  40. package/src/cli/index.d.ts +6 -0
  41. package/src/cli/index.js +14 -3
  42. package/src/cli/options.js +3 -0
  43. package/src/find/index.d.ts +1 -1
  44. package/src/find/index.js +3 -3
  45. package/src/index.d.ts +3 -1
  46. package/src/index.js +4 -1
  47. package/src/live/build.d.ts +36 -0
  48. package/src/live/build.js +55 -0
  49. package/src/live/cache.d.ts +25 -0
  50. package/src/live/cache.js +75 -0
  51. package/src/live/current.d.ts +9 -0
  52. package/src/live/current.js +54 -0
  53. package/src/live/ensure.d.ts +43 -0
  54. package/src/live/ensure.js +92 -0
  55. package/src/live/errors.d.ts +16 -0
  56. package/src/live/errors.js +19 -0
  57. package/src/live/format.d.ts +13 -0
  58. package/src/live/format.js +113 -0
  59. package/src/live/index.d.ts +7 -0
  60. package/src/live/index.js +12 -0
  61. package/src/live/paths.d.ts +17 -0
  62. package/src/live/paths.js +64 -0
  63. package/src/live/remote.d.ts +24 -0
  64. package/src/live/remote.js +109 -0
  65. package/src/mcp/index.d.ts +5 -3
  66. package/src/mcp/index.js +26 -13
  67. package/src/mcp/tools/find.js +2 -2
  68. package/src/sheet/index.d.ts +1 -1
  69. package/src/similar/folder.js +2 -2
  70. package/src/similar/nearest.d.ts +1 -1
  71. package/src/similar/store.d.ts +9 -6
  72. package/src/similar/store.js +23 -62
  73. package/data/index.bin +0 -0
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @module logomarks/live/ensure
3
+ *
4
+ * Getting the live set: `LOGOMARKS_INDEX` when set; else the cached index,
5
+ * re-checked against the site's manifest at most once a day; else downloaded
6
+ * from logomarks.ai (or `LOGOMARKS_INDEX_URL`), verified by sha256 and cached.
7
+ * Offline (`--offline`, `LOGOMARKS_OFFLINE=1`) uses the cache alone. When the
8
+ * site can't be reached or sends a bad file, a cached index is used with a
9
+ * warning; `force` (`logomarks update`) fails instead.
10
+ */
11
+
12
+ import fs from 'node:fs';
13
+ import { IndexError } from './errors.js';
14
+ import { INDEX_FORMAT, formatError, indexHeader } from './format.js';
15
+ import { cacheDir, indexUrl, isOn, localIndex, manifestUrl, MAX_AGE } from './paths.js';
16
+ import { readCache, saveCache, saveRecord } from './cache.js';
17
+ import { fetchIndex, fetchManifest, sha256, TIMEOUT } from './remote.js';
18
+ import { useIndexFile, indexUnavailable } from './current.js';
19
+
20
+ /**
21
+ * Settles on an index file for this process.
22
+ *
23
+ * @param {import('./ensure.js').IndexStatus} status Where it is and how it was had.
24
+ * @returns {import('./ensure.js').IndexStatus} The same.
25
+ */
26
+ function settle(status) {
27
+ useIndexFile(status.file);
28
+ return status;
29
+ }
30
+
31
+ /**
32
+ * The live set's index file: local, cached, or downloaded and cached. It becomes the file `loadIndex()` reads.
33
+ *
34
+ * @param {import('./ensure.js').EnsureOptions} [o] Whether to force a check, stay offline, and where to look.
35
+ * @returns {Promise<import('./ensure.js').IndexStatus>} The file and how it was had.
36
+ * @throws {IndexError} When there's no index to use: `missing`, `offline`, `network`, `checksum` or `format`.
37
+ */
38
+ export async function ensureIndex({ force = false, offline, env = process.env, url = indexUrl(env), dir = cacheDir({ env }), maxAge = MAX_AGE, timeout = TIMEOUT, now = Date.now(), warn = () => {} } = {}) {
39
+ const local = localIndex(env);
40
+ if (local) {
41
+ if (!fs.existsSync(local)) throw new IndexError('missing', `LOGOMARKS_INDEX names ${local}, which doesn't exist`);
42
+ return settle({ file: local, source: 'file', checked: false });
43
+ }
44
+ const cached = readCache(dir);
45
+ const fromCache = (checked = false) => settle({ file: /** @type {NonNullable<typeof cached>} */ (cached).file, source: 'cache', checked, manifest: cached?.record });
46
+ if (offline ?? isOn(env.LOGOMARKS_OFFLINE)) {
47
+ if (force) throw new IndexError('offline', 'updating the index needs the network: run it without --offline or LOGOMARKS_OFFLINE');
48
+ if (!cached) throw new IndexError('offline', `offline, and no index is cached in ${dir}: run logomarks update once online, or set LOGOMARKS_INDEX to an index file`);
49
+ return fromCache();
50
+ }
51
+ if (cached && !force && cached.record.url === url && now - cached.record.checked < maxAge) return fromCache();
52
+ try {
53
+ const manifest = await fetchManifest(manifestUrl(url), { timeout });
54
+ if (manifest.format !== INDEX_FORMAT) throw formatError(manifest.format);
55
+ const record = { format: manifest.format, marks: manifest.marks, letters: manifest.letters, bytes: manifest.bytes, sha256: manifest.sha256, built: manifest.built, url, checked: now };
56
+ if (cached && sha256(fs.readFileSync(cached.file)) === manifest.sha256) {
57
+ saveRecord(dir, record);
58
+ cached.record = record;
59
+ return fromCache(true);
60
+ }
61
+ const bytes = await fetchIndex(url, manifest, { timeout });
62
+ indexHeader(bytes);
63
+ return settle({ file: saveCache(dir, bytes, record), source: 'download', checked: true, manifest: record });
64
+ } catch (e) {
65
+ if (force || !cached || !(e instanceof IndexError)) {
66
+ if (e instanceof IndexError && e.code === 'network' && !cached) {
67
+ throw new IndexError('network', `${e.message}. The first run downloads the Logomarks index (about 3 MB) and caches it in ${dir}; after that it works offline. Without a network, set LOGOMARKS_INDEX to an index file`);
68
+ }
69
+ throw e;
70
+ }
71
+ warn(`logomarks: ${e.message}; using the cached index (built ${cached.record.built})`);
72
+ return fromCache();
73
+ }
74
+ }
75
+
76
+ /** The process's one `ensureIndex` run, shared by every command and tool call. */
77
+ let pending = /** @type {Promise<import('./ensure.js').IndexStatus | undefined> | undefined} */ (undefined);
78
+
79
+ /**
80
+ * Runs `ensureIndex` once per process and never throws: a failure is recorded, and `loadIndex()` throws it if and
81
+ * when something needs the live set (so `check mark.svg` works offline with nothing cached).
82
+ *
83
+ * @param {import('./ensure.js').EnsureOptions} [o] As `ensureIndex` (the first call's options win).
84
+ * @returns {Promise<import('./ensure.js').IndexStatus | undefined>} The status, or undefined when it failed.
85
+ */
86
+ export function prepareIndex(o) {
87
+ pending ??= ensureIndex(o).catch((e) => {
88
+ indexUnavailable(e);
89
+ return undefined;
90
+ });
91
+ return pending;
92
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Why the live set couldn't be had:
3
+ * - `missing`: no index yet (or `LOGOMARKS_INDEX` names no file);
4
+ * - `offline`: offline and nothing cached, or `update` asked for while offline;
5
+ * - `network`: the site couldn't be reached, or answered with an error;
6
+ * - `checksum`: the download didn't match the manifest's sha256 or size (nothing was saved);
7
+ * - `format`: the index is in a format this version of logomarks doesn't read.
8
+ */
9
+ export type IndexErrorCode = 'missing' | 'offline' | 'network' | 'checksum' | 'format';
10
+
11
+ /** The live set is missing, unreachable, corrupt or in a format this version can't read. */
12
+ export class IndexError extends Error {
13
+ constructor(code: IndexErrorCode, message: string);
14
+ readonly name: 'IndexError';
15
+ readonly code: IndexErrorCode;
16
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @module logomarks/live/errors
3
+ *
4
+ * Why the live set couldn't be had, as one error with a code the CLI and the
5
+ * MCP server can tell apart, and a message that says what to do.
6
+ */
7
+
8
+ /** The live set is missing, unreachable, corrupt or in a format this version can't read. */
9
+ export class IndexError extends Error {
10
+ /**
11
+ * @param {import('./errors.js').IndexErrorCode} code What went wrong.
12
+ * @param {string} message What to do about it.
13
+ */
14
+ constructor(code, message) {
15
+ super(message);
16
+ this.name = 'IndexError';
17
+ this.code = code;
18
+ }
19
+ }
@@ -0,0 +1,13 @@
1
+ import type { IndexInput, MarkIndex } from '../similar/store.js';
2
+ import type { IndexError } from './errors.js';
3
+
4
+ /** The index format this version of logomarks writes and reads. */
5
+ export const INDEX_FORMAT: number;
6
+ /** The error for a file in another format (code `format`), saying how to update. */
7
+ export function formatError(format: unknown): IndexError;
8
+ /** An index's bytes (brotli), built today unless `built` (YYYY-MM-DD) is given. */
9
+ export function encodeIndex(input: IndexInput, o?: { built?: string }): Buffer;
10
+ /** An index's format, build date and counts, without reading its signatures. Throws an `IndexError` when it isn't an index or is in another format. */
11
+ export function indexHeader(bytes: Uint8Array): { format: number; built: string; marks: number; letters: number };
12
+ /** An index read back. Throws an `IndexError` when it isn't an index or is in another format. */
13
+ export function decodeIndex(bytes: Uint8Array): MarkIndex;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * @module logomarks/live/format
3
+ *
4
+ * The index file's format: brotli over a 4-byte JSON length, the JSON (the
5
+ * format version, the build date, each mark and the packed search), then the
6
+ * signature bytes (48 px coverages ×255, letter signatures ×4). The version is
7
+ * checked on every read, so a CLI never misreads a file in a newer format.
8
+ */
9
+
10
+ import zlib from 'node:zlib';
11
+ import { SIMILAR, LETTERS } from '../rules.js';
12
+ import { blur } from '../similar/signature.js';
13
+ import { IndexError } from './errors.js';
14
+
15
+ /** The format this version of logomarks writes and reads. Bump it with any change to the layout or the JSON. */
16
+ export const INDEX_FORMAT = 3;
17
+
18
+ /** Letter signatures are quarters (2×2 supersampling): stored ×4. */
19
+ const QUARTERS = 4;
20
+
21
+ /** Where to get a CLI that reads a newer format. */
22
+ const UPGRADE = 'update logomarks (npm i -g logomarks@latest, or run npx logomarks@latest)';
23
+
24
+ /**
25
+ * The error for a file in another format.
26
+ *
27
+ * @param {unknown} format The file's format.
28
+ * @returns {IndexError} The error, saying what to do.
29
+ */
30
+ export function formatError(format) {
31
+ return new IndexError('format', `the index is format ${format}, and this version of logomarks reads format ${INDEX_FORMAT}: ${UPGRADE}`);
32
+ }
33
+
34
+ /**
35
+ * An index's bytes: what `decodeIndex` reads.
36
+ *
37
+ * @param {import('../similar/store.js').IndexInput} input The marks, their signatures and the packed search.
38
+ * @param {{ built?: string }} [o] The build date (YYYY-MM-DD; today).
39
+ * @returns {Buffer} The file's bytes (brotli).
40
+ */
41
+ export function encodeIndex({ marks, coverages, letters, search }, { built = new Date().toISOString().slice(0, 10) } = {}) {
42
+ const head = Buffer.from(JSON.stringify({
43
+ version: INDEX_FORMAT,
44
+ built,
45
+ size: SIMILAR.size,
46
+ letterSize: LETTERS.size,
47
+ marks: marks.map(({ slug, name, svg, ...about }) => [slug, name, svg, about]),
48
+ letters: letters.map((l) => [l.mark, l.kind]),
49
+ search,
50
+ }));
51
+ const length = Buffer.alloc(4);
52
+ length.writeUInt32LE(head.length);
53
+ const bytes = (/** @type {ArrayLike<number>} */ a, /** @type {number} */ k) => Uint8Array.from(a, (v) => Math.round(v * k));
54
+ const body = Buffer.concat([length, head, ...coverages.map((c) => bytes(c, 255)), ...letters.map((l) => bytes(l.sig, QUARTERS))]);
55
+ return zlib.brotliCompressSync(body, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 } });
56
+ }
57
+
58
+ /**
59
+ * An index's body and its JSON head, its format checked.
60
+ *
61
+ * @param {Uint8Array} bytes The file's bytes.
62
+ * @returns {{ body: Buffer, raw: any, at: number }} The body, the head and where the signatures start.
63
+ * @throws {IndexError} When it isn't an index, or is in another format.
64
+ */
65
+ function open(bytes) {
66
+ let body, raw;
67
+ try {
68
+ body = zlib.brotliDecompressSync(bytes);
69
+ const n = body.readUInt32LE(0);
70
+ raw = JSON.parse(body.toString('utf8', 4, 4 + n));
71
+ if (raw.version !== INDEX_FORMAT) throw formatError(raw.version);
72
+ return { body, raw, at: 4 + n };
73
+ } catch (e) {
74
+ if (e instanceof IndexError) throw e;
75
+ throw new IndexError('checksum', `not a Logomarks index (${/** @type {Error} */ (e).message}): run logomarks update`);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * An index's format and build date, without reading the marks' signatures.
81
+ *
82
+ * @param {Uint8Array} bytes The file's bytes.
83
+ * @returns {{ format: number, built: string, marks: number, letters: number }} What it is.
84
+ * @throws {IndexError} When it isn't an index, or is in another format.
85
+ */
86
+ export function indexHeader(bytes) {
87
+ const { raw } = open(bytes);
88
+ return { format: raw.version, built: raw.built, marks: raw.marks.length, letters: raw.letters.length };
89
+ }
90
+
91
+ /**
92
+ * An index read back, its coverages blurred into outline signatures.
93
+ *
94
+ * @param {Uint8Array} bytes The file's bytes.
95
+ * @returns {import('../similar/store.js').MarkIndex} The index.
96
+ * @throws {IndexError} When it isn't an index, or is in another format.
97
+ */
98
+ export function decodeIndex(bytes) {
99
+ const { body, raw, at: start } = open(bytes);
100
+ const S = raw.size * raw.size, L = raw.letterSize * raw.letterSize;
101
+ let at = start;
102
+ const take = (/** @type {number} */ len, /** @type {number} */ k) => {
103
+ const a = Float32Array.from(body.subarray(at, at + len), (v) => v / k);
104
+ at += len;
105
+ return a;
106
+ };
107
+ return {
108
+ built: raw.built,
109
+ marks: raw.marks.map((/** @type {[string, string, string, import('../similar/store.js').MarkAbout]} */ [slug, name, svg, about]) => ({ slug, name, svg, ...about, outline: blur(take(S, 255), raw.size) })),
110
+ letters: raw.letters.map((/** @type {[number, 'hole' | 'ink']} */ [mark, kind]) => ({ mark, kind, sig: take(L, QUARTERS) })),
111
+ search: raw.search,
112
+ };
113
+ }
@@ -0,0 +1,7 @@
1
+ export { IndexError, type IndexErrorCode } from './errors.js';
2
+ export { INDEX_FORMAT, encodeIndex, decodeIndex, indexHeader } from './format.js';
3
+ export { DEFAULT_INDEX_URL, MAX_AGE, cacheDir, indexUrl, manifestUrl } from './paths.js';
4
+ export { ensureIndex, prepareIndex, type EnsureOptions, type IndexStatus } from './ensure.js';
5
+ export { buildIndex, type BuildInput, type BuildMark, type BuiltIndex } from './build.js';
6
+ export type { Manifest } from './remote.js';
7
+ export type { CacheRecord } from './cache.js';
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @module logomarks/live
3
+ *
4
+ * The live set's index: where it comes from (the site, a cache, a local
5
+ * file), its format, and building it (for the site and the monorepo).
6
+ */
7
+
8
+ export { IndexError } from './errors.js';
9
+ export { INDEX_FORMAT, encodeIndex, decodeIndex, indexHeader } from './format.js';
10
+ export { DEFAULT_INDEX_URL, MAX_AGE, cacheDir, indexUrl, manifestUrl } from './paths.js';
11
+ export { ensureIndex, prepareIndex } from './ensure.js';
12
+ export { buildIndex } from './build.js';
@@ -0,0 +1,17 @@
1
+ /** Where the site serves the index (`https://logomarks.ai/cli/index.bin`). Its manifest, `index.json`, sits beside it. */
2
+ export const DEFAULT_INDEX_URL: string;
3
+ /** How long a checked index counts as fresh, in ms: a day. */
4
+ export const MAX_AGE: number;
5
+ /** Whether an environment flag is on: `1`, `true` or `yes`, any case. */
6
+ export function isOn(value: string | undefined): boolean;
7
+ /** The index's URL: `LOGOMARKS_INDEX_URL`, or the site's. */
8
+ export function indexUrl(env?: NodeJS.ProcessEnv): string;
9
+ /** The manifest's URL: `index.json` in the index's folder. */
10
+ export function manifestUrl(url: string): string;
11
+ /** A local index file to use instead of the cache (`LOGOMARKS_INDEX`), as an absolute path; undefined when unset. */
12
+ export function localIndex(env?: NodeJS.ProcessEnv): string | undefined;
13
+ /**
14
+ * The user's cache folder for logomarks: `$XDG_CACHE_HOME/logomarks` when set; otherwise `~/Library/Caches/logomarks`
15
+ * on macOS, `%LOCALAPPDATA%\logomarks\Cache` on Windows and `~/.cache/logomarks` elsewhere.
16
+ */
17
+ export function cacheDir(o?: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; home?: string }): string;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @module logomarks/live/paths
3
+ *
4
+ * Where the live set comes from and where it is kept: the site's URL (or
5
+ * `LOGOMARKS_INDEX_URL`), a local file (`LOGOMARKS_INDEX`), whether to stay
6
+ * offline (`LOGOMARKS_OFFLINE`), and the user's cache folder for this platform.
7
+ */
8
+
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+
12
+ /** Where the site serves the index. Its manifest, `index.json`, sits beside it. */
13
+ export const DEFAULT_INDEX_URL = 'https://logomarks.ai/cli/index.bin';
14
+
15
+ /** How long a checked index counts as fresh: a day. */
16
+ export const MAX_AGE = 24 * 60 * 60 * 1000;
17
+
18
+ /**
19
+ * Whether an environment flag is on: `1`, `true` or `yes`, any case.
20
+ *
21
+ * @param {string | undefined} value The variable's value.
22
+ * @returns {boolean} Whether it is on.
23
+ */
24
+ export const isOn = (value) => /^(1|true|yes)$/i.test(value?.trim() ?? '');
25
+
26
+ /**
27
+ * The index's URL: `LOGOMARKS_INDEX_URL`, or the site's.
28
+ *
29
+ * @param {NodeJS.ProcessEnv} [env] The environment.
30
+ * @returns {string} The URL.
31
+ */
32
+ export const indexUrl = (env = process.env) => env.LOGOMARKS_INDEX_URL?.trim() || DEFAULT_INDEX_URL;
33
+
34
+ /**
35
+ * The manifest's URL: `index.json` in the index's folder.
36
+ *
37
+ * @param {string} url The index's URL.
38
+ * @returns {string} The manifest's.
39
+ */
40
+ export const manifestUrl = (url) => new URL('index.json', url).href;
41
+
42
+ /**
43
+ * A local index file to use instead of the cache (`LOGOMARKS_INDEX`), resolved.
44
+ *
45
+ * @param {NodeJS.ProcessEnv} [env] The environment.
46
+ * @returns {string | undefined} Its absolute path, or undefined when it isn't set.
47
+ */
48
+ export const localIndex = (env = process.env) => (env.LOGOMARKS_INDEX?.trim() ? path.resolve(env.LOGOMARKS_INDEX.trim()) : undefined);
49
+
50
+ /**
51
+ * The user's cache folder for logomarks: `$XDG_CACHE_HOME/logomarks` when set; otherwise
52
+ * `~/Library/Caches/logomarks` on macOS, `%LOCALAPPDATA%\logomarks\Cache` on Windows and `~/.cache/logomarks` elsewhere.
53
+ *
54
+ * @param {{ env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, home?: string }} [o] The environment, platform and
55
+ * home folder (the process's).
56
+ * @returns {string} The folder (it may not exist yet).
57
+ */
58
+ export function cacheDir({ env = process.env, platform = process.platform, home = os.homedir() } = {}) {
59
+ const p = platform === 'win32' ? path.win32 : path.posix;
60
+ if (env.XDG_CACHE_HOME?.trim()) return p.join(env.XDG_CACHE_HOME.trim(), 'logomarks');
61
+ if (platform === 'darwin') return p.join(home, 'Library', 'Caches', 'logomarks');
62
+ if (platform === 'win32') return p.join(env.LOCALAPPDATA?.trim() || p.join(home, 'AppData', 'Local'), 'logomarks', 'Cache');
63
+ return p.join(home, '.cache', 'logomarks');
64
+ }
@@ -0,0 +1,24 @@
1
+ /** What the site says about the index it serves (`/cli/index.json`). */
2
+ export interface Manifest {
3
+ /** The index's format (`INDEX_FORMAT`). */
4
+ format: number;
5
+ /** How many marks it holds. */
6
+ marks: number;
7
+ /** How many letter signatures. */
8
+ letters?: number;
9
+ /** Its size in bytes. */
10
+ bytes: number;
11
+ /** Its sha256, in hex. */
12
+ sha256: string;
13
+ /** When it was built (YYYY-MM-DD). */
14
+ built: string;
15
+ }
16
+
17
+ /** How long one request may take, in ms (30 s). */
18
+ export const TIMEOUT: number;
19
+ /** Bytes' sha256, in hex. */
20
+ export function sha256(bytes: Uint8Array): string;
21
+ /** The site's manifest. Throws an `IndexError` (`network`) when it can't be fetched or isn't one. */
22
+ export function fetchManifest(url: string, o?: { timeout?: number }): Promise<Manifest>;
23
+ /** The index a manifest describes, its size and sha256 checked. Throws an `IndexError`: `network` or `checksum`. */
24
+ export function fetchIndex(url: string, manifest: Manifest, o?: { timeout?: number }): Promise<Uint8Array>;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * @module logomarks/live/remote
3
+ *
4
+ * The site's side: the manifest (`index.json`: the format, the counts, the
5
+ * size, the sha256 and the build date) and the index it describes, fetched
6
+ * with a timeout (global `fetch`) and verified against the manifest.
7
+ */
8
+
9
+ import { createHash } from 'node:crypto';
10
+ import { IndexError } from './errors.js';
11
+ import { VERSION } from '../util/version.js';
12
+
13
+ /** How long one request may take, in ms. */
14
+ export const TIMEOUT = 30_000;
15
+
16
+ /** Sent with every request, so the site's logs can tell CLI versions apart. */
17
+ const HEADERS = { 'user-agent': `logomarks/${VERSION} (+https://logomarks.ai/agents)` };
18
+
19
+ /**
20
+ * Bytes' sha256, in hex.
21
+ *
22
+ * @param {Uint8Array} bytes The bytes.
23
+ * @returns {string} The digest (64 hex digits).
24
+ */
25
+ export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
26
+
27
+ /**
28
+ * Why a request failed, in a few words.
29
+ *
30
+ * @param {unknown} e The error `fetch` threw.
31
+ * @returns {string} The reason.
32
+ */
33
+ function reason(e) {
34
+ const err = /** @type {Error & { cause?: { code?: string, message?: string } }} */ (e);
35
+ if (err?.name === 'TimeoutError') return 'timed out';
36
+ return err?.cause?.code ?? err?.cause?.message ?? err?.message ?? String(e);
37
+ }
38
+
39
+ /**
40
+ * A URL fetched, as an `IndexError` (`network`) when it can't be reached or answers with an error.
41
+ *
42
+ * @param {string} url The URL.
43
+ * @param {number} timeout How long it may take, in ms.
44
+ * @returns {Promise<Response>} The response (2xx).
45
+ * @throws {IndexError} When it fails.
46
+ */
47
+ async function get(url, timeout) {
48
+ let res;
49
+ try {
50
+ res = await fetch(url, { headers: HEADERS, signal: AbortSignal.timeout(timeout) });
51
+ } catch (e) {
52
+ throw new IndexError('network', `couldn't reach ${url} (${reason(e)})`);
53
+ }
54
+ if (!res.ok) throw new IndexError('network', `${url} answered ${res.status} ${res.statusText}`.trim());
55
+ return res;
56
+ }
57
+
58
+ /**
59
+ * Whether a value is a manifest.
60
+ *
61
+ * @param {any} m The value.
62
+ * @returns {m is import('./remote.js').Manifest} Whether it has every field, of the right type.
63
+ */
64
+ const isManifest = (m) =>
65
+ !!m && Number.isInteger(m.format) && Number.isInteger(m.marks) && Number.isInteger(m.bytes) && typeof m.built === 'string' && /^[0-9a-f]{64}$/.test(m.sha256);
66
+
67
+ /**
68
+ * The site's manifest.
69
+ *
70
+ * @param {string} url The manifest's URL.
71
+ * @param {{ timeout?: number }} [o] How long it may take.
72
+ * @returns {Promise<import('./remote.js').Manifest>} The manifest.
73
+ * @throws {IndexError} When it can't be fetched (`network`) or isn't a manifest (`network`).
74
+ */
75
+ export async function fetchManifest(url, { timeout = TIMEOUT } = {}) {
76
+ const res = await get(url, timeout);
77
+ let m;
78
+ try {
79
+ m = await res.json();
80
+ } catch {
81
+ m = null;
82
+ }
83
+ if (!isManifest(m)) throw new IndexError('network', `${url} isn't a Logomarks index manifest`);
84
+ return m;
85
+ }
86
+
87
+ /**
88
+ * The index a manifest describes, its size and sha256 checked.
89
+ *
90
+ * @param {string} url The index's URL.
91
+ * @param {import('./remote.js').Manifest} manifest What it must match.
92
+ * @param {{ timeout?: number }} [o] How long it may take.
93
+ * @returns {Promise<Uint8Array>} The verified bytes.
94
+ * @throws {IndexError} When it can't be fetched (`network`) or doesn't match (`checksum`).
95
+ */
96
+ export async function fetchIndex(url, manifest, { timeout = TIMEOUT } = {}) {
97
+ const res = await get(url, timeout);
98
+ let bytes;
99
+ try {
100
+ bytes = new Uint8Array(await res.arrayBuffer());
101
+ } catch (e) {
102
+ throw new IndexError('network', `the download from ${url} broke off (${reason(e)})`);
103
+ }
104
+ const sum = sha256(bytes);
105
+ if (bytes.length !== manifest.bytes || sum !== manifest.sha256) {
106
+ throw new IndexError('checksum', `the index from ${url} doesn't match its manifest (sha256 ${sum.slice(0, 12)}…, ${bytes.length} bytes; expected ${manifest.sha256.slice(0, 12)}…, ${manifest.bytes} bytes), so it wasn't saved: try again later with logomarks update`);
107
+ }
108
+ return bytes;
109
+ }
@@ -27,15 +27,17 @@ export interface Tool<Input = Record<string, unknown>> {
27
27
  export interface ServerOptions {
28
28
  /** Where relative paths resolve (the process's working folder). */
29
29
  cwd?: string;
30
+ /** Use the cached set alone, never the network (default: `LOGOMARKS_OFFLINE`). */
31
+ offline?: boolean;
30
32
  }
31
33
 
32
34
  /** Every tool, in the order an agent uses them. */
33
35
  export const TOOLS: Tool<never>[];
34
36
  /** What the server tells the agent when it connects. */
35
37
  export const INSTRUCTIONS: string;
36
- /** An MCP server with every tool registered, not yet connected. */
38
+ /** An MCP server with every tool registered, not yet connected. Each tool call first waits for the live set (cached or downloaded). */
37
39
  export function createServer(o?: ServerOptions): McpServer;
38
- /** Loads the renderer, the typefaces and the live set. */
39
- export function warm(): Promise<void>;
40
+ /** Gets and loads the live set, and loads the renderer and the typefaces; rejects when the set can't be had. */
41
+ export function warm(o?: Pick<ServerOptions, 'offline'>): Promise<void>;
40
42
  /** Runs the server over stdio until the client hangs up. */
41
43
  export function serve(o?: ServerOptions): Promise<McpServer>;
package/src/mcp/index.js CHANGED
@@ -3,8 +3,11 @@
3
3
  *
4
4
  * The MCP server: the library's functions as typed tools for an AI agent
5
5
  * (Claude Code, Codex, Cursor …), over stdio. It runs on the user's machine;
6
- * nothing leaves it. The renderer, the typefaces and the live set load once
7
- * and stay warm across calls. `logomarks mcp` starts it.
6
+ * nothing leaves it. The live set is the CLI's (cached, or downloaded from
7
+ * logomarks.ai on first use: every tool call waits for it, and one that needs
8
+ * it fails with the reason when it can't be had). The renderer, the typefaces
9
+ * and the live set load once and stay warm across calls. `logomarks mcp`
10
+ * starts it.
8
11
  */
9
12
 
10
13
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
@@ -12,32 +15,42 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
12
15
  import { TOOLS } from './tools/index.js';
13
16
  import { failure } from './content.js';
14
17
  import { loadIndex } from '../similar/store.js';
18
+ import { prepareIndex } from '../live/ensure.js';
15
19
  import { warmRenderer } from '../raster/render.js';
16
20
  import { VERSION } from '../util/version.js';
17
21
 
18
22
  export { TOOLS };
19
23
 
20
24
  /** What the server tells the agent when it connects. */
21
- export const INSTRUCTIONS = `Logomarks: the free Logomarks set of geometric logomarks (CC0, bundled, searched offline), and the checks the set is held to, run locally.
25
+ export const INSTRUCTIONS = `Logomarks: the free Logomarks set of geometric logomarks (CC0; downloaded from logomarks.ai on first use, then cached and searched locally), and the checks the set is held to, run locally.
22
26
  - Pick a mark: find_marks, then get_mark.
23
27
  - Draw your own: write the SVG to the house rules (512 canvas, longest side 448, centred; filled paths in currentColor; one gap of 22; nothing under 10 units), then check_mark, similar_marks and review_sheet. Fix every error, look at the sheet, and repeat until check_mark is ok.
24
28
  - Make it your own: list_parts, then edit_mark (delete, move, round, offset, mirror, two-tone …), glitch_mark, combine_marks. Each result is fitted and checked.
25
29
  - Build on a mark: build_brand_kit and build_social_pack (folders of files), render_mockup, render_cover.
26
30
  Tools that make images return a preview to look at; pass out to write the file. Relative paths resolve against the server's working folder. The full brand kit, photoreal mockups and custom logos are at https://logomarks.ai/pricing.`;
27
31
 
32
+ /**
33
+ * Gets the live set once per process (see `prepareIndex`), warnings to stderr (stdout is the protocol's).
34
+ *
35
+ * @param {boolean | undefined} offline Use the cache alone.
36
+ * @returns {Promise<unknown>} Resolves when it's settled, had or not.
37
+ */
38
+ const ready = (offline) => prepareIndex({ offline, warn: (m) => console.error(m) });
39
+
28
40
  /**
29
41
  * An MCP server with every tool registered. Errors come back as tool errors
30
42
  * (`isError`) with a message the agent can act on.
31
43
  *
32
- * @param {import('./index.js').ServerOptions} [o] Where relative paths resolve.
44
+ * @param {import('./index.js').ServerOptions} [o] Where relative paths resolve, and whether to stay offline.
33
45
  * @returns {McpServer} The server, not yet connected.
34
46
  */
35
- export function createServer({ cwd = process.cwd() } = {}) {
47
+ export function createServer({ cwd = process.cwd(), offline } = {}) {
36
48
  const server = new McpServer({ name: 'logomarks', title: 'Logomarks', version: VERSION, websiteUrl: 'https://logomarks.ai' }, { instructions: INSTRUCTIONS });
37
49
  for (const t of TOOLS) {
38
50
  const run = /** @type {(args: object, ctx: import('./index.js').ToolContext) => Promise<import('./content.js').ToolResult>} */ (t.run);
39
51
  server.registerTool(t.name, { title: t.title, description: t.description, inputSchema: t.inputSchema, annotations: t.annotations }, async (args) => {
40
52
  try {
53
+ await ready(offline);
41
54
  return await run(args, { cwd });
42
55
  } catch (e) {
43
56
  return failure(e);
@@ -48,25 +61,25 @@ export function createServer({ cwd = process.cwd() } = {}) {
48
61
  }
49
62
 
50
63
  /**
51
- * Loads the renderer, the typefaces and the live set in the background, so
52
- * the first call is as quick as the rest.
64
+ * Gets and loads the live set, the renderer and the typefaces in the background, so the first call is as quick as
65
+ * the rest.
53
66
  *
54
- * @returns {Promise<void>} Resolves when all three are ready.
67
+ * @param {Pick<import('./index.js').ServerOptions, 'offline'>} [o] Whether to stay offline.
68
+ * @returns {Promise<void>} Resolves when all three are ready; rejects when the live set can't be had.
55
69
  */
56
- export async function warm() {
57
- loadIndex();
58
- await warmRenderer();
70
+ export async function warm({ offline } = {}) {
71
+ await Promise.all([ready(offline).then(() => loadIndex()), warmRenderer()]);
59
72
  }
60
73
 
61
74
  /**
62
75
  * Runs the server on stdin and stdout until the client hangs up.
63
76
  *
64
- * @param {import('./index.js').ServerOptions} [o] Where relative paths resolve.
77
+ * @param {import('./index.js').ServerOptions} [o] Where relative paths resolve, and whether to stay offline.
65
78
  * @returns {Promise<McpServer>} The connected server.
66
79
  */
67
80
  export async function serve(o) {
68
81
  const server = createServer(o);
69
82
  await server.connect(new StdioServerTransport());
70
- setImmediate(() => warm().catch((e) => console.error(`logomarks: ${e.message}`)));
83
+ setImmediate(() => warm(o).catch((e) => console.error(`logomarks: ${e.message}`)));
71
84
  return server;
72
85
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @module logomarks/mcp/tools/find
3
3
  *
4
- * `find_marks`: search the bundled live set by words and filters.
4
+ * `find_marks`: search the live set by words and filters.
5
5
  */
6
6
 
7
7
  import { z } from 'zod';
@@ -13,7 +13,7 @@ export const tool = {
13
13
  name: 'find_marks',
14
14
  title: 'Find live Logomarks',
15
15
  description: [
16
- 'Search the free Logomarks set (geometric logomarks, CC0, bundled offline): use it first when someone needs a logo, icon or brand mark, or a mark to build on.',
16
+ 'Search the free Logomarks set (geometric logomarks, CC0; downloaded from logomarks.ai on first use, then cached and searched locally): use it first when someone needs a logo, icon or brand mark, or a mark to build on.',
17
17
  'Search works as on logomarks.ai: every word must be found in a mark (its name, search words, industries, style, tags, mood, letter or description), trades by their UK and US names too ("solicitor" finds the lawyers\' marks); the best fits come first. Filler words (a, for, logo) are ignored.',
18
18
  'Filters narrow it exactly. Call with no arguments to list every style, tag, mood and industry with its count.',
19
19
  'Returns { total, marks: [{ slug, name, style, description, url … }] }: pass a slug to get_mark, build_brand_kit, render_mockup or render_cover.',
@@ -11,7 +11,7 @@ export interface SheetMark {
11
11
 
12
12
  /** How to check the marks. */
13
13
  export interface SheetOptions {
14
- /** The live marks (the bundled index). */
14
+ /** The live marks (the cached index). */
15
15
  index?: MarkIndex;
16
16
  /** Warnings fail a mark too (false). */
17
17
  strict?: boolean;
@@ -2,11 +2,11 @@
2
2
  * @module logomarks/similar/folder
3
3
  *
4
4
  * An index built on the spot, for comparing against marks that aren't in the
5
- * bundled one: a folder of SVGs (the site's svg folder while a batch is
5
+ * live set: a folder of SVGs (the site's svg folder while a batch is
6
6
  * drawn), or the candidates themselves (each against the others). Names, tags
7
7
  * come from a `marks.json` beside the folder when there is one; the letter
8
8
  * signatures cover badges (their holes) and letter and number marks (their ink),
9
- * as the bundled index does.
9
+ * as the live set's index does.
10
10
  */
11
11
 
12
12
  import fs from 'node:fs';