@gentbajko/slopify 1.5.1 → 2.0.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 (64) hide show
  1. package/README.md +34 -8
  2. package/dist/adapters/alignment/cache.js +67 -7
  3. package/dist/edge/cli.js +10 -2
  4. package/dist/edge/docker-launch.js +77 -0
  5. package/dist/edge/docker-projects/activation.js +35 -0
  6. package/dist/edge/docker-projects/claims.js +85 -0
  7. package/dist/edge/docker-projects/committed.js +40 -0
  8. package/dist/edge/docker-projects/engine.js +384 -0
  9. package/dist/edge/docker-projects/install.js +246 -0
  10. package/dist/edge/docker-projects/recover.js +100 -0
  11. package/dist/edge/docker-projects/state.js +221 -0
  12. package/dist/edge/docker-projects/tree.js +170 -0
  13. package/dist/edge/docker-projects/volume.js +95 -0
  14. package/dist/edge/docker.js +6 -0
  15. package/dist/edge/http/actions.js +64 -15
  16. package/dist/edge/http/app.js +6 -0
  17. package/dist/edge/http/folder-location-schema.js +11 -0
  18. package/dist/edge/http/folder-location.js +65 -0
  19. package/dist/edge/http/open-folder.js +2 -14
  20. package/dist/edge/http/projects.js +2 -0
  21. package/dist/edge/http/revision-files.js +2 -18
  22. package/dist/kernel/db/migrations/0012-project-recovery.sql +17 -0
  23. package/dist/kernel/lock.js +20 -6
  24. package/dist/main.js +11 -1
  25. package/dist/slices/admission/rules.js +6 -0
  26. package/dist/slices/admission/schema.js +3 -1
  27. package/dist/slices/article/plain.js +16 -9
  28. package/dist/slices/control/revision-control.js +2 -2
  29. package/dist/slices/narration/pronunciation-chunks.js +105 -0
  30. package/dist/slices/narration/pronunciation.js +126 -0
  31. package/dist/slices/narration/steering.js +82 -24
  32. package/dist/slices/play-drafts/convert.js +3 -1
  33. package/dist/slices/play-drafts/schema.js +3 -1
  34. package/dist/slices/rebuild/admission-repo.js +7 -4
  35. package/dist/slices/rebuild/recipe-audio-parts.js +70 -14
  36. package/dist/slices/rebuild/recipe-audio.js +61 -40
  37. package/dist/slices/rebuild/recipe-model.js +5 -3
  38. package/dist/slices/rebuild/recipe-preparation.js +6 -4
  39. package/dist/slices/rebuild/recipe-text.js +11 -3
  40. package/dist/slices/rebuild/recovery-conflict.js +30 -0
  41. package/dist/slices/rebuild/recovery-model.js +44 -0
  42. package/dist/slices/rebuild/recovery-repo.js +84 -0
  43. package/dist/slices/rebuild/recovery-selection.js +68 -0
  44. package/dist/slices/rebuild/recovery.js +257 -0
  45. package/dist/slices/rebuild/runtime-export-inputs.js +3 -2
  46. package/dist/slices/rebuild/runtime-narration-publication.js +30 -0
  47. package/dist/slices/rebuild/runtime-narration-reuse.js +20 -2
  48. package/dist/slices/rebuild/runtime-plan.js +8 -2
  49. package/dist/slices/rebuild/runtime-publication.js +9 -4
  50. package/dist/slices/rebuild/service.js +44 -22
  51. package/dist/slices/rebuild/transition-repo.js +1 -1
  52. package/dist/slices/revisions/mutation-assets.js +6 -1
  53. package/dist/slices/revisions/mutation-request.js +4 -0
  54. package/dist/slices/revisions/mutations.js +10 -5
  55. package/dist/slices/revisions/rules.js +43 -0
  56. package/dist/slices/revisions/schema.js +12 -0
  57. package/dist/slices/settings/repo.js +4 -2
  58. package/dist/web/assets/index-CZNiHm8P.css +1 -0
  59. package/dist/web/assets/index-DPgGLX5m.js +137 -0
  60. package/dist/web/index.html +2 -2
  61. package/package.json +3 -2
  62. package/scripts/docker-run.sh +1 -74
  63. package/dist/web/assets/index-CbGJtCio.js +0 -137
  64. package/dist/web/assets/index-DX1_chKs.css +0 -1
