@waishnav/devspace 1.0.0 → 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.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +104 -36
  3. package/dist/cli.js +5 -2
  4. package/dist/config.js +16 -7
  5. package/dist/db/client.js +10 -3
  6. package/dist/db/migrations.js +123 -0
  7. package/dist/db/schema.js +24 -1
  8. package/dist/oauth-provider.js +35 -64
  9. package/dist/oauth-store.js +138 -0
  10. package/dist/review-checkpoints.js +4 -4
  11. package/dist/server.js +38 -19
  12. package/dist/ui/.vite/manifest.json +281 -281
  13. package/dist/ui/assets/{angular-html-Di31D1W0.js → angular-html-Dj4LysSE.js} +1 -1
  14. package/dist/ui/assets/{angular-ts-BKuSFrBS.js → angular-ts-CxuC_FFh.js} +1 -1
  15. package/dist/ui/assets/{apl-DgqYbYoh.js → apl-RwuvNFFo.js} +1 -1
  16. package/dist/ui/assets/{astro-B19gi6Os.js → astro-ncoaUbsO.js} +1 -1
  17. package/dist/ui/assets/{blade-BKHjA4oS.js → blade-Dw8-Lzux.js} +1 -1
  18. package/dist/ui/assets/{c-FjyGimBn.js → c-Dpbbo_f2.js} +1 -1
  19. package/dist/ui/assets/{cobol-DlmtgjjX.js → cobol-4ddX3THy.js} +1 -1
  20. package/dist/ui/assets/{coffee-BlTKLwcf.js → coffee-Bn-a5KqK.js} +1 -1
  21. package/dist/ui/assets/{cpp-DtVUwoaa.js → cpp-DoRrlM_T.js} +1 -1
  22. package/dist/ui/assets/{crystal-C0TLGTHr.js → crystal-DiZfCxn-.js} +1 -1
  23. package/dist/ui/assets/{css-FssmIjiX.js → css-DiQoSjDH.js} +1 -1
  24. package/dist/ui/assets/{edge-_m-12TBz.js → edge-wEhCUoEt.js} +1 -1
  25. package/dist/ui/assets/{elixir-RKJED3b7.js → elixir-C3obhm0H.js} +1 -1
  26. package/dist/ui/assets/{elm-CzMSD9Oq.js → elm-DyTBfh1L.js} +1 -1
  27. package/dist/ui/assets/{erb-Cqpp-3vK.js → erb-f4TnXRuY.js} +1 -1
  28. package/dist/ui/assets/{git-rebase-DLS2r2oj.js → git-rebase-vhZbWNfp.js} +1 -1
  29. package/dist/ui/assets/{glimmer-js-B1t-JGK1.js → glimmer-js-BG7puL6Y.js} +1 -1
  30. package/dist/ui/assets/{glimmer-ts-CazygCNf.js → glimmer-ts-CNCfLLL1.js} +1 -1
  31. package/dist/ui/assets/{glsl-gBg2vKZ-.js → glsl-DhT76AL5.js} +1 -1
  32. package/dist/ui/assets/{graphql-C8u6MNtl.js → graphql-CJX08w8y.js} +1 -1
  33. package/dist/ui/assets/{hack-CgKuqejW.js → hack-DY4NGCCL.js} +1 -1
  34. package/dist/ui/assets/{haml-I5WU4_Rg.js → haml-DwSkmVcG.js} +1 -1
  35. package/dist/ui/assets/{handlebars-DGf6O3Q6.js → handlebars-CQ_1dEmi.js} +1 -1
  36. package/dist/ui/assets/{heavy-payload-Bp6srDbj.js → heavy-payload-2rGBiitA.js} +1 -1
  37. package/dist/ui/assets/{html-BrIuweS5.js → html-B_e3Njif.js} +1 -1
  38. package/dist/ui/assets/{html-derivative-CflP_1S6.js → html-derivative-_nHZ_Bk-.js} +1 -1
  39. package/dist/ui/assets/{http-qgGkv9Ds.js → http-CfcvPIru.js} +1 -1
  40. package/dist/ui/assets/{hurl-CantyQ5z.js → hurl-9vWDQIMO.js} +1 -1
  41. package/dist/ui/assets/{java-DqStxnBY.js → java-C4S5r6L5.js} +1 -1
  42. package/dist/ui/assets/{javascript-CJ1ZUMNM.js → javascript-BRwqbMMC.js} +1 -1
  43. package/dist/ui/assets/{jinja-Ta-y-8tW.js → jinja-DHxzMMc5.js} +1 -1
  44. package/dist/ui/assets/{jison-DZStAtdf.js → jison-B1DqeWXb.js} +1 -1
  45. package/dist/ui/assets/{json-Bfn_pgvS.js → json-DcFTkEdd.js} +1 -1
  46. package/dist/ui/assets/{jsx-CFi5D1JI.js → jsx-DgfmYa9f.js} +1 -1
  47. package/dist/ui/assets/{julia-CmuPb_FV.js → julia-3M8SJ8iF.js} +1 -1
  48. package/dist/ui/assets/{just-xoDaYyy4.js → just-UyH7_DNz.js} +1 -1
  49. package/dist/ui/assets/{latex-C7jbQT4s.js → latex-wUbha6EX.js} +1 -1
  50. package/dist/ui/assets/{liquid-DyVckOju.js → liquid-DqNTB9kN.js} +1 -1
  51. package/dist/ui/assets/{lua-CQGEo-kr.js → lua-DrEQ1QTW.js} +1 -1
  52. package/dist/ui/assets/{marko-CF3FIlcL.js → marko-B3GJw9Md.js} +1 -1
  53. package/dist/ui/assets/{mdc-DJfBh8KU.js → mdc-BnThk82m.js} +1 -1
  54. package/dist/ui/assets/{nginx-CmN5T-d3.js → nginx-B5IeSsOX.js} +1 -1
  55. package/dist/ui/assets/{nim-a_705mqZ.js → nim-BJraatnS.js} +1 -1
  56. package/dist/ui/assets/{perl-BFxFi55O.js → perl-BAhrR4gt.js} +1 -1
  57. package/dist/ui/assets/{php-C9Uf0Vo_.js → php-BKRCk_wX.js} +1 -1
  58. package/dist/ui/assets/{pug-DQIrtlYh.js → pug-B8gmSV7z.js} +1 -1
  59. package/dist/ui/assets/{qml-CpI_Asuw.js → qml-DKrxIjHR.js} +1 -1
  60. package/dist/ui/assets/{r-hYZdyIMH.js → r-YASBZvTH.js} +1 -1
  61. package/dist/ui/assets/{razor-CbXx350T.js → razor-P-XFZqh6.js} +1 -1
  62. package/dist/ui/assets/{regexp-BdfO5R2u.js → regexp-CCpiH6nO.js} +1 -1
  63. package/dist/ui/assets/{review-payload-CC-3-Lb9.js → review-payload-BLYwg6t8.js} +1 -1
  64. package/dist/ui/assets/{rst-CP6mAm3Y.js → rst-CFAzckbn.js} +1 -1
  65. package/dist/ui/assets/{ruby-8tjXzrRr.js → ruby-Dyp_Rx-S.js} +1 -1
  66. package/dist/ui/assets/{sas-rqWHY37U.js → sas-YkWZIXxT.js} +1 -1
  67. package/dist/ui/assets/{scss-aGYgGui9.js → scss-Cf89idGl.js} +1 -1
  68. package/dist/ui/assets/{shellscript-CYr5JWBI.js → shellscript-BFMb52q8.js} +1 -1
  69. package/dist/ui/assets/{shellsession-CO3ljRR7.js → shellsession-DuthW-5v.js} +1 -1
  70. package/dist/ui/assets/{soy-C4_rLmHV.js → soy-BohVVlJU.js} +1 -1
  71. package/dist/ui/assets/{sql-2jcbKHJ9.js → sql-CnK9R2Gt.js} +1 -1
  72. package/dist/ui/assets/{stata-Dbr0lM_v.js → stata-D-sV4Vbr.js} +1 -1
  73. package/dist/ui/assets/{surrealql-Cp0eMxMo.js → surrealql-ChAy0gHv.js} +1 -1
  74. package/dist/ui/assets/{svelte-BfsT9lcS.js → svelte-3HBVqG-H.js} +1 -1
  75. package/dist/ui/assets/{templ-BBOwNqeW.js → templ-BixlV4Cp.js} +1 -1
  76. package/dist/ui/assets/{tex-CPmn_RsX.js → tex-DWoJKHvD.js} +1 -1
  77. package/dist/ui/assets/{ts-tags-DMILlUz_.js → ts-tags-DLA8eH13.js} +1 -1
  78. package/dist/ui/assets/{tsx-CUP2Lfiz.js → tsx-BYWa5JUz.js} +1 -1
  79. package/dist/ui/assets/{twig-DEZ_7SDX.js → twig-CnY65vAo.js} +1 -1
  80. package/dist/ui/assets/{typescript-DhaMQEhQ.js → typescript-ByInUsro.js} +1 -1
  81. package/dist/ui/assets/{useFileDiffInstance-Bo6irrE6.js → useFileDiffInstance-Dg0pWcJV.js} +3 -3
  82. package/dist/ui/assets/{vue-CAaS6wtt.js → vue-Qngt2rkz.js} +1 -1
  83. package/dist/ui/assets/{vue-html-D3Etensv.js → vue-html-DDue9upr.js} +1 -1
  84. package/dist/ui/assets/{vue-vine-DNRKj0Lh.js → vue-vine-DBcAKsA6.js} +1 -1
  85. package/dist/ui/assets/workspace-app-DGD9R89D.css +1 -0
  86. package/dist/ui/assets/{workspace-app-BLeU1qC_.js → workspace-app-DuzpU4Hf.js} +6 -6
  87. package/dist/ui/assets/{xml-M-X6I2aP.js → xml-CRLR5jiL.js} +1 -1
  88. package/dist/ui/assets/{xsl-CR0ZlrL7.js → xsl-CmcwyXbm.js} +1 -1
  89. package/dist/ui/assets/{yaml-pI4xDDgj.js → yaml-Dpe6Eg-k.js} +1 -1
  90. package/dist/ui/workspace-app.html +2 -2
  91. package/dist/workspace-store.js +0 -50
  92. package/docs/chatgpt-coding-workflow.md +22 -22
  93. package/docs/configuration.md +13 -10
  94. package/docs/gotchas.md +5 -4
  95. package/package.json +3 -5
  96. package/scripts/dev-server.mjs +120 -0
  97. package/dist/ui/assets/workspace-app-8--oTbCd.css +0 -1
  98. package/scripts/build-release.mjs +0 -89
  99. package/scripts/ensure-native-deps.mjs +0 -29
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Waishnav
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
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
- [![DevSpace connected to ChatGPT](docs/assets/devspace-screenshot.png)](docs/assets/devspace-screenshot.png)
17
+ [![DevSpace connected to ChatGPT](https://raw.githubusercontent.com/Waishnav/devspace/main/docs/assets/devspace-screenshot.png)](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
- ## Quick Start
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
- Then configure your MCP client with:
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
- ## What ChatGPT Gets
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
- - searching files and listing directories
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 review cards for aggregate diffs
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 `npx @waishnav/devspace serve`.
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
- npx @waishnav/devspace doctor
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
 
@@ -132,15 +206,9 @@ For working on DevSpace itself:
132
206
 
133
207
  ```bash
134
208
  npm install --include=dev
209
+ npm run dev
135
210
  npm run typecheck
136
211
  npm test
137
212
  npm run build
138
213
  npm run start
139
214
  ```
140
-
141
- For long-running local server processes, build an immutable release copy:
142
-
143
- ```bash
144
- npm run release:build
145
- npm run release:start
146
- ```
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(() => process.exit(0));
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/config.js CHANGED
@@ -45,7 +45,16 @@ function parseBoolean(value) {
45
45
  return ["1", "true", "yes", "on"].includes(value?.toLowerCase() ?? "");
46
46
  }
47
47
  function parseMinimalTools(env) {
48
- return env.DEVSPACE_TOOL_MODE === "minimal" || parseBoolean(env.DEVSPACE_MINIMAL_TOOLS);
48
+ if (env.DEVSPACE_TOOL_MODE === "minimal")
49
+ return true;
50
+ if (env.DEVSPACE_TOOL_MODE === "full")
51
+ return false;
52
+ if (env.DEVSPACE_TOOL_MODE) {
53
+ throw new Error(`Invalid DEVSPACE_TOOL_MODE: ${env.DEVSPACE_TOOL_MODE}`);
54
+ }
55
+ if (env.DEVSPACE_MINIMAL_TOOLS !== undefined)
56
+ return parseBoolean(env.DEVSPACE_MINIMAL_TOOLS);
57
+ return true;
49
58
  }
50
59
  function parseLogLevel(value) {
51
60
  if (!value || value === "info")
@@ -85,10 +94,10 @@ function parsePositiveInteger(value, fallback, name) {
85
94
  return parsed;
86
95
  }
87
96
  function parseToolNaming(value) {
88
- if (!value || value === "legacy")
89
- return "legacy";
90
- if (value === "short")
97
+ if (!value || value === "short")
91
98
  return "short";
99
+ if (value === "legacy")
100
+ return "legacy";
92
101
  throw new Error(`Invalid DEVSPACE_TOOL_NAMING: ${value}`);
93
102
  }
94
103
  function parseLoggingConfig(env) {
@@ -103,9 +112,9 @@ function parseLoggingConfig(env) {
103
112
  };
104
113
  }
105
114
  function parseWidgetMode(value) {
106
- if (!value || value === "changes")
107
- return "changes";
108
- if (value === "off" || value === "full")
115
+ if (!value || value === "full")
116
+ return "full";
117
+ if (value === "off" || value === "changes")
109
118
  return value;
110
119
  throw new Error(`Invalid DEVSPACE_WIDGETS: ${value}`);
111
120
  }
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
- const sqlite = new Database(databasePath(stateDir));
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
+ });
@@ -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
- accessTokens = new Map();
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.clientsStore = new InMemoryOAuthClientsStore(config.allowedRedirectHosts);
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 record = this.refreshTokens.get(hashToken(refreshToken));
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.refreshTokens.delete(hashToken(refreshToken));
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.accessTokens.get(hashToken(token));
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.accessTokens.delete(hashed);
214
- this.refreshTokens.delete(hashed);
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.accessTokens.set(hashToken(accessToken), {
230
- token: accessToken,
231
- clientId,
232
- scopes,
233
- expiresAt: accessExpiresAt,
234
- resource,
235
- });
236
- this.refreshTokens.set(hashToken(refreshToken), {
237
- token: refreshToken,
238
- clientId,
239
- scopes,
240
- expiresAt: refreshExpiresAt,
241
- resource,
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",