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
4 changes: 0 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,6 @@ jobs:
settings:
- name: linux
host: blacksmith-4vcpu-ubuntu-2404
- name: windows
host: blacksmith-4vcpu-windows-2025
runs-on: ${{ matrix.settings.host }}
defaults:
run:
Expand Down Expand Up @@ -92,8 +90,6 @@ jobs:
settings:
- name: linux
host: blacksmith-4vcpu-ubuntu-2404
- name: windows
host: blacksmith-4vcpu-windows-2025
runs-on: ${{ matrix.settings.host }}
env:
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions deploy/systemd/shuvcode.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[Unit]
Description=Shared Shuvcode V2 server
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
ExecStart=%h/.local/bin/shuvcode serve --service --hostname=127.0.0.1 --port=4096
Restart=on-failure
RestartSec=2s
KillMode=control-group
TimeoutStopSec=20s
UMask=0077

[Install]
WantedBy=default.target
66 changes: 66 additions & 0 deletions docs/shared-service.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Shared host service

A Shuvcode host runs one managed V2 server for the interactive Shuvcode TUI and
all local or remote clients. The server owns sessions, projects, integrations,
providers, agents, plugins, MCP servers, skills, and instructions. Consumers do
not create caller-specific Shuvcode configuration domains.

## Ownership and paths

- Shuvcode owns `shuvcode.service` and its lifecycle.
- The canonical configuration root is `~/.config/opencode`; neither the unit nor
an interactive shell sets `OPENCODE_CONFIG_DIR`.
- Shuvcode's normal XDG data and state roots remain canonical for every caller.
- `~/.config/opencode/service.json` is private mode `0600` and contains the
administrator credential used by trusted loopback clients.
- Mobile clients receive independently revocable device credentials through
pairing. A bridge may keep its own client-facing credential domain while
using the administrator credential on loopback.

Install the user unit from this repository:

```sh
install -m 0644 deploy/systemd/shuvcode.service ~/.config/systemd/user/
systemctl --user daemon-reload
shuvcode service set hostname 127.0.0.1
shuvcode service set port 4096
shuvcode service set advertised-urls https://shuvdev.tail586a6d.ts.net:10001
systemctl --user enable --now shuvcode.service
tailscale serve --bg --https=10001 http://127.0.0.1:4096
```

The managed server binds loopback. Configure it while stopped, then publish the
separate advertised URL through the tailnet reverse proxy as shown above.

The bind and advertised URL are deliberately different. Do not widen the bind
to make a reverse-proxy URL reachable.

## Migrating an isolated service

Stop dependent callers, back up both configuration roots, and merge the desired
server configuration into `~/.config/opencode`. Preserve the active service
password by moving it into the canonical `service.json`, then update dependent
loopback clients to the same value without printing it. Remove any systemd
drop-in that sets `OPENCODE_CONFIG_DIR`, reload the user manager, and restart
Shuvcode before its dependants.

Provider authentication stored only in a legacy credential file is not a V2
integration connection. Reconnect those providers through the V2 TUI after the
shared server is active. Do not copy credential records directly into the V2
database.

## Verification

```sh
systemctl --user show shuvcode.service \
-p ActiveState -p SubState -p ExecStart -p Environment
ss -ltnp | rg '127\.0\.0\.1:4096'
tailscale serve status
shuvcode service get
shuvcode pair
```

Verify that interactive TUI sessions and paired mobile sessions appear in the
same session list, and that every configured V2 provider appears through the
model endpoint. Logs and verification output must not contain administrator
passwords, invitation tokens, or device credentials.
4 changes: 2 additions & 2 deletions packages/cli/script/service-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"

const target = `cli-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`
const target = `shuvcode-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`
const directory = path.join(import.meta.dir, "..", "dist", target, "bin")
const binary = path.join(directory, `opencode2${process.platform === "win32" ? ".exe" : ""}`)
const binary = path.join(directory, `shuvcode${process.platform === "win32" ? ".exe" : ""}`)
if (!(await Bun.file(binary).exists())) throw new Error(`Missing compiled CLI in ${directory}`)

