diff --git a/packages/cli/src/adapter-registry.ts b/packages/cli/src/adapter-registry.ts index eb821a1a..f4234823 100644 --- a/packages/cli/src/adapter-registry.ts +++ b/packages/cli/src/adapter-registry.ts @@ -95,8 +95,8 @@ export const CATEGORIES: readonly AdapterCategory[] = [ { id: 'secrets', pkgPrefix: '@profullstack/sh1pt-secrets', - description: 'Secrets CLIs — Doppler, dotenvx, GitHub Secrets, 1Password', - adapters: ['doppler', 'dotenvx', 'github', 'onepassword'], + description: 'Secrets CLIs — Doppler, dotenvx, GitHub Secrets, 1Password, Railway', + adapters: ['doppler', 'dotenvx', 'github', 'onepassword', 'railway'], }, { id: 'security', diff --git a/packages/secrets/railway/README.md b/packages/secrets/railway/README.md new file mode 100644 index 00000000..85f041c6 --- /dev/null +++ b/packages/secrets/railway/README.md @@ -0,0 +1,36 @@ +# Railway Secrets + +Provides the Railway service variables module for sh1pt. + +## What it does + +- Lists Railway service variables with `railway variable list --json`. +- Pushes variable values with `railway variable set` without logging secret values. +- Supports optional Railway service and environment scopes. +- Supports `--skip-deploys` for staged secret updates. + +## Package + +- Name: `@profullstack/sh1pt-secrets-railway` +- Path: `packages/secrets/railway` +- Adapter ID: `secrets-railway` +- Homepage: https://sh1pt.com + +## Scripts + +- `build`: `tsc -p tsconfig.json` +- `prepublishOnly`: `pnpm build` +- `typecheck`: `tsc -p tsconfig.json --noEmit` + +## Usage + +```bash +pnpm add @profullstack/sh1pt-secrets-railway +``` + +## Development + +```bash +pnpm --filter @profullstack/sh1pt-secrets-railway typecheck +pnpm vitest run packages/secrets/railway/src/index.test.ts +``` diff --git a/packages/secrets/railway/package.json b/packages/secrets/railway/package.json new file mode 100644 index 00000000..7f27e2db --- /dev/null +++ b/packages/secrets/railway/package.json @@ -0,0 +1,37 @@ +{ + "name": "@profullstack/sh1pt-secrets-railway", + "version": "0.1.15", + "type": "module", + "main": "./src/index.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "prepublishOnly": "pnpm build" + }, + "dependencies": { + "@profullstack/sh1pt-core": "workspace:*" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/profullstack/sh1pt.git", + "directory": "packages/secrets/railway" + }, + "homepage": "https://sh1pt.com", + "bugs": "https://github.com/profullstack/sh1pt/issues", + "files": [ + "dist" + ], + "publishConfig": { + "access": "public", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + } + } +} diff --git a/packages/secrets/railway/src/index.test.ts b/packages/secrets/railway/src/index.test.ts new file mode 100644 index 00000000..5a8f97ee --- /dev/null +++ b/packages/secrets/railway/src/index.test.ts @@ -0,0 +1,38 @@ +import { smokeTest } from '@profullstack/sh1pt-core/testing'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { execMock } = vi.hoisted(() => ({ + execMock: vi.fn(), +})); + +vi.mock('@profullstack/sh1pt-core', async () => ({ + ...await vi.importActual('@profullstack/sh1pt-core'), + exec: execMock, +})); + +import adapter from './index.js'; + +smokeTest(adapter, { idPrefix: 'secrets' }); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('Railway secrets provider', () => { + it('redacts secret values from Railway CLI failure messages', async () => { + execMock.mockRejectedValue(new Error('railway variable set API_TOKEN=super-secret failed (exit 1): invalid value')); + + let thrown: unknown; + try { + await adapter.push({ secret: () => undefined, log: () => {} }, [ + { key: 'API_TOKEN', value: 'super-secret' }, + ], {}); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).message).toContain('API_TOKEN='); + expect((thrown as Error).message).not.toContain('super-secret'); + }); +}); diff --git a/packages/secrets/railway/src/index.ts b/packages/secrets/railway/src/index.ts new file mode 100644 index 00000000..3c549de4 --- /dev/null +++ b/packages/secrets/railway/src/index.ts @@ -0,0 +1,127 @@ +import { defineSecretProvider, exec, manualSetup, type SecretRef } from '@profullstack/sh1pt-core'; + +interface Config { + service?: string; + environment?: string; + skipDeploys?: boolean; +} + +interface RailwayVariableEntry { + name?: string; + key?: string; + value?: string; +} + +function scopedArgs(config: Config): string[] { + const args: string[] = []; + const service = config.service?.trim(); + const environment = config.environment?.trim(); + if (service) args.push('--service', service); + if (environment) args.push('--environment', environment); + return args; +} + +function parseVariables(stdout: string): SecretRef[] { + const body = stdout.trim(); + if (!body) return []; + + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch (error) { + throw new Error('Unable to parse `railway variable list --json` output as JSON. Run `railway login` or set RAILWAY_TOKEN and retry.', { + cause: error, + }); + } + + if (Array.isArray(parsed)) { + return parsed.flatMap((entry) => { + if (!entry || typeof entry !== 'object') return []; + const variable = entry as RailwayVariableEntry; + const key = variable.name ?? variable.key; + return key ? [{ key, value: variable.value }] : []; + }); + } + + if (parsed && typeof parsed === 'object') { + return Object.entries(parsed as Record).map(([key, value]) => ({ + key, + value: typeof value === 'string' ? value : undefined, + })); + } + + throw new Error('Expected `railway variable list --json` to return an object or array.'); +} + +function assertSecretKey(key: string): string { + const normalized = key.trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(normalized)) { + throw new Error(`Railway variable key must be an environment-style name: ${key}`); + } + return normalized; +} + +function redactSecretArgError(error: unknown, key: string, value: string): Error { + const leakedArg = `${key}=${value}`; + const redactedArg = `${key}=`; + + if (error instanceof Error) { + return new Error(error.message.split(leakedArg).join(redactedArg)); + } + + return new Error(`railway variable set ${redactedArg} failed`); +} + +export default defineSecretProvider({ + id: 'secrets-railway', + label: 'Railway Variables', + cli: 'railway', + async connect(ctx, config) { + const scope = [ + config.service?.trim() ? `service=${config.service.trim()}` : undefined, + config.environment?.trim() ? `environment=${config.environment.trim()}` : undefined, + ].filter(Boolean).join(' · ') || 'linked project'; + ctx.log(`railway whoami · scope=${scope}`); + await exec('railway', ['whoami'], { log: (message) => ctx.log(message), throwOnNonZero: true }); + return { accountId: scope }; + }, + async pull(ctx, config): Promise { + const args = ['variable', 'list', '--json', ...scopedArgs(config)]; + ctx.log(`railway ${args.join(' ')}`); + const result = await exec('railway', args, { log: (message) => ctx.log(message), throwOnNonZero: true }); + return parseVariables(result.stdout); + }, + async push(ctx, secrets, config) { + const commonArgs = ['variable', 'set', ...scopedArgs(config)]; + if (config.skipDeploys) commonArgs.push('--skip-deploys'); + + for (const secret of secrets) { + const key = assertSecretKey(secret.key); + const value = secret.value ?? ctx.secret(key); + if (value === undefined) { + throw new Error(`No value provided for Railway variable ${key}`); + } + ctx.log(`railway ${commonArgs.join(' ')} ${key}=`); + try { + await exec('railway', [...commonArgs, `${key}=${value}`], { + log: (message) => ctx.log(message), + throwOnNonZero: true, + }); + } catch (error) { + throw redactSecretArgError(error, key, value); + } + } + + return { count: secrets.length }; + }, + setup: manualSetup({ + label: 'Railway CLI', + vendorDocUrl: 'https://docs.railway.com/cli/variable', + steps: [ + 'Install Railway CLI from the official docs', + 'Authenticate locally: railway login', + 'For CI/service use, set RAILWAY_TOKEN or RAILWAY_API_TOKEN', + 'Link the project with railway link or configure service/environment in sh1pt', + ], + }), +}); diff --git a/packages/secrets/railway/tsconfig.json b/packages/secrets/railway/tsconfig.json new file mode 100644 index 00000000..cf441478 --- /dev/null +++ b/packages/secrets/railway/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b91aecd..2b958f45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1391,6 +1391,12 @@ importers: specifier: workspace:* version: link:../../core + packages/secrets/railway: + dependencies: + '@profullstack/sh1pt-core': + specifier: workspace:* + version: link:../../core + packages/security/snyk: dependencies: '@profullstack/sh1pt-core':