freestyle-sync 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -37
- package/dist/src/main.d.ts +46 -0
- package/dist/src/main.js +172 -23
- package/dist/src/plugin-api.d.ts +127 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -1,46 +1,12 @@
|
|
|
1
1
|
# Freestyle Sync
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Offload any development environment to a VM with 1 command. Authentication, agent context, and dependencies are automatically handled.
|
|
4
4
|
|
|
5
5
|
```
|
|
6
6
|
npx freestyle-sync
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
```sh
|
|
12
|
-
npx freestyle-sync --provider apple-container --apple-container-image ubuntu:24.04
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
Use `--vm-id <container-name-or-id>` to reuse an existing Apple container. Snapshot caching is only available with the Freestyle provider.
|
|
16
|
-
|
|
17
|
-
Use the SDK when embedding sync inside another CLI/app (no required `freestyle-sync.config.ts` or cache file):
|
|
18
|
-
|
|
19
|
-
```ts
|
|
20
|
-
import { defineConfig, sync, type SyncCache } from "freestyle-sync";
|
|
21
|
-
import { gitAuthPlugin } from "@freestyle-sync/auth-git";
|
|
22
|
-
import { nodeNpmPlugin } from "@freestyle-sync/node-npm";
|
|
23
|
-
|
|
24
|
-
let cache: SyncCache | undefined;
|
|
25
|
-
|
|
26
|
-
const result = await sync({
|
|
27
|
-
config: defineConfig({
|
|
28
|
-
plugins: [gitAuthPlugin(), nodeNpmPlugin()],
|
|
29
|
-
}),
|
|
30
|
-
options: {
|
|
31
|
-
projectRoot: process.cwd(),
|
|
32
|
-
autoSsh: false,
|
|
33
|
-
},
|
|
34
|
-
cache,
|
|
35
|
-
onCacheUpdate(nextCache) {
|
|
36
|
-
cache = nextCache;
|
|
37
|
-
},
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
console.log("synced runtime", result.vmId);
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
freestyle-sync creates `freestyle-sync.config.mjs` on first run and installs the config dependencies into your project as dev dependencies. Remove the generated config or dependencies if you want to reset it later.
|
|
9
|
+
On it's first run, freestyle-sync creates `freestyle-sync.config.mjs` and configures a large set of default plugins. Plugins help sync parts of your development environment that require more complex logic than just copying the current directory's files.
|
|
44
10
|
|
|
45
11
|
```js
|
|
46
12
|
import { defineConfig } from "freestyle-sync";
|
|
@@ -57,8 +23,11 @@ import { githubCliAuthPlugin } from "@freestyle-sync/auth-github-cli";
|
|
|
57
23
|
import { npmAuthPlugin } from "@freestyle-sync/auth-npm";
|
|
58
24
|
import { sshAuthPlugin } from "@freestyle-sync/auth-ssh";
|
|
59
25
|
import { yarnAuthPlugin } from "@freestyle-sync/auth-yarn";
|
|
26
|
+
import { nextjsPlugin } from "@freestyle-sync/nextjs";
|
|
60
27
|
import { nodeNpmPlugin } from "@freestyle-sync/node-npm";
|
|
28
|
+
import { rustPlugin } from "@freestyle-sync/rust";
|
|
61
29
|
import { shellHistoryPlugin } from "@freestyle-sync/shell-history";
|
|
30
|
+
import { vitePlugin } from "@freestyle-sync/vite";
|
|
62
31
|
|
|
63
32
|
export default defineConfig({
|
|
64
33
|
plugins: [
|
|
@@ -73,10 +42,70 @@ export default defineConfig({
|
|
|
73
42
|
azureAuthPlugin(),
|
|
74
43
|
gcloudAuthPlugin(),
|
|
75
44
|
nodeNpmPlugin(),
|
|
45
|
+
nextjsPlugin(),
|
|
46
|
+
vitePlugin(),
|
|
47
|
+
rustPlugin(),
|
|
76
48
|
claudeAgentPlugin(),
|
|
77
49
|
codexAgentPlugin(),
|
|
78
50
|
copilotAgentPlugin(),
|
|
79
51
|
shellHistoryPlugin(),
|
|
80
52
|
],
|
|
81
53
|
});
|
|
82
|
-
```
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
You can exclude folders and include additional local paths. Include paths may point outside the project directory; they are copied into the remote project at the configured relative target.
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
export default defineConfig({
|
|
60
|
+
sync: {
|
|
61
|
+
exclude: ["dist", ".turbo"],
|
|
62
|
+
include: [
|
|
63
|
+
{ source: "../shared-config", target: "shared-config" },
|
|
64
|
+
],
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The default Node/framework/runtime plugins avoid uploading generated dependency and build output folders:
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
nodeNpmPlugin() // excludes node_modules and installs frozen dependencies remotely
|
|
73
|
+
nextjsPlugin() // excludes .next
|
|
74
|
+
vitePlugin() // excludes dist, build, and .vite
|
|
75
|
+
rustPlugin() // excludes target, installs Rust/Cargo, and runs cargo fetch
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
You can keep uploading those generated folders when needed:
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
nodeNpmPlugin({ syncNodeModules: true })
|
|
82
|
+
nextjsPlugin({ syncNextDir: true })
|
|
83
|
+
vitePlugin({ syncBuildOutput: true })
|
|
84
|
+
rustPlugin({ syncTargetDir: true })
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
If you're integrating freestyle-sync into your own toolchain, you can use it as an sdk and take full control over how configuration and cache is stored.
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { defineConfig, sync, type SyncCache } from "freestyle-sync";
|
|
91
|
+
import { gitAuthPlugin } from "@freestyle-sync/auth-git";
|
|
92
|
+
import { nodeNpmPlugin } from "@freestyle-sync/node-npm";
|
|
93
|
+
|
|
94
|
+
let cache: SyncCache | undefined;
|
|
95
|
+
|
|
96
|
+
const result = await sync({
|
|
97
|
+
config: defineConfig({
|
|
98
|
+
plugins: [gitAuthPlugin(), nodeNpmPlugin()],
|
|
99
|
+
}),
|
|
100
|
+
options: {
|
|
101
|
+
projectRoot: process.cwd(),
|
|
102
|
+
autoSsh: false,
|
|
103
|
+
},
|
|
104
|
+
cache,
|
|
105
|
+
onCacheUpdate(nextCache) {
|
|
106
|
+
cache = nextCache;
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
console.log("synced runtime", result.vmId);
|
|
111
|
+
```
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "dotenv/config";
|
|
3
|
+
import type { CliOptions, PushvmConfig, VmProviderName } from "./plugin-api.ts";
|
|
4
|
+
export * from "./plugin-api.ts";
|
|
5
|
+
type FileKind = "file" | "symlink";
|
|
6
|
+
type FileDigest = {
|
|
7
|
+
hash: string;
|
|
8
|
+
kind: FileKind;
|
|
9
|
+
mode: number;
|
|
10
|
+
size: number;
|
|
11
|
+
};
|
|
12
|
+
type CacheFile = {
|
|
13
|
+
version: number;
|
|
14
|
+
projectRoot: string;
|
|
15
|
+
remoteProjectDir: string;
|
|
16
|
+
provider?: VmProviderName;
|
|
17
|
+
vmId?: string;
|
|
18
|
+
snapshotId?: string;
|
|
19
|
+
projectFiles: Record<string, FileDigest>;
|
|
20
|
+
contextFiles: Record<string, FileDigest>;
|
|
21
|
+
snapshotProjectFiles?: Record<string, FileDigest>;
|
|
22
|
+
snapshotContextFiles?: Record<string, FileDigest>;
|
|
23
|
+
envHash?: string;
|
|
24
|
+
snapshotEnvHash?: string;
|
|
25
|
+
updatedAt?: string;
|
|
26
|
+
};
|
|
27
|
+
type PluginPreferences = {
|
|
28
|
+
version: number;
|
|
29
|
+
disabledPlugins: string[];
|
|
30
|
+
updatedAt?: string;
|
|
31
|
+
};
|
|
32
|
+
export type SyncCache = CacheFile;
|
|
33
|
+
export type SyncPluginPreferences = PluginPreferences;
|
|
34
|
+
export type SyncSdkOptions = {
|
|
35
|
+
config: PushvmConfig;
|
|
36
|
+
options?: Partial<CliOptions>;
|
|
37
|
+
cache?: SyncCache;
|
|
38
|
+
pluginPreferences?: SyncPluginPreferences;
|
|
39
|
+
onCacheUpdate?(cache: SyncCache): Promise<void> | void;
|
|
40
|
+
};
|
|
41
|
+
export type SyncSdkResult = {
|
|
42
|
+
vmId?: string;
|
|
43
|
+
cache: SyncCache;
|
|
44
|
+
snapshotId?: string;
|
|
45
|
+
};
|
|
46
|
+
export declare function sync(sdkOptions: SyncSdkOptions): Promise<SyncSdkResult>;
|
package/dist/src/main.js
CHANGED
|
@@ -10,7 +10,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
10
10
|
import "dotenv/config";
|
|
11
11
|
import { createHash } from "node:crypto";
|
|
12
12
|
import { createReadStream, realpathSync } from "node:fs";
|
|
13
|
-
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
13
|
+
import { chmod, copyFile, lstat, mkdir, mkdtemp, readFile, readlink, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
14
14
|
import { homedir, tmpdir } from "node:os";
|
|
15
15
|
import path from "node:path";
|
|
16
16
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -47,8 +47,11 @@ const DEFAULT_CONFIG_DEPENDENCIES = [
|
|
|
47
47
|
{ name: "@freestyle-sync/auth-npm", spec: "@freestyle-sync/auth-npm@latest" },
|
|
48
48
|
{ name: "@freestyle-sync/auth-ssh", spec: "@freestyle-sync/auth-ssh@latest" },
|
|
49
49
|
{ name: "@freestyle-sync/auth-yarn", spec: "@freestyle-sync/auth-yarn@latest" },
|
|
50
|
+
{ name: "@freestyle-sync/nextjs", spec: "@freestyle-sync/nextjs@latest" },
|
|
50
51
|
{ name: "@freestyle-sync/node-npm", spec: "@freestyle-sync/node-npm@latest" },
|
|
52
|
+
{ name: "@freestyle-sync/rust", spec: "@freestyle-sync/rust@latest" },
|
|
51
53
|
{ name: "@freestyle-sync/shell-history", spec: "@freestyle-sync/shell-history@latest" },
|
|
54
|
+
{ name: "@freestyle-sync/vite", spec: "@freestyle-sync/vite@latest" },
|
|
52
55
|
];
|
|
53
56
|
const pluginUtils = {
|
|
54
57
|
checkedExec,
|
|
@@ -182,7 +185,8 @@ export async function sync(sdkOptions) {
|
|
|
182
185
|
progress.step("Scanning project files");
|
|
183
186
|
const cache = currentCache;
|
|
184
187
|
const base = cacheBaseForSync(options, cache);
|
|
185
|
-
const
|
|
188
|
+
const projectSyncConfig = await resolveProjectSyncConfig(options);
|
|
189
|
+
const projectEntries = await scanProject(options.projectRoot, options.includeGitDir, projectSyncConfig);
|
|
186
190
|
const projectCurrent = digestMap(projectEntries);
|
|
187
191
|
const projectChanges = diffEntries(projectEntries, base.projectFiles);
|
|
188
192
|
progress.step("Detecting auth and agent context");
|
|
@@ -374,8 +378,11 @@ import { githubCliAuthPlugin } from "@freestyle-sync/auth-github-cli";
|
|
|
374
378
|
import { npmAuthPlugin } from "@freestyle-sync/auth-npm";
|
|
375
379
|
import { sshAuthPlugin } from "@freestyle-sync/auth-ssh";
|
|
376
380
|
import { yarnAuthPlugin } from "@freestyle-sync/auth-yarn";
|
|
381
|
+
import { nextjsPlugin } from "@freestyle-sync/nextjs";
|
|
377
382
|
import { nodeNpmPlugin } from "@freestyle-sync/node-npm";
|
|
383
|
+
import { rustPlugin } from "@freestyle-sync/rust";
|
|
378
384
|
import { shellHistoryPlugin } from "@freestyle-sync/shell-history";
|
|
385
|
+
import { vitePlugin } from "@freestyle-sync/vite";
|
|
379
386
|
import { defineConfig } from "freestyle-sync";
|
|
380
387
|
|
|
381
388
|
export default defineConfig({
|
|
@@ -391,6 +398,9 @@ export default defineConfig({
|
|
|
391
398
|
azureAuthPlugin(),
|
|
392
399
|
gcloudAuthPlugin(),
|
|
393
400
|
nodeNpmPlugin(),
|
|
401
|
+
nextjsPlugin(),
|
|
402
|
+
vitePlugin(),
|
|
403
|
+
rustPlugin(),
|
|
394
404
|
claudeAgentPlugin(),
|
|
395
405
|
codexAgentPlugin(),
|
|
396
406
|
copilotAgentPlugin(),
|
|
@@ -413,8 +423,11 @@ import { githubCliAuthPlugin } from "@freestyle-sync/auth-github-cli";
|
|
|
413
423
|
import { npmAuthPlugin } from "@freestyle-sync/auth-npm";
|
|
414
424
|
import { sshAuthPlugin } from "@freestyle-sync/auth-ssh";
|
|
415
425
|
import { yarnAuthPlugin } from "@freestyle-sync/auth-yarn";
|
|
426
|
+
import { nextjsPlugin } from "@freestyle-sync/nextjs";
|
|
416
427
|
import { nodeNpmPlugin } from "@freestyle-sync/node-npm";
|
|
428
|
+
import { rustPlugin } from "@freestyle-sync/rust";
|
|
417
429
|
import { shellHistoryPlugin } from "@freestyle-sync/shell-history";
|
|
430
|
+
import { vitePlugin } from "@freestyle-sync/vite";
|
|
418
431
|
import { defineConfig } from "freestyle-sync";
|
|
419
432
|
|
|
420
433
|
export default defineConfig({
|
|
@@ -430,6 +443,9 @@ export default defineConfig({
|
|
|
430
443
|
azureAuthPlugin(),
|
|
431
444
|
gcloudAuthPlugin(),
|
|
432
445
|
nodeNpmPlugin(),
|
|
446
|
+
nextjsPlugin(),
|
|
447
|
+
vitePlugin(),
|
|
448
|
+
rustPlugin(),
|
|
433
449
|
claudeAgentPlugin(),
|
|
434
450
|
codexAgentPlugin(),
|
|
435
451
|
copilotAgentPlugin(),
|
|
@@ -719,23 +735,92 @@ async function writeCache(cachePath, cache) {
|
|
|
719
735
|
await mkdir(path.dirname(cachePath), { recursive: true });
|
|
720
736
|
await writeFile(cachePath, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
|
|
721
737
|
}
|
|
722
|
-
async function
|
|
723
|
-
const
|
|
724
|
-
|
|
738
|
+
async function resolveProjectSyncConfig(options) {
|
|
739
|
+
const configs = [];
|
|
740
|
+
if (config.sync)
|
|
741
|
+
configs.push(config.sync);
|
|
742
|
+
for (const plugin of plugins) {
|
|
743
|
+
const pluginConfig = await plugin.configureProjectSync?.({ options, utils: pluginUtils });
|
|
744
|
+
if (pluginConfig)
|
|
745
|
+
configs.push(pluginConfig);
|
|
746
|
+
}
|
|
747
|
+
return {
|
|
748
|
+
exclude: configs.flatMap((projectConfig) => projectConfig.exclude ?? []).map(normalizeProjectPattern),
|
|
749
|
+
include: configs.flatMap((projectConfig) => normalizeProjectIncludes(options.projectRoot, projectConfig.include ?? [])),
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
function normalizeProjectIncludes(projectRoot, includes) {
|
|
753
|
+
return includes.map((include) => {
|
|
754
|
+
const source = typeof include === "string" ? include : include.source;
|
|
755
|
+
const target = typeof include === "string" ? undefined : include.target;
|
|
756
|
+
const resolvedSource = path.resolve(projectRoot, source);
|
|
757
|
+
return {
|
|
758
|
+
source: resolvedSource,
|
|
759
|
+
target: normalizeProjectPattern(target ?? path.basename(resolvedSource)),
|
|
760
|
+
};
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
function normalizeProjectPattern(value) {
|
|
764
|
+
const normalized = path.posix.normalize(value.replace(/\\/g, "/")).replace(/^\/+/, "").replace(/\/+$/, "");
|
|
765
|
+
if (!normalized || normalized === ".") {
|
|
766
|
+
throw new Error("project sync paths must not be empty");
|
|
767
|
+
}
|
|
768
|
+
if (normalized === ".." || normalized.startsWith("../")) {
|
|
769
|
+
throw new Error(`project sync paths must stay inside the remote project: ${value}`);
|
|
770
|
+
}
|
|
771
|
+
return normalized;
|
|
772
|
+
}
|
|
773
|
+
async function scanProject(projectRoot, includeGitDir, syncConfig) {
|
|
774
|
+
const entriesByPath = new Map();
|
|
775
|
+
const rootEntries = [];
|
|
776
|
+
await walk(projectRoot, "", rootEntries, {
|
|
725
777
|
skipDirectory(relativePath, name) {
|
|
726
778
|
if (!includeGitDir && relativePath === ".git")
|
|
727
779
|
return true;
|
|
728
|
-
return
|
|
780
|
+
return shouldSkipProjectDirectory(relativePath, name, syncConfig.exclude);
|
|
729
781
|
},
|
|
730
782
|
});
|
|
731
|
-
|
|
783
|
+
upsertProjectEntries(entriesByPath, rootEntries);
|
|
784
|
+
for (const include of syncConfig.include) {
|
|
785
|
+
upsertProjectEntries(entriesByPath, await scanProjectInclude(include, syncConfig.exclude));
|
|
786
|
+
}
|
|
787
|
+
return Array.from(entriesByPath.values()).sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
788
|
+
}
|
|
789
|
+
async function scanProjectInclude(include, exclude) {
|
|
790
|
+
const stats = await lstat(include.source).catch((error) => {
|
|
791
|
+
throw new Error(`project sync include does not exist: ${include.source} (${error instanceof Error ? error.message : String(error)})`);
|
|
792
|
+
});
|
|
793
|
+
if (stats.isFile() || stats.isSymbolicLink()) {
|
|
794
|
+
return [await digestEntry(include.source, include.target)];
|
|
795
|
+
}
|
|
796
|
+
if (!stats.isDirectory()) {
|
|
797
|
+
return [];
|
|
798
|
+
}
|
|
799
|
+
const entries = [];
|
|
800
|
+
await walk(include.source, "", entries, {
|
|
801
|
+
mapRelativePath(relativePath) {
|
|
802
|
+
return `${include.target}/${relativePath}`;
|
|
803
|
+
},
|
|
804
|
+
skipDirectory(relativePath, name) {
|
|
805
|
+
return shouldSkipProjectDirectory(relativePath, name, exclude);
|
|
806
|
+
},
|
|
807
|
+
});
|
|
808
|
+
return entries;
|
|
809
|
+
}
|
|
810
|
+
function upsertProjectEntries(entriesByPath, entries) {
|
|
811
|
+
for (const entry of entries)
|
|
812
|
+
entriesByPath.set(entry.relativePath, entry);
|
|
813
|
+
}
|
|
814
|
+
function shouldSkipProjectDirectory(relativePath, name, exclude) {
|
|
815
|
+
return exclude.some((excludedPath) => relativePath === excludedPath || name === excludedPath);
|
|
732
816
|
}
|
|
733
817
|
async function walk(root, relativePath, entries, options) {
|
|
734
818
|
const absolutePath = path.join(root, relativePath);
|
|
735
819
|
const dir = await import("node:fs/promises").then((fs) => fs.readdir(absolutePath, { withFileTypes: true }));
|
|
736
820
|
for (const dirent of dir) {
|
|
737
821
|
const childRelativePath = relativePath ? path.join(relativePath, dirent.name) : dirent.name;
|
|
738
|
-
const
|
|
822
|
+
const localRelativePath = toPosix(childRelativePath);
|
|
823
|
+
const normalizedRelativePath = options.mapRelativePath?.(localRelativePath) ?? localRelativePath;
|
|
739
824
|
const childAbsolutePath = path.join(root, childRelativePath);
|
|
740
825
|
if (dirent.isDirectory()) {
|
|
741
826
|
if (options.skipDirectory(normalizedRelativePath, dirent.name)) {
|
|
@@ -1387,7 +1472,7 @@ async function ensureRemoteBase(vm, remoteProjectDir) {
|
|
|
1387
1472
|
async function syncProject(vm, vmId, options, changes) {
|
|
1388
1473
|
if (changes.changed.length > 0) {
|
|
1389
1474
|
console.log(`VM ${vmId}: uploading ${changes.changed.length} changed project files...`);
|
|
1390
|
-
const archive = await createProjectArchive(
|
|
1475
|
+
const archive = await createProjectArchive(changes.changed);
|
|
1391
1476
|
try {
|
|
1392
1477
|
await uploadArchiveInChunks(vm, vmId, archive, "/tmp/freestyle-sync-project.tgz", "project");
|
|
1393
1478
|
await checkedExec(vm, `mkdir -p ${shellQuote(options.remoteProjectDir)} && tar --no-same-owner --no-same-permissions -xzf /tmp/freestyle-sync-project.tgz -C ${shellQuote(options.remoteProjectDir)} && rm -f /tmp/freestyle-sync-project.tgz`);
|
|
@@ -1511,12 +1596,14 @@ async function runInstall(vm, projectRoot, remoteProjectDir) {
|
|
|
1511
1596
|
await checkedExec(vm, `cd ${shellQuote(remoteProjectDir)} && ${installCommand}`, 20 * 60 * 1000);
|
|
1512
1597
|
}
|
|
1513
1598
|
async function detectInstallCommand(projectRoot) {
|
|
1599
|
+
if (await exists(path.join(projectRoot, "bun.lock")) || await exists(path.join(projectRoot, "bun.lockb")))
|
|
1600
|
+
return "bun install --frozen-lockfile";
|
|
1514
1601
|
if (await exists(path.join(projectRoot, "pnpm-lock.yaml")))
|
|
1515
|
-
return "corepack enable && pnpm install";
|
|
1602
|
+
return "corepack enable && pnpm install --frozen-lockfile";
|
|
1516
1603
|
if (await exists(path.join(projectRoot, "yarn.lock")))
|
|
1517
|
-
return "corepack enable && yarn install";
|
|
1518
|
-
if (await exists(path.join(projectRoot, "package-lock.json")))
|
|
1519
|
-
return "npm
|
|
1604
|
+
return "corepack enable && yarn_version=$(yarn --version) && case \"$yarn_version\" in 1.*) yarn install --frozen-lockfile ;; *) yarn install --immutable ;; esac";
|
|
1605
|
+
if (await exists(path.join(projectRoot, "package-lock.json")) || await exists(path.join(projectRoot, "npm-shrinkwrap.json")))
|
|
1606
|
+
return "npm ci";
|
|
1520
1607
|
if (await exists(path.join(projectRoot, "requirements.txt")))
|
|
1521
1608
|
return "python3 -m pip install -r requirements.txt";
|
|
1522
1609
|
if (await exists(path.join(projectRoot, "pyproject.toml")))
|
|
@@ -1527,12 +1614,23 @@ async function detectInstallCommand(projectRoot) {
|
|
|
1527
1614
|
return "go mod download";
|
|
1528
1615
|
return undefined;
|
|
1529
1616
|
}
|
|
1530
|
-
async function createProjectArchive(
|
|
1617
|
+
async function createProjectArchive(entries) {
|
|
1531
1618
|
const tempDir = await mkdtemp(path.join(tmpdir(), "freestyle-sync-"));
|
|
1532
|
-
const
|
|
1619
|
+
const stagingDir = path.join(tempDir, "staging");
|
|
1533
1620
|
const archivePath = path.join(tempDir, "project.tgz");
|
|
1534
|
-
await
|
|
1535
|
-
|
|
1621
|
+
await mkdir(stagingDir, { recursive: true });
|
|
1622
|
+
for (const entry of entries) {
|
|
1623
|
+
const destination = path.join(stagingDir, ...entry.relativePath.split("/"));
|
|
1624
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
1625
|
+
if (entry.kind === "symlink") {
|
|
1626
|
+
await symlink(await readlink(entry.absolutePath), destination);
|
|
1627
|
+
}
|
|
1628
|
+
else {
|
|
1629
|
+
await copyFile(entry.absolutePath, destination);
|
|
1630
|
+
await chmod(destination, entry.mode);
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
await createTar(["--no-xattrs", "-czf", archivePath, "-C", stagingDir, "."]);
|
|
1536
1634
|
return archivePath;
|
|
1537
1635
|
}
|
|
1538
1636
|
async function createContextArchive(entries) {
|
|
@@ -1567,11 +1665,13 @@ async function uploadArchiveInChunks(vm, vmId, archivePath, remoteArchivePath, l
|
|
|
1567
1665
|
const width = String(chunkCount - 1).length;
|
|
1568
1666
|
const canRenderInlineProgress = process.stdout.isTTY;
|
|
1569
1667
|
const logEvery = Math.max(1, Math.ceil(chunkCount / 4));
|
|
1668
|
+
const chunkManifest = [];
|
|
1570
1669
|
let index = 0;
|
|
1571
1670
|
for await (const chunk of createReadStream(archivePath, { highWaterMark: ARCHIVE_CHUNK_BYTES })) {
|
|
1572
|
-
const chunkName = `${String(index).padStart(width, "0")}.chunk
|
|
1671
|
+
const chunkName = `${String(index).padStart(width, "0")}.chunk`;
|
|
1573
1672
|
const content = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1574
|
-
|
|
1673
|
+
chunkManifest.push(`${chunkName} ${content.length}`);
|
|
1674
|
+
await vm.fs.writeFile(`${chunkDir}/${chunkName}`, content);
|
|
1575
1675
|
const uploadedChunks = index + 1;
|
|
1576
1676
|
if (chunkCount > 1) {
|
|
1577
1677
|
const progressMessage = `VM ${vmId}: uploaded ${uploadedChunks}/${chunkCount} ${label} archive chunks`;
|
|
@@ -1587,14 +1687,63 @@ async function uploadArchiveInChunks(vm, vmId, archivePath, remoteArchivePath, l
|
|
|
1587
1687
|
}
|
|
1588
1688
|
index += 1;
|
|
1589
1689
|
}
|
|
1690
|
+
if (archiveSize > 0) {
|
|
1691
|
+
await vm.fs.writeTextFile(`${chunkDir}/manifest`, `${chunkManifest.join("\n")}\n`);
|
|
1692
|
+
}
|
|
1590
1693
|
await checkedExec(vm, archiveSize === 0
|
|
1591
1694
|
? `: > ${shellQuote(remoteArchivePath)} && rm -rf ${shellQuote(chunkDir)}`
|
|
1592
1695
|
: [
|
|
1593
1696
|
"set -e",
|
|
1594
|
-
`
|
|
1595
|
-
`
|
|
1596
|
-
`
|
|
1697
|
+
`chunk_dir=${shellQuote(chunkDir)}`,
|
|
1698
|
+
`remote_archive=${shellQuote(remoteArchivePath)}`,
|
|
1699
|
+
`manifest="$chunk_dir/manifest"`,
|
|
1700
|
+
"deadline=$(( $(date +%s) + 60 ))",
|
|
1701
|
+
"while :; do",
|
|
1702
|
+
" validation_error=",
|
|
1703
|
+
" validated_chunks=0",
|
|
1704
|
+
" if [ ! -f \"$manifest\" ]; then",
|
|
1705
|
+
" validation_error='missing chunk manifest'",
|
|
1706
|
+
" else",
|
|
1707
|
+
" actual_chunks=$(find \"$chunk_dir\" -maxdepth 1 -type f -name '*.chunk' | wc -l | tr -d '[:space:]')",
|
|
1708
|
+
` if [ "$actual_chunks" != ${shellQuote(String(chunkCount))} ]; then`,
|
|
1709
|
+
` validation_error="expected ${chunkCount} chunks, found $actual_chunks"`,
|
|
1710
|
+
" else",
|
|
1711
|
+
" while IFS=' ' read -r chunk_name expected_size; do",
|
|
1712
|
+
" [ -n \"$chunk_name\" ] || continue",
|
|
1713
|
+
" chunk_path=\"$chunk_dir/$chunk_name\"",
|
|
1714
|
+
" if [ ! -f \"$chunk_path\" ]; then",
|
|
1715
|
+
" validation_error=\"missing chunk $chunk_name\"",
|
|
1716
|
+
" break",
|
|
1717
|
+
" fi",
|
|
1718
|
+
" actual_chunk_size=$(wc -c < \"$chunk_path\" | tr -d '[:space:]')",
|
|
1719
|
+
" if [ \"$actual_chunk_size\" != \"$expected_size\" ]; then",
|
|
1720
|
+
" validation_error=\"chunk $chunk_name expected $expected_size bytes, got $actual_chunk_size\"",
|
|
1721
|
+
" break",
|
|
1722
|
+
" fi",
|
|
1723
|
+
" validated_chunks=$((validated_chunks + 1))",
|
|
1724
|
+
" done < \"$manifest\"",
|
|
1725
|
+
` if [ "$validated_chunks" != ${shellQuote(String(chunkCount))} ] && [ -z "$validation_error" ]; then`,
|
|
1726
|
+
` validation_error="expected ${chunkCount} manifest entries, found $validated_chunks"`,
|
|
1727
|
+
" fi",
|
|
1728
|
+
" fi",
|
|
1729
|
+
" fi",
|
|
1730
|
+
" if [ -z \"$validation_error\" ]; then",
|
|
1731
|
+
" break",
|
|
1732
|
+
" fi",
|
|
1733
|
+
" if [ \"$(date +%s)\" -ge \"$deadline\" ]; then",
|
|
1734
|
+
` rm -f ${shellQuote(remoteArchivePath)}`,
|
|
1735
|
+
` rm -rf ${shellQuote(chunkDir)}`,
|
|
1736
|
+
` echo ${shellQuote(`archive chunk validation failed for ${label}`)} >&2`,
|
|
1737
|
+
" echo \"$validation_error\" >&2",
|
|
1738
|
+
" exit 1",
|
|
1739
|
+
" fi",
|
|
1740
|
+
" sleep 1",
|
|
1597
1741
|
"done",
|
|
1742
|
+
`rm -f ${shellQuote(remoteArchivePath)}`,
|
|
1743
|
+
"while IFS=' ' read -r chunk_name expected_size; do",
|
|
1744
|
+
" [ -n \"$chunk_name\" ] || continue",
|
|
1745
|
+
" cat \"$chunk_dir/$chunk_name\" >> \"$remote_archive\"",
|
|
1746
|
+
"done < \"$manifest\"",
|
|
1598
1747
|
`actual_size=$(wc -c < ${shellQuote(remoteArchivePath)} | tr -d '[:space:]')`,
|
|
1599
1748
|
`actual_hash=$(sha256sum ${shellQuote(remoteArchivePath)} | awk '{print $1}')`,
|
|
1600
1749
|
`if [ "$actual_size" != ${shellQuote(String(archiveSize))} ] || [ "$actual_hash" != ${shellQuote(archiveHash)} ]; then`,
|
|
@@ -1605,7 +1754,7 @@ async function uploadArchiveInChunks(vm, vmId, archivePath, remoteArchivePath, l
|
|
|
1605
1754
|
" exit 1",
|
|
1606
1755
|
"fi",
|
|
1607
1756
|
`rm -rf ${shellQuote(chunkDir)}`,
|
|
1608
|
-
].join("\n"));
|
|
1757
|
+
].join("\n"), 5 * 60 * MS_PER_SECOND);
|
|
1609
1758
|
}
|
|
1610
1759
|
async function mkdirRemote(vm, directories) {
|
|
1611
1760
|
for (const chunk of chunkArray(directories, 50)) {
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
export type VmProviderName = "freestyle" | "apple-container";
|
|
2
|
+
export type CliOptions = {
|
|
3
|
+
projectRoot: string;
|
|
4
|
+
cachePath: string;
|
|
5
|
+
remoteProjectDir: string;
|
|
6
|
+
provider: VmProviderName;
|
|
7
|
+
name: string;
|
|
8
|
+
vmId?: string;
|
|
9
|
+
appleContainerImage: string;
|
|
10
|
+
idleTimeoutSeconds?: number;
|
|
11
|
+
yes: boolean;
|
|
12
|
+
dryRun: boolean;
|
|
13
|
+
disablePlugins: string[];
|
|
14
|
+
enablePlugins: string[];
|
|
15
|
+
resetPluginPrefs: boolean;
|
|
16
|
+
listPlugins: boolean;
|
|
17
|
+
includeAuth: boolean;
|
|
18
|
+
includeAgentContext: boolean;
|
|
19
|
+
includeGitDir: boolean;
|
|
20
|
+
includeAllCopilotWorkspaces: boolean;
|
|
21
|
+
snapshot: boolean;
|
|
22
|
+
skipSync: boolean;
|
|
23
|
+
install: boolean;
|
|
24
|
+
autoSsh: boolean;
|
|
25
|
+
envKeys: string[];
|
|
26
|
+
};
|
|
27
|
+
export type ContextCandidate = {
|
|
28
|
+
source: string;
|
|
29
|
+
remoteRoot: string;
|
|
30
|
+
label: string;
|
|
31
|
+
sensitive: boolean;
|
|
32
|
+
preferenceKey: string;
|
|
33
|
+
promptLabel: string;
|
|
34
|
+
};
|
|
35
|
+
export type RemoteVm = {
|
|
36
|
+
exec(args: {
|
|
37
|
+
command: string;
|
|
38
|
+
timeoutMs?: number;
|
|
39
|
+
}): Promise<{
|
|
40
|
+
stdout?: string | null;
|
|
41
|
+
stderr?: string | null;
|
|
42
|
+
statusCode?: number | null;
|
|
43
|
+
}>;
|
|
44
|
+
fs: {
|
|
45
|
+
writeFile(path: string, content: Buffer): Promise<void>;
|
|
46
|
+
writeTextFile(path: string, content: string): Promise<void>;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
export type UploadArchiveInChunks = (vm: RemoteVm, vmId: string, archivePath: string, remoteArchivePath: string, label: string) => Promise<void>;
|
|
50
|
+
export type ExecFileAsync = (file: string, args: string[]) => Promise<{
|
|
51
|
+
stdout: string;
|
|
52
|
+
stderr: string;
|
|
53
|
+
}>;
|
|
54
|
+
export type PushvmPluginUtils = {
|
|
55
|
+
checkedExec(vm: RemoteVm, command: string, timeoutMs?: number): Promise<{
|
|
56
|
+
stdout?: string | null;
|
|
57
|
+
stderr?: string | null;
|
|
58
|
+
statusCode?: number | null;
|
|
59
|
+
}>;
|
|
60
|
+
createTar(args: string[]): Promise<void>;
|
|
61
|
+
uploadArchiveInChunks: UploadArchiveInChunks;
|
|
62
|
+
execFileAsync: ExecFileAsync;
|
|
63
|
+
shellQuote(value: string): string;
|
|
64
|
+
md5(value: string): string;
|
|
65
|
+
delay(ms: number): Promise<void>;
|
|
66
|
+
};
|
|
67
|
+
export type PushvmPluginContext = {
|
|
68
|
+
options: CliOptions;
|
|
69
|
+
utils: PushvmPluginUtils;
|
|
70
|
+
};
|
|
71
|
+
export type RemoteHookContext = PushvmPluginContext & {
|
|
72
|
+
vm: RemoteVm;
|
|
73
|
+
vmId: string;
|
|
74
|
+
};
|
|
75
|
+
export type EditorScheme = string;
|
|
76
|
+
export type EditorHookContext = RemoteHookContext & {
|
|
77
|
+
scheme: EditorScheme;
|
|
78
|
+
remoteWorkspaceUri: string;
|
|
79
|
+
};
|
|
80
|
+
export type ConnectHookContext = RemoteHookContext & {
|
|
81
|
+
contextCandidates: ContextCandidate[];
|
|
82
|
+
runBeforeOpenRemoteEditor(args: {
|
|
83
|
+
scheme: EditorScheme;
|
|
84
|
+
remoteWorkspaceUri: string;
|
|
85
|
+
}): Promise<string[]>;
|
|
86
|
+
runAfterOpenRemoteEditor(args: {
|
|
87
|
+
scheme: EditorScheme;
|
|
88
|
+
remoteWorkspaceUri: string;
|
|
89
|
+
}): Promise<string[]>;
|
|
90
|
+
};
|
|
91
|
+
export type ProjectSyncInclude = string | {
|
|
92
|
+
source: string;
|
|
93
|
+
target?: string;
|
|
94
|
+
};
|
|
95
|
+
export type ProjectSyncConfig = {
|
|
96
|
+
exclude?: string[];
|
|
97
|
+
include?: ProjectSyncInclude[];
|
|
98
|
+
};
|
|
99
|
+
export type PushvmPlugin = {
|
|
100
|
+
name: string;
|
|
101
|
+
collectEnvironment?(context: PushvmPluginContext): Record<string, string>;
|
|
102
|
+
discoverContextCandidates?(context: PushvmPluginContext): Promise<ContextCandidate[]> | ContextCandidate[];
|
|
103
|
+
configureProjectSync?(context: PushvmPluginContext): Promise<ProjectSyncConfig | void> | ProjectSyncConfig | void;
|
|
104
|
+
shouldSkipContextDirectory?(relativePath: string, name: string): boolean;
|
|
105
|
+
isProtectedRemotePath?(remotePath: string): boolean;
|
|
106
|
+
afterProjectSync?(context: RemoteHookContext): Promise<void> | void;
|
|
107
|
+
afterContextSync?(context: RemoteHookContext & {
|
|
108
|
+
changedRemotePaths: string[];
|
|
109
|
+
}): Promise<void> | void;
|
|
110
|
+
beforeSnapshot?(context: RemoteHookContext): Promise<void> | void;
|
|
111
|
+
afterSync?(context: RemoteHookContext & {
|
|
112
|
+
contextCandidates: ContextCandidate[];
|
|
113
|
+
}): Promise<string[] | void> | string[] | void;
|
|
114
|
+
connect?(context: ConnectHookContext): Promise<boolean | void> | boolean | void;
|
|
115
|
+
beforeOpenRemoteEditor?(context: EditorHookContext & {
|
|
116
|
+
contextCandidates: ContextCandidate[];
|
|
117
|
+
}): Promise<string[] | void> | string[] | void;
|
|
118
|
+
afterOpenRemoteEditor?(context: EditorHookContext & {
|
|
119
|
+
contextCandidates: ContextCandidate[];
|
|
120
|
+
}): Promise<string[] | void> | string[] | void;
|
|
121
|
+
};
|
|
122
|
+
export type PushvmConfig = {
|
|
123
|
+
plugins: PushvmPlugin[];
|
|
124
|
+
sync?: ProjectSyncConfig;
|
|
125
|
+
};
|
|
126
|
+
export declare function definePlugin(plugin: PushvmPlugin): PushvmPlugin;
|
|
127
|
+
export declare function defineConfig(config: PushvmConfig): PushvmConfig;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "freestyle-sync",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/src/main.js",
|
|
6
6
|
"exports": {
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
"publish": "npm run check && npm run build && npm run publish:workspaces && npm run publish:root",
|
|
26
26
|
"publish:root": "node scripts/publish-root.mjs",
|
|
27
27
|
"publish:workspaces": "node scripts/publish-workspaces.mjs",
|
|
28
|
-
"start": "node src/main.ts"
|
|
28
|
+
"start": "node src/main.ts",
|
|
29
|
+
"version:patch": "node scripts/version-workspaces.mjs patch",
|
|
30
|
+
"version:workspaces": "node scripts/version-workspaces.mjs"
|
|
29
31
|
},
|
|
30
32
|
"dependencies": {
|
|
31
33
|
"dotenv": "^17.4.2",
|