@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.
- package/LICENSE +202 -0
- package/README.md +65 -0
- package/package.json +47 -0
- package/src/api/apps-api.ts +165 -0
- package/src/api/automation-api.ts +140 -0
- package/src/api/platform-api.ts +193 -0
- package/src/api/registry-api.ts +43 -0
- package/src/args.ts +108 -0
- package/src/commands/agent/compose.ts +155 -0
- package/src/commands/agent/index-commands.ts +348 -0
- package/src/commands/agent/resolve.ts +58 -0
- package/src/commands/app/add.ts +78 -0
- package/src/commands/app/deploy.ts +105 -0
- package/src/commands/app/init.ts +53 -0
- package/src/commands/app/list.ts +51 -0
- package/src/commands/app/promote.ts +31 -0
- package/src/commands/app/pull.ts +145 -0
- package/src/commands/app/save.ts +36 -0
- package/src/commands/app/shared.ts +25 -0
- package/src/commands/app/versions.ts +38 -0
- package/src/commands/automation/index-commands.ts +325 -0
- package/src/commands/blueprint/get.ts +160 -0
- package/src/commands/blueprint/list.ts +48 -0
- package/src/commands/blueprint/reserved.ts +40 -0
- package/src/commands/completion.ts +293 -0
- package/src/commands/init.ts +33 -0
- package/src/commands/knowledge/index-commands.ts +140 -0
- package/src/commands/login.ts +103 -0
- package/src/commands/plugin/index-commands.ts +112 -0
- package/src/commands/registry.ts +405 -0
- package/src/commands/skill/index-commands.ts +140 -0
- package/src/commands/types.ts +76 -0
- package/src/config.ts +142 -0
- package/src/context.ts +67 -0
- package/src/errors.ts +30 -0
- package/src/exit.ts +98 -0
- package/src/flag-help.ts +70 -0
- package/src/harness.ts +162 -0
- package/src/heal.ts +418 -0
- package/src/help.ts +128 -0
- package/src/main.ts +204 -0
- package/src/manifest.ts +80 -0
- package/src/output.ts +65 -0
- package/src/pack.ts +18 -0
- package/src/packaging.ts +116 -0
- package/src/project.ts +151 -0
- package/src/prompt.ts +48 -0
- package/src/registry.ts +62 -0
- package/src/secrets.ts +69 -0
- package/src/table.ts +47 -0
- package/src/tar.ts +73 -0
- package/src/template.ts +566 -0
- 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
|
+
})
|
package/src/manifest.ts
ADDED
|
@@ -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
|
+
}
|
package/src/packaging.ts
ADDED
|
@@ -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
|
+
}
|