@open-assets/open-assets 0.1.11 → 0.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.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
3
  import { resolve, join } from "path";
4
- import { existsSync, readFileSync } from "fs";
4
+ import { readFileSync } from "fs";
5
5
 
6
6
  /**
7
7
  * Resolve an option value with env var fallback.
@@ -11,13 +11,6 @@ function env(name, fallback) {
11
11
  return process.env[name] ?? fallback;
12
12
  }
13
13
 
14
- /**
15
- * Get all export sizes from a collection, flattened.
16
- */
17
- function flatSizes(col) {
18
- return (col.exportSizes || []).flatMap((group) => group.sizes || []);
19
- }
20
-
21
14
  const program = new Command();
22
15
 
23
16
  const pkg = JSON.parse(
@@ -34,22 +27,24 @@ program
34
27
  program
35
28
  .command("dev")
36
29
  .description("Start the dev server with live preview and export UI")
37
- .argument("[dir]", "Project directory containing manifest.json", ".")
30
+ .argument("[dir]", "Project directory containing assets.json", ".")
38
31
  .option("-p, --port <port>", "Port to listen on (env: OPEN_ASSETS_PORT)", env("OPEN_ASSETS_PORT", "3200"))
39
32
  .option("-H, --host <host>", "Host to bind to (env: OPEN_ASSETS_HOST)", env("OPEN_ASSETS_HOST", "localhost"))
40
33
  .option("--no-open", "Don't auto-open the browser (env: OPEN_ASSETS_NO_OPEN)")
41
34
  .option("-q, --quiet", "Suppress server logs (env: OPEN_ASSETS_QUIET)", env("OPEN_ASSETS_QUIET") === "true")
42
35
  .option("--ci", "CI mode: no browser, quiet logging", env("CI") === "true")
43
- .option("--manifest <path>", "Path to manifest file (env: OPEN_ASSETS_MANIFEST)", env("OPEN_ASSETS_MANIFEST", "manifest.json"))
36
+ .option("--config <path>", "Path to config file (env: OPEN_ASSETS_CONFIG)", env("OPEN_ASSETS_CONFIG", "assets.json"))
44
37
  .option("--static-dir <dirs...>", "Additional static directories to serve")
45
38
  .option("--render-timeout <ms>", "Puppeteer render timeout in ms (env: OPEN_ASSETS_RENDER_TIMEOUT)", env("OPEN_ASSETS_RENDER_TIMEOUT", "30000"))
46
39
  .action(async (dir, opts) => {
40
+ const { loadConfig } = await import("../lib/manifest.mjs");
47
41
  const projectDir = resolve(dir);
48
- const manifestPath = resolve(projectDir, opts.manifest);
49
42
 
50
- if (!existsSync(manifestPath)) {
43
+ try {
44
+ loadConfig(projectDir, opts.config);
45
+ } catch {
51
46
  console.error(
52
- `Error: ${opts.manifest} not found in ${projectDir}\n` +
47
+ `Error: ${opts.config} not found in ${projectDir}\n` +
53
48
  `Run "open-assets init" to create one.`
54
49
  );
55
50
  process.exit(1);
@@ -68,18 +63,19 @@ program
68
63
 
69
64
  const { startServer } = await import("../lib/server.mjs");
70
65
  const port = parseInt(opts.port, 10);
71
- startServer(projectDir, {
66
+ const { port: actualPort } = await startServer(projectDir, {
72
67
  port,
73
68
  host: opts.host,
74
69
  quiet: opts.quiet,
75
- manifestPath,
70
+ manifestPath: resolve(projectDir, opts.config),
76
71
  staticDirs: opts.staticDir,
77
72
  renderTimeout: parseInt(opts.renderTimeout, 10),
78
73
  });
79
74
 
80
75
  if (opts.open) {
81
76
  const open = (await import("open")).default;
82
- setTimeout(() => open(`http://${opts.host}:${port}`), 500);
77
+ const displayHost = opts.host === "0.0.0.0" ? "localhost" : opts.host;
78
+ setTimeout(() => open(`http://${displayHost}:${actualPort}`), 500);
83
79
  }
84
80
  });
85
81
 
@@ -88,219 +84,65 @@ program
88
84
  program
89
85
  .command("render")
90
86
  .description("Render assets headlessly via CLI")
91
- .argument("[dir]", "Project directory containing manifest.json", ".")
87
+ .argument("[dir]", "Project directory containing assets.json", ".")
92
88
  .option("--collection <id>", "Render only the collection with this ID")
93
89
  .option("--tag <tag>", "Render only collections with this tag")
94
90
  .option("--template <name>", "Render only the template with this name")
95
- .option("--size <name>", "Use a named export size from manifest")
96
- .option("--platform <name>", "Render only sizes for this platform")
91
+ .option("--size <name>", "Use a named export size from config")
97
92
  .option("--width <px>", "Output width in pixels (custom size)")
98
93
  .option("--height <px>", "Output height in pixels (custom size)")
99
- .option("--all", "Export at every size defined in the manifest")
100
- .option("--force", "Re-render all assets even if unchanged")
94
+ .option("-f, --force", "Export at every size and re-render all assets (ignores cache)")
101
95
  .option("-o, --output <dir>", "Output directory (env: OPEN_ASSETS_OUTPUT)", env("OPEN_ASSETS_OUTPUT", "./exports"))
102
- .option("--manifest <path>", "Path to manifest file (env: OPEN_ASSETS_MANIFEST)", env("OPEN_ASSETS_MANIFEST", "manifest.json"))
96
+ .option("--config <path>", "Path to config file (env: OPEN_ASSETS_CONFIG)", env("OPEN_ASSETS_CONFIG", "assets.json"))
103
97
  .option("--parallel <count>", "Number of parallel renders (env: OPEN_ASSETS_PARALLEL)", env("OPEN_ASSETS_PARALLEL", "1"))
104
98
  .option("--render-timeout <ms>", "Puppeteer render timeout in ms", env("OPEN_ASSETS_RENDER_TIMEOUT", "30000"))
105
99
  .option("--json", "Output results as JSON (for CI/scripting)")
106
100
  .option("-q, --quiet", "Suppress progress logs", env("OPEN_ASSETS_QUIET") === "true")
107
101
  .action(async (dir, opts) => {
108
- const projectDir = resolve(dir);
109
- const manifestPath = resolve(projectDir, opts.manifest);
102
+ const { loadConfig } = await import("../lib/manifest.mjs");
103
+ const { renderAssets } = await import("../lib/render.mjs");
104
+ const renderer = await import("../lib/renderer.mjs");
105
+ const lockfile = await import("../lib/lockfile.mjs");
110
106
 
111
- if (!existsSync(manifestPath)) {
112
- console.error(`Error: ${opts.manifest} not found in ${projectDir}`);
107
+ const projectDir = resolve(dir);
108
+ let manifest;
109
+ try {
110
+ manifest = loadConfig(projectDir, opts.config);
111
+ } catch (err) {
112
+ console.error(`Error: ${err.message}`);
113
113
  process.exit(1);
114
114
  }
115
115
 
116
- const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
117
-
118
- const { renderScreenshot, runXcodeOutput, closeBrowser } = await import(
119
- "../lib/renderer.mjs"
120
- );
121
- const { readLockfile, writeLockfile, computeChecksum, isUpToDate, recordExport } = await import(
122
- "../lib/lockfile.mjs"
123
- );
124
- const { mkdirSync, writeFileSync, copyFileSync } = await import("fs");
125
-
126
- const outputDir = resolve(opts.output);
127
- mkdirSync(outputDir, { recursive: true });
128
-
129
- let collections = manifest.collections;
130
- if (opts.collection) {
131
- collections = collections.filter((c) => c.id === opts.collection);
132
- }
133
- if (opts.tag) {
134
- collections = collections.filter((c) => (c.tags || []).includes(opts.tag));
135
- }
136
-
137
- if (collections.length === 0) {
138
- const filter = opts.collection ? `collection "${opts.collection}"` : `tag "${opts.tag}"`;
139
- console.error(`Error: no collections matched ${filter} in manifest`);
116
+ const log = opts.quiet ? undefined : (msg) => console.log(msg);
117
+ const deps = {
118
+ renderScreenshot: renderer.renderScreenshot,
119
+ runXcodeOutput: renderer.runXcodeOutput,
120
+ closeBrowser: renderer.closeBrowser,
121
+ readLockfile: lockfile.readLockfile,
122
+ writeLockfile: lockfile.writeLockfile,
123
+ computeChecksum: lockfile.computeChecksum,
124
+ isUpToDate: lockfile.isUpToDate,
125
+ recordExport: lockfile.recordExport,
126
+ };
127
+
128
+ const result = await renderAssets(projectDir, manifest, opts, deps, log);
129
+
130
+ if (result.error) {
131
+ console.error(`Error: ${result.error}`);
140
132
  process.exit(1);
141
133
  }
142
134
 
143
- const lockData = readLockfile(projectDir);
144
- const results = [];
145
- let skipped = 0;
146
- const startTime = Date.now();
147
-
148
- for (const col of collections) {
149
- const sourceW = col.sourceSize.width;
150
- const sourceH = col.sourceSize.height;
151
-
152
- // Filter templates
153
- const templates = opts.template
154
- ? col.templates.filter((t) => t.name === opts.template || t.label === opts.template)
155
- : col.templates;
156
-
157
- // Determine which sizes to render
158
- let sizesToRender = [];
159
-
160
- if (opts.width && opts.height) {
161
- // Custom size from CLI
162
- sizesToRender = [{
163
- name: `${opts.width}x${opts.height}`,
164
- label: `${opts.width}x${opts.height}`,
165
- width: parseInt(opts.width),
166
- height: parseInt(opts.height),
167
- }];
168
- } else if (opts.size) {
169
- // Specific named size
170
- const found = flatSizes(col).find((s) => s.name === opts.size || s.label === opts.size);
171
- if (found) {
172
- sizesToRender = [found];
173
- } else {
174
- if (!opts.quiet) console.log(` Warning: size "${opts.size}" not found in collection "${col.id}", skipping`);
175
- continue;
176
- }
177
- } else if (opts.all) {
178
- // All sizes
179
- sizesToRender = flatSizes(col);
180
- // Filter by platform if specified
181
- if (opts.platform) {
182
- sizesToRender = (col.exportSizes || [])
183
- .filter((g) => g.platform && g.platform.toLowerCase().includes(opts.platform.toLowerCase()))
184
- .flatMap((g) => g.sizes || []);
185
- }
186
- } else {
187
- // Default: render at source size
188
- sizesToRender = [{
189
- name: `${sourceW}x${sourceH}`,
190
- label: `${sourceW}x${sourceH}`,
191
- width: sourceW,
192
- height: sourceH,
193
- }];
194
- }
195
-
196
- if (sizesToRender.length === 0) {
197
- if (!opts.quiet) console.log(` No export sizes found for collection "${col.id}"`);
198
- continue;
199
- }
200
-
201
- // Determine if we need subdirectories (multiple sizes = subdirs)
202
- const useSubDirs = sizesToRender.length > 1 || opts.all;
203
-
204
- for (const size of sizesToRender) {
205
- const sizeDir = useSubDirs
206
- ? join(outputDir, col.id, size.name)
207
- : join(outputDir, col.id);
208
- mkdirSync(sizeDir, { recursive: true });
209
-
210
- if (!opts.quiet && sizesToRender.length > 1) {
211
- const platformInfo = (col.exportSizes || [])
212
- .find((g) => (g.sizes || []).some((s) => s.name === size.name));
213
- const platform = platformInfo?.platform ? `[${platformInfo.platform}] ` : '';
214
- console.log(`\n ${platform}${size.label} (${size.width}x${size.height})`);
215
- }
216
-
217
- for (const template of templates) {
218
- const outPath = join(sizeDir, `${template.name}.png`);
219
- const assetKey = `${col.id}/${template.name}`;
220
- const variantKey = size.name;
221
- const srcPath = resolve(projectDir, template.src);
222
- const checksum = computeChecksum(srcPath);
223
-
224
- // Skip if unchanged
225
- if (!opts.force && checksum && isUpToDate(lockData, assetKey, variantKey, checksum, outPath)) {
226
- if (!opts.quiet) console.log(` Skipping ${template.name} at ${size.width}x${size.height} (unchanged)`);
227
- skipped++;
228
- continue;
229
- }
230
-
231
- if (!opts.quiet) console.log(` Rendering ${template.name} at ${size.width}x${size.height}...`);
232
-
233
- // Detect SVG source → use copy-source approach for SVG outputs
234
- const isSvg = template.src.endsWith(".svg");
235
-
236
- if (isSvg && size.width === sourceW && size.height === sourceH) {
237
- // For SVGs at source size, just copy the source
238
- copyFileSync(srcPath, outPath.replace(".png", ".svg"));
239
- }
240
-
241
- const buffer = await renderScreenshot(
242
- projectDir,
243
- template.src,
244
- size.width,
245
- size.height,
246
- sourceW,
247
- sourceH
248
- );
249
- writeFileSync(outPath, buffer);
250
- results.push({
251
- collection: col.id,
252
- template: template.name,
253
- path: outPath,
254
- width: size.width,
255
- height: size.height,
256
- size: buffer.length,
257
- });
258
- if (checksum) recordExport(lockData, assetKey, variantKey, checksum, outPath);
259
- if (!opts.quiet) console.log(` → ${outPath}`);
260
- }
261
- }
262
-
263
- // Handle outputs (xcode, copy-source, etc.)
264
- if (col.outputs && opts.all) {
265
- for (const output of col.outputs) {
266
- if (output.type === "xcode") {
267
- if (!opts.quiet) console.log(`\n Exporting to Xcode: ${output.path}`);
268
- const path = await runXcodeOutput(projectDir, col, output);
269
- results.push({ collection: col.id, type: "xcode", path });
270
- } else if (output.type === "copy-source") {
271
- // Copy source files (e.g., SVG originals)
272
- const format = output.format || "svg";
273
- const copyDir = join(outputDir, col.id, format);
274
- mkdirSync(copyDir, { recursive: true });
275
- for (const template of templates) {
276
- if (template.src.endsWith(`.${format}`)) {
277
- const srcPath = resolve(projectDir, template.src);
278
- const destPath = join(copyDir, `${template.name}.${format}`);
279
- if (existsSync(srcPath)) {
280
- copyFileSync(srcPath, destPath);
281
- results.push({ collection: col.id, template: template.name, type: `copy-${format}`, path: destPath });
282
- if (!opts.quiet) console.log(` → ${destPath}`);
283
- }
284
- }
285
- }
286
- }
287
- }
288
- }
289
- }
290
-
291
- // Persist lockfile
292
- writeLockfile(projectDir, lockData);
293
-
294
- await closeBrowser();
295
-
296
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
297
-
298
135
  if (opts.json) {
299
- console.log(JSON.stringify({ ok: true, assets: results, skipped, elapsed: `${elapsed}s` }, null, 2));
136
+ console.log(JSON.stringify({
137
+ ok: true,
138
+ assets: result.results,
139
+ skipped: result.skipped,
140
+ elapsed: result.elapsed,
141
+ }, null, 2));
300
142
  } else if (!opts.quiet) {
301
- const parts = [`${results.length} asset(s) rendered`];
302
- if (skipped > 0) parts.push(`${skipped} skipped (unchanged)`);
303
- console.log(`\nDone. ${parts.join(", ")} in ${elapsed}s.`);
143
+ const parts = [`${result.results.length} asset(s) rendered`];
144
+ if (result.skipped > 0) parts.push(`${result.skipped} skipped (unchanged)`);
145
+ console.log(`\nDone. ${parts.join(", ")} in ${result.elapsed}.`);
304
146
  }
305
147
  });
306
148
 
@@ -308,156 +150,124 @@ program
308
150
 
309
151
  program
310
152
  .command("list")
311
- .description("List all collections and templates defined in the manifest")
312
- .argument("[dir]", "Project directory containing manifest.json", ".")
313
- .option("--manifest <path>", "Path to manifest file", env("OPEN_ASSETS_MANIFEST", "manifest.json"))
153
+ .description("List all collections and templates defined in the config")
154
+ .argument("[dir]", "Project directory containing assets.json", ".")
155
+ .option("--config <path>", "Path to config file", env("OPEN_ASSETS_CONFIG", "assets.json"))
314
156
  .option("--tag <tag>", "List only collections with this tag")
315
157
  .option("--json", "Output as JSON")
316
158
  .action(async (dir, opts) => {
317
- const projectDir = resolve(dir);
318
- const manifestPath = resolve(projectDir, opts.manifest);
159
+ const { loadConfig } = await import("../lib/manifest.mjs");
160
+ const { listCollections, formatCollectionList } = await import("../lib/list.mjs");
319
161
 
320
- if (!existsSync(manifestPath)) {
321
- console.error(`Error: ${opts.manifest} not found in ${projectDir}`);
162
+ const projectDir = resolve(dir);
163
+ let manifest;
164
+ try {
165
+ manifest = loadConfig(projectDir, opts.config);
166
+ } catch (err) {
167
+ console.error(`Error: ${err.message}`);
322
168
  process.exit(1);
323
169
  }
324
170
 
325
- const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
326
-
327
171
  if (opts.json) {
328
172
  console.log(JSON.stringify(manifest, null, 2));
329
173
  return;
330
174
  }
331
175
 
332
- // Show available tags if defined
333
- if (manifest.tags && manifest.tags.length > 0 && !opts.tag) {
334
- console.log(`\n Tags: ${manifest.tags.map((t) => t.id).join(", ")}`);
335
- }
336
-
337
- let cols = manifest.collections;
338
- if (opts.tag) {
339
- cols = cols.filter((c) => (c.tags || []).includes(opts.tag));
340
- if (cols.length === 0) {
341
- console.error(`\n No collections found with tag "${opts.tag}"\n`);
342
- process.exit(1);
343
- }
176
+ const result = listCollections(manifest, { tag: opts.tag });
177
+ if (result.error) {
178
+ console.error(`\n ${result.error}\n`);
179
+ process.exit(1);
344
180
  }
345
181
 
346
- console.log(`\n ${manifest.name}\n`);
347
- for (const col of cols) {
348
- const templateCount = col.templates.length;
349
- const sizeCount = flatSizes(col).length;
350
- const tagStr = (col.tags && col.tags.length > 0) ? ` [${col.tags.join(", ")}]` : "";
351
- console.log(` ${col.label} (${col.id})${tagStr} — ${templateCount} template(s), ${sizeCount} size(s)`);
352
-
353
- for (const template of col.templates) {
354
- console.log(` • ${template.label || template.name} → ${template.src}`);
355
- }
356
-
357
- for (const group of col.exportSizes || []) {
358
- for (const size of group.sizes || []) {
359
- const platform = group.platform ? `${group.platform}: ` : '';
360
- console.log(` ↳ ${platform}${size.label} (${size.width}×${size.height})`);
361
- }
362
- }
363
-
364
- if (col.outputs) {
365
- for (const output of col.outputs) {
366
- if (output.type === "xcode") console.log(` ↳ Xcode: ${output.path}`);
367
- else if (output.type === "copy-source") console.log(` ↳ Copy source: ${output.format}`);
368
- }
369
- }
370
- }
371
- console.log();
182
+ console.log(`\n ${formatCollectionList(manifest, result.collections).split("\n").join("\n ")}\n`);
372
183
  });
373
184
 
374
185
  // ── validate ─────────────────────────────────────────────────────────────────
375
186
 
376
187
  program
377
188
  .command("validate")
378
- .description("Validate the manifest.json and check that all referenced files exist")
379
- .argument("[dir]", "Project directory containing manifest.json", ".")
380
- .option("--manifest <path>", "Path to manifest file", env("OPEN_ASSETS_MANIFEST", "manifest.json"))
189
+ .description("Validate the assets.json and check that all referenced files exist")
190
+ .argument("[dir]", "Project directory containing assets.json", ".")
191
+ .option("--config <path>", "Path to config file", env("OPEN_ASSETS_CONFIG", "assets.json"))
192
+ .option("--fix", "Interactively fix issues (e.g. remove missing templates)")
381
193
  .action(async (dir, opts) => {
382
- const projectDir = resolve(dir);
383
- const manifestPath = resolve(projectDir, opts.manifest);
384
-
385
- if (!existsSync(manifestPath)) {
386
- console.error(`Error: ${opts.manifest} not found in ${projectDir}`);
387
- process.exit(1);
388
- }
194
+ const { loadConfig, saveConfig } = await import("../lib/manifest.mjs");
195
+ const { validateConfig } = await import("../lib/validate.mjs");
389
196
 
197
+ const projectDir = resolve(dir);
390
198
  let manifest;
391
199
  try {
392
- manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
200
+ manifest = loadConfig(projectDir, opts.config);
393
201
  } catch (err) {
394
- console.error(`Error: Invalid JSON in ${opts.manifest}: ${err.message}`);
202
+ console.error(`Error: ${err.message}`);
395
203
  process.exit(1);
396
204
  }
397
205
 
398
- let errors = 0;
399
- const ok = (msg) => console.log(` ✓ ${msg}`);
400
- const fail = (msg) => { console.log(` ✗ ${msg}`); errors++; };
206
+ console.log(`\n Validating ${opts.config}...\n`);
401
207
 
402
- console.log(`\n Validating ${opts.manifest}...\n`);
208
+ const { checks, errors, missingTemplates, emptyCollections } = validateConfig(manifest, projectDir);
403
209
 
404
- // Check top-level fields
405
- if (manifest.name) ok(`name: "${manifest.name}"`);
406
- else fail("Missing top-level 'name' field");
210
+ for (const check of checks) {
211
+ console.log(` ${check.ok ? "\u2713" : "\u2717"} ${check.message}`);
212
+ }
407
213
 
408
- if (Array.isArray(manifest.collections) && manifest.collections.length > 0) {
409
- ok(`${manifest.collections.length} collection(s) defined`);
214
+ console.log();
215
+ if (errors === 0) {
216
+ console.log(" All checks passed.\n");
410
217
  } else {
411
- fail("Missing or empty 'collections' array");
412
- process.exit(1);
413
- }
218
+ console.log(` ${errors} error(s) found.\n`);
414
219
 
415
- // Check each collection
416
- const ids = new Set();
417
- for (const col of manifest.collections) {
418
- if (!col.id) { fail(`Collection missing 'id' field`); continue; }
419
- if (ids.has(col.id)) { fail(`Duplicate collection id: "${col.id}"`); }
420
- ids.add(col.id);
421
-
422
- if (!col.sourceSize || !col.sourceSize.width || !col.sourceSize.height) {
423
- fail(`Collection "${col.id}" missing or invalid 'sourceSize'`);
424
- } else {
425
- ok(`Collection "${col.id}" sourceSize: ${col.sourceSize.width}×${col.sourceSize.height}`);
426
- }
220
+ const fixable = missingTemplates.length > 0 || emptyCollections.length > 0;
221
+
222
+ if (fixable && opts.fix) {
223
+ const { createInterface } = await import("readline");
224
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
225
+ const ask = (question) =>
226
+ new Promise((resolve) => rl.question(question, (answer) => resolve(answer.trim().toLowerCase())));
227
+
228
+ let changes = 0;
229
+
230
+ for (const missing of missingTemplates) {
231
+ const answer = await ask(
232
+ ` Remove template "${missing.templateSrc}" from collection "${missing.collectionId}"? (y/N) `
233
+ );
234
+ if (answer === "y" || answer === "yes") {
235
+ const col = manifest.collections.find((c) => c.id === missing.collectionId);
236
+ if (col) {
237
+ col.templates = col.templates.filter((t) => t.src !== missing.templateSrc);
238
+ changes++;
239
+ }
240
+ }
241
+ }
427
242
 
428
- if (!col.templates || col.templates.length === 0) {
429
- fail(`Collection "${col.id}" has no templates`);
430
- } else {
431
- for (const template of col.templates) {
432
- if (!template.src) {
433
- fail(`Collection "${col.id}" has a template without 'src'`);
434
- continue;
243
+ // Include collections that were already empty plus ones emptied by template removal
244
+ const emptyIds = new Set(emptyCollections.map((e) => e.collectionId));
245
+ for (const col of manifest.collections) {
246
+ if (!col.templates || col.templates.length === 0) {
247
+ emptyIds.add(col.id);
435
248
  }
436
- const filePath = resolve(projectDir, template.src);
437
- if (existsSync(filePath)) ok(`${template.src} exists`);
438
- else fail(`${template.src} not found`);
439
249
  }
440
- }
441
250
 
442
- const sizes = flatSizes(col);
443
- if (sizes.length > 0) {
444
- ok(`Collection "${col.id}" has ${sizes.length} export size(s)`);
445
- }
251
+ for (const collectionId of emptyIds) {
252
+ const answer = await ask(
253
+ ` Remove empty collection "${collectionId}"? (y/N) `
254
+ );
255
+ if (answer === "y" || answer === "yes") {
256
+ manifest.collections = manifest.collections.filter((c) => c.id !== collectionId);
257
+ changes++;
258
+ }
259
+ }
446
260
 
447
- // Validate outputs
448
- if (col.outputs) {
449
- for (const output of col.outputs) {
450
- if (!output.type) fail(`Collection "${col.id}" has an output without 'type'`);
451
- else ok(`Output: ${output.type}`);
261
+ rl.close();
262
+
263
+ if (changes > 0) {
264
+ saveConfig(projectDir, opts.config, manifest);
265
+ console.log(`\n Updated ${opts.config} (${changes} fix(es) applied).\n`);
452
266
  }
267
+ } else if (fixable) {
268
+ console.log(` Run with --fix to remove missing templates and empty collections.\n`);
453
269
  }
454
- }
455
270
 
456
- console.log();
457
- if (errors === 0) {
458
- console.log(" All checks passed.\n");
459
- } else {
460
- console.log(` ${errors} error(s) found.\n`);
461
271
  process.exit(1);
462
272
  }
463
273
  });
@@ -466,13 +276,49 @@ program
466
276
 
467
277
  program
468
278
  .command("init")
469
- .description("Scaffold a new manifest.json and example assets")
279
+ .description("Scaffold a new assets.json and example assets")
470
280
  .argument("[dir]", "Target directory", ".")
471
281
  .action(async (dir) => {
472
282
  const { scaffoldProject } = await import("../lib/init.mjs");
473
283
  await scaffoldProject(resolve(dir));
474
284
  });
475
285
 
286
+ // ── add ─────────────────────────────────────────────────────────────────────
287
+
288
+ const add = program
289
+ .command("add")
290
+ .description("Add collections, templates, or export sizes to the config");
291
+
292
+ add
293
+ .command("collection")
294
+ .description("Add a new asset collection (choose from presets or custom)")
295
+ .argument("[dir]", "Project directory containing assets.json", ".")
296
+ .option("--config <path>", "Path to config file", env("OPEN_ASSETS_CONFIG", "assets.json"))
297
+ .action(async (dir, opts) => {
298
+ const { addCollection } = await import("../lib/add.mjs");
299
+ await addCollection(resolve(dir), opts.config);
300
+ });
301
+
302
+ add
303
+ .command("template")
304
+ .description("Add a new template to an existing collection (HTML or SVG)")
305
+ .argument("[dir]", "Project directory containing assets.json", ".")
306
+ .option("--config <path>", "Path to config file", env("OPEN_ASSETS_CONFIG", "assets.json"))
307
+ .action(async (dir, opts) => {
308
+ const { addTemplate } = await import("../lib/add.mjs");
309
+ await addTemplate(resolve(dir), opts.config);
310
+ });
311
+
312
+ add
313
+ .command("size")
314
+ .description("Add an export size to a collection (choose from presets or custom)")
315
+ .argument("[dir]", "Project directory containing assets.json", ".")
316
+ .option("--config <path>", "Path to config file", env("OPEN_ASSETS_CONFIG", "assets.json"))
317
+ .action(async (dir, opts) => {
318
+ const { addSize } = await import("../lib/add.mjs");
319
+ await addSize(resolve(dir), opts.config);
320
+ });
321
+
476
322
  // ── skills ──────────────────────────────────────────────────────────────────
477
323
 
478
324
  program