Skip to content

Commit 587c12f

Browse files
authored
Add env:// SecretRef and Dockerfile for cloud deploy (#43)
* feat: add env:// SecretRef and Dockerfile for cloud deploy SecretRef gains a second shape — { source: env, var: NAME } — that reads process.env directly with no spawn. The existing { source: exec } shape is unchanged. This makes cloud platforms (Railway, Fly, Docker, k8s) work end-to-end, since op:// and keychain:// can't run there. Also adds a multi-stage node:20-alpine Dockerfile with a /data volume for state, and a README "Deploy in the cloud" section covering Docker, Railway, Fly, and which platforms to avoid. * chore: address simplify findings - Drop redundant `npm cache clean --force` after `npm ci` (no-op). - Add `*.log`, `.env.*`, `coverage`, `.vitest-cache` to .dockerignore. - Add isSecretRef test for bare `var:` (undefined) in YAML. * chore: address review findings - Add _detectPrevBackend test for env-source SecretRef returning "unknown". - Extract ExecSecretRef / EnvSecretRef named types so the public signature of _resolveSecretRefWithOverrides reads ExecSecretRef instead of Extract<SecretRef, { source: "exec" }>. - Switch README docker run example to --env-file to keep secrets out of shell history and ps output. * docs(readme): note that openai-codex provider is unsupported in cloud deploy
1 parent c6fcf6b commit 587c12f

11 files changed

Lines changed: 346 additions & 24 deletions

File tree

.dockerignore

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Build artifacts — rebuilt inside the image
2+
node_modules
3+
dist
4+
*.tgz
5+
6+
# VCS / IDE
7+
.git
8+
.gitignore
9+
.idea
10+
.vscode
11+
.DS_Store
12+
13+
# Tests, docs, scripts not needed at runtime
14+
tests
15+
.tests
16+
docs
17+
.agents
18+
.claude
19+
.github
20+
scripts
21+
22+
# Local secrets / configs
23+
.env
24+
.env.*
25+
.env.example
26+
CLAUDE.local.md
27+
28+
# Local artifacts
29+
*.log
30+
coverage
31+
.vitest-cache
32+
33+
# Repo metadata not needed in image
34+
README.md
35+
CONTRIBUTING.md
36+
LICENSE

Dockerfile

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# syntax=docker/dockerfile:1.7
2+
3+
# ── builder ────────────────────────────────────────────────────────────────
4+
FROM node:20-alpine AS builder
5+
WORKDIR /app
6+
7+
COPY package.json package-lock.json ./
8+
RUN npm ci
9+
10+
COPY tsconfig.json ./
11+
COPY src ./src
12+
RUN npm run build
13+
14+
# ── runtime ────────────────────────────────────────────────────────────────
15+
FROM node:20-alpine AS runtime
16+
WORKDIR /app
17+
18+
ENV NODE_ENV=production
19+
ENV CYCLING_COACH_HOME=/data
20+
21+
COPY package.json package-lock.json ./
22+
RUN npm ci --omit=dev
23+
24+
COPY --from=builder /app/dist ./dist
25+
COPY SOUL.md ./SOUL.md
26+
COPY skills ./skills
27+
28+
RUN addgroup -S app && adduser -S -G app app \
29+
&& mkdir -p /data \
30+
&& chown -R app:app /data /app
31+
32+
USER app
33+
VOLUME ["/data"]
34+
35+
CMD ["node", "dist/index.js"]

README.md

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,26 +163,42 @@ Env vars take precedence over YAML.
163163

164164
## Storing secrets outside config.yaml
165165

166-
If you don't want API keys to live as plaintext in `~/.cycling-coach/config.yaml`, any secret field (`llm.api_key`, `intervals.api_key`, `telegram.bot_token`) can be replaced with a **SecretRef** — a reference to an external command that prints the secret to stdout. Cycling Coach runs the command at startup, reads stdout, and uses the value.
166+
If you don't want API keys to live as plaintext in `~/.cycling-coach/config.yaml`, any secret field (`llm.api_key`, `intervals.api_key`, `telegram.bot_token`) can be replaced with a **SecretRef**. Two shapes are supported:
167+
168+
- **`source: exec`** — runs an external command (1Password CLI, Vault, `age`, etc.) and reads its stdout. Best for local desktop use with a password manager.
169+
- **`source: env`** — reads a process env var directly (no spawn). Best for cloud / Docker / Railway / Kubernetes where the platform injects secrets as env vars.
167170

168171
```yaml
172+
# exec — local with 1Password
169173
llm:
170174
provider: anthropic
171175
model: claude-sonnet-4-6
172176
api_key:
173177
source: exec
174178
command: op
175179
args: [read, "op://Personal/Anthropic/credential"]
180+
181+
# env — cloud / Docker
182+
llm:
183+
provider: anthropic
184+
model: claude-sonnet-4-6
185+
api_key:
186+
source: env
187+
var: ANTHROPIC_API_KEY
176188
```
177189

