@journoid/cli 0.1.1 → 0.1.2

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
@@ -22,12 +22,14 @@ its response, inspect saved data before retrying. Writes are not automatically
22
22
  retried after network failures.
23
23
 
24
24
  Credentials stay in a private file under your user configuration directory.
25
- Use `journoid logout` to revoke the CLI session. Settings > Your agents can
26
- disconnect access immediately, or allow access again after disconnecting.
25
+ Each CLI configuration has its own connection, named after the device.
26
+ Use `journoid logout` to disconnect this CLI and revoke its session.
27
+ Settings > Your agents can disconnect a device without affecting your other CLIs.
28
+ Disconnected devices disappear; run `journoid login` to connect again.
27
29
  Expired sessions refresh automatically when possible; otherwise run `login`.
28
30
 
29
- For remote MCP clients, use `https://journoid.app/mcp` with OAuth instead of
30
- installing the CLI. Settings provides setup commands for supported clients.
31
+ For remote MCP clients, copy a setup URL from Settings > Your agents instead of
32
+ installing the CLI. Create a separate setup for each device or agent configuration.
31
33
 
32
34
  For an isolated local backend, use `--url http://localhost:<app-port>` on each
33
35
  command. Credentials are kept separately for each server origin.
package/bin/journoid.mjs CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  unlink,
9
9
  realpath,
10
10
  } from 'node:fs/promises'
11
- import { homedir } from 'node:os'
11
+ import { homedir, hostname } from 'node:os'
12
12
  import { join, dirname } from 'node:path'
13
13
  import { randomUUID } from 'node:crypto'
14
14
  import { spawn } from 'node:child_process'
