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
64 changes: 64 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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' || '' }}
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ bun run --cwd packages/opencode build --single

The build output is written to `packages/opencode/dist/dcode-<os>-<arch>/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-<os>-<arch>` 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:
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/bin/dcode
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/script/postinstall.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
116 changes: 116 additions & 0 deletions packages/opencode/script/publish.ts
Original file line number Diff line number Diff line change
@@ -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-<os>-<arch>[-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<string, string> = {}
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)`)
11 changes: 6 additions & 5 deletions packages/script/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Loading