@telepat/rilo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +209 -0
  3. package/index.js +1 -0
  4. package/models/black-forest-labs__flux-2-pro.json +78 -0
  5. package/models/black-forest-labs__flux-schnell.json +95 -0
  6. package/models/bytedance__seedream-4.json +71 -0
  7. package/models/deepseek-ai__deepseek-v3.json +61 -0
  8. package/models/google__nano-banana-pro.json +92 -0
  9. package/models/google__veo-3.1-fast.json +93 -0
  10. package/models/google__veo-3.1.json +93 -0
  11. package/models/jaaari__kokoro-82m.json +86 -0
  12. package/models/kwaivgi__kling-v3-video.json +101 -0
  13. package/models/minimax__speech-02-turbo.json +141 -0
  14. package/models/pixverse__pixverse-v5.6.json +113 -0
  15. package/models/prunaai__z-image-turbo.json +107 -0
  16. package/models/resemble-ai__chatterbox-turbo.json +102 -0
  17. package/models/wan-video__wan-2.2-i2v-fast.json +139 -0
  18. package/package.json +67 -0
  19. package/src/api/firebaseFunction.js +46 -0
  20. package/src/api/middleware/auth.js +70 -0
  21. package/src/api/openapi/generateOpenApi.js +21 -0
  22. package/src/api/openapi/spec.js +831 -0
  23. package/src/api/routes/jobs.js +45 -0
  24. package/src/api/routes/projectAssets.js +63 -0
  25. package/src/api/routes/projects.js +647 -0
  26. package/src/api/routes/webhooks.js +13 -0
  27. package/src/api/server.js +88 -0
  28. package/src/backends/firebaseClient.js +57 -0
  29. package/src/backends/outputBackend.js +186 -0
  30. package/src/backends/projectMetadataBackend.js +550 -0
  31. package/src/cli/commands/openHome.js +70 -0
  32. package/src/cli/commands/settingsFlow.js +196 -0
  33. package/src/cli/index.js +192 -0
  34. package/src/config/env.js +158 -0
  35. package/src/config/keystore.js +175 -0
  36. package/src/config/models.js +281 -0
  37. package/src/config/settingsSchema.js +214 -0
  38. package/src/media/ffmpeg.js +144 -0
  39. package/src/media/files.js +77 -0
  40. package/src/media/subtitles.js +444 -0
  41. package/src/observability/apiTrace.js +17 -0
  42. package/src/observability/logger.js +7 -0
  43. package/src/observability/metrics.js +10 -0
  44. package/src/pipeline/inputSanitizer.js +6 -0
  45. package/src/pipeline/orchestrator.js +1669 -0
  46. package/src/policy/contentGuardrails.js +30 -0
  47. package/src/providers/predictions.js +188 -0
  48. package/src/providers/replicateClient.js +12 -0
  49. package/src/steps/alignSubtitles.js +156 -0
  50. package/src/steps/burnInSubtitles.js +22 -0
  51. package/src/steps/composeFinalVideo.js +57 -0
  52. package/src/steps/generateKeyframes.js +70 -0
  53. package/src/steps/generateVideoSegments.js +95 -0
  54. package/src/steps/generateVoiceover.js +128 -0
  55. package/src/steps/imageToVideoAdapters.js +100 -0
  56. package/src/steps/script.js +177 -0
  57. package/src/steps/textToImageAdapters.js +87 -0
  58. package/src/store/assetStore.js +5 -0
  59. package/src/store/jobStore.js +102 -0
  60. package/src/store/projectAnalyticsStore.js +625 -0
  61. package/src/store/projectStore.js +684 -0
  62. package/src/store/settingsStore.js +155 -0
  63. package/src/store/staleAssetStore.js +63 -0
  64. package/src/types/job.js +28 -0
  65. package/src/types/media.js +28 -0
  66. package/src/worker/processor.js +24 -0
