freestyle-sync 0.1.2 → 0.1.4

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 (45) hide show
  1. package/README.md +2 -1
  2. package/{freestyle-sync.config.ts → dist/freestyle-sync.config.js} +2 -3
  3. package/dist/main.js +1319 -0
  4. package/{plugins/agent-claude/src/index.ts → dist/plugins/agent-claude/src/index.js} +32 -29
  5. package/{plugins/agent-codex/src/index.ts → dist/plugins/agent-codex/src/index.js} +13 -14
  6. package/{plugins/agent-copilot/src/index.ts → dist/plugins/agent-copilot/src/index.js} +151 -164
  7. package/{plugins/auth-aws/src/index.ts → dist/plugins/auth-aws/src/index.js} +14 -8
  8. package/{plugins/auth-azure/src/index.ts → dist/plugins/auth-azure/src/index.js} +14 -8
  9. package/dist/plugins/auth-context.js +213 -0
  10. package/{plugins/auth-docker/src/index.ts → dist/plugins/auth-docker/src/index.js} +14 -8
  11. package/{plugins/auth-env/src/index.ts → dist/plugins/auth-env/src/index.js} +11 -11
  12. package/{plugins/auth-gcloud/src/index.ts → dist/plugins/auth-gcloud/src/index.js} +14 -8
  13. package/{plugins/auth-git/src/index.ts → dist/plugins/auth-git/src/index.js} +24 -17
  14. package/{plugins/auth-github-cli/src/index.ts → dist/plugins/auth-github-cli/src/index.js} +20 -14
  15. package/{plugins/auth-npm/src/index.ts → dist/plugins/auth-npm/src/index.js} +19 -13
  16. package/{plugins/auth-ssh/src/index.ts → dist/plugins/auth-ssh/src/index.js} +19 -13
  17. package/dist/plugins/auth-yarn/src/index.js +24 -0
  18. package/{plugins/node-npm/src/index.ts → dist/plugins/node-npm/src/index.js} +6 -8
  19. package/dist/plugins/npm-native-deps.js +307 -0
  20. package/{plugins/shell-history/src/index.ts → dist/plugins/shell-history/src/index.js} +13 -12
  21. package/{plugins/vscode/src/index.ts → dist/plugins/vscode/src/index.js} +38 -40
  22. package/{src/main.ts → dist/src/main.js} +406 -463
  23. package/dist/src/plugin-api.js +6 -0
  24. package/dist/src/pushvm.config.js +36 -0
  25. package/package.json +8 -4
  26. package/PUBLISHING.md +0 -3
  27. package/plugins/agent-claude/package.json +0 -8
  28. package/plugins/agent-codex/package.json +0 -8
  29. package/plugins/agent-copilot/package.json +0 -8
  30. package/plugins/auth-aws/package.json +0 -8
  31. package/plugins/auth-azure/package.json +0 -8
  32. package/plugins/auth-docker/package.json +0 -8
  33. package/plugins/auth-env/package.json +0 -8
  34. package/plugins/auth-gcloud/package.json +0 -8
  35. package/plugins/auth-git/package.json +0 -8
  36. package/plugins/auth-github-cli/package.json +0 -8
  37. package/plugins/auth-npm/package.json +0 -8
  38. package/plugins/auth-ssh/package.json +0 -8
  39. package/plugins/auth-yarn/package.json +0 -8
  40. package/plugins/auth-yarn/src/index.ts +0 -19
  41. package/plugins/node-npm/package.json +0 -8
  42. package/plugins/shell-history/package.json +0 -8
  43. package/plugins/vscode/package.json +0 -8
  44. package/src/plugin-api.ts +0 -107
  45. package/tsconfig.json +0 -18
