@frontera-sdk/cli 1.53.10 → 1.53.11
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 +5 -5
- package/src/api/platform-api.ts +43 -0
- package/src/blueprint/compile.ts +35 -2
- package/src/blueprint/dataset-revision.ts +4 -3
- package/src/blueprint/diff.ts +4 -1
- package/src/commands/blueprint/declarative.ts +106 -44
- package/src/commands/local/steps.ts +4 -4
- package/src/commands/local/sync-request.ts +229 -152
- package/src/commands/local/sync.ts +308 -44
- package/src/commands/registry.ts +3 -0
- package/src/commands/voice/index-commands.ts +363 -0
- package/src/flag-help.ts +19 -0
- package/src/forge/touches.ts +2 -0
- package/src/local/local-blueprint-schemas.ts +6 -2
- package/src/local/local-person-steps.ts +17 -16
- package/src/local/local-portability.ts +5 -3
- package/src/local/local-schemas.ts +41 -0
- package/src/local/local-sync-link.ts +255 -55
- package/src/local/local-sync-plan.ts +1 -1
- package/src/local/local-sync-precheck.ts +16 -2
- package/src/local/local-synthetic-data.ts +12 -7
- package/src/mcp/tools/project-tools.ts +9 -2
|
@@ -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 {
|
|
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
|
-
/**
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
437
|
-
|
|
438
|
-
const
|
|
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! :
|
|
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 (
|
|
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(
|
|
454
|
-
setSyncKey(
|
|
455
|
-
if (keys.organization) setSyncKey(
|
|
456
|
-
else if (!keepsOrganizationKey) removeSyncKey(
|
|
457
|
-
|
|
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(
|
|
460
|
-
(
|
|
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:
|
|
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(
|
|
623
|
+
data: { link: next, ...(name ? { target: name, production } : {}), keys: { workspace: true, organization: !!(keys.organization || getSyncKey(owner, 'organization')) }, profile, signIn },
|
|
467
624
|
text: [
|
|
468
|
-
`
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
844
|
-
|
|
845
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/commands/registry.ts
CHANGED
|
@@ -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',
|