@@ -0,0 +1,196 @@
1
+ import { select, input, password, confirm } from '@inquirer/prompts';
2
+ import { SETTINGS, SECURE_SETTINGS, PUBLIC_SETTINGS } from '../../config/settingsSchema.js';
3
+ import {
4
+ resolvePublicValue,
5
+ resolveSecureStatus,
6
+ readPublicConfig,
7
+ savePublicSetting,
8
+ saveSecureToken
9
+ } from '../../store/settingsStore.js';
10
+
11
+ const DONE_CHOICE = '__done__';
12
+ const CANCEL_CHOICE = '__cancel__';
13
+
14
+ // ── Display helpers ────────────────────────────────────────────────────────────
15
+
16
+ /**
17
+ * Format a value for display next to a setting name.
18
+ * Secure values are always masked; env-sourced values show an indicator.
19
+ */
20
+ export function formatCurrentValue(setting, value, source) {
21
+ if (setting.type === 'secure') {
22
+ if (source === 'env') return '(set via environment variable — not editable here)';
23
+ if (source === 'keystore') return '(stored securely)';
24
+ return '(not set)';
25
+ }
26
+ if (source === 'env') return `${value} ← from environment variable`;
27
+ const display = value !== undefined && value !== null ? String(value) : String(setting.default);
28
+ return source === 'default' ? `${display} ← default` : display;
29
+ }
30
+
31
+ // ── Per-setting prompt ─────────────────────────────────────────────────────────
32
+
33
+ async function promptForSetting(setting, currentValue, source) {
34
+ const isEnvOverridden = source === 'env';
35
+
36
+ if (setting.type === 'secure') {
37
+ if (isEnvOverridden) {
38
+ console.log(
39
+ `\n ${setting.label}: This token is set via an environment variable and cannot be\n` +
40
+ ' overridden here. Remove it from your environment / .env file first.\n'
41
+ );
42
+ return null; // nothing to do
43
+ }
44
+
45
+ const hasExisting = source === 'keystore';
46
+ const action = await select({
47
+ message: `${setting.label}${hasExisting ? ' (currently stored securely)' : ' (not set)'}`,
48
+ choices: [
49
+ { name: hasExisting ? 'Replace stored value' : 'Set value', value: 'set' },
50
+ ...(hasExisting ? [{ name: 'Clear stored value', value: 'clear' }] : []),
51
+ { name: 'Skip', value: 'skip' }
52
+ ]
53
+ });
54
+
55
+ if (action === 'skip') return null;
56
+ if (action === 'clear') return { key: setting.keystoreKey, value: '', clear: true };
57
+
58
+ const value = await password({
59
+ message: `Enter ${setting.label}:`,
60
+ mask: '*',
61
+ validate(v) {
62
+ if (!v || !v.trim()) return 'Value must not be empty.';
63
+ return true;
64
+ }
65
+ });
66
+
67
+ return { key: setting.keystoreKey, value, secure: true };
68
+ }
69
+
70
+ // Public setting
71
+ if (isEnvOverridden) {
72
+ console.log(
73
+ `\n ${setting.label}: Currently set to "${currentValue}" via environment variable.\n` +
74
+ ' rilo will use the env var value; any saved setting here will be ignored while\n' +
75
+ ' the env var is set.\n'
76
+ );
77
+ const proceed = await confirm({
78
+ message: 'Save a config.json value anyway (used when the env var is absent)?',
79
+ default: false
80
+ });
81
+ if (!proceed) return null;
82
+ }
83
+
84
+ const defaultDisplay = String(currentValue ?? setting.default);
85
+ const newValue = await input({
86
+ message: `${setting.label}:`,
87
+ default: defaultDisplay,
88
+ validate(v) {
89
+ if (setting.validate) return setting.validate(v);
90
+ return true;
91
+ }
92
+ });
93
+
94
+ // Coerce type
95
+ const coerced = setting.type === 'number' ? Number(newValue) : newValue;
96
+ return { key: setting.configKey, value: coerced, public: true };
97
+ }
98
+
99
+ // ── Main flow ─────────────────────────────────────────────────────────────────
100
+
101
+ export async function openSettings() {
102
+ console.log('\n rilo settings\n');
103
+
104
+ const storedConfig = await readPublicConfig();
105
+
106
+ // Resolve current state for all settings for menu display
107
+ const stateMap = {};
108
+ for (const s of SETTINGS) {
109
+ if (s.type === 'secure') {
110
+ const status = await resolveSecureStatus(s);
111
+ stateMap[s.id] = { value: null, source: status.source };
112
+ } else {
113
+ const resolved = resolvePublicValue(s, storedConfig);
114
+ stateMap[s.id] = resolved;
115
+ }
116
+ }
117
+
118
+ const pendingChanges = []; // { setting, change }
119
+
120
+ while (true) {
121
+ // Build menu choices
122
+ const choices = [
123
+ ...SECURE_SETTINGS.map((s) => {
124
+ const { source } = stateMap[s.id];
125
+ return {
126
+ name: `Secure: ${s.label}`,
127
+ value: s.id,
128
+ description: formatCurrentValue(s, null, source)
129
+ };
130
+ }),
131
+ ...PUBLIC_SETTINGS.map((s) => {
132
+ const { value, source } = stateMap[s.id];
133
+ return {
134
+ name: `General: ${s.label}`,
135
+ value: s.id,
136
+ description: formatCurrentValue(s, value, source)
137
+ };
138
+ }),
139
+ ...(pendingChanges.length > 0
140
+ ? [{ name: `✓ Save ${pendingChanges.length} change(s) and exit`, value: DONE_CHOICE }]
141
+ : [{ name: '✓ Done (no changes)', value: DONE_CHOICE }]),
142
+ { name: '✗ Cancel / Discard (Ctrl+C to quit)', value: CANCEL_CHOICE }
143
+ ];
144
+
145
+ const selected = await select({
146
+ message: 'Select a setting to edit (secure first, then general):',
147
+ choices,
148
+ pageSize: 20
149
+ });
150
+
151
+ if (selected === CANCEL_CHOICE) {
152
+ console.log('\n No changes saved.\n');
153
+ return;
154
+ }
155
+
156
+ if (selected === DONE_CHOICE) break;
157
+
158
+ const setting = SETTINGS.find((s) => s.id === selected) ?? null;
159
+ if (!setting) continue;
160
+
161
+ const { value, source } = stateMap[setting.id];
162
+ const change = await promptForSetting(setting, value, source);
163
+
164
+ if (change) {
165
+ pendingChanges.push({ setting, change });
166
+ // Update display state optimistically
167
+ if (change.secure) {
168
+ stateMap[setting.id] = { value: null, source: 'keystore' };
169
+ } else if (change.clear) {
170
+ stateMap[setting.id] = { value: null, source: 'none' };
171
+ } else if (change.public) {
172
+ stateMap[setting.id] = { value: change.value, source: 'config' };
173
+ }
174
+ }
175
+ }
176
+
177
+ if (pendingChanges.length === 0) {
178
+ console.log('\n No changes to save.\n');
179
+ return;
180
+ }
181
+
182
+ // Persist all changes
183
+ for (const { change } of pendingChanges) {
184
+ if (change.secure) {
185
+ await saveSecureToken(change.key, change.value);
186
+ } else if (change.clear) {
187
+ // Import deleteSecret lazily to avoid circular deps in tests
188
+ const { deleteSecret } = await import('../../config/keystore.js');
189
+ await deleteSecret(change.key);
190
+ } else if (change.public) {
191
+ await savePublicSetting(change.key, change.value);
192
+ }
193
+ }
194
+
195
+ console.log(`\n Saved ${pendingChanges.length} change(s) to ~/.rilo/config.json / keystore.\n`);
196
+ }
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { createJob } from '../store/jobStore.js';
6
+ import { runPipeline } from '../pipeline/orchestrator.js';
7
+ import {
8
+ ensureProject,
9
+ ensureProjectConfig,
10
+ projectStoryExists,
11
+ readProjectStory,
12
+ resolveProjectName,
13
+ writeProjectStory
14
+ } from '../store/projectStore.js';
15
+ import { openSettings } from './commands/settingsFlow.js';
16
+ import { openHome } from './commands/openHome.js';
17
+ import { applyStoredSettings } from '../config/env.js';
18
+
19
+ function getArg(flag) {
20
+ const idx = process.argv.indexOf(flag);
21
+ if (idx === -1) return null;
22
+ return process.argv[idx + 1] || null;
23
+ }
24
+
25
+ function hasFlag(flag) {
26
+ return process.argv.includes(flag);
27
+ }
28
+
29
+ async function readCliVersion() {
30
+ try {
31
+ const cliDir = path.dirname(fileURLToPath(import.meta.url));
32
+ const packagePath = path.resolve(cliDir, '../../package.json');
33
+ const raw = await fs.readFile(packagePath, 'utf8');
34
+ const parsed = JSON.parse(raw);
35
+ return typeof parsed.version === 'string' ? parsed.version : 'unknown';
36
+ } catch {
37
+ return 'unknown';
38
+ }
39
+ }
40
+
41
+ async function main() {
42
+ if (process.argv.includes('--version')) {
43
+ console.log(await readCliVersion());
44
+ return;
45
+ }
46
+
47
+ if (process.argv.includes('--help')) {
48
+ const helpText = `
49
+ Rilo — Story-first vertical video generation
50
+
51
+ USAGE
52
+ rilo --project <name> [--story-file <path>] [--force]
53
+ rilo settings
54
+ rilo home
55
+ rilo --help
56
+ rilo --version
57
+
58
+ COMMANDS
59
+ rilo --project <name> [--story-file <path>] [--force]
60
+ Generate a complete video from a story
61
+
62
+ --project <name> Project identifier (required); creates projects/<name>/
63
+ --story-file <path> Path to story text file (required on first run)
64
+ --force Force restart from earlier stages (use after config changes)
65
+
66
+ rilo settings
67
+ Configure API tokens, timeouts, and binary paths interactively
68
+
69
+ rilo home
70
+ Open ~/.rilo, the default home for projects and output files
71
+
72
+ FLAGS
73
+ --help Show this help message
74
+ --version Show version information
75
+
76
+ EXAMPLES
77
+ # First run: create project and generate
78
+ rilo --project wedding-case --story-file ./story.txt
79
+
80
+ # Subsequent runs: reuse saved story
81
+ rilo --project wedding-case
82
+
83
+ # Update config and regenerate
84
+ rilo --project wedding-case --force
85
+
86
+ # Configure settings
87
+ rilo settings
88
+
89
+ # Open the default Rilo home folder
90
+ rilo home
91
+
92
+ # Using npx (no installation needed)
93
+ npx @telepat/rilo --project wedding-case --story-file ./story.txt
94
+ npx @telepat/rilo home
95
+
96
+ DOCUMENTATION
97
+ Quick start: https://docs.telepat.io/rilo/getting-started/quickstart
98
+ CLI reference: https://docs.telepat.io/rilo/reference/cli-reference
99
+ Configuration: https://docs.telepat.io/rilo/guides/configuration
100
+ Troubleshooting: https://docs.telepat.io/rilo/guides/troubleshooting
101
+ All docs: https://docs.telepat.io/rilo/
102
+
103
+ PROJECT OUTPUT
104
+ Generated files are stored in:
105
+ projects/<name>/
106
+ ├── config.json Project settings (models, aspect ratio, duration, etc.)
107
+ ├── story.md Formatted story
108
+ ├── final.mp4 Final video
109
+ ├── artifacts.json Generation metadata (paths, durations, etc.)
110
+ ├── run-state.json Checkpoint for resume/invalidation
111
+ ├── assets/ Keyframes, audio, video segments
112
+ └── logs/ Detailed generation logs
113
+
114
+ SETTINGS
115
+ Configure via interactive menu:
116
+ rilo settings
117
+
118
+ Or with environment variables:
119
+ export RILO_REPLICATE_API_TOKEN=r8_xxxxx
120
+ export RILO_MAX_RETRIES=5
121
+ export PREDICTION_MAX_WAIT_MS=900000
122
+ rilo --project my-project --story-file ./story.txt
123
+
124
+ Settings precedence (highest to lowest):
125
+ 1. Environment variable
126
+ 2. ~/.rilo/config.json (saved via 'rilo settings')
127
+ 3. Schema default
128
+
129
+ INVOCATION METHODS
130
+ Global install: rilo --project <name> --story-file <path>
131
+ No install (npx): npx @telepat/rilo --project <name> --story-file <path>
132
+ Local development: npm run dev -- --project <name> --story-file <path>
133
+ `;
134
+ console.log(helpText);
135
+ return;
136
+ }
137
+
138
+ // `rilo settings` subcommand
139
+ if (process.argv[2] === 'settings') {
140
+ await openSettings();
141
+ return;
142
+ }
143
+
144
+ if (process.argv[2] === 'home') {
145
+ await openHome();
146
+ return;
147
+ }
148
+
149
+ // Merge stored settings (config.json + keystore) into env before running
150
+ await applyStoredSettings();
151
+
152
+ const projectArg = getArg('--project');
153
+ if (!projectArg) {
154
+ throw new Error('Missing --project argument');
155
+ }
156
+
157
+ const project = resolveProjectName(projectArg);
158
+ const forceRestart = hasFlag('--force');
159
+ const storyFile = getArg('--story-file');
160
+
161
+ await ensureProject(project);
162
+ await ensureProjectConfig(project);
163
+
164
+ let story;
165
+ const storyExists = await projectStoryExists(project);
166
+ if (storyExists) {
167
+ story = await readProjectStory(project);
168
+ } else if (storyFile) {
169
+ story = await fs.readFile(path.resolve(storyFile), 'utf8');
170
+ await writeProjectStory(project, story);
171
+ } else {
172
+ throw new Error('Project story.md not found. Provide --story-file once to initialize the project.');
173
+ }
174
+
175
+ const job = createJob({ story, project });
176
+ const result = await runPipeline(job.id, { forceRestart });
177
+
178
+ if (result.status !== 'completed') {
179
+ throw new Error(`Generation failed: ${result.error || 'unknown error'}`);
180
+ }
181
+
182
+ console.log(JSON.stringify({
183
+ jobId: result.id,
184
+ project,
185
+ finalVideoPath: result.artifacts.finalVideoPath
186
+ }, null, 2));
187
+ }
188
+
189
+ main().catch((error) => {
190
+ console.error(error.message);
191
+ process.exit(1);
192
+ });
@@ -0,0 +1,158 @@
1
+ /* c8 ignore file */
2
+ import dotenv from 'dotenv';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ dotenv.config();
7
+
8
+ const DEFAULT_APP_DIR = path.join(os.homedir(), '.rilo');
9
+
10
+ export function parseEnvString(value, fallback) {
11
+ return typeof value === 'string' && value.length > 0 ? value : fallback;
12
+ }
13
+
14
+ export function hasEnvValue(value) {
15
+ return value !== undefined && value !== null && value !== '';
16
+ }
17
+
18
+ /* c8 ignore start */
19
+ export function parseEnvNumber(value, fallback) {
20
+ if (!hasEnvValue(value)) {
21
+ return fallback;
22
+ }
23
+ const parsed = Number(value);
24
+ return Number.isFinite(parsed) ? parsed : fallback;
25
+ }
26
+
27
+ export function parseEnvBoolean(value, fallback = false) {
28
+ if (!hasEnvValue(value)) {
29
+ return fallback;
30
+ }
31
+ return String(value).toLowerCase() === 'true';
32
+ }
33
+ /* c8 ignore stop */
34
+
35
+ export function parseAllowedHosts(value, fallback = 'replicate.delivery,replicate.com') {
36
+ return parseEnvString(value, fallback)
37
+ .split(',')
38
+ .map((entry) => entry.trim().toLowerCase())
39
+ .filter(Boolean);
40
+ }
41
+
42
+ export const env = {
43
+ replicateApiToken: parseEnvString(
44
+ process.env.SECRET_REPLICATE_API_TOKEN
45
+ || process.env.RILO_REPLICATE_API_TOKEN
46
+ || process.env.REPLICATE_API_TOKEN,
47
+ ''
48
+ ),
49
+ apiBearerToken: parseEnvString(
50
+ process.env.SECRET_API_BEARER_TOKEN
51
+ || process.env.RILO_API_BEARER_TOKEN
52
+ || process.env.API_BEARER_TOKEN,
53
+ ''
54
+ ),
55
+ port: parseEnvNumber(process.env.API_PORT || process.env.PORT, 3000),
56
+ webhookSecret: parseEnvString(process.env.WEBHOOK_SECRET, ''),
57
+ outputDir: parseEnvString(process.env.OUTPUT_DIR, path.join(DEFAULT_APP_DIR, 'output')),
58
+ projectsDir: parseEnvString(process.env.PROJECTS_DIR, path.join(DEFAULT_APP_DIR, 'projects')),
59
+ outputBackend: parseEnvString(
60
+ process.env.SECRET_OUTPUT_BACKEND
61
+ || process.env.RILO_OUTPUT_BACKEND
62
+ || process.env.OUTPUT_BACKEND,
63
+ 'local'
64
+ ),
65
+ firebaseProjectId: parseEnvString(
66
+ process.env.SECRET_FIREBASE_PROJECT_ID
67
+ || process.env.RILO_FIREBASE_PROJECT_ID
68
+ || process.env.FIREBASE_PROJECT_ID,
69
+ ''
70
+ ),
71
+ firebaseStorageBucket: parseEnvString(
72
+ process.env.SECRET_FIREBASE_STORAGE_BUCKET
73
+ || process.env.RILO_FIREBASE_STORAGE_BUCKET
74
+ || process.env.FIREBASE_STORAGE_BUCKET,
75
+ ''
76
+ ),
77
+ firebaseClientEmail: parseEnvString(
78
+ process.env.RILO_FIREBASE_CLIENT_EMAIL
79
+ || process.env.FIREBASE_CLIENT_EMAIL,
80
+ ''
81
+ ),
82
+ firebasePrivateKey: parseEnvString(
83
+ process.env.RILO_FIREBASE_PRIVATE_KEY
84
+ || process.env.FIREBASE_PRIVATE_KEY,
85
+ ''
86
+ ),
87
+ useWebhooks: parseEnvBoolean(process.env.USE_WEBHOOKS, false),
88
+ maxRetries: parseEnvNumber(process.env.MAX_RETRIES, 2),
89
+ retryDelayMs: parseEnvNumber(process.env.RETRY_DELAY_MS, 2500),
90
+ predictionPollIntervalMs: parseEnvNumber(process.env.PREDICTION_POLL_INTERVAL_MS, 1500),
91
+ predictionMaxWaitMs: parseEnvNumber(process.env.PREDICTION_MAX_WAIT_MS, 600000),
92
+ downloadTimeoutMs: parseEnvNumber(process.env.DOWNLOAD_TIMEOUT_MS, 20000),
93
+ downloadMaxBytes: parseEnvNumber(process.env.DOWNLOAD_MAX_BYTES, 104857600),
94
+ downloadAllowedHosts: parseAllowedHosts(process.env.DOWNLOAD_ALLOWED_HOSTS),
95
+ apiDefaultLogsLimit: parseEnvNumber(process.env.API_DEFAULT_LOGS_LIMIT, 100),
96
+ apiMaxLogsLimit: parseEnvNumber(process.env.API_MAX_LOGS_LIMIT, 1000),
97
+ ffmpegBin: parseEnvString(process.env.FFMPEG_BIN, 'ffmpeg'),
98
+ ffprobeBin: parseEnvString(process.env.FFPROBE_BIN, 'ffprobe'),
99
+ ffsubsyncBin: parseEnvString(process.env.FFSUBSYNC_BIN, 'ffsubsync')
100
+ };
101
+
102
+ /**
103
+ * Merge stored settings (config.json + keystore) into the `env` object.
104
+ * Call this once at CLI/server startup, after env vars have been loaded.
105
+ *
106
+ * Precedence (highest → lowest):
107
+ * 1. Environment variables (already in `env` above — never overwritten)
108
+ * 2. ~/.rilo/config.json (public settings)
109
+ * 3. OS keystore / encrypted file (secure tokens)
110
+ * 4. Schema defaults (already reflected in `env` above)
111
+ */
112
+ export async function applyStoredSettings() {
113
+ // Lazy imports to avoid circular deps and to keep env.js testable without FS
114
+ const { readPublicConfig } = await import('../store/settingsStore.js');
115
+ const { getSecret } = await import('./keystore.js');
116
+ const { PUBLIC_SETTINGS, SECURE_SETTINGS } = await import('./settingsSchema.js');
117
+
118
+ const storedConfig = await readPublicConfig();
119
+
120
+ // Apply public settings (only when no env var is set)
121
+ for (const setting of PUBLIC_SETTINGS) {
122
+ const hasEnvVar = setting.envNames.some((n) => process.env[n] !== undefined && process.env[n] !== '');
123
+ if (hasEnvVar) continue;
124
+
125
+ const stored = storedConfig[setting.configKey];
126
+ if (stored === undefined) continue;
127
+
128
+ // Map configKey → env property name (camelCase matches env object keys)
129
+ if (setting.id in env) {
130
+ env[setting.id] = setting.type === 'number' ? Number(stored) : stored;
131
+ }
132
+ }
133
+
134
+ // Apply secure tokens (only when no env var is set)
135
+ for (const setting of SECURE_SETTINGS) {
136
+ const hasEnvVar = setting.envNames.some((n) => process.env[n] !== undefined && process.env[n] !== '');
137
+ if (hasEnvVar) continue;
138
+
139
+ if (env[setting.id]) continue; // already has a value
140
+
141
+ const stored = await getSecret(setting.keystoreKey);
142
+ if (stored) {
143
+ env[setting.id] = stored;
144
+ }
145
+ }
146
+ }
147
+
148
+ export function assertRequiredEnv() {
149
+ if (!env.replicateApiToken) {
150
+ throw new Error('Missing REPLICATE_API_TOKEN in environment');
151
+ }
152
+ }
153
+
154
+ export function assertRequiredApiEnv() {
155
+ if (!env.apiBearerToken) {
156
+ throw new Error('Missing API_BEARER_TOKEN in environment');
157
+ }
158
+ }