@gentbajko/slopify 2.0.1 → 2.0.3

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 (162) hide show
  1. package/dist/adapters/alignment/cache.js +17 -1
  2. package/dist/adapters/alignment/prefetch.js +35 -0
  3. package/dist/adapters/alignment/protocol.js +11 -1
  4. package/dist/adapters/alignment/runner.js +4 -1
  5. package/dist/adapters/alignment/worker.js +28 -5
  6. package/dist/adapters/explain.js +93 -0
  7. package/dist/adapters/ffmpeg.js +3 -3
  8. package/dist/adapters/host-cli/index.js +9 -2
  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/catalog/registry.js +13 -10
  35. package/dist/catalog/store.js +1 -1
  36. package/dist/catalog/validate.js +13 -4
  37. package/dist/edge/cli.js +37 -8
  38. package/dist/edge/docker-launch.js +6 -6
  39. package/dist/edge/docker-projects/engine.js +34 -13
  40. package/dist/edge/docker-projects/install.js +8 -8
  41. package/dist/edge/docker-projects/state.js +6 -6
  42. package/dist/edge/docker-projects/tree.js +6 -6
  43. package/dist/edge/docker.js +13 -13
  44. package/dist/edge/http/actions.js +27 -25
  45. package/dist/edge/http/app.js +3 -3
  46. package/dist/edge/http/audio-preview.js +1 -1
  47. package/dist/edge/http/checkpoints.js +5 -1
  48. package/dist/edge/http/diagnostics.js +3 -1
  49. package/dist/edge/http/draft-files.js +2 -2
  50. package/dist/edge/http/draft-problem.js +11 -1
  51. package/dist/edge/http/files.js +4 -4
  52. package/dist/edge/http/folder-location.js +3 -3
  53. package/dist/edge/http/fonts.js +22 -10
  54. package/dist/edge/http/host-cli.js +34 -12
  55. package/dist/edge/http/multipart.js +7 -5
  56. package/dist/edge/http/open-folder.js +2 -2
  57. package/dist/edge/http/planning.js +4 -4
  58. package/dist/edge/http/problem.js +2 -2
  59. package/dist/edge/http/project-templates.js +7 -3
  60. package/dist/edge/http/projects.js +8 -8
  61. package/dist/edge/http/prompts.js +9 -4
  62. package/dist/edge/http/providers.js +15 -7
  63. package/dist/edge/http/revision-files.js +3 -3
  64. package/dist/edge/http/revisions.js +30 -4
  65. package/dist/edge/http/schedules.js +9 -9
  66. package/dist/edge/http/settings.js +15 -9
  67. package/dist/edge/http/staging.js +5 -5
  68. package/dist/edge/http/storage.js +4 -4
  69. package/dist/edge/http/subtitles.js +1 -1
  70. package/dist/edge/http/tutorial.js +2 -2
  71. package/dist/edge/http/update.js +3 -3
  72. package/dist/host-cli/install.js +28 -6
  73. package/dist/host-cli/paths.js +6 -6
  74. package/dist/host-cli/server.js +1 -1
  75. package/dist/host-cli/service.js +11 -11
  76. package/dist/host-cli/status.js +7 -3
  77. package/dist/kernel/config/index.js +6 -4
  78. package/dist/kernel/db/migrate.js +2 -1
  79. package/dist/kernel/db/migrations/0013-schedule-topic-queue.sql +4 -0
  80. package/dist/kernel/errors.js +12 -0
  81. package/dist/kernel/lock.js +4 -3
  82. package/dist/kernel/paths.js +5 -0
  83. package/dist/kernel/ports/subtitles.js +15 -1
  84. package/dist/kernel/runner/attempt.js +26 -2
  85. package/dist/kernel/runner/index.js +16 -6
  86. package/dist/kernel/runner/progress.js +56 -0
  87. package/dist/main.js +20 -4
  88. package/dist/slices/admission/rules.js +32 -20
  89. package/dist/slices/article/continuation.js +4 -2
  90. package/dist/slices/article/plain.js +2 -2
  91. package/dist/slices/article/provided-entries.js +3 -3
  92. package/dist/slices/article/run.js +2 -2
  93. package/dist/slices/article/segments.js +5 -3
  94. package/dist/slices/control/providers.js +20 -7
  95. package/dist/slices/fonts/catalog.js +1 -1
  96. package/dist/slices/fonts/upload.js +1 -1
  97. package/dist/slices/images/run.js +5 -5
  98. package/dist/slices/library/lint.js +13 -7
  99. package/dist/slices/library/slots.js +2 -2
  100. package/dist/slices/narration/concat.js +1 -1
  101. package/dist/slices/narration/pronunciation-chunks.js +3 -3
  102. package/dist/slices/narration/pronunciation.js +2 -2
  103. package/dist/slices/narration/run.js +7 -7
  104. package/dist/slices/narration/steering.js +3 -3
  105. package/dist/slices/play-drafts/convert.js +5 -4
  106. package/dist/slices/play-drafts/readiness.js +12 -6
  107. package/dist/slices/play-drafts/review-inputs.js +7 -4
  108. package/dist/slices/play-drafts/review.js +3 -3
  109. package/dist/slices/play-drafts/service.js +3 -1
  110. package/dist/slices/play-drafts/uploads.js +1 -1
  111. package/dist/slices/rebuild/admission-repo.js +2 -2
  112. package/dist/slices/rebuild/recipe-legacy.js +1 -1
  113. package/dist/slices/rebuild/recipe-narration-text.js +1 -1
  114. package/dist/slices/rebuild/recipe-visual.js +1 -1
  115. package/dist/slices/rebuild/recovery.js +2 -2
  116. package/dist/slices/rebuild/repo.js +3 -3
  117. package/dist/slices/rebuild/runtime-admission.js +4 -4
  118. package/dist/slices/rebuild/runtime-article.js +5 -5
  119. package/dist/slices/rebuild/runtime-checkpoints.js +4 -4
  120. package/dist/slices/rebuild/runtime-export-inputs.js +6 -6
  121. package/dist/slices/rebuild/runtime-export.js +4 -4
  122. package/dist/slices/rebuild/runtime-local.js +6 -6
  123. package/dist/slices/rebuild/runtime-narration-text.js +46 -7
  124. package/dist/slices/rebuild/runtime-piece-label.js +62 -0
  125. package/dist/slices/rebuild/runtime-provider.js +6 -4
  126. package/dist/slices/rebuild/runtime-publication.js +3 -3
  127. package/dist/slices/rebuild/runtime-run.js +11 -1
  128. package/dist/slices/rebuild/runtime-store.js +45 -15
  129. package/dist/slices/rebuild/runtime-subtitles.js +80 -17
  130. package/dist/slices/rebuild/transition-repo.js +6 -6
  131. package/dist/slices/reruns/index.js +1 -1
  132. package/dist/slices/research/run.js +9 -7
  133. package/dist/slices/research/synthesis.js +4 -2
  134. package/dist/slices/revisions/mutation-assets.js +26 -8
  135. package/dist/slices/revisions/mutation-cues.js +1 -1
  136. package/dist/slices/revisions/rules.js +18 -9
  137. package/dist/slices/revisions/schema.js +1 -1
  138. package/dist/slices/schedules/repo.js +55 -11
  139. package/dist/slices/schedules/scheduler.js +25 -8
  140. package/dist/slices/schedules/schema.js +19 -4
  141. package/dist/slices/schedules/service.js +2 -0
  142. package/dist/slices/settings/cli-paths.js +7 -4
  143. package/dist/slices/settings/models.js +4 -4
  144. package/dist/slices/settings/playback.js +2 -2
  145. package/dist/slices/storage/assets.js +5 -5
  146. package/dist/slices/storage/layout.js +1 -1
  147. package/dist/slices/subtitles/captions.js +1 -1
  148. package/dist/slices/subtitles/prepare.js +7 -6
  149. package/dist/slices/subtitles/transcript.js +1 -1
  150. package/dist/slices/thumbnail/run.js +8 -8
  151. package/dist/slices/video/audio-inputs.js +2 -2
  152. package/dist/slices/video/ffmpeg.js +6 -4
  153. package/dist/slices/video/plan.js +1 -1
  154. package/dist/slices/video/run.js +2 -2
  155. package/dist/updater/plan.js +1 -1
  156. package/dist/updater/service.js +4 -3
  157. package/dist/updater/worker.js +6 -4
  158. package/dist/web/assets/{index-CZNiHm8P.css → index-Bwtmu9ei.css} +1 -1
  159. package/dist/web/assets/index-DjhMJqMN.js +141 -0
  160. package/dist/web/index.html +2 -2
  161. package/package.json +1 -1
  162. package/dist/web/assets/index-DPgGLX5m.js +0 -137
