freestyle-sync 0.1.5 → 0.1.8
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/dist/src/main.js +210 -13
- package/package.json +3 -2
package/dist/src/main.js
CHANGED
|
@@ -11,19 +11,24 @@ import "dotenv/config";
|
|
|
11
11
|
import { createHash } from "node:crypto";
|
|
12
12
|
import { createReadStream, realpathSync } from "node:fs";
|
|
13
13
|
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
14
|
-
import { tmpdir } from "node:os";
|
|
14
|
+
import { homedir, tmpdir } from "node:os";
|
|
15
15
|
import path from "node:path";
|
|
16
16
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
17
17
|
import { execFile, spawn } from "node:child_process";
|
|
18
18
|
import { promisify } from "node:util";
|
|
19
|
-
import {
|
|
19
|
+
import { Freestyle } from "freestyle";
|
|
20
20
|
export * from "./plugin-api.js";
|
|
21
21
|
const execFileAsync = promisify(execFile);
|
|
22
22
|
const CLI_NAME = "freestyle-sync";
|
|
23
23
|
const CACHE_VERSION = 1;
|
|
24
24
|
const PLUGIN_PREFERENCES_VERSION = 1;
|
|
25
|
-
const
|
|
25
|
+
const ARCHIVE_CHUNK_BYTES = 1024 * 1024;
|
|
26
26
|
const MS_PER_SECOND = 1000;
|
|
27
|
+
const DEFAULT_FREESTYLE_API_URL = "https://api.freestyle.sh";
|
|
28
|
+
const DEFAULT_STACK_API_URL = "https://api.stack-auth.com";
|
|
29
|
+
const DEFAULT_STACK_PROJECT_ID = "0edf478c-f123-46fb-818f-34c0024a9f35";
|
|
30
|
+
const DEFAULT_STACK_PUBLISHABLE_CLIENT_KEY = "pck_h2aft7g9pqjzrkdnzs199h1may5wjtdtdxeex7m2wzp1r";
|
|
31
|
+
const STACK_REFRESH_TOKEN_ENV_KEY = "FREESTYLE_STACK_REFRESH_TOKEN";
|
|
27
32
|
const USE_UNICODE_OUTPUT = process.stdout.isTTY && (process.env.TERM !== "dumb" || Boolean(process.env.TERM_PROGRAM));
|
|
28
33
|
const USE_STYLED_OUTPUT = process.stdout.isTTY && process.env.NO_COLOR !== "1";
|
|
29
34
|
const DEFAULT_CONFIG_DEPENDENCIES = [
|
|
@@ -1005,7 +1010,198 @@ function color(code, text) {
|
|
|
1005
1010
|
return text;
|
|
1006
1011
|
return `\u001b[${code}m${text}\u001b[0m`;
|
|
1007
1012
|
}
|
|
1013
|
+
async function getFreestyleClient() {
|
|
1014
|
+
const baseUrl = process.env.FREESTYLE_API_URL || DEFAULT_FREESTYLE_API_URL;
|
|
1015
|
+
if (process.env.FREESTYLE_API_KEY) {
|
|
1016
|
+
return new Freestyle({ apiKey: process.env.FREESTYLE_API_KEY, baseUrl });
|
|
1017
|
+
}
|
|
1018
|
+
const config = resolveStackConfig();
|
|
1019
|
+
let auth = await readStoredFreestyleAuth(config);
|
|
1020
|
+
if (!auth?.refreshToken && !readRefreshTokenFromEnv()) {
|
|
1021
|
+
await runFreestyleLogin();
|
|
1022
|
+
auth = await readStoredFreestyleAuth(config);
|
|
1023
|
+
}
|
|
1024
|
+
let refreshed = await refreshStackAccessToken(config, readRefreshTokenFromEnv() ?? auth?.refreshToken);
|
|
1025
|
+
if (!refreshed) {
|
|
1026
|
+
await runFreestyleLogin(["--force"]);
|
|
1027
|
+
auth = await readStoredFreestyleAuth(config);
|
|
1028
|
+
refreshed = await refreshStackAccessToken(config, readRefreshTokenFromEnv() ?? auth?.refreshToken);
|
|
1029
|
+
}
|
|
1030
|
+
if (!refreshed) {
|
|
1031
|
+
throw new Error("Freestyle authentication failed. Run `npx freestyle login` or set FREESTYLE_API_KEY.");
|
|
1032
|
+
}
|
|
1033
|
+
if (refreshed.refreshToken && refreshed.refreshToken !== auth?.refreshToken) {
|
|
1034
|
+
auth = await updateStoredFreestyleRefreshToken(config, auth, refreshed.refreshToken);
|
|
1035
|
+
}
|
|
1036
|
+
let teamId = process.env.FREESTYLE_TEAM_ID || auth?.defaultTeamId;
|
|
1037
|
+
if (!teamId) {
|
|
1038
|
+
await runFreestyleLogin();
|
|
1039
|
+
auth = await readStoredFreestyleAuth(config);
|
|
1040
|
+
teamId = process.env.FREESTYLE_TEAM_ID || auth?.defaultTeamId;
|
|
1041
|
+
}
|
|
1042
|
+
if (!teamId) {
|
|
1043
|
+
throw new Error("No Freestyle team selected. Run `npx freestyle login` to configure a default team, or set FREESTYLE_TEAM_ID.");
|
|
1044
|
+
}
|
|
1045
|
+
return new Freestyle({
|
|
1046
|
+
apiKey: "placeholder",
|
|
1047
|
+
baseUrl,
|
|
1048
|
+
fetch: createFreestyleProxyFetch(refreshed.accessToken, teamId),
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
function resolveStackConfig() {
|
|
1052
|
+
return {
|
|
1053
|
+
stackApiUrl: (process.env.FREESTYLE_STACK_API_URL || DEFAULT_STACK_API_URL).replace(/\/+$/, ""),
|
|
1054
|
+
projectId: process.env.FREESTYLE_STACK_PROJECT_ID || process.env.NEXT_PUBLIC_STACK_PROJECT_ID || process.env.VITE_STACK_PROJECT_ID || DEFAULT_STACK_PROJECT_ID,
|
|
1055
|
+
publishableClientKey: process.env.FREESTYLE_STACK_PUBLISHABLE_CLIENT_KEY || process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY || process.env.VITE_STACK_PUBLISHABLE_CLIENT_KEY || DEFAULT_STACK_PUBLISHABLE_CLIENT_KEY,
|
|
1056
|
+
authFilePath: process.env.FREESTYLE_STACK_AUTH_FILE || path.join(homedir(), ".freestyle", "stack-auth.json"),
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
function readRefreshTokenFromEnv() {
|
|
1060
|
+
const refreshToken = process.env[STACK_REFRESH_TOKEN_ENV_KEY]?.trim();
|
|
1061
|
+
return refreshToken ? refreshToken : undefined;
|
|
1062
|
+
}
|
|
1063
|
+
async function readStoredFreestyleAuth(config) {
|
|
1064
|
+
try {
|
|
1065
|
+
const parsed = JSON.parse(await readFile(config.authFilePath, "utf8"));
|
|
1066
|
+
if (typeof parsed.refreshToken !== "string" || parsed.refreshToken.length === 0)
|
|
1067
|
+
return undefined;
|
|
1068
|
+
return {
|
|
1069
|
+
refreshToken: parsed.refreshToken,
|
|
1070
|
+
defaultTeamId: typeof parsed.defaultTeamId === "string" ? parsed.defaultTeamId : undefined,
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
catch (error) {
|
|
1074
|
+
if (error.code === "ENOENT")
|
|
1075
|
+
return undefined;
|
|
1076
|
+
return undefined;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
async function updateStoredFreestyleRefreshToken(config, auth, refreshToken) {
|
|
1080
|
+
const next = {
|
|
1081
|
+
refreshToken,
|
|
1082
|
+
updatedAt: Date.now(),
|
|
1083
|
+
defaultTeamId: auth?.defaultTeamId,
|
|
1084
|
+
};
|
|
1085
|
+
await mkdir(path.dirname(config.authFilePath), { recursive: true });
|
|
1086
|
+
await writeFile(config.authFilePath, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
1087
|
+
return {
|
|
1088
|
+
refreshToken,
|
|
1089
|
+
defaultTeamId: auth?.defaultTeamId,
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
async function refreshStackAccessToken(config, refreshToken) {
|
|
1093
|
+
if (!refreshToken)
|
|
1094
|
+
return undefined;
|
|
1095
|
+
const response = await fetch(`${config.stackApiUrl}/api/v1/auth/sessions/current/refresh`, {
|
|
1096
|
+
method: "POST",
|
|
1097
|
+
headers: {
|
|
1098
|
+
...stackClientHeaders(config),
|
|
1099
|
+
"x-stack-refresh-token": refreshToken,
|
|
1100
|
+
},
|
|
1101
|
+
body: "{}",
|
|
1102
|
+
});
|
|
1103
|
+
if (!response.ok)
|
|
1104
|
+
return undefined;
|
|
1105
|
+
const data = await response.json();
|
|
1106
|
+
if (typeof data.access_token !== "string")
|
|
1107
|
+
return undefined;
|
|
1108
|
+
return {
|
|
1109
|
+
accessToken: data.access_token,
|
|
1110
|
+
refreshToken: typeof data.refresh_token === "string" ? data.refresh_token : undefined,
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
function stackClientHeaders(config) {
|
|
1114
|
+
return {
|
|
1115
|
+
"Content-Type": "application/json",
|
|
1116
|
+
"x-stack-project-id": config.projectId,
|
|
1117
|
+
"x-stack-access-type": "client",
|
|
1118
|
+
"x-stack-publishable-client-key": config.publishableClientKey,
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
function createFreestyleProxyFetch(accessToken, teamId) {
|
|
1122
|
+
const dashboardApiUrl = (process.env.FREESTYLE_DASHBOARD_URL || "https://dash.freestyle.sh").replace(/\/+$/, "");
|
|
1123
|
+
return async (url, init) => {
|
|
1124
|
+
const urlObject = typeof url === "string" ? new URL(url) : url instanceof URL ? url : new URL(url.url);
|
|
1125
|
+
const requestPath = `${urlObject.pathname}${urlObject.search}`;
|
|
1126
|
+
const proxyResponse = await fetch(`${dashboardApiUrl}/api/proxy/request`, {
|
|
1127
|
+
method: "POST",
|
|
1128
|
+
headers: { "Content-Type": "application/json" },
|
|
1129
|
+
body: JSON.stringify({
|
|
1130
|
+
data: {
|
|
1131
|
+
accessToken,
|
|
1132
|
+
teamId,
|
|
1133
|
+
path: requestPath.startsWith("/") ? requestPath.slice(1) : requestPath,
|
|
1134
|
+
method: init?.method || "GET",
|
|
1135
|
+
headers: init?.headers ? Object.fromEntries(new Headers(init.headers).entries()) : {},
|
|
1136
|
+
body: init?.body ? init.body.toString() : undefined,
|
|
1137
|
+
},
|
|
1138
|
+
}),
|
|
1139
|
+
});
|
|
1140
|
+
if (!proxyResponse.ok) {
|
|
1141
|
+
const body = await proxyResponse.text();
|
|
1142
|
+
const normalizedError = normalizeFreestyleProxyError(body, proxyResponse.status);
|
|
1143
|
+
return new Response(normalizedError.body, {
|
|
1144
|
+
status: proxyResponse.status,
|
|
1145
|
+
statusText: proxyResponse.statusText,
|
|
1146
|
+
headers: { "Content-Type": normalizedError.contentType },
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
const data = await proxyResponse.json();
|
|
1150
|
+
return new Response(JSON.stringify(data), {
|
|
1151
|
+
status: 200,
|
|
1152
|
+
headers: { "Content-Type": "application/json" },
|
|
1153
|
+
});
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
function normalizeFreestyleProxyError(errorText, status) {
|
|
1157
|
+
const fallbackCode = status === 400 ? "BAD_REQUEST" : status === 401 ? "UNAUTHORIZED_ERROR" : status === 403 ? "FORBIDDEN" : "INTERNAL_ERROR";
|
|
1158
|
+
try {
|
|
1159
|
+
const parsed = JSON.parse(errorText);
|
|
1160
|
+
if (typeof parsed.code === "string" && typeof parsed.message === "string") {
|
|
1161
|
+
return { body: JSON.stringify(parsed), contentType: "application/json" };
|
|
1162
|
+
}
|
|
1163
|
+
const message = [parsed.error, parsed.message, parsed.reason].find((value) => typeof value === "string" && value.length > 0);
|
|
1164
|
+
if (message) {
|
|
1165
|
+
return {
|
|
1166
|
+
body: JSON.stringify(freestyleErrorBody(fallbackCode, message)),
|
|
1167
|
+
contentType: "application/json",
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
catch {
|
|
1172
|
+
}
|
|
1173
|
+
return {
|
|
1174
|
+
body: JSON.stringify(freestyleErrorBody(fallbackCode, errorText || "Request failed")),
|
|
1175
|
+
contentType: "application/json",
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
function freestyleErrorBody(code, message) {
|
|
1179
|
+
if (code === "UNAUTHORIZED_ERROR") {
|
|
1180
|
+
return { code, message, route: "/api/proxy/request", reason: message };
|
|
1181
|
+
}
|
|
1182
|
+
return { code, message };
|
|
1183
|
+
}
|
|
1184
|
+
async function runFreestyleLogin(extraArgs = []) {
|
|
1185
|
+
console.log("No Freestyle API key found. Opening Freestyle login...");
|
|
1186
|
+
const cliPath = path.join(path.dirname(fileURLToPath(import.meta.resolve("freestyle"))), "cli.mjs");
|
|
1187
|
+
await new Promise((resolve, reject) => {
|
|
1188
|
+
const child = spawn(process.execPath, [cliPath, "login", ...extraArgs], {
|
|
1189
|
+
cwd: process.cwd(),
|
|
1190
|
+
stdio: "inherit",
|
|
1191
|
+
env: process.env,
|
|
1192
|
+
});
|
|
1193
|
+
child.on("error", reject);
|
|
1194
|
+
child.on("exit", (code, signal) => {
|
|
1195
|
+
if (code === 0) {
|
|
1196
|
+
resolve();
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
reject(new Error(signal ? `freestyle login was interrupted by ${signal}` : `freestyle login failed with exit code ${code ?? "unknown"}`));
|
|
1200
|
+
});
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1008
1203
|
async function getOrCreateVm(options, snapshotId) {
|
|
1204
|
+
const freestyle = await getFreestyleClient();
|
|
1009
1205
|
if (options.vmId) {
|
|
1010
1206
|
const { vm } = await freestyle.vms.get({ vmId: options.vmId });
|
|
1011
1207
|
await vm.start(options.idleTimeoutSeconds ? { idleTimeoutSeconds: options.idleTimeoutSeconds } : undefined);
|
|
@@ -1196,20 +1392,18 @@ async function createTar(args) {
|
|
|
1196
1392
|
});
|
|
1197
1393
|
}
|
|
1198
1394
|
async function uploadArchiveInChunks(vm, vmId, archivePath, remoteArchivePath, label) {
|
|
1199
|
-
const
|
|
1200
|
-
const
|
|
1201
|
-
const chunkCount = Math.max(1, Math.ceil(encoded.length / ARCHIVE_CHUNK_CHARS));
|
|
1395
|
+
const archiveSize = (await stat(archivePath)).size;
|
|
1396
|
+
const chunkCount = Math.max(1, Math.ceil(archiveSize / ARCHIVE_CHUNK_BYTES));
|
|
1202
1397
|
const chunkDir = `/tmp/freestyle-sync-${label}-${Date.now()}.chunks`;
|
|
1203
|
-
console.log(`VM ${vmId}: streaming ${formatBytes(
|
|
1398
|
+
console.log(`VM ${vmId}: streaming ${formatBytes(archiveSize)} ${label} archive in ${chunkCount} chunk${chunkCount === 1 ? "" : "s"}...`);
|
|
1204
1399
|
await checkedExec(vm, `rm -rf ${shellQuote(chunkDir)} && mkdir -p ${shellQuote(chunkDir)}`);
|
|
1205
1400
|
const width = String(chunkCount - 1).length;
|
|
1206
1401
|
const canRenderInlineProgress = process.stdout.isTTY;
|
|
1207
1402
|
const logEvery = Math.max(1, Math.ceil(chunkCount / 4));
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
const
|
|
1211
|
-
|
|
1212
|
-
await vm.fs.writeTextFile(`${chunkDir}/${chunkName}`, chunk);
|
|
1403
|
+
let index = 0;
|
|
1404
|
+
for await (const chunk of createReadStream(archivePath, { highWaterMark: ARCHIVE_CHUNK_BYTES })) {
|
|
1405
|
+
const chunkName = `${String(index).padStart(width, "0")}.chunk`;
|
|
1406
|
+
await vm.fs.writeFile(`${chunkDir}/${chunkName}`, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
1213
1407
|
const uploadedChunks = index + 1;
|
|
1214
1408
|
if (chunkCount > 1) {
|
|
1215
1409
|
const progressMessage = `VM ${vmId}: uploaded ${uploadedChunks}/${chunkCount} ${label} archive chunks`;
|
|
@@ -1223,8 +1417,11 @@ async function uploadArchiveInChunks(vm, vmId, archivePath, remoteArchivePath, l
|
|
|
1223
1417
|
console.log(progressMessage);
|
|
1224
1418
|
}
|
|
1225
1419
|
}
|
|
1420
|
+
index += 1;
|
|
1226
1421
|
}
|
|
1227
|
-
await checkedExec(vm,
|
|
1422
|
+
await checkedExec(vm, archiveSize === 0
|
|
1423
|
+
? `: > ${shellQuote(remoteArchivePath)} && rm -rf ${shellQuote(chunkDir)}`
|
|
1424
|
+
: `cat ${shellQuote(chunkDir)}/*.chunk > ${shellQuote(remoteArchivePath)} && rm -rf ${shellQuote(chunkDir)}`);
|
|
1228
1425
|
}
|
|
1229
1426
|
async function mkdirRemote(vm, directories) {
|
|
1230
1427
|
for (const chunk of chunkArray(directories, 50)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "freestyle-sync",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/src/main.js",
|
|
6
6
|
"exports": {
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
"build": "rm -rf dist plugins/*/dist && tsc -p tsconfig.json && node scripts/prepare-plugin-packages.mjs",
|
|
23
23
|
"check": "tsc --noEmit -p tsconfig.json",
|
|
24
24
|
"prepack": "npm run build",
|
|
25
|
-
"publish": "npm run check && npm run build && npm publish
|
|
25
|
+
"publish": "npm run check && npm run build && npm run publish:workspaces && npm publish",
|
|
26
|
+
"publish:workspaces": "node scripts/publish-workspaces.mjs",
|
|
26
27
|
"start": "node src/main.ts"
|
|
27
28
|
},
|
|
28
29
|
"dependencies": {
|