@gentbajko/slopify 1.2.1 → 1.4.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 +15 -8
- package/dist/adapter-registry.js +14 -2
- package/dist/adapters/host-cli/index.js +192 -0
- package/dist/adapters/host-cli/transport.js +135 -0
- package/dist/adapters/image/codex.js +239 -0
- package/dist/adapters/llm/claude-code-models.js +1 -0
- package/dist/adapters/llm/claude-code.js +7 -0
- package/dist/adapters/llm/cli-login-error.js +16 -0
- package/dist/adapters/llm/codex.js +10 -0
- package/dist/catalog/validate.js +3 -1
- package/dist/edge/cli.js +36 -0
- package/dist/edge/docker.js +109 -0
- package/dist/edge/host-cli.js +58 -0
- package/dist/edge/http/actions.js +1 -1
- package/dist/edge/http/diagnostics.js +5 -1
- package/dist/edge/http/host-cli.js +247 -0
- package/dist/edge/http/providers.js +5 -1
- package/dist/host-cli/environment.js +25 -0
- package/dist/host-cli/install.js +61 -0
- package/dist/host-cli/paths.js +68 -0
- package/dist/host-cli/runtime.js +78 -0
- package/dist/host-cli/server.js +98 -0
- package/dist/host-cli/service.js +200 -0
- package/dist/host-cli/status.js +134 -0
- package/dist/kernel/db/migrations/0011-narration-prompts.sql +13 -0
- package/dist/kernel/ports/host-cli.js +95 -0
- package/dist/kernel/ports/model.js +1 -0
- package/dist/kernel/runner/attempt.js +10 -1
- package/dist/main.js +7 -1
- package/dist/slices/admission/rules.js +17 -1
- package/dist/slices/admission/schema.js +1 -0
- package/dist/slices/estimate/index.js +32 -1
- package/dist/slices/estimate/requests.js +8 -0
- package/dist/slices/library/model.js +2 -3
- package/dist/slices/library/slots.js +3 -1
- package/dist/slices/narration/preparation.js +75 -0
- package/dist/slices/narration/steering.js +89 -0
- package/dist/slices/play-drafts/convert.js +3 -0
- package/dist/slices/play-drafts/readiness.js +3 -1
- package/dist/slices/play-drafts/schema.js +1 -0
- package/dist/slices/project-templates/from-project.js +2 -0
- package/dist/slices/project-templates/setup.js +1 -0
- package/dist/slices/rebuild/admission-repo.js +2 -2
- package/dist/slices/rebuild/preview-details.js +12 -9
- package/dist/slices/rebuild/preview-plan.js +7 -1
- package/dist/slices/rebuild/recipe-audio-parts.js +89 -0
- package/dist/slices/rebuild/recipe-audio.js +52 -66
- package/dist/slices/rebuild/recipe-input-schema.js +11 -0
- package/dist/slices/rebuild/recipe-legacy.js +5 -0
- package/dist/slices/rebuild/recipe-model.js +3 -0
- package/dist/slices/rebuild/recipe-narration-text.js +33 -0
- package/dist/slices/rebuild/recipe-preparation.js +66 -0
- package/dist/slices/rebuild/recipe-provider-choice.js +7 -5
- package/dist/slices/rebuild/recipe-validation.js +9 -1
- package/dist/slices/rebuild/recipe-work.js +13 -1
- package/dist/slices/rebuild/runtime-admission.js +11 -0
- package/dist/slices/rebuild/runtime-export-inputs.js +4 -0
- package/dist/slices/rebuild/runtime-local.js +5 -0
- package/dist/slices/rebuild/runtime-materialize.js +2 -0
- package/dist/slices/rebuild/runtime-narration-reuse.js +1 -0
- package/dist/slices/rebuild/runtime-narration-text.js +75 -0
- package/dist/slices/rebuild/runtime-provider.js +30 -1
- package/dist/slices/rebuild/runtime-publication.js +13 -1
- package/dist/slices/rebuild/runtime-store.js +13 -2
- package/dist/slices/revisions/rules.js +5 -3
- package/dist/slices/settings/cli-paths.js +15 -6
- package/dist/slices/settings/cli-status.js +3 -1
- package/dist/slices/settings/model.js +9 -0
- package/dist/slices/settings/readiness.js +24 -3
- package/dist/slices/storage/asset-name.js +3 -0
- package/dist/slices/storage/layout.js +4 -0
- package/dist/slices/storage/model.js +2 -0
- package/dist/slices/storage/schema.js +1 -0
- package/dist/web/assets/index-DV1LKwBP.js +137 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/scripts/docker-run.sh +8 -45
- package/dist/web/assets/index-Cq2O1JRf.js +0 -137
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { access, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { isStableVersion, updatePackage } from "../updater/model.js";
|
|
6
|
+
import { installArgs, npmCommand } from "../updater/plan.js";
|
|
7
|
+
import { hasCode } from "./paths.js";
|
|
8
|
+
export const nodeHostSetupRunner = {
|
|
9
|
+
exec: (file, args, signal) => new Promise((resolve, reject) => {
|
|
10
|
+
execFile(file, [...args], { signal, timeout: 15 * 60_000, maxBuffer: 1024 * 1024, windowsHide: true }, (error, stdout) => {
|
|
11
|
+
if (error && typeof error.code !== "number")
|
|
12
|
+
return reject(new Error("Host setup command could not finish. Check Docker, Node and the systemd user service."));
|
|
13
|
+
resolve({ code: typeof error?.code === "number" ? error.code : 0, stdout });
|
|
14
|
+
});
|
|
15
|
+
}),
|
|
16
|
+
};
|
|
17
|
+
async function entryAt(directory, version) {
|
|
18
|
+
const path = join(directory, "node_modules", "@gentbajko", "slopify");
|
|
19
|
+
z.object({ name: z.literal(updatePackage), version: z.literal(version) }).parse(JSON.parse(await readFile(join(path, "package.json"), "utf8")));
|
|
20
|
+
const entry = join(path, "dist/edge/host-cli.js");
|
|
21
|
+
if (!(await lstat(entry)).isFile())
|
|
22
|
+
throw new Error("Host helper entry is not a regular file.");
|
|
23
|
+
await access(entry);
|
|
24
|
+
return entry;
|
|
25
|
+
}
|
|
26
|
+
export async function installHostPackage(options) {
|
|
27
|
+
const { root, version, runner, signal } = options;
|
|
28
|
+
if (!isStableVersion(version))
|
|
29
|
+
throw new Error("Invalid host helper version.");
|
|
30
|
+
const versions = join(root, "versions");
|
|
31
|
+
const directory = join(versions, version);
|
|
32
|
+
try {
|
|
33
|
+
return { entry: await entryAt(directory, version) };
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (!hasCode(error, "ENOENT"))
|
|
37
|
+
throw new Error("Existing host helper install is invalid. No service was changed.");
|
|
38
|
+
}
|
|
39
|
+
await mkdir(versions, { recursive: true, mode: 0o700 });
|
|
40
|
+
const staging = await mkdtemp(join(versions, `.install-${version}-`));
|
|
41
|
+
try {
|
|
42
|
+
await writeFile(join(staging, ".npmrc"), "registry=https://registry.npmjs.org/\n@gentbajko:registry=https://registry.npmjs.org/\n", { mode: 0o600 });
|
|
43
|
+
await writeFile(join(staging, ".global-npmrc"), "", { mode: 0o600 });
|
|
44
|
+
const npm = await npmCommand();
|
|
45
|
+
const args = installArgs(staging, version);
|
|
46
|
+
const result = await runner.exec(npm.file, [
|
|
47
|
+
...npm.args,
|
|
48
|
+
...args.slice(0, -1),
|
|
49
|
+
"--ignore-scripts",
|
|
50
|
+
options.packageSource ?? `${updatePackage}@${version}`,
|
|
51
|
+
], signal);
|
|
52
|
+
if (result.code !== 0)
|
|
53
|
+
throw new Error("npm could not install the host helper. The existing container was not changed.");
|
|
54
|
+
await entryAt(staging, version);
|
|
55
|
+
await rename(staging, directory);
|
|
56
|
+
return { entry: await entryAt(directory, version) };
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
await rm(staging, { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { chmod, lstat, mkdir, open } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
export async function prepareHostPaths(root) {
|
|
6
|
+
if (!isAbsolute(root) || /[\p{Cc}]/u.test(root))
|
|
7
|
+
throw new Error("Unsafe host helper directory.");
|
|
8
|
+
const normalized = resolve(root);
|
|
9
|
+
const share = join(normalized, "share");
|
|
10
|
+
const socket = join(share, "cli.sock");
|
|
11
|
+
if (Buffer.byteLength(socket) > 100)
|
|
12
|
+
throw new Error("Use a shorter XDG_DATA_HOME for the host helper socket (at most 100 bytes).");
|
|
13
|
+
await mkdir(normalized, { recursive: true, mode: 0o700 });
|
|
14
|
+
await ownedDirectory(normalized, true);
|
|
15
|
+
await chmod(normalized, 0o700);
|
|
16
|
+
await mkdir(share, { recursive: true, mode: 0o755 });
|
|
17
|
+
await ownedDirectory(share);
|
|
18
|
+
await chmod(share, 0o755);
|
|
19
|
+
return { root: normalized, share, socket, tokenFile: join(share, "token") };
|
|
20
|
+
}
|
|
21
|
+
async function ownedDirectory(path, privateRoot = false) {
|
|
22
|
+
const entry = await lstat(path);
|
|
23
|
+
if (!entry.isDirectory() ||
|
|
24
|
+
entry.isSymbolicLink() ||
|
|
25
|
+
(privateRoot && (entry.mode & 0o077) !== 0) ||
|
|
26
|
+
(process.getuid && entry.uid !== process.getuid()))
|
|
27
|
+
throw new Error("Unsafe host helper directory.");
|
|
28
|
+
}
|
|
29
|
+
export async function readBridgeToken(path) {
|
|
30
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
31
|
+
try {
|
|
32
|
+
const entry = await file.stat();
|
|
33
|
+
if (!entry.isFile() || entry.size !== 64 || (entry.mode & 0o022) !== 0)
|
|
34
|
+
throw new Error("Invalid host helper token file.");
|
|
35
|
+
const buffer = Buffer.alloc(65);
|
|
36
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
37
|
+
const value = buffer.subarray(0, bytesRead).toString("utf8");
|
|
38
|
+
if (!/^[a-f0-9]{64}$/.test(value))
|
|
39
|
+
throw new Error("Invalid host helper token file.");
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
await file.close();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export async function ensureBridgeToken(path) {
|
|
47
|
+
try {
|
|
48
|
+
const file = await open(path, "wx", 0o644);
|
|
49
|
+
try {
|
|
50
|
+
await file.writeFile(randomBytes(32).toString("hex"));
|
|
51
|
+
await file.chmod(0o644);
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
await file.close();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (!hasCode(error, "EEXIST"))
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
const entry = await lstat(path);
|
|
62
|
+
if (entry.isSymbolicLink() || (process.getuid && entry.uid !== process.getuid()))
|
|
63
|
+
throw new Error("Unsafe host helper token file.");
|
|
64
|
+
return readBridgeToken(path);
|
|
65
|
+
}
|
|
66
|
+
export function hasCode(error, code) {
|
|
67
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
68
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { codexImage } from "../adapters/image/codex.js";
|
|
5
|
+
import { claudeCodeLlm } from "../adapters/llm/claude-code.js";
|
|
6
|
+
import { nodeClaudeCodeModels } from "../adapters/llm/claude-code-models.js";
|
|
7
|
+
import { codexLlm } from "../adapters/llm/codex.js";
|
|
8
|
+
import { nodeCodexModels } from "../adapters/llm/codex-models.js";
|
|
9
|
+
import { geminiLlm } from "../adapters/llm/gemini.js";
|
|
10
|
+
import { nodeGeminiModels } from "../adapters/llm/gemini-models.js";
|
|
11
|
+
import { providerError } from "../kernel/ports/model.js";
|
|
12
|
+
import { createHostStatus } from "./status.js";
|
|
13
|
+
export function createHostRuntime(deps) {
|
|
14
|
+
const status = createHostStatus(deps);
|
|
15
|
+
async function command(id) {
|
|
16
|
+
const ready = await status(id);
|
|
17
|
+
if (ready.issue || !ready.installed)
|
|
18
|
+
throw providerError({
|
|
19
|
+
kind: ready.issueKind === "login" ? "missing_key" : "unavailable",
|
|
20
|
+
message: ready.issue ?? "The host CLI is unavailable.",
|
|
21
|
+
});
|
|
22
|
+
return deps.resolve(id === "codex-image" ? "codex" : id);
|
|
23
|
+
}
|
|
24
|
+
const factories = { "claude-code": claudeCodeLlm, codex: codexLlm, gemini: geminiLlm };
|
|
25
|
+
const models = async (id) => {
|
|
26
|
+
const binary = await deps.resolve(id);
|
|
27
|
+
return id === "claude-code"
|
|
28
|
+
? nodeClaudeCodeModels(binary)
|
|
29
|
+
: id === "codex"
|
|
30
|
+
? nodeCodexModels(deps.env, binary)
|
|
31
|
+
: nodeGeminiModels(binary);
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
status,
|
|
35
|
+
llm: (id) => ({
|
|
36
|
+
id,
|
|
37
|
+
capabilities: { streams: true, reportsUsage: true, webSearch: true },
|
|
38
|
+
models: () => models(id),
|
|
39
|
+
complete: async function* (request) {
|
|
40
|
+
request.signal.throwIfAborted();
|
|
41
|
+
const binary = await command(id);
|
|
42
|
+
const directory = id === "claude-code" ? await mkdtemp(join(tmpdir(), "slopify-host-claude-")) : undefined;
|
|
43
|
+
try {
|
|
44
|
+
const run = (file, args, signal, options) => deps.run(file, args, signal, {
|
|
45
|
+
...(directory === undefined ? {} : { cwd: directory }),
|
|
46
|
+
...options,
|
|
47
|
+
env: options?.env ?? {
|
|
48
|
+
...deps.env,
|
|
49
|
+
...(directory === undefined ? {} : { PWD: directory }),
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
yield* factories[id]({ run, binary, readModels: () => models(id) }).complete({
|
|
53
|
+
...request,
|
|
54
|
+
...(id === "codex" && request.thinking !== undefined
|
|
55
|
+
? {
|
|
56
|
+
thinkingConfig: {
|
|
57
|
+
effort: request.thinking === "off" ? "none" : request.thinking,
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
: {}),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
if (directory !== undefined)
|
|
65
|
+
await rm(directory, { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
}),
|
|
69
|
+
image: {
|
|
70
|
+
id: "codex-image",
|
|
71
|
+
models: () => codexImage({ run: deps.run }).models(),
|
|
72
|
+
generate: async (request) => {
|
|
73
|
+
const binary = await command("codex-image");
|
|
74
|
+
return codexImage({ run: deps.run, binary }).generate(request);
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { chmod, lstat, unlink } from "node:fs/promises";
|
|
2
|
+
import { Server } from "node:http";
|
|
3
|
+
import { connect } from "node:net";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createAdaptorServer } from "@hono/node-server";
|
|
6
|
+
import { hostCliRoutes, hostGate } from "../edge/http/host-cli.js";
|
|
7
|
+
import { hasCode } from "./paths.js";
|
|
8
|
+
export async function startHostServer(options) {
|
|
9
|
+
const socket = join(options.directory, "cli.sock");
|
|
10
|
+
if (Buffer.byteLength(socket) > 100)
|
|
11
|
+
throw new Error("Use a shorter host helper socket path.");
|
|
12
|
+
const directory = await lstat(options.directory);
|
|
13
|
+
if (!directory.isDirectory() ||
|
|
14
|
+
directory.isSymbolicLink() ||
|
|
15
|
+
(process.getuid && directory.uid !== process.getuid()))
|
|
16
|
+
throw new Error("Unsafe host helper directory.");
|
|
17
|
+
try {
|
|
18
|
+
const existing = await lstat(socket);
|
|
19
|
+
if (!existing.isSocket() || (process.getuid && existing.uid !== process.getuid()))
|
|
20
|
+
throw new Error("Unsafe host helper socket.");
|
|
21
|
+
if (await listening(socket))
|
|
22
|
+
throw new Error("A host helper is already listening.");
|
|
23
|
+
await unlink(socket);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (!hasCode(error, "ENOENT"))
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
const gate = hostGate();
|
|
30
|
+
const app = hostCliRoutes({ ...options, gate });
|
|
31
|
+
const server = createAdaptorServer({ fetch: app.fetch });
|
|
32
|
+
if (!(server instanceof Server))
|
|
33
|
+
throw new Error("Host helper requires HTTP over a Unix socket.");
|
|
34
|
+
server.headersTimeout = 10_000;
|
|
35
|
+
server.requestTimeout = 35_000;
|
|
36
|
+
await new Promise((resolve, reject) => {
|
|
37
|
+
server.once("error", reject);
|
|
38
|
+
server.listen(socket, () => {
|
|
39
|
+
server.removeListener("error", reject);
|
|
40
|
+
resolve();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
await chmod(socket, 0o666);
|
|
44
|
+
const owned = await lstat(socket);
|
|
45
|
+
let stopping;
|
|
46
|
+
return {
|
|
47
|
+
pauseAdmissions: gate.pause,
|
|
48
|
+
resumeAdmissions: gate.resume,
|
|
49
|
+
stop: () => {
|
|
50
|
+
stopping ??= (async () => {
|
|
51
|
+
gate.stop();
|
|
52
|
+
await new Promise((resolve) => {
|
|
53
|
+
const timer = setTimeout(() => {
|
|
54
|
+
server.closeAllConnections();
|
|
55
|
+
resolve();
|
|
56
|
+
}, 5000);
|
|
57
|
+
timer.unref();
|
|
58
|
+
server.close(() => {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
resolve();
|
|
61
|
+
});
|
|
62
|
+
server.closeIdleConnections();
|
|
63
|
+
});
|
|
64
|
+
try {
|
|
65
|
+
const current = await lstat(socket);
|
|
66
|
+
if (current.ino === owned.ino && current.dev === owned.dev)
|
|
67
|
+
await unlink(socket);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (!hasCode(error, "ENOENT"))
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
})();
|
|
74
|
+
return stopping;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function listening(path) {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const socket = connect(path);
|
|
81
|
+
socket.setTimeout(1000);
|
|
82
|
+
socket.once("connect", () => {
|
|
83
|
+
socket.destroy();
|
|
84
|
+
resolve(true);
|
|
85
|
+
});
|
|
86
|
+
socket.once("timeout", () => {
|
|
87
|
+
socket.destroy();
|
|
88
|
+
reject(new Error("Could not verify existing host socket."));
|
|
89
|
+
});
|
|
90
|
+
socket.once("error", (error) => {
|
|
91
|
+
socket.destroy();
|
|
92
|
+
if (hasCode(error, "ECONNREFUSED"))
|
|
93
|
+
resolve(false);
|
|
94
|
+
else
|
|
95
|
+
reject(new Error("Could not verify existing host socket."));
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, mkdir, open, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join } from "node:path";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
+
import { hostRequest, readHostBytes } from "../adapters/host-cli/transport.js";
|
|
6
|
+
import { bridgeLimits, hostHealthSchema } from "../kernel/ports/host-cli.js";
|
|
7
|
+
import { hostEnvironment } from "./environment.js";
|
|
8
|
+
import { hasCode } from "./paths.js";
|
|
9
|
+
const marker = "# Managed by Slopify host CLI bridge, protocol 1";
|
|
10
|
+
export const hostUnit = "slopify-cli-bridge.service";
|
|
11
|
+
function unitArg(value) {
|
|
12
|
+
if (!isAbsolute(value) || /[\p{Cc}]/u.test(value))
|
|
13
|
+
throw new Error("Invalid service path.");
|
|
14
|
+
return JSON.stringify(value.replaceAll("%", "%%"));
|
|
15
|
+
}
|
|
16
|
+
export function serviceUnit(node, entry, root) {
|
|
17
|
+
return [
|
|
18
|
+
marker,
|
|
19
|
+
"[Unit]",
|
|
20
|
+
"Description=Slopify host CLI bridge",
|
|
21
|
+
"",
|
|
22
|
+
"[Service]",
|
|
23
|
+
"Type=simple",
|
|
24
|
+
`ExecStart=:${unitArg(node)} ${unitArg(entry)} --state-dir ${unitArg(root)}`,
|
|
25
|
+
"Restart=on-failure",
|
|
26
|
+
"RestartSec=2",
|
|
27
|
+
"KillMode=control-group",
|
|
28
|
+
"TimeoutStopSec=5",
|
|
29
|
+
"UMask=0077",
|
|
30
|
+
"",
|
|
31
|
+
"[Install]",
|
|
32
|
+
"WantedBy=default.target",
|
|
33
|
+
"",
|
|
34
|
+
].join("\n");
|
|
35
|
+
}
|
|
36
|
+
export async function privateRead(path, uid) {
|
|
37
|
+
try {
|
|
38
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
39
|
+
try {
|
|
40
|
+
const stat = await file.stat();
|
|
41
|
+
if (!stat.isFile() || stat.uid !== uid || stat.size > 64 * 1024 || (stat.mode & 0o022) !== 0)
|
|
42
|
+
throw new Error("Unsafe host helper configuration.");
|
|
43
|
+
const buffer = Buffer.alloc(64 * 1024 + 1);
|
|
44
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
45
|
+
if (bytesRead > 64 * 1024)
|
|
46
|
+
throw new Error("Host configuration too large.");
|
|
47
|
+
return buffer.subarray(0, bytesRead).toString("utf8");
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await file.close();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (hasCode(error, "ENOENT"))
|
|
55
|
+
return undefined;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export async function privateWrite(path, text) {
|
|
60
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
61
|
+
const file = await open(temporary, "wx", 0o600);
|
|
62
|
+
try {
|
|
63
|
+
await file.writeFile(text);
|
|
64
|
+
await file.sync();
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await file.close();
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
await rename(temporary, path);
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
await unlink(temporary).catch((error) => {
|
|
74
|
+
if (!hasCode(error, "ENOENT"))
|
|
75
|
+
throw error;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export async function helperHealth(root, signal) {
|
|
80
|
+
try {
|
|
81
|
+
const response = await hostRequest({
|
|
82
|
+
directory: join(root, "share"),
|
|
83
|
+
path: "/v1/health",
|
|
84
|
+
method: "GET",
|
|
85
|
+
kind: "metadata",
|
|
86
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(1000)]),
|
|
87
|
+
});
|
|
88
|
+
if (response.statusCode !== 200) {
|
|
89
|
+
response.destroy();
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
return hostHealthSchema.parse(JSON.parse((await readHostBytes(response, bridgeLimits.status)).toString("utf8")));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function waitHealth(options, accepting) {
|
|
99
|
+
const deadline = Date.now() + 30_000;
|
|
100
|
+
while (Date.now() < deadline) {
|
|
101
|
+
options.signal.throwIfAborted();
|
|
102
|
+
const health = await helperHealth(options.root, options.signal);
|
|
103
|
+
if (health &&
|
|
104
|
+
health.accepting === accepting &&
|
|
105
|
+
(!accepting || health.version === options.version))
|
|
106
|
+
return health;
|
|
107
|
+
await delay(100, undefined, { signal: options.signal });
|
|
108
|
+
}
|
|
109
|
+
throw new Error("Host helper did not become ready within 30 seconds.");
|
|
110
|
+
}
|
|
111
|
+
export async function ensureHostService(options) {
|
|
112
|
+
const { root, env, runner, signal, uid } = options;
|
|
113
|
+
const environment = JSON.stringify(hostEnvironment(env));
|
|
114
|
+
const directory = join(env.XDG_CONFIG_HOME ?? join(env.HOME ?? "", ".config"), "systemd/user");
|
|
115
|
+
if (!isAbsolute(directory))
|
|
116
|
+
throw new Error("Host configuration directory must be absolute.");
|
|
117
|
+
const unitPath = join(directory, hostUnit);
|
|
118
|
+
const configPath = join(root, "host-environment.json");
|
|
119
|
+
const previous = await privateRead(unitPath, uid);
|
|
120
|
+
const previousConfig = await privateRead(configPath, uid);
|
|
121
|
+
if (previous !== undefined && !previous.startsWith(`${marker}\n`))
|
|
122
|
+
throw new Error("An existing host helper service is not owned by Slopify. It was not changed.");
|
|
123
|
+
const unit = serviceUnit(options.node, options.entry, root);
|
|
124
|
+
const run = async (file, args, required = true, commandSignal = signal) => {
|
|
125
|
+
const result = await runner.exec(file, args, commandSignal);
|
|
126
|
+
if (required && result.code !== 0)
|
|
127
|
+
throw new Error(`Host helper setup failed (${file}). The existing container was not changed.`);
|
|
128
|
+
return result;
|
|
129
|
+
};
|
|
130
|
+
const fragment = (await run("systemctl", ["--user", "show", hostUnit, "--property=FragmentPath", "--value"])).stdout.trim();
|
|
131
|
+
if (fragment && fragment !== unitPath)
|
|
132
|
+
throw new Error("An existing host helper service is not owned by this Slopify installation.");
|
|
133
|
+
const active = (await run("systemctl", ["--user", "is-active", "--quiet", hostUnit], false)).code === 0;
|
|
134
|
+
const wasEnabled = (await run("systemctl", ["--user", "is-enabled", "--quiet", hostUnit], false)).code === 0;
|
|
135
|
+
const current = await helperHealth(root, signal);
|
|
136
|
+
if (active && (!previous || !current))
|
|
137
|
+
throw new Error("The running host helper cannot be verified. Check its service before updating; no work was interrupted.");
|
|
138
|
+
const linger = (await run("loginctl", ["show-user", String(uid), "-p", "Linger", "--value"])).stdout.trim();
|
|
139
|
+
if (linger !== "yes")
|
|
140
|
+
await run("loginctl", ["enable-linger", String(uid)]);
|
|
141
|
+
if (current?.version === options.version &&
|
|
142
|
+
current.accepting &&
|
|
143
|
+
previous === unit &&
|
|
144
|
+
previousConfig === environment) {
|
|
145
|
+
await run("systemctl", ["--user", "enable", hostUnit]);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
let paused = false;
|
|
149
|
+
let replaced = false;
|
|
150
|
+
try {
|
|
151
|
+
if (active) {
|
|
152
|
+
paused = true;
|
|
153
|
+
await run("systemctl", ["--user", "kill", "--kill-whom=main", "--signal=SIGHUP", hostUnit]);
|
|
154
|
+
if ((await waitHealth(options, false)).active !== 0)
|
|
155
|
+
throw new Error("The host helper is doing work. Wait for it to finish before updating.");
|
|
156
|
+
}
|
|
157
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
158
|
+
const directoryStat = await lstat(directory);
|
|
159
|
+
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink() || directoryStat.uid !== uid)
|
|
160
|
+
throw new Error("Unsafe user service directory.");
|
|
161
|
+
replaced = true;
|
|
162
|
+
await privateWrite(configPath, environment);
|
|
163
|
+
await privateWrite(unitPath, unit);
|
|
164
|
+
await run("systemctl", ["--user", "daemon-reload"]);
|
|
165
|
+
await run("systemctl", ["--user", "enable", hostUnit]);
|
|
166
|
+
await run("systemctl", ["--user", active ? "restart" : "start", hostUnit]);
|
|
167
|
+
await waitHealth(options, true);
|
|
168
|
+
paused = false;
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
const rollbackSignal = AbortSignal.timeout(35_000);
|
|
172
|
+
const restore = (args) => run("systemctl", args, true, rollbackSignal);
|
|
173
|
+
try {
|
|
174
|
+
if (replaced) {
|
|
175
|
+
await restore(["--user", "stop", hostUnit]);
|
|
176
|
+
if (!wasEnabled)
|
|
177
|
+
await restore(["--user", "disable", hostUnit]);
|
|
178
|
+
if (previous === undefined)
|
|
179
|
+
await unlink(unitPath);
|
|
180
|
+
else
|
|
181
|
+
await privateWrite(unitPath, previous);
|
|
182
|
+
if (previousConfig === undefined)
|
|
183
|
+
await unlink(configPath);
|
|
184
|
+
else
|
|
185
|
+
await privateWrite(configPath, previousConfig);
|
|
186
|
+
await restore(["--user", "daemon-reload"]);
|
|
187
|
+
if (active) {
|
|
188
|
+
await restore(["--user", "start", hostUnit]);
|
|
189
|
+
await waitHealth({ ...options, signal: rollbackSignal, version: current?.version ?? options.version }, true);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
else if (paused)
|
|
193
|
+
await restore(["--user", "kill", "--kill-whom=main", "--signal=SIGUSR2", hostUnit]);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
throw new Error("Host helper setup failed and rollback needs attention. Check slopify-cli-bridge.service; the Docker container was not changed.");
|
|
197
|
+
}
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { access, stat } from "node:fs/promises";
|
|
4
|
+
import { delimiter, isAbsolute, join } from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { cliLoginError } from "../adapters/llm/cli-login-error.js";
|
|
7
|
+
import { cliCommand } from "../kernel/cli-command.js";
|
|
8
|
+
import { cliProbeTimeoutMs, readinessFromProbe, } from "../slices/settings/cli-status.js";
|
|
9
|
+
export function hostCommandName(id) {
|
|
10
|
+
return id === "claude-code" ? "claude" : id === "codex-image" ? "codex" : id;
|
|
11
|
+
}
|
|
12
|
+
export async function resolveHostCommand(id, env) {
|
|
13
|
+
for (const directory of (env.PATH ?? "").split(delimiter)) {
|
|
14
|
+
if (!isAbsolute(directory))
|
|
15
|
+
continue;
|
|
16
|
+
const path = join(directory, hostCommandName(id));
|
|
17
|
+
try {
|
|
18
|
+
await access(path, constants.X_OK);
|
|
19
|
+
if ((await stat(path)).isFile())
|
|
20
|
+
return path;
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (!isCode(error, "ENOENT") && !isCode(error, "ENOTDIR") && !isCode(error, "EACCES"))
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
throw Object.assign(new Error("The CLI was not found on the host PATH."), { code: "ENOENT" });
|
|
28
|
+
}
|
|
29
|
+
const authSchema = z.object({ loggedIn: z.boolean() });
|
|
30
|
+
export function parseHostLogin(id, stdout, stderr) {
|
|
31
|
+
if (id === "gemini")
|
|
32
|
+
return "unknown";
|
|
33
|
+
if (id === "codex") {
|
|
34
|
+
const text = `${stdout}\n${stderr}`.trim();
|
|
35
|
+
if (/^Not logged in\.?$/i.test(text))
|
|
36
|
+
return "signed-out";
|
|
37
|
+
if (/^Logged in using (?:ChatGPT|an API key|API key)\b/i.test(text))
|
|
38
|
+
return "signed-in";
|
|
39
|
+
return "unknown";
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const result = authSchema.safeParse(JSON.parse(stdout));
|
|
43
|
+
return result.success ? (result.data.loggedIn ? "signed-in" : "signed-out") : "unknown";
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return "unknown";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export async function readHostLogin(binary, id, signal) {
|
|
50
|
+
if (id === "gemini")
|
|
51
|
+
return "unknown";
|
|
52
|
+
const command = cliCommand(binary);
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
execFile(command.file, [...command.args, ...(id === "claude-code" ? ["auth", "status"] : ["login", "status"])], { timeout: cliProbeTimeoutMs, maxBuffer: 64 * 1024, signal, windowsHide: true }, (error, stdout, stderr) => {
|
|
55
|
+
if (error && (error.killed || typeof error.code !== "number"))
|
|
56
|
+
return resolve("unknown");
|
|
57
|
+
resolve(parseHostLogin(id, stdout, stderr));
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
export function createHostStatus(deps) {
|
|
62
|
+
const cache = new Map();
|
|
63
|
+
async function read(id) {
|
|
64
|
+
let command;
|
|
65
|
+
try {
|
|
66
|
+
command = await deps.resolve(id);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
return {
|
|
70
|
+
id,
|
|
71
|
+
command: hostCommandName(id),
|
|
72
|
+
installed: false,
|
|
73
|
+
login: "unknown",
|
|
74
|
+
issueKind: isCode(error, "ENOENT") ? "missing" : "bridge",
|
|
75
|
+
issue: "The CLI could not be found on the host. Check its installation, then rerun the Docker launcher.",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const readiness = readinessFromProbe(await deps.probe(command, ["--version"], cliProbeTimeoutMs), {
|
|
79
|
+
id,
|
|
80
|
+
auth: "cli",
|
|
81
|
+
family: "llm",
|
|
82
|
+
displayName: id,
|
|
83
|
+
binary: command,
|
|
84
|
+
versionArgs: ["--version"],
|
|
85
|
+
});
|
|
86
|
+
if (readiness.kind !== "cli")
|
|
87
|
+
throw new Error("Expected CLI readiness.");
|
|
88
|
+
const base = {
|
|
89
|
+
id,
|
|
90
|
+
command,
|
|
91
|
+
installed: readiness.installed,
|
|
92
|
+
...(readiness.version === undefined ? {} : { version: readiness.version }),
|
|
93
|
+
};
|
|
94
|
+
if (!readiness.installed)
|
|
95
|
+
return {
|
|
96
|
+
...base,
|
|
97
|
+
login: "unknown",
|
|
98
|
+
issueKind: "missing",
|
|
99
|
+
issue: "The host CLI did not answer. Check its installation and rerun the Docker launcher.",
|
|
100
|
+
};
|
|
101
|
+
if (readiness.issue)
|
|
102
|
+
return { ...base, login: "unknown", issueKind: "version", issue: readiness.issue };
|
|
103
|
+
const login = await (deps.login ?? readHostLogin)(command, id, AbortSignal.timeout(cliProbeTimeoutMs));
|
|
104
|
+
return {
|
|
105
|
+
...base,
|
|
106
|
+
login,
|
|
107
|
+
...(login === "signed-out"
|
|
108
|
+
? {
|
|
109
|
+
issueKind: "login",
|
|
110
|
+
issue: cliLoginError(id, "Not logged in")?.message ?? "Sign in on the host.",
|
|
111
|
+
}
|
|
112
|
+
: {}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return async (id) => {
|
|
116
|
+
const source = id === "codex-image" ? "codex" : id;
|
|
117
|
+
let entry = cache.get(source);
|
|
118
|
+
if (!entry || entry.until <= deps.now()) {
|
|
119
|
+
const value = read(source);
|
|
120
|
+
entry = { until: Number.POSITIVE_INFINITY, value };
|
|
121
|
+
cache.set(source, entry);
|
|
122
|
+
const current = entry;
|
|
123
|
+
void value.then(() => {
|
|
124
|
+
current.until = deps.now() + 5000;
|
|
125
|
+
}, () => {
|
|
126
|
+
cache.delete(source);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return { ...(await entry.value), id };
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function isCode(error, code) {
|
|
133
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
134
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
CREATE TABLE prompts_narration_upgrade (
|
|
2
|
+
id TEXT PRIMARY KEY,
|
|
3
|
+
kind TEXT NOT NULL CHECK (kind IN ('article','image','thumbnail','narration')),
|
|
4
|
+
name TEXT NOT NULL,
|
|
5
|
+
body TEXT NOT NULL,
|
|
6
|
+
slots TEXT NOT NULL,
|
|
7
|
+
updated_at TEXT NOT NULL
|
|
8
|
+
);
|
|
9
|
+
INSERT INTO prompts_narration_upgrade (id,kind,name,body,slots,updated_at)
|
|
10
|
+
SELECT id,kind,name,body,slots,updated_at FROM prompts;
|
|
11
|
+
DROP TABLE prompts;
|
|
12
|
+
ALTER TABLE prompts_narration_upgrade RENAME TO prompts;
|
|
13
|
+
CREATE UNIQUE INDEX prompts_name ON prompts(kind,lower(name));
|