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
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Logger } from "pino";
|
|
2
|
+
import type { EnvValue, Secret, Service, Volume } from "../resource.js";
|
|
3
|
+
/**
|
|
4
|
+
* Docker runtime for local development.
|
|
5
|
+
*
|
|
6
|
+
* Runs managed infrastructure (databases, caches, etc.) as plain Docker
|
|
7
|
+
* containers with ports published to localhost. Secrets are generated
|
|
8
|
+
* in-memory and injected as environment variables.
|
|
9
|
+
*/
|
|
10
|
+
export declare class LocalRuntime {
|
|
11
|
+
private docker;
|
|
12
|
+
private project;
|
|
13
|
+
private log;
|
|
14
|
+
private secretValues;
|
|
15
|
+
private containers;
|
|
16
|
+
private networkName;
|
|
17
|
+
constructor(project: string, opts?: {
|
|
18
|
+
parentLogger?: Logger;
|
|
19
|
+
});
|
|
20
|
+
/** Create bridge network for inter-container DNS. */
|
|
21
|
+
ensureInfrastructure(): Promise<void>;
|
|
22
|
+
/** Generate a random secret value and store it in memory. */
|
|
23
|
+
createSecret(secret: Secret): void;
|
|
24
|
+
/** Get the generated value for a secret. */
|
|
25
|
+
getSecretValue(id: string): string | undefined;
|
|
26
|
+
/** Create a Docker volume for persistent data. */
|
|
27
|
+
createVolume(volume: Volume): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Resolve env vars for a managed service container.
|
|
30
|
+
*
|
|
31
|
+
* Secrets become plain `KEY=<value>` env vars.
|
|
32
|
+
* Interpolations are fully resolved with generated secret values.
|
|
33
|
+
*/
|
|
34
|
+
resolveEnv(env: Record<string, EnvValue>): string[];
|
|
35
|
+
/**
|
|
36
|
+
* Build an entrypoint wrapper that writes secret values to /run/secrets/
|
|
37
|
+
* before exec'ing the original entrypoint.
|
|
38
|
+
*
|
|
39
|
+
* This is needed because some factory images (e.g. redis) read passwords
|
|
40
|
+
* from `/run/secrets/*-password` via shell glob.
|
|
41
|
+
*/
|
|
42
|
+
private buildSecretInit;
|
|
43
|
+
/** Pull, create, and start a container for a managed service. */
|
|
44
|
+
createService(service: Service): Promise<number>;
|
|
45
|
+
/** Wait for a container to pass its health check. */
|
|
46
|
+
waitForHealthy(id: string, timeoutMs?: number): Promise<void>;
|
|
47
|
+
/** Stream logs from a container. */
|
|
48
|
+
containerLogs(id: string): AsyncGenerator<{
|
|
49
|
+
stream: "stdout" | "stderr";
|
|
50
|
+
text: string;
|
|
51
|
+
}>;
|
|
52
|
+
/** Stop and remove all managed containers (keep volumes). */
|
|
53
|
+
stop(): Promise<void>;
|
|
54
|
+
/** List running dev containers for this project. */
|
|
55
|
+
listContainers(): Promise<Array<{
|
|
56
|
+
id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
containerId: string;
|
|
59
|
+
}>>;
|
|
60
|
+
private removeContainer;
|
|
61
|
+
}
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { PassThrough } from "node:stream";
|
|
3
|
+
import Docker from "dockerode";
|
|
4
|
+
import { logger as defaultLogger } from "../logger.js";
|
|
5
|
+
import { isInterpolation, isReference } from "../resource.js";
|
|
6
|
+
/**
|
|
7
|
+
* Docker runtime for local development.
|
|
8
|
+
*
|
|
9
|
+
* Runs managed infrastructure (databases, caches, etc.) as plain Docker
|
|
10
|
+
* containers with ports published to localhost. Secrets are generated
|
|
11
|
+
* in-memory and injected as environment variables.
|
|
12
|
+
*/
|
|
13
|
+
export class LocalRuntime {
|
|
14
|
+
docker;
|
|
15
|
+
project;
|
|
16
|
+
log;
|
|
17
|
+
secretValues = new Map();
|
|
18
|
+
containers = [];
|
|
19
|
+
networkName;
|
|
20
|
+
constructor(project, opts) {
|
|
21
|
+
this.docker = new Docker({ socketPath: "/var/run/docker.sock" });
|
|
22
|
+
this.project = project;
|
|
23
|
+
this.networkName = `${project}-dev`;
|
|
24
|
+
this.log = (opts?.parentLogger ?? defaultLogger).child({
|
|
25
|
+
component: "local-runtime",
|
|
26
|
+
project,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/** Create bridge network for inter-container DNS. */
|
|
30
|
+
async ensureInfrastructure() {
|
|
31
|
+
const networks = await this.docker.listNetworks({
|
|
32
|
+
filters: JSON.stringify({ name: [this.networkName] }),
|
|
33
|
+
});
|
|
34
|
+
const exists = networks.some((n) => n.Name === this.networkName);
|
|
35
|
+
if (!exists) {
|
|
36
|
+
this.log.debug({ network: this.networkName }, "creating bridge network");
|
|
37
|
+
await this.docker.createNetwork({
|
|
38
|
+
Name: this.networkName,
|
|
39
|
+
Driver: "bridge",
|
|
40
|
+
Labels: {
|
|
41
|
+
"vyft.dev": "true",
|
|
42
|
+
"vyft.project": this.project,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Generate a random secret value and store it in memory. */
|
|
48
|
+
createSecret(secret) {
|
|
49
|
+
const length = secret.config.length || 32;
|
|
50
|
+
const value = randomBytes(length).toString("base64url");
|
|
51
|
+
this.secretValues.set(secret.id, value);
|
|
52
|
+
this.log.debug({ secretName: secret.id }, "secret generated");
|
|
53
|
+
}
|
|
54
|
+
/** Get the generated value for a secret. */
|
|
55
|
+
getSecretValue(id) {
|
|
56
|
+
return this.secretValues.get(id);
|
|
57
|
+
}
|
|
58
|
+
/** Create a Docker volume for persistent data. */
|
|
59
|
+
async createVolume(volume) {
|
|
60
|
+
const name = volume.id;
|
|
61
|
+
try {
|
|
62
|
+
await this.docker.getVolume(name).inspect();
|
|
63
|
+
this.log.debug({ volumeName: name }, "volume already exists");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Volume doesn't exist, create it
|
|
68
|
+
}
|
|
69
|
+
await this.docker.createVolume({
|
|
70
|
+
Name: name,
|
|
71
|
+
Labels: {
|
|
72
|
+
"vyft.dev": "true",
|
|
73
|
+
"vyft.project": this.project,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
this.log.debug({ volumeName: name }, "volume created");
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Resolve env vars for a managed service container.
|
|
80
|
+
*
|
|
81
|
+
* Secrets become plain `KEY=<value>` env vars.
|
|
82
|
+
* Interpolations are fully resolved with generated secret values.
|
|
83
|
+
*/
|
|
84
|
+
resolveEnv(env) {
|
|
85
|
+
const result = [];
|
|
86
|
+
for (const [key, value] of Object.entries(env)) {
|
|
87
|
+
if (typeof value === "string") {
|
|
88
|
+
result.push(`${key}=${value}`);
|
|
89
|
+
}
|
|
90
|
+
else if (isReference(value)) {
|
|
91
|
+
const secretValue = this.secretValues.get(value.id);
|
|
92
|
+
if (!secretValue) {
|
|
93
|
+
throw new Error(`Secret ${value.id} not yet generated`);
|
|
94
|
+
}
|
|
95
|
+
result.push(`${key}=${secretValue}`);
|
|
96
|
+
}
|
|
97
|
+
else if (isInterpolation(value)) {
|
|
98
|
+
const parts = [];
|
|
99
|
+
for (let i = 0; i < value.strings.length; i++) {
|
|
100
|
+
parts.push(value.strings[i]);
|
|
101
|
+
if (i < value.values.length) {
|
|
102
|
+
const v = value.values[i];
|
|
103
|
+
if (isReference(v)) {
|
|
104
|
+
const secretValue = this.secretValues.get(v.id);
|
|
105
|
+
if (!secretValue) {
|
|
106
|
+
throw new Error(`Secret ${v.id} not yet generated`);
|
|
107
|
+
}
|
|
108
|
+
parts.push(secretValue);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
parts.push(v);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
result.push(`${key}=${parts.join("")}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Build an entrypoint wrapper that writes secret values to /run/secrets/
|
|
122
|
+
* before exec'ing the original entrypoint.
|
|
123
|
+
*
|
|
124
|
+
* This is needed because some factory images (e.g. redis) read passwords
|
|
125
|
+
* from `/run/secrets/*-password` via shell glob.
|
|
126
|
+
*/
|
|
127
|
+
buildSecretInit(secrets, originalEntrypoint, originalCmd) {
|
|
128
|
+
if (secrets.length === 0) {
|
|
129
|
+
return { entrypoint: originalEntrypoint, cmd: originalCmd };
|
|
130
|
+
}
|
|
131
|
+
const writeCommands = secrets
|
|
132
|
+
.map((s) => `printf '%s' "$${s.envVar}" > /run/secrets/${s.name}`)
|
|
133
|
+
.join(" && ");
|
|
134
|
+
// Build the original command to exec
|
|
135
|
+
const original = [...originalEntrypoint, ...originalCmd];
|
|
136
|
+
const execCmd = original.length > 0 ? `exec ${original.map(shellEscape).join(" ")}` : "";
|
|
137
|
+
const script = `mkdir -p /run/secrets && ${writeCommands}${execCmd ? ` && ${execCmd}` : ""}`;
|
|
138
|
+
return {
|
|
139
|
+
entrypoint: ["sh", "-c", script],
|
|
140
|
+
cmd: [],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/** Pull, create, and start a container for a managed service. */
|
|
144
|
+
async createService(service) {
|
|
145
|
+
const config = service.config;
|
|
146
|
+
const image = typeof config.image === "string" ? config.image : `${service.id}:latest`;
|
|
147
|
+
const port = config.port || 3000;
|
|
148
|
+
// Stop and remove existing container if present
|
|
149
|
+
await this.removeContainer(service.id);
|
|
150
|
+
// Pull image
|
|
151
|
+
this.log.debug({ image }, "pulling image");
|
|
152
|
+
const pullStream = await this.docker.pull(image);
|
|
153
|
+
await new Promise((resolve, reject) => {
|
|
154
|
+
this.docker.modem.followProgress(pullStream, (err) => err ? reject(err) : resolve());
|
|
155
|
+
});
|
|
156
|
+
// Resolve env
|
|
157
|
+
const envList = config.env ? this.resolveEnv(config.env) : [];
|
|
158
|
+
// Collect secrets that need /run/secrets/ files
|
|
159
|
+
const secretMappings = [];
|
|
160
|
+
if (config.env) {
|
|
161
|
+
let idx = 0;
|
|
162
|
+
for (const [, value] of Object.entries(config.env)) {
|
|
163
|
+
if (isReference(value)) {
|
|
164
|
+
const envVar = `__VYFT_S${idx}`;
|
|
165
|
+
secretMappings.push({
|
|
166
|
+
name: value.id.replace(/^.*-/, ""), // short name e.g. "db-password" -> "password"
|
|
167
|
+
envVar,
|
|
168
|
+
});
|
|
169
|
+
// The full id as a file too, for glob patterns like *-password
|
|
170
|
+
secretMappings.push({
|
|
171
|
+
name: value.id,
|
|
172
|
+
envVar,
|
|
173
|
+
});
|
|
174
|
+
const secretVal = this.secretValues.get(value.id);
|
|
175
|
+
if (secretVal) {
|
|
176
|
+
envList.push(`${envVar}=${secretVal}`);
|
|
177
|
+
}
|
|
178
|
+
idx++;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Inspect image for default entrypoint/cmd
|
|
183
|
+
const imageInfo = await this.docker.getImage(image).inspect();
|
|
184
|
+
const rawEntrypoint = imageInfo.Config?.Entrypoint ?? [];
|
|
185
|
+
const defaultEntrypoint = Array.isArray(rawEntrypoint)
|
|
186
|
+
? rawEntrypoint
|
|
187
|
+
: [rawEntrypoint];
|
|
188
|
+
const rawCmd = imageInfo.Config?.Cmd ?? [];
|
|
189
|
+
const defaultCmd = Array.isArray(rawCmd) ? rawCmd : [rawCmd];
|
|
190
|
+
// Use config.command if specified, otherwise image defaults
|
|
191
|
+
const userCmd = config.command ?? defaultCmd;
|
|
192
|
+
const { entrypoint, cmd } = this.buildSecretInit(secretMappings, defaultEntrypoint, userCmd);
|
|
193
|
+
// Build volume mounts
|
|
194
|
+
const mounts = config.volumes?.map(({ volume, mount }) => ({
|
|
195
|
+
Type: "volume",
|
|
196
|
+
Source: volume.id,
|
|
197
|
+
Target: mount,
|
|
198
|
+
}));
|
|
199
|
+
// Health check
|
|
200
|
+
const healthCheck = config.healthCheck
|
|
201
|
+
? {
|
|
202
|
+
Test: ["CMD", ...config.healthCheck.command],
|
|
203
|
+
Interval: config.healthCheck.interval
|
|
204
|
+
? parseDurationNs(config.healthCheck.interval)
|
|
205
|
+
: undefined,
|
|
206
|
+
Timeout: config.healthCheck.timeout
|
|
207
|
+
? parseDurationNs(config.healthCheck.timeout)
|
|
208
|
+
: undefined,
|
|
209
|
+
Retries: config.healthCheck.retries,
|
|
210
|
+
StartPeriod: config.healthCheck.startPeriod
|
|
211
|
+
? parseDurationNs(config.healthCheck.startPeriod)
|
|
212
|
+
: undefined,
|
|
213
|
+
}
|
|
214
|
+
: undefined;
|
|
215
|
+
const containerName = `${service.id}-dev`;
|
|
216
|
+
this.log.debug({ containerName, image, port }, "creating container");
|
|
217
|
+
const container = await this.docker.createContainer({
|
|
218
|
+
name: containerName,
|
|
219
|
+
Image: image,
|
|
220
|
+
Entrypoint: entrypoint.length > 0 ? entrypoint : undefined,
|
|
221
|
+
Cmd: cmd.length > 0 ? cmd : undefined,
|
|
222
|
+
Env: envList,
|
|
223
|
+
ExposedPorts: { [`${port}/tcp`]: {} },
|
|
224
|
+
Healthcheck: healthCheck,
|
|
225
|
+
Labels: {
|
|
226
|
+
"vyft.dev": "true",
|
|
227
|
+
"vyft.project": this.project,
|
|
228
|
+
"vyft.service": service.id,
|
|
229
|
+
},
|
|
230
|
+
HostConfig: {
|
|
231
|
+
PortBindings: {
|
|
232
|
+
[`${port}/tcp`]: [{ HostIp: "127.0.0.1", HostPort: `${port}` }],
|
|
233
|
+
},
|
|
234
|
+
NetworkMode: this.networkName,
|
|
235
|
+
Mounts: mounts,
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
await container.start();
|
|
239
|
+
this.containers.push({
|
|
240
|
+
id: service.id,
|
|
241
|
+
name: containerName,
|
|
242
|
+
containerId: container.id,
|
|
243
|
+
});
|
|
244
|
+
this.log.debug({ containerName, port }, "container started");
|
|
245
|
+
return port;
|
|
246
|
+
}
|
|
247
|
+
/** Wait for a container to pass its health check. */
|
|
248
|
+
async waitForHealthy(id, timeoutMs = 120000) {
|
|
249
|
+
const info = this.containers.find((c) => c.id === id);
|
|
250
|
+
if (!info)
|
|
251
|
+
return;
|
|
252
|
+
const start = performance.now();
|
|
253
|
+
const interval = 1000;
|
|
254
|
+
this.log.debug({ resourceId: id }, "waiting for healthy");
|
|
255
|
+
while (performance.now() - start < timeoutMs) {
|
|
256
|
+
const container = this.docker.getContainer(info.containerId);
|
|
257
|
+
const data = await container.inspect();
|
|
258
|
+
const health = data.State?.Health?.Status;
|
|
259
|
+
if (health === "healthy") {
|
|
260
|
+
this.log.debug({ resourceId: id }, "container is healthy");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (health === undefined) {
|
|
264
|
+
// No health check configured, just check if running
|
|
265
|
+
if (data.State?.Running) {
|
|
266
|
+
this.log.debug({ resourceId: id }, "container running (no health check)");
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
271
|
+
}
|
|
272
|
+
throw new Error(`Container ${id} did not become healthy within ${timeoutMs}ms`);
|
|
273
|
+
}
|
|
274
|
+
/** Stream logs from a container. */
|
|
275
|
+
async *containerLogs(id) {
|
|
276
|
+
const info = this.containers.find((c) => c.id === id);
|
|
277
|
+
if (!info)
|
|
278
|
+
return;
|
|
279
|
+
const container = this.docker.getContainer(info.containerId);
|
|
280
|
+
const stream = await container.logs({
|
|
281
|
+
stdout: true,
|
|
282
|
+
stderr: true,
|
|
283
|
+
follow: true,
|
|
284
|
+
tail: 0,
|
|
285
|
+
});
|
|
286
|
+
const buffer = [];
|
|
287
|
+
let notify = null;
|
|
288
|
+
let done = false;
|
|
289
|
+
const push = (entry) => {
|
|
290
|
+
buffer.push(entry);
|
|
291
|
+
if (notify) {
|
|
292
|
+
const n = notify;
|
|
293
|
+
notify = null;
|
|
294
|
+
n();
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
const stdoutPT = new PassThrough();
|
|
298
|
+
const stderrPT = new PassThrough();
|
|
299
|
+
stdoutPT.on("data", (chunk) => {
|
|
300
|
+
for (const line of chunk.toString().split("\n")) {
|
|
301
|
+
if (line)
|
|
302
|
+
push({ stream: "stdout", text: line });
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
stderrPT.on("data", (chunk) => {
|
|
306
|
+
for (const line of chunk.toString().split("\n")) {
|
|
307
|
+
if (line)
|
|
308
|
+
push({ stream: "stderr", text: line });
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
this.docker.modem.demuxStream(stream, stdoutPT, stderrPT);
|
|
312
|
+
stream.on("end", () => {
|
|
313
|
+
done = true;
|
|
314
|
+
if (notify) {
|
|
315
|
+
const n = notify;
|
|
316
|
+
notify = null;
|
|
317
|
+
n();
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
while (!done || buffer.length > 0) {
|
|
321
|
+
if (buffer.length === 0) {
|
|
322
|
+
await new Promise((r) => {
|
|
323
|
+
notify = r;
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
while (buffer.length > 0) {
|
|
327
|
+
yield buffer.shift();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/** Stop and remove all managed containers (keep volumes). */
|
|
332
|
+
async stop() {
|
|
333
|
+
for (const info of this.containers) {
|
|
334
|
+
try {
|
|
335
|
+
const container = this.docker.getContainer(info.containerId);
|
|
336
|
+
await container.stop({ t: 5 });
|
|
337
|
+
await container.remove();
|
|
338
|
+
this.log.debug({ containerName: info.name }, "container stopped");
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
this.log.debug({ containerName: info.name }, "container already stopped");
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
this.containers = [];
|
|
345
|
+
}
|
|
346
|
+
/** List running dev containers for this project. */
|
|
347
|
+
async listContainers() {
|
|
348
|
+
const containers = await this.docker.listContainers({
|
|
349
|
+
filters: JSON.stringify({
|
|
350
|
+
label: ["vyft.dev=true", `vyft.project=${this.project}`],
|
|
351
|
+
}),
|
|
352
|
+
});
|
|
353
|
+
return containers.map((c) => ({
|
|
354
|
+
id: c.Labels["vyft.service"] || "",
|
|
355
|
+
name: c.Names[0]?.replace(/^\//, "") || "",
|
|
356
|
+
containerId: c.Id,
|
|
357
|
+
}));
|
|
358
|
+
}
|
|
359
|
+
async removeContainer(serviceId) {
|
|
360
|
+
const containerName = `${serviceId}-dev`;
|
|
361
|
+
try {
|
|
362
|
+
const container = this.docker.getContainer(containerName);
|
|
363
|
+
await container.stop({ t: 2 });
|
|
364
|
+
await container.remove();
|
|
365
|
+
this.log.debug({ containerName }, "removed existing container");
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
// Container didn't exist
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function parseDurationNs(duration) {
|
|
373
|
+
const match = duration.match(/^(\d+)(ms|s|m|h)$/);
|
|
374
|
+
if (!match?.[1] || !match[2]) {
|
|
375
|
+
throw new Error(`Invalid duration format: ${duration}`);
|
|
376
|
+
}
|
|
377
|
+
const value = parseInt(match[1], 10);
|
|
378
|
+
const unit = match[2];
|
|
379
|
+
const multipliers = {
|
|
380
|
+
ms: 1_000_000,
|
|
381
|
+
s: 1_000_000_000,
|
|
382
|
+
m: 60_000_000_000,
|
|
383
|
+
h: 3_600_000_000_000,
|
|
384
|
+
};
|
|
385
|
+
return value * multipliers[unit];
|
|
386
|
+
}
|
|
387
|
+
function shellEscape(s) {
|
|
388
|
+
if (/^[a-zA-Z0-9_./:=-]+$/.test(s))
|
|
389
|
+
return s;
|
|
390
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
391
|
+
}
|
package/dist/resource.d.ts
CHANGED
|
@@ -139,6 +139,13 @@ export interface ServiceConfig {
|
|
|
139
139
|
* @defaultValue `"any"`
|
|
140
140
|
*/
|
|
141
141
|
restartPolicy?: "none" | "on-failure" | "any";
|
|
142
|
+
/** Local development configuration. Used by `vyft dev`. */
|
|
143
|
+
dev?: {
|
|
144
|
+
/** Shell command to run (e.g. `"bun run dev"`). */
|
|
145
|
+
command: string;
|
|
146
|
+
/** Working directory relative to project root. */
|
|
147
|
+
cwd?: string;
|
|
148
|
+
};
|
|
142
149
|
}
|
|
143
150
|
/** Configuration for a static site. */
|
|
144
151
|
export interface SiteConfig {
|
package/dist/runtime.d.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import type { Resource } from "./resource.js";
|
|
2
|
+
/** Structured result returned by {@link Runtime.create}. */
|
|
3
|
+
export interface CreateResult {
|
|
4
|
+
action: "created" | "updated";
|
|
5
|
+
route?: string;
|
|
6
|
+
duration: number;
|
|
7
|
+
}
|
|
2
8
|
/** Backend that can deploy, query, and tear down resources. */
|
|
3
9
|
export interface Runtime {
|
|
4
10
|
/** Deploy or update a resource. */
|
|
5
|
-
create(resource: Resource): Promise<
|
|
11
|
+
create(resource: Resource): Promise<CreateResult>;
|
|
6
12
|
/** Check whether a resource currently exists. */
|
|
7
13
|
exists(resource: Resource): Promise<boolean>;
|
|
8
14
|
/** Tear down a resource. */
|
package/dist/services/index.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export interface Primitives {
|
|
|
4
4
|
service(id: string, config: ServiceConfig): Service;
|
|
5
5
|
secret(id: string, config?: SecretConfig): Secret;
|
|
6
6
|
}
|
|
7
|
+
/** Returns `true` if a resource was created by a built-in factory. */
|
|
8
|
+
export declare function isManaged(resource: unknown): boolean;
|
|
7
9
|
export declare function createServices(p: Primitives): {
|
|
8
10
|
postgres: (id: string, config?: import("./postgres.js").PostgresConfig) => import("./postgres.js").Postgres;
|
|
9
11
|
redis: (id: string, config?: import("./redis.js").RedisConfig) => import("./redis.js").Redis;
|
package/dist/services/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { VYFT_MANAGED } from "../symbols.js";
|
|
1
2
|
import { createMinio } from "./minio.js";
|
|
2
3
|
import { createMongo } from "./mongo.js";
|
|
3
4
|
import { createMysql } from "./mysql.js";
|
|
@@ -6,15 +7,29 @@ import { createPostgres } from "./postgres.js";
|
|
|
6
7
|
import { createRabbitmq } from "./rabbitmq.js";
|
|
7
8
|
import { createRedis } from "./redis.js";
|
|
8
9
|
import { createStorage } from "./storage.js";
|
|
10
|
+
function wrapPrimitives(p) {
|
|
11
|
+
return {
|
|
12
|
+
volume: (id, cfg) => Object.assign(p.volume(id, cfg), { [VYFT_MANAGED]: true }),
|
|
13
|
+
service: (id, cfg) => Object.assign(p.service(id, cfg), { [VYFT_MANAGED]: true }),
|
|
14
|
+
secret: (id, cfg) => Object.assign(p.secret(id, cfg), { [VYFT_MANAGED]: true }),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** Returns `true` if a resource was created by a built-in factory. */
|
|
18
|
+
export function isManaged(resource) {
|
|
19
|
+
return (typeof resource === "object" &&
|
|
20
|
+
resource !== null &&
|
|
21
|
+
resource[VYFT_MANAGED] === true);
|
|
22
|
+
}
|
|
9
23
|
export function createServices(p) {
|
|
24
|
+
const wrapped = wrapPrimitives(p);
|
|
10
25
|
return {
|
|
11
|
-
postgres: createPostgres(
|
|
12
|
-
redis: createRedis(
|
|
13
|
-
rabbitmq: createRabbitmq(
|
|
14
|
-
nats: createNats(
|
|
15
|
-
mysql: createMysql(
|
|
16
|
-
mongo: createMongo(
|
|
17
|
-
minio: createMinio(
|
|
18
|
-
storage: createStorage(
|
|
26
|
+
postgres: createPostgres(wrapped),
|
|
27
|
+
redis: createRedis(wrapped),
|
|
28
|
+
rabbitmq: createRabbitmq(wrapped),
|
|
29
|
+
nats: createNats(wrapped),
|
|
30
|
+
mysql: createMysql(wrapped),
|
|
31
|
+
mongo: createMongo(wrapped),
|
|
32
|
+
minio: createMinio(wrapped),
|
|
33
|
+
storage: createStorage(wrapped),
|
|
19
34
|
};
|
|
20
35
|
}
|
package/dist/symbols.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** Symbol key used to attach runtime metadata to every resource. */
|
|
2
2
|
export declare const VYFT_RUNTIME: unique symbol;
|
|
3
|
+
/** Symbol key marking resources created by built-in factories (postgres, redis, etc.). */
|
|
4
|
+
export declare const VYFT_MANAGED: unique symbol;
|
|
3
5
|
/** Metadata describing which runtime backend owns a resource. */
|
|
4
6
|
export interface RuntimeMeta {
|
|
5
7
|
/** Runtime name (e.g. `"swarm"`). */
|
package/dist/symbols.js
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
/** Symbol key used to attach runtime metadata to every resource. */
|
|
2
2
|
export const VYFT_RUNTIME = Symbol.for("vyft.runtime");
|
|
3
|
+
/** Symbol key marking resources created by built-in factories (postgres, redis, etc.). */
|
|
4
|
+
export const VYFT_MANAGED = Symbol.for("vyft.managed");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vyft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0-alpha",
|
|
4
4
|
"description": "Deploy apps to Docker Swarm with TypeScript",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"@biomejs/biome": "^2.4.4",
|
|
40
40
|
"@types/dockerode": "^4.0.1",
|
|
41
41
|
"@types/node": "^25.3.1",
|
|
42
|
+
"@types/ssh2": "^1.15.5",
|
|
42
43
|
"@types/tar-fs": "^2.0.4",
|
|
43
44
|
"tsx": "^4.21.0",
|
|
44
45
|
"typescript": "^5.9.3",
|
|
@@ -50,6 +51,7 @@
|
|
|
50
51
|
"dockerode": "^4.0.9",
|
|
51
52
|
"picocolors": "^1.1.1",
|
|
52
53
|
"pino": "^10.3.1",
|
|
54
|
+
"ssh2": "^1.17.0",
|
|
53
55
|
"tar-fs": "^3.1.1"
|
|
54
56
|
},
|
|
55
57
|
"engines": {
|
|
@@ -7,12 +7,8 @@
|
|
|
7
7
|
"vyft": "latest"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"
|
|
11
|
-
"dev": "pnpm --parallel -r dev",
|
|
10
|
+
"dev": "vyft dev",
|
|
12
11
|
"build": "pnpm --parallel -r build",
|
|
13
|
-
"deploy": "vyft deploy"
|
|
14
|
-
"compose:up": "docker compose up -d",
|
|
15
|
-
"compose:down": "docker compose down",
|
|
16
|
-
"compose:stop": "docker compose stop"
|
|
12
|
+
"deploy": "vyft deploy"
|
|
17
13
|
}
|
|
18
14
|
}
|