@neta-art/cohub-cli 10.0.0 → 10.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,6 +33,28 @@ ENV=dev cohub auth login
33
33
  ENV=dev cohub spaces ls
34
34
  ```
35
35
 
36
+ Use a custom API endpoint for self-hosted deployments or proxies:
37
+
38
+ ```bash
39
+ cohub --base-url https://api.example.com spaces ls
40
+ COHUB_BASE_URL=https://api.example.com cohub spaces ls
41
+ ```
42
+
43
+ `--base-url` takes precedence over `COHUB_BASE_URL`. Without either, the CLI uses
44
+ the selected environment's default API address. Configure other endpoints separately:
45
+
46
+ | Variable | Endpoint |
47
+ | --- | --- |
48
+ | `COHUB_BASE_URL` | API base URL |
49
+ | `COHUB_WS_URL` | Realtime gateway URL (`http(s)` or `ws(s)`) |
50
+ | `COHUB_WEB_URL` | Web base URL used in links |
51
+
52
+ Changing the API base URL does not change the gateway or authentication provider.
53
+ For self-hosted authentication, set `COHUB_AUTH_ISSUER`, `COHUB_AUTH_CLIENT_ID`,
54
+ and, if needed, `COHUB_AUTH_RESOURCE` and `COHUB_AUTH_SCOPE`. Stored login sessions,
55
+ Home Space caches, and Runtime directory bindings are scoped to the API base URL.
56
+ Sign in once for each custom server, or supply `COHUB_EXECUTION_TOKEN`.
57
+
36
58
  ## Usage
37
59
 
38
60
  Use `--json` when reading output for decisions, extracting IDs, or chaining commands.
@@ -74,8 +96,8 @@ cohub -s <spaceId> run -- git status
74
96
 
75
97
  Space-scoped commands use `-s`, then `COHUB_SPACE_ID`, then the Space remembered for
76
98
  the current directory. `<space>` is a Space ID, a slug you own such as `home`, or
77
- `username/slug`. Only `prompt`, `completion`, `generate`, `apps`, and `public` fall
78
- back to your Home Space; other commands exit with an error when no target is given:
99
+ `username/slug`. Only `prompt`, `completion`, `decision`, `generate`, `apps`, and
100
+ `public` fall back to your Home Space; other commands exit with an error when no target is given:
79
101
 
80
102
  ```bash
81
103
  cohub -s <spaceId> spaces prompt "message" --json
