@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
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { isAbsolute, join, relative, resolve } from 'node:path'
|
|
2
3
|
import { parse as parseYaml } from 'yaml'
|
|
3
4
|
|
|
4
5
|
import { WorkflowApi, type LineageEdge } from '../../api/workflow-api'
|
|
5
6
|
import { CliError, UsageError } from '../../errors'
|
|
7
|
+
import { localDirectory, resolveTarget } from '../../local/local-dev-references'
|
|
8
|
+
import { localRuntimeAt } from '../../local/local-runtime-target'
|
|
6
9
|
import type { Command, CommandContext } from '../types'
|
|
7
10
|
|
|
8
11
|
/**
|
|
@@ -107,6 +110,9 @@ export const workflowPush: Command = {
|
|
|
107
110
|
const definition = await readDefinitionFile(requireFile(ctx))
|
|
108
111
|
const client = new WorkflowApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
109
112
|
const result = await client.push(slug, definition)
|
|
113
|
+
// What the service accepted but wants said (a confirm step with no one to ask, say): one line each,
|
|
114
|
+
// on stderr as ` warning: …`, which is also how `local dev` reads them from a push it runs.
|
|
115
|
+
for (const w of result.warnings ?? []) ctx.output.note(` warning: ${w}`)
|
|
110
116
|
|
|
111
117
|
const reach = describeReach(result.grants, result.lineage)
|
|
112
118
|
return {
|
|
@@ -118,6 +124,39 @@ export const workflowPush: Command = {
|
|
|
118
124
|
},
|
|
119
125
|
}
|
|
120
126
|
|
|
127
|
+
/**
|
|
128
|
+
* A project file's definition as the local runtime takes it: the names in it
|
|
129
|
+
* (`objectType/WorkOrder`, `objectSet/Open work orders`, `action/closeOrder`,
|
|
130
|
+
* `function/…`) become this runtime's ids, as `local dev` resolves them
|
|
131
|
+
* before a push. Null when the command does not reach the project's own
|
|
132
|
+
* local runtime, or the file is outside the project: the file is sent as
|
|
133
|
+
* written. The lines are what the resolving adapted, said as warnings.
|
|
134
|
+
*/
|
|
135
|
+
export async function localDefinition(ctx: CommandContext, slug: string, path: string): Promise<{ definition: unknown; lines: string[] } | null> {
|
|
136
|
+
const local = localRuntimeAt(ctx.cwd, ctx.apiUrl)
|
|
137
|
+
if (!local) return null
|
|
138
|
+
const absolute = isAbsolute(path) ? path : resolve(ctx.cwd, path)
|
|
139
|
+
const file = relative(local.project.root, absolute)
|
|
140
|
+
if (!file || file.startsWith('..') || isAbsolute(file)) return null
|
|
141
|
+
const resolved = await resolveTarget(
|
|
142
|
+
local.project,
|
|
143
|
+
{ kind: 'workflow', key: `workflow:${slug}`, slug, file },
|
|
144
|
+
() => localDirectory(local.serviceUrl, local.creds),
|
|
145
|
+
local.creds.workspaceId,
|
|
146
|
+
local.creds.organizationId,
|
|
147
|
+
)
|
|
148
|
+
if (!resolved.ok) {
|
|
149
|
+
const [first, ...rest] = resolved.output.split('\n')
|
|
150
|
+
throw new CliError(`${slug}: ${(first ?? '').replace(/^error \[[A-Z_]+\]:\s*/, '')}`, {
|
|
151
|
+
code: 'UNRESOLVED_REFERENCE',
|
|
152
|
+
hint: rest.map((l) => l.trim()).filter(Boolean).join('\n ') || 'add what it names to the project, then run `frontera local dev`',
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
const lines = resolved.output.split('\n').map((l) => l.trim()).filter(Boolean).map((l) => (/^warning:/i.test(l) ? ` ${l}` : ` warning: ${l}`))
|
|
156
|
+
const definition = resolved.file ? await readDefinitionFile(join(local.project.root, resolved.file)) : (resolved.doc ?? (await readDefinitionFile(absolute)))
|
|
157
|
+
return { definition, lines }
|
|
158
|
+
}
|
|
159
|
+
|
|
121
160
|
/**
|
|
122
161
|
* Check a definition without writing anything.
|
|
123
162
|
*
|
|
@@ -137,12 +176,19 @@ export const workflowValidate: Command = {
|
|
|
137
176
|
],
|
|
138
177
|
flags: {},
|
|
139
178
|
summary: 'Validate a definition and report its reach — writes nothing',
|
|
140
|
-
|
|
179
|
+
details: [
|
|
180
|
+
'Inside a project whose local runtime is running (the directory is bound to it), the file is read as `frontera local dev` reads it: the names it uses (`objectType/WorkOrder`, `objectSet/Open work orders`, `action/closeOrder`, `function/nearest-technician`) become the runtime\'s ids before the check, and what that adapts is said as a warning. Elsewhere the file is sent as written.',
|
|
181
|
+
'Warnings are what the service accepts but wants said, such as a confirm step with no `onUnavailable` in a workflow no person can answer. They print on stderr as ` warning: …` and come back as `warnings` with --json.',
|
|
182
|
+
],
|
|
183
|
+
examples: ['frontera workflow validate invoice-intake workflow.yaml', 'frontera workflow validate overdue-sweep workflows/overdue-sweep.yaml --json'],
|
|
141
184
|
},
|
|
142
185
|
|
|
143
186
|
async run(ctx) {
|
|
144
187
|
const slug = requireSlug(ctx)
|
|
145
|
-
const
|
|
188
|
+
const path = requireFile(ctx)
|
|
189
|
+
const local = await localDefinition(ctx, slug, path)
|
|
190
|
+
const definition = local ? local.definition : await readDefinitionFile(path)
|
|
191
|
+
for (const line of local?.lines ?? []) ctx.output.note(line)
|
|
146
192
|
const client = new WorkflowApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
147
193
|
// A dry run reports problems as DATA, not as a 400 — the service does this
|
|
148
194
|
// so the Console can show them beside the draft. So the refusal arrives
|
|
@@ -161,6 +207,7 @@ export const workflowValidate: Command = {
|
|
|
161
207
|
})
|
|
162
208
|
}
|
|
163
209
|
|
|
210
|
+
for (const w of result.warnings ?? []) ctx.output.note(` warning: ${w}`)
|
|
164
211
|
const reach = describeReach(result.grants, result.lineage)
|
|
165
212
|
return {
|
|
166
213
|
data: result,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { WorkflowApi, type RunRequest, type RunTree, type RunTreeNode } from '../../api/workflow-api'
|
|
1
|
+
import { WorkflowApi, type RunRequest, type RunTree, type RunTreeNode, type VersionSummary } from '../../api/workflow-api'
|
|
2
2
|
import { CliError, UsageError } from '../../errors'
|
|
3
3
|
import { readSecretValue } from '../../secrets'
|
|
4
4
|
import { flagBool, flagString, type Command, type CommandContext } from '../types'
|
|
@@ -85,22 +85,54 @@ export async function parseInputFlag(ctx: CommandContext): Promise<Record<string
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
|
-
* `--version N`, or undefined for the live one.
|
|
88
|
+
* `--version N`, `--version draft`, or undefined for the live one.
|
|
89
89
|
*
|
|
90
90
|
* Rejected here rather than forwarded for the reason `promote` rejects its
|
|
91
91
|
* argument: `--version abc` arriving as `NaN` comes back naming a field the
|
|
92
92
|
* author never typed.
|
|
93
|
+
*
|
|
94
|
+
* `draft` (and its synonym `latest`) is the alias the MCP `workflow_run` and
|
|
95
|
+
* `frontera local test` already take: the newest version that is not retired,
|
|
96
|
+
* which `resolveVersion` looks up. Without it an author had to read the number
|
|
97
|
+
* from `workflow get` first, and that number moves on every push or apply.
|
|
93
98
|
*/
|
|
94
|
-
export function parseVersionFlag(ctx: CommandContext): number | undefined {
|
|
99
|
+
export function parseVersionFlag(ctx: CommandContext): number | 'draft' | undefined {
|
|
95
100
|
const raw = flagString(ctx, 'version')
|
|
96
101
|
if (raw === undefined) return undefined
|
|
102
|
+
const word = raw.trim().toLowerCase()
|
|
103
|
+
if (word === 'draft' || word === 'latest') return 'draft'
|
|
97
104
|
const parsed = Number(raw)
|
|
98
105
|
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
99
|
-
throw new UsageError(`"${raw}" is not a version number`, 'frontera workflow get <slug> lists the versions')
|
|
106
|
+
throw new UsageError(`"${raw}" is not a version number or "draft"`, 'frontera workflow get <slug> lists the versions; `--version draft` runs the newest')
|
|
100
107
|
}
|
|
101
108
|
return parsed
|
|
102
109
|
}
|
|
103
110
|
|
|
111
|
+
/**
|
|
112
|
+
* The version number a `--version` flag names: a number as given, and `draft` as
|
|
113
|
+
* the newest version that is not retired. That is the draft just pushed, or the
|
|
114
|
+
* live version when a person promoted it (promoting retires the older drafts),
|
|
115
|
+
* the same rule as the MCP `workflow_run`'s `pickVersion`.
|
|
116
|
+
*/
|
|
117
|
+
export async function resolveVersion(
|
|
118
|
+
requested: number | 'draft' | undefined,
|
|
119
|
+
versions: () => Promise<VersionSummary[]>,
|
|
120
|
+
slug: string,
|
|
121
|
+
): Promise<number | undefined> {
|
|
122
|
+
if (requested !== 'draft') return requested
|
|
123
|
+
const rows = await versions()
|
|
124
|
+
const newest = [...rows].sort((a, b) => b.number - a.number).find((v) => v.status !== 'retired')
|
|
125
|
+
if (!newest) {
|
|
126
|
+
throw new CliError(
|
|
127
|
+
rows.length
|
|
128
|
+
? `workflow "${slug}" has only retired versions (${rows.map((v) => `v${v.number}`).join(', ')}), so there is no draft to run`
|
|
129
|
+
: `workflow "${slug}" has no version yet, so there is no draft to run`,
|
|
130
|
+
{ code: 'NOT_FOUND', hint: `push its definition first: \`frontera workflow push ${slug} <file>\`, or in a local project \`frontera local dev --once\`` },
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
return newest.number
|
|
134
|
+
}
|
|
135
|
+
|
|
104
136
|
/** One line per node, nested under the run each child step started. */
|
|
105
137
|
export function describeNodes(nodes: RunTreeNode[], depth = 1): string[] {
|
|
106
138
|
const lines: string[] = []
|
|
@@ -108,6 +140,8 @@ export function describeNodes(nodes: RunTreeNode[], depth = 1): string[] {
|
|
|
108
140
|
const indent = ' '.repeat(depth)
|
|
109
141
|
const failed = node.status === 'failed' ? ' ✗' : ''
|
|
110
142
|
lines.push(`${indent}${node.nodeId} ${node.kind} ${node.status}${failed}`)
|
|
143
|
+
// A failed step's error, and each input it named (`issues`): the reason, where it happened.
|
|
144
|
+
if (node.error) lines.push(...stepErrorLines(node.error, `${indent} `))
|
|
111
145
|
// A `forEach` or a `workflow` step is a run of its own, and its steps are
|
|
112
146
|
// where its failure is explained — flattening them would report a failed
|
|
113
147
|
// parent with no reason anywhere in the output.
|
|
@@ -116,6 +150,26 @@ export function describeNodes(nodes: RunTreeNode[], depth = 1): string[] {
|
|
|
116
150
|
return lines
|
|
117
151
|
}
|
|
118
152
|
|
|
153
|
+
/**
|
|
154
|
+
* A step's recorded error as lines: `{ code, message }`, then each of its
|
|
155
|
+
* `issues` (`{ path, message }`, the input it named and the rule it broke).
|
|
156
|
+
*/
|
|
157
|
+
export function stepErrorLines(error: unknown, indent: string): string[] {
|
|
158
|
+
if (typeof error === 'string') return [`${indent}error ${error}`]
|
|
159
|
+
if (!error || typeof error !== 'object') return []
|
|
160
|
+
const e = error as { code?: unknown; message?: unknown; issues?: unknown }
|
|
161
|
+
const head = [typeof e.code === 'string' ? e.code : '', typeof e.message === 'string' ? e.message : ''].filter(Boolean).join(': ')
|
|
162
|
+
const issues = Array.isArray(e.issues)
|
|
163
|
+
? e.issues.flatMap((i) => {
|
|
164
|
+
const issue = (i && typeof i === 'object' ? i : {}) as { path?: unknown; message?: unknown }
|
|
165
|
+
const message = typeof issue.message === 'string' ? issue.message : ''
|
|
166
|
+
if (!message) return []
|
|
167
|
+
return [`${indent} · ${typeof issue.path === 'string' && issue.path ? `${issue.path}: ` : ''}${message}`]
|
|
168
|
+
})
|
|
169
|
+
: []
|
|
170
|
+
return [`${indent}error ${head || JSON.stringify(error)}`, ...issues]
|
|
171
|
+
}
|
|
172
|
+
|
|
119
173
|
/**
|
|
120
174
|
* The subject of a bound run, as "<type> <pk>".
|
|
121
175
|
*
|
|
@@ -136,7 +190,7 @@ export function describeRun(run: RunTree): string {
|
|
|
136
190
|
if (run.subject) lines.push(` subject ${describeSubject(run.subject)}`)
|
|
137
191
|
// The error before the steps: it is the reason the reader opened this, and
|
|
138
192
|
// burying it under a trail of successful steps is how it gets missed.
|
|
139
|
-
if (run.error) lines.push(
|
|
193
|
+
if (run.error) lines.push(...stepErrorLines(run.error, ' '))
|
|
140
194
|
lines.push(...describeNodes(run.nodes))
|
|
141
195
|
if (run.output !== null && run.output !== undefined) lines.push(` returned ${JSON.stringify(run.output)}`)
|
|
142
196
|
return lines.join('\n')
|
|
@@ -166,13 +220,17 @@ export const workflowRun: Command = {
|
|
|
166
220
|
verb: 'run',
|
|
167
221
|
args: [{ name: 'slug', required: true, description: 'workflow slug' }],
|
|
168
222
|
flags: { 'no-wait': 'boolean', input: 'string', subject: 'string', version: 'string' },
|
|
169
|
-
summary: 'Run the live version now — or a named one — and report what it did',
|
|
223
|
+
summary: 'Run the live version now — or a named one, or the newest draft — and report what it did',
|
|
224
|
+
details: [
|
|
225
|
+
'`--version <n>` runs that version. `--version draft` runs the newest version that is not retired: the draft you last pushed or applied, or the live version once a person promoted it (`latest` means the same). Without `--version`, the live version runs.',
|
|
226
|
+
],
|
|
170
227
|
examples: [
|
|
171
228
|
'frontera workflow run invoice-intake',
|
|
172
229
|
'frontera workflow run invoice-intake --input \'{"threshold": 500}\'',
|
|
173
230
|
'frontera workflow run invoice-intake --input @input.json',
|
|
174
231
|
'frontera workflow run shipment-review --subject SHP-0000001',
|
|
175
232
|
'frontera workflow run invoice-intake --version 3 --no-wait',
|
|
233
|
+
'frontera workflow run invoice-intake --version draft --input @input.json',
|
|
176
234
|
],
|
|
177
235
|
},
|
|
178
236
|
|
|
@@ -188,7 +246,7 @@ export const workflowRun: Command = {
|
|
|
188
246
|
// pk and nothing else, and asking for `{"pk":"…"}` on a command line would
|
|
189
247
|
// be quoting ceremony around a single value.
|
|
190
248
|
if (subject) body.subject = { pk: subject }
|
|
191
|
-
const version = parseVersionFlag(ctx)
|
|
249
|
+
const version = await resolveVersion(parseVersionFlag(ctx), () => client.versions(slug), slug)
|
|
192
250
|
if (version !== undefined) body.version = version
|
|
193
251
|
|
|
194
252
|
const started = await client.run(slug, body)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { WorkflowApi } from '../../api/workflow-api'
|
|
2
|
-
import { CliError } from '../../errors'
|
|
2
|
+
import { CliError, UsageError } from '../../errors'
|
|
3
3
|
import { table } from '../../table'
|
|
4
|
-
import type
|
|
4
|
+
import { flagString, type Command } from '../types'
|
|
5
5
|
import { describeRun, describeSubject, requireRunId } from './run'
|
|
6
6
|
import { requireSlug } from './push'
|
|
7
7
|
|
|
@@ -25,10 +25,11 @@ export const workflowRuns: Command = {
|
|
|
25
25
|
description: 'one run to open, with its steps; omit to list recent runs',
|
|
26
26
|
},
|
|
27
27
|
],
|
|
28
|
-
flags: {},
|
|
29
|
-
summary: 'List recent runs, newest first — or open one run and its steps',
|
|
28
|
+
flags: { only: 'string' },
|
|
29
|
+
summary: 'List recent runs, newest first — or open one run and its steps (--only live|test: live runs, or test runs)',
|
|
30
30
|
examples: [
|
|
31
31
|
'frontera workflow runs invoice-intake',
|
|
32
|
+
'frontera workflow runs invoice-intake --only live',
|
|
32
33
|
'frontera workflow runs invoice-intake 0f2c8b1e-7a41-4c96-9d3e-52b0c4a7e118',
|
|
33
34
|
],
|
|
34
35
|
},
|
|
@@ -56,11 +57,13 @@ export const workflowRuns: Command = {
|
|
|
56
57
|
return { data: run, text: describeRun(run) }
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
const
|
|
60
|
+
const source = flagString(ctx, 'only')
|
|
61
|
+
if (source !== undefined && source !== 'live' && source !== 'test') throw new UsageError(`--only takes live or test, not "${source}"`, `e.g. \`frontera workflow runs ${slug} --only live\``)
|
|
62
|
+
const rows = await client.runs(slug, source)
|
|
60
63
|
if (rows.length === 0) {
|
|
61
64
|
return {
|
|
62
65
|
data: { runs: [] },
|
|
63
|
-
text: `No runs recorded for ${slug}.\n frontera workflow run ${slug} # to start one`,
|
|
66
|
+
text: `No ${source ? `${source} ` : ''}runs recorded for ${slug}.\n frontera workflow run ${slug} # to start one`,
|
|
64
67
|
}
|
|
65
68
|
}
|
|
66
69
|
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
import { WorkflowApi, type RunSummary, type WebhookListen } from '../../api/workflow-api'
|
|
4
|
+
import { CliError, UsageError } from '../../errors'
|
|
5
|
+
import { flagString, type Command } from '../types'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Send one delivery to a workflow's webhook on its DRAFT, and show what came
|
|
9
|
+
* back: the run the mapping (`$request.body`, headers, query) produced.
|
|
10
|
+
*
|
|
11
|
+
* The draft's test door (`/v1/hooks-test/…`) answers only while a Listen is
|
|
12
|
+
* armed, which was Studio's button alone, so a webhook's mapping could not be
|
|
13
|
+
* tried before a version went live. This arms it (`POST
|
|
14
|
+
* /v1/workflows/:slug/listen`: one delivery, within 120 seconds) and delivers
|
|
15
|
+
* straight away. The trigger's own auth still applies: pass what the sender
|
|
16
|
+
* would send with `--header`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The door to send to: the one at `path`, or the only one. */
|
|
20
|
+
export function pickDoor(listen: WebhookListen, path: string | undefined, slug: string): WebhookListen['doors'][number] {
|
|
21
|
+
const doors = listen.doors
|
|
22
|
+
if (!doors.length) throw new CliError(`workflow ${slug} v${listen.version} has no webhook trigger`, { code: 'USAGE', hint: 'add a `kind: webhook` trigger to its file, or run it with `frontera workflow run`' })
|
|
23
|
+
const wanted = path?.replace(/^\/+/, '')
|
|
24
|
+
if (wanted !== undefined) {
|
|
25
|
+
const door = doors.find((d) => d.path.replace(/^\/+/, '') === wanted)
|
|
26
|
+
if (!door) throw new UsageError(`workflow ${slug} has no webhook at "${wanted}"`, `its webhooks: ${doors.map((d) => d.path || '(no path)').join(', ')}`)
|
|
27
|
+
return door
|
|
28
|
+
}
|
|
29
|
+
if (doors.length > 1) throw new UsageError(`workflow ${slug} has ${doors.length} webhooks; say which with --path`, `its webhooks: ${doors.map((d) => d.path || '(no path)').join(', ')}`)
|
|
30
|
+
return doors[0]!
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The test door on the address this CLI talks to: the service names its public address, which a local runtime may not answer on. */
|
|
34
|
+
export function doorUrl(test: string, apiUrl: string, query: string | undefined): string {
|
|
35
|
+
const url = new URL(test)
|
|
36
|
+
const at = new URL(url.pathname, apiUrl.replace(/\/+$/, '') + '/')
|
|
37
|
+
if (query) for (const [k, v] of new URLSearchParams(query.replace(/^\?/, ''))) at.searchParams.append(k, v)
|
|
38
|
+
return at.toString()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `Name: value`, as a sender's header. */
|
|
42
|
+
export function parseHeader(raw: string | undefined): Record<string, string> {
|
|
43
|
+
if (!raw) return {}
|
|
44
|
+
const at = raw.indexOf(':')
|
|
45
|
+
if (at <= 0) throw new UsageError('--header takes `Name: value`', "e.g. --header 'X-Signature: abc123'")
|
|
46
|
+
return { [raw.slice(0, at).trim()]: raw.slice(at + 1).trim() }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `--version`: a draft's number, or `draft` (and `latest`) for the latest draft, as the MCP tool takes it. */
|
|
50
|
+
export function readVersionFlag(raw: string | undefined): number | undefined {
|
|
51
|
+
if (raw === undefined || /^(draft|latest)$/i.test(raw.trim())) return undefined
|
|
52
|
+
const version = Number(raw)
|
|
53
|
+
if (!Number.isInteger(version) || version < 1) throw new UsageError('--version takes a draft’s number, or draft for the latest one', 'e.g. --version 3 or --version draft; leave it out for the latest draft')
|
|
54
|
+
return version
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The run a delivery started: the id the door answered with (`respond:
|
|
59
|
+
* immediately` gives it), else the newest test run of that version since the
|
|
60
|
+
* delivery was sent. Null when none shows up within a few seconds.
|
|
61
|
+
*/
|
|
62
|
+
export async function findDeliveredRun(list: () => Promise<RunSummary[] | unknown>, version: number, sentAt: number, answered: unknown, tries = 6, waitMs = 500): Promise<string | null> {
|
|
63
|
+
const fromBody = answered && typeof answered === 'object' && typeof (answered as { runId?: unknown }).runId === 'string' ? (answered as { runId: string }).runId : null
|
|
64
|
+
if (fromBody) return fromBody
|
|
65
|
+
for (let attempt = 0; attempt < tries; attempt++) {
|
|
66
|
+
const page = await Promise.resolve().then(list).catch(() => null)
|
|
67
|
+
const rows = (Array.isArray(page) ? page : ((page as { items?: unknown[] } | null)?.items ?? [])) as Array<Partial<RunSummary>>
|
|
68
|
+
const mine = rows
|
|
69
|
+
.filter((r) => typeof r.id === 'string' && (r.versionNumber === undefined || r.versionNumber === null || r.versionNumber === version) && Date.parse(r.startedAt ?? '') >= sentAt)
|
|
70
|
+
.sort((a, b) => Date.parse(b.startedAt ?? '') - Date.parse(a.startedAt ?? ''))
|
|
71
|
+
if (mine[0]) return mine[0].id!
|
|
72
|
+
if (attempt < tries - 1) await new Promise((r) => setTimeout(r, waitMs))
|
|
73
|
+
}
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readBody(from: string | undefined): string | undefined {
|
|
78
|
+
if (from === undefined) return undefined
|
|
79
|
+
if (from === '-') return readFileSync(0, 'utf8')
|
|
80
|
+
try {
|
|
81
|
+
return readFileSync(from, 'utf8')
|
|
82
|
+
} catch {
|
|
83
|
+
throw new UsageError(`could not read ${from}`, 'pass a JSON file with --body, or - for standard input')
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const workflowWebhookTest: Command = {
|
|
88
|
+
meta: {
|
|
89
|
+
noun: 'workflow',
|
|
90
|
+
verb: 'webhook-test',
|
|
91
|
+
args: [{ name: 'workflow', required: true, description: 'the workflow’s slug' }],
|
|
92
|
+
flags: { body: 'string', path: 'string', method: 'string', header: 'string', query: 'string', version: 'string' },
|
|
93
|
+
summary: 'Send one test delivery to a workflow’s webhook on its draft, and show the run it made',
|
|
94
|
+
examples: [
|
|
95
|
+
'frontera workflow webhook-test work-order-intake --body fault.json',
|
|
96
|
+
'frontera workflow webhook-test intake --path fault-reports --body -',
|
|
97
|
+
"frontera workflow webhook-test intake --body a.json --header 'X-Key: k1'",
|
|
98
|
+
'frontera workflow webhook-test intake --body a.json --version draft --json',
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
async run(ctx) {
|
|
103
|
+
const slug = ctx.positional[0]
|
|
104
|
+
if (!slug) throw new UsageError('missing <workflow>', 'frontera workflow webhook-test <slug> --body payload.json')
|
|
105
|
+
const version = readVersionFlag(flagString(ctx, 'version'))
|
|
106
|
+
const body = readBody(flagString(ctx, 'body'))
|
|
107
|
+
if (body !== undefined) {
|
|
108
|
+
try {
|
|
109
|
+
JSON.parse(body)
|
|
110
|
+
} catch {
|
|
111
|
+
// A sender may post text or a form: sent as it is, said here.
|
|
112
|
+
ctx.output.note('[webhook-test] the body is not JSON: it is sent as text')
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const headers = parseHeader(flagString(ctx, 'header'))
|
|
116
|
+
|
|
117
|
+
const api = new WorkflowApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
118
|
+
const listen = await api.listen(slug, version).catch((err: unknown) => {
|
|
119
|
+
if (err instanceof CliError && /not.?found|404/i.test(`${err.code} ${err.message}`) && /listen/i.test(err.message)) {
|
|
120
|
+
throw new CliError('this platform release cannot arm a draft’s webhook from the CLI', { code: 'UNSUPPORTED', hint: 'use Listen on the workflow’s page, or update to a release that supports `frontera workflow webhook-test`' })
|
|
121
|
+
}
|
|
122
|
+
throw err
|
|
123
|
+
})
|
|
124
|
+
const door = pickDoor(listen, flagString(ctx, 'path'), slug)
|
|
125
|
+
const method = (flagString(ctx, 'method') ?? (door.methods.includes('POST') ? 'POST' : door.methods[0] ?? 'POST')).toUpperCase()
|
|
126
|
+
if (door.methods.length && !door.methods.includes(method)) throw new UsageError(`the webhook at "${door.path}" takes ${door.methods.join(', ')}, not ${method}`, `--method ${door.methods[0]}`)
|
|
127
|
+
const url = doorUrl(door.test, ctx.apiUrl, flagString(ctx, 'query'))
|
|
128
|
+
const sent = method === 'GET' || method === 'HEAD' ? undefined : body
|
|
129
|
+
// A second back, so a run the runtime stamps a moment before the delivery returns still counts.
|
|
130
|
+
const sentAt = Date.now() - 1_000
|
|
131
|
+
const res = await fetch(url, {
|
|
132
|
+
method,
|
|
133
|
+
headers: { ...(sent !== undefined ? { 'content-type': 'application/json' } : {}), ...headers },
|
|
134
|
+
...(sent !== undefined ? { body: sent } : {}),
|
|
135
|
+
})
|
|
136
|
+
const text = await res.text()
|
|
137
|
+
let answer: unknown = text
|
|
138
|
+
try {
|
|
139
|
+
answer = text ? JSON.parse(text) : null
|
|
140
|
+
} catch {
|
|
141
|
+
// Kept as text.
|
|
142
|
+
}
|
|
143
|
+
// The run the delivery made, so `--json` shows it (a refused delivery made none).
|
|
144
|
+
const runId = res.status === 401 || res.status === 403 || res.status === 404 ? null : await findDeliveredRun(() => api.runs(slug, 'test'), listen.version, sentAt, answer)
|
|
145
|
+
const data = { workflow: slug, version: listen.version, path: door.path, method, status: res.status, response: answer, runId }
|
|
146
|
+
if (!res.ok) {
|
|
147
|
+
throw new CliError(`the webhook answered ${res.status} on the draft (v${listen.version})`, {
|
|
148
|
+
code: 'WEBHOOK_TEST_FAILED',
|
|
149
|
+
hint: res.status === 401 || res.status === 403
|
|
150
|
+
? 'the trigger’s auth refused the delivery: pass what the sender sends with --header'
|
|
151
|
+
: res.status === 404
|
|
152
|
+
? 'the test door did not answer: the Listen may have ended, or the path or method differs from the trigger’s'
|
|
153
|
+
: `${typeof answer === 'string' ? answer.slice(0, 300) : JSON.stringify(answer).slice(0, 300)}`,
|
|
154
|
+
details: data,
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
data,
|
|
159
|
+
text: [
|
|
160
|
+
`${method} ${door.path ? `/${door.path.replace(/^\/+/, '')}` : '(no path)'} on workflow ${slug} v${listen.version} (draft): ${res.status}`,
|
|
161
|
+
typeof answer === 'string' ? answer : JSON.stringify(answer, null, 2),
|
|
162
|
+
runId ? ` the run: frontera workflow runs ${slug} ${runId}` : ` the run: frontera workflow runs ${slug} --only test`,
|
|
163
|
+
].join('\n'),
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -4,9 +4,11 @@ import { credentialStore, type CredentialSource } from './credential-store'
|
|
|
4
4
|
import { CliError, UsageError } from './errors'
|
|
5
5
|
import { migrateLegacyConfig } from './profile-migration'
|
|
6
6
|
import { configPath, type Env } from './paths'
|
|
7
|
+
import { localRuntimeProfileOf } from './local/local-project'
|
|
7
8
|
import { findContext } from './project-context'
|
|
8
9
|
import {
|
|
9
10
|
assertOriginMatches,
|
|
11
|
+
getProfile,
|
|
10
12
|
isLegacyConfig,
|
|
11
13
|
listProfiles,
|
|
12
14
|
normalizeOrigin,
|
|
@@ -72,6 +74,25 @@ export interface CredentialDeps {
|
|
|
72
74
|
export interface ResolveOptions {
|
|
73
75
|
apiUrl?: string
|
|
74
76
|
profile?: string
|
|
77
|
+
/**
|
|
78
|
+
* The command acts at organization grain. In a directory bound to a local
|
|
79
|
+
* runtime's workspace profile (`local-<project>`), the runtime's
|
|
80
|
+
* organization profile beside it (`local-<project>-org`) is selected
|
|
81
|
+
* instead: the same runtime, the key its organization commands need.
|
|
82
|
+
*/
|
|
83
|
+
organization?: boolean
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The organization profile `local start` registers beside a local runtime's
|
|
88
|
+
* workspace profile, when this is one and it has one.
|
|
89
|
+
*/
|
|
90
|
+
export function localOrganizationCompanion(profile: string, env: Env = process.env): string | null {
|
|
91
|
+
if (!profile.startsWith('local-')) return null
|
|
92
|
+
const org = `${profile}-org`.slice(0, 64)
|
|
93
|
+
if (org === profile || getProfile(profile, env)?.credentialKind !== 'workspace') return null
|
|
94
|
+
const companion = getProfile(org, env)
|
|
95
|
+
return companion?.credentialKind === 'organization' && companion.apiUrl === getProfile(profile, env)?.apiUrl ? org : null
|
|
75
96
|
}
|
|
76
97
|
|
|
77
98
|
/**
|
|
@@ -103,16 +124,24 @@ export function resolveProfileName(
|
|
|
103
124
|
* question rather than an answer, and `auth use` is how it gets answered.
|
|
104
125
|
*/
|
|
105
126
|
if (!found.trusted) {
|
|
127
|
+
// A copied or pulled project carries the machine it came from's selection. When this directory
|
|
128
|
+
// has its own local runtime, ITS profile is the one to bind: the named one belongs to another
|
|
129
|
+
// directory (another project's runtime, or another machine's).
|
|
130
|
+
const own = localRuntimeProfileOf(found.root)
|
|
131
|
+
const ownHere = own && own !== found.profile && getProfile(own, env) ? own : null
|
|
106
132
|
throw new CliError(
|
|
107
133
|
`${found.path} selects "${found.profile}", but this machine never bound that directory`,
|
|
108
134
|
{
|
|
109
135
|
code: 'PROJECT_CONTEXT_UNTRUSTED',
|
|
110
|
-
hint:
|
|
111
|
-
`
|
|
112
|
-
|
|
136
|
+
hint: ownHere
|
|
137
|
+
? `this directory has its own local runtime, whose profile is ${ownHere} (\`frontera local status\` names it): run \`frontera auth use ${ownHere}\` in ${found.root}. "${found.profile}" was bound where this project came from, and would reach that runtime instead`
|
|
138
|
+
: `if you meant to use it, run \`frontera auth use ${found.profile}\` in ${found.root} — `
|
|
139
|
+
+ 'a context file committed to a repository is not proof that you chose it',
|
|
113
140
|
},
|
|
114
141
|
)
|
|
115
142
|
}
|
|
143
|
+
const companion = opts.organization ? localOrganizationCompanion(found.profile, env) : null
|
|
144
|
+
if (companion) return { profile: companion, source: 'directory', path: found.path }
|
|
116
145
|
return { profile: found.profile, source: 'directory', path: found.path }
|
|
117
146
|
}
|
|
118
147
|
|
|
@@ -144,9 +173,18 @@ export async function resolveCredential(
|
|
|
144
173
|
await ensureMigrated(env)
|
|
145
174
|
|
|
146
175
|
const store = deps.store ?? credentialStore(env)
|
|
147
|
-
const selection = resolveProfileName(opts, deps)
|
|
148
|
-
|
|
149
176
|
const explicitOrigin = opts.apiUrl ?? env.FRONTERA_API_URL
|
|
177
|
+
const envToken = env.FRONTERA_TOKEN?.trim()
|
|
178
|
+
// A raw token with its own origin uses no stored key, so a context file this machine did not
|
|
179
|
+
// bind is nothing to refuse: it only names the profile the result reports (none, here).
|
|
180
|
+
let selection: ReturnType<typeof resolveProfileName>
|
|
181
|
+
try {
|
|
182
|
+
selection = resolveProfileName(opts, deps)
|
|
183
|
+
} catch (err) {
|
|
184
|
+
if (!(envToken && explicitOrigin && err instanceof CliError && err.code === 'PROJECT_CONTEXT_UNTRUSTED')) throw err
|
|
185
|
+
selection = { profile: null, source: 'none' }
|
|
186
|
+
}
|
|
187
|
+
|
|
150
188
|
const originSource: OriginSource = opts.apiUrl ? 'flag' : env.FRONTERA_API_URL ? 'environment' : 'profile'
|
|
151
189
|
|
|
152
190
|
/**
|
|
@@ -155,7 +193,6 @@ export async function resolveCredential(
|
|
|
155
193
|
* would be sent to whatever deployment the checked-out directory happens to
|
|
156
194
|
* select. That is a cross-customer credential leak with no visible symptom.
|
|
157
195
|
*/
|
|
158
|
-
const envToken = env.FRONTERA_TOKEN?.trim()
|
|
159
196
|
if (envToken) {
|
|
160
197
|
if (!explicitOrigin) {
|
|
161
198
|
throw new UsageError(
|
package/src/dev-broker.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { createServer } from 'node:net'
|
|
2
3
|
|
|
3
4
|
import type { BridgeInit } from '@frontera-sdk/core/bridge-protocol'
|
|
4
5
|
|
|
@@ -69,3 +70,54 @@ export function createDevSessionBroker(options: {
|
|
|
69
70
|
|
|
70
71
|
return { endpoint: `http://127.0.0.1:${server.port}${path}`, server }
|
|
71
72
|
}
|
|
73
|
+
|
|
74
|
+
/** The addresses an App's port must be free on: the browser resolves `localhost` to either loopback, and a wildcard listener answers both. */
|
|
75
|
+
export const LOOPBACK_ADDRESSES = ['127.0.0.1', '::1', '0.0.0.0', '::'] as const
|
|
76
|
+
|
|
77
|
+
function freeOn(port: number, host: string): Promise<boolean> {
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
const server = createServer()
|
|
80
|
+
server.once('error', (err: NodeJS.ErrnoException) => {
|
|
81
|
+
// No IPv6 on this machine: nothing can hold the port there either.
|
|
82
|
+
resolve(err.code === 'EADDRNOTAVAIL' || err.code === 'EAFNOSUPPORT')
|
|
83
|
+
})
|
|
84
|
+
server.listen({ port, host, exclusive: true, ...(host === '::' ? { ipv6Only: true } : {}) }, () => server.close(() => resolve(true)))
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Whether nothing listens on `port` on either loopback, IPv4 or IPv6, nor on
|
|
90
|
+
* a wildcard. Probing 127.0.0.1 alone let a dev server start beside another
|
|
91
|
+
* process holding the port on ::1, and `localhost` and `127.0.0.1` then
|
|
92
|
+
* served two different Apps.
|
|
93
|
+
*/
|
|
94
|
+
export async function isLoopbackPortFree(port: number, hosts: readonly string[] = LOOPBACK_ADDRESSES): Promise<boolean> {
|
|
95
|
+
for (const host of hosts) if (!(await freeOn(port, host))) return false
|
|
96
|
+
return true
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A port the system hands out on 127.0.0.1 (`listen(0)`), closed again at once. */
|
|
100
|
+
function systemPort(): Promise<number> {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
const server = createServer()
|
|
103
|
+
server.once('error', reject)
|
|
104
|
+
server.listen({ port: 0, host: '127.0.0.1', exclusive: true }, () => {
|
|
105
|
+
const address = server.address()
|
|
106
|
+
const port = typeof address === 'object' && address ? address.port : 0
|
|
107
|
+
server.close(() => (port ? resolve(port) : reject(new Error('the system gave no port'))))
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A port free on every loopback address (`isLoopbackPortFree`), for `--port 0`:
|
|
114
|
+
* the system picks it on 127.0.0.1, and one something holds on ::1 or a
|
|
115
|
+
* wildcard is passed over for the next.
|
|
116
|
+
*/
|
|
117
|
+
export async function freeLoopbackPort(isFree: (port: number) => Promise<boolean> = isLoopbackPortFree): Promise<number> {
|
|
118
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
119
|
+
const port = await systemPort()
|
|
120
|
+
if (await isFree(port)) return port
|
|
121
|
+
}
|
|
122
|
+
throw new Error('no port was free on every loopback address after 20 tries')
|
|
123
|
+
}
|