@frontera-sdk/cli 1.44.1 → 1.45.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +65 -1
  2. package/package.json +4 -3
  3. package/src/api/automation-api.ts +15 -0
  4. package/src/api/dataset-api.ts +99 -0
  5. package/src/api/governed-action-api.ts +80 -0
  6. package/src/api/platform-api.ts +293 -0
  7. package/src/auth-verify.ts +105 -0
  8. package/src/binding-registry.ts +87 -0
  9. package/src/commands/action/deploy.ts +1 -0
  10. package/src/commands/action/grant.ts +1 -0
  11. package/src/commands/action/index-commands.ts +8 -0
  12. package/src/commands/action/prepare.ts +1 -0
  13. package/src/commands/action/requests.ts +111 -0
  14. package/src/commands/action/review.ts +1 -0
  15. package/src/commands/agent/index-commands.ts +189 -7
  16. package/src/commands/app/init.ts +1 -1
  17. package/src/commands/app/pull.ts +1 -1
  18. package/src/commands/auth/add.ts +145 -0
  19. package/src/commands/auth/current.ts +82 -0
  20. package/src/commands/auth/index-commands.ts +16 -0
  21. package/src/commands/auth/list.ts +71 -0
  22. package/src/commands/auth/remove.ts +80 -0
  23. package/src/commands/auth/use.ts +84 -0
  24. package/src/commands/auth/verify.ts +93 -0
  25. package/src/commands/automation/run.ts +41 -2
  26. package/src/commands/blueprint/query.ts +294 -0
  27. package/src/commands/capability/index-commands.ts +334 -0
  28. package/src/commands/dataset/index-commands.ts +103 -14
  29. package/src/commands/kit/doctor.ts +101 -0
  30. package/src/commands/kit/index-commands.ts +7 -0
  31. package/src/commands/kit/shared.ts +52 -0
  32. package/src/commands/kit/status.ts +92 -0
  33. package/src/commands/kit/sync.ts +106 -0
  34. package/src/commands/kit/vendor.ts +120 -0
  35. package/src/commands/knowledge/index-commands.ts +165 -0
  36. package/src/commands/login.ts +64 -84
  37. package/src/commands/plugin/index-commands.ts +284 -21
  38. package/src/commands/registry.ts +104 -1
  39. package/src/commands/setup.ts +248 -0
  40. package/src/commands/source/index-commands.ts +446 -0
  41. package/src/commands/types.ts +14 -0
  42. package/src/config.ts +197 -100
  43. package/src/credential-store.ts +273 -0
  44. package/src/dev-env.ts +3 -3
  45. package/src/exit.ts +29 -2
  46. package/src/flag-help.ts +65 -3
  47. package/src/fs-atomic.ts +44 -0
  48. package/src/harness.ts +155 -4
  49. package/src/kit.ts +431 -0
  50. package/src/main.ts +13 -1
  51. package/src/paths.ts +43 -0
  52. package/src/profile-migration.ts +101 -0
  53. package/src/profiles.ts +240 -0
  54. package/src/project-context.ts +178 -0
  55. package/src/prompt.ts +23 -0
  56. package/src/templates/next-app-files.ts +4 -1
  57. package/src/vendor/kit-assets.json +60 -0
  58. package/src/vendor/sdk-sources.json +1 -1
