@cogitator-ai/deploy 0.4.7 → 0.5.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 (51) hide show
  1. package/README.md +66 -31
  2. package/dist/analyzer.d.ts +60 -9
  3. package/dist/analyzer.d.ts.map +1 -1
  4. package/dist/analyzer.js +463 -91
  5. package/dist/analyzer.js.map +1 -1
  6. package/dist/deployer.d.ts +15 -2
  7. package/dist/deployer.d.ts.map +1 -1
  8. package/dist/deployer.js +18 -8
  9. package/dist/deployer.js.map +1 -1
  10. package/dist/generator.d.ts +6 -0
  11. package/dist/generator.d.ts.map +1 -1
  12. package/dist/generator.js +9 -2
  13. package/dist/generator.js.map +1 -1
  14. package/dist/index.d.ts +3 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/providers/artifacts.d.ts.map +1 -1
  19. package/dist/providers/artifacts.js +2 -1
  20. package/dist/providers/artifacts.js.map +1 -1
  21. package/dist/providers/docker.d.ts +17 -0
  22. package/dist/providers/docker.d.ts.map +1 -1
  23. package/dist/providers/docker.js +63 -8
  24. package/dist/providers/docker.js.map +1 -1
  25. package/dist/providers/fly.d.ts +2 -0
  26. package/dist/providers/fly.d.ts.map +1 -1
  27. package/dist/providers/fly.js +43 -2
  28. package/dist/providers/fly.js.map +1 -1
  29. package/dist/templates/docker-compose.d.ts.map +1 -1
  30. package/dist/templates/docker-compose.js +23 -15
  31. package/dist/templates/docker-compose.js.map +1 -1
  32. package/dist/templates/dockerfile.d.ts +10 -1
  33. package/dist/templates/dockerfile.d.ts.map +1 -1
  34. package/dist/templates/dockerfile.js +84 -42
  35. package/dist/templates/dockerfile.js.map +1 -1
  36. package/dist/templates/fly-toml.d.ts.map +1 -1
  37. package/dist/templates/fly-toml.js +65 -31
  38. package/dist/templates/fly-toml.js.map +1 -1
  39. package/dist/templates/health.d.ts +9 -2
  40. package/dist/templates/health.d.ts.map +1 -1
  41. package/dist/templates/health.js +24 -2
  42. package/dist/templates/health.js.map +1 -1
  43. package/dist/utils/text.d.ts +9 -0
  44. package/dist/utils/text.d.ts.map +1 -0
  45. package/dist/utils/text.js +19 -0
  46. package/dist/utils/text.js.map +1 -0
  47. package/dist/volumes.d.ts +10 -0
  48. package/dist/volumes.d.ts.map +1 -0
  49. package/dist/volumes.js +21 -0
  50. package/dist/volumes.js.map +1 -0
  51. package/package.json +5 -4
package/dist/analyzer.js CHANGED
@@ -1,31 +1,45 @@
1
- import { loadConfig } from '@cogitator-ai/config';
2
- import { readFileSync, existsSync } from 'node:fs';
3
- import { join } from 'node:path';
4
- import { sanitizeName } from './utils/env.js';
1
+ import { isLLMProvider, resolveModelRoute } from '@cogitator-ai/types';
2
+ import { findConfigFile, loadConfig, loadYamlConfig, OLLAMA_DEFAULT_PORT, PROVIDER_ENV, preferredEnvName, providerEnvNames, resolveOllamaHost, } from '@cogitator-ai/config';
3
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
4
+ import { isAbsolute, join, posix } from 'node:path';
5
+ import { resolveDeployEnv, sanitizeName } from './utils/env.js';
6
+ import { healthPath } from './templates/health.js';
7
+ import { APP_DIR } from './volumes.js';
5
8
  const SERVER_PACKAGES = {
6
9
  '@cogitator-ai/express': 'express',
7
10
  '@cogitator-ai/fastify': 'fastify',
8
11
  '@cogitator-ai/hono': 'hono',
9
12
  '@cogitator-ai/koa': 'koa',
13
+ '@cogitator-ai/tetsu': 'tetsu',
14
+ '@cogitator-ai/next': 'next',
15
+ next: 'next',
10
16
  };
