@frontera-sdk/cli 1.53.9 → 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.
@@ -0,0 +1,363 @@
1
+ import { PlatformApi } from '../../api/platform-api'
2
+ import { CliError, UsageError } from '../../errors'
3
+ import { table } from '../../table'
4
+ import { resolveAgentRef } from '../agent/resolve'
5
+ import { flagString, type Command, type CommandContext } from '../types'
6
+
7
+ /**
8
+ * An Agent's phone numbers (the WhatsApp numbers people call to talk to it)
9
+ * and calls the Agent makes. The organization and workspace are the signed-in
10
+ * person's, never typed.
11
+ *
12
+ * Every verb acts as a person. The Voice application checks the person's own
13
+ * access to the Agent (and the Voice manager role for changes), so no API key
14
+ * reaches these routes; the dispatcher refuses a key before any request and
15
+ * names where a person does this in the platform.
16
+ *
17
+ * Voice sets a number up when it is added: the number's WhatsApp calls are
18
+ * pointed at the deployment's phone system through the workspace's WhatsApp
19
+ * channel. No credential passes through the CLI.
20
+ */
21
+
22
+ interface NumberRow {
23
+ id: string
24
+ number: string
25
+ source: string
26
+ channelId: string | null
27
+ enabled: boolean
28
+ callHours: { timeZone: string; windows: { day: string; open: string; close: string }[] } | null
29
+ status: string
30
+ statusReason: string | null
31
+ }
32
+
33
+ const WHERE = 'the Agent → Voice → Phone numbers'
34
+ const WEEK = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'] as const
35
+ const DAY_ALIASES: Record<string, (typeof WEEK)[number]> = { mon: 'MONDAY', tue: 'TUESDAY', wed: 'WEDNESDAY', thu: 'THURSDAY', fri: 'FRIDAY', sat: 'SATURDAY', sun: 'SUNDAY' }
36
+
37
+ function api(ctx: CommandContext): PlatformApi {
38
+ return new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
39
+ }
40
+
41
+ async function agentOf(ctx: CommandContext, client: PlatformApi, usage: string): Promise<string> {
42
+ const ref = ctx.positional[0]
43
+ if (!ref) throw new UsageError('missing <agent>', `${usage} — see \`frontera agent list\``)
44
+ return resolveAgentRef(client, ref)
45
+ }
46
+
47
+ /** `628123456789` as `+628123456789`. */
48
+ const shown = (digits: string) => `+${digits}`
49
+
50
+ function hoursText(hours: NumberRow['callHours']): string {
51
+ if (!hours) return 'any time'
52
+ const first = hours.windows[0]
53
+ if (!first) return `closed (${hours.timeZone})`
54
+ const t = (hhmm: string) => `${hhmm.slice(0, 2)}:${hhmm.slice(2)}`
55
+ const days = new Set(hours.windows.map((w) => w.day)).size
56
+ return `${t(first.open)}-${t(first.close)} ${hours.timeZone}${days === 7 ? '' : `, ${days} days`}`
57
+ }
58
+
59
+ /**
60
+ * `--hours 08:00-17:00` (every day) or `--hours mon-fri@08:00-17:00`, with
61
+ * `--time-zone`. Absent means any time.
62
+ */
63
+ export function parseHours(raw: string | undefined, timeZone: string | undefined): NumberRow['callHours'] {
64
+ if (!raw) return null
65
+ const m = /^(?:([a-z]{3})(?:-([a-z]{3}))?@)?([0-2][0-9]):?([0-5][0-9])-([0-2][0-9]):?([0-5][0-9])$/i.exec(raw.trim())
66
+ if (!m) throw new UsageError(`"${raw}" is not call hours`, 'write them as 08:00-17:00 (every day) or mon-fri@08:00-17:00')
67
+ const [, fromDay, toDay, oh, om, ch, cm] = m
68
+ const open = `${oh}${om}`
69
+ const close = `${ch}${cm}`
70
+ if (open > '2359' || (close > '2359' && close !== '2400')) throw new UsageError(`"${raw}" is not a time of day`, 'times run from 00:00 to 23:59; 24:00 closes at the end of the day')
71
+ if (open === close) throw new UsageError(`"${raw}" opens and closes at the same time`, 'give a window, e.g. 08:00-17:00; 22:00-06:00 runs past midnight')
72
+ let days: readonly (typeof WEEK)[number][] = WEEK
73
+ if (fromDay) {
74
+ const a = DAY_ALIASES[fromDay.toLowerCase()]
75
+ const b = DAY_ALIASES[(toDay ?? fromDay).toLowerCase()]
76
+ if (!a || !b) throw new UsageError(`"${raw}" names an unknown day`, 'days are mon, tue, wed, thu, fri, sat, sun')
77
+ days = WEEK.slice(WEEK.indexOf(a), WEEK.indexOf(b) + 1)
78
+ if (days.length === 0) throw new UsageError(`"${raw}" runs backwards through the week`, 'write the earlier day first, e.g. mon-fri')
79
+ }
80
+ return { timeZone: timeZone ?? 'Asia/Jakarta', windows: days.map((day) => ({ day, open, close })) }
81
+ }
82
+
83
+ async function numberOf(client: PlatformApi, agentId: string, ref: string | undefined, usage: string): Promise<NumberRow> {
84
+ if (!ref) throw new UsageError('missing <number>', usage)
85
+ const { numbers } = await client.voiceNumbers(agentId)
86
+ const digits = ref.replace(/[^0-9]/g, '')
87
+ const found = (numbers as NumberRow[]).find((n) => n.id === ref || (digits.length >= 7 && n.number === digits))
88
+ if (!found) throw new CliError(`this Agent has no number "${ref}"`, { code: 'NOT_FOUND', hint: 'run `frontera voice numbers <agent>` to see them' })
89
+ return found
90
+ }
91
+
92
+ const lineOf = (n: NumberRow) => [shown(n.number), n.status + (n.enabled ? '' : ' (off)'), n.source, hoursText(n.callHours), n.statusReason ?? '']
93
+
94
+ const SESSION = { authLane: 'session' as const, sessionAlternative: WHERE }
95
+
96
+ const list: Command = {
97
+ meta: {
98
+ noun: 'voice',
99
+ verb: 'numbers',
100
+ ...SESSION,
101
+ args: [{ name: 'agent', required: true, description: 'Agent id or slug' }],
102
+ flags: {},
103
+ summary: 'List the phone numbers an Agent answers',
104
+ examples: ['frontera voice numbers front-desk', 'frontera voice numbers front-desk --json'],
105
+ },
106
+ async run(ctx) {
107
+ const client = api(ctx)
108
+ const view = await client.voiceNumbers(await agentOf(ctx, client, 'frontera voice numbers <agent>'))
109
+ const rows = view.numbers as NumberRow[]
110
+ return {
111
+ data: view,
112
+ text: !view.available
113
+ ? 'Phone numbers are not set up on this deployment.'
114
+ : rows.length === 0
115
+ ? 'This Agent has no phone numbers.'
116
+ : table(['number', 'status', 'source', 'hours', 'note'], rows.map(lineOf), [undefined, undefined, undefined, 36, 50]),
117
+ }
118
+ },
119
+ }
120
+
121
+ const add: Command = {
122
+ meta: {
123
+ noun: 'voice',
124
+ verb: 'number-add',
125
+ ...SESSION,
126
+ args: [
127
+ { name: 'agent', required: true, description: 'Agent id or slug' },
128
+ { name: 'number', required: false, description: 'the phone number, international form (+62…); a WhatsApp number is read from its channel' },
129
+ ],
130
+ flags: { channel: 'string', hours: 'string', 'time-zone': 'string' },
131
+ summary: 'Add a WhatsApp number to an Agent and set it up',
132
+ examples: [
133
+ 'frontera voice number-add front-desk +6281234567890 --channel <id>',
134
+ 'frontera voice number-add desk <number> --channel <id> --hours 08:00-17:00',
135
+ 'frontera voice number-add desk <number> --channel <id> --hours sat@0900-1200',
136
+ ],
137
+ },
138
+ async run(ctx) {
139
+ const usage = 'frontera voice number-add <agent> [<number>] --channel <whatsapp-channel-id>'
140
+ const client = api(ctx)
141
+ const agentId = await agentOf(ctx, client, usage)
142
+ const number = ctx.positional[1]
143
+ const channelId = flagString(ctx, 'channel')
144
+ if (!channelId) throw new UsageError('missing --channel', 'name the workspace’s WhatsApp channel the number belongs to')
145
+ // A WhatsApp number is the one its channel serves; a typed one is only checked against it.
146
+ const callHours = parseHours(flagString(ctx, 'hours'), flagString(ctx, 'time-zone'))
147
+ const { number: added } = await client.addVoiceNumber(agentId, { ...(number ? { number } : {}), source: 'whatsapp', channelId, callHours })
148
+ const n = added as NumberRow
149
+ return {
150
+ data: n,
151
+ text:
152
+ n.status === 'active'
153
+ ? `added ${shown(n.number)}: calls to it reach this Agent (${hoursText(n.callHours)})`
154
+ : `added ${shown(n.number)}, but it is not set up yet: ${n.statusReason ?? n.status}\n try again: frontera voice number-apply ${ctx.positional[0]} ${shown(n.number)}`,
155
+ }
156
+ },
157
+ }
158
+
159
+ const apply: Command = {
160
+ meta: {
161
+ noun: 'voice',
162
+ verb: 'number-apply',
163
+ ...SESSION,
164
+ args: [
165
+ { name: 'agent', required: true, description: 'Agent id or slug' },
166
+ { name: 'number', required: true, description: 'the number, or its id' },
167
+ ],
168
+ flags: {},
169
+ summary: 'Set a phone number up again after it needed attention',
170
+ examples: ['frontera voice number-apply front-desk +6281234567890'],
171
+ },
172
+ async run(ctx) {
173
+ const usage = 'frontera voice number-apply <agent> <number>'
174
+ const client = api(ctx)
175
+ const line = await numberOf(client, await agentOf(ctx, client, usage), ctx.positional[1], usage)
176
+ const n = (await client.applyVoiceNumber(line.id)).number as NumberRow
177
+ return { data: n, text: n.status === 'active' ? `${shown(n.number)} is set up` : `${shown(n.number)} still needs attention: ${n.statusReason ?? n.status}` }
178
+ },
179
+ }
180
+
181
+ function toggle(verb: 'number-enable' | 'number-disable', enabled: boolean): Command {
182
+ return {
183
+ meta: {
184
+ noun: 'voice',
185
+ verb,
186
+ ...SESSION,
187
+ args: [
188
+ { name: 'agent', required: true, description: 'Agent id or slug' },
189
+ { name: 'number', required: true, description: 'the number, or its id' },
190
+ ],
191
+ flags: {},
192
+ summary: enabled ? 'Answer calls to a phone number again' : 'Refuse new calls to a phone number, keeping it',
193
+ examples: [`frontera voice ${verb} front-desk +6281234567890`],
194
+ },
195
+ async run(ctx) {
196
+ const usage = `frontera voice ${verb} <agent> <number>`
197
+ const client = api(ctx)
198
+ const line = await numberOf(client, await agentOf(ctx, client, usage), ctx.positional[1], usage)
199
+ const n = (await client.setVoiceNumberEnabled(line.id, enabled)).number as NumberRow
200
+ return { data: n, text: enabled ? `${shown(n.number)} answers calls again (${n.status})` : `${shown(n.number)} refuses new calls` }
201
+ },
202
+ }
203
+ }
204
+
205
+ const hoursCmd: Command = {
206
+ meta: {
207
+ noun: 'voice',
208
+ verb: 'number-hours',
209
+ ...SESSION,
210
+ args: [
211
+ { name: 'agent', required: true, description: 'Agent id or slug' },
212
+ { name: 'number', required: true, description: 'the number, or its id' },
213
+ ],
214
+ flags: { hours: 'string', 'time-zone': 'string' },
215
+ summary: 'Change when a phone number answers, and apply it again',
216
+ examples: ['frontera voice number-hours desk <number> --hours 08:00-17:00', 'frontera voice number-hours desk <number> --hours any'],
217
+ },
218
+ async run(ctx) {
219
+ const usage = 'frontera voice number-hours <agent> <number> --hours <08:00-17:00 | mon-fri@08:00-17:00 | any>'
220
+ const raw = flagString(ctx, 'hours')
221
+ if (!raw) throw new UsageError('missing --hours', usage)
222
+ const client = api(ctx)
223
+ const line = await numberOf(client, await agentOf(ctx, client, usage), ctx.positional[1], usage)
224
+ const callHours = raw === 'any' ? null : parseHours(raw, flagString(ctx, 'time-zone'))
225
+ const n = (await client.setVoiceNumberHours(line.id, callHours)).number as NumberRow
226
+ return { data: n, text: n.status === 'active' ? `${shown(n.number)} now answers ${hoursText(n.callHours)}` : `${shown(n.number)} saved ${hoursText(n.callHours)} but still needs attention: ${n.statusReason ?? n.status}` }
227
+ },
228
+ }
229
+
230
+ const remove: Command = {
231
+ meta: {
232
+ noun: 'voice',
233
+ verb: 'number-remove',
234
+ ...SESSION,
235
+ args: [
236
+ { name: 'agent', required: true, description: 'Agent id or slug' },
237
+ { name: 'number', required: true, description: 'the number, or its id' },
238
+ ],
239
+ flags: {},
240
+ summary: 'Remove a phone number: its calls stop reaching the Agent, its digits are free again',
241
+ examples: ['frontera voice number-remove front-desk +6281234567890'],
242
+ },
243
+ async run(ctx) {
244
+ const usage = 'frontera voice number-remove <agent> <number>'
245
+ const client = api(ctx)
246
+ const line = await numberOf(client, await agentOf(ctx, client, usage), ctx.positional[1], usage)
247
+ await client.removeVoiceNumber(line.id)
248
+ return { data: { id: line.id, number: line.number, removed: true }, text: `removed ${shown(line.number)}; its past calls keep their records` }
249
+ },
250
+ }
251
+
252
+ /** What a refusal means, in words. */
253
+ const REFUSALS: Record<string, string> = {
254
+ not_enabled: 'Voice is not turned on for this organization',
255
+ not_available: 'Voice is not available in this workspace',
256
+ not_licensed: 'the licence does not include Voice',
257
+ read_only: 'Voice is read-only right now',
258
+ suspended: 'Voice is suspended for this organization',
259
+ agent_unpublished: 'the Agent is not published',
260
+ voice_off: 'voice calls are off for this Agent',
261
+ no_voice_chosen: 'the Agent has no speaking voice',
262
+ transcription_model_missing: 'the Agent has no transcription model',
263
+ usage_limit: 'this month’s call minutes are used up',
264
+ speech_provider_missing: 'the organization has no speech provider key',
265
+ concurrency_limit: 'too many calls are running; try again in a minute',
266
+ number_disabled: 'the number is turned off',
267
+ number_not_active: 'the number is not set up yet; run `frontera voice number-apply`',
268
+ no_call_permission: 'this person has not given the number permission to call on WhatsApp; run `frontera voice call-permission-request`',
269
+ }
270
+
271
+ const digitsOf = (raw: string | undefined, usage: string) => {
272
+ const digits = (raw ?? '').replace(/[^0-9]/g, '')
273
+ if (!raw) throw new UsageError('missing --to', usage)
274
+ if (digits.length < 7) throw new UsageError(`"${raw}" is not a phone number`, 'give it in international form, e.g. +628…')
275
+ return digits
276
+ }
277
+
278
+ const call: Command = {
279
+ meta: {
280
+ noun: 'voice',
281
+ verb: 'call',
282
+ ...SESSION,
283
+ args: [{ name: 'agent', required: true, description: 'Agent id or slug' }],
284
+ flags: { number: 'string', to: 'string' },
285
+ summary: 'Have an Agent phone a number, from one of its own numbers (signed-in person only; API keys are refused)',
286
+ examples: ['frontera voice call front-desk --number <id> --to +628…'],
287
+ },
288
+ async run(ctx) {
289
+ const usage = 'frontera voice call <agent> --number <number-id> --to <number>'
290
+ const client = api(ctx)
291
+ const agentId = await agentOf(ctx, client, usage)
292
+ const numberId = flagString(ctx, 'number')
293
+ if (!numberId) throw new UsageError('missing --number', 'the id of the Agent’s number to call from — see `frontera voice numbers`')
294
+ const to = digitsOf(flagString(ctx, 'to'), usage)
295
+ try {
296
+ const started = await client.startVoiceCall(agentId, { numberId, to })
297
+ return { data: started, text: `calling ${shown(to)}; the call is session ${started.callId} in the Agent’s Sessions` }
298
+ } catch (caught) {
299
+ // The SDK keeps the response body as `details`: `{ code: 'VOICE_CALL_REFUSED', reason, callId }`.
300
+ const reason = (caught as { details?: { reason?: string } }).details?.reason
301
+ if (reason && REFUSALS[reason]) throw new CliError(`the call was refused: ${REFUSALS[reason]}`, { code: 'REFUSED', hint: reason })
302
+ throw caught
303
+ }
304
+ },
305
+ }
306
+
307
+ const permissionText = (p: { status: string; expiresAt?: string; canCall: boolean; canRequest: boolean }) =>
308
+ p.canCall
309
+ ? `can call (${p.status}${p.expiresAt ? `, until ${p.expiresAt}` : ''})`
310
+ : `no permission${p.canRequest ? ': ask with `frontera voice call-permission-request` (WhatsApp allows 1 request a day, 2 a week)' : ' and a request cannot be sent right now'}`
311
+
312
+ const callPermission: Command = {
313
+ meta: {
314
+ noun: 'voice',
315
+ verb: 'call-permission',
316
+ ...SESSION,
317
+ args: [{ name: 'numberId', required: true, description: 'the id of a WhatsApp number (see `frontera voice numbers`)' }],
318
+ flags: { to: 'string' },
319
+ summary: 'Check whether a WhatsApp number may call a person',
320
+ examples: ['frontera voice call-permission <number-id> --to +628…'],
321
+ },
322
+ async run(ctx) {
323
+ const usage = 'frontera voice call-permission <number-id> --to <number>'
324
+ const numberId = ctx.positional[0]
325
+ if (!numberId) throw new UsageError('missing <numberId>', usage)
326
+ const { permission } = await api(ctx).voiceCallPermission(numberId, digitsOf(flagString(ctx, 'to'), usage))
327
+ return { data: permission, text: permissionText(permission) }
328
+ },
329
+ }
330
+
331
+ const callPermissionRequest: Command = {
332
+ meta: {
333
+ noun: 'voice',
334
+ verb: 'call-permission-request',
335
+ ...SESSION,
336
+ args: [{ name: 'numberId', required: true, description: 'the id of a WhatsApp number (see `frontera voice numbers`)' }],
337
+ flags: { to: 'string', message: 'string' },
338
+ summary: 'Ask a person for permission to call them on WhatsApp',
339
+ examples: ['frontera voice call-permission-request <number-id> --to +628…', 'frontera voice call-permission-request <id> --to <n> --message Hi'],
340
+ },
341
+ async run(ctx) {
342
+ const usage = 'frontera voice call-permission-request <number-id> --to <number> [--message <text>]'
343
+ const numberId = ctx.positional[0]
344
+ if (!numberId) throw new UsageError('missing <numberId>', usage)
345
+ const message = flagString(ctx, 'message')
346
+ const to = digitsOf(flagString(ctx, 'to'), usage)
347
+ await api(ctx).requestVoiceCallPermission(numberId, { to, ...(message ? { message } : {}) })
348
+ return { data: { requested: true }, text: `asked ${shown(to)} for permission; WhatsApp allows 1 request a day and 2 a week. Check with \`frontera voice call-permission\`` }
349
+ },
350
+ }
351
+
352
+ export const voiceCommands: Command[] = [
353
+ list,
354
+ add,
355
+ apply,
356
+ toggle('number-enable', true),
357
+ toggle('number-disable', false),
358
+ hoursCmd,
359
+ remove,
360
+ call,
361
+ callPermission,
362
+ callPermissionRequest,
363
+ ]
package/src/flag-help.ts CHANGED
@@ -22,6 +22,13 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
22
22
  'no-snapshot': 'don’t save the records first (by default a load saves them, and a refused record puts them back)',
