vyft 0.3.0-alpha → 0.4.0-alpha
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/dist/build.d.ts +1 -0
- package/dist/build.js +9 -4
- package/dist/cli.js +491 -47
- package/dist/docker.d.ts +10 -3
- package/dist/docker.js +173 -91
- package/dist/exec.d.ts +1 -1
- package/dist/exec.js +2 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/local/dev.d.ts +31 -0
- package/dist/local/dev.js +109 -0
- package/dist/local/index.d.ts +2 -0
- package/dist/local/index.js +2 -0
- package/dist/local/runtime.d.ts +61 -0
- package/dist/local/runtime.js +391 -0
- package/dist/resource.d.ts +7 -0
- package/dist/runtime.d.ts +7 -1
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.js +23 -8
- package/dist/symbols.d.ts +2 -0
- package/dist/symbols.js +2 -0
- package/package.json +3 -1
- package/templates/fullstack/package.json +2 -6
- package/templates/fullstack/compose.yaml +0 -14
package/dist/build.d.ts
CHANGED
package/dist/build.js
CHANGED
|
@@ -25,10 +25,15 @@ export async function buildStatic(context, options, log) {
|
|
|
25
25
|
if (buildCommand) {
|
|
26
26
|
const start = performance.now();
|
|
27
27
|
log?.info({ command: buildCommand, context: buildContext }, "static build started");
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
if (!options.silent) {
|
|
29
|
+
const s = spinner();
|
|
30
|
+
s.start(`Building static site (${buildCommand})`);
|
|
31
|
+
await exec(buildCommand, buildContext, options.env, log);
|
|
32
|
+
s.stop("Build complete");
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
await exec(buildCommand, buildContext, options.env, log, true);
|
|
36
|
+
}
|
|
32
37
|
const durationMs = Math.round(performance.now() - start);
|
|
33
38
|
log?.info({ command: buildCommand, context: buildContext, durationMs }, "static build completed");
|
|
34
39
|
}
|
package/dist/cli.js
CHANGED
|
@@ -2,20 +2,17 @@
|
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { access, readFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { log, spinner } from "@clack/prompts";
|
|
6
6
|
import { Command } from "commander";
|
|
7
7
|
import pc from "picocolors";
|
|
8
8
|
import { getCurrentContextName, listContexts, removeContext, resolveContext, saveContext, setCurrentContext, } from "./context.js";
|
|
9
9
|
import { DockerClient } from "./docker.js";
|
|
10
10
|
import { init } from "./init.js";
|
|
11
|
+
import { DevRunner, detectPackageManager, LocalRuntime, resolveDevEnv, } from "./local/index.js";
|
|
11
12
|
import { logger } from "./logger.js";
|
|
13
|
+
import { isInterpolation, isReference } from "./resource.js";
|
|
14
|
+
import { isManaged } from "./services/index.js";
|
|
12
15
|
import { VYFT_RUNTIME } from "./symbols.js";
|
|
13
|
-
const DEPLOY_ORDER = {
|
|
14
|
-
secret: 0,
|
|
15
|
-
volume: 1,
|
|
16
|
-
service: 2,
|
|
17
|
-
site: 3,
|
|
18
|
-
};
|
|
19
16
|
const DESTROY_ORDER = {
|
|
20
17
|
site: 0,
|
|
21
18
|
service: 1,
|
|
@@ -111,57 +108,246 @@ function createRuntime(resources, project, sessionLogger) {
|
|
|
111
108
|
}
|
|
112
109
|
return new DockerClient(project, { host, parentLogger: sessionLogger });
|
|
113
110
|
}
|
|
111
|
+
function stripPrefix(id, project) {
|
|
112
|
+
const prefix = `${project}-`;
|
|
113
|
+
return id.startsWith(prefix) ? id.slice(prefix.length) : id;
|
|
114
|
+
}
|
|
115
|
+
/** Limits concurrent async work to avoid exhausting SSH channels. */
|
|
116
|
+
function createSemaphore(limit) {
|
|
117
|
+
let active = 0;
|
|
118
|
+
const queue = [];
|
|
119
|
+
return {
|
|
120
|
+
async acquire() {
|
|
121
|
+
if (active < limit) {
|
|
122
|
+
active++;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
await new Promise((r) => queue.push(r));
|
|
126
|
+
},
|
|
127
|
+
release() {
|
|
128
|
+
const next = queue.shift();
|
|
129
|
+
if (next) {
|
|
130
|
+
next();
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
active--;
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const MAX_CONCURRENCY = 6;
|
|
139
|
+
async function executeGraph(tasks, onStart, onComplete) {
|
|
140
|
+
const taskMap = new Map(tasks.map((t) => [t.id, t]));
|
|
141
|
+
const promises = new Map();
|
|
142
|
+
const sem = createSemaphore(MAX_CONCURRENCY);
|
|
143
|
+
function resolve(id) {
|
|
144
|
+
if (promises.has(id))
|
|
145
|
+
return promises.get(id);
|
|
146
|
+
const task = taskMap.get(id);
|
|
147
|
+
const p = (async () => {
|
|
148
|
+
await Promise.all(task.deps.map((d) => resolve(d)));
|
|
149
|
+
await sem.acquire();
|
|
150
|
+
try {
|
|
151
|
+
onStart(task.id);
|
|
152
|
+
const result = await task.run();
|
|
153
|
+
onComplete(task.id, result);
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
sem.release();
|
|
157
|
+
}
|
|
158
|
+
})();
|
|
159
|
+
promises.set(id, p);
|
|
160
|
+
return p;
|
|
161
|
+
}
|
|
162
|
+
await Promise.all(tasks.map((t) => resolve(t.id)));
|
|
163
|
+
}
|
|
164
|
+
function getServiceDeps(svc, resources) {
|
|
165
|
+
const deps = [];
|
|
166
|
+
const resourceIds = new Set(resources.map((r) => r.id));
|
|
167
|
+
for (const dep of svc.config.dependsOn ?? [])
|
|
168
|
+
deps.push(dep.id);
|
|
169
|
+
if (typeof svc.config.image === "object")
|
|
170
|
+
deps.push(`build:${svc.id}`);
|
|
171
|
+
for (const { volume } of svc.config.volumes ?? []) {
|
|
172
|
+
if (resourceIds.has(volume.id))
|
|
173
|
+
deps.push(volume.id);
|
|
174
|
+
}
|
|
175
|
+
if (svc.config.env) {
|
|
176
|
+
for (const value of Object.values(svc.config.env)) {
|
|
177
|
+
if (isReference(value)) {
|
|
178
|
+
if (resourceIds.has(value.id))
|
|
179
|
+
deps.push(value.id);
|
|
180
|
+
}
|
|
181
|
+
else if (isInterpolation(value)) {
|
|
182
|
+
for (const v of value.values) {
|
|
183
|
+
if (isReference(v) && resourceIds.has(v.id))
|
|
184
|
+
deps.push(v.id);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return deps;
|
|
190
|
+
}
|
|
191
|
+
function formatDuration(ms) {
|
|
192
|
+
if (ms < 1000)
|
|
193
|
+
return `${ms}ms`;
|
|
194
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
195
|
+
}
|
|
114
196
|
async function deploy(configFile, verbose) {
|
|
115
197
|
const sessionId = randomBytes(4).toString("hex");
|
|
116
198
|
const sessionLog = logger.child({ sessionId, command: "deploy" });
|
|
117
199
|
const start = performance.now();
|
|
118
200
|
const project = await findProjectName(configFile);
|
|
119
201
|
process.env.VYFT_PROJECT = project;
|
|
120
|
-
|
|
202
|
+
log.step(`Deploying ${pc.bold(project)}`);
|
|
121
203
|
const config = await import(configFile);
|
|
122
204
|
const resources = collectResources(config);
|
|
123
205
|
if (resources.length === 0) {
|
|
124
206
|
log.warn("No resources found");
|
|
125
|
-
outro();
|
|
126
207
|
return;
|
|
127
208
|
}
|
|
128
209
|
sessionLog.info({ project, resourceCount: resources.length }, "deploy started");
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
const ordered = [...nonServices, ...sorted];
|
|
210
|
+
const docker = createRuntime(resources, project, sessionLog);
|
|
211
|
+
docker.verbose = verbose;
|
|
212
|
+
await docker.ensureInfrastructure();
|
|
213
|
+
const currentResources = await docker.listManagedResources();
|
|
214
|
+
const silent = !verbose;
|
|
135
215
|
// Collect which service IDs have dependents waiting on them
|
|
216
|
+
const services = resources.filter((r) => r.type === "service");
|
|
136
217
|
const depTargets = new Set();
|
|
137
218
|
for (const svc of services) {
|
|
138
219
|
for (const dep of svc.config.dependsOn ?? []) {
|
|
139
220
|
depTargets.add(dep.id);
|
|
140
221
|
}
|
|
141
222
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
223
|
+
// Build DAG tasks
|
|
224
|
+
const tasks = [];
|
|
225
|
+
// Secrets & volumes: no deps, check existence first
|
|
226
|
+
for (const r of resources) {
|
|
227
|
+
if (r.type === "secret" || r.type === "volume") {
|
|
228
|
+
tasks.push({
|
|
229
|
+
id: r.id,
|
|
230
|
+
deps: [],
|
|
231
|
+
run: async () => {
|
|
232
|
+
const name = stripPrefix(r.id, project);
|
|
233
|
+
if (await docker.exists(r)) {
|
|
234
|
+
return { name, action: "updated", unchanged: true, duration: 0 };
|
|
235
|
+
}
|
|
236
|
+
const result = await docker.create(r);
|
|
237
|
+
return { name, ...result, unchanged: false };
|
|
238
|
+
},
|
|
239
|
+
});
|
|
153
240
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
241
|
+
}
|
|
242
|
+
// Image builds: no deps
|
|
243
|
+
for (const svc of services) {
|
|
244
|
+
if (typeof svc.config.image === "object") {
|
|
245
|
+
tasks.push({
|
|
246
|
+
id: `build:${svc.id}`,
|
|
247
|
+
deps: [],
|
|
248
|
+
run: async () => {
|
|
249
|
+
const name = stripPrefix(svc.id, project);
|
|
250
|
+
const buildStart = performance.now();
|
|
251
|
+
await docker.buildImage(svc);
|
|
252
|
+
const duration = Math.round(performance.now() - buildStart);
|
|
253
|
+
return { name, action: "created", unchanged: false, duration };
|
|
254
|
+
},
|
|
255
|
+
});
|
|
157
256
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
257
|
+
}
|
|
258
|
+
// Services: depend on their secrets, volumes, image builds, and dependsOn
|
|
259
|
+
for (const svc of services) {
|
|
260
|
+
const deps = getServiceDeps(svc, resources);
|
|
261
|
+
tasks.push({
|
|
262
|
+
id: svc.id,
|
|
263
|
+
deps,
|
|
264
|
+
run: async () => {
|
|
265
|
+
const name = stripPrefix(svc.id, project);
|
|
266
|
+
const result = await docker.create(svc);
|
|
267
|
+
if (depTargets.has(svc.id)) {
|
|
268
|
+
await docker.waitForHealthy(svc.id);
|
|
269
|
+
}
|
|
270
|
+
return { name, ...result, unchanged: false };
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
// Sites: no deps (buildStatic runs internally)
|
|
275
|
+
for (const r of resources) {
|
|
276
|
+
if (r.type === "site") {
|
|
277
|
+
tasks.push({
|
|
278
|
+
id: r.id,
|
|
279
|
+
deps: [],
|
|
280
|
+
run: async () => {
|
|
281
|
+
const name = stripPrefix(r.id, project);
|
|
282
|
+
const result = await docker.create(r, { silent });
|
|
283
|
+
return { name, ...result, unchanged: false };
|
|
284
|
+
},
|
|
285
|
+
});
|
|
161
286
|
}
|
|
162
287
|
}
|
|
288
|
+
// Execute the graph
|
|
289
|
+
const results = [];
|
|
290
|
+
const inProgress = new Set();
|
|
291
|
+
if (verbose) {
|
|
292
|
+
// Verbose: print each item as it completes
|
|
293
|
+
await executeGraph(tasks, () => { }, (id, result) => {
|
|
294
|
+
results.push(result);
|
|
295
|
+
if (id.startsWith("build:")) {
|
|
296
|
+
log.success(`${pc.bold(result.name)} ${pc.dim("built")} ${pc.dim(formatDuration(result.duration))}`);
|
|
297
|
+
}
|
|
298
|
+
else if (result.unchanged) {
|
|
299
|
+
log.step(`${pc.bold(result.name)} ${pc.dim("exists")}`);
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
const route = result.route
|
|
303
|
+
? ` ${pc.dim("\u2192")} ${result.route}`
|
|
304
|
+
: "";
|
|
305
|
+
log.success(`${pc.bold(result.name)} ${pc.dim(result.action)}${route}`);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
// Normal mode: single spinner, update message with in-progress tasks
|
|
311
|
+
const s = spinner();
|
|
312
|
+
function updateSpinner() {
|
|
313
|
+
const active = [...inProgress].map((id) => id.startsWith("build:")
|
|
314
|
+
? `Building ${stripPrefix(id.slice(6), project)}`
|
|
315
|
+
: `deploying ${stripPrefix(id, project)}`);
|
|
316
|
+
if (active.length === 0)
|
|
317
|
+
return;
|
|
318
|
+
const first = active[0];
|
|
319
|
+
const rest = active.length > 1 ? ` · ${active.slice(1).join(", ")}` : "";
|
|
320
|
+
s.message(`${first}${rest}`);
|
|
321
|
+
}
|
|
322
|
+
s.start("Deploying");
|
|
323
|
+
await executeGraph(tasks, (id) => {
|
|
324
|
+
// Skip build tasks from spinner display (they show as "Building X")
|
|
325
|
+
inProgress.add(id);
|
|
326
|
+
updateSpinner();
|
|
327
|
+
}, (id, result) => {
|
|
328
|
+
results.push(result);
|
|
329
|
+
inProgress.delete(id);
|
|
330
|
+
updateSpinner();
|
|
331
|
+
});
|
|
332
|
+
s.stop("Deployed");
|
|
333
|
+
}
|
|
334
|
+
// Summary
|
|
335
|
+
const unchanged = results.filter((r) => r.unchanged);
|
|
336
|
+
const changed = results.filter((r) => !r.unchanged && !r.name.startsWith("build:"));
|
|
337
|
+
if (unchanged.length > 0) {
|
|
338
|
+
log.message(`${unchanged.length} resource${unchanged.length === 1 ? "" : "s"} unchanged`);
|
|
339
|
+
}
|
|
340
|
+
if (!verbose && changed.length > 0) {
|
|
341
|
+
const nameWidth = Math.max(...changed.map((r) => r.name.length));
|
|
342
|
+
const actionWidth = Math.max(...changed.map((r) => r.action.length));
|
|
343
|
+
const lines = changed.map((r) => {
|
|
344
|
+
const route = r.route ? ` ${pc.dim("\u2192")} ${r.route}` : "";
|
|
345
|
+
return `${pc.bold(r.name.padEnd(nameWidth))} ${pc.dim(r.action.padEnd(actionWidth))}${route}`;
|
|
346
|
+
});
|
|
347
|
+
log.message(lines.join("\n"));
|
|
348
|
+
}
|
|
349
|
+
// Removal phase
|
|
163
350
|
const desiredIds = new Set(resources.map((r) => r.id));
|
|
164
|
-
// Keep derived secrets whose parent service is still desired
|
|
165
351
|
for (const r of currentResources) {
|
|
166
352
|
if (r.derived && r.parentService && desiredIds.has(r.parentService)) {
|
|
167
353
|
desiredIds.add(r.id);
|
|
@@ -175,20 +361,221 @@ async function deploy(configFile, verbose) {
|
|
|
175
361
|
const resource = { id, type };
|
|
176
362
|
if (await docker.exists(resource)) {
|
|
177
363
|
await docker.remove(resource);
|
|
364
|
+
const name = stripPrefix(id, project);
|
|
365
|
+
if (verbose) {
|
|
366
|
+
log.step(`${pc.bold(name)} ${pc.dim("removed")}`);
|
|
367
|
+
}
|
|
178
368
|
removed++;
|
|
179
369
|
}
|
|
180
370
|
}
|
|
371
|
+
if (!verbose && removed > 0) {
|
|
372
|
+
log.message(`${removed} resource${removed === 1 ? "" : "s"} removed`);
|
|
373
|
+
}
|
|
181
374
|
}
|
|
182
375
|
const durationMs = Math.round(performance.now() - start);
|
|
183
|
-
|
|
184
|
-
|
|
376
|
+
const created = changed.length;
|
|
377
|
+
sessionLog.info({ project, created, removed, skipped: unchanged.length, durationMs }, "deploy completed");
|
|
378
|
+
docker.destroy();
|
|
379
|
+
log.step(pc.dim(formatDuration(durationMs)));
|
|
380
|
+
}
|
|
381
|
+
async function dev(configFile, verbose) {
|
|
382
|
+
const sessionId = randomBytes(4).toString("hex");
|
|
383
|
+
const sessionLog = logger.child({ sessionId, command: "dev" });
|
|
384
|
+
const project = await findProjectName(configFile);
|
|
385
|
+
process.env.VYFT_PROJECT = project;
|
|
386
|
+
const projectRoot = path.dirname(configFile);
|
|
387
|
+
const config = await import(configFile);
|
|
388
|
+
const resources = collectResources(config);
|
|
389
|
+
if (resources.length === 0) {
|
|
390
|
+
log.warn("No resources found");
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
log.step("vyft dev");
|
|
394
|
+
// Partition resources
|
|
395
|
+
const managedSecrets = [];
|
|
396
|
+
const managedVolumes = [];
|
|
397
|
+
const managedServices = [];
|
|
398
|
+
const userServices = [];
|
|
399
|
+
for (const r of resources) {
|
|
400
|
+
if (r.type === "secret" && isManaged(r)) {
|
|
401
|
+
managedSecrets.push(r);
|
|
402
|
+
}
|
|
403
|
+
else if (r.type === "volume" && isManaged(r)) {
|
|
404
|
+
managedVolumes.push(r);
|
|
405
|
+
}
|
|
406
|
+
else if (r.type === "service" && isManaged(r)) {
|
|
407
|
+
managedServices.push(r);
|
|
408
|
+
}
|
|
409
|
+
else if (r.type === "service" && !isManaged(r)) {
|
|
410
|
+
userServices.push(r);
|
|
411
|
+
}
|
|
412
|
+
else if (r.type === "site" && verbose) {
|
|
413
|
+
log.info(`Skipping site ${r.id} (not supported in dev mode)`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// Color palette for log prefixes
|
|
417
|
+
const colors = [pc.cyan, pc.magenta, pc.green, pc.yellow, pc.blue, pc.red];
|
|
418
|
+
const colorMap = new Map();
|
|
419
|
+
let colorIdx = 0;
|
|
420
|
+
function getColor(name) {
|
|
421
|
+
if (!colorMap.has(name)) {
|
|
422
|
+
colorMap.set(name, colors[colorIdx++ % colors.length]);
|
|
423
|
+
}
|
|
424
|
+
return colorMap.get(name);
|
|
425
|
+
}
|
|
426
|
+
const singleProcess = userServices.length === 1 && !verbose;
|
|
427
|
+
function writeLog(name, stream, text) {
|
|
428
|
+
if (singleProcess) {
|
|
429
|
+
const line = text.endsWith("\n") ? text : `${text}\n`;
|
|
430
|
+
const dest = stream === "stderr" ? process.stderr : process.stdout;
|
|
431
|
+
dest.write(line);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const short = stripPrefix(name, project);
|
|
435
|
+
const color = getColor(short);
|
|
436
|
+
const prefix = `${color(pc.bold(short))} ${pc.dim("|")} `;
|
|
437
|
+
const line = text.endsWith("\n") ? text : `${text}\n`;
|
|
438
|
+
const dest = stream === "stderr" ? process.stderr : process.stdout;
|
|
439
|
+
dest.write(`${prefix}${line}`);
|
|
440
|
+
}
|
|
441
|
+
const runtime = new LocalRuntime(project, { parentLogger: sessionLog });
|
|
442
|
+
const devRunner = new DevRunner({ onLog: writeLog });
|
|
443
|
+
// Graceful shutdown
|
|
444
|
+
let stopping = false;
|
|
445
|
+
async function shutdown() {
|
|
446
|
+
if (stopping)
|
|
447
|
+
return;
|
|
448
|
+
stopping = true;
|
|
449
|
+
process.stdout.write("\n");
|
|
450
|
+
const s = spinner();
|
|
451
|
+
s.start("Shutting down");
|
|
452
|
+
devRunner.stop();
|
|
453
|
+
await runtime.stop();
|
|
454
|
+
s.stop("Stopped");
|
|
455
|
+
process.exit(0);
|
|
456
|
+
}
|
|
457
|
+
process.on("SIGINT", shutdown);
|
|
458
|
+
process.on("SIGTERM", shutdown);
|
|
459
|
+
// Topo-sort managed services
|
|
460
|
+
const sortedManaged = topoSortServices(managedServices);
|
|
461
|
+
const depTargets = new Set();
|
|
462
|
+
for (const svc of managedServices) {
|
|
463
|
+
for (const dep of svc.config.dependsOn ?? []) {
|
|
464
|
+
depTargets.add(dep.id);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
const servicePorts = new Map();
|
|
468
|
+
const hostRemap = new Map();
|
|
469
|
+
// Start spinner (normal mode only)
|
|
470
|
+
const s = verbose ? null : spinner();
|
|
471
|
+
s?.start("Starting services");
|
|
472
|
+
// 1. Create infrastructure
|
|
473
|
+
await runtime.ensureInfrastructure();
|
|
474
|
+
// 2. Create secrets (in-memory)
|
|
475
|
+
for (const secret of managedSecrets) {
|
|
476
|
+
runtime.createSecret(secret);
|
|
477
|
+
if (verbose)
|
|
478
|
+
log.step(`Generated ${stripPrefix(secret.id, project)}`);
|
|
479
|
+
}
|
|
480
|
+
// 3. Create volumes
|
|
481
|
+
for (const vol of managedVolumes) {
|
|
482
|
+
await runtime.createVolume(vol);
|
|
483
|
+
if (verbose)
|
|
484
|
+
log.step(`Volume ${stripPrefix(vol.id, project)}`);
|
|
485
|
+
}
|
|
486
|
+
// 4. Create managed services
|
|
487
|
+
for (const svc of sortedManaged) {
|
|
488
|
+
const port = await runtime.createService(svc);
|
|
489
|
+
servicePorts.set(svc.id, port);
|
|
490
|
+
hostRemap.set(svc.host, "localhost");
|
|
491
|
+
if (verbose)
|
|
492
|
+
log.step(`Started ${stripPrefix(svc.id, project)} on localhost:${port}`);
|
|
493
|
+
if (depTargets.has(svc.id)) {
|
|
494
|
+
await runtime.waitForHealthy(svc.id);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
// Wait for remaining health checks
|
|
498
|
+
for (const svc of sortedManaged) {
|
|
499
|
+
if (!depTargets.has(svc.id)) {
|
|
500
|
+
await runtime.waitForHealthy(svc.id);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
s?.stop("Services ready");
|
|
504
|
+
// 5. Build dev processes for user services
|
|
505
|
+
const pm = detectPackageManager(projectRoot);
|
|
506
|
+
const secretValues = new Map();
|
|
507
|
+
for (const secret of managedSecrets) {
|
|
508
|
+
const val = runtime.getSecretValue(secret.id);
|
|
509
|
+
if (val)
|
|
510
|
+
secretValues.set(secret.id, val);
|
|
511
|
+
}
|
|
512
|
+
const devProcesses = userServices.map((svc) => {
|
|
513
|
+
const devConfig = svc.config.dev;
|
|
514
|
+
const command = devConfig?.command ?? `${pm} run dev`;
|
|
515
|
+
const cwd = devConfig?.cwd
|
|
516
|
+
? path.resolve(projectRoot, devConfig.cwd)
|
|
517
|
+
: projectRoot;
|
|
518
|
+
const env = svc.config.env
|
|
519
|
+
? resolveDevEnv(svc.config.env, secretValues, hostRemap)
|
|
520
|
+
: {};
|
|
521
|
+
return { service: svc, command, cwd, env };
|
|
522
|
+
});
|
|
523
|
+
if (devProcesses.length === 0 && sortedManaged.length === 0) {
|
|
524
|
+
log.warn("Nothing to run");
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
// Print summary table
|
|
528
|
+
const allNames = [
|
|
529
|
+
...sortedManaged.map((svc) => stripPrefix(svc.id, project)),
|
|
530
|
+
...devProcesses.map((dp) => stripPrefix(dp.service.id, project)),
|
|
531
|
+
];
|
|
532
|
+
const nameWidth = allNames.length > 0 ? Math.max(...allNames.map((n) => n.length)) : 0;
|
|
533
|
+
const tableLines = [];
|
|
534
|
+
if (sortedManaged.length > 0) {
|
|
535
|
+
const imageWidth = Math.max(...sortedManaged.map((svc) => (typeof svc.config.image === "string" ? svc.config.image : "custom")
|
|
536
|
+
.length));
|
|
537
|
+
for (const svc of sortedManaged) {
|
|
538
|
+
const short = stripPrefix(svc.id, project);
|
|
539
|
+
const image = typeof svc.config.image === "string" ? svc.config.image : "custom";
|
|
540
|
+
const port = servicePorts.get(svc.id) ?? svc.port;
|
|
541
|
+
tableLines.push(`${pc.bold(short.padEnd(nameWidth))} ${pc.dim(image.padEnd(imageWidth))} ${pc.cyan(`localhost:${port}`)}`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (devProcesses.length > 0) {
|
|
545
|
+
if (tableLines.length > 0)
|
|
546
|
+
tableLines.push("");
|
|
547
|
+
for (const dp of devProcesses) {
|
|
548
|
+
const short = stripPrefix(dp.service.id, project);
|
|
549
|
+
tableLines.push(`${pc.bold(short.padEnd(nameWidth))} ${pc.dim(dp.command)}`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (tableLines.length > 0) {
|
|
553
|
+
log.message(tableLines.join("\n"));
|
|
554
|
+
}
|
|
555
|
+
log.message(pc.dim("Ctrl+C to stop"));
|
|
556
|
+
// 6. Start dev processes
|
|
557
|
+
if (devProcesses.length > 0) {
|
|
558
|
+
devRunner.start(devProcesses);
|
|
559
|
+
}
|
|
560
|
+
// 7. Stream container logs (verbose only)
|
|
561
|
+
if (verbose) {
|
|
562
|
+
for (const svc of sortedManaged) {
|
|
563
|
+
(async () => {
|
|
564
|
+
for await (const entry of runtime.containerLogs(svc.id)) {
|
|
565
|
+
writeLog(svc.id, entry.stream, entry.text);
|
|
566
|
+
}
|
|
567
|
+
})();
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
// Keep alive until signal
|
|
571
|
+
await new Promise(() => { });
|
|
185
572
|
}
|
|
186
573
|
async function destroy(searchDir, host) {
|
|
187
574
|
const sessionId = randomBytes(4).toString("hex");
|
|
188
575
|
const sessionLog = logger.child({ sessionId, command: "destroy" });
|
|
189
576
|
const start = performance.now();
|
|
190
577
|
const project = await findProjectName(path.join(searchDir, "dummy"));
|
|
191
|
-
|
|
578
|
+
log.step(`Destroying ${pc.bold(project)}`);
|
|
192
579
|
const docker = new DockerClient(project, {
|
|
193
580
|
host,
|
|
194
581
|
parentLogger: sessionLog,
|
|
@@ -196,23 +583,57 @@ async function destroy(searchDir, host) {
|
|
|
196
583
|
const currentResources = await docker.listManagedResources();
|
|
197
584
|
if (currentResources.length === 0) {
|
|
198
585
|
log.warn("No resources found");
|
|
199
|
-
|
|
586
|
+
docker.destroy();
|
|
200
587
|
return;
|
|
201
588
|
}
|
|
202
589
|
sessionLog.info({ project, resourceCount: currentResources.length }, "destroy started");
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
590
|
+
// Group by type: services/sites first (parallel), then volumes/secrets (parallel)
|
|
591
|
+
const servicesSites = currentResources.filter((r) => r.type === "service" || r.type === "site");
|
|
592
|
+
const volumesSecrets = currentResources.filter((r) => r.type === "volume" || r.type === "secret");
|
|
593
|
+
const removed = [];
|
|
594
|
+
const sem = createSemaphore(MAX_CONCURRENCY);
|
|
595
|
+
async function removeGroup(group, onRemoved) {
|
|
596
|
+
await Promise.all(group.map(async ({ id, type }) => {
|
|
597
|
+
await sem.acquire();
|
|
598
|
+
try {
|
|
599
|
+
const resource = { id, type };
|
|
600
|
+
if (await docker.exists(resource)) {
|
|
601
|
+
await docker.remove(resource);
|
|
602
|
+
const name = stripPrefix(id, project);
|
|
603
|
+
removed.push(name);
|
|
604
|
+
onRemoved(name);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
finally {
|
|
608
|
+
sem.release();
|
|
609
|
+
}
|
|
610
|
+
}));
|
|
611
|
+
}
|
|
612
|
+
const s = spinner();
|
|
613
|
+
s.start("Destroying");
|
|
614
|
+
// Phase 1: Remove services and sites in parallel
|
|
615
|
+
if (servicesSites.length > 0) {
|
|
616
|
+
await removeGroup(servicesSites, (name) => {
|
|
617
|
+
s.message(`removing ${name}`);
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
// Phase 2: Remove volumes and secrets in parallel
|
|
621
|
+
if (volumesSecrets.length > 0) {
|
|
622
|
+
await removeGroup(volumesSecrets, (name) => {
|
|
623
|
+
s.message(`removing ${name}`);
|
|
624
|
+
});
|
|
211
625
|
}
|
|
212
626
|
await docker.removeProjectNetwork();
|
|
627
|
+
s.stop("Destroyed");
|
|
628
|
+
if (removed.length > 0) {
|
|
629
|
+
const nameWidth = Math.max(...removed.map((n) => n.length));
|
|
630
|
+
const lines = removed.map((name) => `${pc.bold(name.padEnd(nameWidth))} ${pc.dim("removed")}`);
|
|
631
|
+
log.message(lines.join("\n"));
|
|
632
|
+
}
|
|
213
633
|
const durationMs = Math.round(performance.now() - start);
|
|
214
|
-
sessionLog.info({ project, removed, durationMs }, "destroy completed");
|
|
215
|
-
|
|
634
|
+
sessionLog.info({ project, removed: removed.length, durationMs }, "destroy completed");
|
|
635
|
+
docker.destroy();
|
|
636
|
+
log.step(pc.dim(formatDuration(durationMs)));
|
|
216
637
|
}
|
|
217
638
|
const program = new Command();
|
|
218
639
|
program
|
|
@@ -250,6 +671,29 @@ program
|
|
|
250
671
|
throw err;
|
|
251
672
|
}
|
|
252
673
|
});
|
|
674
|
+
program
|
|
675
|
+
.command("dev")
|
|
676
|
+
.description("Start local development environment")
|
|
677
|
+
.argument("[config-file]", "path to config file", "vyft.config.ts")
|
|
678
|
+
.option("--verbose", "show detailed output", false)
|
|
679
|
+
.action(async (configFile, opts) => {
|
|
680
|
+
const absolutePath = path.resolve(process.cwd(), configFile);
|
|
681
|
+
if (configFile === "vyft.config.ts") {
|
|
682
|
+
try {
|
|
683
|
+
await access(absolutePath);
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
program.error("No config file specified and vyft.config.ts not found");
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
try {
|
|
690
|
+
await dev(absolutePath, opts.verbose);
|
|
691
|
+
}
|
|
692
|
+
catch (err) {
|
|
693
|
+
logger.fatal({ err }, "dev failed");
|
|
694
|
+
throw err;
|
|
695
|
+
}
|
|
696
|
+
});
|
|
253
697
|
const proxy = program.command("proxy").description("Manage the proxy");
|
|
254
698
|
proxy
|
|
255
699
|
.command("logs")
|