@frontera-sdk/cli 1.44.0 → 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.
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 +419 -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 +31 -0
  58. package/src/vendor/sdk-sources.json +1 -1
@@ -31,6 +31,20 @@ export interface CommandMeta {
31
31
  optionalProject?: boolean
32
32
  /** No credential needed — scaffolding runs before a key exists. */
33
33
  offline?: boolean
34
+ /**
35
+ * Which credential actually reaches this command's endpoints.
36
+ *
37
+ * `requiresCredential` says WHETHER one is needed and nothing about which,
38
+ * so a caller holding an `sk-org-` key could not tell `blueprint list` from
39
+ * `action deploy` — the second reaches routers mounted on session-only auth
40
+ * and answers 401 no matter how valid the key is. That failure is
41
+ * indistinguishable from an expired credential at the call site, so the
42
+ * distinction has to be discoverable before the call.
43
+ *
44
+ * Default `api-key`: the programmatic surface is the norm, and a command
45
+ * that forgets to declare this is far more likely to be on it.
46
+ */
47
+ authLane?: 'api-key' | 'session'
34
48
  /**
35
49
  * Present → the command is planned but unbuilt, and fails with this message.
36
50
  * Reserved in the table rather than omitted: a caller reading "unknown
package/src/config.ts CHANGED
@@ -1,142 +1,239 @@
1
- import { spawnSync } from 'node:child_process'
2
- import { createHash } from 'node:crypto'
3
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
4
- import { homedir } from 'node:os'
5
- import { dirname, join } from 'node:path'
1
+ import { existsSync } from 'node:fs'
6
2
 
3
+ import { credentialStore, type CredentialSource } from './credential-store'
7
4
  import { CliError, UsageError } from './errors'
5
+ import { migrateLegacyConfig } from './profile-migration'
6
+ import { configPath, type Env } from './paths'
7
+ import { findContext } from './project-context'
8
+ import {
9
+ assertOriginMatches,
10
+ isLegacyConfig,
11
+ listProfiles,
12
+ normalizeOrigin,
13
+ readLegacyConfig,
14
+ requireProfile,
15
+ type CredentialKind,
16
+ type ProfileMetadata,
17
+ } from './profiles'
18
+
19
+ export { cacheDir, configDir, configPath, type Env } from './paths'
8
20
 
9
21
  export interface Credential {
10
22
  apiUrl: string
11
23
  token: string
12
24
  }
13
25
 
14
- type Env = Record<string, string | undefined>
26
+ export type ProfileSource = 'flag' | 'environment' | 'directory' | 'none'
27
+ export type OriginSource = 'flag' | 'environment' | 'profile'
15
28
 
16
- interface StoredConfig {
17
- /**
18
- * The origin `frontera login` last verified.
19
- *
20
- * Without it, logging in successfully still left every following command
21
- * failing on "no API origin configured" the credential was stored under a
22
- * key nothing knew to look up. Storing the origin is what makes `login` a
23
- * complete act rather than half of one.
24
- */
25
- defaultOrigin?: string
26
- origins?: Record<string, { token?: string }>
29
+ /**
30
+ * What was resolved, and where each part came from — with no secret in it.
31
+ *
32
+ * Provenance is not decoration. "Which key is this command about to use" is the
33
+ * question an FDE has to answer before running anything against a customer, and
34
+ * an answer that says only "a key" is not one. `auth current --json` returns
35
+ * exactly this.
36
+ */
37
+ export interface CredentialResolution {
38
+ profile: string | null
39
+ profileSource: ProfileSource
40
+ profileSourcePath?: string
41
+ apiUrl: string
42
+ apiUrlSource: OriginSource
43
+ credentialSource: CredentialSource
44
+ credentialKind: CredentialKind | null
45
+ workspaceId: string | null
46
+ orgId: string | null
47
+ fingerprint?: string
27
48
  }
28
49
 
29
- const KEYCHAIN_SERVICE = 'frontera-cli'
50
+ export interface ResolvedCredential extends CredentialResolution {
51
+ token: string
52
+ }
30
53
 
31
54
  /**
32
- * XDG, not `~/.frontera`.
55
+ * The narrow slice of the credential store resolution needs.
33
56
  *
34
- * The spec assigns credentials to `$XDG_CONFIG_HOME` and disposable data to
35
- * `$XDG_CACHE_HOME`; `gh` follows it, and Supabase's `~/.supabase` is its
36
- * fallback rather than its design. A bare dotfile directory is the shape
37
- * nobody chose deliberately.
57
+ * Structural rather than the concrete store so a test can supply two profiles
58
+ * without a keychain, which is the only way the multi-profile cases are
59
+ * testable on a machine that has one.
38
60
  */
39
- export function configPath(env: Env = process.env): string {
40
- const base = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), '.config')
41
- return join(base, 'frontera', 'config.json')
61
+ export interface ResolutionStore {
62
+ locate(profile: string): Promise<{ token: string; source: CredentialSource } | null>
42
63
  }
