@gentbajko/slopify 1.5.1 → 2.0.1

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 (65) 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/projection.js +9 -1
  56. package/dist/slices/revisions/rules.js +43 -0
  57. package/dist/slices/revisions/schema.js +12 -0
  58. package/dist/slices/settings/repo.js +4 -2
  59. package/dist/web/assets/index-CZNiHm8P.css +1 -0
  60. package/dist/web/assets/index-DPgGLX5m.js +137 -0
  61. package/dist/web/index.html +2 -2
  62. package/package.json +3 -2
  63. package/scripts/docker-run.sh +1 -74
  64. package/dist/web/assets/index-CbGJtCio.js +0 -137
  65. package/dist/web/assets/index-DX1_chKs.css +0 -1
package/README.md CHANGED
@@ -30,17 +30,40 @@ slopify
30
30
 
31
31
  Both commands launch the same local app.
32
32
 
33
- For Docker on Linux with systemd, use `slopify --docker` or
33
+ For Docker on Linux with Node 26+ and util-linux (`flock`), use `slopify --docker` or
34
34
  `npx @gentbajko/slopify@latest --docker`. The launcher detects installed Codex,
35
35
  Claude Code and Gemini CLIs. It asks once before installing a private host helper
36
36
  and enabling automatic startup, including user lingering. The CLIs run on your
37
37
  host with their existing logins; credentials never move into Docker.
38
38
 