178-
**Precedence**: env var > SecretRef > plain YAML. Setting `ANTHROPIC_API_KEY` in your shell still wins — useful for debugging a vault issue without touching YAML.
190+
**Precedence**: env var (the legacy `ANTHROPIC_API_KEY` / `INTERVALS_API_KEY` / `TELEGRAM_BOT_TOKEN` keys) > SecretRef > plain YAML. Setting `ANTHROPIC_API_KEY` in your shell still wins — useful for debugging a vault issue without touching YAML.
179191

180-
**Requirements**:
192+
**`exec` requirements**:
181193
- The `command` must print **only the secret** to stdout. JSON blobs, labels, or extra output will be stored verbatim and downstream APIs will reject them.
182194
- A single trailing `\n` or `\r\n` is trimmed; all other whitespace is preserved.
183195
- Empty output, non-zero exit, a 30s timeout, or output over 64KB is a fatal startup error with a clear stderr message.
184196
- `shell: false` — `command` and `args` are passed directly to the OS. `~`, `$HOME`, globs, and shell operators are **not** expanded. Use absolute paths.
185197

198+
**`env` requirements**:
199+
- `var` must name an env var that is set and non-empty at startup. Unset → `ENOENT`; empty string → `EMPTY`. Both are fatal startup errors.
200+
- The value is used verbatim — no trimming, no shell interpretation. If your platform's secret manager appends a newline, set the env var without it.
201+
186202
### Using the setup wizard with a password manager
187203

