Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
127 commits
Select commit Hold shift + click to select a range
9599cc5
More games button, Legend/Master polish, CLAUDE.md, v0.3.0 plan
alexanderilyin Sep 23, 2026
44b3f94
Stage 0: Legend and Tricky games last as long as Hard
alexanderilyin Sep 23, 2026
051e689
Accounts server: sqlite store, swappable auth, classes, homework, par…
alexanderilyin Sep 23, 2026
d4abf94
feat(accounts): sign in, homework, My classes and My kids in the game
alexanderilyin Sep 23, 2026
2668072
build: move to a Turborepo of Vite apps and shared packages
alexanderilyin Sep 23, 2026
ac4d404
feat(game-kit): points, shop, achievements, stats and Home multiplayer
alexanderilyin Sep 23, 2026
e9caee6
docs(log): record how the stage commits were made
alexanderilyin Sep 23, 2026
adbcfb2
WIP
alexanderilyin Oct 8, 2026
fdb8f12
Cleanup
alexanderilyin Oct 8, 2026
b5fc723
WIP
alexanderilyin Oct 8, 2026
e0cac2c
Update 1Password CLI version to latest
alexanderilyin Oct 15, 2026
b580a93
chore: project Claude Code settings
alexanderilyin Oct 15, 2026
2a9f397
fix(server): answer malformed URLs and cut-off bodies instead of cras…
alexanderilyin Oct 15, 2026
9557bd0
feat(server): check settings at start-up; production refuses dev sign…
alexanderilyin Oct 15, 2026
44344b0
feat(server): security headers on every response, HSTS over https, no…
alexanderilyin Oct 15, 2026
19a5648
feat(server): content security policy
alexanderilyin Oct 15, 2026
486cc58
fix(server): class and account codes from unbiased secure randomness
alexanderilyin Oct 15, 2026
e34db9d
feat(server): rate limits on requests, code guessing and dev sign-in
alexanderilyin Oct 15, 2026
d947eb2
feat(server): cap live streams per class and per address
alexanderilyin Oct 15, 2026
54abdc0
feat(server): startup, liveness and readiness endpoints; drain on SIG…
alexanderilyin Oct 15, 2026
b8e1a93
feat(server): structured logs and an audit trail for account changes
alexanderilyin Oct 15, 2026
a97d88a
perf(server): write a signed-in account only when its token changes, …
alexanderilyin Oct 15, 2026
e3d91cc
feat(auth): renew Keycloak tokens with the refresh token; 15-minute a…
alexanderilyin Oct 15, 2026
423c00f
refactor(engine): dice state as plain data, rolled with diceFromState
alexanderilyin Oct 15, 2026
2fe8a86
refactor(server): accounts await the store
alexanderilyin Oct 15, 2026
83e4d81
feat(server): memory accounts store and a contract every store must pass
alexanderilyin Oct 15, 2026
f1aa451
build(deploy): MongoDB and Valkey in the cluster, and npm run services
alexanderilyin Oct 15, 2026
5b2ace2
feat(server): keep accounts in MongoDB
alexanderilyin Oct 15, 2026
46e27f0
feat(server): copy accounts from the sqlite file into MongoDB
alexanderilyin Oct 15, 2026
14adcd8
fix(classroom): don't report a class lost on a temporary error
alexanderilyin Oct 15, 2026
6cab460
refactor(server): live classes run on a shared room runtime
alexanderilyin Oct 15, 2026
0105e9b
feat(server): keep live classes in Redis (Valkey) and share rate limits
alexanderilyin Oct 15, 2026
addae46
refactor(server)!: accounts in MongoDB or memory; sqlite only for cop…
alexanderilyin Oct 15, 2026
1b7cad4
docs: back up MongoDB instead of the sqlite file
alexanderilyin Oct 15, 2026
abbe894
feat(accounts): roles kept by Blockout, one-time invites, admins
alexanderilyin Oct 15, 2026
afbfc48
test(accounts): look for the invite codes themselves, not a pattern t…
alexanderilyin Oct 15, 2026
f53ba5f
build(keycloak): the realm's only role is admin
alexanderilyin Oct 15, 2026
f1d157e
feat(accounts): invite teachers and grown-ups, an admin page, deletin…
alexanderilyin Oct 15, 2026
083c490
docs: screenshots of My classes, a student's grown-ups and the admin …
alexanderilyin Oct 15, 2026
61f2920
build: container image, Helm chart and release workflow
alexanderilyin Oct 15, 2026
8b429dd
build(keycloak): a public address, realm settings for new and existin…
alexanderilyin Oct 15, 2026
1fd8ad4
feat(ops): metrics, alert rules, nightly encrypted backups and a rest…
alexanderilyin Oct 15, 2026
fdf6372
feat(accounts): download your data; unused accounts expire after 13 m…
alexanderilyin Oct 15, 2026
a2af5f0
fix(ui): WCAG AA contrast and names for screen readers
alexanderilyin Oct 15, 2026
9a6fd93
docs: WIP.md follows the production-readiness plan
alexanderilyin Oct 15, 2026
0ce570a
chore(release): 0.4.0
alexanderilyin Oct 15, 2026
94d5ec7
build(image): leave npm out of the runtime image
alexanderilyin Oct 15, 2026
2add7e8
docs: WIP.md holds the plan, the status, what's needed and the uncomm…
alexanderilyin Oct 15, 2026
095c9a6
docs: retake screenshots after the contrast fix
alexanderilyin Oct 15, 2026
590d73d
feat(server): a load test for live classes; no answer lost under cont…
alexanderilyin Oct 15, 2026
073b5af
docs: WIP.md: release 0.4.1 instead of re-running 0.4.0
alexanderilyin Oct 15, 2026
667f0fe
docs: retake the other games' shop and stats screenshots
alexanderilyin Oct 15, 2026
c77ddef
docs: retake the results screenshot
alexanderilyin Oct 15, 2026
1d05995
chore(release): 0.4.1
alexanderilyin Oct 16, 2026
a38ed0a
feat(chart): imagePullSecrets for an internal image
alexanderilyin Oct 16, 2026
2dd62a5
feat(chart): one chart installs everything: servers, MongoDB, Valkey,…
alexanderilyin Oct 16, 2026
cdabf65
fix(chart): MongoDB's name resolves before it's ready; Keycloak meets…
alexanderilyin Oct 16, 2026
f4f0f86
chore(release): 0.5.0
alexanderilyin Oct 17, 2026
942af12
fix(server): a held poll waits for a newer version; CI's MongoDB test…
alexanderilyin Oct 17, 2026
30cbb67
docs: WIP.md after the first public deployment (0.5.0)
alexanderilyin Oct 17, 2026
39d1683
feat(chart): Keycloak's admin console on its public address, when ask…
alexanderilyin Oct 17, 2026
d8b5432
feat(mobile): Blockout for phones in React Native (Expo Go), with sig…
alexanderilyin Oct 17, 2026
69107f8
fix(mobile): Expo SDK 57, the newest the App Store's Expo Go runs
alexanderilyin Oct 17, 2026
818fdf1
docs(mobile): Expo Go through a tunnel needs an Expo account on both …
alexanderilyin Oct 17, 2026
77d4c28
feat(mobile): the rectangle being drawn shows its size, as on the web
alexanderilyin Oct 17, 2026
a7ac441
feat(mobile): a turn on three screens that fit the phone: roll, board…
alexanderilyin Oct 17, 2026
edc4039
fix(mobile): every square in a rectangle can be seen and counted
alexanderilyin Oct 17, 2026
b7665af
feat(mobile): Help me count on a screen of its own; the answer screen…
alexanderilyin Oct 17, 2026
cd8dfe5
feat(mobile): the board screen shows the rolled dice
alexanderilyin Oct 17, 2026
f5b4cf5
feat(mobile): Help me count numbers every square (1, 2, 3, 4, 5, 6)
alexanderilyin Oct 17, 2026
a5babe6
feat(mobile): "Count again" goes back to the counting after Help me c…
alexanderilyin Oct 17, 2026
e3fe49e
fix(mobile): rectangles' outlines stand out again over the square lines
alexanderilyin Oct 17, 2026
a5fa5c1
fix(mobile): cleaner buttons, and "Play again" / "Menu" on the results
alexanderilyin Oct 17, 2026
68954fa
fix(mobile): button labels shrink to fit instead of being cut off ("P…
alexanderilyin Oct 17, 2026
c4971e4
fix(mobile): achievement banners slide in below the notch, not behind it
alexanderilyin Oct 17, 2026
9dd4fcd
feat(mobile): ✕ asks before leaving a game in progress
alexanderilyin Oct 17, 2026
8c27794
feat(mobile): the menu fits on one screen
alexanderilyin Oct 17, 2026
b7d8db3
refactor(progress): the Shop's rules shared by every client (@blockou…
alexanderilyin Oct 17, 2026
d869507
feat(mobile): the Shop, and tapping a locked choice offers it
alexanderilyin Oct 17, 2026
041e121
build(devcontainer): cap each Node process's heap at 1.5 GiB in the 4…
alexanderilyin Oct 17, 2026
74a568f
refactor(progress): achievements, fact speed, the sticker album and s…
alexanderilyin Oct 17, 2026
0e92093
feat(mobile): Achievements and Stickers, and achievements drop stickers
alexanderilyin Oct 17, 2026
35c0b41
refactor(progress): stats and the daily check-in shared (@blockout/pr…
alexanderilyin Oct 17, 2026
cbdd7a4
feat(mobile): Stats, with the phone's own game history
alexanderilyin Oct 17, 2026
d39f7c9
refactor(progress): settings shared (@blockout/progress/settings)
alexanderilyin Oct 17, 2026
730b300
feat(mobile): Settings, honoured by the game
alexanderilyin Oct 17, 2026
7f6c83d
refactor(progress): the game menu's rules shared (@blockout/progress/…
alexanderilyin Oct 17, 2026
1867180
feat(mobile): every difficulty, Expert and its timers, 3–4 players, b…
alexanderilyin Oct 17, 2026
178a47b
feat(mobile): real dice, tap to place and auto placing; every setting…
alexanderilyin Oct 17, 2026
30de626
refactor(multiplication): the web game plays by @blockout/engine/matc…
alexanderilyin Oct 17, 2026
92ea5a3
refactor(progress): practice shared (@blockout/progress/practice)
alexanderilyin Oct 17, 2026
ed6c644
feat(mobile): Practice
alexanderilyin Oct 17, 2026
abc8066
fix(mobile): the practice end screen's Times Table has its row and co…
alexanderilyin Oct 17, 2026
b4c12f6
refactor(progress): the Wardrobe's rules shared (@blockout/progress/w…
alexanderilyin Oct 17, 2026
acfeece
feat(mobile): the Wardrobe
alexanderilyin Oct 17, 2026
600f65a
refactor(progress): homework shared (@blockout/progress/homework)
alexanderilyin Oct 17, 2026
9b020be
feat(mobile): student accounts
alexanderilyin Oct 17, 2026
e34f04a
refactor(classroom): a client for anywhere, and a student's side of a…
alexanderilyin Oct 17, 2026
80dd9e2
feat(mobile): live classes as a student
alexanderilyin Oct 17, 2026
3f5f70d
refactor(progress): grown-ups' and hosting screens' rules shared
alexanderilyin Oct 17, 2026
eff6014
feat(mobile): grown-ups' screens
alexanderilyin Oct 17, 2026
0207e26
feat: today's practice
alexanderilyin Oct 17, 2026
9a77ce7
fix(progress): today's practice counts only a round on exactly its ta…
alexanderilyin Oct 17, 2026
9cf1358
feat: today's practice is in Homework, not on the menu
alexanderilyin Oct 17, 2026
f0ce90d
feat(mobile): what a game earns is listed on its results, not popped up
alexanderilyin Oct 17, 2026
e9ac337
fix: no bonus points after “Help me count”
alexanderilyin Oct 17, 2026
bf6d733
feat(progress): the Journey, one path from 1×1 to 12×12
alexanderilyin Oct 17, 2026
f3f1182
chore(release): 0.6.0
alexanderilyin Oct 17, 2026
82b9b94
feat(practice): Practice's Journey, a times table a level; menu icons…
alexanderilyin Oct 17, 2026
f1a5846
chore(release): 0.7.0
alexanderilyin Oct 17, 2026
61da5dc
feat(progress): three stages a Journey level; Practice rounds of 5, 1…
alexanderilyin Oct 17, 2026
158991f
chore(release): 0.8.0
alexanderilyin Oct 17, 2026
3b71019
feat(practice): Expert rounds on Practice's Journey; Expert practice …
alexanderilyin Oct 17, 2026
871082c
chore(release): 0.9.0
alexanderilyin Oct 17, 2026
df54646
fix(progress): one stage on the first boards; stage 3 asks for every …
alexanderilyin Oct 17, 2026
ab1f79d
chore(release): 0.9.1
alexanderilyin Oct 17, 2026
c1a1f67
feat(engine): no 1 × N rolls and no repeats while something better fits
alexanderilyin Oct 17, 2026
58f4d84
chore(release): 0.9.2
alexanderilyin Oct 17, 2026
0de05d4
feat: Auto practice; remove the corner rule, real dice, "only roll wh…
alexanderilyin Oct 17, 2026
f6f21b4
chore(release): 0.10.0
alexanderilyin Oct 17, 2026
aa956c7
feat(daily): today's practice deals three cards to pick from, and new…
alexanderilyin Oct 17, 2026
2237375
chore(release): 0.11.0
alexanderilyin Oct 17, 2026
01b4710
feat(journey): the speed-ups come with the Journey and switch on
alexanderilyin Oct 17, 2026
9d284c9
build(devcontainer): clocks and logs in Los Angeles time
alexanderilyin Oct 17, 2026
7059f73
chore: ignore .expo/
alexanderilyin Oct 17, 2026
a70b5be
refactor: every web page is one Vite app (ADR-0015)
alexanderilyin Oct 23, 2026
e40ffcd
feat(accounts): sign in through the cluster's shared Keycloak (ADR-0016)
alexanderilyin Oct 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
7 changes: 7 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(kubectl *)"
]
}
}
21 changes: 21 additions & 0 deletions .devcontainer/check.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Checks that the dev container's Playwright Chromium matches the Playwright in
// package-lock.json. Dependabot bumps the lockfile but not devcontainer.json, and
// a mismatch means every new container downloads Chromium again. Run by CI.
import { readFileSync } from 'node:fs';

