@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.
Files changed (224) hide show
  1. package/SUBTITLES.md +1 -1
  2. package/dist/adapters/alignment/protocol.js +11 -1
  3. package/dist/adapters/alignment/runner.js +4 -1
  4. package/dist/adapters/alignment/threads.js +11 -0
  5. package/dist/adapters/alignment/worker.js +66 -9
  6. package/dist/adapters/explain.js +93 -0
  7. package/dist/adapters/ffmpeg.js +3 -3
  8. package/dist/adapters/host-cli/index.js +34 -3
  9. package/dist/adapters/host-cli/transport.js +4 -4
  10. package/dist/adapters/image/bytes.js +2 -2
  11. package/dist/adapters/image/codex-output.js +12 -12
  12. package/dist/adapters/image/codex.js +24 -8
  13. package/dist/adapters/image/fal.js +12 -6
  14. package/dist/adapters/image/google.js +18 -8
  15. package/dist/adapters/image/models.js +8 -2
  16. package/dist/adapters/image/openai.js +15 -6
  17. package/dist/adapters/image/replicate.js +16 -7
  18. package/dist/adapters/llm/claude-code-models.js +1 -1
  19. package/dist/adapters/llm/claude-code.js +16 -7
  20. package/dist/adapters/llm/cli-login-error.js +2 -1
  21. package/dist/adapters/llm/codex-models.js +1 -1
  22. package/dist/adapters/llm/codex.js +5 -6
  23. package/dist/adapters/llm/document-workspace.js +2 -2
  24. package/dist/adapters/llm/gemini-models.js +1 -1
  25. package/dist/adapters/llm/gemini-workspace.js +1 -1
  26. package/dist/adapters/llm/gemini.js +15 -11
  27. package/dist/adapters/llm/openrouter.js +21 -9
  28. package/dist/adapters/llm/run-cli.js +14 -11
  29. package/dist/adapters/tts/cartesia.js +10 -3
  30. package/dist/adapters/tts/elevenlabs.js +18 -6
  31. package/dist/adapters/tts/inworld-async.js +47 -16
  32. package/dist/adapters/tts/inworld.js +25 -15
  33. package/dist/adapters/tts/openai.js +18 -6
  34. package/dist/assets/document/SOURCE.txt +7 -0
  35. package/dist/assets/document/background.jpg +0 -0
  36. package/dist/assets/document/fonts/Cinzel-Black.ttf +0 -0
  37. package/dist/assets/document/fonts/Cinzel-Bold.ttf +0 -0
  38. package/dist/assets/document/fonts/Cinzel-Medium.ttf +0 -0
  39. package/dist/assets/document/fonts/Cinzel-Regular.ttf +0 -0
  40. package/dist/assets/document/fonts/OFL.txt +93 -0
  41. package/dist/assets/document/fonts/SOURCE.txt +11 -0
  42. package/dist/catalog/registry.js +13 -10
  43. package/dist/catalog/store.js +1 -1
  44. package/dist/catalog/validate.js +16 -6
  45. package/dist/edge/cli.js +33 -7
  46. package/dist/edge/docker-launch.js +6 -6
  47. package/dist/edge/docker-projects/engine.js +34 -13
  48. package/dist/edge/docker-projects/install.js +8 -8
  49. package/dist/edge/docker-projects/state.js +11 -7
  50. package/dist/edge/docker-projects/tree.js +6 -6
  51. package/dist/edge/docker.js +13 -13
  52. package/dist/edge/host-cli.js +14 -0
  53. package/dist/edge/http/actions.js +27 -25
  54. package/dist/edge/http/app.js +3 -3
  55. package/dist/edge/http/audio-preview.js +1 -1
  56. package/dist/edge/http/checkpoints.js +5 -1
  57. package/dist/edge/http/diagnostics.js +3 -1
  58. package/dist/edge/http/draft-files.js +2 -2
  59. package/dist/edge/http/draft-problem.js +11 -1
  60. package/dist/edge/http/files.js +4 -4
  61. package/dist/edge/http/folder-location-schema.js +5 -8
  62. package/dist/edge/http/folder-location.js +13 -8
  63. package/dist/edge/http/fonts.js +22 -10
  64. package/dist/edge/http/host-cli.js +61 -14
  65. package/dist/edge/http/multipart.js +7 -5
  66. package/dist/edge/http/open-folder.js +2 -2
  67. package/dist/edge/http/planning.js +4 -4
  68. package/dist/edge/http/problem.js +2 -2
  69. package/dist/edge/http/project-templates.js +7 -3
  70. package/dist/edge/http/projects.js +8 -8
  71. package/dist/edge/http/prompts.js +9 -4
  72. package/dist/edge/http/providers.js +15 -7
  73. package/dist/edge/http/revision-files.js +8 -4
  74. package/dist/edge/http/revisions.js +30 -4
  75. package/dist/edge/http/schedules.js +9 -9
  76. package/dist/edge/http/settings.js +15 -9
  77. package/dist/edge/http/staging.js +5 -5
  78. package/dist/edge/http/storage.js +4 -4
  79. package/dist/edge/http/subtitles.js +1 -1
  80. package/dist/edge/http/tutorial.js +2 -2
  81. package/dist/edge/http/update.js +3 -3
  82. package/dist/host-cli/install.js +28 -6
  83. package/dist/host-cli/open-folder.js +95 -0
  84. package/dist/host-cli/paths.js +6 -6
  85. package/dist/host-cli/runtime.js +2 -0
  86. package/dist/host-cli/server.js +1 -1
  87. package/dist/host-cli/service.js +11 -11
  88. package/dist/host-cli/status.js +7 -3
  89. package/dist/kernel/config/index.js +6 -4
  90. package/dist/kernel/db/migrate.js +27 -2
  91. package/dist/kernel/db/migrations/0013-schedule-topic-queue.sql +4 -0
  92. package/dist/kernel/db/migrations/0014-document-stage.sql +44 -0
  93. package/dist/kernel/db/migrations/0015-description-prompts.sql +13 -0
  94. package/dist/kernel/lock.js +4 -3
  95. package/dist/kernel/pipeline.js +11 -1
  96. package/dist/kernel/ports/host-cli.js +14 -0
  97. package/dist/kernel/ports/subtitles.js +15 -1
  98. package/dist/kernel/runner/attempt.js +26 -2
  99. package/dist/kernel/runner/graph.js +6 -1
  100. package/dist/main.js +8 -4
  101. package/dist/slices/admission/model.js +7 -1
  102. package/dist/slices/admission/rules.js +98 -22
  103. package/dist/slices/admission/schema.js +16 -3
  104. package/dist/slices/admission/start.js +3 -2
  105. package/dist/slices/article/continuation.js +4 -2
  106. package/dist/slices/article/plain.js +2 -2
  107. package/dist/slices/article/provided-entries.js +3 -3
  108. package/dist/slices/article/run.js +2 -2
  109. package/dist/slices/article/segments.js +5 -3
  110. package/dist/slices/control/providers.js +20 -7
  111. package/dist/slices/document/blocks.js +129 -0
  112. package/dist/slices/document/flow.js +117 -0
  113. package/dist/slices/document/fonts.js +54 -0
  114. package/dist/slices/document/model.js +11 -0
  115. package/dist/slices/document/pages.js +198 -0
  116. package/dist/slices/document/render.js +242 -0
  117. package/dist/slices/document/sources.js +35 -0
  118. package/dist/slices/document/theme.js +158 -0
  119. package/dist/slices/document/writer.js +66 -0
  120. package/dist/slices/estimate/index.js +14 -2
  121. package/dist/slices/fonts/catalog.js +1 -1
  122. package/dist/slices/fonts/upload.js +1 -1
  123. package/dist/slices/images/run.js +5 -5
  124. package/dist/slices/library/lint.js +13 -7
  125. package/dist/slices/library/model.js +1 -1
  126. package/dist/slices/library/slots.js +6 -3
  127. package/dist/slices/narration/concat.js +1 -1
  128. package/dist/slices/narration/pronunciation-chunks.js +3 -3
  129. package/dist/slices/narration/pronunciation.js +2 -2
  130. package/dist/slices/narration/run.js +7 -7
  131. package/dist/slices/narration/steering.js +3 -3
  132. package/dist/slices/play-drafts/convert.js +35 -5
  133. package/dist/slices/play-drafts/readiness.js +15 -8
  134. package/dist/slices/play-drafts/review-inputs.js +7 -4
  135. package/dist/slices/play-drafts/review.js +3 -3
  136. package/dist/slices/play-drafts/schema.js +22 -5
  137. package/dist/slices/play-drafts/service.js +3 -1
  138. package/dist/slices/play-drafts/uploads.js +1 -1
  139. package/dist/slices/project-templates/from-project.js +10 -0
  140. package/dist/slices/project-templates/setup.js +1 -0
  141. package/dist/slices/rebuild/admission-repo.js +2 -2
  142. package/dist/slices/rebuild/preview-details.js +11 -1
  143. package/dist/slices/rebuild/recipe-audio.js +3 -0
  144. package/dist/slices/rebuild/recipe-build.js +6 -1
  145. package/dist/slices/rebuild/recipe-document.js +36 -0
  146. package/dist/slices/rebuild/recipe-exports.js +14 -2
  147. package/dist/slices/rebuild/recipe-fixture.js +4 -0
  148. package/dist/slices/rebuild/recipe-legacy.js +6 -1
  149. package/dist/slices/rebuild/recipe-model.js +2 -0
  150. package/dist/slices/rebuild/recipe-narration-text.js +1 -1
  151. package/dist/slices/rebuild/recipe-provider-choice.js +3 -0
  152. package/dist/slices/rebuild/recipe-save.js +15 -3
  153. package/dist/slices/rebuild/recipe-validation.js +15 -2
  154. package/dist/slices/rebuild/recipe-visual.js +11 -2
  155. package/dist/slices/rebuild/recipe-work.js +15 -0
  156. package/dist/slices/rebuild/recipe-youtube.js +33 -0
  157. package/dist/slices/rebuild/recovery-selection.js +2 -0
  158. package/dist/slices/rebuild/recovery.js +12 -7
  159. package/dist/slices/rebuild/repo.js +3 -3
  160. package/dist/slices/rebuild/runtime-actions.js +2 -0
  161. package/dist/slices/rebuild/runtime-admission.js +4 -4
  162. package/dist/slices/rebuild/runtime-article.js +5 -5
  163. package/dist/slices/rebuild/runtime-checkpoints.js +4 -4
  164. package/dist/slices/rebuild/runtime-document.js +85 -0
  165. package/dist/slices/rebuild/runtime-export-inputs.js +7 -6
  166. package/dist/slices/rebuild/runtime-export.js +39 -23
  167. package/dist/slices/rebuild/runtime-local.js +6 -6
  168. package/dist/slices/rebuild/runtime-narration-text.js +46 -7
  169. package/dist/slices/rebuild/runtime-piece-label.js +66 -0
  170. package/dist/slices/rebuild/runtime-provider.js +6 -4
  171. package/dist/slices/rebuild/runtime-publication.js +4 -3
  172. package/dist/slices/rebuild/runtime-run.js +22 -4
  173. package/dist/slices/rebuild/runtime-store.js +7 -2
  174. package/dist/slices/rebuild/runtime-subtitles.js +82 -16
  175. package/dist/slices/rebuild/runtime-youtube.js +84 -0
  176. package/dist/slices/rebuild/transition-repo.js +8 -6
  177. package/dist/slices/reruns/cascade.js +6 -4
  178. package/dist/slices/reruns/index.js +1 -1
  179. package/dist/slices/research/run.js +9 -7
  180. package/dist/slices/research/synthesis.js +4 -2
  181. package/dist/slices/revisions/mutation-assets.js +26 -8
  182. package/dist/slices/revisions/mutation-cues.js +4 -2
  183. package/dist/slices/revisions/projection.js +2 -1
  184. package/dist/slices/revisions/rules.js +18 -9
  185. package/dist/slices/revisions/schema.js +1 -1
  186. package/dist/slices/schedules/repo.js +55 -11
  187. package/dist/slices/schedules/scheduler.js +25 -8
  188. package/dist/slices/schedules/schema.js +19 -4
  189. package/dist/slices/schedules/service.js +2 -0
  190. package/dist/slices/settings/cli-paths.js +7 -4
  191. package/dist/slices/settings/models.js +4 -4
  192. package/dist/slices/settings/playback.js +2 -2
  193. package/dist/slices/storage/assets.js +5 -5
  194. package/dist/slices/storage/downloads.js +1 -0
  195. package/dist/slices/storage/layout.js +7 -1
  196. package/dist/slices/storage/model.js +3 -0
  197. package/dist/slices/subtitles/captions.js +1 -1
  198. package/dist/slices/subtitles/prepare.js +7 -6
  199. package/dist/slices/subtitles/transcript.js +1 -1
  200. package/dist/slices/telemetry/model.js +1 -1
  201. package/dist/slices/thumbnail/run.js +8 -8
  202. package/dist/slices/video/audio-export.js +11 -3
  203. package/dist/slices/video/audio-inputs.js +2 -2
  204. package/dist/slices/video/edit-list.js +73 -0
  205. package/dist/slices/video/ffmpeg.js +129 -61
  206. package/dist/slices/video/motion.js +51 -0
  207. package/dist/slices/video/plan.js +62 -28
  208. package/dist/slices/video/reuse-audio.js +2 -0
  209. package/dist/slices/video/run.js +23 -12
  210. package/dist/slices/video/slideshow.js +48 -0
  211. package/dist/slices/video/write-export.js +17 -25
  212. package/dist/slices/youtube/answer.js +199 -0
  213. package/dist/slices/youtube/model.js +27 -0
  214. package/dist/slices/youtube/timestamps.js +24 -0
  215. package/dist/slices/youtube/transcript.js +47 -0
  216. package/dist/updater/plan.js +1 -1
  217. package/dist/updater/service.js +4 -3
  218. package/dist/updater/worker.js +6 -4
  219. package/dist/web/assets/index-B6NbhpjD.css +1 -0
  220. package/dist/web/assets/index-ComF5gwP.js +142 -0
  221. package/dist/web/index.html +2 -2
  222. package/package.json +4 -2
  223. package/dist/web/assets/index-CZNiHm8P.css +0 -1
  224. package/dist/web/assets/index-DPgGLX5m.js +0 -137
