Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
567 changes: 563 additions & 4 deletions bun.lock

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions services/shell/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules

# output
out
dist
*.tgz

# code coverage
coverage
*.lcov

# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json

# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local

# caches
.eslintcache
.cache
*.tsbuildinfo

# IntelliJ based IDEs
.idea

# Finder (MacOS) folder config
.DS_Store
106 changes: 106 additions & 0 deletions services/shell/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@

Default to using Bun instead of Node.js.

- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv.

## APIs

- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.

## Testing

Use `bun test` to run tests.

```ts#index.test.ts
import { test, expect } from "bun:test";

test("hello world", () => {
expect(1).toBe(1);
});
```

## Frontend

Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.

Server:

```ts#index.ts
import index from "./index.html"

Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```

HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.

```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```

With the following `frontend.tsx`:

```tsx#frontend.tsx
import React from "react";
import { createRoot } from "react-dom/client";

// import .css files directly and it works
import './index.css';

const root = createRoot(document.body);

export default function Frontend() {
return <h1>Hello, world!</h1>;
}

root.render(<Frontend />);
```

Then, run index.ts

```sh
bun --hot ./index.ts
```

For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
15 changes: 15 additions & 0 deletions services/shell/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# shell

To install dependencies:

```bash
bun install
```

To run:

```bash
bun run
```

This project was created using `bun init` in bun v1.3.14. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
124 changes: 124 additions & 0 deletions services/shell/alchemy.run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import * as Output from "alchemy/Output";
import { adopt } from "alchemy/AdoptPolicy";

/**
* Browser-rendered SSH at https://shell.just-be.dev
*
* Architecture:
* browser -> Cloudflare edge (TLS 443)
* -> Access policy (one-time PIN)
* -> tunnel `retro` (outbound-only from the box)
* -> cloudflared -> sshd on localhost:22
*
* `retro` needs no inbound ports. Its egress allowlist is
* cloudflared's edge endpoints + DNS.
*
* Constraint worth remembering: browser-rendered SSH requires the
* Access email prefix to match the server username. `whoami` on
* retro is `just-be`, so the identity must be just-be@just-be.dev.
*/
export default Alchemy.Stack(
"Shell",
{
providers: Cloudflare.providers(),
state: Cloudflare.state(),
},
Effect.gen(function* () {
// ---- Zone -------------------------------------------------------
// Already exists in the account. Zones default to *retain* on
// removal, so `alchemy destroy` will not delete it.
const zone = yield* Cloudflare.Zone.Zone("JustBeDev", {
name: "just-be.dev",
}).pipe(adopt(true));

// ---- Tunnel -----------------------------------------------------
// `retro` already exists: created via the dashboard, connector
// running on the box under systemd with `--token`. That makes it
// remotely-managed, so the routing document below is authoritative.
//
// adopt(true) takes over the existing tunnel. A *create* in the
// plan means you'd end up with two tunnels and a 404, since the
// running connector would keep serving the old one.
const tunnel = yield* Cloudflare.Tunnel.Tunnel("Retro", {
name: "retro",
}).pipe(adopt(true));

// Ingress rules. This REPLACES the entire ordered list — anything
// already routed through `retro` must be listed here or it stops
// working. Rules match top-down, first match wins.
//
// The catch-all rule is appended automatically (Cloudflare rejects
// a config whose last rule has a hostname).
yield* Cloudflare.Tunnel.Configuration("RetroIngress", {
tunnelId: tunnel.tunnelId,
ingress: [
{ hostname: "shell.just-be.dev", service: "ssh://localhost:22" },
],
});

// ---- DNS --------------------------------------------------------
// Output.interpolate, not a plain template literal: tunnelId isn't
// known until deploy time and won't coerce to a string before then.
//
// `proxied: true` is load-bearing: traffic must enter Cloudflare's
// edge to be forwarded down the tunnel.
yield* Cloudflare.DNS.Record("ShellCname", {
zoneId: zone.zoneId,
name: "shell.just-be.dev",
type: "CNAME",
content: Output.interpolate`${tunnel.tunnelId}.cfargotunnel.com`,
proxied: true,
});

// ---- Identity ---------------------------------------------------
// One-time PIN rather than a social IdP: with Google/GitHub the
// Access identity would be that provider's address, not
// just-be@just-be.dev, and the username prefix match would fail.
// OTP mails a code to whatever address is typed, which the
// wildcard catch-all on just-be.dev delivers.
//
// adopt(true) because OTP is often already present in an account.
const otp = yield* Cloudflare.Access.IdentityProvider("OneTimePin", {
name: "One-time PIN login",
type: "onetimepin",
config: {},
}).pipe(adopt(true));

// ---- Policy -----------------------------------------------------
// Pinned to the exact address, NOT emailDomain. With a catch-all,
// a domain rule would make every address at just-be.dev a valid
// login identity.
const allowMe = yield* Cloudflare.Access.Policy("AllowJustBe", {
name: "Allow just-be",
decision: "allow",
include: [{ email: { email: "just-be@just-be.dev" } }],
});

// ---- Access application ----------------------------------------
// Only Allow/Block policies are supported for browser-rendered
// apps; Bypass and Service Auth are not.
//
// sessionDuration governs the window for starting or refreshing a
// connection, not the length of a live SSH session.
const app = yield* Cloudflare.Access.Application("Shell", {
type: "ssh",
domain: "shell.just-be.dev",
sessionDuration: "24h",
allowedIdps: [otp.identityProviderId],
autoRedirectToIdentity: true,
policies: [allowMe.policyId],
});

return {
url: "https://shell.just-be.dev",
tunnelId: tunnel.tunnelId,
// Redacted. Only needed if you ever re-provision the connector:
// cloudflared tunnel run --token <token>
token: tunnel.token,
applicationId: app.applicationId,
};
}),
);
16 changes: 16 additions & 0 deletions services/shell/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "shell",
"private": true,
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"@effect/platform-bun": ">=4.0.0-beta.97|| >=4.0.0",
"@effect/platform-node": ">=4.0.0-beta.97|| >=4.0.0",
"alchemy": "^2.0.0-beta.63",
"effect": ">=4.0.0-beta.97|| >=4.0.0"
}
}
30 changes: 30 additions & 0 deletions services/shell/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
"types": ["bun"],

// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,

// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,

// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}