package/src/kit.ts ADDED
@@ -0,0 +1,431 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { dirname, join, resolve as resolvePath } from 'node:path'
5
+
6
+ import { CliError } from './errors'
7
+ import vendored from './vendor/kit-assets.json'
8
+
9
+ /**
10
+ * The authoring kit, as the CLI carries it.
11
+ *
12
+ * The bytes are pinned at build time (`scripts/sync-kit.ts`), so vendoring is
13
+ * offline and deterministic: the same CLI release writes the same instructions
14
+ * on any machine, on any day, with no network. That is what makes a lock file
15
+ * over them mean anything, and it is why upgrading a project's instructions is
16
+ * a CLI upgrade rather than a silent fetch at session start.
17
+ */
18
+ export interface KitAssets {
19
+ kitVersion: string
20
+ cli: { minimum: string; maximumExclusive: string }
21
+ hosts: { codex: boolean; claudeCode: boolean }
22
+ assets: Record<string, string>
23
+ agentsBlock: string
24
+ claudeBlock: string
25
+ pluginManifests: { claudeCode: string; codex: string }
26
+ marketplaceManifests: { claudeCode: string; codex: string }
27
+ /** `plugin/assets/**`, base64, keyed relative to `plugin/`. */
28
+ pluginAssets: Record<string, string>
29
+ }
30
+
31
+ export const KIT: KitAssets = vendored as KitAssets
32
+
33
+ export const LOCK_FILE = 'frontera.kit.lock.json'
34
+
35
+ export const MANAGED_BEGIN = '<!-- frontera:begin — managed by `frontera kit`; edits here are replaced -->'
36
+ export const MANAGED_END = '<!-- frontera:end -->'
37
+
38
+ export interface KitLock {
39
+ schemaVersion: 1
40
+ kitVersion: string
41
+ cliVersion: string
42
+ /** Project-relative path → digest of the bytes this CLI wrote. */
43
+ files: Record<string, string>
44
+ }
45
+
46
+ export function digest(content: string): string {
47
+ return `sha256:${createHash('sha256').update(content).digest('hex').slice(0, 16)}`
48
+ }
49
+
50
+ /**
51
+ * The managed block, delimited and bounded.
52
+ *
53
+ * Whole-file generation is what makes a tool unwelcome in a repository someone
54
+ * else owns: a team's AGENTS.md carries their conventions, and replacing it to
55
+ * add five lines is not a trade they agreed to. Only what sits between the
56
+ * markers is ours.
57
+ */
58
+ export function managedBlock(body: string): string {
59
+ return `${MANAGED_BEGIN}\n${body.trim()}\n${MANAGED_END}\n`
60
+ }
61
+
62
+ /** Insert or replace the managed block, leaving every other line where it is. */
63
+ export function applyManagedBlock(existing: string | null, body: string): string {
64
+ const block = managedBlock(body)
65
+ if (existing === null || existing.trim() === '') return block
66
+
67
+ const begin = existing.indexOf(MANAGED_BEGIN)
68
+ const end = existing.indexOf(MANAGED_END)
69
+ if (begin !== -1 && end > begin) {
70
+ const before = existing.slice(0, begin)
71
+ const after = existing.slice(end + MANAGED_END.length).replace(/^\n/, '')
72
+ return `${before}${block}${after}`
73
+ }
74
+
75
+ // Appended, not prepended. The first lines of an AGENTS.md are where a team
76
+ // puts what matters most to them, and a tool that inserts itself above that
77
+ // has decided its own instructions outrank theirs.
78
+ const separator = existing.endsWith('\n') ? '\n' : '\n\n'
79
+ return `${existing}${separator}${block}`
80
+ }
81
+
82
+ /**
83
+ * Claude Code imports with `@AGENTS.md`.
84
+ *
85
+ * `./AGENTS.md` is a relative path Claude does not import — it reads as prose,
86
+ * so the contract silently never loads. Generated projects shipped exactly that
87
+ * line, which is why this repairs it rather than only writing it correctly for
88
+ * new projects.
89
+ */
90
+ export function applyClaudeImport(existing: string | null, importLine: string): { content: string; repaired: boolean } {
91
+ const line = importLine.trim()
92
+ if (existing === null || existing.trim() === '') return { content: `${line}\n`, repaired: false }
93
+
94
+ const lines = existing.split('\n')
95
+ const broken = lines.findIndex((l) => l.trim() === './AGENTS.md' || l.trim() === '@./AGENTS.md')
96
+ if (broken !== -1) {
97
+ lines[broken] = line
98
+ return { content: lines.join('\n'), repaired: true }
99
+ }
100
+
101
+ if (lines.some((l) => l.trim() === line)) return { content: existing, repaired: false }
102
+
103
+ // An import has to be reachable, so it goes first — unlike the AGENTS.md
104
+ // block, this single line is the mechanism rather than instructions competing
105
+ // for a reader's attention.
106
+ return { content: `${line}\n\n${existing.replace(/^\n+/, '')}`, repaired: false }
107
+ }
108
+
109
+ export interface RenderedProject {
110
+ /** Generated files: fully owned by the kit, replaceable, digest-tracked. */
111
+ generated: Record<string, string>
112
+ /** Files the kit edits in place without owning. */
113
+ agentsMd: string | null
114
+ claudeMd: string | null
115
+ }
116
+
117
+ /** One source, two native projections — Codex reads neither host's other tree. */
118
+ export function renderKit(kit: KitAssets = KIT): RenderedProject {
119
+ const generated: Record<string, string> = {}
120
+ for (const rel of Object.keys(kit.assets).sort()) {
121
+ const content = kit.assets[rel]!
122
+ if (kit.hosts.codex) generated[join('.agents', 'skills', rel)] = content
123
+ if (kit.hosts.claudeCode) generated[join('.claude', 'skills', rel)] = content
124
+ }
125
+ return {
126
+ generated,
127
+ agentsMd: kit.hosts.codex ? kit.agentsBlock : null,
128
+ claudeMd: kit.hosts.claudeCode ? kit.claudeBlock : null,
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Write a complete, installable plugin marketplace to disk.
134
+ *
135
+ * This is what makes distribution possible without anything public. Both hosts
136
+ * accept a local DIRECTORY as a marketplace, and the CLI already carries every
137
+ * byte of the kit pinned at build time — so `frontera setup` can lay out a real
138
+ * marketplace on a machine with no checkout, no git remote and no network, and
139
+ * point the host at it. No repository to publish, no directory to submit to,
140
+ * and no customer's plugin tree leaving their machine.
141
+ *
142
+ * The layout mirrors the authoring repository exactly, because that is what the
143
+ * hosts validate against:
144
+ *
145
+ * <root>/.claude-plugin/marketplace.json Claude Code reads this
146
+ * <root>/.agents/plugins/marketplace.json Codex prefers this
147
+ * <root>/plugin/.claude-plugin/plugin.json
148
+ * <root>/plugin/.codex-plugin/plugin.json
149
+ * <root>/plugin/skills/<name>/SKILL.md
150
+ *
151
+ * Fully rewritten each time: it is generated data with a version stamp, not
152
+ * somewhere anyone should be editing.
153
+ */
154
+ export function materializeMarketplace(root: string, kit: KitAssets = KIT): string[] {
155
+ const files: Record<string, string> = {
156
+ [join('.claude-plugin', 'marketplace.json')]: kit.marketplaceManifests.claudeCode,
157
+ [join('.agents', 'plugins', 'marketplace.json')]: kit.marketplaceManifests.codex,
158
+ [join('plugin', '.claude-plugin', 'plugin.json')]: kit.pluginManifests.claudeCode,
159
+ [join('plugin', '.codex-plugin', 'plugin.json')]: kit.pluginManifests.codex,
160
+ }
161
+ for (const [rel, content] of Object.entries(kit.assets)) {
162
+ files[join('plugin', 'skills', rel)] = content
163
+ }
164
+
165
+ // A stamp, so a later `setup` can tell whether the tree on disk is this
166
+ // CLI's kit without diffing every file.
167
+ files['frontera-kit.json'] = `${JSON.stringify({ kitVersion: kit.kitVersion, materializedBy: 'frontera setup' }, null, 2)}\n`
168
+
169
+ const written: string[] = []
170
+ for (const [rel, content] of Object.entries(files).sort(([a], [b]) => a.localeCompare(b))) {
171
+ const dest = join(root, rel)
172
+ mkdirSync(dirname(dest), { recursive: true })
173
+ writeFileSync(dest, content)
174
+ written.push(rel)
175
+ }
176
+
177
+ // Binary assets — the icon the host shows. Written from base64 so a PNG can
178
+ // ride inside the vendored JSON.
179
+ for (const [rel, base64] of Object.entries(kit.pluginAssets ?? {}).sort(([a], [b]) => a.localeCompare(b))) {
180
+ const dest = join(root, 'plugin', rel)
181
+ mkdirSync(dirname(dest), { recursive: true })
182
+ writeFileSync(dest, Buffer.from(base64, 'base64'))
183
+ written.push(join('plugin', rel))
184
+ }
185
+
186
+ return written
187
+ }
188
+
189
+ /** The kit version already materialized at `root`, if any. */
190
+ export function materializedVersion(root: string): string | null {
191
+ const stamp = join(root, 'frontera-kit.json')
192
+ if (!existsSync(stamp)) return null
193
+ try {
194
+ return (JSON.parse(readFileSync(stamp, 'utf8')) as { kitVersion?: string }).kitVersion ?? null
195
+ } catch {
196
+ return null
197
+ }
198
+ }
199
+
200
+ export function readLock(root: string): KitLock | null {
201
+ const path = join(root, LOCK_FILE)
202
+ if (!existsSync(path)) return null
203
+ try {
204
+ return JSON.parse(readFileSync(path, 'utf8')) as KitLock
205
+ } catch {
206
+ throw new CliError(`${LOCK_FILE} is not readable JSON`, {
207
+ code: 'GENERATED_FILE_CONFLICT',
208
+ hint: `delete ${LOCK_FILE} and run \`frontera kit vendor\` again`,
209
+ })
210
+ }
211
+ }
212
+
213
+ export interface WriteReport {
214
+ written: string[]
215
+ unchanged: string[]
216
+ conflicts: string[]
217
+ repaired: string[]
218
+ }
219
+
220
+ /**
221
+ * Write the generated tree.
222
+ *
223
+ * The rule that keeps this safe to re-run: a file is replaced only when its
224
+ * current bytes are the bytes this CLI last wrote. Anything else is a local
225
+ * edit, which is reported as a conflict rather than silently discarded —
226
+ * someone changed a skill for a reason, and finding out by losing it is the
227
+ * worst way to learn the tool regenerates.
228
+ */
229
+ export function writeGenerated(
230
+ root: string,
231
+ generated: Record<string, string>,
232
+ lock: KitLock | null,
233
+ opts: { force?: boolean; onlyExisting?: boolean } = {},
234
+ ): WriteReport {
235
+ const report: WriteReport = { written: [], unchanged: [], conflicts: [], repaired: [] }
236
+
237
+ for (const [rel, content] of Object.entries(generated)) {
238
+ const dest = join(root, rel)
239
+ const exists = existsSync(dest)
240
+
241
+ if (!exists) {
242
+ // `sync` updates what vendoring already put here; it does not adopt a
243
+ // project that never opted in.
244
+ if (opts.onlyExisting) continue
245
+ mkdirSync(dirname(dest), { recursive: true })
246
+ writeFileSync(dest, content)
247
+ report.written.push(rel)
248
+ continue
249
+ }
250
+
251
+ const current = readFileSync(dest, 'utf8')
252
+ if (current === content) {
253
+ report.unchanged.push(rel)
254
+ continue
255
+ }
256
+
257
+ const recorded = lock?.files[rel]
258
+ if (opts.force || (recorded && digest(current) === recorded)) {
259
+ writeFileSync(dest, content)
260
+ report.written.push(rel)
261
+ continue
262
+ }
263
+
264
+ report.conflicts.push(rel)
265
+ }
266
+
267
+ return report
268
+ }
269
+
270
+ export function writeLock(root: string, generated: Record<string, string>, cliVersion: string): KitLock {
271
+ const lock: KitLock = {
272
+ schemaVersion: 1,
273
+ kitVersion: KIT.kitVersion,
274
+ cliVersion,
275
+ files: Object.fromEntries(Object.entries(generated).map(([rel, content]) => [rel, digest(content)])),
276
+ }
277
+ writeFileSync(join(root, LOCK_FILE), `${JSON.stringify(lock, null, 2)}\n`)
278
+ return lock
279
+ }
280
+
281
+ /** `1.45.0-dev` and `1.45.0` compare equal — the prerelease tail is noise here. */
282
+ export function compareVersions(a: string, b: string): number {
283
+ const parse = (v: string) => v.split('-')[0]!.split('.').map((n) => Number.parseInt(n, 10) || 0)
284
+ const [x, y] = [parse(a), parse(b)]
285
+ for (let i = 0; i < 3; i += 1) {
286
+ if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) - (y[i] ?? 0)
287
+ }
288
+ return 0
289
+ }
290
+
291
+ export interface Compatibility {
292
+ compatible: boolean
293
+ cliVersion: string
294
+ required: { minimum: string; maximumExclusive: string }
295
+ reason?: string
296
+ }
297
+
298
+ export function checkCompatibility(cliVersion: string, kit: KitAssets = KIT): Compatibility {
299
+ const required = kit.cli
300
+ if (compareVersions(cliVersion, required.minimum) < 0) {
301
+ return {
302
+ compatible: false,
303
+ cliVersion,
304
+ required,
305
+ reason: `this CLI is older than the kit's minimum ${required.minimum}`,
306
+ }
307
+ }
308
+ if (compareVersions(cliVersion, required.maximumExclusive) >= 0) {
309
+ return {
310
+ compatible: false,
311
+ cliVersion,
312
+ required,
313
+ reason: `this CLI is at or past the kit's ${required.maximumExclusive} boundary`,
314
+ }
315
+ }
316
+ return { compatible: true, cliVersion, required }
317
+ }
318
+
319
+ export type SkillHost = 'codex' | 'claudeCode'
320
+
321
+ export interface DuplicateSource {
322
+ skill: string
323
+ host: SkillHost
324
+ sources: string[]
325
+ }
326
+
327
+ /** Every `<dir>/<skill>/SKILL.md` under one scope, for the skills this kit ships. */
328
+ function skillsUnder(dir: string): string[] {
329
+ if (!existsSync(dir)) return []
330
+ const shipped = new Set(Object.keys(KIT.assets).map((rel) => rel.split('/')[0]!))
331
+ const found: string[] = []
332
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
333
+ if (!entry.isDirectory()) continue
334
+ // A project's own skills are not duplicates of anything of ours.
335
+ if (!shipped.has(entry.name)) continue
336
+ if (!existsSync(join(dir, entry.name, 'SKILL.md'))) continue
337
+ found.push(entry.name)
338
+ }
339
+ return found
340
+ }
341
+
342
+ /**
343
+ * Every installed plugin's `skills` directory under `<home>/.<host>/plugins`.
344
+ *
345
+ * Searched rather than constructed. Both hosts currently nest an install as
346
+ * `plugins/cache/<marketplace>/<plugin>/<version>/skills`, which no hand-written
347
+ * path guessed correctly — and a diagnostic that silently finds nothing because
348
+ * a vendor moved a directory is worse than no diagnostic, since it reports
349
+ * "one source per skill" either way. The depth cap keeps it from walking a
350
+ * plugin's own contents.
351
+ */
352
+ function pluginScopes(home: string, host: 'codex' | 'claude'): string[] {
353
+ const root = join(home, `.${host}`, 'plugins')
354
+ const out: string[] = []
355
+
356
+ const walk = (dir: string, depth: number): void => {
357
+ if (depth > 5) return
358
+ let entries
359
+ try {
360
+ entries = readdirSync(dir, { withFileTypes: true })
361
+ } catch {
362
+ // An unreadable directory is not a finding — this is a diagnostic, and
363
+ // failing it on a permissions quirk would help nobody.
364
+ return
365
+ }
366
+ for (const entry of entries) {
367
+ if (!entry.isDirectory()) continue
368
+ if (entry.name === 'skills') {
369
+ out.push(join(dir, entry.name))
370
+ // Do not descend: everything below is one plugin's own skill bodies.
371
+ continue
372
+ }
373
+ walk(join(dir, entry.name), depth + 1)
374
+ }
375
+ }
376
+
377
+ if (existsSync(root)) walk(root, 0)
378
+ return out
379
+ }
380
+
381
+ /**
382
+ * The same skill reachable twice — WITHIN one host.
383
+ *
384
+ * Grouped per host, because `.agents/skills` and `.claude/skills` in one project
385
+ * are the two required projections rather than a duplication: Codex reads only
386
+ * the first and Claude Code only the second, and reporting them against each
387
+ * other would flag every correctly vendored repository.
388
+ *
389
+ * The condition that does matter is an installed plugin AND a vendored copy in
390
+ * the same host. Codex does not merge equal skill names across scopes, so both
391
+ * appear, and a model choosing between two identical-looking skills is making a
392
+ * coin flip nobody asked it to make. Claude namespaces plugin skills, but the
393
+ * recommendation is the same for both hosts so behaviour does not depend on
394
+ * which one the person happens to be in.
395
+ *
396
+ * Evidence-based: it reports the paths it actually found, so a wrong diagnosis
397
+ * is visible rather than asserted.
398
+ */
399
+ export function findDuplicateSources(root: string, home = homedir()): DuplicateSource[] {
400
+ const scopes: Array<[SkillHost, string, string]> = [
401
+ ['codex', 'vendored', join(root, '.agents', 'skills')],
402
+ ['codex', 'user skills', join(home, '.agents', 'skills')],
403
+ ...pluginScopes(home, 'codex').map((dir) => ['codex', 'codex plugin', dir] as [SkillHost, string, string]),
404
+ ['claudeCode', 'vendored', join(root, '.claude', 'skills')],
405
+ ...pluginScopes(home, 'claude').map((dir) => ['claudeCode', 'claude plugin', dir] as [SkillHost, string, string]),
406
+ ]
407
+
408
+ const seen = new Map<string, string[]>()
409
+ // Resolved, because $HOME and the project root can be the same directory
410
+ // reached by two paths — and a scope counted twice is a duplicate that is not
411
+ // there.
412
+ const visited = new Set<string>()
413
+
414
+ for (const [host, label, dir] of scopes) {
415
+ const key = `${host}:${resolvePath(dir)}`
416
+ if (visited.has(key)) continue
417
+ visited.add(key)
418
+ for (const skill of skillsUnder(dir)) {
419
+ const at = `${host}:${skill}`
420
+ seen.set(at, [...(seen.get(at) ?? []), `${label}: ${join(dir, skill)}`])
421
+ }
422
+ }
423
+
424
+ return [...seen.entries()]
425
+ .filter(([, sources]) => sources.length > 1)
426
+ .map(([at, sources]) => {
427
+ const [host, skill] = at.split(':') as [SkillHost, string]
428
+ return { skill, host, sources }
429
+ })
430
+ .sort((a, b) => a.skill.localeCompare(b.skill) || a.host.localeCompare(b.host))
431
+ }
package/src/main.ts CHANGED
@@ -168,9 +168,21 @@ async function main(): Promise<number> {
168
168
 
169
169
  // Offline commands scaffold before a credential exists, so resolving one
170
170
  // would make `frontera init` impossible on a fresh machine.
171
+ //
172
+ // The resolver is given `cwd` — the SAME directory `--dir` already
173
+ // redirects project discovery to — so a profile selection and an app
174
+ // project are always read from one place. Two directories would mean
175
+ // `--dir ../other-customer` could edit one customer's app with another
176
+ // customer's key.
171
177
  const credential = command.meta.offline
172
178
  ? { apiUrl: typeof flags['api-url'] === 'string' ? flags['api-url'] : (process.env.FRONTERA_API_URL ?? ''), token: '' }
173
- : resolveCredential({ apiUrl: typeof flags['api-url'] === 'string' ? flags['api-url'] : undefined })
179
+ : await resolveCredential(
180
+ {
181
+ apiUrl: typeof flags['api-url'] === 'string' ? flags['api-url'] : undefined,
182
+ profile: typeof flags.profile === 'string' ? flags.profile : undefined,
183
+ },
184
+ { cwd },
185
+ )
174
186
 
175
187
  const result = await command.run({
176
188
  cwd,
package/src/paths.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ export type Env = Record<string, string | undefined>
6
+
7
+ /**
8
+ * XDG, not `~/.frontera`.
9
+ *
10
+ * The spec assigns credentials to `$XDG_CONFIG_HOME` and disposable data to
11
+ * `$XDG_CACHE_HOME`; `gh` follows it, and Supabase's `~/.supabase` is its
12
+ * fallback rather than its design. A bare dotfile directory is the shape
13
+ * nobody chose deliberately.
14
+ */
15
+ export function configDir(env: Env = process.env): string {
16
+ const base = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), '.config')
17
+ return join(base, 'frontera')
18
+ }
19
+
20
+ export function configPath(env: Env = process.env): string {
21
+ return join(configDir(env), 'config.json')
22
+ }
23
+
24
+ /**
25
+ * Durable, non-secret data the CLI owns — as distinct from configuration the
26
+ * user edits and cache anything may delete.
27
+ *
28
+ * `frontera setup` materializes an installable plugin here, so a coding host
29
+ * can be pointed at a real marketplace on a machine with no checkout, no git
30
+ * remote and no network. Deleting it costs one `frontera setup` re-run, which
31
+ * is the correct blast radius for data neither secret nor authored.
32
+ */
33
+ export function dataDir(env: Env = process.env): string {
34
+ const base = env.XDG_DATA_HOME || join(env.HOME || homedir(), '.local', 'share')
35
+ return join(base, 'frontera')
36
+ }
37
+
38
+ /** Disposable, keyed by API origin because the registry is per-deployment. */
39
+ export function cacheDir(apiUrl: string, env: Env = process.env): string {
40
+ const base = env.XDG_CACHE_HOME || join(env.HOME || homedir(), '.cache')
41
+ const key = createHash('sha256').update(apiUrl).digest('hex').slice(0, 16)
42
+ return join(base, 'frontera', key)
43
+ }
@@ -0,0 +1,101 @@
1
+ import { credentialStore } from './credential-store'
2
+ import { CliError } from './errors'
3
+ import { configPath, type Env } from './paths'
4
+ import {
5
+ fingerprint,
6
+ nameFromOrigin,
7
+ readLegacyConfig,
8
+ writeProfileConfig,
9
+ type ProfileConfig,
10
+ type ProfileMetadata,
11
+ } from './profiles'
12
+
13
+ export interface MigrationResult {
14
+ migrated: boolean
15
+ profiles: Array<{ name: string; apiUrl: string }>
16
+ }
17
+
18
+ /**
19
+ * Version 1 → version 2, once, without ever holding the only copy of a key.
20
+ *
21
+ * The order matters more than the code does. Every secret is written to the
22
+ * new profile-addressed store and READ BACK before the legacy config is
23
+ * rewritten, so a failure at any step leaves the old file intact and the old
24
+ * credentials recoverable. The alternative — rewrite the metadata first,
25
+ * migrate the secrets after — turns one bad keychain prompt into a machine
26
+ * with no working credential and no record of what it used to have.
27
+ *
28
+ * Idempotent: a config already at version 2 returns immediately, and a partial
29
+ * run simply re-copies what it already copied.
30
+ */
31
+ export async function migrateLegacyConfig(env: Env = process.env): Promise<MigrationResult> {
32
+ const legacy = readLegacyConfig(env)
33
+ if (!legacy) return { migrated: false, profiles: [] }
34
+
35
+ const origins = Object.entries(legacy.origins ?? {})
36
+ .filter((entry): entry is [string, { token: string }] => typeof entry[1]?.token === 'string' && entry[1].token.length > 0)
37
+
38
+ if (origins.length === 0) {
39
+ // Nothing recoverable. Still stamp version 2 so the legacy shape stops
40
+ // being re-read on every command.
41
+ writeProfileConfig({ schemaVersion: 2, profiles: {} }, env)
42
+ return { migrated: true, profiles: [] }
43
+ }
44
+
45
+ // The legacy secrets are already plaintext in this very file, so relocating
46
+ // them into a 0600 store is not a new disclosure — and refusing on a machine
47
+ // without a keychain would strand the only copy.
48
+ const store = credentialStore(env, { allowPlaintext: true })
49
+
50
+ const taken = new Set<string>()
51
+ const planned: Array<{ name: string; apiUrl: string; token: string }> = []
52
+
53
+ // The previous default becomes `default`, so the first command after
54
+ // upgrading behaves exactly as it did before.
55
+ const defaultOrigin = legacy.defaultOrigin
56
+ if (defaultOrigin && legacy.origins?.[defaultOrigin]?.token) {
57
+ taken.add('default')
58
+ planned.push({ name: 'default', apiUrl: defaultOrigin, token: legacy.origins[defaultOrigin]!.token! })
59
+ }
60
+
61
+ for (const [apiUrl, entry] of origins) {
62
+ if (apiUrl === defaultOrigin && taken.has('default')) continue
63
+ const name = nameFromOrigin(apiUrl, taken)
64
+ taken.add(name)
65
+ planned.push({ name, apiUrl, token: entry.token })
66
+ }
67
+
68
+ const now = new Date().toISOString()
69
+ const profiles: Record<string, ProfileMetadata> = {}
70
+
71
+ for (const { name, apiUrl, token } of planned) {
72
+ await store.set(name, token)
73
+ const readBack = await store.get(name)
74
+ if (readBack !== token) {
75
+ throw new CliError(`could not verify the migrated key for "${name}"`, {
76
+ code: 'SECURE_STORE_UNAVAILABLE',
77
+ hint:
78
+ `your existing credentials are untouched at ${configPath(env)} — `
79
+ + 'retry, or set FRONTERA_SECRET_STORE=file and run any command again',
80
+ })
81
+ }
82
+ profiles[name] = {
83
+ apiUrl,
84
+ // The legacy file recorded no kind. The prefix is the only evidence, and
85
+ // `auth verify` refreshes the rest on demand.
86
+ credentialKind: token.startsWith('sk-org-') ? 'organization' : 'workspace',
87
+ workspaceId: null,
88
+ orgId: null,
89
+ fingerprint: fingerprint(token),
90
+ createdAt: now,
91
+ lastVerifiedAt: now,
92
+ }
93
+ }
94
+
95
+ // Only now: every secret is stored AND verified readable, so dropping the
96
+ // plaintext tokens cannot lose one.
97
+ const next: ProfileConfig = { schemaVersion: 2, profiles }
98
+ writeProfileConfig(next, env)
99
+
100
+ return { migrated: true, profiles: planned.map(({ name, apiUrl }) => ({ name, apiUrl })) }
101
+ }