openuispec 0.1.28 → 0.1.30

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/README.md CHANGED
@@ -53,6 +53,7 @@ openuispec init
53
53
  ```
54
54
 
55
55
  This scaffolds a spec directory, starter tokens, and adds rules to `CLAUDE.md` / `AGENTS.md` so AI assistants track spec changes automatically.
56
+ Use `openuispec init --no-configure-targets` if you want to scaffold first and choose target stacks later.
56
57
 
57
58
  Then hand your spec to any AI code generator:
58
59
 
@@ -68,7 +69,7 @@ For platform generation, treat these as hard output constraints:
68
69
 
69
70
  See the examples for concrete reference projects:
70
71
 
71
- - [TaskFlow](./examples/taskflow/openuispec/) for a compact spec covering all 7 contract families
72
+ - [TaskFlow](./examples/taskflow/openuispec/) for a compact reference spec covering all 7 contract families, with generated iOS, Android, and web targets under `examples/taskflow/generated/`
72
73
  - [Todo Orbit](./examples/todo-orbit/openuispec/) for a bilingual task app with generated iOS, Android, and web targets under `examples/todo-orbit/generated/`
73
74
 
74
75
  ## Repository structure
@@ -102,47 +103,22 @@ openuispec/
102
103
  │ │ └── validation.schema.json # Validation rule definitions
103
104
  │ └── validate.ts # Validation script (npm run validate)
104
105
  ├── examples/
105
- │ ├── taskflow/ # Compact reference spec
106
- │ │ ├── openuispec.yaml # Root manifest + data model + API endpoints
107
- │ │ ├── tokens/
108
- │ │ ├── color.yaml # Brand + semantic + status colors
109
- │ │ │ ├── typography.yaml # Font family + 8-step type scale
110
- │ │ ├── spacing.yaml # 4px base unit, 9-step scale
111
- │ │ │ ├── elevation.yaml # 4-level elevation (none/sm/md/lg)
112
- │ │ │ ├── motion.yaml # Durations, easings, patterns
113
- │ │ │ ├── layout.yaml # Size classes, primitives, reflow rules
114
- │ │ │ ├── themes.yaml # Light, dark, warm variants
115
- │ │ │ └── icons.yaml # Icon registry with platform mappings
116
- │ │ ├── contracts/ # Standard contract extensions + custom contracts
117
- │ │ │ ├── input_field.yaml # Standard contract with cut_corner variant
118
- │ │ │ └── x_media_player.yaml # Custom media player contract (Section 12)
119
- │ │ ├── screens/
120
- │ │ │ ├── home.yaml # Task list with search, filters, FAB, adaptive nav
121
- │ │ │ ├── task_detail.yaml # Full task view with actions + assignee sheet
122
- │ │ │ ├── projects.yaml # Project grid + new project dialog
123
- │ │ │ ├── project_detail.yaml # Single project with task list (stub)
124
- │ │ │ ├── settings.yaml # Preferences, toggles, account management
125
- │ │ │ ├── profile_edit.yaml # Edit profile form (stub)
126
- │ │ │ └── calendar.yaml # Calendar view (stub)
127
- │ │ ├── flows/
128
- │ │ │ ├── create_task.yaml # Task creation form (sheet presentation)
129
- │ │ │ └── edit_task.yaml # Task editing flow
130
- │ │ ├── locales/
131
- │ │ │ └── en.json # English locale (ICU MessageFormat)
132
- │ │ └── platform/
133
- │ │ ├── ios.yaml # SwiftUI overrides + behaviors
134
- │ │ ├── android.yaml # Compose overrides + behaviors
135
- │ │ └── web.yaml # React overrides + responsive rules
136
- │ └── todo-orbit/ # Full showcase app with generated targets
106
+ │ ├── taskflow/ # Compact reference sample
107
+ │ │ ├── openuispec/ # Source OpenUISpec project
108
+ │ │ ├── generated/ # Generated iOS, Android, and web apps
109
+ │ │ ├── README.md # Sample overview and structure
110
+ │ │ └── AGENTS.md / CLAUDE.md # AI rules generated from the package
111
+ └── todo-orbit/ # Full showcase sample
137
112
  │ ├── openuispec/ # Source OpenUISpec project
138
113
  │ ├── generated/ # Generated iOS, Android, and web apps
139
- └── artifacts/ # Screenshots and supporting outputs
114
+ ├── README.md # Sample overview and structure
115
+ │ └── AGENTS.md / CLAUDE.md # AI rules generated from the package
140
116
  ├── cli/ # CLI tool (openuispec init, drift, prepare, validate)
141
117
  │ ├── index.ts # Entry point
142
118
  │ └── init.ts # Project scaffolding + AI rules
143
119
  ├── drift/ # Drift detection (spec change tracking)
144
120
  │ └── index.ts # Hash-based drift checker
145
- ├── prepare/ # AI-ready target work bundle generation
121
+ ├── prepare/ # Target work bundle generation
146
122
  │ └── index.ts # Baseline-aware target preparation
147
123
  ├── LICENSE
148
124
  └── README.md
@@ -198,19 +174,36 @@ generation:
198
174
  web: "../web-ui/"
199
175
  android: "../kmp-ui/"
200
176
  ios: "../kmp-ui/iosApp/"
177
+ code_roots:
178
+ backend: "../api/"
201
179
  ```
