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:
ZacharyZhang-NY committed 2026-07-07 00:04:13 -04:00
1 parent 2350f744e3
commit 07d0b4587d
1904 files changed
+1133 -282075

No files matched your search

-48
View File
@@ -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/`.
-118
View File
@@ -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.");
-93
View File
@@ -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;
}
-117
View File
@@ -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 };
}
-93
View File
@@ -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;
}
-94
View File
@@ -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 }));
});
}
-108
View File
@@ -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}`);
}
-132
View File
@@ -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);
}
+98
View File
@@ -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);
});
-241
View File
@@ -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();
-204
View File
@@ -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()
-34
View File
@@ -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\"."
-34
View File
@@ -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);
-246
View File
@@ -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();
-200
View File
@@ -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();
-223
View File
@@ -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();
-306
View File
@@ -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();
-262
View File
@@ -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 });
}
}
-350
View File
@@ -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();
-126
View File
@@ -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();
-129
View File
@@ -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 };
}
-18
View File
@@ -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, "");
}
}
-46
View File
@@ -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}`));
});
});
};
}
-77
View File
@@ -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,
};
}
-116
View File
@@ -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,
};
}
-103
View File
@@ -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();