@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.
- package/package.json +4 -2
- package/src/api/apps-api.ts +13 -1
- package/src/api/automation-api.ts +129 -1
- package/src/api/blueprint-authoring-api.ts +574 -0
- package/src/api/dataset-api.ts +199 -0
- package/src/api/platform-api.ts +300 -0
- package/src/automation-template.ts +224 -0
- package/src/blueprint/compile.ts +371 -0
- package/src/blueprint/dataset-revision.ts +33 -0
- package/src/blueprint/diff.ts +223 -0
- package/src/blueprint/model.ts +227 -0
- package/src/blueprint/projection.ts +254 -0
- package/src/blueprint/render.ts +73 -0
- package/src/blueprint/scaffold.ts +79 -0
- package/src/blueprint/tree.ts +121 -0
- package/src/commands/agent/index-commands.ts +87 -1
- package/src/commands/app/deploy.ts +43 -3
- package/src/commands/app/init.ts +23 -1
- package/src/commands/app/pull.ts +12 -35
- package/src/commands/automation/index-commands.ts +42 -1
- package/src/commands/automation/init.ts +52 -0
- package/src/commands/automation/project-root.ts +58 -0
- package/src/commands/automation/pull.ts +124 -0
- package/src/commands/automation/run.ts +271 -0
- package/src/commands/blueprint/authoring.ts +410 -0
- package/src/commands/blueprint/bind.ts +228 -0
- package/src/commands/blueprint/declarative.ts +1052 -0
- package/src/commands/blueprint/grants.ts +164 -0
- package/src/commands/dataset/index-commands.ts +431 -0
- package/src/commands/knowledge/index-commands.ts +278 -27
- package/src/commands/knowledge/upload-batch.ts +146 -0
- package/src/commands/knowledge/upload-plan.ts +127 -0
- package/src/commands/login.ts +49 -11
- package/src/commands/pack/index-commands.ts +373 -0
- package/src/commands/registry.ts +19 -2
- package/src/commands/secret/index-commands.ts +195 -0
- package/src/commands/skill/bundle-commands.ts +327 -0
- package/src/commands/skill/index-commands.ts +36 -42
- package/src/commands/skill/resolve.ts +34 -0
- package/src/dev-env.ts +114 -0
- package/src/flag-help.ts +34 -0
- package/src/harness.ts +30 -3
- package/src/main.ts +10 -3
- package/src/render-evidence.ts +152 -0
- package/src/template.ts +4 -0
- package/src/untar.ts +44 -0
- package/src/vendor/sdk-sources.json +13 -11
- 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.**
|
|
76
|
-
|
|
77
|
-
|
|
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.
|
|
38
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|