@numa-tech/numa 1.13.1 → 1.13.3

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 (48) hide show
  1. package/README.md +76 -6
  2. package/dist/application-onboarding/client.d.ts +42 -3
  3. package/dist/application-onboarding/client.js +41 -2
  4. package/dist/application-onboarding/client.js.map +1 -1
  5. package/dist/application-onboarding/commands.d.ts +25 -1
  6. package/dist/application-onboarding/commands.js +461 -17
  7. package/dist/application-onboarding/commands.js.map +1 -1
  8. package/dist/application-onboarding/schemas.d.ts +881 -0
  9. package/dist/application-onboarding/schemas.js +329 -4
  10. package/dist/application-onboarding/schemas.js.map +1 -1
  11. package/dist/cli.js +6 -1
  12. package/dist/cli.js.map +1 -1
  13. package/dist/command-catalog.js +201 -6
  14. package/dist/command-catalog.js.map +1 -1
  15. package/dist/gitops/schemas.js +2 -1
  16. package/dist/gitops/schemas.js.map +1 -1
  17. package/dist/jenkins-jobs/client.d.ts +319 -0
  18. package/dist/jenkins-jobs/client.js +136 -0
  19. package/dist/jenkins-jobs/client.js.map +1 -0
  20. package/dist/jenkins-jobs/commands.d.ts +6 -0
  21. package/dist/jenkins-jobs/commands.js +269 -0
  22. package/dist/jenkins-jobs/commands.js.map +1 -0
  23. package/dist/jenkins-jobs/schemas.d.ts +359 -0
  24. package/dist/jenkins-jobs/schemas.js +119 -0
  25. package/dist/jenkins-jobs/schemas.js.map +1 -0
  26. package/dist/publications/client.d.ts +181 -0
  27. package/dist/publications/client.js +147 -0
  28. package/dist/publications/client.js.map +1 -0
  29. package/dist/publications/commands.d.ts +8 -0
  30. package/dist/publications/commands.js +225 -0
  31. package/dist/publications/commands.js.map +1 -0
  32. package/dist/publications/scanner.d.ts +23 -0
  33. package/dist/publications/scanner.js +247 -0
  34. package/dist/publications/scanner.js.map +1 -0
  35. package/dist/publications/schemas.d.ts +164 -0
  36. package/dist/publications/schemas.js +90 -0
  37. package/dist/publications/schemas.js.map +1 -0
  38. package/dist/repositories/commands.js +23 -1
  39. package/dist/repositories/commands.js.map +1 -1
  40. package/package.json +4 -4
  41. package/skills/numa-create-application/SKILL.md +158 -0
  42. package/skills/numa-create-application/agents/openai.yaml +4 -0
  43. package/skills/numa-create-application/evals/evals.json +61 -0
  44. package/skills/numa-create-application/references/checklist.md +126 -0
  45. package/skills/numa-create-application/references/inference-rules.md +69 -0
  46. package/skills/numa-create-application/references/numa-cli.md +216 -0
  47. package/skills/numa-create-application/references/promote-workflows.md +105 -0
  48. package/skills/numa-create-application/scripts/inspect-project.mjs +243 -0
@@ -4,9 +4,10 @@ import { loadAppConfig } from "../app-config.js";
4
4
  import { loadConfig } from "../config.js";
5
5
  import { registerApplicationCandidateCommands } from "../application-candidates/commands.js";
6
6
  import { SourceRepositoryClient } from "../repositories/client.js";
7
+ import { scanProjectPublication } from "../publications/scanner.js";
7
8
  import { OnboardingClient } from "./client.js";
8
9
  import { OnboardingError, sanitizeOnboardingOutput } from "./errors.js";
9
- import { OnboardingModeSchema, OnboardingEventOutputSchema, OnboardingSessionStateSchema, OnboardingSpecSchema, ScmProviderSchema } from "./schemas.js";
10
+ import { OnboardingModeSchema, OnboardingEventOutputSchema, OnboardingFirstBuildActionRequestSchema, OnboardingJenkinsBindingActionRequestSchema, OnboardingObserverScopeActionRequestSchema, OnboardingPublicationPlanActionRequestSchema, OnboardingSessionStateSchema, OnboardingSpecSchema, ScmProviderSchema } from "./schemas.js";
10
11
  import { readScmAdminConnectionInput, readScmCredentialBundle } from "./scm-credentials.js";
11
12
  import { runOnboardingTui } from "./tui.js";
12
13
  const TERMINAL_STATES = new Set(["COMPLETED", "FAILED", "CANCELLED"]);
@@ -188,7 +189,12 @@ export function scmCredentialUpdate(version, authType, credential) {
188
189
  }
189
190
  function scmConnectionLine(connection) {
190
191
  const enabled = Object.entries(connection.capabilities).filter(([, value]) => value).map(([key]) => key);
191
- return `${connection.code}\t${connection.provider}\t${connection.health}\t${connection.organizationKey}\t${enabled.join(",") || "no-capabilities"}`;
192
+ const codeupCloneReadiness = connection.provider === "CODEUP"
193
+ ? `\tcodeup-clone:${connection.codeupCloneCredentialConfigured == null
194
+ ? "unknown"
195
+ : connection.codeupCloneCredentialConfigured ? "ready" : "missing"}`
196
+ : "";
197
+ return `${connection.code}\t${connection.provider}\t${connection.health}\t${connection.organizationKey}\t${enabled.join(",") || "no-capabilities"}${codeupCloneReadiness}`;
192
198
  }
193
199
  function repositoryLine(repository) {
194
200
  return `${repository.externalId}\t${repository.fullName ?? repository.name}\t${repository.defaultBranch ?? "-"}\t${repository.headRevision ?? "-"}${repository.archived ? "\tARCHIVED" : ""}`;
@@ -211,6 +217,56 @@ function idempotencyKey(value, operation = "--apply") {
211
217
  }
212
218
  return candidate;
213
219
  }
