remote-opencode 1.1.1 β†’ 1.3.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 CHANGED
@@ -2,11 +2,10 @@
2
2
 
3
3
  > Control your AI coding assistant from anywhere β€” your phone, tablet, or another computer.
4
4
 
5
- ![npm](https://img.shields.io/npm/dt/remote-opencode) πŸ“¦ Used by developers worldwide β€” **800+ weekly downloads** on npm
6
-
5
+ ![npm](https://img.shields.io/npm/dt/remote-opencode) πŸ“¦ Used by developers worldwide β€” **1000+ weekly downloads** on npm
7
6
 
8
7
  <div align="center">
9
- <img width="1024" alt="Gemini_Generated_Image_47d5gq47d5gq47d5" src="https://github.com/user-attachments/assets/1defa11d-6195-4a9c-956b-4f87470f6393" />
8
+ <img width="1024" alt="remote-opencode logo" src="./asset/remo-code-logo.png" />
10
9
  </div>
11
10
 
12
11
  **remote-opencode** is a Discord bot that bridges your local [OpenCode CLI](https://github.com/sst/opencode) to Discord, enabling you to interact with your AI coding assistant remotely. Perfect for developers who want to:
@@ -19,7 +18,6 @@
19
18
 
20
19
  ## How It Works
21
20
 
22
-
23
21
  ```
24
22
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Discord API β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
25
23
  β”‚ Your Phone / β”‚ ◄──────────────► β”‚ Discord Bot β”‚
@@ -40,6 +38,10 @@
40
38
 
41
39
  The bot runs on your development machine alongside OpenCode. When you send a command via Discord, it's forwarded to OpenCode, and the output streams back to you in real-time.
42
40
 
41
+ ## Demo
42
+
43
+ https://github.com/user-attachments/assets/b6239cb6-234e-41e2-a4d1-d4dd3e86c7b9
44
+
43
45
  ---
44
46
 
45
47
  ## Table of Contents
@@ -50,6 +52,7 @@ The bot runs on your development machine alongside OpenCode. When you send a com
50
52
  - [CLI Commands](#cli-commands)
51
53
  - [Discord Slash Commands](#discord-slash-commands)
52
54
  - [Usage Workflow](#usage-workflow)
55
+ - [Access Control](#access-control)
53
56
  - [Configuration](#configuration)
54
57
  - [Troubleshooting](#troubleshooting)
55
58
  - [Development](#development)
@@ -133,13 +136,17 @@ If you prefer manual setup or need to troubleshoot:
133
136
 
134
137
  ## CLI Commands
135
138
 
136
- | Command | Description |
137
- |---------|-------------|
138
- | `remote-opencode` | Start the bot (shows setup guide if not configured) |
139
- | `remote-opencode setup` | Interactive setup wizard β€” configures bot token, IDs |
140
- | `remote-opencode start` | Start the Discord bot |
141
- | `remote-opencode deploy` | Deploy/update slash commands to Discord |
142
- | `remote-opencode config` | Display current configuration info |
139
+ | Command | Description |
140
+ | --------------------------------------- | ---------------------------------------------------- |
141
+ | `remote-opencode` | Start the bot (shows setup guide if not configured) |
142
+ | `remote-opencode setup` | Interactive setup wizard β€” configures bot token, IDs |
143
+ | `remote-opencode start` | Start the Discord bot |
144
+ | `remote-opencode deploy` | Deploy/update slash commands to Discord |
145
+ | `remote-opencode config` | Display current configuration info |
146
+ | `remote-opencode allow add <userId>` | Add a Discord user ID to the allowlist |
147
+ | `remote-opencode allow remove <userId>` | Remove a Discord user ID from the allowlist |
148
+ | `remote-opencode allow list` | List all user IDs in the allowlist |
149
+ | `remote-opencode allow reset` | Clear the entire allowlist (removes access control) |
143
150
 
144
151
  ---
145
152
 
@@ -155,10 +162,10 @@ Register a local project path with an alias for easy reference.
155
162
  /setpath alias:myapp path:/Users/you/projects/my-app
156
163
  ```
157
164
 
158
- | Parameter | Description |
159
- |-----------|-------------|
160
- | `alias` | Short name for the project (e.g., `myapp`, `backend`) |
161
- | `path` | Absolute path to the project on your machine |
165
+ | Parameter | Description |
166
+ | --------- | ----------------------------------------------------- |
167
+ | `alias` | Short name for the project (e.g., `myapp`, `backend`) |
168
+ | `path` | Absolute path to the project on your machine |
162
169
 
163
170
  ### `/projects` β€” List Registered Projects
164
171
 
@@ -187,6 +194,7 @@ The main command β€” sends a prompt to OpenCode and streams the response.
187
194
  ```
188
195
 
189
196
  **Features:**
197
+
190
198
  - 🧡 **Auto-creates a thread** for each conversation
191
199
  - ⚑ **Real-time streaming** β€” see output as it's generated (1-second updates)
192
200
  - ⏸️ **Interrupt button** β€” stop the current task if needed
@@ -200,12 +208,13 @@ Start isolated work on a new branch with its own worktree.
200
208
  /work branch:feature/dark-mode description:Implement dark mode toggle
201
209
  ```
202
210
 
203
- | Parameter | Description |
204
- |-----------|-------------|
205
- | `branch` | Git branch name (will be sanitized) |
206
- | `description` | Brief description of the work |
211
+ | Parameter | Description |
212
+ | ------------- | ----------------------------------- |
213
+ | `branch` | Git branch name (will be sanitized) |
214
+ | `description` | Brief description of the work |
207
215
 
208
216
  **Features:**
217
+
209
218
  - 🌳 Creates a new git worktree for isolated work
210
219
  - 🧡 Opens a dedicated thread for the task
211
220
  - πŸ—‘οΈ **Delete button** β€” removes worktree and archives thread
@@ -222,11 +231,13 @@ Enable passthrough mode in a thread to send messages directly to OpenCode withou
222
231
  ```
223
232
 
224
233
  **How it works:**
234
+
225
235
  1. Run `/code` in any thread to enable passthrough mode
226
236
  2. Type messages naturally β€” they're sent directly to OpenCode
227
237
  3. Run `/code` again to disable
228
238
 
229
239
  **Example:**
240
+
230
241
  ```
231
242
  You: /code
232
243
  Bot: βœ… Passthrough mode enabled for this thread.
@@ -245,6 +256,7 @@ Bot: ❌ Passthrough mode disabled.
245
256
  ```
246
257
 
247
258
  **Features:**
259
+
248
260
  - πŸ“± **Mobile-friendly** β€” no more typing slash commands on phone
249
261
  - 🧡 **Thread-scoped** β€” only affects the specific thread, not the whole channel
250
262
  - ⏳ **Busy indicator** β€” shows ⏳ reaction if previous task is still running
@@ -259,11 +271,13 @@ Enable automatic worktree creation for a project. When enabled, new `/opencode`
259
271
  ```
260
272
 
261
273
  **How it works:**
274
+
262
275
  1. Run `/autowork` in a channel bound to a project
263
276
  2. The setting toggles on/off for that project
264
277
  3. When enabled, new sessions automatically create worktrees with branch names like `auto/abc12345-1738600000000`
265
278
 
266
279
  **Features:**
280
+
267
281
  - 🌳 **Automatic isolation** β€” each session gets its own branch and worktree
268
282
  - πŸ“± **Mobile-friendly** β€” no need to type `/work` with branch names
269
283
  - πŸ—‘οΈ **Delete button** β€” removes worktree when done
@@ -283,32 +297,92 @@ Control the automated job queue for the current thread.
283
297
  ```
284
298
 
285
299
  **How it works:**
300
+
286
301
  1. Send multiple messages to a thread (or use `/opencode` multiple times)
287
302
  2. If the bot is busy, it reacts with `πŸ“₯` and adds the task to the queue
288
303
  3. Once the current job is done, the bot automatically picks up the next one
289
304
 
290
305
  **Settings:**
306
+
291
307
  - `continue_on_failure`: If `True`, the bot moves to the next task even if the current one fails.
292
308
  - `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.
293
309
 
310
+ ### `/diff` β€” View Git Diff
311
+
312
+ Show git diffs for the current project directly in Discord β€” perfect for reviewing AI-made changes from your phone.
313
+
314
+ ```
315
+ /diff
316
+ /diff target:staged
317
+ /diff target:branch base:develop
318
+ /diff stat:true
319
+ ```
320
+
321
+ | Parameter | Description |
322
+ | --------- | ------------------------------------------------------------------ |
323
+ | `target` | `unstaged` (default), `staged`, or `branch` |
324
+ | `stat` | Show `--stat` summary only instead of full diff (default: `false`) |
325
+ | `base` | Base branch for `target:branch` diff (default: `main`) |
326
+
327
+ **How it works:**
328
+
329
+ - Inside a **worktree thread** β†’ diffs the worktree path for that branch
330
+ - In a **regular channel** β†’ diffs the channel-bound project path
331
+ - Output is formatted in a `diff` code block (truncated if over Discord's 2000-char limit)
332
+
333
+ **Examples:**
334
+
335
+ ```
336
+ /diff β†’ unstaged changes (git diff)
337
+ /diff target:staged β†’ staged changes (git diff --cached)
338
+ /diff target:branch β†’ changes vs main (git diff main...HEAD)
339
+ /diff target:branch base:dev β†’ changes vs dev branch
340
+ /diff stat:true β†’ summary only (git diff --stat)
341
+ ```
342
+
294
343
  ---
295
344
 
345
+ ### `/allow` β€” Manage Allowlist
346
+
347
+ Manage the user allowlist directly from Discord. This command is only available when the allowlist has already been initialized (at least one user exists).
348
+
349
+ ```
350
+ /allow action:add user:@username
351
+ /allow action:remove user:@username
352
+ /allow action:list
353
+ ```
354
+
355
+ | Parameter | Description |
356
+ | --------- | --------------------------------------------- |
357
+ | `action` | `add`, `remove`, or `list` |
358
+ | `user` | Target user (required for `add` and `remove`) |
359
+
360
+ **Behavior:**
361
+
362
+ - **Requires authorization** β€” only users already on the allowlist can use this command
363
+ - **Cannot remove last user** β€” prevents accidental lockout
364
+ - **Disabled when allowlist is empty** β€” initial setup must be done via CLI or setup wizard (see [Access Control](#access-control))
365
+
366
+ ---
296
367
 
297
368
  ## Usage Workflow
298
369
 
299
370
  ### Basic Workflow
300
371
 
301
372
  1. **Register your project:**
373
+
302
374
  ```
303
375
  /setpath alias:webapp path:/home/user/my-webapp
304
376
  ```
305
377
 
306
378
  2. **Bind to a channel:**
379
+
307
380
  ```
308
381
  /use alias:webapp
309
382
  ```
310
383
 
311
384
  3. **Start coding remotely:**
385
+
312
386
  ```
313
387
  /opencode prompt:Refactor the authentication module to use JWT
314
388
  ```
@@ -344,6 +418,7 @@ Share AI coding sessions with your team:
344
418
  Perfect for "setting and forgetting" several tasks:
345
419
 
346
420
  1. **Send multiple instructions:**
421
+
347
422
  ```
348
423
  You: Refactor the API
349
424
  Bot: [Starts working]
@@ -359,15 +434,73 @@ Perfect for "setting and forgetting" several tasks:
359
434
 
360
435
  ---
361
436
 
437
+ ## Access Control
438
+
439
+ 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.
440
+
441
+ ### How It Works
442
+
443
+ - **No allowlist configured (default):** All Discord users in the server can use the bot. This preserves backward compatibility for existing installations.
444
+ - **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.
445
+
446
+ ### Setting Up Access Control
447
+
448
+ > **⚠️ 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.**
449
+
450
+ #### Option 1: Setup Wizard (Recommended for first-time setup)
451
+
452
+ ```bash
453
+ remote-opencode setup
454
+ ```
455
+
456
+ Step 5 of the wizard prompts you to enter your Discord user ID. This becomes the first entry in the allowlist.
457
+
458
+ #### Option 2: CLI
459
+
460
+ ```bash
461
+ # Add your Discord user ID
462
+ remote-opencode allow add 123456789012345678
463
+
464
+ # Verify
465
+ remote-opencode allow list
466
+ ```
467
+
468
+ ### Managing the Allowlist
469
+
470
+ Once at least one user is on the allowlist, authorized users can manage it from Discord:
471
+
472
+ ```
473
+ /allow action:add user:@teammate
474
+ /allow action:remove user:@teammate
475
+ /allow action:list
476
+ ```
477
+
478
+ Or via CLI at any time:
479
+
480
+ ```bash
481
+ remote-opencode allow add <userId>
482
+ remote-opencode allow remove <userId>
483
+ remote-opencode allow list
484
+ remote-opencode allow reset # Clears entire allowlist (disables access control)
485
+ ```
486
+
487
+ ### Safety Guardrails
488
+
489
+ - **Cannot remove the last user** via Discord `/allow` or CLI `allow remove` β€” prevents accidental lockout
490
+ - **`allow reset`** is the only way to fully clear the allowlist (intentional action to disable access control)
491
+ - **Discord `/allow` is disabled when allowlist is empty** β€” prevents bootstrap attacks
492
+ - **Config file permissions** are set to `0o600` (owner-read/write only)
493
+
494
+ ---
362
495
 
363
496
  ## Configuration
364
497
 
365
498
  All configuration is stored in `~/.remote-opencode/`:
366
499
 
367
- | File | Purpose |
368
- |------|---------|
369
- | `config.json` | Bot credentials (token, client ID, guild ID) |
370
- | `data.json` | Project paths, channel bindings, session data |
500
+ | File | Purpose |
501
+ | ------------- | --------------------------------------------- |
502
+ | `config.json` | Bot credentials (token, client ID, guild ID) |
503
+ | `data.json` | Project paths, channel bindings, session data |
371
504
 
372
505
  ### config.json Structure
373
506
 
@@ -375,10 +508,13 @@ All configuration is stored in `~/.remote-opencode/`:
375
508
  {
376
509
  "discordToken": "your-bot-token",
377
510
  "clientId": "your-application-id",
378
- "guildId": "your-server-id"
511
+ "guildId": "your-server-id",
512
+ "allowedUserIds": ["123456789012345678"]
379
513
  }
380
514
  ```
381
515
 
516
+ > `allowedUserIds` is optional. When omitted or empty, access control is disabled and all users can use the bot.
517
+
382
518
  ### data.json Structure
383
519
 
384
520
  ```json
@@ -394,8 +530,8 @@ All configuration is stored in `~/.remote-opencode/`:
394
530
  }
395
531
  ```
396
532
 
397
- | Field | Description |
398
- |-------|-------------|
533
+ | Field | Description |
534
+ | ------------------------- | --------------------------------------------------------- |
399
535
  | `projects[].autoWorktree` | Optional. When `true`, new sessions auto-create worktrees |
400
536
 
401
537
  ---
@@ -419,6 +555,7 @@ All configuration is stored in `~/.remote-opencode/`:
419
555
  ### "No project set for this channel"
420
556
 
421
557
  You need to bind a project to the channel:
558
+
422
559
  ```
423
560
  /setpath alias:myproject path:/path/to/project
424
561
  /use alias:myproject
@@ -427,6 +564,7 @@ You need to bind a project to the channel:
427
564
  ### Commands not appearing in Discord
428
565
 
429
566
  Slash commands can take up to an hour to propagate globally. For faster updates:
567
+
430
568
  1. Kick the bot from your server
431
569
  2. Re-invite it
432
570
  3. Run `remote-opencode deploy`
@@ -443,6 +581,7 @@ Slash commands can take up to an hour to propagate globally. For faster updates:
443
581
  ### Session connection issues
444
582
 
445
583
  The bot maintains persistent sessions. If you encounter issues:
584
+
446
585
  1. Start a new thread with `/opencode` instead of continuing in an old one
447
586
  2. Restart the bot: `remote-opencode start`
448
587
 
@@ -497,6 +636,8 @@ src/
497
636
  β”‚ β”œβ”€β”€ opencode.ts # Main AI interaction command
498
637
  β”‚ β”œβ”€β”€ code.ts # Passthrough mode toggle
499
638
  β”‚ β”œβ”€β”€ work.ts # Worktree management
639
+ β”‚ β”œβ”€β”€ diff.ts # Git diff viewer
640
+ β”‚ β”œβ”€β”€ allow.ts # Allowlist management
500
641
  β”‚ β”œβ”€β”€ setpath.ts # Project registration
501
642
  β”‚ β”œβ”€β”€ projects.ts # List projects
502
643
  β”‚ └── use.ts # Channel binding
@@ -528,18 +669,37 @@ src/
528
669
 
529
670
  See [CHANGELOG.md](CHANGELOG.md) for a full history of changes.
530
671
 
672
+ ### [1.2.0] - 2026-02-15
673
+
674
+ #### Added
675
+
676
+ - **Owner/Admin Authentication**: User allowlist system to restrict bot access to authorized Discord users only.
677
+ - **`/allow` Slash Command**: Manage the allowlist directly from Discord (add, remove, list users).
678
+ - **CLI Allowlist Management**: `remote-opencode allow add|remove|list|reset` commands for managing access control from the terminal.
679
+ - **Setup Wizard Integration**: Step 5 prompts for owner Discord user ID during initial setup.
680
+
681
+ #### Security
682
+
683
+ - Initial allowlist setup is restricted to CLI and setup wizard only β€” prevents bootstrap attacks from Discord.
684
+ - Config file permissions hardened to `0o600` (owner-read/write only).
685
+ - Discord user ID validation enforces snowflake format (`/^\d{17,20}$/`).
686
+ - Cannot remove the last authorized user via Discord or CLI `remove` β€” prevents lockout.
687
+
531
688
  ### [1.1.0] - 2026-02-05
532
689
 
533
690
  #### Added
691
+
534
692
  - **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.
535
693
  - **Queue Management**: New `/queue` slash command suite to list, clear, pause, resume, and configure queue settings.
536
694
 
537
695
  ### [1.0.10] - 2026-02-04
538
696
 
539
697
  #### Added
698
+
540
699
  - New `/setports` slash command to configure the port range for OpenCode server instances.
541
700
 
542
701
  #### Fixed
702
+
543
703
  - Fixed Windows-specific spawning issue (targeting `opencode.cmd`).
544
704
  - Resolved `spawn EINVAL` errors on Windows.
545
705
  - Improved server reliability and suppressed `DEP0190` security warnings.
@@ -547,10 +707,12 @@ See [CHANGELOG.md](CHANGELOG.md) for a full history of changes.
547
707
  ### [1.0.9] - 2026-02-04
548
708
 
549
709
  #### Added
710
+
550
711
  - New `/model` slash command to set AI models per channel.
551
712
  - Support for `--model` flag in OpenCode server instances.
552
713
 
553
714
  #### Fixed
715
+
554
716
  - Fixed connection timeout issues.
555
717
  - Standardized internal communication to use `127.0.0.1`.
556
718
 
@@ -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
+ });
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
+ };
@@ -0,0 +1,93 @@
1
+ import { SlashCommandBuilder, MessageFlags } from 'discord.js';
2
+ import { exec } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import * as dataStore from '../services/dataStore.js';
5
+ const execAsync = promisify(exec);
6
+ const MAX_LENGTH = 1900;
7
+ const CODE_BLOCK_OVERHEAD = 8; // ```diff\n...\n```
8
+ function formatDiff(raw) {
9
+ const maxContent = MAX_LENGTH - CODE_BLOCK_OVERHEAD;
10
+ if (raw.length <= maxContent) {
11
+ return '```diff\n' + raw + '\n```';
12
+ }
13
+ const truncated = '...(truncated)...\n\n' + raw.slice(-maxContent + 20);
14
+ return '```diff\n' + truncated + '\n```';
15
+ }
16
+ export const diff = {
17
+ data: new SlashCommandBuilder()
18
+ .setName('diff')
19
+ .setDescription('Show git diff for the current project')
20
+ .addStringOption(option => option.setName('target')
21
+ .setDescription('What to diff: unstaged (default), staged, or branch')
22
+ .setRequired(false)
23
+ .addChoices({ name: 'unstaged', value: 'unstaged' }, { name: 'staged', value: 'staged' }, { name: 'branch', value: 'branch' }))
24
+ .addBooleanOption(option => option.setName('stat')
25
+ .setDescription('Show summary stats only (--stat)')
26
+ .setRequired(false))
27
+ .addStringOption(option => option.setName('base')
28
+ .setDescription('Base branch for branch diff (default: main)')
29
+ .setRequired(false)),
30
+ execute: async (interaction) => {
31
+ const i = interaction;
32
+ const target = i.options.getString('target') ?? 'unstaged';
33
+ const stat = i.options.getBoolean('stat') ?? false;
34
+ const base = i.options.getString('base') ?? 'main';
35
+ const channel = i.channel;
36
+ if (!channel) {
37
+ await i.reply({ content: '❌ Unknown channel.', flags: MessageFlags.Ephemeral });
38
+ return;
39
+ }
40
+ // Resolve project path: worktree thread takes priority
41
+ let projectPath;
42
+ if (channel.isThread()) {
43
+ const mapping = dataStore.getWorktreeMapping(i.channelId);
44
+ if (mapping) {
45
+ projectPath = mapping.worktreePath;
46
+ }
47
+ else {
48
+ const parentId = channel.parentId;
49
+ if (parentId) {
50
+ projectPath = dataStore.getChannelProjectPath(parentId);
51
+ }
52
+ }
53
+ }
54
+ else {
55
+ projectPath = dataStore.getChannelProjectPath(i.channelId);
56
+ }
57
+ if (!projectPath) {
58
+ await i.reply({
59
+ content: '❌ No project bound to this channel. Use `/setpath` and `/use` first.',
60
+ flags: MessageFlags.Ephemeral
61
+ });
62
+ return;
63
+ }
64
+ await i.deferReply();
65
+ try {
66
+ let gitArgs;
67
+ switch (target) {
68
+ case 'staged':
69
+ gitArgs = 'git diff --cached';
70
+ break;
71
+ case 'branch':
72
+ gitArgs = `git diff ${base}...HEAD`;
73
+ break;
74
+ default:
75
+ gitArgs = 'git diff';
76
+ }
77
+ if (stat) {
78
+ gitArgs += ' --stat';
79
+ }
80
+ const { stdout } = await execAsync(gitArgs, { cwd: projectPath });
81
+ const output = stdout.trim();
82
+ if (!output) {
83
+ const targetLabel = target === 'branch' ? `branch (base: ${base})` : target;
84
+ await i.editReply(`βœ… No ${targetLabel} changes.`);
85
+ return;
86
+ }
87
+ await i.editReply(formatDiff(output));
88
+ }
89
+ catch (error) {
90
+ await i.editReply(`❌ Failed to get diff: ${error.message}`);
91
+ }
92
+ }
93
+ };
@@ -9,6 +9,8 @@ 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';
13
+ import { diff } from './diff.js';
12
14
  export const commands = new Collection();
13
15
  commands.set(setpath.data.name, setpath);
14
16
  commands.set(projects.data.name, projects);
@@ -20,3 +22,5 @@ commands.set(autowork.data.name, autowork);
20
22
  commands.set(model.data.name, model);
21
23
  commands.set(setports.data.name, setports);
22
24
  commands.set(queue.data.name, queue);
25
+ commands.set(allow.data.name, allow);
26
+ commands.set(diff.data.name, diff);
@@ -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
+ }
@@ -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
- // Step 5: Invite Bot to Server
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 5: Invite Bot to Server');
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 6: Deploy Commands
185
+ // Step 7: Deploy Commands
160
186
  const shouldDeploy = await p.confirm({
161
187
  message: 'Deploy slash commands now?',
162
188
  initialValue: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-opencode",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "description": "Discord bot for remote OpenCode CLI access",
5
5
  "main": "dist/src/index.js",
6
6
  "bin": {