openuispec 0.2.18 → 0.2.20

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 (45) hide show
  1. package/README.md +2 -10
  2. package/dist/check/audit.js +392 -0
  3. package/dist/check/index.js +216 -0
  4. package/dist/cli/configure-target.js +391 -0
  5. package/dist/cli/index.js +510 -0
  6. package/dist/cli/init.js +1047 -0
  7. package/dist/drift/index.js +903 -0
  8. package/dist/mcp-server/index.js +886 -0
  9. package/dist/mcp-server/preview-render.js +1761 -0
  10. package/dist/mcp-server/preview.js +233 -0
  11. package/dist/mcp-server/screenshot-android.js +458 -0
  12. package/dist/mcp-server/screenshot-ios.js +639 -0
  13. package/dist/mcp-server/screenshot-shared.js +180 -0
  14. package/dist/mcp-server/screenshot.js +459 -0
  15. package/dist/prepare/index.js +1216 -0
  16. package/dist/runtime/package-paths.js +33 -0
  17. package/dist/schema/semantic-lint.js +564 -0
  18. package/dist/schema/validate.js +689 -0
  19. package/dist/status/index.js +194 -0
  20. package/docs/images/how-it-works.svg +56 -0
  21. package/docs/images/workflows.svg +76 -0
  22. package/package.json +12 -13
  23. package/check/audit.ts +0 -426
  24. package/check/index.ts +0 -320
  25. package/cli/configure-target.ts +0 -523
  26. package/cli/index.ts +0 -537
  27. package/cli/init.ts +0 -1253
  28. package/docs/images/how-it-works-dark.png +0 -0
  29. package/docs/images/how-it-works-light.png +0 -0
  30. package/docs/images/workflows-dark.png +0 -0
  31. package/docs/images/workflows-light.png +0 -0
  32. package/drift/index.ts +0 -1165
  33. package/mcp-server/index.ts +0 -1041
  34. package/mcp-server/preview-render.ts +0 -1922
  35. package/mcp-server/preview.ts +0 -292
  36. package/mcp-server/screenshot-android.ts +0 -621
  37. package/mcp-server/screenshot-ios.ts +0 -753
  38. package/mcp-server/screenshot-shared.ts +0 -237
  39. package/mcp-server/screenshot.ts +0 -563
  40. package/prepare/index.ts +0 -1530
  41. package/schema/semantic-lint.ts +0 -692
  42. package/schema/validate.ts +0 -870
  43. package/scripts/regenerate-previews.ts +0 -136
  44. package/scripts/take-all-screenshots.ts +0 -507
  45. package/status/index.ts +0 -275
