@cogitator-ai/deploy 0.4.8 → 0.6.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 +91 -9
  3. package/dist/analyzer.d.ts.map +1 -1
  4. package/dist/analyzer.js +573 -93
  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 +10 -2
  11. package/dist/generator.d.ts.map +1 -1
  12. package/dist/generator.js +15 -3
  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 +31 -2
  33. package/dist/templates/dockerfile.d.ts.map +1 -1
  34. package/dist/templates/dockerfile.js +144 -45
  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 +6 -4
package/dist/analyzer.js CHANGED
@@ -1,31 +1,46 @@
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 { parse as parseYaml } from 'yaml';
2
+ import { isLLMProvider, resolveModelRoute } from '@cogitator-ai/types';
3
+ import { findConfigFile, loadConfig, loadYamlConfig, OLLAMA_DEFAULT_PORT, PROVIDER_ENV, preferredEnvName, providerEnvNames, resolveOllamaHost, } from '@cogitator-ai/config';
4
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
5
+ import { isAbsolute, join, posix } from 'node:path';
6
+ import { resolveDeployEnv, sanitizeName } from './utils/env.js';
7
+ import { healthPath } from './templates/health.js';
8
+ import { APP_DIR } from './volumes.js';
5
9
  const SERVER_PACKAGES = {
6
10
  '@cogitator-ai/express': 'express',
7
11
  '@cogitator-ai/fastify': 'fastify',
8
12
  '@cogitator-ai/hono': 'hono',
9
13
  '@cogitator-ai/koa': 'koa',
14
+ '@cogitator-ai/tetsu': 'tetsu',
15
+ '@cogitator-ai/next': 'next',
16
+ next: 'next',
10
17
  };
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'];
18
+ /** HTTP frameworks a project may serve with directly, without a Cogitator adapter. */
19
+ const HTTP_PACKAGES = ['express', 'fastify', 'hono', '@hono/node-server', 'koa'];
20
+ /** Packages of long-running processes that serve no HTTP: channel gateways, queue workers. */
21
+ const WORKER_PACKAGES = ['@cogitator-ai/channels', '@cogitator-ai/worker'];
22
+ /** Runtimes that need Bun, whatever the lockfile says. */
23
+ const BUN_PACKAGES = ['@cogitator-ai/tetsu'];
24
+ const DOCKER_HOST_GATEWAY = 'host.docker.internal';
25
+ const LOOPBACK_HOSTS = new Set(['localhost', '0.0.0.0', '[::1]', '[::]', '::1']);
26
+ const SOURCE_EXTENSIONS = /\.(?:[cm]?[jt]s|tsx|jsx)$/;
27
+ const SKIPPED_DIRS = new Set(['node_modules', 'dist', 'build', '.git', '.next', '.cogitator']);
28
+ const MAX_SCANNED_FILES = 2000;
29
+ const REQUIRE_ENV_CALL = /\brequireEnv\(\s*(['"`])([A-Za-z_][A-Za-z0-9_]*)\1\s*\)/g;
30
+ const ENV_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
31
+ const ENV_FILE_FLAG = /--env-file(?!-if-exists)(?:=|\s+)(\S+)/;
32
+ function isRecord(value) {
33
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
34
+ }
24
35
  function isStringRecord(value) {
25
- return (typeof value === 'object' &&
26
- value !== null &&
27
- !Array.isArray(value) &&
28
- Object.values(value).every((v) => typeof v === 'string'));
36
+ return isRecord(value) && Object.values(value).every((v) => typeof v === 'string');
37
+ }
38
+ function isSet(env, name) {
39
+ const value = env[name];
40
+ return value !== undefined && value.trim() !== '';
41
+ }
42
+ function unique(values) {
43
+ return [...new Set(values)];
29
44
  }
30
45
  function mergeSection(file, override) {
31
46
  return file && override ? { ...file, ...override } : (override ?? file);
@@ -57,31 +72,202 @@ function readPackageJson(projectDir, warnings) {
57
72
  warnings.push(`Could not parse package.json: ${error instanceof Error ? error.message : String(error)}`);
58
73
  return {};
59
74
  }
60
- if (typeof raw !== 'object' || raw === null)
75
+ if (!isRecord(raw))
61
76
  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');
77
+ const pnpm = raw.pnpm;
78
+ const patched = {
79
+ ...(isRecord(pnpm) && isStringRecord(pnpm.patchedDependencies) ? pnpm.patchedDependencies : {}),
80
+ ...(isStringRecord(raw.patchedDependencies) ? raw.patchedDependencies : {}),
81
+ };
68
82
  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,
83
+ name: typeof raw.name === 'string' ? raw.name : undefined,
84
+ type: typeof raw.type === 'string' ? raw.type : undefined,
85
+ main: typeof raw.main === 'string' ? raw.main : undefined,
86
+ packageManager: typeof raw.packageManager === 'string' ? raw.packageManager : undefined,
87
+ scripts: isStringRecord(raw.scripts) ? raw.scripts : undefined,
88
+ dependencies: isStringRecord(raw.dependencies) ? raw.dependencies : undefined,
89
+ devDependencies: isStringRecord(raw.devDependencies) ? raw.devDependencies : undefined,
90
+ patchedDependencies: Object.keys(patched).length > 0 ? patched : undefined,
74
91
  };
75
92
  }
93
+ function hasDependency(pkg, names) {
94
+ return names.some((name) => name in (pkg.dependencies ?? {}) || name in (pkg.devDependencies ?? {}));
95
+ }
96
+ /** `name@version` of the `packageManager` field, e.g. `yarn@4.5.0+sha512...`. */
97
+ function parsePackageManagerField(field) {
98
+ const match = /^([a-z]+)(?:@([^+\s]+))?/.exec(field?.trim() ?? '');
99
+ return match ? { name: match[1], version: match[2] } : undefined;
100
+ }
101
+ function majorVersion(version) {
102
+ const major = version ? Number.parseInt(version, 10) : Number.NaN;
103
+ return Number.isNaN(major) ? undefined : major;
104
+ }
105
+ /** Variable names in a `.env`-style file, comments and blank lines skipped. */
106
+ function envFileKeys(path) {
107
+ if (!existsSync(path))
108
+ return [];
109
+ return readFileSync(path, 'utf-8')
110
+ .split(/\r?\n/)
111
+ .map((line) => ENV_LINE.exec(line)?.[1])
112
+ .filter((key) => key !== undefined);
113
+ }
114
+ /** Names passed as string literals to `requireEnv(...)` in the project's sources. */
115
+ function requiredEnvNames(projectDir) {
116
+ const names = [];
117
+ let scanned = 0;
118
+ const visit = (dir) => {
119
+ let entries;
120
+ try {
121
+ entries = readdirSync(dir);
122
+ }
123
+ catch {
124
+ return;
125
+ }
126
+ for (const entry of entries.sort()) {
127
+ if (scanned >= MAX_SCANNED_FILES)
128
+ return;
129
+ if (SKIPPED_DIRS.has(entry))
130
+ continue;
131
+ const full = join(dir, entry);
132
+ let isDirectory;
133
+ try {
134
+ isDirectory = statSync(full).isDirectory();
135
+ }
136
+ catch {
137
+ continue;
138
+ }
139
+ if (isDirectory) {
140
+ visit(full);
141
+ }
142
+ else if (SOURCE_EXTENSIONS.test(entry)) {
143
+ scanned++;
144
+ for (const match of readFileSync(full, 'utf-8').matchAll(REQUIRE_ENV_CALL)) {
145
+ names.push(match[2]);
146
+ }
147
+ }
148
+ }
149
+ };
150
+ visit(join(projectDir, 'src'));
151
+ return unique(names);
152
+ }
153
+ /** The host of `url`, or undefined when it does not parse. */
154
+ function hostOf(url) {
155
+ try {
156
+ return new URL(url).hostname;
157
+ }
158
+ catch {
159
+ return undefined;
160
+ }
161
+ }
162
+ function isLoopbackUrl(url) {
163
+ const host = hostOf(url);
164
+ return host === undefined || LOOPBACK_HOSTS.has(host) || host.startsWith('127.');
165
+ }
166
+ function portOf(url) {
167
+ try {
168
+ const port = new URL(url).port;
169
+ return port ? Number.parseInt(port, 10) : OLLAMA_DEFAULT_PORT;
170
+ }
171
+ catch {
172
+ return OLLAMA_DEFAULT_PORT;
173
+ }
174
+ }
175
+ function providerField(providers, provider, field) {
176
+ const section = providers?.[provider];
177
+ if (!isRecord(section))
178
+ return undefined;
179
+ const value = section[field];
180
+ return typeof value === 'string' && value.trim() !== '' ? value : undefined;
181
+ }
182
+ /** The first directory of a relative path inside the project, undefined for anything else. */
183
+ function topDirectory(path) {
184
+ if (isAbsolute(path))
185
+ return undefined;
186
+ const first = posix.normalize(path.replace(/\\/g, '/')).split('/')[0];
187
+ return first && first !== '..' && first !== '.' ? first : undefined;
188
+ }
189
+ const LOCAL_SPEC = /^(?:file|link):(.+)$/;
190
+ /**
191
+ * Relative paths of the local packages the install reads (`file:` and `link:`
192
+ * specs in dependencies, overrides and resolutions, and in the overrides of
193
+ * pnpm-workspace.yaml), so the image copies them before it installs.
194
+ */
195
+ export function localDependencySpecs(projectDir, pkg) {
196
+ const specs = [];
197
+ const collect = (value) => {
198
+ if (typeof value === 'string') {
199
+ const match = LOCAL_SPEC.exec(value.trim());
200
+ if (match)
201
+ specs.push(match[1]);
202
+ }
203
+ else if (typeof value === 'object' && value !== null) {
204
+ for (const nested of Object.values(value))
205
+ collect(nested);
206
+ }
207
+ };
208
+ for (const field of [
209
+ pkg.dependencies,
210
+ pkg.devDependencies,
211
+ pkg.optionalDependencies,
212
+ pkg.overrides,
213
+ pkg.resolutions,
214
+ ]) {
215
+ collect(field);
216
+ }
217
+ const workspace = join(projectDir, 'pnpm-workspace.yaml');
218
+ if (existsSync(workspace)) {
219
+ try {
220
+ const parsed = parseYaml(readFileSync(workspace, 'utf-8'));
221
+ if (typeof parsed === 'object' && parsed !== null && 'overrides' in parsed)
222
+ collect(parsed.overrides);
223
+ }
224
+ catch {
225
+ return specs;
226
+ }
227
+ }
228
+ return specs;
229
+ }
230
+ /** `.env` never reaches the image (.dockerignore), so a flag that loads it only logs that it is missing. */
231
+ const DOTENV_FLAG = /^--env-file(?:-if-exists)?(?:=|$)/;
232
+ const PLAIN_WORD = /^[^\s&|;<>$`"'*?()]+$/;
233
+ /**
234
+ * `start` as argv when it is `node` or `bun` with flags and one script, such as
235
+ * `node --env-file-if-exists=.env dist/index.js`, and undefined for anything a
236
+ * shell would have to interpret. Flags that load `.env` are dropped: the image
237
+ * gets its environment from the deployment.
238
+ */
239
+ export function directCommand(start) {
240
+ const words = start.split(/\s+/);
241
+ const [runtime, ...rest] = words;
242
+ if (runtime !== 'node' && runtime !== 'bun')
243
+ return undefined;
244
+ if (rest.length === 0 || !rest.every((word) => PLAIN_WORD.test(word)))
245
+ return undefined;
246
+ const script = rest[rest.length - 1];
247
+ if (script.startsWith('-') || rest.slice(0, -1).some((word) => !word.startsWith('--')))
248
+ return undefined;
249
+ return [runtime, ...rest.filter((word) => !DOTENV_FLAG.test(word))];
250
+ }
76
251
  export class ProjectAnalyzer {
77
252
  detectServer(pkg) {
78
- const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
79
253
  for (const [pkgName, server] of Object.entries(SERVER_PACKAGES)) {
80
- if (pkgName in allDeps)
254
+ if (hasDependency(pkg, [pkgName]))
81
255
  return server;
82
256
  }
83
257
  return undefined;
84
258
  }
259
+ /**
260
+ * What the project runs as: a `server` when it serves HTTP through a Cogitator
261
+ * adapter or an HTTP framework, a `worker` when it runs a channel gateway or
262
+ * queue worker, undefined for anything else, such as a script that exits.
263
+ */
264
+ detectKind(pkg, server) {
265
+ if (server || hasDependency(pkg, HTTP_PACKAGES))
266
+ return 'server';
267
+ if (hasDependency(pkg, WORKER_PACKAGES))
268
+ return 'worker';
269
+ return undefined;
270
+ }
85
271
  detectServices(config) {
86
272
  const adapter = config.memory?.adapter;
87
273
  return {
@@ -89,13 +275,21 @@ export class ProjectAnalyzer {
89
275
  postgres: adapter === 'postgres',
90
276
  };
91
277
  }
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))
278
+ /**
279
+ * Variables the provider of `model` cannot run without, routed the way the
280
+ * runtime routes it. Each is the variable set in `env` among the ones that
281
+ * provide the setting, else the provider's own name for it. Settings with a
282
+ * value written in `providers` (cogitator.yml) need none.
283
+ */
284
+ detectSecrets(model, defaultProvider, env = {}, providers) {
285
+ const provider = this.providerOf(model, defaultProvider);
286
+ if (!provider)
97
287
  return [];
98
- return [...PROVIDER_SECRETS[provider]];
288
+ const required = PROVIDER_ENV[provider].filter((setting) => setting.required ||
289
+ (provider === 'ollama' && setting.field === 'apiKey' && this.isOllamaCloud(model)));
290
+ return required
291
+ .filter((setting) => !providerField(providers, provider, setting.field))
292
+ .map((setting) => this.envNameFor(setting, env));
99
293
  }
100
294
  isOllamaCloud(model) {
101
295
  const modelName = model.includes('/') ? model.slice(model.indexOf('/') + 1) : model;
@@ -103,106 +297,392 @@ export class ProjectAnalyzer {
103
297
  }
104
298
  getDeployWarnings(model, target, defaultProvider) {
105
299
  const warnings = [];
106
- const provider = model.includes('/') ? model.split('/')[0] : defaultProvider;
107
- if (provider === 'ollama' && !this.isOllamaCloud(model) && target !== 'docker') {
300
+ if (this.providerOf(model, defaultProvider) === 'ollama' &&
301
+ !this.isOllamaCloud(model) &&
302
+ target !== 'docker') {
108
303
  warnings.push(`Model "${model}" requires local Ollama server. Cloud targets don't include Ollama. ` +
109
304
  'Use a cloud model (e.g. qwen3.5:cloud with OLLAMA_API_KEY), switch to a cloud LLM provider, ' +
110
305
  'or set OLLAMA_HOST to an external Ollama URL.');
111
306
  }
112
307
  return warnings;
113
308
  }