const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8');

const locked = JSON.parse(read('../package-lock.json')).packages['node_modules/playwright']?.version;
const pinned = read('./devcontainer.json')
.match(/"\.\/features\/playwright-chromium":\s*\{\s*"version":\s*"([^"]+)"/)?.[1];

if (!locked || !pinned) {
console.error(`Couldn't find Playwright's version (package-lock.json: ${locked}, devcontainer.json: ${pinned}).`);
process.exit(1);
}
if (locked !== pinned) {
console.error(`package-lock.json has Playwright ${locked}, but .devcontainer/devcontainer.json installs ${pinned}.`);
console.error(`Set "./features/playwright-chromium": { "version": "${locked}" } in devcontainer.json.`);
process.exit(1);
}
console.log(`Playwright ${locked}: devcontainer.json matches package-lock.json.`);
90 changes: 73 additions & 17 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -1,24 +1,80 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
// Format: https://containers.dev/implementors/json_reference/
{
"name": "Node.js & TypeScript",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/typescript-node:5-24-trixie",
"name": "Blockout",
"image": "mcr.microsoft.com/devcontainers/typescript-node:5-26-trixie",
"hostRequirements": { "cpus": 1, "memory": "4gb", "storage": "10gb" },

// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Features: https://containers.dev/features. Local ones live in ./features:
// cloudflared for tunnels, Chromium for the Playwright browser tests,
// python3-venv for the docs site, Agent Skills for Claude Code (user-level, in
// ~/.claude/skills), and the coder CLI from the Coder deployment (always that
// server's version). The other versions are pinned so a rebuild gets the same
// tools; .devcontainer/check.mjs keeps Playwright in step with package-lock.json.
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {},
// kubectl and helm, for the services the workspace's cluster runs (like Keycloak)
"ghcr.io/devcontainers/features/kubectl-helm-minikube:1": { "minikube": "none" },
"./features/cloudflared": { "version": "2026.10.0" },
"./features/playwright-chromium": { "version": "1.63.0" },
"./features/python-venv": {},
"./features/agent-skills": {
"source": "DeepSpaceCartel/skills",
"ref": "5642bd7c6c82a1fdc5f36ddadadf02723951f296"
},
"./features/coder-cli": { "url": "https://coder.deepspacecartel.com" },
// op, for reading deploy secrets from 1Password (deploy/blockout/.env.1password)
"./features/1password-cli": { "version": "latest" },
// On every start: Blockout's clients in the shared Keycloak (keycloak-clients.json),
// with this workspace's URLs for the ports labelled [sign-in] below
"./features/keycloak-clients": {}
},

