@gentbajko/slopify 0.6.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.
Files changed (58) hide show
  1. package/README.md +36 -1
  2. package/SUBTITLES.md +3 -3
  3. package/dist/adapter-registry.js +12 -2
  4. package/dist/adapters/image/google.js +12 -15
  5. package/dist/adapters/image/models.js +72 -0
  6. package/dist/adapters/image/openai.js +3 -5
  7. package/dist/adapters/llm/catalogue-files.js +23 -0
  8. package/dist/adapters/llm/claude-code.js +3 -4
  9. package/dist/adapters/llm/codex-models.js +46 -0
  10. package/dist/adapters/llm/codex.js +4 -10
  11. package/dist/adapters/llm/gemini-models.js +85 -0
  12. package/dist/adapters/llm/gemini.js +3 -6
  13. package/dist/adapters/llm/openrouter.js +11 -2
  14. package/dist/adapters/tts/cartesia.js +9 -1
  15. package/dist/adapters/tts/elevenlabs.js +33 -2
  16. package/dist/adapters/tts/inworld-async.js +150 -0
  17. package/dist/adapters/tts/inworld-text.js +32 -0
  18. package/dist/adapters/tts/inworld.js +170 -0
  19. package/dist/adapters/tts/openai.js +31 -4
  20. package/dist/edge/cli.js +5 -0
  21. package/dist/edge/events/hub.js +9 -2
  22. package/dist/edge/events/preview-cache.js +40 -0
  23. package/dist/edge/http/actions.js +2 -0
  24. package/dist/edge/http/app.js +26 -1
  25. package/dist/edge/http/audio-preview.js +46 -0
  26. package/dist/edge/http/providers.js +16 -0
  27. package/dist/edge/http/update.js +57 -0
  28. package/dist/edge/update-worker.js +44 -0
  29. package/dist/kernel/audio-preview.js +188 -0
  30. package/dist/kernel/runner/providers.js +72 -22
  31. package/dist/main.js +104 -24
  32. package/dist/model-catalog.js +37 -0
  33. package/dist/slices/control/index.js +1 -0
  34. package/dist/slices/control/providers.js +2 -0
  35. package/dist/slices/narration/live.js +21 -0
  36. package/dist/slices/narration/run.js +9 -9
  37. package/dist/slices/research/run.js +3 -0
  38. package/dist/slices/settings/model.js +2 -0
  39. package/dist/slices/settings/models.js +89 -0
  40. package/dist/slices/subtitles/captions.js +4 -1
  41. package/dist/slices/subtitles/layout.js +19 -0
  42. package/dist/slices/subtitles/model.js +9 -0
  43. package/dist/slices/subtitles/prepare.js +6 -1
  44. package/dist/updater/candidate.js +28 -0
  45. package/dist/updater/forward.js +35 -0
  46. package/dist/updater/install-flow.js +26 -0
  47. package/dist/updater/install.js +50 -0
  48. package/dist/updater/model.js +24 -0
  49. package/dist/updater/plan.js +135 -0
  50. package/dist/updater/readiness.js +15 -0
  51. package/dist/updater/registry.js +19 -0
  52. package/dist/updater/service.js +128 -0
  53. package/dist/updater/worker.js +136 -0
  54. package/dist/web/assets/index-6QIQ-TzO.css +1 -0
  55. package/dist/web/assets/{index-CNYs0noB.js → index-CNgWL-ct.js} +24 -24
  56. package/dist/web/index.html +2 -2
  57. package/package.json +1 -1
  58. package/dist/web/assets/index-Mk0bvBg-.css +0 -1
