@numa-tech/numa 1.12.11 → 1.13.1

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 (59) hide show
  1. package/README.md +111 -4
  2. package/dist/application-onboarding/client.d.ts +83 -8
  3. package/dist/application-onboarding/client.js +23 -1
  4. package/dist/application-onboarding/client.js.map +1 -1
  5. package/dist/application-onboarding/commands.js +72 -6
  6. package/dist/application-onboarding/commands.js.map +1 -1
  7. package/dist/application-onboarding/schemas.d.ts +301 -58
  8. package/dist/application-onboarding/schemas.js +134 -8
  9. package/dist/application-onboarding/schemas.js.map +1 -1
  10. package/dist/application-onboarding/tui-model.d.ts +20 -0
  11. package/dist/application-onboarding/tui-model.js +362 -20
  12. package/dist/application-onboarding/tui-model.js.map +1 -1
  13. package/dist/application-onboarding/tui.d.ts +6 -1
  14. package/dist/application-onboarding/tui.js +104 -7
  15. package/dist/application-onboarding/tui.js.map +1 -1
  16. package/dist/cli.js +50 -8
  17. package/dist/cli.js.map +1 -1
  18. package/dist/clusters/client.d.ts +436 -0
  19. package/dist/clusters/client.js +207 -0
  20. package/dist/clusters/client.js.map +1 -0
  21. package/dist/clusters/commands.d.ts +11 -0
  22. package/dist/clusters/commands.js +373 -0
  23. package/dist/clusters/commands.js.map +1 -0
  24. package/dist/clusters/errors.d.ts +10 -0
  25. package/dist/clusters/errors.js +61 -0
  26. package/dist/clusters/errors.js.map +1 -0
  27. package/dist/clusters/kubeconfig-discovery.d.ts +48 -0
  28. package/dist/clusters/kubeconfig-discovery.js +280 -0
  29. package/dist/clusters/kubeconfig-discovery.js.map +1 -0
  30. package/dist/clusters/schemas.d.ts +510 -0
  31. package/dist/clusters/schemas.js +201 -0
  32. package/dist/clusters/schemas.js.map +1 -0
  33. package/dist/command-catalog.js +370 -1
  34. package/dist/command-catalog.js.map +1 -1
  35. package/dist/gitops/client.d.ts +499 -0
  36. package/dist/gitops/client.js +163 -0
  37. package/dist/gitops/client.js.map +1 -0
  38. package/dist/gitops/commands.d.ts +11 -0
  39. package/dist/gitops/commands.js +367 -0
  40. package/dist/gitops/commands.js.map +1 -0
  41. package/dist/gitops/errors.d.ts +10 -0
  42. package/dist/gitops/errors.js +59 -0
  43. package/dist/gitops/errors.js.map +1 -0
  44. package/dist/gitops/schemas.d.ts +1712 -0
  45. package/dist/gitops/schemas.js +360 -0
  46. package/dist/gitops/schemas.js.map +1 -0
  47. package/dist/repositories/client.d.ts +558 -0
  48. package/dist/repositories/client.js +215 -0
  49. package/dist/repositories/client.js.map +1 -0
  50. package/dist/repositories/commands.d.ts +13 -0
  51. package/dist/repositories/commands.js +368 -0
  52. package/dist/repositories/commands.js.map +1 -0
  53. package/dist/repositories/errors.d.ts +10 -0
  54. package/dist/repositories/errors.js +60 -0
  55. package/dist/repositories/errors.js.map +1 -0
  56. package/dist/repositories/schemas.d.ts +1724 -0
  57. package/dist/repositories/schemas.js +261 -0
  58. package/dist/repositories/schemas.js.map +1 -0
  59. package/package.json +4 -4
@@ -1,5 +1,5 @@
1
1
  import { OnboardingError } from "./errors.js";