// Use 'forwardPorts' to make a list of ports inside the container available locally.
// The classroom server (npm run classroom)
"forwardPorts": [8080],
// The workspace has 4 GiB, shared by the editor, its servers and everything
// run here. Each Node process gets at most 1.5 GiB of heap, so a runaway build
// or dev server fails on its own instead of taking the whole container (every
// terminal and session) down with it. Processes that set their own limit (VS
// Code's servers) keep theirs; native memory (bundler workers) isn't covered.
// Clocks and logs in Los Angeles time (CI stays on UTC).
"containerEnv": {
"NODE_OPTIONS": "--max-old-space-size=1536",
"TZ": "America/Los_Angeles",
// The shared Keycloak, for BLOCKOUT_AUTH=keycloak npm start (docs/guides/set-up-keycloak.md)
"KEYCLOAK_ISSUER": "https://id.deepspacecartel.com/realms/dev",
"KEYCLOAK_CLIENT_ID": "blockout"
},

// Use 'postCreateCommand' to run commands after the container is created.
// Playwright + Chromium for the browser tests, and cloudflared for tunnels
"postCreateCommand": "bash .devcontainer/postCreateCommand.sh"
// The Blockout server (npm start), the Vite dev server (npm run dev), the
// docs site (mkdocs serve) and Metro for the phone app (npm run tunnel
// --workspace @blockout/mobile). [sign-in]: the port's Coder URL may sign in
// through Keycloak (the keycloak-clients Feature adds it to the client)
"forwardPorts": [8080, 5173, 8000, 8081],
"portsAttributes": {
"8080": { "label": "Blockout server [sign-in]" },
"5173": { "label": "Vite [sign-in]" },
"8000": { "label": "Docs (mkdocs serve)" },
"8081": { "label": "Metro (phone app)" },
// Throwaway servers that Playwright test scripts start
"8134-8199": { "onAutoForward": "ignore" }
},

