freestyle-sync 0.1.0

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 (39) hide show
  1. package/PUBLISHING.md +3 -0
  2. package/README.md +47 -0
  3. package/freestyle-sync.config.ts +38 -0
  4. package/package.json +28 -0
  5. package/plugins/agent-claude/package.json +8 -0
  6. package/plugins/agent-claude/src/index.ts +113 -0
  7. package/plugins/agent-codex/package.json +8 -0
  8. package/plugins/agent-codex/src/index.ts +69 -0
  9. package/plugins/agent-copilot/package.json +8 -0
  10. package/plugins/agent-copilot/src/index.ts +542 -0
  11. package/plugins/auth-aws/package.json +8 -0
  12. package/plugins/auth-aws/src/index.ts +23 -0
  13. package/plugins/auth-azure/package.json +8 -0
  14. package/plugins/auth-azure/src/index.ts +23 -0
  15. package/plugins/auth-docker/package.json +8 -0
  16. package/plugins/auth-docker/src/index.ts +23 -0
  17. package/plugins/auth-env/package.json +8 -0
  18. package/plugins/auth-env/src/index.ts +35 -0
  19. package/plugins/auth-gcloud/package.json +8 -0
  20. package/plugins/auth-gcloud/src/index.ts +23 -0
  21. package/plugins/auth-git/package.json +8 -0
  22. package/plugins/auth-git/src/index.ts +43 -0
  23. package/plugins/auth-github-cli/package.json +8 -0
  24. package/plugins/auth-github-cli/src/index.ts +33 -0
  25. package/plugins/auth-npm/package.json +8 -0
  26. package/plugins/auth-npm/src/index.ts +32 -0
  27. package/plugins/auth-ssh/package.json +8 -0
  28. package/plugins/auth-ssh/src/index.ts +36 -0
  29. package/plugins/auth-yarn/package.json +8 -0
  30. package/plugins/auth-yarn/src/index.ts +19 -0
  31. package/plugins/node-npm/package.json +8 -0
  32. package/plugins/node-npm/src/index.ts +390 -0
  33. package/plugins/shell-history/package.json +8 -0
  34. package/plugins/shell-history/src/index.ts +64 -0
  35. package/plugins/vscode/package.json +8 -0
  36. package/plugins/vscode/src/index.ts +162 -0
  37. package/src/main.ts +1136 -0
  38. package/src/plugin-api.ts +107 -0
  39. package/tsconfig.json +18 -0
