Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.21.0
0.22.3
3 changes: 2 additions & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nsyte/cli",
"version": "0.21.0",
"version": "0.22.3",
"description": "nsyte - publish your site to nostr and blossom servers",
"license": "MIT",
"exports": "./src/cli.ts",
Expand All @@ -13,6 +13,7 @@
"coverage": "deno test --allow-read --allow-write --allow-net --allow-env --allow-import --coverage=test-output/coverage --no-check",
"coverage:badge": "deno run --allow-read --allow-write --allow-run scripts/generate-coverage-badge.ts",
"coverage:report": "deno task coverage && deno task coverage:badge",
"version": "deno run --allow-read --allow-write --allow-run --allow-env scripts/sync-version.ts",
"compile": "deno compile --allow-run --allow-read --allow-write --allow-net --allow-env --allow-sys --output dist/nsyte src/cli.ts",
"compile:all": "deno task compile:linux && deno task compile:macos && deno task compile:windows",
"compile:linux": "deno compile --no-check --allow-run --allow-read --allow-write --allow-net --allow-env --allow-sys --target x86_64-unknown-linux-gnu --output dist/nsyte-linux src/cli.ts",
Expand Down
26 changes: 26 additions & 0 deletions scripts/sync-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,29 @@ const denoJsonPath = "deno.json";
const denoJson = JSON.parse(await Deno.readTextFile(denoJsonPath));
denoJson.version = version;
await Deno.writeTextFile(denoJsonPath, JSON.stringify(denoJson, null, 2) + "\n");

// Git commit and tag
const tag = `v${version}`;
const commitMsg = `chore: bump version to ${version}`;

const add = new Deno.Command("git", { args: ["add", "VERSION", "src/version.ts", "deno.json"] });
const addResult = await add.output();
if (!addResult.success) {
console.error("git add failed");
Deno.exit(1);
}

const commit = new Deno.Command("git", { args: ["commit", "-m", commitMsg] });
const commitResult = await commit.output();
if (!commitResult.success) {
console.error("git commit failed (maybe no changes?)");
}

const gitTag = new Deno.Command("git", { args: ["tag", tag] });
const tagResult = await gitTag.output();
if (!tagResult.success) {
console.error(`git tag ${tag} failed (tag may already exist)`);
Deno.exit(1);
}

