@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
package/dist/catalog/registry.js
CHANGED
|
@@ -7,7 +7,7 @@ export function curateRegistry(registry, catalogue) {
|
|
|
7
7
|
if (!model)
|
|
8
8
|
throw providerError({
|
|
9
9
|
kind: "unsupported",
|
|
10
|
-
message:
|
|
10
|
+
message: `The chosen ${provider} model is no longer in Slopify's model list. In the Edit tab, choose Edit project and change it under Providers, then use Resume.`,
|
|
11
11
|
});
|
|
12
12
|
return model;
|
|
13
13
|
};
|
|
@@ -21,7 +21,10 @@ export function curateRegistry(registry, catalogue) {
|
|
|
21
21
|
models: () => port.models(),
|
|
22
22
|
complete: async function* (request) {
|
|
23
23
|
if (request.model.trim() === "")
|
|
24
|
-
throw providerError({
|
|
24
|
+
throw providerError({
|
|
25
|
+
kind: "unsupported",
|
|
26
|
+
message: "No model is chosen for this step. In the Edit tab, choose Edit project and change it under Providers, then use Resume.",
|
|
27
|
+
});
|
|
25
28
|
let models;
|
|
26
29
|
try {
|
|
27
30
|
models = await port.models();
|
|
@@ -34,19 +37,19 @@ export function curateRegistry(registry, catalogue) {
|
|
|
34
37
|
if (models !== undefined && selected === undefined)
|
|
35
38
|
throw providerError({
|
|
36
39
|
kind: "unsupported",
|
|
37
|
-
message: "
|
|
40
|
+
message: "The chosen model is not available in the AI command-line tool on your computer. Update the tool, or pick another model: in the Edit tab, choose Edit project and change it under Providers, then use Resume.",
|
|
38
41
|
});
|
|
39
42
|
if (request.thinking !== undefined &&
|
|
40
43
|
selected !== undefined &&
|
|
41
44
|
!selected.thinkingModes?.includes(request.thinking))
|
|
42
45
|
throw providerError({
|
|
43
46
|
kind: "unsupported",
|
|
44
|
-
message: "
|
|
47
|
+
message: "The chosen model does not support this thinking setting. In the Edit tab, choose Edit project and change it under Providers, then use Resume.",
|
|
45
48
|
});
|
|
46
49
|
if (request.webSearch && !port.capabilities.webSearch)
|
|
47
50
|
throw providerError({
|
|
48
51
|
kind: "unsupported",
|
|
49
|
-
message: "This
|
|
52
|
+
message: "This command-line tool cannot search the web for research. Choose another text provider or turn Research off in the Edit tab under Edit project, then use Resume.",
|
|
50
53
|
});
|
|
51
54
|
const thinkingConfig = id === "codex" && request.thinking !== undefined
|
|
52
55
|
? { effort: request.thinking === "off" ? "none" : request.thinking }
|
|
@@ -75,13 +78,13 @@ export function curateRegistry(registry, catalogue) {
|
|
|
75
78
|
if (request.webSearch && !model.llm.webSearch)
|
|
76
79
|
throw providerError({
|
|
77
80
|
kind: "unsupported",
|
|
78
|
-
message: "
|
|
81
|
+
message: "The chosen model cannot search the web for research. Choose another model or turn Research off in the Edit tab under Edit project, then use Resume.",
|
|
79
82
|
});
|
|
80
83
|
const thinkingConfig = request.thinking === undefined ? undefined : model.llm.thinking?.[request.thinking];
|
|
81
84
|
if (request.thinking !== undefined && !thinkingConfig)
|
|
82
85
|
throw providerError({
|
|
83
86
|
kind: "unsupported",
|
|
84
|
-
message: "
|
|
87
|
+
message: "The chosen model does not support this thinking setting. In the Edit tab, choose Edit project and change it under Providers, then use Resume.",
|
|
85
88
|
});
|
|
86
89
|
yield* port.complete({
|
|
87
90
|
...request,
|
|
@@ -100,7 +103,7 @@ export function curateRegistry(registry, catalogue) {
|
|
|
100
103
|
if (!(await port.models()).some((model) => model.id === request.model))
|
|
101
104
|
throw providerError({
|
|
102
105
|
kind: "unsupported",
|
|
103
|
-
message: "
|
|
106
|
+
message: "Image generation is not available in the Codex CLI on your computer. Update Codex CLI, or choose another image provider in the Edit tab under Edit project, then use Resume.",
|
|
104
107
|
});
|
|
105
108
|
return port.generate(request);
|
|
106
109
|
},
|
|
@@ -114,7 +117,7 @@ export function curateRegistry(registry, catalogue) {
|
|
|
114
117
|
if (!("image" in model) || !model.image.aspectRatios.includes(request.aspect))
|
|
115
118
|
throw providerError({
|
|
116
119
|
kind: "unsupported",
|
|
117
|
-
message: "
|
|
120
|
+
message: "The chosen image model cannot make images in this video's shape. In the Edit tab, choose Edit project and change it under Providers, then use Resume.",
|
|
118
121
|
});
|
|
119
122
|
return port.generate(request);
|
|
120
123
|
},
|
|
@@ -135,7 +138,7 @@ export function curateRegistry(registry, catalogue) {
|
|
|
135
138
|
if (request.text.length > model.tts.maxCharacters)
|
|
136
139
|
throw providerError({
|
|
137
140
|
kind: "unsupported",
|
|
138
|
-
message:
|
|
141
|
+
message: `A piece of narration is longer than this voice model's ${model.tts.maxCharacters}-character limit. Use Re-run section on Audio so Slopify splits it into shorter pieces.`,
|
|
139
142
|
});
|
|
140
143
|
return port.synthesize({ ...request, model: model.id });
|
|
141
144
|
},
|
package/dist/catalog/store.js
CHANGED
|
@@ -40,7 +40,7 @@ export function createCatalogueStore(deps) {
|
|
|
40
40
|
}
|
|
41
41
|
catch {
|
|
42
42
|
warning =
|
|
43
|
-
"Your models.yaml is
|
|
43
|
+
"Your models.yaml file is missing or has a mistake, so Slopify is still using the last working model list. Fix the file, or use Refresh published catalogue in Settings → Models to replace it.";
|
|
44
44
|
}
|
|
45
45
|
return current;
|
|
46
46
|
}
|
package/dist/catalog/validate.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { usesNarrationPreparation } from "../slices/admission/rules.js";
|
|
1
|
+
import { usesNarrationPreparation, usesYoutubeDescription, } from "../slices/admission/rules.js";
|
|
2
2
|
import { isLocalCliProvider } from "../slices/settings/model.js";
|
|
3
3
|
export function modelFields(draft, catalogue) {
|
|
4
4
|
if (!catalogue)
|
|
@@ -9,7 +9,8 @@ export function modelFields(draft, catalogue) {
|
|
|
9
9
|
draft.sources.thumbnail === "prompt_by_llm" ||
|
|
10
10
|
draft.intro?.mode === "llm" ||
|
|
11
11
|
draft.outro?.mode === "llm" ||
|
|
12
|
-
usesNarrationPreparation(draft)
|
|
12
|
+
usesNarrationPreparation(draft) ||
|
|
13
|
+
usesYoutubeDescription(draft);
|
|
13
14
|
const checks = [
|
|
14
15
|
{ field: "llm", family: "llm", choice: draft.llm, needed: needLlm },
|
|
15
16
|
{
|
|
@@ -31,16 +32,25 @@ export function modelFields(draft, catalogue) {
|
|
|
31
32
|
continue;
|
|
32
33
|
const model = catalogue.models(choice.provider, family).find((m) => m.id === choice.model);
|
|
33
34
|
if (!model)
|
|
34
|
-
fields.push({
|
|
35
|
+
fields.push({
|
|
36
|
+
field,
|
|
37
|
+
message: "This model is no longer in Slopify's model list. Choose another model.",
|
|
38
|
+
});
|
|
35
39
|
else if ("llm" in model && choice.thinking && !model.llm.thinking?.[choice.thinking])
|
|
36
|
-
fields.push({
|
|
40
|
+
fields.push({
|
|
41
|
+
field,
|
|
42
|
+
message: "This model does not support that thinking setting. Choose another.",
|
|
43
|
+
});
|
|
37
44
|
else if ("llm" in model && draft.sources.research === "generate" && !model.llm.webSearch)
|
|
38
45
|
fields.push({
|
|
39
46
|
field,
|
|
40
|
-
message: "This model cannot
|
|
47
|
+
message: "This model cannot search the web for research. Choose another model or turn Research off.",
|
|
41
48
|
});
|
|
42
49
|
else if ("image" in model && !model.image.aspectRatios.includes(draft.format))
|
|
43
|
-
fields.push({
|
|
50
|
+
fields.push({
|
|
51
|
+
field,
|
|
52
|
+
message: "This image model cannot make images in this video's shape. Choose another model.",
|
|
53
|
+
});
|
|
44
54
|
}
|
|
45
55
|
return fields;
|
|
46
56
|
}
|
package/dist/edge/cli.js
CHANGED
|
@@ -23,17 +23,18 @@ const { values } = parseArgs({
|
|
|
23
23
|
"accept-host-cli": { type: "boolean" },
|
|
24
24
|
},
|
|
25
25
|
});
|
|
26
|
+
let config;
|
|
26
27
|
try {
|
|
27
28
|
if (values["projects-dir"] !== undefined && !values.docker)
|
|
28
|
-
throw new Error("--projects-dir
|
|
29
|
+
throw new Error("--projects-dir only works together with --docker. Without Docker, choose where Slopify keeps its files with --data-dir <folder>.");
|
|
29
30
|
if ((values["host-cli"] !== undefined || values["accept-host-cli"] !== undefined) &&
|
|
30
31
|
!values.docker)
|
|
31
|
-
throw new Error("
|
|
32
|
+
throw new Error("--host-cli and --accept-host-cli only work together with --docker. Add --docker, or remove those options.");
|
|
32
33
|
if (values["host-cli"] !== undefined && values["host-cli"] !== "off")
|
|
33
|
-
throw new Error("The --host-cli
|
|
34
|
+
throw new Error(`--host-cli=${values["host-cli"]} is not supported. The only value is --host-cli=off, which starts Docker using API keys only; leave the option out to use the AI CLIs installed on this machine.`);
|
|
34
35
|
if (values.docker) {
|
|
35
36
|
if (values.host !== undefined || values["data-dir"] !== undefined)
|
|
36
|
-
throw new Error("Docker keeps
|
|
37
|
+
throw new Error("--host and --data-dir can't be used with --docker: the Docker version always listens on localhost and keeps its data in a Docker volume. Use --projects-dir <folder> for project files and --port <number> for the port.");
|
|
37
38
|
const docker = await import("./docker.js");
|
|
38
39
|
docker.assertManagedDockerHost(process.platform, process.getuid?.(), process.getgid?.());
|
|
39
40
|
const { prepareDockerHostCli } = docker;
|
|
@@ -73,10 +74,10 @@ try {
|
|
|
73
74
|
},
|
|
74
75
|
});
|
|
75
76
|
if (result.error)
|
|
76
|
-
throw result.error;
|
|
77
|
+
throw new Error(`Could not start the Docker launcher script (${result.error.message}). Make sure bash is installed and on your PATH, then try again.`);
|
|
77
78
|
process.exit(result.status ?? 1);
|
|
78
79
|
}
|
|
79
|
-
|
|
80
|
+
config = configFrom(values, process.env);
|
|
80
81
|
const forwarded = await forwardManagedUpdate(config.dataDir, readVersion(), process.argv.slice(2));
|
|
81
82
|
if (forwarded !== undefined)
|
|
82
83
|
process.exit(forwarded);
|
|
@@ -103,6 +104,31 @@ try {
|
|
|
103
104
|
});
|
|
104
105
|
}
|
|
105
106
|
catch (error) {
|
|
106
|
-
console.error(error
|
|
107
|
+
console.error(explainStartupError(error, config));
|
|
107
108
|
process.exit(1);
|
|
108
109
|
}
|
|
110
|
+
// Node's own wording for a failed listen or file operation ("listen EADDRINUSE: address already
|
|
111
|
+
// in use") names the symptom but not the fix, so the common startup failures are restated here.
|
|
112
|
+
function explainStartupError(error, config) {
|
|
113
|
+
if (!(error instanceof Error))
|
|
114
|
+
return String(error);
|
|
115
|
+
const code = "code" in error && typeof error.code === "string" ? error.code : undefined;
|
|
116
|
+
const syscall = "syscall" in error && typeof error.syscall === "string" ? error.syscall : "";
|
|
117
|
+
if (config !== undefined && syscall === "listen") {
|
|
118
|
+
const other = config.port === 7070 ? 7071 : 7070;
|
|
119
|
+
if (code === "EADDRINUSE")
|
|
120
|
+
return `Port ${config.port} is already in use by another program (maybe another Slopify). Stop that program, or start Slopify on another port: npx @gentbajko/slopify --port ${other}`;
|
|
121
|
+
if (code === "EACCES" || code === "EPERM")
|
|
122
|
+
return `Slopify is not allowed to use port ${config.port}${config.port < 1024 ? " (ports below 1024 need administrator rights)" : ""}. Start it on another port: npx @gentbajko/slopify --port ${other}`;
|
|
123
|
+
if (code === "EADDRNOTAVAIL" || code === "ENOTFOUND" || code === "EAI_AGAIN")
|
|
124
|
+
return `Slopify can't listen on ${config.host} because that address doesn't belong to this machine. Leave out --host (and SLOPIFY_HOST) to use 127.0.0.1, or use an address this machine owns.`;
|
|
125
|
+
}
|
|
126
|
+
const path = "path" in error && typeof error.path === "string" ? error.path : undefined;
|
|
127
|
+
if (config !== undefined &&
|
|
128
|
+
path?.startsWith(config.dataDir) &&
|
|
129
|
+
(code === "EACCES" || code === "EPERM" || code === "EROFS"))
|
|
130
|
+
return `Slopify can't write to its data folder ${config.dataDir} (${error.message}). Make sure your user owns that folder and can write to it, or choose another one: npx @gentbajko/slopify --data-dir <folder>`;
|
|
131
|
+
if (code === "ENOSPC")
|
|
132
|
+
return `The disk is full (${error.message}). Free up some space and start Slopify again.`;
|
|
133
|
+
return error.message;
|
|
134
|
+
}
|
|
@@ -15,7 +15,7 @@ async function main() {
|
|
|
15
15
|
const gid = process.getgid?.();
|
|
16
16
|
assertManagedDockerHost(process.platform, uid, gid);
|
|
17
17
|
if (gid === undefined)
|
|
18
|
-
throw new Error("
|
|
18
|
+
throw new Error("Could not read your user's group. Run the --docker launcher as your normal logged-in user.");
|
|
19
19
|
const c = dockerConfig(process.env, homedir(), process.cwd(), uid, gid);
|
|
20
20
|
await privateDirectory(c.root, uid);
|
|
21
21
|
if (process.argv[2] !== "--locked") {
|
|
@@ -24,7 +24,7 @@ async function main() {
|
|
|
24
24
|
try {
|
|
25
25
|
const s = await fd.stat();
|
|
26
26
|
if (!s.isFile() || s.nlink !== 1 || s.uid !== uid || (s.mode & 0o077) !== 0)
|
|
27
|
-
throw new Error(
|
|
27
|
+
throw new Error(`The launcher's lock file ${path} has the wrong owner or permissions. Delete it and run the launcher again.`);
|
|
28
28
|
const child = spawn("flock", [
|
|
29
29
|
"--nonblock",
|
|
30
30
|
"--no-fork",
|
|
@@ -36,13 +36,13 @@ async function main() {
|
|
|
36
36
|
"--locked",
|
|
37
37
|
], { stdio: "inherit" });
|
|
38
38
|
const code = await new Promise((resolve, reject) => {
|
|
39
|
-
child.once("error", () => reject(new Error("Install util-linux (
|
|
39
|
+
child.once("error", () => reject(new Error("The --docker launcher needs the flock command, which isn't installed. Install util-linux (for example sudo apt install util-linux, or sudo pacman -S util-linux) and try again.")));
|
|
40
40
|
child.once("exit", (status, signal) => signal
|
|
41
|
-
? reject(new Error("Docker setup was interrupted;
|
|
41
|
+
? reject(new Error("Docker setup was interrupted before it finished. Run the same command again; Slopify will finish or undo the half-done step."))
|
|
42
42
|
: resolve(status ?? 1));
|
|
43
43
|
});
|
|
44
44
|
if (code === 73)
|
|
45
|
-
throw new Error("Another
|
|
45
|
+
throw new Error("Another Slopify --docker launcher is already running and changing your installation. Wait for it to finish, then run the command again.");
|
|
46
46
|
process.exitCode = code;
|
|
47
47
|
return;
|
|
48
48
|
}
|
|
@@ -51,7 +51,7 @@ async function main() {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
const controller = new AbortController();
|
|
54
|
-
const abort = () => controller.abort(new Error("Docker setup
|
|
54
|
+
const abort = () => controller.abort(new Error("Docker setup was stopped before it finished. Run the same command again; Slopify will finish or undo the half-done step."));
|
|
55
55
|
process.once("SIGINT", abort);
|
|
56
56
|
process.once("SIGTERM", abort);
|
|
57
57
|
try {
|
|
@@ -4,12 +4,27 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { containerSchema, digestSchema, } from "./state.js";
|
|
6
6
|
import { contains } from "./tree.js";
|
|
7
|
+
// Docker's exit code alone says nothing, so its last error line decides the advice: the daemon
|
|
8
|
+
// being stopped and the socket being off-limits are by far the most common first-run failures.
|
|
9
|
+
export function dockerFailure(args, result) {
|
|
10
|
+
const detail = (result.stderr ?? "").trim();
|
|
11
|
+
if (/permission denied[^\n]*docker\.sock|docker\.sock[^\n]*permission denied/i.test(detail))
|
|
12
|
+
return "Your user isn't allowed to use Docker. Give it access with sudo usermod -aG docker $USER, then log out and back in (or run newgrp docker) and try again.";
|
|
13
|
+
if (/cannot connect to the docker daemon|is the docker daemon running|error during connect/i.test(detail))
|
|
14
|
+
return "Docker is installed but not running. Start it (sudo systemctl start docker, or systemctl --user start docker for rootless Docker) and try again.";
|
|
15
|
+
if (/port is already allocated|address already in use/i.test(detail))
|
|
16
|
+
return "The port Slopify wants is already in use by another program. Stop that program, or start Slopify on another port: npx @gentbajko/slopify --docker --port 7070";
|
|
17
|
+
if (/no space left on device/i.test(detail))
|
|
18
|
+
return "The disk Docker uses is full. Free up space (for example docker system prune) and try again.";
|
|
19
|
+
const last = detail.split("\n").at(-1)?.trim().slice(0, 300);
|
|
20
|
+
return `Docker command "docker ${args[0] ?? ""}" failed (exit code ${result.code}${last ? `: ${last}` : ""}). Check that Docker works (docker info) and run the launcher again. Nothing was deleted, and any backup Slopify made is kept.`;
|
|
21
|
+
}
|
|
7
22
|
export function dockerEngine(runner, signal, env) {
|
|
8
23
|
const run = (args, localSignal = signal) => runner.exec("docker", args, localSignal);
|
|
9
24
|
async function command(args, localSignal = signal) {
|
|
10
25
|
const r = await run(args, localSignal);
|
|
11
26
|
if (r.code !== 0)
|
|
12
|
-
throw new Error(
|
|
27
|
+
throw new Error(dockerFailure(args, r));
|
|
13
28
|
return r.stdout.trim();
|
|
14
29
|
}
|
|
15
30
|
const mount = (type, source, target, readonly = false) => [
|
|
@@ -48,14 +63,14 @@ export function dockerEngine(runner, signal, env) {
|
|
|
48
63
|
"{{.ID}}",
|
|
49
64
|
]);
|
|
50
65
|
if (present.code !== 0)
|
|
51
|
-
throw new Error("
|
|
66
|
+
throw new Error(dockerFailure(["container", "ls"], present));
|
|
52
67
|
if (!present.stdout.trim() && !/^[a-f0-9]{12,64}$/.test(name))
|
|
53
68
|
return null;
|
|
54
69
|
const result = await run(["inspect", name]);
|
|
55
70
|
if (result.code !== 0) {
|
|
56
71
|
if (!present.stdout.trim())
|
|
57
72
|
return null;
|
|
58
|
-
throw new Error("
|
|
73
|
+
throw new Error(dockerFailure(["inspect", name], result));
|
|
59
74
|
}
|
|
60
75
|
const schema = z.array(z.object({
|
|
61
76
|
Id: z.string(),
|
|
@@ -105,11 +120,11 @@ export function dockerEngine(runner, signal, env) {
|
|
|
105
120
|
for (const id of ids) {
|
|
106
121
|
const c = await inspect(id);
|
|
107
122
|
if (!c)
|
|
108
|
-
throw new Error("Cannot verify volume claims
|
|
123
|
+
throw new Error("Cannot verify volume claims: a Docker container disappeared while Slopify was checking. Run the launcher again.");
|
|
109
124
|
if (!permitted.includes(c.id) &&
|
|
110
125
|
!spared(c) &&
|
|
111
126
|
c.mounts.some((m) => m.type === "volume" && m.name === volume))
|
|
112
|
-
throw new Error(`
|
|
127
|
+
throw new Error(`The Docker volume ${volume} that holds Slopify's data is also used by the container ${c.name}. Remove that container if you no longer need it (docker rm ${c.name}), or give this Slopify its own volume with SLOPIFY_DOCKER_VOLUME=<name>, then try again.`);
|
|
113
128
|
}
|
|
114
129
|
}
|
|
115
130
|
async function writers(volume, paths, permitted) {
|
|
@@ -122,7 +137,7 @@ export function dockerEngine(runner, signal, env) {
|
|
|
122
137
|
(m.name === volume ||
|
|
123
138
|
(m.type === "bind" &&
|
|
124
139
|
paths.some((p) => contains(p, m.source) || contains(m.source, p))))))
|
|
125
|
-
throw new Error(`Another running container
|
|
140
|
+
throw new Error(`Another running container (${c.name}) is writing to Slopify's data or project folder. Stop it (docker stop ${c.name}) and try again.`);
|
|
126
141
|
}
|
|
127
142
|
}
|
|
128
143
|
async function recoveryLabel(j) {
|
|
@@ -154,7 +169,13 @@ export function dockerEngine(runner, signal, env) {
|
|
|
154
169
|
.parse(raw)[0]?.Endpoints.docker.Host;
|
|
155
170
|
}
|
|
156
171
|
if (!endpoint?.startsWith("unix://"))
|
|
157
|
-
throw new Error(
|
|
172
|
+
throw new Error(`The --docker launcher only works with Docker running on this machine, not a remote Docker (${endpoint ?? "unknown endpoint"}). Switch to the local one (unset DOCKER_HOST, or docker context use default) and try again.`);
|
|
173
|
+
const rawInfo = JSON.parse(await command(["info", "--format", "{{json .}}"]));
|
|
174
|
+
// Some Docker versions print the daemon's absence inside the JSON instead of failing.
|
|
175
|
+
const serverErrors = z.object({ ServerErrors: z.array(z.string()) }).safeParse(rawInfo)
|
|
176
|
+
.data?.ServerErrors;
|
|
177
|
+
if (serverErrors !== undefined && serverErrors.length > 0)
|
|
178
|
+
throw new Error(dockerFailure(["info"], { code: 1, stderr: serverErrors.join("\n") }));
|
|
158
179
|
const info = z
|
|
159
180
|
.object({
|
|
160
181
|
ID: z.string(),
|
|
@@ -162,12 +183,12 @@ export function dockerEngine(runner, signal, env) {
|
|
|
162
183
|
OperatingSystem: z.string(),
|
|
163
184
|
SecurityOptions: z.array(z.string()),
|
|
164
185
|
})
|
|
165
|
-
.parse(
|
|
186
|
+
.parse(rawInfo);
|
|
166
187
|
if (/docker desktop/i.test(info.OperatingSystem))
|
|
167
|
-
throw new Error("Docker Desktop
|
|
188
|
+
throw new Error("Docker Desktop isn't supported by the --docker launcher because it can't give your project folder the right owner. Use Docker Engine on Linux (docker context use default), or run Slopify without Docker: npx @gentbajko/slopify");
|
|
168
189
|
const rootless = info.SecurityOptions.includes("name=rootless");
|
|
169
190
|
if (!rootless && info.SecurityOptions.some((s) => s.startsWith("name=userns")))
|
|
170
|
-
throw new Error("Docker userns-remap
|
|
191
|
+
throw new Error("Your Docker uses userns-remap, which the --docker launcher doesn't support. Use rootless Docker or a standard Docker Engine setup, or run Slopify without Docker: npx @gentbajko/slopify. Don't turn off Docker's isolation to get around this.");
|
|
171
192
|
return { daemon: info.ID, user: rootless ? "0:0" : `${uid}:${gid}` };
|
|
172
193
|
},
|
|
173
194
|
image: async (ref) => {
|
|
@@ -214,7 +235,7 @@ export function dockerEngine(runner, signal, env) {
|
|
|
214
235
|
})
|
|
215
236
|
.parse(JSON.parse(await command(["volume", "inspect", "--format", "{{json .}}", name])));
|
|
216
237
|
if (v.Name !== name || (v.Options && Object.keys(v.Options).length > 0))
|
|
217
|
-
throw new Error(
|
|
238
|
+
throw new Error(`The Docker volume ${name} uses a custom driver or options, which Slopify doesn't support. Give Slopify a plain local volume with SLOPIFY_DOCKER_VOLUME=<new name> and try again.`);
|
|
218
239
|
return JSON.stringify([v.Name, v.CreatedAt, v.Driver]);
|
|
219
240
|
},
|
|
220
241
|
probe: async (c, image, user, destination) => {
|
|
@@ -228,7 +249,7 @@ export function dockerEngine(runner, signal, env) {
|
|
|
228
249
|
s.gid !== c.gid ||
|
|
229
250
|
(s.mode & 0o077) !== 0 ||
|
|
230
251
|
(await readFile(join(probe, "container"), "utf8")) !== "slopify ownership probe")
|
|
231
|
-
throw new Error("Docker
|
|
252
|
+
throw new Error("Docker can't create files in your project folder as your user, so the files would end up with the wrong owner. Use a standard Docker Engine or rootless Docker setup, or run Slopify without Docker: npx @gentbajko/slopify");
|
|
232
253
|
await writeFile(join(probe, "container"), "host can write", { flag: "a" });
|
|
233
254
|
}
|
|
234
255
|
finally {
|
|
@@ -378,7 +399,7 @@ export function dockerEngine(runner, signal, env) {
|
|
|
378
399
|
}
|
|
379
400
|
await delay(Math.min(250, Math.max(0, deadline - Date.now())), undefined, { signal });
|
|
380
401
|
}
|
|
381
|
-
throw new Error(
|
|
402
|
+
throw new Error(`The new Slopify container did not start up within 2 minutes. See why with: docker logs ${id}`);
|
|
382
403
|
},
|
|
383
404
|
};
|
|
384
405
|
}
|
|
@@ -17,10 +17,10 @@ export async function installProjects(c, e, recovery) {
|
|
|
17
17
|
const currentVolume = await e.volume(c.volume);
|
|
18
18
|
if ((receipt && receipt.volumeIdentity !== currentVolume) ||
|
|
19
19
|
(previousJournal?.volumeIdentity && previousJournal.volumeIdentity !== currentVolume))
|
|
20
|
-
throw new Error(
|
|
20
|
+
throw new Error(`Remembered data volume ${c.volume} is missing or was replaced since Slopify was installed. Restore the original volume (see docker volume ls) and run the launcher again. Nothing was changed.`);
|
|
21
21
|
if ((receipt && receipt.daemon !== context.daemon) ||
|
|
22
22
|
(previousJournal && previousJournal.daemon !== context.daemon))
|
|
23
|
-
throw new Error("Docker
|
|
23
|
+
throw new Error("This is not the Docker that Slopify was installed with (a different Docker context or DOCKER_HOST is active). Switch back to the original one (docker context use <name>, or unset DOCKER_HOST) and try again.");
|
|
24
24
|
await assertVolumeClaims(c, e, context.daemon, currentVolume, await e.inspect(c.name));
|
|
25
25
|
if (previousJournal && previousJournal.phase !== "rolled-back")
|
|
26
26
|
previousJournal = await recoverInstallation(c, previousJournal, receipt, recovery());
|
|
@@ -42,7 +42,7 @@ export async function installProjects(c, e, recovery) {
|
|
|
42
42
|
continue;
|
|
43
43
|
const other = await readState(join(c.root, name, "receipt.json"), receiptSchema, c.uid);
|
|
44
44
|
if (other?.daemon === context.daemon && other.volume === c.volume)
|
|
45
|
-
throw new Error(`
|
|
45
|
+
throw new Error(`The Docker volume ${c.volume} already belongs to another Slopify installation (${other.name}). Give this one its own volume with SLOPIFY_DOCKER_VOLUME=<name> and try again.`);
|
|
46
46
|
}
|
|
47
47
|
let reuse = null;
|
|
48
48
|
if (previousJournal?.phase === "rolled-back" &&
|
|
@@ -66,7 +66,7 @@ export async function installProjects(c, e, recovery) {
|
|
|
66
66
|
const image = await e.image(c.image);
|
|
67
67
|
const expectedVersion = await e.version(image);
|
|
68
68
|
if (expectedVersion !== readVersion())
|
|
69
|
-
throw new Error(
|
|
69
|
+
throw new Error(`The Docker image is Slopify ${expectedVersion} but this launcher is ${readVersion()}. Run the latest of both (npx @gentbajko/slopify@latest --docker), or if you set SLOPIFY_DOCKER_IMAGE, point it at version ${readVersion()}. Your existing container was not changed.`);
|
|
70
70
|
const signature = createHash("sha256")
|
|
71
71
|
.update(JSON.stringify([image, c.volume, c.port, c.bridge, projects, context.user]))
|
|
72
72
|
.digest("hex");
|
|
@@ -228,19 +228,19 @@ export async function installProjects(c, e, recovery) {
|
|
|
228
228
|
await save({ phase: "committed" });
|
|
229
229
|
const ready = await e.inspect(candidate);
|
|
230
230
|
if (!ready?.port)
|
|
231
|
-
throw new Error("
|
|
231
|
+
throw new Error("Slopify started but Docker did not report which port it is on. Run the same command again to finish.");
|
|
232
232
|
return { url: `http://${ready.port}`, projects, recovery: j.backup };
|
|
233
233
|
}
|
|
234
234
|
catch (cause) {
|
|
235
235
|
const current = await readState(receiptPath, receiptSchema, c.uid);
|
|
236
236
|
if (current?.transaction === j.id)
|
|
237
|
-
throw new Error(`
|
|
237
|
+
throw new Error(`Slopify was installed (project files at ${projects}) but the last step did not finish. Run the same command again to complete it. Backup volume: ${j.backup}`, { cause });
|
|
238
238
|
try {
|
|
239
239
|
await recoverInstallation(c, j, current, recovery());
|
|
240
240
|
}
|
|
241
241
|
catch (failure) {
|
|
242
|
-
throw new Error(`Recovery incomplete. Leave container
|
|
242
|
+
throw new Error(`Recovery incomplete: the install failed and Slopify could not fully undo it. Leave the Slopify container stopped and don't delete anything: backup volume ${j.backup}, progress file ${journalPath}, project folder ${projects}. Fix the problem below, then run the same command again. ${failure instanceof Error ? failure.message : "The undo step failed."}`, { cause });
|
|
243
243
|
}
|
|
244
|
-
throw new Error(`Previous installation restored${old?.running ? " and restarted" : "
|
|
244
|
+
throw new Error(`Install failed. Previous installation restored${old?.running ? " and restarted" : " (not started)"}, so nothing was lost. Backup volume: ${j.backup}; project copy: ${projects}. Reason: ${cause instanceof Error ? cause.message : "Installation failed."}`, { cause });
|
|
245
245
|
}
|
|
246
246
|
}
|
|
@@ -104,15 +104,19 @@ export const journalSchema = z
|
|
|
104
104
|
token: z.string().regex(/^[a-f0-9]{64}$/),
|
|
105
105
|
})
|
|
106
106
|
.strict();
|
|
107
|
+
/** Where the Docker launcher keeps one folder (and receipt) per installation. */
|
|
108
|
+
export function dockerRoot(env, home) {
|
|
109
|
+
return join(absolute.parse(env.XDG_DATA_HOME || join(home, ".local/share")), "slopify/docker");
|
|
110
|
+
}
|
|
107
111
|
export function dockerConfig(env, home, cwd, uid, gid) {
|
|
108
112
|
const name = identifier.parse(env.SLOPIFY_DOCKER_NAME || "slopify");
|
|
109
113
|
const volume = identifier.parse(env.SLOPIFY_DOCKER_VOLUME || "slopify-data");
|
|
110
|
-
const root =
|
|
114
|
+
const root = dockerRoot(env, home);
|
|
111
115
|
const raw = env.SLOPIFY_DOCKER_PROJECTS_DIR || null;
|
|
112
116
|
const projectsOverride = raw === null ? null : resolve(cwd, raw.startsWith("~/") ? join(home, raw.slice(2)) : raw);
|
|
113
117
|
const portText = env.SLOPIFY_DOCKER_HOST_PORT || "6969";
|
|
114
118
|
if (!/^\d{1,5}$/.test(portText) || Number(portText) > 65535)
|
|
115
|
-
throw new Error(
|
|
119
|
+
throw new Error(`Invalid port ${JSON.stringify(portText)}: use a whole number between 1 and 65535, for example --port 7070.`);
|
|
116
120
|
return {
|
|
117
121
|
home,
|
|
118
122
|
uid,
|
|
@@ -144,11 +148,11 @@ export async function privateDirectory(path, uid) {
|
|
|
144
148
|
if (!s.isDirectory() ||
|
|
145
149
|
s.isSymbolicLink() ||
|
|
146
150
|
((s.mode & 0o022) !== 0 && (s.mode & 0o1000) === 0))
|
|
147
|
-
throw new Error(
|
|
151
|
+
throw new Error(`${current} is not a normal folder, or other users can write to it, so Slopify won't keep its Docker settings under it. Fix its permissions (chmod go-w ${current}) or set XDG_DATA_HOME to another folder, then try again.`);
|
|
148
152
|
}
|
|
149
153
|
const s = await lstat(path);
|
|
150
154
|
if (s.uid !== uid || (s.mode & 0o077) !== 0)
|
|
151
|
-
throw new Error(
|
|
155
|
+
throw new Error(`${path} must belong to you and be private. Fix it with chmod 700 ${path} (and chown it to your user if needed), then try again.`);
|
|
152
156
|
}
|
|
153
157
|
export async function readState(path, schema, uid) {
|
|
154
158
|
const s = await lstat(path).catch((error) => {
|
|
@@ -186,7 +190,7 @@ export async function selectProjects(c, receipt, old, retry = null) {
|
|
|
186
190
|
throw new Error("Receipt selects a different installation or volume.");
|
|
187
191
|
await safePath(receipt.projects, c.home, c.root, false);
|
|
188
192
|
await assertIdentity(receipt.projects, receipt.directoryIdentity).catch((cause) => {
|
|
189
|
-
throw new Error(`Remembered project folder is missing or replaced
|
|
193
|
+
throw new Error(`Remembered project folder ${receipt.projects} is missing or was replaced. Put the original folder back where it was and run the launcher again.`, {
|
|
190
194
|
cause,
|
|
191
195
|
});
|
|
192
196
|
});
|
|
@@ -202,7 +206,7 @@ export async function selectProjects(c, receipt, old, retry = null) {
|
|
|
202
206
|
await safePath(chosen, c.home, c.root, true);
|
|
203
207
|
const source = receipt?.projects ?? bind?.source;
|
|
204
208
|
if (source && source !== chosen && (contains(source, chosen) || contains(chosen, source)))
|
|
205
|
-
throw new Error(
|
|
209
|
+
throw new Error(`The new project folder ${chosen} and the current one ${source} are inside each other. Choose a separate folder with --projects-dir <folder>.`);
|
|
206
210
|
if (chosen !== source) {
|
|
207
211
|
const entries = await readdir(chosen).catch((error) => {
|
|
208
212
|
if (isMissing(error))
|
|
@@ -211,7 +215,7 @@ export async function selectProjects(c, receipt, old, retry = null) {
|
|
|
211
215
|
});
|
|
212
216
|
if (entries.length > 0) {
|
|
213
217
|
if (retry?.destination !== chosen || !retry.publishedIdentity || !retry.sourceDigest)
|
|
214
|
-
throw new Error(`
|
|
218
|
+
throw new Error(`The project folder ${chosen} already has files in it that Slopify didn't put there. Choose an empty or new folder with --projects-dir <folder>, or move those files away, then try again.`);
|
|
215
219
|
await assertIdentity(chosen, retry.publishedIdentity);
|
|
216
220
|
if ((await treeDigest(chosen)).hash !== retry.sourceDigest.hash)
|
|
217
221
|
throw new Error(`Published retry destination changed: ${chosen}`);
|
|
@@ -19,7 +19,7 @@ export function contains(root, path) {
|
|
|
19
19
|
}
|
|
20
20
|
export async function safePath(path, home, stateRoot, createParents) {
|
|
21
21
|
if (!isAbsolute(path) || resolve(path) !== path || /[\p{Cc},]/u.test(path))
|
|
22
|
-
throw new Error(
|
|
22
|
+
throw new Error(`The project folder ${JSON.stringify(path)} can't be used: give a full path without commas, for example --projects-dir ~/Slopify/Projects.`);
|
|
23
23
|
const forbidden = [
|
|
24
24
|
"/",
|
|
25
25
|
"/home",
|
|
@@ -36,7 +36,7 @@ export async function safePath(path, home, stateRoot, createParents) {
|
|
|
36
36
|
contains(stateRoot, path) ||
|
|
37
37
|
contains(path, stateRoot) ||
|
|
38
38
|
["/proc", "/sys", "/dev", "/etc", "/usr", "/bin", "/sbin", "/lib", "/var/lib/docker"].some((p) => contains(p, path)))
|
|
39
|
-
throw new Error(
|
|
39
|
+
throw new Error(`${path} can't be the project folder because it is a system or shared folder (or holds Slopify's own settings). Choose a dedicated folder, for example --projects-dir ~/Slopify/Projects.`);
|
|
40
40
|
const parts = path.split(sep).filter(Boolean);
|
|
41
41
|
let current = sep;
|
|
42
42
|
for (const [i, part] of parts.entries()) {
|
|
@@ -53,11 +53,11 @@ export async function safePath(path, home, stateRoot, createParents) {
|
|
|
53
53
|
if (s === undefined)
|
|
54
54
|
continue;
|
|
55
55
|
if (!s.isDirectory() || s.isSymbolicLink())
|
|
56
|
-
throw new Error(
|
|
56
|
+
throw new Error(`${current} is a file or a link, not a folder, so it can't be part of the project folder path. Choose another folder with --projects-dir <folder>.`);
|
|
57
57
|
if (i === parts.length - 1 && (s.uid !== process.getuid?.() || (s.mode & 0o022) !== 0))
|
|
58
|
-
throw new Error(
|
|
58
|
+
throw new Error(`The project folder ${path} must belong to you and must not be writable by other users. Fix it (chmod go-w ${path}) or choose another folder with --projects-dir <folder>.`);
|
|
59
59
|
if (i < parts.length - 1 && (s.mode & 0o022) !== 0 && (s.mode & 0o1000) === 0)
|
|
60
|
-
throw new Error(`
|
|
60
|
+
throw new Error(`Other users can write to ${current}, which contains the project folder. Choose a project folder inside your home folder with --projects-dir <folder>, or fix it with chmod go-w ${current}.`);
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
export function isMissing(error) {
|
|
@@ -137,7 +137,7 @@ export async function assertWritableTree(root, uid) {
|
|
|
137
137
|
(s.isFile() && s.nlink !== 1) ||
|
|
138
138
|
s.uid !== uid ||
|
|
139
139
|
(s.mode & (s.isDirectory() ? 0o700 : 0o600)) !== (s.isDirectory() ? 0o700 : 0o600))
|
|
140
|
-
throw new Error(
|
|
140
|
+
throw new Error(`${root} in your project files doesn't have permissions your user can fully use. Make it yours and writable (chown -R $USER ${root}; chmod -R u+rwX ${root}), then try again.`);
|
|
141
141
|
if (s.isDirectory())
|
|
142
142
|
for (const name of await readdir(root))
|
|
143
143
|
await assertWritableTree(join(root, name), uid);
|