├── .devcontainer └── devcontainer.json ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── myspace.sh ├── myspace.ts ├── package-lock.json ├── package.json └── tsconfig.json /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node 3 | { 4 | "name": "Node.js & TypeScript", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/typescript-node:20" 7 | 8 | // Features to add to the dev container. More info: https://containers.dev/features. 9 | // "features": {}, 10 | 11 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 12 | // "forwardPorts": [], 13 | 14 | // Use 'postCreateCommand' to run commands after the container is created. 15 | // "postCreateCommand": "yarn install", 16 | 17 | // Configure tool-specific properties. 18 | // "customizations": {}, 19 | 20 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 21 | // "remoteUser": "root" 22 | } 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | package-lock.json -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 4, 4 | "arrowParens": "avoid" 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ryan Young 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # myspace 2 | 3 | myspace is GitHub Codespaces but self-hosted. It builds development containers using the [Dev Container CLI](https://github.com/devcontainers/cli) and injects the [Visual Studio Code Server](https://code.visualstudio.com/docs/remote/vscode-server) for access via browser or through a [vscode.dev](https://vscode.dev) tunnel. 4 | 5 | It retains all of your Visual Studio Code tooling and preferences. 6 | 7 | It has no dependencies (except for the Dev Container CLI, which itself has no dependencies), so it's easy to deploy on lightweight container hosts like Flatcar Linux and Fedora CoreOS. 8 | 9 | And, most importantly, it lets you combine the convenience of Codespaces with the power of the hardware you already own. 10 | 11 | ## Build it 12 | 13 | ```sh 14 | npm run build 15 | ``` 16 | 17 | ```sh 18 | ./out/myspace help 19 | ``` 20 | 21 | ## Use it 22 | 23 | Start a project: 24 | 25 | ```sh 26 | git clone https://github.com/microsoft/vscode-remote-try-python.git ~/Projects/vscode-remote-try-python 27 | ``` 28 | 29 | Spin up the container: 30 | 31 | ```sh 32 | myspace ~/Projects/vscode-remote-try-python/ up 33 | ``` 34 | 35 | Authenticate with GitHub, create the tunnel: 36 | 37 | ```sh 38 | myspace ~/Projects/vscode-remote-try-python/ tunnel 39 | ``` 40 | 41 | Or try the standalone web server to bypass the latency of a vscode.dev tunnel: 42 | 43 | ```sh 44 | myspace ~/Projects/vscode-remote-try-python/ local 8000 45 | # Web UI is available at http://0.0.0.0:8000 46 | ``` 47 | 48 | Upon connecting with a browser using either method, the container will download code-server. Once that happens, you will be able to install any extensions defined in devcontainer.json: 49 | 50 | ```sh 51 | myspace ~/Projects/vscode-remote-try-python/ extensions 52 | ``` 53 | 54 | If you're using a tunnel and have finished working, you can disconnect it to free one of your five slots: 55 | 56 | ```sh 57 | myspace ~/Projects/vscode-remote-try-python/ unregister 58 | ``` 59 | -------------------------------------------------------------------------------- /myspace.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | BASE_DIR="$(cd "$(dirname "$0")"; pwd)" # https://stackoverflow.com/a/52293841 3 | exec "$BASE_DIR/node/node" "$BASE_DIR/myspace.js" "$@" 4 | -------------------------------------------------------------------------------- /myspace.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcess, SpawnOptions, spawn } from "child_process"; 2 | import * as fsp from "fs/promises"; 3 | import * as net from "net"; 4 | import * as path from "path"; 5 | import { parseArgs } from "util"; 6 | 7 | /** 8 | * Uniquely identifies a project folder for which development containers are 9 | * created. */ 10 | type Project = { 11 | workspaceFolder: string; 12 | }; 13 | 14 | type Persistent = { 15 | appPort: number; 16 | }; 17 | 18 | const persistPath = "~/.myspace"; 19 | 20 | async function main() { 21 | const { positionals } = parseArgs({ strict: false }); 22 | const [workspaceFolder, action, ...args] = positionals; 23 | const project: Project = { workspaceFolder }; 24 | switch (action !== undefined ? action.toLowerCase() : "") { 25 | case "up": 26 | await setUpContainer(project); 27 | break; 28 | case "tunnel": 29 | await runTunnel(project); 30 | break; 31 | case "local": 32 | const [forwardPort] = args; 33 | await runWebUi(project, forwardPort ? parseInt(forwardPort) : undefined); 34 | break; 35 | case "ext": 36 | case "extensions": 37 | await installExtensions(project); 38 | break; 39 | case "unregister": 40 | await unregisterTunnel(project); 41 | break; 42 | case "bash": 43 | await executeShell(project, "bash"); 44 | break; 45 | default: 46 | console.log("Usage: myspace (up | tunnel | local [forward_port] | extensions | unregister)"); 47 | break; 48 | } 49 | } 50 | 51 | async function setUpContainer(project: Project) { 52 | const cliConfig = await Cli.readConfiguration(project); 53 | const appPort = randomAppPort(); 54 | 55 | // Create the container. Expose the port for the web UI. 56 | const configFile = await fsp.open(cliConfig.configuration.configFilePath.path); 57 | const configText = (await fsp.readFile(configFile)).toString(); 58 | const configJson = JSON.parse(configText.replace(/\/\/.*$/gm, "")); 59 | await configFile.close(); 60 | await Cli.up(project, { ...configJson, appPort }); 61 | 62 | // Create directory for persistent storage. 63 | await waitForChild(Cli.exec(project, ["sh", "-c", `mkdir -p ${persistPath}`])); 64 | await writePersistent(project, { appPort }); 65 | 66 | // Download VS Code CLI. 67 | await waitForChild( 68 | Cli.exec(project, [ 69 | "sh", 70 | "-c", 71 | `cd ${persistPath} && curl -L https://update.code.visualstudio.com/latest/cli-linux-x64/stable | tar xz`, 72 | ]), 73 | ); 74 | 75 | // Insert custom settings JSON. 76 | const settings = cliConfig.configuration?.customizations?.vscode?.settings ?? {}; 77 | await waitForChild(Cli.exec(project, ["sh", "-c", "mkdir -p ~/.vscode-server/data/Machine/"])); 78 | const saveSettings = Cli.exec(project, ["sh", "-c", "cat >~/.vscode-server/data/Machine/settings.json"], { 79 | stdio: ["pipe", "inherit", "inherit"], 80 | }); 81 | saveSettings.stdin.write(JSON.stringify(settings)); 82 | saveSettings.stdin.end(); 83 | await waitForChild(saveSettings); 84 | } 85 | 86 | async function runTunnel(project: Project) { 87 | await waitForChild(Cli.exec(project, ["sh", "-c", `${persistPath}/code tunnel`])); 88 | } 89 | 90 | async function runWebUi(project: Project, forwardPort: number | undefined) { 91 | const { appPort } = await readPersistent(project); 92 | 93 | if (forwardPort !== undefined) { 94 | // App ports are only exposed to localhost, so spin up a simple port proxy. 95 | // https://stackoverflow.com/a/19637388 96 | net.createServer(from => { 97 | const to = net.createConnection({ port: appPort }); 98 | from.on("error", to.destroy); 99 | from.pipe(to); 100 | to.on("error", from.destroy); 101 | to.pipe(from); 102 | }).listen(forwardPort); 103 | log("**", `Forwarding on port ${forwardPort} for access away from localhost.`, "**"); 104 | } 105 | 106 | // TODO: It would be preferable to run with the connection token, but that 107 | // doesn't seem to play nice with the port proxy. 108 | await waitForChild( 109 | Cli.exec(project, [ 110 | "sh", 111 | "-c", 112 | `${persistPath}/code serve-web --host :: --port ${appPort} --without-connection-token`, 113 | ]), 114 | ); 115 | } 116 | 117 | async function installExtensions(project: Project) { 118 | const config = await Cli.readConfiguration(project); 119 | const extensions: string[] = config.configuration?.customizations?.vscode?.extensions ?? []; 120 | if (extensions.length === 0) { 121 | return; 122 | } 123 | 124 | const codeServerFind = Cli.exec(project, ["sh", "-c", "find ~/.vscode -name code-server"], { 125 | stdio: ["ignore", "pipe", "inherit"], 126 | }); 127 | const codeServerPath = (await waitForChildWithStdout(codeServerFind)).trim(); 128 | if (!codeServerPath) { 129 | throw "code-server binary not found; have you connected from a browser yet?"; 130 | } 131 | for (const ext of extensions) { 132 | await waitForChild(Cli.exec(project, [codeServerPath, "--install-extension", ext])); 133 | } 134 | } 135 | 136 | async function unregisterTunnel(project: Project) { 137 | await waitForChild(Cli.exec(project, ["sh", "-c", `${persistPath}/code tunnel unregister`])); 138 | } 139 | 140 | async function executeShell(project: Project, cmd: string, ...args: string[]) { 141 | await waitForChild(Cli.exec(project, [cmd, ...args])); 142 | } 143 | 144 | class Cli { 145 | private static readonly executableArgs: [string, string] = [ 146 | process.argv[0], 147 | path.resolve(__dirname, "@devcontainers", "cli", "dist", "spec-node", "devContainersSpecCLI.js"), 148 | ]; 149 | 150 | static async up(project: Project, overrideConfig: any) { 151 | // Need to use a shell because Node doesn't populate /dev/stdin... 152 | const child = Cli.spawnInShell(project, "up", ["--override-config", "/dev/stdin"], { 153 | stdio: ["pipe", "inherit", "inherit"], 154 | }); 155 | child.stdin.write(JSON.stringify(overrideConfig)); 156 | child.stdin.write("\n"); 157 | child.stdin.end(); 158 | await waitForChild(child); 159 | } 160 | 161 | static exec(project: Project, args: string[], options: SpawnOptions = {}) { 162 | return Cli.spawn(project, "exec", args, { 163 | stdio: ["inherit", "inherit", "inherit"], 164 | ...options, 165 | }); 166 | } 167 | 168 | static async readConfiguration(project: Project) { 169 | const child = Cli.spawn(project, "read-configuration", [], { 170 | stdio: ["ignore", "pipe", "inherit"], 171 | }); 172 | const text = await waitForChildWithStdout(child); 173 | if (!text) { 174 | throw "unable to read dev container configuration; does this folder have one?"; 175 | } 176 | return JSON.parse(text); 177 | } 178 | 179 | private static spawn(project: Project, command: string, args: string[], options: SpawnOptions) { 180 | const cliArgs = [command, ...Cli.projectArgs(project), ...args]; 181 | log("devcontainer", ...cliArgs); 182 | 183 | // Don't use fork() here; it seems to break the exec command. 184 | const [node, modulePath] = Cli.executableArgs; 185 | return spawn(node, [modulePath, ...cliArgs], options); 186 | } 187 | 188 | private static spawnInShell(project: Project, command: string, args: string[], options: SpawnOptions) { 189 | const cliArgs = [command, ...Cli.projectArgs(project), ...args]; 190 | log("devcontainer", ...cliArgs); 191 | 192 | const shell = escapeShell(...Cli.executableArgs, ...cliArgs); 193 | return spawn("sh", ["-c", "cat | " + shell], options); 194 | } 195 | 196 | private static projectArgs(project: Project) { 197 | return ["--workspace-folder", project.workspaceFolder]; 198 | } 199 | } 200 | 201 | function randomAppPort() { 202 | const [first, last] = [49152, 65535]; 203 | return Math.round((last - first) * Math.random()) + first; 204 | } 205 | 206 | async function writePersistent(project: Project, data: Persistent) { 207 | const doWrite = Cli.exec(project, ["sh", "-c", `cat >${persistPath}/persist.json`], { 208 | stdio: ["pipe", "inherit", "inherit"], 209 | }); 210 | doWrite.stdin.write(JSON.stringify(data)); 211 | doWrite.stdin.end(); 212 | await waitForChild(doWrite); 213 | } 214 | 215 | async function readPersistent(project: Project) { 216 | const doRead = Cli.exec(project, ["sh", "-c", `cat ${persistPath}/persist.json`], { 217 | stdio: ["inherit", "pipe", "inherit"], 218 | }); 219 | const text = await waitForChildWithStdout(doRead); 220 | return JSON.parse(text) as Persistent; 221 | } 222 | 223 | function escapeShell(...args: string[]) { 224 | return args.map(a => `'${a.replaceAll("'", "'\\''")}'`).join(" "); 225 | } 226 | 227 | async function waitForChildWithStdout(child: ChildProcess) { 228 | let data = ""; 229 | child.stdout.on("data", chunk => { 230 | data += chunk; 231 | }); 232 | await waitForChild(child); 233 | return data; 234 | } 235 | 236 | async function waitForChild(child: ChildProcess) { 237 | await new Promise((resolve, reject) => { 238 | child.on("close", resolve); 239 | child.on("error", reject); 240 | }); 241 | } 242 | 243 | function log(...message: any[]) { 244 | console.error("+", ...message); 245 | } 246 | 247 | main(); 248 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "myspace", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "@devcontainers/cli": "^0.52.0" 9 | }, 10 | "devDependencies": { 11 | "@types/node": "^20.8.4", 12 | "prettier": "^3.0.3" 13 | } 14 | }, 15 | "node_modules/@devcontainers/cli": { 16 | "version": "0.52.1", 17 | "resolved": "https://registry.npmjs.org/@devcontainers/cli/-/cli-0.52.1.tgz", 18 | "integrity": "sha512-sYK1cHHDVjdBIdxjMB8O668+qf0AAEJPMbeMR9ZTbUzXQBNke88vUOZ6DD9SjsqeE5es8SpWX6jV6gaItzJFyA==", 19 | "bin": { 20 | "devcontainer": "devcontainer.js" 21 | }, 22 | "engines": { 23 | "node": "^16.13.0 || >=18.0.0" 24 | } 25 | }, 26 | "node_modules/@types/node": { 27 | "version": "20.8.6", 28 | "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.6.tgz", 29 | "integrity": "sha512-eWO4K2Ji70QzKUqRy6oyJWUeB7+g2cRagT3T/nxYibYcT4y2BDL8lqolRXjTHmkZCdJfIPaY73KbJAZmcryxTQ==", 30 | "dev": true, 31 | "dependencies": { 32 | "undici-types": "~5.25.1" 33 | } 34 | }, 35 | "node_modules/prettier": { 36 | "version": "3.0.3", 37 | "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.3.tgz", 38 | "integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==", 39 | "dev": true, 40 | "bin": { 41 | "prettier": "bin/prettier.cjs" 42 | }, 43 | "engines": { 44 | "node": ">=14" 45 | }, 46 | "funding": { 47 | "url": "https://github.com/prettier/prettier?sponsor=1" 48 | } 49 | }, 50 | "node_modules/undici-types": { 51 | "version": "5.25.3", 52 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.25.3.tgz", 53 | "integrity": "sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA==", 54 | "dev": true 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@devcontainers/cli": "^0.52.0" 4 | }, 5 | "devDependencies": { 6 | "@types/node": "^20.8.4", 7 | "prettier": "^3.0.3" 8 | }, 9 | "type": "commonjs", 10 | "scripts": { 11 | "lint": "prettier --check .", 12 | "fix:prettier": "prettier --write .", 13 | "build": "tsc && cp -ar node_modules/@devcontainers/ out/ && install -Dm755 $(command -v node) out/node/node && install -m755 myspace.sh out/myspace" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "out", 4 | "module": "NodeNext" 5 | } 6 | } 7 | --------------------------------------------------------------------------------