@frontera-sdk/cli 1.53.10 → 1.53.12

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.
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, writeFileSync } from 'node:fs'
1
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
2
  import { basename, join } from 'node:path'
3
3
 
4
4
  import { CliError, UsageError } from '../../errors'
@@ -11,7 +11,38 @@ import { lessonsToPropose, pullLessons, type LessonsPulled } from '../../local/l
11
11
  import { analyticsNeeded, connectionsNeeded, documentationNeeded, standInPath, writePlatformOnly, writeStandIns } from '../../local/local-stand-ins'
12
12
  import { localProject, type LocalProject } from '../../local/local-project'
13
13
  import { normalizeOrigin, syncClient, type SyncClient } from '../../local/local-sync-client'
14
- import { getSyncKey, organizationReader, projectFile, projectKeyFingerprints, readLink, registerTargetProfile, removeSyncKey, removeTargetProfile, setSyncKey, targetProfileName, targetSession, writeLink, type SyncLink } from '../../local/local-sync-link'
14
+ import {
15
+ getSyncKey,
16
+ keysCheckedAt,
17
+ leftOutTargets,
18
+ linkTarget,
19
+ namedTarget,
20
+ organizationReader,
21
+ projectFile,
22
+ projectKeyFingerprints,
23
+ projectTargets,
24
+ readLink,
25
+ readTargets,
26
+ refuseMovedTarget,
27
+ registerTargetProfile,
28
+ removeSyncKey,
29
+ removeTargetProfile,
30
+ setKeysCheckedAt,
31
+ setSyncKey,
32
+ TARGET_NAME,
33
+ targetLabel,
34
+ targetOwner,
35
+ targetProfileName,
36
+ targetSession,
37
+ targetState,
38
+ targetWords,
39
+ writeLink,
40
+ writeTarget,
41
+ type KeyOwner,
42
+ type NamedTarget,
43
+ type SyncLink,
44
+ type SyncTarget,
45
+ } from '../../local/local-sync-link'
15
46
  import { signInStep } from '../../device-login'
16
47
  import { describeRepair, repairBases, type BaseRepair } from '../../local/local-sync-base'
17
48
  import { conflictsId, mergeItemFiles, type Choice, type Conflict, type Side } from '../../local/local-sync-merge'
