From e9e1bbfe8366251f12d27eb0db4079fb3ca71296 Mon Sep 17 00:00:00 2001 From: yaowenc2 Date: Mon, 27 Apr 2026 11:46:22 -0700 Subject: [PATCH 1/2] feat(schedules): document sub-minute interval cadence in --cron help text The CLI passes --cron through to the backend unchanged. Once the validator update at InsForge/InsForge#1159 lands, --cron also accepts pg_cron interval syntax ("30 seconds", "5 minutes") for sub-minute cadence. Update the help text on `schedules create --cron` and `schedules update --cron` so commander's --help output mentions both formats. No behavioural change. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/schedules/create.ts | 5 ++++- src/commands/schedules/update.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/commands/schedules/create.ts b/src/commands/schedules/create.ts index b1676b4..13edb92 100644 --- a/src/commands/schedules/create.ts +++ b/src/commands/schedules/create.ts @@ -11,7 +11,10 @@ export function registerSchedulesCreateCommand(schedulesCmd: Command): void { .command('create') .description('Create a new schedule') .requiredOption('--name ', 'Schedule name') - .requiredOption('--cron ', 'Cron expression (5-field format)') + .requiredOption( + '--cron ', + 'Cron expression. 5-field cron (e.g. "*/5 * * * *", "0 9 * * 1-5") or pg_cron interval syntax for sub-minute cadence (e.g. "30 seconds", "5 minutes").' + ) .requiredOption('--url ', 'URL to invoke') .requiredOption('--method ', 'HTTP method (GET, POST, PUT, PATCH, DELETE)') .option('--headers ', 'HTTP headers as JSON') diff --git a/src/commands/schedules/update.ts b/src/commands/schedules/update.ts index 80cee34..6cdd839 100644 --- a/src/commands/schedules/update.ts +++ b/src/commands/schedules/update.ts @@ -9,7 +9,10 @@ export function registerSchedulesUpdateCommand(schedulesCmd: Command): void { .command('update ') .description('Update a schedule') .option('--name ', 'New schedule name') - .option('--cron ', 'New cron expression') + .option( + '--cron ', + 'New cron expression. 5-field cron or pg_cron interval syntax (e.g. "30 seconds").' + ) .option('--url ', 'New URL to invoke') .option('--method ', 'New HTTP method') .option('--headers ', 'New HTTP headers as JSON') From 1798ddce27f03410829bf3ac33d4d89f0fe294b0 Mon Sep 17 00:00:00 2001 From: yaowenc2 Date: Thu, 30 Apr 2026 11:28:14 -0700 Subject: [PATCH 2/2] feat(cli/compute): --env-file on deploy, --env-set/--env-unset on update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ergonomics fixes for env vars on compute services. `compute deploy --env-file `. Today --env requires inline JSON, so deploying with seven secrets means hand-rolling a JSON object on the command line. --env-file accepts a standard .env (KEY=VALUE per line, # comments, blank lines, quoted values, trailing inline comments). Mutually exclusive with --env. Pure CLI parser — backend already accepts the same envVars: Record shape that --env emits. `compute update --env-set KEY=VALUE` (repeatable) and `--env-unset KEY` (repeatable). Today --env replaces the whole envVars object, meaning to rotate one secret you have to know the other six. The new flags target the OSS envVarsPatch endpoint that lands the same week — the server decrypts existing env, applies set/unset, and re-encrypts. --env and --env-set/--env-unset are mutually exclusive (the request has one of envVars or envVarsPatch, never both). CLI-side validation mirrors the OSS schema: env var keys must match [A-Z_][A-Z0-9_]* in both flag paths so a typo errors locally instead of round-tripping a 400 from the backend. Tests: 12 new for the dotenv parser (quoted values, # comments, CRLF, inline comments, malformed lines with line-number errors, missing file). 133/133 + 13 skipped existing tests still pass. Typecheck + lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/compute/deploy.ts | 12 +++++ src/commands/compute/update.ts | 76 ++++++++++++++++++++++++++++++- src/lib/env-file.test.ts | 82 ++++++++++++++++++++++++++++++++++ src/lib/env-file.ts | 62 +++++++++++++++++++++++++ 4 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 src/lib/env-file.test.ts create mode 100644 src/lib/env-file.ts diff --git a/src/commands/compute/deploy.ts b/src/commands/compute/deploy.ts index a6a36d8..c0f0e78 100644 --- a/src/commands/compute/deploy.ts +++ b/src/commands/compute/deploy.ts @@ -6,6 +6,7 @@ import { requireAuth } from '../../lib/credentials.js'; import { handleError, getRootOpts, CLIError } from '../../lib/errors.js'; import { outputJson, outputSuccess, outputInfo } from '../../lib/output.js'; import { reportCliUsage } from '../../lib/skills.js'; +import { parseEnvFile } from '../../lib/env-file.js'; import { ensureDockerAvailable, dockerBuild, @@ -42,6 +43,10 @@ export function registerComputeDeployCommand(computeCmd: Command): void { .option('--memory ', 'Memory in MB', '512') .option('--region ', 'Fly.io region', 'iad') .option('--env ', 'Env vars as JSON object') + .option( + '--env-file ', + 'Path to a .env file (KEY=VALUE per line, #-comments + blank lines ok). Mutually exclusive with --env.' + ) .action(async (dir: string | undefined, opts, cmd) => { const { json } = getRootOpts(cmd); try { @@ -65,6 +70,11 @@ export function registerComputeDeployCommand(computeCmd: Command): void { if (!Number.isInteger(memory) || memory <= 0) { throw new CLIError(`Invalid --memory: ${opts.memory}`); } + if (opts.env && opts.envFile) { + throw new CLIError( + '--env and --env-file are mutually exclusive — pick one source for the env vars.' + ); + } let envVars: Record | undefined; if (opts.env) { let parsed: unknown; @@ -84,6 +94,8 @@ export function registerComputeDeployCommand(computeCmd: Command): void { } } envVars = parsed as Record; + } else if (opts.envFile) { + envVars = parseEnvFile(resolve(opts.envFile)); } const baseBody: Record = { diff --git a/src/commands/compute/update.ts b/src/commands/compute/update.ts index 8bb6360..9596e39 100644 --- a/src/commands/compute/update.ts +++ b/src/commands/compute/update.ts @@ -5,6 +5,38 @@ import { handleError, getRootOpts, CLIError } from '../../lib/errors.js'; import { outputJson, outputSuccess } from '../../lib/output.js'; import { reportCliUsage } from '../../lib/skills.js'; +const ENV_KEY_REGEX = /^[A-Z_][A-Z0-9_]*$/; + +// Commander collector for repeatable flags. Each invocation appends to the +// running list rather than overwriting. +function collect(value: string, previous: string[]): string[] { + return previous.concat([value]); +} + +// Parse a "KEY=VALUE" string into a tuple, validating the key shape against +// the same regex the OSS schema enforces. Values may contain '=' and are +// preserved verbatim — only the first '=' separates key from value. +function parseKeyValue(raw: string): [string, string] { + const eq = raw.indexOf('='); + if (eq <= 0) { + throw new CLIError( + `Invalid --env-set "${raw}": expected KEY=VALUE (key first, then '=', then value)` + ); + } + const key = raw.slice(0, eq); + const value = raw.slice(eq + 1); + if (!ENV_KEY_REGEX.test(key)) { + throw new CLIError(`Invalid env var key "${key}": must match [A-Z_][A-Z0-9_]*`); + } + return [key, value]; +} + +function assertValidKey(key: string): void { + if (!ENV_KEY_REGEX.test(key)) { + throw new CLIError(`Invalid env var key "${key}": must match [A-Z_][A-Z0-9_]*`); + } +} + export function registerComputeUpdateCommand(computeCmd: Command): void { computeCmd .command('update ') @@ -14,7 +46,22 @@ export function registerComputeUpdateCommand(computeCmd: Command): void { .option('--cpu ', 'CPU tier') .option('--memory ', 'Memory in MB') .option('--region ', 'Fly.io region') - .option('--env ', 'Environment variables as JSON object') + .option( + '--env ', + 'Replace ALL env vars with this JSON object. To rotate one secret without restating the others, use --env-set instead.' + ) + .option( + '--env-set ', + 'Set or update one env var (repeatable). Merges with existing — does not clear other vars.', + collect, + [] + ) + .option( + '--env-unset ', + 'Remove one env var (repeatable). Merges with existing — leaves other vars in place.', + collect, + [] + ) .action(async (id: string, opts, cmd) => { const { json } = getRootOpts(cmd); try { @@ -37,6 +84,16 @@ export function registerComputeUpdateCommand(computeCmd: Command): void { } if (opts.region) body.region = opts.region; + const envSetArgs = opts.envSet as string[]; + const envUnsetArgs = opts.envUnset as string[]; + const hasPatch = envSetArgs.length > 0 || envUnsetArgs.length > 0; + + if (opts.env && hasPatch) { + throw new CLIError( + '--env (wholesale replace) and --env-set/--env-unset (partial merge) are mutually exclusive — pick one.' + ); + } + if (opts.env) { try { body.envVars = JSON.parse(opts.env); @@ -45,8 +102,23 @@ export function registerComputeUpdateCommand(computeCmd: Command): void { } } + if (hasPatch) { + const setMap: Record = {}; + for (const arg of envSetArgs) { + const [k, v] = parseKeyValue(arg); + setMap[k] = v; + } + for (const k of envUnsetArgs) assertValidKey(k); + body.envVarsPatch = { + ...(envSetArgs.length > 0 && { set: setMap }), + ...(envUnsetArgs.length > 0 && { unset: envUnsetArgs }), + }; + } + if (Object.keys(body).length === 0) { - throw new CLIError('No update fields provided. Use --image, --port, --cpu, --memory, --region, or --env.'); + throw new CLIError( + 'No update fields provided. Use --image, --port, --cpu, --memory, --region, --env, --env-set, or --env-unset.' + ); } const res = await ossFetch(`/api/compute/services/${encodeURIComponent(id)}`, { diff --git a/src/lib/env-file.test.ts b/src/lib/env-file.test.ts new file mode 100644 index 0000000..f4dbcd1 --- /dev/null +++ b/src/lib/env-file.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { parseEnvFile } from './env-file.js'; + +let dir: string; + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'cli-env-file-')); +}); + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +function write(name: string, contents: string): string { + const p = join(dir, name); + writeFileSync(p, contents); + return p; +} + +describe('parseEnvFile', () => { + it('parses plain KEY=VALUE pairs', () => { + const p = write('plain.env', 'FOO=bar\nBAZ=qux\n'); + expect(parseEnvFile(p)).toEqual({ FOO: 'bar', BAZ: 'qux' }); + }); + + it('skips blank lines and # comments', () => { + const p = write('comments.env', '# header comment\n\nFOO=bar\n# inline\nBAZ=qux\n'); + expect(parseEnvFile(p)).toEqual({ FOO: 'bar', BAZ: 'qux' }); + }); + + it('strips matching surrounding double quotes from values', () => { + const p = write('dquotes.env', 'GREETING="hello world"\n'); + expect(parseEnvFile(p)).toEqual({ GREETING: 'hello world' }); + }); + + it('strips matching surrounding single quotes from values', () => { + const p = write('squotes.env', "GREETING='hello world'\n"); + expect(parseEnvFile(p)).toEqual({ GREETING: 'hello world' }); + }); + + it('preserves # inside quoted values (not a comment)', () => { + const p = write('hash.env', 'PASSWORD="abc#123"\n'); + expect(parseEnvFile(p)).toEqual({ PASSWORD: 'abc#123' }); + }); + + it('strips trailing inline comment from unquoted values', () => { + const p = write('inline.env', 'PORT=8080 # default port\n'); + expect(parseEnvFile(p)).toEqual({ PORT: '8080' }); + }); + + it('preserves "=" inside values (only first equals splits)', () => { + const p = write('eq.env', 'JWT=a.b=c.d\n'); + expect(parseEnvFile(p)).toEqual({ JWT: 'a.b=c.d' }); + }); + + it('rejects invalid keys (lowercase, hyphen)', () => { + const p = write('badkey.env', 'lower=ok\n'); + expect(() => parseEnvFile(p)).toThrow(/invalid env var key/); + }); + + it('rejects malformed lines (no equals)', () => { + const p = write('malformed.env', 'NOT_A_PAIR\n'); + expect(() => parseEnvFile(p)).toThrow(/expected KEY=VALUE/); + }); + + it('reports the line number on errors', () => { + const p = write('linenum.env', 'GOOD=ok\n\nBAD\n'); + expect(() => parseEnvFile(p)).toThrow(/:3:/); + }); + + it('throws CLIError when file does not exist', () => { + expect(() => parseEnvFile(join(dir, 'nope.env'))).toThrow(/Could not read --env-file/); + }); + + it('handles CRLF line endings', () => { + const p = write('crlf.env', 'FOO=bar\r\nBAZ=qux\r\n'); + expect(parseEnvFile(p)).toEqual({ FOO: 'bar', BAZ: 'qux' }); + }); +}); diff --git a/src/lib/env-file.ts b/src/lib/env-file.ts new file mode 100644 index 0000000..3374f2d --- /dev/null +++ b/src/lib/env-file.ts @@ -0,0 +1,62 @@ +import { readFileSync } from 'node:fs'; +import { CLIError } from './errors.js'; + +const ENV_KEY_REGEX = /^[A-Z_][A-Z0-9_]*$/; + +// Minimal dotenv parser. Handles: +// • KEY=VALUE lines +// • Blank lines and # comment lines +// • Optional surrounding quotes ("..." or '...') stripped from VALUE +// • Inline trailing comments after unquoted values: KEY=val # note +// +// Keeps escape-sequence handling deliberately out of scope — anything fancy +// (multiline strings, $VAR expansion) belongs in a real dotenv library; for +// `compute deploy --env-file` the goal is feature-parity with `--env ` +// for the 95% case, not full dotenv semantics. +export function parseEnvFile(path: string): Record { + let raw: string; + try { + raw = readFileSync(path, 'utf-8'); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new CLIError(`Could not read --env-file at ${path}: ${msg}`); + } + + const result: Record = {}; + const lines = raw.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === '' || line.startsWith('#')) continue; + + const eq = line.indexOf('='); + if (eq <= 0) { + throw new CLIError( + `${path}:${i + 1}: expected KEY=VALUE, got "${line}"` + ); + } + const key = line.slice(0, eq).trim(); + if (!ENV_KEY_REGEX.test(key)) { + throw new CLIError( + `${path}:${i + 1}: invalid env var key "${key}" (must match [A-Z_][A-Z0-9_]*)` + ); + } + + let value = line.slice(eq + 1).trim(); + + // Surrounding quotes (matching pair) — strip them and use the inner + // string verbatim. Anything inside quotes is preserved including '#'. + if ( + (value.startsWith('"') && value.endsWith('"') && value.length >= 2) || + (value.startsWith("'") && value.endsWith("'") && value.length >= 2) + ) { + value = value.slice(1, -1); + } else { + // Unquoted value — strip a trailing inline comment (`KEY=val # note`). + const hash = value.indexOf(' #'); + if (hash >= 0) value = value.slice(0, hash).trimEnd(); + } + + result[key] = value; + } + return result; +}