@frontera-sdk/cli 1.43.9 → 1.44.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 (42) hide show
  1. package/README.md +140 -12
  2. package/package.json +3 -3
  3. package/src/adopt.ts +436 -0
  4. package/src/api/apps-api.ts +30 -0
  5. package/src/api/blueprint-authoring-api.ts +13 -2
  6. package/src/api/governed-action-api.ts +192 -0
  7. package/src/api/platform-api.ts +4 -0
  8. package/src/blueprint/ontology-edit-plan.ts +195 -0
  9. package/src/blueprint-types.ts +252 -0
  10. package/src/commands/action/deploy.ts +135 -0
  11. package/src/commands/action/grant.ts +68 -0
  12. package/src/commands/action/index-commands.ts +29 -0
  13. package/src/commands/action/list.ts +49 -0
  14. package/src/commands/action/prepare.ts +48 -0
  15. package/src/commands/action/review.ts +94 -0
  16. package/src/commands/app/deploy.ts +16 -5
  17. package/src/commands/app/dev.ts +173 -0
  18. package/src/commands/app/init.ts +270 -28
  19. package/src/commands/app/sdk.ts +31 -0
  20. package/src/commands/app/versions.ts +8 -1
  21. package/src/commands/blueprint/editable.ts +151 -0
  22. package/src/commands/blueprint/generate-types.ts +58 -0
  23. package/src/commands/blueprint/get.ts +29 -34
  24. package/src/commands/blueprint/list.ts +2 -1
  25. package/src/commands/registry.ts +12 -0
  26. package/src/context.ts +4 -4
  27. package/src/dev-broker.ts +71 -0
  28. package/src/flag-help.ts +24 -1
  29. package/src/heal.ts +37 -2
  30. package/src/manifest.ts +89 -8
  31. package/src/packaging.ts +6 -0
  32. package/src/project-bootstrap.ts +176 -0
  33. package/src/project.ts +68 -35
  34. package/src/provenance.ts +89 -0
  35. package/src/render-evidence.ts +28 -0
  36. package/src/sdk-sync.ts +41 -0
  37. package/src/shadcn-components.ts +106 -0
  38. package/src/static-app-validation.ts +67 -0
  39. package/src/template.ts +211 -32
  40. package/src/templates/next-app-files.ts +1052 -0
  41. package/src/templates/next-skills.ts +1216 -0
  42. package/src/vendor/sdk-sources.json +21 -15
@@ -1,30 +1,194 @@
1
- import { join } from 'node:path'
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join, resolve } from 'node:path'
2
3
 
4
+ import { detectAdoption, mergeManifest, planAdoption } from '../../adopt'
3
5
  import { UsageError } from '../../errors'
4
- import { scaffold, scaffoldFiles } from '../../template'
6
+ import {
7
+ assertTargetAvailable,
8
+ initGitRepository,
9
+ installDependencies,
10
+ validateAppName,
11
+ } from '../../project-bootstrap'
12
+ import { BASELINE_COMPONENTS, addShadcnComponents, shadcnAddCommand } from '../../shadcn-components'
13
+ import { scaffold, scaffoldFiles, type AppFramework } from '../../template'
5
14
  import { writeHarnessFiles } from '../../harness'
6
15
  import { DEV_ENV_FILE, writeDevEnv } from '../../dev-env'
