@frontera-sdk/cli 1.52.45 → 1.52.47
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/package.json +4 -4
- package/src/api/apps-api.ts +3 -1
- package/src/api/automation-api.ts +32 -0
- package/src/api/credential-failure.ts +1 -1
- package/src/api/governed-action-api.ts +24 -0
- package/src/api/media-api.ts +24 -0
- package/src/api/platform-api.ts +13 -0
- package/src/binding-registry.ts +9 -2
- package/src/blueprint/compile.ts +3 -1
- package/src/blueprint/ontology-edit-plan.ts +71 -4
- package/src/blueprint/org-name-check.ts +64 -0
- package/src/blueprint/tree.ts +28 -0
- package/src/commands/action/deploy.ts +22 -5
- package/src/commands/action/index-commands.ts +2 -0
- package/src/commands/action/retire.ts +60 -0
- package/src/commands/agent/compose.ts +1 -0
- package/src/commands/agent/index-commands.ts +42 -0
- package/src/commands/app/deploy.ts +1 -1
- package/src/commands/app/dev.ts +2 -2
- package/src/commands/app/save.ts +1 -1
- package/src/commands/auth/add.ts +5 -1
- package/src/commands/auth/current.ts +6 -0
- package/src/commands/auth/index-commands.ts +3 -0
- package/src/commands/auth/login.ts +84 -0
- package/src/commands/auth/use.ts +21 -2
- package/src/commands/automation/bindings.ts +54 -10
- package/src/commands/automation/index-commands.ts +2 -3
- package/src/commands/blueprint/bind.ts +12 -1
- package/src/commands/blueprint/declarative.ts +6 -2
- package/src/commands/conversation/index-commands.ts +66 -0
- package/src/commands/conversation/local-runtime.ts +18 -0
- package/src/commands/local/check.ts +57 -11
- package/src/commands/local/data.ts +75 -7
- package/src/commands/local/dev.ts +77 -9
- package/src/commands/local/embeddings.ts +74 -0
- package/src/commands/local/index-commands.ts +133 -11
- package/src/commands/local/new.ts +78 -0
- package/src/commands/local/publish.ts +34 -0
- package/src/commands/local/steps.ts +105 -0
- package/src/commands/local/sync-request.ts +69 -23
- package/src/commands/local/sync.ts +2 -0
- package/src/commands/media/index-commands.ts +58 -3
- package/src/commands/registry.ts +11 -3
- package/src/commands/trace/index-commands.ts +56 -0
- package/src/commands/workspace/index-commands.ts +14 -7
- package/src/config.ts +4 -1
- package/src/dev-broker.ts +15 -1
- package/src/device-login.ts +143 -0
- package/src/exit.ts +36 -8
- package/src/flag-help.ts +30 -0
- package/src/forge/claude-adapter.ts +17 -4
- package/src/forge/permission-rules.ts +158 -0
- package/src/forge/touches.ts +7 -6
- package/src/function-outside-imports.ts +13 -2
- package/src/function-source.ts +90 -0
- package/src/harness.ts +4 -4
- package/src/key-reach.ts +64 -0
- package/src/local/local-actions.ts +15 -3
- package/src/local/local-apply-error.ts +26 -0
- package/src/local/local-apps.ts +9 -3
- package/src/local/local-blueprint-schemas.ts +15 -1
- package/src/local/local-confidential.ts +81 -0
- package/src/local/local-data-load.ts +130 -0
- package/src/local/local-dev-references.ts +24 -4
- package/src/local/local-embedding-setup.ts +62 -0
- package/src/local/local-jobs.ts +108 -0
- package/src/local/local-new.ts +148 -0
- package/src/local/local-org-settings.ts +1 -1
- package/src/local/local-person-steps.ts +340 -0
- package/src/local/local-personas.ts +16 -5
- package/src/local/local-portability.ts +22 -1
- package/src/local/local-publish.ts +26 -0
- package/src/local/local-request-evidence.ts +35 -6
- package/src/local/local-runtime-history.ts +4 -3
- package/src/local/local-said-once.ts +51 -0
- package/src/local/local-scaffold.ts +40 -2
- package/src/local/local-schemas.ts +60 -11
- package/src/local/local-stand-ins.ts +44 -0
- package/src/local/local-supervisor.ts +22 -0
- package/src/local/local-sync-models.ts +5 -2
- package/src/local/local-sync-names.ts +3 -0
- package/src/local/local-sync-portable.ts +64 -15
- package/src/local/local-sync-precheck.ts +30 -1
- package/src/local/local-sync-push.ts +81 -8
- package/src/local/local-sync-remote.ts +10 -7
- package/src/local/local-test-models.ts +173 -0
- package/src/local/local-test-runner.ts +232 -25
- package/src/local/local-test-scope.ts +174 -0
- package/src/local/local-ungranted-reads.ts +55 -0
- package/src/local/local-writers.ts +171 -0
- package/src/main.ts +75 -17
- package/src/mcp/browser/ax-snapshot.ts +13 -1
- package/src/mcp/browser/page-scripts.ts +1 -0
- package/src/mcp/mcp-conversations.ts +171 -0
- package/src/mcp/mcp-server.ts +4 -1
- package/src/mcp/mcp-traces.ts +7 -2
- package/src/mcp/tools/agent-tools.ts +94 -2
- package/src/mcp/tools/project-tools.ts +149 -13
- package/src/mcp/tools/test-tools.ts +64 -13
- package/src/output.ts +22 -7
- package/src/pack.ts +16 -0
- package/src/project-context.ts +18 -5
- package/src/project.ts +4 -0
- package/src/remote-guard.ts +52 -0
- package/src/vendor/kit-assets.json +11 -11
|
@@ -4,8 +4,7 @@ import { join, resolve } from 'node:path'
|
|
|
4
4
|
import { CliError, UsageError } from '../../errors'
|
|
5
5
|
import { documentPath, readFolders, readPulledDocuments, recordPulledDocuments, sha256Of, type PulledDocument } from '../../local/local-knowledge-documents'
|
|
6
6
|
import { assetFilename } from '../skill/bundle-commands'
|
|
7
|
-
import {
|
|
8
|
-
import { packDirectory } from '../../pack'
|
|
7
|
+
import { packFunctionSource } from '../../pack'
|
|
9
8
|
import { createTarGz } from '../../tar'
|
|
10
9
|
import { functionEntry } from '../../local/local-dev-sync'
|
|
11
10
|
import { adoptProposedLessons } from '../../local/local-lessons'
|
|
@@ -14,11 +13,12 @@ import { HISTORY_KINDS, projectAtRevision } from '../../local/local-sync-base'
|
|
|
14
13
|
import { normalizeOrigin, unwrap } from '../../local/local-sync-client'
|
|
15
14
|
import { getSyncKey, readLink, setModelMapping, type SyncLink } from '../../local/local-sync-link'
|
|
16
15
|
import { itemTitle } from '../../local/local-sync-names'
|
|
17
|
-
import { checkBody, markMissingModels, markRefused, type WorkspaceModel } from '../../local/local-sync-precheck'
|
|
16
|
+
import { checkBody, markMissingModels, markRefused, needsAPerson, planOf, type ApplyStep, type WorkspaceModel } from '../../local/local-sync-precheck'
|
|
17
|
+
import { afterApplyOf, recordAfterApply } from '../../local/local-person-steps'
|
|
18
18
|
import { rows } from '../../local/local-sync-remote'
|
|
19
|
-
import { evalsDetail, evalsLine, lastRender, sessionScreenshots } from '../../local/local-request-evidence'
|
|
19
|
+
import { evalsDetail, evalsLine, lastRender, sessionScreenshots, standInInstalls } from '../../local/local-request-evidence'
|
|
20
20
|
import { readAppliedRun, readLastRun, recordApplied, recordRun } from '../../local/local-test-baseline'
|
|
21
|
-
import { agentFilesProblem, runCases, selectCaseFiles } from '../../local/local-test-runner'
|
|
21
|
+
import { agentFilesProblem, caseRepeat, passRateThreshold, runCases, selectCaseFiles } from '../../local/local-test-runner'
|
|
22
22
|
import { findAppProjects } from '../../mcp/mcp-apps'
|
|
23
23
|
import { runtimeLink } from '../../mcp/mcp-runtime'
|
|
24
24
|
import { readLock, type SyncKind } from '../../local/local-sync-plan'
|
|
@@ -250,6 +250,27 @@ export function releaseItem(slug: string, deployment: string): { ref: string; fi
|
|
|
250
250
|
return { ref: `app/${slug}`, files: { [`apps/${slug}.release.json`]: `${JSON.stringify({ deployment }, null, 2)}\n` } }
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
/**
|
|
254
|
+
* One item per Custom App (X6): `app/<slug>` holds its settings
|
|
255
|
+
* (`apps/<slug>.json`) and its release (`apps/<slug>.release.json`), so the
|
|
256
|
+
* settings pushed now and a release asked now or kept from the request go
|
|
257
|
+
* together; the first of a file wins.
|
|
258
|
+
*/
|
|
259
|
+
export function mergeAppItems<T extends { ref: string; files: Record<string, string> }>(items: T[]): T[] {
|
|
260
|
+
const out: T[] = []
|
|
261
|
+
const at = new Map<string, number>()
|
|
262
|
+
for (const item of items) {
|
|
263
|
+
const i = at.get(item.ref)
|
|
264
|
+
if (!item.ref.startsWith('app/') || i === undefined) {
|
|
265
|
+
if (item.ref.startsWith('app/')) at.set(item.ref, out.length)
|
|
266
|
+
out.push(item)
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
out[i] = { ...item, ...out[i]!, files: { ...item.files, ...out[i]!.files } }
|
|
270
|
+
}
|
|
271
|
+
return out
|
|
272
|
+
}
|
|
273
|
+
|
|
253
274
|
/** The deployment a release item names. */
|
|
254
275
|
export function releasedDeployment(item: Pick<ViewItem, 'name' | 'files'>): string | null {
|
|
255
276
|
try {
|
|
@@ -314,7 +335,7 @@ export function functionBuilder(project: Pick<LocalProject, 'root'>, ctx: Pick<C
|
|
|
314
335
|
const at = findAutomationProjectRoot(resolve(root, entry))
|
|
315
336
|
const inside = at !== null && (at === root || at.startsWith(`${root}/`))
|
|
316
337
|
const tgz = inside
|
|
317
|
-
?
|
|
338
|
+
? packFunctionSource(at!, resolve(root, entry), ctx.output, `source of ${item.name}`).tgz
|
|
318
339
|
: createTarGz(Object.entries(item.files).map(([rel, text]) => ({ rel, bytes: new TextEncoder().encode(text) })))
|
|
319
340
|
const source = Buffer.from(tgz).toString('base64')
|
|
320
341
|
return { manifest, bundle: Buffer.from(code, 'utf8').toString('base64'), source }
|
|
@@ -415,19 +436,20 @@ async function deletionUsers(project: LocalProject, link: SyncLink, items: PushI
|
|
|
415
436
|
* with its reason and fix. A platform without the check leaves them to
|
|
416
437
|
* Propose: `checked` says which.
|
|
417
438
|
*/
|
|
418
|
-
async function precheck(api: Platform, items: PushItem[]): Promise<{ checked: boolean; refused: string[]; problem?: string }> {
|
|
439
|
+
async function precheck(api: Platform, items: PushItem[], organizationKey: string | null): Promise<{ checked: boolean; refused: string[]; plan: ApplyStep[]; problem?: string }> {
|
|
419
440
|
const body = checkBody(items)
|
|
420
|
-
if (!body.length) return { checked: true, refused: [] }
|
|
441
|
+
if (!body.length) return { checked: true, refused: [], plan: [] }
|
|
421
442
|
try {
|
|
422
|
-
|
|
423
|
-
|
|
443
|
+
// The organization key stored for the project, so the plan says whether it can make a Steward's steps.
|
|
444
|
+
const answer = await api.call<{ checks?: unknown[]; plan?: unknown }>('POST', '/v1/change-requests/check', { items: body, ...(organizationKey ? { organizationKey } : {}) }, { organizationKey, timeoutMs: 120_000 })
|
|
445
|
+
return { checked: true, refused: markRefused(items, checksOf(answer)), plan: planOf(answer) }
|
|
424
446
|
} catch (err) {
|
|
425
447
|
if (err instanceof CliError && (err.code === 'NOT_FOUND' || err.code === 'SYNC_CHECKS')) {
|
|
426
448
|
// An older platform has no check; a refusal of the check itself names the items as the push would.
|
|
427
|
-
if (err.code === 'SYNC_CHECKS') return { checked: true, refused: markRefused(items, checksOf(err.details)) }
|
|
428
|
-
return { checked: false, refused: [] }
|
|
449
|
+
if (err.code === 'SYNC_CHECKS') return { checked: true, refused: markRefused(items, checksOf(err.details)), plan: [] }
|
|
450
|
+
return { checked: false, refused: [], plan: [] }
|
|
429
451
|
}
|
|
430
|
-
return { checked: false, refused: [], problem: (err as Error).message.split('\n')[0] }
|
|
452
|
+
return { checked: false, refused: [], plan: [], problem: (err as Error).message.split('\n')[0] }
|
|
431
453
|
}
|
|
432
454
|
}
|
|
433
455
|
|
|
@@ -441,7 +463,7 @@ async function workspaceModels(api: Platform): Promise<WorkspaceModel[]> {
|
|
|
441
463
|
}
|
|
442
464
|
}
|
|
443
465
|
|
|
444
|
-
function previewText(link: SyncLink, items: PushItem[], kept: { title: string } | null, orgNote: string | null, available: boolean, usedBy: Map<string, string[]> = new Map()): string {
|
|
466
|
+
function previewText(link: SyncLink, items: PushItem[], kept: { title: string } | null, orgNote: string | null, available: boolean, usedBy: Map<string, string[]> = new Map(), person: ReturnType<typeof needsAPerson> = []): string {
|
|
445
467
|
const ok = items.filter((i) => i.proposable && !i.sample)
|
|
446
468
|
const samples = items.filter((i) => i.proposable && i.sample)
|
|
447
469
|
const not = items.filter((i) => !i.proposable)
|
|
@@ -453,6 +475,10 @@ function previewText(link: SyncLink, items: PushItem[], kept: { title: string }
|
|
|
453
475
|
]
|
|
454
476
|
if (samples.length) lines.push('', 'Samples from setting up the project, left out unless named:', ...samples.map((i) => ` [ ] ${KIND_WORD[i.kind]} ${i.title}`))
|
|
455
477
|
if (not.length) lines.push('', 'Not proposed:', ...not.flatMap((i) => [` [ ] ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])]))
|
|
478
|
+
const before = person.filter((p) => !p.after)
|
|
479
|
+
const after = person.filter((p) => p.after)
|
|
480
|
+
if (before.length) lines.push('', 'Needs a person before Apply:', ...before.flatMap((p) => [` - ${p.step}: ${p.message}`, ` Fix: ${p.fix}`]))
|
|
481
|
+
if (after.length) lines.push('', 'Left for a person after Apply:', ...after.flatMap((p) => [` - ${p.step}: ${p.message}`, ` Then: ${p.fix}`]))
|
|
456
482
|
if (orgNote) lines.push('', orgNote)
|
|
457
483
|
if (!available) lines.push('', 'This platform does not take change requests yet.')
|
|
458
484
|
if (ok.length && available) lines.push('', 'Propose with `frontera local sync push -t "<title>"`, or name items to propose only those.')
|
|
@@ -530,12 +556,13 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
530
556
|
const documents = documentNames.length
|
|
531
557
|
? { names: new Set(documentNames), pulled: readPulledDocuments(project.root, link.workspace).bases, platform: await platformDocuments(platformFor(project, link), documentNames, remote.items) }
|
|
532
558
|
: undefined
|
|
533
|
-
const all = pushCandidates({ root: project.root, plan, lock, snapshot, remote: remote.items, ...(documents ? { documents } : {}) })
|
|
559
|
+
const all = pushCandidates({ root: project.root, plan, lock, snapshot, remote: remote.items, named, ...(documents ? { documents } : {}) })
|
|
534
560
|
// Before anything is sent: models only this computer has, then the workspace's own checks.
|
|
535
561
|
const known = snapshot.names?.model ? new Set(Object.keys(snapshot.names.model)) : null
|
|
536
562
|
const missing = known ? markMissingModels(all, known) : new Map<string, string[]>()
|
|
537
563
|
const api = platformFor(project, link)
|
|
538
|
-
const checked = available ? await precheck(api, all) : { checked: false, refused: [] as string[] }
|
|
564
|
+
const checked = available ? await precheck(api, all, getSyncKey(project, 'organization')) : { checked: false, refused: [] as string[], plan: [] as ApplyStep[] }
|
|
565
|
+
const person = needsAPerson(checked.plan)
|
|
539
566
|
const kept = currentRequest(readRequests(project.root, link.workspace))
|
|
540
567
|
const updating = kept && isOpen(kept.state) ? kept : null
|
|
541
568
|
const orgStored = !!getSyncKey(project, 'organization')
|
|
@@ -570,9 +597,12 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
570
597
|
mapped: link.models ?? {},
|
|
571
598
|
},
|
|
572
599
|
organizationKey: { needed: needsOrganizationKey(chosen), stored: orgStored },
|
|
600
|
+
// Apply's steps in order, and the ones the project's credentials can't take (N3).
|
|
601
|
+
applyPlan: checked.plan,
|
|
602
|
+
needsPerson: person,
|
|
573
603
|
fetchedAt: snapshot.fetchedAt,
|
|
574
604
|
},
|
|
575
|
-
text: previewText(link, chosen, updating, orgNote, available, usedBy),
|
|
605
|
+
text: previewText(link, chosen, updating, orgNote, available, usedBy, person),
|
|
576
606
|
}
|
|
577
607
|
}
|
|
578
608
|
|
|
@@ -605,8 +635,9 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
605
635
|
|
|
606
636
|
// Custom App releases: each names a deployment that already exists. A later push keeps the releases the request has.
|
|
607
637
|
const releases = await releaseItems(api, releaseAsks)
|
|
608
|
-
const keptReleases = target ? (await api.call<View>('GET', `/v1/change-requests/${target.id}`)).items.filter((i) => i.kind === 'app' && !releases.some((r) => r.ref === i.ref)).map((i) => (
|
|
609
|
-
|
|
638
|
+
const keptReleases = target ? (await api.call<View>('GET', `/v1/change-requests/${target.id}`)).items.filter((i) => i.kind === 'app' && !releases.some((r) => r.ref === i.ref) && releasedDeployment(i)).map((i) => releaseItem(i.name, releasedDeployment(i)!)) : []
|
|
639
|
+
// An App's settings pushed now and its release go as one item (X6).
|
|
640
|
+
const items = mergeAppItems([...(await pushBody(going, functionBuilder(project, ctx), assetUploader(project, api))), ...releases.map(({ ref, files }) => ({ ref, files })), ...keptReleases])
|
|
610
641
|
let view: View
|
|
611
642
|
try {
|
|
612
643
|
view = target
|
|
@@ -887,7 +918,8 @@ async function apply(ctx: CommandContext) {
|
|
|
887
918
|
if (view.request.state === 'closed') throw new CliError(`“${view.request.title}” is closed`, { code: 'CONFLICT', hint: 'propose the changes again with `frontera local sync push -t "<title>"`' })
|
|
888
919
|
const allTitles = Object.fromEntries(view.items.map((i) => [i.ref, titleOf(i, titles)]))
|
|
889
920
|
const blueprint = view.items.some((i) => i.kind === 'blueprint')
|
|
890
|
-
|
|
921
|
+
// Sent whenever one is stored: an Agent's Blueprint grants are a Steward's too, in a request without Blueprint files (P5 A5).
|
|
922
|
+
const organizationKey = getSyncKey(project, 'organization')
|
|
891
923
|
if (blueprint && !organizationKey) {
|
|
892
924
|
throw new CliError('this change request changes the Blueprint, and applying it needs the organization key; none is stored for this project', { code: 'SYNC_NO_ORG_KEY', hint: 'connect again with `frontera local sync connect --workspace-key - --org-key -` (both keys on stdin), then apply' })
|
|
893
925
|
}
|
|
@@ -947,13 +979,18 @@ async function apply(ctx: CommandContext) {
|
|
|
947
979
|
await recordAppliedDocuments(project, link, answer).catch(() => null)
|
|
948
980
|
// The Agents applied: their cases' last local runs become the baseline the next request's evals compare with.
|
|
949
981
|
recordApplied(project, answer.items.filter((i) => i.kind === 'agent' && !i.deleted).map((i) => i.name))
|
|
982
|
+
// What the request left for a person: kept as the delivery's person-steps, which Forge shows as Pending in workspace (N6).
|
|
983
|
+
const pending = recordAfterApply(project.root, answer.request.title, afterApplyOf(answer.outcome))
|
|
950
984
|
return {
|
|
951
|
-
data: { applied: true, outcome, request: { id: answer.request.id, title: answer.request.title, state: answer.request.state }, fetched },
|
|
985
|
+
data: { applied: true, outcome, request: { id: answer.request.id, title: answer.request.title, state: answer.request.state }, fetched, pendingInWorkspace: pending },
|
|
952
986
|
text: [
|
|
953
987
|
`Applied “${answer.request.title}” to ${link.workspaceName ?? link.workspace}.`,
|
|
954
988
|
...describeOutcome(outcome, allTitles),
|
|
955
989
|
'',
|
|
956
990
|
fetched.error ? `Could not fetch afterwards: ${fetched.error}. Run \`frontera local sync fetch\`.` : `Fetched: ${plural(fetched.adopted.length, 'item')} now match${fetched.adopted.length === 1 ? 'es' : ''} the workspace.`,
|
|
991
|
+
...(pending.length
|
|
992
|
+
? ['', `Pending in workspace (${pending.length}), for a person:`, ...pending.map((p) => ` - ${p.changes}${p.command ? `: \`${p.command}\`` : ''}${p.check ? ' (checks itself)' : ''}`), 'Forge lists them on the project’s home; `frontera local steps` here.']
|
|
993
|
+
: []),
|
|
957
994
|
].join('\n'),
|
|
958
995
|
}
|
|
959
996
|
}
|
|
@@ -1027,6 +1064,15 @@ async function evidence(ctx: CommandContext) {
|
|
|
1027
1064
|
const attached: Array<{ kind: string; ref: string | null; title: string }> = []
|
|
1028
1065
|
const skipped: string[] = []
|
|
1029
1066
|
|
|
1067
|
+
// `--repeat N`: each gated case runs N times, and a flaky one passes at `--pass-rate` (X1).
|
|
1068
|
+
let repeat: number | undefined
|
|
1069
|
+
let passRate: number | undefined
|
|
1070
|
+
try {
|
|
1071
|
+
repeat = caseRepeat(flagString(ctx, 'repeat'))
|
|
1072
|
+
passRate = passRateThreshold(flagString(ctx, 'pass-rate'))
|
|
1073
|
+
} catch (err) {
|
|
1074
|
+
throw new UsageError((err as Error).message, 'frontera local sync request evidence --repeat 3')
|
|
1075
|
+
}
|
|
1030
1076
|
if (want.evals) {
|
|
1031
1077
|
const agents = view.items.filter((i) => i.kind === 'agent' && !i.deleted)
|
|
1032
1078
|
if (agents.length) {
|
|
@@ -1038,10 +1084,10 @@ async function evidence(ctx: CommandContext) {
|
|
|
1038
1084
|
// Chosen as `local test` and MCP `test_run { gate: true }` choose them, and run on what the files say:
|
|
1039
1085
|
// a draft that isn't fails its cases with what to run, as there.
|
|
1040
1086
|
const files = selectCaseFiles(project.root, { agent: item.name, gate: true })
|
|
1041
|
-
const results = files.length ? await runCases({ serviceUrl: runtime.serviceUrl, creds: runtime.creds, files, live: false, staleDraft: (slug) => agentFilesProblem(project, runtime.creds.organizationId, slug), onResult: () => {} }) : []
|
|
1087
|
+
const results = files.length ? await runCases({ serviceUrl: runtime.serviceUrl, creds: runtime.creds, files, live: false, ...(repeat ? { repeat } : {}), ...(passRate ? { passRate } : {}), staleDraft: (slug) => agentFilesProblem(project, runtime.creds.organizationId, slug), onResult: () => {} }) : []
|
|
1042
1088
|
// Compared with the run when the Agent was last applied from here; never applied from here, with the last run.
|
|
1043
1089
|
const baseline = Object.keys(applied).some((k) => k.startsWith(`${item.name}\u0000`)) ? applied : last
|
|
1044
|
-
const detail = evalsDetail({ files, results, baseline, runtime: `local ${runtime.state.version ?? ''}`.trim() })
|
|
1090
|
+
const detail = evalsDetail({ files, results, baseline, runtime: `local ${runtime.state.version ?? ''}`.trim(), standIns: standInInstalls(project.root) })
|
|
1045
1091
|
if (results.length) recordRun(project, results)
|
|
1046
1092
|
// Cases that couldn't run (the provider refused them) prove nothing either way: attached, they would read as none newly failing.
|
|
1047
1093
|
if (detail.couldNotRun.length) {
|
|
@@ -91,7 +91,8 @@ const list: Command = {
|
|
|
91
91
|
verb: 'list',
|
|
92
92
|
authLane: 'session',
|
|
93
93
|
args: [],
|
|
94
|
-
|
|
94
|
+
// A local persona (L6): on the project's local runtime only.
|
|
95
|
+
flags: { as: 'string' },
|
|
95
96
|
summary: 'List the Media Sets visible to you in a workspace',
|
|
96
97
|
sessionAlternative: 'Blueprint → Media Sets',
|
|
97
98
|
examples: ['frontera media list --workspace <workspaceId>', 'frontera media list --workspace <workspaceId> --json'],
|
|
@@ -187,7 +188,7 @@ const upload: Command = {
|
|
|
187
188
|
{ name: 'set', required: true, description: 'Media Set API name or id, from `frontera media list`' },
|
|
188
189
|
{ name: 'path...', required: true, description: 'files or directories; directories are walked, unsupported types skipped' },
|
|
189
190
|
],
|
|
190
|
-
flags: { markings: 'string', 'skip-existing': 'boolean' },
|
|
191
|
+
flags: { markings: 'string', 'skip-existing': 'boolean', as: 'string' },
|
|
191
192
|
summary: 'Upload local files into a Media Set; the same path again adds a new version',
|
|
192
193
|
sessionAlternative: 'Blueprint → Media Sets → Files',
|
|
193
194
|
examples: [
|
|
@@ -316,6 +317,60 @@ const upload: Command = {
|
|
|
316
317
|
},
|
|
317
318
|
}
|
|
318
319
|
|
|
320
|
+
/**
|
|
321
|
+
* A file staged for an Action to attach (L6): the Media Set's upload lane,
|
|
322
|
+
* which keeps the file out of the set until an Action request that carries
|
|
323
|
+
* the returned id applies. Pass the id as the Action's upload input.
|
|
324
|
+
*/
|
|
325
|
+
const stage: Command = {
|
|
326
|
+
meta: {
|
|
327
|
+
noun: 'media',
|
|
328
|
+
verb: 'stage',
|
|
329
|
+
authLane: 'session',
|
|
330
|
+
args: [
|
|
331
|
+
{ name: 'set', required: true, description: 'the Media Set the Action attaches into: API name or id' },
|
|
332
|
+
{ name: 'file', required: true, description: 'the file to stage' },
|
|
333
|
+
],
|
|
334
|
+
flags: { markings: 'string', as: 'string' },
|
|
335
|
+
summary: 'Stage a file for an Action to attach; prints the id to pass as its upload input',
|
|
336
|
+
sessionAlternative: 'the Action\'s form, which stages the file it is given',
|
|
337
|
+
examples: [
|
|
338
|
+
'frontera media stage evidence photo.jpg --workspace <workspaceId>',
|
|
339
|
+
'frontera media stage evidence photo.jpg --as submitter',
|
|
340
|
+
],
|
|
341
|
+
},
|
|
342
|
+
async run(ctx) {
|
|
343
|
+
const [ref, path] = ctx.positional
|
|
344
|
+
if (!ref || !path) throw new UsageError('missing <set> or <file>', 'frontera media stage <set> <file>')
|
|
345
|
+
const mime = MEDIA_MIME_BY_EXTENSION[extensionOf(path)]
|
|
346
|
+
if (!mime) throw new UsageError(`cannot stage ${path}: not a type a Media Set takes`, `the types: ${Object.keys(MEDIA_MIME_BY_EXTENSION).join(', ')}`)
|
|
347
|
+
let size: number
|
|
348
|
+
try {
|
|
349
|
+
size = statSync(path).size
|
|
350
|
+
} catch {
|
|
351
|
+
throw new UsageError(`no file at ${path}`, 'pass the path of one file')
|
|
352
|
+
}
|
|
353
|
+
if (size > MEDIA_MAX_BYTES) throw new CliError(`${path} is larger than the 50 MB Media Set limit`, { code: 'BAD_REQUEST' })
|
|
354
|
+
const client = api(ctx)
|
|
355
|
+
const setId = await resolveSet(client, ref)
|
|
356
|
+
const markings = flagString(ctx, 'markings')
|
|
357
|
+
const staged = await client.stage(setId, {
|
|
358
|
+
bytes: new Uint8Array(readFileSync(path)),
|
|
359
|
+
filename: basename(path),
|
|
360
|
+
mime,
|
|
361
|
+
logicalPath: basename(path),
|
|
362
|
+
...(markings ? { markings } : {}),
|
|
363
|
+
})
|
|
364
|
+
return {
|
|
365
|
+
data: { set: ref, setId, upload: staged },
|
|
366
|
+
text: [
|
|
367
|
+
`staged ${basename(path)} for an Action: ${staged.mediaItemId}`,
|
|
368
|
+
' pass it as the Action\'s upload input (`frontera action submit <apiName> --input \'{…, "<upload input>": "' + staged.mediaItemId + '"}\'`); it joins the set only when that request applies',
|
|
369
|
+
].join('\n'),
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
}
|
|
373
|
+
|
|
319
374
|
const processCommand: Command = {
|
|
320
375
|
meta: {
|
|
321
376
|
noun: 'media',
|
|
@@ -589,4 +644,4 @@ const revoke: Command = {
|
|
|
589
644
|
},
|
|
590
645
|
}
|
|
591
646
|
|
|
592
|
-
export const mediaCommands: Command[] = [list, create, upload, processCommand, status, retry, search, grant, revoke]
|
|
647
|
+
export const mediaCommands: Command[] = [list, create, upload, stage, processCommand, status, retry, search, grant, revoke]
|
package/src/commands/registry.ts
CHANGED
|
@@ -50,10 +50,13 @@ import { loginCommand } from './login'
|
|
|
50
50
|
import { setupCommand } from './setup'
|
|
51
51
|
import { authCommands } from './auth/index-commands'
|
|
52
52
|
import { kitCommands } from './kit/index-commands'
|
|
53
|
+
import { conversationCommands } from './conversation/index-commands'
|
|
54
|
+
import { traceCommands } from './trace/index-commands'
|
|
53
55
|
import { workspaceCommands } from './workspace/index-commands'
|
|
54
56
|
import { forgeCommands } from './forge/index-commands'
|
|
55
57
|
import { localCommands } from './local/index-commands'
|
|
56
58
|
import { localSnapshot } from './local/snapshot'
|
|
59
|
+
import { localSteps } from './local/steps'
|
|
57
60
|
import { mcpServe } from './mcp/serve'
|
|
58
61
|
|
|
59
62
|
/**
|
|
@@ -62,6 +65,8 @@ import { mcpServe } from './mcp/serve'
|
|
|
62
65
|
*/
|
|
63
66
|
export const GLOBAL_FLAGS: FlagSpec = {
|
|
64
67
|
json: 'boolean',
|
|
68
|
+
// `{ ok, data, warnings }` on stdout, failures included (X10); implies --json.
|
|
69
|
+
envelope: 'boolean',
|
|
65
70
|
quiet: 'boolean',
|
|
66
71
|
yes: 'boolean',
|
|
67
72
|
help: 'boolean',
|
|
@@ -93,10 +98,13 @@ export const COMMANDS: readonly Command[] = [
|
|
|
93
98
|
|
|
94
99
|
...authCommands,
|
|
95
100
|
...kitCommands,
|
|
101
|
+
...conversationCommands,
|
|
102
|
+
...traceCommands,
|
|
96
103
|
...workspaceCommands,
|
|
97
104
|
...forgeCommands,
|
|
98
105
|
...localCommands,
|
|
99
106
|
localSnapshot,
|
|
107
|
+
localSteps,
|
|
100
108
|
mcpServe,
|
|
101
109
|
|
|
102
110
|
appInit,
|
|
@@ -431,9 +439,9 @@ export function renderNounHelp(noun: string): string {
|
|
|
431
439
|
|
|
432
440
|
/** The help line every session-lane command prints — see `SESSION_LANE_HINT`. */
|
|
433
441
|
export const SESSION_LANE_HELP =
|
|
434
|
-
'Needs a person’s session, not an API key
|
|
435
|
-
+ '
|
|
436
|
-
+ '
|
|
442
|
+
'Needs a person’s session, not an API key: sign in as yourself with '
|
|
443
|
+
+ '`frontera auth login --browser` (approved in your browser, lasts 12 hours). '
|
|
444
|
+
+ 'sk-ws- and sk-org- keys are refused with 401 or 403.'
|
|
437
445
|
|
|
438
446
|
export function renderCommandHelp(meta: CommandMeta): string {
|
|
439
447
|
const width = terminalWidth()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { UsageError } from '../../errors'
|
|
2
|
+
import { getTrace, listTraces, readTrace } from '../../mcp/mcp-traces'
|
|
3
|
+
import { projectRuntime } from '../conversation/local-runtime'
|
|
4
|
+
import { flagBool, flagString, type Command } from '../types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `frontera trace get|list`: the project's local runtime's traces (Forge
|
|
8
|
+
* improvement plan N7), the reads the MCP tools `trace_get` and `trace_list`
|
|
9
|
+
* make, for a person or an agent working in the shell.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const traceGet: Command = {
|
|
13
|
+
meta: {
|
|
14
|
+
noun: 'trace',
|
|
15
|
+
verb: 'get',
|
|
16
|
+
args: [{ name: 'trace', required: true, description: 'The trace id (from a test run, `trace list` or `conversation get`)' }],
|
|
17
|
+
flags: { brief: 'boolean' },
|
|
18
|
+
flagHelp: { brief: 'the span tree without each span’s input and output' },
|
|
19
|
+
summary: 'Read one trace on the project’s local runtime: spans, tool calls, tokens, the reply check',
|
|
20
|
+
examples: ['frontera trace get 0c5e… --json'],
|
|
21
|
+
offline: true,
|
|
22
|
+
},
|
|
23
|
+
async run(ctx) {
|
|
24
|
+
const id = ctx.positional[0]
|
|
25
|
+
if (!id) throw new UsageError('missing <trace>', '`frontera trace list` lists the recent ones')
|
|
26
|
+
const link = projectRuntime(ctx.cwd)
|
|
27
|
+
const trace = await getTrace(link, id)
|
|
28
|
+
if (!trace) throw new UsageError(`no trace ${id} on the local runtime`, '`frontera trace list` lists the recent ones')
|
|
29
|
+
const read = readTrace(link, trace, { detail: !flagBool(ctx, 'brief') })
|
|
30
|
+
return { data: read, text: JSON.stringify(read, null, 2) }
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const traceList: Command = {
|
|
35
|
+
meta: {
|
|
36
|
+
noun: 'trace',
|
|
37
|
+
verb: 'list',
|
|
38
|
+
args: [],
|
|
39
|
+
flags: { conversation: 'string', name: 'string', errors: 'boolean', limit: 'string' },
|
|
40
|
+
flagHelp: { conversation: 'only this conversation’s turns', name: 'only traces whose name contains this, e.g. an Agent’s slug', errors: 'only traces with an error', limit: 'at most this many (default 10)' },
|
|
41
|
+
summary: 'Recent traces on the project’s local runtime, newest first',
|
|
42
|
+
examples: ['frontera trace list', 'frontera trace list --conversation 6f1c… --json'],
|
|
43
|
+
offline: true,
|
|
44
|
+
},
|
|
45
|
+
async run(ctx) {
|
|
46
|
+
const link = projectRuntime(ctx.cwd)
|
|
47
|
+
const limit = Math.min(Math.max(Number(flagString(ctx, 'limit') ?? 10) || 10, 1), 50)
|
|
48
|
+
const traces = await listTraces(link, { limit, conversationId: flagString(ctx, 'conversation'), name: flagString(ctx, 'name'), errorsOnly: flagBool(ctx, 'errors') })
|
|
49
|
+
return {
|
|
50
|
+
data: { traces },
|
|
51
|
+
text: traces.length ? traces.map((t) => `${t.id} ${t.at} ${t.name}${t.level !== 'DEFAULT' ? ` ${t.level}` : ''}`).join('\n') : 'No traces.',
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const traceCommands: readonly Command[] = [traceGet, traceList]
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BlueprintAuthoringApi } from '../../api/blueprint-authoring-api'
|
|
2
|
+
import { keyReach, reachWords } from '../../key-reach'
|
|
2
3
|
import { table } from '../../table'
|
|
3
4
|
import type { Command } from '../types'
|
|
4
5
|
|
|
@@ -27,24 +28,30 @@ const list: Command = {
|
|
|
27
28
|
verb: 'list',
|
|
28
29
|
args: [],
|
|
29
30
|
flags: {},
|
|
30
|
-
summary: 'List workspaces in this organization, with the ids --workspace takes',
|
|
31
|
+
summary: 'List workspaces in this organization, with the ids --workspace takes and, for an organization key, which it may act in',
|
|
31
32
|
examples: ['frontera workspace list', 'frontera workspace list --json'],
|
|
32
33
|
},
|
|
33
34
|
async run(ctx) {
|
|
34
35
|
const rows = await new BlueprintAuthoringApi(ctx.apiUrl, ctx.token).orgWorkspaces()
|
|
36
|
+
// An organization key acts only in the workspaces on its list: each row says whether it may (X3).
|
|
37
|
+
const reach = ctx.token.startsWith('sk-org-') && rows.length ? await keyReach(ctx.apiUrl, ctx.token, { list: async () => rows }) : null
|
|
38
|
+
const actsIn = new Map((reach ?? []).map((w) => [w.id, w.actsIn]))
|
|
35
39
|
|
|
36
40
|
return {
|
|
37
|
-
data: rows,
|
|
41
|
+
data: reach ? rows.map((w) => ({ ...w, actsIn: actsIn.get(w.id ?? '') ?? false })) : rows,
|
|
38
42
|
text: rows.length === 0
|
|
39
43
|
? 'No workspaces visible to this credential.\n'
|
|
40
44
|
+ ' This read needs an organization key (sk-org-…) carrying organization:update.'
|
|
41
45
|
: // ID first: it is the value `--workspace` takes, and the reason to
|
|
42
46
|
// run this at all. Name and slug are for recognising which is which.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
[
|
|
48
|
+
table(
|
|
49
|
+
reach ? ['id', 'name', 'slug', 'this key'] : ['id', 'name', 'slug'],
|
|
50
|
+
rows.map((w) => [w.id ?? '?', w.name ?? '', w.slug ?? '', ...(reach ? [actsIn.get(w.id ?? '') ? 'acts here' : '—'] : [])]),
|
|
51
|
+
[undefined, 34, undefined, undefined],
|
|
52
|
+
),
|
|
53
|
+
...(reach ? ['', `This key ${reachWords(reach)}.`] : []),
|
|
54
|
+
].join('\n'),
|
|
48
55
|
}
|
|
49
56
|
},
|
|
50
57
|
}
|
package/src/config.ts
CHANGED
|
@@ -140,7 +140,10 @@ export function resolveProfileName(
|
|
|
140
140
|
},
|
|
141
141
|
)
|
|
142
142
|
}
|
|
143
|
-
|
|
143
|
+
// An organization command takes the organization profile the binding names (X3), or a local
|
|
144
|
+
// runtime's own organization profile beside its workspace profile.
|
|
145
|
+
const named = opts.organization && found.organizationProfile && found.organizationTrusted && getProfile(found.organizationProfile, env)?.credentialKind === 'organization' ? found.organizationProfile : null
|
|
146
|
+
const companion = named ?? (opts.organization ? localOrganizationCompanion(found.profile, env) : null)
|
|
144
147
|
if (companion) return { profile: companion, source: 'directory', path: found.path }
|
|
145
148
|
return { profile: found.profile, source: 'directory', path: found.path }
|
|
146
149
|
}
|
package/src/dev-broker.ts
CHANGED
|
@@ -79,12 +79,26 @@ function freeOn(port: number, host: string): Promise<boolean> {
|
|
|
79
79
|
const server = createServer()
|
|
80
80
|
server.once('error', (err: NodeJS.ErrnoException) => {
|
|
81
81
|
// No IPv6 on this machine: nothing can hold the port there either.
|
|
82
|
-
|
|
82
|
+
if (err.code === 'EADDRNOTAVAIL' || err.code === 'EAFNOSUPPORT') return resolve(true)
|
|
83
|
+
// Bun says only "Failed to listen at ::1", with no code: free when no IPv6 port can be bound at all.
|
|
84
|
+
if (!err.code && host.includes(':')) return void ipv6Available().then((has) => resolve(!has))
|
|
85
|
+
resolve(false)
|
|
83
86
|
})
|
|
84
87
|
server.listen({ port, host, exclusive: true, ...(host === '::' ? { ipv6Only: true } : {}) }, () => server.close(() => resolve(true)))
|
|
85
88
|
})
|
|
86
89
|
}
|
|
87
90
|
|
|
91
|
+
let ipv6: Promise<boolean> | null = null
|
|
92
|
+
/** Whether this machine can listen on IPv6 at all: a port the system picks on ::1. */
|
|
93
|
+
function ipv6Available(): Promise<boolean> {
|
|
94
|
+
ipv6 ??= new Promise((resolve) => {
|
|
95
|
+
const server = createServer()
|
|
96
|
+
server.once('error', () => resolve(false))
|
|
97
|
+
server.listen({ port: 0, host: '::1' }, () => server.close(() => resolve(true)))
|
|
98
|
+
})
|
|
99
|
+
return ipv6
|
|
100
|
+
}
|
|
101
|
+
|
|
88
102
|
/**
|
|
89
103
|
* Whether nothing listens on `port` on either loopback, IPv4 or IPv6, nor on
|
|
90
104
|
* a wildcard. Probing 127.0.0.1 alone let a dev server start beside another
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { CredentialStore } from './credential-store'
|
|
2
|
+
import { CliError } from './errors'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A person's session for the CLI, approved in the browser (Forge improvement
|
|
6
|
+
* plan N5, RFC 8628 device authorization).
|
|
7
|
+
*
|
|
8
|
+
* Some commands act as a person (publishing a Blueprint, reviewing an
|
|
9
|
+
* Action, granting) and refuse every API key. Before, the only way to run
|
|
10
|
+
* them from a terminal was a session token copied out of the browser's
|
|
11
|
+
* developer tools. Now `frontera auth login --browser` asks the platform for
|
|
12
|
+
* a code, the person approves it on `/auth/device` while signed in, and the
|
|
13
|
+
* CLI keeps the session it receives in the keychain, beside the profile's
|
|
14
|
+
* key, for those commands only. The platform makes it short-lived (12 hours)
|
|
15
|
+
* and `frontera auth logout` revokes it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The client id the platform accepts device codes for. */
|
|
19
|
+
export const DEVICE_CLIENT_ID = 'frontera-cli'
|
|
20
|
+
|
|
21
|
+
const GRANT = 'urn:ietf:params:oauth:grant-type:device_code'
|
|
22
|
+
|
|
23
|
+
export interface DeviceSession {
|
|
24
|
+
token: string
|
|
25
|
+
/** ISO time the platform said it stops working. */
|
|
26
|
+
expiresAt: string
|
|
27
|
+
apiUrl: string
|
|
28
|
+
email?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DeviceCode {
|
|
32
|
+
deviceCode: string
|
|
33
|
+
userCode: string
|
|
34
|
+
verificationUri: string
|
|
35
|
+
verificationUriComplete: string
|
|
36
|
+
intervalMs: number
|
|
37
|
+
expiresAt: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The store slot a profile's session lives in, beside its key. */
|
|
41
|
+
export function sessionSlot(profile: string): string {
|
|
42
|
+
return `${profile}#session`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type Fetch = (url: string, init: RequestInit) => Promise<Response>
|
|
46
|
+
|
|
47
|
+
const authUrl = (apiUrl: string, path: string) => `${apiUrl.replace(/\/+$/, '')}/api/auth${path}`
|
|
48
|
+
|
|
49
|
+
async function post(fetchFn: Fetch, url: string, body: unknown, bearer?: string): Promise<{ status: number; data: Record<string, unknown> }> {
|
|
50
|
+
const res = await fetchFn(url, { method: 'POST', headers: { 'content-type': 'application/json', ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) }, body: JSON.stringify(body) })
|
|
51
|
+
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
|
52
|
+
return { status: res.status, data }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Ask the platform for a code. A platform without device authorization answers 404. */
|
|
56
|
+
export async function requestDeviceCode(apiUrl: string, fetchFn: Fetch = fetch, now = Date.now()): Promise<DeviceCode> {
|
|
57
|
+
const { status, data } = await post(fetchFn, authUrl(apiUrl, '/device/code'), { client_id: DEVICE_CLIENT_ID })
|
|
58
|
+
if (status === 404) throw new CliError('this platform does not offer browser sign-in for the CLI yet', { code: 'DEVICE_LOGIN_UNAVAILABLE', hint: 'ask your administrator to update the platform; until then, do this step in the product' })
|
|
59
|
+
if (status >= 400 || typeof data.device_code !== 'string' || typeof data.user_code !== 'string') {
|
|
60
|
+
throw new CliError(`the platform refused a sign-in code: ${String(data.error_description ?? data.error ?? status)}`, { code: 'DEVICE_LOGIN_REFUSED', hint: 'check the profile points at your deployment (`frontera auth current`), then try again' })
|
|
61
|
+
}
|
|
62
|
+
const uri = String(data.verification_uri ?? '/auth/device')
|
|
63
|
+
const absolute = /^https?:\/\//.test(uri) ? uri : new URL(uri, apiUrl).toString()
|
|
64
|
+
return {
|
|
65
|
+
deviceCode: data.device_code,
|
|
66
|
+
userCode: data.user_code,
|
|
67
|
+
verificationUri: absolute,
|
|
68
|
+
verificationUriComplete: typeof data.verification_uri_complete === 'string' && /^https?:\/\//.test(data.verification_uri_complete) ? data.verification_uri_complete : `${absolute}?user_code=${encodeURIComponent(data.user_code)}`,
|
|
69
|
+
intervalMs: Math.max(1, Number(data.interval) || 5) * 1000,
|
|
70
|
+
expiresAt: now + (Number(data.expires_in) || 600) * 1000,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Poll until the person approves, denies or the code expires. The platform
|
|
76
|
+
* answers `authorization_pending` until then and `slow_down` when polled too
|
|
77
|
+
* often, which adds five seconds as the RFC says.
|
|
78
|
+
*/
|
|
79
|
+
export async function awaitDeviceSession(
|
|
80
|
+
apiUrl: string,
|
|
81
|
+
code: DeviceCode,
|
|
82
|
+
deps: { fetch?: Fetch; sleep?: (ms: number) => Promise<void>; now?: () => number } = {},
|
|
83
|
+
): Promise<DeviceSession> {
|
|
84
|
+
const fetchFn = deps.fetch ?? fetch
|
|
85
|
+
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)))
|
|
86
|
+
const now = deps.now ?? Date.now
|
|
87
|
+
let interval = code.intervalMs
|
|
88
|
+
while (now() < code.expiresAt) {
|
|
89
|
+
await sleep(interval)
|
|
90
|
+
const { data } = await post(fetchFn, authUrl(apiUrl, '/device/token'), { grant_type: GRANT, device_code: code.deviceCode, client_id: DEVICE_CLIENT_ID })
|
|
91
|
+
if (typeof data.access_token === 'string') {
|
|
92
|
+
return { token: data.access_token, expiresAt: new Date(now() + (Number(data.expires_in) || 0) * 1000).toISOString(), apiUrl }
|
|
93
|
+
}
|
|
94
|
+
if (data.error === 'authorization_pending') continue
|
|
95
|
+
if (data.error === 'slow_down') {
|
|
96
|
+
interval += 5000
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
if (data.error === 'access_denied') throw new CliError('the sign-in was denied in the browser', { code: 'DEVICE_LOGIN_DENIED', hint: 'run `frontera auth login --browser` again to start over' })
|
|
100
|
+
if (data.error === 'expired_token') break
|
|
101
|
+
throw new CliError(`the sign-in failed: ${String(data.error_description ?? data.error ?? 'no answer')}`, { code: 'DEVICE_LOGIN_FAILED', hint: 'run `frontera auth login --browser` again' })
|
|
102
|
+
}
|
|
103
|
+
throw new CliError('the sign-in code expired before it was approved', { code: 'DEVICE_LOGIN_EXPIRED', hint: 'run `frontera auth login --browser` again' })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Who the session is, for the line the CLI prints; null when the platform doesn't say. */
|
|
107
|
+
export async function sessionEmail(apiUrl: string, token: string, fetchFn: Fetch = fetch): Promise<string | null> {
|
|
108
|
+
const res = await fetchFn(authUrl(apiUrl, '/get-session'), { method: 'GET', headers: { authorization: `Bearer ${token}` } }).catch(() => null)
|
|
109
|
+
const data = res ? ((await res.json().catch(() => null)) as { user?: { email?: string } } | null) : null
|
|
110
|
+
return data?.user?.email ?? null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function saveDeviceSession(store: CredentialStore, profile: string, session: DeviceSession): Promise<void> {
|
|
114
|
+
await store.set(sessionSlot(profile), JSON.stringify(session))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The profile's session when it is for this origin and still good for a minute; null otherwise. */
|
|
118
|
+
export async function readDeviceSession(store: CredentialStore, profile: string, apiUrl: string, now = Date.now()): Promise<DeviceSession | null> {
|
|
119
|
+
const raw = await store.get(sessionSlot(profile)).catch(() => null)
|
|
120
|
+
if (!raw) return null
|
|
121
|
+
try {
|
|
122
|
+
const session = JSON.parse(raw) as DeviceSession
|
|
123
|
+
if (typeof session.token !== 'string' || session.apiUrl.replace(/\/+$/, '') !== apiUrl.replace(/\/+$/, '')) return null
|
|
124
|
+
if (Date.parse(session.expiresAt) - now < 60_000) return null
|
|
125
|
+
return session
|
|
126
|
+
} catch {
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Revoke the session on the platform (best effort) and forget it here. */
|
|
132
|
+
export async function forgetDeviceSession(store: CredentialStore, profile: string, fetchFn: Fetch = fetch): Promise<boolean> {
|
|
133
|
+
const raw = await store.get(sessionSlot(profile)).catch(() => null)
|
|
134
|
+
if (!raw) return false
|
|
135
|
+
try {
|
|
136
|
+
const session = JSON.parse(raw) as DeviceSession
|
|
137
|
+
await post(fetchFn, authUrl(session.apiUrl, '/revoke-session'), { token: session.token }, session.token).catch(() => null)
|
|
138
|
+
} catch {
|
|
139
|
+
// Not a session this CLI wrote; it is forgotten all the same.
|
|
140
|
+
}
|
|
141
|
+
await store.delete(sessionSlot(profile))
|
|
142
|
+
return true
|
|
143
|
+
}
|