@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.
- 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 +431 -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 +60 -0
- package/src/vendor/sdk-sources.json +1 -1
package/src/profiles.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
import { configPath, type Env } from './paths'
|
|
5
|
+
import { CliError, UsageError } from './errors'
|
|
6
|
+
import { writeFileAtomic } from './fs-atomic'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Named credential profiles.
|
|
10
|
+
*
|
|
11
|
+
* One machine, many customers. The previous shape stored one token per API
|
|
12
|
+
* origin, which made two keys for the SAME deployment impossible to hold at
|
|
13
|
+
* once — the second login overwrote the first, and an FDE moving between two
|
|
14
|
+
* workspaces on one Frontera install had no way to keep both. A profile is the
|
|
15
|
+
* name that separates them.
|
|
16
|
+
*
|
|
17
|
+
* This file owns METADATA only. Key bytes live in a credential store
|
|
18
|
+
* (`credential-store.ts`), addressed by profile name, so a profile file can be
|
|
19
|
+
* read, diffed and printed without ever holding a secret.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type CredentialKind = 'workspace' | 'organization'
|
|
23
|
+
|
|
24
|
+
export interface ProfileMetadata {
|
|
25
|
+
apiUrl: string
|
|
26
|
+
credentialKind: CredentialKind
|
|
27
|
+
/**
|
|
28
|
+
* Nullable because organization-key verification answers neither field —
|
|
29
|
+
* `whoami` has no workspace to report for a key that belongs to none. Absent
|
|
30
|
+
* scope never makes an otherwise verified profile unusable; it is
|
|
31
|
+
* descriptive, and the service re-derives real scope on every request.
|
|
32
|
+
*/
|
|
33
|
+
workspaceId: string | null
|
|
34
|
+
orgId: string | null
|
|
35
|
+
/** Short SHA-256 of the key, for recognition and diagnostics. Not the key. */
|
|
36
|
+
fingerprint: string
|
|
37
|
+
createdAt: string
|
|
38
|
+
lastVerifiedAt: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ProfileConfig {
|
|
42
|
+
schemaVersion: 2
|
|
43
|
+
profiles: Record<string, ProfileMetadata>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The shape written before profiles existed. Read for migration, never written. */
|
|
47
|
+
export interface LegacyConfig {
|
|
48
|
+
defaultOrigin?: string
|
|
49
|
+
origins?: Record<string, { token?: string }>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Reserved so a future resolver can add them without breaking a machine that
|
|
56
|
+
* already has a profile by that name. `none` and `auto` are the two words a
|
|
57
|
+
* `--profile` flag would plausibly grow a meaning for; a path-like value is
|
|
58
|
+
* reserved because `--profile ./x` should never become a file reference by
|
|
59
|
+
* accident.
|
|
60
|
+
*/
|
|
61
|
+
const RESERVED_NAMES = new Set(['none', 'auto'])
|
|
62
|
+
|
|
63
|
+
export function assertProfileName(name: string): void {
|
|
64
|
+
if (RESERVED_NAMES.has(name.toLowerCase())) {
|
|
65
|
+
throw new CliError(`"${name}" is a reserved profile name`, {
|
|
66
|
+
code: 'PROFILE_INVALID',
|
|
67
|
+
hint: 'pick another name — `none` and `auto` are reserved for future selectors',
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
if (name.includes('/') || name.includes('\\')) {
|
|
71
|
+
throw new CliError(`"${name}" looks like a path, not a profile name`, {
|
|
72
|
+
code: 'PROFILE_INVALID',
|
|
73
|
+
hint: 'profile names are plain identifiers, e.g. acme-prod',
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
if (!PROFILE_NAME_PATTERN.test(name)) {
|
|
77
|
+
throw new CliError(`"${name}" is not a valid profile name`, {
|
|
78
|
+
code: 'PROFILE_INVALID',
|
|
79
|
+
hint: 'use letters, digits, dot, dash or underscore, starting with a letter or digit (max 64)',
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A recognisable stand-in for the key.
|
|
86
|
+
*
|
|
87
|
+
* Twelve hex characters is enough to tell two keys apart by eye when
|
|
88
|
+
* `auth list` prints them side by side, and far too little to attack the key
|
|
89
|
+
* it came from. It exists so a person can answer "is this the key I rotated?"
|
|
90
|
+
* without the CLI ever printing one.
|
|
91
|
+
*/
|
|
92
|
+
export function fingerprint(token: string): string {
|
|
93
|
+
return `sha256:${createHash('sha256').update(token).digest('hex').slice(0, 12)}`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readRaw(env: Env): unknown {
|
|
97
|
+
const path = configPath(env)
|
|
98
|
+
if (!existsSync(path)) return null
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(readFileSync(path, 'utf8')) as unknown
|
|
101
|
+
} catch {
|
|
102
|
+
// Deliberately NOT silent. The old reader treated a corrupt config as
|
|
103
|
+
// absent, which turned "your profiles are unreadable" into "you have no
|
|
104
|
+
// credential" — a true statement about a completely different problem.
|
|
105
|
+
throw new CliError(`the Frontera configuration at ${path} is not readable JSON`, {
|
|
106
|
+
code: 'PROFILE_INVALID',
|
|
107
|
+
hint: `inspect ${path}, or move it aside and re-add your profiles with \`frontera auth add\``,
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function isLegacyConfig(raw: unknown): raw is LegacyConfig {
|
|
113
|
+
if (!raw || typeof raw !== 'object') return false
|
|
114
|
+
const rec = raw as Record<string, unknown>
|
|
115
|
+
if (rec.schemaVersion === 2) return false
|
|
116
|
+
return 'origins' in rec || 'defaultOrigin' in rec
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Profiles as stored, with no migration and no derivation.
|
|
121
|
+
*
|
|
122
|
+
* A caller that needs legacy credentials to be visible calls
|
|
123
|
+
* `migrateLegacyConfig` first — separated so that reading is a pure operation
|
|
124
|
+
* and the one place that rewrites a user's credentials is explicit.
|
|
125
|
+
*/
|
|
126
|
+
export function readProfileConfig(env: Env = process.env): ProfileConfig {
|
|
127
|
+
const raw = readRaw(env)
|
|
128
|
+
if (!raw || typeof raw !== 'object') return { schemaVersion: 2, profiles: {} }
|
|
129
|
+
const rec = raw as Record<string, unknown>
|
|
130
|
+
if (rec.schemaVersion !== 2) return { schemaVersion: 2, profiles: {} }
|
|
131
|
+
const profiles = (rec.profiles ?? {}) as Record<string, ProfileMetadata>
|
|
132
|
+
return { schemaVersion: 2, profiles }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function readLegacyConfig(env: Env = process.env): LegacyConfig | null {
|
|
136
|
+
const raw = readRaw(env)
|
|
137
|
+
return isLegacyConfig(raw) ? raw : null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function writeProfileConfig(config: ProfileConfig, env: Env = process.env): void {
|
|
141
|
+
writeFileAtomic(configPath(env), `${JSON.stringify(config, null, 2)}\n`, 0o600)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function listProfiles(env: Env = process.env): Array<{ name: string; profile: ProfileMetadata }> {
|
|
145
|
+
const { profiles } = readProfileConfig(env)
|
|
146
|
+
return Object.entries(profiles)
|
|
147
|
+
.map(([name, profile]) => ({ name, profile }))
|
|
148
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function getProfile(name: string, env: Env = process.env): ProfileMetadata | null {
|
|
152
|
+
return readProfileConfig(env).profiles[name] ?? null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function requireProfile(name: string, env: Env = process.env): ProfileMetadata {
|
|
156
|
+
const profile = getProfile(name, env)
|
|
157
|
+
if (!profile) {
|
|
158
|
+
const known = listProfiles(env).map((p) => p.name)
|
|
159
|
+
throw new CliError(`no profile named "${name}"`, {
|
|
160
|
+
code: 'PROFILE_NOT_FOUND',
|
|
161
|
+
hint: known.length
|
|
162
|
+
? `known profiles: ${known.join(', ')} — or add one with \`frontera auth add ${name} --api-url <origin>\``
|
|
163
|
+
: `add one with \`frontera auth add ${name} --api-url <origin>\``,
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
return profile
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function putProfile(name: string, profile: ProfileMetadata, env: Env = process.env): void {
|
|
170
|
+
const config = readProfileConfig(env)
|
|
171
|
+
config.profiles[name] = profile
|
|
172
|
+
writeProfileConfig(config, env)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function deleteProfile(name: string, env: Env = process.env): void {
|
|
176
|
+
const config = readProfileConfig(env)
|
|
177
|
+
delete config.profiles[name]
|
|
178
|
+
writeProfileConfig(config, env)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* A key is only meaningful at the deployment that issued it, so an explicit
|
|
183
|
+
* origin that disagrees with the profile's is a mistake worth catching BEFORE
|
|
184
|
+
* a request — sending a customer's key to another customer's deployment is not
|
|
185
|
+
* something to discover from a 401.
|
|
186
|
+
*/
|
|
187
|
+
export function assertOriginMatches(name: string, stored: string, supplied: string): void {
|
|
188
|
+
if (normalizeOrigin(stored) === normalizeOrigin(supplied)) return
|
|
189
|
+
throw new CliError(
|
|
190
|
+
`profile "${name}" belongs to ${stored}, not ${supplied}`,
|
|
191
|
+
{
|
|
192
|
+
code: 'PROFILE_ORIGIN_MISMATCH',
|
|
193
|
+
hint: `drop --api-url to use ${stored}, or add a separate profile for ${supplied}`,
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Trailing slashes and case in the host are not a different deployment. */
|
|
199
|
+
export function normalizeOrigin(origin: string): string {
|
|
200
|
+
try {
|
|
201
|
+
const url = new URL(origin)
|
|
202
|
+
return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, '')}`
|
|
203
|
+
} catch {
|
|
204
|
+
return origin.replace(/\/+$/, '')
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function assertApiUrl(origin: string): void {
|
|
209
|
+
try {
|
|
210
|
+
const url = new URL(origin)
|
|
211
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('scheme')
|
|
212
|
+
} catch {
|
|
213
|
+
throw new UsageError(
|
|
214
|
+
`"${origin}" is not an API origin`,
|
|
215
|
+
'pass a full origin, e.g. --api-url https://api.frontera.example',
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* A stable, readable profile name for a legacy origin.
|
|
222
|
+
*
|
|
223
|
+
* Derived from the host so a migrated machine gets `api-frontera-example`
|
|
224
|
+
* rather than `origin-2`, which nobody could match back to a customer.
|
|
225
|
+
*/
|
|
226
|
+
export function nameFromOrigin(origin: string, taken: Set<string>): string {
|
|
227
|
+
let base: string
|
|
228
|
+
try {
|
|
229
|
+
base = new URL(origin).host.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
|
230
|
+
} catch {
|
|
231
|
+
base = origin.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
|
232
|
+
}
|
|
233
|
+
if (!base || !PROFILE_NAME_PATTERN.test(base)) base = 'imported'
|
|
234
|
+
base = base.slice(0, 56)
|
|
235
|
+
if (!taken.has(base)) return base
|
|
236
|
+
for (let n = 2; ; n += 1) {
|
|
237
|
+
const candidate = `${base}-${n}`
|
|
238
|
+
if (!taken.has(candidate)) return candidate
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { isTrustedBinding, recordBinding } from './binding-registry'
|
|
5
|
+
import { findProjectRoot } from './context'
|
|
6
|
+
import { CliError } from './errors'
|
|
7
|
+
import { writeFileAtomic } from './fs-atomic'
|
|
8
|
+
import type { Env } from './paths'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The working directory selects the profile.
|
|
12
|
+
*
|
|
13
|
+
* The rejected alternative was a machine-wide "current customer" that
|
|
14
|
+
* `frontera auth use` mutated. It is one line to implement and it silently
|
|
15
|
+
* misroutes the next command run in a different repository — which, for an FDE
|
|
16
|
+
* holding two customers open in two terminals, is the exact failure the whole
|
|
17
|
+
* design exists to prevent. Binding lives in the directory instead, and the
|
|
18
|
+
* NEAREST binding wins, so a nested project may safely override its parent.
|
|
19
|
+
*
|
|
20
|
+
* The file holds a profile NAME. Never a token, never an origin — the origin
|
|
21
|
+
* belongs to the profile, so a repository cannot redirect a key to another
|
|
22
|
+
* deployment by editing a checked-out file.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const CONTEXT_DIR = '.frontera'
|
|
26
|
+
export const CONTEXT_FILE = 'context.json'
|
|
27
|
+
|
|
28
|
+
export interface ProjectContext {
|
|
29
|
+
schemaVersion: 1
|
|
30
|
+
profile: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface FoundContext {
|
|
34
|
+
profile: string
|
|
35
|
+
/** Absolute path of the `context.json` that supplied it. */
|
|
36
|
+
path: string
|
|
37
|
+
/** The directory that owns it — the binding root. */
|
|
38
|
+
root: string
|
|
39
|
+
/**
|
|
40
|
+
* Did THIS machine bind this root to this profile?
|
|
41
|
+
*
|
|
42
|
+
* A context file can be committed to a repository, and the upward walk cannot
|
|
43
|
+
* tell who wrote it. Reported rather than enforced here, so listings can show
|
|
44
|
+
* a binding while the resolver refuses to act on one nobody here made.
|
|
45
|
+
*/
|
|
46
|
+
trusted: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function contextPath(root: string): string {
|
|
50
|
+
return join(root, CONTEXT_DIR, CONTEXT_FILE)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readContextAt(root: string, env: Env): FoundContext | null {
|
|
54
|
+
const path = contextPath(root)
|
|
55
|
+
if (!existsSync(path)) return null
|
|
56
|
+
|
|
57
|
+
let parsed: unknown
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown
|
|
60
|
+
} catch {
|
|
61
|
+
throw corrupt(path, 'it is not readable JSON')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const profile = (parsed as { profile?: unknown } | null)?.profile
|
|
65
|
+
if (typeof profile !== 'string' || profile.length === 0) {
|
|
66
|
+
throw corrupt(path, 'it names no profile')
|
|
67
|
+
}
|
|
68
|
+
return { profile, path, root, trusted: isTrustedBinding(root, profile, env) }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Corrupt context is an ERROR, not a miss.
|
|
73
|
+
*
|
|
74
|
+
* Falling through to the parent directory would silently run a customer's
|
|
75
|
+
* command against a different customer's profile — the one outcome this design
|
|
76
|
+
* treats as unacceptable. Better to stop and name the file.
|
|
77
|
+
*/
|
|
78
|
+
function corrupt(path: string, why: string): CliError {
|
|
79
|
+
return new CliError(`the profile context at ${path} is unusable — ${why}`, {
|
|
80
|
+
code: 'PROJECT_CONTEXT_CORRUPT',
|
|
81
|
+
hint: `delete ${path} and run \`frontera auth use <profile>\` again`,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Nearest wins: walk up from `from` to the filesystem root. */
|
|
86
|
+
export function findContext(from: string, env: Env = process.env): FoundContext | null {
|
|
87
|
+
let dir = resolve(from)
|
|
88
|
+
for (;;) {
|
|
89
|
+
const found = readContextAt(dir, env)
|
|
90
|
+
if (found) return found
|
|
91
|
+
const parent = dirname(dir)
|
|
92
|
+
if (parent === dir) return null
|
|
93
|
+
dir = parent
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function findGitRoot(from: string): string | null {
|
|
98
|
+
let dir = resolve(from)
|
|
99
|
+
for (;;) {
|
|
100
|
+
// A file, not only a directory: a worktree and a submodule both write
|
|
101
|
+
// `.git` as a file pointing elsewhere, and both are real repository roots.
|
|
102
|
+
if (existsSync(join(dir, '.git'))) return dir
|
|
103
|
+
const parent = dirname(dir)
|
|
104
|
+
if (parent === dir) return null
|
|
105
|
+
dir = parent
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type BindingRootSource = 'flag' | 'project' | 'git' | 'cwd'
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Where `auth use` writes.
|
|
113
|
+
*
|
|
114
|
+
* `--dir` binds that exact directory and searches no further, matching how
|
|
115
|
+
* `--dir` already behaves for App projects. Otherwise the nearest Frontera
|
|
116
|
+
* project root, then the Git root, then the working directory — so the command
|
|
117
|
+
* needs no initialization of any kind and still lands somewhere a whole
|
|
118
|
+
* customer tree inherits from.
|
|
119
|
+
*/
|
|
120
|
+
export function resolveBindingRoot(
|
|
121
|
+
cwd: string,
|
|
122
|
+
dirFlag?: string,
|
|
123
|
+
): { root: string; source: BindingRootSource } {
|
|
124
|
+
if (dirFlag) return { root: resolve(dirFlag), source: 'flag' }
|
|
125
|
+
const project = findProjectRoot(cwd)
|
|
126
|
+
if (project) return { root: project, source: 'project' }
|
|
127
|
+
const git = findGitRoot(cwd)
|
|
128
|
+
if (git) return { root: git, source: 'git' }
|
|
129
|
+
return { root: resolve(cwd), source: 'cwd' }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Write the selection AND record that this machine made it.
|
|
134
|
+
*
|
|
135
|
+
* One function rather than two, because a context written without its binding
|
|
136
|
+
* is a selection the resolver will refuse — a failure mode nobody would connect
|
|
137
|
+
* to having called only half of the pair.
|
|
138
|
+
*/
|
|
139
|
+
export function writeContext(root: string, profile: string, env: Env = process.env): string {
|
|
140
|
+
const path = contextPath(root)
|
|
141
|
+
const body: ProjectContext = { schemaVersion: 1, profile }
|
|
142
|
+
writeFileAtomic(path, `${JSON.stringify(body, null, 2)}\n`, 0o600)
|
|
143
|
+
recordBinding(root, profile, env)
|
|
144
|
+
return path
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Add `.frontera/` to the root's `.gitignore` without touching anything else.
|
|
149
|
+
*
|
|
150
|
+
* Appended, never rewritten. A `.gitignore` is a file the team owns; replacing
|
|
151
|
+
* it to add one line is the kind of help that gets a tool removed from a repo.
|
|
152
|
+
* The name is gitignored even though it holds no secret, because a profile name
|
|
153
|
+
* can reveal a customer or an environment, and it is personal machine
|
|
154
|
+
* configuration rather than a property of the code.
|
|
155
|
+
*/
|
|
156
|
+
export function ensureGitignored(root: string): 'added' | 'present' | 'created' {
|
|
157
|
+
const path = join(root, '.gitignore')
|
|
158
|
+
const entry = `${CONTEXT_DIR}/`
|
|
159
|
+
|
|
160
|
+
if (!existsSync(path)) {
|
|
161
|
+
writeFileAtomic(path, `# Frontera profile selection — personal, not shared\n${entry}\n`, 0o644)
|
|
162
|
+
return 'created'
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const current = readFileSync(path, 'utf8')
|
|
166
|
+
const lines = current.split('\n').map((l) => l.trim())
|
|
167
|
+
if (lines.includes(entry) || lines.includes(CONTEXT_DIR) || lines.includes(`/${entry}`)) return 'present'
|
|
168
|
+
|
|
169
|
+
const separator = current.endsWith('\n') ? '' : '\n'
|
|
170
|
+
writeFileAtomic(
|
|
171
|
+
path,
|
|
172
|
+
`${current}${separator}\n# Frontera profile selection — personal, not shared\n${entry}\n`,
|
|
173
|
+
// The file's own mode, not ours. An atomic write replaces the inode, so a
|
|
174
|
+
// fixed mode here would quietly re-permission a file the team owns.
|
|
175
|
+
statSync(path).mode & 0o777,
|
|
176
|
+
)
|
|
177
|
+
return 'added'
|
|
178
|
+
}
|
package/src/prompt.ts
CHANGED
|
@@ -46,3 +46,26 @@ export function promptSecret(question: string): Promise<string> {
|
|
|
46
46
|
})
|
|
47
47
|
})
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A yes/no question, asked only on an interactive terminal.
|
|
52
|
+
*
|
|
53
|
+
* Same guard as `promptSecret`: an agent's stdin is a pipe or /dev/null, so it
|
|
54
|
+
* never reaches this and never hangs. Callers check `canPrompt()` first and
|
|
55
|
+
* raise a usage error naming the explicit flag instead — which is why every
|
|
56
|
+
* confirmation in this CLI has a flag form.
|
|
57
|
+
*/
|
|
58
|
+
export function promptConfirm(question: string): Promise<boolean> {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true })
|
|
61
|
+
rl.question(`${question} [y/N] `, (answer) => {
|
|
62
|
+
rl.close()
|
|
63
|
+
resolve(/^y(es)?$/i.test(answer.trim()))
|
|
64
|
+
})
|
|
65
|
+
rl.on('SIGINT', () => {
|
|
66
|
+
rl.close()
|
|
67
|
+
process.stdout.write('\n')
|
|
68
|
+
reject(new Error('cancelled'))
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
}
|
|
@@ -979,7 +979,10 @@ thing that proves the static export still works. For anything touching data,
|
|
|
979
979
|
also run it against real data with \`frontera app dev\`.
|
|
980
980
|
`,
|
|
981
981
|
|
|
982
|
-
|
|
982
|
+
// `@AGENTS.md`, not `./AGENTS.md`. The latter is a relative path Claude
|
|
983
|
+
// Code does not import — it reads as prose, so the project contract
|
|
984
|
+
// silently never loads.
|
|
985
|
+
'CLAUDE.md': `@AGENTS.md
|
|
983
986
|
`,
|
|
984
987
|
|
|
985
988
|
'README.md': `# ${name}
|