@frontera-sdk/cli 0.1.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 (53) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +65 -0
  3. package/package.json +47 -0
  4. package/src/api/apps-api.ts +165 -0
  5. package/src/api/automation-api.ts +140 -0
  6. package/src/api/platform-api.ts +193 -0
  7. package/src/api/registry-api.ts +43 -0
  8. package/src/args.ts +108 -0
  9. package/src/commands/agent/compose.ts +155 -0
  10. package/src/commands/agent/index-commands.ts +348 -0
  11. package/src/commands/agent/resolve.ts +58 -0
  12. package/src/commands/app/add.ts +78 -0
  13. package/src/commands/app/deploy.ts +105 -0
  14. package/src/commands/app/init.ts +53 -0
  15. package/src/commands/app/list.ts +51 -0
  16. package/src/commands/app/promote.ts +31 -0
  17. package/src/commands/app/pull.ts +145 -0
  18. package/src/commands/app/save.ts +36 -0
  19. package/src/commands/app/shared.ts +25 -0
  20. package/src/commands/app/versions.ts +38 -0
  21. package/src/commands/automation/index-commands.ts +325 -0
  22. package/src/commands/blueprint/get.ts +160 -0
  23. package/src/commands/blueprint/list.ts +48 -0
  24. package/src/commands/blueprint/reserved.ts +40 -0
  25. package/src/commands/completion.ts +293 -0
  26. package/src/commands/init.ts +33 -0
  27. package/src/commands/knowledge/index-commands.ts +140 -0
  28. package/src/commands/login.ts +103 -0
  29. package/src/commands/plugin/index-commands.ts +112 -0
  30. package/src/commands/registry.ts +405 -0
  31. package/src/commands/skill/index-commands.ts +140 -0
  32. package/src/commands/types.ts +76 -0
  33. package/src/config.ts +142 -0
  34. package/src/context.ts +67 -0
  35. package/src/errors.ts +30 -0
  36. package/src/exit.ts +98 -0
  37. package/src/flag-help.ts +70 -0
  38. package/src/harness.ts +162 -0
  39. package/src/heal.ts +418 -0
  40. package/src/help.ts +128 -0
  41. package/src/main.ts +204 -0
  42. package/src/manifest.ts +80 -0
  43. package/src/output.ts +65 -0
  44. package/src/pack.ts +18 -0
  45. package/src/packaging.ts +116 -0
  46. package/src/project.ts +151 -0
  47. package/src/prompt.ts +48 -0
  48. package/src/registry.ts +62 -0
  49. package/src/secrets.ts +69 -0
  50. package/src/table.ts +47 -0
  51. package/src/tar.ts +73 -0
  52. package/src/template.ts +566 -0
  53. package/src/vendor/sdk-sources.json +25 -0
