remote-opencode 1.0.10 β 1.2.0
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 +150 -31
- package/dist/src/__tests__/auth.test.js +114 -0
- package/dist/src/__tests__/messageFormatter.test.js +15 -1
- package/dist/src/cli.js +51 -1
- package/dist/src/commands/allow.js +70 -0
- package/dist/src/commands/index.js +2 -0
- package/dist/src/handlers/interactionHandler.js +15 -0
- package/dist/src/handlers/messageHandler.js +3 -0
- package/dist/src/services/configStore.js +35 -2
- package/dist/src/services/executionService.js +11 -9
- package/dist/src/services/worktreeManager.js +9 -0
- package/dist/src/setup/wizard.js +30 -4
- package/dist/src/utils/messageFormatter.js +3 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
> Control your AI coding assistant from anywhere β your phone, tablet, or another computer.
|
|
4
4
|
|
|
5
|
+
 π¦ Used by developers worldwide β **1000+ weekly downloads** on npm
|
|
6
|
+
|
|
5
7
|
<div align="center">
|
|
6
8
|
<img width="1024" alt="Gemini_Generated_Image_47d5gq47d5gq47d5" src="https://github.com/user-attachments/assets/1defa11d-6195-4a9c-956b-4f87470f6393" />
|
|
7
9
|
</div>
|
|
@@ -16,7 +18,6 @@
|
|
|
16
18
|
|
|
17
19
|
## How It Works
|
|
18
20
|
|
|
19
|
-
|
|
20
21
|
```
|
|
21
22
|
βββββββββββββββββββ Discord API βββββββββββββββββββ
|
|
22
23
|
β Your Phone / β ββββββββββββββββΊ β Discord Bot β
|
|
@@ -47,6 +48,7 @@ The bot runs on your development machine alongside OpenCode. When you send a com
|
|
|
47
48
|
- [CLI Commands](#cli-commands)
|
|
48
49
|
- [Discord Slash Commands](#discord-slash-commands)
|
|
49
50
|
- [Usage Workflow](#usage-workflow)
|
|
51
|
+
- [Access Control](#access-control)
|
|
50
52
|
- [Configuration](#configuration)
|
|
51
53
|
- [Troubleshooting](#troubleshooting)
|
|
52
54
|
- [Development](#development)
|
|
@@ -130,13 +132,17 @@ If you prefer manual setup or need to troubleshoot:
|
|
|
130
132
|
|
|
131
133
|
## CLI Commands
|
|
132
134
|
|
|
133
|
-
| Command
|
|
134
|
-
|
|
135
|
-
| `remote-opencode`
|
|
136
|
-
| `remote-opencode setup`
|
|
137
|
-
| `remote-opencode start`
|
|
138
|
-
| `remote-opencode deploy`
|
|
139
|
-
| `remote-opencode config`
|
|
135
|
+
| Command | Description |
|
|
136
|
+
| ------------------------------------------ | ---------------------------------------------------- |
|
|
137
|
+
| `remote-opencode` | Start the bot (shows setup guide if not configured) |
|
|
138
|
+
| `remote-opencode setup` | Interactive setup wizard β configures bot token, IDs |
|
|
139
|
+
| `remote-opencode start` | Start the Discord bot |
|
|
140
|
+
| `remote-opencode deploy` | Deploy/update slash commands to Discord |
|
|
141
|
+
| `remote-opencode config` | Display current configuration info |
|
|
142
|
+
| `remote-opencode allow add <userId>` | Add a Discord user ID to the allowlist |
|
|
143
|
+
| `remote-opencode allow remove <userId>` | Remove a Discord user ID from the allowlist |
|
|
144
|
+
| `remote-opencode allow list` | List all user IDs in the allowlist |
|
|
145
|
+
| `remote-opencode allow reset` | Clear the entire allowlist (removes access control) |
|
|
140
146
|
|
|
141
147
|
---
|
|
142
148
|
|
|
@@ -152,10 +158,10 @@ Register a local project path with an alias for easy reference.
|
|
|
152
158
|
/setpath alias:myapp path:/Users/you/projects/my-app
|
|
153
159
|
```
|
|
154
160
|
|
|
155
|
-
| Parameter | Description
|
|
156
|
-
|
|
157
|
-
| `alias`
|
|
158
|
-
| `path`
|
|
161
|
+
| Parameter | Description |
|
|
162
|
+
| --------- | ----------------------------------------------------- |
|
|
163
|
+
| `alias` | Short name for the project (e.g., `myapp`, `backend`) |
|
|
164
|
+
| `path` | Absolute path to the project on your machine |
|
|
159
165
|
|
|
160
166
|
### `/projects` β List Registered Projects
|
|
161
167
|
|
|
@@ -184,6 +190,7 @@ The main command β sends a prompt to OpenCode and streams the response.
|
|
|
184
190
|
```
|
|
185
191
|
|
|
186
192
|
**Features:**
|
|
193
|
+
|
|
187
194
|
- π§΅ **Auto-creates a thread** for each conversation
|
|
188
195
|
- β‘ **Real-time streaming** β see output as it's generated (1-second updates)
|
|
189
196
|
- βΈοΈ **Interrupt button** β stop the current task if needed
|
|
@@ -197,12 +204,13 @@ Start isolated work on a new branch with its own worktree.
|
|
|
197
204
|
/work branch:feature/dark-mode description:Implement dark mode toggle
|
|
198
205
|
```
|
|
199
206
|
|
|
200
|
-
| Parameter
|
|
201
|
-
|
|
202
|
-
| `branch`
|
|
203
|
-
| `description` | Brief description of the work
|
|
207
|
+
| Parameter | Description |
|
|
208
|
+
| ------------- | ----------------------------------- |
|
|
209
|
+
| `branch` | Git branch name (will be sanitized) |
|
|
210
|
+
| `description` | Brief description of the work |
|
|
204
211
|
|
|
205
212
|
**Features:**
|
|
213
|
+
|
|
206
214
|
- π³ Creates a new git worktree for isolated work
|
|
207
215
|
- π§΅ Opens a dedicated thread for the task
|
|
208
216
|
- ποΈ **Delete button** β removes worktree and archives thread
|
|
@@ -219,11 +227,13 @@ Enable passthrough mode in a thread to send messages directly to OpenCode withou
|
|
|
219
227
|
```
|
|
220
228
|
|
|
221
229
|
**How it works:**
|
|
230
|
+
|
|
222
231
|
1. Run `/code` in any thread to enable passthrough mode
|
|
223
232
|
2. Type messages naturally β they're sent directly to OpenCode
|
|
224
233
|
3. Run `/code` again to disable
|
|
225
234
|
|
|
226
235
|
**Example:**
|
|
236
|
+
|
|
227
237
|
```
|
|
228
238
|
You: /code
|
|
229
239
|
Bot: β
Passthrough mode enabled for this thread.
|
|
@@ -242,6 +252,7 @@ Bot: β Passthrough mode disabled.
|
|
|
242
252
|
```
|
|
243
253
|
|
|
244
254
|
**Features:**
|
|
255
|
+
|
|
245
256
|
- π± **Mobile-friendly** β no more typing slash commands on phone
|
|
246
257
|
- π§΅ **Thread-scoped** β only affects the specific thread, not the whole channel
|
|
247
258
|
- β³ **Busy indicator** β shows β³ reaction if previous task is still running
|
|
@@ -256,11 +267,13 @@ Enable automatic worktree creation for a project. When enabled, new `/opencode`
|
|
|
256
267
|
```
|
|
257
268
|
|
|
258
269
|
**How it works:**
|
|
270
|
+
|
|
259
271
|
1. Run `/autowork` in a channel bound to a project
|
|
260
272
|
2. The setting toggles on/off for that project
|
|
261
273
|
3. When enabled, new sessions automatically create worktrees with branch names like `auto/abc12345-1738600000000`
|
|
262
274
|
|
|
263
275
|
**Features:**
|
|
276
|
+
|
|
264
277
|
- π³ **Automatic isolation** β each session gets its own branch and worktree
|
|
265
278
|
- π± **Mobile-friendly** β no need to type `/work` with branch names
|
|
266
279
|
- ποΈ **Delete button** β removes worktree when done
|
|
@@ -280,32 +293,57 @@ Control the automated job queue for the current thread.
|
|
|
280
293
|
```
|
|
281
294
|
|
|
282
295
|
**How it works:**
|
|
296
|
+
|
|
283
297
|
1. Send multiple messages to a thread (or use `/opencode` multiple times)
|
|
284
298
|
2. If the bot is busy, it reacts with `π₯` and adds the task to the queue
|
|
285
299
|
3. Once the current job is done, the bot automatically picks up the next one
|
|
286
300
|
|
|
287
301
|
**Settings:**
|
|
302
|
+
|
|
288
303
|
- `continue_on_failure`: If `True`, the bot moves to the next task even if the current one fails.
|
|
289
304
|
- `fresh_context`: If `True` (default), the AI forgets previous chat history for each new queued task to improve performance, while maintaining the same code state.
|
|
290
305
|
|
|
291
|
-
|
|
306
|
+
### `/allow` β Manage Allowlist
|
|
307
|
+
|
|
308
|
+
Manage the user allowlist directly from Discord. This command is only available when the allowlist has already been initialized (at least one user exists).
|
|
309
|
+
|
|
310
|
+
```
|
|
311
|
+
/allow action:add user:@username
|
|
312
|
+
/allow action:remove user:@username
|
|
313
|
+
/allow action:list
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
| Parameter | Description |
|
|
317
|
+
| --------- | -------------------------------------------- |
|
|
318
|
+
| `action` | `add`, `remove`, or `list` |
|
|
319
|
+
| `user` | Target user (required for `add` and `remove`) |
|
|
320
|
+
|
|
321
|
+
**Behavior:**
|
|
292
322
|
|
|
323
|
+
- **Requires authorization** β only users already on the allowlist can use this command
|
|
324
|
+
- **Cannot remove last user** β prevents accidental lockout
|
|
325
|
+
- **Disabled when allowlist is empty** β initial setup must be done via CLI or setup wizard (see [Access Control](#access-control))
|
|
326
|
+
|
|
327
|
+
---
|
|
293
328
|
|
|
294
329
|
## Usage Workflow
|
|
295
330
|
|
|
296
331
|
### Basic Workflow
|
|
297
332
|
|
|
298
333
|
1. **Register your project:**
|
|
334
|
+
|
|
299
335
|
```
|
|
300
336
|
/setpath alias:webapp path:/home/user/my-webapp
|
|
301
337
|
```
|
|
302
338
|
|
|
303
339
|
2. **Bind to a channel:**
|
|
340
|
+
|
|
304
341
|
```
|
|
305
342
|
/use alias:webapp
|
|
306
343
|
```
|
|
307
344
|
|
|
308
345
|
3. **Start coding remotely:**
|
|
346
|
+
|
|
309
347
|
```
|
|
310
348
|
/opencode prompt:Refactor the authentication module to use JWT
|
|
311
349
|
```
|
|
@@ -341,6 +379,7 @@ Share AI coding sessions with your team:
|
|
|
341
379
|
Perfect for "setting and forgetting" several tasks:
|
|
342
380
|
|
|
343
381
|
1. **Send multiple instructions:**
|
|
382
|
+
|
|
344
383
|
```
|
|
345
384
|
You: Refactor the API
|
|
346
385
|
Bot: [Starts working]
|
|
@@ -356,15 +395,73 @@ Perfect for "setting and forgetting" several tasks:
|
|
|
356
395
|
|
|
357
396
|
---
|
|
358
397
|
|
|
398
|
+
## Access Control
|
|
399
|
+
|
|
400
|
+
remote-opencode supports an optional **user allowlist** to restrict who can interact with the bot. This is essential when your bot runs in a shared Discord server where untrusted users could otherwise execute commands on your machine.
|
|
401
|
+
|
|
402
|
+
### How It Works
|
|
403
|
+
|
|
404
|
+
- **No allowlist configured (default):** All Discord users in the server can use the bot. This preserves backward compatibility for existing installations.
|
|
405
|
+
- **Allowlist configured (1+ user IDs):** Only users whose Discord IDs are in the allowlist can use slash commands, buttons, and passthrough messages. Unauthorized users receive a rejection message.
|
|
406
|
+
|
|
407
|
+
### Setting Up Access Control
|
|
408
|
+
|
|
409
|
+
> **β οΈ SECURITY WARNING: If your bot operates in a Discord channel accessible to untrusted users, you MUST configure the allowlist before starting the bot. The initial allowlist setup can ONLY be done via the CLI or the setup wizard β NOT from Discord. This prevents unauthorized users from adding themselves to an empty allowlist.**
|
|
410
|
+
|
|
411
|
+
#### Option 1: Setup Wizard (Recommended for first-time setup)
|
|
412
|
+
|
|
413
|
+
```bash
|
|
414
|
+
remote-opencode setup
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Step 5 of the wizard prompts you to enter your Discord user ID. This becomes the first entry in the allowlist.
|
|
418
|
+
|
|
419
|
+
#### Option 2: CLI
|
|
420
|
+
|
|
421
|
+
```bash
|
|
422
|
+
# Add your Discord user ID
|
|
423
|
+
remote-opencode allow add 123456789012345678
|
|
424
|
+
|
|
425
|
+
# Verify
|
|
426
|
+
remote-opencode allow list
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
### Managing the Allowlist
|
|
430
|
+
|
|
431
|
+
Once at least one user is on the allowlist, authorized users can manage it from Discord:
|
|
432
|
+
|
|
433
|
+
```
|
|
434
|
+
/allow action:add user:@teammate
|
|
435
|
+
/allow action:remove user:@teammate
|
|
436
|
+
/allow action:list
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
Or via CLI at any time:
|
|
440
|
+
|
|
441
|
+
```bash
|
|
442
|
+
remote-opencode allow add <userId>
|
|
443
|
+
remote-opencode allow remove <userId>
|
|
444
|
+
remote-opencode allow list
|
|
445
|
+
remote-opencode allow reset # Clears entire allowlist (disables access control)
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
### Safety Guardrails
|
|
449
|
+
|
|
450
|
+
- **Cannot remove the last user** via Discord `/allow` or CLI `allow remove` β prevents accidental lockout
|
|
451
|
+
- **`allow reset`** is the only way to fully clear the allowlist (intentional action to disable access control)
|
|
452
|
+
- **Discord `/allow` is disabled when allowlist is empty** β prevents bootstrap attacks
|
|
453
|
+
- **Config file permissions** are set to `0o600` (owner-read/write only)
|
|
454
|
+
|
|
455
|
+
---
|
|
359
456
|
|
|
360
457
|
## Configuration
|
|
361
458
|
|
|
362
459
|
All configuration is stored in `~/.remote-opencode/`:
|
|
363
460
|
|
|
364
|
-
| File
|
|
365
|
-
|
|
366
|
-
| `config.json` | Bot credentials (token, client ID, guild ID)
|
|
367
|
-
| `data.json`
|
|
461
|
+
| File | Purpose |
|
|
462
|
+
| ------------- | --------------------------------------------- |
|
|
463
|
+
| `config.json` | Bot credentials (token, client ID, guild ID) |
|
|
464
|
+
| `data.json` | Project paths, channel bindings, session data |
|
|
368
465
|
|
|
369
466
|
### config.json Structure
|
|
370
467
|
|
|
@@ -372,10 +469,13 @@ All configuration is stored in `~/.remote-opencode/`:
|
|
|
372
469
|
{
|
|
373
470
|
"discordToken": "your-bot-token",
|
|
374
471
|
"clientId": "your-application-id",
|
|
375
|
-
"guildId": "your-server-id"
|
|
472
|
+
"guildId": "your-server-id",
|
|
473
|
+
"allowedUserIds": ["123456789012345678"]
|
|
376
474
|
}
|
|
377
475
|
```
|
|
378
476
|
|
|
477
|
+
> `allowedUserIds` is optional. When omitted or empty, access control is disabled and all users can use the bot.
|
|
478
|
+
|
|
379
479
|
### data.json Structure
|
|
380
480
|
|
|
381
481
|
```json
|
|
@@ -391,8 +491,8 @@ All configuration is stored in `~/.remote-opencode/`:
|
|
|
391
491
|
}
|
|
392
492
|
```
|
|
393
493
|
|
|
394
|
-
| Field
|
|
395
|
-
|
|
494
|
+
| Field | Description |
|
|
495
|
+
| ------------------------- | --------------------------------------------------------- |
|
|
396
496
|
| `projects[].autoWorktree` | Optional. When `true`, new sessions auto-create worktrees |
|
|
397
497
|
|
|
398
498
|
---
|
|
@@ -416,6 +516,7 @@ All configuration is stored in `~/.remote-opencode/`:
|
|
|
416
516
|
### "No project set for this channel"
|
|
417
517
|
|
|
418
518
|
You need to bind a project to the channel:
|
|
519
|
+
|
|
419
520
|
```
|
|
420
521
|
/setpath alias:myproject path:/path/to/project
|
|
421
522
|
/use alias:myproject
|
|
@@ -424,6 +525,7 @@ You need to bind a project to the channel:
|
|
|
424
525
|
### Commands not appearing in Discord
|
|
425
526
|
|
|
426
527
|
Slash commands can take up to an hour to propagate globally. For faster updates:
|
|
528
|
+
|
|
427
529
|
1. Kick the bot from your server
|
|
428
530
|
2. Re-invite it
|
|
429
531
|
3. Run `remote-opencode deploy`
|
|
@@ -440,6 +542,7 @@ Slash commands can take up to an hour to propagate globally. For faster updates:
|
|
|
440
542
|
### Session connection issues
|
|
441
543
|
|
|
442
544
|
The bot maintains persistent sessions. If you encounter issues:
|
|
545
|
+
|
|
443
546
|
1. Start a new thread with `/opencode` instead of continuing in an old one
|
|
444
547
|
2. Restart the bot: `remote-opencode start`
|
|
445
548
|
|
|
@@ -494,6 +597,7 @@ src/
|
|
|
494
597
|
β βββ opencode.ts # Main AI interaction command
|
|
495
598
|
β βββ code.ts # Passthrough mode toggle
|
|
496
599
|
β βββ work.ts # Worktree management
|
|
600
|
+
β βββ allow.ts # Allowlist management
|
|
497
601
|
β βββ setpath.ts # Project registration
|
|
498
602
|
β βββ projects.ts # List projects
|
|
499
603
|
β βββ use.ts # Channel binding
|
|
@@ -525,18 +629,37 @@ src/
|
|
|
525
629
|
|
|
526
630
|
See [CHANGELOG.md](CHANGELOG.md) for a full history of changes.
|
|
527
631
|
|
|
632
|
+
### [1.2.0] - 2026-02-15
|
|
633
|
+
|
|
634
|
+
#### Added
|
|
635
|
+
|
|
636
|
+
- **Owner/Admin Authentication**: User allowlist system to restrict bot access to authorized Discord users only.
|
|
637
|
+
- **`/allow` Slash Command**: Manage the allowlist directly from Discord (add, remove, list users).
|
|
638
|
+
- **CLI Allowlist Management**: `remote-opencode allow add|remove|list|reset` commands for managing access control from the terminal.
|
|
639
|
+
- **Setup Wizard Integration**: Step 5 prompts for owner Discord user ID during initial setup.
|
|
640
|
+
|
|
641
|
+
#### Security
|
|
642
|
+
|
|
643
|
+
- Initial allowlist setup is restricted to CLI and setup wizard only β prevents bootstrap attacks from Discord.
|
|
644
|
+
- Config file permissions hardened to `0o600` (owner-read/write only).
|
|
645
|
+
- Discord user ID validation enforces snowflake format (`/^\d{17,20}$/`).
|
|
646
|
+
- Cannot remove the last authorized user via Discord or CLI `remove` β prevents lockout.
|
|
647
|
+
|
|
528
648
|
### [1.1.0] - 2026-02-05
|
|
529
649
|
|
|
530
650
|
#### Added
|
|
651
|
+
|
|
531
652
|
- **Automated Message Queuing**: Added a new system to queue multiple prompts in a thread. If the bot is busy, new messages are automatically queued and processed sequentially.
|
|
532
653
|
- **Queue Management**: New `/queue` slash command suite to list, clear, pause, resume, and configure queue settings.
|
|
533
654
|
|
|
534
655
|
### [1.0.10] - 2026-02-04
|
|
535
656
|
|
|
536
657
|
#### Added
|
|
658
|
+
|
|
537
659
|
- New `/setports` slash command to configure the port range for OpenCode server instances.
|
|
538
660
|
|
|
539
661
|
#### Fixed
|
|
662
|
+
|
|
540
663
|
- Fixed Windows-specific spawning issue (targeting `opencode.cmd`).
|
|
541
664
|
- Resolved `spawn EINVAL` errors on Windows.
|
|
542
665
|
- Improved server reliability and suppressed `DEP0190` security warnings.
|
|
@@ -544,10 +667,12 @@ See [CHANGELOG.md](CHANGELOG.md) for a full history of changes.
|
|
|
544
667
|
### [1.0.9] - 2026-02-04
|
|
545
668
|
|
|
546
669
|
#### Added
|
|
670
|
+
|
|
547
671
|
- New `/model` slash command to set AI models per channel.
|
|
548
672
|
- Support for `--model` flag in OpenCode server instances.
|
|
549
673
|
|
|
550
674
|
#### Fixed
|
|
675
|
+
|
|
551
676
|
- Fixed connection timeout issues.
|
|
552
677
|
- Standardized internal communication to use `127.0.0.1`.
|
|
553
678
|
|
|
@@ -561,10 +686,4 @@ MIT
|
|
|
561
686
|
|
|
562
687
|
## Contributing
|
|
563
688
|
|
|
564
|
-
Contributions are welcome! Please
|
|
565
|
-
|
|
566
|
-
1. Fork the repository
|
|
567
|
-
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
|
568
|
-
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
|
569
|
-
4. Push to the branch (`git push origin feature/amazing-feature`)
|
|
570
|
-
5. Open a Pull Request
|
|
689
|
+
Contributions are welcome! Please read our [Contributing Guide](CONTRIBUTING.md) before submitting a Pull Request.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
3
|
+
vi.mock('node:fs', () => ({
|
|
4
|
+
readFileSync: vi.fn(),
|
|
5
|
+
writeFileSync: vi.fn(),
|
|
6
|
+
existsSync: vi.fn(),
|
|
7
|
+
mkdirSync: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
vi.mock('node:os', () => ({
|
|
10
|
+
homedir: vi.fn(() => '/mock/home'),
|
|
11
|
+
}));
|
|
12
|
+
import { getAllowedUserIds, setAllowedUserIds, addAllowedUserId, removeAllowedUserId, isAuthorized, } from '../services/configStore.js';
|
|
13
|
+
function mockConfigFile(config) {
|
|
14
|
+
vi.mocked(existsSync).mockReturnValue(true);
|
|
15
|
+
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(config));
|
|
16
|
+
}
|
|
17
|
+
describe('auth allowlist', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.resetAllMocks();
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
vi.restoreAllMocks();
|
|
23
|
+
});
|
|
24
|
+
describe('getAllowedUserIds', () => {
|
|
25
|
+
it('should return empty array when no allowedUserIds configured', () => {
|
|
26
|
+
mockConfigFile({ bot: { discordToken: 't', clientId: 'c', guildId: 'g' } });
|
|
27
|
+
expect(getAllowedUserIds()).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
it('should return configured user IDs', () => {
|
|
30
|
+
mockConfigFile({ allowedUserIds: ['111', '222'] });
|
|
31
|
+
expect(getAllowedUserIds()).toEqual(['111', '222']);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe('isAuthorized', () => {
|
|
35
|
+
it('should allow everyone when allowlist is empty', () => {
|
|
36
|
+
mockConfigFile({});
|
|
37
|
+
expect(isAuthorized('anyUserId')).toBe(true);
|
|
38
|
+
expect(isAuthorized('anotherUser')).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
it('should allow only listed users when allowlist is non-empty', () => {
|
|
41
|
+
mockConfigFile({ allowedUserIds: ['111', '222'] });
|
|
42
|
+
expect(isAuthorized('111')).toBe(true);
|
|
43
|
+
expect(isAuthorized('222')).toBe(true);
|
|
44
|
+
expect(isAuthorized('333')).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
describe('addAllowedUserId', () => {
|
|
48
|
+
it('should add a user to the allowlist', () => {
|
|
49
|
+
mockConfigFile({ allowedUserIds: ['111'] });
|
|
50
|
+
addAllowedUserId('222');
|
|
51
|
+
expect(writeFileSync).toHaveBeenCalledWith(expect.any(String), expect.stringContaining('"222"'), expect.objectContaining({ encoding: 'utf-8', mode: 0o600 }));
|
|
52
|
+
});
|
|
53
|
+
it('should not duplicate an existing user', () => {
|
|
54
|
+
mockConfigFile({ allowedUserIds: ['111'] });
|
|
55
|
+
addAllowedUserId('111');
|
|
56
|
+
expect(writeFileSync).not.toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
it('should create the array when adding first user', () => {
|
|
59
|
+
mockConfigFile({});
|
|
60
|
+
addAllowedUserId('111');
|
|
61
|
+
const writtenData = JSON.parse(vi.mocked(writeFileSync).mock.calls[0][1]);
|
|
62
|
+
expect(writtenData.allowedUserIds).toEqual(['111']);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe('removeAllowedUserId', () => {
|
|
66
|
+
it('should remove a user from the allowlist', () => {
|
|
67
|
+
mockConfigFile({ allowedUserIds: ['111', '222'] });
|
|
68
|
+
const result = removeAllowedUserId('111');
|
|
69
|
+
expect(result).toBe(true);
|
|
70
|
+
const writtenData = JSON.parse(vi.mocked(writeFileSync).mock.calls[0][1]);
|
|
71
|
+
expect(writtenData.allowedUserIds).toEqual(['222']);
|
|
72
|
+
});
|
|
73
|
+
it('should return false when user is not on the allowlist', () => {
|
|
74
|
+
mockConfigFile({ allowedUserIds: ['111'] });
|
|
75
|
+
expect(removeAllowedUserId('999')).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
it('should not remove the last remaining user', () => {
|
|
78
|
+
mockConfigFile({ allowedUserIds: ['111'] });
|
|
79
|
+
const result = removeAllowedUserId('111');
|
|
80
|
+
expect(result).toBe(false);
|
|
81
|
+
expect(writeFileSync).not.toHaveBeenCalled();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
describe('setAllowedUserIds', () => {
|
|
85
|
+
it('should replace the entire allowlist', () => {
|
|
86
|
+
mockConfigFile({ allowedUserIds: ['111'] });
|
|
87
|
+
setAllowedUserIds(['333', '444']);
|
|
88
|
+
const writtenData = JSON.parse(vi.mocked(writeFileSync).mock.calls[0][1]);
|
|
89
|
+
expect(writtenData.allowedUserIds).toEqual(['333', '444']);
|
|
90
|
+
});
|
|
91
|
+
it('should clear the allowlist with empty array', () => {
|
|
92
|
+
mockConfigFile({ allowedUserIds: ['111'] });
|
|
93
|
+
setAllowedUserIds([]);
|
|
94
|
+
const writtenData = JSON.parse(vi.mocked(writeFileSync).mock.calls[0][1]);
|
|
95
|
+
expect(writtenData.allowedUserIds).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe('config persistence', () => {
|
|
99
|
+
it('should preserve existing config when modifying allowlist', () => {
|
|
100
|
+
mockConfigFile({
|
|
101
|
+
bot: { discordToken: 'tok', clientId: 'cid', guildId: 'gid' },
|
|
102
|
+
allowedUserIds: ['111'],
|
|
103
|
+
});
|
|
104
|
+
addAllowedUserId('222');
|
|
105
|
+
const writtenData = JSON.parse(vi.mocked(writeFileSync).mock.calls[0][1]);
|
|
106
|
+
expect(writtenData.bot).toEqual({
|
|
107
|
+
discordToken: 'tok',
|
|
108
|
+
clientId: 'cid',
|
|
109
|
+
guildId: 'gid',
|
|
110
|
+
});
|
|
111
|
+
expect(writtenData.allowedUserIds).toEqual(['111', '222']);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { parseSSEEvent, extractTextFromPart, accumulateText, formatOutput, stripAnsi } from '../utils/messageFormatter.js';
|
|
2
|
+
import { parseSSEEvent, extractTextFromPart, accumulateText, formatOutput, stripAnsi, buildContextHeader } from '../utils/messageFormatter.js';
|
|
3
3
|
describe('messageFormatter', () => {
|
|
4
4
|
describe('stripAnsi', () => {
|
|
5
5
|
it('should remove ANSI escape codes', () => {
|
|
@@ -63,6 +63,20 @@ describe('messageFormatter', () => {
|
|
|
63
63
|
expect(accumulateText('', 'Hello')).toBe('Hello');
|
|
64
64
|
});
|
|
65
65
|
});
|
|
66
|
+
describe('buildContextHeader', () => {
|
|
67
|
+
it('should format branch name and model name', () => {
|
|
68
|
+
const result = buildContextHeader('feature/dark-mode', 'claude-sonnet-4-20250514');
|
|
69
|
+
expect(result).toBe('πΏ `feature/dark-mode` Β· π€ `claude-sonnet-4-20250514`');
|
|
70
|
+
});
|
|
71
|
+
it('should handle default model', () => {
|
|
72
|
+
const result = buildContextHeader('main', 'default');
|
|
73
|
+
expect(result).toBe('πΏ `main` Β· π€ `default`');
|
|
74
|
+
});
|
|
75
|
+
it('should handle auto-generated branch names', () => {
|
|
76
|
+
const result = buildContextHeader('auto/abc12345-1738600000000', 'default');
|
|
77
|
+
expect(result).toBe('πΏ `auto/abc12345-1738600000000` Β· π€ `default`');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
66
80
|
describe('formatOutput (existing functionality)', () => {
|
|
67
81
|
it('should work for OpenCode JSON output with newlines preserved', () => {
|
|
68
82
|
const buffer = JSON.stringify({ type: 'text', part: { text: 'Hello' } }) + '\n' +
|
package/dist/src/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ import updateNotifier from 'update-notifier';
|
|
|
7
7
|
import { runSetupWizard } from './setup/wizard.js';
|
|
8
8
|
import { deployCommands } from './setup/deploy.js';
|
|
9
9
|
import { startBot } from './bot.js';
|
|
10
|
-
import { hasBotConfig, getConfigDir } from './services/configStore.js';
|
|
10
|
+
import { hasBotConfig, getConfigDir, getAllowedUserIds, addAllowedUserId, removeAllowedUserId, setAllowedUserIds } from './services/configStore.js';
|
|
11
11
|
const require = createRequire(import.meta.url);
|
|
12
12
|
// In dev mode (src/cli.ts), package.json is one level up
|
|
13
13
|
// In production (dist/src/cli.js), package.json is two levels up
|
|
@@ -68,6 +68,56 @@ program
|
|
|
68
68
|
console.log(` Bot configured: ${hasBotConfig() ? pc.green('Yes') : pc.red('No')}`);
|
|
69
69
|
console.log();
|
|
70
70
|
});
|
|
71
|
+
const allowCmd = program.command('allow').description('Manage the bot access allowlist');
|
|
72
|
+
allowCmd
|
|
73
|
+
.command('add <userId>')
|
|
74
|
+
.description('Add a user to the allowlist')
|
|
75
|
+
.action((userId) => {
|
|
76
|
+
if (!/^\d{17,20}$/.test(userId)) {
|
|
77
|
+
console.log(pc.red('β Invalid user ID. Must be a Discord snowflake (17-20 digits).'));
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
addAllowedUserId(userId);
|
|
81
|
+
console.log(pc.green(`β
User ${userId} added to allowlist.`));
|
|
82
|
+
});
|
|
83
|
+
allowCmd
|
|
84
|
+
.command('remove <userId>')
|
|
85
|
+
.description('Remove a user from the allowlist')
|
|
86
|
+
.action((userId) => {
|
|
87
|
+
const result = removeAllowedUserId(userId);
|
|
88
|
+
if (result) {
|
|
89
|
+
console.log(pc.green(`β
User ${userId} removed from allowlist.`));
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
const ids = getAllowedUserIds();
|
|
93
|
+
if (ids.length <= 1) {
|
|
94
|
+
console.log(pc.red('β Cannot remove the last allowed user. Use "allow reset" to disable restrictions.'));
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
console.log(pc.red(`β User ${userId} is not on the allowlist.`));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
allowCmd
|
|
102
|
+
.command('list')
|
|
103
|
+
.description('Show all allowed users')
|
|
104
|
+
.action(() => {
|
|
105
|
+
const ids = getAllowedUserIds();
|
|
106
|
+
if (ids.length === 0) {
|
|
107
|
+
console.log(pc.yellow('π No restrictions β all server members can use this bot.'));
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
console.log(pc.bold(`π Allowed Users (${ids.length}):`));
|
|
111
|
+
ids.forEach(id => console.log(` β’ ${id}`));
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
allowCmd
|
|
115
|
+
.command('reset')
|
|
116
|
+
.description('Clear the allowlist (unrestricted mode)')
|
|
117
|
+
.action(() => {
|
|
118
|
+
setAllowedUserIds([]);
|
|
119
|
+
console.log(pc.green('β
Allowlist cleared. All server members can now use the bot.'));
|
|
120
|
+
});
|
|
71
121
|
program
|
|
72
122
|
.action(async () => {
|
|
73
123
|
if (!hasBotConfig()) {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { SlashCommandBuilder, MessageFlags } from 'discord.js';
|
|
2
|
+
import { getAllowedUserIds, addAllowedUserId, removeAllowedUserId, isAuthorized } from '../services/configStore.js';
|
|
3
|
+
export const allow = {
|
|
4
|
+
data: new SlashCommandBuilder()
|
|
5
|
+
.setName('allow')
|
|
6
|
+
.setDescription('Manage the bot access allowlist')
|
|
7
|
+
.addSubcommand(sub => sub.setName('add')
|
|
8
|
+
.setDescription('Add a user to the allowlist')
|
|
9
|
+
.addUserOption(opt => opt.setName('user')
|
|
10
|
+
.setDescription('The user to allow')
|
|
11
|
+
.setRequired(true)))
|
|
12
|
+
.addSubcommand(sub => sub.setName('remove')
|
|
13
|
+
.setDescription('Remove a user from the allowlist')
|
|
14
|
+
.addUserOption(opt => opt.setName('user')
|
|
15
|
+
.setDescription('The user to remove')
|
|
16
|
+
.setRequired(true)))
|
|
17
|
+
.addSubcommand(sub => sub.setName('list')
|
|
18
|
+
.setDescription('Show all allowed users')),
|
|
19
|
+
async execute(interaction) {
|
|
20
|
+
const currentList = getAllowedUserIds();
|
|
21
|
+
if (currentList.length === 0) {
|
|
22
|
+
await interaction.reply({
|
|
23
|
+
content: 'β οΈ No allowlist configured. Use `remote-opencode allow add <userId>` or `remote-opencode setup` to set up access control first.',
|
|
24
|
+
flags: MessageFlags.Ephemeral
|
|
25
|
+
});
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (!isAuthorized(interaction.user.id)) {
|
|
29
|
+
await interaction.reply({
|
|
30
|
+
content: 'π« You are not authorized to manage the allowlist.',
|
|
31
|
+
flags: MessageFlags.Ephemeral
|
|
32
|
+
});
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const subcommand = interaction.options.getSubcommand();
|
|
36
|
+
if (subcommand === 'add') {
|
|
37
|
+
const user = interaction.options.getUser('user', true);
|
|
38
|
+
addAllowedUserId(user.id);
|
|
39
|
+
await interaction.reply({
|
|
40
|
+
content: `β
<@${user.id}> has been added to the allowlist.`,
|
|
41
|
+
flags: MessageFlags.Ephemeral
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
else if (subcommand === 'remove') {
|
|
45
|
+
const user = interaction.options.getUser('user', true);
|
|
46
|
+
const removed = removeAllowedUserId(user.id);
|
|
47
|
+
if (!removed) {
|
|
48
|
+
const reason = currentList.length <= 1
|
|
49
|
+
? 'Cannot remove the last allowed user. Use CLI `remote-opencode allow reset` to disable restrictions.'
|
|
50
|
+
: `<@${user.id}> is not on the allowlist.`;
|
|
51
|
+
await interaction.reply({
|
|
52
|
+
content: `β ${reason}`,
|
|
53
|
+
flags: MessageFlags.Ephemeral
|
|
54
|
+
});
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
await interaction.reply({
|
|
58
|
+
content: `β
<@${user.id}> has been removed from the allowlist.`,
|
|
59
|
+
flags: MessageFlags.Ephemeral
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
else if (subcommand === 'list') {
|
|
63
|
+
const userMentions = currentList.map(id => `β’ <@${id}>`).join('\n');
|
|
64
|
+
await interaction.reply({
|
|
65
|
+
content: `π **Allowed Users** (${currentList.length}):\n${userMentions}`,
|
|
66
|
+
flags: MessageFlags.Ephemeral
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
@@ -9,6 +9,7 @@ import { autowork } from './autowork.js';
|
|
|
9
9
|
import { model } from './model.js';
|
|
10
10
|
import { setports } from './setports.js';
|
|
11
11
|
import { queue } from './queue.js';
|
|
12
|
+
import { allow } from './allow.js';
|
|
12
13
|
export const commands = new Collection();
|
|
13
14
|
commands.set(setpath.data.name, setpath);
|
|
14
15
|
commands.set(projects.data.name, projects);
|
|
@@ -20,3 +21,4 @@ commands.set(autowork.data.name, autowork);
|
|
|
20
21
|
commands.set(model.data.name, model);
|
|
21
22
|
commands.set(setports.data.name, setports);
|
|
22
23
|
commands.set(queue.data.name, queue);
|
|
24
|
+
commands.set(allow.data.name, allow);
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { MessageFlags } from 'discord.js';
|
|
2
2
|
import { commands } from '../commands/index.js';
|
|
3
3
|
import { handleButton } from './buttonHandler.js';
|
|
4
|
+
import { isAuthorized } from '../services/configStore.js';
|
|
4
5
|
export async function handleInteraction(interaction) {
|
|
5
6
|
if (interaction.isButton()) {
|
|
7
|
+
if (!isAuthorized(interaction.user.id)) {
|
|
8
|
+
await interaction.reply({
|
|
9
|
+
content: 'π« You are not authorized to use this bot.',
|
|
10
|
+
flags: MessageFlags.Ephemeral
|
|
11
|
+
});
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
6
14
|
try {
|
|
7
15
|
await handleButton(interaction);
|
|
8
16
|
}
|
|
@@ -13,6 +21,13 @@ export async function handleInteraction(interaction) {
|
|
|
13
21
|
}
|
|
14
22
|
if (!interaction.isChatInputCommand())
|
|
15
23
|
return;
|
|
24
|
+
if (!isAuthorized(interaction.user.id)) {
|
|
25
|
+
await interaction.reply({
|
|
26
|
+
content: 'π« You are not authorized to use this bot.',
|
|
27
|
+
flags: MessageFlags.Ephemeral
|
|
28
|
+
});
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
16
31
|
const command = commands.get(interaction.commandName);
|
|
17
32
|
if (!command) {
|
|
18
33
|
return;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as dataStore from '../services/dataStore.js';
|
|
2
2
|
import { runPrompt } from '../services/executionService.js';
|
|
3
3
|
import { isBusy } from '../services/queueManager.js';
|
|
4
|
+
import { isAuthorized } from '../services/configStore.js';
|
|
4
5
|
export async function handleMessageCreate(message) {
|
|
5
6
|
if (message.author.bot)
|
|
6
7
|
return;
|
|
@@ -12,6 +13,8 @@ export async function handleMessageCreate(message) {
|
|
|
12
13
|
const threadId = channel.id;
|
|
13
14
|
if (!dataStore.isPassthroughEnabled(threadId))
|
|
14
15
|
return;
|
|
16
|
+
if (!isAuthorized(message.author.id))
|
|
17
|
+
return;
|
|
15
18
|
const parentChannelId = channel.parentId;
|
|
16
19
|
if (!parentChannelId)
|
|
17
20
|
return;
|
|
@@ -5,7 +5,7 @@ const CONFIG_DIR = join(homedir(), '.remote-opencode');
|
|
|
5
5
|
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
6
6
|
function ensureConfigDir() {
|
|
7
7
|
if (!existsSync(CONFIG_DIR)) {
|
|
8
|
-
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
8
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
export function getConfigDir() {
|
|
@@ -26,7 +26,7 @@ export function loadConfig() {
|
|
|
26
26
|
}
|
|
27
27
|
export function saveConfig(config) {
|
|
28
28
|
ensureConfigDir();
|
|
29
|
-
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
|
29
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
30
30
|
}
|
|
31
31
|
export function getBotConfig() {
|
|
32
32
|
return loadConfig().bot;
|
|
@@ -53,3 +53,36 @@ export function clearBotConfig() {
|
|
|
53
53
|
delete config.bot;
|
|
54
54
|
saveConfig(config);
|
|
55
55
|
}
|
|
56
|
+
export function getAllowedUserIds() {
|
|
57
|
+
return loadConfig().allowedUserIds ?? [];
|
|
58
|
+
}
|
|
59
|
+
export function setAllowedUserIds(ids) {
|
|
60
|
+
const config = loadConfig();
|
|
61
|
+
config.allowedUserIds = ids;
|
|
62
|
+
saveConfig(config);
|
|
63
|
+
}
|
|
64
|
+
export function addAllowedUserId(id) {
|
|
65
|
+
const config = loadConfig();
|
|
66
|
+
const current = config.allowedUserIds ?? [];
|
|
67
|
+
if (!current.includes(id)) {
|
|
68
|
+
config.allowedUserIds = [...current, id];
|
|
69
|
+
saveConfig(config);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export function removeAllowedUserId(id) {
|
|
73
|
+
const config = loadConfig();
|
|
74
|
+
const current = config.allowedUserIds ?? [];
|
|
75
|
+
if (!current.includes(id))
|
|
76
|
+
return false;
|
|
77
|
+
if (current.length <= 1)
|
|
78
|
+
return false; // prevent removing last user
|
|
79
|
+
config.allowedUserIds = current.filter(uid => uid !== id);
|
|
80
|
+
saveConfig(config);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
export function isAuthorized(userId) {
|
|
84
|
+
const ids = getAllowedUserIds();
|
|
85
|
+
if (ids.length === 0)
|
|
86
|
+
return true; // no restriction
|
|
87
|
+
return ids.includes(userId);
|
|
88
|
+
}
|
|
@@ -4,7 +4,7 @@ import * as sessionManager from './sessionManager.js';
|
|
|
4
4
|
import * as serveManager from './serveManager.js';
|
|
5
5
|
import * as worktreeManager from './worktreeManager.js';
|
|
6
6
|
import { SSEClient } from './sseClient.js';
|
|
7
|
-
import { formatOutput } from '../utils/messageFormatter.js';
|
|
7
|
+
import { formatOutput, buildContextHeader } from '../utils/messageFormatter.js';
|
|
8
8
|
import { processNextInQueue } from './queueManager.js';
|
|
9
9
|
export async function runPrompt(channel, threadId, prompt, parentChannelId) {
|
|
10
10
|
const projectPath = dataStore.getChannelProjectPath(parentChannelId);
|
|
@@ -52,7 +52,9 @@ export async function runPrompt(channel, threadId, prompt, parentChannelId) {
|
|
|
52
52
|
}
|
|
53
53
|
const effectivePath = worktreeMapping?.worktreePath ?? projectPath;
|
|
54
54
|
const preferredModel = dataStore.getChannelModel(parentChannelId);
|
|
55
|
-
const modelDisplay = preferredModel ?
|
|
55
|
+
const modelDisplay = preferredModel ? `${preferredModel}` : 'default';
|
|
56
|
+
const branchName = worktreeMapping?.branchName ?? await worktreeManager.getCurrentBranch(effectivePath) ?? 'main';
|
|
57
|
+
const contextHeader = buildContextHeader(branchName, modelDisplay);
|
|
56
58
|
const buttons = new ActionRowBuilder()
|
|
57
59
|
.addComponents(new ButtonBuilder()
|
|
58
60
|
.setCustomId(`interrupt_${threadId}`)
|
|
@@ -61,7 +63,7 @@ export async function runPrompt(channel, threadId, prompt, parentChannelId) {
|
|
|
61
63
|
let streamMessage;
|
|
62
64
|
try {
|
|
63
65
|
streamMessage = await channel.send({
|
|
64
|
-
content:
|
|
66
|
+
content: `${contextHeader}\nπ **Prompt**: ${prompt}\n\nπ Starting OpenCode server...`,
|
|
65
67
|
components: [buttons]
|
|
66
68
|
});
|
|
67
69
|
}
|
|
@@ -84,7 +86,7 @@ export async function runPrompt(channel, threadId, prompt, parentChannelId) {
|
|
|
84
86
|
};
|
|
85
87
|
try {
|
|
86
88
|
port = await serveManager.spawnServe(effectivePath, preferredModel);
|
|
87
|
-
await updateStreamMessage(
|
|
89
|
+
await updateStreamMessage(`${contextHeader}\nπ **Prompt**: ${prompt}\n\nβ³ Waiting for OpenCode server...`, [buttons]);
|
|
88
90
|
await serveManager.waitForReady(port, 30000, effectivePath, preferredModel);
|
|
89
91
|
const settings = dataStore.getQueueSettings(threadId);
|
|
90
92
|
// If fresh context is enabled, we always clear the session before starting
|
|
@@ -127,7 +129,7 @@ export async function runPrompt(channel, threadId, prompt, parentChannelId) {
|
|
|
127
129
|
.setLabel('βΈοΈ Interrupt')
|
|
128
130
|
.setStyle(ButtonStyle.Secondary)
|
|
129
131
|
.setDisabled(true));
|
|
130
|
-
await updateStreamMessage(
|
|
132
|
+
await updateStreamMessage(`${contextHeader}\nπ **Prompt**: ${prompt}\n\n\`\`\`\n${formatted}\n\`\`\``, [disabledButtons]);
|
|
131
133
|
await channel.send({ content: 'β
Done' });
|
|
132
134
|
sseClient.disconnect();
|
|
133
135
|
sessionManager.clearSseClient(threadId);
|
|
@@ -146,7 +148,7 @@ export async function runPrompt(channel, threadId, prompt, parentChannelId) {
|
|
|
146
148
|
}
|
|
147
149
|
(async () => {
|
|
148
150
|
try {
|
|
149
|
-
await updateStreamMessage(
|
|
151
|
+
await updateStreamMessage(`${contextHeader}\nπ **Prompt**: ${prompt}\n\nβ Connection error: ${error.message}`, []);
|
|
150
152
|
sseClient.disconnect();
|
|
151
153
|
sessionManager.clearSseClient(threadId);
|
|
152
154
|
const settings = dataStore.getQueueSettings(threadId);
|
|
@@ -170,13 +172,13 @@ export async function runPrompt(channel, threadId, prompt, parentChannelId) {
|
|
|
170
172
|
const newContent = formatted || 'Processing...';
|
|
171
173
|
if (newContent !== lastContent || tick % 2 === 0) {
|
|
172
174
|
lastContent = newContent;
|
|
173
|
-
await updateStreamMessage(
|
|
175
|
+
await updateStreamMessage(`${contextHeader}\nπ **Prompt**: ${prompt}\n\n${spinnerChar} **Running...**\n\`\`\`\n${newContent}\n\`\`\``, [buttons]);
|
|
174
176
|
}
|
|
175
177
|
}
|
|
176
178
|
catch {
|
|
177
179
|
}
|
|
178
180
|
}, 1000);
|
|
179
|
-
await updateStreamMessage(
|
|
181
|
+
await updateStreamMessage(`${contextHeader}\nπ **Prompt**: ${prompt}\n\nπ Sending prompt...`, [buttons]);
|
|
180
182
|
await sessionManager.sendPrompt(port, sessionId, prompt, preferredModel);
|
|
181
183
|
}
|
|
182
184
|
catch (error) {
|
|
@@ -184,7 +186,7 @@ export async function runPrompt(channel, threadId, prompt, parentChannelId) {
|
|
|
184
186
|
clearInterval(updateInterval);
|
|
185
187
|
}
|
|
186
188
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
187
|
-
await updateStreamMessage(
|
|
189
|
+
await updateStreamMessage(`${contextHeader}\nπ **Prompt**: ${prompt}\n\nβ OpenCode execution failed: ${errorMessage}`, []);
|
|
188
190
|
const client = sessionManager.getSseClient(threadId);
|
|
189
191
|
if (client) {
|
|
190
192
|
client.disconnect();
|
|
@@ -67,6 +67,15 @@ export async function removeWorktree(worktreePath, deleteBranch) {
|
|
|
67
67
|
throw new Error(`Failed to remove worktree: ${error.message}`);
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
+
export async function getCurrentBranch(cwd) {
|
|
71
|
+
try {
|
|
72
|
+
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd });
|
|
73
|
+
return stdout.trim() || null;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
70
79
|
export function worktreeExists(worktreePath) {
|
|
71
80
|
return existsSync(worktreePath);
|
|
72
81
|
}
|
package/dist/src/setup/wizard.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as p from '@clack/prompts';
|
|
2
2
|
import pc from 'picocolors';
|
|
3
3
|
import open from 'open';
|
|
4
|
-
import { setBotConfig, getBotConfig, hasBotConfig } from '../services/configStore.js';
|
|
4
|
+
import { setBotConfig, getBotConfig, hasBotConfig, addAllowedUserId } from '../services/configStore.js';
|
|
5
5
|
import { deployCommands } from './deploy.js';
|
|
6
6
|
const DISCORD_DEV_URL = 'https://discord.com/developers/applications';
|
|
7
7
|
const BOT_PERMISSIONS = '2147534848';
|
|
@@ -27,6 +27,13 @@ function validateGuildId(value) {
|
|
|
27
27
|
return 'Invalid format (should be 17-20 digits)';
|
|
28
28
|
return undefined;
|
|
29
29
|
}
|
|
30
|
+
function validateUserId(value) {
|
|
31
|
+
if (!value)
|
|
32
|
+
return undefined;
|
|
33
|
+
if (!/^\d{17,20}$/.test(value))
|
|
34
|
+
return 'Invalid format (should be 17-20 digits)';
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
30
37
|
function generateInviteUrl(clientId) {
|
|
31
38
|
const url = new URL('https://discord.com/api/oauth2/authorize');
|
|
32
39
|
url.searchParams.set('client_id', clientId);
|
|
@@ -123,6 +130,22 @@ export async function runSetupWizard() {
|
|
|
123
130
|
p.cancel('Setup cancelled.');
|
|
124
131
|
process.exit(0);
|
|
125
132
|
}
|
|
133
|
+
// Step 5: Set Bot Owner
|
|
134
|
+
p.note(`Restrict who can use this bot by setting an owner.\n\n` +
|
|
135
|
+
`1. In Discord, right-click ${pc.bold('YOUR profile')}\n` +
|
|
136
|
+
`2. Click ${pc.bold('"Copy User ID"')}\n\n` +
|
|
137
|
+
`${pc.dim('(Requires Developer Mode β same setting as Step 4)')}\n` +
|
|
138
|
+
`${pc.dim('Leave blank to allow everyone)')}`, 'Step 5: Set Bot Owner (Optional)');
|
|
139
|
+
const ownerId = await p.text({
|
|
140
|
+
message: 'Enter your Discord User ID (leave blank to allow everyone):',
|
|
141
|
+
placeholder: 'e.g., 1234567890123456789',
|
|
142
|
+
defaultValue: '',
|
|
143
|
+
validate: validateUserId,
|
|
144
|
+
});
|
|
145
|
+
if (p.isCancel(ownerId)) {
|
|
146
|
+
p.cancel('Setup cancelled.');
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
|
126
149
|
// Save configuration
|
|
127
150
|
const s = p.spinner();
|
|
128
151
|
s.start('Saving configuration...');
|
|
@@ -132,12 +155,15 @@ export async function runSetupWizard() {
|
|
|
132
155
|
guildId: guildId,
|
|
133
156
|
});
|
|
134
157
|
s.stop('Configuration saved!');
|
|
135
|
-
|
|
158
|
+
if (ownerId && ownerId.length > 0) {
|
|
159
|
+
addAllowedUserId(ownerId);
|
|
160
|
+
}
|
|
161
|
+
// Step 6: Invite Bot to Server
|
|
136
162
|
const inviteUrl = generateInviteUrl(clientId);
|
|
137
163
|
p.note(`We'll open the bot invite page in your browser.\n\n` +
|
|
138
164
|
`1. Select your server\n` +
|
|
139
165
|
`2. Click ${pc.bold('"Authorize"')}\n\n` +
|
|
140
|
-
`${pc.dim('URL: ' + inviteUrl)}`, 'Step
|
|
166
|
+
`${pc.dim('URL: ' + inviteUrl)}`, 'Step 6: Invite Bot to Server');
|
|
141
167
|
const openInvite = await p.text({
|
|
142
168
|
message: `Press ${pc.cyan('Enter')} to open the invite page...`,
|
|
143
169
|
placeholder: 'Press Enter',
|
|
@@ -156,7 +182,7 @@ export async function runSetupWizard() {
|
|
|
156
182
|
p.cancel('Setup cancelled.');
|
|
157
183
|
process.exit(0);
|
|
158
184
|
}
|
|
159
|
-
// Step
|
|
185
|
+
// Step 7: Deploy Commands
|
|
160
186
|
const shouldDeploy = await p.confirm({
|
|
161
187
|
message: 'Deploy slash commands now?',
|
|
162
188
|
initialValue: true,
|
|
@@ -54,6 +54,9 @@ export function parseOpenCodeOutput(buffer) {
|
|
|
54
54
|
}
|
|
55
55
|
return result;
|
|
56
56
|
}
|
|
57
|
+
export function buildContextHeader(branchName, modelName) {
|
|
58
|
+
return `πΏ \`${branchName}\` Β· π€ \`${modelName}\``;
|
|
59
|
+
}
|
|
57
60
|
export function formatOutput(buffer, maxLength = 1900) {
|
|
58
61
|
const parsed = parseOpenCodeOutput(buffer);
|
|
59
62
|
if (!parsed.trim()) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "remote-opencode",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Discord bot for remote OpenCode CLI access",
|
|
5
5
|
"main": "dist/src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"eventsource": "^4.1.0",
|
|
43
43
|
"node-pty": "^1.1.0",
|
|
44
44
|
"open": "^10.1.0",
|
|
45
|
+
"opencode-antigravity-auth": "^1.4.6",
|
|
45
46
|
"picocolors": "^1.1.1",
|
|
46
47
|
"update-notifier": "^7.3.1"
|
|
47
48
|
},
|