@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.
@@ -9,9 +9,11 @@ import { credentialStore } from '../credential-store'
9
9
  import { forgetDeviceSession, readDeviceSession, type DeviceSession } from '../device-login'
10
10
  import { writeFileAtomic } from '../fs-atomic'
11
11
  import { configDir } from '../paths'
12
+ import { CliError } from '../errors'
12
13
  import { deleteProfile, fingerprint, getProfile, putProfile } from '../profiles'
13
14
  import { syncClient, type SyncClient } from './local-sync-client'
14
15
  import { PROJECT_FILES, type LocalProject } from './local-project'
16
+ import { normalizeOrigin } from './local-sync-client'
15
17
 
16
18
  /**
17
19
  * A project's link to one platform workspace, and the keys that read it.
@@ -40,15 +42,30 @@ export interface SyncLink {
40
42
  models?: Record<string, string>
41
43
  }
42
44
 
45
+ /**
46
+ * A workspace the project delivers to by name besides its link
47
+ * (`sync.targets.<name>` in frontera.yaml), on its own platform: staging and
48
+ * production, say. It has its own keys, target profile and change requests.
49
+ */
50
+ export interface NamedTarget extends SyncLink {
51
+ /** Reached only by its name (`--target`); the project's link is never pointed at its workspace. */
52
+ production?: boolean
53
+ }
54
+
55
+ export const TARGET_NAME = /^[a-z][a-z0-9-]{0,31}$/
56
+
43
57
  export function projectFile(root: string): string {
44
58
  return join(root, PROJECT_FILES.find((f) => existsSync(join(root, f))) ?? PROJECT_FILES[0])
45
59
  }
46
60
 
