vyft 0.1.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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/dist/build.d.ts +11 -0
  4. package/dist/build.js +39 -0
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +200 -0
  7. package/dist/docker.d.ts +48 -0
  8. package/dist/docker.js +855 -0
  9. package/dist/exec.d.ts +2 -0
  10. package/dist/exec.js +28 -0
  11. package/dist/index.d.ts +6 -0
  12. package/dist/index.js +3 -0
  13. package/dist/init.d.ts +1 -0
  14. package/dist/init.js +100 -0
  15. package/dist/interpolate.d.ts +2 -0
  16. package/dist/interpolate.js +8 -0
  17. package/dist/logger.d.ts +2 -0
  18. package/dist/logger.js +10 -0
  19. package/dist/resource.d.ts +78 -0
  20. package/dist/resource.js +35 -0
  21. package/dist/runtime.d.ts +6 -0
  22. package/dist/runtime.js +0 -0
  23. package/dist/swarm/factories.d.ts +8 -0
  24. package/dist/swarm/factories.js +50 -0
  25. package/dist/swarm/index.d.ts +10 -0
  26. package/dist/swarm/index.js +5 -0
  27. package/dist/swarm/types.d.ts +25 -0
  28. package/dist/swarm/types.js +0 -0
  29. package/dist/symbols.d.ts +8 -0
  30. package/dist/symbols.js +1 -0
  31. package/package.json +68 -0
  32. package/templates/fullstack/apps/api/Dockerfile +21 -0
  33. package/templates/fullstack/apps/api/package.json +26 -0
  34. package/templates/fullstack/apps/api/src/auth.ts +21 -0
  35. package/templates/fullstack/apps/api/src/db.ts +16 -0
  36. package/templates/fullstack/apps/api/src/index.ts +17 -0
  37. package/templates/fullstack/apps/api/src/router.ts +11 -0
  38. package/templates/fullstack/apps/api/src/schema.ts +11 -0
  39. package/templates/fullstack/apps/api/tsconfig.json +8 -0
  40. package/templates/fullstack/apps/web/index.html +12 -0
  41. package/templates/fullstack/apps/web/package.json +21 -0
  42. package/templates/fullstack/apps/web/src/app.tsx +8 -0
  43. package/templates/fullstack/apps/web/src/main.tsx +9 -0
  44. package/templates/fullstack/apps/web/tsconfig.json +7 -0
  45. package/templates/fullstack/apps/web/vite.config.ts +14 -0
  46. package/templates/fullstack/compose.yaml +14 -0
  47. package/templates/fullstack/dockerignore +7 -0
  48. package/templates/fullstack/gitignore +3 -0
  49. package/templates/fullstack/package.json +17 -0
  50. package/templates/fullstack/pnpm-workspace.yaml +2 -0
  51. package/templates/fullstack/tsconfig.json +11 -0
  52. package/templates/fullstack/vyft.config.ts +37 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,195 @@
