@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.
- package/package.json +4 -4
- package/src/api/apps-api.ts +2 -2
- package/src/api/blueprint-authoring-api.ts +19 -19
- package/src/api/governed-action-api.ts +78 -3
- package/src/api/platform-api.ts +20 -0
- package/src/blueprint/dataset-revision.ts +66 -0
- package/src/blueprint/diff.ts +134 -6
- package/src/blueprint/publish-plan.ts +253 -0
- package/src/blueprint/render.ts +19 -0
- package/src/commands/action/deploy.ts +143 -25
- package/src/commands/action/index-commands.ts +3 -3
- package/src/commands/action/list.ts +3 -3
- package/src/commands/action/prepare.ts +5 -3
- package/src/commands/action/retire.ts +78 -26
- package/src/commands/action/review.ts +86 -25
- package/src/commands/app/checks.ts +1 -1
- package/src/commands/app/deploy.ts +9 -9
- package/src/commands/app/deployments.ts +1 -1
- package/src/commands/app/dev.ts +79 -14
- package/src/commands/app/promote.ts +1 -1
- package/src/commands/app/pull.ts +4 -2
- package/src/commands/app/rollback.ts +1 -1
- package/src/commands/app/save.ts +3 -7
- package/src/commands/app/shared.ts +59 -2
- package/src/commands/app/unpin.ts +1 -1
- package/src/commands/app/versions.ts +1 -1
- package/src/commands/blueprint/authoring.ts +110 -24
- package/src/commands/blueprint/bind.ts +10 -46
- package/src/commands/blueprint/declarative.ts +124 -30
- package/src/commands/blueprint/editable.ts +3 -5
- package/src/commands/knowledge/index-commands.ts +15 -2
- package/src/commands/local/check.ts +21 -13
- package/src/commands/local/data.ts +1 -1
- package/src/commands/local/dev.ts +1 -21
- package/src/commands/local/index-commands.ts +1 -1
- package/src/commands/local/snapshot.ts +2 -2
- package/src/commands/local/steps.ts +11 -7
- package/src/commands/local/sync-request.ts +45 -8
- package/src/commands/local/sync.ts +23 -4
- package/src/commands/mcp-server/index-commands.ts +99 -11
- package/src/commands/registry.ts +1 -1
- package/src/commands/types.ts +2 -0
- package/src/device-login.ts +19 -1
- package/src/exit.ts +5 -0
- package/src/flag-help.ts +2 -1
- package/src/forge/permission-rules.ts +2 -0
- package/src/local/local-actions.ts +6 -5
- package/src/local/local-app-runtime.ts +6 -4
- package/src/local/local-blueprint-schemas.ts +2 -2
- package/src/local/local-build-inputs.ts +2 -1
- package/src/local/local-db-snapshot.ts +62 -46
- package/src/local/local-dev-references.ts +0 -23
- package/src/local/local-docker.ts +84 -10
- package/src/local/local-mcp-servers.ts +79 -14
- package/src/local/local-person-steps.ts +121 -19
- package/src/local/local-project.ts +5 -1
- package/src/local/local-supervisor.ts +41 -3
- package/src/local/local-sync-link.ts +76 -0
- package/src/local/local-sync-precheck.ts +1 -1
- package/src/local/local-sync-push.ts +8 -2
- package/src/local/local-sync-remote.ts +18 -0
- package/src/main.ts +7 -1
- package/src/mcp/mcp-apps.ts +96 -20
- package/src/mcp/tools/agent-tools.ts +29 -7
- package/src/mcp/tools/app-tools.ts +3 -3
- package/src/mcp/tools/project-tools.ts +2 -1
- package/src/project.ts +47 -11
- package/src/remote-guard.ts +4 -1
- package/src/templates/next-app-files.ts +4 -1
- package/src/templates/next-skills.ts +9 -5
- package/src/vendor/kit-assets.json +8 -8
- 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;
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 {
|
|
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,
|
|
71
|
-
source?: 'agent' | 'change-request'
|
|
72
|
-
/** A change request's own key for it (`action-deploy:qualifyLead`), so
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
261
|
-
* settled one stays settled
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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 '
|
|
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 ? `${
|
|
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
|
}
|