@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/src/exit.ts
CHANGED
|
@@ -43,10 +43,14 @@ const USAGE_CODES = new Set([
|
|
|
43
43
|
'VALIDATION_ERROR',
|
|
44
44
|
'BAD_REQUEST',
|
|
45
45
|
'NOT_FOUND',
|
|
46
|
+
// A Blueprint file that doesn't parse, named with its line and column: fixed in the file.
|
|
47
|
+
'BLUEPRINT_FILE_INVALID',
|
|
46
48
|
// Profile selection. Every one of these is fixed by changing the command or
|
|
47
49
|
// the directory binding — never by retrying, which is what separates them
|
|
48
50
|
// from AUTH below: a wrong profile is a usage mistake, a revoked key is not.
|
|
49
51
|
'PROFILE_NOT_FOUND',
|
|
52
|
+
// A change from a local project bound for another deployment, not said with --profile (X3).
|
|
53
|
+
'REMOTE_FROM_LOCAL_PROJECT',
|
|
50
54
|
'PROFILE_NOT_SELECTED',
|
|
51
55
|
'PROFILE_ORIGIN_MISMATCH',
|
|
52
56
|
'PROFILE_INVALID',
|
|
@@ -314,6 +318,28 @@ export function withRefusedNames(message: string, details: unknown): string {
|
|
|
314
318
|
return names.length ? `${message}: ${names.join(', ')}` : message
|
|
315
319
|
}
|
|
316
320
|
|
|
321
|
+
/**
|
|
322
|
+
* The blockers a refusal carries in its details (`RELEASE_BLOCKED`'s
|
|
323
|
+
* `blockers`: code, path, message), said under its message, at most five
|
|
324
|
+
* (Forge improvement plan N9). Before, `blueprint publish` and `local dev`
|
|
325
|
+
* printed "Blueprint validation is blocked." and nothing about why.
|
|
326
|
+
*/
|
|
327
|
+
export function withBlockers(message: string, details: unknown): string {
|
|
328
|
+
if (typeof details !== 'object' || details === null) return message
|
|
329
|
+
const body = details as { blockers?: unknown; details?: { blockers?: unknown } }
|
|
330
|
+
const list = Array.isArray(body.blockers) ? body.blockers : Array.isArray(body.details?.blockers) ? body.details.blockers : []
|
|
331
|
+
const lines = list.flatMap((b): string[] => {
|
|
332
|
+
if (typeof b !== 'object' || b === null) return []
|
|
333
|
+
const { code, path, message: why } = b as { code?: unknown; path?: unknown; message?: unknown }
|
|
334
|
+
const text = typeof why === 'string' ? why : typeof code === 'string' ? code : ''
|
|
335
|
+
if (!text) return []
|
|
336
|
+
return [`${typeof code === 'string' ? `[${code}] ` : ''}${typeof path === 'string' && path ? `${path}: ` : ''}${text}`]
|
|
337
|
+
})
|
|
338
|
+
if (!lines.length) return message
|
|
339
|
+
const shown = lines.slice(0, 5)
|
|
340
|
+
return [`${message.replace(/[.:]\s*$/, '')}:`, ...shown.map((l) => ` ✕ ${l}`), ...(lines.length > shown.length ? [` … and ${lines.length - shown.length} more`] : [])].join('\n')
|
|
341
|
+
}
|
|
342
|
+
|
|
317
343
|
function isFronteraError(
|
|
318
344
|
err: unknown,
|
|
319
345
|
): err is { code: string; message: string; details?: unknown } {
|
|
@@ -346,7 +372,7 @@ export function toEnvelope(err: unknown): ErrorEnvelope {
|
|
|
346
372
|
}
|
|
347
373
|
const hint = hintFor(err.code, err.details)
|
|
348
374
|
const reason = reasonOf(err.details)
|
|
349
|
-
return { error: true, code: err.code, message: withRefusedNames(err.message, err.details), ...(hint ? { hint } : {}), ...(reason ? { details: { reason } } : {}) }
|
|
375
|
+
return { error: true, code: err.code, message: withBlockers(withRefusedNames(err.message, err.details), err.details), ...(hint ? { hint } : {}), ...(reason ? { details: { reason } } : {}) }
|
|
350
376
|
}
|
|
351
377
|
|
|
352
378
|
return {
|
|
@@ -359,15 +385,17 @@ export function toEnvelope(err: unknown): ErrorEnvelope {
|
|
|
359
385
|
/**
|
|
360
386
|
* The one recovery for a key sent to a command that acts as a person.
|
|
361
387
|
*
|
|
362
|
-
* `frontera
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
388
|
+
* `frontera auth add` stores only `sk-ws-`/`sk-org-` keys; a person's session
|
|
389
|
+
* comes from `frontera auth login --browser`, approved in the browser (Forge
|
|
390
|
+
* improvement plan N5). Before, this told people to set FRONTERA_TOKEN to a
|
|
391
|
+
* session token, which they could only copy out of the browser's developer
|
|
392
|
+
* tools. Worded once here because every session-lane noun meets the same
|
|
393
|
+
* refusal — 401 on session-only routers, 403 on programmatic ones — and
|
|
394
|
+
* per-API copies of this sentence had started to drift apart.
|
|
367
395
|
*/
|
|
368
396
|
export const SESSION_LANE_HINT =
|
|
369
|
-
'this command acts as a person and refuses API keys —
|
|
370
|
-
+ '
|
|
397
|
+
'this command acts as a person and refuses API keys — sign in as yourself with '
|
|
398
|
+
+ '`frontera auth login --browser` (approved in your browser, lasts 12 hours), then re-run'
|
|
371
399
|
|
|
372
400
|
/** What a session-lane command needs to know about itself to word a refusal. */
|
|
373
401
|
export interface SessionLaneMeta {
|
package/src/flag-help.ts
CHANGED
|
@@ -15,7 +15,16 @@
|
|
|
15
15
|
export const FLAG_HELP: Readonly<Record<string, string>> = {
|
|
16
16
|
// Agent↔automation bindings. `agent` is deliberately singular: a binding
|
|
17
17
|
// names one agent, and there is no wildcard form.
|
|
18
|
+
envelope: 'JSON as one document on stdout whatever happens (implies --json): { ok, data, warnings }, or { ok: false, error, warnings } on a failure (FRONTERA_JSON_ENVELOPE=1 sets it for every command)',
|
|
19
|
+
model: 'a catalog model to run on instead of the Agents’ own (their drafts are put back after)',
|
|
20
|
+
baseline: 'a kept test run to compare with: last, last-own, last-<model>, or a run id',
|
|
21
|
+
'via-actions': 'submit each record as the governed Action its group names, as the local submitter persona',
|
|
22
|
+
'no-snapshot': 'don’t save the records first (by default a load saves them, and a refused record puts them back)',
|
|
23
|
+
batch: 'how many records are submitted at once (1 to 16, default 4)',
|
|
18
24
|
agent: 'agent slug this binding is for — one agent, no wildcard',
|
|
25
|
+
app: 'a Custom App by its slug: the App may start the Function as the person using it',
|
|
26
|
+
apps: 'list the Custom Apps that may start it, instead of the agents',
|
|
27
|
+
off: 'keep the binding, switched off',
|
|
19
28
|
approval: '"required" (default) asks a human before each call; "auto" does not',
|
|
20
29
|
wait: 'seconds the agent waits for the run before it is told the run is still going (5–300)',
|
|
21
30
|
// Declarative Blueprint authoring — the file tree, and what applying it may do.
|
|
@@ -31,6 +40,12 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
|
|
|
31
40
|
'dry-run': 'derive and print the write path without building anything',
|
|
32
41
|
'plan-id': 'reuse this mutation plan id instead of minting one, so a retry names the same artifact',
|
|
33
42
|
'binding-id': 'reuse this Binding id instead of minting one, so a retry names the same artifact',
|
|
43
|
+
attach: 'the media reference property an attachment Action fills with the file it is given',
|
|
44
|
+
like: 'start from another Agent’s files, by its slug',
|
|
45
|
+
customer: 'the Agent serves customers: write behavior settings (operating.json)',
|
|
46
|
+
'attach-item-id': 'the text property that keeps the attached file\'s Media Item id (a link key)',
|
|
47
|
+
'file-input': 'the Action input the file arrives in: a staged upload id (`frontera media stage`) or a run\'s file',
|
|
48
|
+
'media-set': 'the Media Set the attached file joins; defaults to the media reference property\'s own set',
|
|
34
49
|
reason: 'reason recorded with the deployment transition, request or approval decision; the reason of a rejection in an approval chain is shown to whoever raised the request',
|
|
35
50
|
stage: 'approval-chain step being decided: required for a request approved in steps, and refused if another step is waiting by then',
|
|
36
51
|
'no-activate': 'review the write path but leave it switched off',
|
|
@@ -103,6 +118,9 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
|
|
|
103
118
|
query: 'a query string to send with the delivery, such as source=portal&urgent=1',
|
|
104
119
|
'fail-fast': 'stop the check at the first step that fails, instead of running and reporting every step',
|
|
105
120
|
strict: 'fail on inherited failures too: items and files unchanged since the pull, which otherwise are listed and do not fail the check',
|
|
121
|
+
'org-profile': 'the organization profile organization-scoped commands use in this directory, beside a workspace profile on the same deployment',
|
|
122
|
+
repeat: 'run each Agent test case N times (1–10) and report its pass rate; a case that passes some runs and fails others is flaky',
|
|
123
|
+
'pass-rate': 'with --repeat, the share of runs a flaky case must pass to pass (default 0.8, or 80%); below it, it fails',
|
|
106
124
|
concurrency: 'how many Agent test cases run at once (1–16, default 2); cases that change records for real still run one at a time. Lower it when the model provider refuses for requests in flight',
|
|
107
125
|
|
|
108
126
|
// Apps
|
|
@@ -263,6 +281,18 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
|
|
|
263
281
|
picks: 'a side per conflict, numbered as `diff` shows them: 1=workspace,2=mine; =both keeps mine then the workspace’s where `diff` says both can be kept',
|
|
264
282
|
edited: 'take the files as you edited them; the workspace’s copies beside them go',
|
|
265
283
|
title: 'the change request’s title; a push that opens a new one needs it',
|
|
284
|
+
why: 'a person-step: why it is needed now, in a sentence',
|
|
285
|
+
changes: 'a person-step: what it changes, in the workspace’s words',
|
|
286
|
+
credential: 'a person-step: who or what it needs (workspace-key, organization-key, steward, admin, second-person, person)',
|
|
287
|
+
delivery: 'a person-step: the delivery it belongs to; steps with the same one show as one card',
|
|
288
|
+
all: 'list the settled ones too',
|
|
289
|
+
browser: 'sign in by approving a code in the browser, as yourself',
|
|
290
|
+
'no-traces': 'leave out what each turn’s trace says',
|
|
291
|
+
tests: 'only what test runs started',
|
|
292
|
+
brief: 'the short form, without inputs and outputs',
|
|
293
|
+
conversation: 'only this conversation’s',
|
|
294
|
+
errors: 'only those with an error',
|
|
295
|
+
transcripts: 'include each case’s whole conversation, turn by turn',
|
|
266
296
|
migration: 'for a Blueprint change that is not additive: what happens to existing records',
|
|
267
297
|
documents: 'comma-separated knowledge bases whose documents a pull brings (a base pulled now brings its own anyway), or a push proposes (`*` for every one)',
|
|
268
298
|
'no-documents': 'pull knowledge bases without their documents (by default a pulled base brings them)',
|
|
@@ -276,6 +306,7 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
|
|
|
276
306
|
conflicts: 'the conflicts the picks were chosen against, as `diff --json` names them; refused if they moved',
|
|
277
307
|
'from-workspace': 'start the project from a platform workspace instead of the examples',
|
|
278
308
|
'no-sample-data': 'do not generate sample rows for the pulled Blueprint',
|
|
309
|
+
'no-kit': 'leave out the Frontera skills (the kit) that local init vendors into the project; for a machine that uses the installed plugin instead',
|
|
279
310
|
'keep-sample-data': 'leave the sample datasets as they are; by default they are made again (same seed, rows and day) after a pull that brings Blueprint items, or when an older generator made them',
|
|
280
311
|
'remake-sample-data': 'make the sample datasets again with the newest sample rules (same seed, rows and day), even the ones a pull wrote, which local dev otherwise keeps as pulled',
|
|
281
312
|
synthetic: 'generate sample rows for the Blueprint: values fit each property, links stay consistent',
|
|
@@ -6,6 +6,7 @@ import { ApprovalBroker, type ApprovalDecision, type Emit, type HostAdapter, typ
|
|
|
6
6
|
import { ClaudeEventTranslator } from './claude-translator'
|
|
7
7
|
import { forgeEnv, forgePath } from './env'
|
|
8
8
|
import type { Logger } from './log'
|
|
9
|
+
import { fronteraPermissionRules, runsPersonStep } from './permission-rules'
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Claude Code as a Forge host, driven through `@anthropic-ai/claude-agent-sdk`.
|
|
@@ -48,6 +49,15 @@ export function autoAllows(mode: string, toolName: string): boolean {
|
|
|
48
49
|
return false
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* A call the mode never answers on its own: a shell command that runs a
|
|
54
|
+
* person-step (`blueprint apply`, `app promote`, …). It always gets a card,
|
|
55
|
+
* even under Full access, where every other call is approved upstream.
|
|
56
|
+
*/
|
|
57
|
+
export function asksThePerson(toolName: string, input: Record<string, unknown>): boolean {
|
|
58
|
+
return toolName === 'Bash' && typeof input.command === 'string' && runsPersonStep(input.command)
|
|
59
|
+
}
|
|
60
|
+
|
|
51
61
|
/** The thread's effort, when the SDK would accept it. */
|
|
52
62
|
export function claudeEffort(effort: string | null): Options['effort'] | undefined {
|
|
53
63
|
if (!effort || !EFFORT_LEVELS.has(effort)) return undefined
|
|
@@ -95,7 +105,7 @@ export class ClaudeAdapter implements HostAdapter {
|
|
|
95
105
|
|
|
96
106
|
const mode = ctx.thread.mode
|
|
97
107
|
const canUseTool: CanUseTool = async (toolName, input, options) => {
|
|
98
|
-
if (autoAllows(mode, toolName)) return { behavior: 'allow', updatedInput: input }
|
|
108
|
+
if (autoAllows(mode, toolName) && !asksThePerson(toolName, input)) return { behavior: 'allow', updatedInput: input }
|
|
99
109
|
const toolCallId = options.toolUseID ?? crypto.randomUUID()
|
|
100
110
|
flush(translator.ensureToolCall(toolCallId, toolName, input))
|
|
101
111
|
const decision = await this.approvals.request(ctx, emit, toolCallId, toolName, input)
|
|
@@ -117,10 +127,13 @@ export class ClaudeAdapter implements HostAdapter {
|
|
|
117
127
|
...(ctx.thread.hostSessionId ? { resume: ctx.thread.hostSessionId } : {}),
|
|
118
128
|
includePartialMessages: true,
|
|
119
129
|
settingSources: ['user', 'project', 'local'],
|
|
130
|
+
// The same rules as a terminal session: read-only `frontera` commands
|
|
131
|
+
// run without a card, delivery commands always raise one.
|
|
132
|
+
settings: { permissions: fronteraPermissionRules() },
|
|
120
133
|
additionalDirectories: [ctx.project.root],
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
134
|
+
// An ask rule reaches the callback in every mode, Full access included,
|
|
135
|
+
// so a delivery command is still a card there.
|
|
136
|
+
canUseTool,
|
|
124
137
|
env: forgeEnv(ctx.project.profile ? { FRONTERA_PROFILE: ctx.project.profile } : {}),
|
|
125
138
|
...(await this.mcpServers(ctx)),
|
|
126
139
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Claude Code permission rules Forge gives every Claude Code session it
|
|
3
|
+
* starts, in its terminal (`--settings`) and in its in-app chat (the Agent
|
|
4
|
+
* SDK's `settings`).
|
|
5
|
+
*
|
|
6
|
+
* Without rules, every `frontera` command went to Claude Code's auto-mode
|
|
7
|
+
* classifier: it denied `blueprint apply` and `app promote` outright, and a
|
|
8
|
+
* classifier outage ended the turn even on `--help`. With them:
|
|
9
|
+
*
|
|
10
|
+
* - commands that only read are allowed, so they never wait on a classifier
|
|
11
|
+
* or a prompt;
|
|
12
|
+
* - commands that make something live, or act with a person's authority,
|
|
13
|
+
* always ask. An ask rule prompts in every mode, Auto and Full access
|
|
14
|
+
* included, so no mode turns a delivery step into one the agent takes on
|
|
15
|
+
* its own.
|
|
16
|
+
*
|
|
17
|
+
* The lists name exact command paths, never a wildcard across arguments: an
|
|
18
|
+
* allow rule `frontera * list` would also match a write whose last argument
|
|
19
|
+
* is "list". Claude Code checks each part of a compound command (`&&`, `|`,
|
|
20
|
+
* `;`) on its own, so `frontera status && <anything>` is not allowed by the
|
|
21
|
+
* first part.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Commands that only read: help, status, lists, single items, diffs and previews. */
|
|
25
|
+
export const FRONTERA_READ_ONLY_COMMANDS: readonly string[] = [
|
|
26
|
+
'--help',
|
|
27
|
+
'--version',
|
|
28
|
+
'help',
|
|
29
|
+
'auth list',
|
|
30
|
+
'auth current',
|
|
31
|
+
'kit status',
|
|
32
|
+
'kit doctor',
|
|
33
|
+
'workspace list',
|
|
34
|
+
'local status',
|
|
35
|
+
'local logs',
|
|
36
|
+
'local schema',
|
|
37
|
+
'local providers',
|
|
38
|
+
'local models-available',
|
|
39
|
+
'local key-list',
|
|
40
|
+
'local sync status',
|
|
41
|
+
'local sync diff',
|
|
42
|
+
'local sync push --preview',
|
|
43
|
+
'local sync pull --preview',
|
|
44
|
+
'local sync request show',
|
|
45
|
+
'app list',
|
|
46
|
+
'app versions',
|
|
47
|
+
'app checks',
|
|
48
|
+
'app deployments',
|
|
49
|
+
'agent list',
|
|
50
|
+
'agent get',
|
|
51
|
+
'agent diff',
|
|
52
|
+
'agent versions',
|
|
53
|
+
'skill list',
|
|
54
|
+
'skill get',
|
|
55
|
+
'plugin list',
|
|
56
|
+
'plugin get',
|
|
57
|
+
'plugin catalog',
|
|
58
|
+
'capability list',
|
|
59
|
+
'capability available',
|
|
60
|
+
'capability installs',
|
|
61
|
+
'dataset list',
|
|
62
|
+
'dataset get',
|
|
63
|
+
'dataset sources',
|
|
64
|
+
'source list',
|
|
65
|
+
'source get',
|
|
66
|
+
'knowledge list',
|
|
67
|
+
'knowledge get',
|
|
68
|
+
'knowledge sources',
|
|
69
|
+
'knowledge agents',
|
|
70
|
+
'media list',
|
|
71
|
+
'media status',
|
|
72
|
+
'pack list',
|
|
73
|
+
'pack get',
|
|
74
|
+
'secret list',
|
|
75
|
+
'chat-app list',
|
|
76
|
+
'chat-app get',
|
|
77
|
+
'function list',
|
|
78
|
+
'function versions',
|
|
79
|
+
'function runs',
|
|
80
|
+
'function bindings',
|
|
81
|
+
'workflow list',
|
|
82
|
+
'workflow get',
|
|
83
|
+
'workflow validate',
|
|
84
|
+
'workflow runs',
|
|
85
|
+
'workflow lineage',
|
|
86
|
+
'blueprint list',
|
|
87
|
+
'blueprint get',
|
|
88
|
+
'blueprint status',
|
|
89
|
+
'blueprint plan',
|
|
90
|
+
'blueprint object-sets',
|
|
91
|
+
'marking list',
|
|
92
|
+
'action list',
|
|
93
|
+
'action requests',
|
|
94
|
+
'action available',
|
|
95
|
+
'conversation get',
|
|
96
|
+
'conversation search',
|
|
97
|
+
'trace get',
|
|
98
|
+
'trace list',
|
|
99
|
+
'local steps check',
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Commands that make something live, decide for the workspace or set a
|
|
104
|
+
* secret. They are the person's calls: the agent prepares them, the person
|
|
105
|
+
* says yes to each one.
|
|
106
|
+
*/
|
|
107
|
+
export const FRONTERA_PERSON_COMMANDS: readonly string[] = [
|
|
108
|
+
'blueprint apply',
|
|
109
|
+
'blueprint publish',
|
|
110
|
+
'blueprint rollback',
|
|
111
|
+
'blueprint grant',
|
|
112
|
+
'app promote',
|
|
113
|
+
'app rollback',
|
|
114
|
+
'agent publish',
|
|
115
|
+
'chat-app publish',
|
|
116
|
+
'function promote',
|
|
117
|
+
'workflow promote',
|
|
118
|
+
'local sync request apply',
|
|
119
|
+
'action deploy',
|
|
120
|
+
'action review',
|
|
121
|
+
'action retire',
|
|
122
|
+
'action grant',
|
|
123
|
+
'action approve',
|
|
124
|
+
'action reject',
|
|
125
|
+
'capability grant',
|
|
126
|
+
'media grant',
|
|
127
|
+
'marking grant',
|
|
128
|
+
'secret set',
|
|
129
|
+
// Stores a provider key from the shell (L9); not on the read-only list, whose rule would match it by prefix.
|
|
130
|
+
'local embeddings use',
|
|
131
|
+
'secret delete',
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
const rule = (path: string) => `Bash(frontera ${path}:*)`
|
|
135
|
+
|
|
136
|
+
/** The `permissions` block for a Claude Code session Forge starts. */
|
|
137
|
+
export function fronteraPermissionRules(): { allow: string[]; ask: string[] } {
|
|
138
|
+
return { allow: FRONTERA_READ_ONLY_COMMANDS.map(rule), ask: FRONTERA_PERSON_COMMANDS.map(rule) }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** `FOO=1 frontera …`: assignments before the command name it doesn't. */
|
|
142
|
+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=\S*$/
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Whether a shell command runs a person-step: any part of it, split the way
|
|
146
|
+
* Claude Code splits a compound command, that starts with `frontera` and one
|
|
147
|
+
* of the person-step paths. The in-app chat asks for these on a card in every
|
|
148
|
+
* mode, Full access included, as the ask rules do in the terminal.
|
|
149
|
+
*/
|
|
150
|
+
export function runsPersonStep(command: string): boolean {
|
|
151
|
+
return command.split(/&&|\|\||[;|&\n]/).some((part) => {
|
|
152
|
+
const words = part.trim().split(/\s+/).filter(Boolean)
|
|
153
|
+
while (words.length && ASSIGNMENT.test(words[0]!)) words.shift()
|
|
154
|
+
if (words[0] !== 'frontera' && !/\/frontera$/.test(words[0] ?? '')) return false
|
|
155
|
+
const rest = words.slice(1).join(' ')
|
|
156
|
+
return FRONTERA_PERSON_COMMANDS.some((path) => rest === path || rest.startsWith(`${path} `))
|
|
157
|
+
})
|
|
158
|
+
}
|
package/src/forge/touches.ts
CHANGED
|
@@ -44,11 +44,12 @@ export const FORGE_ARTIFACT_KINDS = [
|
|
|
44
44
|
* `search`, `runs`, `bindings` — is a read and is deliberately absent.
|
|
45
45
|
*/
|
|
46
46
|
export const TOUCHING_VERBS: Record<ForgeArtifactKind, readonly string[]> = {
|
|
47
|
-
agent
|
|
47
|
+
// `behavior clear <agent>` stages the Agent's behavior settings away (X4); its Agent is the second positional.
|
|
48
|
+
agent: ['apply', 'create', 'publish', 'revert', 'discard', 'skill-attach', 'skill-detach', 'archive', 'restore', 'behavior'],
|
|
48
49
|
automation: ['deploy', 'promote', 'enable', 'disable', 'init', 'bind', 'unbind', 'run'],
|
|
49
50
|
app: ['deploy', 'promote', 'rollback', 'unpin', 'save', 'add'],
|
|
50
51
|
blueprint: ['apply', 'publish', 'create', 'update', 'delete', 'rename', 'rollback', 'bind', 'grant', 'adopt', 'editable'],
|
|
51
|
-
action: ['prepare', 'deploy', 'grant', 'review'],
|
|
52
|
+
action: ['prepare', 'deploy', 'grant', 'review', 'retire'],
|
|
52
53
|
skill: ['push', 'delete'],
|
|
53
54
|
source: ['create', 'update', 'revise', 'disable'],
|
|
54
55
|
// `revise` cuts the dataset's next revision from its CSV.
|
|
@@ -119,12 +120,12 @@ const BLUEPRINT_KINDED_VERBS = new Set(['update', 'delete', 'rename'])
|
|
|
119
120
|
*/
|
|
120
121
|
export const BOOLEAN_FLAGS = new Set([
|
|
121
122
|
// Global.
|
|
122
|
-
'json', 'quiet', 'yes', 'help', 'force',
|
|
123
|
+
'json', 'envelope', 'quiet', 'yes', 'help', 'force',
|
|
123
124
|
// Every `'boolean'` FlagSpec a command declares (`grep "'boolean'" src/commands`).
|
|
124
125
|
'accept-contract-change', 'accept-orphaned-knowledge', 'dry-run', 'end-user', 'no-activate',
|
|
125
126
|
'no-components', 'no-git', 'no-input', 'no-install', 'no-open', 'no-promote', 'no-wait',
|
|
126
|
-
'remove-apps', 'token-stdin', 'prune', 'dev', 'revoke', 'draft', 'check',
|
|
127
|
-
'skip-build-check', 'prod', 'preview', 'undo', 'build',
|
|
127
|
+
'customer', 'remove-apps', 'token-stdin', 'prune', 'dev', 'revoke', 'draft', 'check',
|
|
128
|
+
'skip-build-check', 'prod', 'preview', 'undo', 'build', 'off',
|
|
128
129
|
])
|
|
129
130
|
|
|
130
131
|
/**
|
|
@@ -398,7 +399,7 @@ export function parseFronteraCommands(line: string): ParsedFronteraCommand[] {
|
|
|
398
399
|
if (!TOUCHING_VERBS[kind].includes(verb)) continue
|
|
399
400
|
const ref = (FILE_POSITIONAL_VERBS[kind] ?? []).includes(verb)
|
|
400
401
|
? null
|
|
401
|
-
: kind === 'blueprint' && BLUEPRINT_KINDED_VERBS.has(verb)
|
|
402
|
+
: (kind === 'blueprint' && BLUEPRINT_KINDED_VERBS.has(verb)) || (kind === 'agent' && verb === 'behavior')
|
|
402
403
|
? (rest[1] ?? null)
|
|
403
404
|
: (rest[0] ?? null)
|
|
404
405
|
const live =
|
|
@@ -53,10 +53,20 @@ const within = (root: string, path: string) => path === root || path.startsWith(
|
|
|
53
53
|
* files it imports inside it too, as archive entries under `frontera-imports/`.
|
|
54
54
|
*/
|
|
55
55
|
export function outsideImports(projectRoot: string, entry: string): SourceFile[] {
|
|
56
|
+
return importGraph(projectRoot, entry).outside
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Every file `entry` reaches by relative import, followed file to file:
|
|
61
|
+
* `inside` the project folder (absolute paths, the entry included), and the
|
|
62
|
+
* ones `outside` it as archive entries under `frontera-imports/`.
|
|
63
|
+
*/
|
|
64
|
+
export function importGraph(projectRoot: string, entry: string): { inside: string[]; outside: SourceFile[] } {
|
|
56
65
|
const root = resolve(projectRoot)
|
|
57
66
|
const bound = resolve(solutionRoot(root))
|
|
58
67
|
const seen = new Set<string>()
|
|
59
68
|
const out: SourceFile[] = []
|
|
69
|
+
const inside: string[] = []
|
|
60
70
|
const read = (path: string): string | null => {
|
|
61
71
|
try {
|
|
62
72
|
return existsSync(path) && lstatSync(path).isFile() ? readFileSync(path, 'utf8') : null
|
|
@@ -69,7 +79,8 @@ export function outsideImports(projectRoot: string, entry: string): SourceFile[]
|
|
|
69
79
|
const text = read(file)
|
|
70
80
|
if (text === null) return
|
|
71
81
|
seen.add(file)
|
|
72
|
-
if (
|
|
82
|
+
if (within(root, file)) inside.push(file)
|
|
83
|
+
else {
|
|
73
84
|
const rel = relative(root, file).split(sep).join('/')
|
|
74
85
|
if (CODE.test(file) && !isExcluded(rel.replace(/^(\.\.\/)+/, ''))) out.push({ rel: outsidePath(rel), bytes: new TextEncoder().encode(text) })
|
|
75
86
|
}
|
|
@@ -85,5 +96,5 @@ export function outsideImports(projectRoot: string, entry: string): SourceFile[]
|
|
|
85
96
|
}
|
|
86
97
|
}
|
|
87
98
|
visit(resolve(entry))
|
|
88
|
-
return out
|
|
99
|
+
return { inside, outside: out }
|
|
89
100
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { join, relative, resolve, sep } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { importGraph } from './function-outside-imports'
|
|
5
|
+
import { collectSourceFiles, formatBytes, gitignoreMatcher, isExcluded, type SourceFile } from './packaging'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What a Function's source archive carries (Forge improvement plan L7; P4
|
|
9
|
+
* 2.9, P5 A14: a one-file Function in a project carried the whole project,
|
|
10
|
+
* 424 files and 8.3 MB, as its source).
|
|
11
|
+
*
|
|
12
|
+
* - A Function with a project of its own (`functions/<slug>/package.json`,
|
|
13
|
+
* or a project `frontera function init` made) keeps that project's files.
|
|
14
|
+
* - A Function in a solution project's own package (`functions/<slug>.ts`,
|
|
15
|
+
* whose nearest `package.json` is the project's, beside `frontera.yaml`)
|
|
16
|
+
* keeps only what it reaches: its entry, every file the entry imports by
|
|
17
|
+
* relative path, and the package files it builds with.
|
|
18
|
+
* - `.fronteraignore` at the packed folder's root, in `.gitignore` form,
|
|
19
|
+
* leaves more out. The hard exclusions (`.env`, `node_modules`, build
|
|
20
|
+
* output, `.frontera/`) always apply.
|
|
21
|
+
* - Past `SOURCE_WARN_BYTES` or `SOURCE_WARN_FILES`, the deploy warns and
|
|
22
|
+
* names the largest files.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const SOURCE_WARN_BYTES = 2 * 1024 * 1024
|
|
26
|
+
export const SOURCE_WARN_FILES = 150
|
|
27
|
+
export const IGNORE_FILE = '.fronteraignore'
|
|
28
|
+
|
|
29
|
+
/** The files a one-file Function builds with, beside what it imports. */
|
|
30
|
+
const PACKAGE_FILES = ['package.json', 'tsconfig.json', 'bun.lock', 'bun.lockb', 'package-lock.json', 'bunfig.toml', IGNORE_FILE]
|
|
31
|
+
|
|
32
|
+
const posixRel = (root: string, path: string) => relative(root, path).split(sep).join('/')
|
|
33
|
+
|
|
34
|
+
/** `.fronteraignore`'s rules at `root`, in `.gitignore` form; nothing ignored without one. */
|
|
35
|
+
function fronteraIgnore(root: string): (rel: string) => boolean {
|
|
36
|
+
const path = join(root, IGNORE_FILE)
|
|
37
|
+
if (!existsSync(path)) return () => false
|
|
38
|
+
// The `.gitignore` matcher, read from the other file.
|
|
39
|
+
const rules = readFileSync(path, 'utf8')
|
|
40
|
+
.split('\n')
|
|
41
|
+
.map((l) => l.trim())
|
|
42
|
+
.filter((l) => l && !l.startsWith('#') && !l.startsWith('!'))
|
|
43
|
+
.map((l) => l.replace(/\/$/, ''))
|
|
44
|
+
return (rel) => {
|
|
45
|
+
const segments = rel.split('/')
|
|
46
|
+
return rules.some((rule) => (rule.startsWith('*.') ? rel.endsWith(rule.slice(1)) : segments.includes(rule) || rel === rule || (rule.includes('/') && (rel === rule || rel.startsWith(`${rule}/`)))))
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface FunctionSource {
|
|
51
|
+
files: SourceFile[]
|
|
52
|
+
/** True when only the files the entry reaches were kept (a one-file Function in a solution project). */
|
|
53
|
+
reachedOnly: boolean
|
|
54
|
+
/** How many files `.fronteraignore` left out. */
|
|
55
|
+
ignored: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The source of the Function at `entry`, packed from `packageRoot` (its nearest package.json's folder). */
|
|
59
|
+
export function functionSourceFiles(packageRoot: string, entry: string): FunctionSource {
|
|
60
|
+
const root = resolve(packageRoot)
|
|
61
|
+
const graph = importGraph(root, resolve(entry))
|
|
62
|
+
const reachedOnly = existsSync(join(root, 'frontera.yaml'))
|
|
63
|
+
let own: SourceFile[]
|
|
64
|
+
if (reachedOnly) {
|
|
65
|
+
const gitignored = gitignoreMatcher(root)
|
|
66
|
+
const paths = new Set([...graph.inside, ...PACKAGE_FILES.map((f) => join(root, f)).filter((p) => existsSync(p) && statSync(p).isFile())])
|
|
67
|
+
own = [...paths]
|
|
68
|
+
.map((p) => ({ path: p, rel: posixRel(root, p) }))
|
|
69
|
+
.filter(({ rel }) => !isExcluded(rel) && !gitignored(rel))
|
|
70
|
+
.sort((a, b) => a.rel.localeCompare(b.rel))
|
|
71
|
+
.map(({ path, rel }) => ({ rel, bytes: new Uint8Array(readFileSync(path)) }))
|
|
72
|
+
} else {
|
|
73
|
+
own = collectSourceFiles(root)
|
|
74
|
+
}
|
|
75
|
+
const ignore = fronteraIgnore(root)
|
|
76
|
+
const kept = own.filter((f) => !ignore(f.rel))
|
|
77
|
+
return { files: [...kept, ...graph.outside], reachedOnly, ignored: own.length - kept.length }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The warning for a source past the limits, naming its largest files; null under them. */
|
|
81
|
+
export function sourceSizeWarning(name: string, files: SourceFile[]): string | null {
|
|
82
|
+
const bytes = files.reduce((n, f) => n + f.bytes.byteLength, 0)
|
|
83
|
+
if (bytes <= SOURCE_WARN_BYTES && files.length <= SOURCE_WARN_FILES) return null
|
|
84
|
+
const largest = [...files]
|
|
85
|
+
.sort((a, b) => b.bytes.byteLength - a.bytes.byteLength)
|
|
86
|
+
.slice(0, 3)
|
|
87
|
+
.map((f) => `${f.rel} (${formatBytes(f.bytes.byteLength)})`)
|
|
88
|
+
.join(', ')
|
|
89
|
+
return `the source of ${name} is ${files.length} files, ${formatBytes(bytes)} (largest: ${largest}). Every version keeps it, and a pull brings it back: leave out what the Function doesn't need with ${IGNORE_FILE} (.gitignore form) beside its package.json`
|
|
90
|
+
}
|
package/src/harness.ts
CHANGED
|
@@ -70,10 +70,10 @@ Two fields decide whether a call can work at all:
|
|
|
70
70
|
|
|
71
71
|
- \`authLane\` — \`api-key\` reaches the command with \`sk-ws-\`/\`sk-org-\` or a
|
|
72
72
|
session. \`session\` acts as a person and needs a person's session, not an API
|
|
73
|
-
key:
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
key: the person signs in with \`frontera auth login --browser\` (approved in
|
|
74
|
+
their browser, 12 hours), and those commands use it. The CLI refuses an
|
|
75
|
+
\`sk-ws-\`/\`sk-org-\` key on those before sending anything. Never ask the
|
|
76
|
+
person for a session token: prepare the step for them instead. On a project's local runtime, never fetch a person's
|
|
77
77
|
session: \`--as <persona>\` runs \`action available\`, \`submit\`,
|
|
78
78
|
\`approve\` and \`reject\` as a local persona (\`--as submitter\`,
|
|
79
79
|
\`--as <approver>\`; \`frontera local status\` lists them).
|
package/src/key-reach.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { BlueprintAuthoringApi } from './api/blueprint-authoring-api'
|
|
2
|
+
import { PlatformApi } from './api/platform-api'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What an organization key can reach (Forge improvement plan X3, T7; P2 F6,
|
|
6
|
+
* P5 A16): workspace-scoped commands run with the organization key were
|
|
7
|
+
* refused, and nothing said which workspaces the key may act in.
|
|
8
|
+
*
|
|
9
|
+
* An organization key carries a list of workspaces, and the service refuses
|
|
10
|
+
* an `x-workspace-id` that is not on it before anything else runs. So the
|
|
11
|
+
* key's own `whoami`, asked once per workspace, answers whether it may act
|
|
12
|
+
* there without the CLI reading the key's row or guessing.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface ReachedWorkspace {
|
|
16
|
+
id: string
|
|
17
|
+
name: string
|
|
18
|
+
slug: string
|
|
19
|
+
/** The key may act in it (`--workspace <id>`). */
|
|
20
|
+
actsIn: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** At most this many workspaces are asked about; the rest are listed without the mark. */
|
|
24
|
+
export const MAX_REACH_PROBES = 50
|
|
25
|
+
|
|
26
|
+
/** Whether the key may act in the workspace: its `whoami` there answers with it. */
|
|
27
|
+
export async function actsInWorkspace(apiUrl: string, token: string, workspaceId: string): Promise<boolean> {
|
|
28
|
+
try {
|
|
29
|
+
const me = await new PlatformApi(apiUrl, token, workspaceId).whoami()
|
|
30
|
+
return me.workspaceId === workspaceId
|
|
31
|
+
} catch {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The organization's workspaces, each marked with whether this key may act
|
|
38
|
+
* in it. Null when the key can't list them (a workspace key, or one without
|
|
39
|
+
* `organization:update`).
|
|
40
|
+
*/
|
|
41
|
+
export async function keyReach(
|
|
42
|
+
apiUrl: string,
|
|
43
|
+
token: string,
|
|
44
|
+
deps: { list?: () => Promise<Array<{ id?: string; name?: string; slug?: string }>>; acts?: (workspaceId: string) => Promise<boolean> } = {},
|
|
45
|
+
): Promise<ReachedWorkspace[] | null> {
|
|
46
|
+
const list = deps.list ?? (() => new BlueprintAuthoringApi(apiUrl, token).orgWorkspaces())
|
|
47
|
+
const acts = deps.acts ?? ((id: string) => actsInWorkspace(apiUrl, token, id))
|
|
48
|
+
let rows: Array<{ id?: string; name?: string; slug?: string }>
|
|
49
|
+
try {
|
|
50
|
+
rows = await list()
|
|
51
|
+
} catch {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
const named = rows.filter((w): w is { id: string; name?: string; slug?: string } => typeof w.id === 'string' && w.id !== '')
|
|
55
|
+
return Promise.all(named.map(async (w, i) => ({ id: w.id, name: w.name ?? '', slug: w.slug ?? '', actsIn: i < MAX_REACH_PROBES ? await acts(w.id) : false })))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The reach in one line: `may act in 2 of 5 workspaces: Ops, Claims`. */
|
|
59
|
+
export function reachWords(reach: readonly ReachedWorkspace[]): string {
|
|
60
|
+
const acting = reach.filter((w) => w.actsIn)
|
|
61
|
+
if (!reach.length) return 'the organization has no workspaces it can list'
|
|
62
|
+
if (!acting.length) return `may act in none of the organization's ${reach.length} workspace${reach.length === 1 ? '' : 's'}: its workspace-scoped commands are refused`
|
|
63
|
+
return `may act in ${acting.length} of ${reach.length} workspace${reach.length === 1 ? '' : 's'}: ${acting.map((w) => w.name || w.slug || w.id).join(', ')} (\`--workspace <id>\`; \`frontera workspace list\` marks them)`
|
|
64
|
+
}
|
|
@@ -251,7 +251,7 @@ interface DeployFailure {
|
|
|
251
251
|
}
|
|
252
252
|
|
|
253
253
|
/** The write path for one Action that writes the Blueprint's records: built, validated, reviewed, activated. */
|
|
254
|
-
async function deployOne(call: Call, published: Deployable): Promise<string | DeployFailure | null> {
|
|
254
|
+
async function deployOne(call: Call, published: Deployable, attachment?: LocalAction['attachment']): Promise<string | DeployFailure | null> {
|
|
255
255
|
const { definition, contractDigest } = published
|
|
256
256
|
const types = data<Array<{ id?: string; apiName?: string }>>(await call('org', 'GET', '/v1/blueprint/object-types')) ?? []
|
|
257
257
|
const subject = (Array.isArray(types) ? types : []).find((t) => t.id === definition.subject.objectTypeId)?.apiName
|
|
@@ -260,9 +260,19 @@ async function deployOne(call: Call, published: Deployable): Promise<string | De
|
|
|
260
260
|
await call('org', 'GET', `/v1/blueprint/object-types/${encodeURIComponent(subject)}`),
|
|
261
261
|
)
|
|
262
262
|
if (!shape?.objectType?.id) return `${subject} is not published here`
|
|
263
|
+
// A Media Set named in the file is found by its API name among this runtime's sets (the stand-ins included).
|
|
264
|
+
let mediaSetId: string | undefined
|
|
265
|
+
if (attachment?.mediaSet) {
|
|
266
|
+
const sets = data<Array<{ id?: string; apiName?: string }>>(await call('session', 'GET', '/v1/media/sets')) ?? []
|
|
267
|
+
mediaSetId = (Array.isArray(sets) ? sets : []).find((m) => m.apiName === attachment.mediaSet || m.id === attachment.mediaSet)?.id
|
|
268
|
+
if (!mediaSetId) return `its attachment names the Media Set ${attachment.mediaSet}, which this runtime has no set of`
|
|
269
|
+
}
|
|
270
|
+
const choice = attachment
|
|
271
|
+
? { uploadInput: attachment.fileInput, ...(attachment.property ? { property: attachment.property } : {}), ...(attachment.itemIdProperty ? { itemIdProperty: attachment.itemIdProperty } : {}), ...(mediaSetId ? { mediaSetId } : {}) }
|
|
272
|
+
: undefined
|
|
263
273
|
let plan
|
|
264
274
|
try {
|
|
265
|
-
plan = deriveOntologyEditPlan(definition, { id: shape.objectType.id, apiName: shape.objectType.apiName ?? subject, primaryKeyPropertyId: shape.objectType.primaryKeyPropertyId ?? null, properties: shape.properties ?? [] })
|
|
275
|
+
plan = deriveOntologyEditPlan(definition, { id: shape.objectType.id, apiName: shape.objectType.apiName ?? subject, primaryKeyPropertyId: shape.objectType.primaryKeyPropertyId ?? null, properties: shape.properties ?? [] }, choice)
|
|
266
276
|
} catch (err) {
|
|
267
277
|
if (err instanceof PlanDerivationError) return err.message
|
|
268
278
|
throw err
|
|
@@ -309,6 +319,8 @@ export interface LocalAction {
|
|
|
309
319
|
fit: ActionFit
|
|
310
320
|
/** The capability that lets a role invoke it (`governance.invokeCapability`). */
|
|
311
321
|
invokeCapability?: string
|
|
322
|
+
/** How its deployment attaches the file it takes: its file's `attachment` (L6). */
|
|
323
|
+
attachment?: { fileInput: string; property?: string; itemIdProperty?: string; mediaSet?: string }
|
|
312
324
|
}
|
|
313
325
|
|
|
314
326
|
/**
|
|
@@ -338,7 +350,7 @@ export async function deployActions(call: Call, actions: LocalAction[]): Promise
|
|
|
338
350
|
// Not active, or active on a write path whose validation no longer holds: built again.
|
|
339
351
|
if (published.deploymentStatus !== 'active' || published.reason === 'ACTION_BINDING_VALIDATION_STALE') {
|
|
340
352
|
try {
|
|
341
|
-
const why = await deployOne(call, published)
|
|
353
|
+
const why = await deployOne(call, published, action.attachment)
|
|
342
354
|
failure = typeof why === 'string' ? { reason: why } : why
|
|
343
355
|
} catch (err) {
|
|
344
356
|
failure = { reason: (err as Error).message }
|