package/dist/main.js ADDED
@@ -0,0 +1,1319 @@
1
+ #!/usr/bin/env node
2
+ import "dotenv/config";
3
+ import { createHash } from "node:crypto";
4
+ import { createReadStream } from "node:fs";
5
+ import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
6
+ import { homedir, tmpdir } from "node:os";
7
+ import path from "node:path";
8
+ import { execFile, spawn } from "node:child_process";
9
+ import { promisify } from "node:util";
10
+ import { createInterface } from "node:readline/promises";
11
+ import { stdin as input, stdout as output } from "node:process";
12
+ import { freestyle } from "freestyle";
13
+ import { collectEnvironment, discoverContextCandidates, hardenRemoteSecrets, isProtectedRemotePath, shouldSkipContextDirectory } from "./plugins/auth-context.js";
14
+ import { npmNativeDepsPlugin } from "./plugins/npm-native-deps.js";
15
+ const execFileAsync = promisify(execFile);
16
+ const CACHE_VERSION = 1;
17
+ const SENSITIVE_PREFERENCES_VERSION = 2;
18
+ const ARCHIVE_CHUNK_CHARS = 1024 * 1024;
19
+ class Progress {
20
+ current = 0;
21
+ total;
22
+ constructor(total) {
23
+ this.total = total;
24
+ }
25
+ step(message) {
26
+ this.current += 1;
27
+ console.log(`[${this.current}/${this.total}] ${message}`);
28
+ }
29
+ }
30
+ main().catch((error) => {
31
+ console.error(`vmpush: ${error instanceof Error ? error.message : String(error)}`);
32
+ process.exitCode = 1;
33
+ });
34
+ async function main() {
35
+ const options = await parseArgs(process.argv.slice(2));
36
+ const progress = new Progress(options.dryRun ? 2 : options.install ? 12 : 11);
37
+ if (options.dryRun) {
38
+ console.log("vmpush dry run");
39
+ }
40
+ progress.step("Scanning project files");
41
+ const cache = await readCache(options.cachePath, options);
42
+ const projectEntries = await scanProject(options.projectRoot, options.includeGitDir);
43
+ const projectCurrent = digestMap(projectEntries);
44
+ const projectChanges = diffEntries(projectEntries, cache.projectFiles);
45
+ progress.step("Detecting auth and agent context");
46
+ let envExports = options.includeAuth ? collectEnvironment(options.envKeys) : {};
47
+ let contextCandidates = await discoverContextCandidates(options);
48
+ if (!options.dryRun) {
49
+ const selection = await selectSensitiveSync(options, contextCandidates, envExports);
50
+ contextCandidates = selection.contextCandidates;
51
+ envExports = selection.envExports;
52
+ }
53
+ const envHash = hashString(renderEnvFile(envExports));
54
+ const contextEntries = await scanContextCandidates(contextCandidates);
55
+ const contextCurrent = digestMapByRemotePath(contextEntries);
56
+ const contextChanges = diffContextEntries(contextEntries, cache.contextFiles);
57
+ printPlan(options, projectChanges, contextChanges, envExports, cache);
58
+ if (options.dryRun) {
59
+ return;
60
+ }
61
+ progress.step("Preparing Freestyle VM");
62
+ const { vm, vmId } = await getOrCreateVm(options, cache.snapshotId);
63
+ console.log(`Uploading to VM: ${vmId}`);
64
+ progress.step(`Uploading project changes to ${vmId}`);
65
+ await ensureRemoteBase(vm, options.remoteProjectDir);
66
+ const projectResult = await syncProject(vm, vmId, options, projectChanges);
67
+ progress.step(`Running remote fixups on ${vmId}`);
68
+ await runRemoteFixups(vm, options);
69
+ progress.step(`Uploading auth/context changes to ${vmId}`);
70
+ const contextResult = await syncContext(vm, vmId, contextChanges);
71
+ progress.step(`Installing environment files on ${vmId}`);
72
+ await installEnvironment(vm, envExports, envHash, cache.envHash);
73
+ progress.step(`Writing resume metadata on ${vmId}`);
74
+ await writeRemoteResumeFiles(vm, options, vmId, envExports);
75
+ await hardenRemoteRoot(vm);
76
+ if (options.install) {
77
+ progress.step(`Running install command on ${vmId}`);
78
+ await runInstall(vm, options.projectRoot, options.remoteProjectDir);
79
+ }
80
+ let snapshotId = cache.snapshotId;
81
+ if (options.snapshot) {
82
+ progress.step(`Creating snapshot cache for ${vmId}`);
83
+ await warmVsCodeServerCache(vm).catch((error) => {
84
+ console.warn(`VS Code Server cache warm-up skipped: ${error instanceof Error ? error.message : String(error)}`);
85
+ });
86
+ try {
87
+ const snapshot = await vm.snapshot({ name: `vmpush-${path.basename(options.projectRoot)}-${Date.now()}` });
88
+ snapshotId = snapshot.snapshotId;
89
+ }
90
+ catch (error) {
91
+ console.warn(`Snapshot cache skipped: ${error instanceof Error ? error.message : String(error)}`);
92
+ }
93
+ }
94
+ else {
95
+ progress.step("Skipping snapshot cache");
96
+ }
97
+ progress.step("Saving local sync cache");
98
+ await writeCache(options.cachePath, {
99
+ version: CACHE_VERSION,
100
+ projectRoot: options.projectRoot,
101
+ remoteProjectDir: options.remoteProjectDir,
102
+ vmId,
103
+ snapshotId,
104
+ projectFiles: projectCurrent,
105
+ contextFiles: contextCurrent,
106
+ envHash,
107
+ updatedAt: new Date().toISOString(),
108
+ });
109
+ progress.step(`Syncing local VS Code Copilot state for ${vmId}`);
110
+ const localCopilotResult = await syncLocalCopilotForRemoteWorkspaces(vm, options, vmId, contextCandidates);
111
+ console.log("");
112
+ console.log(`VM ready: ${vmId}`);
113
+ console.log(`Project: ${options.remoteProjectDir}`);
114
+ console.log(`Project files: ${projectResult.uploaded} uploaded, ${projectResult.removed} removed, ${projectResult.unchanged} unchanged`);
115
+ console.log(`Context files: ${contextResult.uploaded} uploaded, ${contextResult.unchanged} unchanged`);
116
+ if (snapshotId) {
117
+ console.log(`Snapshot cache: ${snapshotId}`);
118
+ }
119
+ if (localCopilotResult.status === "copied") {
120
+ console.log(`Copilot sidebar state: copied to ${localCopilotResult.count} VS Code Remote workspace${localCopilotResult.count === 1 ? "" : "s"}`);
121
+ }
122
+ else if (localCopilotResult.status === "pending") {
123
+ console.log(`Copilot sidebar state: ${localCopilotResult.reason}`);
124
+ }
125
+ console.log(`SSH: npx freestyle vm ssh ${vmId}`);
126
+ if (options.autoSsh) {
127
+ await connectToVm(vm, vmId, options, contextCandidates);
128
+ }
129
+ }
130
+ async function parseArgs(args) {
131
+ const options = {
132
+ projectRoot: process.cwd(),
133
+ cachePath: "",
134
+ remoteProjectDir: "",
135
+ name: "",
136
+ yes: false,
137
+ dryRun: false,
138
+ includeAuth: true,
139
+ includeAgentContext: true,
140
+ includeGitDir: true,
141
+ includeAllCopilotWorkspaces: false,
142
+ resetContextPrefs: false,
143
+ snapshot: true,
144
+ install: false,
145
+ autoSsh: true,
146
+ envKeys: [],
147
+ };
148
+ const positional = [];
149
+ for (let index = 0; index < args.length; index += 1) {
150
+ const arg = args[index];
151
+ if (arg === "--help" || arg === "-h") {
152
+ printHelp();
153
+ process.exit(0);
154
+ }
155
+ else if (arg === "--yes" || arg === "-y") {
156
+ options.yes = true;
157
+ }
158
+ else if (arg === "--dry-run") {
159
+ options.dryRun = true;
160
+ }
161
+ else if (arg === "--no-auth") {
162
+ options.includeAuth = false;
163
+ }
164
+ else if (arg === "--no-agent-context") {
165
+ options.includeAgentContext = false;
166
+ }
167
+ else if (arg === "--no-git-dir") {
168
+ options.includeGitDir = false;
169
+ }
170
+ else if (arg === "--all-copilot-workspaces") {
171
+ options.includeAllCopilotWorkspaces = true;
172
+ }
173
+ else if (arg === "--reset-context-prefs") {
174
+ options.resetContextPrefs = true;
175
+ }
176
+ else if (arg === "--no-snapshot" || arg === "--skip-snapshot") {
177
+ options.snapshot = false;
178
+ }
179
+ else if (arg === "--install") {
180
+ options.install = true;
181
+ }
182
+ else if (arg === "--no-ssh") {
183
+ options.autoSsh = false;
184
+ }
185
+ else if (arg === "--vm-id") {
186
+ options.vmId = readOptionValue(args, ++index, arg);
187
+ }
188
+ else if (arg === "--name") {
189
+ options.name = readOptionValue(args, ++index, arg);
190
+ }
191
+ else if (arg === "--remote-dir") {
192
+ options.remoteProjectDir = normalizeRemotePath(readOptionValue(args, ++index, arg));
193
+ }
194
+ else if (arg === "--cache") {
195
+ options.cachePath = path.resolve(readOptionValue(args, ++index, arg));
196
+ }
197
+ else if (arg === "--idle-timeout") {
198
+ options.idleTimeoutSeconds = Number(readOptionValue(args, ++index, arg));
199
+ if (!Number.isInteger(options.idleTimeoutSeconds) || options.idleTimeoutSeconds < 1) {
200
+ throw new Error("--idle-timeout must be a positive integer");
201
+ }
202
+ }
203
+ else if (arg === "--include-env") {
204
+ options.envKeys.push(readOptionValue(args, ++index, arg));
205
+ }
206
+ else if (arg.startsWith("--")) {
207
+ throw new Error(`unknown option: ${arg}`);
208
+ }
209
+ else {
210
+ positional.push(arg);
211
+ }
212
+ }
213
+ if (positional.length > 1) {
214
+ throw new Error("expected at most one project path");
215
+ }
216
+ options.projectRoot = path.resolve(positional[0] ?? options.projectRoot);
217
+ const projectStats = await stat(options.projectRoot).catch(() => null);
218
+ if (!projectStats?.isDirectory()) {
219
+ throw new Error(`project path is not a directory: ${options.projectRoot}`);
220
+ }
221
+ const projectName = sanitizeName(path.basename(options.projectRoot));
222
+ options.name ||= `vmpush-${projectName}`;
223
+ options.remoteProjectDir ||= defaultRemoteProjectDir(options.projectRoot);
224
+ options.cachePath ||= path.join(options.projectRoot, ".vmpush", "cache.json");
225
+ return options;
226
+ }
227
+ function printHelp() {
228
+ console.log(`vmpush uploads the current project into a Freestyle VM.
229
+
230
+ Usage:
231
+ vmpush [project-dir] [options]
232
+
233
+ Options:
234
+ --vm-id <id> Sync into an existing Freestyle VM.
235
+ --name <name> Name for a newly created VM. Defaults to vmpush-<project>.
236
+ --remote-dir <path> Remote project directory. Defaults to the local absolute path.
237
+ --cache <path> Snapshot/hash cache path. Defaults to .vmpush/cache.json.
238
+ --include-env <name> Always copy an environment variable. Repeatable.
239
+ --install Run detected dependency install command after sync.
240
+ --no-ssh Do not automatically open VS Code/Cursor or SSH after sync.
241
+ --idle-timeout <seconds> Set VM idle timeout when creating/starting.
242
+ --no-auth Do not copy auth files or token-like environment variables.
243
+ --no-agent-context Do not copy Copilot, Claude, or Codex conversation state.
244
+ --no-git-dir Exclude the local .git directory from project sync.
245
+ --all-copilot-workspaces Include Copilot chat state for every VS Code workspace.
246
+ --reset-context-prefs Re-prompt for saved auth/conversation preferences.
247
+ --no-snapshot, --skip-snapshot
248
+ Do not snapshot the VM after sync.
249
+ --dry-run Show what would sync without creating or changing a VM.
250
+ -y, --yes Approve copying discovered sensitive auth/context files.
251
+ -h, --help Show this help.
252
+ `);
253
+ }
254
+ function readOptionValue(args, index, option) {
255
+ const value = args[index];
256
+ if (!value || value.startsWith("--")) {
257
+ throw new Error(`${option} requires a value`);
258
+ }
259
+ return value;
260
+ }
261
+ async function readCache(cachePath, options) {
262
+ try {
263
+ const parsed = JSON.parse(await readFile(cachePath, "utf8"));
264
+ if (parsed.version === CACHE_VERSION && parsed.projectRoot === options.projectRoot && parsed.remoteProjectDir === options.remoteProjectDir) {
265
+ return {
266
+ ...parsed,
267
+ projectFiles: parsed.projectFiles ?? {},
268
+ contextFiles: parsed.contextFiles ?? {},
269
+ };
270
+ }
271
+ }
272
+ catch (error) {
273
+ if (error.code !== "ENOENT") {
274
+ console.warn(`Ignoring unreadable cache ${cachePath}: ${error instanceof Error ? error.message : String(error)}`);
275
+ }
276
+ }
277
+ return {
278
+ version: CACHE_VERSION,
279
+ projectRoot: options.projectRoot,
280
+ remoteProjectDir: options.remoteProjectDir,
281
+ projectFiles: {},
282
+ contextFiles: {},
283
+ };
284
+ }
285
+ async function writeCache(cachePath, cache) {
286
+ await mkdir(path.dirname(cachePath), { recursive: true });
287
+ await writeFile(cachePath, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
288
+ }
289
+ async function scanProject(projectRoot, includeGitDir) {
290
+ const entries = [];
291
+ await walk(projectRoot, "", entries, {
292
+ skipDirectory(relativePath, name) {
293
+ if (!includeGitDir && relativePath === ".git")
294
+ return true;
295
+ return false;
296
+ },
297
+ });
298
+ return entries.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
299
+ }
300
+ async function walk(root, relativePath, entries, options) {
301
+ const absolutePath = path.join(root, relativePath);
302
+ const dir = await import("node:fs/promises").then((fs) => fs.readdir(absolutePath, { withFileTypes: true }));
303
+ for (const dirent of dir) {
304
+ const childRelativePath = relativePath ? path.join(relativePath, dirent.name) : dirent.name;
305
+ const normalizedRelativePath = toPosix(childRelativePath);
306
+ const childAbsolutePath = path.join(root, childRelativePath);
307
+ if (dirent.isDirectory()) {
308
+ if (options.skipDirectory(normalizedRelativePath, dirent.name)) {
309
+ continue;
310
+ }
311
+ await walk(root, childRelativePath, entries, options);
312
+ continue;
313
+ }
314
+ if (dirent.isFile() || dirent.isSymbolicLink()) {
315
+ entries.push(await digestEntry(childAbsolutePath, normalizedRelativePath));
316
+ }
317
+ }
318
+ }
319
+ async function digestEntry(absolutePath, relativePath) {
320
+ const stats = await import("node:fs/promises").then((fs) => fs.lstat(absolutePath));
321
+ if (stats.isSymbolicLink()) {
322
+ const target = await import("node:fs/promises").then((fs) => fs.readlink(absolutePath));
323
+ return {
324
+ absolutePath,
325
+ relativePath,
326
+ kind: "symlink",
327
+ mode: stats.mode,
328
+ size: target.length,
329
+ hash: hashString(`symlink:${target}`),
330
+ };
331
+ }
332
+ return {
333
+ absolutePath,
334
+ relativePath,
335
+ kind: "file",
336
+ mode: stats.mode,
337
+ size: stats.size,
338
+ hash: await hashFile(absolutePath),
339
+ };
340
+ }
341
+ function digestMap(entries) {
342
+ return Object.fromEntries(entries.map((entry) => [entry.relativePath, stripLocal(entry)]));
343
+ }
344
+ function digestMapByRemotePath(entries) {
345
+ return Object.fromEntries(entries.map((entry) => [entry.remotePath, stripLocal(entry)]));
346
+ }
347
+ function stripLocal(entry) {
348
+ return {
349
+ hash: entry.hash,
350
+ kind: entry.kind,
351
+ mode: entry.mode,
352
+ size: entry.size,
353
+ };
354
+ }
355
+ function diffEntries(entries, previous) {
356
+ const changed = entries.filter((entry) => previous[entry.relativePath]?.hash !== entry.hash);
357
+ const currentKeys = new Set(entries.map((entry) => entry.relativePath));
358
+ const removed = Object.keys(previous).filter((relativePath) => !currentKeys.has(relativePath));
359
+ return { changed, removed, unchanged: entries.length - changed.length };
360
+ }
361
+ function diffContextEntries(entries, previous) {
362
+ const changed = entries.filter((entry) => previous[entry.remotePath]?.hash !== entry.hash);
363
+ const currentKeys = new Set(entries.map((entry) => entry.remotePath));
364
+ const removed = Object.keys(previous).filter((remotePath) => !currentKeys.has(remotePath) && !isProtectedRemotePath(remotePath));
365
+ return { changed, removed, unchanged: entries.length - changed.length };
366
+ }
367
+ async function scanContextCandidates(candidates) {
368
+ const entries = [];
369
+ for (const candidate of candidates) {
370
+ const stats = await import("node:fs/promises").then((fs) => fs.lstat(candidate.source));
371
+ if (stats.isFile()) {
372
+ const local = await digestEntry(candidate.source, path.basename(candidate.source));
373
+ entries.push({
374
+ ...local,
375
+ remotePath: candidate.remoteRoot,
376
+ label: candidate.label,
377
+ sensitive: candidate.sensitive,
378
+ });
379
+ }
380
+ else if (stats.isDirectory()) {
381
+ const candidateEntries = [];
382
+ await walk(candidate.source, "", candidateEntries, {
383
+ skipDirectory(relativePath, name) {
384
+ return shouldSkipContextDirectory(relativePath, name);
385
+ },
386
+ });
387
+ for (const local of candidateEntries) {
388
+ if (local.kind === "symlink")
389
+ continue;
390
+ const remotePath = `${candidate.remoteRoot}/${local.relativePath}`;
391
+ if (isProtectedRemotePath(remotePath))
392
+ continue;
393
+ entries.push({
394
+ ...local,
395
+ remotePath,
396
+ label: candidate.label,
397
+ sensitive: candidate.sensitive,
398
+ });
399
+ }
400
+ }
401
+ }
402
+ return entries.sort((left, right) => left.remotePath.localeCompare(right.remotePath));
403
+ }
404
+ async function selectSensitiveSync(options, candidates, envExports) {
405
+ if (options.yes || (!options.includeAuth && !options.includeAgentContext)) {
406
+ return { contextCandidates: candidates, envExports };
407
+ }
408
+ const sensitiveCandidates = candidates.filter((candidate) => candidate.sensitive);
409
+ const envKeys = Object.keys(envExports).sort();
410
+ if (sensitiveCandidates.length === 0 && envKeys.length === 0) {
411
+ return { contextCandidates: candidates, envExports };
412
+ }
413
+ const preferencesPath = getPreferencesPath(options);
414
+ const preferences = options.resetContextPrefs ? emptySensitivePreferences() : await readSensitivePreferences(preferencesPath);
415
+ const contextGroups = groupContextCandidates(candidates);
416
+ const missingContextGroups = contextGroups.filter((group) => group.sensitive && preferences.context[group.key] === undefined);
417
+ const missingEnvKeys = envKeys.filter((key) => preferences.env[key] === undefined);
418
+ if (missingContextGroups.length === 0 && missingEnvKeys.length === 0) {
419
+ return applySensitivePreferences(candidates, envExports, preferences);
420
+ }
421
+ if (!process.stdin.isTTY) {
422
+ throw new Error("refusing to copy new sensitive data without an interactive terminal; rerun with --yes, --no-auth, --no-agent-context, or answer prompts once to save preferences");
423
+ }
424
+ console.log(`Choose which local auth and conversation context to copy into the VM. Default is no. Choices are saved in ${preferencesPath}.`);
425
+ const readline = createInterface({ input, output });
426
+ try {
427
+ for (const group of missingContextGroups) {
428
+ preferences.context[group.key] = await askYesNo(readline, await contextGroupQuestion(group));
429
+ }
430
+ for (const key of missingEnvKeys) {
431
+ preferences.env[key] = await askYesNo(readline, `Include environment variable ${key}? [y/N] `);
432
+ }
433
+ await writeSensitivePreferences(preferencesPath, preferences);
434
+ const selection = applySensitivePreferences(candidates, envExports, preferences);
435
+ console.log(`Selected ${selection.contextCandidates.length}/${candidates.length} context item${candidates.length === 1 ? "" : "s"} and ${Object.keys(selection.envExports).length}/${envKeys.length} environment variable${envKeys.length === 1 ? "" : "s"}.`);
436
+ return selection;
437
+ }
438
+ finally {
439
+ readline.close();
440
+ }
441
+ }
442
+ function groupContextCandidates(candidates) {
443
+ const groups = new Map();
444
+ for (const candidate of candidates) {
445
+ const group = groups.get(candidate.preferenceKey);
446
+ if (group) {
447
+ group.candidates.push(candidate);
448
+ group.sensitive ||= candidate.sensitive;
449
+ }
450
+ else {
451
+ groups.set(candidate.preferenceKey, {
452
+ key: candidate.preferenceKey,
453
+ promptLabel: candidate.promptLabel,
454
+ candidates: [candidate],
455
+ sensitive: candidate.sensitive,
456
+ });
457
+ }
458
+ }
459
+ return [...groups.values()];
460
+ }
461
+ async function contextGroupQuestion(group) {
462
+ const size = await estimateCandidatesSize(group.candidates);
463
+ const sizeLabel = size > 0 ? `, about ${formatBytes(size)}` : "";
464
+ if (group.candidates.length === 1) {
465
+ return `Include ${group.promptLabel} (${group.candidates[0].source}${sizeLabel})? [y/N] `;
466
+ }
467
+ const noun = group.promptLabel.toLowerCase().includes("workspace") ? "workspaces" : "locations";
468
+ return `Include ${group.promptLabel} (${group.candidates.length} ${noun}${sizeLabel})? [y/N] `;
469
+ }
470
+ async function estimateCandidatesSize(candidates) {
471
+ let total = 0;
472
+ for (const candidate of candidates) {
473
+ total += await estimatePathSize(candidate.source);
474
+ }
475
+ return total;
476
+ }
477
+ async function estimatePathSize(source) {
478
+ try {
479
+ const stats = await stat(source);
480
+ if (stats.isFile())
481
+ return stats.size;
482
+ if (!stats.isDirectory())
483
+ return 0;
484
+ const entries = [];
485
+ await walk(source, "", entries, {
486
+ skipDirectory(relativePath, name) {
487
+ return shouldSkipContextDirectory(relativePath, name);
488
+ },
489
+ });
490
+ return entries.reduce((total, entry) => total + entry.size, 0);
491
+ }
492
+ catch {
493
+ return 0;
494
+ }
495
+ }
496
+ function applySensitivePreferences(candidates, envExports, preferences) {
497
+ const contextCandidates = candidates.filter((candidate) => !candidate.sensitive || preferences.context[candidate.preferenceKey] === true);
498
+ const selectedEnv = {};
499
+ for (const [key, value] of Object.entries(envExports)) {
500
+ if (preferences.env[key] === true) {
501
+ selectedEnv[key] = value;
502
+ }
503
+ }
504
+ return { contextCandidates, envExports: selectedEnv };
505
+ }
506
+ function emptySensitivePreferences() {
507
+ return { version: SENSITIVE_PREFERENCES_VERSION, context: {}, env: {} };
508
+ }
509
+ function getPreferencesPath(options) {
510
+ return path.join(path.dirname(options.cachePath), "preferences.json");
511
+ }
512
+ async function readSensitivePreferences(preferencesPath) {
513
+ try {
514
+ const parsed = JSON.parse(await readFile(preferencesPath, "utf8"));
515
+ if (parsed.version === SENSITIVE_PREFERENCES_VERSION) {
516
+ return {
517
+ version: SENSITIVE_PREFERENCES_VERSION,
518
+ context: parsed.context ?? {},
519
+ env: parsed.env ?? {},
520
+ updatedAt: parsed.updatedAt,
521
+ };
522
+ }
523
+ }
524
+ catch (error) {
525
+ if (error.code !== "ENOENT") {
526
+ console.warn(`Ignoring unreadable preferences ${preferencesPath}: ${error instanceof Error ? error.message : String(error)}`);
527
+ }
528
+ }
529
+ return emptySensitivePreferences();
530
+ }
531
+ async function writeSensitivePreferences(preferencesPath, preferences) {
532
+ await mkdir(path.dirname(preferencesPath), { recursive: true });
533
+ await writeFile(preferencesPath, `${JSON.stringify({ ...preferences, updatedAt: new Date().toISOString() }, null, 2)}\n`, "utf8");
534
+ }
535
+ async function askYesNo(readline, question) {
536
+ const answer = await readline.question(question);
537
+ return /^y(es)?$/i.test(answer.trim());
538
+ }
539
+ function printPlan(options, projectChanges, contextChanges, envExports, cache) {
540
+ const source = options.vmId ? `existing VM ${options.vmId}` : cache.snapshotId ? `snapshot ${cache.snapshotId}` : "a new VM";
541
+ console.log(`Syncing ${options.projectRoot} to ${source}`);
542
+ console.log(`Remote project: ${options.remoteProjectDir}`);
543
+ console.log(`Project files: ${projectChanges.changed.length} changed, ${projectChanges.removed.length} removed, ${projectChanges.unchanged} unchanged`);
544
+ console.log(`Context files: ${contextChanges.changed.length} changed, ${contextChanges.removed.length} removed, ${contextChanges.unchanged} unchanged`);
545
+ console.log(`Estimated upload: ${formatBytes(totalEntrySize(projectChanges.changed))} project, ${formatBytes(totalEntrySize(contextChanges.changed))} context`);
546
+ if (Object.keys(envExports).length > 0) {
547
+ console.log(`Environment exports: ${Object.keys(envExports).length}`);
548
+ }
549
+ }
550
+ function totalEntrySize(entries) {
551
+ return entries.reduce((total, entry) => total + entry.size, 0);
552
+ }
553
+ async function getOrCreateVm(options, snapshotId) {
554
+ if (options.vmId) {
555
+ const { vm } = await freestyle.vms.get({ vmId: options.vmId });
556
+ await vm.start(options.idleTimeoutSeconds ? { idleTimeoutSeconds: options.idleTimeoutSeconds } : undefined);
557
+ return { vm, vmId: options.vmId };
558
+ }
559
+ console.log(snapshotId ? `Creating VM from snapshot ${snapshotId}...` : "Creating Freestyle VM...");
560
+ const result = await freestyle.vms.create({
561
+ name: options.name,
562
+ snapshotId: snapshotId ?? undefined,
563
+ idleTimeoutSeconds: options.idleTimeoutSeconds,
564
+ });
565
+ return { vm: result.vm, vmId: result.vmId };
566
+ }
567
+ async function warmVsCodeServerCache(vm) {
568
+ const commit = await localVsCodeCommit();
569
+ if (!commit)
570
+ return;
571
+ const script = `
572
+ set -eu
573
+ commit=${shellQuote(commit)}
574
+ machine=$(uname -m)
575
+ case "$machine" in
576
+ x86_64|amd64) arch=x64 ;;
577
+ aarch64|arm64) arch=arm64 ;;
578
+ armv7l|armv8l) arch=armhf ;;
579
+ *) echo "unsupported VS Code Server architecture: $machine" >&2; exit 0 ;;
580
+ esac
581
+ bin_dir=/root/.vscode-server/bin/$commit
582
+ cli_server_dir=/root/.vscode-server/cli/servers/Stable-$commit/server
583
+ if { [ -x "$bin_dir/server.sh" ] || [ -x "$bin_dir/bin/code-server" ]; } && [ -x "$cli_server_dir/bin/code-server" ]; then
584
+ echo "VS Code Server $commit already cached"
585
+ exit 0
586
+ fi
587
+ tmp=$(mktemp -d /tmp/vmpush-vscode-server.XXXXXX)
588
+ trap 'rm -rf "$tmp"' EXIT
589
+ url=https://update.code.visualstudio.com/commit:$commit/server-linux-$arch/stable
590
+ archive=$tmp/server.tgz
591
+ if command -v curl >/dev/null 2>&1; then
592
+ curl -fsSL "$url" -o "$archive"
593
+ elif command -v wget >/dev/null 2>&1; then
594
+ wget -qO "$archive" "$url"
595
+ else
596
+ echo "curl or wget is required to cache VS Code Server" >&2
597
+ exit 0
598
+ fi
599
+ tar -xzf "$archive" -C "$tmp"
600
+ extracted=$(find "$tmp" -mindepth 1 -maxdepth 1 -type d | head -n 1)
601
+ if [ -z "$extracted" ]; then
602
+ echo "VS Code Server archive did not contain a directory" >&2
603
+ exit 1
604
+ fi
605
+ if ! { [ -x "$bin_dir/server.sh" ] || [ -x "$bin_dir/bin/code-server" ]; }; then
606
+ mkdir -p "$bin_dir"
607
+ cp -a "$extracted"/. "$bin_dir"/
608
+ touch "$bin_dir/0"
609
+ fi
610
+ if [ ! -x "$cli_server_dir/bin/code-server" ]; then
611
+ mkdir -p "$cli_server_dir"
612
+ cp -a "$extracted"/. "$cli_server_dir"/
613
+ fi
614
+ echo "Cached VS Code Server $commit for linux-$arch"
615
+ `;
616
+ const result = await checkedExec(vm, script);
617
+ if (result.stdout?.trim())
618
+ console.log(result.stdout.trim());
619
+ }
620
+ async function localVsCodeCommit() {
621
+ try {
622
+ const { stdout } = await execFileAsync("code", ["--version"]);
623
+ const commit = stdout.split(/\r?\n/)[1]?.trim();
624
+ return /^[0-9a-f]{40}$/i.test(commit ?? "") ? commit : undefined;
625
+ }
626
+ catch {
627
+ return undefined;
628
+ }
629
+ }
630
+ async function ensureRemoteBase(vm, remoteProjectDir) {
631
+ await checkedExec(vm, `mkdir -p ${shellQuote(remoteProjectDir)} /root/.vmpush`);
632
+ }
633
+ async function syncProject(vm, vmId, options, changes) {
634
+ if (changes.changed.length > 0) {
635
+ console.log(`VM ${vmId}: uploading ${changes.changed.length} changed project files...`);
636
+ const archive = await createProjectArchive(options.projectRoot, changes.changed);
637
+ try {
638
+ await uploadArchiveInChunks(vm, vmId, archive, "/tmp/vmpush-project.tgz", "project");
639
+ await checkedExec(vm, `mkdir -p ${shellQuote(options.remoteProjectDir)} && tar --no-same-owner --no-same-permissions -xzf /tmp/vmpush-project.tgz -C ${shellQuote(options.remoteProjectDir)} && rm -f /tmp/vmpush-project.tgz`);
640
+ }
641
+ finally {
642
+ await rm(path.dirname(archive), { recursive: true, force: true });
643
+ }
644
+ }
645
+ if (changes.removed.length > 0) {
646
+ console.log(`VM ${vmId}: removing ${changes.removed.length} deleted project files...`);
647
+ const removeList = Buffer.from(changes.removed.join("\0") + "\0");
648
+ await vm.fs.writeFile("/tmp/vmpush-remove-list", removeList);
649
+ await checkedExec(vm, `cd ${shellQuote(options.remoteProjectDir)} && xargs -0 rm -f -- < /tmp/vmpush-remove-list && rm -f /tmp/vmpush-remove-list`);
650
+ }
651
+ return {
652
+ uploaded: changes.changed.length,
653
+ removed: changes.removed.length,
654
+ unchanged: changes.unchanged,
655
+ };
656
+ }
657
+ async function runRemoteFixups(vm, options) {
658
+ for (const plugin of [npmNativeDepsPlugin]) {
659
+ try {
660
+ await plugin.afterProjectSync({ vm, options });
661
+ }
662
+ catch (error) {
663
+ console.warn(`Remote fixup ${plugin.name} skipped: ${error instanceof Error ? error.message : String(error)}`);
664
+ }
665
+ }
666
+ }
667
+ async function syncContext(vm, vmId, changes) {
668
+ if (changes.removed.length > 0) {
669
+ console.log(`VM ${vmId}: removing ${changes.removed.length} deleted auth/context files...`);
670
+ await checkedExec(vm, changes.removed.map((remotePath) => `rm -f -- ${shellQuote(remotePath)}`).join("\n"));
671
+ }
672
+ if (changes.changed.length === 0) {
673
+ return { uploaded: 0, removed: changes.removed.length, unchanged: changes.unchanged };
674
+ }
675
+ console.log(`VM ${vmId}: uploading ${changes.changed.length} changed auth/context files...`);
676
+ const archive = await createContextArchive(changes.changed);
677
+ try {
678
+ await uploadArchiveInChunks(vm, vmId, archive, "/tmp/vmpush-context.tgz", "context");
679
+ await checkedExec(vm, "tar --no-same-owner --no-same-permissions -xzf /tmp/vmpush-context.tgz -C / && rm -f /tmp/vmpush-context.tgz");
680
+ }
681
+ finally {
682
+ await rm(path.dirname(archive), { recursive: true, force: true });
683
+ }
684
+ await hardenRemoteSecrets(vm, changes.changed.map((entry) => entry.remotePath));
685
+ return { uploaded: changes.changed.length, removed: changes.removed.length, unchanged: changes.unchanged };
686
+ }
687
+ async function installEnvironment(vm, envExports, envHash, previousHash) {
688
+ if (Object.keys(envExports).length === 0 || envHash === previousHash)
689
+ return;
690
+ await checkedExec(vm, "mkdir -p /root/.vmpush");
691
+ await vm.fs.writeTextFile("/root/.vmpush/env.sh", renderEnvFile(envExports));
692
+ await checkedExec(vm, "chmod 700 /root/.vmpush && chmod 600 /root/.vmpush/env.sh && grep -qxF 'test -f /root/.vmpush/env.sh && . /root/.vmpush/env.sh' /root/.profile || printf '\n%s\n' 'test -f /root/.vmpush/env.sh && . /root/.vmpush/env.sh' >> /root/.profile");
693
+ }
694
+ async function writeRemoteResumeFiles(vm, options, vmId, envExports) {
695
+ const manifest = {
696
+ vmId,
697
+ project: options.remoteProjectDir,
698
+ syncedAt: new Date().toISOString(),
699
+ env: Object.keys(envExports).sort(),
700
+ installCommand: await detectInstallCommand(options.projectRoot),
701
+ };
702
+ await checkedExec(vm, `mkdir -p ${shellQuote(`${options.remoteProjectDir}/.vmpush`)} /root/.vmpush`);
703
+ await vm.fs.writeTextFile(`${options.remoteProjectDir}/.vmpush/remote.json`, `${JSON.stringify(manifest, null, 2)}\n`);
704
+ await vm.fs.writeTextFile("/root/.vmpush/resume.sh", `#!/usr/bin/env bash
705
+ set -euo pipefail
706
+ test -f /root/.vmpush/env.sh && . /root/.vmpush/env.sh
707
+ cd ${shellQuote(options.remoteProjectDir)}
708
+ exec "$SHELL" -l
709
+ `);
710
+ await vm.fs.writeTextFile("/root/.vmpush/profile.sh", renderRemoteProfile(options.remoteProjectDir));
711
+ await checkedExec(vm, "chmod 700 /root/.vmpush && chmod +x /root/.vmpush/resume.sh && chmod 600 /root/.vmpush/profile.sh && grep -qxF 'test -f /root/.vmpush/profile.sh && . /root/.vmpush/profile.sh' /root/.profile || printf '\n%s\n' 'test -f /root/.vmpush/profile.sh && . /root/.vmpush/profile.sh' >> /root/.profile");
712
+ }
713
+ function renderRemoteProfile(remoteProjectDir) {
714
+ return `# Generated by vmpush. Loaded by /root/.profile.
715
+ test -f /root/.vmpush/env.sh && . /root/.vmpush/env.sh
716
+ if [ -d ${shellQuote(remoteProjectDir)} ]; then
717
+ cd ${shellQuote(remoteProjectDir)}
718
+ fi
719
+ `;
720
+ }
721
+ async function syncLocalCopilotForRemoteWorkspaces(vm, options, vmId, contextCandidates) {
722
+ const sourceCopilotDir = selectedCurrentCopilotSource(contextCandidates);
723
+ if (!sourceCopilotDir || !(await exists(sourceCopilotDir))) {
724
+ return { status: "skipped", count: 0, reason: "Copilot current workspace state is not selected." };
725
+ }
726
+ const destinations = await findRemoteWorkspaceCopilotDestinations(vmId, options.remoteProjectDir);
727
+ if (destinations.length === 0) {
728
+ return {
729
+ status: "pending",
730
+ count: 0,
731
+ reason: "open this VM folder in VS Code once, then rerun vmpush to copy sidebar history.",
732
+ };
733
+ }
734
+ await copyCopilotState(sourceCopilotDir, destinations);
735
+ await copyCopilotWorkspaceStateKeys(sourceCopilotDir, destinations);
736
+ await copyCopilotStateToRemoteServer(vm, sourceCopilotDir, destinations);
737
+ return { status: "copied", count: destinations.length };
738
+ }
739
+ function selectedCurrentCopilotSource(contextCandidates) {
740
+ return contextCandidates.find((candidate) => candidate.preferenceKey === "context:copilot-current-workspace")?.source;
741
+ }
742
+ async function findRemoteWorkspaceCopilotDestinations(vmId, remoteProjectDir) {
743
+ const destinations = [];
744
+ for (const codeRoot of codeUserRoots(homedir())) {
745
+ const workspaceStorage = path.join(codeRoot, "workspaceStorage");
746
+ const workspaceDirs = await listDirectories(workspaceStorage);
747
+ for (const workspaceDir of workspaceDirs) {
748
+ if (await isRemoteWorkspaceStorageForVmProject(workspaceDir, vmId, remoteProjectDir)) {
749
+ destinations.push(path.join(workspaceDir, "GitHub.copilot-chat"));
750
+ }
751
+ }
752
+ }
753
+ return destinations;
754
+ }
755
+ async function syncLocalCopilotForRemoteUri(vm, contextCandidates, remoteWorkspaceUri) {
756
+ const sourceCopilotDir = selectedCurrentCopilotSource(contextCandidates);
757
+ if (!sourceCopilotDir || !(await exists(sourceCopilotDir)))
758
+ return { status: "skipped", count: 0 };
759
+ for (let attempt = 0; attempt < 20; attempt += 1) {
760
+ const destinations = [];
761
+ for (const codeRoot of codeUserRoots(homedir())) {
762
+ const workspaceStorage = path.join(codeRoot, "workspaceStorage");
763
+ const workspaceDirs = await listDirectories(workspaceStorage);
764
+ for (const workspaceDir of workspaceDirs) {
765
+ if (await isWorkspaceStorageForRemoteUri(workspaceDir, remoteWorkspaceUri)) {
766
+ destinations.push(path.join(workspaceDir, "GitHub.copilot-chat"));
767
+ }
768
+ }
769
+ }
770
+ if (destinations.length > 0) {
771
+ await copyCopilotState(sourceCopilotDir, destinations);
772
+ await copyCopilotWorkspaceStateKeys(sourceCopilotDir, destinations);
773
+ await copyCopilotStateToRemoteServer(vm, sourceCopilotDir, destinations);
774
+ return { status: "copied", count: destinations.length };
775
+ }
776
+ await delay(500);
777
+ }
778
+ return { status: "pending", count: 0 };
779
+ }
780
+ async function copyCopilotState(sourceCopilotDir, destinations) {
781
+ for (const destination of destinations) {
782
+ await rm(destination, { recursive: true, force: true });
783
+ await mkdir(path.dirname(destination), { recursive: true });
784
+ await cp(sourceCopilotDir, destination, { recursive: true, force: true });
785
+ await sanitizeCopilotTranscripts(destination);
786
+ await copyCoreChatState(sourceCopilotDir, destination);
787
+ }
788
+ }
789
+ async function copyCoreChatState(sourceCopilotDir, destinationCopilotDir) {
790
+ const sourceWorkspaceDir = path.dirname(sourceCopilotDir);
791
+ const destinationWorkspaceDir = path.dirname(destinationCopilotDir);
792
+ const sessionIds = await copiedCopilotSessionIds(destinationCopilotDir);
793
+ for (const sessionId of sessionIds) {
794
+ const sourceChatSession = path.join(sourceWorkspaceDir, "chatSessions", `${sessionId}.jsonl`);
795
+ if (await exists(sourceChatSession)) {
796
+ const destinationChatSession = path.join(destinationWorkspaceDir, "chatSessions", `${sessionId}.jsonl`);
797
+ await mkdir(path.dirname(destinationChatSession), { recursive: true });
798
+ await cp(sourceChatSession, destinationChatSession, { force: true });
799
+ await trimTranscriptToCompletedTurn(destinationChatSession);
800
+ }
801
+ const sourceEditingSession = path.join(sourceWorkspaceDir, "chatEditingSessions", sessionId);
802
+ if (await exists(sourceEditingSession)) {
803
+ const destinationEditingSession = path.join(destinationWorkspaceDir, "chatEditingSessions", sessionId);
804
+ await rm(destinationEditingSession, { recursive: true, force: true });
805
+ await mkdir(path.dirname(destinationEditingSession), { recursive: true });
806
+ await cp(sourceEditingSession, destinationEditingSession, { recursive: true, force: true });
807
+ }
808
+ }
809
+ }
810
+ async function copiedCopilotSessionIds(copilotDir) {
811
+ const transcripts = await listFiles(path.join(copilotDir, "transcripts"));
812
+ return transcripts
813
+ .filter((transcript) => transcript.endsWith(".jsonl"))
814
+ .map((transcript) => path.basename(transcript, ".jsonl"));
815
+ }
816
+ async function sanitizeCopilotTranscripts(copilotDir) {
817
+ const transcriptsDir = path.join(copilotDir, "transcripts");
818
+ const entries = await listFiles(transcriptsDir);
819
+ for (const transcript of entries.filter((entry) => entry.endsWith(".jsonl"))) {
820
+ await trimTranscriptToCompletedTurn(transcript);
821
+ }
822
+ }
823
+ async function trimTranscriptToCompletedTurn(transcriptPath) {
824
+ const content = await readFile(transcriptPath, "utf8").catch(() => undefined);
825
+ if (!content)
826
+ return;
827
+ const lines = content.trimEnd().split("\n");
828
+ let lastCompletedLine = -1;
829
+ for (let index = 0; index < lines.length; index += 1) {
830
+ try {
831
+ const event = JSON.parse(lines[index]);
832
+ if (event.type === "assistant.turn_end")
833
+ lastCompletedLine = index;
834
+ }
835
+ catch {
836
+ break;
837
+ }
838
+ }
839
+ if (lastCompletedLine >= 0 && lastCompletedLine < lines.length - 1) {
840
+ await writeFile(transcriptPath, `${lines.slice(0, lastCompletedLine + 1).join("\n")}\n`, "utf8");
841
+ }
842
+ }
843
+ async function listFiles(directory) {
844
+ const result = [];
845
+ async function visit(current) {
846
+ const dirents = await import("node:fs/promises").then((fs) => fs.readdir(current, { withFileTypes: true })).catch(() => []);
847
+ for (const dirent of dirents) {
848
+ const child = path.join(current, dirent.name);
849
+ if (dirent.isDirectory())
850
+ await visit(child);
851
+ else if (dirent.isFile())
852
+ result.push(child);
853
+ }
854
+ }
855
+ await visit(directory);
856
+ return result;
857
+ }
858
+ async function copyCopilotWorkspaceStateKeys(sourceCopilotDir, destinations) {
859
+ const sourceStateDb = path.join(path.dirname(sourceCopilotDir), "state.vscdb");
860
+ if (!(await exists(sourceStateDb)))
861
+ return;
862
+ for (const destination of destinations) {
863
+ const destinationStateDb = path.join(path.dirname(destination), "state.vscdb");
864
+ if (!(await exists(destinationStateDb)))
865
+ continue;
866
+ await mergeWorkspaceStateKeys(sourceStateDb, destinationStateDb).catch((error) => {
867
+ console.warn(`Copilot sidebar state database merge skipped: ${error instanceof Error ? error.message : String(error)}`);
868
+ });
869
+ await normalizeCopiedChatIndex(destination).catch((error) => {
870
+ console.warn(`Copilot sidebar index normalization skipped: ${error instanceof Error ? error.message : String(error)}`);
871
+ });
872
+ }
873
+ }
874
+ async function mergeWorkspaceStateKeys(sourceStateDb, destinationStateDb) {
875
+ await execFileAsync("sqlite3", [
876
+ destinationStateDb,
877
+ `PRAGMA busy_timeout=5000;
878
+ ATTACH DATABASE ${sqliteLiteral(sourceStateDb)} AS source_state;
879
+ INSERT OR REPLACE INTO ItemTable(key, value)
880
+ SELECT key, value FROM source_state.ItemTable
881
+ WHERE key IN (
882
+ 'GitHub.copilot-chat',
883
+ 'chat.ChatSessionStore.index',
884
+ 'memento/interactive-session-view-copilot',
885
+ 'workbench.panel.chat',
886
+ 'workbench.panel.chat.numberOfVisibleViews'
887
+ ) OR key LIKE 'chat.%';
888
+ DETACH DATABASE source_state;`,
889
+ ]);
890
+ }
891
+ function sqliteLiteral(value) {
892
+ return `'${value.replace(/'/g, "''")}'`;
893
+ }
894
+ async function normalizeCopiedChatIndex(copilotDir) {
895
+ const stateDb = path.join(path.dirname(copilotDir), "state.vscdb");
896
+ if (!(await exists(stateDb)))
897
+ return;
898
+ const { stdout } = await execFileAsync("sqlite3", [stateDb, "SELECT value FROM ItemTable WHERE key='chat.ChatSessionStore.index';"]);
899
+ if (!stdout.trim())
900
+ return;
901
+ const index = JSON.parse(stdout);
902
+ if (!index.entries)
903
+ return;
904
+ for (const sessionId of Object.keys(index.entries)) {
905
+ const transcriptPath = path.join(copilotDir, "transcripts", `${sessionId}.jsonl`);
906
+ const lastCompletedTimestamp = await lastCompletedTurnTimestamp(transcriptPath);
907
+ if (!lastCompletedTimestamp) {
908
+ delete index.entries[sessionId];
909
+ continue;
910
+ }
911
+ const entry = index.entries[sessionId];
912
+ entry.lastResponseState = 1;
913
+ entry.hasPendingEdits = false;
914
+ entry.lastMessageDate = lastCompletedTimestamp;
915
+ const timing = typeof entry.timing === "object" && entry.timing !== null ? entry.timing : {};
916
+ timing.lastRequestEnded = lastCompletedTimestamp;
917
+ entry.timing = timing;
918
+ }
919
+ await execFileAsync("sqlite3", [
920
+ stateDb,
921
+ `INSERT OR REPLACE INTO ItemTable(key, value) VALUES ('chat.ChatSessionStore.index', ${sqliteLiteral(JSON.stringify(index))});`,
922
+ ]);
923
+ }
924
+ async function lastCompletedTurnTimestamp(transcriptPath) {
925
+ const content = await readFile(transcriptPath, "utf8").catch(() => undefined);
926
+ if (!content)
927
+ return undefined;
928
+ const lines = content.trimEnd().split("\n");
929
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
930
+ try {
931
+ const event = JSON.parse(lines[index]);
932
+ if (event.type === "assistant.turn_end" && event.timestamp)
933
+ return Date.parse(event.timestamp);
934
+ }
935
+ catch {
936
+ return undefined;
937
+ }
938
+ }
939
+ return undefined;
940
+ }
941
+ async function copyCopilotStateToRemoteServer(vm, sourceCopilotDir, localDestinations) {
942
+ const workspaceIds = localDestinations.map((destination) => path.basename(path.dirname(destination)));
943
+ if (workspaceIds.length === 0)
944
+ return;
945
+ const archive = await createRemoteServerWorkspaceStateArchive(sourceCopilotDir, localDestinations);
946
+ try {
947
+ await uploadArchiveInChunks(vm, "vscode-server", archive, "/tmp/vmpush-copilot-chat.tgz", "copilot-chat");
948
+ const baseDir = "/root/.vscode-server/data/User/workspaceStorage";
949
+ const commands = [`mkdir -p ${shellQuote(baseDir)}`];
950
+ for (const workspaceId of workspaceIds) {
951
+ commands.push(`mkdir -p ${shellQuote(`${baseDir}/${workspaceId}`)} && rm -rf ${shellQuote(`${baseDir}/${workspaceId}/GitHub.copilot-chat`)}`);
952
+ }
953
+ commands.push(`tar --no-same-owner --no-same-permissions -xzf /tmp/vmpush-copilot-chat.tgz -C ${shellQuote(baseDir)}`);
954
+ commands.push("rm -f /tmp/vmpush-copilot-chat.tgz");
955
+ await checkedExec(vm, commands.join("\n"));
956
+ }
957
+ finally {
958
+ await rm(path.dirname(archive), { recursive: true, force: true });
959
+ }
960
+ }
961
+ async function createRemoteServerWorkspaceStateArchive(sourceCopilotDir, localDestinations) {
962
+ const tempDir = await mkdtemp(path.join(tmpdir(), "vmpush-copilot-"));
963
+ const stagingDir = path.join(tempDir, "staging");
964
+ const archivePath = path.join(tempDir, "copilot-chat.tgz");
965
+ await mkdir(stagingDir, { recursive: true });
966
+ for (const destination of localDestinations) {
967
+ const workspaceId = path.basename(path.dirname(destination));
968
+ const workspaceDir = path.join(stagingDir, workspaceId);
969
+ await mkdir(workspaceDir, { recursive: true });
970
+ await cp(sourceCopilotDir, path.join(workspaceDir, "GitHub.copilot-chat"), { recursive: true, force: true });
971
+ await sanitizeCopilotTranscripts(path.join(workspaceDir, "GitHub.copilot-chat"));
972
+ await copyCoreChatState(sourceCopilotDir, path.join(workspaceDir, "GitHub.copilot-chat"));
973
+ const stateDb = path.join(path.dirname(destination), "state.vscdb");
974
+ if (await exists(stateDb)) {
975
+ await cp(stateDb, path.join(workspaceDir, "state.vscdb"), { force: true });
976
+ }
977
+ }
978
+ await createTar(["--no-xattrs", "-czf", archivePath, "-C", stagingDir, "."]);
979
+ return archivePath;
980
+ }
981
+ async function isRemoteWorkspaceStorageForVmProject(workspaceDir, vmId, remoteProjectDir) {
982
+ try {
983
+ const raw = await readFile(path.join(workspaceDir, "workspace.json"), "utf8");
984
+ const parsed = JSON.parse(raw);
985
+ const workspaceUri = parsed.folder ?? parsed.workspace;
986
+ if (!workspaceUri?.startsWith("vscode-remote://"))
987
+ return false;
988
+ const decodedUri = decodeURIComponent(workspaceUri);
989
+ return decodedUri.includes(vmId) && remoteWorkspacePath(decodedUri) === remoteProjectDir;
990
+ }
991
+ catch {
992
+ return false;
993
+ }
994
+ }
995
+ async function isWorkspaceStorageForRemoteUri(workspaceDir, remoteWorkspaceUri) {
996
+ try {
997
+ const raw = await readFile(path.join(workspaceDir, "workspace.json"), "utf8");
998
+ const parsed = JSON.parse(raw);
999
+ const workspaceUri = parsed.folder ?? parsed.workspace;
1000
+ return workspaceUri === remoteWorkspaceUri;
1001
+ }
1002
+ catch {
1003
+ return false;
1004
+ }
1005
+ }
1006
+ function remoteWorkspacePath(decodedRemoteUri) {
1007
+ const hostEnd = decodedRemoteUri.indexOf("/", "vscode-remote://".length);
1008
+ if (hostEnd === -1)
1009
+ return "/";
1010
+ return normalizeRemotePath(decodedRemoteUri.slice(hostEnd));
1011
+ }
1012
+ async function runInstall(vm, projectRoot, remoteProjectDir) {
1013
+ const installCommand = await detectInstallCommand(projectRoot);
1014
+ if (!installCommand) {
1015
+ console.log("No dependency install command detected.");
1016
+ return;
1017
+ }
1018
+ console.log(`Running install: ${installCommand}`);
1019
+ await checkedExec(vm, `cd ${shellQuote(remoteProjectDir)} && ${installCommand}`, 20 * 60 * 1000);
1020
+ }
1021
+ async function detectInstallCommand(projectRoot) {
1022
+ if (await exists(path.join(projectRoot, "pnpm-lock.yaml")))
1023
+ return "corepack enable && pnpm install";
1024
+ if (await exists(path.join(projectRoot, "yarn.lock")))
1025
+ return "corepack enable && yarn install";
1026
+ if (await exists(path.join(projectRoot, "package-lock.json")))
1027
+ return "npm install";
1028
+ if (await exists(path.join(projectRoot, "requirements.txt")))
1029
+ return "python3 -m pip install -r requirements.txt";
1030
+ if (await exists(path.join(projectRoot, "pyproject.toml")))
1031
+ return "python3 -m pip install -e .";
1032
+ if (await exists(path.join(projectRoot, "Cargo.toml")))
1033
+ return "cargo fetch";
1034
+ if (await exists(path.join(projectRoot, "go.mod")))
1035
+ return "go mod download";
1036
+ return undefined;
1037
+ }
1038
+ async function createProjectArchive(projectRoot, entries) {
1039
+ const tempDir = await mkdtemp(path.join(tmpdir(), "vmpush-"));
1040
+ const listPath = path.join(tempDir, "files.list");
1041
+ const archivePath = path.join(tempDir, "project.tgz");
1042
+ await writeFile(listPath, Buffer.from(entries.map((entry) => entry.relativePath).join("\0") + "\0"));
1043
+ await createTar(["--null", "--no-xattrs", "-T", listPath, "-czf", archivePath, "-C", projectRoot]);
1044
+ return archivePath;
1045
+ }
1046
+ async function createContextArchive(entries) {
1047
+ const tempDir = await mkdtemp(path.join(tmpdir(), "vmpush-context-"));
1048
+ const stagingDir = path.join(tempDir, "staging");
1049
+ const archivePath = path.join(tempDir, "context.tgz");
1050
+ await mkdir(stagingDir, { recursive: true });
1051
+ for (const entry of entries) {
1052
+ const relativeRemotePath = entry.remotePath.replace(/^\/+/, "");
1053
+ const destination = path.join(stagingDir, ...relativeRemotePath.split("/"));
1054
+ await mkdir(path.dirname(destination), { recursive: true });
1055
+ await writeFile(destination, await readFile(entry.absolutePath));
1056
+ }
1057
+ await createTar(["--no-xattrs", "-czf", archivePath, "-C", stagingDir, "."]);
1058
+ return archivePath;
1059
+ }
1060
+ async function createTar(args) {
1061
+ await execFileAsync("tar", args, {
1062
+ env: {
1063
+ ...process.env,
1064
+ COPYFILE_DISABLE: "1",
1065
+ },
1066
+ });
1067
+ }
1068
+ async function uploadArchiveInChunks(vm, vmId, archivePath, remoteArchivePath, label) {
1069
+ const archive = await readFile(archivePath);
1070
+ const encoded = archive.toString("base64");
1071
+ const chunkCount = Math.max(1, Math.ceil(encoded.length / ARCHIVE_CHUNK_CHARS));
1072
+ const chunkDir = `/tmp/vmpush-${label}-${Date.now()}.chunks`;
1073
+ console.log(`VM ${vmId}: streaming ${formatBytes(archive.length)} ${label} archive in ${chunkCount} chunk${chunkCount === 1 ? "" : "s"}...`);
1074
+ await checkedExec(vm, `rm -rf ${shellQuote(chunkDir)} && mkdir -p ${shellQuote(chunkDir)}`);
1075
+ const width = String(chunkCount - 1).length;
1076
+ for (let index = 0; index < chunkCount; index += 1) {
1077
+ const start = index * ARCHIVE_CHUNK_CHARS;
1078
+ const chunk = encoded.slice(start, start + ARCHIVE_CHUNK_CHARS);
1079
+ const chunkName = `${String(index).padStart(width, "0")}.b64`;
1080
+ await vm.fs.writeTextFile(`${chunkDir}/${chunkName}`, chunk);
1081
+ if (chunkCount > 1 && ((index + 1) % 10 === 0 || index + 1 === chunkCount)) {
1082
+ console.log(`VM ${vmId}: uploaded ${index + 1}/${chunkCount} ${label} archive chunks`);
1083
+ }
1084
+ }
1085
+ await checkedExec(vm, `cat ${shellQuote(chunkDir)}/*.b64 | base64 -d > ${shellQuote(remoteArchivePath)} && rm -rf ${shellQuote(chunkDir)}`);
1086
+ }
1087
+ async function mkdirRemote(vm, directories) {
1088
+ for (const chunk of chunkArray(directories, 50)) {
1089
+ await checkedExec(vm, `mkdir -p ${chunk.map(shellQuote).join(" ")}`);
1090
+ }
1091
+ }
1092
+ async function hardenRemoteRoot(vm) {
1093
+ await checkedExec(vm, "chown root:root /root && chmod 700 /root");
1094
+ }
1095
+ async function checkedExec(vm, command, timeoutMs) {
1096
+ const result = await vm.exec({ command, timeoutMs });
1097
+ if (result.statusCode && result.statusCode !== 0) {
1098
+ throw new Error(`remote command failed (${result.statusCode}): ${command}\n${result.stderr ?? result.stdout ?? ""}`);
1099
+ }
1100
+ return result;
1101
+ }
1102
+ async function connectToVm(vm, vmId, options, contextCandidates) {
1103
+ const editorScheme = await detectEditorScheme();
1104
+ if (!editorScheme) {
1105
+ await sshIntoVm(vmId);
1106
+ return;
1107
+ }
1108
+ await openRemoteEditor(vm, vmId, editorScheme, options.remoteProjectDir, contextCandidates);
1109
+ }
1110
+ async function detectEditorScheme() {
1111
+ const envText = Object.entries(process.env)
1112
+ .filter(([key]) => key.startsWith("VSCODE_") || key.startsWith("CURSOR") || key === "TERM_PROGRAM")
1113
+ .map(([key, value]) => `${key}=${value ?? ""}`)
1114
+ .join("\n")
1115
+ .toLowerCase();
1116
+ if (envText.includes("cursor"))
1117
+ return "cursor";
1118
+ if (!envText.includes("term_program=vscode") && !envText.includes("vscode_"))
1119
+ return undefined;
1120
+ const parentCommands = await processCommandChain(process.ppid);
1121
+ if (parentCommands.some((command) => command.toLowerCase().includes("cursor")))
1122
+ return "cursor";
1123
+ return "vscode";
1124
+ }
1125
+ async function processCommandChain(startPid) {
1126
+ const commands = [];
1127
+ let pid = startPid;
1128
+ for (let depth = 0; depth < 20 && pid > 1; depth += 1) {
1129
+ try {
1130
+ const { stdout } = await execFileAsync("ps", ["-p", String(pid), "-o", "ppid=", "-o", "comm="]);
1131
+ const line = stdout.trim();
1132
+ if (!line)
1133
+ break;
1134
+ const match = line.match(/^(\d+)\s+(.+)$/);
1135
+ if (!match)
1136
+ break;
1137
+ pid = Number(match[1]);
1138
+ commands.push(match[2]);
1139
+ }
1140
+ catch {
1141
+ break;
1142
+ }
1143
+ }
1144
+ return commands;
1145
+ }
1146
+ async function openRemoteEditor(vm, vmId, scheme, remoteProjectDir, contextCandidates) {
1147
+ console.log(`Opening ${scheme === "cursor" ? "Cursor" : "VS Code"} Remote SSH window for VM ${vmId}...`);
1148
+ const { identity, identityId } = await freestyle.identities.create();
1149
+ await identity.permissions.vms.grant({ vmId });
1150
+ const { token, tokenId } = await identity.tokens.create();
1151
+ const remoteWorkspaceUri = `vscode-remote://ssh-remote%2B${vmId}%2C${token}@${vmId}.vm-ssh.freestyle.sh${encodeRemotePath(remoteProjectDir)}`;
1152
+ const uri = `${scheme}://vscode-remote/ssh-remote+${vmId},${token}@${vmId}.vm-ssh.freestyle.sh${encodeRemotePath(remoteProjectDir)}?windowId=_blank`;
1153
+ try {
1154
+ const preseedResult = await preseedLocalCopilotForRemoteUri(vm, contextCandidates, scheme, remoteWorkspaceUri);
1155
+ if (preseedResult.status === "copied") {
1156
+ console.log(`Prepared Copilot sidebar state for ${scheme === "cursor" ? "Cursor" : "VS Code"}.`);
1157
+ }
1158
+ await openUri(uri);
1159
+ }
1160
+ catch (error) {
1161
+ await identity.tokens.revoke({ tokenId }).catch(() => undefined);
1162
+ await freestyle.identities.delete({ identityId }).catch(() => undefined);
1163
+ throw error;
1164
+ }
1165
+ await delay(5000);
1166
+ const copilotResult = await syncLocalCopilotForRemoteUri(vm, contextCandidates, remoteWorkspaceUri);
1167
+ if (copilotResult.status === "copied") {
1168
+ console.log(`Copied Copilot sidebar state to opened ${scheme === "cursor" ? "Cursor" : "VS Code"} workspace.`);
1169
+ }
1170
+ else if (copilotResult.status === "pending") {
1171
+ console.log("Copilot sidebar state: opened the editor, but the workspace storage bucket was not created yet. Reload the window after it finishes connecting, then rerun vmpush if needed.");
1172
+ }
1173
+ console.log(`Opened ${scheme === "cursor" ? "Cursor" : "VS Code"}. SSH identity ${identityId} remains active for the editor session.`);
1174
+ }
1175
+ async function preseedLocalCopilotForRemoteUri(vm, contextCandidates, scheme, remoteWorkspaceUri) {
1176
+ const sourceCopilotDir = selectedCurrentCopilotSource(contextCandidates);
1177
+ if (!sourceCopilotDir || !(await exists(sourceCopilotDir)))
1178
+ return { status: "skipped", count: 0 };
1179
+ const workspaceId = md5(remoteWorkspaceUri);
1180
+ const destinations = [];
1181
+ for (const codeRoot of codeUserRootsForScheme(homedir(), scheme)) {
1182
+ const workspaceDir = path.join(codeRoot, "workspaceStorage", workspaceId);
1183
+ await mkdir(workspaceDir, { recursive: true });
1184
+ await writeFile(path.join(workspaceDir, "workspace.json"), `${JSON.stringify({ folder: remoteWorkspaceUri }, null, 2)}\n`, "utf8");
1185
+ await ensureWorkspaceStateDb(path.join(workspaceDir, "state.vscdb"));
1186
+ destinations.push(path.join(workspaceDir, "GitHub.copilot-chat"));
1187
+ }
1188
+ await copyCopilotState(sourceCopilotDir, destinations);
1189
+ await copyCopilotWorkspaceStateKeys(sourceCopilotDir, destinations);
1190
+ await copyCopilotStateToRemoteServer(vm, sourceCopilotDir, destinations);
1191
+ return { status: "copied", count: destinations.length };
1192
+ }
1193
+ async function ensureWorkspaceStateDb(stateDb) {
1194
+ await execFileAsync("sqlite3", [stateDb, "CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB);"]);
1195
+ }
1196
+ function codeUserRootsForScheme(home, scheme) {
1197
+ if (scheme === "cursor") {
1198
+ return [
1199
+ path.join(home, "Library", "Application Support", "Cursor", "User"),
1200
+ path.join(home, ".config", "Cursor", "User"),
1201
+ ];
1202
+ }
1203
+ return [
1204
+ path.join(home, "Library", "Application Support", "Code", "User"),
1205
+ path.join(home, ".config", "Code", "User"),
1206
+ ];
1207
+ }
1208
+ function codeUserRoots(home) {
1209
+ return [
1210
+ path.join(home, "Library", "Application Support", "Code", "User"),
1211
+ path.join(home, "Library", "Application Support", "Code - Insiders", "User"),
1212
+ path.join(home, "Library", "Application Support", "Cursor", "User"),
1213
+ path.join(home, ".config", "Code", "User"),
1214
+ path.join(home, ".config", "Code - Insiders", "User"),
1215
+ path.join(home, ".config", "Cursor", "User"),
1216
+ ];
1217
+ }
1218
+ async function listDirectories(directory) {
1219
+ try {
1220
+ const dirents = await import("node:fs/promises").then((fs) => fs.readdir(directory, { withFileTypes: true }));
1221
+ return dirents.filter((dirent) => dirent.isDirectory()).map((dirent) => path.join(directory, dirent.name));
1222
+ }
1223
+ catch {
1224
+ return [];
1225
+ }
1226
+ }
1227
+ function encodeRemotePath(remoteProjectDir) {
1228
+ return remoteProjectDir.split("/").map((segment) => encodeURIComponent(segment)).join("/");
1229
+ }
1230
+ async function openUri(uri) {
1231
+ if (process.platform === "darwin") {
1232
+ await execFileAsync("open", [uri]);
1233
+ }
1234
+ else if (process.platform === "win32") {
1235
+ await execFileAsync("cmd", ["/c", "start", "", uri]);
1236
+ }
1237
+ else {
1238
+ await execFileAsync("xdg-open", [uri]);
1239
+ }
1240
+ }
1241
+ async function sshIntoVm(vmId) {
1242
+ console.log(`Connecting to VM ${vmId}...`);
1243
+ const exitCode = await new Promise((resolve, reject) => {
1244
+ const child = spawn("npx", ["freestyle", "vm", "ssh", vmId], { stdio: "inherit" });
1245
+ child.on("error", reject);
1246
+ child.on("exit", (code) => resolve(code));
1247
+ });
1248
+ if (exitCode && exitCode !== 0) {
1249
+ throw new Error(`ssh exited with status ${exitCode}`);
1250
+ }
1251
+ }
1252
+ async function hashFile(filePath) {
1253
+ const hash = createHash("sha256");
1254
+ await new Promise((resolve, reject) => {
1255
+ const stream = createReadStream(filePath);
1256
+ stream.on("data", (chunk) => hash.update(chunk));
1257
+ stream.on("error", reject);
1258
+ stream.on("end", resolve);
1259
+ });
1260
+ return hash.digest("hex");
1261
+ }
1262
+ function hashString(value) {
1263
+ return createHash("sha256").update(value).digest("hex");
1264
+ }
1265
+ function md5(value) {
1266
+ return createHash("md5").update(value).digest("hex");
1267
+ }
1268
+ function renderEnvFile(envExports) {
1269
+ const lines = ["# Generated by vmpush. Contains local auth tokens."];
1270
+ for (const key of Object.keys(envExports).sort()) {
1271
+ lines.push(`export ${key}=${shellQuote(envExports[key])}`);
1272
+ }
1273
+ return `${lines.join("\n")}\n`;
1274
+ }
1275
+ function shellQuote(value) {
1276
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
1277
+ }
1278
+ function normalizeRemotePath(value) {
1279
+ const normalized = path.posix.normalize(value.replace(/\\/g, "/"));
1280
+ if (!normalized.startsWith("/")) {
1281
+ throw new Error("--remote-dir must be an absolute VM path");
1282
+ }
1283
+ return normalized;
1284
+ }
1285
+ function defaultRemoteProjectDir(projectRoot) {
1286
+ return normalizeRemotePath(toPosix(projectRoot));
1287
+ }
1288
+ function sanitizeName(value) {
1289
+ return value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "project";
1290
+ }
1291
+ function toPosix(value) {
1292
+ return value.split(path.sep).join(path.posix.sep);
1293
+ }
1294
+ function formatBytes(bytes) {
1295
+ if (bytes < 1024)
1296
+ return `${bytes} B`;
1297
+ if (bytes < 1024 * 1024)
1298
+ return `${(bytes / 1024).toFixed(1)} KB`;
1299
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
1300
+ }
1301
+ async function delay(ms) {
1302
+ await new Promise((resolve) => setTimeout(resolve, ms));
1303
+ }
1304
+ async function exists(filePath) {
1305
+ try {
1306
+ await stat(filePath);
1307
+ return true;
1308
+ }
1309
+ catch {
1310
+ return false;
1311
+ }
1312
+ }
1313
+ function chunkArray(items, size) {
1314
+ const chunks = [];
1315
+ for (let index = 0; index < items.length; index += size) {
1316
+ chunks.push(items.slice(index, index + size));
1317
+ }
1318
+ return chunks;
1319
+ }