@@ -7,7 +7,7 @@ export function curateRegistry(registry, catalogue) {
7
7
  if (!model)
8
8
  throw providerError({
9
9
  kind: "unsupported",
10
- message: `${provider}: this model is not enabled in the current catalogue. Choose a supported model in Run settings.`,
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({ kind: "unsupported", message: "Choose a model ID." });
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: "This model is not available in the installed CLI.",
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: "This model does not support the selected thinking setting.",
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 CLI does not support research web search.",
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: "This model does not support research web search.",
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: "This model does not support the selected thinking setting. Choose an available setting.",
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: "The Codex image capability is unavailable.",
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: "This model does not support the selected video shape.",
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: `${id}: this physical narration request exceeds the current ${model.tts.maxCharacters}-character limit. Rebuild narration to split it using the new limit.`,
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
  },
@@ -40,7 +40,7 @@ export function createCatalogueStore(deps) {
40
40
  }
41
41
  catch {
42
42
  warning =
43
- "Your models.yaml is unavailable or invalid. The last valid catalogue is still in use.";
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
  }
@@ -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({ field, message: "Choose an enabled model from the current catalogue." });
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({ field, message: "Choose a supported thinking setting for this model." });
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 perform web research. Choose another or turn Research off.",
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({ field, message: "This image model does not support the selected shape." });
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 requires --docker; native installs use --data-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("Host CLI options require --docker.");
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 override supports only off (API-only Docker).");
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 private data in its named volume and binds to localhost. Use --projects-dir for project files and --port for the port.");
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
- const config = configFrom(values, process.env);
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 instanceof Error ? error.message : String(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("Missing host group.");
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("Unsafe Docker setup lock.");
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 (flock) before using the managed Docker launcher.")));
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; rerun to recover."))
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 managed Docker installation is changing storage. Wait for it to finish and rerun.");
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 interrupted."));
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(`Docker ${args[0] ?? "command"} failed; recovery material is retained.`);
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("Docker container inventory failed.");
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("Docker inspect failed.");
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; an inventoried container is missing.");
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(`Another container claims installation volume ${volume}: ${c.name}.`);
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 writes installation storage: ${c.name}. Stop it before retrying.`);
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("Managed folders require a local Linux Docker unix socket; remote daemons are unsupported.");
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(JSON.parse(await command(["info", "--format", "{{json .}}"])));
186
+ .parse(rawInfo);
166
187
  if (/docker desktop/i.test(info.OperatingSystem))
167
- throw new Error("Docker Desktop is unsupported by managed project folders. Use a native Linux Docker daemon or native Slopify.");
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 is unsupported by managed project folders. Use a supported rootless daemon or native Slopify; do not disable isolation.");
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("Managed projects require an ordinary local named volume; remote/custom volume drivers are unsupported.");
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 cannot create private files as this host user; ownership mapping is unsupported.");
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("Replacement did not become healthy within 120 seconds.");
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("Remembered data volume is missing or was replaced; restore the original volume before retrying.");
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 daemon differs from saved installation; select the original daemon.");
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(`Named volume belongs to installation ${other.name}; select a separate volume.`);
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("Docker image and installed launcher versions differ. Refresh the default release image or select the matching custom image before retrying; the existing container is unchanged.");
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("Committed container has no localhost port; rerun the launcher.");
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(`Installation committed at ${projects}; rerun the launcher to finish activation. Recovery volume: ${j.backup}`, { cause });
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/data stopped; recovery volume ${j.backup}, journal ${journalPath}, project folder ${projects}. ${failure instanceof Error ? failure.message : "Recovery operation failed."}`, { cause });
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" : " without starting it"}. Recovery: ${j.backup}; project copy: ${projects}. ${cause instanceof Error ? cause.message : "Installation failed."}`, { cause });
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 = join(absolute.parse(env.XDG_DATA_HOME || join(home, ".local/share")), "slopify/docker");
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("Docker host port must be from 0 to 65535.");
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("Unsafe private state ancestor.");
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("Installation state must be private and owned by the installing user.");
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: ${receipt.projects}`, {
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("Source and destination project folders cannot contain each other.");
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(`Unrelated populated destination: ${chosen}`);
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("Project directory must be a normal absolute path without commas/control characters.");
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("Unsafe broad or private project directory.");
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(`Unsafe path component: ${current}`);
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("Project directory must belong to the installing user and must not be publicly writable.");
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(`Writable project ancestor: ${current}`);
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(`Existing project permissions are incompatible with this host user: ${root}`);
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);