@frontera-sdk/cli 1.53.5 → 1.53.6
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/platform-api.ts +20 -0
- 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/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/index-commands.ts +1 -1
- package/src/commands/local/snapshot.ts +2 -2
- package/src/commands/local/sync-request.ts +17 -3
- package/src/commands/local/sync.ts +4 -1
- package/src/commands/mcp-server/index-commands.ts +99 -11
- package/src/commands/registry.ts +1 -1
- package/src/flag-help.ts +2 -1
- package/src/forge/permission-rules.ts +2 -0
- package/src/local/local-app-runtime.ts +6 -4
- package/src/local/local-build-inputs.ts +2 -1
- package/src/local/local-db-snapshot.ts +62 -46
- package/src/local/local-docker.ts +84 -10
- package/src/local/local-mcp-servers.ts +79 -14
- package/src/local/local-person-steps.ts +29 -11
- package/src/local/local-project.ts +5 -1
- package/src/local/local-supervisor.ts +41 -3
- package/src/local/local-sync-remote.ts +18 -0
- package/src/main.ts +6 -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 +6 -6
- package/src/vendor/sdk-sources.json +17 -17
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process'
|
|
2
2
|
import { openSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* The container runtime, through its CLI.
|
|
@@ -67,6 +69,80 @@ export function imagePresent(image: string): boolean {
|
|
|
67
69
|
return docker(['image', 'inspect', image, '--format', '{{.Id}}']).ok
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
/** The registry an image comes from: its first part when that names a host, else Docker Hub. */
|
|
73
|
+
export function registryHost(image: string): string {
|
|
74
|
+
const first = image.split('/')[0]!
|
|
75
|
+
return image.includes('/') && /[.:]|^localhost$/.test(first) ? first : 'docker.io'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Docker Hub answers to three names, and its sign-in is kept under `https://index.docker.io/v1/`. */
|
|
79
|
+
const DOCKER_HUB = new Set(['docker.io', 'index.docker.io', 'registry-1.docker.io'])
|
|
80
|
+
const sameRegistry = (host: string) => (DOCKER_HUB.has(host) ? 'docker.io' : host)
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether Docker holds a sign-in for a registry host, read as Docker reads it:
|
|
84
|
+
* `config.json` in `$DOCKER_CONFIG` (else `~/.docker`), then the host's
|
|
85
|
+
* credential helper or the default store, asked for its `list` (addresses and
|
|
86
|
+
* user names, never a secret), else the file's own `auths`. False when none
|
|
87
|
+
* is found or it can't be read; nothing here prompts.
|
|
88
|
+
*/
|
|
89
|
+
export function dockerSignedIn(host: string): boolean {
|
|
90
|
+
let config: { auths?: Record<string, { auth?: string; identitytoken?: string } | null>; credHelpers?: Record<string, string>; credsStore?: string }
|
|
91
|
+
try {
|
|
92
|
+
config = JSON.parse(readFileSync(join(process.env.DOCKER_CONFIG || join(homedir(), '.docker'), 'config.json'), 'utf8'))
|
|
93
|
+
} catch {
|
|
94
|
+
return false
|
|
95
|
+
}
|
|
96
|
+
const isHost = (address: string) => sameRegistry(address.replace(/^https?:\/\//, '').split('/')[0]!) === sameRegistry(host)
|
|
97
|
+
const helper = Object.entries(config.credHelpers ?? {}).find(([address]) => isHost(address))?.[1] ?? config.credsStore
|
|
98
|
+
if (!helper) return Object.entries(config.auths ?? {}).some(([address, entry]) => isHost(address) && !!(entry?.auth || entry?.identitytoken))
|
|
99
|
+
// A name with a slash would run a program by path (`../evil` from the folder), not a helper on PATH.
|
|
100
|
+
if (!/^[A-Za-z0-9._-]+$/.test(helper)) return false
|
|
101
|
+
const listed = spawnSync(`docker-credential-${helper}`, ['list'], { input: 'unused', encoding: 'utf8', timeout: 5_000 })
|
|
102
|
+
if (listed.status !== 0) return false
|
|
103
|
+
try {
|
|
104
|
+
return Object.keys(JSON.parse(listed.stdout) as Record<string, string>).some(isHost)
|
|
105
|
+
} catch {
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Why a pull failed, decided once from its output and Docker's sign-in for
|
|
112
|
+
* the registry. `signed-out`: refused, with no sign-in Docker shows, so
|
|
113
|
+
* signing in is the fix. `no-access`: refused although Docker is signed in,
|
|
114
|
+
* so only someone who grants access can clear it. `missing`: the image or
|
|
115
|
+
* tag is not there. `other`: anything else, in the log.
|
|
116
|
+
*/
|
|
117
|
+
export type PullFailure = 'signed-out' | 'no-access' | 'missing' | 'other'
|
|
118
|
+
|
|
119
|
+
export class PullError extends DockerError {
|
|
120
|
+
constructor(
|
|
121
|
+
readonly failure: PullFailure,
|
|
122
|
+
message: string,
|
|
123
|
+
) {
|
|
124
|
+
super(message)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function pullFailureMessage(failure: PullFailure, image: string, code: number | null, logFile: string): string {
|
|
129
|
+
const host = registryHost(image)
|
|
130
|
+
switch (failure) {
|
|
131
|
+
case 'signed-out':
|
|
132
|
+
return host === 'ghcr.io'
|
|
133
|
+
? `the registry refused access to ${image}: run \`gh auth refresh -s read:packages\`, then \`gh auth token | docker login ghcr.io -u <github-user> --password-stdin\`, or run from a platform checkout without --image`
|
|
134
|
+
: `the registry refused access to ${image}: sign in with \`docker login ${host}\`, or run from a platform checkout without --image`
|
|
135
|
+
case 'no-access':
|
|
136
|
+
return host === 'ghcr.io'
|
|
137
|
+
? `the registry refused ${image}, though Docker is signed in to ghcr.io. The GitHub account it signed in with has no read access to the organization's packages, or its sign-in no longer works. Ask an organization owner to grant that account read access, or run from a platform checkout without --image`
|
|
138
|
+
: `the registry refused ${image}, though Docker is signed in to ${host}. The account it signed in with may not pull it, or its sign-in no longer works. Ask an owner of ${host} to grant that account pull access, or run from a platform checkout without --image`
|
|
139
|
+
case 'missing':
|
|
140
|
+
return `${image} is not in the registry: check --registry and --version, or push that release there first`
|
|
141
|
+
case 'other':
|
|
142
|
+
return `docker pull ${image} failed (exit ${code}); see ${logFile}`
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
70
146
|
/** Pull an image, writing progress to a log file instead of the terminal. */
|
|
71
147
|
export function pullImage(image: string, logFile: string): Promise<void> {
|
|
72
148
|
return new Promise((resolvePromise, reject) => {
|
|
@@ -75,23 +151,21 @@ export function pullImage(image: string, logFile: string): Promise<void> {
|
|
|
75
151
|
child.on('error', reject)
|
|
76
152
|
child.on('exit', (code) => {
|
|
77
153
|
if (code === 0) return resolvePromise()
|
|
78
|
-
// The private release images are the usual failure: say what grants access.
|
|
79
154
|
let tail = ''
|
|
80
155
|
try {
|
|
81
156
|
tail = readFileSync(logFile, 'utf8').slice(-2000)
|
|
82
157
|
} catch {
|
|
83
158
|
// The log is a convenience; the exit code is the fact.
|
|
84
159
|
}
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
: `the registry refused access to ${image}: sign in with \`docker login ${host}\`, or run from a platform checkout without --image`
|
|
160
|
+
// The words don't tell no sign-in from a sign-in without access (ghcr.io answers `denied` to a token that no longer works); Docker's own sign-in does.
|
|
161
|
+
const failure: PullFailure = /unauthorized|denied|authentication required/i.test(tail)
|
|
162
|
+
? dockerSignedIn(registryHost(image))
|
|
163
|
+
? 'no-access'
|
|
164
|
+
: 'signed-out'
|
|
91
165
|
: /manifest unknown|not found/i.test(tail)
|
|
92
|
-
?
|
|
93
|
-
:
|
|
94
|
-
reject(new
|
|
166
|
+
? 'missing'
|
|
167
|
+
: 'other'
|
|
168
|
+
reject(new PullError(failure, pullFailureMessage(failure, image, code, logFile)))
|
|
95
169
|
})
|
|
96
170
|
})
|
|
97
171
|
}
|
|
@@ -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
|
}
|
|
@@ -25,7 +25,7 @@ import { writeFileAtomic } from '../fs-atomic'
|
|
|
25
25
|
export const PERSON_STEPS_FILE = join('.frontera', 'sync', 'person-steps.json')
|
|
26
26
|
|
|
27
27
|
/** Who or what the step needs. */
|
|
28
|
-
export const CREDENTIALS = ['workspace-key', 'organization-key', 'steward', 'admin', 'second-person', 'person'] as const
|
|
28
|
+
export const CREDENTIALS = ['workspace-key', 'organization-key', 'steward', 'admin', 'second-person', 'person', 'registry-owner'] as const
|
|
29
29
|
export type Credential = (typeof CREDENTIALS)[number]
|
|
30
30
|
|
|
31
31
|
/** Each credential in the person's words, as the card and the list say it. */
|
|
@@ -36,6 +36,7 @@ export const CREDENTIAL_WORDS: Record<Credential, string> = {
|
|
|
36
36
|
admin: 'a workspace admin',
|
|
37
37
|
'second-person': 'a second person (not whoever made the change)',
|
|
38
38
|
person: 'you, signed in (`frontera auth login --browser`)',
|
|
39
|
+
'registry-owner': 'an owner of the organization that publishes the runtime images',
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
/**
|
|
@@ -67,9 +68,9 @@ export interface PersonStep {
|
|
|
67
68
|
doneAt?: string
|
|
68
69
|
/** Who saw it done: the person's mark, or its check. */
|
|
69
70
|
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
|
|
71
|
+
/** 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. */
|
|
72
|
+
source?: 'agent' | 'change-request' | 'runtime'
|
|
73
|
+
/** 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
74
|
key?: string
|
|
74
75
|
check?: StepCheck
|
|
75
76
|
lastCheckedAt?: string
|
|
@@ -133,19 +134,23 @@ function writeSteps(root: string, steps: PersonStep[]): void {
|
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
export interface NewPersonStep {
|
|
136
|
-
|
|
137
|
+
/** null for a step done outside the CLI, which only the CLI itself records. */
|
|
138
|
+
command: string | null
|
|
137
139
|
why: string
|
|
138
140
|
changes: string
|
|
139
141
|
credential: string
|
|
140
142
|
delivery?: string | null
|
|
141
143
|
/** The read that proves it done, already checked (`checkProblem`). */
|
|
142
144
|
check?: StepCheck
|
|
145
|
+
/** The CLI's own step, under a stable key: recording it again updates that step, and reopens it if it was settled. */
|
|
146
|
+
key?: string
|
|
147
|
+
source?: 'agent' | 'runtime'
|
|
143
148
|
}
|
|
144
149
|
|
|
145
150
|
/** Each problem with a step as given; empty when it can be recorded. */
|
|
146
151
|
export function stepProblems(input: NewPersonStep): string[] {
|
|
147
152
|
const problems: string[] = []
|
|
148
|
-
const command = commandProblem(input.command)
|
|
153
|
+
const command = input.command === null ? null : commandProblem(input.command)
|
|
149
154
|
if (command) problems.push(command)
|
|
150
155
|
if (!input.why?.trim()) problems.push('say why the step is needed (`why`)')
|
|
151
156
|
if (!input.changes?.trim()) problems.push('say what the step changes (`changes`)')
|
|
@@ -160,17 +165,20 @@ export function stepProblems(input: NewPersonStep): string[] {
|
|
|
160
165
|
/**
|
|
161
166
|
* Record a step. The same command pending in the same delivery is the same
|
|
162
167
|
* step, updated rather than listed twice, so an agent restating its plan
|
|
163
|
-
* after a compaction doesn't double the card.
|
|
168
|
+
* after a compaction doesn't double the card. A keyed step is one step
|
|
169
|
+
* whatever its state: a refusal seen again is pending again.
|
|
164
170
|
*/
|
|
165
171
|
export function addPersonStep(root: string, input: NewPersonStep, now = new Date()): { step: PersonStep; updated: boolean } {
|
|
166
172
|
const problems = stepProblems(input)
|
|
167
173
|
if (problems.length) throw new Error(problems.join('; '))
|
|
168
174
|
const steps = readPersonSteps(root)
|
|
169
|
-
const command = input.command.trim().replace(/\s+/g, ' ')
|
|
175
|
+
const command = input.command === null ? null : input.command.trim().replace(/\s+/g, ' ')
|
|
170
176
|
const delivery = input.delivery?.trim() || null
|
|
171
|
-
const same = steps.find((s) => s.status === 'pending' && s.command === command && s.delivery === delivery)
|
|
177
|
+
const same = input.key ? steps.find((s) => s.key === input.key) : steps.find((s) => s.status === 'pending' && s.command === command && s.delivery === delivery)
|
|
172
178
|
if (same) {
|
|
173
|
-
Object.assign(same, { why: input.why.trim(), changes: input.changes.trim(), credential: input.credential as Credential, ...(input.check ? { check: input.check } : {}) })
|
|
179
|
+
Object.assign(same, { why: input.why.trim(), changes: input.changes.trim(), credential: input.credential as Credential, status: 'pending', ...(input.check ? { check: input.check } : {}) })
|
|
180
|
+
delete same.doneAt
|
|
181
|
+
delete same.doneBy
|
|
174
182
|
writeSteps(root, steps)
|
|
175
183
|
return { step: same, updated: true }
|
|
176
184
|
}
|
|
@@ -184,7 +192,8 @@ export function addPersonStep(root: string, input: NewPersonStep, now = new Date
|
|
|
184
192
|
delivery,
|
|
185
193
|
status: 'pending',
|
|
186
194
|
createdAt: now.toISOString(),
|
|
187
|
-
source: 'agent',
|
|
195
|
+
source: input.source ?? 'agent',
|
|
196
|
+
...(input.key ? { key: input.key } : {}),
|
|
188
197
|
...(input.check ? { check: input.check } : {}),
|
|
189
198
|
}
|
|
190
199
|
writeSteps(root, [...steps, step])
|
|
@@ -208,6 +217,15 @@ export function settlePersonStep(root: string, id: string, status: 'done' | 'dis
|
|
|
208
217
|
return step
|
|
209
218
|
}
|
|
210
219
|
|
|
220
|
+
/** Mark the pending step recorded under `key` done, seen by the CLI itself (the pull it waited for now works). */
|
|
221
|
+
export function markKeyedStepDone(root: string, key: string, now = new Date()): void {
|
|
222
|
+
const steps = readPersonSteps(root)
|
|
223
|
+
const step = steps.find((s) => s.key === key && s.status === 'pending')
|
|
224
|
+
if (!step) return
|
|
225
|
+
Object.assign(step, { status: 'done', doneAt: now.toISOString(), doneBy: 'check' })
|
|
226
|
+
writeSteps(root, steps)
|
|
227
|
+
}
|
|
228
|
+
|
|
211
229
|
/** Take settled steps off the list; pending ones stay. */
|
|
212
230
|
export function clearSettledSteps(root: string): number {
|
|
213
231
|
const steps = readPersonSteps(root)
|
|
@@ -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
|
/**
|
|
@@ -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
|
}
|
package/src/main.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { EXIT, sessionLaneFailure, sessionLaneRefusal, type ExitCode } from './e
|
|
|
9
9
|
import { actAsLocalPersona, listPersonas, localPersonaHint, redact } from './local/local-personas'
|
|
10
10
|
import { localRuntimeAt } from './local/local-runtime-target'
|
|
11
11
|
import { acquireLock, lockRefusal, locksRuntime, projectStateDir, recordWrite, writeCommand } from './local/local-writers'
|
|
12
|
+
import { tokenFromStdin } from './mcp/mcp-apps'
|
|
12
13
|
import { resolveOrganization, setSelectedOrganization } from './organization'
|
|
13
14
|
import { canPrompt, promptConfirm } from './prompt'
|
|
14
15
|
import { remoteFromLocalProject, remoteWords } from './remote-guard'
|
|
@@ -228,6 +229,10 @@ async function main(): Promise<number> {
|
|
|
228
229
|
// project are always read from one place. Two directories would mean
|
|
229
230
|
// `--dir ../other-customer` could edit one customer's app with another
|
|
230
231
|
// customer's key.
|
|
232
|
+
//
|
|
233
|
+
// The `frontera app dev` that `--detach` starts takes its caller's key on stdin, used as FRONTERA_TOKEN
|
|
234
|
+
// would be, so it is never in the environment the App's own server inherits.
|
|
235
|
+
const piped = await tokenFromStdin()
|
|
231
236
|
let credential = command.meta.offline
|
|
232
237
|
? { apiUrl: typeof flags['api-url'] === 'string' ? flags['api-url'] : (process.env.FRONTERA_API_URL ?? ''), token: '' }
|
|
233
238
|
: await resolveCredential(
|
|
@@ -237,7 +242,7 @@ async function main(): Promise<number> {
|
|
|
237
242
|
// In a local project, an organization command takes the runtime's organization key.
|
|
238
243
|
organization: commandScope(command.meta) === 'organization' && command.meta.noun !== 'auth' && command.meta.noun !== 'kit',
|
|
239
244
|
},
|
|
240
|
-
{ cwd },
|
|
245
|
+
{ cwd, ...(piped ? { env: { ...process.env, FRONTERA_TOKEN: piped } } : {}) },
|
|
241
246
|
)
|
|
242
247
|
// A command that acts as a person takes the browser sign-in (`auth login --browser`) over the
|
|
243
248
|
// profile's key, which it would refuse; nothing else does. A raw FRONTERA_TOKEN still wins.
|