Skip to content

Commit 6cc50a0

Browse files
chriswritescode-devCS
andauthored
feat(ocm-cli): add file and keychain credential backends with auth commands (#327)
* feat(ocm-cli): add file and keychain credential backends with auth commands * feat(ocm-cli): refactor credentials into async TokenStore architecture Replace the credential-backend module with a TokenStore interface and file/keychain implementations behind an internal facade. Convert token get/set/delete to async, add env-token precedence warnings on login and logout, and surface store availability in status. Add token-store tests and helpers; remove the old credential modules and tests. Bump to 0.2.6. * fix(ocm-cli): keep tokens out of security argv and stop masking config read errors Route every macOS Keychain call through `security -i`, sending the quoted command on stdin so the token is never visible in process arguments. Reject arguments containing line breaks, which would otherwise inject additional security commands. Let unexpected state file read failures propagate so a broken config dir is reported instead of being reduced to "no manager configured", and stop consuming the install notice when reading it fails unexpectedly. --------- Co-authored-by: CS <chris@chriswritescode.dev>
1 parent 29f154a commit 6cc50a0

19 files changed

Lines changed: 1560 additions & 117 deletions

docs/ocm-cli.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ ocm login https://your-manager-url
7676
# paste your Manager internal token when prompted
7777
```
7878

79-
The token is stored in the macOS Keychain under the manager URL. The manager URL is persisted to `~/.config/opencode-manager/state.json`.
79+
The token is stored in a platform-specific token store: the macOS Keychain (service `opencode-manager`, account = manager URL) on macOS, or `~/.config/opencode-manager/credentials.json` at mode `0600` on Linux. On Linux the token is plaintext JSON protected only by file permissions. Run `ocm status` to see the active store. The manager URL itself is persisted to `~/.config/opencode-manager/state.json`.
80+
81+
Windows is not supported: the CLI falls back to the same file store, but the `0600` mode is not enforced there and hidden token entry requires `bash`.
8082

8183
Generate or rotate your internal token from **Settings → Manager Token** in the Manager web UI (Settings cog in the sidebar, then **Manager Token**).
8284

@@ -88,7 +90,7 @@ Generate or rotate your internal token from **Settings → Manager Token** in th
8890
ocm Attach to the Manager repo matching $PWD's git origin,
8991
or fall back to the last selected repo
9092
ocm login <url> [token] Save manager URL + token (token via stdin if omitted)
91-
ocm logout Forget saved token (Keychain) and state
93+
ocm logout Forget saved token and state
9294
ocm status Show current manager URL, repo, and whether token is set
9395
ocm list List ready repos from the manager
9496
ocm use <repoId|name> Attach to a specific repo and remember it as last
@@ -141,14 +143,15 @@ When the TUI plugin entry is installed, `/ocm-move` is available in local OpenCo
141143

142144
## 4. Environment variables
143145

144-
Both can be used in place of `ocm login`:
146+
The CLI's environment and token inputs:
145147

146148
| Variable | Description |
147149
|---|---|
148150
| `OPENCODE_MANAGER_URL` | Manager base URL (e.g., `https://manager.example.com`). Not currently consumed by the CLI — use `ocm login`. |
149151
| `OCM_REMOTE_MANAGER_URL` | Internal child-process context set by `ocm attach`; controls the remote TUI indicator. Not a login setting. |
150152
| `OCM_REMOTE_REPO_NAME` | Internal child-process context set by `ocm attach`; labels the remote TUI indicator. Not a login setting. |
151-
| Keychain entry under `https://manager.example.com` | Token used for Bearer auth on Manager API calls and Basic auth on the OpenCode proxy. |
153+
| `OCM_TOKEN` | Read-only override for the stored token; takes priority over the platform token store. `ocm login` never writes it, and `ocm logout` cannot remove it. Because the manager URL still comes from `state.json`, CI must run `ocm login` first, so this override does not yet avoid writing a token to disk. |
154+
| Token store entry under `<manager url>` | Token used for Bearer auth on Manager API calls and Basic auth on the OpenCode proxy. macOS Keychain (service `opencode-manager`) or `~/.config/opencode-manager/credentials.json` (mode `0600`) on Linux. |
152155

153156
---
154157

ocm-cli/README.md

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,24 @@ also create a best-effort `~/.local/bin/ocm` symlink.
2222
ocm login <manager-url> [token]
2323
```
2424

25-
The token is stored in macOS Keychain under the `opencode-manager` service. CLI
26-
state is stored at `~/.config/opencode-manager/state.json`.
25+
The token is stored in a platform-specific token store:
2726

28-
If `[token]` is omitted, `ocm login` reads it from hidden TTY input or stdin.
27+
| Platform | Store |
28+
|---|---|
29+
| macOS | Keychain, service `opencode-manager`, account = manager URL |
30+
| Linux | `~/.config/opencode-manager/credentials.json`, mode `0600` |
31+
32+
On Linux the token is stored as plaintext JSON protected only by file
33+
permissions. CLI state is stored at `~/.config/opencode-manager/state.json`.
34+
Windows is unsupported: the same file store is used, but the `0600` mode is not
35+
enforced there.
36+
37+
`OCM_TOKEN` overrides the token store for reads; `ocm login` always writes to
38+
the platform store and `ocm logout` cannot remove the override. Run `ocm status`
39+
to see the active store.
40+
41+
If `[token]` is omitted, `ocm login` reads it from hidden TTY input (requires
42+
`bash`) or stdin.
2943

3044
## Commands
3145

@@ -92,7 +106,9 @@ global installs); the plugin surface is TUI-only.
92106

93107
## Requirements
94108

109+
- macOS or Linux (Windows is unsupported)
95110
- `opencode` available on `PATH`
96111
- `git` and `tar` (with gzip support, i.e. the `-z` flag) available on `PATH`
97-
- macOS `security` CLI for Keychain-backed token storage
112+
- `bash`, used for hidden token entry and interactive confirmations
113+
- macOS only: `/usr/bin/security`, used for Keychain-backed token storage (Linux uses a mode-`0600` file under the user config dir `~/.config/opencode-manager`)
98114
- An OpenCode Manager URL and bearer token

ocm-cli/bin/ocm.ts

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { spawn, spawnSync } from 'child_process'
22
import { basename } from 'path'
33
import { readState, writeState, clearState, getStatePath, type OcmState } from '../src/state.js'
4-
import { getToken, setToken, deleteToken, KeychainError } from '../src/keychain.js'
4+
import { getToken, setToken, deleteToken, hasStoredToken, describeTokenStore, describeTokenWriteTarget, envToken, TOKEN_ENV, TokenStoreError } from '../src/internal-token-store.js'
55
import { ManagerApi, ManagerApiError } from '../src/manager-api.js'
66
import { mirrorUp, mirrorDown, mirrorUpFast, mirrorDownFast, prepareMirror, MirrorAbort, checkPushDivergence, checkPullDivergence } from '../src/mirror.js'
77
import type { RemoteRepoSummary, MirrorProgress, PushDivergence, PullDivergence } from '../src/mirror.js'
@@ -22,7 +22,7 @@ Usage:
2222
fall back to the last selected repo, or launch local
2323
opencode when no Manager target applies
2424
ocm login <url> [token] Save manager URL + token (token via stdin if omitted)
25-
ocm logout Forget saved token (Keychain) and state
25+
ocm logout Forget saved token and state
2626
ocm status Show current manager URL, repo, and whether token is set
2727
ocm list List ready repos from the manager
2828
ocm use <repoId|name> Attach to a specific repo and remember it as last
@@ -108,10 +108,17 @@ function requireState(): OcmState {
108108
return state
109109
}
110110

111-
function requireToken(state: OcmState): string {
112-
const token = getToken(state.managerUrl)
111+
async function requireToken(state: OcmState): Promise<string> {
112+
const store = describeTokenStore()
113+
let token: string | null
114+
try {
115+
token = await getToken(state.managerUrl)
116+
} catch (err) {
117+
if (!(err instanceof TokenStoreError)) throw err
118+
die(`token store error (${store.kind}: ${store.location}): ${err.message}. Run \`ocm login ${state.managerUrl}\` after fixing the store.`)
119+
}
113120
if (!token) {
114-
die(`no token in Keychain for ${state.managerUrl}. Run \`ocm login ${state.managerUrl}\`.`)
121+
die(`no token stored for ${state.managerUrl} (${store.kind}: ${store.location}). Run \`ocm login ${state.managerUrl}\`.`)
115122
}
116123
return token
117124
}
@@ -164,7 +171,7 @@ function findRepo(repos: ManagerRepo[], needle: string | number): ManagerRepo |
164171
return repos.find((r) => r.name === needle) ?? repos.find((r) => r.name.toLowerCase() === needle.toLowerCase())
165172
}
166173

