@mrdemonwolf/iconwolf 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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/dist/generator.d.ts +3 -0
  3. package/dist/generator.d.ts.map +1 -0
  4. package/dist/generator.js +122 -0
  5. package/dist/generator.js.map +1 -0
  6. package/dist/index.d.ts +3 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +48 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/lib.d.ts +10 -0
  11. package/dist/lib.d.ts.map +1 -0
  12. package/dist/lib.js +9 -0
  13. package/dist/lib.js.map +1 -0
  14. package/dist/types.d.ts +21 -0
  15. package/dist/types.d.ts.map +1 -0
  16. package/dist/types.js +2 -0
  17. package/dist/types.js.map +1 -0
  18. package/dist/utils/icon-composer.d.ts +14 -0
  19. package/dist/utils/icon-composer.d.ts.map +1 -0
  20. package/dist/utils/icon-composer.js +200 -0
  21. package/dist/utils/icon-composer.js.map +1 -0
  22. package/dist/utils/image.d.ts +22 -0
  23. package/dist/utils/image.d.ts.map +1 -0
  24. package/dist/utils/image.js +145 -0
  25. package/dist/utils/image.js.map +1 -0
  26. package/dist/utils/logger.d.ts +10 -0
  27. package/dist/utils/logger.d.ts.map +1 -0
  28. package/dist/utils/logger.js +38 -0
  29. package/dist/utils/logger.js.map +1 -0
  30. package/dist/utils/paths.d.ts +17 -0
  31. package/dist/utils/paths.d.ts.map +1 -0
  32. package/dist/utils/paths.js +27 -0
  33. package/dist/utils/paths.js.map +1 -0
  34. package/dist/utils/update-notifier.d.ts +9 -0
  35. package/dist/utils/update-notifier.d.ts.map +1 -0
  36. package/dist/utils/update-notifier.js +80 -0
  37. package/dist/utils/update-notifier.js.map +1 -0
  38. package/dist/variants/android.d.ts +5 -0
  39. package/dist/variants/android.d.ts.map +1 -0
  40. package/dist/variants/android.js +18 -0
  41. package/dist/variants/android.js.map +1 -0
  42. package/dist/variants/favicon.d.ts +3 -0
  43. package/dist/variants/favicon.d.ts.map +1 -0
  44. package/dist/variants/favicon.js +23 -0
  45. package/dist/variants/favicon.js.map +1 -0
  46. package/dist/variants/splash.d.ts +3 -0
  47. package/dist/variants/splash.d.ts.map +1 -0
  48. package/dist/variants/splash.js +7 -0
  49. package/dist/variants/splash.js.map +1 -0
  50. package/dist/variants/standard.d.ts +3 -0
  51. package/dist/variants/standard.d.ts.map +1 -0
  52. package/dist/variants/standard.js +7 -0
  53. package/dist/variants/standard.js.map +1 -0
  54. package/eslint.config.js +10 -0
  55. package/package.json +57 -0
  56. package/scripts/build-release.sh +63 -0
  57. package/src/generator.ts +163 -0
  58. package/src/index.ts +73 -0
  59. package/src/lib.ts +16 -0
  60. package/src/types.ts +22 -0
  61. package/src/utils/icon-composer.ts +283 -0
  62. package/src/utils/image.ts +207 -0
  63. package/src/utils/logger.ts +61 -0
  64. package/src/utils/paths.ts +30 -0
  65. package/src/utils/update-notifier.ts +99 -0
  66. package/src/variants/android.ts +45 -0
  67. package/src/variants/favicon.ts +32 -0
  68. package/src/variants/splash.ts +11 -0
  69. package/src/variants/standard.ts +11 -0
  70. package/tests/cli.test.ts +84 -0
  71. package/tests/generator.test.ts +368 -0
  72. package/tests/helpers.ts +36 -0
  73. package/tests/utils/icon-composer.test.ts +208 -0
  74. package/tests/utils/image.test.ts +207 -0
  75. package/tests/utils/logger.test.ts +128 -0
  76. package/tests/utils/paths.test.ts +51 -0
  77. package/tests/utils/update-notifier.test.ts +184 -0
  78. package/tests/variants/android.test.ts +77 -0
  79. package/tests/variants/favicon.test.ts +36 -0
  80. package/tests/variants/splash.test.ts +35 -0
  81. package/tests/variants/standard.test.ts +35 -0
  82. package/tsconfig.json +19 -0
  83. package/vitest.config.ts +8 -0
