@gitterm/opencode-sandbox 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,12 @@
1
+ import type { Plugin } from "@opencode/plugin";
2
+ import { type SandboxSettings } from "@gitterm/sdk/sandbox";
3
+ /**
4
+ * Remote sandbox tools for a local conversation. The conversation and local files stay
5
+ * here; commands run in a separate sandbox with no coding agent or model credentials.
6
+ * Nothing here replaces local shell execution or calls `/cloud`.
7
+ */
8
+ type SetupContext = Plugin.Context;
9
+ export declare function registerSandboxTools(ctx: SetupContext, settings: SandboxSettings | undefined,
10
+ /** Tests shorten the job watch interval. */
11
+ watchIntervalMs?: number): Promise<() => Promise<void>>;
12
+ export {};
@@ -0,0 +1,10 @@
1
+ import type { SandboxRecord } from "@gitterm/sdk/sandbox/store";
2
+ export type SidebarRow = {
3
+ id: string;
4
+ title: string;
5
+ detail: string;
6
+ state: SandboxRecord["state"];
7
+ };
8
+ /** Live sandboxes for one checkout, newest first, as short sidebar lines. */
9
+ export declare function sidebarRows(records: SandboxRecord[], project: string, now?: number): SidebarRow[];
10
+ export declare function truncate(text: string, width: number): string;
package/dist/tui.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { Plugin } from "@opencode/plugin/tui";
2
+ declare const _default: Plugin.Definition;
3
+ export default _default;
package/dist/tui.js ADDED
@@ -0,0 +1,218 @@
1
+ // @bun
2
+ // src/tui.tsx
3
+ import { effect as _$effect } from "@opentui/solid";
4
+ import { insert as _$insert } from "@opentui/solid";
5
+ import { createComponent as _$createComponent } from "@opentui/solid";
6
+ import { createTextNode as _$createTextNode } from "@opentui/solid";
7
+ import { insertNode as _$insertNode } from "@opentui/solid";
8
+ import { setProp as _$setProp } from "@opentui/solid";
9
+ import { createElement as _$createElement } from "@opentui/solid";
10
+ import { realpath } from "fs/promises";
11
+ import { Plugin, usePlugin } from "@opencode/plugin/tui";
12
+
13
+ // ../sdk/dist/sandbox/store.js
14
+ import { createHash, randomBytes } from "crypto";
15
+ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
16
+ import { homedir } from "os";
17
+ import path from "path";
18
+ function defaultStoreDirectory() {
19
+ const base = process.env.XDG_STATE_HOME || path.join(homedir(), ".local", "state");
20
+ return path.join(base, "gitterm", "sandboxes");
21
+ }
22
+ var fileName = (ref) => `${ref.mode}-${ref.provider}-${ref.account}-${ref.id}`.replace(/[^A-Za-z0-9_.-]/g, "_") + ".json";
23
+ var snapshotFile = (id, root) => `${id.replace(/[^A-Za-z0-9_]/g, "")}-${createHash("sha256").update(root).digest("hex").slice(0, 16)}.json`;
24
+ var LOCK_STALE_MS = 30000;
25
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
26
+ async function withFileLock(target, action) {
27
+ const lock = `${target}.lock`;
28
+ const deadline = Date.now() + 2 * LOCK_STALE_MS;
29
+ while (true) {
30
+ try {
31
+ await writeFile(lock, String(process.pid), { flag: "wx", mode: 384 });
32
+ break;
33
+ } catch (error) {
34
+ if (error.code !== "EEXIST")
35
+ throw error;
36
+ const age = await stat(lock).then((info) => Date.now() - info.mtimeMs, () => 0);
37
+ if (age > LOCK_STALE_MS)
38
+ await rm(lock, { force: true });
39
+ else if (Date.now() > deadline)
40
+ throw new Error(`Timed out waiting for ${lock}`);
41
+ else
42
+ await sleep(5 + Math.random() * 20);
43
+ }
44
+ }
45
+ try {
46
+ return await action();
47
+ } finally {
48
+ await rm(lock, { force: true });
49
+ }
50
+ }
51
+ function createFileSandboxStore(directory = defaultStoreDirectory()) {
52
+ const read = async (file) => JSON.parse(await readFile(path.join(directory, file), "utf8"));
53
+ const write = async (target, record) => {
54
+ const temporary = `${target}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
55
+ try {
56
+ await writeFile(temporary, JSON.stringify(record, null, 2), { mode: 384 });
57
+ await rename(temporary, target);
58
+ } finally {
59
+ await rm(temporary, { force: true });
60
+ }
61
+ };
62
+ const queues = new Map;
63
+ const serialized = (target, action) => {
64
+ const next = (queues.get(target) ?? Promise.resolve()).catch(() => {}).then(() => withFileLock(target, action));
65
+ queues.set(target, next);
66
+ next.then(() => queues.get(target) === next && queues.delete(target), () => queues.get(target) === next && queues.delete(target));
67
+ return next;
68
+ };
69
+ return {
70
+ async list() {
71
+ const files = await readdir(directory).catch(() => []);
72
+ const records = await Promise.all(files.filter((file) => file.endsWith(".json")).map((file) => read(file).catch(() => null)));
73
+ return records.filter((record) => record !== null);
74
+ },
75
+ async get(ref) {
76
+ return read(fileName(ref)).catch(() => null);
77
+ },
78
+ async put(record) {
79
+ await mkdir(directory, { recursive: true, mode: 448 });
80
+ const target = path.join(directory, fileName(record));
81
+ await serialized(target, () => write(target, record));
82
+ },
83
+ async update(ref, mutate, create) {
84
+ await mkdir(directory, { recursive: true, mode: 448 });
85
+ const target = path.join(directory, fileName(ref));
86
+ return serialized(target, async () => {
87
+ const current = await read(fileName(ref)).catch(() => null) ?? create?.() ?? null;
88
+ if (!current)
89
+ return null;
90
+ mutate(current);
91
+ current.updatedAt = new Date().toISOString();
92
+ await write(target, current);
93
+ return current;
94
+ });
95
+ },
96
+ async remove(ref) {
97
+ await rm(path.join(directory, fileName(ref)), { force: true });
98
+ },
99
+ async putSnapshot(snapshot) {
100
+ const target = path.join(directory, "snapshots", snapshotFile(snapshot.id, snapshot.root));
101
+ await mkdir(path.dirname(target), { recursive: true, mode: 448 });
102
+ await writeFile(target, JSON.stringify(snapshot), { mode: 384 });
103
+ },
104
+ async getSnapshot(id, root) {
105
+ return readFile(path.join(directory, "snapshots", snapshotFile(id, root)), "utf8").then((text) => JSON.parse(text)).catch(() => null);
106
+ }
107
+ };
108
+ }
109
+
110
+ // src/tui.tsx
111
+ import { createSignal, For, onCleanup, onMount, Show } from "solid-js";
112
+
113
+ // src/sidebar.ts
114
+ function sidebarRows(records, project, now = Date.now()) {
115
+ return records.filter((record) => record.project === project && record.state !== "terminated" && !(record.expiresAt && Date.parse(record.expiresAt) <= now)).toSorted((a, b) => b.createdAt.localeCompare(a.createdAt)).map((record) => {
116
+ const running = Object.values(record.jobs).filter((job) => job.status === "running").length;
117
+ const detail = [
118
+ record.state === "running" ? "" : record.state,
119
+ running ? `${running} running` : "",
120
+ ...Object.values(record.previews).map((preview) => `:${preview.port}`),
121
+ record.purpose || record.provider
122
+ ].filter(Boolean).join(" \xB7 ");
123
+ return { id: record.id, title: record.name, detail, state: record.state };
124
+ });
125
+ }
126
+ function truncate(text, width) {
127
+ return text.length > width ? `${text.slice(0, width - 1)}\u2026` : text;
128
+ }
129
+
130
+ // src/tui.tsx
131
+ var REFRESH_MS = 3000;
132
+ var WIDTH = 34;
133
+ function SandboxSidebar(props) {
134
+ const context = usePlugin();
135
+ const theme = () => context.theme;
136
+ const [rows, setRows] = createSignal([]);
137
+ onMount(() => {
138
+ const store = createFileSandboxStore();
139
+ let disposed = false;
140
+ const refresh = async () => {
141
+ const directory = context.data.session.get(props.sessionID)?.location.directory;
142
+ if (!directory)
143
+ return;
144
+ const project = await realpath(directory).catch(() => directory);
145
+ const next = sidebarRows(await store.list(), project);
146
+ if (!disposed)
147
+ setRows(next);
148
+ };
149
+ refresh();
150
+ const timer = setInterval(() => void refresh(), REFRESH_MS);
151
+ onCleanup(() => {
152
+ disposed = true;
153
+ clearInterval(timer);
154
+ });
155
+ });
156
+ const dot = (state) => state === "running" ? theme().text.feedback.success.base : state === "deleting" ? theme().text.feedback.error.base : theme().text.feedback.warning.base;
157
+ return _$createComponent(Show, {
158
+ get when() {
159
+ return rows().length > 0;
160
+ },
161
+ get children() {
162
+ var _el$ = _$createElement("box"), _el$2 = _$createElement("text"), _el$3 = _$createElement("b");
163
+ _$insertNode(_el$, _el$2);
164
+ _$setProp(_el$, "flexDirection", "column");
165
+ _$insertNode(_el$2, _el$3);
166
+ _$insertNode(_el$3, _$createTextNode(`Sandboxes`));
167
+ _$insert(_el$, _$createComponent(For, {
168
+ get each() {
169
+ return rows();
170
+ },
171
+ children: (row) => (() => {
172
+ var _el$5 = _$createElement("box"), _el$6 = _$createElement("text"), _el$7 = _$createElement("span"), _el$9 = _$createTextNode(` `), _el$0 = _$createElement("text"), _el$1 = _$createTextNode(` `);
173
+ _$insertNode(_el$5, _el$6);
174
+ _$insertNode(_el$5, _el$0);
175
+ _$setProp(_el$5, "flexDirection", "column");
176
+ _$insertNode(_el$6, _el$7);
177
+ _$insertNode(_el$6, _el$9);
178
+ _$insertNode(_el$7, _$createTextNode(`\u2022`));
179
+ _$insert(_el$6, () => truncate(row.title, WIDTH - 2), null);
180
+ _$insertNode(_el$0, _el$1);
181
+ _$insert(_el$0, () => truncate(row.detail, WIDTH - 2), null);
182
+ _$effect((_p$) => {
183
+ var _v$ = theme().text.base, _v$2 = {
184
+ fg: dot(row.state)
185
+ }, _v$3 = theme().text.muted;
186
+ _v$ !== _p$.e && (_p$.e = _$setProp(_el$6, "fg", _v$, _p$.e));
187
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$7, "style", _v$2, _p$.t));
188
+ _v$3 !== _p$.a && (_p$.a = _$setProp(_el$0, "fg", _v$3, _p$.a));
189
+ return _p$;
190
+ }, {
191
+ e: undefined,
192
+ t: undefined,
193
+ a: undefined
194
+ });
195
+ return _el$5;
196
+ })()
197
+ }), null);
198
+ _$effect((_$p) => _$setProp(_el$2, "fg", theme().text.base, _$p));
199
+ return _el$;
200
+ }
201
+ });
202
+ }
203
+ var tui_default = Plugin.define({
204
+ id: "gitterm.sandbox",
205
+ setup(context) {
206
+ return context.ui.slot({
207
+ append: "sidebar.content",
208
+ render: (input) => _$createComponent(SandboxSidebar, {
209
+ get sessionID() {
210
+ return input.sessionID;
211
+ }
212
+ })
213
+ });
214
+ }
215
+ });
216
+ export {
217
+ tui_default as default
218
+ };
package/package.json CHANGED
@@ -1,6 +1,61 @@
1
1
  {
2
2
  "name": "@gitterm/opencode-sandbox",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Let your OpenCode agent run builds, tests, and previews in remote sandboxes while the session stays local",
5
+ "keywords": [
6
+ "e2b",
7
+ "gitterm",
8
+ "opencode",
9
+ "opencode-plugin",
10
+ "remote-execution",
11
+ "sandbox",
12
+ "tui"
13
+ ],
14
+ "homepage": "https://gitterm.dev",
15
+ "license": "MIT",
16
+ "author": "Opeyemi Oginni",
17
+ "files": [
18
+ "dist",
19
+ "README.md"
20
+ ],
21
+ "type": "module",
22
+ "main": "./dist/index.js",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.js",
27
+ "default": "./dist/index.js"
28
+ },
29
+ "./tui": {
30
+ "types": "./dist/tui.d.ts",
31
+ "import": "./dist/tui.js",
32
+ "default": "./dist/tui.js"
33
+ }
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "scripts": {
39
+ "build": "rm -rf dist && bun build src/index.ts --outdir dist --target bun --format esm --external @opencode/plugin && bun run build-tui.ts && tsc -p tsconfig.build.json",
40
+ "check-types": "tsc --noEmit",
41
+ "prepack": "bun run check-types && bun run build"
42
+ },
43
+ "devDependencies": {
44
+ "@gitterm/sdk": "workspace:*",
45
+ "@opencode/plugin": "^2.2.6",
46
+ "@opentui/core": "0.8.0",
47
+ "@opentui/solid": "0.8.0",
48
+ "@types/bun": "^1.2.6",
49
+ "solid-js": "1.9.17",
50
+ "typescript": "^5.8.2"
51
+ },
52
+ "peerDependencies": {
53
+ "@opencode/plugin": "^2.2.6",
54
+ "@opentui/core": ">=0.8.0",
55
+ "@opentui/solid": ">=0.8.0",
56
+ "solid-js": ">=1.9.0"
57
+ },
58
+ "engines": {
59
+ "bun": ">=1.2.0"
60
+ }
61
+ }