@@ -47,7 +47,7 @@ export function revisionFolderRoutes(deps) {
47
47
  return problem(c, {
48
48
  status: 403,
49
49
  title: titleOf(403),
50
- detail: "Open folders from Slopify itself.",
50
+ detail: "For your safety, folders can only be opened from the Slopify page itself. Open Slopify and try again there.",
51
51
  });
52
52
  const { id: projectId, revisionId, recordId } = c.req.valid("param");
53
53
  const result = findRevisionDownload(deps, projectId, revisionId, recordId);
@@ -61,8 +61,8 @@ function unavailable(c, reason) {
61
61
  status: 404,
62
62
  title: titleOf(404),
63
63
  detail: reason === "missing-file"
64
- ? "This retained file is no longer on disk. Review the affected rebuild to recreate it."
65
- : "The retained file is unavailable.",
64
+ ? "This file was deleted or moved from the project folder. In the Edit tab, use Rebuild affected outputs to make it again."
65
+ : "This file is no longer part of the project. Reload the page to see its current files.",
66
66
  extensions: { reason },
67
67
  });
68
68
  }
@@ -3,6 +3,7 @@ import { Hono } from "hono";
3
3
  import { z } from "zod";
4
4
  import { projectExists } from "../../slices/admission/repo.js";
5
5
  import { previewRebuildSchema, startRebuildSchema } from "../../slices/rebuild/model.js";
6
+ import { narrationChunkOrder } from "../../slices/rebuild/runtime-narration-text.js";
6
7
  import { previewRebuild, startRebuild } from "../../slices/rebuild/service.js";
7
8
  import { ensureBaseline, restoreRevision, saveRevision } from "../../slices/revisions/index.js";
8
9
  import { currentRevisionId, listRevisionHistory } from "../../slices/revisions/repo.js";
@@ -30,12 +31,24 @@ const statuses = {
30
31
  "cost-ack-required": 409,
31
32
  readiness: 409,
32
33
  };
