@spicetify/kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/bin/spicetify-kit.js +4 -0
  2. package/dist/build.js +335 -0
  3. package/dist/check.js +284 -0
  4. package/dist/classmap.js +187 -0
  5. package/dist/cli.js +53 -0
  6. package/dist/create.js +429 -0
  7. package/dist/dev.js +100 -0
  8. package/dist/from-theme.js +96 -0
  9. package/dist/install.js +60 -0
  10. package/dist/launch.js +122 -0
  11. package/dist/pack.js +31 -0
  12. package/dist/push.js +211 -0
  13. package/dist/vault.js +108 -0
  14. package/package.json +49 -0
  15. package/vendor/classmaps/1020096/classmap-19f856aefd5.json +109 -0
  16. package/vendor/shims/chunks.d.ts +10 -0
  17. package/vendor/shims/remote-modules.d.ts +6 -0
  18. package/vendor/shims/spicetify.d.ts +2437 -0
  19. package/vendor/stdlib/deps.ts +24 -0
  20. package/vendor/stdlib/index.ts +24 -0
  21. package/vendor/stdlib/lib/modal.tsx +128 -0
  22. package/vendor/stdlib/lib/popover.ts +84 -0
  23. package/vendor/stdlib/lib/primitives-classes.ts +66 -0
  24. package/vendor/stdlib/lib/primitives-vanilla.ts +277 -0
  25. package/vendor/stdlib/lib/primitives.tsx +314 -0
  26. package/vendor/stdlib/load.ts +17 -0
  27. package/vendor/stdlib/metadata.json +18 -0
  28. package/vendor/stdlib/mixin.ts +32 -0
  29. package/vendor/stdlib/mod.ts +26 -0
  30. package/vendor/stdlib/src/chunks.d.ts +10 -0
  31. package/vendor/stdlib/src/client.ts +114 -0
  32. package/vendor/stdlib/src/createIconComponent.tsx +29 -0
  33. package/vendor/stdlib/src/events.mix.ts +55 -0
  34. package/vendor/stdlib/src/events.ts +58 -0
  35. package/vendor/stdlib/src/expose/GraphQL.ts +40 -0
  36. package/vendor/stdlib/src/expose/Platform.ts +48 -0
  37. package/vendor/stdlib/src/expose/React.ts +95 -0
  38. package/vendor/stdlib/src/expose/ReactFlipToolkitSpring.ts +27 -0
  39. package/vendor/stdlib/src/expose/ReduxStore.ts +39 -0
  40. package/vendor/stdlib/src/expose/SettingsSection.ts +60 -0
  41. package/vendor/stdlib/src/expose/Snackbar.ts +34 -0
  42. package/vendor/stdlib/src/expose/Tippy.ts +26 -0
  43. package/vendor/stdlib/src/expose/enqueueImageSnackbar.ts +25 -0
  44. package/vendor/stdlib/src/expose/index.ts +14 -0
  45. package/vendor/stdlib/src/expose/jsx-runtime.ts +46 -0
  46. package/vendor/stdlib/src/expose/react-dom-shim.ts +50 -0
  47. package/vendor/stdlib/src/expose/react-shim.ts +116 -0
  48. package/vendor/stdlib/src/logger.ts +35 -0
  49. package/vendor/stdlib/src/playbar-compat.tsx +136 -0
  50. package/vendor/stdlib/src/registers/index.ts +176 -0
  51. package/vendor/stdlib/src/registers/menu.ts +160 -0
  52. package/vendor/stdlib/src/registers/mount.ts +307 -0
  53. package/vendor/stdlib/src/registers/nativeAnchors.ts +49 -0
  54. package/vendor/stdlib/src/registers/navlink.tsx +165 -0
  55. package/vendor/stdlib/src/registers/order.ts +22 -0
  56. package/vendor/stdlib/src/registers/panel.ts +168 -0
  57. package/vendor/stdlib/src/registers/playbarButton.tsx +93 -0
  58. package/vendor/stdlib/src/registers/playbarWidget.tsx +71 -0
  59. package/vendor/stdlib/src/registers/registry.ts +12 -0
  60. package/vendor/stdlib/src/registers/root.ts +101 -0
  61. package/vendor/stdlib/src/registers/route.ts +123 -0
  62. package/vendor/stdlib/src/registers/settingsRow.tsx +56 -0
  63. package/vendor/stdlib/src/registers/settingsSection.ts +59 -0
  64. package/vendor/stdlib/src/registers/topbarLeftButton.tsx +86 -0
  65. package/vendor/stdlib/src/registers/topbarRightButton.tsx +87 -0
  66. package/vendor/stdlib/src/storage.ts +163 -0
  67. package/vendor/stdlib/src/util.ts +62 -0
  68. package/vendor/stdlib/src/utils/index.ts +16 -0
  69. package/vendor/stdlib/src/webpack/ClassNames.gen.ts +10 -0
  70. package/vendor/stdlib/src/webpack/ClassNames.ts +6 -0
  71. package/vendor/stdlib/src/webpack/ClassNames.xpui.ts +16 -0
  72. package/vendor/stdlib/src/webpack/ComponentLibrary.gen.ts +10 -0
  73. package/vendor/stdlib/src/webpack/ComponentLibrary.ts +6 -0
  74. package/vendor/stdlib/src/webpack/ComponentLibrary.xpui.ts +18 -0
  75. package/vendor/stdlib/src/webpack/FilterContext.gen.ts +10 -0
  76. package/vendor/stdlib/src/webpack/FilterContext.ts +6 -0
  77. package/vendor/stdlib/src/webpack/FilterContext.xpui.ts +10 -0
  78. package/vendor/stdlib/src/webpack/Mousetrap.gen.ts +10 -0
  79. package/vendor/stdlib/src/webpack/Mousetrap.ts +6 -0
  80. package/vendor/stdlib/src/webpack/Mousetrap.xpui.ts +12 -0
  81. package/vendor/stdlib/src/webpack/React.gen.ts +14 -0
  82. package/vendor/stdlib/src/webpack/React.ts +6 -0
  83. package/vendor/stdlib/src/webpack/React.xpui.ts +15 -0
  84. package/vendor/stdlib/src/webpack/ReactComponents.desktop.ts +17 -0
  85. package/vendor/stdlib/src/webpack/ReactComponents.gen.ts +74 -0
  86. package/vendor/stdlib/src/webpack/ReactComponents.panel.ts +19 -0
  87. package/vendor/stdlib/src/webpack/ReactComponents.ts +50 -0
  88. package/vendor/stdlib/src/webpack/ReactComponents.xpui.ts +164 -0
  89. package/vendor/stdlib/src/webpack/ReactFlipToolkit.gen.ts +12 -0
  90. package/vendor/stdlib/src/webpack/ReactFlipToolkit.ts +6 -0
  91. package/vendor/stdlib/src/webpack/ReactFlipToolkit.xpui.ts +14 -0
  92. package/vendor/stdlib/src/webpack/ReactHooks.fullscreen.ts +15 -0
  93. package/vendor/stdlib/src/webpack/ReactHooks.gen.ts +32 -0
  94. package/vendor/stdlib/src/webpack/ReactHooks.ts +6 -0
  95. package/vendor/stdlib/src/webpack/ReactHooks.xpui.ts +38 -0
  96. package/vendor/stdlib/src/webpack/ReactQuery.gen.ts +26 -0
  97. package/vendor/stdlib/src/webpack/ReactQuery.ts +6 -0
  98. package/vendor/stdlib/src/webpack/ReactQuery.xpui.ts +46 -0
  99. package/vendor/stdlib/src/webpack/ReactRouter.gen.ts +12 -0
  100. package/vendor/stdlib/src/webpack/ReactRouter.ts +6 -0
  101. package/vendor/stdlib/src/webpack/ReactRouter.xpui.ts +22 -0
  102. package/vendor/stdlib/src/webpack/Snackbar.gen.ts +12 -0
  103. package/vendor/stdlib/src/webpack/Snackbar.ts +6 -0
  104. package/vendor/stdlib/src/webpack/Snackbar.xpui.ts +27 -0
  105. package/vendor/stdlib/src/webpack/URI.gen.ts +26 -0
  106. package/vendor/stdlib/src/webpack/URI.ts +82 -0
  107. package/vendor/stdlib/src/webpack/URI.xpui.ts +111 -0
  108. package/vendor/stdlib/src/webpack/capture-readiness.ts +49 -0
  109. package/vendor/stdlib/src/webpack/index.ts +186 -0
  110. package/vendor/stdlib/src/webpack/misc.gen.ts +16 -0
  111. package/vendor/stdlib/src/webpack/misc.ts +6 -0
  112. package/vendor/stdlib/src/webpack/misc.xpui.ts +27 -0
  113. package/vendor/stdlib/src/wpunpk.mix.ts +104 -0
  114. package/vendor/stdlib/src/wpunpk.ts +36 -0
  115. package/vendor/stdlib/vendor/rxjs.d.ts +5 -0
