diff --git a/install.sh b/install.sh index 4d25dc8..3649cad 100755 --- a/install.sh +++ b/install.sh @@ -15,8 +15,32 @@ set -eu +# Colours are on only for a real terminal: not when output is piped or logged +# (CI), not when NO_COLOR is set (https://no-color.org), and not on a dumb +# terminal. stdout and stderr are checked separately, because either can be +# redirected on its own. With colours off every variable is empty, so the +# output is the same text with no escape codes. +setup_colors() { + BOLD='' DIM='' GREEN='' YELLOW='' CYAN='' RESET='' + ERR_RED='' ERR_RESET='' + if [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != dumb ]; then + if [ -t 1 ]; then + BOLD=$(printf '\033[1m') + DIM=$(printf '\033[2m') + GREEN=$(printf '\033[32m') + YELLOW=$(printf '\033[33m') + CYAN=$(printf '\033[36m') + RESET=$(printf '\033[0m') + fi + if [ -t 2 ]; then + ERR_RED=$(printf '\033[31m') + ERR_RESET=$(printf '\033[0m') + fi + fi +} + err() { - printf 'error: %s\n' "$1" >&2 + printf '%s✗ error:%s %s\n' "$ERR_RED" "$ERR_RESET" "$1" >&2 exit 1 } @@ -24,12 +48,39 @@ info() { printf '%s\n' "$1" } +# The same tree shape the CLI itself prints (see STYLE_GUIDE.md): a `⏺` heading +# for each stage, with `⎿` detail rows beneath it. +step() { + printf '\n%s⏺%s %s%s%s\n' "$DIM" "$RESET" "$BOLD" "$1" "$RESET" +} + +detail() { + printf ' %s⎿%s %s\n' "$DIM" "$RESET" "$1" +} + +# A second row under detail(), aligned with its text. +detail_more() { + printf ' %s\n' "$1" +} + +success() { + printf ' %s✓%s %s\n' "$GREEN" "$RESET" "$1" +} + +warn() { + printf '%s!%s %s\n' "$YELLOW" "$RESET" "$1" +} + +banner() { + printf '\n %sdcd%s %s·%s DeviceCloud installer\n' "$BOLD$CYAN" "$RESET" "$DIM" "$RESET" +} + # Stable is the default channel and beta is strictly opt-in, so when no stable # release exists yet (only prereleases published) we refuse to silently install a # beta and instead point the user at the two explicit opt-ins. $DOWNLOAD_BASE is # echoed so a custom host shows the right command. no_stable_release_err() { - printf 'error: No stable dcd release is available yet.\n' >&2 + printf '%s✗ error:%s No stable dcd release is available yet.\n' "$ERR_RED" "$ERR_RESET" >&2 printf ' Install the latest beta: curl -fsSL %s/install.sh | DCD_BETA=1 sh\n' "$DOWNLOAD_BASE" >&2 printf ' Or pin a version: curl -fsSL %s/install.sh | DCD_VERSION=5.0.0-beta.1 sh\n' "$DOWNLOAD_BASE" >&2 exit 1 @@ -112,9 +163,12 @@ persist_path() { } main() { + setup_colors DOWNLOAD_BASE="${DCD_DOWNLOAD_BASE:-https://get.devicecloud.dev}" INSTALL_DIR="${DCD_INSTALL_DIR:-$HOME/.dcd/bin}" + banner + # --- detect platform --- os=$(uname -s) case "$os" in @@ -134,17 +188,17 @@ main() { # --- resolve version --- # Precedence: explicit DCD_VERSION pin > DCD_BETA opt-in > latest stable. + step "Resolving version" if [ -n "${DCD_VERSION:-}" ]; then version="$DCD_VERSION" + detail "${CYAN}${version}${RESET} ${DIM}(pinned by DCD_VERSION)${RESET}" else if [ -n "${DCD_BETA:-}" ]; then channel=beta manifest_url="$DOWNLOAD_BASE/latest.json?channel=beta" - info "Resolving latest beta version..." else channel=stable manifest_url="$DOWNLOAD_BASE/latest.json" - info "Resolving latest version..." fi # Fetch the manifest separately from parsing so we can tell a transient @@ -167,14 +221,15 @@ main() { err "No beta release is available yet from $manifest_url" fi fi + detail "${CYAN}${version}${RESET} ${DIM}(latest ${channel})${RESET}" fi url="$DOWNLOAD_BASE/download/${version}/${asset}" sums_url="$DOWNLOAD_BASE/download/${version}/SHA256SUMS" - info "Installing dcd ${version} (${os_id}-${arch_id})" - info " from: $url" - info " to: $INSTALL_DIR/dcd" + step "Downloading dcd ${version} ${DIM}(${os_id}-${arch_id})${RESET}" + detail "${DIM}from${RESET} $url" + detail_more "${DIM}to${RESET} $INSTALL_DIR/dcd" # --- download --- mkdir -p "$INSTALL_DIR" @@ -182,6 +237,7 @@ main() { trap 'rm -f "$tmp" "$tmp.sums"' EXIT curl -fSL --progress-bar "$url" -o "$tmp" \ || err "Download failed: $url" + success "Downloaded" # --- verify checksum --- curl -fsSL "$sums_url" -o "$tmp.sums" \ @@ -200,6 +256,7 @@ main() { if [ "$expected" != "$actual" ]; then err "Checksum mismatch for $asset: expected $expected, got $actual" fi + success "Checksum verified ${DIM}(SHA-256)${RESET}" # --- install --- chmod +x "$tmp" @@ -213,36 +270,51 @@ main() { *) on_path=0 ;; esac - info "" - info "✓ Installed dcd $version to $INSTALL_DIR/dcd" + success "Installed to $INSTALL_DIR/dcd" + # Work out the PATH outcome now, but print it in the "next steps" block + # below, so the install log above stays a plain record of what happened. + path_state=on_path if [ "$on_path" -eq 0 ]; then rc=$(rc_file) if persist_path "$rc"; then - info "" - info " Added $INSTALL_DIR to your PATH in $rc." - info " Restart your shell, or run: $path_line" + path_state=persisted else - info "" - info " $INSTALL_DIR is not on your PATH. Add this to your shell rc:" - info " $path_line" + path_state=manual fi fi # --- warn about a conflicting (shadowing) install --- if conflict=$(find_conflicting_dcd); then info "" - info "! Another dcd is already on your PATH:" + warn "Another dcd is already on your PATH:" info " $conflict" info " This is usually a previous 'npm install -g @devicecloud.dev/dcd', which" info " can shadow this binary depending on PATH order. Remove it with:" - info " npm uninstall -g @devicecloud.dev/dcd" + info " ${CYAN}npm uninstall -g @devicecloud.dev/dcd${RESET}" fi - if [ "$on_path" -eq 1 ]; then - info "" - info " Try: dcd --help" - fi + # --- next steps --- + info "" + info " ${GREEN}${BOLD}dcd ${version} is installed.${RESET}" + info "" + case "$path_state" in + persisted) + info " Added $INSTALL_DIR to your PATH in ${BOLD}$rc${RESET}." + info " Restart your shell, or run:" + info " ${CYAN}$path_line${RESET}" + info "" + ;; + manual) + info " $INSTALL_DIR is not on your PATH. Add this to your shell rc:" + info " ${CYAN}$path_line${RESET}" + info "" + ;; + esac + info " Get started:" + info " ${CYAN}dcd login${RESET} ${DIM}sign in to DeviceCloud${RESET}" + info " ${CYAN}dcd --help${RESET} ${DIM}see every command${RESET}" + info "" } main "$@" diff --git a/src/methods.ts b/src/methods.ts index 3634c06..c1d6839 100644 --- a/src/methods.ts +++ b/src/methods.ts @@ -195,7 +195,7 @@ export const uploadBinary = async (config: UploadBinaryConfig) => { ux.info( colors.dim('SHA hash matches existing binary with ID: ') + formatId(binaryId) + colors.dim(', skipping upload. Force upload with --ignore-sha-check'), ); - ux.action.stop(colors.info('Skipping upload')); + ux.action.stop('Skipping upload'); } return binaryId; @@ -233,13 +233,13 @@ export const uploadBinary = async (config: UploadBinaryConfig) => { }); if (log) { - ux.action.stop(colors.success('\n✓ Binary uploaded with ID: ') + formatId(uploadId)); + ux.action.stop(colors.success('Binary uploaded with ID: ') + formatId(uploadId)); } return uploadId; } catch (error) { if (log) { - ux.action.stop(colors.error('✗ Failed')); + ux.action.fail(colors.error('Failed')); } if (debug) { diff --git a/src/services/moropo.service.ts b/src/services/moropo.service.ts index 57873d3..89e877b 100644 --- a/src/services/moropo.service.ts +++ b/src/services/moropo.service.ts @@ -92,7 +92,7 @@ export class MoropoService { return moropoDir; } catch (error) { if (!quiet && !json) { - ux.action.stop('failed'); + ux.action.fail('failed'); } // Remove the temp directory (and any partially-written zip inside it) diff --git a/src/services/results-polling.service.ts b/src/services/results-polling.service.ts index d411451..04589ea 100644 --- a/src/services/results-polling.service.ts +++ b/src/services/results-polling.service.ts @@ -196,7 +196,7 @@ export class ResultsPollingService { // displayFinalResults. Anything else aborts mid-poll with the spinner // still live, which would corrupt the terminal under the error output. if (!options.json && !(error instanceof RunFailedError)) { - ux.action.stop(colors.error('failed')); + ux.action.fail(colors.error('failed')); } throw error; } diff --git a/src/utils/progress.ts b/src/utils/progress.ts index fcf8d45..099b6b8 100644 --- a/src/utils/progress.ts +++ b/src/utils/progress.ts @@ -10,13 +10,25 @@ * non-interactive environments we skip the spinner entirely and instead print a * plain line once per *distinct* status, so CI logs show real progress without * the flood. + * + * Styling: the spinner uses the CLI's own palette (cyan braille frames) rather + * than clack's magenta default, and no `│` guide bar, since the rest of the CLI + * is a `⏺`/`⎿` tree rather than a clack session. The final line is written by + * us, not clack, so it carries the same `✓` / `✗` symbols as every other + * command (see STYLE_GUIDE.md). */ import * as p from '@clack/prompts'; import { isCI } from './ci.js'; +import { colors, symbols } from './styling.js'; type ClackSpinner = ReturnType; +// Braille dots where the terminal can draw them, plain ASCII otherwise. +const FRAMES = p.unicode + ? ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + : ['-', '\\', '|', '/']; + class Action { private current: ClackSpinner | null = null; private _status = ''; @@ -29,37 +41,32 @@ class Action { start(title: string, initialStatus?: string, _opts?: unknown): void { this._status = initialStatus ?? ''; - const line = initialStatus ? `${title} — ${initialStatus}` : title; + const line = initialStatus ? `${title} ${colors.dim('·')} ${initialStatus}` : title; if (!this.interactive()) { this.current = null; this.print(line); return; } if (this.current) { - this.current.stop(); + this.current.clear(); } - this.current = p.spinner(); + this.current = p.spinner({ + delay: 80, + frames: FRAMES, + styleFrame: colors.highlight, + withGuide: false, + }); this.current.start(line); } + /** End the action as a success: `✓ message`. */ stop(message?: string): void { - if (!this.interactive()) { - if (message) this.print(message); - this.current = null; - this._status = ''; - this._lastPrinted = ''; - return; - } - if (!this.current) { - if (message) { - // eslint-disable-next-line no-console - console.log(message); - } - return; - } - this.current.stop(message ?? ''); - this.current = null; - this._status = ''; + this.finish(symbols.success, message); + } + + /** End the action as a failure: `✗ message`. */ + fail(message?: string): void { + this.finish(symbols.error, message); } set status(value: string) { @@ -78,6 +85,26 @@ class Action { return this._status; } + // Erase the spinner (if one is live) and print the final line ourselves, so + // the symbol matches the rest of the CLI instead of clack's `◇`. + private finish(symbol: string, message?: string): void { + const line = message ? `${symbol} ${message}` : ''; + if (!this.interactive()) { + if (line) this.print(line); + this.current = null; + this._status = ''; + this._lastPrinted = ''; + return; + } + this.current?.clear(); + this.current = null; + this._status = ''; + if (line) { + // eslint-disable-next-line no-console + console.log(line); + } + } + // Emit a line only when it differs from the previous one, so repeated polls // with no state change stay quiet. private print(line: string): void { diff --git a/test/unit/progress.test.ts b/test/unit/progress.test.ts new file mode 100644 index 0000000..686ae99 --- /dev/null +++ b/test/unit/progress.test.ts @@ -0,0 +1,52 @@ +import { expect } from 'chai'; + +import { stripAnsi } from '../../src/utils/styling.js'; +import { ux } from '../../src/utils/progress.js'; + +// The non-interactive path (CI, pipes) prints plain lines instead of animating, +// so it can be asserted on directly by capturing console.log. +describe('progress (non-interactive)', () => { + const originalLog = console.log; + const originalCI = process.env.CI; + let lines: string[]; + + beforeEach(() => { + process.env.CI = 'true'; + lines = []; + console.log = (line: string) => { + lines.push(stripAnsi(line)); + }; + }); + + afterEach(() => { + console.log = originalLog; + if (originalCI === undefined) { + delete process.env.CI; + } else { + process.env.CI = originalCI; + } + }); + + it('prints the title and initial status on one line', () => { + ux.action.start('Waiting for results', 'Initializing'); + ux.action.stop(); + expect(lines).to.deep.equal(['Waiting for results · Initializing']); + }); + + it('prints each distinct status once', () => { + ux.action.start('Uploading'); + ux.action.status = '10%'; + ux.action.status = '10%'; + ux.action.status = '50%'; + ux.action.stop(); + expect(lines).to.deep.equal(['Uploading', '10%', '50%']); + }); + + it('marks a stop with ✓ and a failure with ✗', () => { + ux.action.start('Uploading'); + ux.action.stop('completed'); + ux.action.start('Uploading'); + ux.action.fail('failed'); + expect(lines).to.deep.equal(['Uploading', '✓ completed', 'Uploading', '✗ failed']); + }); +});