23
23
  batch: 'how many records are submitted at once (1 to 16, default 4)',
24
24
  agent: 'agent slug this binding is for — one agent, no wildcard',
25
+ // Voice phone numbers.
26
+ channel: 'the workspace’s WhatsApp channel the number belongs to, by id',
27
+ hours: 'when the number answers: 08:00-17:00 (every day), mon-fri@08:00-17:00, or 22:00-06:00 past midnight; absent or `any` means any time',
28
+ 'time-zone': 'the time zone of --hours, e.g. Asia/Jakarta (the default)',
29
+ number: 'the id of the Agent’s phone number to call from (see `frontera voice numbers`)',
30
+ to: 'the number being called, in international form',
31
+ message: 'text sent with the permission request',
25
32
  app: 'a Custom App by its slug: the App may start the Function as the person using it',
26
33
  apps: 'list the Custom Apps that may start it, instead of the agents',
27
34
  off: 'keep the binding, switched off',
@@ -267,6 +274,8 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
267
274
  'org-key': '`-` to read an organization key (sk-org-…) from stdin, after the workspace key; widens the Blueprint to the whole model',
268
275
  blueprint: 'workspace (the slice granted to the workspace, default) or organization (the whole model; needs an organization key)',
269
276
  relink: 'point a project already linked to another workspace at this one',