2
- import { CredentialFreeOnboardingValueSchema } from "./schemas.js";
2
+ import { CredentialFreeOnboardingValueSchema, OnboardingSpecSchema } from "./schemas.js";
3
3
  export const ONBOARDING_STEPS = [
4
4
  "MODE",
5
5
  "BASIC_INFORMATION",
@@ -11,21 +11,54 @@ export const ONBOARDING_STEPS = [
11
11
  "REVIEW",
12
12
  "EXECUTION"
13
13
  ];
14
+ const DEFAULT_GITOPS_STRUCTURAL_SPEC = {
15
+ workloadKind: "SPRING",
16
+ environment: {},
17
+ secretEnvironment: []
18
+ };
19
+ const GITOPS_STRUCTURAL_FIELDS = new Set([
20
+ "workloadKind", "chart", "workload", "service", "probes", "ingress",
21
+ "environment", "secretEnvironment", "advancedValues"
22
+ ]);
23
+ const GITOPS_PLATFORM_MANAGED_FIELDS = new Set([
24
+ "image", "appCode", "environmentId", "namespace", "releaseName", "targetKey",
25
+ "repository", "writeBranch", "reconcileBranch", "overlayPath", "imageRepository", "imageTag"
26
+ ]);
27
+ const GITOPS_MANAGED_ADVANCED_VALUES = new Set([
28
+ "image", "replicaCount", "strategy", "service", "ingress", "startupProbe",
29
+ "livenessProbe", "readinessProbe", "resources", "env"
30
+ ]);
31
+ const GITOPS_SENSITIVE_ENVIRONMENT_NAME = /(^|_)(PASSWORD|PASSWD|PWD|SECRET|TOKEN|API_KEY|PRIVATE_KEY|ACCESS_KEY|SECRET_KEY|CREDENTIAL|DATABASE_URL|REDIS_URL|MONGO_URI|JDBC_URL)($|_)/u;
32
+ const GITOPS_ENVIRONMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
14
33
  export function emptyWizardDraft(mode) {
15
34
  return {
16
- schemaVersion: 1,
35
+ schemaVersion: 2,
17
36
  mode,
18
37
  application: { lifecycle: "EXPERIMENTAL", tier: "TIER_3" },
19
- repository: { defaultBranch: "main" },
20
- technology: { repoMode: "STANDALONE", parameters: {} },
38
+ repositoryStrategy: mode === "EXISTING" ? "ATTACH_EXISTING" : "CREATE_NEW",
39
+ projectStrategy: mode === "EXISTING" ? "ASSESS_ONLY" : "BOOTSTRAP_TEMPLATE",
40
+ technology: { repoMode: mode === "EXISTING" ? "ASSESS_ONLY" : "STANDALONE", parameters: {} },
21
41
  delivery: {
22
42
  jenkinsRequired: true,
23
43
  tiers: ["DEVELOP"],
24
44
  gitopsRequired: false,
45
+ profileCode: "MCI_FLUX_V1",
46
+ profileVersion: "1.0.0",
47
+ structuralSpec: cloneValue(DEFAULT_GITOPS_STRUCTURAL_SPEC),
25
48
  nacosRequired: false
26
49
  }
27
50
  };
28
51
  }
52
+ function legacyWizardDraft(mode) {
53
+ return {
54
+ schemaVersion: 1,
55
+ mode,
56
+ application: { lifecycle: "EXPERIMENTAL", tier: "TIER_3" },
57
+ repository: { defaultBranch: "main" },
58
+ technology: { repoMode: "STANDALONE", parameters: {} },
59
+ delivery: cloneValue(emptyWizardDraft(mode).delivery)
60
+ };
61
+ }
29
62
  export function initialOnboardingTuiState() {
30
63
  return {
31
64
  phase: "LOADING",
@@ -174,20 +207,27 @@ function mergeCatalog(current, update) {
174
207
  };
175
208
  }
176
209
  export function draftFromSession(session) {
177
- const base = emptyWizardDraft(session.mode);
178
- if (!session.draft || typeof session.draft !== "object" || Array.isArray(session.draft))
179
- return base;
210
+ if (!session.draft || typeof session.draft !== "object" || Array.isArray(session.draft)) {
211
+ return emptyWizardDraft(session.mode);
212
+ }
180
213
  const incoming = cloneObject(session.draft);
181
- return {
214
+ const schemaVersion = incoming.schemaVersion === 2
215
+ ? 2
216
+ : incoming.schemaVersion === 1 || Object.hasOwn(incoming, "repository") ? 1 : 2;
217
+ const base = schemaVersion === 2 ? emptyWizardDraft(session.mode) : legacyWizardDraft(session.mode);
218
+ const result = {
182
219
  ...base,
183
220
  ...incoming,
184
- schemaVersion: typeof incoming.schemaVersion === "number" ? incoming.schemaVersion : 1,
221
+ schemaVersion,
185
222
  mode: session.mode,
186
223
  application: { ...asObject(base.application), ...asObject(incoming.application) },
187
- repository: { ...asObject(base.repository), ...asObject(incoming.repository) },
188
224
  technology: { ...asObject(base.technology), ...asObject(incoming.technology) },
189
225
  delivery: { ...asObject(base.delivery), ...asObject(incoming.delivery) }
190
226
  };
227
+ if (schemaVersion === 1) {
228
+ result.repository = { ...asObject(base.repository), ...asObject(incoming.repository) };
229
+ }
230
+ return result;
191
231
  }
192
232
  export function fieldsForState(state) {
193
233
  const step = ONBOARDING_STEPS[state.stepIndex];
@@ -209,6 +249,39 @@ export function fieldsForState(state) {
209
249
  { path: "application.ownerUsername", label: "Owner username", kind: "text" }
210
250
  ];
211
251
  if (step === "SOURCE_CONTROL") {
252
+ if (valueAtPath(state.draft, "schemaVersion") === 2) {
253
+ const strategy = stringAt(state.draft, "repositoryStrategy");
254
+ const repositoryChoices = state.catalog.repositories.map((item) => ({
255
+ value: text(item.repositoryKey),
256
+ label: [
257
+ text(item.fullName ?? item.name ?? item.repositoryKey),
258
+ text(item.connectionCode),
259
+ item.migrationRequired === true ? "MIGRATION_REQUIRED" : ""
260
+ ].filter(Boolean).join(" · ")
261
+ })).filter((item) => item.value);
262
+ return [
263
+ {
264
+ path: "repositoryStrategy",
265
+ label: "Repository strategy",
266
+ kind: "select",
267
+ required: true,
268
+ choices: choices(mode === "EXISTING" ? ["ATTACH_EXISTING"] : ["CREATE_NEW", "ATTACH_EXISTING"])
269
+ },
270
+ ...(strategy === "CREATE_NEW" ? [
271
+ { path: "repositoryPlanRef.planNo", label: "Repository plan number", kind: "text", required: true, placeholder: "RP-1" },
272
+ { path: "repositoryPlanRef.planHash", label: "Repository plan hash", kind: "text", required: true, placeholder: "64 lowercase hex" }
273
+ ] : [
274
+ {
275
+ path: "repositoryRef.repositoryKey",
276
+ label: "Managed repository key",
277
+ kind: "choiceOrText",
278
+ required: true,
279
+ choices: includeCurrent(repositoryChoices, stringAt(state.draft, "repositoryRef.repositoryKey"))
280
+ },
281
+ { path: "repositoryRef.expectedHead", label: "Expected repository HEAD", kind: "text", required: true, placeholder: "40-64 hex commit" }
282
+ ])
283
+ ];
284
+ }
212
285
  const connectionChoices = state.catalog.connections.map((item) => ({
213
286
  value: text(item.code),
214
287
  label: [text(item.name), text(item.provider), text(item.health)].filter(Boolean).join(" · ")
@@ -258,7 +331,9 @@ export function fieldsForState(state) {
258
331
  required: true,
259
332
  choices: includeCurrent(stackChoices, stackCode)
260
333
  },
261
- { path: "technology.repoMode", label: "Repository mode", kind: "select", choices: choices(["STANDALONE", "ADD_MODULE"]) },
334
+ ...(valueAtPath(state.draft, "schemaVersion") === 2 ? [] : [
335
+ { path: "technology.repoMode", label: "Repository mode", kind: "select", choices: choices(["STANDALONE", "ADD_MODULE"]) }
336
+ ]),
262
337
  ...(mode === "NEW" ? [{
263
338
  path: "technology.templateCode",
264
339
  label: "Copier template",
@@ -274,6 +349,12 @@ export function fieldsForState(state) {
274
349
  { path: "delivery.jenkinsRequired", label: "Jenkins required", kind: "boolean" },
275
350
  { path: "delivery.tiers", label: "Jenkins tiers", kind: "tiers" },
276
351
  { path: "delivery.gitopsRequired", label: "GitOps required", kind: "boolean" },
352
+ ...(valueAtPath(state.draft, "delivery.gitopsRequired") === true ? [
353
+ { path: "delivery.targetKey", label: "GitOps deployment target key", kind: "text", required: true },
354
+ { path: "delivery.profileCode", label: "GitOps layout profile", kind: "text", required: true, placeholder: "MCI_FLUX_V1" },
355
+ { path: "delivery.profileVersion", label: "GitOps profile version", kind: "text", required: true, placeholder: "1.0.0" },
356
+ { path: "delivery.structuralSpec", label: "GitOps structural spec JSON", kind: "json", required: true }
357
+ ] : []),
277
358
  { path: "delivery.nacosRequired", label: "Nacos required", kind: "boolean" }
278
359
  ];
279
360
  return [];
@@ -287,6 +368,33 @@ export function valueAtPath(root, path) {
287
368
  }
288
369
  return current;
289
370
  }
371
+ export function gitopsExecutionDetails(events) {
372
+ for (const event of [...events].reverse()) {
373
+ const output = event.output;
374
+ if (!output || typeof output !== "object" || Array.isArray(output))
375
+ continue;
376
+ const value = output;
377
+ if (!value.gitopsPlanNumber && !value.gitopsRunNo && !value.gitopsState)
378
+ continue;
379
+ return {
380
+ planNumber: optionalText(value.gitopsPlanNumber),
381
+ planHash: optionalText(value.gitopsPlanHash),
382
+ files: textArray(value.gitopsPlanFiles),
383
+ warnings: textArray(value.gitopsWarnings),
384
+ sideEffects: textArray(value.gitopsSideEffects),
385
+ runNumber: optionalText(value.gitopsRunNo),
386
+ state: optionalText(value.gitopsState),
387
+ lastEventSequence: typeof value.gitopsLastEventSequence === "number"
388
+ ? value.gitopsLastEventSequence : undefined,
389
+ bindingId: optionalText(value.gitopsBindingId),
390
+ targetKey: optionalText(value.gitopsTargetKey),
391
+ baseRevision: optionalText(value.gitopsBaseRevision),
392
+ resultRevision: optionalText(value.gitopsResultRevision),
393
+ reviewUrl: optionalText(value.gitopsReviewUrl)
394
+ };
395
+ }
396
+ return undefined;
397
+ }
290
398
  export function stringAt(root, path) {
291
399
  const value = valueAtPath(root, path);
292
400
  return typeof value === "string" ? value : value == null ? "" : String(value);
@@ -329,6 +437,8 @@ export function parseFieldInput(field, input) {
329
437
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
330
438
  throw new OnboardingError(`${field.label} must be a JSON object.`, "onboarding_input_invalid");
331
439
  }
440
+ if (field.path === "delivery.structuralSpec")
441
+ validateGitopsStructuralSpec(parsed);
332
442
  return parsed;
333
443
  }
334
444
  if (field.kind === "tiers") {
@@ -344,9 +454,26 @@ export function parseFieldInput(field, input) {
344
454
  return trimmed || undefined;
345
455
  }
346
456
  export function assertCredentialFreeDraft(draft) {
347
- const parsed = CredentialFreeOnboardingValueSchema.safeParse(draft);
348
- if (!parsed.success) {
349
- throw new OnboardingError(`Draft contains credential-bearing input: ${parsed.error.issues[0]?.path.join(".") || "unknown field"}.`, "onboarding_secret_not_allowed");
457
+ const credentialScan = cloneObject(draft);
458
+ const delivery = asObject(credentialScan.delivery);
459
+ let structuralCredentialScan;
460
+ if (delivery.structuralSpec !== undefined) {
461
+ const structuralSpec = asObject(delivery.structuralSpec);
462
+ validateGitopsStructuralSpec(structuralSpec);
463
+ structuralCredentialScan = cloneObject(structuralSpec);
464
+ delete structuralCredentialScan.secretEnvironment;
465
+ delete delivery.structuralSpec;
466
+ credentialScan.delivery = delivery;
467
+ }
468
+ const parsed = CredentialFreeOnboardingValueSchema.safeParse(credentialScan);
469
+ const structuralParsed = structuralCredentialScan == null
470
+ ? undefined
471
+ : CredentialFreeOnboardingValueSchema.safeParse({ controlledStructure: structuralCredentialScan });
472
+ if (!parsed.success || structuralParsed?.success === false) {
473
+ const issue = !parsed.success
474
+ ? parsed.error.issues[0]
475
+ : structuralParsed && !structuralParsed.success ? structuralParsed.error.issues[0] : undefined;
476
+ throw new OnboardingError(`Draft contains credential-bearing input: ${issue?.path.join(".") || "unknown field"}.`, "onboarding_secret_not_allowed");
350
477
  }
351
478
  }
352
479
  export function cycleFieldValue(state, field, direction) {
@@ -364,6 +491,102 @@ export function cycleFieldValue(state, field, direction) {
364
491
  const selected = options[nextIndex].value;
365
492
  return setDraftPath(state.draft, field.path, field.kind === "number" ? Number(selected) : selected);
366
493
  }
494
+ export function enableGitopsDeliveryDefaults(draft) {
495
+ let result = setDraftPath(draft, "delivery.jenkinsRequired", true);
496
+ if (!stringAt(result, "delivery.profileCode")) {
497
+ result = setDraftPath(result, "delivery.profileCode", "MCI_FLUX_V1");
498
+ }
499
+ if (!stringAt(result, "delivery.profileVersion")) {
500
+ result = setDraftPath(result, "delivery.profileVersion", "1.0.0");
501
+ }
502
+ if (!valueAtPath(result, "delivery.structuralSpec")) {
503
+ result = setDraftPath(result, "delivery.structuralSpec", DEFAULT_GITOPS_STRUCTURAL_SPEC);
504
+ }
505
+ return result;
506
+ }
507
+ export function validateGitopsStructuralSpec(spec) {
508
+ for (const field of Object.keys(spec)) {
509
+ if (GITOPS_PLATFORM_MANAGED_FIELDS.has(field)) {
510
+ throw new OnboardingError(`delivery.structuralSpec.${field} is platform/Jenkins-managed and cannot be submitted.`, "GITOPS_MANAGED_FIELD_FORBIDDEN");
511
+ }
512
+ if (!GITOPS_STRUCTURAL_FIELDS.has(field)) {
513
+ throw new OnboardingError(`delivery.structuralSpec.${field} is not supported by MCI_FLUX_V1.`, "GITOPS_SPEC_INVALID");
514
+ }
515
+ }
516
+ validateGitopsEnvironment(spec.environment);
517
+ validateGitopsSecretEnvironment(spec.secretEnvironment);
518
+ if (spec.advancedValues != null) {
519
+ const advancedValues = requireRecord(spec.advancedValues, "delivery.structuralSpec.advancedValues");
520
+ for (const field of Object.keys(advancedValues)) {
521
+ if (GITOPS_MANAGED_ADVANCED_VALUES.has(field)) {
522
+ throw new OnboardingError(`delivery.structuralSpec.advancedValues.${field} is managed by MCI_FLUX_V1.`, "GITOPS_MANAGED_FIELD_FORBIDDEN");
523
+ }
524
+ }
525
+ }
526
+ rejectRawGitopsSecret(spec, "delivery.structuralSpec");
527
+ }
528
+ function validateGitopsEnvironment(value) {
529
+ if (value == null)
530
+ return;
531
+ const environment = requireRecord(value, "delivery.structuralSpec.environment");
532
+ for (const [name, raw] of Object.entries(environment)) {
533
+ if (!GITOPS_ENVIRONMENT_NAME.test(name) || typeof raw !== "string") {
534
+ throw new OnboardingError(`delivery.structuralSpec.environment.${name} must have a valid environment name and string value.`, "GITOPS_SPEC_INVALID");
535
+ }
536
+ if (GITOPS_SENSITIVE_ENVIRONMENT_NAME.test(name.toUpperCase()) || looksLikeGitopsCredential(raw)) {
537
+ throw new OnboardingError(`delivery.structuralSpec.environment.${name} may contain plaintext credentials; use secretEnvironment SecretRef.`, "GITOPS_PLAINTEXT_CREDENTIAL_FORBIDDEN");
538
+ }
539
+ }
540
+ }
541
+ function validateGitopsSecretEnvironment(value) {
542
+ if (value == null)
543
+ return;
544
+ if (!Array.isArray(value)) {
545
+ throw new OnboardingError("delivery.structuralSpec.secretEnvironment must be an array of SecretRef objects.", "GITOPS_SPEC_INVALID");
546
+ }
547
+ value.forEach((raw, index) => {
548
+ const item = requireRecord(raw, `delivery.structuralSpec.secretEnvironment[${index}]`);
549
+ const allowed = new Set(["name", "secretName", "secretKey", "optional"]);
550
+ const unknown = Object.keys(item).find((field) => !allowed.has(field));
551
+ if (unknown) {
552
+ throw new OnboardingError(`delivery.structuralSpec.secretEnvironment[${index}].${unknown} is not an allowed SecretRef field.`, "GITOPS_RAW_SECRET_FORBIDDEN");
553
+ }
554
+ if (!nonEmptyString(item.name) || !GITOPS_ENVIRONMENT_NAME.test(item.name)
555
+ || !nonEmptyString(item.secretName) || !nonEmptyString(item.secretKey)
556
+ || (item.optional != null && typeof item.optional !== "boolean")) {
557
+ throw new OnboardingError(`delivery.structuralSpec.secretEnvironment[${index}] requires name, secretName, secretKey, and optional boolean optional.`, "GITOPS_SPEC_INVALID");
558
+ }
559
+ });
560
+ }
561
+ function rejectRawGitopsSecret(value, path) {
562
+ if (Array.isArray(value)) {
563
+ value.forEach((item, index) => rejectRawGitopsSecret(item, `${path}[${index}]`));
564
+ return;
565
+ }
566
+ if (!value || typeof value !== "object")
567
+ return;
568
+ const object = value;
569
+ if (String(object.kind ?? "").toLowerCase() === "secret"
570
+ && (Object.hasOwn(object, "data") || Object.hasOwn(object, "stringData"))) {
571
+ throw new OnboardingError(`${path} cannot contain Kubernetes Secret.data/stringData; use an existing SecretRef.`, "GITOPS_RAW_SECRET_FORBIDDEN");
572
+ }
573
+ Object.entries(object).forEach(([key, item]) => rejectRawGitopsSecret(item, `${path}.${key}`));
574
+ }
575
+ function requireRecord(value, path) {
576
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
577
+ throw new OnboardingError(`${path} must be a JSON object.`, "GITOPS_SPEC_INVALID");
578
+ }
579
+ return value;
580
+ }
581
+ function nonEmptyString(value) {
582
+ return typeof value === "string" && Boolean(value.trim());
583
+ }
584
+ function looksLikeGitopsCredential(value) {
585
+ return /:\/\/[^/\s:@]+:[^@\s/]+@/u.test(value)
586
+ || /(password|passwd|secret|token|api[_-]?key)\s*[:=]/iu.test(value)
587
+ || value.includes("-----BEGIN PRIVATE KEY-----")
588
+ || value.includes("-----BEGIN RSA PRIVATE KEY-----");
589
+ }
367
590
  export function applyRepositorySelection(draft, repositories, externalId) {
368
591
  let result = setDraftPath(draft, "repository.externalId", externalId || undefined);
369
592
  const repository = repositories.find((item) => text(item.externalId ?? item.id) === externalId);
@@ -374,6 +597,22 @@ export function applyRepositorySelection(draft, repositories, externalId) {
374
597
  result = setDraftPath(result, "repository.namespace", undefined);
375
598
  return setDraftPath(result, "repository.defaultBranch", text(repository.defaultBranch) || "main");
376
599
  }
600
+ export function applyRepositoryStrategy(draft, mode, strategy) {
601
+ const effective = mode === "EXISTING" ? "ATTACH_EXISTING" : strategy;
602
+ let result = setDraftPath(draft, "repositoryStrategy", effective);
603
+ result = setDraftPath(result, "projectStrategy", mode === "EXISTING" ? "ASSESS_ONLY" : effective === "CREATE_NEW" ? "BOOTSTRAP_TEMPLATE" : "ADD_MODULE_CHANGE");
604
+ result = setDraftPath(result, "technology.repoMode", mode === "EXISTING" ? "ASSESS_ONLY" : effective === "CREATE_NEW" ? "STANDALONE" : "ADD_MODULE");
605
+ result = setDraftPath(result, effective === "CREATE_NEW" ? "repositoryRef" : "repositoryPlanRef", undefined);
606
+ return result;
607
+ }
608
+ export function applyManagedRepositorySelection(draft, repositories, repositoryKey) {
609
+ let result = setDraftPath(draft, "repositoryRef.repositoryKey", repositoryKey || undefined);
610
+ const repository = repositories.find((item) => text(item.repositoryKey) === repositoryKey);
611
+ if (!repository)
612
+ return result;
613
+ const observedRevision = text(repository.observedRevision ?? repository.lastObservedRevision);
614
+ return setDraftPath(result, "repositoryRef.expectedHead", /^[0-9a-fA-F]{40,64}$/u.test(observedRevision) ? observedRevision : undefined);
615
+ }
377
616
  export function applyTemplateDefaults(draft, template) {
378
617
  const schema = asObject(template?.parameterSchema);
379
618
  const defaults = defaultsFromSchema(schema);
@@ -403,15 +642,60 @@ export function validateWizardBeforeStep(state, targetStepIndex) {
403
642
  errors.push("application.teamId must be a positive integer.");
404
643
  if (!positiveIntegerAt(state.draft, "application.systemId"))
405
644
  errors.push("application.systemId must be a positive integer.");
406
- requireText("repository.connectionCode");
645
+ const schemaVersion = valueAtPath(state.draft, "schemaVersion");
407
646
  const addModule = mode === "NEW" && stringAt(state.draft, "technology.repoMode") === "ADD_MODULE";
408
- if (mode === "NEW" && !addModule) {
409
- requireText("repository.namespace");
410
- requireText("repository.name");
647
+ if (schemaVersion === 2) {
648
+ if (Object.hasOwn(state.draft, "repository")) {
649
+ errors.push("schemaVersion=2 forbids legacy repository/provider fields; use repositoryPlanRef or repositoryRef.");
650
+ }
651
+ const strategy = stringAt(state.draft, "repositoryStrategy");
652
+ if (mode === "EXISTING" && strategy !== "ATTACH_EXISTING") {
653
+ errors.push("repositoryStrategy must be ATTACH_EXISTING for EXISTING onboarding.");
654
+ }
655
+ else if (!["CREATE_NEW", "ATTACH_EXISTING"].includes(strategy)) {
656
+ errors.push("repositoryStrategy must be CREATE_NEW or ATTACH_EXISTING.");
657
+ }
658
+ const expectedProject = mode === "EXISTING"
659
+ ? "ASSESS_ONLY"
660
+ : strategy === "CREATE_NEW" ? "BOOTSTRAP_TEMPLATE" : "ADD_MODULE_CHANGE";
661
+ if (stringAt(state.draft, "projectStrategy") !== expectedProject) {
662
+ errors.push(`projectStrategy must be ${expectedProject}.`);
663
+ }
664
+ const expectedRepoMode = mode === "EXISTING"
665
+ ? "ASSESS_ONLY"
666
+ : strategy === "CREATE_NEW" ? "STANDALONE" : "ADD_MODULE";
667
+ if (stringAt(state.draft, "technology.repoMode") !== expectedRepoMode) {
668
+ errors.push(`technology.repoMode must be ${expectedRepoMode}.`);
669
+ }
670
+ if (strategy === "CREATE_NEW") {
671
+ requireText("repositoryPlanRef.planNo");
672
+ if (!/^[0-9a-f]{64}$/u.test(stringAt(state.draft, "repositoryPlanRef.planHash"))) {
673
+ errors.push("repositoryPlanRef.planHash must be the exact 64-lowercase-hex server plan hash.");
674
+ }
675
+ if (valueAtPath(state.draft, "repositoryRef") != null) {
676
+ errors.push("CREATE_NEW cannot include repositoryRef.");
677
+ }
678
+ }
679
+ else if (strategy === "ATTACH_EXISTING") {
680
+ requireText("repositoryRef.repositoryKey");
681
+ if (!/^[0-9a-fA-F]{40,64}$/u.test(stringAt(state.draft, "repositoryRef.expectedHead"))) {
682
+ errors.push("repositoryRef.expectedHead must be an immutable 40-64 hex revision.");
683
+ }
684
+ if (valueAtPath(state.draft, "repositoryPlanRef") != null) {
685
+ errors.push("ATTACH_EXISTING cannot include repositoryPlanRef.");
686
+ }
687
+ }
411
688
  }
412
689
  else {
413
- requireText("repository.externalId");
414
- requireText("repository.expectedHead");
690
+ requireText("repository.connectionCode");
691
+ if (mode === "NEW" && !addModule) {
692
+ requireText("repository.namespace");
693
+ requireText("repository.name");
694
+ }
695
+ else {
696
+ requireText("repository.externalId");
697
+ requireText("repository.expectedHead");
698
+ }
415
699
  }
416
700
  requireText("technology.stackCode");
417
701
  if (mode === "NEW") {
@@ -420,6 +704,52 @@ export function validateWizardBeforeStep(state, targetStepIndex) {
420
704
  if (selected)
421
705
  errors.push(...validateTemplateParameters(state.draft, state.catalog.templates));
422
706
  }
707
+ if (targetStepIndex >= ONBOARDING_STEPS.indexOf("REVIEW")
708
+ && valueAtPath(state.draft, "delivery.gitopsRequired") === true) {
709
+ if (valueAtPath(state.draft, "delivery.jenkinsRequired") !== true) {
710
+ errors.push("delivery.jenkinsRequired must be enabled for GitOps image delivery evidence.");
711
+ }
712
+ const tiers = valueAtPath(state.draft, "delivery.tiers");
713
+ if (!Array.isArray(tiers) || tiers.length === 0) {
714
+ errors.push("delivery.tiers requires at least one Jenkins tier for GitOps onboarding.");
715
+ }
716
+ requireText("delivery.targetKey");
717
+ requireText("delivery.profileCode");
718
+ requireText("delivery.profileVersion");
719
+ const structuralSpec = valueAtPath(state.draft, "delivery.structuralSpec");
720
+ if (!structuralSpec || typeof structuralSpec !== "object" || Array.isArray(structuralSpec)
721
+ || Object.keys(structuralSpec).length === 0) {
722
+ errors.push("delivery.structuralSpec must be a non-empty controlled JSON object.");
723
+ }
724
+ else {
725
+ try {
726
+ validateGitopsStructuralSpec(structuralSpec);
727
+ }
728
+ catch (error) {
729
+ errors.push(error instanceof Error ? error.message : String(error));
730
+ }
731
+ }
732
+ }
733
+ if (targetStepIndex >= ONBOARDING_STEPS.indexOf("REVIEW") && schemaVersion !== 2) {
734
+ errors.push("Legacy schemaVersion=1 TUI sessions are recovery/read-only and cannot preflight or apply. "
735
+ + "Start a v2 TUI session or use `numa app onboard --spec <v2.json> --plan`.");
736
+ }
737
+ else if (targetStepIndex >= ONBOARDING_STEPS.indexOf("REVIEW")) {
738
+ const schemaDraft = cloneObject(state.draft);
739
+ const delivery = asObject(schemaDraft.delivery);
740
+ const structuralSpec = asObject(delivery.structuralSpec);
741
+ if (Object.hasOwn(structuralSpec, "secretEnvironment")) {
742
+ delete structuralSpec.secretEnvironment;
743
+ delivery.structuralSpec = structuralSpec;
744
+ schemaDraft.delivery = delivery;
745
+ }
746
+ const parsed = OnboardingSpecSchema.safeParse(schemaDraft);
747
+ if (!parsed.success) {
748
+ for (const issue of parsed.error.issues) {
749
+ errors.push(`${issue.path.join(".") || "spec"}: ${issue.message}`);
750
+ }
751
+ }
752
+ }
423
753
  return [...new Set(errors)];
424
754
  }
425
755
  function validateSchemaObject(schema, value, path) {
@@ -513,6 +843,18 @@ function asObject(value) {
513
843
  function text(value) {
514
844
  return typeof value === "string" ? value : typeof value === "number" ? String(value) : "";
515
845
  }
846
+ function optionalText(value) {
847
+ const result = text(value).trim();
848
+ return result || undefined;
849
+ }
850
+ function textArray(value) {
851
+ return Array.isArray(value)
852
+ ? value.flatMap((item) => {
853
+ const result = optionalText(item);
854
+ return result == null ? [] : [result];
855
+ })
856
+ : [];
857
+ }
516
858
  function choices(values) {
517
859
  return values.map((value) => ({ value, label: value }));
518
860
  }