@frontera-sdk/cli 0.1.0 → 1.43.6

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.
Files changed (48) hide show
  1. package/package.json +4 -2
  2. package/src/api/apps-api.ts +13 -1
  3. package/src/api/automation-api.ts +129 -1
  4. package/src/api/blueprint-authoring-api.ts +574 -0
  5. package/src/api/dataset-api.ts +199 -0
  6. package/src/api/platform-api.ts +300 -0
  7. package/src/automation-template.ts +224 -0
  8. package/src/blueprint/compile.ts +371 -0
  9. package/src/blueprint/dataset-revision.ts +33 -0
  10. package/src/blueprint/diff.ts +223 -0
  11. package/src/blueprint/model.ts +227 -0
  12. package/src/blueprint/projection.ts +254 -0
  13. package/src/blueprint/render.ts +73 -0
  14. package/src/blueprint/scaffold.ts +79 -0
  15. package/src/blueprint/tree.ts +121 -0
  16. package/src/commands/agent/index-commands.ts +87 -1
  17. package/src/commands/app/deploy.ts +43 -3
  18. package/src/commands/app/init.ts +23 -1
  19. package/src/commands/app/pull.ts +12 -35
  20. package/src/commands/automation/index-commands.ts +42 -1
  21. package/src/commands/automation/init.ts +52 -0
  22. package/src/commands/automation/project-root.ts +58 -0
  23. package/src/commands/automation/pull.ts +124 -0
  24. package/src/commands/automation/run.ts +271 -0
  25. package/src/commands/blueprint/authoring.ts +410 -0
  26. package/src/commands/blueprint/bind.ts +228 -0
  27. package/src/commands/blueprint/declarative.ts +1052 -0
  28. package/src/commands/blueprint/grants.ts +164 -0
  29. package/src/commands/dataset/index-commands.ts +431 -0
  30. package/src/commands/knowledge/index-commands.ts +278 -27
  31. package/src/commands/knowledge/upload-batch.ts +146 -0
  32. package/src/commands/knowledge/upload-plan.ts +127 -0
  33. package/src/commands/login.ts +49 -11
  34. package/src/commands/pack/index-commands.ts +373 -0
  35. package/src/commands/registry.ts +19 -2
  36. package/src/commands/secret/index-commands.ts +195 -0
  37. package/src/commands/skill/bundle-commands.ts +327 -0
  38. package/src/commands/skill/index-commands.ts +36 -42
  39. package/src/commands/skill/resolve.ts +34 -0
  40. package/src/dev-env.ts +114 -0
  41. package/src/flag-help.ts +34 -0
  42. package/src/harness.ts +30 -3
  43. package/src/main.ts +10 -3
  44. package/src/render-evidence.ts +152 -0
  45. package/src/template.ts +4 -0
  46. package/src/untar.ts +44 -0
  47. package/src/vendor/sdk-sources.json +13 -11
  48. package/src/commands/blueprint/reserved.ts +0 -40