@@ -0,0 +1,390 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { definePlugin } from "../../../src/plugin-api.ts";
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ export function nodeNpmPlugin() {
8
+ return definePlugin({
9
+ name: "@freestyle-sync/node-npm",
10
+ async afterProjectSync({ vm, options }) {
11
+ const nodeVersion = process.versions.node;
12
+ const npmVersion = await localNpmVersion();
13
+ const script = `
14
+ set -e
15
+ export HOME="\${HOME:-/root}"
16
+ set -u
17
+ cd ${shellQuote(options.remoteProjectDir)}
18
+ if [ ! -f package.json ]; then
19
+ exit 0
20
+ fi
21
+ desired_node=${shellQuote(nodeVersion)}
22
+ desired_npm=${shellQuote(npmVersion ?? "")}
23
+ nvm_version=v0.40.3
24
+ export NVM_DIR=/root/.nvm
25
+ runtime_cache=.freestyle-sync/node-runtime-cache.json
26
+
27
+ install_nvm() {
28
+ if [ -s "$NVM_DIR/nvm.sh" ]; then
29
+ return 0
30
+ fi
31
+ mkdir -p "$NVM_DIR"
32
+ if command -v git >/dev/null 2>&1; then
33
+ rm -rf "$NVM_DIR"
34
+ git clone --depth 1 --branch "$nvm_version" https://github.com/nvm-sh/nvm.git "$NVM_DIR"
35
+ elif command -v curl >/dev/null 2>&1; then
36
+ curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/$nvm_version/install.sh" | PROFILE=/dev/null bash
37
+ elif command -v wget >/dev/null 2>&1; then
38
+ wget -qO- "https://raw.githubusercontent.com/nvm-sh/nvm/$nvm_version/install.sh" | PROFILE=/dev/null bash
39
+ else
40
+ echo "git, curl, or wget is required to install nvm" >&2
41
+ return 1
42
+ fi
43
+ }
44
+
45
+ install_nvm
46
+ . "$NVM_DIR/nvm.sh"
47
+ installed_node=$(nvm version "$desired_node" || true)
48
+ if [ "$installed_node" = "N/A" ]; then
49
+ echo "Installing Node $desired_node with nvm"
50
+ nvm install "$desired_node"
51
+ fi
52
+ nvm alias default "$desired_node" >/dev/null
53
+ nvm use --silent "$desired_node"
54
+ actual_node=$(node -p 'process.versions.node')
55
+ if [ "$actual_node" != "$desired_node" ]; then
56
+ echo "Expected Node $desired_node but activated $actual_node" >&2
57
+ exit 1
58
+ fi
59
+ actual_npm=$(npm --version 2>/dev/null || true)
60
+ if [ -n "$desired_npm" ] && [ "$actual_npm" != "$desired_npm" ]; then
61
+ echo "Installing npm $desired_npm for Node $desired_node"
62
+ npm install -g "npm@$desired_npm"
63
+ actual_npm=$(npm --version)
64
+ fi
65
+ mkdir -p .freestyle-sync
66
+ printf '{"node":"%s","npm":"%s","nvm":"%s"}\n' "$actual_node" "$actual_npm" "$nvm_version" > "$runtime_cache"
67
+ grep -qxF 'export NVM_DIR=/root/.nvm' /root/.profile || printf '\n%s\n' 'export NVM_DIR=/root/.nvm' >> /root/.profile
68
+ grep -qxF '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm use --silent default >/dev/null 2>&1' /root/.profile || printf '%s\n' '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm use --silent default >/dev/null 2>&1' >> /root/.profile
69
+
70
+ node <<'NODE'
71
+ const fs = require('node:fs');
72
+ const path = require('node:path');
73
+
74
+ const root = process.cwd();
75
+ const rootPackage = readJson(path.join(root, 'package.json'));
76
+ const workspaces = Array.isArray(rootPackage.workspaces)
77
+ ? rootPackage.workspaces
78
+ : Array.isArray(rootPackage.workspaces && rootPackage.workspaces.packages)
79
+ ? rootPackage.workspaces.packages
80
+ : [];
81
+
82
+ let repaired = 0;
83
+ for (const packageDir of unique(workspaces.flatMap(expandWorkspacePattern))) {
84
+ const packageJson = readJson(path.join(packageDir, 'package.json'));
85
+ if (typeof packageJson.name !== 'string' || packageJson.name.length === 0) continue;
86
+
87
+ const linkPath = path.join(root, 'node_modules', ...packageJson.name.split('/'));
88
+ const target = path.relative(path.dirname(linkPath), packageDir) || '.';
89
+ fs.mkdirSync(path.dirname(linkPath), { recursive: true });
90
+
91
+ try {
92
+ const stats = fs.lstatSync(linkPath);
93
+ if (stats.isSymbolicLink()) {
94
+ const currentTarget = fs.readlinkSync(linkPath);
95
+ if (currentTarget === target || path.resolve(path.dirname(linkPath), currentTarget) === packageDir) continue;
96
+ fs.unlinkSync(linkPath);
97
+ } else {
98
+ continue;
99
+ }
100
+ } catch (error) {
101
+ if (error.code !== 'ENOENT') continue;
102
+ }
103
+
104
+ fs.symlinkSync(target, linkPath, 'dir');
105
+ repaired += 1;
106
+ }
107
+
108
+ if (repaired > 0) console.log('Repaired npm workspace links: ' + repaired);
109
+
110
+ function readJson(filePath) {
111
+ try {
112
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
113
+ } catch {
114
+ return {};
115
+ }
116
+ }
117
+
118
+ function expandWorkspacePattern(pattern) {
119
+ if (typeof pattern !== 'string' || pattern.length === 0 || pattern.includes('**')) return [];
120
+ const segments = pattern.split(/[\\/]+/).filter(Boolean);
121
+ const starIndex = segments.indexOf('*');
122
+ if (starIndex === -1) return [path.resolve(root, ...segments)];
123
+ if (segments.indexOf('*', starIndex + 1) !== -1) return [];
124
+
125
+ const baseDir = path.resolve(root, ...segments.slice(0, starIndex));
126
+ const suffix = segments.slice(starIndex + 1);
127
+ let entries;
128
+ try {
129
+ entries = fs.readdirSync(baseDir, { withFileTypes: true });
130
+ } catch {
131
+ return [];
132
+ }
133
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => path.join(baseDir, entry.name, ...suffix));
134
+ }
135
+
136
+ function unique(values) {
137
+ return Array.from(new Set(values));
138
+ }
139
+ NODE
140
+
141
+ if [ ! -d node_modules ]; then
142
+ exit 0
143
+ fi
144
+ package_list=$(mktemp /tmp/vmpush-native-packages.XXXXXX)
145
+ fingerprint_file=$(mktemp /tmp/vmpush-native-fingerprints.XXXXXX)
146
+ scanner_file=$(mktemp /tmp/vmpush-native-scanner.XXXXXX.js)
147
+ cache_file=.freestyle-sync/npm-native-deps-cache.json
148
+ trap 'rm -f "$package_list" "$fingerprint_file" "$scanner_file"' EXIT
149
+ cat > "$scanner_file" <<'NODE'
150
+ const fs = require('node:fs');
151
+ const path = require('node:path');
152
+ const crypto = require('node:crypto');
153
+
154
+ const cachePath = path.resolve(process.argv[2]);
155
+ const fingerprintPath = process.argv[3];
156
+ const mode = process.argv[4];
157
+ const nodeModules = path.resolve('node_modules');
158
+ const packages = new Set();
159
+ const packageInfos = [];
160
+ const incompatiblePlatformPackages = new Set();
161
+ const nativeFilesByPackage = new Map();
162
+ const platformPackagesByWrapper = new Map();
163
+
164
+ function readPackageJson(packageDir) {
165
+ try {
166
+ return JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8'));
167
+ } catch {
168
+ return undefined;
169
+ }
170
+ }
171
+
172
+ function addPackage(packageDir) {
173
+ const packageJson = readPackageJson(packageDir);
174
+ if (typeof packageJson?.name !== 'string' || packageJson.name.length === 0) return;
175
+ packageInfos.push({ dir: packageDir, json: packageJson, name: packageJson.name });
176
+ }
177
+
178
+ function listAllowsCurrent(value, current) {
179
+ if (value === undefined) return true;
180
+ const list = Array.isArray(value) ? value : [value];
181
+ const positives = list.filter((item) => typeof item === 'string' && !item.startsWith('!'));
182
+ const negatives = list.filter((item) => typeof item === 'string' && item.startsWith('!')).map((item) => item.slice(1));
183
+ if (negatives.includes(current)) return false;
184
+ return positives.length === 0 || positives.includes(current);
185
+ }
186
+
187
+ function packageAllowsCurrentPlatform(packageJson) {
188
+ return listAllowsCurrent(packageJson.os, process.platform) && listAllowsCurrent(packageJson.cpu, process.arch);
189
+ }
190
+
191
+ function dependencyNames(packageJson) {
192
+ return Object.keys(Object.assign({}, packageJson.dependencies, packageJson.optionalDependencies));
193
+ }
194
+
195
+ function addMapSet(map, key, value) {
196
+ const existing = map.get(key);
197
+ if (existing) existing.add(value);
198
+ else map.set(key, new Set([value]));
199
+ }
200
+
201
+ function relativeToProject(filePath) {
202
+ return path.relative(process.cwd(), filePath).split(path.sep).join('/');
203
+ }
204
+
205
+ function collectPackageFiles(packageDir, files) {
206
+ let entries;
207
+ try {
208
+ entries = fs.readdirSync(packageDir, { withFileTypes: true });
209
+ } catch {
210
+ return;
211
+ }
212
+
213
+ for (const entry of entries) {
214
+ const child = path.join(packageDir, entry.name);
215
+ if (entry.isDirectory()) collectPackageFiles(child, files);
216
+ else if (entry.isFile()) files.push(child);
217
+ }
218
+ }
219
+
220
+ function packageFingerprint(packageName) {
221
+ const info = packageInfos.find((candidate) => candidate.name === packageName);
222
+ if (!info) return undefined;
223
+ const files = [path.join(info.dir, 'package.json')];
224
+ for (const nativeFile of nativeFilesByPackage.get(packageName) || []) files.push(nativeFile);
225
+ for (const platformPackageName of platformPackagesByWrapper.get(packageName) || []) {
226
+ const platformInfo = packageInfos.find((candidate) => candidate.name === platformPackageName);
227
+ if (!platformInfo) continue;
228
+ files.push(path.join(platformInfo.dir, 'package.json'));
229
+ collectPackageFiles(platformInfo.dir, files);
230
+ }
231
+
232
+ const hash = crypto.createHash('sha256');
233
+ hash.update(process.platform);
234
+ hash.update(String.fromCharCode(0));
235
+ hash.update(process.arch);
236
+ hash.update(String.fromCharCode(0));
237
+ hash.update(process.versions.modules || '');
238
+ hash.update(String.fromCharCode(0));
239
+ for (const filePath of Array.from(new Set(files)).sort()) {
240
+ try {
241
+ const stats = fs.statSync(filePath);
242
+ if (!stats.isFile()) continue;
243
+ hash.update(relativeToProject(filePath));
244
+ hash.update(String.fromCharCode(0));
245
+ hash.update(String(stats.size));
246
+ hash.update(String.fromCharCode(0));
247
+ hash.update(String(stats.mtimeMs));
248
+ hash.update(String.fromCharCode(0));
249
+ hash.update(String(stats.mode));
250
+ hash.update(String.fromCharCode(0));
251
+ } catch {
252
+ // Ignore files that disappeared while scanning.
253
+ }
254
+ }
255
+ return hash.digest('hex');
256
+ }
257
+
258
+ function nearestPackageDir(start) {
259
+ let current = start;
260
+ while (current.startsWith(nodeModules)) {
261
+ if (fs.existsSync(path.join(current, 'package.json'))) return current;
262
+ const parent = path.dirname(current);
263
+ if (parent === current) break;
264
+ current = parent;
265
+ }
266
+ return undefined;
267
+ }
268
+
269
+ function addNativePackage(nativeFile) {
270
+ const packageDir = nearestPackageDir(path.dirname(nativeFile));
271
+ if (!packageDir) return;
272
+ const packageJson = readPackageJson(packageDir);
273
+ if (typeof packageJson?.name !== 'string' || packageJson.name.length === 0) return;
274
+ packages.add(packageJson.name);
275
+ addMapSet(nativeFilesByPackage, packageJson.name, nativeFile);
276
+ }
277
+
278
+ function walk(directory) {
279
+ let entries;
280
+ try {
281
+ entries = fs.readdirSync(directory, { withFileTypes: true });
282
+ } catch {
283
+ return;
284
+ }
285
+
286
+ for (const entry of entries) {
287
+ const child = path.join(directory, entry.name);
288
+ if (entry.isDirectory()) {
289
+ if (entry.name !== 'node_modules' && fs.existsSync(path.join(child, 'package.json'))) addPackage(child);
290
+ walk(child);
291
+ } else if (entry.isFile() && entry.name.endsWith('.node')) {
292
+ addNativePackage(child);
293
+ }
294
+ }
295
+ }
296
+
297
+ function cacheEntryMatches(entry, fingerprint) {
298
+ if (entry === fingerprint) return true;
299
+ if (entry && typeof entry === 'object' && (entry.input === fingerprint || entry.fixed === fingerprint)) return true;
300
+ return false;
301
+ }
302
+
303
+ walk(nodeModules);
304
+ for (const info of packageInfos) {
305
+ if ((info.json.os !== undefined || info.json.cpu !== undefined) && !packageAllowsCurrentPlatform(info.json)) {
306
+ incompatiblePlatformPackages.add(info.name);
307
+ }
308
+ }
309
+ for (const info of packageInfos) {
310
+ const platformDependencies = dependencyNames(info.json).filter((dependency) => incompatiblePlatformPackages.has(dependency));
311
+ if (platformDependencies.length === 0) continue;
312
+ packages.add(info.name);
313
+ for (const dependency of platformDependencies) addMapSet(platformPackagesByWrapper, info.name, dependency);
314
+ }
315
+
316
+ let previous = {};
317
+ try {
318
+ previous = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
319
+ } catch {
320
+ previous = {};
321
+ }
322
+ const next = Object.assign({}, previous);
323
+ const changedPackages = [];
324
+ for (const packageName of Array.from(packages).sort()) {
325
+ const fingerprint = packageFingerprint(packageName);
326
+ if (!fingerprint) continue;
327
+ const previousEntry = previous[packageName];
328
+ if (mode !== 'record' && !cacheEntryMatches(previousEntry, fingerprint)) changedPackages.push(packageName);
329
+ const previousObject = previousEntry && typeof previousEntry === 'object' ? previousEntry : {};
330
+ next[packageName] = mode === 'record'
331
+ ? Object.assign({}, previousObject, { fixed: fingerprint })
332
+ : Object.assign({}, previousObject, { input: fingerprint });
333
+ }
334
+ fs.mkdirSync(path.dirname(fingerprintPath), { recursive: true });
335
+ fs.writeFileSync(fingerprintPath, JSON.stringify(next, null, 2) + String.fromCharCode(10));
336
+ if (mode !== 'record') {
337
+ for (const packageName of changedPackages) console.log(packageName);
338
+ }
339
+ NODE
340
+ node "$scanner_file" "$cache_file" "$fingerprint_file" detect > "$package_list"
341
+ if [ ! -s "$package_list" ]; then
342
+ mkdir -p .freestyle-sync
343
+ cp "$fingerprint_file" "$cache_file"
344
+ exit 0
345
+ fi
346
+ echo "Rebuilding native Node dependencies: $(tr '\n' ' ' < "$package_list")"
347
+ if [ -f pnpm-lock.yaml ] && command -v corepack >/dev/null 2>&1; then
348
+ corepack enable >/dev/null 2>&1 || true
349
+ xargs pnpm rebuild < "$package_list"
350
+ elif [ -f yarn.lock ] && command -v corepack >/dev/null 2>&1; then
351
+ corepack enable >/dev/null 2>&1 || true
352
+ if yarn rebuild --help >/dev/null 2>&1; then
353
+ xargs yarn rebuild < "$package_list"
354
+ else
355
+ echo "Yarn does not support targeted rebuild here; skipping broad reinstall"
356
+ fi
357
+ elif command -v npm >/dev/null 2>&1; then
358
+ xargs npm rebuild < "$package_list"
359
+ fi
360
+ node "$scanner_file" "$cache_file" "$fingerprint_file" record > /dev/null
361
+ mkdir -p .freestyle-sync
362
+ cp "$fingerprint_file" "$cache_file"
363
+ `;
364
+ const scriptPath = `/tmp/vmpush-node-npm-${Date.now()}.sh`;
365
+ await vm.fs.writeTextFile(scriptPath, script);
366
+ const result = await vm.exec({
367
+ command: `bash ${shellQuote(scriptPath)}\nexit_code=$?\nrm -f ${shellQuote(scriptPath)}\nexit "$exit_code"`,
368
+ timeoutMs: 20 * 60 * 1000,
369
+ });
370
+ if (result.statusCode && result.statusCode !== 0) {
371
+ throw new Error(result.stderr ?? result.stdout ?? `Node/npm fixup failed with status ${result.statusCode}`);
372
+ }
373
+ if (result.stdout?.trim()) console.log(result.stdout.trim());
374
+ },
375
+ });
376
+ }
377
+
378
+ async function localNpmVersion() {
379
+ try {
380
+ const { stdout } = await execFileAsync("npm", ["--version"]);
381
+ const version = stdout.trim();
382
+ return version.length > 0 ? version : undefined;
383
+ } catch {
384
+ return undefined;
385
+ }
386
+ }
387
+
388
+ function shellQuote(value: string): string {
389
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
390
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "@freestyle-sync/shell-history",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.ts"
7
+ }
8
+ }
@@ -0,0 +1,64 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ import { definePlugin, type ContextCandidate, type PushvmPluginUtils, type RemoteVm } from "../../../src/plugin-api.ts";
5
+
6
+ export function shellHistoryPlugin() {
7
+ return definePlugin({
8
+ name: "@freestyle-sync/shell-history",
9
+ async discoverContextCandidates() {
10
+ const item: ContextCandidate = {
11
+ source: path.join(homedir(), ".zsh_history"),
12
+ remoteRoot: "/root/.zsh_history",
13
+ label: "zsh history",
14
+ sensitive: true,
15
+ preferenceKey: "context:zsh-history",
16
+ promptLabel: "zsh history",
17
+ };
18
+ return await exists(item.source) ? [item] : [];
19
+ },
20
+ async afterContextSync({ vm, changedRemotePaths, utils }) {
21
+ if (!changedRemotePaths.includes("/root/.zsh_history")) return;
22
+ await seedRemoteShellHistory(vm, utils.checkedExec);
23
+ },
24
+ async afterSync({ vm, utils }) {
25
+ await seedRemoteShellHistory(vm, utils.checkedExec);
26
+ },
27
+ });
28
+ }
29
+
30
+ async function seedRemoteShellHistory(
31
+ vm: RemoteVm,
32
+ checkedExec: PushvmPluginUtils["checkedExec"],
33
+ ) {
34
+ await checkedExec(vm, `
35
+ set -eu
36
+ if [ ! -f /root/.zsh_history ]; then
37
+ exit 0
38
+ fi
39
+ node <<'NODE'
40
+ const fs = require('node:fs');
41
+
42
+ const zshHistory = '/root/.zsh_history';
43
+ const bashHistory = '/root/.bash_history';
44
+ const newline = String.fromCharCode(10);
45
+ const raw = fs.readFileSync(zshHistory, 'utf8');
46
+ const commands = raw
47
+ .split(newline)
48
+ .map((line) => {
49
+ const extended = line.match(/^: [0-9]+:[0-9]+;(.*)$/);
50
+ return extended ? extended[1] : line;
51
+ })
52
+ .map((line) => line.trimEnd())
53
+ .filter((line) => line.length > 0);
54
+
55
+ fs.writeFileSync(bashHistory, commands.join(newline) + (commands.length > 0 ? newline : ''), { mode: 0o600 });
56
+ NODE
57
+ chown root:root /root/.zsh_history /root/.bash_history 2>/dev/null || true
58
+ chmod 600 /root/.zsh_history /root/.bash_history 2>/dev/null || true
59
+ `);
60
+ }
61
+
62
+ async function exists(filePath: string) {
63
+ try { await stat(filePath); return true; } catch { return false; }
64
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "@freestyle-sync/vscode",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.ts"
7
+ }
8
+ }
@@ -0,0 +1,162 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { freestyle } from "freestyle";
4
+ import { definePlugin, type ConnectHookContext, type RemoteVm } from "../../../src/plugin-api.ts";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ export function vscodePlugin() {
9
+ return definePlugin({
10
+ name: "@freestyle-sync/vscode",
11
+ async beforeSnapshot({ vm, utils }) {
12
+ await warmVsCodeServerCache(vm, utils.shellQuote, utils.checkedExec).catch((error) => {
13
+ console.warn(`VS Code Server cache warm-up skipped: ${error instanceof Error ? error.message : String(error)}`);
14
+ });
15
+ },
16
+ async connect(context) {
17
+ const editorScheme = await detectEditorScheme();
18
+ if (!editorScheme) return false;
19
+ await openRemoteEditor(context, editorScheme);
20
+ return true;
21
+ },
22
+ });
23
+ }
24
+
25
+ async function warmVsCodeServerCache(
26
+ vm: RemoteVm,
27
+ shellQuote: (value: string) => string,
28
+ checkedExec: (vm: RemoteVm, command: string, timeoutMs?: number) => Promise<{ stdout?: string | null }>,
29
+ ) {
30
+ const commit = await localVsCodeCommit();
31
+ if (!commit) return;
32
+
33
+ const script = `
34
+ set -eu
35
+ commit=${shellQuote(commit)}
36
+ machine=$(uname -m)
37
+ case "$machine" in
38
+ x86_64|amd64) arch=x64 ;;
39
+ aarch64|arm64) arch=arm64 ;;
40
+ armv7l|armv8l) arch=armhf ;;
41
+ *) echo "unsupported VS Code Server architecture: $machine" >&2; exit 0 ;;
42
+ esac
43
+ bin_dir=/root/.vscode-server/bin/$commit
44
+ cli_server_dir=/root/.vscode-server/cli/servers/Stable-$commit/server
45
+ if { [ -x "$bin_dir/server.sh" ] || [ -x "$bin_dir/bin/code-server" ]; } && [ -x "$cli_server_dir/bin/code-server" ]; then
46
+ echo "VS Code Server $commit already cached"
47
+ exit 0
48
+ fi
49
+ tmp=$(mktemp -d /tmp/vmpush-vscode-server.XXXXXX)
50
+ trap 'rm -rf "$tmp"' EXIT
51
+ url=https://update.code.visualstudio.com/commit:$commit/server-linux-$arch/stable
52
+ archive=$tmp/server.tgz
53
+ if command -v curl >/dev/null 2>&1; then
54
+ curl -fsSL "$url" -o "$archive"
55
+ elif command -v wget >/dev/null 2>&1; then
56
+ wget -qO "$archive" "$url"
57
+ else
58
+ echo "curl or wget is required to cache VS Code Server" >&2
59
+ exit 0
60
+ fi
61
+ tar -xzf "$archive" -C "$tmp"
62
+ extracted=$(find "$tmp" -mindepth 1 -maxdepth 1 -type d | head -n 1)
63
+ if [ -z "$extracted" ]; then
64
+ echo "VS Code Server archive did not contain a directory" >&2
65
+ exit 1
66
+ fi
67
+ if ! { [ -x "$bin_dir/server.sh" ] || [ -x "$bin_dir/bin/code-server" ]; }; then
68
+ mkdir -p "$bin_dir"
69
+ cp -a "$extracted"/. "$bin_dir"/
70
+ touch "$bin_dir/0"
71
+ fi
72
+ if [ ! -x "$cli_server_dir/bin/code-server" ]; then
73
+ mkdir -p "$cli_server_dir"
74
+ cp -a "$extracted"/. "$cli_server_dir"/
75
+ fi
76
+ echo "Cached VS Code Server $commit for linux-$arch"
77
+ `;
78
+
79
+ const result = await checkedExec(vm, script);
80
+ if (result.stdout?.trim()) console.log(result.stdout.trim());
81
+ }
82
+
83
+ async function localVsCodeCommit() {
84
+ try {
85
+ const { stdout } = await execFileAsync("code", ["--version"]);
86
+ const commit = stdout.split(/\r?\n/)[1]?.trim();
87
+ return /^[0-9a-f]{40}$/i.test(commit ?? "") ? commit : undefined;
88
+ } catch {
89
+ return undefined;
90
+ }
91
+ }
92
+
93
+ async function openRemoteEditor(context: ConnectHookContext, scheme: "vscode" | "cursor") {
94
+ const { vmId, options } = context;
95
+ console.log(`Opening ${scheme === "cursor" ? "Cursor" : "VS Code"} Remote SSH window for VM ${vmId}...`);
96
+ const { identity, identityId } = await freestyle.identities.create();
97
+ await identity.permissions.vms.grant({ vmId });
98
+ const { token, tokenId } = await identity.tokens.create();
99
+ const remoteWorkspaceUri = `vscode-remote://ssh-remote%2B${vmId}%2C${token}@${vmId}.vm-ssh.freestyle.sh${encodeRemotePath(options.remoteProjectDir)}`;
100
+ const uri = `${scheme}://vscode-remote/ssh-remote+${vmId},${token}@${vmId}.vm-ssh.freestyle.sh${encodeRemotePath(options.remoteProjectDir)}?windowId=_blank`;
101
+ try {
102
+ const preOpenMessages = await context.runBeforeOpenRemoteEditor({ scheme, remoteWorkspaceUri });
103
+ for (const message of preOpenMessages) console.log(message);
104
+ await openUri(uri);
105
+ } catch (error) {
106
+ await identity.tokens.revoke({ tokenId }).catch(() => undefined);
107
+ await freestyle.identities.delete({ identityId }).catch(() => undefined);
108
+ throw error;
109
+ }
110
+ await context.utils.delay(5000);
111
+ const postOpenMessages = await context.runAfterOpenRemoteEditor({ scheme, remoteWorkspaceUri });
112
+ for (const message of postOpenMessages) console.log(message);
113
+ console.log(`Opened ${scheme === "cursor" ? "Cursor" : "VS Code"}. SSH identity ${identityId} remains active for the editor session.`);
114
+ }
115
+
116
+ async function detectEditorScheme(): Promise<"vscode" | "cursor" | undefined> {
117
+ const envText = Object.entries(process.env)
118
+ .filter(([key]) => key.startsWith("VSCODE_") || key.startsWith("CURSOR") || key === "TERM_PROGRAM")
119
+ .map(([key, value]) => `${key}=${value ?? ""}`)
120
+ .join("\n")
121
+ .toLowerCase();
122
+
123
+ if (envText.includes("cursor")) return "cursor";
124
+ if (!envText.includes("term_program=vscode") && !envText.includes("vscode_")) return undefined;
125
+
126
+ const parentCommands = await processCommandChain(process.ppid);
127
+ if (parentCommands.some((command) => command.toLowerCase().includes("cursor"))) return "cursor";
128
+ return "vscode";
129
+ }
130
+
131
+ async function processCommandChain(startPid: number) {
132
+ const commands: string[] = [];
133
+ let pid = startPid;
134
+ for (let depth = 0; depth < 20 && pid > 1; depth += 1) {
135
+ try {
136
+ const { stdout } = await execFileAsync("ps", ["-p", String(pid), "-o", "ppid=", "-o", "comm="]);
137
+ const line = stdout.trim();
138
+ if (!line) break;
139
+ const match = line.match(/^(\d+)\s+(.+)$/);
140
+ if (!match) break;
141
+ pid = Number(match[1]);
142
+ commands.push(match[2]);
143
+ } catch {
144
+ break;
145
+ }
146
+ }
147
+ return commands;
148
+ }
149
+
150
+ function encodeRemotePath(remoteProjectDir: string) {
151
+ return remoteProjectDir.split("/").map((segment) => encodeURIComponent(segment)).join("/");
152
+ }
153
+
154
+ async function openUri(uri: string) {
155
+ if (process.platform === "darwin") {
156
+ await execFileAsync("open", [uri]);
157
+ } else if (process.platform === "win32") {
158
+ await execFileAsync("cmd", ["/c", "start", "", uri]);
159
+ } else {
160
+ await execFileAsync("xdg-open", [uri]);
161
+ }
162
+ }