277
+ target: 'local sync: a named target of the project (sync.targets in frontera.yaml), matched exactly, to connect, propose to, apply on, diff or resolve against, or disconnect; without it, the project’s link',
278
+ production: 'local sync connect --target: mark the target as production in status and messages (every target is reached only by a command that names it)',
270
279
  preview: 'local sync: list what a pull would bring, or a push would propose, file by file, and change nothing; app deploy: make a preview deployment (the default), said explicitly as a pipeline must',
271
280
  kinds: 'comma-separated: blueprint, skills, functions, plugins, knowledge, workflows, agents, evaluations',
272
281
  drafts: 'comma-separated items (agent/<slug>, workflow/<slug>, function/<slug>, blueprint) to read as their draft instead of the live version; the Blueprint’s draft takes an organization key',
@@ -340,6 +349,15 @@ const PLACEHOLDER: Readonly<Record<string, string>> = {
340
349
  header: 'name: value',
341
350
  framework: 'next|react',
342
351
  port: 'number',
352
+ line: 'id',
353
+ host: 'host',
354
+ transport: 'udp|tcp',
355
+ address: 'ip[/bits]',
356
+ username: 'name',
357
+ realm: 'realm',
358
+ number: 'id',
359
+ to: 'number',
360
+ message: 'text',
343
361
  origin: 'url',
344
362
  'token-fd': 'fd',
345
363
  'expect-revision': 'hash',
@@ -381,6 +399,7 @@ const PLACEHOLDER: Readonly<Record<string, string>> = {
381
399
  'workspace-key': '-',
382
400
  'org-key': '-',
383
401
  blueprint: 'workspace|organization',
402
+ target: 'name',
384
403
  kinds: 'a,b',
385
404
  drafts: 'kind/name,…',
386
405
  snapshot: 'id',
@@ -89,6 +89,8 @@ export const NOUN_ALIASES: Record<string, ForgeArtifactKind> = { function: 'auto
89
89
  export const AGENT_PROXY_NOUNS: Record<string, { verbs: readonly string[]; agentAt: number }> = {
90
90
  capability: { verbs: ['grant', 'revoke', 'bind', 'unbind'], agentAt: 0 },
91
91
  pack: { verbs: ['apply', 'detach'], agentAt: 1 },
92
+ // Phone numbers belong to the Agent they name; `call*` act on a number id (no Agent to name).
93
+ voice: { verbs: ['number-add', 'number-apply', 'number-enable', 'number-disable', 'number-hours', 'number-remove'], agentAt: 0 },
92
94
  }
93
95
 
94
96
  /**
@@ -85,7 +85,7 @@ const OBJECT_TYPE: JsonSchema = {
85
85
  properties: {
86
86
  type: 'array',
87
87
  minItems: 1,
88
- description: 'Every field. Each names the column it reads in backing.mapping.',
88
+ description: 'Every field. Each names the column it reads in backing.mapping, except an edit-only one.',
89
89
  items: {
90
90
  type: 'object',
91
91
  properties: {
@@ -100,6 +100,10 @@ const OBJECT_TYPE: JsonSchema = {
100
100
  sharedField: { anyOf: [ref('The shared field it implements', 'shared-field'), { type: 'null' }], description: 'The shared field it implements, by apiName; null detaches it.' },
101
101
  semanticType: { anyOf: [ref('The semantic type it carries', 'semantic-type'), { type: 'null' }], description: 'The semantic type its values follow (allowed values, bounds), by apiName; null detaches it.' },
102
102
  calculation: { type: 'object', description: 'A calculated field. A location names its latitude and longitude fields: { kind: location, latitude, longitude }.' },
103
+ editOnly: {
104
+ type: 'boolean',
105
+ description: 'true: the field reads no column. Its values exist only where an Action writes them (data an App owns), and a record no Action has written reads it as empty. Leave it out of backing.mapping and list it in backing.editable. It is a string, number, boolean, date or timestamp, never required, unique, the primaryKey or the title, and the type reads a dataset. Set when the field is added: it cannot be switched on or off or given another value type later, because the values Actions wrote would not follow. renamedFrom renames it and keeps them. A field added under a removed one\'s name starts empty.',
106
+ },
103
107
  renamedFrom: {
104
108
  type: 'string',
105
109
  pattern: '^[a-z][A-Za-z0-9]{0,99}$',
@@ -126,7 +130,7 @@ const OBJECT_TYPE: JsonSchema = {
126
130
  dataset: text('The dataset’s apiName (datasets/<apiName>.json). Naming another dataset moves the type to it on `frontera blueprint apply` and in a change request’s Apply; each property keeps its column. A change request creates a new type bound to it, in the same release.'),
127
131
  mapping: {
128
132
  type: 'object',
129
- description: 'property apiName → its column, or { column, field } for a field inside a column.',
133
+ description: 'property apiName → its column, or { column, field } for a field inside a column. An edit-only property has no entry.',
130
134
  additionalProperties: {
131
135
  anyOf: [
132
136
  { type: 'string', minLength: 1 },
@@ -4,7 +4,7 @@ import { dirname, join } from 'node:path'
4
4
  import { writeFileAtomic } from '../fs-atomic'
5
5
  import { getProfile } from '../profiles'
6
6
  import { localProject } from './local-project'
7
- import { readLink, targetOf, targetProfileName } from './local-sync-link'
7
+ import { projectTargets, targetOf, targetProfileName } from './local-sync-link'
8
8
 
9
9
  /**
10
10
  * Person-steps (Forge improvement plan T4, N4): a step only the person may
@@ -133,8 +133,8 @@ export function readPersonSteps(root: string): PersonStep[] {
133
133
  } catch {
134
134
  return []
135
135
  }
136
- const current = steps.some((s) => s.profile) ? currentTarget(root) : null
137
- return current ? steps.map((s) => aimedAt(s, current)) : steps
136
+ const targets = steps.some((s) => s.profile) ? stepTargets(root) : []
137
+ return targets.length ? steps.map((s) => aimedAt(s, targets)) : steps
138
138
  }
139
139
 
140
140
  /**
@@ -143,9 +143,10 @@ export function readPersonSteps(root: string): PersonStep[] {
143
143
  * folder's path, so it changes when the folder is moved or renamed, while the
144
144
  * steps file moves with the folder.
145
145
  */
146
- function aimedAt(step: PersonStep, current: StepTarget): PersonStep {
146
+ function aimedAt(step: PersonStep, targets: StepTarget[]): PersonStep {
147
147
  const was = step.profile
148
- if (!was || step.target !== current.target || was === current.profile) return step
148
+ const current = targets.find((t) => t.target === step.target)
149
+ if (!was || !current || was === current.profile) return step
149
150
  const swap = (command: string) => command.split(' ')
150
151
  .map((word, i, words) => (word === was && words[i - 1] === '--profile' ? current.profile : word === `--profile=${was}` ? `--profile=${current.profile}` : word))
151
152
  .join(' ')
@@ -303,16 +304,15 @@ export function checkProblem(check: StepCheck, readOnly: readonly string[]): str
303
304
  return null
304
305
  }
305
306
 
306
- /** Where a folder's link points (`targetOf`), and the profile that reaches it (`targetProfileName`). */
307
+ /** Where a folder's link or one of its named targets points (`targetOf`), and the profile that reaches it (`targetProfileName`). */
307
308
  export interface StepTarget {
308
309
  target: string
309
310
  profile: string
310
311
  }
311
312
 
312
- /** The folder's current target, or null when it links to no workspace. */
313
- export function currentTarget(root: string): StepTarget | null {
314
- const link = readLink(root)
315
- return link ? { target: targetOf(link), profile: targetProfileName(localProject(root), link) } : null
313
+ /** The folder's link, then its named targets; none when it delivers nowhere. */
314
+ export function stepTargets(root: string): StepTarget[] {
315
+ return projectTargets(localProject(root)).map((t) => ({ target: targetOf(t.link), profile: targetProfileName(t.owner, t.link) }))
316
316
  }
317
317
 
318
318
  /**
@@ -324,12 +324,13 @@ export function currentTarget(root: string): StepTarget | null {
324
324
  * is refused; adding a profile by hand would aim the step at a profile
325
325
  * nothing keeps in step with the link.
326
326
  */
327
- export function staleProblem(step: PersonStep, current: StepTarget | null): string | null {
328
- if (step.target && step.target !== current?.target) {
329
- return `it was recorded for ${step.target}, and this folder now links to ${current?.target ?? 'no workspace'}: link this folder back to it with \`frontera local sync connect --relink\`, or dismiss this step`
327
+ export function staleProblem(step: PersonStep, targets: StepTarget[]): string | null {
328
+ const at = targets.find((t) => t.target === step.target)
329
+ if (step.target && !at) {
330
+ return `it was recorded for ${step.target}, and this folder now links to ${targets.map((t) => t.target).join(', ') || 'no workspace'}: link this folder back to it with \`frontera local sync connect --relink\`, or dismiss this step`
330
331
  }
331
332
  if (!step.profile) return null
332
- if (step.profile !== current?.profile) {
333
+ if (!(at ? [at] : targets).some((t) => t.profile === step.profile)) {
333
334
  return `it names the profile ${step.profile}, which is not this folder's for its link, and it records no target to aim it at this folder's: dismiss this step`
334
335
  }
335
336
  if (!getProfile(step.profile)) {
@@ -415,13 +416,13 @@ export async function runStepChecks(
415
416
  now = new Date(),
416
417
  ): Promise<{ checked: number; done: PersonStep[]; problems: Array<{ id: string; problem: string }> }> {
417
418
  const steps = readPersonSteps(root)
418
- const current = currentTarget(root)
419
+ const targets = stepTargets(root)
419
420
  const done: PersonStep[] = []
420
421
  const problems: Array<{ id: string; problem: string }> = []
421
422
  let checked = 0
422
423
  for (const step of steps) {
423
424
  if (step.status !== 'pending' || !step.check) continue
424
- const problem = staleProblem(step, current) ?? checkProblem(step.check, readOnly)
425
+ const problem = staleProblem(step, targets) ?? checkProblem(step.check, readOnly)
425
426
  if (problem) {
426
427
  problems.push({ id: step.id, problem })
427
428
  continue
@@ -253,14 +253,16 @@ const MEDIA_CATALOG_COLUMNS = new Set(['media_item_id', 'path', 'mime_type', 'si
253
253
  /**
254
254
  * The Media Set whose catalog an object type reads, by its API name: the
255
255
  * catalog's dataset is `media_<set>`, and every column the type reads is one
256
- * the catalog publishes. Null for a type over a set's extracted text or
257
- * passages (other columns), or a dataset named otherwise.
256
+ * the catalog publishes (an edit-only field reads none). Null for a type over
257
+ * a set's extracted text or passages (other columns), or a dataset named
258
+ * otherwise.
258
259
  */
259
260
  export function mediaCatalogSet(doc: Doc): string | null {
260
261
  const m = typeof doc.backing?.dataset === 'string' ? /^media_([A-Za-z][A-Za-z0-9_]*)$/.exec(doc.backing.dataset) : null
261
262
  if (!m) return null
262
263
  const mapping = (doc.backing?.mapping ?? {}) as Record<string, unknown>
263
- const properties = (Array.isArray(doc.properties) ? doc.properties : []) as Array<{ apiName?: string; column?: unknown }>
264
+ const properties = ((Array.isArray(doc.properties) ? doc.properties : []) as Array<{ apiName?: string; column?: unknown; editOnly?: unknown }>)
265
+ .filter((p) => p.editOnly !== true)
264
266
  const columns = properties.map((p) => (typeof p.column === 'string' ? p.column : typeof mapping[p.apiName ?? ''] === 'string' ? (mapping[p.apiName ?? ''] as string) : null))
265
267
  return columns.every((c) => c !== null && MEDIA_CATALOG_COLUMNS.has(c)) ? m[1]! : null
266
268
  }
@@ -7,6 +7,7 @@ import type { LocalProject } from './local-project'
7
7
  import { isAlive, readState } from './local-state'
8
8
  import { PROCEDURE_TOOL_FORMS, PROCEDURE_TOOL_GROUPS } from './local-agent-checks'
9
9
  import { DEFAULT_HANDOVER_REASON_KEYS } from './local-operating'
10
+ import { TARGET_NAME } from './local-sync-link'
10
11
  import { REF_KINDS, refPattern, type RefKind } from './local-sync-refs'
11
12
 
12
13
  /**
@@ -47,6 +48,7 @@ export const SCHEMA_KINDS = [
47
48
  'blueprint-object-set',
48
49
  'blueprint-workspace-grants',
49
50
  'secrets',
51
+ 'sync',
50
52
  ] as const
51
53
  export type SchemaKind = (typeof SCHEMA_KINDS)[number]
52
54
 
@@ -66,6 +68,7 @@ export const SCHEMA_FILES: Record<SchemaKind, string> = {
66
68
  knowledge: 'knowledge/<name>/knowledge.json',
67
69
  skill: 'skills/<name>/SKILL.md (its front matter)',
68
70
  secrets: 'secrets.json',
71
+ sync: 'frontera.yaml (its sync block)',
69
72
  ...BLUEPRINT_SCHEMA_FILES,
70
73
  }
71
74
 
@@ -740,6 +743,43 @@ const SECRETS_SCHEMA: JsonSchema = {
740
743
  additionalProperties: false,
741
744
  }
742
745
 
746
+ /** A workspace the project reaches, as `local-sync-link.ts` reads it. */
747
+ const SYNC_LINK_PROPERTIES = {
748
+ platform: { type: 'string', description: 'The platform’s API origin, e.g. https://api.acme.frontera.example.' },
749
+ workspace: { type: 'string', description: 'The workspace’s id.' },
750
+ workspaceName: { type: 'string', description: 'Its name, shown by `sync status` and Forge; refreshed on connect.' },
751
+ organization: { type: 'string', description: 'The organization’s id, when an organization key was given.' },
752
+ organizationName: { type: 'string' },
753
+ blueprint: { enum: ['workspace', 'organization'], description: '`workspace`: the slice of the Blueprint granted to the workspace (default). `organization`: the whole model.' },
754
+ models: { type: 'object', additionalProperties: { type: 'string' }, description: 'A model only this computer has → the workspace’s model it goes as when proposed (`local sync map-model`).' },
755
+ } as const
756
+
757
+ /** `frontera.yaml`'s `sync` block: the project's link and its named targets (`local-sync-link.ts`). */
758
+ const SYNC_SCHEMA: JsonSchema = {
759
+ $schema: DRAFT,
760
+ title: 'The workspaces a project pulls from and delivers to (frontera.yaml sync)',
761
+ description:
762
+ 'Written by `frontera local sync connect`, which checks the keys and keeps them in this computer’s keychain, never in the file. The block’s own fields are the project’s link: what it pulls from, and where `push` and `request` act by default. `targets` are workspaces it delivers to by name (`local sync push --target <name>`, `request apply --target <name>`), each with its own keys, stored with `frontera local sync connect --target <name>`. A teammate who clones the project runs those connects to store their own keys.',
763
+ type: 'object',
764
+ properties: {
765
+ ...SYNC_LINK_PROPERTIES,
766
+ targets: {
767
+ type: 'object',
768
+ description: 'Named targets, by name: lowercase letters, digits and dashes, starting with a letter (staging, production). Each is another workspace than the link and than every other target. `platform` is an https address (the scheme may be left out); an entry the CLI cannot use is left out, and `sync status` says why.',
769
+ propertyNames: { pattern: TARGET_NAME.source },
770
+ additionalProperties: {
771
+ type: 'object',
772
+ // No `models`: a push to a target maps models with the project's own `sync.models`.
773
+ properties: {
774
+ ...Object.fromEntries(Object.entries(SYNC_LINK_PROPERTIES).filter(([key]) => key !== 'models')),
775
+ production: { type: 'boolean', description: 'Marks a production target in `sync status` and in messages, set with `connect --target <name> --production`. Every target, production or not, is reached only by a command that names it, and the link is never pointed at a target’s workspace.' },
776
+ },
777
+ required: ['platform', 'workspace'],
778
+ },
779
+ },
780
+ },
781
+ }
782
+
743
783
  /** `apps/<slug>.json`, as a pull writes it (`appDocument` in `local-apps.ts`; `appSettingsCandidate` in the service). */
744
784
  const APP_SCHEMA: JsonSchema = {
745
785
  $schema: DRAFT,
@@ -788,6 +828,7 @@ export function cliSchema(kind: SchemaKind): JsonSchema | null {
788
828
  if (kind === 'knowledge') return KNOWLEDGE_SCHEMA
789
829
  if (kind === 'skill') return SKILL_SCHEMA
790
830
  if (kind === 'secrets') return SECRETS_SCHEMA
831
+ if (kind === 'sync') return SYNC_SCHEMA
791
832
  if (kind in BLUEPRINT_SCHEMAS) return BLUEPRINT_SCHEMAS[kind as keyof typeof BLUEPRINT_SCHEMAS]
792
833
  return null
793
834
  }