opencode-hub 1.0.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.
- package/README.md +119 -0
- package/oc-tui.js +1108 -0
- package/package.json +29 -0
- package/plugin.js +130 -0
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# opencode-hub
|
|
2
|
+
|
|
3
|
+
TUI launcher and `oc` shell command for [OpenCode](https://github.com/sst/opencode).
|
|
4
|
+
|
|
5
|
+
When loaded as an OpenCode plugin, it installs the `oc` command into your shell. Running `oc` opens an interactive TUI for switching between projects and managing plugins.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Project list** — shows recent OpenCode projects sorted by last used, with session counts
|
|
10
|
+
- **Pin / Hide / Unhide** — organize your project list
|
|
11
|
+
- **Custom path** — open any directory directly
|
|
12
|
+
- **Change path** — reassociate sessions when a project moves
|
|
13
|
+
- **Plugin manager** — view plugin status, toggle auto-update, force rebuild, downgrade to specific commits
|
|
14
|
+
- **Auto-update OpenCode** — checks for new `opencode-ai` npm versions once per day
|
|
15
|
+
- **Centralized config** — stores config in `config/oc-config.json`, plugins in `config/plugins.json`
|
|
16
|
+
- **`<creator>/<repo>` layout** — plugin repos stored under `repos/<github-user>/<repo-name>` to prevent collisions
|
|
17
|
+
|
|
18
|
+
## Requirements
|
|
19
|
+
|
|
20
|
+
- [Bun](https://bun.sh/) runtime (uses `bun:sqlite` for reading the OpenCode session database)
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
### Option A — Via plugin-updater (recommended)
|
|
25
|
+
|
|
26
|
+
If you have [opencode-plugin-updater](https://github.com/intisy/opencode-plugin-updater) installed, add this entry to `~/.config/opencode/config/plugins.json`:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"name": "opencode-hub",
|
|
31
|
+
"url": "https://github.com/intisy/opencode-hub.git",
|
|
32
|
+
"install": null,
|
|
33
|
+
"build": null,
|
|
34
|
+
"bundle": null,
|
|
35
|
+
"output": "plugin.js",
|
|
36
|
+
"pluginFile": "oc-launcher.js",
|
|
37
|
+
"autoUpdate": true
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Restart OpenCode. The updater will clone the repo and deploy the plugin automatically.
|
|
42
|
+
|
|
43
|
+
### Option B — npm
|
|
44
|
+
|
|
45
|
+
Add the package to your `~/.config/opencode/opencode.json`:
|
|
46
|
+
|
|
47
|
+
```jsonc
|
|
48
|
+
{
|
|
49
|
+
"plugins": ["opencode-hub@latest"]
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Restart OpenCode.
|
|
54
|
+
|
|
55
|
+
### Option C — Manual
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
mkdir -p ~/.config/opencode/repos/intisy/opencode-hub
|
|
59
|
+
git clone https://github.com/intisy/opencode-hub.git ~/.config/opencode/repos/intisy/opencode-hub
|
|
60
|
+
cp ~/.config/opencode/repos/intisy/opencode-hub/plugin.js ~/.config/opencode/plugins/oc-launcher.js
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Register the plugin in `~/.config/opencode/opencode.json`:
|
|
64
|
+
|
|
65
|
+
```jsonc
|
|
66
|
+
{
|
|
67
|
+
"plugins": {
|
|
68
|
+
"oc-launcher": "./plugins/oc-launcher.js"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## How It Works
|
|
74
|
+
|
|
75
|
+
1. **On OpenCode startup** — the plugin installs `oc` (or `oc.cmd` on Windows) into `~/.local/bin/`
|
|
76
|
+
2. **When you run `oc`** — the TUI launcher opens, showing your projects and plugins
|
|
77
|
+
3. **Select a project** — the launcher `cd`s into the directory and starts `opencode`
|
|
78
|
+
|
|
79
|
+
The plugin also provides an `oc_remove` tool to uninstall the shell command.
|
|
80
|
+
|
|
81
|
+
## Usage
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
oc # Launch TUI
|
|
85
|
+
oc 3 # Open project #3 directly
|
|
86
|
+
oc myproject # Open first project matching "myproject"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Keyboard shortcuts
|
|
90
|
+
|
|
91
|
+
#### Projects tab
|
|
92
|
+
|
|
93
|
+
| Key | Action |
|
|
94
|
+
|-----|--------|
|
|
95
|
+
| ↑↓ / W S | Navigate |
|
|
96
|
+
| Enter | Open action menu |
|
|
97
|
+
| O | Open project |
|
|
98
|
+
| P | Pin/Unpin |
|
|
99
|
+
| H | Hide |
|
|
100
|
+
| U | Unhide all |
|
|
101
|
+
| C | Custom path |
|
|
102
|
+
| ← → | Switch tabs |
|
|
103
|
+
| Q | Quit |
|
|
104
|
+
|
|
105
|
+
#### Plugins tab
|
|
106
|
+
|
|
107
|
+
| Key | Action |
|
|
108
|
+
|-----|--------|
|
|
109
|
+
| ↑↓ / W S | Navigate |
|
|
110
|
+
| Enter | Open action menu |
|
|
111
|
+
| F | Fetch remote updates |
|
|
112
|
+
| A | Toggle auto-update |
|
|
113
|
+
| U | Update plugin |
|
|
114
|
+
| Q | Quit |
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
|
119
|
+
|
package/oc-tui.js
ADDED
|
@@ -0,0 +1,1108 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from "fs";
|
|
4
|
+
import { execSync } from "child_process";
|
|
5
|
+
import { join, dirname } from "path";
|
|
6
|
+
import { homedir } from "os";
|
|
7
|
+
|
|
8
|
+
var HOME = homedir();
|
|
9
|
+
var CONFIG_DIR = join(HOME, ".config", "opencode");
|
|
10
|
+
var DB_PATH = join(HOME, ".local", "share", "opencode", "opencode.db");
|
|
11
|
+
var CONFIG_FOLDER = join(CONFIG_DIR, "config");
|
|
12
|
+
var CONFIG_PATH = join(CONFIG_FOLDER, "oc-config.json");
|
|
13
|
+
var UPDATE_CHECK_PATH = join(CONFIG_DIR, "oc-last-update-check");
|
|
14
|
+
var PLUGINS_JSON = join(CONFIG_FOLDER, "plugins.json");
|
|
15
|
+
var REPOS_DIR = join(CONFIG_DIR, "repos");
|
|
16
|
+
var PLUGINS_DIR = join(CONFIG_DIR, "plugins");
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Folder name helper: <creator>/<repo-name> to avoid collisions
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
function getFolderName(plugin) {
|
|
23
|
+
var match = (plugin.url || "").match(/github\.com\/([^\/]+)\/([^\/\.]+)/);
|
|
24
|
+
if (match) return match[1] + "/" + plugin.name;
|
|
25
|
+
return plugin.name;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Migration: move legacy config files into config/
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
function migrateConfigs() {
|
|
33
|
+
if (!existsSync(CONFIG_FOLDER)) try { mkdirSync(CONFIG_FOLDER, { recursive: true }); } catch {}
|
|
34
|
+
var legacyConfig = join(CONFIG_DIR, "oc-config.json");
|
|
35
|
+
if (existsSync(legacyConfig) && !existsSync(CONFIG_PATH)) {
|
|
36
|
+
try { copyFileSync(legacyConfig, CONFIG_PATH); } catch {}
|
|
37
|
+
}
|
|
38
|
+
var legacyPlugins = join(CONFIG_DIR, "plugins.json");
|
|
39
|
+
if (existsSync(legacyPlugins) && !existsSync(PLUGINS_JSON)) {
|
|
40
|
+
try { copyFileSync(legacyPlugins, PLUGINS_JSON); } catch {}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
migrateConfigs();
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Auto-update OpenCode itself
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
function checkForUpdates() {
|
|
51
|
+
try {
|
|
52
|
+
if (existsSync(UPDATE_CHECK_PATH)) {
|
|
53
|
+
var lastCheck = parseInt(readFileSync(UPDATE_CHECK_PATH, "utf-8").trim(), 10);
|
|
54
|
+
if (Date.now() - lastCheck < 86400000) return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
|
|
58
|
+
writeFileSync(UPDATE_CHECK_PATH, String(Date.now()));
|
|
59
|
+
|
|
60
|
+
var installed = execSync("opencode --version", { encoding: "utf-8", timeout: 10000 }).trim();
|
|
61
|
+
var latest = execSync("npm view opencode-ai version", { encoding: "utf-8", timeout: 15000 }).trim();
|
|
62
|
+
|
|
63
|
+
if (!latest || !installed || latest === installed) return;
|
|
64
|
+
|
|
65
|
+
process.stderr.write("\x1b[33m > Updating OpenCode: " + installed + " -> " + latest + "\x1b[0m\n");
|
|
66
|
+
execSync("npm install -g opencode-ai@latest", { stdio: "inherit", timeout: 120000 });
|
|
67
|
+
process.stderr.write("\x1b[32m > Updated to " + latest + "\x1b[0m\n\n");
|
|
68
|
+
} catch (e) {}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
checkForUpdates();
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Project launcher data
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
function loadConfig() {
|
|
78
|
+
try { if (existsSync(CONFIG_PATH)) return JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); } catch {}
|
|
79
|
+
// Legacy fallback
|
|
80
|
+
var legacy = join(CONFIG_DIR, "oc-config.json");
|
|
81
|
+
try { if (existsSync(legacy)) return JSON.parse(readFileSync(legacy, "utf-8")); } catch {}
|
|
82
|
+
return { pinned: [], hidden: [] };
|
|
83
|
+
}
|
|
84
|
+
function saveConfig(cfg) {
|
|
85
|
+
try {
|
|
86
|
+
if (!existsSync(CONFIG_FOLDER)) mkdirSync(CONFIG_FOLDER, { recursive: true });
|
|
87
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
|
|
88
|
+
} catch {}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function queryProjects() {
|
|
92
|
+
if (!existsSync(DB_PATH)) return [];
|
|
93
|
+
try {
|
|
94
|
+
var db = new Database(DB_PATH, { readonly: true });
|
|
95
|
+
var rows = db.query(
|
|
96
|
+
"SELECT directory, MAX(time_updated) as last_used, COUNT(*) as sessions " +
|
|
97
|
+
"FROM session WHERE parent_id IS NULL GROUP BY directory ORDER BY last_used DESC LIMIT 30"
|
|
98
|
+
).all();
|
|
99
|
+
db.close();
|
|
100
|
+
return rows;
|
|
101
|
+
} catch { return []; }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function timeAgo(ts) {
|
|
105
|
+
if (!ts) return "--";
|
|
106
|
+
var d = Date.now() - ts;
|
|
107
|
+
if (d < 60000) return "now";
|
|
108
|
+
if (d < 3600000) return Math.floor(d / 60000) + "m ago";
|
|
109
|
+
if (d < 86400000) return Math.floor(d / 3600000) + "h ago";
|
|
110
|
+
return Math.floor(d / 86400000) + "d ago";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function shortPath(dir) {
|
|
114
|
+
var h = HOME.replace(/\\/g, "/");
|
|
115
|
+
var d = dir.replace(/\\/g, "/");
|
|
116
|
+
if (d.startsWith(h)) d = "~" + d.substring(h.length);
|
|
117
|
+
return d;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function pad(s, len) {
|
|
121
|
+
s = String(s || "");
|
|
122
|
+
while (s.length < len) s += " ";
|
|
123
|
+
return s.substring(0, len);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function trunc(s, max) {
|
|
127
|
+
if (!s) return "";
|
|
128
|
+
if (s.length <= max) return s;
|
|
129
|
+
return s.substring(0, max - 1) + "…";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function buildList() {
|
|
133
|
+
var cfg = loadConfig();
|
|
134
|
+
var rows = queryProjects();
|
|
135
|
+
var list = [];
|
|
136
|
+
|
|
137
|
+
var pinnedItems = [];
|
|
138
|
+
for (var dir of cfg.pinned) {
|
|
139
|
+
var row = rows.find(function(r) { return r.directory === dir; });
|
|
140
|
+
if (cfg.hidden.indexOf(dir) !== -1) continue;
|
|
141
|
+
pinnedItems.push({
|
|
142
|
+
dir: dir,
|
|
143
|
+
name: dir.split(/[\\/]/).pop() || dir,
|
|
144
|
+
sessions: row ? row.sessions : 0,
|
|
145
|
+
lastUsed: row ? row.last_used : 0,
|
|
146
|
+
pinned: true
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
pinnedItems.sort(function(a, b) { return (b.lastUsed || 0) - (a.lastUsed || 0); });
|
|
150
|
+
for (var pi = 0; pi < pinnedItems.length; pi++) { list.push(pinnedItems[pi]); }
|
|
151
|
+
|
|
152
|
+
for (var i = 0; i < rows.length; i++) {
|
|
153
|
+
var r = rows[i];
|
|
154
|
+
if (cfg.pinned.indexOf(r.directory) !== -1) continue;
|
|
155
|
+
if (cfg.hidden.indexOf(r.directory) !== -1) continue;
|
|
156
|
+
list.push({
|
|
157
|
+
dir: r.directory,
|
|
158
|
+
name: r.directory.split(/[\\/]/).pop() || r.directory,
|
|
159
|
+
sessions: r.sessions,
|
|
160
|
+
lastUsed: r.last_used,
|
|
161
|
+
pinned: false
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
return list;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Plugin data
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
function loadPlugins() {
|
|
172
|
+
try { if (existsSync(PLUGINS_JSON)) return JSON.parse(readFileSync(PLUGINS_JSON, "utf-8")); } catch {}
|
|
173
|
+
// Legacy fallback
|
|
174
|
+
var legacy = join(CONFIG_DIR, "plugins.json");
|
|
175
|
+
try { if (existsSync(legacy)) return JSON.parse(readFileSync(legacy, "utf-8")); } catch {}
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function savePlugins(plugins) {
|
|
180
|
+
if (!existsSync(CONFIG_FOLDER)) try { mkdirSync(CONFIG_FOLDER, { recursive: true }); } catch {}
|
|
181
|
+
writeFileSync(PLUGINS_JSON, JSON.stringify(plugins, null, 2), "utf-8");
|
|
182
|
+
// Dual-write for backward compat
|
|
183
|
+
try { writeFileSync(join(CONFIG_DIR, "plugins.json"), JSON.stringify(plugins, null, 2), "utf-8"); } catch {}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function gitText(args, cwd) {
|
|
187
|
+
try {
|
|
188
|
+
var out = execSync(args.join(" "), { cwd: cwd, encoding: "utf-8", timeout: 15000, stdio: ["ignore", "pipe", "ignore"] });
|
|
189
|
+
return out.trim();
|
|
190
|
+
} catch { return ""; }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function buildPluginList() {
|
|
194
|
+
var plugins = loadPlugins();
|
|
195
|
+
var list = [];
|
|
196
|
+
for (var p of plugins) {
|
|
197
|
+
var folderName = getFolderName(p);
|
|
198
|
+
var dir = join(REPOS_DIR, folderName);
|
|
199
|
+
var installed = existsSync(dir);
|
|
200
|
+
var deployed = existsSync(join(PLUGINS_DIR, p.pluginFile));
|
|
201
|
+
var localHead = "";
|
|
202
|
+
var remoteHead = "";
|
|
203
|
+
var subject = "";
|
|
204
|
+
var updateAvail = false;
|
|
205
|
+
|
|
206
|
+
if (installed) {
|
|
207
|
+
localHead = gitText(["git", "rev-parse", "HEAD"], dir);
|
|
208
|
+
subject = gitText(["git", "log", "-1", "--format=%s"], dir);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
list.push({
|
|
212
|
+
name: p.name,
|
|
213
|
+
folderName: folderName,
|
|
214
|
+
url: p.url,
|
|
215
|
+
autoUpdate: p.autoUpdate !== false,
|
|
216
|
+
installed: installed,
|
|
217
|
+
deployed: deployed,
|
|
218
|
+
localHead: localHead,
|
|
219
|
+
remoteHead: remoteHead,
|
|
220
|
+
subject: subject,
|
|
221
|
+
updateAvail: updateAvail,
|
|
222
|
+
hasBuild: !!(p.build || p.bundle),
|
|
223
|
+
pluginFile: p.pluginFile,
|
|
224
|
+
_raw: p
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return list;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function fetchPluginRemotes(pluginItems) {
|
|
231
|
+
for (var p of pluginItems) {
|
|
232
|
+
if (!p.installed) continue;
|
|
233
|
+
var dir = join(REPOS_DIR, p.folderName);
|
|
234
|
+
gitText(["git", "fetch", "origin"], dir);
|
|
235
|
+
for (var ref of ["origin/HEAD", "origin/main", "origin/master"]) {
|
|
236
|
+
var h = gitText(["git", "rev-parse", ref], dir);
|
|
237
|
+
if (h) { p.remoteHead = h; break; }
|
|
238
|
+
}
|
|
239
|
+
p.updateAvail = !!(p.localHead && p.remoteHead && p.localHead !== p.remoteHead);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function runPluginUpdate(pluginItem) {
|
|
244
|
+
var plugins = loadPlugins();
|
|
245
|
+
var repo = plugins.find(function(r) { return r.name === pluginItem.name; });
|
|
246
|
+
if (!repo) return "Plugin not found in plugins.json";
|
|
247
|
+
|
|
248
|
+
var folderName = getFolderName(repo);
|
|
249
|
+
var dir = join(REPOS_DIR, folderName);
|
|
250
|
+
|
|
251
|
+
if (!existsSync(dir)) {
|
|
252
|
+
var parentDir = dirname(dir);
|
|
253
|
+
if (!existsSync(parentDir)) try { mkdirSync(parentDir, { recursive: true }); } catch {}
|
|
254
|
+
try {
|
|
255
|
+
execSync("git clone " + repo.url + " " + folderName, { cwd: REPOS_DIR, timeout: 60000, stdio: "ignore" });
|
|
256
|
+
} catch (e) { return "Clone failed: " + (e.message || e); }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
try { execSync("git pull --ff-only", { cwd: dir, timeout: 30000, stdio: "ignore" }); } catch {}
|
|
260
|
+
|
|
261
|
+
if (repo.install) {
|
|
262
|
+
try { execSync(repo.install.join(" "), { cwd: dir, timeout: 120000, stdio: "ignore" }); }
|
|
263
|
+
catch (e) { return "Install failed"; }
|
|
264
|
+
}
|
|
265
|
+
if (repo.build) {
|
|
266
|
+
try { execSync(repo.build.join(" "), { cwd: dir, timeout: 120000, stdio: "ignore" }); }
|
|
267
|
+
catch (e) { return "Build failed"; }
|
|
268
|
+
}
|
|
269
|
+
if (repo.bundle) {
|
|
270
|
+
try { execSync(repo.bundle.join(" "), { cwd: dir, timeout: 120000, stdio: "ignore" }); }
|
|
271
|
+
catch (e) { return "Bundle failed"; }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
var outputPath = join(dir, repo.output);
|
|
275
|
+
var destPath = join(PLUGINS_DIR, repo.pluginFile);
|
|
276
|
+
|
|
277
|
+
if (!existsSync(PLUGINS_DIR)) try { mkdirSync(PLUGINS_DIR, { recursive: true }); } catch {}
|
|
278
|
+
|
|
279
|
+
if (existsSync(outputPath)) {
|
|
280
|
+
try { copyFileSync(outputPath, destPath); }
|
|
281
|
+
catch (e) { return "Copy failed"; }
|
|
282
|
+
} else {
|
|
283
|
+
return "Build output not found: " + repo.output;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return null; // success
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// ANSI
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
var E = "\x1b[";
|
|
294
|
+
var RST = E + "0m";
|
|
295
|
+
var BOLD = E + "1m";
|
|
296
|
+
var DIM = E + "2m";
|
|
297
|
+
var GRAY = E + "90m";
|
|
298
|
+
var WHITE = E + "37m";
|
|
299
|
+
var YELLOW = E + "33m";
|
|
300
|
+
var GREEN = E + "32m";
|
|
301
|
+
var CYAN = E + "36m";
|
|
302
|
+
var RED = E + "31m";
|
|
303
|
+
var BLUE = E + "34m";
|
|
304
|
+
var MAGENTA = E + "35m";
|
|
305
|
+
var BG_SEL = E + "48;5;236m";
|
|
306
|
+
var CLR = E + "K";
|
|
307
|
+
|
|
308
|
+
var _buf = "";
|
|
309
|
+
function b(s) { _buf += s; }
|
|
310
|
+
function flush() { process.stderr.write(_buf); _buf = ""; }
|
|
311
|
+
function hideCur() { process.stderr.write(E + "?25l"); }
|
|
312
|
+
function showCur() { process.stderr.write(E + "?25h"); }
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// State
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
var items = buildList();
|
|
319
|
+
var pluginItems = buildPluginList();
|
|
320
|
+
var cursor = 0;
|
|
321
|
+
var pcursor = 0; // plugin page cursor
|
|
322
|
+
var mode = "list"; // "list" | "actions" | "input" | "pactions"
|
|
323
|
+
var page = "projects"; // "projects" | "plugins"
|
|
324
|
+
var acursor = 0;
|
|
325
|
+
var pacursor = 0; // plugin action cursor
|
|
326
|
+
var message = "";
|
|
327
|
+
var msgTimeout = null;
|
|
328
|
+
var scrollOff = 0;
|
|
329
|
+
var pscrollOff = 0;
|
|
330
|
+
var inputBuf = "";
|
|
331
|
+
var chpathDir = "";
|
|
332
|
+
var pluginFetched = false;
|
|
333
|
+
var pluginUpdating = "";
|
|
334
|
+
var commitItems = [];
|
|
335
|
+
var ccursor = 0;
|
|
336
|
+
var cscrollOff = 0;
|
|
337
|
+
|
|
338
|
+
function flash(msg) {
|
|
339
|
+
message = msg;
|
|
340
|
+
if (msgTimeout) clearTimeout(msgTimeout);
|
|
341
|
+
msgTimeout = setTimeout(function() { message = ""; render(); }, 2500);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// Project actions
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
function getActions(item) {
|
|
349
|
+
var a = [
|
|
350
|
+
{ key: "open", label: "Open in OpenCode", icon: ">" },
|
|
351
|
+
];
|
|
352
|
+
if (item.pinned) {
|
|
353
|
+
a.push({ key: "unpin", label: "Unpin from favorites", icon: "x" });
|
|
354
|
+
} else {
|
|
355
|
+
a.push({ key: "pin", label: "Pin to favorites", icon: "*" });
|
|
356
|
+
}
|
|
357
|
+
a.push({ key: "hide", label: "Hide from list", icon: "-" });
|
|
358
|
+
a.push({ key: "chpath", label: "Change path", icon: "~" });
|
|
359
|
+
a.push({ key: "unhide", label: "Show hidden projects", icon: "+" });
|
|
360
|
+
a.push({ key: "cancel", label: "Cancel", icon: "<" });
|
|
361
|
+
return a;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function getPluginActions(pitem) {
|
|
365
|
+
var a = [];
|
|
366
|
+
if (pitem.updateAvail || !pitem.deployed) {
|
|
367
|
+
a.push({ key: "update", label: "Update now" });
|
|
368
|
+
}
|
|
369
|
+
if (pitem.autoUpdate) {
|
|
370
|
+
a.push({ key: "disable-auto", label: "Disable auto-update" });
|
|
371
|
+
} else {
|
|
372
|
+
a.push({ key: "enable-auto", label: "Enable auto-update" });
|
|
373
|
+
}
|
|
374
|
+
a.push({ key: "force-update", label: "Force rebuild & deploy" });
|
|
375
|
+
a.push({ key: "commits", label: "Select specific commit (Downgrade)" });
|
|
376
|
+
a.push({ key: "cancel", label: "Cancel" });
|
|
377
|
+
return a;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function outputDir(dir) {
|
|
381
|
+
var outFile = process.env.OC_OUTPUT;
|
|
382
|
+
if (outFile) {
|
|
383
|
+
writeFileSync(outFile, dir, "utf-8");
|
|
384
|
+
} else {
|
|
385
|
+
process.stdout.write(dir);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function openProject(item) {
|
|
390
|
+
cleanup();
|
|
391
|
+
outputDir(item.dir);
|
|
392
|
+
process.exit(0);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function togglePin(idx) {
|
|
396
|
+
var item = items[idx];
|
|
397
|
+
var cfg = loadConfig();
|
|
398
|
+
if (item.pinned) {
|
|
399
|
+
cfg.pinned = cfg.pinned.filter(function(d) { return d !== item.dir; });
|
|
400
|
+
flash("Unpinned: " + item.name);
|
|
401
|
+
} else {
|
|
402
|
+
cfg.pinned.push(item.dir);
|
|
403
|
+
flash("Pinned: " + item.name);
|
|
404
|
+
}
|
|
405
|
+
saveConfig(cfg);
|
|
406
|
+
items = buildList();
|
|
407
|
+
if (cursor >= items.length) cursor = Math.max(0, items.length - 1);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function hideItem(idx) {
|
|
411
|
+
var item = items[idx];
|
|
412
|
+
var cfg = loadConfig();
|
|
413
|
+
if (cfg.hidden.indexOf(item.dir) === -1) cfg.hidden.push(item.dir);
|
|
414
|
+
cfg.pinned = cfg.pinned.filter(function(d) { return d !== item.dir; });
|
|
415
|
+
saveConfig(cfg);
|
|
416
|
+
flash("Hidden: " + item.name);
|
|
417
|
+
items = buildList();
|
|
418
|
+
if (cursor >= items.length) cursor = Math.max(0, items.length - 1);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function unhideAll() {
|
|
422
|
+
var cfg = loadConfig();
|
|
423
|
+
var count = cfg.hidden.length;
|
|
424
|
+
cfg.hidden = [];
|
|
425
|
+
saveConfig(cfg);
|
|
426
|
+
flash("Restored " + count + " hidden project(s)");
|
|
427
|
+
items = buildList();
|
|
428
|
+
if (cursor >= items.length) cursor = Math.max(0, items.length - 1);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function getProjectId(dir) {
|
|
432
|
+
try {
|
|
433
|
+
var root = execSync("git rev-list --max-parents=0 HEAD", { cwd: dir, encoding: "utf-8", timeout: 5000 });
|
|
434
|
+
var lines = root.trim().split("\n").filter(Boolean).map(function(x) { return x.trim(); }).sort();
|
|
435
|
+
return lines[0] || null;
|
|
436
|
+
} catch (e) { return null; }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function changeProjectPath(oldDir, newDir) {
|
|
440
|
+
if (!existsSync(DB_PATH)) { flash("DB not found"); return; }
|
|
441
|
+
try {
|
|
442
|
+
var db = new Database(DB_PATH);
|
|
443
|
+
var count = db.query("SELECT COUNT(*) as c FROM session WHERE directory = ?").get(oldDir);
|
|
444
|
+
if (!count || count.c === 0) { db.close(); flash("No sessions at old path"); return; }
|
|
445
|
+
|
|
446
|
+
var oldSess = db.query("SELECT project_id FROM session WHERE directory = ? LIMIT 1").get(oldDir);
|
|
447
|
+
var oldPid = oldSess.project_id;
|
|
448
|
+
var newPid = getProjectId(newDir);
|
|
449
|
+
|
|
450
|
+
if (newPid) {
|
|
451
|
+
var existing = db.query("SELECT id FROM project WHERE id = ?").get(newPid);
|
|
452
|
+
if (existing) {
|
|
453
|
+
db.run("UPDATE session SET project_id = ?, directory = ? WHERE directory = ?", [newPid, newDir, oldDir]);
|
|
454
|
+
} else if (oldPid !== "global") {
|
|
455
|
+
db.run("UPDATE project SET id = ?, worktree = ? WHERE id = ?", [newPid, newDir, oldPid]);
|
|
456
|
+
db.run("UPDATE session SET project_id = ?, directory = ? WHERE directory = ?", [newPid, newDir, oldDir]);
|
|
457
|
+
} else {
|
|
458
|
+
var now = Date.now();
|
|
459
|
+
db.run("INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES (?, ?, ?, ?, '[]')", [newPid, newDir, now, now]);
|
|
460
|
+
db.run("UPDATE session SET project_id = ?, directory = ? WHERE directory = ?", [newPid, newDir, oldDir]);
|
|
461
|
+
}
|
|
462
|
+
try {
|
|
463
|
+
var gitDir = join(newDir, ".git");
|
|
464
|
+
if (existsSync(gitDir)) writeFileSync(join(gitDir, "opencode"), newPid);
|
|
465
|
+
} catch (e) {}
|
|
466
|
+
} else {
|
|
467
|
+
db.run("UPDATE session SET project_id = 'global', directory = ? WHERE directory = ?", [newDir, oldDir]);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (oldPid !== "global" && oldPid !== newPid) {
|
|
471
|
+
var rem = db.query("SELECT COUNT(*) as c FROM session WHERE project_id = ?").get(oldPid);
|
|
472
|
+
if (!rem || rem.c === 0) db.run("DELETE FROM project WHERE id = ?", [oldPid]);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
db.close();
|
|
476
|
+
var cfg = loadConfig();
|
|
477
|
+
var pidx = cfg.pinned.indexOf(oldDir);
|
|
478
|
+
if (pidx !== -1) cfg.pinned[pidx] = newDir;
|
|
479
|
+
var hidx = cfg.hidden.indexOf(oldDir);
|
|
480
|
+
if (hidx !== -1) cfg.hidden[hidx] = newDir;
|
|
481
|
+
saveConfig(cfg);
|
|
482
|
+
flash("Moved " + count.c + " sessions to new path");
|
|
483
|
+
items = buildList();
|
|
484
|
+
if (cursor >= items.length) cursor = Math.max(0, items.length - 1);
|
|
485
|
+
} catch (e) {
|
|
486
|
+
flash("Error: " + (e.message || e));
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
// Render: projects page
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
|
|
494
|
+
function buildProjectItem(pushBody, i, item, nameW, cols, isSelected) {
|
|
495
|
+
var sel = i === cursor;
|
|
496
|
+
var arrow = sel ? (YELLOW + " > " + RST) : " ";
|
|
497
|
+
var bg = sel ? BG_SEL : "";
|
|
498
|
+
var nameStyle = sel ? (BOLD + WHITE) : DIM;
|
|
499
|
+
var sessStr = GRAY + pad(item.sessions + " sess", 8) + RST;
|
|
500
|
+
var timeStr = GRAY + pad(timeAgo(item.lastUsed), 9) + RST;
|
|
501
|
+
var pinMark = item.pinned ? (YELLOW + " *" + RST) : "";
|
|
502
|
+
|
|
503
|
+
pushBody(" " + bg + arrow + nameStyle + pad(trunc(item.name, nameW), nameW) + RST + bg + sessStr + timeStr + pinMark + RST, isSelected);
|
|
504
|
+
|
|
505
|
+
if (sel && (mode === "list" || mode === "actions")) {
|
|
506
|
+
pushBody(" " + GRAY + " " + trunc(shortPath(item.dir), cols - 10) + RST, isSelected);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (sel && mode === "actions") {
|
|
510
|
+
pushBody("", isSelected);
|
|
511
|
+
var acts = getActions(item);
|
|
512
|
+
for (var j = 0; j < acts.length; j++) {
|
|
513
|
+
var a = acts[j];
|
|
514
|
+
var aSel = j === acursor;
|
|
515
|
+
var lbl = trunc(a.label, cols - 12);
|
|
516
|
+
if (aSel) {
|
|
517
|
+
pushBody(" " + GREEN + " > " + BOLD + lbl + RST, isSelected);
|
|
518
|
+
} else {
|
|
519
|
+
pushBody(" " + GRAY + " " + lbl + RST, isSelected);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
pushBody("", isSelected);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function buildProjects(pushBody, pushFoot, cols, barW) {
|
|
527
|
+
var nameW = Math.min(28, Math.max(16, cols - 36));
|
|
528
|
+
|
|
529
|
+
if (items.length === 0) {
|
|
530
|
+
pushBody(" " + GRAY + "No projects found." + RST, false);
|
|
531
|
+
pushBody(" " + GRAY + "Use OpenCode in a directory first, then come back." + RST, false);
|
|
532
|
+
pushBody("", false);
|
|
533
|
+
|
|
534
|
+
pushFoot(" " + GRAY + "-".repeat(barW) + RST);
|
|
535
|
+
pushFoot(" " + GRAY + "Q" + RST + " Quit " + GRAY + "U" + RST + " Unhide all");
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
var pinnedCount = 0;
|
|
540
|
+
for (var i = 0; i < items.length; i++) { if (items[i].pinned) pinnedCount++; }
|
|
541
|
+
var recentCount = items.length - pinnedCount;
|
|
542
|
+
|
|
543
|
+
if (pinnedCount > 0) {
|
|
544
|
+
pushBody(" " + YELLOW + "*" + GRAY + " Pinned" + RST, false);
|
|
545
|
+
for (var i = 0; i < pinnedCount; i++) {
|
|
546
|
+
buildProjectItem(pushBody, i, items[i], nameW, cols, i === cursor);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (pinnedCount > 0 && recentCount > 0) pushBody("", false);
|
|
551
|
+
|
|
552
|
+
if (recentCount > 0) {
|
|
553
|
+
var countLabel = recentCount > 0 ? " (" + recentCount + ")" : "";
|
|
554
|
+
pushBody(" " + BLUE + "~" + GRAY + " Recent" + countLabel + RST, false);
|
|
555
|
+
for (var i = pinnedCount; i < items.length; i++) {
|
|
556
|
+
buildProjectItem(pushBody, i, items[i], nameW, cols, i === cursor);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
pushBody("", false);
|
|
561
|
+
|
|
562
|
+
if (message) {
|
|
563
|
+
pushFoot(" " + GREEN + " " + trunc(message, cols - 5) + RST);
|
|
564
|
+
}
|
|
565
|
+
pushFoot(" " + GRAY + "-".repeat(barW) + RST);
|
|
566
|
+
|
|
567
|
+
if (mode === "input") {
|
|
568
|
+
var inputLabel = chpathDir ? "New path: " : "Path: ";
|
|
569
|
+
var maxInput = Math.max(10, cols - 15 - inputLabel.length);
|
|
570
|
+
var displayInput = inputBuf.length > maxInput ? "…" + inputBuf.substring(inputBuf.length - maxInput + 1) : inputBuf;
|
|
571
|
+
pushFoot(" " + CYAN + inputLabel + RST + displayInput + BOLD + "|" + RST);
|
|
572
|
+
pushFoot(" " + DIM + "Enter" + RST + " Confirm " + DIM + "Esc" + RST + " Cancel" + RST);
|
|
573
|
+
} else if (mode === "list") {
|
|
574
|
+
pushFoot(" " + DIM + "^v" + RST + "/" + DIM + "WS" + RST + " Move " +
|
|
575
|
+
DIM + "Enter" + RST + " Select " +
|
|
576
|
+
DIM + "P" + RST + " Pin " +
|
|
577
|
+
DIM + "H" + RST + " Hide " +
|
|
578
|
+
DIM + "O" + RST + " Open " +
|
|
579
|
+
DIM + "C" + RST + " Custom " +
|
|
580
|
+
DIM + "Q" + RST + " Quit" + RST);
|
|
581
|
+
} else {
|
|
582
|
+
pushFoot(" " + DIM + "^v" + RST + "/" + DIM + "WS" + RST + " Move " +
|
|
583
|
+
DIM + "Enter" + RST + " Confirm " +
|
|
584
|
+
DIM + "Esc" + RST + " Back" + RST);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ---------------------------------------------------------------------------
|
|
589
|
+
// Render: plugins page
|
|
590
|
+
// ---------------------------------------------------------------------------
|
|
591
|
+
|
|
592
|
+
function buildPluginItem(pushBody, i, pitem, nameW, cols, isSelected) {
|
|
593
|
+
var sel = i === pcursor;
|
|
594
|
+
var arrow = sel ? (YELLOW + " > " + RST) : " ";
|
|
595
|
+
var bg = sel ? BG_SEL : "";
|
|
596
|
+
var nameStyle = sel ? (BOLD + WHITE) : DIM;
|
|
597
|
+
|
|
598
|
+
var statusParts = [];
|
|
599
|
+
if (pitem.autoUpdate) {
|
|
600
|
+
statusParts.push(GREEN + "auto" + RST);
|
|
601
|
+
} else {
|
|
602
|
+
statusParts.push(YELLOW + "manual" + RST);
|
|
603
|
+
}
|
|
604
|
+
if (pitem.updateAvail) {
|
|
605
|
+
statusParts.push(CYAN + "UPDATE" + RST);
|
|
606
|
+
} else if (pitem.deployed) {
|
|
607
|
+
statusParts.push(GRAY + "ok" + RST);
|
|
608
|
+
} else {
|
|
609
|
+
statusParts.push(RED + "missing" + RST);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
var statusStr = statusParts.join(GRAY + " | " + RST);
|
|
613
|
+
var commitStr = pitem.localHead ? (GRAY + pitem.localHead.substring(0, 7) + RST) : (GRAY + "---" + RST);
|
|
614
|
+
|
|
615
|
+
pushBody(" " + bg + arrow + nameStyle + pad(trunc(pitem.name, nameW), nameW) + RST + bg + " " + statusStr + " " + commitStr + RST, isSelected);
|
|
616
|
+
|
|
617
|
+
if (sel) {
|
|
618
|
+
var subInfo = GRAY + " " + trunc(pitem.subject || pitem.url, cols - 10) + RST;
|
|
619
|
+
pushBody(" " + subInfo, isSelected);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (sel && mode === "pactions") {
|
|
623
|
+
pushBody("", isSelected);
|
|
624
|
+
var acts = getPluginActions(pitem);
|
|
625
|
+
for (var j = 0; j < acts.length; j++) {
|
|
626
|
+
var a = acts[j];
|
|
627
|
+
var aSel = j === pacursor;
|
|
628
|
+
if (aSel) {
|
|
629
|
+
pushBody(" " + GREEN + " > " + BOLD + a.label + RST, isSelected);
|
|
630
|
+
} else {
|
|
631
|
+
pushBody(" " + GRAY + " " + a.label + RST, isSelected);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
pushBody("", isSelected);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function buildPlugins(pushBody, pushFoot, cols, barW) {
|
|
639
|
+
var nameW = Math.min(32, Math.max(20, cols - 44));
|
|
640
|
+
|
|
641
|
+
if (mode === "pcommits") {
|
|
642
|
+
pushBody(" " + MAGENTA + "#" + GRAY + " Select commit for " + pluginItems[pcursor].name + RST, false);
|
|
643
|
+
for (var i = 0; i < commitItems.length; i++) {
|
|
644
|
+
var c = commitItems[i];
|
|
645
|
+
var sel = i === ccursor;
|
|
646
|
+
var arrow = sel ? (YELLOW + " > " + RST) : " ";
|
|
647
|
+
var bg = sel ? BG_SEL : "";
|
|
648
|
+
var nameStyle = sel ? (BOLD + WHITE) : DIM;
|
|
649
|
+
pushBody(" " + bg + arrow + nameStyle + c.hash + RST + bg + " " + pad(c.time, 12) + " " + trunc(c.subject, Math.max(10, cols - 30)) + RST, sel);
|
|
650
|
+
}
|
|
651
|
+
pushBody("", false);
|
|
652
|
+
|
|
653
|
+
if (message) {
|
|
654
|
+
pushFoot(" " + GREEN + " " + trunc(message, cols - 5) + RST);
|
|
655
|
+
}
|
|
656
|
+
pushFoot(" " + GRAY + "-".repeat(barW) + RST);
|
|
657
|
+
pushFoot(" " + DIM + "^v" + RST + "/" + DIM + "WS" + RST + " Move " +
|
|
658
|
+
DIM + "Enter" + RST + " Checkout " +
|
|
659
|
+
DIM + "Esc" + RST + " Cancel" + RST);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (pluginItems.length === 0) {
|
|
664
|
+
pushBody(" " + GRAY + "No plugins configured." + RST, false);
|
|
665
|
+
pushBody(" " + GRAY + "Add plugins to ~/.config/opencode/config/plugins.json" + RST, false);
|
|
666
|
+
pushBody("", false);
|
|
667
|
+
|
|
668
|
+
pushFoot(" " + GRAY + "-".repeat(barW) + RST);
|
|
669
|
+
pushFoot(" " + GRAY + "Q" + RST + " Quit");
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
var autoCount = 0, manualCount = 0, updateCount = 0;
|
|
674
|
+
for (var p of pluginItems) {
|
|
675
|
+
if (p.autoUpdate) autoCount++; else manualCount++;
|
|
676
|
+
if (p.updateAvail) updateCount++;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
pushBody(" " + MAGENTA + "#" + GRAY + " Plugins " +
|
|
680
|
+
DIM + "(" + autoCount + " auto, " + manualCount + " manual" +
|
|
681
|
+
(updateCount > 0 ? ", " + CYAN + updateCount + " updates" + DIM : "") +
|
|
682
|
+
")" + RST, false);
|
|
683
|
+
|
|
684
|
+
if (!pluginFetched) {
|
|
685
|
+
pushBody(" " + GRAY + " Press " + RST + "F" + GRAY + " to check for updates" + RST, false);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
for (var i = 0; i < pluginItems.length; i++) {
|
|
689
|
+
buildPluginItem(pushBody, i, pluginItems[i], nameW, cols, i === pcursor);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
pushBody("", false);
|
|
693
|
+
|
|
694
|
+
if (message) {
|
|
695
|
+
pushFoot(" " + GREEN + " " + trunc(message, cols - 5) + RST);
|
|
696
|
+
}
|
|
697
|
+
pushFoot(" " + GRAY + "-".repeat(barW) + RST);
|
|
698
|
+
|
|
699
|
+
if (mode === "pactions") {
|
|
700
|
+
pushFoot(" " + DIM + "^v" + RST + "/" + DIM + "WS" + RST + " Move " +
|
|
701
|
+
DIM + "Enter" + RST + " Confirm " +
|
|
702
|
+
DIM + "Esc" + RST + " Back" + RST);
|
|
703
|
+
} else {
|
|
704
|
+
pushFoot(" " + DIM + "^v" + RST + "/" + DIM + "WS" + RST + " Move " +
|
|
705
|
+
DIM + "Enter" + RST + " Select " +
|
|
706
|
+
DIM + "F" + RST + " Fetch " +
|
|
707
|
+
DIM + "A" + RST + " Toggle auto " +
|
|
708
|
+
DIM + "U" + RST + " Update " +
|
|
709
|
+
DIM + "Q" + RST + " Quit" + RST);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// ---------------------------------------------------------------------------
|
|
714
|
+
// Main render
|
|
715
|
+
// ---------------------------------------------------------------------------
|
|
716
|
+
|
|
717
|
+
function render() {
|
|
718
|
+
var cols = process.stderr.columns || 80;
|
|
719
|
+
var totalRows = (process.stderr.rows || 24) - 1;
|
|
720
|
+
var barW = Math.min(56, cols - 4);
|
|
721
|
+
|
|
722
|
+
var headLines = [];
|
|
723
|
+
var bodyLines = [];
|
|
724
|
+
var footLines = [];
|
|
725
|
+
var selStart = 0;
|
|
726
|
+
var selEnd = 0;
|
|
727
|
+
|
|
728
|
+
function pushHead(s) { headLines.push(s); }
|
|
729
|
+
function pushBody(s, isSelLine) {
|
|
730
|
+
if (isSelLine && selStart === 0) selStart = bodyLines.length;
|
|
731
|
+
bodyLines.push(s);
|
|
732
|
+
if (isSelLine) selEnd = bodyLines.length;
|
|
733
|
+
}
|
|
734
|
+
function pushFoot(s) { footLines.push(s); }
|
|
735
|
+
|
|
736
|
+
// 1. Build Header
|
|
737
|
+
pushHead("");
|
|
738
|
+
pushHead(" " + BOLD + CYAN + " OpenCode" + RST + GRAY + " Launcher" + RST);
|
|
739
|
+
pushHead(" " + GRAY + "-".repeat(barW) + RST);
|
|
740
|
+
var showPluginsTab = pluginItems.length > 0;
|
|
741
|
+
var projTab = page === "projects" ? (BOLD + WHITE + BG_SEL + " Projects " + RST) : (GRAY + " Projects " + RST);
|
|
742
|
+
var plugTab = showPluginsTab ? (page === "plugins" ? (BOLD + WHITE + BG_SEL + " Plugins " + RST) : (GRAY + " Plugins " + RST)) : "";
|
|
743
|
+
pushHead(" " + projTab + (showPluginsTab ? " " + plugTab + " " + DIM + "<- ->" + RST : ""));
|
|
744
|
+
pushHead("");
|
|
745
|
+
|
|
746
|
+
// 2. Build Body & Footer
|
|
747
|
+
if (page === "projects") {
|
|
748
|
+
buildProjects(pushBody, pushFoot, cols, barW);
|
|
749
|
+
} else {
|
|
750
|
+
buildPlugins(pushBody, pushFoot, cols, barW);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// 3. Viewport calculation
|
|
754
|
+
var maxBody = Math.max(2, totalRows - headLines.length - footLines.length);
|
|
755
|
+
|
|
756
|
+
var activeScroll = 0;
|
|
757
|
+
if (page === "projects") activeScroll = scrollOff;
|
|
758
|
+
else if (mode === "pcommits") activeScroll = cscrollOff;
|
|
759
|
+
else activeScroll = pscrollOff;
|
|
760
|
+
|
|
761
|
+
if (bodyLines.length > maxBody) {
|
|
762
|
+
if (selStart < activeScroll) activeScroll = selStart;
|
|
763
|
+
if (selEnd > activeScroll + maxBody) activeScroll = selEnd - maxBody;
|
|
764
|
+
if (activeScroll > bodyLines.length - maxBody) activeScroll = bodyLines.length - maxBody;
|
|
765
|
+
if (activeScroll < 0) activeScroll = 0;
|
|
766
|
+
|
|
767
|
+
if (page === "projects") scrollOff = activeScroll;
|
|
768
|
+
else if (mode === "pcommits") cscrollOff = activeScroll;
|
|
769
|
+
else pscrollOff = activeScroll;
|
|
770
|
+
|
|
771
|
+
var origLen = bodyLines.length;
|
|
772
|
+
|
|
773
|
+
var hasAbove = activeScroll > 0;
|
|
774
|
+
var hasBelow = activeScroll + maxBody < origLen;
|
|
775
|
+
|
|
776
|
+
var sliceLen = maxBody;
|
|
777
|
+
if (hasAbove) sliceLen--;
|
|
778
|
+
if (hasBelow) sliceLen--;
|
|
779
|
+
|
|
780
|
+
hasBelow = activeScroll + sliceLen < origLen;
|
|
781
|
+
if (hasBelow && !hasAbove && activeScroll > 0) {
|
|
782
|
+
// Re-evaluate in case reducing sliceLen triggered hasAbove
|
|
783
|
+
hasAbove = true;
|
|
784
|
+
sliceLen--;
|
|
785
|
+
hasBelow = activeScroll + sliceLen < origLen;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
var visibleBody = bodyLines.slice(activeScroll, activeScroll + sliceLen);
|
|
789
|
+
|
|
790
|
+
if (hasAbove) {
|
|
791
|
+
visibleBody.unshift(" " + GRAY + " ^ " + activeScroll + " more" + RST);
|
|
792
|
+
}
|
|
793
|
+
if (hasBelow) {
|
|
794
|
+
visibleBody.push(" " + GRAY + " v " + (origLen - (activeScroll + sliceLen)) + " more" + RST);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
bodyLines = visibleBody;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// 4. Render to screen
|
|
801
|
+
_buf = E + "H";
|
|
802
|
+
for (var h of headLines) _buf += h + CLR + "\n";
|
|
803
|
+
for (var b of bodyLines) _buf += b + CLR + "\n";
|
|
804
|
+
for (var f of footLines) _buf += f + CLR + "\n";
|
|
805
|
+
_buf += E + "J";
|
|
806
|
+
|
|
807
|
+
process.stderr.write(_buf);
|
|
808
|
+
_buf = "";
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// ---------------------------------------------------------------------------
|
|
812
|
+
// Key handling
|
|
813
|
+
// ---------------------------------------------------------------------------
|
|
814
|
+
function handleKey(key) {
|
|
815
|
+
// Page switching with left/right (only in list mode, not in actions/input)
|
|
816
|
+
if ((mode === "list") && (key === "left" || key === "right")) {
|
|
817
|
+
var showPluginsTab = pluginItems.length > 0;
|
|
818
|
+
if (key === "left" && page === "plugins") { page = "projects"; mode = "list"; render(); return; }
|
|
819
|
+
if (key === "right" && page === "projects" && showPluginsTab) { page = "plugins"; mode = "list"; render(); return; }
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (page === "projects") {
|
|
824
|
+
handleProjectKey(key);
|
|
825
|
+
} else {
|
|
826
|
+
handlePluginKey(key);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function handleProjectKey(key) {
|
|
831
|
+
if (mode === "list") {
|
|
832
|
+
if (key === "up" || key === "w") { cursor = Math.max(0, cursor - 1); }
|
|
833
|
+
else if (key === "down" || key === "s") { cursor = Math.min(items.length - 1, cursor + 1); }
|
|
834
|
+
else if (key === "enter" || key === "space") {
|
|
835
|
+
if (items.length > 0) { mode = "actions"; acursor = 0; }
|
|
836
|
+
}
|
|
837
|
+
else if (key === "o") { if (items.length > 0) openProject(items[cursor]); }
|
|
838
|
+
else if (key === "p") { if (items.length > 0) togglePin(cursor); }
|
|
839
|
+
else if (key === "h") { if (items.length > 0) hideItem(cursor); }
|
|
840
|
+
else if (key === "u") { unhideAll(); }
|
|
841
|
+
else if (key === "c") { mode = "input"; inputBuf = ""; }
|
|
842
|
+
else if (key === "q" || key === "escape") { cleanup(); process.exit(1); }
|
|
843
|
+
} else if (mode === "actions") {
|
|
844
|
+
var acts = getActions(items[cursor]);
|
|
845
|
+
if (key === "up" || key === "w") { acursor = Math.max(0, acursor - 1); }
|
|
846
|
+
else if (key === "down" || key === "s") { acursor = Math.min(acts.length - 1, acursor + 1); }
|
|
847
|
+
else if (key === "enter" || key === "space") {
|
|
848
|
+
var action = acts[acursor].key;
|
|
849
|
+
if (action === "open") { openProject(items[cursor]); }
|
|
850
|
+
else if (action === "pin" || action === "unpin") { togglePin(cursor); mode = "list"; }
|
|
851
|
+
else if (action === "hide") { hideItem(cursor); mode = "list"; }
|
|
852
|
+
else if (action === "chpath") { mode = "input"; chpathDir = items[cursor].dir; inputBuf = items[cursor].dir; }
|
|
853
|
+
else if (action === "unhide") { unhideAll(); mode = "list"; }
|
|
854
|
+
else { mode = "list"; }
|
|
855
|
+
}
|
|
856
|
+
else if (key === "escape" || key === "q" || key === "left") { mode = "list"; }
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function handlePluginKey(key) {
|
|
861
|
+
if (mode === "list") {
|
|
862
|
+
if (key === "up" || key === "w") { pcursor = Math.max(0, pcursor - 1); }
|
|
863
|
+
else if (key === "down" || key === "s") { pcursor = Math.min(pluginItems.length - 1, pcursor + 1); }
|
|
864
|
+
else if (key === "enter" || key === "space") {
|
|
865
|
+
if (pluginItems.length > 0) { mode = "pactions"; pacursor = 0; }
|
|
866
|
+
}
|
|
867
|
+
else if (key === "f") {
|
|
868
|
+
flash("Fetching remotes...");
|
|
869
|
+
render();
|
|
870
|
+
fetchPluginRemotes(pluginItems);
|
|
871
|
+
pluginFetched = true;
|
|
872
|
+
var updateCount = 0;
|
|
873
|
+
for (var p of pluginItems) { if (p.updateAvail) updateCount++; }
|
|
874
|
+
flash(updateCount > 0 ? updateCount + " update(s) available" : "All plugins up to date");
|
|
875
|
+
}
|
|
876
|
+
else if (key === "a") {
|
|
877
|
+
if (pluginItems.length > 0) {
|
|
878
|
+
var p = pluginItems[pcursor];
|
|
879
|
+
p.autoUpdate = !p.autoUpdate;
|
|
880
|
+
var plugins = loadPlugins();
|
|
881
|
+
var match = plugins.find(function(r) { return r.name === p.name; });
|
|
882
|
+
if (match) { match.autoUpdate = p.autoUpdate; savePlugins(plugins); }
|
|
883
|
+
flash(p.name + ": auto-update " + (p.autoUpdate ? "ON" : "OFF"));
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
else if (key === "u") {
|
|
887
|
+
if (pluginItems.length > 0) {
|
|
888
|
+
var p = pluginItems[pcursor];
|
|
889
|
+
flash("Updating " + p.name + "...");
|
|
890
|
+
render();
|
|
891
|
+
var err = runPluginUpdate(p);
|
|
892
|
+
pluginItems = buildPluginList();
|
|
893
|
+
if (pcursor >= pluginItems.length) pcursor = Math.max(0, pluginItems.length - 1);
|
|
894
|
+
flash(err ? p.name + ": " + err : p.name + " updated. Restart OpenCode to apply.");
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
else if (key === "q" || key === "escape") { cleanup(); process.exit(1); }
|
|
898
|
+
} else if (mode === "pactions") {
|
|
899
|
+
var pitem = pluginItems[pcursor];
|
|
900
|
+
var acts = getPluginActions(pitem);
|
|
901
|
+
if (key === "up" || key === "w") { pacursor = Math.max(0, pacursor - 1); }
|
|
902
|
+
else if (key === "down" || key === "s") { pacursor = Math.min(acts.length - 1, pacursor + 1); }
|
|
903
|
+
else if (key === "enter" || key === "space") {
|
|
904
|
+
var action = acts[pacursor].key;
|
|
905
|
+
if (action === "update" || action === "force-update") {
|
|
906
|
+
flash("Updating " + pitem.name + "...");
|
|
907
|
+
render();
|
|
908
|
+
var err = runPluginUpdate(pitem);
|
|
909
|
+
pluginItems = buildPluginList();
|
|
910
|
+
if (pcursor >= pluginItems.length) pcursor = Math.max(0, pluginItems.length - 1);
|
|
911
|
+
flash(err ? pitem.name + ": " + err : pitem.name + " updated. Restart OpenCode to apply.");
|
|
912
|
+
mode = "list";
|
|
913
|
+
}
|
|
914
|
+
else if (action === "enable-auto" || action === "disable-auto") {
|
|
915
|
+
var newVal = action === "enable-auto";
|
|
916
|
+
pitem.autoUpdate = newVal;
|
|
917
|
+
var plugins = loadPlugins();
|
|
918
|
+
var match = plugins.find(function(r) { return r.name === pitem.name; });
|
|
919
|
+
if (match) { match.autoUpdate = newVal; savePlugins(plugins); }
|
|
920
|
+
flash(pitem.name + ": auto-update " + (newVal ? "ON" : "OFF"));
|
|
921
|
+
mode = "list";
|
|
922
|
+
}
|
|
923
|
+
else if (action === "commits") {
|
|
924
|
+
var dir = join(REPOS_DIR, pitem.folderName);
|
|
925
|
+
if (!existsSync(dir)) { flash("Not installed locally yet"); mode = "list"; return; }
|
|
926
|
+
try {
|
|
927
|
+
var log = execSync('git log -20 --format="%h|%s|%ar"', { cwd: dir, encoding: "utf-8", timeout: 5000 });
|
|
928
|
+
var lines = log.trim().split("\n");
|
|
929
|
+
commitItems = [];
|
|
930
|
+
for (var i = 0; i < lines.length; i++) {
|
|
931
|
+
if (!lines[i]) continue;
|
|
932
|
+
var parts = lines[i].split("|");
|
|
933
|
+
if (parts.length >= 3) {
|
|
934
|
+
commitItems.push({ hash: parts[0], subject: parts.slice(1, -1).join("|"), time: parts[parts.length-1] });
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
if (commitItems.length > 0) {
|
|
938
|
+
ccursor = 0; cscrollOff = 0; mode = "pcommits";
|
|
939
|
+
} else {
|
|
940
|
+
flash("No commits found"); mode = "list";
|
|
941
|
+
}
|
|
942
|
+
} catch (e) {
|
|
943
|
+
flash("Failed to fetch commits"); mode = "list";
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
else { mode = "list"; }
|
|
947
|
+
}
|
|
948
|
+
else if (key === "escape" || key === "q" || key === "left") { mode = "list"; }
|
|
949
|
+
} else if (mode === "pcommits") {
|
|
950
|
+
if (key === "up" || key === "w") { ccursor = Math.max(0, ccursor - 1); }
|
|
951
|
+
else if (key === "down" || key === "s") { ccursor = Math.min(commitItems.length - 1, ccursor + 1); }
|
|
952
|
+
else if (key === "escape" || key === "q" || key === "left") { mode = "list"; }
|
|
953
|
+
else if (key === "enter" || key === "space") {
|
|
954
|
+
var pitem = pluginItems[pcursor];
|
|
955
|
+
var citem = commitItems[ccursor];
|
|
956
|
+
flash("Downgrading " + pitem.name + " to " + citem.hash + "...");
|
|
957
|
+
render();
|
|
958
|
+
|
|
959
|
+
var dir = join(REPOS_DIR, pitem.folderName);
|
|
960
|
+
try {
|
|
961
|
+
execSync("git reset --hard", { cwd: dir, timeout: 15000, stdio: "ignore" });
|
|
962
|
+
execSync("git checkout " + citem.hash, { cwd: dir, timeout: 15000, stdio: "ignore" });
|
|
963
|
+
} catch (e) {
|
|
964
|
+
flash("Checkout failed"); mode = "list"; return;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
var plugins = loadPlugins();
|
|
968
|
+
var repo = plugins.find(function(r) { return r.name === pitem.name; });
|
|
969
|
+
var err = null;
|
|
970
|
+
if (repo) {
|
|
971
|
+
if (repo.install) {
|
|
972
|
+
try { execSync(repo.install.join(" "), { cwd: dir, timeout: 120000, stdio: "ignore" }); } catch(e) { err="Install failed"; }
|
|
973
|
+
}
|
|
974
|
+
if (!err && repo.build) {
|
|
975
|
+
try { execSync(repo.build.join(" "), { cwd: dir, timeout: 120000, stdio: "ignore" }); } catch(e) { err="Build failed"; }
|
|
976
|
+
}
|
|
977
|
+
if (!err && repo.bundle) {
|
|
978
|
+
try { execSync(repo.bundle.join(" "), { cwd: dir, timeout: 120000, stdio: "ignore" }); } catch(e) { err="Bundle failed"; }
|
|
979
|
+
}
|
|
980
|
+
var outputPath = join(dir, repo.output);
|
|
981
|
+
var destPath = join(PLUGINS_DIR, repo.pluginFile);
|
|
982
|
+
if (!err && existsSync(outputPath)) {
|
|
983
|
+
try { copyFileSync(outputPath, destPath); } catch(e) { err="Copy failed"; }
|
|
984
|
+
} else if (!err) {
|
|
985
|
+
err = "Build output not found";
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
pluginItems = buildPluginList();
|
|
989
|
+
if (err) flash("Error: " + err);
|
|
990
|
+
else flash("Downgraded to " + citem.hash.substring(0,7));
|
|
991
|
+
mode = "list";
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function handleInputData(buf) {
|
|
997
|
+
if (buf[0] === 27) { mode = "list"; chpathDir = ""; return; }
|
|
998
|
+
if (buf[0] === 3) { cleanup(); process.exit(1); }
|
|
999
|
+
if (buf[0] === 13 || buf[0] === 10) {
|
|
1000
|
+
var p = inputBuf.trim();
|
|
1001
|
+
if (p) {
|
|
1002
|
+
if (p.charAt(0) === "~") p = HOME + p.substring(1);
|
|
1003
|
+
p = p.replace(/\//g, "\\");
|
|
1004
|
+
if (chpathDir) {
|
|
1005
|
+
if (p === chpathDir) { flash("Same path, nothing changed"); mode = "list"; chpathDir = ""; return; }
|
|
1006
|
+
if (existsSync(p)) {
|
|
1007
|
+
changeProjectPath(chpathDir, p);
|
|
1008
|
+
} else {
|
|
1009
|
+
flash("Path not found: " + p);
|
|
1010
|
+
}
|
|
1011
|
+
mode = "list"; chpathDir = "";
|
|
1012
|
+
} else {
|
|
1013
|
+
if (existsSync(p)) {
|
|
1014
|
+
cleanup();
|
|
1015
|
+
outputDir(p);
|
|
1016
|
+
process.exit(0);
|
|
1017
|
+
} else {
|
|
1018
|
+
flash("Path not found: " + p);
|
|
1019
|
+
mode = "list";
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
} else {
|
|
1023
|
+
mode = "list"; chpathDir = "";
|
|
1024
|
+
}
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
if (buf[0] === 127 || buf[0] === 8) {
|
|
1028
|
+
inputBuf = inputBuf.substring(0, inputBuf.length - 1);
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
if (buf[0] >= 32 && buf[0] < 127) {
|
|
1032
|
+
inputBuf += String.fromCharCode(buf[0]);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
var s = buf.toString("utf-8");
|
|
1036
|
+
if (s.length > 0) {
|
|
1037
|
+
for (var i = 0; i < s.length; i++) {
|
|
1038
|
+
var c = s.charCodeAt(i);
|
|
1039
|
+
if (c >= 32) inputBuf += s.charAt(i);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function parseKey(buf) {
|
|
1045
|
+
if (buf[0] === 27) {
|
|
1046
|
+
if (buf.length === 1) return "escape";
|
|
1047
|
+
if (buf[1] === 91) {
|
|
1048
|
+
if (buf[2] === 65) return "up";
|
|
1049
|
+
if (buf[2] === 66) return "down";
|
|
1050
|
+
if (buf[2] === 67) return "right";
|
|
1051
|
+
if (buf[2] === 68) return "left";
|
|
1052
|
+
}
|
|
1053
|
+
return null;
|
|
1054
|
+
}
|
|
1055
|
+
if (buf[0] === 13 || buf[0] === 10) return "enter";
|
|
1056
|
+
if (buf[0] === 32) return "space";
|
|
1057
|
+
if (buf[0] === 3) { cleanup(); process.exit(1); }
|
|
1058
|
+
var ch = String.fromCharCode(buf[0]).toLowerCase();
|
|
1059
|
+
if ("wsadqpchouf".indexOf(ch) !== -1) return ch;
|
|
1060
|
+
return null;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// ---------------------------------------------------------------------------
|
|
1064
|
+
// Cleanup & startup
|
|
1065
|
+
// ---------------------------------------------------------------------------
|
|
1066
|
+
|
|
1067
|
+
function cleanup() {
|
|
1068
|
+
showCur();
|
|
1069
|
+
process.stderr.write(E + "H" + E + "2J");
|
|
1070
|
+
try { process.stdin.setRawMode(false); } catch {}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
process.on("exit", function() { showCur(); });
|
|
1074
|
+
process.on("SIGINT", function() { cleanup(); process.exit(1); });
|
|
1075
|
+
process.on("SIGTERM", function() { cleanup(); process.exit(1); });
|
|
1076
|
+
try { process.stderr.on("resize", function() { render(); }); } catch(e) {}
|
|
1077
|
+
|
|
1078
|
+
// Direct argument handling (skip TUI)
|
|
1079
|
+
var arg = process.argv[2];
|
|
1080
|
+
if (arg) {
|
|
1081
|
+
if (/^\d+$/.test(arg)) {
|
|
1082
|
+
var idx = parseInt(arg) - 1;
|
|
1083
|
+
if (idx >= 0 && idx < items.length) {
|
|
1084
|
+
outputDir(items[idx].dir);
|
|
1085
|
+
process.exit(0);
|
|
1086
|
+
}
|
|
1087
|
+
process.stderr.write("Invalid number: " + arg + "\n");
|
|
1088
|
+
process.exit(1);
|
|
1089
|
+
}
|
|
1090
|
+
var match = items.find(function(it) { return it.name.toLowerCase().indexOf(arg.toLowerCase()) !== -1; });
|
|
1091
|
+
if (!match) match = items.find(function(it) { return it.dir.toLowerCase().indexOf(arg.toLowerCase()) !== -1; });
|
|
1092
|
+
if (match) {
|
|
1093
|
+
outputDir(match.dir);
|
|
1094
|
+
process.exit(0);
|
|
1095
|
+
}
|
|
1096
|
+
process.stderr.write("No match for: " + arg + "\n");
|
|
1097
|
+
process.exit(1);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
hideCur();
|
|
1101
|
+
render();
|
|
1102
|
+
process.stdin.setRawMode(true);
|
|
1103
|
+
process.stdin.resume();
|
|
1104
|
+
process.stdin.on("data", function(buf) {
|
|
1105
|
+
if (mode === "input") { handleInputData(buf); render(); return; }
|
|
1106
|
+
var key = parseKey(buf);
|
|
1107
|
+
if (key) { handleKey(key); render(); }
|
|
1108
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-hub",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "TUI launcher for OpenCode - project switcher and plugin manager with oc command",
|
|
5
|
+
"main": "plugin.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "intisy",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/intisy/opencode-launcher.git"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/intisy/opencode-launcher#readme",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"opencode",
|
|
16
|
+
"launcher",
|
|
17
|
+
"tui",
|
|
18
|
+
"project-switcher",
|
|
19
|
+
"plugin"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": "\u003e=20.0.0"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"plugin.js",
|
|
26
|
+
"oc-tui.js",
|
|
27
|
+
"README.md"
|
|
28
|
+
]
|
|
29
|
+
}
|
package/plugin.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { existsSync, writeFileSync, mkdirSync, unlinkSync, readFileSync } from "fs";
|
|
3
|
+
import { join, dirname } from "path";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Find oc-tui.js — works for both npm and plugin-updater installs
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
function findTuiScript() {
|
|
11
|
+
// 1. Same directory as this plugin file (npm install case)
|
|
12
|
+
var sameDirPath = join(import.meta.dir, "oc-tui.js");
|
|
13
|
+
if (existsSync(sameDirPath)) return sameDirPath;
|
|
14
|
+
|
|
15
|
+
// 2. Find config dir, then check repos/intisy/opencode-launcher/ (updater case)
|
|
16
|
+
var configDir = findConfigDir(import.meta.dir);
|
|
17
|
+
if (configDir) {
|
|
18
|
+
var repoPath = join(configDir, "repos", "intisy", "opencode-launcher", "oc-tui.js");
|
|
19
|
+
if (existsSync(repoPath)) return repoPath;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function findConfigDir(start) {
|
|
26
|
+
var dir = start;
|
|
27
|
+
for (var i = 0; i < 8; i++) {
|
|
28
|
+
if (existsSync(join(dir, "opencode.json"))) return dir;
|
|
29
|
+
if (existsSync(join(dir, "config", "plugins.json"))) return dir;
|
|
30
|
+
var parent = dirname(dir);
|
|
31
|
+
if (parent === dir) break;
|
|
32
|
+
dir = parent;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Install / remove the `oc` shell command
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
function getBinDir() {
|
|
42
|
+
if (process.platform === "win32") {
|
|
43
|
+
return join(homedir(), ".local", "bin");
|
|
44
|
+
}
|
|
45
|
+
return join(homedir(), ".local", "bin");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function installOcCommand() {
|
|
49
|
+
var tuiPath = findTuiScript();
|
|
50
|
+
if (!tuiPath) return;
|
|
51
|
+
|
|
52
|
+
var binDir = getBinDir();
|
|
53
|
+
if (!existsSync(binDir)) try { mkdirSync(binDir, { recursive: true }); } catch {}
|
|
54
|
+
|
|
55
|
+
var tuiPathEscaped = tuiPath.replace(/\\/g, "\\\\");
|
|
56
|
+
|
|
57
|
+
if (process.platform === "win32") {
|
|
58
|
+
// oc.cmd for Windows
|
|
59
|
+
var cmdPath = join(binDir, "oc.cmd");
|
|
60
|
+
var cmdContent = '@echo off\r\n'
|
|
61
|
+
+ 'set "tmp=%TEMP%\\oc-output-%RANDOM%.tmp"\r\n'
|
|
62
|
+
+ 'set "OC_OUTPUT=%tmp%"\r\n'
|
|
63
|
+
+ 'bun "' + tuiPath + '" %*\r\n'
|
|
64
|
+
+ 'set /p dir=<"%tmp%" 2>nul\r\n'
|
|
65
|
+
+ 'del "%tmp%" 2>nul\r\n'
|
|
66
|
+
+ 'if defined dir (\r\n'
|
|
67
|
+
+ ' cd /d "%dir%" && opencode\r\n'
|
|
68
|
+
+ ')\r\n';
|
|
69
|
+
try { writeFileSync(cmdPath, cmdContent, "utf-8"); } catch {}
|
|
70
|
+
} else {
|
|
71
|
+
// oc for Unix
|
|
72
|
+
var shPath = join(binDir, "oc");
|
|
73
|
+
var shContent = '#!/bin/sh\n'
|
|
74
|
+
+ 'tmp=$(mktemp)\n'
|
|
75
|
+
+ 'OC_OUTPUT="$tmp" bun "' + tuiPathEscaped + '" "$@"\n'
|
|
76
|
+
+ 'dir=$(cat "$tmp" 2>/dev/null)\n'
|
|
77
|
+
+ 'rm -f "$tmp"\n'
|
|
78
|
+
+ 'if [ -n "$dir" ]; then\n'
|
|
79
|
+
+ ' cd "$dir" && opencode\n'
|
|
80
|
+
+ 'fi\n';
|
|
81
|
+
try {
|
|
82
|
+
writeFileSync(shPath, shContent, { mode: 0o755 });
|
|
83
|
+
} catch {}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function removeOcCommand() {
|
|
88
|
+
var binDir = getBinDir();
|
|
89
|
+
var files = ["oc", "oc.cmd"];
|
|
90
|
+
var removed = [];
|
|
91
|
+
for (var f of files) {
|
|
92
|
+
var p = join(binDir, f);
|
|
93
|
+
if (existsSync(p)) {
|
|
94
|
+
try { unlinkSync(p); removed.push(f); } catch {}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return removed;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Install on load (with guard against dual execution)
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
if (!globalThis.__ocLauncherInstalled) {
|
|
105
|
+
globalThis.__ocLauncherInstalled = true;
|
|
106
|
+
installOcCommand();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Plugin export
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
export default async function OpenCodeLauncher(ctx) {
|
|
114
|
+
return {
|
|
115
|
+
tool: {
|
|
116
|
+
oc_remove: tool({
|
|
117
|
+
description:
|
|
118
|
+
"Remove the oc launcher command. Deletes oc, oc.cmd from ~/.local/bin. The launcher will be reinstalled on next opencode start if the plugin is still active.",
|
|
119
|
+
args: {
|
|
120
|
+
_placeholder: tool.schema.boolean().describe("Placeholder. Always pass true."),
|
|
121
|
+
},
|
|
122
|
+
async execute() {
|
|
123
|
+
var removed = removeOcCommand();
|
|
124
|
+
if (removed.length === 0) return "No oc commands found to remove.";
|
|
125
|
+
return "Removed: " + removed.join(", ") + " from " + getBinDir();
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|