@frontera-sdk/cli 1.52.45 → 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/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/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 +30 -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-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-stand-ins.ts +44 -0
- 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 +11 -11
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, relative, sep } from 'node:path'
|
|
3
|
+
import { parseDocument } from 'yaml'
|
|
4
|
+
|
|
5
|
+
import { CliError, UsageError } from '../errors'
|
|
6
|
+
import { starterAgentFiles } from './local-scaffold'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Starting and tidying project files without the web app (Forge improvement
|
|
10
|
+
* plan L8).
|
|
11
|
+
*
|
|
12
|
+
* `frontera local new agent <slug>` writes a new Agent's folder: the
|
|
13
|
+
* scaffold's starter, or with `--like <agent>` a copy of another Agent's
|
|
14
|
+
* files (its instructions, bindings, behavior settings and cases) under the
|
|
15
|
+
* new name. `frontera local fmt` writes the project's JSON and YAML files in
|
|
16
|
+
* one shape (two-space indents, a final newline, comments kept), so a diff
|
|
17
|
+
* shows what changed rather than how it was typed.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const SLUG = /^[a-z][a-z0-9-]{0,62}$/
|
|
21
|
+
|
|
22
|
+
export function titleOf(slug: string): string {
|
|
23
|
+
return slug.split('-').filter(Boolean).map((w) => w[0]!.toUpperCase() + w.slice(1)).join(' ')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function filesUnder(root: string, dir: string): string[] {
|
|
27
|
+
const out: string[] = []
|
|
28
|
+
const walk = (at: string) => {
|
|
29
|
+
for (const entry of readdirSync(at)) {
|
|
30
|
+
const full = join(at, entry)
|
|
31
|
+
if (statSync(full).isDirectory()) walk(full)
|
|
32
|
+
else out.push(relative(root, full).split(sep).join('/'))
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (existsSync(join(root, dir))) walk(join(root, dir))
|
|
36
|
+
return out.sort()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface NewAgentOptions {
|
|
40
|
+
like?: string
|
|
41
|
+
name?: string
|
|
42
|
+
description?: string
|
|
43
|
+
model?: string
|
|
44
|
+
customer?: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The new Agent's files by project path; nothing is written. */
|
|
48
|
+
export function newAgentFiles(root: string, slug: string, opts: NewAgentOptions = {}): Record<string, string> {
|
|
49
|
+
if (!SLUG.test(slug)) throw new UsageError(`"${slug}" is not an Agent slug`, 'lower-case letters, digits and hyphens, starting with a letter: `support-concierge`')
|
|
50
|
+
if (existsSync(join(root, 'agents', slug))) throw new CliError(`agents/${slug}/ is already there`, { code: 'USAGE', hint: 'choose another slug, or edit that Agent' })
|
|
51
|
+
const name = opts.name ?? titleOf(slug)
|
|
52
|
+
if (!opts.like) return starterAgentFiles(slug, { name, ...(opts.description ? { description: opts.description } : {}), ...(opts.model ? { model: opts.model } : {}), ...(opts.customer ? { customer: true } : {}) })
|
|
53
|
+
|
|
54
|
+
const from = `agents/${opts.like}`
|
|
55
|
+
const files = filesUnder(root, from)
|
|
56
|
+
if (!files.includes(`${from}/agent.json`)) throw new CliError(`there is no Agent at ${from}/ to start from`, { code: 'USAGE', hint: '`frontera local status` lists the project\'s Agents' })
|
|
57
|
+
if (opts.customer) throw new UsageError('--customer and --like together', 'the copy keeps the other Agent\'s behavior settings as they are; leave --customer out')
|
|
58
|
+
const out: Record<string, string> = {}
|
|
59
|
+
for (const rel of files) out[`agents/${slug}/${rel.slice(from.length + 1)}`] = readFileSync(join(root, rel), 'utf8')
|
|
60
|
+
// Its own name and description; the model only when one is given.
|
|
61
|
+
const agentPath = `agents/${slug}/agent.json`
|
|
62
|
+
let doc: { config?: Record<string, unknown>; modelConfigs?: Array<Record<string, unknown>> }
|
|
63
|
+
try {
|
|
64
|
+
doc = JSON.parse(out[agentPath]!) as typeof doc
|
|
65
|
+
} catch (err) {
|
|
66
|
+
throw new CliError(`${from}/agent.json could not be read: ${(err as Error).message}`, { code: 'USAGE', hint: 'fix that file, or start from the scaffold without --like' })
|
|
67
|
+
}
|
|
68
|
+
doc.config = { ...(doc.config ?? {}), name, description: opts.description ?? (doc.config?.description as string | null | undefined) ?? null }
|
|
69
|
+
if (opts.model) {
|
|
70
|
+
doc.modelConfigs = [{ modeKey: 'default', modelSlug: opts.model, displayName: 'Default', sortOrder: 0 }]
|
|
71
|
+
doc.config.defaultMode = 'default'
|
|
72
|
+
}
|
|
73
|
+
out[agentPath] = `${JSON.stringify(doc, null, 2)}\n`
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Write files that are not there; one that is would be the person's, so nothing is written then. */
|
|
78
|
+
export function writeNewFiles(root: string, files: Record<string, string>): string[] {
|
|
79
|
+
const taken = Object.keys(files).filter((rel) => existsSync(join(root, rel)))
|
|
80
|
+
if (taken.length) throw new CliError(`${taken[0]} is already there`, { code: 'USAGE', hint: 'nothing was written' })
|
|
81
|
+
for (const [rel, text] of Object.entries(files)) {
|
|
82
|
+
mkdirSync(dirname(join(root, rel)), { recursive: true })
|
|
83
|
+
writeFileSync(join(root, rel), text)
|
|
84
|
+
}
|
|
85
|
+
return Object.keys(files).sort()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── fmt ─────────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/** The folders whose JSON and YAML are project files; an App's own code and the tool state are left alone. */
|
|
91
|
+
const FORMATTED_DIRS = ['agents', 'workflows', 'blueprint', 'knowledge', 'skills', 'chat-apps', 'themes', 'datasets', 'mcp']
|
|
92
|
+
const FORMATTED_ROOT_FILES = ['frontera.yaml']
|
|
93
|
+
const FORMATTED = /\.(json|ya?ml)$/
|
|
94
|
+
|
|
95
|
+
/** The project files `fmt` reads: under the project folders, apps/<slug>.json, and frontera.yaml. */
|
|
96
|
+
export function formattableFiles(root: string): string[] {
|
|
97
|
+
const out = [...FORMATTED_DIRS.flatMap((d) => filesUnder(root, d)).filter((rel) => FORMATTED.test(rel) && !rel.split('/').includes('node_modules'))]
|
|
98
|
+
if (existsSync(join(root, 'apps'))) for (const entry of readdirSync(join(root, 'apps'))) if (entry.endsWith('.json') && statSync(join(root, 'apps', entry)).isFile()) out.push(`apps/${entry}`)
|
|
99
|
+
for (const f of FORMATTED_ROOT_FILES) if (existsSync(join(root, f))) out.push(f)
|
|
100
|
+
return out.sort()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The file in the project's shape, or an error naming where it can't be read. */
|
|
104
|
+
export function formatted(rel: string, text: string): { text: string } | { error: string } {
|
|
105
|
+
if (rel.endsWith('.json')) {
|
|
106
|
+
try {
|
|
107
|
+
return { text: `${JSON.stringify(JSON.parse(text), null, 2)}\n` }
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return { error: (err as Error).message }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const doc = parseDocument(text)
|
|
113
|
+
if (doc.errors.length) {
|
|
114
|
+
const e = doc.errors[0]!
|
|
115
|
+
return { error: `${e.linePos?.[0] ? `line ${e.linePos[0].line}, column ${e.linePos[0].col}: ` : ''}${e.message.split('\n')[0]}` }
|
|
116
|
+
}
|
|
117
|
+
// Comments and block text kept; long text never folded onto new lines.
|
|
118
|
+
const out = doc.toString({ indent: 2, lineWidth: 0, minContentWidth: 0 })
|
|
119
|
+
return { text: out.endsWith('\n') ? out : `${out}\n` }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface FmtOutcome {
|
|
123
|
+
changed: string[]
|
|
124
|
+
unchanged: number
|
|
125
|
+
failed: Array<{ path: string; error: string }>
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Format the project's files; with `check`, write nothing and only say which would change. */
|
|
129
|
+
export function formatProject(root: string, opts: { check?: boolean; only?: readonly string[] } = {}): FmtOutcome {
|
|
130
|
+
const all = formattableFiles(root)
|
|
131
|
+
const files = opts.only?.length ? all.filter((rel) => opts.only!.some((p) => rel === p || rel.startsWith(`${p.replace(/\/$/, '')}/`))) : all
|
|
132
|
+
const outcome: FmtOutcome = { changed: [], unchanged: 0, failed: [] }
|
|
133
|
+
for (const rel of files) {
|
|
134
|
+
const before = readFileSync(join(root, rel), 'utf8')
|
|
135
|
+
const after = formatted(rel, before)
|
|
136
|
+
if ('error' in after) {
|
|
137
|
+
outcome.failed.push({ path: rel, error: after.error })
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
if (after.text === before) {
|
|
141
|
+
outcome.unchanged++
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
outcome.changed.push(rel)
|
|
145
|
+
if (!opts.check) writeFileSync(join(root, rel), after.text)
|
|
146
|
+
}
|
|
147
|
+
return outcome
|
|
148
|
+
}
|
|
@@ -101,7 +101,7 @@ export const KEYLESS_KNOWLEDGE_THRESHOLD = 0.99
|
|
|
101
101
|
* embeddings (`local status`, `knowledge get` and `search`, the apply's
|
|
102
102
|
* warning): the retrieval is approximate, and what gives the platform's.
|
|
103
103
|
*/
|
|
104
|
-
export const KEYLESS_RETRIEVAL_NOTE = `retrieval here runs on approximate local embeddings (words hashed into vectors): a passage is found by the words it shares with the question, not by its meaning, and each knowledge base searches with threshold ${KEYLESS_KNOWLEDGE_THRESHOLD}, set for these vectors (its file keeps its own threshold for the workspace). A key the provider accepts for an embedding model gives the platform's retrieval: \`frontera local key-set <provider>\` (openai or openrouter; google when the workspace embeds with Gemini's model), then \`frontera local stop\` and \`frontera local start\`; the next \`frontera local dev\` sets each base's threshold for that model`
|
|
104
|
+
export const KEYLESS_RETRIEVAL_NOTE = `retrieval here runs on approximate local embeddings (words hashed into vectors): a passage is found by the words it shares with the question, not by its meaning, and each knowledge base searches with threshold ${KEYLESS_KNOWLEDGE_THRESHOLD}, set for these vectors (its file keeps its own threshold for the workspace). A key the provider accepts for an embedding model gives the platform's retrieval (\`frontera local embeddings\` says which key you already have): \`frontera local key-set <provider>\` (openai or openrouter; google when the workspace embeds with Gemini's model), then \`frontera local stop\` and \`frontera local start\`; the next \`frontera local dev\` sets each base's threshold for that model`
|
|
105
105
|
|
|
106
106
|
/** How a difference line says the runtime refused the workspace's model through this computer's key (`mirrorOrgSettings`). */
|
|
107
107
|
const KEY_REFUSED = /; the runtime refused it through this computer’s (\S+) key/
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { writeFileAtomic } from '../fs-atomic'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Person-steps (Forge improvement plan T4, N4): a step only the person may
|
|
8
|
+
* take (publishing, promoting, applying a change request, deciding for an
|
|
9
|
+
* Action, granting, setting a secret), prepared by the coding agent and run
|
|
10
|
+
* by the person, in their own terminal, with their own identity.
|
|
11
|
+
*
|
|
12
|
+
* The agent never approves its own work. Before, it could only say what was
|
|
13
|
+
* ready, so the person became its terminal: commands typed out by hand and
|
|
14
|
+
* their output pasted back, ten or more times in one delivery. Now the agent
|
|
15
|
+
* records the step (`person_step`, `frontera local steps add`): the command,
|
|
16
|
+
* why it is needed, what it changes and the credential it needs. Forge shows
|
|
17
|
+
* the steps as one delivery card, and **Run in my terminal** types the
|
|
18
|
+
* command into a shell without pressing Enter. The person reads it and
|
|
19
|
+
* presses Enter. Nothing here runs a command.
|
|
20
|
+
*
|
|
21
|
+
* Kept in `.frontera/sync/person-steps.json`, a file, so the list outlives
|
|
22
|
+
* the agent's context and a restart.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const PERSON_STEPS_FILE = join('.frontera', 'sync', 'person-steps.json')
|
|
26
|
+
|
|
27
|
+
/** Who or what the step needs. */
|
|
28
|
+
export const CREDENTIALS = ['workspace-key', 'organization-key', 'steward', 'admin', 'second-person', 'person'] as const
|
|
29
|
+
export type Credential = (typeof CREDENTIALS)[number]
|
|
30
|
+
|
|
31
|
+
/** Each credential in the person's words, as the card and the list say it. */
|
|
32
|
+
export const CREDENTIAL_WORDS: Record<Credential, string> = {
|
|
33
|
+
'workspace-key': 'the project’s workspace key',
|
|
34
|
+
'organization-key': 'an organization key',
|
|
35
|
+
steward: 'a Blueprint Steward',
|
|
36
|
+
admin: 'a workspace admin',
|
|
37
|
+
'second-person': 'a second person (not whoever made the change)',
|
|
38
|
+
person: 'you, signed in (`frontera auth login --browser`)',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The read that proves a step done (N6): a read-only `frontera … --json`
|
|
43
|
+
* command, the list in its data (`in`, a property; the data itself when it
|
|
44
|
+
* is a list), the row whose fields equal `find`, and the fields that equal
|
|
45
|
+
* `expect` once the step is done.
|
|
46
|
+
*/
|
|
47
|
+
export interface StepCheck {
|
|
48
|
+
command: string
|
|
49
|
+
in?: string
|
|
50
|
+
find: Record<string, string | number | boolean>
|
|
51
|
+
expect: Record<string, string | number | boolean>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface PersonStep {
|
|
55
|
+
id: string
|
|
56
|
+
/** One `frontera` command, as the person runs it; null for a step done in the product. */
|
|
57
|
+
command: string | null
|
|
58
|
+
/** Why it is needed now. */
|
|
59
|
+
why: string
|
|
60
|
+
/** What it changes, in the workspace's words. */
|
|
61
|
+
changes: string
|
|
62
|
+
credential: Credential
|
|
63
|
+
/** The delivery it belongs to: steps with the same one show as one card, in order. */
|
|
64
|
+
delivery: string | null
|
|
65
|
+
status: 'pending' | 'done' | 'dismissed'
|
|
66
|
+
createdAt: string
|
|
67
|
+
doneAt?: string
|
|
68
|
+
/** Who saw it done: the person's mark, or its check. */
|
|
69
|
+
doneBy?: 'person' | 'check'
|
|
70
|
+
/** Where it came from: the coding agent, or what a change request left after Apply. */
|
|
71
|
+
source?: 'agent' | 'change-request'
|
|
72
|
+
/** A change request's own key for it (`action-deploy:qualifyLead`), so applying again updates it. */
|
|
73
|
+
key?: string
|
|
74
|
+
check?: StepCheck
|
|
75
|
+
lastCheckedAt?: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface StepsFile {
|
|
79
|
+
version: 1
|
|
80
|
+
steps: PersonStep[]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const MAX_STEPS = 200
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What would make a command unfit for the person to run from a card:
|
|
87
|
+
* anything but one `frontera` command on one line. A step is typed into the
|
|
88
|
+
* person's shell, so it never chains (`;`, `&&`, `|`), substitutes (`$(…)`,
|
|
89
|
+
* backticks) or redirects, and it never carries a secret: a value is piped
|
|
90
|
+
* in by the person (`… --from -`), never written into the step.
|
|
91
|
+
*/
|
|
92
|
+
export function commandProblem(command: string): string | null {
|
|
93
|
+
const c = command.trim()
|
|
94
|
+
if (!c) return 'the command is empty'
|
|
95
|
+
if (c.length > 500) return 'the command is longer than 500 characters'
|
|
96
|
+
if (/[\r\n]/.test(c)) return 'a step is one command on one line'
|
|
97
|
+
if (!/^frontera\s+\S/.test(c)) return 'a step is a `frontera` command'
|
|
98
|
+
if (/[;&|`<>]|\$\(/.test(c)) return 'a step is one command: no `;`, `&&`, `|`, redirects or `$(…)`'
|
|
99
|
+
if (looksSecret(c)) return 'the command carries what looks like a key, token or password; the person pipes a secret in (`--from -`), it is never written into a step'
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A key, token or password written into a command. */
|
|
104
|
+
export function looksSecret(text: string): boolean {
|
|
105
|
+
return (
|
|
106
|
+
/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/i.test(text) ||
|
|
107
|
+
/\b(?:sk|pk|rk|fk|fpk|fok|fwk|ghp|gho|xox[abpr])[-_][A-Za-z0-9_-]{12,}/.test(text) ||
|
|
108
|
+
/--(?:token|password|secret|api-key|key-value)[= ]\S+/i.test(text) ||
|
|
109
|
+
/\b[A-Fa-f0-9]{40,}\b/.test(text) ||
|
|
110
|
+
/\b[A-Za-z0-9+/]{48,}={0,2}(?![A-Za-z0-9+/=])/.test(text)
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function filePath(root: string): string {
|
|
115
|
+
return join(root, PERSON_STEPS_FILE)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function readPersonSteps(root: string): PersonStep[] {
|
|
119
|
+
const path = filePath(root)
|
|
120
|
+
if (!existsSync(path)) return []
|
|
121
|
+
try {
|
|
122
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<StepsFile>
|
|
123
|
+
return Array.isArray(parsed.steps) ? parsed.steps.filter((s): s is PersonStep => !!s && typeof s.id === 'string' && (typeof s.command === 'string' || s.command === null)) : []
|
|
124
|
+
} catch {
|
|
125
|
+
return []
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function writeSteps(root: string, steps: PersonStep[]): void {
|
|
130
|
+
const path = filePath(root)
|
|
131
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
132
|
+
writeFileAtomic(path, `${JSON.stringify({ version: 1, steps } satisfies StepsFile, null, 2)}\n`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface NewPersonStep {
|
|
136
|
+
command: string
|
|
137
|
+
why: string
|
|
138
|
+
changes: string
|
|
139
|
+
credential: string
|
|
140
|
+
delivery?: string | null
|
|
141
|
+
/** The read that proves it done, already checked (`checkProblem`). */
|
|
142
|
+
check?: StepCheck
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Each problem with a step as given; empty when it can be recorded. */
|
|
146
|
+
export function stepProblems(input: NewPersonStep): string[] {
|
|
147
|
+
const problems: string[] = []
|
|
148
|
+
const command = commandProblem(input.command)
|
|
149
|
+
if (command) problems.push(command)
|
|
150
|
+
if (!input.why?.trim()) problems.push('say why the step is needed (`why`)')
|
|
151
|
+
if (!input.changes?.trim()) problems.push('say what the step changes (`changes`)')
|
|
152
|
+
if (!(CREDENTIALS as readonly string[]).includes(input.credential)) problems.push(`the credential is one of: ${CREDENTIALS.join(', ')}`)
|
|
153
|
+
for (const [field, value] of [['why', input.why], ['changes', input.changes], ['delivery', input.delivery ?? '']] as const) {
|
|
154
|
+
if (value && looksSecret(value)) problems.push(`\`${field}\` carries what looks like a key, token or password`)
|
|
155
|
+
if (value && value.length > 1000) problems.push(`\`${field}\` is longer than 1000 characters`)
|
|
156
|
+
}
|
|
157
|
+
return problems
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Record a step. The same command pending in the same delivery is the same
|
|
162
|
+
* step, updated rather than listed twice, so an agent restating its plan
|
|
163
|
+
* after a compaction doesn't double the card.
|
|
164
|
+
*/
|
|
165
|
+
export function addPersonStep(root: string, input: NewPersonStep, now = new Date()): { step: PersonStep; updated: boolean } {
|
|
166
|
+
const problems = stepProblems(input)
|
|
167
|
+
if (problems.length) throw new Error(problems.join('; '))
|
|
168
|
+
const steps = readPersonSteps(root)
|
|
169
|
+
const command = input.command.trim().replace(/\s+/g, ' ')
|
|
170
|
+
const delivery = input.delivery?.trim() || null
|
|
171
|
+
const same = steps.find((s) => s.status === 'pending' && s.command === command && s.delivery === delivery)
|
|
172
|
+
if (same) {
|
|
173
|
+
Object.assign(same, { why: input.why.trim(), changes: input.changes.trim(), credential: input.credential as Credential, ...(input.check ? { check: input.check } : {}) })
|
|
174
|
+
writeSteps(root, steps)
|
|
175
|
+
return { step: same, updated: true }
|
|
176
|
+
}
|
|
177
|
+
if (steps.filter((s) => s.status === 'pending').length >= MAX_STEPS) throw new Error(`${MAX_STEPS} steps are already pending; mark the done ones done first`)
|
|
178
|
+
const step: PersonStep = {
|
|
179
|
+
id: `ps_${crypto.randomUUID().replace(/-/g, '').slice(0, 10)}`,
|
|
180
|
+
command,
|
|
181
|
+
why: input.why.trim(),
|
|
182
|
+
changes: input.changes.trim(),
|
|
183
|
+
credential: input.credential as Credential,
|
|
184
|
+
delivery,
|
|
185
|
+
status: 'pending',
|
|
186
|
+
createdAt: now.toISOString(),
|
|
187
|
+
source: 'agent',
|
|
188
|
+
...(input.check ? { check: input.check } : {}),
|
|
189
|
+
}
|
|
190
|
+
writeSteps(root, [...steps, step])
|
|
191
|
+
return { step, updated: false }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Mark a step done (the person ran it) or dismissed (not needed after all). */
|
|
195
|
+
export function settlePersonStep(root: string, id: string, status: 'done' | 'dismissed' | 'pending', now = new Date()): PersonStep {
|
|
196
|
+
const steps = readPersonSteps(root)
|
|
197
|
+
const step = steps.find((s) => s.id === id)
|
|
198
|
+
if (!step) throw new Error(`no person-step ${id}; \`frontera local steps\` lists them`)
|
|
199
|
+
step.status = status
|
|
200
|
+
if (status === 'pending') {
|
|
201
|
+
delete step.doneAt
|
|
202
|
+
delete step.doneBy
|
|
203
|
+
} else {
|
|
204
|
+
step.doneAt = now.toISOString()
|
|
205
|
+
step.doneBy = 'person'
|
|
206
|
+
}
|
|
207
|
+
writeSteps(root, steps)
|
|
208
|
+
return step
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Take settled steps off the list; pending ones stay. */
|
|
212
|
+
export function clearSettledSteps(root: string): number {
|
|
213
|
+
const steps = readPersonSteps(root)
|
|
214
|
+
const kept = steps.filter((s) => s.status === 'pending')
|
|
215
|
+
if (kept.length !== steps.length) writeSteps(root, kept)
|
|
216
|
+
return steps.length - kept.length
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The pending steps grouped as delivery cards, each in the order recorded; steps with no delivery come last as one group. */
|
|
220
|
+
export function deliveryGroups(steps: PersonStep[]): Array<{ delivery: string | null; steps: PersonStep[] }> {
|
|
221
|
+
const pending = steps.filter((s) => s.status === 'pending')
|
|
222
|
+
const order: Array<string | null> = []
|
|
223
|
+
for (const s of pending) if (!order.includes(s.delivery)) order.push(s.delivery)
|
|
224
|
+
order.sort((a, b) => (a === null ? 1 : b === null ? -1 : 0))
|
|
225
|
+
return order.map((delivery) => ({ delivery, steps: pending.filter((s) => s.delivery === delivery) }))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── What a change request leaves after Apply (N6) ───────────────────────────
|
|
229
|
+
|
|
230
|
+
/** One item of an applied request's after-apply list, as the platform returns it. */
|
|
231
|
+
export interface AfterApplyItem {
|
|
232
|
+
key: string
|
|
233
|
+
label: string
|
|
234
|
+
why: string
|
|
235
|
+
command?: string
|
|
236
|
+
credential: string
|
|
237
|
+
check?: StepCheck
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The after-apply list from an Apply's outcome; empty from a platform that sends none. */
|
|
241
|
+
export function afterApplyOf(outcome: unknown): AfterApplyItem[] {
|
|
242
|
+
const list = (outcome as { afterApply?: unknown } | null)?.afterApply
|
|
243
|
+
if (!Array.isArray(list)) return []
|
|
244
|
+
return list.filter((i): i is AfterApplyItem => !!i && typeof i === 'object' && typeof (i as AfterApplyItem).key === 'string' && typeof (i as AfterApplyItem).label === 'string')
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Why a check can't be run: it must be a read-only `frontera` command with `--json`. */
|
|
248
|
+
export function checkProblem(check: StepCheck, readOnly: readonly string[]): string | null {
|
|
249
|
+
const problem = commandProblem(check.command)
|
|
250
|
+
if (problem) return problem
|
|
251
|
+
const words = check.command.trim().split(/\s+/).slice(1)
|
|
252
|
+
if (!words.includes('--json')) return 'a check reads JSON (`--json`)'
|
|
253
|
+
const path = words.filter((w) => !w.startsWith('-')).join(' ')
|
|
254
|
+
if (!readOnly.some((r) => path === r || path.startsWith(`${r} `))) return 'a check is a command that only reads'
|
|
255
|
+
return null
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Record what a request left for a person, as person-steps of its delivery
|
|
260
|
+
* (the request's title). An item already listed (by its key) is updated, a
|
|
261
|
+
* settled one stays settled, so applying a request again doesn't bring back
|
|
262
|
+
* what was done.
|
|
263
|
+
*/
|
|
264
|
+
export function recordAfterApply(root: string, delivery: string, items: AfterApplyItem[], now = new Date()): PersonStep[] {
|
|
265
|
+
const steps = readPersonSteps(root)
|
|
266
|
+
const out: PersonStep[] = []
|
|
267
|
+
for (const item of items) {
|
|
268
|
+
const credential = ((CREDENTIALS as readonly string[]).includes(item.credential) ? item.credential : 'person') as Credential
|
|
269
|
+
const command = item.command && !commandProblem(item.command) ? item.command : null
|
|
270
|
+
const existing = steps.find((s) => s.key === item.key && s.delivery === delivery)
|
|
271
|
+
if (existing) {
|
|
272
|
+
Object.assign(existing, { command, why: item.why, changes: item.label, credential, ...(item.check ? { check: item.check } : {}) })
|
|
273
|
+
out.push(existing)
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
const step: PersonStep = {
|
|
277
|
+
id: `ps_${crypto.randomUUID().replace(/-/g, '').slice(0, 10)}`,
|
|
278
|
+
command,
|
|
279
|
+
why: item.why,
|
|
280
|
+
changes: item.label,
|
|
281
|
+
credential,
|
|
282
|
+
delivery,
|
|
283
|
+
status: 'pending',
|
|
284
|
+
createdAt: now.toISOString(),
|
|
285
|
+
source: 'change-request',
|
|
286
|
+
key: item.key,
|
|
287
|
+
...(item.check ? { check: item.check } : {}),
|
|
288
|
+
}
|
|
289
|
+
steps.push(step)
|
|
290
|
+
out.push(step)
|
|
291
|
+
}
|
|
292
|
+
if (out.length) writeSteps(root, steps)
|
|
293
|
+
return out
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Whether a check's data says the step is done: the row `find` names, with every field of `expect`. */
|
|
297
|
+
export function checkPasses(check: StepCheck, data: unknown): boolean {
|
|
298
|
+
const list = Array.isArray(data) ? data : check.in && data && typeof data === 'object' ? (data as Record<string, unknown>)[check.in] : null
|
|
299
|
+
if (!Array.isArray(list)) return false
|
|
300
|
+
const row = list.find((r) => r && typeof r === 'object' && Object.entries(check.find).every(([k, v]) => (r as Record<string, unknown>)[k] === v))
|
|
301
|
+
return !!row && Object.entries(check.expect).every(([k, v]) => (row as Record<string, unknown>)[k] === v)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Run each pending step's check, and mark the ones it proves done. `read`
|
|
306
|
+
* runs a check's command (the CLI itself, `--json`) and returns its data, or
|
|
307
|
+
* null when it could not run; such a step stays pending and says when it was
|
|
308
|
+
* last checked.
|
|
309
|
+
*/
|
|
310
|
+
export async function runStepChecks(
|
|
311
|
+
root: string,
|
|
312
|
+
read: (args: string[]) => Promise<unknown | null>,
|
|
313
|
+
readOnly: readonly string[],
|
|
314
|
+
now = new Date(),
|
|
315
|
+
): Promise<{ checked: number; done: PersonStep[]; problems: Array<{ id: string; problem: string }> }> {
|
|
316
|
+
const steps = readPersonSteps(root)
|
|
317
|
+
const done: PersonStep[] = []
|
|
318
|
+
const problems: Array<{ id: string; problem: string }> = []
|
|
319
|
+
let checked = 0
|
|
320
|
+
for (const step of steps) {
|
|
321
|
+
if (step.status !== 'pending' || !step.check) continue
|
|
322
|
+
const problem = checkProblem(step.check, readOnly)
|
|
323
|
+
if (problem) {
|
|
324
|
+
problems.push({ id: step.id, problem })
|
|
325
|
+
continue
|
|
326
|
+
}
|
|
327
|
+
checked++
|
|
328
|
+
const args = step.check.command.trim().split(/\s+/).slice(1).filter((w) => w !== '--json')
|
|
329
|
+
const data = await read(args)
|
|
330
|
+
step.lastCheckedAt = now.toISOString()
|
|
331
|
+
if (data !== null && checkPasses(step.check, data)) {
|
|
332
|
+
step.status = 'done'
|
|
333
|
+
step.doneAt = now.toISOString()
|
|
334
|
+
step.doneBy = 'check'
|
|
335
|
+
done.push(step)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (checked) writeSteps(root, steps)
|
|
339
|
+
return { checked, done, problems }
|
|
340
|
+
}
|
|
@@ -19,7 +19,8 @@ import type { LocalCredentials } from './local-state'
|
|
|
19
19
|
* approver may not be the submitter, and a chain needs one person per step.
|
|
20
20
|
* The submitter is an `owner` (an Action grants its invoke capability to
|
|
21
21
|
* owners only); approvers are `admin`s. `frontera action available|submit|approve|reject --as
|
|
22
|
-
* <persona>` then runs as that persona
|
|
22
|
+
* <persona>` then runs as that persona, and so do `media list|upload|stage` (both roles hold
|
|
23
|
+
* Media content write, so a persona can stage the file an attachment Action takes). The session it uses is minted for the
|
|
23
24
|
* one command, sent only to the local runtime, and never printed or logged.
|
|
24
25
|
*/
|
|
25
26
|
|
|
@@ -27,7 +28,11 @@ import type { LocalCredentials } from './local-state'
|
|
|
27
28
|
export const SUBMITTER = 'submitter'
|
|
28
29
|
|
|
29
30
|
/** The commands `--as` works on. */
|
|
30
|
-
export const ACT_AS_COMMANDS = [
|
|
31
|
+
export const ACT_AS_COMMANDS = [
|
|
32
|
+
'action available', 'action submit', 'action approve', 'action reject',
|
|
33
|
+
// L6: a persona's files, into the runtime's stand-in Media Sets, and staged for an Action to attach.
|
|
34
|
+
'media list', 'media upload', 'media stage',
|
|
35
|
+
] as const
|
|
31
36
|
|
|
32
37
|
const KEY = /^[a-z][a-z0-9_-]{0,62}$/
|
|
33
38
|
/** The most personas one seed takes (the route's limit). */
|
|
@@ -82,9 +87,11 @@ function actionDocs(root: string): Array<{ file: string; doc: Doc }> {
|
|
|
82
87
|
|
|
83
88
|
/**
|
|
84
89
|
* The personas the project's Actions need: one per approval step (a chain's
|
|
85
|
-
* stage key; `approver` for an Action approved in one step), and a
|
|
86
|
-
*
|
|
87
|
-
*
|
|
90
|
+
* stage key; `approver` for an Action approved in one step), and always a
|
|
91
|
+
* submitter, so `--as submitter` works for an Action that needs no approval
|
|
92
|
+
* too (Forge improvement plan X2; P1 #7, P4 2.3: PERSONA_NOT_FOUND when every
|
|
93
|
+
* Action had `approval: none`). A step decided by an App role is said, not
|
|
94
|
+
* seeded: a persona holds an organization role, not an App's.
|
|
88
95
|
*/
|
|
89
96
|
export function personaPlan(root: string): PersonaPlan {
|
|
90
97
|
const byKey = new Map<string, PersonaSpec>()
|
|
@@ -117,6 +124,10 @@ export function personaPlan(root: string): PersonaPlan {
|
|
|
117
124
|
}
|
|
118
125
|
add(SUBMITTER, `requests for ${apiName}`)
|
|
119
126
|
}
|
|
127
|
+
if (!byKey.has(SUBMITTER)) {
|
|
128
|
+
const names = actionDocs(root).map(({ file, doc }) => (typeof doc.apiName === 'string' ? doc.apiName : file))
|
|
129
|
+
add(SUBMITTER, names.length ? `requests for ${names.join(', ')}` : 'requests, as the person who submits them')
|
|
130
|
+
}
|
|
120
131
|
// The submitter first, then the approvers as their steps come.
|
|
121
132
|
const personas = [...byKey.values()].sort((a, b) => Number(b.key === SUBMITTER) - Number(a.key === SUBMITTER))
|
|
122
133
|
if (personas.length > MAX_PERSONAS) notes.push(`${personas.length - MAX_PERSONAS} more approval steps than the ${MAX_PERSONAS} personas a runtime seeds at once: the rest were left out`)
|
|
@@ -332,10 +332,31 @@ export function actionFit(doc: Doc, semantic?: SemanticFacts, opts: { changed?:
|
|
|
332
332
|
if (!effect || !ONTOLOGY_EDITS.has(effect)) return { runsLocally: false, reason: 'it changes a system outside the local runtime', subject, ...inherited }
|
|
333
333
|
const changes = (Array.isArray(doc.businessOutcomes) ? doc.businessOutcomes : []).flatMap((o: Doc) => (Array.isArray(o?.expectedChanges) ? o.expectedChanges : []))
|
|
334
334
|
const sets = changes.some((c: Doc) => c?.kind === 'property_set' && c?.value?.kind === 'input')
|
|
335
|
-
|
|
335
|
+
// An Action whose file says how its file is attached writes that (L6), even with no other field.
|
|
336
|
+
if (effect !== 'delete' && !sets && !actionAttachment(doc)) return { runsLocally: false, reason: 'it sets no field from its inputs, so there is nothing to write locally', subject, ...inherited }
|
|
336
337
|
return { runsLocally: true, subject, ...inherited }
|
|
337
338
|
}
|
|
338
339
|
|
|
340
|
+
/**
|
|
341
|
+
* How a deployment of the Action attaches the file it takes (L6), from its
|
|
342
|
+
* file's `attachment`: the input the file arrives in (`fileInput`), and the
|
|
343
|
+
* media reference property (`property`), the text property that keeps the
|
|
344
|
+
* item's id (`itemIdProperty`), or both, with the Media Set by API name when
|
|
345
|
+
* it is not the property's own. Null when the file says none. A deployment
|
|
346
|
+
* choice, not the Action's contract: `local dev` deploys with it, and it
|
|
347
|
+
* stays in the project when the Action is proposed.
|
|
348
|
+
*/
|
|
349
|
+
export function actionAttachment(doc: Doc): { fileInput: string; property?: string; itemIdProperty?: string; mediaSet?: string } | null {
|
|
350
|
+
const a = doc.attachment
|
|
351
|
+
if (!a || typeof a !== 'object' || typeof a.fileInput !== 'string' || !a.fileInput) return null
|
|
352
|
+
return {
|
|
353
|
+
fileInput: a.fileInput,
|
|
354
|
+
...(typeof a.property === 'string' && a.property ? { property: a.property } : {}),
|
|
355
|
+
...(typeof a.itemIdProperty === 'string' && a.itemIdProperty ? { itemIdProperty: a.itemIdProperty } : {}),
|
|
356
|
+
...(typeof a.mediaSet === 'string' && a.mediaSet ? { mediaSet: a.mediaSet } : {}),
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
339
360
|
/** What a pulled Action needs here: it is the workspace's as pulled, so a semantic-type conflict is inherited. */
|
|
340
361
|
export function actionNeeds(doc: Doc, semantic?: SemanticFacts): Need[] {
|
|
341
362
|
const conflicts = semantic ? actionSemanticConflicts(doc, semantic) : []
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { PlatformApi } from '../api/platform-api'
|
|
2
|
+
import { CliError } from '../errors'
|
|
3
|
+
import type { LocalCredentials } from './local-state'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Publish an Agent's draft on the local runtime (Forge improvement plan L2,
|
|
7
|
+
* T12; P3 F8). A public website key, a Chat App and a channel talk to an
|
|
8
|
+
* Agent's published version, and publishing one locally took a raw REST
|
|
9
|
+
* call. Local only: the workspace's Agent is published by a person, through
|
|
10
|
+
* a change request or on the platform.
|
|
11
|
+
*/
|
|
12
|
+
export async function publishLocalAgent(
|
|
13
|
+
link: { serviceUrl: string; creds: LocalCredentials },
|
|
14
|
+
slug: string,
|
|
15
|
+
notes: string | undefined,
|
|
16
|
+
deps: { api?: Pick<PlatformApi, 'allAgents' | 'publishAgent'> } = {},
|
|
17
|
+
): Promise<{ agent: string; id: string; published: unknown }> {
|
|
18
|
+
const api = deps.api ?? new PlatformApi(link.serviceUrl, link.creds.workspaceKey ?? link.creds.orgKey, link.creds.workspaceId)
|
|
19
|
+
const listed = (await api.allAgents()) as Array<{ id?: string; agentId?: string }>
|
|
20
|
+
const row = listed.find((a) => a.agentId === slug)
|
|
21
|
+
if (!row?.id) {
|
|
22
|
+
throw new CliError(`the local runtime has no Agent ${slug}`, { code: 'NOT_FOUND', hint: `apply it first: \`frontera local dev --once\` (agents/${slug}/agent.json)` })
|
|
23
|
+
}
|
|
24
|
+
const published = await api.publishAgent(row.id, { notes: notes ?? 'Published on the local runtime.' })
|
|
25
|
+
return { agent: slug, id: row.id, published }
|
|
26
|
+
}
|