7
- import type { Command } from '../types'
16
+ import { flagBool, flagString, type Command, type CommandContext, type CommandResult } from '../types'
17
+
18
+ /**
19
+ * Scaffold a project someone can start working in.
20
+ *
21
+ * The bar is the one every other framework's `create-*` command already meets:
22
+ * refuse a name or a directory that cannot work BEFORE writing anything, say
23
+ * what is being made and where, install it, put it on a first commit, and end
24
+ * with commands that are correct for the project that now exists. Stopping at
25
+ * "files written" left the author to retype four commands and to discover the
26
+ * failures — an occupied directory, an invalid package name — after the damage.
27
+ */
28
+ /**
29
+ * Initialise an existing Next.js project in place.
30
+ *
31
+ * Writes what did not exist, adds the keys that were missing, and REPORTS
32
+ * everything it will not do for you. The three files it refuses to rewrite —
33
+ * the Next config, the root layout, the stylesheet — are the ones where a
34
+ * codemod that guesses wrong is indistinguishable from vandalism, and where the
35
+ * author is the only one who knows what the surrounding code means.
36
+ *
37
+ * No `git init` here, ever: an existing project has whatever history it has.
38
+ */
39
+ async function adoptExistingProject(ctx: CommandContext, root: string): Promise<CommandResult> {
40
+ const target = detectAdoption(root)
41
+ const force = flagBool(ctx, 'force')
42
+ if (target.alreadyAdopted && !force) {
43
+ throw new UsageError(
44
+ `${root} is already a Frontera App`,
45
+ 'frontera.config.json is already here — pass --force to rewrite what is missing',
46
+ )
47
+ }
48
+
49
+ const plan = planAdoption(target)
50
+ // The same CLI skill `frontera init` writes; it skips files that exist.
51
+ const harness = writeHarnessFiles(root)
52
+
53
+ ctx.output.note(`Initializing a Frontera App in ${root}`)
54
+ ctx.output.note('')
55
+ ctx.output.note(` router ${target.appDir}/ (App Router)`)
56
+ ctx.output.note(` stylesheet ${target.css}`)
57
+ ctx.output.note(` alias ${target.alias}/`)
58
+ ctx.output.note('')
59
+
60
+ for (const [path, content] of Object.entries(plan.files)) {
61
+ const full = join(root, path)
62
+ mkdirSync(dirname(full), { recursive: true })
63
+ writeFileSync(full, content)
64
+ }
65
+ const written = Object.keys(plan.files)
66
+ ctx.output.note(` ${written.length} files written, existing files untouched`)
67
+
68
+ const manifestPath = join(root, 'package.json')
69
+ const added = [
70
+ ...Object.keys(plan.dependencies),
71
+ ...Object.keys(plan.devDependencies),
72
+ ...Object.keys(plan.scripts),
73
+ ]
74
+ if (added.length > 0) {
75
+ writeFileSync(manifestPath, mergeManifest(readFileSync(manifestPath, 'utf8'), plan))
76
+ ctx.output.note(` package.json + ${added.join(', ')}`)
77
+ }
78
+ ctx.output.note('')
79
+
80
+ let installed = false
81
+ const needsInstall = Object.keys(plan.dependencies).length + Object.keys(plan.devDependencies).length > 0
82
+ if (needsInstall && !flagBool(ctx, 'no-install')) {
83
+ ctx.output.note('Installing dependencies with bun…')
84
+ const result = await installDependencies(root)
85
+ installed = result.installed
86
+ ctx.output.note(result.installed ? ` ${result.summary}` : ` install skipped: ${result.summary}`)
87
+ ctx.output.note('')
88
+ }
89
+
90
+ // The manual steps are the point of the command, not an apology for it: an
91
+ // adopted project is not finished until they are done, so they are the last
92
+ // thing on screen and they carry the exact edit.
93
+ const next = [
94
+ ` cd ${root}`,
95
+ ...(installed || !needsInstall ? [] : [' bun install']),
96
+ ' frontera app dev',
97
+ ' bun run build',
98
+ ]
99
+
100
+ return {
101
+ data: {
102
+ mode: 'adopted',
103
+ path: root,
104
+ appDir: target.appDir,
105
+ files: written,
106
+ dependencies: [...Object.keys(plan.dependencies), ...Object.keys(plan.devDependencies)],
107
+ scripts: Object.keys(plan.scripts),
108
+ manual: plan.manual,
109
+ harnessFiles: harness.written,
110
+ installed,
111
+ },
112
+ text: [
113
+ `Adopted ${target.packageName} at ${root}`,
114
+ ...(plan.manual.length > 0
115
+ ? ['', 'Finish by hand — these edit files you already wrote:', ...plan.manual.map((m) => ` · ${m}`)]
116
+ : []),
117
+ '',
118
+ ...next,
119
+ '',
120
+ ' AGENTS.md and .agents/skills/ carry the patterns this project now follows.',
121
+ ].join('\n'),
122
+ }
123
+ }
8
124
 