11
- const PROVIDER_SECRETS = {
12
- openai: ['OPENAI_API_KEY'],
13
- anthropic: ['ANTHROPIC_API_KEY'],
14
- google: ['GOOGLE_API_KEY'],
15
- azure: ['AZURE_OPENAI_API_KEY'],
16
- bedrock: ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY'],
17
- mistral: ['MISTRAL_API_KEY'],
18
- groq: ['GROQ_API_KEY'],
19
- together: ['TOGETHER_API_KEY'],
20
- deepseek: ['DEEPSEEK_API_KEY'],
21
- ollama: ['OLLAMA_API_KEY'],
22
- };
23
- const CONFIG_FILES = ['cogitator.yml', 'cogitator.yaml'];
17
+ /** HTTP frameworks a project may serve with directly, without a Cogitator adapter. */
18
+ const HTTP_PACKAGES = ['express', 'fastify', 'hono', '@hono/node-server', 'koa'];
19
+ /** Packages of long-running processes that serve no HTTP: channel gateways, queue workers. */
20
+ const WORKER_PACKAGES = ['@cogitator-ai/channels', '@cogitator-ai/worker'];
21
+ /** Runtimes that need Bun, whatever the lockfile says. */
22
+ const BUN_PACKAGES = ['@cogitator-ai/tetsu'];
23
+ const DOCKER_HOST_GATEWAY = 'host.docker.internal';
24
+ const LOOPBACK_HOSTS = new Set(['localhost', '0.0.0.0', '[::1]', '[::]', '::1']);
25
+ const SOURCE_EXTENSIONS = /\.(?:[cm]?[jt]s|tsx|jsx)$/;
26
+ const SKIPPED_DIRS = new Set(['node_modules', 'dist', 'build', '.git', '.next', '.cogitator']);
27
+ const MAX_SCANNED_FILES = 2000;
28
+ const REQUIRE_ENV_CALL = /\brequireEnv\(\s*(['"`])([A-Za-z_][A-Za-z0-9_]*)\1\s*\)/g;
29
+ const ENV_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
30
+ const ENV_FILE_FLAG = /--env-file(?!-if-exists)(?:=|\s+)(\S+)/;
31
+ function isRecord(value) {
32
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
33
+ }
24
34
  function isStringRecord(value) {
25
- return (typeof value === 'object' &&
26
- value !== null &&
27
- !Array.isArray(value) &&
28
- Object.values(value).every((v) => typeof v === 'string'));
35
+ return isRecord(value) && Object.values(value).every((v) => typeof v === 'string');
36
+ }
37
+ function isSet(env, name) {
38
+ const value = env[name];
39
+ return value !== undefined && value.trim() !== '';
40
+ }
41
+ function unique(values) {
42
+ return [...new Set(values)];
29
43
  }
30
44
  function mergeSection(file, override) {
31
45
  return file && override ? { ...file, ...override } : (override ?? file);
@@ -57,31 +71,132 @@ function readPackageJson(projectDir, warnings) {
57
71
  warnings.push(`Could not parse package.json: ${error instanceof Error ? error.message : String(error)}`);
58
72
  return {};
59
73
  }
60
- if (typeof raw !== 'object' || raw === null)
74
+ if (!isRecord(raw))
61
75
  return {};
62
- const get = (key) => Reflect.get(raw, key);
63
- const name = get('name');
64
- const main = get('main');
65
- const scripts = get('scripts');
66
- const dependencies = get('dependencies');
67
- const devDependencies = get('devDependencies');
76
+ const pnpm = raw.pnpm;
77
+ const patched = {
78
+ ...(isRecord(pnpm) && isStringRecord(pnpm.patchedDependencies) ? pnpm.patchedDependencies : {}),
79
+ ...(isStringRecord(raw.patchedDependencies) ? raw.patchedDependencies : {}),
80
+ };
68
81
  return {
69
- name: typeof name === 'string' ? name : undefined,
70
- main: typeof main === 'string' ? main : undefined,
71
- scripts: isStringRecord(scripts) ? scripts : undefined,
72
- dependencies: isStringRecord(dependencies) ? dependencies : undefined,
73
- devDependencies: isStringRecord(devDependencies) ? devDependencies : undefined,
82
+ name: typeof raw.name === 'string' ? raw.name : undefined,
83
+ main: typeof raw.main === 'string' ? raw.main : undefined,
84
+ packageManager: typeof raw.packageManager === 'string' ? raw.packageManager : undefined,
85
+ scripts: isStringRecord(raw.scripts) ? raw.scripts : undefined,
86
+ dependencies: isStringRecord(raw.dependencies) ? raw.dependencies : undefined,
87
+ devDependencies: isStringRecord(raw.devDependencies) ? raw.devDependencies : undefined,
88
+ patchedDependencies: Object.keys(patched).length > 0 ? patched : undefined,
89
+ };
90
+ }
91
+ function hasDependency(pkg, names) {
92
+ return names.some((name) => name in (pkg.dependencies ?? {}) || name in (pkg.devDependencies ?? {}));
93
+ }
94
+ /** `name@version` of the `packageManager` field, e.g. `yarn@4.5.0+sha512...`. */
95
+ function parsePackageManagerField(field) {
96
+ const match = /^([a-z]+)(?:@([^+\s]+))?/.exec(field?.trim() ?? '');
97
+ return match ? { name: match[1], version: match[2] } : undefined;
98
+ }
99
+ function majorVersion(version) {
100
+ const major = version ? Number.parseInt(version, 10) : Number.NaN;
101
+ return Number.isNaN(major) ? undefined : major;
102
+ }
103
+ /** Variable names in a `.env`-style file, comments and blank lines skipped. */
104
+ function envFileKeys(path) {
105
+ if (!existsSync(path))
106
+ return [];
107
+ return readFileSync(path, 'utf-8')
108
+ .split(/\r?\n/)
109
+ .map((line) => ENV_LINE.exec(line)?.[1])
110
+ .filter((key) => key !== undefined);
111
+ }
112
+ /** Names passed as string literals to `requireEnv(...)` in the project's sources. */
113
+ function requiredEnvNames(projectDir) {
114
+ const names = [];
115
+ let scanned = 0;
116
+ const visit = (dir) => {
117
+ let entries;
118
+ try {
119
+ entries = readdirSync(dir);
120
+ }
121
+ catch {
122
+ return;
123
+ }
124
+ for (const entry of entries.sort()) {
125
+ if (scanned >= MAX_SCANNED_FILES)
126
+ return;
127
+ if (SKIPPED_DIRS.has(entry))
128
+ continue;
129
+ const full = join(dir, entry);
130
+ let isDirectory;
131
+ try {
132
+ isDirectory = statSync(full).isDirectory();
133
+ }
134
+ catch {
135
+ continue;
136
+ }
137
+ if (isDirectory) {
138
+ visit(full);
139
+ }
140
+ else if (SOURCE_EXTENSIONS.test(entry)) {
141
+ scanned++;
142
+ for (const match of readFileSync(full, 'utf-8').matchAll(REQUIRE_ENV_CALL)) {
143
+ names.push(match[2]);
144
+ }
145
+ }
146
+ }
74
147
  };
148
+ visit(join(projectDir, 'src'));
149
+ return unique(names);
150
+ }
151
+ /** The host of `url`, or undefined when it does not parse. */
152
+ function hostOf(url) {
153
+ try {
154
+ return new URL(url).hostname;
155
+ }
156
+ catch {
157
+ return undefined;
158
+ }
159
+ }
160
+ function isLoopbackUrl(url) {
161
+ const host = hostOf(url);
162
+ return host === undefined || LOOPBACK_HOSTS.has(host) || host.startsWith('127.');
163
+ }
164
+ function portOf(url) {
165
+ try {
166
+ const port = new URL(url).port;
167
+ return port ? Number.parseInt(port, 10) : OLLAMA_DEFAULT_PORT;
168
+ }
169
+ catch {
170
+ return OLLAMA_DEFAULT_PORT;
171
+ }
172
+ }
173
+ function providerField(providers, provider, field) {
174
+ const section = providers?.[provider];
175
+ if (!isRecord(section))
176
+ return undefined;
177
+ const value = section[field];
178
+ return typeof value === 'string' && value.trim() !== '' ? value : undefined;
75
179
  }
76
180
  export class ProjectAnalyzer {
77
181
  detectServer(pkg) {
78
- const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
79
182
  for (const [pkgName, server] of Object.entries(SERVER_PACKAGES)) {
80
- if (pkgName in allDeps)
183
+ if (hasDependency(pkg, [pkgName]))
81
184
  return server;
82
185
  }
83
186
  return undefined;
84
187
  }
188
+ /**
189
+ * What the project runs as: a `server` when it serves HTTP through a Cogitator
190
+ * adapter or an HTTP framework, a `worker` when it runs a channel gateway or
191
+ * queue worker, undefined for anything else, such as a script that exits.
192
+ */
193
+ detectKind(pkg, server) {
194
+ if (server || hasDependency(pkg, HTTP_PACKAGES))
195
+ return 'server';
196
+ if (hasDependency(pkg, WORKER_PACKAGES))
197
+ return 'worker';
198
+ return undefined;
199
+ }
85
200
  detectServices(config) {
86
201
  const adapter = config.memory?.adapter;
87
202
  return {
@@ -89,13 +204,21 @@ export class ProjectAnalyzer {
89
204
  postgres: adapter === 'postgres',
90
205
  };
91
206
  }
92
- detectSecrets(model, defaultProvider) {
93
- const provider = model.includes('/') ? model.split('/')[0] : defaultProvider;
94
- if (!provider || !(provider in PROVIDER_SECRETS))
95
- return [];
96
- if (provider === 'ollama' && !this.isOllamaCloud(model))
207
+ /**
208
+ * Variables the provider of `model` cannot run without, routed the way the
209
+ * runtime routes it. Each is the variable set in `env` among the ones that
210
+ * provide the setting, else the provider's own name for it. Settings with a
211
+ * value written in `providers` (cogitator.yml) need none.
212
+ */
213
+ detectSecrets(model, defaultProvider, env = {}, providers) {
214
+ const provider = this.providerOf(model, defaultProvider);
215
+ if (!provider)
97
216
  return [];
98
- return [...PROVIDER_SECRETS[provider]];
217
+ const required = PROVIDER_ENV[provider].filter((setting) => setting.required ||
218
+ (provider === 'ollama' && setting.field === 'apiKey' && this.isOllamaCloud(model)));
219
+ return required
220
+ .filter((setting) => !providerField(providers, provider, setting.field))
221
+ .map((setting) => this.envNameFor(setting, env));
99
222
  }
100
223
  isOllamaCloud(model) {
101
224
  const modelName = model.includes('/') ? model.slice(model.indexOf('/') + 1) : model;
@@ -103,106 +226,355 @@ export class ProjectAnalyzer {
103
226
  }
104
227
  getDeployWarnings(model, target, defaultProvider) {
105
228
  const warnings = [];
106
- const provider = model.includes('/') ? model.split('/')[0] : defaultProvider;
107
- if (provider === 'ollama' && !this.isOllamaCloud(model) && target !== 'docker') {
229
+ if (this.providerOf(model, defaultProvider) === 'ollama' &&
230
+ !this.isOllamaCloud(model) &&
231
+ target !== 'docker') {
108
232
  warnings.push(`Model "${model}" requires local Ollama server. Cloud targets don't include Ollama. ` +
109
233
  'Use a cloud model (e.g. qwen3.5:cloud with OLLAMA_API_KEY), switch to a cloud LLM provider, ' +
110
234
  'or set OLLAMA_HOST to an external Ollama URL.');
111
235
  }
112
236
  return warnings;
113
237
  }
114
- detectPackageManager(projectDir) {
115
- if (existsSync(join(projectDir, 'pnpm-lock.yaml'))) {
238
+ detectPackageManager(projectDir, pkg = readPackageJson(projectDir, [])) {
239
+ const declared = parsePackageManagerField(pkg.packageManager);
240
+ const berry = existsSync(join(projectDir, '.yarnrc.yml')) ||
241
+ (declared?.name === 'yarn' && (majorVersion(declared.version) ?? 1) >= 2);
242
+ const has = (file) => existsSync(join(projectDir, file));
243
+ if (has('pnpm-lock.yaml'))
116
244
  return { packageManager: 'pnpm', hasLockfile: true };
245
+ if (has('bun.lock') || has('bun.lockb'))
246
+ return { packageManager: 'bun', hasLockfile: true };
247
+ if (has('yarn.lock')) {
248
+ const lock = readFileSync(join(projectDir, 'yarn.lock'), 'utf-8');
249
+ const isBerry = berry || /^__metadata:/m.test(lock);
250
+ return { packageManager: isBerry ? 'yarn-berry' : 'yarn', hasLockfile: true };
117
251
  }
118
- if (existsSync(join(projectDir, 'yarn.lock'))) {
119
- return { packageManager: 'yarn', hasLockfile: true };
120
- }
121
- if (existsSync(join(projectDir, 'package-lock.json'))) {
252
+ if (has('package-lock.json') || has('npm-shrinkwrap.json')) {
122
253
  return { packageManager: 'npm', hasLockfile: true };
123
254
  }
255
+ switch (declared?.name) {
256
+ case 'pnpm':
257
+ case 'bun':
258
+ return { packageManager: declared.name, hasLockfile: false };
259
+ case 'yarn':
260
+ return { packageManager: berry ? 'yarn-berry' : 'yarn', hasLockfile: false };
261
+ }
262
+ if (has('pnpm-workspace.yaml'))
263
+ return { packageManager: 'pnpm', hasLockfile: false };
264
+ const start = pkg.scripts?.start?.trim() ?? '';
265
+ if (hasDependency(pkg, BUN_PACKAGES) || /^bun(?:x)?\s/.test(start)) {
266
+ return { packageManager: 'bun', hasLockfile: false };
267
+ }
124
268
  return { packageManager: 'npm', hasLockfile: false };
125
269
  }
126
- detectStartCommand(pkg, hasTypeScript) {
270
+ detectStartCommand(pkg, hasTypeScript, packageManager = 'npm') {
271
+ const bun = packageManager === 'bun';
127
272
  const start = pkg.scripts?.start?.trim();
128
273
  if (start) {
274
+ if (bun)
275
+ return ['bun', 'run', 'start'];
276
+ if (packageManager === 'yarn-berry')
277
+ return ['yarn', 'start'];
129
278
  const nodeScript = /^node\s+([^\s&|;<>$`"']+)$/.exec(start);
130
279
  return nodeScript ? ['node', nodeScript[1]] : ['npm', 'start'];
131
280
  }
281
+ const runtime = bun ? 'bun' : 'node';
132
282
  if (pkg.main)
133
- return ['node', pkg.main];
134
- return ['node', hasTypeScript ? 'dist/server.js' : 'src/server.js'];
135
- }
136
- loadProjectConfig(projectDir, warnings) {
137
- const configPath = CONFIG_FILES.map((name) => join(projectDir, name)).find((p) => existsSync(p));
138
- if (!configPath)
139
- return undefined;
140
- try {
141
- return loadConfig({ configPath, skipEnv: true });
142
- }
143
- catch (error) {
144
- warnings.push(`Ignoring invalid ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
145
- return undefined;
146
- }
283
+ return [runtime, pkg.main];
284
+ return [runtime, hasTypeScript ? 'dist/server.js' : 'src/server.js'];
147
285
  }
148
286
  /** How the project in `projectDir` builds and starts. */
149
287
  detectBuild(projectDir) {
150
288
  return this.buildOf(projectDir, readPackageJson(projectDir, []));
151
289
  }
152
- buildOf(projectDir, pkg) {
153
- const hasTypeScript = existsSync(join(projectDir, 'tsconfig.json'));
154
- return {
155
- hasTypeScript,
156
- ...this.detectPackageManager(projectDir),
157
- hasBuildScript: typeof pkg.scripts?.build === 'string',
158
- startCommand: this.detectStartCommand(pkg, hasTypeScript),
159
- };
160
- }
161
290
  /**
162
291
  * Reads the project in `projectDir` into a deploy config: what it detects,
163
- * under the `deploy` section of the project's cogitator.yml, under
292
+ * under the `deploy` section of the project's config file, under
164
293
  * `configOverrides`.
165
294
  */
166
- analyze(projectDir, configOverrides) {
295
+ analyze(projectDir, configOverrides, options = {}) {
167
296
  const warnings = [];
297
+ const checks = [];
298
+ const env = options.env ?? resolveDeployEnv(projectDir);
299
+ const hasPackageJson = existsSync(join(projectDir, 'package.json'));
168
300
  const pkg = readPackageJson(projectDir, warnings);
169
- const fullConfig = this.loadProjectConfig(projectDir, warnings);
301
+ const configPath = options.configPath ?? findConfigFile(projectDir);
302
+ const fullConfig = this.loadProjectConfig(configPath, warnings);
170
303
  const configured = mergeDeployConfig(fullConfig?.deploy, configOverrides);
171
304
  const server = configured.server ?? this.detectServer(pkg);
305
+ const kind = configured.kind ?? (configured.health?.path ? 'server' : this.detectKind(pkg, server));
172
306
  const target = configured.target ?? 'docker';
173
307
  const build = this.buildOf(projectDir, pkg);
174
- const { hasTypeScript, hasBuildScript, startCommand } = build;
175
- if (!pkg.scripts?.start && !pkg.main) {
176
- warnings.push(`No "start" script or "main" in package.json — the container will run "${startCommand.join(' ')}"`);
308
+ checks.push(this.packageJsonCheck(projectDir, hasPackageJson));
309
+ checks.push(this.kindCheck(kind));
310
+ const envFileCheck = this.startScriptCheck(pkg);
311
+ if (envFileCheck)
312
+ checks.push(envFileCheck);
313
+ if (hasPackageJson && !pkg.scripts?.start && !pkg.main) {
314
+ warnings.push(`No "start" script or "main" in package.json, the container will run "${build.startCommand.join(' ')}"`);
177
315
  }
178
- if (hasTypeScript && !hasBuildScript) {
179
- warnings.push('tsconfig.json found but package.json has no "build" script — skipping build step');
316
+ if (build.hasTypeScript && !build.hasBuildScript) {
317
+ warnings.push('tsconfig.json found but package.json has no "build" script, skipping build step');
318
+ }
319
+ if (this.isPnpmMonorepo(projectDir)) {
320
+ warnings.push('pnpm-workspace.yaml lists workspace packages: only this directory goes into the image, so deploy a single package');
180
321
  }
181
322
  const model = fullConfig?.llm?.defaultModel ?? '';
182
323
  const defaultProvider = fullConfig?.llm?.defaultProvider;
324
+ const rawProviders = this.literalProviders(configPath);
183
325
  const services = configured.services ??
184
326
  (fullConfig ? this.detectServices(fullConfig) : { redis: false, postgres: false });
185
- const secrets = configured.secrets ?? (model ? this.detectSecrets(model, defaultProvider) : []);
327
+ const appSecrets = configured.secrets ??
328
+ unique([
329
+ ...(model || defaultProvider
330
+ ? this.detectSecrets(model, defaultProvider, env, rawProviders)
331
+ : []),
332
+ ...this.optionalProviderSecrets(model, defaultProvider, env),
333
+ ...requiredEnvNames(projectDir),
334
+ ...envFileKeys(join(projectDir, '.env.example')).filter((key) => isSet(env, key)),
335
+ ]);
336
+ const ollama = this.ollamaWiring(model, defaultProvider, target, env, configPath, warnings);
337
+ const infraSecrets = [
338
+ ...ollama.secrets,
339
+ ...(target === 'fly' ? this.flyServiceSecrets(services, warnings) : []),
340
+ ];
341
+ const secrets = unique([...appSecrets, ...infraSecrets]);
342
+ if (configured.secrets === undefined && secrets.length === 0) {
343
+ const local = envFileKeys(join(projectDir, '.env'));
344
+ if (local.length > 0) {
345
+ checks.push({
346
+ name: 'Secrets',
347
+ passed: false,
348
+ message: `No secrets detected, but .env defines ${local.join(', ')}: the deployment would start without them`,
349
+ fix: 'List the variables the app needs in deploy.secrets in cogitator.yml (an empty list deploys without any)',
350
+ });
351
+ }
352
+ }
353
+ if (secrets.some((name) => name.endsWith('SESSION_TOKEN') && /AWS|BEDROCK/.test(name))) {
354
+ warnings.push('AWS_SESSION_TOKEN is set: temporary credentials expire, and the deployed app stops reaching Bedrock when they do. Deploy with long-lived credentials or a role of the host.');
355
+ }
186
356
  if (model)
187
357
  warnings.push(...this.getDeployWarnings(model, target, defaultProvider));
188
358
  if (target === 'docker' && (configured.instances ?? 1) > 1) {
189
- warnings.push('instances > 1 is not supported for the docker target; running one container');
359
+ warnings.push('instances > 1 is not supported for the docker target, running one container');
360
+ }
361
+ const volumes = configured.volumes ?? this.detectVolumes(fullConfig, warnings);
362
+ if (volumes && target === 'fly') {
363
+ if (volumes.length > 1) {
364
+ checks.push({
365
+ name: 'Volumes',
366
+ passed: false,
367
+ message: `Fly machines mount one volume, ${volumes.length} are configured`,
368
+ fix: 'Keep the data the app writes under one directory and list only it in deploy.volumes',
369
+ });
370
+ }
371
+ if ((configured.instances ?? 1) > 1) {
372
+ warnings.push('Each Fly machine gets its own volume: with instances > 1 the machines do not share the data on it');
373
+ }
190
374
  }
375
+ const port = kind === 'worker' ? configured.port : (configured.port ?? 3000);
191
376
  const image = configured.image ?? sanitizeName(pkg.name ?? '');
377
+ const deployConfig = {
378
+ ...configured,
379
+ server,
380
+ kind,
381
+ image,
382
+ services,
383
+ secrets,
384
+ port,
385
+ ...(volumes && volumes.length > 0 ? { volumes } : {}),
386
+ ...(ollama.env ? { env: { ...ollama.env, ...configured.env }, hostGateway: true } : {}),
387
+ };
388
+ if (deployConfig.env === undefined)
389
+ delete deployConfig.env;
390
+ if (deployConfig.volumes === undefined)
391
+ delete deployConfig.volumes;
392
+ if (kind === 'server' && !healthPath(deployConfig)) {
393
+ warnings.push('No health check: set deploy.health.path to the route that answers health checks, the platform probes it to tell the app is up');
394
+ }
192
395
  return {
193
396
  server,
397
+ kind,
194
398
  services,
195
399
  secrets,
196
400
  warnings,
401
+ checks,
197
402
  ...build,
198
- deployConfig: {
199
- ...configured,
200
- server,
201
- image,
202
- services,
203
- secrets,
204
- port: configured.port ?? 3000,
205
- },
403
+ deployConfig,
404
+ };
405
+ }
406
+ providerOf(model, defaultProvider) {
407
+ const { provider } = model
408
+ ? resolveModelRoute(model, { defaultProvider })
409
+ : { provider: defaultProvider ?? '' };
410
+ return isLLMProvider(provider) ? provider : undefined;
411
+ }
412
+ envNameFor(setting, env) {
413
+ return providerEnvNames(setting).find((name) => isSet(env, name)) ?? preferredEnvName(setting);
414
+ }
415
+ /** Optional settings of the model's provider set in `env`, forwarded so the app sees them too. */
416
+ optionalProviderSecrets(model, defaultProvider, env) {
417
+ const provider = this.providerOf(model, defaultProvider);
418
+ if (!provider)
419
+ return [];
420
+ return PROVIDER_ENV[provider]
421
+ .filter((setting) => !setting.required && !setting.local)
422
+ .map((setting) => providerEnvNames(setting).find((name) => isSet(env, name)))
423
+ .filter((name) => name !== undefined);
424
+ }
425
+ /**
426
+ * How a local Ollama is reached from the deployment. A URL that already
427
+ * points beyond this machine is forwarded. Otherwise the docker target
428
+ * reaches the host through `host.docker.internal`, and other targets warn.
429
+ */
430
+ ollamaWiring(model, defaultProvider, target, env, configPath, warnings) {
431
+ if (this.providerOf(model, defaultProvider) !== 'ollama' || this.isOllamaCloud(model)) {
432
+ return { secrets: [] };
433
+ }
434
+ const setting = PROVIDER_ENV.ollama.find((s) => s.field === 'baseUrl');
435
+ const overrideNames = setting?.env ?? [];
436
+ const fallbackNames = setting?.fallbackEnv ?? [];
437
+ const firstUrl = (names) => names.map((name) => resolveOllamaHost(env[name])).find((url) => url !== undefined);
438
+ const configured = this.yamlInput(configPath, env)?.llm?.providers?.ollama?.baseUrl;
439
+ const url = firstUrl(overrideNames) ?? resolveOllamaHost(configured) ?? firstUrl(fallbackNames);
440
+ if (url && !isLoopbackUrl(url)) {
441
+ const forwarded = [...overrideNames, ...fallbackNames].filter((name) => {
442
+ const value = resolveOllamaHost(env[name]);
443
+ return value !== undefined && !isLoopbackUrl(value);
444
+ });
445
+ return { secrets: forwarded };
446
+ }
447
+ if (target !== 'docker')
448
+ return { secrets: [] };
449
+ const hostUrl = `http://${DOCKER_HOST_GATEWAY}:${url ? portOf(url) : OLLAMA_DEFAULT_PORT}`;
450
+ warnings.push(`Model "${model}" runs on the Ollama of this machine: the container reaches it at ${hostUrl}. ` +
451
+ 'On Linux, Ollama must listen beyond localhost (OLLAMA_HOST=0.0.0.0 ollama serve), ' +
452
+ `and the model must be pulled (ollama pull ${model.replace(/^ollama\//, '')}).`);
453
+ return {
454
+ secrets: [],
455
+ env: Object.fromEntries([...overrideNames, ...fallbackNames].map((name) => [name, hostUrl])),
456
+ };
457
+ }
458
+ flyServiceSecrets(services, warnings) {
459
+ const secrets = [];
460
+ if (services.redis) {
461
+ secrets.push('REDIS_URL');
462
+ warnings.push('Fly runs no Redis next to the app: create one (fly redis create, or any Redis such as Upstash) and set REDIS_URL');
463
+ }
464
+ if (services.postgres) {
465
+ secrets.push('DATABASE_URL');
466
+ warnings.push('Fly runs no Postgres next to the app: create one (fly postgres create, or any Postgres) and set DATABASE_URL');
467
+ }
468
+ return secrets;
469
+ }
470
+ detectVolumes(config, warnings) {
471
+ if (config?.memory?.adapter !== 'sqlite')
472
+ return undefined;
473
+ const dbPath = config.memory.sqlite?.path;
474
+ if (!dbPath) {
475
+ warnings.push('SQLite memory without memory.sqlite.path: set it to a file in a directory (data/memory.db) so the database can live on a volume');
476
+ return undefined;
477
+ }
478
+ if (dbPath === ':memory:')
479
+ return undefined;
480
+ const dir = posix.dirname(posix.normalize(dbPath.replace(/\\/g, '/')));
481
+ if (dir === '.' || dir === '/') {
482
+ warnings.push(`The SQLite database ${dbPath} sits in the ${dir === '/' ? 'root' : 'app'} directory, so it cannot get a volume and is lost on every deploy. Move it into a directory, such as data/memory.db`);
483
+ return undefined;
484
+ }
485
+ const path = dir.startsWith('..') ? posix.join(APP_DIR, dir) : dir.replace(/^\.\//, '');
486
+ return [{ path }];
487
+ }
488
+ packageJsonCheck(projectDir, exists) {
489
+ return exists
490
+ ? { name: 'package.json', passed: true, message: 'package.json found' }
491
+ : {
492
+ name: 'package.json',
493
+ passed: false,
494
+ message: `No package.json in ${projectDir}`,
495
+ fix: 'Deploy a Node.js project (cogitator init, create-cogitator-app). An assistant made by cogitator wizard runs with cogitator up or cogitator daemon start',
496
+ };
497
+ }
498
+ kindCheck(kind) {
499
+ return kind
500
+ ? { name: 'Project kind', passed: true, message: `Deploys as a ${kind}` }
501
+ : {
502
+ name: 'Project kind',
503
+ passed: false,
504
+ message: 'No HTTP server or long-running worker found: a script that exits after one run would be restarted forever',
505
+ fix: 'Set deploy.kind in cogitator.yml: server (answers HTTP on deploy.port) or worker (runs without HTTP, like a bot). Run a one-off script with node or tsx instead',
506
+ };
507
+ }
508
+ startScriptCheck(pkg) {
509
+ const match = ENV_FILE_FLAG.exec(pkg.scripts?.start ?? '');
510
+ if (!match)
511
+ return undefined;
512
+ return {
513
+ name: 'Start script environment',
514
+ passed: false,
515
+ message: `The "start" script loads ${match[1]} with --env-file, but .env files stay out of the image, so the container would exit at start`,
516
+ fix: `Use --env-file-if-exists=${match[1]} (Node 22.9+, tsx), the deployment passes the variables through the environment`,
517
+ };
518
+ }
519
+ isPnpmMonorepo(projectDir) {
520
+ const path = join(projectDir, 'pnpm-workspace.yaml');
521
+ return existsSync(path) && /^packages\s*:/m.test(readFileSync(path, 'utf-8'));
522
+ }
523
+ loadProjectConfig(configPath, warnings) {
524
+ if (!configPath)
525
+ return undefined;
526
+ try {
527
+ return loadConfig({ configPath, skipEnv: true });
528
+ }
529
+ catch (error) {
530
+ warnings.push(`Ignoring invalid ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
531
+ return undefined;
532
+ }
533
+ }
534
+ yamlInput(configPath, env) {
535
+ if (!configPath || !existsSync(configPath))
536
+ return undefined;
537
+ try {
538
+ return loadYamlConfig(configPath, env) ?? undefined;
539
+ }
540
+ catch {
541
+ return undefined;
542
+ }
543
+ }
544
+ /** `llm.providers` of the config file with every `${VAR}` reference left empty: the values written in it. */
545
+ literalProviders(configPath) {
546
+ const providers = this.yamlInput(configPath, {})?.llm?.providers;
547
+ return isRecord(providers) ? providers : undefined;
548
+ }
549
+ installFiles(projectDir, pkg, pm) {
550
+ const candidates = {
551
+ pnpm: ['pnpm-lock.yaml', 'pnpm-workspace.yaml', '.pnpmfile.cjs'],
552
+ npm: ['package-lock.json', 'npm-shrinkwrap.json'],
553
+ yarn: ['yarn.lock', '.yarnrc'],
554
+ 'yarn-berry': ['yarn.lock', '.yarnrc.yml', '.yarn'],
555
+ bun: ['bun.lock', 'bun.lockb', 'bunfig.toml'],
556
+ };
557
+ const patchDirs = Object.values(pkg.patchedDependencies ?? {})
558
+ .filter((path) => !isAbsolute(path))
559
+ .map((path) => posix.normalize(path.replace(/\\/g, '/')).split('/')[0])
560
+ .filter((first) => first && first !== '..' && first !== '.');
561
+ return unique([...candidates[pm], '.npmrc', 'patches', ...patchDirs])
562
+ .filter((file) => existsSync(join(projectDir, file)))
563
+ .map((file) => (statSync(join(projectDir, file)).isDirectory() ? `${file}/` : file));
564
+ }
565
+ buildOf(projectDir, pkg) {
566
+ const hasTypeScript = existsSync(join(projectDir, 'tsconfig.json'));
567
+ const pm = this.detectPackageManager(projectDir, pkg);
568
+ const declared = parsePackageManagerField(pkg.packageManager);
569
+ const binary = pm.packageManager === 'yarn-berry' ? 'yarn' : pm.packageManager;
570
+ const version = declared?.name === binary ? declared.version : undefined;
571
+ return {
572
+ hasTypeScript,
573
+ ...pm,
574
+ hasBuildScript: typeof pkg.scripts?.build === 'string',
575
+ startCommand: this.detectStartCommand(pkg, hasTypeScript, pm.packageManager),
576
+ installFiles: this.installFiles(projectDir, pkg, pm.packageManager),
577
+ ...(version ? { packageManagerVersion: version } : {}),
206
578
  };
207
579
  }
208
580
  }