220
+ function stableActionText(value, label, maximum = 253) {
221
+ const candidate = value?.trim() ?? "";
222
+ if (!candidate || candidate.length > maximum || /[\u0000-\u001f\u007f]/u.test(candidate)) {
223
+ throw new OnboardingError(`${label} must be 1-${maximum} printable characters.`, "onboarding_input_invalid");
224
+ }
225
+ return candidate;
226
+ }
227
+ function sha256(value, label) {
228
+ const candidate = value?.trim() ?? "";
229
+ if (!/^[0-9a-f]{64}$/u.test(candidate)) {
230
+ throw new OnboardingError(`${label} must be the exact lower-case SHA-256 locked by the server.`, "onboarding_input_invalid");
231
+ }
232
+ return candidate;
233
+ }
234
+ function collectParameter(value, previous) {
235
+ return [...previous, value];
236
+ }
237
+ function buildParameters(values) {
238
+ if (!values?.length)
239
+ return undefined;
240
+ if (values.length > 100)
241
+ throw new OnboardingError("At most 100 first-build parameters are allowed.", "onboarding_input_invalid");
242
+ const result = {};
243
+ for (const value of values) {
244
+ const separator = value.indexOf("=");
245
+ const key = separator > 0 ? value.slice(0, separator).trim() : "";
246
+ const parameterValue = separator > 0 ? value.slice(separator + 1) : "";
247
+ if (!/^[A-Za-z_][A-Za-z0-9_.-]{0,127}$/u.test(key) || parameterValue.length > 2_000
248
+ || /[\u0000-\u001f\u007f]/u.test(parameterValue)) {
249
+ throw new OnboardingError("Each --parameter must be printable KEY=VALUE with a stable key.", "onboarding_input_invalid");
250
+ }
251
+ const normalizedKey = key.replace(/[^A-Za-z0-9]/gu, "").toLowerCase();
252
+ if (["secret", "password", "passwd", "credential", "token", "privatekey", "apikey", "accesskey"].some((part) => normalizedKey.includes(part))
253
+ || /\b(?:Bearer|Basic)\s+\S+/iu.test(parameterValue)
254
+ || /\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|LTAI[A-Za-z0-9]{12,})\b/u.test(parameterValue)
255
+ || /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/u.test(parameterValue)) {
256
+ throw new OnboardingError("First-build parameters cannot contain credential-like keys or values.", "onboarding_secret_not_allowed");
257
+ }
258
+ if (Object.hasOwn(result, key))
259
+ throw new OnboardingError(`Duplicate first-build parameter: ${key}`, "onboarding_input_invalid");
260
+ result[key] = parameterValue;
261
+ }
262
+ return result;
263
+ }
264
+ function parsedActionInput(schema, value) {
265
+ const parsed = schema.safeParse(value);
266
+ if (!parsed.success)
267
+ throw new OnboardingError(`Invalid onboarding action input: ${parsed.error.message}`, "onboarding_input_invalid");
268
+ return parsed.data;
269
+ }
214
270
  export async function readOnboardingSpec(path) {
215
271
  try {
216
272
  const info = await stat(path);
@@ -239,6 +295,9 @@ export async function executeOnboardingSpec(client, spec, options = {}) {
239
295
  if (!options.apply && options.idempotencyKey) {
240
296
  throw new OnboardingError("--idempotency-key is only valid with --apply.", "onboarding_input_invalid");
241
297
  }
298
+ if (spec.schemaVersion === 3 && spec.projectStrategy === "PUBLISH_PROJECT" && !options.sessionNo) {
299
+ throw new OnboardingError("schemaVersion=3 PUBLISH_PROJECT must reuse the session created by `numa app begin`; provide --session <session-no>.", "onboarding_session_required");
300
+ }
242
301
  const key = options.apply ? idempotencyKey(options.idempotencyKey) : undefined;
243
302
  const draftSession = options.sessionNo
244
303
  ? await client.getSession(options.sessionNo)
@@ -264,9 +323,17 @@ export async function executeOnboardingSpec(client, spec, options = {}) {
264
323
  if (!planHash) {
265
324
  throw new OnboardingError("The server did not return a plan hash; apply was not submitted.", "onboarding_plan_missing");
266
325
  }
267
- const task = await client.apply(session.sessionNo, { planHash, idempotencyKey: key });
326
+ const attempt = await executeOnboardingSessionMutation(client, session.sessionNo, () => client.apply(session.sessionNo, { planHash, idempotencyKey: key }), (event) => hasOnboardingApplyRecoveryEvidence(event, planHash));
327
+ if (!attempt.result) {
328
+ return {
329
+ session: attempt.inspection.session,
330
+ preflight,
331
+ idempotencyKey: key,
332
+ recoveredFromStatus: true
333
+ };
334
+ }
268
335
  session = await client.getSession(session.sessionNo);
269
- return { session, preflight, task, idempotencyKey: key };
336
+ return { session, preflight, task: attempt.result, idempotencyKey: key, recoveredFromStatus: false };
270
337
  }
271
338
  function defaultSleep(milliseconds, signal) {
272
339
  return new Promise((resolve, reject) => {
@@ -316,7 +383,7 @@ export async function inspectOnboardingSession(client, sessionNo, afterSequence
316
383
  .filter((event) => event.sequenceNo > afterSequence)
317
384
  .map(publicOnboardingEvent)
318
385
  .sort((left, right) => left.sequenceNo - right.sequenceNo);
319
- const lastSequence = events.reduce((maximum, event) => Math.max(maximum, event.sequenceNo), afterSequence);
386
+ const lastSequence = events.reduce((maximum, event) => Math.max(maximum, event.sequenceNo), Math.max(afterSequence, eventPage.nextSequence ?? afterSequence));
320
387
  return {
321
388
  session,
322
389
  events,
@@ -324,6 +391,192 @@ export async function inspectOnboardingSession(client, sessionNo, afterSequence
324
391
  execution: { ...onboardingExecutionView(session, events), lastSequence }
325
392
  };
326
393
  }
394
+ function mutationResultMayBeUnknown(error) {
395
+ if (error instanceof OnboardingError) {
396
+ return error.retryable
397
+ || (error.status != null && error.status >= 500)
398
+ || (error.code === "onboarding_invalid_response" && error.status != null
399
+ && error.status >= 200 && error.status < 300);
400
+ }
401
+ return error instanceof TypeError;
402
+ }
403
+ export function hasOnboardingApplyRecoveryEvidence(event, planHash) {
404
+ return event.output?.taskNo != null && event.output.planHash === planHash;
405
+ }
406
+ export function hasJenkinsBindingRecoveryEvidence(_event, _bindingId) {
407
+ // jenkinsBindingId alone does not prove CONFIRMED status or the requested
408
+ // optimistic-lock version; unknown confirmations therefore fail closed.
409
+ return false;
410
+ }
411
+ export function hasObserverScopeRecoveryEvidence(_event) {
412
+ // The reviewed event projection currently lacks serviceBindingId and exact
413
+ // observer coordinates, so no event can prove this request's full intent.
414
+ return false;
415
+ }
416
+ export function hasFirstBuildRecoveryEvidence(_event, _input) {
417
+ // Aggregated task output can repeat an older decision on an unrelated new
418
+ // event and does not carry the action idempotency key or full build intent.
419
+ return false;
420
+ }
421
+ export async function executeOnboardingSessionMutation(client, sessionNo, mutate, hasNewEvidence) {
422
+ // A pre-write cursor is required: without it, a matching field or broad
423
+ // session state that predates this request could be misreported as recovery.
424
+ const baseline = await inspectOnboardingSession(client, sessionNo);
425
+ try {
426
+ return { result: await mutate(), recoveredFromStatus: false };
427
+ }
428
+ catch (error) {
429
+ if (!mutationResultMayBeUnknown(error))
430
+ throw error;
431
+ let inspection;
432
+ try {
433
+ inspection = await inspectOnboardingSession(client, sessionNo, baseline.lastSequence);
434
+ }
435
+ catch {
436
+ // Preserve the mutation error below; a failed read must never trigger a write replay.
437
+ }
438
+ if (inspection && inspection.events.some((event) => event.sequenceNo > baseline.lastSequence && hasNewEvidence(event, baseline, inspection))) {
439
+ return { inspection, recoveredFromStatus: true };
440
+ }
441
+ const original = error instanceof OnboardingError ? error : undefined;
442
+ throw new OnboardingError("The mutation result is unknown. Status recovery was attempted; inspect the same session before reusing the original idempotency key.", "onboarding_result_unknown", original?.status, original?.requestId, true, {
443
+ sessionNo,
444
+ recoveryCommand: `numa app inspect ${sessionNo} --json`,
445
+ originalCode: original?.code ?? "network_error"
446
+ }, original?.retryAfterSeconds);
447
+ }
448
+ }
449
+ function assertPublicationSessionBinding(result, sessionNo, resolved) {
450
+ if (result.session.sessionNo !== sessionNo
451
+ || result.publicationPlan.consumerType !== "APPLICATION_ONBOARDING"
452
+ || result.publicationPlan.consumerRef !== sessionNo
453
+ || (resolved && !result.replayed)) {
454
+ throw new OnboardingError("The publication response is not a replay bound to the same APPLICATION_ONBOARDING session.", "onboarding_invalid_response", 200);
455
+ }
456
+ }
457
+ function publicationResolutionPending(error) {
458
+ return error instanceof OnboardingError
459
+ && error.status === 404
460
+ && error.code === "ONBOARDING_PUBLICATION_PLAN_NOT_RESOLVED"
461
+ && error.retryable;
462
+ }
463
+ export async function executePublicationAttachmentWithRecovery(client, sessionNo, clientRequestId, mutate, options = {}) {
464
+ try {
465
+ const result = await mutate();
466
+ assertPublicationSessionBinding(result, sessionNo, false);
467
+ return { result, recoveredFromStatus: false };
468
+ }
469
+ catch (error) {
470
+ if (!mutationResultMayBeUnknown(error))
471
+ throw error;
472
+ const delaysMs = options.delaysMs ?? [0, 200, 500, 1_000];
473
+ const sleep = options.sleep ?? ((milliseconds) => defaultSleep(milliseconds));
474
+ let resolutionCode;
475
+ for (const delayMs of delaysMs) {
476
+ if (delayMs > 0)
477
+ await sleep(delayMs);
478
+ try {
479
+ const result = await client.resolvePublicationPlan(sessionNo, clientRequestId);
480
+ assertPublicationSessionBinding(result, sessionNo, true);
481
+ return { result, recoveredFromStatus: true };
482
+ }
483
+ catch (resolutionError) {
484
+ resolutionCode = resolutionError instanceof OnboardingError ? resolutionError.code : "network_error";
485
+ if (publicationResolutionPending(resolutionError) || mutationResultMayBeUnknown(resolutionError))
486
+ continue;
487
+ break;
488
+ }
489
+ }
490
+ const original = error instanceof OnboardingError ? error : undefined;
491
+ throw new OnboardingError("The publication attachment result is unknown. Continue read-only resolution with the original session and client-request-id; do not replay the write based on this error.", "onboarding_publication_result_unknown", original?.status, original?.requestId, true, {
492
+ sessionNo,
493
+ clientRequestId,
494
+ recoveryCommand: `numa app publication resolve ${sessionNo} --client-request-id ${clientRequestId} --json`,
495
+ originalCode: original?.code ?? "network_error",
496
+ resolutionCode
497
+ }, original?.retryAfterSeconds);
498
+ }
499
+ }
500
+ function sessionSummary(session) {
501
+ return {
502
+ sessionNo: session.sessionNo,
503
+ mode: session.mode,
504
+ state: session.state,
505
+ currentStep: session.currentStep,
506
+ revision: session.revision,
507
+ planHash: session.planHash
508
+ };
509
+ }
510
+ function recoveredMutationPayload(inspection, idempotencyKeyValue) {
511
+ return {
512
+ ok: true,
513
+ session: sessionSummary(inspection.session),
514
+ execution: inspection.execution,
515
+ recoveredFromStatus: true,
516
+ mutationResponseReceived: false,
517
+ idempotencyKey: idempotencyKeyValue,
518
+ recovery: { inspectCommand: `numa app inspect ${inspection.session.sessionNo} --json` }
519
+ };
520
+ }
521
+ export function onboardingPublicationAttachmentView(result, source, idempotencyKeyValue, recoveredFromStatus = false) {
522
+ const plan = result.publicationPlan;
523
+ return {
524
+ ok: true,
525
+ session: sessionSummary(result.session),
526
+ publicationPlan: {
527
+ planNo: plan.planNo,
528
+ state: plan.state,
529
+ planHash: plan.planHash,
530
+ repositoryKey: plan.repositoryKey,
531
+ consumerType: plan.consumerType,
532
+ consumerRef: plan.consumerRef,
533
+ publicationMode: plan.publicationMode,
534
+ branch: plan.branch,
535
+ expectedHead: plan.expectedHead,
536
+ fileCount: plan.manifest.length,
537
+ warningCount: plan.warnings.length,
538
+ blockerCount: plan.blockers.length,
539
+ expiresAt: plan.expiresAt
540
+ },
541
+ scan: {
542
+ schemaVersion: source.schemaVersion,
543
+ sourceRevision: source.sourceRevision,
544
+ sourceTreeDigest: source.sourceTreeDigest,
545
+ clean: source.clean,
546
+ fileCount: source.fileCount,
547
+ totalBytes: source.totalBytes,
548
+ executableFileCount: source.executableFileCount
549
+ },
550
+ replayed: result.replayed,
551
+ recoveredFromStatus,
552
+ idempotencyKey: idempotencyKeyValue
553
+ };
554
+ }
555
+ export function onboardingPublicationResolutionView(result, clientRequestId) {
556
+ const plan = result.publicationPlan;
557
+ return {
558
+ ok: true,
559
+ session: sessionSummary(result.session),
560
+ publicationPlan: {
561
+ planNo: plan.planNo,
562
+ state: plan.state,
563
+ planHash: plan.planHash,
564
+ repositoryKey: plan.repositoryKey,
565
+ consumerType: plan.consumerType,
566
+ consumerRef: plan.consumerRef,
567
+ publicationMode: plan.publicationMode,
568
+ branch: plan.branch,
569
+ expectedHead: plan.expectedHead,
570
+ fileCount: plan.manifest.length,
571
+ warningCount: plan.warnings.length,
572
+ blockerCount: plan.blockers.length,
573
+ expiresAt: plan.expiresAt
574
+ },
575
+ replayed: result.replayed,
576
+ recoveredFromStatus: true,
577
+ clientRequestId
578
+ };
579
+ }
327
580
  export async function executeOnboardingAction(client, sessionNo, action) {
328
581
  await client[action](sessionNo);
329
582
  return inspectOnboardingSession(client, sessionNo);
@@ -353,6 +606,7 @@ export function registerApplicationOnboardingCommands(program, dependencies = {}
353
606
  const client = () => dependencies.clientFactory?.() ?? new OnboardingClient(loadConfig("platform"));
354
607
  const isInteractive = dependencies.interactive ?? (() => Boolean(process.stdin.isTTY && process.stdout.isTTY));
355
608
  const tui = dependencies.runTui ?? runOnboardingTui;
609
+ const scanPublication = dependencies.scanPublication ?? scanProjectPublication;
356
610
  const sleep = dependencies.sleep ?? defaultSleep;
357
611
  const app = program.command("app")
358
612
  .description("创建新应用或把存量代码库接入 DevOps 平台")
@@ -363,6 +617,15 @@ export function registerApplicationOnboardingCommands(program, dependencies = {}
363
617
  结果不明时用相同 idempotency-key 重试或 inspect/resume,禁止新建重复会话。
364
618
  `);
365
619
  registerApplicationCandidateCommands(app);
620
+ addExamples(app.command("capabilities")
621
+ .description("读取服务端 Application Onboarding 能力自述;命令存在不代表端到端能力可用")
622
+ .action(async (_options, command) => {
623
+ const capabilities = await client().getCapabilities();
624
+ await output(command, { ok: true, capabilities }, JSON.stringify(stableOutput(capabilities), null, 2));
625
+ }), ["numa app capabilities --json"], [
626
+ "与 `numa commands --json`、Repository capability 及各 child control plane 的只读能力共同形成运行时能力矩阵。",
627
+ "HTTP 501 表示服务端能力未发布;不得因为本地命令存在就声称可执行。"
628
+ ]);
366
629
  addExamples(app.command("options <type>")
367
630
  .description("按名称查询应用所需的团队、系统、业务域或环境 ID")
368
631
  .option("--search <text>", "按 code 或名称搜索")
@@ -388,7 +651,8 @@ export function registerApplicationOnboardingCommands(program, dependencies = {}
388
651
  live probe 与连接变更由服务端校验平台管理员权限和乐观锁 version。
389
652
  provider credential 只能来自权限受限文件或重定向 stdin;禁止通过 argv、环境变量或普通 JSON 输出传递。
390
653
  Codeup 云 AK/Role 只能引用已有 aliyunAccountId/aliyunAssumeRoleId,Numa 不读取或保存云 AK。
391
- 未提供 Codeup accessToken 时使用服务端 Aliyun identity,不提交空 credential bundle
654
+ Codeup accessToken 只用于 OpenAPI;含 100755 文件的 Publication 另需 HTTPS cloneUsername/clonePassword
655
+ 三者只从 chmod 600 文件或重定向 stdin 写入;未提供时使用服务端 Aliyun identity,不提交空 credential bundle。
392
656
  `);
393
657
  addExamples(scm.command("list")
394
658
  .description("列出当前用户可见的安全 SCM 连接摘要")
@@ -541,7 +805,7 @@ export function registerApplicationOnboardingCommands(program, dependencies = {}
541
805
  if (credential) {
542
806
  connection = await onboarding.rotateAdminScmCredential(connection.code, scmCredentialUpdate(connection.version, connection.authType, credential));
543
807
  }
544
- await output(command, { ok: true, connection }, `${connection.code}\t${connection.status}\tversion:${connection.version}\tcredential:${connection.credentialConfigured ? "configured" : "missing"}`);
808
+ await output(command, { ok: true, connection }, `${connection.code}\t${connection.status}\tversion:${connection.version}\tcredential:${connection.credentialConfigured ? "configured" : "missing"}\tcodeup-at:${connection.codeupAccessTokenConfigured ? "ready" : "missing"}\tcodeup-clone:${connection.codeupCloneCredentialConfigured ? "ready" : "missing"}`);
545
809
  }), [
546
810
  "numa app scm create --input github.json --credential-file github-app.private.json --json",
547
811
  "numa app scm create --input codeup.json --json"
@@ -565,7 +829,7 @@ export function registerApplicationOnboardingCommands(program, dependencies = {}
565
829
  await output(command, { ok: true, connection }, `${connection.code}\t${connection.status}\tversion:${connection.version}`);
566
830
  }), ["numa app scm update github-main --input github.json --revision 4 --yes --json"]);
567
831
  addExamples(scm.command("rotate <code>")
568
- .description("平台管理员写入新 GitHub Codeup access token;不会回显 credential")
832
+ .description("平台管理员轮换 GitHub 凭据或 Codeup OpenAPI/HTTPS clone 凭据;不会回显 credential")
569
833
  .requiredOption("--revision <number>", "当前乐观锁 version")
570
834
  .option("--credential-file <file>", "仅 owner 可读(chmod 600)的 provider credential JSON")
571
835
  .option("--credential-stdin", "从重定向 stdin 读取 provider credential JSON")
@@ -587,7 +851,7 @@ export function registerApplicationOnboardingCommands(program, dependencies = {}
587
851
  await output(command, { ok: true, connection }, `${connection.code}\tcredential:configured\tversion:${connection.version}`);
588
852
  }), [
589
853
  "numa app scm rotate github-main --revision 4 --credential-file github-app.private.json --yes --json",
590
- "numa app scm rotate codeup-main --revision 2 --credential-stdin --yes --json < codeup-token.private.json"
854
+ "numa app scm rotate codeup-main --revision 2 --credential-stdin --yes --json < codeup.private.json"
591
855
  ]);
592
856
  addExamples(scm.command("disable <code>")
593
857
  .description("平台管理员软禁用连接;服务端会阻止仍被非终态会话使用的连接")
@@ -603,11 +867,27 @@ export function registerApplicationOnboardingCommands(program, dependencies = {}
603
867
  await onboarding.disableAdminScmConnection(code, version);
604
868
  await output(command, { ok: true, code, disabled: true, previousVersion: version }, `${code}\tDISABLED`);
605
869
  }), ["numa app scm disable github-main --revision 5 --yes --json"]);
870
+ addExamples(app.command("begin")
871
+ .description("幂等创建空 onboarding session,为 consumer-bound Repository plan 获取 sessionNo")
872
+ .requiredOption("--mode <mode>", "new|existing")
873
+ .requiredOption("--idempotency-key <key>", "同一 actor/mode 结果未知时复用")
874
+ .option("--yes", "确认创建服务端草稿会话")
875
+ .action(async (options, command) => {
876
+ requireConfirmation(options.yes, "Beginning an onboarding session");
877
+ const key = idempotencyKey(options.idempotencyKey, "onboarding begin");
878
+ const session = await client().createSession(parseOnboardingMode(options.mode), key);
879
+ await output(command, { ok: true, session, idempotencyKey: key }, sessionLine(session));
880
+ }), [
881
+ "numa app begin --mode new --idempotency-key begin-code-index-onboarding --yes --json"
882
+ ], [
883
+ "先取得 sessionNo,再创建 consumer=APPLICATION_ONBOARDING、consumerRef=sessionNo 的 Repository plan。",
884
+ "相同 actor/key/mode 安全返回原 session;不同 mode 冲突,不会创建第二个会话。"
885
+ ]);
606
886
  addExamples(app.command("onboard")
607
887
  .description("启动入驻向导,或按 JSON spec 创建、预检和执行")
608
888
  .option("--mode <mode>", "new|existing;如有 --spec 必须与文件一致")
609
889
  .option("--session <session-no>", "更新已有草稿并跨 Web/CLI 恢复;不创建新会话")
610
- .option("--spec <file>", "schemaVersion=1(兼容)或 2(Repository Control Plane)的 onboarding JSON 文件")
890
+ .option("--spec <file>", "schemaVersion=1(兼容)、2(Repository CP)或 3(Promote 全链路)的 onboarding JSON 文件")
611
891
  .option("--plan", "保存草稿并执行服务端预检,不产生外部副作用")
612
892
  .option("--apply", "保存草稿、预检并提交服务端异步执行")
613
893
  .option("--idempotency-key <key>", "apply 幂等键;结果不明时必须复用")
@@ -632,11 +912,13 @@ export function registerApplicationOnboardingCommands(program, dependencies = {}
632
912
  idempotencyKey: options.idempotencyKey
633
913
  });
634
914
  const execution = onboardingExecutionView(result.session);
635
- const text = result.task
636
- ? `入驻执行已提交:\n${executionText(result.session)}\nidempotency-key: ${result.idempotencyKey}`
637
- : result.preflight
638
- ? `预检完成: ${sessionLine(result.session)}\nplan: ${result.preflight.planHash ?? "-"}\nblocking: ${result.preflight.blockingErrors.length}`
639
- : `草稿已保存: ${sessionLine(result.session)}`;
915
+ const text = result.recoveredFromStatus
916
+ ? `写响应未知,已从同一会话状态恢复:\n${executionText(result.session)}\nidempotency-key: ${result.idempotencyKey}`
917
+ : result.task
918
+ ? `入驻执行已提交:\n${executionText(result.session)}\nidempotency-key: ${result.idempotencyKey}`
919
+ : result.preflight
920
+ ? `预检完成: ${sessionLine(result.session)}\nplan: ${result.preflight.planHash ?? "-"}\nblocking: ${result.preflight.blockingErrors.length}`
921
+ : `草稿已保存: ${sessionLine(result.session)}`;
640
922
  await output(command, { ok: true, ...result, execution }, text);
641
923
  }), [
642
924
  "numa app onboard",
@@ -648,6 +930,163 @@ export function registerApplicationOnboardingCommands(program, dependencies = {}
648
930
  "未指定 --plan/--apply 时只保存服务端草稿。",
649
931
  "完整 spec 保存的 currentStep 为稳定字符串 REVIEW。"
650
932
  ]);
933
+ const publicationAction = app.command("publication")
934
+ .description("为 schemaVersion=3 会话附加受控本地源码 Publication 计划");
935
+ addExamples(publicationAction.command("resolve <session-no>")
936
+ .description("按原 clientRequestId 只读恢复 publication attach 的确定结果")
937
+ .requiredOption("--client-request-id <id>", "原 attach 使用的稳定 child request ID")
938
+ .action(async (sessionNo, options, command) => {
939
+ const clientRequestId = stableActionText(options.clientRequestId, "client-request-id", 160);
940
+ const result = await client().resolvePublicationPlan(sessionNo, clientRequestId);
941
+ assertPublicationSessionBinding(result, sessionNo, true);
942
+ await output(command, onboardingPublicationResolutionView(result, clientRequestId), `${result.publicationPlan.planNo}\t${result.publicationPlan.state}\t${result.publicationPlan.planHash}\trecovered-from-status`);
943
+ }), [
944
+ "numa app publication resolve <session-no> --client-request-id onboarding-code-index-publication --json"
945
+ ], [
946
+ "固定 GET resolve,不上传 bundle、不重放 attach;404 ONBOARDING_PUBLICATION_PLAN_NOT_RESOLVED 可安全稍后重查。",
947
+ "compact 输出不含 manifest path、文件内容、完整 session draft/plan 或 SCM credential。"
948
+ ]);
949
+ addExamples(publicationAction.command("attach <session-no>")
950
+ .description("扫描 clean Git HEAD 并创建绑定当前 onboarding planHash 的 child plan;不写远端 Git")
951
+ .requiredOption("--source <directory>", "本地 Git worktree 根目录")
952
+ .requiredOption("--commit-message <message>", "受审提交消息,1-500字符")
953
+ .requiredOption("--client-request-id <id>", "稳定 child request ID")
954
+ .requiredOption("--idempotency-key <key>", "结果未知时必须复用;须与 client-request-id 相同")
955
+ .option("--yes", "确认上传受限源码 bundle 并附加 child plan")
956
+ .action(async (sessionNo, options, command) => {
957
+ requireConfirmation(options.yes, "Attaching a project publication plan");
958
+ const key = idempotencyKey(options.idempotencyKey, "publication attach");
959
+ const clientRequestId = stableActionText(options.clientRequestId, "client-request-id", 160);
960
+ if (key !== clientRequestId) {
961
+ throw new OnboardingError("idempotency-key must exactly match client-request-id for publication attachment.", "onboarding_input_invalid");
962
+ }
963
+ const onboarding = client();
964
+ const session = await onboarding.getSession(sessionNo);
965
+ if (session.state !== "WAITING_EXTERNAL" || session.currentStep !== "ATTACH_PUBLICATION_PLAN") {
966
+ throw new OnboardingError("Publication attachment requires the same parent session to be WAITING_EXTERNAL at ATTACH_PUBLICATION_PLAN.", "onboarding_publication_session_invalid", undefined, undefined, false, { sessionNo: session.sessionNo, state: session.state, currentStep: session.currentStep ?? null });
967
+ }
968
+ const source = await scanPublication(options.source);
969
+ const input = parsedActionInput(OnboardingPublicationPlanActionRequestSchema, {
970
+ parentPlanHash: sha256(session.planHash, "session planHash"),
971
+ clientRequestId,
972
+ sourceRevision: source.sourceRevision,
973
+ sourceTreeDigest: source.sourceTreeDigest,
974
+ clean: true,
975
+ commitMessage: options.commitMessage,
976
+ files: source.requestFiles
977
+ });
978
+ const attempt = await executePublicationAttachmentWithRecovery(onboarding, session.sessionNo, clientRequestId, () => onboarding.attachPublicationPlan(session.sessionNo, input, key));
979
+ const result = attempt.result;
980
+ await output(command, onboardingPublicationAttachmentView(result, source, key, attempt.recoveredFromStatus), `${result.publicationPlan.planNo}\t${result.publicationPlan.state}\t${result.publicationPlan.planHash}\t${attempt.recoveredFromStatus ? "recovered-from-status" : `replayed:${result.replayed}`}`);
981
+ }), [
982
+ "numa app publication attach <session-no> --source ./code-index --commit-message 'feat: publish code-index source' --client-request-id onboarding-code-index-publication --idempotency-key onboarding-code-index-publication --yes --json"
983
+ ], [
984
+ "只读取 clean Git HEAD;compact 输出不含 sourceRoot、manifest path、contentBase64、完整 session draft/plan 或 SCM credential。",
985
+ "POST 500/504、连接中断或成功响应无法解析时,CLI 以原 sessionNo/clientRequestId 轮询 GET resolve;绝不自动重放 attach。",
986
+ "该动作只创建 child plan;onboarding worker 仍会等待显式执行和验证。"
987
+ ]);
988
+ const jenkinsBindingAction = app.command("jenkins-binding")
989
+ .description("确认 Job Control Plane 产生的候选 Jenkins binding");
990
+ addExamples(jenkinsBindingAction.command("confirm <session-no>")
991
+ .requiredOption("--binding-id <id>", "会话事件返回的候选 binding ID")
992
+ .requiredOption("--expected-version <number>", "候选 binding 当前乐观锁版本")
993
+ .requiredOption("--idempotency-key <key>", "稳定 key;写响应未知时仅用于取证,不自动重发")
994
+ .option("--yes", "确认该 Jenkins job 与应用/tier 的绑定关系")
995
+ .action(async (sessionNo, options, command) => {
996
+ requireConfirmation(options.yes, "Confirming a Jenkins binding");
997
+ const key = idempotencyKey(options.idempotencyKey, "jenkins binding confirmation");
998
+ const input = parsedActionInput(OnboardingJenkinsBindingActionRequestSchema, {
999
+ bindingId: positiveInteger(options.bindingId, "binding-id"),
1000
+ expectedVersion: nonNegativeInteger(options.expectedVersion, "expected-version", 0)
1001
+ });
1002
+ const onboarding = client();
1003
+ const attempt = await executeOnboardingSessionMutation(onboarding, sessionNo, () => onboarding.confirmJenkinsBinding(sessionNo, input, key), (event) => hasJenkinsBindingRecoveryEvidence(event, input.bindingId));
1004
+ if (!attempt.result) {
1005
+ await output(command, recoveredMutationPayload(attempt.inspection, key), `${input.bindingId}\trecovered-from-status\t${attempt.inspection.session.state}`);
1006
+ return;
1007
+ }
1008
+ const result = attempt.result;
1009
+ await output(command, { ok: true, ...result, idempotencyKey: key }, `${result.binding.id}\t${result.binding.confirmationStatus}\tv${result.binding.version}\treplayed:${result.replayed}`);
1010
+ }), [
1011
+ "numa app jenkins-binding confirm <session-no> --binding-id 42 --expected-version 0 --idempotency-key confirm-code-index-binding --yes --json"
1012
+ ], ["确认不会触发 scan 或 build;Job Control Plane 已独立验证配置和 inventory generation。"]);
1013
+ const observerScopeAction = app.command("observer-scope")
1014
+ .description("把服务绑定关联到 observer 可验证的精确 Flux/Kubernetes 坐标");
1015
+ addExamples(observerScopeAction.command("configure <session-no>")
1016
+ .requiredOption("--service-binding-id <id>", "会话锁定的 GitOps service binding ID")
1017
+ .requiredOption("--kustomization-namespace <name>", "Flux Kustomization namespace")
1018
+ .requiredOption("--kustomization-name <name>", "Flux Kustomization name")
1019
+ .requiredOption("--helm-release-namespace <name>", "HelmRelease namespace")
1020
+ .requiredOption("--helm-release-name <name>", "HelmRelease name")
1021
+ .requiredOption("--workload-namespace <name>", "最终 workload namespace")
1022
+ .requiredOption("--workload-kind <kind>", "最终 workload kind")
1023
+ .requiredOption("--workload-name <name>", "最终 workload name")
1024
+ .requiredOption("--expected-version <number>", "scope 当前版本;首次创建使用服务端事件给出的版本")
1025
+ .requiredOption("--idempotency-key <key>", "稳定 key;写响应未知时仅用于取证,不自动重发")
1026
+ .option("--yes", "确认精确运行态坐标")
1027
+ .action(async (sessionNo, options, command) => {
1028
+ requireConfirmation(options.yes, "Configuring an observer scope");
1029
+ const key = idempotencyKey(options.idempotencyKey, "observer scope configuration");
1030
+ const input = parsedActionInput(OnboardingObserverScopeActionRequestSchema, {
1031
+ serviceBindingId: positiveInteger(options.serviceBindingId, "service-binding-id"),
1032
+ kustomizationNamespace: stableActionText(options.kustomizationNamespace, "kustomization-namespace"),
1033
+ kustomizationName: stableActionText(options.kustomizationName, "kustomization-name"),
1034
+ helmReleaseNamespace: stableActionText(options.helmReleaseNamespace, "helm-release-namespace"),
1035
+ helmReleaseName: stableActionText(options.helmReleaseName, "helm-release-name"),
1036
+ workloadNamespace: stableActionText(options.workloadNamespace, "workload-namespace"),
1037
+ workloadKind: stableActionText(options.workloadKind, "workload-kind", 160),
1038
+ workloadName: stableActionText(options.workloadName, "workload-name"),
1039
+ expectedVersion: nonNegativeInteger(options.expectedVersion, "expected-version", 0)
1040
+ });
1041
+ const onboarding = client();
1042
+ const attempt = await executeOnboardingSessionMutation(onboarding, sessionNo, () => onboarding.configureObserverScope(sessionNo, input, key), hasObserverScopeRecoveryEvidence);
1043
+ if (!attempt.result) {
1044
+ await output(command, recoveredMutationPayload(attempt.inspection, key), `${String(attempt.inspection.execution.eventOutput.observerScopeNo)}\trecovered-from-status\t${attempt.inspection.session.state}`);
1045
+ return;
1046
+ }
1047
+ const result = attempt.result;
1048
+ await output(command, { ok: true, ...result, idempotencyKey: key }, `${result.scope.scopeNo}\t${result.scope.status}\t${result.scope.evidenceFreshness}\t${result.scope.lastDriftState ?? "-"}`);
1049
+ }), [
1050
+ "numa app observer-scope configure <session-no> --service-binding-id 17 --kustomization-namespace flux-system --kustomization-name prd --helm-release-namespace prd --helm-release-name code-index --workload-namespace prd --workload-kind Deployment --workload-name code-index --expected-version 0 --idempotency-key scope-code-index-prd --yes --json"
1051
+ ], ["服务端强制 serviceBindingId 属于该会话;CLI 不允许任意 cluster/path/YAML。"]);
1052
+ const firstBuildAction = app.command("first-build")
1053
+ .description("在全部结构与运行证据就绪后,独立决定首次 Jenkins build");
1054
+ addExamples(firstBuildAction.command("decide <session-no>")
1055
+ .requiredOption("--decision <decision>", "trigger|defer")
1056
+ .option("--branch <branch>", "TRIGGER 的分支;缺省由服务端锁定配置决定")
1057
+ .option("--parameter <KEY=VALUE>", "非敏感构建参数,可重复", collectParameter, [])
1058
+ .option("--approve-id <id>", "生产 TRIGGER 的审批记录 ID")
1059
+ .option("--production-reason <reason>", "生产 TRIGGER 的受审原因(至少10字符)")
1060
+ .requiredOption("--idempotency-key <key>", "稳定 key;写响应未知时仅用于取证,不自动重发")
1061
+ .option("--yes", "确认记录决定;TRIGGER 会真正提交 Jenkins build")
1062
+ .action(async (sessionNo, options, command) => {
1063
+ requireConfirmation(options.yes, "Recording the first-build decision");
1064
+ const decision = options.decision.trim().toUpperCase();
1065
+ const input = parsedActionInput(OnboardingFirstBuildActionRequestSchema, {
1066
+ decision,
1067
+ branch: options.branch?.trim() || undefined,
1068
+ parameters: buildParameters(options.parameter),
1069
+ approveId: options.approveId?.trim() || undefined,
1070
+ productionReason: options.productionReason?.trim() || undefined
1071
+ });
1072
+ const key = idempotencyKey(options.idempotencyKey, "first-build decision");
1073
+ const onboarding = client();
1074
+ const attempt = await executeOnboardingSessionMutation(onboarding, sessionNo, () => onboarding.decideFirstBuild(sessionNo, input, key), (event) => hasFirstBuildRecoveryEvidence(event, input));
1075
+ if (!attempt.result) {
1076
+ await output(command, recoveredMutationPayload(attempt.inspection, key), `${decision}\trecovered-from-status\t${attempt.inspection.session.state}`);
1077
+ return;
1078
+ }
1079
+ const result = attempt.result;
1080
+ await output(command, { ok: true, ...result, idempotencyKey: key }, result.build
1081
+ ? `${result.decision}\t${result.build.requestId}\t${result.build.state}\treplayed:${result.replayed}`
1082
+ : `${result.decision}\tno-build\treplayed:${result.replayed}`);
1083
+ }), [
1084
+ "numa app first-build decide <session-no> --decision defer --idempotency-key defer-code-index-first-build --yes --json",
1085
+ "numa app first-build decide <session-no> --decision trigger --branch main --idempotency-key trigger-code-index-first-build --yes --json"
1086
+ ], [
1087
+ "DEFER 永不触发 build,且会话保持 WAITING_EXTERNAL。",
1088
+ "生产 TRIGGER 仍由服务端强制 ops-admin 与受审生产依据;参数禁止凭据式 key/value。"
1089
+ ]);
651
1090
  addExamples(app.command("resume <session-no>")
652
1091
  .description("让服务端重新检查外部等待条件,不在客户端推进状态")
653
1092
  .action(async (sessionNo, _options, command) => {
@@ -664,11 +1103,16 @@ export function registerApplicationOnboardingCommands(program, dependencies = {}
664
1103
  throw new OnboardingError("Session has no server plan hash; run onboarding preflight first.", "onboarding_plan_missing");
665
1104
  }
666
1105
  const key = idempotencyKey(options.idempotencyKey);
667
- const task = await onboarding.apply(sessionNo, { planHash: before.planHash, idempotencyKey: key });
1106
+ const attempt = await executeOnboardingSessionMutation(onboarding, sessionNo, () => onboarding.apply(sessionNo, { planHash: before.planHash, idempotencyKey: key }), (event) => hasOnboardingApplyRecoveryEvidence(event, before.planHash));
1107
+ if (!attempt.result) {
1108
+ await output(command, recoveredMutationPayload(attempt.inspection, key), `写响应未知,已从同一会话状态恢复:\n${executionText(attempt.inspection.session, attempt.inspection.events)}\nidempotency-key: ${key}`);
1109
+ return;
1110
+ }
1111
+ const task = attempt.result;
668
1112
  const session = await onboarding.getSession(sessionNo);
669
1113
  await output(command, { ok: true, session, task, idempotencyKey: key, execution: onboardingExecutionView(session) }, `入驻执行已提交:\n${executionText(session)}\nidempotency-key: ${key}`);
670
1114
  }), ["numa app apply 8d15ee89-43f0-4378-a7aa-51f09c5b42fc --idempotency-key app-order-20260819 --json"], [
671
- "用于先 --plan apply 的安全自动化,也用于响应不明时以同一 key 重放。"
1115
+ "用于先 --plan 后的首次显式 apply;响应不明时只读 inspect/status,证据不足返回 onboarding_result_unknown,不要重发 apply。"
672
1116
  ]);
673
1117
  addExamples(app.command("inspect <session-no>")
674
1118
  .description("查询会话;watch 从最后 event sequence 持续恢复")