vyft 0.2.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.
Files changed (56) hide show
  1. package/README.md +5 -16
  2. package/dist/build.d.ts +1 -0
  3. package/dist/build.js +9 -4
  4. package/dist/cli.js +648 -43
  5. package/dist/context.d.ts +39 -0
  6. package/dist/context.js +101 -0
  7. package/dist/docker.d.ts +24 -12
  8. package/dist/docker.js +299 -389
  9. package/dist/exec.d.ts +1 -1
  10. package/dist/exec.js +2 -2
  11. package/dist/index.d.ts +4 -1
  12. package/dist/index.js +5 -1
  13. package/dist/init.js +19 -2
  14. package/dist/interpolate.d.ts +11 -0
  15. package/dist/interpolate.js +11 -0
  16. package/dist/local/dev.d.ts +31 -0
  17. package/dist/local/dev.js +109 -0
  18. package/dist/local/index.d.ts +2 -0
  19. package/dist/local/index.js +2 -0
  20. package/dist/local/runtime.d.ts +61 -0
  21. package/dist/local/runtime.js +391 -0
  22. package/dist/proxy.d.ts +16 -0
  23. package/dist/proxy.js +0 -0
  24. package/dist/resource.d.ts +104 -1
  25. package/dist/resource.js +11 -1
  26. package/dist/runtime.d.ts +11 -1
  27. package/dist/services/index.d.ts +26 -0
  28. package/dist/services/index.js +35 -0
  29. package/dist/services/minio.d.ts +36 -0
  30. package/dist/services/minio.js +53 -0
  31. package/dist/services/mongo.d.ts +28 -0
  32. package/dist/services/mongo.js +45 -0
  33. package/dist/services/mysql.d.ts +28 -0
  34. package/dist/services/mysql.js +44 -0
  35. package/dist/services/nats.d.ts +26 -0
  36. package/dist/services/nats.js +38 -0
  37. package/dist/services/postgres.d.ts +28 -0
  38. package/dist/services/postgres.js +45 -0
  39. package/dist/services/rabbitmq.d.ts +28 -0
  40. package/dist/services/rabbitmq.js +44 -0
  41. package/dist/services/redis.d.ts +28 -0
  42. package/dist/services/redis.js +49 -0
  43. package/dist/services/storage.d.ts +39 -0
  44. package/dist/services/storage.js +94 -0
  45. package/dist/swarm/factories.d.ts +9 -2
  46. package/dist/swarm/factories.js +9 -32
  47. package/dist/swarm/index.d.ts +11 -2
  48. package/dist/swarm/proxy.d.ts +24 -0
  49. package/dist/swarm/proxy.js +339 -0
  50. package/dist/swarm/types.d.ts +11 -21
  51. package/dist/symbols.d.ts +7 -0
  52. package/dist/symbols.js +3 -0
  53. package/package.json +4 -5
  54. package/templates/fullstack/package.json +2 -6
  55. package/templates/fullstack/vyft.config.ts +13 -28
  56. package/templates/fullstack/compose.yaml +0 -14
package/dist/cli.js CHANGED
@@ -2,18 +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 { intro, log, outro } from "@clack/prompts";
5
+ import { log, spinner } from "@clack/prompts";
6
6
  import { Command } from "commander";
7
+ import pc from "picocolors";
8
+ import { getCurrentContextName, listContexts, removeContext, resolveContext, saveContext, setCurrentContext, } from "./context.js";
7
9
  import { DockerClient } from "./docker.js";
8
10
  import { init } from "./init.js";
11
+ import { DevRunner, detectPackageManager, LocalRuntime, resolveDevEnv, } from "./local/index.js";
9
12
  import { logger } from "./logger.js";
13
+ import { isInterpolation, isReference } from "./resource.js";
14
+ import { isManaged } from "./services/index.js";
10
15
  import { VYFT_RUNTIME } from "./symbols.js";
11
- const DEPLOY_ORDER = {
12
- secret: 0,
13
- volume: 1,
14
- service: 2,
15
- site: 3,
16
- };
17
16
  const DESTROY_ORDER = {
18
17
  site: 0,
19
18
  service: 1,
@@ -67,23 +66,132 @@ function collectResources(exports) {
67
66
  }
68
67
  return resources;
69
68
  }