const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-"))
Expand Down
15 changes: 11 additions & 4 deletions packages/cli/src/commands/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
Flag.atMost(100),
),
title: Flag.string("title").pipe(Flag.withDescription("Session title"), Flag.optional),
thinking: Flag.boolean("thinking").pipe(
Flag.withDescription("Show thinking blocks"),
Flag.withDefault(false),
),
thinking: Flag.boolean("thinking").pipe(Flag.withDescription("Show thinking blocks"), Flag.withDefault(false)),
auto: Flag.boolean("auto").pipe(
Flag.withDescription("Auto-approve permissions that are not explicitly denied"),
Flag.withDefault(false),
Expand Down Expand Up @@ -211,6 +208,16 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
],
}),
Spec.make("pair", { description: "Show server pairing information" }),
Spec.make("device", {
description: "Manage paired mobile devices",
commands: [
Spec.make("list", { description: "List paired devices" }),
Spec.make("revoke", {
description: "Revoke a paired device",
params: { deviceID: Argument.string("deviceID").pipe(Argument.withDescription("Paired device ID")) },
}),
],
}),
Spec.make("serve", {
description: "Start the v2 API server",
params: {
Expand Down
30 changes: 30 additions & 0 deletions packages/cli/src/commands/handlers/device/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { EOL } from "os"
import { OpenCode } from "@opencode-ai/client/promise"
import { Service } from "@opencode-ai/client/effect"
import { Effect } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"

export default Runtime.handler(
Commands.commands.device.commands.list,
Effect.fn("cli.device.list")(function* () {
const endpoint = yield* Service.start(yield* ServiceConfig.options())
const devices = yield* Effect.tryPromise(() =>
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).pairing.device.list(),
)
if (devices.length === 0) {
process.stdout.write("No paired devices" + EOL)
return
}
const width = Math.max(...devices.map((device) => device.deviceID.length))
process.stdout.write(
devices
.map(
(device) =>
`${device.deviceID.padEnd(width)} ${device.name} ${device.revokedAt ? `revoked ${device.revokedAt}` : "active"}`,
)
.join(EOL) + EOL,
)
}),
)
21 changes: 21 additions & 0 deletions packages/cli/src/commands/handlers/device/revoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { EOL } from "os"
import { OpenCode } from "@opencode-ai/client/promise"
import { Service } from "@opencode-ai/client/effect"
import { Effect } from "effect"
import { Pairing } from "@opencode-ai/schema/pairing"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"

export default Runtime.handler(
Commands.commands.device.commands.revoke,
Effect.fn("cli.device.revoke")(function* (input) {
const endpoint = yield* Service.start(yield* ServiceConfig.options())
yield* Effect.tryPromise(() =>
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).pairing.device.revoke({
deviceID: Pairing.DeviceID.make(input.deviceID),
}),
)
process.stdout.write(`Revoked ${input.deviceID}${EOL}`)
}),
)
17 changes: 7 additions & 10 deletions packages/cli/src/commands/handlers/pair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,19 @@ export default Runtime.handler(
Commands.commands.pair,
Effect.fn("cli.pair")(function* () {
const endpoint = yield* Service.start(yield* ServiceConfig.options())
const password = yield* ServiceConfig.password()
const server = yield* Effect.tryPromise(() =>
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.get(),
const invitation = yield* Effect.tryPromise(() =>
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).pairing.invitation.create(),
)
const info = { urls: server.urls, username: "opencode", password }
process.stdout.write(
[
"",
` URLs ${info.urls[0] ?? "(none)"}`,
...info.urls.slice(1).map((url) => ` ${url}`),
` Username ${info.username}`,
` Password ${info.password}`,
` URLs ${invitation.urls[0] ?? "(none)"}`,
...invitation.urls.slice(1).map((url) => ` ${url}`),
` Expires ${invitation.expiresAt}`,
"",
" Scan to pair",
"",
renderUnicodeCompact(JSON.stringify(info), { border: 2 })
renderUnicodeCompact(JSON.stringify(invitation), { border: 2 })
.split(EOL)
.map((line) => " " + line)
.join(EOL),
Expand All @@ -37,7 +34,7 @@ export default Runtime.handler(
const hostname = new URL(endpoint.url).hostname
if (!["localhost", "127.0.0.1", "[::1]"].includes(hostname)) return
process.stderr.write(
` Run \`opencode service set hostname 0.0.0.0\` to access the service remotely.${EOL}${EOL}`,
` The service is bound to loopback. Configure \`shuvcode service set advertised-urls https://host\` when using Tailscale Serve or a reverse proxy.${EOL}${EOL}`,
)
}),
)
4 changes: 4 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ const Handlers = Runtime.handlers(Commands, {
mini: () => import("./commands/handlers/mini"),
run: () => import("./commands/handlers/run"),
pair: () => import("./commands/handlers/pair"),
device: {
list: () => import("./commands/handlers/device/list"),
revoke: () => import("./commands/handlers/device/revoke"),
},
service: {
start: () => import("./commands/handlers/service/start"),
restart: () => import("./commands/handlers/service/restart"),
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/server-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
hostname: options.hostname ?? config.hostname ?? "127.0.0.1",
port: Option.fromNullishOr(options.port ?? config.port),
password,
advertisedURLs: config.advertisedUrls,
instanceID,
service:
serviceOptions === undefined
Expand Down
25 changes: 23 additions & 2 deletions packages/cli/src/services/service-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Hash } from "@opencode-ai/core/util/hash"
import { Service } from "@opencode-ai/client/effect"
import { Effect, FileSystem, Option, Schema } from "effect"
import { randomBytes } from "crypto"
import { Pairing } from "@opencode-ai/schema/pairing"
import path from "path"

// The CLI's service configuration file, plus the Service.Options binding that
Expand All @@ -14,10 +15,11 @@ export const Info = Schema.Struct({
hostname: Schema.optional(Schema.String),
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
password: Schema.optional(Schema.String),
advertisedUrls: Schema.optional(Schema.Array(Schema.String)),
})
export type Info = typeof Info.Type

const keys = ["hostname", "port", "password"] as const
const keys = ["hostname", "port", "password", "advertised-urls"] as const
type Key = (typeof keys)[number]

const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
Expand Down Expand Up @@ -58,7 +60,7 @@ export const migrateRegistration = Effect.fnUntraced(function* (
})

function configKey(key: string): Key {
if (key === "hostname" || key === "port" || key === "password") return key
if (keys.includes(key as Key)) return key as Key
throw new Error(`Unknown service config key: ${key}`)
}

Expand Down Expand Up @@ -131,6 +133,9 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string)
case "password": {
return yield* password()
}
case "advertised-urls": {
return ((yield* read()).advertisedUrls ?? []).join(",")
}
}
throw new Error(`Unknown service config key: ${key}`)
})
Expand All @@ -154,6 +159,16 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v
yield* password(value)
return
}
case "advertised-urls": {
const advertisedUrls = value
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0)
Pairing.advertisedURLs(advertisedUrls)
yield* Service.stop(yield* options())
yield* write({ ...(yield* read()), advertisedUrls })
return
}
}
})

Expand All @@ -177,6 +192,12 @@ export const unset = Effect.fn("cli.service-config.unset")(function* (key: strin
yield* write(next)
return
}
case "advertised-urls": {
yield* Service.stop(yield* options())
const { advertisedUrls: _advertisedUrls, ...next } = yield* read()
yield* write(next)
return
}
}
})

Expand Down
6 changes: 5 additions & 1 deletion packages/cli/test/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,17 @@ test("local channel stores service config with the local service filename", asyn
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
try {
await Effect.runPromise(
ServiceConfig.set("hostname", "127.0.0.2").pipe(
Effect.gen(function* () {
yield* ServiceConfig.set("hostname", "127.0.0.2")
yield* ServiceConfig.set("advertised-urls", "https://shuvdev.example:10001,http://127.0.0.1:4096")
}).pipe(
Effect.provide(Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })),
Effect.provide(NodeFileSystem.layer),
),
)
expect(await Bun.file(path.join(root, "config", "service-local.json")).json()).toEqual({
hostname: "127.0.0.2",
advertisedUrls: ["https://shuvdev.example:10001", "http://127.0.0.1:4096"],
})
expect(await Bun.file(path.join(root, "config", "service.json")).exists()).toBe(false)
} finally {
Expand Down
Loading
Loading