202
180
 
203
181
  Paths are relative to `openuispec.yaml`. The `.openuispec-state.json` file is stored inside each output directory and records spec file hashes plus the git baseline commit metadata captured at snapshot time.
204
182
 
183
+ If `api.endpoints` are declared, `generation.code_roots.backend` is required. It should point at the backend folder the AI must inspect when generating API clients or wiring request/response behavior.
184
+
205
185
  `openuispec drift --snapshot --target <target>` requires that target output directory to already exist. If it does not, generate the target code first, then snapshot the accepted baseline.
206
186
 
207
187
  Use the commands like this:
208
188
  - `openuispec validate` checks schema correctness
209
189
  - `openuispec validate semantic` checks cross-references such as locale keys, formatters, mappers, contracts, icons, navigation targets, and API endpoints
190
+ - `openuispec init --no-configure-targets` scaffolds the spec project without running the target-stack wizard
191
+ - `openuispec configure-target <t>` records and confirms target stack choices in `platform/<target>.yaml`, while still allowing custom framework/library values when the project uses something outside the catalog
210
192
  - `openuispec drift --target <t> --explain` explains semantic spec changes since that target's accepted baseline
211
- - `openuispec prepare --target <t>` turns those changes into an AI-ready target update bundle
193
+ - `openuispec prepare --target <t>` builds the target work bundle for either first-time generation or drift-based updates
212
194
  - `openuispec status` shows every target's snapshot state, baseline commit, and whether that target is behind the current spec, still needs a baseline, or has not been generated yet
213
195
 
196
+ In first-time generation mode, `prepare` also carries target-specific generation constraints such as native localization requirements, multi-file output rules, target folder layout expectations, and a requirement to refresh current platform/framework setup knowledge before code generation.
197
+
198
+ If stack choices were auto-applied via `configure-target --defaults` or `init --defaults`, they remain unconfirmed. `prepare` will block implementation readiness until the user explicitly confirms the target stack, and AI agents should ask the user to confirm or change those choices instead of silently proceeding to code generation.
199
+
200
+ When target stack choices come from the preset catalog, `prepare --json` also exposes install-oriented refs for the selected options:
201
+ - Android: Gradle plugin ids and library coordinates
202
+ - Web: npm package specs
203
+ - iOS: package identifiers plus docs links
204
+
205
+ Those refs are anchors, not a full dependency manifest. The AI is expected to add any supporting build, plugin, repository, annotation-processing, runtime, dev, and test dependencies required by the current platform setup.
206
+
214
207
  If a target snapshot was created before baseline metadata was added, `--explain` and `status` will tell you to re-run `openuispec drift --snapshot --target <target>` for that target.
215
208
 
216
209
  ## Target update workflow
@@ -219,6 +212,8 @@ When a shared spec change needs to be applied to a target:
219
212
 