package/dist/auth.d.ts CHANGED
@@ -12,6 +12,7 @@ type DeviceCode = {
12
12
  type AuthSession = {
13
13
  schemaVersion: 1;
14
14
  env: CohubEnvironment;
15
+ baseUrl?: string;
15
16
  issuer: string;
16
17
  clientId: string;
17
18
  resource: string;
@@ -28,7 +29,7 @@ export declare class AuthRequiredError extends Error {
28
29
  constructor(message?: string);
29
30
  }
30
31
  export declare const readAuthSession: () => AuthSession | null;
31
- export declare const clearAuthSession: () => void;
32
+ export declare const clearAuthSession: (env?: CohubEnvironment, baseUrl?: string) => void;
32
33
  export declare const authSource: () => AuthSource;
33
34
  export declare function resolveAccessToken(options?: {
34
35
  forceRefresh?: boolean;
package/dist/auth.js CHANGED
@@ -1,6 +1,6 @@
1
- import { resolveCohubEnvironment } from "@neta-art/cohub";
1
+ import { COHUB_ENVIRONMENTS, normalizeBaseUrl, resolveApiBaseUrl, resolveCohubEnvironment } from "@neta-art/cohub";
2
2
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
- import { randomUUID } from "node:crypto";
3
+ import { createHash, randomUUID } from "node:crypto";
4
4
  import { withRuntimeSpaceBindingsLock } from "./runtime/space-binding.js";
5
5
  import { homedir } from "node:os";
6
6
  import { join } from "node:path";
@@ -28,9 +28,17 @@ const authConfig = (env = currentEnv()) => {
28
28
  scope: process.env[`${prefix}_AUTH_SCOPE`] ?? process.env.COHUB_AUTH_SCOPE ?? DEFAULT_SCOPE,
29
29
  };
30
30
  };
31
- const sessionPath = (env = currentEnv()) => join(CONFIG_DIR, env === "dev" ? "auth.dev.json" : "auth.json");
32
- const deviceCodePath = (env = currentEnv()) => join(CONFIG_DIR, env === "dev" ? "device-code.dev.json" : "device-code.json");
33
- const legacyTokenPath = (env = currentEnv()) => join(CONFIG_DIR, env === "dev" ? "token.dev" : "token");
31
+ const endpointSuffix = (env, baseUrl = resolveApiBaseUrl({ env })) => {
32
+ const url = normalizeBaseUrl(baseUrl);
33
+ const endpoint = url === COHUB_ENVIRONMENTS[env].apiBaseUrl
34
+ ? ""
35
+ : `.${createHash("sha256").update(url).digest("hex")}`;
36
+ return `${env === "dev" ? ".dev" : ""}${endpoint}`;
37
+ };
38
+ const sessionPath = (env = currentEnv(), baseUrl) => join(CONFIG_DIR, `auth${endpointSuffix(env, baseUrl)}.json`);
39
+ const deviceCodePath = (env = currentEnv(), baseUrl) => join(CONFIG_DIR, `device-code${endpointSuffix(env, baseUrl)}.json`);
40
+ const legacyTokenPath = (env = currentEnv(), baseUrl) => join(CONFIG_DIR, `token${endpointSuffix(env, baseUrl)}`);
41
+ const sessionBaseUrl = (session) => session.baseUrl ?? COHUB_ENVIRONMENTS[session.env].apiBaseUrl;
34
42
  const writePrivateJson = (path, value) => {
35
43
  mkdirSync(CONFIG_DIR, { recursive: true });
36
44
  const temporary = `${path}.${randomUUID()}.tmp`;
@@ -106,6 +114,7 @@ const toSession = (token, config, env, previous) => {
106
114
  return {
107
115
  schemaVersion: 1,
108
116
  env,
117
+ baseUrl: previous ? sessionBaseUrl(previous) : resolveApiBaseUrl({ env }),
109
118
  issuer: config.issuer,
110
119
  clientId: config.clientId,
111
120
  resource: config.resource,
@@ -120,13 +129,10 @@ const toSession = (token, config, env, previous) => {
120
129
  };
121
130
  };
122
131
  export const readAuthSession = () => readJson(sessionPath());
123
- const clearDeviceCode = () => {
124
- removeIfExists(deviceCodePath());
125
- };
126
- export const clearAuthSession = () => {
127
- removeIfExists(sessionPath());
128
- clearDeviceCode();
129
- removeIfExists(legacyTokenPath());
132
+ export const clearAuthSession = (env = currentEnv(), baseUrl = resolveApiBaseUrl({ env })) => {
133
+ removeIfExists(sessionPath(env, baseUrl));
134
+ removeIfExists(deviceCodePath(env, baseUrl));
135
+ removeIfExists(legacyTokenPath(env, baseUrl));
130
136
  };
131
137
  export const authSource = () => {
132
138
  if (process.env.COHUB_EXECUTION_TOKEN?.trim())
@@ -152,14 +158,15 @@ export async function requireAccessToken() {
152
158
  throw new AuthRequiredError();
153
159
  return token;
154
160
  }
155
- let refreshInFlight = null;
161
+ const refreshesInFlight = new Map();
156
162
  export function refreshAccessToken(session = readAuthSession()) {
157
163
  if (!session?.refreshToken)
158
164
  return Promise.reject(new AuthRequiredError());
159
- if (refreshInFlight)
160
- return refreshInFlight;
161
- const path = sessionPath(session.env);
162
- refreshInFlight = withRuntimeSpaceBindingsLock(async () => {
165
+ const path = sessionPath(session.env, sessionBaseUrl(session));
166
+ const inFlight = refreshesInFlight.get(path);
167
+ if (inFlight)
168
+ return inFlight;
169
+ const refresh = withRuntimeSpaceBindingsLock(async () => {
163
170
  const current = readJson(path);
164
171
  if (!current)
165
172
  throw new AuthRequiredError();
@@ -167,11 +174,13 @@ export function refreshAccessToken(session = readAuthSession()) {
167
174
  if (current.refreshToken !== session.refreshToken || current.updatedAt !== session.updatedAt)
168
175
  return current.accessToken;
169
176
  return performRefresh(current);
170
- }, { path, lockPath: `${path}.refresh.lock` }).finally(() => { refreshInFlight = null; });
171
- return refreshInFlight;
177
+ }, { path, lockPath: `${path}.refresh.lock` }).finally(() => { refreshesInFlight.delete(path); });
178
+ refreshesInFlight.set(path, refresh);
179
+ return refresh;
172
180
  }
173
181
  async function performRefresh(session) {
174
182
  const config = authConfig(session.env);
183
+ const path = sessionPath(session.env, sessionBaseUrl(session));
175
184
  const { response, data } = await formPost(tokenEndpoint(session.issuer), new URLSearchParams({
176
185
  client_id: session.clientId,
177
186
  grant_type: "refresh_token",
@@ -181,24 +190,25 @@ async function performRefresh(session) {
181
190
  }));
182
191
  if (!response.ok) {
183
192
  if (isUnrecoverableRefreshError(data)) {
184
- if (readJson(sessionPath(session.env))?.refreshToken === session.refreshToken)
185
- clearAuthSession();
193
+ if (readJson(path)?.refreshToken === session.refreshToken)
194
+ clearAuthSession(session.env, sessionBaseUrl(session));
186
195
  return null;
187
196
  }
188
197
  throw new Error(formatAuthError(data, response.status));
189
198
  }
190
199
  const next = toSession(data, config, session.env, session);
191
200
  // Never overwrite a login/logout that completed while the request was in flight.
192
- const current = readJson(sessionPath(session.env));
201
+ const current = readJson(path);
193
202
  if (!current)
194
203
  throw new AuthRequiredError();
195
204
  if (current.refreshToken !== session.refreshToken)
196
205
  return current.accessToken;
197
- writePrivateJson(sessionPath(session.env), next);
206
+ writePrivateJson(path, next);
198
207
  return next.accessToken;
199
208
  }
200
209
  export async function requestDeviceCode() {
201
210
  const env = currentEnv();
211
+ const baseUrl = resolveApiBaseUrl({ env });
202
212
  const config = authConfig(env);
203
213
  const { response, data } = await formPost(deviceEndpoint(config.issuer), new URLSearchParams({
204
214
  client_id: config.clientId,
@@ -224,13 +234,14 @@ export async function requestDeviceCode() {
224
234
  interval,
225
235
  createdAt: now,
226
236
  };
227
- writePrivateJson(deviceCodePath(env), deviceCode);
237
+ writePrivateJson(deviceCodePath(env, baseUrl), deviceCode);
228
238
  return deviceCode;
229
239
  }
230
240
  export async function verifyDeviceCode() {
231
241
  const env = currentEnv();
242
+ const baseUrl = resolveApiBaseUrl({ env });
232
243
  const config = authConfig(env);
233
- const deviceCode = readJson(deviceCodePath(env));
244
+ const deviceCode = readJson(deviceCodePath(env, baseUrl));
234
245
  if (!deviceCode)
235
246
  throw new Error("Device code not found. Run `cohub auth login --request-code` first.");
236
247
  if (deviceCode.expiresAt <= Date.now())
@@ -243,9 +254,9 @@ export async function verifyDeviceCode() {
243
254
  }));
244
255
  if (!response.ok)
245
256
  throw new Error(formatAuthError(data, response.status));
246
- const session = toSession(data, config, env);
247
- writePrivateJson(sessionPath(env), session);
248
- removeIfExists(deviceCodePath(env));
257
+ const session = { ...toSession(data, config, env), baseUrl };
258
+ writePrivateJson(sessionPath(env, sessionBaseUrl(session)), session);
259
+ removeIfExists(deviceCodePath(env, baseUrl));
249
260
  return session;
250
261
  }
251
262
  export async function loginWithDeviceFlow(onCode, onPoll) {