@expo-up/cli 0.1.0
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/dist/index.js +296860 -0
- package/package.json +35 -0
- package/src/auth.ts +135 -0
- package/src/channels.tsx +123 -0
- package/src/cli-utils.test.ts +25 -0
- package/src/cli-utils.ts +19 -0
- package/src/codesigning.test.ts +165 -0
- package/src/codesigning.ts +265 -0
- package/src/history-utils.test.ts +23 -0
- package/src/history-utils.ts +24 -0
- package/src/history.tsx +559 -0
- package/src/index.tsx +366 -0
- package/src/release-utils.test.ts +219 -0
- package/src/release-utils.ts +146 -0
- package/src/release.tsx +510 -0
- package/src/rollback-utils.test.ts +74 -0
- package/src/rollback-utils.ts +53 -0
- package/src/rollback.tsx +267 -0
- package/src/ui.tsx +99 -0
- package/tsconfig.json +12 -0
package/src/history.tsx
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Text, Box, useInput } from "ink";
|
|
3
|
+
import Spinner from "ink-spinner";
|
|
4
|
+
import { Octokit } from "@octokit/rest";
|
|
5
|
+
import { getStoredToken, getAutoConfig } from "./auth";
|
|
6
|
+
import {
|
|
7
|
+
EMBEDDED_ROLLBACK_TARGET,
|
|
8
|
+
parseProjectDescriptor,
|
|
9
|
+
} from "../../core/src/index";
|
|
10
|
+
import { Badge, BrandHeader, CliCard, KV } from "./ui";
|
|
11
|
+
import { parseDeleteBuildIds } from "./history-utils";
|
|
12
|
+
|
|
13
|
+
interface HistoryProps {
|
|
14
|
+
channel: string;
|
|
15
|
+
debug?: boolean;
|
|
16
|
+
deleteBuildIds?: string[];
|
|
17
|
+
interactiveDelete?: boolean;
|
|
18
|
+
yes?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type BuildItem = {
|
|
22
|
+
id: number;
|
|
23
|
+
type: "ROLLBACK" | "RELEASE";
|
|
24
|
+
label: string;
|
|
25
|
+
isLive: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type HistoryStatus = "loading" | "idle" | "deleting" | "success" | "error";
|
|
29
|
+
|
|
30
|
+
type HistoryContext = {
|
|
31
|
+
octokit: Octokit;
|
|
32
|
+
owner: string;
|
|
33
|
+
repo: string;
|
|
34
|
+
runtimeVersion: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function toErrorMessage(error: unknown): string {
|
|
38
|
+
return error instanceof Error ? error.message : "Unknown error";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const History: React.FC<HistoryProps> = ({
|
|
42
|
+
channel,
|
|
43
|
+
debug = false,
|
|
44
|
+
deleteBuildIds,
|
|
45
|
+
interactiveDelete = true,
|
|
46
|
+
yes = false,
|
|
47
|
+
}) => {
|
|
48
|
+
const [items, setItems] = React.useState<BuildItem[]>([]);
|
|
49
|
+
const [status, setStatus] = React.useState<HistoryStatus>("loading");
|
|
50
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
51
|
+
const [debugLogs, setDebugLogs] = React.useState<string[]>([]);
|
|
52
|
+
const [cursor, setCursor] = React.useState(0);
|
|
53
|
+
const [selectedBuilds, setSelectedBuilds] = React.useState<Set<number>>(
|
|
54
|
+
new Set(),
|
|
55
|
+
);
|
|
56
|
+
const [logs, setLogs] = React.useState<string[]>([]);
|
|
57
|
+
const [pendingDeleteIds, setPendingDeleteIds] = React.useState<
|
|
58
|
+
number[] | null
|
|
59
|
+
>(null);
|
|
60
|
+
|
|
61
|
+
const ctxRef = React.useRef<HistoryContext | null>(null);
|
|
62
|
+
const autoDeleteTriggeredRef = React.useRef(false);
|
|
63
|
+
|
|
64
|
+
const parsedAutoDeleteIds = React.useMemo(() => {
|
|
65
|
+
try {
|
|
66
|
+
return parseDeleteBuildIds(deleteBuildIds);
|
|
67
|
+
} catch (parseError) {
|
|
68
|
+
setError(toErrorMessage(parseError));
|
|
69
|
+
setStatus("error");
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
}, [deleteBuildIds]);
|
|
73
|
+
|
|
74
|
+
const interactiveMode =
|
|
75
|
+
interactiveDelete &&
|
|
76
|
+
parsedAutoDeleteIds.length === 0 &&
|
|
77
|
+
Boolean(process.stdin.isTTY) &&
|
|
78
|
+
process.env.CI !== "true";
|
|
79
|
+
|
|
80
|
+
const appendDebug = React.useCallback((message: string) => {
|
|
81
|
+
setDebugLogs((prev) => [...prev, message]);
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
const appendLog = React.useCallback((message: string) => {
|
|
85
|
+
setLogs((prev) => [...prev, message]);
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
const loadHistory = React.useCallback(async () => {
|
|
89
|
+
try {
|
|
90
|
+
setStatus("loading");
|
|
91
|
+
setError(null);
|
|
92
|
+
setLogs([]);
|
|
93
|
+
setPendingDeleteIds(null);
|
|
94
|
+
setSelectedBuilds(new Set());
|
|
95
|
+
setCursor(0);
|
|
96
|
+
|
|
97
|
+
const token = getStoredToken();
|
|
98
|
+
const { serverUrl, projectId, runtimeVersion } = getAutoConfig();
|
|
99
|
+
|
|
100
|
+
if (!token || !serverUrl || !projectId || !runtimeVersion) {
|
|
101
|
+
throw new Error("Missing configuration. Are you logged in?");
|
|
102
|
+
}
|
|
103
|
+
if (debug) {
|
|
104
|
+
appendDebug(
|
|
105
|
+
`Resolved config: server=${serverUrl}, project=${projectId}, runtime=${runtimeVersion}, channel=${channel}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const octokit = new Octokit({ auth: token });
|
|
110
|
+
|
|
111
|
+
const projRes = await fetch(`${serverUrl}/projects/${projectId}`);
|
|
112
|
+
if (!projRes.ok) throw new Error(`Project "${projectId}" not found.`);
|
|
113
|
+
|
|
114
|
+
const { owner, repo } = parseProjectDescriptor(await projRes.json());
|
|
115
|
+
ctxRef.current = { octokit, owner, repo, runtimeVersion };
|
|
116
|
+
|
|
117
|
+
const { data: contents } = await octokit.repos.getContent({
|
|
118
|
+
owner,
|
|
119
|
+
repo,
|
|
120
|
+
path: runtimeVersion,
|
|
121
|
+
ref: channel,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (!Array.isArray(contents)) {
|
|
125
|
+
setItems([]);
|
|
126
|
+
setStatus("idle");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const buildFolders = contents
|
|
131
|
+
.filter((f) => f.type === "dir")
|
|
132
|
+
.map((f) => parseInt(f.name))
|
|
133
|
+
.filter((n) => !isNaN(n))
|
|
134
|
+
.sort((a, b) => b - a);
|
|
135
|
+
|
|
136
|
+
if (debug) {
|
|
137
|
+
appendDebug(`Detected builds: ${buildFolders.join(", ") || "(none)"}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const historyItems = await Promise.all(
|
|
141
|
+
buildFolders.map(async (id) => {
|
|
142
|
+
try {
|
|
143
|
+
const buildPath = `${runtimeVersion}/${id}`;
|
|
144
|
+
const { data: buildContents } = await octokit.repos.getContent({
|
|
145
|
+
owner,
|
|
146
|
+
repo,
|
|
147
|
+
path: buildPath,
|
|
148
|
+
ref: channel,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
if (!Array.isArray(buildContents)) {
|
|
152
|
+
return {
|
|
153
|
+
id,
|
|
154
|
+
type: "RELEASE" as const,
|
|
155
|
+
label: "Standard Release",
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const hasRollbackFile = buildContents.some(
|
|
160
|
+
(entry) => entry.type === "file" && entry.name === "rollback",
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
if (!hasRollbackFile) {
|
|
164
|
+
return {
|
|
165
|
+
id,
|
|
166
|
+
type: "RELEASE" as const,
|
|
167
|
+
label: "Standard Release",
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const { data: rbFile } = (await octokit.repos.getContent({
|
|
172
|
+
owner,
|
|
173
|
+
repo,
|
|
174
|
+
path: `${buildPath}/rollback`,
|
|
175
|
+
ref: channel,
|
|
176
|
+
})) as any;
|
|
177
|
+
|
|
178
|
+
const target = Buffer.from(rbFile.content, "base64")
|
|
179
|
+
.toString()
|
|
180
|
+
.trim();
|
|
181
|
+
const label =
|
|
182
|
+
target === EMBEDDED_ROLLBACK_TARGET
|
|
183
|
+
? "Rollback to EMBEDDED"
|
|
184
|
+
: `Rollback to ${target}`;
|
|
185
|
+
|
|
186
|
+
return { id, type: "ROLLBACK" as const, label };
|
|
187
|
+
} catch {
|
|
188
|
+
return { id, type: "RELEASE" as const, label: "Standard Release" };
|
|
189
|
+
}
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
setItems(
|
|
194
|
+
historyItems.map((item, index) => ({ ...item, isLive: index === 0 })),
|
|
195
|
+
);
|
|
196
|
+
setStatus("idle");
|
|
197
|
+
} catch (caughtError) {
|
|
198
|
+
const message = toErrorMessage(caughtError);
|
|
199
|
+
setError(message);
|
|
200
|
+
setStatus("error");
|
|
201
|
+
if (debug) appendDebug(`Failure: ${message}`);
|
|
202
|
+
}
|
|
203
|
+
}, [appendDebug, channel, debug]);
|
|
204
|
+
|
|
205
|
+
const deleteBuilds = React.useCallback(
|
|
206
|
+
async (buildIds: number[]) => {
|
|
207
|
+
const context = ctxRef.current;
|
|
208
|
+
if (!context) {
|
|
209
|
+
throw new Error("History context is not initialized.");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const { octokit, owner, repo, runtimeVersion } = context;
|
|
213
|
+
setStatus("deleting");
|
|
214
|
+
setError(null);
|
|
215
|
+
setLogs([]);
|
|
216
|
+
|
|
217
|
+
appendLog(`Preparing to delete builds: ${buildIds.join(", ")}`);
|
|
218
|
+
|
|
219
|
+
const { data: targetRef } = await octokit.git.getRef({
|
|
220
|
+
owner,
|
|
221
|
+
repo,
|
|
222
|
+
ref: `heads/${channel}`,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const { data: baseCommit } = await octokit.git.getCommit({
|
|
226
|
+
owner,
|
|
227
|
+
repo,
|
|
228
|
+
commit_sha: targetRef.object.sha,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const { data: fullTree } = await octokit.git.getTree({
|
|
232
|
+
owner,
|
|
233
|
+
repo,
|
|
234
|
+
tree_sha: baseCommit.tree.sha,
|
|
235
|
+
recursive: "1",
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const deletionEntries = buildIds.flatMap((buildId) => {
|
|
239
|
+
const prefix = `${runtimeVersion}/${buildId}/`;
|
|
240
|
+
const matchingFiles = fullTree.tree
|
|
241
|
+
.filter(
|
|
242
|
+
(entry) => entry.type === "blob" && entry.path?.startsWith(prefix),
|
|
243
|
+
)
|
|
244
|
+
.map((entry) => entry.path)
|
|
245
|
+
.filter((path): path is string => Boolean(path));
|
|
246
|
+
|
|
247
|
+
if (matchingFiles.length === 0) {
|
|
248
|
+
appendLog(`Build ${buildId}: no files found, skipping.`);
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
appendLog(
|
|
253
|
+
`Build ${buildId}: deleting ${matchingFiles.length} file(s).`,
|
|
254
|
+
);
|
|
255
|
+
return matchingFiles.map((path) => ({
|
|
256
|
+
path,
|
|
257
|
+
mode: "100644" as const,
|
|
258
|
+
type: "blob" as const,
|
|
259
|
+
sha: null,
|
|
260
|
+
}));
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
if (deletionEntries.length === 0) {
|
|
264
|
+
appendLog("No matching files to delete.");
|
|
265
|
+
setStatus("success");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const { data: tree } = await octokit.git.createTree({
|
|
270
|
+
owner,
|
|
271
|
+
repo,
|
|
272
|
+
base_tree: baseCommit.tree.sha,
|
|
273
|
+
tree: deletionEntries,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const { data: commit } = await octokit.git.createCommit({
|
|
277
|
+
owner,
|
|
278
|
+
repo,
|
|
279
|
+
message: `cleanup: delete build(s) ${buildIds.join(", ")} on ${channel} [cli]`,
|
|
280
|
+
tree: tree.sha,
|
|
281
|
+
parents: [targetRef.object.sha],
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
await octokit.git.updateRef({
|
|
285
|
+
owner,
|
|
286
|
+
repo,
|
|
287
|
+
ref: `heads/${channel}`,
|
|
288
|
+
sha: commit.sha,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
appendLog(`Delete commit created: ${commit.sha}`);
|
|
292
|
+
setStatus("success");
|
|
293
|
+
await loadHistory();
|
|
294
|
+
},
|
|
295
|
+
[appendLog, channel, loadHistory],
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
React.useEffect(() => {
|
|
299
|
+
loadHistory();
|
|
300
|
+
}, [loadHistory]);
|
|
301
|
+
|
|
302
|
+
React.useEffect(() => {
|
|
303
|
+
if (
|
|
304
|
+
status !== "idle" ||
|
|
305
|
+
parsedAutoDeleteIds.length === 0 ||
|
|
306
|
+
autoDeleteTriggeredRef.current
|
|
307
|
+
) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
autoDeleteTriggeredRef.current = true;
|
|
312
|
+
if (yes) {
|
|
313
|
+
deleteBuilds(parsedAutoDeleteIds).catch((caughtError) => {
|
|
314
|
+
const message = toErrorMessage(caughtError);
|
|
315
|
+
setError(message);
|
|
316
|
+
setStatus("error");
|
|
317
|
+
if (debug) appendDebug(`Delete failure: ${message}`);
|
|
318
|
+
});
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (interactiveMode) {
|
|
323
|
+
setPendingDeleteIds(parsedAutoDeleteIds);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
setError(
|
|
328
|
+
'Delete confirmation required. Re-run with "--yes" for non-interactive delete.',
|
|
329
|
+
);
|
|
330
|
+
setStatus("error");
|
|
331
|
+
}, [
|
|
332
|
+
appendDebug,
|
|
333
|
+
debug,
|
|
334
|
+
deleteBuilds,
|
|
335
|
+
interactiveMode,
|
|
336
|
+
parsedAutoDeleteIds,
|
|
337
|
+
status,
|
|
338
|
+
yes,
|
|
339
|
+
]);
|
|
340
|
+
|
|
341
|
+
useInput((input, key) => {
|
|
342
|
+
if (!interactiveMode || status !== "idle" || items.length === 0) return;
|
|
343
|
+
if (pendingDeleteIds) {
|
|
344
|
+
const normalized = input.toLowerCase();
|
|
345
|
+
|
|
346
|
+
if (normalized === "y") {
|
|
347
|
+
const confirmed = [...pendingDeleteIds];
|
|
348
|
+
setPendingDeleteIds(null);
|
|
349
|
+
deleteBuilds(confirmed).catch((caughtError) => {
|
|
350
|
+
const message = toErrorMessage(caughtError);
|
|
351
|
+
setError(message);
|
|
352
|
+
setStatus("error");
|
|
353
|
+
if (debug) appendDebug(`Delete failure: ${message}`);
|
|
354
|
+
});
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (normalized === "n" || key.return || key.escape) {
|
|
359
|
+
setPendingDeleteIds(null);
|
|
360
|
+
appendLog("Delete cancelled.");
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (key.upArrow || input === "k" || input === "w") {
|
|
367
|
+
setCursor((prev) => (prev <= 0 ? items.length - 1 : prev - 1));
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (key.downArrow || input === "j" || input === "s") {
|
|
372
|
+
setCursor((prev) => (prev >= items.length - 1 ? 0 : prev + 1));
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (input === " ") {
|
|
377
|
+
const buildId = items[cursor]?.id;
|
|
378
|
+
if (!buildId) return;
|
|
379
|
+
setSelectedBuilds((prev) => {
|
|
380
|
+
const next = new Set(prev);
|
|
381
|
+
if (next.has(buildId)) next.delete(buildId);
|
|
382
|
+
else next.add(buildId);
|
|
383
|
+
return next;
|
|
384
|
+
});
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (key.return) {
|
|
389
|
+
const selected = Array.from(selectedBuilds.values()).sort(
|
|
390
|
+
(a, b) => b - a,
|
|
391
|
+
);
|
|
392
|
+
if (selected.length === 0) return;
|
|
393
|
+
setPendingDeleteIds(selected);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (input === "r") {
|
|
398
|
+
loadHistory();
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const selectionCount = selectedBuilds.size;
|
|
403
|
+
const terminalRows = process.stdout.rows ?? 24;
|
|
404
|
+
const maxVisibleItems = Math.max(6, terminalRows - 18);
|
|
405
|
+
const visibleStart = interactiveMode
|
|
406
|
+
? Math.max(
|
|
407
|
+
0,
|
|
408
|
+
Math.min(
|
|
409
|
+
cursor - Math.floor(maxVisibleItems / 2),
|
|
410
|
+
Math.max(0, items.length - maxVisibleItems),
|
|
411
|
+
),
|
|
412
|
+
)
|
|
413
|
+
: 0;
|
|
414
|
+
const visibleEnd = Math.min(items.length, visibleStart + maxVisibleItems);
|
|
415
|
+
const visibleItems = items.slice(visibleStart, visibleEnd);
|
|
416
|
+
const hiddenAbove = visibleStart;
|
|
417
|
+
const hiddenBelow = Math.max(0, items.length - visibleEnd);
|
|
418
|
+
|
|
419
|
+
return (
|
|
420
|
+
<Box flexDirection="column" padding={1}>
|
|
421
|
+
<BrandHeader subtitle="Over-the-air updates" />
|
|
422
|
+
<CliCard title="expo-up history" subtitle="Release and rollback timeline">
|
|
423
|
+
<KV keyName="Channel" value={channel} valueColor="cyan" />
|
|
424
|
+
{parsedAutoDeleteIds.length > 0 ? (
|
|
425
|
+
<KV
|
|
426
|
+
keyName="Delete IDs"
|
|
427
|
+
value={parsedAutoDeleteIds.join(", ")}
|
|
428
|
+
valueColor="yellow"
|
|
429
|
+
/>
|
|
430
|
+
) : null}
|
|
431
|
+
</CliCard>
|
|
432
|
+
|
|
433
|
+
<CliCard title="Builds">
|
|
434
|
+
{status === "loading" && (
|
|
435
|
+
<Box>
|
|
436
|
+
<Badge label="LOADING" tone="yellow" />
|
|
437
|
+
<Text>
|
|
438
|
+
<Spinner /> Fetching history...
|
|
439
|
+
</Text>
|
|
440
|
+
</Box>
|
|
441
|
+
)}
|
|
442
|
+
|
|
443
|
+
{status === "deleting" && (
|
|
444
|
+
<Box>
|
|
445
|
+
<Badge label="DELETING" tone="yellow" />
|
|
446
|
+
<Text>
|
|
447
|
+
<Spinner /> Deleting selected builds...
|
|
448
|
+
</Text>
|
|
449
|
+
</Box>
|
|
450
|
+
)}
|
|
451
|
+
|
|
452
|
+
{status === "success" && logs.length > 0 && (
|
|
453
|
+
<Box marginBottom={1}>
|
|
454
|
+
<Badge label="SUCCESS" tone="green" />
|
|
455
|
+
<Text color="green">Delete operation completed.</Text>
|
|
456
|
+
</Box>
|
|
457
|
+
)}
|
|
458
|
+
|
|
459
|
+
{status !== "loading" && !error && items.length === 0 && (
|
|
460
|
+
<Text color="gray">No builds found on this channel.</Text>
|
|
461
|
+
)}
|
|
462
|
+
|
|
463
|
+
{hiddenAbove > 0 && (
|
|
464
|
+
<Text color="gray">... {hiddenAbove} build(s) above</Text>
|
|
465
|
+
)}
|
|
466
|
+
|
|
467
|
+
{visibleItems.map((item, idx) => {
|
|
468
|
+
const itemIndex = visibleStart + idx;
|
|
469
|
+
const isCursor = interactiveMode && cursor === itemIndex;
|
|
470
|
+
const isSelected = selectedBuilds.has(item.id);
|
|
471
|
+
const selector = interactiveMode
|
|
472
|
+
? `${isCursor ? ">" : " "} [${isSelected ? "x" : " "}]`
|
|
473
|
+
: item.isLive
|
|
474
|
+
? "●"
|
|
475
|
+
: "○";
|
|
476
|
+
|
|
477
|
+
return (
|
|
478
|
+
<Box key={item.id}>
|
|
479
|
+
<Text
|
|
480
|
+
color={isCursor ? "cyan" : item.isLive ? "green" : "white"}
|
|
481
|
+
bold={item.isLive || isCursor}
|
|
482
|
+
>
|
|
483
|
+
{selector} Build {item.id.toString().padEnd(3)}
|
|
484
|
+
</Text>
|
|
485
|
+
<Text color="gray">{" -> "}</Text>
|
|
486
|
+
<Text color={item.type === "ROLLBACK" ? "yellow" : "blue"}>
|
|
487
|
+
{item.label}
|
|
488
|
+
</Text>
|
|
489
|
+
{item.isLive && (
|
|
490
|
+
<Text color="green" bold>
|
|
491
|
+
{" "}
|
|
492
|
+
(LIVE)
|
|
493
|
+
</Text>
|
|
494
|
+
)}
|
|
495
|
+
</Box>
|
|
496
|
+
);
|
|
497
|
+
})}
|
|
498
|
+
|
|
499
|
+
{hiddenBelow > 0 && (
|
|
500
|
+
<Text color="gray">... {hiddenBelow} build(s) below</Text>
|
|
501
|
+
)}
|
|
502
|
+
|
|
503
|
+
{interactiveMode && status === "idle" && items.length > 0 && (
|
|
504
|
+
<Box marginTop={1} flexDirection="column">
|
|
505
|
+
<Text color="gray">
|
|
506
|
+
Interactive delete: ↑/↓ (or w/s, j/k) move, space select, enter
|
|
507
|
+
delete, r refresh
|
|
508
|
+
</Text>
|
|
509
|
+
<Text color="yellow">Selected: {selectionCount}</Text>
|
|
510
|
+
</Box>
|
|
511
|
+
)}
|
|
512
|
+
{pendingDeleteIds && (
|
|
513
|
+
<Box marginTop={1} flexDirection="column">
|
|
514
|
+
<Text color="yellow">
|
|
515
|
+
Confirm delete builds [{pendingDeleteIds.join(", ")}]? [y/N]
|
|
516
|
+
</Text>
|
|
517
|
+
<Text color="gray">Press y to confirm, n/enter/esc to cancel.</Text>
|
|
518
|
+
</Box>
|
|
519
|
+
)}
|
|
520
|
+
{!interactiveMode &&
|
|
521
|
+
parsedAutoDeleteIds.length === 0 &&
|
|
522
|
+
status === "idle" && (
|
|
523
|
+
<Box marginTop={1} flexDirection="column">
|
|
524
|
+
<Text color="gray">
|
|
525
|
+
Interactive mode disabled (non-TTY or CI environment).
|
|
526
|
+
</Text>
|
|
527
|
+
<Text color="gray">
|
|
528
|
+
Use --delete 18 17 13 for CI-safe cleanup.
|
|
529
|
+
</Text>
|
|
530
|
+
</Box>
|
|
531
|
+
)}
|
|
532
|
+
|
|
533
|
+
{logs.map((line, index) => (
|
|
534
|
+
<Text key={`${line}-${index}`} color="gray">{`• ${line}`}</Text>
|
|
535
|
+
))}
|
|
536
|
+
|
|
537
|
+
{error && (
|
|
538
|
+
<Box>
|
|
539
|
+
<Badge label="FAILED" tone="red" />
|
|
540
|
+
<Text color="red">{error}</Text>
|
|
541
|
+
</Box>
|
|
542
|
+
)}
|
|
543
|
+
</CliCard>
|
|
544
|
+
|
|
545
|
+
{debug && (
|
|
546
|
+
<CliCard title="Debug Logs" subtitle="Verbose diagnostics">
|
|
547
|
+
{debugLogs.length === 0 ? (
|
|
548
|
+
<Text color="gray">No debug logs yet.</Text>
|
|
549
|
+
) : null}
|
|
550
|
+
{debugLogs.map((line, i) => (
|
|
551
|
+
<Text key={i} color="gray">
|
|
552
|
+
{line}
|
|
553
|
+
</Text>
|
|
554
|
+
))}
|
|
555
|
+
</CliCard>
|
|
556
|
+
)}
|
|
557
|
+
</Box>
|
|
558
|
+
);
|
|
559
|
+
};
|