@frontera-sdk/cli 1.53.5 → 1.53.7

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.
Files changed (72) hide show
  1. package/package.json +4 -4
  2. package/src/api/apps-api.ts +2 -2
  3. package/src/api/blueprint-authoring-api.ts +19 -19
  4. package/src/api/governed-action-api.ts +78 -3
  5. package/src/api/platform-api.ts +20 -0
  6. package/src/blueprint/dataset-revision.ts +66 -0
  7. package/src/blueprint/diff.ts +134 -6
  8. package/src/blueprint/publish-plan.ts +253 -0
  9. package/src/blueprint/render.ts +19 -0
  10. package/src/commands/action/deploy.ts +143 -25
  11. package/src/commands/action/index-commands.ts +3 -3
  12. package/src/commands/action/list.ts +3 -3
  13. package/src/commands/action/prepare.ts +5 -3
  14. package/src/commands/action/retire.ts +78 -26
  15. package/src/commands/action/review.ts +86 -25
  16. package/src/commands/app/checks.ts +1 -1
  17. package/src/commands/app/deploy.ts +9 -9
  18. package/src/commands/app/deployments.ts +1 -1
  19. package/src/commands/app/dev.ts +79 -14
  20. package/src/commands/app/promote.ts +1 -1
  21. package/src/commands/app/pull.ts +4 -2
  22. package/src/commands/app/rollback.ts +1 -1
  23. package/src/commands/app/save.ts +3 -7
  24. package/src/commands/app/shared.ts +59 -2
  25. package/src/commands/app/unpin.ts +1 -1
  26. package/src/commands/app/versions.ts +1 -1
  27. package/src/commands/blueprint/authoring.ts +110 -24
  28. package/src/commands/blueprint/bind.ts +10 -46
  29. package/src/commands/blueprint/declarative.ts +124 -30
  30. package/src/commands/blueprint/editable.ts +3 -5
  31. package/src/commands/knowledge/index-commands.ts +15 -2
  32. package/src/commands/local/check.ts +21 -13
  33. package/src/commands/local/data.ts +1 -1
  34. package/src/commands/local/dev.ts +1 -21
  35. package/src/commands/local/index-commands.ts +1 -1
  36. package/src/commands/local/snapshot.ts +2 -2
  37. package/src/commands/local/steps.ts +11 -7
  38. package/src/commands/local/sync-request.ts +45 -8
  39. package/src/commands/local/sync.ts +23 -4
  40. package/src/commands/mcp-server/index-commands.ts +99 -11
  41. package/src/commands/registry.ts +1 -1
  42. package/src/commands/types.ts +2 -0
  43. package/src/device-login.ts +19 -1
  44. package/src/exit.ts +5 -0
  45. package/src/flag-help.ts +2 -1
  46. package/src/forge/permission-rules.ts +2 -0
  47. package/src/local/local-actions.ts +6 -5
  48. package/src/local/local-app-runtime.ts +6 -4
  49. package/src/local/local-blueprint-schemas.ts +2 -2
  50. package/src/local/local-build-inputs.ts +2 -1
  51. package/src/local/local-db-snapshot.ts +62 -46
  52. package/src/local/local-dev-references.ts +0 -23
  53. package/src/local/local-docker.ts +84 -10
  54. package/src/local/local-mcp-servers.ts +79 -14
  55. package/src/local/local-person-steps.ts +121 -19
  56. package/src/local/local-project.ts +5 -1
  57. package/src/local/local-supervisor.ts +41 -3
  58. package/src/local/local-sync-link.ts +76 -0
  59. package/src/local/local-sync-precheck.ts +1 -1
  60. package/src/local/local-sync-push.ts +8 -2
  61. package/src/local/local-sync-remote.ts +18 -0
  62. package/src/main.ts +7 -1
  63. package/src/mcp/mcp-apps.ts +96 -20
  64. package/src/mcp/tools/agent-tools.ts +29 -7
  65. package/src/mcp/tools/app-tools.ts +3 -3
  66. package/src/mcp/tools/project-tools.ts +2 -1
  67. package/src/project.ts +47 -11
  68. package/src/remote-guard.ts +4 -1
  69. package/src/templates/next-app-files.ts +4 -1
  70. package/src/templates/next-skills.ts +9 -5
  71. package/src/vendor/kit-assets.json +8 -8
  72. package/src/vendor/sdk-sources.json +17 -17