69
+ function topoSortServices(services) {
70
+ const byId = new Map(services.map((s) => [s.id, s]));
71
+ const visited = new Set();
72
+ const result = [];
73
+ function visit(svc) {
74
+ if (visited.has(svc.id))
75
+ return;
76
+ visited.add(svc.id);
77
+ for (const dep of svc.config.dependsOn ?? []) {
78
+ const depSvc = byId.get(dep.id);
79
+ if (depSvc)
80
+ visit(depSvc);
81
+ }
82
+ result.push(svc);
83
+ }
84
+ for (const svc of services)
85
+ visit(svc);
86
+ return result;
87
+ }
70
88
  function hasRuntime(value) {
71
89
  return (typeof value === "object" &&
72
90
  value !== null &&
73
91
  VYFT_RUNTIME in value);
74
92
  }
93
+ function getContextHost() {
94
+ const ctx = resolveContext(program.opts().context);
95
+ return ctx?.host;
96
+ }
75
97
  function createRuntime(resources, project, sessionLogger) {
98
+ const host = getContextHost();
76
99
  const ref = resources.find((r) => hasRuntime(r));
77
100
  if (ref && hasRuntime(ref)) {
78
101
  const meta = ref[VYFT_RUNTIME];
79
102
  switch (meta.name) {
80
103
  case "swarm":
81
- return new DockerClient(project, sessionLogger);
104
+ return new DockerClient(project, { host, parentLogger: sessionLogger });
82
105
  default:
83
106
  throw new Error(`Unknown runtime: ${meta.name}`);
84
107
  }
85
108
  }
86
- return new DockerClient(project, sessionLogger);
109
+ return new DockerClient(project, { host, parentLogger: sessionLogger });
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`;
87
195
  }
88
196
  async function deploy(configFile, verbose) {
89
197
  const sessionId = randomBytes(4).toString("hex");
@@ -91,35 +199,155 @@ async function deploy(configFile, verbose) {
91
199
  const start = performance.now();
92
200
  const project = await findProjectName(configFile);
93
201
  process.env.VYFT_PROJECT = project;
94
- intro(`Deploying ${project}`);
202
+ log.step(`Deploying ${pc.bold(project)}`);
95
203
  const config = await import(configFile);
96
204
  const resources = collectResources(config);
97
205
  if (resources.length === 0) {
98
206
  log.warn("No resources found");
99
- outro();
100
207
  return;
101
208
  }
102
209
  sessionLog.info({ project, resourceCount: resources.length }, "deploy started");
103
- resources.sort((a, b) => DEPLOY_ORDER[a.type] - DEPLOY_ORDER[b.type]);
104
210
  const docker = createRuntime(resources, project, sessionLog);
105
211
  docker.verbose = verbose;
106
212
  await docker.ensureInfrastructure();
107
213
  const currentResources = await docker.listManagedResources();
108
- let created = 0;
109
- let skipped = 0;
110
- for (const resource of resources) {
111
- const exists = await docker.exists(resource);
112
- if (exists && resource.type !== "service" && resource.type !== "site") {
113
- log.success(resource.id);
114
- skipped++;
214
+ const silent = !verbose;
215
+ // Collect which service IDs have dependents waiting on them
216
+ const services = resources.filter((r) => r.type === "service");
217
+ const depTargets = new Set();
218
+ for (const svc of services) {
219
+ for (const dep of svc.config.dependsOn ?? []) {
220
+ depTargets.add(dep.id);
115
221
  }
116
- else {
117
- await docker.create(resource);
118
- created++;
222
+ }
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
+ });
240
+ }
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
+ });
119
256
  }
120
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
+ });
286
+ }
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
121
350
  const desiredIds = new Set(resources.map((r) => r.id));
122
- // Keep derived secrets whose parent service is still desired
123
351
  for (const r of currentResources) {
124
352
  if (r.derived && r.parentService && desiredIds.has(r.parentService)) {
125
353
  desiredIds.add(r.id);
@@ -133,47 +361,286 @@ async function deploy(configFile, verbose) {
133
361
  const resource = { id, type };
134
362
  if (await docker.exists(resource)) {
135
363
  await docker.remove(resource);
364
+ const name = stripPrefix(id, project);
365
+ if (verbose) {
366
+ log.step(`${pc.bold(name)} ${pc.dim("removed")}`);
367
+ }
136
368
  removed++;
137
369
  }
138
370
  }
371
+ if (!verbose && removed > 0) {
372
+ log.message(`${removed} resource${removed === 1 ? "" : "s"} removed`);
373
+ }
139
374
  }
140
375
  const durationMs = Math.round(performance.now() - start);
141
- sessionLog.info({ project, created, removed, skipped, durationMs }, "deploy completed");
142
- outro("Deploy complete");
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)));
143
380
  }
144
- async function destroy(searchDir) {
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(() => { });
572
+ }
573
+ async function destroy(searchDir, host) {
145
574
  const sessionId = randomBytes(4).toString("hex");
146
575
  const sessionLog = logger.child({ sessionId, command: "destroy" });
147
576
  const start = performance.now();
148
577
  const project = await findProjectName(path.join(searchDir, "dummy"));
149
- intro(`Destroying ${project}`);
150
- const docker = new DockerClient(project, sessionLog);
578
+ log.step(`Destroying ${pc.bold(project)}`);
579
+ const docker = new DockerClient(project, {
580
+ host,
581
+ parentLogger: sessionLog,
582
+ });
151
583
  const currentResources = await docker.listManagedResources();
152
584
  if (currentResources.length === 0) {
153
585
  log.warn("No resources found");
154
- outro();
586
+ docker.destroy();
155
587
  return;
156
588
  }
157
589
  sessionLog.info({ project, resourceCount: currentResources.length }, "destroy started");
158
- const sorted = [...currentResources].sort((a, b) => DESTROY_ORDER[a.type] - DESTROY_ORDER[b.type]);
159
- let removed = 0;
160
- for (const { id, type } of sorted) {
161
- const resource = { id, type };
162
- if (await docker.exists(resource)) {
163
- await docker.remove(resource);
164
- removed++;
165
- }
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
+ });
166
625
  }
167
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
+ }
168
633
  const durationMs = Math.round(performance.now() - start);
169
- sessionLog.info({ project, removed, durationMs }, "destroy completed");
170
- outro("Destroy complete");
634
+ sessionLog.info({ project, removed: removed.length, durationMs }, "destroy completed");
635
+ docker.destroy();
636
+ log.step(pc.dim(formatDuration(durationMs)));
171
637
  }
172
638
  const program = new Command();
173
639
  program
174
640
  .name("vyft")
175
641
  .description("Deploy apps to Docker Swarm with TypeScript")
176
- .version("0.1.0");
642
+ .version("0.1.0")
643
+ .option("--context <name>", "override active context for this invocation");
177
644
  program
178
645
  .command("init")
179
646
  .description("Create a new project")
@@ -204,6 +671,29 @@ program
204
671
  throw err;
205
672
  }
206
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
+ });
207
697
  const proxy = program.command("proxy").description("Manage the proxy");
208
698
  proxy
209
699
  .command("logs")
@@ -212,12 +702,51 @@ proxy
212
702
  .option("--tail <n>", "number of lines to show", "100")
213
703
  .action(async (opts) => {
214
704
  const project = await findProjectName(path.join(process.cwd(), "package.json"));
215
- const docker = new DockerClient(project);
216
- await docker.proxyLogs({
705
+ const docker = new DockerClient(project, { host: getContextHost() });
706
+ await docker.proxy.logs({
217
707
  follow: opts.follow,
218
708
  tail: parseInt(opts.tail, 10),
219
709
  });
220
710
  });
711
+ program
712
+ .command("logs")
713
+ .description("View service logs")
714
+ .argument("[services...]", "service names to filter (short names without project prefix)")
715
+ .option("-f, --follow", "stream logs continuously", false)
716
+ .option("--tail <n>", "number of lines to show", "100")
717
+ .action(async (services, opts) => {
718
+ const project = await findProjectName(path.join(process.cwd(), "package.json"));
719
+ const docker = new DockerClient(project, { host: getContextHost() });
720
+ // Support comma-separated names: "api,web" -> ["api", "web"]
721
+ const parsed = services.flatMap((s) => s.split(","));
722
+ const logs = docker.serviceLogs({
723
+ follow: opts.follow,
724
+ tail: parseInt(opts.tail, 10),
725
+ services: parsed.length > 0 ? parsed : undefined,
726
+ });
727
+ const colors = [
728
+ pc.cyan,
729
+ pc.magenta,
730
+ pc.green,
731
+ pc.yellow,
732
+ pc.blue,
733
+ pc.red,
734
+ ];
735
+ const colorMap = new Map();
736
+ let colorIdx = 0;
737
+ for await (const entry of logs) {
738
+ if (!colorMap.has(entry.service)) {
739
+ colorMap.set(entry.service, colors[colorIdx++ % colors.length]);
740
+ }
741
+ const color = colorMap.get(entry.service);
742
+ const prefix = parsed.length === 1
743
+ ? ""
744
+ : `${color(pc.bold(entry.service))} ${pc.dim("|")} `;
745
+ const line = entry.text.endsWith("\n") ? entry.text : `${entry.text}\n`;
746
+ const dest = entry.stream === "stderr" ? process.stderr : process.stdout;
747
+ dest.write(`${prefix}${line}`);
748
+ }
749
+ });
221
750
  program
222
751
  .command("destroy")
223
752
  .description("Destroy all deployed resources")
@@ -227,11 +756,87 @@ program
227
756
  ? path.resolve(process.cwd(), configFile)
228
757
  : process.cwd();
229
758
  try {
230
- await destroy(absolutePath);
759
+ await destroy(absolutePath, getContextHost());
231
760
  }
232
761
  catch (err) {
233
762
  logger.fatal({ err }, "destroy failed");
234
763
  throw err;
235
764
  }
236
765
  });
766
+ const context = program
767
+ .command("context")
768
+ .description("Manage deployment contexts");
769
+ context
770
+ .command("create")
771
+ .description("Create a new context")
772
+ .argument("<name>", "context name")
773
+ .requiredOption("--host <endpoint>", "Docker endpoint (e.g. ssh://root@1.2.3.4)")
774
+ .option("--runtime <rt>", "runtime backend", "swarm")
775
+ .option("--description <text>", "optional description")
776
+ .action((name, opts) => {
777
+ saveContext({
778
+ name,
779
+ host: opts.host,
780
+ runtime: opts.runtime,
781
+ description: opts.description,
782
+ });
783
+ console.log(`Context "${name}" created`);
784
+ });
785
+ context
786
+ .command("ls")
787
+ .description("List all contexts")
788
+ .action(() => {
789
+ const contexts = listContexts();
790
+ if (contexts.length === 0) {
791
+ console.log("No contexts configured. Create one with: vyft context create <name> --host <endpoint>");
792
+ return;
793
+ }
794
+ const current = getCurrentContextName();
795
+ for (const ctx of contexts) {
796
+ const marker = ctx.name === current ? "* " : " ";
797
+ const desc = ctx.description ? ` - ${ctx.description}` : "";
798
+ const host = ctx.host ? ` (${ctx.host})` : "";
799
+ console.log(`${marker}${ctx.name}${host}${desc}`);
800
+ }
801
+ });
802
+ context
803
+ .command("show")
804
+ .description("Print current context name")
805
+ .action(() => {
806
+ const name = getCurrentContextName();
807
+ if (!name) {
808
+ console.log("No context set");
809
+ }
810
+ else {
811
+ console.log(name);
812
+ }
813
+ });
814
+ context
815
+ .command("use")
816
+ .description("Set the active context")
817
+ .argument("<name>", "context name")
818
+ .action((name) => {
819
+ setCurrentContext(name);
820
+ console.log(`Switched to context "${name}"`);
821
+ });
822
+ context
823
+ .command("rm")
824
+ .description("Remove a context")
825
+ .argument("<name>", "context name")
826
+ .action((name) => {
827
+ removeContext(name);
828
+ console.log(`Context "${name}" removed`);
829
+ });
830
+ context
831
+ .command("inspect")
832
+ .description("Print context as JSON")
833
+ .argument("[name]", "context name (defaults to current)")
834
+ .action((name) => {
835
+ const ctx = resolveContext(name);
836
+ if (!ctx) {
837
+ console.log("No context set");
838
+ return;
839
+ }
840
+ console.log(JSON.stringify(ctx, null, 2));
841
+ });
237
842
  await program.parseAsync();