@@ -0,0 +1,37 @@
1
+ import { falModels } from "./adapters/image/fal.js";
2
+ import { googleImageModels } from "./adapters/image/google.js";
3
+ import { openAiImageModels } from "./adapters/image/openai.js";
4
+ import { replicateModels } from "./adapters/image/replicate.js";
5
+ import { claudeCodeModels } from "./adapters/llm/claude-code.js";
6
+ import { codexModels } from "./adapters/llm/codex.js";
7
+ import { geminiModels } from "./adapters/llm/gemini.js";
8
+ import { cartesiaModels } from "./adapters/tts/cartesia.js";
9
+ import { elevenLabsModels } from "./adapters/tts/elevenlabs.js";
10
+ import { inworldModels } from "./adapters/tts/inworld.js";
11
+ import { openAiTtsModels } from "./adapters/tts/openai.js";
12
+ // Composition keeps provider-specific fallback data out of the HTTP and settings layers.
13
+ export function modelSources(registry) {
14
+ const fallbacks = {
15
+ "claude-code": claudeCodeModels,
16
+ codex: codexModels,
17
+ gemini: geminiModels,
18
+ elevenlabs: elevenLabsModels,
19
+ "openai-tts": openAiTtsModels,
20
+ cartesia: cartesiaModels,
21
+ inworld: inworldModels,
22
+ fal: falModels,
23
+ replicate: replicateModels,
24
+ "openai-image": openAiImageModels,
25
+ "google-image": googleImageModels,
26
+ };
27
+ return {
28
+ modelsFor: (provider, family) => {
29
+ if (family === "llm")
30
+ return registry.llm(provider).models();
31
+ if (family === "image")
32
+ return registry.image(provider).models();
33
+ return registry.tts(provider).models();
34
+ },
35
+ fallbackModelsFor: (provider) => Object.hasOwn(fallbacks, provider) ? (fallbacks[provider] ?? []) : [],
36
+ };
37
+ }
@@ -15,6 +15,7 @@ async function providerFields(deps, changes) {
15
15
  providers: await deps.providers(),
16
16
  voices: listVoices(deps.db),
17
17
  modelsFor: deps.modelsFor,
18
+ allowsCustomModels: deps.allowsCustomModels,
18
19
  }))(),
