@tutti-os/nexight-devops 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,6 +48,10 @@ nexight-devops release hotfix
48
48
  nexight-devops release approve
49
49
  nexight-devops release rollback
50
50
  nexight-devops release rollback tutti-server
51
+ nexight-devops release rollback platform-web
52
+ nexight-devops release rollback platform-web --previous --yes --reason "entry failure"
53
+ nexight-devops release rollback platform-web --version-id <uuid> --yes
54
+ nexight-devops release rollback platform-web --probe
51
55
  ```
52
56
 
53
57
  ### Function overview
@@ -66,7 +70,8 @@ nexight-devops release rollback tutti-server
66
70
  - `ppe-lane destroy` checks existing lane instances and triggers destroy workflows after a lane-name confirmation. `tutti-server` must be selected explicitly for destruction.
67
71
  - `ppe-lane status` queries AWS Cloud Map for one lane, or all lanes with `--all`, including `tutti-server`.
68
72
  - `release prepare`, `release hotfix`, and `release approve` run the controlled release lifecycle through GitHub Actions and approval issues.
69
- - `release rollback` lets you choose `tutti-server` interactively; `release rollback tutti-server` selects it directly. The command reads the current production image and up to ten recent Helm deployments directly when local read access to the production cluster is available. Otherwise it retrieves the verified list through GitHub Actions. The selected image is always revalidated and restored by GitHub Actions, followed by health checks. Use `--revision <number> --yes` with the explicit repository for scripted selection and confirmation.
73
+ - `release rollback` lets you choose `tutti-server` or `platform-web` interactively. For `tutti-server`, the command reads the current production image and up to ten recent Helm deployments directly when local cluster read access is available, otherwise through GitHub Actions. The selected image is revalidated and restored by GitHub Actions, followed by health checks. Use `--revision <number> --yes` for scripted selection.
74
+ - `release rollback platform-web` restores a retained, previously deployed Cloudflare Worker version through Tutti's lightweight `platform-web-rollback.yml`. It does not build, test, install packages, upload assets, or roll back ingress/storage. The interactive flow binds your confirmation to the displayed deployment ID; if production changes, execution refuses the stale selection. `--previous --yes` starts only one workflow and selects the latest eligible previous version from live history under the writer lock, excluding versions withdrawn by earlier recorded rollbacks. `--version-id <uuid> --yes` explicitly selects a previously deployed version, including a withdrawn version. `--yes` skips confirmation only. `--reason` records up to 200 UTF-8 bytes; missing historical SHA/run mappings remain unknown. Results distinguish deployment switching, public entry/assets, version/content identity, and unverified Room behavior, and include queue/setup, switch, verification, and total timings. `--probe` measures the same read-only production checks without changing traffic. The CLI needs GitHub access; Cloudflare credentials remain in the production environment's GitHub Secrets.
70
75
 
71
76
  ## Controlled Release
72
77
 
package/dist/cli.js CHANGED
@@ -6,17 +6,6 @@ import { resolve as resolve4 } from "path";
6
6
  import { fileURLToPath, pathToFileURL as pathToFileURL2 } from "url";
7
7
  import { Command } from "commander";
8
8
 
9
- // src/constants/admin.ts
10
- var DEFAULT_ADMIN_API_ORIGIN = "https://tutti.sh";
11
-
12
- // src/lib/admin-computer.ts
13
- import { randomUUID } from "crypto";
14
- import { createWriteStream } from "fs";
15
- import { link, mkdir, rename, stat, unlink } from "fs/promises";
16
- import { basename, dirname, join, resolve } from "path";
17
- import { Readable } from "stream";
18
- import { pipeline } from "stream/promises";
19
-
20
9
  // src/lib/errors.ts
21
10
  var CliError = class extends Error {
22
11
  exitCode;
@@ -45,7 +34,36 @@ var toExitCode = (error) => {
45
34
  return 1;
46
35
  };
47
36
 
37
+ // src/lib/ppe-lane.ts
38
+ var PPE_LANE_MAX_LENGTH = 38;
39
+ var PPE_LANE_PATTERN = /^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/;
40
+ var validatePpeLaneName = (value) => {
41
+ const lane = value.trim();
42
+ if (!lane) {
43
+ throw new CliError("PPE lane name is required", 1);
44
+ }
45
+ if (lane.length > PPE_LANE_MAX_LENGTH) {
46
+ throw new CliError(`PPE lane name must be ${PPE_LANE_MAX_LENGTH} characters or fewer`, 1);
47
+ }
48
+ if (!PPE_LANE_PATTERN.test(lane)) {
49
+ throw new CliError(
50
+ "PPE lane name must use lowercase letters, digits, or hyphens, and start and end with a letter or digit",
51
+ 1
52
+ );
53
+ }
54
+ return lane;
55
+ };
56
+
57
+ // src/constants/admin.ts
58
+ var DEFAULT_ADMIN_API_ORIGIN = "https://tutti.sh";
59
+
48
60
  // src/lib/admin-computer.ts
61
+ import { randomUUID } from "crypto";
62
+ import { createWriteStream } from "fs";
63
+ import { link, mkdir, rename, stat, unlink } from "fs/promises";
64
+ import { basename, dirname, join, resolve } from "path";
65
+ import { Readable } from "stream";
66
+ import { pipeline } from "stream/promises";
49
67
  var TUTTI_COMPUTER_API_BASE_PATH = "/api/desktop/v2/admin/tutti-v2/computers";
50
68
  var TUTTI_COMPUTER_CONNECTION_WIRE_VALUE = {
51
69
  online: 1,
@@ -127,6 +145,12 @@ var parseTuttiComputer = (value) => {
127
145
  const owner = objectValue(root.owner);
128
146
  return {
129
147
  computerId: stringField(root, "computerId", "computer_id"),
148
+ ...field(root, "canCollect", "can_collect") !== void 0 ? {
149
+ canCollect: booleanField(root, "canCollect", "can_collect"),
150
+ collectionBlockedReason: stringField(root, "collectionBlockedReason", "collection_blocked_reason"),
151
+ computerKind: stringField(root, "computerKind", "computer_kind"),
152
+ computerVersion: stringField(root, "computerVersion", "computer_version")
153
+ } : {},
130
154
  displayName: stringField(root, "displayName", "display_name"),
131
155
  reportedName: stringField(root, "reportedName", "reported_name"),
132
156
  owner: {
@@ -356,6 +380,9 @@ var AdminApi = class {
356
380
  constructor(runtime, fetchImpl = fetch) {
357
381
  this.runtime = runtime;
358
382
  this.fetchImpl = fetchImpl;
383
+ if (runtime.ppeLane !== void 0) {
384
+ this.runtime = { ...runtime, ppeLane: validatePpeLaneName(runtime.ppeLane) };
385
+ }
359
386
  }
360
387
  async request(method, path, body) {
361
388
  const headers = {
@@ -364,6 +391,9 @@ var AdminApi = class {
364
391
  if (this.runtime.token) {
365
392
  headers.authorization = `Bearer ${this.runtime.token}`;
366
393
  }
394
+ if (this.runtime.ppeLane) {
395
+ headers["x-zk-ppe-lane"] = this.runtime.ppeLane;
396
+ }
367
397
  let payload;
368
398
  if (body !== void 0) {
369
399
  headers["content-type"] = "application/json";
@@ -372,9 +402,22 @@ var AdminApi = class {
372
402
  const response = await this.fetchImpl(`${this.runtime.apiOrigin}${path}`, {
373
403
  method,
374
404
  headers,
375
- body: payload
405
+ body: payload,
406
+ ...this.runtime.ppeLane ? { redirect: "error" } : {}
376
407
  });
377
408
  const parsed = await parseResponseBody(response);
409
+ if (this.runtime.ppeLane) {
410
+ const hit = response.headers.get("x-zk-ppe-lane-hit");
411
+ const fallback = response.headers.get("x-tutti-server-route-fallback");
412
+ if (hit !== this.runtime.ppeLane || fallback) {
413
+ const detail = adminApiErrorDetail(parsed);
414
+ throw new AdminApiError(
415
+ `PPE \u8DEF\u7531\u4E0D\u5339\u914D: \u8BF7\u6C42 ${this.runtime.ppeLane}\uFF0C\u5B9E\u9645 ${hit || "\u672A\u786E\u8BA4\u6CF3\u9053\uFF08\u6B63\u5F0F\u73AF\u5883\u56DE\u843D\u6216\u7F3A\u5C11\u547D\u4E2D\u54CD\u5E94\u5934\uFF09"}${fallback ? `\uFF0CServer \u56DE\u843D\u539F\u56E0 ${fallback}` : ""}; ${method} ${path}; HTTP ${response.status}${detail ? `: ${detail}` : ""}`,
416
+ response.status,
417
+ parsed
418
+ );
419
+ }
420
+ }
378
421
  if (!response.ok) {
379
422
  const detail = adminApiErrorDetail(parsed);
380
423
  throw new AdminApiError(
@@ -658,7 +701,7 @@ var logger = {
658
701
  };
659
702
 
660
703
  // src/commands/admin/shared.ts
661
- var addAdminConnectionOptions = (command) => command.option("--admin-url <url>", "management API URL or admin.tutti.sh page URL").option("--json", "print the response as JSON without CLI decorations");
704
+ var addAdminConnectionOptions = (command) => command.option("--admin-url <url>", "management API URL or admin.tutti.sh page URL").option("--ppe-lane <lane>", "route every management request to this exact PPE lane").option("--json", "print the response as JSON without CLI decorations");
662
705
  var resolveAdminContext = async (options = {}, tokenOverride) => {
663
706
  const config = await loadConfig();
664
707
  const apiOrigin = normalizeAdminApiOrigin(options.adminUrl ?? config.admin?.apiOrigin);
@@ -667,7 +710,7 @@ var resolveAdminContext = async (options = {}, tokenOverride) => {
667
710
  throw new CliError("\u7BA1\u7406\u540E\u53F0 Token \u672A\u914D\u7F6E\uFF0C\u8BF7\u5148\u8FD0\u884C nexight-devops admin auth login", 1);
668
711
  }
669
712
  return {
670
- api: new AdminApi({ apiOrigin, token }),
713
+ api: new AdminApi({ apiOrigin, token, ppeLane: options.ppeLane }),
671
714
  apiOrigin,
672
715
  config,
673
716
  token
@@ -852,6 +895,14 @@ var resolveRuntimeLogRecallSelection = (input2, now = /* @__PURE__ */ new Date()
852
895
  var isUUID = (value) => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
853
896
  value
854
897
  );
898
+ var assertComputerCollectable = (computer) => {
899
+ if (computer.canCollect === false) {
900
+ throw new CliError(`Computer ${computer.computerId} \u65E0\u6CD5\u91C7\u96C6\u65E5\u5FD7: ${computer.collectionBlockedReason || "\u670D\u52A1\u7AEF\u672A\u63D0\u4F9B\u963B\u585E\u539F\u56E0"}`, 1);
901
+ }
902
+ if (computer.canCollect === void 0 && computer.connectionStatus !== "online") {
903
+ throw new CliError(`Computer ${computer.computerId} \u5F53\u524D\u4E0D\u5728\u7EBF\uFF0C\u65E0\u6CD5\u53EC\u56DE\u65E5\u5FD7`, 1);
904
+ }
905
+ };
855
906
  var computerNameMatches = (computer, selector) => {
856
907
  const expected = selector.trim().toLowerCase();
857
908
  return [computer.displayName, computer.reportedName].map((value) => value.trim().toLowerCase()).includes(expected);
@@ -863,9 +914,7 @@ var resolveOnlineTuttiComputer = async (api, selector) => {
863
914
  }
864
915
  if (isUUID(normalized)) {
865
916
  const computer = await api.getTuttiComputer(normalized);
866
- if (computer.connectionStatus !== "online") {
867
- throw new CliError(`Computer ${normalized} \u5F53\u524D\u4E0D\u5728\u7EBF\uFF0C\u65E0\u6CD5\u53EC\u56DE\u65E5\u5FD7`, 1);
868
- }
917
+ assertComputerCollectable(computer);
869
918
  return computer;
870
919
  }
871
920
  const matches = [];
@@ -977,6 +1026,7 @@ var recallComputerRuntimeLogs = async (input2, dependencies) => {
977
1026
  const selector = input2.ownerQuery ? `\u7528\u6237\u540D/\u90AE\u7BB1 ${input2.ownerQuery}` : `Computer ${input2.computer}`;
978
1027
  progress(`\u6B63\u5728\u89E3\u6790\u552F\u4E00\u5728\u7EBF Computer: ${selector}`);
979
1028
  const computer = await resolveOnlineTuttiComputerForLogs(dependencies.api, input2);
1029
+ assertComputerCollectable(computer);
980
1030
  progress(
981
1031
  `\u5DF2\u5339\u914D ${computer.displayName || computer.reportedName} (${computer.computerId})\uFF0Cdesktopd ${computer.desktopdVersion || "\u672A\u77E5"}`
982
1032
  );
@@ -3142,6 +3192,9 @@ var matchesWorkflowRunCorrelation = (run, correlation) => {
3142
3192
  if (correlation.actorLogin && run.actor?.login !== correlation.actorLogin) {
3143
3193
  return false;
3144
3194
  }
3195
+ if (correlation.runName && run.display_title !== correlation.runName) {
3196
+ return false;
3197
+ }
3145
3198
  if (!isRunCreatedAtOrAfter(run.created_at, correlation.createdAt)) {
3146
3199
  return false;
3147
3200
  }
@@ -3280,26 +3333,6 @@ var pollWorkflowRun = async (input2) => {
3280
3333
  throw new CliError(`Timed out waiting for workflow run in ${input2.repoFullName}`, 1);
3281
3334
  };
3282
3335
 
3283
- // src/lib/ppe-lane.ts
3284
- var PPE_LANE_MAX_LENGTH = 38;
3285
- var PPE_LANE_PATTERN = /^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/;
3286
- var validatePpeLaneName = (value) => {
3287
- const lane = value.trim();
3288
- if (!lane) {
3289
- throw new CliError("PPE lane name is required", 1);
3290
- }
3291
- if (lane.length > PPE_LANE_MAX_LENGTH) {
3292
- throw new CliError(`PPE lane name must be ${PPE_LANE_MAX_LENGTH} characters or fewer`, 1);
3293
- }
3294
- if (!PPE_LANE_PATTERN.test(lane)) {
3295
- throw new CliError(
3296
- "PPE lane name must use lowercase letters, digits, or hyphens, and start and end with a letter or digit",
3297
- 1
3298
- );
3299
- }
3300
- return lane;
3301
- };
3302
-
3303
3336
  // src/lib/prerequisites.ts
3304
3337
  var ensureCommandInstalled = async (command, installHint) => {
3305
3338
  const result = await runCommand(command, ["--version"]);
@@ -4148,9 +4181,9 @@ var registerReleasePrepareCommand = (program) => {
4148
4181
  };
4149
4182
 
4150
4183
  // src/commands/release/rollback.ts
4151
- import { mkdtemp as mkdtemp2, readFile as readFile2, rm } from "fs/promises";
4152
- import { tmpdir } from "os";
4153
- import { join as join5 } from "path";
4184
+ import { mkdtemp as mkdtemp3, readFile as readFile3, rm as rm2 } from "fs/promises";
4185
+ import { tmpdir as tmpdir2 } from "os";
4186
+ import { join as join6 } from "path";
4154
4187
 
4155
4188
  // src/constants/rollback-targets.ts
4156
4189
  var tuttiServerRollbackTarget = {
@@ -4158,7 +4191,12 @@ var tuttiServerRollbackTarget = {
4158
4191
  label: "tutti-server",
4159
4192
  repoFullName: "tutti-lab/tutti-server"
4160
4193
  };
4161
- var rollbackTargets = [tuttiServerRollbackTarget];
4194
+ var platformWebRollbackTarget = {
4195
+ key: "platform-web",
4196
+ label: "platform-web",
4197
+ repoFullName: "tutti-lab/tutti"
4198
+ };
4199
+ var rollbackTargets = [tuttiServerRollbackTarget, platformWebRollbackTarget];
4162
4200
 
4163
4201
  // src/commands/release/rollback-local.ts
4164
4202
  var CLUSTER = "arn:aws:eks:us-east-1:250509935467:cluster/nexight-ojo-us-east-1";
@@ -4246,9 +4284,137 @@ var fetchLocalRollbackCatalog = async () => {
4246
4284
  return buildLocalRollbackCatalog(history, deployment, manifests);
4247
4285
  };
4248
4286
 
4287
+ // src/commands/release/rollback-web.ts
4288
+ import { randomUUID as randomUUID3 } from "crypto";
4289
+ import { mkdtemp as mkdtemp2, readFile as readFile2, rm } from "fs/promises";
4290
+ import { tmpdir } from "os";
4291
+ import { join as join5 } from "path";
4292
+ var REPO = platformWebRollbackTarget.repoFullName;
4293
+ var WORKFLOW = "platform-web-rollback.yml";
4294
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
4295
+ var SHA = /^[0-9a-f]{40}$/;
4296
+ var choiceTime = new Intl.DateTimeFormat("sv-SE", { timeZone: "Asia/Singapore", dateStyle: "short", timeStyle: "short" });
4297
+ var validDeployment = (value) => UUID.test(value?.id) && Array.isArray(value.versions) && value.versions.length > 0 && value.versions.every((item) => UUID.test(item.version_id) && Number.isFinite(item.percentage) && item.percentage > 0 && item.percentage <= 100) && Math.abs(value.versions.reduce((sum, item) => sum + item.percentage, 0) - 100) < 1e-3;
4298
+ var parseWebRollbackCatalog = (raw) => {
4299
+ const data = JSON.parse(raw);
4300
+ if (data.schema !== "tutti.web.rollback.catalog.v1" || data.environment !== "production" || !validDeployment(data.currentDeployment) || !Array.isArray(data.candidates) || data.candidates.length > 100) {
4301
+ throw new CliError("Invalid Platform Web rollback catalog", 1);
4302
+ }
4303
+ const seen = /* @__PURE__ */ new Set();
4304
+ const active = new Set(data.currentDeployment.versions.map((item) => item.version_id));
4305
+ for (const candidate of data.candidates) {
4306
+ if (!UUID.test(candidate.versionId) || !UUID.test(candidate.deploymentId) || !Number.isFinite(Date.parse(candidate.deployedAt)) || typeof candidate.current !== "boolean" || candidate.current !== active.has(candidate.versionId) || typeof candidate.withdrawn !== "boolean" || typeof candidate.fullTraffic !== "boolean" || !(candidate.sha === null || SHA.test(candidate.sha)) || !(candidate.runId === null || /^\d+$/.test(candidate.runId)) || seen.has(candidate.versionId)) {
4307
+ throw new CliError("Invalid Platform Web deployment in rollback catalog", 1);
4308
+ }
4309
+ seen.add(candidate.versionId);
4310
+ }
4311
+ return data;
4312
+ };
4313
+ var buildWebRollbackChoices = (catalog) => catalog.candidates.filter((candidate) => !candidate.current && !candidate.withdrawn && candidate.fullTraffic).map((candidate) => ({
4314
+ name: `${choiceTime.format(new Date(candidate.deployedAt))} SGT ${candidate.versionId.slice(0, 8)} ${candidate.sha?.slice(0, 12) ?? "SHA unknown"}${candidate.runId ? ` run ${candidate.runId}` : ""}`,
4315
+ value: candidate.versionId
4316
+ }));
4317
+ var gh = async (args) => {
4318
+ const result = await runCommand("gh", args);
4319
+ if (result.exitCode !== 0) throw new CliError(result.stderr.trim() || result.stdout.trim() || "GitHub command failed", 1);
4320
+ return result.stdout;
4321
+ };
4322
+ var dispatch = async (operation, inputs = {}) => {
4323
+ const operationId = randomUUID3();
4324
+ const requestedAt = (/* @__PURE__ */ new Date()).toISOString();
4325
+ const knownRunIds = await listWorkflowRunIds({ repoFullName: REPO, workflowFile: WORKFLOW, ref: "main" });
4326
+ const fields = { operation, operation_id: operationId, requested_at: requestedAt, ...inputs };
4327
+ await gh(["workflow", "run", WORKFLOW, "--repo", REPO, "--ref", "main", ...Object.entries(fields).flatMap(([key, value]) => ["-f", `${key}=${value}`])]);
4328
+ const progress = ui.spinner(operation === "execute" ? "\u7B49\u5F85 Platform Web \u56DE\u6EDA\u548C\u5165\u53E3\u9A8C\u8BC1..." : "\u8BFB\u53D6 Platform Web \u751F\u4EA7\u72B6\u6001...").start();
4329
+ try {
4330
+ const run = await pollWorkflowRun({
4331
+ knownRunIds,
4332
+ repoFullName: REPO,
4333
+ workflowFile: WORKFLOW,
4334
+ ref: "main",
4335
+ intervalMs: 1e3,
4336
+ maxAttempts: 600,
4337
+ correlation: { runName: `Platform Web Rollback / ${operation} / ${operationId}`, createdAt: requestedAt },
4338
+ onUpdate: (update) => {
4339
+ if ("htmlUrl" in update) progress.text = update.htmlUrl;
4340
+ }
4341
+ });
4342
+ const directory = await mkdtemp2(join5(tmpdir(), "platform-web-rollback-"));
4343
+ try {
4344
+ try {
4345
+ await gh(["run", "download", String(run.runId), "--repo", REPO, "--name", "platform-web-rollback", "--dir", directory]);
4346
+ } catch (error) {
4347
+ throw new CliError(`Could not retrieve operation outcome; inspect actual production state at ${run.htmlUrl} before retrying: ${error instanceof Error ? error.message : String(error)}`, 1);
4348
+ }
4349
+ const result = JSON.parse(await readFile2(join5(directory, "rollback-result.json"), "utf8"));
4350
+ if (result.schema !== "tutti.web.rollback.result.v1" || result.operationId !== operationId || !["success", "failure", "pending_verification"].includes(result.status) || !["unchanged", "switched", "unknown"].includes(result.deployment) || !result.verification || !result.timings || Object.values(result.timings).some((value) => !Number.isFinite(value) || value < 0) || result.actual && !validDeployment(result.actual)) {
4351
+ throw new CliError(`Invalid rollback result: ${run.htmlUrl}`, 1);
4352
+ }
4353
+ if (run.conclusion !== "success" || result.status !== "success") {
4354
+ const state = result.actual ? `${result.actual.id} / ${JSON.stringify(result.actual.versions)}` : "unknown";
4355
+ throw new CliError(`Platform Web operation failed; deployment ${result.deployment}
4356
+ Actual production: ${state}
4357
+ ${result.error ?? run.failureMessage ?? run.conclusion}
4358
+ ${run.htmlUrl}`, 1);
4359
+ }
4360
+ if (operation === "execute" && (result.deployment !== "switched" || !result.actual || !UUID.test(result.targetVersionId ?? "") || result.actual.versions.length !== 1 || result.actual.versions[0]?.version_id !== result.targetVersionId || result.actual.versions[0]?.percentage !== 100)) {
4361
+ throw new CliError(`Rollback did not confirm target version at 100%: ${run.htmlUrl}`, 1);
4362
+ }
4363
+ const catalog = operation === "list" ? parseWebRollbackCatalog(await readFile2(join5(directory, "rollback-catalog.json"), "utf8")) : void 0;
4364
+ return { run, result, catalog };
4365
+ } catch (error) {
4366
+ if (error instanceof CliError) throw error;
4367
+ throw new CliError(`Could not read actual operation state; inspect ${run.htmlUrl} before retrying: ${error instanceof Error ? error.message : String(error)}`, 1);
4368
+ } finally {
4369
+ await rm(directory, { recursive: true, force: true });
4370
+ }
4371
+ } finally {
4372
+ progress.stop();
4373
+ }
4374
+ };
4375
+ var runPlatformWebRollback = async (options) => {
4376
+ if (options.previous && options.versionId || options.probe && (options.previous || options.versionId)) throw new CliError("Choose only one of --previous, --version-id, or --probe", 1);
4377
+ if (options.versionId && !UUID.test(options.versionId)) throw new CliError("--version-id must be a Worker version UUID", 1);
4378
+ if (options.reason && Buffer.byteLength(options.reason) > 200) throw new CliError("--reason must be at most 200 UTF-8 bytes", 1);
4379
+ await ensurePrerequisites({ needsGh: true });
4380
+ let inputs = { reason: options.reason ?? "operator rollback" };
4381
+ if (options.probe) {
4382
+ const { run: run2, result: result2 } = await dispatch("probe");
4383
+ logger.info(`Read-only production probe: ${JSON.stringify(result2.timings)}`);
4384
+ logger.info(`Verification: ${JSON.stringify(result2.verification)}`);
4385
+ logger.info(run2.htmlUrl);
4386
+ return;
4387
+ }
4388
+ if (options.yes && (options.previous || options.versionId)) {
4389
+ inputs = { ...inputs, previous: String(Boolean(options.previous)), version_id: options.versionId ?? "" };
4390
+ } else {
4391
+ const { catalog } = await dispatch("list", options.versionId ? { version_id: options.versionId } : {});
4392
+ if (!catalog) throw new CliError("Missing Platform Web rollback catalog", 1);
4393
+ const choices = buildWebRollbackChoices(catalog);
4394
+ if (!options.versionId && choices.length === 0) throw new CliError("No eligible recent production Web version is available", 1);
4395
+ const versionId = options.versionId ?? (options.previous ? choices[0]?.value : await ui.select({ message: "Select a production Web version to restore", choices, pageSize: Math.min(choices.length, 15) }));
4396
+ const selected = catalog.candidates.find((candidate) => candidate.versionId === versionId && !candidate.current);
4397
+ if (!selected) throw new CliError("No eligible recent production Web version is available", 1);
4398
+ logger.info(`Current deployment: ${catalog.currentDeployment.id}`);
4399
+ logger.info(`Target version: ${selected.versionId} / SHA ${selected.sha ?? "unknown"}`);
4400
+ if (selected.runId) logger.info(`Published by: https://github.com/${REPO}/actions/runs/${selected.runId}`);
4401
+ if (selected.withdrawn) logger.warn("This version was previously withdrawn by a rollback.");
4402
+ if (!options.yes && !await ui.confirm({ message: "Restore this Web version to production?", default: false })) {
4403
+ logger.warn("Rollback cancelled");
4404
+ return;
4405
+ }
4406
+ inputs = { ...inputs, previous: "false", version_id: selected.versionId, expected_deployment_id: catalog.currentDeployment.id };
4407
+ }
4408
+ const { run, result } = await dispatch("execute", inputs);
4409
+ logger.success(`Platform Web switched to ${result.targetVersionId} at 100% traffic`);
4410
+ logger.info(`Verification: ${JSON.stringify(result.verification)}`);
4411
+ logger.info(`Timing (ms): ${JSON.stringify(result.timings)}`);
4412
+ logger.info(run.htmlUrl);
4413
+ };
4414
+
4249
4415
  // src/commands/release/rollback.ts
