@gentbajko/slopify 1.5.0 → 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.
- package/README.md +34 -8
- package/dist/adapters/alignment/cache.js +67 -7
- package/dist/edge/cli.js +10 -2
- package/dist/edge/docker-launch.js +77 -0
- package/dist/edge/docker-projects/activation.js +35 -0
- package/dist/edge/docker-projects/claims.js +85 -0
- package/dist/edge/docker-projects/committed.js +40 -0
- package/dist/edge/docker-projects/engine.js +384 -0
- package/dist/edge/docker-projects/install.js +246 -0
- package/dist/edge/docker-projects/recover.js +100 -0
- package/dist/edge/docker-projects/state.js +221 -0
- package/dist/edge/docker-projects/tree.js +170 -0
- package/dist/edge/docker-projects/volume.js +95 -0
- package/dist/edge/docker.js +6 -0
- package/dist/edge/http/actions.js +64 -15
- package/dist/edge/http/app.js +6 -0
- package/dist/edge/http/folder-location-schema.js +11 -0
- package/dist/edge/http/folder-location.js +65 -0
- package/dist/edge/http/open-folder.js +2 -14
- package/dist/edge/http/projects.js +2 -0
- package/dist/edge/http/revision-files.js +2 -18
- package/dist/kernel/db/migrations/0012-project-recovery.sql +17 -0
- package/dist/kernel/lock.js +20 -6
- package/dist/main.js +11 -1
- package/dist/slices/admission/rules.js +6 -0
- package/dist/slices/admission/schema.js +3 -1
- package/dist/slices/article/plain.js +16 -9
- package/dist/slices/control/revision-control.js +2 -2
- package/dist/slices/narration/pronunciation-chunks.js +105 -0
- package/dist/slices/narration/pronunciation.js +126 -0
- package/dist/slices/narration/steering.js +82 -24
- package/dist/slices/play-drafts/convert.js +3 -1
- package/dist/slices/play-drafts/schema.js +3 -1
- package/dist/slices/rebuild/admission-repo.js +7 -4
- package/dist/slices/rebuild/preview-plan.js +1 -1
- package/dist/slices/rebuild/preview-retained.js +16 -3
- package/dist/slices/rebuild/recipe-audio-parts.js +70 -14
- package/dist/slices/rebuild/recipe-audio.js +61 -40
- package/dist/slices/rebuild/recipe-model.js +5 -3
- package/dist/slices/rebuild/recipe-preparation.js +6 -4
- package/dist/slices/rebuild/recipe-text.js +11 -3
- package/dist/slices/rebuild/recovery-conflict.js +30 -0
- package/dist/slices/rebuild/recovery-model.js +44 -0
- package/dist/slices/rebuild/recovery-repo.js +84 -0
- package/dist/slices/rebuild/recovery-selection.js +68 -0
- package/dist/slices/rebuild/recovery.js +257 -0
- package/dist/slices/rebuild/runtime-export-inputs.js +3 -2
- package/dist/slices/rebuild/runtime-narration-publication.js +30 -0
- package/dist/slices/rebuild/runtime-narration-reuse.js +20 -2
- package/dist/slices/rebuild/runtime-plan.js +8 -2
- package/dist/slices/rebuild/runtime-publication.js +9 -4
- package/dist/slices/rebuild/service.js +44 -22
- package/dist/slices/rebuild/transition-repo.js +1 -1
- package/dist/slices/revisions/mutation-assets.js +6 -1
- package/dist/slices/revisions/mutation-request.js +4 -0
- package/dist/slices/revisions/mutations.js +10 -5
- package/dist/slices/revisions/rules.js +43 -0
- package/dist/slices/revisions/schema.js +12 -0
- package/dist/slices/settings/repo.js +4 -2
- package/dist/web/assets/index-CZNiHm8P.css +1 -0
- package/dist/web/assets/index-DPgGLX5m.js +137 -0
- package/dist/web/index.html +2 -2
- package/package.json +3 -2
- package/scripts/docker-run.sh +1 -74
- package/dist/web/assets/index-CbGJtCio.js +0 -137
- package/dist/web/assets/index-DX1_chKs.css +0 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { readCommittedJournal } from "./committed.js";
|
|
3
|
+
import { selectProjects, writeState, } from "./state.js";
|
|
4
|
+
import { assertIdentity, assertWritableTree, identity, isMissing, treeDigest } from "./tree.js";
|
|
5
|
+
export async function assertSourceIdentity(j) {
|
|
6
|
+
if (j.sourceBind === null)
|
|
7
|
+
return;
|
|
8
|
+
if (!j.sourceIdentity)
|
|
9
|
+
throw new Error("Original project bind identity is missing.");
|
|
10
|
+
await assertIdentity(j.sourceBind, j.sourceIdentity);
|
|
11
|
+
}
|
|
12
|
+
export async function recoverInstallation(c, j, receipt, e) {
|
|
13
|
+
const directory = join(c.directory, j.id);
|
|
14
|
+
if (j.name !== c.name || j.volume !== c.volume)
|
|
15
|
+
throw new Error("Journal installation conflict.");
|
|
16
|
+
if (receipt?.transaction === j.id) {
|
|
17
|
+
const current = await e.inspect(c.name);
|
|
18
|
+
await selectProjects({ ...c, projectsOverride: null }, receipt, current);
|
|
19
|
+
await assertWritableTree(receipt.projects, c.uid);
|
|
20
|
+
j = await readCommittedJournal(c, receipt, current);
|
|
21
|
+
await e.writers(c.volume, [receipt.projects], current ? [current.id] : []);
|
|
22
|
+
await writeState(join(directory, "activation.json"), {
|
|
23
|
+
version: 1,
|
|
24
|
+
token: j.token,
|
|
25
|
+
committed: true,
|
|
26
|
+
});
|
|
27
|
+
if (current && current.installation === j.installation && current.signature === j.signature) {
|
|
28
|
+
await e.command(["update", "--restart=always", current.id]);
|
|
29
|
+
if (!current.running)
|
|
30
|
+
await e.command(["start", current.id]);
|
|
31
|
+
}
|
|
32
|
+
const finished = { ...j, phase: "committed" };
|
|
33
|
+
await writeState(join(c.directory, "journal.json"), finished);
|
|
34
|
+
return finished;
|
|
35
|
+
}
|
|
36
|
+
if (j.phase === "rolled-back")
|
|
37
|
+
return j;
|
|
38
|
+
await assertSourceIdentity(j);
|
|
39
|
+
const occupant = await e.inspect(c.name);
|
|
40
|
+
if (j.phase === "verified" && j.stagingIdentity && j.sourceDigest) {
|
|
41
|
+
const found = await identity(j.destination).catch((error) => {
|
|
42
|
+
if (isMissing(error))
|
|
43
|
+
return null;
|
|
44
|
+
throw error;
|
|
45
|
+
});
|
|
46
|
+
if (found?.dev === j.stagingIdentity.dev &&
|
|
47
|
+
found.ino === j.stagingIdentity.ino &&
|
|
48
|
+
(await treeDigest(j.destination)).hash === j.sourceDigest.hash) {
|
|
49
|
+
j = { ...j, publishedIdentity: found };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const possible = j.candidate ? await e.inspect(j.candidate) : occupant;
|
|
53
|
+
if (occupant && occupant.id !== j.previous?.id && occupant.id !== possible?.id)
|
|
54
|
+
throw new Error("Recovery name is occupied by an unrelated container.");
|
|
55
|
+
if (possible && possible.id !== j.previous?.id) {
|
|
56
|
+
const transaction = await e.command([
|
|
57
|
+
"inspect",
|
|
58
|
+
"--format",
|
|
59
|
+
'{{index .Config.Labels "io.slopify.transaction"}}',
|
|
60
|
+
possible.id,
|
|
61
|
+
]);
|
|
62
|
+
if (transaction !== j.id ||
|
|
63
|
+
possible.installation !== j.installation ||
|
|
64
|
+
possible.signature !== j.signature)
|
|
65
|
+
throw new Error("Recovery name is occupied by an unrelated container.");
|
|
66
|
+
if (j.candidate !== possible.id) {
|
|
67
|
+
j = { ...j, candidate: possible.id };
|
|
68
|
+
await writeState(join(c.directory, "journal.json"), j);
|
|
69
|
+
await writeState(join(directory, "journal.json"), j);
|
|
70
|
+
}
|
|
71
|
+
await e.stop(possible);
|
|
72
|
+
const failedName = `${c.name}-failed-${j.id}`;
|
|
73
|
+
if (possible.name !== failedName)
|
|
74
|
+
await e.command(["rename", possible.id, failedName]);
|
|
75
|
+
}
|
|
76
|
+
if (j.phase !== "restored") {
|
|
77
|
+
if (j.backupDigest) {
|
|
78
|
+
await e.writers(c.volume, [j.destination, ...(j.sourceBind ? [j.sourceBind] : [])], []);
|
|
79
|
+
await e.restore(j);
|
|
80
|
+
}
|
|
81
|
+
j = { ...j, phase: "restored" };
|
|
82
|
+
await writeState(join(c.directory, "journal.json"), j);
|
|
83
|
+
await writeState(join(directory, "journal.json"), j);
|
|
84
|
+
}
|
|
85
|
+
if (j.previous) {
|
|
86
|
+
const old = await e.inspect(j.previous.id);
|
|
87
|
+
if (!old || old.mounts.find((m) => m.destination === "/data")?.name !== c.volume)
|
|
88
|
+
throw new Error("Original container/storage is missing; automatic rollback is unsafe.");
|
|
89
|
+
if (j.previousReceipt)
|
|
90
|
+
await selectProjects({ ...c, projectsOverride: null }, j.previousReceipt, old);
|
|
91
|
+
if (j.sourceBind !== null)
|
|
92
|
+
await assertWritableTree(j.sourceBind, c.uid);
|
|
93
|
+
await e.writers(c.volume, [j.destination, ...(j.sourceBind ? [j.sourceBind] : [])], [old.id]);
|
|
94
|
+
await e.restart(j.previous);
|
|
95
|
+
}
|
|
96
|
+
const rolledBack = { ...j, phase: "rolled-back" };
|
|
97
|
+
await writeState(join(c.directory, "journal.json"), rolledBack);
|
|
98
|
+
await writeState(join(directory, "journal.json"), rolledBack);
|
|
99
|
+
return rolledBack;
|
|
100
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { lstat, mkdir, readdir } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { privateRead, privateWrite } from "../../host-cli/service.js";
|
|
5
|
+
import { assertIdentity, contains, isMissing, safePath, syncDirectory, treeDigest, } from "./tree.js";
|
|
6
|
+
const identifier = z
|
|
7
|
+
.string()
|
|
8
|
+
.min(1)
|
|
9
|
+
.max(128)
|
|
10
|
+
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/);
|
|
11
|
+
const absolute = z
|
|
12
|
+
.string()
|
|
13
|
+
.min(1)
|
|
14
|
+
.max(4096)
|
|
15
|
+
.refine((p) => isAbsolute(p) && resolve(p) === p && !/[\p{Cc},]/u.test(p));
|
|
16
|
+
export const identitySchema = z.object({ dev: z.string(), ino: z.string() }).strict();
|
|
17
|
+
export const digestSchema = z
|
|
18
|
+
.object({
|
|
19
|
+
hash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
20
|
+
files: z.number().int().nonnegative(),
|
|
21
|
+
bytes: z.number().int().nonnegative(),
|
|
22
|
+
})
|
|
23
|
+
.strict();
|
|
24
|
+
export const containerSchema = z
|
|
25
|
+
.object({
|
|
26
|
+
id: z.string(),
|
|
27
|
+
name: z.string(),
|
|
28
|
+
image: z.string(),
|
|
29
|
+
user: z.string(),
|
|
30
|
+
running: z.boolean(),
|
|
31
|
+
restart: z.object({ Name: z.string(), MaximumRetryCount: z.number().int() }),
|
|
32
|
+
signature: z.string().nullable(),
|
|
33
|
+
installation: z.string().nullable(),
|
|
34
|
+
mounts: z.array(z.object({
|
|
35
|
+
type: z.string(),
|
|
36
|
+
name: z.string(),
|
|
37
|
+
source: z.string(),
|
|
38
|
+
destination: z.string(),
|
|
39
|
+
rw: z.boolean(),
|
|
40
|
+
})),
|
|
41
|
+
port: z.string().nullable(),
|
|
42
|
+
})
|
|
43
|
+
.strict();
|
|
44
|
+
export const receiptSchema = z
|
|
45
|
+
.object({
|
|
46
|
+
version: z.literal(1),
|
|
47
|
+
installation: z.string().uuid(),
|
|
48
|
+
daemon: z.string(),
|
|
49
|
+
name: identifier,
|
|
50
|
+
volume: identifier,
|
|
51
|
+
volumeIdentity: z.string(),
|
|
52
|
+
projects: absolute,
|
|
53
|
+
directoryIdentity: identitySchema,
|
|
54
|
+
user: z.string(),
|
|
55
|
+
image: z.string(),
|
|
56
|
+
signature: z.string(),
|
|
57
|
+
transaction: z.string().uuid(),
|
|
58
|
+
})
|
|
59
|
+
.strict();
|
|
60
|
+
export const phases = [
|
|
61
|
+
"prepared",
|
|
62
|
+
"stopping",
|
|
63
|
+
"stopped",
|
|
64
|
+
"snapshot",
|
|
65
|
+
"copying",
|
|
66
|
+
"verified",
|
|
67
|
+
"published",
|
|
68
|
+
"starting",
|
|
69
|
+
"healthy",
|
|
70
|
+
"committed",
|
|
71
|
+
"restored",
|
|
72
|
+
"rolled-back",
|
|
73
|
+
];
|
|
74
|
+
export const journalSchema = z
|
|
75
|
+
.object({
|
|
76
|
+
version: z.literal(1),
|
|
77
|
+
id: z.string().uuid(),
|
|
78
|
+
installation: z.string().uuid(),
|
|
79
|
+
daemon: z.string(),
|
|
80
|
+
name: identifier,
|
|
81
|
+
volume: identifier,
|
|
82
|
+
volumeIdentity: z.string().nullable(),
|
|
83
|
+
image: z.string(),
|
|
84
|
+
user: z.string(),
|
|
85
|
+
signature: z.string(),
|
|
86
|
+
phase: z.enum(phases),
|
|
87
|
+
previous: containerSchema.nullable(),
|
|
88
|
+
previousReceipt: receiptSchema.nullable(),
|
|
89
|
+
sourceBind: absolute.nullable(),
|
|
90
|
+
sourceIdentity: identitySchema.nullable().default(null),
|
|
91
|
+
destination: absolute,
|
|
92
|
+
destinationBefore: identitySchema.nullable(),
|
|
93
|
+
staging: absolute,
|
|
94
|
+
stagingIdentity: identitySchema.nullable(),
|
|
95
|
+
publishedIdentity: identitySchema.nullable(),
|
|
96
|
+
sourceDigest: digestSchema.nullable(),
|
|
97
|
+
sourceAbsent: z.boolean().default(false),
|
|
98
|
+
backup: z
|
|
99
|
+
.string()
|
|
100
|
+
.max(174)
|
|
101
|
+
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/),
|
|
102
|
+
backupDigest: digestSchema.nullable(),
|
|
103
|
+
candidate: z.string().nullable(),
|
|
104
|
+
token: z.string().regex(/^[a-f0-9]{64}$/),
|
|
105
|
+
})
|
|
106
|
+
.strict();
|
|
107
|
+
export function dockerConfig(env, home, cwd, uid, gid) {
|
|
108
|
+
const name = identifier.parse(env.SLOPIFY_DOCKER_NAME || "slopify");
|
|
109
|
+
const volume = identifier.parse(env.SLOPIFY_DOCKER_VOLUME || "slopify-data");
|
|
110
|
+
const root = join(absolute.parse(env.XDG_DATA_HOME || join(home, ".local/share")), "slopify/docker");
|
|
111
|
+
const raw = env.SLOPIFY_DOCKER_PROJECTS_DIR || null;
|
|
112
|
+
const projectsOverride = raw === null ? null : resolve(cwd, raw.startsWith("~/") ? join(home, raw.slice(2)) : raw);
|
|
113
|
+
const portText = env.SLOPIFY_DOCKER_HOST_PORT || "6969";
|
|
114
|
+
if (!/^\d{1,5}$/.test(portText) || Number(portText) > 65535)
|
|
115
|
+
throw new Error("Docker host port must be from 0 to 65535.");
|
|
116
|
+
return {
|
|
117
|
+
home,
|
|
118
|
+
uid,
|
|
119
|
+
gid,
|
|
120
|
+
root,
|
|
121
|
+
directory: join(root, name),
|
|
122
|
+
name,
|
|
123
|
+
volume,
|
|
124
|
+
image: z
|
|
125
|
+
.string()
|
|
126
|
+
.min(1)
|
|
127
|
+
.refine((s) => !/[\p{Cc}]/u.test(s))
|
|
128
|
+
.parse(env.SLOPIFY_DOCKER_IMAGE || "ghcr.io/gentbajko/slopify:latest"),
|
|
129
|
+
port: Number(portText),
|
|
130
|
+
projectsOverride,
|
|
131
|
+
bridge: env.SLOPIFY_HOST_CLI_DIR || null,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
export async function privateDirectory(path, uid) {
|
|
135
|
+
absolute.parse(path);
|
|
136
|
+
let current = sep;
|
|
137
|
+
for (const name of path.split(sep).filter(Boolean)) {
|
|
138
|
+
current = join(current, name);
|
|
139
|
+
await mkdir(current, { mode: 0o700 }).catch((error) => {
|
|
140
|
+
if (!(typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST"))
|
|
141
|
+
throw error;
|
|
142
|
+
});
|
|
143
|
+
const s = await lstat(current);
|
|
144
|
+
if (!s.isDirectory() ||
|
|
145
|
+
s.isSymbolicLink() ||
|
|
146
|
+
((s.mode & 0o022) !== 0 && (s.mode & 0o1000) === 0))
|
|
147
|
+
throw new Error("Unsafe private state ancestor.");
|
|
148
|
+
}
|
|
149
|
+
const s = await lstat(path);
|
|
150
|
+
if (s.uid !== uid || (s.mode & 0o077) !== 0)
|
|
151
|
+
throw new Error("Installation state must be private and owned by the installing user.");
|
|
152
|
+
}
|
|
153
|
+
export async function readState(path, schema, uid) {
|
|
154
|
+
const s = await lstat(path).catch((error) => {
|
|
155
|
+
if (isMissing(error))
|
|
156
|
+
return undefined;
|
|
157
|
+
throw error;
|
|
158
|
+
});
|
|
159
|
+
if (s === undefined)
|
|
160
|
+
return null;
|
|
161
|
+
if (!s.isFile() || s.isSymbolicLink() || s.nlink !== 1 || s.uid !== uid || (s.mode & 0o077) !== 0)
|
|
162
|
+
throw new Error("Unsafe private installation record.");
|
|
163
|
+
const text = await privateRead(path, uid);
|
|
164
|
+
if (text === undefined)
|
|
165
|
+
throw new Error("Installation record changed while reading.");
|
|
166
|
+
return schema.parse(JSON.parse(text));
|
|
167
|
+
}
|
|
168
|
+
export async function writeState(path, value) {
|
|
169
|
+
const text = JSON.stringify(value);
|
|
170
|
+
if (Buffer.byteLength(text) > 64 * 1024)
|
|
171
|
+
throw new Error("Installation control record exceeds 64 KiB.");
|
|
172
|
+
await privateWrite(path, text);
|
|
173
|
+
await syncDirectory(dirname(path));
|
|
174
|
+
}
|
|
175
|
+
export async function selectProjects(c, receipt, old, retry = null) {
|
|
176
|
+
const data = old?.mounts.find((m) => m.destination === "/data");
|
|
177
|
+
const bind = old?.mounts.find((m) => m.destination === "/data/projects");
|
|
178
|
+
if (old && (data?.type !== "volume" || data.name !== c.volume))
|
|
179
|
+
throw new Error("Existing container uses a different data volume.");
|
|
180
|
+
if (bind && (bind.type !== "bind" || !bind.rw))
|
|
181
|
+
throw new Error("Unsupported existing projects mount.");
|
|
182
|
+
if (old?.mounts.some((m) => m.destination.startsWith("/data/") && m.destination !== "/data/projects"))
|
|
183
|
+
throw new Error("Unexpected nested data mount; use the existing installation for recovery.");
|
|
184
|
+
if (receipt) {
|
|
185
|
+
if (receipt.name !== c.name || receipt.volume !== c.volume)
|
|
186
|
+
throw new Error("Receipt selects a different installation or volume.");
|
|
187
|
+
await safePath(receipt.projects, c.home, c.root, false);
|
|
188
|
+
await assertIdentity(receipt.projects, receipt.directoryIdentity).catch((cause) => {
|
|
189
|
+
throw new Error(`Remembered project folder is missing or replaced: ${receipt.projects}`, {
|
|
190
|
+
cause,
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
if (old && (bind?.source !== receipt.projects || old.installation !== receipt.installation))
|
|
194
|
+
throw new Error("Actual container mounts/installation disagree with its receipt.");
|
|
195
|
+
}
|
|
196
|
+
const chosen = c.projectsOverride ??
|
|
197
|
+
receipt?.projects ??
|
|
198
|
+
bind?.source ??
|
|
199
|
+
(c.name === "slopify"
|
|
200
|
+
? join(c.home, "Slopify/Projects")
|
|
201
|
+
: join(c.home, "Slopify", c.name, "Projects"));
|
|
202
|
+
await safePath(chosen, c.home, c.root, true);
|
|
203
|
+
const source = receipt?.projects ?? bind?.source;
|
|
204
|
+
if (source && source !== chosen && (contains(source, chosen) || contains(chosen, source)))
|
|
205
|
+
throw new Error("Source and destination project folders cannot contain each other.");
|
|
206
|
+
if (chosen !== source) {
|
|
207
|
+
const entries = await readdir(chosen).catch((error) => {
|
|
208
|
+
if (isMissing(error))
|
|
209
|
+
return [];
|
|
210
|
+
throw error;
|
|
211
|
+
});
|
|
212
|
+
if (entries.length > 0) {
|
|
213
|
+
if (retry?.destination !== chosen || !retry.publishedIdentity || !retry.sourceDigest)
|
|
214
|
+
throw new Error(`Unrelated populated destination: ${chosen}`);
|
|
215
|
+
await assertIdentity(chosen, retry.publishedIdentity);
|
|
216
|
+
if ((await treeDigest(chosen)).hash !== retry.sourceDigest.hash)
|
|
217
|
+
throw new Error(`Published retry destination changed: ${chosen}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return chosen;
|
|
221
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { chmod, lchown, lstat, mkdir, open, readdir, readlink } from "node:fs/promises";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
export async function identity(path) {
|
|
6
|
+
const s = await lstat(path, { bigint: true });
|
|
7
|
+
if (!s.isDirectory() || s.isSymbolicLink())
|
|
8
|
+
throw new Error("Unsafe directory identity.");
|
|
9
|
+
return { dev: String(s.dev), ino: String(s.ino) };
|
|
10
|
+
}
|
|
11
|
+
export async function assertIdentity(path, expected) {
|
|
12
|
+
const actual = await identity(path);
|
|
13
|
+
if (actual.dev !== expected.dev || actual.ino !== expected.ino)
|
|
14
|
+
throw new Error(`Directory identity changed: ${path}`);
|
|
15
|
+
}
|
|
16
|
+
export function contains(root, path) {
|
|
17
|
+
const r = relative(root, path);
|
|
18
|
+
return r === "" || (r !== ".." && !r.startsWith(`..${sep}`) && !isAbsolute(r));
|
|
19
|
+
}
|
|
20
|
+
export async function safePath(path, home, stateRoot, createParents) {
|
|
21
|
+
if (!isAbsolute(path) || resolve(path) !== path || /[\p{Cc},]/u.test(path))
|
|
22
|
+
throw new Error("Project directory must be a normal absolute path without commas/control characters.");
|
|
23
|
+
const forbidden = [
|
|
24
|
+
"/",
|
|
25
|
+
"/home",
|
|
26
|
+
"/root",
|
|
27
|
+
"/tmp",
|
|
28
|
+
"/var",
|
|
29
|
+
"/srv",
|
|
30
|
+
"/mnt",
|
|
31
|
+
"/media",
|
|
32
|
+
home,
|
|
33
|
+
join(home, "Slopify"),
|
|
34
|
+
];
|
|
35
|
+
if (forbidden.includes(path) ||
|
|
36
|
+
contains(stateRoot, path) ||
|
|
37
|
+
contains(path, stateRoot) ||
|
|
38
|
+
["/proc", "/sys", "/dev", "/etc", "/usr", "/bin", "/sbin", "/lib", "/var/lib/docker"].some((p) => contains(p, path)))
|
|
39
|
+
throw new Error("Unsafe broad or private project directory.");
|
|
40
|
+
const parts = path.split(sep).filter(Boolean);
|
|
41
|
+
let current = sep;
|
|
42
|
+
for (const [i, part] of parts.entries()) {
|
|
43
|
+
current = join(current, part);
|
|
44
|
+
let s = await lstat(current).catch((error) => {
|
|
45
|
+
if (isMissing(error))
|
|
46
|
+
return undefined;
|
|
47
|
+
throw error;
|
|
48
|
+
});
|
|
49
|
+
if (s === undefined && i < parts.length - 1 && createParents) {
|
|
50
|
+
await mkdir(current, { mode: 0o700 });
|
|
51
|
+
s = await lstat(current);
|
|
52
|
+
}
|
|
53
|
+
if (s === undefined)
|
|
54
|
+
continue;
|
|
55
|
+
if (!s.isDirectory() || s.isSymbolicLink())
|
|
56
|
+
throw new Error(`Unsafe path component: ${current}`);
|
|
57
|
+
if (i === parts.length - 1 && (s.uid !== process.getuid?.() || (s.mode & 0o022) !== 0))
|
|
58
|
+
throw new Error("Project directory must belong to the installing user and must not be publicly writable.");
|
|
59
|
+
if (i < parts.length - 1 && (s.mode & 0o022) !== 0 && (s.mode & 0o1000) === 0)
|
|
60
|
+
throw new Error(`Writable project ancestor: ${current}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function isMissing(error) {
|
|
64
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
65
|
+
}
|
|
66
|
+
export async function syncDirectory(path) {
|
|
67
|
+
const fd = await open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
68
|
+
try {
|
|
69
|
+
await fd.sync();
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await fd.close();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export async function treeDigest(root, metadata = false, omitProjects = false) {
|
|
76
|
+
const hash = createHash("sha256");
|
|
77
|
+
let files = 0;
|
|
78
|
+
let bytes = 0;
|
|
79
|
+
async function walk(path, name) {
|
|
80
|
+
const before = await lstat(path, { bigint: true });
|
|
81
|
+
const meta = metadata
|
|
82
|
+
? [String(before.uid), String(before.gid), Number(before.mode & 4095n)]
|
|
83
|
+
: [];
|
|
84
|
+
if (before.isDirectory()) {
|
|
85
|
+
hash.update(`${JSON.stringify([name, "directory", ...meta])}\n`);
|
|
86
|
+
for (const entry of (await readdir(path)).sort()) {
|
|
87
|
+
if (omitProjects && name === "" && entry === "projects")
|
|
88
|
+
continue;
|
|
89
|
+
await walk(join(path, entry), name === "" ? entry : `${name}/${entry}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else if (before.isSymbolicLink() && metadata) {
|
|
93
|
+
hash.update(`${JSON.stringify([name, "symlink", await readlink(path), ...meta])}\n`);
|
|
94
|
+
}
|
|
95
|
+
else if (before.isFile() && (metadata || before.nlink === 1n)) {
|
|
96
|
+
const fd = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
97
|
+
const content = createHash("sha256");
|
|
98
|
+
try {
|
|
99
|
+
const opened = await fd.stat({ bigint: true });
|
|
100
|
+
if (opened.dev !== before.dev || opened.ino !== before.ino)
|
|
101
|
+
throw new Error("Source changed during verification.");
|
|
102
|
+
const buffer = Buffer.alloc(1024 * 1024);
|
|
103
|
+
let length = 0;
|
|
104
|
+
for (;;) {
|
|
105
|
+
const result = await fd.read(buffer, 0, buffer.length, null);
|
|
106
|
+
if (result.bytesRead === 0)
|
|
107
|
+
break;
|
|
108
|
+
length += result.bytesRead;
|
|
109
|
+
content.update(buffer.subarray(0, result.bytesRead));
|
|
110
|
+
}
|
|
111
|
+
const after = await fd.stat({ bigint: true });
|
|
112
|
+
if (after.size !== before.size ||
|
|
113
|
+
after.mtimeNs !== before.mtimeNs ||
|
|
114
|
+
BigInt(length) !== before.size)
|
|
115
|
+
throw new Error("Source changed during verification.");
|
|
116
|
+
files++;
|
|
117
|
+
bytes += length;
|
|
118
|
+
hash.update(`${JSON.stringify([name, "file", length, content.digest("hex"), ...meta])}\n`);
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
await fd.close();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else
|
|
125
|
+
throw new Error(`Unsafe project entry: ${name}`);
|
|
126
|
+
const after = await lstat(path, { bigint: true });
|
|
127
|
+
if (after.dev !== before.dev || after.ino !== before.ino || after.mtimeNs !== before.mtimeNs)
|
|
128
|
+
throw new Error("Source changed during verification.");
|
|
129
|
+
}
|
|
130
|
+
await walk(root, "");
|
|
131
|
+
return { hash: hash.digest("hex"), files, bytes };
|
|
132
|
+
}
|
|
133
|
+
export async function assertWritableTree(root, uid) {
|
|
134
|
+
const s = await lstat(root);
|
|
135
|
+
if (s.isSymbolicLink() ||
|
|
136
|
+
(!s.isDirectory() && !s.isFile()) ||
|
|
137
|
+
(s.isFile() && s.nlink !== 1) ||
|
|
138
|
+
s.uid !== uid ||
|
|
139
|
+
(s.mode & (s.isDirectory() ? 0o700 : 0o600)) !== (s.isDirectory() ? 0o700 : 0o600))
|
|
140
|
+
throw new Error(`Existing project permissions are incompatible with this host user: ${root}`);
|
|
141
|
+
if (s.isDirectory())
|
|
142
|
+
for (const name of await readdir(root))
|
|
143
|
+
await assertWritableTree(join(root, name), uid);
|
|
144
|
+
}
|
|
145
|
+
export async function privateTree(root, uid, gid) {
|
|
146
|
+
async function visit(path) {
|
|
147
|
+
const s = await lstat(path);
|
|
148
|
+
if (s.isSymbolicLink() || (!s.isDirectory() && !s.isFile()) || (s.isFile() && s.nlink !== 1))
|
|
149
|
+
throw new Error("Unsafe project entry during ownership setup.");
|
|
150
|
+
if (s.uid !== uid || s.gid !== gid)
|
|
151
|
+
await lchown(path, uid, gid);
|
|
152
|
+
await chmod(path, s.isDirectory() ? 0o700 : 0o600);
|
|
153
|
+
if (s.isDirectory()) {
|
|
154
|
+
for (const name of await readdir(path))
|
|
155
|
+
await visit(join(path, name));
|
|
156
|
+
await syncDirectory(path);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
const fd = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
160
|
+
try {
|
|
161
|
+
await fd.sync();
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
await fd.close();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
await visit(root);
|
|
169
|
+
await syncDirectory(dirname(root));
|
|
170
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { chmod, lchown, lstat, mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { isMissing, syncDirectory, treeDigest } from "./tree.js";
|
|
6
|
+
export async function volumeOperation(args) {
|
|
7
|
+
const [operation, user = "0:0"] = args;
|
|
8
|
+
if (operation === "projects") {
|
|
9
|
+
const present = await lstat("/source/projects").catch((e) => {
|
|
10
|
+
if (isMissing(e))
|
|
11
|
+
return null;
|
|
12
|
+
throw e;
|
|
13
|
+
});
|
|
14
|
+
if (present === null)
|
|
15
|
+
return { exists: false };
|
|
16
|
+
return { exists: true, digest: await treeDigest("/source/projects") };
|
|
17
|
+
}
|
|
18
|
+
if (operation === "private")
|
|
19
|
+
return treeDigest("/source", true, true);
|
|
20
|
+
if (operation === "snapshot") {
|
|
21
|
+
if ((await readdir("/backup")).length !== 0)
|
|
22
|
+
throw new Error("Recovery volume is not empty.");
|
|
23
|
+
const before = await treeDigest("/source", true);
|
|
24
|
+
execFileSync("cp", ["-a", "/source/.", "/backup/"], { stdio: "pipe" });
|
|
25
|
+
const copied = await treeDigest("/backup", true);
|
|
26
|
+
const after = await treeDigest("/source", true);
|
|
27
|
+
if (before.hash !== copied.hash || before.hash !== after.hash)
|
|
28
|
+
throw new Error("Private snapshot verification failed.");
|
|
29
|
+
execFileSync("sync", ["-f", "/backup"], { stdio: "pipe" });
|
|
30
|
+
await syncDirectory("/backup");
|
|
31
|
+
return treeDigest("/backup", true, true);
|
|
32
|
+
}
|
|
33
|
+
if (operation === "restore") {
|
|
34
|
+
const source = await treeDigest("/source", true, true);
|
|
35
|
+
for (const name of await readdir("/data")) {
|
|
36
|
+
if (name !== "projects")
|
|
37
|
+
await rm(join("/data", name), { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
for (const name of await readdir("/source")) {
|
|
40
|
+
if (name !== "projects")
|
|
41
|
+
execFileSync("cp", ["-a", "--", join("/source", name), "/data/"], { stdio: "pipe" });
|
|
42
|
+
}
|
|
43
|
+
const root = await lstat("/source");
|
|
44
|
+
await lchown("/data", root.uid, root.gid);
|
|
45
|
+
await chmod("/data", root.mode & 0o7777);
|
|
46
|
+
execFileSync("sync", ["-f", "/data"], { stdio: "pipe" });
|
|
47
|
+
await syncDirectory("/data");
|
|
48
|
+
const restored = await treeDigest("/data", true, true);
|
|
49
|
+
if (source.hash !== restored.hash)
|
|
50
|
+
throw new Error("Private volume restore verification failed.");
|
|
51
|
+
return restored;
|
|
52
|
+
}
|
|
53
|
+
if (!/^\d+:\d+$/.test(user))
|
|
54
|
+
throw new Error("Invalid container owner.");
|
|
55
|
+
const [uid, gid] = user.split(":").map(Number);
|
|
56
|
+
if (uid === undefined || gid === undefined)
|
|
57
|
+
throw new Error("Missing container owner.");
|
|
58
|
+
if (operation === "own") {
|
|
59
|
+
const owner = { uid, gid };
|
|
60
|
+
async function own(path) {
|
|
61
|
+
const s = await lstat(path);
|
|
62
|
+
if (!s.isDirectory() && !s.isFile() && !s.isSymbolicLink())
|
|
63
|
+
throw new Error("Unsupported private-volume entry.");
|
|
64
|
+
await lchown(path, owner.uid, owner.gid);
|
|
65
|
+
if (s.isSymbolicLink())
|
|
66
|
+
return;
|
|
67
|
+
await chmod(path, s.isDirectory() ? 0o700 : 0o600 | (s.mode & 0o100));
|
|
68
|
+
if (s.isDirectory()) {
|
|
69
|
+
for (const name of await readdir(path)) {
|
|
70
|
+
if (path === "/data" && name === "projects")
|
|
71
|
+
continue;
|
|
72
|
+
await own(join(path, name));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
await own("/data");
|
|
77
|
+
await mkdir("/data/home", { recursive: true, mode: 0o700 });
|
|
78
|
+
await lchown("/data/home", uid, gid);
|
|
79
|
+
return { ok: true };
|
|
80
|
+
}
|
|
81
|
+
if (operation === "probe") {
|
|
82
|
+
await writeFile("/probe/container", "slopify ownership probe", { flag: "wx", mode: 0o600 });
|
|
83
|
+
return { uid: process.getuid?.(), gid: process.getgid?.() };
|
|
84
|
+
}
|
|
85
|
+
throw new Error("Unknown fixed Docker volume operation.");
|
|
86
|
+
}
|
|
87
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
88
|
+
try {
|
|
89
|
+
console.log(JSON.stringify(await volumeOperation(process.argv.slice(2))));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
console.error("Docker volume operation failed; original recovery material is retained.");
|
|
93
|
+
process.exitCode = 1;
|
|
94
|
+
}
|
|
95
|
+
}
|
package/dist/edge/docker.js
CHANGED
|
@@ -7,6 +7,12 @@ import { ensureBridgeToken, hasCode, prepareHostPaths } from "../host-cli/paths.
|
|
|
7
7
|
import { ensureHostService, privateRead, privateWrite } from "../host-cli/service.js";
|
|
8
8
|
import { resolveHostCommand } from "../host-cli/status.js";
|
|
9
9
|
import { hostCliProtocol, hostLlmIds } from "../kernel/ports/host-cli.js";
|
|
10
|
+
export function assertManagedDockerHost(platform, uid, gid) {
|
|
11
|
+
if (platform !== "linux")
|
|
12
|
+
throw new Error("The managed Docker launcher requires Linux. Native Slopify and plain API-only Docker remain available on other hosts.");
|
|
13
|
+
if (uid === undefined || gid === undefined || uid === 0)
|
|
14
|
+
throw new Error("Run the managed Docker launcher as your logged-in user, not with sudo; grant that account access to Docker.");
|
|
15
|
+
}
|
|
10
16
|
export async function prepareDockerHostCli(options) {
|
|
11
17
|
if (options.disabled)
|
|
12
18
|
return {};
|