@frontera-sdk/cli 1.52.39 → 1.52.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/package.json +6 -4
- package/src/adopt.ts +6 -2
- package/src/api/apps-api.ts +86 -4
- package/src/api/blueprint-authoring-api.ts +35 -11
- package/src/api/dataset-api.ts +29 -0
- package/src/api/governed-action-api.ts +90 -2
- package/src/api/platform-api.ts +87 -2
- package/src/api/workflow-api.ts +33 -2
- package/src/app-contract.ts +156 -0
- package/src/app-lock.ts +125 -0
- package/src/args.ts +14 -3
- package/src/auth-verify.ts +10 -0
- package/src/automation-template.ts +12 -1
- package/src/blueprint/action-rules.ts +516 -0
- package/src/blueprint/action-semantic-fit.ts +205 -0
- package/src/blueprint/compile.ts +51 -2
- package/src/blueprint/diff.ts +34 -2
- package/src/blueprint/metric-query.ts +215 -0
- package/src/blueprint/metric-rules.ts +122 -0
- package/src/blueprint/object-sets.ts +116 -0
- package/src/blueprint/ontology-edit-plan.ts +49 -5
- package/src/blueprint/projection.ts +24 -1
- package/src/blueprint/scaffold.ts +84 -42
- package/src/commands/action/available.ts +1 -1
- package/src/commands/action/criteria-refusal.ts +84 -0
- package/src/commands/action/decide.ts +5 -2
- package/src/commands/action/deploy.ts +13 -4
- package/src/commands/action/list.ts +134 -19
- package/src/commands/action/requests.ts +34 -6
- package/src/commands/action/submit.ts +28 -13
- package/src/commands/agent/draft-changes.ts +1 -1
- package/src/commands/agent/index-commands.ts +69 -7
- package/src/commands/agent/resolve.ts +2 -0
- package/src/commands/app/checks.ts +44 -0
- package/src/commands/app/deploy.ts +243 -60
- package/src/commands/app/deployments.ts +97 -0
- package/src/commands/app/dev.ts +103 -11
- package/src/commands/app/init.ts +36 -3
- package/src/commands/app/promote.ts +45 -19
- package/src/commands/app/pull.ts +3 -1
- package/src/commands/app/rollback.ts +46 -0
- package/src/commands/app/sdk.ts +54 -8
- package/src/commands/app/unpin.ts +27 -0
- package/src/commands/app/versions.ts +1 -1
- package/src/commands/automation/bindings.ts +4 -2
- package/src/commands/automation/build-entry.ts +4 -1
- package/src/commands/automation/index-commands.ts +3 -1
- package/src/commands/automation/pull.ts +6 -1
- package/src/commands/automation/run.ts +34 -9
- package/src/commands/blueprint/bind.ts +2 -1
- package/src/commands/blueprint/declarative.ts +332 -32
- package/src/commands/blueprint/editable.ts +3 -3
- package/src/commands/blueprint/get.ts +6 -2
- package/src/commands/blueprint/grants.ts +64 -10
- package/src/commands/blueprint/metric.ts +108 -0
- package/src/commands/blueprint/object-sets.ts +71 -0
- package/src/commands/blueprint/query.ts +155 -31
- package/src/commands/dataset/index-commands.ts +156 -7
- package/src/commands/knowledge/index-commands.ts +122 -20
- package/src/commands/local/check.ts +657 -44
- package/src/commands/local/data.ts +33 -12
- package/src/commands/local/dev.ts +876 -89
- package/src/commands/local/docs-mcp.ts +10 -18
- package/src/commands/local/index-commands.ts +300 -63
- package/src/commands/local/init.ts +18 -5
- package/src/commands/local/key.ts +0 -3
- package/src/commands/local/providers.ts +0 -7
- package/src/commands/local/remove.ts +116 -0
- package/src/commands/local/schema.ts +3 -4
- package/src/commands/local/sdk-refresh.ts +59 -0
- package/src/commands/local/snapshot.ts +100 -0
- package/src/commands/local/sync-request.ts +321 -14
- package/src/commands/local/sync.ts +103 -6
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +54 -10
- package/src/commands/types.ts +29 -0
- package/src/commands/workflow/archive.ts +46 -0
- package/src/commands/workflow/get.ts +2 -1
- package/src/commands/workflow/index-commands.ts +5 -0
- package/src/commands/workflow/push.ts +49 -2
- package/src/commands/workflow/run.ts +65 -7
- package/src/commands/workflow/runs.ts +9 -6
- package/src/commands/workflow/webhook-test.ts +166 -0
- package/src/config.ts +43 -6
- package/src/dev-broker.ts +52 -0
- package/src/exit.ts +103 -4
- package/src/flag-help.ts +77 -17
- package/src/forge/claude-commands.ts +11 -6
- package/src/forge/claude-skills.ts +4 -4
- package/src/forge/touches.ts +15 -12
- package/src/function-outside-imports.ts +89 -0
- package/src/harness.ts +42 -24
- package/src/heal.ts +113 -2
- package/src/kit.ts +1 -1
- package/src/local/fix-words.ts +12 -0
- package/src/local/function-bundle-recovery.ts +775 -0
- package/src/local/local-actions.ts +103 -10
- package/src/local/local-agent-action-grants.ts +210 -0
- package/src/local/local-agent-checks.ts +232 -0
- package/src/local/local-agent-grants.ts +29 -7
- package/src/local/local-agent-lean-plugins.ts +68 -0
- package/src/local/local-agent-missing-tools.ts +68 -0
- package/src/local/local-agent-name.ts +27 -0
- package/src/local/local-app-code.ts +191 -0
- package/src/local/local-app-dev-files.ts +186 -0
- package/src/local/local-app-dev-session.ts +88 -0
- package/src/local/local-app-lock-install.ts +114 -0
- package/src/local/local-app-pulled.ts +167 -0
- package/src/local/local-app-restore.ts +111 -0
- package/src/local/local-app-runtime.ts +68 -0
- package/src/local/local-app-sdk-paths.ts +149 -0
- package/src/local/local-app-sdk.ts +509 -0
- package/src/local/local-applied.ts +212 -22
- package/src/local/local-apply-history.ts +84 -0
- package/src/local/local-apply-summary.ts +86 -0
- package/src/local/local-apply-watchdog.ts +95 -0
- package/src/local/local-apps.ts +181 -8
- package/src/local/local-blueprint-errors.ts +198 -0
- package/src/local/local-blueprint-outline.ts +31 -12
- package/src/local/local-blueprint-schemas.ts +582 -0
- package/src/local/local-build-inputs.ts +48 -0
- package/src/local/local-build-summary.ts +495 -28
- package/src/local/local-case-evidence.ts +120 -0
- package/src/local/local-case-not-run.ts +153 -0
- package/src/local/local-chat-apps.ts +93 -16
- package/src/local/local-check-inherited.ts +83 -0
- package/src/local/local-cities.ts +89 -0
- package/src/local/local-component-health.ts +119 -0
- package/src/local/local-control.ts +34 -7
- package/src/local/local-db-snapshot.ts +348 -0
- package/src/local/local-dev-references.ts +347 -36
- package/src/local/local-dev-sync.ts +141 -12
- package/src/local/local-docker.ts +35 -1
- package/src/local/local-docs-search.ts +1 -1
- package/src/local/local-docs.ts +281 -2
- package/src/local/local-empty-workflows.ts +92 -0
- package/src/local/local-env.ts +16 -1
- package/src/local/local-file-checks.ts +104 -0
- package/src/local/local-function-ids.ts +141 -0
- package/src/local/local-function-recovery.ts +141 -0
- package/src/local/local-function-state.ts +87 -0
- package/src/local/local-keys.ts +7 -5
- package/src/local/local-knowledge-documents.ts +1 -1
- package/src/local/local-knowledge-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- package/src/local/local-lessons.ts +1 -1
- package/src/local/local-library.ts +1 -1
- package/src/local/local-media-placeholders.ts +182 -0
- package/src/local/local-media-sets.ts +90 -10
- package/src/local/local-model-parameters.ts +89 -0
- package/src/local/local-object-sets.ts +18 -6
- package/src/local/local-operating.ts +35 -1
- package/src/local/local-org-settings.ts +206 -8
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +143 -18
- package/src/local/local-ports.ts +99 -7
- package/src/local/local-project-notices.ts +234 -0
- package/src/local/local-project-secrets.ts +133 -0
- package/src/local/local-project.ts +12 -0
- package/src/local/local-provider-refusal.ts +66 -0
- package/src/local/local-request-evidence.ts +112 -0
- package/src/local/local-runtime-health.ts +123 -0
- package/src/local/local-runtime-history.ts +50 -0
- package/src/local/local-runtime-target.ts +42 -0
- package/src/local/local-sample-data.ts +141 -0
- package/src/local/local-sample-remake.ts +138 -0
- package/src/local/local-scaffold.ts +336 -53
- package/src/local/local-schema-check.ts +148 -0
- package/src/local/local-schemas.ts +742 -25
- package/src/local/local-secret-carry.ts +107 -0
- package/src/local/local-secrets.ts +41 -11
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +3 -3
- package/src/local/local-state.ts +43 -2
- package/src/local/local-studio-server.ts +170 -7
- package/src/local/local-supervisor.ts +44 -6
- package/src/local/local-sync-merge.ts +1 -1
- package/src/local/local-sync-names.ts +2 -0
- package/src/local/local-sync-plan.ts +19 -8
- package/src/local/local-sync-push.ts +15 -12
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +349 -38
- package/src/local/local-sync-snapshot.ts +18 -13
- package/src/local/local-synthetic-data.ts +2116 -99
- package/src/local/local-test-baseline.ts +113 -0
- package/src/local/local-test-cases.ts +76 -1
- package/src/local/local-test-runner.ts +582 -91
- package/src/local/local-test-runs.ts +13 -5
- package/src/local/local-vendored-sdk.ts +183 -0
- package/src/local/local-workflow-state.ts +16 -3
- package/src/local/local-workflow-tests.ts +482 -0
- package/src/local/local-workspace-grants.ts +81 -0
- package/src/main.ts +50 -9
- package/src/mcp/a11y-scan.ts +55 -0
- package/src/mcp/browser/ax-snapshot.ts +181 -0
- package/src/mcp/browser/credentials.ts +78 -0
- package/src/mcp/browser/frames.ts +336 -0
- package/src/mcp/browser/headless-session.ts +413 -0
- package/src/mcp/browser/headless-tab.ts +575 -0
- package/src/mcp/browser/keys.ts +70 -0
- package/src/mcp/browser/page-scripts.ts +129 -0
- package/src/mcp/browser/wait.ts +73 -0
- package/src/mcp/forge-link.ts +126 -0
- package/src/mcp/headless.ts +199 -0
- package/src/mcp/mcp-apps.ts +435 -0
- package/src/mcp/mcp-chat.ts +226 -0
- package/src/mcp/mcp-cli.ts +53 -0
- package/src/mcp/mcp-protocol.ts +122 -0
- package/src/mcp/mcp-runtime.ts +113 -0
- package/src/mcp/mcp-server.ts +108 -0
- package/src/mcp/mcp-traces.ts +409 -0
- package/src/mcp/page-observation.ts +193 -0
- package/src/mcp/tools/agent-tools.ts +700 -0
- package/src/mcp/tools/app-tools.ts +241 -0
- package/src/mcp/tools/browser-tools.ts +310 -0
- package/src/mcp/tools/docs-tools.ts +32 -0
- package/src/mcp/tools/project-tools.ts +205 -0
- package/src/mcp/tools/test-tools.ts +339 -0
- package/src/mcp/tools/workflow-tools.ts +611 -0
- package/src/output.ts +1 -1
- package/src/pack.ts +8 -3
- package/src/packaging.ts +1 -1
- package/src/project-bootstrap.ts +107 -4
- package/src/project-context.ts +18 -1
- package/src/project.ts +27 -2
- package/src/provenance.ts +25 -0
- package/src/render-evidence.ts +65 -7
- package/src/scopes.ts +1 -1
- package/src/template.ts +14 -8
- package/src/templates/next-app-files.ts +35 -6
- package/src/templates/next-skills.ts +61 -27
- package/src/vendor/kit-assets.json +6 -6
- package/src/vendor/sdk-sources.json +17 -14
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
import { POSTGRES_DB, POSTGRES_USER } from './local-env'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The workspace secrets carried across a database restore.
|
|
7
|
+
*
|
|
8
|
+
* A restore (`frontera local snapshot restore`, `local check`'s rollback)
|
|
9
|
+
* throws away what was written since the baseline: test data. Workspace
|
|
10
|
+
* secrets are configuration, set by the person (`frontera secret set`), not
|
|
11
|
+
* by a test, so a restore keeps them as they are now unless asked to put the
|
|
12
|
+
* baseline's back (`--exact`).
|
|
13
|
+
*
|
|
14
|
+
* The rows are copied as stored, never decrypted: a value is `enc:v2`
|
|
15
|
+
* ciphertext under its workspace's data key version, and that key is a
|
|
16
|
+
* wrapped row in `workspace_deks` that only the service can unwrap. So the
|
|
17
|
+
* key versions the restored database lacks come with the secrets, and the
|
|
18
|
+
* value decrypts there exactly as before. A value under a key version the
|
|
19
|
+
* restored database holds as a different key would not decrypt: it is left
|
|
20
|
+
* as the baseline holds it and named, to be set again. Values never leave
|
|
21
|
+
* the database container as plain text, and nothing here is written to disk.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type QueryFn = (container: string, cmd: string[], input?: string) => { ok: boolean; out: string }
|
|
25
|
+
|
|
26
|
+
/** What the current database holds, read before it is dropped: its workspaces, their secrets and data keys, as JSON. */
|
|
27
|
+
export interface SecretsHeld {
|
|
28
|
+
json: string
|
|
29
|
+
count: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SecretsCarried {
|
|
33
|
+
ok: boolean
|
|
34
|
+
/** How many secrets are as they were before the restore. */
|
|
35
|
+
kept: number
|
|
36
|
+
/** Secrets whose value would not decrypt in the restored database, by name: set them again. */
|
|
37
|
+
notKept: string[]
|
|
38
|
+
/** Why nothing was carried, when it failed. */
|
|
39
|
+
problem?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const psqlArgs = (extra: string[]) => ['psql', '-U', POSTGRES_USER, '-d', POSTGRES_DB, '-v', 'ON_ERROR_STOP=1', '-q', '-At', ...extra]
|
|
43
|
+
|
|
44
|
+
export const READ_SECRETS_SQL = `SELECT json_build_object(
|
|
45
|
+
'workspaces', coalesce((SELECT json_agg(id) FROM workspaces), '[]'::json),
|
|
46
|
+
'secrets', coalesce((SELECT json_agg(s) FROM workspace_secrets s), '[]'::json),
|
|
47
|
+
'deks', coalesce((SELECT json_agg(d) FROM workspace_deks d), '[]'::json))`
|
|
48
|
+
|
|
49
|
+
/** The secrets and data keys as the database holds them now, or why they could not be read. */
|
|
50
|
+
export function readSecrets(query: QueryFn, container: string): SecretsHeld | { problem: string } {
|
|
51
|
+
const res = query(container, psqlArgs(['-c', READ_SECRETS_SQL.replace(/\s+/g, ' ')]))
|
|
52
|
+
if (!res.ok) return { problem: 'the current secrets could not be read' }
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(res.out) as { secrets?: unknown[] }
|
|
55
|
+
return { json: res.out, count: Array.isArray(parsed.secrets) ? parsed.secrets.length : 0 }
|
|
56
|
+
} catch {
|
|
57
|
+
return { problem: 'the current secrets could not be read' }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The SQL that puts the held secrets into the restored database, in one
|
|
63
|
+
* transaction: the data key versions it lacks are added, each workspace's
|
|
64
|
+
* secrets replaced by the held ones (a secret deleted since the baseline
|
|
65
|
+
* stays deleted), except those under a key version it holds differently.
|
|
66
|
+
* Answers one JSON line: how many were kept, and the names that weren't.
|
|
67
|
+
*/
|
|
68
|
+
export function carrySql(json: string, tag = `carry_${randomBytes(6).toString('hex')}`): string {
|
|
69
|
+
if (json.includes(`$${tag}$`)) throw new Error('the held secrets contain the quoting tag')
|
|
70
|
+
const q = `$${tag}$`
|
|
71
|
+
return [
|
|
72
|
+
'BEGIN;',
|
|
73
|
+
`CREATE TEMP TABLE carry ON COMMIT DROP AS SELECT ${q}${json}${q}::json AS j;`,
|
|
74
|
+
// Only workspaces both databases have: one made after the baseline is gone with it.
|
|
75
|
+
`CREATE TEMP TABLE carry_ws ON COMMIT DROP AS SELECT (value #>> '{}')::uuid AS id FROM json_array_elements((SELECT j->'workspaces' FROM carry)) WHERE (value #>> '{}')::uuid IN (SELECT id FROM workspaces);`,
|
|
76
|
+
`CREATE TEMP TABLE carry_deks ON COMMIT DROP AS SELECT * FROM json_populate_recordset(NULL::workspace_deks, (SELECT j->'deks' FROM carry)) WHERE workspace_id IN (SELECT id FROM carry_ws);`,
|
|
77
|
+
`CREATE TEMP TABLE carry_secrets ON COMMIT DROP AS SELECT * FROM json_populate_recordset(NULL::workspace_secrets, (SELECT j->'secrets' FROM carry)) WHERE workspace_id IN (SELECT id FROM carry_ws);`,
|
|
78
|
+
// A key version the restored database holds as another key: a value under it would not decrypt there.
|
|
79
|
+
`CREATE TEMP TABLE carry_skip ON COMMIT DROP AS SELECT s.workspace_id, s.name FROM carry_secrets s JOIN carry_deks c ON c.workspace_id = s.workspace_id AND s.encrypted_value LIKE 'enc:v2:dek.' || c.version || ':%' JOIN workspace_deks d ON d.workspace_id = c.workspace_id AND d.version = c.version AND d.wrapped_dek <> c.wrapped_dek;`,
|
|
80
|
+
`INSERT INTO workspace_deks SELECT c.* FROM carry_deks c WHERE NOT EXISTS (SELECT 1 FROM workspace_deks d WHERE d.workspace_id = c.workspace_id AND d.version = c.version) ON CONFLICT DO NOTHING;`,
|
|
81
|
+
`DELETE FROM workspace_secrets t WHERE t.workspace_id IN (SELECT id FROM carry_ws) AND NOT EXISTS (SELECT 1 FROM carry_skip k WHERE k.workspace_id = t.workspace_id AND k.name = t.name);`,
|
|
82
|
+
`INSERT INTO workspace_secrets SELECT s.* FROM carry_secrets s WHERE NOT EXISTS (SELECT 1 FROM carry_skip k WHERE k.workspace_id = s.workspace_id AND k.name = s.name) ON CONFLICT DO NOTHING;`,
|
|
83
|
+
`SELECT json_build_object('kept', (SELECT count(*) FROM workspace_secrets t JOIN carry_secrets s ON s.id = t.id AND s.encrypted_value = t.encrypted_value), 'notKept', coalesce((SELECT json_agg(name ORDER BY name) FROM carry_skip), '[]'::json));`,
|
|
84
|
+
'COMMIT;',
|
|
85
|
+
'',
|
|
86
|
+
].join('\n')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Put the held secrets into the restored database (`carrySql`), and say what became of them. */
|
|
90
|
+
export function carrySecrets(query: QueryFn, container: string, held: SecretsHeld): SecretsCarried {
|
|
91
|
+
let sql: string
|
|
92
|
+
try {
|
|
93
|
+
sql = carrySql(held.json)
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return { ok: false, kept: 0, notKept: [], problem: (err as Error).message }
|
|
96
|
+
}
|
|
97
|
+
const res = query(container, psqlArgs(['-f', '-']), sql)
|
|
98
|
+
if (!res.ok) return { ok: false, kept: 0, notKept: [], problem: 'the restored database refused them' }
|
|
99
|
+
try {
|
|
100
|
+
const line = res.out.split('\n').map((l) => l.trim()).filter(Boolean).at(-1) ?? '{}'
|
|
101
|
+
const said = JSON.parse(line) as { kept?: number; notKept?: string[] }
|
|
102
|
+
return { ok: true, kept: Number(said.kept ?? 0), notKept: Array.isArray(said.notKept) ? said.notKept.filter((n) => typeof n === 'string') : [] }
|
|
103
|
+
} catch {
|
|
104
|
+
// Committed, but its answer could not be read: what the runtime lists says the rest.
|
|
105
|
+
return { ok: true, kept: held.count, notKept: [] }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -55,7 +55,7 @@ export function pluginSecretName(plugin: string, field: string): string {
|
|
|
55
55
|
|
|
56
56
|
/** The `secret:NAME` grants a Function's source names. */
|
|
57
57
|
export function functionSecretNames(source: string): string[] {
|
|
58
|
-
return [...new Set([...source.matchAll(/['"`]secret:([A-Z][A-Z0-9_]*)['"`]/g)].map((m) => m[1]!))].sort()
|
|
58
|
+
return [...new Set([...source.matchAll(/['"`]secret:([A-Z][A-Z0-9_]*)['"`]/g)].map((m) => m[1]!))].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/** The secrets a workflow's HTTP steps and webhook trigger name in their `auth`, at any depth. */
|
|
@@ -70,7 +70,7 @@ export function workflowSecretNames(doc: unknown): string[] {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
visit(doc)
|
|
73
|
-
return [...out].sort()
|
|
73
|
+
return [...out].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
/** A Plugin setting declared empty in its file: a header or a config key, and the local secret that fills it. */
|
|
@@ -115,27 +115,57 @@ export function needsValueLine(name: string, who: string): string {
|
|
|
115
115
|
/** The build's lines for the secrets `names` that the local runtime has no value for. */
|
|
116
116
|
export async function missingSecretLines(names: string[], who: string, serviceUrl: string, creds: LocalCredentials): Promise<string[]> {
|
|
117
117
|
if (!names.length) return []
|
|
118
|
-
const
|
|
119
|
-
if (!
|
|
120
|
-
return names.filter((n) => !
|
|
118
|
+
const presence = await localSecretPresence(serviceUrl, creds)
|
|
119
|
+
if (!presence) return []
|
|
120
|
+
return names.filter((n) => !hasSecretValue(presence, n)).map((n) => needsValueLine(n, who))
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
/**
|
|
124
|
-
|
|
123
|
+
/**
|
|
124
|
+
* What the local runtime holds of its secrets, never their values: each name
|
|
125
|
+
* the secrets list route lists, and those of them it says have no value
|
|
126
|
+
* (`hasValue: false` on a row; a runtime that does not say counts a listed
|
|
127
|
+
* secret as having one).
|
|
128
|
+
*/
|
|
129
|
+
export interface SecretPresence {
|
|
130
|
+
names: Set<string>
|
|
131
|
+
withoutValue: Set<string>
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Whether the runtime has a value for `name`: listed, and not said to be without one. */
|
|
135
|
+
export function hasSecretValue(presence: SecretPresence, name: string): boolean {
|
|
136
|
+
return presence.names.has(name) && !presence.withoutValue.has(name)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The secrets list route's answer, read as `SecretPresence`; `null` when it could not say. */
|
|
140
|
+
export async function localSecretPresence(serviceUrl: string, creds: LocalCredentials, deps: { fetch?: (url: string | URL, init?: RequestInit) => Promise<Response> } = {}): Promise<SecretPresence | null> {
|
|
125
141
|
if (!creds.workspaceKey || !creds.workspaceId) return null
|
|
126
142
|
try {
|
|
127
|
-
const res = await fetch(new URL(`/v1/workspaces/${encodeURIComponent(creds.workspaceId)}/secrets`, serviceUrl), {
|
|
143
|
+
const res = await (deps.fetch ?? fetch)(new URL(`/v1/workspaces/${encodeURIComponent(creds.workspaceId)}/secrets`, serviceUrl), {
|
|
128
144
|
headers: { authorization: `Bearer ${creds.workspaceKey}` },
|
|
145
|
+
signal: AbortSignal.timeout(5000),
|
|
129
146
|
})
|
|
130
147
|
if (!res.ok) return null
|
|
131
|
-
const body = (await res.json()) as { data?: Array<{ name?: string }> } | Array<{ name?: string }>
|
|
148
|
+
const body = (await res.json()) as { data?: Array<{ name?: string; hasValue?: unknown }> } | Array<{ name?: string; hasValue?: unknown }>
|
|
132
149
|
const rows = Array.isArray(body) ? body : (body.data ?? [])
|
|
133
|
-
|
|
150
|
+
const names = new Set<string>()
|
|
151
|
+
const withoutValue = new Set<string>()
|
|
152
|
+
for (const r of rows) {
|
|
153
|
+
if (typeof r.name !== 'string') continue
|
|
154
|
+
names.add(r.name)
|
|
155
|
+
if (r.hasValue === false) withoutValue.add(r.name)
|
|
156
|
+
}
|
|
157
|
+
return { names, withoutValue }
|
|
134
158
|
} catch {
|
|
135
159
|
return null
|
|
136
160
|
}
|
|
137
161
|
}
|
|
138
162
|
|
|
163
|
+
/** The secret names the local runtime holds a value for (never the values); `null` when it could not say. */
|
|
164
|
+
export async function localSecretNames(serviceUrl: string, creds: LocalCredentials): Promise<Set<string> | null> {
|
|
165
|
+
const presence = await localSecretPresence(serviceUrl, creds)
|
|
166
|
+
return presence ? new Set([...presence.names].filter((n) => !presence.withoutValue.has(n))) : null
|
|
167
|
+
}
|
|
168
|
+
|
|
139
169
|
/**
|
|
140
170
|
* A Plugin file as a push sends it: every header, key-like setting and
|
|
141
171
|
* environment value empty, as the pull writes them. The platform keeps its
|
|
@@ -232,7 +262,7 @@ function sameBesideSecrets(text: string, base: string): boolean {
|
|
|
232
262
|
|
|
233
263
|
function sortKeys(v: unknown): unknown {
|
|
234
264
|
if (Array.isArray(v)) return v.map(sortKeys)
|
|
235
|
-
if (v && typeof v === 'object') return Object.fromEntries(Object.keys(v as Record<string, unknown>).sort().map((k) => [k, sortKeys((v as Record<string, unknown>)[k])]))
|
|
265
|
+
if (v && typeof v === 'object') return Object.fromEntries(Object.keys(v as Record<string, unknown>).sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0)).map((k) => [k, sortKeys((v as Record<string, unknown>)[k])]))
|
|
236
266
|
return v
|
|
237
267
|
}
|
|
238
268
|
|
package/src/local/local-seed.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { appendFileSync } from 'node:fs'
|
|
2
2
|
|
|
3
|
-
import { findProvider, getKey,
|
|
3
|
+
import { findProvider, getKey, LOCAL_PROVIDERS, localEmbeddingsReachable } from './local-keys'
|
|
4
4
|
import type { LocalCredentials } from './local-state'
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -174,11 +174,35 @@ const EMBEDDING: Array<{ provider: string; model: string }> = [
|
|
|
174
174
|
{ provider: 'openrouter', model: 'openai/text-embedding-3-small' },
|
|
175
175
|
]
|
|
176
176
|
|
|
177
|
+
/**
|
|
178
|
+
* The runtime's embedding choice, when it has one its credentials can run
|
|
179
|
+
* (`/v1/config/embedding` lists only credentialed providers): one chosen
|
|
180
|
+
* before, by `local dev` to match the workspace or by its owner, is kept.
|
|
181
|
+
*/
|
|
182
|
+
async function credentialedEmbedding(serviceUrl: string, token: string, orgId: string): Promise<{ provider: string; model: string } | null> {
|
|
183
|
+
try {
|
|
184
|
+
const res = await call<{ data?: EmbeddingView } & EmbeddingView>(serviceUrl, '/v1/config/embedding', { token, orgId })
|
|
185
|
+
if (res.status >= 300) return null
|
|
186
|
+
const view = res.body?.data ?? res.body
|
|
187
|
+
const { provider, model } = view ?? {}
|
|
188
|
+
if (typeof provider !== 'string' || !provider || typeof model !== 'string' || !model) return null
|
|
189
|
+
return Array.isArray(view?.providers) && view.providers.some((p) => p?.id === provider) ? { provider, model } : null
|
|
190
|
+
} catch {
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
interface EmbeddingView {
|
|
196
|
+
provider?: string | null
|
|
197
|
+
model?: string | null
|
|
198
|
+
providers?: Array<{ id?: string }>
|
|
199
|
+
}
|
|
200
|
+
|
|
177
201
|
/**
|
|
178
202
|
* Copy the FDE's stored keys into the local organization's credentials, and
|
|
179
|
-
* pick an embedding model when one is possible
|
|
180
|
-
* the status line; failures are logged,
|
|
181
|
-
* key still starts, and says so.
|
|
203
|
+
* pick an embedding model when one is possible and none that runs is chosen
|
|
204
|
+
* yet. Returns what was applied, for the status line; failures are logged,
|
|
205
|
+
* never fatal — a runtime without a model key still starts, and says so.
|
|
182
206
|
*/
|
|
183
207
|
export async function applyModelKeys(args: { serviceUrl: string; password: string; organizationId: string; logFile: string }): Promise<string[]> {
|
|
184
208
|
const applied: string[] = []
|
|
@@ -202,33 +226,56 @@ export async function applyModelKeys(args: { serviceUrl: string; password: strin
|
|
|
202
226
|
if (res.status >= 200 && res.status < 300) applied.push(p.id)
|
|
203
227
|
else log(args.logFile, `storing the ${findProvider(p.id)?.label} key answered ${res.status}: ${JSON.stringify(res.body).slice(0, 200)}`)
|
|
204
228
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
orgId: args.organizationId,
|
|
213
|
-
body: { fields: { apiKey: 'local-embeddings' } },
|
|
214
|
-
})
|
|
215
|
-
if (res.status >= 200 && res.status < 300) {
|
|
216
|
-
embedding = EMBEDDING[0]
|
|
217
|
-
applied.push('local-embeddings')
|
|
218
|
-
} else log(args.logFile, `storing the local embeddings credential answered ${res.status}: ${JSON.stringify(res.body).slice(0, 200)}`)
|
|
229
|
+
// A choice that runs with this runtime's keys stays: the workspace's model (`mirrorOrgSettings`) or the owner's.
|
|
230
|
+
// The local embeddings' stand-in OpenAI credential is not a key: a real key's model replaces it when the provider takes it.
|
|
231
|
+
const kept = await credentialedEmbedding(args.serviceUrl, token, args.organizationId)
|
|
232
|
+
const standIn = !!kept && isLocalEmbeddings(kept)
|
|
233
|
+
if (kept && !standIn) {
|
|
234
|
+
log(args.logFile, `kept the embedding model ${kept.provider} / ${kept.model}`)
|
|
235
|
+
return applied
|
|
219
236
|
}
|
|
220
|
-
|
|
221
|
-
const res = await call(args.serviceUrl, '/v1/config/embedding', {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (
|
|
237
|
+
const setEmbedding = async (embedding: { provider: string; model: string }) => {
|
|
238
|
+
const res = await call(args.serviceUrl, '/v1/config/embedding', { method: 'PUT', token, orgId: args.organizationId, body: embedding })
|
|
239
|
+
if (res.status < 300) return true
|
|
240
|
+
log(args.logFile, `setting the embedding model ${embedding.provider} / ${embedding.model} answered ${res.status}: ${JSON.stringify(res.body).slice(0, 300)}`)
|
|
241
|
+
return false
|
|
242
|
+
}
|
|
243
|
+
for (const embedding of EMBEDDING.filter((e) => applied.includes(e.provider))) {
|
|
244
|
+
if (await setEmbedding(embedding)) return applied
|
|
245
|
+
}
|
|
246
|
+
if (standIn) {
|
|
247
|
+
applied.push('local-embeddings')
|
|
248
|
+
return applied
|
|
249
|
+
}
|
|
250
|
+
// No key, or the provider refused the one stored (the line above says its answer): the keyless local
|
|
251
|
+
// embeddings, so knowledge bases still ingest and search here. They need the service's OpenAI client,
|
|
252
|
+
// which points at them while this computer has no OpenAI key (see serviceEnv).
|
|
253
|
+
if (!localEmbeddingsReachable()) return applied
|
|
254
|
+
const res = await call(args.serviceUrl, '/v1/admin/provider-credentials/openai', {
|
|
255
|
+
method: 'PUT',
|
|
256
|
+
token,
|
|
257
|
+
orgId: args.organizationId,
|
|
258
|
+
body: { fields: { apiKey: LOCAL_EMBEDDINGS_CREDENTIAL } },
|
|
259
|
+
})
|
|
260
|
+
if (res.status >= 300) {
|
|
261
|
+
log(args.logFile, `storing the local embeddings credential answered ${res.status}: ${JSON.stringify(res.body).slice(0, 200)}`)
|
|
262
|
+
return applied
|
|
263
|
+
}
|
|
264
|
+
if (await setEmbedding(EMBEDDING[0]!)) {
|
|
265
|
+
applied.push('local-embeddings')
|
|
266
|
+
if (applied.some((p) => p === 'openrouter')) log(args.logFile, 'embedding with the keyless local embeddings: the stored key’s embedding model was refused (see the line above)')
|
|
228
267
|
}
|
|
229
268
|
return applied
|
|
230
269
|
}
|
|
231
270
|
|
|
271
|
+
/** The placeholder OpenAI credential that sends the service's OpenAI client to the keyless local embeddings. */
|
|
272
|
+
const LOCAL_EMBEDDINGS_CREDENTIAL = 'local-embeddings'
|
|
273
|
+
|
|
274
|
+
/** Whether a choice is the keyless local embeddings: OpenAI's model while this computer holds no OpenAI key. */
|
|
275
|
+
export function isLocalEmbeddings(embedding: { provider: string | null; model: string | null }): boolean {
|
|
276
|
+
return embedding.provider === 'openai' && localEmbeddingsReachable()
|
|
277
|
+
}
|
|
278
|
+
|
|
232
279
|
/**
|
|
233
280
|
* Runtimes started before the echo model was removed still list it: the
|
|
234
281
|
* `local` custom provider, its `local-model` catalog entry, and that model as
|
|
@@ -273,6 +320,35 @@ export const PROVIDER_MODELS: Record<string, Array<{ slug: string; modelId: stri
|
|
|
273
320
|
{ slug: 'gemini-3-8-flash', modelId: 'google/gemini-3.8-flash', name: 'Gemini 3.8 Flash' },
|
|
274
321
|
{ slug: 'gpt-5-6-terra', modelId: 'openai/gpt-5.6-terra', name: 'GPT-5.6 Terra' },
|
|
275
322
|
],
|
|
323
|
+
// Direct from Anthropic, beside OpenRouter's: a stored Anthropic key runs Claude without OpenRouter's
|
|
324
|
+
// credit. Their slugs name the provider, so they never take the OpenRouter models' slugs.
|
|
325
|
+
anthropic: [
|
|
326
|
+
{ slug: 'anthropic-claude-haiku-5-5', modelId: 'claude-haiku-5-5', name: 'Claude Haiku 5.5 (Anthropic)' },
|
|
327
|
+
{ slug: 'anthropic-claude-sonnet-5-5', modelId: 'claude-sonnet-5-5', name: 'Claude Sonnet 5.5 (Anthropic)' },
|
|
328
|
+
],
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** A model the local catalog offers directly from a provider (not through OpenRouter), and that provider. */
|
|
332
|
+
export interface DirectModel {
|
|
333
|
+
slug: string
|
|
334
|
+
name: string
|
|
335
|
+
provider: string
|
|
336
|
+
providerLabel: string
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The first model offered directly by a provider whose key the runtime holds
|
|
341
|
+
* (`providers`: the ids the runtime applied keys for, never a key), other than
|
|
342
|
+
* the provider that refused (`refusedBy`); null when there is none. What a
|
|
343
|
+
* credit refusal suggests switching to.
|
|
344
|
+
*/
|
|
345
|
+
export function directModelFor(providers: readonly string[] | undefined, refusedBy?: string | null): DirectModel | null {
|
|
346
|
+
for (const [provider, models] of Object.entries(PROVIDER_MODELS)) {
|
|
347
|
+
if (provider === 'openrouter' || provider === refusedBy || !providers?.includes(provider) || !models.length) continue
|
|
348
|
+
const m = models[0]!
|
|
349
|
+
return { slug: m.slug, name: m.name.replace(/\s*\([^)]*\)$/, ''), provider, providerLabel: findProvider(provider)?.label ?? provider }
|
|
350
|
+
}
|
|
351
|
+
return null
|
|
276
352
|
}
|
|
277
353
|
|
|
278
354
|
/**
|
|
@@ -34,7 +34,7 @@ import { rows } from './local-sync-remote'
|
|
|
34
34
|
* - **Documentation** (Frontera's documentation for Agents): its stand-in
|
|
35
35
|
* `serves: "documentation"`, answering the plugin's tools from the
|
|
36
36
|
* documentation `local start` copied for the runtime's release (the same
|
|
37
|
-
* copy `frontera
|
|
37
|
+
* copy `frontera mcp` serves the project's coding agents). The
|
|
38
38
|
* Agent's documentation tools work here.
|
|
39
39
|
* - **Analytics**: the workspace's analytics tools read its analytics
|
|
40
40
|
* project, which a local runtime does not have (design §8). The pull
|
|
@@ -70,7 +70,7 @@ const DOCS_TOOLS: StandInFile['tools'] = [
|
|
|
70
70
|
|
|
71
71
|
/** The built-in Plugins these items need that get a stand-in (connections, the Documentation plugin), by install name. */
|
|
72
72
|
export function connectionsNeeded(items: Array<Pick<RemoteItem, 'portability'>>): string[] {
|
|
73
|
-
return [...new Set(items.flatMap((i) => (i.portability?.needs ?? []).flatMap((n) => (n.kind === 'connection' || n.kind === 'documentation' ? [n.name] : []))))].sort()
|
|
73
|
+
return [...new Set(items.flatMap((i) => (i.portability?.needs ?? []).flatMap((n) => (n.kind === 'connection' || n.kind === 'documentation' ? [n.name] : []))))].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
/** Which of those are the Documentation plugin. */
|
|
@@ -80,7 +80,7 @@ export function documentationNeeded(items: Array<Pick<RemoteItem, 'portability'>
|
|
|
80
80
|
|
|
81
81
|
/** The workspace's analytics Plugins these items use: platform-only. */
|
|
82
82
|
export function analyticsNeeded(items: Array<Pick<RemoteItem, 'portability'>>): string[] {
|
|
83
|
-
return [...new Set(items.flatMap((i) => (i.portability?.needs ?? []).flatMap((n) => (n.kind === 'analytics' ? [n.name] : []))))].sort()
|
|
83
|
+
return [...new Set(items.flatMap((i) => (i.portability?.needs ?? []).flatMap((n) => (n.kind === 'analytics' ? [n.name] : []))))].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
// ── Platform-only Plugins ────────────────────────────────────────────────────
|
package/src/local/local-state.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
|
|
4
|
+
import { ignoreInFronteraDir } from '../project-context'
|
|
4
5
|
import { writeFileAtomic } from '../fs-atomic'
|
|
5
6
|
import type { LocalProject } from './local-project'
|
|
6
7
|
|
|
@@ -88,8 +89,8 @@ export function ensureStateDir(project: LocalProject): void {
|
|
|
88
89
|
} catch {
|
|
89
90
|
// Best effort; a filesystem without modes still works.
|
|
90
91
|
}
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
// The runtime's state, and which profile this machine bound the directory to, stay out of git.
|
|
93
|
+
ignoreInFronteraDir(project.root, ['local/'])
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
export function readState(project: LocalProject): LocalState | null {
|
|
@@ -163,3 +164,43 @@ export function readApplied(project: LocalProject): LastApplied | null {
|
|
|
163
164
|
return null
|
|
164
165
|
}
|
|
165
166
|
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* An apply under way: `local dev` writes it as it starts applying what is
|
|
170
|
+
* queued and removes it when the queue is done, so Forge and Studio say
|
|
171
|
+
* "Applying…" rather than show a half-made count as if it were the result.
|
|
172
|
+
*/
|
|
173
|
+
export interface ApplyingNow {
|
|
174
|
+
/** When this round of applies began. */
|
|
175
|
+
since: string
|
|
176
|
+
/** How many items were queued when it began. */
|
|
177
|
+
queued: number
|
|
178
|
+
/** The `local dev` applying them: a record whose process is gone is stale. */
|
|
179
|
+
pid: number
|
|
180
|
+
/** What started it, in words: "runtime started", "files changed: agents/desk/agent.json". */
|
|
181
|
+
trigger?: string
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function applyingPath(project: LocalProject): string {
|
|
185
|
+
return join(project.stateDir, 'applying.json')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Say an apply is under way (`entry`), or that it is done (`null`). Never throws. */
|
|
189
|
+
export function recordApplying(project: LocalProject, entry: ApplyingNow | null): void {
|
|
190
|
+
try {
|
|
191
|
+
if (entry) writeFileAtomic(applyingPath(project), `${JSON.stringify(entry)}\n`, 0o644)
|
|
192
|
+
else rmSync(applyingPath(project), { force: true })
|
|
193
|
+
} catch {
|
|
194
|
+
// A missed hint must never fail an apply.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The apply under way, or null: none, or its `local dev` is gone. */
|
|
199
|
+
export function readApplying(stateDir: string): ApplyingNow | null {
|
|
200
|
+
try {
|
|
201
|
+
const entry = JSON.parse(readFileSync(join(stateDir, 'applying.json'), 'utf8')) as ApplyingNow
|
|
202
|
+
return entry && typeof entry.since === 'string' && isAlive(entry.pid) ? entry : null
|
|
203
|
+
} catch {
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
206
|
+
}
|