package/src/main.ts ADDED
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env bun
2
+ import { parseArgs } from './args'
3
+ import { resolveCredential } from './config'
4
+ import { findProjectRoot } from './context'
5
+ import { CliError, UsageError } from './errors'
6
+ import { EXIT } from './exit'
7
+ import { createOutput, type OutputMode } from './output'
8
+ import { readProject } from './project'
9
+ import {
10
+ aliasesFor,
11
+ describeCommand,
12
+ describeCommands,
13
+ findCommand,
14
+ flagsFor,
15
+ nouns,
16
+ renderCommandHelp,
17
+ renderHelp,
18
+ renderNounHelp,
19
+ unknownCommand,
20
+ } from './commands/registry'
21
+ import type { AppProject } from './context'
22
+
23
+ /**
24
+ * The shell: parse, resolve context, dispatch, render, exit.
25
+ *
26
+ * It is deliberately the only place that knows about process streams and exit
27
+ * codes. Commands return data; `output` decides how it is shown and where it
28
+ * goes. That is what makes `--json` uniform across every command by
29
+ * construction rather than by each one remembering to branch.
30
+ */
31
+ /**
32
+ * What `--version` reports.
33
+ *
34
+ * A released binary is stamped at build time with the tag it shipped from
35
+ * (`scripts/build-release.ts`), because that is the number a caller can
36
+ * actually act on: it names the platform release this client was built
37
+ * against, so a bug report says which server contract it expects. Running from
38
+ * source there is no tag, and the package version is the honest answer.
39
+ */
40
+ async function resolveVersion(): Promise<string> {
41
+ const stamped = process.env.FRONTERA_CLI_VERSION
42
+ if (stamped) return stamped
43
+ const pkg = await import('../package.json', { with: { type: 'json' } })
44
+ return `${(pkg.default as { version: string }).version}-dev`
45
+ }
46
+
47
+ async function main(): Promise<number> {
48
+ const argv = process.argv.slice(2)
49
+
50
+ /**
51
+ * Help is available as DATA wherever it is available as text.
52
+ *
53
+ * `--help` is how a caller discovers the surface, and an agent discovering it
54
+ * had to parse aligned prose. Checked against raw argv because the help
55
+ * paths below run before any flag parsing — help has to work when the
56
+ * command is unknown, which is exactly when a caller needs it most.
57
+ */
58
+ const wantsJson = argv.includes('--json')
59
+ const emit = (data: unknown, text: () => string): number => {
60
+ process.stdout.write(wantsJson ? `${JSON.stringify(data, null, 2)}\n` : `${text()}\n`)
61
+ return EXIT.OK
62
+ }
63
+
64
+ // A leading global flag is not a command name. `frontera --api-url X` used
65
+ // to fail with "unknown command: --api-url", which reads as a typo rather
66
+ // than as a missing command — so flags are lifted out before the noun is
67
+ // read, and re-appended afterwards.
68
+ const leadingFlags: string[] = []
69
+ let cursor = 0
70
+ while (cursor < argv.length && argv[cursor]!.startsWith('--')) {
71
+ const flag = argv[cursor]!
72
+ if (flag === '--help' || flag === '--version') break
73
+ leadingFlags.push(flag)
74
+ cursor += 1
75
+ // A `--key value` pair moves together; `--key=value` is already one token.
76
+ if (!flag.includes('=') && cursor < argv.length && !argv[cursor]!.startsWith('--')) {
77
+ leadingFlags.push(argv[cursor]!)
78
+ cursor += 1
79
+ }
80
+ }
81
+
82
+ const remaining = argv.slice(cursor)
83
+
84
+ // Flags but no command. Showing help beats "unknown command: --api-url",
85
+ // which reads as a typo in the flag rather than as a missing command.
86
+ if (leadingFlags.length > 0 && remaining.length === 0) {
87
+ process.stderr.write('error [USAGE]: no command given\n')
88
+ process.stdout.write(`${renderHelp()}\n`)
89
+ return EXIT.USAGE
90
+ }
91
+
92
+ const [noun, maybeVerb, ...rest] = [...remaining, ...leadingFlags]
93
+
94
+ // Bare `frontera`, `help`, `--help`, `--version` before anything that could
95
+ // fail — help has to work with no credential and no project.
96
+ if (!noun || noun === 'help' || noun === '--help' || noun === '-h') {
97
+ return emit(describeCommands(), renderHelp)
98
+ }
99
+ if (noun === '--version' || noun === 'version') {
100
+ const version = await resolveVersion()
101
+ return emit({ version }, () => version)
102
+ }
103
+
104
+ // Nouns with no verb (`frontera init`) take their arguments directly.
105
+ const direct = findCommand(noun, '')
106
+ const command = direct ?? findCommand(noun, maybeVerb)
107
+
108
+ // `frontera agent --help`, or a bare `frontera agent`: list the noun's
109
+ // commands. Without this the CLI's own hints dead-ended, since a reserved
110
+ // verb tells the caller to run exactly this.
111
+ if (!command && nouns().includes(noun)) {
112
+ const askedForHelp =
113
+ maybeVerb === undefined ||
114
+ maybeVerb === '--help' ||
115
+ maybeVerb === '-h' ||
116
+ // `frontera app --json` is a help request too: the noun alone is not a
117
+ // command, so there is nothing else it could mean.
118
+ maybeVerb === '--json'
119
+ if (askedForHelp) {
120
+ return emit(describeCommands(noun), () => renderNounHelp(noun))
121
+ }
122
+ }
123
+
124
+ if (!command) throw unknownCommand(noun, maybeVerb)
125
+
126
+ const args = direct ? [maybeVerb, ...rest].filter((a): a is string => a !== undefined) : rest
127
+ const spec = flagsFor(command.meta)
128
+ const { positional, flags } = parseArgs(args, spec, aliasesFor(command.meta))
129
+
130
+ if (flags.help === true) {
131
+ return emit(describeCommand(command.meta), () => renderCommandHelp(command.meta))
132
+ }
133
+
134
+ const mode: OutputMode = flags.json === true ? 'json' : 'human'
135
+ const output = createOutput(mode, { quiet: flags.quiet === true })
136
+
137
+ try {
138
+ // Reserved commands fail BEFORE anything else — no credential is
139
+ // required to be told a verb does not exist yet.
140
+ if (command.meta.reserved) {
141
+ throw new CliError(command.meta.reserved, {
142
+ code: 'BAD_REQUEST',
143
+ hint: `run \`frontera ${command.meta.noun} --help\` to see what this noun supports today`,
144
+ })
145
+ }
146
+
147
+ const dirFlag = typeof flags.dir === 'string' ? flags.dir : undefined
148
+ const cwd = dirFlag ?? process.cwd()
149
+
150
+ let project: AppProject | null = null
151
+ if (command.meta.needsProject || command.meta.optionalProject) {
152
+ const root = findProjectRoot(cwd)
153
+ if (!root && command.meta.needsProject) {
154
+ throw new UsageError(
155
+ 'not in a Frontera app directory',
156
+ 'cd into an app project, or run `frontera app init <name>`',
157
+ )
158
+ }
159
+ if (root) project = { ...readProject(root), root }
160
+ }
161
+
162
+ // Offline commands scaffold before a credential exists, so resolving one
163
+ // would make `frontera init` impossible on a fresh machine.
164
+ const credential = command.meta.offline
165
+ ? { apiUrl: typeof flags['api-url'] === 'string' ? flags['api-url'] : (process.env.FRONTERA_API_URL ?? ''), token: '' }
166
+ : resolveCredential({ apiUrl: typeof flags['api-url'] === 'string' ? flags['api-url'] : undefined })
167
+
168
+ const result = await command.run({
169
+ cwd,
170
+ apiUrl: credential.apiUrl,
171
+ token: credential.token,
172
+ positional,
173
+ flags,
174
+ output,
175
+ project,
176
+ })
177
+
178
+ output.render(result.data, () => result.text)
179
+ return EXIT.OK
180
+ } catch (err) {
181
+ return output.fail(err)
182
+ }
183
+ }
184
+
185
+ main()
186
+ .then((code) => {
187
+ // `process.exitCode`, NOT `process.exit(code)`.
188
+ //
189
+ // stdout is a pipe when anything reads this — an agent, `jq`, a shell
190
+ // redirect — and writes to a pipe are asynchronous. `process.exit()` tears
191
+ // the process down before the buffer drains, so a large `--json` payload
192
+ // arrived TRUNCATED: valid-looking output that fails to parse partway
193
+ // through. Setting the code lets the runtime flush and exit on its own.
194
+ process.exitCode = code
195
+ })
196
+ .catch((err) => {
197
+ // Only reachable for a throw outside the try — argument parsing, or help
198
+ // rendering. Rendered as human text because the mode is not known yet.
199
+ const message = err instanceof Error ? err.message : String(err)
200
+ const code = err instanceof CliError ? err.code : 'INTERNAL_ERROR'
201
+ process.stderr.write(`error [${code}]: ${message}\n`)
202
+ if (err instanceof CliError && err.hint) process.stderr.write(` ${err.hint}\n`)
203
+ process.exitCode = code === 'USAGE' ? EXIT.USAGE : EXIT.FAILURE
204
+ })
@@ -0,0 +1,80 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { CliError } from './errors'
5
+ import type { ProjectConfig } from './project'
6
+
7
+ /**
8
+ * The version manifest — what the SERVER needs to know about a build that the
9
+ * bundle itself cannot tell it.
10
+ *
11
+ * Today that is one thing: which origins the app may reach, which becomes the
12
+ * `connect-src` and `img-src`/`font-src` of the CSP served with every asset.
13
+ * The design of record had a Vite plugin emit this at build time. Emitting it
14
+ * here instead means it also covers an app built by any other toolchain, and
15
+ * `connectDomains` reaches the CSP for every app rather than only scaffolded
16
+ * ones — which is the gap this closes.
17
+ *
18
+ * A build that DOES emit `dist/frontera.manifest.json` still wins, so a future
19
+ * plugin can add pages and an app-state schema without changing this.
20
+ */
21
+ export interface AppManifest {
22
+ schemaVersion: 1
23
+ displayName: string
24
+ connectDomains: string[]
25
+ resourceDomains: string[]
26
+ }
27
+
28
+ /**
29
+ * A CSP source expression, conservatively.
30
+ *
31
+ * These strings are joined with spaces into a `Content-Security-Policy` header.
32
+ * A value carrying a `;` would close its directive and open a new one, so it is
33
+ * refused HERE with a message naming the offender rather than silently
34
+ * rewriting a header on the serving side. The server sanitises as well — this
35
+ * is the layer that can explain the problem.
36
+ */
37
+ const CSP_SOURCE = /^(?:https?:\/\/)?(?:\*\.)?[a-z0-9][a-z0-9.-]*(?::\d+)?$/i
38
+
39
+ function assertDomains(field: string, values: string[]): string[] {
40
+ for (const value of values) {
41
+ if (!CSP_SOURCE.test(value)) {
42
+ throw new CliError(`invalid ${field} entry: ${JSON.stringify(value)}`, {
43
+ code: 'VALIDATION_ERROR',
44
+ hint: 'use a host, optionally with a scheme, port or leading `*.` — e.g. https://api.example.com',
45
+ })
46
+ }
47
+ }
48
+ return values
49
+ }
50
+
51
+ export function buildManifest(project: ProjectConfig): AppManifest {
52
+ return {
53
+ schemaVersion: 1,
54
+ displayName: project.displayName,
55
+ connectDomains: assertDomains('connectDomains', project.connectDomains),
56
+ resourceDomains: assertDomains('resourceDomains', project.resourceDomains),
57
+ }
58
+ }
59
+
60
+ /**
61
+ * The manifest to publish with this build.
62
+ *
63
+ * Read from `dist/` when the build produced one; otherwise synthesised from
64
+ * `package.json`. A malformed `dist/frontera.manifest.json` is an error rather
65
+ * than a silent fallback: the build meant to say something, and quietly
66
+ * publishing different rules than the author declared is worse than refusing.
67
+ */
68
+ export function resolveManifest(distDir: string, project: ProjectConfig): AppManifest {
69
+ const emitted = join(distDir, 'frontera.manifest.json')
70
+ if (!existsSync(emitted)) return buildManifest(project)
71
+
72
+ try {
73
+ return JSON.parse(readFileSync(emitted, 'utf8')) as AppManifest
74
+ } catch (err) {
75
+ throw new CliError(`dist/frontera.manifest.json is not valid JSON: ${(err as Error).message}`, {
76
+ code: 'VALIDATION_ERROR',
77
+ hint: 'delete it to fall back to the `frontera` key in package.json',
78
+ })
79
+ }
80
+ }
package/src/output.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { exitCodeFor, toEnvelope, type ExitCode } from './exit'
2
+
3
+ export type OutputMode = 'human' | 'json'
4
+
5
+ export interface Streams {
6
+ out(chunk: string): void
7
+ err(chunk: string): void
8
+ }
9
+
10
+ export interface Output {
11
+ /** The command's data. The ONLY thing that ever reaches stdout. */
12
+ render(data: unknown, toText: (data: unknown) => string): void
13
+ /** Progress and warnings. Always stderr, so piping stays clean. */
14
+ note(message: string): void
15
+ /** Report a failure and return the exit code the shell should use. */
16
+ fail(err: unknown): ExitCode
17
+ }
18
+
19
+ const defaultStreams: Streams = {
20
+ out: (chunk) => process.stdout.write(chunk),
21
+ err: (chunk) => process.stderr.write(chunk),
22
+ }
23
+
24
+ /**
25
+ * The one module that writes to a stream.
26
+ *
27
+ * Commands return data; this decides how it is shown and where it goes. That
28
+ * split is what makes `--json` uniform by construction rather than by every
29
+ * command remembering to branch — and it is why nothing else in the CLI may
30
+ * call `console.*`.
31
+ *
32
+ * The contract a caller relies on: stdout carries data and nothing else, so
33
+ * exit 0 means stdout is parseable. On failure stdout stays EMPTY — a partial
34
+ * document followed by an error is worse than no document, because it parses.
35
+ */
36
+ export function createOutput(
37
+ mode: OutputMode,
38
+ opts: { quiet?: boolean; streams?: Streams } = {},
39
+ ): Output {
40
+ const streams = opts.streams ?? defaultStreams
41
+ const quiet = opts.quiet ?? false
42
+
43
+ return {
44
+ render(data, toText) {
45
+ const text = mode === 'json' ? JSON.stringify(data, null, 2) : toText(data)
46
+ streams.out(`${text}\n`)
47
+ },
48
+
49
+ note(message) {
50
+ if (quiet) return
51
+ streams.err(`${message}\n`)
52
+ },
53
+
54
+ fail(err) {
55
+ const envelope = toEnvelope(err)
56
+ if (mode === 'json') {
57
+ streams.err(`${JSON.stringify(envelope, null, 2)}\n`)
58
+ } else {
59
+ streams.err(`error [${envelope.code}]: ${envelope.message}\n`)
60
+ if (envelope.hint) streams.err(` ${envelope.hint}\n`)
61
+ }
62
+ return exitCodeFor(envelope.code)
63
+ },
64
+ }
65
+ }
package/src/pack.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { collectSourceFiles, formatBytes } from './packaging'
2
+ import { createTarGz } from './tar'
3
+ import type { Output } from './output'
4
+
5
+ /**
6
+ * Package a directory for upload, reporting its size.
7
+ *
8
+ * The count and size line is kept — it is how a stray 200MB asset directory
9
+ * becomes visible the moment it happens rather than months later as a storage
10
+ * bill — but it moves to stderr via `note`. It is commentary about the work,
11
+ * not the work's result, and mixing it into stdout broke piping.
12
+ */
13
+ export function packDirectory(dir: string, output: Output, label: string) {
14
+ const files = collectSourceFiles(dir)
15
+ const totalBytes = files.reduce((n, f) => n + f.bytes.byteLength, 0)
16
+ output.note(` ${label} ${files.length} files, ${formatBytes(totalBytes)}`)
17
+ return { tgz: createTarGz(files), fileCount: files.length, totalBytes }
18
+ }
@@ -0,0 +1,116 @@
1
+ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
2
+ import { join, relative, sep } from 'node:path'
3
+
4
+ /**
5
+ * Paths that are NEVER packaged, whatever any config says.
6
+ *
7
+ * `.env` is the reason this set is non-overridable rather than a default: an
8
+ * app project may hold an API key, and stored source is readable by anyone with
9
+ * `apps:deploy` — so a packaged `.env` leaks credentials to every deployer in
10
+ * the workspace. An author who *could* opt back in would eventually do so by
11
+ * accident, so the option does not exist.
12
+ *
13
+ * The rest is build output and noise: shipping it bloats every pull for no
14
+ * benefit, since `dist/` is rebuilt and `node_modules/` reinstalled from the
15
+ * lockfile.
16
+ */
17
+ export const HARD_EXCLUDED = Object.freeze([
18
+ 'node_modules',
19
+ 'dist',
20
+ 'build',
21
+ '.next',
22
+ '.git',
23
+ 'coverage',
24
+ '.turbo',
25
+ '.cache',
26
+ '.vscode',
27
+ '.idea',
28
+ ] as const)
29
+
30
+ const HARD_EXCLUDED_SET = new Set<string>(HARD_EXCLUDED)
31
+
32
+ /** Is this project-relative path excluded by the hard set? */
33
+ export function isExcluded(rel: string): boolean {
34
+ const segments = rel.split('/')
35
+ for (const seg of segments) {
36
+ if (HARD_EXCLUDED_SET.has(seg)) return true
37
+ // `.env`, `.env.local`, `.env.production`, … at any depth.
38
+ if (seg === '.env' || seg.startsWith('.env.')) return true
39
+ if (seg === '.DS_Store') return true
40
+ if (seg.endsWith('.log')) return true
41
+ }
42
+ return false
43
+ }
44
+
45
+ /**
46
+ * Minimal `.gitignore` matcher.
47
+ *
48
+ * Deliberately simple — it handles the literal names and `dir/` and `*.ext`
49
+ * forms that cover real app projects. Negation (`!rule`) is parsed and then
50
+ * IGNORED for anything in the hard set, so a permissive `.gitignore` can never
51
+ * widen packaging past §6.2.
52
+ */
53
+ function gitignoreMatcher(root: string): (rel: string) => boolean {
54
+ const path = join(root, '.gitignore')
55
+ if (!existsSync(path)) return () => false
56
+
57
+ const rules = readFileSync(path, 'utf8')
58
+ .split('\n')
59
+ .map((l) => l.trim())
60
+ .filter((l) => l && !l.startsWith('#') && !l.startsWith('!'))
61
+ .map((l) => l.replace(/\/$/, ''))
62
+
63
+ return (rel: string) => {
64
+ const segments = rel.split('/')
65
+ return rules.some((rule) => {
66
+ if (rule.startsWith('*.')) {
67
+ const ext = rule.slice(1)
68
+ return rel.endsWith(ext)
69
+ }
70
+ return segments.includes(rule) || rel === rule
71
+ })
72
+ }
73
+ }
74
+
75
+ export interface SourceFile {
76
+ /** Project-relative path, POSIX separators. */
77
+ rel: string
78
+ bytes: Uint8Array
79
+ }
80
+
81
+ /**
82
+ * Walk a project directory and collect what belongs in `src.tgz`.
83
+ *
84
+ * The hard set is applied first and unconditionally; `.gitignore` is applied on
85
+ * top of it, never instead of it.
86
+ */
87
+ export function collectSourceFiles(root: string): SourceFile[] {
88
+ const ignored = gitignoreMatcher(root)
89
+ const out: SourceFile[] = []
90
+
91
+ const walk = (dir: string) => {
92
+ for (const entry of readdirSync(dir)) {
93
+ const full = join(dir, entry)
94
+ const rel = relative(root, full).split(sep).join('/')
95
+ if (isExcluded(rel)) continue
96
+
97
+ const stat = statSync(full)
98
+ if (stat.isDirectory()) {
99
+ walk(full)
100
+ continue
101
+ }
102
+ if (ignored(rel)) continue
103
+ out.push({ rel, bytes: new Uint8Array(readFileSync(full)) })
104
+ }
105
+ }
106
+
107
+ walk(root)
108
+ return out
109
+ }
110
+
111
+ /** Human-readable size, for the count+size line printed on save and deploy. */
112
+ export function formatBytes(n: number): string {
113
+ if (n < 1024) return `${n}B`
114
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`
115
+ return `${(n / 1048576).toFixed(1)}MB`
116
+ }
package/src/project.ts ADDED
@@ -0,0 +1,151 @@
1
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
2
+ import { join, dirname } from 'node:path'
3
+ import { execSync } from 'node:child_process'
4
+
5
+ import { collectSourceFiles, isExcluded } from './packaging'
6
+
7
+ export interface ProjectConfig {
8
+ /** The app's real identifier; null until the first ensure. */
9
+ appId: string | null
10
+ /** Bootstrap name for the first deploy only — the platform owns it after. */
11
+ slug: string
12
+ displayName: string
13
+ description?: string
14
+ /** Version the working tree was pulled from — feeds parentVersion. */
15
+ parentVersion?: string | null
16
+ /** Origins the app may reach at runtime; become the served CSP. */
17
+ connectDomains: string[]
18
+ resourceDomains: string[]
19
+ }
20
+
21
+ const STATE_FILE = '.frontera/state.json'
22
+
23
+ /**
24
+ * Read app identity and runtime configuration.
25
+ *
26
+ * All of it lives in `package.json` under a `frontera` key — the same key that
27
+ * marks a directory as an app project. A separate `frontera.config.ts` was
28
+ * specified originally and is deliberately not used: it would force the CLI to
29
+ * evaluate TypeScript to learn a slug, and it would put an app's identity in
30
+ * two files that can disagree. JSON is also what an agent can patch safely.
31
+ */
32
+ export function readProject(dir: string): ProjectConfig {
33
+ const pkgPath = join(dir, 'package.json')
34
+ if (!existsSync(pkgPath)) {
35
+ throw new Error(`no package.json in ${dir} — is this a Frontera app project?`)
36
+ }
37
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as {
38
+ name?: string
39
+ version?: string
40
+ frontera?: {
41
+ appId?: string
42
+ slug?: string
43
+ displayName?: string
44
+ description?: string
45
+ connectDomains?: unknown
46
+ resourceDomains?: unknown
47
+ }
48
+ }
49
+
50
+ // Bootstrap only: the name to CREATE the app under, on the one deploy where
51
+ // it does not exist yet. Never written back, because the platform owns the
52
+ // slug from that point on and can rename it — a committed copy would be
53
+ // stale the moment someone did, and would still look authoritative.
54
+ // `frontera.slug` is read for projects scaffolded before this changed.
55
+ const slug = pkg.frontera?.slug ?? pkg.name
56
+ if (!slug) throw new Error('package.json needs a "name"')
57
+
58
+ return {
59
+ // The real identifier. In package.json rather than `.frontera/state.json`
60
+ // because state is gitignored, so a colleague who clones the repo would
61
+ // otherwise have nothing durable — and would deploy to a new app.
62
+ appId: pkg.frontera?.appId ?? readState(dir).appId ?? null,
63
+ slug,
64
+ displayName: pkg.frontera?.displayName ?? slug,
65
+ description: pkg.frontera?.description,
66
+ parentVersion: readState(dir).parentVersion ?? null,
67
+ connectDomains: stringList(pkg.frontera?.connectDomains),
68
+ resourceDomains: stringList(pkg.frontera?.resourceDomains),
69
+ }
70
+ }
71
+
72
+ /** Tolerate a missing or malformed list rather than failing a whole read. */
73
+ function stringList(value: unknown): string[] {
74
+ return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []
75
+ }
76
+
77
+ /**
78
+ * Record the app's id in package.json, so it is committed alongside the code.
79
+ *
80
+ * Also DROPS any `frontera.slug`. Once the id is known the slug is dead weight
81
+ * — the platform owns it and can rename it, so a committed copy only survives
82
+ * to go stale and mislead the next person who reads the file.
83
+ *
84
+ * Idempotent: when nothing would change, the file is left untouched rather
85
+ * than rewritten, so this never shows up as a spurious diff.
86
+ */
87
+ export function writeAppId(dir: string, appId: string): void {
88
+ const pkgPath = join(dir, 'package.json')
89
+ const raw = readFileSync(pkgPath, 'utf8')
90
+ const pkg = JSON.parse(raw) as { frontera?: Record<string, unknown> }
91
+ const frontera = pkg.frontera ?? {}
92
+ if (frontera.appId === appId && frontera.slug === undefined) return
93
+
94
+ const { slug: _dropped, ...rest } = frontera
95
+ pkg.frontera = { appId, ...rest }
96
+ const indent = /^\{\n(\s+)"/.exec(raw)?.[1]?.length ?? 2
97
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, indent)}\n`)
98
+ }
99
+
100
+ export function readPackageVersion(dir: string): string {
101
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { version?: string }
102
+ if (!pkg.version) throw new Error('package.json has no "version" — pass --version explicitly')
103
+ return pkg.version
104
+ }
105
+
106
+ interface ProjectState {
107
+ parentVersion?: string | null
108
+ appId?: string
109
+ }
110
+
111
+ export function readState(dir: string): ProjectState {
112
+ const p = join(dir, STATE_FILE)
113
+ if (!existsSync(p)) return {}
114
+ try {
115
+ return JSON.parse(readFileSync(p, 'utf8')) as ProjectState
116
+ } catch {
117
+ return {}
118
+ }
119
+ }
120
+
121
+ export function writeState(dir: string, patch: ProjectState): void {
122
+ const p = join(dir, STATE_FILE)
123
+ mkdirSync(dirname(p), { recursive: true })
124
+ writeFileSync(p, `${JSON.stringify({ ...readState(dir), ...patch }, null, 2)}\n`)
125
+ }
126
+
127
+ /**
128
+ * Files a pull would overwrite.
129
+ *
130
+ * `pull` unpacks over an existing tree, so proceeding past uncommitted work
131
+ * would destroy it — and inside a Computer sandbox there is often no git to
132
+ * recover from. When the project is a git repo we trust `git status`; otherwise
133
+ * any packageable file already present counts as dirty. Erring toward refusal
134
+ * costs one `--force` flag; erring the other way costs someone's afternoon.
135
+ */
136
+ export function dirtyFiles(dir: string): string[] {
137
+ if (existsSync(join(dir, '.git'))) {
138
+ try {
139
+ const out = execSync('git status --porcelain', { cwd: dir, encoding: 'utf8' })
140
+ return out
141
+ .split('\n')
142
+ .map((l) => l.slice(3).trim())
143
+ .filter((l) => l && !isExcluded(l))
144
+ } catch {
145
+ // Fall through to the conservative check below.
146
+ }
147
+ }
148
+ return collectSourceFiles(dir)
149
+ .map((f) => f.rel)
150
+ .filter((f) => f !== 'package.json')
151
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { createInterface } from 'node:readline'
2
+
3
+ /**
4
+ * Ask for a secret, with the answer hidden.
5
+ *
6
+ * The CLI does not prompt — with one exception, and this is the guard that
7
+ * makes it safe: a prompt happens ONLY when stdin is an interactive TTY. An
8
+ * agent's stdin never is (it is a pipe or /dev/null), so an agent cannot hang
9
+ * here. Callers must still check `canPrompt()` first, so the decision is
10
+ * visible at the call site rather than buried.
11
+ *
12
+ * The answer is not echoed. A key pasted into a terminal would otherwise sit
13
+ * in scrollback, which is the same objection that keeps secrets out of flags.
14
+ */
15
+ export function canPrompt(): boolean {
16
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY)
17
+ }
18
+
19
+ export function promptSecret(question: string): Promise<string> {
20
+ return new Promise((resolve, reject) => {
21
+ const input = process.stdin
22
+ const output = process.stdout
23
+
24
+ const rl = createInterface({ input, output, terminal: true })
25
+ // `_writeToOutput` is readline's single seam for suppressing echo. Writing
26
+ // the prompt once and nothing after keeps the cursor where the user
27
+ // expects while their keystrokes stay off the screen.
28
+ let promptWritten = false
29
+ ;(rl as unknown as { _writeToOutput(s: string): void })._writeToOutput = (chunk: string) => {
30
+ if (!promptWritten) {
31
+ output.write(question)
32
+ promptWritten = true
33
+ }
34
+ // Preserve the newline when the user submits, so the next line is clean.
35
+ if (chunk.includes('\n')) output.write('\n')
36
+ }
37
+
38
+ rl.question(question, (answer) => {
39
+ rl.close()
40
+ resolve(answer.trim())
41
+ })
42
+ rl.on('SIGINT', () => {
43
+ rl.close()
44
+ output.write('\n')
45
+ reject(new Error('cancelled'))
46
+ })
47
+ })
48
+ }