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.
- package/config/constants.ts +49 -0
- package/config/index.ts +2 -0
- package/config/skills/agent/core/find-skill/SKILL.md +58 -0
- package/config/skills/agent/core/find-skill/execute.sh +67 -0
- package/config/skills/agent/core/find-skill/execute.test.sh +69 -0
- package/config/skills/agent/core/generate-pdf/SKILL.md +24 -29
- package/config/skills/agent/core/generate-pdf/execute.sh +22 -78
- package/config/skills/agent/core/generate-pdf/execute.test.sh +45 -0
- package/config/skills/agent/core/install-skill/SKILL.md +67 -0
- package/config/skills/agent/core/install-skill/execute.sh +80 -0
- package/config/skills/agent/core/install-skill/execute.test.sh +74 -0
- package/config/skills/agent/pdf-tools/SKILL.md +103 -0
- package/config/skills/agent/pdf-tools/execute.sh +301 -0
- package/config/skills/agent/pdf-tools/execute.test.sh +218 -0
- package/config/skills/agent/pdf-tools/instructions.md +14 -0
- package/config/skills/agent/pdf-tools/pdf_tools.py +268 -0
- package/config/skills/agent/pdf-tools/skill.json +103 -0
- package/config/skills/agent/pdf-tools/style.css +143 -0
- package/config/skills/agent/transcribe-audio/SKILL.md +42 -5
- package/config/skills/agent/transcribe-audio/execute.sh +44 -11
- package/config/skills/agent/transcribe-audio/execute.test.sh +90 -0
- package/config/skills/agent/transcribe-audio/install-whisper-cpp.sh +126 -0
- package/config/skills/agent/transcribe-audio/install-whisper-cpp.test.sh +69 -0
- package/config/skills/agent/transcribe-audio/skill.json +67 -6
- package/config/skills/marketplace-registry.ts +8 -1
- package/config/skills/registry.json +337 -3
- package/dist/backend/backend/src/constants.d.ts +71 -0
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +96 -0
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud-device-pairing.controller.d.ts +56 -0
- package/dist/backend/backend/src/controllers/cloud/cloud-device-pairing.controller.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/cloud/cloud-device-pairing.controller.js +114 -0
- package/dist/backend/backend/src/controllers/cloud/cloud-device-pairing.controller.js.map +1 -0
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts +25 -0
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.js +73 -55
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.routes.d.ts +3 -0
- package/dist/backend/backend/src/controllers/cloud/cloud.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.routes.js +9 -0
- package/dist/backend/backend/src/controllers/cloud/cloud.routes.js.map +1 -1
- package/dist/backend/backend/src/controllers/skill-setup/skill-setup.controller.d.ts +49 -0
- package/dist/backend/backend/src/controllers/skill-setup/skill-setup.controller.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/skill-setup/skill-setup.controller.js +185 -0
- package/dist/backend/backend/src/controllers/skill-setup/skill-setup.controller.js.map +1 -0
- package/dist/backend/backend/src/controllers/skill-setup/skill-setup.routes.d.ts +21 -0
- package/dist/backend/backend/src/controllers/skill-setup/skill-setup.routes.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/skill-setup/skill-setup.routes.js +29 -0
- package/dist/backend/backend/src/controllers/skill-setup/skill-setup.routes.js.map +1 -0
- package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/routes/api.routes.js +5 -0
- package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts +14 -0
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js +33 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
- package/dist/backend/backend/src/services/cloud/cloud-device-pairing.client.d.ts +132 -0
- package/dist/backend/backend/src/services/cloud/cloud-device-pairing.client.d.ts.map +1 -0
- package/dist/backend/backend/src/services/cloud/cloud-device-pairing.client.js +228 -0
- package/dist/backend/backend/src/services/cloud/cloud-device-pairing.client.js.map +1 -0
- package/dist/backend/backend/src/services/cloud/cloud-device-pairing.service.d.ts +115 -0
- package/dist/backend/backend/src/services/cloud/cloud-device-pairing.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/cloud/cloud-device-pairing.service.js +160 -0
- package/dist/backend/backend/src/services/cloud/cloud-device-pairing.service.js.map +1 -0
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js +8 -0
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js.map +1 -1
- package/dist/backend/backend/src/services/marketplace/marketplace.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/marketplace/marketplace.service.js +11 -3
- package/dist/backend/backend/src/services/marketplace/marketplace.service.js.map +1 -1
- package/dist/backend/backend/src/services/skill-setup/skill-discovery.service.d.ts +281 -0
- package/dist/backend/backend/src/services/skill-setup/skill-discovery.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/skill-setup/skill-discovery.service.js +559 -0
- package/dist/backend/backend/src/services/skill-setup/skill-discovery.service.js.map +1 -0
- package/dist/backend/backend/src/services/skill-setup/skill-install-job.service.d.ts +243 -0
- package/dist/backend/backend/src/services/skill-setup/skill-install-job.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/skill-setup/skill-install-job.service.js +439 -0
- package/dist/backend/backend/src/services/skill-setup/skill-install-job.service.js.map +1 -0
- package/dist/backend/backend/src/services/skill-setup/skill-setup-manifest.d.ts +138 -0
- package/dist/backend/backend/src/services/skill-setup/skill-setup-manifest.d.ts.map +1 -0
- package/dist/backend/backend/src/services/skill-setup/skill-setup-manifest.js +281 -0
- package/dist/backend/backend/src/services/skill-setup/skill-setup-manifest.js.map +1 -0
- package/dist/backend/backend/src/services/skill-setup/skill-setup-runner.service.d.ts +338 -0
- package/dist/backend/backend/src/services/skill-setup/skill-setup-runner.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/skill-setup/skill-setup-runner.service.js +761 -0
- package/dist/backend/backend/src/services/skill-setup/skill-setup-runner.service.js.map +1 -0
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js +6 -0
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
- package/dist/backend/backend/src/types/marketplace.types.d.ts +16 -1
- package/dist/backend/backend/src/types/marketplace.types.d.ts.map +1 -1
- package/dist/backend/backend/src/utils/inbound-file-hint.utils.d.ts +55 -0
- package/dist/backend/backend/src/utils/inbound-file-hint.utils.d.ts.map +1 -0
- package/dist/backend/backend/src/utils/inbound-file-hint.utils.js +78 -0
- package/dist/backend/backend/src/utils/inbound-file-hint.utils.js.map +1 -0
- package/dist/backend/build-info.json +2 -2
- package/dist/backend/config/constants.d.ts +48 -0
- package/dist/backend/config/constants.d.ts.map +1 -1
- package/dist/backend/config/constants.js +48 -0
- package/dist/backend/config/constants.js.map +1 -1
- package/dist/backend/config/index.d.ts +2 -2
- package/dist/backend/config/index.d.ts.map +1 -1
- package/dist/backend/config/index.js +2 -2
- package/dist/backend/config/index.js.map +1 -1
- package/dist/cli/backend/src/constants.d.ts +71 -0
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +96 -0
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts +14 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js +33 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
- package/dist/cli/backend/src/services/cloud/cloud-device-pairing.client.d.ts +132 -0
- package/dist/cli/backend/src/services/cloud/cloud-device-pairing.client.d.ts.map +1 -0
- package/dist/cli/backend/src/services/cloud/cloud-device-pairing.client.js +228 -0
- package/dist/cli/backend/src/services/cloud/cloud-device-pairing.client.js.map +1 -0
- package/dist/cli/backend/src/services/marketplace/marketplace.service.d.ts +101 -0
- package/dist/cli/backend/src/services/marketplace/marketplace.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/marketplace/marketplace.service.js +392 -0
- package/dist/cli/backend/src/services/marketplace/marketplace.service.js.map +1 -0
- package/dist/cli/backend/src/services/skill-setup/skill-discovery.service.d.ts +281 -0
- package/dist/cli/backend/src/services/skill-setup/skill-discovery.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/skill-setup/skill-discovery.service.js +559 -0
- package/dist/cli/backend/src/services/skill-setup/skill-discovery.service.js.map +1 -0
- package/dist/cli/backend/src/services/skill-setup/skill-setup-manifest.d.ts +138 -0
- package/dist/cli/backend/src/services/skill-setup/skill-setup-manifest.d.ts.map +1 -0
- package/dist/cli/backend/src/services/skill-setup/skill-setup-manifest.js +281 -0
- package/dist/cli/backend/src/services/skill-setup/skill-setup-manifest.js.map +1 -0
- package/dist/cli/backend/src/services/skill-setup/skill-setup-runner.service.d.ts +338 -0
- package/dist/cli/backend/src/services/skill-setup/skill-setup-runner.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/skill-setup/skill-setup-runner.service.js +761 -0
- package/dist/cli/backend/src/services/skill-setup/skill-setup-runner.service.js.map +1 -0
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js +6 -0
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
- package/dist/cli/backend/src/types/marketplace.types.d.ts +219 -0
- package/dist/cli/backend/src/types/marketplace.types.d.ts.map +1 -0
- package/dist/cli/backend/src/types/marketplace.types.js +6 -0
- package/dist/cli/backend/src/types/marketplace.types.js.map +1 -0
- package/dist/cli/backend/src/utils/inbound-file-hint.utils.d.ts +55 -0
- package/dist/cli/backend/src/utils/inbound-file-hint.utils.d.ts.map +1 -0
- package/dist/cli/backend/src/utils/inbound-file-hint.utils.js +78 -0
- package/dist/cli/backend/src/utils/inbound-file-hint.utils.js.map +1 -0
- package/dist/cli/backend/src/utils/package-root.d.ts +15 -0
- package/dist/cli/backend/src/utils/package-root.d.ts.map +1 -0
- package/dist/cli/backend/src/utils/package-root.js +38 -0
- package/dist/cli/backend/src/utils/package-root.js.map +1 -0
- package/dist/cli/cli/src/commands/cloud.d.ts +45 -25
- package/dist/cli/cli/src/commands/cloud.d.ts.map +1 -1
- package/dist/cli/cli/src/commands/cloud.js +153 -47
- package/dist/cli/cli/src/commands/cloud.js.map +1 -1
- package/dist/cli/cli/src/commands/install.d.ts +13 -1
- package/dist/cli/cli/src/commands/install.d.ts.map +1 -1
- package/dist/cli/cli/src/commands/install.js +40 -3
- package/dist/cli/cli/src/commands/install.js.map +1 -1
- package/dist/cli/cli/src/commands/onboard-checklist.js +3 -3
- package/dist/cli/cli/src/commands/onboard-checklist.js.map +1 -1
- package/dist/cli/cli/src/commands/skills.d.ts +58 -0
- package/dist/cli/cli/src/commands/skills.d.ts.map +1 -0
- package/dist/cli/cli/src/commands/skills.js +132 -0
- package/dist/cli/cli/src/commands/skills.js.map +1 -0
- package/dist/cli/cli/src/index.js +18 -7
- package/dist/cli/cli/src/index.js.map +1 -1
- package/dist/cli/cli/src/utils/archive-creator.d.ts +14 -0
- package/dist/cli/cli/src/utils/archive-creator.d.ts.map +1 -1
- package/dist/cli/cli/src/utils/archive-creator.js +24 -3
- package/dist/cli/cli/src/utils/archive-creator.js.map +1 -1
- package/dist/cli/cli/src/utils/cloud-submit.d.ts.map +1 -1
- package/dist/cli/cli/src/utils/cloud-submit.js +14 -5
- package/dist/cli/cli/src/utils/cloud-submit.js.map +1 -1
- package/dist/cli/cli/src/utils/package-validator.d.ts +2 -0
- package/dist/cli/cli/src/utils/package-validator.d.ts.map +1 -1
- package/dist/cli/cli/src/utils/package-validator.js +16 -0
- package/dist/cli/cli/src/utils/package-validator.js.map +1 -1
- package/dist/cli/config/constants.d.ts +48 -0
- package/dist/cli/config/constants.d.ts.map +1 -1
- package/dist/cli/config/constants.js +48 -0
- package/dist/cli/config/constants.js.map +1 -1
- package/dist/cli/config/index.d.ts +2 -2
- package/dist/cli/config/index.d.ts.map +1 -1
- package/dist/cli/config/index.js +2 -2
- package/dist/cli/config/index.js.map +1 -1
- package/frontend/dist/assets/{index-5b98bf77.js → index-e6c403b6.js} +265 -265
- package/frontend/dist/index.html +1 -1
- 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
|