4250
- var REPO = tuttiServerRollbackTarget.repoFullName;
4251
- var WORKFLOW = "rollback.yml";
4416
+ var REPO2 = tuttiServerRollbackTarget.repoFullName;
4417
+ var WORKFLOW2 = "rollback.yml";
4252
4418
  var SHA256 = /^sha256:[0-9a-f]{64}$/;
4253
4419
  var SINGAPORE_TIME = new Intl.DateTimeFormat("sv-SE", {
4254
4420
  timeZone: "Asia/Singapore",
@@ -4272,7 +4438,7 @@ var buildRollbackChoices = (catalog) => catalog.candidates.filter((candidate) =>
4272
4438
  name: `${displayChoiceTime(candidate.deployedAt)} SGT #${candidate.revision} ${candidate.version}`,
4273
4439
  value: candidate.revision
4274
4440
  }));
4275
- var gh = async (args) => {
4441
+ var gh2 = async (args) => {
4276
4442
  const result = await runCommand("gh", args);
4277
4443
  if (result.exitCode !== 0) {
4278
4444
  throw new CliError(result.stderr.trim() || result.stdout.trim() || `gh ${args[0]} failed`, 1);
@@ -4292,8 +4458,8 @@ var parseRollbackCatalog = (raw) => {
4292
4458
  return data;
4293
4459
  };
4294
4460
  var listActiveDeployments = async () => {
4295
- const runs = await Promise.all(["deploy.yml", WORKFLOW].map(async (workflow) => {
4296
- const raw = await gh(["api", `repos/${REPO}/actions/workflows/${workflow}/runs?per_page=30`]);
4461
+ const runs = await Promise.all(["deploy.yml", WORKFLOW2].map(async (workflow) => {
4462
+ const raw = await gh2(["api", `repos/${REPO2}/actions/workflows/${workflow}/runs?per_page=30`]);
4297
4463
  return JSON.parse(raw).workflow_runs;
4298
4464
  }));
4299
4465
  return runs.flat().filter(
@@ -4306,19 +4472,19 @@ var ensureNoActiveDeployment = async () => {
4306
4472
  throw new CliError(`Production deployment is already running: ${active[0]?.html_url}`, 1);
4307
4473
  }
4308
4474
  };
4309
- var dispatch = async (operation, inputs = {}) => {
4310
- const knownRunIds = await listWorkflowRunIds({ repoFullName: REPO, workflowFile: WORKFLOW, ref: "main" });
4311
- const args = ["workflow", "run", WORKFLOW, "--repo", REPO, "--ref", "main", "-f", `operation=${operation}`];
4475
+ var dispatch2 = async (operation, inputs = {}) => {
4476
+ const knownRunIds = await listWorkflowRunIds({ repoFullName: REPO2, workflowFile: WORKFLOW2, ref: "main" });
4477
+ const args = ["workflow", "run", WORKFLOW2, "--repo", REPO2, "--ref", "main", "-f", `operation=${operation}`];
4312
4478
  for (const [key, value] of Object.entries(inputs)) {
4313
4479
  args.push("-f", `${key}=${value}`);
4314
4480
  }
4315
- await gh(args);
4481
+ await gh2(args);
4316
4482
  const progress = ui.spinner(operation === "list" ? "\u8BFB\u53D6\u751F\u4EA7\u90E8\u7F72\u5386\u53F2..." : "\u7B49\u5F85\u751F\u4EA7\u56DE\u6EDA\u548C\u5065\u5EB7\u68C0\u67E5...").start();
4317
4483
  try {
4318
4484
  const run = await pollWorkflowRun({
4319
4485
  knownRunIds,
4320
- repoFullName: REPO,
4321
- workflowFile: WORKFLOW,
4486
+ repoFullName: REPO2,
4487
+ workflowFile: WORKFLOW2,
4322
4488
  ref: "main",
4323
4489
  maxAttempts: operation === "execute" ? 420 : 180,
4324
4490
  onUpdate: (update) => {
@@ -4328,15 +4494,15 @@ var dispatch = async (operation, inputs = {}) => {
4328
4494
  if (run.conclusion !== "success") {
4329
4495
  let state = "";
4330
4496
  if (operation === "execute") {
4331
- const dir = await mkdtemp2(join5(tmpdir(), "tutti-rollback-state-"));
4497
+ const dir = await mkdtemp3(join6(tmpdir2(), "tutti-rollback-state-"));
4332
4498
  try {
4333
- await gh(["run", "download", String(run.runId), "--repo", REPO, "--name", "rollback-state", "--dir", dir]);
4499
+ await gh2(["run", "download", String(run.runId), "--repo", REPO2, "--name", "rollback-state", "--dir", dir]);
4334
4500
  state = `
4335
- ${await readFile2(join5(dir, "rollback-state.txt"), "utf8")}`;
4501
+ ${await readFile3(join6(dir, "rollback-state.txt"), "utf8")}`;
4336
4502
  } catch {
4337
4503
  state = "\nActual production state is available in the workflow logs.";
4338
4504
  } finally {
4339
- await rm(dir, { recursive: true, force: true });
4505
+ await rm2(dir, { recursive: true, force: true });
4340
4506
  }
4341
4507
  }
4342
4508
  throw new CliError(`Workflow failed: ${run.failureMessage ?? run.conclusion}
@@ -4348,12 +4514,12 @@ ${run.htmlUrl}${state}`, 1);
4348
4514
  }
4349
4515
  };
4350
4516
  var fetchCatalog = async (runId) => {
4351
- const dir = await mkdtemp2(join5(tmpdir(), "tutti-rollback-"));
4517
+ const dir = await mkdtemp3(join6(tmpdir2(), "tutti-rollback-"));
4352
4518
  try {
4353
- await gh(["run", "download", String(runId), "--repo", REPO, "--name", "rollback-catalog", "--dir", dir]);
4354
- return parseRollbackCatalog(await readFile2(join5(dir, "rollback-catalog.json"), "utf8"));
4519
+ await gh2(["run", "download", String(runId), "--repo", REPO2, "--name", "rollback-catalog", "--dir", dir]);
4520
+ return parseRollbackCatalog(await readFile3(join6(dir, "rollback-catalog.json"), "utf8"));
4355
4521
  } finally {
4356
- await rm(dir, { recursive: true, force: true });
4522
+ await rm2(dir, { recursive: true, force: true });
4357
4523
  }
4358
4524
  };
4359
4525
  var readCatalog = async () => {
@@ -4365,7 +4531,7 @@ var readCatalog = async () => {
4365
4531
  progress.stop();
4366
4532
  }
4367
4533
  logger.warn("\u672C\u673A\u65E0\u6CD5\u5B8C\u6574\u8BFB\u53D6\u751F\u4EA7\u90E8\u7F72\u5386\u53F2\uFF0C\u6539\u7528 GitHub Actions \u67E5\u8BE2\u3002");
4368
- const listRun = await dispatch("list");
4534
+ const listRun = await dispatch2("list");
4369
4535
  return fetchCatalog(listRun.runId);
4370
4536
  };
4371
4537
  var runTuttiRollback = async (options) => {
@@ -4405,7 +4571,7 @@ var runTuttiRollback = async (options) => {
4405
4571
  return;
4406
4572
  }
4407
4573
  await ensureNoActiveDeployment();
4408
- const run = await dispatch("execute", {
4574
+ const run = await dispatch2("execute", {
4409
4575
  revision: String(selected.revision),
4410
4576
  digest: selected.digest,
4411
4577
  expected_current_digest: catalog.currentDigest,
@@ -4415,14 +4581,18 @@ var runTuttiRollback = async (options) => {
4415
4581
  logger.info(run.htmlUrl);
4416
4582
  };
4417
4583
  var registerReleaseRollbackCommand = (program) => {
4418
- program.command("rollback").description("Restore a recent production image").argument("[repository]", "Rollback target; omit to choose interactively").option("--revision <number>", "Select a recent Helm revision without the chooser").option("--yes", "Skip the final confirmation").action(async (repository, options) => {
4584
+ program.command("rollback").description("Restore a recent production deployment").argument("[repository]", "Rollback target; omit to choose interactively").option("--revision <number>", "Select a recent Helm revision without the chooser").option("--previous", "Restore the latest eligible previous Platform Web version").option("--version-id <uuid>", "Select a previously deployed Platform Web Worker version").option("--reason <text>", "Record a Platform Web rollback reason").option("--probe", "Measure a read-only Platform Web production probe").option("--yes", "Skip the final confirmation").action(async (repository, options) => {
4419
4585
  const selected = repository ?? await ui.select({
4420
4586
  message: "Select a repository to roll back",
4421
4587
  choices: rollbackTargets.map((target) => ({ name: target.label, value: target.key }))
4422
4588
  });
4423
- if (selected !== tuttiServerRollbackTarget.key) {
4424
- throw new CliError("Only tutti-server is currently supported", 1);
4589
+ if (selected === platformWebRollbackTarget.key) {
4590
+ if (options.revision) throw new CliError("--revision applies only to tutti-server", 1);
4591
+ await runPlatformWebRollback(options);
4592
+ return;
4425
4593
  }
4594
+ if (selected !== tuttiServerRollbackTarget.key) throw new CliError(`Unsupported rollback target: ${selected}`, 1);
4595
+ if (options.previous || options.versionId || options.reason || options.probe) throw new CliError("--previous, --version-id, --reason and --probe apply only to platform-web", 1);
4426
4596
  await runTuttiRollback(options);
4427
4597
  });
4428
4598
  };
@@ -4452,7 +4622,7 @@ var readCliVersion = () => {
4452
4622
 
4453
4623
  // src/lib/version-check.ts
4454
4624
  import { realpath } from "fs/promises";
4455
- import { join as join6, resolve as resolve3, sep } from "path";
4625
+ import { join as join7, resolve as resolve3, sep } from "path";
4456
4626
  var PACKAGE_NAME = "@tutti-os/nexight-devops";
4457
4627
  var AUTO_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
4458
4628
  var DEFAULT_LOCALE = "zh-CN";
@@ -4521,7 +4691,7 @@ var isInsidePackageRoot = (entryPath, root) => {
4521
4691
  if (!entryPath || !root) {
4522
4692
  return false;
4523
4693
  }
4524
- const packageRoot = resolve3(join6(root, "@tutti-os", "nexight-devops"));
4694
+ const packageRoot = resolve3(join7(root, "@tutti-os", "nexight-devops"));
4525
4695
  const targetPath = resolve3(entryPath);
4526
4696
  return targetPath === packageRoot || targetPath.startsWith(`${packageRoot}${sep}`);
4527
4697
  };
@@ -4531,7 +4701,7 @@ var isInsideBunGlobalBin = (entryPath, bunGlobalBin) => {
4531
4701
  }
4532
4702
  const targetPath = resolve3(entryPath);
4533
4703
  const binPath = resolve3(bunGlobalBin);
4534
- const expectedEntryPath = resolve3(join6(binPath, "nexight-devops"));
4704
+ const expectedEntryPath = resolve3(join7(binPath, "nexight-devops"));
4535
4705
  return targetPath === expectedEntryPath;
4536
4706
  };
4537
4707
  var readGlobalPath = async (command, args) => {