@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/heal.ts
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repair a working tree that was packaged before the scaffold could package
|
|
3
|
+
* itself.
|
|
4
|
+
*
|
|
5
|
+
* ## The defect
|
|
6
|
+
*
|
|
7
|
+
* `frontera app pull` promises "a working copy". For an app stored by an older
|
|
8
|
+
* toolchain it did not deliver one: the `package.json` in storage declares
|
|
9
|
+
*
|
|
10
|
+
* ```json
|
|
11
|
+
* "@frontera-sdk/blueprint": "file:/Users/…/worktrees/…/packages/sdk-blueprint"
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* — an absolute path into a git worktree on one developer's laptop that no
|
|
15
|
+
* longer exists. `bun install` fails on every machine, including the one that
|
|
16
|
+
* authored it. There is no `src/frontera/`, and `tsconfig` carries only `@/*`,
|
|
17
|
+
* so nothing else resolves `@frontera-sdk/*` either.
|
|
18
|
+
*
|
|
19
|
+
* An agent handed that tree cannot install, cannot build, and cannot verify.
|
|
20
|
+
* The session this was written from spent roughly 42 operations working around
|
|
21
|
+
* it and finished by hand-writing a fake `@frontera-sdk/blueprint` — which then
|
|
22
|
+
* produced the screenshots it presented as evidence. Every number in them came
|
|
23
|
+
* from a linear congruential generator the agent wrote twenty minutes earlier.
|
|
24
|
+
*
|
|
25
|
+
* So this runs on pull, not as a separate `frontera app doctor` the model has
|
|
26
|
+
* to know to reach for. A repair the caller must discover is a repair that does
|
|
27
|
+
* not happen.
|
|
28
|
+
*
|
|
29
|
+
* ## How it repairs
|
|
30
|
+
*
|
|
31
|
+
* The vendored SDK is written into `src/frontera/<pkg>/`, the same layout
|
|
32
|
+
* `frontera app init` produces, and each directory is given a `package.json`
|
|
33
|
+
* whose `exports` map its subpaths. The app's `@frontera-sdk/*` dependency is then
|
|
34
|
+
* pointed at that directory with `file:`.
|
|
35
|
+
*
|
|
36
|
+
* That last step is what lets this touch no code. Aliases are the scaffold's
|
|
37
|
+
* mechanism and they are fine when the scaffold writes `vite.config.ts` itself
|
|
38
|
+
* — but a pulled project's Vite config is arbitrary TypeScript, and a healer
|
|
39
|
+
* that rewrites arbitrary TypeScript is a healer that eventually eats someone's
|
|
40
|
+
* config. A `file:` dependency is resolved by the package manager, honoured by
|
|
41
|
+
* both Vite and `tsc`, and expressed entirely in JSON. A scaffolded app keeps
|
|
42
|
+
* its aliases; they point at the same files and win first.
|
|
43
|
+
*
|
|
44
|
+
* Nothing here reaches the network: the SDK source is already compiled into the
|
|
45
|
+
* CLI (`src/vendor/sdk-sources.json`).
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
|
49
|
+
import { dirname, join } from 'node:path'
|
|
50
|
+
|
|
51
|
+
import {
|
|
52
|
+
APP_SDK_PACKAGES,
|
|
53
|
+
LEGACY_SDK_ALIASES,
|
|
54
|
+
currentSdkPackage,
|
|
55
|
+
devHostHtml,
|
|
56
|
+
sdkPackageFiles,
|
|
57
|
+
type AppSdkPackage,
|
|
58
|
+
} from './template'
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Dependency specs a package manager cannot resolve away from the machine that
|
|
62
|
+
* wrote them. `workspace:` needs this monorepo; `file:`/`link:`/`portal:` need
|
|
63
|
+
* a path that survived; `git+ssh` needs a key a sandbox does not have.
|
|
64
|
+
*/
|
|
65
|
+
const UNRESOLVABLE = /^(file|link|portal|workspace|git\+ssh):/
|
|
66
|
+
|
|
67
|
+
const SDK_PACKAGE_NAMES = Object.keys(APP_SDK_PACKAGES) as AppSdkPackage[]
|
|
68
|
+
|
|
69
|
+
export interface HealResult {
|
|
70
|
+
/** One line per repair, in the order they were applied. Empty = nothing to do. */
|
|
71
|
+
repairs: string[]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface PackageJson {
|
|
75
|
+
name?: string
|
|
76
|
+
dependencies?: Record<string, string>
|
|
77
|
+
devDependencies?: Record<string, string>
|
|
78
|
+
[key: string]: unknown
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Bring a pulled working tree up to something that installs and builds.
|
|
83
|
+
*
|
|
84
|
+
* Idempotent: a tree already in good shape gets no repairs and no writes, so
|
|
85
|
+
* re-pulling does not churn files. Never throws — a project too unusual to
|
|
86
|
+
* repair is left exactly as it was, because a half-repaired tree is worse than
|
|
87
|
+
* an honestly broken one.
|
|
88
|
+
*/
|
|
89
|
+
export function healProject(dir: string): HealResult {
|
|
90
|
+
const repairs: string[] = []
|
|
91
|
+
|
|
92
|
+
const manifestPath = join(dir, 'package.json')
|
|
93
|
+
let manifest: PackageJson | null = null
|
|
94
|
+
try {
|
|
95
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as PackageJson
|
|
96
|
+
} catch {
|
|
97
|
+
// No manifest, or not JSON. Not a project this CLI knows how to repair —
|
|
98
|
+
// and `pull` has already put the files on disk, which is the part the
|
|
99
|
+
// caller asked for.
|
|
100
|
+
return { repairs }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const name = typeof manifest.name === 'string' && manifest.name ? manifest.name : 'app'
|
|
104
|
+
let manifestChanged = false
|
|
105
|
+
|
|
106
|
+
// Packages a legacy tree needs declared under their CURRENT name as well, so
|
|
107
|
+
// the vendored copy resolves the siblings it imports internally.
|
|
108
|
+
const needCurrentScope = new Set<AppSdkPackage>()
|
|
109
|
+
|
|
110
|
+
// Legacy spellings this particular app uses. Only these get tsconfig paths:
|
|
111
|
+
// adding both spellings unconditionally reports a repair on a HEALTHY
|
|
112
|
+
// scaffolded app, which breaks the idempotency this function promises.
|
|
113
|
+
const legacyInUse = new Map<string, AppSdkPackage>()
|
|
114
|
+
|
|
115
|
+
// ── SDK dependencies, in either scope ───────────────────────────────────────
|
|
116
|
+
//
|
|
117
|
+
// A stored app declares whatever scope was current when it was packaged, and
|
|
118
|
+
// every app in storage predates `@frontera-sdk`. Matching only the new scope
|
|
119
|
+
// is matching only trees that do not need repairing.
|
|
120
|
+
//
|
|
121
|
+
// A legacy name keeps its key and is pointed at the vendored directory. The
|
|
122
|
+
// key is what the app's own `from '@frontera/blueprint/hooks'` resolves
|
|
123
|
+
// through, and a `file:` dependency installs under its key regardless of the
|
|
124
|
+
// name in the target's manifest — so one vendored copy serves both spellings
|
|
125
|
+
// and no source file has to be rewritten.
|
|
126
|
+
for (const field of ['dependencies', 'devDependencies'] as const) {
|
|
127
|
+
const deps = manifest[field]
|
|
128
|
+
if (!deps || typeof deps !== 'object') continue
|
|
129
|
+
for (const [dep, spec] of Object.entries(deps)) {
|
|
130
|
+
if (!dep.startsWith('@frontera-sdk/') && !dep.startsWith('@frontera/')) continue
|
|
131
|
+
if (typeof spec !== 'string' || !UNRESOLVABLE.test(spec)) continue
|
|
132
|
+
|
|
133
|
+
const pkg = currentSdkPackage(dep)
|
|
134
|
+
if (pkg) {
|
|
135
|
+
// Already pointing at the copy in this tree — that is the shape this
|
|
136
|
+
// function produces, so re-pulling must not rewrite it back over
|
|
137
|
+
// itself and report a repair that did nothing.
|
|
138
|
+
if (spec === `file:./${APP_SDK_PACKAGES[pkg].dir}` && vendorPresent(dir, pkg)) continue
|
|
139
|
+
vendorSdkPackage(dir, pkg)
|
|
140
|
+
deps[dep] = `file:./${APP_SDK_PACKAGES[pkg].dir}`
|
|
141
|
+
const under = dep === pkg ? '' : ` (legacy name, now ${pkg})`
|
|
142
|
+
repairs.push(`vendored ${dep} into ${APP_SDK_PACKAGES[pkg].dir}${under} (was ${truncate(spec)})`)
|
|
143
|
+
|
|
144
|
+
// The vendored source imports its sibling by the CURRENT name, so a
|
|
145
|
+
// legacy tree needs that spelling installed too or the copy cannot
|
|
146
|
+
// resolve its own imports.
|
|
147
|
+
if (dep !== pkg) {
|
|
148
|
+
needCurrentScope.add(pkg)
|
|
149
|
+
legacyInUse.set(dep, pkg)
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
// Not vendorable. Leaving a dead `file:` spec is the difference between
|
|
153
|
+
// a tree that installs and one that does not, so it goes — but the two
|
|
154
|
+
// reasons are not the same and the repair line should not pretend they
|
|
155
|
+
// are.
|
|
156
|
+
//
|
|
157
|
+
// The CLI is genuinely not a build input: it lives on PATH, and an app
|
|
158
|
+
// never imports it. Anything else here (`@frontera-sdk/automation`) is a
|
|
159
|
+
// real package that this app may well import, and dropping it trades a
|
|
160
|
+
// failing `bun install` for a failing `bun run build`. Rewriting it to a
|
|
161
|
+
// version range is the actual repair and is not available yet — nothing
|
|
162
|
+
// is on the registry — so the honest move is to say what was lost.
|
|
163
|
+
const isCli = dep.endsWith('/cli')
|
|
164
|
+
delete deps[dep]
|
|
165
|
+
repairs.push(
|
|
166
|
+
isCli
|
|
167
|
+
? `dropped ${dep} — the CLI is on PATH, not a build input (was ${truncate(spec)})`
|
|
168
|
+
: `dropped ${dep} — unresolvable and not vendorable; anything importing it will not build (was ${truncate(spec)})`,
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
manifestChanged = true
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// An app can also IMPORT the SDK without declaring it — the monorepo used to
|
|
176
|
+
// resolve `@frontera-sdk/*` for free, so a project authored there installs
|
|
177
|
+
// nowhere else and says nothing about why.
|
|
178
|
+
//
|
|
179
|
+
// A SCAFFOLDED app looks the same from the manifest and is not broken: it
|
|
180
|
+
// resolves through tsconfig `paths` and a matching Vite alias into the
|
|
181
|
+
// directory it already carries. `vendorPresent` is what tells them apart, and
|
|
182
|
+
// getting it wrong would write a second, differently-wired copy of the SDK
|
|
183
|
+
// into a project that has one — which is how a project ends up with two
|
|
184
|
+
// Reacts.
|
|
185
|
+
const imported = importedSdkPackages(join(dir, 'src'))
|
|
186
|
+
for (const { specifier, pkg } of imported) {
|
|
187
|
+
if (declares(manifest, specifier) || (specifier === pkg && vendorPresent(dir, pkg))) continue
|
|
188
|
+
vendorSdkPackage(dir, pkg)
|
|
189
|
+
manifest.dependencies ??= {}
|
|
190
|
+
manifest.dependencies[specifier] = `file:./${APP_SDK_PACKAGES[pkg].dir}`
|
|
191
|
+
manifestChanged = true
|
|
192
|
+
const under = specifier === pkg ? '' : ` (legacy name, now ${pkg})`
|
|
193
|
+
repairs.push(`vendored ${specifier} into ${APP_SDK_PACKAGES[pkg].dir}${under} (imported but never declared)`)
|
|
194
|
+
if (specifier !== pkg) {
|
|
195
|
+
needCurrentScope.add(pkg)
|
|
196
|
+
legacyInUse.set(specifier, pkg)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The vendored source's own imports are written in the current scope. A tree
|
|
201
|
+
// that only ever mentions the old one still needs those resolvable, or the
|
|
202
|
+
// copy this function just wrote cannot import its own sibling.
|
|
203
|
+
for (const pkg of needCurrentScope) {
|
|
204
|
+
if (declares(manifest, pkg)) continue
|
|
205
|
+
vendorSdkPackage(dir, pkg)
|
|
206
|
+
manifest.dependencies ??= {}
|
|
207
|
+
manifest.dependencies[pkg] = `file:./${APP_SDK_PACKAGES[pkg].dir}`
|
|
208
|
+
manifestChanged = true
|
|
209
|
+
repairs.push(`declared ${pkg} (the vendored copy imports it under its current name)`)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// The SDK's `config.ts` reads `process.env` behind a guard, so vendoring it
|
|
213
|
+
// into a project without `@types/node` turns `bun run typecheck` — one of the
|
|
214
|
+
// two commands the shipped skill says to run before claiming done — red, with
|
|
215
|
+
// four errors inside code the author was told not to edit. An agent that sees
|
|
216
|
+
// that goes and fixes the SDK.
|
|
217
|
+
if (vendored(dir) && !manifest.devDependencies?.['@types/node'] && !manifest.dependencies?.['@types/node']) {
|
|
218
|
+
manifest.devDependencies ??= {}
|
|
219
|
+
manifest.devDependencies['@types/node'] = '^22'
|
|
220
|
+
manifestChanged = true
|
|
221
|
+
repairs.push('added @types/node (the vendored SDK type-checks a `process` guard)')
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (manifestChanged) writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
|
225
|
+
|
|
226
|
+
// Point the editor at the vendored copy directly. Without this TypeScript
|
|
227
|
+
// reaches it through the `file:` link instead and reports every diagnostic
|
|
228
|
+
// twice — once under `src/`, once under `node_modules/` — for one file.
|
|
229
|
+
// Vite is unaffected either way: it resolves the link to the same real path.
|
|
230
|
+
const tsconfigRepair = ensureTsconfigPaths(dir, legacyInUse)
|
|
231
|
+
if (tsconfigRepair) repairs.push(tsconfigRepair)
|
|
232
|
+
|
|
233
|
+
// ── the dev host ──────────────────────────────────────────────────────────
|
|
234
|
+
// `bun run dev` serves the app at `/`, where `connectToHost` refuses an
|
|
235
|
+
// unknown parent and renders a panel. Without this page there is no way to
|
|
236
|
+
// see the app running at all, and a screenshot of `/` proves nothing.
|
|
237
|
+
const devHost = join(dir, 'dev-host.html')
|
|
238
|
+
if (!existsSync(devHost)) {
|
|
239
|
+
writeFileSync(devHost, devHostHtml(name))
|
|
240
|
+
repairs.push('wrote dev-host.html (open /dev-host.html, never /)')
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { repairs }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function isSdkPackage(dep: string): dep is AppSdkPackage {
|
|
247
|
+
return (SDK_PACKAGE_NAMES as string[]).includes(dep)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Does this tree already carry the SDK's source where the scaffold puts it? */
|
|
251
|
+
function vendorPresent(dir: string, pkg: AppSdkPackage): boolean {
|
|
252
|
+
return existsSync(join(dir, APP_SDK_PACKAGES[pkg].dir))
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Does it carry any of them? */
|
|
256
|
+
function vendored(dir: string): boolean {
|
|
257
|
+
return SDK_PACKAGE_NAMES.some((pkg) => vendorPresent(dir, pkg))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Add the two `@frontera-sdk/*` path mappings, keeping everything already there.
|
|
262
|
+
*
|
|
263
|
+
* Returns a repair line, or null when there was nothing to do — including when
|
|
264
|
+
* `tsconfig.json` is missing or is JSON with comments, which `JSON.parse`
|
|
265
|
+
* refuses. Skipping is the right answer there: the `file:` dependency already
|
|
266
|
+
* makes the imports resolve, so this is a duplicate-diagnostics fix, and a
|
|
267
|
+
* healer that reformats someone's commented config to win it is a bad trade.
|
|
268
|
+
*/
|
|
269
|
+
function ensureTsconfigPaths(dir: string, legacyInUse: Map<string, AppSdkPackage>): string | null {
|
|
270
|
+
const path = join(dir, 'tsconfig.json')
|
|
271
|
+
let config: { compilerOptions?: { paths?: Record<string, string[]> } }
|
|
272
|
+
let raw: string
|
|
273
|
+
try {
|
|
274
|
+
raw = readFileSync(path, 'utf8')
|
|
275
|
+
config = JSON.parse(raw) as typeof config
|
|
276
|
+
} catch {
|
|
277
|
+
return null
|
|
278
|
+
}
|
|
279
|
+
if (!config.compilerOptions) return null
|
|
280
|
+
|
|
281
|
+
const paths = config.compilerOptions.paths ?? {}
|
|
282
|
+
let added = false
|
|
283
|
+
// Current names always; legacy names only where this app uses them, so an
|
|
284
|
+
// editor follows a legacy import to the vendored source instead of through the
|
|
285
|
+
// `file:` link and reporting every diagnostic twice.
|
|
286
|
+
const spellings: Array<[string, AppSdkPackage]> = [
|
|
287
|
+
...SDK_PACKAGE_NAMES.map((pkg) => [pkg, pkg] as [string, AppSdkPackage]),
|
|
288
|
+
...legacyInUse,
|
|
289
|
+
]
|
|
290
|
+
for (const [specifier, pkg] of spellings) {
|
|
291
|
+
const key = `${specifier}/*`
|
|
292
|
+
if (paths[key] || !vendorPresent(dir, pkg)) continue
|
|
293
|
+
paths[key] = [`./${APP_SDK_PACKAGES[pkg].dir}/*`]
|
|
294
|
+
added = true
|
|
295
|
+
}
|
|
296
|
+
if (!added) return null
|
|
297
|
+
|
|
298
|
+
config.compilerOptions.paths = paths
|
|
299
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`)
|
|
300
|
+
return 'mapped @frontera-sdk/* in tsconfig paths'
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function declares(manifest: PackageJson, dep: string): boolean {
|
|
304
|
+
return Boolean(manifest.dependencies?.[dep] ?? manifest.devDependencies?.[dep])
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function truncate(spec: string): string {
|
|
308
|
+
return spec.length > 60 ? `${spec.slice(0, 57)}…` : spec
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Write one SDK package's source into the project, plus the `package.json`
|
|
313
|
+
* that makes the directory resolvable as `@frontera-sdk/<pkg>`.
|
|
314
|
+
*
|
|
315
|
+
* Overwrites unconditionally. The vendored copy is not the author's code — it
|
|
316
|
+
* is the SDK, and the shipped skill says so ("Read it freely; do not edit it").
|
|
317
|
+
* A tree being healed has already proven it cannot resolve the real thing, so
|
|
318
|
+
* preferring whatever partial copy is on disk would mean preferring the fake
|
|
319
|
+
* one an agent wrote to get past this.
|
|
320
|
+
*/
|
|
321
|
+
function vendorSdkPackage(dir: string, pkg: AppSdkPackage): void {
|
|
322
|
+
const target = join(dir, APP_SDK_PACKAGES[pkg].dir)
|
|
323
|
+
const sources = sdkPackageFiles(pkg)
|
|
324
|
+
|
|
325
|
+
for (const [rel, content] of Object.entries(sources)) {
|
|
326
|
+
const full = join(target, rel)
|
|
327
|
+
mkdirSync(dirname(full), { recursive: true })
|
|
328
|
+
writeFileSync(full, content)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
writeFileSync(
|
|
332
|
+
join(target, 'package.json'),
|
|
333
|
+
`${JSON.stringify(vendoredManifest(pkg, Object.keys(sources)), null, 2)}\n`,
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The manifest for a vendored SDK directory.
|
|
339
|
+
*
|
|
340
|
+
* `exports` is derived from the files actually written rather than copied from
|
|
341
|
+
* the real package, so a module added to the SDK cannot be vendored-but-
|
|
342
|
+
* unreachable. Subpaths point at `.ts`/`.tsx` directly: the consumer is always
|
|
343
|
+
* a bundler or `tsc`, never Node, and shipping a build step into every healed
|
|
344
|
+
* app to produce sources it would immediately re-transpile buys nothing.
|
|
345
|
+
*/
|
|
346
|
+
export function vendoredManifest(pkg: AppSdkPackage, files: string[]): Record<string, unknown> {
|
|
347
|
+
const exports: Record<string, unknown> = { './package.json': './package.json' }
|
|
348
|
+
for (const rel of files.sort()) {
|
|
349
|
+
if (!/\.tsx?$/.test(rel)) continue
|
|
350
|
+
exports[`./${rel.replace(/\.tsx?$/, '')}`] = { types: `./${rel}`, import: `./${rel}` }
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
name: pkg,
|
|
354
|
+
version: '0.0.0-vendored',
|
|
355
|
+
private: true,
|
|
356
|
+
// The terms the copied source arrives under. `LICENSE` sits beside it,
|
|
357
|
+
// written from the same vendored set as the modules.
|
|
358
|
+
license: 'Apache-2.0',
|
|
359
|
+
type: 'module',
|
|
360
|
+
exports,
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Which SDK packages this project's own source imports. */
|
|
365
|
+
function importedSdkPackages(srcDir: string): Array<{ specifier: string; pkg: AppSdkPackage }> {
|
|
366
|
+
// Keyed by the specifier the app actually wrote, because that is what has to
|
|
367
|
+
// resolve. Scanning for current names only would miss every stored app, all
|
|
368
|
+
// of which import the old scope.
|
|
369
|
+
const found = new Map<string, AppSdkPackage>()
|
|
370
|
+
const candidates = [...SDK_PACKAGE_NAMES as string[], ...Object.keys(LEGACY_SDK_ALIASES)]
|
|
371
|
+
for (const file of walk(srcDir)) {
|
|
372
|
+
if (!/\.(ts|tsx|js|jsx)$/.test(file)) continue
|
|
373
|
+
// Skip the vendored copies themselves: `@frontera-sdk/blueprint` imports
|
|
374
|
+
// `@frontera-sdk/core/client`, and reading that as an app import would make
|
|
375
|
+
// the scan self-fulfilling.
|
|
376
|
+
if (file.includes(`${join('src', 'frontera')}`)) continue
|
|
377
|
+
let text: string
|
|
378
|
+
try {
|
|
379
|
+
text = readFileSync(file, 'utf8')
|
|
380
|
+
} catch {
|
|
381
|
+
continue
|
|
382
|
+
}
|
|
383
|
+
for (const specifier of candidates) {
|
|
384
|
+
if (!text.includes(`'${specifier}/`) && !text.includes(`"${specifier}/`)) continue
|
|
385
|
+
found.set(specifier, currentSdkPackage(specifier)!)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
// blueprint re-exports types from core, so an app that imports only the former
|
|
389
|
+
// still needs both on disk — in whichever spelling it is already using.
|
|
390
|
+
for (const [specifier, pkg] of [...found]) {
|
|
391
|
+
if (pkg !== '@frontera-sdk/blueprint') continue
|
|
392
|
+
const core = specifier.startsWith('@frontera/') ? '@frontera/sdk-core' : '@frontera-sdk/core'
|
|
393
|
+
if (!found.has(core)) found.set(core, '@frontera-sdk/core')
|
|
394
|
+
}
|
|
395
|
+
return [...found].map(([specifier, pkg]) => ({ specifier, pkg }))
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function walk(dir: string, out: string[] = []): string[] {
|
|
399
|
+
let entries: string[]
|
|
400
|
+
try {
|
|
401
|
+
entries = readdirSync(dir)
|
|
402
|
+
} catch {
|
|
403
|
+
return out
|
|
404
|
+
}
|
|
405
|
+
for (const entry of entries) {
|
|
406
|
+
if (entry === 'node_modules' || entry === 'dist' || entry.startsWith('.')) continue
|
|
407
|
+
const full = join(dir, entry)
|
|
408
|
+
let stats
|
|
409
|
+
try {
|
|
410
|
+
stats = statSync(full)
|
|
411
|
+
} catch {
|
|
412
|
+
continue
|
|
413
|
+
}
|
|
414
|
+
if (stats.isDirectory()) walk(full, out)
|
|
415
|
+
else out.push(full)
|
|
416
|
+
}
|
|
417
|
+
return out
|
|
418
|
+
}
|
package/src/help.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout and colour for help text.
|
|
3
|
+
*
|
|
4
|
+
* Help was a `padEnd(46)` and a summary, which meant every description longer
|
|
5
|
+
* than the remaining columns wrapped at the terminal edge — mid-word, back to
|
|
6
|
+
* column zero, so `frontera app add`'s summary read "…from the registry i /
|
|
7
|
+
* to this project". Codex and Claude Code both wrap with a hanging indent and
|
|
8
|
+
* are legible at 80 columns; Bun does not, and its own `--help` breaks the same
|
|
9
|
+
* way. This follows the ones that work.
|
|
10
|
+
*
|
|
11
|
+
* Colour is opt-out via NO_COLOR, and off entirely when stdout is not a TTY —
|
|
12
|
+
* an agent capturing `--help` gets clean text with no escape sequences to strip.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const MIN_WIDTH = 60
|
|
16
|
+
const MAX_WIDTH = 100
|
|
17
|
+
|
|
18
|
+
export function terminalWidth(): number {
|
|
19
|
+
const columns = process.stdout.columns
|
|
20
|
+
if (!columns || Number.isNaN(columns)) return 80
|
|
21
|
+
return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, columns))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function colorEnabled(): boolean {
|
|
25
|
+
// https://no-color.org — any value, including empty, disables.
|
|
26
|
+
if (process.env.NO_COLOR !== undefined) return false
|
|
27
|
+
if (process.env.FORCE_COLOR !== undefined) return true
|
|
28
|
+
return Boolean(process.stdout.isTTY)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function paint(code: string, close: string) {
|
|
32
|
+
return (text: string): string => (colorEnabled() ? `\x1b[${code}m${text}\x1b[${close}m` : text)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const bold = paint('1', '22')
|
|
36
|
+
export const dim = paint('2', '22')
|
|
37
|
+
/** Command names, matching the accent Bun and Codex use for the same thing. */
|
|
38
|
+
export const accent = paint('36', '39')
|
|
39
|
+
|
|
40
|
+
/** Visible width, ignoring any escape sequences already applied. */
|
|
41
|
+
function visibleLength(text: string): number {
|
|
42
|
+
// eslint-disable-next-line no-control-regex
|
|
43
|
+
return text.replace(/\x1b\[\d+m/g, '').length
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Greedy wrap. Words longer than the line (a URL, a long flag) are left whole
|
|
48
|
+
* and allowed to overflow rather than broken — a split identifier cannot be
|
|
49
|
+
* copied, which is the same reason `table.ts` never truncates an id.
|
|
50
|
+
*/
|
|
51
|
+
export function wrap(text: string, width: number): string[] {
|
|
52
|
+
if (width <= 0) return [text]
|
|
53
|
+
const lines: string[] = []
|
|
54
|
+
let current = ''
|
|
55
|
+
|
|
56
|
+
for (const word of text.split(/\s+/).filter(Boolean)) {
|
|
57
|
+
if (!current) current = word
|
|
58
|
+
else if (current.length + 1 + word.length <= width) current += ` ${word}`
|
|
59
|
+
else {
|
|
60
|
+
lines.push(current)
|
|
61
|
+
current = word
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (current) lines.push(current)
|
|
65
|
+
return lines.length > 0 ? lines : ['']
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface DefinitionOptions {
|
|
69
|
+
/** Leading spaces before the term. */
|
|
70
|
+
indent?: number
|
|
71
|
+
/** Spaces between the term column and the description column. */
|
|
72
|
+
gap?: number
|
|
73
|
+
width?: number
|
|
74
|
+
/**
|
|
75
|
+
* Terms wider than this get their description on the following line rather
|
|
76
|
+
* than pushing every description off the right edge.
|
|
77
|
+
*/
|
|
78
|
+
maxTerm?: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A term/description list — the shape every section of every help page uses.
|
|
83
|
+
*
|
|
84
|
+
* The description column is computed from the widest term that fits, and
|
|
85
|
+
* continuation lines align under it, so a wrapped summary still reads as one
|
|
86
|
+
* block instead of drifting back to the margin.
|
|
87
|
+
*/
|
|
88
|
+
export function definitions(
|
|
89
|
+
rows: ReadonlyArray<readonly [string, string]>,
|
|
90
|
+
options: DefinitionOptions = {},
|
|
91
|
+
): string[] {
|
|
92
|
+
const indent = options.indent ?? 2
|
|
93
|
+
const gap = options.gap ?? 2
|
|
94
|
+
const width = options.width ?? terminalWidth()
|
|
95
|
+
const maxTerm = options.maxTerm ?? 34
|
|
96
|
+
|
|
97
|
+
const fitting = rows.map(([term]) => visibleLength(term)).filter((n) => n <= maxTerm)
|
|
98
|
+
const termWidth = fitting.length > 0 ? Math.max(...fitting) : maxTerm
|
|
99
|
+
const descColumn = indent + termWidth + gap
|
|
100
|
+
const descWidth = Math.max(20, width - descColumn)
|
|
101
|
+
|
|
102
|
+
const out: string[] = []
|
|
103
|
+
for (const [term, description] of rows) {
|
|
104
|
+
const pad = ' '.repeat(indent)
|
|
105
|
+
if (!description) {
|
|
106
|
+
out.push(`${pad}${term}`)
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const lines = wrap(description, descWidth)
|
|
111
|
+
if (visibleLength(term) > termWidth) {
|
|
112
|
+
// Too wide to share a line — the description starts underneath.
|
|
113
|
+
out.push(`${pad}${term}`)
|
|
114
|
+
for (const line of lines) out.push(`${' '.repeat(descColumn)}${line}`)
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const padding = ' '.repeat(termWidth - visibleLength(term) + gap)
|
|
119
|
+
out.push(`${pad}${term}${padding}${lines[0]}`)
|
|
120
|
+
for (const line of lines.slice(1)) out.push(`${' '.repeat(descColumn)}${line}`)
|
|
121
|
+
}
|
|
122
|
+
return out
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A section heading, matching the `Usage` / `Commands` style of codex. */
|
|
126
|
+
export function heading(text: string): string {
|
|
127
|
+
return bold(text)
|
|
128
|
+
}
|