1
+ # Vyft
2
+
3
+ Deploy apps with TypeScript.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install vyft
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ Create `vyft.config.ts`:
14
+
15
+ ```typescript
16
+ import { interpolate } from 'vyft';
17
+ import { swarm } from 'vyft/swarm';
18
+
19
+ const { secret, volume, service, site } = swarm();
20
+
21
+ export const dbPassword = secret('db-password');
22
+ export const dbData = volume('db-data', { size: '20GB' });
23
+
24
+ export const db = service('db', {
25
+ image: 'postgres:16',
26
+ env: {
27
+ POSTGRES_PASSWORD: dbPassword,
28
+ POSTGRES_DB: 'myapp'
29
+ },
30
+ volumes: [
31
+ { volume: dbData, mount: '/var/lib/postgresql/data' }
32
+ ]
33
+ });
34
+
35
+ export const api = service('api', {
36
+ route: 'api.example.com',
37
+ image: { context: './api' },
38
+ env: {
39
+ DATABASE_URL: interpolate`postgres://postgres:${dbPassword}@${db.host}:5432/myapp`
40
+ }
41
+ });
42
+
43
+ export const app = site('app', {
44
+ route: 'example.com',
45
+ spa: true,
46
+ build: { context: './frontend' }
47
+ });
48
+ ```
49
+
50
+ Deploy:
51
+
52
+ ```bash
53
+ vyft deploy
54
+ ```
55
+
56
+ Tear down:
57
+
58
+ ```bash
59
+ vyft destroy
60
+ ```
61
+
62
+ ## Runtime
63
+
64
+ Resources are created through a runtime. The `swarm` runtime manages containers, networking, and routing.
65
+
66
+ ```typescript
67
+ import { swarm } from 'vyft/swarm';
68
+
69
+ const { secret, volume, service, site } = swarm();
70
+ ```
71
+
72
+ Volumes support size limits (requires xfs with project quotas on the host):
73
+
74
+ ```typescript
75
+ const { volume } = swarm();
76
+
77
+ export const data = volume('data', { size: '50GB' });
78
+ ```
79
+
80
+ ## Primitives
81
+
82
+ ### volume
83
+
84
+ Persistent storage.
85
+
86
+ ```typescript
87
+ volume('data');
88
+ ```
89
+
90
+ ### secret
91
+
92
+ Auto-generated secure values. Mounted as files at `/run/secrets/<id>`.
93
+
94
+ ```typescript
95
+ const apiKey = secret('api-key');
96
+ const jwtSecret = secret('jwt-secret', { length: 64 });
97
+ ```
98
+
99
+ Pass a secret directly as an env value to mount it. The `_FILE` suffix is added automatically:
100
+
101
+ ```typescript
102
+ env: {
103
+ API_KEY: apiKey
104
+ }
105
+ // → sets API_KEY_FILE=/run/secrets/api-key
106
+ ```
107
+
108
+ ### interpolate
109
+
110
+ Compose env values that contain secrets. Imported from `vyft`.
111
+
112
+ ```typescript
113
+ import { interpolate } from 'vyft';
114
+
115
+ env: {
116
+ DATABASE_URL: interpolate`postgres://user:${dbPassword}@${db.host}:5432/mydb`
117
+ }
118
+ // → sets DATABASE_URL_FILE=/run/secrets/<derived-secret>
119
+ ```
120
+
121
+ The result is stored as a derived secret and mounted as a file.
122
+
123
+ ### service
124
+
125
+ Run containers.
126
+
127
+ ```typescript
128
+ const api = service('api', {
129
+ image: { context: './api' }, // Build from directory
130
+ // or: image: 'node:20', // Use existing image
131
+ route: 'api.example.com',
132
+ port: 3000,
133
+ env: { NODE_ENV: 'production' },
134
+ command: ['node', 'server.js'],
135
+ volumes: [{ volume: data, mount: '/app/data' }],
136
+ replicas: 3,
137
+ healthCheck: {
138
+ command: ['curl', '-f', 'http://localhost:3000/health'],
139
+ interval: '30s',
140
+ timeout: '10s',
141
+ retries: 3
142
+ },
143
+ resources: {
144
+ memory: '512MB',
145
+ cpus: 0.5
146
+ },
147
+ restartPolicy: 'on-failure'
148
+ });
149
+ ```
150
+
151
+ Services expose output properties for referencing elsewhere:
152
+
153
+ ```typescript
154
+ api.host // internal hostname
155
+ api.port // internal port (default 3000)
156
+ api.url // full URL (https if routed, http otherwise)
157
+ ```
158
+
159
+ ### site
160
+
161
+ Serve static sites with automatic builds.
162
+
163
+ ```typescript
164
+ const app = site('app', {
165
+ route: 'example.com',
166
+ spa: true,
167
+ build: {
168
+ context: './frontend',
169
+ output: './dist',
170
+ command: 'npm run build'
171
+ }
172
+ });
173
+
174
+ app.url // https://example.com
175
+ ```
176
+
177
+ ## Routing
178
+
179
+ Routes go through a reverse proxy with automatic SSL.
180
+
181
+ ```typescript
182
+ 'example.com' // Root domain
183
+ 'example.com/api/*' // Path prefix
184
+ 'api.example.com' // Subdomain
185
+ '*.example.com' // Wildcard subdomain
186
+ ```
187
+
188
+ ## Requirements
189
+
190
+ - Node.js >= 22
191
+ - Docker
192
+
193
+ ## License
194
+
195
+ MIT
@@ -0,0 +1,11 @@
1
+ import type { Logger } from "pino";
2
+ export interface BuildResult {
3
+ outputPath: string;
4
+ }
5
+ type BuildOptions = {
6
+ output?: string;
7
+ command?: string;
8
+ env?: Record<string, string>;
9
+ };
10
+ export declare function buildStatic(context: string, options: BuildOptions, log?: Logger): Promise<BuildResult>;
11
+ export {};
package/dist/build.js ADDED
@@ -0,0 +1,39 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { spinner } from "@clack/prompts";
4
+ import { exec } from "./exec.js";
5
+ function resolveBuildCommand(context, optionCommand) {
6
+ if (optionCommand)
7
+ return optionCommand;
8
+ const packageJsonPath = path.join(context, "package.json");
9
+ if (existsSync(packageJsonPath)) {
10
+ const hasYarn = existsSync(path.join(context, "yarn.lock"));
11
+ const hasPnpm = existsSync(path.join(context, "pnpm-lock.yaml"));
12
+ if (hasYarn)
13
+ return "yarn build";
14
+ if (hasPnpm)
15
+ return "pnpm build";
16
+ return "npm run build";
17
+ }
18
+ return undefined;
19
+ }
20
+ export async function buildStatic(context, options, log) {
21
+ const buildContext = path.resolve(context);
22
+ const outputDir = options.output ?? "./dist";
23
+ const outputPath = path.join(buildContext, outputDir);
24
+ const buildCommand = resolveBuildCommand(buildContext, options.command);
25
+ if (buildCommand) {
26
+ const start = performance.now();
27
+ log?.info({ command: buildCommand, context: buildContext }, "static build started");
28
+ const s = spinner();
29
+ s.start(`Building static site (${buildCommand})`);
30
+ await exec(buildCommand, buildContext, options.env, log);
31
+ s.stop("Build complete");
32
+ const durationMs = Math.round(performance.now() - start);
33
+ log?.info({ command: buildCommand, context: buildContext, durationMs }, "static build completed");
34
+ }
35
+ if (!existsSync(outputPath)) {
36
+ throw new Error(`Output directory not found: ${outputPath}`);
37
+ }
38
+ return { outputPath };
39
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env node
2
+ import { randomBytes } from "node:crypto";
3
+ import { access, readFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { intro, log, outro } from "@clack/prompts";
6
+ import { Command } from "commander";
7
+ import { DockerClient } from "./docker.js";
8
+ import { init } from "./init.js";
9
+ import { logger } from "./logger.js";
10
+ import { VYFT_RUNTIME } from "./symbols.js";
11
+ const DEPLOY_ORDER = {
12
+ secret: 0,
13
+ volume: 1,
14
+ service: 2,
15
+ site: 3,
16
+ };
17
+ const DESTROY_ORDER = {
18
+ site: 0,
19
+ service: 1,
20
+ volume: 2,
21
+ secret: 3,
22
+ };
23
+ async function findProjectName(configPath) {
24
+ let dir = path.dirname(configPath);
25
+ while (dir !== path.dirname(dir)) {
26
+ try {
27
+ const pkgPath = path.join(dir, "package.json");
28
+ const content = await readFile(pkgPath, "utf-8");
29
+ const pkg = JSON.parse(content);
30
+ if (pkg.name) {
31
+ return pkg.name;
32
+ }
33
+ }
34
+ catch {
35
+ // No package.json here, keep walking up
36
+ }
37
+ dir = path.dirname(dir);
38
+ }
39
+ throw new Error("Could not find package.json with a name field");
40
+ }
41
+ function isResource(value) {
42
+ return (typeof value === "object" &&
43
+ value !== null &&
44
+ "type" in value &&
45
+ "id" in value &&
46
+ typeof value.type === "string" &&
47
+ typeof value.id === "string");
48
+ }
49
+ function hasRuntime(value) {
50
+ return (typeof value === "object" &&
51
+ value !== null &&
52
+ VYFT_RUNTIME in value);
53
+ }
54
+ function createRuntime(resources, project, sessionLogger) {
55
+ const ref = resources.find((r) => hasRuntime(r));
56
+ if (ref && hasRuntime(ref)) {
57
+ const meta = ref[VYFT_RUNTIME];
58
+ switch (meta.name) {
59
+ case "swarm":
60
+ return new DockerClient(project, sessionLogger);
61
+ default:
62
+ throw new Error(`Unknown runtime: ${meta.name}`);
63
+ }
64
+ }
65
+ return new DockerClient(project, sessionLogger);
66
+ }
67
+ async function deploy(configFile, verbose) {
68
+ const sessionId = randomBytes(4).toString("hex");
69
+ const sessionLog = logger.child({ sessionId, command: "deploy" });
70
+ const start = performance.now();
71
+ const project = await findProjectName(configFile);
72
+ intro(`Deploying ${project}`);
73
+ const config = await import(configFile);
74
+ const resources = Object.values(config).filter(isResource);
75
+ if (resources.length === 0) {
76
+ log.warn("No resources found");
77
+ outro();
78
+ return;
79
+ }
80
+ sessionLog.info({ project, resourceCount: resources.length }, "deploy started");
81
+ resources.sort((a, b) => DEPLOY_ORDER[a.type] - DEPLOY_ORDER[b.type]);
82
+ const docker = createRuntime(resources, project, sessionLog);
83
+ docker.verbose = verbose;
84
+ await docker.ensureInfrastructure();
85
+ const currentResources = await docker.listManagedResources();
86
+ let created = 0;
87
+ let skipped = 0;
88
+ for (const resource of resources) {
89
+ const exists = await docker.exists(resource);
90
+ if (exists) {
91
+ log.success(resource.id);
92
+ skipped++;
93
+ }
94
+ else {
95
+ await docker.create(resource);
96
+ created++;
97
+ }
98
+ }
99
+ const desiredIds = new Set(resources.map((r) => r.id));
100
+ // Keep derived secrets whose parent service is still desired
101
+ for (const r of currentResources) {
102
+ if (r.derived && r.parentService && desiredIds.has(r.parentService)) {
103
+ desiredIds.add(r.id);
104
+ }
105
+ }
106
+ const toRemove = currentResources.filter((r) => !desiredIds.has(r.id));
107
+ let removed = 0;
108
+ if (toRemove.length > 0) {
109
+ toRemove.sort((a, b) => DESTROY_ORDER[a.type] - DESTROY_ORDER[b.type]);
110
+ for (const { id, type } of toRemove) {
111
+ const resource = { id, type };
112
+ if (await docker.exists(resource)) {
113
+ await docker.remove(resource);
114
+ removed++;
115
+ }
116
+ }
117
+ }
118
+ const durationMs = Math.round(performance.now() - start);
119
+ sessionLog.info({ project, created, removed, skipped, durationMs }, "deploy completed");
120
+ outro("Deploy complete");
121
+ }
122
+ async function destroy(searchDir) {
123
+ const sessionId = randomBytes(4).toString("hex");
124
+ const sessionLog = logger.child({ sessionId, command: "destroy" });
125
+ const start = performance.now();
126
+ const project = await findProjectName(path.join(searchDir, "dummy"));
127
+ intro(`Destroying ${project}`);
128
+ const docker = new DockerClient(project, sessionLog);
129
+ const currentResources = await docker.listManagedResources();
130
+ if (currentResources.length === 0) {
131
+ log.warn("No resources found");
132
+ outro();
133
+ return;
134
+ }
135
+ sessionLog.info({ project, resourceCount: currentResources.length }, "destroy started");
136
+ const sorted = [...currentResources].sort((a, b) => DESTROY_ORDER[a.type] - DESTROY_ORDER[b.type]);
137
+ let removed = 0;
138
+ for (const { id, type } of sorted) {
139
+ const resource = { id, type };
140
+ if (await docker.exists(resource)) {
141
+ await docker.remove(resource);
142
+ removed++;
143
+ }
144
+ }
145
+ const durationMs = Math.round(performance.now() - start);
146
+ sessionLog.info({ project, removed, durationMs }, "destroy completed");
147
+ outro("Destroy complete");
148
+ }
149
+ const program = new Command();
150
+ program
151
+ .name("vyft")
152
+ .description("Deploy apps to Docker Swarm with TypeScript")
153
+ .version("0.1.0");
154
+ program
155
+ .command("init")
156
+ .description("Create a new project")
157
+ .argument("[directory]", "directory to create the project in")
158
+ .action(async (directory) => {
159
+ await init(directory);
160
+ });
161
+ program
162
+ .command("deploy")
163
+ .description("Deploy resources from a config file")
164
+ .argument("[config-file]", "path to config file", "vyft.config.ts")
165
+ .option("--verbose", "show build output", false)
166
+ .action(async (configFile, opts) => {
167
+ const absolutePath = path.resolve(process.cwd(), configFile);
168
+ if (configFile === "vyft.config.ts") {
169
+ try {
170
+ await access(absolutePath);
171
+ }
172
+ catch {
173
+ program.error("No config file specified and vyft.config.ts not found");
174
+ }
175
+ }
176
+ try {
177
+ await deploy(absolutePath, opts.verbose);
178
+ }
179
+ catch (err) {
180
+ logger.fatal({ err }, "deploy failed");
181
+ throw err;
182
+ }
183
+ });
184
+ program
185
+ .command("destroy")
186
+ .description("Destroy all deployed resources")
187
+ .argument("[config-file]", "path to config file (used to locate project)")
188
+ .action(async (configFile) => {
189
+ const absolutePath = configFile
190
+ ? path.resolve(process.cwd(), configFile)
191
+ : process.cwd();
192
+ try {
193
+ await destroy(absolutePath);
194
+ }
195
+ catch (err) {
196
+ logger.fatal({ err }, "destroy failed");
197
+ throw err;
198
+ }
199
+ });
200
+ await program.parseAsync();
@@ -0,0 +1,48 @@
1
+ import type { Logger } from "pino";
2
+ import type { Resource, ResourceType } from "./resource.js";
3
+ import type { Runtime } from "./runtime.js";
4
+ export declare function parseRoute(route: string): {
5
+ host: string;
6
+ path?: string;
7
+ };
8
+ export declare function buildCaddyRoute(project: string, resourceId: string, route: string, handler: Record<string, unknown>): Record<string, unknown>;
9
+ export interface ManagedResource {
10
+ id: string;
11
+ type: ResourceType;
12
+ route?: string;
13
+ derived?: boolean;
14
+ parentService?: string;
15
+ }
16
+ export declare class DockerClient implements Runtime {
17
+ private docker;
18
+ private project;
19
+ private secretValues;
20
+ private log;
21
+ verbose: boolean;
22
+ constructor(project: string, parentLogger?: Logger);
23
+ ensureInfrastructure(): Promise<void>;
24
+ private findProxyContainer;
25
+ private caddyApiRequest;
26
+ private seedCaddyConfig;
27
+ private addRoute;
28
+ private removeRoute;
29
+ listManagedResources(): Promise<ManagedResource[]>;
30
+ create(resource: Resource): Promise<void>;
31
+ exists(resource: Resource): Promise<boolean>;
32
+ remove(resource: Resource): Promise<void>;
33
+ private createVolume;
34
+ private volumeExists;
35
+ private removeVolume;
36
+ private createSecret;
37
+ private secretExists;
38
+ private removeSecret;
39
+ private storeSecretData;
40
+ private lookupSecretId;
41
+ private createDerivedSecret;
42
+ private resolveEnv;
43
+ private createService;
44
+ private serviceExists;
45
+ private removeService;
46
+ private createStatic;
47
+ private removeStatic;
48
+ }