// Configure tool-specific properties.
// "customizations": {},
// npm packages and the docs venv, which need the workspace (features can't see it)
"postCreateCommand": "bash .devcontainer/postCreateCommand.sh",

// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
"customizations": {
"vscode": {
"extensions": [
"anthropic.claude-code",
"dbaeumer.vscode-eslint",
"redhat.vscode-yaml"
],
"settings": {
// mkdocs.yml uses Material's !!python/name tags
"yaml.customTags": [
"!!python/name:material.extensions.emoji.twemoji",
"!!python/name:material.extensions.emoji.to_svg",
"!!python/name:pymdownx.superfences.fence_code_format"
]
}
}
}
}
13 changes: 13 additions & 0 deletions .devcontainer/features/1password-cli/devcontainer-feature.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"id": "1password-cli",
"version": "1.0.0",
"name": "1Password CLI",
"description": "The 1Password CLI (op) from 1Password's signed apt repository, for reading deploy secrets (like the Google OAuth client secret) instead of typing them.",
"options": {
"version": {
"type": "string",
"default": "2.40.0",
"description": "An op version in 1Password's apt repository (like 2.40.0), or latest."
}
}
}
47 changes: 47 additions & 0 deletions .devcontainer/features/1password-cli/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Installs the 1Password CLI (op) from 1Password's apt repository, following
# https://developer.1password.com/docs/cli/get-started/. apt checks the packages'
# signatures with 1Password's key, and the key itself is checked against the
# fingerprint 1Password publishes before anything trusts it.
set -euo pipefail