package/src/dev-env.ts ADDED
@@ -0,0 +1,114 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { resolveCredential } from './config'
5
+
6
+ /**
7
+ * Give a project the credential the machine already holds.
8
+ *
9
+ * ## The failure this closes
10
+ *
11
+ * The dev host reads `VITE_FRONTERA_TOKEN` and warns, accurately, when it is
12
+ * missing:
13
+ *
14
+ * [dev-host] No VITE_FRONTERA_TOKEN — the app will mount but platform
15
+ * reads will 401.
16
+ *
17
+ * The credential is on the machine — `frontera login` stores it, and the
18
+ * Computer is provisioned with it before a turn starts. The dev host wants
19
+ * exactly it. Nothing joined the two for a project created by `app init`, so
20
+ * every app built from scratch rendered empty, and the agent looking at it
21
+ * reported the 401s as "normal, not a bug in the code" — which is true of the
22
+ * code and false of the situation.
23
+ *
24
+ * That is worth stating plainly, because it defeats the check that was added
25
+ * to catch it: an agent that renders its own dashboard and finds no numbers on
26
+ * it cannot see that one of the numbers is wrong.
27
+ *
28
+ * ## Why Vite needs its own file
29
+ *
30
+ * Vite exposes only `VITE_`-prefixed variables to client code, and reads them
31
+ * from the project's own `.env.local` — it does not inherit a shell profile,
32
+ * and the dev server is started by a non-login shell. So a project-local file
33
+ * is the mechanism, not a convenience.
34
+ *
35
+ * ## Why this never fails a command
36
+ *
37
+ * `app init` is `offline: true` and must stay so: scaffolding has to work
38
+ * before anyone has logged in. Everything here is best-effort — resolve, and
39
+ * on any failure write nothing and say nothing. A scaffold that refused
40
+ * because there was no credential would break the one case the offline flag
41
+ * exists for.
42
+ */
43
+
44
+ export const DEV_ENV_FILE = '.env.local'
45
+
46
+ export interface DevEnvResult {
47
+ /** False when there was no credential, or the file already existed. */
48
+ written: boolean
49
+ /** Why not, for a caller that wants to say something. */
50
+ reason?: 'no-credential' | 'exists' | 'failed'
51
+ }
52
+
53
+ interface DevEnvDeps {
54
+ env?: NodeJS.ProcessEnv
55
+ /** Seam: lets the no-credential path be exercised on a machine that has one. */
56
+ credential?: () => { apiUrl: string; token: string }
57
+ }
58
+
59
+ /**
60
+ * Write `<root>/.env.local` so the dev host can reach the platform.
61
+ *
62
+ * Never overwrites. An author who has pointed a project at a different origin,
63
+ * or pasted a longer-lived key, has said something more specific than we know —
64
+ * and silently replacing a credential file is the kind of help nobody asks for
65
+ * twice.
66
+ */
67
+ export function writeDevEnv(root: string, deps: DevEnvDeps = {}): DevEnvResult {
68
+ const path = join(root, DEV_ENV_FILE)
69
+ if (existsSync(path)) return { written: false, reason: 'exists' }
70
+
71
+ let credential: { apiUrl: string; token: string }
72
+ try {
73
+ credential = (deps.credential ?? (() => resolveCredential({}, { env: deps.env })))()
74
+ } catch {
75
+ // No origin, no token, no keychain — the ordinary state before a login.
76
+ return { written: false, reason: 'no-credential' }
77
+ }
78
+
79
+ const env = deps.env ?? process.env
80
+ // Only what the dev host actually reads. `workspaceId`/`orgId` are optional
81
+ // there and optional here: an `sk-ws-` key resolves to its own workspace
82
+ // server-side and cannot be widened by a header, so the token alone is
83
+ // enough to read real data. They are written when known because the dev host
84
+ // passes them into `frontera:init`, where an app may read them.
85
+ const lines = [
86
+ '# Written by the frontera CLI so the dev host can read real data.',
87
+ '# Not packaged, not committed — see .gitignore.',
88
+ `VITE_FRONTERA_API_URL=${credential.apiUrl}`,
89
+ `VITE_FRONTERA_TOKEN=${credential.token}`,
90
+ env.FRONTERA_WORKSPACE_ID ? `VITE_FRONTERA_WORKSPACE_ID=${env.FRONTERA_WORKSPACE_ID}` : '',
91
+ env.FRONTERA_ORG_ID ? `VITE_FRONTERA_ORG_ID=${env.FRONTERA_ORG_ID}` : '',
92
+ ].filter(Boolean)
93
+
94
+ try {
95
+ writeFileSync(path, `${lines.join('\n')}\n`, { mode: 0o600 })
96
+ } catch {
97
+ return { written: false, reason: 'failed' }
98
+ }
99
+ return { written: true }
100
+ }
101
+
102
+ /**
103
+ * Is this project's dev host able to read real data?
104
+ *
105
+ * Read rather than assumed, so a caller can say the true thing about a
106
+ * project whose `.env.local` predates this or was written by hand.
107
+ */
108
+ export function devEnvHasToken(root: string): boolean {
109
+ try {
110
+ return /^VITE_FRONTERA_TOKEN=.+$/m.test(readFileSync(join(root, DEV_ENV_FILE), 'utf8'))
111
+ } catch {
112
+ return false
113
+ }
114
+ }
package/src/flag-help.ts CHANGED
@@ -13,6 +13,17 @@
13
13
  * fails if a command declares a flag this file does not describe.
