@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.
- 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/commands/types.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
26
|
+
export type ProfileSource = 'flag' | 'environment' | 'directory' | 'none'
|
|
27
|
+
export type OriginSource = 'flag' | 'environment' | 'profile'
|
|
15
28
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
50
|
+
export interface ResolvedCredential extends CredentialResolution {
|
|
51
|
+
token: string
|
|
52
|
+
}
|
|
30
53
|
|
|
31
54
|
/**
|
|
32
|
-
*
|
|
55
|
+
* The narrow slice of the credential store resolution needs.
|
|
33
56
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
|
40
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
130
|
+
* Resolve profile, origin and key — in that order, each with its own rules.
|
|
90
131
|
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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:
|
|
139
|
+
export async function resolveCredential(
|
|
140
|
+
opts: ResolveOptions = {},
|
|
101
141
|
deps: CredentialDeps = {},
|
|
102
|
-
):
|
|
142
|
+
): Promise<ResolvedCredential> {
|
|
103
143
|
const env = deps.env ?? process.env
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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 {
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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' }
|