hikoutei 0.5.19 → 0.5.21

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 (116) hide show
  1. package/README.md +17 -0
  2. package/dist/adapter/persistence/contracts/scalar.d.ts +34 -0
  3. package/dist/adapter/persistence/contracts/scalar.d.ts.map +1 -1
  4. package/dist/adapter/persistence/contracts/scalar.js +6 -1
  5. package/dist/adapter/persistence/contracts/scalar.js.map +1 -1
  6. package/dist/adapter/persistence/providers/mikro-orm/api/MikroOrmScalarPersistenceProvider.d.ts +19 -20
  7. package/dist/adapter/persistence/providers/mikro-orm/api/MikroOrmScalarPersistenceProvider.d.ts.map +1 -1
  8. package/dist/adapter/persistence/providers/mikro-orm/api/MikroOrmScalarPersistenceProvider.js +117 -67
  9. package/dist/adapter/persistence/providers/mikro-orm/api/MikroOrmScalarPersistenceProvider.js.map +1 -1
  10. package/dist/adapter/persistence/providers/mikro-orm/engine/MikroOrmMappedRuntime.d.ts +31 -0
  11. package/dist/adapter/persistence/providers/mikro-orm/engine/MikroOrmMappedRuntime.d.ts.map +1 -0
  12. package/dist/adapter/persistence/providers/mikro-orm/engine/{MikroOrmMappedTypedSheets.js → MikroOrmMappedRuntime.js} +10 -31
  13. package/dist/adapter/persistence/providers/mikro-orm/engine/MikroOrmMappedRuntime.js.map +1 -0
  14. package/dist/adapter/persistence/providers/mikro-orm/engine/MikroOrmScalarEntityRuntime.js.map +1 -1
  15. package/dist/adapter/persistence/providers/mikro-orm/engine/MikroOrmScalarRuntime.js.map +1 -1
  16. package/dist/adapter/persistence/providers/mikro-orm/engine/MikroOrmScalarStorage.d.ts +12 -0
  17. package/dist/adapter/persistence/providers/mikro-orm/engine/MikroOrmScalarStorage.d.ts.map +1 -0
  18. package/dist/adapter/persistence/providers/mikro-orm/engine/MikroOrmScalarStorage.js +20 -0
  19. package/dist/adapter/persistence/providers/mikro-orm/engine/MikroOrmScalarStorage.js.map +1 -0
  20. package/dist/adapter/persistence/providers/mikro-orm/storage/MikroOrmSqliteAdapter.d.ts +2 -7
  21. package/dist/adapter/persistence/providers/mikro-orm/storage/MikroOrmSqliteAdapter.d.ts.map +1 -1
  22. package/dist/adapter/persistence/providers/mikro-orm/storage/MikroOrmSqliteAdapter.js +5 -11
  23. package/dist/adapter/persistence/providers/mikro-orm/storage/MikroOrmSqliteAdapter.js.map +1 -1
  24. package/dist/api/Hikoutei.d.ts.map +1 -1
  25. package/dist/api/Hikoutei.js +3 -4
  26. package/dist/api/Hikoutei.js.map +1 -1
  27. package/dist/api/errors.d.ts +2 -0
  28. package/dist/api/errors.d.ts.map +1 -1
  29. package/dist/api/errors.js +2 -0
  30. package/dist/api/errors.js.map +1 -1
  31. package/dist/api/internalEntityManager.d.ts.map +1 -1
  32. package/dist/api/internalEntityManager.js +31 -9
  33. package/dist/api/internalEntityManager.js.map +1 -1
  34. package/dist/application/orm/mapping/contracts.d.ts +8 -3
  35. package/dist/application/orm/mapping/contracts.d.ts.map +1 -1
  36. package/dist/application/orm/mapping/values.d.ts +2 -0
  37. package/dist/application/orm/mapping/values.d.ts.map +1 -1
  38. package/dist/application/orm/mapping/values.js +10 -3
  39. package/dist/application/orm/mapping/values.js.map +1 -1
  40. package/dist/application/orm/persistence/flush/flushCoordinator.d.ts +2 -2
  41. package/dist/application/orm/persistence/flush/flushCoordinator.d.ts.map +1 -1
  42. package/dist/application/orm/persistence/flush/flushCoordinator.js +9 -16
  43. package/dist/application/orm/persistence/flush/flushCoordinator.js.map +1 -1
  44. package/dist/application/orm/persistence/lifecycle/entityLifecycle.d.ts.map +1 -1
  45. package/dist/application/orm/persistence/lifecycle/entityLifecycle.js +26 -13
  46. package/dist/application/orm/persistence/lifecycle/entityLifecycle.js.map +1 -1
  47. package/dist/application/orm/persistence/projection/projectionEffects.d.ts +2 -2
  48. package/dist/application/orm/persistence/projection/projectionEffects.d.ts.map +1 -1
  49. package/dist/application/orm/persistence/projection/projectionEffects.js +6 -6
  50. package/dist/application/orm/persistence/projection/projectionEffects.js.map +1 -1
  51. package/dist/application/orm/persistence/scalar/unitOfWork.d.ts +21 -2
  52. package/dist/application/orm/persistence/scalar/unitOfWork.d.ts.map +1 -1
  53. package/dist/application/orm/persistence/scalar/unitOfWork.js +107 -8
  54. package/dist/application/orm/persistence/scalar/unitOfWork.js.map +1 -1
  55. package/dist/application/orm/persistence/support/contracts.d.ts +6 -6
  56. package/dist/application/orm/persistence/support/contracts.d.ts.map +1 -1
  57. package/dist/application/orm/persistence/support/contracts.js +0 -2
  58. package/dist/application/orm/persistence/support/contracts.js.map +1 -1
  59. package/dist/application/orm/persistence/support/helpers.d.ts +3 -3
  60. package/dist/application/orm/persistence/support/helpers.d.ts.map +1 -1
  61. package/dist/application/orm/persistence/support/helpers.js +12 -9
  62. package/dist/application/orm/persistence/support/helpers.js.map +1 -1
  63. package/dist/application/orm/persistence/support/timing.d.ts +2 -2
  64. package/dist/application/orm/persistence/support/timing.d.ts.map +1 -1
  65. package/dist/application/orm/persistence/support/timing.js +4 -4
  66. package/dist/application/orm/persistence/support/timing.js.map +1 -1
  67. package/dist/application/sync/service/SyncServiceBootstrap.d.ts.map +1 -1
  68. package/dist/application/sync/service/SyncServiceBootstrap.js +3 -3
  69. package/dist/application/sync/service/SyncServiceBootstrap.js.map +1 -1
  70. package/dist/cli/args.d.ts.map +1 -1
  71. package/dist/cli/args.js +16 -0
  72. package/dist/cli/args.js.map +1 -1
  73. package/dist/cli/keyProvision.d.ts +38 -0
  74. package/dist/cli/keyProvision.d.ts.map +1 -1
  75. package/dist/cli/keyProvision.js +86 -13
  76. package/dist/cli/keyProvision.js.map +1 -1
  77. package/dist/cli/saVerify.d.ts +37 -0
  78. package/dist/cli/saVerify.d.ts.map +1 -1
  79. package/dist/cli/saVerify.js +46 -0
  80. package/dist/cli/saVerify.js.map +1 -1
  81. package/dist/cli/setup.d.ts +23 -2
  82. package/dist/cli/setup.d.ts.map +1 -1
  83. package/dist/cli/setup.js +40 -4
  84. package/dist/cli/setup.js.map +1 -1
  85. package/dist/cli/setupFlow.d.ts +8 -0
  86. package/dist/cli/setupFlow.d.ts.map +1 -1
  87. package/dist/cli/setupFlow.js +206 -49
  88. package/dist/cli/setupFlow.js.map +1 -1
  89. package/dist/cli/setupProgress.d.ts +385 -0
  90. package/dist/cli/setupProgress.d.ts.map +1 -0
  91. package/dist/cli/setupProgress.js +1009 -0
  92. package/dist/cli/setupProgress.js.map +1 -0
  93. package/package.json +1 -1
  94. package/dist/adapter/persistence/providers/mikro-orm/api/MikroOrmTypedSheetsEntityManager.d.ts +0 -55
  95. package/dist/adapter/persistence/providers/mikro-orm/api/MikroOrmTypedSheetsEntityManager.d.ts.map +0 -1
  96. package/dist/adapter/persistence/providers/mikro-orm/api/MikroOrmTypedSheetsEntityManager.js +0 -120
  97. package/dist/adapter/persistence/providers/mikro-orm/api/MikroOrmTypedSheetsEntityManager.js.map +0 -1
  98. package/dist/adapter/persistence/providers/mikro-orm/engine/MikroOrmMappedTypedSheets.d.ts +0 -56
  99. package/dist/adapter/persistence/providers/mikro-orm/engine/MikroOrmMappedTypedSheets.d.ts.map +0 -1
  100. package/dist/adapter/persistence/providers/mikro-orm/engine/MikroOrmMappedTypedSheets.js.map +0 -1
  101. package/dist/adapter/persistence/providers/mikro-orm/engine/MikroOrmTypedSheetsEngine.d.ts +0 -38
  102. package/dist/adapter/persistence/providers/mikro-orm/engine/MikroOrmTypedSheetsEngine.d.ts.map +0 -1
  103. package/dist/adapter/persistence/providers/mikro-orm/engine/MikroOrmTypedSheetsEngine.js +0 -57
  104. package/dist/adapter/persistence/providers/mikro-orm/engine/MikroOrmTypedSheetsEngine.js.map +0 -1
  105. package/dist/adapter/persistence/providers/mikro-orm/storage/MikroOrmFlushChanges.d.ts +0 -19
  106. package/dist/adapter/persistence/providers/mikro-orm/storage/MikroOrmFlushChanges.d.ts.map +0 -1
  107. package/dist/adapter/persistence/providers/mikro-orm/storage/MikroOrmFlushChanges.js +0 -67
  108. package/dist/adapter/persistence/providers/mikro-orm/storage/MikroOrmFlushChanges.js.map +0 -1
  109. package/dist/application/orm/api/TypedSheetsOrm.d.ts +0 -71
  110. package/dist/application/orm/api/TypedSheetsOrm.d.ts.map +0 -1
  111. package/dist/application/orm/api/TypedSheetsOrm.js +0 -116
  112. package/dist/application/orm/api/TypedSheetsOrm.js.map +0 -1
  113. package/dist/application/orm/api/contracts.d.ts +0 -118
  114. package/dist/application/orm/api/contracts.d.ts.map +0 -1
  115. package/dist/application/orm/api/contracts.js +0 -21
  116. package/dist/application/orm/api/contracts.js.map +0 -1