console.log(`Version synced and tagged as ${tag}`);
19 changes: 16 additions & 3 deletions src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1493,7 +1493,9 @@ async function uploadFiles(
flushQueuedLogs();
console.log("");

if (uploadedCount === preparedFiles.length) {
const allSucceeded = uploadedCount === preparedFiles.length &&
uploadResponses.length === preparedFiles.length;
if (allSucceeded) {
const msg = `${uploadedCount} files uploaded successfully (${formatFileSize(uploadedSize)})`;
progressRenderer.complete(true, msg);
} else if (uploadedCount > 0) {
Expand Down Expand Up @@ -1523,11 +1525,14 @@ async function uploadFiles(

if (uploadedCount > 0) {
console.log(formatSectionHeader("Blobs Upload Results (Blossom)"));
if (uploadedCount === preparedFiles.length) {
if (allSucceeded) {
console.log(colors.green(`✓ All ${uploadedCount} files successfully uploaded`));
} else {
const failedCount = preparedFiles.length - uploadedCount;
console.log(
colors.yellow(`${uploadedCount}/${preparedFiles.length} files successfully uploaded`),
colors.yellow(
`${uploadedCount}/${preparedFiles.length} blobs uploaded, ${failedCount} failed`,
),
);
}
messageCollector.printFileSuccessSummary();
Expand All @@ -1553,6 +1558,14 @@ async function uploadFiles(
}
}
console.log(formatServerResults(serverResults));

const totalBlobs = uploadResponses.length;
const successBlobs = uploadResponses.filter((r) => r.success).length;
const pct = totalBlobs === 0 ? 100 : Math.round((successBlobs / totalBlobs) * 100);
const colorFn = pct === 100 ? colors.green : pct > 0 ? colors.yellow : colors.red;
console.log(
colorFn(`Overall: ${successBlobs}/${totalBlobs} blobs on at least one server (${pct}%)`),
);
console.log("");

// Create and publish site manifest event after all files are uploaded
Expand Down
20 changes: 18 additions & 2 deletions src/lib/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export interface UploadProgress {
failed: number;
inProgress: number;
skipped: number;
retrying: number;
}

export type UploadResponse = {
Expand Down Expand Up @@ -395,6 +396,7 @@ export async function processUploads(
failed: 0,
inProgress: 0,
skipped: 0,
retrying: 0,
};

if (progressCallback) {
Expand Down Expand Up @@ -446,7 +448,12 @@ export async function processUploads(
const chunkResults = await Promise.all(
chunk.map(async (file) => {
try {
return await uploadFile(file, baseDir, servers, authTokenMap, relays, userPubkey);
return await uploadFile(file, baseDir, servers, authTokenMap, relays, userPubkey, 0, (delta) => {
progress.retrying += delta;
if (progressCallback) {
progressCallback({ ...progress });
}
});
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);

Expand Down Expand Up @@ -545,6 +552,7 @@ async function uploadFile(
relays: string[],
userPubkey: string,
retryCount = 0,
onRetry?: (delta: number) => void,
): Promise<UploadResponse> {
// Ensure serverResults is visible in catch blocks
const serverResults: {
Expand Down Expand Up @@ -647,7 +655,15 @@ async function uploadFile(
`Retrying upload for ${file.path} (attempt ${retryCount + 1}/${MAX_RETRIES})`,
);
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
return uploadFile(file, baseDir, servers, authTokenMap, relays, userPubkey, retryCount + 1);
onRetry?.(1);
try {
const result = await uploadFile(file, baseDir, servers, authTokenMap, relays, userPubkey, retryCount + 1, onRetry);
onRetry?.(-1);
return result;
} catch (e) {
onRetry?.(-1);
throw e;
}
}

return {
Expand Down
53 changes: 48 additions & 5 deletions src/ui/progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ const PROGRESS_BAR_WIDTH = 30;
const PROGRESS_CHAR = "█";
const INCOMPLETE_CHAR = "░";

function stripAnsi(text: string): string {
return text.replace(/\x1b\[[0-9;]*m/g, "");
}

/**
* Format a progress bar with colored output
*/
Expand Down Expand Up @@ -54,6 +58,7 @@ interface ProgressData {
failed: number;
inProgress: number;
skipped?: number;
retrying?: number;
serverStats?: {
[filename: string]: {
successCount: number;
Expand Down Expand Up @@ -171,24 +176,62 @@ export class ProgressRenderer {
eta = etaSeconds <= 0 ? "0s" : `${etaSeconds}s`;
}

const filledLength = Math.floor((percent * this.barLength) / 100);
const bar = "█".repeat(filledLength) + "░".repeat(this.barLength - filledLength);
let greenW = 0;
let yellowW = 0;
let redW = 0;
let grayW = this.barLength;

if (data.total > 0) {
greenW = Math.floor((data.completed / data.total) * this.barLength);
yellowW = Math.floor(((data.retrying ?? 0) / data.total) * this.barLength);
redW = Math.floor((data.failed / data.total) * this.barLength);
grayW = Math.max(0, this.barLength - greenW - yellowW - redW);
}

const bar = colors.green("█".repeat(greenW))
+ colors.yellow("█".repeat(yellowW))
+ colors.red("█".repeat(redW))
+ "░".repeat(grayW);

let serverInfo = "";
if (data.serverStats) {
const entries = Object.entries(data.serverStats);
if (entries.length > 0) {
const latestFile = entries[entries.length - 1];
const [filename, stats] = latestFile;
serverInfo = ` | ${
serverInfo = `${
colors.cyan(`${stats.successCount}/${stats.totalServers}`)
} servers for ${filename.split("/").pop()}`;
}
}

const skipped = data.skipped ?? 0;
const progressText =
`[${bar}] ${percent}% | ${done}/${data.total} files | ${data.completed} succeeded, ${skipped} skipped, ${data.failed} failed, ${data.inProgress} in progress | Elapsed: ${elapsed}s | ETA: ${eta}${serverInfo}`;
const retrying = data.retrying ?? 0;

// Build progress text with segments that can be dropped to fit terminal width
const segments = [
`[${bar}] ${percent}%`,
`${done}/${data.total} files`,
`${data.completed} ok, ${skipped} skip, ${retrying} retry, ${data.failed} fail, ${data.inProgress} active`,
`${elapsed}s`,
`ETA: ${eta}`,
];
if (serverInfo) segments.push(serverInfo);

let progressText = segments.join(" | ");

// Truncate to terminal width to prevent line wrapping, which breaks \r overwrite
try {
const { columns } = Deno.consoleSize();
if (columns > 0) {
while (segments.length > 1 && stripAnsi(segments.join(" | ")).length > columns) {
segments.pop();
}
progressText = segments.join(" | ");
}
} catch {
// consoleSize() throws if not a TTY — just write the full text
}

Deno.stdout.writeSync(new TextEncoder().encode(progressText));

Expand Down
2 changes: 1 addition & 1 deletion src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const version = "0.21.0";
export const version = "0.22.3";
140 changes: 140 additions & 0 deletions tests/unit/ui_progress_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,146 @@ Deno.test("UI Progress - ProgressRenderer", async (t) => {
});
});

Deno.test("UI Progress - ProgressRenderer colored bar and retry count", async (t) => {
let stdoutStub: any;

await t.step("should show retry count in progress text", () => {
restore();
stdoutStub = stub(Deno.stdout, "writeSync", () => 0);
// Stub consoleSize to return wide terminal so truncation doesn't drop segments
stub(Deno, "consoleSize", () => ({ columns: 300, rows: 50 }));

const progress = new ProgressRenderer(10);
progress.update({
total: 10,
completed: 5,
failed: 1,
inProgress: 2,
retrying: 2,
});

let found = false;
for (let i = 0; i < stdoutStub.calls.length; i++) {
const output = new TextDecoder().decode(stdoutStub.calls[i].args[0]);
if (output.includes("retry")) {
assertStringIncludes(output, "2 retry");
found = true;
break;
}
}
assertEquals(found, true, "Should find retry count in output");

stdoutStub.restore();
});

await t.step("should render green segments for completed files", () => {
restore();
stdoutStub = stub(Deno.stdout, "writeSync", () => 0);

const progress = new ProgressRenderer(10);
progress.update({
total: 10,
completed: 10,
failed: 0,
inProgress: 0,
});

// All 30 bar chars should be green (ANSI green escape)
let found = false;
for (let i = 0; i < stdoutStub.calls.length; i++) {
const output = new TextDecoder().decode(stdoutStub.calls[i].args[0]);
if (output.includes("█")) {
// Should contain green ANSI code and not contain red blocks
assertStringIncludes(output, "\x1b[32m"); // green
found = true;
break;
}
}
assertEquals(found, true, "Should find green bar segments");

stdoutStub.restore();
});

await t.step("should render red segments for failed files", () => {
restore();
stdoutStub = stub(Deno.stdout, "writeSync", () => 0);

const progress = new ProgressRenderer(10);
progress.update({
total: 10,
completed: 5,
failed: 5,
inProgress: 0,
});

let found = false;
for (let i = 0; i < stdoutStub.calls.length; i++) {
const output = new TextDecoder().decode(stdoutStub.calls[i].args[0]);
if (output.includes("█")) {
assertStringIncludes(output, "\x1b[31m"); // red
found = true;
break;
}
}
assertEquals(found, true, "Should find red bar segments");

stdoutStub.restore();
});

await t.step("should render yellow segments for retrying files", () => {
restore();
stdoutStub = stub(Deno.stdout, "writeSync", () => 0);

const progress = new ProgressRenderer(10);
progress.update({
total: 10,
completed: 3,
failed: 0,
inProgress: 4,
retrying: 3,
});

let found = false;
for (let i = 0; i < stdoutStub.calls.length; i++) {
const output = new TextDecoder().decode(stdoutStub.calls[i].args[0]);
if (output.includes("█")) {
assertStringIncludes(output, "\x1b[33m"); // yellow
found = true;
break;
}
}
assertEquals(found, true, "Should find yellow bar segments");

stdoutStub.restore();
});

await t.step("should default retrying to 0 when not provided", () => {
restore();
stdoutStub = stub(Deno.stdout, "writeSync", () => 0);

const progress = new ProgressRenderer(10);
progress.update({
total: 10,
completed: 5,
failed: 0,
inProgress: 5,
});

let found = false;
for (let i = 0; i < stdoutStub.calls.length; i++) {
const output = new TextDecoder().decode(stdoutStub.calls[i].args[0]);
if (output.includes("retry")) {
assertStringIncludes(output, "0 retry");
found = true;
break;
}
}
assertEquals(found, true, "Should show 0 retry when not provided");

stdoutStub.restore();
});
});

// Clean up
Deno.test("Cleanup", () => {
restore();
Expand Down