VERSION="${VERSION:-2.40.0}"
FINGERPRINT=3FEF9748469ADBE15DA7CA80AC2D62742012EA22
KEY_ID=AC2D62742012EA22
arch="$(dpkg --print-architecture)" # amd64 or arm64

export DEBIAN_FRONTEND=noninteractive
missing=()
for tool in curl gpg; do command -v "$tool" >/dev/null 2>&1 || missing+=("$tool"); done
if [ "${#missing[@]}" -gt 0 ]; then
apt-get update -qq
apt-get install -y -qq --no-install-recommends curl ca-certificates gnupg
fi

tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
export GNUPGHOME="$tmp/gnupg" # no keyring left behind in root's home
mkdir -m 700 "$GNUPGHOME"
curl -fsSL https://downloads.1password.com/linux/keys/1password.asc -o "$tmp/1password.asc"
got="$(gpg --batch --quiet --show-keys --with-colons "$tmp/1password.asc" | awk -F: '/^fpr:/ { print $10; exit }')"
if [ "$got" != "$FINGERPRINT" ]; then
echo "1password-cli: the signing key's fingerprint is $got, expected $FINGERPRINT." >&2
exit 1
fi

gpg --batch --quiet --dearmor < "$tmp/1password.asc" > /usr/share/keyrings/1password-archive-keyring.gpg
echo "deb [arch=${arch} signed-by=/usr/share/keyrings/1password-archive-keyring.gpg] https://downloads.1password.com/linux/debian/${arch} stable main" \
> /etc/apt/sources.list.d/1password.list
# debsig policy: the package files are signed too, not only the repository
mkdir -p "/etc/debsig/policies/${KEY_ID}" "/usr/share/debsig/keyrings/${KEY_ID}"
curl -fsSL https://downloads.1password.com/linux/debian/debsig/1password.pol -o "/etc/debsig/policies/${KEY_ID}/1password.pol"
cp /usr/share/keyrings/1password-archive-keyring.gpg "/usr/share/debsig/keyrings/${KEY_ID}/debsig.gpg"