package/dist/dev.js ADDED
@@ -0,0 +1,100 @@
1
+ /*
2
+ * Copyright (C) 2026 Afonso Jorge Ramos
3
+ * SPDX-License-Identifier: GPL-3.0-or-later
4
+ */
5
+ /**
6
+ * dev - hot-push a module into a running Spotify client.
7
+ *
8
+ * Watches the module's sources; on change it rebuilds and pushes the
9
+ * fresh bundle over the Chrome DevTools Protocol as a local install
10
+ * (Spicetify.Modules.installLocal), so nothing in the staged app bundle
11
+ * is touched and the loop is sub-second. Spotify must be running with
12
+ * --remote-debugging-port=<port>. Remove the override afterwards with
13
+ * Spicetify.Modules.removeLocal("<name>") (or from the manager page) to
14
+ * fall back to the staged copy.
15
+ */
16
+ import { watch } from "node:fs";
17
+ import path from "node:path";
18
+ import { buildModule, readMetadata, resolveModuleDir } from "./build.js";
19
+ import { loadConfig, resolveClassmap } from "./classmap.js";
20
+ import { launchSpotify } from "./launch.js";
21
+ import { formatPushResult, push, record } from "./push.js";
22
+ // Re-exported for callers that imported it from here before the push extraction.
23
+ export { formatPushResult } from "./push.js";
24
+ const USAGE = "spicetify-kit dev <module> [--launch] [--port 9229] [--once] [--classmap <key|path>] [--out <dir>]\n" +
25
+ " --launch start (or reuse) Spotify with the remote-debugging port itself";
26
+ export async function runDev(argv, cwd = process.cwd()) {
27
+ const moduleArg = argv.find((a) => !a.startsWith("--"));
28
+ const flag = (n) => {
29
+ const i = argv.indexOf(`--${n}`);
30
+ return i >= 0 ? argv[i + 1] : undefined;
31
+ };
32
+ if (!moduleArg)
33
+ throw new Error(USAGE);
34
+ const port = flag("port") ?? "9229";
35
+ const once = argv.includes("--once");
36
+ if (argv.includes("--launch"))
37
+ await launchSpotify(port);
38
+ const config = loadConfig(cwd);
39
+ const modulesDir = config.modulesDir ? path.resolve(cwd, config.modulesDir) : path.join(cwd, "modules");
40
+ const outDir = flag("out") ?? (config.outDir ? path.resolve(cwd, config.outDir) : path.join(cwd, "dist"));
41
+ const moduleDir = resolveModuleDir(moduleArg, modulesDir, cwd);
42
+ const id = readMetadata(moduleDir).name;
43
+ const resolved = await resolveClassmap({
44
+ flag: flag("classmap") ?? null,
45
+ config,
46
+ cwd,
47
+ refresh: argv.includes("--refresh"),
48
+ });
49
+ if (!resolved.path)
50
+ throw new Error("no classmap found (pass --classmap <key|path>)");
51
+ const cycle = async () => {
52
+ const started = Date.now();
53
+ let distDir;
54
+ try {
55
+ // Dev never blocks on standard findings: print them, push anyway.
56
+ distDir = await buildModule(moduleDir, outDir, resolved, cwd, { check: "warn" });
57
+ }
58
+ catch (e) {
59
+ console.error(`[dev] build failed: ${e.message}`);
60
+ return;
61
+ }
62
+ try {
63
+ const raw = await push(record(distDir, id), id, port);
64
+ const result = formatPushResult(raw);
65
+ const line = `[dev] ${id} ${result.message} (${Date.now() - started}ms)`;
66
+ if (result.ok)
67
+ console.log(line);
68
+ else
69
+ console.error(line);
70
+ }
71
+ catch (e) {
72
+ console.error(`[dev] push failed: ${e.message}`);
73
+ }
74
+ };
75
+ await cycle();
76
+ if (once)
77
+ return;
78
+ console.log(`[dev] watching ${moduleDir} (ctrl-c to stop; removeLocal("${id}") drops the override)`);
79
+ let timer;
80
+ let loggedDts = false;
81
+ watch(moduleDir, { recursive: true }, (_event, file) => {
82
+ if (!file)
83
+ return;
84
+ // The build regenerates classmap.d.ts into the source dir on every run;
85
+ // reacting to it would loop. Note the skip once so it is not a mystery.
86
+ if (file.endsWith(".d.ts")) {
87
+ if (!loggedDts) {
88
+ console.log("[dev] ignoring generated classmap.d.ts changes");
89
+ loggedDts = true;
90
+ }
91
+ return;
92
+ }
93
+ if (file.startsWith("."))
94
+ return;
95
+ clearTimeout(timer);
96
+ timer = setTimeout(() => void cycle(), 200);
97
+ });
98
+ // Keep the process alive while the watcher runs.
99
+ await new Promise(() => { });
100
+ }
@@ -0,0 +1,96 @@
1
+ /*
2
+ * Copyright (C) 2026 Afonso Jorge Ramos
3
+ * SPDX-License-Identifier: GPL-3.0-or-later
4
+ */
5
+ /**
6
+ * from-theme - migrate a classic spicetify theme (user.css + color.ini)
7
+ * into a v3 theme module.
8
+ *
9
+ * Theme modules are CSS-only modules: user.css becomes the css entry,
10
+ * color.ini ships alongside it and the loader applies the preferred
11
+ * scheme at load ([Section] names become switchable schemes). Previews
12
+ * are copied into assets/.
13
+ */
14
+ import { cpSync, existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
15
+ import path from "node:path";
16
+ const USAGE = 'spicetify-kit from-theme <classic-theme-dir> [--name <id>] [--author "..."] [--bare]';
17
+ export async function runFromTheme(argv, cwd = process.cwd()) {
18
+ const source = argv.find((a) => !a.startsWith("--"));
19
+ const flag = (n) => {
20
+ const i = argv.indexOf(`--${n}`);
21
+ return i >= 0 ? argv[i + 1] : undefined;
22
+ };
23
+ const bare = argv.includes("--bare");
24
+ if (!source)
25
+ throw new Error(USAGE);
26
+ const themeDir = path.resolve(cwd, source);
27
+ const userCss = path.join(themeDir, "user.css");
28
+ const colorIni = path.join(themeDir, "color.ini");
29
+ if (!existsSync(userCss) && !existsSync(colorIni)) {
30
+ throw new Error(`${themeDir} has neither user.css nor color.ini; not a classic theme`);
31
+ }
32
+ const name = flag("name") ??
33
+ path
34
+ .basename(themeDir)
35
+ .toLowerCase()
36
+ .replace(/[^a-z0-9-]+/g, "-")
37
+ .replace(/^-+|-+$/g, "");
38
+ if (!/^[a-z][a-z0-9-]*$/.test(name))
39
+ throw new Error(`derived name "${name}" is not kebab-case; pass --name`);
40
+ const author = flag("author") ?? "spicetify";
41
+ const dir = bare ? path.join(cwd, "modules", name) : path.join(cwd, name);
42
+ if (existsSync(dir))
43
+ throw new Error(`${dir} already exists`);
44
+ mkdirSync(dir, { recursive: true });
45
+ const hasCss = existsSync(userCss);
46
+ if (hasCss)
47
+ cpSync(userCss, path.join(dir, "index.css"));
48
+ if (existsSync(colorIni))
49
+ cpSync(colorIni, path.join(dir, "color.ini"));
50
+ const previews = readdirSync(themeDir)
51
+ .filter((f) => /\.(png|jpe?g|gif|webp)$/i.test(f))
52
+ .sort();
53
+ if (previews.length) {
54
+ mkdirSync(path.join(dir, "assets"), { recursive: true });
55
+ for (const p of previews)
56
+ cpSync(path.join(themeDir, p), path.join(dir, "assets", p));
57
+ }
58
+ writeFileSync(path.join(dir, "metadata.json"), `${JSON.stringify({
59
+ name,
60
+ tags: ["theme"],
61
+ version: "0.1.0",
62
+ authors: [author],
63
+ description: `${path.basename(themeDir)} theme, migrated from the classic format`,
64
+ ...(previews.length ? { preview: `./assets/${previews[0]}` } : {}),
65
+ entries: hasCss ? { css: "index.css" } : {},
66
+ hasMixins: false,
67
+ dependencies: {},
68
+ }, null, "\t")}\n`);
69
+ if (!bare) {
70
+ writeFileSync(path.join(dir, "package.json"), `${JSON.stringify({
71
+ name,
72
+ private: true,
73
+ type: "module",
74
+ scripts: {
75
+ build: "spicetify-kit build .",
76
+ dev: "spicetify-kit dev .",
77
+ },
78
+ devDependencies: {
79
+ "@spicetify/kit": "^0.1.0",
80
+ },
81
+ }, null, "\t")}\n`);
82
+ writeFileSync(path.join(dir, ".gitignore"), "node_modules/\ndist/\n");
83
+ }
84
+ const rel = path.relative(cwd, dir) || ".";
85
+ console.log(`created ${rel}/ from ${path.basename(themeDir)}`);
86
+ console.log("notes:");
87
+ console.log(" - classic user.css targets classic class names; selectors may need updating for current clients");
88
+ if (existsSync(colorIni)) {
89
+ console.log(" - color.ini sections become switchable schemes (Spicetify.Modules.schemes/setScheme)");
90
+ }
91
+ if (!bare) {
92
+ console.log("next steps:");
93
+ console.log(` cd ${rel} && npm install`);
94
+ console.log(" npm run dev # hot-push into a running client");
95
+ }
96
+ }
@@ -0,0 +1,60 @@
1
+ /*
2
+ * Copyright (C) 2026 Afonso Jorge Ramos
3
+ * SPDX-License-Identifier: GPL-3.0-or-later
4
+ */
5
+ /**
6
+ * install - sideload a packed module (or a dist dir) into a running client.
7
+ *
8
+ * One-shot CDP hot-push via Spicetify.Modules.installLocal, reusing the dev
9
+ * loop's push machinery. Nothing is written to the spicetify config folder.
10
+ */
11
+ import { execFileSync } from "node:child_process";
12
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import path from "node:path";
15
+ import { formatPushResult, push, record } from "./push.js";
16
+ const USAGE = "spicetify-kit install <zip|dist-dir> [--port 9229]\n (requires `unzip` on PATH for a .zip)";
17
+ function unzipTo(zipPath, dest) {
18
+ try {
19
+ execFileSync("unzip", ["-q", "-o", zipPath, "-d", dest], { stdio: "ignore" });
20
+ }
21
+ catch {
22
+ throw new Error("`unzip` is required to install a .zip but was not found on PATH");
23
+ }
24
+ }
25
+ export async function runInstall(argv, cwd = process.cwd()) {
26
+ const target = argv.find((a) => !a.startsWith("--"));
27
+ const flag = (n) => {
28
+ const i = argv.indexOf(`--${n}`);
29
+ return i >= 0 ? argv[i + 1] : undefined;
30
+ };
31
+ const port = flag("port") ?? "9229";
32
+ if (!target)
33
+ throw new Error(USAGE);
34
+ const abs = path.resolve(cwd, target);
35
+ if (!existsSync(abs))
36
+ throw new Error(`${target} not found`);
37
+ let distDir = abs;
38
+ let tmp = null;
39
+ if (statSync(abs).isFile() && abs.endsWith(".zip")) {
40
+ tmp = mkdtempSync(path.join(tmpdir(), "kit-install-"));
41
+ unzipTo(abs, tmp);
42
+ distDir = tmp;
43
+ }
44
+ try {
45
+ if (!existsSync(path.join(distDir, "metadata.json"))) {
46
+ throw new Error(`no metadata.json in ${target} (not a built/packed module)`);
47
+ }
48
+ const meta = JSON.parse(readFileSync(path.join(distDir, "metadata.json"), "utf8"));
49
+ const id = meta.name;
50
+ const raw = await push(record(distDir, id), id, port);
51
+ const result = formatPushResult(raw);
52
+ console.log(`[install] ${id} ${result.message}`);
53
+ if (!result.ok)
54
+ process.exitCode = 1;
55
+ }
56
+ finally {
57
+ if (tmp)
58
+ rmSync(tmp, { recursive: true, force: true });
59
+ }
60
+ }
package/dist/launch.js ADDED
@@ -0,0 +1,122 @@
1
+ /*
2
+ * Copyright (C) 2026 Afonso Jorge Ramos
3
+ * SPDX-License-Identifier: GPL-3.0-or-later
4
+ */
5
+ /**
6
+ * launch - bring up a debuggable Spotify client for the dev loop.
7
+ *
8
+ * Discovers the Spotify binary per platform (mirroring the CLI's
9
+ * restart.go), reuses an already-debuggable instance when one is running
10
+ * on the target port, otherwise kills the running client and spawns a
11
+ * fresh one with --remote-debugging-port. AppX (Microsoft Store) installs
12
+ * are unsupported: the kit cannot supply the spicetify-staged
13
+ * --app-directory without reading CLI config, so launching would yield an
14
+ * unpatched client where hot-push fails right after a "successful" launch.
15
+ */
16
+ import { execFileSync, spawn } from "node:child_process";
17
+ import { existsSync } from "node:fs";
18
+ import path from "node:path";
19
+ const APPX_MESSAGE = "Spotify is a Microsoft Store (AppX) install, which the kit cannot launch with a debug port: " +
20
+ "it needs the spicetify-staged --app-directory, which only the CLI knows. " +
21
+ "Start Spotify yourself with --remote-debugging-port, or run `spicetify-kit dev` without --launch.";
22
+ // which returns the resolved path of a command on PATH, or null.
23
+ function defaultWhich(cmd) {
24
+ try {
25
+ const out = execFileSync(process.platform === "win32" ? "where" : "which", [cmd], {
26
+ encoding: "utf8",
27
+ });
28
+ return out.split(/\r?\n/).find(Boolean) ?? null;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ // discoverSpotify resolves the launchable Spotify per platform. Pure over
35
+ // its injected probes so it can be unit-tested with a fixture path table.
36
+ export function discoverSpotify(platform = process.platform, env = process.env, exists = existsSync, which = defaultWhich) {
37
+ if (platform === "darwin") {
38
+ const app = "/Applications/Spotify.app";
39
+ if (exists(app))
40
+ return { kind: "macos", app };
41
+ throw new Error(`Spotify not found at ${app}. Install Spotify, or start it yourself with --remote-debugging-port and run dev without --launch.`);
42
+ }
43
+ if (platform === "win32") {
44
+ // win32.join so paths use backslashes on any host (matters for tests).
45
+ const exe = path.win32.join(env.APPDATA ?? "", "Spotify", "Spotify.exe");
46
+ if (exists(exe))
47
+ return { kind: "windows", exe };
48
+ const appx = path.win32.join(env.LOCALAPPDATA ?? "", "Microsoft", "WindowsApps", "Spotify.exe");
49
+ if (exists(appx))
50
+ return { kind: "appx" };
51
+ throw new Error(`Spotify.exe not found (looked in ${exe} and ${appx}). Install the standalone Spotify, or start it yourself with --remote-debugging-port.`);
52
+ }
53
+ const onPath = which("spotify");
54
+ if (onPath)
55
+ return { kind: "linux", exe: onPath };
56
+ throw new Error("'spotify' not found on PATH. Install Spotify, or start it yourself with --remote-debugging-port and run dev without --launch.");
57
+ }
58
+ // hasXpuiTarget reports whether a debuggable xpui page is already listening
59
+ // on the port, so an already-good client is reused rather than killed.
60
+ export async function hasXpuiTarget(port) {
61
+ try {
62
+ const res = await fetch(`http://localhost:${port}/json/list`);
63
+ if (!res.ok)
64
+ return false;
65
+ const targets = (await res.json());
66
+ return targets.some((t) => t.url?.includes("xpui"));
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
72
+ // waitForTarget polls until an xpui debug target appears or the timeout
73
+ // fires; the timeout error names the port and the flag so the remedy is
74
+ // obvious.
75
+ export async function waitForTarget(port, timeoutMs = 30_000) {
76
+ const deadline = Date.now() + timeoutMs;
77
+ while (Date.now() < deadline) {
78
+ if (await hasXpuiTarget(port))
79
+ return;
80
+ await new Promise((r) => setTimeout(r, 500));
81
+ }
82
+ throw new Error(`timed out waiting for a Spotify xpui debug target on port ${port}. ` +
83
+ `Confirm Spotify started with --remote-debugging-port=${port} and that spicetify apply has staged the v3 loader.`);
84
+ }
85
+ function killRunning(platform) {
86
+ try {
87
+ if (platform === "win32")
88
+ execFileSync("taskkill", ["/F", "/IM", "Spotify.exe"], { stdio: "ignore" });
89
+ else
90
+ execFileSync("pkill", ["-x", platform === "darwin" ? "Spotify" : "spotify"], { stdio: "ignore" });
91
+ }
92
+ catch {
93
+ // Not running is fine.
94
+ }
95
+ }
96
+ function spawnDetached(cmd, args) {
97
+ // Detached + unref'd so the client survives the kit process (matters on
98
+ // Linux/Windows; macOS `open` already returns immediately).
99
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
100
+ child.unref();
101
+ }
102
+ // launchSpotify reuses a good client, otherwise (re)starts one with the
103
+ // debug port and waits for its xpui target. Returns "reused" or "launched".
104
+ export async function launchSpotify(port, log = console.log, platform = process.platform) {
105
+ if (await hasXpuiTarget(port)) {
106
+ log(`[dev] reusing the Spotify client already debuggable on port ${port}`);
107
+ return "reused";
108
+ }
109
+ const target = discoverSpotify(platform);
110
+ if (target.kind === "appx")
111
+ throw new Error(APPX_MESSAGE);
112
+ log(`[dev] (re)starting Spotify with --remote-debugging-port=${port}`);
113
+ killRunning(platform);
114
+ await new Promise((r) => setTimeout(r, 1500));
115
+ const portFlag = `--remote-debugging-port=${port}`;
116
+ if (target.kind === "macos")
117
+ spawnDetached("open", ["-a", target.app, "--args", portFlag]);
118
+ else
119
+ spawnDetached(target.exe, [portFlag]);
120
+ await waitForTarget(port);
121
+ return "launched";
122
+ }
package/dist/pack.js ADDED
@@ -0,0 +1,31 @@
1
+ /*
2
+ * Copyright (C) 2026 Afonso Jorge Ramos
3
+ * SPDX-License-Identifier: GPL-3.0-or-later
4
+ */
5
+ /**
6
+ * pack - zip a built module into <name>@<version>.zip and print its
7
+ * sha256, ready to upload as a release artifact.
8
+ */
9
+ import { execFileSync } from "node:child_process";
10
+ import { createHash } from "node:crypto";
11
+ import { readFileSync, rmSync } from "node:fs";
12
+ import path from "node:path";
13
+ const USAGE = "spicetify-kit pack <dist-dir> [--out <dir>]";
14
+ export async function runPack(argv, cwd = process.cwd()) {
15
+ const distDir = argv.find((a) => !a.startsWith("--"));
16
+ const outIdx = argv.indexOf("--out");
17
+ if (!distDir)
18
+ throw new Error(USAGE);
19
+ const outDir = outIdx >= 0 ? argv[outIdx + 1] : ".";
20
+ const abs = path.resolve(cwd, distDir);
21
+ const meta = JSON.parse(readFileSync(path.join(abs, "metadata.json"), "utf8"));
22
+ if (!meta.name || !meta.version)
23
+ throw new Error(`${distDir}/metadata.json must set name and version`);
24
+ const zipPath = path.resolve(cwd, outDir, `${meta.name}@${meta.version}.zip`);
25
+ rmSync(zipPath, { force: true });
26
+ // Zip contents at the archive root (metadata.json at top level), the
27
+ // layout installLocal and the module installers expect.
28
+ execFileSync("zip", ["-qr", zipPath, "."], { cwd: abs });
29
+ const digest = createHash("sha256").update(readFileSync(zipPath)).digest("hex");
30
+ console.log(`${zipPath}\nsha256:${digest}`);
31
+ }
package/dist/push.js ADDED
@@ -0,0 +1,211 @@
1
+ /*
2
+ * Copyright (C) 2026 Afonso Jorge Ramos
3
+ * SPDX-License-Identifier: GPL-3.0-or-later
4
+ */
5
+ /**
6
+ * push - shared CDP hot-push machinery for the dev loop and install.
7
+ *
8
+ * Builds the LocalModuleRecord a dist dir installs as (metadata + files +
9
+ * sidecar), finds the client's xpui debug target, and runs
10
+ * Spicetify.Modules.installLocal in the client so nothing in the staged app
11
+ * bundle is touched.
12
+ */
13
+ import { readdirSync, readFileSync, statSync } from "node:fs";
14
+ import path from "node:path";
15
+ // localStorage quota is ~5 MB for the xpui origin, shared with Spotify's own
16
+ // keys and other local modules, so thresholds are conservative. Counted in
17
+ // UTF-16 code units (JSON.stringify(...).length), the unit the quota counts.
18
+ const WARN_BYTES = 4_000_000;
19
+ const ABORT_BYTES = 4_500_000;
20
+ export function estimateRecordSize(rec) {
21
+ return JSON.stringify(rec).length;
22
+ }
23
+ function largestFiles(rec, n = 3) {
24
+ return Object.entries(rec.files ?? {})
25
+ .map(([f, c]) => [f, c.length])
26
+ .sort((a, b) => b[1] - a[1])
27
+ .slice(0, n)
28
+ .map(([f, len]) => `${f} (~${Math.round(len / 1024)}KB)`)
29
+ .join(", ");
30
+ }
31
+ // checkQuota estimates the serialized install size before the socket opens:
32
+ // over the abort threshold it refuses with guidance, over the warn threshold
33
+ // it prints the size and the largest files.
34
+ export function checkQuota(rec, log = console.warn) {
35
+ const size = estimateRecordSize(rec);
36
+ if (size >= ABORT_BYTES) {
37
+ throw new Error(`install is ~${Math.round(size / 1024)}KB, over the ~${Math.round(ABORT_BYTES / 1024)}KB local-install ` +
38
+ `limit (localStorage is shared across the whole client). Largest: ${largestFiles(rec)}. ` +
39
+ "Sourcemaps and assets are already excluded — trim shipped chunks or split the module.");
40
+ }
41
+ if (size >= WARN_BYTES) {
42
+ log(`[push] warning: install is ~${Math.round(size / 1024)}KB, approaching the local-install limit. ` +
43
+ `Largest: ${largestFiles(rec)}`);
44
+ }
45
+ }
46
+ // interpretResult turns a CDP Runtime.evaluate response into a value or a
47
+ // named error. A client-side QuotaExceededError arrives via exceptionDetails
48
+ // (not the resolved value), so it is detected and translated here.
49
+ export function interpretResult(msg) {
50
+ const ex = msg.result?.exceptionDetails;
51
+ if (ex) {
52
+ const text = ex.exception?.description ?? ex.text ?? JSON.stringify(ex);
53
+ if (/quota/i.test(text)) {
54
+ return {
55
+ error: "client rejected the install: localStorage quota exceeded (shared across the whole client). " +
56
+ "Trim shipped chunks or split the module.",
57
+ };
58
+ }
59
+ return { error: `client evaluation error: ${text.slice(0, 200)}` };
60
+ }
61
+ return { value: msg.result?.result?.value ?? JSON.stringify(msg) };
62
+ }
63
+ // record builds the install payload from a dist dir. Maps and asset dirs stay
64
+ // out of localStorage; metadata rides separately (it is stamped with the id).
65
+ export function record(distDir, id) {
66
+ const metadata = JSON.parse(readFileSync(path.join(distDir, "metadata.json"), "utf8"));
67
+ metadata.identifier = id;
68
+ const sidecar = JSON.parse(readFileSync(path.join(distDir, "spicetify-module.json"), "utf8"));
69
+ const files = {};
70
+ for (const f of readdirSync(distDir)) {
71
+ if (f === "metadata.json" || f.endsWith(".map"))
72
+ continue;
73
+ if (statSync(path.join(distDir, f)).isDirectory())
74
+ continue;
75
+ files[f] = readFileSync(path.join(distDir, f), "utf8");
76
+ }
77
+ return { metadata, files, sidecar };
78
+ }
79
+ // stampRecord appends an execution stamp to the record's js entry. The push
80
+ // asserts the stamp after enable, turning "the client says loaded" into "the
81
+ // pushed code demonstrably ran". A loaded flag alone can be a stale instance:
82
+ // the dev loop once reported a build live whose code had never executed.
83
+ // Returns false when the record has no js entry to stamp (css-only themes).
84
+ export function stampRecord(rec, id, nonce) {
85
+ const entry = rec.metadata.entries?.js;
86
+ if (!entry || typeof rec.files[entry] !== "string")
87
+ return false;
88
+ rec.files[entry] +=
89
+ `\nglobalThis.__spicetifyPushStamps = Object.assign(globalThis.__spicetifyPushStamps ?? {}, ${JSON.stringify({ [id]: nonce })});\n`;
90
+ return true;
91
+ }
92
+ export function newNonce() {
93
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
94
+ }
95
+ export async function wsUrl(port) {
96
+ const res = await fetch(`http://localhost:${port}/json/list`);
97
+ const targets = (await res.json());
98
+ const target = targets.find((t) => t.url?.includes("xpui"));
99
+ if (!target?.webSocketDebuggerUrl) {
100
+ throw new Error(`no xpui debug target on port ${port}. Start Spotify with --remote-debugging-port=${port} ` +
101
+ "(or pass --launch), and ensure `spicetify apply` has staged the v3 loader.");
102
+ }
103
+ return target.webSocketDebuggerUrl;
104
+ }
105
+ export function push(rec, id, port) {
106
+ // Stamp before the quota check so the stamp's own bytes are counted.
107
+ const nonce = newNonce();
108
+ const stamped = stampRecord(rec, id, nonce);
109
+ // Refuse an oversized install before opening the socket (U7), so the failure
110
+ // is a named cause here rather than an opaque client-side quota error.
111
+ checkQuota(rec);
112
+ // The whole exchange runs in the client: disable the old instance, install
113
+ // the fresh content, and re-enable anything the unload cascade took down.
114
+ const expr = `(async () => {
115
+ const M = globalThis.Spicetify?.Modules;
116
+ if (!M) return JSON.stringify({ error: "loader not ready" });
117
+ const rec = ${JSON.stringify(rec)};
118
+ const id = ${JSON.stringify(id)};
119
+ const before = M.list().filter((m) => m.loaded).map((m) => m.identifier);
120
+ const hadPrevious = before.includes(id);
121
+ await M.disable(id).catch(() => {});
122
+ await M.installLocal(id, rec);
123
+ // Re-enabling a theme the loader just unloaded would fight the
124
+ // single-active-theme invariant and knock the pushed theme back off.
125
+ const pushedIsTheme = (rec.metadata.tags ?? []).includes("theme");
126
+ const isTheme = (mid) => ((M.manifest?.modules?.find((m) => m.identifier === mid)?.tags) ?? []).includes("theme");
127
+ for (const other of before) {
128
+ if (pushedIsTheme && isTheme(other)) continue;
129
+ const s = M.list().find((m) => m.identifier === other);
130
+ if (s && !s.loaded) await M.enable(other).catch(() => {});
131
+ }
132
+ const s = M.list().find((m) => m.identifier === id);
133
+ const stampLive = ${stamped ? `globalThis.__spicetifyPushStamps?.[id] === ${JSON.stringify(nonce)}` : "null"};
134
+ return JSON.stringify({
135
+ loaded: s?.loaded ?? false,
136
+ failed: M.report?.failed?.[id] ?? null,
137
+ stamp: ${stamped ? '(stampLive ? "live" : "stale")' : '"unstamped"'},
138
+ hadPrevious,
139
+ });
140
+ })()`;
141
+ return new Promise((resolve, reject) => {
142
+ void wsUrl(port).then((url) => {
143
+ const ws = new WebSocket(url);
144
+ const timer = setTimeout(() => {
145
+ ws.close();
146
+ reject(new Error("push timed out"));
147
+ }, 15_000);
148
+ ws.addEventListener("error", (e) => {
149
+ clearTimeout(timer);
150
+ reject(new Error(`websocket error: ${String(e.message ?? e)}`));
151
+ });
152
+ ws.addEventListener("open", () => {
153
+ ws.send(JSON.stringify({
154
+ id: 1,
155
+ method: "Runtime.evaluate",
156
+ params: { expression: expr, awaitPromise: true, returnByValue: true },
157
+ }));
158
+ });
159
+ ws.addEventListener("message", (ev) => {
160
+ const msg = JSON.parse(String(ev.data));
161
+ if (msg.id !== 1)
162
+ return;
163
+ clearTimeout(timer);
164
+ ws.close();
165
+ const outcome = interpretResult(msg);
166
+ if ("error" in outcome)
167
+ reject(new Error(outcome.error));
168
+ else
169
+ resolve(outcome.value);
170
+ });
171
+ }, reject);
172
+ });
173
+ }
174
+ // Turn the raw client-side push result into an honest, actionable line.
175
+ export function formatPushResult(raw) {
176
+ let parsed;
177
+ try {
178
+ parsed = JSON.parse(raw);
179
+ }
180
+ catch {
181
+ return { ok: false, message: `unexpected push result (not JSON): ${raw}` };
182
+ }
183
+ if (parsed.error === "loader not ready") {
184
+ return {
185
+ ok: false,
186
+ message: "the v3 loader is not staged in this client (Spicetify.Modules is absent). " +
187
+ "Run `spicetify apply` with v3 modules installed, then retry.",
188
+ };
189
+ }
190
+ if (parsed.failed)
191
+ return { ok: false, message: `module loaded but failed: ${parsed.failed}` };
192
+ if (parsed.loaded !== true)
193
+ return { ok: false, message: "installed but not loaded" };
194
+ // The loaded flag alone is not proof the pushed code runs; the stamp is.
195
+ if (parsed.stamp === "stale") {
196
+ return {
197
+ ok: false,
198
+ message: "installed, but the pushed code did NOT execute — a stale instance is still live. " +
199
+ "Restart the client (or removeLocal, then push again) before trusting any verification.",
200
+ };
201
+ }
202
+ const remount = parsed.hadPrevious
203
+ ? " — UI mounted before the push may still be the old build; re-navigate to its surface to remount"
204
+ : "";
205
+ if (parsed.stamp === "live")
206
+ return { ok: true, message: `loaded, pushed build verified executing${remount}` };
207
+ // css-only records carry no executable entry to stamp.
208
+ if (parsed.stamp === "unstamped")
209
+ return { ok: true, message: `loaded (css-only, no execution stamp)${remount}` };
210
+ return { ok: true, message: "loaded" };
211
+ }