Promote AgentCompany-New to the repository root and retire the old app
The legacy application - its src, tests, landing site, promo assets, docs, release tooling, CI workflows, and root configs - leaves the tree; it stays reachable in history. Everything under AgentCompany-New moves up unchanged: same package name, so the production userData/database path is untouched, and e2e/build scripts already resolved paths relative to the project. The developer cred.txt stays gitignored at its new location. Full verify:ci passes from the new root.
This commit is contained in:
1 parent
2350f744e3
commit
07d0b4587d
1904 files changed
+1133
-282075
No files matched your search
@@ -1,48 +0,0 @@
|
||||
# scripts/
|
||||
|
||||
Build, release, and asset-generation helpers invoked from `package.json`
|
||||
or from the release pipeline. Every top-level script has a single owner
|
||||
and is wired up either as an npm script or as a child of `release-boss.mjs`.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | Purpose |
|
||||
| ----------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `postinstall.mjs` | Ran by pnpm after install; stamps Electron name patches and env dependencies. |
|
||||
| `build-desktop.mjs` | `pnpm build` entry. Wraps `electron-builder` with AgentCompany defaults. |
|
||||
| `build-all-platforms.mjs` | `pnpm build:current` entry. Cross-platform matrix driver for local testing. |
|
||||
| `build/` | Auxiliary assets consumed by electron-builder (entitlements, installer hooks). |
|
||||
| `electron-builder-mac-sign.mjs` | macOS codesign + notarize helper invoked from electron-builder `afterSign`. |
|
||||
| `patch-electron-name.sh` | Rewrites the Electron binary name into `AgentCompany` so macOS treats it as a distinct app. |
|
||||
| `release/` | Shared helpers imported by `release-*` scripts. |
|
||||
| `release-boss.mjs` | `pnpm release:boss` entry. Builds the Boss edition bundle and uploads it. |
|
||||
| `release-local.mjs` | `pnpm release:local` entry. Packages a local-only build for internal testers. |
|
||||
| `release-local/` | Fixtures / templates consumed by `release-local.mjs`. |
|
||||
| `release-public-cloudflare.mjs` | `pnpm release:cloudflare:public` entry. Publishes the public release to Cloudflare R2. |
|
||||
| `generate-landing-release-links.mjs` | Regenerates the landing-page download links from the Cloudflare manifest. |
|
||||
| `icons/` | Icon generation utilities. See `icons/generate-icons.py`. |
|
||||
|
||||
## Icons
|
||||
|
||||
`pnpm assets:generate` runs `scripts/icons/generate-icons.py`, a
|
||||
macOS-only helper that rebuilds the Electron app icon, the landing-page
|
||||
favicons, and the tray icons from the SVG masters in `assets/`. Only
|
||||
re-run it when the SVG sources change; the committed PNG / ICNS outputs
|
||||
are consumed directly at build time.
|
||||
|
||||
## Release
|
||||
|
||||
`pnpm release:cloudflare` invokes `release-public-cloudflare.mjs`
|
||||
followed by `release-boss.mjs`. The Boss edition bundles additional
|
||||
credentials and is uploaded to a private bucket; never commit its
|
||||
outputs (see the `boss-build/` entry in `.gitignore`).
|
||||
|
||||
## Conventions
|
||||
|
||||
- Scripts here are invoked from CI and from local dev. They must run
|
||||
without network access except where explicitly required (release
|
||||
uploads). Any tool requirement (e.g. `cairo`, `iconutil`) belongs in
|
||||
the script's header comment with install instructions.
|
||||
- Treat `scripts/` as source, not generated output. Do not commit
|
||||
build artefacts into subdirectories beyond the fixtures under
|
||||
`build/` and `release-local/`.
|
||||
@@ -1,118 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* build-all-platforms.mjs
|
||||
* Builds Electron app artifacts for the current platform and reports
|
||||
* which platforms still need native builds on their respective hosts.
|
||||
*
|
||||
* Usage: node scripts/build-all-platforms.mjs [--skip-vite]
|
||||
*
|
||||
* Per D-07: All three platforms (macOS, Windows, Linux) must produce
|
||||
* valid build artifacts.
|
||||
*
|
||||
* electron-builder does NOT support true cross-compilation for all targets:
|
||||
* - macOS DMG/zip requires macOS
|
||||
* - Windows NSIS/portable requires Windows (or Wine)
|
||||
* - Linux AppImage requires Linux (or Docker)
|
||||
*
|
||||
* This script builds for the CURRENT platform, verifies artifacts, and
|
||||
* documents remaining platforms that must be built on their native hosts.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, "..");
|
||||
const releaseDir = join(rootDir, "build", "release");
|
||||
const skipVite = process.argv.includes("--skip-vite");
|
||||
|
||||
const PLATFORM_FLAGS = {
|
||||
darwin: "--mac",
|
||||
win32: "--win",
|
||||
linux: "--linux",
|
||||
};
|
||||
|
||||
const PLATFORM_NAMES = {
|
||||
darwin: "macOS",
|
||||
win32: "Windows",
|
||||
linux: "Linux",
|
||||
};
|
||||
|
||||
const EXPECTED_EXTENSIONS = {
|
||||
darwin: [".dmg", ".zip"],
|
||||
win32: [".exe"],
|
||||
linux: [".AppImage"],
|
||||
};
|
||||
|
||||
const currentPlatform = process.platform;
|
||||
const platformFlag = PLATFORM_FLAGS[currentPlatform];
|
||||
|
||||
if (!platformFlag) {
|
||||
console.error(`[build-all] Unsupported platform: ${currentPlatform}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`[build-all] Building for ${PLATFORM_NAMES[currentPlatform]} (${currentPlatform})...`);
|
||||
|
||||
// Step 1: Run electron-vite build (unless --skip-vite)
|
||||
if (!skipVite) {
|
||||
console.log("[build-all] Running electron-vite build...");
|
||||
execFileSync("pnpm", ["electron-vite", "build"], {
|
||||
stdio: "inherit",
|
||||
cwd: rootDir,
|
||||
shell: currentPlatform === "win32",
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: Run build-desktop.mjs with the current platform flag
|
||||
console.log(`[build-all] Running electron-builder ${platformFlag}...`);
|
||||
execFileSync("node", [join(__dirname, "build-desktop.mjs"), platformFlag], {
|
||||
stdio: "inherit",
|
||||
cwd: rootDir,
|
||||
env: {
|
||||
...process.env,
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: process.env.CSC_IDENTITY_AUTO_DISCOVERY ?? "false",
|
||||
},
|
||||
shell: currentPlatform === "win32",
|
||||
});
|
||||
|
||||
// Step 3: Verify build artifacts exist
|
||||
if (!existsSync(releaseDir)) {
|
||||
console.error(`[build-all] ERROR: Release directory not found: ${releaseDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = await readdir(releaseDir);
|
||||
const expectedExts = EXPECTED_EXTENSIONS[currentPlatform];
|
||||
const foundArtifacts = files.filter((f) =>
|
||||
expectedExts.some((ext) => f.endsWith(ext)),
|
||||
);
|
||||
|
||||
if (foundArtifacts.length === 0) {
|
||||
console.error(
|
||||
`[build-all] ERROR: No ${PLATFORM_NAMES[currentPlatform]} artifacts found in ${releaseDir}. ` +
|
||||
`Expected files with extensions: ${expectedExts.join(", ")}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n[build-all] SUCCESS: ${PLATFORM_NAMES[currentPlatform]} build artifacts:`);
|
||||
for (const artifact of foundArtifacts) {
|
||||
console.log(` - ${artifact}`);
|
||||
}
|
||||
|
||||
// Step 4: Report remaining platforms
|
||||
const otherPlatforms = Object.entries(PLATFORM_NAMES)
|
||||
.filter(([key]) => key !== currentPlatform)
|
||||
.map(([, name]) => name);
|
||||
|
||||
if (otherPlatforms.length > 0) {
|
||||
console.log(`\n[build-all] NOTE: Build on these platforms for full D-07 coverage:`);
|
||||
for (const name of otherPlatforms) {
|
||||
console.log(` - ${name}: run 'pnpm build:current' on a ${name} host`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n[build-all] Done.");
|
||||
@@ -1,93 +0,0 @@
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { prepareBuildEnv, resolvePublishMode } from "./build/prepare-env.mjs";
|
||||
import { resolveBuildVersion, writeRuntimeBuilderConfig } from "./build/emit-builder-config.mjs";
|
||||
import { buildElectronBuilderArgs, runElectronBuilder } from "./build/run-electron-builder.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, "..");
|
||||
const packageJsonPath = join(rootDir, "package.json");
|
||||
const buildDir = join(rootDir, "build");
|
||||
const runtimeConfigPath = join(buildDir, "electron-builder.runtime.json");
|
||||
const localElectronDistPath = join(rootDir, "node_modules", "electron", "dist");
|
||||
const customMacSignPath = join(__dirname, "electron-builder-mac-sign.mjs");
|
||||
|
||||
const extraArgs = process.argv.slice(2);
|
||||
const prepared = prepareBuildEnv(process.env, extraArgs);
|
||||
const publishMode = resolvePublishMode(prepared.env);
|
||||
|
||||
await rm(buildDir, { recursive: true, force: true });
|
||||
await mkdir(buildDir, { recursive: true });
|
||||
|
||||
const buildVersion = await resolveBuildVersion(packageJsonPath, prepared.env.AGENTCOMPANY_BUILD_VERSION);
|
||||
await writeRuntimeBuilderConfig({
|
||||
rootDir,
|
||||
buildDir,
|
||||
runtimeConfigPath,
|
||||
buildVersion,
|
||||
extraArgs,
|
||||
prepared,
|
||||
customMacSignPath,
|
||||
localElectronDistPath,
|
||||
});
|
||||
prepared.env.AGENTCOMPANY_BUILD_VERSION = buildVersion;
|
||||
prepared.env.npm_package_version = buildVersion;
|
||||
console.log(`[build-desktop] Cleaned ${buildDir} and staged build version ${buildVersion}.`);
|
||||
|
||||
const builderArgs = buildElectronBuilderArgs({
|
||||
runtimeConfigPath,
|
||||
extraArgs,
|
||||
publishMode,
|
||||
prepared,
|
||||
});
|
||||
|
||||
// electron-builder with `extraMetadata` mutates the source package.json in
|
||||
// place during packaging and restores it when it finishes. If the process is
|
||||
// killed mid-flight (CI timeout, Ctrl-C, OOM, parent task cancellation) the
|
||||
// restore step never fires and the source tree is left with a stripped
|
||||
// scripts block — every subsequent `pnpm` command then fails until someone
|
||||
// notices the drift and runs `git checkout`. The snapshot + signal-safe
|
||||
// restore below is idempotent: on a clean exit electron-builder's own
|
||||
// restore already wrote the original bytes back and this is a no-op; on
|
||||
// any abnormal termination we guarantee the working tree is clean.
|
||||
const packageJsonSnapshot = await readFile(packageJsonPath);
|
||||
let packageJsonRestored = false;
|
||||
async function restorePackageJsonIfNeeded() {
|
||||
if (packageJsonRestored) return;
|
||||
packageJsonRestored = true;
|
||||
try {
|
||||
const current = await readFile(packageJsonPath);
|
||||
if (current.equals(packageJsonSnapshot)) return;
|
||||
await writeFile(packageJsonPath, packageJsonSnapshot);
|
||||
console.log("[build-desktop] Restored package.json from snapshot after builder left it mutated.");
|
||||
} catch (error) {
|
||||
console.error(`[build-desktop] Failed to restore package.json snapshot: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
|
||||
const signalHandlers = new Map();
|
||||
for (const signal of signals) {
|
||||
const handler = () => {
|
||||
void restorePackageJsonIfNeeded().finally(() => {
|
||||
process.kill(process.pid, signal);
|
||||
});
|
||||
};
|
||||
signalHandlers.set(signal, handler);
|
||||
process.once(signal, handler);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runElectronBuilder(builderArgs, prepared.env);
|
||||
await restorePackageJsonIfNeeded();
|
||||
for (const [signal, handler] of signalHandlers) process.off(signal, handler);
|
||||
if (result.signal) {
|
||||
process.kill(process.pid, result.signal);
|
||||
} else {
|
||||
process.exit(result.code);
|
||||
}
|
||||
} catch (error) {
|
||||
await restorePackageJsonIfNeeded();
|
||||
throw error;
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
function validateBuildVersion(currentVersion) {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(currentVersion);
|
||||
if (!match) {
|
||||
throw new Error(`[build-desktop] Unsupported package version format: ${currentVersion}`);
|
||||
}
|
||||
return currentVersion;
|
||||
}
|
||||
|
||||
export async function resolveBuildVersion(packageJsonPath, override) {
|
||||
if (override?.trim()) return validateBuildVersion(override.trim());
|
||||
const raw = await readFile(packageJsonPath, "utf8");
|
||||
const pkg = JSON.parse(raw);
|
||||
return validateBuildVersion(pkg.version);
|
||||
}
|
||||
|
||||
export function hasExplicitArchArg(args) {
|
||||
return args.some((arg) =>
|
||||
arg === "--arm64" ||
|
||||
arg === "--x64" ||
|
||||
arg === "--ia32" ||
|
||||
arg === "--universal" ||
|
||||
arg.startsWith("--arm64=") ||
|
||||
arg.startsWith("--x64=") ||
|
||||
arg.startsWith("--ia32=") ||
|
||||
arg.startsWith("--universal="),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveCurrentMacArch(env, extraArgs) {
|
||||
if (process.platform !== "darwin" || env.AGENTCOMPANY_MAC_ALL_ARCHES === "true" || hasExplicitArchArg(extraArgs)) {
|
||||
return null;
|
||||
}
|
||||
return process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "x64" : null;
|
||||
}
|
||||
|
||||
export async function writeRuntimeBuilderConfig({
|
||||
rootDir,
|
||||
buildDir,
|
||||
runtimeConfigPath,
|
||||
buildVersion,
|
||||
extraArgs,
|
||||
prepared,
|
||||
customMacSignPath,
|
||||
localElectronDistPath,
|
||||
}) {
|
||||
const { env, resolvedUpdateUrl, hasR2PublishConfig, hasMacSigningMaterial, strictRelease, isDirBuild, explicitElectronDistPath, r2 } = prepared;
|
||||
const forcedMacArch = resolveCurrentMacArch(env, extraArgs);
|
||||
const isUnsignedLocalMacBuild = (
|
||||
process.platform === "darwin" &&
|
||||
!hasMacSigningMaterial &&
|
||||
env.AGENTCOMPANY_ENABLE_LOCAL_DMG !== "true"
|
||||
);
|
||||
|
||||
const releaseDir = join(buildDir, "release");
|
||||
const runtimeConfig = {
|
||||
extends: join(rootDir, "electron-builder.yml"),
|
||||
directories: { output: releaseDir },
|
||||
extraMetadata: { version: buildVersion },
|
||||
};
|
||||
|
||||
if (explicitElectronDistPath) {
|
||||
runtimeConfig.electronDist = explicitElectronDistPath;
|
||||
} else if (isDirBuild) {
|
||||
runtimeConfig.electronDist = localElectronDistPath;
|
||||
}
|
||||
|
||||
if (hasR2PublishConfig) {
|
||||
runtimeConfig.publish = [{
|
||||
provider: "s3",
|
||||
bucket: r2.bucket,
|
||||
endpoint: r2.endpoint,
|
||||
region: r2.region,
|
||||
path: r2.path || undefined,
|
||||
acl: null,
|
||||
publishAutoUpdate: true,
|
||||
}];
|
||||
} else if (resolvedUpdateUrl) {
|
||||
runtimeConfig.publish = [{ provider: "generic", url: resolvedUpdateUrl }];
|
||||
}
|
||||
|
||||
if (isUnsignedLocalMacBuild) {
|
||||
if (strictRelease) {
|
||||
throw new Error("[build-desktop] Strict release mode requires macOS signing/notarization material.");
|
||||
}
|
||||
runtimeConfig.mac = { hardenedRuntime: false, identity: null };
|
||||
if (!isDirBuild) {
|
||||
runtimeConfig.mac.target = forcedMacArch
|
||||
? [{ target: "zip", arch: [forcedMacArch] }]
|
||||
: ["zip"];
|
||||
}
|
||||
} else if (process.platform === "darwin" && !hasMacSigningMaterial) {
|
||||
runtimeConfig.mac = { hardenedRuntime: false, identity: null };
|
||||
} else if (process.platform === "darwin") {
|
||||
runtimeConfig.mac = { hardenedRuntime: true };
|
||||
if (hasMacSigningMaterial) {
|
||||
runtimeConfig.mac.sign = customMacSignPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === "darwin" && !isDirBuild && forcedMacArch && !isUnsignedLocalMacBuild) {
|
||||
runtimeConfig.mac ??= {};
|
||||
runtimeConfig.mac.target = [
|
||||
{ target: "dmg", arch: [forcedMacArch] },
|
||||
{ target: "zip", arch: [forcedMacArch] },
|
||||
];
|
||||
}
|
||||
|
||||
if (strictRelease && !resolvedUpdateUrl) {
|
||||
throw new Error("[build-desktop] Strict release mode requires AGENTCOMPANY_UPDATE_URL or an R2 public URL to be configured.");
|
||||
}
|
||||
|
||||
await writeFile(runtimeConfigPath, `${JSON.stringify(runtimeConfig, null, 2)}\n`, "utf8");
|
||||
return { forcedMacArch, isUnsignedLocalMacBuild };
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
function normalizeBaseUrl(url) {
|
||||
return url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function normalizePublishPath(targetPath) {
|
||||
return targetPath.replace(/^\/+|\/+$/g, "");
|
||||
}
|
||||
|
||||
export function prepareBuildEnv(sourceEnv, extraArgs) {
|
||||
const env = { ...sourceEnv };
|
||||
const noDeprecationFlag = "--no-deprecation";
|
||||
const requestedUpdateUrl = env.AGENTCOMPANY_UPDATE_URL?.trim() ?? "";
|
||||
const strictRelease = env.AGENTCOMPANY_STRICT_RELEASE === "true";
|
||||
const hasMacSigningMaterial = Boolean(
|
||||
env.CSC_LINK?.trim() ||
|
||||
env.CSC_NAME?.trim() ||
|
||||
env.APPLE_API_KEY?.trim(),
|
||||
);
|
||||
const isDirBuild = extraArgs.includes("--dir");
|
||||
const r2AccountId = env.AGENTCOMPANY_R2_ACCOUNT_ID?.trim() ?? env.R2_ACCOUNT_ID?.trim() ?? "";
|
||||
const r2Bucket = env.AGENTCOMPANY_R2_BUCKET?.trim() ?? env.R2_BUCKET?.trim() ?? "";
|
||||
const r2PublicUrl = env.AGENTCOMPANY_R2_PUBLIC_URL?.trim() ?? env.R2_PUBLIC_BASE_URL?.trim() ?? "";
|
||||
const r2Path = env.AGENTCOMPANY_R2_PATH?.trim() ?? env.AGENTCOMPANY_RELEASE_PREFIX?.trim() ?? "desktop";
|
||||
const r2Region = env.AGENTCOMPANY_R2_REGION?.trim() || "auto";
|
||||
const r2Endpoint = env.AGENTCOMPANY_R2_ENDPOINT?.trim() || (r2AccountId ? `https://${r2AccountId}.r2.cloudflarestorage.com` : "");
|
||||
|
||||
const resolvedUpdateUrl = (() => {
|
||||
if (requestedUpdateUrl && !requestedUpdateUrl.includes("example.invalid")) {
|
||||
return normalizeBaseUrl(requestedUpdateUrl);
|
||||
}
|
||||
if (!r2PublicUrl) return "";
|
||||
const baseUrl = normalizeBaseUrl(r2PublicUrl);
|
||||
const publishPath = normalizePublishPath(r2Path);
|
||||
return publishPath ? `${baseUrl}/${publishPath}` : baseUrl;
|
||||
})();
|
||||
|
||||
const hasR2PublishConfig = Boolean(
|
||||
r2Bucket &&
|
||||
r2Endpoint &&
|
||||
resolvedUpdateUrl &&
|
||||
(env.AGENTCOMPANY_R2_ACCESS_KEY_ID?.trim() || env.R2_ACCESS_KEY_ID?.trim() || env.AWS_ACCESS_KEY_ID?.trim()) &&
|
||||
(env.AGENTCOMPANY_R2_SECRET_ACCESS_KEY?.trim() || env.R2_SECRET_ACCESS_KEY?.trim() || env.AWS_SECRET_ACCESS_KEY?.trim()),
|
||||
);
|
||||
|
||||
if (!env.AWS_ACCESS_KEY_ID && (env.AGENTCOMPANY_R2_ACCESS_KEY_ID?.trim() || env.R2_ACCESS_KEY_ID?.trim())) {
|
||||
env.AWS_ACCESS_KEY_ID = env.AGENTCOMPANY_R2_ACCESS_KEY_ID?.trim() || env.R2_ACCESS_KEY_ID?.trim();
|
||||
}
|
||||
if (!env.AWS_SECRET_ACCESS_KEY && (env.AGENTCOMPANY_R2_SECRET_ACCESS_KEY?.trim() || env.R2_SECRET_ACCESS_KEY?.trim())) {
|
||||
env.AWS_SECRET_ACCESS_KEY = env.AGENTCOMPANY_R2_SECRET_ACCESS_KEY?.trim() || env.R2_SECRET_ACCESS_KEY?.trim();
|
||||
}
|
||||
if (!env.AWS_REGION) env.AWS_REGION = r2Region;
|
||||
if (!env.AWS_DEFAULT_REGION) env.AWS_DEFAULT_REGION = env.AWS_REGION;
|
||||
if (!env.AWS_EC2_METADATA_DISABLED) env.AWS_EC2_METADATA_DISABLED = "true";
|
||||
if (!env.AGENTCOMPANY_UPDATE_URL && resolvedUpdateUrl) {
|
||||
env.AGENTCOMPANY_UPDATE_URL = resolvedUpdateUrl;
|
||||
}
|
||||
|
||||
if (env.CSC_IDENTITY_AUTO_DISCOVERY === undefined) {
|
||||
env.CSC_IDENTITY_AUTO_DISCOVERY = hasMacSigningMaterial ? "true" : "false";
|
||||
}
|
||||
|
||||
const existingNodeOptions = env.NODE_OPTIONS?.trim();
|
||||
if (!existingNodeOptions) {
|
||||
env.NODE_OPTIONS = noDeprecationFlag;
|
||||
} else if (!existingNodeOptions.includes(noDeprecationFlag)) {
|
||||
env.NODE_OPTIONS = `${existingNodeOptions} ${noDeprecationFlag}`;
|
||||
}
|
||||
|
||||
return {
|
||||
env,
|
||||
resolvedUpdateUrl,
|
||||
hasR2PublishConfig,
|
||||
hasMacSigningMaterial,
|
||||
strictRelease,
|
||||
isDirBuild,
|
||||
explicitElectronDistPath: env.AGENTCOMPANY_ELECTRON_DIST?.trim() || "",
|
||||
r2: { bucket: r2Bucket, endpoint: r2Endpoint, region: r2Region, path: normalizePublishPath(r2Path) },
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePublishMode(env) {
|
||||
const allowedPublishModes = new Set(["always", "never", "onTag", "onTagOrDraft"]);
|
||||
const requested = env.AGENTCOMPANY_ELECTRON_PUBLISH?.trim();
|
||||
if (!requested) return "never";
|
||||
if (!allowedPublishModes.has(requested)) {
|
||||
console.warn(
|
||||
`[build-desktop] Ignoring invalid AGENTCOMPANY_ELECTRON_PUBLISH="${requested}". ` +
|
||||
"Falling back to --publish never.",
|
||||
);
|
||||
return "never";
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { hasExplicitArchArg } from "./emit-builder-config.mjs";
|
||||
|
||||
const filteredUnsignedMacWarningSnippets = [
|
||||
"skipped macOS code signing reason=identity explicitly is set to null",
|
||||
"arm64 requires signing, but identity is set to null and signing is being skipped",
|
||||
];
|
||||
|
||||
function resolveCurrentPlatformArg() {
|
||||
switch (process.platform) {
|
||||
case "darwin": return "--mac";
|
||||
case "win32": return "--win";
|
||||
default: return "--linux";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCurrentArchArg() {
|
||||
switch (process.arch) {
|
||||
case "arm64": return "--arm64";
|
||||
case "x64": return "--x64";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSuppressBuildLine(line) {
|
||||
return filteredUnsignedMacWarningSnippets.some((snippet) => line.includes(snippet));
|
||||
}
|
||||
|
||||
function forwardChildOutput(stream, writer) {
|
||||
if (!stream) return;
|
||||
let pending = "";
|
||||
stream.on("data", (chunk) => {
|
||||
pending += chunk.toString();
|
||||
const lines = pending.split(/\r?\n/);
|
||||
pending = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (!shouldSuppressBuildLine(line)) writer.write(`${line}\n`);
|
||||
}
|
||||
});
|
||||
stream.on("end", () => {
|
||||
if (!pending) return;
|
||||
if (!shouldSuppressBuildLine(pending)) writer.write(`${pending}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildElectronBuilderArgs({ runtimeConfigPath, extraArgs, publishMode, prepared }) {
|
||||
const explicitPublishArg = extraArgs.some((arg) => arg === "--publish" || arg.startsWith("--publish="));
|
||||
const explicitPlatformArg = extraArgs.some((arg) =>
|
||||
arg === "--mac" || arg === "--win" || arg === "--linux" ||
|
||||
arg === "-m" || arg === "-w" || arg === "-l" ||
|
||||
arg.startsWith("--mac=") || arg.startsWith("--win=") || arg.startsWith("--linux="),
|
||||
);
|
||||
const explicitArchArg = hasExplicitArchArg(extraArgs);
|
||||
const { env, hasR2PublishConfig, resolvedUpdateUrl, hasMacSigningMaterial, isDirBuild } = prepared;
|
||||
const shouldForceLocalMacZip = (
|
||||
process.platform === "darwin" &&
|
||||
!hasMacSigningMaterial &&
|
||||
env.AGENTCOMPANY_ENABLE_LOCAL_DMG !== "true" &&
|
||||
!explicitPlatformArg &&
|
||||
!isDirBuild
|
||||
);
|
||||
|
||||
if (publishMode !== "never" && !hasR2PublishConfig && !resolvedUpdateUrl) {
|
||||
throw new Error(
|
||||
"[build-desktop] Publishing requires either AGENTCOMPANY_UPDATE_URL (generic feed only) " +
|
||||
"or full Cloudflare R2 publish configuration.",
|
||||
);
|
||||
}
|
||||
|
||||
const args = ["exec", "electron-builder", "--config", runtimeConfigPath];
|
||||
if (!explicitPublishArg) args.push("--publish", publishMode);
|
||||
if (!explicitPlatformArg) args.push(resolveCurrentPlatformArg());
|
||||
if (!explicitArchArg) {
|
||||
const currentArchArg = resolveCurrentArchArg();
|
||||
if (currentArchArg) args.push(currentArchArg);
|
||||
}
|
||||
if (shouldForceLocalMacZip) args.push("zip");
|
||||
args.push(...extraArgs);
|
||||
return args;
|
||||
}
|
||||
|
||||
export function runElectronBuilder(builderArgs, env) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("pnpm", builderArgs, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
forwardChildOutput(child.stdout, process.stdout);
|
||||
forwardChildOutput(child.stderr, process.stderr);
|
||||
child.on("error", reject);
|
||||
child.on("close", (code, signal) => resolve({ code: code ?? 1, signal }));
|
||||
});
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
# scripts/codemod/
|
||||
|
||||
One-shot and reusable codemods for mechanical refactors. Kept in the repo
|
||||
so the next person doing a similar migration has a working starting
|
||||
point instead of re-deriving it from scratch.
|
||||
|
||||
## remove-appdatabase-passthrough.mjs
|
||||
|
||||
Rewrites `db.<method>(...)` → `db.<module>.<method>(...)` for every
|
||||
pass-through method that used to live on `AppDatabase` / `AppDatabaseBase`
|
||||
and now lives on a sub-module field.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Full rewrite across src/**, tests/**:
|
||||
node scripts/codemod/remove-appdatabase-passthrough.mjs
|
||||
|
||||
# Dry run (print what would change, don't write):
|
||||
node scripts/codemod/remove-appdatabase-passthrough.mjs --dry-run
|
||||
|
||||
# Restrict to one or more modules:
|
||||
node scripts/codemod/remove-appdatabase-passthrough.mjs --modules=tasks,agents
|
||||
```
|
||||
|
||||
The script walks `git ls-files 'src/**/*.ts' 'src/**/*.tsx' 'tests/**/*.ts'`
|
||||
(so it respects `.gitignore`), excludes the three files that define the
|
||||
pass-throughs themselves (`src/main/database.ts`,
|
||||
`src/main/database-base.ts`, `src/main/database/**`), and applies the
|
||||
regex `(^|[^\w.])((?:[\w$]+\.)*)db\.(<method>)\(` → `$1$2db.<module>.<method>(`.
|
||||
|
||||
### C1 migration evidence (coverage proof)
|
||||
|
||||
The `METHOD_TO_MODULE` map in the script was derived mechanically from
|
||||
two source-of-truth files as they existed at the start of C1:
|
||||
|
||||
1. `src/main/database.ts` (pre-C1) — defined pass-through methods on
|
||||
`AppDatabase` that delegated to domain modules.
|
||||
2. `src/main/database-base.ts` (pre-C1) — defined pass-through methods
|
||||
on the base class that delegated to sub-module fields.
|
||||
|
||||
Every pass-through method name from those two files was copied into the
|
||||
map with its owning sub-module field name. Two composite methods
|
||||
(`createHireRequest`, `rollbackConfigRevision`) are intentionally left
|
||||
unmapped because they span multiple modules and live on `AppDatabase`
|
||||
itself; rewriting them would break caller semantics.
|
||||
|
||||
The completeness of the map is proven by two independent signals, not
|
||||
by inspection alone:
|
||||
|
||||
- **Typecheck after C1 part 3/3** (commit `ee1734e`): `pnpm typecheck`
|
||||
is clean. If the map had missed any pass-through, call sites would
|
||||
still read `db.<method>(...)` against a deleted method, and the
|
||||
TypeScript compiler would surface it as `Property '<method>' does
|
||||
not exist on type 'AppDatabase'`. Typecheck passing = no orphaned
|
||||
call sites.
|
||||
- **Test suite green after C1 part 3/3**: 524 tests pass. If the
|
||||
codemod had silently mis-mapped a method (e.g. routed `saveGoal` to
|
||||
`documentsMod` instead of `entityCrud`), the runtime would throw
|
||||
`TypeError: db.documentsMod.saveGoal is not a function` on the
|
||||
exercising test. All 524 tests green = map entries are semantically
|
||||
correct, not just syntactically in scope.
|
||||
|
||||
Taken together, typecheck-clean + 524 tests green = the map is
|
||||
complete and correct across every live call site at the time of C1.
|
||||
|
||||
### When to use this script in the future
|
||||
|
||||
If a future refactor re-introduces a pass-through on `AppDatabase` for
|
||||
temporary back-compat, and you want to remove it again, you have two
|
||||
options:
|
||||
|
||||
1. Add the method to `METHOD_TO_MODULE` in this script and run it.
|
||||
2. If the new pass-through follows a different shape (e.g. a method
|
||||
that moved to a different module entirely), copy this script as a
|
||||
template and adjust the map. The regex structure is reusable.
|
||||
|
||||
The ESLint rule in `eslint.config.mjs` blocks new pass-throughs from
|
||||
landing in the first place. See `CONTRIBUTING.md` at the repo root for
|
||||
the rule and the guidance on when to invoke the codemod.
|
||||
|
||||
### C1 commit recipe and revert ordering
|
||||
|
||||
C1 landed as three commits. The final one (`ee1734e`) did two things in
|
||||
the same commit — removed the remaining pass-throughs AND simplified the
|
||||
vault binding layer. In retrospect the vault simplification should have
|
||||
been a separate fourth commit so each concern could revert independently;
|
||||
in practice the commits are already published, so the recipe below
|
||||
records the actual state.
|
||||
|
||||
Commits (oldest first):
|
||||
|
||||
1. `e352a69` — codemod-driven rewrite of `tasks` + `agents` pass-throughs
|
||||
2. (C1 part 2/3) — rewrite of remaining domain-module pass-throughs
|
||||
3. `ee1734e` — remove final pass-throughs + add ESLint guard + simplify
|
||||
vault binding
|
||||
|
||||
**Sequential revert** (not independent): to unwind C1 cleanly you revert
|
||||
in reverse order: 3 → 2 → 1. Reverting commit 2 or 1 without first
|
||||
reverting 3 will leave the ESLint guard still active, which will then
|
||||
fail on the re-introduced pass-throughs. The "net simplification" part
|
||||
of `ee1734e` that touched the vault layer comes along for the ride; it
|
||||
does not have its own revert handle.
|
||||
|
||||
If that bundling is ever a problem, the fix is not to rewrite history
|
||||
(already published) but to issue a targeted revert of only the vault
|
||||
hunks of `ee1734e` via `git revert -n ee1734e && git checkout --patch
|
||||
HEAD -- <vault-files>`.
|
||||
@@ -1,412 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Codemod: rewrite `db.<passthroughMethod>(` → `db.<module>.<passthroughMethod>(`
|
||||
* for all AppDatabase pass-through methods removed by the C1 refactor.
|
||||
*
|
||||
* Regex-based (no ts-morph dep). Operates on a fixed method→module map
|
||||
* derived mechanically from src/main/database.ts and
|
||||
* src/main/database-base.ts. Only rewrites `db.<method>(...)` with a
|
||||
* word-boundary before `db`; never touches `db.<submodule>.<method>(...)`.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/codemod/remove-appdatabase-passthrough.mjs \
|
||||
* --modules=tasks,agents \
|
||||
* [--dry-run]
|
||||
*
|
||||
* The --modules flag restricts rewrites to passthroughs owned by those
|
||||
* sub-module fields; omitting it rewrites every passthrough in the map.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = join(__dirname, "..", "..");
|
||||
|
||||
// Canonical map: passthrough method name → AppDatabaseBase field name
|
||||
// Source: src/main/database.ts + src/main/database-base.ts as of C1 start.
|
||||
const METHOD_TO_MODULE = {
|
||||
// activityMod
|
||||
listComments: "activityMod",
|
||||
addActivity: "activityMod",
|
||||
addComment: "activityMod",
|
||||
listRecentTaskComments: "activityMod",
|
||||
// messages
|
||||
sendAgentMessage: "messages",
|
||||
getAgentMessage: "messages",
|
||||
listAgentMessages: "messages",
|
||||
searchMessages: "messages",
|
||||
markMessageReadForReader: "messages",
|
||||
listAgentInboxUnread: "messages",
|
||||
// social
|
||||
saveSocialAccount: "social",
|
||||
deleteSocialAccount: "social",
|
||||
listSocialAccounts: "social",
|
||||
getSocialAccount: "social",
|
||||
saveBrowserAction: "social",
|
||||
updateBrowserAction: "social",
|
||||
listBrowserActions: "social",
|
||||
getBrowserAction: "social",
|
||||
getBrowserActionByApprovalId: "social",
|
||||
// skills
|
||||
upsertCompanySkill: "skills",
|
||||
listCompanySkills: "skills",
|
||||
getCompanySkill: "skills",
|
||||
deleteCompanySkill: "skills",
|
||||
assignSkillToAgent: "skills",
|
||||
removeSkillFromAgent: "skills",
|
||||
listAgentSkillAssignments: "skills",
|
||||
listSkillAgentAssignments: "skills",
|
||||
updateCompanySkillMetadata: "skills",
|
||||
// budget
|
||||
saveBudgetPolicy: "budget",
|
||||
deleteBudgetPolicy: "budget",
|
||||
listBudgetPolicies: "budget",
|
||||
getBudgetPolicy: "budget",
|
||||
getActivePoliciesForRun: "budget",
|
||||
getCalendarMonthWindow: "budget",
|
||||
getSpentInWindow: "budget",
|
||||
upsertBudgetIncident: "budget",
|
||||
listBudgetIncidents: "budget",
|
||||
resolveBudgetIncident: "budget",
|
||||
resolveIncidentsForPolicy: "budget",
|
||||
resolveWarnIncidentsForPolicy: "budget",
|
||||
autoCloseExpiredIncidents: "budget",
|
||||
// entityCrud
|
||||
saveGoal: "entityCrud",
|
||||
listGoals: "entityCrud",
|
||||
getGoalForUpdate: "entityCrud",
|
||||
saveProject: "entityCrud",
|
||||
listProjects: "entityCrud",
|
||||
getProjectForUpdate: "entityCrud",
|
||||
saveSecret: "entityCrud",
|
||||
resolveSecretEnv: "entityCrud",
|
||||
deleteGoal: "entityCrud",
|
||||
deleteProject: "entityCrud",
|
||||
deleteSecret: "entityCrud",
|
||||
countActiveGoalsForCompany: "entityCrud",
|
||||
// createHireRequest is a multi-module composite that lives on
|
||||
// AppDatabase itself (spans agents + approvalsMod). Do not rewrite.
|
||||
// rollbackConfigRevision is the same (spans documents + entityCrud).
|
||||
// documentsMod
|
||||
saveDocument: "documentsMod",
|
||||
listImportantDocsForReview: "documentsMod",
|
||||
listDocuments: "documentsMod",
|
||||
getDocumentForUpdate: "documentsMod",
|
||||
updateDocumentStatus: "documentsMod",
|
||||
deleteDocument: "documentsMod",
|
||||
createDocumentRevision: "documentsMod",
|
||||
getDocumentLatestRevisionId: "documentsMod",
|
||||
listDocumentRevisions: "documentsMod",
|
||||
createWorkProduct: "documentsMod",
|
||||
updateWorkProduct: "documentsMod",
|
||||
listWorkProductsForTask: "documentsMod",
|
||||
listWorkProductsForCompany: "documentsMod",
|
||||
findWorkProductByExternalId: "documentsMod",
|
||||
resolveWorkProductTarget: "documentsMod",
|
||||
deleteWorkProduct: "documentsMod",
|
||||
archiveWorkProductsByExternalPaths: "documentsMod",
|
||||
linkDocumentToTask: "documentsMod",
|
||||
unlinkDocumentFromTask: "documentsMod",
|
||||
listDocumentsForTask: "documentsMod",
|
||||
listConfigRevisions: "documentsMod",
|
||||
getConfigRevision: "documentsMod",
|
||||
rollbackConfigRevision: "documentsMod",
|
||||
recordConfigRevision: "documentsMod",
|
||||
// knowledgeMod
|
||||
saveMeeting: "knowledgeMod",
|
||||
deleteMeeting: "knowledgeMod",
|
||||
listMeetings: "knowledgeMod",
|
||||
getMeeting: "knowledgeMod",
|
||||
saveKnowledgeEntry: "knowledgeMod",
|
||||
deleteKnowledgeEntry: "knowledgeMod",
|
||||
listKnowledgeEntries: "knowledgeMod",
|
||||
saveSprint: "knowledgeMod",
|
||||
deleteSprint: "knowledgeMod",
|
||||
listSprints: "knowledgeMod",
|
||||
collectRunDeliverableSignals: "knowledgeMod",
|
||||
// automationMod
|
||||
saveAutomationRule: "automationMod",
|
||||
deleteAutomationRule: "automationMod",
|
||||
listAutomationRuleNames: "automationMod",
|
||||
toggleAutomationRule: "automationMod",
|
||||
recordAutomationExecution: "automationMod",
|
||||
getAutomationLog: "automationMod",
|
||||
listActiveAutomationRules: "automationMod",
|
||||
saveWorkflow: "automationMod",
|
||||
getWorkflowRecord: "automationMod",
|
||||
updateWorkflowExecution: "automationMod",
|
||||
deleteWorkflow: "automationMod",
|
||||
listTriggerableWorkflows: "automationMod",
|
||||
// wakeup
|
||||
enqueueWakeupRequest: "wakeup",
|
||||
promoteWakeupRequests: "wakeup",
|
||||
listWakeupRequests: "wakeup",
|
||||
getPreferredWakeTrigger: "wakeup",
|
||||
completePendingWakeupRequests: "wakeup",
|
||||
purgeExpiredWakeupRequests: "wakeup",
|
||||
// backupMod
|
||||
exportBackup: "backupMod",
|
||||
createAutomaticBackup: "backupMod",
|
||||
restoreFromBackup: "backupMod",
|
||||
// threads
|
||||
createThread: "threads",
|
||||
listThreads: "threads",
|
||||
getThread: "threads",
|
||||
addThreadEvent: "threads",
|
||||
listThreadEvents: "threads",
|
||||
setThreadWaitState: "threads",
|
||||
resolveThreadWaitState: "threads",
|
||||
listThreadWaitStates: "threads",
|
||||
hasUnresolvedWaitStateForAgent: "threads",
|
||||
addParticipant: "threads",
|
||||
listParticipants: "threads",
|
||||
removeParticipant: "threads",
|
||||
subscribe: "threads",
|
||||
unsubscribe: "threads",
|
||||
listSubscriptions: "threads",
|
||||
listSubscribedThreads: "threads",
|
||||
// settings
|
||||
getCurrentCompanyId: "settings",
|
||||
setCurrentCompany: "settings",
|
||||
hasCompany: "settings",
|
||||
belongsToCompany: "settings",
|
||||
updateSettings: "settings",
|
||||
getLogRetentionDays: "settings",
|
||||
purgeExpiredRunLogs: "settings",
|
||||
canOpenPath: "settings",
|
||||
getDisabledModels: "settings",
|
||||
setDisabledModels: "settings",
|
||||
hasModelPoolSettings: "settings",
|
||||
getLastBudgetResetMonth: "settings",
|
||||
setLastBudgetResetMonth: "settings",
|
||||
// snap
|
||||
listSnapshot: "snap",
|
||||
listRendererSnapshot: "snap",
|
||||
// company
|
||||
saveCompany: "company",
|
||||
deleteCompany: "company",
|
||||
getCompanyStatus: "company",
|
||||
getCompanyBasic: "company",
|
||||
getCompanyExecutionMode: "company",
|
||||
getCompanyApprovalSettings: "company",
|
||||
listActiveCompanyIds: "company",
|
||||
getCompanyMetrics: "company",
|
||||
getStandupReport: "company",
|
||||
getDashboardSnapshot: "company",
|
||||
// connectorsCrud
|
||||
saveConnector: "connectorsCrud",
|
||||
updateConnectorHealth: "connectorsCrud",
|
||||
getConnector: "connectorsCrud",
|
||||
findConnector: "connectorsCrud",
|
||||
// agents (note: getAgent/findAgent live on entityCrud)
|
||||
saveAgent: "agents",
|
||||
deleteAgent: "agents",
|
||||
getAgent: "entityCrud",
|
||||
findAgent: "entityCrud",
|
||||
setHeartbeat: "agents",
|
||||
markHeartbeatAt: "agents",
|
||||
resetMonthlyBudgets: "agents",
|
||||
listHeartbeatEligibleAgents: "agents",
|
||||
getChainOfCommand: "agents",
|
||||
getDirectReports: "agents",
|
||||
getCeoForCompany: "agents",
|
||||
listActiveAgentsForCompany: "agents",
|
||||
listAgentsForApi: "agents",
|
||||
listDirectReportsForStandup: "agents",
|
||||
listReportStatusesForStandup: "agents",
|
||||
listExistingAgentsForHireCheck: "agents",
|
||||
hasAgentAssignedTasks: "agents",
|
||||
updateAgentStatus: "agents",
|
||||
isAgentPendingApproval: "agents",
|
||||
findMentionedAgentIds: "agents",
|
||||
getIdleAgentsForDepartment: "agents",
|
||||
getManagerForAgent: "agents",
|
||||
getAgentPermissions: "agents",
|
||||
setAgentPermission: "agents",
|
||||
hasAgentPermission: "agents",
|
||||
getRuntimeState: "agents",
|
||||
setRuntimeState: "agents",
|
||||
getHeartbeatPolicy: "agents",
|
||||
setHeartbeatPolicy: "agents",
|
||||
countActiveRunsForAgent: "agents",
|
||||
listDepartmentAgentIds: "agents",
|
||||
listCompanyBroadcastAgentIds: "agents",
|
||||
listIncidentPingableAgentIds: "agents",
|
||||
findAgentName: "agents",
|
||||
listAgentNamesByIds: "agents",
|
||||
listActiveAgentIdsFromIds: "agents",
|
||||
// tasks (note: getTask lives on entityCrud)
|
||||
saveTask: "tasks",
|
||||
getTask: "entityCrud",
|
||||
deleteTask: "tasks",
|
||||
getSessionById: "tasks",
|
||||
incrementSessionUsage: "tasks",
|
||||
archiveSession: "tasks",
|
||||
createNewSession: "tasks",
|
||||
ensureAgentSession: "tasks",
|
||||
setAgentSessionId: "tasks",
|
||||
getRecentRunsForTask: "tasks",
|
||||
getRecentActivityForTask: "tasks",
|
||||
checkoutTask: "tasks",
|
||||
releaseTaskLock: "tasks",
|
||||
renewTaskLease: "tasks",
|
||||
listAgentTasks: "tasks",
|
||||
listTasksByStatuses: "tasks",
|
||||
listAssigneeTasksForStandup: "tasks",
|
||||
listTasksForAssignees: "tasks",
|
||||
listReviewTasksForManager: "tasks",
|
||||
countActiveTasksForCompany: "tasks",
|
||||
countAdvancingTasksForCompany: "tasks",
|
||||
listChildTasks: "tasks",
|
||||
advanceTaskStatus: "tasks",
|
||||
setTaskRequiresUserReview: "tasks",
|
||||
hasPendingDeliverableReviews: "tasks",
|
||||
hasAgentPendingDeliverableReview: "tasks",
|
||||
hasAgentTaskAwaitingReview: "tasks",
|
||||
autoAssignTask: "tasks",
|
||||
getTaskRetryCount: "tasks",
|
||||
incrementTaskRetryCount: "tasks",
|
||||
resetTaskRetryCount: "tasks",
|
||||
retryInterruptedTask: "tasks",
|
||||
dismissInterruptedTask: "tasks",
|
||||
markInterruptedTaskFailed: "tasks",
|
||||
setTaskType: "tasks",
|
||||
listProjectTaskAssigneeIds: "tasks",
|
||||
listReviewTasksForSweep: "tasks",
|
||||
getTaskType: "tasks",
|
||||
// runs
|
||||
getRun: "runs",
|
||||
getRunAuthState: "runs",
|
||||
createRun: "runs",
|
||||
appendRunLog: "runs",
|
||||
readRunLog: "runs",
|
||||
readRunLogChunk: "runs",
|
||||
markRunStatus: "runs",
|
||||
finishRun: "runs",
|
||||
recordCostEntry: "runs",
|
||||
hasActiveRun: "runs",
|
||||
listQueuedRuns: "runs",
|
||||
getInterruptedRunsForRecovery: "runs",
|
||||
appendRunEvent: "runs",
|
||||
listRunEvents: "runs",
|
||||
listRunEventsAfter: "runs",
|
||||
updateRunActor: "runs",
|
||||
// approvalsMod
|
||||
requestApproval: "approvalsMod",
|
||||
decideApproval: "approvalsMod",
|
||||
getApprovalRecord: "approvalsMod",
|
||||
findApprovalById: "approvalsMod",
|
||||
deleteApproval: "approvalsMod",
|
||||
getBlockingApproval: "approvalsMod",
|
||||
resubmitApproval: "approvalsMod",
|
||||
addApprovalComment: "approvalsMod",
|
||||
listApprovalComments: "approvalsMod",
|
||||
linkTaskToApproval: "approvalsMod",
|
||||
unlinkTaskFromApproval: "approvalsMod",
|
||||
listApprovalsForTask: "approvalsMod",
|
||||
listTasksForApproval: "approvalsMod",
|
||||
linkManyTasksToApproval: "approvalsMod",
|
||||
listAgentPendingApprovals: "approvalsMod",
|
||||
listAgentResolvedApprovals: "approvalsMod",
|
||||
listPendingHireApprovalPayloads: "approvalsMod",
|
||||
listApprovalsForApi: "approvalsMod",
|
||||
findPendingApprovalForTask: "approvalsMod",
|
||||
hasPendingReviewApprovalForAgent: "approvalsMod",
|
||||
listCompanyApprovalsByType: "approvalsMod",
|
||||
// workspacesMod
|
||||
saveWorkspace: "workspacesMod",
|
||||
getWorkspace: "workspacesMod",
|
||||
getProjectPrimaryWorkspace: "workspacesMod",
|
||||
listWorkspaceLocalPaths: "workspacesMod",
|
||||
deleteWorkspace: "workspacesMod",
|
||||
acquireWorkspaceLock: "workspacesMod",
|
||||
claimWorkspaceLock: "workspacesMod",
|
||||
releaseWorkspaceLock: "workspacesMod",
|
||||
releaseAllWorkspaceLocks: "workspacesMod",
|
||||
releaseStaleWorkspaceLocks: "workspacesMod",
|
||||
isWorkspaceLocked: "workspacesMod",
|
||||
};
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes("--dry-run");
|
||||
const modulesArg = args.find((a) => a.startsWith("--modules="));
|
||||
const methodsArg = args.find((a) => a.startsWith("--methods="));
|
||||
const includedModules = modulesArg
|
||||
? new Set(modulesArg.split("=")[1].split(","))
|
||||
: null;
|
||||
const extraMethods = methodsArg
|
||||
? new Set(methodsArg.split("=")[1].split(","))
|
||||
: new Set();
|
||||
|
||||
const targetMethods = Object.entries(METHOD_TO_MODULE)
|
||||
.filter(([name, mod]) => {
|
||||
if (!includedModules) return true;
|
||||
if (includedModules.has(mod)) return true;
|
||||
if (extraMethods.has(name)) return true;
|
||||
return false;
|
||||
})
|
||||
.map(([m]) => m);
|
||||
|
||||
if (targetMethods.length === 0) {
|
||||
console.error("No methods matched module filter.");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Collect files via git ls-files (fast, respects .gitignore)
|
||||
const filesOut = execSync("git ls-files 'src/**/*.ts' 'src/**/*.tsx' 'tests/**/*.ts'", {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: "utf8",
|
||||
});
|
||||
const files = filesOut
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.filter((f) => !f.startsWith("src/main/database.ts"))
|
||||
.filter((f) => !f.startsWith("src/main/database-base.ts"))
|
||||
.filter((f) => !f.startsWith("src/main/database/"));
|
||||
|
||||
// Build one giant alternation regex, non-capturing, then use a back-reference
|
||||
// group only for the method name so we can look up the module.
|
||||
const methodAlt = targetMethods
|
||||
.sort((a, b) => b.length - a.length) // longest-first to avoid partial shadow
|
||||
.join("|");
|
||||
|
||||
// Match `<optional qualifier>db.<method>(`. The qualifier must end with
|
||||
// a `.` so `adb.saveTask(` (same token) is not matched. Examples that
|
||||
// DO match: `db.saveTask(`, `this.db.saveTask(`, `shared.db.saveTask(`,
|
||||
// `ctx.db.saveTask(`. The pre-anchor `(^|[^\w.])` keeps us from
|
||||
// splitting an identifier token containing `db` somewhere in the middle.
|
||||
const pattern = new RegExp(
|
||||
`(^|[^\\w.])((?:[\\w$]+\\.)*)db\\.(${methodAlt})\\(`,
|
||||
"g",
|
||||
);
|
||||
|
||||
let totalReplacements = 0;
|
||||
let changedFiles = 0;
|
||||
const perFile = {};
|
||||
|
||||
for (const relPath of files) {
|
||||
const abs = join(REPO_ROOT, relPath);
|
||||
const src = readFileSync(abs, "utf8");
|
||||
let count = 0;
|
||||
const out = src.replace(pattern, (match, pre, qualifier, method) => {
|
||||
const mod = METHOD_TO_MODULE[method];
|
||||
if (!mod) return match;
|
||||
count += 1;
|
||||
return `${pre}${qualifier}db.${mod}.${method}(`;
|
||||
});
|
||||
if (count > 0) {
|
||||
perFile[relPath] = count;
|
||||
totalReplacements += count;
|
||||
changedFiles += 1;
|
||||
if (!dryRun) writeFileSync(abs, out);
|
||||
}
|
||||
}
|
||||
|
||||
const hdr = dryRun ? "DRY RUN" : "APPLIED";
|
||||
console.log(`${hdr}: ${totalReplacements} rewrites across ${changedFiles} files`);
|
||||
for (const [f, n] of Object.entries(perFile).sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${n}\t${f}`);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { extname } from "node:path";
|
||||
|
||||
async function runCommand(command, args, capture = false) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit",
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
if (capture) {
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
}
|
||||
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr });
|
||||
return;
|
||||
}
|
||||
reject(new Error(stderr || stdout || `${command} failed with exit code ${code ?? 1}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runCodesign(args) {
|
||||
await runCommand("/usr/bin/codesign", args, false);
|
||||
}
|
||||
|
||||
function buildCodesignArgs(targetPath, identity, keychain, fileOptions = {}) {
|
||||
const args = [
|
||||
"--sign", identity,
|
||||
"--force",
|
||||
];
|
||||
|
||||
if (keychain) {
|
||||
args.push("--keychain", keychain);
|
||||
}
|
||||
if (fileOptions.timestamp !== false) {
|
||||
args.push("--timestamp");
|
||||
}
|
||||
if (fileOptions.hardenedRuntime) {
|
||||
args.push("--options", "runtime");
|
||||
}
|
||||
if (fileOptions.entitlements) {
|
||||
args.push("--entitlements", fileOptions.entitlements);
|
||||
}
|
||||
if (Array.isArray(fileOptions.additionalArguments) && fileOptions.additionalArguments.length > 0) {
|
||||
args.push(...fileOptions.additionalArguments);
|
||||
}
|
||||
|
||||
args.push(targetPath);
|
||||
return args;
|
||||
}
|
||||
|
||||
async function isMachBinary(filePath) {
|
||||
const extension = extname(filePath);
|
||||
if (extension === ".dylib" || extension === ".node") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { stdout } = await runCommand("/usr/bin/file", ["-b", filePath], true);
|
||||
return stdout.includes("Mach-O");
|
||||
}
|
||||
|
||||
async function collectSignTargets(rootPath) {
|
||||
const bundleTargets = [];
|
||||
const binaryTargets = [];
|
||||
|
||||
async function walk(currentPath) {
|
||||
const entries = await readdir(currentPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = `${currentPath}/${entry.name}`;
|
||||
|
||||
if (entry.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (
|
||||
entry.name.endsWith(".app") ||
|
||||
entry.name.endsWith(".framework") ||
|
||||
entry.name.endsWith(".xpc") ||
|
||||
entry.name.endsWith(".appex") ||
|
||||
entry.name.endsWith(".plugin")
|
||||
) {
|
||||
bundleTargets.push(fullPath);
|
||||
}
|
||||
await walk(fullPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await isMachBinary(fullPath)) {
|
||||
binaryTargets.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(rootPath);
|
||||
|
||||
bundleTargets.sort((left, right) => right.length - left.length);
|
||||
binaryTargets.sort((left, right) => right.length - left.length);
|
||||
|
||||
return { bundleTargets, binaryTargets };
|
||||
}
|
||||
|
||||
async function signTarget(targetPath, context) {
|
||||
const fileOptions = context.optionsForFile?.(targetPath) ?? {};
|
||||
const args = buildCodesignArgs(targetPath, context.identity, context.keychain, fileOptions);
|
||||
await runCodesign(args);
|
||||
}
|
||||
|
||||
export default async function sign(context) {
|
||||
const { bundleTargets, binaryTargets } = await collectSignTargets(context.app);
|
||||
|
||||
for (const target of binaryTargets) {
|
||||
await signTarget(target, context);
|
||||
}
|
||||
|
||||
for (const target of bundleTargets) {
|
||||
await signTarget(target, context);
|
||||
}
|
||||
|
||||
await signTarget(context.app, context);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* Ensures the Electron binary exists after `pnpm install`.
|
||||
*
|
||||
* On some Windows/Node combos electron's own install script dies silently:
|
||||
* its extract-zip dependency drops the event loop mid-extraction and the
|
||||
* process exits 0 with a partial `dist/` and no `path.txt`, which later makes
|
||||
* electron-vite fail with "Error: Electron uninstall". The download/cache
|
||||
* layer (@electron/get) is unaffected, so when that happens we fetch the zip
|
||||
* through it and extract with the OS-native unzipper instead.
|
||||
*
|
||||
* No-op when the binary is already in place (the normal case on macOS/Linux).
|
||||
*/
|
||||
const { createRequire } = require('node:module');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const electronPkgJson = require.resolve('electron/package.json');
|
||||
const electronDir = path.dirname(electronPkgJson);
|
||||
const electronRequire = createRequire(electronPkgJson);
|
||||
|
||||
const platformPath = {
|
||||
win32: 'electron.exe',
|
||||
darwin: 'Electron.app/Contents/MacOS/Electron',
|
||||
mas: 'Electron.app/Contents/MacOS/Electron',
|
||||
}[process.platform] || 'electron';
|
||||
|
||||
function isInstalled() {
|
||||
try {
|
||||
return (
|
||||
fs.readFileSync(path.join(electronDir, 'path.txt'), 'utf8') === platformPath &&
|
||||
fs.existsSync(path.join(electronDir, 'dist', platformPath))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function extractNatively(zipPath, dist) {
|
||||
if (process.platform === 'win32') {
|
||||
execFileSync('powershell.exe', [
|
||||
'-NoProfile', '-NonInteractive', '-Command',
|
||||
`Expand-Archive -LiteralPath "${zipPath}" -DestinationPath "${dist}" -Force`,
|
||||
], { stdio: 'inherit' });
|
||||
} else if (process.platform === 'darwin') {
|
||||
// ditto preserves the Electron.app symlinks and executable bits
|
||||
execFileSync('ditto', ['-xk', zipPath, dist], { stdio: 'inherit' });
|
||||
} else {
|
||||
execFileSync('unzip', ['-oq', zipPath, '-d', dist], { stdio: 'inherit' });
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (isInstalled()) return;
|
||||
|
||||
// Normal route: electron's own installer (covers the case where pnpm
|
||||
// skipped its build script).
|
||||
try {
|
||||
execFileSync(process.execPath, [path.join(electronDir, 'install.js')], { stdio: 'inherit' });
|
||||
} catch {
|
||||
// fall through to the manual route
|
||||
}
|
||||
if (isInstalled()) return;
|
||||
|
||||
console.log('[ensure-electron] electron install script left no binary; extracting manually...');
|
||||
const { downloadArtifact } = electronRequire('@electron/get');
|
||||
const { version } = electronRequire('./package.json');
|
||||
const zipPath = await downloadArtifact({
|
||||
version,
|
||||
artifactName: 'electron',
|
||||
cacheRoot: process.env.electron_config_cache,
|
||||
checksums: electronRequire('./checksums.json'),
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
});
|
||||
|
||||
const dist = path.join(electronDir, 'dist');
|
||||
fs.rmSync(dist, { recursive: true, force: true });
|
||||
fs.mkdirSync(dist, { recursive: true });
|
||||
extractNatively(zipPath, dist);
|
||||
|
||||
const typeDef = path.join(dist, 'electron.d.ts');
|
||||
if (fs.existsSync(typeDef)) {
|
||||
fs.renameSync(typeDef, path.join(electronDir, 'electron.d.ts'));
|
||||
}
|
||||
fs.writeFileSync(path.join(electronDir, 'path.txt'), platformPath);
|
||||
|
||||
if (!isInstalled()) {
|
||||
throw new Error('Electron binary still missing after manual extraction');
|
||||
}
|
||||
console.log('[ensure-electron] Electron binary restored.');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[ensure-electron] failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,241 +0,0 @@
|
||||
import { readdir, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { loadEnvFile } from "./release/lib/dotenv.mjs";
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const localReleaseDir = join(rootDir, "local-release");
|
||||
const outputPath = join(rootDir, "landing", "components", "release-links.generated.ts");
|
||||
|
||||
function normalizeBaseUrl(url) {
|
||||
return url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function normalizePathSegment(pathValue) {
|
||||
return pathValue.replace(/^\/+|\/+$/g, "");
|
||||
}
|
||||
|
||||
function resolveUpdateBaseUrl() {
|
||||
const explicit = process.env.AGENTCOMPANY_UPDATE_URL?.trim();
|
||||
if (explicit) return normalizeBaseUrl(explicit);
|
||||
|
||||
const publicBaseUrl =
|
||||
process.env.AGENTCOMPANY_R2_PUBLIC_URL?.trim() ||
|
||||
process.env.R2_PUBLIC_BASE_URL?.trim();
|
||||
const prefix = process.env.AGENTCOMPANY_RELEASE_PREFIX?.trim() || "AC";
|
||||
|
||||
if (!publicBaseUrl) {
|
||||
throw new Error("Missing AGENTCOMPANY_UPDATE_URL / R2 public URL while generating landing release links.");
|
||||
}
|
||||
|
||||
const normalizedBase = normalizeBaseUrl(publicBaseUrl);
|
||||
const normalizedPrefix = normalizePathSegment(prefix);
|
||||
return `${normalizedBase}/${normalizedPrefix}`;
|
||||
}
|
||||
|
||||
function ensureSingle(files, predicate, label) {
|
||||
const matches = files.filter(predicate);
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(`Expected exactly one ${label} artifact, found ${matches.length}: ${matches.join(", ")}`);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function extractVersion(fileNames) {
|
||||
const versions = new Set(
|
||||
fileNames
|
||||
.map((fileName) => fileName.match(/(\d+\.\d+\.\d+)/)?.[1] ?? null)
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
if (versions.size !== 1) {
|
||||
throw new Error(`Expected exactly one release version across artifacts, found: ${[...versions].join(", ")}`);
|
||||
}
|
||||
|
||||
return [...versions][0];
|
||||
}
|
||||
|
||||
function urlFor(baseUrl, fileName) {
|
||||
return `${baseUrl}/${encodeURIComponent(fileName)}`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await loadEnvFile(join(rootDir, ".env.release.local"));
|
||||
await loadEnvFile(join(rootDir, ".env.release"));
|
||||
|
||||
if (!existsSync(localReleaseDir)) {
|
||||
throw new Error(`Local release directory not found: ${localReleaseDir}`);
|
||||
}
|
||||
|
||||
const baseUrl = resolveUpdateBaseUrl();
|
||||
const macFiles = (await readdir(join(localReleaseDir, "mac"))).filter((name) => !name.startsWith("."));
|
||||
const winFiles = (await readdir(join(localReleaseDir, "win"))).filter((name) => !name.startsWith("."));
|
||||
const linuxFiles = (await readdir(join(localReleaseDir, "linux"))).filter((name) => !name.startsWith("."));
|
||||
|
||||
const macArm64Dmg = ensureSingle(macFiles, (name) => name.endsWith(".dmg") && name.includes("arm64"), "macOS Apple Silicon .dmg");
|
||||
const macIntelDmg = ensureSingle(macFiles, (name) => name.endsWith(".dmg") && !name.includes("arm64"), "macOS Intel .dmg");
|
||||
const windowsUniversalInstaller = ensureSingle(
|
||||
winFiles,
|
||||
(name) => name.endsWith(".exe") && name.includes("Setup"),
|
||||
"Windows universal installer",
|
||||
);
|
||||
const linuxX64AppImage = ensureSingle(
|
||||
linuxFiles,
|
||||
(name) => name.endsWith(".AppImage") && !name.includes("arm64"),
|
||||
"Linux x64 AppImage",
|
||||
);
|
||||
const linuxArm64AppImage = ensureSingle(
|
||||
linuxFiles,
|
||||
(name) => name.endsWith(".AppImage") && name.includes("arm64"),
|
||||
"Linux arm64 AppImage",
|
||||
);
|
||||
const linuxX64Deb = ensureSingle(
|
||||
linuxFiles,
|
||||
(name) => name.endsWith(".deb") && name.includes("amd64"),
|
||||
"Linux x64 .deb",
|
||||
);
|
||||
const linuxArm64Deb = ensureSingle(
|
||||
linuxFiles,
|
||||
(name) => name.endsWith(".deb") && name.includes("arm64"),
|
||||
"Linux arm64 .deb",
|
||||
);
|
||||
const linuxX64Rpm = ensureSingle(
|
||||
linuxFiles,
|
||||
(name) => name.endsWith(".rpm") && (name.includes("x86_64") || name.includes("amd64")),
|
||||
"Linux x64 .rpm",
|
||||
);
|
||||
const linuxArm64Rpm = ensureSingle(
|
||||
linuxFiles,
|
||||
(name) => name.endsWith(".rpm") && (name.includes("aarch64") || name.includes("arm64")),
|
||||
"Linux arm64 .rpm",
|
||||
);
|
||||
|
||||
const version = extractVersion([
|
||||
macArm64Dmg,
|
||||
macIntelDmg,
|
||||
windowsUniversalInstaller,
|
||||
linuxX64AppImage,
|
||||
linuxArm64AppImage,
|
||||
linuxX64Deb,
|
||||
linuxArm64Deb,
|
||||
linuxX64Rpm,
|
||||
linuxArm64Rpm,
|
||||
]);
|
||||
|
||||
const content = `export type PlatformDownloadAsset = {
|
||||
architectures: ReleaseArchitecture[];
|
||||
id: string;
|
||||
label: string;
|
||||
href: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export type ReleaseArchitecture = "arm64" | "x64" | "x86";
|
||||
|
||||
export type PlatformDownload = {
|
||||
id: "macos" | "windows" | "linux";
|
||||
packageDescription: string;
|
||||
title: string;
|
||||
note: string;
|
||||
assets: PlatformDownloadAsset[];
|
||||
};
|
||||
|
||||
export const releaseVersion = "${version}";
|
||||
|
||||
export const platformDownloads: PlatformDownload[] = [
|
||||
{
|
||||
id: "macos",
|
||||
packageDescription: "signed and notarized DMG builds",
|
||||
title: "macOS",
|
||||
note: "Signed + notarized DMG builds",
|
||||
assets: [
|
||||
{
|
||||
architectures: ["arm64"],
|
||||
id: "macos-arm64-dmg",
|
||||
label: "Apple Silicon DMG",
|
||||
href: "${urlFor(baseUrl, macArm64Dmg)}",
|
||||
note: "arm64",
|
||||
},
|
||||
{
|
||||
architectures: ["x64"],
|
||||
id: "macos-x64-dmg",
|
||||
label: "Intel Mac DMG",
|
||||
href: "${urlFor(baseUrl, macIntelDmg)}",
|
||||
note: "x64",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "windows",
|
||||
packageDescription: "an NSIS installer build",
|
||||
title: "Windows",
|
||||
note: "NSIS installer builds",
|
||||
assets: [
|
||||
{
|
||||
architectures: ["arm64", "x64"],
|
||||
id: "windows-universal-installer",
|
||||
label: "Windows Universal Installer",
|
||||
href: "${urlFor(baseUrl, windowsUniversalInstaller)}",
|
||||
note: "arm64 + x64",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "linux",
|
||||
packageDescription: "AppImage, DEB, and RPM builds",
|
||||
title: "Linux",
|
||||
note: "AppImage, DEB, and RPM builds",
|
||||
assets: [
|
||||
{
|
||||
architectures: ["x64"],
|
||||
id: "linux-x64-appimage",
|
||||
label: "Linux x64 AppImage",
|
||||
href: "${urlFor(baseUrl, linuxX64AppImage)}",
|
||||
note: "x64",
|
||||
},
|
||||
{
|
||||
architectures: ["x64"],
|
||||
id: "linux-x64-deb",
|
||||
label: "Linux x64 DEB",
|
||||
href: "${urlFor(baseUrl, linuxX64Deb)}",
|
||||
note: "x64",
|
||||
},
|
||||
{
|
||||
architectures: ["x64"],
|
||||
id: "linux-x64-rpm",
|
||||
label: "Linux x64 RPM",
|
||||
href: "${urlFor(baseUrl, linuxX64Rpm)}",
|
||||
note: "x64",
|
||||
},
|
||||
{
|
||||
architectures: ["arm64"],
|
||||
id: "linux-arm64-appimage",
|
||||
label: "Linux ARM64 AppImage",
|
||||
href: "${urlFor(baseUrl, linuxArm64AppImage)}",
|
||||
note: "arm64",
|
||||
},
|
||||
{
|
||||
architectures: ["arm64"],
|
||||
id: "linux-arm64-deb",
|
||||
label: "Linux ARM64 DEB",
|
||||
href: "${urlFor(baseUrl, linuxArm64Deb)}",
|
||||
note: "arm64",
|
||||
},
|
||||
{
|
||||
architectures: ["arm64"],
|
||||
id: "linux-arm64-rpm",
|
||||
label: "Linux ARM64 RPM",
|
||||
href: "${urlFor(baseUrl, linuxArm64Rpm)}",
|
||||
note: "arm64",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
`;
|
||||
|
||||
await writeFile(outputPath, content, "utf8");
|
||||
console.log(`[generate-landing-release-links] wrote ${outputPath}`);
|
||||
console.log(`[generate-landing-release-links] version ${version}`);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -1,204 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate AgentCompany icons from SVG sources using cairosvg.
|
||||
|
||||
Two icon families:
|
||||
- Web (circle bg): icon.svg → landing favicon + website logo
|
||||
- App (square bg): icon-app.svg → Electron app icon (Apple HIG, OS applies mask)
|
||||
- Tray: rendered from logo.svg for menu bar / notification area
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ── macOS-only build helper (uses iconutil and Homebrew cairo) ────────
|
||||
if sys.platform != "darwin":
|
||||
sys.exit(
|
||||
"generate-icons.py is a macOS-only build helper (requires iconutil + "
|
||||
"Homebrew cairo). Icons are pre-generated and committed under assets/."
|
||||
)
|
||||
|
||||
# ── Ensure cairo library is discoverable on macOS (Homebrew) ──────────
|
||||
for _lib_dir in ("/opt/homebrew/opt/cairo/lib", "/usr/local/opt/cairo/lib"):
|
||||
if Path(_lib_dir).exists():
|
||||
os.environ["DYLD_LIBRARY_PATH"] = (
|
||||
_lib_dir + ":" + os.environ.get("DYLD_LIBRARY_PATH", "")
|
||||
)
|
||||
break
|
||||
|
||||
import cairosvg # noqa: E402 — must come after DYLD_LIBRARY_PATH setup
|
||||
from PIL import Image # noqa: E402
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
ASSETS = ROOT / "assets"
|
||||
LANDING = ROOT / "landing" / "public"
|
||||
ICONSET = ASSETS / "icon.iconset"
|
||||
LOGO_SVG = ROOT / "logo.svg"
|
||||
|
||||
# SVG sources
|
||||
WEB_SVG = ASSETS / "icon.svg" # circle bg — web favicon
|
||||
APP_SVG = ASSETS / "icon-app.svg" # square bg — Electron (Apple HIG)
|
||||
|
||||
SIZE = 1024
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
def svg_to_image(svg_path: Path, width: int, height: int | None = None) -> Image.Image:
|
||||
"""Render an SVG to a Pillow RGBA Image at the given size."""
|
||||
h = height or width
|
||||
png_data = cairosvg.svg2png(
|
||||
url=str(svg_path), output_width=width, output_height=h,
|
||||
)
|
||||
return Image.open(io.BytesIO(png_data)).convert("RGBA")
|
||||
|
||||
|
||||
def svg_bytes_to_image(svg_bytes: bytes, width: int, height: int | None = None) -> Image.Image:
|
||||
"""Render SVG bytes to a Pillow RGBA Image."""
|
||||
h = height or width
|
||||
png_data = cairosvg.svg2png(
|
||||
bytestring=svg_bytes, output_width=width, output_height=h,
|
||||
)
|
||||
return Image.open(io.BytesIO(png_data)).convert("RGBA")
|
||||
|
||||
|
||||
# ── Web icons (circle background) ────────────────────────────────────
|
||||
|
||||
def generate_web_icons() -> None:
|
||||
"""Generate favicon.ico and icon.png for the landing site (circle bg)."""
|
||||
print("Generating web icons from icon.svg (circle bg)…")
|
||||
master = svg_to_image(WEB_SVG, SIZE)
|
||||
|
||||
# PNG for apple-touch-icon / og
|
||||
web_png = LANDING / "icon.png"
|
||||
master.save(web_png, format="PNG")
|
||||
print(f" → {web_png}")
|
||||
|
||||
# Copy SVG to landing
|
||||
shutil.copy2(WEB_SVG, LANDING / "icon.svg")
|
||||
print(f" → {LANDING / 'icon.svg'}")
|
||||
|
||||
# ICO with multiple sizes for favicon
|
||||
favicon = LANDING / "favicon.ico"
|
||||
master.save(
|
||||
favicon, format="ICO",
|
||||
sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)],
|
||||
)
|
||||
print(f" → {favicon}")
|
||||
|
||||
|
||||
# ── App icons (square background, Apple HIG) ─────────────────────────
|
||||
|
||||
def generate_app_icons() -> None:
|
||||
"""Generate Electron app icons: PNG, ICO, ICNS (square bg, Apple HIG)."""
|
||||
print("Generating app icons from icon-app.svg (square bg, Apple HIG)…")
|
||||
master = svg_to_image(APP_SVG, SIZE)
|
||||
|
||||
# Main PNG
|
||||
png_path = ASSETS / "icon.png"
|
||||
master.save(png_path, format="PNG")
|
||||
print(f" → {png_path}")
|
||||
|
||||
# Windows ICO
|
||||
ico_path = ASSETS / "icon.ico"
|
||||
master.save(
|
||||
ico_path, format="ICO",
|
||||
sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)],
|
||||
)
|
||||
print(f" → {ico_path}")
|
||||
|
||||
# macOS ICNS via iconutil
|
||||
generate_icns(master)
|
||||
|
||||
|
||||
def generate_icns(master: Image.Image) -> None:
|
||||
"""Generate .icns from a master image using macOS iconutil."""
|
||||
if ICONSET.exists():
|
||||
shutil.rmtree(ICONSET)
|
||||
ICONSET.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for size in (16, 32, 64, 128, 256, 512):
|
||||
master.resize((size, size), Image.Resampling.LANCZOS).save(
|
||||
ICONSET / f"icon_{size}x{size}.png", format="PNG",
|
||||
)
|
||||
master.resize((size * 2, size * 2), Image.Resampling.LANCZOS).save(
|
||||
ICONSET / f"icon_{size}x{size}@2x.png", format="PNG",
|
||||
)
|
||||
|
||||
icns_path = ASSETS / "icon.icns"
|
||||
subprocess.run(
|
||||
["iconutil", "-c", "icns", str(ICONSET), "-o", str(icns_path)],
|
||||
check=True,
|
||||
)
|
||||
shutil.rmtree(ICONSET)
|
||||
print(f" → {icns_path}")
|
||||
|
||||
|
||||
# ── Tray / notification area icons ───────────────────────────────────
|
||||
|
||||
def generate_tray_icons() -> None:
|
||||
"""Generate tray icons following Apple HIG for menu bar.
|
||||
|
||||
- Color (Windows/Linux): square bg with logo, 32×32 and 64×64
|
||||
- Template (macOS): monochrome black logo on transparent, 22×22 and 44×44
|
||||
"""
|
||||
print("Generating tray icons…")
|
||||
|
||||
# Color tray icons — render app icon (square bg) at small sizes
|
||||
for name, size in [("tray-icon.png", 32), ("tray-icon@2x.png", 64)]:
|
||||
img = svg_to_image(APP_SVG, size)
|
||||
img.save(ASSETS / name, format="PNG")
|
||||
print(f" → {ASSETS / name}")
|
||||
|
||||
# macOS template icons — black logo silhouette on transparent
|
||||
# Read logo.svg paths, render as black on transparent
|
||||
logo_svg = LOGO_SVG.read_text()
|
||||
|
||||
for name, size in [("tray-iconTemplate.png", 22), ("tray-iconTemplate@2x.png", 44)]:
|
||||
# Create SVG with black fill, sized to target
|
||||
template_svg = _make_template_svg(logo_svg, size)
|
||||
img = svg_bytes_to_image(template_svg.encode("utf-8"), size)
|
||||
img.save(ASSETS / name, format="PNG")
|
||||
print(f" → {ASSETS / name}")
|
||||
|
||||
|
||||
def _make_template_svg(logo_svg_content: str, target_size: int) -> str:
|
||||
"""Create a macOS template SVG: black logo silhouette on transparent bg."""
|
||||
# Logo viewBox is 0 0 122 126 — scale to fit target with padding
|
||||
padding = max(2, int(target_size * 0.1))
|
||||
available = target_size - 2 * padding
|
||||
# Scale to fit height (taller dimension)
|
||||
scale = available / 126
|
||||
logo_w = 122 * scale
|
||||
# Center horizontally
|
||||
offset_x = (target_size - logo_w) / 2
|
||||
offset_y = padding
|
||||
|
||||
return f"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {target_size} {target_size}" fill="none">
|
||||
<g transform="translate({offset_x:.1f} {offset_y:.1f}) scale({scale:.4f})">
|
||||
<path fill="#000000" d="M 76.604813 111.661926 C 65.070183 111.662621 53.987053 111.662621 42.757744 111.662621 C 45.2239 101.998177 45.297546 101.949265 53.830105 102.036659 C 57.279823 102.071999 60.730202 102.04248 64.259201 102.04248 C 62.159458 93.316338 61.799103 93.009819 51.340881 91.341682 C 59.312901 73.847687 67.193756 56.553749 75.587578 38.134155 C 86.933441 62.965954 97.804642 86.758881 108.711754 110.630394 C 100.707413 111.871689 92.355965 107.246559 88.107536 98.852119 C 84.207748 91.146538 80.800591 83.191696 77.172813 75.348343 C 76.340195 73.54818 75.492958 71.754791 74.614624 69.877441 C 69.107384 75.691788 68.008286 81.475128 71.005882 88.088753 C 74.413452 95.606903 77.813919 103.128281 81.452705 111.167984 C 79.55114 111.381325 78.303734 111.521271 76.604813 111.661926 Z"/>
|
||||
<path fill="#000000" d="M 26.805992 87.743256 C 38.616821 61.830872 50.261688 36.260269 62.470131 9.452141 C 65.732315 17.818909 71.145126 23.524567 66.258224 33.086182 C 55.518253 54.099815 46.339973 75.911873 36.533691 97.402336 C 32.699284 105.805443 26.495394 110.823158 16.012421 111.410957 C 19.72117 103.270836 23.180601 95.67794 26.805992 87.743256 Z"/>
|
||||
</g>
|
||||
</svg>"""
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> None:
|
||||
ASSETS.mkdir(parents=True, exist_ok=True)
|
||||
LANDING.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
generate_web_icons()
|
||||
generate_app_icons()
|
||||
generate_tray_icons()
|
||||
|
||||
print("\nDone! All icons generated.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Patch the Electron.app bundle name so macOS shows "AgentCompany"
|
||||
# in the dock and menu bar during development.
|
||||
# This runs as a postinstall hook and is safe to re-run.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
APP_NAME="AgentCompany"
|
||||
|
||||
# Find the Electron.app Info.plist (works with pnpm hoisted or nested)
|
||||
PLIST=$(find node_modules -path "*/electron/dist/Electron.app/Contents/Info.plist" -maxdepth 8 2>/dev/null | head -1)
|
||||
|
||||
if [ -z "$PLIST" ]; then
|
||||
echo "[patch-electron-name] Electron.app not found, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Also find helper plists
|
||||
HELPERS_DIR=$(dirname "$PLIST")/../Frameworks
|
||||
MAIN_PLIST="$PLIST"
|
||||
|
||||
echo "[patch-electron-name] Patching $MAIN_PLIST"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$MAIN_PLIST" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName $APP_NAME" "$MAIN_PLIST" 2>/dev/null || true
|
||||
|
||||
# Patch helper apps too (they show in Activity Monitor)
|
||||
for HELPER in "$HELPERS_DIR"/Electron\ Helper*.app/Contents/Info.plist; do
|
||||
if [ -f "$HELPER" ]; then
|
||||
HELPER_NAME=$(basename "$(dirname "$(dirname "$HELPER")")" .app | sed "s/Electron Helper/$APP_NAME Helper/")
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleName $HELPER_NAME" "$HELPER" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
echo "[patch-electron-name] Done — dock and menu bar will show \"$APP_NAME\"."
|
||||
@@ -1,34 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const patchScriptPath = join(__dirname, "patch-electron-name.sh");
|
||||
|
||||
if (process.platform !== "darwin") {
|
||||
console.log("[postinstall] Skipping macOS Electron plist patch on non-darwin host.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!existsSync(patchScriptPath)) {
|
||||
console.log("[postinstall] No Electron name patch script found.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const result = spawnSync("bash", [patchScriptPath], {
|
||||
cwd: dirname(__dirname),
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(`[postinstall] Electron name patch failed: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if ((result.status ?? 0) !== 0) {
|
||||
console.error(`[postinstall] Electron name patch exited with status ${result.status}.`);
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
@@ -1,246 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile, mkdir, cp, readdir } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadEnvFile } from "./release/lib/dotenv.mjs";
|
||||
import { createRunner } from "./release/lib/process.mjs";
|
||||
import { createWorkflowHelpers } from "./release/lib/workflow.mjs";
|
||||
import { createSnapshotHelpers } from "./release/lib/snapshot.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, "..");
|
||||
const packageJsonPath = join(rootDir, "package.json");
|
||||
const bossOutDir = join(rootDir, "boss-build", "build", "out");
|
||||
const remoteBossWorkflowFile = ".github/workflows/remote-boss-platforms.yml";
|
||||
const remoteBossWorkflowName = "remote-boss-platforms.yml";
|
||||
const versionPattern = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/;
|
||||
const syncExcludes = [
|
||||
".git",
|
||||
"node_modules/",
|
||||
".pnpm-store/",
|
||||
"build/",
|
||||
"local-release/",
|
||||
"out/",
|
||||
".omx/",
|
||||
".omc/",
|
||||
".claude/",
|
||||
"landing/.next/",
|
||||
"landing/out/",
|
||||
"landing/node_modules/",
|
||||
".DS_Store",
|
||||
".env.release.local",
|
||||
"CertificateSigningRequest.certSigningRequest",
|
||||
"Certificates.p12",
|
||||
"developerID_application.cer",
|
||||
];
|
||||
const bossSnapshotIncludes = [
|
||||
remoteBossWorkflowFile,
|
||||
"boss-build/boss-runtime.json",
|
||||
"boss-build/build-boss-edition.mjs",
|
||||
"boss-build/electron-builder-boss.yml",
|
||||
];
|
||||
|
||||
const run = createRunner(rootDir);
|
||||
const {
|
||||
getGitHubHttpAuthHeader,
|
||||
getRepoInfo,
|
||||
assertRemoteWorkflowExists,
|
||||
waitForWorkflowRun,
|
||||
waitForWorkflowCompletion,
|
||||
downloadRemoteAssets,
|
||||
cleanupRemoteTransportRelease,
|
||||
} = createWorkflowHelpers(run);
|
||||
const {
|
||||
createSnapshotBranch,
|
||||
cleanupRemoteBranch,
|
||||
cleanupLocalWorktree,
|
||||
} = createSnapshotHelpers(run, getGitHubHttpAuthHeader);
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
version: null,
|
||||
deleteRemoteBranch: false,
|
||||
keepTransportRelease: false,
|
||||
};
|
||||
|
||||
for (const arg of argv) {
|
||||
if (arg.startsWith("--version=")) {
|
||||
options.version = arg.slice("--version=".length).trim();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--delete-remote-branch") {
|
||||
options.deleteRemoteBranch = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--keep-transport-release") {
|
||||
options.keepTransportRelease = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help") {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: node scripts/release-boss.mjs [options]
|
||||
|
||||
Options:
|
||||
--version=x.y.z Explicit version. Defaults to current package version.
|
||||
--delete-remote-branch Delete the GitHub snapshot branch after success.
|
||||
--keep-transport-release Keep the temporary draft GitHub release after success.
|
||||
--help Show this help.
|
||||
`);
|
||||
}
|
||||
|
||||
async function readPackageJson() {
|
||||
return JSON.parse(await readFile(packageJsonPath, "utf8"));
|
||||
}
|
||||
|
||||
async function forceStageBossSnapshotFiles(tempDir) {
|
||||
const stagedPaths = bossSnapshotIncludes
|
||||
.filter((relativePath) => existsSync(join(tempDir, relativePath)));
|
||||
|
||||
if (stagedPaths.length === 0) {
|
||||
throw new Error("Boss snapshot is missing required boss-build source files after syncing the workspace.");
|
||||
}
|
||||
|
||||
await run("git", ["-C", tempDir, "add", "--force", ...stagedPaths]);
|
||||
}
|
||||
|
||||
async function ensureRemoteBuildPrerequisites(repo) {
|
||||
if (process.platform !== "darwin") {
|
||||
throw new Error("Boss release currently expects to run on macOS for the local Boss DMG build.");
|
||||
}
|
||||
if (!existsSync(join(rootDir, remoteBossWorkflowFile))) {
|
||||
throw new Error(`Required workflow file missing locally: ${remoteBossWorkflowFile}`);
|
||||
}
|
||||
await assertRemoteWorkflowExists(
|
||||
repo,
|
||||
remoteBossWorkflowFile,
|
||||
`Remote workflow ${remoteBossWorkflowFile} was not found on ${repo.defaultBranch}. ` +
|
||||
"Commit and push this Boss automation workflow before using scripts/release-boss.mjs.",
|
||||
);
|
||||
await run("gh", ["auth", "status"], { capture: true });
|
||||
}
|
||||
|
||||
function buildSnapshotCommitMessage(version) {
|
||||
return [
|
||||
`Package Boss build ${version} from the current workspace snapshot`,
|
||||
"",
|
||||
"Constraint: Boss Windows artifacts are built on a native GitHub runner from a temporary snapshot branch",
|
||||
"Rejected: Build Boss Windows locally on Apple Silicon | docker/wine packaging is not stable on this host",
|
||||
"Confidence: medium",
|
||||
"Scope-risk: narrow",
|
||||
"Directive: This snapshot branch is transport-only and should be deleted after the Boss build is recovered locally",
|
||||
"Tested: local Boss mac build stages before snapshot creation",
|
||||
"Not-tested: remote Boss Windows packaging before workflow execution",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function dispatchRemoteBossWorkflow(repo, branchName, version, releaseTag, releaseTitle) {
|
||||
await run("gh", [
|
||||
"workflow",
|
||||
"run",
|
||||
remoteBossWorkflowName,
|
||||
"--repo",
|
||||
repo.nameWithOwner,
|
||||
"--ref",
|
||||
branchName,
|
||||
"-f",
|
||||
`version=${version}`,
|
||||
"-f",
|
||||
`release_tag=${releaseTag}`,
|
||||
"-f",
|
||||
`release_title=${releaseTitle}`,
|
||||
]);
|
||||
}
|
||||
|
||||
async function stageBossRemoteAssets(downloadRoot) {
|
||||
await mkdir(bossOutDir, { recursive: true });
|
||||
const files = await readdir(downloadRoot);
|
||||
for (const fileName of files) {
|
||||
if (fileName.endsWith(".exe") || fileName.endsWith(".blockmap")) {
|
||||
await cp(join(downloadRoot, fileName), join(bossOutDir, fileName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function buildBossMac(version) {
|
||||
await run("node", ["boss-build/build-boss-edition.mjs", `--platform=mac`, `--version=${version}`]);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
await loadEnvFile(join(rootDir, ".env.release.local"));
|
||||
await loadEnvFile(join(rootDir, ".env.release"));
|
||||
|
||||
const repo = await getRepoInfo();
|
||||
await ensureRemoteBuildPrerequisites(repo);
|
||||
await run("pnpm", ["audit:deps"]);
|
||||
|
||||
const currentVersion = (await readPackageJson()).version;
|
||||
const nextVersion = options.version ?? currentVersion;
|
||||
if (!versionPattern.test(nextVersion)) {
|
||||
throw new Error(`Invalid version: ${nextVersion}`);
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const releaseTag = `boss-transport-${nextVersion}-${timestamp}`;
|
||||
const branchName = `boss-build-${nextVersion.replace(/\./g, "-")}-${timestamp}`;
|
||||
const releaseTitle = `Boss Release ${nextVersion}`;
|
||||
|
||||
const tempDir = await createSnapshotBranch({
|
||||
repo,
|
||||
branchName,
|
||||
rootDir,
|
||||
syncExcludes,
|
||||
commitMessage: buildSnapshotCommitMessage(nextVersion),
|
||||
afterRsync: forceStageBossSnapshotFiles,
|
||||
});
|
||||
let cleanupPending = true;
|
||||
|
||||
try {
|
||||
await dispatchRemoteBossWorkflow(repo, branchName, nextVersion, releaseTag, releaseTitle);
|
||||
await buildBossMac(nextVersion);
|
||||
const runSummary = await waitForWorkflowRun(repo, remoteBossWorkflowName, branchName, 60_000);
|
||||
const runInfo = await waitForWorkflowCompletion(repo, runSummary.databaseId, 30 * 60 * 1000);
|
||||
if (runInfo.conclusion !== "success") {
|
||||
throw new Error(`Boss remote workflow failed: ${runInfo.url}`);
|
||||
}
|
||||
const downloadRoot = await downloadRemoteAssets(repo, releaseTag);
|
||||
await mkdir(bossOutDir, { recursive: true });
|
||||
await stageBossRemoteAssets(downloadRoot);
|
||||
|
||||
if (!options.keepTransportRelease) {
|
||||
await cleanupRemoteTransportRelease(repo, releaseTag);
|
||||
}
|
||||
if (options.deleteRemoteBranch) {
|
||||
await cleanupRemoteBranch(repo, branchName);
|
||||
}
|
||||
await cleanupLocalWorktree(tempDir);
|
||||
cleanupPending = false;
|
||||
|
||||
console.log(JSON.stringify({
|
||||
version: nextVersion,
|
||||
remoteBranch: branchName,
|
||||
remoteWorkflowRun: runInfo.url,
|
||||
bossOutDir,
|
||||
releaseTag,
|
||||
}, null, 2));
|
||||
} finally {
|
||||
if (cleanupPending) {
|
||||
try {
|
||||
await cleanupLocalWorktree(tempDir);
|
||||
} catch {
|
||||
// no-op during best-effort temp cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -1,200 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { readFile, writeFile, mkdir, rm, readdir } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { prepareR2ReleaseHost, purgeReleasePrefix, uploadReleaseFiles } from "./release-local/r2.mjs";
|
||||
import { loadEnvFile } from "./release/lib/dotenv.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, "..");
|
||||
const packageJsonPath = join(rootDir, "package.json");
|
||||
const buildReleaseDir = join(rootDir, "build", "release");
|
||||
const localReleaseDir = join(rootDir, "local-release");
|
||||
const versionPattern = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { version: null, skipBoss: false, bump: true };
|
||||
for (const arg of argv) {
|
||||
if (arg.startsWith("--version=")) {
|
||||
options.version = arg.slice("--version=".length).trim();
|
||||
options.bump = false;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--no-bump") {
|
||||
options.bump = false;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--skip-boss") {
|
||||
options.skipBoss = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help") {
|
||||
console.log(`Usage: node scripts/release-local-mac.mjs [options]
|
||||
|
||||
Options:
|
||||
--version=x.y.z Explicit version (disables auto-bump).
|
||||
--no-bump Use current package.json version without bumping.
|
||||
--skip-boss Build public Mac only; skip Boss DMG.
|
||||
--help Show this help.
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
async function readPackageJson() {
|
||||
return JSON.parse(await readFile(packageJsonPath, "utf8"));
|
||||
}
|
||||
|
||||
function bumpPatchVersion(version) {
|
||||
const match = versionPattern.exec(version);
|
||||
if (!match) throw new Error(`Unsupported package version format: ${version}`);
|
||||
const [, major, minor, patch] = match;
|
||||
return `${major}.${minor}.${Number.parseInt(patch, 10) + 1}`;
|
||||
}
|
||||
|
||||
async function updatePackageVersion(nextVersion) {
|
||||
const pkg = await readPackageJson();
|
||||
pkg.version = nextVersion;
|
||||
await writeFile(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function hasMacSigningCredential() {
|
||||
return Boolean(process.env.CSC_LINK?.trim() || process.env.CSC_NAME?.trim());
|
||||
}
|
||||
|
||||
function hasMacNotarizationCredential() {
|
||||
return Boolean(
|
||||
(process.env.APPLE_ID?.trim() &&
|
||||
process.env.APPLE_APP_SPECIFIC_PASSWORD?.trim() &&
|
||||
process.env.APPLE_TEAM_ID?.trim()) ||
|
||||
(process.env.APPLE_API_KEY?.trim() &&
|
||||
process.env.APPLE_API_KEY_ID?.trim() &&
|
||||
process.env.APPLE_API_ISSUER?.trim()),
|
||||
);
|
||||
}
|
||||
|
||||
function validatePreflight() {
|
||||
if (process.platform !== "darwin") {
|
||||
throw new Error("release:local-mac must run on macOS for signed Mac builds.");
|
||||
}
|
||||
if (!hasMacSigningCredential() || !hasMacNotarizationCredential()) {
|
||||
throw new Error(
|
||||
"Missing Mac signing/notarization credentials. Set CSC_LINK (or CSC_NAME) and " +
|
||||
"APPLE_ID/APPLE_APP_SPECIFIC_PASSWORD/APPLE_TEAM_ID or the APPLE_API_KEY trio.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function run(command, args, env = process.env) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: rootDir,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`${command} ${args.join(" ")} failed with exit code ${code ?? 1}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function buildPublicMac() {
|
||||
const env = {
|
||||
...process.env,
|
||||
AGENTCOMPANY_ELECTRON_PUBLISH: "never",
|
||||
AGENTCOMPANY_ENABLE_LOCAL_DMG: "true",
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "true",
|
||||
};
|
||||
await run("pnpm", ["exec", "electron-vite", "build"], env);
|
||||
await run("node", ["scripts/build-desktop.mjs", "--mac", "--arm64", "--x64"], env);
|
||||
}
|
||||
|
||||
async function stageMac() {
|
||||
const destination = join(localReleaseDir, "mac");
|
||||
await rm(destination, { recursive: true, force: true });
|
||||
await mkdir(localReleaseDir, { recursive: true });
|
||||
await run("cp", ["-R", buildReleaseDir, destination]);
|
||||
console.log(`[release-local-mac] staged mac artifacts in ${destination}`);
|
||||
return destination;
|
||||
}
|
||||
|
||||
async function buildBossMac(version) {
|
||||
await run("node", ["boss-build/build-boss-edition.mjs", `--platform=mac`, `--version=${version}`]);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
await loadEnvFile(join(rootDir, ".env.release.local"));
|
||||
await loadEnvFile(join(rootDir, ".env.release"));
|
||||
process.env.AGENTCOMPANY_ELECTRON_PUBLISH = "never";
|
||||
|
||||
validatePreflight();
|
||||
await run("pnpm", ["audit:deps"]);
|
||||
|
||||
const currentVersion = (await readPackageJson()).version;
|
||||
const nextVersion = options.version ?? (options.bump ? bumpPatchVersion(currentVersion) : currentVersion);
|
||||
if (!versionPattern.test(nextVersion)) throw new Error(`Invalid version: ${nextVersion}`);
|
||||
if (nextVersion !== currentVersion) await updatePackageVersion(nextVersion);
|
||||
|
||||
await rm(localReleaseDir, { recursive: true, force: true });
|
||||
await rm(buildReleaseDir, { recursive: true, force: true });
|
||||
|
||||
const r2 = await prepareR2ReleaseHost({ rootDir });
|
||||
console.log(`[release-local-mac] update URL: ${process.env.AGENTCOMPANY_UPDATE_URL}`);
|
||||
console.log(`[release-local-mac] version: ${nextVersion}`);
|
||||
|
||||
// Purge only Mac artifacts — Win/Linux (from the Windows release script) stay put.
|
||||
await purgeReleasePrefix({
|
||||
bucket: r2.bucket,
|
||||
releasePrefix: r2.releasePrefix,
|
||||
keep: ["win", "linux", "other"],
|
||||
});
|
||||
|
||||
await buildPublicMac();
|
||||
await stageMac();
|
||||
await uploadReleaseFiles({
|
||||
rootDir,
|
||||
releaseDir: buildReleaseDir,
|
||||
bucket: r2.bucket,
|
||||
releasePrefix: r2.releasePrefix,
|
||||
});
|
||||
|
||||
if (!options.skipBoss) {
|
||||
await buildBossMac(nextVersion);
|
||||
}
|
||||
|
||||
await mkdir(localReleaseDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(localReleaseDir, "manifest.json"),
|
||||
JSON.stringify({
|
||||
generatedAt: new Date().toISOString(),
|
||||
command: "release:local-mac",
|
||||
version: nextVersion,
|
||||
updateUrl: process.env.AGENTCOMPANY_UPDATE_URL,
|
||||
}, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const bossFiles = options.skipBoss
|
||||
? []
|
||||
: (await readdir(join(rootDir, "boss-build", "build", "out")).catch(() => []))
|
||||
.filter((name) => name.endsWith(".dmg") || name.endsWith(".zip"));
|
||||
|
||||
console.log(JSON.stringify({
|
||||
version: nextVersion,
|
||||
updateUrl: process.env.AGENTCOMPANY_UPDATE_URL,
|
||||
macStagedIn: join(localReleaseDir, "mac"),
|
||||
bossArtifacts: bossFiles,
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -1,223 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { readFile, writeFile, mkdir, rm, readdir } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { prepareR2ReleaseHost, purgeReleasePrefix, uploadReleaseFiles } from "./release-local/r2.mjs";
|
||||
import { loadEnvFile } from "./release/lib/dotenv.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, "..");
|
||||
const packageJsonPath = join(rootDir, "package.json");
|
||||
const buildReleaseDir = join(rootDir, "build", "release");
|
||||
const localReleaseDir = join(rootDir, "local-release");
|
||||
const versionPattern = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { version: null, skipBoss: false, only: null };
|
||||
for (const arg of argv) {
|
||||
if (arg.startsWith("--version=")) {
|
||||
options.version = arg.slice("--version=".length).trim();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--skip-boss") {
|
||||
options.skipBoss = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--win-only") {
|
||||
options.only = "win";
|
||||
continue;
|
||||
}
|
||||
if (arg === "--linux-only") {
|
||||
options.only = "linux";
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help") {
|
||||
console.log(`Usage: node scripts/release-local-winlinux.mjs [options]
|
||||
|
||||
Options:
|
||||
--version=x.y.z Override version (defaults to current package.json).
|
||||
--win-only Build and upload Windows only.
|
||||
--linux-only Build and upload Linux only.
|
||||
--skip-boss Skip Boss Windows EXE build.
|
||||
--help Show this help.
|
||||
|
||||
Environment:
|
||||
On Windows host: Windows builds run natively via electron-builder.
|
||||
On any host: Linux builds run through Docker + electronuserland/builder image.
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
async function readPackageJson() {
|
||||
return JSON.parse(await readFile(packageJsonPath, "utf8"));
|
||||
}
|
||||
|
||||
async function run(command, args, env = process.env) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: rootDir,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`${command} ${args.join(" ")} failed with exit code ${code ?? 1}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function hasDockerInstalled() {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("docker", ["--version"], { stdio: "ignore" });
|
||||
child.on("error", () => resolve(false));
|
||||
child.on("close", (code) => resolve(code === 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function buildPublicWinNative() {
|
||||
const env = {
|
||||
...process.env,
|
||||
AGENTCOMPANY_ELECTRON_PUBLISH: "never",
|
||||
CSC_LINK: process.env.WIN_CSC_LINK ?? "",
|
||||
CSC_KEY_PASSWORD: process.env.WIN_CSC_KEY_PASSWORD ?? "",
|
||||
};
|
||||
await run("pnpm", ["exec", "electron-vite", "build"], env);
|
||||
await run("node", ["scripts/build-desktop.mjs", "--win", "nsis", "--x64"], env);
|
||||
await run("node", ["scripts/build-desktop.mjs", "--win", "nsis", "--arm64"], env);
|
||||
}
|
||||
|
||||
async function buildPublicLinuxDocker() {
|
||||
if (!(await hasDockerInstalled())) {
|
||||
throw new Error("Docker is required for Linux builds. Install Docker Desktop and retry.");
|
||||
}
|
||||
// Reuse the existing Docker-based Linux build path from release-local.mjs by
|
||||
// shelling into it with --platforms=linux --no-upload. That keeps one source
|
||||
// of truth for the container mounts.
|
||||
await run("node", [
|
||||
"scripts/release-local.mjs",
|
||||
"--platforms=linux",
|
||||
"--no-upload",
|
||||
"--skip-verify",
|
||||
]);
|
||||
}
|
||||
|
||||
async function stage(platform) {
|
||||
const destination = join(localReleaseDir, platform);
|
||||
await rm(destination, { recursive: true, force: true });
|
||||
await mkdir(localReleaseDir, { recursive: true });
|
||||
await run(process.platform === "win32" ? "xcopy" : "cp", process.platform === "win32"
|
||||
? [buildReleaseDir, destination, "/E", "/I", "/Y"]
|
||||
: ["-R", buildReleaseDir, destination]);
|
||||
console.log(`[release-local-winlinux] staged ${platform} artifacts in ${destination}`);
|
||||
return destination;
|
||||
}
|
||||
|
||||
async function buildBossWin(version) {
|
||||
if (process.platform !== "win32") {
|
||||
throw new Error("Boss Windows EXE build must run on Windows (electron-builder --win native).");
|
||||
}
|
||||
await run("node", ["boss-build/build-boss-edition.mjs", `--platform=win`, `--version=${version}`]);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
await loadEnvFile(join(rootDir, ".env.release.local"));
|
||||
await loadEnvFile(join(rootDir, ".env.release"));
|
||||
process.env.AGENTCOMPANY_ELECTRON_PUBLISH = "never";
|
||||
|
||||
const pkgVersion = (await readPackageJson()).version;
|
||||
const nextVersion = options.version ?? pkgVersion;
|
||||
if (!versionPattern.test(nextVersion)) throw new Error(`Invalid version: ${nextVersion}`);
|
||||
if (nextVersion !== pkgVersion) {
|
||||
console.log(`[release-local-winlinux] version override: ${pkgVersion} -> ${nextVersion}`);
|
||||
}
|
||||
await run("pnpm", ["audit:deps"]);
|
||||
|
||||
await rm(localReleaseDir, { recursive: true, force: true });
|
||||
await rm(buildReleaseDir, { recursive: true, force: true });
|
||||
|
||||
const r2 = await prepareR2ReleaseHost({ rootDir });
|
||||
console.log(`[release-local-winlinux] update URL: ${process.env.AGENTCOMPANY_UPDATE_URL}`);
|
||||
console.log(`[release-local-winlinux] version: ${nextVersion}`);
|
||||
|
||||
// Purge only Win+Linux artifacts — Mac files (owned by release-local-mac) stay put.
|
||||
const keepClasses = options.only === "win"
|
||||
? ["mac", "linux", "other"]
|
||||
: options.only === "linux"
|
||||
? ["mac", "win", "other"]
|
||||
: ["mac", "other"];
|
||||
await purgeReleasePrefix({
|
||||
bucket: r2.bucket,
|
||||
releasePrefix: r2.releasePrefix,
|
||||
keep: keepClasses,
|
||||
});
|
||||
|
||||
if (options.only !== "linux") {
|
||||
if (process.platform !== "win32") {
|
||||
throw new Error(
|
||||
"release:local-winlinux Windows builds must run on a Windows host. " +
|
||||
"Use --linux-only on non-Windows machines, or run the full command on the Windows box.",
|
||||
);
|
||||
}
|
||||
await buildPublicWinNative();
|
||||
await stage("win");
|
||||
await uploadReleaseFiles({
|
||||
rootDir,
|
||||
releaseDir: join(localReleaseDir, "win"),
|
||||
bucket: r2.bucket,
|
||||
releasePrefix: r2.releasePrefix,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.only !== "win") {
|
||||
await buildPublicLinuxDocker();
|
||||
// release-local.mjs already staged into local-release/linux when it ran.
|
||||
// Upload from there so we do not wipe the staged tree.
|
||||
await uploadReleaseFiles({
|
||||
rootDir,
|
||||
releaseDir: join(localReleaseDir, "linux"),
|
||||
bucket: r2.bucket,
|
||||
releasePrefix: r2.releasePrefix,
|
||||
});
|
||||
}
|
||||
|
||||
if (!options.skipBoss && options.only !== "linux" && process.platform === "win32") {
|
||||
await buildBossWin(nextVersion);
|
||||
}
|
||||
|
||||
await mkdir(localReleaseDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(localReleaseDir, "manifest.json"),
|
||||
JSON.stringify({
|
||||
generatedAt: new Date().toISOString(),
|
||||
command: "release:local-winlinux",
|
||||
version: nextVersion,
|
||||
only: options.only,
|
||||
updateUrl: process.env.AGENTCOMPANY_UPDATE_URL,
|
||||
}, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const bossFiles = options.skipBoss || process.platform !== "win32"
|
||||
? []
|
||||
: (await readdir(join(rootDir, "boss-build", "build", "out")).catch(() => []))
|
||||
.filter((name) => name.endsWith(".exe"));
|
||||
|
||||
console.log(JSON.stringify({
|
||||
version: nextVersion,
|
||||
only: options.only,
|
||||
updateUrl: process.env.AGENTCOMPANY_UPDATE_URL,
|
||||
bossArtifacts: bossFiles,
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -1,306 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { cp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { prepareR2ReleaseHost, uploadReleaseFiles } from "./release-local/r2.mjs";
|
||||
import { loadEnvFile } from "./release/lib/dotenv.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, "..");
|
||||
const buildReleaseDir = join(rootDir, "build", "release");
|
||||
const localReleaseDir = join(rootDir, "local-release");
|
||||
const pnpmVersion = "10.24.0";
|
||||
const checkoutHash = createHash("sha1").update(rootDir).digest("hex").slice(0, 10);
|
||||
const dockerNodeModulesVolume = `agentcompany-node-modules-${checkoutHash}`;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
skipVerify: false,
|
||||
upload: true,
|
||||
platforms: ["mac", "win", "linux"],
|
||||
};
|
||||
for (const arg of argv) {
|
||||
if (arg === "--skip-verify") {
|
||||
options.skipVerify = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--no-upload") {
|
||||
options.upload = false;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--platforms=")) {
|
||||
options.platforms = arg.slice("--platforms=".length)
|
||||
.split(",")
|
||||
.map((platform) => platform.trim())
|
||||
.filter(Boolean);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help") {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: node scripts/release-local.mjs [options]
|
||||
|
||||
Options:
|
||||
--platforms=mac,win,linux Comma-separated platform list (default: all)
|
||||
--skip-verify Skip dependency audit and pnpm verify before release builds
|
||||
--no-upload Build artifacts locally without publishing
|
||||
--help Show this help
|
||||
|
||||
Environment loading:
|
||||
- .env.release
|
||||
- .env.release.local
|
||||
- existing shell environment (takes precedence)
|
||||
`);
|
||||
}
|
||||
|
||||
function hasDockerInstalled() {
|
||||
return spawnAndAwaitExitCode("docker", ["--version"]);
|
||||
}
|
||||
|
||||
function spawnAndAwaitExitCode(command, args) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, args, { stdio: "ignore" });
|
||||
child.on("error", () => resolve(false));
|
||||
child.on("close", (code) => resolve(code === 0));
|
||||
});
|
||||
}
|
||||
|
||||
function hasMacSigningCredential() {
|
||||
return Boolean(process.env.CSC_LINK?.trim() || process.env.CSC_NAME?.trim());
|
||||
}
|
||||
|
||||
function hasMacNotarizationCredential() {
|
||||
return Boolean(
|
||||
(process.env.APPLE_ID?.trim() &&
|
||||
process.env.APPLE_APP_SPECIFIC_PASSWORD?.trim() &&
|
||||
process.env.APPLE_TEAM_ID?.trim()) ||
|
||||
(process.env.APPLE_API_KEY?.trim() &&
|
||||
process.env.APPLE_API_KEY_ID?.trim() &&
|
||||
process.env.APPLE_API_ISSUER?.trim()),
|
||||
);
|
||||
}
|
||||
|
||||
async function validatePreflight(platforms, upload) {
|
||||
if (platforms.includes("mac") && process.platform !== "darwin") {
|
||||
throw new Error("Signed macOS release builds must run on macOS.");
|
||||
}
|
||||
if (platforms.includes("mac") && (!hasMacSigningCredential() || !hasMacNotarizationCredential())) {
|
||||
throw new Error(
|
||||
"Signed macOS release requires signing credentials (CSC_LINK or CSC_NAME) and notarization credentials " +
|
||||
"(APPLE_ID/APPLE_APP_SPECIFIC_PASSWORD/APPLE_TEAM_ID or APPLE_API_KEY trio).",
|
||||
);
|
||||
}
|
||||
if ((platforms.includes("win") || platforms.includes("linux")) && !(await hasDockerInstalled())) {
|
||||
throw new Error("Docker is required to build Windows/Linux locally from this macOS host.");
|
||||
}
|
||||
if (!upload) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function run(command, args, env = process.env) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: rootDir,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`${command} ${args.join(" ")} failed with exit code ${code ?? 1}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function stageRelease(platform) {
|
||||
const destination = join(localReleaseDir, platform);
|
||||
await rm(destination, { recursive: true, force: true });
|
||||
await mkdir(localReleaseDir, { recursive: true });
|
||||
await cp(buildReleaseDir, destination, { recursive: true });
|
||||
console.log(`[release-local] staged ${platform} artifacts in ${destination}`);
|
||||
}
|
||||
|
||||
function makeDockerEnv(platform) {
|
||||
const env = {
|
||||
AGENTCOMPANY_ELECTRON_PUBLISH: "never",
|
||||
AGENTCOMPANY_UPDATE_URL: process.env.AGENTCOMPANY_UPDATE_URL ?? "",
|
||||
AGENTCOMPANY_ELECTRON_DIST: "/root/.cache/electron",
|
||||
};
|
||||
if (platform === "win") {
|
||||
env.CSC_LINK = process.env.WIN_CSC_LINK ?? "";
|
||||
env.CSC_KEY_PASSWORD = process.env.WIN_CSC_KEY_PASSWORD ?? "";
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function getDockerBuildCommands(platform) {
|
||||
if (platform === "win") {
|
||||
return [
|
||||
"node scripts/build-desktop.mjs --win nsis --x64",
|
||||
"node scripts/build-desktop.mjs --win nsis --arm64",
|
||||
];
|
||||
}
|
||||
return [
|
||||
"node scripts/build-desktop.mjs --linux AppImage deb rpm --x64",
|
||||
"node scripts/build-desktop.mjs --linux AppImage deb rpm --arm64",
|
||||
];
|
||||
}
|
||||
|
||||
// Paths under rootDir that the container needs. Credentials, env files, docs,
|
||||
// screenshots, and other host-only state are deliberately
|
||||
// excluded to keep secrets out of the Wine/Linux builder image.
|
||||
const containerSourceMounts = [
|
||||
{ host: "src", mode: "ro" },
|
||||
{ host: "scripts", mode: "ro" },
|
||||
{ host: "assets", mode: "ro" },
|
||||
{ host: "builtin-skills", mode: "ro" },
|
||||
{ host: "electron-builder.yml", mode: "ro" },
|
||||
{ host: "package.json", mode: "ro" },
|
||||
{ host: "pnpm-lock.yaml", mode: "ro" },
|
||||
{ host: "electron.vite.config.ts", mode: "ro" },
|
||||
{ host: "tsconfig.json", mode: "ro" },
|
||||
{ host: "tsconfig.base.json", mode: "ro" },
|
||||
{ host: "tsconfig.check.json", mode: "ro" },
|
||||
{ host: "eslint.config.mjs", mode: "ro" },
|
||||
];
|
||||
|
||||
// Writable host paths — electron-vite writes to out/, electron-builder writes to build/.
|
||||
const containerOutputMounts = [
|
||||
{ host: "out" },
|
||||
{ host: "build" },
|
||||
];
|
||||
|
||||
async function runDockerBuild(platform) {
|
||||
const isWindows = platform === "win";
|
||||
const image = isWindows ? "electronuserland/builder:wine" : "electronuserland/builder";
|
||||
const env = makeDockerEnv(platform);
|
||||
const cacheDir = join(os.homedir(), ".cache");
|
||||
const command = [
|
||||
"corepack enable",
|
||||
`corepack prepare pnpm@${pnpmVersion} --activate`,
|
||||
"pnpm install --frozen-lockfile",
|
||||
"pnpm exec electron-vite build",
|
||||
...getDockerBuildCommands(platform),
|
||||
].join(" && ");
|
||||
// Ensure host-side output dirs exist before binding (docker would otherwise create
|
||||
// them as root-owned on Linux hosts).
|
||||
for (const mount of containerOutputMounts) {
|
||||
await mkdir(join(rootDir, mount.host), { recursive: true });
|
||||
}
|
||||
const dockerArgs = [
|
||||
"run",
|
||||
"--rm",
|
||||
"--platform", "linux/amd64",
|
||||
];
|
||||
for (const mount of containerSourceMounts) {
|
||||
dockerArgs.push("-v", `${join(rootDir, mount.host)}:/project/${mount.host}:${mount.mode}`);
|
||||
}
|
||||
for (const mount of containerOutputMounts) {
|
||||
dockerArgs.push("-v", `${join(rootDir, mount.host)}:/project/${mount.host}`);
|
||||
}
|
||||
dockerArgs.push(
|
||||
"-v", `${dockerNodeModulesVolume}:/project/node_modules`,
|
||||
"-v", `${join(cacheDir, "electron")}:/root/.cache/electron`,
|
||||
"-v", `${join(cacheDir, "electron-builder")}:/root/.cache/electron-builder`,
|
||||
"-w", "/project",
|
||||
);
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
dockerArgs.push("-e", `${key}=${value}`);
|
||||
}
|
||||
dockerArgs.push(image, "/bin/bash", "-lc", command);
|
||||
await run("docker", dockerArgs);
|
||||
}
|
||||
|
||||
async function buildMac() {
|
||||
const env = {
|
||||
...process.env,
|
||||
AGENTCOMPANY_ELECTRON_PUBLISH: "never",
|
||||
AGENTCOMPANY_ENABLE_LOCAL_DMG: "true",
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "true",
|
||||
};
|
||||
await run("pnpm", ["exec", "electron-vite", "build"], env);
|
||||
await run("node", ["scripts/build-desktop.mjs", "--mac", "--arm64", "--x64"], env);
|
||||
await stageRelease("mac");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
await loadEnvFile(join(rootDir, ".env.release.local"));
|
||||
await loadEnvFile(join(rootDir, ".env.release"));
|
||||
process.env.AGENTCOMPANY_ELECTRON_PUBLISH = "never";
|
||||
const r2 = options.upload ? await prepareR2ReleaseHost({ rootDir }) : null;
|
||||
await validatePreflight(options.platforms, options.upload);
|
||||
|
||||
console.log(`[release-local] update URL: ${process.env.AGENTCOMPANY_UPDATE_URL}`);
|
||||
console.log(`[release-local] upload mode: ${options.upload ? "enabled" : "disabled"}`);
|
||||
console.log(`[release-local] platforms: ${options.platforms.join(", ")}`);
|
||||
|
||||
await rm(localReleaseDir, { recursive: true, force: true });
|
||||
if (!options.skipVerify) {
|
||||
await run("pnpm", ["audit:deps"]);
|
||||
await run("pnpm", ["verify"]);
|
||||
}
|
||||
if (options.platforms.includes("mac")) {
|
||||
await buildMac();
|
||||
if (r2) {
|
||||
await uploadReleaseFiles({
|
||||
rootDir,
|
||||
releaseDir: buildReleaseDir,
|
||||
bucket: r2.bucket,
|
||||
releasePrefix: r2.releasePrefix,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (options.platforms.includes("win")) {
|
||||
await runDockerBuild("win");
|
||||
await stageRelease("win");
|
||||
if (r2) {
|
||||
await uploadReleaseFiles({
|
||||
rootDir,
|
||||
releaseDir: buildReleaseDir,
|
||||
bucket: r2.bucket,
|
||||
releasePrefix: r2.releasePrefix,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (options.platforms.includes("linux")) {
|
||||
await runDockerBuild("linux");
|
||||
await stageRelease("linux");
|
||||
if (r2) {
|
||||
await uploadReleaseFiles({
|
||||
rootDir,
|
||||
releaseDir: buildReleaseDir,
|
||||
bucket: r2.bucket,
|
||||
releasePrefix: r2.releasePrefix,
|
||||
});
|
||||
}
|
||||
}
|
||||
await mkdir(localReleaseDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(localReleaseDir, "manifest.json"),
|
||||
JSON.stringify({
|
||||
generatedAt: new Date().toISOString(),
|
||||
platforms: options.platforms,
|
||||
uploadEnabled: options.upload,
|
||||
updateUrl: process.env.AGENTCOMPANY_UPDATE_URL,
|
||||
}, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
console.log(`[release-local] complete. Local artifact staging: ${localReleaseDir}`);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -1,262 +0,0 @@
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { DeleteObjectsCommand, ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3";
|
||||
import { Upload } from "@aws-sdk/lib-storage";
|
||||
|
||||
const defaultBucket = "agentcompany-updates";
|
||||
const defaultPrefix = "AC";
|
||||
const ignoredReleaseFiles = new Set(["builder-debug.yml"]);
|
||||
|
||||
function normalizeBaseUrl(url) {
|
||||
return url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function normalizePathSegment(pathValue) {
|
||||
return pathValue.replace(/^\/+|\/+$/g, "");
|
||||
}
|
||||
|
||||
function guessContentType(fileName) {
|
||||
if (fileName.endsWith(".yml")) return "text/yaml; charset=utf-8";
|
||||
if (fileName.endsWith(".blockmap")) return "application/octet-stream";
|
||||
if (fileName.endsWith(".zip")) return "application/zip";
|
||||
if (fileName.endsWith(".dmg")) return "application/x-apple-diskimage";
|
||||
if (fileName.endsWith(".exe")) return "application/vnd.microsoft.portable-executable";
|
||||
if (fileName.endsWith(".AppImage")) return "application/octet-stream";
|
||||
if (fileName.endsWith(".deb")) return "application/vnd.debian.binary-package";
|
||||
if (fileName.endsWith(".rpm")) return "application/x-rpm";
|
||||
if (fileName.endsWith(".snap")) return "application/vnd.snap";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
async function runWrangler(args, cwd, capture = false) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn("npx", ["wrangler", ...args], {
|
||||
cwd,
|
||||
stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit",
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
if (capture) {
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
}
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr });
|
||||
return;
|
||||
}
|
||||
reject(new Error(stderr || stdout || `wrangler ${args.join(" ")} failed with exit code ${code ?? 1}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function resolveUpdateUrl(publicBaseUrl, releasePrefix) {
|
||||
const baseUrl = normalizeBaseUrl(publicBaseUrl);
|
||||
const prefix = normalizePathSegment(releasePrefix);
|
||||
return prefix ? `${baseUrl}/${prefix}` : baseUrl;
|
||||
}
|
||||
|
||||
function hasS3Credentials() {
|
||||
return Boolean(
|
||||
process.env.R2_ACCESS_KEY_ID?.trim() &&
|
||||
process.env.R2_SECRET_ACCESS_KEY?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function prepareR2ReleaseHost({ rootDir }) {
|
||||
const prefix = normalizePathSegment(process.env.AGENTCOMPANY_RELEASE_PREFIX?.trim() || defaultPrefix);
|
||||
const s3Mode = hasS3Credentials();
|
||||
|
||||
let accountId = process.env.R2_ACCOUNT_ID?.trim()
|
||||
|| process.env.AGENTCOMPANY_R2_ACCOUNT_ID?.trim()
|
||||
|| "";
|
||||
if (!accountId) {
|
||||
if (s3Mode) {
|
||||
throw new Error("R2 S3 credentials set but R2_ACCOUNT_ID missing. Add it to .env.release.local.");
|
||||
}
|
||||
const whoami = JSON.parse((await runWrangler(["whoami", "--json"], rootDir, true)).stdout);
|
||||
accountId = whoami.accounts?.[0]?.id;
|
||||
}
|
||||
if (!accountId) {
|
||||
throw new Error("Unable to resolve Cloudflare account ID. Set R2_ACCOUNT_ID in .env.release.local.");
|
||||
}
|
||||
|
||||
const bucket = process.env.R2_BUCKET?.trim()
|
||||
|| process.env.AGENTCOMPANY_R2_BUCKET?.trim()
|
||||
|| defaultBucket;
|
||||
|
||||
// Skip wrangler bucket-info/create when S3 creds are available: wrangler's
|
||||
// OAuth may be logged into a different Cloudflare account (see Win host
|
||||
// where the OAuth account has no R2 enabled). S3 API operations later in
|
||||
// the flow cover the real bucket existence check.
|
||||
if (!s3Mode) {
|
||||
try {
|
||||
await runWrangler(["r2", "bucket", "info", bucket, "--json"], rootDir, true);
|
||||
} catch (error) {
|
||||
if (!String(error).includes("does not exist")) {
|
||||
throw error;
|
||||
}
|
||||
await runWrangler(["r2", "bucket", "create", bucket], rootDir, false);
|
||||
}
|
||||
}
|
||||
|
||||
let publicBaseUrl = process.env.R2_PUBLIC_BASE_URL?.trim()
|
||||
|| process.env.AGENTCOMPANY_R2_PUBLIC_URL?.trim()
|
||||
|| "";
|
||||
|
||||
if (!publicBaseUrl) {
|
||||
if (s3Mode) {
|
||||
throw new Error(
|
||||
"R2 S3 credentials set but R2_PUBLIC_BASE_URL missing. Add it to .env.release.local " +
|
||||
"(e.g. R2_PUBLIC_BASE_URL=https://pub-<hash>.r2.dev).",
|
||||
);
|
||||
}
|
||||
const devUrlState = await runWrangler(["r2", "bucket", "dev-url", "get", bucket], rootDir, true)
|
||||
.then((result) => result.stdout)
|
||||
.catch(() => "");
|
||||
if (!devUrlState.includes("https://")) {
|
||||
await runWrangler(["r2", "bucket", "dev-url", "enable", bucket], rootDir, false);
|
||||
}
|
||||
const enabledOutput = (await runWrangler(["r2", "bucket", "dev-url", "get", bucket], rootDir, true)).stdout;
|
||||
const match = enabledOutput.match(/https:\/\/[^\s']+\.r2\.dev/);
|
||||
if (!match) {
|
||||
throw new Error("Unable to resolve R2 public dev URL from wrangler.");
|
||||
}
|
||||
publicBaseUrl = match[0];
|
||||
}
|
||||
|
||||
process.env.R2_ACCOUNT_ID = accountId;
|
||||
process.env.AGENTCOMPANY_R2_ACCOUNT_ID = accountId;
|
||||
process.env.R2_BUCKET = bucket;
|
||||
process.env.AGENTCOMPANY_R2_BUCKET = bucket;
|
||||
process.env.R2_PUBLIC_BASE_URL = publicBaseUrl;
|
||||
process.env.AGENTCOMPANY_R2_PUBLIC_URL = publicBaseUrl;
|
||||
process.env.AGENTCOMPANY_RELEASE_PREFIX = prefix;
|
||||
process.env.AGENTCOMPANY_R2_PATH = prefix;
|
||||
process.env.AGENTCOMPANY_UPDATE_URL = process.env.AGENTCOMPANY_UPDATE_URL?.trim()
|
||||
|| resolveUpdateUrl(publicBaseUrl, prefix);
|
||||
|
||||
return {
|
||||
accountId,
|
||||
bucket,
|
||||
publicBaseUrl,
|
||||
releasePrefix: prefix,
|
||||
updateUrl: process.env.AGENTCOMPANY_UPDATE_URL,
|
||||
};
|
||||
}
|
||||
|
||||
function createS3ClientForLargeUploads() {
|
||||
const accessKeyId = process.env.R2_ACCESS_KEY_ID?.trim();
|
||||
const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY?.trim();
|
||||
const endpoint = process.env.R2_S3_ENDPOINT?.trim()
|
||||
|| (process.env.R2_ACCOUNT_ID ? `https://${process.env.R2_ACCOUNT_ID.trim()}.r2.cloudflarestorage.com` : "");
|
||||
if (!accessKeyId || !secretAccessKey || !endpoint) {
|
||||
throw new Error(
|
||||
"Large file (>290 MiB) requires R2 S3 credentials. Set R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, and R2_S3_ENDPOINT (or R2_ACCOUNT_ID) in .env.release.local.",
|
||||
);
|
||||
}
|
||||
return new S3Client({
|
||||
region: "auto",
|
||||
endpoint,
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
forcePathStyle: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadViaS3Multipart({ bucket, key, filePath, fileName }) {
|
||||
const client = createS3ClientForLargeUploads();
|
||||
const upload = new Upload({
|
||||
client,
|
||||
params: {
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: createReadStream(filePath),
|
||||
ContentType: guessContentType(fileName),
|
||||
CacheControl: fileName.endsWith(".yml") ? "no-cache" : "public, max-age=31536000, immutable",
|
||||
},
|
||||
partSize: 64 * 1024 * 1024,
|
||||
queueSize: 4,
|
||||
});
|
||||
upload.on("httpUploadProgress", (p) => {
|
||||
if (p.loaded && p.total) {
|
||||
process.stdout.write(`\r[s3-multipart] ${key} ${(p.loaded / p.total * 100).toFixed(1)}%`);
|
||||
}
|
||||
});
|
||||
await upload.done();
|
||||
process.stdout.write(`\n[s3-multipart] ${key} uploaded\n`);
|
||||
}
|
||||
|
||||
export function classifyReleaseArtifact(fileName) {
|
||||
if (fileName === "latest-mac.yml") return "mac";
|
||||
if (/\.dmg(\.blockmap)?$/.test(fileName)) return "mac";
|
||||
if (/-mac\.zip(\.blockmap)?$/.test(fileName)) return "mac";
|
||||
if (fileName === "latest.yml") return "win";
|
||||
if (/\.exe(\.blockmap)?$/.test(fileName)) return "win";
|
||||
if (fileName === "latest-linux.yml") return "linux";
|
||||
if (/\.(AppImage|deb|rpm|snap)$/.test(fileName)) return "linux";
|
||||
return "other";
|
||||
}
|
||||
|
||||
export async function purgeReleasePrefix({ bucket, releasePrefix, keep }) {
|
||||
const client = createS3ClientForLargeUploads();
|
||||
const prefix = `${normalizePathSegment(releasePrefix)}/`;
|
||||
const keepClass = new Set(Array.isArray(keep) ? keep : []);
|
||||
let continuationToken;
|
||||
let total = 0;
|
||||
let skipped = 0;
|
||||
do {
|
||||
const list = await client.send(new ListObjectsV2Command({
|
||||
Bucket: bucket,
|
||||
Prefix: prefix,
|
||||
ContinuationToken: continuationToken,
|
||||
}));
|
||||
const candidates = list.Contents ?? [];
|
||||
const toDelete = [];
|
||||
for (const obj of candidates) {
|
||||
const baseName = obj.Key.slice(prefix.length);
|
||||
if (keepClass.has(classifyReleaseArtifact(baseName))) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
toDelete.push({ Key: obj.Key });
|
||||
}
|
||||
if (toDelete.length > 0) {
|
||||
await client.send(new DeleteObjectsCommand({
|
||||
Bucket: bucket,
|
||||
Delete: { Objects: toDelete, Quiet: true },
|
||||
}));
|
||||
total += toDelete.length;
|
||||
}
|
||||
continuationToken = list.IsTruncated ? list.NextContinuationToken : undefined;
|
||||
} while (continuationToken);
|
||||
console.log(
|
||||
`[r2-purge] deleted ${total} object(s) under ${bucket}/${prefix}` +
|
||||
(skipped > 0 ? ` (kept ${skipped} in classes: ${[...keepClass].join(",")})` : ""),
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadReleaseFiles({ rootDir, releaseDir, bucket, releasePrefix }) {
|
||||
const files = (await readdir(releaseDir, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isFile() && !ignoredReleaseFiles.has(entry.name))
|
||||
.map((entry) => entry.name);
|
||||
|
||||
for (const fileName of files) {
|
||||
const key = `${normalizePathSegment(releasePrefix)}/${fileName}`;
|
||||
const filePath = join(releaseDir, fileName);
|
||||
// Always use the S3 multipart path: wrangler's OAuth token commonly lacks
|
||||
// the r2 scope, which returns 401 on `r2 object put`. The S3 credentials
|
||||
// (R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY) bypass that mismatch and
|
||||
// handle every file size uniformly. `rootDir` is kept in the signature
|
||||
// for call-site compatibility even though the S3 path does not need it.
|
||||
void rootDir;
|
||||
await uploadViaS3Multipart({ bucket, key, filePath, fileName });
|
||||
}
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile, rm, writeFile, mkdir, cp, readdir } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { prepareR2ReleaseHost, purgeReleasePrefix, uploadReleaseFiles } from "./release-local/r2.mjs";
|
||||
import { loadEnvFile } from "./release/lib/dotenv.mjs";
|
||||
import { createRunner } from "./release/lib/process.mjs";
|
||||
import { createWorkflowHelpers } from "./release/lib/workflow.mjs";
|
||||
import { createSnapshotHelpers } from "./release/lib/snapshot.mjs";
|
||||
import { applyD1Migrations } from "./release/lib/d1-migrations.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, "..");
|
||||
const packageJsonPath = join(rootDir, "package.json");
|
||||
const landingDir = join(rootDir, "landing");
|
||||
const localReleaseDir = join(rootDir, "local-release");
|
||||
const remoteWorkflowFile = ".github/workflows/remote-release-platforms.yml";
|
||||
const remoteWorkflowName = "remote-release-platforms.yml";
|
||||
const pagesProjectName = "agentcompany";
|
||||
const pagesBranch = "main";
|
||||
const versionPattern = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/;
|
||||
const syncExcludes = [
|
||||
".git",
|
||||
"node_modules/",
|
||||
".pnpm-store/",
|
||||
"build/",
|
||||
"local-release/",
|
||||
"out/",
|
||||
".omx/",
|
||||
".omc/",
|
||||
".claude/",
|
||||
"landing/.next/",
|
||||
"landing/out/",
|
||||
"landing/node_modules/",
|
||||
".DS_Store",
|
||||
".env.release.local",
|
||||
"CertificateSigningRequest.certSigningRequest",
|
||||
"Certificates.p12",
|
||||
"developerID_application.cer",
|
||||
];
|
||||
|
||||
const run = createRunner(rootDir);
|
||||
const {
|
||||
getGitHubHttpAuthHeader,
|
||||
getRepoInfo,
|
||||
assertRemoteWorkflowExists,
|
||||
waitForWorkflowRun,
|
||||
waitForWorkflowCompletion,
|
||||
downloadRemoteAssets,
|
||||
cleanupRemoteTransportRelease,
|
||||
} = createWorkflowHelpers(run);
|
||||
const {
|
||||
createSnapshotBranch,
|
||||
cleanupRemoteBranch,
|
||||
cleanupLocalWorktree,
|
||||
} = createSnapshotHelpers(run, getGitHubHttpAuthHeader);
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
version: null,
|
||||
skipVerify: false,
|
||||
deleteRemoteBranch: false,
|
||||
keepTransportRelease: false,
|
||||
};
|
||||
|
||||
for (const arg of argv) {
|
||||
if (arg.startsWith("--version=")) {
|
||||
options.version = arg.slice("--version=".length).trim();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--skip-verify") {
|
||||
options.skipVerify = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--delete-remote-branch") {
|
||||
options.deleteRemoteBranch = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--keep-transport-release") {
|
||||
options.keepTransportRelease = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help") {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: node scripts/release-cloudflare.mjs [options]
|
||||
|
||||
Options:
|
||||
--version=x.y.z Explicit version. Defaults to current patch + 1.
|
||||
--skip-verify Skip local dependency audit and pnpm verify before packaging.
|
||||
--delete-remote-branch Delete the GitHub snapshot branch after success.
|
||||
--keep-transport-release Keep the temporary draft GitHub release after success.
|
||||
--help Show this help.
|
||||
`);
|
||||
}
|
||||
|
||||
async function readPackageJson() {
|
||||
return JSON.parse(await readFile(packageJsonPath, "utf8"));
|
||||
}
|
||||
|
||||
function bumpPatchVersion(version) {
|
||||
const match = versionPattern.exec(version);
|
||||
if (!match) {
|
||||
throw new Error(`Unsupported package version format: ${version}`);
|
||||
}
|
||||
const [, major, minor, patch] = match;
|
||||
return `${major}.${minor}.${Number.parseInt(patch, 10) + 1}`;
|
||||
}
|
||||
|
||||
async function updatePackageVersion(nextVersion) {
|
||||
const pkg = await readPackageJson();
|
||||
pkg.version = nextVersion;
|
||||
await writeFile(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function ensureRemoteBuildPrerequisites(repo) {
|
||||
if (process.platform !== "darwin") {
|
||||
throw new Error("This release script currently expects to run on macOS for the signed macOS build.");
|
||||
}
|
||||
if (!existsSync(join(rootDir, remoteWorkflowFile))) {
|
||||
throw new Error(`Required workflow file missing locally: ${remoteWorkflowFile}`);
|
||||
}
|
||||
await assertRemoteWorkflowExists(
|
||||
repo,
|
||||
remoteWorkflowFile,
|
||||
`Remote workflow ${remoteWorkflowFile} was not found on ${repo.defaultBranch}. ` +
|
||||
"Commit and push this automation workflow before using scripts/release-cloudflare.mjs.",
|
||||
);
|
||||
await run("gh", ["auth", "status"], { capture: true });
|
||||
await run("npx", ["wrangler", "whoami", "--json"], { capture: true });
|
||||
}
|
||||
|
||||
async function resetReleaseStaging() {
|
||||
await rm(localReleaseDir, { recursive: true, force: true });
|
||||
await rm(join(rootDir, "build", "release"), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function buildMacAndUpload() {
|
||||
await run("node", ["scripts/release-local.mjs", "--platforms=mac", "--skip-verify"]);
|
||||
}
|
||||
|
||||
function buildSnapshotCommitMessage(version) {
|
||||
return [
|
||||
`Package release ${version} from the current workspace snapshot`,
|
||||
"",
|
||||
"Constraint: Windows and Linux artifacts are built on native GitHub runners from a temporary snapshot branch",
|
||||
"Rejected: Build Windows/Linux locally on Apple Silicon | docker/wine packaging is not stable on this host",
|
||||
"Confidence: medium",
|
||||
"Scope-risk: moderate",
|
||||
"Directive: This snapshot branch is transport-only and should be deleted after the release completes",
|
||||
"Tested: local verify/build stages before snapshot creation",
|
||||
"Not-tested: remote packaging before workflow execution",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function dispatchRemoteWorkflow(repo, branchName, releaseTag, releaseTitle) {
|
||||
await run("gh", [
|
||||
"workflow",
|
||||
"run",
|
||||
remoteWorkflowName,
|
||||
"--repo",
|
||||
repo.nameWithOwner,
|
||||
"--ref",
|
||||
branchName,
|
||||
"-f",
|
||||
`release_tag=${releaseTag}`,
|
||||
"-f",
|
||||
`release_title=${releaseTitle}`,
|
||||
]);
|
||||
}
|
||||
|
||||
async function stageRemoteAssets(downloadRoot) {
|
||||
const winDir = join(localReleaseDir, "win");
|
||||
const linuxDir = join(localReleaseDir, "linux");
|
||||
await rm(winDir, { recursive: true, force: true });
|
||||
await rm(linuxDir, { recursive: true, force: true });
|
||||
await mkdir(winDir, { recursive: true });
|
||||
await mkdir(linuxDir, { recursive: true });
|
||||
|
||||
const files = await readdir(downloadRoot);
|
||||
for (const fileName of files) {
|
||||
const sourcePath = join(downloadRoot, fileName);
|
||||
if (fileName.endsWith(".exe") || fileName.endsWith(".blockmap") || fileName === "latest.yml") {
|
||||
await cp(sourcePath, join(winDir, fileName));
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
fileName.endsWith(".AppImage") ||
|
||||
fileName.endsWith(".deb") ||
|
||||
fileName.endsWith(".rpm") ||
|
||||
fileName === "latest-linux.yml"
|
||||
) {
|
||||
await cp(sourcePath, join(linuxDir, fileName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadWinAndLinuxToR2() {
|
||||
await loadEnvFile(join(rootDir, ".env.release.local"));
|
||||
await loadEnvFile(join(rootDir, ".env.release"));
|
||||
const r2 = await prepareR2ReleaseHost({ rootDir });
|
||||
await uploadReleaseFiles({
|
||||
rootDir,
|
||||
releaseDir: join(localReleaseDir, "win"),
|
||||
bucket: r2.bucket,
|
||||
releasePrefix: r2.releasePrefix,
|
||||
});
|
||||
await uploadReleaseFiles({
|
||||
rootDir,
|
||||
releaseDir: join(localReleaseDir, "linux"),
|
||||
bucket: r2.bucket,
|
||||
releasePrefix: r2.releasePrefix,
|
||||
});
|
||||
}
|
||||
|
||||
async function applyLandingMigrations() {
|
||||
await applyD1Migrations({ landingDir, run });
|
||||
}
|
||||
|
||||
async function buildAndDeployLanding() {
|
||||
if (!existsSync(join(landingDir, "node_modules"))) {
|
||||
await run("npm", ["ci"], { cwd: landingDir });
|
||||
}
|
||||
await run("npm", ["run", "build"], { cwd: landingDir });
|
||||
const deploy = await run(
|
||||
"npx",
|
||||
["wrangler", "pages", "deploy", "out", "--project-name", pagesProjectName, "--branch", pagesBranch, "--commit-dirty=true"],
|
||||
{ cwd: landingDir, capture: true },
|
||||
);
|
||||
const output = `${deploy.stdout}\n${deploy.stderr}`;
|
||||
const urlMatch = output.match(/https:\/\/[^\s]+\.agentcompany\.pages\.dev/);
|
||||
return urlMatch?.[0] ?? null;
|
||||
}
|
||||
|
||||
async function verifyPublishedAssets(version, deployUrl) {
|
||||
const releaseLinksSource = await readFile(join(landingDir, "components", "release-links.generated.ts"), "utf8");
|
||||
const requiredFragments = [
|
||||
`AgentCompany-0.4.150-arm64.dmg`.replace("0.4.150", version),
|
||||
`AgentCompany.Setup.${version}.exe`,
|
||||
`agent-company-${version}.aarch64.rpm`,
|
||||
`agent-company_${version}_arm64.deb`,
|
||||
];
|
||||
for (const fragment of requiredFragments) {
|
||||
if (!releaseLinksSource.includes(fragment)) {
|
||||
throw new Error(`Missing expected release asset in generated links: ${fragment}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (deployUrl) {
|
||||
const { stdout } = await run("curl", ["-L", "--silent", "-A", "Mozilla/5.0", deployUrl], { capture: true });
|
||||
if (!stdout.includes(version)) {
|
||||
throw new Error(`Deployed landing preview ${deployUrl} does not contain version ${version}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
await loadEnvFile(join(rootDir, ".env.release.local"));
|
||||
await loadEnvFile(join(rootDir, ".env.release"));
|
||||
|
||||
const repo = await getRepoInfo();
|
||||
await ensureRemoteBuildPrerequisites(repo);
|
||||
|
||||
const currentVersion = (await readPackageJson()).version;
|
||||
const nextVersion = options.version ?? bumpPatchVersion(currentVersion);
|
||||
if (!versionPattern.test(nextVersion)) {
|
||||
throw new Error(`Invalid version: ${nextVersion}`);
|
||||
}
|
||||
|
||||
await updatePackageVersion(nextVersion);
|
||||
await resetReleaseStaging();
|
||||
|
||||
if (!options.skipVerify) {
|
||||
await run("pnpm", ["audit:deps"]);
|
||||
await run("pnpm", ["verify"]);
|
||||
}
|
||||
|
||||
const r2Host = await prepareR2ReleaseHost({ rootDir });
|
||||
await purgeReleasePrefix({ bucket: r2Host.bucket, releasePrefix: r2Host.releasePrefix });
|
||||
|
||||
await buildMacAndUpload();
|
||||
|
||||
const timestamp = Date.now();
|
||||
const releaseTag = `transport-${nextVersion}-${timestamp}`;
|
||||
const branchName = `release-build-${nextVersion.replace(/\./g, "-")}-${timestamp}`;
|
||||
const releaseTitle = `Remote Release ${nextVersion}`;
|
||||
|
||||
const tempDir = await createSnapshotBranch({
|
||||
repo,
|
||||
branchName,
|
||||
rootDir,
|
||||
syncExcludes,
|
||||
commitMessage: buildSnapshotCommitMessage(nextVersion),
|
||||
});
|
||||
let cleanupPending = true;
|
||||
|
||||
try {
|
||||
await dispatchRemoteWorkflow(repo, branchName, releaseTag, releaseTitle);
|
||||
const runSummary = await waitForWorkflowRun(repo, remoteWorkflowName, branchName, 60_000);
|
||||
const runInfo = await waitForWorkflowCompletion(repo, runSummary.databaseId, 45 * 60 * 1000);
|
||||
if (runInfo.conclusion !== "success") {
|
||||
throw new Error(`Remote workflow failed: ${runInfo.url}`);
|
||||
}
|
||||
|
||||
const downloadRoot = await downloadRemoteAssets(repo, releaseTag);
|
||||
await stageRemoteAssets(downloadRoot);
|
||||
await uploadWinAndLinuxToR2();
|
||||
await run("node", ["scripts/generate-landing-release-links.mjs"]);
|
||||
await applyLandingMigrations();
|
||||
const deployUrl = await buildAndDeployLanding();
|
||||
await verifyPublishedAssets(nextVersion, deployUrl);
|
||||
|
||||
if (!options.keepTransportRelease) {
|
||||
await cleanupRemoteTransportRelease(repo, releaseTag);
|
||||
}
|
||||
if (options.deleteRemoteBranch) {
|
||||
await cleanupRemoteBranch(repo, branchName);
|
||||
}
|
||||
await cleanupLocalWorktree(tempDir);
|
||||
cleanupPending = false;
|
||||
|
||||
console.log(JSON.stringify({
|
||||
version: nextVersion,
|
||||
remoteBranch: branchName,
|
||||
remoteWorkflowRun: runInfo.url,
|
||||
releaseTag,
|
||||
deployUrl,
|
||||
updateUrl: process.env.AGENTCOMPANY_UPDATE_URL ?? null,
|
||||
}, null, 2));
|
||||
} finally {
|
||||
if (cleanupPending) {
|
||||
try {
|
||||
await cleanupLocalWorktree(tempDir);
|
||||
} catch {
|
||||
// no-op during best-effort temp cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -1,126 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
import { prepareR2ReleaseHost } from "./release-local/r2.mjs";
|
||||
import { loadEnvFile } from "./release/lib/dotenv.mjs";
|
||||
import { applyD1Migrations } from "./release/lib/d1-migrations.mjs";
|
||||
import { createRunner } from "./release/lib/process.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, "..");
|
||||
const landingDir = join(rootDir, "landing");
|
||||
const packageJsonPath = join(rootDir, "package.json");
|
||||
const pagesProjectName = "agentcompany";
|
||||
const pagesBranch = "main";
|
||||
|
||||
const run = createRunner(rootDir);
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { skipVerify: false };
|
||||
for (const arg of argv) {
|
||||
if (arg === "--skip-verify") {
|
||||
options.skipVerify = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help") {
|
||||
console.log(`Usage: node scripts/release-website.mjs [options]
|
||||
|
||||
Options:
|
||||
--skip-verify Do not assert the deployed page includes the current version.
|
||||
--help Show this help.
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
async function readPackageJson() {
|
||||
return JSON.parse(await readFile(packageJsonPath, "utf8"));
|
||||
}
|
||||
|
||||
async function runPlain(command, args, cwd = rootDir) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`${command} ${args.join(" ")} failed with exit code ${code ?? 1}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function buildAndDeployLanding() {
|
||||
if (!existsSync(join(landingDir, "node_modules"))) {
|
||||
await runPlain("npm", ["ci"], landingDir);
|
||||
}
|
||||
await runPlain("npm", ["run", "build"], landingDir);
|
||||
const deploy = await run(
|
||||
"npx",
|
||||
["wrangler", "pages", "deploy", "out", "--project-name", pagesProjectName, "--branch", pagesBranch, "--commit-dirty=true"],
|
||||
{ cwd: landingDir, capture: true },
|
||||
);
|
||||
const output = `${deploy.stdout}\n${deploy.stderr}`;
|
||||
const urlMatch = output.match(/https:\/\/[^\s]+\.agentcompany\.pages\.dev/);
|
||||
return urlMatch?.[0] ?? null;
|
||||
}
|
||||
|
||||
async function verifyPublishedAssets(version, deployUrl) {
|
||||
const linksSource = await readFile(join(landingDir, "components", "release-links.generated.ts"), "utf8");
|
||||
const required = [
|
||||
`AgentCompany-${version}-arm64.dmg`,
|
||||
`AgentCompany.Setup.${version}.exe`,
|
||||
`agent-company-${version}.aarch64.rpm`,
|
||||
`agent-company_${version}_arm64.deb`,
|
||||
];
|
||||
const missing = required.filter((frag) => !linksSource.includes(frag));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Missing expected release assets in generated links (did you run release:local-mac and release:local-winlinux first?):\n ${missing.join("\n ")}`,
|
||||
);
|
||||
}
|
||||
if (deployUrl) {
|
||||
const { stdout } = await run("curl", ["-L", "--silent", "-A", "Mozilla/5.0", deployUrl], { capture: true });
|
||||
if (!stdout.includes(version)) {
|
||||
throw new Error(`Deployed landing preview ${deployUrl} does not contain version ${version}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
await loadEnvFile(join(rootDir, ".env.release.local"));
|
||||
await loadEnvFile(join(rootDir, ".env.release"));
|
||||
|
||||
await prepareR2ReleaseHost({ rootDir });
|
||||
await runPlain("pnpm", ["audit:deps"]);
|
||||
|
||||
const version = (await readPackageJson()).version;
|
||||
console.log(`[release-website] regenerating landing for version ${version}`);
|
||||
|
||||
await runPlain("node", ["scripts/generate-landing-release-links.mjs"]);
|
||||
await applyD1Migrations({ landingDir, run });
|
||||
const deployUrl = await buildAndDeployLanding();
|
||||
|
||||
if (!options.skipVerify) {
|
||||
await verifyPublishedAssets(version, deployUrl);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
version,
|
||||
deployUrl,
|
||||
updateUrl: process.env.AGENTCOMPANY_UPDATE_URL ?? null,
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -1,129 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
const databaseNamePattern = /^[A-Za-z0-9_-]+$/;
|
||||
const migrationFilePattern = /^[A-Za-z0-9][A-Za-z0-9._-]*\.sql$/;
|
||||
|
||||
function stripTomlComment(line) {
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let i = 0; i < line.length; i += 1) {
|
||||
const char = line[i];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (char === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (char === "\"") {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
if (char === "#" && !inString) {
|
||||
return line.slice(0, i);
|
||||
}
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function parseTomlString(value, key) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith("\"") || !trimmed.endsWith("\"")) {
|
||||
throw new Error(`wrangler.toml key "${key}" must be a quoted string.`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`wrangler.toml key "${key}" has invalid string syntax: ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseAssignment(line) {
|
||||
const equals = line.indexOf("=");
|
||||
if (equals === -1) return null;
|
||||
const key = line.slice(0, equals).trim();
|
||||
const value = line.slice(equals + 1).trim();
|
||||
if (!key) return null;
|
||||
return { key, value };
|
||||
}
|
||||
|
||||
export function validateD1DatabaseName(databaseName) {
|
||||
if (!databaseNamePattern.test(databaseName)) {
|
||||
throw new Error(
|
||||
`Invalid Cloudflare D1 database_name "${databaseName}". Use only letters, numbers, underscores, and hyphens.`,
|
||||
);
|
||||
}
|
||||
return databaseName;
|
||||
}
|
||||
|
||||
export function validateD1MigrationFileName(fileName) {
|
||||
if (!migrationFilePattern.test(fileName)) {
|
||||
throw new Error(
|
||||
`Invalid D1 migration file name "${fileName}". Use a plain .sql file name with letters, numbers, dots, underscores, and hyphens.`,
|
||||
);
|
||||
}
|
||||
return fileName;
|
||||
}
|
||||
|
||||
export function parseD1DatabaseName(wranglerToml) {
|
||||
let inD1Database = false;
|
||||
for (const rawLine of wranglerToml.split(/\r?\n/)) {
|
||||
const line = stripTomlComment(rawLine).trim();
|
||||
if (!line) continue;
|
||||
if (line.startsWith("[[")) {
|
||||
inD1Database = line === "[[d1_databases]]";
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("[")) {
|
||||
inD1Database = false;
|
||||
continue;
|
||||
}
|
||||
if (!inD1Database) continue;
|
||||
const assignment = parseAssignment(line);
|
||||
if (!assignment || assignment.key !== "database_name") continue;
|
||||
return validateD1DatabaseName(
|
||||
parseTomlString(assignment.value, assignment.key),
|
||||
);
|
||||
}
|
||||
throw new Error("landing/wrangler.toml does not define [[d1_databases]].database_name.");
|
||||
}
|
||||
|
||||
export async function listD1MigrationFiles(migrationsDir) {
|
||||
return (await readdir(migrationsDir))
|
||||
.filter((fileName) => fileName.endsWith(".sql"))
|
||||
.map(validateD1MigrationFileName)
|
||||
.sort();
|
||||
}
|
||||
|
||||
export async function applyD1Migrations({ landingDir, run }) {
|
||||
const migrationsDir = join(landingDir, "migrations");
|
||||
if (!existsSync(migrationsDir)) {
|
||||
return { databaseName: null, applied: 0 };
|
||||
}
|
||||
|
||||
const wranglerToml = await readFile(join(landingDir, "wrangler.toml"), "utf8");
|
||||
const databaseName = parseD1DatabaseName(wranglerToml);
|
||||
const migrationFiles = await listD1MigrationFiles(migrationsDir);
|
||||
|
||||
for (const fileName of migrationFiles) {
|
||||
await run(
|
||||
"npx",
|
||||
[
|
||||
"wrangler",
|
||||
"d1",
|
||||
"execute",
|
||||
databaseName,
|
||||
"--remote",
|
||||
"--file",
|
||||
`migrations/${fileName}`,
|
||||
],
|
||||
{ cwd: landingDir, input: "y\n" },
|
||||
);
|
||||
}
|
||||
|
||||
return { databaseName, applied: migrationFiles.length };
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
export async function loadEnvFile(filePath) {
|
||||
if (!existsSync(filePath)) return;
|
||||
const content = await readFile(filePath, "utf8");
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const separatorIndex = line.indexOf("=");
|
||||
if (separatorIndex <= 0) continue;
|
||||
const key = line.slice(0, separatorIndex).trim();
|
||||
if (!key || process.env[key]) continue;
|
||||
const rawValue = line.slice(separatorIndex + 1).trim();
|
||||
if (!rawValue) continue;
|
||||
process.env[key] = rawValue.replace(/^['"]|['"]$/g, "");
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
export function createRunner(rootDir) {
|
||||
return function run(command, args, options = {}) {
|
||||
const {
|
||||
cwd = rootDir,
|
||||
env = process.env,
|
||||
input = null,
|
||||
capture = false,
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
env,
|
||||
stdio: capture ? ["pipe", "pipe", "pipe"] : ["pipe", "inherit", "inherit"],
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
if (capture) {
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
}
|
||||
|
||||
if (input) {
|
||||
child.stdin.write(input);
|
||||
}
|
||||
child.stdin.end();
|
||||
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr });
|
||||
return;
|
||||
}
|
||||
reject(new Error(stderr || stdout || `${command} ${args.join(" ")} failed with exit code ${code ?? 1}`));
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
export function createSnapshotHelpers(run, getGitHubHttpAuthHeader) {
|
||||
async function createSnapshotBranch({
|
||||
repo,
|
||||
branchName,
|
||||
rootDir,
|
||||
syncExcludes,
|
||||
commitMessage,
|
||||
afterRsync,
|
||||
}) {
|
||||
const tempDir = await mkdtemp(join(os.tmpdir(), "agentcompany-release-"));
|
||||
const auth = await getGitHubHttpAuthHeader();
|
||||
await run("git", [
|
||||
"-c",
|
||||
`http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth}`,
|
||||
"fetch",
|
||||
`https://github.com/${repo.nameWithOwner}.git`,
|
||||
repo.defaultBranch,
|
||||
]);
|
||||
await run("git", ["worktree", "add", "--detach", tempDir, "FETCH_HEAD"]);
|
||||
await run("git", ["-C", tempDir, "checkout", "-b", branchName]);
|
||||
|
||||
const rsyncArgs = ["-a", "--delete"];
|
||||
for (const pattern of syncExcludes) {
|
||||
rsyncArgs.push("--exclude", pattern);
|
||||
}
|
||||
rsyncArgs.push(`${rootDir}/`, `${tempDir}/`);
|
||||
await run("rsync", rsyncArgs);
|
||||
|
||||
await run("git", ["-C", tempDir, "add", "-A"]);
|
||||
if (afterRsync) {
|
||||
await afterRsync(tempDir);
|
||||
}
|
||||
const diffStatus = await run("git", ["-C", tempDir, "diff", "--cached", "--quiet"], { capture: true })
|
||||
.then(() => false)
|
||||
.catch(() => true);
|
||||
|
||||
if (!diffStatus) {
|
||||
throw new Error("Snapshot branch has no staged changes after syncing the workspace.");
|
||||
}
|
||||
|
||||
await run("git", ["-C", tempDir, "commit", "-m", commitMessage]);
|
||||
|
||||
await run(
|
||||
"git",
|
||||
[
|
||||
"-C",
|
||||
tempDir,
|
||||
"-c",
|
||||
`http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth}`,
|
||||
"push",
|
||||
`https://github.com/${repo.nameWithOwner}.git`,
|
||||
branchName,
|
||||
],
|
||||
);
|
||||
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
async function cleanupRemoteBranch(repo, branchName) {
|
||||
await run("gh", ["api", "-X", "DELETE", `repos/${repo.nameWithOwner}/git/refs/heads/${branchName}`]);
|
||||
}
|
||||
|
||||
async function cleanupLocalWorktree(tempDir) {
|
||||
await run("git", ["worktree", "remove", "--force", tempDir]);
|
||||
await run("git", ["worktree", "prune"]);
|
||||
}
|
||||
|
||||
return {
|
||||
createSnapshotBranch,
|
||||
cleanupRemoteBranch,
|
||||
cleanupLocalWorktree,
|
||||
};
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
export function createWorkflowHelpers(run) {
|
||||
async function ghJson(args) {
|
||||
const { stdout } = await run("gh", args, { capture: true });
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
|
||||
async function getGitHubHttpAuthHeader() {
|
||||
const tokenResult = await run("gh", ["auth", "token"], { capture: true });
|
||||
const token = tokenResult.stdout.trim();
|
||||
if (!token) {
|
||||
throw new Error("Unable to read a GitHub token from gh auth.");
|
||||
}
|
||||
return Buffer.from(`x-access-token:${token}`).toString("base64");
|
||||
}
|
||||
|
||||
async function getRepoInfo() {
|
||||
const info = await ghJson(["repo", "view", "--json", "nameWithOwner,defaultBranchRef"]);
|
||||
const defaultBranch = info.defaultBranchRef?.name;
|
||||
if (!info.nameWithOwner || !defaultBranch) {
|
||||
throw new Error("Unable to resolve GitHub repository name/default branch from gh.");
|
||||
}
|
||||
return {
|
||||
nameWithOwner: info.nameWithOwner,
|
||||
defaultBranch,
|
||||
};
|
||||
}
|
||||
|
||||
async function assertRemoteWorkflowExists(repo, workflowFile, errorMessage) {
|
||||
try {
|
||||
await run("gh", [
|
||||
"api",
|
||||
`repos/${repo.nameWithOwner}/contents/${workflowFile}?ref=${repo.defaultBranch}`,
|
||||
], { capture: true });
|
||||
} catch {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForWorkflowRun(repo, workflowName, branchName, timeoutMs) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const runs = await ghJson([
|
||||
"run",
|
||||
"list",
|
||||
"--repo",
|
||||
repo.nameWithOwner,
|
||||
"--workflow",
|
||||
workflowName,
|
||||
"--branch",
|
||||
branchName,
|
||||
"--limit",
|
||||
"1",
|
||||
"--json",
|
||||
"databaseId,status,conclusion,url,displayTitle",
|
||||
]);
|
||||
if (runs.length > 0) {
|
||||
return runs[0];
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
throw new Error(`Timed out waiting for workflow ${workflowName} on branch ${branchName}.`);
|
||||
}
|
||||
|
||||
async function waitForWorkflowCompletion(repo, runId, timeoutMs) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const runInfo = await ghJson([
|
||||
"run",
|
||||
"view",
|
||||
String(runId),
|
||||
"--repo",
|
||||
repo.nameWithOwner,
|
||||
"--json",
|
||||
"status,conclusion,url",
|
||||
]);
|
||||
if (runInfo.status === "completed") {
|
||||
return runInfo;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 15000));
|
||||
}
|
||||
throw new Error(`Timed out waiting for workflow run ${runId} to complete.`);
|
||||
}
|
||||
|
||||
async function downloadRemoteAssets(repo, releaseTag) {
|
||||
const downloadRoot = await mkdtemp(join(os.tmpdir(), "agentcompany-transport-"));
|
||||
await run("gh", [
|
||||
"release",
|
||||
"download",
|
||||
releaseTag,
|
||||
"--repo",
|
||||
repo.nameWithOwner,
|
||||
"-D",
|
||||
downloadRoot,
|
||||
]);
|
||||
return downloadRoot;
|
||||
}
|
||||
|
||||
async function cleanupRemoteTransportRelease(repo, releaseTag) {
|
||||
await run("gh", ["release", "delete", releaseTag, "--repo", repo.nameWithOwner, "--yes"]);
|
||||
}
|
||||
|
||||
return {
|
||||
ghJson,
|
||||
getGitHubHttpAuthHeader,
|
||||
getRepoInfo,
|
||||
assertRemoteWorkflowExists,
|
||||
waitForWorkflowRun,
|
||||
waitForWorkflowCompletion,
|
||||
downloadRemoteAssets,
|
||||
cleanupRemoteTransportRelease,
|
||||
};
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { prepareR2ReleaseHost, uploadReleaseFiles } from "./release-local/r2.mjs";
|
||||
import { loadEnvFile } from "./release/lib/dotenv.mjs";
|
||||
import { createRunner } from "./release/lib/process.mjs";
|
||||
import { createWorkflowHelpers } from "./release/lib/workflow.mjs";
|
||||
import { applyD1Migrations } from "./release/lib/d1-migrations.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, "..");
|
||||
const landingDir = join(rootDir, "landing");
|
||||
const localReleaseDir = join(rootDir, "local-release");
|
||||
const pagesProjectName = "agentcompany";
|
||||
const pagesBranch = "main";
|
||||
|
||||
const run = createRunner(rootDir);
|
||||
const { getRepoInfo, cleanupRemoteTransportRelease } = createWorkflowHelpers(run);
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { releaseTag: null, version: null };
|
||||
for (const arg of argv) {
|
||||
if (arg.startsWith("--release-tag=")) options.releaseTag = arg.slice("--release-tag=".length).trim();
|
||||
else if (arg.startsWith("--version=")) options.version = arg.slice("--version=".length).trim();
|
||||
else throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
if (!options.releaseTag || !options.version) {
|
||||
throw new Error("Usage: node scripts/resume-release-public.mjs --release-tag=<tag> --version=<x.y.z>");
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
async function uploadWinAndLinuxToR2() {
|
||||
const r2 = await prepareR2ReleaseHost({ rootDir });
|
||||
await uploadReleaseFiles({ rootDir, releaseDir: join(localReleaseDir, "win"), bucket: r2.bucket, releasePrefix: r2.releasePrefix });
|
||||
await uploadReleaseFiles({ rootDir, releaseDir: join(localReleaseDir, "linux"), bucket: r2.bucket, releasePrefix: r2.releasePrefix });
|
||||
}
|
||||
|
||||
async function applyLandingMigrations() {
|
||||
await applyD1Migrations({ landingDir, run });
|
||||
}
|
||||
|
||||
async function buildAndDeployLanding() {
|
||||
if (!existsSync(join(landingDir, "node_modules"))) {
|
||||
await run("npm", ["ci"], { cwd: landingDir });
|
||||
}
|
||||
await run("npm", ["run", "build"], { cwd: landingDir });
|
||||
const deploy = await run(
|
||||
"npx",
|
||||
["wrangler", "pages", "deploy", "out", "--project-name", pagesProjectName, "--branch", pagesBranch, "--commit-dirty=true"],
|
||||
{ cwd: landingDir, capture: true },
|
||||
);
|
||||
const output = `${deploy.stdout}\n${deploy.stderr}`;
|
||||
const urlMatch = output.match(/https:\/\/[^\s]+\.agentcompany\.pages\.dev/);
|
||||
return urlMatch?.[0] ?? null;
|
||||
}
|
||||
|
||||
async function verifyPublishedAssets(version, deployUrl) {
|
||||
const releaseLinksSource = await readFile(join(landingDir, "components", "release-links.generated.ts"), "utf8");
|
||||
const requiredFragments = [
|
||||
`AgentCompany-${version}-arm64.dmg`,
|
||||
`AgentCompany.Setup.${version}.exe`,
|
||||
`agent-company-${version}.aarch64.rpm`,
|
||||
`agent-company_${version}_arm64.deb`,
|
||||
];
|
||||
for (const fragment of requiredFragments) {
|
||||
if (!releaseLinksSource.includes(fragment)) {
|
||||
throw new Error(`Missing expected release asset in generated links: ${fragment}`);
|
||||
}
|
||||
}
|
||||
if (deployUrl) {
|
||||
const { stdout } = await run("curl", ["-L", "--silent", "-A", "Mozilla/5.0", deployUrl], { capture: true });
|
||||
if (!stdout.includes(version)) {
|
||||
throw new Error(`Deployed landing preview ${deployUrl} does not contain version ${version}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
await loadEnvFile(join(rootDir, ".env.release.local"));
|
||||
await loadEnvFile(join(rootDir, ".env.release"));
|
||||
|
||||
const repo = await getRepoInfo();
|
||||
await run("pnpm", ["audit:deps"]);
|
||||
|
||||
await uploadWinAndLinuxToR2();
|
||||
await run("node", ["scripts/generate-landing-release-links.mjs"]);
|
||||
await applyLandingMigrations();
|
||||
const deployUrl = await buildAndDeployLanding();
|
||||
await verifyPublishedAssets(options.version, deployUrl);
|
||||
await cleanupRemoteTransportRelease(repo, options.releaseTag);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
version: options.version,
|
||||
releaseTag: options.releaseTag,
|
||||
deployUrl,
|
||||
updateUrl: process.env.AGENTCOMPANY_UPDATE_URL ?? null,
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
await main();
|
||||
Reference in new issue
Block a user