14
14
  */
15
15
  export const FLAG_HELP: Readonly<Record<string, string>> = {
16
+ // Declarative Blueprint authoring — the file tree, and what applying it may do.
17
+ prune: 'also remove artifacts that are on the draft but absent from the file tree',
18
+ dataset: 'dataset name the object type reads through; the revision is resolved at apply time',
19
+ plan: 'write a mapping skeleton to this path instead of binding',
20
+ 'accept-contract-change': 'confirm a rebind whose column contract differs from the pinned one',
21
+ revoke: 'remove the grant instead of adding it',
22
+ // Blueprint authoring (organization API key). `notes` is described further down
23
+ // — this map is keyed by flag name across every command, so one entry serves both.
24
+ label: 'release label recorded on the published or rolled-back release',
25
+ report: 'validation report id to publish against; defaults to a fresh validation',
26
+ instruction: 'migration instruction applied to every change a rollback discards',
16
27
  // Global
17
28
  json: 'machine-readable output on stdout; stderr still carries commentary',
18
29
  quiet: 'suppress progress commentary on stderr',
@@ -33,8 +44,24 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
33
44
  // Apps
34
45
  version: 'version to publish, overriding the one in package.json',
35
46
  'no-promote': 'publish the version without moving the live pointer',
47
+ 'no-wait': 'return as soon as the run is queued instead of waiting for it to finish',
36
48
  draft: 'act on your saved draft rather than a published version',
37
49
 
50
+ // Agents
51
+ name: 'display name; defaults to the slug',
52
+ kind: 'agent kind: conversation (default) or work',
53
+
54
+ // Packs
55
+ 'remove-apps': 'also uninstall the apps the pack installed',
56
+
57
+ // Secrets
58
+ from: 'where to read the value from: `-` for stdin, or a file path. Never an inline value',
59
+
60
+ // Knowledge
61
+ description: 'one-line description stored on the resource',
62
+ strategy:
63
+ 'text extraction to use per file: auto (default), text, or ocr; ocr needs an OCR engine on the base',
64
+
38
65
  // Auth and setup
39
66
  'token-stdin': 'read the workspace key from stdin instead of prompting',
40
67
  'no-input': 'never prompt; fail instead, for use in scripts and CI',
@@ -48,11 +75,18 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
48
75
  */
49
76
  const PLACEHOLDER: Readonly<Record<string, string>> = {
50
77
  'api-url': 'origin',
78
+ dataset: 'name',
79
+ plan: 'path',
51
80
  dir: 'path',
52
81
  file: 'path',
53
82
  version: 'semver',
54
83
  'expect-revision': 'hash',
55
84
  notes: 'text',
85
+ description: 'text',
86
+ strategy: 'auto|text|ocr',
87
+ from: '-|path',
88
+ name: 'text',
89
+ kind: 'conversation|work',
56
90
  }
57
91
 
58
92
  /**
package/src/harness.ts CHANGED
@@ -72,13 +72,40 @@ Errors carry a \`hint\` naming the next command. Read it.
72
72
  - **On exit 3, re-fetch — never force.** The document you hold is stale. Get it
73
73
  again, reapply your edit on top, then send it. Overwriting discards whatever
74
74
  the other writer did.
75
- - **Secrets never go in a flag.** Use \`--secret name@-\` (stdin), \`name@./file\`,
76
- or \`name=ref:<workspace-secret>\`. A value on the command line lands in
77
- process listings and shell history.
75
+ - **Secrets never go in a flag.** \`frontera secret set NAME --from -\` reads the
76
+ value from stdin, \`--from ./file\` from a file. A value passed inline lands in
77
+ shell history and in the process list, so the inline form is refused. An
78
+ automation then NAMES the secret — \`auth: { secret: 'NAME' }\` plus a
79
+ \`secret:NAME\` grant — and its value never enters the automation's process.
78
80
  - **\`.env\` is never packaged**, and that is not overridable.
79
81
  - **A pull refuses to clobber.** If it reports dirty files, deal with them —
80
82
  inside a sandbox there is usually no git to recover from.
81
83
 
84
+ ## Seeding a knowledge base
85
+
86
+ \`\`\`bash
87
+ frontera knowledge create <name> --description "<what is in it>"
88
+ frontera knowledge upload <name> ./corpus # a directory is walked
89
+ frontera knowledge attach <name> <agent> # nothing can read it until this
90
+ frontera knowledge sources <name> # status per file, minutes later
91
+ \`\`\`
92
+
93
+ Three things about this are not visible from \`--help\`:
94
+
95
+ - **Upload queues ingestion; it does not finish it.** A file comes back
96
+ \`processing\` with 0 chunks and turns \`ready\` minutes later. Retrieval tested
97
+ before then returns nothing, which is not a failed upload.
98
+ - **A partial batch still exits 0.** One unreadable file does not end the run, so
99
+ read \`failed\` in the payload rather than trusting the exit code alone. Exit is
100
+ non-zero only when nothing at all landed. Re-running is safe — upload is
101
+ additive.
102
+ - **Attachment is the only route in.** An automation has no knowledge capability
103
+ of its own; it reaches a corpus only through \`ctx.agent(<slug>)\` where that
104
+ agent has the base attached. A base nobody is attached to is unreachable.
105
+
106
+ Naming a directory is the normal case: unsupported types inside it are skipped
107
+ and counted. Naming an unsupported file directly is an error and uploads nothing.
108
+
82
109
  ## Building an app
83
110
 
84
111
  \`\`\`bash
package/src/main.ts CHANGED
@@ -34,14 +34,21 @@ import type { AppProject } from './context'
34
34
  * A released binary is stamped at build time with the tag it shipped from
35
35
  * (`scripts/build-release.ts`), because that is the number a caller can
36
36
  * actually act on: it names the platform release this client was built
37
- * against, so a bug report says which server contract it expects. Running from
38
- * source there is no tag, and the package version is the honest answer.
37
+ * against, so a bug report says which server contract it expects.
38
+ *
39
+ * Unstamped, the manifest version is the answer, and whether it is a release
40
+ * depends on where this file is running from. Installed from npm it is exact:
41
+ * the release commit bumps this package with the tag, so the manifest version
42
+ * IS the release. In a checkout it is the LAST release, with unreleased commits
43
+ * on top — so it gets `-dev`, which is what stops a bug report from naming a
44
+ * version whose code the reporter is not running.
39
45
  */
40
46
  async function resolveVersion(): Promise<string> {
41
47
  const stamped = process.env.FRONTERA_CLI_VERSION
42
48
  if (stamped) return stamped
43
49
  const pkg = await import('../package.json', { with: { type: 'json' } })
44
- return `${(pkg.default as { version: string }).version}-dev`
50
+ const version = (pkg.default as { version: string }).version
51
+ return import.meta.dir.includes('/node_modules/') ? version : `${version}-dev`
45
52
  }
46
53
 
47
54
  async function main(): Promise<number> {
@@ -0,0 +1,152 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
2
+ import { join, relative, sep } from 'node:path'
3
+
4
+ import { isExcluded } from './packaging'
5
+
6
+ /**
7
+ * Has anybody looked at this app before it goes live?
8
+ *
9
+ * ## Why deploy asks
10
+ *
11
+ * `bun run build` was the only gate between authoring and live. It says the
12
+ * TypeScript compiles. It does not say the COD Share tile reads 551,7% because
13
+ * the numerator forgot the filter its denominator applies, or that the status
14
+ * pie renders nothing because the API returns counts as JSON strings and
15
+ * Recharts cannot do angle math on strings. Both shipped, to a live URL, with
16
+ * "the dashboard is ready".
17
+ *
18
+ * Both were one screenshot away. So the deploy path asks the one question a
19
+ * compiler cannot: since you last changed the source, did you look at it?
20
+ *
21
+ * ## Why it does not refuse
22
+ *
23
+ * A tree can legitimately deploy unrendered — a copy edit, a promotion from
24
+ * CI, a rebuild of something already reviewed. A gate that fires on legitimate
25
+ * cases is one people learn to route around, and the routing-around outlives
26
+ * the gate. This records, reports, and stores the answer on the version. That
27
+ * is the whole intervention.
28
+ *
29
+ * ## Why mtime
30
+ *
31
+ * The question is literally "was this rendered SINCE the last source change",
32
+ * and file mtimes are that question written down. A content hash would be
33
+ * stabler against a `touch`, and would also call an unchanged reformat a new
34
+ * tree; neither error matters here, because nothing is being enforced. The
35
+ * cheap answer to a reported question beats the expensive answer to a blocked
36
+ * one.
37
+ */
38
+
39
+ /** Written by `render_app`; see RENDER_EVIDENCE_RELATIVE_PATH in the service. */
40
+ export const RENDER_EVIDENCE_PATH = '.frontera/last-render.json'
41
+
42
+ export interface RenderEvidence {
43
+ /** True only when a render happened AFTER the newest source change. */
44
+ rendered: boolean
45
+ /**
46
+ * `fresh` — rendered, and nothing has changed since.
47
+ * `stale` — rendered, but the source moved on afterwards.
48
+ * `none` — this tree has never been rendered.
49
+ */
50
+ reason: 'fresh' | 'stale' | 'none'
51
+ renderedAt?: string
52
+ /** Which page was opened — "/" proves less than "/dev-host.html" for an App. */
53
+ path?: string
54
+ /** What the browser console said at render time, if the renderer recorded it. */
55
+ consoleErrors?: number
56
+ }
57
+
58
+ interface RenderRecord {
59
+ renderedAt?: string
60
+ renderedAtMs?: number
61
+ path?: string
62
+ consoleErrors?: number
63
+ }
64
+
65
+ /**
66
+ * Newest mtime across the files that would be packaged.
67
+ *
68
+ * Same exclusion set as packaging, so `node_modules` churn and build output
69
+ * cannot make a tree look edited — the ONE failure mode that would make this
70
+ * report worthless, because it would report `stale` every single time and
71
+ * everyone would stop reading it.
72
+ */
73
+ export function newestSourceChangeMs(root: string): number {
74
+ let newest = 0
75
+ const walk = (dir: string) => {
76
+ let entries: string[]
77
+ try {
78
+ entries = readdirSync(dir)
79
+ } catch {
80
+ return
81
+ }
82
+ for (const entry of entries) {
83
+ const full = join(dir, entry)
84
+ const rel = relative(root, full).split(sep).join('/')
85
+ if (isExcluded(rel)) continue
86
+ // The evidence file lives inside the project and is written by the
87
+ // render itself. Counting it would make every render immediately stale.
88
+ if (rel === RENDER_EVIDENCE_PATH || rel === '.frontera') continue
89
+ let stat: ReturnType<typeof statSync>
90
+ try {
91
+ stat = statSync(full)
92
+ } catch {
93
+ continue
94
+ }
95
+ if (stat.isDirectory()) {
96
+ walk(full)
97
+ continue
98
+ }
99
+ newest = Math.max(newest, stat.mtimeMs)
100
+ }
101
+ }
102
+ walk(root)
103
+ return newest
104
+ }
105
+
106
+ export function readRenderEvidence(root: string): RenderEvidence {
107
+ const path = join(root, RENDER_EVIDENCE_PATH)
108
+ if (!existsSync(path)) return { rendered: false, reason: 'none' }
109
+
110
+ let record: RenderRecord
111
+ try {
112
+ record = JSON.parse(readFileSync(path, 'utf8')) as RenderRecord
113
+ } catch {
114
+ // Unreadable is indistinguishable from absent, and claiming a render on
115
+ // the strength of a corrupt file is the one answer that would be worse
116
+ // than no answer.
117
+ return { rendered: false, reason: 'none' }
118
+ }
119
+
120
+ const renderedAtMs = record.renderedAtMs ?? (record.renderedAt ? Date.parse(record.renderedAt) : NaN)
121
+ if (!Number.isFinite(renderedAtMs)) return { rendered: false, reason: 'none' }
122
+
123
+ const rendered = renderedAtMs >= newestSourceChangeMs(root)
124
+ return {
125
+ rendered,
126
+ reason: rendered ? 'fresh' : 'stale',
127
+ ...(record.renderedAt ? { renderedAt: record.renderedAt } : {}),
128
+ ...(record.path ? { path: record.path } : {}),
129
+ ...(typeof record.consoleErrors === 'number' ? { consoleErrors: record.consoleErrors } : {}),
130
+ }
131
+ }
132
+
133
+ /**
134
+ * The line deploy prints, and the one the agent reads.
135
+ *
136
+ * Written as a statement of fact rather than a warning. "You should have
137
+ * rendered this" invites an argument; "deploying a tree that has never been
138
+ * rendered" is simply what is happening, and a reader who is fine with that
139
+ * moves on.
140
+ */
141
+ export function describeRenderEvidence(evidence: RenderEvidence): string {
142
+ switch (evidence.reason) {
143
+ case 'fresh':
144
+ return ` rendered ${evidence.path ?? '/'}${
145
+ evidence.consoleErrors ? ` (${evidence.consoleErrors} console error${evidence.consoleErrors === 1 ? '' : 's'} at render time)` : ''
146
+ }`
147
+ case 'stale':
148
+ return ' deploying source that has changed since it was last rendered — nobody has seen this version'
149
+ default:
150
+ return ' deploying a tree that has never been rendered — a build that compiles is the only check this version has had'
151
+ }
152
+ }
package/src/template.ts CHANGED
@@ -298,6 +298,10 @@ export default function App() {
298
298
  dist/
299
299
  .frontera/
300
300
  *.log
301
+ # Holds a workspace key with write scope — written by the CLI so the dev host
302
+ # can read real data. Packaging already refuses it; this stops a commit.
303
+ .env.local
304
+ .env*.local
301
305
  `,
302
306
 
303
307
  '.agents/skills/using-frontera-sdk/SKILL.md': `---
package/src/untar.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { gunzipSync } from 'node:zlib'
4
+
5
+ /**
6
+ * Unpack a gzipped ustar archive onto disk, returning the file count.
7
+ *
8
+ * Shared by `app pull` and `automation pull`. The `..` refusal below is why this
9
+ * is one function rather than a copy in each: an archive is remote input, and a
10
+ * traversal member that is honoured writes anywhere the process can reach.
11
+ *
12
+ * Directory members (typeflag '5') are skipped — `mkdirSync(recursive)` on each
13
+ * file's parent creates everything needed, and honouring them adds a second path
14
+ * to validate for no benefit.
15
+ */
16
+ export function unpackTo(dir: string, gz: Uint8Array): number {
17
+ const buf = new Uint8Array(gunzipSync(Buffer.from(gz)))
18
+ const dec = new TextDecoder()
19
+ const readStr = (start: number, len: number) => {
20
+ let end = start
21
+ while (end < start + len && buf[end] !== 0) end++
22
+ return dec.decode(buf.subarray(start, end))
23
+ }
24
+ let off = 0
25
+ let count = 0
26
+ while (off + 512 <= buf.length) {
27
+ const name = readStr(off, 100)
28
+ if (name === '') break
29
+ const prefix = readStr(off + 345, 155)
30
+ const size = parseInt(readStr(off + 124, 12).trim() || '0', 8)
31
+ const dataStart = off + 512
32
+ if (buf[off + 156] === 0x30 || buf[off + 156] === 0) {
33
+ const rel = (prefix ? `${prefix}/${name}` : name).replace(/^\.?\//, '')
34
+ if (rel && !rel.split('/').includes('..')) {
35
+ const full = join(dir, rel)
36
+ mkdirSync(dirname(full), { recursive: true })
37
+ writeFileSync(full, buf.subarray(dataStart, dataStart + size))
38
+ count++
39
+ }
40
+ }
41
+ off = dataStart + Math.ceil(size / 512) * 512
42
+ }
43
+ return count
44
+ }