@@ -2,7 +2,11 @@ import { existsSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { parse as parseYaml } from 'yaml'
4
4
 
5
+ import { CliError } from '../errors'
5
6
  import { errorOf } from './local-chat-apps'
7
+ import { allTargets } from './local-dev-sync'
8
+ import { localProjectAt } from './local-project'
9
+ import { getSyncKey, readLink, type SyncLink } from './local-sync-link'
6
10
 
7
11
  /**
8
12
  * A workspace's own MCP servers: `mcp-servers/<slug>.yaml` (or .yml, .json),
@@ -12,6 +16,11 @@ import { errorOf } from './local-chat-apps'
12
16
  * types the workspace is granted, its Functions, workflows and governed
13
17
  * Actions. A server whose file is deleted is archived on the runtime.
14
18
  *
19
+ * A change request carries no MCP servers: `frontera mcp-server save` sends
20
+ * the same file with the same request to the workspace the project is linked
21
+ * to (`local sync connect`), with its workspace key, as `local sync push`
22
+ * reaches it; to the profile's workspace in a project with no link.
23
+ *
15
24
  * Its format is the service's (`frontera local schema mcp-server`).
16
25
  */
17
26
 
@@ -24,45 +33,101 @@ export interface McpServerApplied {
24
33
  output: string
25
34
  }
26
35
 
27
- interface SavedServer {
36
+ export interface SavedServer {
28
37
  server?: { slug?: string; name?: string; url?: string; signIn?: boolean; tools?: unknown[] }
29
38
  created?: boolean
30
39
  warnings?: Array<{ name?: string; reason?: string }>
31
40
  }
32
41
 
33
42
  /** The file as the service takes it: YAML or JSON, a mapping at the top. */
34
- export function readMcpServerFile(root: string, file: string): { ok: true; doc: Record<string, unknown> } | { ok: false; output: string } {
43
+ export function readMcpServerFile(root: string, file: string): { ok: true; doc: Record<string, unknown> } | { ok: false; problem: string } {
35
44
  const path = join(root, file)
36
- if (!existsSync(path)) return { ok: false, output: `error [MCP_SERVER]: ${file} is missing` }
45
+ if (!existsSync(path)) return { ok: false, problem: `${file} is missing` }
37
46
  let doc: unknown
38
47
  try {
39
48
  const text = readFileSync(path, 'utf8')
40
49
  doc = file.endsWith('.json') ? JSON.parse(text) : parseYaml(text)
41
50
  } catch (err) {
42
- return { ok: false, output: `error [MCP_SERVER]: ${file} can't be read: ${(err as Error).message.split('\n')[0]}` }
51
+ return { ok: false, problem: `${file} can't be read: ${(err as Error).message.split('\n')[0]}` }
43
52
  }
44
53
  if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
45
- return { ok: false, output: `error [MCP_SERVER]: ${file} must be a mapping with name, instructions and tools (\`frontera local schema mcp-server\`)` }
54
+ return { ok: false, problem: `${file} must be a mapping with name, instructions and tools (\`frontera local schema mcp-server\`)` }
46
55
  }
47
56
  return { ok: true, doc: doc as Record<string, unknown> }
48
57
  }
49
58
 
59
+ /** Where a client connects to a saved server, and each tool offered to nobody for now. */
60
+ export function savedLines(slug: string, saved: SavedServer | null, updated = 'applied'): string[] {
61
+ const tools = saved?.server?.tools?.length ?? 0
62
+ const url = saved?.server?.url
63
+ // A local runtime offers no sign-in: a client there sends the runtime's workspace key.
64
+ const how = saved?.server?.signIn === false ? " with the runtime's workspace key" : ''
65
+ const lines = [` ${saved?.created ? 'created' : updated} MCP server ${slug} (${tools} tool${tools === 1 ? '' : 's'})${url ? ` — connect at ${url}${how}` : ''}`]
66
+ for (const warning of saved?.warnings ?? []) {
67
+ lines.push(` warning: MCP server ${slug}: tool ${warning.name ?? '?'} is offered to nobody for now: ${warning.reason ?? 'unavailable'}`)
68
+ }
69
+ return lines
70
+ }
71
+
50
72
  /** Save one server on the runtime; the line says where a client connects, or what the service refused. */
51
73
  export async function applyMcpServer(root: string, slug: string, file: string, call: Call): Promise<McpServerApplied> {
52
74
  const read = readMcpServerFile(root, file)
53
- if (!read.ok) return read
75
+ if (!read.ok) return { ok: false, output: `error [MCP_SERVER]: ${read.problem}` }
54
76
  const res = await call('PUT', `/v1/mcp-servers/${encodeURIComponent(slug)}`, read.doc)
55
77
  if (res.status !== 200 && res.status !== 201) {
56
78
  return { ok: false, output: `error [MCP_SERVER]: MCP server ${slug} was refused: ${errorOf(res)}` }
57
79
  }
58
80
  const saved = ((res.body as { data?: unknown } | null)?.data ?? res.body) as SavedServer | null
59
- const tools = saved?.server?.tools?.length ?? 0
60
- const url = saved?.server?.url
61
- // A local runtime offers no sign-in: a client there sends the runtime's workspace key.
62
- const how = saved?.server?.signIn === false ? " with the runtime's workspace key" : ''
63
- const lines = [` ${saved?.created ? 'created' : 'applied'} MCP server ${slug} (${tools} tool${tools === 1 ? '' : 's'})${url ? ` — connect at ${url}${how}` : ''}`]
64
- for (const warning of saved?.warnings ?? []) {
65
- lines.push(` warning: MCP server ${slug}: tool ${warning.name ?? '?'} is offered to nobody for now: ${warning.reason ?? 'unavailable'}`)
81
+ return { ok: true, output: savedLines(slug, saved).join('\n') }
82
+ }
83
+
84
+ /** The project's MCP server files, as `local dev` finds them. */
85
+ export function projectMcpServers(root: string): Array<{ slug: string; file: string }> {
86
+ return allTargets(root).flatMap((t) => (t.kind === 'mcp-server' ? [{ slug: t.name, file: t.file }] : []))
87
+ }
88
+
89
+ /** "Orders (ws_1) on acme.frontera.example": the linked workspace in a sentence. */
90
+ function linkedWorkspaceWords(link: SyncLink): string {
91
+ return `${link.workspaceName ?? link.workspace}${link.workspaceName ? ` (${link.workspace})` : ''} on ${new URL(link.platform).host}`
92
+ }
93
+
94
+ /**
95
+ * Where `frontera mcp-server save` sends a linked project's servers: the
96
+ * linked workspace, with the project's workspace key, which is what `local
97
+ * sync push` reaches and what its line names. The directory's profile in a
98
+ * project with a local runtime is the runtime's (`local start` binds it), and
99
+ * `local dev` already saves the files there. A workspace key acts for its
100
+ * creator, so the service lets it save a server as it lets `local dev`'s.
101
+ * Null when the project has no link. With a link and no key on this computer,
102
+ * refused rather than sent to the profile.
103
+ */
104
+ export function linkedSaveTarget(root: string): { platform: string; key: string; where: string } | null {
105
+ const link = readLink(root)
106
+ if (!link) return null
107
+ const workspace = linkedWorkspaceWords(link)
108
+ const key = getSyncKey(localProjectAt(root), 'workspace')
109
+ if (!key) {
110
+ throw new CliError(`this project is linked to ${workspace}, and this computer holds no workspace key for it`, {
111
+ code: 'SYNC_NO_KEY',
112
+ hint: '`frontera local sync connect` stores one (keys are kept per computer); `--profile <name>` saves to that profile’s workspace instead',
113
+ })
66
114
  }
67
- return { ok: true, output: lines.join('\n') }
115
+ return { platform: link.platform, key, where: `${workspace}, the workspace this project is linked to` }
116
+ }
117
+
118
+ /**
119
+ * What a push or `sync status` says of the project's MCP server files, which
120
+ * no change request carries, with the command that saves them. Null when the
121
+ * project has none.
122
+ */
123
+ export function mcpServersNotCarried(root: string): { files: string[]; command: string; line: string } | null {
124
+ const files = projectMcpServers(root).map((s) => s.file)
125
+ if (!files.length) return null
126
+ const command = files.length === 1 ? `frontera mcp-server save ${files[0]}` : 'frontera mcp-server save'
127
+ const link = readLink(root)
128
+ const where = link
129
+ ? `${linkedWorkspaceWords(link)}, the workspace this project is linked to, with its workspace key (\`--profile <name>\` saves to a profile’s workspace instead)`
130
+ : 'the workspace your profile signs in to (`--profile <name>` picks another)'
131
+ const line = `A change request does not carry MCP servers (${files.join(', ')}): \`${command}\` saves ${files.length === 1 ? 'it' : 'every one'} to ${where}.`
132
+ return { files, command, line }
68
133
  }
@@ -2,6 +2,9 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
3
 
4
4
  import { writeFileAtomic } from '../fs-atomic'
5
+ import { getProfile } from '../profiles'
6
+ import { localProject } from './local-project'
7
+ import { readLink, targetOf, targetProfileName } from './local-sync-link'
5
8
 
6
9
  /**
7
10
  * Person-steps (Forge improvement plan T4, N4): a step only the person may
@@ -25,7 +28,7 @@ import { writeFileAtomic } from '../fs-atomic'
25
28
  export const PERSON_STEPS_FILE = join('.frontera', 'sync', 'person-steps.json')
26
29
 
27
30
  /** Who or what the step needs. */
28
- export const CREDENTIALS = ['workspace-key', 'organization-key', 'steward', 'admin', 'second-person', 'person'] as const
31
+ export const CREDENTIALS = ['workspace-key', 'organization-key', 'steward', 'admin', 'second-person', 'person', 'registry-owner'] as const
29
32
  export type Credential = (typeof CREDENTIALS)[number]
30
33
 
31
34
  /** Each credential in the person's words, as the card and the list say it. */
@@ -36,6 +39,7 @@ export const CREDENTIAL_WORDS: Record<Credential, string> = {
36
39
  admin: 'a workspace admin',
37
40
  'second-person': 'a second person (not whoever made the change)',
38
41
  person: 'you, signed in (`frontera auth login --browser`)',
42
+ 'registry-owner': 'an owner of the organization that publishes the runtime images',
39
43
  }
40
44
 
41
45
  /**
@@ -67,12 +71,16 @@ export interface PersonStep {
67
71
  doneAt?: string
68
72
  /** Who saw it done: the person's mark, or its check. */
69
73
  doneBy?: 'person' | 'check'
70
- /** Where it came from: the coding agent, or what a change request left after Apply. */
71
- source?: 'agent' | 'change-request'
72
- /** A change request's own key for it (`action-deploy:qualifyLead`), so applying again updates it. */
74
+ /** Where it came from: the coding agent, what a change request left after Apply, or the local runtime at a refusal only a person clears. */
75
+ source?: 'agent' | 'change-request' | 'runtime'
76
+ /** A change request's own key for it (`action-deploy:qualifyLead`), or the runtime's (`image-access:<repository>`), so recording it again updates it. */
73
77
  key?: string
74
78
  check?: StepCheck
75
79
  lastCheckedAt?: string
80
+ /** The workspace a change request's step acts on (`targetOf`): stale once the folder links elsewhere. */
81
+ target?: string
82
+ /** The profile its command and check name (`targetProfileName`): stale when it is gone or no longer the folder's. */
83
+ profile?: string
76
84
  }
77
85
 
78
86
  interface StepsFile {
@@ -118,12 +126,35 @@ function filePath(root: string): string {
118
126
  export function readPersonSteps(root: string): PersonStep[] {
119
127
  const path = filePath(root)
120
128
  if (!existsSync(path)) return []
129
+ let steps: PersonStep[]
121
130
  try {
122
131
  const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<StepsFile>
123
- return Array.isArray(parsed.steps) ? parsed.steps.filter((s): s is PersonStep => !!s && typeof s.id === 'string' && (typeof s.command === 'string' || s.command === null)) : []
132
+ steps = Array.isArray(parsed.steps) ? parsed.steps.filter((s): s is PersonStep => !!s && typeof s.id === 'string' && (typeof s.command === 'string' || s.command === null)) : []
124
133
  } catch {
125
134
  return []
126
135
  }
136
+ const current = steps.some((s) => s.profile) ? currentTarget(root) : null
137
+ return current ? steps.map((s) => aimedAt(s, current)) : steps
138
+ }
139
+
140
+ /**
141
+ * A change request's step recorded for the folder's target under another
142
+ * profile, aimed at the folder's profile for it. The profile name follows the
143
+ * folder's path, so it changes when the folder is moved or renamed, while the
144
+ * steps file moves with the folder.
145
+ */
146
+ function aimedAt(step: PersonStep, current: StepTarget): PersonStep {
147
+ const was = step.profile
148
+ if (!was || step.target !== current.target || was === current.profile) return step
149
+ const swap = (command: string) => command.split(' ')
150
+ .map((word, i, words) => (word === was && words[i - 1] === '--profile' ? current.profile : word === `--profile=${was}` ? `--profile=${current.profile}` : word))
151
+ .join(' ')
152
+ return {
153
+ ...step,
154
+ profile: current.profile,
155
+ ...(step.command ? { command: swap(step.command) } : {}),
156
+ ...(step.check ? { check: { ...step.check, command: swap(step.check.command) } } : {}),
157
+ }
127
158
  }
128
159
 
129
160
  function writeSteps(root: string, steps: PersonStep[]): void {
@@ -133,19 +164,23 @@ function writeSteps(root: string, steps: PersonStep[]): void {
133
164
  }
134
165
 
135
166
  export interface NewPersonStep {
136
- command: string
167
+ /** null for a step done outside the CLI, which only the CLI itself records. */
168
+ command: string | null
137
169
  why: string
138
170
  changes: string
139
171
  credential: string
140
172
  delivery?: string | null
141
173
  /** The read that proves it done, already checked (`checkProblem`). */
142
174
  check?: StepCheck
175
+ /** The CLI's own step, under a stable key: recording it again updates that step, and reopens it if it was settled. */
176
+ key?: string
177
+ source?: 'agent' | 'runtime'
143
178
  }
144
179
 
145
180
  /** Each problem with a step as given; empty when it can be recorded. */
146
181
  export function stepProblems(input: NewPersonStep): string[] {
147
182
  const problems: string[] = []
148
- const command = commandProblem(input.command)
183
+ const command = input.command === null ? null : commandProblem(input.command)
149
184
  if (command) problems.push(command)
150
185
  if (!input.why?.trim()) problems.push('say why the step is needed (`why`)')
151
186
  if (!input.changes?.trim()) problems.push('say what the step changes (`changes`)')
@@ -160,17 +195,20 @@ export function stepProblems(input: NewPersonStep): string[] {
160
195
  /**
161
196
  * Record a step. The same command pending in the same delivery is the same
162
197
  * step, updated rather than listed twice, so an agent restating its plan
163
- * after a compaction doesn't double the card.
198
+ * after a compaction doesn't double the card. A keyed step is one step
199
+ * whatever its state: a refusal seen again is pending again.
164
200
  */
165
201
  export function addPersonStep(root: string, input: NewPersonStep, now = new Date()): { step: PersonStep; updated: boolean } {
166
202
  const problems = stepProblems(input)
167
203
  if (problems.length) throw new Error(problems.join('; '))
168
204
  const steps = readPersonSteps(root)
169
- const command = input.command.trim().replace(/\s+/g, ' ')
205
+ const command = input.command === null ? null : input.command.trim().replace(/\s+/g, ' ')
170
206
  const delivery = input.delivery?.trim() || null
171
- const same = steps.find((s) => s.status === 'pending' && s.command === command && s.delivery === delivery)
207
+ const same = input.key ? steps.find((s) => s.key === input.key) : steps.find((s) => s.status === 'pending' && s.command === command && s.delivery === delivery)
172
208
  if (same) {
173
- Object.assign(same, { why: input.why.trim(), changes: input.changes.trim(), credential: input.credential as Credential, ...(input.check ? { check: input.check } : {}) })
209
+ Object.assign(same, { why: input.why.trim(), changes: input.changes.trim(), credential: input.credential as Credential, status: 'pending', ...(input.check ? { check: input.check } : {}) })
210
+ delete same.doneAt
211
+ delete same.doneBy
174
212
  writeSteps(root, steps)
175
213
  return { step: same, updated: true }
176
214
  }
@@ -184,7 +222,8 @@ export function addPersonStep(root: string, input: NewPersonStep, now = new Date
184
222
  delivery,
185
223
  status: 'pending',
186
224
  createdAt: now.toISOString(),
187
- source: 'agent',
225
+ source: input.source ?? 'agent',
226
+ ...(input.key ? { key: input.key } : {}),
188
227
  ...(input.check ? { check: input.check } : {}),
189
228
  }
190
229
  writeSteps(root, [...steps, step])
@@ -208,6 +247,15 @@ export function settlePersonStep(root: string, id: string, status: 'done' | 'dis
208
247
  return step
209
248
  }
210
249
 
250
+ /** Mark the pending step recorded under `key` done, seen by the CLI itself (the pull it waited for now works). */
251
+ export function markKeyedStepDone(root: string, key: string, now = new Date()): void {
252
+ const steps = readPersonSteps(root)
253
+ const step = steps.find((s) => s.key === key && s.status === 'pending')
254
+ if (!step) return
255
+ Object.assign(step, { status: 'done', doneAt: now.toISOString(), doneBy: 'check' })
256
+ writeSteps(root, steps)
257
+ }
258
+
211
259
  /** Take settled steps off the list; pending ones stay. */
212
260
  export function clearSettledSteps(root: string): number {
213
261
  const steps = readPersonSteps(root)
@@ -255,21 +303,73 @@ export function checkProblem(check: StepCheck, readOnly: readonly string[]): str
255
303
  return null
256
304
  }
257
305
 
306
+ /** Where a folder's link points (`targetOf`), and the profile that reaches it (`targetProfileName`). */
307
+ export interface StepTarget {
308
+ target: string
309
+ profile: string
310
+ }
311
+
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
316
+ }
317
+
318
+ /**
319
+ * Why a change request's step must not run, or null when it may: it was
320
+ * recorded for another target than the folder links to now, it names a profile
321
+ * that is not the folder's and no target to aim it again (a hand-written
322
+ * step), or its profile is gone. Each says how to go on: connect the folder,
323
+ * or dismiss the step. The request is applied already, so applying it again
324
+ * is refused; adding a profile by hand would aim the step at a profile
325
+ * nothing keeps in step with the link.
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`
330
+ }
331
+ if (!step.profile) return null
332
+ if (step.profile !== current?.profile) {
333
+ 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
+ if (!getProfile(step.profile)) {
336
+ return `its profile ${step.profile} is not on this computer: connect this folder again with \`frontera local sync connect\`, which records it, or dismiss this step`
337
+ }
338
+ return null
339
+ }
340
+
341
+ /** The command aimed at a profile: `--profile <profile>` added unless it names one already. */
342
+ export function atProfile(command: string, profile: string | null): string {
343
+ return !profile || /(?:^|\s)--profile(?:\s|=|$)/.test(command) ? command : `${command} --profile ${profile}`
344
+ }
345
+
258
346
  /**
259
347
  * Record what a request left for a person, as person-steps of its delivery
260
- * (the request's title). An item already listed (by its key) is updated, a
261
- * settled one stays settled, so applying a request again doesn't bring back
262
- * what was done.
348
+ * (the request's title). An item already listed (by its key) is updated. A
349
+ * settled one stays settled when the request is applied again to the same
350
+ * target, so that doesn't bring back what was done; applied to another
351
+ * target, it is pending again there. `at` is the request's target: its profile
352
+ * (`targetProfileName`), which each command and check names, since the folder
353
+ * is bound to its local runtime and the steps change the workspace the request
354
+ * was applied to, and the target itself, recorded on each step.
263
355
  */
264
- export function recordAfterApply(root: string, delivery: string, items: AfterApplyItem[], now = new Date()): PersonStep[] {
356
+ export function recordAfterApply(root: string, delivery: string, items: AfterApplyItem[], at: { profile: string; target: string } | null = null, now = new Date()): PersonStep[] {
357
+ const profile = at?.profile ?? null
265
358
  const steps = readPersonSteps(root)
266
359
  const out: PersonStep[] = []
267
- for (const item of items) {
360
+ for (const raw of items) {
361
+ const item = { ...raw, ...(raw.command ? { command: atProfile(raw.command, profile) } : {}), ...(raw.check ? { check: { ...raw.check, command: atProfile(raw.check.command, profile) } } : {}) }
268
362
  const credential = ((CREDENTIALS as readonly string[]).includes(item.credential) ? item.credential : 'person') as Credential
269
363
  const command = item.command && !commandProblem(item.command) ? item.command : null
270
364
  const existing = steps.find((s) => s.key === item.key && s.delivery === delivery)
271
365
  if (existing) {
272
- Object.assign(existing, { command, why: item.why, changes: item.label, credential, ...(item.check ? { check: item.check } : {}) })
366
+ if (at && existing.target && existing.target !== at.target) {
367
+ existing.status = 'pending'
368
+ delete existing.doneAt
369
+ delete existing.doneBy
370
+ delete existing.lastCheckedAt
371
+ }
372
+ Object.assign(existing, { command, why: item.why, changes: item.label, credential, ...(item.check ? { check: item.check } : {}), ...(at ? { target: at.target, profile: at.profile } : {}) })
273
373
  out.push(existing)
274
374
  continue
275
375
  }
@@ -285,6 +385,7 @@ export function recordAfterApply(root: string, delivery: string, items: AfterApp
285
385
  source: 'change-request',
286
386
  key: item.key,
287
387
  ...(item.check ? { check: item.check } : {}),
388
+ ...(at ? { target: at.target, profile: at.profile } : {}),
288
389
  }
289
390
  steps.push(step)
290
391
  out.push(step)
@@ -314,12 +415,13 @@ export async function runStepChecks(
314
415
  now = new Date(),
315
416
  ): Promise<{ checked: number; done: PersonStep[]; problems: Array<{ id: string; problem: string }> }> {
316
417
  const steps = readPersonSteps(root)
418
+ const current = currentTarget(root)
317
419
  const done: PersonStep[] = []
318
420
  const problems: Array<{ id: string; problem: string }> = []
319
421
  let checked = 0
320
422
  for (const step of steps) {
321
423
  if (step.status !== 'pending' || !step.check) continue
322
- const problem = checkProblem(step.check, readOnly)
424
+ const problem = staleProblem(step, current) ?? checkProblem(step.check, readOnly)
323
425
  if (problem) {
324
426
  problems.push({ id: step.id, problem })
325
427
  continue
@@ -48,7 +48,11 @@ export function slugify(name: string): string {
48
48
  }
49
49
 
50
50
  export function localProject(cwd: string): LocalProject {
51
- const root = findSolutionRoot(cwd)
51
+ return localProjectAt(findSolutionRoot(cwd))
52
+ }
53
+
54
+ /** The local project rooted at `root` itself, with no walk up. */
55
+ export function localProjectAt(root: string): LocalProject {
52
56
  const hash = createHash('sha256').update(root).digest('hex').slice(0, 8)
53
57
  return {
54
58
  root,
@@ -9,7 +9,9 @@ import {
9
9
  execInContainer,
10
10
  followLogs,
11
11
  imagePresent,
12
+ PullError,
12
13
  pullImage,
14
+ registryHost,
13
15
  removeContainer,
14
16
  runContainer,
15
17
  startContainer,
@@ -33,6 +35,7 @@ import {
33
35
  type LocalSecrets,
34
36
  } from './local-env'
35
37
  import { runtimeKeyCustodianProblem } from './local-key-custodian'
38
+ import { addPersonStep, markKeyedStepDone } from './local-person-steps'
36
39
  import { localEmbeddingsReachable, providerEnv } from './local-keys'
37
40
  import { applyProviderSetup, readProviderSetup, rememberSlugs, writeProviderSetup } from './local-providers'
38
41
  import type { PortBlock } from './local-ports'
@@ -325,9 +328,44 @@ export async function runSupervisor(project: LocalProject, ports: PortBlock, ses
325
328
 
326
329
  type SetFn = (name: ComponentName, patch: Partial<ComponentState>) => void
327
330
 
328
- async function ensureImage(image: string, project: LocalProject, component: ComponentName): Promise<void> {
329
- if (imagePresent(image)) return
330
- await pullImage(image, logPath(project, component))
331
+ /**
332
+ * Every image the runtime runs comes through here. A pull refused for want of
333
+ * access is the person's step, recorded without the agent choosing to:
334
+ * one per image repository, so a retry finds the same step, and done once an
335
+ * image from that repository is on the machine.
336
+ */
337
+ export async function ensureImage(image: string, project: LocalProject, component: ComponentName): Promise<void> {
338
+ const repository = image.replace(/:[^/:]*$/, '')
339
+ const key = `image-access:${repository}`
340
+ if (!imagePresent(image)) {
341
+ try {
342
+ await pullImage(image, logPath(project, component))
343
+ } catch (err) {
344
+ if (!(err instanceof PullError) || err.failure !== 'no-access') throw err
345
+ const host = registryHost(image)
346
+ const ghcr = host === 'ghcr.io'
347
+ try {
348
+ addPersonStep(project.root, {
349
+ key,
350
+ source: 'runtime',
351
+ command: null,
352
+ credential: 'registry-owner',
353
+ changes: ghcr ? `Read access for your GitHub account to the organization's package ${repository}` : `Pull access for your account on ${host} to ${repository}`,
354
+ why: [
355
+ `Docker is signed in to ${host}, and the registry still refused ${image}, so the local runtime can't start from it.`,
356
+ ghcr ? "Ask an organization owner to grant the GitHub account Docker signed in with read access to the organization's packages." : `Ask an owner of ${host} to grant the account Docker signed in with pull access.`,
357
+ `If the account already has access, its sign-in no longer works: sign Docker in to ${host} again.`,
358
+ 'The next `frontera local start --image` that pulls the image marks this step done. Without access, run from a platform checkout without --image.',
359
+ ].join(' '),
360
+ })
361
+ } catch {
362
+ // Not recorded (200 steps already pending): the refusal still says what to do.
363
+ throw err
364
+ }
365
+ throw new PullError('no-access', `${err.message}. This is the person's step; \`frontera local steps\` lists it`)
366
+ }
367
+ }
368
+ markKeyedStepDone(project.root, key)
331
369
  }
332
370
 
333
371
  /**
@@ -1,11 +1,15 @@
1
1
  import { spawnSync } from 'node:child_process'
2
+ import { createHash } from 'node:crypto'
2
3
  import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
3
4
  import { join } from 'node:path'
4
5
 
5
6
  import { parseDocument } from 'yaml'
6
7
 
8
+ import { credentialStore } from '../credential-store'
9
+ import { forgetDeviceSession } from '../device-login'
7
10
  import { writeFileAtomic } from '../fs-atomic'
8
11
  import { configDir } from '../paths'
12
+ import { deleteProfile, fingerprint, getProfile, putProfile } from '../profiles'
9
13
  import { PROJECT_FILES, type LocalProject } from './local-project'
10
14
 
11
15
  /**
@@ -161,3 +165,75 @@ export function removeSyncKey(project: LocalProject, role: KeyRole): void {
161
165
  delete keys[account(project, role)]
162
166
  writeFileAtomic(keysFile(), `${JSON.stringify(keys, null, 2)}\n`, 0o600)
163
167
  }
168
+
169
+ // ── The link's target as a CLI profile ──────────────────────────────────────
170
+
171
+ /**
172
+ * The CLI profile that reaches the linked workspace's platform with the
173
+ * project's own keys, one per folder and target: its name comes from the
174
+ * project (its folder), the platform and the workspace. The organization is
175
+ * left out, since a workspace already fixes it, so adding the organization
176
+ * key keeps the name. The project folder is bound to its local runtime, so a step
177
+ * that must change the workspace (a change request's after-apply steps, the
178
+ * read that checks them) names this profile with `--profile`; the remote guard
179
+ * lets a command that names its target through.
180
+ */
181
+ export function targetProfileName(project: LocalProject, link: SyncLink): string {
182
+ const target = [project.id, link.platform, link.workspace].join('\n')
183
+ return `sync-${createHash('sha256').update(target).digest('hex').slice(0, 16)}`
184
+ }
185
+
186
+ /** Where a recorded step acts, `<host>/<workspace>`: a step whose target is not the folder's link is stale. */
187
+ export const targetOf = (link: SyncLink) => `${new URL(link.platform).host}/${link.workspace}`
188
+
189
+ /** The fingerprints of the keys stored for the project now. */
190
+ export function projectKeyFingerprints(project: LocalProject): string[] {
191
+ return (['organization', 'workspace'] as const).flatMap((role) => {
192
+ const key = getSyncKey(project, role)
193
+ return key ? [fingerprint(key)] : []
194
+ })
195
+ }
196
+
197
+ /**
198
+ * Record the target profile from the keys stored for the project: the
199
+ * organization key when there is one (every step a change request leaves is
200
+ * at organization grain), else the workspace key. Null when no key is stored.
201
+ * The profile is the project's when it holds one of the project's keys, now
202
+ * or before `connect` replaced them (`previous`), and then takes the current
203
+ * key; one holding another key is left alone. The key is stored as every
204
+ * profile's is, so a machine without a secure store refuses it unless its
205
+ * person chose the 0600 file.
206
+ */
207
+ export async function registerTargetProfile(project: LocalProject, link: SyncLink, previous: readonly string[] = []): Promise<string | null> {
208
+ const organizationKey = getSyncKey(project, 'organization')
209
+ const key = organizationKey ?? getSyncKey(project, 'workspace')
210
+ if (!key) return null
211
+ const name = targetProfileName(project, link)
212
+ const existing = getProfile(name)
213
+ if (existing && ![...previous, ...projectKeyFingerprints(project)].includes(existing.fingerprint)) {
214
+ 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`)
215
+ }
216
+ await credentialStore().set(name, key)
217
+ const now = new Date().toISOString()
218
+ putProfile(name, {
219
+ apiUrl: link.platform,
220
+ credentialKind: organizationKey ? 'organization' : 'workspace',
221
+ workspaceId: organizationKey ? null : link.workspace,
222
+ orgId: link.organization ?? null,
223
+ fingerprint: fingerprint(key),
224
+ createdAt: existing?.createdAt ?? now,
225
+ lastVerifiedAt: now,
226
+ })
227
+ return name
228
+ }
229
+
230
+ /** 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. */
231
+ export async function removeTargetProfile(project: LocalProject, link: SyncLink): Promise<void> {
232
+ const name = targetProfileName(project, link)
233
+ const existing = getProfile(name)
234
+ if (existing && !projectKeyFingerprints(project).includes(existing.fingerprint)) return
235
+ if (existing) deleteProfile(name)
236
+ const store = credentialStore()
237
+ await store.delete(name)
238
+ await forgetDeviceSession(store, name, (url, init) => fetch(url, { ...init, signal: AbortSignal.timeout(5_000) }))
239
+ }
@@ -64,7 +64,7 @@ export function fixFor(check: Check): string | null {
64
64
  const ref = check.ref
65
65
  if (check.code === 'stale_base' || check.code === 'exists' || check.code === 'gone') return `pull it first (\`frontera local sync pull ${ref}\`), then propose`
66
66
  if (check.code === 'unpublished_draft') return 'publish or discard its draft on the platform, then fetch and propose'
67
- if (check.code === 'blueprint_backing') return 'leave it out: a dataset binding is set on the platform'
67
+ if (check.code === 'blueprint_backing') return 'make the type’s backing name a dataset the organization has, or add its datasets/<name>.json to this request, and map each field to one of its columns'
68
68
  if (check.code === 'blueprint_draft') return 'publish or discard the Blueprint’s draft on the platform, then propose'
69
69
  const model = /the model (\S+) is not in this workspace/.exec(check.message)
70
70
  if (model) return `choose the workspace model it goes as (\`frontera local sync map-model ${model[1]} <workspace model>\`)`
@@ -1055,6 +1055,13 @@ export function parseOutcome(raw: unknown): ApplyOutcome {
1055
1055
  const STEP_MARK: Record<StepStatus, string> = { done: '✓', failed: '✗', 'not-run': '·' }
1056
1056
  const BACK_WORD = { 'put-back': 'put back', failed: 'could not be put back', irreversible: 'cannot be withdrawn' } as const
1057
1057
 
1058
+ /** What failed: an item's title when the step is one item's, otherwise the step's own words ("Grant the workspace its new object types"). */
1059
+ export function failedName(outcome: ApplyOutcome, titles: Record<string, string> = {}): string {
1060
+ const failed = outcome.failure
1061
+ if (!failed) return 'a step'
1062
+ return titles[failed.ref] ?? outcome.steps.find((s) => s.key === failed.key)?.label ?? failed.ref
1063
+ }
1064
+
1058
1065
  /** Apply's steps, and after a failure what was put back, as `request apply` prints them. */
1059
1066
  export function describeOutcome(outcome: ApplyOutcome, titles: Record<string, string> = {}): string[] {
1060
1067
  const lines = outcome.steps.flatMap((s) => [
@@ -1063,9 +1070,8 @@ export function describeOutcome(outcome: ApplyOutcome, titles: Record<string, st
1063
1070
  ])
1064
1071
  if (outcome.ok) return [`Applied ${outcome.steps.length} step${outcome.steps.length === 1 ? '' : 's'}:`, ...lines]
1065
1072
  const failed = outcome.failure
1066
- const name = failed ? titles[failed.ref] ?? failed.ref : 'a step'
1067
1073
  return [
1068
- `Not applied: ${failed ? `${name} failed: ${failed.message}` : 'a step failed'}`,
1074
+ `Not applied: ${failed ? `${failedName(outcome, titles)} failed: ${failed.message}` : 'a step failed'}`,
1069
1075
  ...lines,
1070
1076
  ...(outcome.compensation.length
1071
1077
  ? ['', 'What was put back:', ...outcome.compensation.map((c) => ` ${c.label}: ${BACK_WORD[c.status]}${c.error ? ` (${c.error})` : ''}`)]
@@ -578,6 +578,22 @@ export async function agentOperatingConfig(
578
578
  return { config, notes }
579
579
  }
580
580
 
581
+ /**
582
+ * The Knowledge app's bases an Agent searches are platform settings, not part
583
+ * of its files: a local runtime has no Knowledge app, and a base belongs to
584
+ * one installation. Said, so what the Agent answers locally is not taken for
585
+ * what it answers on the platform. Nothing to say where the platform has no
586
+ * Knowledge app, the Agent searches none, or the route is not there.
587
+ */
588
+ export async function knowledgeBasesNote(source: SyncSource, agentId: string): Promise<string | null> {
589
+ const answer = await source.workspace.get(`/v1/config/agents/${encodeURIComponent(agentId)}/knowledge-bases`).catch(() => null)
590
+ const bases = answer ? one<{ bases?: Array<{ name?: unknown; searched?: unknown }> } | null>(answer)?.bases ?? [] : []
591
+ const names = bases.filter((base) => base.searched === true && typeof base.name === 'string').map((base) => `“${base.name as string}”`)
592
+ if (!names.length) return null
593
+ const which = names.length === 1 ? `the knowledge base ${names[0]}` : `${names.length} knowledge bases (${names.join(', ')})`
594
+ return `Searches ${which} from the Knowledge app on the platform. A local runtime has no Knowledge app, so here it answers without them: test this Agent’s Knowledge on the platform`
595
+ }
596
+
581
597
  /** An Agent's record (`/v1/config/agents/:id`): its published version, and its behavior settings, scrubbed. */
582
598
  interface AgentDetail { currentVersion?: { versionNumber?: number } | null; operatingConfig?: unknown }
583
599
 
@@ -632,6 +648,8 @@ async function readAgents(source: SyncSource, lists: Lists, directory: Directory
632
648
  it.notes.push(`Its Context and data couldn’t be read on the platform: ${context.unread}. Until it can be read, local dev gives it read access to the object types the project holds, as an adaptation, so locally it may read more than on the platform`)
633
649
  }
634
650
  if (bound.functions.length || bound.workflows.length || bound.blueprint?.length || bound.blueprintUnread) docs.push({ path: `agents/${row.agentId}/bindings.json`, doc: bound, format: 'json' })
651
+ const knowledgeBases = await knowledgeBasesNote(source, row.id)
652
+ if (knowledgeBases) it.notes.push(knowledgeBases)
635
653
  await nameUnknownAgents(source, directory, docs)
636
654
  out.push(finish(it, directory, docs))
637
655
  }