@vividcodeai/embeddedcowork 0.0.5 → 0.0.8
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/dist/config/schema.js +1 -0
- package/dist/events/bus.js +2 -0
- package/dist/index.js +30 -0
- package/dist/opencode-downloader.js +295 -0
- package/dist/opencode-paths.js +167 -0
- package/dist/server/http-server.js +2 -0
- package/dist/server/routes/opencode-status.js +10 -0
- package/dist/workspaces/manager.js +68 -56
- package/package.json +3 -1
- package/public/assets/{ChangesTab-C2DJXDf9.js → ChangesTab-C4_zdV74.js} +2 -2
- package/public/assets/{DiffToolbar-De-3SRCF.js → DiffToolbar-BsUPigM5.js} +1 -1
- package/public/assets/{FilesTab-BuQ00MEc.js → FilesTab-Drvo30nM.js} +2 -2
- package/public/assets/{GitChangesTab-D9bf2jkM.js → GitChangesTab-BhV6qTfP.js} +2 -2
- package/public/assets/{SplitFilePanel-B-3h60o2.js → SplitFilePanel-BBrs329I.js} +1 -1
- package/public/assets/{StatusTab-D5s19fRN.js → StatusTab-DfYWlTSX.js} +1 -1
- package/public/assets/{bundle-full-CdNbUxmo.js → bundle-full-CAZqmV2E.js} +1 -1
- package/public/assets/{diff-viewer-B1l_VZc1.js → diff-viewer--UIZ5GEn.js} +1 -1
- package/public/assets/{index-C9Tl2tHH.js → index--0EL0_Tu.js} +2 -2
- package/public/assets/{index-BErmCqgL.js → index-BLouhjer.js} +1 -1
- package/public/assets/{index-ixx_g9gD.js → index-CgjnEdBS.js} +1 -1
- package/public/assets/{index-BQeBs108.js → index-ChCPoe9m.js} +1 -1
- package/public/assets/{index-B2LsA7hD.js → index-Co-dJ-Xs.js} +1 -1
- package/public/assets/index-Ctcq8Rhl.css +1 -0
- package/public/assets/{index-BqQARTCd.js → index-Da0V-sLI.js} +1 -1
- package/public/assets/{index-BKvZBimW.js → index-hOT6sqTO.js} +1 -1
- package/public/assets/{loading-CQjaT4lJ.js → loading-CvW03p4Z.js} +1 -1
- package/public/assets/main-ByuKWHRz.js +53 -0
- package/public/assets/{markdown-D5eIdNMf.js → markdown-BBFC6uy4.js} +3 -3
- package/public/assets/{monaco-viewer-9Byc1Kpy.js → monaco-viewer-DnczYBfh.js} +8 -8
- package/public/assets/{todo-uxdyLWei.js → todo-BLpUqy61.js} +1 -1
- package/public/assets/{tool-call-C_JEoVSV.js → tool-call-B4Xz6FbC.js} +3 -3
- package/public/assets/{unified-picker-ePaJEYDm.js → unified-picker-BrzM_sH6.js} +1 -1
- package/public/assets/{wrap-text-S8HH4qqP.js → wrap-text-B1i7zgLk.js} +1 -1
- package/public/index.html +4 -4
- package/public/loading.html +4 -4
- package/public/sw.js +1 -1
- package/public/ui-version.json +1 -1
- package/public/assets/index-DElsPAzQ.css +0 -1
- package/public/assets/main-C1yBw4P8.js +0 -53
package/dist/config/schema.js
CHANGED
|
@@ -24,6 +24,7 @@ const PreferencesSchema = z
|
|
|
24
24
|
autoCleanupBlankSessions: z.boolean().default(true),
|
|
25
25
|
listeningMode: z.enum(["local", "all"]).default("local"),
|
|
26
26
|
logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"),
|
|
27
|
+
sessionStorageMode: z.enum(["project", "global"]).default("project"),
|
|
27
28
|
// OS notifications
|
|
28
29
|
osNotificationsEnabled: z.boolean().default(false),
|
|
29
30
|
osNotificationsAllowWhenVisible: z.boolean().default(false),
|
package/dist/events/bus.js
CHANGED
|
@@ -16,6 +16,7 @@ export class EventBus extends EventEmitter {
|
|
|
16
16
|
onEvent(listener) {
|
|
17
17
|
const handler = (event) => listener(event);
|
|
18
18
|
this.on("workspace.created", handler);
|
|
19
|
+
this.on("workspace.update", handler);
|
|
19
20
|
this.on("workspace.started", handler);
|
|
20
21
|
this.on("workspace.error", handler);
|
|
21
22
|
this.on("workspace.stopped", handler);
|
|
@@ -29,6 +30,7 @@ export class EventBus extends EventEmitter {
|
|
|
29
30
|
this.on("instance.eventStatus", handler);
|
|
30
31
|
return () => {
|
|
31
32
|
this.off("workspace.created", handler);
|
|
33
|
+
this.off("workspace.update", handler);
|
|
32
34
|
this.off("workspace.started", handler);
|
|
33
35
|
this.off("workspace.error", handler);
|
|
34
36
|
this.off("workspace.stopped", handler);
|
package/dist/index.js
CHANGED
|
@@ -29,6 +29,7 @@ import { ClientConnectionManager } from "./clients/connection-manager";
|
|
|
29
29
|
import { PluginChannelManager } from "./plugins/channel";
|
|
30
30
|
import { VoiceModeManager } from "./plugins/voice-mode";
|
|
31
31
|
import { readServerPackageVersion, resolveServerPublicDir } from "./runtime-paths";
|
|
32
|
+
import { isBinaryAvailable, triggerBinaryDownload } from "./opencode-paths";
|
|
32
33
|
const require = createRequire(import.meta.url);
|
|
33
34
|
const packageJson = { version: readServerPackageVersion(import.meta.url) };
|
|
34
35
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -390,6 +391,15 @@ async function main() {
|
|
|
390
391
|
else {
|
|
391
392
|
serverMeta.addresses = [];
|
|
392
393
|
}
|
|
394
|
+
if (!isBinaryAvailable()) {
|
|
395
|
+
logger.info("OpenCode binary not found, triggering auto-download in background");
|
|
396
|
+
triggerBinaryDownload(logger)
|
|
397
|
+
.then(() => logger.info("OpenCode auto-download completed"))
|
|
398
|
+
.catch((err) => logger.error({ err }, "OpenCode auto-download failed"));
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
logger.info("OpenCode binary found, skipping auto-download");
|
|
402
|
+
}
|
|
393
403
|
console.log(`Local Connection URL : ${serverMeta.localUrl}`);
|
|
394
404
|
if (serverMeta.remoteUrl) {
|
|
395
405
|
console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`);
|
|
@@ -461,6 +471,26 @@ async function main() {
|
|
|
461
471
|
}
|
|
462
472
|
main().catch((error) => {
|
|
463
473
|
const logger = createLogger({ component: "app" });
|
|
474
|
+
const message = error?.message ?? "";
|
|
475
|
+
if (message.includes("No server password configured")) {
|
|
476
|
+
const friendlyMsg = `\
|
|
477
|
+
[ERROR] [auth] EmbeddedCowork cannot start: no server password configured.
|
|
478
|
+
|
|
479
|
+
To fix this, choose one of the following options:
|
|
480
|
+
|
|
481
|
+
1. Set a password via the command line:
|
|
482
|
+
npx @vividcodeai/embeddedcowork --password <your-password>
|
|
483
|
+
|
|
484
|
+
2. Set the EMBEDDEDCOWORK_SERVER_PASSWORD environment variable
|
|
485
|
+
|
|
486
|
+
3. Create an auth.json configuration file (see path in the error above)
|
|
487
|
+
|
|
488
|
+
4. Use --dangerously-skip-auth to disable authentication (not recommended for production)
|
|
489
|
+
`;
|
|
490
|
+
console.error(friendlyMsg);
|
|
491
|
+
process.exit(1);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
464
494
|
logger.error({ err: error }, "CLI server crashed");
|
|
465
495
|
process.exit(1);
|
|
466
496
|
});
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { createWriteStream, existsSync, mkdirSync, chmodSync, readFileSync, createReadStream, renameSync } from "fs";
|
|
2
|
+
import { stat, mkdir } from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import { spawnSync } from "child_process";
|
|
6
|
+
import yauzl from "yauzl";
|
|
7
|
+
import tar from "tar";
|
|
8
|
+
import { BIN_DIR, BINARY_NAME } from "./opencode-paths";
|
|
9
|
+
const GITHUB_API = "https://api.github.com/repos/anomalyco/opencode/releases/latest";
|
|
10
|
+
const GITHUB_DL = "https://github.com/anomalyco/opencode/releases/latest/download";
|
|
11
|
+
export class OpencodeDownloader {
|
|
12
|
+
constructor(logger) {
|
|
13
|
+
this.logger = logger;
|
|
14
|
+
}
|
|
15
|
+
getDownloadTarget() {
|
|
16
|
+
const platform = process.platform;
|
|
17
|
+
const arch = process.arch;
|
|
18
|
+
let osName;
|
|
19
|
+
switch (platform) {
|
|
20
|
+
case "win32":
|
|
21
|
+
osName = "windows";
|
|
22
|
+
break;
|
|
23
|
+
case "darwin":
|
|
24
|
+
osName = "darwin";
|
|
25
|
+
break;
|
|
26
|
+
case "linux":
|
|
27
|
+
osName = "linux";
|
|
28
|
+
break;
|
|
29
|
+
default:
|
|
30
|
+
throw new Error(`Unsupported platform: ${platform}`);
|
|
31
|
+
}
|
|
32
|
+
let archName;
|
|
33
|
+
switch (arch) {
|
|
34
|
+
case "x64":
|
|
35
|
+
archName = "x64";
|
|
36
|
+
break;
|
|
37
|
+
case "arm64":
|
|
38
|
+
archName = "arm64";
|
|
39
|
+
break;
|
|
40
|
+
default:
|
|
41
|
+
throw new Error(`Unsupported architecture: ${arch}`);
|
|
42
|
+
}
|
|
43
|
+
let target = `${osName}-${archName}`;
|
|
44
|
+
if (arch === "x64" && !this.hasAvx2()) {
|
|
45
|
+
target += "-baseline";
|
|
46
|
+
}
|
|
47
|
+
if (platform === "linux" && this.isMusl()) {
|
|
48
|
+
target += "-musl";
|
|
49
|
+
}
|
|
50
|
+
const archiveExt = platform === "linux" ? ".tar.gz" : ".zip";
|
|
51
|
+
return { filename: `opencode-${target}${archiveExt}`, archiveExt };
|
|
52
|
+
}
|
|
53
|
+
hasAvx2() {
|
|
54
|
+
if (process.platform === "win32") {
|
|
55
|
+
try {
|
|
56
|
+
const ps = `(Add-Type -MemberDefinition "[DllImport(\"kernel32.dll\")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)`;
|
|
57
|
+
const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", ps], { encoding: "utf8", timeout: 5000 });
|
|
58
|
+
const out = result.stdout?.trim()?.toLowerCase();
|
|
59
|
+
return out === "true" || out === "1";
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (process.platform === "darwin") {
|
|
66
|
+
try {
|
|
67
|
+
const result = spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], { encoding: "utf8" });
|
|
68
|
+
return result.status === 0 && result.stdout.trim() === "1";
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (process.platform === "linux") {
|
|
75
|
+
try {
|
|
76
|
+
const cpuinfo = readFileSync("/proc/cpuinfo", "utf8");
|
|
77
|
+
return /avx2/i.test(cpuinfo);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
isMusl() {
|
|
86
|
+
try {
|
|
87
|
+
const result = spawnSync("ldd", ["--version"], { encoding: "utf8" });
|
|
88
|
+
return result.stderr?.toLowerCase().includes("musl") ?? false;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return existsSync("/etc/alpine-release");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async getLatestVersion() {
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
if (OpencodeDownloader.versionCache && now < OpencodeDownloader.versionCache.expiry) {
|
|
97
|
+
return OpencodeDownloader.versionCache.version;
|
|
98
|
+
}
|
|
99
|
+
const res = await fetch(GITHUB_API, {
|
|
100
|
+
headers: { Accept: "application/json", "User-Agent": "embeddedcowork" },
|
|
101
|
+
signal: AbortSignal.timeout(10000),
|
|
102
|
+
});
|
|
103
|
+
if (!res.ok)
|
|
104
|
+
throw new Error(`Failed to fetch latest version: HTTP ${res.status}`);
|
|
105
|
+
const data = (await res.json());
|
|
106
|
+
const version = data.tag_name.replace(/^v/, "");
|
|
107
|
+
OpencodeDownloader.versionCache = { version, expiry: now + 30000 };
|
|
108
|
+
return version;
|
|
109
|
+
}
|
|
110
|
+
async downloadWithRetry(url, dest, progressCb, retries = 5) {
|
|
111
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
112
|
+
let writer = null;
|
|
113
|
+
try {
|
|
114
|
+
const res = await fetch(url);
|
|
115
|
+
if (!res.ok)
|
|
116
|
+
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
117
|
+
const total = Number(res.headers.get("content-length") ?? 0);
|
|
118
|
+
let current = 0;
|
|
119
|
+
const reader = res.body?.getReader();
|
|
120
|
+
if (!reader)
|
|
121
|
+
throw new Error("Response body is not readable");
|
|
122
|
+
writer = createWriteStream(dest);
|
|
123
|
+
while (true) {
|
|
124
|
+
const { done, value } = await reader.read();
|
|
125
|
+
if (done)
|
|
126
|
+
break;
|
|
127
|
+
current += value.length;
|
|
128
|
+
progressCb?.(current, total);
|
|
129
|
+
writer.write(value);
|
|
130
|
+
}
|
|
131
|
+
await new Promise((resolve, reject) => {
|
|
132
|
+
writer.end((err) => (err ? reject(err) : resolve()));
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
if (writer) {
|
|
138
|
+
writer.destroy();
|
|
139
|
+
writer = null;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
import("fs/promises").then((fs) => fs.unlink(dest)).catch(() => { });
|
|
143
|
+
}
|
|
144
|
+
catch { }
|
|
145
|
+
if (attempt === retries)
|
|
146
|
+
throw err;
|
|
147
|
+
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 30000);
|
|
148
|
+
this.logger?.warn({ attempt, retries, err }, `Download attempt ${attempt} failed, retrying in ${delay}ms`);
|
|
149
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async extractZip(zipPath, outDir) {
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => {
|
|
156
|
+
if (err || !zipfile)
|
|
157
|
+
return reject(err ?? new Error("Failed to open zip"));
|
|
158
|
+
zipfile.readEntry();
|
|
159
|
+
zipfile.on("entry", (entry) => {
|
|
160
|
+
if (entry.fileName === BINARY_NAME || entry.fileName.endsWith(`/${BINARY_NAME}`)) {
|
|
161
|
+
zipfile.openReadStream(entry, (err2, readStream) => {
|
|
162
|
+
if (err2 || !readStream)
|
|
163
|
+
return reject(err2 ?? new Error("Failed to open read stream"));
|
|
164
|
+
const tmpPath = path.join(outDir, BINARY_NAME + ".tmp");
|
|
165
|
+
const destPath = path.join(outDir, BINARY_NAME);
|
|
166
|
+
const writer = createWriteStream(tmpPath);
|
|
167
|
+
readStream.pipe(writer);
|
|
168
|
+
writer.on("finish", () => {
|
|
169
|
+
chmodSync(tmpPath, 0o755);
|
|
170
|
+
renameSync(tmpPath, destPath);
|
|
171
|
+
resolve();
|
|
172
|
+
});
|
|
173
|
+
writer.on("error", (e) => {
|
|
174
|
+
try {
|
|
175
|
+
import("fs/promises").then((fs) => fs.unlink(tmpPath)).catch(() => { });
|
|
176
|
+
}
|
|
177
|
+
catch { }
|
|
178
|
+
reject(e);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
zipfile.readEntry();
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
zipfile.on("error", reject);
|
|
187
|
+
zipfile.on("end", () => reject(new Error("Binary not found in archive")));
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
async extractTarGz(tarPath, outDir) {
|
|
192
|
+
const extractPath = path.join(outDir, "extracted");
|
|
193
|
+
await mkdir(extractPath, { recursive: true });
|
|
194
|
+
await tar.extract({
|
|
195
|
+
file: tarPath,
|
|
196
|
+
cwd: extractPath,
|
|
197
|
+
filter: (filePath) => filePath === BINARY_NAME || filePath.endsWith(`/${BINARY_NAME}`),
|
|
198
|
+
});
|
|
199
|
+
const binarySrc = path.join(extractPath, BINARY_NAME);
|
|
200
|
+
const tmpPath = path.join(outDir, BINARY_NAME + ".tmp");
|
|
201
|
+
const binaryDest = path.join(outDir, BINARY_NAME);
|
|
202
|
+
await new Promise((resolve, reject) => {
|
|
203
|
+
const src = createReadStream(binarySrc);
|
|
204
|
+
const dst = createWriteStream(tmpPath);
|
|
205
|
+
src.pipe(dst);
|
|
206
|
+
dst.on("finish", () => {
|
|
207
|
+
chmodSync(tmpPath, 0o755);
|
|
208
|
+
renameSync(tmpPath, binaryDest);
|
|
209
|
+
resolve();
|
|
210
|
+
});
|
|
211
|
+
dst.on("error", (e) => {
|
|
212
|
+
try {
|
|
213
|
+
import("fs/promises").then((fs) => fs.unlink(tmpPath)).catch(() => { });
|
|
214
|
+
}
|
|
215
|
+
catch { }
|
|
216
|
+
reject(e);
|
|
217
|
+
});
|
|
218
|
+
src.on("error", (e) => {
|
|
219
|
+
try {
|
|
220
|
+
import("fs/promises").then((fs) => fs.unlink(tmpPath)).catch(() => { });
|
|
221
|
+
}
|
|
222
|
+
catch { }
|
|
223
|
+
reject(e);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
getInstalledPath() {
|
|
228
|
+
return path.join(BIN_DIR, BINARY_NAME);
|
|
229
|
+
}
|
|
230
|
+
async verifyBinary(binaryPath) {
|
|
231
|
+
if (!existsSync(binaryPath))
|
|
232
|
+
return null;
|
|
233
|
+
try {
|
|
234
|
+
const result = spawnSync(binaryPath, ["--version"], { encoding: "utf8", timeout: 5000 });
|
|
235
|
+
if (result.status === 0) {
|
|
236
|
+
return (result.stdout ?? result.stderr ?? "").trim();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
catch { }
|
|
240
|
+
this.logger?.warn({ path: binaryPath }, "Binary exists but --version failed, proceeding anyway");
|
|
241
|
+
return binaryPath;
|
|
242
|
+
}
|
|
243
|
+
async ensureDownloaded(statusCb) {
|
|
244
|
+
const target = this.getDownloadTarget();
|
|
245
|
+
const binaryPath = this.getInstalledPath();
|
|
246
|
+
const existingVersion = await this.verifyBinary(binaryPath);
|
|
247
|
+
try {
|
|
248
|
+
const latestVersion = await this.getLatestVersion();
|
|
249
|
+
if (existingVersion && existingVersion.includes(latestVersion)) {
|
|
250
|
+
this.logger?.info({ version: latestVersion, path: binaryPath }, "OpenCode binary is up to date");
|
|
251
|
+
return binaryPath;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
if (existingVersion) {
|
|
256
|
+
this.logger?.warn({ err }, "Failed to check latest version, using existing binary");
|
|
257
|
+
return binaryPath;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
mkdirSync(BIN_DIR, { recursive: true });
|
|
261
|
+
const tmpDir = path.join(os.tmpdir(), "embeddedcowork-dl");
|
|
262
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
263
|
+
const archivePath = path.join(tmpDir, target.filename);
|
|
264
|
+
const downloadUrl = `${GITHUB_DL}/${target.filename}`;
|
|
265
|
+
try {
|
|
266
|
+
statusCb?.({ type: "downloading", progress: { current: 0, total: 0 } });
|
|
267
|
+
await this.downloadWithRetry(downloadUrl, archivePath, (current, total) => {
|
|
268
|
+
statusCb?.({ type: "downloading", progress: { current, total } });
|
|
269
|
+
});
|
|
270
|
+
statusCb?.({ type: "extracting" });
|
|
271
|
+
if (target.archiveExt === ".zip") {
|
|
272
|
+
await this.extractZip(archivePath, BIN_DIR);
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
await this.extractTarGz(archivePath, BIN_DIR);
|
|
276
|
+
}
|
|
277
|
+
statusCb?.({ type: "verifying" });
|
|
278
|
+
const version = await this.verifyBinary(binaryPath);
|
|
279
|
+
if (!version) {
|
|
280
|
+
throw new Error("Downloaded binary failed verification");
|
|
281
|
+
}
|
|
282
|
+
this.logger?.info({ version, path: binaryPath }, "OpenCode binary downloaded and verified");
|
|
283
|
+
statusCb?.({ type: "completed", binaryPath });
|
|
284
|
+
return binaryPath;
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
try {
|
|
288
|
+
await stat(archivePath);
|
|
289
|
+
await import("fs/promises").then((fs) => fs.unlink(archivePath));
|
|
290
|
+
}
|
|
291
|
+
catch { }
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
OpencodeDownloader.versionCache = null;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { spawnSync } from "child_process";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import { OpencodeDownloader } from "./opencode-downloader";
|
|
6
|
+
// ── Path constants ──────────────────────────────────────────
|
|
7
|
+
export const BIN_DIR = path.join(os.homedir(), ".embeddedcowork", "bin");
|
|
8
|
+
export const BINARY_NAME = process.platform === "win32" ? "opencode.exe" : "opencode";
|
|
9
|
+
export const INSTALLED_BINARY_PATH = path.join(BIN_DIR, BINARY_NAME);
|
|
10
|
+
// ── Shell helpers ───────────────────────────────────────────
|
|
11
|
+
function defaultShellPath() {
|
|
12
|
+
const configured = process.env.SHELL?.trim();
|
|
13
|
+
if (configured)
|
|
14
|
+
return configured;
|
|
15
|
+
return process.platform === "darwin" ? "/bin/zsh" : "/bin/bash";
|
|
16
|
+
}
|
|
17
|
+
function shellEscape(input) {
|
|
18
|
+
if (!input)
|
|
19
|
+
return "''";
|
|
20
|
+
return `'${input.replace(/'/g, `'\\''`)}'`;
|
|
21
|
+
}
|
|
22
|
+
function wrapCommandForShell(command, shellPath) {
|
|
23
|
+
const shellName = path.basename(shellPath).toLowerCase();
|
|
24
|
+
if (shellName.includes("bash")) {
|
|
25
|
+
return `if [ -f ~/.bashrc ]; then source ~/.bashrc >/dev/null 2>&1; fi; ${command}`;
|
|
26
|
+
}
|
|
27
|
+
if (shellName.includes("zsh")) {
|
|
28
|
+
return `if [ -f ~/.zshrc ]; then source ~/.zshrc >/dev/null 2>&1; fi; ${command}`;
|
|
29
|
+
}
|
|
30
|
+
return command;
|
|
31
|
+
}
|
|
32
|
+
function buildShellArgs(shellPath, command) {
|
|
33
|
+
const shellName = path.basename(shellPath).toLowerCase();
|
|
34
|
+
if (shellName.includes("zsh"))
|
|
35
|
+
return ["-l", "-i", "-c", command];
|
|
36
|
+
return ["-l", "-c", command];
|
|
37
|
+
}
|
|
38
|
+
// ── Binary lookup ───────────────────────────────────────────
|
|
39
|
+
export function resolveBinaryPathFromUserShell(identifier) {
|
|
40
|
+
if (process.platform === "win32")
|
|
41
|
+
return null;
|
|
42
|
+
const shellPath = defaultShellPath();
|
|
43
|
+
const lookupCommand = wrapCommandForShell(`command -v ${shellEscape(identifier)}`, shellPath);
|
|
44
|
+
const result = spawnSync(shellPath, buildShellArgs(shellPath, lookupCommand), {
|
|
45
|
+
encoding: "utf8",
|
|
46
|
+
env: {
|
|
47
|
+
...process.env,
|
|
48
|
+
npm_config_prefix: undefined,
|
|
49
|
+
NPM_CONFIG_PREFIX: undefined,
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
if (result.status !== 0)
|
|
53
|
+
return null;
|
|
54
|
+
const resolved = String(result.stdout ?? "")
|
|
55
|
+
.split(/\r?\n/)
|
|
56
|
+
.map((line) => line.trim())
|
|
57
|
+
.find((line) => line.length > 0);
|
|
58
|
+
return resolved ?? null;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Resolve an opencode binary identifier to an absolute path.
|
|
62
|
+
* Order: absolute/relative → which/where → shell command -v → installed path → raw identifier
|
|
63
|
+
*/
|
|
64
|
+
export function resolveBinary(identifier) {
|
|
65
|
+
if (!identifier)
|
|
66
|
+
return identifier;
|
|
67
|
+
if (path.isAbsolute(identifier) || identifier.includes("/") || identifier.includes("\\") || identifier.startsWith(".")) {
|
|
68
|
+
return identifier;
|
|
69
|
+
}
|
|
70
|
+
const locator = process.platform === "win32" ? "where" : "which";
|
|
71
|
+
try {
|
|
72
|
+
const result = spawnSync(locator, [identifier], { encoding: "utf8" });
|
|
73
|
+
if (result.status === 0 && result.stdout) {
|
|
74
|
+
const candidates = result.stdout
|
|
75
|
+
.split(/\r?\n/)
|
|
76
|
+
.map((line) => line.trim())
|
|
77
|
+
.filter((line) => line.length > 0)
|
|
78
|
+
.filter((line) => !/^INFO:/i.test(line));
|
|
79
|
+
if (candidates.length > 0) {
|
|
80
|
+
return candidates[0];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch { }
|
|
85
|
+
const shellResolved = resolveBinaryPathFromUserShell(identifier);
|
|
86
|
+
if (shellResolved)
|
|
87
|
+
return shellResolved;
|
|
88
|
+
if (existsSync(INSTALLED_BINARY_PATH))
|
|
89
|
+
return INSTALLED_BINARY_PATH;
|
|
90
|
+
return identifier;
|
|
91
|
+
}
|
|
92
|
+
let downloadPhase = "idle";
|
|
93
|
+
let downloadProgress = { current: 0, total: 0 };
|
|
94
|
+
let downloadError;
|
|
95
|
+
function setDownloadPhase(phase) {
|
|
96
|
+
downloadPhase = phase;
|
|
97
|
+
if (phase === "idle" || phase === "completed" || phase === "error") {
|
|
98
|
+
downloadProgress = { current: 0, total: 0 };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export function getDownloadProgress() {
|
|
102
|
+
return { ...downloadProgress };
|
|
103
|
+
}
|
|
104
|
+
export function getDownloadPhase() {
|
|
105
|
+
return downloadPhase;
|
|
106
|
+
}
|
|
107
|
+
export function getDownloadError() {
|
|
108
|
+
return downloadError;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Check whether opencode is available on this system.
|
|
112
|
+
* Uses the same lookup order as resolveBinary("opencode").
|
|
113
|
+
* Returns additional download progress info when not available.
|
|
114
|
+
*/
|
|
115
|
+
export function getOpencodeStatus() {
|
|
116
|
+
const resolved = resolveBinary("opencode");
|
|
117
|
+
const available = resolved !== "opencode" && existsSync(resolved);
|
|
118
|
+
if (available)
|
|
119
|
+
return { available: true };
|
|
120
|
+
return {
|
|
121
|
+
available: false,
|
|
122
|
+
status: downloadPhase,
|
|
123
|
+
progress: downloadPhase === "downloading" ? { ...downloadProgress } : undefined,
|
|
124
|
+
error: downloadError,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
export function isBinaryAvailable() {
|
|
128
|
+
const resolved = resolveBinary("opencode");
|
|
129
|
+
return resolved !== "opencode" && existsSync(resolved);
|
|
130
|
+
}
|
|
131
|
+
// ── Download trigger (global singleton) ─────────────────────
|
|
132
|
+
let downloadPromise = null;
|
|
133
|
+
export function triggerBinaryDownload(logger) {
|
|
134
|
+
if (downloadPromise)
|
|
135
|
+
return downloadPromise;
|
|
136
|
+
setDownloadPhase("downloading");
|
|
137
|
+
downloadError = undefined;
|
|
138
|
+
const downloader = new OpencodeDownloader(logger);
|
|
139
|
+
downloadPromise = downloader
|
|
140
|
+
.ensureDownloaded((status) => {
|
|
141
|
+
if (status.type === "downloading") {
|
|
142
|
+
downloadPhase = "downloading";
|
|
143
|
+
downloadProgress = status.progress;
|
|
144
|
+
}
|
|
145
|
+
else if (status.type === "extracting") {
|
|
146
|
+
setDownloadPhase("extracting");
|
|
147
|
+
}
|
|
148
|
+
else if (status.type === "verifying") {
|
|
149
|
+
setDownloadPhase("verifying");
|
|
150
|
+
}
|
|
151
|
+
else if (status.type === "completed") {
|
|
152
|
+
setDownloadPhase("completed");
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
.then(() => {
|
|
156
|
+
logger.info("OpenCode auto-download completed");
|
|
157
|
+
setDownloadPhase("completed");
|
|
158
|
+
downloadPromise = null;
|
|
159
|
+
})
|
|
160
|
+
.catch((err) => {
|
|
161
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
162
|
+
logger.error({ err }, "OpenCode auto-download failed");
|
|
163
|
+
downloadPhase = "error";
|
|
164
|
+
downloadError = message;
|
|
165
|
+
});
|
|
166
|
+
return downloadPromise;
|
|
167
|
+
}
|
|
@@ -19,6 +19,7 @@ import { registerEventRoutes } from "./routes/events";
|
|
|
19
19
|
import { registerStorageRoutes } from "./routes/storage";
|
|
20
20
|
import { registerPluginRoutes } from "./routes/plugin";
|
|
21
21
|
import { registerBackgroundProcessRoutes } from "./routes/background-processes";
|
|
22
|
+
import { registerOpencodeStatusRoutes } from "./routes/opencode-status";
|
|
22
23
|
import { registerWorktreeRoutes } from "./routes/worktrees";
|
|
23
24
|
import { registerSpeechRoutes } from "./routes/speech";
|
|
24
25
|
import { registerRemoteServerRoutes } from "./routes/remote-servers";
|
|
@@ -194,6 +195,7 @@ export function createHttpServer(deps) {
|
|
|
194
195
|
});
|
|
195
196
|
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager });
|
|
196
197
|
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger });
|
|
198
|
+
registerOpencodeStatusRoutes(app, { settings: deps.settings, logger: apiLogger });
|
|
197
199
|
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser });
|
|
198
200
|
registerMetaRoutes(app, { serverMeta: deps.serverMeta });
|
|
199
201
|
registerEventRoutes(app, {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { getOpencodeStatus, triggerBinaryDownload } from "../../opencode-paths";
|
|
2
|
+
export function registerOpencodeStatusRoutes(app, deps) {
|
|
3
|
+
app.get("/api/opencode/status", async (_request, reply) => {
|
|
4
|
+
const status = getOpencodeStatus();
|
|
5
|
+
if (!status.available) {
|
|
6
|
+
void triggerBinaryDownload(deps.logger);
|
|
7
|
+
}
|
|
8
|
+
return reply.send(status);
|
|
9
|
+
});
|
|
10
|
+
}
|