9
125
  export const appInit: Command = {
10
126
  meta: {
11
127
  noun: 'app',
12
128
  verb: 'init',
13
- args: [{ name: 'name', required: true, description: 'directory and bootstrap slug for the new app' }],
14
- flags: { dir: 'string' },
15
- summary: 'Scaffold a new Frontera app project',
16
- examples: ['frontera app init shipments-console'],
129
+ args: [
130
+ {
131
+ name: 'name',
132
+ required: false,
133
+ description: 'directory to create; omit to adopt the project in the current directory',
134
+ },
135
+ ],
136
+ flags: {
137
+ dir: 'string',
138
+ framework: 'string',
139
+ 'no-install': 'boolean',
140
+ 'no-git': 'boolean',
141
+ 'no-components': 'boolean',
142
+ force: 'boolean',
143
+ },
144
+ summary: 'Create a new Frontera App, or adopt the existing project here',
145
+ examples: [
146
+ 'frontera app init shipments-console',
147
+ 'frontera app init',
148
+ 'frontera app init legacy-console --framework react',
149
+ 'frontera app init shipments-console --no-install --no-git --no-components',
150
+ ],
17
151
  // Scaffolding must work before a credential exists.
18
152
  offline: true,
19
153
  },
20
154
 
21
155
  async run(ctx) {
22
156
  const name = ctx.positional[0]
23
- if (!name) throw new UsageError('missing <name>', 'frontera app init <name>')
157
+ const base = flagString(ctx, 'dir') ?? ctx.cwd
158
+
159
+ /**
160
+ * Which of the two jobs this is, decided the way `shadcn init` decides it:
161
+ * a `package.json` at the target means initialise IN PLACE, its absence
162
+ * means create. An FDE usually meets the second case first — the customer
163
+ * already has an app, or the work started from `create-next-app` — and
164
+ * "scaffold a new one and copy your code across" is not an answer.
165
+ */
166
+ const adoptTarget = resolve(name ? join(base, name) : base)
167
+ if (existsSync(join(adoptTarget, 'package.json'))) {
168
+ return adoptExistingProject(ctx, adoptTarget)
169
+ }
170
+ if (!name) {
171
+ throw new UsageError(
172
+ `no package.json in ${adoptTarget}`,
173
+ 'run `frontera app init <name>` to create a project, or cd into an existing one',
174
+ )
175
+ }
24
176
 
25
- const base = typeof ctx.flags.dir === 'string' ? ctx.flags.dir : ctx.cwd
26
- const target = join(base, name)
27
- scaffold(target, name)
177
+ const requested = flagString(ctx, 'framework')
178
+ if (requested !== undefined && requested !== 'next' && requested !== 'react') {
179
+ throw new UsageError('unsupported framework', 'use --framework next or --framework react')
180
+ }
181
+ const framework: AppFramework = (requested as AppFramework | undefined) ?? 'next'
182
+ const target = resolve(join(flagString(ctx, 'dir') ?? ctx.cwd, name))
183
+
184
+ // Both checks precede the first write. A scaffold that fails halfway is
185
+ // worse than one that never started, because the caller cannot tell which
186
+ // of the files in front of them are theirs.
187
+ validateAppName(name)
188
+ assertTargetAvailable(target)
189
+
190
+ const files = scaffoldFiles(name, framework)
191
+ scaffold(target, name, framework)
28
192
  // The app project is also a directory a harness will work in, so it gets
29
193
  // the same AGENTS.md + CLI skill that `frontera init` writes.
30
194
  const harness = writeHarnessFiles(target)
@@ -32,12 +196,27 @@ export const appInit: Command = {
32
196
  // The SDK is written into `src/frontera/` rather than declared as a
33
197
  // dependency, so `package.json` asks for nothing but public npm and this
34
198
  // installs on a laptop or in a sandbox that has never seen the monorepo.
35
- // Reported here because it is the one surprising thing about the tree, and
36
- // an agent that does not know it will try to "fix" the imports.
37
- const vendored = Object.keys(scaffoldFiles(name)).filter((f) =>
38
- f.startsWith('src/frontera/'),
39
- ).length
40
- ctx.output.note(` SDK vendored into src/frontera/ (${vendored} files) — imports resolve by alias`)
199
+ // Reported because it is the one surprising thing about the legacy tree,
200
+ // and an agent that does not know it will try to "fix" the imports.
201
+ const vendored = Object.keys(files).filter((f) => f.startsWith('src/frontera/')).length
202
+ const skills = Object.keys(files).filter((f) => f.endsWith('SKILL.md')).length
203
+
204
+ ctx.output.note(`Creating a Frontera App in ${target}`)
205
+ ctx.output.note('')
206
+ ctx.output.note(
207
+ ` framework ${framework === 'next' ? 'Next.js (static export)' : 'Vite + React (legacy)'}`,
208
+ )
209
+ ctx.output.note(
210
+ vendored > 0
211
+ ? ` SDK vendored into src/frontera/ (${vendored} files) — imports resolve by alias`
212
+ : ' SDK public @frontera-sdk packages, versioned with the App dependencies',
213
+ )
214
+ ctx.output.note(
215
+ framework === 'next'
216
+ ? ` patterns ${skills + harness.written.length} skills, and a reference feature under src/ui/`
217
+ : ` patterns ${skills + harness.written.length} skills`,
218
+ )
219
+ ctx.output.note('')
41
220
 
42
221
  // The dev host reads VITE_FRONTERA_TOKEN and 401s on every platform read
43
222
  // without it. The credential is already on this machine; only a scaffolded
@@ -47,28 +226,91 @@ export const appInit: Command = {
47
226
  //
48
227
  // Best-effort by construction: `offline: true` above means this command
49
228
  // must work before anyone has logged in.
50
- const devEnv = writeDevEnv(target)
51
- ctx.output.note(
52
- devEnv.written
53
- ? ` ${DEV_ENV_FILE} written — the dev host will read real data`
54
- : ` no ${DEV_ENV_FILE} (${devEnv.reason === 'no-credential' ? 'no stored credential' : devEnv.reason}) — the dev host will mount but platform reads will 401`,
55
- )
229
+ const devEnv = framework === 'react' ? writeDevEnv(target) : { written: false as const, reason: 'not-needed' }
230
+ if (framework === 'react') {
231
+ ctx.output.note(
232
+ devEnv.written
233
+ ? ` ${DEV_ENV_FILE} written — the dev host will read real data`
234
+ : ` no ${DEV_ENV_FILE} (${devEnv.reason === 'no-credential' ? 'no stored credential' : devEnv.reason}) — the dev host will mount but platform reads will 401`,
235
+ )
236
+ ctx.output.note('')
237
+ }
238
+
239
+ let installed = false
240
+ if (!flagBool(ctx, 'no-install')) {
241
+ ctx.output.note('Installing dependencies with bun…')
242
+ const result = await installDependencies(target)
243
+ installed = result.installed
244
+ ctx.output.note(
245
+ result.installed
246
+ ? ` ${result.summary}`
247
+ : ` install skipped: ${result.summary} — run \`bun install\` when you have a network`,
248
+ )
249
+ ctx.output.note('')
250
+ }
251
+
252
+ // Shared primitives come from the shadcn registry rather than a library
253
+ // this repository maintains, so they are fetched rather than written. The
254
+ // reference feature imports them, which is why a failure has to name the
255
+ // command that finishes the job instead of being swallowed.
256
+ let components = false
257
+ if (framework === 'next' && !flagBool(ctx, 'no-components')) {
258
+ ctx.output.note(`Adding shadcn components: ${BASELINE_COMPONENTS.join(', ')}…`)
259
+ const result = await addShadcnComponents(target)
260
+ components = result.added
261
+ ctx.output.note(result.added ? ` ${result.summary}` : ` not added: ${result.summary}`)
262
+ ctx.output.note('')
263
+ }
264
+
265
+ const git = flagBool(ctx, 'no-git')
266
+ ? { initialized: false, reason: 'skipped' as const }
267
+ : await initGitRepository(target)
268
+ if (git.initialized) {
269
+ ctx.output.note('Initialized a git repository with an initial commit.')
270
+ ctx.output.note('')
271
+ } else if (git.reason === 'already-in-repository') {
272
+ ctx.output.note('Inside an existing git repository — no repository created.')
273
+ ctx.output.note('')
274
+ }
275
+
276
+ const next = [
277
+ ` cd ${name}`,
278
+ ...(installed ? [] : [' bun install']),
279
+ // Whenever they are absent — skipped or failed. The reference feature
280
+ // imports these files, so without them the first `bun run check` fails
281
+ // on a missing module and the cause is two screens up.
282
+ ...(framework === 'next' && !components ? [` ${shadcnAddCommand()}`] : []),
283
+ // The legacy Vite scaffold has neither `frontera app dev` as its entry
284
+ // point nor a `check` script; printing commands it does not have is how
285
+ // a first session starts with an error.
286
+ ...(framework === 'react'
287
+ ? [' bun run dev', ' bun run typecheck', ' bun run build']
288
+ : [' frontera app dev', ' bun run check']),
289
+ ' frontera app deploy --no-promote',
290
+ ]
56
291
 
57
292
  return {
58
293
  data: {
59
294
  name,
295
+ framework,
60
296
  path: target,
297
+ files: Object.keys(files).length,
298
+ skills,
61
299
  harnessFiles: harness.written,
62
300
  vendoredSdkFiles: vendored,
63
301
  devEnvWritten: devEnv.written,
302
+ installed,
303
+ components,
304
+ gitInitialized: git.initialized,
64
305
  },
65
306
  text: [
66
- `Scaffolded ${name}`,
307
+ `Success! Created ${name} at ${target}`,
308
+ '',
309
+ ...next,
67
310
  '',
68
- ` cd ${name}`,
69
- ' bun install',
70
- ' bun run build',
71
- ' frontera app deploy',
311
+ // The tree carries its own instructions; saying so is what makes an
312
+ // agent read them instead of inferring a house style from one file.
313
+ ' AGENTS.md routes to the patterns this project already follows.',
72
314
  ].join('\n'),
73
315
  }
74
316
  },
@@ -0,0 +1,31 @@
1
+ import { UsageError } from '../../errors'
2
+ import { syncVendoredAppSdk } from '../../sdk-sync'
3
+ import { requireProjectFrom } from './shared'
4
+ import type { Command } from '../types'
5
+
6
+ export const appSdk: Command = {
7
+ meta: {
8
+ noun: 'app',
9
+ verb: 'sdk',
10
+ args: [{ name: 'action', required: true, description: 'sync' }],
11
+ flags: {},
12
+ summary: 'Refresh the generated SDK tree in a legacy vendored App',
13
+ examples: ['frontera app sdk sync'],
14
+ needsProject: true,
15
+ },
16
+ async run(ctx) {
17
+ if (ctx.positional[0] !== 'sync') throw new UsageError('expected sync', 'frontera app sdk sync')
18
+ const project = requireProjectFrom(ctx)
19
+ const result = syncVendoredAppSdk(project.root)
20
+ if (result.kind === 'published') {
21
+ return {
22
+ data: result,
23
+ text: 'This App uses published @frontera-sdk packages; update them with the package manager.',
24
+ }
25
+ }
26
+ return {
27
+ data: result,
28
+ text: `Synced vendored Frontera SDK ${result.before} → ${result.after} (${result.fileCount} files).`,
29
+ }
30
+ },
31
+ }
@@ -30,7 +30,14 @@ export const appVersions: Command = {
30
30
  (v) =>
31
31
  `${v.deployed ? '*' : ' '} ${v.version.padEnd(12)} ` +
32
32
  `parent=${(v.parentVersion ?? '-').padEnd(10)} ` +
33
- `${v.fileCount} files ${formatBytes(v.totalBytes)}`,
33
+ `${v.fileCount} files ${formatBytes(v.totalBytes)} ` +
34
+ `${v.runtime}/${v.routing}` +
35
+ (v.provenance?.framework
36
+ ? ` ${v.provenance.framework}${v.provenance.frameworkVersion ? `@${v.provenance.frameworkVersion}` : ''}`
37
+ : '') +
38
+ (v.provenance?.sourceCommit
39
+ ? ` source=${v.provenance.sourceCommit.slice(0, 8)}${v.provenance.sourceDirty ? '+dirty' : ''}`
40
+ : ''),
34
41
  )
35
42
  .join('\n'),
36
43
  }
@@ -0,0 +1,151 @@
1
+ import { BlueprintAuthoringApi } from '../../api/blueprint-authoring-api'
2
+ import { CliError, UsageError } from '../../errors'
3
+ import type { Command, CommandContext } from '../types'
4
+
5
+ /**
6
+ * Declare which properties a governed Action may write.
7
+ *
8
+ * The `set_editable_properties` draft command has existed since the edit
9
+ * overlay landed, and until now nothing called it: `apply` excludes backings
10
+ * from its diff (`bind` owns them) and `bind` sends only the column mapping.
11
+ * So the one declaration standing between a published ontology and an Action
12
+ * that can change it was reachable by raw HTTP alone.
13
+ *
14
+ * It is its own verb rather than a flag on `bind` because narrowing the set is
15
+ * a governed act with its own consequence — an edit already stored against a
16
+ * property that stops being editable is no longer readable — and the service
17
+ * refuses a rebind while any are set for exactly that reason.
18
+ */
19
+
20
+ function nameList(value: string | boolean | undefined): string[] {
21
+ if (typeof value !== 'string') return []
22
+ return value.split(',').map((entry) => entry.trim()).filter(Boolean)
23
+ }
24
+
25
+ /**
26
+ * The next editable set, as API names.
27
+ *
28
+ * Exported for the tests: the selection is where a caller's mistake turns into
29
+ * the wrong contract, and it is worth pinning without a draft to run against.
30
+ *
31
+ * `--set` replaces, `--add`/`--remove` compose against what is already there,
32
+ * and the result is deduplicated and sorted so restating the same intent in
33
+ * another order produces the same command — the service sorts the ids for the
34
+ * same reason, to keep the bundle digest stable.
35
+ */
36
+ export function nextEditableSelection(
37
+ current: readonly string[],
38
+ selection: { set?: string[]; add?: string[]; remove?: string[] },
39
+ ): string[] {
40
+ const base = selection.set ?? [...current]
41
+ const removed = new Set(selection.remove ?? [])
42
+ const next = new Set([...base, ...(selection.add ?? [])].filter((name) => !removed.has(name)))
43
+ return [...next].sort()
44
+ }
45
+
46
+ function api(ctx: CommandContext): BlueprintAuthoringApi {
47
+ return new BlueprintAuthoringApi(ctx.apiUrl, ctx.token)
48
+ }
49
+
50
+ export const blueprintEditable: Command = {
51
+ meta: {
52
+ noun: 'blueprint',
53
+ verb: 'editable',
54
+ args: [{ name: 'objectType', required: true, description: 'The object type’s API name' }],
55
+ flags: { set: 'string', add: 'string', remove: 'string' },
56
+ summary: 'Declare which properties a governed Action may edit',
57
+ examples: [
58
+ 'frontera blueprint editable SupportTicket',
59
+ 'frontera blueprint editable SupportTicket --set priority,escalated',
60
+ 'frontera blueprint editable SupportTicket --add customerTier',
61
+ 'frontera blueprint editable SupportTicket --set ""',
62
+ ],
63
+ },
64
+
65
+ async run(ctx) {
66
+ const apiName = ctx.positional[0]
67
+ if (!apiName) throw new UsageError('missing <objectType>', 'frontera blueprint editable SupportTicket')
68
+
69
+ // `--set ''` is how the set is cleared, and it is a different intent from
70
+ // passing no flag at all — so presence is what selects the mode, never
71
+ // whether the parsed list came back empty.
72
+ const hasSet = typeof ctx.flags.set === 'string'
73
+ const add = nameList(ctx.flags.add)
74
+ const remove = nameList(ctx.flags.remove)
75
+ if (hasSet && (add.length > 0 || remove.length > 0)) {
76
+ throw new UsageError(
77
+ '--set replaces the whole set, so it cannot be combined with --add or --remove.',
78
+ `frontera blueprint editable ${apiName} --set ${[...nameList(ctx.flags.set), ...add].join(',')}`,
79
+ )
80
+ }
81
+
82
+ const client = api(ctx)
83
+ const detail = await client.getObjectTypeDetail(apiName)
84
+ if (!detail?.id) {
85
+ throw new CliError(`No object type "${apiName}" on the draft.`, {
86
+ code: 'NOT_FOUND',
87
+ hint: 'frontera blueprint catalog',
88
+ })
89
+ }
90
+
91
+ const known = new Set(detail.properties.map((property) => property.apiName).filter(Boolean))
92
+ // Already API names: the draft stores ids, and the catalog resolves them on
93
+ // the way out. Mapping them through the property ids again read every one
94
+ // as unknown and reported an empty set — which would have let `--add` post
95
+ // a set containing only the addition, silently dropping the rest.
96
+ const current = [...detail.editableProperties].sort()
97
+
98
+ if (!hasSet && add.length === 0 && remove.length === 0) {
99
+ return {
100
+ data: { objectType: apiName, editable: current },
101
+ text: current.length === 0
102
+ ? `"${apiName}" has no editable properties — no Action can change it yet.`
103
+ : `"${apiName}" editable: ${current.join(', ')}`,
104
+ }
105
+ }
106
+
107
+ const next = nextEditableSelection(current, {
108
+ ...(hasSet ? { set: nameList(ctx.flags.set) } : {}),
109
+ add,
110
+ remove,
111
+ })
112
+
113
+ // Refused here rather than at the service, which answers for the first
114
+ // unknown name it reaches. The properties came from the same read, so this
115
+ // can name all of them at once and list what the type actually has.
116
+ const unknown = next.filter((name) => !known.has(name))
117
+ if (unknown.length > 0) {
118
+ throw new CliError(
119
+ `${unknown.join(', ')} ${unknown.length === 1 ? 'is not a property' : 'are not properties'} of "${apiName}".`,
120
+ {
121
+ code: 'USAGE',
122
+ hint: `Properties: ${[...known].sort().join(', ')}`,
123
+ },
124
+ )
125
+ }
126
+
127
+ if (next.length === current.length && next.every((name, index) => name === current[index])) {
128
+ return {
129
+ data: { objectType: apiName, editable: current, changed: false },
130
+ text: `"${apiName}" editable is already ${current.length === 0 ? 'empty' : current.join(', ')}.`,
131
+ }
132
+ }
133
+
134
+ const result = await client.draftCommand({
135
+ kind: 'set_editable_properties',
136
+ objectId: detail.id,
137
+ editableProperties: next,
138
+ }, await client.revision())
139
+
140
+ const dropped = current.filter((name) => !next.includes(name))
141
+ return {
142
+ data: { objectType: apiName, editable: next, dropped, revision: result.revision },
143
+ // Narrowing is called out, because an edit already stored against a
144
+ // dropped property stops being read — the one consequence of this
145
+ // command a caller cannot see from the new set alone.
146
+ text: `"${apiName}" editable: ${next.length === 0 ? '(none)' : next.join(', ')}`
147
+ + (dropped.length > 0 ? `\nNo longer editable: ${dropped.join(', ')} — edits already stored against them stop being read.` : '')
148
+ + '\n\nPublish a release for this to reach an Action.',
149
+ }
150
+ },
151
+ }
@@ -0,0 +1,58 @@
1
+ import { relative } from 'node:path'
2
+
3
+ import { PlatformApi } from '../../api/platform-api'
4
+ import {
5
+ generateBlueprintTypes,
6
+ parseBlueprintSchema,
7
+ writeBlueprintTypesFile,
8
+ } from '../../blueprint-types'
9
+ import type { Command } from '../types'
10
+ import { flagBool, flagString } from '../types'
11
+
12
+ export const blueprintGenerateTypes: Command = {
13
+ meta: {
14
+ noun: 'blueprint',
15
+ verb: 'generate-types',
16
+ args: [],
17
+ flags: { output: 'string', check: 'boolean' },
18
+ summary: 'Generate App-local TypeScript types from this workspace Blueprint',
19
+ examples: [
20
+ 'frontera blueprint generate-types',
21
+ 'frontera blueprint generate-types --check',
22
+ 'frontera blueprint generate-types --output src/contracts/blueprint.ts',
23
+ ],
24
+ needsProject: true,
25
+ },
26
+
27
+ async run(ctx) {
28
+ const project = ctx.project!
29
+ const api = new PlatformApi(ctx.apiUrl, ctx.token)
30
+ const schema = parseBlueprintSchema(await api.blueprintSchema())
31
+ const contents = generateBlueprintTypes(schema)
32
+ const written = writeBlueprintTypesFile(
33
+ project.root,
34
+ flagString(ctx, 'output'),
35
+ contents,
36
+ flagBool(ctx, 'check'),
37
+ )
38
+ const path = relative(project.root, written.path)
39
+ const propertyCount = schema.objectTypes.reduce((total, objectType) => total + objectType.properties.length, 0)
40
+ const checked = flagBool(ctx, 'check')
41
+
42
+ return {
43
+ data: {
44
+ path,
45
+ digest: schema.digest,
46
+ objectCount: schema.objectTypes.length,
47
+ propertyCount,
48
+ changed: written.changed,
49
+ checked,
50
+ },
51
+ text: checked
52
+ ? `Blueprint types are current at ${path} (${schema.objectTypes.length} objects, ${propertyCount} properties).`
53
+ : written.changed
54
+ ? `Generated Blueprint types at ${path} (${schema.objectTypes.length} objects, ${propertyCount} properties).`
55
+ : `Blueprint types already current at ${path} (${schema.objectTypes.length} objects, ${propertyCount} properties).`,
56
+ }
57
+ },
58
+ }