diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 000000000000..e2de22cbd1c3 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,64 @@ +name: publish-npm + +on: + workflow_dispatch: + inputs: + version: + description: Version to publish without the leading v (e.g. 1.18.0) + required: true + type: string + channel: + description: npm dist-tag / channel (latest for stable, e.g. beta for previews) + type: string + default: latest + dry_run: + description: Build and validate without publishing to npm + type: boolean + default: false + +permissions: + contents: read + +concurrency: publish-npm-${{ inputs.version }} + +jobs: + publish: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: ./.github/actions/setup-bun + + - name: Validate version + env: + VERSION: ${{ inputs.version }} + run: | + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]; then + echo "Invalid version: $VERSION" >&2 + exit 1 + fi + + - name: Configure npm auth + if: ${{ !inputs.dry_run }} + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [ -z "$NPM_TOKEN" ]; then + echo "NPM_TOKEN secret is not set" >&2 + exit 1 + fi + # Pin the registry so publish can't be redirected by a repo/workspace .npmrc. + { + echo "registry=https://registry.npmjs.org/" + echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" + } > "$HOME/.npmrc" + + - name: Build and publish to npm + working-directory: packages/opencode + env: + OPENCODE_CHANNEL: ${{ inputs.channel }} + OPENCODE_VERSION: ${{ inputs.version }} + run: bun run script/publish.ts ${{ inputs.dry_run && '--dry-run' || '' }} diff --git a/README.md b/README.md index 95774c47acb6..576ac278a957 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,17 @@ bun run --cwd packages/opencode build --single The build output is written to `packages/opencode/dist/dcode--/bin/dcode`. +### npm + +```bash +npm install -g dcode-ai +``` + +This installs the `dcode` command. npm automatically selects the prebuilt binary +for your platform (published as `dcode-ai--` packages). Publishing is +driven by the `publish-npm` GitHub Actions workflow and requires an `NPM_TOKEN` +repository secret. + ### GitHub Releases Once a release is available, install the latest build with: @@ -35,9 +46,9 @@ The installer writes the executable to `~/.dcode/bin` by default. It supports order. DCode releases are published at [CreatorGhost/TheCode](https://github.com/CreatorGhost/TheCode/releases). -DCode is not currently distributed through npm, Homebrew, Chocolatey, Scoop, -Arch, Nix, or desktop app stores. Packages named `opencode` or `opencode-ai` in -those channels install upstream OpenCode, not DCode. +DCode is not distributed through Homebrew, Chocolatey, Scoop, Arch, Nix, or +desktop app stores. Packages named `opencode` or `opencode-ai` in those channels +install upstream OpenCode, not DCode. ## Usage diff --git a/packages/opencode/bin/dcode b/packages/opencode/bin/dcode index 06f7627ccf9a..1734d2d00b45 100755 --- a/packages/opencode/bin/dcode +++ b/packages/opencode/bin/dcode @@ -70,7 +70,7 @@ let arch = archMap[os.arch()] if (!arch) { arch = os.arch() } -const base = "dcode-" + platform + "-" + arch +const base = "dcode-ai-" + platform + "-" + arch const binary = platform === "windows" ? "dcode.exe" : "dcode" function supportsAvx2() { diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 131ce608299c..dd8618570308 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -12,6 +12,7 @@ "bench:test": "bun run script/bench-test-suite.ts", "profile:test": "bun run script/profile-test-files.ts", "build": "bun run script/build.ts", + "publish:npm": "bun run script/publish.ts", "dev": "bun run --conditions=browser ./src/index.ts", "dev:temporary": "bun run --conditions=browser ./src/temporary.ts" }, diff --git a/packages/opencode/script/postinstall.mjs b/packages/opencode/script/postinstall.mjs index ff483aae8033..0b2f618d8093 100644 --- a/packages/opencode/script/postinstall.mjs +++ b/packages/opencode/script/postinstall.mjs @@ -24,7 +24,7 @@ const archMap = { const platform = platformMap[os.platform()] ?? os.platform() const arch = archMap[os.arch()] ?? os.arch() -const base = `dcode-${platform}-${arch}` +const base = `dcode-ai-${platform}-${arch}` const sourceBinary = platform === "windows" ? "dcode.exe" : "dcode" const targetBinary = path.join(__dirname, "bin", "dcode.exe") diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts new file mode 100644 index 000000000000..7a04ae3a6b52 --- /dev/null +++ b/packages/opencode/script/publish.ts @@ -0,0 +1,116 @@ +#!/usr/bin/env bun + +// Publishes the DCode CLI to npm as an esbuild-style package: one thin wrapper +// (`dcode-ai`) plus a per-platform binary package for every build target +// (`dcode-ai--[-baseline][-musl]`). npm installs only the platform +// package matching the user's os/cpu, and the wrapper's bin/dcode shim resolves +// and execs it at runtime (pure optionalDependencies, no postinstall step). If a +// platform's optional dependency fails to install, the shim prints the exact +// package name to install manually. +// +// OPENCODE_VERSION=1.2.3 OPENCODE_CHANNEL=latest bun run script/publish.ts +// bun run script/publish.ts --dry-run # validate without publishing +// bun run script/publish.ts --single --dry-run # build only the current platform +// +// Auth: npm publish reads ~/.npmrc (NODE_AUTH_TOKEN/NPM_TOKEN). --dry-run needs none. + +import { $ } from "bun" +import fs from "fs" +import path from "path" +import { fileURLToPath } from "url" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const dir = path.resolve(__dirname, "..") +process.chdir(dir) + +import { Script } from "@opencode-ai/script" +// Importing build.ts runs the full build (all targets) and returns the produced +// platform-package name -> version map. It uploads to GitHub Releases only when +// OPENCODE_RELEASE is set, which the npm publish flow deliberately leaves unset. +const { binaries } = await import("./build.ts") + +const NPM_NAME = "dcode-ai" +const version = Script.version +const dryRun = process.argv.includes("--dry-run") +const single = process.argv.includes("--single") +// Preview channels publish under their channel dist-tag so they never become the +// default `npm install dcode-ai`. +const tag = Script.channel === "latest" ? "latest" : Script.channel +const flags = ["--access", "public", "--tag", tag, ...(dryRun ? ["--dry-run"] : [])] + +// A real publish must build every platform (a --single wrapper would only install +// on one OS/arch) and carry an explicit version (never the 0.0.0 fallback). +if (!dryRun && single) throw new Error("--single builds one platform; it is only valid with --dry-run") +if (!dryRun && !process.env["OPENCODE_VERSION"]) throw new Error("set OPENCODE_VERSION for a real publish") + +const names = Object.keys(binaries) +if (names.length === 0) throw new Error("build produced no binaries") + +// npm forbids republishing an existing version. Skip already-published packages so +// a re-run after a partial failure is idempotent instead of 403-aborting. +const alreadyPublished = async (name: string) => { + if (dryRun) return false + const res = await fetch(`https://registry.npmjs.org/${name}/${version}`) + return res.ok +} +const publish = async (pkgDir: string, name: string) => { + if (await alreadyPublished(name)) { + console.log(`skipping ${name}@${version} (already published)`) + return + } + console.log(`publishing ${name}@${version}`) + await $`npm publish ${flags}`.cwd(pkgDir) +} + +// Map each built target (dcode-<...>) to its published npm name (dcode-ai-<...>). +const npmNameFor = (built: string) => built.replace(/^dcode-/, `${NPM_NAME}-`) + +const optionalDependencies: Record = {} +for (const built of names) { + const npmName = npmNameFor(built) + const pkgDir = path.join(dir, "dist", built) + const pkgJsonPath = path.join(pkgDir, "package.json") + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")) + pkgJson.name = npmName + pkgJson.description = `DCode CLI binary for ${pkgJson.os?.[0]} ${pkgJson.cpu?.[0]}` + pkgJson.license = "MIT" + pkgJson.repository = { type: "git", url: "git+https://github.com/CreatorGhost/TheCode.git" } + fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2)) + await publish(pkgDir, npmName) + optionalDependencies[npmName] = version +} + +// Assemble the wrapper package from a clean generated manifest (the source +// package.json is private and carries workspace deps + raw TS exports). +const wrapperDir = path.join(dir, "dist", NPM_NAME) +fs.mkdirSync(path.join(wrapperDir, "bin"), { recursive: true }) +fs.copyFileSync(path.join(dir, "bin", "dcode"), path.join(wrapperDir, "bin", "dcode")) +fs.chmodSync(path.join(wrapperDir, "bin", "dcode"), 0o755) + +const readme = path.resolve(dir, "../../README.md") +if (fs.existsSync(readme)) fs.copyFileSync(readme, path.join(wrapperDir, "README.md")) + +fs.writeFileSync( + path.join(wrapperDir, "package.json"), + JSON.stringify( + { + name: NPM_NAME, + version, + description: "DCode — an AI coding agent for the terminal (fork of opencode)", + bin: { dcode: "bin/dcode" }, + optionalDependencies, + license: "MIT", + repository: { type: "git", url: "git+https://github.com/CreatorGhost/TheCode.git" }, + homepage: "https://github.com/CreatorGhost/TheCode", + bugs: { url: "https://github.com/CreatorGhost/TheCode/issues" }, + keywords: ["dcode", "opencode", "ai", "cli", "coding-agent", "terminal", "agent"], + files: ["bin", "README.md"], + }, + null, + 2, + ), +) + +await publish(wrapperDir, NPM_NAME) + +console.log(dryRun ? "dry run complete" : `published ${NPM_NAME}@${version} (${names.length} platform packages)`) diff --git a/packages/script/src/index.ts b/packages/script/src/index.ts index d148ce0d2b70..2c24a1ed3c24 100644 --- a/packages/script/src/index.ts +++ b/packages/script/src/index.ts @@ -34,12 +34,13 @@ const IS_PREVIEW = CHANNEL !== "latest" const VERSION = await (async () => { if (env.OPENCODE_VERSION) return env.OPENCODE_VERSION if (IS_PREVIEW) return `0.0.0-${CHANNEL}-${new Date().toISOString().slice(0, 16).replace(/[-:T]/g, "")}` - const version = await fetch("https://registry.npmjs.org/opencode-ai/latest") - .then((res) => { - if (!res.ok) throw new Error(res.statusText) - return res.json() - }) + // Latest channel without an explicit OPENCODE_VERSION: bump the last published + // dcode-ai release. Falls back to 0.0.0 for the very first publish (before the + // package exists on npm). In CI the publish workflow always sets OPENCODE_VERSION. + const version = await fetch("https://registry.npmjs.org/dcode-ai/latest") + .then((res) => (res.ok ? res.json() : { version: "0.0.0" })) .then((data: any) => data.version) + .catch(() => "0.0.0") const [major, minor, patch] = version.split(".").map((x: string) => Number(x) || 0) const t = env.OPENCODE_BUMP?.toLowerCase() if (t === "major") return `${major + 1}.0.0`