@gentbajko/slopify 2.0.2 → 2.1.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/SUBTITLES.md +1 -1
- package/dist/adapters/alignment/protocol.js +11 -1
- package/dist/adapters/alignment/runner.js +4 -1
- package/dist/adapters/alignment/threads.js +11 -0
- package/dist/adapters/alignment/worker.js +66 -9
- package/dist/adapters/explain.js +93 -0
- package/dist/adapters/ffmpeg.js +3 -3
- package/dist/adapters/host-cli/index.js +34 -3
- package/dist/adapters/host-cli/transport.js +4 -4
- package/dist/adapters/image/bytes.js +2 -2
- package/dist/adapters/image/codex-output.js +12 -12
- package/dist/adapters/image/codex.js +24 -8
- package/dist/adapters/image/fal.js +12 -6
- package/dist/adapters/image/google.js +18 -8
- package/dist/adapters/image/models.js +8 -2
- package/dist/adapters/image/openai.js +15 -6
- package/dist/adapters/image/replicate.js +16 -7
- package/dist/adapters/llm/claude-code-models.js +1 -1
- package/dist/adapters/llm/claude-code.js +16 -7
- package/dist/adapters/llm/cli-login-error.js +2 -1
- package/dist/adapters/llm/codex-models.js +1 -1
- package/dist/adapters/llm/codex.js +5 -6
- package/dist/adapters/llm/document-workspace.js +2 -2
- package/dist/adapters/llm/gemini-models.js +1 -1
- package/dist/adapters/llm/gemini-workspace.js +1 -1
- package/dist/adapters/llm/gemini.js +15 -11
- package/dist/adapters/llm/openrouter.js +21 -9
- package/dist/adapters/llm/run-cli.js +14 -11
- package/dist/adapters/tts/cartesia.js +10 -3
- package/dist/adapters/tts/elevenlabs.js +18 -6
- package/dist/adapters/tts/inworld-async.js +47 -16
- package/dist/adapters/tts/inworld.js +25 -15
- package/dist/adapters/tts/openai.js +18 -6
- package/dist/assets/document/SOURCE.txt +7 -0
- package/dist/assets/document/background.jpg +0 -0
- package/dist/assets/document/fonts/Cinzel-Black.ttf +0 -0
- package/dist/assets/document/fonts/Cinzel-Bold.ttf +0 -0
- package/dist/assets/document/fonts/Cinzel-Medium.ttf +0 -0
- package/dist/assets/document/fonts/Cinzel-Regular.ttf +0 -0
- package/dist/assets/document/fonts/OFL.txt +93 -0
- package/dist/assets/document/fonts/SOURCE.txt +11 -0
- package/dist/catalog/registry.js +13 -10
- package/dist/catalog/store.js +1 -1
- package/dist/catalog/validate.js +16 -6
- package/dist/edge/cli.js +33 -7
- package/dist/edge/docker-launch.js +6 -6
- package/dist/edge/docker-projects/engine.js +34 -13
- package/dist/edge/docker-projects/install.js +8 -8
- package/dist/edge/docker-projects/state.js +11 -7
- package/dist/edge/docker-projects/tree.js +6 -6
- package/dist/edge/docker.js +13 -13
- package/dist/edge/host-cli.js +14 -0
- package/dist/edge/http/actions.js +27 -25
- package/dist/edge/http/app.js +3 -3
- package/dist/edge/http/audio-preview.js +1 -1
- package/dist/edge/http/checkpoints.js +5 -1
- package/dist/edge/http/diagnostics.js +3 -1
- package/dist/edge/http/draft-files.js +2 -2
- package/dist/edge/http/draft-problem.js +11 -1
- package/dist/edge/http/files.js +4 -4
- package/dist/edge/http/folder-location-schema.js +5 -8
- package/dist/edge/http/folder-location.js +13 -8
- package/dist/edge/http/fonts.js +22 -10
- package/dist/edge/http/host-cli.js +61 -14
- package/dist/edge/http/multipart.js +7 -5
- package/dist/edge/http/open-folder.js +2 -2
- package/dist/edge/http/planning.js +4 -4
- package/dist/edge/http/problem.js +2 -2
- package/dist/edge/http/project-templates.js +7 -3
- package/dist/edge/http/projects.js +8 -8
- package/dist/edge/http/prompts.js +9 -4
- package/dist/edge/http/providers.js +15 -7
- package/dist/edge/http/revision-files.js +8 -4
- package/dist/edge/http/revisions.js +30 -4
- package/dist/edge/http/schedules.js +9 -9
- package/dist/edge/http/settings.js +15 -9
- package/dist/edge/http/staging.js +5 -5
- package/dist/edge/http/storage.js +4 -4
- package/dist/edge/http/subtitles.js +1 -1
- package/dist/edge/http/tutorial.js +2 -2
- package/dist/edge/http/update.js +3 -3
- package/dist/host-cli/install.js +28 -6
- package/dist/host-cli/open-folder.js +95 -0
- package/dist/host-cli/paths.js +6 -6
- package/dist/host-cli/runtime.js +2 -0
- package/dist/host-cli/server.js +1 -1
- package/dist/host-cli/service.js +11 -11
- package/dist/host-cli/status.js +7 -3
- package/dist/kernel/config/index.js +6 -4
- package/dist/kernel/db/migrate.js +27 -2
- package/dist/kernel/db/migrations/0013-schedule-topic-queue.sql +4 -0
- package/dist/kernel/db/migrations/0014-document-stage.sql +44 -0
- package/dist/kernel/db/migrations/0015-description-prompts.sql +13 -0
- package/dist/kernel/lock.js +4 -3
- package/dist/kernel/pipeline.js +11 -1
- package/dist/kernel/ports/host-cli.js +14 -0
- package/dist/kernel/ports/subtitles.js +15 -1
- package/dist/kernel/runner/attempt.js +26 -2
- package/dist/kernel/runner/graph.js +6 -1
- package/dist/main.js +8 -4
- package/dist/slices/admission/model.js +7 -1
- package/dist/slices/admission/rules.js +98 -22
- package/dist/slices/admission/schema.js +16 -3
- package/dist/slices/admission/start.js +3 -2
- package/dist/slices/article/continuation.js +4 -2
- package/dist/slices/article/plain.js +2 -2
- package/dist/slices/article/provided-entries.js +3 -3
- package/dist/slices/article/run.js +2 -2
- package/dist/slices/article/segments.js +5 -3
- package/dist/slices/control/providers.js +20 -7
- package/dist/slices/document/blocks.js +129 -0
- package/dist/slices/document/flow.js +117 -0
- package/dist/slices/document/fonts.js +54 -0
- package/dist/slices/document/model.js +11 -0
- package/dist/slices/document/pages.js +198 -0
- package/dist/slices/document/render.js +242 -0
- package/dist/slices/document/sources.js +35 -0
- package/dist/slices/document/theme.js +158 -0
- package/dist/slices/document/writer.js +66 -0
- package/dist/slices/estimate/index.js +14 -2
- package/dist/slices/fonts/catalog.js +1 -1
- package/dist/slices/fonts/upload.js +1 -1
- package/dist/slices/images/run.js +5 -5
- package/dist/slices/library/lint.js +13 -7
- package/dist/slices/library/model.js +1 -1
- package/dist/slices/library/slots.js +6 -3
- package/dist/slices/narration/concat.js +1 -1
- package/dist/slices/narration/pronunciation-chunks.js +3 -3
- package/dist/slices/narration/pronunciation.js +2 -2
- package/dist/slices/narration/run.js +7 -7
- package/dist/slices/narration/steering.js +3 -3
- package/dist/slices/play-drafts/convert.js +35 -5
- package/dist/slices/play-drafts/readiness.js +15 -8
- package/dist/slices/play-drafts/review-inputs.js +7 -4
- package/dist/slices/play-drafts/review.js +3 -3
- package/dist/slices/play-drafts/schema.js +22 -5
- package/dist/slices/play-drafts/service.js +3 -1
- package/dist/slices/play-drafts/uploads.js +1 -1
- package/dist/slices/project-templates/from-project.js +10 -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 +11 -1
- package/dist/slices/rebuild/recipe-audio.js +3 -0
- package/dist/slices/rebuild/recipe-build.js +6 -1
- package/dist/slices/rebuild/recipe-document.js +36 -0
- package/dist/slices/rebuild/recipe-exports.js +14 -2
- package/dist/slices/rebuild/recipe-fixture.js +4 -0
- package/dist/slices/rebuild/recipe-legacy.js +6 -1
- package/dist/slices/rebuild/recipe-model.js +2 -0
- package/dist/slices/rebuild/recipe-narration-text.js +1 -1
- package/dist/slices/rebuild/recipe-provider-choice.js +3 -0
- package/dist/slices/rebuild/recipe-save.js +15 -3
- package/dist/slices/rebuild/recipe-validation.js +15 -2
- package/dist/slices/rebuild/recipe-visual.js +11 -2
- package/dist/slices/rebuild/recipe-work.js +15 -0
- package/dist/slices/rebuild/recipe-youtube.js +33 -0
- package/dist/slices/rebuild/recovery-selection.js +2 -0
- package/dist/slices/rebuild/recovery.js +12 -7
- package/dist/slices/rebuild/repo.js +3 -3
- package/dist/slices/rebuild/runtime-actions.js +2 -0
- package/dist/slices/rebuild/runtime-admission.js +4 -4
- package/dist/slices/rebuild/runtime-article.js +5 -5
- package/dist/slices/rebuild/runtime-checkpoints.js +4 -4
- package/dist/slices/rebuild/runtime-document.js +85 -0
- package/dist/slices/rebuild/runtime-export-inputs.js +7 -6
- package/dist/slices/rebuild/runtime-export.js +39 -23
- package/dist/slices/rebuild/runtime-local.js +6 -6
- package/dist/slices/rebuild/runtime-narration-text.js +46 -7
- package/dist/slices/rebuild/runtime-piece-label.js +66 -0
- package/dist/slices/rebuild/runtime-provider.js +6 -4
- package/dist/slices/rebuild/runtime-publication.js +4 -3
- package/dist/slices/rebuild/runtime-run.js +22 -4
- package/dist/slices/rebuild/runtime-store.js +7 -2
- package/dist/slices/rebuild/runtime-subtitles.js +82 -16
- package/dist/slices/rebuild/runtime-youtube.js +84 -0
- package/dist/slices/rebuild/transition-repo.js +8 -6
- package/dist/slices/reruns/cascade.js +6 -4
- package/dist/slices/reruns/index.js +1 -1
- package/dist/slices/research/run.js +9 -7
- package/dist/slices/research/synthesis.js +4 -2
- package/dist/slices/revisions/mutation-assets.js +26 -8
- package/dist/slices/revisions/mutation-cues.js +4 -2
- package/dist/slices/revisions/projection.js +2 -1
- package/dist/slices/revisions/rules.js +18 -9
- package/dist/slices/revisions/schema.js +1 -1
- package/dist/slices/schedules/repo.js +55 -11
- package/dist/slices/schedules/scheduler.js +25 -8
- package/dist/slices/schedules/schema.js +19 -4
- package/dist/slices/schedules/service.js +2 -0
- package/dist/slices/settings/cli-paths.js +7 -4
- package/dist/slices/settings/models.js +4 -4
- package/dist/slices/settings/playback.js +2 -2
- package/dist/slices/storage/assets.js +5 -5
- package/dist/slices/storage/downloads.js +1 -0
- package/dist/slices/storage/layout.js +7 -1
- package/dist/slices/storage/model.js +3 -0
- package/dist/slices/subtitles/captions.js +1 -1
- package/dist/slices/subtitles/prepare.js +7 -6
- package/dist/slices/subtitles/transcript.js +1 -1
- package/dist/slices/telemetry/model.js +1 -1
- package/dist/slices/thumbnail/run.js +8 -8
- package/dist/slices/video/audio-export.js +11 -3
- package/dist/slices/video/audio-inputs.js +2 -2
- package/dist/slices/video/edit-list.js +73 -0
- package/dist/slices/video/ffmpeg.js +129 -61
- package/dist/slices/video/motion.js +51 -0
- package/dist/slices/video/plan.js +62 -28
- package/dist/slices/video/reuse-audio.js +2 -0
- package/dist/slices/video/run.js +23 -12
- package/dist/slices/video/slideshow.js +48 -0
- package/dist/slices/video/write-export.js +17 -25
- package/dist/slices/youtube/answer.js +199 -0
- package/dist/slices/youtube/model.js +27 -0
- package/dist/slices/youtube/timestamps.js +24 -0
- package/dist/slices/youtube/transcript.js +47 -0
- package/dist/updater/plan.js +1 -1
- package/dist/updater/service.js +4 -3
- package/dist/updater/worker.js +6 -4
- package/dist/web/assets/index-B6NbhpjD.css +1 -0
- package/dist/web/assets/index-ComF5gwP.js +142 -0
- package/dist/web/index.html +2 -2
- package/package.json +4 -2
- package/dist/web/assets/index-CZNiHm8P.css +0 -1
- package/dist/web/assets/index-DPgGLX5m.js +0 -137
|
@@ -30,7 +30,7 @@ export function settingsRoutes(deps) {
|
|
|
30
30
|
return problem(c, {
|
|
31
31
|
status: 400,
|
|
32
32
|
title: titleOf(400),
|
|
33
|
-
detail: "These settings cannot be saved
|
|
33
|
+
detail: "These settings cannot be saved yet. Fix the highlighted fields and try again.",
|
|
34
34
|
extensions: { fields: result.fields },
|
|
35
35
|
});
|
|
36
36
|
}
|
|
@@ -46,12 +46,12 @@ export function settingsRoutes(deps) {
|
|
|
46
46
|
? problem(c, {
|
|
47
47
|
status: 409,
|
|
48
48
|
title: titleOf(409),
|
|
49
|
-
detail: "This voice ID is already
|
|
49
|
+
detail: "This voice ID is already saved for this provider. Use the existing voice, or enter a different voice ID.",
|
|
50
50
|
})
|
|
51
51
|
: problem(c, {
|
|
52
52
|
status: 400,
|
|
53
53
|
title: titleOf(400),
|
|
54
|
-
detail: "This voice cannot be added
|
|
54
|
+
detail: "This voice cannot be added yet. Fix the highlighted field and try again.",
|
|
55
55
|
extensions: { fields: [fieldOf(result.reason)] },
|
|
56
56
|
});
|
|
57
57
|
}
|
|
@@ -62,7 +62,7 @@ export function settingsRoutes(deps) {
|
|
|
62
62
|
return problem(c, {
|
|
63
63
|
status: 404,
|
|
64
64
|
title: titleOf(404),
|
|
65
|
-
detail: "
|
|
65
|
+
detail: "This voice no longer exists; it may have been deleted already. Reload the page to see your voices.",
|
|
66
66
|
});
|
|
67
67
|
}
|
|
68
68
|
return c.body(null, 204);
|
|
@@ -71,20 +71,26 @@ export function settingsRoutes(deps) {
|
|
|
71
71
|
function fieldOf(reason) {
|
|
72
72
|
switch (reason) {
|
|
73
73
|
case "blank-name":
|
|
74
|
-
return { field: "name", message: "
|
|
74
|
+
return { field: "name", message: "Enter a name for this voice." };
|
|
75
75
|
case "name-too-long":
|
|
76
76
|
return {
|
|
77
77
|
field: "name",
|
|
78
|
-
message: `
|
|
78
|
+
message: `Keep the voice name to ${String(voiceNameMax)} characters or fewer.`,
|
|
79
79
|
};
|
|
80
80
|
case "blank-voice-id":
|
|
81
|
-
return {
|
|
81
|
+
return {
|
|
82
|
+
field: "voiceId",
|
|
83
|
+
message: "Enter the voice ID from your provider's voice library.",
|
|
84
|
+
};
|
|
82
85
|
case "voice-id-too-long":
|
|
83
86
|
return {
|
|
84
87
|
field: "voiceId",
|
|
85
|
-
message: `A voice ID is at most ${String(voiceIdMax)} characters.`,
|
|
88
|
+
message: `A voice ID is at most ${String(voiceIdMax)} characters. Check you copied only the ID.`,
|
|
86
89
|
};
|
|
87
90
|
case "not-a-tts-provider":
|
|
88
|
-
return {
|
|
91
|
+
return {
|
|
92
|
+
field: "provider",
|
|
93
|
+
message: "Choose a text-to-speech provider, such as Inworld or Cartesia.",
|
|
94
|
+
};
|
|
89
95
|
}
|
|
90
96
|
}
|
|
@@ -34,7 +34,7 @@ export function stagingRoutes(deps) {
|
|
|
34
34
|
return problem(c, {
|
|
35
35
|
status: 400,
|
|
36
36
|
title: titleOf(400),
|
|
37
|
-
detail: "
|
|
37
|
+
detail: "No file arrived with the upload. Choose the file again and retry.",
|
|
38
38
|
});
|
|
39
39
|
}
|
|
40
40
|
if (!result.ok) {
|
|
@@ -49,8 +49,8 @@ export function stagingRoutes(deps) {
|
|
|
49
49
|
status: result.reason === "in-use" ? 409 : 404,
|
|
50
50
|
title: titleOf(result.reason === "in-use" ? 409 : 404),
|
|
51
51
|
detail: result.reason === "in-use"
|
|
52
|
-
? "This file is
|
|
53
|
-
: "
|
|
52
|
+
? "This file is still attached to a draft in Play. Remove it from the draft first."
|
|
53
|
+
: "This uploaded file no longer exists; it may have been removed already. Reload the page.",
|
|
54
54
|
});
|
|
55
55
|
}
|
|
56
56
|
return c.body(null, 204);
|
|
@@ -58,6 +58,6 @@ export function stagingRoutes(deps) {
|
|
|
58
58
|
}
|
|
59
59
|
function detailOf(reason) {
|
|
60
60
|
return reason === "empty-file"
|
|
61
|
-
? "
|
|
62
|
-
: "
|
|
61
|
+
? "This file is empty (0 bytes). Choose a file that has content."
|
|
62
|
+
: "This file's name is empty or contains a slash. Rename the file on your computer, then upload it again.";
|
|
63
63
|
}
|
|
@@ -23,7 +23,7 @@ export function storageRoutes(deps) {
|
|
|
23
23
|
return problem(c, {
|
|
24
24
|
status: 500,
|
|
25
25
|
title: titleOf(500),
|
|
26
|
-
detail: "
|
|
26
|
+
detail: "Slopify could not create the backup. Check there is free disk space and try again; if it keeps failing, use Download diagnostics in Settings and report it.",
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
29
|
})
|
|
@@ -33,14 +33,14 @@ export function storageRoutes(deps) {
|
|
|
33
33
|
return problem(c, {
|
|
34
34
|
status: 415,
|
|
35
35
|
title: titleOf(415),
|
|
36
|
-
detail: "
|
|
36
|
+
detail: "Choose a Slopify backup file (.zip) made with Backup & storage in Settings.",
|
|
37
37
|
});
|
|
38
38
|
const upload = await readPortableUpload(c.req.raw);
|
|
39
39
|
if (!upload.ok)
|
|
40
40
|
return problem(c, {
|
|
41
41
|
status: 413,
|
|
42
42
|
title: titleOf(413),
|
|
43
|
-
detail: "
|
|
43
|
+
detail: "This backup file is empty or larger than 100 MB. Choose a Slopify backup file under 100 MB.",
|
|
44
44
|
});
|
|
45
45
|
try {
|
|
46
46
|
const imported = importPortable({
|
|
@@ -55,7 +55,7 @@ export function storageRoutes(deps) {
|
|
|
55
55
|
return problem(c, {
|
|
56
56
|
status: 400,
|
|
57
57
|
title: titleOf(400),
|
|
58
|
-
detail: "
|
|
58
|
+
detail: "This file is not a complete Slopify backup; it may be damaged or a different kind of ZIP. Choose a backup made with Backup & storage in Settings.",
|
|
59
59
|
});
|
|
60
60
|
}
|
|
61
61
|
})
|
|
@@ -16,7 +16,7 @@ function revisionRequired(c) {
|
|
|
16
16
|
return problem(c, {
|
|
17
17
|
status: 409,
|
|
18
18
|
title: titleOf(409),
|
|
19
|
-
detail: "
|
|
19
|
+
detail: "This change is now made from the Edit tab. Choose Edit project, save your change, then use Rebuild affected outputs.",
|
|
20
20
|
extensions: { reason: "revision-required" },
|
|
21
21
|
});
|
|
22
22
|
}
|
|
@@ -13,8 +13,8 @@ export function tutorialRoutes(deps) {
|
|
|
13
13
|
status: 409,
|
|
14
14
|
title: titleOf(409),
|
|
15
15
|
detail: result.reason === "unreadable"
|
|
16
|
-
? "
|
|
17
|
-
: "
|
|
16
|
+
? "Your saved tutorial progress could not be read. Choose Restart tutorial to start it again."
|
|
17
|
+
: "Your tutorial progress changed in another tab or window. Reload the page, then try again.",
|
|
18
18
|
extensions: { reason: result.reason },
|
|
19
19
|
});
|
|
20
20
|
return c.json(result.value);
|
package/dist/edge/http/update.js
CHANGED
|
@@ -8,7 +8,7 @@ export function updateRoutes(deps) {
|
|
|
8
8
|
busy: false,
|
|
9
9
|
canUpdate: false,
|
|
10
10
|
status: "idle",
|
|
11
|
-
blockedReason: "
|
|
11
|
+
blockedReason: "This copy of Slopify cannot update itself from the app. Update it the way you installed it, for example by running the launcher again.",
|
|
12
12
|
};
|
|
13
13
|
return new Hono()
|
|
14
14
|
.get("/ready", (c) => {
|
|
@@ -38,7 +38,7 @@ export function updateRoutes(deps) {
|
|
|
38
38
|
return problem(c, {
|
|
39
39
|
status: 403,
|
|
40
40
|
title: titleOf(403),
|
|
41
|
-
detail: "
|
|
41
|
+
detail: "For your safety, updates can only be started from the Slopify page itself. Open Slopify and try again there.",
|
|
42
42
|
});
|
|
43
43
|
if (deps.updater === undefined)
|
|
44
44
|
return problem(c, { status: 409, title: titleOf(409), detail: unavailable.blockedReason });
|
|
@@ -51,7 +51,7 @@ export function updateRoutes(deps) {
|
|
|
51
51
|
title: titleOf(status),
|
|
52
52
|
detail: result.info.blockedReason ??
|
|
53
53
|
result.info.error ??
|
|
54
|
-
"
|
|
54
|
+
"You already have the latest version of Slopify.",
|
|
55
55
|
});
|
|
56
56
|
});
|
|
57
57
|
}
|
package/dist/host-cli/install.js
CHANGED
|
@@ -1,19 +1,41 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { access, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { isStableVersion, updatePackage } from "../updater/model.js";
|
|
6
6
|
import { installArgs, npmCommand } from "../updater/plan.js";
|
|
7
7
|
import { hasCode } from "./paths.js";
|
|
8
8
|
export const nodeHostSetupRunner = {
|
|
9
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) => {
|
|
10
|
+
execFile(file, [...args], { signal, timeout: 15 * 60_000, maxBuffer: 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => {
|
|
11
11
|
if (error && typeof error.code !== "number")
|
|
12
|
-
return reject(new Error(
|
|
13
|
-
resolve({ code: typeof error?.code === "number" ? error.code : 0, stdout });
|
|
12
|
+
return reject(new Error(setupCommandFailure(file, args, error)));
|
|
13
|
+
resolve({ code: typeof error?.code === "number" ? error.code : 0, stdout, stderr });
|
|
14
14
|
});
|
|
15
15
|
}),
|
|
16
16
|
};
|
|
17
|
+
// Says which program could not run and what to do about it; Node's own error only names the
|
|
18
|
+
// errno, and the person running the launcher needs the fix.
|
|
19
|
+
function setupCommandFailure(file, args, error) {
|
|
20
|
+
const name = basename(file);
|
|
21
|
+
const step = `"${[name, ...args.slice(0, 2)].join(" ")}"`;
|
|
22
|
+
if (error.code === "ENOENT") {
|
|
23
|
+
if (name === "docker")
|
|
24
|
+
return "Docker is not installed, or the docker command is not on your PATH. Install Docker Engine (https://docs.docker.com/engine/install/), check that docker info works, and run the launcher again.";
|
|
25
|
+
if (name === "systemctl" || name === "loginctl")
|
|
26
|
+
return `${name} was not found. Using this machine's AI CLIs from Docker needs systemd; start with --host-cli=off to use API keys only.`;
|
|
27
|
+
if (name === "npm" || name === "npm.cmd")
|
|
28
|
+
return "npm was not found. Install Node.js with npm (https://nodejs.org) and run the launcher again.";
|
|
29
|
+
return `${name} was not found on your PATH. Install it and run the launcher again.`;
|
|
30
|
+
}
|
|
31
|
+
if (error.code === "EACCES")
|
|
32
|
+
return `${file} could not be run (permission denied). Check that your user may run it, then try again.`;
|
|
33
|
+
if (error.code === "ABORT_ERR" || error.name === "AbortError")
|
|
34
|
+
return `${step} was stopped before it finished. Run the launcher again; Slopify will finish or undo the half-done step.`;
|
|
35
|
+
if (error.killed)
|
|
36
|
+
return `${step} took too long and was stopped. Check your internet connection and that Docker is running (docker info), then run the launcher again.`;
|
|
37
|
+
return `${step} could not finish (${error.message}). Check that Docker, Node.js and systemd user services work on this machine, then run the launcher again.`;
|
|
38
|
+
}
|
|
17
39
|
async function entryAt(directory, version) {
|
|
18
40
|
const path = join(directory, "node_modules", "@gentbajko", "slopify");
|
|
19
41
|
z.object({ name: z.literal(updatePackage), version: z.literal(version) }).parse(JSON.parse(await readFile(join(path, "package.json"), "utf8")));
|
|
@@ -34,7 +56,7 @@ export async function installHostPackage(options) {
|
|
|
34
56
|
}
|
|
35
57
|
catch (error) {
|
|
36
58
|
if (!hasCode(error, "ENOENT"))
|
|
37
|
-
throw new Error(
|
|
59
|
+
throw new Error(`The installed host helper in ${directory} is damaged. Delete that folder and run the launcher again. Nothing else was changed.`);
|
|
38
60
|
}
|
|
39
61
|
await mkdir(versions, { recursive: true, mode: 0o700 });
|
|
40
62
|
const staging = await mkdtemp(join(versions, `.install-${version}-`));
|
|
@@ -50,7 +72,7 @@ export async function installHostPackage(options) {
|
|
|
50
72
|
options.packageSource ?? `${updatePackage}@${version}`,
|
|
51
73
|
], signal);
|
|
52
74
|
if (result.code !== 0)
|
|
53
|
-
throw new Error("npm could not install the host helper.
|
|
75
|
+
throw new Error("npm could not install the Slopify host helper. Check your internet connection (npm must reach registry.npmjs.org) and run the launcher again. Your existing container was not changed.");
|
|
54
76
|
await entryAt(staging, version);
|
|
55
77
|
await rename(staging, directory);
|
|
56
78
|
return { entry: await entryAt(directory, version) };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { lstat, readdir } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join, relative, sep } from "node:path";
|
|
4
|
+
import { readState, receiptSchema } from "../edge/docker-projects/state.js";
|
|
5
|
+
import { HostFolderRefused } from "../kernel/ports/host-cli.js";
|
|
6
|
+
import { hasCode } from "./paths.js";
|
|
7
|
+
const again = "run the Docker launcher again (npx @gentbajko/slopify@latest --docker)";
|
|
8
|
+
/** Starts the desktop's opener and returns once it is running; the file manager is not awaited. */
|
|
9
|
+
export function launchXdgOpen(directory) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const child = spawn("xdg-open", [directory], {
|
|
12
|
+
detached: true,
|
|
13
|
+
shell: false,
|
|
14
|
+
stdio: "ignore",
|
|
15
|
+
});
|
|
16
|
+
child.once("error", (error) => {
|
|
17
|
+
reject(new Error(hasCode(error, "ENOENT")
|
|
18
|
+
? "xdg-open isn't installed on this machine, so the Slopify host helper can't open a file manager. Install xdg-utils (for example sudo pacman -S xdg-utils or sudo apt install xdg-utils), then use Open folder again."
|
|
19
|
+
: `The Slopify host helper could not start xdg-open to open ${directory}. Check that you are signed in to this computer's desktop, then use Open folder again.`));
|
|
20
|
+
});
|
|
21
|
+
child.once("spawn", () => {
|
|
22
|
+
child.unref();
|
|
23
|
+
resolve();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/** Only folders inside a launcher receipt's project folder, reached through real folders you own, are opened. */
|
|
28
|
+
export function createHostFolderOpener(deps) {
|
|
29
|
+
async function receipts() {
|
|
30
|
+
const found = [];
|
|
31
|
+
let entries;
|
|
32
|
+
try {
|
|
33
|
+
const root = await lstat(deps.root);
|
|
34
|
+
if (!root.isDirectory() || root.isSymbolicLink() || root.uid !== deps.uid)
|
|
35
|
+
return [];
|
|
36
|
+
entries = await readdir(deps.root, { withFileTypes: true });
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
if (!entry.isDirectory())
|
|
43
|
+
continue;
|
|
44
|
+
try {
|
|
45
|
+
const receipt = await readState(join(deps.root, entry.name, "receipt.json"), receiptSchema, deps.uid);
|
|
46
|
+
if (receipt)
|
|
47
|
+
found.push(receipt);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// An unreadable or damaged receipt grants nothing; the others still count.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return found;
|
|
54
|
+
}
|
|
55
|
+
async function verify(projects, identity, r) {
|
|
56
|
+
const root = await lstat(projects, { bigint: true }).catch(() => undefined);
|
|
57
|
+
if (!root?.isDirectory() ||
|
|
58
|
+
root.isSymbolicLink() ||
|
|
59
|
+
root.uid !== BigInt(deps.uid) ||
|
|
60
|
+
String(root.dev) !== identity.dev ||
|
|
61
|
+
String(root.ino) !== identity.ino)
|
|
62
|
+
throw new HostFolderRefused(`The Slopify host helper did not open this folder because the project folder ${projects} was moved or replaced since the Docker launcher set it up. Put the original folder back, or ${again}.`);
|
|
63
|
+
let current = projects;
|
|
64
|
+
for (const part of r === "" ? [] : r.split(sep)) {
|
|
65
|
+
current = join(current, part);
|
|
66
|
+
const s = await lstat(current).catch(() => undefined);
|
|
67
|
+
if (!s?.isDirectory() || s.isSymbolicLink() || s.uid !== deps.uid)
|
|
68
|
+
throw new HostFolderRefused(`The Slopify host helper did not open ${current} because it is missing, a link or a file, or isn't owned by you. Use Re-run section on the project page to make the file again, or copy the path into your file manager.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return async (path) => {
|
|
72
|
+
if (!isAbsolute(path) || /[\p{Cc}]/u.test(path))
|
|
73
|
+
throw new HostFolderRefused(`The Slopify host helper did not open this folder because Slopify sent a path it can't use. Update Slopify: ${again}.`);
|
|
74
|
+
let refusal;
|
|
75
|
+
for (const receipt of await receipts()) {
|
|
76
|
+
const r = relative(receipt.projects, path);
|
|
77
|
+
if (r === ".." || r.startsWith(`..${sep}`) || isAbsolute(r))
|
|
78
|
+
continue;
|
|
79
|
+
try {
|
|
80
|
+
await verify(receipt.projects, receipt.directoryIdentity, r);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (!(error instanceof HostFolderRefused))
|
|
84
|
+
throw error;
|
|
85
|
+
refusal = error;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
// Open the normalised path that was checked, never the caller's spelling of it.
|
|
89
|
+
await deps.launch(join(receipt.projects, r));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
throw (refusal ??
|
|
93
|
+
new HostFolderRefused(`The Slopify host helper did not open ${path} because it isn't inside a project folder set up by the Docker launcher. Copy the path into your file manager, or if you moved your project folder, ${again}.`));
|
|
94
|
+
};
|
|
95
|
+
}
|
package/dist/host-cli/paths.js
CHANGED
|
@@ -4,12 +4,12 @@ import { chmod, lstat, mkdir, open } from "node:fs/promises";
|
|
|
4
4
|
import { isAbsolute, join, resolve } from "node:path";
|
|
5
5
|
export async function prepareHostPaths(root) {
|
|
6
6
|
if (!isAbsolute(root) || /[\p{Cc}]/u.test(root))
|
|
7
|
-
throw new Error(
|
|
7
|
+
throw new Error(`Unsafe host helper directory ${JSON.stringify(root)}: it must be a full path. Check XDG_DATA_HOME and HOME, then run the launcher again.`);
|
|
8
8
|
const normalized = resolve(root);
|
|
9
9
|
const share = join(normalized, "share");
|
|
10
10
|
const socket = join(share, "cli.sock");
|
|
11
11
|
if (Buffer.byteLength(socket) > 100)
|
|
12
|
-
throw new Error(
|
|
12
|
+
throw new Error(`The host helper path ${socket} is too long for this system (over 100 bytes). Set a shorter XDG_DATA_HOME, for example XDG_DATA_HOME=~/.local/share, and run the launcher again.`);
|
|
13
13
|
await mkdir(normalized, { recursive: true, mode: 0o700 });
|
|
14
14
|
await ownedDirectory(normalized, true);
|
|
15
15
|
await chmod(normalized, 0o700);
|
|
@@ -24,19 +24,19 @@ async function ownedDirectory(path, privateRoot = false) {
|
|
|
24
24
|
entry.isSymbolicLink() ||
|
|
25
25
|
(privateRoot && (entry.mode & 0o077) !== 0) ||
|
|
26
26
|
(process.getuid && entry.uid !== process.getuid()))
|
|
27
|
-
throw new Error(
|
|
27
|
+
throw new Error(`Unsafe host helper directory ${path}: it must be a real folder owned by you${privateRoot ? " that only you can read" : ""}. Fix its owner and permissions (chmod ${privateRoot ? "700" : "755"} ${path}) or delete it, then run the launcher again.`);
|
|
28
28
|
}
|
|
29
29
|
export async function readBridgeToken(path) {
|
|
30
30
|
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
31
31
|
try {
|
|
32
32
|
const entry = await file.stat();
|
|
33
33
|
if (!entry.isFile() || entry.size !== 64 || (entry.mode & 0o022) !== 0)
|
|
34
|
-
throw new Error(
|
|
34
|
+
throw new Error(`The host helper token file ${path} is damaged. Delete it and run the launcher again.`);
|
|
35
35
|
const buffer = Buffer.alloc(65);
|
|
36
36
|
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
37
37
|
const value = buffer.subarray(0, bytesRead).toString("utf8");
|
|
38
38
|
if (!/^[a-f0-9]{64}$/.test(value))
|
|
39
|
-
throw new Error(
|
|
39
|
+
throw new Error(`The host helper token file ${path} is damaged. Delete it and run the launcher again.`);
|
|
40
40
|
return value;
|
|
41
41
|
}
|
|
42
42
|
finally {
|
|
@@ -60,7 +60,7 @@ export async function ensureBridgeToken(path) {
|
|
|
60
60
|
}
|
|
61
61
|
const entry = await lstat(path);
|
|
62
62
|
if (entry.isSymbolicLink() || (process.getuid && entry.uid !== process.getuid()))
|
|
63
|
-
throw new Error(
|
|
63
|
+
throw new Error(`The host helper token file ${path} is a link or belongs to another user. Delete it and run the launcher again.`);
|
|
64
64
|
return readBridgeToken(path);
|
|
65
65
|
}
|
|
66
66
|
export function hasCode(error, code) {
|
package/dist/host-cli/runtime.js
CHANGED
|
@@ -9,6 +9,7 @@ import { nodeCodexModels } from "../adapters/llm/codex-models.js";
|
|
|
9
9
|
import { geminiLlm } from "../adapters/llm/gemini.js";
|
|
10
10
|
import { nodeGeminiModels } from "../adapters/llm/gemini-models.js";
|
|
11
11
|
import { providerError } from "../kernel/ports/model.js";
|
|
12
|
+
import { createHostFolderOpener } from "./open-folder.js";
|
|
12
13
|
import { createHostStatus } from "./status.js";
|
|
13
14
|
export function createHostRuntime(deps) {
|
|
14
15
|
const status = createHostStatus(deps);
|
|
@@ -32,6 +33,7 @@ export function createHostRuntime(deps) {
|
|
|
32
33
|
};
|
|
33
34
|
return {
|
|
34
35
|
status,
|
|
36
|
+
...(deps.folders === undefined ? {} : { openFolder: createHostFolderOpener(deps.folders) }),
|
|
35
37
|
llm: (id) => ({
|
|
36
38
|
id,
|
|
37
39
|
capabilities: { streams: true, reportsUsage: true, webSearch: true },
|
package/dist/host-cli/server.js
CHANGED
|
@@ -19,7 +19,7 @@ export async function startHostServer(options) {
|
|
|
19
19
|
if (!existing.isSocket() || (process.getuid && existing.uid !== process.getuid()))
|
|
20
20
|
throw new Error("Unsafe host helper socket.");
|
|
21
21
|
if (await listening(socket))
|
|
22
|
-
throw new Error(
|
|
22
|
+
throw new Error(`A Slopify host helper is already listening on ${socket}. Stop it first (systemctl --user stop slopify-cli-bridge.service).`);
|
|
23
23
|
await unlink(socket);
|
|
24
24
|
}
|
|
25
25
|
catch (error) {
|
package/dist/host-cli/service.js
CHANGED
|
@@ -39,11 +39,11 @@ export async function privateRead(path, uid) {
|
|
|
39
39
|
try {
|
|
40
40
|
const stat = await file.stat();
|
|
41
41
|
if (!stat.isFile() || stat.uid !== uid || stat.size > 64 * 1024 || (stat.mode & 0o022) !== 0)
|
|
42
|
-
throw new Error(
|
|
42
|
+
throw new Error(`${path} must be a regular file owned by you that others can't write to. Fix it (chmod 600 ${path}) or delete it, then run the launcher again.`);
|
|
43
43
|
const buffer = Buffer.alloc(64 * 1024 + 1);
|
|
44
44
|
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
45
45
|
if (bytesRead > 64 * 1024)
|
|
46
|
-
throw new Error(
|
|
46
|
+
throw new Error(`${path} is too large to be a Slopify file. Delete it and run the launcher again.`);
|
|
47
47
|
return buffer.subarray(0, bytesRead).toString("utf8");
|
|
48
48
|
}
|
|
49
49
|
finally {
|
|
@@ -106,35 +106,35 @@ async function waitHealth(options, accepting) {
|
|
|
106
106
|
return health;
|
|
107
107
|
await delay(100, undefined, { signal: options.signal });
|
|
108
108
|
}
|
|
109
|
-
throw new Error(
|
|
109
|
+
throw new Error(`The Slopify host helper did not start within 30 seconds. See why with: systemctl --user status ${hostUnit}`);
|
|
110
110
|
}
|
|
111
111
|
export async function ensureHostService(options) {
|
|
112
112
|
const { root, env, runner, signal, uid } = options;
|
|
113
113
|
const environment = JSON.stringify(hostEnvironment(env));
|
|
114
114
|
const directory = join(env.XDG_CONFIG_HOME ?? join(env.HOME ?? "", ".config"), "systemd/user");
|
|
115
115
|
if (!isAbsolute(directory))
|
|
116
|
-
throw new Error("
|
|
116
|
+
throw new Error("Could not find your systemd user folder because HOME (or XDG_CONFIG_HOME) is not a full path. Set it and run the launcher again.");
|
|
117
117
|
const unitPath = join(directory, hostUnit);
|
|
118
118
|
const configPath = join(root, "host-environment.json");
|
|
119
119
|
const previous = await privateRead(unitPath, uid);
|
|
120
120
|
const previousConfig = await privateRead(configPath, uid);
|
|
121
121
|
if (previous !== undefined && !previous.startsWith(`${marker}\n`))
|
|
122
|
-
throw new Error(
|
|
122
|
+
throw new Error(`A service file ${unitPath} already exists and is not owned by Slopify, so it was left alone. Rename or remove it, or start with --host-cli=off to use API keys only.`);
|
|
123
123
|
const unit = serviceUnit(options.node, options.entry, root);
|
|
124
124
|
const run = async (file, args, required = true, commandSignal = signal) => {
|
|
125
125
|
const result = await runner.exec(file, args, commandSignal);
|
|
126
126
|
if (required && result.code !== 0)
|
|
127
|
-
throw new Error(`Host helper setup failed
|
|
127
|
+
throw new Error(`Host helper setup failed at "${[file, ...args.slice(0, 2)].join(" ")}"${result.stderr?.trim() ? ` (${result.stderr.trim().split("\n").at(-1)?.slice(0, 300)})` : ""}. Check that systemd user services work (systemctl --user status), or start with --host-cli=off to use API keys only. Your existing container was not changed.`);
|
|
128
128
|
return result;
|
|
129
129
|
};
|
|
130
130
|
const fragment = (await run("systemctl", ["--user", "show", hostUnit, "--property=FragmentPath", "--value"])).stdout.trim();
|
|
131
131
|
if (fragment && fragment !== unitPath)
|
|
132
|
-
throw new Error(
|
|
132
|
+
throw new Error(`A ${hostUnit} service from somewhere else (${fragment}) is already installed and is not owned by this Slopify, so it was left alone. Remove it, or start with --host-cli=off to use API keys only.`);
|
|
133
133
|
const active = (await run("systemctl", ["--user", "is-active", "--quiet", hostUnit], false)).code === 0;
|
|
134
134
|
const wasEnabled = (await run("systemctl", ["--user", "is-enabled", "--quiet", hostUnit], false)).code === 0;
|
|
135
135
|
const current = await helperHealth(root, signal);
|
|
136
136
|
if (active && (!previous || !current))
|
|
137
|
-
throw new Error(
|
|
137
|
+
throw new Error(`The Slopify host helper is running but not answering, so it was not replaced. Restart it (systemctl --user restart ${hostUnit}) and run the launcher again; no work was interrupted.`);
|
|
138
138
|
const linger = (await run("loginctl", ["show-user", String(uid), "-p", "Linger", "--value"])).stdout.trim();
|
|
139
139
|
if (linger !== "yes")
|
|
140
140
|
await run("loginctl", ["enable-linger", String(uid)]);
|
|
@@ -152,12 +152,12 @@ export async function ensureHostService(options) {
|
|
|
152
152
|
paused = true;
|
|
153
153
|
await run("systemctl", ["--user", "kill", "--kill-whom=main", "--signal=SIGHUP", hostUnit]);
|
|
154
154
|
if ((await waitHealth(options, false)).active !== 0)
|
|
155
|
-
throw new Error("The host helper is doing work. Wait for
|
|
155
|
+
throw new Error("The host helper is doing work for Slopify right now. Wait for running projects to finish, then run the launcher again.");
|
|
156
156
|
}
|
|
157
157
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
158
158
|
const directoryStat = await lstat(directory);
|
|
159
159
|
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink() || directoryStat.uid !== uid)
|
|
160
|
-
throw new Error(
|
|
160
|
+
throw new Error(`${directory} is not a normal folder owned by your user, so Slopify can't add its service there. Fix its owner and run the launcher again.`);
|
|
161
161
|
replaced = true;
|
|
162
162
|
await privateWrite(configPath, environment);
|
|
163
163
|
await privateWrite(unitPath, unit);
|
|
@@ -193,7 +193,7 @@ export async function ensureHostService(options) {
|
|
|
193
193
|
await restore(["--user", "kill", "--kill-whom=main", "--signal=SIGUSR2", hostUnit]);
|
|
194
194
|
}
|
|
195
195
|
catch {
|
|
196
|
-
throw new Error(
|
|
196
|
+
throw new Error(`Host helper setup failed and Slopify could not fully undo it. Check the service with systemctl --user status ${hostUnit}; your Docker container was not changed.`);
|
|
197
197
|
}
|
|
198
198
|
throw error;
|
|
199
199
|
}
|
package/dist/host-cli/status.js
CHANGED
|
@@ -24,7 +24,9 @@ export async function resolveHostCommand(id, env) {
|
|
|
24
24
|
throw error;
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
-
throw Object.assign(new Error(
|
|
27
|
+
throw Object.assign(new Error(`${hostCommandName(id)} was not found on this machine's PATH.`), {
|
|
28
|
+
code: "ENOENT",
|
|
29
|
+
});
|
|
28
30
|
}
|
|
29
31
|
const authSchema = z.object({ loggedIn: z.boolean() });
|
|
30
32
|
export function parseHostLogin(id, stdout, stderr) {
|
|
@@ -72,7 +74,9 @@ export function createHostStatus(deps) {
|
|
|
72
74
|
installed: false,
|
|
73
75
|
login: "unknown",
|
|
74
76
|
issueKind: isCode(error, "ENOENT") ? "missing" : "bridge",
|
|
75
|
-
issue:
|
|
77
|
+
issue: isCode(error, "ENOENT")
|
|
78
|
+
? `${hostCommandName(id)} was not found on this machine. Install it and sign in there, then run the launcher again: npx @gentbajko/slopify --docker`
|
|
79
|
+
: `Slopify could not check for ${hostCommandName(id)} on this machine (${error instanceof Error ? error.message : String(error)}). Fix the problem, then run the launcher again: npx @gentbajko/slopify --docker`,
|
|
76
80
|
};
|
|
77
81
|
}
|
|
78
82
|
const readiness = readinessFromProbe(await deps.probe(command, ["--version"], cliProbeTimeoutMs), {
|
|
@@ -96,7 +100,7 @@ export function createHostStatus(deps) {
|
|
|
96
100
|
...base,
|
|
97
101
|
login: "unknown",
|
|
98
102
|
issueKind: "missing",
|
|
99
|
-
issue:
|
|
103
|
+
issue: `${command} is installed but did not answer "${hostCommandName(id)} --version". Reinstall or update it on this machine, then run the launcher again: npx @gentbajko/slopify --docker`,
|
|
100
104
|
};
|
|
101
105
|
if (readiness.issue)
|
|
102
106
|
return { ...base, login: "unknown", issueKind: "version", issue: readiness.issue };
|
|
@@ -7,19 +7,21 @@ export function configFrom(flags, env) {
|
|
|
7
7
|
const host = flags.host ?? env.SLOPIFY_HOST ?? defaultHost;
|
|
8
8
|
const dataDir = flags["data-dir"] ?? env.SLOPIFY_DATA_DIR ?? join(homedir(), ".slopify");
|
|
9
9
|
if (host.trim() === "") {
|
|
10
|
-
throw new Error(
|
|
10
|
+
throw new Error(`The host setting is empty (from ${flags.host !== undefined ? "--host" : "SLOPIFY_HOST"}). Leave it out to use 127.0.0.1, or give an address such as --host 127.0.0.1.`);
|
|
11
11
|
}
|
|
12
12
|
return {
|
|
13
|
-
port: port === undefined
|
|
13
|
+
port: port === undefined
|
|
14
|
+
? defaultPort
|
|
15
|
+
: parsePort(port, flags.port !== undefined ? "--port" : "SLOPIFY_PORT"),
|
|
14
16
|
host,
|
|
15
17
|
dataDir: resolve(dataDir),
|
|
16
18
|
open: flags["no-open"] === true ? false : !isTruthy(env.SLOPIFY_NO_OPEN),
|
|
17
19
|
};
|
|
18
20
|
}
|
|
19
|
-
function parsePort(value) {
|
|
21
|
+
function parsePort(value, source) {
|
|
20
22
|
const port = Number(value.trim());
|
|
21
23
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
22
|
-
throw new Error(`
|
|
24
|
+
throw new Error(`Invalid port ${JSON.stringify(value)} (from ${source}): use a whole number between 1 and 65535, for example --port 7070.`);
|
|
23
25
|
}
|
|
24
26
|
return port;
|
|
25
27
|
}
|
|
@@ -12,16 +12,32 @@ export function migrate(db, clock) {
|
|
|
12
12
|
const newest = Math.max(0, ...applied);
|
|
13
13
|
const newestKnown = Math.max(0, ...known);
|
|
14
14
|
if (newest > newestKnown) {
|
|
15
|
-
throw new Error(`database schema ${newest} is newer than this app knows (${newestKnown})`
|
|
15
|
+
throw new Error(`Your Slopify database was last used by a newer version of Slopify (database schema ${newest} is newer than this app knows (${newestKnown})). ` +
|
|
16
|
+
"Update Slopify and start it again: npx @gentbajko/slopify@latest (or pull the latest Docker image). Your data was not changed.");
|
|
16
17
|
}
|
|
17
18
|
for (const [index, file] of files.entries()) {
|
|
18
19
|
const version = known[index];
|
|
19
20
|
if (version === undefined || applied.has(version)) {
|
|
20
21
|
continue;
|
|
21
22
|
}
|
|
23
|
+
const sql = readFileSync(new URL(file, migrationsDir), "utf8");
|
|
24
|
+
// A migration that rebuilds a table other tables reference must run with enforcement
|
|
25
|
+
// off: dropping the old table would otherwise cascade into every row that points at it.
|
|
26
|
+
// SQLite ignores this pragma inside a transaction, so it is set around one, and every
|
|
27
|
+
// reference is checked before the rebuild commits.
|
|
28
|
+
const rebuild = sql.split("\n", 1)[0]?.trim() === foreignKeysOff;
|
|
29
|
+
const enforced = rebuild && foreignKeysOn(db);
|
|
30
|
+
if (enforced)
|
|
31
|
+
db.exec("PRAGMA foreign_keys = OFF");
|
|
22
32
|
db.exec("BEGIN");
|
|
23
33
|
try {
|
|
24
|
-
db.exec(
|
|
34
|
+
db.exec(sql);
|
|
35
|
+
if (rebuild) {
|
|
36
|
+
const broken = db.prepare("PRAGMA foreign_key_check").all();
|
|
37
|
+
if (broken.length > 0) {
|
|
38
|
+
throw new Error(`Slopify couldn't upgrade its database: migration ${file} would leave ${broken.length} broken links between saved records, so it was undone and your data was not changed. Go back to the previous version of Slopify (npx @gentbajko/slopify@<previous version>) and report this on GitHub with the Download diagnostics file from Settings.`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
25
41
|
db.prepare("INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)").run(version, clock.now().toISOString());
|
|
26
42
|
db.exec("COMMIT");
|
|
27
43
|
}
|
|
@@ -29,8 +45,17 @@ export function migrate(db, clock) {
|
|
|
29
45
|
db.exec("ROLLBACK");
|
|
30
46
|
throw error;
|
|
31
47
|
}
|
|
48
|
+
finally {
|
|
49
|
+
if (enforced)
|
|
50
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
51
|
+
}
|
|
32
52
|
}
|
|
33
53
|
}
|
|
54
|
+
// The first line of such a migration, compared without its line ending.
|
|
55
|
+
const foreignKeysOff = "-- foreign-keys: off";
|
|
56
|
+
function foreignKeysOn(db) {
|
|
57
|
+
return db.prepare("PRAGMA foreign_keys").get()?.foreign_keys === 1;
|
|
58
|
+
}
|
|
34
59
|
function versionOf(file) {
|
|
35
60
|
const version = Number(file.slice(0, 4));
|
|
36
61
|
if (!Number.isInteger(version) || version < 1) {
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
-- A schedule's items became a queue of topics: each run fills one template keyword with the
|
|
2
|
+
-- first topic and the rest of the keywords with fixed values.
|
|
3
|
+
ALTER TABLE schedules ADD COLUMN topic_keyword TEXT;
|
|
4
|
+
ALTER TABLE schedules ADD COLUMN values_json TEXT NOT NULL DEFAULT '{}' CHECK(json_valid(values_json));
|