Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ name: Publish
# - latest: run by hand on `main`; publishes each package whose version is not on npm yet
# dev and latest also make GitHub Releases (scripts/github.mjs): `devel`, one prerelease with the
# standalone binary replaced on every dev push, or `<pkg>@v<version>` per package released on
# latest, the CLI's with the binary and marked latest. canary stays npm-only.
# latest, the CLI's with the binary and marked latest. canary makes `nightly`, one prerelease
# with the desktop app replaced on every canary push.
on:
push:
branches: [canary, dev]
Expand Down Expand Up @@ -195,3 +196,76 @@ jobs:
# codework.sh/install verifies each download against SHA256SUMS.
- run: (cd release && sha256sum *.tar.gz > SHA256SUMS)
- run: node scripts/github.mjs "$CHANNEL" release

desktop:
needs: guard
if: github.ref_name == 'canary'
name: desktop (${{ matrix.target }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: true
matrix:
# Each on its own platform: electron-builder packages for the host, and macOS signs.
include:
- target: mac-arm64
runner: macos-14
args: --mac --arm64
- target: linux-arm64
runner: ubuntu-24.04-arm
args: --linux --arm64
- target: linux-x64
runner: ubuntu-24.04
args: --linux --x64
env:
CODEWORK_RELEASE_ID: ${{ needs.guard.outputs.id }}
steps:
- uses: actions/checkout@v6
# The pacman target packs with bsdtar.
- if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libarchive-tools
- uses: voidzero-dev/setup-vp@v1.21.1
with:
node-version: 24
run-install: false
cache: false
# The app packages the web app and the app server it builds, so those install too.
- run: vp install --frozen-lockfile --filter '@codeworksh/desktop...' --filter '@codeworksh/server...'
- working-directory: apps/desktop
run: vp run package "$CHANNEL" ${{ matrix.args }}
# Installers only; electron-builder's update blockmaps and unpacked app stay behind.
- uses: actions/upload-artifact@v7
with:
name: desktop-${{ matrix.target }}
path: |
apps/desktop/dist/codework-desktop-*.dmg
apps/desktop/dist/codework-desktop-*.zip
apps/desktop/dist/codework-desktop-*.AppImage
apps/desktop/dist/codework-desktop-*.deb
apps/desktop/dist/codework-desktop-*.pacman
if-no-files-found: error
retention-days: 1

# After npm, so `nightly` never names a commit whose packages did not ship.
nightly:
needs: [guard, publish, desktop]
if: github.ref_name == 'canary'
runs-on: ubuntu-latest
permissions:
contents: write
env:
CODEWORK_RELEASE_ID: ${{ needs.guard.outputs.id }}
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v6
- uses: voidzero-dev/setup-vp@v1.21.1
with:
node-version: 24
run-install: false
cache: false
- uses: actions/download-artifact@v8
with:
pattern: desktop-*
path: release
merge-multiple: true
- run: (cd release && sha256sum codework-desktop-* > SHA256SUMS)
- run: node scripts/github.mjs canary release
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,13 @@ Conventional Commit style with optional package scopes, e.g `feat(aikit): ...`,

Every package is published by CI from `.github/workflows/publish.yml`, which runs `vp run release <channel>` (`scripts/release.mjs`). It builds the TUI binaries, then publishes the packages in dependency order, derived from their `package.json` workspace dependencies (`scripts/workspace.mjs`), pinning each package to the exact versions uploaded in the same run. `scripts/publish.mjs` builds a single package, rewrites its manifest and publishes it from a temp dir.

- **canary**: every push to `canary`, without `vp check` or typecheck, for quick testing. Versions are `<version>-canary.<run>.<attempt>.g<sha>`.
- **canary**: every push to `canary`, without `vp check` or typecheck, for quick testing. Versions are `<version>-canary.<run>.<attempt>.g<sha>`. It also packages the desktop app (`apps/desktop`, `vp run package <channel>`) as "CodeWork Canary" for macOS arm64 and Linux x64/arm64, and replaces the `nightly` prerelease with it.
- **dev**: every push to `dev`. Versions are `<version>-dev.<YYYYMMDDHHMMSS>` (UTC).
- **latest**: run the workflow by hand on `main`. It publishes each package whose manifest version is not on npm yet and skips the rest.

GitHub Releases (`scripts/github.mjs`): dev replaces the `devel` prerelease with the CLI binaries; latest makes a `<pkg>@v<version>` release per package not released yet, the CLI's carrying the binaries. canary has none.
GitHub Releases (`scripts/github.mjs`): dev replaces the `devel` prerelease with the CLI binaries; latest makes a `<pkg>@v<version>` release per package not released yet, the CLI's carrying the binaries. canary replaces the `nightly` prerelease with the desktop app.

Versions are bumped locally with `vp run bump <aikit|plugin|harness|tui|cli> [version]`, which runs `bumpp` with the package's `bump.config.ts` and commits and tags `<pkg>@v<version>` without pushing. `vp run bump tui` moves the launcher and its three platform packages together.
Versions are bumped locally with `vp run bump <aikit|plugin|harness|tui|cli|desktop> [version]`, which runs `bumpp` with the package's `bump.config.ts` and commits and tags `<pkg>@v<version>` without pushing. `vp run bump tui` moves the launcher and its three platform packages together.

Flow:

Expand Down
20 changes: 20 additions & 0 deletions apps/contract/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "@codeworksh/contract",
"version": "0.0.0",
"private": true,
"description": "The desktop app server's RPC contract, shared by the app server and the web app.",
"type": "module",
"exports": {
".": "./src/contract.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@codeworksh/widget": "workspace:*",
"effect": "4.0.5"
},
"devDependencies": {
"typescript": "catalog:"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ export class CodeworkUnavailable extends Schema.TaggedError<CodeworkUnavailable>
message: Schema.String,
}) {}

/** The models could not be listed: a settings file or the model catalog is broken. */
/** The models could not be listed: a settings file or the model catalog, at `path`, is broken. */
export class ModelsError extends Schema.TaggedError<ModelsError>()("Server.ModelsError", {
source: Schema.Literals(["settings", "catalog"]),
path: Schema.String,
message: Schema.String,
}) {}

Expand Down
7 changes: 7 additions & 0 deletions apps/contract/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "../../.cache/typescript/contract.tsbuildinfo"
},
"include": ["src"]
}
9 changes: 9 additions & 0 deletions apps/desktop/bump.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from "bumpp";

