@sriinnu/harmon 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.
- package/LICENSE +661 -0
- package/README.md +70 -0
- package/SKILL.md +37 -0
- package/dist/bin/harmon.js +1924 -0
- package/dist/index.d.ts +126 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +255 -0
- package/dist/index.js.map +7 -0
- package/logo.svg +16 -0
- package/package.json +53 -0
|
@@ -0,0 +1,1924 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
// bin/harmon.js
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
7
|
+
import { createInterface } from "node:readline";
|
|
8
|
+
import fs from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
|
+
|
|
12
|
+
// dist/index.js
|
|
13
|
+
var DEFAULT_ENDPOINT = "http://127.0.0.1:17373";
|
|
14
|
+
function createCLI(config) {
|
|
15
|
+
const authHeaders = config.token ? { Authorization: `Bearer ${config.token}` } : {};
|
|
16
|
+
const timeoutMs = config.timeoutMs ?? 1e4;
|
|
17
|
+
let insecureWarned = false;
|
|
18
|
+
const requestJson = async (path2, options = {}) => {
|
|
19
|
+
if (!insecureWarned && config.endpoint.startsWith("http://")) {
|
|
20
|
+
const parsed = new URL(config.endpoint);
|
|
21
|
+
const loopback = ["127.0.0.1", "::1", "localhost"].includes(parsed.hostname);
|
|
22
|
+
if (!loopback && config.token) {
|
|
23
|
+
console.warn(
|
|
24
|
+
"WARNING: Sending auth token over insecure HTTP to %s. Use HTTPS for remote daemons.",
|
|
25
|
+
parsed.hostname
|
|
26
|
+
);
|
|
27
|
+
insecureWarned = true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const url = new URL(`${config.endpoint}${path2}`);
|
|
31
|
+
if (options.query) {
|
|
32
|
+
for (const [key, value] of Object.entries(options.query)) {
|
|
33
|
+
if (value === void 0) continue;
|
|
34
|
+
url.searchParams.set(key, String(value));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch(url, {
|
|
41
|
+
method: options.method ?? "GET",
|
|
42
|
+
headers: { "Content-Type": "application/json", ...authHeaders },
|
|
43
|
+
body: options.body ? JSON.stringify(options.body) : void 0,
|
|
44
|
+
signal: controller.signal
|
|
45
|
+
});
|
|
46
|
+
if (res.status === 204) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
const detail = await res.text();
|
|
51
|
+
throw new Error(`${res.status} ${detail || res.statusText}`);
|
|
52
|
+
}
|
|
53
|
+
return await res.json();
|
|
54
|
+
} finally {
|
|
55
|
+
clearTimeout(timeout);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
async status() {
|
|
60
|
+
return requestJson("/v1/status");
|
|
61
|
+
},
|
|
62
|
+
async command(cmd) {
|
|
63
|
+
return requestJson("/v1/command", { method: "POST", body: cmd });
|
|
64
|
+
},
|
|
65
|
+
async devices() {
|
|
66
|
+
return requestJson("/v1/devices");
|
|
67
|
+
},
|
|
68
|
+
async useDevice(deviceId) {
|
|
69
|
+
return requestJson("/v1/device/use", { method: "POST", body: { deviceId } });
|
|
70
|
+
},
|
|
71
|
+
async authLogin() {
|
|
72
|
+
return requestJson("/v1/auth/spotify/login", { method: "POST" });
|
|
73
|
+
},
|
|
74
|
+
async authLogout() {
|
|
75
|
+
return requestJson("/v1/auth/spotify/logout", { method: "POST" });
|
|
76
|
+
},
|
|
77
|
+
async authImportCookies(cookies) {
|
|
78
|
+
return requestJson("/v1/auth/spotify/import", { method: "POST", body: { cookies } });
|
|
79
|
+
},
|
|
80
|
+
// YouTube auth
|
|
81
|
+
async youtubeAuthLogin() {
|
|
82
|
+
return requestJson("/v1/auth/youtube/login", { method: "POST" });
|
|
83
|
+
},
|
|
84
|
+
async youtubeAuthLogout() {
|
|
85
|
+
return requestJson("/v1/auth/youtube/logout", { method: "POST" });
|
|
86
|
+
},
|
|
87
|
+
async youtubeAuthRefresh() {
|
|
88
|
+
return requestJson("/v1/auth/youtube/refresh", { method: "POST" });
|
|
89
|
+
},
|
|
90
|
+
// Apple auth
|
|
91
|
+
async appleAuthSetUserToken(token) {
|
|
92
|
+
return requestJson("/v1/auth/apple/set-user-token", { method: "POST", body: { token } });
|
|
93
|
+
},
|
|
94
|
+
async appleAuthRefresh() {
|
|
95
|
+
return requestJson("/v1/auth/apple/refresh", { method: "POST" });
|
|
96
|
+
},
|
|
97
|
+
async appleAuthLogout() {
|
|
98
|
+
return requestJson("/v1/auth/apple/logout", { method: "POST" });
|
|
99
|
+
},
|
|
100
|
+
async spotifySearch(query, type, options) {
|
|
101
|
+
return requestJson("/v1/spotify/search", {
|
|
102
|
+
query: { q: query, type, limit: options?.limit, offset: options?.offset }
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
async appleSearch(query, type, options) {
|
|
106
|
+
return requestJson("/v1/apple/search", {
|
|
107
|
+
query: { q: query, type, limit: options?.limit, offset: options?.offset }
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
async appleLibraryTracks(options) {
|
|
111
|
+
return requestJson("/v1/apple/library/songs", {
|
|
112
|
+
query: { limit: options?.limit }
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
async applePlaylists(options) {
|
|
116
|
+
return requestJson("/v1/apple/library/playlists", {
|
|
117
|
+
query: { limit: options?.limit }
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
async applePlaylistTracks(playlistId, options) {
|
|
121
|
+
return requestJson(`/v1/apple/playlists/${encodeURIComponent(playlistId)}/tracks`, {
|
|
122
|
+
query: { limit: options?.limit }
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
async appleRecommendations(options) {
|
|
126
|
+
return requestJson("/v1/apple/recommendations", {
|
|
127
|
+
query: { limit: options?.limit, seed: options?.seed }
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
async youtubeSearch(query, type, options) {
|
|
131
|
+
return requestJson("/v1/youtube/search", {
|
|
132
|
+
query: { q: query, type, limit: options?.limit }
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
async youtubeLibraryTracks(options) {
|
|
136
|
+
return requestJson("/v1/youtube/library/tracks", {
|
|
137
|
+
query: { limit: options?.limit }
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
async youtubePlaylists(options) {
|
|
141
|
+
return requestJson("/v1/youtube/playlists", {
|
|
142
|
+
query: { limit: options?.limit }
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
async youtubePlaylistTracks(playlistId, options) {
|
|
146
|
+
return requestJson(`/v1/youtube/playlists/${encodeURIComponent(playlistId)}/tracks`, {
|
|
147
|
+
query: { limit: options?.limit }
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
async youtubeRecommendations(options) {
|
|
151
|
+
return requestJson("/v1/youtube/recommendations", {
|
|
152
|
+
query: { limit: options?.limit, seed: options?.seed }
|
|
153
|
+
});
|
|
154
|
+
},
|
|
155
|
+
async applePlay(payload) {
|
|
156
|
+
return requestJson("/v1/apple/play", { method: "POST", body: payload ?? {} });
|
|
157
|
+
},
|
|
158
|
+
async applePause() {
|
|
159
|
+
return requestJson("/v1/apple/pause", { method: "POST" });
|
|
160
|
+
},
|
|
161
|
+
async appleNext() {
|
|
162
|
+
return requestJson("/v1/apple/next", { method: "POST" });
|
|
163
|
+
},
|
|
164
|
+
async applePrev() {
|
|
165
|
+
return requestJson("/v1/apple/prev", { method: "POST" });
|
|
166
|
+
},
|
|
167
|
+
async appleNowPlaying() {
|
|
168
|
+
return requestJson("/v1/apple/now-playing");
|
|
169
|
+
},
|
|
170
|
+
async youtubePlay(payload) {
|
|
171
|
+
return requestJson("/v1/youtube/play", { method: "POST", body: payload ?? {} });
|
|
172
|
+
},
|
|
173
|
+
async youtubePause() {
|
|
174
|
+
return requestJson("/v1/youtube/pause", { method: "POST" });
|
|
175
|
+
},
|
|
176
|
+
async youtubeNext() {
|
|
177
|
+
return requestJson("/v1/youtube/next", { method: "POST" });
|
|
178
|
+
},
|
|
179
|
+
async youtubePrev() {
|
|
180
|
+
return requestJson("/v1/youtube/prev", { method: "POST" });
|
|
181
|
+
},
|
|
182
|
+
async youtubeNowPlaying() {
|
|
183
|
+
return requestJson("/v1/youtube/now-playing");
|
|
184
|
+
},
|
|
185
|
+
async youtubeQueueAdd(uri) {
|
|
186
|
+
return requestJson("/v1/youtube/queue", { method: "POST", body: { uri } });
|
|
187
|
+
},
|
|
188
|
+
async spotifyPlay(payload) {
|
|
189
|
+
return requestJson("/v1/spotify/play", { method: "POST", body: payload ?? {} });
|
|
190
|
+
},
|
|
191
|
+
async spotifyPlaylists(options) {
|
|
192
|
+
const result = await requestJson("/v1/spotify/playlists", {
|
|
193
|
+
query: { limit: options?.limit, offset: options?.offset }
|
|
194
|
+
});
|
|
195
|
+
return result.items ?? [];
|
|
196
|
+
},
|
|
197
|
+
async spotifyPlaylistTracks(playlistId, options) {
|
|
198
|
+
const result = await requestJson(`/v1/spotify/playlists/${encodeURIComponent(playlistId)}/tracks`, {
|
|
199
|
+
query: { limit: options?.limit, offset: options?.offset }
|
|
200
|
+
});
|
|
201
|
+
return result.items ?? [];
|
|
202
|
+
},
|
|
203
|
+
async spotifyLibraryTracks(options) {
|
|
204
|
+
const result = await requestJson("/v1/spotify/library/tracks", {
|
|
205
|
+
query: { limit: options?.limit, offset: options?.offset }
|
|
206
|
+
});
|
|
207
|
+
return result.items ?? [];
|
|
208
|
+
},
|
|
209
|
+
async spotifyRecommendations(options) {
|
|
210
|
+
return requestJson("/v1/spotify/recommendations", {
|
|
211
|
+
query: { limit: options?.limit, seed: options?.seed }
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
async spotifyPause() {
|
|
215
|
+
return requestJson("/v1/spotify/pause", { method: "POST" });
|
|
216
|
+
},
|
|
217
|
+
async spotifyNext() {
|
|
218
|
+
return requestJson("/v1/spotify/next", { method: "POST" });
|
|
219
|
+
},
|
|
220
|
+
async spotifyPrev() {
|
|
221
|
+
return requestJson("/v1/spotify/prev", { method: "POST" });
|
|
222
|
+
},
|
|
223
|
+
async spotifySeek(positionMs) {
|
|
224
|
+
return requestJson("/v1/spotify/seek", { method: "POST", body: { positionMs } });
|
|
225
|
+
},
|
|
226
|
+
async spotifyVolume(volumePercent) {
|
|
227
|
+
return requestJson("/v1/spotify/volume", { method: "POST", body: { volumePercent } });
|
|
228
|
+
},
|
|
229
|
+
async spotifyShuffle(state) {
|
|
230
|
+
return requestJson("/v1/spotify/shuffle", { method: "POST", body: { state } });
|
|
231
|
+
},
|
|
232
|
+
async spotifyRepeat(state) {
|
|
233
|
+
return requestJson("/v1/spotify/repeat", { method: "POST", body: { state } });
|
|
234
|
+
},
|
|
235
|
+
async spotifyNowPlaying() {
|
|
236
|
+
return requestJson("/v1/spotify/now-playing");
|
|
237
|
+
},
|
|
238
|
+
async spotifyQueueAdd(uri) {
|
|
239
|
+
return requestJson("/v1/spotify/queue", { method: "POST", body: { uri } });
|
|
240
|
+
},
|
|
241
|
+
// Smart cross-provider
|
|
242
|
+
async smartSearch(query, options) {
|
|
243
|
+
return requestJson("/v1/smart/search", {
|
|
244
|
+
query: { q: query, limit: options?.limit }
|
|
245
|
+
});
|
|
246
|
+
},
|
|
247
|
+
async smartPlay(options) {
|
|
248
|
+
return requestJson("/v1/smart/play", { method: "POST", body: options });
|
|
249
|
+
},
|
|
250
|
+
// Song recognition
|
|
251
|
+
async recognize(audioBase64) {
|
|
252
|
+
return requestJson("/v1/recognize", { method: "POST", body: { audio: audioBase64 } });
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
function getDefaultEndpoint() {
|
|
257
|
+
return process.env.HARMON_ENDPOINT || DEFAULT_ENDPOINT;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// bin/listen.js
|
|
261
|
+
import { spawn } from "node:child_process";
|
|
262
|
+
import { unlink, mkdtemp } from "node:fs/promises";
|
|
263
|
+
import { tmpdir } from "node:os";
|
|
264
|
+
import { join } from "node:path";
|
|
265
|
+
import { readFileSync } from "node:fs";
|
|
266
|
+
async function recordAudio(durationSeconds = 5) {
|
|
267
|
+
const tempDir = await mkdtemp(join(tmpdir(), "harmon-listen-"));
|
|
268
|
+
const outputPath = join(tempDir, "recording.wav");
|
|
269
|
+
return new Promise((resolve, reject) => {
|
|
270
|
+
const child = spawn("rec", [
|
|
271
|
+
outputPath,
|
|
272
|
+
"rate",
|
|
273
|
+
"16000",
|
|
274
|
+
"channels",
|
|
275
|
+
"1",
|
|
276
|
+
"trim",
|
|
277
|
+
"0",
|
|
278
|
+
String(durationSeconds)
|
|
279
|
+
], {
|
|
280
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
281
|
+
});
|
|
282
|
+
let stderr = "";
|
|
283
|
+
child.stderr.on("data", (chunk) => {
|
|
284
|
+
stderr += chunk.toString();
|
|
285
|
+
});
|
|
286
|
+
child.on("error", (error) => {
|
|
287
|
+
if (error.code === "ENOENT") {
|
|
288
|
+
reject(new Error(
|
|
289
|
+
"sox is not installed. Install it:\n macOS: brew install sox\n Ubuntu: sudo apt install sox\n Fedora: sudo dnf install sox"
|
|
290
|
+
));
|
|
291
|
+
} else {
|
|
292
|
+
reject(error);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
child.on("close", (code) => {
|
|
296
|
+
if (code !== 0) {
|
|
297
|
+
reject(new Error(`Recording failed (exit ${code}): ${stderr.trim()}`));
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
const audioBuffer = readFileSync(outputPath);
|
|
302
|
+
resolve({ audioBuffer, wavPath: outputPath });
|
|
303
|
+
} catch (err) {
|
|
304
|
+
reject(err);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
async function recognizeWithAudD(audioData, apiToken) {
|
|
310
|
+
const formData = new FormData();
|
|
311
|
+
formData.append("api_token", apiToken);
|
|
312
|
+
formData.append("file", new Blob([audioData], { type: "audio/wav" }), "recording.wav");
|
|
313
|
+
formData.append("return", "apple_music,spotify");
|
|
314
|
+
const response = await fetch("https://api.audd.io/", {
|
|
315
|
+
method: "POST",
|
|
316
|
+
body: formData
|
|
317
|
+
});
|
|
318
|
+
if (!response.ok) {
|
|
319
|
+
throw new Error(`AudD API error: ${response.status} ${response.statusText}`);
|
|
320
|
+
}
|
|
321
|
+
const data = await response.json();
|
|
322
|
+
if (data.status === "error") {
|
|
323
|
+
throw new Error(`AudD error: ${data.error?.error_message || "Unknown error"}`);
|
|
324
|
+
}
|
|
325
|
+
if (!data.result) {
|
|
326
|
+
return { recognized: false, backend: "audd" };
|
|
327
|
+
}
|
|
328
|
+
const result = data.result;
|
|
329
|
+
return {
|
|
330
|
+
recognized: true,
|
|
331
|
+
backend: "audd",
|
|
332
|
+
title: result.title || "",
|
|
333
|
+
artist: result.artist || "",
|
|
334
|
+
album: result.album || "",
|
|
335
|
+
releaseDate: result.release_date || "",
|
|
336
|
+
isrc: result.isrc || void 0,
|
|
337
|
+
spotify: result.spotify ? {
|
|
338
|
+
uri: result.spotify.uri,
|
|
339
|
+
id: result.spotify.id,
|
|
340
|
+
name: result.spotify.name,
|
|
341
|
+
artist: result.spotify.artists?.[0]?.name,
|
|
342
|
+
album: result.spotify.album?.name,
|
|
343
|
+
imageUrl: result.spotify.album?.images?.[0]?.url
|
|
344
|
+
} : void 0,
|
|
345
|
+
apple: result.apple_music ? {
|
|
346
|
+
url: result.apple_music.url,
|
|
347
|
+
name: result.apple_music.name,
|
|
348
|
+
artist: result.apple_music.artistName,
|
|
349
|
+
album: result.apple_music.albumName,
|
|
350
|
+
imageUrl: result.apple_music.artwork?.url
|
|
351
|
+
} : void 0
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
async function isFpcalcAvailable() {
|
|
355
|
+
return new Promise((resolve) => {
|
|
356
|
+
const child = spawn("fpcalc", ["-version"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
357
|
+
child.on("error", () => resolve(false));
|
|
358
|
+
child.on("close", (code) => resolve(code === 0));
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
async function generateFingerprint(wavPath) {
|
|
362
|
+
return new Promise((resolve, reject) => {
|
|
363
|
+
const child = spawn("fpcalc", ["-json", wavPath], {
|
|
364
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
365
|
+
});
|
|
366
|
+
let stdout = "";
|
|
367
|
+
let stderr = "";
|
|
368
|
+
child.stdout.on("data", (chunk) => {
|
|
369
|
+
stdout += chunk.toString();
|
|
370
|
+
});
|
|
371
|
+
child.stderr.on("data", (chunk) => {
|
|
372
|
+
stderr += chunk.toString();
|
|
373
|
+
});
|
|
374
|
+
child.on("error", (error) => {
|
|
375
|
+
if (error.code === "ENOENT") {
|
|
376
|
+
reject(new Error(
|
|
377
|
+
"fpcalc is not installed. Install Chromaprint:\n macOS: brew install chromaprint\n Ubuntu: sudo apt install libchromaprint-tools\n Fedora: sudo dnf install chromaprint-tools\n(FFmpeg is also required: brew install ffmpeg)"
|
|
378
|
+
));
|
|
379
|
+
} else {
|
|
380
|
+
reject(error);
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
child.on("close", (code) => {
|
|
384
|
+
if (code !== 0) {
|
|
385
|
+
reject(new Error(`fpcalc failed (exit ${code}): ${stderr.trim()}`));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
const result = JSON.parse(stdout);
|
|
390
|
+
resolve({
|
|
391
|
+
duration: Math.round(result.duration),
|
|
392
|
+
fingerprint: result.fingerprint
|
|
393
|
+
});
|
|
394
|
+
} catch {
|
|
395
|
+
reject(new Error("Failed to parse fpcalc output"));
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
async function lookupAcoustID(fingerprint, duration, apiKey) {
|
|
401
|
+
const clientId = apiKey || process.env.ACOUSTID_API_KEY || "harmon-open";
|
|
402
|
+
const params = new URLSearchParams({
|
|
403
|
+
client: clientId,
|
|
404
|
+
duration: String(duration),
|
|
405
|
+
fingerprint,
|
|
406
|
+
meta: "recordings+releasegroups+compress"
|
|
407
|
+
});
|
|
408
|
+
const response = await fetch(`https://api.acoustid.org/v2/lookup?${params.toString()}`);
|
|
409
|
+
if (!response.ok) {
|
|
410
|
+
throw new Error(`AcoustID API error: ${response.status} ${response.statusText}`);
|
|
411
|
+
}
|
|
412
|
+
const data = await response.json();
|
|
413
|
+
if (data.status === "error") {
|
|
414
|
+
throw new Error(`AcoustID error: ${data.error?.message || "Unknown error"}`);
|
|
415
|
+
}
|
|
416
|
+
return data.results || [];
|
|
417
|
+
}
|
|
418
|
+
function parseAcoustIDResults(acoustIdResults) {
|
|
419
|
+
if (!acoustIdResults || acoustIdResults.length === 0) {
|
|
420
|
+
return { recognized: false, backend: "chromaprint" };
|
|
421
|
+
}
|
|
422
|
+
for (const result of acoustIdResults) {
|
|
423
|
+
const recordings = result.recordings;
|
|
424
|
+
if (!recordings || recordings.length === 0) continue;
|
|
425
|
+
const recording = recordings[0];
|
|
426
|
+
const artists = recording.artists || [];
|
|
427
|
+
const releaseGroups = recording.releasegroups || [];
|
|
428
|
+
const album = releaseGroups.length > 0 ? releaseGroups[0] : null;
|
|
429
|
+
return {
|
|
430
|
+
recognized: true,
|
|
431
|
+
backend: "chromaprint",
|
|
432
|
+
confidence: result.score,
|
|
433
|
+
title: recording.title || "",
|
|
434
|
+
artist: artists.map((a) => a.name).join(", ") || "",
|
|
435
|
+
album: album?.title || "",
|
|
436
|
+
releaseDate: album?.firstreleasedate || "",
|
|
437
|
+
musicBrainzId: recording.id,
|
|
438
|
+
// Chromaprint doesn't provide direct Spotify/Apple links,
|
|
439
|
+
// but the ISRC or MusicBrainz ID can be used for cross-lookup
|
|
440
|
+
isrc: void 0,
|
|
441
|
+
spotify: void 0,
|
|
442
|
+
apple: void 0
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
return { recognized: false, backend: "chromaprint" };
|
|
446
|
+
}
|
|
447
|
+
async function recognizeWithChromaprint(wavPath, options = {}) {
|
|
448
|
+
const { duration, fingerprint } = await generateFingerprint(wavPath);
|
|
449
|
+
const acoustIdResults = await lookupAcoustID(fingerprint, duration, options.apiKey);
|
|
450
|
+
return parseAcoustIDResults(acoustIdResults);
|
|
451
|
+
}
|
|
452
|
+
async function listen(options = {}) {
|
|
453
|
+
const duration = options.duration || 5;
|
|
454
|
+
const auddToken = options.apiToken || process.env.AUDD_API_TOKEN;
|
|
455
|
+
const forceBackend = options.backend || "auto";
|
|
456
|
+
const { audioBuffer, wavPath } = await recordAudio(duration);
|
|
457
|
+
try {
|
|
458
|
+
if (forceBackend === "audd") {
|
|
459
|
+
if (!auddToken) throw new Error("AudD backend requested but AUDD_API_TOKEN is not set.");
|
|
460
|
+
return await recognizeWithAudD(audioBuffer, auddToken);
|
|
461
|
+
}
|
|
462
|
+
if (forceBackend === "chromaprint") {
|
|
463
|
+
return await recognizeWithChromaprint(wavPath);
|
|
464
|
+
}
|
|
465
|
+
if (auddToken) {
|
|
466
|
+
return await recognizeWithAudD(audioBuffer, auddToken);
|
|
467
|
+
}
|
|
468
|
+
if (await isFpcalcAvailable()) {
|
|
469
|
+
return await recognizeWithChromaprint(wavPath);
|
|
470
|
+
}
|
|
471
|
+
throw new Error(
|
|
472
|
+
"No recognition backend available.\n\nOption 1 \u2014 AudD (commercial, best accuracy):\n Get a free token at https://audd.io/ and set AUDD_API_TOKEN\n\nOption 2 \u2014 Chromaprint (open-source, free):\n macOS: brew install chromaprint ffmpeg\n Ubuntu: sudo apt install libchromaprint-tools ffmpeg\n Fedora: sudo dnf install chromaprint-tools ffmpeg"
|
|
473
|
+
);
|
|
474
|
+
} finally {
|
|
475
|
+
unlink(wavPath).catch(() => {
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// bin/runtime.js
|
|
481
|
+
var EXIT_OK = 0;
|
|
482
|
+
var EXIT_GENERIC = 1;
|
|
483
|
+
var EXIT_USAGE = 2;
|
|
484
|
+
var EXIT_AUTH = 3;
|
|
485
|
+
var EXIT_NETWORK = 4;
|
|
486
|
+
var PLAYBACK_ENGINES = ["connect", "applescript"];
|
|
487
|
+
var SUPPORTED_PROVIDERS = ["spotify", "apple", "youtube"];
|
|
488
|
+
var SESSION_MODES = ["focus", "relax", "energize", "meditate", "workout", "custom"];
|
|
489
|
+
var SPOTIFY_SEARCH_TYPES = ["track", "album", "artist", "playlist", "episode", "show"];
|
|
490
|
+
var WSL_ENV_KEYS = ["WSL_DISTRO_NAME", "WSL_INTEROP"];
|
|
491
|
+
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "::1", "localhost"]);
|
|
492
|
+
var CliUsageError = class extends Error {
|
|
493
|
+
constructor(message) {
|
|
494
|
+
super(message);
|
|
495
|
+
this.name = "CliUsageError";
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
function parseDurationInput(value, options) {
|
|
499
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
500
|
+
return options.defaultMs;
|
|
501
|
+
}
|
|
502
|
+
const match = value.trim().match(/^(\d+)(ms|s|m|h)?$/);
|
|
503
|
+
if (!match) {
|
|
504
|
+
throw new CliUsageError(`${options.label} must be a positive duration like 500ms, 10s, 30m, or 1h.`);
|
|
505
|
+
}
|
|
506
|
+
const amount = Number.parseInt(match[1], 10);
|
|
507
|
+
const unit = match[2] || "s";
|
|
508
|
+
if (!Number.isFinite(amount) || amount <= 0) {
|
|
509
|
+
throw new CliUsageError(`${options.label} must be a positive duration.`);
|
|
510
|
+
}
|
|
511
|
+
const multiplier = unit === "ms" ? 1 : unit === "s" ? 1e3 : unit === "m" ? 60 * 1e3 : unit === "h" ? 60 * 60 * 1e3 : null;
|
|
512
|
+
if (multiplier === null) {
|
|
513
|
+
throw new CliUsageError(`${options.label} uses an unsupported duration unit.`);
|
|
514
|
+
}
|
|
515
|
+
const durationMs = amount * multiplier;
|
|
516
|
+
if (durationMs > options.maxMs) {
|
|
517
|
+
throw new CliUsageError(`${options.label} must be ${formatMaxDuration(options.maxMs)} or less.`);
|
|
518
|
+
}
|
|
519
|
+
return durationMs;
|
|
520
|
+
}
|
|
521
|
+
function formatMaxDuration(maxMs) {
|
|
522
|
+
if (maxMs % (60 * 60 * 1e3) === 0) {
|
|
523
|
+
return `${maxMs / (60 * 60 * 1e3)}h`;
|
|
524
|
+
}
|
|
525
|
+
if (maxMs % (60 * 1e3) === 0) {
|
|
526
|
+
return `${maxMs / (60 * 1e3)}m`;
|
|
527
|
+
}
|
|
528
|
+
if (maxMs % 1e3 === 0) {
|
|
529
|
+
return `${maxMs / 1e3}s`;
|
|
530
|
+
}
|
|
531
|
+
return `${maxMs}ms`;
|
|
532
|
+
}
|
|
533
|
+
function parseTimeoutOption(value) {
|
|
534
|
+
return parseDurationInput(value, {
|
|
535
|
+
label: "timeout",
|
|
536
|
+
defaultMs: 10 * 1e3,
|
|
537
|
+
maxMs: 10 * 60 * 1e3
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
function parseSessionDurationOption(value) {
|
|
541
|
+
return parseDurationInput(value, {
|
|
542
|
+
label: "duration",
|
|
543
|
+
defaultMs: 60 * 60 * 1e3,
|
|
544
|
+
maxMs: 24 * 60 * 60 * 1e3
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
function validateFraction(value, label) {
|
|
548
|
+
if (value === void 0) {
|
|
549
|
+
return void 0;
|
|
550
|
+
}
|
|
551
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
|
|
552
|
+
throw new CliUsageError(`${label} must be a number between 0 and 1.`);
|
|
553
|
+
}
|
|
554
|
+
return value;
|
|
555
|
+
}
|
|
556
|
+
function validateChoice(value, label, allowed) {
|
|
557
|
+
if (!allowed.includes(value)) {
|
|
558
|
+
throw new CliUsageError(`${label} must be one of: ${allowed.join(", ")}.`);
|
|
559
|
+
}
|
|
560
|
+
return value;
|
|
561
|
+
}
|
|
562
|
+
function assertSafeAuthImportEndpoint(endpoint, allowInsecure = process.env.HARMON_ALLOW_INSECURE_AUTH_IMPORT === "1") {
|
|
563
|
+
if (allowInsecure) {
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const url = new URL(endpoint);
|
|
567
|
+
if (url.protocol === "https:") {
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (url.protocol === "http:" && isLoopbackHostname(url.hostname)) {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
throw new CliUsageError(
|
|
574
|
+
"Cookie import only allows loopback HTTP or HTTPS endpoints. Set HARMON_ALLOW_INSECURE_AUTH_IMPORT=1 to override for local development."
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
function detectDeviceOS(platform = process.platform, env = process.env) {
|
|
578
|
+
if (platform === "darwin") {
|
|
579
|
+
return "macos";
|
|
580
|
+
}
|
|
581
|
+
if (platform === "win32") {
|
|
582
|
+
return "windows";
|
|
583
|
+
}
|
|
584
|
+
if (platform === "linux" && WSL_ENV_KEYS.some((key) => Boolean(env[key]))) {
|
|
585
|
+
return "wsl";
|
|
586
|
+
}
|
|
587
|
+
return "linux";
|
|
588
|
+
}
|
|
589
|
+
function isLoopbackHostname(hostname) {
|
|
590
|
+
return LOOPBACK_HOSTS.has(hostname) || hostname.endsWith(".localhost");
|
|
591
|
+
}
|
|
592
|
+
function classifyCliError(error, argv = process.argv) {
|
|
593
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
594
|
+
const cause = error instanceof Error ? error.cause : void 0;
|
|
595
|
+
const json = argv.includes("--json");
|
|
596
|
+
if (error instanceof CliUsageError) {
|
|
597
|
+
return { exitCode: EXIT_USAGE, message, json };
|
|
598
|
+
}
|
|
599
|
+
const errorCode = error && typeof error === "object" && "code" in error ? String(error.code) : "";
|
|
600
|
+
const errorExitCode = error && typeof error === "object" && "exitCode" in error ? Number(error.exitCode) : void 0;
|
|
601
|
+
if (error instanceof Error && error.name === "CommanderError" && errorExitCode === EXIT_OK && (errorCode === "commander.helpDisplayed" || errorCode === "commander.version")) {
|
|
602
|
+
return { exitCode: EXIT_OK, message, json };
|
|
603
|
+
}
|
|
604
|
+
if (error instanceof Error && error.name === "CommanderError" || errorCode.startsWith("commander.")) {
|
|
605
|
+
return { exitCode: EXIT_USAGE, message, json };
|
|
606
|
+
}
|
|
607
|
+
if (message.includes("fetch failed") || message.includes("ECONNREFUSED") || message.includes("EHOSTUNREACH") || message.includes("ENOTFOUND") || message.includes("ETIMEDOUT") || cause && String(cause).includes("ECONNREFUSED")) {
|
|
608
|
+
return { exitCode: EXIT_NETWORK, message, json };
|
|
609
|
+
}
|
|
610
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
611
|
+
return { exitCode: EXIT_NETWORK, message: "Request timed out. Use --timeout to increase.", json };
|
|
612
|
+
}
|
|
613
|
+
if (message.includes("401") || message.includes("403") || message.includes("Unauthorized")) {
|
|
614
|
+
return {
|
|
615
|
+
exitCode: EXIT_AUTH,
|
|
616
|
+
message: 'Authentication failed. Run "harmon auth status" to inspect provider auth, then use the provider-specific auth flow if needed.',
|
|
617
|
+
json
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
if (message.includes("Unknown device") || message.includes("Invalid") || message.includes("Missing") || message.includes("must be")) {
|
|
621
|
+
return { exitCode: EXIT_USAGE, message, json };
|
|
622
|
+
}
|
|
623
|
+
return { exitCode: EXIT_GENERIC, message, json };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// bin/harmon.js
|
|
627
|
+
var program = new Command();
|
|
628
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
629
|
+
var __dirname = path.dirname(__filename);
|
|
630
|
+
var repoRoot = path.resolve(__dirname, "..", "..", "..");
|
|
631
|
+
var siloPackagePath = path.join(repoRoot, "tools", "harmon-silo");
|
|
632
|
+
var packageVersion = "0.1.0";
|
|
633
|
+
function resolveOptions(command) {
|
|
634
|
+
const chain = [];
|
|
635
|
+
let current = command;
|
|
636
|
+
while (current) {
|
|
637
|
+
chain.push(current);
|
|
638
|
+
current = current.parent;
|
|
639
|
+
}
|
|
640
|
+
const resolved = {};
|
|
641
|
+
for (const entry of chain.reverse()) {
|
|
642
|
+
Object.assign(resolved, entry.opts());
|
|
643
|
+
}
|
|
644
|
+
return resolved;
|
|
645
|
+
}
|
|
646
|
+
function createContext(command) {
|
|
647
|
+
const opts = resolveOptions(command);
|
|
648
|
+
const endpoint = getDefaultEndpoint();
|
|
649
|
+
const token = process.env.HARMON_API_TOKEN;
|
|
650
|
+
const engine = validateChoice(opts.engine, "engine", PLAYBACK_ENGINES);
|
|
651
|
+
const provider = resolveProviderOption(opts.provider, engine);
|
|
652
|
+
const timeoutMs = parseTimeoutOption(opts.timeout);
|
|
653
|
+
if (opts.debug) {
|
|
654
|
+
console.error(`[debug] endpoint: ${endpoint}`);
|
|
655
|
+
console.error(`[debug] timeout: ${timeoutMs}ms`);
|
|
656
|
+
console.error(`[debug] auth: ${token ? "token set" : "no token"}`);
|
|
657
|
+
console.error(`[debug] engine: ${engine}`);
|
|
658
|
+
console.error(`[debug] provider: ${provider}`);
|
|
659
|
+
}
|
|
660
|
+
return {
|
|
661
|
+
opts: { ...opts, engine, provider },
|
|
662
|
+
cli: createCLI({ endpoint, token, timeoutMs }),
|
|
663
|
+
endpoint
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
function resolveProviderOption(value, engine) {
|
|
667
|
+
const provider = validateChoice(value || "spotify", "provider", SUPPORTED_PROVIDERS);
|
|
668
|
+
if (engine === "applescript" && provider !== "apple") {
|
|
669
|
+
throw new CliUsageError("--engine applescript requires --provider apple.");
|
|
670
|
+
}
|
|
671
|
+
return provider;
|
|
672
|
+
}
|
|
673
|
+
function outputResult(opts, data, formatters = {}) {
|
|
674
|
+
if (opts.quiet) return;
|
|
675
|
+
if (opts.json) {
|
|
676
|
+
console.log(JSON.stringify(data, null, 2));
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if (opts.plain && typeof formatters.plain === "function") {
|
|
680
|
+
console.log(formatters.plain(data));
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
if (typeof formatters.human === "function") {
|
|
684
|
+
console.log(formatters.human(data));
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
console.log(JSON.stringify(data, null, 2));
|
|
688
|
+
}
|
|
689
|
+
function formatProviderStatusLine(name, provider) {
|
|
690
|
+
if (!provider) {
|
|
691
|
+
return `${name}: unavailable`;
|
|
692
|
+
}
|
|
693
|
+
const status = provider.status || (provider.connected ? "ready" : "missing");
|
|
694
|
+
const auth2 = provider.auth ? ` (${provider.auth})` : "";
|
|
695
|
+
return `${name}: ${status}${auth2}`;
|
|
696
|
+
}
|
|
697
|
+
async function readCookieFile(filePath) {
|
|
698
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
699
|
+
const parsed = JSON.parse(raw);
|
|
700
|
+
if (Array.isArray(parsed)) {
|
|
701
|
+
return parsed;
|
|
702
|
+
}
|
|
703
|
+
if (parsed && Array.isArray(parsed.records)) {
|
|
704
|
+
return parsed.records;
|
|
705
|
+
}
|
|
706
|
+
throw new CliUsageError("Unsupported cookie file format.");
|
|
707
|
+
}
|
|
708
|
+
async function runSiloExport(options) {
|
|
709
|
+
const helper = process.env.HARMON_SILO_HELPER;
|
|
710
|
+
const args = [];
|
|
711
|
+
if (options.browser) args.push("--browser", options.browser);
|
|
712
|
+
if (options.browserProfile) args.push("--browser-profile", options.browserProfile);
|
|
713
|
+
if (options.domain) args.push("--domain", options.domain);
|
|
714
|
+
if (options.includeExpired) args.push("--include-expired");
|
|
715
|
+
const cmd = helper || "swift";
|
|
716
|
+
const cmdArgs = helper ? args : ["run", "--package-path", siloPackagePath, "harmon-silo", "export", ...args];
|
|
717
|
+
return new Promise((resolve, reject) => {
|
|
718
|
+
const child = spawn2(cmd, cmdArgs, { stdio: ["ignore", "pipe", "pipe"] });
|
|
719
|
+
let stdout = "";
|
|
720
|
+
let stderr = "";
|
|
721
|
+
child.stdout.on("data", (chunk) => {
|
|
722
|
+
stdout += chunk.toString();
|
|
723
|
+
});
|
|
724
|
+
child.stderr.on("data", (chunk) => {
|
|
725
|
+
stderr += chunk.toString();
|
|
726
|
+
});
|
|
727
|
+
child.on("error", (error) => {
|
|
728
|
+
reject(error);
|
|
729
|
+
});
|
|
730
|
+
child.on("close", (code) => {
|
|
731
|
+
if (code !== 0) {
|
|
732
|
+
reject(new Error(stderr.trim() || `Silo export failed (${code}).`));
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
try {
|
|
736
|
+
const parsed = JSON.parse(stdout);
|
|
737
|
+
resolve(parsed);
|
|
738
|
+
} catch (error) {
|
|
739
|
+
reject(new Error("Failed to parse Silo output."));
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
async function ensureSiloHelperAvailable() {
|
|
745
|
+
if (process.env.HARMON_SILO_HELPER) {
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
try {
|
|
749
|
+
await fs.access(path.join(siloPackagePath, "Package.swift"));
|
|
750
|
+
} catch {
|
|
751
|
+
throw new CliUsageError(
|
|
752
|
+
"Browser cookie import without --cookie-path only works from a repo checkout with tools/harmon-silo or when HARMON_SILO_HELPER points to an installed helper."
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
function normalizeCookies(records) {
|
|
757
|
+
if (!Array.isArray(records)) return [];
|
|
758
|
+
return records.map((record) => ({
|
|
759
|
+
domain: record.domain ?? "",
|
|
760
|
+
name: record.name ?? "",
|
|
761
|
+
path: record.path ?? "/",
|
|
762
|
+
value: record.value ?? "",
|
|
763
|
+
expires: record.expires ?? record.expiry ?? null,
|
|
764
|
+
isSecure: Boolean(record.isSecure ?? record.secure),
|
|
765
|
+
isHTTPOnly: Boolean(record.isHTTPOnly ?? record.isHttpOnly ?? record.httpOnly)
|
|
766
|
+
})).filter((record) => record.name && record.value);
|
|
767
|
+
}
|
|
768
|
+
function formatTrackLines(tracks) {
|
|
769
|
+
if (!Array.isArray(tracks) || tracks.length === 0) return "No results.";
|
|
770
|
+
return tracks.map((track, index) => {
|
|
771
|
+
const uri = track.uri ? ` [${track.uri}]` : "";
|
|
772
|
+
return `${index + 1}. ${track.artist} - ${track.name} (${track.album})${uri}`;
|
|
773
|
+
}).join("\n");
|
|
774
|
+
}
|
|
775
|
+
function formatTrackPlain(tracks) {
|
|
776
|
+
if (!Array.isArray(tracks) || tracks.length === 0) return "";
|
|
777
|
+
return tracks.map((track) => `${track.name} ${track.artist} ${track.album} ${track.uri ?? ""}`).join("\n");
|
|
778
|
+
}
|
|
779
|
+
function formatAlbumLines(albums) {
|
|
780
|
+
if (!Array.isArray(albums) || albums.length === 0) return "No results.";
|
|
781
|
+
return albums.map((album, index) => {
|
|
782
|
+
const artists = Array.isArray(album.artists) ? album.artists.join(", ") : "";
|
|
783
|
+
const uri = album.uri ? ` [${album.uri}]` : "";
|
|
784
|
+
return `${index + 1}. ${album.name} - ${artists}${uri}`;
|
|
785
|
+
}).join("\n");
|
|
786
|
+
}
|
|
787
|
+
function formatAlbumPlain(albums) {
|
|
788
|
+
if (!Array.isArray(albums) || albums.length === 0) return "";
|
|
789
|
+
return albums.map((album) => `${album.name} ${(album.artists || []).join(", ")} ${album.uri ?? ""}`).join("\n");
|
|
790
|
+
}
|
|
791
|
+
function formatArtistLines(artists) {
|
|
792
|
+
if (!Array.isArray(artists) || artists.length === 0) return "No results.";
|
|
793
|
+
return artists.map((artist, index) => {
|
|
794
|
+
const uri = artist.uri ? ` [${artist.uri}]` : "";
|
|
795
|
+
return `${index + 1}. ${artist.name}${uri}`;
|
|
796
|
+
}).join("\n");
|
|
797
|
+
}
|
|
798
|
+
function formatArtistPlain(artists) {
|
|
799
|
+
if (!Array.isArray(artists) || artists.length === 0) return "";
|
|
800
|
+
return artists.map((artist) => `${artist.name} ${artist.uri ?? ""}`).join("\n");
|
|
801
|
+
}
|
|
802
|
+
function formatPlaylistLines(playlists) {
|
|
803
|
+
if (!Array.isArray(playlists) || playlists.length === 0) return "No results.";
|
|
804
|
+
return playlists.map((playlist2, index) => {
|
|
805
|
+
const uri = playlist2.uri ? ` [${playlist2.uri}]` : "";
|
|
806
|
+
return `${index + 1}. ${playlist2.name} (${playlist2.owner}, ${playlist2.totalTracks} tracks)${uri}`;
|
|
807
|
+
}).join("\n");
|
|
808
|
+
}
|
|
809
|
+
function formatPlaylistPlain(playlists) {
|
|
810
|
+
if (!Array.isArray(playlists) || playlists.length === 0) return "";
|
|
811
|
+
return playlists.map((playlist2) => `${playlist2.name} ${playlist2.owner} ${playlist2.totalTracks} ${playlist2.uri ?? ""}`).join("\n");
|
|
812
|
+
}
|
|
813
|
+
function formatEpisodeLines(episodes) {
|
|
814
|
+
if (!Array.isArray(episodes) || episodes.length === 0) return "No results.";
|
|
815
|
+
return episodes.map((episode, index) => {
|
|
816
|
+
const showName = episode.showName ? ` (${episode.showName})` : "";
|
|
817
|
+
const uri = episode.uri ? ` [${episode.uri}]` : "";
|
|
818
|
+
return `${index + 1}. ${episode.name}${showName}${uri}`;
|
|
819
|
+
}).join("\n");
|
|
820
|
+
}
|
|
821
|
+
function formatEpisodePlain(episodes) {
|
|
822
|
+
if (!Array.isArray(episodes) || episodes.length === 0) return "";
|
|
823
|
+
return episodes.map((episode) => `${episode.name} ${episode.showName ?? ""} ${episode.releaseDate ?? ""} ${episode.uri ?? ""}`).join("\n");
|
|
824
|
+
}
|
|
825
|
+
function formatShowLines(shows) {
|
|
826
|
+
if (!Array.isArray(shows) || shows.length === 0) return "No results.";
|
|
827
|
+
return shows.map((show, index) => {
|
|
828
|
+
const publisher = show.publisher ? ` (${show.publisher})` : "";
|
|
829
|
+
const uri = show.uri ? ` [${show.uri}]` : "";
|
|
830
|
+
return `${index + 1}. ${show.name}${publisher}${uri}`;
|
|
831
|
+
}).join("\n");
|
|
832
|
+
}
|
|
833
|
+
function formatShowPlain(shows) {
|
|
834
|
+
if (!Array.isArray(shows) || shows.length === 0) return "";
|
|
835
|
+
return shows.map((show) => `${show.name} ${show.publisher ?? ""} ${show.totalEpisodes ?? ""} ${show.uri ?? ""}`).join("\n");
|
|
836
|
+
}
|
|
837
|
+
async function fetchNowPlaying(cli, provider) {
|
|
838
|
+
if (provider === "apple") {
|
|
839
|
+
return cli.appleNowPlaying();
|
|
840
|
+
}
|
|
841
|
+
if (provider === "youtube") {
|
|
842
|
+
return cli.youtubeNowPlaying();
|
|
843
|
+
}
|
|
844
|
+
return cli.spotifyNowPlaying();
|
|
845
|
+
}
|
|
846
|
+
async function searchCatalog(cli, provider, type, query, options) {
|
|
847
|
+
if (provider === "apple") {
|
|
848
|
+
const appleType = mapAppleSearchType(type);
|
|
849
|
+
const result = await cli.appleSearch(query, appleType, options);
|
|
850
|
+
return normalizeAppleSearchResult(type, result);
|
|
851
|
+
}
|
|
852
|
+
if (provider === "youtube") {
|
|
853
|
+
if (options?.offset && options.offset > 0) {
|
|
854
|
+
throw new CliUsageError("YouTube Music search does not support --offset.");
|
|
855
|
+
}
|
|
856
|
+
const youtubeType = mapYouTubeSearchType(type);
|
|
857
|
+
const result = await cli.youtubeSearch(query, youtubeType, { limit: options?.limit });
|
|
858
|
+
return normalizeYouTubeSearchResult(type, result);
|
|
859
|
+
}
|
|
860
|
+
return cli.spotifySearch(query, type, options);
|
|
861
|
+
}
|
|
862
|
+
function mapAppleSearchType(type) {
|
|
863
|
+
if (type === "track") return "songs";
|
|
864
|
+
if (type === "album") return "albums";
|
|
865
|
+
if (type === "artist") return "artists";
|
|
866
|
+
if (type === "playlist") return "playlists";
|
|
867
|
+
throw new CliUsageError("Apple Music search supports track, album, artist, and playlist.");
|
|
868
|
+
}
|
|
869
|
+
function mapYouTubeSearchType(type) {
|
|
870
|
+
if (type === "track") return "songs";
|
|
871
|
+
if (type === "album") return "albums";
|
|
872
|
+
if (type === "artist") return "artists";
|
|
873
|
+
if (type === "playlist") return "playlists";
|
|
874
|
+
throw new CliUsageError("YouTube Music search supports track, album, artist, and playlist.");
|
|
875
|
+
}
|
|
876
|
+
function normalizeAppleSearchResult(type, result) {
|
|
877
|
+
if (type === "track") {
|
|
878
|
+
return {
|
|
879
|
+
tracks: (result.songs || []).map((song) => ({
|
|
880
|
+
name: song.name,
|
|
881
|
+
artist: song.artistName,
|
|
882
|
+
album: song.albumName || "",
|
|
883
|
+
uri: song.url || `apple:song:${song.id}`
|
|
884
|
+
}))
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
if (type === "album") {
|
|
888
|
+
return {
|
|
889
|
+
albums: (result.albums || []).map((album) => ({
|
|
890
|
+
name: album.name,
|
|
891
|
+
artists: [album.artistName],
|
|
892
|
+
uri: album.url || `apple:album:${album.id}`
|
|
893
|
+
}))
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
if (type === "artist") {
|
|
897
|
+
return {
|
|
898
|
+
artists: (result.artists || []).map((artist) => ({
|
|
899
|
+
name: artist.name,
|
|
900
|
+
uri: artist.url || `apple:artist:${artist.id}`
|
|
901
|
+
}))
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
return {
|
|
905
|
+
playlists: (result.playlists || []).map((playlist2) => ({
|
|
906
|
+
name: playlist2.name,
|
|
907
|
+
owner: playlist2.curatorName || "Apple Music",
|
|
908
|
+
totalTracks: playlist2.trackCount || 0,
|
|
909
|
+
uri: playlist2.url || `apple:playlist:${playlist2.id}`
|
|
910
|
+
}))
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
function normalizeYouTubeSearchResult(type, result) {
|
|
914
|
+
if (type === "track") {
|
|
915
|
+
return {
|
|
916
|
+
tracks: (result.songs || []).map((song) => ({
|
|
917
|
+
name: song.name,
|
|
918
|
+
artist: song.artistName,
|
|
919
|
+
album: song.albumName || "",
|
|
920
|
+
uri: `youtube:video:${song.id}`
|
|
921
|
+
}))
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
if (type === "album") {
|
|
925
|
+
return {
|
|
926
|
+
albums: (result.albums || []).map((album) => ({
|
|
927
|
+
name: album.name,
|
|
928
|
+
artists: [album.artistName],
|
|
929
|
+
uri: `youtube:playlist:${album.id}`
|
|
930
|
+
}))
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
if (type === "artist") {
|
|
934
|
+
return {
|
|
935
|
+
artists: (result.artists || []).map((artist) => ({
|
|
936
|
+
name: artist.name,
|
|
937
|
+
uri: `youtube:artist:${artist.id}`
|
|
938
|
+
}))
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
return {
|
|
942
|
+
playlists: (result.playlists || []).map((playlist2) => ({
|
|
943
|
+
name: playlist2.name,
|
|
944
|
+
owner: playlist2.author || "YouTube Music",
|
|
945
|
+
totalTracks: playlist2.trackCount || 0,
|
|
946
|
+
uri: `youtube:playlist:${playlist2.id}`
|
|
947
|
+
}))
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
function searchOutputFormatters(type) {
|
|
951
|
+
return {
|
|
952
|
+
track: { human: (data) => formatTrackLines(data.tracks), plain: (data) => formatTrackPlain(data.tracks) },
|
|
953
|
+
album: { human: (data) => formatAlbumLines(data.albums), plain: (data) => formatAlbumPlain(data.albums) },
|
|
954
|
+
artist: { human: (data) => formatArtistLines(data.artists), plain: (data) => formatArtistPlain(data.artists) },
|
|
955
|
+
playlist: {
|
|
956
|
+
human: (data) => formatPlaylistLines(data.playlists),
|
|
957
|
+
plain: (data) => formatPlaylistPlain(data.playlists)
|
|
958
|
+
},
|
|
959
|
+
episode: {
|
|
960
|
+
human: (data) => formatEpisodeLines(data.episodes),
|
|
961
|
+
plain: (data) => formatEpisodePlain(data.episodes)
|
|
962
|
+
},
|
|
963
|
+
show: {
|
|
964
|
+
human: (data) => formatShowLines(data.shows),
|
|
965
|
+
plain: (data) => formatShowPlain(data.shows)
|
|
966
|
+
}
|
|
967
|
+
}[type] || {};
|
|
968
|
+
}
|
|
969
|
+
function normalizeTrackCollection(provider, tracks) {
|
|
970
|
+
const collection = Array.isArray(tracks) ? tracks : Array.isArray(tracks?.items) ? tracks.items : [];
|
|
971
|
+
if (!Array.isArray(collection)) {
|
|
972
|
+
return [];
|
|
973
|
+
}
|
|
974
|
+
return collection.map((track) => ({
|
|
975
|
+
name: track.name,
|
|
976
|
+
artist: track.artist ?? track.artistName ?? "",
|
|
977
|
+
album: track.album ?? track.albumName ?? "",
|
|
978
|
+
uri: track.uri || track.url || (provider === "apple" ? `apple:song:${track.id}` : provider === "youtube" ? `youtube:video:${track.id}` : track.uri)
|
|
979
|
+
}));
|
|
980
|
+
}
|
|
981
|
+
function normalizePlaylistCollection(provider, playlists) {
|
|
982
|
+
const collection = Array.isArray(playlists) ? playlists : Array.isArray(playlists?.items) ? playlists.items : [];
|
|
983
|
+
if (!Array.isArray(collection)) {
|
|
984
|
+
return [];
|
|
985
|
+
}
|
|
986
|
+
return collection.map((playlist2) => ({
|
|
987
|
+
name: playlist2.name,
|
|
988
|
+
owner: playlist2.owner || playlist2.curatorName || playlist2.author || (provider === "apple" ? "Apple Music" : provider === "youtube" ? "YouTube Music" : ""),
|
|
989
|
+
totalTracks: playlist2.totalTracks ?? playlist2.trackCount ?? 0,
|
|
990
|
+
uri: playlist2.uri || playlist2.url || (provider === "apple" ? `apple:playlist:${playlist2.id}` : provider === "youtube" ? `youtube:playlist:${playlist2.id}` : playlist2.uri)
|
|
991
|
+
}));
|
|
992
|
+
}
|
|
993
|
+
function isAppleInput(value) {
|
|
994
|
+
if (!value) return false;
|
|
995
|
+
return value.startsWith("applemusic:") || value.includes("music.apple.com");
|
|
996
|
+
}
|
|
997
|
+
function isYouTubeInput(value) {
|
|
998
|
+
if (!value) return false;
|
|
999
|
+
return value.startsWith("youtube:video:") || value.startsWith("youtube:playlist:") || value.includes("youtube.com/watch") || value.includes("music.youtube.com/watch") || value.includes("youtube.com/playlist") || value.includes("music.youtube.com/playlist");
|
|
1000
|
+
}
|
|
1001
|
+
function resolvePlaybackProvider(opts, value) {
|
|
1002
|
+
if (isAppleInput(value)) {
|
|
1003
|
+
return "apple";
|
|
1004
|
+
}
|
|
1005
|
+
if (isYouTubeInput(value)) {
|
|
1006
|
+
return "youtube";
|
|
1007
|
+
}
|
|
1008
|
+
return opts.provider;
|
|
1009
|
+
}
|
|
1010
|
+
function normalizeAppleMusicUrl(value, market) {
|
|
1011
|
+
if (!value) return null;
|
|
1012
|
+
if (value.startsWith("http://") || value.startsWith("https://")) {
|
|
1013
|
+
return value;
|
|
1014
|
+
}
|
|
1015
|
+
if (!value.startsWith("applemusic:")) return null;
|
|
1016
|
+
const parts = value.split(":");
|
|
1017
|
+
if (parts.length < 3) return null;
|
|
1018
|
+
const rawType = parts[1];
|
|
1019
|
+
const type = rawType.split("/")[0];
|
|
1020
|
+
const id = parts.slice(2).join(":");
|
|
1021
|
+
const region = market ? market.toLowerCase() : "us";
|
|
1022
|
+
const pathType = type === "album" ? "album" : type === "playlist" ? "playlist" : "song";
|
|
1023
|
+
return `https://music.apple.com/${region}/${pathType}/${id}`;
|
|
1024
|
+
}
|
|
1025
|
+
function normalizeSpotifyUri(value, typeOverride) {
|
|
1026
|
+
if (!value) return null;
|
|
1027
|
+
if (value.startsWith("spotify:")) return value;
|
|
1028
|
+
if (value.startsWith("http://") || value.startsWith("https://")) {
|
|
1029
|
+
try {
|
|
1030
|
+
const url = new URL(value);
|
|
1031
|
+
if (url.hostname.endsWith("spotify.com")) {
|
|
1032
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
1033
|
+
if (parts.length >= 2) {
|
|
1034
|
+
return `spotify:${parts[0]}:${parts[1]}`;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
} catch {
|
|
1038
|
+
return null;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
if (typeOverride) {
|
|
1042
|
+
return `spotify:${typeOverride}:${value}`;
|
|
1043
|
+
}
|
|
1044
|
+
return `spotify:track:${value}`;
|
|
1045
|
+
}
|
|
1046
|
+
function normalizeYouTubeUri(value) {
|
|
1047
|
+
if (!value) return null;
|
|
1048
|
+
if (value.startsWith("youtube:video:")) return value;
|
|
1049
|
+
if (value.startsWith("youtube:playlist:")) {
|
|
1050
|
+
return `https://music.youtube.com/playlist?list=${value.split(":").pop()}`;
|
|
1051
|
+
}
|
|
1052
|
+
if (value.startsWith("http://") || value.startsWith("https://")) {
|
|
1053
|
+
try {
|
|
1054
|
+
const url = new URL(value);
|
|
1055
|
+
const playlistId = url.searchParams.get("list");
|
|
1056
|
+
if (playlistId && !url.searchParams.get("v")) {
|
|
1057
|
+
return `https://music.youtube.com/playlist?list=${playlistId}`;
|
|
1058
|
+
}
|
|
1059
|
+
const id = url.hostname === "youtu.be" ? url.pathname.split("/").filter(Boolean)[0] : url.searchParams.get("v");
|
|
1060
|
+
if (id) {
|
|
1061
|
+
return `youtube:video:${id}`;
|
|
1062
|
+
}
|
|
1063
|
+
} catch {
|
|
1064
|
+
return null;
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
return /^[a-zA-Z0-9_-]{6,}$/.test(value) ? `youtube:video:${value}` : null;
|
|
1068
|
+
}
|
|
1069
|
+
function extractPlaylistId(provider, value) {
|
|
1070
|
+
if (!value) return null;
|
|
1071
|
+
if (provider === "spotify") {
|
|
1072
|
+
const uri = normalizeSpotifyUri(value, "playlist");
|
|
1073
|
+
return uri?.split(":").pop() || null;
|
|
1074
|
+
}
|
|
1075
|
+
if (provider === "apple") {
|
|
1076
|
+
if (value.startsWith("applemusic:playlist:")) {
|
|
1077
|
+
return value.split(":").slice(2).join(":") || null;
|
|
1078
|
+
}
|
|
1079
|
+
try {
|
|
1080
|
+
const url = new URL(value);
|
|
1081
|
+
if (url.hostname.includes("music.apple.com")) {
|
|
1082
|
+
return url.pathname.split("/").filter(Boolean).pop() || null;
|
|
1083
|
+
}
|
|
1084
|
+
} catch {
|
|
1085
|
+
return value;
|
|
1086
|
+
}
|
|
1087
|
+
return value;
|
|
1088
|
+
}
|
|
1089
|
+
if (value.startsWith("youtube:playlist:")) {
|
|
1090
|
+
return value.split(":").pop() || null;
|
|
1091
|
+
}
|
|
1092
|
+
try {
|
|
1093
|
+
const url = new URL(value);
|
|
1094
|
+
return url.searchParams.get("list") || value;
|
|
1095
|
+
} catch {
|
|
1096
|
+
return value;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
function spotifyUriType(uri) {
|
|
1100
|
+
if (!uri || !uri.startsWith("spotify:")) return null;
|
|
1101
|
+
const parts = uri.split(":");
|
|
1102
|
+
return parts.length >= 2 ? parts[1] : null;
|
|
1103
|
+
}
|
|
1104
|
+
function parseSeekValue(value) {
|
|
1105
|
+
if (typeof value !== "string") return null;
|
|
1106
|
+
if (/^\d+$/.test(value)) return Number.parseInt(value, 10);
|
|
1107
|
+
const parts = value.split(":").map((part) => Number.parseInt(part, 10));
|
|
1108
|
+
if (parts.some((part) => !Number.isFinite(part))) return null;
|
|
1109
|
+
if (parts.length === 2) {
|
|
1110
|
+
const [min, sec] = parts;
|
|
1111
|
+
return (min * 60 + sec) * 1e3;
|
|
1112
|
+
}
|
|
1113
|
+
if (parts.length === 3) {
|
|
1114
|
+
const [hour, min, sec] = parts;
|
|
1115
|
+
return (hour * 3600 + min * 60 + sec) * 1e3;
|
|
1116
|
+
}
|
|
1117
|
+
return null;
|
|
1118
|
+
}
|
|
1119
|
+
async function resolveDevice(cli, device2) {
|
|
1120
|
+
if (!device2) return null;
|
|
1121
|
+
const devices = await cli.devices();
|
|
1122
|
+
if (!Array.isArray(devices)) return device2;
|
|
1123
|
+
const match = devices.find(
|
|
1124
|
+
(entry) => entry.id === device2 || entry.name?.toLowerCase() === device2.toLowerCase()
|
|
1125
|
+
);
|
|
1126
|
+
return match ? match.id : null;
|
|
1127
|
+
}
|
|
1128
|
+
async function applyDeviceIfNeeded(cli, opts) {
|
|
1129
|
+
if (!opts.device) return;
|
|
1130
|
+
if (opts.provider !== "spotify") {
|
|
1131
|
+
throw new CliUsageError("Device selection is only supported for Spotify Connect.");
|
|
1132
|
+
}
|
|
1133
|
+
const deviceId = await resolveDevice(cli, opts.device);
|
|
1134
|
+
if (!deviceId) {
|
|
1135
|
+
throw new CliUsageError(`Unknown device: ${opts.device}`);
|
|
1136
|
+
}
|
|
1137
|
+
await cli.useDevice(deviceId);
|
|
1138
|
+
}
|
|
1139
|
+
program.name("harmon").version(packageVersion, "-V, --version").showHelpAfterError().exitOverride().description('Harmon \u2014 mood-based music session engine CLI\n\nExamples:\n harmon status\n harmon play spotify:track:4cOdK2wGLETKBW3PvgPWqT\n harmon search track "lofi beats"\n harmon session start --mode focus\n harmon auth import --browser chrome').option("--timeout <dur>", "request timeout (e.g. 5s, 30s, 1m)", "10s").option("--market <cc>", "market country code (e.g. US, GB)").option("--provider <name>", "provider: spotify, apple, youtube", "spotify").option("--device <name|id>", "target playback device").option("--engine <connect|applescript>", "playback engine", "connect").option("--json", "JSON output (machine-readable)").option("--plain", "tab-separated output (for piping)").option("--no-color", "disable color output").option("-q, --quiet", "suppress output").option("-v, --verbose", "verbose output").option("-d, --debug", "debug mode \u2014 show request/response details");
|
|
1140
|
+
program.command("init").description("Interactive setup wizard \u2014 configure providers and generate .env").option("--output <path>", "output path for .env file", ".env.harmon").action(async (...args) => {
|
|
1141
|
+
const command = args[args.length - 1];
|
|
1142
|
+
const outputPath = command.opts().output;
|
|
1143
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1144
|
+
const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
1145
|
+
console.log("");
|
|
1146
|
+
console.log(" Welcome to Harmon \u2014 Policy-driven music session manager");
|
|
1147
|
+
console.log(" This wizard will help you configure your music providers.");
|
|
1148
|
+
console.log("");
|
|
1149
|
+
const env = {};
|
|
1150
|
+
console.log("\u2500\u2500 Security \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
1151
|
+
const genSecret = await ask(" Generate encryption secret? (Y/n) ");
|
|
1152
|
+
if (genSecret.toLowerCase() !== "n") {
|
|
1153
|
+
const { randomBytes } = await import("node:crypto");
|
|
1154
|
+
env.HARMON_ENCRYPTION_SECRET = randomBytes(32).toString("base64");
|
|
1155
|
+
console.log(" \u2713 Encryption secret generated");
|
|
1156
|
+
}
|
|
1157
|
+
const genToken = await ask(" Generate API token? (Y/n) ");
|
|
1158
|
+
if (genToken.toLowerCase() !== "n") {
|
|
1159
|
+
const { randomBytes } = await import("node:crypto");
|
|
1160
|
+
env.HARMON_API_TOKEN = randomBytes(32).toString("base64");
|
|
1161
|
+
console.log(" \u2713 API token generated");
|
|
1162
|
+
}
|
|
1163
|
+
console.log("");
|
|
1164
|
+
console.log("\u2500\u2500 Spotify \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
1165
|
+
console.log(" Create an app at https://developer.spotify.com/dashboard");
|
|
1166
|
+
const spotifyId = await ask(" Client ID (or skip): ");
|
|
1167
|
+
if (spotifyId.trim()) {
|
|
1168
|
+
env.SPOTIFY_CLIENT_ID = spotifyId.trim();
|
|
1169
|
+
const spotifySecret = await ask(" Client Secret: ");
|
|
1170
|
+
if (spotifySecret.trim()) env.SPOTIFY_CLIENT_SECRET = spotifySecret.trim();
|
|
1171
|
+
env.SPOTIFY_REDIRECT_URI = "http://127.0.0.1:17373/v1/auth/spotify/callback";
|
|
1172
|
+
console.log(" \u2713 Spotify configured");
|
|
1173
|
+
} else {
|
|
1174
|
+
console.log(" \u2298 Skipped");
|
|
1175
|
+
}
|
|
1176
|
+
console.log("");
|
|
1177
|
+
console.log("\u2500\u2500 YouTube Music \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
1178
|
+
console.log(" Option 1: API key (search only) \u2014 https://console.cloud.google.com");
|
|
1179
|
+
console.log(" Option 2: OAuth (full access) \u2014 create OAuth credentials");
|
|
1180
|
+
const ytMode = await ask(" Setup mode? (api-key / oauth / skip): ");
|
|
1181
|
+
if (ytMode.trim() === "api-key") {
|
|
1182
|
+
const ytKey = await ask(" API Key: ");
|
|
1183
|
+
if (ytKey.trim()) {
|
|
1184
|
+
env.YT_API_KEY = ytKey.trim();
|
|
1185
|
+
console.log(" \u2713 YouTube configured (API key mode)");
|
|
1186
|
+
}
|
|
1187
|
+
} else if (ytMode.trim() === "oauth") {
|
|
1188
|
+
const ytClientId = await ask(" Client ID: ");
|
|
1189
|
+
if (ytClientId.trim()) {
|
|
1190
|
+
env.YOUTUBE_MUSIC_CLIENT_ID = ytClientId.trim();
|
|
1191
|
+
const ytSecret = await ask(" Client Secret (optional): ");
|
|
1192
|
+
if (ytSecret.trim()) env.YOUTUBE_MUSIC_CLIENT_SECRET = ytSecret.trim();
|
|
1193
|
+
env.YOUTUBE_MUSIC_REDIRECT_URI = "http://127.0.0.1:17373/v1/auth/youtube/callback";
|
|
1194
|
+
console.log(" \u2713 YouTube configured (OAuth mode)");
|
|
1195
|
+
}
|
|
1196
|
+
} else {
|
|
1197
|
+
console.log(" \u2298 Skipped");
|
|
1198
|
+
}
|
|
1199
|
+
console.log("");
|
|
1200
|
+
console.log("\u2500\u2500 Apple Music \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
1201
|
+
console.log(" Option 1: Static token \u2014 paste a developer JWT");
|
|
1202
|
+
console.log(" Option 2: Auto-JWT \u2014 provide signing key material");
|
|
1203
|
+
const appleMode = await ask(" Setup mode? (token / auto-jwt / skip): ");
|
|
1204
|
+
if (appleMode.trim() === "token") {
|
|
1205
|
+
const appleDev = await ask(" Developer Token: ");
|
|
1206
|
+
if (appleDev.trim()) {
|
|
1207
|
+
env.APPLE_MUSIC_DEVELOPER_TOKEN = appleDev.trim();
|
|
1208
|
+
const appleUser = await ask(" User Token (optional, for library): ");
|
|
1209
|
+
if (appleUser.trim()) env.APPLE_MUSIC_USER_TOKEN = appleUser.trim();
|
|
1210
|
+
console.log(" \u2713 Apple Music configured (static token)");
|
|
1211
|
+
}
|
|
1212
|
+
} else if (appleMode.trim() === "auto-jwt") {
|
|
1213
|
+
const teamId = await ask(" Team ID: ");
|
|
1214
|
+
const keyId = await ask(" Key ID: ");
|
|
1215
|
+
const keyPath = await ask(" Private key path (.p8 file): ");
|
|
1216
|
+
if (teamId.trim() && keyId.trim() && keyPath.trim()) {
|
|
1217
|
+
env.APPLE_MUSIC_TEAM_ID = teamId.trim();
|
|
1218
|
+
env.APPLE_MUSIC_KEY_ID = keyId.trim();
|
|
1219
|
+
try {
|
|
1220
|
+
const { readFileSync: readSync } = await import("node:fs");
|
|
1221
|
+
env.APPLE_MUSIC_PRIVATE_KEY = readSync(keyPath.trim(), "utf8").replace(/\n/g, "\\n");
|
|
1222
|
+
console.log(" \u2713 Apple Music configured (auto-JWT)");
|
|
1223
|
+
} catch {
|
|
1224
|
+
console.log(" \u2717 Could not read key file: " + keyPath.trim());
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
} else {
|
|
1228
|
+
console.log(" \u2298 Skipped");
|
|
1229
|
+
}
|
|
1230
|
+
console.log("");
|
|
1231
|
+
console.log("\u2500\u2500 Song Recognition (optional) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
1232
|
+
const auddToken = await ask(" AudD API token (or skip): ");
|
|
1233
|
+
if (auddToken.trim()) {
|
|
1234
|
+
env.AUDD_API_TOKEN = auddToken.trim();
|
|
1235
|
+
console.log(" \u2713 Song recognition configured");
|
|
1236
|
+
} else {
|
|
1237
|
+
console.log(" \u2298 Skipped (use Chromaprint for free recognition)");
|
|
1238
|
+
}
|
|
1239
|
+
console.log("");
|
|
1240
|
+
const lines = Object.entries(env).map(([k, v]) => `${k}=${v}`);
|
|
1241
|
+
const content = "# Generated by harmon init\n" + lines.join("\n") + "\n";
|
|
1242
|
+
await fs.writeFile(outputPath, content, { mode: 384 });
|
|
1243
|
+
console.log(` \u2713 Config written to ${outputPath} (permissions: 600)`);
|
|
1244
|
+
console.log("");
|
|
1245
|
+
console.log("\u2500\u2500 Next Steps \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
1246
|
+
console.log(` 1. Source the config: export $(cat ${outputPath} | xargs)`);
|
|
1247
|
+
console.log(" 2. Start the daemon: pnpm start:daemon");
|
|
1248
|
+
console.log(" 3. Check status: harmon status");
|
|
1249
|
+
if (env.SPOTIFY_CLIENT_ID) {
|
|
1250
|
+
console.log(" 4. Login to Spotify: harmon auth import --browser chrome");
|
|
1251
|
+
}
|
|
1252
|
+
if (env.YOUTUBE_MUSIC_CLIENT_ID) {
|
|
1253
|
+
console.log(" 5. Login to YouTube: harmon auth youtube login");
|
|
1254
|
+
}
|
|
1255
|
+
console.log("");
|
|
1256
|
+
rl.close();
|
|
1257
|
+
});
|
|
1258
|
+
program.command("status").description("Show daemon and playback status").action(async (...args) => {
|
|
1259
|
+
const command = args[args.length - 1];
|
|
1260
|
+
const { cli, opts } = createContext(command);
|
|
1261
|
+
const status = await cli.status();
|
|
1262
|
+
const sessionProvider = status.session?.provider;
|
|
1263
|
+
const activeProvider = sessionProvider || opts.provider;
|
|
1264
|
+
let nowPlaying = null;
|
|
1265
|
+
try {
|
|
1266
|
+
nowPlaying = await fetchNowPlaying(cli, activeProvider);
|
|
1267
|
+
} catch {
|
|
1268
|
+
nowPlaying = null;
|
|
1269
|
+
}
|
|
1270
|
+
outputResult(opts, { status, nowPlaying }, {
|
|
1271
|
+
plain: (data) => {
|
|
1272
|
+
const sessionId = data.status.session?.id || "";
|
|
1273
|
+
const sessionActive = data.status.session?.isActive ? "1" : "0";
|
|
1274
|
+
return `${data.status.isRunning ? "1" : "0"} ${data.status.spotifyConnected ? "1" : "0"} ${sessionId} ${sessionActive}`;
|
|
1275
|
+
},
|
|
1276
|
+
human: (data) => {
|
|
1277
|
+
const spotifyProvider = data.status.providers?.spotify;
|
|
1278
|
+
const appleProvider = data.status.providers?.apple;
|
|
1279
|
+
const youtubeProvider = data.status.providers?.youtube;
|
|
1280
|
+
const lines = [
|
|
1281
|
+
`daemon: ${data.status.isRunning ? "running" : "stopped"}`,
|
|
1282
|
+
spotifyProvider ? formatProviderStatusLine("spotify", spotifyProvider) : `spotify: ${data.status.spotifyConnected ? "connected" : "not connected"}`
|
|
1283
|
+
];
|
|
1284
|
+
if (appleProvider) {
|
|
1285
|
+
lines.push(formatProviderStatusLine("apple", appleProvider));
|
|
1286
|
+
}
|
|
1287
|
+
if (youtubeProvider) {
|
|
1288
|
+
lines.push(formatProviderStatusLine("youtube", youtubeProvider));
|
|
1289
|
+
}
|
|
1290
|
+
if (data.status.session) {
|
|
1291
|
+
const providerSuffix = data.status.session.provider ? `, provider: ${data.status.session.provider}` : "";
|
|
1292
|
+
lines.push(`session: ${data.status.session.id} (${data.status.session.isActive ? "active" : "idle"}${providerSuffix})`);
|
|
1293
|
+
}
|
|
1294
|
+
if (data.nowPlaying && data.nowPlaying.name) {
|
|
1295
|
+
lines.push(`now playing: ${data.nowPlaying.artist} - ${data.nowPlaying.name}`);
|
|
1296
|
+
}
|
|
1297
|
+
return lines.join("\n");
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
});
|
|
1301
|
+
program.action(async (...args) => {
|
|
1302
|
+
const command = args[args.length - 1];
|
|
1303
|
+
const { cli, opts } = createContext(command);
|
|
1304
|
+
const status = await cli.status();
|
|
1305
|
+
outputResult(opts, status);
|
|
1306
|
+
});
|
|
1307
|
+
var auth = program.command("auth").description("Authentication commands");
|
|
1308
|
+
auth.command("status").description("Show auth status").action(async (...args) => {
|
|
1309
|
+
const command = args[args.length - 1];
|
|
1310
|
+
const { cli, opts } = createContext(command);
|
|
1311
|
+
const status = await cli.status();
|
|
1312
|
+
outputResult(opts, status, {
|
|
1313
|
+
plain: (data) => `${data.spotifyConnected ? "1" : "0"}`,
|
|
1314
|
+
human: (data) => {
|
|
1315
|
+
const lines = [
|
|
1316
|
+
formatProviderStatusLine("spotify", data.providers?.spotify)
|
|
1317
|
+
];
|
|
1318
|
+
if (data.providers?.apple) {
|
|
1319
|
+
lines.push(formatProviderStatusLine("apple", data.providers.apple));
|
|
1320
|
+
}
|
|
1321
|
+
if (data.providers?.youtube) {
|
|
1322
|
+
lines.push(formatProviderStatusLine("youtube", data.providers.youtube));
|
|
1323
|
+
}
|
|
1324
|
+
return lines.join("\n");
|
|
1325
|
+
}
|
|
1326
|
+
});
|
|
1327
|
+
});
|
|
1328
|
+
auth.command("import").description("Import auth from browser cookies (OAuth fallback)").option("--browser <browser>", "browser to read", "chrome").option("--browser-profile <name>", "browser profile name").option("--cookie-path <file>", "cookie file path").option("--domain <host>", "cookie domain", "spotify.com").action(async (...args) => {
|
|
1329
|
+
const command = args[args.length - 1];
|
|
1330
|
+
const { cli, opts, endpoint } = createContext(command);
|
|
1331
|
+
assertSafeAuthImportEndpoint(endpoint);
|
|
1332
|
+
let cookies = [];
|
|
1333
|
+
if (command.opts().cookiePath) {
|
|
1334
|
+
cookies = normalizeCookies(await readCookieFile(command.opts().cookiePath));
|
|
1335
|
+
} else {
|
|
1336
|
+
await ensureSiloHelperAvailable();
|
|
1337
|
+
const exportResult = await runSiloExport({
|
|
1338
|
+
browser: command.opts().browser,
|
|
1339
|
+
browserProfile: command.opts().browserProfile,
|
|
1340
|
+
domain: command.opts().domain
|
|
1341
|
+
});
|
|
1342
|
+
cookies = normalizeCookies(exportResult.records || []);
|
|
1343
|
+
}
|
|
1344
|
+
if (!Array.isArray(cookies) || cookies.length === 0) {
|
|
1345
|
+
throw new CliUsageError("No cookies found. Try a different browser/profile or pass --cookie-path.");
|
|
1346
|
+
}
|
|
1347
|
+
const result = await cli.authImportCookies(cookies);
|
|
1348
|
+
outputResult(opts, result, {
|
|
1349
|
+
plain: () => "ok",
|
|
1350
|
+
human: () => `Imported ${cookies.length} cookies.`
|
|
1351
|
+
});
|
|
1352
|
+
});
|
|
1353
|
+
auth.command("clear").description("Clear auth").action(async (...args) => {
|
|
1354
|
+
const command = args[args.length - 1];
|
|
1355
|
+
const { cli, opts } = createContext(command);
|
|
1356
|
+
const result = await cli.authLogout();
|
|
1357
|
+
outputResult(opts, result, {
|
|
1358
|
+
plain: () => "ok",
|
|
1359
|
+
human: () => "Spotify auth cleared."
|
|
1360
|
+
});
|
|
1361
|
+
});
|
|
1362
|
+
var authYoutube = auth.command("youtube").description("YouTube Music authentication");
|
|
1363
|
+
authYoutube.command("login").description("Start YouTube Music OAuth login (opens browser)").action(async (...args) => {
|
|
1364
|
+
const command = args[args.length - 1];
|
|
1365
|
+
const { cli, opts } = createContext(command);
|
|
1366
|
+
const result = await cli.youtubeAuthLogin();
|
|
1367
|
+
outputResult(opts, result, {
|
|
1368
|
+
plain: (data) => data.url || "",
|
|
1369
|
+
human: (data) => data.url ? `Open this URL to authenticate:
|
|
1370
|
+
${data.url}` : "YouTube OAuth login initiated."
|
|
1371
|
+
});
|
|
1372
|
+
});
|
|
1373
|
+
authYoutube.command("refresh").description("Refresh YouTube Music access token").action(async (...args) => {
|
|
1374
|
+
const command = args[args.length - 1];
|
|
1375
|
+
const { cli, opts } = createContext(command);
|
|
1376
|
+
const result = await cli.youtubeAuthRefresh();
|
|
1377
|
+
outputResult(opts, result, {
|
|
1378
|
+
plain: () => "ok",
|
|
1379
|
+
human: () => "YouTube Music token refreshed."
|
|
1380
|
+
});
|
|
1381
|
+
});
|
|
1382
|
+
authYoutube.command("logout").description("Clear YouTube Music authentication").action(async (...args) => {
|
|
1383
|
+
const command = args[args.length - 1];
|
|
1384
|
+
const { cli, opts } = createContext(command);
|
|
1385
|
+
const result = await cli.youtubeAuthLogout();
|
|
1386
|
+
outputResult(opts, result, {
|
|
1387
|
+
plain: () => "ok",
|
|
1388
|
+
human: () => "YouTube Music auth cleared."
|
|
1389
|
+
});
|
|
1390
|
+
});
|
|
1391
|
+
var authApple = auth.command("apple").description("Apple Music authentication");
|
|
1392
|
+
authApple.command("set-token <token>").description("Set Apple Music user token (from MusicKit JS)").action(async (token, ...args) => {
|
|
1393
|
+
const command = args[args.length - 1];
|
|
1394
|
+
const { cli, opts } = createContext(command);
|
|
1395
|
+
if (!token || typeof token !== "string" || token.trim().length === 0) {
|
|
1396
|
+
throw new CliUsageError("User token is required.");
|
|
1397
|
+
}
|
|
1398
|
+
const result = await cli.appleAuthSetUserToken(token.trim());
|
|
1399
|
+
outputResult(opts, result, {
|
|
1400
|
+
plain: () => "ok",
|
|
1401
|
+
human: () => "Apple Music user token set."
|
|
1402
|
+
});
|
|
1403
|
+
});
|
|
1404
|
+
authApple.command("refresh").description("Refresh Apple Music developer token (requires key material)").action(async (...args) => {
|
|
1405
|
+
const command = args[args.length - 1];
|
|
1406
|
+
const { cli, opts } = createContext(command);
|
|
1407
|
+
const result = await cli.appleAuthRefresh();
|
|
1408
|
+
outputResult(opts, result, {
|
|
1409
|
+
plain: () => "ok",
|
|
1410
|
+
human: (data) => data.hasToken ? "Apple Music developer token refreshed." : "Apple Music developer token refresh failed (no key material configured)."
|
|
1411
|
+
});
|
|
1412
|
+
});
|
|
1413
|
+
authApple.command("logout").description("Clear Apple Music authentication").action(async (...args) => {
|
|
1414
|
+
const command = args[args.length - 1];
|
|
1415
|
+
const { cli, opts } = createContext(command);
|
|
1416
|
+
const result = await cli.appleAuthLogout();
|
|
1417
|
+
outputResult(opts, result, {
|
|
1418
|
+
plain: () => "ok",
|
|
1419
|
+
human: () => "Apple Music auth cleared."
|
|
1420
|
+
});
|
|
1421
|
+
});
|
|
1422
|
+
var search = program.command("search").description("Search catalog");
|
|
1423
|
+
function registerSearch(type) {
|
|
1424
|
+
search.command(`${type} <query>`).option("--limit <n>", "limit results", (value) => Number.parseInt(value, 10)).option("--offset <n>", "offset", (value) => Number.parseInt(value, 10)).action(async (query, ...args) => {
|
|
1425
|
+
const command = args[args.length - 1];
|
|
1426
|
+
const { cli, opts } = createContext(command);
|
|
1427
|
+
const result = await searchCatalog(cli, opts.provider, type, query, {
|
|
1428
|
+
limit: command.opts().limit,
|
|
1429
|
+
offset: command.opts().offset
|
|
1430
|
+
});
|
|
1431
|
+
outputResult(opts, result, searchOutputFormatters(type));
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
SPOTIFY_SEARCH_TYPES.forEach(registerSearch);
|
|
1435
|
+
var library = program.command("library").description("Browse provider library");
|
|
1436
|
+
library.command("tracks").description("List library or liked tracks").option("--limit <n>", "limit results", (value) => Number.parseInt(value, 10)).action(async (...args) => {
|
|
1437
|
+
const command = args[args.length - 1];
|
|
1438
|
+
const { cli, opts } = createContext(command);
|
|
1439
|
+
const limit = command.opts().limit;
|
|
1440
|
+
const tracks = opts.provider === "apple" ? await cli.appleLibraryTracks({ limit }) : opts.provider === "youtube" ? await cli.youtubeLibraryTracks({ limit }) : await cli.spotifyLibraryTracks({ limit });
|
|
1441
|
+
const normalized = normalizeTrackCollection(opts.provider, tracks);
|
|
1442
|
+
outputResult(opts, normalized, {
|
|
1443
|
+
human: (data) => formatTrackLines(data),
|
|
1444
|
+
plain: (data) => formatTrackPlain(data)
|
|
1445
|
+
});
|
|
1446
|
+
});
|
|
1447
|
+
var playlist = program.command("playlist").description("Browse provider playlists");
|
|
1448
|
+
playlist.command("list").description("List playlists for the selected provider").option("--limit <n>", "limit results", (value) => Number.parseInt(value, 10)).action(async (...args) => {
|
|
1449
|
+
const command = args[args.length - 1];
|
|
1450
|
+
const { cli, opts } = createContext(command);
|
|
1451
|
+
const limit = command.opts().limit;
|
|
1452
|
+
const playlists = opts.provider === "apple" ? await cli.applePlaylists({ limit }) : opts.provider === "youtube" ? await cli.youtubePlaylists({ limit }) : await cli.spotifyPlaylists({ limit });
|
|
1453
|
+
const normalized = normalizePlaylistCollection(opts.provider, playlists);
|
|
1454
|
+
outputResult(opts, normalized, {
|
|
1455
|
+
human: (data) => formatPlaylistLines(data),
|
|
1456
|
+
plain: (data) => formatPlaylistPlain(data)
|
|
1457
|
+
});
|
|
1458
|
+
});
|
|
1459
|
+
playlist.command("tracks <idOrUrl>").description("List tracks from a playlist").option("--limit <n>", "limit results", (value) => Number.parseInt(value, 10)).action(async (idOrUrl, ...args) => {
|
|
1460
|
+
const command = args[args.length - 1];
|
|
1461
|
+
const { cli, opts } = createContext(command);
|
|
1462
|
+
const limit = command.opts().limit;
|
|
1463
|
+
const playlistId = extractPlaylistId(opts.provider, idOrUrl);
|
|
1464
|
+
if (!playlistId) {
|
|
1465
|
+
throw new CliUsageError("Invalid playlist identifier.");
|
|
1466
|
+
}
|
|
1467
|
+
const tracks = opts.provider === "apple" ? await cli.applePlaylistTracks(playlistId, { limit }) : opts.provider === "youtube" ? await cli.youtubePlaylistTracks(playlistId, { limit }) : await cli.spotifyPlaylistTracks(playlistId, { limit });
|
|
1468
|
+
const normalized = normalizeTrackCollection(opts.provider, tracks);
|
|
1469
|
+
outputResult(opts, normalized, {
|
|
1470
|
+
human: (data) => formatTrackLines(data),
|
|
1471
|
+
plain: (data) => formatTrackPlain(data)
|
|
1472
|
+
});
|
|
1473
|
+
});
|
|
1474
|
+
program.command("recommend [seed]").description("Fetch recommended tracks for the selected provider").option("--limit <n>", "limit results", (value) => Number.parseInt(value, 10)).action(async (seed, ...args) => {
|
|
1475
|
+
const command = args[args.length - 1];
|
|
1476
|
+
const { cli, opts } = createContext(command);
|
|
1477
|
+
const limit = command.opts().limit;
|
|
1478
|
+
const tracks = opts.provider === "apple" ? await cli.appleRecommendations({ limit, seed }) : opts.provider === "youtube" ? await cli.youtubeRecommendations({ limit, seed }) : await cli.spotifyRecommendations({ limit, seed });
|
|
1479
|
+
const normalized = normalizeTrackCollection(opts.provider, tracks);
|
|
1480
|
+
outputResult(opts, normalized, {
|
|
1481
|
+
human: (data) => formatTrackLines(data),
|
|
1482
|
+
plain: (data) => formatTrackPlain(data)
|
|
1483
|
+
});
|
|
1484
|
+
});
|
|
1485
|
+
var session = program.command("session").description("Session lifecycle commands");
|
|
1486
|
+
session.command("start").description("Start a music session with a policy").option("--mode <mode>", "session mode: focus, relax, energize, meditate, workout, custom", "focus").option("--duration <dur>", "session duration (e.g. 30m, 1h)", "1h").option("--energy <n>", "target energy 0-1", parseFloat).option("--instrumental", "no vocals / instrumental only").action(async (...args) => {
|
|
1487
|
+
const command = args[args.length - 1];
|
|
1488
|
+
const { cli, opts } = createContext(command);
|
|
1489
|
+
const cmdOpts = command.opts();
|
|
1490
|
+
const mode = validateChoice(cmdOpts.mode, "mode", SESSION_MODES);
|
|
1491
|
+
const durationMs = parseSessionDurationOption(cmdOpts.duration);
|
|
1492
|
+
const energy = validateFraction(cmdOpts.energy, "energy");
|
|
1493
|
+
const policy = {
|
|
1494
|
+
version: 1,
|
|
1495
|
+
provider: opts.provider,
|
|
1496
|
+
mode,
|
|
1497
|
+
durationMs,
|
|
1498
|
+
hard: {},
|
|
1499
|
+
soft: { weights: {} }
|
|
1500
|
+
};
|
|
1501
|
+
if (energy !== void 0) {
|
|
1502
|
+
policy.soft.weights.energy = energy;
|
|
1503
|
+
}
|
|
1504
|
+
if (cmdOpts.instrumental) {
|
|
1505
|
+
policy.hard.noVocals = true;
|
|
1506
|
+
}
|
|
1507
|
+
const result = await cli.command({
|
|
1508
|
+
id: `c_${Date.now().toString(36)}`,
|
|
1509
|
+
ts: Date.now(),
|
|
1510
|
+
source: { kind: "cli", device: detectDeviceOS() },
|
|
1511
|
+
type: "session.start",
|
|
1512
|
+
payload: { policy }
|
|
1513
|
+
});
|
|
1514
|
+
outputResult(opts, result, {
|
|
1515
|
+
plain: (data) => data.sessionId || "ok",
|
|
1516
|
+
human: (data) => `Session started: ${data.sessionId || "ok"} (mode: ${mode}, provider: ${opts.provider})`
|
|
1517
|
+
});
|
|
1518
|
+
});
|
|
1519
|
+
session.command("stop").description("Stop the active session").action(async (...args) => {
|
|
1520
|
+
const command = args[args.length - 1];
|
|
1521
|
+
const { cli, opts } = createContext(command);
|
|
1522
|
+
const result = await cli.command({
|
|
1523
|
+
id: `c_${Date.now().toString(36)}`,
|
|
1524
|
+
ts: Date.now(),
|
|
1525
|
+
source: { kind: "cli", device: detectDeviceOS() },
|
|
1526
|
+
type: "session.stop",
|
|
1527
|
+
payload: {}
|
|
1528
|
+
});
|
|
1529
|
+
outputResult(opts, result, { plain: () => "ok", human: () => "Session stopped." });
|
|
1530
|
+
});
|
|
1531
|
+
session.command("nudge <direction>").description("Nudge session calmer or sharper").option("--amount <n>", "adjustment amount 0-1", parseFloat).action(async (direction, ...args) => {
|
|
1532
|
+
const command = args[args.length - 1];
|
|
1533
|
+
const { cli, opts } = createContext(command);
|
|
1534
|
+
if (direction !== "calmer" && direction !== "sharper") {
|
|
1535
|
+
throw new CliUsageError('Direction must be "calmer" or "sharper".');
|
|
1536
|
+
}
|
|
1537
|
+
const amount = validateFraction(command.opts().amount, "amount");
|
|
1538
|
+
const result = await cli.command({
|
|
1539
|
+
id: `c_${Date.now().toString(36)}`,
|
|
1540
|
+
ts: Date.now(),
|
|
1541
|
+
source: { kind: "cli", device: detectDeviceOS() },
|
|
1542
|
+
type: "session.nudge",
|
|
1543
|
+
payload: { direction, amount }
|
|
1544
|
+
});
|
|
1545
|
+
outputResult(opts, result, { plain: () => "ok", human: () => `Session nudged ${direction}.` });
|
|
1546
|
+
});
|
|
1547
|
+
program.command("play [idOrUrl]").description("Play a track/album/playlist").option("--type <type>", "type for raw Spotify IDs (track|album|playlist|artist|show|episode)").action(async (idOrUrl, ...args) => {
|
|
1548
|
+
const command = args[args.length - 1];
|
|
1549
|
+
const { cli, opts } = createContext(command);
|
|
1550
|
+
const provider = resolvePlaybackProvider(opts, idOrUrl);
|
|
1551
|
+
if (provider === "spotify") {
|
|
1552
|
+
await applyDeviceIfNeeded(cli, opts);
|
|
1553
|
+
}
|
|
1554
|
+
const typeOverride = command.opts().type;
|
|
1555
|
+
if (typeOverride && !["track", "album", "playlist", "artist", "show", "episode"].includes(typeOverride)) {
|
|
1556
|
+
throw new CliUsageError("Invalid type. Use track, album, playlist, artist, show, or episode.");
|
|
1557
|
+
}
|
|
1558
|
+
if (typeOverride && provider !== "spotify") {
|
|
1559
|
+
throw new CliUsageError("--type is only supported for raw Spotify IDs in this build.");
|
|
1560
|
+
}
|
|
1561
|
+
if (!idOrUrl) {
|
|
1562
|
+
if (provider === "apple") {
|
|
1563
|
+
const result3 = await cli.applePlay();
|
|
1564
|
+
outputResult(opts, result3, {
|
|
1565
|
+
plain: () => "ok",
|
|
1566
|
+
human: () => "Resumed Apple Music playback."
|
|
1567
|
+
});
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
if (provider === "youtube") {
|
|
1571
|
+
const result3 = await cli.youtubePlay();
|
|
1572
|
+
outputResult(opts, result3, {
|
|
1573
|
+
plain: () => "ok",
|
|
1574
|
+
human: () => "Opened queued YouTube Music track."
|
|
1575
|
+
});
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
const result2 = await cli.spotifyPlay();
|
|
1579
|
+
outputResult(opts, result2, {
|
|
1580
|
+
plain: () => "ok",
|
|
1581
|
+
human: () => "Resumed playback."
|
|
1582
|
+
});
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
if (provider === "apple") {
|
|
1586
|
+
const url = normalizeAppleMusicUrl(idOrUrl, opts.market);
|
|
1587
|
+
if (!url) {
|
|
1588
|
+
throw new CliUsageError("Invalid Apple Music URL or URI.");
|
|
1589
|
+
}
|
|
1590
|
+
const result2 = await cli.applePlay({ url });
|
|
1591
|
+
outputResult(opts, result2, {
|
|
1592
|
+
plain: () => url,
|
|
1593
|
+
human: () => `Playing Apple Music ${url}`
|
|
1594
|
+
});
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
if (provider === "youtube") {
|
|
1598
|
+
const youtubeUri = normalizeYouTubeUri(idOrUrl);
|
|
1599
|
+
if (!youtubeUri) {
|
|
1600
|
+
throw new CliUsageError("Invalid YouTube Music URL or URI.");
|
|
1601
|
+
}
|
|
1602
|
+
const result2 = await cli.youtubePlay({ uri: youtubeUri });
|
|
1603
|
+
outputResult(opts, result2, {
|
|
1604
|
+
plain: () => youtubeUri,
|
|
1605
|
+
human: () => `Opening ${youtubeUri}`
|
|
1606
|
+
});
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
const spotifyUri = normalizeSpotifyUri(idOrUrl, typeOverride);
|
|
1610
|
+
if (!spotifyUri) {
|
|
1611
|
+
throw new CliUsageError("Invalid Spotify URI or URL.");
|
|
1612
|
+
}
|
|
1613
|
+
const type = spotifyUriType(spotifyUri);
|
|
1614
|
+
const isContext = ["album", "playlist", "artist", "show"].includes(type);
|
|
1615
|
+
const result = await cli.spotifyPlay(isContext ? { contextUri: spotifyUri } : { uri: spotifyUri });
|
|
1616
|
+
outputResult(opts, result, {
|
|
1617
|
+
plain: () => spotifyUri,
|
|
1618
|
+
human: () => `Playing ${spotifyUri}`
|
|
1619
|
+
});
|
|
1620
|
+
});
|
|
1621
|
+
program.command("pause").description("Pause playback").action(async (...args) => {
|
|
1622
|
+
const command = args[args.length - 1];
|
|
1623
|
+
const { cli, opts } = createContext(command);
|
|
1624
|
+
if (opts.provider === "youtube") {
|
|
1625
|
+
throw new CliUsageError("YouTube Music pause is not supported in browser-handoff mode.");
|
|
1626
|
+
}
|
|
1627
|
+
if (opts.provider === "spotify") {
|
|
1628
|
+
await applyDeviceIfNeeded(cli, opts);
|
|
1629
|
+
}
|
|
1630
|
+
const result = opts.provider === "apple" ? await cli.applePause() : await cli.spotifyPause();
|
|
1631
|
+
outputResult(opts, result, {
|
|
1632
|
+
plain: () => "ok",
|
|
1633
|
+
human: () => opts.provider === "apple" ? "Apple Music paused." : "Paused."
|
|
1634
|
+
});
|
|
1635
|
+
});
|
|
1636
|
+
program.command("next").description("Skip to next track").action(async (...args) => {
|
|
1637
|
+
const command = args[args.length - 1];
|
|
1638
|
+
const { cli, opts } = createContext(command);
|
|
1639
|
+
if (opts.provider === "spotify") {
|
|
1640
|
+
await applyDeviceIfNeeded(cli, opts);
|
|
1641
|
+
}
|
|
1642
|
+
const result = opts.provider === "apple" ? await cli.appleNext() : opts.provider === "youtube" ? await cli.youtubeNext() : await cli.spotifyNext();
|
|
1643
|
+
outputResult(opts, result, {
|
|
1644
|
+
plain: () => "ok",
|
|
1645
|
+
human: () => opts.provider === "apple" ? "Apple Music next." : opts.provider === "youtube" ? "YouTube Music next." : "Skipped."
|
|
1646
|
+
});
|
|
1647
|
+
});
|
|
1648
|
+
program.command("prev").description("Skip to previous track").action(async (...args) => {
|
|
1649
|
+
const command = args[args.length - 1];
|
|
1650
|
+
const { cli, opts } = createContext(command);
|
|
1651
|
+
if (opts.provider === "spotify") {
|
|
1652
|
+
await applyDeviceIfNeeded(cli, opts);
|
|
1653
|
+
}
|
|
1654
|
+
const result = opts.provider === "apple" ? await cli.applePrev() : opts.provider === "youtube" ? await cli.youtubePrev() : await cli.spotifyPrev();
|
|
1655
|
+
outputResult(opts, result, {
|
|
1656
|
+
plain: () => "ok",
|
|
1657
|
+
human: () => opts.provider === "apple" ? "Apple Music previous." : opts.provider === "youtube" ? "YouTube Music previous." : "Previous track."
|
|
1658
|
+
});
|
|
1659
|
+
});
|
|
1660
|
+
program.command("seek <position>").description("Seek to a position (ms or mm:ss)").action(async (position, ...args) => {
|
|
1661
|
+
const command = args[args.length - 1];
|
|
1662
|
+
const { cli, opts } = createContext(command);
|
|
1663
|
+
if (opts.provider !== "spotify") {
|
|
1664
|
+
throw new CliUsageError("Seek is supported only for Spotify.");
|
|
1665
|
+
}
|
|
1666
|
+
await applyDeviceIfNeeded(cli, opts);
|
|
1667
|
+
const positionMs = parseSeekValue(position);
|
|
1668
|
+
if (positionMs === null) {
|
|
1669
|
+
throw new CliUsageError("Invalid seek position.");
|
|
1670
|
+
}
|
|
1671
|
+
const result = await cli.spotifySeek(positionMs);
|
|
1672
|
+
outputResult(opts, result, { plain: () => String(positionMs), human: () => `Seeked to ${positionMs}ms.` });
|
|
1673
|
+
});
|
|
1674
|
+
program.command("volume <percent>").description("Set volume 0-100").action(async (percent, ...args) => {
|
|
1675
|
+
const command = args[args.length - 1];
|
|
1676
|
+
const { cli, opts } = createContext(command);
|
|
1677
|
+
if (opts.provider !== "spotify") {
|
|
1678
|
+
throw new CliUsageError("Volume control is supported only for Spotify.");
|
|
1679
|
+
}
|
|
1680
|
+
await applyDeviceIfNeeded(cli, opts);
|
|
1681
|
+
const volumePercent = Number.parseInt(percent, 10);
|
|
1682
|
+
if (!Number.isFinite(volumePercent) || volumePercent < 0 || volumePercent > 100) {
|
|
1683
|
+
throw new CliUsageError("Volume must be 0-100.");
|
|
1684
|
+
}
|
|
1685
|
+
const result = await cli.spotifyVolume(volumePercent);
|
|
1686
|
+
outputResult(opts, result, { plain: () => String(volumePercent), human: () => `Volume ${volumePercent}%.` });
|
|
1687
|
+
});
|
|
1688
|
+
program.command("shuffle <state>").description("Shuffle on/off").action(async (state, ...args) => {
|
|
1689
|
+
const command = args[args.length - 1];
|
|
1690
|
+
const { cli, opts } = createContext(command);
|
|
1691
|
+
if (opts.provider !== "spotify") {
|
|
1692
|
+
throw new CliUsageError("Shuffle is supported only for Spotify.");
|
|
1693
|
+
}
|
|
1694
|
+
await applyDeviceIfNeeded(cli, opts);
|
|
1695
|
+
const normalized = state === "on" ? true : state === "off" ? false : null;
|
|
1696
|
+
if (normalized === null) {
|
|
1697
|
+
throw new CliUsageError("Shuffle state must be on or off.");
|
|
1698
|
+
}
|
|
1699
|
+
const result = await cli.spotifyShuffle(normalized);
|
|
1700
|
+
outputResult(opts, result, { plain: () => normalized ? "on" : "off", human: () => `Shuffle ${state}.` });
|
|
1701
|
+
});
|
|
1702
|
+
program.command("repeat <state>").description("Repeat off/track/context").action(async (state, ...args) => {
|
|
1703
|
+
const command = args[args.length - 1];
|
|
1704
|
+
const { cli, opts } = createContext(command);
|
|
1705
|
+
if (opts.provider !== "spotify") {
|
|
1706
|
+
throw new CliUsageError("Repeat is supported only for Spotify.");
|
|
1707
|
+
}
|
|
1708
|
+
await applyDeviceIfNeeded(cli, opts);
|
|
1709
|
+
if (!["off", "track", "context"].includes(state)) {
|
|
1710
|
+
throw new CliUsageError("Repeat state must be off, track, or context.");
|
|
1711
|
+
}
|
|
1712
|
+
const result = await cli.spotifyRepeat(state);
|
|
1713
|
+
outputResult(opts, result, { plain: () => state, human: () => `Repeat ${state}.` });
|
|
1714
|
+
});
|
|
1715
|
+
var device = program.command("device").description("Device commands");
|
|
1716
|
+
device.command("list").description("List devices").action(async (...args) => {
|
|
1717
|
+
const command = args[args.length - 1];
|
|
1718
|
+
const { cli, opts } = createContext(command);
|
|
1719
|
+
if (opts.provider !== "spotify") {
|
|
1720
|
+
throw new CliUsageError("Device listing is only supported for Spotify Connect.");
|
|
1721
|
+
}
|
|
1722
|
+
const devices = await cli.devices();
|
|
1723
|
+
outputResult(opts, devices, {
|
|
1724
|
+
plain: (data) => Array.isArray(data) ? data.map((entry) => `${entry.id} ${entry.name} ${entry.type} ${entry.isActive ? "1" : "0"}`).join("\n") : "",
|
|
1725
|
+
human: (data) => Array.isArray(data) ? data.map((entry) => `${entry.isActive ? "*" : " "} ${entry.name} (${entry.type}) ${entry.id}`).join("\n") : "No devices."
|
|
1726
|
+
});
|
|
1727
|
+
});
|
|
1728
|
+
device.command("set <nameOrId>").description("Select device").action(async (nameOrId, ...args) => {
|
|
1729
|
+
const command = args[args.length - 1];
|
|
1730
|
+
const { cli, opts } = createContext(command);
|
|
1731
|
+
if (opts.provider !== "spotify") {
|
|
1732
|
+
throw new CliUsageError("Device selection is only supported for Spotify Connect.");
|
|
1733
|
+
}
|
|
1734
|
+
const deviceId = await resolveDevice(cli, nameOrId);
|
|
1735
|
+
if (!deviceId) {
|
|
1736
|
+
throw new CliUsageError(`Unknown device: ${nameOrId}`);
|
|
1737
|
+
}
|
|
1738
|
+
const result = await cli.useDevice(deviceId);
|
|
1739
|
+
outputResult(opts, result, { plain: () => deviceId, human: () => "Device switched." });
|
|
1740
|
+
});
|
|
1741
|
+
var queue = program.command("queue").description("Queue commands");
|
|
1742
|
+
queue.command("add <idOrUrl>").description("Add track to queue").action(async (idOrUrl, ...args) => {
|
|
1743
|
+
const command = args[args.length - 1];
|
|
1744
|
+
const { cli, opts } = createContext(command);
|
|
1745
|
+
if (opts.provider === "apple") {
|
|
1746
|
+
throw new CliUsageError("Queue management is not supported for Apple Music in this build.");
|
|
1747
|
+
}
|
|
1748
|
+
if (opts.provider === "spotify") {
|
|
1749
|
+
await applyDeviceIfNeeded(cli, opts);
|
|
1750
|
+
}
|
|
1751
|
+
if (opts.provider === "youtube") {
|
|
1752
|
+
const youtubeUri = normalizeYouTubeUri(idOrUrl);
|
|
1753
|
+
if (!youtubeUri) {
|
|
1754
|
+
throw new CliUsageError("Invalid YouTube Music URL or URI.");
|
|
1755
|
+
}
|
|
1756
|
+
const result2 = await cli.youtubeQueueAdd(youtubeUri);
|
|
1757
|
+
outputResult(opts, result2, { plain: () => youtubeUri, human: () => `Queued ${youtubeUri}` });
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
const spotifyUri = normalizeSpotifyUri(idOrUrl, "track");
|
|
1761
|
+
if (!spotifyUri) {
|
|
1762
|
+
throw new CliUsageError("Invalid Spotify URI or URL.");
|
|
1763
|
+
}
|
|
1764
|
+
const result = await cli.spotifyQueueAdd(spotifyUri);
|
|
1765
|
+
outputResult(opts, result, { plain: () => spotifyUri, human: () => `Queued ${spotifyUri}` });
|
|
1766
|
+
});
|
|
1767
|
+
program.command("devices").description("List devices (legacy)").action(async (...args) => {
|
|
1768
|
+
const command = args[args.length - 1];
|
|
1769
|
+
const { cli, opts } = createContext(command);
|
|
1770
|
+
if (opts.provider !== "spotify") {
|
|
1771
|
+
throw new CliUsageError("Device listing is only supported for Spotify Connect.");
|
|
1772
|
+
}
|
|
1773
|
+
const devices = await cli.devices();
|
|
1774
|
+
outputResult(opts, devices);
|
|
1775
|
+
});
|
|
1776
|
+
program.command("use <device-id>").description("Select device (legacy)").action(async (deviceId, ...args) => {
|
|
1777
|
+
const command = args[args.length - 1];
|
|
1778
|
+
const { cli, opts } = createContext(command);
|
|
1779
|
+
if (opts.provider !== "spotify") {
|
|
1780
|
+
throw new CliUsageError("Device selection is only supported for Spotify Connect.");
|
|
1781
|
+
}
|
|
1782
|
+
const result = await cli.useDevice(deviceId);
|
|
1783
|
+
outputResult(opts, result, { plain: () => deviceId, human: () => "Device switched." });
|
|
1784
|
+
});
|
|
1785
|
+
program.command("smart-play <query>").description("Search all connected providers and play the best match").action(async (query, ...args) => {
|
|
1786
|
+
const command = args[args.length - 1];
|
|
1787
|
+
const { cli, opts } = createContext(command);
|
|
1788
|
+
const result = await cli.smartPlay({ query });
|
|
1789
|
+
outputResult(opts, result, {
|
|
1790
|
+
plain: (data) => data.track ? `${data.provider} ${data.track.name} ${data.track.artist}` : "not found",
|
|
1791
|
+
human: (data) => {
|
|
1792
|
+
if (!data.success && data.needsAuth) {
|
|
1793
|
+
return `${data.provider} needs authentication.
|
|
1794
|
+
${data.authUrl ? `Open: ${data.authUrl}` : `Use: harmon auth ${data.provider} login`}`;
|
|
1795
|
+
}
|
|
1796
|
+
if (!data.success) {
|
|
1797
|
+
return data.error || "Playback failed.";
|
|
1798
|
+
}
|
|
1799
|
+
const track = data.track;
|
|
1800
|
+
return track ? `Now playing on ${data.provider}: ${track.artist} - ${track.name} (${track.album})` : `Playing on ${data.provider}.`;
|
|
1801
|
+
}
|
|
1802
|
+
});
|
|
1803
|
+
});
|
|
1804
|
+
program.command("smart-search <query>").description("Search all connected providers for a track").action(async (query, ...args) => {
|
|
1805
|
+
const command = args[args.length - 1];
|
|
1806
|
+
const { cli, opts } = createContext(command);
|
|
1807
|
+
const result = await cli.smartSearch(query);
|
|
1808
|
+
outputResult(opts, result, {
|
|
1809
|
+
plain: (data) => {
|
|
1810
|
+
if (!data.results || data.results.length === 0) return "no results";
|
|
1811
|
+
return data.results.flatMap((r) => r.tracks.map((t) => `${r.provider} ${t.name} ${t.artist}`)).join("\n");
|
|
1812
|
+
},
|
|
1813
|
+
human: (data) => {
|
|
1814
|
+
if (!data.results || data.results.length === 0) return "No results found.";
|
|
1815
|
+
const lines = [];
|
|
1816
|
+
for (const r of data.results) {
|
|
1817
|
+
lines.push(`\u2500\u2500 ${r.provider} \u2500\u2500`);
|
|
1818
|
+
for (const t of r.tracks) {
|
|
1819
|
+
lines.push(` ${t.artist} - ${t.name} (${t.album})`);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
if (data.unavailable && data.unavailable.length > 0) {
|
|
1823
|
+
lines.push("");
|
|
1824
|
+
for (const u of data.unavailable) {
|
|
1825
|
+
lines.push(`${u.provider}: ${u.reason}${u.authUrl ? ` (${u.authUrl})` : ""}`);
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
return lines.join("\n");
|
|
1829
|
+
}
|
|
1830
|
+
});
|
|
1831
|
+
});
|
|
1832
|
+
program.command("listen").description("Listen to ambient audio, recognize the song, and optionally play it").option("--duration <seconds>", "recording duration in seconds", "5").option("--play", "play the recognized song via smart-play").option("--provider <name>", "preferred provider for playback").option("--backend <name>", "recognition backend: audd, chromaprint, or auto (default: auto)").action(async (...args) => {
|
|
1833
|
+
const command = args[args.length - 1];
|
|
1834
|
+
const { cli, opts } = createContext(command);
|
|
1835
|
+
const cmdOpts = command.opts();
|
|
1836
|
+
const duration = Number.parseInt(cmdOpts.duration, 10) || 5;
|
|
1837
|
+
const backend = cmdOpts.backend || "auto";
|
|
1838
|
+
if (duration < 3 || duration > 30) {
|
|
1839
|
+
throw new CliUsageError("Duration must be between 3 and 30 seconds.");
|
|
1840
|
+
}
|
|
1841
|
+
if (!["auto", "audd", "chromaprint"].includes(backend)) {
|
|
1842
|
+
throw new CliUsageError("Backend must be one of: auto, audd, chromaprint.");
|
|
1843
|
+
}
|
|
1844
|
+
if (!opts.quiet) {
|
|
1845
|
+
const backendLabel = backend === "auto" ? "" : ` (${backend})`;
|
|
1846
|
+
process.stderr.write(`Listening for ${duration} seconds${backendLabel}...
|
|
1847
|
+
`);
|
|
1848
|
+
}
|
|
1849
|
+
const result = await listen({ duration, backend });
|
|
1850
|
+
if (!result.recognized) {
|
|
1851
|
+
outputResult(opts, { recognized: false }, {
|
|
1852
|
+
plain: () => "",
|
|
1853
|
+
human: () => "Could not recognize the song. Try again with less background noise or a longer duration (--duration 10)."
|
|
1854
|
+
});
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
if (!opts.quiet) {
|
|
1858
|
+
const via = result.backend === "chromaprint" ? " (via Chromaprint)" : "";
|
|
1859
|
+
process.stderr.write(`Recognized${via}: ${result.artist} - ${result.title}
|
|
1860
|
+
`);
|
|
1861
|
+
}
|
|
1862
|
+
if (cmdOpts.play) {
|
|
1863
|
+
const playUri = result.spotify?.uri;
|
|
1864
|
+
const playQuery = `${result.artist} ${result.title}`;
|
|
1865
|
+
const playResult = await cli.smartPlay({
|
|
1866
|
+
uri: playUri,
|
|
1867
|
+
query: playUri ? void 0 : playQuery,
|
|
1868
|
+
provider: cmdOpts.provider || opts.provider
|
|
1869
|
+
});
|
|
1870
|
+
outputResult(opts, { ...result, playback: playResult }, {
|
|
1871
|
+
plain: (data) => `${data.artist} ${data.title} ${data.album} ${data.playback?.provider || ""}`,
|
|
1872
|
+
human: (data) => {
|
|
1873
|
+
const lines = [
|
|
1874
|
+
`${data.artist} \u2014 ${data.title}`,
|
|
1875
|
+
data.album ? `Album: ${data.album}` : null,
|
|
1876
|
+
data.releaseDate ? `Released: ${data.releaseDate}` : null,
|
|
1877
|
+
data.playback?.success ? `Now playing on ${data.playback.provider}` : null
|
|
1878
|
+
].filter(Boolean);
|
|
1879
|
+
return lines.join("\n");
|
|
1880
|
+
}
|
|
1881
|
+
});
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
outputResult(opts, result, {
|
|
1885
|
+
plain: (data) => `${data.artist} ${data.title} ${data.album}`,
|
|
1886
|
+
human: (data) => {
|
|
1887
|
+
const lines = [
|
|
1888
|
+
`${data.artist} \u2014 ${data.title}`,
|
|
1889
|
+
data.album ? `Album: ${data.album}` : null,
|
|
1890
|
+
data.releaseDate ? `Released: ${data.releaseDate}` : null,
|
|
1891
|
+
data.spotify ? `Spotify: ${data.spotify.uri}` : null,
|
|
1892
|
+
data.apple ? `Apple Music: ${data.apple.url}` : null
|
|
1893
|
+
].filter(Boolean);
|
|
1894
|
+
return lines.join("\n");
|
|
1895
|
+
}
|
|
1896
|
+
});
|
|
1897
|
+
});
|
|
1898
|
+
async function main() {
|
|
1899
|
+
try {
|
|
1900
|
+
await program.parseAsync(process.argv);
|
|
1901
|
+
} catch (error) {
|
|
1902
|
+
const details = classifyCliError(error, process.argv);
|
|
1903
|
+
if (details.exitCode === 0) {
|
|
1904
|
+
process.exitCode = 0;
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
if (details.json) {
|
|
1908
|
+
console.error(JSON.stringify({ error: details.message, exitCode: details.exitCode }));
|
|
1909
|
+
} else {
|
|
1910
|
+
console.error(`Error: ${details.message}`);
|
|
1911
|
+
if (details.exitCode === 4 && details.message.includes("fetch failed")) {
|
|
1912
|
+
console.error(" Start it with: harmond");
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
process.exitCode = details.exitCode;
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
process.on("SIGINT", () => {
|
|
1919
|
+
process.exit(130);
|
|
1920
|
+
});
|
|
1921
|
+
var isMain = import.meta.url === pathToFileURL(process.argv[1] || "").href;
|
|
1922
|
+
if (isMain) {
|
|
1923
|
+
main();
|
|
1924
|
+
}
|