@gentbajko/slopify 0.5.1 → 0.7.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/README.md +36 -1
- package/SUBTITLES.md +21 -0
- package/dist/adapter-registry.js +18 -3
- package/dist/adapters/alignment/audio.js +45 -0
- package/dist/adapters/alignment/cache.js +91 -0
- package/dist/adapters/alignment/ctc.js +128 -0
- package/dist/adapters/alignment/index.js +34 -0
- package/dist/adapters/alignment/lock.js +58 -0
- package/dist/adapters/alignment/numbers.js +91 -0
- package/dist/adapters/alignment/protocol.js +23 -0
- package/dist/adapters/alignment/quality.js +17 -0
- package/dist/adapters/alignment/runner.js +76 -0
- package/dist/adapters/alignment/text.js +61 -0
- package/dist/adapters/alignment/vocabulary.js +69 -0
- package/dist/adapters/alignment/worker.js +163 -0
- package/dist/adapters/image/google.js +12 -15
- package/dist/adapters/image/models.js +72 -0
- package/dist/adapters/image/openai.js +3 -5
- package/dist/adapters/llm/catalogue-files.js +23 -0
- package/dist/adapters/llm/claude-code.js +9 -7
- package/dist/adapters/llm/codex-models.js +46 -0
- package/dist/adapters/llm/codex.js +7 -12
- package/dist/adapters/llm/gemini-models.js +85 -0
- package/dist/adapters/llm/gemini-workspace.js +70 -0
- package/dist/adapters/llm/gemini.js +138 -0
- package/dist/adapters/llm/openrouter.js +11 -2
- package/dist/adapters/llm/run-cli.js +6 -9
- package/dist/adapters/tts/cartesia.js +9 -1
- package/dist/adapters/tts/elevenlabs.js +33 -2
- package/dist/adapters/tts/inworld-async.js +150 -0
- package/dist/adapters/tts/inworld-text.js +32 -0
- package/dist/adapters/tts/inworld.js +170 -0
- package/dist/adapters/tts/openai.js +31 -4
- package/dist/assets/fonts/Barlow-Regular.ttf +0 -0
- package/dist/assets/fonts/OFL.txt +93 -0
- package/dist/assets/fonts/SOURCE.txt +7 -0
- package/dist/edge/cli.js +5 -0
- package/dist/edge/events/hub.js +9 -2
- package/dist/edge/events/preview-cache.js +40 -0
- package/dist/edge/http/actions.js +2 -0
- package/dist/edge/http/app.js +30 -1
- package/dist/edge/http/audio-preview.js +46 -0
- package/dist/edge/http/fonts.js +147 -0
- package/dist/edge/http/projects.js +23 -1
- package/dist/edge/http/providers.js +29 -0
- package/dist/edge/http/subtitles.js +92 -0
- package/dist/edge/http/update.js +57 -0
- package/dist/edge/update-worker.js +44 -0
- package/dist/kernel/audio-preview.js +188 -0
- package/dist/kernel/cli-command.js +45 -0
- package/dist/kernel/ports/subtitles.js +1 -0
- package/dist/kernel/runner/providers.js +75 -24
- package/dist/main.js +106 -25
- package/dist/model-catalog.js +37 -0
- package/dist/slices/admission/repo.js +2 -0
- package/dist/slices/admission/rules.js +6 -0
- package/dist/slices/control/index.js +1 -0
- package/dist/slices/control/providers.js +2 -0
- package/dist/slices/fonts/catalog.js +95 -0
- package/dist/slices/fonts/discovery.js +81 -0
- package/dist/slices/fonts/files.js +37 -0
- package/dist/slices/fonts/index.js +5 -0
- package/dist/slices/fonts/model.js +4 -0
- package/dist/slices/fonts/preview.js +14 -0
- package/dist/slices/fonts/sfnt.js +262 -0
- package/dist/slices/fonts/upload.js +40 -0
- package/dist/slices/narration/live.js +21 -0
- package/dist/slices/narration/run.js +9 -9
- package/dist/slices/research/run.js +3 -0
- package/dist/slices/settings/cli-paths.js +98 -0
- package/dist/slices/settings/cli-status.js +21 -12
- package/dist/slices/settings/model.js +11 -0
- package/dist/slices/settings/models.js +89 -0
- package/dist/slices/settings/readiness.js +13 -9
- package/dist/slices/storage/downloads.js +2 -0
- package/dist/slices/storage/layout.js +10 -0
- package/dist/slices/storage/model.js +5 -0
- package/dist/slices/storage/repo.js +1 -0
- package/dist/slices/subtitles/captions.js +91 -0
- package/dist/slices/subtitles/layout.js +19 -0
- package/dist/slices/subtitles/model.js +27 -0
- package/dist/slices/subtitles/prepare.js +167 -0
- package/dist/slices/subtitles/transcript.js +34 -0
- package/dist/slices/video/audio-export.js +3 -0
- package/dist/slices/video/ffmpeg.js +14 -7
- package/dist/slices/video/plan.js +4 -4
- package/dist/slices/video/run.js +5 -2
- package/dist/slices/video/write-export.js +85 -20
- package/dist/updater/candidate.js +28 -0
- package/dist/updater/forward.js +35 -0
- package/dist/updater/install-flow.js +26 -0
- package/dist/updater/install.js +50 -0
- package/dist/updater/model.js +24 -0
- package/dist/updater/plan.js +135 -0
- package/dist/updater/readiness.js +15 -0
- package/dist/updater/registry.js +19 -0
- package/dist/updater/service.js +128 -0
- package/dist/updater/worker.js +136 -0
- package/dist/web/assets/index-6QIQ-TzO.css +1 -0
- package/dist/web/assets/index-CNgWL-ct.js +130 -0
- package/dist/web/index.html +2 -2
- package/package.json +4 -2
- package/dist/web/assets/index-C1fsrIP5.css +0 -1
- package/dist/web/assets/index-DwJ_XTl3.js +0 -81
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
import { mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { transact } from "../../kernel/db/tx.js";
|
|
4
4
|
import { setStageProgress } from "../admission/repo.js";
|
|
5
5
|
import { outputPath } from "../storage/layout.js";
|
|
6
6
|
import { deleteOutput, insertOutput, outputsOf } from "../storage/repo.js";
|
|
7
|
+
import { subtitleRoles } from "../subtitles/prepare.js";
|
|
7
8
|
import { runFfmpeg } from "./ffmpeg.js";
|
|
8
9
|
const progressIntervalMs = 500;
|
|
9
|
-
const renderRoles = [
|
|
10
|
+
const renderRoles = [
|
|
11
|
+
"video",
|
|
12
|
+
"audio_export",
|
|
13
|
+
"render_params",
|
|
14
|
+
...subtitleRoles,
|
|
15
|
+
];
|
|
10
16
|
// Both local exports retain the previous finished asset until ffmpeg succeeds and
|
|
11
17
|
// the abort check passes. The part file keeps its muxer's filename extension.
|
|
12
18
|
export async function writeExport(deps, context, output) {
|
|
@@ -23,6 +29,7 @@ export async function writeExport(deps, context, output) {
|
|
|
23
29
|
await runFfmpeg({
|
|
24
30
|
bin: deps.ffmpeg,
|
|
25
31
|
args: output.args(part),
|
|
32
|
+
cwd: output.subtitles?.directory,
|
|
26
33
|
signal: context.signal,
|
|
27
34
|
log: deps.log,
|
|
28
35
|
onProgress: (elapsedMs) => {
|
|
@@ -31,7 +38,9 @@ export async function writeExport(deps, context, output) {
|
|
|
31
38
|
return;
|
|
32
39
|
}
|
|
33
40
|
announced = at;
|
|
34
|
-
const current =
|
|
41
|
+
const current = output.subtitles === undefined
|
|
42
|
+
? Math.min(elapsedMs, totalMs)
|
|
43
|
+
: Math.round((0.35 + 0.65 * Math.min(elapsedMs / totalMs, 1)) * totalMs);
|
|
35
44
|
setStageProgress(deps.db, context.stage.id, current, totalMs);
|
|
36
45
|
context.emit({
|
|
37
46
|
type: "stage.progress",
|
|
@@ -51,26 +60,82 @@ export async function writeExport(deps, context, output) {
|
|
|
51
60
|
// A partial render file is discarded, never kept or served. Only the part file: the
|
|
52
61
|
// previous export is a finished output and stays downloadable.
|
|
53
62
|
rmSync(part, { force: true });
|
|
63
|
+
if (output.subtitles !== undefined)
|
|
64
|
+
rmSync(output.subtitles.directory, { recursive: true, force: true });
|
|
54
65
|
throw error;
|
|
55
66
|
}
|
|
56
|
-
// The swap. Everything below replaces the previous render, and nothing above it could.
|
|
57
|
-
renameSync(part, target);
|
|
58
67
|
const params = outputPath(deps.paths, projectId, "render.json");
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
const previousOutputs = outputsOf(deps.db, projectId);
|
|
69
|
+
const replaced = [];
|
|
70
|
+
const paramsPart = outputPath(deps.paths, projectId, "render.part.json");
|
|
71
|
+
let committed = false;
|
|
72
|
+
try {
|
|
73
|
+
writeFileSync(paramsPart, `${JSON.stringify(output.record, null, 2)}\n`, { mode: 0o600 });
|
|
74
|
+
// The DB commit can fail after ffmpeg succeeds. Keep rollback copies until all
|
|
75
|
+
// media, caption and font rows commit, so a failed export keeps its old playback.
|
|
76
|
+
for (const path of [target, params]) {
|
|
77
|
+
const saved = { path, backup: `${path}.previous`, existed: existsSync(path), swapped: false };
|
|
78
|
+
if (saved.existed)
|
|
79
|
+
copyFileSync(path, saved.backup);
|
|
80
|
+
replaced.push(saved);
|
|
81
|
+
}
|
|
82
|
+
for (const saved of replaced) {
|
|
83
|
+
renameSync(saved.path === target ? part : paramsPart, saved.path);
|
|
84
|
+
saved.swapped = true;
|
|
85
|
+
}
|
|
86
|
+
transact(deps.db, () => {
|
|
87
|
+
for (const previous of previousOutputs) {
|
|
88
|
+
if (renderRoles.includes(previous.role))
|
|
89
|
+
deleteOutput(deps.db, previous.id);
|
|
69
90
|
}
|
|
91
|
+
store(deps, projectId, "render_params", "render.json", null);
|
|
92
|
+
store(deps, projectId, output.role, output.filename, totalMs, {
|
|
93
|
+
subtitlesMode: output.subtitles === undefined ? "off" : output.subtitles.burnIn ? "burn-in" : "files",
|
|
94
|
+
});
|
|
95
|
+
for (const asset of output.subtitles?.assets ?? [])
|
|
96
|
+
store(deps, projectId, asset.role, asset.path, null);
|
|
97
|
+
});
|
|
98
|
+
committed = true;
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
for (const saved of replaced) {
|
|
102
|
+
if (!saved.swapped)
|
|
103
|
+
continue;
|
|
104
|
+
if (saved.existed)
|
|
105
|
+
renameSync(saved.backup, saved.path);
|
|
106
|
+
else
|
|
107
|
+
rmSync(saved.path, { force: true });
|
|
108
|
+
saved.swapped = false;
|
|
70
109
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
110
|
+
rmSync(part, { force: true });
|
|
111
|
+
rmSync(paramsPart, { force: true });
|
|
112
|
+
if (output.subtitles !== undefined)
|
|
113
|
+
rmSync(output.subtitles.directory, { recursive: true, force: true });
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
for (const saved of replaced) {
|
|
118
|
+
if (!committed && saved.swapped)
|
|
119
|
+
continue;
|
|
120
|
+
try {
|
|
121
|
+
rmSync(saved.backup, { force: true });
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
/* A retained backup is collected at the next boot. */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const kept = new Set(outputsOf(deps.db, projectId).map((one) => one.path));
|
|
129
|
+
for (const previous of previousOutputs) {
|
|
130
|
+
if (renderRoles.includes(previous.role) && !kept.has(previous.path)) {
|
|
131
|
+
try {
|
|
132
|
+
rmSync(outputPath(deps.paths, projectId, previous.path), { force: true });
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* Boot reconciliation collects a file that could not be removed. */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
74
139
|
// Written as well as emitted: a page opened after the render reads the row, and the
|
|
75
140
|
// throttled in-flight writes would have left it frozen short of the end.
|
|
76
141
|
setStageProgress(deps.db, context.stage.id, totalMs, totalMs);
|
|
@@ -82,7 +147,7 @@ export async function writeExport(deps, context, output) {
|
|
|
82
147
|
total: totalMs,
|
|
83
148
|
});
|
|
84
149
|
}
|
|
85
|
-
function store(deps, projectId, role, path, durationMs) {
|
|
150
|
+
function store(deps, projectId, role, path, durationMs, meta = {}) {
|
|
86
151
|
insertOutput(deps.db, {
|
|
87
152
|
id: deps.ids.next(),
|
|
88
153
|
projectId,
|
|
@@ -92,7 +157,7 @@ function store(deps, projectId, role, path, durationMs) {
|
|
|
92
157
|
originalFilename: null,
|
|
93
158
|
bytes: statSync(outputPath(deps.paths, projectId, path)).size,
|
|
94
159
|
durationMs,
|
|
95
|
-
meta
|
|
160
|
+
meta,
|
|
96
161
|
createdAt: deps.clock.now().toISOString(),
|
|
97
162
|
});
|
|
98
163
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function watchActivation(updater, token, expired, report) {
|
|
2
|
+
let checking = false;
|
|
3
|
+
const until = Date.now() + 120_000;
|
|
4
|
+
const timer = setInterval(() => {
|
|
5
|
+
if (checking)
|
|
6
|
+
return;
|
|
7
|
+
checking = true;
|
|
8
|
+
void updater
|
|
9
|
+
.activate(token)
|
|
10
|
+
.then(async (activated) => {
|
|
11
|
+
if (activated)
|
|
12
|
+
clearInterval(timer);
|
|
13
|
+
else if (Date.now() >= until) {
|
|
14
|
+
clearInterval(timer);
|
|
15
|
+
report("The update was not committed; stopping the candidate server.");
|
|
16
|
+
await expired();
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
.catch(() => {
|
|
20
|
+
report("The update activation marker could not be checked.");
|
|
21
|
+
})
|
|
22
|
+
.finally(() => {
|
|
23
|
+
checking = false;
|
|
24
|
+
});
|
|
25
|
+
}, 250);
|
|
26
|
+
timer.unref();
|
|
27
|
+
return () => clearInterval(timer);
|
|
28
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { activeUpdateEntry } from "./plan.js";
|
|
3
|
+
export async function forwardManagedUpdate(dataDir, version, args) {
|
|
4
|
+
if (process.env.SLOPIFY_SKIP_MANAGED_UPDATE === "1")
|
|
5
|
+
return undefined;
|
|
6
|
+
const entry = await activeUpdateEntry(dataDir, version);
|
|
7
|
+
if (entry === undefined)
|
|
8
|
+
return undefined;
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
const child = spawn(process.execPath, [entry, ...args], {
|
|
11
|
+
stdio: "inherit",
|
|
12
|
+
windowsHide: true,
|
|
13
|
+
});
|
|
14
|
+
const interrupt = () => {
|
|
15
|
+
child.kill("SIGINT");
|
|
16
|
+
};
|
|
17
|
+
const terminate = () => {
|
|
18
|
+
child.kill("SIGTERM");
|
|
19
|
+
};
|
|
20
|
+
process.once("SIGINT", interrupt);
|
|
21
|
+
process.once("SIGTERM", terminate);
|
|
22
|
+
const cleanup = () => {
|
|
23
|
+
process.off("SIGINT", interrupt);
|
|
24
|
+
process.off("SIGTERM", terminate);
|
|
25
|
+
};
|
|
26
|
+
child.once("error", () => {
|
|
27
|
+
cleanup();
|
|
28
|
+
resolve(undefined);
|
|
29
|
+
});
|
|
30
|
+
child.once("close", (code) => {
|
|
31
|
+
cleanup();
|
|
32
|
+
resolve(code ?? 1);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export async function runUpdateFlow(plan, deps) {
|
|
2
|
+
const entry = await deps.install();
|
|
3
|
+
// npm or package verification failures leave the currently serving app entirely alone.
|
|
4
|
+
await deps.handoff();
|
|
5
|
+
let backedUp = false;
|
|
6
|
+
try {
|
|
7
|
+
await deps.backup();
|
|
8
|
+
backedUp = true;
|
|
9
|
+
await deps.start(entry);
|
|
10
|
+
await deps.healthy(plan.version);
|
|
11
|
+
await deps.activate();
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
await deps.stopCandidate();
|
|
15
|
+
if (backedUp)
|
|
16
|
+
await deps.restore();
|
|
17
|
+
await deps.start(plan.oldEntry);
|
|
18
|
+
await deps.healthy(plan.previousVersion);
|
|
19
|
+
deps.report("The update did not start; the previous version and database were restored.");
|
|
20
|
+
throw new Error("The previous Slopify version was restored.");
|
|
21
|
+
}
|
|
22
|
+
// The pointer rename commits the new database. The candidate may already observe
|
|
23
|
+
// it and accept writes, so a lost unlock acknowledgement can never trigger rollback.
|
|
24
|
+
await deps.release();
|
|
25
|
+
deps.report(`Slopify ${plan.version} started successfully.`);
|
|
26
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { updatePlanSchema } from "./plan.js";
|
|
6
|
+
export async function launchUpdate(worker, plan, handoff) {
|
|
7
|
+
updatePlanSchema.parse(plan);
|
|
8
|
+
const directory = join(plan.dataDir, "updates");
|
|
9
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
10
|
+
const planPath = join(directory, `plan-${randomUUID()}.json`);
|
|
11
|
+
await writeFile(planPath, JSON.stringify(plan), { mode: 0o600 });
|
|
12
|
+
const child = spawn(process.execPath, [worker, planPath], {
|
|
13
|
+
cwd: plan.cwd,
|
|
14
|
+
detached: true,
|
|
15
|
+
windowsHide: true,
|
|
16
|
+
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
|
17
|
+
});
|
|
18
|
+
try {
|
|
19
|
+
await new Promise((resolve, reject) => {
|
|
20
|
+
let restarting = false;
|
|
21
|
+
const send = (type) => {
|
|
22
|
+
if (!child.connected) {
|
|
23
|
+
reject(new Error("The update worker disconnected."));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
child.send({ type }, (error) => {
|
|
27
|
+
if (error !== null)
|
|
28
|
+
reject(new Error("The update handoff failed."));
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
child.once("error", () => reject(new Error("The update worker could not start.")));
|
|
32
|
+
child.once("exit", (code) => code === 0 ? resolve() : reject(new Error("The update worker could not finish.")));
|
|
33
|
+
child.on("message", (message) => {
|
|
34
|
+
if (typeof message !== "object" || message === null || !("type" in message))
|
|
35
|
+
return;
|
|
36
|
+
if (message.type === "installed" && !restarting) {
|
|
37
|
+
restarting = true;
|
|
38
|
+
void handoff().then(() => send("handoff"), () => {
|
|
39
|
+
send("abort");
|
|
40
|
+
reject(new Error("Slopify could not release its server for the update."));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
child.unref();
|
|
48
|
+
await rm(planPath, { force: true });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const updatePackage = "@gentbajko/slopify";
|
|
2
|
+
export const npmRegistry = "https://registry.npmjs.org/";
|
|
3
|
+
const stableVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
4
|
+
export function isStableVersion(value) {
|
|
5
|
+
return value.length <= 50 && stableVersion.test(value);
|
|
6
|
+
}
|
|
7
|
+
export function newerVersion(candidate, current) {
|
|
8
|
+
if (!isStableVersion(candidate) || !isStableVersion(current))
|
|
9
|
+
return false;
|
|
10
|
+
const left = candidate.split(".").map(BigInt);
|
|
11
|
+
const right = current.split(".").map(BigInt);
|
|
12
|
+
for (let index = 0; index < 3; index++) {
|
|
13
|
+
const a = left[index];
|
|
14
|
+
const b = right[index];
|
|
15
|
+
if (a === undefined || b === undefined)
|
|
16
|
+
return false;
|
|
17
|
+
if (a !== b)
|
|
18
|
+
return a > b;
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
export function isUpdateToken(value) {
|
|
23
|
+
return /^[a-f0-9]{64}$/.test(value);
|
|
24
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access, readFile, realpath, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { delimiter, dirname, join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { cliCommand } from "../kernel/cli-command.js";
|
|
6
|
+
import { isStableVersion, isUpdateToken, newerVersion, npmRegistry, updatePackage, } from "./model.js";
|
|
7
|
+
export const updatePlanSchema = z.object({
|
|
8
|
+
token: z.string().refine(isUpdateToken),
|
|
9
|
+
version: z.string().refine(isStableVersion),
|
|
10
|
+
previousVersion: z.string().refine(isStableVersion),
|
|
11
|
+
oldEntry: z.string().min(1),
|
|
12
|
+
dataDir: z.string().min(1),
|
|
13
|
+
cwd: z.string().min(1),
|
|
14
|
+
host: z.string().min(1),
|
|
15
|
+
port: z.number().int().min(1).max(65535),
|
|
16
|
+
npm: z.object({ file: z.string().min(1), args: z.array(z.string()) }),
|
|
17
|
+
});
|
|
18
|
+
export function installDirectory(dataDir, version) {
|
|
19
|
+
if (!isStableVersion(version))
|
|
20
|
+
throw new Error("Invalid update version.");
|
|
21
|
+
return join(dataDir, "updates", version);
|
|
22
|
+
}
|
|
23
|
+
export function installArgs(directory, version) {
|
|
24
|
+
if (!isStableVersion(version))
|
|
25
|
+
throw new Error("Invalid update version.");
|
|
26
|
+
return [
|
|
27
|
+
"install",
|
|
28
|
+
"--prefix",
|
|
29
|
+
directory,
|
|
30
|
+
"--registry",
|
|
31
|
+
npmRegistry,
|
|
32
|
+
`--@gentbajko:registry=${npmRegistry}`,
|
|
33
|
+
"--no-audit",
|
|
34
|
+
"--no-fund",
|
|
35
|
+
"--omit=dev",
|
|
36
|
+
"--save-exact",
|
|
37
|
+
"--global=false",
|
|
38
|
+
"--workspaces=false",
|
|
39
|
+
"--userconfig",
|
|
40
|
+
join(directory, ".npmrc"),
|
|
41
|
+
"--globalconfig",
|
|
42
|
+
join(directory, ".global-npmrc"),
|
|
43
|
+
"--cache",
|
|
44
|
+
join(dirname(directory), "npm-cache"),
|
|
45
|
+
`${updatePackage}@${version}`,
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
export function restartArgs(entry, plan) {
|
|
49
|
+
// The existing page reconnects itself; opening a second browser tab on an update is unnecessary.
|
|
50
|
+
return [
|
|
51
|
+
entry,
|
|
52
|
+
"--host",
|
|
53
|
+
plan.host,
|
|
54
|
+
"--port",
|
|
55
|
+
String(plan.port),
|
|
56
|
+
"--data-dir",
|
|
57
|
+
plan.dataDir,
|
|
58
|
+
"--no-open",
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
export async function installedEntry(dataDir, version) {
|
|
62
|
+
const directory = join(installDirectory(dataDir, version), "node_modules", "@gentbajko", "slopify");
|
|
63
|
+
const metadata = z
|
|
64
|
+
.object({ name: z.literal(updatePackage), version: z.literal(version) })
|
|
65
|
+
.parse(JSON.parse(await readFile(join(directory, "package.json"), "utf8")));
|
|
66
|
+
if (metadata.version !== version)
|
|
67
|
+
throw new Error("Installed update version differs.");
|
|
68
|
+
const entry = join(directory, "dist", "edge", "cli.js");
|
|
69
|
+
await access(entry, constants.R_OK);
|
|
70
|
+
return entry;
|
|
71
|
+
}
|
|
72
|
+
export async function activeUpdateEntry(dataDir, currentVersion) {
|
|
73
|
+
try {
|
|
74
|
+
const content = await readFile(join(dataDir, "updates", "current.json"), "utf8");
|
|
75
|
+
const { version } = z
|
|
76
|
+
.object({ version: z.string().refine(isStableVersion) })
|
|
77
|
+
.parse(JSON.parse(content));
|
|
78
|
+
return newerVersion(version, currentVersion)
|
|
79
|
+
? await installedEntry(dataDir, version)
|
|
80
|
+
: undefined;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// A missing, partial or no-longer-installed pointer must not prevent the original CLI starting.
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export async function activateUpdate(dataDir, version, token) {
|
|
88
|
+
if (!isUpdateToken(token))
|
|
89
|
+
throw new Error("Invalid update token.");
|
|
90
|
+
await installedEntry(dataDir, version);
|
|
91
|
+
const pointer = join(dataDir, "updates", "current.json");
|
|
92
|
+
const temporary = `${pointer}.${process.pid}.tmp`;
|
|
93
|
+
await writeFile(temporary, JSON.stringify({ version, token }), { mode: 0o600 });
|
|
94
|
+
await rename(temporary, pointer);
|
|
95
|
+
}
|
|
96
|
+
export async function npmCommand() {
|
|
97
|
+
const nodeDir = dirname(process.execPath);
|
|
98
|
+
for (const candidate of [
|
|
99
|
+
join(nodeDir, "node_modules", "npm", "bin", "npm-cli.js"),
|
|
100
|
+
join(nodeDir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"),
|
|
101
|
+
]) {
|
|
102
|
+
try {
|
|
103
|
+
await access(candidate, constants.R_OK);
|
|
104
|
+
return { file: process.execPath, args: [await realpath(candidate)] };
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
/* Try the next standard Node installation layout. */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (process.platform === "win32")
|
|
111
|
+
return cliCommand("npm.cmd");
|
|
112
|
+
for (const directory of (process.env.PATH ?? "").split(delimiter)) {
|
|
113
|
+
if (directory === "")
|
|
114
|
+
continue;
|
|
115
|
+
const candidate = join(directory, "npm");
|
|
116
|
+
try {
|
|
117
|
+
await access(candidate, constants.X_OK);
|
|
118
|
+
return { file: await realpath(candidate), args: [] };
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
/* PATH can include directories without npm. */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
throw new Error("npm is unavailable. Install Node.js with npm to use in-app updates.");
|
|
125
|
+
}
|
|
126
|
+
export async function updateCommitted(dataDir, version, token) {
|
|
127
|
+
try {
|
|
128
|
+
const pointer = z.object({ version: z.literal(version), token: z.literal(token) });
|
|
129
|
+
return pointer.safeParse(JSON.parse(await readFile(join(dataDir, "updates", "current.json"), "utf8"))).success;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// Before activation the marker is absent or belongs to the previous release.
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export async function candidateReady(origin, version, token, exited, fetcher) {
|
|
3
|
+
if (exited())
|
|
4
|
+
return false;
|
|
5
|
+
const response = await fetcher(`${origin}/api/update/ready`, {
|
|
6
|
+
headers: { "X-Slopify-Update-Token": token },
|
|
7
|
+
signal: AbortSignal.timeout(1500),
|
|
8
|
+
redirect: "error",
|
|
9
|
+
});
|
|
10
|
+
if (!response.ok)
|
|
11
|
+
return false;
|
|
12
|
+
const health = z.object({ status: z.literal("ok"), version: z.literal(version) });
|
|
13
|
+
const matches = health.safeParse(await response.json()).success;
|
|
14
|
+
return matches && !exited();
|
|
15
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { isStableVersion, npmRegistry, updatePackage } from "./model.js";
|
|
3
|
+
const release = z.object({
|
|
4
|
+
name: z.literal(updatePackage),
|
|
5
|
+
version: z.string().refine(isStableVersion),
|
|
6
|
+
});
|
|
7
|
+
export async function publishedVersion(fetcher) {
|
|
8
|
+
const response = await fetcher(`${npmRegistry}@gentbajko%2Fslopify/latest`, {
|
|
9
|
+
signal: AbortSignal.timeout(10_000),
|
|
10
|
+
redirect: "error",
|
|
11
|
+
headers: { Accept: "application/json" },
|
|
12
|
+
});
|
|
13
|
+
if (!response.ok)
|
|
14
|
+
throw new Error("The npm release could not be checked.");
|
|
15
|
+
const parsed = release.safeParse(await response.json());
|
|
16
|
+
if (!parsed.success)
|
|
17
|
+
throw new Error("The npm release metadata is invalid.");
|
|
18
|
+
return parsed.data.version;
|
|
19
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { isStableVersion, isUpdateToken, newerVersion } from "./model.js";
|
|
3
|
+
export function createUpdater(deps) {
|
|
4
|
+
let latestVersion = null;
|
|
5
|
+
let checkedAt = -Infinity;
|
|
6
|
+
let status = deps.candidate?.pending === true ? "restarting" : "idle";
|
|
7
|
+
let activated = deps.candidate?.pending !== true;
|
|
8
|
+
let installError = deps.previousUpdateFailed
|
|
9
|
+
? "The update did not start. The previous version and database were restored. You can try again."
|
|
10
|
+
: undefined;
|
|
11
|
+
let error = installError;
|
|
12
|
+
let checking;
|
|
13
|
+
let mutations = 0;
|
|
14
|
+
const busyNow = () => deps.busy() || mutations > 0;
|
|
15
|
+
const locked = () => status === "installing" || status === "restarting";
|
|
16
|
+
function info() {
|
|
17
|
+
const busy = busyNow();
|
|
18
|
+
const available = latestVersion !== null && newerVersion(latestVersion, deps.currentVersion);
|
|
19
|
+
const blockedReason = deps.unsupported() ??
|
|
20
|
+
(busy
|
|
21
|
+
? "Pause running projects and wait for their active work to stop before updating."
|
|
22
|
+
: locked()
|
|
23
|
+
? "An update is already in progress."
|
|
24
|
+
: undefined);
|
|
25
|
+
return {
|
|
26
|
+
currentVersion: deps.currentVersion,
|
|
27
|
+
latestVersion,
|
|
28
|
+
available,
|
|
29
|
+
busy,
|
|
30
|
+
canUpdate: available && blockedReason === undefined,
|
|
31
|
+
status,
|
|
32
|
+
...(error === undefined ? {} : { error }),
|
|
33
|
+
...(blockedReason === undefined ? {} : { blockedReason }),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
async function check(refresh = false) {
|
|
37
|
+
if (locked())
|
|
38
|
+
return info();
|
|
39
|
+
if (checking !== undefined) {
|
|
40
|
+
await checking;
|
|
41
|
+
return info();
|
|
42
|
+
}
|
|
43
|
+
if (!refresh && deps.now() - checkedAt < 15 * 60_000)
|
|
44
|
+
return info();
|
|
45
|
+
status = "checking";
|
|
46
|
+
checking = (async () => {
|
|
47
|
+
try {
|
|
48
|
+
const found = await deps.latest();
|
|
49
|
+
if (!isStableVersion(found))
|
|
50
|
+
throw new Error("Invalid release version");
|
|
51
|
+
latestVersion = found;
|
|
52
|
+
status = installError === undefined ? "idle" : "error";
|
|
53
|
+
error = installError;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
status = "error";
|
|
57
|
+
error = "Could not check for updates. Check your connection and try again.";
|
|
58
|
+
deps.report(error);
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
checkedAt = deps.now();
|
|
62
|
+
}
|
|
63
|
+
})();
|
|
64
|
+
await checking;
|
|
65
|
+
checking = undefined;
|
|
66
|
+
return info();
|
|
67
|
+
}
|
|
68
|
+
const ready = (token) => deps.candidate !== undefined &&
|
|
69
|
+
isUpdateToken(token) &&
|
|
70
|
+
isUpdateToken(deps.candidate.token) &&
|
|
71
|
+
timingSafeEqual(Buffer.from(token), Buffer.from(deps.candidate.token));
|
|
72
|
+
return {
|
|
73
|
+
ready,
|
|
74
|
+
activate: async (token) => {
|
|
75
|
+
if (!ready(token) || deps.candidate === undefined)
|
|
76
|
+
return false;
|
|
77
|
+
if (activated)
|
|
78
|
+
return true;
|
|
79
|
+
const committed = await deps.candidate.committed();
|
|
80
|
+
if (activated)
|
|
81
|
+
return true;
|
|
82
|
+
if (!committed)
|
|
83
|
+
return false;
|
|
84
|
+
activated = true;
|
|
85
|
+
status = "idle";
|
|
86
|
+
return true;
|
|
87
|
+
},
|
|
88
|
+
check,
|
|
89
|
+
locked,
|
|
90
|
+
beginMutation: () => {
|
|
91
|
+
if (locked())
|
|
92
|
+
return undefined;
|
|
93
|
+
mutations++;
|
|
94
|
+
return () => {
|
|
95
|
+
mutations--;
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
start: async () => {
|
|
99
|
+
if (locked() || busyNow())
|
|
100
|
+
return { ok: false, code: 409, info: info() };
|
|
101
|
+
installError = undefined;
|
|
102
|
+
await check(true);
|
|
103
|
+
const current = info();
|
|
104
|
+
if (!current.canUpdate || latestVersion === null || current.status === "error") {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
code: current.busy || locked() ? 409 : current.status === "error" ? 503 : 400,
|
|
108
|
+
info: current,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
status = "installing";
|
|
112
|
+
error = undefined;
|
|
113
|
+
const version = latestVersion;
|
|
114
|
+
void Promise.resolve()
|
|
115
|
+
.then(() => deps.install(version, () => {
|
|
116
|
+
status = "restarting";
|
|
117
|
+
}))
|
|
118
|
+
.catch(() => {
|
|
119
|
+
status = "error";
|
|
120
|
+
error =
|
|
121
|
+
"The update could not be completed. Your current installation is still available. Try again or restart Slopify.";
|
|
122
|
+
installError = error;
|
|
123
|
+
deps.report(error);
|
|
124
|
+
});
|
|
125
|
+
return { ok: true, info: info() };
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|