114
- detectPackageManager(projectDir) {
115
- if (existsSync(join(projectDir, 'pnpm-lock.yaml'))) {
309
+ detectPackageManager(projectDir, pkg = readPackageJson(projectDir, [])) {
310
+ const declared = parsePackageManagerField(pkg.packageManager);
311
+ const berry = existsSync(join(projectDir, '.yarnrc.yml')) ||
312
+ (declared?.name === 'yarn' && (majorVersion(declared.version) ?? 1) >= 2);
313
+ const has = (file) => existsSync(join(projectDir, file));
314
+ if (has('pnpm-lock.yaml'))
116
315
  return { packageManager: 'pnpm', hasLockfile: true };
316
+ if (has('bun.lock') || has('bun.lockb'))
317
+ return { packageManager: 'bun', hasLockfile: true };
318
+ if (has('yarn.lock')) {
319
+ const lock = readFileSync(join(projectDir, 'yarn.lock'), 'utf-8');
320
+ const isBerry = berry || /^__metadata:/m.test(lock);
321
+ return { packageManager: isBerry ? 'yarn-berry' : 'yarn', hasLockfile: true };
117
322
  }
118
- if (existsSync(join(projectDir, 'yarn.lock'))) {
119
- return { packageManager: 'yarn', hasLockfile: true };
120
- }
121
- if (existsSync(join(projectDir, 'package-lock.json'))) {
323
+ if (has('package-lock.json') || has('npm-shrinkwrap.json')) {
122
324
  return { packageManager: 'npm', hasLockfile: true };
123
325
  }
326
+ switch (declared?.name) {
327
+ case 'pnpm':
328
+ case 'bun':
329
+ return { packageManager: declared.name, hasLockfile: false };
330
+ case 'yarn':
331
+ return { packageManager: berry ? 'yarn-berry' : 'yarn', hasLockfile: false };
332
+ }
333
+ if (has('pnpm-workspace.yaml'))
334
+ return { packageManager: 'pnpm', hasLockfile: false };
335
+ const start = pkg.scripts?.start?.trim() ?? '';
336
+ if (hasDependency(pkg, BUN_PACKAGES) || /^bun(?:x)?\s/.test(start)) {
337
+ return { packageManager: 'bun', hasLockfile: false };
338
+ }
124
339
  return { packageManager: 'npm', hasLockfile: false };
125
340
  }
126
- detectStartCommand(pkg, hasTypeScript) {
341
+ /**
342
+ * The command a container runs: the start script itself when it is a plain
343
+ * `node` or `bun` call, so the runtime is PID 1's direct child and gets
344
+ * signals without a package manager in between, else the package manager.
345
+ */
346
+ detectStartCommand(pkg, hasTypeScript, packageManager = 'npm') {
347
+ const bun = packageManager === 'bun';
127
348
  const start = pkg.scripts?.start?.trim();
128
349
  if (start) {
129
- const nodeScript = /^node\s+([^\s&|;<>$`"']+)$/.exec(start);
130
- return nodeScript ? ['node', nodeScript[1]] : ['npm', 'start'];
350
+ const direct = directCommand(start);
351
+ if (direct)
352
+ return direct;
353
+ if (bun)
354
+ return ['bun', 'run', 'start'];
355
+ if (packageManager === 'yarn-berry')
356
+ return ['yarn', 'start'];
357
+ return ['npm', 'start'];
131
358
  }
359
+ const runtime = bun ? 'bun' : 'node';
132
360
  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
- }
361
+ return [runtime, pkg.main];
362
+ return [runtime, hasTypeScript ? 'dist/server.js' : 'src/server.js'];
147
363
  }
148
364
  /** How the project in `projectDir` builds and starts. */
149
365
  detectBuild(projectDir) {
150
366
  return this.buildOf(projectDir, readPackageJson(projectDir, []));
151
367
  }
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
368
  /**
162
369
  * Reads the project in `projectDir` into a deploy config: what it detects,
163
- * under the `deploy` section of the project's cogitator.yml, under
370
+ * under the `deploy` section of the project's config file, under
164
371
  * `configOverrides`.
165
372
  */
166
- analyze(projectDir, configOverrides) {
373
+ analyze(projectDir, configOverrides, options = {}) {
167
374
  const warnings = [];
375
+ const checks = [];
376
+ const env = options.env ?? resolveDeployEnv(projectDir);
377
+ const hasPackageJson = existsSync(join(projectDir, 'package.json'));
168
378
  const pkg = readPackageJson(projectDir, warnings);
169
- const fullConfig = this.loadProjectConfig(projectDir, warnings);
379
+ const configPath = options.configPath ?? findConfigFile(projectDir);
380
+ const fullConfig = this.loadProjectConfig(configPath, warnings);
170
381
  const configured = mergeDeployConfig(fullConfig?.deploy, configOverrides);
171
382
  const server = configured.server ?? this.detectServer(pkg);
383
+ const kind = configured.kind ?? (configured.health?.path ? 'server' : this.detectKind(pkg, server));
172
384
  const target = configured.target ?? 'docker';
173
385
  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(' ')}"`);
386
+ checks.push(this.packageJsonCheck(projectDir, hasPackageJson));
387
+ checks.push(this.kindCheck(kind));
388
+ const envFileCheck = this.startScriptCheck(pkg);
389
+ if (envFileCheck)
390
+ checks.push(envFileCheck);
391
+ if (hasPackageJson && !pkg.scripts?.start && !pkg.main) {
392
+ warnings.push(`No "start" script or "main" in package.json, the container will run "${build.startCommand.join(' ')}"`);
393
+ }
394
+ if (build.hasTypeScript && !build.hasBuildScript) {
395
+ warnings.push('tsconfig.json found but package.json has no "build" script, skipping build step');
177
396
  }
178
- if (hasTypeScript && !hasBuildScript) {
179
- warnings.push('tsconfig.json found but package.json has no "build" script — skipping build step');
397
+ if (this.isPnpmMonorepo(projectDir)) {
398
+ warnings.push('pnpm-workspace.yaml lists workspace packages: only this directory goes into the image, so deploy a single package');
180
399
  }
181
400
  const model = fullConfig?.llm?.defaultModel ?? '';
182
401
  const defaultProvider = fullConfig?.llm?.defaultProvider;
402
+ const rawProviders = this.literalProviders(configPath);
183
403
  const services = configured.services ??
184
404
  (fullConfig ? this.detectServices(fullConfig) : { redis: false, postgres: false });
185
- const secrets = configured.secrets ?? (model ? this.detectSecrets(model, defaultProvider) : []);
405
+ const appSecrets = configured.secrets ??
406
+ unique([
407
+ ...(model || defaultProvider
408
+ ? this.detectSecrets(model, defaultProvider, env, rawProviders)
409
+ : []),
410
+ ...this.optionalProviderSecrets(model, defaultProvider, env),
411
+ ...requiredEnvNames(projectDir),
412
+ ...envFileKeys(join(projectDir, '.env.example')).filter((key) => isSet(env, key)),
413
+ ]);
414
+ const ollama = this.ollamaWiring(model, defaultProvider, target, env, configPath, warnings);
415
+ const infraSecrets = [
416
+ ...ollama.secrets,
417
+ ...(target === 'fly' ? this.flyServiceSecrets(services, warnings) : []),
418
+ ];
419
+ const secrets = unique([...appSecrets, ...infraSecrets]);
420
+ if (configured.secrets === undefined && secrets.length === 0) {
421
+ const local = envFileKeys(join(projectDir, '.env'));
422
+ if (local.length > 0) {
423
+ checks.push({
424
+ name: 'Secrets',
425
+ passed: false,
426
+ message: `No secrets detected, but .env defines ${local.join(', ')}: the deployment would start without them`,
427
+ fix: 'List the variables the app needs in deploy.secrets in cogitator.yml (an empty list deploys without any)',
428
+ });
429
+ }
430
+ }
431
+ if (secrets.some((name) => name.endsWith('SESSION_TOKEN') && /AWS|BEDROCK/.test(name))) {
432
+ 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.');
433
+ }
186
434
  if (model)
187
435
  warnings.push(...this.getDeployWarnings(model, target, defaultProvider));
188
436
  if (target === 'docker' && (configured.instances ?? 1) > 1) {
189
- warnings.push('instances > 1 is not supported for the docker target; running one container');
437
+ warnings.push('instances > 1 is not supported for the docker target, running one container');
190
438
  }
439
+ const volumes = configured.volumes ?? this.detectVolumes(fullConfig, warnings);
440
+ if (volumes && target === 'fly') {
441
+ if (volumes.length > 1) {
442
+ checks.push({
443
+ name: 'Volumes',
444
+ passed: false,
445
+ message: `Fly machines mount one volume, ${volumes.length} are configured`,
446
+ fix: 'Keep the data the app writes under one directory and list only it in deploy.volumes',
447
+ });
448
+ }
449
+ if ((configured.instances ?? 1) > 1) {
450
+ warnings.push('Each Fly machine gets its own volume: with instances > 1 the machines do not share the data on it');
451
+ }
452
+ }
453
+ const port = kind === 'worker' ? configured.port : (configured.port ?? 3000);
191
454
  const image = configured.image ?? sanitizeName(pkg.name ?? '');
455
+ const deployConfig = {
456
+ ...configured,
457
+ server,
458
+ kind,
459
+ image,
460
+ services,
461
+ secrets,
462
+ port,
463
+ ...(volumes && volumes.length > 0 ? { volumes } : {}),
464
+ ...(ollama.env ? { env: { ...ollama.env, ...configured.env }, hostGateway: true } : {}),
465
+ };
466
+ if (deployConfig.env === undefined)
467
+ delete deployConfig.env;
468
+ if (deployConfig.volumes === undefined)
469
+ delete deployConfig.volumes;
470
+ if (kind === 'server' && !healthPath(deployConfig)) {
471
+ 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');
472
+ }
192
473
  return {
193
474
  server,
475
+ kind,
194
476
  services,
195
477
  secrets,
196
478
  warnings,
479
+ checks,
197
480
  ...build,
198
- deployConfig: {
199
- ...configured,
200
- server,
201
- image,
202
- services,
203
- secrets,
204
- port: configured.port ?? 3000,
205
- },
481
+ deployConfig,
482
+ };
483
+ }
484
+ providerOf(model, defaultProvider) {
485
+ const { provider } = model
486
+ ? resolveModelRoute(model, { defaultProvider })
487
+ : { provider: defaultProvider ?? '' };
488
+ return isLLMProvider(provider) ? provider : undefined;
489
+ }
490
+ envNameFor(setting, env) {
491
+ return providerEnvNames(setting).find((name) => isSet(env, name)) ?? preferredEnvName(setting);
492
+ }
493
+ /** Optional settings of the model's provider set in `env`, forwarded so the app sees them too. */
494
+ optionalProviderSecrets(model, defaultProvider, env) {
495
+ const provider = this.providerOf(model, defaultProvider);
496
+ if (!provider)
497
+ return [];
498
+ return PROVIDER_ENV[provider]
499
+ .filter((setting) => !setting.required && !setting.local)
500
+ .map((setting) => providerEnvNames(setting).find((name) => isSet(env, name)))
501
+ .filter((name) => name !== undefined);
502
+ }
503
+ /**
504
+ * How a local Ollama is reached from the deployment. A URL that already
505
+ * points beyond this machine is forwarded. Otherwise the docker target
506
+ * reaches the host through `host.docker.internal`, and other targets warn.
507
+ */
508
+ ollamaWiring(model, defaultProvider, target, env, configPath, warnings) {
509
+ if (this.providerOf(model, defaultProvider) !== 'ollama' || this.isOllamaCloud(model)) {
510
+ return { secrets: [] };
511
+ }
512
+ const setting = PROVIDER_ENV.ollama.find((s) => s.field === 'baseUrl');
513
+ const overrideNames = setting?.env ?? [];
514
+ const fallbackNames = setting?.fallbackEnv ?? [];
515
+ const firstUrl = (names) => names.map((name) => resolveOllamaHost(env[name])).find((url) => url !== undefined);
516
+ const configured = this.yamlInput(configPath, env)?.llm?.providers?.ollama?.baseUrl;
517
+ const url = firstUrl(overrideNames) ?? resolveOllamaHost(configured) ?? firstUrl(fallbackNames);
518
+ if (url && !isLoopbackUrl(url)) {
519
+ const forwarded = [...overrideNames, ...fallbackNames].filter((name) => {
520
+ const value = resolveOllamaHost(env[name]);
521
+ return value !== undefined && !isLoopbackUrl(value);
522
+ });
523
+ return { secrets: forwarded };
524
+ }
525
+ if (target !== 'docker')
526
+ return { secrets: [] };
527
+ const hostUrl = `http://${DOCKER_HOST_GATEWAY}:${url ? portOf(url) : OLLAMA_DEFAULT_PORT}`;
528
+ warnings.push(`Model "${model}" runs on the Ollama of this machine: the container reaches it at ${hostUrl}. ` +
529
+ 'On Linux, Ollama must listen beyond localhost (OLLAMA_HOST=0.0.0.0 ollama serve), ' +
530
+ `and the model must be pulled (ollama pull ${model.replace(/^ollama\//, '')}).`);
531
+ return {
532
+ secrets: [],
533
+ env: Object.fromEntries([...overrideNames, ...fallbackNames].map((name) => [name, hostUrl])),
534
+ };
535
+ }
536
+ flyServiceSecrets(services, warnings) {
537
+ const secrets = [];
538
+ if (services.redis) {
539
+ secrets.push('REDIS_URL');
540
+ 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');
541
+ }
542
+ if (services.postgres) {
543
+ secrets.push('DATABASE_URL');
544
+ warnings.push('Fly runs no Postgres next to the app: create one (fly postgres create, or any Postgres) and set DATABASE_URL');
545
+ }
546
+ return secrets;
547
+ }
548
+ detectVolumes(config, warnings) {
549
+ if (config?.memory?.adapter !== 'sqlite')
550
+ return undefined;
551
+ const dbPath = config.memory.sqlite?.path;
552
+ if (!dbPath) {
553
+ 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');
554
+ return undefined;
555
+ }
556
+ if (dbPath === ':memory:')
557
+ return undefined;
558
+ const dir = posix.dirname(posix.normalize(dbPath.replace(/\\/g, '/')));
559
+ if (dir === '.' || dir === '/') {
560
+ 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`);
561
+ return undefined;
562
+ }
563
+ const path = dir.startsWith('..') ? posix.join(APP_DIR, dir) : dir.replace(/^\.\//, '');
564
+ return [{ path }];
565
+ }
566
+ packageJsonCheck(projectDir, exists) {
567
+ return exists
568
+ ? { name: 'package.json', passed: true, message: 'package.json found' }
569
+ : {
570
+ name: 'package.json',
571
+ passed: false,
572
+ message: `No package.json in ${projectDir}`,
573
+ 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',
574
+ };
575
+ }
576
+ kindCheck(kind) {
577
+ return kind
578
+ ? { name: 'Project kind', passed: true, message: `Deploys as a ${kind}` }
579
+ : {
580
+ name: 'Project kind',
581
+ passed: false,
582
+ message: 'No HTTP server or long-running worker found: a script that exits after one run would be restarted forever',
583
+ 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',
584
+ };
585
+ }
586
+ startScriptCheck(pkg) {
587
+ const match = ENV_FILE_FLAG.exec(pkg.scripts?.start ?? '');
588
+ if (!match)
589
+ return undefined;
590
+ return {
591
+ name: 'Start script environment',
592
+ passed: false,
593
+ 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`,
594
+ fix: `Use --env-file-if-exists=${match[1]} (Node 22.9+, tsx), the deployment passes the variables through the environment`,
595
+ };
596
+ }
597
+ isPnpmMonorepo(projectDir) {
598
+ const path = join(projectDir, 'pnpm-workspace.yaml');
599
+ return existsSync(path) && /^packages\s*:/m.test(readFileSync(path, 'utf-8'));
600
+ }
601
+ loadProjectConfig(configPath, warnings) {
602
+ if (!configPath)
603
+ return undefined;
604
+ try {
605
+ return loadConfig({ configPath, skipEnv: true });
606
+ }
607
+ catch (error) {
608
+ warnings.push(`Ignoring invalid ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
609
+ return undefined;
610
+ }
611
+ }
612
+ yamlInput(configPath, env) {
613
+ if (!configPath || !existsSync(configPath))
614
+ return undefined;
615
+ try {
616
+ return loadYamlConfig(configPath, env) ?? undefined;
617
+ }
618
+ catch {
619
+ return undefined;
620
+ }
621
+ }
622
+ /** `llm.providers` of the config file with every `${VAR}` reference left empty: the values written in it. */
623
+ literalProviders(configPath) {
624
+ const providers = this.yamlInput(configPath, {})?.llm?.providers;
625
+ return isRecord(providers) ? providers : undefined;
626
+ }
627
+ installFiles(projectDir, pkg, pm) {
628
+ const candidates = {
629
+ pnpm: ['pnpm-lock.yaml', 'pnpm-workspace.yaml', '.pnpmfile.cjs'],
630
+ npm: ['package-lock.json', 'npm-shrinkwrap.json'],
631
+ yarn: ['yarn.lock', '.yarnrc'],
632
+ 'yarn-berry': ['yarn.lock', '.yarnrc.yml', '.yarn'],
633
+ bun: ['bun.lock', 'bun.lockb', 'bunfig.toml'],
634
+ };
635
+ const isDir = (dir) => dir !== undefined;
636
+ const patchDirs = Object.values(pkg.patchedDependencies ?? {})
637
+ .map(topDirectory)
638
+ .filter(isDir);
639
+ const localDirs = localDependencySpecs(projectDir, pkg).map(topDirectory).filter(isDir);
640
+ return unique([...candidates[pm], '.npmrc', 'patches', ...patchDirs, ...localDirs])
641
+ .filter((file) => existsSync(join(projectDir, file)))
642
+ .map((file) => (statSync(join(projectDir, file)).isDirectory() ? `${file}/` : file));
643
+ }
644
+ /**
645
+ * Plug'n'Play settings of a Yarn 2+ project, `undefined` when it installs
646
+ * node_modules. PnP is Yarn's default linker, and Yarn writes the ESM loader
647
+ * when it is asked to or when a workspace is an ES module.
648
+ */
649
+ detectPlugAndPlay(projectDir, pkg) {
650
+ const rcPath = join(projectDir, '.yarnrc.yml');
651
+ let rc = {};
652
+ if (existsSync(rcPath)) {
653
+ try {
654
+ const parsed = parseYaml(readFileSync(rcPath, 'utf-8'));
655
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
656
+ rc = parsed;
657
+ }
658
+ catch {
659
+ rc = {};
660
+ }
661
+ }
662
+ const linker = typeof rc.nodeLinker === 'string' ? rc.nodeLinker : 'pnp';
663
+ if (linker !== 'pnp')
664
+ return undefined;
665
+ return {
666
+ esmLoader: rc.pnpEnableEsmLoader === true ||
667
+ pkg.type === 'module' ||
668
+ existsSync(join(projectDir, '.pnp.loader.mjs')),
669
+ };
670
+ }
671
+ buildOf(projectDir, pkg) {
672
+ const hasTypeScript = existsSync(join(projectDir, 'tsconfig.json'));
673
+ const pm = this.detectPackageManager(projectDir, pkg);
674
+ const declared = parsePackageManagerField(pkg.packageManager);
675
+ const binary = pm.packageManager === 'yarn-berry' ? 'yarn' : pm.packageManager;
676
+ const version = declared?.name === binary ? declared.version : undefined;
677
+ const plugAndPlay = pm.packageManager === 'yarn-berry' ? this.detectPlugAndPlay(projectDir, pkg) : undefined;
678
+ return {
679
+ hasTypeScript,
680
+ ...pm,
681
+ hasBuildScript: typeof pkg.scripts?.build === 'string',
682
+ startCommand: this.detectStartCommand(pkg, hasTypeScript, pm.packageManager),
683
+ installFiles: this.installFiles(projectDir, pkg, pm.packageManager),
684
+ ...(version ? { packageManagerVersion: version } : {}),
685
+ ...(plugAndPlay && { plugAndPlay }),
206
686
  };
207
687
  }
208
688
  }