@audiofool/moondog 0.1.0 → 0.2.0-beta.2
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 +202 -0
- package/README.md +153 -68
- package/contracts/v1/definitions.schema.json +1 -169
- package/package.json +7 -23
- package/scripts/contract-lib.mjs +0 -7
- package/scripts/contract-semantics.mjs +0 -1904
- package/scripts/moondog.mjs +285 -74
- package/src/core/apple-library-source-status.mjs +0 -16
- package/src/core/apple-projection-domain-services.mjs +168 -36
- package/src/core/application-update.mjs +320 -0
- package/src/core/capability-catalog.mjs +278 -55
- package/src/core/listening-now.mjs +108 -0
- package/src/core/listening-profile-domain-services.mjs +153 -41
- package/src/core/listening-upkeep.mjs +31 -0
- package/src/core/moondog-application.mjs +1908 -104
- package/src/core/playback-preferences.mjs +97 -0
- package/src/core/spotify-listening-intent.mjs +121 -0
- package/src/core/synthetic-domain-services.mjs +24 -3
- package/src/demo/moondog-demo.mjs +4 -22
- package/src/i18n/index.mjs +285 -0
- package/src/i18n/messages/de.mjs +128 -0
- package/src/i18n/messages/en.mjs +129 -0
- package/src/i18n/messages/es.mjs +128 -0
- package/src/i18n/messages/fr.mjs +128 -0
- package/src/i18n/messages/it.mjs +128 -0
- package/src/i18n/messages/ja.mjs +128 -0
- package/src/i18n/messages/ko.mjs +128 -0
- package/src/i18n/messages/pt.mjs +128 -0
- package/src/i18n/messages/zh-Hant.mjs +128 -0
- package/src/i18n/messages/zh.mjs +128 -0
- package/src/i18n/preferences.mjs +34 -0
- package/src/i18n/screen/es.mjs +966 -0
- package/src/i18n/screen/ja.mjs +966 -0
- package/src/i18n/screen/pt.mjs +966 -0
- package/src/i18n/screen/zh.mjs +966 -0
- package/src/importers/apple-music-library/index.mjs +0 -16
- package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
- package/src/integrations/apple-music/catalog.mjs +123 -14
- package/src/integrations/apple-music/music-app.mjs +232 -0
- package/src/integrations/apple-music/storefront.mjs +28 -0
- package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
- package/src/integrations/lastfm/history.mjs +221 -0
- package/src/integrations/lastfm/settings.mjs +75 -0
- package/src/integrations/netease/ncm-cli-player.mjs +321 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
- package/src/integrations/public-playlists.mjs +2 -1
- package/src/integrations/spotify/account-data-profile.mjs +14 -6
- package/src/integrations/spotify/authentication.mjs +149 -15
- package/src/integrations/spotify/catalog-resolver.mjs +8 -4
- package/src/integrations/spotify/connection.mjs +2 -1
- package/src/integrations/spotify/credential-store.mjs +4 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
- package/src/integrations/spotify/history-archive.mjs +1 -1
- package/src/integrations/spotify/library-snapshot.mjs +227 -0
- package/src/integrations/spotify/recent-activity.mjs +2 -0
- package/src/integrations/spotify/service.mjs +873 -69
- package/src/integrations/spotify/web-api-client.mjs +407 -127
- package/src/integrations/web/brave-web.mjs +45 -0
- package/src/integrations/web/codex-web.mjs +15 -82
- package/src/integrations/web/deepseek-web.mjs +81 -0
- package/src/integrations/web/web-research.mjs +119 -0
- package/src/integrations/web/web-result.mjs +215 -0
- package/src/integrations/youtube-music/takeout.mjs +3 -1
- package/src/memory/local-memory-store.mjs +469 -41
- package/src/profile/history-import.mjs +42 -6
- package/src/profile/listener-profile-build.mjs +378 -0
- package/src/profile/listener-profile-store.mjs +130 -0
- package/src/profile/listener-profile-verification.mjs +68 -0
- package/src/profile/listener-profile-worker.mjs +172 -0
- package/src/profile/listening-history-store.mjs +424 -8
- package/src/profile/listening-profile-projection.mjs +242 -76
- package/src/profile/local-music-data.mjs +1 -1
- package/src/profile/music-providers.mjs +11 -7
- package/src/profile/profile-exploration.mjs +84 -0
- package/src/profile/recorded-measures.mjs +24 -0
- package/src/profile/spotify-archive-taste.mjs +0 -4
- package/src/runtime/pi/agent-runtime.mjs +2204 -605
- package/src/runtime/pi/authentication.mjs +34 -1
- package/src/runtime/pi/configured-runtime.mjs +22 -6
- package/src/runtime/pi/conversation-name.mjs +53 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
- package/src/runtime/pi/model-catalog.mjs +28 -5
- package/src/runtime/pi/model-settings.mjs +202 -0
- package/src/runtime/pi/model-transport.mjs +10 -0
- package/src/runtime/pi/persistent-credential-store.mjs +67 -31
- package/src/runtime/pi/profile-build-runtime.mjs +160 -0
- package/src/runtime/pi/public-model-catalog.mjs +288 -0
- package/src/runtime/pi/runtime-settings.mjs +10 -2
- package/src/runtime/pi/skills/listener-profile/SKILL.md +46 -0
- package/src/runtime/pi/tool-trace.mjs +37 -0
- package/src/surfaces/cli/auth-command.mjs +12 -4
- package/src/surfaces/cli/brand-components.mjs +310 -132
- package/src/surfaces/cli/brand-theme.mjs +51 -28
- package/src/surfaces/cli/command-completions.mjs +58 -42
- package/src/surfaces/cli/format-output.mjs +199 -388
- package/src/surfaces/cli/history-import-view.mjs +292 -139
- package/src/surfaces/cli/import-guides.mjs +2 -0
- package/src/surfaces/cli/listener-profile-command.mjs +92 -0
- package/src/surfaces/cli/listening-menu.mjs +7 -4
- package/src/surfaces/cli/profile-command.mjs +30 -21
- package/src/surfaces/cli/seasons-chart.mjs +87 -0
- package/src/surfaces/cli/spotify-command.mjs +54 -6
- package/src/surfaces/cli/taste-profile-model.mjs +78 -79
- package/src/surfaces/cli/taste-profile-view.mjs +33 -26
- package/src/surfaces/cli/terminal-art.mjs +140 -35
- package/src/surfaces/cli/tui.mjs +1066 -357
- package/src/surfaces/cli/update-command.mjs +32 -0
- package/src/surfaces/cli/web-command.mjs +53 -14
- package/src/surfaces/html/tasteprint.mjs +43 -70
- package/contracts/v1/capability-policy.schema.json +0 -572
- package/contracts/v1/profile-evidence.schema.json +0 -237
- package/contracts/v1/tool-invocation.schema.json +0 -318
- package/contracts/v1/tool-receipt.schema.json +0 -1066
- package/scripts/demo-studio.mjs +0 -58
- package/src/core/apple-data-migration.mjs +0 -50
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
- package/src/demo/moondog-tasteprint-demo.mjs +0 -631
- package/src/integrations/spotify/index.mjs +0 -80
- package/src/surfaces/web/showcase.mjs +0 -272
- package/src/surfaces/web/studio-page.mjs +0 -4148
- package/src/surfaces/web/studio.mjs +0 -2288
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { access, lstat, mkdir, mkdtemp, readFile, readlink, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { constants, realpathSync } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { resolveMoondogSettingsFile } from "../runtime/pi/runtime-settings.mjs";
|
|
9
|
+
import { resolveMoondogStateDirectory } from "./state-directory.mjs";
|
|
10
|
+
|
|
11
|
+
const exec = promisify(execFile);
|
|
12
|
+
export const UPDATE_PACKAGE = "@audiofool/moondog";
|
|
13
|
+
const REPOSITORY = "https://github.com/Audiofool934/moondog";
|
|
14
|
+
const DEFAULT_ROOT = fileURLToPath(new URL("../../", import.meta.url));
|
|
15
|
+
const CACHE_MS = 24 * 60 * 60_000;
|
|
16
|
+
const failure = (code, message) => Object.assign(new Error(message), { code });
|
|
17
|
+
const exists = async file => { try { await lstat(file); return true; } catch (error) { if (error.code === "ENOENT") return false; throw error; } };
|
|
18
|
+
const readJson = async file => JSON.parse(await readFile(file, "utf8"));
|
|
19
|
+
const alive = pid => { try { process.kill(pid, 0); return true; } catch (error) { return error.code !== "ESRCH"; } };
|
|
20
|
+
async function resolvedPath(file) {
|
|
21
|
+
try { return await realpath(file); } catch (error) {
|
|
22
|
+
if (error.code !== "ENOENT") throw error;
|
|
23
|
+
const parent = path.dirname(file);
|
|
24
|
+
return parent === file ? file : path.join(await resolvedPath(parent), path.basename(file));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const inside = (root, file) => file === root || file.startsWith(root + path.sep);
|
|
28
|
+
|
|
29
|
+
export function releaseVersion(value) {
|
|
30
|
+
if (typeof value !== "string") return null;
|
|
31
|
+
const match = value.match(/^(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})(?:-beta\.(0|[1-9]\d{0,5}))?$/u);
|
|
32
|
+
return match ? [...match.slice(1, 4).map(Number), match[4] === undefined ? Infinity : Number(match[4])] : null;
|
|
33
|
+
}
|
|
34
|
+
export function compareReleaseVersions(a, b) {
|
|
35
|
+
const left = releaseVersion(a), right = releaseVersion(b);
|
|
36
|
+
if (!left || !right) throw failure("update_version_invalid", "The release version is unsupported.");
|
|
37
|
+
for (let index = 0; index < left.length; index++) if (left[index] !== right[index]) return left[index] > right[index] ? 1 : -1;
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
function channelName(value) {
|
|
41
|
+
if (!["latest", "beta"].includes(value)) throw failure("update_channel_invalid", "Choose the latest or beta update channel.");
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
async function atomicJson(file, value) {
|
|
45
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
46
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
47
|
+
try { await writeFile(temporary, JSON.stringify(value) + "\n", { mode: 0o600 }); await rename(temporary, file); }
|
|
48
|
+
finally { await rm(temporary, { force: true }); }
|
|
49
|
+
}
|
|
50
|
+
async function command(binary, args, options = {}) {
|
|
51
|
+
return (await exec(binary, args, { encoding: "utf8", timeout: 120_000, maxBuffer: 2 * 1024 * 1024, ...options })).stdout.trim();
|
|
52
|
+
}
|
|
53
|
+
async function boundedBody(response, maximum) {
|
|
54
|
+
const chunks = [];
|
|
55
|
+
let bytes = 0;
|
|
56
|
+
for await (const chunk of response.body) {
|
|
57
|
+
bytes += chunk.length;
|
|
58
|
+
if (bytes > maximum) throw new Error("Update download exceeds its size limit");
|
|
59
|
+
chunks.push(chunk);
|
|
60
|
+
}
|
|
61
|
+
return Buffer.concat(chunks);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Covers older installations that do not know about the update lock. Only PID
|
|
65
|
+
// values leave this function; process arguments are never logged or persisted.
|
|
66
|
+
export async function activeInstallationProcesses(root, { run = command, ownPid = process.pid, platform = process.platform } = {}) {
|
|
67
|
+
const output = await run("ps", ["-axo", "pid=,comm=,args="], { timeout: 5_000 });
|
|
68
|
+
const active = [];
|
|
69
|
+
for (const line of output.split("\n")) {
|
|
70
|
+
const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.+)$/u);
|
|
71
|
+
if (!match || Number(match[1]) === ownPid) continue;
|
|
72
|
+
const exactLauncher = match[3].includes(path.join(root, "scripts", "moondog"));
|
|
73
|
+
if (!exactLauncher && !/^(?:node(?:js|\d+(?:\.\d+)*)?|moondog)$/u.test(path.basename(match[2]))) continue;
|
|
74
|
+
if (!/(?:^|[ /])(?:scripts\/)?moondog(?:\.mjs)?(?:\s|$)/u.test(match[3])) continue;
|
|
75
|
+
let cwd = "";
|
|
76
|
+
if (!match[3].includes(path.join(root, "scripts", "moondog"))) {
|
|
77
|
+
try {
|
|
78
|
+
cwd = platform === "linux" ? await readlink(`/proc/${match[1]}/cwd`) :
|
|
79
|
+
(await run("lsof", ["-a", "-p", match[1], "-d", "cwd", "-Fn"], { timeout: 3_000 })).split("\n").find(value => value.startsWith("n"))?.slice(1);
|
|
80
|
+
} catch { throw failure("update_session_check_failed", "Could not check another Moondog process. Close other sessions and retry."); }
|
|
81
|
+
if (cwd !== root) continue;
|
|
82
|
+
}
|
|
83
|
+
active.push(Number(match[1]));
|
|
84
|
+
}
|
|
85
|
+
return active;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createApplicationUpdater({ root = DEFAULT_ROOT, environment = process.env, fetchImpl = globalThis.fetch,
|
|
89
|
+
run = command, now = Date.now, activeProcesses, node = process.execPath, npm = "npm" } = {}) {
|
|
90
|
+
root = realpathSync(path.resolve(root));
|
|
91
|
+
const storage = path.join(path.dirname(resolveMoondogSettingsFile(environment)), "updates");
|
|
92
|
+
// Installation-wide, even when two sessions use different config homes.
|
|
93
|
+
const lock = path.join(path.dirname(root), ".moondog-update-" + createHash("sha256").update(root).digest("hex").slice(0, 24) + ".lock");
|
|
94
|
+
const recovery = lock + ".recovery.json";
|
|
95
|
+
const settings = path.join(storage, "settings.json");
|
|
96
|
+
const git = (args, options = {}) => run("git", ["-C", root, ...args], { env: environment, ...options });
|
|
97
|
+
const inspectProcesses = () => activeProcesses ? activeProcesses(root) : activeInstallationProcesses(root, { run });
|
|
98
|
+
const packageInfo = async () => {
|
|
99
|
+
const value = await readJson(path.join(root, "package.json"));
|
|
100
|
+
if (value.name !== UPDATE_PACKAGE || !releaseVersion(value.version)) throw failure("update_installation_invalid", "This is not a supported Moondog installation.");
|
|
101
|
+
return value;
|
|
102
|
+
};
|
|
103
|
+
async function assertDataOutsideReplacement(mode) {
|
|
104
|
+
const replaced = mode === "npm" ? root : path.join(root, "node_modules");
|
|
105
|
+
const canonical = await resolvedPath(replaced);
|
|
106
|
+
const personal = [path.dirname(resolveMoondogSettingsFile(environment)), resolveMoondogStateDirectory(environment),
|
|
107
|
+
environment.MOONDOG_APPLE_IMPORTS_ROOT, environment.MOONDOG_APPLE_PROJECTION_PATH].filter(Boolean);
|
|
108
|
+
for (const file of personal) {
|
|
109
|
+
if (inside(replaced, path.resolve(file)) || inside(canonical, await resolvedPath(path.resolve(file)))) {
|
|
110
|
+
throw failure("update_data_inside_installation", "Personal data is configured inside the program directory being replaced. Move that data and update its configured path before updating; nothing was replaced.");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (mode === "npm" && await exists(path.join(root, "data"))) throw failure("update_data_inside_installation", "Legacy data exists inside this installation. Move it to the configured state directory before updating.");
|
|
114
|
+
}
|
|
115
|
+
async function installation() {
|
|
116
|
+
const info = await packageInfo();
|
|
117
|
+
if (await exists(path.join(root, ".git"))) {
|
|
118
|
+
if (await git(["rev-parse", "--show-toplevel"]) !== root) throw failure("update_installation_invalid", "The source checkout root is inconsistent.");
|
|
119
|
+
return { mode: "source", root, version: info.version };
|
|
120
|
+
}
|
|
121
|
+
const prefix = path.resolve(root, "../../../..");
|
|
122
|
+
if (path.join(prefix, "lib/node_modules/@audiofool/moondog") === root) {
|
|
123
|
+
try {
|
|
124
|
+
if (await realpath(path.join(prefix, "bin/moondog")) === path.join(root, "scripts/moondog")) return { mode: "npm", root, prefix, version: info.version };
|
|
125
|
+
} catch { /* Local installs and unrelated launchers are not managed here. */ }
|
|
126
|
+
}
|
|
127
|
+
return { mode: "unmanaged", root, version: info.version };
|
|
128
|
+
}
|
|
129
|
+
async function selectedChannel() {
|
|
130
|
+
try { return channelName((await readJson(settings)).channel); } catch { return "latest"; }
|
|
131
|
+
}
|
|
132
|
+
async function assertNotUpdating() {
|
|
133
|
+
if (await exists(recovery)) throw failure("update_recovery_required", `An interrupted update needs recovery. See ${recovery}.`);
|
|
134
|
+
if (!await exists(lock)) return;
|
|
135
|
+
let owner;
|
|
136
|
+
try { owner = await readJson(path.join(lock, "owner.json")); } catch { throw failure("update_busy", "Another update is starting. Wait for it to finish."); }
|
|
137
|
+
if (!Number.isInteger(owner.pid) || owner.pid < 1 || alive(owner.pid)) throw failure("update_busy", "Another Moondog update is running. Wait for it to finish.");
|
|
138
|
+
}
|
|
139
|
+
async function check({ channel, force = false, signal } = {}) {
|
|
140
|
+
const current = await packageInfo();
|
|
141
|
+
channel = channelName(channel ?? await selectedChannel());
|
|
142
|
+
const cache = path.join(storage, `${channel}.json`);
|
|
143
|
+
if (!force) {
|
|
144
|
+
try {
|
|
145
|
+
const cached = await readJson(cache), age = now() - cached.checkedAt;
|
|
146
|
+
if (age >= 0 && age < CACHE_MS && cached.current === current.version && cached.channel === channel) return cached;
|
|
147
|
+
} catch { /* A missing or damaged cache cannot prevent startup. */ }
|
|
148
|
+
}
|
|
149
|
+
let result;
|
|
150
|
+
try {
|
|
151
|
+
const controller = AbortSignal.timeout(5_000);
|
|
152
|
+
const response = await fetchImpl(`https://registry.npmjs.org/@audiofool%2fmoondog/${channel}`, {
|
|
153
|
+
signal: signal ? AbortSignal.any([signal, controller]) : controller,
|
|
154
|
+
redirect: "error", headers: { Accept: "application/json" },
|
|
155
|
+
});
|
|
156
|
+
if (response.status === 404) throw failure("update_channel_empty", `No ${channel} release has been published yet.`);
|
|
157
|
+
if (!response.ok) throw new Error("Registry unavailable");
|
|
158
|
+
const body = (await boundedBody(response, 512_000)).toString("utf8");
|
|
159
|
+
const release = JSON.parse(body);
|
|
160
|
+
if (release.name !== UPDATE_PACKAGE || !releaseVersion(release.version) ||
|
|
161
|
+
channel === "latest" && release.version.includes("-") ||
|
|
162
|
+
![`${REPOSITORY}.git`, `git+${REPOSITORY}.git`].includes(release.repository?.url)) throw new Error("Invalid release metadata");
|
|
163
|
+
const minimum = release.engines?.node?.match(/^>=(\d+\.\d+\.\d+)$/u)?.[1];
|
|
164
|
+
if (!minimum || !releaseVersion(minimum)) throw new Error("Unsupported runtime requirement");
|
|
165
|
+
const tarball = `https://registry.npmjs.org/@audiofool/moondog/-/moondog-${release.version}.tgz`;
|
|
166
|
+
if (release.dist?.tarball !== tarball || !/^sha512-[A-Za-z0-9+/]{86}==$/u.test(release.dist?.integrity ?? "")) throw new Error("Unsupported package integrity metadata");
|
|
167
|
+
result = { state: compareReleaseVersions(release.version, current.version) > 0 ? "available" : "current", current: current.version,
|
|
168
|
+
latest: release.version, channel, minimumNode: minimum,
|
|
169
|
+
compatible: compareReleaseVersions(process.versions.node, minimum) >= 0,
|
|
170
|
+
gitHead: /^[a-f0-9]{40}$/u.test(release.gitHead ?? "") ? release.gitHead : null,
|
|
171
|
+
tarball, integrity: release.dist.integrity,
|
|
172
|
+
releaseUrl: `${REPOSITORY}/releases/tag/v${release.version}` };
|
|
173
|
+
} catch (error) {
|
|
174
|
+
signal?.throwIfAborted();
|
|
175
|
+
result = { state: "unavailable", current: current.version, channel,
|
|
176
|
+
message: error.code === "update_channel_empty" ? error.message : "Could not check for updates. Your installed version is still available." };
|
|
177
|
+
}
|
|
178
|
+
result.checkedAt = now();
|
|
179
|
+
await atomicJson(cache, result).catch(() => {});
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
async function assertSourceUnchanged(head) {
|
|
183
|
+
const origin = await git(["remote", "get-url", "origin"]);
|
|
184
|
+
if (![`${REPOSITORY}.git`, REPOSITORY, "git@github.com:Audiofool934/moondog.git"].includes(origin)) throw failure("update_source_remote", "Source updates require the official Moondog origin. Update this checkout manually.");
|
|
185
|
+
if (await git(["branch", "--show-current"]) !== "main") throw failure("update_source_branch", "Source updates require the main branch. Your current branch was left unchanged.");
|
|
186
|
+
if (await git(["status", "--porcelain", "--untracked-files=normal"])) throw failure("update_source_dirty", "This checkout has local changes. Commit or move them before updating; nothing was overwritten.");
|
|
187
|
+
if (head && await git(["rev-parse", "HEAD"]) !== head) throw failure("update_source_changed", "The checkout changed while preparing the update. Nothing was installed.");
|
|
188
|
+
}
|
|
189
|
+
async function noActiveSessions() {
|
|
190
|
+
if ((await inspectProcesses()).length) throw failure("update_session_active", "Another Moondog session is running. Close it before updating.");
|
|
191
|
+
}
|
|
192
|
+
async function smoke(stagedRoot, version, signal) {
|
|
193
|
+
const isolated = path.join(path.dirname(stagedRoot), `.update-smoke-${randomUUID()}`);
|
|
194
|
+
await mkdir(isolated, { mode: 0o700 });
|
|
195
|
+
try {
|
|
196
|
+
const output = await run(node, [path.join(stagedRoot, "scripts/moondog.mjs"), "--version"], {
|
|
197
|
+
cwd: stagedRoot, timeout: 15_000, signal,
|
|
198
|
+
env: { ...environment, MOONDOG_CONFIG_HOME: isolated, MOONDOG_STATE_HOME: isolated, MOONDOG_UPDATE_CHECK: "off" },
|
|
199
|
+
});
|
|
200
|
+
if (output !== version) throw failure("update_verification_failed", "The prepared program did not report the expected version. Your installation was left unchanged.");
|
|
201
|
+
} finally { await rm(isolated, { recursive: true, force: true }); }
|
|
202
|
+
}
|
|
203
|
+
async function apply({ channel, signal, onProgress = () => {} } = {}) {
|
|
204
|
+
await assertNotUpdating();
|
|
205
|
+
await mkdir(storage, { recursive: true, mode: 0o700 });
|
|
206
|
+
// Never remove a lock after a separate read: another updater could acquire
|
|
207
|
+
// it between those operations. Abandoned locks require explicit recovery.
|
|
208
|
+
try { await mkdir(lock, { mode: 0o700 }); }
|
|
209
|
+
catch (error) { if (error.code === "EEXIST") throw failure("update_busy", `An update lock already exists. If its owner has exited, follow docs/UPDATES.md to recover: ${lock}`); throw error; }
|
|
210
|
+
let stage, backup, install, priorHead, installed = false, sourceAttempted = false, dependenciesMoved = false, replacementMoved = false;
|
|
211
|
+
try {
|
|
212
|
+
await writeFile(path.join(lock, "owner.json"), JSON.stringify({ pid: process.pid }), { mode: 0o600 });
|
|
213
|
+
install = await installation();
|
|
214
|
+
if (install.mode === "unmanaged") throw failure("update_installation_unmanaged", "This installation is managed by another tool or local project. Update it using its original installation method.");
|
|
215
|
+
await assertDataOutsideReplacement(install.mode);
|
|
216
|
+
await noActiveSessions();
|
|
217
|
+
if (install.mode === "source") { await assertSourceUnchanged(); priorHead = await git(["rev-parse", "HEAD"]); }
|
|
218
|
+
onProgress("Checking the selected release channel...");
|
|
219
|
+
const release = await check({ channel, force: true, signal });
|
|
220
|
+
if (release.state === "unavailable") throw failure("update_check_failed", release.message);
|
|
221
|
+
if (release.state !== "available") {
|
|
222
|
+
await atomicJson(settings, { channel: release.channel });
|
|
223
|
+
return release;
|
|
224
|
+
}
|
|
225
|
+
if (!release.compatible) throw failure("update_node_required", `This release needs Node ${release.minimumNode} or newer. Update Node first; Moondog was left unchanged.`);
|
|
226
|
+
await access(path.dirname(root), constants.W_OK);
|
|
227
|
+
stage = await mkdtemp(path.join(path.dirname(root), ".moondog-update-"));
|
|
228
|
+
backup = path.join(stage, "previous");
|
|
229
|
+
let stagedRoot;
|
|
230
|
+
onProgress(`Preparing Moondog ${release.latest}. The installed program and your data are unchanged.`);
|
|
231
|
+
if (install.mode === "source") {
|
|
232
|
+
if (!release.gitHead) throw failure("update_release_unverified", "The release has no source commit identity. Update this checkout manually.");
|
|
233
|
+
const tag = `refs/tags/v${release.latest}`;
|
|
234
|
+
await git(["fetch", "--no-tags", "origin", `${tag}:${tag}`], { signal });
|
|
235
|
+
if (await git(["rev-parse", `${tag}^{commit}`]) !== release.gitHead) throw failure("update_release_unverified", "The release tag does not match the published package. No update was installed.");
|
|
236
|
+
try { await git(["merge-base", "--is-ancestor", priorHead, release.gitHead]); }
|
|
237
|
+
catch { throw failure("update_source_diverged", "This checkout is ahead of or diverges from the published release. No history was replaced."); }
|
|
238
|
+
stagedRoot = path.join(stage, "source");
|
|
239
|
+
await mkdir(stagedRoot);
|
|
240
|
+
const archive = path.join(stage, "release.tar");
|
|
241
|
+
await git(["archive", "--format=tar", `--output=${archive}`, release.gitHead], { signal });
|
|
242
|
+
await run("tar", ["-xf", archive, "-C", stagedRoot], { signal });
|
|
243
|
+
await run(npm, ["ci", "--ignore-scripts", "--no-audit", "--no-fund", "--registry=https://registry.npmjs.org/"], { cwd: stagedRoot, env: environment, signal });
|
|
244
|
+
} else {
|
|
245
|
+
const prefix = path.join(stage, "install");
|
|
246
|
+
const response = await fetchImpl(release.tarball, { redirect: "error",
|
|
247
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(60_000)]) : AbortSignal.timeout(60_000) });
|
|
248
|
+
if (!response.ok) throw failure("update_download_failed", "The release package could not be downloaded. Nothing was installed.");
|
|
249
|
+
const archive = await boundedBody(response, 16 * 1024 * 1024);
|
|
250
|
+
if (`sha512-${createHash("sha512").update(archive).digest("base64")}` !== release.integrity) throw failure("update_integrity_failed", "The package did not match the published checksum. Nothing was installed.");
|
|
251
|
+
const tarball = path.join(stage, "release.tgz");
|
|
252
|
+
await writeFile(tarball, archive, { mode: 0o600 });
|
|
253
|
+
await run(npm, ["install", "--prefix", prefix, "--install-strategy=nested", "--ignore-scripts", "--no-audit", "--no-fund", "--no-save", "--registry=https://registry.npmjs.org/", tarball], { env: environment, signal });
|
|
254
|
+
stagedRoot = path.join(prefix, "node_modules/@audiofool/moondog");
|
|
255
|
+
}
|
|
256
|
+
const prepared = await readJson(path.join(stagedRoot, "package.json"));
|
|
257
|
+
if (prepared.name !== UPDATE_PACKAGE || prepared.version !== release.latest) throw failure("update_verification_failed", "The prepared package identity did not match. Nothing was installed.");
|
|
258
|
+
await smoke(stagedRoot, release.latest, signal);
|
|
259
|
+
await noActiveSessions();
|
|
260
|
+
await assertDataOutsideReplacement(install.mode);
|
|
261
|
+
if (install.mode === "source") await assertSourceUnchanged(priorHead);
|
|
262
|
+
else if ((await packageInfo()).version !== install.version) throw failure("update_installation_changed", "The installed version changed during preparation. Retry after the other update finishes.");
|
|
263
|
+
signal?.throwIfAborted();
|
|
264
|
+
const record = { installation: root, mode: install.mode, previousVersion: install.version, version: release.latest,
|
|
265
|
+
previousHead: priorHead ?? null, targetHead: release.gitHead, backup, stage };
|
|
266
|
+
await atomicJson(recovery, record);
|
|
267
|
+
onProgress(`Installing ${release.latest}. Recovery record: ${recovery}`);
|
|
268
|
+
// Once replacement begins, settle it or restore our own change before
|
|
269
|
+
// honoring cancellation. No user data is opened, migrated or removed.
|
|
270
|
+
if (install.mode === "source") {
|
|
271
|
+
sourceAttempted = true;
|
|
272
|
+
await git(["merge", "--ff-only", "--no-overwrite-ignore", release.gitHead]);
|
|
273
|
+
installed = true;
|
|
274
|
+
if (await exists(path.join(root, "node_modules"))) { await rename(path.join(root, "node_modules"), backup); dependenciesMoved = true; }
|
|
275
|
+
await rename(path.join(stagedRoot, "node_modules"), path.join(root, "node_modules")); replacementMoved = true;
|
|
276
|
+
} else {
|
|
277
|
+
await rename(root, backup); dependenciesMoved = true;
|
|
278
|
+
await rename(stagedRoot, root); installed = true; replacementMoved = true;
|
|
279
|
+
}
|
|
280
|
+
await smoke(root, release.latest);
|
|
281
|
+
await atomicJson(path.join(storage, "last-update.json"), record);
|
|
282
|
+
await atomicJson(settings, { channel: release.channel });
|
|
283
|
+
await rm(recovery);
|
|
284
|
+
return { ...release, state: "updated", previous: install.version, recoveryBackup: backup, restartRequired: true };
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (await exists(recovery)) {
|
|
287
|
+
try {
|
|
288
|
+
if (install.mode === "source") {
|
|
289
|
+
const record = await readJson(recovery);
|
|
290
|
+
if (sourceAttempted) {
|
|
291
|
+
// A command can change HEAD and then report a timeout/error.
|
|
292
|
+
// Observe the actual repository before deciding it is unchanged.
|
|
293
|
+
const currentHead = await git(["rev-parse", "HEAD"]);
|
|
294
|
+
if (currentHead === record.targetHead) {
|
|
295
|
+
await assertSourceUnchanged(record.targetHead);
|
|
296
|
+
await git(["reset", "--hard", priorHead]);
|
|
297
|
+
} else if (currentHead === priorHead) await assertSourceUnchanged(priorHead);
|
|
298
|
+
else throw failure("update_source_changed", "Source identity is uncertain; recovery is required.");
|
|
299
|
+
}
|
|
300
|
+
if (replacementMoved) await rename(path.join(root, "node_modules"), path.join(stage, "failed-dependencies"));
|
|
301
|
+
if (dependenciesMoved) await rename(backup, path.join(root, "node_modules"));
|
|
302
|
+
} else {
|
|
303
|
+
if (replacementMoved) await rename(root, path.join(stage, "failed-install"));
|
|
304
|
+
if (dependenciesMoved) await rename(backup, root);
|
|
305
|
+
}
|
|
306
|
+
await rm(recovery);
|
|
307
|
+
installed = false;
|
|
308
|
+
} catch { throw failure("update_recovery_required", `The update stopped and needs recovery. Your previous program is preserved. See ${recovery}.`); }
|
|
309
|
+
}
|
|
310
|
+
if (error.name === "AbortError") throw error;
|
|
311
|
+
if (typeof error.code === "string" && error.code.startsWith("update_")) throw error;
|
|
312
|
+
throw failure("update_failed", "The update could not finish. The previous program was retained or restored; your personal data was not changed.");
|
|
313
|
+
} finally {
|
|
314
|
+
if (stage && !installed && !await exists(recovery)) await rm(stage, { recursive: true, force: true });
|
|
315
|
+
await rm(lock, { recursive: true, force: true });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return { check, apply, installation, assertNotUpdating, version: async () => (await packageInfo()).version,
|
|
319
|
+
startupCheck: options => environment.MOONDOG_UPDATE_CHECK === "off" ? Promise.resolve(null) : check(options) };
|
|
320
|
+
}
|