43
64
 
44
- /** Disposable, keyed by API origin because the registry is per-deployment. */
45
- export function cacheDir(apiUrl: string, env: Env = process.env): string {
46
- const base = env.XDG_CACHE_HOME || join(env.HOME || homedir(), '.cache')
47
- const key = createHash('sha256').update(apiUrl).digest('hex').slice(0, 16)
48
- return join(base, 'frontera', key)
65
+ export interface CredentialDeps {
66
+ env?: Env
67
+ /** The directory the command is acting on — `--dir` when given, else cwd. */
68
+ cwd?: string
69
+ store?: ResolutionStore
49
70
  }
50
71
 
51
- function readStored(env: Env): StoredConfig {
52
- const path = configPath(env)
53
- if (!existsSync(path)) return {}
54
- try {
55
- return JSON.parse(readFileSync(path, 'utf8')) as StoredConfig
56
- } catch {
57
- // A corrupt config must not brick every command — treat it as absent and
58
- // let the missing-credential error say what to do.
59
- return {}
60
- }
72
+ export interface ResolveOptions {
73
+ apiUrl?: string
74
+ profile?: string
61
75
  }
62
76
 
63
77
  /**
64
- * macOS keychain read. Returns null on any failure, including "not macOS" and
65
- * "no keychain in this sandbox" — both ordinary, since the file fallback is
66
- * the hot path in CI and inside the Computer.
78
+ * Stage one: which profile.
79
+ *
80
+ * Explicit outranks inferred, and there is deliberately NO most-recently-used
81
+ * fallback. A machine-wide "last profile" is what makes a command in one
82
+ * customer's repository inherit another customer's key, which is the failure
83
+ * this whole model exists to remove — so a directory with nothing selected in
84
+ * its ancestry is an error naming the fix, not a guess.
67
85
  */
68
- function readKeychain(apiUrl: string): string | null {
69
- try {
70
- const res = spawnSync(
71
- 'security',
72
- ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-a', apiUrl, '-w'],
73
- { encoding: 'utf8' },
74
- )
75
- if (res.status !== 0) return null
76
- const value = res.stdout.trim()
77
- return value || null
78
- } catch {
79
- return null
86
+ export function resolveProfileName(
87
+ opts: ResolveOptions,
88
+ deps: CredentialDeps = {},
89
+ ): { profile: string | null; source: ProfileSource; path?: string } {
90
+ const env = deps.env ?? process.env
91
+ if (opts.profile) return { profile: opts.profile, source: 'flag' }
92
+ if (env.FRONTERA_PROFILE) return { profile: env.FRONTERA_PROFILE, source: 'environment' }
93
+
94
+ const found = findContext(deps.cwd ?? process.cwd(), env)
95
+ if (found) {
96
+ /**
97
+ * Selection is answered by the file; PROVENANCE is not.
98
+ *
99
+ * The upward walk cannot tell who wrote the context it found, and a
100
+ * repository may commit one — naming `default`, which every migrated
101
+ * machine has. Acting on it would run a customer's key inside a checkout
102
+ * the user merely cloned. So a binding this machine did not make is a
103
+ * question rather than an answer, and `auth use` is how it gets answered.
104
+ */
105
+ if (!found.trusted) {
106
+ throw new CliError(
107
+ `${found.path} selects "${found.profile}", but this machine never bound that directory`,
108
+ {
109
+ code: 'PROJECT_CONTEXT_UNTRUSTED',
110
+ hint:
111
+ `if you meant to use it, run \`frontera auth use ${found.profile}\` in ${found.root} — `
112
+ + 'a context file committed to a repository is not proof that you chose it',
113
+ },
114
+ )
115
+ }
116
+ return { profile: found.profile, source: 'directory', path: found.path }
80
117
  }
118
+
119
+ return { profile: null, source: 'none' }
81
120
  }
82
121
 
83
- export interface CredentialDeps {
84
- env?: Env
85
- keychain?: (apiUrl: string) => string | null
122
+ /** Version-1 configuration is upgraded on first access, then never again. */
123
+ export async function ensureMigrated(env: Env): Promise<void> {
124
+ if (!existsSync(configPath(env))) return
125
+ if (!readLegacyConfig(env)) return
126
+ await migrateLegacyConfig(env)
86
127
  }
87
128
 
88
129
  /**
89
- * Resolve the API origin and token.
130
+ * Resolve profile, origin and key — in that order, each with its own rules.
90
131
  *
91
- * Order: `--api-url` environment OS keychain XDG config file. The
92
- * environment keeps precedence over stored credentials so that adding storage
93
- * changes nothing for the Computer or CI, both of which set the variable.
94
- *
95
- * The token is never read from a flag. A secret in a flag lands in process
96
- * listings and shell history, and a CLI that offers the option will have it
97
- * used.
132
+ * The three stages are separate because they fail differently. A missing
133
+ * profile is a selection problem the caller fixes with `auth use`; a mismatched
134
+ * origin is a mistake that must never reach the network; a missing key is an
135
+ * authorization problem no retry helps with. Collapsing them into one
136
+ * "unauthorized" was the old behaviour and it sent every case to the same
137
+ * unhelpful hint.
98
138
  */
99
- export function resolveCredential(
100
- opts: { apiUrl?: string } = {},
139
+ export async function resolveCredential(
140
+ opts: ResolveOptions = {},
101
141
  deps: CredentialDeps = {},
102
- ): Credential {
142
+ ): Promise<ResolvedCredential> {
103
143
  const env = deps.env ?? process.env
104
- const keychain = deps.keychain ?? readKeychain
105
-
106
- const stored = readStored(env)
107
- const apiUrl = opts.apiUrl || env.FRONTERA_API_URL || stored.defaultOrigin
108
- if (!apiUrl) {
109
- throw new UsageError(
110
- 'no API origin configured',
111
- 'run `frontera login --api-url <origin>`, set FRONTERA_API_URL, or pass --api-url',
112
- )
144
+ await ensureMigrated(env)
145
+
146
+ const store = deps.store ?? credentialStore(env)
147
+ const selection = resolveProfileName(opts, deps)
148
+
149
+ const explicitOrigin = opts.apiUrl ?? env.FRONTERA_API_URL
150
+ const originSource: OriginSource = opts.apiUrl ? 'flag' : env.FRONTERA_API_URL ? 'environment' : 'profile'
151
+
152
+ /**
153
+ * A raw token in the environment is the CI and Computer path, and it is
154
+ * deliberately origin-strict: without a required explicit origin, a CI secret
155
+ * would be sent to whatever deployment the checked-out directory happens to
156
+ * select. That is a cross-customer credential leak with no visible symptom.
157
+ */
158
+ const envToken = env.FRONTERA_TOKEN?.trim()
159
+ if (envToken) {
160
+ if (!explicitOrigin) {
161
+ throw new UsageError(
162
+ 'FRONTERA_TOKEN is set but no API origin is',
163
+ 'set FRONTERA_API_URL, or pass --api-url <origin> — a raw token is never sent to a directory-selected origin',
164
+ )
165
+ }
166
+ return {
167
+ profile: selection.profile,
168
+ profileSource: selection.source,
169
+ ...(selection.path ? { profileSourcePath: selection.path } : {}),
170
+ apiUrl: explicitOrigin,
171
+ apiUrlSource: originSource,
172
+ credentialSource: 'environment',
173
+ credentialKind: envToken.startsWith('sk-org-') ? 'organization' : 'workspace',
174
+ workspaceId: null,
175
+ orgId: null,
176
+ token: envToken,
177
+ }
113
178
  }
114
179
 
115
- const token = env.FRONTERA_TOKEN || keychain(apiUrl) || stored.origins?.[apiUrl]?.token
180
+ if (!selection.profile) throw noProfileSelected(env)
181
+
182
+ const metadata = requireProfile(selection.profile, env)
183
+ if (explicitOrigin) assertOriginMatches(selection.profile, metadata.apiUrl, explicitOrigin)
116
184
 
117
- if (!token) {
118
- throw new CliError(`no credential for ${apiUrl}`, {
119
- code: 'UNAUTHORIZED',
120
- hint: `set FRONTERA_TOKEN to an sk-ws- workspace key, or store one at ${configPath(env)}`,
185
+ const located = await store.locate(selection.profile)
186
+ if (!located) {
187
+ throw new CliError(`profile "${selection.profile}" has no stored key`, {
188
+ code: 'PROFILE_SECRET_MISSING',
189
+ hint: `re-add it with \`frontera auth add ${selection.profile} --api-url ${metadata.apiUrl}\``,
121
190
  })
122
191
  }
123
192
 
124
- return { apiUrl, token }
193
+ return {
194
+ profile: selection.profile,
195
+ profileSource: selection.source,
196
+ ...(selection.path ? { profileSourcePath: selection.path } : {}),
197
+ apiUrl: metadata.apiUrl,
198
+ apiUrlSource: originSource === 'profile' ? 'profile' : originSource,
199
+ credentialSource: located.source,
200
+ credentialKind: metadata.credentialKind,
201
+ workspaceId: metadata.workspaceId,
202
+ orgId: metadata.orgId,
203
+ fingerprint: metadata.fingerprint,
204
+ token: located.token,
205
+ }
125
206
  }
126
207
 
127
- /** Persist a token for one origin, leaving any other origin's untouched. */
128
- export function writeStoredToken(apiUrl: string, token: string, env: Env = process.env): void {
129
- const path = configPath(env)
130
- const current = readStored(env)
131
- const next: StoredConfig = {
132
- ...current,
133
- // The most recent successful login becomes the default, so the next
134
- // command needs no environment at all.
135
- defaultOrigin: apiUrl,
136
- origins: { ...current.origins, [apiUrl]: { token } },
137
- }
138
- mkdirSync(dirname(path), { recursive: true })
139
- writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`)
140
- // 0600 because this holds a key with full workspace write.
141
- chmodSync(path, 0o600)
208
+ function noProfileSelected(env: Env): CliError {
209
+ const known = listProfiles(env).map((p) => p.name)
210
+ return new CliError('no Frontera profile is selected for this directory', {
211
+ code: 'PROFILE_NOT_SELECTED',
212
+ hint: known.length
213
+ ? `run \`frontera auth use <profile>\` here — you have: ${known.join(', ')}`
214
+ : 'run `frontera auth add <profile> --api-url <origin>`, then `frontera auth use <profile>`',
215
+ })
142
216
  }
217
+
218
+ /**
219
+ * The same resolution WITHOUT reading a secret.
220
+ *
221
+ * `auth current` has to work when the key is missing — that is one of the
222
+ * states it exists to report — and `auth use` has no reason to touch the
223
+ * keychain at all.
224
+ */
225
+ export async function describeSelection(
226
+ opts: ResolveOptions = {},
227
+ deps: CredentialDeps = {},
228
+ ): Promise<{
229
+ selection: ReturnType<typeof resolveProfileName>
230
+ metadata: ProfileMetadata | null
231
+ }> {
232
+ const env = deps.env ?? process.env
233
+ await ensureMigrated(env)
234
+ const selection = resolveProfileName(opts, deps)
235
+ const metadata = selection.profile ? requireProfile(selection.profile, env) : null
236
+ return { selection, metadata }
237
+ }
238
+
239
+ export { isLegacyConfig, normalizeOrigin }
@@ -0,0 +1,273 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ import { configDir, type Env } from './paths'
6
+ import { CliError } from './errors'
7
+ import { writeFileAtomic } from './fs-atomic'
8
+
9
+ /**
10
+ * Where key bytes live.
11
+ *
12
+ * Addressed by PROFILE, not by API origin. That single change is what lets two
13
+ * keys for the same deployment coexist: the old keychain record was keyed by
14
+ * origin, so the second one overwrote the first and an FDE could hold exactly
15
+ * one customer at a time.
16
+ *
17
+ * The logical identifier is:
18
+ *
19
+ * service: frontera-cli
20
+ * account: profile:<profile-name>
21
+ */
22
+ export interface CredentialStore {
23
+ readonly kind: CredentialSource
24
+ get(profile: string): Promise<string | null>
25
+ set(profile: string, token: string): Promise<void>
26
+ delete(profile: string): Promise<void>
27
+ }
28
+
29
+ export type CredentialSource = 'keychain' | 'file' | 'environment'
30
+
31
+ export const KEYCHAIN_SERVICE = 'frontera-cli'
32
+
33
+ export function accountFor(profile: string): string {
34
+ return `profile:${profile}`
35
+ }
36
+
37
+ /**
38
+ * macOS Keychain, through the `security` command already used here.
39
+ *
40
+ * The token is passed as an argument to `security`, which is the only write
41
+ * form it has: with `-w` omitted it does not read the value from stdin, it
42
+ * stores an EMPTY password — an entry that looks stored and authenticates
43
+ * nothing. The exposure is one short-lived child process on the user's own
44
+ * machine, which is a materially smaller surface than the Frontera CLI itself
45
+ * accepting `--token`, and that remains refused.
46
+ */
47
+ export class KeychainStore implements CredentialStore {
48
+ readonly kind = 'keychain' as const
49
+
50
+ async get(profile: string): Promise<string | null> {
51
+ const res = run(['find-generic-password', '-s', KEYCHAIN_SERVICE, '-a', accountFor(profile), '-w'])
52
+ if (!res || res.status !== 0) return null
53
+ const value = res.stdout.trim()
54
+ return value || null
55
+ }
56
+
57
+ async set(profile: string, token: string): Promise<void> {
58
+ const res = run([
59
+ 'add-generic-password',
60
+ '-s', KEYCHAIN_SERVICE,
61
+ '-a', accountFor(profile),
62
+ '-w', token,
63
+ // Update in place. Without it a second write fails with "already exists",
64
+ // which would make rotating a key mean deleting one first.
65
+ '-U',
66
+ ])
67
+ if (!res || res.status !== 0) {
68
+ throw new CliError('the macOS Keychain refused to store the key', {
69
+ code: 'SECURE_STORE_UNAVAILABLE',
70
+ hint: 'unlock the login keychain and retry, or set FRONTERA_SECRET_STORE=file to use a 0600 file instead',
71
+ })
72
+ }
73
+ }
74
+
75
+ async delete(profile: string): Promise<void> {
76
+ // A missing record is the desired end state, so a non-zero status here is
77
+ // not a failure worth raising.
78
+ run(['delete-generic-password', '-s', KEYCHAIN_SERVICE, '-a', accountFor(profile)])
79
+ }
80
+ }
81
+
82
+ function run(args: string[]): { status: number | null; stdout: string } | null {
83
+ try {
84
+ const res = spawnSync('security', args, { encoding: 'utf8' })
85
+ if (res.error) return null
86
+ return { status: res.status, stdout: res.stdout ?? '' }
87
+ } catch {
88
+ return null
89
+ }
90
+ }
91
+
92
+ /**
93
+ * A 0600 file beside the profile metadata.
94
+ *
95
+ * Present because the keychain does not exist in CI, in the Frontera Computer,
96
+ * or on Linux — the two places the CLI runs most often unattended. It is
97
+ * readable always and writable only on request, which is the line the design
98
+ * draws: a secret already on disk keeps working, and a NEW secret is never
99
+ * written in plaintext without the user having said so.
100
+ */
101
+ export class FileStore implements CredentialStore {
102
+ readonly kind = 'file' as const
103
+
104
+ constructor(
105
+ private readonly env: Env,
106
+ private readonly writable: boolean,
107
+ ) {}
108
+
109
+ private path(): string {
110
+ return join(configDir(this.env), 'credentials.json')
111
+ }
112
+
113
+ /**
114
+ * Absent and unreadable are DIFFERENT answers.
115
+ *
116
+ * Swallowing every failure into `{}` reads as "no keys stored", and the next
117
+ * `set()` — an ordinary `auth add` — then rebuilds the map from that assumed
118
+ * emptiness and writes back a file containing only the new entry. Every other
119
+ * profile's key is gone, and these are last copies: `auth remove` says as much
120
+ * itself.
121
+ *
122
+ * A permission error from a file a `sudo` run left behind is enough to trigger
123
+ * it. So this raises, exactly as `profiles.ts` does for the metadata file —
124
+ * and the file holding the actual secrets has more reason to hold that line,
125
+ * not less.
126
+ */
127
+ private read(): Record<string, string> {
128
+ const path = this.path()
129
+ if (!existsSync(path)) return {}
130
+
131
+ let raw: string
132
+ try {
133
+ raw = readFileSync(path, 'utf8')
134
+ } catch (err) {
135
+ throw unreadable(path, (err as NodeJS.ErrnoException).code === 'EACCES' ? 'it is not readable' : 'it could not be read')
136
+ }
137
+
138
+ let parsed: unknown
139
+ try {
140
+ parsed = JSON.parse(raw) as unknown
141
+ } catch {
142
+ throw unreadable(path, 'it is not valid JSON')
143
+ }
144
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
145
+ throw unreadable(path, 'it is not a JSON object')
146
+ }
147
+ return parsed as Record<string, string>
148
+ }
149
+
150
+ async get(profile: string): Promise<string | null> {
151
+ return this.read()[accountFor(profile)] ?? null
152
+ }
153
+
154
+ async set(profile: string, token: string): Promise<void> {
155
+ if (!this.writable) throw unavailable()
156
+ // Reads BEFORE deciding to write, so an unreadable store aborts here rather
157
+ // than silently becoming a one-entry file.
158
+ const current = this.read()
159
+ current[accountFor(profile)] = token
160
+ writeFileAtomic(this.path(), `${JSON.stringify(current, null, 2)}\n`, 0o600)
161
+ }
162
+
163
+ async delete(profile: string): Promise<void> {
164
+ const current = this.read()
165
+ if (!(accountFor(profile) in current)) return
166
+ delete current[accountFor(profile)]
167
+ writeFileAtomic(this.path(), `${JSON.stringify(current, null, 2)}\n`, 0o600)
168
+ }
169
+ }
170
+
171
+ function unreadable(path: string, why: string): CliError {
172
+ return new CliError(`the credential file at ${path} is unusable — ${why}`, {
173
+ code: 'SECURE_STORE_UNAVAILABLE',
174
+ hint:
175
+ `fix or move ${path} — every profile's key lives in it, and writing over it `
176
+ + 'from here would discard the ones that are still good',
177
+ })
178
+ }
179
+
180
+ function unavailable(): CliError {
181
+ return new CliError('no secure credential store is available on this platform', {
182
+ code: 'SECURE_STORE_UNAVAILABLE',
183
+ hint:
184
+ 'export FRONTERA_TOKEN with FRONTERA_API_URL for this session, '
185
+ + 'or set FRONTERA_SECRET_STORE=file to accept a 0600 file on disk',
186
+ })
187
+ }
188
+
189
+ /**
190
+ * Read from every provider, write to the preferred one.
191
+ *
192
+ * Reads have to span providers because a machine can hold both: a key stored
193
+ * in the keychain today and one migrated into the file before the keychain
194
+ * existed. Writes must not, or "where is my key" becomes unanswerable.
195
+ */
196
+ class ChainStore implements CredentialStore {
197
+ constructor(private readonly providers: CredentialStore[], private readonly writer: CredentialStore | null) {}
198
+
199
+ get kind(): CredentialSource {
200
+ return this.writer?.kind ?? 'file'
201
+ }
202
+
203
+ async get(profile: string): Promise<string | null> {
204
+ for (const provider of this.providers) {
205
+ const value = await provider.get(profile)
206
+ if (value) return value
207
+ }
208
+ return null
209
+ }
210
+
211
+ /** Which provider actually answered — reported as `credentialSource`. */
212
+ async locate(profile: string): Promise<{ token: string; source: CredentialSource } | null> {
213
+ for (const provider of this.providers) {
214
+ const value = await provider.get(profile)
215
+ if (value) return { token: value, source: provider.kind }
216
+ }
217
+ return null
218
+ }
219
+
220
+ async set(profile: string, token: string): Promise<void> {
221
+ if (!this.writer) throw unavailable()
222
+ await this.writer.set(profile, token)
223
+ }
224
+
225
+ async delete(profile: string): Promise<void> {
226
+ // Every provider, because a rotation may have left a stale copy behind in
227
+ // one of them and a half-deleted credential is the worst of both.
228
+ for (const provider of this.providers) await provider.delete(profile)
229
+ }
230
+ }
231
+
232
+ export interface StoreOptions {
233
+ /**
234
+ * Allow a plaintext file to be the WRITE target without the user opting in.
235
+ *
236
+ * Used by migration alone: the legacy config already held that key in
237
+ * plaintext, so relocating it is not a new disclosure — and refusing would
238
+ * strand the only copy of a working credential.
239
+ */
240
+ allowPlaintext?: boolean
241
+ }
242
+
243
+ /**
244
+ * The store this machine should use.
245
+ *
246
+ * `FRONTERA_SECRET_STORE` pins it (`keychain` or `file`); otherwise macOS gets
247
+ * the Keychain and everything else gets a readable-but-not-writable file, so
248
+ * an unsupported platform fails with a named remedy instead of quietly
249
+ * inventing a plaintext secret.
250
+ */
251
+ export function credentialStore(env: Env = process.env, opts: StoreOptions = {}): ChainStore {
252
+ const pinned = env.FRONTERA_SECRET_STORE
253
+ const plaintextAllowed = opts.allowPlaintext === true || pinned === 'file'
254
+
255
+ const keychainUsable = pinned !== 'file' && (pinned === 'keychain' || process.platform === 'darwin')
256
+
257
+ const providers: CredentialStore[] = []
258
+ let writer: CredentialStore | null = null
259
+
260
+ if (keychainUsable) {
261
+ const keychain = new KeychainStore()
262
+ providers.push(keychain)
263
+ writer = keychain
264
+ }
265
+
266
+ const file = new FileStore(env, plaintextAllowed)
267
+ providers.push(file)
268
+ if (!writer && plaintextAllowed) writer = file
269
+
270
+ return new ChainStore(providers, writer)
271
+ }
272
+
273
+ export { ChainStore }
package/src/dev-env.ts CHANGED
@@ -53,7 +53,7 @@ export interface DevEnvResult {
53
53
  interface DevEnvDeps {
54
54
  env?: NodeJS.ProcessEnv
55
55
  /** Seam: lets the no-credential path be exercised on a machine that has one. */
56
- credential?: () => { apiUrl: string; token: string }
56
+ credential?: () => { apiUrl: string; token: string } | Promise<{ apiUrl: string; token: string }>
57
57
  }
58
58
 
59
59
  /**
@@ -64,13 +64,13 @@ interface DevEnvDeps {
64
64
  * and silently replacing a credential file is the kind of help nobody asks for
65
65
  * twice.
66
66
  */
67
- export function writeDevEnv(root: string, deps: DevEnvDeps = {}): DevEnvResult {
67
+ export async function writeDevEnv(root: string, deps: DevEnvDeps = {}): Promise<DevEnvResult> {
68
68
  const path = join(root, DEV_ENV_FILE)
69
69
  if (existsSync(path)) return { written: false, reason: 'exists' }
70
70
 
71
71
  let credential: { apiUrl: string; token: string }
72
72
  try {
73
- credential = (deps.credential ?? (() => resolveCredential({}, { env: deps.env })))()
73
+ credential = await (deps.credential ?? (() => resolveCredential({}, { env: deps.env, cwd: root })))()
74
74
  } catch {
75
75
  // No origin, no token, no keychain — the ordinary state before a login.
76
76
  return { written: false, reason: 'no-credential' }