schornai 0.0.1 → 0.3.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 +67 -22
- package/bin/schornai.js +32 -6
- package/dist/service/bootstrap.mjs +478 -0
- package/dist/service/cli.mjs +29 -0
- package/dist/service/commands.mjs +24 -0
- package/dist/service/installer.mjs +14 -0
- package/dist/service/shared/cli-DwJIziOh.mjs +15579 -0
- package/dist/service/shared/docker-Dj7cdbkw.mjs +6502 -0
- package/dist/service/shared/installer-CWopk_6Z.mjs +470 -0
- package/package.json +11 -5
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realpath, stat, readdir, readFile, chmod, appendFile, mkdir, cp, access, rm, rename } from 'node:fs/promises';
|
|
3
|
+
import { createReadStream, existsSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join, resolve, dirname } from 'node:path';
|
|
6
|
+
import { promisify, parseEnv } from 'node:util';
|
|
7
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
8
|
+
import { d as defaultConfigPath, g as configSchema, l as loadConfig, w as writeJson, h as saveInitialConfig, c as createLogger, j as control, b as run, k as ensureDocker, r as resolveFrom, e as binary } from './docker-Dj7cdbkw.mjs';
|
|
9
|
+
import { execFile } from 'node:child_process';
|
|
10
|
+
import { setTimeout } from 'node:timers/promises';
|
|
11
|
+
import 'node:events';
|
|
12
|
+
|
|
13
|
+
const execute = promisify(execFile);
|
|
14
|
+
/** Some old Admin versions do not terminate their Node transcriber on quit. */
|
|
15
|
+
async function stopLegacyTranscriber(root, log) {
|
|
16
|
+
if (process.platform !== "darwin")
|
|
17
|
+
return;
|
|
18
|
+
let cwd;
|
|
19
|
+
try {
|
|
20
|
+
cwd = await realpath(join(root, "apps/transcriber-mac"));
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error.code === "ENOENT")
|
|
24
|
+
return;
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
const matches = await execute("/usr/sbin/lsof", [
|
|
28
|
+
"-a",
|
|
29
|
+
"-u",
|
|
30
|
+
String(process.getuid()),
|
|
31
|
+
"-d",
|
|
32
|
+
"cwd",
|
|
33
|
+
"-F",
|
|
34
|
+
"pn",
|
|
35
|
+
"--",
|
|
36
|
+
cwd,
|
|
37
|
+
]).catch((error) => {
|
|
38
|
+
if (error.code === 1)
|
|
39
|
+
return { stdout: "" };
|
|
40
|
+
throw error;
|
|
41
|
+
});
|
|
42
|
+
for (const match of matches.stdout.matchAll(/^p(\d+)$/gm)) {
|
|
43
|
+
const pid = Number(match[1]);
|
|
44
|
+
const { stdout: command } = await execute("/bin/ps", [
|
|
45
|
+
"-p",
|
|
46
|
+
String(pid),
|
|
47
|
+
"-o",
|
|
48
|
+
"command=",
|
|
49
|
+
]).catch(() => ({ stdout: "" }));
|
|
50
|
+
if (!/^(?:\S*\/)?node\s+appTranscriberMac\.js\s*$/.test(command.trim()))
|
|
51
|
+
continue;
|
|
52
|
+
try {
|
|
53
|
+
process.kill(pid, "SIGTERM");
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error.code === "ESRCH")
|
|
57
|
+
continue;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
log("setup", "Stopping the legacy transcriber");
|
|
61
|
+
const deadline = Date.now() + 15_000;
|
|
62
|
+
while (true) {
|
|
63
|
+
try {
|
|
64
|
+
process.kill(pid, 0);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error.code === "ESRCH")
|
|
68
|
+
break;
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
if (Date.now() > deadline)
|
|
72
|
+
throw new Error("Legacy transcriber did not stop; update halted before replacing its files");
|
|
73
|
+
await setTimeout(100);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getFileHash(path) {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const hash = createHash("sha256");
|
|
81
|
+
const stream = createReadStream(path);
|
|
82
|
+
stream.on("error", (err) => reject(err));
|
|
83
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
84
|
+
stream.on("end", () => resolve(hash.digest("hex")));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
async function getFileSystemIntegrity(path) {
|
|
88
|
+
const stats = await stat(path);
|
|
89
|
+
if (stats.isFile()) {
|
|
90
|
+
return {
|
|
91
|
+
hash: await getFileHash(path),
|
|
92
|
+
name: path.split("/").pop() ?? "@@INVALID_FILE_NAME@@",
|
|
93
|
+
type: "file",
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
else if (stats.isDirectory()) {
|
|
97
|
+
const contentNames = await readdir(path);
|
|
98
|
+
const content = [];
|
|
99
|
+
for (const contentName of contentNames) {
|
|
100
|
+
const contentPath = `${path}/${contentName}`;
|
|
101
|
+
const contentDescription = await getFileSystemIntegrity(contentPath);
|
|
102
|
+
content.push(contentDescription);
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
content,
|
|
106
|
+
name: path.split("/").pop() ?? "@@INVALID_DIRECTORY_NAME@@",
|
|
107
|
+
type: "directory",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
throw new Error(`Cannot get integrity of "${path}" because it is of unsupported type`);
|
|
111
|
+
}
|
|
112
|
+
async function getDirectoryIntegrity(path) {
|
|
113
|
+
const description = await getFileSystemIntegrity(path);
|
|
114
|
+
if (description.type === "file") {
|
|
115
|
+
throw new Error(`Cannot get directory integrity for "${path}" because it is a file`);
|
|
116
|
+
}
|
|
117
|
+
return description;
|
|
118
|
+
}
|
|
119
|
+
async function getFileIntegrity(path) {
|
|
120
|
+
const description = await getFileSystemIntegrity(path);
|
|
121
|
+
if (description.type === "directory") {
|
|
122
|
+
throw new Error(`Cannot get file integrity for "${path}" because it is a directory`);
|
|
123
|
+
}
|
|
124
|
+
return description;
|
|
125
|
+
}
|
|
126
|
+
function checkIntegrity(expected, actual, path = "") {
|
|
127
|
+
if (expected.type !== actual.type) {
|
|
128
|
+
throw new Error(`Expected integrity of type "${expected.type}" but got type "${actual.type}" for "${expected.name}"`);
|
|
129
|
+
}
|
|
130
|
+
if (expected.name !== actual.name) {
|
|
131
|
+
throw new Error(`Expected integrity with name "${expected.name}" but got name "${actual.name}"`);
|
|
132
|
+
}
|
|
133
|
+
if (expected.type === "file" && actual.type === "file") {
|
|
134
|
+
if (expected.hash !== actual.hash) {
|
|
135
|
+
throw new Error(`Expected file integrity with hash "${expected.hash}" but got hash "${actual.hash}" for file "${expected.name}"`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else if (expected.type === "directory" && actual.type === "directory") {
|
|
139
|
+
if (expected.content.length > actual.content.length) {
|
|
140
|
+
throw new Error(`Expected directory integrity with ${expected.content.length} content items but got ${actual.content.length} content items for directory "${expected.name}"`);
|
|
141
|
+
}
|
|
142
|
+
for (const expectedContent of expected.content) {
|
|
143
|
+
const actualContent = actual.content.find((content) => content.name === expectedContent.name);
|
|
144
|
+
if (!actualContent) {
|
|
145
|
+
throw new Error(`Expected directory integrity to contain content with name "${expectedContent.name}" but it was not found in directory "${expected.name}"`);
|
|
146
|
+
}
|
|
147
|
+
checkIntegrity(expectedContent, actualContent, `${path}/${expected.name}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else if (expected.type === "docker-image" &&
|
|
151
|
+
actual.type === "docker-image") {
|
|
152
|
+
if (expected.digest !== actual.digest) {
|
|
153
|
+
throw new Error(`Expected docker image integrity with digest "${expected.digest}" but got digest "${actual.digest}" for image "${expected.name}:${expected.tag}"`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else if (expected.type === "bin-version" && actual.type === "bin-version") {
|
|
157
|
+
if (expected.version !== actual.version) {
|
|
158
|
+
throw new Error(`Expected bin version integrity with version "${expected.version}" but got version "${actual.version}" for bin "${expected.name}"`);
|
|
159
|
+
}
|
|
160
|
+
checkIntegrity(expected.fileIntegrity, actual.fileIntegrity, `${path}/${expected.name}`);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
throw new Error(`Unsupported integrity types for "${expected.name}": expected type "${expected.type}", actual type "${actual.type}"`);
|
|
164
|
+
}
|
|
165
|
+
console.log(`Integrity check passed for "${path}/${expected.name}"`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function generateSecuritySecrets() {
|
|
169
|
+
return {
|
|
170
|
+
SERVICE_API_TOKEN: randomBytes(32).toString("hex"),
|
|
171
|
+
REALTIME_SERVER_SECRET: randomBytes(32).toString("hex"),
|
|
172
|
+
SUPABASE_AUTH_HOOK_SECRET: `v1,whsec_${randomBytes(32).toString("base64")}`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/** Add missing secrets on upgrades, preserving keys already used by the box. */
|
|
176
|
+
async function ensureSecuritySecrets(envPath) {
|
|
177
|
+
const env = parseEnv(await readFile(envPath, "utf8"));
|
|
178
|
+
const generated = generateSecuritySecrets();
|
|
179
|
+
const additions = [];
|
|
180
|
+
for (const key of Object.keys(generated)) {
|
|
181
|
+
const value = env[key];
|
|
182
|
+
if (!value) {
|
|
183
|
+
additions.push(`${key}=${generated[key]}`);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const valid = key !== "SUPABASE_AUTH_HOOK_SECRET"
|
|
187
|
+
? value.length >= 32 && !/\s/.test(value)
|
|
188
|
+
: /^v1,whsec_[A-Za-z0-9+/]+={0,2}$/.test(value) &&
|
|
189
|
+
Buffer.from(value.slice("v1,whsec_".length), "base64").length >= 32;
|
|
190
|
+
if (!valid) {
|
|
191
|
+
throw new Error(`Invalid ${key} in box configuration`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Restrict the existing file before appending credentials. Never log its data.
|
|
195
|
+
await chmod(envPath, 0o600);
|
|
196
|
+
if (additions.length) {
|
|
197
|
+
await appendFile(envPath, `\n${additions.join("\n")}\n`, "utf8");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function replaceDirectory(source, destination, log) {
|
|
202
|
+
const temporary = `${destination}.new-${randomUUID()}`;
|
|
203
|
+
const previous = `${destination}.previous`;
|
|
204
|
+
await cp(source, temporary, { recursive: true, verbatimSymlinks: true });
|
|
205
|
+
try {
|
|
206
|
+
await rm(previous, { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
if (!["EACCES", "EPERM"].includes(error.code ?? ""))
|
|
210
|
+
throw error;
|
|
211
|
+
// Old bundles used sudo ditto for the Mac app. Its root-owned backup can
|
|
212
|
+
// be moved within the user's Applications folder, but not removed by us.
|
|
213
|
+
const preserved = `${previous}-${randomUUID()}`;
|
|
214
|
+
await rename(previous, preserved);
|
|
215
|
+
log("setup", `Preserved protected backup: ${preserved}`);
|
|
216
|
+
}
|
|
217
|
+
if (existsSync(destination))
|
|
218
|
+
await rename(destination, previous);
|
|
219
|
+
try {
|
|
220
|
+
await rename(temporary, destination);
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
if (existsSync(previous))
|
|
224
|
+
await rename(previous, destination);
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
async function hasLegacyLayout(root) {
|
|
229
|
+
try {
|
|
230
|
+
return /^name:\s*supabase\s*$/m.test(await readFile(join(root, "docker/docker-compose.yml"), "utf8"));
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
if (error.code !== "ENOENT")
|
|
234
|
+
throw error;
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
async function installUpdate(bundle) {
|
|
239
|
+
const configPath = resolve(process.env.SCHORNAI_CONFIG ?? defaultConfigPath());
|
|
240
|
+
const root = dirname(configPath);
|
|
241
|
+
const legacyCompose = join(root, "docker/docker-compose.yml");
|
|
242
|
+
const legacyLayout = await hasLegacyLayout(root);
|
|
243
|
+
const migrationFile = join(root, "service-migration.json");
|
|
244
|
+
const legacy = legacyLayout || existsSync(migrationFile);
|
|
245
|
+
const createConfig = !existsSync(configPath);
|
|
246
|
+
// Old Admin releases know only setup.sh and docker.env. Keep this bootstrap
|
|
247
|
+
// in every bundle so an old box can skip any number of intermediate releases.
|
|
248
|
+
const legacyEnvironment = createConfig && legacy
|
|
249
|
+
? parseEnv(await readFile(join(root, "docker.env"), "utf8"))
|
|
250
|
+
: undefined;
|
|
251
|
+
const initialConfig = legacyEnvironment
|
|
252
|
+
? configSchema.parse({
|
|
253
|
+
version: 1,
|
|
254
|
+
id: legacyEnvironment.BOX_ID || randomUUID(),
|
|
255
|
+
services: [
|
|
256
|
+
"supabase",
|
|
257
|
+
"core",
|
|
258
|
+
"ollama",
|
|
259
|
+
"transcriber-mlx",
|
|
260
|
+
"document-extractor",
|
|
261
|
+
],
|
|
262
|
+
docker: { manageColima: process.platform === "darwin" },
|
|
263
|
+
sharing: { url: "http://127.0.0.1:3001" },
|
|
264
|
+
})
|
|
265
|
+
: undefined;
|
|
266
|
+
const context = await loadConfig(configPath, initialConfig);
|
|
267
|
+
const { config } = context;
|
|
268
|
+
if (config.development)
|
|
269
|
+
throw new Error("Setup bundles install production runtimes. Stop development and use a separate installation config to test setup updates.");
|
|
270
|
+
const manifest = JSON.parse(await readFile(join(bundle, "installation.json"), "utf8"));
|
|
271
|
+
if (manifest.version !== 1)
|
|
272
|
+
throw new Error("Unsupported installation bundle");
|
|
273
|
+
// Verify everything before stopping services or replacing the first installed file.
|
|
274
|
+
for (const part of ["apps", "bin", "container"])
|
|
275
|
+
checkIntegrity(manifest.integrity[part], await getDirectoryIntegrity(join(bundle, part)));
|
|
276
|
+
const native = config.services.some((service) => ["transcriber-mlx", "ollama"].includes(service));
|
|
277
|
+
const compatible = manifest.platform === process.platform && manifest.arch === process.arch;
|
|
278
|
+
if (native && !compatible)
|
|
279
|
+
throw new Error("This bundle's native binaries do not match this machine");
|
|
280
|
+
const hasDocker = config.services.some((service) => ["supabase", "core", "document-extractor"].includes(service));
|
|
281
|
+
if (hasDocker && manifest.arch !== process.arch)
|
|
282
|
+
throw new Error("Docker image architecture does not match this machine");
|
|
283
|
+
// A retry must still finish migration after the old Compose file was replaced.
|
|
284
|
+
if (legacy)
|
|
285
|
+
await writeJson(migrationFile, { version: 1, from: "admin-app" });
|
|
286
|
+
if (createConfig)
|
|
287
|
+
await saveInitialConfig(configPath, config);
|
|
288
|
+
const log = createLogger(root, context.environment);
|
|
289
|
+
const dockerEnvironment = {
|
|
290
|
+
...context.environment,
|
|
291
|
+
PATH: `${join(root, "bin/docker")}:${join(root, "bin/lima/bin")}:${context.environment.PATH ?? "/usr/bin:/bin:/opt/homebrew/bin"}`,
|
|
292
|
+
};
|
|
293
|
+
const cli = join(root, "apps/service/cli.mjs");
|
|
294
|
+
const managed = process.env.SCHORNAI_SERVICE_UPDATE === "1";
|
|
295
|
+
let resume = false;
|
|
296
|
+
if (!managed) {
|
|
297
|
+
try {
|
|
298
|
+
await control(root, "status");
|
|
299
|
+
resume = true;
|
|
300
|
+
await control(root, "stop");
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
if (!["ENOENT", "ECONNREFUSED"].includes(error.code ?? ""))
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
if (resume) {
|
|
307
|
+
const deadline = Date.now() + 120_000;
|
|
308
|
+
while (true) {
|
|
309
|
+
try {
|
|
310
|
+
await control(root, "status");
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
if (["ENOENT", "ECONNREFUSED"].includes(error.code ?? ""))
|
|
314
|
+
break;
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
if (Date.now() > deadline)
|
|
318
|
+
throw new Error("Service did not stop for update");
|
|
319
|
+
await setTimeout(250);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (legacyLayout) {
|
|
324
|
+
if (process.platform === "darwin")
|
|
325
|
+
await run({
|
|
326
|
+
name: "setup",
|
|
327
|
+
command: "osascript",
|
|
328
|
+
args: [
|
|
329
|
+
"-e",
|
|
330
|
+
'if application "schorn.ai Admin" is running then tell application "schorn.ai Admin" to quit',
|
|
331
|
+
"-e",
|
|
332
|
+
"repeat 300 times",
|
|
333
|
+
"-e",
|
|
334
|
+
'if not (application "schorn.ai Admin" is running) then return',
|
|
335
|
+
"-e",
|
|
336
|
+
"delay 0.2",
|
|
337
|
+
"-e",
|
|
338
|
+
"end repeat",
|
|
339
|
+
"-e",
|
|
340
|
+
'error "Legacy Admin app did not quit"',
|
|
341
|
+
],
|
|
342
|
+
env: context.environment,
|
|
343
|
+
}, log);
|
|
344
|
+
await stopLegacyTranscriber(root, log);
|
|
345
|
+
const legacyDocker = join(root, "bin/docker/docker-compose");
|
|
346
|
+
// The legacy app owns a foreground Colima process and stops it on quit.
|
|
347
|
+
const legacyColima = await ensureDocker(context, dockerEnvironment, log);
|
|
348
|
+
try {
|
|
349
|
+
await run({
|
|
350
|
+
name: "setup",
|
|
351
|
+
command: existsSync(legacyDocker) ? legacyDocker : "docker",
|
|
352
|
+
args: [
|
|
353
|
+
...(existsSync(legacyDocker) ? [] : ["compose"]),
|
|
354
|
+
"--project-directory",
|
|
355
|
+
join(root, "docker"),
|
|
356
|
+
"--env-file",
|
|
357
|
+
resolveFrom(root, config.envFile),
|
|
358
|
+
"-f",
|
|
359
|
+
legacyCompose,
|
|
360
|
+
"down",
|
|
361
|
+
],
|
|
362
|
+
env: dockerEnvironment,
|
|
363
|
+
}, log);
|
|
364
|
+
}
|
|
365
|
+
finally {
|
|
366
|
+
if (legacyColima)
|
|
367
|
+
await run({ ...legacyColima, args: ["stop"] }, log);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (config.services.some((service) => ["supabase", "core"].includes(service)))
|
|
371
|
+
await ensureSecuritySecrets(resolveFrom(root, config.envFile));
|
|
372
|
+
await mkdir(join(root, "apps"), { recursive: true });
|
|
373
|
+
await mkdir(join(root, "bin"), { recursive: true });
|
|
374
|
+
if (compatible) {
|
|
375
|
+
const bins = new Set([
|
|
376
|
+
"node",
|
|
377
|
+
...(hasDocker
|
|
378
|
+
? ["docker", ...(config.docker.manageColima ? ["colima", "lima"] : [])]
|
|
379
|
+
: []),
|
|
380
|
+
...(config.services.includes("ollama") ? ["ollama"] : []),
|
|
381
|
+
...(config.services.includes("transcriber-mlx") ? ["ffmpeg"] : []),
|
|
382
|
+
]);
|
|
383
|
+
for (const name of bins) {
|
|
384
|
+
// Linux/container-only bundles use the host's Node, Docker and Ollama.
|
|
385
|
+
// Mac bundles carry these runtimes and replace them together with the app.
|
|
386
|
+
if (existsSync(join(bundle, "bin", name)))
|
|
387
|
+
await replaceDirectory(join(bundle, "bin", name), join(root, "bin", name), log);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (config.services.includes("transcriber-mlx"))
|
|
391
|
+
await replaceDirectory(join(bundle, "apps/transcriber-mac"), join(root, "apps/transcriber-mac"), log);
|
|
392
|
+
if (hasDocker) {
|
|
393
|
+
const docker = join(root, "docker");
|
|
394
|
+
await mkdir(docker, { recursive: true });
|
|
395
|
+
// Only configuration is copied. Existing PGDATA, storage, certificates and keys remain in place.
|
|
396
|
+
await cp(join(bundle, "container/volumes"), join(docker, "volumes"), {
|
|
397
|
+
recursive: true,
|
|
398
|
+
verbatimSymlinks: true,
|
|
399
|
+
});
|
|
400
|
+
await cp(join(bundle, "container/docker-compose.yml"), join(docker, "docker-compose.yml"));
|
|
401
|
+
await cp(join(bundle, "container/supabase"), join(docker, "supabase"), {
|
|
402
|
+
recursive: true,
|
|
403
|
+
});
|
|
404
|
+
await cp(join(bundle, "container/document-extractor"), join(docker, "document-extractor"), {
|
|
405
|
+
recursive: true,
|
|
406
|
+
});
|
|
407
|
+
const dockerBin = join(root, "bin/docker/docker");
|
|
408
|
+
const colima = await ensureDocker(context, dockerEnvironment, log);
|
|
409
|
+
try {
|
|
410
|
+
for (const image of manifest.images) {
|
|
411
|
+
if (!image.services.some((service) => config.services.includes(service)))
|
|
412
|
+
continue;
|
|
413
|
+
if (!/^[A-Za-z0-9_.-]+\.tar$/.test(image.file))
|
|
414
|
+
throw new Error("Invalid image archive name");
|
|
415
|
+
await run({
|
|
416
|
+
name: "setup",
|
|
417
|
+
command: existsSync(dockerBin) ? dockerBin : "docker",
|
|
418
|
+
args: [
|
|
419
|
+
"load",
|
|
420
|
+
"--input",
|
|
421
|
+
join(bundle, "container/images", image.file),
|
|
422
|
+
],
|
|
423
|
+
env: dockerEnvironment,
|
|
424
|
+
}, log);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
finally {
|
|
428
|
+
if (colima)
|
|
429
|
+
await run({ ...colima, args: ["stop"] }, log);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
// The Mac launcher is optional; headless hosts never install or launch a GUI.
|
|
433
|
+
const appPath = join(homedir(), "Applications/schorn.ai Admin.app");
|
|
434
|
+
if (compatible &&
|
|
435
|
+
process.platform === "darwin" &&
|
|
436
|
+
existsSync(join(bundle, "apps/schorn.ai Admin.app")) &&
|
|
437
|
+
(legacy || existsSync(appPath))) {
|
|
438
|
+
await mkdir(dirname(appPath), { recursive: true });
|
|
439
|
+
await access(join(bundle, "apps/schorn.ai Admin.app"));
|
|
440
|
+
await replaceDirectory(join(bundle, "apps/schorn.ai Admin.app"), appPath, log);
|
|
441
|
+
}
|
|
442
|
+
await replaceDirectory(join(bundle, "apps/service"), join(root, "apps/service"), log);
|
|
443
|
+
log("setup", "Update finished");
|
|
444
|
+
// An old Admin download includes xrag.tar in its extracted working folder.
|
|
445
|
+
// Record it before starting the daemon to avoid immediately installing it again.
|
|
446
|
+
if (legacy && existsSync(join(bundle, "xrag.tar"))) {
|
|
447
|
+
const { hash } = await getFileIntegrity(join(bundle, "xrag.tar"));
|
|
448
|
+
const state = join(root, "service-artifacts.json");
|
|
449
|
+
let installed = {};
|
|
450
|
+
try {
|
|
451
|
+
installed = JSON.parse(await readFile(state, "utf8"));
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
if (error.code !== "ENOENT")
|
|
455
|
+
throw error;
|
|
456
|
+
}
|
|
457
|
+
await writeJson(state, { ...installed, [hash]: new Date().toISOString() });
|
|
458
|
+
}
|
|
459
|
+
if ((resume || legacy) && !managed)
|
|
460
|
+
await run({
|
|
461
|
+
name: "setup",
|
|
462
|
+
command: binary(root, "node", process.execPath),
|
|
463
|
+
args: [cli, "service", "start", "--config", context.configPath],
|
|
464
|
+
env: process.env,
|
|
465
|
+
}, log);
|
|
466
|
+
if (legacy)
|
|
467
|
+
await rm(migrationFile);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export { generateSecuritySecrets as g, hasLegacyLayout as h, installUpdate as i };
|
package/package.json
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "schornai",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "The schorn.ai CLI
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "The schorn.ai CLI, including machine service commands.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"schornai": "bin/schornai.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
|
-
"bin/"
|
|
10
|
+
"bin/",
|
|
11
|
+
"dist/"
|
|
11
12
|
],
|
|
12
13
|
"scripts": {
|
|
13
|
-
"start": "node bin/schornai.js"
|
|
14
|
+
"start": "node bin/schornai.js",
|
|
15
|
+
"build": "npm run build --workspace @schornio/aibox-package-service && node scripts/build.mjs",
|
|
16
|
+
"prepack": "npm run build"
|
|
14
17
|
},
|
|
15
18
|
"engines": {
|
|
16
|
-
"node": ">=
|
|
19
|
+
"node": ">=24"
|
|
17
20
|
},
|
|
18
21
|
"homepage": "https://schorn.ai",
|
|
19
22
|
"license": "UNLICENSED",
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@schornio/aibox-package-service": "*"
|
|
25
|
+
},
|
|
20
26
|
"publishConfig": {
|
|
21
27
|
"access": "public",
|
|
22
28
|
"registry": "https://registry.npmjs.org/"
|