@journoid/cli 0.1.0 → 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 +6 -4
- package/bin/journoid.mjs +93 -53
- package/package.json +1 -1
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
|
-
|
|
26
|
-
|
|
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,
|
|
30
|
-
installing the CLI.
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
if (
|
|
243
|
-
|
|
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(
|
|
275
|
-
|
|
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
|
-
|
|
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
|
|