// Release config for the desktop app, loaded automatically by `bumpp`. Its version is its own;
// canary and dev builds add their run id to it as the npm packages do.
export default defineConfig({
tag: "desktop@v%s",
commit: "release: desktop@v%s",
push: false,
});
65 changes: 65 additions & 0 deletions apps/desktop/electron-builder.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { Configuration } from "electron-builder";

// One identity per channel, so channels install side by side with their own data. The version
// is the run's, passed by scripts/package.mjs.
const CHANNELS = {
latest: { productName: "CodeWork", appId: "sh.codework.desktop", packageName: "codework-desktop" },
dev: { productName: "CodeWork Dev", appId: "sh.codework.desktop.dev", packageName: "codework-desktop-dev" },
canary: {
productName: "CodeWork Canary",
appId: "sh.codework.desktop.canary",
packageName: "codework-desktop-canary",
},
} as const;

const channel = process.env.CODEWORK_CHANNEL;
if (channel !== "latest" && channel !== "dev" && channel !== "canary") {
throw new Error(`CODEWORK_CHANNEL must be latest, dev or canary, not ${channel}`);
}
const { productName, appId, packageName } = CHANNELS[channel];

export default {
appId,
productName,
// The same name on every channel; the release a file sits in says which channel it is.
artifactName: "codework-desktop-${os}-${arch}.${ext}",
// Nothing native to rebuild: the app server uses node:sqlite.
npmRebuild: false,
copyright: "Copyright © CodeWork",
directories: { output: "dist", buildResources: "resources" },
extraMetadata: { name: packageName, productName, homepage: "https://www.codework.sh" },
// The app is its two bundles and its icon; Electron is the only dependency, and it is the runtime.
files: ["out/*.cjs", "resources/icon.png", "package.json"],
// The app server runs as its own process from Resources, so it sits outside the asar archive.
extraResources: [
{ from: "../server/dist/index.mjs", to: "server/index.mjs" },
{ from: "../web/dist", to: "web" },
],
mac: {
target: [
{ target: "dmg", arch: "arm64" },
{ target: "zip", arch: "arm64" },
],
category: "public.app-category.developer-tools",
icon: "resources/icon.png",
// Ad-hoc: Apple silicon runs nothing unsigned. A downloaded build still needs its quarantine
// flag removed until builds are signed and notarized.
identity: "-",
hardenedRuntime: false,
},
linux: {
// Each format names x64 and arm64 its own way (x86_64, amd64, aarch64); one name for all,
// from the arch scripts/package.mjs builds for.
artifactName: "codework-desktop-linux-${env.CODEWORK_ARCH}.${ext}",
target: ["AppImage", "deb", "pacman"],
category: "Development",
icon: "resources/icon.png",
maintainer: "CodeWork",
executableName: packageName,
},
deb: { packageName },
pacman: { packageName },
// The static AppImage runtime, so it runs without libfuse2.
toolsets: { appimage: "1.0.3" },
publish: null,
} satisfies Configuration;
12 changes: 7 additions & 5 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
{
"name": "@codeworksh/desktop",
"version": "0.0.0",
"version": "0.3.0",
"private": true,
"description": "The CodeWork desktop app.",
"type": "module",
"main": "out/main.cjs",
"scripts": {
"build": "vp pack",
"dev": "node scripts/dev.mjs",
"start": "node scripts/start-electron.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"electron": "44.4.2"
"typecheck": "tsc --noEmit",
"package": "node scripts/package.mjs"
},
"devDependencies": {
"@types/node": "catalog:",
"bumpp": "^12.3.0",
"electron": "44.4.2",
"electron-builder": "^26.15.3",
"typescript": "catalog:",
"vite-plus": "catalog:"
}
Expand Down
47 changes: 47 additions & 0 deletions apps/desktop/scripts/package.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// `vp run package <canary|dev|latest> [electron-builder args]`: builds the web app, the app server
// and this app, then packages them for the channel under the run's version, e.g. `--mac --arm64 --dir`.
// In CI the version carries the run id; locally a dry-run id stands in.
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { releaseId, releaseVersion } from "../../../scripts/version.mjs";

const desktopDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");

const [channel, ...builderArgs] = process.argv.slice(2);
if (!["canary", "dev", "latest"].includes(channel)) {
console.error("Usage: vp run package <canary|dev|latest> [electron-builder args]");
process.exit(1);
}

const run = (command, args, cwd, env = process.env) => {
const result = spawnSync(command, args, { cwd, env, stdio: "inherit" });
if (result.status !== 0) process.exit(result.status ?? 1);
};

// One arch per run: the one asked for, else this machine's (Node names them as the file names do).
const arch = builderArgs.includes("--arm64") ? "arm64" : builderArgs.includes("--x64") ? "x64" : process.arch;

const { version: base } = JSON.parse(readFileSync(path.join(desktopDir, "package.json"), "utf8"));
const version = releaseVersion(base, channel, releaseId(channel, { dryRun: !process.env.CI }));

run("vp", ["build"], path.resolve(desktopDir, "../web"));
run("vp", ["pack"], path.resolve(desktopDir, "../server"));
run("vp", ["pack"], desktopDir);
run(
"pnpm",
[
"exec",
"electron-builder",
"--config",
"electron-builder.config.ts",
`-c.extraMetadata.version=${version}`,
"--publish",
"never",
...builderArgs,
],
desktopDir,
{ ...process.env, CODEWORK_CHANNEL: channel, CODEWORK_ARCH: arch },
);
9 changes: 6 additions & 3 deletions apps/desktop/scripts/start-electron.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { fileURLToPath } from "node:url";
const desktopDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const electronBinary = createRequire(import.meta.url)("electron");
const APP_NAME = "CodeWork";
// Its own identity, so a local run never shares data or Dock state with an installed channel.
const APP_ID = "sh.codework.desktop.local";

// macOS names the Dock icon after the running bundle's Info.plist, so on macOS
// Electron runs from a renamed copy of Electron.app, rebuilt when Electron changes.
Expand All @@ -15,7 +17,8 @@ function brandedElectron() {
const target = path.join(desktopDir, ".electron-runtime", `${APP_NAME}.app`);
const marker = path.join(desktopDir, ".electron-runtime", "source");
const binary = path.join(target, "Contents", "MacOS", "Electron");
if (existsSync(marker) && readFileSync(marker, "utf8") === source) return binary;
const stamp = `${source}\n${APP_ID}`;
if (existsSync(marker) && readFileSync(marker, "utf8") === stamp) return binary;

rmSync(target, { recursive: true, force: true });
// Keeps the frameworks' relative symlinks; absolute ones crash the helper processes.
Expand All @@ -24,11 +27,11 @@ function brandedElectron() {
for (const [key, value] of [
["CFBundleName", APP_NAME],
["CFBundleDisplayName", APP_NAME],
["CFBundleIdentifier", "sh.codework.desktop.dev"],
["CFBundleIdentifier", APP_ID],
])
execFileSync("plutil", ["-replace", key, "-string", value, plist]);
execFileSync("codesign", ["--force", "--deep", "--sign", "-", target]);
writeFileSync(marker, source);
writeFileSync(marker, stamp);
return binary;
}

Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ export const SERVER_CHANNEL = "codework:server";
export const HOME_CHANNEL = "codework:home";
/** Invoke: opens the native folder dialog (Finder on macOS); resolves to the chosen path or null. */
export const PICK_FOLDER_CHANNEL = "codework:pick-folder";
/** Invoke: shows a file in Finder (Explorer on Windows), selected. */
export const REVEAL_CHANNEL = "codework:reveal";
12 changes: 8 additions & 4 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { app, BrowserWindow, dialog, ipcMain, net, protocol } from "electron";
import { app, BrowserWindow, dialog, ipcMain, net, protocol, shell } from "electron";
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import { HOME_CHANNEL, PICK_FOLDER_CHANNEL } from "./ipc.ts";
import { HOME_CHANNEL, PICK_FOLDER_CHANNEL, REVEAL_CHANNEL } from "./ipc.ts";
import { startServer } from "./server.ts";

const SCHEME = app.isPackaged ? "codework" : "codework-dev";
const HOST = "app";
const APP_URL = `${SCHEME}://${HOST}/`;

// When set (web dev server), the scheme proxies to it; otherwise the bundled
// `@codeworksh/web` build is served from disk.
// `@codeworksh/web` build is served from disk: the app's Resources when packaged,
// else the workspace beside this app.
const DEV_SERVER_URL = process.env.CODEWORK_WEB_URL;
const CLIENT_ASSETS_DIR = process.env.CODEWORK_WEB_DIR ?? path.resolve(app.getAppPath(), "../web/dist");
const CLIENT_ASSETS_DIR =
process.env.CODEWORK_WEB_DIR ??
(app.isPackaged ? path.join(process.resourcesPath, "web") : path.resolve(app.getAppPath(), "../web/dist"));
// The 3D brand mark on Apple's icon grid. A packaged build will carry it as an .icns; until then
// it is set at runtime, or the Dock shows Electron's own icon.
const ICON = path.join(app.getAppPath(), "resources/icon.png");
Expand Down Expand Up @@ -146,6 +149,7 @@ void app.whenReady().then(async () => {
const result = await (window === null ? dialog.showOpenDialog(options) : dialog.showOpenDialog(window, options));
return result.canceled ? null : (result.filePaths[0] ?? null);
});
ipcMain.handle(REVEAL_CHANNEL, (_event, file: string) => shell.showItemInFolder(file));
await startServer();
createMainWindow();

Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { contextBridge, ipcRenderer } from "electron";
import { HOME_CHANNEL, PICK_FOLDER_CHANNEL, SERVER_CHANNEL } from "./ipc.ts";
import { HOME_CHANNEL, PICK_FOLDER_CHANNEL, REVEAL_CHANNEL, SERVER_CHANNEL } from "./ipc.ts";

contextBridge.exposeInMainWorld("desktopBridge", {
platform: process.platform,
// Main creates the window only after the server is ready, so this is set.
server: ipcRenderer.sendSync(SERVER_CHANNEL) as string,
home: ipcRenderer.sendSync(HOME_CHANNEL) as string,
pickFolder: (from: string) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, from) as Promise<string | null>,
reveal: (path: string) => ipcRenderer.invoke(REVEAL_CHANNEL, path) as Promise<void>,
});
6 changes: 4 additions & 2 deletions apps/desktop/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import path from "node:path";
import { createInterface } from "node:readline";
import { SERVER_CHANNEL } from "./ipc.ts";

// Dev layout: the server bundle sits beside this app in the workspace.
const ENTRY = path.resolve(app.getAppPath(), "../server/dist/index.mjs");
// The server bundle: in the app's Resources when packaged, else beside this app in the workspace.
const ENTRY = app.isPackaged
? path.join(process.resourcesPath, "server", "index.mjs")
: path.resolve(app.getAppPath(), "../server/dist/index.mjs");

// How long the server gets to stop by itself on quit before it is killed.
const STOP_GRACE_MS = 5000;
Expand Down
Loading
Loading