220
213
  ```bash
221
214
  openuispec validate
215
+ openuispec validate semantic
216
+ openuispec status
222
217
  openuispec drift --target ios --explain
223
218
  openuispec prepare --target ios
224
219
  # update the ios implementation
@@ -229,8 +224,9 @@ openuispec drift --snapshot --target ios
229
224
  Meaning:
230
225
  - `validate` checks schema correctness
231
226
  - `validate semantic` checks cross-reference integrity
227
+ - `status` shows which targets are up to date, need a baseline, or still need generation
232
228
  - `drift --explain` shows semantic spec changes since that target's accepted baseline
233
- - `prepare` packages those changes into an AI/developer work bundle
229
+ - `prepare` packages the target work bundle for AI/developers. It runs in `bootstrap` mode for first-time generation and `update` mode after a target snapshot exists.
234
230
  - `drift --snapshot` accepts the updated state after the target UI has been updated and the target output directory exists
235
231
 
236
232
  Before picking the next platform to update, run:
@@ -0,0 +1,523 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { stdin, stdout } from "node:process";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import YAML from "yaml";
7
+ import { findProjectDir, isSupportedTarget, readManifest, type SupportedTarget } from "../drift/index.js";
8
+ import { ask, askChoice } from "./init.js";
9
+
10
+ type WizardOptionPreset = {
11
+ value: string;
12
+ generation_value?: string;
13
+ framework_filter?: string[];
14
+ dependencies?: string[];
15
+ extra_generation?: Record<string, any>;
16
+ refs?: {
17
+ plugins?: string[];
18
+ libraries?: string[];
19
+ packages?: string[];
20
+ docs?: string[];
21
+ };
22
+ };
23
+
24
+ type ChoiceQuestionPreset = {
25
+ key: string;
26
+ prompt: string;
27
+ recommended: string;
28
+ options: WizardOptionPreset[];
29
+ };
30
+
31
+ type TargetWizardPreset = {
32
+ framework: string;
33
+ framework_prompt?: string;
34
+ framework_options?: string[];
35
+ language?: string;
36
+ min_version?: string;
37
+ min_sdk?: number;
38
+ target_sdk?: number;
39
+ generation_defaults?: Record<string, any>;
40
+ base_dependencies?: string[];
41
+ framework_dependencies?: Record<string, string[]>;
42
+ questions: ChoiceQuestionPreset[];
43
+ };
44
+
45
+ export type TargetWizardOptionsResponse = {
46
+ target: SupportedTarget;
47
+ defaults_are_unconfirmed: true;
48
+ confirmation_required_before_implementation: true;
49
+ interactive_command: string;
50
+ defaults_command: string;
51
+ framework: {
52
+ prompt: string;
53
+ recommended: string;
54
+ options: string[];
55
+ custom_allowed: true;
56
+ };
57
+ questions: Array<{
58
+ key: string;
59
+ prompt: string;
60
+ recommended: string;
61
+ custom_allowed: true;
62
+ options: WizardOptionPreset[];
63
+ }>;
64
+ };
65
+
66
+ function readWizardPresets(): Record<SupportedTarget, TargetWizardPreset> {
67
+ const presetsPath = join(dirname(fileURLToPath(import.meta.url)), "target-presets.json");
68
+ return JSON.parse(readFileSync(presetsPath, "utf-8")) as Record<SupportedTarget, TargetWizardPreset>;
69
+ }
70
+
71
+ const TARGET_WIZARDS = readWizardPresets();
72
+
73
+ function listFrameworkOptions(wizard: TargetWizardPreset): string[] {
74
+ return [...new Set([...(wizard.framework_options ?? [wizard.framework]), "other"])];
75
+ }
76
+
77
+ export function listTargetWizardOptions(target: SupportedTarget): TargetWizardOptionsResponse {
78
+ const wizard = TARGET_WIZARDS[target];
79
+ return {
80
+ target,
81
+ defaults_are_unconfirmed: true,
82
+ confirmation_required_before_implementation: true,
83
+ interactive_command: `openuispec configure-target ${target}`,
84
+ defaults_command: `openuispec configure-target ${target} --defaults`,
85
+ framework: {
86
+ prompt: wizard.framework_prompt ?? `${target} framework`,
87
+ recommended: wizard.framework,
88
+ options: listFrameworkOptions(wizard),
89
+ custom_allowed: true,
90
+ },
91
+ questions: wizard.questions.map((question) => ({
92
+ key: question.key,
93
+ prompt: question.prompt,
94
+ recommended: question.recommended,
95
+ custom_allowed: true,
96
+ options: question.options,
97
+ })),
98
+ };
99
+ }
100
+
101
+ function filterOptionsForFramework(
102
+ question: ChoiceQuestionPreset,
103
+ framework: string
104
+ ): WizardOptionPreset[] {
105
+ return question.options.filter((option) => {
106
+ if (!option.framework_filter) return true;
107
+ return option.framework_filter.includes(framework);
108
+ });
109
+ }
110
+
111
+ function effectiveDefault(
112
+ question: ChoiceQuestionPreset,
113
+ framework: string,
114
+ inferred: string | undefined
115
+ ): string {
116
+ if (inferred) return inferred;
117
+ const filtered = filterOptionsForFramework(question, framework);
118
+ if (filtered.some((o) => o.value === question.recommended)) return question.recommended;
119
+ return filtered[0]?.value ?? question.recommended;
120
+ }
121
+
122
+ function mergeDependencies(
123
+ derived: string[],
124
+ existing: unknown,
125
+ managedDependencies: Set<string>
126
+ ): string[] {
127
+ const extras = Array.isArray(existing)
128
+ ? existing.filter((dep): dep is string => typeof dep === "string" && !managedDependencies.has(dep))
129
+ : [];
130
+ return Array.from(new Set([...derived, ...extras]));
131
+ }
132
+
133
+ function findOption(question: ChoiceQuestionPreset, value: string): WizardOptionPreset | null {
134
+ return question.options.find((option) => option.value === value) ?? null;
135
+ }
136
+
137
+ function collectManagedDependencies(wizard: TargetWizardPreset): Set<string> {
138
+ const managed = new Set<string>(wizard.base_dependencies ?? []);
139
+ if (wizard.framework_dependencies) {
140
+ for (const deps of Object.values(wizard.framework_dependencies)) {
141
+ for (const dep of deps) managed.add(dep);
142
+ }
143
+ }
144
+ for (const question of wizard.questions) {
145
+ for (const option of question.options) {
146
+ for (const dependency of option.dependencies ?? []) {
147
+ managed.add(dependency);
148
+ }
149
+ }
150
+ }
151
+ return managed;
152
+ }
153
+
154
+ function normalizeAndroid(existingPlatform: Record<string, any>): Record<string, string> {
155
+ const generation = existingPlatform.generation ?? {};
156
+ const deps = new Set(Array.isArray(generation.dependencies) ? generation.dependencies : []);
157
+ const architectureValue = typeof generation.architecture === "string" ? generation.architecture.toLowerCase() : "";
158
+ const stateValue = typeof generation.state === "string" ? generation.state.toLowerCase() : "";
159
+ const persistenceValue = typeof generation.persistence === "string" ? generation.persistence.toLowerCase() : "";
160
+ return {
161
+ architecture:
162
+ typeof generation.architecture === "string" &&
163
+ !architectureValue.includes("decompose") &&
164
+ !architectureValue.includes("compose") &&
165
+ !deps.has("decompose") &&
166
+ !deps.has("navigation-compose")
167
+ ? generation.architecture
168
+ : architectureValue.includes("decompose") || deps.has("decompose")
169
+ ? "decompose"
170
+ : architectureValue.includes("compose") || deps.has("navigation-compose")
171
+ ? "plain_compose"
172
+ : "decompose",
173
+ state:
174
+ typeof generation.state === "string" &&
175
+ !stateValue.includes("mvikotlin") &&
176
+ !stateValue.includes("viewmodel") &&
177
+ !deps.has("mvikotlin") &&
178
+ !deps.has("lifecycle-viewmodel-compose")
179
+ ? generation.state
180
+ : stateValue.includes("mvikotlin") || deps.has("mvikotlin")
181
+ ? "mvikotlin"
182
+ : stateValue.includes("viewmodel") || deps.has("lifecycle-viewmodel-compose")
183
+ ? "viewmodel"
184
+ : "mvikotlin",
185
+ preferences:
186
+ typeof generation.preferences === "string"
187
+ ? generation.preferences
188
+ : persistenceValue === "datastore" || deps.has("datastore-preferences")
189
+ ? "datastore"
190
+ : "datastore",
191
+ database:
192
+ typeof generation.database === "string"
193
+ ? generation.database
194
+ : persistenceValue === "sqldelight" || deps.has("sqldelight")
195
+ ? "sqldelight"
196
+ : persistenceValue === "room" || deps.has("room-runtime")
197
+ ? "room"
198
+ : "none",
199
+ di:
200
+ typeof generation.di === "string"
201
+ ? ["metro", "koin", "hilt", "none"].includes(generation.di)
202
+ ? generation.di
203
+ : generation.di
204
+ : deps.has("metro")
205
+ ? "metro"
206
+ : deps.has("koin-android")
207
+ ? "koin"
208
+ : deps.has("hilt-android")
209
+ ? "hilt"
210
+ : "metro",
211
+ };
212
+ }
213
+
214
+ function normalizeWeb(existingPlatform: Record<string, any>): Record<string, string> {
215
+ const generation = existingPlatform.generation ?? {};
216
+ const result: Record<string, string> = {
217
+ runtime:
218
+ typeof generation.runtime === "string"
219
+ ? generation.runtime
220
+ : "frontend_only",
221
+ css:
222
+ typeof generation.css === "string"
223
+ ? generation.css
224
+ : "tailwind",
225
+ storage_backend:
226
+ typeof generation.storage_backend === "string"
227
+ ? generation.storage_backend
228
+ : "none",
229
+ };
230
+
231
+ if (typeof generation.routing === "string") {
232
+ result.routing = generation.routing.includes("tanstack")
233
+ ? "tanstack_router"
234
+ : generation.routing.includes("react-router") || generation.routing === "react_router"
235
+ ? "react_router"
236
+ : generation.routing;
237
+ }
238
+
239
+ if (typeof generation.state === "string") {
240
+ result.state = generation.state === "redux-toolkit"
241
+ ? "redux"
242
+ : generation.state === "tanstack-query"
243
+ ? "query_only"
244
+ : generation.state;
245
+ }
246
+
247
+ return result;
248
+ }
249
+
250
+ function normalizeIos(existingPlatform: Record<string, any>): Record<string, string> {
251
+ const generation = existingPlatform.generation ?? {};
252
+ const deps = new Set(Array.isArray(generation.dependencies) ? generation.dependencies : []);
253
+ return {
254
+ architecture:
255
+ typeof generation.architecture === "string" &&
256
+ !generation.architecture.toLowerCase().includes("tca") &&
257
+ generation.architecture.toLowerCase() !== "native swiftui"
258
+ ? generation.architecture
259
+ : typeof generation.architecture === "string" && generation.architecture.toLowerCase().includes("tca")
260
+ ? "tca_style"
261
+ : deps.has("swift-composable-architecture")
262
+ ? "tca_style"
263
+ : "native",
264
+ persistence:
265
+ typeof generation.persistence === "string"
266
+ ? generation.persistence
267
+ : deps.has("sqlite")
268
+ ? "sqlite"
269
+ : deps.has("swiftdata")
270
+ ? "swiftdata"
271
+ : "swiftdata",
272
+ di:
273
+ typeof generation.di === "string"
274
+ ? generation.di
275
+ : deps.has("factory")
276
+ ? "factory"
277
+ : deps.has("custom-di")
278
+ ? "custom"
279
+ : "none",
280
+ };
281
+ }
282
+
283
+ function normalizeExisting(target: SupportedTarget, existingPlatform: Record<string, any>): Record<string, string> {
284
+ switch (target) {
285
+ case "android":
286
+ return normalizeAndroid(existingPlatform);
287
+ case "web":
288
+ return normalizeWeb(existingPlatform);
289
+ case "ios":
290
+ return normalizeIos(existingPlatform);
291
+ }
292
+ }
293
+
294
+ function buildGeneration(
295
+ wizard: TargetWizardPreset,
296
+ answers: Record<string, string>,
297
+ existingGeneration: Record<string, any>,
298
+ framework: string
299
+ ): Record<string, any> {
300
+ const generation = {
301
+ ...(wizard.generation_defaults ?? {}),
302
+ ...existingGeneration,
303
+ };
304
+ const managedDependencies = collectManagedDependencies(wizard);
305
+ const frameworkDeps = wizard.framework_dependencies?.[framework] ?? [];
306
+ const derivedDependencies = [...(wizard.base_dependencies ?? []), ...frameworkDeps];
307
+
308
+ for (const question of wizard.questions) {
309
+ const answer = answers[question.key];
310
+ const selected = answer ? findOption(question, answer) : null;
311
+ if (!selected) {
312
+ generation[question.key] = answer || question.recommended;
313
+ continue;
314
+ }
315
+ generation[question.key] = selected.generation_value ?? selected.value;
316
+ Object.assign(generation, selected.extra_generation ?? {});
317
+ derivedDependencies.push(...(selected.dependencies ?? []));
318
+ }
319
+
320
+ generation.dependencies = mergeDependencies(
321
+ derivedDependencies,
322
+ existingGeneration.dependencies,
323
+ managedDependencies
324
+ );
325
+
326
+ return generation;
327
+ }
328
+
329
+ function stackConfirmation(useDefaults: boolean): Record<string, string> {
330
+ const now = new Date().toISOString();
331
+ return useDefaults
332
+ ? {
333
+ status: "pending_user_confirmation",
334
+ source: "defaults",
335
+ updated_at: now,
336
+ }
337
+ : {
338
+ status: "confirmed",
339
+ source: "user",
340
+ confirmed_at: now,
341
+ };
342
+ }
343
+
344
+ function parseTarget(argv: string[]): SupportedTarget | null {
345
+ const direct = argv[0];
346
+ if (direct && isSupportedTarget(direct)) {
347
+ return direct;
348
+ }
349
+ const targetIdx = argv.indexOf("--target");
350
+ if (targetIdx !== -1 && argv[targetIdx + 1] && isSupportedTarget(argv[targetIdx + 1])) {
351
+ return argv[targetIdx + 1];
352
+ }
353
+ return null;
354
+ }
355
+
356
+ function parseSetPairs(argv: string[]): Record<string, string> {
357
+ const pairs: Record<string, string> = {};
358
+ for (let i = 0; i < argv.length; i++) {
359
+ if (argv[i] === "--set" && argv[i + 1]) {
360
+ const raw = argv[i + 1];
361
+ const eqIdx = raw.indexOf("=");
362
+ if (eqIdx > 0) {
363
+ pairs[raw.slice(0, eqIdx)] = raw.slice(eqIdx + 1);
364
+ }
365
+ i++;
366
+ }
367
+ }
368
+ return pairs;
369
+ }
370
+
371
+ export async function runConfigureTarget(argv: string[]): Promise<void> {
372
+ const target = parseTarget(argv);
373
+ const listOptions = argv.includes("--list-options");
374
+ const useDefaults = argv.includes("--defaults");
375
+ const quiet = argv.includes("--quiet");
376
+ const setPairs = parseSetPairs(argv);
377
+ const hasSetPairs = Object.keys(setPairs).length > 0;
378
+ const interactive = stdin.isTTY && stdout.isTTY && !useDefaults && !hasSetPairs;
379
+ if (!target) {
380
+ console.error("Error: target is required for configure-target");
381
+ console.error("Usage: openuispec configure-target <ios|android|web> [--defaults] [--list-options] [--set key=value]");
382
+ process.exit(1);
383
+ }
384
+
385
+ if (listOptions) {
386
+ console.log(JSON.stringify(listTargetWizardOptions(target), null, 2));
387
+ return;
388
+ }
389
+
390
+ if (!interactive && !useDefaults && !hasSetPairs) {
391
+ console.error(
392
+ "Error: `openuispec configure-target` needs a TTY for prompts.\n" +
393
+ "Preferred: ask the user to confirm the target stack, then run `openuispec configure-target <target>` in an interactive terminal.\n" +
394
+ "Fallback: run with `--defaults` only for unattended setup; those values remain unconfirmed and `prepare` will block implementation until the user confirms them."
395
+ );
396
+ process.exit(1);
397
+ }
398
+
399
+ const projectDir = findProjectDir(process.cwd());
400
+ const manifest = readManifest(projectDir);
401
+ const configuredTargets: string[] = manifest.generation?.targets ?? [];
402
+ if (configuredTargets.length > 0 && !configuredTargets.includes(target)) {
403
+ console.error(
404
+ `Error: target "${target}" is not listed in generation.targets.\n` +
405
+ `Configured targets: ${configuredTargets.join(", ")}`
406
+ );
407
+ process.exit(1);
408
+ }
409
+
410
+ const platformDir = resolve(projectDir, manifest.includes?.platform ?? "./platform/");
411
+ mkdirSync(platformDir, { recursive: true });
412
+
413
+ const platformPath = join(platformDir, `${target}.yaml`);
414
+ const existingDoc = existsSync(platformPath)
415
+ ? (YAML.parse(readFileSync(platformPath, "utf-8")) as Record<string, any>)
416
+ : {};
417
+ const existingPlatform = existingDoc[target] ?? {};
418
+ const existingGeneration = existingPlatform.generation ?? {};
419
+ const wizard = TARGET_WIZARDS[target];
420
+ const defaultFramework =
421
+ typeof existingPlatform.framework === "string" && existingPlatform.framework.trim().length > 0
422
+ ? existingPlatform.framework
423
+ : wizard.framework;
424
+ const inferredDefaults = {
425
+ ...normalizeExisting(target, existingPlatform),
426
+ };
427
+
428
+ let framework = defaultFramework;
429
+
430
+ function computeDefaultAnswers(fw: string): Record<string, string> {
431
+ return Object.fromEntries(
432
+ wizard.questions.map((question) => {
433
+ const defaultValue = effectiveDefault(question, fw, inferredDefaults[question.key]);
434
+ return [question.key, defaultValue];
435
+ })
436
+ );
437
+ }
438
+
439
+ let answers = computeDefaultAnswers(framework);
440
+
441
+ if (hasSetPairs) {
442
+ // Non-interactive --set path: merge provided values with existing/defaults
443
+ const knownKeys = new Set(wizard.questions.map((q) => q.key));
444
+ for (const [key, value] of Object.entries(setPairs)) {
445
+ if (!knownKeys.has(key)) {
446
+ console.error(`Warning: "${key}" does not match any wizard question; setting as custom value.`);
447
+ }
448
+ answers[key] = value;
449
+ }
450
+ } else if (interactive) {
451
+ const rl = createInterface({ input: stdin, output: stdout });
452
+
453
+ try {
454
+ console.log(`\nOpenUISpec — Configure ${target}\n`);
455
+ console.log(`Writing target stack choices to ${relative(process.cwd(), platformPath)}\n`);
456
+
457
+ const frameworkOptions = [
458
+ ...(wizard.framework_options ?? [wizard.framework]),
459
+ "other",
460
+ ];
461
+ const chosenFramework = await askChoice(
462
+ rl,
463
+ wizard.framework_prompt ?? `${target} framework`,
464
+ frameworkOptions,
465
+ framework
466
+ );
467
+ framework =
468
+ chosenFramework === "other"
469
+ ? await ask(rl, `Custom ${target} framework`, framework)
470
+ : chosenFramework;
471
+
472
+ const defaultAnswers = computeDefaultAnswers(framework);
473
+ answers = {};
474
+ for (const question of wizard.questions) {
475
+ const filtered = filterOptionsForFramework(question, framework);
476
+ const chosen = await askChoice(
477
+ rl,
478
+ question.prompt,
479
+ [...filtered.map((option) => option.value), "other"],
480
+ defaultAnswers[question.key]
481
+ );
482
+ answers[question.key] =
483
+ chosen === "other"
484
+ ? await ask(rl, `Custom value for ${question.key}`, defaultAnswers[question.key])
485
+ : chosen;
486
+ }
487
+ } finally {
488
+ rl.close();
489
+ }
490
+ }
491
+
492
+ // --set implies confirmed (user explicitly chose values); --defaults without --set is pending
493
+ const updatedPlatform: Record<string, any> = {
494
+ ...existingPlatform,
495
+ framework,
496
+ generation: {
497
+ ...buildGeneration(wizard, answers, existingGeneration, framework),
498
+ stack_confirmation: stackConfirmation(useDefaults && !hasSetPairs),
499
+ },
500
+ };
501
+
502
+ if (wizard.language) updatedPlatform.language = wizard.language;
503
+ if (wizard.min_version) updatedPlatform.min_version = existingPlatform.min_version ?? wizard.min_version;
504
+ if (typeof wizard.min_sdk === "number") updatedPlatform.min_sdk = existingPlatform.min_sdk ?? wizard.min_sdk;
505
+ if (typeof wizard.target_sdk === "number") {
506
+ updatedPlatform.target_sdk = existingPlatform.target_sdk ?? wizard.target_sdk;
507
+ }
508
+
509
+ writeFileSync(platformPath, YAML.stringify({ [target]: updatedPlatform }));
510
+
511
+ const savedPath = relative(process.cwd(), platformPath);
512
+ if (argv.includes("--silent")) {
513
+ // Called as subroutine (e.g. from init --quiet) — no output at all
514
+ } else if (quiet) {
515
+ console.log(savedPath);
516
+ } else {
517
+ console.log(`\nSaved ${savedPath}`);
518
+ console.log("Configured values:");
519
+ for (const [key, value] of Object.entries(answers)) {
520
+ console.log(` - ${key}: ${value}`);
521
+ }
522
+ }
523
+ }