@@ -0,0 +1,207 @@
1
+ import sharp from 'sharp';
2
+ import type { GenerationResult } from '../types.js';
3
+
4
+ const ADAPTIVE_ICON_SIZE = 1024;
5
+ const SAFE_ZONE_RATIO = 66 / 108;
6
+ const SAFE_ZONE_PX = Math.round(ADAPTIVE_ICON_SIZE * SAFE_ZONE_RATIO);
7
+
8
+ export interface SourceImageMeta {
9
+ width: number;
10
+ height: number;
11
+ format: string;
12
+ }
13
+
14
+ export async function validateSourceImage(
15
+ inputPath: string,
16
+ ): Promise<SourceImageMeta> {
17
+ const metadata = await sharp(inputPath).metadata();
18
+
19
+ if (!metadata.format || metadata.format !== 'png') {
20
+ throw new Error(
21
+ `Source image must be a PNG file (got ${metadata.format || 'unknown'})`,
22
+ );
23
+ }
24
+
25
+ if (!metadata.width || !metadata.height) {
26
+ throw new Error('Could not read image dimensions');
27
+ }
28
+
29
+ if (metadata.width !== metadata.height) {
30
+ throw new Error(
31
+ `Source image must be square (got ${metadata.width}x${metadata.height})`,
32
+ );
33
+ }
34
+
35
+ return {
36
+ width: metadata.width,
37
+ height: metadata.height,
38
+ format: metadata.format,
39
+ };
40
+ }
41
+
42
+ export async function resizeImage(
43
+ inputPath: string,
44
+ width: number,
45
+ height: number,
46
+ outputPath: string,
47
+ ): Promise<GenerationResult> {
48
+ const info = await sharp(inputPath)
49
+ .resize(width, height, {
50
+ fit: 'contain',
51
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
52
+ })
53
+ .png()
54
+ .toFile(outputPath);
55
+
56
+ return {
57
+ filePath: outputPath,
58
+ width: info.width,
59
+ height: info.height,
60
+ size: info.size,
61
+ };
62
+ }
63
+
64
+ export async function createAdaptiveForeground(
65
+ inputPath: string,
66
+ targetSize: number,
67
+ outputPath: string,
68
+ ): Promise<GenerationResult> {
69
+ const artwork = await sharp(inputPath)
70
+ .resize(SAFE_ZONE_PX, SAFE_ZONE_PX, {
71
+ fit: 'contain',
72
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
73
+ })
74
+ .png()
75
+ .toBuffer();
76
+
77
+ const margin = Math.round((targetSize - SAFE_ZONE_PX) / 2);
78
+
79
+ const info = await sharp({
80
+ create: {
81
+ width: targetSize,
82
+ height: targetSize,
83
+ channels: 4,
84
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
85
+ },
86
+ })
87
+ .composite([{ input: artwork, left: margin, top: margin }])
88
+ .png()
89
+ .toFile(outputPath);
90
+
91
+ return {
92
+ filePath: outputPath,
93
+ width: info.width,
94
+ height: info.height,
95
+ size: info.size,
96
+ };
97
+ }
98
+
99
+ export async function createSolidBackground(
100
+ hexColor: string,
101
+ size: number,
102
+ outputPath: string,
103
+ ): Promise<GenerationResult> {
104
+ const { r, g, b } = parseHexColor(hexColor);
105
+
106
+ const info = await sharp({
107
+ create: {
108
+ width: size,
109
+ height: size,
110
+ channels: 4,
111
+ background: { r, g, b, alpha: 255 },
112
+ },
113
+ })
114
+ .png()
115
+ .toFile(outputPath);
116
+
117
+ return {
118
+ filePath: outputPath,
119
+ width: info.width,
120
+ height: info.height,
121
+ size: info.size,
122
+ };
123
+ }
124
+
125
+ export async function createMonochromeIcon(
126
+ inputPath: string,
127
+ targetSize: number,
128
+ outputPath: string,
129
+ ): Promise<GenerationResult> {
130
+ const artwork = await sharp(inputPath)
131
+ .resize(SAFE_ZONE_PX, SAFE_ZONE_PX, {
132
+ fit: 'contain',
133
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
134
+ })
135
+ .grayscale()
136
+ .png()
137
+ .toBuffer();
138
+
139
+ const margin = Math.round((targetSize - SAFE_ZONE_PX) / 2);
140
+
141
+ const info = await sharp({
142
+ create: {
143
+ width: targetSize,
144
+ height: targetSize,
145
+ channels: 4,
146
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
147
+ },
148
+ })
149
+ .composite([{ input: artwork, left: margin, top: margin }])
150
+ .png()
151
+ .toFile(outputPath);
152
+
153
+ return {
154
+ filePath: outputPath,
155
+ width: info.width,
156
+ height: info.height,
157
+ size: info.size,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Apply rounded corners to an image using an SVG mask.
163
+ * Uses Apple's icon corner radius ratio (~22.37%).
164
+ */
165
+ export async function applyRoundedCorners(
166
+ inputBuffer: Buffer,
167
+ size: number,
168
+ ): Promise<Buffer> {
169
+ const radius = Math.round(size * 0.2237);
170
+ const mask = Buffer.from(
171
+ `<svg width="${size}" height="${size}"><rect x="0" y="0" width="${size}" height="${size}" rx="${radius}" ry="${radius}" fill="white"/></svg>`,
172
+ );
173
+
174
+ return sharp(inputBuffer)
175
+ .resize(size, size)
176
+ .composite([{ input: mask, blend: 'dest-in' }])
177
+ .png()
178
+ .toBuffer();
179
+ }
180
+
181
+ export function parseHexColor(hex: string): {
182
+ r: number;
183
+ g: number;
184
+ b: number;
185
+ } {
186
+ const cleaned = hex.replace(/^#/, '');
187
+
188
+ let r: number, g: number, b: number;
189
+
190
+ if (cleaned.length === 3) {
191
+ r = parseInt(cleaned[0] + cleaned[0], 16);
192
+ g = parseInt(cleaned[1] + cleaned[1], 16);
193
+ b = parseInt(cleaned[2] + cleaned[2], 16);
194
+ } else if (cleaned.length === 6) {
195
+ r = parseInt(cleaned.slice(0, 2), 16);
196
+ g = parseInt(cleaned.slice(2, 4), 16);
197
+ b = parseInt(cleaned.slice(4, 6), 16);
198
+ } else {
199
+ throw new Error(`Invalid hex color: ${hex}. Use #RGB or #RRGGBB format.`);
200
+ }
201
+
202
+ if (isNaN(r) || isNaN(g) || isNaN(b)) {
203
+ throw new Error(`Invalid hex color: ${hex}. Contains non-hex characters.`);
204
+ }
205
+
206
+ return { r, g, b };
207
+ }
@@ -0,0 +1,61 @@
1
+ import chalk from 'chalk';
2
+ import type { GenerationResult } from '../types.js';
3
+
4
+ export function banner(): void {
5
+ console.log(
6
+ chalk.bold.hex('#FF6B35')('\n iconwolf') +
7
+ chalk.dim(' - app icon generator\n'),
8
+ );
9
+ }
10
+
11
+ export function info(message: string): void {
12
+ console.log(chalk.blue(' info') + chalk.dim(' · ') + message);
13
+ }
14
+
15
+ export function success(message: string): void {
16
+ console.log(chalk.green(' done') + chalk.dim(' · ') + message);
17
+ }
18
+
19
+ export function generated(result: GenerationResult): void {
20
+ const sizeKB = (result.size / 1024).toFixed(1);
21
+ console.log(
22
+ chalk.green(' generated') +
23
+ chalk.dim(' · ') +
24
+ chalk.white(result.filePath) +
25
+ chalk.dim(` (${result.width}x${result.height}, ${sizeKB} KB)`),
26
+ );
27
+ }
28
+
29
+ export function warn(message: string): void {
30
+ console.log(chalk.yellow(' warn') + chalk.dim(' · ') + message);
31
+ }
32
+
33
+ export function error(message: string): void {
34
+ console.error(chalk.red(' error') + chalk.dim(' · ') + message);
35
+ }
36
+
37
+ export function summary(results: GenerationResult[]): void {
38
+ const totalSize = results.reduce((sum, r) => sum + r.size, 0);
39
+ const totalKB = (totalSize / 1024).toFixed(1);
40
+ console.log(
41
+ chalk.bold(
42
+ `\n ${results.length} file${results.length === 1 ? '' : 's'} generated`,
43
+ ) + chalk.dim(` (${totalKB} KB total)\n`),
44
+ );
45
+ }
46
+
47
+ export function updateNotice(
48
+ currentVersion: string,
49
+ latestVersion: string,
50
+ ): void {
51
+ console.log(
52
+ chalk.yellow(' update') +
53
+ chalk.dim(' · ') +
54
+ `New version available: ${chalk.dim(currentVersion)} → ${chalk.green(latestVersion)}`,
55
+ );
56
+ console.log(
57
+ ' ' +
58
+ chalk.dim(' · ') +
59
+ `Run ${chalk.cyan('brew upgrade iconwolf')} to update\n`,
60
+ );
61
+ }
@@ -0,0 +1,30 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export const DEFAULT_OUTPUT_DIR = './assets/images';
5
+
6
+ /**
7
+ * Detect the default output directory based on project structure.
8
+ * If a `src/` directory exists (common in React Native/Expo projects),
9
+ * defaults to `./src/assets/images/`. Otherwise uses `./assets/images/`.
10
+ */
11
+ export function resolveDefaultOutputDir(): string {
12
+ const srcDir = path.resolve('src');
13
+ if (fs.existsSync(srcDir) && fs.statSync(srcDir).isDirectory()) {
14
+ return './src/assets/images';
15
+ }
16
+ return DEFAULT_OUTPUT_DIR;
17
+ }
18
+
19
+ export const OUTPUT_FILES = {
20
+ icon: 'icon.png',
21
+ androidForeground: 'adaptive-icon.png',
22
+ androidBackground: 'android-icon-background.png',
23
+ androidMonochrome: 'monochrome-icon.png',
24
+ favicon: 'favicon.png',
25
+ splashIcon: 'splash-icon.png',
26
+ } as const;
27
+
28
+ export function resolveOutputPath(outputDir: string, fileName: string): string {
29
+ return path.resolve(outputDir, fileName);
30
+ }
@@ -0,0 +1,99 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+
5
+ const CACHE_DIR = path.join(os.homedir(), '.iconwolf');
6
+ const CACHE_FILE = path.join(CACHE_DIR, 'update-check.json');
7
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
8
+ const FETCH_TIMEOUT_MS = 5000;
9
+ const RELEASES_URL =
10
+ 'https://api.github.com/repos/MrDemonWolf/iconwolf/releases/latest';
11
+
12
+ export interface UpdateInfo {
13
+ updateAvailable: boolean;
14
+ currentVersion: string;
15
+ latestVersion: string;
16
+ }
17
+
18
+ interface CacheData {
19
+ latestVersion: string;
20
+ checkedAt: number;
21
+ }
22
+
23
+ export function isNewerVersion(current: string, latest: string): boolean {
24
+ const currentParts = current.split('.').map(Number);
25
+ const latestParts = latest.split('.').map(Number);
26
+ const len = Math.max(currentParts.length, latestParts.length);
27
+
28
+ for (let i = 0; i < len; i++) {
29
+ const c = currentParts[i] ?? 0;
30
+ const l = latestParts[i] ?? 0;
31
+ if (l > c) return true;
32
+ if (l < c) return false;
33
+ }
34
+
35
+ return false;
36
+ }
37
+
38
+ export function readCachedUpdateInfo(
39
+ currentVersion: string,
40
+ ): UpdateInfo | null {
41
+ try {
42
+ const raw = fs.readFileSync(CACHE_FILE, 'utf-8');
43
+ const data: CacheData = JSON.parse(raw);
44
+ if (!data.latestVersion) return null;
45
+
46
+ return {
47
+ updateAvailable: isNewerVersion(currentVersion, data.latestVersion),
48
+ currentVersion,
49
+ latestVersion: data.latestVersion,
50
+ };
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ export async function refreshCacheInBackground(
57
+ currentCacheFile?: string,
58
+ ): Promise<void> {
59
+ const cacheFile = currentCacheFile ?? CACHE_FILE;
60
+ const cacheDir = path.dirname(cacheFile);
61
+
62
+ try {
63
+ // Check if cache is still fresh
64
+ try {
65
+ const stat = fs.statSync(cacheFile);
66
+ if (Date.now() - stat.mtimeMs < CACHE_TTL_MS) return;
67
+ } catch {
68
+ // Cache missing, proceed with fetch
69
+ }
70
+
71
+ const controller = new AbortController();
72
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
73
+
74
+ try {
75
+ const res = await fetch(RELEASES_URL, {
76
+ signal: controller.signal,
77
+ headers: { Accept: 'application/vnd.github.v3+json' },
78
+ });
79
+
80
+ if (!res.ok) return;
81
+
82
+ const body = (await res.json()) as { tag_name?: string };
83
+ if (!body.tag_name) return;
84
+
85
+ const latestVersion = body.tag_name.replace(/^v/, '');
86
+ const cacheData: CacheData = {
87
+ latestVersion,
88
+ checkedAt: Date.now(),
89
+ };
90
+
91
+ fs.mkdirSync(cacheDir, { recursive: true });
92
+ fs.writeFileSync(cacheFile, JSON.stringify(cacheData));
93
+ } finally {
94
+ clearTimeout(timeout);
95
+ }
96
+ } catch {
97
+ // Silently swallow all errors
98
+ }
99
+ }
@@ -0,0 +1,45 @@
1
+ import {
2
+ createAdaptiveForeground,
3
+ createSolidBackground,
4
+ createMonochromeIcon,
5
+ } from '../utils/image.js';
6
+ import { resolveOutputPath, OUTPUT_FILES } from '../utils/paths.js';
7
+ import type { GenerationResult } from '../types.js';
8
+
9
+ const ANDROID_ICON_SIZE = 1024;
10
+
11
+ export async function generateAndroidIcons(
12
+ inputPath: string,
13
+ outputDir: string,
14
+ bgColor: string,
15
+ options?: { includeBackground?: boolean },
16
+ ): Promise<GenerationResult[]> {
17
+ const includeBackground = options?.includeBackground ?? false;
18
+
19
+ const foregroundPromise = createAdaptiveForeground(
20
+ inputPath,
21
+ ANDROID_ICON_SIZE,
22
+ resolveOutputPath(outputDir, OUTPUT_FILES.androidForeground),
23
+ );
24
+
25
+ if (!includeBackground) {
26
+ const foreground = await foregroundPromise;
27
+ return [foreground];
28
+ }
29
+
30
+ const [foreground, background, monochrome] = await Promise.all([
31
+ foregroundPromise,
32
+ createSolidBackground(
33
+ bgColor,
34
+ ANDROID_ICON_SIZE,
35
+ resolveOutputPath(outputDir, OUTPUT_FILES.androidBackground),
36
+ ),
37
+ createMonochromeIcon(
38
+ inputPath,
39
+ ANDROID_ICON_SIZE,
40
+ resolveOutputPath(outputDir, OUTPUT_FILES.androidMonochrome),
41
+ ),
42
+ ]);
43
+
44
+ return [foreground, background, monochrome];
45
+ }
@@ -0,0 +1,32 @@
1
+ import sharp from 'sharp';
2
+ import { applyRoundedCorners } from '../utils/image.js';
3
+ import { resolveOutputPath, OUTPUT_FILES } from '../utils/paths.js';
4
+ import type { GenerationResult } from '../types.js';
5
+
6
+ const FAVICON_SIZE = 48;
7
+
8
+ export async function generateFavicon(
9
+ inputPath: string,
10
+ outputDir: string,
11
+ ): Promise<GenerationResult> {
12
+ const outputPath = resolveOutputPath(outputDir, OUTPUT_FILES.favicon);
13
+
14
+ const resized = await sharp(inputPath)
15
+ .resize(FAVICON_SIZE, FAVICON_SIZE, {
16
+ fit: 'contain',
17
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
18
+ })
19
+ .png()
20
+ .toBuffer();
21
+
22
+ const rounded = await applyRoundedCorners(resized, FAVICON_SIZE);
23
+
24
+ const info = await sharp(rounded).png().toFile(outputPath);
25
+
26
+ return {
27
+ filePath: outputPath,
28
+ width: info.width,
29
+ height: info.height,
30
+ size: info.size,
31
+ };
32
+ }
@@ -0,0 +1,11 @@
1
+ import { resizeImage } from '../utils/image.js';
2
+ import { resolveOutputPath, OUTPUT_FILES } from '../utils/paths.js';
3
+ import type { GenerationResult } from '../types.js';
4
+
5
+ export async function generateSplashIcon(
6
+ inputPath: string,
7
+ outputDir: string,
8
+ ): Promise<GenerationResult> {
9
+ const outputPath = resolveOutputPath(outputDir, OUTPUT_FILES.splashIcon);
10
+ return resizeImage(inputPath, 1024, 1024, outputPath);
11
+ }
@@ -0,0 +1,11 @@
1
+ import { resizeImage } from '../utils/image.js';
2
+ import { resolveOutputPath, OUTPUT_FILES } from '../utils/paths.js';
3
+ import type { GenerationResult } from '../types.js';
4
+
5
+ export async function generateStandardIcon(
6
+ inputPath: string,
7
+ outputDir: string,
8
+ ): Promise<GenerationResult> {
9
+ const outputPath = resolveOutputPath(outputDir, OUTPUT_FILES.icon);
10
+ return resizeImage(inputPath, 1024, 1024, outputPath);
11
+ }
@@ -0,0 +1,84 @@
1
+ import { execSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
5
+ import { createTestPng, createTmpDir, cleanDir } from './helpers.js';
6
+
7
+ let tmpDir: string;
8
+ let testPng: string;
9
+ const CLI_PATH = path.resolve('dist/index.js');
10
+
11
+ beforeAll(async () => {
12
+ tmpDir = createTmpDir();
13
+ testPng = await createTestPng(1024, 1024, tmpDir);
14
+ });
15
+
16
+ afterAll(() => {
17
+ cleanDir(tmpDir);
18
+ });
19
+
20
+ function runCli(args: string): string {
21
+ return execSync(`node ${CLI_PATH} ${args}`, {
22
+ encoding: 'utf-8',
23
+ timeout: 30000,
24
+ });
25
+ }
26
+
27
+ describe('CLI end-to-end', () => {
28
+ it('prints version with --version', () => {
29
+ const output = runCli('--version');
30
+ expect(output.trim()).toMatch(/^\d+\.\d+\.\d+$/);
31
+ });
32
+
33
+ it('prints help with --help', () => {
34
+ const output = runCli('--help');
35
+ expect(output).toContain('iconwolf');
36
+ expect(output).toContain('--output');
37
+ expect(output).toContain('--android');
38
+ expect(output).toContain('--favicon');
39
+ expect(output).toContain('--splash');
40
+ expect(output).toContain('--icon');
41
+ expect(output).toContain('--splash-input');
42
+ expect(output).toContain('--bg-color');
43
+ });
44
+
45
+ it('generates icon.png via CLI with --icon flag', () => {
46
+ const outDir = path.join(tmpDir, 'cli-icon');
47
+
48
+ runCli(`${testPng} --icon -o ${outDir}`);
49
+
50
+ expect(fs.existsSync(path.join(outDir, 'icon.png'))).toBe(true);
51
+ expect(fs.existsSync(path.join(outDir, 'favicon.png'))).toBe(false);
52
+ });
53
+
54
+ it('generates all 4 default files via CLI', () => {
55
+ const outDir = path.join(tmpDir, 'cli-all');
56
+
57
+ runCli(`${testPng} -o ${outDir}`);
58
+
59
+ expect(fs.existsSync(path.join(outDir, 'icon.png'))).toBe(true);
60
+ expect(fs.existsSync(path.join(outDir, 'adaptive-icon.png'))).toBe(true);
61
+ expect(fs.existsSync(path.join(outDir, 'splash-icon.png'))).toBe(true);
62
+ expect(fs.existsSync(path.join(outDir, 'favicon.png'))).toBe(true);
63
+ // Background and monochrome NOT generated by default
64
+ expect(
65
+ fs.existsSync(path.join(outDir, 'android-icon-background.png')),
66
+ ).toBe(false);
67
+ expect(fs.existsSync(path.join(outDir, 'monochrome-icon.png'))).toBe(false);
68
+ });
69
+
70
+ it('exits with error on missing input file', () => {
71
+ expect(() => {
72
+ runCli('/tmp/nonexistent-icon.png -o /tmp/out');
73
+ }).toThrow();
74
+ });
75
+
76
+ it('generates favicon when --favicon flag is used', () => {
77
+ const outDir = path.join(tmpDir, 'cli-favicon');
78
+
79
+ runCli(`${testPng} --favicon -o ${outDir}`);
80
+
81
+ expect(fs.existsSync(path.join(outDir, 'favicon.png'))).toBe(true);
82
+ expect(fs.existsSync(path.join(outDir, 'icon.png'))).toBe(false);
83
+ });
84
+ });