@nocobase/cli 2.1.0-beta.17 → 2.1.0-beta.19

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.
@@ -0,0 +1,466 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+ import { Args, Command, Flags } from '@oclif/core';
10
+ import { spawn } from 'node:child_process';
11
+ import fsp from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import Install from './install.js';
14
+ import { defaultWorkspaceName } from '../lib/app-runtime.js';
15
+ import { findAvailableTcpPort, validateAvailableTcpPort } from '../lib/prompt-validators.js';
16
+ import { commandSucceeds, resolveProjectCwd, run, runNocoBaseCommand } from '../lib/run-npm.js';
17
+ import { failTask, printInfo, setVerboseMode, startTask, succeedTask } from '../lib/ui.js';
18
+ const DEFAULT_DB_HOST = '127.0.0.1';
19
+ const DEFAULT_DB_DATABASE = 'nocobase-test';
20
+ const DEFAULT_DB_USER = 'nocobase';
21
+ const DEFAULT_DB_PASSWORD = 'nocobase';
22
+ const DEFAULT_DB_DIALECT = 'postgres';
23
+ const DEFAULT_TEST_TIMEZONE = 'UTC';
24
+ const DEFAULT_TEST_DB_IMAGES = {
25
+ postgres: 'postgres:16',
26
+ mysql: 'mysql:8',
27
+ mariadb: 'mariadb:11',
28
+ kingbase: 'registry.cn-shanghai.aliyuncs.com/nocobase/kingbase:v009r001c001b0030_single_x86',
29
+ };
30
+ const DEFAULT_TEST_DB_DISTRIBUTOR_PORT = '23450';
31
+ const DEFAULT_TEST_DB_DISTRIBUTOR_PREFIX = {
32
+ postgres: 'test',
33
+ mysql: 'test_',
34
+ mariadb: 'test_',
35
+ };
36
+ const DEFAULT_DB_PORTS = {
37
+ postgres: 5433,
38
+ mysql: 3307,
39
+ mariadb: 3307,
40
+ kingbase: 54322,
41
+ };
42
+ const TCP_PORT_READY_SCRIPT = [
43
+ "const net = require('node:net');",
44
+ "const port = Number(process.argv.at(-1));",
45
+ "const socket = net.createConnection({ host: '127.0.0.1', port });",
46
+ "socket.once('connect', () => { socket.end(); process.exit(0); });",
47
+ "socket.once('error', () => process.exit(1));",
48
+ "setTimeout(() => { socket.destroy(); process.exit(1); }, 200).unref();",
49
+ ].join('\n');
50
+ function inferTestEnv(paths) {
51
+ const first = String(paths[0] ?? '').trim();
52
+ if (!first) {
53
+ return undefined;
54
+ }
55
+ const normalized = first.split('\\').join('/');
56
+ if (normalized.includes('/client/')
57
+ || normalized.includes('/client-v2/')
58
+ || normalized.includes('/flow-engine/')) {
59
+ return 'client-side';
60
+ }
61
+ return 'server-side';
62
+ }
63
+ function trimValue(value) {
64
+ return String(value ?? '').trim();
65
+ }
66
+ function resolveWorkspaceName(cwd) {
67
+ return defaultWorkspaceName(cwd);
68
+ }
69
+ function defaultTestDbPort(dbDialect) {
70
+ return String(DEFAULT_DB_PORTS[dbDialect] ?? DEFAULT_DB_PORTS.postgres);
71
+ }
72
+ function defaultTestDbImage(dbDialect) {
73
+ return DEFAULT_TEST_DB_IMAGES[dbDialect] ?? DEFAULT_TEST_DB_IMAGES.postgres;
74
+ }
75
+ function delay(ms) {
76
+ return new Promise((resolve) => {
77
+ setTimeout(resolve, ms);
78
+ });
79
+ }
80
+ function shouldRunServerTests(params) {
81
+ if (params.server) {
82
+ return true;
83
+ }
84
+ if (params.client) {
85
+ return false;
86
+ }
87
+ return inferTestEnv(params.paths) !== 'client-side';
88
+ }
89
+ function defaultTestDbDistributorPrefix(dbDialect) {
90
+ return DEFAULT_TEST_DB_DISTRIBUTOR_PREFIX[dbDialect];
91
+ }
92
+ function supportsTestDbDistributor(dbDialect) {
93
+ return Boolean(defaultTestDbDistributorPrefix(dbDialect));
94
+ }
95
+ function buildTestDbDistributorEnv(env) {
96
+ if (env.DB_DIALECT === 'mysql' || env.DB_DIALECT === 'mariadb') {
97
+ return {
98
+ ...env,
99
+ DB_APP_USER: env.DB_USER,
100
+ DB_USER: 'root',
101
+ };
102
+ }
103
+ return env;
104
+ }
105
+ async function waitForTcpPortReady(port, timeoutMs = 5000) {
106
+ const deadline = Date.now() + timeoutMs;
107
+ while (Date.now() < deadline) {
108
+ if (await commandSucceeds(process.execPath, ['-e', TCP_PORT_READY_SCRIPT, port])) {
109
+ return;
110
+ }
111
+ await delay(100);
112
+ }
113
+ throw new Error(`Timed out while waiting for the test DB distributor on 127.0.0.1:${port}.`);
114
+ }
115
+ async function stopBackgroundProcess(child) {
116
+ if (child.exitCode !== null || child.killed) {
117
+ return;
118
+ }
119
+ await new Promise((resolve) => {
120
+ const finish = () => {
121
+ clearTimeout(timeout);
122
+ resolve();
123
+ };
124
+ const timeout = setTimeout(finish, 1000);
125
+ child.once('close', finish);
126
+ try {
127
+ child.kill();
128
+ }
129
+ catch {
130
+ finish();
131
+ }
132
+ });
133
+ }
134
+ async function startTestDbDistributor(params) {
135
+ const port = DEFAULT_TEST_DB_DISTRIBUTOR_PORT;
136
+ const prefix = defaultTestDbDistributorPrefix(params.env.DB_DIALECT);
137
+ if (!prefix) {
138
+ throw new Error(`The ${params.env.DB_DIALECT} test DB distributor is not supported.`);
139
+ }
140
+ const portError = await validateAvailableTcpPort(port);
141
+ if (portError) {
142
+ throw new Error(`Host port ${port} is unavailable for the test DB distributor. ${portError}`);
143
+ }
144
+ const distributorEnv = buildTestDbDistributorEnv(params.env);
145
+ const child = spawn(process.execPath, [
146
+ path.resolve(params.cwd, 'node_modules', 'tsx', 'dist', 'cli.mjs'),
147
+ path.resolve(params.cwd, 'packages', 'core', 'test', 'src', 'scripts', 'test-db-creator.ts'),
148
+ ], {
149
+ cwd: params.cwd,
150
+ env: {
151
+ ...process.env,
152
+ ...distributorEnv,
153
+ DB_TEST_DISTRIBUTOR_PORT: port,
154
+ DB_TEST_PREFIX: prefix,
155
+ },
156
+ stdio: params.stdio,
157
+ windowsHide: process.platform === 'win32',
158
+ });
159
+ let childError;
160
+ child.once('error', (error) => {
161
+ childError = error;
162
+ });
163
+ child.once('close', (code, signal) => {
164
+ if (code === 0) {
165
+ return;
166
+ }
167
+ childError = childError ?? new Error(signal
168
+ ? `test DB distributor exited due to signal ${signal}`
169
+ : `test DB distributor exited with code ${code ?? 'unknown'}`);
170
+ });
171
+ try {
172
+ await waitForTcpPortReady(port);
173
+ }
174
+ catch (error) {
175
+ await stopBackgroundProcess(child);
176
+ throw childError ?? error;
177
+ }
178
+ return {
179
+ port,
180
+ prefix,
181
+ stop: async () => {
182
+ await stopBackgroundProcess(child);
183
+ },
184
+ };
185
+ }
186
+ async function ensureDockerNetwork(networkName, options) {
187
+ if (await commandSucceeds('docker', ['network', 'inspect', networkName])) {
188
+ return;
189
+ }
190
+ await run('docker', ['network', 'create', networkName], {
191
+ errorName: 'docker network create',
192
+ stdio: options?.stdio ?? 'ignore',
193
+ });
194
+ }
195
+ async function removeDockerContainerIfExists(containerName, options) {
196
+ if (!(await commandSucceeds('docker', ['container', 'inspect', containerName]))) {
197
+ return 'missing';
198
+ }
199
+ await run('docker', ['rm', '-f', containerName], {
200
+ errorName: 'docker rm',
201
+ stdio: options?.stdio ?? 'ignore',
202
+ });
203
+ return 'removed';
204
+ }
205
+ function formatDbBootstrapFailure(message) {
206
+ return [
207
+ 'Could not prepare the built-in test database.',
208
+ 'The CLI was not able to recreate a clean Docker database for this test run.',
209
+ 'Check Docker status, the selected port, and local storage permissions, then try again.',
210
+ `Details: ${message}`,
211
+ ].join('\n');
212
+ }
213
+ function buildTestDbConfig(params) {
214
+ const dbDialect = trimValue(params.dbDialect) || DEFAULT_DB_DIALECT;
215
+ const workspaceName = resolveWorkspaceName(params.cwd);
216
+ const storagePath = path.join(params.cwd, 'storage', 'test');
217
+ const plan = Install.buildBuiltinDbPlan({
218
+ envName: 'test',
219
+ workspaceName,
220
+ storagePath,
221
+ source: 'test',
222
+ dbDialect,
223
+ dbHost: DEFAULT_DB_HOST,
224
+ dbPort: trimValue(params.dbPort) || defaultTestDbPort(dbDialect),
225
+ dbDatabase: trimValue(params.dbDatabase) || DEFAULT_DB_DATABASE,
226
+ dbUser: trimValue(params.dbUser) || DEFAULT_DB_USER,
227
+ dbPassword: trimValue(params.dbPassword) || DEFAULT_DB_PASSWORD,
228
+ builtinDbImage: trimValue(params.builtinDbImage) || defaultTestDbImage(dbDialect),
229
+ });
230
+ return {
231
+ storagePath,
232
+ containerName: plan.containerName,
233
+ networkName: plan.networkName,
234
+ dataDir: plan.dataDir,
235
+ args: plan.args,
236
+ env: {
237
+ APP_ENV_PATH: '.env',
238
+ STORAGE_PATH: storagePath,
239
+ TZ: DEFAULT_TEST_TIMEZONE,
240
+ DB_DIALECT: plan.dbDialect,
241
+ DB_HOST: plan.dbHost,
242
+ DB_PORT: plan.dbPort,
243
+ DB_DATABASE: plan.dbDatabase,
244
+ DB_USER: plan.dbUser,
245
+ DB_PASSWORD: plan.dbPassword,
246
+ },
247
+ };
248
+ }
249
+ async function prepareTestDatabase(config, options) {
250
+ let nextConfig = config;
251
+ await ensureDockerNetwork(nextConfig.networkName, {
252
+ stdio: options?.stdio,
253
+ });
254
+ await removeDockerContainerIfExists(nextConfig.containerName, {
255
+ stdio: options?.stdio,
256
+ });
257
+ await fsp.rm(nextConfig.storagePath, { recursive: true, force: true });
258
+ const portError = await validateAvailableTcpPort(nextConfig.env.DB_PORT);
259
+ if (portError) {
260
+ if (options?.dbPortExplicit) {
261
+ throw new Error(`Host port ${nextConfig.env.DB_PORT} is unavailable. ${portError}`);
262
+ }
263
+ const fallbackPort = await findAvailableTcpPort();
264
+ printInfo(`Host port ${nextConfig.env.DB_PORT} is unavailable for the test database, so the CLI will use ${fallbackPort} instead.`);
265
+ nextConfig = buildTestDbConfig({
266
+ cwd: path.dirname(path.dirname(nextConfig.storagePath)),
267
+ dbDialect: nextConfig.env.DB_DIALECT,
268
+ dbPort: fallbackPort,
269
+ dbDatabase: nextConfig.env.DB_DATABASE,
270
+ dbUser: nextConfig.env.DB_USER,
271
+ dbPassword: nextConfig.env.DB_PASSWORD,
272
+ builtinDbImage: undefined,
273
+ });
274
+ }
275
+ await fsp.mkdir(nextConfig.dataDir, { recursive: true });
276
+ await run('docker', nextConfig.args, {
277
+ errorName: 'docker run',
278
+ stdio: options?.stdio ?? 'ignore',
279
+ });
280
+ await waitForTcpPortReady(nextConfig.env.DB_PORT, 30_000);
281
+ return nextConfig;
282
+ }
283
+ export default class Test extends Command {
284
+ static args = {
285
+ paths: Args.string({
286
+ description: 'test file paths or globs to pass through',
287
+ multiple: true,
288
+ required: false,
289
+ }),
290
+ };
291
+ static description = 'Run project tests from the selected app directory. Before running tests, the CLI recreates a built-in Docker test database and injects `DB_*` values internally.';
292
+ static examples = [
293
+ '<%= config.bin %> <%= command.id %>',
294
+ '<%= config.bin %> <%= command.id %> --cwd /path/to/app',
295
+ '<%= config.bin %> <%= command.id %> packages/core/server/src/__tests__/foo.test.ts',
296
+ '<%= config.bin %> <%= command.id %> --server --coverage',
297
+ '<%= config.bin %> <%= command.id %> --db-port 5433',
298
+ ];
299
+ static flags = {
300
+ cwd: Flags.string({
301
+ char: 'c',
302
+ description: 'App directory to run tests from. Defaults to the current working directory',
303
+ required: false,
304
+ }),
305
+ watch: Flags.boolean({
306
+ char: 'w',
307
+ description: 'Run Vitest in watch mode',
308
+ default: false,
309
+ }),
310
+ run: Flags.boolean({
311
+ description: 'Run once without watch mode',
312
+ default: false,
313
+ }),
314
+ allowOnly: Flags.boolean({
315
+ description: 'Allow `.only` tests',
316
+ default: false,
317
+ }),
318
+ bail: Flags.boolean({
319
+ description: 'Stop after the first failure',
320
+ default: false,
321
+ }),
322
+ coverage: Flags.boolean({
323
+ description: 'Enable coverage reporting',
324
+ default: false,
325
+ }),
326
+ 'single-thread': Flags.string({
327
+ description: 'Forward single-thread mode to the underlying test runner',
328
+ required: false,
329
+ }),
330
+ server: Flags.boolean({
331
+ description: 'Force server-side test mode',
332
+ default: false,
333
+ }),
334
+ client: Flags.boolean({
335
+ description: 'Force client-side test mode',
336
+ default: false,
337
+ }),
338
+ 'db-clean': Flags.boolean({
339
+ char: 'd',
340
+ description: 'Clean the database before tests when supported by the underlying app command',
341
+ default: false,
342
+ }),
343
+ 'db-dialect': Flags.string({
344
+ description: 'Built-in test database dialect to start',
345
+ options: ['postgres', 'mysql', 'mariadb', 'kingbase'],
346
+ required: false,
347
+ }),
348
+ 'db-image': Flags.string({
349
+ description: 'Built-in test database Docker image to start',
350
+ aliases: ['builtin-db-image'],
351
+ required: false,
352
+ }),
353
+ 'db-port': Flags.string({
354
+ description: 'Host TCP port to publish for the built-in test database',
355
+ required: false,
356
+ }),
357
+ 'db-database': Flags.string({
358
+ description: 'Database name to inject for tests',
359
+ required: false,
360
+ }),
361
+ 'db-user': Flags.string({
362
+ description: 'Database user to inject for tests',
363
+ required: false,
364
+ }),
365
+ 'db-password': Flags.string({
366
+ description: 'Database password to inject for tests',
367
+ required: false,
368
+ }),
369
+ verbose: Flags.boolean({
370
+ description: 'Show raw Docker and test runner output',
371
+ default: false,
372
+ }),
373
+ };
374
+ async run() {
375
+ const { args, flags } = await this.parse(Test);
376
+ setVerboseMode(flags.verbose);
377
+ if (flags.server && flags.client) {
378
+ this.error('Cannot use `--server` and `--client` together.');
379
+ }
380
+ const cwd = resolveProjectCwd(flags.cwd);
381
+ const commandArgs = ['test', ...(args.paths ?? [])];
382
+ if (flags.watch) {
383
+ commandArgs.push('--watch');
384
+ }
385
+ if (flags.run || !flags.watch) {
386
+ commandArgs.push('--run');
387
+ }
388
+ if (flags.allowOnly) {
389
+ commandArgs.push('--allowOnly');
390
+ }
391
+ if (flags.bail) {
392
+ commandArgs.push('--bail');
393
+ }
394
+ if (flags.coverage) {
395
+ commandArgs.push('--coverage');
396
+ }
397
+ if (flags.server) {
398
+ commandArgs.push('--server');
399
+ }
400
+ else if (flags.client) {
401
+ commandArgs.push('--client');
402
+ }
403
+ if (flags['db-clean']) {
404
+ commandArgs.push('--db-clean');
405
+ }
406
+ if (flags['single-thread'] !== undefined) {
407
+ commandArgs.push(`--single-thread=${flags['single-thread']}`);
408
+ }
409
+ else if (!flags.client && !flags.server && inferTestEnv(args.paths ?? []) === 'server-side') {
410
+ commandArgs.push('--single-thread=true');
411
+ }
412
+ startTask('Recreating the built-in test database...');
413
+ let testDbConfig;
414
+ let testDbDistributor;
415
+ try {
416
+ testDbConfig = await prepareTestDatabase(buildTestDbConfig({
417
+ cwd,
418
+ dbDialect: flags['db-dialect'],
419
+ builtinDbImage: flags['db-image'],
420
+ dbPort: flags['db-port'],
421
+ dbDatabase: flags['db-database'],
422
+ dbUser: flags['db-user'],
423
+ dbPassword: flags['db-password'],
424
+ }), {
425
+ stdio: flags.verbose ? 'inherit' : 'ignore',
426
+ dbPortExplicit: Boolean(flags['db-port']),
427
+ });
428
+ if (shouldRunServerTests({
429
+ server: flags.server,
430
+ client: flags.client,
431
+ paths: args.paths ?? [],
432
+ })
433
+ && supportsTestDbDistributor(testDbConfig.env.DB_DIALECT)) {
434
+ testDbDistributor = await startTestDbDistributor({
435
+ cwd,
436
+ env: testDbConfig.env,
437
+ stdio: flags.verbose ? 'inherit' : 'ignore',
438
+ });
439
+ testDbConfig.env.DB_TEST_DISTRIBUTOR_PORT = testDbDistributor.port;
440
+ testDbConfig.env.DB_TEST_PREFIX = testDbDistributor.prefix;
441
+ }
442
+ succeedTask(`The built-in test database is ready at ${testDbConfig.env.DB_HOST}:${testDbConfig.env.DB_PORT}.`);
443
+ printInfo(`Test DB settings: DB_DIALECT=${testDbConfig.env.DB_DIALECT} DB_HOST=${testDbConfig.env.DB_HOST} DB_PORT=${testDbConfig.env.DB_PORT} DB_DATABASE=${testDbConfig.env.DB_DATABASE} DB_USER=${testDbConfig.env.DB_USER}`);
444
+ }
445
+ catch (error) {
446
+ const message = error instanceof Error ? error.message : String(error);
447
+ failTask('Failed to recreate the built-in test database.');
448
+ this.error(formatDbBootstrapFailure(message));
449
+ return;
450
+ }
451
+ try {
452
+ await runNocoBaseCommand(commandArgs, {
453
+ cwd,
454
+ stdio: flags.verbose ? 'inherit' : 'ignore',
455
+ env: testDbConfig.env,
456
+ });
457
+ }
458
+ catch (error) {
459
+ const message = error instanceof Error ? error.message : String(error);
460
+ this.error(message);
461
+ }
462
+ finally {
463
+ await testDbDistributor?.stop();
464
+ }
465
+ }
466
+ }
@@ -22,8 +22,14 @@ function clackSelectOptions(options, locale) {
22
22
  value: o.value,
23
23
  label: resolvePromptText(o.label, locale, o.value),
24
24
  ...(o.hint !== undefined ? { hint: resolvePromptText(o.hint, locale) } : {}),
25
+ ...(o.disabled !== undefined ? { disabled: o.disabled } : {}),
25
26
  });
