Repository navigation
Expand file tree
/
Copy pathinstall.sh
More file actions
executable file
·325 lines (287 loc) · 10.4 KB
/
Copy pathinstall.sh
File metadata and controls
executable file
·325 lines (287 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
#!/usr/bin/env sh
# dcd installer for macOS and Linux.
#
# Usage:
# curl -fsSL https://get.devicecloud.dev/install.sh | sh
#
# Env vars:
# DCD_VERSION Pin a specific version, e.g. for rollback (default: latest stable)
# DCD_BETA Set to any value to install the latest beta/prerelease (opt-in)
# DCD_INSTALL_DIR Override install location (default: $HOME/.dcd/bin)
# DCD_DOWNLOAD_BASE Override the download host (default: https://get.devicecloud.dev)
#
# The whole script is wrapped in main() and only invoked on the last line, so
# a truncated download (curl | sh executes as it streams) runs nothing at all.
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 '%s✗ error:%s %s\n' "$ERR_RED" "$ERR_RESET" "$1" >&2
exit 1
}
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 '%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
}
# Find a dcd on PATH other than the one we just installed — usually a leftover
# `npm install -g @devicecloud.dev/dcd` that can shadow this binary. Runs in a
# subshell so the temporary IFS change never leaks back to the caller.
find_conflicting_dcd() {
(
IFS=:
for dir in $PATH; do
[ -n "$dir" ] || continue
if [ "$dir" != "$INSTALL_DIR" ] && [ -x "$dir/dcd" ]; then
printf '%s\n' "$dir/dcd"
exit 0
fi
done
exit 1
)
}
# Pick the shell rc file to persist PATH into, based on the login shell.
rc_file() {
case "${SHELL:-}" in
*zsh)
printf '%s\n' "${ZDOTDIR:-$HOME}/.zshrc"
;;
*bash)
if [ -f "$HOME/.bashrc" ]; then
printf '%s\n' "$HOME/.bashrc"
else
printf '%s\n' "$HOME/.bash_profile"
fi
;;
*)
printf '%s\n' "$HOME/.profile"
;;
esac
}
# Persist INSTALL_DIR onto PATH in the given rc file, idempotently. Anything a
# previous run wrote is stripped first — the current sentinel-delimited block,
# and the legacy bare "# dcd" marker with its export line — so re-running the
# installer never accumulates duplicate markers or export lines. Trailing blank
# lines are trimmed so the rc doesn't grow a gap on each run. Returns non-zero
# (leaving the rc untouched) if it can't be written.
persist_path() {
rc="$1"
begin='# >>> dcd installer >>>'
end='# <<< dcd installer <<<'
if [ ! -e "$rc" ]; then
: >> "$rc" 2>/dev/null || return 1
fi
[ -w "$rc" ] || return 1
cleaned=$(mktemp "${TMPDIR:-/tmp}/dcd-rc-XXXXXX") || return 1
# Drop our managed block (between the sentinels) and any legacy lines we may
# have written before, then trim trailing blanks. buf[] preserves order.
awk -v dir="$INSTALL_DIR" -v b="$begin" -v e="$end" '
$0 == b { skip = 1; next }
$0 == e { skip = 0; next }
skip { next }
$0 == "# dcd" { next }
$0 == "export PATH=\"" dir ":$PATH\"" { next }
{ buf[++n] = $0 }
END {
while (n > 0 && buf[n] ~ /^[[:space:]]*$/) n--
for (i = 1; i <= n; i++) print buf[i]
}
' "$rc" > "$cleaned" || { rm -f "$cleaned"; return 1; }
printf '\n%s\n%s\n%s\n' "$begin" "$path_line" "$end" >> "$cleaned" \
|| { rm -f "$cleaned"; return 1; }
# Truncate-and-rewrite so the rc keeps its original inode and permissions.
cat "$cleaned" > "$rc" || { rm -f "$cleaned"; return 1; }
rm -f "$cleaned"
}
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
Darwin) os_id=darwin ;;
Linux) os_id=linux ;;
*) err "Unsupported OS: $os. Try the Windows installer (install.ps1)." ;;
esac
arch=$(uname -m)
case "$arch" in
arm64|aarch64) arch_id=arm64 ;;
x86_64|amd64) arch_id=x64 ;;
*) err "Unsupported architecture: $arch" ;;
esac
asset="dcd-${os_id}-${arch_id}"
# --- 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"
else
channel=stable
manifest_url="$DOWNLOAD_BASE/latest.json"
fi
# Fetch the manifest separately from parsing so we can tell a transient
# network/proxy failure (curl -f returns non-zero → empty $manifest) apart
# from a channel that simply has no release yet (HTTP 200 with
# "version": null → $manifest non-empty but $version empty).
manifest=$(curl -fsSL "$manifest_url") || manifest=""
[ -z "$manifest" ] && err "Could not reach $manifest_url"
# /latest.json returns { "version": "5.1.0", ... }; a null version is unquoted
# and so won't match this quoted-string pattern.
version=$(
printf '%s' "$manifest" \
| sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
| head -n1
)
if [ -z "$version" ]; then
if [ "$channel" = stable ]; then
no_stable_release_err
else
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"
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"
tmp=$(mktemp "${TMPDIR:-/tmp}/dcd-XXXXXX")
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" \
|| err "Could not fetch checksums: $sums_url"
expected=$(grep -F " ${asset}" "$tmp.sums" | awk '{print $1}' | head -n1)
[ -z "$expected" ] && err "SHA256SUMS has no entry for $asset"
if command -v sha256sum >/dev/null 2>&1; then
actual=$(sha256sum "$tmp" | awk '{print $1}')
elif command -v shasum >/dev/null 2>&1; then
actual=$(shasum -a 256 "$tmp" | awk '{print $1}')
else
err "Need sha256sum or shasum to verify download"
fi
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"
mv "$tmp" "$INSTALL_DIR/dcd"
trap - EXIT # tmp has been moved; nothing to clean up
# --- PATH setup ---
path_line="export PATH=\"$INSTALL_DIR:\$PATH\""
case ":$PATH:" in
*":$INSTALL_DIR:"*) on_path=1 ;;
*) on_path=0 ;;
esac
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
path_state=persisted
else
path_state=manual
fi
fi
# --- warn about a conflicting (shadowing) install ---
if conflict=$(find_conflicting_dcd); then
info ""
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 " ${CYAN}npm uninstall -g @devicecloud.dev/dcd${RESET}"
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 mcp install${RESET} ${DIM}connect an AI agent (Claude, Cursor, VS Code)${RESET}"
info " ${CYAN}dcd --help${RESET} ${DIM}see every command${RESET}"
info ""
# Desktop apps don't read the shell rc that puts dcd on PATH, so an MCP
# config written by hand needs the full path (dcd mcp install uses it).
info " ${DIM}In an MCP config file, start dcd by its full path: $INSTALL_DIR/dcd${RESET}"
info ""
}
main "$@"