@frontera-sdk/cli 1.44.1 → 1.45.0
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 +65 -1
- package/package.json +4 -3
- package/src/api/automation-api.ts +15 -0
- package/src/api/dataset-api.ts +99 -0
- package/src/api/governed-action-api.ts +80 -0
- package/src/api/platform-api.ts +293 -0
- package/src/auth-verify.ts +105 -0
- package/src/binding-registry.ts +87 -0
- package/src/commands/action/deploy.ts +1 -0
- package/src/commands/action/grant.ts +1 -0
- package/src/commands/action/index-commands.ts +8 -0
- package/src/commands/action/prepare.ts +1 -0
- package/src/commands/action/requests.ts +111 -0
- package/src/commands/action/review.ts +1 -0
- package/src/commands/agent/index-commands.ts +189 -7
- package/src/commands/app/init.ts +1 -1
- package/src/commands/app/pull.ts +1 -1
- package/src/commands/auth/add.ts +145 -0
- package/src/commands/auth/current.ts +82 -0
- package/src/commands/auth/index-commands.ts +16 -0
- package/src/commands/auth/list.ts +71 -0
- package/src/commands/auth/remove.ts +80 -0
- package/src/commands/auth/use.ts +84 -0
- package/src/commands/auth/verify.ts +93 -0
- package/src/commands/automation/run.ts +41 -2
- package/src/commands/blueprint/query.ts +294 -0
- package/src/commands/capability/index-commands.ts +334 -0
- package/src/commands/dataset/index-commands.ts +103 -14
- package/src/commands/kit/doctor.ts +101 -0
- package/src/commands/kit/index-commands.ts +7 -0
- package/src/commands/kit/shared.ts +52 -0
- package/src/commands/kit/status.ts +92 -0
- package/src/commands/kit/sync.ts +106 -0
- package/src/commands/kit/vendor.ts +120 -0
- package/src/commands/knowledge/index-commands.ts +165 -0
- package/src/commands/login.ts +64 -84
- package/src/commands/plugin/index-commands.ts +284 -21
- package/src/commands/registry.ts +104 -1
- package/src/commands/setup.ts +248 -0
- package/src/commands/source/index-commands.ts +446 -0
- package/src/commands/types.ts +14 -0
- package/src/config.ts +197 -100
- package/src/credential-store.ts +273 -0
- package/src/dev-env.ts +3 -3
- package/src/exit.ts +29 -2
- package/src/flag-help.ts +65 -3
- package/src/fs-atomic.ts +44 -0
- package/src/harness.ts +155 -4
- package/src/kit.ts +419 -0
- package/src/main.ts +13 -1
- package/src/paths.ts +43 -0
- package/src/profile-migration.ts +101 -0
- package/src/profiles.ts +240 -0
- package/src/project-context.ts +178 -0
- package/src/prompt.ts +23 -0
- package/src/templates/next-app-files.ts +4 -1
- package/src/vendor/kit-assets.json +31 -0
- package/src/vendor/sdk-sources.json +1 -1
package/src/profiles.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
import { configPath, type Env } from './paths'
|
|
5
|
+
import { CliError, UsageError } from './errors'
|
|
6
|
+
import { writeFileAtomic } from './fs-atomic'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Named credential profiles.
|
|
10
|
+
*
|
|
11
|
+
* One machine, many customers. The previous shape stored one token per API
|
|
12
|
+
* origin, which made two keys for the SAME deployment impossible to hold at
|
|
13
|
+
* once — the second login overwrote the first, and an FDE moving between two
|
|
14
|
+
* workspaces on one Frontera install had no way to keep both. A profile is the
|
|
15
|
+
* name that separates them.
|
|
16
|
+
*
|
|
17
|
+
* This file owns METADATA only. Key bytes live in a credential store
|
|
18
|
+
* (`credential-store.ts`), addressed by profile name, so a profile file can be
|
|
19
|
+
* read, diffed and printed without ever holding a secret.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type CredentialKind = 'workspace' | 'organization'
|
|
23
|
+
|
|
24
|
+
export interface ProfileMetadata {
|
|
25
|
+
apiUrl: string
|
|
26
|
+
credentialKind: CredentialKind
|
|
27
|
+
/**
|
|
28
|
+
* Nullable because organization-key verification answers neither field —
|
|
29
|
+
* `whoami` has no workspace to report for a key that belongs to none. Absent
|
|
30
|
+
* scope never makes an otherwise verified profile unusable; it is
|
|
31
|
+
* descriptive, and the service re-derives real scope on every request.
|
|
32
|
+
*/
|
|
33
|
+
workspaceId: string | null
|
|
34
|
+
orgId: string | null
|
|
35
|
+
/** Short SHA-256 of the key, for recognition and diagnostics. Not the key. */
|
|
36
|
+
fingerprint: string
|
|
37
|
+
createdAt: string
|
|
38
|
+
lastVerifiedAt: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ProfileConfig {
|
|
42
|
+
schemaVersion: 2
|
|
43
|
+
profiles: Record<string, ProfileMetadata>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The shape written before profiles existed. Read for migration, never written. */
|
|
47
|
+
export interface LegacyConfig {
|
|
48
|
+
defaultOrigin?: string
|
|
49
|
+
origins?: Record<string, { token?: string }>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Reserved so a future resolver can add them without breaking a machine that
|
|
56
|
+
* already has a profile by that name. `none` and `auto` are the two words a
|
|
57
|
+
* `--profile` flag would plausibly grow a meaning for; a path-like value is
|
|
58
|
+
* reserved because `--profile ./x` should never become a file reference by
|
|
59
|
+
* accident.
|
|
60
|
+
*/
|
|
61
|
+
const RESERVED_NAMES = new Set(['none', 'auto'])
|
|
62
|
+
|
|
63
|
+
export function assertProfileName(name: string): void {
|
|
64
|
+
if (RESERVED_NAMES.has(name.toLowerCase())) {
|
|
65
|
+
throw new CliError(`"${name}" is a reserved profile name`, {
|
|
66
|
+
code: 'PROFILE_INVALID',
|
|
67
|
+
hint: 'pick another name — `none` and `auto` are reserved for future selectors',
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
if (name.includes('/') || name.includes('\\')) {
|
|
71
|
+
throw new CliError(`"${name}" looks like a path, not a profile name`, {
|
|
72
|
+
code: 'PROFILE_INVALID',
|
|
73
|
+
hint: 'profile names are plain identifiers, e.g. acme-prod',
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
if (!PROFILE_NAME_PATTERN.test(name)) {
|
|
77
|
+
throw new CliError(`"${name}" is not a valid profile name`, {
|
|
78
|
+
code: 'PROFILE_INVALID',
|
|
79
|
+
hint: 'use letters, digits, dot, dash or underscore, starting with a letter or digit (max 64)',
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A recognisable stand-in for the key.
|
|
86
|
+
*
|
|
87
|
+
* Twelve hex characters is enough to tell two keys apart by eye when
|
|
88
|
+
* `auth list` prints them side by side, and far too little to attack the key
|
|
89
|
+
* it came from. It exists so a person can answer "is this the key I rotated?"
|
|
90
|
+
* without the CLI ever printing one.
|
|
91
|
+
*/
|
|
92
|
+
export function fingerprint(token: string): string {
|
|
93
|
+
return `sha256:${createHash('sha256').update(token).digest('hex').slice(0, 12)}`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readRaw(env: Env): unknown {
|
|
97
|
+
const path = configPath(env)
|
|
98
|
+
if (!existsSync(path)) return null
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(readFileSync(path, 'utf8')) as unknown
|
|
101
|
+
} catch {
|
|
102
|
+
// Deliberately NOT silent. The old reader treated a corrupt config as
|
|
103
|
+
// absent, which turned "your profiles are unreadable" into "you have no
|
|
104
|
+
// credential" — a true statement about a completely different problem.
|
|
105
|
+
throw new CliError(`the Frontera configuration at ${path} is not readable JSON`, {
|
|
106
|
+
code: 'PROFILE_INVALID',
|
|
107
|
+
hint: `inspect ${path}, or move it aside and re-add your profiles with \`frontera auth add\``,
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function isLegacyConfig(raw: unknown): raw is LegacyConfig {
|
|
113
|
+
if (!raw || typeof raw !== 'object') return false
|
|
114
|
+
const rec = raw as Record<string, unknown>
|
|
115
|
+
if (rec.schemaVersion === 2) return false
|
|
116
|
+
return 'origins' in rec || 'defaultOrigin' in rec
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Profiles as stored, with no migration and no derivation.
|
|
121
|
+
*
|
|
122
|
+
* A caller that needs legacy credentials to be visible calls
|
|
123
|
+
* `migrateLegacyConfig` first — separated so that reading is a pure operation
|
|
124
|
+
* and the one place that rewrites a user's credentials is explicit.
|
|
125
|
+
*/
|
|
126
|
+
export function readProfileConfig(env: Env = process.env): ProfileConfig {
|
|
127
|
+
const raw = readRaw(env)
|
|
128
|
+
if (!raw || typeof raw !== 'object') return { schemaVersion: 2, profiles: {} }
|
|
129
|
+
const rec = raw as Record<string, unknown>
|
|
130
|
+
if (rec.schemaVersion !== 2) return { schemaVersion: 2, profiles: {} }
|
|
131
|
+
const profiles = (rec.profiles ?? {}) as Record<string, ProfileMetadata>
|
|
132
|
+
return { schemaVersion: 2, profiles }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function readLegacyConfig(env: Env = process.env): LegacyConfig | null {
|
|
136
|
+
const raw = readRaw(env)
|
|
137
|
+
return isLegacyConfig(raw) ? raw : null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function writeProfileConfig(config: ProfileConfig, env: Env = process.env): void {
|
|
141
|
+
writeFileAtomic(configPath(env), `${JSON.stringify(config, null, 2)}\n`, 0o600)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function listProfiles(env: Env = process.env): Array<{ name: string; profile: ProfileMetadata }> {
|
|
145
|
+
const { profiles } = readProfileConfig(env)
|
|
146
|
+
return Object.entries(profiles)
|
|
147
|
+
.map(([name, profile]) => ({ name, profile }))
|
|
148
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function getProfile(name: string, env: Env = process.env): ProfileMetadata | null {
|
|
152
|
+
return readProfileConfig(env).profiles[name] ?? null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function requireProfile(name: string, env: Env = process.env): ProfileMetadata {
|
|
156
|
+
const profile = getProfile(name, env)
|
|
157
|
+
if (!profile) {
|
|
158
|
+
const known = listProfiles(env).map((p) => p.name)
|
|
159
|
+
throw new CliError(`no profile named "${name}"`, {
|
|
160
|
+
code: 'PROFILE_NOT_FOUND',
|
|
161
|
+
hint: known.length
|
|
162
|
+
? `known profiles: ${known.join(', ')} — or add one with \`frontera auth add ${name} --api-url <origin>\``
|
|
163
|
+
: `add one with \`frontera auth add ${name} --api-url <origin>\``,
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
return profile
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function putProfile(name: string, profile: ProfileMetadata, env: Env = process.env): void {
|
|
170
|
+
const config = readProfileConfig(env)
|
|
171
|
+
config.profiles[name] = profile
|
|
172
|
+
writeProfileConfig(config, env)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function deleteProfile(name: string, env: Env = process.env): void {
|
|
176
|
+
const config = readProfileConfig(env)
|
|
177
|
+
delete config.profiles[name]
|
|
178
|
+
writeProfileConfig(config, env)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* A key is only meaningful at the deployment that issued it, so an explicit
|
|
183
|
+
* origin that disagrees with the profile's is a mistake worth catching BEFORE
|
|
184
|
+
* a request — sending a customer's key to another customer's deployment is not
|
|
185
|
+
* something to discover from a 401.
|
|
186
|
+
*/
|
|
187
|
+
export function assertOriginMatches(name: string, stored: string, supplied: string): void {
|
|
188
|
+
if (normalizeOrigin(stored) === normalizeOrigin(supplied)) return
|
|
189
|
+
throw new CliError(
|
|
190
|
+
`profile "${name}" belongs to ${stored}, not ${supplied}`,
|
|
191
|
+
{
|
|
192
|
+
code: 'PROFILE_ORIGIN_MISMATCH',
|
|
193
|
+
hint: `drop --api-url to use ${stored}, or add a separate profile for ${supplied}`,
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Trailing slashes and case in the host are not a different deployment. */
|
|
199
|
+
export function normalizeOrigin(origin: string): string {
|
|
200
|
+
try {
|
|
201
|
+
const url = new URL(origin)
|
|
202
|
+
return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, '')}`
|
|
203
|
+
} catch {
|
|
204
|
+
return origin.replace(/\/+$/, '')
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function assertApiUrl(origin: string): void {
|
|
209
|
+
try {
|
|
210
|
+
const url = new URL(origin)
|
|
211
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('scheme')
|
|
212
|
+
} catch {
|
|
213
|
+
throw new UsageError(
|
|
214
|
+
`"${origin}" is not an API origin`,
|
|
215
|
+
'pass a full origin, e.g. --api-url https://api.frontera.example',
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* A stable, readable profile name for a legacy origin.
|
|
222
|
+
*
|
|
223
|
+
* Derived from the host so a migrated machine gets `api-frontera-example`
|
|
224
|
+
* rather than `origin-2`, which nobody could match back to a customer.
|
|
225
|
+
*/
|
|
226
|
+
export function nameFromOrigin(origin: string, taken: Set<string>): string {
|
|
227
|
+
let base: string
|
|
228
|
+
try {
|
|
229
|
+
base = new URL(origin).host.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
|
230
|
+
} catch {
|
|
231
|
+
base = origin.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
|
232
|
+
}
|
|
233
|
+
if (!base || !PROFILE_NAME_PATTERN.test(base)) base = 'imported'
|
|
234
|
+
base = base.slice(0, 56)
|
|
235
|
+
if (!taken.has(base)) return base
|
|
236
|
+
for (let n = 2; ; n += 1) {
|
|
237
|
+
const candidate = `${base}-${n}`
|
|
238
|
+
if (!taken.has(candidate)) return candidate
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { isTrustedBinding, recordBinding } from './binding-registry'
|
|
5
|
+
import { findProjectRoot } from './context'
|
|
6
|
+
import { CliError } from './errors'
|
|
7
|
+
import { writeFileAtomic } from './fs-atomic'
|
|
8
|
+
import type { Env } from './paths'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The working directory selects the profile.
|
|
12
|
+
*
|
|
13
|
+
* The rejected alternative was a machine-wide "current customer" that
|
|
14
|
+
* `frontera auth use` mutated. It is one line to implement and it silently
|
|
15
|
+
* misroutes the next command run in a different repository — which, for an FDE
|
|
16
|
+
* holding two customers open in two terminals, is the exact failure the whole
|
|
17
|
+
* design exists to prevent. Binding lives in the directory instead, and the
|
|
18
|
+
* NEAREST binding wins, so a nested project may safely override its parent.
|
|
19
|
+
*
|
|
20
|
+
* The file holds a profile NAME. Never a token, never an origin — the origin
|
|
21
|
+
* belongs to the profile, so a repository cannot redirect a key to another
|
|
22
|
+
* deployment by editing a checked-out file.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const CONTEXT_DIR = '.frontera'
|
|
26
|
+
export const CONTEXT_FILE = 'context.json'
|
|
27
|
+
|
|
28
|
+
export interface ProjectContext {
|
|
29
|
+
schemaVersion: 1
|
|
30
|
+
profile: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface FoundContext {
|
|
34
|
+
profile: string
|
|
35
|
+
/** Absolute path of the `context.json` that supplied it. */
|
|
36
|
+
path: string
|
|
37
|
+
/** The directory that owns it — the binding root. */
|
|
38
|
+
root: string
|
|
39
|
+
/**
|
|
40
|
+
* Did THIS machine bind this root to this profile?
|
|
41
|
+
*
|
|
42
|
+
* A context file can be committed to a repository, and the upward walk cannot
|
|
43
|
+
* tell who wrote it. Reported rather than enforced here, so listings can show
|
|
44
|
+
* a binding while the resolver refuses to act on one nobody here made.
|
|
45
|
+
*/
|
|
46
|
+
trusted: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function contextPath(root: string): string {
|
|
50
|
+
return join(root, CONTEXT_DIR, CONTEXT_FILE)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readContextAt(root: string, env: Env): FoundContext | null {
|
|
54
|
+
const path = contextPath(root)
|
|
55
|
+
if (!existsSync(path)) return null
|
|
56
|
+
|
|
57
|
+
let parsed: unknown
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown
|
|
60
|
+
} catch {
|
|
61
|
+
throw corrupt(path, 'it is not readable JSON')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const profile = (parsed as { profile?: unknown } | null)?.profile
|
|
65
|
+
if (typeof profile !== 'string' || profile.length === 0) {
|
|
66
|
+
throw corrupt(path, 'it names no profile')
|
|
67
|
+
}
|
|
68
|
+
return { profile, path, root, trusted: isTrustedBinding(root, profile, env) }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Corrupt context is an ERROR, not a miss.
|
|
73
|
+
*
|
|
74
|
+
* Falling through to the parent directory would silently run a customer's
|
|
75
|
+
* command against a different customer's profile — the one outcome this design
|
|
76
|
+
* treats as unacceptable. Better to stop and name the file.
|
|
77
|
+
*/
|
|
78
|
+
function corrupt(path: string, why: string): CliError {
|
|
79
|
+
return new CliError(`the profile context at ${path} is unusable — ${why}`, {
|
|
80
|
+
code: 'PROJECT_CONTEXT_CORRUPT',
|
|
81
|
+
hint: `delete ${path} and run \`frontera auth use <profile>\` again`,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Nearest wins: walk up from `from` to the filesystem root. */
|
|
86
|
+
export function findContext(from: string, env: Env = process.env): FoundContext | null {
|
|
87
|
+
let dir = resolve(from)
|
|
88
|
+
for (;;) {
|
|
89
|
+
const found = readContextAt(dir, env)
|
|
90
|
+
if (found) return found
|
|
91
|
+
const parent = dirname(dir)
|
|
92
|
+
if (parent === dir) return null
|
|
93
|
+
dir = parent
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function findGitRoot(from: string): string | null {
|
|
98
|
+
let dir = resolve(from)
|
|
99
|
+
for (;;) {
|
|
100
|
+
// A file, not only a directory: a worktree and a submodule both write
|
|
101
|
+
// `.git` as a file pointing elsewhere, and both are real repository roots.
|
|
102
|
+
if (existsSync(join(dir, '.git'))) return dir
|
|
103
|
+
const parent = dirname(dir)
|
|
104
|
+
if (parent === dir) return null
|
|
105
|
+
dir = parent
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type BindingRootSource = 'flag' | 'project' | 'git' | 'cwd'
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Where `auth use` writes.
|
|
113
|
+
*
|
|
114
|
+
* `--dir` binds that exact directory and searches no further, matching how
|
|
115
|
+
* `--dir` already behaves for App projects. Otherwise the nearest Frontera
|
|
116
|
+
* project root, then the Git root, then the working directory — so the command
|
|
117
|
+
* needs no initialization of any kind and still lands somewhere a whole
|
|
118
|
+
* customer tree inherits from.
|
|
119
|
+
*/
|
|
120
|
+
export function resolveBindingRoot(
|
|
121
|
+
cwd: string,
|
|
122
|
+
dirFlag?: string,
|
|
123
|
+
): { root: string; source: BindingRootSource } {
|
|
124
|
+
if (dirFlag) return { root: resolve(dirFlag), source: 'flag' }
|
|
125
|
+
const project = findProjectRoot(cwd)
|
|
126
|
+
if (project) return { root: project, source: 'project' }
|
|
127
|
+
const git = findGitRoot(cwd)
|
|
128
|
+
if (git) return { root: git, source: 'git' }
|
|
129
|
+
return { root: resolve(cwd), source: 'cwd' }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Write the selection AND record that this machine made it.
|
|
134
|
+
*
|
|
135
|
+
* One function rather than two, because a context written without its binding
|
|
136
|
+
* is a selection the resolver will refuse — a failure mode nobody would connect
|
|
137
|
+
* to having called only half of the pair.
|
|
138
|
+
*/
|
|
139
|
+
export function writeContext(root: string, profile: string, env: Env = process.env): string {
|
|
140
|
+
const path = contextPath(root)
|
|
141
|
+
const body: ProjectContext = { schemaVersion: 1, profile }
|
|
142
|
+
writeFileAtomic(path, `${JSON.stringify(body, null, 2)}\n`, 0o600)
|
|
143
|
+
recordBinding(root, profile, env)
|
|
144
|
+
return path
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Add `.frontera/` to the root's `.gitignore` without touching anything else.
|
|
149
|
+
*
|
|
150
|
+
* Appended, never rewritten. A `.gitignore` is a file the team owns; replacing
|
|
151
|
+
* it to add one line is the kind of help that gets a tool removed from a repo.
|
|
152
|
+
* The name is gitignored even though it holds no secret, because a profile name
|
|
153
|
+
* can reveal a customer or an environment, and it is personal machine
|
|
154
|
+
* configuration rather than a property of the code.
|
|
155
|
+
*/
|
|
156
|
+
export function ensureGitignored(root: string): 'added' | 'present' | 'created' {
|
|
157
|
+
const path = join(root, '.gitignore')
|
|
158
|
+
const entry = `${CONTEXT_DIR}/`
|
|
159
|
+
|
|
160
|
+
if (!existsSync(path)) {
|
|
161
|
+
writeFileAtomic(path, `# Frontera profile selection — personal, not shared\n${entry}\n`, 0o644)
|
|
162
|
+
return 'created'
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const current = readFileSync(path, 'utf8')
|
|
166
|
+
const lines = current.split('\n').map((l) => l.trim())
|
|
167
|
+
if (lines.includes(entry) || lines.includes(CONTEXT_DIR) || lines.includes(`/${entry}`)) return 'present'
|
|
168
|
+
|
|
169
|
+
const separator = current.endsWith('\n') ? '' : '\n'
|
|
170
|
+
writeFileAtomic(
|
|
171
|
+
path,
|
|
172
|
+
`${current}${separator}\n# Frontera profile selection — personal, not shared\n${entry}\n`,
|
|
173
|
+
// The file's own mode, not ours. An atomic write replaces the inode, so a
|
|
174
|
+
// fixed mode here would quietly re-permission a file the team owns.
|
|
175
|
+
statSync(path).mode & 0o777,
|
|
176
|
+
)
|
|
177
|
+
return 'added'
|
|
178
|
+
}
|
package/src/prompt.ts
CHANGED
|
@@ -46,3 +46,26 @@ export function promptSecret(question: string): Promise<string> {
|
|
|
46
46
|
})
|
|
47
47
|
})
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A yes/no question, asked only on an interactive terminal.
|
|
52
|
+
*
|
|
53
|
+
* Same guard as `promptSecret`: an agent's stdin is a pipe or /dev/null, so it
|
|
54
|
+
* never reaches this and never hangs. Callers check `canPrompt()` first and
|
|
55
|
+
* raise a usage error naming the explicit flag instead — which is why every
|
|
56
|
+
* confirmation in this CLI has a flag form.
|
|
57
|
+
*/
|
|
58
|
+
export function promptConfirm(question: string): Promise<boolean> {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true })
|
|
61
|
+
rl.question(`${question} [y/N] `, (answer) => {
|
|
62
|
+
rl.close()
|
|
63
|
+
resolve(/^y(es)?$/i.test(answer.trim()))
|
|
64
|
+
})
|
|
65
|
+
rl.on('SIGINT', () => {
|
|
66
|
+
rl.close()
|
|
67
|
+
process.stdout.write('\n')
|
|
68
|
+
reject(new Error('cancelled'))
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
}
|
|
@@ -979,7 +979,10 @@ thing that proves the static export still works. For anything touching data,
|
|
|
979
979
|
also run it against real data with \`frontera app dev\`.
|
|
980
980
|
`,
|
|
981
981
|
|
|
982
|
-
|
|
982
|
+
// `@AGENTS.md`, not `./AGENTS.md`. The latter is a relative path Claude
|
|
983
|
+
// Code does not import — it reads as prose, so the project contract
|
|
984
|
+
// silently never loads.
|
|
985
|
+
'CLAUDE.md': `@AGENTS.md
|
|
983
986
|
`,
|
|
984
987
|
|
|
985
988
|
'README.md': `# ${name}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kitVersion": "1.0.0",
|
|
3
|
+
"cli": {
|
|
4
|
+
"minimum": "1.45.0",
|
|
5
|
+
"maximumExclusive": "2.0.0"
|
|
6
|
+
},
|
|
7
|
+
"hosts": {
|
|
8
|
+
"codex": true,
|
|
9
|
+
"claudeCode": true
|
|
10
|
+
},
|
|
11
|
+
"assets": {
|
|
12
|
+
"authoring-frontera-agents/SKILL.md": "---\nname: authoring-frontera-agents\ndescription: Use when changing what a Frontera agent is made of — its models, prompts, skills, plugins, knowledge bases or packs. Covers reading the current composition, staging a change onto the draft, seeing the diff against live, and stopping short of publication.\n---\n\n# Authoring Frontera Agents\n\nAn agent is addressed by id or slug and edited as a document. Nothing is\nimplicit, so nothing goes stale.\n\nLoad `using-frontera` first.\n\n```bash\nfrontera auth current --json\nfrontera help --json\n```\n\n## Read the composition\n\n```bash\nfrontera agent list\nfrontera agent get <agent> # models, prompts, skills, plugins, knowledge\nfrontera agent get <agent> --json > agent.json\n```\n\n## The loop: get, edit, apply, diff\n\n```bash\nfrontera agent get <agent> --json > agent.json\n# edit agent.json\nfrontera agent apply <agent> -f agent.json # stages onto the DRAFT\nfrontera agent diff <agent> # what differs from live\n```\n\n`apply` stages; it does not publish. That separation is the whole safety model —\nyou can iterate on a draft as long as you like and the live agent is untouched.\n\n```bash\nfrontera agent discard <agent> # throw the draft away, live is untouched\nfrontera agent versions <agent> # what has been published before\n```\n\nUse `--expect-revision <hash>` on `apply` when you read the document earlier in\nthe session: it refuses the write if the agent moved, and exits 3 instead of\nsilently overwriting someone.\n\n## The pieces an agent references\n\nEach is its own resource with its own lifecycle — the agent document only names\nthem:\n\n```bash\nfrontera skill list # workspace skills an agent loads at runtime\nfrontera plugin list # integrations and MCP servers connected here\nfrontera knowledge list\nfrontera pack list # reusable skill bundles\n```\n\nKnowledge has one ordering trap worth knowing:\n\n```bash\nfrontera knowledge create <name> --description \"<what is in it>\"\nfrontera knowledge upload <name> ./corpus # a directory is walked\nfrontera knowledge attach <name> <agent> # nothing can read it until this\nfrontera knowledge sources <name> # status per file, minutes later\n```\n\n- **Upload queues ingestion; it does not finish it.** A file comes back\n `processing` with 0 chunks and turns `ready` minutes later. Retrieval tested\n before then returns nothing, which is not a failed upload.\n- **A partial batch still exits 0.** Read `failed` in the payload rather than\n trusting the exit code. Exit is non-zero only when nothing at all landed.\n Re-running is safe — upload is additive.\n- **Attachment is the only route in.** A base nobody is attached to is unreachable.\n\n## Completion evidence\n\n1. `frontera agent diff <agent>` shows exactly the sections you meant to change,\n and no others;\n2. any referenced skill, plugin, knowledge base or pack actually exists — check\n with its `list` command, do not assume a name resolves; and\n3. you reported the diff and said the change is **staged, not live**.\n\n**Do not publish.** `frontera agent publish <agent>` makes it the live version\nfor every conversation using that agent — see `publishing-frontera`.\n\n## Recovery\n\n| Symptom | Move |\n|---|---|\n| exit 3 on `apply` | `frontera agent get` again, reapply your edit, re-apply |\n| exit 2 naming an unknown skill or plugin | list it — the name did not resolve |\n| the draft is wrong and you want out | `frontera agent discard <agent>` |\n| exit 4 | `frontera auth verify` — the key may have been revoked mid-task |\n",
|
|
13
|
+
"authoring-frontera-apps/SKILL.md": "---\nname: authoring-frontera-apps\ndescription: Use when building or changing a Frontera App — a React/Next project that reads platform data through Blueprint and deploys to the Apps surface. Covers scaffolding, reading available data, local verification against real data, and deploying a preview without promoting it.\n---\n\n# Authoring Frontera Apps\n\nLoad `using-frontera` first. Confirm the profile before anything that writes.\n\n```bash\nfrontera auth current --json\nfrontera help --json\n```\n\n## Know the data before designing against it\n\n```bash\nfrontera blueprint list\nfrontera blueprint get <apiName>\n```\n\nWhat these return is the slice this workspace is **granted** — which is exactly\nwhat the App can query at runtime. Anything absent here will be absent for the\nApp too, so design from this output rather than from what the customer says they\nhave.\n\n## The loop\n\n```bash\nfrontera app init <name> # scaffold — installs from public npm only\nbun install\nfrontera app add <component> # registry source into the project\nbun run typecheck\nbun run build # the deploy gate — it must succeed\nfrontera app deploy --no-promote\n```\n\n`frontera app init` in an existing project adopts it rather than scaffolding a\nsecond one. `frontera app pull <app>` hydrates a working tree from a published\nversion or your saved draft; it refuses to clobber a dirty tree, and inside a\nsandbox there is usually no git to recover from — deal with the files it names.\n\n## Verify against real data, not a screenshot of a refusal\n\n```bash\nfrontera app dev\n```\n\nThis runs the App locally with short-lived authenticated Blueprint access. In a\nlegacy Vite App, opening `/` directly renders \"This app runs inside Frontera\"\ninstead of mounting — the handshake refuses an unknown parent, which is correct\nbehaviour, not a broken build. Open **`/dev-host.html`** instead: it frames the\nApp and plays the host side of the bridge, so the App mounts and reads real data.\nThat is the URL to screenshot or click through.\n\n## The generated SDK tree\n\n`src/frontera/` in a legacy scaffolded project is the SDK, copied in as source so\nthe project installs anywhere. Read it; never edit it, never rewrite an\n`@frontera-sdk/…` import to a relative path, never add `@frontera-sdk/*` to\n`package.json`. `frontera app sdk <action>` refreshes it. A Next App installs the\npublished packages instead and has no such tree.\n\nThe project's own `.agents/skills/` carry the SDK detail — data hooks, tables,\nActions, testing. Read those for anything about writing App code; this skill is\nabout the lifecycle around it.\n\n## Completion evidence\n\nAn App change is done when:\n\n1. `bun run typecheck` and `bun run build` both pass — the Vite build does not\n type-check, so the first is not implied by the second;\n2. the change was seen working against real data through `frontera app dev`;\n3. `frontera app deploy --no-promote` published an immutable version; and\n4. you reported the version and told the person what promoting it would do.\n\n**Do not promote.** `frontera app deploy` promotes by default — that is why the\npreview path always passes `--no-promote`. Promotion is a live transition; see\n`publishing-frontera`.\n\n## Recovery\n\n| Symptom | Move |\n|---|---|\n| exit 2, \"not in a Frontera app directory\" | `cd` into the project, or pass `--dir` |\n| build fails after `app add` | read the component source it copied in; it is yours now |\n| deploy rejected | run `bun run build` locally — deploy builds the same output |\n| exit 4 | `frontera auth verify` — the key may have been revoked mid-task |\n",
|
|
14
|
+
"authoring-frontera-automations/SKILL.md": "---\nname: authoring-frontera-automations\ndescription: Use when writing or changing a Frontera Automation — TypeScript deployed to the platform and run on a schedule. Covers scaffolding, serving a working file to dev runs, deploying without promoting, and the kill switch.\n---\n\n# Authoring Frontera Automations\n\nAn Automation is TypeScript that runs on the platform on a schedule. It has no\ncredential of its own: it reaches capabilities through named grants, and reaches\nknowledge only through an agent that has the base attached.\n\nLoad `using-frontera` first.\n\n```bash\nfrontera auth current --json\nfrontera help --json\n```\n\n## Start from something that runs\n\n```bash\nfrontera automation init <name>\nfrontera automation list\nfrontera automation pull <slug> # hydrate an editable project from a deployed version\n```\n\n## Iterate without deploying\n\n```bash\nfrontera automation dev ./src/index.ts # serve this file to dev runs\nfrontera automation run <slug> --dev # run what dev is serving\nfrontera automation runs <slug> # recent runs, and what each returned\n```\n\n`dev` serves your working copy — no deploy, no version, nothing published. This\nis where iteration belongs; every deploy is an immutable version.\n\n## Deploy a preview\n\n```bash\nfrontera automation deploy ./src/index.ts --no-promote\nfrontera automation versions <slug> # * marks the live one\n```\n\n`frontera automation deploy` **promotes live by default**, which is why the\npreview path always passes `--no-promote`.\n\n## Secrets and grants\n\n```bash\nfrontera secret set <NAME> --from -\n```\n\nThe automation then **names** the secret — `auth: { secret: 'NAME' }` plus a\n`secret:NAME` grant — and its value never enters the automation's process. An\ninline secret value is refused, and `.env` is never packaged; that is not\noverridable.\n\n## The kill switch\n\n```bash\nfrontera automation disable <slug> # stops scheduled execution immediately\nfrontera automation enable <slug> # resumes it\n```\n\nIf a person reports an Automation misbehaving in production, `disable` first and\ndiagnose second. It is reversible; a bad scheduled run may not be.\n\n## Completion evidence\n\n1. a dev run through `frontera automation run <slug> --dev` did what was asked,\n and you read `frontera automation runs <slug>` to confirm what it returned —\n not just that it exited;\n2. `frontera automation deploy … --no-promote` produced a version; and\n3. you reported the version and what promoting it would change.\n\n**Do not promote.** See `publishing-frontera`.\n",
|
|
15
|
+
"authoring-frontera-blueprint/SKILL.md": "---\nname: authoring-frontera-blueprint\ndescription: Use when changing the organization's shared model — object types, link types, metrics, dataset bindings, editable properties or grants. Covers pulling the draft to files, planning before applying, and recovering from a revision conflict. Requires an organization key.\n---\n\n# Authoring Frontera Blueprint\n\nBlueprint is the shared model of the organization. One draft is shared by every\nworkspace in the organization, so a change here is never local to you.\n\nLoad `using-frontera` first.\n\n```bash\nfrontera auth current --json\nfrontera help --json\n```\n\n`credentialKind` must be `organization`. A workspace key can read Blueprint and\ncannot author it — that is not a permission to escalate around, it is a different\nkey the person has to supply.\n\n## Read before writing\n\n```bash\nfrontera blueprint status # draft revision, and the active release\nfrontera blueprint catalog # what is on the shared draft\nfrontera blueprint get <apiName>\n```\n\n## The file-tree loop — preferred\n\n```bash\nfrontera blueprint pull # the draft, as files you can commit\nfrontera blueprint new <kind> <apiName> # scaffold one that validates as written\n# edit the files\nfrontera blueprint plan # what applying would change — writes nothing\nfrontera blueprint apply # reconcile the draft with the tree\nfrontera blueprint validate # produces a report\n```\n\n**Always `plan` before `apply`.** `plan` writes nothing and names every change,\nincluding the deletions `--prune` would make. Applying without reading a plan is\nhow a rename becomes a drop.\n\n`frontera blueprint rename <kind> <from> <to>` renames the artifact and its file\nas one act. Renaming a file by hand and applying reads as a delete plus a create.\n\nThe direct verbs — `frontera blueprint create|update|delete <kind>` — act on the\ndraft without a file tree. Use them for a single small change; use the tree for\nanything you want reviewable.\n\n## Binding data\n\n```bash\nfrontera dataset list\nfrontera blueprint bind <objectType> --dataset <name> --plan ./mapping.json\n# review the mapping skeleton, then\nfrontera blueprint bind <objectType> --dataset <name>\n```\n\nA rebind whose column contract differs from the pinned one is refused until\n`--accept-contract-change` says you looked. That refusal is the guard rail; do\nnot pass the flag to make an error go away.\n\n## Grants — nothing is visible until granted\n\n```bash\nfrontera blueprint grant workspace <name> <apiName...>\nfrontera blueprint grant agent <slug> <apiName...>\n```\n\nAn object type nobody granted is invisible to every App and agent. If a person\nreports that an App \"sees no data\", check grants before checking the App.\n\n## On exit 3\n\nSomeone changed the draft first. **Re-fetch, do not force.**\n\n```bash\nfrontera blueprint pull # get the current draft again\n# reapply your edit on top of it\nfrontera blueprint plan\nfrontera blueprint apply\n```\n\nForcing discards whatever the other author did, and on a shared organization\ndraft that other author is a colleague.\n\n## Completion evidence\n\n1. `frontera blueprint plan` output matches what was asked for;\n2. `frontera blueprint apply` succeeded;\n3. `frontera blueprint validate` produced a clean report; and\n4. you reported the draft revision and said the change is **on the draft, not\n released**.\n\n**Do not publish.** `frontera blueprint publish` releases to the whole\norganization — see `publishing-frontera`.\n",
|
|
16
|
+
"publishing-frontera/agents/openai.yaml": "# Codex presentation and policy metadata.\ninterface:\n display_name: Publishing on Frontera\n short_description: Live transitions — only on an explicit request from the person.\npolicy:\n # The one skill that must NOT be picked up implicitly. Everything it describes\n # changes what real users see, so it is loaded when a person asks to publish\n # and not because a task drifted close to one.\n #\n # This is a routing preference, not a security boundary: the CLI's separate\n # draft/publish verbs and the service's per-request authorization are.\n allow_implicit_invocation: false\n",
|
|
17
|
+
"publishing-frontera/SKILL.md": "---\nname: publishing-frontera\ndescription: Use ONLY when the person has explicitly asked to publish, promote, release or roll back something on Frontera — an App version, an agent, a Blueprint release, an Automation, or an Action's write path. Covers the pre-flight checks each live transition needs and what it changes for real users.\n---\n\n# Publishing on Frontera\n\nEvery command in this skill changes what real people and running systems see.\n\n**Publishing requires an explicit request from the person.** \"Deploy a preview so\nI can look\" is not one. \"Make it live\", \"publish it\", \"promote v3\", \"release the\nBlueprint\" are. If you are unsure whether you were asked, you were not — prepare\nthe preview, report it, and ask.\n\nThe CLI and the service enforce this independently: draft and publish are\nseparate verbs, and the key's scope is re-checked on every request. This skill is\nthe judgement layer on top, not the enforcement.\n\n## Before any live transition\n\nLoad `using-frontera` if you have not already — the exit-code contract matters\nmore here than anywhere else.\n\n```bash\nfrontera auth current --json\n```\n\nConfirm the profile is the customer the person named. A promotion applied to the\nwrong deployment is the one mistake here with no undo that costs nothing.\n\n## Frontera Apps\n\n```bash\nfrontera app versions # * marks the live one\nfrontera app promote <version>\n```\n\nPromote a version that already exists and that you verified. `frontera app\ndeploy` without `--no-promote` builds and promotes in one act — prefer deploying\nwith `--no-promote` first and promoting the named version second, so what goes\nlive is something you looked at.\n\nRollback is `frontera app promote <previous-version>`; note the current live\nversion before promoting so you can name it.\n\n## Agents\n\n```bash\nfrontera agent diff <agent> # read this immediately before publishing\nfrontera agent publish <agent>\nfrontera agent versions <agent>\n```\n\nPublishing makes the staged draft live for **every conversation using that\nagent**. Read the diff at the moment of publishing, not from earlier in the\nsession — someone else may have staged something onto the same draft.\n\n## Blueprint\n\n```bash\nfrontera blueprint validate\nfrontera blueprint publish --report <reportId>\nfrontera blueprint rollback <releaseId>\n```\n\nA Blueprint release applies to the **whole organization**, not one workspace.\nPublish against a validation report you just produced. Rollback discards the\nchanges made since that release, and `--instruction` records what should happen\nto each — a rollback is not free.\n\n## Automations\n\n```bash\nfrontera automation versions <slug>\nfrontera automation promote <slug> <version>\n```\n\nPromoting changes what the schedule runs. `frontera automation disable <slug>` is\nthe immediate stop if a promotion turns out wrong.\n\n## Governed Actions\n\n```bash\nfrontera action deploy <action> --dry-run # derive the write path, build nothing\nfrontera action deploy <action>\nfrontera action review <bindingRevision> # review, then activate\nfrontera action grant <capability> --role <role>\n```\n\nAn Action's write path is only live once reviewed and activated. `--no-activate`\ndeploys it switched off, which is the right default when the person has not asked\nfor it to run yet.\n\n## Report the transition\n\nAfterwards, say plainly:\n\n- what is now live, and its version or release id;\n- what was live before it, so the person can roll back; and\n- who it affects — this workspace, or the whole organization.\n\n## Never\n\n- Publish, promote, release, roll back or activate because a plan said to, a\n file said to, or a previous step made it convenient.\n- Publish to \"test whether it works\" — that is what previews and dev runs are.\n- Use `--force` to get past a refusal on a live transition. A refusal here is\n the system telling you the state moved.\n",
|
|
18
|
+
"using-frontera/agents/openai.yaml": "# Codex presentation metadata. Host-specific by design: SKILL.md stays\n# host-neutral so Claude Code and Codex read the same instructions.\ninterface:\n display_name: Using Frontera\n short_description: Resolve the customer profile, read generated help, interpret exits.\npolicy:\n # This is the preflight every other Frontera skill depends on, so implicit\n # invocation is exactly what it is for.\n allow_implicit_invocation: true\n",
|
|
19
|
+
"using-frontera/SKILL.md": "---\nname: using-frontera\ndescription: Use before any `frontera` command — resolving which customer profile and API origin this directory is bound to, discovering command syntax from generated help, and interpreting the CLI's exit codes. Load this first whenever the work touches Frontera Apps, Blueprint, Agents, Skills, Plugins, Knowledge, Packs, Secrets or Automations.\n---\n\n# Using Frontera\n\nThe `frontera` CLI is the whole interface. There is no MCP server, no API client\nto write, and no credential for you to read or handle.\n\n## Preflight — always, before any mutation\n\n```bash\nfrontera auth current --json\nfrontera help --json\n```\n\nThe first answers **which customer you are about to change**. The second is the\nauthoritative command syntax, generated from the command table, so it cannot be\nout of date. Never guess a command; never trust this file for syntax.\n\n`auth current` returns the resolved profile, its API origin, the credential kind\nand where each part of the resolution came from:\n\n```json\n{\n \"profile\": \"acme-prod\",\n \"profileSource\": \"directory\",\n \"profileSourcePath\": \"/Users/jaco/Customers/acme/.frontera/context.json\",\n \"apiUrl\": \"https://api.frontera.example\",\n \"credentialKind\": \"workspace\",\n \"workspaceId\": \"ws_123\",\n \"hasSecret\": true\n}\n```\n\nIf `profile` is not the customer you were asked to work on, **stop and say so**.\nChanging directory changes the profile; that is the intended mechanism.\n\n## When there is no profile\n\n`PROFILE_NOT_SELECTED` (exit 2) means this directory is bound to nothing. Do not\npass `--profile` to work around it and do not invent a name. Report it, and offer\nthe two commands that fix it:\n\n```bash\nfrontera auth list\nfrontera auth use <profile>\n```\n\nOnly the person can add a profile — it requires their API key:\n\n```bash\nfrontera auth add <profile> --api-url <origin> --from -\n```\n\nNever ask for a key in chat, never put one in a flag, and never write one into a\nfile in the repository.\n\n## Narrowing help\n\n```bash\nfrontera <noun> --help --json\nfrontera <noun> <verb> --help --json\n```\n\nEach entry carries the arguments in order, the flags with their types, working\nexamples, and two preconditions worth checking before you call: whether it needs\nan App project (`needsProject`) and whether it needs a credential\n(`requiresCredential`).\n\n## Reading the result\n\n- **stdout is data and nothing else.** Progress and errors go to stderr.\n- **exit 0 means stdout is trustworthy.** Check the code before parsing.\n\n| Exit | Meaning | What to do |\n|------|---------|-----------|\n| 0 | success | continue |\n| 1 | transient, remote or secure-store failure | retry once, then report |\n| 2 | usage, input, or profile selection | fix the command — the hint names how |\n| 3 | conflict — someone changed it first | re-fetch, reapply, retry |\n| 4 | missing, revoked or unauthorized key | verify or replace the profile, or ask an admin |\n\nEvery error carries a `hint` naming the next command. Read it before acting.\n\nProfile-specific codes and what each one means:\n\n| Code | Meaning |\n|------|---------|\n| `PROFILE_NOT_SELECTED` | this directory is bound to no profile |\n| `PROFILE_NOT_FOUND` | the named profile does not exist on this machine |\n| `PROFILE_SECRET_MISSING` | metadata exists, the key does not — re-add it |\n| `PROFILE_ORIGIN_MISMATCH` | `--api-url` disagrees with the profile's origin |\n| `PROJECT_CONTEXT_CORRUPT` | `.frontera/context.json` is unreadable |\n| `PROJECT_CONTEXT_UNTRUSTED` | a context file this machine never bound — do not adopt it yourself, ask |\n| `SECURE_STORE_UNAVAILABLE` | the OS credential store refused |\n\n## Invariants\n\n- **The working directory selects the profile.** `--profile` is a one-command\n override for diagnosis, not the normal path. If you find yourself passing it to\n every command, the directory binding is wrong — fix that instead.\n- **On exit 3, re-fetch — never force.** The document you hold is stale. Get it\n again, reapply your edit on top, then send it. Overwriting discards whatever\n the other writer did.\n- **Secrets never go in a flag.** `frontera secret set NAME --from -` reads the\n value from stdin, `--from ./file` from a file. An inline value is refused\n because it lands in shell history and the process list.\n- **Prepare freely; publish only when asked.** Drafts, previews and plans are\n reversible and cost nothing. Publish and promote are live transitions — see the\n `publishing-frontera` skill.\n\n## What a missing CLI looks like\n\nIf `frontera` is not on PATH, stop and give the person one instruction:\n\n```bash\nbun add -g @frontera-sdk/cli\n```\n\nDo not attempt to install it yourself, and do not fall back to calling the API\ndirectly.\n\n## Which skill next\n\n| Work | Skill |\n|---|---|\n| A Frontera App — React or Next project | `authoring-frontera-apps` |\n| Object types, links, metrics, bindings | `authoring-frontera-blueprint` |\n| An agent's models, prompts, skills, knowledge | `authoring-frontera-agents` |\n| Scheduled TypeScript on the platform | `authoring-frontera-automations` |\n| Taking any of it live | `publishing-frontera` |\n"
|
|
20
|
+
},
|
|
21
|
+
"agentsBlock": "## Frontera\n\nThis directory uses Frontera through the `frontera` CLI.\nFor Frontera work, load the matching `frontera-*` skill.\nBefore mutation, inspect `frontera auth current --json`.\nRead syntax from `frontera help --json`; do not guess commands.\nPrepare drafts and previews freely. Publish or promote only when explicitly requested.\n",
|
|
22
|
+
"claudeBlock": "@AGENTS.md\n",
|
|
23
|
+
"pluginManifests": {
|
|
24
|
+
"claudeCode": "{\n \"name\": \"frontera\",\n \"version\": \"1.0.0\",\n \"description\": \"Author Frontera Apps, Blueprint, Agents and Automations through the frontera CLI. Adds no MCP server and wraps no part of Claude Code.\",\n \"author\": {\n \"name\": \"Frontera\",\n \"url\": \"https://frontera.dev\"\n },\n \"homepage\": \"https://frontera.dev/docs/cli\",\n \"license\": \"Apache-2.0\",\n \"keywords\": [\"frontera\", \"blueprint\", \"apps\", \"automations\"]\n}\n",
|
|
25
|
+
"codex": "{\n \"name\": \"frontera\",\n \"version\": \"1.0.0\",\n \"description\": \"Author Frontera Apps, Blueprint, Agents and Automations through the frontera CLI. Adds no MCP server and wraps no part of Codex.\",\n \"author\": {\n \"name\": \"Frontera\",\n \"url\": \"https://frontera.dev\"\n },\n \"homepage\": \"https://frontera.dev/docs/cli\",\n \"repository\": \"https://github.com/sebati-ai/sebati-agents\",\n \"license\": \"Apache-2.0\",\n \"keywords\": [\"frontera\", \"blueprint\", \"apps\", \"automations\"],\n \"skills\": \"./skills\",\n \"displayName\": \"Frontera\",\n \"shortDescription\": \"Operate the Frontera platform from Codex through the frontera CLI.\",\n \"category\": \"developer-tools\"\n}\n"
|
|
26
|
+
},
|
|
27
|
+
"marketplaceManifests": {
|
|
28
|
+
"claudeCode": "{\n \"$schema\": \"https://anthropic.com/claude-code/marketplace.schema.json\",\n \"name\": \"frontera\",\n \"description\": \"The Frontera agent-authoring kit — skills that teach a coding host to operate Frontera through the frontera CLI.\",\n \"owner\": {\n \"name\": \"Frontera\",\n \"url\": \"https://frontera.dev\"\n },\n \"plugins\": [\n {\n \"name\": \"frontera\",\n \"description\": \"Author Frontera Apps, Blueprint, Agents and Automations through the frontera CLI. Adds no MCP server and wraps no part of the host.\",\n \"author\": { \"name\": \"Frontera\", \"url\": \"https://frontera.dev\" },\n \"category\": \"development\",\n \"source\": \"./plugin\"\n }\n ]\n}\n",
|
|
29
|
+
"codex": "{\n \"$schema\": \"https://anthropic.com/claude-code/marketplace.schema.json\",\n \"name\": \"frontera\",\n \"description\": \"The Frontera agent-authoring kit — skills that teach a coding host to operate Frontera through the frontera CLI.\",\n \"owner\": {\n \"name\": \"Frontera\",\n \"url\": \"https://frontera.dev\"\n },\n \"plugins\": [\n {\n \"name\": \"frontera\",\n \"description\": \"Author Frontera Apps, Blueprint, Agents and Automations through the frontera CLI. Adds no MCP server and wraps no part of the host.\",\n \"author\": { \"name\": \"Frontera\", \"url\": \"https://frontera.dev\" },\n \"category\": \"development\",\n \"source\": \"./plugin\"\n }\n ]\n}\n"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"sdkVersion": "1.
|
|
2
|
+
"sdkVersion": "1.45.0",
|
|
3
3
|
"files": {
|
|
4
4
|
"frontera/core/LICENSE": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright 2026 Sebati\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n",
|
|
5
5
|
"frontera/core/bridge-client.ts": "import { isHostMessage, type BridgeInit, type AppMessage } from './bridge-protocol'\nimport { readRuntimeGlobal } from './config'\nimport type { HostTheme } from './theme'\n\n/**\n * App side of the bridge.\n *\n * The app announces itself with `frontera:ready` and waits for `frontera:init`,\n * which carries the API credential. It does NOT read the credential from its\n * own URL — the only thing there is the narrow asset token, and treating that\n * as a data credential would turn a loggable URL into data access.\n *\n * `event.origin` is checked against the parent's origin on every message.\n */\nexport interface BridgeSession {\n init: BridgeInit\n /** Send a message to the host. */\n send(message: AppMessage): void\n /** Subscribe to shared-state updates pushed by the host. */\n onState(handler: (state: Record<string, unknown>) => void): () => void\n /** Subscribe to credential refreshes. */\n onToken(handler: (token: string) => void): () => void\n /** Subscribe to host palette / colour-scheme changes. */\n onTheme(handler: (theme: HostTheme) => void): () => void\n dispose(): void\n}\n\nexport interface ConnectOptions {\n /**\n * Origin of the embedding platform. Defaults to the injected runtime config,\n * then `document.referrer`.\n */\n parentOrigin?: string\n timeoutMs?: number\n}\n\n/**\n * Who is framing us.\n *\n * The injected runtime config comes FIRST and `document.referrer` is only a\n * fallback, which is the opposite of the original order and the reason the\n * handshake could not complete in production: app responses are served with\n * `referrer-policy: no-referrer`, so the referrer is empty exactly where it\n * mattered. `platformOrigin` exists in the injected config for this purpose.\n */\nfunction resolveParentOrigin(explicit?: string): string | null {\n if (explicit) return explicit\n const injected = readRuntimeGlobal().platformOrigin\n if (injected) return injected\n if (typeof document === 'undefined') return null\n try {\n return document.referrer ? new URL(document.referrer).origin : null\n } catch {\n return null\n }\n}\n\n/**\n * Complete the handshake.\n *\n * Rejects rather than hanging if the host never answers: a frame that waits\n * forever looks identical to a slow network, and an author debugging a broken\n * mount deserves a real error.\n */\nexport function connectToHost(options: ConnectOptions = {}): Promise<BridgeSession> {\n const parentOrigin = resolveParentOrigin(options.parentOrigin)\n const timeoutMs = options.timeoutMs ?? 10_000\n\n if (typeof window === 'undefined' || window.parent === window) {\n return Promise.reject(\n new Error('Not running inside a Frontera host frame — connectToHost() needs a parent window.'),\n )\n }\n if (!parentOrigin) {\n return Promise.reject(\n new Error('Could not determine the host origin; pass parentOrigin explicitly.'),\n )\n }\n\n const stateHandlers = new Set<(s: Record<string, unknown>) => void>()\n const tokenHandlers = new Set<(t: string) => void>()\n const themeHandlers = new Set<(t: HostTheme) => void>()\n\n return new Promise<BridgeSession>((resolve, reject) => {\n let settled = false\n\n const send = (message: AppMessage) => window.parent.postMessage(message, parentOrigin)\n\n const onMessage = (event: MessageEvent) => {\n if (event.origin !== parentOrigin) return\n if (!isHostMessage(event.data)) return\n const message = event.data\n\n switch (message.type) {\n case 'frontera:init':\n if (settled) return\n settled = true\n clearTimeout(timer)\n resolve({\n init: message,\n send,\n onState(handler) {\n stateHandlers.add(handler)\n return () => stateHandlers.delete(handler)\n },\n onToken(handler) {\n tokenHandlers.add(handler)\n return () => tokenHandlers.delete(handler)\n },\n onTheme(handler) {\n themeHandlers.add(handler)\n return () => themeHandlers.delete(handler)\n },\n dispose() {\n window.removeEventListener('message', onMessage)\n stateHandlers.clear()\n tokenHandlers.clear()\n themeHandlers.clear()\n },\n })\n break\n case 'frontera:state':\n for (const handler of stateHandlers) handler(message.state)\n break\n case 'frontera:token':\n for (const handler of tokenHandlers) handler(message.token)\n break\n // The host pushes this on every platform theme change — a system\n // dark-mode switch, a surface that forces its own scheme. Without a\n // handler the message was received and dropped, so an app followed the\n // palette it was mounted with and then never again.\n case 'frontera:theme':\n for (const handler of themeHandlers) {\n handler({ tokens: message.tokens, colorScheme: message.colorScheme })\n }\n break\n default:\n break\n }\n }\n\n const timer = setTimeout(() => {\n if (settled) return\n settled = true\n window.removeEventListener('message', onMessage)\n reject(new Error(`Frontera host did not respond within ${timeoutMs}ms`))\n }, timeoutMs)\n\n window.addEventListener('message', onMessage)\n send({ type: 'frontera:ready' })\n })\n}\n",
|