File size: 6,224 Bytes
b36da9a
 
7d285f5
0b6a8e8
b36da9a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b6a8e8
 
 
b36da9a
 
 
 
 
 
 
 
 
 
0b6a8e8
b36da9a
 
 
 
 
 
 
 
5c97d4f
 
b36da9a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d285f5
 
b36da9a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c97d4f
b36da9a
 
5c97d4f
b36da9a
 
 
 
 
 
 
 
 
 
 
 
 
0b6a8e8
 
b36da9a
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
import fs from 'fs/promises';
import path from 'path';
import type Docker from 'dockerode';
import * as tar from 'tar-fs';

export interface CodeverseConfig {
    env?: Record<string, string>;
    packages?: {
        apt?: string[];
        npm?: string[];
    };
    ios?: {
        appetizeUrl?: string;
    };
}

/**
 * Parses a simple subset of Nix language from .idx/dev.nix
 * Extracts pkgs.* into apt packages and env.* into env vars.
 */
function parseBasicNix(nixContent: string): CodeverseConfig {
    const config: CodeverseConfig = { packages: { apt: [], npm: [] }, env: {} };

    // Extract packages: pkgs.nodejs_20, pkgs.python3, etc.
    const pkgMatches = nixContent.matchAll(/pkgs\.([a-zA-Z0-9_\-]+)/g);
    for (const match of pkgMatches) {
        let pkgName = match[1];
        // Soft map nix packages to common ubuntu apt packages
        if (pkgName.startsWith('nodejs')) pkgName = 'nodejs npm';
        else if (pkgName.startsWith('python')) pkgName = 'python3 python3-pip';
        else if (pkgName === 'go') pkgName = 'golang';
        else if (pkgName === 'rust') pkgName = 'rustc cargo';
        // Add unique
        const split = pkgName.split(' ');
        for (const p of split) {
            if (!config.packages?.apt?.includes(p)) {
                config.packages?.apt?.push(p);
            }
        }
    }

    // Extract basic env: PORT = 3000;
    const envBlockMatch = nixContent.match(/env\s*=\s*{([^}]+)}/);
    if (envBlockMatch) {
        const envLines = envBlockMatch[1].split('\n');
        for (const line of envLines) {
            const kvMatch = line.trim().match(/([a-zA-Z0-9_]+)\s*=\s*['"]?([^'";]+)['"]?\s*;/);
            if (kvMatch) {
                if (config.env) config.env[kvMatch[1]] = kvMatch[2];
            }
        }
    }

    return config;
}

export async function loadWorkspaceConfig(workspacePath: string): Promise<CodeverseConfig> {
    const codeverseJsonPath = path.join(/*turbopackIgnore: true*/ workspacePath, 'codeverse.json');
    const idxNixPath = path.join(/*turbopackIgnore: true*/ workspacePath, '.idx', 'dev.nix');

    // 1. Check for Project IDX dev.nix
    try {
        const nixContent = await fs.readFile(idxNixPath, 'utf-8');
        return parseBasicNix(nixContent);
    } catch {
        // Fallthrough if no dev.nix
    }

    // 2. Check for codeverse.json
    try {
        const jsonContent = await fs.readFile(codeverseJsonPath, 'utf-8');
        return JSON.parse(jsonContent);
    } catch (err: unknown) {
        const fsErr = err as Error & { code?: string };
        if (fsErr.code === 'ENOENT') {
            // 3. Create default if none exists
            const defaultConfig: CodeverseConfig = {
                "env": { "PORT": "3000" },
                "packages": { "apt": [], "npm": [] }
            };
            await fs.writeFile(codeverseJsonPath, JSON.stringify(defaultConfig, null, 2));
            return defaultConfig;
        }
        throw err;
    }
}

/**
 * Dynamically writes a Dockerfile and uses Docker's native layer caching 
 * to speed up future startups with the same packages.
 */
export async function buildWorkspaceImage(
    workspaceId: string,
    workspacePath: string,
    onLog: (msg: string) => void,
    docker: Docker
): Promise<{ imageName: string, config: CodeverseConfig }> {

    onLog("Loading workspace configuration (codeverse.json or dev.nix)...");
    const config = await loadWorkspaceConfig(workspacePath);

    // Generate Dockerfile content
    let dockerfile = `FROM codercom/code-server:latest\n`;
    dockerfile += `USER root\n`;

    // APT Packages
    if (config.packages?.apt && config.packages.apt.length > 0) {
        const aptList = config.packages.apt.join(' ');
        dockerfile += `RUN apt-get update && apt-get install -y ${aptList} && rm -rf /var/lib/apt/lists/*\n`;
    }

    // NPM Packages
    if (config.packages?.npm && config.packages.npm.length > 0) {
        // Ensure nodejs and npm are present if not already installed
        if (!config.packages.apt?.some(p => p.includes('npm'))) {
            dockerfile += `RUN apt-get update && apt-get install -y nodejs npm && rm -rf /var/lib/apt/lists/*\n`;
        }
        const npmList = config.packages.npm.join(' ');
        dockerfile += `RUN npm install -g ${npmList}\n`;
    }

    // Environment map (so they exist in all shells)
    // We add them to /etc/environment and .bashrc to be safe
    if (config.env) {
        for (const [k, v] of Object.entries(config.env)) {
            dockerfile += `ENV ${k}="${v}"\n`;
            dockerfile += `RUN echo 'export ${k}="${v}"' >> /home/coder/.bashrc\n`;
        }
    }

    dockerfile += `USER coder\n`;
    // Set WORKDIR
    dockerfile += `WORKDIR /home/coder/project\n`;

    // Write to a temporary hidden dir in the workspace
    const buildDir = path.join(/*turbopackIgnore: true*/ workspacePath, '.codeverse');
    await fs.mkdir(buildDir, { recursive: true });

    const dockerfilePath = path.join(/*turbopackIgnore: true*/ buildDir, 'Dockerfile');
    await fs.writeFile(dockerfilePath, dockerfile);

    const imageName = `codeverse-workspace-${workspaceId}`;
    onLog(`Building image ${imageName}...`);

    return new Promise((resolve, reject) => {
        // Native Docker build engine with tar-fs
        const pack = tar.pack(buildDir);
        docker.buildImage(pack, { t: imageName }, (err: Error | null, stream?: NodeJS.ReadableStream) => {
            if (err) return reject(err);
            if (!stream) return reject(new Error("No stream returned from Docker build"));

            docker.modem.followProgress(stream,
                (err2: Error | null) => {
                    if (err2) return reject(err2);
                    onLog(`Image ${imageName} built successfully.`);
                    resolve({ imageName, config });
                },
                (event: { stream?: string, error?: string, status?: string }) => {
                    if (event.stream) onLog(event.stream.trim());
                    else if (event.status) onLog(event.status.trim());
                    else if (event.error) onLog(`Build Error: ${event.error}`);
                }
            );
        });
    });
}