@spicemod/creator 0.0.21 → 0.0.23

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 (70) hide show
  1. package/client.d.ts +47 -0
  2. package/dist/bin.mjs +682 -242
  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 +23 -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 +9 -0
  20. package/dist/templates/custom-app/shared/spice.config.ts +9 -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 +23 -0
  24. package/dist/templates/custom-app/ts/react/src/components/Onboarding.tsx +105 -0
  25. package/dist/templates/custom-app/ts/react/src/extension/index.tsx +27 -0
  26. package/dist/templates/extension/js/vanilla/src/components/Onboarding.js +71 -0
  27. package/dist/templates/extension/shared/DOT-gitignore +34 -0
  28. package/dist/templates/extension/shared/DOT-oxlintrc.json +36 -0
  29. package/dist/templates/extension/shared/spice.config.js +1 -1
  30. package/dist/templates/extension/shared/spice.config.ts +1 -1
  31. package/dist/templates/liveReload.js +0 -1
  32. package/dist/templates/theme/shared/DOT-gitignore +34 -0
  33. package/dist/templates/theme/shared/DOT-oxlintrc.json +36 -0
  34. package/dist/templates/theme/shared/spice.config.js +1 -1
  35. package/dist/templates/theme/shared/spice.config.ts +1 -1
  36. package/dist/templates/wrapper.js +6 -9
  37. package/package.json +7 -3
  38. package/templates/custom-app/js/react/eslint.config.ts +29 -0
  39. package/templates/custom-app/js/react/src/app.jsx +22 -0
  40. package/templates/custom-app/js/react/src/components/Onboarding.jsx +82 -0
  41. package/templates/custom-app/js/react/src/extension/index.jsx +23 -0
  42. package/templates/custom-app/meta.json +4 -0
  43. package/templates/custom-app/shared/DOT-gitignore +34 -0
  44. package/templates/custom-app/shared/DOT-oxlintrc.json +36 -0
  45. package/templates/custom-app/shared/README.template.md +53 -0
  46. package/templates/custom-app/shared/app.css +163 -0
  47. package/templates/custom-app/shared/biome.json +36 -0
  48. package/templates/custom-app/shared/css/app.module.scss +58 -0
  49. package/templates/custom-app/shared/icon-active.svg +7 -0
  50. package/templates/custom-app/shared/icon.svg +7 -0
  51. package/templates/custom-app/shared/jsconfig.json +32 -0
  52. package/templates/custom-app/shared/spice.config.js +9 -0
  53. package/templates/custom-app/shared/spice.config.ts +9 -0
  54. package/templates/custom-app/shared/tsconfig.json +32 -0
  55. package/templates/custom-app/ts/react/eslint.config.ts +29 -0
  56. package/templates/custom-app/ts/react/src/app.tsx +23 -0
  57. package/templates/custom-app/ts/react/src/components/Onboarding.tsx +105 -0
  58. package/templates/custom-app/ts/react/src/extension/index.tsx +27 -0
  59. package/templates/extension/js/vanilla/src/components/Onboarding.js +71 -0
  60. package/templates/extension/shared/DOT-gitignore +34 -0
  61. package/templates/extension/shared/DOT-oxlintrc.json +36 -0
  62. package/templates/extension/shared/spice.config.js +1 -1
  63. package/templates/extension/shared/spice.config.ts +1 -1
  64. package/templates/liveReload.js +0 -1
  65. package/templates/theme/shared/DOT-gitignore +34 -0
  66. package/templates/theme/shared/DOT-oxlintrc.json +36 -0
  67. package/templates/theme/shared/spice.config.js +1 -1
  68. package/templates/theme/shared/spice.config.ts +1 -1
  69. package/templates/wrapper.js +6 -9
  70. 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,7 +10,7 @@ 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
16
  import { gzipSync } from "node:zlib";
@@ -22,8 +22,11 @@ import postcssImport from "postcss-import";
22
22
  import postcssPresetEnv from "postcss-preset-env";
23
23
  import { readFile, writeFile } from "node:fs/promises";
