@frontera-sdk/cli 0.1.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/LICENSE +202 -0
- package/README.md +65 -0
- package/package.json +47 -0
- package/src/api/apps-api.ts +165 -0
- package/src/api/automation-api.ts +140 -0
- package/src/api/platform-api.ts +193 -0
- package/src/api/registry-api.ts +43 -0
- package/src/args.ts +108 -0
- package/src/commands/agent/compose.ts +155 -0
- package/src/commands/agent/index-commands.ts +348 -0
- package/src/commands/agent/resolve.ts +58 -0
- package/src/commands/app/add.ts +78 -0
- package/src/commands/app/deploy.ts +105 -0
- package/src/commands/app/init.ts +53 -0
- package/src/commands/app/list.ts +51 -0
- package/src/commands/app/promote.ts +31 -0
- package/src/commands/app/pull.ts +145 -0
- package/src/commands/app/save.ts +36 -0
- package/src/commands/app/shared.ts +25 -0
- package/src/commands/app/versions.ts +38 -0
- package/src/commands/automation/index-commands.ts +325 -0
- package/src/commands/blueprint/get.ts +160 -0
- package/src/commands/blueprint/list.ts +48 -0
- package/src/commands/blueprint/reserved.ts +40 -0
- package/src/commands/completion.ts +293 -0
- package/src/commands/init.ts +33 -0
- package/src/commands/knowledge/index-commands.ts +140 -0
- package/src/commands/login.ts +103 -0
- package/src/commands/plugin/index-commands.ts +112 -0
- package/src/commands/registry.ts +405 -0
- package/src/commands/skill/index-commands.ts +140 -0
- package/src/commands/types.ts +76 -0
- package/src/config.ts +142 -0
- package/src/context.ts +67 -0
- package/src/errors.ts +30 -0
- package/src/exit.ts +98 -0
- package/src/flag-help.ts +70 -0
- package/src/harness.ts +162 -0
- package/src/heal.ts +418 -0
- package/src/help.ts +128 -0
- package/src/main.ts +204 -0
- package/src/manifest.ts +80 -0
- package/src/output.ts +65 -0
- package/src/pack.ts +18 -0
- package/src/packaging.ts +116 -0
- package/src/project.ts +151 -0
- package/src/prompt.ts +48 -0
- package/src/registry.ts +62 -0
- package/src/secrets.ts +69 -0
- package/src/table.ts +47 -0
- package/src/tar.ts +73 -0
- package/src/template.ts +566 -0
- package/src/vendor/sdk-sources.json +25 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
import { PlatformApi } from '../../api/platform-api'
|
|
4
|
+
import { CliError, UsageError } from '../../errors'
|
|
5
|
+
import { table } from '../../table'
|
|
6
|
+
import { flagString, type Command, type CommandContext } from '../types'
|
|
7
|
+
import { renderComposition, type Lookups } from './compose'
|
|
8
|
+
import { resolveAgentRef, type AgentRow } from './resolve'
|
|
9
|
+
|
|
10
|
+
function api(ctx: CommandContext): PlatformApi {
|
|
11
|
+
return new PlatformApi(ctx.apiUrl, ctx.token)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function target(ctx: CommandContext, verb: string): Promise<{ api: PlatformApi; id: string; ref: string }> {
|
|
15
|
+
const ref = ctx.positional[0]
|
|
16
|
+
if (!ref) {
|
|
17
|
+
throw new UsageError(`missing <agent>`, `frontera agent ${verb} <id-or-slug> — see \`frontera agent list\``)
|
|
18
|
+
}
|
|
19
|
+
const client = api(ctx)
|
|
20
|
+
return { api: client, id: await resolveAgentRef(client, ref), ref }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Names for the ids a snapshot carries.
|
|
25
|
+
*
|
|
26
|
+
* Best-effort on purpose: a caller whose key cannot list plugins should still
|
|
27
|
+
* get the agent, with those entries marked unresolved rather than the whole
|
|
28
|
+
* read failing.
|
|
29
|
+
*/
|
|
30
|
+
async function buildLookups(
|
|
31
|
+
client: PlatformApi,
|
|
32
|
+
identity?: { id?: string; slug?: string; kind?: string },
|
|
33
|
+
): Promise<Lookups> {
|
|
34
|
+
const [plugins, knowledge, skills] = await Promise.all([
|
|
35
|
+
client.pluginInstalls().catch(() => [] as unknown[]),
|
|
36
|
+
client.whoami().then((me) => (me.workspaceId ? client.knowledgeBases(me.workspaceId) : [])).catch(() => [] as unknown[]),
|
|
37
|
+
client.workspaceSkills().catch(() => [] as unknown[]),
|
|
38
|
+
])
|
|
39
|
+
|
|
40
|
+
const pluginMap = new Map<string, string>()
|
|
41
|
+
for (const row of plugins as Array<{ install?: { id?: string; installName?: string }; catalog?: { kind?: string; displayName?: string } }>) {
|
|
42
|
+
const id = row.install?.id
|
|
43
|
+
if (!id) continue
|
|
44
|
+
pluginMap.set(id, row.install?.installName ?? row.catalog?.displayName ?? row.catalog?.kind ?? id)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const knowledgeMap = new Map<string, string>()
|
|
48
|
+
for (const row of knowledge as Array<{ id?: string; name?: string; displayName?: string }>) {
|
|
49
|
+
if (row.id) knowledgeMap.set(row.id, row.name ?? row.displayName ?? row.id)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const skillMap = new Map<string, string>()
|
|
53
|
+
for (const row of skills as Array<{ id?: string; name?: string; displayName?: string }>) {
|
|
54
|
+
if (row.id) skillMap.set(row.id, row.name ?? row.displayName ?? row.id)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { plugins: pluginMap, knowledge: knowledgeMap, skills: skillMap, ...(identity ? { identity } : {}) }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Read a document from `-f <path>`, or from stdin when it is piped. */
|
|
61
|
+
async function readDocument(ctx: CommandContext): Promise<Record<string, unknown>> {
|
|
62
|
+
const file = flagString(ctx, 'file')
|
|
63
|
+
let raw: string
|
|
64
|
+
|
|
65
|
+
if (file && file !== '-') {
|
|
66
|
+
try {
|
|
67
|
+
raw = readFileSync(file, 'utf8')
|
|
68
|
+
} catch {
|
|
69
|
+
throw new UsageError(`could not read ${file}`, 'check the path, or pipe the document in with -f -')
|
|
70
|
+
}
|
|
71
|
+
} else if (file === '-' || !process.stdin.isTTY) {
|
|
72
|
+
const chunks: Uint8Array[] = []
|
|
73
|
+
for await (const chunk of Bun.stdin.stream()) chunks.push(chunk)
|
|
74
|
+
raw = Buffer.concat(chunks).toString('utf8')
|
|
75
|
+
} else {
|
|
76
|
+
throw new UsageError(
|
|
77
|
+
'no document supplied',
|
|
78
|
+
'pass -f <file>, or pipe one in: `frontera agent get <id> --draft --json | … | frontera agent apply <id> -f -`',
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(raw) as Record<string, unknown>
|
|
84
|
+
} catch {
|
|
85
|
+
throw new UsageError('the document is not valid JSON', 'it should be the object `agent get --draft` returns')
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const list: Command = {
|
|
90
|
+
meta: {
|
|
91
|
+
noun: 'agent',
|
|
92
|
+
verb: 'list',
|
|
93
|
+
args: [],
|
|
94
|
+
flags: {},
|
|
95
|
+
summary: 'List agents in this workspace',
|
|
96
|
+
examples: ['frontera agent list', 'frontera agent list --json'],
|
|
97
|
+
},
|
|
98
|
+
async run(ctx) {
|
|
99
|
+
const rows = (await api(ctx).agents()) as AgentRow[]
|
|
100
|
+
return {
|
|
101
|
+
data: rows,
|
|
102
|
+
text:
|
|
103
|
+
rows.length === 0
|
|
104
|
+
? 'No agents in this workspace.'
|
|
105
|
+
: // Either ID or SLUG works wherever an agent is named.
|
|
106
|
+
table(
|
|
107
|
+
['id', 'slug', 'kind', 'name'],
|
|
108
|
+
rows.map((a) => [a.id ?? '?', a.agentId ?? '', a.kind ?? '', a.name ?? '']),
|
|
109
|
+
),
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const get: Command = {
|
|
115
|
+
meta: {
|
|
116
|
+
noun: 'agent',
|
|
117
|
+
verb: 'get',
|
|
118
|
+
args: [{ name: 'agent', required: true, description: 'agent id or slug, from `frontera agent list`' }],
|
|
119
|
+
flags: { draft: 'boolean', config: 'boolean' },
|
|
120
|
+
summary: 'Show what an agent is made of — models, prompts, skills, plugins, knowledge',
|
|
121
|
+
examples: [
|
|
122
|
+
'frontera agent get sei',
|
|
123
|
+
'frontera agent get sei --json > agent.json',
|
|
124
|
+
'frontera agent get sei --draft --json',
|
|
125
|
+
'frontera agent get sei --config',
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
async run(ctx) {
|
|
129
|
+
const ref = ctx.positional[0]
|
|
130
|
+
if (!ref) {
|
|
131
|
+
throw new UsageError('missing <agent>', 'frontera agent get <id-or-slug> — see `frontera agent list`')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// `--config` is the old behaviour: the settings row alone, no relations.
|
|
135
|
+
if (ctx.flags.config === true && ctx.flags.draft !== true) {
|
|
136
|
+
const doc = await api(ctx).agent(ref)
|
|
137
|
+
return { data: doc, text: JSON.stringify(doc, null, 2) }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const t = await target(ctx, 'get')
|
|
141
|
+
|
|
142
|
+
if (ctx.flags.draft !== true) {
|
|
143
|
+
// The full composition, not the config row: prompts, skills, models,
|
|
144
|
+
// knowledge, plugins and channels are the reason to read an agent.
|
|
145
|
+
// The lookups turn the snapshot's uuids into names — without them the
|
|
146
|
+
// human output is a list of identifiers nobody can act on.
|
|
147
|
+
// The snapshot's config carries neither id, slug, kind nor the published
|
|
148
|
+
// version, so the config ROW is read alongside it. Two requests for a
|
|
149
|
+
// complete picture beats one that silently omits what an agent is.
|
|
150
|
+
const [snapshot, row, lookups] = await Promise.all([
|
|
151
|
+
t.api.agentComposition(t.id),
|
|
152
|
+
t.api.agent(t.id).catch(() => null),
|
|
153
|
+
buildLookups(t.api),
|
|
154
|
+
])
|
|
155
|
+
const identity = (row ?? {}) as {
|
|
156
|
+
id?: string
|
|
157
|
+
agentId?: string
|
|
158
|
+
kind?: string
|
|
159
|
+
currentVersion?: { versionNumber?: number }
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
data: snapshot,
|
|
163
|
+
text: renderComposition(snapshot, {
|
|
164
|
+
...lookups,
|
|
165
|
+
identity: {
|
|
166
|
+
id: identity.id ?? t.id,
|
|
167
|
+
slug: identity.agentId,
|
|
168
|
+
kind: identity.kind,
|
|
169
|
+
version: identity.currentVersion?.versionNumber,
|
|
170
|
+
},
|
|
171
|
+
}),
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const draft = await t.api.agentDraft(t.id)
|
|
175
|
+
if (!draft) {
|
|
176
|
+
throw new CliError(`no draft staged for ${t.ref}`, {
|
|
177
|
+
code: 'NOT_FOUND',
|
|
178
|
+
hint: 'edit the live document instead: `frontera agent get <agent> --json`',
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
return { data: draft, text: JSON.stringify(draft, null, 2) }
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const apply: Command = {
|
|
186
|
+
meta: {
|
|
187
|
+
noun: 'agent',
|
|
188
|
+
verb: 'apply',
|
|
189
|
+
args: [{ name: 'agent', required: true, description: 'agent id or slug' }],
|
|
190
|
+
flags: { file: 'string', 'expect-revision': 'string' },
|
|
191
|
+
aliases: { f: 'file' },
|
|
192
|
+
summary: 'Stage a document onto the agent draft (does not publish)',
|
|
193
|
+
examples: [
|
|
194
|
+
'frontera agent apply sei -f agent.json',
|
|
195
|
+
'cat agent.json | frontera agent apply sei -f -',
|
|
196
|
+
],
|
|
197
|
+
},
|
|
198
|
+
async run(ctx) {
|
|
199
|
+
const t = await target(ctx, 'apply')
|
|
200
|
+
const document = await readDocument(ctx)
|
|
201
|
+
|
|
202
|
+
// Accept either the whole `get --draft` document or a bare snapshot, so a
|
|
203
|
+
// round trip works without the caller unwrapping anything by hand.
|
|
204
|
+
const patch = (document.snapshot ?? document) as Record<string, unknown>
|
|
205
|
+
|
|
206
|
+
// The revision the caller last read. Passing it is what turns a
|
|
207
|
+
// concurrent edit into a DRAFT_CONFLICT instead of a silent overwrite.
|
|
208
|
+
const explicit = flagString(ctx, 'expect-revision')
|
|
209
|
+
const fromDocument = typeof document.revision === 'number' ? document.revision : undefined
|
|
210
|
+
const expectedRevision = explicit !== undefined ? Number(explicit) : fromDocument
|
|
211
|
+
|
|
212
|
+
if (explicit !== undefined && Number.isNaN(expectedRevision)) {
|
|
213
|
+
throw new UsageError('--expect-revision must be a number', 'take it from the document you applied')
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const draft = await t.api.patchAgentDraft(t.id, patch, expectedRevision)
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
data: draft,
|
|
220
|
+
text:
|
|
221
|
+
`Staged a draft for ${t.ref} (revision ${draft.revision}).\n` +
|
|
222
|
+
`Nothing is live yet — publish with \`frontera agent publish ${t.ref}\`.`,
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const publish: Command = {
|
|
228
|
+
meta: {
|
|
229
|
+
noun: 'agent',
|
|
230
|
+
verb: 'publish',
|
|
231
|
+
args: [{ name: 'agent', required: true, description: 'agent id or slug' }],
|
|
232
|
+
flags: { notes: 'string', 'expect-revision': 'string' },
|
|
233
|
+
summary: 'Publish the staged draft as a new live version',
|
|
234
|
+
examples: ['frontera agent publish sei', 'frontera agent publish sei --notes "raise temperature"'],
|
|
235
|
+
},
|
|
236
|
+
async run(ctx) {
|
|
237
|
+
const t = await target(ctx, 'publish')
|
|
238
|
+
|
|
239
|
+
// Read the draft first: it supplies the revision, and its absence is a
|
|
240
|
+
// clearer failure here than whatever the publish route would return.
|
|
241
|
+
const draft = await t.api.agentDraft(t.id)
|
|
242
|
+
if (!draft) {
|
|
243
|
+
throw new CliError(`no draft staged for ${t.ref}`, {
|
|
244
|
+
code: 'NOT_FOUND',
|
|
245
|
+
hint: `stage one first: \`frontera agent apply ${t.ref} -f <file>\``,
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const explicit = flagString(ctx, 'expect-revision')
|
|
250
|
+
const result = await t.api.publishAgent(t.id, {
|
|
251
|
+
...(flagString(ctx, 'notes') ? { notes: flagString(ctx, 'notes')! } : {}),
|
|
252
|
+
expectedRevision: explicit !== undefined ? Number(explicit) : draft.revision,
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
return { data: result, text: `Published ${t.ref} from draft revision ${draft.revision}.` }
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const diff: Command = {
|
|
260
|
+
meta: {
|
|
261
|
+
noun: 'agent',
|
|
262
|
+
verb: 'diff',
|
|
263
|
+
args: [{ name: 'agent', required: true, description: 'agent id or slug' }],
|
|
264
|
+
flags: {},
|
|
265
|
+
summary: 'Show which sections of the draft differ from what is live',
|
|
266
|
+
examples: ['frontera agent diff sei'],
|
|
267
|
+
},
|
|
268
|
+
async run(ctx) {
|
|
269
|
+
const t = await target(ctx, 'diff')
|
|
270
|
+
const draft = await t.api.agentDraft(t.id)
|
|
271
|
+
|
|
272
|
+
if (!draft) {
|
|
273
|
+
return { data: { hasDraft: false, changed: [] }, text: 'No draft staged — the live version is current.' }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const live = (await t.api.agent(t.id)) as Record<string, unknown>
|
|
277
|
+
const liveConfig = (live.config ?? live) as Record<string, unknown>
|
|
278
|
+
const draftConfig = (draft.snapshot.config ?? {}) as Record<string, unknown>
|
|
279
|
+
|
|
280
|
+
// Only what the DRAFT declares, and never server-managed bookkeeping.
|
|
281
|
+
// Taking the union instead reported `createdAt`, `createdBy` and
|
|
282
|
+
// `currentVersion` as pending changes on a draft that touched none of
|
|
283
|
+
// them — noise that buries the one field the caller actually staged.
|
|
284
|
+
const MANAGED = new Set([
|
|
285
|
+
'id', 'orgId', 'workspaceId', 'createdAt', 'updatedAt', 'createdBy',
|
|
286
|
+
'currentVersion', 'currentVersionId', 'draft', 'lifecycleStatus',
|
|
287
|
+
])
|
|
288
|
+
const changed = Object.keys(draftConfig)
|
|
289
|
+
.filter((k) => !MANAGED.has(k))
|
|
290
|
+
.filter((k) => JSON.stringify(draftConfig[k]) !== JSON.stringify(liveConfig[k]))
|
|
291
|
+
.sort()
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
data: { hasDraft: true, revision: draft.revision, changed },
|
|
295
|
+
text:
|
|
296
|
+
changed.length === 0
|
|
297
|
+
? `Draft revision ${draft.revision} matches the live version.`
|
|
298
|
+
: [`Draft revision ${draft.revision} differs in:`, ...changed.map((k) => ` ${k}`)].join('\n'),
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const discard: Command = {
|
|
304
|
+
meta: {
|
|
305
|
+
noun: 'agent',
|
|
306
|
+
verb: 'discard',
|
|
307
|
+
args: [{ name: 'agent', required: true, description: 'agent id or slug' }],
|
|
308
|
+
flags: {},
|
|
309
|
+
summary: 'Throw away the staged draft, leaving the live version untouched',
|
|
310
|
+
examples: ['frontera agent discard sei'],
|
|
311
|
+
},
|
|
312
|
+
async run(ctx) {
|
|
313
|
+
const t = await target(ctx, 'discard')
|
|
314
|
+
await t.api.discardAgentDraft(t.id)
|
|
315
|
+
return { data: { discarded: true, agent: t.ref }, text: `Discarded the draft for ${t.ref}.` }
|
|
316
|
+
},
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const versions: Command = {
|
|
320
|
+
meta: {
|
|
321
|
+
noun: 'agent',
|
|
322
|
+
verb: 'versions',
|
|
323
|
+
args: [{ name: 'agent', required: true, description: 'agent id or slug' }],
|
|
324
|
+
flags: {},
|
|
325
|
+
summary: 'List published versions of one agent',
|
|
326
|
+
examples: ['frontera agent versions sei'],
|
|
327
|
+
},
|
|
328
|
+
async run(ctx) {
|
|
329
|
+
const t = await target(ctx, 'versions')
|
|
330
|
+
const rows = (await t.api.agentVersions(t.id)) as Array<Record<string, unknown>>
|
|
331
|
+
return {
|
|
332
|
+
data: rows,
|
|
333
|
+
text:
|
|
334
|
+
rows.length === 0
|
|
335
|
+
? 'No versions published yet.'
|
|
336
|
+
: table(
|
|
337
|
+
['version', 'published', 'by'],
|
|
338
|
+
rows.map((v) => [
|
|
339
|
+
String(v.versionNumber ?? v.version ?? '?'),
|
|
340
|
+
String(v.publishedAt ?? ''),
|
|
341
|
+
String(v.publishedBy ?? ''),
|
|
342
|
+
]),
|
|
343
|
+
),
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export const agentCommands: Command[] = [list, get, apply, publish, diff, discard, versions]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { PlatformApi } from '../../api/platform-api'
|
|
2
|
+
import { CliError } from '../../errors'
|
|
3
|
+
|
|
4
|
+
export interface AgentRow {
|
|
5
|
+
id?: string
|
|
6
|
+
/** `agent_configs.agentId` — the SLUG, despite the column name. */
|
|
7
|
+
agentId?: string
|
|
8
|
+
name?: string
|
|
9
|
+
kind?: string
|
|
10
|
+
lifecycleStatus?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Turn an id or a slug into the id every write route needs.
|
|
17
|
+
*
|
|
18
|
+
* `GET /config/agents/:ref` resolves either, so one request answers it —
|
|
19
|
+
* deliberately NOT a scan of `agent list`, which omits agents still in
|
|
20
|
+
* `configuring`. Resolving from the list made a freshly created agent
|
|
21
|
+
* unreachable by its own slug, which is exactly the create-then-configure
|
|
22
|
+
* sequence an agent-driven caller runs.
|
|
23
|
+
*
|
|
24
|
+
* Only the draft and publish routes need this: they key on
|
|
25
|
+
* `agent_drafts.agent_id`, a uuid column, so a slug reaches postgres as a
|
|
26
|
+
* malformed uuid rather than a lookup.
|
|
27
|
+
*/
|
|
28
|
+
export async function resolveAgentRef(api: PlatformApi, ref: string): Promise<string> {
|
|
29
|
+
if (UUID.test(ref)) return ref
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const agent = (await api.agent(ref)) as { id?: string }
|
|
33
|
+
if (agent?.id) return agent.id
|
|
34
|
+
} catch (err) {
|
|
35
|
+
// A miss is worth a second request to say what DOES exist; anything else
|
|
36
|
+
// (auth, network) belongs to the caller unchanged.
|
|
37
|
+
if ((err as { code?: string }).code !== 'NOT_FOUND') throw err
|
|
38
|
+
throw await notFound(api, ref)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
throw await notFound(api, ref)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function notFound(api: PlatformApi, ref: string): Promise<CliError> {
|
|
45
|
+
const rows = await api.agents().catch(() => [] as unknown[])
|
|
46
|
+
const near = (rows as AgentRow[])
|
|
47
|
+
.filter((a) => `${a.agentId ?? ''} ${a.name ?? ''}`.toLowerCase().includes(ref.toLowerCase()))
|
|
48
|
+
.map((a) => a.agentId ?? a.id)
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
|
|
51
|
+
return new CliError(`no agent named "${ref}"`, {
|
|
52
|
+
code: 'NOT_FOUND',
|
|
53
|
+
hint:
|
|
54
|
+
near.length > 0
|
|
55
|
+
? `did you mean ${near.slice(0, 3).join(', ')}?`
|
|
56
|
+
: 'run `frontera agent list` to see ids and slugs',
|
|
57
|
+
})
|
|
58
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { RegistryApi, type RegistryItem } from '../../api/registry-api'
|
|
2
|
+
import { UsageError } from '../../errors'
|
|
3
|
+
import { missingDependencies, writeRegistryItems } from '../../registry'
|
|
4
|
+
import { flagBool, type Command } from '../types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Copy registry source into this project.
|
|
8
|
+
*
|
|
9
|
+
* Collapses the former `ui add` and `skills add`: the registry already tags
|
|
10
|
+
* every item with a `type` and already resolves transitive dependencies, so
|
|
11
|
+
* two verbs were distinguishing something the caller does not have to know.
|
|
12
|
+
* With `skill` now meaning the platform resource, keeping a second `skills`
|
|
13
|
+
* verb here would have put one word on two unrelated things.
|
|
14
|
+
*/
|
|
15
|
+
export const appAdd: Command = {
|
|
16
|
+
meta: {
|
|
17
|
+
noun: 'app',
|
|
18
|
+
verb: 'add',
|
|
19
|
+
args: [{ name: 'name...', required: false, description: 'registry items to copy; omit to list what is available' }],
|
|
20
|
+
flags: { force: 'boolean' },
|
|
21
|
+
summary: 'Copy components and authoring skills from the registry into this project',
|
|
22
|
+
examples: [
|
|
23
|
+
'frontera app add',
|
|
24
|
+
'frontera app add data-table',
|
|
25
|
+
'frontera app add data-table chart --force',
|
|
26
|
+
],
|
|
27
|
+
// Listing the registry is how someone decides what to scaffold, so it must
|
|
28
|
+
// work outside a project; writing into one still requires it (below).
|
|
29
|
+
optionalProject: true,
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
async run(ctx) {
|
|
33
|
+
const registry = new RegistryApi(ctx.apiUrl)
|
|
34
|
+
const names = ctx.positional
|
|
35
|
+
|
|
36
|
+
if (names.length === 0) {
|
|
37
|
+
const items = await registry.list()
|
|
38
|
+
return {
|
|
39
|
+
data: items,
|
|
40
|
+
text:
|
|
41
|
+
items.length === 0
|
|
42
|
+
? 'Nothing published to the registry yet.'
|
|
43
|
+
: items.map((i) => ` ${i.name.padEnd(24)} ${i.type.padEnd(6)} ${i.description}`).join('\n'),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const project = ctx.project
|
|
48
|
+
if (!project) {
|
|
49
|
+
throw new UsageError(
|
|
50
|
+
'not in a Frontera app directory',
|
|
51
|
+
'cd into an app project, or run `frontera app init <name>` — `frontera app add` with no arguments lists the registry from anywhere',
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Resolve every requested item BEFORE writing anything, so one bad name
|
|
56
|
+
// fails before the project is half-updated.
|
|
57
|
+
const resolved = new Map<string, RegistryItem>()
|
|
58
|
+
for (const name of names) {
|
|
59
|
+
for (const item of await registry.resolve(name)) resolved.set(item.name, item)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const result = writeRegistryItems(project.root, [...resolved.values()], {
|
|
63
|
+
force: flagBool(ctx, 'force'),
|
|
64
|
+
})
|
|
65
|
+
const missing = missingDependencies(project.root, result.npmDependencies)
|
|
66
|
+
|
|
67
|
+
const lines = [
|
|
68
|
+
...result.written.map((f) => ` + ${f}`),
|
|
69
|
+
...result.skipped.map((f) => ` · ${f} (exists — use --force to replace)`),
|
|
70
|
+
]
|
|
71
|
+
if (missing.length > 0) lines.push('', `Install the packages these need:`, ` bun add ${missing.join(' ')}`)
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
data: { written: result.written, skipped: result.skipped, missingDependencies: missing },
|
|
75
|
+
text: lines.join('\n'),
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { AppsApi } from '../../api/apps-api'
|
|
5
|
+
import { CliError } from '../../errors'
|
|
6
|
+
import { resolveManifest } from '../../manifest'
|
|
7
|
+
import { collectSourceFiles, formatBytes } from '../../packaging'
|
|
8
|
+
import { packDirectory } from '../../pack'
|
|
9
|
+
import { readPackageVersion, writeAppId, writeState } from '../../project'
|
|
10
|
+
import { createTarGz } from '../../tar'
|
|
11
|
+
import { requireProjectFrom } from './shared'
|
|
12
|
+
import { flagBool, flagString, type Command } from '../types'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Publish, and say the useful thing when the version is taken.
|
|
16
|
+
*
|
|
17
|
+
* Versions are immutable, so republishing one is a CONFLICT — but the generic
|
|
18
|
+
* conflict hint says "re-fetch, reapply your edit", which is the recovery for a
|
|
19
|
+
* DRAFT conflict and does nothing here. The fix is to bump a number, and this
|
|
20
|
+
* is the only layer that knows that.
|
|
21
|
+
*/
|
|
22
|
+
async function publish(
|
|
23
|
+
client: AppsApi,
|
|
24
|
+
appId: string,
|
|
25
|
+
args: Parameters<AppsApi['publish']>[1],
|
|
26
|
+
): Promise<{ version: string; promoted: boolean }> {
|
|
27
|
+
try {
|
|
28
|
+
return await client.publish(appId, args)
|
|
29
|
+
} catch (err) {
|
|
30
|
+
const code = (err as { code?: string }).code
|
|
31
|
+
if (code === 'CONFLICT' || code === 'DRAFT_CONFLICT') {
|
|
32
|
+
throw new CliError(`version ${args.version} already exists for this app`, {
|
|
33
|
+
code: 'CONFLICT',
|
|
34
|
+
hint: 'versions are immutable — bump "version" in package.json, or pass --version <next>',
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
throw err
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const appDeploy: Command = {
|
|
42
|
+
meta: {
|
|
43
|
+
noun: 'app',
|
|
44
|
+
verb: 'deploy',
|
|
45
|
+
args: [],
|
|
46
|
+
flags: { version: 'string', 'no-promote': 'boolean' },
|
|
47
|
+
summary: 'Build output up to an immutable version, and promote it',
|
|
48
|
+
examples: ['frontera app deploy', 'frontera app deploy --no-promote'],
|
|
49
|
+
needsProject: true,
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
async run(ctx) {
|
|
53
|
+
const project = requireProjectFrom(ctx)
|
|
54
|
+
const version = flagString(ctx, 'version') ?? readPackageVersion(project.root)
|
|
55
|
+
const distDir = join(project.root, 'dist')
|
|
56
|
+
|
|
57
|
+
if (!existsSync(join(distDir, 'index.html'))) {
|
|
58
|
+
throw new CliError('dist/index.html not found', {
|
|
59
|
+
code: 'VALIDATION_ERROR',
|
|
60
|
+
hint: 'run your build first: bun run build',
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const client = new AppsApi(ctx.apiUrl, ctx.token)
|
|
65
|
+
const app = await client.ensureApp(project.slug, project.displayName, project.appId)
|
|
66
|
+
|
|
67
|
+
const distFiles = collectSourceFiles(distDir)
|
|
68
|
+
ctx.output.note(
|
|
69
|
+
` bundle ${distFiles.length} files, ${formatBytes(distFiles.reduce((n, f) => n + f.bytes.byteLength, 0))}`,
|
|
70
|
+
)
|
|
71
|
+
const dist = createTarGz(distFiles)
|
|
72
|
+
const { tgz: source } = packDirectory(project.root, ctx.output, 'packaged')
|
|
73
|
+
|
|
74
|
+
// Synthesised from package.json when the build emitted none, so the CSP
|
|
75
|
+
// domains an author declared actually reach the served headers.
|
|
76
|
+
const manifest = resolveManifest(distDir, project)
|
|
77
|
+
const reach = [...manifest.connectDomains, ...manifest.resourceDomains]
|
|
78
|
+
if (reach.length > 0) ctx.output.note(` may reach ${reach.join(', ')}`)
|
|
79
|
+
|
|
80
|
+
const promote = !flagBool(ctx, 'no-promote')
|
|
81
|
+
const res = await publish(client, app.id, {
|
|
82
|
+
version,
|
|
83
|
+
parentVersion: project.parentVersion,
|
|
84
|
+
dist,
|
|
85
|
+
source,
|
|
86
|
+
manifest,
|
|
87
|
+
promote,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
writeState(project.root, { appId: app.id, parentVersion: res.version })
|
|
91
|
+
// Committed alongside the code, so a clone deploys to THIS app even after
|
|
92
|
+
// the slug is renamed — and any stale slug is dropped.
|
|
93
|
+
writeAppId(project.root, app.id)
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
data: { app: app.slug, appId: app.id, version: res.version, promoted: res.promoted },
|
|
97
|
+
// The slug as the PLATFORM knows it: `project.slug` is a bootstrap name
|
|
98
|
+
// and goes stale the moment the app is renamed, so printing it would
|
|
99
|
+
// report a deploy to an app that no longer exists.
|
|
100
|
+
text:
|
|
101
|
+
`Published ${app.slug}@${res.version}` +
|
|
102
|
+
(res.promoted ? ' and promoted it' : ' (not promoted — use `frontera app promote`)'),
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { UsageError } from '../../errors'
|
|
4
|
+
import { scaffold, scaffoldFiles } from '../../template'
|
|
5
|
+
import { writeHarnessFiles } from '../../harness'
|
|
6
|
+
import type { Command } from '../types'
|
|
7
|
+
|
|
8
|
+
export const appInit: Command = {
|
|
9
|
+
meta: {
|
|
10
|
+
noun: 'app',
|
|
11
|
+
verb: 'init',
|
|
12
|
+
args: [{ name: 'name', required: true, description: 'directory and bootstrap slug for the new app' }],
|
|
13
|
+
flags: { dir: 'string' },
|
|
14
|
+
summary: 'Scaffold a new Frontera app project',
|
|
15
|
+
examples: ['frontera app init shipments-console'],
|
|
16
|
+
// Scaffolding must work before a credential exists.
|
|
17
|
+
offline: true,
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
async run(ctx) {
|
|
21
|
+
const name = ctx.positional[0]
|
|
22
|
+
if (!name) throw new UsageError('missing <name>', 'frontera app init <name>')
|
|
23
|
+
|
|
24
|
+
const base = typeof ctx.flags.dir === 'string' ? ctx.flags.dir : ctx.cwd
|
|
25
|
+
const target = join(base, name)
|
|
26
|
+
scaffold(target, name)
|
|
27
|
+
// The app project is also a directory a harness will work in, so it gets
|
|
28
|
+
// the same AGENTS.md + CLI skill that `frontera init` writes.
|
|
29
|
+
const harness = writeHarnessFiles(target)
|
|
30
|
+
|
|
31
|
+
// The SDK is written into `src/frontera/` rather than declared as a
|
|
32
|
+
// dependency, so `package.json` asks for nothing but public npm and this
|
|
33
|
+
// installs on a laptop or in a sandbox that has never seen the monorepo.
|
|
34
|
+
// Reported here because it is the one surprising thing about the tree, and
|
|
35
|
+
// an agent that does not know it will try to "fix" the imports.
|
|
36
|
+
const vendored = Object.keys(scaffoldFiles(name)).filter((f) =>
|
|
37
|
+
f.startsWith('src/frontera/'),
|
|
38
|
+
).length
|
|
39
|
+
ctx.output.note(` SDK vendored into src/frontera/ (${vendored} files) — imports resolve by alias`)
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
data: { name, path: target, harnessFiles: harness.written, vendoredSdkFiles: vendored },
|
|
43
|
+
text: [
|
|
44
|
+
`Scaffolded ${name}`,
|
|
45
|
+
'',
|
|
46
|
+
` cd ${name}`,
|
|
47
|
+
' bun install',
|
|
48
|
+
' bun run build',
|
|
49
|
+
' frontera app deploy',
|
|
50
|
+
].join('\n'),
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { PlatformApi } from '../../api/platform-api'
|
|
2
|
+
import { table } from '../../table'
|
|
3
|
+
import type { Command } from '../types'
|
|
4
|
+
|
|
5
|
+
interface AppRow {
|
|
6
|
+
id?: string
|
|
7
|
+
slug?: string
|
|
8
|
+
displayName?: string
|
|
9
|
+
status?: string
|
|
10
|
+
deployedVersionId?: string | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Frontera Apps in this workspace.
|
|
15
|
+
*
|
|
16
|
+
* Every other `app` verb resolves a project from the filesystem, which left no
|
|
17
|
+
* way to see what exists on the platform — you had to already know a slug to
|
|
18
|
+
* pull one. This is the entry point that makes the rest discoverable, and it
|
|
19
|
+
* needs no project.
|
|
20
|
+
*/
|
|
21
|
+
export const appList: Command = {
|
|
22
|
+
meta: {
|
|
23
|
+
noun: 'app',
|
|
24
|
+
verb: 'list',
|
|
25
|
+
args: [],
|
|
26
|
+
flags: {},
|
|
27
|
+
summary: 'List Frontera Apps in this workspace',
|
|
28
|
+
examples: ['frontera app list', 'frontera app list --json'],
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
async run(ctx) {
|
|
32
|
+
const rows = (await new PlatformApi(ctx.apiUrl, ctx.token).platformApps()) as AppRow[]
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
data: rows,
|
|
36
|
+
text:
|
|
37
|
+
rows.length === 0
|
|
38
|
+
? 'No apps in this workspace. Create one with `frontera app init <name>`.'
|
|
39
|
+
: table(
|
|
40
|
+
['slug', 'name', 'live', 'status', 'id'],
|
|
41
|
+
rows.map((a) => [
|
|
42
|
+
a.slug ?? '?',
|
|
43
|
+
a.displayName ?? '',
|
|
44
|
+
a.deployedVersionId ? 'yes' : 'no',
|
|
45
|
+
a.status ?? '',
|
|
46
|
+
a.id ?? '',
|
|
47
|
+
]),
|
|
48
|
+
),
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
}
|