@@ -0,0 +1,384 @@
1
+ import { lstat, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ import { z } from "zod";
5
+ import { containerSchema, digestSchema, } from "./state.js";
6
+ import { contains } from "./tree.js";
7
+ export function dockerEngine(runner, signal, env) {
8
+ const run = (args, localSignal = signal) => runner.exec("docker", args, localSignal);
9
+ async function command(args, localSignal = signal) {
10
+ const r = await run(args, localSignal);
11
+ if (r.code !== 0)
12
+ throw new Error(`Docker ${args[0] ?? "command"} failed; recovery material is retained.`);
13
+ return r.stdout.trim();
14
+ }
15
+ const mount = (type, source, target, readonly = false) => [
16
+ "--mount",
17
+ `type=${type},source=${source},target=${target}${readonly ? ",readonly" : ""}${type === "volume" ? ",volume-nocopy" : ""}`,
18
+ ];
19
+ const source = (volume, bind) => [
20
+ ...mount("volume", volume, "/source", true),
21
+ ...(bind === null ? [] : mount("bind", bind, "/source/projects", true)),
22
+ ];
23
+ const helper = async (image, args, operation, user = "0:0") => JSON.parse(await command([
24
+ "run",
25
+ "--rm",
26
+ "--network",
27
+ "none",
28
+ "--read-only",
29
+ "--user",
30
+ operation === "probe" ? user : "0:0",
31
+ ...(args.some((a) => a.includes("target=/data,")) ? [] : ["--tmpfs", "/data"]),
32
+ ...args,
33
+ "--entrypoint",
34
+ "node",
35
+ image,
36
+ "/opt/slopify/packages/app/dist/edge/docker-projects/volume.js",
37
+ operation,
38
+ user,
39
+ ]));
40
+ async function inspect(name) {
41
+ const present = await run([
42
+ "container",
43
+ "ls",
44
+ "-a",
45
+ "--filter",
46
+ `name=^/${name}$`,
47
+ "--format",
48
+ "{{.ID}}",
49
+ ]);
50
+ if (present.code !== 0)
51
+ throw new Error("Docker container inventory failed.");
52
+ if (!present.stdout.trim() && !/^[a-f0-9]{12,64}$/.test(name))
53
+ return null;
54
+ const result = await run(["inspect", name]);
55
+ if (result.code !== 0) {
56
+ if (!present.stdout.trim())
57
+ return null;
58
+ throw new Error("Docker inspect failed.");
59
+ }
60
+ const schema = z.array(z.object({
61
+ Id: z.string(),
62
+ Name: z.string(),
63
+ Image: z.string(),
64
+ Config: z.object({ User: z.string(), Labels: z.record(z.string(), z.string()).nullable() }),
65
+ State: z.object({ Running: z.boolean() }),
66
+ HostConfig: z.object({
67
+ RestartPolicy: z.object({ Name: z.string(), MaximumRetryCount: z.number() }),
68
+ }),
69
+ Mounts: z.array(z.object({
70
+ Type: z.string(),
71
+ Name: z.string().optional(),
72
+ Source: z.string(),
73
+ Destination: z.string(),
74
+ RW: z.boolean(),
75
+ })),
76
+ NetworkSettings: z.object({
77
+ Ports: z.record(z.string(), z.array(z.object({ HostIp: z.string(), HostPort: z.string() })).nullable()),
78
+ }),
79
+ }));
80
+ const raw = schema.parse(JSON.parse(result.stdout))[0];
81
+ if (!raw)
82
+ throw new Error("Missing inspected container.");
83
+ const port = raw.NetworkSettings.Ports["6969/tcp"]?.[0];
84
+ return containerSchema.parse({
85
+ id: raw.Id,
86
+ name: raw.Name.replace(/^\//, ""),
87
+ image: raw.Image,
88
+ user: raw.Config.User,
89
+ running: raw.State.Running,
90
+ restart: raw.HostConfig.RestartPolicy,
91
+ signature: raw.Config.Labels?.["io.slopify.launcher"] ?? null,
92
+ installation: raw.Config.Labels?.["io.slopify.installation"] ?? null,
93
+ mounts: raw.Mounts.map((m) => ({
94
+ type: m.Type,
95
+ name: m.Name ?? "",
96
+ source: m.Source,
97
+ destination: m.Destination,
98
+ rw: m.RW,
99
+ })),
100
+ port: port ? `${port.HostIp}:${port.HostPort}` : null,
101
+ });
102
+ }
103
+ async function claims(volume, permitted, spared = () => false) {
104
+ const ids = (await command(["container", "ls", "-a", "-q"])).split(/\s+/).filter(Boolean);
105
+ for (const id of ids) {
106
+ const c = await inspect(id);
107
+ if (!c)
108
+ throw new Error("Cannot verify volume claims; an inventoried container is missing.");
109
+ if (!permitted.includes(c.id) &&
110
+ !spared(c) &&
111
+ c.mounts.some((m) => m.type === "volume" && m.name === volume))
112
+ throw new Error(`Another container claims installation volume ${volume}: ${c.name}.`);
113
+ }
114
+ }
115
+ async function writers(volume, paths, permitted) {
116
+ const ids = (await command(["container", "ls", "-q"])).split(/\s+/).filter(Boolean);
117
+ for (const id of ids) {
118
+ const c = await inspect(id);
119
+ if (!c || permitted.includes(c.id))
120
+ continue;
121
+ if (c.mounts.some((m) => m.rw &&
122
+ (m.name === volume ||
123
+ (m.type === "bind" &&
124
+ paths.some((p) => contains(p, m.source) || contains(m.source, p))))))
125
+ throw new Error(`Another running container writes installation storage: ${c.name}. Stop it before retrying.`);
126
+ }
127
+ }
128
+ async function recoveryLabel(j) {
129
+ const label = await command([
130
+ "volume",
131
+ "inspect",
132
+ "--format",
133
+ '{{index .Labels "io.slopify.transaction"}}',
134
+ j.backup,
135
+ ]);
136
+ if (label !== j.id)
137
+ throw new Error("Recovery volume identity conflict.");
138
+ }
139
+ return {
140
+ command,
141
+ inspect,
142
+ claims,
143
+ writers,
144
+ context: async (uid, gid) => {
145
+ let endpoint = env.DOCKER_HOST;
146
+ if (env.DOCKER_CONTEXT || !endpoint) {
147
+ const raw = JSON.parse(await command([
148
+ "context",
149
+ "inspect",
150
+ ...(env.DOCKER_CONTEXT ? [env.DOCKER_CONTEXT] : []),
151
+ ]));
152
+ endpoint = z
153
+ .array(z.object({ Endpoints: z.object({ docker: z.object({ Host: z.string() }) }) }))
154
+ .parse(raw)[0]?.Endpoints.docker.Host;
155
+ }
156
+ if (!endpoint?.startsWith("unix://"))
157
+ throw new Error("Managed folders require a local Linux Docker unix socket; remote daemons are unsupported.");
158
+ const info = z
159
+ .object({
160
+ ID: z.string(),
161
+ OSType: z.literal("linux"),
162
+ OperatingSystem: z.string(),
163
+ SecurityOptions: z.array(z.string()),
164
+ })
165
+ .parse(JSON.parse(await command(["info", "--format", "{{json .}}"])));
166
+ if (/docker desktop/i.test(info.OperatingSystem))
167
+ throw new Error("Docker Desktop is unsupported by managed project folders. Use a native Linux Docker daemon or native Slopify.");
168
+ const rootless = info.SecurityOptions.includes("name=rootless");
169
+ if (!rootless && info.SecurityOptions.some((s) => s.startsWith("name=userns")))
170
+ throw new Error("Docker userns-remap is unsupported by managed project folders. Use a supported rootless daemon or native Slopify; do not disable isolation.");
171
+ return { daemon: info.ID, user: rootless ? "0:0" : `${uid}:${gid}` };
172
+ },
173
+ image: async (ref) => {
174
+ if (ref === "ghcr.io/gentbajko/slopify:latest") {
175
+ await command(["pull", ref]);
176
+ return command(["image", "inspect", "--format", "{{.Id}}", ref]);
177
+ }
178
+ const found = await run(["image", "inspect", "--format", "{{.Id}}", ref]);
179
+ if (found.code !== 0)
180
+ await command(["pull", ref]);
181
+ return command(["image", "inspect", "--format", "{{.Id}}", ref]);
182
+ },
183
+ version: async (image) => z
184
+ .string()
185
+ .regex(/^\d+\.\d+\.\d+$/)
186
+ .parse(await command([
187
+ "run",
188
+ "--rm",
189
+ "--network",
190
+ "none",
191
+ "--read-only",
192
+ "--tmpfs",
193
+ "/data",
194
+ "--entrypoint",
195
+ "node",
196
+ image,
197
+ "-p",
198
+ "require('/opt/slopify/packages/app/package.json').version",
199
+ ])),
200
+ ensureVolume: async (volume) => {
201
+ await command(["volume", "create", volume]);
202
+ },
203
+ volume: async (name) => {
204
+ const names = (await command(["volume", "ls", "--format", "{{.Name}}"])).split(/\s+/);
205
+ if (!names.includes(name))
206
+ return null;
207
+ const v = z
208
+ .object({
209
+ Name: z.string(),
210
+ CreatedAt: z.string(),
211
+ Driver: z.literal("local"),
212
+ Scope: z.literal("local"),
213
+ Options: z.record(z.string(), z.string()).nullable(),
214
+ })
215
+ .parse(JSON.parse(await command(["volume", "inspect", "--format", "{{json .}}", name])));
216
+ if (v.Name !== name || (v.Options && Object.keys(v.Options).length > 0))
217
+ throw new Error("Managed projects require an ordinary local named volume; remote/custom volume drivers are unsupported.");
218
+ return JSON.stringify([v.Name, v.CreatedAt, v.Driver]);
219
+ },
220
+ probe: async (c, image, user, destination) => {
221
+ const probe = await mkdtemp(join(dirname(destination), ".slopify-probe-"));
222
+ try {
223
+ await writeFile(join(probe, "host"), "host", { mode: 0o600 });
224
+ await helper(image, mount("bind", probe, "/probe"), "probe", user);
225
+ const s = await lstat(join(probe, "container"));
226
+ if (!s.isFile() ||
227
+ s.uid !== c.uid ||
228
+ s.gid !== c.gid ||
229
+ (s.mode & 0o077) !== 0 ||
230
+ (await readFile(join(probe, "container"), "utf8")) !== "slopify ownership probe")
231
+ throw new Error("Docker cannot create private files as this host user; ownership mapping is unsupported.");
232
+ await writeFile(join(probe, "container"), "host can write", { flag: "a" });
233
+ }
234
+ finally {
235
+ await rm(probe, { recursive: true, force: true });
236
+ }
237
+ },
238
+ projects: async (image, volume, bind) => {
239
+ const value = z
240
+ .discriminatedUnion("exists", [
241
+ z.object({ exists: z.literal(false) }),
242
+ z.object({ exists: z.literal(true), digest: digestSchema }),
243
+ ])
244
+ .parse(await helper(image, source(volume, bind), "projects"));
245
+ return value.exists ? value.digest : null;
246
+ },
247
+ copy: async (image, volume, bind, destination, id) => {
248
+ const name = `slopify-reader-${id}`;
249
+ const reader = await command([
250
+ "create",
251
+ "--name",
252
+ name,
253
+ "--label",
254
+ `io.slopify.reader=${id}`,
255
+ "--network",
256
+ "none",
257
+ "--read-only",
258
+ "--tmpfs",
259
+ "/data",
260
+ ...source(volume, bind),
261
+ "--entrypoint",
262
+ "true",
263
+ image,
264
+ ]);
265
+ try {
266
+ await command(["cp", `${reader}:/source/projects/.`, destination]);
267
+ }
268
+ finally {
269
+ await command(["rm", reader], AbortSignal.timeout(10_000));
270
+ }
271
+ },
272
+ snapshot: async (j) => {
273
+ const all = (await command(["volume", "ls", "--format", "{{.Name}}"])).split(/\s+/);
274
+ if (all.includes(j.backup))
275
+ throw new Error("Recovery volume already exists; reconcile the journal before retrying.");
276
+ await command(["volume", "create", "--label", `io.slopify.transaction=${j.id}`, j.backup]);
277
+ return digestSchema.parse(await helper(j.image, [...source(j.volume, null), ...mount("volume", j.backup, "/backup")], "snapshot"));
278
+ },
279
+ restore: async (j) => {
280
+ if (!j.backupDigest)
281
+ throw new Error("No verified private snapshot; automatic restore is unsafe.");
282
+ await recoveryLabel(j);
283
+ const found = digestSchema.parse(await helper(j.image, source(j.backup, null), "private"));
284
+ if (found.hash !== j.backupDigest.hash)
285
+ throw new Error("Recovery snapshot changed; automatic restore refused.");
286
+ const result = digestSchema.parse(await helper(j.image, [...source(j.backup, null), ...mount("volume", j.volume, "/data")], "restore"));
287
+ if (result.hash !== j.backupDigest.hash)
288
+ throw new Error("Restored private data differs from its snapshot.");
289
+ },
290
+ own: async (j) => {
291
+ await helper(j.image, mount("volume", j.volume, "/data"), "own", j.user);
292
+ },
293
+ stop: async (c) => {
294
+ await command(["update", "--restart=no", c.id]);
295
+ await command(["stop", c.id]);
296
+ },
297
+ restart: async (c) => {
298
+ const current = await inspect(c.id);
299
+ if (!current)
300
+ throw new Error("Previous container is missing; use the retained recovery volume.");
301
+ if (current.name !== c.name)
302
+ await command(["rename", c.id, c.name]);
303
+ const restart = c.restart.Name === "on-failure" && c.restart.MaximumRetryCount > 0
304
+ ? `on-failure:${c.restart.MaximumRetryCount}`
305
+ : c.restart.Name;
306
+ await command(["update", `--restart=${restart}`, c.id]);
307
+ if (c.running)
308
+ await command(["start", c.id]);
309
+ },
310
+ start: async (c, j, transactionDirectory) => {
311
+ const port = c.port === 0 ? "" : String(c.port);
312
+ return command([
313
+ "run",
314
+ "-d",
315
+ "--name",
316
+ c.name,
317
+ "--restart",
318
+ "no",
319
+ "--user",
320
+ j.user,
321
+ "--label",
322
+ `io.slopify.launcher=${j.signature}`,
323
+ "--label",
324
+ `io.slopify.installation=${j.installation}`,
325
+ "--label",
326
+ `io.slopify.transaction=${j.id}`,
327
+ "-p",
328
+ `127.0.0.1:${port}:6969`,
329
+ ...mount("volume", c.volume, "/data"),
330
+ ...mount("bind", j.destination, "/data/projects"),
331
+ ...mount("bind", transactionDirectory, "/opt/slopify-install", true),
332
+ ...(c.bridge
333
+ ? [
334
+ ...mount("bind", c.bridge, "/opt/slopify-host", true),
335
+ "--env",
336
+ "SLOPIFY_HOST_CLI_DIR=/opt/slopify-host",
337
+ ]
338
+ : []),
339
+ "--env",
340
+ `SLOPIFY_DOCKER_PROJECTS_DIR=${j.destination}`,
341
+ "--env",
342
+ "SLOPIFY_DOCKER_INSTALL_STATE=/opt/slopify-install/activation.json",
343
+ "--env",
344
+ `SLOPIFY_UPDATE_TOKEN=${j.token}`,
345
+ "--env",
346
+ "SLOPIFY_UPDATE_PENDING=1",
347
+ j.image,
348
+ ]);
349
+ },
350
+ health: async (id, token, expectedVersion) => {
351
+ const deadline = Date.now() + 120_000;
352
+ while (Date.now() < deadline) {
353
+ signal.throwIfAborted();
354
+ const remaining = deadline - Date.now();
355
+ const probe = AbortSignal.any([
356
+ signal,
357
+ AbortSignal.timeout(Math.max(1, Math.min(2000, remaining))),
358
+ ]);
359
+ try {
360
+ const result = await run([
361
+ "exec",
362
+ id,
363
+ "node",
364
+ "-e",
365
+ "const [token,expected]=process.argv.slice(1); Promise.all([fetch('http://127.0.0.1:6969/api/health'),fetch('http://127.0.0.1:6969/api/update/ready',{headers:{'X-Slopify-Update-Token':token}})]).then(async rs=>{for(const r of rs){const b=await r.json();if(!r.ok||b.status!=='ok'||b.version!==expected)process.exit(1)}}).catch(()=>process.exit(1))",
366
+ token,
367
+ expectedVersion,
368
+ ], probe);
369
+ if (result.code === 0) {
370
+ const running = await run(["inspect", "--format", "{{.State.Running}}", id], probe);
371
+ if (running.code === 0 && running.stdout.trim() === "true")
372
+ return;
373
+ }
374
+ }
375
+ catch (error) {
376
+ if (signal.aborted || !probe.aborted)
377
+ throw error;
378
+ }
379
+ await delay(Math.min(250, Math.max(0, deadline - Date.now())), undefined, { signal });
380
+ }
381
+ throw new Error("Replacement did not become healthy within 120 seconds.");
382
+ },
383
+ };
384
+ }
@@ -0,0 +1,246 @@
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
+ import { lstat, mkdir, readdir, rename } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { readVersion } from "../../kernel/version.js";
5
+ import { assertVolumeClaims } from "./claims.js";
6
+ import { readCommittedJournal } from "./committed.js";
7
+ import { assertSourceIdentity, recoverInstallation } from "./recover.js";
8
+ import { journalSchema, privateDirectory, readState, receiptSchema, selectProjects, writeState, } from "./state.js";
9
+ import { assertIdentity, assertWritableTree, identity, isMissing, privateTree, safePath, syncDirectory, treeDigest, } from "./tree.js";
10
+ export async function installProjects(c, e, recovery) {
11
+ await privateDirectory(c.directory, c.uid);
12
+ const receiptPath = join(c.directory, "receipt.json");
13
+ const journalPath = join(c.directory, "journal.json");
14
+ let receipt = await readState(receiptPath, receiptSchema, c.uid);
15
+ let previousJournal = await readState(journalPath, journalSchema, c.uid);
16
+ const context = await e.context(c.uid, c.gid);
17
+ const currentVolume = await e.volume(c.volume);
18
+ if ((receipt && receipt.volumeIdentity !== currentVolume) ||
19
+ (previousJournal?.volumeIdentity && previousJournal.volumeIdentity !== currentVolume))
20
+ throw new Error("Remembered data volume is missing or was replaced; restore the original volume before retrying.");
21
+ if ((receipt && receipt.daemon !== context.daemon) ||
22
+ (previousJournal && previousJournal.daemon !== context.daemon))
23
+ throw new Error("Docker daemon differs from saved installation; select the original daemon.");
24
+ await assertVolumeClaims(c, e, context.daemon, currentVolume, await e.inspect(c.name));
25
+ if (previousJournal && previousJournal.phase !== "rolled-back")
26
+ previousJournal = await recoverInstallation(c, previousJournal, receipt, recovery());
27
+ if (previousJournal?.phase === "rolled-back")
28
+ await assertSourceIdentity(previousJournal);
29
+ receipt = await readState(receiptPath, receiptSchema, c.uid);
30
+ const old = await e.inspect(c.name);
31
+ if (!receipt &&
32
+ previousJournal?.sourceBind &&
33
+ (old?.id !== previousJournal.previous?.id ||
34
+ old?.mounts.find((m) => m.destination === "/data/projects")?.source !==
35
+ previousJournal.sourceBind))
36
+ throw new Error(`Original bound container is missing or changed. Restore the recorded original container using ${previousJournal.sourceBind}; the hidden volume projects are not authoritative.`);
37
+ for (const name of await readdir(c.root)) {
38
+ if (name === c.name || name === "setup.lock")
39
+ continue;
40
+ const s = await lstat(join(c.root, name));
41
+ if (!s.isDirectory() || s.isSymbolicLink())
42
+ continue;
43
+ const other = await readState(join(c.root, name, "receipt.json"), receiptSchema, c.uid);
44
+ if (other?.daemon === context.daemon && other.volume === c.volume)
45
+ throw new Error(`Named volume belongs to installation ${other.name}; select a separate volume.`);
46
+ }
47
+ let reuse = null;
48
+ if (previousJournal?.phase === "rolled-back" &&
49
+ previousJournal.sourceBind !== previousJournal.destination &&
50
+ previousJournal.publishedIdentity &&
51
+ previousJournal.sourceDigest &&
52
+ (c.projectsOverride === null || c.projectsOverride === previousJournal.destination)) {
53
+ await assertIdentity(previousJournal.destination, previousJournal.publishedIdentity);
54
+ if ((await treeDigest(previousJournal.destination)).hash !== previousJournal.sourceDigest.hash)
55
+ throw new Error(`Published retry folder changed; preserve it for inspection: ${previousJournal.destination}`);
56
+ reuse = previousJournal;
57
+ }
58
+ const base = await selectProjects(c, receipt, old, reuse);
59
+ const projects = reuse?.destination ?? base;
60
+ await safePath(projects, c.home, c.root, true);
61
+ const bind = receipt?.projects ??
62
+ old?.mounts.find((m) => m.destination === "/data/projects")?.source ??
63
+ null;
64
+ if (bind !== null)
65
+ await assertWritableTree(bind, c.uid);
66
+ const image = await e.image(c.image);
67
+ const expectedVersion = await e.version(image);
68
+ if (expectedVersion !== readVersion())
69
+ throw new Error("Docker image and installed launcher versions differ. Refresh the default release image or select the matching custom image before retrying; the existing container is unchanged.");
70
+ const signature = createHash("sha256")
71
+ .update(JSON.stringify([image, c.volume, c.port, c.bridge, projects, context.user]))
72
+ .digest("hex");
73
+ await e.writers(c.volume, [projects, ...(bind ? [bind] : [])], old ? [old.id] : []);
74
+ await e.probe(c, image, context.user, projects);
75
+ if (receipt &&
76
+ old &&
77
+ old.signature === signature &&
78
+ old.image === image &&
79
+ old.user === context.user &&
80
+ old.mounts.find((m) => m.destination === "/data/projects")?.source === projects &&
81
+ old.mounts.find((m) => m.destination === "/data")?.name === c.volume &&
82
+ old.mounts.find((m) => m.destination === "/data")?.rw === true &&
83
+ old.mounts.find((m) => m.destination === "/opt/slopify-install")?.source ===
84
+ join(c.directory, receipt.transaction) &&
85
+ old.mounts.find((m) => m.destination === "/opt/slopify-install")?.rw === false &&
86
+ old.restart.Name === "always" &&
87
+ old.port?.startsWith("127.0.0.1:") &&
88
+ (c.port === 0 || old.port === `127.0.0.1:${c.port}`) &&
89
+ (old.mounts.find((m) => m.destination === "/opt/slopify-host")?.source ?? null) === c.bridge) {
90
+ const j = await readCommittedJournal(c, receipt, old);
91
+ if (!old.running)
92
+ await e.command(["start", old.id]);
93
+ await e.health(old.id, j.token, expectedVersion);
94
+ return { url: `http://${old.port}`, projects, recovery: j.backup };
95
+ }
96
+ const id = randomUUID();
97
+ const directory = join(c.directory, id);
98
+ await privateDirectory(directory, c.uid);
99
+ const destinationBefore = await identity(projects).catch((error) => {
100
+ if (isMissing(error))
101
+ return null;
102
+ throw error;
103
+ });
104
+ let j = {
105
+ version: 1,
106
+ id,
107
+ installation: receipt?.installation ?? randomUUID(),
108
+ daemon: context.daemon,
109
+ name: c.name,
110
+ volume: c.volume,
111
+ volumeIdentity: currentVolume,
112
+ image,
113
+ user: context.user,
114
+ signature,
115
+ phase: "prepared",
116
+ previous: old,
117
+ previousReceipt: receipt,
118
+ sourceBind: bind,
119
+ sourceIdentity: bind === null ? null : (receipt?.directoryIdentity ?? (await identity(bind))),
120
+ destination: projects,
121
+ destinationBefore,
122
+ staging: join(dirname(projects), `.slopify-projects-${id}`),
123
+ stagingIdentity: null,
124
+ publishedIdentity: reuse?.publishedIdentity ?? null,
125
+ sourceDigest: reuse?.sourceDigest ?? null,
126
+ sourceAbsent: reuse?.sourceAbsent ?? false,
127
+ backup: `${c.volume}-recovery-${id}`,
128
+ backupDigest: null,
129
+ candidate: null,
130
+ token: randomBytes(32).toString("hex"),
131
+ };
132
+ async function save(change) {
133
+ j = journalSchema.parse({ ...j, ...change });
134
+ await writeState(join(directory, "journal.json"), j);
135
+ await writeState(journalPath, j);
136
+ }
137
+ await save({});
138
+ await writeState(join(directory, "activation.json"), {
139
+ version: 1,
140
+ token: j.token,
141
+ committed: false,
142
+ });
143
+ try {
144
+ await save({ phase: "stopping" });
145
+ if (old)
146
+ await e.stop(old);
147
+ await e.writers(c.volume, [projects, ...(bind ? [bind] : [])], []);
148
+ await assertSourceIdentity(j);
149
+ await save({ phase: "stopped" });
150
+ await e.ensureVolume(c.volume);
151
+ await save({ volumeIdentity: await e.volume(c.volume) });
152
+ const source = await e.projects(image, c.volume, bind);
153
+ if (reuse && (reuse.sourceAbsent ? source !== null : source?.hash !== reuse.sourceDigest?.hash))
154
+ throw new Error(`Stale migration baseline. Retain ${projects} and ${reuse.staging}; select a new empty --projects-dir for a fresh verified copy.`);
155
+ await save({
156
+ sourceDigest: source ?? reuse?.sourceDigest ?? null,
157
+ sourceAbsent: source === null,
158
+ });
159
+ const backupDigest = await e.snapshot(j);
160
+ await save({ phase: "snapshot", backupDigest });
161
+ if (projects !== bind && reuse === null) {
162
+ await mkdir(j.staging, { mode: 0o700 });
163
+ await save({ phase: "copying", stagingIdentity: await identity(j.staging) });
164
+ if (source !== null)
165
+ await e.copy(image, c.volume, bind, j.staging, j.id);
166
+ await privateTree(j.staging, c.uid, c.gid);
167
+ const copied = await treeDigest(j.staging);
168
+ const after = await e.projects(image, c.volume, bind);
169
+ if ((source === null && copied.files !== 0) ||
170
+ (source !== null && (copied.hash !== source.hash || after?.hash !== source.hash)))
171
+ throw new Error("Project copy verification failed; source and staging are retained.");
172
+ await save({ phase: "verified", sourceDigest: source ?? copied });
173
+ await safePath(projects, c.home, c.root, false);
174
+ if (j.destinationBefore) {
175
+ await assertIdentity(projects, j.destinationBefore);
176
+ if ((await readdir(projects)).length !== 0)
177
+ throw new Error("Destination became populated before publication.");
178
+ }
179
+ else {
180
+ const exists = await lstat(projects).catch((error) => {
181
+ if (isMissing(error))
182
+ return null;
183
+ throw error;
184
+ });
185
+ if (exists !== null)
186
+ throw new Error("Destination appeared before publication.");
187
+ }
188
+ if (!j.stagingIdentity)
189
+ throw new Error("Missing staging identity.");
190
+ await assertIdentity(j.staging, j.stagingIdentity);
191
+ await rename(j.staging, projects);
192
+ await syncDirectory(dirname(projects));
193
+ }
194
+ await save({ phase: "published", publishedIdentity: await identity(projects) });
195
+ await assertSourceIdentity(j);
196
+ await e.writers(c.volume, [projects, ...(bind ? [bind] : [])], []);
197
+ await e.own(j);
198
+ if (old)
199
+ await e.command(["rename", old.id, `${c.name}-previous-${j.id}`]);
200
+ await save({ phase: "starting" });
201
+ const candidate = await e.start(c, j, directory);
202
+ await save({ candidate });
203
+ await e.health(candidate, j.token, expectedVersion);
204
+ await save({ phase: "healthy" });
205
+ if (!j.publishedIdentity || !j.volumeIdentity)
206
+ throw new Error("Missing published storage identity.");
207
+ const next = {
208
+ version: 1,
209
+ installation: j.installation,
210
+ daemon: j.daemon,
211
+ name: c.name,
212
+ volume: c.volume,
213
+ volumeIdentity: j.volumeIdentity,
214
+ projects,
215
+ directoryIdentity: j.publishedIdentity,
216
+ user: j.user,
217
+ image,
218
+ signature,
219
+ transaction: j.id,
220
+ };
221
+ await writeState(receiptPath, next);
222
+ await e.command(["update", "--restart=always", candidate]);
223
+ await writeState(join(directory, "activation.json"), {
224
+ version: 1,
225
+ token: j.token,
226
+ committed: true,
227
+ });
228
+ await save({ phase: "committed" });
229
+ const ready = await e.inspect(candidate);
230
+ if (!ready?.port)
231
+ throw new Error("Committed container has no localhost port; rerun the launcher.");
232
+ return { url: `http://${ready.port}`, projects, recovery: j.backup };
233
+ }
234
+ catch (cause) {
235
+ const current = await readState(receiptPath, receiptSchema, c.uid);
236
+ if (current?.transaction === j.id)
237
+ throw new Error(`Installation committed at ${projects}; rerun the launcher to finish activation. Recovery volume: ${j.backup}`, { cause });
238
+ try {
239
+ await recoverInstallation(c, j, current, recovery());
240
+ }
241
+ catch (failure) {
242
+ throw new Error(`Recovery incomplete. Leave container/data stopped; recovery volume ${j.backup}, journal ${journalPath}, project folder ${projects}. ${failure instanceof Error ? failure.message : "Recovery operation failed."}`, { cause });
243
+ }
244
+ throw new Error(`Previous installation restored${old?.running ? " and restarted" : " without starting it"}. Recovery: ${j.backup}; project copy: ${projects}. ${cause instanceof Error ? cause.message : "Installation failed."}`, { cause });
245
+ }
246
+ }