@frontera-sdk/cli 1.52.44 → 1.52.46
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 +3 -1
- package/src/api/automation-api.ts +32 -0
- package/src/api/credential-failure.ts +1 -1
- package/src/api/governed-action-api.ts +24 -0
- package/src/api/media-api.ts +24 -0
- package/src/api/platform-api.ts +13 -0
- package/src/binding-registry.ts +9 -2
- package/src/blueprint/compile.ts +3 -1
- package/src/blueprint/ontology-edit-plan.ts +71 -4
- package/src/blueprint/org-name-check.ts +64 -0
- package/src/blueprint/tree.ts +28 -0
- package/src/commands/action/deploy.ts +22 -5
- package/src/commands/action/index-commands.ts +2 -0
- package/src/commands/action/retire.ts +60 -0
- package/src/commands/agent/compose.ts +1 -0
- package/src/commands/agent/index-commands.ts +42 -0
- package/src/commands/app/deploy.ts +1 -1
- package/src/commands/app/dev.ts +2 -2
- package/src/commands/app/save.ts +1 -1
- package/src/commands/auth/add.ts +5 -1
- package/src/commands/auth/current.ts +6 -0
- package/src/commands/auth/index-commands.ts +3 -0
- package/src/commands/auth/login.ts +84 -0
- package/src/commands/auth/use.ts +21 -2
- package/src/commands/automation/bindings.ts +54 -10
- package/src/commands/automation/index-commands.ts +2 -3
- package/src/commands/blueprint/bind.ts +12 -1
- package/src/commands/blueprint/declarative.ts +6 -2
- package/src/commands/conversation/index-commands.ts +66 -0
- package/src/commands/conversation/local-runtime.ts +18 -0
- package/src/commands/kit/vendor.ts +62 -48
- package/src/commands/local/check.ts +57 -11
- package/src/commands/local/data.ts +75 -7
- package/src/commands/local/dev.ts +77 -9
- package/src/commands/local/embeddings.ts +74 -0
- package/src/commands/local/index-commands.ts +133 -11
- package/src/commands/local/init.ts +22 -2
- package/src/commands/local/new.ts +78 -0
- package/src/commands/local/publish.ts +34 -0
- package/src/commands/local/steps.ts +105 -0
- package/src/commands/local/sync-request.ts +69 -23
- package/src/commands/local/sync.ts +2 -0
- package/src/commands/media/index-commands.ts +58 -3
- package/src/commands/registry.ts +11 -3
- package/src/commands/trace/index-commands.ts +56 -0
- package/src/commands/workspace/index-commands.ts +14 -7
- package/src/config.ts +4 -1
- package/src/dev-broker.ts +15 -1
- package/src/device-login.ts +143 -0
- package/src/exit.ts +36 -8
- package/src/flag-help.ts +31 -0
- package/src/forge/claude-adapter.ts +17 -4
- package/src/forge/permission-rules.ts +158 -0
- package/src/forge/touches.ts +7 -6
- package/src/function-outside-imports.ts +13 -2
- package/src/function-source.ts +90 -0
- package/src/harness.ts +4 -4
- package/src/key-reach.ts +64 -0
- package/src/local/local-actions.ts +15 -3
- package/src/local/local-apply-error.ts +26 -0
- package/src/local/local-apps.ts +9 -3
- package/src/local/local-blueprint-schemas.ts +15 -1
- package/src/local/local-confidential.ts +81 -0
- package/src/local/local-data-load.ts +130 -0
- package/src/local/local-dev-references.ts +24 -4
- package/src/local/local-embedding-setup.ts +62 -0
- package/src/local/local-jobs.ts +108 -0
- package/src/local/local-keys.ts +5 -3
- package/src/local/local-new.ts +148 -0
- package/src/local/local-org-settings.ts +1 -1
- package/src/local/local-person-steps.ts +340 -0
- package/src/local/local-personas.ts +16 -5
- package/src/local/local-portability.ts +22 -1
- package/src/local/local-publish.ts +26 -0
- package/src/local/local-request-evidence.ts +35 -6
- package/src/local/local-runtime-history.ts +4 -3
- package/src/local/local-said-once.ts +51 -0
- package/src/local/local-scaffold.ts +40 -2
- package/src/local/local-schemas.ts +60 -11
- package/src/local/local-seed.ts +1 -1
- package/src/local/local-stand-ins.ts +44 -0
- package/src/local/local-studio-server.ts +13 -7
- package/src/local/local-supervisor.ts +22 -0
- package/src/local/local-sync-models.ts +5 -2
- package/src/local/local-sync-names.ts +3 -0
- package/src/local/local-sync-portable.ts +64 -15
- package/src/local/local-sync-precheck.ts +30 -1
- package/src/local/local-sync-push.ts +81 -8
- package/src/local/local-sync-remote.ts +10 -7
- package/src/local/local-test-models.ts +173 -0
- package/src/local/local-test-runner.ts +232 -25
- package/src/local/local-test-scope.ts +174 -0
- package/src/local/local-ungranted-reads.ts +55 -0
- package/src/local/local-writers.ts +171 -0
- package/src/main.ts +75 -17
- package/src/mcp/browser/ax-snapshot.ts +13 -1
- package/src/mcp/browser/page-scripts.ts +1 -0
- package/src/mcp/mcp-conversations.ts +171 -0
- package/src/mcp/mcp-server.ts +4 -1
- package/src/mcp/mcp-traces.ts +7 -2
- package/src/mcp/tools/agent-tools.ts +94 -2
- package/src/mcp/tools/project-tools.ts +149 -13
- package/src/mcp/tools/test-tools.ts +64 -13
- package/src/output.ts +22 -7
- package/src/pack.ts +16 -0
- package/src/project-context.ts +18 -5
- package/src/project.ts +4 -0
- package/src/remote-guard.ts +52 -0
- package/src/vendor/kit-assets.json +67 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/cli",
|
|
3
|
-
"version": "1.52.
|
|
3
|
+
"version": "1.52.46",
|
|
4
4
|
"description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"frontera",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@anthropic-ai/claude-agent-sdk": "^0.3.251",
|
|
42
42
|
"@faker-js/faker": "10.6.0",
|
|
43
|
-
"@frontera-sdk/core": "1.52.
|
|
44
|
-
"@frontera-sdk/functions": "1.52.
|
|
43
|
+
"@frontera-sdk/core": "1.52.46",
|
|
44
|
+
"@frontera-sdk/functions": "1.52.46",
|
|
45
45
|
"ai": "^6.0.116",
|
|
46
46
|
"axe-core": "4.12.1",
|
|
47
47
|
"gray-matter": "^4.0.3",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"yaml": "^2.9.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@frontera-sdk/forge-contracts": "1.52.
|
|
52
|
+
"@frontera-sdk/forge-contracts": "1.52.46",
|
|
53
53
|
"@types/bun": "^1.3.14",
|
|
54
54
|
"typescript": "^5.9.3"
|
|
55
55
|
}
|
package/src/api/apps-api.ts
CHANGED
|
@@ -144,10 +144,12 @@ export class AppsApi {
|
|
|
144
144
|
slug: string,
|
|
145
145
|
displayName: string,
|
|
146
146
|
appId?: string | null,
|
|
147
|
+
/** Used only when this creates the App: its icon from `frontera.config.json` (X6). */
|
|
148
|
+
opts: { icon?: string } = {},
|
|
147
149
|
): Promise<{ id: string; slug: string; created: boolean }> {
|
|
148
150
|
return this.client.request<{ id: string; slug: string; created: boolean }>('/v1/platform-apps/ensure', {
|
|
149
151
|
method: 'POST',
|
|
150
|
-
body: appId ? { slug, displayName, appId } : { slug, displayName },
|
|
152
|
+
body: { ...(appId ? { slug, displayName, appId } : { slug, displayName }), ...(opts.icon ? { icon: opts.icon } : {}) },
|
|
151
153
|
})
|
|
152
154
|
}
|
|
153
155
|
|
|
@@ -29,6 +29,19 @@ export interface AutomationVersionSummary {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/** One agent↔automation binding, as the bindings routes return it. */
|
|
32
|
+
/** A Custom App bound to a Function: it may start the Function as the person using it. */
|
|
33
|
+
export interface AppFunctionBinding {
|
|
34
|
+
appId: string
|
|
35
|
+
appSlug: string
|
|
36
|
+
appDisplayName: string
|
|
37
|
+
enabled: boolean
|
|
38
|
+
createdBy: string
|
|
39
|
+
createdAt: string
|
|
40
|
+
/** Whether it would start anything now: false while the Function has nothing live or the binding is off. */
|
|
41
|
+
projects?: boolean
|
|
42
|
+
inertReason?: 'no_live_version' | 'disabled' | null
|
|
43
|
+
}
|
|
44
|
+
|
|
32
45
|
export interface AgentAutomationBinding {
|
|
33
46
|
agentId: string
|
|
34
47
|
agentSlug: string
|
|
@@ -387,6 +400,25 @@ export class AutomationApi {
|
|
|
387
400
|
)
|
|
388
401
|
}
|
|
389
402
|
|
|
403
|
+
/** Which Custom Apps may start this Function (X6): the App lane's own permission, beside the agent bindings. */
|
|
404
|
+
appBindings(slug: string): Promise<AppFunctionBinding[]> {
|
|
405
|
+
return this.client.request<AppFunctionBinding[]>(`/v1/functions/${encodeURIComponent(slug)}/app-bindings`)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
bindApp(slug: string, appSlug: string, options: { enabled?: boolean } = {}): Promise<AppFunctionBinding> {
|
|
409
|
+
return this.client.request<AppFunctionBinding>(
|
|
410
|
+
`/v1/functions/${encodeURIComponent(slug)}/app-bindings/${encodeURIComponent(appSlug)}`,
|
|
411
|
+
{ method: 'PUT', body: options },
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
unbindApp(slug: string, appSlug: string): Promise<{ unbound: boolean }> {
|
|
416
|
+
return this.client.request<{ unbound: boolean }>(
|
|
417
|
+
`/v1/functions/${encodeURIComponent(slug)}/app-bindings/${encodeURIComponent(appSlug)}`,
|
|
418
|
+
{ method: 'DELETE' },
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
|
|
390
422
|
setEnabled(slug: string, enabled: boolean): Promise<{ slug: string; enabled: boolean }> {
|
|
391
423
|
return this.client.request<{ slug: string; enabled: boolean }>(
|
|
392
424
|
`/v1/functions/${encodeURIComponent(slug)}/enabled`,
|
|
@@ -66,7 +66,7 @@ export function credentialFailure(
|
|
|
66
66
|
return person
|
|
67
67
|
? new CliError('The session token was refused — it has expired or been signed out.', {
|
|
68
68
|
code: 'UNAUTHORIZED',
|
|
69
|
-
hint: 'sign in again
|
|
69
|
+
hint: 'sign in again with `frontera auth login --browser`',
|
|
70
70
|
})
|
|
71
71
|
: new CliError(
|
|
72
72
|
'The credential was refused. An organization key that has been revoked or has '
|
|
@@ -478,6 +478,30 @@ export class GovernedActionApi {
|
|
|
478
478
|
)
|
|
479
479
|
}
|
|
480
480
|
|
|
481
|
+
/** The Action's definition id: on the draft first, where an Action lives before it has ever published, else the release. */
|
|
482
|
+
async actionDefinitionId(apiName: string): Promise<string | null> {
|
|
483
|
+
const drafted = (await this.listDraftActions().catch(() => [])).find((a) => a.apiName === apiName)
|
|
484
|
+
if (typeof drafted?.id === 'string') return drafted.id
|
|
485
|
+
const published = await this.publishedAction(apiName).catch(() => null)
|
|
486
|
+
return (published?.definition as { id?: string } | undefined)?.id ?? null
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** The Action's current deployment decision, or null when it has none. */
|
|
490
|
+
async currentDeployment(actionDefinitionId: string): Promise<{ stateId: string; state: string; generation: number; bindingRevisionId: string | null } | null> {
|
|
491
|
+
const page = await this.call<{ entries?: Array<{ stateId: string; state: string; generation: number; bindingRevisionId: string | null }> }>(
|
|
492
|
+
`/v1/blueprint/governed-actions/admin/deployments?actionDefinitionId=${encodeURIComponent(actionDefinitionId)}`,
|
|
493
|
+
)
|
|
494
|
+
return page?.entries?.[0] ?? null
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Stand a deployment decision down: one that was never bound, or a draining one (the service refuses the rest). */
|
|
498
|
+
retireDeployment(stateId: string, expectedGeneration: number, reason: string): Promise<{ stateId?: string; state?: string; generation?: number }> {
|
|
499
|
+
return this.call(`/v1/blueprint/governed-actions/admin/deployments/${encodeURIComponent(stateId)}/retire`, {
|
|
500
|
+
method: 'POST',
|
|
501
|
+
body: { expectedGeneration, reason },
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
|
|
481
505
|
/**
|
|
482
506
|
* Bindings at their newest revision, each with `deployment`: the CURRENT
|
|
483
507
|
* state of the Action it binds (null when the Action has none). Every
|
package/src/api/media-api.ts
CHANGED
|
@@ -29,6 +29,15 @@ export interface MediaSet {
|
|
|
29
29
|
itemCount?: number
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/** A file staged for an Action: its preallocated item id is the Action's upload input. */
|
|
33
|
+
export interface StagedUpload {
|
|
34
|
+
mediaItemId: string
|
|
35
|
+
path: string
|
|
36
|
+
mimeType: string
|
|
37
|
+
sizeBytes: number
|
|
38
|
+
state: string
|
|
39
|
+
}
|
|
40
|
+
|
|
32
41
|
export interface MediaItem {
|
|
33
42
|
mediaItemId: string
|
|
34
43
|
path: string
|
|
@@ -140,6 +149,21 @@ export class MediaApi {
|
|
|
140
149
|
return (await this.call<{ item: MediaItem }>(`${this.set(mediaSetId)}/items`, { form })).item
|
|
141
150
|
}
|
|
142
151
|
|
|
152
|
+
/**
|
|
153
|
+
* One file staged for an Action to attach (L6): nothing is in the set until
|
|
154
|
+
* an Action request that carries the returned id as its upload input applies.
|
|
155
|
+
*/
|
|
156
|
+
async stage(
|
|
157
|
+
mediaSetId: string,
|
|
158
|
+
file: { bytes: Uint8Array; filename: string; mime: string; logicalPath: string; markings?: string },
|
|
159
|
+
): Promise<StagedUpload> {
|
|
160
|
+
const form = new FormData()
|
|
161
|
+
form.set('file', new File([new Blob([file.bytes.buffer as ArrayBuffer])], file.filename, { type: file.mime }))
|
|
162
|
+
form.set('logicalPath', file.logicalPath)
|
|
163
|
+
if (file.markings) form.set('markings', file.markings)
|
|
164
|
+
return (await this.call<{ upload: StagedUpload }>(`${this.set(mediaSetId)}/uploads`, { form })).upload
|
|
165
|
+
}
|
|
166
|
+
|
|
143
167
|
extraction(mediaSetId: string): Promise<ExtractionStatus> {
|
|
144
168
|
return this.call(`${this.set(mediaSetId)}/extraction`)
|
|
145
169
|
}
|
package/src/api/platform-api.ts
CHANGED
|
@@ -359,6 +359,19 @@ export class PlatformApi {
|
|
|
359
359
|
})
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
+
/** The Agent's behavior settings as its draft and live version hold them (`operating.json`'s source). */
|
|
363
|
+
agentOperatingConfig(id: string) {
|
|
364
|
+
return this.client.request<{ draft?: unknown; live?: unknown } | null>(`/v1/config/agents/${encodeURIComponent(id)}/operating`)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Stage the Agent's behavior settings on its draft; `null` takes them all away. They take effect when it is published. */
|
|
368
|
+
stageAgentOperatingConfig(id: string, operatingConfig: Record<string, unknown> | null) {
|
|
369
|
+
return this.client.request<unknown>(`/v1/config/agents/${encodeURIComponent(id)}/operating`, {
|
|
370
|
+
method: 'PUT',
|
|
371
|
+
body: { operatingConfig },
|
|
372
|
+
})
|
|
373
|
+
}
|
|
374
|
+
|
|
362
375
|
discardAgentDraft(id: string) {
|
|
363
376
|
return this.client.request<unknown>(`/v1/config/agents/${encodeURIComponent(id)}/draft-v2`, {
|
|
364
377
|
method: 'DELETE',
|
package/src/binding-registry.ts
CHANGED
|
@@ -28,6 +28,8 @@ import { configDir, type Env } from './paths'
|
|
|
28
28
|
|
|
29
29
|
export interface Binding {
|
|
30
30
|
profile: string
|
|
31
|
+
/** The organization profile bound beside it for organization-scoped commands (X3). */
|
|
32
|
+
organizationProfile?: string
|
|
31
33
|
boundAt: string
|
|
32
34
|
}
|
|
33
35
|
|
|
@@ -59,9 +61,9 @@ function read(env: Env): BindingFile {
|
|
|
59
61
|
}
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
export function recordBinding(root: string, profile: string, env: Env = process.env): void {
|
|
64
|
+
export function recordBinding(root: string, profile: string, env: Env = process.env, organizationProfile?: string): void {
|
|
63
65
|
const file = read(env)
|
|
64
|
-
file.roots[key(root)] = { profile, boundAt: new Date().toISOString() }
|
|
66
|
+
file.roots[key(root)] = { profile, ...(organizationProfile ? { organizationProfile } : {}), boundAt: new Date().toISOString() }
|
|
65
67
|
writeFileAtomic(bindingsPath(env), `${JSON.stringify(file, null, 2)}\n`, 0o600)
|
|
66
68
|
}
|
|
67
69
|
|
|
@@ -85,3 +87,8 @@ export function forgetBinding(root: string, env: Env = process.env): void {
|
|
|
85
87
|
export function isTrustedBinding(root: string, profile: string, env: Env = process.env): boolean {
|
|
86
88
|
return bindingFor(root, env)?.profile === profile
|
|
87
89
|
}
|
|
90
|
+
|
|
91
|
+
/** This machine bound `organizationProfile` beside the root's profile (`auth use --org-profile`, X3). */
|
|
92
|
+
export function isTrustedOrganizationBinding(root: string, organizationProfile: string, env: Env = process.env): boolean {
|
|
93
|
+
return bindingFor(root, env)?.organizationProfile === organizationProfile
|
|
94
|
+
}
|
package/src/blueprint/compile.ts
CHANGED
|
@@ -446,6 +446,8 @@ export function declaredPropertyType(property: Record<string, unknown>, sharedPr
|
|
|
446
446
|
}
|
|
447
447
|
|
|
448
448
|
export function actionWithoutMintedIds(document: Record<string, unknown>): Record<string, unknown> {
|
|
449
|
+
// `attachment` is the project's deployment choice for the local runtime (L6), not part of the Action's contract.
|
|
450
|
+
const { attachment: _deployment, ...contract } = document
|
|
449
451
|
const drop = (value: unknown): unknown => {
|
|
450
452
|
if (Array.isArray(value)) return value.map(drop)
|
|
451
453
|
if (!value || typeof value !== 'object') return value
|
|
@@ -459,7 +461,7 @@ export function actionWithoutMintedIds(document: Record<string, unknown>): Recor
|
|
|
459
461
|
}
|
|
460
462
|
return out
|
|
461
463
|
}
|
|
462
|
-
return drop(
|
|
464
|
+
return drop(contract) as Record<string, unknown>
|
|
463
465
|
}
|
|
464
466
|
|
|
465
467
|
export function actionBody(file: AuthoredFile): Record<string, unknown> {
|
|
@@ -31,7 +31,22 @@ export interface ObjectTypeShape {
|
|
|
31
31
|
id: string
|
|
32
32
|
apiName: string
|
|
33
33
|
primaryKeyPropertyId: string | null
|
|
34
|
-
properties: Array<{ id?: string; apiName?: string }>
|
|
34
|
+
properties: Array<{ id?: string; apiName?: string; valueType?: string; mediaSetId?: string | null }>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What an attachment Action fills (L6): the file arrives in `uploadInput` (a
|
|
39
|
+
* staged upload's id, or a run's `{ fileId }`), and the Media write gate puts
|
|
40
|
+
* it in `mediaSetId` and fills `property` (a media reference), `itemIdProperty`
|
|
41
|
+
* (a string that keeps the item's id), or both. Stated at deploy, because an
|
|
42
|
+
* Action's own contract has no input kind that says "this is a file".
|
|
43
|
+
*/
|
|
44
|
+
export interface MediaAttachmentChoice {
|
|
45
|
+
property?: string
|
|
46
|
+
itemIdProperty?: string
|
|
47
|
+
uploadInput: string
|
|
48
|
+
/** Defaults to the media reference property's own set. */
|
|
49
|
+
mediaSetId?: string
|
|
35
50
|
}
|
|
36
51
|
|
|
37
52
|
export interface OntologyEditPlan {
|
|
@@ -45,6 +60,7 @@ export interface OntologyEditPlan {
|
|
|
45
60
|
set: Record<string, string>
|
|
46
61
|
pkParameter: string
|
|
47
62
|
expectedVersionParameter?: string
|
|
63
|
+
mediaAttachment?: { propertyApiName?: string; itemIdProperty?: string; mediaSetId: string; uploadParameter: string }
|
|
48
64
|
impactBounds: { maxAffectedRows: number; maxTouchedRows: number }
|
|
49
65
|
capabilities: Record<string, unknown>
|
|
50
66
|
}
|
|
@@ -87,6 +103,7 @@ const OP_FOR_EFFECT: Readonly<Record<string, OntologyEditPlan['op']>> = {
|
|
|
87
103
|
export function deriveOntologyEditPlan(
|
|
88
104
|
action: PublishedActionDefinition,
|
|
89
105
|
objectType: ObjectTypeShape,
|
|
106
|
+
attachment?: MediaAttachmentChoice,
|
|
90
107
|
): OntologyEditPlan {
|
|
91
108
|
const op = OP_FOR_EFFECT[action.effect.kind]
|
|
92
109
|
if (!op) {
|
|
@@ -132,8 +149,13 @@ export function deriveOntologyEditPlan(
|
|
|
132
149
|
}
|
|
133
150
|
}
|
|
134
151
|
|
|
135
|
-
|
|
136
|
-
if (op
|
|
152
|
+
const mediaAttachment = attachment ? attachmentOf(action, objectType, attachment, set) : undefined
|
|
153
|
+
if (mediaAttachment && op === 'delete') {
|
|
154
|
+
throw new PlanDerivationError(`Action "${action.apiName}" deletes its record, so it has nothing to attach a file to.`, 'Attach the file with an Action that creates or updates the record.')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// The plan must map at least one parameter (or attach a file), so constants alone cannot be deployed.
|
|
158
|
+
if (op !== 'delete' && Object.keys(set).length === 0 && !mediaAttachment) {
|
|
137
159
|
throw new PlanDerivationError(
|
|
138
160
|
constants.length
|
|
139
161
|
? `Action "${action.apiName}" sets only constants (${constants.join(', ')}), and its write path needs at least one property set from an input.`
|
|
@@ -199,6 +221,12 @@ export function deriveOntologyEditPlan(
|
|
|
199
221
|
'Add a required input { apiName: expectedVersion, valueType: { kind: number, integer: true, minimum: 0 } }: the record version the change was decided on (a query row\'s _meta.recordVersion).',
|
|
200
222
|
)
|
|
201
223
|
}
|
|
224
|
+
if (mediaAttachment && [pkParameter, expectedVersionParameter].includes(mediaAttachment.uploadParameter)) {
|
|
225
|
+
throw new PlanDerivationError(
|
|
226
|
+
`Action "${action.apiName}" takes the file in ${mediaAttachment.uploadParameter}, which already ${mediaAttachment.uploadParameter === pkParameter ? 'addresses the record' : 'carries the record version'}.`,
|
|
227
|
+
'Give the file its own input: every mapped parameter carries exactly one role.',
|
|
228
|
+
)
|
|
229
|
+
}
|
|
202
230
|
if (expectedVersionParameter && Object.values(set).includes(expectedVersionParameter)) {
|
|
203
231
|
throw new PlanDerivationError(
|
|
204
232
|
`Action "${action.apiName}" writes a property from expectedVersion, which carries the record version.`,
|
|
@@ -211,9 +239,10 @@ export function deriveOntologyEditPlan(
|
|
|
211
239
|
kind: 'ontology_edit',
|
|
212
240
|
targetCompatibility: {
|
|
213
241
|
kind: 'ontology_edit',
|
|
242
|
+
// The attachment's properties count as used, as the service's `ontologyEditUsedProperties` counts them.
|
|
214
243
|
expectedFingerprint: ontologyEditCompatibilityFingerprint(
|
|
215
244
|
objectType.id,
|
|
216
|
-
Object.keys(set),
|
|
245
|
+
[...Object.keys(set), ...(mediaAttachment?.propertyApiName ? [mediaAttachment.propertyApiName] : []), ...(mediaAttachment?.itemIdProperty ? [mediaAttachment.itemIdProperty] : [])],
|
|
217
246
|
),
|
|
218
247
|
},
|
|
219
248
|
objectTypeId: objectType.id,
|
|
@@ -222,6 +251,7 @@ export function deriveOntologyEditPlan(
|
|
|
222
251
|
set,
|
|
223
252
|
pkParameter,
|
|
224
253
|
...(expectedVersionParameter ? { expectedVersionParameter } : {}),
|
|
254
|
+
...(mediaAttachment ? { mediaAttachment } : {}),
|
|
225
255
|
// One subject, because the Action itself declares `impact.maxSubjects: 1`
|
|
226
256
|
// and the schema admits no other value.
|
|
227
257
|
impactBounds: { maxAffectedRows: 1, maxTouchedRows: 1 },
|
|
@@ -237,3 +267,40 @@ export function deriveOntologyEditPlan(
|
|
|
237
267
|
},
|
|
238
268
|
}
|
|
239
269
|
}
|
|
270
|
+
|
|
271
|
+
/** The plan's `mediaAttachment` for this choice, checked against the Action and its object type. */
|
|
272
|
+
function attachmentOf(
|
|
273
|
+
action: PublishedActionDefinition,
|
|
274
|
+
objectType: ObjectTypeShape,
|
|
275
|
+
choice: MediaAttachmentChoice,
|
|
276
|
+
set: Record<string, string>,
|
|
277
|
+
): NonNullable<OntologyEditPlan['mediaAttachment']> {
|
|
278
|
+
if (!choice.property && !choice.itemIdProperty) {
|
|
279
|
+
throw new PlanDerivationError('An attachment fills a media reference property, an item id property, or both.', 'Pass --attach <media property>, --attach-item-id <text property>, or both.')
|
|
280
|
+
}
|
|
281
|
+
if (!action.inputs.some((i) => i.apiName === choice.uploadInput)) {
|
|
282
|
+
throw new PlanDerivationError(`Action "${action.apiName}" takes no "${choice.uploadInput}" input for the file.`, 'Add a string input for the staged upload (`frontera media stage` prints its id), publish, then deploy.')
|
|
283
|
+
}
|
|
284
|
+
if (Object.values(set).includes(choice.uploadInput)) {
|
|
285
|
+
throw new PlanDerivationError(`Action "${action.apiName}" already writes a property from ${choice.uploadInput}.`, 'Give the file its own input: every mapped parameter carries exactly one role.')
|
|
286
|
+
}
|
|
287
|
+
const property = (name: string) => objectType.properties.find((p) => p.apiName === name)
|
|
288
|
+
for (const name of [choice.property, choice.itemIdProperty]) {
|
|
289
|
+
if (!name) continue
|
|
290
|
+
if (!property(name)) throw new PlanDerivationError(`Object type "${objectType.apiName}" has no property "${name}".`, 'Name a property of the Action\'s object type.')
|
|
291
|
+
if (Object.prototype.hasOwnProperty.call(set, name)) throw new PlanDerivationError(`Action "${action.apiName}" already sets ${name} from an input, so the attachment can't fill it.`, 'Remove that change from the Action, or attach into another property.')
|
|
292
|
+
}
|
|
293
|
+
if (choice.property && choice.property === choice.itemIdProperty) {
|
|
294
|
+
throw new PlanDerivationError('The media reference and the item id cannot be the same property.', 'Name two properties, or one of them.')
|
|
295
|
+
}
|
|
296
|
+
const mediaSetId = choice.mediaSetId ?? (choice.property ? property(choice.property)?.mediaSetId ?? undefined : undefined)
|
|
297
|
+
if (!mediaSetId) {
|
|
298
|
+
throw new PlanDerivationError('The attachment names no Media Set.', 'Pass --media-set <id> (`frontera media list` shows the ids).')
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
...(choice.property ? { propertyApiName: choice.property } : {}),
|
|
302
|
+
...(choice.itemIdProperty ? { itemIdProperty: choice.itemIdProperty } : {}),
|
|
303
|
+
mediaSetId,
|
|
304
|
+
uploadParameter: choice.uploadInput,
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { localProject } from '../local/local-project'
|
|
2
|
+
import { syncClient, unwrap, type SyncClient } from '../local/local-sync-client'
|
|
3
|
+
import { getSyncKey, readLink } from '../local/local-sync-link'
|
|
4
|
+
import type { ArtifactKind } from './model'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether a new Blueprint artifact's API name is already the organization's
|
|
8
|
+
* (Forge improvement plan X11). A workspace sees only its slice of the
|
|
9
|
+
* organization's Blueprint, so `blueprint new object-type Activity` wrote a
|
|
10
|
+
* file a change request then refused: the organization had an `Activity`
|
|
11
|
+
* outside the slice (P5 A9). Read with the project's organization key from
|
|
12
|
+
* `frontera local sync connect`; without a link or that key it is not
|
|
13
|
+
* checked, and says so.
|
|
14
|
+
*/
|
|
15
|
+
export type NameCheck =
|
|
16
|
+
| { checked: false; why: string }
|
|
17
|
+
| { checked: true; taken: false }
|
|
18
|
+
| { checked: true; taken: true; as: string }
|
|
19
|
+
|
|
20
|
+
/** Where each kind's names are in the organization's Blueprint definition. */
|
|
21
|
+
const KEYS: Record<ArtifactKind, string> = {
|
|
22
|
+
'object-type': 'objects',
|
|
23
|
+
'link-type': 'relationships',
|
|
24
|
+
'shared-field': 'sharedProperties',
|
|
25
|
+
'semantic-type': 'semanticTypes',
|
|
26
|
+
metric: 'metrics',
|
|
27
|
+
action: 'actions',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The API names a definition holds for a kind. */
|
|
31
|
+
export function namesIn(definition: Record<string, unknown> | null | undefined, kind: ArtifactKind): string[] {
|
|
32
|
+
const list = definition?.[KEYS[kind]]
|
|
33
|
+
return Array.isArray(list) ? list.flatMap((e) => (typeof (e as { apiName?: unknown })?.apiName === 'string' ? [(e as { apiName: string }).apiName] : [])) : []
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function orgNameCheck(root: string, kind: ArtifactKind, apiName: string, deps: { client?: SyncClient | null } = {}): Promise<NameCheck> {
|
|
37
|
+
let client = deps.client ?? null
|
|
38
|
+
if (!client) {
|
|
39
|
+
const project = localProject(root)
|
|
40
|
+
const link = readLink(project.root)
|
|
41
|
+
if (!link) return { checked: false, why: 'the project is linked to no workspace' }
|
|
42
|
+
const key = getSyncKey(project, 'organization')
|
|
43
|
+
if (!key) return { checked: false, why: 'the project has no organization key (`frontera local sync connect --org-key -`)' }
|
|
44
|
+
client = syncClient(link.platform, key)
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const found = await client.find<unknown>('/v1/blueprint/lifecycle')
|
|
48
|
+
if (!found) return { checked: true, taken: false }
|
|
49
|
+
const lifecycle = unwrap<{ draft?: { definition?: Record<string, unknown> } | null; activeRelease?: { definition?: Record<string, unknown> } | null }>(found)
|
|
50
|
+
const names = new Set([...namesIn(lifecycle.activeRelease?.definition, kind), ...namesIn(lifecycle.draft?.definition, kind)])
|
|
51
|
+
// The same name in other letters is the same name to a reader, and to a URL.
|
|
52
|
+
const as = [...names].find((n) => n.toLowerCase() === apiName.toLowerCase())
|
|
53
|
+
return as ? { checked: true, taken: true, as } : { checked: true, taken: false }
|
|
54
|
+
} catch (err) {
|
|
55
|
+
return { checked: false, why: `the organization's Blueprint couldn't be read (${(err as Error).message})` }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** What `blueprint new` says about the check, or null when there is nothing to say. */
|
|
60
|
+
export function nameCheckWords(kind: ArtifactKind, apiName: string, check: NameCheck): string | null {
|
|
61
|
+
if (!check.checked || !check.taken) return null
|
|
62
|
+
const word = kind.replace('-', ' ')
|
|
63
|
+
return `warning: the organization already has a ${word} ${check.as}${check.as === apiName ? '' : ` (the same name to a reader)`}. If it is outside this workspace's Blueprint, a change request refuses a new one of that name: give this one a more specific API name (Sales${apiName[0]!.toUpperCase()}${apiName.slice(1)}), or ask a Blueprint Steward to grant the workspace the organization's`
|
|
64
|
+
}
|
package/src/blueprint/tree.ts
CHANGED
|
@@ -22,7 +22,35 @@ import {
|
|
|
22
22
|
|
|
23
23
|
const READABLE = new Set(['.yaml', '.yml', '.json'])
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Where a file fails to parse, as an editor jumps to it: `path:line:col: why`
|
|
27
|
+
* (Forge improvement plan N9). One YAML error used to fail every item in the
|
|
28
|
+
* Blueprint with no file name. Null when the text parses.
|
|
29
|
+
*/
|
|
30
|
+
export function parseProblem(path: string, text: string): string | null {
|
|
31
|
+
try {
|
|
32
|
+
if (extname(path) === '.json') JSON.parse(text)
|
|
33
|
+
else parseYaml(text)
|
|
34
|
+
return null
|
|
35
|
+
} catch (err) {
|
|
36
|
+
const e = err as { message?: string; linePos?: Array<{ line: number; col: number }> }
|
|
37
|
+
const at = e.linePos?.[0] ?? (() => {
|
|
38
|
+
// JSON: "… at position 41" (or "line 3 column 5").
|
|
39
|
+
const lc = /line (\d+) column (\d+)/.exec(e.message ?? '')
|
|
40
|
+
if (lc) return { line: Number(lc[1]), col: Number(lc[2]) }
|
|
41
|
+
const pos = /position (\d+)/.exec(e.message ?? '')
|
|
42
|
+
if (!pos) return null
|
|
43
|
+
const before = text.slice(0, Number(pos[1])).split('\n')
|
|
44
|
+
return { line: before.length, col: before[before.length - 1]!.length + 1 }
|
|
45
|
+
})()
|
|
46
|
+
const why = (e.message ?? 'does not parse').split('\n')[0]!.replace(/ at line \d+, column \d+:?$/, '').trim()
|
|
47
|
+
return `${path}${at ? `:${at.line}:${at.col}` : ''}: ${why}`
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
25
51
|
export function parseDocument(path: string, text: string): Record<string, unknown> {
|
|
52
|
+
const problem = parseProblem(path, text)
|
|
53
|
+
if (problem) throw new CliError(problem, { code: 'BLUEPRINT_FILE_INVALID', hint: 'fix the file at that line; nothing in the Blueprint was applied' })
|
|
26
54
|
const parsed = extname(path) === '.json' ? JSON.parse(text) : parseYaml(text)
|
|
27
55
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
28
56
|
throw new CliError(`${path} does not contain a mapping.`, {
|
|
@@ -29,11 +29,12 @@ export const actionDeploy: Command = {
|
|
|
29
29
|
// organization key carrying `actionMutationPlan`/`actionBinding` runs this.
|
|
30
30
|
authLane: 'api-key',
|
|
31
31
|
args: [{ name: 'action', required: true, description: 'The published Action’s API name' }],
|
|
32
|
-
flags: { 'dry-run': 'boolean', 'plan-id': 'string', 'binding-id': 'string' },
|
|
32
|
+
flags: { 'dry-run': 'boolean', 'plan-id': 'string', 'binding-id': 'string', attach: 'string', 'attach-item-id': 'string', 'file-input': 'string', 'media-set': 'string' },
|
|
33
33
|
summary: 'Build and validate the write path for a published Action',
|
|
34
34
|
examples: [
|
|
35
35
|
'frontera action deploy escalateTicket --dry-run',
|
|
36
36
|
'frontera action deploy escalateTicket',
|
|
37
|
+
'frontera action deploy attachPhoto --attach photo --file-input upload',
|
|
37
38
|
],
|
|
38
39
|
},
|
|
39
40
|
|
|
@@ -55,9 +56,24 @@ export const actionDeploy: Command = {
|
|
|
55
56
|
})
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
// An attachment Action (L6): the file arrives in --file-input, and the write fills --attach and/or --attach-item-id.
|
|
60
|
+
const attachProperty = flagString(ctx, 'attach')
|
|
61
|
+
const attachItemId = flagString(ctx, 'attach-item-id')
|
|
62
|
+
const fileInput = flagString(ctx, 'file-input')
|
|
63
|
+
const mediaSet = flagString(ctx, 'media-set')
|
|
64
|
+
if ((attachProperty || attachItemId || mediaSet) && !fileInput) {
|
|
65
|
+
throw new UsageError('an attachment needs the input the file arrives in', `frontera action deploy ${apiName} --attach <media property> --file-input <input>`)
|
|
66
|
+
}
|
|
67
|
+
if (fileInput && !attachProperty && !attachItemId) {
|
|
68
|
+
throw new UsageError('--file-input names where the file arrives, and nothing says what it fills', `add --attach <media property> or --attach-item-id <text property>`)
|
|
69
|
+
}
|
|
70
|
+
const attachment = fileInput
|
|
71
|
+
? { uploadInput: fileInput, ...(attachProperty ? { property: attachProperty } : {}), ...(attachItemId ? { itemIdProperty: attachItemId } : {}), ...(mediaSet ? { mediaSetId: mediaSet } : {}) }
|
|
72
|
+
: undefined
|
|
73
|
+
|
|
58
74
|
let plan
|
|
59
75
|
try {
|
|
60
|
-
plan = deriveOntologyEditPlan(definition, objectType)
|
|
76
|
+
plan = deriveOntologyEditPlan(definition, objectType, attachment)
|
|
61
77
|
} catch (err) {
|
|
62
78
|
if (err instanceof PlanDerivationError) {
|
|
63
79
|
throw new CliError(err.message, { code: 'VALIDATION_ERROR', hint: err.hint })
|
|
@@ -65,9 +81,10 @@ export const actionDeploy: Command = {
|
|
|
65
81
|
throw err
|
|
66
82
|
}
|
|
67
83
|
|
|
68
|
-
const writes =
|
|
69
|
-
.map(([property, parameter]) => `${property} ← ${parameter}`)
|
|
70
|
-
.join('
|
|
84
|
+
const writes = [
|
|
85
|
+
...Object.entries(plan.set).map(([property, parameter]) => `${property} ← ${parameter}`),
|
|
86
|
+
...(plan.mediaAttachment ? [`${[plan.mediaAttachment.propertyApiName, plan.mediaAttachment.itemIdProperty].filter(Boolean).join(' and ')} ← the file in ${plan.mediaAttachment.uploadParameter} (Media Set ${plan.mediaAttachment.mediaSetId})`] : []),
|
|
87
|
+
].join(', ')
|
|
71
88
|
|
|
72
89
|
if (flagBool(ctx, 'dry-run')) {
|
|
73
90
|
return {
|
|
@@ -6,6 +6,7 @@ import { actionGrant } from './grant'
|
|
|
6
6
|
import { actionList } from './list'
|
|
7
7
|
import { actionPrepare } from './prepare'
|
|
8
8
|
import { actionRequests } from './requests'
|
|
9
|
+
import { actionRetire } from './retire'
|
|
9
10
|
import { actionReview } from './review'
|
|
10
11
|
import { actionSubmit } from './submit'
|
|
11
12
|
import type { Command } from '../types'
|
|
@@ -41,6 +42,7 @@ export const actionCommands: readonly Command[] = [
|
|
|
41
42
|
actionReject,
|
|
42
43
|
actionCancel,
|
|
43
44
|
actionPrepare,
|
|
45
|
+
actionRetire,
|
|
44
46
|
actionDeploy,
|
|
45
47
|
actionReview,
|
|
46
48
|
actionGrant,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { GovernedActionApi } from '../../api/governed-action-api'
|
|
2
|
+
import { CliError, UsageError } from '../../errors'
|
|
3
|
+
import { flagString, type Command } from '../types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Retire an Action's deployment decision (Forge improvement plan X11).
|
|
7
|
+
*
|
|
8
|
+
* `action prepare` records that a drafted Action ships switched off, and the
|
|
9
|
+
* decision pins the Action's contract as it was then: editing the drafted
|
|
10
|
+
* Action after preparing it left it stranded, since publish refuses the
|
|
11
|
+
* changed contract and a second prepare refuses a second decision. Retiring
|
|
12
|
+
* the decision stands it down, so `action prepare` can record one for the
|
|
13
|
+
* contract as it is now.
|
|
14
|
+
*
|
|
15
|
+
* Only a decision that was never bound, or one already draining, retires:
|
|
16
|
+
* the service refuses an active deployment, which is switched off first. It
|
|
17
|
+
* arms nothing and removes nothing from the Blueprint. It takes a signed-in
|
|
18
|
+
* person with `actionBinding: retire`.
|
|
19
|
+
*/
|
|
20
|
+
export const actionRetire: Command = {
|
|
21
|
+
meta: {
|
|
22
|
+
noun: 'action',
|
|
23
|
+
verb: 'retire',
|
|
24
|
+
authLane: 'session',
|
|
25
|
+
args: [{ name: 'action', required: true, description: 'The Action’s API name' }],
|
|
26
|
+
flags: { reason: 'string' },
|
|
27
|
+
summary: 'Retire an Action’s deployment decision, so a changed draft can be prepared again',
|
|
28
|
+
details: [
|
|
29
|
+
'For an Action prepared before its draft changed: retire, then `frontera action prepare <action>` records a decision for the contract as it is now.',
|
|
30
|
+
'An active deployment is refused: switch it off in Console first.',
|
|
31
|
+
],
|
|
32
|
+
examples: [
|
|
33
|
+
'frontera action retire escalateTicket --reason "draft changed"',
|
|
34
|
+
'frontera action prepare escalateTicket',
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
async run(ctx) {
|
|
39
|
+
const apiName = ctx.positional[0]
|
|
40
|
+
if (!apiName) throw new UsageError('missing <action>', 'frontera action retire escalateTicket --reason "…"')
|
|
41
|
+
const reason = flagString(ctx, 'reason')?.trim()
|
|
42
|
+
if (!reason) throw new UsageError('say why with --reason', 'the reason is kept with the deployment’s history: frontera action retire escalateTicket --reason "the draft changed after it was prepared"')
|
|
43
|
+
|
|
44
|
+
const client = new GovernedActionApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
45
|
+
const definitionId = await client.actionDefinitionId(apiName)
|
|
46
|
+
if (!definitionId) throw new CliError(`No Action "${apiName}" on the draft or in the release.`, { code: 'ACTION_NOT_FOUND', hint: 'frontera action list' })
|
|
47
|
+
const current = await client.currentDeployment(definitionId)
|
|
48
|
+
if (!current) {
|
|
49
|
+
return { data: { action: apiName, retired: false }, text: `"${apiName}" carries no deployment decision: nothing to retire. \`frontera action prepare ${apiName}\` records one.` }
|
|
50
|
+
}
|
|
51
|
+
if (current.state === 'active') {
|
|
52
|
+
throw new CliError(`"${apiName}" is deployed and switched on; an active deployment isn't retired.`, { code: 'CONFLICT', hint: 'switch it off in Console (the Action’s deployment) first, then retire it' })
|
|
53
|
+
}
|
|
54
|
+
const retired = await client.retireDeployment(current.stateId, current.generation, reason)
|
|
55
|
+
return {
|
|
56
|
+
data: { action: apiName, retired: true, from: current.state, deployment: retired },
|
|
57
|
+
text: `Retired the ${current.state} deployment decision of "${apiName}".\n\nNext:\n frontera action prepare ${apiName}`,
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
}
|
|
@@ -57,6 +57,7 @@ export function renderComposition(snapshot: Row, lookups: Lookups): string {
|
|
|
57
57
|
['id', str(config, 'id') || (lookups.identity?.id ?? '')],
|
|
58
58
|
['status', str(config, 'lifecycleStatus')],
|
|
59
59
|
['default mode', str(config, 'defaultMode')],
|
|
60
|
+
['fallback mode', str(config, 'fallbackMode')],
|
|
60
61
|
['live version', lookups.identity?.version ? String(lookups.identity.version) : ''],
|
|
61
62
|
] as Array<[string, string]>) {
|
|
62
63
|
if (v) lines.push(`${k.padEnd(14)}${v}`)
|