@frontera-sdk/cli 1.53.10 → 1.53.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/automation/dev.ts +1 -1
- package/src/commands/blueprint/declarative.ts +106 -44
- package/src/commands/knowledge/index-commands.ts +1 -1
- 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 +5 -2
- 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
- package/src/vendor/sdk-sources.json +17 -17
|
@@ -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
|
-
|
|
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?:
|
|
51
|
-
|
|
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
|
-
/**
|
|
99
|
-
|
|
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
|
-
|
|
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> =
|
|
107
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
239
|
+
function storeSet(name: string, value: string): void {
|
|
144
240
|
if (useKeychain()) {
|
|
145
|
-
const res = spawnSync('security', ['add-generic-password', '-U', '-s', SERVICE, '-a',
|
|
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(), [
|
|
245
|
+
writeFileAtomic(keysFile(), `${JSON.stringify({ ...readKeysFile(), [name]: value }, null, 2)}\n`, 0o600)
|
|
150
246
|
}
|
|
151
247
|
|
|
152
|
-
|
|
248
|
+
function storeGet(name: string): string | null {
|
|
153
249
|
if (useKeychain()) {
|
|
154
|
-
const res = spawnSync('security', ['find-generic-password', '-s', SERVICE, '-a',
|
|
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()[
|
|
253
|
+
return readKeysFile()[name] ?? null
|
|
158
254
|
}
|
|
159
255
|
|
|
160
|
-
|
|
256
|
+
function storeRemove(name: string): void {
|
|
161
257
|
if (useKeychain()) {
|
|
162
|
-
spawnSync('security', ['delete-generic-password', '-s', SERVICE, '-a',
|
|
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[
|
|
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(
|
|
183
|
-
const target = [
|
|
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
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
* sign-in counts: one kept for another
|
|
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(
|
|
197
|
-
return readDeviceSession(credentialStore(), targetProfileName(
|
|
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
|
|
202
|
-
* in for
|
|
203
|
-
* read there (a Blueprint Steward, or an owner or admin); else
|
|
204
|
-
* organization key stored; null with neither. `notSteward` names
|
|
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(
|
|
208
|
-
const key = getSyncKey(
|
|
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(
|
|
211
|
-
const orgId = person ? await targetOrganization(
|
|
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(
|
|
231
|
-
const name = targetProfileName(
|
|
232
|
-
if (!getProfile(name)?.orgId) await registerTargetProfile(
|
|
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(
|
|
238
|
-
const key = getSyncKey(
|
|
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(
|
|
389
|
+
export function projectKeyFingerprints(owner: KeyOwner): string[] {
|
|
245
390
|
return (['organization', 'workspace'] as const).flatMap((role) => {
|
|
246
|
-
const key = getSyncKey(
|
|
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(
|
|
264
|
-
const organizationKey = getSyncKey(
|
|
265
|
-
const key = organizationKey ?? getSyncKey(
|
|
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(
|
|
412
|
+
const name = targetProfileName(owner, link)
|
|
268
413
|
const existing = getProfile(name)
|
|
269
|
-
if (existing && ![...previous, ...projectKeyFingerprints(
|
|
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(
|
|
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(
|
|
290
|
-
const name = targetProfileName(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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(
|
|
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(
|
|
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')
|