167-
async function cmdLogin(args: string[]): Promise<void> {
174+
export async function cmdLogin(args: string[]): Promise<void> {
168175
const url = args[0]
169176
if (!url) die('usage: ocm login <url> [token]')
170177
const normalisedUrl = url.replace(/\/+$/, '')
@@ -190,9 +197,9 @@ async function cmdLogin(args: string[]): Promise<void> {
190197
if (!token) die('no token provided')
191198

192199
try {
193-
setToken(normalisedUrl, token)
200+
await setToken(normalisedUrl, token)
194201
} catch (err) {
195-
if (err instanceof KeychainError) die(`Keychain error: ${err.message}`)
202+
if (err instanceof TokenStoreError) die(`token store error: ${err.message}`)
196203
throw err
197204
}
198205

@@ -202,32 +209,65 @@ async function cmdLogin(args: string[]): Promise<void> {
202209
managerUrl: normalisedUrl,
203210
})
204211

205-
info(`Saved token for ${normalisedUrl} in Keychain.`)
212+
const store = describeTokenWriteTarget()
213+
info(`Saved token for ${normalisedUrl} (${store.kind}: ${store.location}).`)
206214
info(`State file: ${getStatePath()}`)
215+
warnEnvTokenPrecedence()
207216
}
208217

209-
async function cmdLogout(): Promise<void> {
218+
export async function cmdLogout(): Promise<void> {
210219
const state = readState()
211220
if (!state || !state.managerUrl) {
212221
info('Nothing to log out from.')
213222
return
214223
}
215-
const deleted = deleteToken(state.managerUrl)
224+
let deleted = false
225+
let storeError: TokenStoreError | undefined
226+
try {
227+
deleted = await deleteToken(state.managerUrl)
228+
} catch (err) {
229+
if (!(err instanceof TokenStoreError)) throw err
230+
storeError = err
231+
}
216232
clearState()
217-
info(deleted ? `Removed Keychain entry for ${state.managerUrl}.` : `No Keychain entry found.`)
233+
if (storeError) {
234+
info('State cleared.')
235+
die(`token store error: ${storeError.message}. The stored token may still exist.`)
236+
}
237+
info(deleted ? `Removed stored token for ${state.managerUrl}.` : 'No stored token found.')
218238
info('State cleared.')
239+
warnEnvTokenPrecedence()
240+
}
241+
242+
function warnEnvTokenPrecedence(): void {
243+
if (envToken()) {
244+
info(`note: ${TOKEN_ENV} is set and takes precedence over the stored token; unset it to use the token store.`)
245+
}
246+
}
247+
248+
async function describeTokenPresence(account: string | undefined): Promise<string> {
249+
try {
250+
return (await hasStoredToken(account)) ? 'yes' : 'no'
251+
} catch (err) {
252+
if (!(err instanceof TokenStoreError)) throw err
253+
return `unavailable (${err.message})`
254+
}
219255
}
220256

221-
async function cmdStatus(): Promise<void> {
257+
export async function cmdStatus(): Promise<void> {
258+
const store = describeTokenStore()
222259
const state = readState()
223260
if (!state) {
224261
info(`version: ${VERSION}`)
262+
info(`token store: ${store.kind} (${store.location})`)
263+
info(`token: ${await describeTokenPresence(undefined)}`)
225264
info('no state. run: ocm login <url>')
226265
return
227266
}
228267
info(`version: ${VERSION}`)
229268
info(`manager url: ${state.managerUrl}`)
230-
info(`token in kc: ${getToken(state.managerUrl) ? 'yes' : 'no'}`)
269+
info(`token: ${await describeTokenPresence(state.managerUrl)}`)
270+
info(`token store: ${store.kind} (${store.location})`)
231271
if (state.lastRepoId !== undefined) {
232272
info(`last repo: ${state.lastRepoName} (id=${state.lastRepoId}, branch=${state.lastRepoBranch ?? 'n/a'})`)
233273
info(`last repo dir: ${state.lastRepoDir}`)
@@ -239,7 +279,7 @@ async function cmdStatus(): Promise<void> {
239279

240280
async function cmdList(): Promise<void> {
241281
const state = requireState()
242-
const token = requireToken(state)
282+
const token = await requireToken(state)
243283
const repos = await fetchRepos(state.managerUrl, token)
244284
if (repos.length === 0) {
245285
info('No ready repos.')
@@ -259,7 +299,7 @@ async function cmdUse(args: string[]): Promise<void> {
259299
const needle = args[0]
260300
if (!needle) die('usage: ocm use <repoId|name>')
261301
const state = requireState()
262-
const token = requireToken(state)
302+
const token = await requireToken(state)
263303
const repos = await fetchRepos(state.managerUrl, token)
264304
const repo = findRepo(repos, needle)
265305
if (!repo) die(`repo not found: ${needle}`)
@@ -278,7 +318,7 @@ async function cmdUse(args: string[]): Promise<void> {
278318
async function cmdDefault(): Promise<void> {
279319
info(`ocm v${VERSION}`)
280320
const state = requireState()
281-
const token = requireToken(state)
321+
const token = await requireToken(state)
282322

283323
const last = state.lastRepoId !== undefined && state.lastRepoDir
284324
? {
@@ -361,7 +401,7 @@ export async function cmdPush(args: string[]): Promise<void> {
361401
}
362402

363403
const state = requireState()
364-
const token = requireToken(state)
404+
const token = await requireToken(state)
365405
const api = new ManagerApi(state.managerUrl, token)
366406
const repos = await fetchRepos(state.managerUrl, token)
367407

@@ -439,7 +479,7 @@ async function cmdPull(args: string[]): Promise<void> {
439479
}
440480

441481
const state = requireState()
442-
const token = requireToken(state)
482+
const token = await requireToken(state)
443483
const api = new ManagerApi(state.managerUrl, token)
444484
const repos = await fetchRepos(state.managerUrl, token)
445485

ocm-cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@opencode-manager/ocm-cli",
3-
"version": "0.2.5",
3+
"version": "0.2.6",
44
"description": "OpenCode Manager CLI: attach a local OpenCode TUI to a Manager-hosted repo.",
55
"license": "MIT",
66
"repository": {
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import type { TokenStore, TokenStoreDescription } from './token-store.js'
2+
import { TokenStoreError } from './token-store.js'
3+
import { createFileTokenStore } from './token-store-file.js'
4+
import { createKeychainTokenStore } from './token-store-keychain.js'
5+
6+
export const TOKEN_ENV = 'OCM_TOKEN'
7+
8+
export function selectTokenStore(platform: NodeJS.Platform = process.platform): TokenStore {
9+
return platform === 'darwin'
10+
? createKeychainTokenStore()
11+
: createFileTokenStore()
12+
}
13+
14+
function memoizeReads(store: TokenStore): TokenStore {
15+
const cache = new Map<string, string>()
16+
return {
17+
describe: () => store.describe(),
18+
async get(account: string): Promise<string | null> {
19+
const cached = cache.get(account)
20+
if (cached) return cached
21+
const token = await store.get(account)
22+
if (token) cache.set(account, token)
23+
return token
24+
},
25+
async set(account: string, token: string): Promise<void> {
26+
await store.set(account, token)
27+
cache.set(account, token)
28+
},
29+
async delete(account: string): Promise<boolean> {
30+
cache.delete(account)
31+
return store.delete(account)
32+
},
33+
}
34+
}
35+
36+
let active: TokenStore | undefined
37+
function defaultStore(): TokenStore {
38+
active ??= memoizeReads(selectTokenStore())
39+
return active
40+
}
41+
42+
export function envToken(): string | null {
43+
const raw = process.env[TOKEN_ENV]?.trim()
44+
return raw ? raw : null
45+
}
46+
47+
export async function getToken(
48+
account: string,
49+
store: TokenStore = defaultStore(),
50+
): Promise<string | null> {
51+
return envToken() ?? (await store.get(account))
52+
}
53+
54+
export async function setToken(
55+
account: string,
56+
token: string,
57+
store: TokenStore = defaultStore(),
58+
): Promise<void> {
59+
const normalised = token.trim()
60+
if (!normalised) {
61+
throw new TokenStoreError('refusing to store an empty token', store.describe().kind)
62+
}
63+
await store.set(account, normalised)
64+
}
65+
66+
export async function deleteToken(
67+
account: string,
68+
store: TokenStore = defaultStore(),
69+
): Promise<boolean> {
70+
return store.delete(account)
71+
}
72+
73+
export async function hasStoredToken(
74+
account: string | undefined,
75+
store: TokenStore = defaultStore(),
76+
): Promise<boolean> {
77+
if (envToken()) return true
78+
if (!account) return false
79+
return (await store.get(account)) !== null
80+
}
81+
82+
export function describeTokenStore(
83+
store: TokenStore = defaultStore(),
84+
): TokenStoreDescription {
85+
return envToken() ? { kind: 'env', location: TOKEN_ENV } : store.describe()
86+
}
87+
88+
export function describeTokenWriteTarget(
89+
store: TokenStore = defaultStore(),
90+
): TokenStoreDescription {
91+
return store.describe()
92+
}
93+
94+
export { TokenStoreError } from './token-store.js'

0 commit comments

Comments
 (0)