@@ -131,6 +131,10 @@ export class JournoidClient {
131
131
  throw new Error(
132
132
  'CLI sign-in is not configured on this Journoid server yet.'
133
133
  )
134
+ if (value.cliResource !== `${this.origin}/mcp/cli/`)
135
+ throw new Error(
136
+ 'This Journoid server needs an update before connecting this CLI.'
137
+ )
134
138
  const issuer = secureUrl(value.authorizationServer).origin
135
139
  const metadata = await this.json(
136
140
  `${issuer}/.well-known/oauth-authorization-server`
@@ -190,59 +194,75 @@ export class JournoidClient {
190
194
  tokenEndpoint: config.endpoints.token_endpoint,
191
195
  revocationEndpoint: config.endpoints.revocation_endpoint,
192
196
  resource: config.resource ?? previous?.resource,
197
+ deviceName: previous?.deviceName ?? hostname().slice(0, 60),
193
198
  }
194
199
  }
195
200
 
196
201
  async login(onDevice) {
202
+ if ((await this.readStore())[this.origin]) await this.logout()
197
203
  const config = await this.discover()
198
- const { response, value } = await this.form(
199
- config.endpoints.device_authorization_endpoint,
200
- {
201
- client_id: config.clientId,
202
- scope: config.scopes.join(' '),
203
- ...(config.resource ? { resource: config.resource } : {}),
204
- }
205
- )
206
- if (
207
- !response.ok ||
208
- typeof value.device_code !== 'string' ||
209
- typeof value.user_code !== 'string' ||
210
- !Number.isFinite(value.expires_in) ||
211
- value.expires_in <= 0
212
- )
213
- throw new Error('Could not start sign-in. Try again.')
214
- const verificationUrl = secureUrl(
215
- value.verification_uri_complete || value.verification_uri
216
- ).href
217
- onDevice({ url: verificationUrl, code: value.user_code })
218
- const expires = this.now() + value.expires_in * 1000
219
- let interval = Math.max(5, Number(value.interval) || 5) * 1000
220
- while (this.now() < expires) {
221
- await this.wait(Math.min(interval, expires - this.now()))
222
- if (this.now() >= expires) break
223
- const token = await this.form(config.endpoints.token_endpoint, {
224
- grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
225
- client_id: config.clientId,
226
- device_code: value.device_code,
227
- ...(config.resource ? { resource: config.resource } : {}),
228
- })
229
- if (token.response.ok && token.value.access_token) {
230
- await this.save(this.accountFromTokens(token.value, config))
231
- // Verify scope, audience, and connection access before reporting success.
232
- // A device token without an audience gets one resource-bound refresh.
233
- return this.request('GET')
234
- }
235
- if (token.value.error === 'authorization_pending') continue
236
- if (token.value.error === 'slow_down') {
237
- interval += 5000
238
- continue
204
+ config.resource = `${config.resource}/cli/${randomUUID()}`
205
+ const pending = { loginId: randomUUID() }
206
+ await this.save(pending, { expected: undefined })
207
+ try {
208
+ const { response, value } = await this.form(
209
+ config.endpoints.device_authorization_endpoint,
210
+ {
211
+ client_id: config.clientId,
212
+ scope: config.scopes.join(' '),
213
+ ...(config.resource ? { resource: config.resource } : {}),
214
+ }
215
+ )
216
+ if (
217
+ !response.ok ||
218
+ typeof value.device_code !== 'string' ||
219
+ typeof value.user_code !== 'string' ||
220
+ !Number.isFinite(value.expires_in) ||
221
+ value.expires_in <= 0
222
+ )
223
+ throw new Error('Could not start sign-in. Try again.')
224
+ const verificationUrl = secureUrl(
225
+ value.verification_uri_complete || value.verification_uri
226
+ ).href
227
+ onDevice({ url: verificationUrl, code: value.user_code })
228
+ const expires = this.now() + value.expires_in * 1000
229
+ let interval = Math.max(5, Number(value.interval) || 5) * 1000
230
+ while (this.now() < expires) {
231
+ await this.wait(Math.min(interval, expires - this.now()))
232
+ if (this.now() >= expires) break
233
+ const token = await this.form(config.endpoints.token_endpoint, {
234
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
235
+ client_id: config.clientId,
236
+ device_code: value.device_code,
237
+ ...(config.resource ? { resource: config.resource } : {}),
238
+ })
239
+ if (token.response.ok && token.value.access_token) {
240
+ const account = this.accountFromTokens(token.value, config)
241
+ try {
242
+ await this.save(account, { expected: pending })
243
+ } catch (error) {
244
+ await this.revoke(account)
245
+ throw error
246
+ }
247
+ // Verify scope, audience, and connection access before reporting success.
248
+ // A device token without an audience gets one resource-bound refresh.
249
+ return this.request('GET')
250
+ }
251
+ if (token.value.error === 'authorization_pending') continue
252
+ if (token.value.error === 'slow_down') {
253
+ interval += 5000
254
+ continue
255
+ }
256
+ if (token.value.error === 'access_denied')
257
+ throw new Error('Sign-in was declined.')
258
+ if (token.value.error === 'expired_token') break
259
+ throw new Error('Sign-in failed. Run journoid login again.')
239
260
  }
240
- if (token.value.error === 'access_denied')
241
- throw new Error('Sign-in was declined.')
242
- if (token.value.error === 'expired_token') break
243
- throw new Error('Sign-in failed. Run journoid login again.')
261
+ throw new Error('The sign-in code expired. Run journoid login again.')
262
+ } finally {
263
+ if ((await this.readStore())[this.origin]?.loginId === pending.loginId)
264
+ await this.save(undefined, { expected: pending })
244
265
  }
245
- throw new Error('The sign-in code expired. Run journoid login again.')
246
266
  }
247
267
 
248
268
  async refresh(account) {
@@ -271,12 +291,17 @@ export class JournoidClient {
271
291
  return updated
272
292
  }
273
293
 
274
- async request(method, { view, body } = {}) {
275
- let account = (await this.readStore())[this.origin]
294
+ async request(
295
+ method,
296
+ { view, body, account: providedAccount, onRefresh } = {}
297
+ ) {
298
+ let account = providedAccount ?? (await this.readStore())[this.origin]
276
299
  if (!account?.accessToken)
277
300
  throw new Error('Sign in first with journoid login.')
278
- if (account.expiresAt <= this.now() + 30_000)
301
+ if (method !== 'DELETE' && account.expiresAt <= this.now() + 30_000) {
279
302
  account = await this.refresh(account)
303
+ onRefresh?.(account)
304
+ }
280
305
  const url = new URL('/api/agents', this.origin)
281
306
  if (view) url.searchParams.set('view', view)
282
307
  const send = async () => {
@@ -286,12 +311,15 @@ export class JournoidClient {
286
311
  headers: {
287
312
  Authorization: `Bearer ${account.accessToken}`,
288
313
  'Content-Type': 'application/json',
314
+ ...(account.deviceName
315
+ ? { 'X-Journoid-Device-Name': account.deviceName }
316
+ : {}),
289
317
  },
290
318
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
291
319
  })
292
320
  } catch {
293
321
  throw new Error(
294
- method === 'POST'
322
+ method === 'POST' || method === 'DELETE'
295
323
  ? 'Connection lost. Check Journoid before retrying this change; it may have completed.'
296
324
  : 'Could not reach Journoid. Try again.'
297
325
  )
@@ -300,6 +328,7 @@ export class JournoidClient {
300
328
  let result = await send()
301
329
  if (result.response.status === 401) {
302
330
  account = await this.refresh(account)
331
+ onRefresh?.(account)
303
332
  result = await send()
304
333
  }
305
334
  if (!result.response.ok)
@@ -312,7 +341,19 @@ export class JournoidClient {
312
341
  }
313
342
 
314
343
  async logout() {
315
- const account = (await this.readStore())[this.origin]
344
+ let account = (await this.readStore())[this.origin]
345
+ if (account?.accessToken)
346
+ await this.request('DELETE', {
347
+ account,
348
+ onRefresh: (updated) => {
349
+ account = updated
350
+ },
351
+ })
352
+ await this.revoke(account)
353
+ await this.save(undefined, { expected: account })
354
+ }
355
+
356
+ async revoke(account) {
316
357
  if (account?.refreshToken) {
317
358
  if (!account.revocationEndpoint)
318
359
  throw new Error(
@@ -332,7 +373,6 @@ export class JournoidClient {
332
373
  'Could not revoke the session. Try again, or disconnect the CLI in Journoid Settings.'
333
374
  )
334
375
  }
335
- await this.save(undefined, { expected: account })
336
376
  }
337
377
  }
338
378
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@journoid/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Connect your agents and terminal to Journoid",
5
5
  "type": "module",
6
6
  "license": "MIT",