@frontera-sdk/cli 1.44.1 → 1.45.1
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/README.md +65 -1
- package/package.json +4 -3
- package/src/api/automation-api.ts +15 -0
- package/src/api/dataset-api.ts +99 -0
- package/src/api/governed-action-api.ts +80 -0
- package/src/api/platform-api.ts +293 -0
- package/src/auth-verify.ts +105 -0
- package/src/binding-registry.ts +87 -0
- package/src/commands/action/deploy.ts +1 -0
- package/src/commands/action/grant.ts +1 -0
- package/src/commands/action/index-commands.ts +8 -0
- package/src/commands/action/prepare.ts +1 -0
- package/src/commands/action/requests.ts +111 -0
- package/src/commands/action/review.ts +1 -0
- package/src/commands/agent/index-commands.ts +189 -7
- package/src/commands/app/init.ts +1 -1
- package/src/commands/app/pull.ts +1 -1
- package/src/commands/auth/add.ts +145 -0
- package/src/commands/auth/current.ts +82 -0
- package/src/commands/auth/index-commands.ts +16 -0
- package/src/commands/auth/list.ts +71 -0
- package/src/commands/auth/remove.ts +80 -0
- package/src/commands/auth/use.ts +84 -0
- package/src/commands/auth/verify.ts +93 -0
- package/src/commands/automation/run.ts +41 -2
- package/src/commands/blueprint/query.ts +294 -0
- package/src/commands/capability/index-commands.ts +334 -0
- package/src/commands/dataset/index-commands.ts +103 -14
- package/src/commands/kit/doctor.ts +101 -0
- package/src/commands/kit/index-commands.ts +7 -0
- package/src/commands/kit/shared.ts +52 -0
- package/src/commands/kit/status.ts +92 -0
- package/src/commands/kit/sync.ts +106 -0
- package/src/commands/kit/vendor.ts +120 -0
- package/src/commands/knowledge/index-commands.ts +165 -0
- package/src/commands/login.ts +64 -84
- package/src/commands/plugin/index-commands.ts +284 -21
- package/src/commands/registry.ts +104 -1
- package/src/commands/setup.ts +248 -0
- package/src/commands/source/index-commands.ts +446 -0
- package/src/commands/types.ts +14 -0
- package/src/config.ts +197 -100
- package/src/credential-store.ts +273 -0
- package/src/dev-env.ts +3 -3
- package/src/exit.ts +29 -2
- package/src/flag-help.ts +65 -3
- package/src/fs-atomic.ts +44 -0
- package/src/harness.ts +155 -4
- package/src/kit.ts +431 -0
- package/src/main.ts +13 -1
- package/src/paths.ts +43 -0
- package/src/profile-migration.ts +101 -0
- package/src/profiles.ts +240 -0
- package/src/project-context.ts +178 -0
- package/src/prompt.ts +23 -0
- package/src/templates/next-app-files.ts +4 -1
- package/src/vendor/kit-assets.json +60 -0
- package/src/vendor/sdk-sources.json +1 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { PlatformApi } from './api/platform-api'
|
|
2
|
+
import { CliError, UsageError } from './errors'
|
|
3
|
+
import type { CredentialKind } from './profiles'
|
|
4
|
+
|
|
5
|
+
export interface VerifiedKey {
|
|
6
|
+
kind: CredentialKind
|
|
7
|
+
workspaceId: string | null
|
|
8
|
+
orgId: string | null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Two credential kinds reach this CLI, and they are not interchangeable:
|
|
13
|
+
*
|
|
14
|
+
* sk-ws- a WORKSPACE key — one workspace's slice of the platform.
|
|
15
|
+
* sk-org- an ORGANIZATION key — the organization-level Blueprint draft,
|
|
16
|
+
* which no workspace credential can reach, because that draft is
|
|
17
|
+
* shared by every workspace in the organization.
|
|
18
|
+
*
|
|
19
|
+
* Both are stored the same way; only the verification differs, because an
|
|
20
|
+
* organization key belongs to no workspace and `whoami` has none to report.
|
|
21
|
+
*/
|
|
22
|
+
export function classifyKey(token: string): CredentialKind {
|
|
23
|
+
if (token.startsWith('sk-ws-')) return 'workspace'
|
|
24
|
+
if (token.startsWith('sk-org-')) return 'organization'
|
|
25
|
+
throw new UsageError(
|
|
26
|
+
'that does not look like a Frontera key',
|
|
27
|
+
'workspace keys start with sk-ws- (Workspace settings → API Keys); '
|
|
28
|
+
+ 'organization keys start with sk-org- (Settings → API keys)',
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Prove the key works BEFORE it is stored.
|
|
34
|
+
*
|
|
35
|
+
* Storing an unusable credential only moves the failure to the next command,
|
|
36
|
+
* where the cause is no longer obvious — and the check costs one request that
|
|
37
|
+
* also reports which workspace the key just bound to.
|
|
38
|
+
*/
|
|
39
|
+
export async function verifyKey(apiUrl: string, token: string): Promise<VerifiedKey> {
|
|
40
|
+
const kind = classifyKey(token)
|
|
41
|
+
|
|
42
|
+
if (kind === 'workspace') {
|
|
43
|
+
const me = await new PlatformApi(apiUrl, token).whoami().catch(() => null)
|
|
44
|
+
if (!me?.workspaceId) {
|
|
45
|
+
throw new CliError('the token was rejected by this API origin', {
|
|
46
|
+
code: 'UNAUTHORIZED',
|
|
47
|
+
hint: `check the key is enabled, and that the origin is right (currently ${apiUrl})`,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
return { kind, workspaceId: me.workspaceId, orgId: me.orgId ?? null }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Organization keys verify against an organization-scoped read, because
|
|
55
|
+
* `/v1/whoami` refuses them: it performs no permission check, and the org-key
|
|
56
|
+
* lane default-denies any route that neither checks a permission nor declares
|
|
57
|
+
* itself a read.
|
|
58
|
+
*
|
|
59
|
+
* The subtlety is that 200 is NOT the only success. An organization that has
|
|
60
|
+
* never run `blueprint adopt` has no lifecycle row, so this route answers 404
|
|
61
|
+
* for a perfectly valid key — requiring 2xx would reject the very keys someone
|
|
62
|
+
* adds in order to run `adopt`.
|
|
63
|
+
*
|
|
64
|
+
* So the question asked is "did a FRONTERA API answer this", not "did it
|
|
65
|
+
* answer 200". A Frontera error carries the service's envelope
|
|
66
|
+
* (`{ error: true, code, message }`); a typo'd host's 404 page or a 500 from
|
|
67
|
+
* something else does not. Without that distinction any non-401 reply counted
|
|
68
|
+
* as proof, so `auth add --api-url <typo>` sent the key to that host and then
|
|
69
|
+
* stored it as verified.
|
|
70
|
+
*/
|
|
71
|
+
const lifecycle = await fetch(`${apiUrl}/v1/blueprint/lifecycle`, {
|
|
72
|
+
headers: { authorization: `Bearer ${token}` },
|
|
73
|
+
}).catch(() => null)
|
|
74
|
+
|
|
75
|
+
if (!lifecycle) {
|
|
76
|
+
throw new CliError(`could not reach ${apiUrl}`, {
|
|
77
|
+
code: 'SERVICE_UNAVAILABLE',
|
|
78
|
+
hint: 'check the origin and your network, then try again',
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
if (lifecycle.status === 401 || lifecycle.status === 403) {
|
|
82
|
+
throw new CliError('the organization key was rejected by this API origin', {
|
|
83
|
+
code: 'UNAUTHORIZED',
|
|
84
|
+
hint: `check the key is active and carries organization:update, and that the origin is right (currently ${apiUrl})`,
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
if (!lifecycle.ok && !(await looksLikeFronteraError(lifecycle))) {
|
|
88
|
+
throw new CliError(`${apiUrl} did not answer like a Frontera API`, {
|
|
89
|
+
code: 'BAD_REQUEST',
|
|
90
|
+
hint: `check --api-url for a typo — the key was NOT stored (this origin answered ${lifecycle.status})`,
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { kind, workspaceId: null, orgId: null }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The service's error envelope — `{ error: true, code, message }`. */
|
|
98
|
+
async function looksLikeFronteraError(response: Response): Promise<boolean> {
|
|
99
|
+
try {
|
|
100
|
+
const body = (await response.json()) as { error?: unknown; code?: unknown }
|
|
101
|
+
return body?.error === true && typeof body.code === 'string'
|
|
102
|
+
} catch {
|
|
103
|
+
return false
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { writeFileAtomic } from './fs-atomic'
|
|
5
|
+
import { configDir, type Env } from './paths'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Which directories THIS machine bound to a profile, and to which one.
|
|
9
|
+
*
|
|
10
|
+
* `.frontera/context.json` is discovered by walking upward, and the walk cannot
|
|
11
|
+
* tell who wrote the file it finds. A repository that COMMITS one selects a real
|
|
12
|
+
* profile — `default` is the name every migrated machine ends up with — and
|
|
13
|
+
* therefore a real key and a real origin, for every `frontera` command run
|
|
14
|
+
* inside that checkout. Nothing is printed and nothing is asked.
|
|
15
|
+
*
|
|
16
|
+
* The file holds a name rather than a token, which bounds the damage to
|
|
17
|
+
* deployments the user already has keys for. It does not close the case that
|
|
18
|
+
* matters: `frontera app pull` in a cloned repository writes a customer's source
|
|
19
|
+
* into that repository's tree, and the write paths write to the customer's
|
|
20
|
+
* deployment.
|
|
21
|
+
*
|
|
22
|
+
* So selection is answered by the context file and PROVENANCE is answered here.
|
|
23
|
+
* A binding this machine did not make is not an answer; it is a question, and
|
|
24
|
+
* `frontera auth use` is how the person answers it.
|
|
25
|
+
*
|
|
26
|
+
* Recording a path, not a secret — and only paths the user explicitly bound.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export interface Binding {
|
|
30
|
+
profile: string
|
|
31
|
+
boundAt: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface BindingFile {
|
|
35
|
+
schemaVersion: 1
|
|
36
|
+
roots: Record<string, Binding>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function bindingsPath(env: Env): string {
|
|
40
|
+
return join(configDir(env), 'bindings.json')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Resolved, so `/tmp` and `/private/tmp` are not two different bindings. */
|
|
44
|
+
function key(root: string): string {
|
|
45
|
+
return resolve(root)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function read(env: Env): BindingFile {
|
|
49
|
+
const path = bindingsPath(env)
|
|
50
|
+
if (!existsSync(path)) return { schemaVersion: 1, roots: {} }
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as BindingFile
|
|
53
|
+
return { schemaVersion: 1, roots: parsed.roots ?? {} }
|
|
54
|
+
} catch {
|
|
55
|
+
// Unlike the credential file, losing this costs no secret and no
|
|
56
|
+
// configuration — the worst case is that `auth use` must be re-run, which
|
|
57
|
+
// is the safe direction to fail in.
|
|
58
|
+
return { schemaVersion: 1, roots: {} }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function recordBinding(root: string, profile: string, env: Env = process.env): void {
|
|
63
|
+
const file = read(env)
|
|
64
|
+
file.roots[key(root)] = { profile, boundAt: new Date().toISOString() }
|
|
65
|
+
writeFileAtomic(bindingsPath(env), `${JSON.stringify(file, null, 2)}\n`, 0o600)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function bindingFor(root: string, env: Env = process.env): Binding | null {
|
|
69
|
+
return read(env).roots[key(root)] ?? null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function forgetBinding(root: string, env: Env = process.env): void {
|
|
73
|
+
const file = read(env)
|
|
74
|
+
if (!(key(root) in file.roots)) return
|
|
75
|
+
delete file.roots[key(root)]
|
|
76
|
+
writeFileAtomic(bindingsPath(env), `${JSON.stringify(file, null, 2)}\n`, 0o600)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Did this machine bind this root to this profile?
|
|
81
|
+
*
|
|
82
|
+
* The profile must match too. A committed context that edits the NAME inside a
|
|
83
|
+
* directory the user did bind is the same attack with an extra step.
|
|
84
|
+
*/
|
|
85
|
+
export function isTrustedBinding(root: string, profile: string, env: Env = process.env): boolean {
|
|
86
|
+
return bindingFor(root, env)?.profile === profile
|
|
87
|
+
}
|
|
@@ -23,6 +23,7 @@ export const actionDeploy: Command = {
|
|
|
23
23
|
meta: {
|
|
24
24
|
noun: 'action',
|
|
25
25
|
verb: 'deploy',
|
|
26
|
+
authLane: 'session',
|
|
26
27
|
args: [{ name: 'action', required: true, description: 'The published Action’s API name' }],
|
|
27
28
|
flags: { 'dry-run': 'boolean', 'plan-id': 'string', 'binding-id': 'string' },
|
|
28
29
|
summary: 'Build and validate the write path for a published Action',
|
|
@@ -2,6 +2,7 @@ import { actionDeploy } from './deploy'
|
|
|
2
2
|
import { actionGrant } from './grant'
|
|
3
3
|
import { actionList } from './list'
|
|
4
4
|
import { actionPrepare } from './prepare'
|
|
5
|
+
import { actionRequests } from './requests'
|
|
5
6
|
import { actionReview } from './review'
|
|
6
7
|
import type { Command } from '../types'
|
|
7
8
|
|
|
@@ -19,9 +20,16 @@ import type { Command } from '../types'
|
|
|
19
20
|
* non-member principal — `wskey:…` and `orgkey:…` — and the invoke check joins
|
|
20
21
|
* organization membership, so a `submit` verb here would refuse every call it
|
|
21
22
|
* ever made. It belongs to a credential that represents a person.
|
|
23
|
+
*
|
|
24
|
+
* READING those invocations is not the same boundary, and `requests` is here:
|
|
25
|
+
* the list gates on `actionRequest: ['read']` rather than on membership. The
|
|
26
|
+
* distinction matters because an armed path that fails every request looks
|
|
27
|
+
* exactly like one nothing has tried, and until now nothing in the CLI could
|
|
28
|
+
* tell the two apart.
|
|
22
29
|
*/
|
|
23
30
|
export const actionCommands: readonly Command[] = [
|
|
24
31
|
actionList,
|
|
32
|
+
actionRequests,
|
|
25
33
|
actionPrepare,
|
|
26
34
|
actionDeploy,
|
|
27
35
|
actionReview,
|
|
@@ -17,6 +17,7 @@ export const actionPrepare: Command = {
|
|
|
17
17
|
meta: {
|
|
18
18
|
noun: 'action',
|
|
19
19
|
verb: 'prepare',
|
|
20
|
+
authLane: 'session',
|
|
20
21
|
args: [{ name: 'action', required: true, description: 'The drafted Action’s API name' }],
|
|
21
22
|
flags: { reason: 'string' },
|
|
22
23
|
summary: 'Record that a drafted Action ships disabled, so it can be published',
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { GovernedActionApi } from '../../api/governed-action-api'
|
|
2
|
+
import { UsageError } from '../../errors'
|
|
3
|
+
import { table } from '../../table'
|
|
4
|
+
import { flagString, type Command } from '../types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What has actually run through the write path.
|
|
8
|
+
*
|
|
9
|
+
* `action list` reports that an Action is published and deployed; `action
|
|
10
|
+
* review` reports that its Binding is active. Both describe the ARMING, and an
|
|
11
|
+
* armed path that every caller's request fails against looks identical to one
|
|
12
|
+
* nothing has tried yet. This is the only verb that can tell them apart.
|
|
13
|
+
*
|
|
14
|
+
* Read-only, and that is a boundary rather than an omission: invoking joins
|
|
15
|
+
* organization membership, which no CLI credential satisfies. Listing gates on
|
|
16
|
+
* `actionRequest: ['read']`, which a key can hold.
|
|
17
|
+
*/
|
|
18
|
+
export const actionRequests: Command = {
|
|
19
|
+
meta: {
|
|
20
|
+
noun: 'action',
|
|
21
|
+
verb: 'requests',
|
|
22
|
+
args: [
|
|
23
|
+
{
|
|
24
|
+
name: 'requestId',
|
|
25
|
+
required: false,
|
|
26
|
+
description: 'one request to open in full; omit to list recent ones',
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
flags: { lifecycle: 'string', limit: 'string', cursor: 'string' },
|
|
30
|
+
summary: 'List Action requests and how they resolved — or open one',
|
|
31
|
+
examples: [
|
|
32
|
+
'frontera action requests',
|
|
33
|
+
'frontera action requests --lifecycle failed',
|
|
34
|
+
'frontera action requests <requestId> --json',
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
async run(ctx) {
|
|
39
|
+
const client = new GovernedActionApi(ctx.apiUrl, ctx.token)
|
|
40
|
+
const requestId = ctx.positional[0]
|
|
41
|
+
|
|
42
|
+
if (requestId) {
|
|
43
|
+
const request = await client.getRequest(requestId)
|
|
44
|
+
return {
|
|
45
|
+
data: request,
|
|
46
|
+
// Flattened rather than curated: the surface records provenance,
|
|
47
|
+
// attempts and failure detail under names that differ by adapter, and
|
|
48
|
+
// naming a subset here would hide exactly the field that explains the
|
|
49
|
+
// failure being investigated.
|
|
50
|
+
text: table(
|
|
51
|
+
['field', 'value'],
|
|
52
|
+
Object.entries(request).map(([k, v]) => [
|
|
53
|
+
k,
|
|
54
|
+
v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v),
|
|
55
|
+
]),
|
|
56
|
+
[undefined, 80],
|
|
57
|
+
),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const limitRaw = flagString(ctx, 'limit')
|
|
62
|
+
const limit = limitRaw === undefined ? undefined : Number(limitRaw)
|
|
63
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 200)) {
|
|
64
|
+
throw new UsageError(
|
|
65
|
+
`--limit must be a whole number between 1 and 200, not "${limitRaw}"`,
|
|
66
|
+
'try --limit 20',
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const page = await client.listRequests({
|
|
71
|
+
...(flagString(ctx, 'lifecycle') ? { lifecycle: flagString(ctx, 'lifecycle')! } : {}),
|
|
72
|
+
...(limit === undefined ? {} : { limit }),
|
|
73
|
+
...(flagString(ctx, 'cursor') ? { cursor: flagString(ctx, 'cursor')! } : {}),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
if (page.requests.length === 0) {
|
|
77
|
+
const filtered = flagString(ctx, 'lifecycle')
|
|
78
|
+
return {
|
|
79
|
+
data: page,
|
|
80
|
+
text: filtered
|
|
81
|
+
? `No requests with lifecycle "${filtered}".`
|
|
82
|
+
: 'No Action requests recorded.\n'
|
|
83
|
+
+ ' Nothing has invoked a deployed Action yet — invoking needs a credential\n'
|
|
84
|
+
+ ' that represents a person, so it happens from an agent or the Console.',
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
data: page,
|
|
90
|
+
text:
|
|
91
|
+
table(
|
|
92
|
+
// The request id is here because the detail verb takes it — without
|
|
93
|
+
// it the listing could not drive `action requests <requestId>`, which
|
|
94
|
+
// is the only place the failure detail lives. `effectCertainty` is
|
|
95
|
+
// the column to read on a failure: it separates "the effect landed
|
|
96
|
+
// and the request still failed" from "nothing happened", which is
|
|
97
|
+
// the difference between retrying and not.
|
|
98
|
+
['created', 'lifecycle', 'effect', 'action', 'request id'],
|
|
99
|
+
page.requests.map((r) => [
|
|
100
|
+
r.createdAt ?? '',
|
|
101
|
+
r.lifecycle ?? '',
|
|
102
|
+
r.effectCertainty ?? (r.hasEverDispatched ? 'dispatched' : ''),
|
|
103
|
+
r.actionDefinitionId ?? '?',
|
|
104
|
+
r.id ?? '',
|
|
105
|
+
]),
|
|
106
|
+
[24, 12, 20, 38, undefined],
|
|
107
|
+
)
|
|
108
|
+
+ (page.nextCursor ? `\n\nMore requests. Continue with --cursor ${page.nextCursor}` : ''),
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
}
|
|
@@ -26,6 +26,7 @@ export const actionReview: Command = {
|
|
|
26
26
|
meta: {
|
|
27
27
|
noun: 'action',
|
|
28
28
|
verb: 'review',
|
|
29
|
+
authLane: 'session',
|
|
29
30
|
args: [{ name: 'bindingRevision', required: true, description: 'The Binding revision to review' }],
|
|
30
31
|
flags: { report: 'string', reason: 'string', 'no-activate': 'boolean' },
|
|
31
32
|
summary: 'Review a validated write path and activate it',
|
|
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'
|
|
|
3
3
|
import { PlatformApi } from '../../api/platform-api'
|
|
4
4
|
import { CliError, UsageError } from '../../errors'
|
|
5
5
|
import { table } from '../../table'
|
|
6
|
-
import { flagString, type Command, type CommandContext } from '../types'
|
|
6
|
+
import { flagBool, flagString, type Command, type CommandContext } from '../types'
|
|
7
7
|
import { renderComposition, type Lookups } from './compose'
|
|
8
8
|
import { resolveAgentRef, type AgentRow } from './resolve'
|
|
9
9
|
|
|
@@ -86,6 +86,26 @@ async function readDocument(ctx: CommandContext): Promise<Record<string, unknown
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* JSON with object keys in a fixed order, for comparison.
|
|
91
|
+
*
|
|
92
|
+
* `JSON.stringify` preserves insertion order, and the draft snapshot and the
|
|
93
|
+
* live snapshot are built by different code paths — so the same
|
|
94
|
+
* `caseDefinition` serialises as `{metrics, objectSets, objectTypes}` on one
|
|
95
|
+
* side and `{objectTypes, metrics, objectSets}` on the other. Comparing the
|
|
96
|
+
* strings reported both as changed on a draft that had touched neither, which
|
|
97
|
+
* is the noise `agent diff` exists to avoid.
|
|
98
|
+
*
|
|
99
|
+
* Arrays keep their order: element order is meaningful in `stages` and
|
|
100
|
+
* `prompts`, where a reordering IS the change.
|
|
101
|
+
*/
|
|
102
|
+
export function stableStringify(value: unknown): string {
|
|
103
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'
|
|
104
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
|
|
105
|
+
const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
106
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(',')}}`
|
|
107
|
+
}
|
|
108
|
+
|
|
89
109
|
/** `agent_configs.agentId` — a slug, lowercase, the value every verb takes. */
|
|
90
110
|
const SLUG_PATTERN = /^[a-z][a-z0-9-]*$/
|
|
91
111
|
|
|
@@ -362,10 +382,39 @@ const diff: Command = {
|
|
|
362
382
|
'id', 'orgId', 'workspaceId', 'createdAt', 'updatedAt', 'createdBy',
|
|
363
383
|
'currentVersion', 'currentVersionId', 'draft', 'lifecycleStatus',
|
|
364
384
|
])
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* A snapshot is `config` PLUS fifteen sibling sections, and this compared
|
|
388
|
+
* only `config`.
|
|
389
|
+
*
|
|
390
|
+
* So a draft that staged a capability, a skill, a prompt or a knowledge
|
|
391
|
+
* attachment reported "matches the live version" — the answer a caller
|
|
392
|
+
* gets when nothing is pending, given immediately after they staged
|
|
393
|
+
* something. `capability grant` made this reachable in one step, but
|
|
394
|
+
* `agent apply` has always been able to stage these sections too.
|
|
395
|
+
*
|
|
396
|
+
* The live side comes from the live SNAPSHOT rather than the config row:
|
|
397
|
+
* `GET /config/agents/:id` returns flat scalars with every relation null,
|
|
398
|
+
* so every section would read as "changed" against it.
|
|
399
|
+
*/
|
|
400
|
+
const liveSnapshot = await t.api.agentComposition(t.id).catch(() => null)
|
|
401
|
+
const draftSections = Object.fromEntries(
|
|
402
|
+
Object.entries(draft.snapshot).filter(([k]) => k !== 'config' && !MANAGED.has(k)),
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
const changed = [
|
|
406
|
+
...Object.keys(draftConfig)
|
|
407
|
+
.filter((k) => !MANAGED.has(k))
|
|
408
|
+
.filter((k) => stableStringify(draftConfig[k]) !== stableStringify(liveConfig[k])),
|
|
409
|
+
// Only when the live snapshot could be read. Reporting every section as
|
|
410
|
+
// changed because one request failed would be worse than the silence
|
|
411
|
+
// this replaces.
|
|
412
|
+
...(liveSnapshot
|
|
413
|
+
? Object.keys(draftSections).filter(
|
|
414
|
+
(k) => stableStringify(draftSections[k]) !== stableStringify(liveSnapshot[k]),
|
|
415
|
+
)
|
|
416
|
+
: []),
|
|
417
|
+
].sort()
|
|
369
418
|
|
|
370
419
|
return {
|
|
371
420
|
data: { hasDraft: true, revision: draft.revision, changed },
|
|
@@ -410,18 +459,148 @@ const versions: Command = {
|
|
|
410
459
|
text:
|
|
411
460
|
rows.length === 0
|
|
412
461
|
? 'No versions published yet.'
|
|
462
|
+
// The id is here because `agent revert` takes it, and nothing else
|
|
463
|
+
// prints it — the listing could name a version to go back to and
|
|
464
|
+
// not the value needed to go there, so the only route was --json.
|
|
465
|
+
// `publishedByName` before `publishedBy`: the raw column holds a
|
|
466
|
+
// principal string like `wskey:<uuid>:<uuid>`, which identifies the
|
|
467
|
+
// credential rather than the person.
|
|
413
468
|
: table(
|
|
414
|
-
['version', 'published', 'by'],
|
|
469
|
+
['version', 'published', 'by', 'notes', 'id'],
|
|
415
470
|
rows.map((v) => [
|
|
416
471
|
String(v.versionNumber ?? v.version ?? '?'),
|
|
417
472
|
String(v.publishedAt ?? ''),
|
|
418
|
-
String(v.publishedBy ?? ''),
|
|
473
|
+
String(v.publishedByName ?? v.publishedBy ?? ''),
|
|
474
|
+
String(v.notes ?? ''),
|
|
475
|
+
String(v.id ?? ''),
|
|
419
476
|
]),
|
|
477
|
+
[7, 26, 22, 24, undefined],
|
|
420
478
|
),
|
|
421
479
|
}
|
|
422
480
|
},
|
|
423
481
|
}
|
|
424
482
|
|
|
483
|
+
/**
|
|
484
|
+
* Bind a workspace skill to an agent.
|
|
485
|
+
*
|
|
486
|
+
* The missing step in the skill workflow. `skill push` uploaded a bundle and
|
|
487
|
+
* nothing loaded it — the only route from a folder to a running agent went
|
|
488
|
+
* through `pack apply`, which carries a whole pack, or through the Console.
|
|
489
|
+
*
|
|
490
|
+
* Unlike `capability grant`, this writes LIVE. There is no draft to publish
|
|
491
|
+
* and `agent diff` stays empty, because the route binds the skill directly
|
|
492
|
+
* rather than staging a snapshot patch. Measured, not assumed: attaching one
|
|
493
|
+
* changes `agent get` immediately and leaves no draft behind. The two verbs
|
|
494
|
+
* sit next to each other and behave oppositely, so both say which they are.
|
|
495
|
+
*/
|
|
496
|
+
const skillAttach: Command = {
|
|
497
|
+
meta: {
|
|
498
|
+
noun: 'agent',
|
|
499
|
+
verb: 'skill-attach',
|
|
500
|
+
args: [
|
|
501
|
+
{ name: 'agent', required: true, description: 'agent id or slug' },
|
|
502
|
+
{ name: 'skill', required: true, description: 'workspace skill id, from `frontera skill list`' },
|
|
503
|
+
],
|
|
504
|
+
flags: {},
|
|
505
|
+
summary: 'Give an agent a workspace skill to load at runtime — takes effect immediately',
|
|
506
|
+
examples: ['frontera agent skill-attach support-bot <skillId>'],
|
|
507
|
+
},
|
|
508
|
+
async run(ctx) {
|
|
509
|
+
const [ref, skillId] = ctx.positional
|
|
510
|
+
if (!ref) throw new UsageError('missing <agent>', 'frontera agent list')
|
|
511
|
+
if (!skillId) throw new UsageError('missing <skill>', 'frontera skill list — then pass an id')
|
|
512
|
+
|
|
513
|
+
const api = new PlatformApi(ctx.apiUrl, ctx.token)
|
|
514
|
+
const result = await api.attachAgentSkill(await resolveAgentRef(api, ref), skillId)
|
|
515
|
+
|
|
516
|
+
return {
|
|
517
|
+
data: result,
|
|
518
|
+
text: `Attached ${skillId} to ${ref}.\n`
|
|
519
|
+
+ ' Live immediately — this is not staged, and there is nothing to publish.',
|
|
520
|
+
}
|
|
521
|
+
},
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const skillDetach: Command = {
|
|
525
|
+
meta: {
|
|
526
|
+
noun: 'agent',
|
|
527
|
+
verb: 'skill-detach',
|
|
528
|
+
args: [
|
|
529
|
+
{ name: 'agent', required: true, description: 'agent id or slug' },
|
|
530
|
+
{ name: 'skill', required: true, description: 'workspace skill id' },
|
|
531
|
+
],
|
|
532
|
+
flags: {},
|
|
533
|
+
summary: 'Take a workspace skill away from an agent — takes effect immediately',
|
|
534
|
+
examples: ['frontera agent skill-detach support-bot <skillId>'],
|
|
535
|
+
},
|
|
536
|
+
async run(ctx) {
|
|
537
|
+
const [ref, skillId] = ctx.positional
|
|
538
|
+
if (!ref) throw new UsageError('missing <agent>', 'frontera agent list')
|
|
539
|
+
if (!skillId) throw new UsageError('missing <skill>', `frontera agent get ${ref} — lists what it loads`)
|
|
540
|
+
|
|
541
|
+
const api = new PlatformApi(ctx.apiUrl, ctx.token)
|
|
542
|
+
const result = await api.detachAgentSkill(await resolveAgentRef(api, ref), skillId)
|
|
543
|
+
|
|
544
|
+
return { data: result, text: `Detached ${skillId} from ${ref}. The skill itself is untouched.` }
|
|
545
|
+
},
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Go back to a version that worked.
|
|
550
|
+
*
|
|
551
|
+
* `agent versions` listed what could be reverted to and nothing could revert,
|
|
552
|
+
* so recovering from a bad publish meant reconstructing the document by hand.
|
|
553
|
+
*
|
|
554
|
+
* It RE-POINTS rather than appends: the route applies vN's snapshot to the
|
|
555
|
+
* live tables and moves `current_version_id` back to vN in one transaction, so
|
|
556
|
+
* the version list does not grow and the pointer moves backwards. Measured,
|
|
557
|
+
* not assumed — this was first documented as "publishes a new version", and
|
|
558
|
+
* reverting a two-version agent left it with two versions, not three. The act
|
|
559
|
+
* itself is recorded in the agent's event log, which is the append-only trail.
|
|
560
|
+
*
|
|
561
|
+
* There is no draft staging: this is live the moment it returns, which is the
|
|
562
|
+
* opposite of every other write on this noun and the reason it says so in its
|
|
563
|
+
* own output.
|
|
564
|
+
*/
|
|
565
|
+
const revert: Command = {
|
|
566
|
+
meta: {
|
|
567
|
+
noun: 'agent',
|
|
568
|
+
verb: 'revert',
|
|
569
|
+
args: [
|
|
570
|
+
{ name: 'agent', required: true, description: 'agent id or slug' },
|
|
571
|
+
{ name: 'version', required: true, description: 'version id to go back to, from `frontera agent versions`' },
|
|
572
|
+
],
|
|
573
|
+
flags: { 'accept-orphaned-knowledge': 'boolean' },
|
|
574
|
+
summary: 'Restore an earlier version and point the agent back at it',
|
|
575
|
+
examples: ['frontera agent revert support-bot <versionId>'],
|
|
576
|
+
},
|
|
577
|
+
async run(ctx) {
|
|
578
|
+
const [ref, versionId] = ctx.positional
|
|
579
|
+
if (!ref) throw new UsageError('missing <agent>', 'frontera agent list')
|
|
580
|
+
if (!versionId) {
|
|
581
|
+
throw new UsageError('missing <version>', `frontera agent versions ${ref} — then pass a version id`)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const api = new PlatformApi(ctx.apiUrl, ctx.token)
|
|
585
|
+
const result = await api.revertAgent(await resolveAgentRef(api, ref), versionId, {
|
|
586
|
+
// The old snapshot can name a knowledge base that has since been
|
|
587
|
+
// deleted. Refusing by default is right — silently reverting to an agent
|
|
588
|
+
// that reads a corpus which no longer exists is a working agent with a
|
|
589
|
+
// missing dependency, which is worse than a refusal.
|
|
590
|
+
...(flagBool(ctx, 'accept-orphaned-knowledge') ? { acceptOrphanedKnowledge: true } : {}),
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
return {
|
|
594
|
+
data: result,
|
|
595
|
+
text:
|
|
596
|
+
`${ref} reverted to ${versionId}.\n`
|
|
597
|
+
+ ' Its snapshot is live now and the current-version pointer moved back to it —\n'
|
|
598
|
+
+ ' no new version was created, so `agent versions` is unchanged.\n'
|
|
599
|
+
+ ' This did not stage a draft; there is nothing to publish.',
|
|
600
|
+
}
|
|
601
|
+
},
|
|
602
|
+
}
|
|
603
|
+
|
|
425
604
|
export const agentCommands: Command[] = [
|
|
426
605
|
list,
|
|
427
606
|
get,
|
|
@@ -431,4 +610,7 @@ export const agentCommands: Command[] = [
|
|
|
431
610
|
diff,
|
|
432
611
|
discard,
|
|
433
612
|
versions,
|
|
613
|
+
revert,
|
|
614
|
+
skillAttach,
|
|
615
|
+
skillDetach,
|
|
434
616
|
]
|
package/src/commands/app/init.ts
CHANGED
|
@@ -226,7 +226,7 @@ export const appInit: Command = {
|
|
|
226
226
|
//
|
|
227
227
|
// Best-effort by construction: `offline: true` above means this command
|
|
228
228
|
// must work before anyone has logged in.
|
|
229
|
-
const devEnv = framework === 'react' ? writeDevEnv(target) : { written: false as const, reason: 'not-needed' }
|
|
229
|
+
const devEnv = framework === 'react' ? await writeDevEnv(target) : { written: false as const, reason: 'not-needed' }
|
|
230
230
|
if (framework === 'react') {
|
|
231
231
|
ctx.output.note(
|
|
232
232
|
devEnv.written
|
package/src/commands/app/pull.ts
CHANGED
|
@@ -108,7 +108,7 @@ export const appPull: Command = {
|
|
|
108
108
|
// carries the publisher's — every working copy has to be given one here.
|
|
109
109
|
// Never overwrites, so an author who pointed this project somewhere else
|
|
110
110
|
// keeps their setting.
|
|
111
|
-
const devEnv = writeDevEnv(dir)
|
|
111
|
+
const devEnv = await writeDevEnv(dir)
|
|
112
112
|
if (devEnv.written) ctx.output.note(` ${DEV_ENV_FILE} written — the dev host will read real data`)
|
|
113
113
|
|
|
114
114
|
const pulled = `Pulled ${count} files from ${app.slug}${parentVersion ? `@${parentVersion}` : ' (draft)'}`
|