@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
@@ -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
- 'CLAUDE.md': `./AGENTS.md
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}