19
20
  new Promise((_resolve, reject) => {
20
21
  timeout = setTimeout(() => reject(new Error("Provider catalog timed out")), providerCheckTimeoutMs);
@@ -50,6 +50,8 @@ export async function validateProviderChanges(changes, current) {
50
50
  const picked = changes[key];
51
51
  if (picked === undefined || fields.some((field) => field.field === key))
52
52
  continue;
53
+ if (current.allowsCustomModels?.(picked.provider) === true)
54
+ continue;
53
55
  const models = await current.modelsFor(picked.provider, families[key]);
54
56
  if (!models.some((model) => model.id === picked.model)) {
55
57
  fields.push({ field: `${key}.model`, message: "Choose a model supported by this provider." });
@@ -0,0 +1,21 @@
1
+ export function observeNarration(store, projectId, key, label) {
2
+ if (store === undefined)
3
+ return undefined;
4
+ let sink;
5
+ return (event) => {
6
+ switch (event.type) {
7
+ case "start":
8
+ sink = store.begin(projectId, key, label);
9
+ break;
10
+ case "chunk":
11
+ sink?.append(event.bytes);
12
+ break;
13
+ case "complete":
14
+ sink?.complete();
15
+ break;
16
+ case "interrupted":
17
+ sink?.interrupt();
18
+ break;
19
+ }
20
+ };
21
+ }
@@ -11,6 +11,7 @@ import { insertOutput, outputsOf } from "../storage/repo.js";
11
11
  import { probeDurationMs } from "../video/ffmpeg.js";
12
12
  import { chunkNarration, defaultChunking } from "./chunk.js";
13
13
  import { joinNarration } from "./concat.js";
14
+ import { observeNarration } from "./live.js";
14
15
  // An empty narration source fails the stage immediately with no retries. Thrown from the
15
16
  // slice rather than from a provider call, so the attempt wrapper never sees it.
16
17
  export const nothingToNarrate = "nothing to narrate";
@@ -31,6 +32,7 @@ const segmentPayload = z.object({
31
32
  const chunkDir = "audio-chunks";
32
33
  export async function runNarration(deps, context, providers) {
33
34
  const { projectId } = context.stage;
35
+ deps.audioPreviews?.clear(projectId);
34
36
  const project = projectById(deps.db, projectId);
35
37
  if (project === undefined) {
36
38
  throw new Error(`project ${projectId} has no row`);
@@ -110,9 +112,10 @@ async function speakChunks(deps, context, providers, choice, pieces) {
110
112
  try {
111
113
  const spoken = await providers.forPiece(piece.id).tts({
112
114
  provider: choice.provider,
115
+ model: choice.model,
113
116
  voiceId: choice.voice,
114
117
  text: carried.text,
115
- });
118
+ }, observeNarration(deps.audioPreviews, projectId, piece.id, total === 1 ? "Body" : `Body part ${piece.idx} of ${total}`));
116
119
  write(deps, projectId, file, spoken.bytes);
117
120
  // The file is on disk before the row says so: a crash between the two leaves a
118
121
  // file the reconcile collects, where the other order would leave a `done` chunk
@@ -180,9 +183,10 @@ async function speakSegments(deps, context, providers, choice, outputs) {
180
183
  }
181
184
  const spoken = await providers.forPiece(piece.id).tts({
182
185
  provider: choice.provider,
186
+ model: choice.model,
183
187
  voiceId: choice.voice,
184
188
  text,
185
- });
189
+ }, observeNarration(deps.audioPreviews, projectId, piece.id, segment.category === "intro" ? "Intro" : "Outro"));
186
190
  const name = outputFileName(role, 1, ".mp3", "audio");
187
191
  write(deps, projectId, name, spoken.bytes);
188
192
  // Measured the same way the body is, because the render adds all three and the gaps to
@@ -208,14 +212,13 @@ function segmentPieces(deps, projectId) {
208
212
  }
209
213
  return piecesOf(deps.db, article.id, "segment");
210
214
  }
211
- // Audio seconds come from the measured duration per segment, never from the text's length:
212
- // the real duration goes on the row and the video timeline is built from it. No model is
213
- // named because the TTS port carries none - the same reason `store` above leaves it off.
215
+ // Audio seconds come from the measured duration per segment, never from the text's length.
214
216
  function counted(deps, segment, durationMs, choice) {
215
217
  deps.count("stage.completed", {
216
218
  stage: "audio",
217
219
  segment,
218
220
  provider: choice.provider,
221
+ model: choice.model,
219
222
  audioSeconds: durationMs / 1000,
220
223
  });
221
224
  }
@@ -246,9 +249,6 @@ function write(deps, projectId, path, bytes) {
246
249
  mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
247
250
  writeFileSync(target, bytes, { mode: 0o600 });
248
251
  }
249
- // The provider and the voice are stored with the audio they made. The model is not:
250
- // the TTS port carries no model, so every request went to the adapter's own, and writing
251
- // the run's dropdown value here would record something that was never sent.
252
252
  function store(deps, projectId, role, path, durationMs, choice) {
253
253
  insertOutput(deps.db, {
254
254
  id: deps.ids.next(),
@@ -259,7 +259,7 @@ function store(deps, projectId, role, path, durationMs, choice) {
259
259
  originalFilename: null,
260
260
  bytes: statSync(outputPath(deps.paths, projectId, path)).size,
261
261
  durationMs,
262
- meta: { provider: choice.provider, voice: choice.voice },
262
+ meta: { provider: choice.provider, model: choice.model, voice: choice.voice },
263
263
  createdAt: deps.clock.now().toISOString(),
264
264
  });
265
265
  }
@@ -53,6 +53,7 @@ async function research(deps, context, providers, choice, brief) {
53
53
  provider: choice.provider,
54
54
  model: choice.model,
55
55
  messages: synthesisMessages(brief, findings),
56
+ previewLabel: "Writing research notes",
56
57
  check: (given) => sourcedAnswer("the synthesis", given.text),
57
58
  });
58
59
  add(answer.usage);
@@ -89,6 +90,7 @@ async function plan(deps, context, providers, choice, brief, add) {
89
90
  provider: choice.provider,
90
91
  model: choice.model,
91
92
  messages: plannerMessages(brief),
93
+ previewLabel: "Planning research",
92
94
  // An empty answer, or one with no chapter in it, is a failed attempt.
93
95
  check: (given) => chaptersFrom(given.text).length === 0 ? "the planner named no chapters" : undefined,
94
96
  });
@@ -129,6 +131,7 @@ async function researchChapters(deps, context, providers, choice, brief, chapter
129
131
  provider: choice.provider,
130
132
  model: choice.model,
131
133
  messages: subAgentMessages(brief, kept.title, outline),
134
+ previewLabel: kept.title,
132
135
  // Grounding is asked for explicitly, so a model without it says so
133
136
  // instead of answering from what it already knows.
134
137
  webSearch: true,
@@ -12,6 +12,7 @@ export const providerIds = [
12
12
  "elevenlabs",
13
13
  "openai-tts",
14
14
  "cartesia",
15
+ "inworld",
15
16
  "fal",
16
17
  "replicate",
17
18
  "openai-image",
@@ -46,6 +47,7 @@ export const providers = [
46
47
  { id: "elevenlabs", family: "tts", displayName: "ElevenLabs", auth: "key" },
47
48
  { id: "openai-tts", family: "tts", displayName: "OpenAI", auth: "key" },
48
49
  { id: "cartesia", family: "tts", displayName: "Cartesia", auth: "key" },
50
+ { id: "inworld", family: "tts", displayName: "Inworld", auth: "key" },
49
51
  { id: "fal", family: "image", displayName: "fal.ai", auth: "key" },
50
52
  { id: "replicate", family: "image", displayName: "Replicate", auth: "key" },
51
53
  { id: "openai-image", family: "image", displayName: "OpenAI", auth: "key" },
@@ -0,0 +1,89 @@
1
+ // These marketplaces host models with different input schemas. Discovery alone cannot
2
+ // make an arbitrary endpoint compatible with our image request.
3
+ export function allowsCustomModel(provider) {
4
+ return provider !== "fal" && provider !== "replicate";
5
+ }
6
+ const ttlMs = 5 * 60_000;
7
+ const retryMs = 30_000;
8
+ export function createModelCatalog(deps) {
9
+ const cache = new Map();
10
+ const pending = new Map();
11
+ const generations = new Map();
12
+ async function get(provider, family, refresh = false) {
13
+ const active = pending.get(provider);
14
+ if (active !== undefined)
15
+ return active;
16
+ const previous = cache.get(provider);
17
+ if (!refresh && previous !== undefined && previous.expires > deps.now())
18
+ return previous.value;
19
+ const generation = generations.get(provider) ?? 0;
20
+ const task = Promise.resolve()
21
+ .then(async () => {
22
+ let value;
23
+ let lifetime = ttlMs;
24
+ try {
25
+ const models = await deps.load(provider, family);
26
+ value = {
27
+ models: uniqueModels(models),
28
+ allowsCustom: allowsCustomModel(provider),
29
+ ...catalogNotice(provider),
30
+ };
31
+ }
32
+ catch {
33
+ // A catalogue is metadata, not generation. Never expose an upstream response
34
+ // here: it can quote credentials that belong only inside its adapter.
35
+ deps.report(provider);
36
+ const saved = previous?.value.models;
37
+ value = {
38
+ models: saved ?? deps.fallback(provider),
39
+ ...catalogNotice(provider),
40
+ allowsCustom: allowsCustomModel(provider),
41
+ warning: saved === undefined
42
+ ? "Could not load the model list. Showing bundled choices; refresh to try again."
43
+ : "Could not refresh the model list. Showing the last loaded choices.",
44
+ };
45
+ lifetime = retryMs;
46
+ }
47
+ if ((generations.get(provider) ?? 0) === generation) {
48
+ cache.set(provider, { value, expires: deps.now() + lifetime });
49
+ }
50
+ return value;
51
+ })
52
+ .finally(() => {
53
+ if (pending.get(provider) === task)
54
+ pending.delete(provider);
55
+ });
56
+ pending.set(provider, task);
57
+ return task;
58
+ }
59
+ return {
60
+ get,
61
+ invalidate: (provider) => {
62
+ generations.set(provider, (generations.get(provider) ?? 0) + 1);
63
+ cache.delete(provider);
64
+ pending.delete(provider);
65
+ },
66
+ };
67
+ }
68
+ function uniqueModels(models) {
69
+ const seen = new Set();
70
+ return models.filter((model) => {
71
+ if (model.id.trim() === "" || seen.has(model.id))
72
+ return false;
73
+ seen.add(model.id);
74
+ return true;
75
+ });
76
+ }
77
+ function catalogNotice(provider) {
78
+ const notices = {
79
+ codex: "Models come from Codex's local catalogue. Open Codex to refresh that catalogue, then refresh this list.",
80
+ gemini: "Models and aliases come from the installed Gemini CLI. Update that CLI to discover newer models.",
81
+ "claude-code": "These aliases follow Claude Code's model families. You can also enter an exact model ID.",
82
+ inworld: "These are Inworld’s documented TTS models. You can also enter a compatible model ID.",
83
+ cartesia: "Cartesia has no model-list API. These are bundled compatible choices; you can enter a newer model ID.",
84
+ fal: "These models have input formats supported by Slopify's fal adapter.",
85
+ replicate: "These models have input formats supported by Slopify's Replicate adapter.",
86
+ };
87
+ const notice = Object.hasOwn(notices, provider) ? notices[provider] : undefined;
88
+ return notice === undefined ? {} : { notice };
89
+ }
@@ -1,3 +1,4 @@
1
+ import { subtitlePlacement } from "./layout.js";
1
2
  // Keep spoken spelling and punctuation. Word times come only from acoustic alignment.
2
3
  export function captionCues(words) {
3
4
  const cues = [];
@@ -61,10 +62,12 @@ export function serializeVtt(cues) {
61
62
  }
62
63
  export function serializeAss(cues, style) {
63
64
  const name = style.fontName.replace(/[\p{Cc},]/gu, " ").trim();
65
+ const placement = subtitlePlacement(style.position ?? "bottom", style.height);
66
+ const position = `{\\an${placement.alignment}\\pos(${style.width / 2},${placement.y})}`;
64
67
  const header = `[Script Info]\nScriptType: v4.00+\nPlayResX: ${style.width}\nPlayResY: ${style.height}\nWrapStyle: 0\nScaledBorderAndShadow: yes\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,${name},${style.fontSize},&H00FFFFFF,&H00FFFFFF,&H00101010,&H80000000,0,0,0,0,100,100,0,0,1,2.5,1,2,60,60,60,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n`;
65
68
  return (header +
66
69
  cues
67
- .map((cue) => `Dialogue: 0,${assStamp(cue.start)},${assStamp(cue.end)},Default,,0,0,0,,${assText(cue.text)}\n`)
70
+ .map((cue) => `Dialogue: 0,${assStamp(cue.start)},${assStamp(cue.end)},Default,,0,0,0,,${position}${assText(cue.text)}\n`)
68
71
  .join(""));
69
72
  }
70
73
  function markup(text) {
@@ -0,0 +1,19 @@
1
+ export function subtitleFrame(format) {
2
+ return format === "9:16" ? { width: 1080, height: 1920 } : { width: 1920, height: 1080 };
3
+ }
4
+ // ASS and the browser preview share anchors. Edge positions keep the existing
5
+ // 60-pixel safe margin; interior positions center both single and multiline cues.
6
+ export function subtitlePlacement(position, height) {
7
+ switch (position) {
8
+ case "top":
9
+ return { y: 60, alignment: 8, translateY: 0 };
10
+ case "upper-middle":
11
+ return { y: height / 4, alignment: 5, translateY: -50 };
12
+ case "center":
13
+ return { y: height / 2, alignment: 5, translateY: -50 };
14
+ case "lower-middle":
15
+ return { y: (height * 3) / 4, alignment: 5, translateY: -50 };
16
+ case "bottom":
17
+ return { y: height - 60, alignment: 2, translateY: -100 };
18
+ }
19
+ }
@@ -1,5 +1,12 @@
1
1
  import { z } from "zod";
2
2
  export const subtitleModes = ["off", "files", "burn-in"];
3
+ export const subtitlePositions = [
4
+ "top",
5
+ "upper-middle",
6
+ "center",
7
+ "lower-middle",
8
+ "bottom",
9
+ ];
3
10
  export const subtitleConfigSchema = z.object({
4
11
  mode: z.enum(subtitleModes),
5
12
  language: z.literal("en").default("en"),
@@ -8,6 +15,7 @@ export const subtitleConfigSchema = z.object({
8
15
  .max(160)
9
16
  .regex(/^[A-Za-z0-9_-]+$/)
10
17
  .default("default"),
18
+ position: z.enum(subtitlePositions).default("bottom"),
11
19
  fontSize: z.number().int().min(16).max(120).default(48),
12
20
  });
13
21
  export const defaultSubtitles = {
@@ -15,4 +23,5 @@ export const defaultSubtitles = {
15
23
  language: "en",
16
24
  fontId: "default",
17
25
  fontSize: 48,
26
+ position: "bottom",
18
27
  };
@@ -59,7 +59,12 @@ export async function prepareSubtitles(deps, context, audio, frame) {
59
59
  throw new Error("No spoken words could be aligned. Check that the English article matches the audio.");
60
60
  writeFileSync(join(directory, "subtitles.srt"), serializeSrt(cues), { mode: 0o600 });
61
61
  writeFileSync(join(directory, "subtitles.vtt"), serializeVtt(cues), { mode: 0o600 });
62
- writeFileSync(join(directory, "subtitles.ass"), serializeAss(cues, { ...frame, fontSize: config.fontSize, fontName: font.assName }), { mode: 0o600 });
62
+ writeFileSync(join(directory, "subtitles.ass"), serializeAss(cues, {
63
+ ...frame,
64
+ fontSize: config.fontSize,
65
+ fontName: font.assName,
66
+ position: config.position,
67
+ }), { mode: 0o600 });
63
68
  writeFileSync(join(directory, "subtitles.json"), JSON.stringify({ key, words, font }), {
64
69
  mode: 0o600,
65
70
  });
@@ -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
+ }