crewly 1.20.131 → 1.20.133

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 (186) hide show
  1. package/config/constants.ts +49 -0
  2. package/config/index.ts +2 -0
  3. package/config/skills/agent/core/find-skill/SKILL.md +58 -0
  4. package/config/skills/agent/core/find-skill/execute.sh +67 -0
  5. package/config/skills/agent/core/find-skill/execute.test.sh +69 -0
  6. package/config/skills/agent/core/generate-pdf/SKILL.md +24 -29
  7. package/config/skills/agent/core/generate-pdf/execute.sh +22 -78
  8. package/config/skills/agent/core/generate-pdf/execute.test.sh +45 -0
  9. package/config/skills/agent/core/install-skill/SKILL.md +67 -0
  10. package/config/skills/agent/core/install-skill/execute.sh +80 -0
  11. package/config/skills/agent/core/install-skill/execute.test.sh +74 -0
  12. package/config/skills/agent/pdf-tools/SKILL.md +103 -0
  13. package/config/skills/agent/pdf-tools/execute.sh +301 -0
  14. package/config/skills/agent/pdf-tools/execute.test.sh +218 -0
  15. package/config/skills/agent/pdf-tools/instructions.md +14 -0
  16. package/config/skills/agent/pdf-tools/pdf_tools.py +268 -0
  17. package/config/skills/agent/pdf-tools/skill.json +103 -0
  18. package/config/skills/agent/pdf-tools/style.css +143 -0
  19. package/config/skills/agent/transcribe-audio/SKILL.md +42 -5
  20. package/config/skills/agent/transcribe-audio/execute.sh +44 -11
  21. package/config/skills/agent/transcribe-audio/execute.test.sh +90 -0
  22. package/config/skills/agent/transcribe-audio/install-whisper-cpp.sh +126 -0
  23. package/config/skills/agent/transcribe-audio/install-whisper-cpp.test.sh +69 -0
  24. package/config/skills/agent/transcribe-audio/skill.json +67 -6
  25. package/config/skills/marketplace-registry.ts +8 -1
  26. package/config/skills/registry.json +337 -3
  27. package/dist/backend/backend/src/constants.d.ts +71 -0
  28. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  29. package/dist/backend/backend/src/constants.js +96 -0
  30. package/dist/backend/backend/src/constants.js.map +1 -1
  31. package/dist/backend/backend/src/controllers/cloud/cloud-device-pairing.controller.d.ts +56 -0
  32. package/dist/backend/backend/src/controllers/cloud/cloud-device-pairing.controller.d.ts.map +1 -0
  33. package/dist/backend/backend/src/controllers/cloud/cloud-device-pairing.controller.js +114 -0
  34. package/dist/backend/backend/src/controllers/cloud/cloud-device-pairing.controller.js.map +1 -0
  35. package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts +25 -0
  36. package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts.map +1 -1
  37. package/dist/backend/backend/src/controllers/cloud/cloud.controller.js +73 -55
  38. package/dist/backend/backend/src/controllers/cloud/cloud.controller.js.map +1 -1
  39. package/dist/backend/backend/src/controllers/cloud/cloud.routes.d.ts +3 -0
  40. package/dist/backend/backend/src/controllers/cloud/cloud.routes.d.ts.map +1 -1
  41. package/dist/backend/backend/src/controllers/cloud/cloud.routes.js +9 -0
  42. package/dist/backend/backend/src/controllers/cloud/cloud.routes.js.map +1 -1
  43. package/dist/backend/backend/src/controllers/skill-setup/skill-setup.controller.d.ts +49 -0
  44. package/dist/backend/backend/src/controllers/skill-setup/skill-setup.controller.d.ts.map +1 -0
  45. package/dist/backend/backend/src/controllers/skill-setup/skill-setup.controller.js +185 -0
  46. package/dist/backend/backend/src/controllers/skill-setup/skill-setup.controller.js.map +1 -0
  47. package/dist/backend/backend/src/controllers/skill-setup/skill-setup.routes.d.ts +21 -0
  48. package/dist/backend/backend/src/controllers/skill-setup/skill-setup.routes.d.ts.map +1 -0
  49. package/dist/backend/backend/src/controllers/skill-setup/skill-setup.routes.js +29 -0
  50. package/dist/backend/backend/src/controllers/skill-setup/skill-setup.routes.js.map +1 -0
  51. package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
  52. package/dist/backend/backend/src/routes/api.routes.js +5 -0
  53. package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
  54. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts +14 -0
  55. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
  56. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js +33 -1
  57. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
  58. package/dist/backend/backend/src/services/cloud/cloud-device-pairing.client.d.ts +132 -0
  59. package/dist/backend/backend/src/services/cloud/cloud-device-pairing.client.d.ts.map +1 -0
  60. package/dist/backend/backend/src/services/cloud/cloud-device-pairing.client.js +228 -0
  61. package/dist/backend/backend/src/services/cloud/cloud-device-pairing.client.js.map +1 -0
  62. package/dist/backend/backend/src/services/cloud/cloud-device-pairing.service.d.ts +115 -0
  63. package/dist/backend/backend/src/services/cloud/cloud-device-pairing.service.d.ts.map +1 -0
  64. package/dist/backend/backend/src/services/cloud/cloud-device-pairing.service.js +160 -0
  65. package/dist/backend/backend/src/services/cloud/cloud-device-pairing.service.js.map +1 -0
  66. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts.map +1 -1
  67. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js +8 -0
  68. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js.map +1 -1
  69. package/dist/backend/backend/src/services/marketplace/marketplace.service.d.ts.map +1 -1
  70. package/dist/backend/backend/src/services/marketplace/marketplace.service.js +11 -3
  71. package/dist/backend/backend/src/services/marketplace/marketplace.service.js.map +1 -1
  72. package/dist/backend/backend/src/services/skill-setup/skill-discovery.service.d.ts +281 -0
  73. package/dist/backend/backend/src/services/skill-setup/skill-discovery.service.d.ts.map +1 -0
  74. package/dist/backend/backend/src/services/skill-setup/skill-discovery.service.js +559 -0
  75. package/dist/backend/backend/src/services/skill-setup/skill-discovery.service.js.map +1 -0
  76. package/dist/backend/backend/src/services/skill-setup/skill-install-job.service.d.ts +243 -0
  77. package/dist/backend/backend/src/services/skill-setup/skill-install-job.service.d.ts.map +1 -0
  78. package/dist/backend/backend/src/services/skill-setup/skill-install-job.service.js +439 -0
  79. package/dist/backend/backend/src/services/skill-setup/skill-install-job.service.js.map +1 -0
  80. package/dist/backend/backend/src/services/skill-setup/skill-setup-manifest.d.ts +138 -0
  81. package/dist/backend/backend/src/services/skill-setup/skill-setup-manifest.d.ts.map +1 -0
  82. package/dist/backend/backend/src/services/skill-setup/skill-setup-manifest.js +281 -0
  83. package/dist/backend/backend/src/services/skill-setup/skill-setup-manifest.js.map +1 -0
  84. package/dist/backend/backend/src/services/skill-setup/skill-setup-runner.service.d.ts +338 -0
  85. package/dist/backend/backend/src/services/skill-setup/skill-setup-runner.service.d.ts.map +1 -0
  86. package/dist/backend/backend/src/services/skill-setup/skill-setup-runner.service.js +761 -0
  87. package/dist/backend/backend/src/services/skill-setup/skill-setup-runner.service.js.map +1 -0
  88. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
  89. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js +6 -0
  90. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
  91. package/dist/backend/backend/src/types/marketplace.types.d.ts +16 -1
  92. package/dist/backend/backend/src/types/marketplace.types.d.ts.map +1 -1
  93. package/dist/backend/backend/src/utils/inbound-file-hint.utils.d.ts +55 -0
  94. package/dist/backend/backend/src/utils/inbound-file-hint.utils.d.ts.map +1 -0
  95. package/dist/backend/backend/src/utils/inbound-file-hint.utils.js +78 -0
  96. package/dist/backend/backend/src/utils/inbound-file-hint.utils.js.map +1 -0
  97. package/dist/backend/build-info.json +2 -2
  98. package/dist/backend/config/constants.d.ts +48 -0
  99. package/dist/backend/config/constants.d.ts.map +1 -1
  100. package/dist/backend/config/constants.js +48 -0
  101. package/dist/backend/config/constants.js.map +1 -1
  102. package/dist/backend/config/index.d.ts +2 -2
  103. package/dist/backend/config/index.d.ts.map +1 -1
  104. package/dist/backend/config/index.js +2 -2
  105. package/dist/backend/config/index.js.map +1 -1
  106. package/dist/cli/backend/src/constants.d.ts +71 -0
  107. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  108. package/dist/cli/backend/src/constants.js +96 -0
  109. package/dist/cli/backend/src/constants.js.map +1 -1
  110. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts +14 -0
  111. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
  112. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js +33 -1
  113. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
  114. package/dist/cli/backend/src/services/cloud/cloud-device-pairing.client.d.ts +132 -0
  115. package/dist/cli/backend/src/services/cloud/cloud-device-pairing.client.d.ts.map +1 -0
  116. package/dist/cli/backend/src/services/cloud/cloud-device-pairing.client.js +228 -0
  117. package/dist/cli/backend/src/services/cloud/cloud-device-pairing.client.js.map +1 -0
  118. package/dist/cli/backend/src/services/marketplace/marketplace.service.d.ts +101 -0
  119. package/dist/cli/backend/src/services/marketplace/marketplace.service.d.ts.map +1 -0
  120. package/dist/cli/backend/src/services/marketplace/marketplace.service.js +392 -0
  121. package/dist/cli/backend/src/services/marketplace/marketplace.service.js.map +1 -0
  122. package/dist/cli/backend/src/services/skill-setup/skill-discovery.service.d.ts +281 -0
  123. package/dist/cli/backend/src/services/skill-setup/skill-discovery.service.d.ts.map +1 -0
  124. package/dist/cli/backend/src/services/skill-setup/skill-discovery.service.js +559 -0
  125. package/dist/cli/backend/src/services/skill-setup/skill-discovery.service.js.map +1 -0
  126. package/dist/cli/backend/src/services/skill-setup/skill-setup-manifest.d.ts +138 -0
  127. package/dist/cli/backend/src/services/skill-setup/skill-setup-manifest.d.ts.map +1 -0
  128. package/dist/cli/backend/src/services/skill-setup/skill-setup-manifest.js +281 -0
  129. package/dist/cli/backend/src/services/skill-setup/skill-setup-manifest.js.map +1 -0
  130. package/dist/cli/backend/src/services/skill-setup/skill-setup-runner.service.d.ts +338 -0
  131. package/dist/cli/backend/src/services/skill-setup/skill-setup-runner.service.d.ts.map +1 -0
  132. package/dist/cli/backend/src/services/skill-setup/skill-setup-runner.service.js +761 -0
  133. package/dist/cli/backend/src/services/skill-setup/skill-setup-runner.service.js.map +1 -0
  134. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
  135. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js +6 -0
  136. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
  137. package/dist/cli/backend/src/types/marketplace.types.d.ts +219 -0
  138. package/dist/cli/backend/src/types/marketplace.types.d.ts.map +1 -0
  139. package/dist/cli/backend/src/types/marketplace.types.js +6 -0
  140. package/dist/cli/backend/src/types/marketplace.types.js.map +1 -0
  141. package/dist/cli/backend/src/utils/inbound-file-hint.utils.d.ts +55 -0
  142. package/dist/cli/backend/src/utils/inbound-file-hint.utils.d.ts.map +1 -0
  143. package/dist/cli/backend/src/utils/inbound-file-hint.utils.js +78 -0
  144. package/dist/cli/backend/src/utils/inbound-file-hint.utils.js.map +1 -0
  145. package/dist/cli/backend/src/utils/package-root.d.ts +15 -0
  146. package/dist/cli/backend/src/utils/package-root.d.ts.map +1 -0
  147. package/dist/cli/backend/src/utils/package-root.js +38 -0
  148. package/dist/cli/backend/src/utils/package-root.js.map +1 -0
  149. package/dist/cli/cli/src/commands/cloud.d.ts +45 -25
  150. package/dist/cli/cli/src/commands/cloud.d.ts.map +1 -1
  151. package/dist/cli/cli/src/commands/cloud.js +153 -47
  152. package/dist/cli/cli/src/commands/cloud.js.map +1 -1
  153. package/dist/cli/cli/src/commands/install.d.ts +13 -1
  154. package/dist/cli/cli/src/commands/install.d.ts.map +1 -1
  155. package/dist/cli/cli/src/commands/install.js +40 -3
  156. package/dist/cli/cli/src/commands/install.js.map +1 -1
  157. package/dist/cli/cli/src/commands/onboard-checklist.js +3 -3
  158. package/dist/cli/cli/src/commands/onboard-checklist.js.map +1 -1
  159. package/dist/cli/cli/src/commands/skills.d.ts +58 -0
  160. package/dist/cli/cli/src/commands/skills.d.ts.map +1 -0
  161. package/dist/cli/cli/src/commands/skills.js +132 -0
  162. package/dist/cli/cli/src/commands/skills.js.map +1 -0
  163. package/dist/cli/cli/src/index.js +18 -7
  164. package/dist/cli/cli/src/index.js.map +1 -1
  165. package/dist/cli/cli/src/utils/archive-creator.d.ts +14 -0
  166. package/dist/cli/cli/src/utils/archive-creator.d.ts.map +1 -1
  167. package/dist/cli/cli/src/utils/archive-creator.js +24 -3
  168. package/dist/cli/cli/src/utils/archive-creator.js.map +1 -1
  169. package/dist/cli/cli/src/utils/cloud-submit.d.ts.map +1 -1
  170. package/dist/cli/cli/src/utils/cloud-submit.js +14 -5
  171. package/dist/cli/cli/src/utils/cloud-submit.js.map +1 -1
  172. package/dist/cli/cli/src/utils/package-validator.d.ts +2 -0
  173. package/dist/cli/cli/src/utils/package-validator.d.ts.map +1 -1
  174. package/dist/cli/cli/src/utils/package-validator.js +16 -0
  175. package/dist/cli/cli/src/utils/package-validator.js.map +1 -1
  176. package/dist/cli/config/constants.d.ts +48 -0
  177. package/dist/cli/config/constants.d.ts.map +1 -1
  178. package/dist/cli/config/constants.js +48 -0
  179. package/dist/cli/config/constants.js.map +1 -1
  180. package/dist/cli/config/index.d.ts +2 -2
  181. package/dist/cli/config/index.d.ts.map +1 -1
  182. package/dist/cli/config/index.js +2 -2
  183. package/dist/cli/config/index.js.map +1 -1
  184. package/frontend/dist/assets/{index-5b98bf77.js → index-e6c403b6.js} +265 -265
  185. package/frontend/dist/index.html +1 -1
  186. package/package.json +1 -1