188204
If you have the [1Password CLI (`op`)](https://developer.1password.com/docs/cli/) or you're on macOS, `cycling-coach setup` can create the backend items for you — no YAML hand-editing, no manual `op item create` / `security add-generic-password` calls.
@@ -268,6 +284,72 @@ telegram:
268284

269285
SecretRef support was added in a recent release. Downgrading cycling-coach while `config.yaml` contains SecretRef blocks will fail at startup — older versions treat non-string secret values as malformed. Keep plain strings or env vars if you need to roll back.
270286

287+
## Deploy in the cloud
288+
289+
Cycling Coach is a single Node process holding a long-polling connection to Telegram, with state on a local volume at `$CYCLING_COACH_HOME` (defaults to `~/.cycling-coach`). It needs:
290+
291+
- An always-on container or VM (no scale-to-zero — long-polling stops when the process stops).
292+
- A persistent volume mounted at `/data` (or wherever you point `CYCLING_COACH_HOME`).
293+
- Secrets injected as env vars, referenced from `config.yaml` via `source: env` (see [Storing secrets outside config.yaml](#storing-secrets-outside-configyaml)).
294+
- One instance only — sessions are sharded by Telegram chat ID on local disk; do not enable autoscaling.
295+
- A BYOK provider (`anthropic` / `openai` / `google`). `LLM_PROVIDER=openai-codex` is **not supported in containers** — it depends on an interactive OAuth flow that writes to the data dir, which can't run headless. Use Anthropic, OpenAI, or Google in the cloud.
296+
297+
### Docker
298+
299+
A `Dockerfile` is included. Build and run locally:
300+
301+
```bash
302+
docker build -t cycling-coach .
303+
304+
docker run -d --name cycling-coach \
305+
-v cycling-coach-data:/data \
306+
--env-file .env \
307+
cycling-coach
308+
```
309+
310+
Use `--env-file` rather than inline `-e KEY=value` flags — inline values land in shell history and are visible to other users via `ps`. Your `.env` should contain `ANTHROPIC_API_KEY` (or `OPENAI_API_KEY` / `GOOGLE_GENERATIVE_AI_API_KEY`), `INTERVALS_API_KEY`, `INTERVALS_ATHLETE_ID`, and `TELEGRAM_BOT_TOKEN`.
311+
312+
The image runs as a non-root user, mounts `/data` for state, and reads `/data/config.yaml` if present. With the `-e` env vars above, no `config.yaml` is required — the legacy env-var fallback (`ANTHROPIC_API_KEY` etc.) covers the three secret fields.
313+
314+
For finer control (custom model, idle timeout, etc.) drop a `config.yaml` into the volume:
315+
316+
```yaml
317+
# /data/config.yaml
318+
llm:
319+
provider: anthropic
320+
model: claude-sonnet-4-6
321+
api_key:
322+
source: env
323+
var: ANTHROPIC_API_KEY
324+
intervals:
325+
api_key:
326+
source: env
327+
var: INTERVALS_API_KEY
328+
athlete_id: "i12345"
329+
telegram:
330+
bot_token:
331+
source: env
332+
var: TELEGRAM_BOT_TOKEN
333+
```
334+
335+
### Railway
336+
337+
Railway autodetects the `Dockerfile` and deploys with no extra config files. Steps:
338+
339+
1. Push this repo to GitHub.
340+
2. In Railway, **New Project → Deploy from GitHub repo**.
341+
3. Add a **Volume** mounted at `/data`.
342+
4. Add the env vars in **Variables**: `ANTHROPIC_API_KEY` (or `OPENAI_API_KEY` / `GOOGLE_GENERATIVE_AI_API_KEY`), `INTERVALS_API_KEY`, `INTERVALS_ATHLETE_ID`, `TELEGRAM_BOT_TOKEN`.
343+
5. Deploy. Railway shows logs; `Telegram bot started` confirms it's polling.
344+
345+
Railway does not scale services with attached volumes to zero, so the bot stays connected.
346+
347+
### Other platforms
348+
349+
- **Fly.io** — works with the same Dockerfile. In `fly.toml` set `auto_stop_machines = false` and `min_machines_running = 1`, otherwise Fly will stop the machine on idle inbound HTTP and the bot stops polling. Mount a 1 GB volume at `/data`.
350+
- **VPS (Hetzner, DigitalOcean, Lightsail, Oracle Free)** — `docker run` as above, or use systemd. Mount a host directory at `/data` for state.
351+
- **Avoid** scale-to-zero serverless (Lambda, Cloud Run with `min=0`, Cloudflare Workers, Vercel) and platforms with ephemeral filesystems (Heroku) — both break this app.
352+
271353
## Development
272354

273355
```bash

src/config.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ function readSecretField(value: unknown, path: SecretFieldPath): string | Secret
120120
if (isSecretRef(value)) return value;
121121
throw new SecretResolutionError(
122122
"INVALID_REF",
123-
`Config field ${path} is not a valid SecretRef. Expected a string, or { source: "exec", command: string, args?: string[] }.`,
123+
`Config field ${path} is not a valid SecretRef. Expected a string, { source: "exec", command: string, args?: string[] }, or { source: "env", var: string }.`,
124124
);
125125
}
126126

@@ -255,7 +255,8 @@ export async function resolveConfigSecrets(cfg: Config): Promise<Config> {
255255
for (const [path, ref] of pending) {
256256
const value = await resolveSecretRef(ref);
257257
assignFieldByPath(next, path, value);
258-
console.log(`Resolved secret: ${path} (exec: ${ref.command})`);
258+
const desc = ref.source === "env" ? `env: ${ref.var}` : `exec: ${ref.command}`;
259+
console.log(`Resolved secret: ${path} (${desc})`);
259260
}
260261

261262
return next;

src/secrets/resolve.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,35 @@
11
import { spawn } from "node:child_process";
2-
import { SecretRef, SecretResolutionError } from "./types.js";
2+
import { ExecSecretRef, SecretRef, SecretResolutionError } from "./types.js";
33

44
const DEFAULT_TIMEOUT_MS = 30_000;
55
const DEFAULT_MAX_BYTES = 64 * 1024;
66

77
export async function resolveSecretRef(ref: SecretRef): Promise<string> {
8+
if (ref.source === "env") {
9+
return resolveEnvRef(ref.var);
10+
}
811
return await _resolveSecretRefWithOverrides(ref, {});
912
}
1013

14+
function resolveEnvRef(name: string): string {
15+
const value = process.env[name];
16+
if (value === undefined) {
17+
throw new SecretResolutionError(
18+
"ENOENT",
19+
`Secret env var '${name}' is not set.`,
20+
);
21+
}
22+
if (value === "") {
23+
throw new SecretResolutionError(
24+
"EMPTY",
25+
`Secret env var '${name}' is set but empty.`,
26+
);
27+
}
28+
return value;
29+
}
30+
1131
export async function _resolveSecretRefWithOverrides(
12-
ref: SecretRef,
32+
ref: ExecSecretRef,
1333
overrides: { timeoutMs?: number; maxBytes?: number },
1434
): Promise<string> {
1535
const timeoutMs = overrides.timeoutMs ?? DEFAULT_TIMEOUT_MS;

src/secrets/types.ts

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
export type SecretRef = {
2-
source: "exec";
3-
command: string;
4-
args?: string[];
5-
};
1+
export type ExecSecretRef = { source: "exec"; command: string; args?: string[] };
2+
export type EnvSecretRef = { source: "env"; var: string };
3+
export type SecretRef = ExecSecretRef | EnvSecretRef;
64

75
export type SecretResolutionErrorCode =
86
| "ENOENT"
@@ -21,23 +19,36 @@ export class SecretResolutionError extends Error {
2119
}
2220
}
2321

24-
const ALLOWED_KEYS = new Set(["source", "command", "args"]);
22+
const ALLOWED_KEYS_EXEC = new Set(["source", "command", "args"]);
23+
const ALLOWED_KEYS_ENV = new Set(["source", "var"]);
2524

2625
export function isSecretRef(value: unknown): value is SecretRef {
2726
if (typeof value !== "object" || value === null || Array.isArray(value)) {
2827
return false;
2928
}
3029
const obj = value as Record<string, unknown>;
31-
for (const key of Object.keys(obj)) {
32-
if (!ALLOWED_KEYS.has(key)) return false;
30+
31+
if (obj.source === "exec") {
32+
for (const key of Object.keys(obj)) {
33+
if (!ALLOWED_KEYS_EXEC.has(key)) return false;
34+
}
35+
if (typeof obj.command !== "string" || obj.command.length === 0) return false;
36+
if (obj.args !== undefined) {
37+
if (!Array.isArray(obj.args)) return false;
38+
for (const a of obj.args) {
39+
if (typeof a !== "string") return false;
40+
}
41+
}
42+
return true;
3343
}
34-
if (obj.source !== "exec") return false;
35-
if (typeof obj.command !== "string" || obj.command.length === 0) return false;
36-
if (obj.args !== undefined) {
37-
if (!Array.isArray(obj.args)) return false;
38-
for (const a of obj.args) {
39-
if (typeof a !== "string") return false;
44+
45+
if (obj.source === "env") {
46+
for (const key of Object.keys(obj)) {
47+
if (!ALLOWED_KEYS_ENV.has(key)) return false;
4048
}
49+
if (typeof obj.var !== "string" || obj.var.length === 0) return false;
50+
return true;
4151
}
42-
return true;
52+
53+
return false;
4354
}

src/setup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ const FIELD_KEYCHAIN_ACCOUNT: Record<SecretFieldPath, string> = {
116116
export function _detectPrevBackend(value: unknown): BackendChoice | "unknown" {
117117
if (typeof value === "string") return "plain";
118118
if (isSecretRef(value)) {
119+
if (value.source === "env") return "unknown";
119120
const cmd = value.command;
120121
if (cmd === "op" || cmd.endsWith("/op")) return "op";
121122
if (cmd === "/usr/bin/security" || cmd.endsWith("/security")) return "keychain";

tests/config.secret-ref.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,4 +146,53 @@ describe("config — SecretRef integration", () => {
146146
expect(cfg.intervals.apiKey).toBe("iv-key");
147147
expect(cfg.telegram.botToken).toBe("tg-key");
148148
});
149+
150+
it("env-source SecretRef on all three fields resolves from process.env", async () => {
151+
process.env.MY_LLM_KEY = "llm-from-env";
152+
process.env.MY_IV_KEY = "iv-from-env";
153+
process.env.MY_TG_KEY = "tg-from-env";
154+
try {
155+
seed({
156+
llm: {
157+
provider: "anthropic",
158+
api_key: { source: "env", var: "MY_LLM_KEY" },
159+
model: "claude-sonnet-4-6",
160+
},
161+
intervals: {
162+
api_key: { source: "env", var: "MY_IV_KEY" },
163+
athlete_id: "i1",
164+
},
165+
telegram: {
166+
bot_token: { source: "env", var: "MY_TG_KEY" },
167+
},
168+
});
169+
const { loadConfig, resolveConfigSecrets } = await import("../src/config.js");
170+
const cfg = await resolveConfigSecrets(loadConfig());
171+
expect(cfg.llm.apiKey).toBe("llm-from-env");
172+
expect(cfg.intervals.apiKey).toBe("iv-from-env");
173+
expect(cfg.telegram.botToken).toBe("tg-from-env");
174+
} finally {
175+
delete process.env.MY_LLM_KEY;
176+
delete process.env.MY_IV_KEY;
177+
delete process.env.MY_TG_KEY;
178+
}
179+
});
180+
181+
it("env-source SecretRef throws ENOENT when var is unset", async () => {
182+
seed({
183+
llm: {
184+
provider: "anthropic",
185+
api_key: { source: "env", var: "DEFINITELY_UNSET_XYZ_123" },
186+
model: "claude-sonnet-4-6",
187+
},
188+
});
189+
const { loadConfig, resolveConfigSecrets } = await import("../src/config.js");
190+
const { SecretResolutionError } = await import("../src/secrets/types.js");
191+
const err = await resolveConfigSecrets(loadConfig()).catch(
192+
(e) => e as SecretResolutionError,
193+
);
194+
expect(err).toBeInstanceOf(SecretResolutionError);
195+
expect(err.code).toBe("ENOENT");
196+
expect(err.message).toContain("DEFINITELY_UNSET_XYZ_123");
197+
});
149198
});

0 commit comments

Comments
 (0)