@spicemod/creator 0.0.22 → 0.0.24

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 (72) hide show
  1. package/client.d.ts +47 -0
  2. package/dist/bin.mjs +834 -342
  3. package/dist/index.d.mts +697 -0
  4. package/dist/{client/index.mjs → index.mjs} +1 -1
  5. package/dist/templates/custom-app/js/react/eslint.config.ts +29 -0
  6. package/dist/templates/custom-app/js/react/src/app.jsx +22 -0
  7. package/dist/templates/custom-app/js/react/src/components/Onboarding.jsx +82 -0
  8. package/dist/templates/custom-app/js/react/src/extension/index.jsx +22 -0
  9. package/dist/templates/custom-app/meta.json +4 -0
  10. package/dist/templates/custom-app/shared/DOT-gitignore +34 -0
  11. package/dist/templates/custom-app/shared/DOT-oxlintrc.json +36 -0
  12. package/dist/templates/custom-app/shared/README.template.md +53 -0
  13. package/dist/templates/custom-app/shared/app.css +163 -0
  14. package/dist/templates/custom-app/shared/biome.json +36 -0
  15. package/dist/templates/custom-app/shared/css/app.module.scss +58 -0
  16. package/dist/templates/custom-app/shared/icon-active.svg +7 -0
  17. package/dist/templates/custom-app/shared/icon.svg +7 -0
  18. package/dist/templates/custom-app/shared/jsconfig.json +32 -0
  19. package/dist/templates/custom-app/shared/spice.config.js +10 -0
  20. package/dist/templates/custom-app/shared/spice.config.ts +10 -0
  21. package/dist/templates/custom-app/shared/tsconfig.json +32 -0
  22. package/dist/templates/custom-app/ts/react/eslint.config.ts +29 -0
  23. package/dist/templates/custom-app/ts/react/src/app.tsx +22 -0
  24. package/dist/templates/custom-app/ts/react/src/components/Onboarding.tsx +92 -0
  25. package/dist/templates/custom-app/ts/react/src/extension/index.tsx +27 -0
  26. package/dist/templates/customAppEntry.js +6 -0
  27. package/dist/templates/extension/js/vanilla/src/components/Onboarding.js +71 -0
  28. package/dist/templates/extension/shared/DOT-gitignore +34 -0
  29. package/dist/templates/extension/shared/DOT-oxlintrc.json +36 -0
  30. package/dist/templates/extension/shared/spice.config.js +2 -1
  31. package/dist/templates/extension/shared/spice.config.ts +2 -1
  32. package/dist/templates/liveReload.js +0 -1
  33. package/dist/templates/theme/shared/DOT-gitignore +34 -0
  34. package/dist/templates/theme/shared/DOT-oxlintrc.json +36 -0
  35. package/dist/templates/theme/shared/spice.config.js +2 -1
  36. package/dist/templates/theme/shared/spice.config.ts +2 -1
  37. package/dist/templates/wrapper.js +5 -8
  38. package/package.json +7 -3
  39. package/templates/custom-app/js/react/eslint.config.ts +29 -0
  40. package/templates/custom-app/js/react/src/app.jsx +22 -0
  41. package/templates/custom-app/js/react/src/components/Onboarding.jsx +82 -0
  42. package/templates/custom-app/js/react/src/extension/index.jsx +22 -0
  43. package/templates/custom-app/meta.json +4 -0
  44. package/templates/custom-app/shared/DOT-gitignore +34 -0
  45. package/templates/custom-app/shared/DOT-oxlintrc.json +36 -0
  46. package/templates/custom-app/shared/README.template.md +53 -0
  47. package/templates/custom-app/shared/app.css +163 -0
  48. package/templates/custom-app/shared/biome.json +36 -0
  49. package/templates/custom-app/shared/css/app.module.scss +58 -0
  50. package/templates/custom-app/shared/icon-active.svg +7 -0
  51. package/templates/custom-app/shared/icon.svg +7 -0
  52. package/templates/custom-app/shared/jsconfig.json +32 -0
  53. package/templates/custom-app/shared/spice.config.js +10 -0
  54. package/templates/custom-app/shared/spice.config.ts +10 -0
  55. package/templates/custom-app/shared/tsconfig.json +32 -0
  56. package/templates/custom-app/ts/react/eslint.config.ts +29 -0
  57. package/templates/custom-app/ts/react/src/app.tsx +22 -0
  58. package/templates/custom-app/ts/react/src/components/Onboarding.tsx +92 -0
  59. package/templates/custom-app/ts/react/src/extension/index.tsx +27 -0
  60. package/templates/customAppEntry.js +6 -0
  61. package/templates/extension/js/vanilla/src/components/Onboarding.js +71 -0
  62. package/templates/extension/shared/DOT-gitignore +34 -0
  63. package/templates/extension/shared/DOT-oxlintrc.json +36 -0
  64. package/templates/extension/shared/spice.config.js +2 -1
  65. package/templates/extension/shared/spice.config.ts +2 -1
  66. package/templates/liveReload.js +0 -1
  67. package/templates/theme/shared/DOT-gitignore +34 -0
  68. package/templates/theme/shared/DOT-oxlintrc.json +36 -0
  69. package/templates/theme/shared/spice.config.js +2 -1
  70. package/templates/theme/shared/spice.config.ts +2 -1
  71. package/templates/wrapper.js +5 -8
  72. package/dist/client/index.d.mts +0 -2183
package/dist/bin.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import { Command, Option } from "commander";
3
3
  import * as v from "valibot";
4
4
  import path, { basename, dirname, extname, join, relative, resolve } from "node:path";
