@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
|
@@ -2,6 +2,7 @@ import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs'
|
|
|
2
2
|
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
|
3
3
|
|
|
4
4
|
import { agentIdentity, createFlags } from './local-agent-name'
|
|
5
|
+
import { widerGrants, type WorkspaceGrant } from './local-workspace-grants'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* What `frontera local dev` applies when a file in the solution changes.
|
|
@@ -196,7 +197,9 @@ export function allTargets(root: string): SyncTarget[] {
|
|
|
196
197
|
if (depth > 6 || !existsSync(dir)) return
|
|
197
198
|
let names: string[]
|
|
198
199
|
try {
|
|
199
|
-
|
|
200
|
+
// Sorted: the order a directory lists its files in is the file system's (macOS sorts, Linux does not),
|
|
201
|
+
// and items of one kind are applied, and listed, in this order.
|
|
202
|
+
names = readdirSync(dir).sort()
|
|
200
203
|
} catch {
|
|
201
204
|
return
|
|
202
205
|
}
|
|
@@ -296,10 +299,10 @@ export function commandFor(target: SyncTarget, profiles: SyncProfiles): string[]
|
|
|
296
299
|
}
|
|
297
300
|
}
|
|
298
301
|
|
|
299
|
-
/** When the first command fails with `when`, run `
|
|
302
|
+
/** When the first command fails with `when`, run `steps` instead. */
|
|
300
303
|
export interface Fallback {
|
|
301
304
|
when: RegExp
|
|
302
|
-
|
|
305
|
+
steps: string[][]
|
|
303
306
|
}
|
|
304
307
|
|
|
305
308
|
/**
|
|
@@ -307,22 +310,26 @@ export interface Fallback {
|
|
|
307
310
|
* Agent applied for the first time is created, then applied (`agent apply`
|
|
308
311
|
* stages onto an existing draft); a dataset that exists already gets its CSV
|
|
309
312
|
* as a new snapshot (`dataset create` refuses a name in use). A dataset with
|
|
310
|
-
* no CSV that exists already has nothing more to load: an empty `
|
|
313
|
+
* no CSV that exists already has nothing more to load: an empty `steps`.
|
|
311
314
|
*
|
|
312
|
-
* Only rows reach an existing dataset. A
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
+
* Only rows reach an existing dataset. A CSV with empty values a column of its
|
|
316
|
+
* revision does not allow is refused; `local dev` then cuts the dataset's next
|
|
317
|
+
* revision from it (`dataset revise`, which only widens nullability), binds the
|
|
318
|
+
* object types reading it to that revision and applies the Blueprint again. A
|
|
319
|
+
* column added, dropped or retyped is a new dataset, and stays refused.
|
|
315
320
|
*/
|
|
316
321
|
export function fallbackFor(target: SyncTarget, profiles: SyncProfiles, root: string): Fallback | null {
|
|
317
322
|
if (target.kind === 'agent') {
|
|
318
323
|
// Created with its name, or every list reads the slug until it is published.
|
|
319
324
|
const named = createFlags(agentIdentity(root, target.file))
|
|
320
|
-
return { when: /NOT_FOUND/,
|
|
325
|
+
return { when: /NOT_FOUND/, steps: [['agent', 'create', target.slug, ...named, '--profile', profiles.workspace], commandFor(target, profiles)] }
|
|
321
326
|
}
|
|
327
|
+
// A workflow archived on the runtime takes no new version: restored, then pushed (the files say it is in service).
|
|
328
|
+
if (target.kind === 'workflow') return { when: /is archived, so it cannot/i, steps: [['workflow', 'restore', target.slug, '--profile', profiles.workspace], commandFor(target, profiles)] }
|
|
322
329
|
if (target.kind === 'dataset') {
|
|
323
330
|
const doc = datasetDocument(root, target)
|
|
324
331
|
const upload = doc?.csv ? [['dataset', 'upload', doc.apiName, doc.csv, '--profile', profiles.organization]] : []
|
|
325
|
-
return { when: /already in use/i,
|
|
332
|
+
return { when: /already in use/i, steps: upload }
|
|
326
333
|
}
|
|
327
334
|
return null
|
|
328
335
|
}
|
|
@@ -342,7 +349,7 @@ export interface FollowUp {
|
|
|
342
349
|
export function followUpsFor(
|
|
343
350
|
target: SyncTarget,
|
|
344
351
|
profiles: SyncProfiles,
|
|
345
|
-
opts: { publish: boolean; workspaceId: string; objectTypes: string[] },
|
|
352
|
+
opts: { publish: boolean; workspaceId: string; objectTypes: string[]; grants?: WorkspaceGrant[] },
|
|
346
353
|
): FollowUp[] {
|
|
347
354
|
if (target.kind !== 'blueprint' || !opts.publish) return []
|
|
348
355
|
const steps: FollowUp[] = [{
|
|
@@ -357,8 +364,24 @@ export function followUpsFor(
|
|
|
357
364
|
],
|
|
358
365
|
alreadyDone: /no unpublished changes/i,
|
|
359
366
|
}]
|
|
360
|
-
|
|
361
|
-
|
|
367
|
+
// `blueprint/workspace-grants.yaml`: a type granted more than `read` is granted that, after the
|
|
368
|
+
// plain grants (each grant sets its whole permission list, so the plain one would undo it). A
|
|
369
|
+
// plain grant keeps what the type's grant already holds; a type the file lists as `[read]` is
|
|
370
|
+
// granted exactly that, which is how the file takes `read_sensitive` away.
|
|
371
|
+
const wider = widerGrants(opts.grants ?? [])
|
|
372
|
+
const widened = new Set([...wider.values()].flat())
|
|
373
|
+
const onlyRead = new Set((opts.grants ?? []).filter((g) => g.permissions.length <= 1).map((g) => g.objectType))
|
|
374
|
+
const plain = opts.objectTypes.filter((t) => !widened.has(t) && !onlyRead.has(t))
|
|
375
|
+
if (plain.length) {
|
|
376
|
+
steps.push({ args: ['blueprint', 'grant', 'workspace', opts.workspaceId, ...plain, '--profile', profiles.organization] })
|
|
377
|
+
}
|
|
378
|
+
const exactlyRead = opts.objectTypes.filter((t) => onlyRead.has(t) && !widened.has(t))
|
|
379
|
+
if (exactlyRead.length) {
|
|
380
|
+
steps.push({ args: ['blueprint', 'grant', 'workspace', opts.workspaceId, ...exactlyRead, '--permissions', 'read', '--profile', profiles.organization] })
|
|
381
|
+
}
|
|
382
|
+
for (const [permissions, types] of wider) {
|
|
383
|
+
const present = types.filter((t) => opts.objectTypes.includes(t))
|
|
384
|
+
if (present.length) steps.push({ args: ['blueprint', 'grant', 'workspace', opts.workspaceId, ...present, '--permissions', permissions, '--profile', profiles.organization] })
|
|
362
385
|
}
|
|
363
386
|
return steps
|
|
364
387
|
}
|
|
@@ -418,3 +441,109 @@ export function describeTarget(target: SyncTarget): string {
|
|
|
418
441
|
return 'the library installations'
|
|
419
442
|
}
|
|
420
443
|
}
|
|
444
|
+
|
|
445
|
+
/** The Blueprint's folders: `blueprint apply` reads the model's, `local dev` saves the object sets. */
|
|
446
|
+
const BLUEPRINT_FOLDERS = new Set(['object-types', 'link-types', 'shared-fields', 'semantic-types', 'metrics', 'actions', 'object-sets'])
|
|
447
|
+
|
|
448
|
+
/** What an Agent's folder holds that something reads: `local dev` applies the files, `local test` the cases. */
|
|
449
|
+
const AGENT_FILES = new Set(['agent.json', 'operating.json', 'bindings.json', 'lessons.yaml'])
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Why a file in one of the folders `local dev` applies is not applied, or null
|
|
453
|
+
* when it is (or is read by something else: test cases, fixtures, a
|
|
454
|
+
* Function's helpers, a knowledge base's documents). Said instead of ignoring
|
|
455
|
+
* the file in silence: `themes/acme.json` is not a theme, `themes/acme/theme.json` is.
|
|
456
|
+
*/
|
|
457
|
+
export function unappliedReason(root: string, rel: string): string | null {
|
|
458
|
+
const parts = rel.split(sep)
|
|
459
|
+
if (parts.some((p) => IGNORED_DIRS.has(p) || isIgnoredName(p))) return null
|
|
460
|
+
const [top, second, third] = parts
|
|
461
|
+
const name = parts[parts.length - 1]!
|
|
462
|
+
if (/^(readme|agents|claude)\.md$/i.test(name)) return null
|
|
463
|
+
// Any YAML under blueprint/ queues a Blueprint apply, which reads only the kinds' folders.
|
|
464
|
+
if (top !== 'blueprint' && targetFor(root, join(root, rel))) return null
|
|
465
|
+
switch (top) {
|
|
466
|
+
case 'themes':
|
|
467
|
+
if (parts.length === 2) return `a slide theme is themes/<name>/theme.json, with its images beside it (\`frontera local schema theme\`): move it to themes/${name.replace(/\.(json|ya?ml)$/, '')}/theme.json`
|
|
468
|
+
return `themes/${second}/ has no theme.json, so nothing in it is applied (\`frontera local schema theme\`)`
|
|
469
|
+
case 'agents':
|
|
470
|
+
if (parts.length === 2) return 'an Agent is a folder: agents/<slug>/agent.json'
|
|
471
|
+
if (third === 'evals' || third === 'fixtures') return null
|
|
472
|
+
if (parts.length === 3 && !AGENT_FILES.has(name)) {
|
|
473
|
+
return `an Agent's files are agent.json, operating.json, bindings.json (the Functions and workflows it may start, the object types it may read) and lessons.yaml, with its test cases in evals/`
|
|
474
|
+
}
|
|
475
|
+
return null
|
|
476
|
+
case 'blueprint':
|
|
477
|
+
// The workspace's own grants beyond read, applied after each publish (`local-workspace-grants.ts`).
|
|
478
|
+
if (parts.length === 2 && name === 'workspace-grants.yaml') return null
|
|
479
|
+
if (parts.length === 2) return 'a Blueprint file lives in its kind’s folder: blueprint/object-types/, link-types/, shared-fields/, semantic-types/, metrics/, actions/ or object-sets/, or is blueprint/workspace-grants.yaml (`frontera local schema` lists each)'
|
|
480
|
+
if (!BLUEPRINT_FOLDERS.has(second!)) return `blueprint/${second}/ is not a Blueprint folder; the folders are ${[...BLUEPRINT_FOLDERS].join(', ')}`
|
|
481
|
+
if (!/\.(ya?ml|json)$/.test(name)) return 'a Blueprint file is YAML or JSON'
|
|
482
|
+
if (parts.length > 3) return `a Blueprint file sits directly in blueprint/${second}/`
|
|
483
|
+
return null
|
|
484
|
+
case 'workflows':
|
|
485
|
+
if (second === 'tests') return null
|
|
486
|
+
if (parts.length > 2) return 'a workflow is workflows/<slug>.yaml, directly in workflows/ (its tests go in workflows/tests/)'
|
|
487
|
+
return 'a workflow is workflows/<slug>.yaml (or .yml, .json)'
|
|
488
|
+
case 'datasets':
|
|
489
|
+
if (parts.length > 2) return 'a dataset is datasets/<apiName>.json with its CSV beside it, directly in datasets/'
|
|
490
|
+
return 'a dataset is datasets/<apiName>.json, with datasets/<apiName>.csv beside it for a file dataset'
|
|
491
|
+
case 'knowledge':
|
|
492
|
+
if (parts.length === 2) return 'a knowledge base is a folder: knowledge/<name>/knowledge.json ({ "name": "…", "description": "…" }) with its documents beside it'
|
|
493
|
+
return `knowledge/${second}/ has no knowledge.json ({ "name": "…", "description": "…" }), so its documents are not uploaded`
|
|
494
|
+
case 'skills':
|
|
495
|
+
if (parts.length === 2) return 'a skill is a folder: skills/<name>/SKILL.md'
|
|
496
|
+
return `skills/${second}/ has no SKILL.md, so it is not pushed`
|
|
497
|
+
case 'chat-apps':
|
|
498
|
+
return 'a Chat App is chat-apps/<slug>.json, directly in chat-apps/'
|
|
499
|
+
case 'mcp':
|
|
500
|
+
return 'a Plugin is mcp/<name>.plugin.json (a stand-in mcp/<name>.stand-in.json)'
|
|
501
|
+
default:
|
|
502
|
+
return null
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Every file in the applied folders that `local dev` does not apply, with why: one line each. */
|
|
507
|
+
export function unappliedFiles(root: string): string[] {
|
|
508
|
+
const out: string[] = []
|
|
509
|
+
const walk = (dir: string, depth: number) => {
|
|
510
|
+
if (depth > 4 || !existsSync(dir)) return
|
|
511
|
+
let names: string[]
|
|
512
|
+
try {
|
|
513
|
+
names = readdirSync(dir).sort()
|
|
514
|
+
} catch {
|
|
515
|
+
return
|
|
516
|
+
}
|
|
517
|
+
for (const name of names) {
|
|
518
|
+
if (IGNORED_DIRS.has(name) || isIgnoredName(name)) continue
|
|
519
|
+
const path = join(dir, name)
|
|
520
|
+
let stat
|
|
521
|
+
try {
|
|
522
|
+
stat = lstatSync(path)
|
|
523
|
+
} catch {
|
|
524
|
+
continue
|
|
525
|
+
}
|
|
526
|
+
if (stat.isDirectory()) {
|
|
527
|
+
const rel = relative(root, path)
|
|
528
|
+
const [top, second] = rel.split(sep)
|
|
529
|
+
// Folders whose files are not each applied: a Function's helpers, a knowledge base's documents, an App's code.
|
|
530
|
+
if (top === 'functions' || top === 'apps' || (top === 'knowledge' && second && existsSync(join(root, 'knowledge', second, 'knowledge.json')))) continue
|
|
531
|
+
if (top === 'skills' && second && existsSync(join(root, 'skills', second, 'SKILL.md'))) continue
|
|
532
|
+
if (top === 'themes' && second && existsSync(join(root, 'themes', second, 'theme.json'))) continue
|
|
533
|
+
if (top === 'knowledge' || top === 'skills' || top === 'themes') {
|
|
534
|
+
// One line for the folder, not one per document in it.
|
|
535
|
+
const why = unappliedReason(root, join(rel, '_'))
|
|
536
|
+
if (why && rel.split(sep).length === 2) out.push(`${rel}/: ${why}`)
|
|
537
|
+
continue
|
|
538
|
+
}
|
|
539
|
+
walk(path, depth + 1)
|
|
540
|
+
} else if (stat.isFile()) {
|
|
541
|
+
const rel = relative(root, path)
|
|
542
|
+
const why = unappliedReason(root, rel)
|
|
543
|
+
if (why) out.push(`${rel}: ${why}`)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
for (const top of ['agents', 'blueprint', 'chat-apps', 'datasets', 'knowledge', 'mcp', 'skills', 'themes', 'workflows']) walk(join(root, top), 1)
|
|
548
|
+
return out
|
|
549
|
+
}
|
|
@@ -29,7 +29,18 @@ export interface DockerRunSpec {
|
|
|
29
29
|
|
|
30
30
|
export class DockerError extends Error {}
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
export type DockerResult = { ok: boolean; out: string; err: string }
|
|
33
|
+
export type DockerExec = (args: string[], opts: { input?: string }) => DockerResult
|
|
34
|
+
|
|
35
|
+
let dockerExec: DockerExec | null = null
|
|
36
|
+
|
|
37
|
+
/** Answer every docker call with `exec` instead of the docker CLI; null restores it. For tests. */
|
|
38
|
+
export function setDockerExecForTests(exec: DockerExec | null): void {
|
|
39
|
+
dockerExec = exec
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function docker(args: string[], opts: { input?: string } = {}): DockerResult {
|
|
43
|
+
if (dockerExec) return dockerExec(args, opts)
|
|
33
44
|
const res = spawnSync('docker', args, { encoding: 'utf8', input: opts.input })
|
|
34
45
|
if (res.error) {
|
|
35
46
|
throw new DockerError(
|
|
@@ -133,6 +144,12 @@ export function execInContainer(name: string, cmd: string[]): boolean {
|
|
|
133
144
|
return docker(['exec', name, ...cmd]).ok
|
|
134
145
|
}
|
|
135
146
|
|
|
147
|
+
/** As `execInContainer`, with `input` on the command's stdin when given, and what it printed. */
|
|
148
|
+
export function queryInContainer(name: string, cmd: string[], input?: string): { ok: boolean; out: string } {
|
|
149
|
+
const res = docker(['exec', ...(input === undefined ? [] : ['-i']), name, ...cmd], input === undefined ? {} : { input })
|
|
150
|
+
return { ok: res.ok, out: res.out }
|
|
151
|
+
}
|
|
152
|
+
|
|
136
153
|
/** Follow a container's logs into a file for as long as the container lives. */
|
|
137
154
|
export function followLogs(name: string, logFile: string): void {
|
|
138
155
|
const fd = openSync(logFile, 'a')
|
|
@@ -144,3 +161,20 @@ export function containersForProject(projectId: string): string[] {
|
|
|
144
161
|
const r = docker(['ps', '-a', '--filter', `label=${PROJECT_LABEL}=${projectId}`, '--format', '{{.Names}}'])
|
|
145
162
|
return r.ok && r.out ? r.out.split('\n') : []
|
|
146
163
|
}
|
|
164
|
+
|
|
165
|
+
/** A local runtime's database volume, by its id (the key of its port block). */
|
|
166
|
+
export const dbVolumeName = (projectId: string) => `frontera-${projectId}-db`
|
|
167
|
+
|
|
168
|
+
/** A local runtime's file storage volume, by its id. */
|
|
169
|
+
export const storageVolumeName = (projectId: string) => `frontera-${projectId}-storage`
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Delete a local runtime's containers and volumes, by its id: the containers
|
|
173
|
+
* labelled for it, running or not, and its database and storage volumes. Its
|
|
174
|
+
* data is gone afterwards.
|
|
175
|
+
*/
|
|
176
|
+
export function removeRuntimeResources(projectId: string): void {
|
|
177
|
+
for (const name of containersForProject(projectId)) removeContainer(name)
|
|
178
|
+
removeVolume(dbVolumeName(projectId))
|
|
179
|
+
removeVolume(storageVolumeName(projectId))
|
|
180
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Full-text search over a documentation publication, for `
|
|
2
|
+
* Full-text search over a documentation publication, for `frontera mcp`.
|
|
3
3
|
* The same ranking the platform's documentation search uses (title, aliases,
|
|
4
4
|
* description, the best matching section, prefix matches), kept here because
|
|
5
5
|
* the CLI is published on its own and cannot import the service.
|
package/src/local/local-docs.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { appendFileSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
2
3
|
import { dirname, join } from 'node:path'
|
|
3
4
|
import { createInterface } from 'node:readline'
|
|
4
5
|
|
|
@@ -10,7 +11,7 @@ import { isSchemaKind, SCHEMA_FILES, SCHEMA_KINDS, type SchemaKind } from './loc
|
|
|
10
11
|
* Frontera's documentation for the coding agents working in a project
|
|
11
12
|
* (Claude Code, Codex, Cursor). `local start` copies the customer publication
|
|
12
13
|
* of the release the runtime runs into the runtime's state, and
|
|
13
|
-
* `frontera
|
|
14
|
+
* `frontera mcp` serves it over MCP on stdio, straight from that
|
|
14
15
|
* file: no key, no request to the runtime, and it keeps working while the
|
|
15
16
|
* runtime is stopped. The formats of the project's files (`local-schemas.ts`)
|
|
16
17
|
* are served the same way, from the copies `local start` keeps.
|
|
@@ -28,6 +29,284 @@ export function customerDocsBundle(platformSource: string | undefined): string |
|
|
|
28
29
|
return existsSync(path) ? path : null
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
/** What `local status` says about the documentation a project's coding agents read. */
|
|
33
|
+
export interface DocsStatus {
|
|
34
|
+
/** The copy is there and readable. */
|
|
35
|
+
present: boolean
|
|
36
|
+
/** The product version the copy documents (`productVersion`). */
|
|
37
|
+
version: string | null
|
|
38
|
+
/** The runtime's version: the checkout's in source mode, the release's in image mode. */
|
|
39
|
+
runtimeVersion: string
|
|
40
|
+
builtAt: string | null
|
|
41
|
+
/** The copy documents another version, or (source mode) the checkout's guides changed since it was built. */
|
|
42
|
+
stale: boolean
|
|
43
|
+
/** `local start` is rebuilding the checkout's documentation now; the copy is refreshed when it is done. */
|
|
44
|
+
rebuilding?: boolean
|
|
45
|
+
/** The last rebuild at start failed: the copy stays as it was. */
|
|
46
|
+
rebuildFailed?: boolean
|
|
47
|
+
/** In words, when there is something to say: why it is stale, or that there is none. */
|
|
48
|
+
note: string | null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The version a platform checkout is at (its root `package.json`): what a
|
|
53
|
+
* runtime started from it runs, recorded as the runtime's version at start.
|
|
54
|
+
*/
|
|
55
|
+
export function checkoutVersion(platformSource: string): string | null {
|
|
56
|
+
return readVersion(join(platformSource, 'package.json'))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The version a runtime records at start, which the Runtime page, `local
|
|
61
|
+
* status` and the Docs note all say: from a checkout, the checkout's version
|
|
62
|
+
* at this start, never one kept from an earlier start (run 8: started from a
|
|
63
|
+
* 1.52.33 checkout, the runtime said 1.52.31, its first start's version); from
|
|
64
|
+
* a release image, the release asked for, else the one an earlier image start
|
|
65
|
+
* used, else this CLI's.
|
|
66
|
+
*/
|
|
67
|
+
export function versionAtStart(o: { checkout: string | null; flag: string | undefined; previous: { mode: 'source' | 'image'; platformVersion: string } | null; cli: string }): string {
|
|
68
|
+
if (o.checkout) return o.checkout
|
|
69
|
+
return o.flag ?? (o.previous?.mode === 'image' ? o.previous.platformVersion : undefined) ?? o.cli
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readVersion(path: string): string | null {
|
|
73
|
+
try {
|
|
74
|
+
const v = (JSON.parse(readFileSync(path, 'utf8')) as { version?: unknown }).version
|
|
75
|
+
return typeof v === 'string' ? v : null
|
|
76
|
+
} catch {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The guides' sources the customer publication is built from. The internal
|
|
83
|
+
* pages (`content/internal`) ship only in the internal distribution, so a
|
|
84
|
+
* change there never makes the customer copy out of date.
|
|
85
|
+
*/
|
|
86
|
+
const GUIDE_PATHS = ['packages/docs-content/content', 'packages/docs-content/catalog', 'packages/docs-content/identity', ':(exclude)packages/docs-content/content/internal']
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Whether the guides in a platform checkout changed since the commit a
|
|
90
|
+
* publication was built from (committed or not, new files included). A
|
|
91
|
+
* build reads the working tree, not the commit, so with `builtAt` a file
|
|
92
|
+
* that differs from the commit counts only when it changed after the build
|
|
93
|
+
* (by its modification time): an edit already in the build is not a reason
|
|
94
|
+
* to say the copy is behind. Null when it cannot tell (no git, a commit the
|
|
95
|
+
* checkout does not have).
|
|
96
|
+
*/
|
|
97
|
+
export function guidesChangedSince(platformSource: string, commit: string, builtAt?: string | null): boolean | null {
|
|
98
|
+
if (!/^[0-9a-f]{7,64}$/i.test(commit)) return null
|
|
99
|
+
const known = Bun.spawnSync(['git', '-C', platformSource, 'cat-file', '-e', `${commit}^{commit}`], { stdout: 'ignore', stderr: 'ignore' })
|
|
100
|
+
if (known.exitCode !== 0) return null
|
|
101
|
+
const diff = Bun.spawnSync(['git', '-C', platformSource, 'diff', '--name-only', commit, '--', ...GUIDE_PATHS], { stdout: 'pipe', stderr: 'ignore' })
|
|
102
|
+
if (diff.exitCode !== 0) return null
|
|
103
|
+
const added = Bun.spawnSync(['git', '-C', platformSource, 'ls-files', '--others', '--exclude-standard', '--', ...GUIDE_PATHS], { stdout: 'pipe', stderr: 'ignore' })
|
|
104
|
+
const paths = [...diff.stdout.toString().split('\n'), ...(added.exitCode === 0 ? added.stdout.toString().split('\n') : [])].map((l) => l.trim()).filter(Boolean)
|
|
105
|
+
if (!paths.length) return false
|
|
106
|
+
const built = builtAt ? Date.parse(builtAt) : NaN
|
|
107
|
+
if (!Number.isFinite(built)) return true
|
|
108
|
+
return paths.some((rel) => changedAfter(join(platformSource, rel), built))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A file changed after `at` (ms): its modification time, or, removed, its folder's. */
|
|
112
|
+
function changedAfter(path: string, at: number): boolean {
|
|
113
|
+
try {
|
|
114
|
+
return statSync(path).mtimeMs > at
|
|
115
|
+
} catch {
|
|
116
|
+
try {
|
|
117
|
+
return statSync(dirname(path)).mtimeMs > at
|
|
118
|
+
} catch {
|
|
119
|
+
return true
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Where `local start` says how its rebuild of the checkout's documentation is going: building, or failed. */
|
|
125
|
+
export function docsBuildMarkerPath(project: LocalProject): string {
|
|
126
|
+
return join(project.stateDir, 'docs', 'rebuild.json')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface DocsBuildMarker {
|
|
130
|
+
state: 'building' | 'failed'
|
|
131
|
+
/** When the rebuild started, or failed. */
|
|
132
|
+
at: string
|
|
133
|
+
/** The process building it (the runtime's supervisor): a marker whose process is gone is not building. */
|
|
134
|
+
pid?: number
|
|
135
|
+
/** Why it was rebuilt. */
|
|
136
|
+
why?: string
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function readDocsBuildMarker(project: LocalProject): DocsBuildMarker | null {
|
|
140
|
+
try {
|
|
141
|
+
const m = JSON.parse(readFileSync(docsBuildMarkerPath(project), 'utf8')) as DocsBuildMarker
|
|
142
|
+
return m && (m.state === 'building' || m.state === 'failed') && typeof m.at === 'string' ? m : null
|
|
143
|
+
} catch {
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Say a rebuild is under way or failed; null clears it (it built). Never throws: the marker is only for what status says. */
|
|
149
|
+
export function writeDocsBuildMarker(project: LocalProject, marker: DocsBuildMarker | null): void {
|
|
150
|
+
const path = docsBuildMarkerPath(project)
|
|
151
|
+
try {
|
|
152
|
+
if (!marker) return rmSync(path, { force: true })
|
|
153
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
154
|
+
writeFileSync(path, `${JSON.stringify(marker)}\n`)
|
|
155
|
+
} catch {
|
|
156
|
+
// Status falls back to what the copy says.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function processAlive(pid: number | undefined): boolean {
|
|
161
|
+
if (!pid) return false
|
|
162
|
+
try {
|
|
163
|
+
process.kill(pid, 0)
|
|
164
|
+
return true
|
|
165
|
+
} catch (err) {
|
|
166
|
+
return (err as NodeJS.ErrnoException).code === 'EPERM'
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* A current build in the checkout that is newer than the project's copy
|
|
172
|
+
* (someone ran `bun run docs:build` there after the runtime started): copy
|
|
173
|
+
* it in, as the next start would, so the copy and its status follow the
|
|
174
|
+
* checkout. Returns whether it copied.
|
|
175
|
+
*/
|
|
176
|
+
export function refreshDocsFromCheckout(project: LocalProject, platformSource: string | undefined, changedSince: typeof guidesChangedSince = guidesChangedSince): boolean {
|
|
177
|
+
const bundle = customerDocsBundle(platformSource)
|
|
178
|
+
if (!bundle || !platformSource) return false
|
|
179
|
+
const builtAtOf = (path: string): number => {
|
|
180
|
+
try {
|
|
181
|
+
const at = (JSON.parse(readFileSync(path, 'utf8')) as { builtAt?: unknown }).builtAt
|
|
182
|
+
return typeof at === 'string' ? Date.parse(at) : NaN
|
|
183
|
+
} catch {
|
|
184
|
+
return NaN
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const theirs = builtAtOf(bundle)
|
|
188
|
+
const ours = builtAtOf(docsCopyPath(project))
|
|
189
|
+
if (!Number.isFinite(theirs) || (Number.isFinite(ours) && theirs <= ours)) return false
|
|
190
|
+
if (checkoutDocsStale(platformSource, changedSince)) return false
|
|
191
|
+
return copyDocs({ project, bundle }) === null
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The documentation copy against the runtime it was copied for. `local
|
|
196
|
+
* start` copies what the checkout last built and, when that build is
|
|
197
|
+
* behind the checkout, rebuilds it in the background and copies it again;
|
|
198
|
+
* this says plainly when the copy documents another version, or guides
|
|
199
|
+
* changed since, instead of serving it silently, and whether that rebuild
|
|
200
|
+
* is running or failed. A newer current build in the checkout is copied in
|
|
201
|
+
* first (`refreshDocsFromCheckout`). The fix it names fits the runtime: a
|
|
202
|
+
* platform checkout's build only in source mode. Versions compare like with
|
|
203
|
+
* like: the copy's against the version the running runtime recorded at start.
|
|
204
|
+
*/
|
|
205
|
+
export function docsStatus(project: LocalProject, runtime: { mode: 'source' | 'image'; platformVersion: string; platformSource?: string }, changedSince: typeof guidesChangedSince = guidesChangedSince): DocsStatus {
|
|
206
|
+
// The runtime's version is the one it recorded when it started (`state.platformVersion`), as the Runtime page and
|
|
207
|
+
// `local status` say it: never the checkout's version now, which a pull can move while the runtime runs (run 8 said
|
|
208
|
+
// "the runtime is 1.52.33" beside a Runtime page saying 1.52.31).
|
|
209
|
+
const runtimeVersion = runtime.platformVersion
|
|
210
|
+
if (runtime.mode === 'source') refreshDocsFromCheckout(project, runtime.platformSource, changedSince)
|
|
211
|
+
const publication = loadDocs(docsCopyPath(project)) as (DocsPublication & { productVersion?: string; sourceCommit?: string; builtAt?: string }) | null
|
|
212
|
+
const marker = runtime.mode === 'source' ? readDocsBuildMarker(project) : null
|
|
213
|
+
const rebuilding = marker?.state === 'building' && processAlive(marker.pid)
|
|
214
|
+
const rebuildFailed = marker?.state === 'failed'
|
|
215
|
+
const restart = '`frontera local stop`, then `frontera local start`'
|
|
216
|
+
if (!publication) {
|
|
217
|
+
return {
|
|
218
|
+
present: false,
|
|
219
|
+
version: null,
|
|
220
|
+
runtimeVersion,
|
|
221
|
+
builtAt: null,
|
|
222
|
+
stale: false,
|
|
223
|
+
...(rebuilding ? { rebuilding: true } : {}),
|
|
224
|
+
note: rebuilding
|
|
225
|
+
? 'rebuilding — `frontera local start` is building it in the background; it is here when that is done'
|
|
226
|
+
: runtime.mode === 'source'
|
|
227
|
+
? `none: build it in the platform checkout (\`bun run docs:build\`), then ${restart}`
|
|
228
|
+
: 'none: this release’s service image carries no documentation',
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const version = typeof publication.productVersion === 'string' ? publication.productVersion : null
|
|
232
|
+
const builtAt = typeof publication.builtAt === 'string' ? publication.builtAt : null
|
|
233
|
+
const reasons: string[] = []
|
|
234
|
+
if (version && version !== runtimeVersion) reasons.push(`it documents ${version}, and the runtime is ${runtimeVersion}`)
|
|
235
|
+
if (runtime.mode === 'source' && runtime.platformSource && typeof publication.sourceCommit === 'string' && changedSince(runtime.platformSource, publication.sourceCommit, builtAt)) {
|
|
236
|
+
reasons.push(`the checkout’s guides changed since it was built (from ${publication.sourceCommit.slice(0, 9)})`)
|
|
237
|
+
}
|
|
238
|
+
const stale = reasons.length > 0
|
|
239
|
+
if (stale && rebuilding) {
|
|
240
|
+
return { present: true, version, runtimeVersion, builtAt, stale, rebuilding: true, note: `rebuilding — ${reasons.join('; ')}: \`frontera local start\` is building it in the background and refreshes the copy when it is done; the copy here serves until then` }
|
|
241
|
+
}
|
|
242
|
+
const fix =
|
|
243
|
+
runtime.mode === 'image'
|
|
244
|
+
? `: ${restart} copies this release’s documentation`
|
|
245
|
+
: rebuildFailed
|
|
246
|
+
? `: the rebuild at start failed (\`frontera local logs service\` shows why); run \`bun run docs:build\` in the platform checkout, then ${restart}`
|
|
247
|
+
: `: ${restart} rebuilds it in the background and refreshes the copy (see \`frontera local logs service\`), or run \`bun run docs:build\` in the platform checkout`
|
|
248
|
+
return { present: true, version, runtimeVersion, builtAt, stale, ...(stale && rebuildFailed ? { rebuildFailed: true } : {}), note: stale ? `out of date — ${reasons.join('; ')}${fix}` : null }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Why a platform checkout's customer publication is behind the checkout, or
|
|
253
|
+
* null when it is current (or that cannot be told): no build yet, guides
|
|
254
|
+
* changed since the commit it was built from, or another version.
|
|
255
|
+
*/
|
|
256
|
+
export function checkoutDocsStale(platformSource: string, changedSince: typeof guidesChangedSince = guidesChangedSince): string | null {
|
|
257
|
+
const bundle = join(platformSource, 'packages', 'docs-content', 'dist', 'customer', 'publication.json')
|
|
258
|
+
if (!existsSync(bundle)) return 'the checkout has no documentation build'
|
|
259
|
+
let publication: { productVersion?: unknown; sourceCommit?: unknown; builtAt?: unknown } | null = null
|
|
260
|
+
try {
|
|
261
|
+
publication = JSON.parse(readFileSync(bundle, 'utf8')) as { productVersion?: unknown; sourceCommit?: unknown; builtAt?: unknown }
|
|
262
|
+
} catch {
|
|
263
|
+
return 'the checkout’s documentation build could not be read'
|
|
264
|
+
}
|
|
265
|
+
const version = readVersion(join(platformSource, 'package.json'))
|
|
266
|
+
if (typeof publication?.productVersion === 'string' && version && publication.productVersion !== version) return `the checkout’s documentation build documents ${publication.productVersion}, and the checkout is ${version}`
|
|
267
|
+
if (typeof publication?.sourceCommit === 'string' && changedSince(platformSource, publication.sourceCommit, typeof publication.builtAt === 'string' ? publication.builtAt : null)) return `the checkout’s guides changed since its documentation was built (from ${publication.sourceCommit.slice(0, 9)})`
|
|
268
|
+
return null
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Marks a documentation build in a checkout, so two runtimes started from it don't build at once. */
|
|
272
|
+
const DOCS_BUILD_LOCK = ['packages', 'docs-content', 'dist', '.frontera-docs-build']
|
|
273
|
+
|
|
274
|
+
function lockHolder(path: string): number | null {
|
|
275
|
+
try {
|
|
276
|
+
const pid = Number(readFileSync(path, 'utf8').trim())
|
|
277
|
+
if (!Number.isInteger(pid) || pid <= 0) return null
|
|
278
|
+
process.kill(pid, 0)
|
|
279
|
+
return pid
|
|
280
|
+
} catch {
|
|
281
|
+
return null
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Build the checkout's documentation at low priority (`nice -n 10 bun run
|
|
287
|
+
* docs:build`), logging to `logFile`; resolves whether it built. Resolves
|
|
288
|
+
* false at once when another build of this checkout is running.
|
|
289
|
+
*/
|
|
290
|
+
export function buildDocs(platformSource: string, logFile: string): Promise<boolean> {
|
|
291
|
+
const lock = join(platformSource, ...DOCS_BUILD_LOCK)
|
|
292
|
+
if (lockHolder(lock)) {
|
|
293
|
+
appendFileSync(logFile, '[docs] another runtime is building this checkout’s documentation; the copy is refreshed at the next start\n')
|
|
294
|
+
return Promise.resolve(false)
|
|
295
|
+
}
|
|
296
|
+
mkdirSync(dirname(lock), { recursive: true })
|
|
297
|
+
return new Promise((done) => {
|
|
298
|
+
const fd = openSync(logFile, 'a')
|
|
299
|
+
const child = spawn('nice', ['-n', '10', 'bun', 'run', 'docs:build'], { cwd: platformSource, stdio: ['ignore', fd, fd] })
|
|
300
|
+
if (child.pid) writeFileSync(lock, String(child.pid))
|
|
301
|
+
const finish = (ok: boolean) => {
|
|
302
|
+
rmSync(lock, { force: true })
|
|
303
|
+
done(ok)
|
|
304
|
+
}
|
|
305
|
+
child.on('error', () => finish(false))
|
|
306
|
+
child.on('exit', (code) => finish(code === 0))
|
|
307
|
+
})
|
|
308
|
+
}
|
|
309
|
+
|
|
31
310
|
/** Where the service image carries its customer publication. */
|
|
32
311
|
export const IMAGE_DOCS_PATH = '/app/documentation/publication.json'
|
|
33
312
|
|