@waishnav/devspace 1.0.1 → 1.0.2
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 +103 -29
- package/dist/cli.js +5 -2
- package/dist/db/client.js +10 -3
- package/dist/db/migrations.js +123 -0
- package/dist/db/schema.js +24 -1
- package/dist/oauth-provider.js +35 -64
- package/dist/oauth-store.js +138 -0
- package/dist/server.js +23 -4
- package/dist/ui/.vite/manifest.json +281 -281
- package/dist/ui/assets/{angular-html-B_77pPm_.js → angular-html-Dj4LysSE.js} +1 -1
- package/dist/ui/assets/{angular-ts-tGBi7sOr.js → angular-ts-CxuC_FFh.js} +1 -1
- package/dist/ui/assets/{apl-CtVPpGgP.js → apl-RwuvNFFo.js} +1 -1
- package/dist/ui/assets/{astro-CALPYvnG.js → astro-ncoaUbsO.js} +1 -1
- package/dist/ui/assets/{blade-Bh4eBXdk.js → blade-Dw8-Lzux.js} +1 -1
- package/dist/ui/assets/{c-BGkebjQE.js → c-Dpbbo_f2.js} +1 -1
- package/dist/ui/assets/{cobol-f7rP2PjR.js → cobol-4ddX3THy.js} +1 -1
- package/dist/ui/assets/{coffee-E4OhMc7W.js → coffee-Bn-a5KqK.js} +1 -1
- package/dist/ui/assets/{cpp-Cr_J6lB2.js → cpp-DoRrlM_T.js} +1 -1
- package/dist/ui/assets/{crystal-B_TFXhon.js → crystal-DiZfCxn-.js} +1 -1
- package/dist/ui/assets/{css-DDMxq-4J.js → css-DiQoSjDH.js} +1 -1
- package/dist/ui/assets/{edge-Bxqhgg5I.js → edge-wEhCUoEt.js} +1 -1
- package/dist/ui/assets/{elixir-WrSbgJJ1.js → elixir-C3obhm0H.js} +1 -1
- package/dist/ui/assets/{elm-BXBWJ4Kz.js → elm-DyTBfh1L.js} +1 -1
- package/dist/ui/assets/{erb-D0iMw9rO.js → erb-f4TnXRuY.js} +1 -1
- package/dist/ui/assets/{git-rebase-BOrZ-_iR.js → git-rebase-vhZbWNfp.js} +1 -1
- package/dist/ui/assets/{glimmer-js-PCWk96O8.js → glimmer-js-BG7puL6Y.js} +1 -1
- package/dist/ui/assets/{glimmer-ts-4pYwX8pC.js → glimmer-ts-CNCfLLL1.js} +1 -1
- package/dist/ui/assets/{glsl-CDyKZvZX.js → glsl-DhT76AL5.js} +1 -1
- package/dist/ui/assets/{graphql-8sIw92YT.js → graphql-CJX08w8y.js} +1 -1
- package/dist/ui/assets/{hack-Ci96Zcew.js → hack-DY4NGCCL.js} +1 -1
- package/dist/ui/assets/{haml-4uzh6End.js → haml-DwSkmVcG.js} +1 -1
- package/dist/ui/assets/{handlebars-B6Eq7QV8.js → handlebars-CQ_1dEmi.js} +1 -1
- package/dist/ui/assets/{heavy-payload--bwRtRpo.js → heavy-payload-2rGBiitA.js} +1 -1
- package/dist/ui/assets/{html-C29wL7bm.js → html-B_e3Njif.js} +1 -1
- package/dist/ui/assets/{html-derivative-Cmd90ilz.js → html-derivative-_nHZ_Bk-.js} +1 -1
- package/dist/ui/assets/{http-Bdr_R0Kw.js → http-CfcvPIru.js} +1 -1
- package/dist/ui/assets/{hurl-CiTPn-zH.js → hurl-9vWDQIMO.js} +1 -1
- package/dist/ui/assets/{java-C03kBuJH.js → java-C4S5r6L5.js} +1 -1
- package/dist/ui/assets/{javascript-135g9Wwx.js → javascript-BRwqbMMC.js} +1 -1
- package/dist/ui/assets/{jinja-CPpiEHqV.js → jinja-DHxzMMc5.js} +1 -1
- package/dist/ui/assets/{jison-qabCQzwp.js → jison-B1DqeWXb.js} +1 -1
- package/dist/ui/assets/{json-CtRj9Trp.js → json-DcFTkEdd.js} +1 -1
- package/dist/ui/assets/{jsx-Ccuy5Wm3.js → jsx-DgfmYa9f.js} +1 -1
- package/dist/ui/assets/{julia-Fwya-MwM.js → julia-3M8SJ8iF.js} +1 -1
- package/dist/ui/assets/{just-c9YEH7Gf.js → just-UyH7_DNz.js} +1 -1
- package/dist/ui/assets/{latex-D9xoD6UX.js → latex-wUbha6EX.js} +1 -1
- package/dist/ui/assets/{liquid-BKZLZSHN.js → liquid-DqNTB9kN.js} +1 -1
- package/dist/ui/assets/{lua-CBD2zNyC.js → lua-DrEQ1QTW.js} +1 -1
- package/dist/ui/assets/{marko-Dkgf9bj3.js → marko-B3GJw9Md.js} +1 -1
- package/dist/ui/assets/{mdc-BUgUKiX3.js → mdc-BnThk82m.js} +1 -1
- package/dist/ui/assets/{nginx-CAaav4Zh.js → nginx-B5IeSsOX.js} +1 -1
- package/dist/ui/assets/{nim-CTjWWztL.js → nim-BJraatnS.js} +1 -1
- package/dist/ui/assets/{perl-DTM5zgH4.js → perl-BAhrR4gt.js} +1 -1
- package/dist/ui/assets/{php-dqfFDbWm.js → php-BKRCk_wX.js} +1 -1
- package/dist/ui/assets/{pug-4f_clTm2.js → pug-B8gmSV7z.js} +1 -1
- package/dist/ui/assets/{qml-BFeWjFRL.js → qml-DKrxIjHR.js} +1 -1
- package/dist/ui/assets/{r-ei1iNvHm.js → r-YASBZvTH.js} +1 -1
- package/dist/ui/assets/{razor-jj95jncJ.js → razor-P-XFZqh6.js} +1 -1
- package/dist/ui/assets/{regexp-0joUf3Cn.js → regexp-CCpiH6nO.js} +1 -1
- package/dist/ui/assets/{review-payload-DxI2fTnP.js → review-payload-BLYwg6t8.js} +1 -1
- package/dist/ui/assets/{rst-BMbcN8--.js → rst-CFAzckbn.js} +1 -1
- package/dist/ui/assets/{ruby-BQx0xtOB.js → ruby-Dyp_Rx-S.js} +1 -1
- package/dist/ui/assets/{sas-C_Fl1EyP.js → sas-YkWZIXxT.js} +1 -1
- package/dist/ui/assets/{scss-5vaLJPJl.js → scss-Cf89idGl.js} +1 -1
- package/dist/ui/assets/{shellscript-p_y7YCYP.js → shellscript-BFMb52q8.js} +1 -1
- package/dist/ui/assets/{shellsession-ChZkMp2K.js → shellsession-DuthW-5v.js} +1 -1
- package/dist/ui/assets/{soy-im25cA8U.js → soy-BohVVlJU.js} +1 -1
- package/dist/ui/assets/{sql-BzvG6ssm.js → sql-CnK9R2Gt.js} +1 -1
- package/dist/ui/assets/{stata-CwSzNM20.js → stata-D-sV4Vbr.js} +1 -1
- package/dist/ui/assets/{surrealql-BWzKhI6S.js → surrealql-ChAy0gHv.js} +1 -1
- package/dist/ui/assets/{svelte-NMlQHjaa.js → svelte-3HBVqG-H.js} +1 -1
- package/dist/ui/assets/{templ-1W11KGSL.js → templ-BixlV4Cp.js} +1 -1
- package/dist/ui/assets/{tex-CTxN8q2c.js → tex-DWoJKHvD.js} +1 -1
- package/dist/ui/assets/{ts-tags-B6prphu-.js → ts-tags-DLA8eH13.js} +1 -1
- package/dist/ui/assets/{tsx-Daks24y9.js → tsx-BYWa5JUz.js} +1 -1
- package/dist/ui/assets/{twig-C0KeCxch.js → twig-CnY65vAo.js} +1 -1
- package/dist/ui/assets/{typescript-37-dkCwp.js → typescript-ByInUsro.js} +1 -1
- package/dist/ui/assets/{useFileDiffInstance--R0_ywL_.js → useFileDiffInstance-Dg0pWcJV.js} +3 -3
- package/dist/ui/assets/{vue-Ba-v02vP.js → vue-Qngt2rkz.js} +1 -1
- package/dist/ui/assets/{vue-html-D2UmbuN7.js → vue-html-DDue9upr.js} +1 -1
- package/dist/ui/assets/{vue-vine-ZftUW_ID.js → vue-vine-DBcAKsA6.js} +1 -1
- package/dist/ui/assets/workspace-app-DGD9R89D.css +1 -0
- package/dist/ui/assets/{workspace-app-CsMiGtz9.js → workspace-app-DuzpU4Hf.js} +5 -5
- package/dist/ui/assets/{xml-DrhRYxTS.js → xml-CRLR5jiL.js} +1 -1
- package/dist/ui/assets/{xsl-DvbIvBcQ.js → xsl-CmcwyXbm.js} +1 -1
- package/dist/ui/assets/{yaml-oktUYAzQ.js → yaml-Dpe6Eg-k.js} +1 -1
- package/dist/ui/workspace-app.html +2 -2
- package/dist/workspace-store.js +0 -50
- package/package.json +2 -2
- package/dist/ui/assets/workspace-app-8--oTbCd.css +0 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<p align="center">
|
|
2
2
|
<picture>
|
|
3
|
-
<img src="docs/assets/devspace-logo-light.png" alt="DevSpace logo" width="140">
|
|
3
|
+
<img src="https://raw.githubusercontent.com/Waishnav/devspace/main/docs/assets/devspace-logo-light.png" alt="DevSpace logo" width="140">
|
|
4
4
|
</picture>
|
|
5
5
|
</p>
|
|
6
6
|
|
|
@@ -14,16 +14,66 @@
|
|
|
14
14
|
<a href="https://github.com/Waishnav/devspace/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/npm/l/%40waishnav%2Fdevspace?style=flat-square" /></a>
|
|
15
15
|
</p>
|
|
16
16
|
|
|
17
|
-
[](docs/assets/devspace-screenshot.png)
|
|
17
|
+
[](https://raw.githubusercontent.com/Waishnav/devspace/main/docs/assets/devspace-screenshot.png)
|
|
18
18
|
|
|
19
19
|
**Give ChatGPT a secure connection to your own machine and Turn ChatGPT into Codex**
|
|
20
20
|
|
|
21
21
|
DevSpace is a self-hosted MCP server that lets ChatGPT read, edit, search, and run code in your real local projects — your files, your tools, your terminal — without uploading anything to a third party. You run it on your machine, expose it through a tunnel you control, and approve the connection with a password only you have.
|
|
22
22
|
|
|
23
|
-
##
|
|
23
|
+
## Sponsors and Special Thanks
|
|
24
|
+
|
|
25
|
+
<table>
|
|
26
|
+
<thead>
|
|
27
|
+
<tr>
|
|
28
|
+
<th>Sponsor</th>
|
|
29
|
+
<th>About</th>
|
|
30
|
+
</tr>
|
|
31
|
+
</thead>
|
|
32
|
+
<tbody>
|
|
33
|
+
<tr>
|
|
34
|
+
<td align="center" width="220">
|
|
35
|
+
<a href="https://rebates.ai/">
|
|
36
|
+
<img
|
|
37
|
+
src="https://app.rebates.ai/brand/rebates-lockup.svg"
|
|
38
|
+
alt="Rebates"
|
|
39
|
+
width="170"
|
|
40
|
+
>
|
|
41
|
+
</a>
|
|
42
|
+
</td>
|
|
43
|
+
<td>
|
|
44
|
+
<strong>The ads in your terminal pay you.</strong><br><br>
|
|
45
|
+
<a href="https://rebates.ai/">Rebates</a> adds one optional
|
|
46
|
+
sponsored footer to your coding agent and pays you cash back for every
|
|
47
|
+
session in which it is shown. Turn it off at any time.
|
|
48
|
+
</td>
|
|
49
|
+
</tr>
|
|
50
|
+
</tbody>
|
|
51
|
+
</table>
|
|
52
|
+
|
|
53
|
+
<p align="center">
|
|
54
|
+
DevSpace is open to new sponsors.
|
|
55
|
+
<a href="https://x.com/wshxnv">Get in touch to become one.</a>
|
|
56
|
+
</p>
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
24
59
|
|
|
25
60
|
DevSpace requires Node `>=20.12 <27`. Node 22 LTS is recommended.
|
|
26
61
|
|
|
62
|
+
Install the DevSpace CLI:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm install -g @waishnav/devspace
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Then initialize and start the server:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
devspace init
|
|
72
|
+
devspace serve
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Or run it without a global install:
|
|
76
|
+
|
|
27
77
|
```bash
|
|
28
78
|
npx @waishnav/devspace init
|
|
29
79
|
npx @waishnav/devspace serve
|
|
@@ -42,11 +92,7 @@ Use the public origin without `/mcp` during setup:
|
|
|
42
92
|
https://your-tunnel-host.example.com
|
|
43
93
|
```
|
|
44
94
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
```text
|
|
48
|
-
https://your-tunnel-host.example.com/mcp
|
|
49
|
-
```
|
|
95
|
+
You will configure your MCP client with the public `/mcp` URL after setup.
|
|
50
96
|
|
|
51
97
|
When the client connects, DevSpace opens an Owner password approval page. Enter
|
|
52
98
|
the Owner password printed by `devspace init`. It is also stored in:
|
|
@@ -57,20 +103,7 @@ the Owner password printed by `devspace init`. It is also stored in:
|
|
|
57
103
|
|
|
58
104
|
Keep that password private.
|
|
59
105
|
|
|
60
|
-
##
|
|
61
|
-
|
|
62
|
-
After the MCP client connects, ChatGPT can open a project with
|
|
63
|
-
`open_workspace` and then reuse the returned `workspaceId` for later calls.
|
|
64
|
-
|
|
65
|
-
DevSpace provides tools for:
|
|
66
|
-
|
|
67
|
-
- reading, writing, and editing files inside the opened workspace
|
|
68
|
-
- running shell-backed search and directory inspection in the default minimal tool mode
|
|
69
|
-
- running shell commands for tests, builds, git, and package scripts
|
|
70
|
-
- opening isolated Git worktrees when you want parallel work
|
|
71
|
-
- loading `AGENTS.md` and `CLAUDE.md` instructions
|
|
72
|
-
- exposing local agent skills from your skill folders
|
|
73
|
-
- showing ChatGPT Apps tool cards, with an opt-in aggregate `show_changes` card
|
|
106
|
+
## Connect Your MCP Client
|
|
74
107
|
|
|
75
108
|
The default local endpoint is:
|
|
76
109
|
|
|
@@ -84,6 +117,22 @@ Most users should connect through a public HTTPS tunnel:
|
|
|
84
117
|
https://your-tunnel-host.example.com/mcp
|
|
85
118
|
```
|
|
86
119
|
|
|
120
|
+
## What ChatGPT Can Do
|
|
121
|
+
|
|
122
|
+
Once connected, ChatGPT can open one of your approved project folders as a
|
|
123
|
+
workspace. From there, it can inspect the repo, make scoped edits, run commands,
|
|
124
|
+
and show you what changed.
|
|
125
|
+
|
|
126
|
+
DevSpace gives ChatGPT tools to:
|
|
127
|
+
|
|
128
|
+
- read, write, and edit files inside the opened workspace
|
|
129
|
+
- search code and inspect directories
|
|
130
|
+
- run shell commands for tests, builds, git, and package scripts
|
|
131
|
+
- use isolated Git worktrees for parallel coding sessions
|
|
132
|
+
- follow project instructions from `AGENTS.md` and `CLAUDE.md`
|
|
133
|
+
- discover local agent skills from your skill folders
|
|
134
|
+
- show tool cards and optional change summaries in ChatGPT Apps-compatible hosts
|
|
135
|
+
|
|
87
136
|
## Mental Model
|
|
88
137
|
|
|
89
138
|
DevSpace is remote access to selected local folders.
|
|
@@ -95,7 +144,7 @@ connected client like a trusted coding partner with access to your machine.
|
|
|
95
144
|
For a normal ChatGPT coding session:
|
|
96
145
|
|
|
97
146
|
1. Start your tunnel.
|
|
98
|
-
2. Run `
|
|
147
|
+
2. Run `devspace serve`.
|
|
99
148
|
3. Connect the MCP client to your public `/mcp` URL.
|
|
100
149
|
4. Approve the connection with the Owner password.
|
|
101
150
|
5. Ask ChatGPT to open a project inside one of your allowed roots.
|
|
@@ -115,16 +164,41 @@ shell.
|
|
|
115
164
|
Run this to inspect your local setup:
|
|
116
165
|
|
|
117
166
|
```bash
|
|
118
|
-
|
|
167
|
+
devspace doctor
|
|
119
168
|
```
|
|
120
169
|
|
|
121
170
|
## Documentation
|
|
122
171
|
|
|
123
|
-
- [Setup Guide](docs/setup.md)
|
|
124
|
-
- [ChatGPT Coding Workflow](docs/chatgpt-coding-workflow.md)
|
|
125
|
-
- [Configuration Reference](docs/configuration.md)
|
|
126
|
-
- [Security Model](docs/security.md)
|
|
127
|
-
- [Troubleshooting Gotchas](docs/gotchas.md)
|
|
172
|
+
- [Setup Guide](https://github.com/Waishnav/devspace/blob/main/docs/setup.md)
|
|
173
|
+
- [ChatGPT Coding Workflow](https://github.com/Waishnav/devspace/blob/main/docs/chatgpt-coding-workflow.md)
|
|
174
|
+
- [Configuration Reference](https://github.com/Waishnav/devspace/blob/main/docs/configuration.md)
|
|
175
|
+
- [Security Model](https://github.com/Waishnav/devspace/blob/main/docs/security.md)
|
|
176
|
+
- [Troubleshooting Gotchas](https://github.com/Waishnav/devspace/blob/main/docs/gotchas.md)
|
|
177
|
+
|
|
178
|
+
## Philosophy
|
|
179
|
+
|
|
180
|
+
Every piece of software is becoming conversational. Natural language is
|
|
181
|
+
redefining how we interact with tools, workflows, and systems.
|
|
182
|
+
|
|
183
|
+
My bet is that ChatGPT becomes the operating system for everything. Once we
|
|
184
|
+
reach AGI, we will simply talk to ChatGPT, and it will prompt, coordinate, and
|
|
185
|
+
orchestrate sub-agents that set up the right loops for us.
|
|
186
|
+
|
|
187
|
+
We are not there yet.
|
|
188
|
+
|
|
189
|
+
DevSpace is one attempt to fast-forward that future: a way for MCP-capable
|
|
190
|
+
hosts like ChatGPT and Claude to work directly with local project files through
|
|
191
|
+
explicit, inspectable tools.
|
|
192
|
+
|
|
193
|
+
## Built by Waishnav
|
|
194
|
+
|
|
195
|
+
I'm Waishnav, the creator of [GitCMS](https://gitcms.dev/), a Git-backed CMS
|
|
196
|
+
for markdown sites.
|
|
197
|
+
|
|
198
|
+
I like building opinionated products, and DevSpace is another example of that.
|
|
199
|
+
I'm on a journey to build a single-person company doing multiple millions in
|
|
200
|
+
revenue. If you want to watch the failures, wins, lessons, and everything in
|
|
201
|
+
between, come hang out with me on [X](https://x.com/wshxnv).
|
|
128
202
|
|
|
129
203
|
## Local Development
|
|
130
204
|
|
package/dist/cli.js
CHANGED
|
@@ -149,7 +149,7 @@ async function serve() {
|
|
|
149
149
|
}
|
|
150
150
|
const { createServer } = await import("./server.js");
|
|
151
151
|
const config = loadConfig();
|
|
152
|
-
const { app } = createServer(config);
|
|
152
|
+
const { app, close } = createServer(config);
|
|
153
153
|
const httpServer = app.listen(config.port, config.host, () => {
|
|
154
154
|
console.log(`devspace listening on http://${config.host}:${config.port}/mcp`);
|
|
155
155
|
console.log(`public base url: ${config.publicBaseUrl}`);
|
|
@@ -162,7 +162,10 @@ async function serve() {
|
|
|
162
162
|
console.log(`logging: ${config.logging.level} ${config.logging.format}`);
|
|
163
163
|
});
|
|
164
164
|
const shutdown = () => {
|
|
165
|
-
httpServer.close(() =>
|
|
165
|
+
httpServer.close(() => {
|
|
166
|
+
close();
|
|
167
|
+
process.exit(0);
|
|
168
|
+
});
|
|
166
169
|
};
|
|
167
170
|
process.once("SIGINT", shutdown);
|
|
168
171
|
process.once("SIGTERM", shutdown);
|
package/dist/db/client.js
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
|
-
import { mkdirSync } from "node:fs";
|
|
1
|
+
import { chmodSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import Database from "better-sqlite3";
|
|
4
4
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
5
5
|
import * as schema from "./schema.js";
|
|
6
|
+
import { migrateDatabase } from "./migrations.js";
|
|
6
7
|
export function databasePath(stateDir) {
|
|
7
8
|
return join(stateDir, "devspace.sqlite");
|
|
8
9
|
}
|
|
9
10
|
export function openDatabase(stateDir) {
|
|
10
|
-
mkdirSync(stateDir, { recursive: true });
|
|
11
|
-
|
|
11
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
12
|
+
chmodSync(stateDir, 0o700);
|
|
13
|
+
const path = databasePath(stateDir);
|
|
14
|
+
const sqlite = new Database(path);
|
|
15
|
+
chmodSync(path, 0o600);
|
|
12
16
|
sqlite.pragma("journal_mode = WAL");
|
|
17
|
+
sqlite.pragma("synchronous = NORMAL");
|
|
18
|
+
sqlite.pragma("busy_timeout = 5000");
|
|
13
19
|
sqlite.pragma("foreign_keys = ON");
|
|
20
|
+
migrateDatabase(sqlite);
|
|
14
21
|
return {
|
|
15
22
|
sqlite,
|
|
16
23
|
db: createDrizzleDatabase(sqlite),
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
const migrations = [
|
|
2
|
+
{
|
|
3
|
+
version: 1,
|
|
4
|
+
name: "workspace-state",
|
|
5
|
+
up: migrateWorkspaceState,
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
version: 2,
|
|
9
|
+
name: "oauth-state",
|
|
10
|
+
up: migrateOAuthState,
|
|
11
|
+
},
|
|
12
|
+
];
|
|
13
|
+
export function migrateDatabase(sqlite) {
|
|
14
|
+
const migrate = sqlite.transaction(() => {
|
|
15
|
+
sqlite.exec(`
|
|
16
|
+
create table if not exists devspace_schema_migrations (
|
|
17
|
+
version integer primary key,
|
|
18
|
+
name text not null,
|
|
19
|
+
applied_at text not null
|
|
20
|
+
);
|
|
21
|
+
`);
|
|
22
|
+
const applied = new Set(sqlite.prepare("select version from devspace_schema_migrations").all().map((row) => row.version));
|
|
23
|
+
const recordMigration = sqlite.prepare("insert into devspace_schema_migrations (version, name, applied_at) values (?, ?, ?)");
|
|
24
|
+
for (const migration of migrations) {
|
|
25
|
+
if (applied.has(migration.version))
|
|
26
|
+
continue;
|
|
27
|
+
migration.up(sqlite);
|
|
28
|
+
recordMigration.run(migration.version, migration.name, new Date().toISOString());
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
migrate.immediate();
|
|
32
|
+
}
|
|
33
|
+
function migrateWorkspaceState(sqlite) {
|
|
34
|
+
sqlite.exec(`
|
|
35
|
+
create table if not exists workspace_sessions (
|
|
36
|
+
id text primary key,
|
|
37
|
+
root text not null,
|
|
38
|
+
status text not null default 'active',
|
|
39
|
+
mode text not null default 'checkout',
|
|
40
|
+
source_root text,
|
|
41
|
+
base_ref text,
|
|
42
|
+
base_sha text,
|
|
43
|
+
managed text not null default 'false',
|
|
44
|
+
created_at text not null,
|
|
45
|
+
last_used_at text not null
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
create index if not exists workspace_sessions_root_idx
|
|
49
|
+
on workspace_sessions(root, last_used_at desc);
|
|
50
|
+
|
|
51
|
+
create index if not exists workspace_sessions_status_idx
|
|
52
|
+
on workspace_sessions(status, last_used_at desc);
|
|
53
|
+
|
|
54
|
+
create table if not exists loaded_agent_files (
|
|
55
|
+
workspace_session_id text not null,
|
|
56
|
+
path text not null,
|
|
57
|
+
content_hash text not null,
|
|
58
|
+
content text not null,
|
|
59
|
+
loaded_at text not null,
|
|
60
|
+
last_seen_at text not null,
|
|
61
|
+
primary key (workspace_session_id, path),
|
|
62
|
+
foreign key (workspace_session_id)
|
|
63
|
+
references workspace_sessions(id)
|
|
64
|
+
on delete cascade
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
create index if not exists loaded_agent_files_path_idx
|
|
68
|
+
on loaded_agent_files(path);
|
|
69
|
+
`);
|
|
70
|
+
addColumnIfMissing(sqlite, "workspace_sessions", "mode", "text not null default 'checkout'");
|
|
71
|
+
addColumnIfMissing(sqlite, "workspace_sessions", "source_root", "text");
|
|
72
|
+
addColumnIfMissing(sqlite, "workspace_sessions", "base_ref", "text");
|
|
73
|
+
addColumnIfMissing(sqlite, "workspace_sessions", "base_sha", "text");
|
|
74
|
+
addColumnIfMissing(sqlite, "workspace_sessions", "managed", "text not null default 'false'");
|
|
75
|
+
}
|
|
76
|
+
function migrateOAuthState(sqlite) {
|
|
77
|
+
sqlite.exec(`
|
|
78
|
+
create table if not exists oauth_clients (
|
|
79
|
+
client_id text primary key,
|
|
80
|
+
client_json text not null,
|
|
81
|
+
issued_at integer not null
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
create index if not exists oauth_clients_issued_at_idx
|
|
85
|
+
on oauth_clients(issued_at desc);
|
|
86
|
+
|
|
87
|
+
create table if not exists oauth_access_tokens (
|
|
88
|
+
token_hash text primary key,
|
|
89
|
+
client_id text not null,
|
|
90
|
+
scopes_json text not null,
|
|
91
|
+
expires_at integer not null,
|
|
92
|
+
resource text,
|
|
93
|
+
foreign key (client_id) references oauth_clients(client_id) on delete cascade
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
create index if not exists oauth_access_tokens_client_id_idx
|
|
97
|
+
on oauth_access_tokens(client_id);
|
|
98
|
+
|
|
99
|
+
create index if not exists oauth_access_tokens_expires_at_idx
|
|
100
|
+
on oauth_access_tokens(expires_at);
|
|
101
|
+
|
|
102
|
+
create table if not exists oauth_refresh_tokens (
|
|
103
|
+
token_hash text primary key,
|
|
104
|
+
client_id text not null,
|
|
105
|
+
scopes_json text not null,
|
|
106
|
+
expires_at integer not null,
|
|
107
|
+
resource text,
|
|
108
|
+
foreign key (client_id) references oauth_clients(client_id) on delete cascade
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
create index if not exists oauth_refresh_tokens_client_id_idx
|
|
112
|
+
on oauth_refresh_tokens(client_id);
|
|
113
|
+
|
|
114
|
+
create index if not exists oauth_refresh_tokens_expires_at_idx
|
|
115
|
+
on oauth_refresh_tokens(expires_at);
|
|
116
|
+
`);
|
|
117
|
+
}
|
|
118
|
+
function addColumnIfMissing(sqlite, table, column, definition) {
|
|
119
|
+
const columns = sqlite.prepare(`pragma table_info(${table})`).all();
|
|
120
|
+
if (columns.some((existingColumn) => existingColumn.name === column))
|
|
121
|
+
return;
|
|
122
|
+
sqlite.exec(`alter table ${table} add column ${column} ${definition}`);
|
|
123
|
+
}
|
package/dist/db/schema.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { index, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
1
|
+
import { index, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
2
2
|
export const workspaceSessions = sqliteTable("workspace_sessions", {
|
|
3
3
|
id: text("id").primaryKey(),
|
|
4
4
|
root: text("root").notNull(),
|
|
@@ -27,3 +27,26 @@ export const loadedAgentFiles = sqliteTable("loaded_agent_files", {
|
|
|
27
27
|
primaryKey({ columns: [table.workspaceSessionId, table.path] }),
|
|
28
28
|
index("loaded_agent_files_path_idx").on(table.path),
|
|
29
29
|
]);
|
|
30
|
+
export const oauthClients = sqliteTable("oauth_clients", {
|
|
31
|
+
clientId: text("client_id").primaryKey(),
|
|
32
|
+
clientJson: text("client_json").notNull(),
|
|
33
|
+
issuedAt: integer("issued_at").notNull(),
|
|
34
|
+
});
|
|
35
|
+
export const oauthAccessTokens = sqliteTable("oauth_access_tokens", {
|
|
36
|
+
tokenHash: text("token_hash").primaryKey(),
|
|
37
|
+
clientId: text("client_id")
|
|
38
|
+
.notNull()
|
|
39
|
+
.references(() => oauthClients.clientId, { onDelete: "cascade" }),
|
|
40
|
+
scopesJson: text("scopes_json").notNull(),
|
|
41
|
+
expiresAt: integer("expires_at").notNull(),
|
|
42
|
+
resource: text("resource"),
|
|
43
|
+
});
|
|
44
|
+
export const oauthRefreshTokens = sqliteTable("oauth_refresh_tokens", {
|
|
45
|
+
tokenHash: text("token_hash").primaryKey(),
|
|
46
|
+
clientId: text("client_id")
|
|
47
|
+
.notNull()
|
|
48
|
+
.references(() => oauthClients.clientId, { onDelete: "cascade" }),
|
|
49
|
+
scopesJson: text("scopes_json").notNull(),
|
|
50
|
+
expiresAt: integer("expires_at").notNull(),
|
|
51
|
+
resource: text("resource"),
|
|
52
|
+
});
|
package/dist/oauth-provider.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { timingSafeEqual, randomBytes, randomUUID, createHash } from "node:crypto";
|
|
2
2
|
import { AccessDeniedError, InvalidGrantError, InvalidRequestError, InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
|
3
3
|
import { checkResourceAllowed, resourceUrlFromServerUrl } from "@modelcontextprotocol/sdk/shared/auth-utils.js";
|
|
4
|
+
import { SqliteOAuthClientsStore, SqliteOAuthStore } from "./oauth-store.js";
|
|
4
5
|
const CODE_TTL_MS = 5 * 60 * 1000;
|
|
5
6
|
function randomToken() {
|
|
6
7
|
return randomBytes(32).toString("base64url");
|
|
@@ -74,55 +75,17 @@ ${hiddenFields}
|
|
|
74
75
|
function requestedScopesAllowed(requested, supported) {
|
|
75
76
|
return requested.every((scope) => supported.includes(scope));
|
|
76
77
|
}
|
|
77
|
-
function redirectHostAllowed(redirectUri, allowedHosts) {
|
|
78
|
-
let parsed;
|
|
79
|
-
try {
|
|
80
|
-
parsed = new URL(redirectUri);
|
|
81
|
-
}
|
|
82
|
-
catch {
|
|
83
|
-
return false;
|
|
84
|
-
}
|
|
85
|
-
if (["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname))
|
|
86
|
-
return true;
|
|
87
|
-
return allowedHosts.includes(parsed.hostname);
|
|
88
|
-
}
|
|
89
|
-
export class InMemoryOAuthClientsStore {
|
|
90
|
-
allowedRedirectHosts;
|
|
91
|
-
clients = new Map();
|
|
92
|
-
constructor(allowedRedirectHosts) {
|
|
93
|
-
this.allowedRedirectHosts = allowedRedirectHosts;
|
|
94
|
-
}
|
|
95
|
-
getClient(clientId) {
|
|
96
|
-
return this.clients.get(clientId);
|
|
97
|
-
}
|
|
98
|
-
registerClient(client) {
|
|
99
|
-
if (!client.redirect_uris.every((uri) => redirectHostAllowed(uri, this.allowedRedirectHosts))) {
|
|
100
|
-
throw new InvalidRequestError("Client redirect_uri is not allowed for this DevSpace server");
|
|
101
|
-
}
|
|
102
|
-
const now = Math.floor(Date.now() / 1000);
|
|
103
|
-
const registered = {
|
|
104
|
-
...client,
|
|
105
|
-
client_id: `devspace-${randomUUID()}`,
|
|
106
|
-
client_id_issued_at: now,
|
|
107
|
-
token_endpoint_auth_method: client.token_endpoint_auth_method ?? "none",
|
|
108
|
-
grant_types: client.grant_types ?? ["authorization_code", "refresh_token"],
|
|
109
|
-
response_types: client.response_types ?? ["code"],
|
|
110
|
-
};
|
|
111
|
-
this.clients.set(registered.client_id, registered);
|
|
112
|
-
return registered;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
78
|
export class SingleUserOAuthProvider {
|
|
116
79
|
config;
|
|
117
80
|
clientsStore;
|
|
118
81
|
codes = new Map();
|
|
119
|
-
|
|
120
|
-
refreshTokens = new Map();
|
|
82
|
+
oauthStore;
|
|
121
83
|
resourceServerUrl;
|
|
122
|
-
constructor(config, resourceServerUrl) {
|
|
84
|
+
constructor(config, resourceServerUrl, stateDir) {
|
|
123
85
|
this.config = config;
|
|
124
86
|
this.resourceServerUrl = resourceUrlFromServerUrl(resourceServerUrl);
|
|
125
|
-
this.
|
|
87
|
+
this.oauthStore = new SqliteOAuthStore(stateDir);
|
|
88
|
+
this.clientsStore = new SqliteOAuthClientsStore(this.oauthStore, config.allowedRedirectHosts);
|
|
126
89
|
}
|
|
127
90
|
async authorize(client, params, res) {
|
|
128
91
|
if (!params.resource || !checkResourceAllowed({ requestedResource: params.resource, configuredResource: this.resourceServerUrl })) {
|
|
@@ -181,7 +144,8 @@ export class SingleUserOAuthProvider {
|
|
|
181
144
|
return this.issueTokens(client.client_id, record.params.scopes ?? this.config.scopes, record.params.resource);
|
|
182
145
|
}
|
|
183
146
|
async exchangeRefreshToken(client, refreshToken, scopes, resource) {
|
|
184
|
-
const
|
|
147
|
+
const refreshTokenHash = hashToken(refreshToken);
|
|
148
|
+
const record = this.oauthStore.getRefreshToken(refreshTokenHash);
|
|
185
149
|
if (!record || record.clientId !== client.client_id || record.expiresAt < Math.floor(Date.now() / 1000)) {
|
|
186
150
|
throw new InvalidGrantError("Invalid refresh token");
|
|
187
151
|
}
|
|
@@ -192,11 +156,10 @@ export class SingleUserOAuthProvider {
|
|
|
192
156
|
if (!requestedScopes.every((scope) => record.scopes.includes(scope))) {
|
|
193
157
|
throw new AccessDeniedError("Refresh token cannot grant requested scopes");
|
|
194
158
|
}
|
|
195
|
-
this.
|
|
196
|
-
return this.issueTokens(client.client_id, requestedScopes, resource ?? record.resource);
|
|
159
|
+
return this.issueTokens(client.client_id, requestedScopes, resource ?? (record.resource ? new URL(record.resource) : undefined), refreshTokenHash);
|
|
197
160
|
}
|
|
198
161
|
async verifyAccessToken(token) {
|
|
199
|
-
const record = this.
|
|
162
|
+
const record = this.oauthStore.getAccessToken(hashToken(token));
|
|
200
163
|
if (!record || record.expiresAt < Math.floor(Date.now() / 1000)) {
|
|
201
164
|
throw new InvalidTokenError("Invalid or expired access token");
|
|
202
165
|
}
|
|
@@ -205,13 +168,16 @@ export class SingleUserOAuthProvider {
|
|
|
205
168
|
clientId: record.clientId,
|
|
206
169
|
scopes: record.scopes,
|
|
207
170
|
expiresAt: record.expiresAt,
|
|
208
|
-
resource: record.resource,
|
|
171
|
+
resource: record.resource ? new URL(record.resource) : undefined,
|
|
209
172
|
};
|
|
210
173
|
}
|
|
211
174
|
async revokeToken(_client, request) {
|
|
212
175
|
const hashed = hashToken(request.token);
|
|
213
|
-
this.
|
|
214
|
-
this.
|
|
176
|
+
this.oauthStore.deleteAccessToken(hashed);
|
|
177
|
+
this.oauthStore.deleteRefreshToken(hashed);
|
|
178
|
+
}
|
|
179
|
+
close() {
|
|
180
|
+
this.oauthStore.close();
|
|
215
181
|
}
|
|
216
182
|
validCodeRecord(client, authorizationCode) {
|
|
217
183
|
const record = this.codes.get(authorizationCode);
|
|
@@ -220,26 +186,31 @@ export class SingleUserOAuthProvider {
|
|
|
220
186
|
}
|
|
221
187
|
return record;
|
|
222
188
|
}
|
|
223
|
-
issueTokens(clientId, scopes, resource) {
|
|
189
|
+
issueTokens(clientId, scopes, resource, consumedRefreshTokenHash) {
|
|
224
190
|
const now = Math.floor(Date.now() / 1000);
|
|
225
191
|
const accessToken = randomToken();
|
|
226
192
|
const refreshToken = randomToken();
|
|
227
193
|
const accessExpiresAt = now + this.config.accessTokenTtlSeconds;
|
|
228
194
|
const refreshExpiresAt = now + this.config.refreshTokenTtlSeconds;
|
|
229
|
-
this.
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
195
|
+
const saved = this.oauthStore.saveTokenPair({
|
|
196
|
+
accessTokenHash: hashToken(accessToken),
|
|
197
|
+
accessToken: {
|
|
198
|
+
clientId,
|
|
199
|
+
scopes,
|
|
200
|
+
expiresAt: accessExpiresAt,
|
|
201
|
+
resource: resource?.href,
|
|
202
|
+
},
|
|
203
|
+
refreshTokenHash: hashToken(refreshToken),
|
|
204
|
+
refreshToken: {
|
|
205
|
+
clientId,
|
|
206
|
+
scopes,
|
|
207
|
+
expiresAt: refreshExpiresAt,
|
|
208
|
+
resource: resource?.href,
|
|
209
|
+
},
|
|
210
|
+
}, consumedRefreshTokenHash);
|
|
211
|
+
if (!saved) {
|
|
212
|
+
throw new InvalidGrantError("Invalid refresh token");
|
|
213
|
+
}
|
|
243
214
|
return {
|
|
244
215
|
access_token: accessToken,
|
|
245
216
|
token_type: "bearer",
|