@@ -19,6 +50,7 @@ import {
19
50
  applyItems,
20
51
  baseFiles,
21
52
  computePlan,
53
+ entryFor,
22
54
  forgetItems,
23
55
  itemHash,
24
56
  KIND_LABEL,
@@ -29,11 +61,14 @@ import {
29
61
  summarize,
30
62
  SYNC_KINDS,
31
63
  WORKSPACE_SUFFIX,
64
+ writeLock,
32
65
  type ItemStatus,
66
+ type LockEntry,
33
67
  type PlanItem,
34
68
  type RemoteItem,
35
69
  type Resolution,
36
70
  type SyncKind,
71
+ type SyncLock,
37
72
  } from '../../local/local-sync-plan'
38
73
  import { readWorkspace, type SyncSource } from '../../local/local-sync-remote'
39
74
  import { rememberLocalCatalog } from '../../local/local-sync-models'
@@ -62,7 +97,7 @@ import {
62
97
  type RemoteSnapshot,
63
98
  } from '../../local/local-sync-snapshot'
64
99
  import { flagBool, flagString, type Command, type CommandContext } from '../types'
65
- import { currentRequest, readRequests } from '../../local/local-sync-push'
100
+ import { currentRequest, projectRefs, readRequests } from '../../local/local-sync-push'
66
101
  import { syncMapModel, syncPush, syncRequest } from './sync-request'
67
102
 
68
103
  /**
@@ -83,6 +118,12 @@ import { syncMapModel, syncPush, syncRequest } from './sync-request'
83
118
  * and `request apply` write to the workspace, through a change request.
84
119
  * Keys arrive on stdin or at a hidden prompt and go to the keychain; the
85
120
  * link is in `frontera.yaml`.
121
+ *
122
+ * Named targets (`sync.targets`: staging, production) are workspaces the
123
+ * project delivers to besides its link, each with its own keys, profile and
124
+ * change requests and base. `connect`, `push`, `request`, `diff`, `resolve`
125
+ * and `disconnect` act on one when they name it with `--target`; nothing else
126
+ * reaches it, and nothing is pulled from it.
86
127
  */
87
128
 
88
129
  /** A snapshot this recent is used as it is by pull; an older one is fetched again first. */
@@ -107,11 +148,43 @@ function needLink(project: LocalProject): SyncLink {
107
148
  return link
108
149
  }
109
150
 
110
- /** The project's readers: the workspace's with its key, and the organization's (`organizationReader`) when there is one. */
111
- export async function sourceFor(project: LocalProject, link: SyncLink): Promise<SyncSource> {
112
- const key = getSyncKey(project, 'workspace')
113
- if (!key) throw new CliError('no workspace key for this project on this machine', { code: 'SYNC_NO_KEY', hint: 'run `frontera local sync connect` to store one (the link is in frontera.yaml; keys are per machine)' })
114
- const { client, notSteward } = await organizationReader(project, link)
151
+ /**
152
+ * The target a command names with `--target`, else the project's link. The
153
+ * name is matched exactly, so a production target is reached only when the
154
+ * command names it; one the CLI leaves out is refused with why, and one the
155
+ * project doesn't have with the ones it has.
156
+ */
157
+ function findTarget(project: LocalProject, ctx: CommandContext): SyncTarget {
158
+ const name = flagString(ctx, 'target')
159
+ if (name === undefined) return linkTarget(project, needLink(project))
160
+ const targets = readTargets(project.root)
161
+ const target = targets[name]
162
+ const leftOut = leftOutTargets(project.root).find((t) => t.name === name)
163
+ if (leftOut) throw new UsageError(`target ${name} in frontera.yaml can’t be used: ${leftOut.problem}`, `fix its entry in frontera.yaml, or connect it again: \`frontera local sync connect --target ${TARGET_NAME.test(name) ? name : '<name>'} --platform <url>\``)
164
+ if (!target) {
165
+ const names = Object.keys(targets)
166
+ throw new UsageError(`this project has no target \`${name}\``, names.length ? `its targets are ${names.join(', ')}, named exactly` : `add one with \`frontera local sync connect --target ${TARGET_NAME.test(name) ? name : '<name>'} --platform <url> --workspace-key -\``)
167
+ }
168
+ return namedTarget(project, name, target)
169
+ }
170
+
171
+ /** Where a command that calls the platform acts: a named target only while it points where its keys were checked. */
172
+ export function chooseTarget(project: LocalProject, ctx: CommandContext): SyncTarget {
173
+ const target = findTarget(project, ctx)
174
+ refuseMovedTarget(target)
175
+ return target
176
+ }
177
+
178
+ /**
179
+ * The readers for a project's link, or for one of its named targets (`name`),
180
+ * with that one's own keys and sign-in, never another target's: the
181
+ * workspace's with its key, and the organization's (`organizationReader`)
182
+ * when there is one.
183
+ */
184
+ export async function sourceFor(owner: KeyOwner, link: SyncLink, name: string | null = null): Promise<SyncSource> {
185
+ const key = getSyncKey(owner, 'workspace')
186
+ if (!key) throw new CliError(`no workspace key for ${name ? `target ${name}` : 'this project'} on this machine`, { code: 'SYNC_NO_KEY', hint: `run \`${targetWords({ name }, 'frontera local sync connect')}\` to store one (the link is in frontera.yaml; keys are per machine)` })
187
+ const { client, notSteward } = await organizationReader(owner, link)
115
188
  return {
116
189
  workspace: syncClient(link.platform, key),
117
190
  ...(client ? { organization: client } : {}),
@@ -195,12 +268,68 @@ export async function fetchFor(project: LocalProject, link: SyncLink, opts: { fu
195
268
  return { ...result, conflicts: await settleConflicts(project, link, result.snapshot, source) }
196
269
  }
197
270
 
271
+ /**
272
+ * Fetch where a command delivers: the link as `fetchFor` does, or a named
273
+ * target into its own snapshot (`target.state`) with its own keys. A named
274
+ * target is never pulled from, so it has no conflicts to settle.
275
+ */
276
+ export async function fetchTarget(project: LocalProject, target: SyncTarget, opts: { full?: boolean } = {}): Promise<FetchResult> {
277
+ if (!target.name) return fetchFor(project, target.link, opts)
278
+ const source = await sourceFor(target.owner, target.link, target.name)
279
+ const result = await fetchWorkspace(target.state, source, { blueprint: target.link.blueprint, full: opts.full })
280
+ await nameLink(project, target.link, source, (named) => writeTarget(project.root, target.name!, { ...named, ...(target.production ? { production: true } : {}) }))
281
+ return result
282
+ }
283
+
284
+ /**
285
+ * The plan against a named target. The project's own base was pulled from
286
+ * its link, whose ids and revisions are another platform's, so a target keeps
287
+ * a base of its own: what an Apply to it left (`frontera.lock` under
288
+ * `target.state`). A change made on the target since then reads as changed
289
+ * there, and is not put back unasked; one made on both sides is a conflict.
290
+ * An item the project knows (pulled, or made here) that it never delivered to
291
+ * the target, but the target holds, has the target's version as its base, so
292
+ * whatever differs here is proposed over it with the target's own id and
293
+ * revision. An item the target doesn't hold is new there, unless this project
294
+ * delivered it: then the target deleted it since. An item pulled with
295
+ * conflicts stays unresolved. The target's index is read whole, since one
296
+ * read from a cursor lists no deletion.
297
+ */
298
+ export async function targetPlan(project: LocalProject, target: SyncTarget) {
299
+ const { snapshot } = await fetchTarget(project, target, { full: true })
300
+ const items = snapshotItems(target.state, snapshot)
301
+ const theirs = new Map(items.map((i) => [i.ref, i]))
302
+ const gone = knownGone(snapshot)
303
+ const delivered = readLock(target.state)
304
+ const known = delivered?.workspace === target.link.workspace ? delivered.items : {}
305
+ const pulled = readLock(project.root)?.items ?? {}
306
+ const now = new Date().toISOString()
307
+ // The base text of an item never delivered from here: what the target holds.
308
+ const holds = new Map<string, string>()
309
+ const refs = new Set([...Object.keys(known), ...Object.keys(pulled), ...projectRefs(project.root).keys()])
310
+ const lock: SyncLock = {
311
+ version: 2,
312
+ workspace: target.link.workspace,
313
+ items: Object.fromEntries([...refs].flatMap((ref): Array<[string, LockEntry]> => {
314
+ const item = theirs.get(ref)
315
+ if (!item) return known[ref] && gone(ref) ? [[ref, known[ref]!]] : []
316
+ if (!known[ref]) for (const [path, text] of Object.entries(item.files)) holds.set(path, text)
317
+ const conflict = pulled[ref]?.conflict
318
+ return [[ref, { ...(known[ref] ?? entryFor(item, now)), ...(conflict ? { conflict } : {}) }]]
319
+ })),
320
+ }
321
+ const base = (path: string) => holds.get(path) ?? readBase(target.state, path)
322
+ await refreshLocalCatalog(project)
323
+ const plan = computePlan(project.root, items, lock, localItems(project), { remoteKnownGone: gone, base, directory: directoryOf(snapshot) })
324
+ return { remote: { items, warnings: snapshot.warnings, notes: snapshot.notes }, plan, lock, snapshot, base, delivered: new Set(Object.keys(known)) }
325
+ }
326
+
198
327
  /**
199
328
  * A link made while the platform could not say the workspace's name (or
200
329
  * before it could) learns it on the next fetch, so the project shows the
201
330
  * workspace by its name rather than its id. The link passed in is named too.
202
331
  */
203
- async function nameLink(project: LocalProject, link: SyncLink, source: SyncSource): Promise<void> {
332
+ async function nameLink(project: LocalProject, link: SyncLink, source: SyncSource, write = (named: SyncLink) => writeLink(project.root, named)): Promise<void> {
204
333
  if (link.workspaceName && link.organizationName) return
205
334
  const identity = await source.workspace.find<{ workspace?: { name?: unknown }; organization?: { name?: unknown } }>('/v1/sync/identity').catch(() => null)
206
335
  const name = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
@@ -208,7 +337,7 @@ async function nameLink(project: LocalProject, link: SyncLink, source: SyncSourc
208
337
  const organizationName = link.organizationName ?? name(identity?.organization?.name)
209
338
  if (workspaceName === link.workspaceName && organizationName === link.organizationName) return
210
339
  Object.assign(link, { workspaceName, organizationName })
211
- writeLink(project.root, link)
340
+ write(link)
212
341
  }
213
342
 
214
343
  /** The snapshot for this link, when one was fetched for it. */
@@ -420,26 +549,50 @@ async function checkKeys(platform: string, keys: { workspace: string; organizati
420
549
 
421
550
  const connect = async (ctx: CommandContext) => {
422
551
  const project = localProject(ctx.cwd)
423
- const previous = readLink(project.root)
552
+ // `--target <name>`: a named target (`sync.targets.<name>`) with its own keys; without it, the project's link.
553
+ const name = flagString(ctx, 'target') ?? null
554
+ if (name !== null && !TARGET_NAME.test(name)) throw new UsageError(`\`${name}\` can’t name a target`, 'a target’s name is lowercase letters, digits and dashes, starting with a letter: staging, production')
555
+ if (name === null && flagBool(ctx, 'production')) throw new UsageError('--production marks a named target', 'the project’s link is never a production target: `frontera local sync connect --target production --production --platform <url> --workspace-key -`')
556
+ const link = readLink(project.root)
557
+ const targets = readTargets(project.root)
558
+ const previous = name ? targets[name] ?? null : link
559
+ const owner = targetOwner(project, name)
424
560
  const platformRaw = flagString(ctx, 'platform') ?? previous?.platform
425
- if (!platformRaw) throw new UsageError('give the platform address', 'frontera local sync connect --platform https://api.acme.frontera.example')
561
+ if (!platformRaw) throw new UsageError('give the platform address', `${targetWords({ name }, 'frontera local sync connect')} --platform https://api.acme.frontera.example`)
426
562
  const platform = normalizeOrigin(platformRaw)
427
563
  const wantOrg = flagString(ctx, 'org-key') !== undefined
428
564
  const keys = await readKeys(ctx, wantOrg)
429
565
  if (!keys.workspace) throw new UsageError('no workspace key given', 'pass `--workspace-key -` with the key on stdin, or run in a terminal to be prompted')
430
566
  const { me, identity } = await checkKeys(platform, keys)
431
567
  if (previous && previous.workspace !== me.workspaceId && !flagBool(ctx, 'relink')) {
432
- throw new CliError(`this project is linked to another workspace (${previous.workspaceName ?? previous.workspace})`, { code: 'CONFLICT', hint: 'a project links to one workspace; pass --relink to point it at this one' })
568
+ throw new CliError(`${name ? `target ${name}` : 'this project'} is linked to another workspace (${previous.workspaceName ?? previous.workspace})`, { code: 'CONFLICT', hint: `${name ? 'a target' : 'a project'} links to one workspace; pass --relink to point it at this one` })
433
569
  }
570
+ // Each workspace is reached one way: by the link, or by one named target. The link is never pointed at a production target.
571
+ const others: Array<[string | null, NamedTarget]> = [...(name && link ? [[null, link] as [null, SyncLink]] : []), ...Object.entries(targets).filter(([n]) => n !== name)]
572
+ const taken = others.find(([, t]) => normalizeOrigin(t.platform) === platform && t.workspace === me.workspaceId)
573
+ if (taken) {
574
+ const [other, t] = taken
575
+ const where = t.workspaceName ?? t.workspace
576
+ if (other === null) throw new CliError(`${where} is this project’s link already`, { code: 'CONFLICT', hint: 'a named target is another workspace; push and apply to the link without --target' })
577
+ throw new CliError(`target ${other} reaches ${where} already`, {
578
+ code: 'CONFLICT',
579
+ hint: t.production
580
+ ? `${other} is a production target, reached only by its name: deliver to it with \`--target ${other}\``
581
+ : `deliver to it with \`--target ${other}\`, or remove that target first (\`frontera local sync disconnect --target ${other} --forget-link\`)`,
582
+ })
583
+ }
584
+ const production = name !== null && (flagBool(ctx, 'production') || targets[name]?.production === true)
434
585
  const blueprintFlag = flagString(ctx, 'blueprint')
435
586
  if (blueprintFlag && blueprintFlag !== 'workspace' && blueprintFlag !== 'organization') throw new UsageError('--blueprint is workspace or organization', '`--blueprint organization` reads the whole model and needs an organization key')
436
- // The stored organization key belongs to the previous link's platform and organization: kept only while both stay.
437
- const keepsOrganizationKey = !keys.organization && previous?.platform === platform && previous.organization === me.orgId
438
- const link: SyncLink = {
587
+ // The stored organization key belongs to the platform and organization it was checked with: kept only while both stay.
588
+ // A named target's entry is a committed file that a commit can move, so its keys stay only where `connect` checked them.
589
+ const checked = name ? keysCheckedAt(owner) : previous
590
+ const keepsOrganizationKey = !keys.organization && checked?.platform === platform && (name ? checked.workspace === me.workspaceId : previous?.organization === me.orgId)
591
+ const next: SyncLink = {
439
592
  platform,
440
593
  workspace: me.workspaceId!,
441
594
  workspaceName: identity?.workspace.name ?? previous?.workspaceName,
442
- organization: keys.organization ? me.orgId! : keepsOrganizationKey ? previous!.organization : undefined,
595
+ organization: keys.organization || keepsOrganizationKey ? me.orgId! : undefined,
443
596
  organizationName: identity?.organization.name ?? previous?.organizationName,
444
597
  blueprint: (blueprintFlag as SyncLink['blueprint'] | undefined) ?? (previous?.blueprint === 'organization' && keys.organization ? 'organization' : 'workspace'),
445
598
  // The models mapped for this workspace stay; another workspace has its own catalog.
@@ -448,29 +601,33 @@ const connect = async (ctx: CommandContext) => {
448
601
  // A folder that is not a project yet gets the project file the link lives in.
449
602
  if (!existsSync(projectFile(project.root))) writeFileSync(projectFile(project.root), `# A Frontera project. \`frontera local dev\` applies agents/, workflows/, functions/ and blueprint/.\nname: ${basename(project.root)}\n`)
450
603
  // The previous target's profile goes with the keys it was made from.
451
- if (previous && targetProfileName(project, previous) !== targetProfileName(project, link)) await removeTargetProfile(project, previous)
604
+ if (checked && targetProfileName(owner, checked) !== targetProfileName(owner, next)) await removeTargetProfile(owner, checked)
452
605
  // Read before the keys are replaced, so a profile holding the key a rotation replaces is still the project's.
453
- const previousKeys = projectKeyFingerprints(project)
454
- setSyncKey(project, 'workspace', keys.workspace)
455
- if (keys.organization) setSyncKey(project, 'organization', keys.organization)
456
- else if (!keepsOrganizationKey) removeSyncKey(project, 'organization')
457
- writeLink(project.root, link)
606
+ const previousKeys = projectKeyFingerprints(owner)
607
+ setSyncKey(owner, 'workspace', keys.workspace)
608
+ if (keys.organization) setSyncKey(owner, 'organization', keys.organization)
609
+ else if (!keepsOrganizationKey) removeSyncKey(owner, 'organization')
610
+ if (name) {
611
+ setKeysCheckedAt(owner, next)
612
+ writeTarget(project.root, name, { ...next, ...(production ? { production: true } : {}) })
613
+ } else writeLink(project.root, next)
458
614
  // The workspace as a CLI profile: a change request's steps for a person name it (`--profile`).
459
- const { profile, profileError } = await registerTargetProfile(project, link, previousKeys, me.orgId).then(
460
- (name) => ({ profile: name, profileError: null }),
615
+ const { profile, profileError } = await registerTargetProfile(owner, next, previousKeys, me.orgId).then(
616
+ (profileName) => ({ profile: profileName, profileError: null }),
461
617
  (err: Error) => ({ profile: null, profileError: err.message }),
462
618
  )
463
619
  // A change request is applied as the person signed in for this profile; no organization key needs to be here.
464
- const signIn = profile ? await signInStep({ profile, apiUrl: link.platform, named: true }) : null
620
+ const signIn = profile ? await signInStep({ profile, apiUrl: next.platform, named: true }) : null
621
+ const where = `${next.workspaceName ?? next.workspace}${next.organizationName ? ` (${next.organizationName})` : ''} on ${new URL(platform).host}`
465
622
  return {
466
- data: { link, keys: { workspace: true, organization: !!(keys.organization || getSyncKey(project, 'organization')) }, profile, signIn },
623
+ data: { link: next, ...(name ? { target: name, production } : {}), keys: { workspace: true, organization: !!(keys.organization || getSyncKey(owner, 'organization')) }, profile, signIn },
467
624
  text: [
468
- `Linked to ${link.workspaceName ?? link.workspace}${link.organizationName ? ` (${link.organizationName})` : ''} on ${new URL(platform).host}.`,
469
- 'Keys are in your keychain; the link is in frontera.yaml.',
625
+ name ? `Target ${name}${production ? ' (production: reached only by its name)' : ''} is ${where}.` : `Linked to ${where}.`,
626
+ name ? 'Keys are in your keychain; the target is in frontera.yaml (sync.targets).' : 'Keys are in your keychain; the link is in frontera.yaml.',
470
627
  ...(profile ? [`A change request's steps for a person reach this platform with \`--profile ${profile}\`, not the local runtime.`] : []),
471
628
  ...(signIn ? [`Change requests you apply, and the steps they leave, act as you: sign in first with \`${signIn}\`.`] : []),
472
629
  ...(profileError ? [`No CLI profile was recorded for this platform (${profileError}); a change request's steps for a person name none until one is.`] : []),
473
- 'See what you can pull: `frontera local sync pull --preview`.',
630
+ name ? `See what would be proposed to it: \`frontera local sync push --preview --target ${name}\`.` : 'See what you can pull: `frontera local sync pull --preview`.',
474
631
  ].join('\n'),
475
632
  }
476
633
  }
@@ -505,7 +662,37 @@ const status = async (ctx: CommandContext) => {
505
662
  const lock = readLock(project.root)
506
663
  const lastPull = lock && link && lock.workspace === link.workspace ? lock.pulledAt ?? null : null
507
664
  const none = { fetchedAt: null, platform: null, incoming: 0, nowNamed: 0, newInWorkspace: 0, changedHere: 0, bothChanged: 0, mergeable: 0, conflicts: 0, unresolved: 0, clearedByWorkspace: 0, deletedInWorkspace: 0, deletedHere: 0, onlyHere: 0 }
508
- if (!link) return { data: { link: null, keys, lastPull: null, counts: null, error: null, ...none }, text: 'Not linked to a workspace. Connect one with `frontera local sync connect --platform <url>`.' }
665
+ const requestLine = (r: ReturnType<typeof currentRequest>) => (r ? `${r.state === 'merged' ? 'Applied' : r.applied && !r.applied.ok && r.state !== 'closed' ? 'Apply failed' : r.state === 'closed' ? 'Closed' : 'Proposed'} · ${plural(r.refs.length, 'item')} · ${r.title}` : null)
666
+ // The named targets: where each is, whether this machine has its keys and who is signed in for it, and its own change request.
667
+ const targets = await Promise.all(projectTargets(project).filter((t) => t.name).map(async (t) => {
668
+ const keys = { workspace: !!getSyncKey(t.owner, 'workspace'), organization: !!getSyncKey(t.owner, 'organization') }
669
+ const person = await targetSession(t.owner, t.link)
670
+ // A sign-in goes on the profile `connect` made where the keys were checked: none for an entry moved since.
671
+ const checked = keysCheckedAt(t.owner)
672
+ const here = keys.workspace && checked?.platform === t.link.platform && checked.workspace === t.link.workspace
673
+ return {
674
+ name: t.name!,
675
+ workspace: t.link.workspaceName ?? t.link.workspace,
676
+ platform: t.link.platform,
677
+ production: t.production,
678
+ keys,
679
+ signedIn: person ? { email: person.email ?? null, until: person.expiresAt } : null,
680
+ signIn: person || !here ? null : await signInStep({ profile: targetProfileName(t.owner, t.link), apiUrl: t.link.platform, named: true }),
681
+ request: requestLine(currentRequest(readRequests(t.state, t.link.workspace))),
682
+ }
683
+ }))
684
+ const targetsLeftOut = leftOutTargets(project.root)
685
+ const signedInWords = (t: (typeof targets)[number]) => (t.signedIn ? `, signed in as ${t.signedIn.email ?? 'you'}` : t.signIn ? `, not signed in (\`${t.signIn}\`)` : '')
686
+ const targetLines = [
687
+ ...targets.map((t) => ` Target ${t.name}${t.production ? ' (production)' : ''}: ${t.workspace} on ${new URL(t.platform).host}, ${t.keys.workspace ? 'keys stored' : 'no keys here'}${signedInWords(t)}${t.request ? ` · ${t.request}` : ''}`),
688
+ ...targetsLeftOut.map((t) => ` Target ${t.name}: left out, ${t.problem}`),
689
+ ]
690
+ if (!link) {
691
+ return {
692
+ data: { link: null, keys, lastPull: null, counts: null, error: null, ...none, targets, targetsLeftOut },
693
+ text: targetLines.length ? ['Not linked to a workspace: nothing is pulled. Connect one with `frontera local sync connect --platform <url>`.', ...targetLines].join('\n') : 'Not linked to a workspace. Connect one with `frontera local sync connect --platform <url>`.',
694
+ }
695
+ }
509
696
  let error: string | null = null
510
697
  let snapshot = snapshotFor(project, link)
511
698
  // `--fetch` asks the platform first; `--offline` is the default now and kept for older callers.
@@ -549,9 +736,9 @@ const status = async (ctx: CommandContext) => {
549
736
  // How the last `local dev` left the project, counted as Forge's home counts it.
550
737
  const build = projectBuildSummary(project.root, project.id)
551
738
  const mcp = mcpServersNotCarried(project.root)
552
- const request = kept ? `${kept.state === 'merged' ? 'Applied' : kept.applied && !kept.applied.ok && kept.state !== 'closed' ? 'Apply failed' : kept.state === 'closed' ? 'Closed' : 'Proposed'} · ${plural(kept.refs.length, 'item')} · ${kept.title}` : null
739
+ const request = requestLine(kept)
553
740
  return {
554
- data: { link, keys, signedIn: person ? { email: person.email ?? null, until: person.expiresAt } : null, signIn, lastPull, ...none, ...(tally ?? { counts: null }), ...(plan ? { items: { ...itemStates(plan), ...Object.fromEntries(lessons.map((slug) => [`lessons/${slug}`, 'outgoing' as const])) } } : {}), fetchedAt, platform: platformOf(snapshot), error, organizationKeyFor, build, notCarried: mcp ? { mcpServers: mcp.files, save: mcp.command } : null },
741
+ data: { link, keys, signedIn: person ? { email: person.email ?? null, until: person.expiresAt } : null, signIn, lastPull, ...none, ...(tally ?? { counts: null }), ...(plan ? { items: { ...itemStates(plan), ...Object.fromEntries(lessons.map((slug) => [`lessons/${slug}`, 'outgoing' as const])) } } : {}), fetchedAt, platform: platformOf(snapshot), error, organizationKeyFor, build, notCarried: mcp ? { mcpServers: mcp.files, save: mcp.command } : null, targets, targetsLeftOut },
555
742
  text: [
556
743
  `Linked to ${link.workspaceName ?? link.workspace}${link.organizationName ? ` (${link.organizationName})` : ''} on ${new URL(link.platform).host}`,
557
744
  ` Last pull ${lastPull ? when(lastPull) : 'never'}`,
@@ -565,6 +752,7 @@ const status = async (ctx: CommandContext) => {
565
752
  ...(build ? [` Build ${buildStatusText(build)}`] : []),
566
753
  ...(organizationKeyFor ? [` Actions this workspace’s workflows use governed Actions: ${signIn ? `sign in with \`${signIn}\`` : 'connect this computer, then sign in,'} to bring them`] : []),
567
754
  ...(mcp ? [` MCP servers ${mcp.files.join(', ')}: not in a change request; save with \`${mcp.command}\``] : []),
755
+ ...targetLines,
568
756
  ].join('\n'),
569
757
  }
570
758
  }
@@ -840,16 +1028,20 @@ export function wontRunLocally(root: string, items: ReadonlyArray<Pick<RemoteIte
840
1028
  }
841
1029
 
842
1030
  /** Everything about one item for `diff` and `resolve`: the plan row, the workspace's version, and the three-way merge. */
843
- async function itemState(project: LocalProject, link: SyncLink, ref: string) {
844
- // Offline, as diff and resolve are: a lost base comes back only from an unchanged copy here (pull reads the platform).
845
- const { remote, plan, lock, snapshot } = await planFor(project, link, { fetch: 'never', repair: 'offline' })
1031
+ async function itemState(project: LocalProject, target: SyncTarget, ref: string) {
1032
+ const { link } = target
1033
+ // Offline for the link, as diff and resolve are: a lost base comes back only from an unchanged copy here (pull
1034
+ // reads the platform). A named target is read as a push to it reads it, against what this project delivered.
1035
+ const { remote, plan, lock, snapshot, base: readBaseOf } = target.name
1036
+ ? await targetPlan(project, target)
1037
+ : { ...(await planFor(project, link, { fetch: 'never', repair: 'offline' })), base: (p: string) => readBase(project.root, p) }
846
1038
  if (!plan || !snapshot) throw new CliError('nothing fetched from the workspace yet', { code: 'SYNC_NOT_FETCHED', hint: 'run `frontera local sync fetch`' })
847
1039
  const row = plan.find((p) => p.ref === ref)
848
1040
  const item = remote.items.find((i) => i.ref === ref) ?? null
849
1041
  const entry = lock?.workspace === link.workspace ? lock.items[ref] : undefined
850
- if (!row && !item && !entry) throw new UsageError(`not in the workspace or the project: ${ref}`, 'list items with `frontera local sync pull --preview`')
1042
+ if (!row && !item && !entry) throw new UsageError(`not in the workspace or the project: ${ref}`, target.name ? `list what would go to it with \`${targetWords(target, 'frontera local sync push --preview')}\`` : 'list items with `frontera local sync pull --preview`')
851
1043
  const paths = [...new Set([...Object.keys(item?.files ?? {}), ...Object.keys(entry?.files ?? {}), ...(entry?.conflict?.files ?? [])])].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
852
- const base = baseFiles(entry, (p) => readBase(project.root, p))
1044
+ const base = baseFiles(entry, readBaseOf)
853
1045
  const local = localFiles(project.root, paths)
854
1046
  const theirs = item?.files ?? {}
855
1047
  return { row, item, lock, snapshot, paths, base, local, theirs, entry }
@@ -886,10 +1078,10 @@ function conflictText(c: Conflict): string[] {
886
1078
 
887
1079
  const diff = async (ctx: CommandContext) => {
888
1080
  const project = localProject(ctx.cwd)
889
- const link = needLink(project)
1081
+ const target = chooseTarget(project, ctx)
890
1082
  const ref = ctx.positional[1]
891
1083
  if (!ref) throw new UsageError('name the item', 'frontera local sync diff agent/support-concierge')
892
- const s = await itemState(project, link, ref)
1084
+ const s = await itemState(project, target, ref)
893
1085
  const result = mergeItemFiles(s.base, s.local, s.theirs)
894
1086
  const files = result.files.map((f) => {
895
1087
  const base = s.base[f.path] ?? null
@@ -908,7 +1100,9 @@ const diff = async (ctx: CommandContext) => {
908
1100
  ` ${f.path}: ${f.conflicts.length ? plural(f.conflicts.length, 'conflict') : WORD[f.state]}`,
909
1101
  ...f.conflicts.flatMap(conflictText),
910
1102
  ]),
911
- ...(result.conflicts
1103
+ ...(result.conflicts && target.name
1104
+ ? ['', `Keep the project’s version with \`${targetWords(target, `frontera local sync resolve ${ref} --keep mine`)}\`; to keep ${target.link.workspaceName ?? target.name}’s change too, carry it into the files here, then \`${targetWords(target, `frontera local sync resolve ${ref} --edited`)}\`.`]
1105
+ : result.conflicts
912
1106
  ? [
913
1107
  '',
914
1108
  `Settle with \`frontera local sync resolve ${ref} --keep workspace|mine\` (or \`--picks 1=workspace,2=mine\`), or edit and \`--edited\`.`,
@@ -921,10 +1115,12 @@ const diff = async (ctx: CommandContext) => {
921
1115
 
922
1116
  const resolve = async (ctx: CommandContext) => {
923
1117
  const project = localProject(ctx.cwd)
924
- const link = needLink(project)
1118
+ const target = chooseTarget(project, ctx)
1119
+ const { link } = target
925
1120
  const ref = ctx.positional[1]
926
1121
  if (!ref) throw new UsageError('name the item', 'frontera local sync resolve agent/support-concierge --keep workspace')
927
- const s = await itemState(project, link, ref)
1122
+ if (target.name) return resolveForTarget(project, target, ref, ctx)
1123
+ const s = await itemState(project, target, ref)
928
1124
  // Picks are by number: they apply only to the conflicts they were chosen for (`conflictsId` from
929
1125
  // `diff`). Checked first, so a conflict a workspace change cleared reads as stale, not as an error.
930
1126
  const chosenFor = flagString(ctx, 'conflicts')
@@ -963,8 +1159,58 @@ const resolve = async (ctx: CommandContext) => {
963
1159
  }
964
1160
  }
965
1161
 
1162
+ /**
1163
+ * A named target is never pulled from, so an item it changed since this
1164
+ * project delivered it is settled one way: the project's files are kept, and
1165
+ * the next push proposes them over what the target holds now. Its base for
1166
+ * the item is forgotten, so the push compares with the target's version.
1167
+ */
1168
+ async function resolveForTarget(project: LocalProject, target: SyncTarget, ref: string, ctx: CommandContext) {
1169
+ const keep = flagString(ctx, 'keep')
1170
+ const edited = flagBool(ctx, 'edited')
1171
+ const label = targetLabel(target)
1172
+ if ((keep !== undefined && keep !== 'mine') || flagString(ctx, 'picks') !== undefined) {
1173
+ throw new UsageError('a target is never pulled from: resolve keeps the project’s files', `\`${targetWords(target, `frontera local sync resolve ${ref} --keep mine`)}\` proposes them over what it holds; to keep its change too, carry it into the files here, then \`--edited\``)
1174
+ }
1175
+ if (!keep && !edited) throw new UsageError('say how to resolve it', `\`--keep mine\`, or \`--edited\` once the files here carry what to keep of ${label}’s change`)
1176
+ const s = await itemState(project, target, ref)
1177
+ if (!s.row || (s.row.status !== 'conflict' && s.row.status !== 'changed' && s.row.status !== 'deleted-remote')) {
1178
+ throw new UsageError(`nothing to resolve in ${ref} for ${label}: ${s.row ? statusWord(s.row) : 'it holds no such item'}`, 'resolve with --target settles an item the target changed since this project last delivered it')
1179
+ }
1180
+ const delivered = readLock(target.state)
1181
+ if (delivered?.items[ref]) {
1182
+ const { [ref]: _forgotten, ...items } = delivered.items
1183
+ writeLock(target.state, { ...delivered, items })
1184
+ }
1185
+ return {
1186
+ data: { ref, target: target.name, resolved: true, written: [], removed: [] },
1187
+ text: `Resolved ${ref} for ${label}: the project’s files are kept, and the next push to it ${s.row.status === 'deleted-remote' ? 'proposes it again, as new' : 'proposes them over what it holds now'}.`,
1188
+ }
1189
+ }
1190
+
966
1191
  const disconnect = async (ctx: CommandContext) => {
967
1192
  const project = localProject(ctx.cwd)
1193
+ const name = flagString(ctx, 'target')
1194
+ if (name !== undefined) {
1195
+ // Not `chooseTarget`: a target whose entry now points elsewhere, or that the CLI leaves out, still has its keys removed here.
1196
+ if (!TARGET_NAME.test(name) || !leftOutTargets(project.root).some((t) => t.name === name)) findTarget(project, ctx)
1197
+ const owner = targetOwner(project, name)
1198
+ // The profile made where its keys were checked: first, while those keys still show it is the project's.
1199
+ const checked = keysCheckedAt(owner)
1200
+ if (checked) await removeTargetProfile(owner, checked)
1201
+ removeSyncKey(owner, 'workspace')
1202
+ removeSyncKey(owner, 'organization')
1203
+ setKeysCheckedAt(owner, null)
1204
+ const forget = flagBool(ctx, 'forget-link')
1205
+ if (forget) {
1206
+ writeTarget(project.root, name, null)
1207
+ rmSync(targetState(project, name), { recursive: true, force: true })
1208
+ }
1209
+ return {
1210
+ data: { target: name, keysRemoved: true, linkRemoved: forget },
1211
+ text: forget ? `Removed target ${name}: its keys, and its entry in frontera.yaml.` : `Removed target ${name}’s keys from this machine. It stays in frontera.yaml for your teammates; \`--forget-link\` removes it too.`,
1212
+ }
1213
+ }
968
1214
  const link = readLink(project.root)
969
1215
  // First, while the keys that show the profile is the project's are still stored.
970
1216
  if (link) await removeTargetProfile(project, link)
@@ -992,6 +1238,14 @@ const SUBCOMMANDS: Record<string, (ctx: CommandContext) => Promise<{ data: unkno
992
1238
  'map-model': (ctx) => syncMapModel(ctx),
993
1239
  }
994
1240
 
1241
+ /** Subcommands that take no `--target`, and why: a target is only delivered to. */
1242
+ const NO_TARGET: Record<string, (name: string) => [string, string]> = {
1243
+ fetch: (name) => ['`fetch` reads the project’s link: a target is never pulled from', `see what would go to it with \`frontera local sync push --preview --target ${name}\``],
1244
+ pull: (name) => ['`pull` reads the project’s link: a target is never pulled from', `see what would go to it with \`frontera local sync push --preview --target ${name}\``],
1245
+ status: () => ['`status` lists every target already', '`frontera local sync status`'],
1246
+ 'map-model': () => ['a model mapping is the project’s, for its link and every target', '`frontera local sync map-model <local model> <workspace model>`'],
1247
+ }
1248
+
995
1249
  export const localSync: Command = {
996
1250
  meta: {
997
1251
  noun: 'local',
@@ -1002,6 +1256,8 @@ export const localSync: Command = {
1002
1256
  ],
1003
1257
  flags: {
1004
1258
  platform: 'string',
1259
+ target: 'string',
1260
+ production: 'boolean',
1005
1261
  'workspace-key': 'string',
1006
1262
  'org-key': 'string',
1007
1263
  blueprint: 'string',
@@ -1041,9 +1297,10 @@ export const localSync: Command = {
1041
1297
  remove: 'boolean',
1042
1298
  },
1043
1299
  aliases: { t: 'title', d: 'description' },
1044
- summary: 'Link the project to a platform workspace, pull and merge what you choose, and propose changes back',
1300
+ summary: 'Link the project to a platform workspace, pull and merge what you choose, and propose changes back, to the link or to a named target',
1045
1301
  examples: [
1046
1302
  'frontera local sync connect --platform $URL --workspace-key -',
1303
+ 'frontera local sync connect --target prod --production --platform $URL',
1047
1304
  'frontera local sync fetch',
1048
1305
  'frontera local sync status',
1049
1306
  'frontera local sync status --fetch',
@@ -1068,10 +1325,14 @@ export const localSync: Command = {
1068
1325
  'frontera local sync request evidence --evals --renders',
1069
1326
  'frontera local sync request apply',
1070
1327
  'frontera local sync request apply --force --reason "hotfix, checked by hand"',
1328
+ 'frontera local sync push --preview --target prod',
1329
+ 'frontera local sync push -t "Customer segments" --target prod',
1330
+ 'frontera local sync request apply --target prod',
1071
1331
  'frontera local sync request close',
1072
1332
  'frontera local sync map-model llama-4-70b claude-haiku-5-5',
1073
1333
  'frontera local sync map-model llama-4-70b --remove',
1074
1334
  'frontera local sync disconnect --forget-link',
1335
+ 'frontera local sync disconnect --target prod --forget-link',
1075
1336
  ],
1076
1337
  offline: true,
1077
1338
  },
@@ -1080,6 +1341,9 @@ export const localSync: Command = {
1080
1341
  const run = action ? SUBCOMMANDS[action] : undefined
1081
1342
  const named = action ? `\`${action}\`` : '(none given)'
1082
1343
  if (!run) throw new UsageError(`unknown sync action ${named}`, 'use connect, fetch, status, pull, diff, resolve, push, request, map-model or disconnect')
1344
+ const target = flagString(ctx, 'target')
1345
+ const refused = target !== undefined ? NO_TARGET[action!]?.(TARGET_NAME.test(target) ? target : '<name>') : undefined
1346
+ if (refused) throw new UsageError(refused[0], refused[1])
1083
1347
  return run(ctx)
1084
1348
  },
1085
1349
  }
@@ -40,6 +40,7 @@ import { knowledgeCommands } from './knowledge/index-commands'
40
40
  import { mediaCommands } from './media/index-commands'
41
41
  import { packCommands } from './pack/index-commands'
42
42
  import { secretCommands } from './secret/index-commands'
43
+ import { voiceCommands } from './voice/index-commands'
43
44
  import { chatAppCommands } from './chat-app/index-commands'
44
45
  import { mcpServerCommands } from './mcp-server/index-commands'
45
46
  import { markingCommands } from './marking/index-commands'
@@ -133,6 +134,7 @@ export const COMMANDS: readonly Command[] = [
133
134
  ...mediaCommands,
134
135
  ...packCommands,
135
136
  ...secretCommands,
137
+ ...voiceCommands,
136
138
  ...chatAppCommands,
137
139
  ...mcpServerCommands,
138
140
  ...automationCommands,
@@ -211,6 +213,7 @@ const NOUN_SUMMARY: Readonly<Record<string, string>> = {
211
213
  knowledge: 'Knowledge bases and the sources inside them',
212
214
  pack: 'Reusable skill bundles — author once, install per workspace',
213
215
  secret: 'Workspace secrets — named here, never printed back',
216
+ voice: 'Voice — Agent phone numbers and outbound calls',
214
217
  'chat-app': 'Chat Apps — stage, publish and open an Agent roster',
215
218
  'mcp-server': 'Workspace MCP servers — save, list, inspect and archive',
216
219
  function: 'Functions — TypeScript deployed here, run on a schedule',
@@ -258,8 +261,8 @@ const KEYS_ARE_SETTINGS_ONLY =
258
261
  */
259
262
  export const UNSUPPORTED: Readonly<Record<string, string>> = {
260
263
  channel:
261
- 'channels have no CLI surface — create one and bind it to an agent in the Console, '
262
- + 'under the agent’s Channels tab',
264
+ 'channels have no CLI surface — create one and bind it to an agent in Studio on the platform, '
265
+ + 'on the agent’s Channels page',
263
266
  key: KEYS_ARE_SETTINGS_ONLY,
264
267
  // The names a caller reaches for when the goal is provisioning or rotating an
265
268
  // organization key. The service routes are session-only by construction, so