5
- import { context, transform } from "esbuild";
5
+ import { build, context, transform } from "esbuild";
6
6
  import { createReadStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
7
7
  import { watchConfig } from "c12";
8
8
  import { globSync } from "tinyglobby";
@@ -10,9 +10,11 @@ import { URL as URL$1, fileURLToPath } from "node:url";
10
10
  import { execSync, spawn, spawnSync } from "node:child_process";
11
11
  import readline, { createInterface } from "node:readline";
12
12
  import * as p from "@clack/prompts";
13
- import { cancel, log } from "@clack/prompts";
13
+ import { cancel, log, spinner } from "@clack/prompts";
14
14
  import pc from "picocolors";
15
15
  import "dotenv/config";
16
+ import { readFile, writeFile } from "node:fs/promises";
17
+ import { parse } from "ini";
16
18
  import { gzipSync } from "node:zlib";
17
19
  import postcssMinify from "@csstools/postcss-minify";
18
20
  import autoprefixer from "autoprefixer";
@@ -20,10 +22,11 @@ import { postcssModules, sassPlugin } from "esbuild-sass-plugin";
20
22
  import postcss from "postcss";
21
23
  import postcssImport from "postcss-import";
22
24
  import postcssPresetEnv from "postcss-preset-env";
23
- import { readFile, writeFile } from "node:fs/promises";
24
- import { parse } from "ini";
25
+ import { createHash } from "node:crypto";
25
26
  import { chdir } from "node:process";
26
27
  import { lookup } from "node:dns/promises";
28
+ import { mkdir, writeFile as writeFile$1 } from "fs/promises";
29
+ import { dirname as dirname$1, join as join$1 } from "path";
27
30
  import { createServer } from "node:http";
28
31
  import { WebSocket, WebSocketServer } from "ws";
29
32
 
@@ -57,7 +60,11 @@ const toOptions = (metadata) => metadata.map((m) => ({
57
60
  label: m.title,
58
61
  hint: m.description
59
62
  }));
60
- const templateTypes = ["extension", "theme"];
63
+ const templateTypes = [
64
+ "extension",
65
+ "theme",
66
+ "custom-app"
67
+ ];
61
68
  const templates = templateTypes.map((dir) => {
62
69
  const meta_file = dist(`templates/${dir}/meta.json`, import.meta.url);
63
70
  const { title, description } = JSON.parse(readFileSync(meta_file, "utf8"));
@@ -130,12 +137,13 @@ const frameworks = frameworkTypes.map((name) => ({
130
137
  }));
131
138
  const frameworkOptions = toOptions(frameworks);
132
139
  const liveReloadFilePath = dist(`templates/liveReload.js`, import.meta.url);
133
- const templateFilePath = dist("templates/wrapper.js", import.meta.url);
140
+ const templateWrapperFilePath = dist("templates/wrapper.js", import.meta.url);
141
+ const customAppEntryFilePath = dist("templates/customAppEntry.js", import.meta.url);
134
142
 
135
143
  //#endregion
136
144
  //#region package.json
137
145
  var name = "@spicemod/creator";
138
- var version = "0.0.22";
146
+ var version = "0.0.24";
139
147
 
140
148
  //#endregion
141
149
  //#region src/utils/common.ts
@@ -229,57 +237,6 @@ async function installPackages(packageManager, isOnline) {
229
237
  });
230
238
  }
231
239
 
232
- //#endregion
233
- //#region src/config/schema.ts
234
- const ServerConfigSchema = v.object({
235
- port: v.optional(v.number()),
236
- serveDir: v.string(),
237
- hmrPath: v.optional(v.string())
238
- });
239
- const EntryFileSchema = v.string();
240
- const ThemeEntrySchema = v.object({
241
- js: EntryFileSchema,
242
- css: EntryFileSchema
243
- });
244
- const TemplateSpecificOptionalSchema = v.variant("template", [v.partial(v.object({
245
- template: v.literal("extension"),
246
- entry: EntryFileSchema
247
- })), v.partial(v.object({
248
- template: v.literal("theme"),
249
- entry: ThemeEntrySchema
250
- }))]);
251
- const TemplateSpecificSchema = v.variant("template", [v.object({
252
- template: v.literal("extension"),
253
- entry: EntryFileSchema
254
- }), v.object({
255
- template: v.literal("theme"),
256
- entry: ThemeEntrySchema
257
- })]);
258
- const CommonSchema = v.object({
259
- name: v.string(),
260
- outDir: v.string(),
261
- linter: v.picklist(linterTypes),
262
- framework: v.picklist(frameworkTypes),
263
- packageManager: v.picklist(packageManagers),
264
- esbuildOptions: v.record(v.string(), v.any()),
265
- devModeVarName: v.optional(v.string()),
266
- serverConfig: v.partial(ServerConfigSchema),
267
- version: v.string()
268
- });
269
- const FileOptionsSchema = v.intersect([v.partial(CommonSchema), TemplateSpecificOptionalSchema]);
270
- const OptionsSchema$1 = v.pipe(v.intersect([v.required(CommonSchema), TemplateSpecificSchema]), v.check((input) => !!input.name, "Name is required"));
271
-
272
- //#endregion
273
- //#region src/env.ts
274
- const isInternal = process.env.SPICE_INTERNAL === "true";
275
- const isDev = process.env.IS_DEV === "true";
276
- const spicetifyBin = process.env.SPICETIFY_BIN || process.env.SPICE_BIN || "spicetify";
277
- const env = {
278
- isInternal,
279
- isDev,
280
- spicetifyBin
281
- };
282
-
283
240
  //#endregion
284
241
  //#region src/constants.ts
285
242
  const GITHUB_LINK = "https://github.com/sanoojes/spicetify-creator";
@@ -313,6 +270,150 @@ const VALID_PROJECT_FILES = new Set([
313
270
  "yarnrc.yml",
314
271
  ".yarn"
315
272
  ]);
273
+ const CUSTOM_APP_NAME_LOCALES = [
274
+ "ms",
275
+ "gu",
276
+ "ko",
277
+ "pa-IN",
278
+ "az",
279
+ "ru",
280
+ "uk",
281
+ "nb",
282
+ "sv",
283
+ "sw",
284
+ "ur",
285
+ "bho",
286
+ "pa-PK",
287
+ "te",
288
+ "ro",
289
+ "vi",
290
+ "am",
291
+ "bn",
292
+ "en",
293
+ "id",
294
+ "bg",
295
+ "da",
296
+ "es-419",
297
+ "mr",
298
+ "ml",
299
+ "th",
300
+ "tr",
301
+ "is",
302
+ "fa",
303
+ "or",
304
+ "he",
305
+ "hi",
306
+ "zh-TW",
307
+ "sr",
308
+ "pt-BR",
309
+ "zu",
310
+ "nl",
311
+ "es",
312
+ "lt",
313
+ "ja",
314
+ "st",
315
+ "it",
316
+ "el",
317
+ "pt-PT",
318
+ "kn",
319
+ "de",
320
+ "fr",
321
+ "ne",
322
+ "ar",
323
+ "af",
324
+ "et",
325
+ "pl",
326
+ "ta",
327
+ "sl",
328
+ "pk",
329
+ "hr",
330
+ "sk",
331
+ "fi",
332
+ "lv",
333
+ "fil",
334
+ "fr-CA",
335
+ "cs",
336
+ "zh-CN",
337
+ "hu"
338
+ ];
339
+
340
+ //#endregion
341
+ //#region src/config/schema.ts
342
+ const ServerConfigSchema = v.object({
343
+ port: v.optional(v.number()),
344
+ serveDir: v.string(),
345
+ hmrPath: v.optional(v.string())
346
+ });
347
+ const EntryFileSchema = v.string();
348
+ const AssetEntrySchema = v.object({
349
+ js: EntryFileSchema,
350
+ css: EntryFileSchema
351
+ });
352
+ const CustomAppEntrySchema = v.object({
353
+ extension: EntryFileSchema,
354
+ app: EntryFileSchema
355
+ });
356
+ const LocaleNameSchema = v.intersect([v.object({ en: v.string() }), v.record(v.picklist(CUSTOM_APP_NAME_LOCALES), v.string())]);
357
+ const ExtensionTemplateSchema = v.object({
358
+ name: v.string(),
359
+ template: v.literal("extension"),
360
+ entry: EntryFileSchema
361
+ });
362
+ const ThemeTemplateSchema = v.object({
363
+ name: v.string(),
364
+ template: v.literal("theme"),
365
+ entry: AssetEntrySchema
366
+ });
367
+ const CustomAppTemplateSchema = v.object({
368
+ name: v.union([v.string(), LocaleNameSchema], "Name must be a string or a translations object containing the required 'en' locale."),
369
+ icon: v.object({
370
+ default: v.string(),
371
+ active: v.optional(v.string())
372
+ }),
373
+ template: v.literal("custom-app"),
374
+ entry: CustomAppEntrySchema
375
+ });
376
+ const TemplateSpecificSchema = v.variant("template", [
377
+ ExtensionTemplateSchema,
378
+ ThemeTemplateSchema,
379
+ CustomAppTemplateSchema
380
+ ]);
381
+ const TemplateSpecificOptionalSchema = v.variant("template", [
382
+ v.partial(ExtensionTemplateSchema),
383
+ v.partial(ThemeTemplateSchema),
384
+ v.partial(CustomAppTemplateSchema)
385
+ ]);
386
+ const RequiredCommonSchema = v.object({
387
+ outDir: v.string(),
388
+ linter: v.picklist(linterTypes),
389
+ framework: v.picklist(frameworkTypes),
390
+ packageManager: v.picklist(packageManagers),
391
+ esbuildOptions: v.record(v.string(), v.any()),
392
+ serverConfig: v.partial(ServerConfigSchema),
393
+ version: v.string()
394
+ });
395
+ const OptionalCommonSchema = v.object({ devModeVarName: v.optional(v.string()) });
396
+ const FileOptionsSchema = v.intersect([
397
+ v.partial(RequiredCommonSchema),
398
+ OptionalCommonSchema,
399
+ TemplateSpecificOptionalSchema
400
+ ]);
401
+ const OptionsSchema$1 = v.intersect([
402
+ v.required(RequiredCommonSchema),
403
+ OptionalCommonSchema,
404
+ TemplateSpecificSchema
405
+ ]);
406
+
407
+ //#endregion
408
+ //#region src/env.ts
409
+ const isDev = process.env.IS_DEV === "true";
410
+ const spicetifyBin = process.env.SPICETIFY_BIN || process.env.SPICE_BIN || "spicetify";
411
+ const skipSpicetify = process.env.SPICETIFY_SKIP === "true" || process.env.CI === "true";
412
+ const env = {
413
+ isDev,
414
+ spicetifyBin,
415
+ skipSpicetify
416
+ };
316
417
 
317
418
  //#endregion
318
419
  //#region src/utils/logger.ts
@@ -350,8 +451,15 @@ var Logger = class {
350
451
  this.add(console.warn, args);
351
452
  }
352
453
  error(...errors) {
353
- this.add(console.error, errors);
354
- for (const err of errors) if (err instanceof Error && this.isDev && err.stack) console.error(pc.dim(err.stack.split("\n").slice(1).join("\n")));
454
+ const formatted = [];
455
+ for (const err of errors) if (err instanceof Error) {
456
+ formatted.push(pc.red(err.message));
457
+ if (this.isDev && err.stack) {
458
+ const stack = err.stack.split("\n").slice(1).join("\n");
459
+ formatted.push(pc.dim(stack));
460
+ }
461
+ } else formatted.push(err);
462
+ this.add(console.error, formatted);
355
463
  }
356
464
  log(...args) {
357
465
  if (!this.isDev) return;
@@ -363,6 +471,10 @@ var Logger = class {
363
471
  }
364
472
  clear() {
365
473
  if (!process.stdout.isTTY || process.env.CI) return;
474
+ if (this.isDev) {
475
+ this.log("clear skipped");
476
+ return;
477
+ }
366
478
  readline.cursorTo(process.stdout, 0, 0);
367
479
  readline.clearScreenDown(process.stdout);
368
480
  }
@@ -371,34 +483,90 @@ const createLogger = (prefix = "", mode) => new Logger(prefix, mode);
371
483
  const logger$2 = createLogger("common");
372
484
 
373
485
  //#endregion
374
- //#region src/utils/schema.ts
375
- function safeParse(schema, data, type = "CLI") {
376
- const result = v.safeParse(schema, data);
486
+ //#region src/utils/spicetify/schema.ts
487
+ const SpicetifyConfigSchema = v.object({
488
+ Setting: v.object({
489
+ spotify_path: v.string(),
490
+ prefs_path: v.string(),
491
+ inject_theme_js: v.string(),
492
+ inject_css: v.string(),
493
+ current_theme: v.string(),
494
+ color_scheme: v.string(),
495
+ always_enable_devtools: v.string()
496
+ }),
497
+ AdditionalOptions: v.object({
498
+ experimental_features: v.string(),
499
+ extensions: v.pipe(v.string(), v.transform((input) => input.split("|").filter(Boolean))),
500
+ custom_apps: v.string()
501
+ }),
502
+ Backup: v.object({
503
+ version: v.string(),
504
+ with: v.string()
505
+ })
506
+ });
507
+
508
+ //#endregion
509
+ //#region src/utils/spicetify/index.ts
510
+ function runSpice(args) {
511
+ validateSpicetify(env.spicetifyBin);
512
+ return spawnSync(env.spicetifyBin, args, { encoding: "utf-8" });
513
+ }
514
+ const getCustomAppsDir = () => join(getSpiceDataPath(), "CustomApps");
515
+ const getExtensionDir = () => join(getSpiceDataPath(), "Extensions");
516
+ const getThemesDir = () => join(getSpiceDataPath(), "Themes");
517
+ async function getSpicetifyConfig() {
518
+ const { stdout, stderr, error } = runSpice(["path", "-c"]);
519
+ if (error || stderr) throw new Error(`Failed to locate Spicetify config: ${stderr || error?.message}`);
520
+ const rawConfig = parse(await readFile(stdout.trim(), "utf-8"));
521
+ const result = v.safeParse(SpicetifyConfigSchema, rawConfig);
377
522
  if (result.success) return result.output;
378
- logger$2.error(`\n${pc.bgRed(pc.black(" ERROR "))} ${pc.red(`Invalid ${type} options:`)}`);
379
- result.issues.forEach((issue) => {
380
- const path = issue.path?.map((p) => p.key).join(".") || "input";
381
- logger$2.error(`${pc.dim(" └─")} ${pc.yellow(path)}: ${pc.white(issue.message)}`);
382
- });
383
- logger$2.error(`\n${pc.dim("Check your command flags and try again.")}\n`);
384
- process.exit(1);
523
+ else throw new Error("Spicetify Config Validation Failed:", v.flatten(result.issues).nested);
385
524
  }
525
+ function getSpiceDataPath() {
526
+ const { stdout, stderr, error } = runSpice(["path", "userdata"]);
527
+ if (error || stderr) throw new Error(`Failed to locate Spicetify config: ${stderr || error?.message}`);
528
+ return stdout.trim();
529
+ }
530
+ function validateSpicetify(bin) {
531
+ const result = spawnSync(bin, ["--version"], { encoding: "utf-8" });
532
+ if (result.error) throw result.error;
533
+ if (result.status !== 0) throw new Error(`Invalid spicetify binary "${bin}": ${result.stderr || "unknown error"}`);
534
+ }
535
+
536
+ //#endregion
537
+ //#region src/config/globs.ts
538
+ const JS_EXTENSIONS = "{ts,tsx,js,jsx,mts,mjs,cts,cjs}";
539
+ const CSS_EXTENSIONS = "{css,scss,sass,less,styl,stylus,pcss,postcss}";
540
+ const withSrc = (dirs) => dirs.flatMap((dir) => [dir, `src/${dir}`]);
541
+ const createGlobs = (names, dirs, ext) => names.flatMap((name) => dirs.map((dir) => `${dir}${name}.${ext}`));
542
+ const JS_ENTRY_GLOBS = createGlobs(["app", "index"], withSrc([""]), JS_EXTENSIONS);
543
+ const JS_EXTENSION_ENTRY_GLOBS = createGlobs(["app", "index"], withSrc(["extension/"]), JS_EXTENSIONS);
544
+ const CSS_ENTRY_GLOBS = createGlobs(["app"], withSrc([
545
+ "",
546
+ "styles/",
547
+ "css/"
548
+ ]), CSS_EXTENSIONS);
549
+ const ICON_GLOBS = createGlobs(["icon", "logo"], withSrc([
550
+ "",
551
+ "icons/",
552
+ "assets/"
553
+ ]), "svg");
554
+ const ICON_ACTIVE_GLOBS = createGlobs(["icon-active", "logo-active"], withSrc([
555
+ "",
556
+ "icons/",
557
+ "assets/"
558
+ ]), "svg");
559
+ const ENTRY_MAP = {
560
+ js: [...JS_ENTRY_GLOBS, ...JS_EXTENSION_ENTRY_GLOBS],
561
+ css: CSS_ENTRY_GLOBS,
562
+ "js-app-only": JS_ENTRY_GLOBS,
563
+ "js-extension-only": JS_EXTENSION_ENTRY_GLOBS
564
+ };
386
565
 
387
566
  //#endregion
388
567
  //#region src/config/index.ts
389
568
  const logger$1 = createLogger("config");
390
- const JS_ENTRY_GLOBS = [
391
- "app.{ts,tsx,js,jsx,mts,mjs,cts,cjs}",
392
- "extension/app.{ts,tsx,js,jsx,mts,mjs,cts,cjs}",
393
- "src/app.{ts,tsx,js,jsx,mts,mjs,cts,cjs}",
394
- "src/extension/app.{ts,tsx,js,jsx,mts,mjs,cts,cjs}"
395
- ];
396
- const CSS_ENTRY_GLOBS = [
397
- "app.{css,scss,sass,less,styl,stylus,pcss,postcss}",
398
- "styles/app.{css,scss,sass,less,styl,stylus,pcss,postcss}",
399
- "src/app.{css,scss,sass,less,styl,stylus,pcss,postcss}",
400
- "src/styles/app.{css,scss,sass,less,styl,stylus,pcss,postcss}"
401
- ];
569
+ let previousConfig;
402
570
  const CONFIG_DEFAULTS = {
403
571
  outDir: "./dist",
404
572
  linter: "biome",
@@ -409,71 +577,133 @@ const CONFIG_DEFAULTS = {
409
577
  async function loadConfig(cb) {
410
578
  let cleanup;
411
579
  const runCb = async (config, isUpdate) => {
580
+ if (isUpdate && previousConfig) await cleanupSpicetifyConfig();
412
581
  if (typeof cleanup === "function") await cleanup();
413
582
  cleanup = await cb(config, isUpdate);
583
+ previousConfig = config;
414
584
  };
415
585
  const watcher = await watchConfig({
416
586
  name: "spice",
417
587
  defaults: CONFIG_DEFAULTS,
418
- configFileRequired: false,
588
+ configFileRequired: true,
419
589
  packageJson: true,
420
590
  async onUpdate({ newConfig }) {
421
- await runCb(await getResolvedConfig(newConfig.config), true);
591
+ try {
592
+ await runCb(await getResolvedConfig(newConfig.config, { exitOnError: false }), true);
593
+ } catch {
594
+ logger$1.error(pc.red("Config validation failed, keeping previous configuration"));
595
+ }
422
596
  }
423
597
  });
424
598
  await runCb(await getResolvedConfig(watcher.config), false);
425
599
  return watcher;
426
600
  }
427
- async function getResolvedConfig(config) {
428
- try {
429
- return safeParse(OptionsSchema$1, await resolveContext(config), "Config");
430
- } catch (e) {
431
- const message = e instanceof Error ? e.message : String(e);
432
- logger$1.error(pc.red(`Failed to load configuration: ${message}`));
433
- process.exit(1);
434
- }
601
+ async function getResolvedConfig(config, { exitOnError = true } = {}) {
602
+ const resolvedContext = await resolveContext(config);
603
+ const result = v.safeParse(OptionsSchema$1, resolvedContext);
604
+ if (result.success) return result.output;
605
+ logger$1.error(pc.red("Failed to load configuration:"));
606
+ result.issues.forEach((issue) => {
607
+ const path = issue.path?.map((p) => p.key).join(".") || "input";
608
+ logger$1.error(`${pc.dim(" └─")} ${pc.yellow(path)}: ${pc.white(issue.message)}`);
609
+ });
610
+ if (exitOnError) process.exit(1);
611
+ throw new Error("Invalid configuration");
435
612
  }
436
613
  async function resolveContext(config) {
437
614
  const cwd = process.cwd();
438
- const getPkg = () => {
439
- try {
440
- return JSON.parse(readFileSync(resolve(cwd, "package.json"), "utf-8"));
441
- } catch {
442
- return {};
443
- }
444
- };
445
- const pkg = config.name && config.version ? {} : getPkg();
446
- if (!config.name) config.name = pkg.name || basename(cwd);
447
- const DEFAULT_VERSION = "0.0.1";
448
- if (!config.version) config.version = pkg.version ?? DEFAULT_VERSION;
449
- if (!config.entry) if (config.template === "theme") {
450
- config.entry = {
451
- js: resolveDefaultEntries(cwd, "js"),
452
- css: resolveDefaultEntries(cwd, "css")
453
- };
454
- if (!config.entry.js || config.entry.js.length === 0) config.entry.js = resolveDefaultEntries(cwd, "js");
455
- if (!config.entry.css || config.entry.css.length === 0) config.entry.css = resolveDefaultEntries(cwd, "css");
456
- } else config.entry = resolveDefaultEntries(cwd, "js");
457
- if (!config.devModeVarName) config.devModeVarName = DEV_MODE_VAR_NAME;
615
+ const pkg = config.name && config.version ? {} : getPackageMeta(cwd);
616
+ config.name ||= pkg.name || basename(cwd);
617
+ config.version ||= pkg.version || "0.0.1";
618
+ config.entry ||= resolveConfigEntries(config.template, cwd);
458
619
  config.outDir = resolve(cwd, config.outDir || "./dist");
459
620
  config.esbuildOptions ??= {};
460
621
  config.serverConfig ??= {};
622
+ if (config.template === "custom-app") config.icon ??= {
623
+ default: resolveDefaultIcon(cwd),
624
+ active: resolveActiveIcon(cwd)
625
+ };
461
626
  return config;
462
627
  }
463
- function resolveDefaultEntries(cwd, type) {
464
- const resolveFile = (globs) => {
465
- for (const glob of globs) {
466
- const matches = globSync(glob, {
467
- cwd,
468
- absolute: true
469
- });
470
- if (matches.length > 0) return matches;
471
- }
472
- return [];
628
+ function getPackageMeta(cwd) {
629
+ try {
630
+ return JSON.parse(readFileSync(resolve(cwd, "package.json"), "utf-8"));
631
+ } catch {
632
+ return {};
633
+ }
634
+ }
635
+ function resolveConfigEntries(template, cwd) {
636
+ if (template === "theme") return {
637
+ js: resolveDefaultEntry(cwd, "js"),
638
+ css: resolveDefaultEntry(cwd, "css")
639
+ };
640
+ if (template === "custom-app") return {
641
+ app: resolveDefaultEntry(cwd, "js-app-only"),
642
+ extension: resolveDefaultEntry(cwd, "js-extension-only")
473
643
  };
474
- const firstEntry = resolveFile(type === "js" ? JS_ENTRY_GLOBS : CSS_ENTRY_GLOBS)[0];
475
- if (!firstEntry) throw new Error(type === "js" ? "No JavaScript entry found (src/app or src/index)." : "No CSS entry found (src/app, src/index, or src/styles).");
476
- return firstEntry;
644
+ return resolveDefaultEntry(cwd, "js");
645
+ }
646
+ function resolveDefaultEntry(cwd, type) {
647
+ const globs = ENTRY_MAP[type];
648
+ for (const glob of globs) {
649
+ const matches = globSync(glob, {
650
+ cwd,
651
+ absolute: true
652
+ });
653
+ if (matches.length > 0 && matches[0]) return matches[0];
654
+ }
655
+ const displayType = {
656
+ js: "JavaScript/TypeScript",
657
+ css: "CSS/Stylesheet",
658
+ "js-app-only": "JavaScript/TypeScript (Custom App)",
659
+ "js-extension-only": "JavaScript/TypeScript (Extension)"
660
+ }[type];
661
+ const expectedFiles = globs.map((g) => ` - ${g}`).join("\n");
662
+ throw new Error(`No ${displayType} entry file found in your project.\nPlease create one of the following files:\n${expectedFiles}`);
663
+ }
664
+ function resolveDefaultIcon(cwd) {
665
+ for (const glob of ICON_GLOBS) {
666
+ const matches = globSync(glob, {
667
+ cwd,
668
+ absolute: true
669
+ });
670
+ if (matches.length > 0 && matches[0]) return readFileSync(matches[0]).toString();
671
+ }
672
+ const expectedFiles = ICON_GLOBS.map((g) => ` - ${g}`).join("\n");
673
+ throw new Error(`No icon file found in your project.\nPlease create one of the following files:\n${expectedFiles}`);
674
+ }
675
+ function resolveActiveIcon(cwd) {
676
+ for (const glob of ICON_ACTIVE_GLOBS) {
677
+ const matches = globSync(glob, {
678
+ cwd,
679
+ absolute: true
680
+ });
681
+ if (matches.length > 0 && matches[0]) return readFileSync(matches[0]).toString();
682
+ }
683
+ return "";
684
+ }
685
+ const getEnName = (configName) => typeof configName === "string" ? configName : configName.en;
686
+ function getSpiceIdentifier(config) {
687
+ if (config.template === "extension") return `${urlSlugify(config.name)}.js`;
688
+ if (config.template === "custom-app") return urlSlugify(getEnName(config.name));
689
+ return urlSlugify(getEnName(config.name));
690
+ }
691
+ function getSpiceVarName(template) {
692
+ if (template === "extension") return "extensions";
693
+ if (template === "custom-app") return "custom_apps";
694
+ return "current_theme";
695
+ }
696
+ async function cleanupSpicetifyConfig() {
697
+ if (!previousConfig) return;
698
+ const prevIdentifier = getSpiceIdentifier(previousConfig);
699
+ const varName = getSpiceVarName(previousConfig.template);
700
+ logger$1.debug(`Cleaning up previous spicetify config: ${varName} ${prevIdentifier}-`);
701
+ runSpice([
702
+ "config",
703
+ varName,
704
+ `${prevIdentifier}-`
705
+ ]);
706
+ runSpice(["apply"]);
477
707
  }
478
708
 
479
709
  //#endregion
@@ -577,14 +807,14 @@ function css({ minify = false, inline = false, logger = createLogger("plugin:css
577
807
  type,
578
808
  transform: postcssModules({
579
809
  getJSON: () => {},
580
- generateScopedName: "[name]__[local]___[hash:base64:5]",
581
- localsConvention: "camelCaseOnly"
810
+ generateScopedName: "[name]__[local]___[hash:base64:5]"
582
811
  }, postCssPlugins)
583
812
  }), sassPlugin({
584
813
  filter: /\.(s[ac]ss|css)$/,
585
814
  type,
586
815
  async transform(css, _resolveDir, filePath) {
587
816
  const start = performance.now();
817
+ logger.log("processing:", filePath, "type:", type);
588
818
  const result = await postcss(postCssPlugins).process(css, { from: filePath });
589
819
  logger.debug("Global CSS processed", {
590
820
  filePath,
@@ -615,68 +845,18 @@ const externalGlobal = (externals, namespace = "spicetify-global") => {
615
845
  };
616
846
  };
617
847
 
618
- //#endregion
619
- //#region src/utils/spicetify/schema.ts
620
- const SpicetifyConfigSchema = v.object({
621
- Setting: v.object({
622
- spotify_path: v.string(),
623
- prefs_path: v.string(),
624
- inject_theme_js: v.string(),
625
- inject_css: v.string(),
626
- current_theme: v.string(),
627
- color_scheme: v.string(),
628
- always_enable_devtools: v.string()
629
- }),
630
- AdditionalOptions: v.object({
631
- experimental_features: v.string(),
632
- extensions: v.pipe(v.string(), v.transform((input) => input.split("|").filter(Boolean))),
633
- custom_apps: v.string()
634
- }),
635
- Backup: v.object({
636
- version: v.string(),
637
- with: v.string()
638
- })
639
- });
640
-
641
- //#endregion
642
- //#region src/utils/spicetify/index.ts
643
- function runSpice(args) {
644
- validateSpicetify(env.spicetifyBin);
645
- return spawnSync(env.spicetifyBin, args, { encoding: "utf-8" });
646
- }
647
- const getExtensionDir = () => join(getSpiceDataPath(), "Extensions");
648
- const getThemesDir = () => join(getSpiceDataPath(), "Themes");
649
- async function getSpicetifyConfig() {
650
- const { stdout, stderr, error } = runSpice(["path", "-c"]);
651
- if (error || stderr) throw new Error(`Failed to locate Spicetify config: ${stderr || error?.message}`);
652
- const rawConfig = parse(await readFile(stdout.trim(), "utf-8"));
653
- const result = v.safeParse(SpicetifyConfigSchema, rawConfig);
654
- if (result.success) return result.output;
655
- else throw new Error("Spicetify Config Validation Failed:", v.flatten(result.issues).nested);
656
- }
657
- function getSpiceDataPath() {
658
- const { stdout, stderr, error } = runSpice(["path", "userdata"]);
659
- if (error || stderr) throw new Error(`Failed to locate Spicetify config: ${stderr || error?.message}`);
660
- return stdout.trim();
661
- }
662
- function validateSpicetify(bin) {
663
- const result = spawnSync(bin, ["--version"], { encoding: "utf-8" });
664
- if (result.error) throw result.error;
665
- if (result.status !== 0) throw new Error(`Invalid spicetify binary "${bin}": ${result.stderr || "unknown error"}`);
666
- }
667
-
668
848
  //#endregion
669
849
  //#region src/esbuild/plugins/spicetifyHandlers.ts
670
- const skipSpicetify = process.env.SPICETIFY_SKIP === "true" || process.env.CI === "true";
671
- const spicetifyHandler = ({ config, options, cache, logger = createLogger("plugin:spicetifyHandler") }) => ({
850
+ const spicetifyHandler = ({ config, options, cache, logger = createLogger("plugin:spicetify-handler") }) => ({
672
851
  name: "spice_internal__spicetify-build-handler",
673
852
  async setup(build) {
674
853
  const { apply = true, copy = true, applyOnce = true, remove, outDir = "./dist" } = options;
675
854
  let hasAppliedOnce = false;
676
855
  const isExtension = config.template === "extension";
677
- const identifier = isExtension ? `${urlSlugify(config.name)}.js` : urlSlugify(config.name);
678
- if (skipSpicetify) {
679
- logger.info(pc.yellow("SPICETIFY_SKIP=true, skipping spicetify operations"));
856
+ const isCustomApp = config.template === "custom-app";
857
+ const identifier = isExtension ? `${urlSlugify(config.name)}.js` : urlSlugify(getEnName(config.name));
858
+ if (env.skipSpicetify) {
859
+ logger.info(pc.yellow("skipping spicetify operations"));
680
860
  build.onEnd(async (result) => {
681
861
  if (result.errors.length > 0) return;
682
862
  if (!cache.hasChanges || cache.changed.size === 0) return;
@@ -702,38 +882,36 @@ const spicetifyHandler = ({ config, options, cache, logger = createLogger("plugi
702
882
  const spiceConfig = await getSpicetifyConfig();
703
883
  logger.debug(pc.green("Spicetify Config: "), spiceConfig);
704
884
  if (apply) {
705
- const defaultTheme = spiceConfig.Setting.current_theme;
706
- build.onStart(() => {
707
- const spiceIdentifier = remove ? `${identifier}-` : identifier;
708
- if (isExtension) runSpice([
709
- "config",
710
- "extensions",
711
- spiceIdentifier
712
- ]);
713
- else runSpice([
714
- "config",
715
- "current_theme",
716
- spiceIdentifier
717
- ]);
718
- });
885
+ const defaultTheme = spiceConfig?.Setting?.current_theme || "SpicetifyDefault";
886
+ const spiceIdentifier = remove ? `${identifier}-` : identifier;
887
+ runSpice([
888
+ "config",
889
+ isExtension ? "extensions" : isCustomApp ? "custom_apps" : "current_theme",
890
+ spiceIdentifier
891
+ ]);
719
892
  if (!isExtension && !remove) {
720
- const resetTheme = () => {
721
- runSpice([
893
+ const cleanup = () => {
894
+ if (isCustomApp) runSpice([
895
+ "config",
896
+ "custom_apps",
897
+ `${identifier}-`
898
+ ]);
899
+ else runSpice([
722
900
  "config",
723
901
  "current_theme",
724
902
  defaultTheme
725
903
  ]);
726
904
  process.exit();
727
905
  };
728
- process.once("SIGINT", resetTheme);
729
- process.once("SIGTERM", resetTheme);
906
+ process.once("SIGINT", cleanup);
907
+ process.once("SIGTERM", cleanup);
730
908
  }
731
909
  }
732
910
  build.onEnd(async (result) => {
733
911
  if (result.errors.length > 0) return;
734
912
  if (!cache.hasChanges || cache.changed.size === 0) return;
735
913
  const destDirs = [resolve(outDir)];
736
- if (copy) destDirs.push(isExtension ? getExtensionDir() : resolve(getThemesDir(), identifier));
914
+ if (copy) destDirs.push(isExtension ? getExtensionDir() : isCustomApp ? resolve(getCustomAppsDir(), identifier) : resolve(getThemesDir(), identifier));
737
915
  const tasks = [];
738
916
  for (const filePath of cache.changed) {
739
917
  const fileData = cache.files.get(filePath);
@@ -754,8 +932,8 @@ const spicetifyHandler = ({ config, options, cache, logger = createLogger("plugi
754
932
  return;
755
933
  }
756
934
  if (apply && cache.hasChanges && (!applyOnce || !hasAppliedOnce)) {
757
- const { stderr, status } = runSpice(["apply"]);
758
- if (status !== 0) logger.error(pc.red(`${CROSS} Spicetify apply failed: ${stderr}`));
935
+ const { stdout, stderr, status } = runSpice(["apply"]);
936
+ if (status !== 0) logger.error(pc.red(`${CROSS} Spicetify apply failed:`), stdout, stderr);
759
937
  else hasAppliedOnce = true;
760
938
  }
761
939
  });
@@ -764,70 +942,161 @@ const spicetifyHandler = ({ config, options, cache, logger = createLogger("plugi
764
942
 
765
943
  //#endregion
766
944
  //#region src/esbuild/plugins/wrapWithLoader.ts
767
- function wrapWithLoader({ name, type, version, cache, outFiles, server, dev = false, logger = createLogger("plugin:wrapWithLoader") }) {
945
+ function wrapWithLoader({ config, cache, outFiles, server, dev = false, logger = createLogger("plugin:wrapper") }) {
768
946
  const namespace = "spice_internal__wrap-with-loader";
947
+ const name = typeof config.name === "string" ? config.name : config.name.en;
948
+ const { template: type, version } = config;
949
+ let previousManifestHash;
769
950
  return {
770
951
  name: namespace,
771
- setup(build) {
772
- if (build.initialOptions.write !== false) throw new Error(`[${namespace}] This plugin requires "write: false" in build options.`);
773
- build.onEnd(async (res) => {
952
+ setup(build$3) {
953
+ if (build$3.initialOptions.write !== false) throw new Error(`[${namespace}] This plugin requires "write: false" in build options.`);
954
+ build$3.onEnd(async (res) => {
774
955
  try {
775
956
  if (res.errors.length > 0 || !res.outputFiles) return;
776
957
  cache.changed.clear();
777
958
  cache.hasChanges = false;
778
959
  const filesChanged = [];
779
- let bundledCss = "";
780
- if (!dev && type === "extension") bundledCss = res.outputFiles.filter((f) => f.path.endsWith(".css")).map((f) => f.text).join("");
960
+ const outdir = resolve(build$3.initialOptions.outdir || "./dist");
961
+ const bundledCss = getBundledCss(res.outputFiles, outdir, type, dev);
962
+ const minify = build$3.initialOptions.minify;
963
+ const slug = varSlugify(`${name}_${version}`);
781
964
  const transformPromises = res.outputFiles.map(async (file) => {
782
965
  const isJs = file.path.endsWith(".js");
783
966
  const isCss = file.path.endsWith(".css");
784
967
  if (!dev && isCss && type === "extension") return;
785
- const targetName = isJs ? outFiles.js : isCss ? outFiles.css ?? basename(file.path) : basename(file.path);
786
- const renamedPath = join(build.initialOptions.outdir || "./dist/", targetName);
968
+ const relPath = file.path.slice(outdir.length);
969
+ const isCustomAppExtension = type === "custom-app" && isExtensionDir(relPath);
970
+ let targetName;
971
+ if (isJs) targetName = isCustomAppExtension ? outFiles.jsExtension ?? "extension.js" : outFiles.js;
972
+ else if (isCss && !isCustomAppExtension) targetName = outFiles.css;
973
+ if (!targetName) {
974
+ logger.debug("Skipped file: ", file.path);
975
+ return;
976
+ }
977
+ const renamedPath = join(build$3.initialOptions.outdir || "./dist/", targetName);
978
+ if (type === "custom-app" && targetName === outFiles.js && isJs) {
979
+ const globalName = varSlugify(getEnName(config.name));
980
+ const final = (await build({
981
+ bundle: true,
982
+ write: false,
983
+ minify,
984
+ platform: "browser",
985
+ format: "iife",
986
+ globalName,
987
+ stdin: {
988
+ contents: readFileSync(customAppEntryFilePath),
989
+ loader: "tsx",
990
+ sourcefile: "entry.tsx"
991
+ },
992
+ plugins: [plugins.externalGlobal({ react: "Spicetify.React" }), {
993
+ name: "virtual-modules",
994
+ setup(vBuild) {
995
+ vBuild.onResolve({ filter: /^virtual:app$/ }, () => ({
996
+ path: "app",
997
+ namespace: "virtual"
998
+ }));
999
+ vBuild.onLoad({
1000
+ filter: /.*/,
1001
+ namespace: "virtual"
1002
+ }, () => ({
1003
+ contents: file.text,
1004
+ loader: "js"
1005
+ }));
1006
+ }
1007
+ }]
1008
+ })).outputFiles?.[0];
1009
+ if (!final) return;
1010
+ let combinedCode = `${final.text}${dev ? `export default () => ${globalName}.default();` : `var render = () => ${globalName}.default();`}\n`;
1011
+ const nextBuffer = Buffer.from(combinedCode);
1012
+ const existingFile = cache.files.get(renamedPath);
1013
+ const nextHash = final.hash;
1014
+ if (!existingFile || existingFile.hash !== nextHash || config.template === "custom-app") {
1015
+ cache.files.set(renamedPath, {
1016
+ contents: nextBuffer,
1017
+ name: targetName,
1018
+ hash: final.hash
1019
+ });
1020
+ cache.changed.add(renamedPath);
1021
+ cache.hasChanges = true;
1022
+ filesChanged.push(renamedPath);
1023
+ }
1024
+ return;
1025
+ }
787
1026
  if (!isJs) {
788
- cache.files.set(renamedPath, {
789
- name: targetName,
790
- contents: file.contents
791
- });
792
- cache.changed.add(renamedPath);
793
- cache.hasChanges = true;
794
- filesChanged.push(renamedPath);
1027
+ const nextBuffer = Buffer.from(file.contents);
1028
+ const existingFile = cache.files.get(renamedPath);
1029
+ const nextHash = file.hash;
1030
+ if (!existingFile || existingFile.hash !== nextHash || config.template === "custom-app") {
1031
+ cache.files.set(renamedPath, {
1032
+ name: targetName,
1033
+ contents: nextBuffer,
1034
+ hash: file.hash
1035
+ });
1036
+ cache.changed.add(renamedPath);
1037
+ cache.hasChanges = true;
1038
+ filesChanged.push(renamedPath);
1039
+ }
795
1040
  return;
796
1041
  }
797
- const slug = varSlugify(`${name}_${version}`);
798
- const templateRaw = readFileSync(templateFilePath, "utf-8");
799
- const minify = build.initialOptions.minify;
800
- const { code: transformedTemp } = await transform(templateRaw, {
1042
+ const { code: transformedTemp } = await transform(readFileSync(templateWrapperFilePath, "utf-8"), {
801
1043
  minify,
802
- target: build.initialOptions.target || "es2020",
1044
+ target: build$3.initialOptions.target || "es2020",
803
1045
  loader: "jsx",
804
1046
  define: {
805
- __ESBUILD__HAS_CSS: JSON.stringify(type === "extension"),
1047
+ __ESBUILD__HAS_CSS: JSON.stringify(type !== "theme"),
1048
+ __ESBUILD__INJECTED_CSS: JSON.stringify(bundledCss),
806
1049
  __ESBUILD__APP_SLUG: JSON.stringify(slug),
807
1050
  __ESBUILD__APP_TYPE: JSON.stringify(type),
808
1051
  __ESBUILD__APP_ID: JSON.stringify(varSlugify(name)),
809
- __ESBUILD__APP_VERSION: JSON.stringify(version),
810
- __ESBUILD__APP_HASH: JSON.stringify("")
1052
+ __ESBUILD__APP_VERSION: JSON.stringify(version)
811
1053
  }
812
1054
  });
813
1055
  const template = replace(transformedTemp, {
814
1056
  "\"{{INJECT_START_COMMENT}}\"": minify ? "" : "/* --- START --- */",
815
1057
  "\"{{INJECT_END_COMMENT}}\"": minify ? "" : "/* --- END --- */",
816
- "{{INJECTED_CSS_HERE}}": bundledCss,
817
1058
  "\"{{INJECTED_JS_HERE}}\"": file.text
818
1059
  });
819
1060
  const nextBuffer = Buffer.from(template);
820
- const previous = cache.files.get(renamedPath);
821
- if (previous?.contents && Buffer.compare(previous.contents, nextBuffer) === 0) return;
822
- cache.files.set(renamedPath, {
823
- name: targetName,
824
- contents: nextBuffer
825
- });
826
- cache.changed.add(renamedPath);
827
- cache.hasChanges = true;
828
- filesChanged.push(renamedPath);
1061
+ const existingFile = cache.files.get(renamedPath);
1062
+ const nextHash = file.hash;
1063
+ if (!existingFile || existingFile.hash !== nextHash || config.template === "custom-app") {
1064
+ cache.files.set(renamedPath, {
1065
+ name: targetName,
1066
+ contents: nextBuffer,
1067
+ hash: file.hash
1068
+ });
1069
+ cache.changed.add(renamedPath);
1070
+ cache.hasChanges = true;
1071
+ filesChanged.push(renamedPath);
1072
+ }
829
1073
  });
830
1074
  await Promise.all(transformPromises);
1075
+ if (type === "custom-app") {
1076
+ const icon = config.icon;
1077
+ const manifestPath = join(outdir, "manifest.json");
1078
+ const manifest = {
1079
+ name: config.name,
1080
+ subfiles: [],
1081
+ subfiles_extension: ["extension.js"],
1082
+ icon: icon.default,
1083
+ "active-icon": icon.active ?? ""
1084
+ };
1085
+ const manifestString = JSON.stringify(manifest, null, 2);
1086
+ const currentHash = createHash("md5").update(manifestString).digest("hex");
1087
+ if (currentHash !== previousManifestHash) {
1088
+ previousManifestHash = currentHash;
1089
+ const manifestBuffer = Buffer.from(manifestString);
1090
+ cache.files.set(manifestPath, {
1091
+ contents: manifestBuffer,
1092
+ name: "manifest.json",
1093
+ hash: currentHash
1094
+ });
1095
+ cache.changed.add(manifestPath);
1096
+ cache.hasChanges = true;
1097
+ filesChanged.push(manifestPath);
1098
+ }
1099
+ }
831
1100
  if (filesChanged.length > 0) server?.broadcast(filesChanged);
832
1101
  } catch (e) {
833
1102
  logger.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
@@ -836,6 +1105,15 @@ function wrapWithLoader({ name, type, version, cache, outFiles, server, dev = fa
836
1105
  }
837
1106
  };
838
1107
  }
1108
+ function isExtensionDir(relPath) {
1109
+ return relPath.startsWith("/extension/") || relPath.startsWith("\\extension\\");
1110
+ }
1111
+ function getBundledCss(files, outdir, type, dev) {
1112
+ const cssFiles = files.filter((f) => f.path.endsWith(".css"));
1113
+ if (!dev && type === "extension") return cssFiles.map((f) => f.text).join("");
1114
+ if (type === "custom-app") return cssFiles.filter((f) => isExtensionDir(f.path.slice(outdir.length))).map((f) => f.text).join("");
1115
+ return "";
1116
+ }
839
1117
 
840
1118
  //#endregion
841
1119
  //#region src/esbuild/plugins/index.ts
@@ -861,11 +1139,12 @@ const defaultBuildOptions = {
861
1139
  target: ["es2022", "chrome120"]
862
1140
  };
863
1141
  const getCommonPlugins = (opts) => {
864
- const { template, minify, cache, name, version, buildOptions, outFiles, server, dev } = opts;
1142
+ const { template, minify, cache, buildOptions, outFiles, server, dev } = opts;
1143
+ const inline = !dev && template === "extension";
865
1144
  return [
866
1145
  ...plugins.css({
867
1146
  minify,
868
- inline: !dev && template === "extension"
1147
+ inline
869
1148
  }),
870
1149
  plugins.externalGlobal({
871
1150
  react: "Spicetify.React",
@@ -875,9 +1154,7 @@ const getCommonPlugins = (opts) => {
875
1154
  "react/jsx-runtime": "Spicetify.ReactJSX"
876
1155
  }),
877
1156
  plugins.wrapWithLoader({
878
- name,
879
- version,
880
- type: template,
1157
+ config: opts,
881
1158
  cache,
882
1159
  outFiles,
883
1160
  server,
@@ -891,11 +1168,35 @@ const getCommonPlugins = (opts) => {
891
1168
  plugins.buildLogger({ cache })
892
1169
  ];
893
1170
  };
1171
+ function getEntryPoints(config) {
1172
+ if (config.template === "theme") return [config.entry.js, config.entry.css];
1173
+ if (config.template === "custom-app") return [config.entry.app, config.entry.extension];
1174
+ return [config.entry];
1175
+ }
1176
+ function getOutFiles(config, isDev = false) {
1177
+ switch (config.template) {
1178
+ case "custom-app": return {
1179
+ js: "index.js",
1180
+ css: "style.css",
1181
+ jsExtension: "extension.js",
1182
+ manifest: "manifest.json"
1183
+ };
1184
+ case "extension": return {
1185
+ js: `${urlSlugify(getEnName(config.name))}.js`,
1186
+ css: isDev ? "app.css" : void 0
1187
+ };
1188
+ case "theme": return {
1189
+ js: "theme.js",
1190
+ css: "user.css"
1191
+ };
1192
+ default: throw new Error("Unknown template");
1193
+ }
1194
+ }
894
1195
 
895
1196
  //#endregion
896
1197
  //#region src/build/index.ts
897
1198
  const logger = createLogger("build");
898
- async function build$1(options) {
1199
+ async function build$2(options) {
899
1200
  logger.clear();
900
1201
  logger.greeting(pc.green("Building for production..."));
901
1202
  let ctx;
@@ -922,10 +1223,7 @@ async function build$1(options) {
922
1223
  });
923
1224
  }
924
1225
  function getJSBuildOptions(config, options) {
925
- const entryPoints = (() => {
926
- if (config.template === "theme") return [config.entry.js, config.entry.css];
927
- return [config.entry];
928
- })();
1226
+ const entryPoints = getEntryPoints(config);
929
1227
  const minify = options.watch ? false : options.minify;
930
1228
  const outDir = resolve(config.outDir);
931
1229
  const cache = {
@@ -933,14 +1231,13 @@ function getJSBuildOptions(config, options) {
933
1231
  changed: /* @__PURE__ */ new Set(),
934
1232
  hasChanges: true
935
1233
  };
936
- const outFiles = {
937
- js: config.template === "extension" ? `${urlSlugify(config.name)}.js` : "theme.js",
938
- css: config.template === "theme" ? "user.css" : null
939
- };
1234
+ const outFiles = getOutFiles(config);
940
1235
  const overrides = {
941
1236
  ...defaultBuildOptions,
942
1237
  outdir: outDir,
1238
+ format: "esm",
943
1239
  minify,
1240
+ globalName: varSlugify(getEnName(config.name)),
944
1241
  sourcemap: false,
945
1242
  external: [
946
1243
  ...config.esbuildOptions?.external ? config.esbuildOptions.external : [],
@@ -949,7 +1246,7 @@ function getJSBuildOptions(config, options) {
949
1246
  ],
950
1247
  define: {
951
1248
  [DEV_MODE_VAR_NAME]: "false",
952
- [config.devModeVarName]: "false",
1249
+ ...config.devModeVarName ? { [config.devModeVarName]: "false" } : {},
953
1250
  ...config.esbuildOptions.define
954
1251
  },
955
1252
  plugins: [...config.esbuildOptions?.plugins ? config.esbuildOptions.plugins : [], ...getCommonPlugins({
@@ -973,6 +1270,20 @@ function getJSBuildOptions(config, options) {
973
1270
  };
974
1271
  }
975
1272
 
1273
+ //#endregion
1274
+ //#region src/utils/schema.ts
1275
+ function safeParse(schema, data, type = "CLI") {
1276
+ const result = v.safeParse(schema, data);
1277
+ if (result.success) return result.output;
1278
+ logger$2.error(`\n${pc.bgRed(pc.black(" ERROR "))} ${pc.red(`Invalid ${type} options:`)}`);
1279
+ result.issues.forEach((issue) => {
1280
+ const path = issue.path?.map((p) => p.key).join(".") || "input";
1281
+ logger$2.error(`${pc.dim(" └─")} ${pc.yellow(path)}: ${pc.white(issue.message)}`);
1282
+ });
1283
+ logger$2.error(`\n${pc.dim("Check your command flags and try again.")}\n`);
1284
+ process.exit(1);
1285
+ }
1286
+
976
1287
  //#endregion
977
1288
  //#region src/commands/build.ts
978
1289
  const CLIOptionsSchema$1 = v.strictObject({
@@ -981,8 +1292,8 @@ const CLIOptionsSchema$1 = v.strictObject({
981
1292
  apply: v.boolean(),
982
1293
  copy: v.boolean()
983
1294
  });
984
- const build = new Command("build").description("Build your spicetify project").option("-a, --apply", "Apply to spicetify", false).option("-w, --watch", "Watch mode", false).option("--no-copy", "Do not copy files to spicetify").option("--no-minify", "Disable code minification").action(async (opts) => {
985
- await build$1(safeParse(CLIOptionsSchema$1, opts));
1295
+ const build$1 = new Command("build").description("Build your spicetify project").option("-a, --apply", "Apply to spicetify", false).option("-w, --watch", "Watch mode", false).option("--no-copy", "Do not copy files to spicetify").option("--no-minify", "Disable code minification").action(async (opts) => {
1296
+ await build$2(safeParse(CLIOptionsSchema$1, opts));
986
1297
  });
987
1298
 
988
1299
  //#endregion
@@ -1040,10 +1351,11 @@ function createPackageJSON(options) {
1040
1351
  scripts: {
1041
1352
  sc: "spicetify-creator",
1042
1353
  dev: "spicetify-creator dev",
1043
- build: "spicetify-creator build"
1354
+ build: "spicetify-creator build",
1355
+ "update-types": "spicetify-creator update-types"
1044
1356
  },
1045
1357
  dependencies: {},
1046
- devDependencies: { "@spicetify/creator": env.isInternal ? "link:@spicetify/creator" : "latest" }
1358
+ devDependencies: { "@spicetify/creator": "latest" }
1047
1359
  };
1048
1360
  if (options.language === "ts") result.peerDependencies = {
1049
1361
  ...result.peerDependencies,
@@ -1085,9 +1397,10 @@ function validateProjectName(name) {
1085
1397
 
1086
1398
  //#endregion
1087
1399
  //#region src/create/template.ts
1088
- const ext = (lang) => lang === "ts" ? "ts" : "js";
1400
+ const ext = (lang, jsx = false) => lang === "ts" ? `ts${jsx ? "x" : ""}` : `js${jsx ? "x" : ""}`;
1089
1401
  const kv = ({ name, language, framework, linter, packageManager, template }) => ({
1090
1402
  "{{project-name}}": name,
1403
+ "{{project-url}}": `/${urlSlugify(name)}`,
1091
1404
  "{{framework}}": framework,
1092
1405
  "{{linter}}": linter,
1093
1406
  "{{package-manager}}": packageManager,
@@ -1104,36 +1417,60 @@ const kv = ({ name, language, framework, linter, packageManager, template }) =>
1104
1417
  const action = { modify(c, opts) {
1105
1418
  return replace(c, kv(opts));
1106
1419
  } };
1107
- const COMMON_FILES = (opts) => [
1108
- {
1109
- from: "README.template.md",
1110
- to: "README.md",
1111
- action,
1112
- isShared: true
1113
- },
1114
- {
1115
- from: ".gitignore",
1116
- to: ".gitignore",
1117
- isShared: true
1118
- },
1119
- {
1120
- from: `spice.config.${ext(opts.language)}`,
1121
- to: `spice.config.${ext(opts.language)}`,
1122
- action,
1123
- isShared: true
1124
- },
1125
- {
1420
+ const SHARED_FILES = (opts) => {
1421
+ const isShared = true;
1422
+ const files = [
1423
+ {
1424
+ from: "README.template.md",
1425
+ to: "README.md",
1426
+ action,
1427
+ isShared
1428
+ },
1429
+ {
1430
+ from: "DOT-gitignore",
1431
+ to: ".gitignore",
1432
+ isShared: true
1433
+ },
1434
+ {
1435
+ from: `spice.config.${ext(opts.language)}`,
1436
+ to: `spice.config.${ext(opts.language)}`,
1437
+ action,
1438
+ isShared
1439
+ }
1440
+ ];
1441
+ if (opts.template === "custom-app") {
1442
+ files.push({
1443
+ from: `css/app.module.scss`,
1444
+ to: `src/css/app.module.scss`,
1445
+ action,
1446
+ isShared
1447
+ });
1448
+ files.push({
1449
+ from: `icon.svg`,
1450
+ to: `src/icon.svg`,
1451
+ action,
1452
+ isShared
1453
+ });
1454
+ files.push({
1455
+ from: `icon-active.svg`,
1456
+ to: `src/icon-active.svg`,
1457
+ action,
1458
+ isShared
1459
+ });
1460
+ files.push({
1461
+ from: "app.css",
1462
+ to: "src/extension/app.css",
1463
+ action,
1464
+ isShared
1465
+ });
1466
+ } else files.push({
1126
1467
  from: "app.css",
1127
1468
  to: "src/app.css",
1128
1469
  action,
1129
- isShared: true
1130
- },
1131
- ...opts.language === "ts" ? [{
1132
- from: "css.d.ts",
1133
- to: "src/types/css.d.ts",
1134
- isShared: true
1135
- }] : []
1136
- ];
1470
+ isShared
1471
+ });
1472
+ return files;
1473
+ };
1137
1474
  const LANGUAGE_FILES = {
1138
1475
  js: [{
1139
1476
  from: "jsconfig.json",
@@ -1147,24 +1484,36 @@ const LANGUAGE_FILES = {
1147
1484
  }]
1148
1485
  };
1149
1486
  const FRAMEWORKS = {
1150
- react: ({ language }) => [{
1151
- from: `src/app.${ext(language)}x`,
1152
- to: `src/app.${ext(language)}x`,
1153
- action
1154
- }, {
1155
- from: `src/components/Onboarding.${ext(language)}x`,
1156
- to: `src/components/Onboarding.${ext(language)}x`,
1157
- action
1158
- }],
1159
- vanilla: ({ language }) => [{
1160
- from: `src/app.${ext(language)}`,
1161
- to: `src/app.${ext(language)}`,
1162
- action
1163
- }, {
1164
- from: `src/components/Onboarding.${ext(language)}`,
1165
- to: `src/components/Onboarding.${ext(language)}`,
1166
- action
1167
- }]
1487
+ react: ({ language, template }) => {
1488
+ const react = [{
1489
+ from: `src/app.${ext(language, true)}`,
1490
+ to: `src/app.${ext(language, true)}`,
1491
+ action
1492
+ }, {
1493
+ from: `src/components/Onboarding.${ext(language, true)}`,
1494
+ to: `src/components/Onboarding.${ext(language, true)}`,
1495
+ action
1496
+ }];
1497
+ if (template === "custom-app") react.push({
1498
+ from: `src/extension/index.${ext(language, true)}`,
1499
+ to: `src/extension/index.${ext(language, true)}`,
1500
+ action
1501
+ });
1502
+ return react;
1503
+ },
1504
+ vanilla: ({ language, template }) => {
1505
+ const vanilla = [{
1506
+ from: `src/app.${ext(language)}`,
1507
+ to: `src/app.${ext(language)}`,
1508
+ action
1509
+ }, {
1510
+ from: `src/components/Onboarding.${ext(language)}`,
1511
+ to: `src/components/Onboarding.${ext(language)}`,
1512
+ action
1513
+ }];
1514
+ if (template === "custom-app") throw new Error("vanilla doesn't exist for custom-app");
1515
+ return vanilla;
1516
+ }
1168
1517
  };
1169
1518
  const LINTERS = {
1170
1519
  biome: [{
@@ -1177,23 +1526,25 @@ const LINTERS = {
1177
1526
  to: `eslint.config.${ext(language)}`
1178
1527
  }],
1179
1528
  oxlint: [{
1180
- from: ".oxlintrc.json",
1529
+ from: "DOT-oxlintrc.json",
1181
1530
  to: ".oxlintrc.json",
1182
1531
  isShared: true
1183
1532
  }]
1184
1533
  };
1534
+ const getFiles = (options) => {
1535
+ const resolve = (slice) => typeof slice === "function" ? slice(options) : slice ?? [];
1536
+ return [
1537
+ ...resolve(SHARED_FILES),
1538
+ ...resolve(LANGUAGE_FILES[options.language]),
1539
+ ...resolve(FRAMEWORKS[options.framework]),
1540
+ ...resolve(LINTERS[options.linter])
1541
+ ];
1542
+ };
1185
1543
  function setupTemplateFiles(options, targetDir) {
1186
- const { template, language, framework, linter } = options;
1544
+ const { template, language, framework } = options;
1187
1545
  const templateRoot = dist(`templates/${template}`, import.meta.url);
1188
1546
  const fromDir = join(templateRoot, language, framework);
1189
- const resolve = (slice) => typeof slice === "function" ? slice(options) : slice ?? [];
1190
- const files = [
1191
- ...resolve(COMMON_FILES),
1192
- ...resolve(LANGUAGE_FILES[language]),
1193
- ...resolve(FRAMEWORKS[framework]),
1194
- ...resolve(LINTERS[linter])
1195
- ];
1196
- for (const file of files) {
1547
+ for (const file of getFiles(options)) {
1197
1548
  const src = (() => {
1198
1549
  if (file.isGlobal) return join(templateRoot, file.from);
1199
1550
  if (file.isShared) return join(templateRoot, "shared", file.from);
@@ -1291,6 +1642,36 @@ function tryGitInit(root) {
1291
1642
  }
1292
1643
  }
1293
1644
 
1645
+ //#endregion
1646
+ //#region src/utils/update-types.ts
1647
+ const downloads = { spicetify: {
1648
+ from: "https://raw.githubusercontent.com/spicetify/cli/main/globals.d.ts",
1649
+ to: "./src/types/spicetify.d.ts",
1650
+ action: (content) => content.replace("const React: any;", "const React: typeof import(\"react\");").replace("const ReactDOM: any;", "const ReactDOM: typeof import(\"react-dom/client\");").replace("const ReactDOMServer: any;", "const ReactDOMServer: typeof import(\"react-dom/server\");")
1651
+ } };
1652
+ async function updateTypes(isUpdating = true, cwd = process.cwd()) {
1653
+ const s = spinner();
1654
+ s.start(`${isUpdating ? "Updating" : "Creating"} Types...`);
1655
+ await Promise.all(Object.entries(downloads).map(([name, download]) => downloadFile(name, download, isUpdating, cwd)));
1656
+ s.stop(`${isUpdating ? "Updated" : "Created"} Types!`);
1657
+ }
1658
+ async function downloadFile(name, { from, to, action }, isUpdating, cwd) {
1659
+ try {
1660
+ const res = await fetch(from);
1661
+ if (!res.ok) throw new Error(`HTTP Error: ${res.status} ${res.statusText}`);
1662
+ let text = await res.text();
1663
+ if (action) text = await action(text);
1664
+ const fullPath = join$1(cwd, to);
1665
+ await mkdir(dirname$1(fullPath), { recursive: true });
1666
+ await writeFile$1(fullPath, text, "utf8");
1667
+ const actionLog = isUpdating ? "updated" : "created";
1668
+ logger$2.log(`${name}.d.ts ${actionLog} (${from} -> ${fullPath})`);
1669
+ } catch (e) {
1670
+ log.error(`${name} failed`);
1671
+ console.error(e);
1672
+ }
1673
+ }
1674
+
1294
1675
  //#endregion
1295
1676
  //#region src/create/index.ts
1296
1677
  const isOnline = await getOnline();
@@ -1337,7 +1718,8 @@ async function createProject(cwd, options) {
1337
1718
  options: templateOptions
1338
1719
  });
1339
1720
  },
1340
- framework: async () => {
1721
+ framework: async ({ results: { template } }) => {
1722
+ if (template === "custom-app") return "react";
1341
1723
  if (options.framework) return options.framework;
1342
1724
  return await p.select({
1343
1725
  message: "Select which framework you want to chose",
@@ -1407,6 +1789,7 @@ async function create$1(cwd, options) {
1407
1789
  try {
1408
1790
  mkdirp(cwd);
1409
1791
  setupTemplateFiles(options, cwd);
1792
+ await updateTypes(false, cwd);
1410
1793
  const pkgJSON = createPackageJSON(options);
1411
1794
  writePackageJSON(pkgJSON, cwd);
1412
1795
  chdir(cwd);
@@ -1587,7 +1970,12 @@ async function createHmrServer(config, logger = createLogger("hmrServer")) {
1587
1970
  resolve();
1588
1971
  });
1589
1972
  }),
1590
- stop: () => new Promise((resolve, reject) => {
1973
+ stop: async () => new Promise((resolve, reject) => {
1974
+ if (!isRunning) return resolve();
1975
+ for (const client of clients) client.terminate();
1976
+ clients.clear();
1977
+ wss.close();
1978
+ if ("closeAllConnections" in httpServer) httpServer.closeAllConnections();
1591
1979
  httpServer.close((err) => {
1592
1980
  if (err) return reject(err);
1593
1981
  isRunning = false;
@@ -1617,7 +2005,7 @@ const injectHMRExtension = async (rootLink, wsLink, outFiles) => {
1617
2005
  const extName = `sc-live-reload-helper.js`;
1618
2006
  const spiceConfig = await getSpicetifyConfig();
1619
2007
  const cleanup = () => {
1620
- if (env.isDev) logger$2.log(`[Spicetify] Removing Live reload extension...`);
2008
+ logger$2.info(`Removing Live reload extension...`);
1621
2009
  try {
1622
2010
  runSpice([
1623
2011
  "config",
@@ -1625,18 +2013,16 @@ const injectHMRExtension = async (rootLink, wsLink, outFiles) => {
1625
2013
  `${extName}-`
1626
2014
  ]);
1627
2015
  runSpice(["apply"]);
1628
- logger$2.debug(pc.green(`${CHECK} Cleanup successful.`));
2016
+ logger$2.info(pc.green(`${CHECK} Cleanup successful.`));
1629
2017
  } catch (e) {
1630
- if (env.isDev) logger$2.error(pc.red(`${CROSS} Cleanup failed: `), e);
2018
+ logger$2.error(pc.red(`${CROSS} Cleanup failed: `), e);
1631
2019
  }
1632
2020
  process.exit();
1633
2021
  };
1634
- if (env.isDev) {
1635
- process.on("SIGINT", cleanup);
1636
- process.on("SIGTERM", cleanup);
1637
- }
2022
+ process.on("SIGINT", cleanup);
2023
+ process.on("SIGTERM", cleanup);
1638
2024
  try {
1639
- logger$2.debug(`[Spicetify] Preparing Live reload extension...`);
2025
+ logger$2.debug(`Preparing Live reload extension...`);
1640
2026
  const destDir = getExtensionDir();
1641
2027
  mkdirp(destDir);
1642
2028
  const outDir = resolve(destDir, extName);
@@ -1663,6 +2049,114 @@ const injectHMRExtension = async (rootLink, wsLink, outFiles) => {
1663
2049
  logger$2.error(pc.red(`${CROSS} Failed to inject HMR helper: ${err instanceof Error ? err.message : String(err)}`));
1664
2050
  }
1665
2051
  };
2052
+ const injectHMRCustomApp = async (rootLink, wsLink, outFiles, config) => {
2053
+ if (config.template !== "custom-app") throw new Error("only supports custom-app templates");
2054
+ const identifier = getEnName(config.name);
2055
+ const spiceConfig = await getSpicetifyConfig();
2056
+ const customAppId = urlSlugify(identifier);
2057
+ runSpice([
2058
+ "config",
2059
+ "extensions",
2060
+ "sc-live-reload-helper.js-"
2061
+ ]);
2062
+ const cleanup = () => {
2063
+ logger$2.info(`Removing Live reload custom-app...`);
2064
+ try {
2065
+ runSpice([
2066
+ "config",
2067
+ "custom_apps",
2068
+ `${customAppId}-`
2069
+ ]);
2070
+ runSpice(["apply"]);
2071
+ logger$2.info(pc.green(`${CHECK} Cleanup successful.`));
2072
+ } catch (e) {
2073
+ logger$2.error(pc.red(`${CROSS} Cleanup failed: `), e);
2074
+ }
2075
+ process.exit();
2076
+ };
2077
+ process.on("SIGINT", cleanup);
2078
+ process.on("SIGTERM", cleanup);
2079
+ try {
2080
+ logger$2.debug(`Preparing Live reload custom-app...`);
2081
+ const destDir = resolve(getCustomAppsDir(), customAppId);
2082
+ mkdirp(destDir);
2083
+ const indexJsPath = resolve(destDir, "index.js");
2084
+ const extensionJsPath = resolve(destDir, outFiles.jsExtension ?? "extension.js");
2085
+ writeFileSync(indexJsPath, `const React = Spicetify.React;
2086
+ const waitForImport = () => {
2087
+ return import("${rootLink}/files/${outFiles.js}")
2088
+ .then((mod) => mod.default || mod.render)
2089
+ .catch((err) => {
2090
+ console.error("Failed to import app:", err);
2091
+ return null;
2092
+ });
2093
+ };
2094
+
2095
+ const AppWrapper = ({ appPromise }) => {
2096
+ const [App, setApp] = React.useState(null);
2097
+
2098
+ React.useEffect(() => {
2099
+ let mounted = true;
2100
+
2101
+ appPromise.then((app) => {
2102
+ if (mounted && app) {
2103
+ setApp(() => app);
2104
+ }
2105
+ });
2106
+
2107
+ return () => {
2108
+ mounted = false;
2109
+ };
2110
+ }, [appPromise]);
2111
+
2112
+ if (!App) {
2113
+ return React.createElement(
2114
+ "div",
2115
+ { className: "loading" },
2116
+ "Loading app..."
2117
+ );
2118
+ }
2119
+
2120
+ return React.createElement(App);
2121
+ };
2122
+
2123
+ const render = () => {
2124
+ const appPromise = waitForImport();
2125
+ return React.createElement(AppWrapper, { appPromise });
2126
+ };
2127
+ `);
2128
+ const { code: extensionCode } = await transform(readFileSync(liveReloadFilePath, "utf8"), {
2129
+ loader: "js",
2130
+ define: {
2131
+ _SERVER_URL: JSON.stringify(rootLink),
2132
+ _HOT_RELOAD_LINK: JSON.stringify(wsLink),
2133
+ _JS_PATH: JSON.stringify(`/files/${outFiles.jsExtension ?? "extension.js"}`),
2134
+ _CSS_PATH: JSON.stringify(outFiles.css ? `/files/${outFiles.css}` : `/files/app.css`)
2135
+ }
2136
+ });
2137
+ writeFileSync(extensionJsPath, extensionCode);
2138
+ const manifestPath = resolve(destDir, "manifest.json");
2139
+ const manifest = {
2140
+ name: config.name,
2141
+ subfiles: [],
2142
+ subfiles_extension: [outFiles.jsExtension ?? "extension.js"],
2143
+ icon: config.icon.default,
2144
+ "active-icon": config.icon.active ?? config.icon.default
2145
+ };
2146
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
2147
+ if (spiceConfig) {
2148
+ runSpice([
2149
+ "config",
2150
+ "custom_apps",
2151
+ customAppId
2152
+ ]);
2153
+ runSpice(["apply"]);
2154
+ }
2155
+ logger$2.debug(pc.green(`${CHECK} Live reload custom-app injected successfully.`));
2156
+ } catch (err) {
2157
+ logger$2.error(pc.red(`${CROSS} Failed to inject HMR custom-app: ${err instanceof Error ? err.message : String(err)}`));
2158
+ }
2159
+ };
1666
2160
 
1667
2161
  //#endregion
1668
2162
  //#region src/dev/index.ts
@@ -1673,7 +2167,7 @@ async function dev$1(options) {
1673
2167
  logger$2.greeting(pc.green("Starting development environment"));
1674
2168
  let ctx;
1675
2169
  let server = void 0;
1676
- loadConfig(async (config, isNewUpdate) => {
2170
+ await loadConfig(async (config, isNewUpdate) => {
1677
2171
  if (isNewUpdate) {
1678
2172
  logger$2.clear();
1679
2173
  logger$2.info(pc.green("Config updated, reloading..."));
@@ -1685,11 +2179,9 @@ async function dev$1(options) {
1685
2179
  port: options.port ?? config.serverConfig.port
1686
2180
  });
1687
2181
  await server.start();
1688
- const outFiles = {
1689
- js: config.template === "extension" ? `${urlSlugify(config.name)}.js` : "theme.js",
1690
- css: config.template === "theme" ? "user.css" : null
1691
- };
1692
- await injectHMRExtension(server.link, server.wsLink, outFiles);
2182
+ const outFiles = getOutFiles(config, true);
2183
+ if (config.template === "custom-app") await injectHMRCustomApp(server.link, server.wsLink, outFiles, config);
2184
+ else await injectHMRExtension(server.link, server.wsLink, outFiles);
1693
2185
  ctx = await context(getJSDevOptions(config, {
1694
2186
  ...options,
1695
2187
  outFiles,
@@ -1701,21 +2193,14 @@ async function dev$1(options) {
1701
2193
  logger$2.error("Failed to start dev server: ", err);
1702
2194
  }
1703
2195
  return async () => {
1704
- try {
1705
- await ctx?.dispose();
1706
- ctx = void 0;
1707
- await server?.stop();
1708
- } finally {
1709
- process.exit();
1710
- }
2196
+ await ctx?.dispose();
2197
+ ctx = void 0;
2198
+ await server?.stop();
1711
2199
  };
1712
2200
  });
1713
2201
  }
1714
2202
  function getJSDevOptions(config, options) {
1715
- const entryPoints = (() => {
1716
- if (config.template === "theme") return [config.entry.js, config.entry.css];
1717
- return [config.entry];
1718
- })();
2203
+ const entryPoints = getEntryPoints(config);
1719
2204
  const minify = false;
1720
2205
  const cache = {
1721
2206
  files: /* @__PURE__ */ new Map(),
@@ -1734,7 +2219,7 @@ function getJSDevOptions(config, options) {
1734
2219
  ],
1735
2220
  define: {
1736
2221
  [DEV_MODE_VAR_NAME]: "true",
1737
- [config.devModeVarName]: "true",
2222
+ ...config.devModeVarName ? { [config.devModeVarName]: "true" } : {},
1738
2223
  ...config.esbuildOptions.define
1739
2224
  },
1740
2225
  plugins: [...config.esbuildOptions?.plugins ? config.esbuildOptions.plugins : [], ...getCommonPlugins({
@@ -1743,7 +2228,9 @@ function getJSDevOptions(config, options) {
1743
2228
  cache,
1744
2229
  buildOptions: {
1745
2230
  copy: true,
1746
- remove: true,
2231
+ apply: false,
2232
+ applyOnce: false,
2233
+ remove: config.template !== "custom-app",
1747
2234
  outDir
1748
2235
  },
1749
2236
  dev: true,
@@ -1765,12 +2252,17 @@ const dev = new Command("dev").description("Develop your spicetify project").opt
1765
2252
  await dev$1(safeParse(CLIOptionsSchema, opts));
1766
2253
  });
1767
2254
 
2255
+ //#endregion
2256
+ //#region src/commands/update-types.ts
2257
+ const update_types = new Command("update-types").description("Update Spicetify Types").action(async () => {
2258
+ await updateTypes();
2259
+ });
2260
+
1768
2261
  //#endregion
1769
2262
  //#region src/bin.ts
1770
2263
  logger$2.debug(`Env: ${JSON.stringify(env, null, 2)}\n`);
1771
2264
  const command = new Command();
1772
- create.alias("init");
1773
- command.addCommand(create).addCommand(build).addCommand(dev);
2265
+ command.version(version).addCommand(create.alias("init")).addCommand(build$1).addCommand(dev).addCommand(update_types);
1774
2266
  command.parse();
1775
2267
 
1776
2268
  //#endregion