24
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 } 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 templateCustomAppWrapperFilePath = dist("templates/customAppWrapper.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.21";
146
+ var version = "0.0.23";
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";
@@ -291,6 +248,7 @@ const DEV_MODE_VAR_NAME = `__SPICE_CREATOR_DEV__`;
291
248
  const CHECK = pc.bold(pc.green("✔"));
292
249
  const CROSS = pc.bold(pc.red("✖"));
293
250
  const WARN = pc.bold(pc.yellow("⚠"));
251
+ const SKIP_SPICETIFY = process.env.SPICETIFY_SKIP === "true" || process.env.CI === "true";
294
252
  const VALID_PROJECT_FILES = new Set([
295
253
  ".DS_Store",
296
254
  ".git",
@@ -313,6 +271,150 @@ const VALID_PROJECT_FILES = new Set([
313
271
  "yarnrc.yml",
314
272
  ".yarn"
315
273
  ]);
274
+ const CUSTOM_APP_NAME_LOCALES = [
275
+ "ms",
276
+ "gu",
277
+ "ko",
278
+ "pa-IN",
279
+ "az",
280
+ "ru",
281
+ "uk",
282
+ "nb",
283
+ "sv",
284
+ "sw",
285
+ "ur",
286
+ "bho",
287
+ "pa-PK",
288
+ "te",
289
+ "ro",
290
+ "vi",
291
+ "am",
292
+ "bn",
293
+ "en",
294
+ "id",
295
+ "bg",
296
+ "da",
297
+ "es-419",
298
+ "mr",
299
+ "ml",
300
+ "th",
301
+ "tr",
302
+ "is",
303
+ "fa",
304
+ "or",
305
+ "he",
306
+ "hi",
307
+ "zh-TW",
308
+ "sr",
309
+ "pt-BR",
310
+ "zu",
311
+ "nl",
312
+ "es",
313
+ "lt",
314
+ "ja",
315
+ "st",
316
+ "it",
317
+ "el",
318
+ "pt-PT",
319
+ "kn",
320
+ "de",
321
+ "fr",
322
+ "ne",
323
+ "ar",
324
+ "af",
325
+ "et",
326
+ "pl",
327
+ "ta",
328
+ "sl",
329
+ "pk",
330
+ "hr",
331
+ "sk",
332
+ "fi",
333
+ "lv",
334
+ "fil",
335
+ "fr-CA",
336
+ "cs",
337
+ "zh-CN",
338
+ "hu"
339
+ ];
340
+
341
+ //#endregion
342
+ //#region src/config/schema.ts
343
+ const ServerConfigSchema = v.object({
344
+ port: v.optional(v.number()),
345
+ serveDir: v.string(),
346
+ hmrPath: v.optional(v.string())
347
+ });
348
+ const EntryFileSchema = v.string();
349
+ const AssetEntrySchema = v.object({
350
+ js: EntryFileSchema,
351
+ css: EntryFileSchema
352
+ });
353
+ const CustomAppEntrySchema = v.object({
354
+ extension: EntryFileSchema,
355
+ app: EntryFileSchema
356
+ });
357
+ const LocaleNameSchema = v.intersect([v.object({ en: v.string() }), v.record(v.picklist(CUSTOM_APP_NAME_LOCALES), v.string())]);
358
+ const ExtensionTemplateSchema = v.object({
359
+ name: v.string(),
360
+ template: v.literal("extension"),
361
+ entry: EntryFileSchema
362
+ });
363
+ const ThemeTemplateSchema = v.object({
364
+ name: v.string(),
365
+ template: v.literal("theme"),
366
+ entry: AssetEntrySchema
367
+ });
368
+ const CustomAppTemplateSchema = v.object({
369
+ name: v.union([v.string(), LocaleNameSchema]),
370
+ icon: v.object({
371
+ default: v.string(),
372
+ active: v.optional(v.string())
373
+ }),
374
+ template: v.literal("custom-app"),
375
+ entry: CustomAppEntrySchema
376
+ });
377
+ const TemplateSpecificSchema = v.variant("template", [
378
+ ExtensionTemplateSchema,
379
+ ThemeTemplateSchema,
380
+ CustomAppTemplateSchema
381
+ ]);
382
+ const TemplateSpecificOptionalSchema = v.variant("template", [
383
+ v.partial(ExtensionTemplateSchema),
384
+ v.partial(ThemeTemplateSchema),
385
+ v.partial(CustomAppTemplateSchema)
386
+ ]);
387
+ const RequiredCommonSchema = v.object({
388
+ outDir: v.string(),
389
+ linter: v.picklist(linterTypes),
390
+ framework: v.picklist(frameworkTypes),
391
+ packageManager: v.picklist(packageManagers),
392
+ esbuildOptions: v.record(v.string(), v.any()),
393
+ serverConfig: v.partial(ServerConfigSchema),
394
+ version: v.string()
395
+ });
396
+ const OptionalCommonSchema = v.object({ devModeVarName: v.optional(v.string()) });
397
+ const FileOptionsSchema = v.intersect([
398
+ v.partial(RequiredCommonSchema),
399
+ OptionalCommonSchema,
400
+ TemplateSpecificOptionalSchema
401
+ ]);
402
+ const OptionsSchema$1 = v.intersect([
403
+ v.required(RequiredCommonSchema),
404
+ OptionalCommonSchema,
405
+ TemplateSpecificSchema
406
+ ]);
407
+
408
+ //#endregion
409
+ //#region src/env.ts
410
+ const isInternal = process.env.SPICE_INTERNAL === "true";
411
+ const isDev = process.env.IS_DEV === "true";
412
+ const spicetifyBin = process.env.SPICETIFY_BIN || process.env.SPICE_BIN || "spicetify";
413
+ const env = {
414
+ isInternal,
415
+ isDev,
416
+ spicetifyBin
417
+ };
316
418
 
317
419
  //#endregion
318
420
  //#region src/utils/logger.ts
@@ -350,8 +452,15 @@ var Logger = class {
350
452
  this.add(console.warn, args);
351
453
  }
352
454
  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")));
455
+ const formatted = [];
456
+ for (const err of errors) if (err instanceof Error) {
457
+ formatted.push(pc.red(err.message));
458
+ if (this.isDev && err.stack) {
459
+ const stack = err.stack.split("\n").slice(1).join("\n");
460
+ formatted.push(pc.dim(stack));
461
+ }
462
+ } else formatted.push(err);
463
+ this.add(console.error, formatted);
355
464
  }
356
465
  log(...args) {
357
466
  if (!this.isDev) return;
@@ -384,21 +493,39 @@ function safeParse(schema, data, type = "CLI") {
384
493
  process.exit(1);
385
494
  }
386
495
 
496
+ //#endregion
497
+ //#region src/config/globs.ts
498
+ const JS_EXTENSIONS = "{ts,tsx,js,jsx,mts,mjs,cts,cjs}";
499
+ const CSS_EXTENSIONS = "{css,scss,sass,less,styl,stylus,pcss,postcss}";
500
+ const withSrc = (dirs) => dirs.flatMap((dir) => [dir, `src/${dir}`]);
501
+ const createGlobs = (names, dirs, ext) => names.flatMap((name) => dirs.map((dir) => `${dir}${name}.${ext}`));
502
+ const JS_ENTRY_GLOBS = createGlobs(["app", "index"], withSrc([""]), JS_EXTENSIONS);
503
+ const JS_EXTENSION_ENTRY_GLOBS = createGlobs(["app", "index"], withSrc(["extension/"]), JS_EXTENSIONS);
504
+ const CSS_ENTRY_GLOBS = createGlobs(["app"], withSrc([
505
+ "",
506
+ "styles/",
507
+ "css/"
508
+ ]), CSS_EXTENSIONS);
509
+ const ICON_GLOBS = createGlobs(["icon", "logo"], withSrc([
510
+ "",
511
+ "icons/",
512
+ "assets/"
513
+ ]), "svg");
514
+ const ICON_ACTIVE_GLOBS = createGlobs(["icon-active", "logo-active"], withSrc([
515
+ "",
516
+ "icons/",
517
+ "assets/"
518
+ ]), "svg");
519
+ const ENTRY_MAP = {
520
+ js: [...JS_ENTRY_GLOBS, ...JS_EXTENSION_ENTRY_GLOBS],
521
+ css: CSS_ENTRY_GLOBS,
522
+ "js-app-only": JS_ENTRY_GLOBS,
523
+ "js-extension-only": JS_EXTENSION_ENTRY_GLOBS
524
+ };
525
+
387
526
  //#endregion
388
527
  //#region src/config/index.ts
389
528
  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
- ];
402
529
  const CONFIG_DEFAULTS = {
403
530
  outDir: "./dist",
404
531
  linter: "biome",
@@ -435,45 +562,77 @@ async function getResolvedConfig(config) {
435
562
  }
436
563
  async function resolveContext(config) {
437
564
  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");
565
+ const pkg = config.name && config.version ? {} : getPackageMeta(cwd);
566
+ config.name ||= pkg.name || basename(cwd);
567
+ config.version ||= pkg.version || "0.0.1";
568
+ config.entry ||= resolveConfigEntries(config.template, cwd);
457
569
  config.outDir = resolve(cwd, config.outDir || "./dist");
458
570
  config.esbuildOptions ??= {};
459
571
  config.serverConfig ??= {};
572
+ if (config.template === "custom-app") config.icon ??= {
573
+ default: resolveDefaultIcon(cwd),
574
+ active: resolveActiveIcon(cwd)
575
+ };
460
576
  return config;
461
577
  }
462
- function resolveDefaultEntries(cwd, type) {
463
- const resolveFile = (globs) => {
464
- for (const glob of globs) {
465
- const matches = globSync(glob, {
466
- cwd,
467
- absolute: true
468
- });
469
- if (matches.length > 0) return matches;
470
- }
471
- return [];
578
+ function getPackageMeta(cwd) {
579
+ try {
580
+ return JSON.parse(readFileSync(resolve(cwd, "package.json"), "utf-8"));
581
+ } catch {
582
+ return {};
583
+ }
584
+ }
585
+ function resolveConfigEntries(template, cwd) {
586
+ if (template === "theme") return {
587
+ js: resolveDefaultEntry(cwd, "js"),
588
+ css: resolveDefaultEntry(cwd, "css")
589
+ };
590
+ if (template === "custom-app") return {
591
+ app: resolveDefaultEntry(cwd, "js-app-only"),
592
+ extension: resolveDefaultEntry(cwd, "js-extension-only")
472
593
  };
473
- const firstEntry = resolveFile(type === "js" ? JS_ENTRY_GLOBS : CSS_ENTRY_GLOBS)[0];
474
- 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).");
475
- return firstEntry;
594
+ return resolveDefaultEntry(cwd, "js");
476
595
  }
596
+ function resolveDefaultEntry(cwd, type) {
597
+ const globs = ENTRY_MAP[type];
598
+ for (const glob of globs) {
599
+ const matches = globSync(glob, {
600
+ cwd,
601
+ absolute: true
602
+ });
603
+ if (matches.length > 0 && matches[0]) return matches[0];
604
+ }
605
+ const displayType = {
606
+ js: "JavaScript/TypeScript",
607
+ css: "CSS/Stylesheet",
608
+ "js-app-only": "JavaScript/TypeScript (Custom App)",
609
+ "js-extension-only": "JavaScript/TypeScript (Extension)"
610
+ }[type];
611
+ const expectedFiles = globs.map((g) => ` - ${g}`).join("\n");
612
+ throw new Error(`No ${displayType} entry file found in your project.\nPlease create one of the following files:\n${expectedFiles}`);
613
+ }
614
+ function resolveDefaultIcon(cwd) {
615
+ for (const glob of ICON_GLOBS) {
616
+ const matches = globSync(glob, {
617
+ cwd,
618
+ absolute: true
619
+ });
620
+ if (matches.length > 0 && matches[0]) return readFileSync(matches[0]).toString();
621
+ }
622
+ const expectedFiles = ICON_GLOBS.map((g) => ` - ${g}`).join("\n");
623
+ throw new Error(`No icon file found in your project.\nPlease create one of the following files:\n${expectedFiles}`);
624
+ }
625
+ function resolveActiveIcon(cwd) {
626
+ for (const glob of ICON_ACTIVE_GLOBS) {
627
+ const matches = globSync(glob, {
628
+ cwd,
629
+ absolute: true
630
+ });
631
+ if (matches.length > 0 && matches[0]) return readFileSync(matches[0]).toString();
632
+ }
633
+ return "";
634
+ }
635
+ const getEnName = (configName) => typeof configName === "string" ? configName : configName.en;
477
636
 
478
637
  //#endregion
479
638
  //#region src/esbuild/format.ts
@@ -576,14 +735,14 @@ function css({ minify = false, inline = false, logger = createLogger("plugin:css
576
735
  type,
577
736
  transform: postcssModules({
578
737
  getJSON: () => {},
579
- generateScopedName: "[name]__[local]___[hash:base64:5]",
580
- localsConvention: "camelCaseOnly"
738
+ generateScopedName: "[name]__[local]___[hash:base64:5]"
581
739
  }, postCssPlugins)
582
740
  }), sassPlugin({
583
741
  filter: /\.(s[ac]ss|css)$/,
584
742
  type,
585
743
  async transform(css, _resolveDir, filePath) {
586
744
  const start = performance.now();
745
+ logger.log("processing:", filePath, "type:", type);
587
746
  const result = await postcss(postCssPlugins).process(css, { from: filePath });
588
747
  logger.debug("Global CSS processed", {
589
748
  filePath,
@@ -643,6 +802,7 @@ function runSpice(args) {
643
802
  validateSpicetify(env.spicetifyBin);
644
803
  return spawnSync(env.spicetifyBin, args, { encoding: "utf-8" });
645
804
  }
805
+ const getCustomAppsDir = () => join(getSpiceDataPath(), "CustomApps");
646
806
  const getExtensionDir = () => join(getSpiceDataPath(), "Extensions");
647
807
  const getThemesDir = () => join(getSpiceDataPath(), "Themes");
648
808
  async function getSpicetifyConfig() {
@@ -666,16 +826,16 @@ function validateSpicetify(bin) {
666
826
 
667
827
  //#endregion
668
828
  //#region src/esbuild/plugins/spicetifyHandlers.ts
669
- const skipSpicetify = process.env.SPICETIFY_SKIP === "true" || process.env.CI === "true";
670
- const spicetifyHandler = ({ config, options, cache, logger = createLogger("plugin:spicetifyHandler") }) => ({
829
+ const spicetifyHandler = ({ config, options, cache, logger = createLogger("plugin:spicetify-handler") }) => ({
671
830
  name: "spice_internal__spicetify-build-handler",
672
831
  async setup(build) {
673
832
  const { apply = true, copy = true, applyOnce = true, remove, outDir = "./dist" } = options;
674
833
  let hasAppliedOnce = false;
675
834
  const isExtension = config.template === "extension";
676
- const identifier = isExtension ? `${urlSlugify(config.name)}.js` : urlSlugify(config.name);
677
- if (skipSpicetify) {
678
- logger.info(pc.yellow("SPICETIFY_SKIP=true, skipping spicetify operations"));
835
+ const isCustomApp = config.template === "custom-app";
836
+ const identifier = isExtension ? `${urlSlugify(config.name)}.js` : urlSlugify(getEnName(config.name));
837
+ if (SKIP_SPICETIFY) {
838
+ logger.info(pc.yellow("skipping spicetify operations"));
679
839
  build.onEnd(async (result) => {
680
840
  if (result.errors.length > 0) return;
681
841
  if (!cache.hasChanges || cache.changed.size === 0) return;
@@ -701,38 +861,36 @@ const spicetifyHandler = ({ config, options, cache, logger = createLogger("plugi
701
861
  const spiceConfig = await getSpicetifyConfig();
702
862
  logger.debug(pc.green("Spicetify Config: "), spiceConfig);
703
863
  if (apply) {
704
- const defaultTheme = spiceConfig.Setting.current_theme;
705
- build.onStart(() => {
706
- const spiceIdentifier = remove ? `${identifier}-` : identifier;
707
- if (isExtension) runSpice([
708
- "config",
709
- "extensions",
710
- spiceIdentifier
711
- ]);
712
- else runSpice([
713
- "config",
714
- "current_theme",
715
- spiceIdentifier
716
- ]);
717
- });
864
+ const defaultTheme = spiceConfig?.Setting?.current_theme || "SpicetifyDefault";
865
+ const spiceIdentifier = remove ? `${identifier}-` : identifier;
866
+ runSpice([
867
+ "config",
868
+ isExtension ? "extensions" : isCustomApp ? "custom_apps" : "current_theme",
869
+ spiceIdentifier
870
+ ]);
718
871
  if (!isExtension && !remove) {
719
- const resetTheme = () => {
720
- runSpice([
872
+ const cleanup = () => {
873
+ if (isCustomApp) runSpice([
874
+ "config",
875
+ "custom_apps",
876
+ `${identifier}-`
877
+ ]);
878
+ else runSpice([
721
879
  "config",
722
880
  "current_theme",
723
881
  defaultTheme
724
882
  ]);
725
883
  process.exit();
726
884
  };
727
- process.once("SIGINT", resetTheme);
728
- process.once("SIGTERM", resetTheme);
885
+ process.once("SIGINT", cleanup);
886
+ process.once("SIGTERM", cleanup);
729
887
  }
730
888
  }
731
889
  build.onEnd(async (result) => {
732
890
  if (result.errors.length > 0) return;
733
891
  if (!cache.hasChanges || cache.changed.size === 0) return;
734
892
  const destDirs = [resolve(outDir)];
735
- if (copy) destDirs.push(isExtension ? getExtensionDir() : resolve(getThemesDir(), identifier));
893
+ if (copy) destDirs.push(isExtension ? getExtensionDir() : isCustomApp ? resolve(getCustomAppsDir(), identifier) : resolve(getThemesDir(), identifier));
736
894
  const tasks = [];
737
895
  for (const filePath of cache.changed) {
738
896
  const fileData = cache.files.get(filePath);
@@ -753,8 +911,8 @@ const spicetifyHandler = ({ config, options, cache, logger = createLogger("plugi
753
911
  return;
754
912
  }
755
913
  if (apply && cache.hasChanges && (!applyOnce || !hasAppliedOnce)) {
756
- const { stderr, status } = runSpice(["apply"]);
757
- if (status !== 0) logger.error(pc.red(`${CROSS} Spicetify apply failed: ${stderr}`));
914
+ const { stdout, stderr, status } = runSpice(["apply"]);
915
+ if (status !== 0) logger.error(pc.red(`${CROSS} Spicetify apply failed:`), stdout, stderr);
758
916
  else hasAppliedOnce = true;
759
917
  }
760
918
  });
@@ -763,26 +921,88 @@ const spicetifyHandler = ({ config, options, cache, logger = createLogger("plugi
763
921
 
764
922
  //#endregion
765
923
  //#region src/esbuild/plugins/wrapWithLoader.ts
766
- function wrapWithLoader({ name, type, version, cache, outFiles, server, dev = false, logger = createLogger("plugin:wrapWithLoader") }) {
924
+ function wrapWithLoader({ config, cache, outFiles, server, dev = false, logger = createLogger("plugin:wrapper") }) {
767
925
  const namespace = "spice_internal__wrap-with-loader";
926
+ const name = typeof config.name === "string" ? config.name : config.name.en;
927
+ const { template: type, version } = config;
928
+ let previousManifestHash;
768
929
  return {
769
930
  name: namespace,
770
- setup(build) {
771
- if (build.initialOptions.write !== false) throw new Error(`[${namespace}] This plugin requires "write: false" in build options.`);
772
- build.onEnd(async (res) => {
931
+ setup(build$3) {
932
+ if (build$3.initialOptions.write !== false) throw new Error(`[${namespace}] This plugin requires "write: false" in build options.`);
933
+ build$3.onEnd(async (res) => {
773
934
  try {
774
935
  if (res.errors.length > 0 || !res.outputFiles) return;
775
936
  cache.changed.clear();
776
937
  cache.hasChanges = false;
777
938
  const filesChanged = [];
778
- let bundledCss = "";
779
- if (!dev && type === "extension") bundledCss = res.outputFiles.filter((f) => f.path.endsWith(".css")).map((f) => f.text).join("");
939
+ const outdir = resolve(build$3.initialOptions.outdir || "./dist");
940
+ const bundledCss = getBundledCss(res.outputFiles, outdir, type, dev);
941
+ const minify = build$3.initialOptions.minify;
942
+ const slug = varSlugify(`${name}_${version}`);
780
943
  const transformPromises = res.outputFiles.map(async (file) => {
781
944
  const isJs = file.path.endsWith(".js");
782
945
  const isCss = file.path.endsWith(".css");
783
946
  if (!dev && isCss && type === "extension") return;
784
- const targetName = isJs ? outFiles.js : isCss ? outFiles.css ?? basename(file.path) : basename(file.path);
785
- const renamedPath = join(build.initialOptions.outdir || "./dist/", targetName);
947
+ const relPath = file.path.slice(outdir.length);
948
+ const isCustomAppExtension = type === "custom-app" && isExtensionDir(relPath);
949
+ let targetName;
950
+ if (isJs) targetName = isCustomAppExtension ? outFiles.jsExtension ?? "extension.js" : outFiles.js;
951
+ else if (isCss && !isCustomAppExtension) targetName = outFiles.css;
952
+ if (!targetName) {
953
+ logger.debug("Skipped file: ", file.path);
954
+ return;
955
+ }
956
+ const renamedPath = join(build$3.initialOptions.outdir || "./dist/", targetName);
957
+ if (type === "custom-app" && targetName === outFiles.js && isJs) {
958
+ const globalName = varSlugify(getEnName(config.name));
959
+ const final = (await build({
960
+ bundle: true,
961
+ write: false,
962
+ minify,
963
+ platform: "browser",
964
+ format: "iife",
965
+ globalName,
966
+ stdin: {
967
+ contents: `
968
+ import App from "virtual:app";
969
+ import React from "react";
970
+
971
+ export default function render() {
972
+ return <App />;
973
+ }
974
+ `,
975
+ loader: "tsx",
976
+ sourcefile: "entry.tsx"
977
+ },
978
+ plugins: [plugins.externalGlobal({ react: "Spicetify.React" }), {
979
+ name: "virtual-modules",
980
+ setup(vBuild) {
981
+ vBuild.onResolve({ filter: /^virtual:app$/ }, () => ({
982
+ path: "app",
983
+ namespace: "virtual"
984
+ }));
985
+ vBuild.onLoad({
986
+ filter: /.*/,
987
+ namespace: "virtual"
988
+ }, () => ({
989
+ contents: file.text,
990
+ loader: "js"
991
+ }));
992
+ }
993
+ }]
994
+ })).outputFiles?.[0];
995
+ if (!final) return;
996
+ let combinedCode = `${final.text}${dev ? `export default () => ${globalName}.default();` : `var render = () => ${globalName}.default();`}\n`;
997
+ cache.files.set(renamedPath, {
998
+ contents: Buffer.from(combinedCode),
999
+ name: targetName
1000
+ });
1001
+ cache.changed.add(renamedPath);
1002
+ cache.hasChanges = true;
1003
+ filesChanged.push(renamedPath);
1004
+ return;
1005
+ }
786
1006
  if (!isJs) {
787
1007
  cache.files.set(renamedPath, {
788
1008
  name: targetName,
@@ -793,31 +1013,25 @@ function wrapWithLoader({ name, type, version, cache, outFiles, server, dev = fa
793
1013
  filesChanged.push(renamedPath);
794
1014
  return;
795
1015
  }
796
- const slug = varSlugify(`${name}_${version}`);
797
- const templateRaw = readFileSync(templateFilePath, "utf-8");
798
- const minify = build.initialOptions.minify;
799
- const { code: transformedTemp } = await transform(templateRaw, {
1016
+ const { code: transformedTemp } = await transform(readFileSync(templateWrapperFilePath, "utf-8"), {
800
1017
  minify,
801
- target: build.initialOptions.target || "es2020",
1018
+ target: build$3.initialOptions.target || "es2020",
802
1019
  loader: "jsx",
803
1020
  define: {
804
- __ESBUILD__HAS_CSS: JSON.stringify(type === "extension"),
1021
+ __ESBUILD__HAS_CSS: JSON.stringify(type !== "theme"),
1022
+ __ESBUILD__INJECTED_CSS: JSON.stringify(bundledCss),
805
1023
  __ESBUILD__APP_SLUG: JSON.stringify(slug),
806
1024
  __ESBUILD__APP_TYPE: JSON.stringify(type),
807
1025
  __ESBUILD__APP_ID: JSON.stringify(varSlugify(name)),
808
- __ESBUILD__APP_VERSION: JSON.stringify(version),
809
- __ESBUILD__APP_HASH: JSON.stringify("")
1026
+ __ESBUILD__APP_VERSION: JSON.stringify(version)
810
1027
  }
811
1028
  });
812
1029
  const template = replace(transformedTemp, {
813
1030
  "\"{{INJECT_START_COMMENT}}\"": minify ? "" : "/* --- START --- */",
814
1031
  "\"{{INJECT_END_COMMENT}}\"": minify ? "" : "/* --- END --- */",
815
- "{{INJECTED_CSS_HERE}}": bundledCss,
816
1032
  "\"{{INJECTED_JS_HERE}}\"": file.text
817
1033
  });
818
1034
  const nextBuffer = Buffer.from(template);
819
- const previous = cache.files.get(renamedPath);
820
- if (previous?.contents && Buffer.compare(previous.contents, nextBuffer) === 0) return;
821
1035
  cache.files.set(renamedPath, {
822
1036
  name: targetName,
823
1037
  contents: nextBuffer
@@ -827,6 +1041,29 @@ function wrapWithLoader({ name, type, version, cache, outFiles, server, dev = fa
827
1041
  filesChanged.push(renamedPath);
828
1042
  });
829
1043
  await Promise.all(transformPromises);
1044
+ if (type === "custom-app") {
1045
+ const icon = config.icon;
1046
+ const manifestPath = join(outdir, "manifest.json");
1047
+ const manifest = {
1048
+ name: config.name,
1049
+ subfiles: [],
1050
+ subfiles_extension: ["extension.js"],
1051
+ icon: icon.default,
1052
+ "active-icon": icon.active ?? ""
1053
+ };
1054
+ const manifestString = JSON.stringify(manifest, null, 2);
1055
+ const currentHash = createHash("md5").update(manifestString).digest("hex");
1056
+ if (currentHash !== previousManifestHash) {
1057
+ previousManifestHash = currentHash;
1058
+ cache.files.set(manifestPath, {
1059
+ contents: Buffer.from(manifestString),
1060
+ name: "manifest.json"
1061
+ });
1062
+ cache.changed.add(manifestPath);
1063
+ cache.hasChanges = true;
1064
+ filesChanged.push(manifestPath);
1065
+ }
1066
+ }
830
1067
  if (filesChanged.length > 0) server?.broadcast(filesChanged);
831
1068
  } catch (e) {
832
1069
  logger.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
@@ -835,6 +1072,15 @@ function wrapWithLoader({ name, type, version, cache, outFiles, server, dev = fa
835
1072
  }
836
1073
  };
837
1074
  }
1075
+ function isExtensionDir(relPath) {
1076
+ return relPath.startsWith("/extension/") || relPath.startsWith("\\extension\\");
1077
+ }
1078
+ function getBundledCss(files, outdir, type, dev) {
1079
+ const cssFiles = files.filter((f) => f.path.endsWith(".css"));
1080
+ if (!dev && type === "extension") return cssFiles.map((f) => f.text).join("");
1081
+ if (type === "custom-app") return cssFiles.filter((f) => isExtensionDir(f.path.slice(outdir.length))).map((f) => f.text).join("");
1082
+ return "";
1083
+ }
838
1084
 
839
1085
  //#endregion
840
1086
  //#region src/esbuild/plugins/index.ts
@@ -860,7 +1106,7 @@ const defaultBuildOptions = {
860
1106
  target: ["es2022", "chrome120"]
861
1107
  };
862
1108
  const getCommonPlugins = (opts) => {
863
- const { template, minify, cache, name, version, buildOptions, outFiles, server, dev } = opts;
1109
+ const { template, minify, cache, buildOptions, outFiles, server, dev } = opts;
864
1110
  return [
865
1111
  ...plugins.css({
866
1112
  minify,
@@ -874,9 +1120,7 @@ const getCommonPlugins = (opts) => {
874
1120
  "react/jsx-runtime": "Spicetify.ReactJSX"
875
1121
  }),
876
1122
  plugins.wrapWithLoader({
877
- name,
878
- version,
879
- type: template,
1123
+ config: opts,
880
1124
  cache,
881
1125
  outFiles,
882
1126
  server,
@@ -890,11 +1134,32 @@ const getCommonPlugins = (opts) => {
890
1134
  plugins.buildLogger({ cache })
891
1135
  ];
892
1136
  };
1137
+ function getEntryPoints(config) {
1138
+ if (config.template === "theme") return [config.entry.js, config.entry.css];
1139
+ if (config.template === "custom-app") return [config.entry.app, config.entry.extension];
1140
+ return [config.entry];
1141
+ }
1142
+ function getOutFiles(config) {
1143
+ switch (config.template) {
1144
+ case "custom-app": return {
1145
+ js: "index.js",
1146
+ css: "style.css",
1147
+ jsExtension: "extension.js",
1148
+ manifest: "manifest.json"
1149
+ };
1150
+ case "extension": return { js: `${urlSlugify(getEnName(config.name))}.js` };
1151
+ case "theme": return {
1152
+ js: "theme.js",
1153
+ css: "user.css"
1154
+ };
1155
+ default: throw new Error("Unknown template");
1156
+ }
1157
+ }
893
1158
 
894
1159
  //#endregion
895
1160
  //#region src/build/index.ts
896
1161
  const logger = createLogger("build");
897
- async function build$1(options) {
1162
+ async function build$2(options) {
898
1163
  logger.clear();
899
1164
  logger.greeting(pc.green("Building for production..."));
900
1165
  let ctx;
@@ -921,10 +1186,7 @@ async function build$1(options) {
921
1186
  });
922
1187
  }
923
1188
  function getJSBuildOptions(config, options) {
924
- const entryPoints = (() => {
925
- if (config.template === "theme") return [config.entry.js, config.entry.css];
926
- return [config.entry];
927
- })();
1189
+ const entryPoints = getEntryPoints(config);
928
1190
  const minify = options.watch ? false : options.minify;
929
1191
  const outDir = resolve(config.outDir);
930
1192
  const cache = {
@@ -932,14 +1194,13 @@ function getJSBuildOptions(config, options) {
932
1194
  changed: /* @__PURE__ */ new Set(),
933
1195
  hasChanges: true
934
1196
  };
935
- const outFiles = {
936
- js: config.template === "extension" ? `${urlSlugify(config.name)}.js` : "theme.js",
937
- css: config.template === "theme" ? "user.css" : null
938
- };
1197
+ const outFiles = getOutFiles(config);
939
1198
  const overrides = {
940
1199
  ...defaultBuildOptions,
941
1200
  outdir: outDir,
1201
+ format: "esm",
942
1202
  minify,
1203
+ globalName: varSlugify(getEnName(config.name)),
943
1204
  sourcemap: false,
944
1205
  external: [
945
1206
  ...config.esbuildOptions?.external ? config.esbuildOptions.external : [],
@@ -980,8 +1241,8 @@ const CLIOptionsSchema$1 = v.strictObject({
980
1241
  apply: v.boolean(),
981
1242
  copy: v.boolean()
982
1243
  });
983
- 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) => {
984
- await build$1(safeParse(CLIOptionsSchema$1, opts));
1244
+ 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) => {
1245
+ await build$2(safeParse(CLIOptionsSchema$1, opts));
985
1246
  });
986
1247
 
987
1248
  //#endregion
@@ -1039,7 +1300,8 @@ function createPackageJSON(options) {
1039
1300
  scripts: {
1040
1301
  sc: "spicetify-creator",
1041
1302
  dev: "spicetify-creator dev",
1042
- build: "spicetify-creator build"
1303
+ build: "spicetify-creator build",
1304
+ "update-types": "spicetify-creator update-types"
1043
1305
  },
1044
1306
  dependencies: {},
1045
1307
  devDependencies: { "@spicetify/creator": env.isInternal ? "link:@spicetify/creator" : "latest" }
@@ -1084,9 +1346,10 @@ function validateProjectName(name) {
1084
1346
 
1085
1347
  //#endregion
1086
1348
  //#region src/create/template.ts
1087
- const ext = (lang) => lang === "ts" ? "ts" : "js";
1349
+ const ext = (lang, jsx = false) => lang === "ts" ? `ts${jsx ? "x" : ""}` : `js${jsx ? "x" : ""}`;
1088
1350
  const kv = ({ name, language, framework, linter, packageManager, template }) => ({
1089
1351
  "{{project-name}}": name,
1352
+ "{{project-url}}": `/${urlSlugify(name)}`,
1090
1353
  "{{framework}}": framework,
1091
1354
  "{{linter}}": linter,
1092
1355
  "{{package-manager}}": packageManager,
@@ -1103,36 +1366,60 @@ const kv = ({ name, language, framework, linter, packageManager, template }) =>
1103
1366
  const action = { modify(c, opts) {
1104
1367
  return replace(c, kv(opts));
1105
1368
  } };
1106
- const COMMON_FILES = (opts) => [
1107
- {
1108
- from: "README.template.md",
1109
- to: "README.md",
1110
- action,
1111
- isShared: true
1112
- },
1113
- {
1114
- from: ".gitignore",
1115
- to: ".gitignore",
1116
- isShared: true
1117
- },
1118
- {
1119
- from: `spice.config.${ext(opts.language)}`,
1120
- to: `spice.config.${ext(opts.language)}`,
1121
- action,
1122
- isShared: true
1123
- },
1124
- {
1369
+ const SHARED_FILES = (opts) => {
1370
+ const isShared = true;
1371
+ const files = [
1372
+ {
1373
+ from: "README.template.md",
1374
+ to: "README.md",
1375
+ action,
1376
+ isShared
1377
+ },
1378
+ {
1379
+ from: "DOT-gitignore",
1380
+ to: ".gitignore",
1381
+ isShared: true
1382
+ },
1383
+ {
1384
+ from: `spice.config.${ext(opts.language)}`,
1385
+ to: `spice.config.${ext(opts.language)}`,
1386
+ action,
1387
+ isShared
1388
+ }
1389
+ ];
1390
+ if (opts.template === "custom-app") {
1391
+ files.push({
1392
+ from: `css/app.module.scss`,
1393
+ to: `src/css/app.module.scss`,
1394
+ action,
1395
+ isShared
1396
+ });
1397
+ files.push({
1398
+ from: `icon.svg`,
1399
+ to: `src/icon.svg`,
1400
+ action,
1401
+ isShared
1402
+ });
1403
+ files.push({
1404
+ from: `icon-active.svg`,
1405
+ to: `src/icon-active.svg`,
1406
+ action,
1407
+ isShared
1408
+ });
1409
+ files.push({
1410
+ from: "app.css",
1411
+ to: "src/extension/app.css",
1412
+ action,
1413
+ isShared
1414
+ });
1415
+ } else files.push({
1125
1416
  from: "app.css",
1126
1417
  to: "src/app.css",
1127
1418
  action,
1128
- isShared: true
1129
- },
1130
- ...opts.language === "ts" ? [{
1131
- from: "css.d.ts",
1132
- to: "src/types/css.d.ts",
1133
- isShared: true
1134
- }] : []
1135
- ];
1419
+ isShared
1420
+ });
1421
+ return files;
1422
+ };
1136
1423
  const LANGUAGE_FILES = {
1137
1424
  js: [{
1138
1425
  from: "jsconfig.json",
@@ -1146,24 +1433,36 @@ const LANGUAGE_FILES = {
1146
1433
  }]
1147
1434
  };
1148
1435
  const FRAMEWORKS = {
1149
- react: ({ language }) => [{
1150
- from: `src/app.${ext(language)}x`,
1151
- to: `src/app.${ext(language)}x`,
1152
- action
1153
- }, {
1154
- from: `src/components/Onboarding.${ext(language)}x`,
1155
- to: `src/components/Onboarding.${ext(language)}x`,
1156
- action
1157
- }],
1158
- vanilla: ({ language }) => [{
1159
- from: `src/app.${ext(language)}`,
1160
- to: `src/app.${ext(language)}`,
1161
- action
1162
- }, {
1163
- from: `src/components/Onboarding.${ext(language)}`,
1164
- to: `src/components/Onboarding.${ext(language)}`,
1165
- action
1166
- }]
1436
+ react: ({ language, template }) => {
1437
+ const react = [{
1438
+ from: `src/app.${ext(language, true)}`,
1439
+ to: `src/app.${ext(language, true)}`,
1440
+ action
1441
+ }, {
1442
+ from: `src/components/Onboarding.${ext(language, true)}`,
1443
+ to: `src/components/Onboarding.${ext(language, true)}`,
1444
+ action
1445
+ }];
1446
+ if (template === "custom-app") react.push({
1447
+ from: `src/extension/index.${ext(language, true)}`,
1448
+ to: `src/extension/index.${ext(language, true)}`,
1449
+ action
1450
+ });
1451
+ return react;
1452
+ },
1453
+ vanilla: ({ language, template }) => {
1454
+ const vanilla = [{
1455
+ from: `src/app.${ext(language)}`,
1456
+ to: `src/app.${ext(language)}`,
1457
+ action
1458
+ }, {
1459
+ from: `src/components/Onboarding.${ext(language)}`,
1460
+ to: `src/components/Onboarding.${ext(language)}`,
1461
+ action
1462
+ }];
1463
+ if (template === "custom-app") throw new Error("vanilla doesn't exist for custom-app");
1464
+ return vanilla;
1465
+ }
1167
1466
  };
1168
1467
  const LINTERS = {
1169
1468
  biome: [{
@@ -1176,23 +1475,25 @@ const LINTERS = {
1176
1475
  to: `eslint.config.${ext(language)}`
1177
1476
  }],
1178
1477
  oxlint: [{
1179
- from: ".oxlintrc.json",
1478
+ from: "DOT-oxlintrc.json",
1180
1479
  to: ".oxlintrc.json",
1181
1480
  isShared: true
1182
1481
  }]
1183
1482
  };
1483
+ const getFiles = (options) => {
1484
+ const resolve = (slice) => typeof slice === "function" ? slice(options) : slice ?? [];
1485
+ return [
1486
+ ...resolve(SHARED_FILES),
1487
+ ...resolve(LANGUAGE_FILES[options.language]),
1488
+ ...resolve(FRAMEWORKS[options.framework]),
1489
+ ...resolve(LINTERS[options.linter])
1490
+ ];
1491
+ };
1184
1492
  function setupTemplateFiles(options, targetDir) {
1185
- const { template, language, framework, linter } = options;
1493
+ const { template, language, framework } = options;
1186
1494
  const templateRoot = dist(`templates/${template}`, import.meta.url);
1187
1495
  const fromDir = join(templateRoot, language, framework);
1188
- const resolve = (slice) => typeof slice === "function" ? slice(options) : slice ?? [];
1189
- const files = [
1190
- ...resolve(COMMON_FILES),
1191
- ...resolve(LANGUAGE_FILES[language]),
1192
- ...resolve(FRAMEWORKS[framework]),
1193
- ...resolve(LINTERS[linter])
1194
- ];
1195
- for (const file of files) {
1496
+ for (const file of getFiles(options)) {
1196
1497
  const src = (() => {
1197
1498
  if (file.isGlobal) return join(templateRoot, file.from);
1198
1499
  if (file.isShared) return join(templateRoot, "shared", file.from);
@@ -1290,6 +1591,35 @@ function tryGitInit(root) {
1290
1591
  }
1291
1592
  }
1292
1593
 
1594
+ //#endregion
1595
+ //#region src/utils/update-types.ts
1596
+ const downloads = { spicetify: {
1597
+ from: "https://raw.githubusercontent.com/spicetify/cli/main/globals.d.ts",
1598
+ to: "./src/types/spicetify.d.ts",
1599
+ 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\");")
1600
+ } };
1601
+ async function updateTypes(isUpdating = true) {
1602
+ const s = spinner();
1603
+ s.start(`${isUpdating ? "Updating" : "Creating"} Types...`);
1604
+ await Promise.all(Object.entries(downloads).map(([name, download]) => downloadFile(name, download, isUpdating)));
1605
+ s.stop(`${isUpdating ? "Updated" : "Created"} Types!`);
1606
+ }
1607
+ async function downloadFile(name, { from, to, action }, isUpdating) {
1608
+ try {
1609
+ const res = await fetch(from);
1610
+ if (!res.ok) throw new Error(`HTTP Error: ${res.status} ${res.statusText}`);
1611
+ let text = await res.text();
1612
+ if (action) text = await action(text);
1613
+ await mkdir(dirname$1(to), { recursive: true });
1614
+ await writeFile$1(to, text, "utf8");
1615
+ const actionLog = isUpdating ? "updated" : "created";
1616
+ logger$2.log(`${name}.d.ts ${actionLog} (${from} -> ${to})`);
1617
+ } catch (e) {
1618
+ log.error(`${name} failed`);
1619
+ console.error(e);
1620
+ }
1621
+ }
1622
+
1293
1623
  //#endregion
1294
1624
  //#region src/create/index.ts
1295
1625
  const isOnline = await getOnline();
@@ -1336,7 +1666,8 @@ async function createProject(cwd, options) {
1336
1666
  options: templateOptions
1337
1667
  });
1338
1668
  },
1339
- framework: async () => {
1669
+ framework: async ({ results: { template } }) => {
1670
+ if (template === "custom-app") return "react";
1340
1671
  if (options.framework) return options.framework;
1341
1672
  return await p.select({
1342
1673
  message: "Select which framework you want to chose",
@@ -1406,6 +1737,7 @@ async function create$1(cwd, options) {
1406
1737
  try {
1407
1738
  mkdirp(cwd);
1408
1739
  setupTemplateFiles(options, cwd);
1740
+ await updateTypes(false);
1409
1741
  const pkgJSON = createPackageJSON(options);
1410
1742
  writePackageJSON(pkgJSON, cwd);
1411
1743
  chdir(cwd);
@@ -1616,7 +1948,7 @@ const injectHMRExtension = async (rootLink, wsLink, outFiles) => {
1616
1948
  const extName = `sc-live-reload-helper.js`;
1617
1949
  const spiceConfig = await getSpicetifyConfig();
1618
1950
  const cleanup = () => {
1619
- if (env.isDev) logger$2.log(`[Spicetify] Removing Live reload extension...`);
1951
+ logger$2.info(`Removing Live reload extension...`);
1620
1952
  try {
1621
1953
  runSpice([
1622
1954
  "config",
@@ -1624,18 +1956,16 @@ const injectHMRExtension = async (rootLink, wsLink, outFiles) => {
1624
1956
  `${extName}-`
1625
1957
  ]);
1626
1958
  runSpice(["apply"]);
1627
- logger$2.debug(pc.green(`${CHECK} Cleanup successful.`));
1959
+ logger$2.info(pc.green(`${CHECK} Cleanup successful.`));
1628
1960
  } catch (e) {
1629
- if (env.isDev) logger$2.error(pc.red(`${CROSS} Cleanup failed: `), e);
1961
+ logger$2.error(pc.red(`${CROSS} Cleanup failed: `), e);
1630
1962
  }
1631
1963
  process.exit();
1632
1964
  };
1633
- if (env.isDev) {
1634
- process.on("SIGINT", cleanup);
1635
- process.on("SIGTERM", cleanup);
1636
- }
1965
+ process.on("SIGINT", cleanup);
1966
+ process.on("SIGTERM", cleanup);
1637
1967
  try {
1638
- logger$2.debug(`[Spicetify] Preparing Live reload extension...`);
1968
+ logger$2.debug(`Preparing Live reload extension...`);
1639
1969
  const destDir = getExtensionDir();
1640
1970
  mkdirp(destDir);
1641
1971
  const outDir = resolve(destDir, extName);
@@ -1662,6 +1992,114 @@ const injectHMRExtension = async (rootLink, wsLink, outFiles) => {
1662
1992
  logger$2.error(pc.red(`${CROSS} Failed to inject HMR helper: ${err instanceof Error ? err.message : String(err)}`));
1663
1993
  }
1664
1994
  };
1995
+ const injectHMRCustomApp = async (rootLink, wsLink, outFiles, config) => {
1996
+ if (config.template !== "custom-app") throw new Error("only supports custom-app templates");
1997
+ const identifier = getEnName(config.name);
1998
+ const spiceConfig = await getSpicetifyConfig();
1999
+ const customAppId = urlSlugify(identifier);
2000
+ runSpice([
2001
+ "config",
2002
+ "extensions",
2003
+ "sc-live-reload-helper.js-"
2004
+ ]);
2005
+ const cleanup = () => {
2006
+ logger$2.info(`Removing Live reload custom-app...`);
2007
+ try {
2008
+ runSpice([
2009
+ "config",
2010
+ "custom_apps",
2011
+ `${customAppId}-`
2012
+ ]);
2013
+ runSpice(["apply"]);
2014
+ logger$2.info(pc.green(`${CHECK} Cleanup successful.`));
2015
+ } catch (e) {
2016
+ logger$2.error(pc.red(`${CROSS} Cleanup failed: `), e);
2017
+ }
2018
+ process.exit();
2019
+ };
2020
+ process.on("SIGINT", cleanup);
2021
+ process.on("SIGTERM", cleanup);
2022
+ try {
2023
+ logger$2.debug(`Preparing Live reload custom-app...`);
2024
+ const destDir = resolve(getCustomAppsDir(), customAppId);
2025
+ mkdirp(destDir);
2026
+ const indexJsPath = resolve(destDir, "index.js");
2027
+ const extensionJsPath = resolve(destDir, outFiles.jsExtension ?? "extension.js");
2028
+ writeFileSync(indexJsPath, `const React = Spicetify.React;
2029
+ const waitForImport = () => {
2030
+ return import("${rootLink}/files/${outFiles.js}")
2031
+ .then((mod) => mod.default || mod.render)
2032
+ .catch((err) => {
2033
+ console.error("Failed to import app:", err);
2034
+ return null;
2035
+ });
2036
+ };
2037
+
2038
+ const AppWrapper = ({ appPromise }) => {
2039
+ const [App, setApp] = React.useState(null);
2040
+
2041
+ React.useEffect(() => {
2042
+ let mounted = true;
2043
+
2044
+ appPromise.then((app) => {
2045
+ if (mounted && app) {
2046
+ setApp(() => app);
2047
+ }
2048
+ });
2049
+
2050
+ return () => {
2051
+ mounted = false;
2052
+ };
2053
+ }, [appPromise]);
2054
+
2055
+ if (!App) {
2056
+ return React.createElement(
2057
+ "div",
2058
+ { className: "loading" },
2059
+ "Loading app..."
2060
+ );
2061
+ }
2062
+
2063
+ return React.createElement(App);
2064
+ };
2065
+
2066
+ const render = () => {
2067
+ const appPromise = waitForImport();
2068
+ return React.createElement(AppWrapper, { appPromise });
2069
+ };
2070
+ `);
2071
+ const { code: extensionCode } = await transform(readFileSync(liveReloadFilePath, "utf8"), {
2072
+ loader: "js",
2073
+ define: {
2074
+ _SERVER_URL: JSON.stringify(rootLink),
2075
+ _HOT_RELOAD_LINK: JSON.stringify(wsLink),
2076
+ _JS_PATH: JSON.stringify(`/files/${outFiles.jsExtension ?? "extension.js"}`),
2077
+ _CSS_PATH: JSON.stringify(outFiles.css ? `/files/${outFiles.css}` : `/files/app.css`)
2078
+ }
2079
+ });
2080
+ writeFileSync(extensionJsPath, extensionCode);
2081
+ const manifestPath = resolve(destDir, "manifest.json");
2082
+ const manifest = {
2083
+ name: identifier,
2084
+ subfiles: [],
2085
+ subfiles_extension: [outFiles.jsExtension ?? "extension.js"],
2086
+ icon: config.icon.default,
2087
+ "active-icon": config.icon.active ?? config.icon.default
2088
+ };
2089
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
2090
+ if (spiceConfig) {
2091
+ runSpice([
2092
+ "config",
2093
+ "custom_apps",
2094
+ customAppId
2095
+ ]);
2096
+ runSpice(["apply"]);
2097
+ }
2098
+ logger$2.debug(pc.green(`${CHECK} Live reload custom-app injected successfully.`));
2099
+ } catch (err) {
2100
+ logger$2.error(pc.red(`${CROSS} Failed to inject HMR custom-app: ${err instanceof Error ? err.message : String(err)}`));
2101
+ }
2102
+ };
1665
2103
 
1666
2104
  //#endregion
1667
2105
  //#region src/dev/index.ts
@@ -1684,11 +2122,9 @@ async function dev$1(options) {
1684
2122
  port: options.port ?? config.serverConfig.port
1685
2123
  });
1686
2124
  await server.start();
1687
- const outFiles = {
1688
- js: config.template === "extension" ? `${urlSlugify(config.name)}.js` : "theme.js",
1689
- css: config.template === "theme" ? "user.css" : null
1690
- };
1691
- await injectHMRExtension(server.link, server.wsLink, outFiles);
2125
+ const outFiles = getOutFiles(config);
2126
+ if (config.template === "custom-app") await injectHMRCustomApp(server.link, server.wsLink, outFiles, config);
2127
+ else await injectHMRExtension(server.link, server.wsLink, outFiles);
1692
2128
  ctx = await context(getJSDevOptions(config, {
1693
2129
  ...options,
1694
2130
  outFiles,
@@ -1711,10 +2147,7 @@ async function dev$1(options) {
1711
2147
  });
1712
2148
  }
1713
2149
  function getJSDevOptions(config, options) {
1714
- const entryPoints = (() => {
1715
- if (config.template === "theme") return [config.entry.js, config.entry.css];
1716
- return [config.entry];
1717
- })();
2150
+ const entryPoints = getEntryPoints(config);
1718
2151
  const minify = false;
1719
2152
  const cache = {
1720
2153
  files: /* @__PURE__ */ new Map(),
@@ -1742,7 +2175,9 @@ function getJSDevOptions(config, options) {
1742
2175
  cache,
1743
2176
  buildOptions: {
1744
2177
  copy: true,
1745
- remove: true,
2178
+ apply: false,
2179
+ applyOnce: false,
2180
+ remove: config.template !== "custom-app",
1746
2181
  outDir
1747
2182
  },
1748
2183
  dev: true,
@@ -1764,12 +2199,17 @@ const dev = new Command("dev").description("Develop your spicetify project").opt
1764
2199
  await dev$1(safeParse(CLIOptionsSchema, opts));
1765
2200
  });
1766
2201
 
2202
+ //#endregion
2203
+ //#region src/commands/update-types.ts
2204
+ const update_types = new Command("update-types").description("Update Spicetify Types").action(async () => {
2205
+ await updateTypes();
2206
+ });
2207
+
1767
2208
  //#endregion
1768
2209
  //#region src/bin.ts
1769
2210
  logger$2.debug(`Env: ${JSON.stringify(env, null, 2)}\n`);
1770
2211
  const command = new Command();
1771
- create.alias("init");
1772
- command.addCommand(create).addCommand(build).addCommand(dev);
2212
+ command.addCommand(create.alias("init")).addCommand(build$1).addCommand(dev).addCommand(update_types);
1773
2213
  command.parse();
1774
2214
 
1775
2215
  //#endregion