@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.
- package/README.md +66 -31
- package/dist/analyzer.d.ts +91 -9
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +573 -93
- package/dist/analyzer.js.map +1 -1
- package/dist/deployer.d.ts +15 -2
- package/dist/deployer.d.ts.map +1 -1
- package/dist/deployer.js +18 -8
- package/dist/deployer.js.map +1 -1
- package/dist/generator.d.ts +10 -2
- package/dist/generator.d.ts.map +1 -1
- package/dist/generator.js +15 -3
- package/dist/generator.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/providers/artifacts.d.ts.map +1 -1
- package/dist/providers/artifacts.js +2 -1
- package/dist/providers/artifacts.js.map +1 -1
- package/dist/providers/docker.d.ts +17 -0
- package/dist/providers/docker.d.ts.map +1 -1
- package/dist/providers/docker.js +63 -8
- package/dist/providers/docker.js.map +1 -1
- package/dist/providers/fly.d.ts +2 -0
- package/dist/providers/fly.d.ts.map +1 -1
- package/dist/providers/fly.js +43 -2
- package/dist/providers/fly.js.map +1 -1
- package/dist/templates/docker-compose.d.ts.map +1 -1
- package/dist/templates/docker-compose.js +23 -15
- package/dist/templates/docker-compose.js.map +1 -1
- package/dist/templates/dockerfile.d.ts +31 -2
- package/dist/templates/dockerfile.d.ts.map +1 -1
- package/dist/templates/dockerfile.js +144 -45
- package/dist/templates/dockerfile.js.map +1 -1
- package/dist/templates/fly-toml.d.ts.map +1 -1
- package/dist/templates/fly-toml.js +65 -31
- package/dist/templates/fly-toml.js.map +1 -1
- package/dist/templates/health.d.ts +9 -2
- package/dist/templates/health.d.ts.map +1 -1
- package/dist/templates/health.js +24 -2
- package/dist/templates/health.js.map +1 -1
- package/dist/utils/text.d.ts +9 -0
- package/dist/utils/text.d.ts.map +1 -0
- package/dist/utils/text.js +19 -0
- package/dist/utils/text.js.map +1 -0
- package/dist/volumes.d.ts +10 -0
- package/dist/volumes.d.ts.map +1 -0
- package/dist/volumes.js +21 -0
- package/dist/volumes.js.map +1 -0
- package/package.json +6 -4
package/dist/analyzer.js
CHANGED
|
@@ -1,31 +1,46 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
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 (
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 (
|
|
75
|
+
if (!isRecord(raw))
|
|
61
76
|
return {};
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
130
|
-
|
|
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 [
|
|
134
|
-
return [
|
|
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
|
|
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
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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 (
|
|
179
|
-
warnings.push('
|
|
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
|
|
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
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
}
|