26
27
  }
28
+ function enabledSelectOptionValues(options) {
29
+ return options
30
+ .filter((o) => typeof o === 'string' || o.disabled !== true)
31
+ .map((o) => (typeof o === 'string' ? o : o.value));
32
+ }
27
33
  function defaultOnCancel(locale) {
28
34
  p.cancel(createCliTranslate(locale)('promptCatalog.common.cancelled'));
29
35
  exit(0);
@@ -63,21 +69,22 @@ function mergedBoolean(key, def, iv, useYesInitial) {
63
69
  }
64
70
  function mergedSelect(key, def, iv, useYesInitial) {
65
71
  const valueList = selectOptionValues(def.options);
72
+ const enabledValueList = enabledSelectOptionValues(def.options);
66
73
  if (hasIvKey(iv, key)) {
67
74
  const s = String(iv[key]);
68
- if (valueList.includes(s)) {
75
+ if (enabledValueList.includes(s)) {
69
76
  return s;
70
77
  }
71
78
  return undefined;
72
79
  }
73
- if (useYesInitial && def.yesInitialValue !== undefined && valueList.includes(def.yesInitialValue)) {
80
+ if (useYesInitial && def.yesInitialValue !== undefined && enabledValueList.includes(def.yesInitialValue)) {
74
81
  return def.yesInitialValue;
75
82
  }
76
83
  const d = def.initialValue;
77
- if (d !== undefined && valueList.includes(d)) {
84
+ if (d !== undefined && enabledValueList.includes(d)) {
78
85
  return d;
79
86
  }
80
- return valueList[0];
87
+ return enabledValueList[0];
81
88
  }
82
89
  function mergedInteger(key, def, iv, useYesInitial) {
83
90
  if (hasIvKey(iv, key)) {
@@ -9,7 +9,7 @@
9
9
  import { spawn } from 'node:child_process';
10
10
  import { createServer } from 'node:http';
11
11
  import { createCliTranslate, resolveCliLocale, resolveLocalizedText, } from "./cli-locale.js";
12
- import { isPromptBlockSkipped, runPromptFieldValidate, selectOptionValues, } from "./prompt-catalog.js";
12
+ import { isPromptBlockSkipped, runPromptFieldValidate, } from "./prompt-catalog.js";
13
13
  const DEFAULT_SUBMIT = '/__pwc_ui_submit';
14
14
  const DEFAULT_REFLOW = '/__pwc_ui_reflow';
15
15
  const DEFAULT_VALIDATE_STEP = '/__pwc_ui_validate_step';
@@ -76,12 +76,17 @@ function defaultValueForInput(key, def, out) {
76
76
  case 'boolean':
77
77
  return def.initialValue !== undefined ? Boolean(def.initialValue) : true;
78
78
  case 'select': {
79
- const first = selectOptionValues(def.options)[0];
79
+ const first = def.options
80
+ .find((o) => typeof o === 'string' || o.disabled !== true);
81
+ const firstValue = typeof first === 'string' ? first : first?.value;
80
82
  const i = def.initialValue;
81
- if (i !== undefined && selectOptionValues(def.options).includes(i)) {
83
+ const enabledValues = def.options
84
+ .filter((o) => typeof o === 'string' || o.disabled !== true)
85
+ .map((o) => (typeof o === 'string' ? o : o.value));
86
+ if (i !== undefined && enabledValues.includes(i)) {
82
87
  return i;
83
88
  }
84
- return first ?? '';
89
+ return firstValue ?? '';
85
90
  }
86
91
  case 'password':
87
92
  return def.initialValue ?? '';
@@ -147,7 +152,9 @@ function normalizeWebRawForBlock(def, raw, key) {
147
152
  }
148
153
  case 'select': {
149
154
  const s = String(raw ?? '');
150
- const list = selectOptionValues(def.options);
155
+ const list = def.options
156
+ .filter((o) => typeof o === 'string' || o.disabled !== true)
157
+ .map((o) => (typeof o === 'string' ? o : o.value));
151
158
  if (list.includes(s)) {
152
159
  return s;
153
160
  }
@@ -298,11 +305,15 @@ function renderPwcRadioOptions(key, def, defaults, hidden, locale) {
298
305
  : o.hint
299
306
  ? `<div class="pwc-radio-option-hint">${escapeHtml(resolveUiText(o.hint, locale))}</div>`
300
307
  : '';
308
+ const optionDisabled = typeof o !== 'string' && o.disabled === true;
301
309
  const checked = String(defaults[key] ?? '') === val ? ' checked' : '';
302
310
  const required = def.required && index === 0 ? ' required' : '';
303
- const disabled = hidden ? ' disabled' : '';
304
- return (`<label class="pwc-radio-option">` +
305
- `<input class="pwc-radio-input" name="${escapeHtml(key)}" type="radio" value="${escapeHtml(String(val))}"${checked}${required}${disabled} />` +
311
+ const disabled = hidden || optionDisabled ? ' disabled' : '';
312
+ const optionClass = optionDisabled ? 'pwc-radio-option pwc-radio-option--disabled' : 'pwc-radio-option';
313
+ const ariaDisabled = optionDisabled ? ' aria-disabled="true"' : '';
314
+ const staticDisabled = optionDisabled ? ' data-pwc-static-disabled="1"' : '';
315
+ return (`<label class="${optionClass}"${ariaDisabled}>` +
316
+ `<input class="pwc-radio-input" name="${escapeHtml(key)}" type="radio" value="${escapeHtml(String(val))}"${checked}${required}${disabled}${staticDisabled} />` +
306
317
  `<span class="pwc-radio-option-body">` +
307
318
  `<span class="pwc-radio-option-label">${escapeHtml(String(lab))}</span>` +
308
319
  hint +
@@ -361,7 +372,8 @@ function renderPwcFieldRow(key, def, defaults, show, locale) {
361
372
  const val = typeof o === 'string' ? o : o.value;
362
373
  const lab = typeof o === 'string' ? o : resolveUiText(o.label, locale, o.value);
363
374
  const sel = String(defaults[key] ?? '') === val ? ' selected' : '';
364
- return `<option value="${escapeHtml(String(val))}"${sel}>${escapeHtml(String(lab))}</option>`;
375
+ const disabledOpt = typeof o !== 'string' && o.disabled === true ? ' disabled' : '';
376
+ return `<option value="${escapeHtml(String(val))}"${sel}${disabledOpt}>${escapeHtml(String(lab))}</option>`;
365
377
  })
366
378
  .join('');
367
379
  const req = def.required ? ' required' : '';
@@ -1132,6 +1144,15 @@ function runPromptCatalogWebUIImpl(options) {
1132
1144
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--pwc-ad-color-primary) 8%, transparent);
1133
1145
  background: color-mix(in srgb, var(--pwc-ad-color-primary) 2%, var(--pwc-ad-color-container));
1134
1146
  }
1147
+ .pwc-radio-option--disabled {
1148
+ cursor: not-allowed;
1149
+ background: var(--pwc-ad-color-fill-alter);
1150
+ }
1151
+ .pwc-radio-option--disabled:hover {
1152
+ border-color: var(--pwc-ad-color-border);
1153
+ box-shadow: none;
1154
+ background: var(--pwc-ad-color-fill-alter);
1155
+ }
1135
1156
  .pwc-radio-input {
1136
1157
  margin: 3px 0 0;
1137
1158
  width: 16px;
@@ -1140,6 +1161,9 @@ function runPromptCatalogWebUIImpl(options) {
1140
1161
  accent-color: var(--pwc-ad-color-primary);
1141
1162
  cursor: pointer;
1142
1163
  }
1164
+ .pwc-radio-option--disabled .pwc-radio-input {
1165
+ cursor: not-allowed;
1166
+ }
1143
1167
  .pwc-radio-option-body { min-width: 0; }
1144
1168
  .pwc-radio-option-label {
1145
1169
  display: block;
@@ -1147,12 +1171,18 @@ function runPromptCatalogWebUIImpl(options) {
1147
1171
  line-height: 1.5715;
1148
1172
  color: var(--pwc-ad-color-text);
1149
1173
  }
1174
+ .pwc-radio-option--disabled .pwc-radio-option-label {
1175
+ color: color-mix(in srgb, var(--pwc-ad-color-text) 55%, transparent);
1176
+ }
1150
1177
  .pwc-radio-option-hint {
1151
1178
  margin-top: 2px;
1152
1179
  font-size: 12px;
1153
1180
  line-height: 1.5;
1154
1181
  color: var(--pwc-ad-color-text-description);
1155
1182
  }
1183
+ .pwc-radio-option--disabled .pwc-radio-option-hint {
1184
+ color: color-mix(in srgb, var(--pwc-ad-color-text-description) 70%, transparent);
1185
+ }
1156
1186
  .pwc-radio-input:focus-visible {
1157
1187
  outline: none;
1158
1188
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--pwc-ad-color-primary) 20%, transparent);
@@ -1163,6 +1193,11 @@ function runPromptCatalogWebUIImpl(options) {
1163
1193
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--pwc-ad-color-primary) 12%, transparent);
1164
1194
  background: color-mix(in srgb, var(--pwc-ad-color-primary) 5%, var(--pwc-ad-color-container));
1165
1195
  }
1196
+ .pwc-radio-option--disabled:has(.pwc-radio-input:checked) {
1197
+ border-color: var(--pwc-ad-color-border);
1198
+ box-shadow: none;
1199
+ background: var(--pwc-ad-color-fill-alter);
1200
+ }
1166
1201
  .pwc-form-item-has-error .pwc-radio-option {
1167
1202
  border-color: var(--pwc-form-color-error);
1168
1203
  }
@@ -1570,7 +1605,7 @@ function runPromptCatalogWebUIImpl(options) {
1570
1605
  w.style.display = hidden ? 'none' : 'block';
1571
1606
  var ctrls = w.querySelectorAll('input, select, textarea');
1572
1607
  for (var i = 0; i < ctrls.length; i++) {
1573
- ctrls[i].disabled = hidden;
1608
+ ctrls[i].disabled = hidden || ctrls[i].getAttribute('data-pwc-static-disabled') === '1';
1574
1609
  }
1575
1610
  if (!hidden && k && !pwcIsFieldDirty(k) && Object.prototype.hasOwnProperty.call(vals, k)) {
1576
1611
  setControlValue(form, k, vals[k]);