1#!/usr/bin/env node
2
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import * as HostProcess from "@t3tools/shared/HostProcess";
6import * as Console from "effect/Console";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Option from "effect/Option";
10import * as Path from "effect/Path";
11import * as Schema from "effect/Schema";
12import * as Stream from "effect/Stream";
13import { Command, Flag } from "effect/cli";
14import { ChildProcess, ChildProcessSpawner } from "effect/process";
15import sharp from "sharp";
16
17import { BRAND_ASSET_PATHS, DEVELOPMENT_PUBLIC_ICON_OVERRIDES } from "./lib/brand-assets.ts";
18import { encodePngIco, readPngDimensions, WINDOWS_ICON_SIZES } from "./lib/icon-export.ts";
19
20const DESIGN_GENERATION = 26;
21const ICON_COMPOSER_EXECUTABLE_PARTS = [
22 "Contents",
23 "Applications",
24 "Icon Composer.app",
25 "Contents",
26 "Executables",
27 "ictool",
28] as const;
29const STANDALONE_ICON_COMPOSER_EXECUTABLE_PARTS = [
30 "Icon Composer.app",
31 "Contents",
32 "Executables",
33 "ictool",
34] as const;
35const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
36
37const IconComposerVersion = Schema.Struct({
38 "bundle-version": Schema.NonEmptyString,
39 "short-bundle-version": Schema.NonEmptyString,
40});
41const decodeIconComposerVersion = Schema.decodeUnknownEffect(
42 Schema.fromJsonString(IconComposerVersion),
43);
44
45type IconPlatform = "iOS" | "macOS";
46
47interface VariantOutputs {
48 readonly ios: string;
49 readonly macos: string;
50 readonly universal: string;
51 readonly appleTouch: string;
52 readonly favicon16: string;
53 readonly favicon32: string;
54 readonly faviconIco: string;
55 readonly windowsIco: string;
56}
57
58interface IconVariant {
59 readonly label: string;
60 readonly source: string;
61 readonly outputs: VariantOutputs;
62}
63
64interface IconComposerTool {
65 readonly path: string;
66 readonly version: string;
67 readonly bundleVersion: string;
68 readonly supportsDesignGeneration: boolean;
69}
70
71interface CommandResult {
72 readonly stdout: string;
73 readonly stderr: string;
74 readonly exitCode: number;
75}
76
77export class IconExportFileSystemError extends Schema.TaggedError<IconExportFileSystemError>()(
78 "IconExportFileSystemError",
79 {
80 operation: Schema.Literals([
81 "resolve-repository-root",
82 "check-path",
83 "read-directory",
84 "read-file",
85 "make-directory",
86 "make-temp-directory",
87 "make-temp-file",
88 "write-file",
89 "rename-file",
90 ]),
91 path: Schema.String,
92 cause: Schema.Defect(),
93 },
94) {
95 override get message(): string {
96 return `Icon export file-system operation '${this.operation}' failed for ${this.path}.`;
97 }
98}
99
100export class IconExportProcessError extends Schema.TaggedError<IconExportProcessError>()(
101 "IconExportProcessError",
102 {
103 operation: Schema.Literals(["spawn", "collect-stdout", "collect-stderr", "wait-for-exit"]),
104 command: Schema.String,
105 argumentCount: NonNegativeInt,
106 cause: Schema.Defect(),
107 },
108) {
109 override get message(): string {
110 return `Icon export process operation '${this.operation}' failed for ${this.command}.`;
111 }
112}
113
114export class IconExportCommandFailedError extends Schema.TaggedError<IconExportCommandFailedError>()(
115 "IconExportCommandFailedError",
116 {
117 command: Schema.String,
118 argumentCount: NonNegativeInt,
119 exitCode: Schema.Int,
120 sourcePath: Schema.String,
121 size: Schema.Int,
122 stdout: Schema.optional(Schema.String),
123 stderr: Schema.optional(Schema.String),
124 },
125) {
126 override get message(): string {
127 return `Icon Composer failed to export ${this.sourcePath} at ${this.size}x${this.size}.`;
128 }
129}
130
131export class IconExportToolResolutionError extends Schema.TaggedError<IconExportToolResolutionError>()(
132 "IconExportToolResolutionError",
133 {
134 reason: Schema.Literals(["configured-invalid", "configured-outdated", "not-found"]),
135 designGeneration: Schema.Int,
136 toolPath: Schema.optional(Schema.String),
137 version: Schema.optional(Schema.String),
138 },
139) {
140 override get message(): string {
141 switch (this.reason) {
142 case "configured-invalid":
143 return `ICON_COMPOSER_TOOL does not point to Icon Composer's export-capable ictool: ${this.toolPath}`;
144 case "configured-outdated":
145 return `ICON_COMPOSER_TOOL points to Icon Composer ${this.version}, but version 2 or newer is required for design generation ${this.designGeneration}.`;
146 case "not-found":
147 return `Could not find an Icon Composer 2.x exporter compatible with design generation ${this.designGeneration}. Install a compatible Icon Composer/Xcode or set ICON_COMPOSER_TOOL to Icon Composer.app/Contents/Executables/ictool.`;
148 }
149 }
150}
151
152export class IconExportSourceMissingError extends Schema.TaggedError<IconExportSourceMissingError>()(
153 "IconExportSourceMissingError",
154 {
155 sourcePath: Schema.String,
156 },
157) {
158 override get message(): string {
159 return `Missing Icon Composer source project: ${this.sourcePath}`;
160 }
161}
162
163export class IconExportRenditionError extends Schema.TaggedError<IconExportRenditionError>()(
164 "IconExportRenditionError",
165 {
166 sourcePath: Schema.String,
167 outputPath: Schema.String,
168 expectedSize: Schema.Int,
169 actualWidth: Schema.optional(Schema.Int),
170 actualHeight: Schema.optional(Schema.Int),
171 cause: Schema.optional(Schema.Defect()),
172 },
173) {
174 override get message(): string {
175 const actual =
176 this.actualWidth === undefined || this.actualHeight === undefined
177 ? "an invalid PNG"
178 : `${this.actualWidth}x${this.actualHeight}`;
179 return `Icon Composer produced ${actual}; expected ${this.expectedSize}x${this.expectedSize} for ${this.sourcePath}.`;
180 }
181}
182
183export class IconExportEncodingError extends Schema.TaggedError<IconExportEncodingError>()(
184 "IconExportEncodingError",
185 {
186 variant: Schema.String,
187 cause: Schema.Defect(),
188 },
189) {
190 override get message(): string {
191 return `Failed to encode ICO renditions for the ${this.variant} icon.`;
192 }
193}
194
195export class IconExportAssetsStaleError extends Schema.TaggedError<IconExportAssetsStaleError>()(
196 "IconExportAssetsStaleError",
197 {
198 paths: Schema.Array(Schema.String),
199 },
200) {
201 override get message(): string {
202 return `Generated icon assets are stale:\n${this.paths.map((path) => `- ${path}`).join("\n")}`;
203 }
204}
205
206const ICON_VARIANTS = [
207 {
208 label: "knife",
209 source: BRAND_ASSET_PATHS.developmentIconComposerProject,
210 outputs: {
211 ios: BRAND_ASSET_PATHS.developmentIosIconPng,
212 macos: BRAND_ASSET_PATHS.developmentDesktopIconPng,
213 universal: BRAND_ASSET_PATHS.developmentUniversalIconPng,
214 appleTouch: BRAND_ASSET_PATHS.developmentWebAppleTouchIconPng,
215 favicon16: BRAND_ASSET_PATHS.developmentWebFavicon16Png,
216 favicon32: BRAND_ASSET_PATHS.developmentWebFavicon32Png,
217 faviconIco: BRAND_ASSET_PATHS.developmentWebFaviconIco,
218 windowsIco: BRAND_ASSET_PATHS.developmentWindowsIconIco,
219 },
220 },
221] as const satisfies ReadonlyArray<IconVariant>;
222
223const RepositoryRoot = Effect.service(Path.Path).pipe(
224 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
225 Effect.mapError(
226 (cause) =>
227 new IconExportFileSystemError({
228 operation: "resolve-repository-root",
229 path: new URL("..", import.meta.url).pathname,
230 cause,
231 }),
232 ),
233);
234
235const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>) =>
236 stream.pipe(
237 Stream.decodeText(),
238 Stream.runFold(
239 () => "",
240 (acc, chunk) => acc + chunk,
241 ),
242 );
243
244const runCommand = Effect.fn("iconExport.runCommand")(function* (
245 command: string,
246 args: ReadonlyArray<string>,
247) {
248 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
249 const child = yield* spawner.spawn(ChildProcess.make(command, args)).pipe(
250 Effect.mapError(
251 (cause) =>
252 new IconExportProcessError({
253 operation: "spawn",
254 command,
255 argumentCount: args.length,
256 cause,
257 }),
258 ),
259 );
260 const [stdout, stderr, exitCode] = yield* Effect.all(
261 [
262 collectStreamAsString(child.stdout).pipe(
263 Effect.mapError(
264 (cause) =>
265 new IconExportProcessError({
266 operation: "collect-stdout",
267 command,
268 argumentCount: args.length,
269 cause,
270 }),
271 ),
272 ),
273 collectStreamAsString(child.stderr).pipe(
274 Effect.mapError(
275 (cause) =>
276 new IconExportProcessError({
277 operation: "collect-stderr",
278 command,
279 argumentCount: args.length,
280 cause,
281 }),
282 ),
283 ),
284 child.exitCode.pipe(
285 Effect.map(Number),
286 Effect.mapError(
287 (cause) =>
288 new IconExportProcessError({
289 operation: "wait-for-exit",
290 command,
291 argumentCount: args.length,
292 cause,
293 }),
294 ),
295 ),
296 ],
297 { concurrency: "unbounded" },
298 );
299
300 return { stdout, stderr, exitCode } satisfies CommandResult;
301});
302
303const iconComposerToolFromDeveloperDirectory = (developerDirectory: string, path: Path.Path) =>
304 path.resolve(developerDirectory, "..", ...ICON_COMPOSER_EXECUTABLE_PARTS.slice(1));
305
306const readSelectedDeveloperDirectory = Effect.fn("iconExport.readSelectedDeveloperDirectory")(
307 function* () {
308 const result = yield* runCommand("xcode-select", ["-p"]).pipe(Effect.option);
309 return Option.flatMap(result, (output) => {
310 const developerDirectory = output.stdout.trim();
311 return output.exitCode === 0 && developerDirectory.length > 0
312 ? Option.some(developerDirectory)
313 : Option.none();
314 });
315 },
316);
317
318const findXcodeAppCandidates = Effect.fn("iconExport.findXcodeAppCandidates")(function* (
319 directory: string,
320) {
321 const fs = yield* FileSystem.FileSystem;
322 const path = yield* Path.Path;
323 const entries = yield* fs.readDirectory(directory).pipe(
324 Effect.mapError(
325 (cause) =>
326 new IconExportFileSystemError({
327 operation: "read-directory",
328 path: directory,
329 cause,
330 }),
331 ),
332 Effect.orElseSucceed(() => []),
333 );
334 return entries
335 .filter((entry) => /^Xcode.*\.app$/.test(entry))
336 .map((entry) => path.join(directory, entry, ...ICON_COMPOSER_EXECUTABLE_PARTS));
337});
338
339const probeIconComposerTool = Effect.fn("iconExport.probeIconComposerTool")(function* (
340 candidate: string,
341) {
342 const fs = yield* FileSystem.FileSystem;
343 const exists = yield* fs.exists(candidate).pipe(
344 Effect.mapError(
345 (cause) =>
346 new IconExportFileSystemError({
347 operation: "check-path",
348 path: candidate,
349 cause,
350 }),
351 ),
352 Effect.orElseSucceed(() => false),
353 );
354 if (!exists) return Option.none<IconComposerTool>();
355
356 const result = yield* runCommand(candidate, ["--version"]).pipe(Effect.option);
357 if (Option.isNone(result) || result.value.exitCode !== 0) {
358 return Option.none<IconComposerTool>();
359 }
360
361 const version = yield* decodeIconComposerVersion(result.value.stdout).pipe(Effect.option);
362 if (Option.isNone(version)) return Option.none<IconComposerTool>();
363
364 const bundleVersion = version.value["bundle-version"];
365 const shortVersion = version.value["short-bundle-version"];
366 return Option.some({
367 path: candidate,
368 version: `${shortVersion} (${bundleVersion})`,
369 bundleVersion,
370 supportsDesignGeneration: Number.parseInt(shortVersion, 10) >= 2,
371 });
372});
373
374const resolveIconComposerTool = Effect.fn("iconExport.resolveIconComposerTool")(function* () {
375 const path = yield* Path.Path;
376 const environment = yield* HostProcess.Environment;
377 const configuredTool = environment.ICON_COMPOSER_TOOL?.trim();
378 if (configuredTool) {
379 const tool = yield* probeIconComposerTool(configuredTool);
380 if (Option.isNone(tool)) {
381 return yield* new IconExportToolResolutionError({
382 reason: "configured-invalid",
383 designGeneration: DESIGN_GENERATION,
384 toolPath: configuredTool,
385 });
386 }
387 if (!tool.value.supportsDesignGeneration) {
388 return yield* new IconExportToolResolutionError({
389 reason: "configured-outdated",
390 designGeneration: DESIGN_GENERATION,
391 toolPath: configuredTool,
392 version: tool.value.version,
393 });
394 }
395 return tool.value;
396 }
397
398 const selectedDeveloperDirectory = yield* readSelectedDeveloperDirectory();
399 const configuredDeveloperDirectory = environment.DEVELOPER_DIR?.trim();
400 const homeDirectory = environment.HOME?.trim();
401 const searchDirectories = [
402 "/Applications",
403 ...(homeDirectory ? [path.join(homeDirectory, "Downloads")] : []),
404 ];
405 const xcodeCandidates = yield* Effect.forEach(searchDirectories, findXcodeAppCandidates, {
406 concurrency: "unbounded",
407 });
408 const candidates = new Set<string>([
409 ...(configuredDeveloperDirectory
410 ? [iconComposerToolFromDeveloperDirectory(configuredDeveloperDirectory, path)]
411 : []),
412 ...Option.match(selectedDeveloperDirectory, {
413 onNone: () => [],
414 onSome: (developerDirectory) => [
415 iconComposerToolFromDeveloperDirectory(developerDirectory, path),
416 ],
417 }),
418 path.join("/Applications", ...STANDALONE_ICON_COMPOSER_EXECUTABLE_PARTS),
419 ...(homeDirectory
420 ? [path.join(homeDirectory, "Applications", ...STANDALONE_ICON_COMPOSER_EXECUTABLE_PARTS)]
421 : []),
422 ...xcodeCandidates.flat(),
423 ]);
424 const probed = yield* Effect.forEach([...candidates], probeIconComposerTool, {
425 concurrency: "unbounded",
426 });
427 const compatibleTools = probed
428 .filter(Option.isSome)
429 .map((tool) => tool.value)
430 .filter((tool) => tool.supportsDesignGeneration)
431 .sort((left, right) =>
432 right.bundleVersion.localeCompare(left.bundleVersion, undefined, { numeric: true }),
433 );
434 const newestTool = compatibleTools[0];
435 if (newestTool) return newestTool;
436
437 return yield* new IconExportToolResolutionError({
438 reason: "not-found",
439 designGeneration: DESIGN_GENERATION,
440 });
441});
442
443const renderIcon = Effect.fn("iconExport.renderIcon")(function* (
444 toolPath: string,
445 sourcePath: string,
446 outputPath: string,
447 platform: IconPlatform,
448 size: number,
449) {
450 const fs = yield* FileSystem.FileSystem;
451 const args = [
452 sourcePath,
453 "--export-image",
454 "--output-file",
455 outputPath,
456 "--platform",
457 platform,
458 "--rendition",
459 "Default",
460 "--width",
461 String(size),
462 "--height",
463 String(size),
464 "--scale",
465 "1",
466 "--design-generation",
467 String(DESIGN_GENERATION),
468 ];
469 const result = yield* runCommand(toolPath, args);
470 if (result.exitCode !== 0) {
471 return yield* new IconExportCommandFailedError({
472 command: toolPath,
473 argumentCount: args.length,
474 exitCode: result.exitCode,
475 sourcePath,
476 size,
477 ...(result.stdout.trim() ? { stdout: result.stdout.trim() } : {}),
478 ...(result.stderr.trim() ? { stderr: result.stderr.trim() } : {}),
479 });
480 }
481
482 const contents = yield* fs.readFile(outputPath).pipe(
483 Effect.mapError(
484 (cause) =>
485 new IconExportFileSystemError({
486 operation: "read-file",
487 path: outputPath,
488 cause,
489 }),
490 ),
491 );
492 const buffer = Buffer.from(contents);
493 const dimensions = yield* Effect.try({
494 try: () => readPngDimensions(buffer),
495 catch: (cause) =>
496 new IconExportRenditionError({
497 sourcePath,
498 outputPath,
499 expectedSize: size,
500 cause,
501 }),
502 });
503 if (dimensions.width !== size || dimensions.height !== size) {
504 return yield* new IconExportRenditionError({
505 sourcePath,
506 outputPath,
507 expectedSize: size,
508 actualWidth: dimensions.width,
509 actualHeight: dimensions.height,
510 });
511 }
512 return buffer;
513});
514
515const renderVariant = Effect.fn("iconExport.renderVariant")(function* (
516 toolPath: string,
517 repositoryRoot: string,
518 temporaryDirectory: string,
519 variant: IconVariant,
520) {
521 const fs = yield* FileSystem.FileSystem;
522 const path = yield* Path.Path;
523 const sourcePath = path.join(repositoryRoot, variant.source);
524 const sourceExists = yield* fs.exists(sourcePath).pipe(
525 Effect.mapError(
526 (cause) =>
527 new IconExportFileSystemError({
528 operation: "check-path",
529 path: sourcePath,
530 cause,
531 }),
532 ),
533 );
534 if (!sourceExists) {
535 return yield* new IconExportSourceMissingError({ sourcePath: variant.source });
536 }
537
538 const renditionCache = new Map<string, Buffer>();
539 const render = Effect.fn("iconExport.renderVariant.rendition")(function* (
540 platform: IconPlatform,
541 size: number,
542 ) {
543 const cacheKey = `${platform}-${size}`;
544 const cached = renditionCache.get(cacheKey);
545 if (cached) return cached;
546
547 const outputPath = path.join(temporaryDirectory, `${variant.label}-${platform}-${size}.png`);
548 const contents = yield* renderIcon(toolPath, sourcePath, outputPath, platform, size);
549 renditionCache.set(cacheKey, contents);
550 return contents;
551 });
552
553 const ios = yield* render("iOS", 1024);
554 const macosFullBleed = yield* render("macOS", 1024);
555 const macos = yield* Effect.tryPromise({
556 try: async () => {
557 const body = await sharp(macosFullBleed).resize(824, 824).png().toBuffer();
558 const shadow = await sharp(body)
559 .ensureAlpha()
560 .linear([0, 0, 0, 0.2], [0, 0, 0, 0])
561 .extend({
562 top: 108,
563 bottom: 92,
564 left: 100,
565 right: 100,
566 background: "transparent",
567 })
568 .blur(12)
569 .png()
570 .toBuffer();
571 return sharp(shadow)
572 .composite([{ input: body, left: 100, top: 100 }])
573 .png()
574 .toBuffer();
575 },
576 catch: (cause) =>
577 new IconExportRenditionError({
578 sourcePath,
579 outputPath: variant.outputs.macos,
580 expectedSize: 1024,
581 cause,
582 }),
583 });
584 const icoRenditions = yield* Effect.forEach(
585 WINDOWS_ICON_SIZES,
586 (size) => render("iOS", size).pipe(Effect.map((contents) => ({ size, contents }))),
587 { concurrency: 1 },
588 );
589 const ico = yield* Effect.try({
590 try: () => encodePngIco(icoRenditions),
591 catch: (cause) => new IconExportEncodingError({ variant: variant.label, cause }),
592 });
593
594 return new Map<string, Buffer>([
595 [variant.outputs.ios, ios],
596 [variant.outputs.macos, macos],
597 [variant.outputs.universal, ios],
598 [variant.outputs.appleTouch, yield* render("iOS", 180)],
599 [variant.outputs.favicon16, yield* render("iOS", 16)],
600 [variant.outputs.favicon32, yield* render("iOS", 32)],
601 [variant.outputs.faviconIco, ico],
602 [variant.outputs.windowsIco, ico],
603 ]);
604});
605
606const writeAtomically = Effect.fn("iconExport.writeAtomically")(function* (
607 repositoryRoot: string,
608 relativePath: string,
609 contents: Buffer,
610) {
611 const fs = yield* FileSystem.FileSystem;
612 const path = yield* Path.Path;
613 const targetPath = path.join(repositoryRoot, relativePath);
614 const targetDirectory = path.dirname(targetPath);
615 yield* fs.makeDirectory(targetDirectory, { recursive: true }).pipe(
616 Effect.mapError(
617 (cause) =>
618 new IconExportFileSystemError({
619 operation: "make-directory",
620 path: targetDirectory,
621 cause,
622 }),
623 ),
624 );
625 const temporaryPath = yield* fs
626 .makeTempFileScoped({
627 directory: targetDirectory,
628 prefix: ".t3-icon-export-",
629 suffix: ".tmp",
630 })
631 .pipe(
632 Effect.mapError(
633 (cause) =>
634 new IconExportFileSystemError({
635 operation: "make-temp-file",
636 path: targetDirectory,
637 cause,
638 }),
639 ),
640 );
641 yield* fs.writeFile(temporaryPath, contents).pipe(
642 Effect.mapError(
643 (cause) =>
644 new IconExportFileSystemError({
645 operation: "write-file",
646 path: temporaryPath,
647 cause,
648 }),
649 ),
650 );
651 yield* fs.rename(temporaryPath, targetPath).pipe(
652 Effect.mapError(
653 (cause) =>
654 new IconExportFileSystemError({
655 operation: "rename-file",
656 path: targetPath,
657 cause,
658 }),
659 ),
660 );
661});
662
663const isCurrent = Effect.fn("iconExport.isCurrent")(function* (
664 repositoryRoot: string,
665 relativePath: string,
666 expected: Buffer,
667) {
668 const fs = yield* FileSystem.FileSystem;
669 const path = yield* Path.Path;
670 const targetPath = path.join(repositoryRoot, relativePath);
671 const exists = yield* fs.exists(targetPath).pipe(
672 Effect.mapError(
673 (cause) =>
674 new IconExportFileSystemError({
675 operation: "check-path",
676 path: targetPath,
677 cause,
678 }),
679 ),
680 );
681 if (!exists) return false;
682
683 const actual = yield* fs.readFile(targetPath).pipe(
684 Effect.mapError(
685 (cause) =>
686 new IconExportFileSystemError({
687 operation: "read-file",
688 path: targetPath,
689 cause,
690 }),
691 ),
692 );
693 return Buffer.from(actual).equals(expected);
694});
695
696export const exportBrandIcons = Effect.fn("exportBrandIcons")(function* (checkOnly: boolean) {
697 const fs = yield* FileSystem.FileSystem;
698 const repositoryRoot = yield* RepositoryRoot;
699 const tool = yield* resolveIconComposerTool();
700 const temporaryDirectory = yield* fs
701 .makeTempDirectoryScoped({
702 prefix: "t3-icon-export-",
703 })
704 .pipe(
705 Effect.mapError(
706 (cause) =>
707 new IconExportFileSystemError({
708 operation: "make-temp-directory",
709 path: "system temporary directory",
710 cause,
711 }),
712 ),
713 );
714 yield* Console.log(
715 `Exporting icons with Icon Composer ${tool.version}, design generation ${DESIGN_GENERATION}.`,
716 );
717
718 const generated = new Map<string, Buffer>();
719 for (const variant of ICON_VARIANTS) {
720 yield* Console.log(`Rendering ${variant.label} from ${variant.source}...`);
721 const variantAssets = yield* renderVariant(
722 tool.path,
723 repositoryRoot,
724 temporaryDirectory,
725 variant,
726 );
727 for (const [relativePath, contents] of variantAssets) {
728 generated.set(relativePath, contents);
729 }
730 }
731
732 for (const override of DEVELOPMENT_PUBLIC_ICON_OVERRIDES) {
733 const sourceContents = generated.get(override.sourceRelativePath);
734 if (sourceContents === undefined) {
735 return yield* Effect.die(
736 new Error(`Generated development web icon is missing: ${override.sourceRelativePath}`),
737 );
738 }
739 generated.set(override.targetRelativePath, sourceContents);
740 }
741
742 if (checkOnly) {
743 const stale = yield* Effect.filter(
744 [...generated.entries()],
745 ([relativePath, contents]) =>
746 isCurrent(repositoryRoot, relativePath, contents).pipe(Effect.map((current) => !current)),
747 { concurrency: "unbounded" },
748 );
749 if (stale.length > 0) {
750 return yield* new IconExportAssetsStaleError({
751 paths: stale.map(([relativePath]) => relativePath),
752 });
753 }
754 yield* Console.log(`All ${generated.size} generated icon assets are current.`);
755 return;
756 }
757
758 yield* Effect.forEach(
759 generated,
760 ([relativePath, contents]) => writeAtomically(repositoryRoot, relativePath, contents),
761 { concurrency: 1, discard: true },
762 );
763 yield* Console.log(`Updated ${generated.size} generated icon assets.`);
764});
765
766export const exportBrandIconsCommand = Command.make(
767 "export-brand-icons",
768 {
769 check: Flag.Boolean("check").pipe(
770 Flag.withDescription("Verify generated icon assets without modifying files."),
771 Flag.withDefault(false),
772 ),
773 },
774 ({ check }) => exportBrandIcons(check).pipe(Effect.scoped),
775).pipe(Command.withDescription("Export The Knife assets from its Icon Composer project."));
776
777if (import.meta.main) {
778 Command.run(exportBrandIconsCommand, { version: "0.0.0" }).pipe(
779 Effect.provide(NodeServices.layer),
780 NodeRuntime.runMain,
781 );
782}