47
- export function readLink(root: string): SyncLink | null {
61
+ function readSync(root: string): Record<string, unknown> | null {
48
62
  const file = projectFile(root)
49
63
  if (!existsSync(file)) return null
50
- const doc = parseDocument(readFileSync(file, 'utf8')).toJS() as { sync?: Partial<SyncLink> } | null
51
- const s = doc?.sync
64
+ const doc = parseDocument(readFileSync(file, 'utf8')).toJS() as { sync?: unknown } | null
65
+ return doc?.sync && typeof doc.sync === 'object' && !Array.isArray(doc.sync) ? (doc.sync as Record<string, unknown>) : null
66
+ }
67
+
68
+ function linkOf(s: Partial<Record<keyof SyncLink, unknown>> | null | undefined): SyncLink | null {
52
69
  if (!s || typeof s.platform !== 'string' || typeof s.workspace !== 'string') return null
53
70
  const models = s.models && typeof s.models === 'object' && !Array.isArray(s.models)
54
71
  ? Object.fromEntries(Object.entries(s.models as Record<string, unknown>).filter((e): e is [string, string] => typeof e[1] === 'string' && !!e[0] && !!e[1]))
@@ -64,6 +81,58 @@ export function readLink(root: string): SyncLink | null {
64
81
  }
65
82
  }
66
83
 
84
+ export function readLink(root: string): SyncLink | null {
85
+ return linkOf(readSync(root))
86
+ }
87
+
88
+ /**
89
+ * The project's named targets (`sync.targets`), and each entry left out with
90
+ * why: a name the CLI refuses, no platform or workspace, or a platform a key
91
+ * is not sent to. A target's platform is read as `connect` writes it (an
92
+ * https origin), so every call and every step reads one address for it.
93
+ */
94
+ function targetEntries(root: string): { targets: Record<string, NamedTarget>; leftOut: Array<{ name: string; problem: string }> } {
95
+ // No prototype: `constructor` is a target only when the file has one.
96
+ const targets: Record<string, NamedTarget> = Object.create(null)
97
+ const leftOut: Array<{ name: string; problem: string }> = []
98
+ for (const [name, value] of Object.entries(rawTargets(root)).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
99
+ const entry = value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null
100
+ const link = linkOf(entry)
101
+ if (!TARGET_NAME.test(name)) leftOut.push({ name, problem: 'a target’s name is lowercase letters, digits and dashes, starting with a letter: rename it in frontera.yaml' })
102
+ else if (!link) leftOut.push({ name, problem: `it needs a platform and a workspace: \`frontera local sync connect --target ${name} --platform <url>\` writes both` })
103
+ else {
104
+ try {
105
+ targets[name] = { ...link, platform: normalizeOrigin(link.platform), ...(entry?.production === true ? { production: true } : {}) }
106
+ } catch (err) {
107
+ const why = err as CliError
108
+ leftOut.push({ name, problem: `\`${link.platform}\` is not a platform a key is sent to: ${why.message}${why.hint ? ` (${why.hint})` : ''}` })
109
+ }
110
+ }
111
+ }
112
+ return { targets, leftOut }
113
+ }
114
+
115
+ /** The project's named targets (`sync.targets`), by name; an entry the CLI can't use is left out (`leftOutTargets`). */
116
+ export function readTargets(root: string): Record<string, NamedTarget> {
117
+ return targetEntries(root).targets
118
+ }
119
+
120
+ /** The `sync.targets` entries left out, each with what to fix. */
121
+ export function leftOutTargets(root: string): Array<{ name: string; problem: string }> {
122
+ return targetEntries(root).leftOut
123
+ }
124
+
125
+ /** The link's fields as frontera.yaml writes them, in order. */
126
+ function linkFields(link: SyncLink): Record<string, unknown> {
127
+ const value: Record<string, unknown> = { platform: link.platform, workspace: link.workspace }
128
+ if (link.workspaceName) value.workspaceName = link.workspaceName
129
+ if (link.organization) value.organization = link.organization
130
+ if (link.organizationName) value.organizationName = link.organizationName
131
+ value.blueprint = link.blueprint
132
+ if (link.models && Object.keys(link.models).length) value.models = link.models
133
+ return value
134
+ }
135
+
67
136
  const mapped = new Map<string, { mtimeMs: number; models: Record<string, string> }>()
68
137
 
69
138
  /** The project's model mapping (`sync.models`), empty when it has none or no link; read once per change of the file. */
@@ -95,20 +164,21 @@ export function setModelMapping(root: string, local: string, workspace: string |
95
164
  return next
96
165
  }
97
166
 
98
- /** Write the link into `frontera.yaml`, keeping the rest of the file and its comments. */
99
- export function writeLink(root: string, link: SyncLink | null): void {
167
+ /**
168
+ * Write the `sync` block into `frontera.yaml`, keeping the rest of the file and
169
+ * its comments. `targets` is written as given: an entry this CLI can't read
170
+ * (a name it refuses, a missing workspace) is kept as it was written.
171
+ */
172
+ function writeSync(root: string, link: SyncLink | null, targets: Record<string, unknown>): void {
100
173
  const file = projectFile(root)
101
174
  const doc = parseDocument(existsSync(file) ? readFileSync(file, 'utf8') : '')
102
175
  if (!doc.contents) doc.contents = doc.createNode({}) as never
103
- if (!link) {
176
+ const named = Object.keys(targets).sort()
177
+ if (!link && !named.length) {
104
178
  doc.delete('sync')
105
179
  } else {
106
- const value: Record<string, unknown> = { platform: link.platform, workspace: link.workspace }
107
- if (link.workspaceName) value.workspaceName = link.workspaceName
108
- if (link.organization) value.organization = link.organization
109
- if (link.organizationName) value.organizationName = link.organizationName
110
- value.blueprint = link.blueprint
111
- if (link.models && Object.keys(link.models).length) value.models = link.models
180
+ const value: Record<string, unknown> = link ? linkFields(link) : {}
181
+ if (named.length) value.targets = Object.fromEntries(named.map((name) => [name, targets[name]]))
112
182
  const existing = doc.get('sync', true) as { commentBefore?: string } | undefined
113
183
  const node = doc.createNode(value) as unknown as { commentBefore?: string }
114
184
  node.commentBefore = existing?.commentBefore ?? ' The platform workspace this project pulls from (`frontera local sync`). Keys are in your keychain, not here.'
@@ -117,10 +187,35 @@ export function writeLink(root: string, link: SyncLink | null): void {
117
187
  writeFileSync(file, doc.toString())
118
188
  }
119
189
 
190
+ /** The `sync.targets` block as written. */
191
+ function rawTargets(root: string): Record<string, unknown> {
192
+ const raw = readSync(root)?.targets
193
+ return raw && typeof raw === 'object' && !Array.isArray(raw) ? { ...(raw as Record<string, unknown>) } : {}
194
+ }
195
+
196
+ /** Write the link into `frontera.yaml`, keeping the named targets, the rest of the file and its comments. */
197
+ export function writeLink(root: string, link: SyncLink | null): void {
198
+ writeSync(root, link, rawTargets(root))
199
+ }
200
+
201
+ const TARGET_FIELDS = new Set(['platform', 'workspace', 'workspaceName', 'organization', 'organizationName', 'blueprint', 'models', 'production'])
202
+
203
+ /** Write one named target into `frontera.yaml` (`sync.targets.<name>`), keeping the keys it doesn't know, or remove it. */
204
+ export function writeTarget(root: string, name: string, target: NamedTarget | null): void {
205
+ const targets = rawTargets(root)
206
+ const was = targets[name]
207
+ const kept = was && typeof was === 'object' && !Array.isArray(was) ? Object.entries(was).filter(([key]) => !TARGET_FIELDS.has(key)) : []
208
+ if (target) targets[name] = { ...linkFields(target), ...(target.production ? { production: true } : {}), ...Object.fromEntries(kept) }
209
+ else delete targets[name]
210
+ writeSync(root, readLink(root), targets)
211
+ }
212
+
120
213
  // ── Keys ─────────────────────────────────────────────────────────────────────
121
214
 
122
215
  const SERVICE = 'frontera-sync-key'
123
216
  export type KeyRole = 'workspace' | 'organization'
217
+ /** Whose keys and target profile: a project's (its link), or one of its named targets (`<project id>@<name>`). */
218
+ export type KeyOwner = Pick<LocalProject, 'id'>
124
219
 
125
220
  function useKeychain(): boolean {
126
221
  return process.platform === 'darwin' && !process.env.FRONTERA_SYNC_KEYS_FILE
@@ -138,35 +233,84 @@ function readKeysFile(): Record<string, string> {
138
233
  }
139
234
  }
140
235
 
141
- const account = (project: LocalProject, role: KeyRole) => `${project.id}:${role}`
236
+ /** `checked-at`: where a named target's keys were checked, kept beside them. */
237
+ const account = (owner: KeyOwner, role: KeyRole | 'checked-at') => `${owner.id}:${role}`
142
238
 
143
- export function setSyncKey(project: LocalProject, role: KeyRole, key: string): void {
239
+ function storeSet(name: string, value: string): void {
144
240
  if (useKeychain()) {
145
- const res = spawnSync('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', account(project, role), '-w', key], { stdio: 'ignore' })
241
+ const res = spawnSync('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', name, '-w', value], { stdio: 'ignore' })
146
242
  if (res.status !== 0) throw new Error('the macOS keychain refused the key')
147
243
  return
148
244
  }
149
- writeFileAtomic(keysFile(), `${JSON.stringify({ ...readKeysFile(), [account(project, role)]: key }, null, 2)}\n`, 0o600)
245
+ writeFileAtomic(keysFile(), `${JSON.stringify({ ...readKeysFile(), [name]: value }, null, 2)}\n`, 0o600)
150
246
  }
151
247
 
152
- export function getSyncKey(project: LocalProject, role: KeyRole): string | null {
248
+ function storeGet(name: string): string | null {
153
249
  if (useKeychain()) {
154
- const res = spawnSync('security', ['find-generic-password', '-s', SERVICE, '-a', account(project, role), '-w'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
250
+ const res = spawnSync('security', ['find-generic-password', '-s', SERVICE, '-a', name, '-w'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
155
251
  return res.status === 0 ? res.stdout.trim() || null : null
156
252
  }
157
- return readKeysFile()[account(project, role)] ?? null
253
+ return readKeysFile()[name] ?? null
158
254
  }
159
255
 
160
- export function removeSyncKey(project: LocalProject, role: KeyRole): void {
256
+ function storeRemove(name: string): void {
161
257
  if (useKeychain()) {
162
- spawnSync('security', ['delete-generic-password', '-s', SERVICE, '-a', account(project, role)], { stdio: 'ignore' })
258
+ spawnSync('security', ['delete-generic-password', '-s', SERVICE, '-a', name], { stdio: 'ignore' })
163
259
  return
164
260
  }
165
261
  const keys = readKeysFile()
166
- delete keys[account(project, role)]
262
+ delete keys[name]
167
263
  writeFileAtomic(keysFile(), `${JSON.stringify(keys, null, 2)}\n`, 0o600)
168
264
  }
169
265
 
266
+ export function setSyncKey(owner: KeyOwner, role: KeyRole, key: string): void {
267
+ storeSet(account(owner, role), key)
268
+ }
269
+
270
+ export function getSyncKey(owner: KeyOwner, role: KeyRole): string | null {
271
+ return storeGet(account(owner, role))
272
+ }
273
+
274
+ export function removeSyncKey(owner: KeyOwner, role: KeyRole): void {
275
+ storeRemove(account(owner, role))
276
+ }
277
+
278
+ /** A platform and a workspace: what a checked-at record holds, and what a target profile's name is made of. */
279
+ type CheckedAt = Pick<SyncLink, 'platform' | 'workspace'>
280
+
281
+ /** Record where a named target's keys were checked (`connect --target`), or (null) forget it. */
282
+ export function setKeysCheckedAt(owner: KeyOwner, at: CheckedAt | null): void {
283
+ if (at) storeSet(account(owner, 'checked-at'), JSON.stringify({ platform: normalizeOrigin(at.platform), workspace: at.workspace }))
284
+ else storeRemove(account(owner, 'checked-at'))
285
+ }
286
+
287
+ export function keysCheckedAt(owner: KeyOwner): CheckedAt | null {
288
+ try {
289
+ const at = JSON.parse(storeGet(account(owner, 'checked-at')) ?? 'null') as Partial<CheckedAt> | null
290
+ return typeof at?.platform === 'string' && typeof at.workspace === 'string' ? { platform: at.platform, workspace: at.workspace } : null
291
+ } catch {
292
+ return null
293
+ }
294
+ }
295
+
296
+ /**
297
+ * A named target's keys go only to the platform and workspace `connect`
298
+ * checked them on. `frontera.yaml` is a file in the repository, so a commit
299
+ * that points the target elsewhere (a teammate's, a merge, a copied entry)
300
+ * is refused before any call, not followed with the stored keys.
301
+ */
302
+ export function refuseMovedTarget(target: Pick<SyncTarget, 'name' | 'owner' | 'link'>): void {
303
+ if (!target.name || !getSyncKey(target.owner, 'workspace')) return
304
+ const at = keysCheckedAt(target.owner)
305
+ const { platform, workspace } = target.link
306
+ if (at && at.platform === platform && at.workspace === workspace) return
307
+ const where = (a: CheckedAt) => `${a.workspace} on ${new URL(a.platform).host}`
308
+ throw new CliError(`target ${target.name}’s keys were ${at ? `checked for ${where(at)}` : 'stored without the address they were checked for'}, and frontera.yaml now points it at ${where(target.link)}`, {
309
+ code: 'SYNC_TARGET_MOVED',
310
+ hint: `its keys are not sent there. If ${target.name} moved, connect it again: \`frontera local sync connect --target ${target.name} --platform ${platform}\`; otherwise put its entry back`,
311
+ })
312
+ }
313
+
170
314
  // ── The link's target as a CLI profile ──────────────────────────────────────
171
315
 
172
316
  /**
@@ -179,8 +323,8 @@ export function removeSyncKey(project: LocalProject, role: KeyRole): void {
179
323
  * read that checks them) names this profile with `--profile`; the remote guard
180
324
  * lets a command that names its target through.
181
325
  */
182
- export function targetProfileName(project: LocalProject, link: SyncLink): string {
183
- const target = [project.id, link.platform, link.workspace].join('\n')
326
+ export function targetProfileName(owner: KeyOwner, link: CheckedAt): string {
327
+ const target = [owner.id, link.platform, link.workspace].join('\n')
184
328
  return `sync-${createHash('sha256').update(target).digest('hex').slice(0, 16)}`
185
329
  }
186
330
 
@@ -188,27 +332,28 @@ export function targetProfileName(project: LocalProject, link: SyncLink): string
188
332
  export const targetOf = (link: SyncLink) => `${new URL(link.platform).host}/${link.workspace}`
189
333
 
190
334
  /**
191
- * The person signed in for the project's target (`frontera auth login
192
- * --browser --profile <target>`), when the sign-in is for the linked
193
- * platform and still lasts; null otherwise. Only the target profile's own
194
- * sign-in counts: one kept for another profile is never borrowed.
335
+ * The person signed in for the target profile of the project's link, or of
336
+ * one of its named targets (`owner`), with `frontera auth login --browser
337
+ * --profile <target>`, when the sign-in is for that platform and still lasts;
338
+ * null otherwise. Only that profile's own sign-in counts: one kept for another
339
+ * profile, the link's or another target's, is never borrowed.
195
340
  */
196
- export function targetSession(project: LocalProject, link: SyncLink): Promise<DeviceSession | null> {
197
- return readDeviceSession(credentialStore(), targetProfileName(project, link), link.platform)
341
+ export function targetSession(owner: KeyOwner, link: SyncLink): Promise<DeviceSession | null> {
342
+ return readDeviceSession(credentialStore(), targetProfileName(owner, link), link.platform)
198
343
  }
199
344
 
200
345
  /**
201
- * The project's reader at organization grain (`client`): the person signed
202
- * in for the target, in the linked workspace's organization, when they may
203
- * read there (a Blueprint Steward, or an owner or admin); else the
204
- * organization key stored; null with neither. `notSteward` names the person
205
- * signed in when they may not and no key reads in their place.
346
+ * The reader at organization grain (`client`) for the link or a named target
347
+ * (`owner`): the person signed in for it, in its workspace's organization,
348
+ * when they may read there (a Blueprint Steward, or an owner or admin); else
349
+ * the organization key stored for it; null with neither. `notSteward` names
350
+ * the person signed in when they may not and no key reads in their place.
206
351
  */
207
- export async function organizationReader(project: LocalProject, link: SyncLink): Promise<{ client: SyncClient | null; notSteward: string | null }> {
208
- const key = getSyncKey(project, 'organization')
352
+ export async function organizationReader(owner: KeyOwner, link: SyncLink): Promise<{ client: SyncClient | null; notSteward: string | null }> {
353
+ const key = getSyncKey(owner, 'organization')
209
354
  const byKey = key ? syncClient(link.platform, key) : null
210
- const person = await targetSession(project, link)
211
- const orgId = person ? await targetOrganization(project, link) : null
355
+ const person = await targetSession(owner, link)
356
+ const orgId = person ? await targetOrganization(owner, link) : null
212
357
  if (!person || !orgId) return { client: byKey, notSteward: null }
213
358
  const client = syncClient(link.platform, person.token, { 'x-org-id': orgId })
214
359
  if (await readsOrganization(client, person.token, orgId)) return { client, notSteward: null }
@@ -227,23 +372,23 @@ function readsOrganization(client: SyncClient, token: string, orgId: string): Pr
227
372
  }
228
373
 
229
374
  /** The linked workspace's organization, as the target profile records it: a profile recorded before it knew one is recorded again. */
230
- async function targetOrganization(project: LocalProject, link: SyncLink): Promise<string | null> {
231
- const name = targetProfileName(project, link)
232
- if (!getProfile(name)?.orgId) await registerTargetProfile(project, link).catch(() => null)
375
+ async function targetOrganization(owner: KeyOwner, link: SyncLink): Promise<string | null> {
376
+ const name = targetProfileName(owner, link)
377
+ if (!getProfile(name)?.orgId) await registerTargetProfile(owner, link).catch(() => null)
233
378
  return getProfile(name)?.orgId ?? null
234
379
  }
235
380
 
236
381
  /** The linked workspace's organization as the platform answers the workspace key; null when it does not. */
237
- async function workspaceOrganization(project: LocalProject, link: SyncLink): Promise<string | null> {
238
- const key = getSyncKey(project, 'workspace')
382
+ async function workspaceOrganization(owner: KeyOwner, link: SyncLink): Promise<string | null> {
383
+ const key = getSyncKey(owner, 'workspace')
239
384
  const me = key ? await syncClient(link.platform, key).get<{ orgId?: string | null }>('/v1/whoami').catch(() => null) : null
240
385
  return me?.orgId ?? null
241
386
  }
242
387
 
243
388
  /** The fingerprints of the keys stored for the project now. */
244
- export function projectKeyFingerprints(project: LocalProject): string[] {
389
+ export function projectKeyFingerprints(owner: KeyOwner): string[] {
245
390
  return (['organization', 'workspace'] as const).flatMap((role) => {
246
- const key = getSyncKey(project, role)
391
+ const key = getSyncKey(owner, role)
247
392
  return key ? [fingerprint(key)] : []
248
393
  })
249
394
  }
@@ -260,13 +405,13 @@ export function projectKeyFingerprints(project: LocalProject): string[] {
260
405
  * which `connect` knows even with no organization key; without it, the one
261
406
  * recorded, else the platform's answer to the workspace key.
262
407
  */
263
- export async function registerTargetProfile(project: LocalProject, link: SyncLink, previous: readonly string[] = [], orgId: string | null = null): Promise<string | null> {
264
- const organizationKey = getSyncKey(project, 'organization')
265
- const key = organizationKey ?? getSyncKey(project, 'workspace')
408
+ export async function registerTargetProfile(owner: KeyOwner, link: SyncLink, previous: readonly string[] = [], orgId: string | null = null): Promise<string | null> {
409
+ const organizationKey = getSyncKey(owner, 'organization')
410
+ const key = organizationKey ?? getSyncKey(owner, 'workspace')
266
411
  if (!key) return null
267
- const name = targetProfileName(project, link)
412
+ const name = targetProfileName(owner, link)
268
413
  const existing = getProfile(name)
269
- if (existing && ![...previous, ...projectKeyFingerprints(project)].includes(existing.fingerprint)) {
414
+ if (existing && ![...previous, ...projectKeyFingerprints(owner)].includes(existing.fingerprint)) {
270
415
  throw new Error(`the profile ${name} holds a key that isn't this project's, so it is left as it is; \`frontera auth remove ${name}\` frees the name`)
271
416
  }
272
417
  await credentialStore().set(name, key)
@@ -277,7 +422,7 @@ export async function registerTargetProfile(project: LocalProject, link: SyncLin
277
422
  workspaceId: organizationKey ? null : link.workspace,
278
423
  // The workspace's organization as the platform reports it, so the person signed in on the profile addresses it
279
424
  // when they belong to several. Never frontera.yaml's: a file in the repository does not choose it.
280
- orgId: orgId ?? existing?.orgId ?? (await workspaceOrganization(project, link)),
425
+ orgId: orgId ?? existing?.orgId ?? (await workspaceOrganization(owner, link)),
281
426
  fingerprint: fingerprint(key),
282
427
  createdAt: existing?.createdAt ?? now,
283
428
  lastVerifiedAt: now,
@@ -286,12 +431,67 @@ export async function registerTargetProfile(project: LocalProject, link: SyncLin
286
431
  }
287
432
 
288
433
  /** Remove this folder's target profile with its key and its browser sign-in, unless the profile holds a key that isn't the project's. */
289
- export async function removeTargetProfile(project: LocalProject, link: SyncLink): Promise<void> {
290
- const name = targetProfileName(project, link)
434
+ export async function removeTargetProfile(owner: KeyOwner, link: CheckedAt): Promise<void> {
435
+ const name = targetProfileName(owner, link)
291
436
  const existing = getProfile(name)
292
- if (existing && !projectKeyFingerprints(project).includes(existing.fingerprint)) return
437
+ if (existing && !projectKeyFingerprints(owner).includes(existing.fingerprint)) return
293
438
  if (existing) deleteProfile(name)
294
439
  const store = credentialStore()
295
440
  await store.delete(name)
296
441
  await forgetDeviceSession(store, name, (url, init) => fetch(url, { ...init, signal: AbortSignal.timeout(5_000) }))
297
442
  }
443
+
444
+ // ── Where a command delivers ────────────────────────────────────────────────
445
+
446
+ /**
447
+ * Where a `local sync` command acts: the project's link (`name` null, what
448
+ * every command acts on unless it names a target), or a named target.
449
+ */
450
+ export interface SyncTarget {
451
+ name: string | null
452
+ link: SyncLink
453
+ production: boolean
454
+ /** Whose keys and target profile it uses: never another target's. */
455
+ owner: KeyOwner
456
+ /**
457
+ * The root its change requests and fetched snapshot are kept under, laid
458
+ * out as the project's own, so the same readers serve it: the project
459
+ * folder for the link, `.frontera/sync/targets/<name>/` for a named target.
460
+ */
461
+ state: string
462
+ }
463
+
464
+ export function linkTarget(project: LocalProject, link: SyncLink): SyncTarget {
465
+ return { name: null, link, production: false, owner: project, state: project.root }
466
+ }
467
+
468
+ /** Whose keys and target profile a target uses: the project's for its link, `<project id>@<name>` for a named target. */
469
+ export function targetOwner(project: LocalProject, name: string | null): KeyOwner {
470
+ return name ? { id: `${project.id}@${name}` } : project
471
+ }
472
+
473
+ /** Where a named target's change requests, snapshot and base live. */
474
+ export function targetState(project: Pick<LocalProject, 'root'>, name: string): string {
475
+ return join(project.root, '.frontera', 'sync', 'targets', name)
476
+ }
477
+
478
+ export function namedTarget(project: LocalProject, name: string, target: NamedTarget): SyncTarget {
479
+ return { name, link: target, production: target.production === true, owner: targetOwner(project, name), state: targetState(project, name) }
480
+ }
481
+
482
+ /** The project's link, when it has one, then its named targets. */
483
+ export function projectTargets(project: LocalProject): SyncTarget[] {
484
+ const link = readLink(project.root)
485
+ return [...(link ? [linkTarget(project, link)] : []), ...Object.entries(readTargets(project.root)).map(([name, t]) => namedTarget(project, name, t))]
486
+ }
487
+
488
+ /** A command's words for this target: `--target <name>` follows them for a named target. */
489
+ export function targetWords(target: Pick<SyncTarget, 'name'>, words: string): string {
490
+ return target.name ? `${words} --target ${target.name}` : words
491
+ }
492
+
493
+ /** How the output names the target: its workspace, and the target's name when it has one. */
494
+ export function targetLabel(target: Pick<SyncTarget, 'name' | 'link'>): string {
495
+ const workspace = target.link.workspaceName ?? target.link.workspace
496
+ return target.name ? `${workspace} (target ${target.name})` : workspace
497
+ }
@@ -523,7 +523,7 @@ export interface ApplyResult {
523
523
  }
524
524
 
525
525
  /** The lock entry for an item as the workspace has it: the base after a pull or a resolve. */
526
- function entryFor(item: RemoteItem, now: string): LockEntry {
526
+ export function entryFor(item: RemoteItem, now: string): LockEntry {
527
527
  return {
528
528
  ...(item.id ? { id: item.id } : {}),
529
529
  ...(item.revision ? { revision: item.revision } : {}),
@@ -1,3 +1,4 @@
1
+ import { targetLabel, targetWords, type SyncTarget } from './local-sync-link'
1
2
  import { modelsNamed } from './local-sync-portable'
2
3
  import type { Check, PushItem } from './local-sync-push'
3
4
 
@@ -109,15 +110,28 @@ export function splitCheckMessage(message: string): { why: string; file: string
109
110
  return { why: m[2]!.charAt(0).toUpperCase() + m[2]!.slice(1), file: m[1]! }
110
111
  }
111
112
 
113
+ const MOVED_THERE: Record<string, string> = { stale_base: 'Changed in', gone: 'Deleted in', exists: 'Made in' }
114
+
115
+ /**
116
+ * A refusal because the item moved on a named target since this push read
117
+ * it, in the target's words: a target is never pulled from, so its fix is a
118
+ * look that reads it again. Null for the link, and for any other refusal.
119
+ */
120
+ export function movedOnTarget(check: Check, target: Pick<SyncTarget, 'name' | 'link'>): { reason: string; fix: string } | null {
121
+ const moved = target.name ? MOVED_THERE[check.code] : undefined
122
+ return moved ? { reason: `${moved} ${targetLabel(target)} since this push read it`, fix: `look again with \`${targetWords(target, 'frontera local sync push --preview')}\`, which reads what it holds now` } : null
123
+ }
124
+
112
125
  /** Block each item the workspace's checks would refuse, with the reason first and its fix. Returns the refs refused. */
113
- export function markRefused(items: PushItem[], checks: Check[]): string[] {
126
+ export function markRefused(items: PushItem[], checks: Check[], target?: Pick<SyncTarget, 'name' | 'link'>): string[] {
114
127
  const byRef = new Map(items.map((i) => [i.ref, i]))
115
128
  const refused: string[] = []
116
129
  for (const check of checks) {
117
130
  const item = byRef.get(check.ref)
118
131
  if (!item || !item.proposable) continue
119
132
  const { why, file } = splitCheckMessage(check.message)
120
- Object.assign(item, { proposable: false, blocked: 'refused', reason: file ? `${why} (${file})` : why, fix: fixFor(check) })
133
+ const there = target ? movedOnTarget(check, target) : null
134
+ Object.assign(item, { proposable: false, blocked: 'refused', reason: there?.reason ?? (file ? `${why} (${file})` : why), fix: there?.fix ?? fixFor(check) })
121
135
  refused.push(item.ref)
122
136
  }
123
137
  return refused
@@ -62,8 +62,13 @@ interface Property {
62
62
  enum?: unknown[]
63
63
  /** A calculated field (a location from latitude and longitude): computed by the Blueprint, never a column. */
64
64
  calculation?: unknown
65
+ /** An edit-only field: only Actions write it, so sample data gives it no column and no value. */
66
+ editOnly?: boolean
65
67
  }
66
68
 
69
+ /** Whether a property reads a column of its type's dataset: neither calculated nor edit-only. */
70
+ const readsColumn = (p: Property) => !p.calculation && p.editOnly !== true
71
+
67
72
  /**
68
73
  * Whether a record may leave a property empty: unless it says `required:
69
74
  * true`, as the Blueprint reads it (a property without `required` is
@@ -327,7 +332,7 @@ export function inferReferences(types: ObjectType[], refs: Map<string, Map<strin
327
332
  for (const type of types) {
328
333
  const own = refs.get(type.apiName)
329
334
  for (const p of type.properties ?? []) {
330
- if (p.calculation || p.apiName === primaryKeys.get(type.apiName) || own?.has(p.apiName) || !['string', 'number'].includes(p.valueType ?? 'string') || hasValues(type, p)) continue
335
+ if (!readsColumn(p) || p.apiName === primaryKeys.get(type.apiName) || own?.has(p.apiName) || !['string', 'number'].includes(p.valueType ?? 'string') || hasValues(type, p)) continue
331
336
  const others = types.filter((t) => t.apiName !== type.apiName && sameKind(p, t))
332
337
  const name = squash(p.apiName)
333
338
  // Another type's key by name: `staffId`, `decisionId`.
@@ -1528,11 +1533,11 @@ function timeBound(c: Constraint, rule: SemanticType['rule'] | undefined): Const
1528
1533
  return { ...c, ...(Number.isFinite(min) ? { min } : {}), ...(Number.isFinite(max) ? { max } : {}) }
1529
1534
  }
1530
1535
 
1531
- /** The properties a type's dataset holds a column for: not a calculated field, and in its mapping when it has one. */
1536
+ /** The properties a type's dataset holds a column for: not a calculated or edit-only field, and in its mapping when it has one. */
1532
1537
  export function storedProperties(type: ObjectType): Property[] {
1533
1538
  const mapping = type.backing?.mapping
1534
1539
  const mapped = mapping && Object.keys(mapping).length ? new Set(Object.keys(mapping)) : null
1535
- return (type.properties ?? []).filter((p) => !p.calculation && (!mapped || mapped.has(p.apiName)))
1540
+ return (type.properties ?? []).filter((p) => readsColumn(p) && (!mapped || mapped.has(p.apiName)))
1536
1541
  }
1537
1542
 
1538
1543
  type Row = Record<string, unknown>
@@ -1824,8 +1829,8 @@ export function generateSyntheticData(root: string, opts: SyntheticOptions = {})
1824
1829
  // Types others point at are the fewer ones (customers, products); the rest scale with them.
1825
1830
  const referenced = [...refs.values()].some((m) => [...m.values()].some((r) => r.objectType === type.apiName))
1826
1831
  const wanted = custom?.rows ?? (referenced ? Math.max(5, Math.round(baseRows / 2)) : baseRows)
1827
- // A calculated field is the Blueprint's to compute: no value, no column.
1828
- const properties = (type.properties ?? []).filter((p) => !p.calculation)
1832
+ // A calculated field is the Blueprint's to compute, and an edit-only one the Actions' to write: no value, no column.
1833
+ const properties = (type.properties ?? []).filter(readsColumn)
1829
1834
  const pk = primaryKeys.get(type.apiName)!
1830
1835
  const prefix = keyPrefix(type.apiName)
1831
1836
  const ownRefs = refs.get(type.apiName) ?? new Map<string, Reference>()
@@ -2303,8 +2308,8 @@ export function addMissingBackings(root: string): string[] {
2303
2308
  for (const { file, doc } of readTree<ObjectType>(dir)) {
2304
2309
  if (!doc?.apiName || doc.backing?.dataset) continue
2305
2310
  const { dataset, column } = backingOf(doc)
2306
- // A calculated field reads no column.
2307
- const next = { ...doc, backing: { dataset, mapping: Object.fromEntries((doc.properties ?? []).filter((p) => !p.calculation).map((p) => [p.apiName, column(p.apiName)])) } }
2311
+ // A calculated or edit-only field reads no column.
2312
+ const next = { ...doc, backing: { dataset, mapping: Object.fromEntries((doc.properties ?? []).filter(readsColumn).map((p) => [p.apiName, column(p.apiName)])) } }
2308
2313
  const path = join(dir, file)
2309
2314
  writeFileSync(path, file.endsWith('.json') ? `${JSON.stringify(next, null, 2)}\n` : stringifyYaml(next, { lineWidth: 0 }))
2310
2315
  changed.push(join('blueprint', 'object-types', file))
@@ -18,7 +18,7 @@ import { readBaseline } from '../../local/local-db-snapshot'
18
18
  import { stuckKnowledge } from '../../local/local-knowledge-ingestion'
19
19
  import { projectOrphans } from '../../local/local-orphans'
20
20
  import type { LocalProject } from '../../local/local-project'
21
- import { readLink } from '../../local/local-sync-link'
21
+ import { leftOutTargets, readLink, readTargets } from '../../local/local-sync-link'
22
22
  import { logPath, readState, type ComponentName } from '../../local/local-state'
23
23
  import { runSelf } from '../mcp-cli'
24
24
  import { errorResult, intArg, jsonResult, stringArg, type McpTool } from '../mcp-protocol'
@@ -71,7 +71,7 @@ export function projectTools(project: LocalProject): McpTool[] {
71
71
  {
72
72
  name: 'status',
73
73
  description:
74
- "Where the project stands on the local runtime: who last changed it or its files (`writers`, one per session, `self` for yours; `runtimeLock` while another run holds the runtime), what each Agent, workflow, Function and Blueprint object type came to (built, adapted, stubbed, needs setup, blocked, failed, not applied) with the reason chain to the item actually at fault, files saved but not applied, the workspace link, the runtime's health and its documentation's version, the saved object sets with their ids (blueprint_query reads one), any knowledge document left pending or processing with no ingestion behind it (`knowledgeStuck`, each with the command that re-queues it), and each Agent, workflow, Function or knowledge base on the runtime with no file in the project (`notInProject`, each with its fix: apply archives an Agent or workflow whose file was deleted, and switches off such a Function). `build` counts exactly the rows `items` lists with all: true. Read it first after a change.",
74
+ "Where the project stands on the local runtime: who last changed it or its files (`writers`, one per session, `self` for yours; `runtimeLock` while another run holds the runtime), what each Agent, workflow, Function and Blueprint object type came to (built, adapted, stubbed, needs setup, blocked, failed, not applied) with the reason chain to the item actually at fault, files saved but not applied, the workspace link and the named targets the project delivers to (`targets`: `local sync push --target <name>`) and any entry in frontera.yaml it leaves out (`targetsLeftOut`, each with what to fix), the runtime's health and its documentation's version, the saved object sets with their ids (blueprint_query reads one), any knowledge document left pending or processing with no ingestion behind it (`knowledgeStuck`, each with the command that re-queues it), and each Agent, workflow, Function or knowledge base on the runtime with no file in the project (`notInProject`, each with its fix: apply archives an Agent or workflow whose file was deleted, and switches off such a Function). `build` counts exactly the rows `items` lists with all: true. Read it first after a change.",
75
75
  inputSchema: { type: 'object', properties: { all: { type: 'boolean', description: 'List built items too, not only the ones with something to say (default false).' } }, additionalProperties: false },
76
76
  annotations: { title: 'Project status', readOnlyHint: true, openWorldHint: false },
77
77
  async run(input) {
@@ -140,6 +140,13 @@ export function projectTools(project: LocalProject): McpTool[] {
140
140
  return orphans?.length ? { notInProject: orphans.map((o) => ({ ref: o.ref, problem: o.problem, fix: o.fix, fileDeleted: o.deleted })) } : {}
141
141
  })()),
142
142
  workspace: sync ? { linked: true, workspace: sync.workspaceName ?? sync.workspace, platform: sync.platform } : { linked: false },
143
+ // The named targets it delivers to by name (`local sync push --target <name>`); a production one is reached only so.
144
+ // An entry in frontera.yaml the CLI can't use is listed apart, with what to fix.
145
+ ...(() => {
146
+ const named = Object.entries(readTargets(project.root))
147
+ const leftOut = leftOutTargets(project.root)
148
+ return { ...(named.length ? { targets: named.map(([name, t]) => ({ name, workspace: t.workspaceName ?? t.workspace, platform: t.platform, production: t.production === true })) } : {}), ...(leftOut.length ? { targetsLeftOut: leftOut } : {}) }
149
+ })(),
143
150
  // The steps prepared for the person and not yet run (person_step): the delivery card Forge shows.
144
151
  ...(() => {
145
152
  const pending = readPersonSteps(project.root).filter((p) => p.status === 'pending')