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