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 +188 -26
- package/dist/src/__tests__/auth.test.js +114 -0
- package/dist/src/cli.js +51 -1
- package/dist/src/commands/allow.js +70 -0
- package/dist/src/commands/diff.js +93 -0
- package/dist/src/commands/index.js +4 -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/setup/wizard.js +30 -4
- package/package.json +1 -1
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
|
-
 π¦ Used by developers worldwide β **
|
|
6
|
-
|
|
5
|
+
 π¦ Used by developers worldwide β **1000+ weekly downloads** on npm
|
|
7
6
|
|
|
8
7
|
<div align="center">
|
|
9
|
-
<img width="1024" alt="
|
|
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
|
|
137
|
-
|
|
138
|
-
| `remote-opencode`
|
|
139
|
-
| `remote-opencode setup`
|
|
140
|
-
| `remote-opencode start`
|
|
141
|
-
| `remote-opencode deploy`
|
|
142
|
-
| `remote-opencode config`
|
|
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`
|
|
161
|
-
| `path`
|
|
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
|
|
204
|
-
|
|
205
|
-
| `branch`
|
|
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
|
|
368
|
-
|
|
369
|
-
| `config.json` | Bot credentials (token, client ID, guild ID)
|
|
370
|
-
| `data.json`
|
|
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
|
|
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
|
+
}
|
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,
|