@@ -0,0 +1,1009 @@
1
+ /**
2
+ * Step-by-step progress reporting for `hikoutei setup`.
3
+ *
4
+ * The setup flow creates several remote Google Cloud resources and waits on
5
+ * asynchronous IAM/ACL propagation, so a run can take minutes with no stdout
6
+ * activity. This module renders that progress to stderr as two bars:
7
+ *
8
+ * - an OVERALL bar that advances one segment per completed setup phase
9
+ * (never a guessed percentage or an ETA — only finished work moves it);
10
+ * and
11
+ * - a DETAIL bar for the bounded retry polls inside the key-settlement and
12
+ * service-account access-verification phases (how many of the eight
13
+ * propagation checks have run, and during a known wait how far through
14
+ * that wait the clock is) plus a fixed `working…` label for
15
+ * unknown-duration steps.
16
+ *
17
+ * The flow reports progress through a discriminated
18
+ * {@link SetupProgressEvent} union: `resumed` (once, with the phases a
19
+ * checkpoint guarantees), `phase_started` / `phase_completed` at phase
20
+ * boundaries, `operation_started` / `operation_completed` around the
21
+ * notable remote/local steps (fixed safe labels; the bounded propagation
22
+ * checks additionally carry 1-based attempt info), `retry_wait_started`
23
+ * before each bounded sleep, and `phase_failed` (a stable
24
+ * {@link SetupErrorCode} only) emitted by the CLI controller when a run
25
+ * ends in error — the flow itself returns a stable error result and never
26
+ * knows it is the final attempt (the interactive login retry can still
27
+ * rescue an auth preflight failure).
28
+ *
29
+ * Two renderers share one validating state machine:
30
+ * - an interactive (TTY, color-capable) renderer that redraws a fixed
31
+ * four-line block in place with ANSI and animates a known wait through
32
+ * an `unref`-ed interval timer; and
33
+ * - an append-only renderer for CI / non-TTY / `NO_COLOR` that prints a
34
+ * static line per phase start / phase completion / bounded-check attempt
35
+ * / retry wait / failure and NEVER uses control sequences, a clock tick,
36
+ * or a line for ordinary operation events (no log spam). The final
37
+ * bounded attempt (8/8) has no following wait line, so it must print its
38
+ * own attempt line or it would be invisible before success/failure.
39
+ *
40
+ * Security contract: progress events and rendered text carry ONLY fixed
41
+ * labels, attempt/delay numbers, and stable error codes. Project ids,
42
+ * service-account emails, owner emails, paths, access tokens, private keys,
43
+ * key ids, raw gcloud output, and raw provider payloads are NEVER placed in
44
+ * an event or written by a renderer. A throwing renderer callback is
45
+ * swallowed by {@link safeProgressSink}, and the controller swallows its
46
+ * own write/scheduler failures, so progress can never change the setup
47
+ * result, the mutation order, or the process exit code. This module is
48
+ * internal CLI machinery only; it is not part of the application-facing
49
+ * API.
50
+ */
51
+ import { SETUP_ERROR_CODES } from "./errors.js";
52
+ /** The ten setup phases in execution order. */
53
+ export const SETUP_PROGRESS_PHASES = [
54
+ "cloud_auth",
55
+ "drive_access",
56
+ "project",
57
+ "apis",
58
+ "service_account",
59
+ "service_account_key",
60
+ "spreadsheet",
61
+ "share",
62
+ "sa_access",
63
+ "output",
64
+ ];
65
+ /** Total number of setup phases (drives the overall bar denominator). */
66
+ export const SETUP_PROGRESS_PHASE_COUNT = SETUP_PROGRESS_PHASES.length;
67
+ /**
68
+ * The phases a checkpoint can ever guarantee as already complete, in order.
69
+ *
70
+ * A valid `resumed` event lists a PREFIX of this sequence: cloud_auth and
71
+ * drive_access are never checkpoint-complete (every run re-runs them
72
+ * fresh) and the output phase is never checkpoint-complete (the `.env`
73
+ * write runs on every successful run). The tracker rejects any other
74
+ * shape (gaps, duplicates, out-of-order entries, unknown phases) as an
75
+ * invalid checkpoint phase list.
76
+ */
77
+ const RESUMABLE_CHECKPOINT_PHASES = [
78
+ "project",
79
+ "apis",
80
+ "service_account",
81
+ "service_account_key",
82
+ "spreadsheet",
83
+ "share",
84
+ "sa_access",
85
+ ];
86
+ /** Fixed, safe human labels for each phase (never secrets). */
87
+ export const SETUP_PROGRESS_LABELS = {
88
+ cloud_auth: "Google Cloud authentication",
89
+ drive_access: "Drive access",
90
+ project: "Project",
91
+ apis: "Sheets and Drive APIs",
92
+ service_account: "Service account",
93
+ service_account_key: "Service-account key",
94
+ spreadsheet: "Spreadsheet",
95
+ share: "Share and ownership",
96
+ sa_access: "Service-account access",
97
+ output: "Checkpoint and .env",
98
+ };
99
+ /** Short labels for the compact "done" line. */
100
+ export const SETUP_PROGRESS_SHORT_LABELS = {
101
+ cloud_auth: "Cloud auth",
102
+ drive_access: "Drive access",
103
+ project: "Project",
104
+ apis: "APIs",
105
+ service_account: "Service account",
106
+ service_account_key: "Key",
107
+ spreadsheet: "Spreadsheet",
108
+ share: "Share",
109
+ sa_access: "SA access",
110
+ output: "Output",
111
+ };
112
+ /** The retry kind a phase can host, if any. */
113
+ const RETRY_KIND_OF_PHASE = {
114
+ service_account_key: "key_settlement",
115
+ sa_access: "sa_access",
116
+ };
117
+ /** Human word for each retry kind, used in "before next <word> check". */
118
+ const RETRY_KIND_WORD = {
119
+ key_settlement: "key",
120
+ sa_access: "access",
121
+ };
122
+ /** Human noun for each retry kind shown between checks. */
123
+ const RETRY_CHECK_NOUN = {
124
+ key_settlement: "key settlement check",
125
+ sa_access: "access check",
126
+ };
127
+ /**
128
+ * Fixed, safe operation labels for the notable setup steps.
129
+ *
130
+ * The flow emits `operation_started` / `operation_completed` around the
131
+ * unknown-duration calls (gcloud spawns, API calls) so the detail bar can
132
+ * show a fixed `working… <label>` instead of a guessed percentage. Labels
133
+ * are constant text and never carry identifiers, paths, or secrets.
134
+ */
135
+ export const SETUP_PROGRESS_OPERATIONS = {
136
+ GCLOUD_PRESENCE: "checking gcloud CLI",
137
+ ACTIVE_ACCOUNT: "checking active gcloud account",
138
+ DRIVE_SCOPE: "verifying Drive access",
139
+ PROJECT_VERIFY: "verifying project",
140
+ PROJECT_CREATE: "creating project",
141
+ PROJECT_SELECT: "selecting default project",
142
+ API_ENABLE: "enabling Sheets and Drive APIs",
143
+ SA_LIST: "listing service accounts",
144
+ SA_CREATE: "creating service account",
145
+ KEY_LIST: "listing service-account keys",
146
+ SHEET_CREATE: "creating spreadsheet",
147
+ SHEET_RECONCILE: "reconciling spreadsheet",
148
+ SHARE: "sharing spreadsheet with service account",
149
+ ENV_WRITE: "writing .env",
150
+ CHECKPOINT_PERSIST: "persisting checkpoint",
151
+ };
152
+ /**
153
+ * Fixed, safe operation labels for the bounded propagation checks.
154
+ *
155
+ * Operation events carrying these labels also carry a
156
+ * {@link BoundedCheckInfo} with the 1-based attempt within the bounded
157
+ * window, so the tracker can keep the detail bar on the propagation check
158
+ * while it runs.
159
+ */
160
+ export const SETUP_PROGRESS_BOUNDED_OPERATIONS = {
161
+ KEY_SETTLE: "key settlement check",
162
+ SA_ACCESS: "access check",
163
+ };
164
+ /** The bounded-check operation label a phase hosts, if any. */
165
+ function boundedOperationOf(phase) {
166
+ if (RETRY_KIND_OF_PHASE[phase] === "key_settlement") {
167
+ return SETUP_PROGRESS_BOUNDED_OPERATIONS.KEY_SETTLE;
168
+ }
169
+ if (RETRY_KIND_OF_PHASE[phase] === "sa_access") {
170
+ return SETUP_PROGRESS_BOUNDED_OPERATIONS.SA_ACCESS;
171
+ }
172
+ return undefined;
173
+ }
174
+ /** A sink that drops every event; used when progress is disabled. */
175
+ export const NOOP_PROGRESS_SINK = {
176
+ report() {
177
+ /* progress disabled */
178
+ },
179
+ };
180
+ /**
181
+ * Wraps a sink so a throwing callback (or an absent sink) can never affect
182
+ * the setup run. Returns {@link NOOP_PROGRESS_SINK} for `undefined` so flow
183
+ * call sites can call `progress.report(...)` unconditionally.
184
+ */
185
+ export function safeProgressSink(sink) {
186
+ if (sink === undefined) {
187
+ return NOOP_PROGRESS_SINK;
188
+ }
189
+ return {
190
+ report(event) {
191
+ try {
192
+ sink.report(event);
193
+ }
194
+ catch {
195
+ // Progress rendering must never change the setup result, the
196
+ // mutation order, or the exit code.
197
+ }
198
+ },
199
+ };
200
+ }
201
+ /** Width (in cells) of both progress bars. */
202
+ const PROGRESS_BAR_WIDTH = 20;
203
+ /**
204
+ * Renders a `[████…░░░…]` bar for a 0..1 fill ratio.
205
+ *
206
+ * The fill is clamped to [0, 1] so a clock tick past the end of a known wait
207
+ * never overflows the bar.
208
+ */
209
+ export function renderProgressBar(fill) {
210
+ const ratio = fill < 0 ? 0 : fill > 1 ? 1 : fill;
211
+ const filled = Math.round(ratio * PROGRESS_BAR_WIDTH);
212
+ return `[${"█".repeat(filled)}${"░".repeat(PROGRESS_BAR_WIDTH - filled)}]`;
213
+ }
214
+ /**
215
+ * Whole-percent of completed phases over the total.
216
+ *
217
+ * The input is clamped to the ten logical phases (defense in depth: the
218
+ * tracker count is already clamped, but an out-of-range caller value must
219
+ * never be able to render 110% or a negative percent).
220
+ */
221
+ export function overallPercent(completed) {
222
+ const clamped = completed < 0 ? 0 : completed > SETUP_PROGRESS_PHASE_COUNT ? SETUP_PROGRESS_PHASE_COUNT : completed;
223
+ return Math.floor((clamped / SETUP_PROGRESS_PHASE_COUNT) * 100);
224
+ }
225
+ /** True when the value names a known phase (runtime boundary guard). */
226
+ function isKnownPhase(value) {
227
+ return typeof value === "string" && SETUP_PROGRESS_PHASES.includes(value);
228
+ }
229
+ /** True when the value is a known generic or bounded operation label. */
230
+ function isKnownOperation(value) {
231
+ if (typeof value !== "string") {
232
+ return false;
233
+ }
234
+ return (Object.values(SETUP_PROGRESS_OPERATIONS).includes(value) ||
235
+ Object.values(SETUP_PROGRESS_BOUNDED_OPERATIONS).includes(value));
236
+ }
237
+ /**
238
+ * Fixed size of every bounded propagation window.
239
+ *
240
+ * Both polls (key settlement and SA-access verification) run exactly eight
241
+ * checks (`1/8`..`8/8`); the tracker rejects any attempt/maxAttempts pair
242
+ * that claims a different window size, so impossible values can never
243
+ * reach the detail bar or a rendered line.
244
+ */
245
+ export const BOUNDED_CHECK_MAX_ATTEMPTS = 8;
246
+ /** True when the value is a structurally valid bounded-check info. */
247
+ function isValidCheckInfo(value) {
248
+ if (typeof value !== "object" || value === null) {
249
+ return false;
250
+ }
251
+ const { attempt, maxAttempts } = value;
252
+ return (typeof attempt === "number" &&
253
+ Number.isInteger(attempt) &&
254
+ attempt >= 1 &&
255
+ attempt <= BOUNDED_CHECK_MAX_ATTEMPTS &&
256
+ typeof maxAttempts === "number" &&
257
+ Number.isInteger(maxAttempts) &&
258
+ maxAttempts === BOUNDED_CHECK_MAX_ATTEMPTS &&
259
+ maxAttempts >= attempt);
260
+ }
261
+ /** True when the value is a stable setup error code. */
262
+ function isSetupErrorCode(value) {
263
+ return typeof value === "string" && Object.values(SETUP_ERROR_CODES).includes(value);
264
+ }
265
+ /**
266
+ * Validating progress state machine shared by both renderers.
267
+ *
268
+ * The tracker validates every event and REJECTS (ignores, never throws)
269
+ * invalid ones: phase order (a phase may only start when every earlier
270
+ * phase is complete and no other phase is in progress), duplicate phase
271
+ * completion (a completed phase never increments the count again),
272
+ * operation start/complete pairing (an operation may only complete the
273
+ * exact operation that is active, with matching check attempt info),
274
+ * retry kind/phase consistency, attempt bounds, and checkpoint phase
275
+ * lists (a `resumed` list must be a prefix of the resumable phases).
276
+ * Invalid events are silently dropped so a benign reporting quirk can
277
+ * never silence progress for the rest of a run and can never throw.
278
+ * {@link SetupProgressTracker.resetTransient} clears the in-progress
279
+ * state (current phase, active operation, retry, failure) before the
280
+ * interactive login retry so a re-run can re-emit phase events from
281
+ * scratch; completed phases and the resume flag are kept.
282
+ * {@link SetupProgressTracker.markFailed} records a failure for a phase
283
+ * that is no longer in progress (the controller's `fail()` fallback after
284
+ * a suspend cleared the current phase).
285
+ */
286
+ export class SetupProgressTracker {
287
+ completed = new Map();
288
+ current;
289
+ operation;
290
+ retry;
291
+ failed;
292
+ resumed = false;
293
+ /**
294
+ * Number of completed phases (any source), clamped to the ten logical
295
+ * phases. Duplicate completions are rejected, so the count can never
296
+ * exceed the total; the clamp is defense in depth.
297
+ */
298
+ get completedCount() {
299
+ return Math.min(this.completed.size, SETUP_PROGRESS_PHASE_COUNT);
300
+ }
301
+ /** True once a valid `resumed` event was processed. */
302
+ get isResumed() {
303
+ return this.resumed;
304
+ }
305
+ /** The currently in-progress phase, if any. */
306
+ get currentPhase() {
307
+ return this.current;
308
+ }
309
+ /** The active operation (generic step or bounded check), if any. */
310
+ get activeOperation() {
311
+ return this.operation;
312
+ }
313
+ /** The active retry (bounded check/wait context), if any. */
314
+ get activeRetry() {
315
+ return this.retry;
316
+ }
317
+ /** The failed phase/code on a `phase_failed` event, if any. */
318
+ get failure() {
319
+ return this.failed;
320
+ }
321
+ /** True when the phase was completed (by run or checkpoint). */
322
+ isComplete(phase) {
323
+ return this.completed.has(phase);
324
+ }
325
+ /**
326
+ * Resets the in-progress state so a retried run can re-emit phase events
327
+ * from scratch.
328
+ *
329
+ * Called by the renderer's `suspend()` before the inherited `gcloud auth
330
+ * login` handoff: the first attempt may have died mid-phase (for example
331
+ * during `drive_access`), and the retry re-runs the whole flow from
332
+ * `cloud_auth`. Completed phases and the resume flag are kept — the
333
+ * retry re-completes the same phases, and duplicate completion is
334
+ * rejected so the overall count never grows past the real work.
335
+ */
336
+ resetTransient() {
337
+ this.current = undefined;
338
+ this.operation = undefined;
339
+ this.retry = undefined;
340
+ this.failed = undefined;
341
+ }
342
+ /**
343
+ * Records a failure for a phase that is no longer in progress.
344
+ *
345
+ * The controller uses this as the `fail()` fallback after `suspend()`
346
+ * cleared the current phase for the inherited login handoff — and for
347
+ * the deterministic next-pending phase when a run-level error has no
348
+ * current phase: the final failure frame must still name a phase.
349
+ * Validation matches the `phase_failed` event (known phase, stable
350
+ * code) minus the current-phase requirement; the transient
351
+ * operation/retry state is already clear after the suspend.
352
+ */
353
+ markFailed(phase, code) {
354
+ if (!isKnownPhase(phase) || !isSetupErrorCode(code)) {
355
+ return false;
356
+ }
357
+ this.failed = { phase, code };
358
+ this.current = undefined;
359
+ this.retry = undefined;
360
+ this.operation = undefined;
361
+ return true;
362
+ }
363
+ /** True when every phase before `phase` (in execution order) is complete. */
364
+ allEarlierCompleted(phase) {
365
+ const index = SETUP_PROGRESS_PHASES.indexOf(phase);
366
+ if (index === -1) {
367
+ return false;
368
+ }
369
+ for (let i = 0; i < index; i += 1) {
370
+ if (!this.completed.has(SETUP_PROGRESS_PHASES[i])) {
371
+ return false;
372
+ }
373
+ }
374
+ return true;
375
+ }
376
+ /** True when `phase` may start: no current phase, not completed, ordered. */
377
+ isValidNextPhase(phase) {
378
+ return this.current === undefined && !this.completed.has(phase) && this.allEarlierCompleted(phase);
379
+ }
380
+ /** True when the resumed list is a valid prefix of the resumable phases. */
381
+ isValidCheckpointList(phases) {
382
+ if (!Array.isArray(phases)) {
383
+ return false;
384
+ }
385
+ if (phases.length > RESUMABLE_CHECKPOINT_PHASES.length) {
386
+ return false;
387
+ }
388
+ for (let i = 0; i < phases.length; i += 1) {
389
+ if (phases[i] !== RESUMABLE_CHECKPOINT_PHASES[i]) {
390
+ return false;
391
+ }
392
+ }
393
+ return true;
394
+ }
395
+ /**
396
+ * Processes one event, updating internal state.
397
+ *
398
+ * Never throws: invalid events are rejected (ignored) and `false` is
399
+ * returned so the renderer can skip drawing for them.
400
+ */
401
+ apply(event) {
402
+ switch (event.type) {
403
+ case "resumed": {
404
+ if (!this.isValidCheckpointList(event.completedFromCheckpoint)) {
405
+ return false;
406
+ }
407
+ this.resumed = true;
408
+ for (const phase of event.completedFromCheckpoint) {
409
+ if (!this.completed.has(phase)) {
410
+ this.completed.set(phase, "checkpoint");
411
+ }
412
+ }
413
+ return true;
414
+ }
415
+ case "phase_started": {
416
+ if (!isKnownPhase(event.phase) || !this.isValidNextPhase(event.phase)) {
417
+ return false;
418
+ }
419
+ this.current = event.phase;
420
+ // A re-run of a phase clears any prior failure display (the login
421
+ // retry re-runs cloud_auth/drive_access after a suspended failure).
422
+ this.failed = undefined;
423
+ if (this.retry?.phase === event.phase) {
424
+ this.retry = undefined;
425
+ }
426
+ return true;
427
+ }
428
+ case "phase_completed": {
429
+ if (!isKnownPhase(event.phase)) {
430
+ return false;
431
+ }
432
+ if (event.source !== "run" && event.source !== "checkpoint") {
433
+ return false;
434
+ }
435
+ // Duplicate completion never increments the count again.
436
+ if (this.completed.has(event.phase)) {
437
+ return false;
438
+ }
439
+ if (event.source === "run") {
440
+ if (this.current !== event.phase) {
441
+ return false;
442
+ }
443
+ }
444
+ else if (!this.allEarlierCompleted(event.phase)) {
445
+ return false;
446
+ }
447
+ this.completed.set(event.phase, event.source);
448
+ if (this.current === event.phase) {
449
+ this.current = undefined;
450
+ }
451
+ if (this.retry?.phase === event.phase) {
452
+ this.retry = undefined;
453
+ }
454
+ if (this.activeOperation?.phase === event.phase) {
455
+ this.operation = undefined;
456
+ }
457
+ return true;
458
+ }
459
+ case "operation_started":
460
+ case "operation_completed": {
461
+ if (!isKnownPhase(event.phase) || this.current !== event.phase) {
462
+ return false;
463
+ }
464
+ if ("check" in event) {
465
+ // A bounded check must carry valid attempt info and the exact
466
+ // operation label its phase hosts (kind/phase consistency).
467
+ if (!isValidCheckInfo(event.check)) {
468
+ return false;
469
+ }
470
+ const bounded = boundedOperationOf(event.phase);
471
+ if (bounded === undefined || event.operation !== bounded) {
472
+ return false;
473
+ }
474
+ }
475
+ else if (!isKnownOperation(event.operation)) {
476
+ return false;
477
+ }
478
+ if (event.type === "operation_started") {
479
+ this.operation =
480
+ "check" in event
481
+ ? { phase: event.phase, operation: event.operation, check: event.check }
482
+ : { phase: event.phase, operation: event.operation };
483
+ if ("check" in event) {
484
+ const kind = RETRY_KIND_OF_PHASE[event.phase];
485
+ if (kind === undefined) {
486
+ return false;
487
+ }
488
+ this.retry = {
489
+ phase: event.phase,
490
+ kind,
491
+ attempt: event.check.attempt,
492
+ maxAttempts: event.check.maxAttempts,
493
+ };
494
+ }
495
+ return true;
496
+ }
497
+ // operation_completed must pair with the exact active operation
498
+ // (same phase, operation, and check attempt info).
499
+ const active = this.operation;
500
+ if (active === undefined || active.phase !== event.phase || active.operation !== event.operation) {
501
+ return false;
502
+ }
503
+ if ("check" in event) {
504
+ if (!("check" in active) ||
505
+ active.check.attempt !== event.check.attempt ||
506
+ active.check.maxAttempts !== event.check.maxAttempts) {
507
+ return false;
508
+ }
509
+ const kind = RETRY_KIND_OF_PHASE[event.phase];
510
+ if (kind === undefined) {
511
+ return false;
512
+ }
513
+ this.retry = {
514
+ phase: event.phase,
515
+ kind,
516
+ attempt: event.check.attempt,
517
+ maxAttempts: event.check.maxAttempts,
518
+ };
519
+ }
520
+ else if ("check" in active) {
521
+ return false;
522
+ }
523
+ this.operation = undefined;
524
+ return true;
525
+ }
526
+ case "retry_wait_started": {
527
+ if (!isKnownPhase(event.phase) || this.current !== event.phase) {
528
+ return false;
529
+ }
530
+ // The retry kind must match the phase that hosts it.
531
+ if (RETRY_KIND_OF_PHASE[event.phase] !== event.kind) {
532
+ return false;
533
+ }
534
+ if (!Number.isInteger(event.attempt) ||
535
+ !Number.isInteger(event.maxAttempts) ||
536
+ event.attempt < 1 ||
537
+ event.attempt > BOUNDED_CHECK_MAX_ATTEMPTS ||
538
+ event.maxAttempts !== BOUNDED_CHECK_MAX_ATTEMPTS ||
539
+ event.attempt > event.maxAttempts) {
540
+ return false;
541
+ }
542
+ if (!Number.isFinite(event.delayMs) || event.delayMs < 0) {
543
+ return false;
544
+ }
545
+ this.retry = {
546
+ phase: event.phase,
547
+ kind: event.kind,
548
+ attempt: event.attempt,
549
+ maxAttempts: event.maxAttempts,
550
+ };
551
+ return true;
552
+ }
553
+ case "phase_failed": {
554
+ if (!isKnownPhase(event.phase) || this.current !== event.phase) {
555
+ return false;
556
+ }
557
+ if (!isSetupErrorCode(event.code)) {
558
+ return false;
559
+ }
560
+ this.failed = { phase: event.phase, code: event.code };
561
+ this.current = undefined;
562
+ this.retry = undefined;
563
+ this.operation = undefined;
564
+ return true;
565
+ }
566
+ }
567
+ }
568
+ }
569
+ /**
570
+ * Builds a {@link BoundedCheckReporter} that forwards to a progress sink as
571
+ * operation events (with check attempt info) and `retry_wait_started`
572
+ * events for the given phase and kind. Returns a no-op when the sink is
573
+ * the {@link NOOP_PROGRESS_SINK}, so key provisioning and SA verification
574
+ * stay cheap when progress is disabled.
575
+ */
576
+ export function boundedCheckReporter(sink, phase, kind) {
577
+ if (sink === NOOP_PROGRESS_SINK) {
578
+ return () => {
579
+ /* progress disabled */
580
+ };
581
+ }
582
+ const operation = kind === "key_settlement" ? SETUP_PROGRESS_BOUNDED_OPERATIONS.KEY_SETTLE : SETUP_PROGRESS_BOUNDED_OPERATIONS.SA_ACCESS;
583
+ return (event) => {
584
+ switch (event.type) {
585
+ case "check_started":
586
+ sink.report({
587
+ type: "operation_started",
588
+ phase,
589
+ operation,
590
+ check: { attempt: event.attempt, maxAttempts: event.maxAttempts },
591
+ });
592
+ return;
593
+ case "check_completed":
594
+ sink.report({
595
+ type: "operation_completed",
596
+ phase,
597
+ operation,
598
+ check: { attempt: event.attempt, maxAttempts: event.maxAttempts },
599
+ });
600
+ return;
601
+ case "wait_started":
602
+ sink.report({
603
+ type: "retry_wait_started",
604
+ phase,
605
+ kind,
606
+ attempt: event.attempt,
607
+ maxAttempts: event.maxAttempts,
608
+ delayMs: event.delayMs,
609
+ });
610
+ return;
611
+ }
612
+ };
613
+ }
614
+ /**
615
+ * True when the environment marks an automation session.
616
+ *
617
+ * Mirrors the de-facto CI convention: a NON-EMPTY `CI` value (GitHub
618
+ * Actions, GitLab CI, CircleCI, Travis, ...) marks automation, while an
619
+ * empty override keeps interactive behavior (the same empty-override rule
620
+ * as NO_COLOR). Production `main()` passes this into the CLI context so a
621
+ * CI pseudo-TTY can never prompt for the interactive login handoff or
622
+ * spawn the browser login.
623
+ */
624
+ export function isCiEnvironment(env = process.env) {
625
+ return env.CI !== undefined && env.CI !== "";
626
+ }
627
+ /** True when ANSI color/control output should be used (TTY, not NO_COLOR, not CI). */
628
+ export function shouldUseInteractiveProgress(isTty, env = process.env) {
629
+ if (!isTty) {
630
+ return false;
631
+ }
632
+ // Respect the de-facto NO_COLOR convention (any non-empty value disables
633
+ // color/control output) and a TTY-only NO_COLOR=0/empty override. A CI
634
+ // pseudo-TTY must also stay static: setup progress is documented as one
635
+ // static line per event there — ANSI redraws and animation timers must
636
+ // never run in CI.
637
+ if (env.NO_COLOR !== undefined && env.NO_COLOR !== "") {
638
+ return false;
639
+ }
640
+ return !isCiEnvironment(env);
641
+ }
642
+ /** Interval (ms) between frames of the known-wait animation in TTY mode. */
643
+ const WAIT_ANIMATION_INTERVAL_MS = 250;
644
+ /** Marker lines emitted before the static block of the append-only renderer. */
645
+ const APPEND_HEADER_FRESH = "Hikoutei setup progress";
646
+ const APPEND_HEADER_RESUMED_PREFIX = "Hikoutei setup progress (resuming";
647
+ /**
648
+ * First phase (in execution order) that is not yet complete, if any.
649
+ *
650
+ * The deterministic fallback for a run-level error with no current phase
651
+ * (for example lock contention after the login retry's fresh auth phases):
652
+ * the failure frame names the phase the run was about to enter.
653
+ */
654
+ function nextPendingPhase(tracker) {
655
+ for (const phase of SETUP_PROGRESS_PHASES) {
656
+ if (!tracker.isComplete(phase)) {
657
+ return phase;
658
+ }
659
+ }
660
+ return undefined;
661
+ }
662
+ /**
663
+ * Creates the production progress controller.
664
+ *
665
+ * In interactive mode it owns a single animation timer for the known-wait
666
+ * detail bar; the timer is cleared on every subsequent event, on
667
+ * {@link SetupProgressController.suspend} (before the inherited gcloud
668
+ * login), and on {@link SetupProgressController.finish}. The timer is
669
+ * `unref`-ed so it can never be what keeps the process alive (the setup
670
+ * sleep itself holds the run open). In append-only mode no timer is used
671
+ * and ordinary operation events update state without printing a line.
672
+ * All controller methods swallow their own write/scheduler failures so
673
+ * progress can never change the setup result or the exit code.
674
+ */
675
+ export function createSetupProgressRenderer(options) {
676
+ const interactive = options.interactive ?? shouldUseInteractiveProgress(options.isTty);
677
+ const now = options.now ?? Date.now;
678
+ const setIntervalFn = options.setInterval ?? setInterval;
679
+ const clearIntervalFn = options.clearInterval ?? clearInterval;
680
+ const tracker = new SetupProgressTracker();
681
+ const output = options.output;
682
+ let headerShown = false;
683
+ let blockLines = 0;
684
+ let animationHandle;
685
+ // The known wait being animated (interactive mode only).
686
+ let wait;
687
+ // The phase that was in progress when `suspend()` cleared the tracker
688
+ // for the login handoff. `fail()` falls back to it when the handoff was
689
+ // cancelled or the login failed and no phase is current anymore, so the
690
+ // final failure frame still names the phase the run died in.
691
+ let suspendedPhase;
692
+ /** Writes to the output stream; a closed/erroring stream never fails the run. */
693
+ const safeWrite = (text) => {
694
+ try {
695
+ output.write(text);
696
+ }
697
+ catch {
698
+ // stderr closed or unwritable: progress must never change the
699
+ // setup result, the mutation order, or the exit code.
700
+ }
701
+ };
702
+ /** Clears the wait-animation timer if one is active. */
703
+ const clearAnimation = () => {
704
+ if (animationHandle !== undefined) {
705
+ try {
706
+ clearIntervalFn(animationHandle);
707
+ }
708
+ catch {
709
+ // Best-effort cleanup; the process is finishing.
710
+ }
711
+ animationHandle = undefined;
712
+ }
713
+ wait = undefined;
714
+ };
715
+ /** Starts the known-wait animation (interactive only) for `delayMs`. */
716
+ const startAnimation = (delayMs) => {
717
+ clearAnimation();
718
+ if (!interactive || delayMs <= 0) {
719
+ return;
720
+ }
721
+ wait = { start: now(), delay: delayMs };
722
+ try {
723
+ animationHandle = setIntervalFn(() => {
724
+ render();
725
+ }, WAIT_ANIMATION_INTERVAL_MS);
726
+ }
727
+ catch {
728
+ // The scheduler failed: fall back to a static detail line; the
729
+ // setup wait itself is unaffected.
730
+ wait = undefined;
731
+ return;
732
+ }
733
+ // Never let the animation timer be the reason the process stays alive;
734
+ // the real setup sleep keeps the run open while it waits.
735
+ if (typeof animationHandle.unref === "function") {
736
+ animationHandle.unref();
737
+ }
738
+ };
739
+ /** Writes the interactive block in place (clears the previous lines first). */
740
+ const renderInteractive = () => {
741
+ const lines = buildBlockLines();
742
+ // Move the cursor up over the previously drawn block, then redraw each
743
+ // line on a cleared line. The first draw has no previous block.
744
+ if (blockLines > 0) {
745
+ safeWrite(`\x1b[${blockLines}A`);
746
+ }
747
+ let written = 0;
748
+ for (const line of lines) {
749
+ safeWrite(`\x1b[2K\r${line}\n`);
750
+ written += 1;
751
+ }
752
+ blockLines = written;
753
+ };
754
+ /**
755
+ * Computes the detail-bar ratio and text for the current state.
756
+ *
757
+ * Priority: a known wait animates its elapsed/total ratio; otherwise a
758
+ * bounded check shows its attempt/max ratio; otherwise a generic step
759
+ * (or the bare phase) shows a fixed `working…` label with a zero ratio;
760
+ * a failed phase shows its stable code; everything else reads "done".
761
+ * No fake percentage is ever shown for unknown-duration work.
762
+ */
763
+ const detailState = () => {
764
+ const retry = tracker.activeRetry;
765
+ if (retry !== undefined) {
766
+ if (wait !== undefined) {
767
+ // Mid-wait: animate the elapsed portion of THIS wait.
768
+ const elapsed = now() - wait.start;
769
+ const ratio = wait.delay <= 0 ? 1 : elapsed / wait.delay;
770
+ return {
771
+ ratio,
772
+ text: `${retry.attempt}/${retry.maxAttempts} waiting ${Math.round(wait.delay / 1000)}s before next ${RETRY_KIND_WORD[retry.kind]} check`,
773
+ };
774
+ }
775
+ // Between checks (brief): show check progress.
776
+ return {
777
+ ratio: retry.attempt / retry.maxAttempts,
778
+ text: `${retry.attempt}/${retry.maxAttempts} ${RETRY_CHECK_NOUN[retry.kind]}`,
779
+ };
780
+ }
781
+ const operation = tracker.activeOperation;
782
+ if (operation !== undefined) {
783
+ return { ratio: 0, text: `working… ${operation.operation}` };
784
+ }
785
+ const current = tracker.currentPhase;
786
+ if (current !== undefined) {
787
+ return { ratio: 0, text: `working… ${SETUP_PROGRESS_LABELS[current].toLowerCase()}` };
788
+ }
789
+ const failure = tracker.failure;
790
+ if (failure !== undefined) {
791
+ return { ratio: 0, text: `failed: ${failure.code}` };
792
+ }
793
+ return { ratio: 1, text: "done" };
794
+ };
795
+ /** Builds the fixed-height block (header + overall + detail + done). */
796
+ const buildBlockLines = () => {
797
+ const completed = tracker.completedCount;
798
+ const pct = overallPercent(completed);
799
+ const header = tracker.isResumed ? "Hikoutei setup (resuming)" : "Hikoutei setup";
800
+ const current = tracker.currentPhase ?? tracker.failure?.phase;
801
+ // The Overall label names the phase the run is working on; with no
802
+ // phase current it names the NEXT pending phase (a phase boundary at
803
+ // 10-90% must never read "complete"), and only a fully completed run
804
+ // (10/10) earns the literal "complete" label.
805
+ let overallLabel;
806
+ if (current !== undefined) {
807
+ overallLabel = SETUP_PROGRESS_LABELS[current];
808
+ }
809
+ else if (completed >= SETUP_PROGRESS_PHASE_COUNT) {
810
+ overallLabel = "complete";
811
+ }
812
+ else {
813
+ const next = nextPendingPhase(tracker);
814
+ overallLabel = next === undefined ? "complete" : `next: ${SETUP_PROGRESS_LABELS[next]}`;
815
+ }
816
+ const overallLine = `Overall ${renderProgressBar(completed / SETUP_PROGRESS_PHASE_COUNT)} ${String(pct).padStart(3, " ")}% ${completed}/${SETUP_PROGRESS_PHASE_COUNT} ${overallLabel}`;
817
+ const detail = detailState();
818
+ const detailLine = `Detail ${renderProgressBar(detail.ratio)} ${detail.text}`;
819
+ const doneLine = `Done ${buildDoneSegment()}`;
820
+ return [header, overallLine, detailLine, doneLine];
821
+ };
822
+ /** Builds the compact checkmark list for the done line. */
823
+ const buildDoneSegment = () => {
824
+ const parts = [];
825
+ for (const phase of SETUP_PROGRESS_PHASES) {
826
+ if (tracker.isComplete(phase)) {
827
+ parts.push(`✓ ${SETUP_PROGRESS_SHORT_LABELS[phase]}`);
828
+ }
829
+ else if (tracker.failure !== undefined && tracker.failure.phase === phase) {
830
+ parts.push(`✗ ${SETUP_PROGRESS_SHORT_LABELS[phase]}`);
831
+ }
832
+ else if (tracker.currentPhase === phase) {
833
+ parts.push(`… ${SETUP_PROGRESS_SHORT_LABELS[phase]}`);
834
+ }
835
+ }
836
+ return parts.length === 0 ? "(starting)" : parts.join(" ");
837
+ };
838
+ /**
839
+ * Append-only renderer: one static line per phase/retry/failure event
840
+ * (bounded-check attempts included — the final 8/8 has no following wait
841
+ * line and must stay visible before success/failure), no control
842
+ * sequences, no clock ticks, and no line for ordinary operation events
843
+ * (CI log spam must stay bounded).
844
+ */
845
+ const renderAppend = (event) => {
846
+ const completed = tracker.completedCount;
847
+ if (event.type === "resumed") {
848
+ // The resumed event arrives AFTER the fresh auth/drive phases in the
849
+ // real flow, so it always prints its own resume line with the
850
+ // checkpoint-guaranteed step count.
851
+ safeWrite(`${APPEND_HEADER_RESUMED_PREFIX}; ${completed}/${SETUP_PROGRESS_PHASE_COUNT} steps already complete)\n`);
852
+ return;
853
+ }
854
+ if (!headerShown) {
855
+ safeWrite(`${APPEND_HEADER_FRESH}\n`);
856
+ headerShown = true;
857
+ }
858
+ const pct = overallPercent(completed);
859
+ switch (event.type) {
860
+ case "phase_started": {
861
+ safeWrite(`[ ${String(pct).padStart(3, " ")}% | ${completed}/${SETUP_PROGRESS_PHASE_COUNT}] ${SETUP_PROGRESS_LABELS[event.phase]}\n`);
862
+ return;
863
+ }
864
+ case "phase_completed": {
865
+ const suffix = event.source === "checkpoint" ? " (from checkpoint)" : " ready";
866
+ safeWrite(`[ ${String(pct).padStart(3, " ")}% | ${completed}/${SETUP_PROGRESS_PHASE_COUNT}] ✓ ${SETUP_PROGRESS_LABELS[event.phase]}${suffix}\n`);
867
+ return;
868
+ }
869
+ case "retry_wait_started": {
870
+ const checkWord = RETRY_KIND_WORD[event.kind];
871
+ safeWrite(` [ ${event.attempt}/${event.maxAttempts}] waiting ${Math.round(event.delayMs / 1000)}s before next ${checkWord} check\n`);
872
+ return;
873
+ }
874
+ case "phase_failed": {
875
+ safeWrite(`[ FAIL | ${event.code}] ${SETUP_PROGRESS_LABELS[event.phase]}\n`);
876
+ return;
877
+ }
878
+ case "operation_started":
879
+ case "operation_completed": {
880
+ if ("check" in event) {
881
+ // A bounded propagation check is visible in static mode: one line
882
+ // per attempt, including the final N/8 that has no following wait
883
+ // line (without it the last attempt would be invisible before
884
+ // success/failure). Only the start prints; the matching
885
+ // completion adds no extra line.
886
+ if (event.type === "operation_started") {
887
+ safeWrite(` [ ${event.check.attempt}/${event.check.maxAttempts}] ${event.operation}\n`);
888
+ }
889
+ return;
890
+ }
891
+ // Ordinary operation events update state without printing a line.
892
+ return;
893
+ }
894
+ }
895
+ };
896
+ /** Draws the current state in the active mode. */
897
+ const render = () => {
898
+ if (interactive) {
899
+ renderInteractive();
900
+ }
901
+ // The append-only renderer draws per event, not on a clock tick.
902
+ };
903
+ return {
904
+ report(event) {
905
+ // Invalid events are rejected by the tracker and never drawn.
906
+ if (!tracker.apply(event)) {
907
+ return;
908
+ }
909
+ // A new event always ends any in-flight wait animation first.
910
+ clearAnimation();
911
+ if (interactive) {
912
+ if (event.type === "retry_wait_started") {
913
+ startAnimation(event.delayMs);
914
+ }
915
+ renderInteractive();
916
+ }
917
+ else {
918
+ renderAppend(event);
919
+ }
920
+ },
921
+ suspend() {
922
+ // Remember the phase that was in progress BEFORE resetting: the
923
+ // inherited gcloud login owns the terminal cleanly and the retry
924
+ // re-emits phase events from scratch, but a cancelled handoff or a
925
+ // failed login must still render a stable failure frame naming the
926
+ // phase the run died in.
927
+ const phase = tracker.currentPhase;
928
+ if (phase !== undefined) {
929
+ suspendedPhase = phase;
930
+ }
931
+ // Stop the animation timer and reset the in-progress tracker state.
932
+ clearAnimation();
933
+ tracker.resetTransient();
934
+ if (interactive && blockLines > 0) {
935
+ // Move up over the block, erase each line, then park the cursor on
936
+ // the first cleared line so the next draw (or the login prompt)
937
+ // reuses the space.
938
+ safeWrite(`\x1b[${blockLines}A`);
939
+ for (let i = 0; i < blockLines; i += 1) {
940
+ safeWrite("\x1b[2K\r");
941
+ if (i < blockLines - 1) {
942
+ safeWrite("\n");
943
+ }
944
+ }
945
+ if (blockLines > 1) {
946
+ safeWrite(`\x1b[${blockLines - 1}A`);
947
+ }
948
+ blockLines = 0;
949
+ }
950
+ },
951
+ resume() {
952
+ // The inherited login finished and the retry is live again: the
953
+ // suspended-phase fallback must never label a retry failure against
954
+ // the phase the FIRST attempt died in. No timer starts or stops
955
+ // here — the retry's own events restart the animation when a known
956
+ // wait runs, and nothing ever runs during the inherited login.
957
+ suspendedPhase = undefined;
958
+ },
959
+ fail(code) {
960
+ clearAnimation();
961
+ // The failure frame names the phase the run died in: the live
962
+ // current phase first, then the phase suspended for the login
963
+ // handoff, then the deterministic next pending phase (a run-level
964
+ // error with no current phase — for example lock contention after
965
+ // the retry's fresh auth phases — must still render a safe failure
966
+ // frame), and finally the last phase as a terminal fallback when
967
+ // every phase is complete. All fallbacks use fixed phase labels and
968
+ // stable codes only, so nothing about the failure can leak data.
969
+ const phase = tracker.currentPhase ??
970
+ suspendedPhase ??
971
+ nextPendingPhase(tracker) ??
972
+ SETUP_PROGRESS_PHASES[SETUP_PROGRESS_PHASES.length - 1];
973
+ // Consume the suspended fallback: once a failure is finalized, a
974
+ // later fail() or finish() can never reuse the pre-login phase.
975
+ suspendedPhase = undefined;
976
+ if (phase === undefined) {
977
+ return;
978
+ }
979
+ // A phase that is still current fails through the validating
980
+ // tracker; a suspended or next-pending phase is no longer current
981
+ // (the login handoff or the phase boundary cleared it), so it is
982
+ // recorded directly with the same phase/code validation.
983
+ const applied = tracker.currentPhase === phase
984
+ ? tracker.apply({ type: "phase_failed", phase, code })
985
+ : tracker.markFailed(phase, code);
986
+ if (!applied) {
987
+ return;
988
+ }
989
+ if (interactive) {
990
+ renderInteractive();
991
+ }
992
+ else {
993
+ // The append-only renderer printed the phase_started line already
994
+ // (or nothing, for a run-level error); emit an explicit failure
995
+ // line for the phase the run died in.
996
+ renderAppend({ type: "phase_failed", phase, code });
997
+ }
998
+ },
999
+ finish() {
1000
+ clearAnimation();
1001
+ suspendedPhase = undefined;
1002
+ if (interactive && blockLines > 0) {
1003
+ renderInteractive();
1004
+ }
1005
+ // Append-only mode needs no terminal frame.
1006
+ },
1007
+ };
1008
+ }
1009
+ //# sourceMappingURL=setupProgress.js.map