@@ -0,0 +1,391 @@
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 { join, relative, resolve } from "node:path";
5
+ import YAML from "yaml";
6
+ import { findProjectDir, isSupportedTarget, readManifest } from "../drift/index.js";
7
+ import { resolvePackagePath } from "../runtime/package-paths.js";
8
+ import { ask, askChoice } from "./init.js";
9
+ function readWizardPresets() {
10
+ const presetsPath = resolvePackagePath(import.meta.url, "cli", "target-presets.json");
11
+ return JSON.parse(readFileSync(presetsPath, "utf-8"));
12
+ }
13
+ const TARGET_WIZARDS = readWizardPresets();
14
+ function listFrameworkOptions(wizard) {
15
+ return [...new Set([...(wizard.framework_options ?? [wizard.framework]), "other"])];
16
+ }
17
+ export function listTargetWizardOptions(target) {
18
+ const wizard = TARGET_WIZARDS[target];
19
+ return {
20
+ target,
21
+ defaults_are_unconfirmed: true,
22
+ confirmation_required_before_implementation: true,
23
+ interactive_command: `openuispec configure-target ${target}`,
24
+ defaults_command: `openuispec configure-target ${target} --defaults`,
25
+ framework: {
26
+ prompt: wizard.framework_prompt ?? `${target} framework`,
27
+ recommended: wizard.framework,
28
+ options: listFrameworkOptions(wizard),
29
+ custom_allowed: true,
30
+ },
31
+ questions: wizard.questions.map((question) => ({
32
+ key: question.key,
33
+ prompt: question.prompt,
34
+ recommended: question.recommended,
35
+ custom_allowed: true,
36
+ options: question.options,
37
+ })),
38
+ };
39
+ }
40
+ function filterOptionsForFramework(question, framework) {
41
+ return question.options.filter((option) => {
42
+ if (!option.framework_filter)
43
+ return true;
44
+ return option.framework_filter.includes(framework);
45
+ });
46
+ }
47
+ function effectiveDefault(question, framework, inferred) {
48
+ if (inferred)
49
+ return inferred;
50
+ const filtered = filterOptionsForFramework(question, framework);
51
+ if (filtered.some((o) => o.value === question.recommended))
52
+ return question.recommended;
53
+ return filtered[0]?.value ?? question.recommended;
54
+ }
55
+ function mergeDependencies(derived, existing, managedDependencies) {
56
+ const extras = Array.isArray(existing)
57
+ ? existing.filter((dep) => typeof dep === "string" && !managedDependencies.has(dep))
58
+ : [];
59
+ return Array.from(new Set([...derived, ...extras]));
60
+ }
61
+ function findOption(question, value) {
62
+ return question.options.find((option) => option.value === value) ?? null;
63
+ }
64
+ function collectManagedDependencies(wizard) {
65
+ const managed = new Set(wizard.base_dependencies ?? []);
66
+ if (wizard.framework_dependencies) {
67
+ for (const deps of Object.values(wizard.framework_dependencies)) {
68
+ for (const dep of deps)
69
+ managed.add(dep);
70
+ }
71
+ }
72
+ for (const question of wizard.questions) {
73
+ for (const option of question.options) {
74
+ for (const dependency of option.dependencies ?? []) {
75
+ managed.add(dependency);
76
+ }
77
+ }
78
+ }
79
+ return managed;
80
+ }
81
+ function normalizeAndroid(existingPlatform) {
82
+ const generation = existingPlatform.generation ?? {};
83
+ const deps = new Set(Array.isArray(generation.dependencies) ? generation.dependencies : []);
84
+ const architectureValue = typeof generation.architecture === "string" ? generation.architecture.toLowerCase() : "";
85
+ const stateValue = typeof generation.state === "string" ? generation.state.toLowerCase() : "";
86
+ const persistenceValue = typeof generation.persistence === "string" ? generation.persistence.toLowerCase() : "";
87
+ return {
88
+ architecture: typeof generation.architecture === "string" &&
89
+ !architectureValue.includes("decompose") &&
90
+ !architectureValue.includes("compose") &&
91
+ !deps.has("decompose") &&
92
+ !deps.has("navigation-compose")
93
+ ? generation.architecture
94
+ : architectureValue.includes("decompose") || deps.has("decompose")
95
+ ? "decompose"
96
+ : architectureValue.includes("compose") || deps.has("navigation-compose")
97
+ ? "plain_compose"
98
+ : "decompose",
99
+ state: typeof generation.state === "string" &&
100
+ !stateValue.includes("mvikotlin") &&
101
+ !stateValue.includes("viewmodel") &&
102
+ !deps.has("mvikotlin") &&
103
+ !deps.has("lifecycle-viewmodel-compose")
104
+ ? generation.state
105
+ : stateValue.includes("mvikotlin") || deps.has("mvikotlin")
106
+ ? "mvikotlin"
107
+ : stateValue.includes("viewmodel") || deps.has("lifecycle-viewmodel-compose")
108
+ ? "viewmodel"
109
+ : "mvikotlin",
110
+ preferences: typeof generation.preferences === "string"
111
+ ? generation.preferences
112
+ : persistenceValue === "datastore" || deps.has("datastore-preferences")
113
+ ? "datastore"
114
+ : "datastore",
115
+ database: typeof generation.database === "string"
116
+ ? generation.database
117
+ : persistenceValue === "sqldelight" || deps.has("sqldelight")
118
+ ? "sqldelight"
119
+ : persistenceValue === "room" || deps.has("room-runtime")
120
+ ? "room"
121
+ : "none",
122
+ di: typeof generation.di === "string"
123
+ ? ["metro", "koin", "hilt", "none"].includes(generation.di)
124
+ ? generation.di
125
+ : generation.di
126
+ : deps.has("metro")
127
+ ? "metro"
128
+ : deps.has("koin-android")
129
+ ? "koin"
130
+ : deps.has("hilt-android")
131
+ ? "hilt"
132
+ : "metro",
133
+ };
134
+ }
135
+ function normalizeWeb(existingPlatform) {
136
+ const generation = existingPlatform.generation ?? {};
137
+ const result = {
138
+ runtime: typeof generation.runtime === "string"
139
+ ? generation.runtime
140
+ : "frontend_only",
141
+ css: typeof generation.css === "string"
142
+ ? generation.css
143
+ : "tailwind",
144
+ storage_backend: typeof generation.storage_backend === "string"
145
+ ? generation.storage_backend
146
+ : "none",
147
+ };
148
+ if (typeof generation.routing === "string") {
149
+ result.routing = generation.routing.includes("tanstack")
150
+ ? "tanstack_router"
151
+ : generation.routing.includes("react-router") || generation.routing === "react_router"
152
+ ? "react_router"
153
+ : generation.routing;
154
+ }
155
+ if (typeof generation.state === "string") {
156
+ result.state = generation.state === "redux-toolkit"
157
+ ? "redux"
158
+ : generation.state === "tanstack-query"
159
+ ? "query_only"
160
+ : generation.state;
161
+ }
162
+ return result;
163
+ }
164
+ function normalizeIos(existingPlatform) {
165
+ const generation = existingPlatform.generation ?? {};
166
+ const deps = new Set(Array.isArray(generation.dependencies) ? generation.dependencies : []);
167
+ return {
168
+ architecture: typeof generation.architecture === "string" &&
169
+ !generation.architecture.toLowerCase().includes("tca") &&
170
+ generation.architecture.toLowerCase() !== "native swiftui"
171
+ ? generation.architecture
172
+ : typeof generation.architecture === "string" && generation.architecture.toLowerCase().includes("tca")
173
+ ? "tca_style"
174
+ : deps.has("swift-composable-architecture")
175
+ ? "tca_style"
176
+ : "native",
177
+ persistence: typeof generation.persistence === "string"
178
+ ? generation.persistence
179
+ : deps.has("sqlite")
180
+ ? "sqlite"
181
+ : deps.has("swiftdata")
182
+ ? "swiftdata"
183
+ : "swiftdata",
184
+ di: typeof generation.di === "string"
185
+ ? generation.di
186
+ : deps.has("factory")
187
+ ? "factory"
188
+ : deps.has("custom-di")
189
+ ? "custom"
190
+ : "none",
191
+ };
192
+ }
193
+ function normalizeExisting(target, existingPlatform) {
194
+ switch (target) {
195
+ case "android":
196
+ return normalizeAndroid(existingPlatform);
197
+ case "web":
198
+ return normalizeWeb(existingPlatform);
199
+ case "ios":
200
+ return normalizeIos(existingPlatform);
201
+ }
202
+ }
203
+ function buildGeneration(wizard, answers, existingGeneration, framework) {
204
+ const generation = {
205
+ ...(wizard.generation_defaults ?? {}),
206
+ ...existingGeneration,
207
+ };
208
+ const managedDependencies = collectManagedDependencies(wizard);
209
+ const frameworkDeps = wizard.framework_dependencies?.[framework] ?? [];
210
+ const derivedDependencies = [...(wizard.base_dependencies ?? []), ...frameworkDeps];
211
+ for (const question of wizard.questions) {
212
+ const answer = answers[question.key];
213
+ const selected = answer ? findOption(question, answer) : null;
214
+ if (!selected) {
215
+ generation[question.key] = answer || question.recommended;
216
+ continue;
217
+ }
218
+ generation[question.key] = selected.generation_value ?? selected.value;
219
+ Object.assign(generation, selected.extra_generation ?? {});
220
+ derivedDependencies.push(...(selected.dependencies ?? []));
221
+ }
222
+ generation.dependencies = mergeDependencies(derivedDependencies, existingGeneration.dependencies, managedDependencies);
223
+ return generation;
224
+ }
225
+ function stackConfirmation(useDefaults) {
226
+ const now = new Date().toISOString();
227
+ return useDefaults
228
+ ? {
229
+ status: "pending_user_confirmation",
230
+ source: "defaults",
231
+ updated_at: now,
232
+ }
233
+ : {
234
+ status: "confirmed",
235
+ source: "user",
236
+ confirmed_at: now,
237
+ };
238
+ }
239
+ function parseTarget(argv) {
240
+ const direct = argv[0];
241
+ if (direct && isSupportedTarget(direct)) {
242
+ return direct;
243
+ }
244
+ const targetIdx = argv.indexOf("--target");
245
+ const candidate = targetIdx !== -1 ? argv[targetIdx + 1] : null;
246
+ if (candidate && isSupportedTarget(candidate)) {
247
+ return candidate;
248
+ }
249
+ return null;
250
+ }
251
+ function parseSetPairs(argv) {
252
+ const pairs = {};
253
+ for (let i = 0; i < argv.length; i++) {
254
+ if (argv[i] === "--set" && argv[i + 1]) {
255
+ const raw = argv[i + 1];
256
+ const eqIdx = raw.indexOf("=");
257
+ if (eqIdx > 0) {
258
+ pairs[raw.slice(0, eqIdx)] = raw.slice(eqIdx + 1);
259
+ }
260
+ i++;
261
+ }
262
+ }
263
+ return pairs;
264
+ }
265
+ export async function runConfigureTarget(argv) {
266
+ const target = parseTarget(argv);
267
+ const listOptions = argv.includes("--list-options");
268
+ const useDefaults = argv.includes("--defaults");
269
+ const quiet = argv.includes("--quiet");
270
+ const setPairs = parseSetPairs(argv);
271
+ const hasSetPairs = Object.keys(setPairs).length > 0;
272
+ const interactive = stdin.isTTY && stdout.isTTY && !useDefaults && !hasSetPairs;
273
+ if (!target) {
274
+ console.error("Error: target is required for configure-target");
275
+ console.error("Usage: openuispec configure-target <ios|android|web> [--defaults] [--list-options] [--set key=value]");
276
+ process.exit(1);
277
+ }
278
+ if (listOptions) {
279
+ console.log(JSON.stringify(listTargetWizardOptions(target), null, 2));
280
+ return;
281
+ }
282
+ if (!interactive && !useDefaults && !hasSetPairs) {
283
+ console.error("Error: `openuispec configure-target` needs a TTY for prompts.\n" +
284
+ "Preferred: ask the user to confirm the target stack, then run `openuispec configure-target <target>` in an interactive terminal.\n" +
285
+ "Fallback: run with `--defaults` only for unattended setup; those values remain unconfirmed and `prepare` will block implementation until the user confirms them.");
286
+ process.exit(1);
287
+ }
288
+ const projectDir = findProjectDir(process.cwd());
289
+ const manifest = readManifest(projectDir);
290
+ const configuredTargets = manifest.generation?.targets ?? [];
291
+ if (configuredTargets.length > 0 && !configuredTargets.includes(target)) {
292
+ console.error(`Error: target "${target}" is not listed in generation.targets.\n` +
293
+ `Configured targets: ${configuredTargets.join(", ")}`);
294
+ process.exit(1);
295
+ }
296
+ const platformDir = resolve(projectDir, manifest.includes?.platform ?? "./platform/");
297
+ mkdirSync(platformDir, { recursive: true });
298
+ const platformPath = join(platformDir, `${target}.yaml`);
299
+ const existingDoc = existsSync(platformPath)
300
+ ? YAML.parse(readFileSync(platformPath, "utf-8"))
301
+ : {};
302
+ const existingPlatform = existingDoc[target] ?? {};
303
+ const existingGeneration = existingPlatform.generation ?? {};
304
+ const wizard = TARGET_WIZARDS[target];
305
+ const defaultFramework = typeof existingPlatform.framework === "string" && existingPlatform.framework.trim().length > 0
306
+ ? existingPlatform.framework
307
+ : wizard.framework;
308
+ const inferredDefaults = {
309
+ ...normalizeExisting(target, existingPlatform),
310
+ };
311
+ let framework = defaultFramework;
312
+ function computeDefaultAnswers(fw) {
313
+ return Object.fromEntries(wizard.questions.map((question) => {
314
+ const defaultValue = effectiveDefault(question, fw, inferredDefaults[question.key]);
315
+ return [question.key, defaultValue];
316
+ }));
317
+ }
318
+ let answers = computeDefaultAnswers(framework);
319
+ if (hasSetPairs) {
320
+ // Non-interactive --set path: merge provided values with existing/defaults
321
+ const knownKeys = new Set(wizard.questions.map((q) => q.key));
322
+ for (const [key, value] of Object.entries(setPairs)) {
323
+ if (!knownKeys.has(key)) {
324
+ console.error(`Warning: "${key}" does not match any wizard question; setting as custom value.`);
325
+ }
326
+ answers[key] = value;
327
+ }
328
+ }
329
+ else if (interactive) {
330
+ const rl = createInterface({ input: stdin, output: stdout });
331
+ try {
332
+ console.log(`\nOpenUISpec — Configure ${target}\n`);
333
+ console.log(`Writing target stack choices to ${relative(process.cwd(), platformPath)}\n`);
334
+ const frameworkOptions = [
335
+ ...(wizard.framework_options ?? [wizard.framework]),
336
+ "other",
337
+ ];
338
+ const chosenFramework = await askChoice(rl, wizard.framework_prompt ?? `${target} framework`, frameworkOptions, framework);
339
+ framework =
340
+ chosenFramework === "other"
341
+ ? await ask(rl, `Custom ${target} framework`, framework)
342
+ : chosenFramework;
343
+ const defaultAnswers = computeDefaultAnswers(framework);
344
+ answers = {};
345
+ for (const question of wizard.questions) {
346
+ const filtered = filterOptionsForFramework(question, framework);
347
+ const chosen = await askChoice(rl, question.prompt, [...filtered.map((option) => option.value), "other"], defaultAnswers[question.key]);
348
+ answers[question.key] =
349
+ chosen === "other"
350
+ ? await ask(rl, `Custom value for ${question.key}`, defaultAnswers[question.key])
351
+ : chosen;
352
+ }
353
+ }
354
+ finally {
355
+ rl.close();
356
+ }
357
+ }
358
+ // --set implies confirmed (user explicitly chose values); --defaults without --set is pending
359
+ const updatedPlatform = {
360
+ ...existingPlatform,
361
+ framework,
362
+ generation: {
363
+ ...buildGeneration(wizard, answers, existingGeneration, framework),
364
+ stack_confirmation: stackConfirmation(useDefaults && !hasSetPairs),
365
+ },
366
+ };
367
+ if (wizard.language)
368
+ updatedPlatform.language = wizard.language;
369
+ if (wizard.min_version)
370
+ updatedPlatform.min_version = existingPlatform.min_version ?? wizard.min_version;
371
+ if (typeof wizard.min_sdk === "number")
372
+ updatedPlatform.min_sdk = existingPlatform.min_sdk ?? wizard.min_sdk;
373
+ if (typeof wizard.target_sdk === "number") {
374
+ updatedPlatform.target_sdk = existingPlatform.target_sdk ?? wizard.target_sdk;
375
+ }
376
+ writeFileSync(platformPath, YAML.stringify({ [target]: updatedPlatform }));
377
+ const savedPath = relative(process.cwd(), platformPath);
378
+ if (argv.includes("--silent")) {
379
+ // Called as subroutine (e.g. from init --quiet) — no output at all
380
+ }
381
+ else if (quiet) {
382
+ console.log(savedPath);
383
+ }
384
+ else {
385
+ console.log(`\nSaved ${savedPath}`);
386
+ console.log("Configured values:");
387
+ for (const [key, value] of Object.entries(answers)) {
388
+ console.log(` - ${key}: ${value}`);
389
+ }
390
+ }
391
+ }