@@ -0,0 +1,761 @@
1
+ /**
2
+ * Skill setup runner — performs a skill's `setup` block, idempotently.
3
+ *
4
+ * Every step is checked first and reported "already satisfied" when it is,
5
+ * so running setup twice is cheap and changes nothing the second time. A step
6
+ * that is missing is installed and then checked again; "installed" is only
7
+ * reported when the re-check passes.
8
+ *
9
+ * Safety properties (specs/skill-auto-install.md):
10
+ * - **Never hangs on a password prompt.** Every child gets a closed stdin;
11
+ * apt-get runs only as root or through `sudo -n` (passwordless sudo). With
12
+ * neither, the step fails at once with a message saying what to run by hand.
13
+ * - **Downloads are verified before they are used.** A file is streamed to a
14
+ * temp name next to its destination, its size and sha256 are compared with
15
+ * the manifest, and only then renamed into place. A mismatch deletes the
16
+ * temp file and fails the step.
17
+ * - **One setup per skill at a time.** Concurrent calls in this process share
18
+ * one run; another process (the CLI while the backend installs) waits on a
19
+ * lock file under `$CREWLY_HOME/skill-setup/locks/`. A lock whose pid is
20
+ * gone, or that is older than LOCK_STALE_MS, is reclaimed.
21
+ * - **Everything is logged** to `$CREWLY_HOME/logs/skill-setup/<skill>.log`,
22
+ * including the full output of every install command.
23
+ *
24
+ * @module services/skill-setup/skill-setup-runner.service
25
+ */
26
+ import { createHash } from 'crypto';
27
+ import * as fs from 'fs';
28
+ import * as os from 'os';
29
+ import * as path from 'path';
30
+ import { SKILL_SETUP_CONSTANTS } from '../../constants.js';
31
+ import { getCrewlyHomePath } from '../core/crewly-home.utils.js';
32
+ import { resolveExecutable, runCommand } from '../harness/harness-exec.utils.js';
33
+ import { DEST_PREFIXES, recipeFor, } from './skill-setup-manifest.js';
34
+ /** The one-line command a person would run for an apt install. */
35
+ const aptHint = (packages) => `sudo apt-get install -y ${packages.join(' ')}`;
36
+ /**
37
+ * Detect the OS family.
38
+ *
39
+ * @returns `darwin`, `debian` (Linux with /etc/debian_version) or `linux`
40
+ */
41
+ export function detectOsFamily() {
42
+ if (process.platform === 'darwin')
43
+ return 'darwin';
44
+ return fs.existsSync(SKILL_SETUP_CONSTANTS.DEBIAN_MARKER_FILE) ? 'debian' : 'linux';
45
+ }
46
+ /**
47
+ * Whether a pid belongs to a running process.
48
+ *
49
+ * @param pid - Process id
50
+ * @returns True when signal 0 can be delivered (or is refused with EPERM)
51
+ */
52
+ export function isPidAlive(pid) {
53
+ try {
54
+ process.kill(pid, 0);
55
+ return true;
56
+ }
57
+ catch (error) {
58
+ return error.code === 'EPERM';
59
+ }
60
+ }
61
+ /**
62
+ * Free bytes on the filesystem holding a directory.
63
+ *
64
+ * @param dir - An existing directory
65
+ * @returns Bytes available to this user, or null when statfs is unavailable
66
+ */
67
+ function defaultFreeBytes(dir) {
68
+ try {
69
+ const stats = fs.statfsSync(dir);
70
+ return Number(stats.bavail) * Number(stats.bsize);
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ /**
77
+ * Download with fetch, streaming to disk and hashing on the way.
78
+ *
79
+ * @param url - Source URL (redirects are followed)
80
+ * @param destPath - Destination file
81
+ * @param onBytes - Progress callback (running byte count)
82
+ * @param stallTimeoutMs - Abort after this long without data
83
+ * @returns Bytes and sha256
84
+ * @throws On HTTP errors, stalls and write errors
85
+ */
86
+ export const defaultDownload = async (url, destPath, onBytes, stallTimeoutMs) => {
87
+ const controller = new AbortController();
88
+ let stallTimer = null;
89
+ const arm = () => {
90
+ if (stallTimer)
91
+ clearTimeout(stallTimer);
92
+ stallTimer = setTimeout(() => controller.abort(new Error(`download stalled for ${stallTimeoutMs} ms`)), stallTimeoutMs);
93
+ };
94
+ arm();
95
+ const hash = createHash('sha256');
96
+ let bytes = 0;
97
+ const out = fs.createWriteStream(destPath);
98
+ try {
99
+ const res = await fetch(url, { redirect: 'follow', signal: controller.signal });
100
+ if (!res.ok || !res.body)
101
+ throw new Error(`HTTP ${res.status} ${res.statusText}`.trim());
102
+ const reader = res.body.getReader();
103
+ for (;;) {
104
+ const { done, value } = await reader.read();
105
+ if (done)
106
+ break;
107
+ arm();
108
+ hash.update(value);
109
+ bytes += value.length;
110
+ if (!out.write(value))
111
+ await new Promise((resolve) => out.once('drain', () => resolve()));
112
+ onBytes(bytes);
113
+ }
114
+ await new Promise((resolve, reject) => out.end((err) => (err ? reject(err) : resolve())));
115
+ return { bytes, sha256: hash.digest('hex') };
116
+ }
117
+ catch (error) {
118
+ out.destroy();
119
+ const reason = controller.signal.aborted && controller.signal.reason instanceof Error ? controller.signal.reason : error;
120
+ throw reason instanceof Error ? reason : new Error(String(reason));
121
+ }
122
+ finally {
123
+ if (stallTimer)
124
+ clearTimeout(stallTimer);
125
+ }
126
+ };
127
+ /** Error raised when another process holds a skill's setup lock for too long. */
128
+ export class SetupLockTimeoutError extends Error {
129
+ skillId;
130
+ holderPid;
131
+ /**
132
+ * @param skillId - Skill whose lock is held
133
+ * @param holderPid - Pid recorded in the lock file
134
+ */
135
+ constructor(skillId, holderPid) {
136
+ super(`Another setup of ${skillId} is still running (pid ${holderPid}); gave up waiting for it`);
137
+ this.skillId = skillId;
138
+ this.holderPid = holderPid;
139
+ this.name = 'SetupLockTimeoutError';
140
+ }
141
+ }
142
+ /** Runs setup manifests. */
143
+ export class SkillSetupRunner {
144
+ run;
145
+ download;
146
+ osFamily;
147
+ isRoot;
148
+ crewlyHome;
149
+ homeDir;
150
+ env;
151
+ now;
152
+ pid;
153
+ pidAlive;
154
+ freeBytes;
155
+ sleep;
156
+ lockPollMs;
157
+ lockWaitMs;
158
+ extraCommandDirs;
159
+ brewCandidates;
160
+ /** Setup runs in flight in this process, by skill id */
161
+ inflight = new Map();
162
+ /**
163
+ * @param deps - Injectable dependencies (all optional)
164
+ */
165
+ constructor(deps = {}) {
166
+ this.run = deps.run ?? runCommand;
167
+ this.download = deps.download ?? defaultDownload;
168
+ this.osFamily = deps.osFamily ?? detectOsFamily;
169
+ this.isRoot = deps.isRoot ?? (() => typeof process.getuid === 'function' && process.getuid() === 0);
170
+ this.crewlyHome = deps.crewlyHome ?? getCrewlyHomePath;
171
+ this.homeDir = deps.homeDir ?? os.homedir;
172
+ this.env = deps.env ?? process.env;
173
+ this.now = deps.now ?? Date.now;
174
+ this.pid = deps.pid ?? process.pid;
175
+ this.pidAlive = deps.isPidAlive ?? isPidAlive;
176
+ this.freeBytes = deps.freeBytes ?? defaultFreeBytes;
177
+ this.sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
178
+ this.lockPollMs = deps.lockPollMs ?? SKILL_SETUP_CONSTANTS.LOCK_POLL_MS;
179
+ this.lockWaitMs = deps.lockWaitMs ?? SKILL_SETUP_CONSTANTS.LOCK_WAIT_MS;
180
+ this.extraCommandDirs = deps.extraCommandDirs ?? SKILL_SETUP_CONSTANTS.EXTRA_COMMAND_DIRS;
181
+ this.brewCandidates = deps.brewCandidates ?? SKILL_SETUP_CONSTANTS.BREW_CANDIDATES;
182
+ }
183
+ /**
184
+ * Path of a skill's setup log.
185
+ *
186
+ * @param skillId - Skill id
187
+ * @returns `$CREWLY_HOME/logs/skill-setup/<id>.log`
188
+ */
189
+ logFileFor(skillId) {
190
+ return path.join(this.crewlyHome(), SKILL_SETUP_CONSTANTS.LOG_DIR, `${skillId}.log`);
191
+ }
192
+ /**
193
+ * Expand `~/` and `$CREWLY_HOME/` in a manifest path.
194
+ *
195
+ * @param p - Manifest path
196
+ * @returns Absolute path
197
+ */
198
+ expandPath(p) {
199
+ if (p.startsWith(DEST_PREFIXES[0]))
200
+ return path.join(this.homeDir(), p.slice(DEST_PREFIXES[0].length));
201
+ if (p.startsWith(DEST_PREFIXES[1]))
202
+ return path.join(this.crewlyHome(), p.slice(DEST_PREFIXES[1].length));
203
+ return p;
204
+ }
205
+ /**
206
+ * Perform (or only check) a setup manifest.
207
+ *
208
+ * Concurrent calls for the same skill in this process share one run.
209
+ *
210
+ * @param input - Skill id, directory, manifest, mode and progress callback
211
+ * @returns Per-step results; never rejects for step failures
212
+ *
213
+ * @example
214
+ * ```ts
215
+ * const result = await runner.runSetup({ skillId: 'transcribe-audio', skillDir, manifest });
216
+ * if (!result.success) console.error(result.error, 'see', result.logFile);
217
+ * ```
218
+ */
219
+ runSetup(input) {
220
+ if (input.checkOnly)
221
+ return this.execute(input);
222
+ const running = this.inflight.get(input.skillId);
223
+ if (running)
224
+ return running;
225
+ const promise = this.execute(input).finally(() => this.inflight.delete(input.skillId));
226
+ this.inflight.set(input.skillId, promise);
227
+ return promise;
228
+ }
229
+ /**
230
+ * Run every step (under the lock unless check-only).
231
+ *
232
+ * @param input - Run input
233
+ * @returns The result
234
+ */
235
+ async execute(input) {
236
+ const started = this.now();
237
+ const checkOnly = input.checkOnly === true;
238
+ const logFile = checkOnly ? '' : this.logFileFor(input.skillId);
239
+ const emit = (stepId, phase, message) => {
240
+ if (!checkOnly)
241
+ this.log(logFile, input.skillId, stepId, `${phase}: ${message}`);
242
+ input.onProgress?.({ skillId: input.skillId, stepId, phase, message });
243
+ };
244
+ const finish = (steps, error) => {
245
+ const firstProblem = steps.find((s) => !s.optional && (s.status === 'failed' || s.status === 'missing'));
246
+ const result = {
247
+ skillId: input.skillId,
248
+ success: !error && !firstProblem,
249
+ checkOnly,
250
+ steps,
251
+ logFile,
252
+ durationMs: this.now() - started,
253
+ ...(error || firstProblem ? { error: error ?? `${firstProblem?.id}: ${firstProblem?.message}` } : {}),
254
+ };
255
+ if (!checkOnly)
256
+ emit('setup', result.success ? 'installed' : 'failed', result.success ? `done in ${Math.round(result.durationMs / 1000)}s` : (result.error ?? 'failed'));
257
+ return result;
258
+ };
259
+ let release = null;
260
+ if (!checkOnly) {
261
+ emit('setup', 'checking', `setup of ${input.skillId} started (os=${this.osFamily()}, pid=${this.pid})`);
262
+ try {
263
+ release = await this.acquireLock(input.skillId, (message) => emit('setup', 'waiting', message));
264
+ }
265
+ catch (error) {
266
+ return finish([], error instanceof Error ? error.message : String(error));
267
+ }
268
+ }
269
+ const steps = [];
270
+ try {
271
+ for (const step of input.manifest.steps) {
272
+ emit(step.id, 'checking', step.description ?? step.type);
273
+ let result;
274
+ try {
275
+ result = await this.runStep(step, input, emit);
276
+ }
277
+ catch (error) {
278
+ result = this.stepResult(step, 'failed', error instanceof Error ? error.message : String(error));
279
+ }
280
+ if (result.status === 'failed' && result.optional) {
281
+ result = { ...result, status: 'skipped', message: `optional, skipped: ${result.message}` };
282
+ }
283
+ else if (result.status === 'missing' && result.optional) {
284
+ result = { ...result, message: `optional: ${result.message}` };
285
+ }
286
+ steps.push(result);
287
+ emit(step.id, result.status, result.message);
288
+ if (!checkOnly && result.status === 'failed')
289
+ break;
290
+ }
291
+ }
292
+ finally {
293
+ release?.();
294
+ }
295
+ return finish(steps);
296
+ }
297
+ /**
298
+ * Dispatch one step.
299
+ *
300
+ * @param step - Step
301
+ * @param input - Run input
302
+ * @param emit - Progress emitter
303
+ * @returns Step result
304
+ */
305
+ runStep(step, input, emit) {
306
+ switch (step.type) {
307
+ case 'command':
308
+ return this.runCommandStep(step, input, emit);
309
+ case 'file':
310
+ return this.runFileStep(step, input, emit);
311
+ case 'python':
312
+ return this.runPythonStep(step, input, emit);
313
+ }
314
+ }
315
+ /**
316
+ * Build a step result.
317
+ *
318
+ * @param step - Step
319
+ * @param status - Final status
320
+ * @param message - Message
321
+ * @returns The result
322
+ */
323
+ stepResult(step, status, message) {
324
+ return { id: step.id, type: step.type, status, message, optional: step.optional === true };
325
+ }
326
+ /**
327
+ * PATH used for checks and installs: `$CREWLY_HOME/bin`, the process PATH,
328
+ * then the usual system and Homebrew dirs (a backend started by launchd or
329
+ * systemd often lacks /opt/homebrew/bin).
330
+ *
331
+ * @returns PATH string
332
+ */
333
+ searchPath() {
334
+ const entries = [
335
+ path.join(this.crewlyHome(), SKILL_SETUP_CONSTANTS.BIN_DIR),
336
+ ...(this.env.PATH ?? '').split(path.delimiter),
337
+ ...this.extraCommandDirs,
338
+ ].filter((e) => e.length > 0);
339
+ return [...new Set(entries)].join(path.delimiter);
340
+ }
341
+ /**
342
+ * Environment for child processes.
343
+ *
344
+ * @param extra - Additional variables
345
+ * @returns Environment
346
+ */
347
+ childEnv(extra = {}) {
348
+ return {
349
+ ...this.env,
350
+ PATH: this.searchPath(),
351
+ CREWLY_HOME: this.crewlyHome(),
352
+ CREWLY_BIN_DIR: path.join(this.crewlyHome(), SKILL_SETUP_CONSTANTS.BIN_DIR),
353
+ HOMEBREW_NO_AUTO_UPDATE: '1',
354
+ HOMEBREW_NO_ENV_HINTS: '1',
355
+ NONINTERACTIVE: '1',
356
+ DEBIAN_FRONTEND: 'noninteractive',
357
+ ...extra,
358
+ };
359
+ }
360
+ /**
361
+ * Run a command, streaming its output into the log.
362
+ *
363
+ * @param logFile - Log file ('' = do not log)
364
+ * @param skillId - Skill id
365
+ * @param stepId - Step id
366
+ * @param command - Executable
367
+ * @param args - Arguments
368
+ * @param timeoutMs - Timeout
369
+ * @param env - Environment
370
+ * @returns Exit code and combined output
371
+ */
372
+ async exec(logFile, skillId, stepId, command, args, timeoutMs, env = this.childEnv()) {
373
+ if (logFile)
374
+ this.log(logFile, skillId, stepId, `$ ${[command, ...args].join(' ')}`);
375
+ const result = await this.run(command, args, {
376
+ env,
377
+ timeoutMs,
378
+ onOutput: logFile ? (chunk) => this.appendRaw(logFile, chunk) : undefined,
379
+ });
380
+ const output = `${result.stdout}\n${result.stderr}\n${result.error ?? ''}`.trim();
381
+ return { ok: result.code === 0, output };
382
+ }
383
+ /**
384
+ * What satisfies a command check, if anything.
385
+ *
386
+ * @param step - Command step
387
+ * @returns A description of the match, or null
388
+ */
389
+ async commandPresent(step) {
390
+ const searchPath = this.searchPath();
391
+ for (const command of step.check.commands ?? []) {
392
+ const found = resolveExecutable(command, searchPath);
393
+ if (found)
394
+ return found;
395
+ }
396
+ for (const p of step.check.paths ?? []) {
397
+ const abs = this.expandPath(p);
398
+ try {
399
+ fs.accessSync(abs, fs.constants.X_OK);
400
+ return abs;
401
+ }
402
+ catch {
403
+ // not there — try the next one
404
+ }
405
+ }
406
+ if (step.check.shell) {
407
+ const res = await this.run('bash', ['-c', step.check.shell], {
408
+ env: this.childEnv(),
409
+ timeoutMs: SKILL_SETUP_CONSTANTS.CHECK_TIMEOUT_MS,
410
+ });
411
+ if (res.code === 0)
412
+ return 'check passed';
413
+ }
414
+ return null;
415
+ }
416
+ /**
417
+ * How this process can run apt-get.
418
+ *
419
+ * @param packages - Packages (for the hint)
420
+ * @returns The command + argument prefix, or an error message
421
+ */
422
+ async aptAccess(packages) {
423
+ if (this.isRoot())
424
+ return { command: 'apt-get', prefix: [] };
425
+ const sudo = resolveExecutable('sudo', this.searchPath());
426
+ if (sudo) {
427
+ const probe = await this.run(sudo, ['-n', 'true'], { env: this.childEnv(), timeoutMs: SKILL_SETUP_CONSTANTS.CHECK_TIMEOUT_MS });
428
+ if (probe.code === 0)
429
+ return { command: sudo, prefix: ['-n', 'env', 'DEBIAN_FRONTEND=noninteractive', 'apt-get'] };
430
+ }
431
+ return {
432
+ error: `installing ${packages.join(', ')} needs root (apt-get), and this process is not root and has no passwordless sudo, ` +
433
+ `so Crewly did not try (it would hang on a password prompt). Ask the owner to run: ${aptHint(packages)} — then run the setup again.`,
434
+ };
435
+ }
436
+ /**
437
+ * The value handed to install scripts in CREWLY_SUDO: '' (root),
438
+ * 'sudo -n' (passwordless sudo) or 'unavailable'.
439
+ *
440
+ * @returns Sudo mode
441
+ */
442
+ async sudoMode() {
443
+ if (this.isRoot())
444
+ return '';
445
+ const sudo = resolveExecutable('sudo', this.searchPath());
446
+ if (!sudo)
447
+ return 'unavailable';
448
+ const probe = await this.run(sudo, ['-n', 'true'], { env: this.childEnv(), timeoutMs: SKILL_SETUP_CONSTANTS.CHECK_TIMEOUT_MS });
449
+ return probe.code === 0 ? 'sudo -n' : 'unavailable';
450
+ }
451
+ /**
452
+ * Check, install and re-check a command dependency.
453
+ *
454
+ * @param step - Command step
455
+ * @param input - Run input
456
+ * @param emit - Progress emitter
457
+ * @returns Step result
458
+ */
459
+ async runCommandStep(step, input, emit) {
460
+ const present = await this.commandPresent(step);
461
+ if (present)
462
+ return this.stepResult(step, 'satisfied', `already satisfied (${present})`);
463
+ const family = this.osFamily();
464
+ const recipe = recipeFor(step, family);
465
+ if (input.checkOnly)
466
+ return this.stepResult(step, 'missing', `not installed${recipe ? '' : ` (no automatic install on ${family})`}`);
467
+ if (!recipe) {
468
+ return this.stepResult(step, 'failed', `not installed, and there is no automatic install on ${family}${step.manualHint ? `. ${step.manualHint}` : ''}`);
469
+ }
470
+ const logFile = this.logFileFor(input.skillId);
471
+ if (recipe.brew || recipe.brewCask) {
472
+ if (this.isRoot())
473
+ return this.stepResult(step, 'failed', 'Homebrew refuses to run as root; run the setup as the normal user');
474
+ const brew = resolveExecutable('brew', this.searchPath()) ?? this.brewCandidates.find((c) => fs.existsSync(c));
475
+ if (!brew) {
476
+ const pkgs = [...(recipe.brew ?? []), ...(recipe.brewCask ?? [])].join(' ');
477
+ return this.stepResult(step, 'failed', `Homebrew is not installed. Install it from https://brew.sh, then run: brew install ${pkgs}`);
478
+ }
479
+ if (recipe.brew?.length) {
480
+ emit(step.id, 'installing', `brew install ${recipe.brew.join(' ')}`);
481
+ const res = await this.exec(logFile, input.skillId, step.id, brew, ['install', ...recipe.brew], SKILL_SETUP_CONSTANTS.PACKAGE_INSTALL_TIMEOUT_MS);
482
+ if (!res.ok)
483
+ return this.stepResult(step, 'failed', `brew install ${recipe.brew.join(' ')} failed: ${tail(res.output)}`);
484
+ }
485
+ if (recipe.brewCask?.length) {
486
+ emit(step.id, 'installing', `brew install --cask ${recipe.brewCask.join(' ')}`);
487
+ const res = await this.exec(logFile, input.skillId, step.id, brew, ['install', '--cask', ...recipe.brewCask], SKILL_SETUP_CONSTANTS.PACKAGE_INSTALL_TIMEOUT_MS);
488
+ if (!res.ok)
489
+ return this.stepResult(step, 'failed', `brew install --cask ${recipe.brewCask.join(' ')} failed: ${tail(res.output)}`);
490
+ }
491
+ }
492
+ if (recipe.apt?.length) {
493
+ const access = await this.aptAccess(recipe.apt);
494
+ if ('error' in access)
495
+ return this.stepResult(step, 'failed', access.error);
496
+ emit(step.id, 'installing', `apt-get install -y ${recipe.apt.join(' ')}`);
497
+ const installArgs = [...access.prefix, 'install', '-y', '--no-install-recommends', ...recipe.apt];
498
+ let res = await this.exec(logFile, input.skillId, step.id, access.command, installArgs, SKILL_SETUP_CONSTANTS.PACKAGE_INSTALL_TIMEOUT_MS);
499
+ if (!res.ok) {
500
+ // A fresh machine or container often has no package lists yet.
501
+ emit(step.id, 'installing', 'apt-get update, then retry');
502
+ await this.exec(logFile, input.skillId, step.id, access.command, [...access.prefix, 'update'], SKILL_SETUP_CONSTANTS.PACKAGE_INSTALL_TIMEOUT_MS);
503
+ res = await this.exec(logFile, input.skillId, step.id, access.command, installArgs, SKILL_SETUP_CONSTANTS.PACKAGE_INSTALL_TIMEOUT_MS);
504
+ }
505
+ if (!res.ok)
506
+ return this.stepResult(step, 'failed', `apt-get install ${recipe.apt.join(' ')} failed: ${tail(res.output)}`);
507
+ }
508
+ if (recipe.script) {
509
+ const script = path.join(input.skillDir, recipe.script);
510
+ if (!fs.existsSync(script))
511
+ return this.stepResult(step, 'failed', `install script ${recipe.script} is missing from ${input.skillDir}`);
512
+ emit(step.id, 'installing', `bash ${recipe.script}`);
513
+ const env = this.childEnv({ SKILL_DIR: input.skillDir, CREWLY_SUDO: await this.sudoMode() });
514
+ fs.mkdirSync(path.join(this.crewlyHome(), SKILL_SETUP_CONSTANTS.BIN_DIR), { recursive: true });
515
+ const res = await this.exec(logFile, input.skillId, step.id, 'bash', [script], SKILL_SETUP_CONSTANTS.SCRIPT_INSTALL_TIMEOUT_MS, env);
516
+ if (!res.ok)
517
+ return this.stepResult(step, 'failed', `${recipe.script} failed: ${tail(res.output)}`);
518
+ }
519
+ const after = await this.commandPresent(step);
520
+ return after
521
+ ? this.stepResult(step, 'installed', `installed (${after})`)
522
+ : this.stepResult(step, 'failed', 'the install finished but the check still fails');
523
+ }
524
+ /**
525
+ * Check, download, verify and place a file.
526
+ *
527
+ * @param step - File step
528
+ * @param input - Run input
529
+ * @param emit - Progress emitter
530
+ * @returns Step result
531
+ */
532
+ async runFileStep(step, input, emit) {
533
+ const dest = this.expandPath(step.dest);
534
+ for (const candidate of [dest, ...(step.alternatives ?? []).map((p) => this.expandPath(p))]) {
535
+ try {
536
+ // Size, not sha256: hashing a 500 MB model on every probe is too slow.
537
+ // The sha256 was checked when Crewly downloaded it.
538
+ if (fs.statSync(candidate).size === step.sizeBytes)
539
+ return this.stepResult(step, 'satisfied', `already satisfied (${candidate})`);
540
+ }
541
+ catch {
542
+ // not there — try the next one
543
+ }
544
+ }
545
+ if (input.checkOnly)
546
+ return this.stepResult(step, 'missing', `not downloaded (${formatSize(step.sizeBytes)})`);
547
+ const dir = path.dirname(dest);
548
+ fs.mkdirSync(dir, { recursive: true });
549
+ const free = this.freeBytes(dir);
550
+ const needed = Math.ceil(step.sizeBytes * (1 + SKILL_SETUP_CONSTANTS.DOWNLOAD_FREE_SPACE_MARGIN));
551
+ if (free !== null && free < needed) {
552
+ return this.stepResult(step, 'failed', `not enough disk space in ${dir}: need ${formatSize(needed)}, have ${formatSize(free)}`);
553
+ }
554
+ const tmp = `${dest}.part-${this.pid}`;
555
+ emit(step.id, 'downloading', `${step.url} (${formatSize(step.sizeBytes)}) → ${dest}`);
556
+ let lastStep = -1;
557
+ let result;
558
+ try {
559
+ result = await this.download(step.url, tmp, (received) => {
560
+ const pct = Math.floor((received / step.sizeBytes) * 100);
561
+ const bucket = Math.floor(pct / SKILL_SETUP_CONSTANTS.DOWNLOAD_PROGRESS_STEP_PERCENT);
562
+ if (bucket > lastStep) {
563
+ lastStep = bucket;
564
+ emit(step.id, 'downloading', `${Math.min(pct, 100)}% (${formatSize(received)})`);
565
+ }
566
+ }, SKILL_SETUP_CONSTANTS.DOWNLOAD_STALL_TIMEOUT_MS);
567
+ }
568
+ catch (error) {
569
+ removeQuietly(tmp);
570
+ return this.stepResult(step, 'failed', `download failed: ${error instanceof Error ? error.message : String(error)}`);
571
+ }
572
+ if (result.bytes !== step.sizeBytes || result.sha256 !== step.sha256) {
573
+ removeQuietly(tmp);
574
+ return this.stepResult(step, 'failed', `checksum mismatch — expected sha256 ${step.sha256} (${step.sizeBytes} bytes), got ${result.sha256} (${result.bytes} bytes); the download was deleted`);
575
+ }
576
+ fs.renameSync(tmp, dest);
577
+ return this.stepResult(step, 'installed', `downloaded and verified (${dest})`);
578
+ }
579
+ /**
580
+ * Check, create and fill a Python virtualenv.
581
+ *
582
+ * @param step - Python step
583
+ * @param input - Run input
584
+ * @param emit - Progress emitter
585
+ * @returns Step result
586
+ */
587
+ async runPythonStep(step, input, emit) {
588
+ const venvDir = path.join(this.crewlyHome(), SKILL_SETUP_CONSTANTS.VENV_DIR, step.venv ?? input.skillId);
589
+ const py = path.join(venvDir, 'bin', 'python3');
590
+ const importsOk = async () => {
591
+ if (!fs.existsSync(py))
592
+ return false;
593
+ const res = await this.run(py, ['-c', `import ${step.imports.join(', ')}`], {
594
+ env: this.childEnv(),
595
+ timeoutMs: SKILL_SETUP_CONSTANTS.CHECK_TIMEOUT_MS,
596
+ });
597
+ return res.code === 0;
598
+ };
599
+ if (await importsOk())
600
+ return this.stepResult(step, 'satisfied', `already satisfied (${venvDir})`);
601
+ if (input.checkOnly)
602
+ return this.stepResult(step, 'missing', `Python packages missing: ${step.packages.join(' ')}`);
603
+ const logFile = this.logFileFor(input.skillId);
604
+ if (!fs.existsSync(py)) {
605
+ const python3 = resolveExecutable('python3', this.searchPath());
606
+ if (!python3) {
607
+ const hint = this.osFamily() === 'darwin' ? 'brew install python' : aptHint(['python3', 'python3-venv']);
608
+ return this.stepResult(step, 'failed', `python3 is not installed. Install it (${hint}) and run the setup again`);
609
+ }
610
+ emit(step.id, 'installing', `python3 -m venv ${venvDir}`);
611
+ const res = await this.exec(logFile, input.skillId, step.id, python3, ['-m', 'venv', venvDir], SKILL_SETUP_CONSTANTS.PACKAGE_INSTALL_TIMEOUT_MS);
612
+ if (!res.ok) {
613
+ const hint = this.osFamily() === 'darwin' ? '' : ` (on Debian/Ubuntu: ${aptHint(['python3-venv'])})`;
614
+ return this.stepResult(step, 'failed', `could not create the venv${hint}: ${tail(res.output)}`);
615
+ }
616
+ }
617
+ emit(step.id, 'installing', `pip install ${step.packages.join(' ')}`);
618
+ const pip = await this.exec(logFile, input.skillId, step.id, py, ['-m', 'pip', 'install', '--disable-pip-version-check', '--quiet', ...step.packages], SKILL_SETUP_CONSTANTS.PACKAGE_INSTALL_TIMEOUT_MS);
619
+ if (!pip.ok)
620
+ return this.stepResult(step, 'failed', `pip install failed: ${tail(pip.output)}`);
621
+ return (await importsOk())
622
+ ? this.stepResult(step, 'installed', `installed into ${venvDir}`)
623
+ : this.stepResult(step, 'failed', `pip finished but \`import ${step.imports.join(', ')}\` still fails`);
624
+ }
625
+ /**
626
+ * Take the per-skill lock file, waiting while another live process holds it.
627
+ *
628
+ * @param skillId - Skill id
629
+ * @param onWait - Called once when the lock is busy
630
+ * @returns A release function
631
+ * @throws SetupLockTimeoutError when the holder does not finish in lockWaitMs
632
+ */
633
+ async acquireLock(skillId, onWait) {
634
+ const dir = path.join(this.crewlyHome(), SKILL_SETUP_CONSTANTS.STATE_DIR, SKILL_SETUP_CONSTANTS.LOCKS_SUBDIR);
635
+ fs.mkdirSync(dir, { recursive: true });
636
+ const file = path.join(dir, `${skillId}.lock`);
637
+ const deadline = this.now() + this.lockWaitMs;
638
+ let announced = false;
639
+ for (;;) {
640
+ try {
641
+ const fd = fs.openSync(file, 'wx');
642
+ fs.writeSync(fd, JSON.stringify({ pid: this.pid, startedAt: this.now() }));
643
+ fs.closeSync(fd);
644
+ return () => {
645
+ try {
646
+ const holder = JSON.parse(fs.readFileSync(file, 'utf-8'));
647
+ if (holder.pid === this.pid)
648
+ fs.unlinkSync(file);
649
+ }
650
+ catch {
651
+ // already gone
652
+ }
653
+ };
654
+ }
655
+ catch (error) {
656
+ if (error.code !== 'EEXIST')
657
+ throw error;
658
+ }
659
+ let holder = null;
660
+ try {
661
+ holder = JSON.parse(fs.readFileSync(file, 'utf-8'));
662
+ }
663
+ catch {
664
+ holder = null;
665
+ }
666
+ const stale = !holder ||
667
+ typeof holder.pid !== 'number' ||
668
+ !this.pidAlive(holder.pid) ||
669
+ typeof holder.startedAt !== 'number' ||
670
+ this.now() - holder.startedAt > SKILL_SETUP_CONSTANTS.LOCK_STALE_MS;
671
+ if (stale) {
672
+ removeQuietly(file);
673
+ continue;
674
+ }
675
+ const holderPid = holder?.pid ?? -1;
676
+ if (this.now() >= deadline)
677
+ throw new SetupLockTimeoutError(skillId, holderPid);
678
+ if (!announced) {
679
+ announced = true;
680
+ onWait(`another setup of ${skillId} is running (pid ${holderPid}); waiting for it to finish`);
681
+ }
682
+ await this.sleep(this.lockPollMs);
683
+ }
684
+ }
685
+ /**
686
+ * Append a timestamped line to a log file.
687
+ *
688
+ * @param logFile - Log file
689
+ * @param skillId - Skill id
690
+ * @param stepId - Step id
691
+ * @param message - Message
692
+ */
693
+ log(logFile, skillId, stepId, message) {
694
+ this.appendRaw(logFile, `[${new Date(this.now()).toISOString()}] [${skillId}] [${stepId}] ${message}\n`);
695
+ }
696
+ /**
697
+ * Append text to a log file (best effort).
698
+ *
699
+ * @param logFile - Log file
700
+ * @param text - Text
701
+ */
702
+ appendRaw(logFile, text) {
703
+ try {
704
+ fs.mkdirSync(path.dirname(logFile), { recursive: true });
705
+ fs.appendFileSync(logFile, text);
706
+ }
707
+ catch {
708
+ // Logging must never break a setup.
709
+ }
710
+ }
711
+ }
712
+ /**
713
+ * Last part of command output, for a one-line failure message.
714
+ *
715
+ * @param output - Combined output
716
+ * @returns Up to the last 300 characters, single-spaced
717
+ */
718
+ function tail(output) {
719
+ const flat = output.replace(/\s+/g, ' ').trim();
720
+ return flat.length > 300 ? `…${flat.slice(-300)}` : flat || '(no output)';
721
+ }
722
+ /**
723
+ * Human-readable byte size.
724
+ *
725
+ * @param bytes - Size
726
+ * @returns e.g. "547.4 MB"
727
+ */
728
+ export function formatSize(bytes) {
729
+ if (bytes < 1024)
730
+ return `${bytes} B`;
731
+ if (bytes < 1024 * 1024)
732
+ return `${(bytes / 1024).toFixed(1)} KB`;
733
+ if (bytes < 1024 * 1024 * 1024)
734
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
735
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
736
+ }
737
+ /**
738
+ * Delete a file, ignoring errors.
739
+ *
740
+ * @param file - Path
741
+ */
742
+ function removeQuietly(file) {
743
+ try {
744
+ fs.unlinkSync(file);
745
+ }
746
+ catch {
747
+ // already gone
748
+ }
749
+ }
750
+ let runnerSingleton = null;
751
+ /**
752
+ * The process-wide runner (shares in-flight runs across callers).
753
+ *
754
+ * @returns The runner
755
+ */
756
+ export function getSkillSetupRunner() {
757
+ if (!runnerSingleton)
758
+ runnerSingleton = new SkillSetupRunner();
759
+ return runnerSingleton;
760
+ }
761
+ //# sourceMappingURL=skill-setup-runner.service.js.map