34
+ const details = {
35
+ "no-project": "This project no longer exists. Go back to Projects to pick another.",
36
+ "no-revision": "This saved version of the project no longer exists. Reload the page, then try again.",
37
+ conflict: "This project changed while you were editing. Reload the page to see the latest, then make your change again.",
38
+ "idempotency-conflict": "This change was already sent with different details. Reload the page, then try again.",
39
+ "invalid-edit": "Some of your changes are not valid. Fix the highlighted fields and save again.",
40
+ "stale-preview": "What needs rebuilding changed since you opened this review. Cancel it and open Rebuild affected outputs again to see the latest.",
41
+ "invalid-selection": "Some of the outputs you picked can no longer be rebuilt. Reload the page and choose again.",
42
+ "review-required": 'This rebuild touches content you supplied yourself. Tick "Keep the provided content" for each item in the review, then choose Start rebuild.',
43
+ "cost-ack-required": "Some costs for this rebuild are unknown. Tick the box confirming you understand, then choose Start rebuild.",
44
+ readiness: "Slopify cannot run this rebuild yet. Fix the problems listed, then try again.",
45
+ };
33
46
  function refused(c, deps, projectId, value) {
34
47
  const status = statuses[value.reason];
35
48
  return problem(c, {
36
49
  status,
37
50
  title: titleOf(status),
38
- detail: value.reason.replaceAll("-", " "),
51
+ detail: details[value.reason],
39
52
  extensions: {
40
53
  reason: value.reason,
41
54
  currentRevisionId: currentRevisionId(deps.db, projectId) ?? null,
@@ -47,11 +60,11 @@ function unavailable(c) {
47
60
  return problem(c, {
48
61
  status: 503,
49
62
  title: titleOf(503),
50
- detail: "Rebuild services are unavailable.",
63
+ detail: "Rebuilding is not ready yet because Slopify is still starting. Wait a moment, reload the page and try again.",
51
64
  });
52
65
  }
53
66
  export function revisionRoutes(deps) {
54
- return new Hono()
67
+ return (new Hono()
55
68
  .post("/:id/revisions/prepare", zValidator("param", projectParam, onInvalid), async (c) => {
56
69
  const projectId = c.req.valid("param").id;
57
70
  const result = await ensureBaseline(deps, projectId);
@@ -69,6 +82,19 @@ export function revisionRoutes(deps) {
69
82
  return view === undefined
70
83
  ? refused(c, deps, projectId, { reason: "no-revision" })
71
84
  : c.json({ view });
85
+ })
86
+ // Chunk keys in spoken order, so the Narration editor numbers chunks the way a subtitle
87
+ // error names them. Building the plan is the costly part, so it has its own request.
88
+ .get("/:id/revisions/:revisionId/narration-chunks", zValidator("param", revisionParam, onInvalid), (c) => {
89
+ const { id: projectId, revisionId } = c.req.valid("param");
90
+ let chunks;
91
+ try {
92
+ chunks = narrationChunkOrder(deps, projectId, revisionId);
93
+ }
94
+ catch {
95
+ chunks = undefined;
96
+ }
97
+ return c.json({ chunks: chunks ?? null });
72
98
  })
73
99
  .post("/:id/revisions", zValidator("param", projectParam, onInvalid), zValidator("json", saveBody, onInvalid), async (c) => {
74
100
  const projectId = c.req.valid("param").id;
@@ -99,5 +125,5 @@ export function revisionRoutes(deps) {
99
125
  const projectId = c.req.valid("param").id;
100
126
  const result = await startRebuild(deps.rebuild, { projectId, ...c.req.valid("json") });
101
127
  return result.ok ? c.json(result, 202) : refused(c, deps, projectId, result);
102
- });
128
+ }));
103
129
  }
@@ -13,15 +13,15 @@ function refused(c, result) {
13
13
  ? 409
14
14
  : 400;
15
15
  const detail = {
16
- "invalid-input": "Check the schedule fields and try again.",
17
- "not-found": "The schedule was not found.",
18
- conflict: "The schedule changed elsewhere. Reload it before trying again.",
19
- "cancel-required": "Cancel this schedule before deleting it. Completed schedules can be deleted directly.",
20
- "missing-template": "The selected template version is no longer available.",
21
- "unsupported-media": "This template contains provided media and cannot run unattended.",
22
- "not-due": "Choose a future one-off time.",
23
- "spend-limit": "The estimate exceeds the schedule spend limit or contains unknown pricing.",
24
- readiness: "Provider or local readiness checks refused this scheduled run.",
16
+ "invalid-input": "Some schedule settings are not valid. Check each field and save again.",
17
+ "not-found": "This schedule no longer exists; it may have been deleted. Reload the page to see your schedules.",
18
+ conflict: "This schedule changed while you were editing, possibly because a run just started. Reload the page to see the latest, then make your change again.",
19
+ "cancel-required": "This schedule is still active. Cancel it first, then delete it. Completed schedules can be deleted straight away.",
20
+ "missing-template": "The template you picked was deleted or changed. Choose a template again, then save.",
21
+ "unsupported-media": "This template uses audio, images or a thumbnail you supplied, so it cannot run on a schedule. Pick a template that generates these instead.",
22
+ "not-due": "This one-off time has already passed. Choose a time in the future.",
23
+ "spend-limit": "The estimated cost is above this schedule's spend limit, or some prices are unknown. Raise the spend limit or choose models with known prices.",
24
+ readiness: "A provider this template needs is not ready, for example a missing API key, model or voice. Check Settings → Providers, then try again.",
25
25
  };
26
26
  return problem(c, {
27
27
  status,
@@ -30,7 +30,7 @@ export function settingsRoutes(deps) {
30
30
  return problem(c, {
31
31
  status: 400,
32
32
  title: titleOf(400),
33
- detail: "These settings cannot be saved; the listed fields need attention.",
33
+ detail: "These settings cannot be saved yet. Fix the highlighted fields and try again.",
34
34
  extensions: { fields: result.fields },
35
35
  });
36
36
  }
@@ -46,12 +46,12 @@ export function settingsRoutes(deps) {
46
46
  ? problem(c, {
47
47
  status: 409,
48
48
  title: titleOf(409),
49
- detail: "This voice ID is already listed for this provider.",
49
+ detail: "This voice ID is already saved for this provider. Use the existing voice, or enter a different voice ID.",
50
50
  })
51
51
  : problem(c, {
52
52
  status: 400,
53
53
  title: titleOf(400),
54
- detail: "This voice cannot be added; the listed fields need attention.",
54
+ detail: "This voice cannot be added yet. Fix the highlighted field and try again.",
55
55
  extensions: { fields: [fieldOf(result.reason)] },
56
56
  });
57
57
  }
@@ -62,7 +62,7 @@ export function settingsRoutes(deps) {
62
62
  return problem(c, {
63
63
  status: 404,
64
64
  title: titleOf(404),
65
- detail: "No voice has that id.",
65
+ detail: "This voice no longer exists; it may have been deleted already. Reload the page to see your voices.",
66
66
  });
67
67
  }
68
68
  return c.body(null, 204);
@@ -71,20 +71,26 @@ export function settingsRoutes(deps) {
71
71
  function fieldOf(reason) {
72
72
  switch (reason) {
73
73
  case "blank-name":
74
- return { field: "name", message: "A voice name is required." };
74
+ return { field: "name", message: "Enter a name for this voice." };
75
75
  case "name-too-long":
76
76
  return {
77
77
  field: "name",
78
- message: `A voice name is at most ${String(voiceNameMax)} characters.`,
78
+ message: `Keep the voice name to ${String(voiceNameMax)} characters or fewer.`,
79
79
  };
80
80
  case "blank-voice-id":
81
- return { field: "voiceId", message: "A voice ID is required." };
81
+ return {
82
+ field: "voiceId",
83
+ message: "Enter the voice ID from your provider's voice library.",
84
+ };
82
85
  case "voice-id-too-long":
83
86
  return {
84
87
  field: "voiceId",
85
- message: `A voice ID is at most ${String(voiceIdMax)} characters.`,
88
+ message: `A voice ID is at most ${String(voiceIdMax)} characters. Check you copied only the ID.`,
86
89
  };
87
90
  case "not-a-tts-provider":
88
- return { field: "provider", message: "Pick a text-to-speech provider." };
91
+ return {
92
+ field: "provider",
93
+ message: "Choose a text-to-speech provider, such as Inworld or Cartesia.",
94
+ };
89
95
  }
90
96
  }
@@ -34,7 +34,7 @@ export function stagingRoutes(deps) {
34
34
  return problem(c, {
35
35
  status: 400,
36
36
  title: titleOf(400),
37
- detail: "The request carried no file part.",
37
+ detail: "No file arrived with the upload. Choose the file again and retry.",
38
38
  });
39
39
  }
40
40
  if (!result.ok) {
@@ -49,8 +49,8 @@ export function stagingRoutes(deps) {
49
49
  status: result.reason === "in-use" ? 409 : 404,
50
50
  title: titleOf(result.reason === "in-use" ? 409 : 404),
51
51
  detail: result.reason === "in-use"
52
- ? "This file is used by a saved draft."
53
- : "No staged file has that id.",
52
+ ? "This file is still attached to a draft in Play. Remove it from the draft first."
53
+ : "This uploaded file no longer exists; it may have been removed already. Reload the page.",
54
54
  });
55
55
  }
56
56
  return c.body(null, 204);
@@ -58,6 +58,6 @@ export function stagingRoutes(deps) {
58
58
  }
59
59
  function detailOf(reason) {
60
60
  return reason === "empty-file"
61
- ? "The uploaded file is empty."
62
- : "The file name may not be empty or contain a path separator.";
61
+ ? "This file is empty (0 bytes). Choose a file that has content."
62
+ : "This file's name is empty or contains a slash. Rename the file on your computer, then upload it again.";
63
63
  }
@@ -23,7 +23,7 @@ export function storageRoutes(deps) {
23
23
  return problem(c, {
24
24
  status: 500,
25
25
  title: titleOf(500),
26
- detail: "The backup could not be created.",
26
+ detail: "Slopify could not create the backup. Check there is free disk space and try again; if it keeps failing, use Download diagnostics in Settings and report it.",
27
27
  });
28
28
  }
29
29
  })
@@ -33,14 +33,14 @@ export function storageRoutes(deps) {
33
33
  return problem(c, {
34
34
  status: 415,
35
35
  title: titleOf(415),
36
- detail: "Upload a Slopify backup ZIP.",
36
+ detail: "Choose a Slopify backup file (.zip) made with Backup & storage in Settings.",
37
37
  });
38
38
  const upload = await readPortableUpload(c.req.raw);
39
39
  if (!upload.ok)
40
40
  return problem(c, {
41
41
  status: 413,
42
42
  title: titleOf(413),
43
- detail: "The backup must be between 1 byte and 100 MB.",
43
+ detail: "This backup file is empty or larger than 100 MB. Choose a Slopify backup file under 100 MB.",
44
44
  });
45
45
  try {
46
46
  const imported = importPortable({
@@ -55,7 +55,7 @@ export function storageRoutes(deps) {
55
55
  return problem(c, {
56
56
  status: 400,
57
57
  title: titleOf(400),
58
- detail: "The backup is invalid or incomplete.",
58
+ detail: "This file is not a complete Slopify backup; it may be damaged or a different kind of ZIP. Choose a backup made with Backup & storage in Settings.",
59
59
  });
60
60
  }
61
61
  })
@@ -16,7 +16,7 @@ function revisionRequired(c) {
16
16
  return problem(c, {
17
17
  status: 409,
18
18
  title: titleOf(409),
19
- detail: "Open Edit project to save changes, then review the affected rebuild.",
19
+ detail: "This change is now made from the Edit tab. Choose Edit project, save your change, then use Rebuild affected outputs.",
20
20
  extensions: { reason: "revision-required" },
21
21
  });
22
22
  }
@@ -13,8 +13,8 @@ export function tutorialRoutes(deps) {
13
13
  status: 409,
14
14
  title: titleOf(409),
15
15
  detail: result.reason === "unreadable"
16
- ? "Saved tutorial progress cannot be read. Choose Restart tutorial to reset it."
17
- : "Tutorial progress changed. Reload the saved progress before trying again.",
16
+ ? "Your saved tutorial progress could not be read. Choose Restart tutorial to start it again."
17
+ : "Your tutorial progress changed in another tab or window. Reload the page, then try again.",
18
18
  extensions: { reason: result.reason },
19
19
  });
20
20
  return c.json(result.value);
@@ -8,7 +8,7 @@ export function updateRoutes(deps) {
8
8
  busy: false,
9
9
  canUpdate: false,
10
10
  status: "idle",
11
- blockedReason: "In-app updates are unavailable for this server.",
11
+ blockedReason: "This copy of Slopify cannot update itself from the app. Update it the way you installed it, for example by running the launcher again.",
12
12
  };
13
13
  return new Hono()
14
14
  .get("/ready", (c) => {
@@ -38,7 +38,7 @@ export function updateRoutes(deps) {
38
38
  return problem(c, {
39
39
  status: 403,
40
40
  title: titleOf(403),
41
- detail: "Start updates from the Slopify page on this server.",
41
+ detail: "For your safety, updates can only be started from the Slopify page itself. Open Slopify and try again there.",
42
42
  });
43
43
  if (deps.updater === undefined)
44
44
  return problem(c, { status: 409, title: titleOf(409), detail: unavailable.blockedReason });
@@ -51,7 +51,7 @@ export function updateRoutes(deps) {
51
51
  title: titleOf(status),
52
52
  detail: result.info.blockedReason ??
53
53
  result.info.error ??
54
- "No newer published update is available.",
54
+ "You already have the latest version of Slopify.",
55
55
  });
56
56
  });
57
57
  }
@@ -1,19 +1,41 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { access, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
3
- import { join } from "node:path";
3
+ import { basename, join } from "node:path";
4
4
  import { z } from "zod";
5
5
  import { isStableVersion, updatePackage } from "../updater/model.js";
6
6
  import { installArgs, npmCommand } from "../updater/plan.js";
7
7
  import { hasCode } from "./paths.js";
8
8
  export const nodeHostSetupRunner = {
9
9
  exec: (file, args, signal) => new Promise((resolve, reject) => {
10
- execFile(file, [...args], { signal, timeout: 15 * 60_000, maxBuffer: 1024 * 1024, windowsHide: true }, (error, stdout) => {
10
+ execFile(file, [...args], { signal, timeout: 15 * 60_000, maxBuffer: 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => {
11
11
  if (error && typeof error.code !== "number")
12
- return reject(new Error("Host setup command could not finish. Check Docker, Node and the systemd user service."));
13
- resolve({ code: typeof error?.code === "number" ? error.code : 0, stdout });
12
+ return reject(new Error(setupCommandFailure(file, args, error)));
13
+ resolve({ code: typeof error?.code === "number" ? error.code : 0, stdout, stderr });
14
14
  });
15
15
  }),
16
16
  };
17
+ // Says which program could not run and what to do about it; Node's own error only names the
18
+ // errno, and the person running the launcher needs the fix.
19
+ function setupCommandFailure(file, args, error) {
20
+ const name = basename(file);
21
+ const step = `"${[name, ...args.slice(0, 2)].join(" ")}"`;
22
+ if (error.code === "ENOENT") {
23
+ if (name === "docker")
24
+ return "Docker is not installed, or the docker command is not on your PATH. Install Docker Engine (https://docs.docker.com/engine/install/), check that docker info works, and run the launcher again.";
25
+ if (name === "systemctl" || name === "loginctl")
26
+ return `${name} was not found. Using this machine's AI CLIs from Docker needs systemd; start with --host-cli=off to use API keys only.`;
27
+ if (name === "npm" || name === "npm.cmd")
28
+ return "npm was not found. Install Node.js with npm (https://nodejs.org) and run the launcher again.";
29
+ return `${name} was not found on your PATH. Install it and run the launcher again.`;
30
+ }
31
+ if (error.code === "EACCES")
32
+ return `${file} could not be run (permission denied). Check that your user may run it, then try again.`;
33
+ if (error.code === "ABORT_ERR" || error.name === "AbortError")
34
+ return `${step} was stopped before it finished. Run the launcher again; Slopify will finish or undo the half-done step.`;
35
+ if (error.killed)
36
+ return `${step} took too long and was stopped. Check your internet connection and that Docker is running (docker info), then run the launcher again.`;
37
+ return `${step} could not finish (${error.message}). Check that Docker, Node.js and systemd user services work on this machine, then run the launcher again.`;
38
+ }
17
39
  async function entryAt(directory, version) {
18
40
  const path = join(directory, "node_modules", "@gentbajko", "slopify");
19
41
  z.object({ name: z.literal(updatePackage), version: z.literal(version) }).parse(JSON.parse(await readFile(join(path, "package.json"), "utf8")));
@@ -34,7 +56,7 @@ export async function installHostPackage(options) {
34
56
  }
35
57
  catch (error) {
36
58
  if (!hasCode(error, "ENOENT"))
37
- throw new Error("Existing host helper install is invalid. No service was changed.");
59
+ throw new Error(`The installed host helper in ${directory} is damaged. Delete that folder and run the launcher again. Nothing else was changed.`);
38
60
  }
39
61
  await mkdir(versions, { recursive: true, mode: 0o700 });
40
62
  const staging = await mkdtemp(join(versions, `.install-${version}-`));
@@ -50,7 +72,7 @@ export async function installHostPackage(options) {
50
72
  options.packageSource ?? `${updatePackage}@${version}`,
51
73
  ], signal);
52
74
  if (result.code !== 0)
53
- throw new Error("npm could not install the host helper. The existing container was not changed.");
75
+ throw new Error("npm could not install the Slopify host helper. Check your internet connection (npm must reach registry.npmjs.org) and run the launcher again. Your existing container was not changed.");
54
76
  await entryAt(staging, version);
55
77
  await rename(staging, directory);
56
78
  return { entry: await entryAt(directory, version) };
@@ -4,12 +4,12 @@ import { chmod, lstat, mkdir, open } from "node:fs/promises";
4
4
  import { isAbsolute, join, resolve } from "node:path";
5
5
  export async function prepareHostPaths(root) {
6
6
  if (!isAbsolute(root) || /[\p{Cc}]/u.test(root))
7
- throw new Error("Unsafe host helper directory.");
7
+ throw new Error(`Unsafe host helper directory ${JSON.stringify(root)}: it must be a full path. Check XDG_DATA_HOME and HOME, then run the launcher again.`);
8
8
  const normalized = resolve(root);
9
9
  const share = join(normalized, "share");
10
10
  const socket = join(share, "cli.sock");
11
11
  if (Buffer.byteLength(socket) > 100)
12
- throw new Error("Use a shorter XDG_DATA_HOME for the host helper socket (at most 100 bytes).");
12
+ throw new Error(`The host helper path ${socket} is too long for this system (over 100 bytes). Set a shorter XDG_DATA_HOME, for example XDG_DATA_HOME=~/.local/share, and run the launcher again.`);
13
13
  await mkdir(normalized, { recursive: true, mode: 0o700 });
14
14
  await ownedDirectory(normalized, true);
15
15
  await chmod(normalized, 0o700);
@@ -24,19 +24,19 @@ async function ownedDirectory(path, privateRoot = false) {
24
24
  entry.isSymbolicLink() ||
25
25
  (privateRoot && (entry.mode & 0o077) !== 0) ||
26
26
  (process.getuid && entry.uid !== process.getuid()))
27
- throw new Error("Unsafe host helper directory.");
27
+ throw new Error(`Unsafe host helper directory ${path}: it must be a real folder owned by you${privateRoot ? " that only you can read" : ""}. Fix its owner and permissions (chmod ${privateRoot ? "700" : "755"} ${path}) or delete it, then run the launcher again.`);
28
28
  }
29
29
  export async function readBridgeToken(path) {
30
30
  const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
31
31
  try {
32
32
  const entry = await file.stat();
33
33
  if (!entry.isFile() || entry.size !== 64 || (entry.mode & 0o022) !== 0)
34
- throw new Error("Invalid host helper token file.");
34
+ throw new Error(`The host helper token file ${path} is damaged. Delete it and run the launcher again.`);
35
35
  const buffer = Buffer.alloc(65);
36
36
  const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
37
37
  const value = buffer.subarray(0, bytesRead).toString("utf8");
38
38
  if (!/^[a-f0-9]{64}$/.test(value))
39
- throw new Error("Invalid host helper token file.");
39
+ throw new Error(`The host helper token file ${path} is damaged. Delete it and run the launcher again.`);
40
40
  return value;
41
41
  }
42
42
  finally {
@@ -60,7 +60,7 @@ export async function ensureBridgeToken(path) {
60
60
  }
61
61
  const entry = await lstat(path);
62
62
  if (entry.isSymbolicLink() || (process.getuid && entry.uid !== process.getuid()))
63
- throw new Error("Unsafe host helper token file.");
63
+ throw new Error(`The host helper token file ${path} is a link or belongs to another user. Delete it and run the launcher again.`);
64
64
  return readBridgeToken(path);
65
65
  }
66
66
  export function hasCode(error, code) {
@@ -19,7 +19,7 @@ export async function startHostServer(options) {
19
19
  if (!existing.isSocket() || (process.getuid && existing.uid !== process.getuid()))
20
20
  throw new Error("Unsafe host helper socket.");
21
21
  if (await listening(socket))
22
- throw new Error("A host helper is already listening.");
22
+ throw new Error(`A Slopify host helper is already listening on ${socket}. Stop it first (systemctl --user stop slopify-cli-bridge.service).`);
23
23
  await unlink(socket);
24
24
  }
25
25
  catch (error) {
@@ -39,11 +39,11 @@ export async function privateRead(path, uid) {
39
39
  try {
40
40
  const stat = await file.stat();
41
41
  if (!stat.isFile() || stat.uid !== uid || stat.size > 64 * 1024 || (stat.mode & 0o022) !== 0)
42
- throw new Error("Unsafe host helper configuration.");
42
+ throw new Error(`${path} must be a regular file owned by you that others can't write to. Fix it (chmod 600 ${path}) or delete it, then run the launcher again.`);
43
43
  const buffer = Buffer.alloc(64 * 1024 + 1);
44
44
  const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
45
45
  if (bytesRead > 64 * 1024)
46
- throw new Error("Host configuration too large.");
46
+ throw new Error(`${path} is too large to be a Slopify file. Delete it and run the launcher again.`);
47
47
  return buffer.subarray(0, bytesRead).toString("utf8");
48
48
  }
49
49
  finally {
@@ -106,35 +106,35 @@ async function waitHealth(options, accepting) {
106
106
  return health;
107
107
  await delay(100, undefined, { signal: options.signal });
108
108
  }
109
- throw new Error("Host helper did not become ready within 30 seconds.");
109
+ throw new Error(`The Slopify host helper did not start within 30 seconds. See why with: systemctl --user status ${hostUnit}`);
110
110
  }
111
111
  export async function ensureHostService(options) {
112
112
  const { root, env, runner, signal, uid } = options;
113
113
  const environment = JSON.stringify(hostEnvironment(env));
114
114
  const directory = join(env.XDG_CONFIG_HOME ?? join(env.HOME ?? "", ".config"), "systemd/user");
115
115
  if (!isAbsolute(directory))
116
- throw new Error("Host configuration directory must be absolute.");
116
+ throw new Error("Could not find your systemd user folder because HOME (or XDG_CONFIG_HOME) is not a full path. Set it and run the launcher again.");
117
117
  const unitPath = join(directory, hostUnit);
118
118
  const configPath = join(root, "host-environment.json");
119
119
  const previous = await privateRead(unitPath, uid);
120
120
  const previousConfig = await privateRead(configPath, uid);
121
121
  if (previous !== undefined && !previous.startsWith(`${marker}\n`))
122
- throw new Error("An existing host helper service is not owned by Slopify. It was not changed.");
122
+ throw new Error(`A service file ${unitPath} already exists and is not owned by Slopify, so it was left alone. Rename or remove it, or start with --host-cli=off to use API keys only.`);
123
123
  const unit = serviceUnit(options.node, options.entry, root);
124
124
  const run = async (file, args, required = true, commandSignal = signal) => {
125
125
  const result = await runner.exec(file, args, commandSignal);
126
126
  if (required && result.code !== 0)
127
- throw new Error(`Host helper setup failed (${file}). The existing container was not changed.`);
127
+ throw new Error(`Host helper setup failed at "${[file, ...args.slice(0, 2)].join(" ")}"${result.stderr?.trim() ? ` (${result.stderr.trim().split("\n").at(-1)?.slice(0, 300)})` : ""}. Check that systemd user services work (systemctl --user status), or start with --host-cli=off to use API keys only. Your existing container was not changed.`);
128
128
  return result;
129
129
  };
130
130
  const fragment = (await run("systemctl", ["--user", "show", hostUnit, "--property=FragmentPath", "--value"])).stdout.trim();
131
131
  if (fragment && fragment !== unitPath)
132
- throw new Error("An existing host helper service is not owned by this Slopify installation.");
132
+ throw new Error(`A ${hostUnit} service from somewhere else (${fragment}) is already installed and is not owned by this Slopify, so it was left alone. Remove it, or start with --host-cli=off to use API keys only.`);
133
133
  const active = (await run("systemctl", ["--user", "is-active", "--quiet", hostUnit], false)).code === 0;
134
134
  const wasEnabled = (await run("systemctl", ["--user", "is-enabled", "--quiet", hostUnit], false)).code === 0;
135
135
  const current = await helperHealth(root, signal);
136
136
  if (active && (!previous || !current))
137
- throw new Error("The running host helper cannot be verified. Check its service before updating; no work was interrupted.");
137
+ throw new Error(`The Slopify host helper is running but not answering, so it was not replaced. Restart it (systemctl --user restart ${hostUnit}) and run the launcher again; no work was interrupted.`);
138
138
  const linger = (await run("loginctl", ["show-user", String(uid), "-p", "Linger", "--value"])).stdout.trim();
139
139
  if (linger !== "yes")
140
140
  await run("loginctl", ["enable-linger", String(uid)]);
@@ -152,12 +152,12 @@ export async function ensureHostService(options) {
152
152
  paused = true;
153
153
  await run("systemctl", ["--user", "kill", "--kill-whom=main", "--signal=SIGHUP", hostUnit]);
154
154
  if ((await waitHealth(options, false)).active !== 0)
155
- throw new Error("The host helper is doing work. Wait for it to finish before updating.");
155
+ throw new Error("The host helper is doing work for Slopify right now. Wait for running projects to finish, then run the launcher again.");
156
156
  }
157
157
  await mkdir(directory, { recursive: true, mode: 0o700 });
158
158
  const directoryStat = await lstat(directory);
159
159
  if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink() || directoryStat.uid !== uid)
160
- throw new Error("Unsafe user service directory.");
160
+ throw new Error(`${directory} is not a normal folder owned by your user, so Slopify can't add its service there. Fix its owner and run the launcher again.`);
161
161
  replaced = true;
162
162
  await privateWrite(configPath, environment);
163
163
  await privateWrite(unitPath, unit);
@@ -193,7 +193,7 @@ export async function ensureHostService(options) {
193
193
  await restore(["--user", "kill", "--kill-whom=main", "--signal=SIGUSR2", hostUnit]);
194
194
  }
195
195
  catch {
196
- throw new Error("Host helper setup failed and rollback needs attention. Check slopify-cli-bridge.service; the Docker container was not changed.");
196
+ throw new Error(`Host helper setup failed and Slopify could not fully undo it. Check the service with systemctl --user status ${hostUnit}; your Docker container was not changed.`);
197
197
  }
198
198
  throw error;
199
199
  }
@@ -24,7 +24,9 @@ export async function resolveHostCommand(id, env) {
24
24
  throw error;
25
25
  }
26
26
  }
27
- throw Object.assign(new Error("The CLI was not found on the host PATH."), { code: "ENOENT" });
27
+ throw Object.assign(new Error(`${hostCommandName(id)} was not found on this machine's PATH.`), {
28
+ code: "ENOENT",
29
+ });
28
30
  }
29
31
  const authSchema = z.object({ loggedIn: z.boolean() });
30
32
  export function parseHostLogin(id, stdout, stderr) {
@@ -72,7 +74,9 @@ export function createHostStatus(deps) {
72
74
  installed: false,
73
75
  login: "unknown",
74
76
  issueKind: isCode(error, "ENOENT") ? "missing" : "bridge",
75
- issue: "The CLI could not be found on the host. Check its installation, then rerun the Docker launcher.",
77
+ issue: isCode(error, "ENOENT")
78
+ ? `${hostCommandName(id)} was not found on this machine. Install it and sign in there, then run the launcher again: npx @gentbajko/slopify --docker`
79
+ : `Slopify could not check for ${hostCommandName(id)} on this machine (${error instanceof Error ? error.message : String(error)}). Fix the problem, then run the launcher again: npx @gentbajko/slopify --docker`,
76
80
  };
77
81
  }
78
82
  const readiness = readinessFromProbe(await deps.probe(command, ["--version"], cliProbeTimeoutMs), {
@@ -96,7 +100,7 @@ export function createHostStatus(deps) {
96
100
  ...base,
97
101
  login: "unknown",
98
102
  issueKind: "missing",
99
- issue: "The host CLI did not answer. Check its installation and rerun the Docker launcher.",
103
+ issue: `${command} is installed but did not answer "${hostCommandName(id)} --version". Reinstall or update it on this machine, then run the launcher again: npx @gentbajko/slopify --docker`,
100
104
  };
101
105
  if (readiness.issue)
102
106
  return { ...base, login: "unknown", issueKind: "version", issue: readiness.issue };
@@ -7,19 +7,21 @@ export function configFrom(flags, env) {
7
7
  const host = flags.host ?? env.SLOPIFY_HOST ?? defaultHost;
8
8
  const dataDir = flags["data-dir"] ?? env.SLOPIFY_DATA_DIR ?? join(homedir(), ".slopify");
9
9
  if (host.trim() === "") {
10
- throw new Error("host must not be empty");
10
+ throw new Error(`The host setting is empty (from ${flags.host !== undefined ? "--host" : "SLOPIFY_HOST"}). Leave it out to use 127.0.0.1, or give an address such as --host 127.0.0.1.`);
11
11
  }
12
12
  return {
13
- port: port === undefined ? defaultPort : parsePort(port),
13
+ port: port === undefined
14
+ ? defaultPort
15
+ : parsePort(port, flags.port !== undefined ? "--port" : "SLOPIFY_PORT"),
14
16
  host,
15
17
  dataDir: resolve(dataDir),
16
18
  open: flags["no-open"] === true ? false : !isTruthy(env.SLOPIFY_NO_OPEN),
17
19
  };
18
20
  }
19
- function parsePort(value) {
21
+ function parsePort(value, source) {
20
22
  const port = Number(value.trim());
21
23
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
22
- throw new Error(`invalid port ${JSON.stringify(value)}: expected an integer between 1 and 65535`);
24
+ throw new Error(`Invalid port ${JSON.stringify(value)} (from ${source}): use a whole number between 1 and 65535, for example --port 7070.`);
23
25
  }
24
26
  return port;
25
27
  }
@@ -12,7 +12,8 @@ export function migrate(db, clock) {
12
12
  const newest = Math.max(0, ...applied);
13
13
  const newestKnown = Math.max(0, ...known);
14
14
  if (newest > newestKnown) {
15
- throw new Error(`database schema ${newest} is newer than this app knows (${newestKnown})`);
15
+ throw new Error(`Your Slopify database was last used by a newer version of Slopify (database schema ${newest} is newer than this app knows (${newestKnown})). ` +
16
+ "Update Slopify and start it again: npx @gentbajko/slopify@latest (or pull the latest Docker image). Your data was not changed.");
16
17
  }
17
18
  for (const [index, file] of files.entries()) {
18
19
  const version = known[index];
@@ -0,0 +1,4 @@
1
+ -- A schedule's items became a queue of topics: each run fills one template keyword with the
2
+ -- first topic and the rest of the keywords with fixed values.
3
+ ALTER TABLE schedules ADD COLUMN topic_keyword TEXT;
4
+ ALTER TABLE schedules ADD COLUMN values_json TEXT NOT NULL DEFAULT '{}' CHECK(json_valid(values_json));
@@ -0,0 +1,12 @@
1
+ // A failure's own sentence and every cause it wraps, for the log. The page shows only the
2
+ // sentence; the chain is the only place a "download interrupted" says the server closed the
3
+ // stream.
4
+ export function causedBy(error) {
5
+ const chain = [error instanceof Error ? error.message : String(error)];
6
+ let cause = error instanceof Error ? error.cause : undefined;
7
+ while (cause !== undefined && cause !== null && chain.length < 6) {
8
+ chain.push(cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause));
9
+ cause = cause instanceof Error ? cause.cause : undefined;
10
+ }
11
+ return chain.join(" <- ");
12
+ }
@@ -6,14 +6,15 @@ export function acquireInstanceLock(lockPath) {
6
6
  if (!claim(lockPath, pid)) {
7
7
  const holder = holderOf(lockPath);
8
8
  if (holder !== undefined && holds(holder, pid)) {
9
- throw new Error(`another Slopify instance is already running on this data directory (pid ${holder.pid}). ` +
10
- `Stop it, or delete ${lockPath} if it is stale.`);
9
+ throw new Error(`Slopify is already running on this data directory (process ${holder.pid}). ` +
10
+ "Use the copy that is already open, or stop it (Ctrl+C in its terminal) and start again. " +
11
+ `If no Slopify is running, delete ${lockPath} and try again.`);
11
12
  }
12
13
  // The recorded process is gone, or the file says nothing usable. Reclaiming it here
13
14
  // is what keeps a crash from bricking the data directory until a file is deleted by hand.
14
15
  discard(lockPath);
15
16
  if (!claim(lockPath, pid)) {
16
- throw new Error(`could not take the instance lock at ${lockPath}: another process was faster`);
17
+ throw new Error(`Another Slopify started on this data directory at the same moment (lock file ${lockPath}). Close the extra copy and start Slopify again.`);
17
18
  }
18
19
  }
19
20
  return {