apt-get update -qq
if [ "$VERSION" = latest ]; then
apt-get install -y -qq --no-install-recommends 1password-cli
else
apt-get install -y -qq --no-install-recommends "1password-cli=${VERSION}-*"
fi
rm -rf /var/lib/apt/lists/*
op --version
33 changes: 33 additions & 0 deletions .devcontainer/features/agent-skills/devcontainer-feature.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"id": "agent-skills",
"version": "1.1.0",
"name": "Agent Skills",
"description": "Agent Skills from a GitHub repo, installed user-level (~/.claude/skills) for the container user with the skills CLI.",
"options": {
"source": {
"type": "string",
"default": "DeepSpaceCartel/skills",
"description": "The skills repo on GitHub (owner/repo)."
},
"ref": {
"type": "string",
"default": "",
"description": "A commit, branch or tag to install from. Empty means the default branch; a commit SHA makes builds reproducible."
},
"skills": {
"type": "string",
"default": "*",
"description": "Space-separated skill names to install, or * for all of them."
},
"agent": {
"type": "string",
"default": "claude-code",
"description": "The agent to install for (the skills CLI's --agent)."
},
"cliVersion": {
"type": "string",
"default": "1.7.1",
"description": "The skills CLI (npm package skills) version to run."
}
}
}
35 changes: 35 additions & 0 deletions .devcontainer/features/agent-skills/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Installs Agent Skills user-level for the container user. Features run before the
# workspace is mounted, so they can't go in the project's .claude/skills.
# Needs Node (npx) and git, which the base image has.
set -euo pipefail

SOURCE="${SOURCE:-DeepSpaceCartel/skills}"
REF="${REF:-}"
SKILLS="${SKILLS:-*}"
AGENT="${AGENT:-claude-code}"
CLIVERSION="${CLIVERSION:-1.7.1}"
user="${_REMOTE_USER:-root}"

if ! command -v npx >/dev/null 2>&1; then
echo "agent-skills: needs Node (npx) in the base image." >&2
exit 1
fi

# The skills CLI takes a ref as a GitHub tree URL
from="$SOURCE"
[ -n "$REF" ] && from="https://github.com/${SOURCE}/tree/${REF}"

# As the container user, so the files land in (and belong to) their home
cmd="npx --yes 'skills@${CLIVERSION}' add '${from}' --global --copy --yes --agent '${AGENT}'"
set -f
for s in $SKILLS; do cmd+=" --skill '${s}'"; done
set +f

su "$user" -s /bin/bash -c "
set -euo pipefail
export DO_NOT_TRACK=1 DISABLE_TELEMETRY=1 npm_config_update_notifier=false
export npm_config_cache=\"\$(mktemp -d)\"
$cmd
rm -rf \"\$npm_config_cache\"
"
13 changes: 13 additions & 0 deletions .devcontainer/features/cloudflared/devcontainer-feature.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"id": "cloudflared",
"version": "1.1.0",
"name": "cloudflared",
"description": "Cloudflare's tunnel client, for sharing the classroom server through a quick tunnel.",
"options": {
"version": {
"type": "string",
"default": "2026.10.0",
"description": "A cloudflared release tag (like 2026.10.0), or latest."
}
}
}
23 changes: 23 additions & 0 deletions .devcontainer/features/cloudflared/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Installs cloudflared from Cloudflare's GitHub release .deb.
set -euo pipefail

VERSION="${VERSION:-2026.10.0}"
arch="$(dpkg --print-architecture)" # amd64 or arm64

if [ "$VERSION" = latest ]; then
url="https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${arch}.deb"
else
url="https://github.com/cloudflare/cloudflared/releases/download/${VERSION}/cloudflared-linux-${arch}.deb"
fi

if ! command -v curl >/dev/null 2>&1; then
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl ca-certificates
fi

tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
curl -fsSL -o "$tmp/cloudflared.deb" "$url"
dpkg -i "$tmp/cloudflared.deb"
cloudflared --version | head -n 1
13 changes: 13 additions & 0 deletions .devcontainer/features/coder-cli/devcontainer-feature.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"id": "coder-cli",
"version": "1.0.0",
"name": "Coder CLI",
"description": "The coder CLI, downloaded from a Coder deployment so it matches that server's version.",
"options": {
"url": {
"type": "string",
"default": "https://coder.deepspacecartel.com",
"description": "The Coder deployment to download the CLI from (what CODER_AGENT_URL points at in a workspace)."
}
}
}
20 changes: 20 additions & 0 deletions .devcontainer/features/coder-cli/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Installs the coder CLI from a Coder deployment's /bin endpoint, so the CLI is the
# same version as the server. CODER_AGENT_URL only exists at runtime, hence the option.
set -euo pipefail

URL="${URL:-https://coder.deepspacecartel.com}"
URL="${URL%/}"
arch="$(dpkg --print-architecture)" # amd64 or arm64

if ! command -v curl >/dev/null 2>&1; then
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl ca-certificates
rm -rf /var/lib/apt/lists/*
fi

tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
curl -fsSL -o "$tmp/coder" "${URL}/bin/coder-linux-${arch}"
install -m 0755 "$tmp/coder" /usr/local/bin/coder
coder version
30 changes: 30 additions & 0 deletions .devcontainer/features/keycloak-clients/devcontainer-feature.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"id": "keycloak-clients",
"version": "1.0.0",
"name": "Keycloak clients for Coder workspaces",
"description": "On every start, creates or updates the project's Keycloak clients (from a JSON file in the repository) and adds this Coder workspace's URLs to them, so apps on Coder's port URLs can sign in. Needs python3 and kubectl, and the platform's registrar Secret (keycloak/workspace-registrar).",
"options": {
"file": {
"type": "string",
"default": ".devcontainer/keycloak-clients.json",
"description": "The project's clients, relative to the repository root."
},
"marker": {
"type": "string",
"default": "sign-in",
"description": "A forwarded port's URL is added when its portsAttributes label ends in [<marker>]."
},
"domain": {
"type": "string",
"default": "",
"description": "Coder's wildcard domain (the part after <port>--<agent>--<workspace>--<owner>.). Empty: the host of CODER_AGENT_URL."
},
"secret": {
"type": "string",
"default": "keycloak/workspace-registrar",
"description": "The registrar's Secret, as <namespace>/<name>."
}
},
"postStartCommand": "keycloak-clients sync",
"installsAfter": ["ghcr.io/devcontainers/features/kubectl-helm-minikube"]
}
27 changes: 27 additions & 0 deletions .devcontainer/features/keycloak-clients/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Installs keycloak-clients (a Python 3 script, standard library only) and
# writes the Feature's options as its defaults: options only reach this script,
# at build time, so the command reads them back from a file on every start.
# kubectl comes from another Feature (kubectl-helm-minikube).
set -euo pipefail

here="$(cd "$(dirname "$0")" && pwd)"

if ! command -v python3 >/dev/null 2>&1; then
if command -v apt-get >/dev/null 2>&1; then
apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/*
else
echo "keycloak-clients: needs python3 in the base image." >&2
exit 1
fi
fi
command -v kubectl >/dev/null 2>&1 || echo "keycloak-clients: no kubectl yet (add the kubectl-helm-minikube Feature)." >&2

install -m 0755 "$here/keycloak-clients" /usr/local/bin/keycloak-clients

# (as JSON, so values are never run by a shell)
python3 - "${FILE:-}" "${MARKER:-}" "${DOMAIN:-}" "${SECRET:-}" > /usr/local/etc/keycloak-clients.json <<'EOF'
import json, sys
keys = ['KEYCLOAK_CLIENTS_FILE', 'KEYCLOAK_CLIENTS_MARKER', 'KEYCLOAK_CLIENTS_DOMAIN', 'KEYCLOAK_CLIENTS_SECRET']
print(json.dumps(dict(zip(keys, sys.argv[1:])), indent=2))
EOF
Loading
Loading