39
- Slopify runs in the background, restarts with Docker and keeps your data in
40
- `slopify-data`. Add `--port 7070` to change the default port. Rerun the launcher
41
- after changing host CLI installations or search paths. Native installs need no helper.
42
- Plain `docker run` is API-only without a configured helper. Use `--host-cli=off`
43
- for API-only launcher setup, or `--accept-host-cli` to approve non-interactive setup.
39
+ Slopify runs in the background at `http://127.0.0.1:6969` and restarts with Docker.
40
+ The managed Linux launcher saves generated project files in `~/Slopify/Projects`.
41
+ Custom container names use `~/Slopify/<container>/Projects`. The database,
42
+ credentials, logs and staging stay private in the named `slopify-data` volume.
43
+ Run as your normal user with Node 26+, Docker access and util-linux (`flock`).
44
+ Systemd is needed only for the optional host CLI helper. Native installs continue
45
+ using their configured data directory and need no helper.
46
+
47
+ Use `--projects-dir "/path/to/Projects"` or `SLOPIFY_DOCKER_PROJECTS_DIR` to choose
48
+ a dedicated folder. Later launches remember that absolute path when the override
49
+ is omitted. `--port 7070` changes the localhost port. Custom installations sharing
50
+ a machine need distinct `SLOPIFY_DOCKER_NAME` and `SLOPIFY_DOCKER_VOLUME` values.
51
+ Do not store unrelated documents in the managed Projects tree: storage
52
+ reconciliation owns it. Moving to another folder performs a verified copy;
53
+ existing unrelated contents are never merged or overwritten.
54
+
55
+ Existing installations are stopped, copied and verified before the new folder
56
+ is activated. Current and historical output actions show the real host folder
57
+ on the machine running Slopify; downloads remain available. This also works with
58
+ `--host-cli=off`, without installing a host helper. Docker does not open a desktop
59
+ window on your browser's machine.
60
+
61
+ Rerun the launcher after changing host CLI installations or search paths.
62
+ Plain `docker run` is API-only unless connected to an already configured helper.
63
+ It does not set up a host project folder or install host services; its files stay
64
+ in the private named volume. Use the managed launcher for automatic migration
65
+ and host-folder access. Use `--host-cli=off` for API-only launcher setup, or
66
+ `--accept-host-cli` to approve non-interactive setup.
44
67
  See the [Docker setup guide](https://github.com/GentBajko/slopify#docker) for
45
68
  updates and the dedicated helper's status/disable commands.
46
69
 
@@ -85,6 +108,7 @@ for account limits. Both model IDs are bundled; Inworld's LLM catalogue does not
85
108
  | `--port` | `SLOPIFY_PORT` | `6969` |
86
109
  | `--host` | `SLOPIFY_HOST` | `127.0.0.1` |
87
110
  | `--data-dir` | `SLOPIFY_DATA_DIR` | `~/.slopify` |
111
+ | `--projects-dir` (Docker only) | `SLOPIFY_DOCKER_PROJECTS_DIR` | remembered path; otherwise `~/Slopify/Projects` (custom names: `~/Slopify/<container>/Projects`) |
88
112
  | `--no-open` | `SLOPIFY_NO_OPEN` | the browser opens |
89
113
  | `--docker` | — | run locally; with the flag, launch Docker with host CLI detection |
90
114
  | — | `SLOPIFY_FFMPEG` | the bundled binary |
@@ -92,8 +116,10 @@ for account limits. Both model IDs are bundled; Inworld's LLM catalogue does not
92
116
  There is no login. Binding to anything but `127.0.0.1` hands the app and every key in
93
117
  it to whoever reaches the port, and the CLI says so on startup.
94
118
 
95
- Everything lives in one SQLite file and one directory tree under the data directory:
96
- `slopify.db`, `projects/`, `staging/`, `logs/`. Delete it and nothing of yours remains.
119
+ Native installations keep `slopify.db`, `projects/`, `staging/` and `logs/` under
120
+ their configured data directory. Managed Docker places only projects in its
121
+ remembered host folder; keep both that folder and the named private volume when
122
+ backing up an installation. The portable settings archive is not a project/history backup.
97
123
 
98
124
  ## ffmpeg and the GPL
99
125
 
@@ -4,6 +4,7 @@ import { mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { Readable } from "node:stream";
6
6
  import { pipeline } from "node:stream/promises";
7
+ import { setTimeout as delay } from "node:timers/promises";
7
8
  const revision = "a19f851b3d42865797e410752b4c570c871e4825";
8
9
  export const alignmentModel = {
9
10
  filename: "wav2vec2-base-960h-cd5040c1.onnx",
@@ -17,22 +18,70 @@ export async function prepareModel(input, deps = { model: alignmentModel, fetch:
17
18
  const target = join(input.cacheDir, deps.model.filename);
18
19
  if (await verified(target, deps.model, input.signal))
19
20
  return target;
21
+ let lastFailure;
22
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
23
+ try {
24
+ return await downloadModel(input, deps, target);
25
+ }
26
+ catch (error) {
27
+ input.signal.throwIfAborted();
28
+ if (!(error instanceof Error) || error.name !== "SubtitleModelDownloadInterrupted")
29
+ throw error;
30
+ lastFailure = error;
31
+ if (attempt < 3) {
32
+ if (deps.wait)
33
+ await deps.wait(attempt * 1000, input.signal);
34
+ else
35
+ await delay(attempt * 1000, undefined, { signal: input.signal });
36
+ }
37
+ }
38
+ }
39
+ throw new Error("The free subtitle model download was interrupted after 3 attempts. Check your connection and retry.", { cause: lastFailure });
40
+ }
41
+ async function downloadModel(input, deps, target) {
42
+ input.signal.throwIfAborted();
20
43
  const staging = await mkdtemp(join(input.cacheDir, ".alignment-download-"));
21
44
  const part = join(staging, "model.part");
22
45
  try {
23
- const response = await deps.fetch(deps.model.url, {
24
- signal: AbortSignal.any([input.signal, AbortSignal.timeout(300_000)]),
25
- });
26
- if (!response.ok || response.body === null)
27
- throw new Error(`The free subtitle model could not be downloaded (HTTP ${response.status}). Check your connection and try again.`);
46
+ let response;
47
+ try {
48
+ response = await deps.fetch(deps.model.url, {
49
+ signal: AbortSignal.any([input.signal, AbortSignal.timeout(300_000)]),
50
+ });
51
+ }
52
+ catch (error) {
53
+ throw interrupted(error);
54
+ }
55
+ if (!response.ok || response.body === null) {
56
+ const error = new Error(`The free subtitle model could not be downloaded (HTTP ${response.status}). Check your connection and try again.`);
57
+ let failure = error;
58
+ try {
59
+ if (response.body !== null)
60
+ await response.body.cancel();
61
+ }
62
+ catch (cleanup) {
63
+ failure = new AggregateError([error, cleanup], error.message, { cause: error });
64
+ }
65
+ if (response.status === 408 || response.status === 429 || response.status >= 500)
66
+ throw interrupted(failure);
67
+ throw failure;
68
+ }
28
69
  let current = 0;
29
70
  const hash = createHash("sha256");
30
71
  const reader = response.body.getReader();
31
72
  async function* chunks() {
73
+ let readFailed = false;
32
74
  try {
33
75
  for (;;) {
34
76
  input.signal.throwIfAborted();
35
- const result = await reader.read();
77
+ let result;
78
+ try {
79
+ result = await reader.read();
80
+ }
81
+ catch (error) {
82
+ readFailed = true;
83
+ throw interrupted(error);
84
+ }
36
85
  if (result.done)
37
86
  break;
38
87
  current += result.value.length;
@@ -44,7 +93,13 @@ export async function prepareModel(input, deps = { model: alignmentModel, fetch:
44
93
  }
45
94
  }
46
95
  finally {
47
- await reader.cancel();
96
+ try {
97
+ if (!readFailed)
98
+ await reader.cancel();
99
+ }
100
+ finally {
101
+ reader.releaseLock();
102
+ }
48
103
  }
49
104
  }
50
105
  await pipeline(Readable.from(chunks()), createWriteStream(part, { mode: 0o600 }), {
@@ -72,6 +127,11 @@ export async function prepareModel(input, deps = { model: alignmentModel, fetch:
72
127
  await rm(staging, { recursive: true, force: true });
73
128
  }
74
129
  }
130
+ function interrupted(cause) {
131
+ const error = new Error("The subtitle model download was interrupted.", { cause });
132
+ error.name = "SubtitleModelDownloadInterrupted";
133
+ return error;
134
+ }
75
135
  async function verified(path, model, signal) {
76
136
  try {
77
137
  if ((await stat(path)).size !== model.bytes)
package/dist/edge/cli.js CHANGED
@@ -16,6 +16,7 @@ const { values } = parseArgs({
16
16
  port: { type: "string" },
17
17
  host: { type: "string" },
18
18
  "data-dir": { type: "string" },
19
+ "projects-dir": { type: "string" },
19
20
  "no-open": { type: "boolean" },
20
21
  docker: { type: "boolean" },
21
22
  "host-cli": { type: "string" },
@@ -23,6 +24,8 @@ const { values } = parseArgs({
23
24
  },
24
25
  });
25
26
  try {
27
+ if (values["projects-dir"] !== undefined && !values.docker)
28
+ throw new Error("--projects-dir requires --docker; native installs use --data-dir.");
26
29
  if ((values["host-cli"] !== undefined || values["accept-host-cli"] !== undefined) &&
27
30
  !values.docker)
28
31
  throw new Error("Host CLI options require --docker.");
@@ -30,8 +33,10 @@ try {
30
33
  throw new Error("The --host-cli override supports only off (API-only Docker).");
31
34
  if (values.docker) {
32
35
  if (values.host !== undefined || values["data-dir"] !== undefined)
33
- throw new Error("Docker stores data in the slopify-data volume and binds to localhost. Use --port to change its port.");
34
- const { prepareDockerHostCli } = await import("./docker.js");
36
+ throw new Error("Docker keeps private data in its named volume and binds to localhost. Use --projects-dir for project files and --port for the port.");
37
+ const docker = await import("./docker.js");
38
+ docker.assertManagedDockerHost(process.platform, process.getuid?.(), process.getgid?.());
39
+ const { prepareDockerHostCli } = docker;
35
40
  const { nodeHostSetupRunner } = await import("../host-cli/install.js");
36
41
  const bridge = await prepareDockerHostCli({
37
42
  root: join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local/share"), "slopify/host-cli"),
@@ -62,6 +67,9 @@ try {
62
67
  ...process.env,
63
68
  SLOPIFY_HOST_CLI_DIR: bridge.directory ?? "",
64
69
  ...(values.port === undefined ? {} : { SLOPIFY_DOCKER_HOST_PORT: values.port }),
70
+ ...(values["projects-dir"] === undefined
71
+ ? {}
72
+ : { SLOPIFY_DOCKER_PROJECTS_DIR: values["projects-dir"] }),
65
73
  },
66
74
  });
67
75
  if (result.error)
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { constants } from "node:fs";
4
+ import { open } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { nodeHostSetupRunner } from "../host-cli/install.js";
9
+ import { assertManagedDockerHost } from "./docker.js";
10
+ import { dockerEngine } from "./docker-projects/engine.js";
11
+ import { installProjects } from "./docker-projects/install.js";
12
+ import { dockerConfig, privateDirectory } from "./docker-projects/state.js";
13
+ async function main() {
14
+ const uid = process.getuid?.();
15
+ const gid = process.getgid?.();
16
+ assertManagedDockerHost(process.platform, uid, gid);
17
+ if (gid === undefined)
18
+ throw new Error("Missing host group.");
19
+ const c = dockerConfig(process.env, homedir(), process.cwd(), uid, gid);
20
+ await privateDirectory(c.root, uid);
21
+ if (process.argv[2] !== "--locked") {
22
+ const path = join(c.root, "setup.lock");
23
+ const fd = await open(path, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
24
+ try {
25
+ const s = await fd.stat();
26
+ if (!s.isFile() || s.nlink !== 1 || s.uid !== uid || (s.mode & 0o077) !== 0)
27
+ throw new Error("Unsafe Docker setup lock.");
28
+ const child = spawn("flock", [
29
+ "--nonblock",
30
+ "--no-fork",
31
+ "--conflict-exit-code",
32
+ "73",
33
+ path,
34
+ process.execPath,
35
+ fileURLToPath(import.meta.url),
36
+ "--locked",
37
+ ], { stdio: "inherit" });
38
+ const code = await new Promise((resolve, reject) => {
39
+ child.once("error", () => reject(new Error("Install util-linux (flock) before using the managed Docker launcher.")));
40
+ child.once("exit", (status, signal) => signal
41
+ ? reject(new Error("Docker setup was interrupted; rerun to recover."))
42
+ : resolve(status ?? 1));
43
+ });
44
+ if (code === 73)
45
+ throw new Error("Another managed Docker installation is changing storage. Wait for it to finish and rerun.");
46
+ process.exitCode = code;
47
+ return;
48
+ }
49
+ finally {
50
+ await fd.close();
51
+ }
52
+ }
53
+ const controller = new AbortController();
54
+ const abort = () => controller.abort(new Error("Docker setup interrupted."));
55
+ process.once("SIGINT", abort);
56
+ process.once("SIGTERM", abort);
57
+ try {
58
+ const e = dockerEngine(nodeHostSetupRunner, controller.signal, process.env);
59
+ const recovery = () => dockerEngine(nodeHostSetupRunner, AbortSignal.timeout(5 * 60_000), process.env);
60
+ const result = await installProjects(c, e, recovery);
61
+ console.log(`Slopify is running at ${result.url}`);
62
+ console.log(`Project files on this machine: ${result.projects}`);
63
+ if (result.recovery)
64
+ console.log(`Recovery volume retained: ${result.recovery}. Previous containers remain stopped.`);
65
+ }
66
+ finally {
67
+ process.off("SIGINT", abort);
68
+ process.off("SIGTERM", abort);
69
+ }
70
+ }
71
+ try {
72
+ await main();
73
+ }
74
+ catch (error) {
75
+ console.error(error instanceof Error ? error.message : "Docker setup failed.");
76
+ process.exitCode = 1;
77
+ }
@@ -0,0 +1,35 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import { lstat, readFile } from "node:fs/promises";
3
+ import { isAbsolute, resolve } from "node:path";
4
+ import { z } from "zod";
5
+ import { readState } from "./state.js";
6
+ const activationSchema = z
7
+ .object({
8
+ version: z.literal(1),
9
+ token: z.string().regex(/^[a-f0-9]{64}$/),
10
+ committed: z.boolean(),
11
+ })
12
+ .strict();
13
+ export async function dockerActivationCommitted(path, token) {
14
+ if (!/^[a-f0-9]{64}$/.test(token))
15
+ return false;
16
+ const marker = await readState(path, activationSchema, process.getuid?.() ?? -1);
17
+ return (marker?.committed === true && timingSafeEqual(Buffer.from(marker.token), Buffer.from(token)));
18
+ }
19
+ export async function dockerFolderConfiguration(env, projectsRoot) {
20
+ if (env.SLOPIFY_CONTAINER !== "1")
21
+ return { container: false, hostProjects: null };
22
+ const host = env.SLOPIFY_DOCKER_PROJECTS_DIR;
23
+ if (!host || env.SLOPIFY_DOCKER_INSTALL_STATE !== "/opt/slopify-install/activation.json")
24
+ return { container: true, hostProjects: null };
25
+ if (!isAbsolute(host) || resolve(host) !== host || /[\p{Cc},]/u.test(host))
26
+ throw new Error("Invalid Docker host project path.");
27
+ const root = await lstat(projectsRoot);
28
+ const mounts = await readFile("/proc/self/mountinfo", "utf8");
29
+ if (projectsRoot !== "/data/projects" ||
30
+ !root.isDirectory() ||
31
+ root.isSymbolicLink() ||
32
+ !mounts.split("\n").some((line) => line.split(" ")[4] === "/data/projects"))
33
+ throw new Error("Managed Docker project bind is missing.");
34
+ return { container: true, hostProjects: host };
35
+ }
@@ -0,0 +1,85 @@
1
+ import { lstat, readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { journalSchema, readState } from "./state.js";
4
+ export async function assertVolumeClaims(c, e, daemon, volumeIdentity, current) {
5
+ const permitted = new Set(current ? [current.id] : []);
6
+ for (const entry of await readdir(c.directory)) {
7
+ if (!/^[a-f0-9-]{36}$/.test(entry))
8
+ continue;
9
+ const directory = join(c.directory, entry);
10
+ const s = await lstat(directory);
11
+ if (!s.isDirectory() || s.isSymbolicLink() || s.uid !== c.uid || (s.mode & 0o077) !== 0)
12
+ throw new Error("Unsafe recovery transaction directory.");
13
+ const j = await readState(join(directory, "journal.json"), journalSchema, c.uid);
14
+ if (!j ||
15
+ j.id !== entry ||
16
+ j.name !== c.name ||
17
+ j.daemon !== daemon ||
18
+ j.volume !== c.volume ||
19
+ j.volumeIdentity !== volumeIdentity)
20
+ continue;
21
+ const readerName = `slopify-reader-${j.id}`;
22
+ const reader = await e.inspect(readerName);
23
+ if (reader) {
24
+ const transaction = await e.command([
25
+ "inspect",
26
+ "--format",
27
+ '{{index .Config.Labels "io.slopify.reader"}}',
28
+ reader.id,
29
+ ]);
30
+ const source = reader.mounts.find((m) => m.destination === "/source");
31
+ const bind = reader.mounts.find((m) => m.destination === "/source/projects");
32
+ if (reader.name !== readerName ||
33
+ transaction !== j.id ||
34
+ reader.image !== j.image ||
35
+ reader.running ||
36
+ source?.type !== "volume" ||
37
+ source.name !== j.volume ||
38
+ source.rw ||
39
+ (j.sourceBind !== null &&
40
+ (bind?.type !== "bind" || bind.source !== j.sourceBind || bind.rw)) ||
41
+ reader.mounts.some((m) => m !== source &&
42
+ !(j.sourceBind !== null && m === bind) &&
43
+ !(m.type === "tmpfs" && m.destination === "/data")))
44
+ throw new Error("Copy reader identity or mounts conflict; recovery material is retained.");
45
+ await e.command(["rm", reader.id]);
46
+ }
47
+ if (j.previous) {
48
+ const prior = await e.inspect(j.previous.id);
49
+ if (prior &&
50
+ [j.previous.name, `${c.name}-previous-${j.id}`].includes(prior.name) &&
51
+ prior.image === j.previous.image &&
52
+ prior.user === j.previous.user &&
53
+ sameMounts(prior.mounts, j.previous.mounts))
54
+ permitted.add(prior.id);
55
+ }
56
+ if (j.candidate) {
57
+ const candidate = await e.inspect(j.candidate);
58
+ if (candidate &&
59
+ [c.name, `${c.name}-failed-${j.id}`].includes(candidate.name) &&
60
+ candidate.installation === j.installation &&
61
+ candidate.signature === j.signature)
62
+ permitted.add(candidate.id);
63
+ }
64
+ }
65
+ await e.claims(c.volume, [...permitted], (container) => legacyRollback(container, c.name));
66
+ }
67
+ // Docker reports a container's mounts in no fixed order, so the recorded shape is compared as
68
+ // a set; comparing the JSON as listed refused this installation's own retained container on
69
+ // some runs.
70
+ function sameMounts(a, b) {
71
+ const shape = (mounts) => JSON.stringify([...mounts].sort((x, y) => `${x.destination}\0${x.type}\0${x.name}\0${x.source}`.localeCompare(`${y.destination}\0${y.type}\0${y.name}\0${y.source}`)));
72
+ return shape(a) === shape(b);
73
+ }
74
+ // Launchers before project folders kept each replaced container stopped as a rollback, named
75
+ // `<name>-previous-<YYYYmmddHHMMSS>` with the launcher's label and restart off. Such a container
76
+ // never writes unless someone starts it by hand, which is equally true of the rollbacks this
77
+ // launcher keeps itself, so it does not block an upgrade. Anything running, restartable,
78
+ // unlabelled or otherwise named still does.
79
+ function legacyRollback(container, name) {
80
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
81
+ return (!container.running &&
82
+ container.restart.Name === "no" &&
83
+ container.signature !== null &&
84
+ new RegExp(`^${escaped}-previous-\\d{14}$`).test(container.name));
85
+ }
@@ -0,0 +1,40 @@
1
+ import { join } from "node:path";
2
+ import { journalSchema, readState, } from "./state.js";
3
+ export async function readCommittedJournal(c, receipt, current) {
4
+ const j = await readState(join(c.directory, receipt.transaction, "journal.json"), journalSchema, c.uid);
5
+ if (!j ||
6
+ j.id !== receipt.transaction ||
7
+ j.name !== receipt.name ||
8
+ j.installation !== receipt.installation ||
9
+ j.daemon !== receipt.daemon ||
10
+ j.volume !== receipt.volume ||
11
+ j.volumeIdentity !== receipt.volumeIdentity ||
12
+ j.destination !== receipt.projects ||
13
+ j.signature !== receipt.signature ||
14
+ j.image !== receipt.image ||
15
+ j.user !== receipt.user ||
16
+ j.publishedIdentity?.dev !== receipt.directoryIdentity.dev ||
17
+ j.publishedIdentity.ino !== receipt.directoryIdentity.ino)
18
+ throw new Error("Committed activation journal is missing or inconsistent.");
19
+ if (current) {
20
+ const data = current.mounts.find((m) => m.destination === "/data");
21
+ const projects = current.mounts.find((m) => m.destination === "/data/projects");
22
+ const activation = current.mounts.find((m) => m.destination === "/opt/slopify-install");
23
+ if (current.id !== j.candidate ||
24
+ current.image !== receipt.image ||
25
+ current.user !== receipt.user ||
26
+ current.signature !== receipt.signature ||
27
+ current.installation !== receipt.installation ||
28
+ data?.type !== "volume" ||
29
+ data.name !== receipt.volume ||
30
+ !data.rw ||
31
+ projects?.type !== "bind" ||
32
+ projects.source !== receipt.projects ||
33
+ !projects.rw ||
34
+ activation?.type !== "bind" ||
35
+ activation.source !== join(c.directory, receipt.transaction) ||
36
+ activation.rw)
37
+ throw new Error("Committed container identity or mounts differ from its receipt.");
38
+ }
39
+ return j;
40
+ }