drafted 1.18.1 → 1.18.2
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/README.md +28 -16
- package/agent-instructions/global.md +3 -0
- package/cli/repo-scan.mjs +156 -0
- package/install-mcp.sh +1269 -0
- package/mcp/active-project-store.mjs +81 -0
- package/mcp/gates.mjs +187 -0
- package/mcp/server.mjs +115 -25
- package/mcp/test-file-path-text.mjs +58 -0
- package/mcp/test-org-guards.mjs +172 -0
- package/mcp/test-project-index.mjs +88 -0
- package/mcp/widgets/canvas-overview.html +229 -0
- package/mcp/widgets/frame-preview.html +162 -0
- package/package.json +80 -16
- package/plugin/commands/create-project.md +20 -0
- package/plugin/commands/create-skill.md +18 -0
- package/plugin/commands/extract.md +16 -0
- package/plugin/commands/improve-project-harness.md +16 -0
- package/plugin/commands/improve-skill.md +14 -0
- package/plugin/commands/improve-wiki.md +14 -0
- package/plugin/commands/ingest.md +20 -0
- package/plugin/commands/onboard-drafted.md +17 -0
- package/plugin/skills/drafted/SKILL.md +90 -0
- package/server/lib/umami.mjs +162 -0
- package/src/shared/excalidraw.mjs +84 -0
- package/src/shared/gate-budget.mjs +30 -0
- package/src/shared/minion-presets.mjs +67 -0
- package/src/shared/okf-log.mjs +62 -0
- package/src/shared/record-conformance.mjs +167 -0
- package/src/shared/test-excalidraw-merge.mjs +53 -0
- package/src/shared/wiki-excalidraw.mjs +90 -0
- package/skills/import-website-to-drafted.md +0 -317
- /package/{shared → src/shared}/constants.mjs +0 -0
package/install-mcp.sh
ADDED
|
@@ -0,0 +1,1269 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
# Drafted — Shared surface for AI-human collaboration
|
|
5
|
+
# This script installs the Drafted MCP server and CLI globally via npm,
|
|
6
|
+
# then registers it with Claude Desktop, Claude Code, Codex, and Cursor.
|
|
7
|
+
#
|
|
8
|
+
# Run with:
|
|
9
|
+
# curl -fsSL https://drafted.live/install.sh | bash
|
|
10
|
+
|
|
11
|
+
SERVER="https://drafted.live"
|
|
12
|
+
INSTALLER_VERSION="1"
|
|
13
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
14
|
+
INSTALL_MODE="production"
|
|
15
|
+
INSTALL_NAME="${DRAFTED_MCP_NAME:-drafted}"
|
|
16
|
+
INSTALL_SERVER="${DRAFTED_SERVER:-$SERVER}"
|
|
17
|
+
INSTALL_AUTH_FILE="${DRAFTED_AUTH_FILE:-}"
|
|
18
|
+
|
|
19
|
+
while [ $# -gt 0 ]; do
|
|
20
|
+
case "$1" in
|
|
21
|
+
--local)
|
|
22
|
+
INSTALL_MODE="local"
|
|
23
|
+
INSTALL_NAME="${DRAFTED_MCP_NAME:-drafted-local}"
|
|
24
|
+
INSTALL_SERVER="${DRAFTED_SERVER:-http://localhost:3477}"
|
|
25
|
+
INSTALL_AUTH_FILE="${DRAFTED_AUTH_FILE:-$HOME/.drafted/auth.local.json}"
|
|
26
|
+
if [ -z "${DRAFTED_TELEMETRY+x}" ]; then export DRAFTED_TELEMETRY=0; fi
|
|
27
|
+
shift
|
|
28
|
+
;;
|
|
29
|
+
--server)
|
|
30
|
+
INSTALL_SERVER="$2"
|
|
31
|
+
shift 2
|
|
32
|
+
;;
|
|
33
|
+
--name)
|
|
34
|
+
INSTALL_NAME="$2"
|
|
35
|
+
shift 2
|
|
36
|
+
;;
|
|
37
|
+
--auth-file)
|
|
38
|
+
INSTALL_AUTH_FILE="$2"
|
|
39
|
+
shift 2
|
|
40
|
+
;;
|
|
41
|
+
--help|-h)
|
|
42
|
+
echo "Usage: install-mcp.sh [--local] [--server URL] [--name MCP_NAME] [--auth-file PATH]"
|
|
43
|
+
echo " default: installs production MCP named drafted -> https://drafted.live"
|
|
44
|
+
echo " --local: installs duplicate MCP named drafted-local -> http://localhost:3477"
|
|
45
|
+
exit 0
|
|
46
|
+
;;
|
|
47
|
+
*)
|
|
48
|
+
echo "Unknown option: $1" >&2
|
|
49
|
+
exit 1
|
|
50
|
+
;;
|
|
51
|
+
esac
|
|
52
|
+
done
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
BOLD="\033[1m"
|
|
56
|
+
DIM="\033[2m"
|
|
57
|
+
GREEN="\033[32m"
|
|
58
|
+
YELLOW="\033[33m"
|
|
59
|
+
RED="\033[31m"
|
|
60
|
+
CYAN="\033[36m"
|
|
61
|
+
RESET="\033[0m"
|
|
62
|
+
|
|
63
|
+
step=0
|
|
64
|
+
step() {
|
|
65
|
+
step=$((step + 1))
|
|
66
|
+
echo ""
|
|
67
|
+
echo -e "${CYAN}[$step]${RESET} ${BOLD}$1${RESET}"
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
ok() {
|
|
71
|
+
echo -e " ${GREEN}✓${RESET} $1"
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
fail() {
|
|
75
|
+
echo -e " ${RED}✗${RESET} $1"
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
init_telemetry() {
|
|
79
|
+
INSTALL_ID=""
|
|
80
|
+
TELEMETRY_ENABLED=true
|
|
81
|
+
if [ "${DRAFTED_TELEMETRY:-}" = "0" ]; then
|
|
82
|
+
TELEMETRY_ENABLED=false
|
|
83
|
+
return 0
|
|
84
|
+
fi
|
|
85
|
+
mkdir -p "$HOME/.drafted"
|
|
86
|
+
INSTALL_ID="$(node - "$HOME/.drafted/install.json" <<'NODE'
|
|
87
|
+
const fs = require('fs');
|
|
88
|
+
const crypto = require('crypto');
|
|
89
|
+
const p = process.argv[2];
|
|
90
|
+
let data = {};
|
|
91
|
+
try { data = JSON.parse(fs.readFileSync(p, 'utf8')); } catch {}
|
|
92
|
+
if (data.telemetry === false) process.exit(2);
|
|
93
|
+
if (!/^[0-9a-f-]{36}$/i.test(String(data.installId || ''))) data.installId = crypto.randomUUID();
|
|
94
|
+
data.telemetry = data.telemetry !== false;
|
|
95
|
+
data.updatedAt = new Date().toISOString();
|
|
96
|
+
fs.writeFileSync(p, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
97
|
+
process.stdout.write(data.installId);
|
|
98
|
+
NODE
|
|
99
|
+
)" || TELEMETRY_ENABLED=false
|
|
100
|
+
if [ "$TELEMETRY_ENABLED" = true ]; then
|
|
101
|
+
echo -e " ${DIM}Drafted sends anonymous install telemetry. Set DRAFTED_TELEMETRY=0 to opt out.${RESET}"
|
|
102
|
+
fi
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
report_telemetry() {
|
|
106
|
+
[ "${TELEMETRY_ENABLED:-false}" = true ] || return 0
|
|
107
|
+
[ -n "${INSTALL_ID:-}" ] || return 0
|
|
108
|
+
local event="$1"
|
|
109
|
+
local helper_status="${2:-installed}"
|
|
110
|
+
node - "$SERVER" "$INSTALL_ID" "$event" "$INSTALLER_VERSION" "$helper_status" \
|
|
111
|
+
"${CLIENT_CLAUDE_DESKTOP:-false}" "${CLIENT_CLAUDE_CODE:-false}" "${CLIENT_CODEX:-false}" "${CLIENT_CURSOR:-false}" <<'NODE' >/dev/null 2>&1 || true
|
|
112
|
+
const [server, installId, event, installerVersion, updateHelperStatus, claudeDesktop, claudeCode, codex, cursor] = process.argv.slice(2);
|
|
113
|
+
const os = require('os');
|
|
114
|
+
const cp = require('child_process');
|
|
115
|
+
function run(cmd) { try { return cp.execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().slice(0, 80); } catch { return undefined; } }
|
|
116
|
+
const platform = os.platform();
|
|
117
|
+
const osFamily = platform === 'darwin' ? 'macos' : platform === 'win32' ? 'windows' : platform === 'linux' ? 'linux' : 'unknown';
|
|
118
|
+
fetch(`${server}/api/installations/report`, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: { 'Content-Type': 'application/json', 'User-Agent': 'Drafted Installer' },
|
|
121
|
+
body: JSON.stringify({
|
|
122
|
+
installId,
|
|
123
|
+
event,
|
|
124
|
+
schemaVersion: 1,
|
|
125
|
+
installerVersion,
|
|
126
|
+
cliVersion: run('drafted --version'),
|
|
127
|
+
osFamily,
|
|
128
|
+
osVersion: os.release(),
|
|
129
|
+
arch: os.arch(),
|
|
130
|
+
nodeVersion: process.version,
|
|
131
|
+
npmVersion: run('npm --version'),
|
|
132
|
+
clientsConfigured: {
|
|
133
|
+
claudeDesktop: claudeDesktop === 'true',
|
|
134
|
+
claudeCode: claudeCode === 'true',
|
|
135
|
+
codex: codex === 'true',
|
|
136
|
+
cursor: cursor === 'true'
|
|
137
|
+
},
|
|
138
|
+
updateHelperStatus,
|
|
139
|
+
mcpMode: 'stdio',
|
|
140
|
+
source: 'installer'
|
|
141
|
+
})
|
|
142
|
+
}).catch(() => {});
|
|
143
|
+
NODE
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
install_portable_node() {
|
|
147
|
+
local os arch platform filename url tmp node_dir
|
|
148
|
+
os="$(uname -s)"
|
|
149
|
+
arch="$(uname -m)"
|
|
150
|
+
case "$os" in
|
|
151
|
+
Darwin) platform="darwin" ;;
|
|
152
|
+
Linux) platform="linux" ;;
|
|
153
|
+
*) fail "Unsupported OS for automatic Node.js install: $os"; exit 1 ;;
|
|
154
|
+
esac
|
|
155
|
+
case "$arch" in
|
|
156
|
+
x86_64|amd64) arch="x64" ;;
|
|
157
|
+
arm64|aarch64) arch="arm64" ;;
|
|
158
|
+
*) fail "Unsupported CPU for automatic Node.js install: $arch"; exit 1 ;;
|
|
159
|
+
esac
|
|
160
|
+
|
|
161
|
+
mkdir -p "$HOME/.drafted"
|
|
162
|
+
node_dir="$HOME/.drafted/node"
|
|
163
|
+
tmp="$(mktemp -d)"
|
|
164
|
+
filename="$(curl -fsSL https://nodejs.org/dist/latest-v22.x/SHASUMS256.txt | awk "/node-v.*-${platform}-${arch}\\.tar\\.xz/ {print \$2; exit}")"
|
|
165
|
+
if [ -z "$filename" ]; then
|
|
166
|
+
fail "Could not find Node.js 22 download for $platform-$arch"
|
|
167
|
+
exit 1
|
|
168
|
+
fi
|
|
169
|
+
url="https://nodejs.org/dist/latest-v22.x/$filename"
|
|
170
|
+
echo -e " ${YELLOW}Downloading Node.js 22 for $platform-$arch...${RESET}"
|
|
171
|
+
curl -fsSL "$url" -o "$tmp/node.tar.xz"
|
|
172
|
+
rm -rf "$node_dir"
|
|
173
|
+
mkdir -p "$node_dir"
|
|
174
|
+
tar -xJf "$tmp/node.tar.xz" -C "$node_dir" --strip-components=1
|
|
175
|
+
rm -rf "$tmp"
|
|
176
|
+
export PATH="$node_dir/bin:$PATH"
|
|
177
|
+
ok "Installed portable Node.js $(node -v)"
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
# ── Welcome ───────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
echo ""
|
|
183
|
+
echo -e "${BOLD}Welcome to Drafted${RESET}"
|
|
184
|
+
echo -e "This will set up Drafted so your agents can create work on a shared surface."
|
|
185
|
+
echo -e "It only takes a minute."
|
|
186
|
+
if [ "$INSTALL_MODE" = "local" ]; then
|
|
187
|
+
echo -e "${DIM}Local mode: installing MCP ${BOLD}$INSTALL_NAME${RESET}${DIM} -> $INSTALL_SERVER without touching production drafted.${RESET}"
|
|
188
|
+
fi
|
|
189
|
+
|
|
190
|
+
# ── Prerequisites ─────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
step "Checking your system"
|
|
193
|
+
|
|
194
|
+
# Node.js
|
|
195
|
+
if command -v node &>/dev/null; then
|
|
196
|
+
NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1)
|
|
197
|
+
if [ "$NODE_VERSION" -lt 22 ]; then
|
|
198
|
+
echo -e " ${YELLOW}Node.js $(node -v) is too old; installing Node.js 22 for Drafted.${RESET}"
|
|
199
|
+
install_portable_node
|
|
200
|
+
else
|
|
201
|
+
ok "Node.js $(node -v)"
|
|
202
|
+
fi
|
|
203
|
+
else
|
|
204
|
+
echo -e " ${YELLOW}Node.js is not installed; installing Node.js 22 for Drafted.${RESET}"
|
|
205
|
+
install_portable_node
|
|
206
|
+
fi
|
|
207
|
+
|
|
208
|
+
init_telemetry
|
|
209
|
+
|
|
210
|
+
NPM_GLOBAL_PREFIX="$HOME/.drafted/npm-global"
|
|
211
|
+
mkdir -p "$NPM_GLOBAL_PREFIX"
|
|
212
|
+
|
|
213
|
+
# A `drafted` resolved elsewhere on PATH (a leftover install from before this
|
|
214
|
+
# fixed-prefix scheme, or a manual `npm i -g` under the default prefix) would
|
|
215
|
+
# permanently shadow the fixed-prefix copy: the updater keeps this prefix
|
|
216
|
+
# current, but the shell keeps running the stale one. Remove it at its source.
|
|
217
|
+
STALE_DRAFTED_BIN="$(command -v drafted 2>/dev/null || true)"
|
|
218
|
+
if [ -n "$STALE_DRAFTED_BIN" ]; then
|
|
219
|
+
# Derived from where the resolved binary actually sits (bin's grandparent),
|
|
220
|
+
# not `npm config get prefix` — that reflects our own target prefix once
|
|
221
|
+
# it's already been set on a prior run, which would make this a no-op on
|
|
222
|
+
# every machine that's already installed once (i.e. every real machine).
|
|
223
|
+
CANDIDATE_PREFIX="$(dirname "$(dirname "$STALE_DRAFTED_BIN")")"
|
|
224
|
+
if [ "$CANDIDATE_PREFIX" != "$NPM_GLOBAL_PREFIX" ] && [ -d "$CANDIDATE_PREFIX/lib/node_modules/drafted" ]; then
|
|
225
|
+
echo -e " ${YELLOW}Found a stale Drafted install at $CANDIDATE_PREFIX — removing it so it can't shadow the current one.${RESET}"
|
|
226
|
+
npm uninstall -g drafted --prefix "$CANDIDATE_PREFIX" >/dev/null 2>&1 || true
|
|
227
|
+
fi
|
|
228
|
+
fi
|
|
229
|
+
|
|
230
|
+
# Do NOT `npm config set prefix` — that repoints the user's GLOBAL npm prefix, so every
|
|
231
|
+
# `npm install -g <pkg>` they ever run lands in our dir AND our uninstall (`rm -rf ~/.drafted`)
|
|
232
|
+
# would wipe all their other globals. Install drafted with an explicit per-command --prefix
|
|
233
|
+
# instead (below), and HEAL any global prefix pin an older version of this installer wrote so
|
|
234
|
+
# the user's default is restored. Preserve every other ~/.npmrc line (auth tokens, etc.).
|
|
235
|
+
NPMRC="${npm_config_userconfig:-$HOME/.npmrc}"
|
|
236
|
+
if [ -f "$NPMRC" ] && grep -Eq '^[[:space:]]*prefix[[:space:]]*=.*/\.drafted/npm-global/?[[:space:]]*$' "$NPMRC"; then
|
|
237
|
+
tmp_npmrc="$(mktemp)"
|
|
238
|
+
grep -Ev '^[[:space:]]*prefix[[:space:]]*=.*/\.drafted/npm-global/?[[:space:]]*$' "$NPMRC" > "$tmp_npmrc" && cat "$tmp_npmrc" > "$NPMRC"
|
|
239
|
+
rm -f "$tmp_npmrc"
|
|
240
|
+
fi
|
|
241
|
+
export PATH="$NPM_GLOBAL_PREFIX/bin:$PATH"
|
|
242
|
+
|
|
243
|
+
# Persist the prefix's bin dir at the FRONT of PATH for future shells — without
|
|
244
|
+
# this, npm's prefix config makes `npm install -g` land in the right place, but
|
|
245
|
+
# a bare `drafted` in a new terminal keeps resolving through whatever was
|
|
246
|
+
# already on PATH (see the installedVersion() comment on the login-shell PATH
|
|
247
|
+
# gap below). Idempotent guarded block, same pattern as the agent-instructions
|
|
248
|
+
# markers further down.
|
|
249
|
+
persist_npm_global_path() {
|
|
250
|
+
local marker_begin="# BEGIN drafted-path"
|
|
251
|
+
local marker_end="# END drafted-path"
|
|
252
|
+
local line="export PATH=\"$NPM_GLOBAL_PREFIX/bin:\$PATH\""
|
|
253
|
+
local rc
|
|
254
|
+
case "$(basename "${SHELL:-}")" in
|
|
255
|
+
zsh) rc="$HOME/.zshrc" ;;
|
|
256
|
+
bash) rc="$HOME/.bash_profile" ;;
|
|
257
|
+
*) rc="$HOME/.profile" ;;
|
|
258
|
+
esac
|
|
259
|
+
[ -f "$rc" ] || : > "$rc"
|
|
260
|
+
grep -qF "$marker_begin" "$rc" 2>/dev/null && return 0
|
|
261
|
+
{ echo ""; echo "$marker_begin"; echo "$line"; echo "$marker_end"; } >> "$rc"
|
|
262
|
+
}
|
|
263
|
+
persist_npm_global_path
|
|
264
|
+
|
|
265
|
+
# ── Install ───────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
step "Installing Drafted"
|
|
268
|
+
|
|
269
|
+
# Retry with back-off: a fresh `npm publish` can briefly 404/ETARGET on the CDN
|
|
270
|
+
# edge before the tarball propagates, so an updater that fires the instant a new
|
|
271
|
+
# version lands would otherwise report a spurious "update failed". Ride it out.
|
|
272
|
+
install_drafted_pkg() {
|
|
273
|
+
attempts=5; delay=4; n=1
|
|
274
|
+
while :; do
|
|
275
|
+
if npm install -g drafted@latest --force --prefix "$NPM_GLOBAL_PREFIX"; then return 0; fi
|
|
276
|
+
if [ "$n" -ge "$attempts" ]; then return 1; fi
|
|
277
|
+
echo -e " ${YELLOW}npm install failed (attempt $n/$attempts) — retrying in ${delay}s (a new release may still be propagating to the npm CDN)...${RESET}"
|
|
278
|
+
sleep "$delay"
|
|
279
|
+
n=$((n + 1)); delay=$((delay * 2))
|
|
280
|
+
done
|
|
281
|
+
}
|
|
282
|
+
if ! install_drafted_pkg; then
|
|
283
|
+
echo -e " ${RED}npm install -g drafted@latest failed after multiple attempts.${RESET}"
|
|
284
|
+
exit 1
|
|
285
|
+
fi
|
|
286
|
+
hash -r 2>/dev/null || true
|
|
287
|
+
NPM_ROOT="$(npm root -g --prefix "$NPM_GLOBAL_PREFIX" 2>/dev/null || true)"
|
|
288
|
+
MCP_SERVER_MODULE="$NPM_ROOT/drafted/mcp/server.mjs"
|
|
289
|
+
if [ -n "$NPM_ROOT" ] && [ -f "$MCP_SERVER_MODULE" ]; then
|
|
290
|
+
node -e "import('node:url').then(({ pathToFileURL }) => import(pathToFileURL(process.argv[1]).href)).then(() => process.exit(0), (err) => { console.error(err); process.exit(1); })" "$MCP_SERVER_MODULE"
|
|
291
|
+
elif [ "${DRAFTED_HEADLESS:-}" = "1" ]; then
|
|
292
|
+
echo -e " ${DIM}Skipping package import check in headless installer test.${RESET}"
|
|
293
|
+
else
|
|
294
|
+
echo -e " ${RED}Could not find installed MCP server module at $MCP_SERVER_MODULE${RESET}"
|
|
295
|
+
exit 1
|
|
296
|
+
fi
|
|
297
|
+
ok "Installed $(drafted --version 2>/dev/null || echo 'drafted') via npm"
|
|
298
|
+
|
|
299
|
+
# ── Configure ─────────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
step "Connecting to your tools"
|
|
302
|
+
|
|
303
|
+
# Write server URL config
|
|
304
|
+
mkdir -p "$HOME/.drafted"
|
|
305
|
+
if [ "$INSTALL_MODE" = "production" ]; then
|
|
306
|
+
echo "{\"server\":\"$INSTALL_SERVER\"}" > "$HOME/.drafted/config.json"
|
|
307
|
+
ok "Server: $INSTALL_SERVER"
|
|
308
|
+
else
|
|
309
|
+
ok "Local server: $INSTALL_SERVER"
|
|
310
|
+
fi
|
|
311
|
+
|
|
312
|
+
# Local stdio MCP command. Production uses the installed package; --local uses this checkout when available.
|
|
313
|
+
if [ "$INSTALL_MODE" = "local" ] && [ -f "$SCRIPT_DIR/mcp/server.mjs" ]; then
|
|
314
|
+
DRAFTED_MCP_COMMAND="$(command -v node)"
|
|
315
|
+
DRAFTED_MCP_ARGS_JSON="$(node -e 'console.log(JSON.stringify([process.argv[1]]))' "$SCRIPT_DIR/mcp/server.mjs")"
|
|
316
|
+
else
|
|
317
|
+
DRAFTED_MCP_COMMAND="$(command -v drafted-mcp)"
|
|
318
|
+
DRAFTED_MCP_ARGS_JSON="[]"
|
|
319
|
+
fi
|
|
320
|
+
|
|
321
|
+
configure_mcp() {
|
|
322
|
+
local config_path="$1"
|
|
323
|
+
local label="$2"
|
|
324
|
+
local config_dir
|
|
325
|
+
config_dir="$(dirname "$config_path")"
|
|
326
|
+
mkdir -p "$config_dir"
|
|
327
|
+
|
|
328
|
+
node -e "
|
|
329
|
+
const fs = require('fs');
|
|
330
|
+
const p = process.argv[1];
|
|
331
|
+
const name = process.argv[2];
|
|
332
|
+
const command = process.argv[3];
|
|
333
|
+
const args = JSON.parse(process.argv[4]);
|
|
334
|
+
const server = process.argv[5];
|
|
335
|
+
const authFile = process.argv[6];
|
|
336
|
+
const mode = process.argv[7];
|
|
337
|
+
let c = {};
|
|
338
|
+
try { c = JSON.parse(fs.readFileSync(p, 'utf8')); } catch {}
|
|
339
|
+
if (!c.mcpServers) c.mcpServers = {};
|
|
340
|
+
const env = { DRAFTED_SERVER: server };
|
|
341
|
+
if (authFile) env.DRAFTED_AUTH_FILE = authFile;
|
|
342
|
+
if (mode === 'local') env.DRAFTED_TELEMETRY = '0';
|
|
343
|
+
c.mcpServers[name] = { command, args, env };
|
|
344
|
+
fs.writeFileSync(p, JSON.stringify(c, null, 2) + '\n');
|
|
345
|
+
" "$config_path" "$INSTALL_NAME" "$DRAFTED_MCP_COMMAND" "$DRAFTED_MCP_ARGS_JSON" "$INSTALL_SERVER" "$INSTALL_AUTH_FILE" "$INSTALL_MODE"
|
|
346
|
+
ok "$label"
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
verify_no_legacy_http_config() {
|
|
351
|
+
local found=false
|
|
352
|
+
for file in "$CLAUDE_DESKTOP_CONFIG" "$CLAUDE_CODE_CONFIG" "$CURSOR_CONFIG" "$CODEX_CONFIG"; do
|
|
353
|
+
[ -f "$file" ] || continue
|
|
354
|
+
if grep -q "https://drafted.live/mcp" "$file" 2>/dev/null; then
|
|
355
|
+
found=true
|
|
356
|
+
echo -e " ${RED}✗${RESET} Legacy HTTP Drafted MCP still present in $file"
|
|
357
|
+
fi
|
|
358
|
+
done
|
|
359
|
+
if [ "$found" = true ]; then
|
|
360
|
+
fail "Installer migration incomplete. Remove legacy https://drafted.live/mcp entries and rerun."
|
|
361
|
+
exit 1
|
|
362
|
+
fi
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
configure_codex() {
|
|
366
|
+
local config_path="$1"
|
|
367
|
+
local label="$2"
|
|
368
|
+
local config_dir
|
|
369
|
+
config_dir="$(dirname "$config_path")"
|
|
370
|
+
mkdir -p "$config_dir"
|
|
371
|
+
|
|
372
|
+
node - "$config_path" "$INSTALL_NAME" "$DRAFTED_MCP_COMMAND" "$DRAFTED_MCP_ARGS_JSON" "$INSTALL_SERVER" "$INSTALL_AUTH_FILE" "$INSTALL_MODE" <<'NODE'
|
|
373
|
+
const fs = require('fs');
|
|
374
|
+
const p = process.argv[2];
|
|
375
|
+
const mcpName = process.argv[3];
|
|
376
|
+
const command = process.argv[4];
|
|
377
|
+
const args = JSON.parse(process.argv[5]);
|
|
378
|
+
const server = process.argv[6];
|
|
379
|
+
const authFile = process.argv[7];
|
|
380
|
+
const mode = process.argv[8];
|
|
381
|
+
|
|
382
|
+
let text = '';
|
|
383
|
+
try { text = fs.readFileSync(p, 'utf8'); } catch {}
|
|
384
|
+
|
|
385
|
+
const lines = text.split(/\r?\n/);
|
|
386
|
+
const out = [];
|
|
387
|
+
let skip = false;
|
|
388
|
+
|
|
389
|
+
for (const line of lines) {
|
|
390
|
+
const section = line.match(/^\[(.+)\]$/);
|
|
391
|
+
if (section) {
|
|
392
|
+
const name = section[1].trim();
|
|
393
|
+
if (name === `mcp_servers.${mcpName}` || name === `mcp_servers.${mcpName}.env`) {
|
|
394
|
+
skip = true;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (skip) skip = false;
|
|
398
|
+
}
|
|
399
|
+
if (!skip) out.push(line);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
let next = out.join('\n').replace(/\s+$/, '');
|
|
403
|
+
if (next) next += '\n\n';
|
|
404
|
+
next += `[mcp_servers.${mcpName}]\n`;
|
|
405
|
+
next += 'command = ' + JSON.stringify(command) + '\n';
|
|
406
|
+
next += 'args = [' + args.map(a => JSON.stringify(a)).join(', ') + ']\n';
|
|
407
|
+
next += `\n[mcp_servers.${mcpName}.env]\n`;
|
|
408
|
+
next += 'DRAFTED_SERVER = ' + JSON.stringify(server) + '\n';
|
|
409
|
+
if (authFile) next += 'DRAFTED_AUTH_FILE = ' + JSON.stringify(authFile) + '\n';
|
|
410
|
+
if (mode === 'local') next += 'DRAFTED_TELEMETRY = "0"\n';
|
|
411
|
+
next += '\n';
|
|
412
|
+
|
|
413
|
+
fs.writeFileSync(p, next);
|
|
414
|
+
NODE
|
|
415
|
+
|
|
416
|
+
ok "$label"
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
CLAUDE_DESKTOP_CONFIG="$HOME/Library/Application Support/Claude/claude_desktop_config.json"
|
|
420
|
+
CLAUDE_CODE_CONFIG="$HOME/.claude.json"
|
|
421
|
+
CODEX_CONFIG="$HOME/.codex/config.toml"
|
|
422
|
+
CURSOR_CONFIG="$HOME/.cursor/mcp.json"
|
|
423
|
+
|
|
424
|
+
CONFIGURED=false
|
|
425
|
+
CLIENT_CLAUDE_DESKTOP=false
|
|
426
|
+
CLIENT_CLAUDE_CODE=false
|
|
427
|
+
CLIENT_CODEX=false
|
|
428
|
+
CLIENT_CURSOR=false
|
|
429
|
+
|
|
430
|
+
# Claude Desktop
|
|
431
|
+
if [ -d "/Applications/Claude.app" ] || [ -d "$HOME/Applications/Claude.app" ] || [ -f "$CLAUDE_DESKTOP_CONFIG" ]; then
|
|
432
|
+
configure_mcp "$CLAUDE_DESKTOP_CONFIG" "Claude Desktop"
|
|
433
|
+
CLIENT_CLAUDE_DESKTOP=true
|
|
434
|
+
CONFIGURED=true
|
|
435
|
+
fi
|
|
436
|
+
|
|
437
|
+
# Claude Code
|
|
438
|
+
if command -v claude &>/dev/null || [ -f "$CLAUDE_CODE_CONFIG" ] || [ -d "$HOME/.claude" ]; then
|
|
439
|
+
configure_mcp "$CLAUDE_CODE_CONFIG" "Claude Code"
|
|
440
|
+
CLIENT_CLAUDE_CODE=true
|
|
441
|
+
CONFIGURED=true
|
|
442
|
+
fi
|
|
443
|
+
|
|
444
|
+
# Codex
|
|
445
|
+
if command -v codex &>/dev/null || [ -d "$HOME/.codex" ]; then
|
|
446
|
+
configure_codex "$CODEX_CONFIG" "Codex"
|
|
447
|
+
CLIENT_CODEX=true
|
|
448
|
+
CONFIGURED=true
|
|
449
|
+
fi
|
|
450
|
+
|
|
451
|
+
# Cursor
|
|
452
|
+
if [ -d "/Applications/Cursor.app" ] || [ -d "$HOME/Applications/Cursor.app" ] || command -v cursor &>/dev/null || [ -f "$CURSOR_CONFIG" ]; then
|
|
453
|
+
configure_mcp "$CURSOR_CONFIG" "Cursor"
|
|
454
|
+
CLIENT_CURSOR=true
|
|
455
|
+
CONFIGURED=true
|
|
456
|
+
fi
|
|
457
|
+
|
|
458
|
+
# If nothing detected, pre-configure all
|
|
459
|
+
if [ "$CONFIGURED" = false ]; then
|
|
460
|
+
echo -e " ${YELLOW}No supported editors detected — pre-configuring all.${RESET}"
|
|
461
|
+
configure_mcp "$CLAUDE_DESKTOP_CONFIG" "Claude Desktop (pre-configured)"
|
|
462
|
+
configure_mcp "$CLAUDE_CODE_CONFIG" "Claude Code (pre-configured)"
|
|
463
|
+
configure_codex "$CODEX_CONFIG" "Codex (pre-configured)"
|
|
464
|
+
configure_mcp "$CURSOR_CONFIG" "Cursor (pre-configured)"
|
|
465
|
+
CLIENT_CLAUDE_DESKTOP=true
|
|
466
|
+
CLIENT_CLAUDE_CODE=true
|
|
467
|
+
CLIENT_CODEX=true
|
|
468
|
+
CLIENT_CURSOR=true
|
|
469
|
+
fi
|
|
470
|
+
|
|
471
|
+
# ── Agent instructions ────────────────────────────────────────────
|
|
472
|
+
|
|
473
|
+
step "Installing global agent instructions"
|
|
474
|
+
|
|
475
|
+
load_global_agent_instructions() {
|
|
476
|
+
local local_path="$SCRIPT_DIR/agent-instructions/global.md"
|
|
477
|
+
if [ -f "$local_path" ]; then
|
|
478
|
+
cat "$local_path"
|
|
479
|
+
return 0
|
|
480
|
+
fi
|
|
481
|
+
cat <<'DRAFTED_GLOBAL_INSTRUCTIONS'
|
|
482
|
+
<drafted>
|
|
483
|
+
You have Drafted MCP tools — a shared surface for durable, reviewable work: produce substantive output as frames on the surface (not only in chat), put knowledge in the org wiki, and encode repeatable methods as skills. The full operating manual is the `drafted` skill installed with the plugin — follow it when working with Drafted. Before writing, verify the org/project echoed in the response is the one you intend.
|
|
484
|
+
</drafted>
|
|
485
|
+
DRAFTED_GLOBAL_INSTRUCTIONS
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
install_agent_instructions() {
|
|
489
|
+
local instructions_path="$1"
|
|
490
|
+
local label="$2"
|
|
491
|
+
local instructions_dir
|
|
492
|
+
local body
|
|
493
|
+
instructions_dir="$(dirname "$instructions_path")"
|
|
494
|
+
mkdir -p "$instructions_dir"
|
|
495
|
+
|
|
496
|
+
if ! body="$(load_global_agent_instructions)"; then
|
|
497
|
+
fail "Could not load Drafted global instructions"
|
|
498
|
+
return 1
|
|
499
|
+
fi
|
|
500
|
+
|
|
501
|
+
DRAFTED_GLOBAL_INSTRUCTIONS_BODY="$body" node - "$instructions_path" <<'NODE'
|
|
502
|
+
const fs = require('fs');
|
|
503
|
+
const p = process.argv[2];
|
|
504
|
+
const begin = '<!-- BEGIN drafted-global-instructions -->';
|
|
505
|
+
const end = '<!-- END drafted-global-instructions -->';
|
|
506
|
+
const body = process.env.DRAFTED_GLOBAL_INSTRUCTIONS_BODY || '';
|
|
507
|
+
if (!body.trim()) throw new Error('Drafted global instructions are empty');
|
|
508
|
+
const block = `${begin}\n${body.replace(/\s+$/, '')}\n${end}`;
|
|
509
|
+
let text = '';
|
|
510
|
+
try { text = fs.readFileSync(p, 'utf8'); } catch {}
|
|
511
|
+
const escapeRe = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
512
|
+
const re = new RegExp(`${escapeRe(begin)}[\\s\\S]*?${escapeRe(end)}`);
|
|
513
|
+
let next;
|
|
514
|
+
if (re.test(text)) {
|
|
515
|
+
next = text.replace(re, block);
|
|
516
|
+
} else {
|
|
517
|
+
next = text.replace(/\s+$/, '');
|
|
518
|
+
if (next) next += '\n\n';
|
|
519
|
+
next += block + '\n';
|
|
520
|
+
}
|
|
521
|
+
fs.writeFileSync(p, next.endsWith('\n') ? next : next + '\n');
|
|
522
|
+
NODE
|
|
523
|
+
|
|
524
|
+
ok "$label"
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
install_agent_instructions "$HOME/.claude/CLAUDE.md" "Claude global instructions"
|
|
528
|
+
install_agent_instructions "$HOME/.codex/CODEX.md" "Codex global instructions"
|
|
529
|
+
|
|
530
|
+
# ── Skills & commands ─────────────────────────────────────────────
|
|
531
|
+
|
|
532
|
+
step "Installing skills and commands"
|
|
533
|
+
|
|
534
|
+
# Resolve the plugin source: a local checkout when present (e.g. --local from this
|
|
535
|
+
# repo), otherwise the installed npm package's bundled plugin/ directory.
|
|
536
|
+
# require.resolve() misses packages under the custom global prefix
|
|
537
|
+
# ($HOME/.drafted/npm-global) when run from an arbitrary cwd (curl | bash), so
|
|
538
|
+
# prefer the already-computed global node_modules root from the install step.
|
|
539
|
+
if [ -n "${NPM_ROOT:-}" ] && [ -d "$NPM_ROOT/drafted" ]; then
|
|
540
|
+
DRAFTED_PKG_DIR="$NPM_ROOT/drafted"
|
|
541
|
+
else
|
|
542
|
+
DRAFTED_PKG_DIR="$(node -e "try { console.log(require.resolve('drafted/package.json').replace('/package.json','')) } catch { process.exit(1) }" 2>/dev/null)" || true
|
|
543
|
+
fi
|
|
544
|
+
if [ -d "$SCRIPT_DIR/plugin/skills" ] || [ -d "$SCRIPT_DIR/plugin/commands" ]; then
|
|
545
|
+
PLUGIN_SRC="$SCRIPT_DIR/plugin"
|
|
546
|
+
elif [ -n "$DRAFTED_PKG_DIR" ] && [ -d "$DRAFTED_PKG_DIR/plugin" ]; then
|
|
547
|
+
PLUGIN_SRC="$DRAFTED_PKG_DIR/plugin"
|
|
548
|
+
else
|
|
549
|
+
PLUGIN_SRC=""
|
|
550
|
+
fi
|
|
551
|
+
|
|
552
|
+
# Skills — Claude Code auto-loads these from ~/.claude/skills/<name>/SKILL.md
|
|
553
|
+
if [ -n "$PLUGIN_SRC" ] && [ -d "$PLUGIN_SRC/skills" ]; then
|
|
554
|
+
SKILLS_DIR="$HOME/.claude/skills"
|
|
555
|
+
mkdir -p "$SKILLS_DIR"
|
|
556
|
+
SKILL_COUNT=0
|
|
557
|
+
for d in "$PLUGIN_SRC/skills/"*/; do
|
|
558
|
+
[ -d "$d" ] || continue
|
|
559
|
+
DIRNAME="$(basename "$d")"
|
|
560
|
+
rm -rf "$SKILLS_DIR/$DIRNAME"
|
|
561
|
+
cp -r "$d" "$SKILLS_DIR/$DIRNAME"
|
|
562
|
+
SKILL_COUNT=$((SKILL_COUNT + 1))
|
|
563
|
+
done
|
|
564
|
+
ok "Installed $SKILL_COUNT skill(s) to $SKILLS_DIR"
|
|
565
|
+
else
|
|
566
|
+
echo -e " ${YELLOW}Skill source not found in package — skipping skills.${RESET}"
|
|
567
|
+
fi
|
|
568
|
+
|
|
569
|
+
# Commands — namespaced under drafted/ so they register as /drafted:<name>,
|
|
570
|
+
# matching the marketplace install and the commands' own cross-references.
|
|
571
|
+
if [ -n "$PLUGIN_SRC" ] && [ -d "$PLUGIN_SRC/commands" ]; then
|
|
572
|
+
CMD_COUNT=0
|
|
573
|
+
for f in "$PLUGIN_SRC/commands/"*.md; do
|
|
574
|
+
[ -f "$f" ] || continue
|
|
575
|
+
CMD_COUNT=$((CMD_COUNT + 1))
|
|
576
|
+
done
|
|
577
|
+
|
|
578
|
+
if [ "$CLIENT_CLAUDE_CODE" = true ]; then
|
|
579
|
+
CLAUDE_CMD_DIR="$HOME/.claude/commands/drafted"
|
|
580
|
+
rm -rf "$CLAUDE_CMD_DIR"
|
|
581
|
+
mkdir -p "$CLAUDE_CMD_DIR"
|
|
582
|
+
cp "$PLUGIN_SRC/commands/"*.md "$CLAUDE_CMD_DIR/" 2>/dev/null || true
|
|
583
|
+
ok "Installed $CMD_COUNT command(s) to $CLAUDE_CMD_DIR"
|
|
584
|
+
fi
|
|
585
|
+
|
|
586
|
+
if [ "$CLIENT_CODEX" = true ]; then
|
|
587
|
+
CODEX_CMD_DIR="$HOME/.codex/prompts/drafted"
|
|
588
|
+
rm -rf "$CODEX_CMD_DIR"
|
|
589
|
+
mkdir -p "$CODEX_CMD_DIR"
|
|
590
|
+
cp "$PLUGIN_SRC/commands/"*.md "$CODEX_CMD_DIR/" 2>/dev/null || true
|
|
591
|
+
ok "Installed $CMD_COUNT command(s) to $CODEX_CMD_DIR"
|
|
592
|
+
fi
|
|
593
|
+
else
|
|
594
|
+
echo -e " ${YELLOW}Command source not found in package — skipping commands.${RESET}"
|
|
595
|
+
fi
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
# ── Update menu bar helper ───────────────────────────────────────
|
|
599
|
+
|
|
600
|
+
# Idempotently remove the legacy Swift menu-bar updater so it can neither run alongside the
|
|
601
|
+
# new desktop app nor be resurrected at next login. Safe to call when nothing legacy exists.
|
|
602
|
+
retire_legacy_updater() {
|
|
603
|
+
local uid legacy_plist
|
|
604
|
+
uid="$(id -u)"
|
|
605
|
+
legacy_plist="$HOME/Library/LaunchAgents/live.drafted.updater.plist"
|
|
606
|
+
launchctl bootout "gui/$uid/live.drafted.updater" >/dev/null 2>&1 || true
|
|
607
|
+
if [ -f "$legacy_plist" ]; then
|
|
608
|
+
launchctl bootout "gui/$uid" "$legacy_plist" >/dev/null 2>&1 || true
|
|
609
|
+
fi
|
|
610
|
+
rm -f "$legacy_plist" >/dev/null 2>&1 || true
|
|
611
|
+
pkill -x DraftedUpdater >/dev/null 2>&1 || true
|
|
612
|
+
rm -rf "/Applications/Drafted Updater.app" "$HOME/Applications/Drafted Updater.app" >/dev/null 2>&1 || true
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
# Install (or detect) the Tauri desktop app that replaces the legacy menu-bar updater.
|
|
616
|
+
# Returns 0 ONLY when a valid Drafted.app is present afterward, so the caller can safely
|
|
617
|
+
# retire the legacy updater. Returns 1 when no new bundle is available yet — the caller then
|
|
618
|
+
# falls back to building the legacy Swift updater, so users are never left without a tray.
|
|
619
|
+
install_desktop_app() {
|
|
620
|
+
local install_dir app_bundle app_exe uid plist tmp tarball url
|
|
621
|
+
install_dir="/Applications"
|
|
622
|
+
[ -w "$install_dir" ] || install_dir="$HOME/Applications"
|
|
623
|
+
mkdir -p "$install_dir"
|
|
624
|
+
app_bundle="$install_dir/Drafted.app"
|
|
625
|
+
|
|
626
|
+
if [ -n "${DRAFTED_DESKTOP_APP:-}" ] && [ -d "${DRAFTED_DESKTOP_APP}" ]; then
|
|
627
|
+
# Local prebuilt bundle — lets us test the cutover before the CDN endpoint exists.
|
|
628
|
+
rm -rf "$app_bundle" >/dev/null 2>&1 || true
|
|
629
|
+
ditto "${DRAFTED_DESKTOP_APP}" "$app_bundle" >/dev/null 2>&1 || true
|
|
630
|
+
else
|
|
631
|
+
# Signed bundle tarball (expects Drafted.app at the archive root). Until this endpoint is
|
|
632
|
+
# published the request 404s and we fall through to legacy — that is the cutover gate.
|
|
633
|
+
url="${DRAFTED_DESKTOP_URL:-$INSTALL_SERVER/desktop/Drafted-macos.tar.gz}"
|
|
634
|
+
tmp="$(mktemp -d)"
|
|
635
|
+
tarball="$tmp/Drafted-macos.tar.gz"
|
|
636
|
+
if curl -fsSL --max-time 120 "$url" -o "$tarball" >/dev/null 2>&1; then
|
|
637
|
+
rm -rf "$app_bundle" >/dev/null 2>&1 || true
|
|
638
|
+
tar -xzf "$tarball" -C "$install_dir" >/dev/null 2>&1 || true
|
|
639
|
+
fi
|
|
640
|
+
rm -rf "$tmp" >/dev/null 2>&1 || true
|
|
641
|
+
fi
|
|
642
|
+
|
|
643
|
+
# Validate: a real bundle whose declared executable exists. The bundle dir is Drafted.app
|
|
644
|
+
# (productName) but the binary is the Cargo bin name (drafted-desktop), so read the actual
|
|
645
|
+
# name from Info.plist rather than assuming. If absent/invalid, signal "not available yet".
|
|
646
|
+
local exe_name
|
|
647
|
+
exe_name="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$app_bundle/Contents/Info.plist" 2>/dev/null || true)"
|
|
648
|
+
app_exe="$app_bundle/Contents/MacOS/$exe_name"
|
|
649
|
+
if [ -z "$exe_name" ] || [ ! -x "$app_exe" ]; then
|
|
650
|
+
return 1
|
|
651
|
+
fi
|
|
652
|
+
|
|
653
|
+
# Clear quarantine so Gatekeeper doesn't block a freshly downloaded bundle.
|
|
654
|
+
xattr -dr com.apple.quarantine "$app_bundle" >/dev/null 2>&1 || true
|
|
655
|
+
|
|
656
|
+
# Register run-at-login for the new app (mirrors the legacy launch agent). Write the
|
|
657
|
+
# plist BEFORE touching launchd so the bootstrap below loads the new binary path.
|
|
658
|
+
uid="$(id -u)"
|
|
659
|
+
plist="$HOME/Library/LaunchAgents/live.drafted.desktop.plist"
|
|
660
|
+
mkdir -p "$HOME/Library/LaunchAgents"
|
|
661
|
+
cat > "$plist" <<PLIST
|
|
662
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
663
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
664
|
+
<plist version="1.0">
|
|
665
|
+
<dict>
|
|
666
|
+
<key>Label</key>
|
|
667
|
+
<string>live.drafted.desktop</string>
|
|
668
|
+
<key>ProgramArguments</key>
|
|
669
|
+
<array>
|
|
670
|
+
<string>$app_exe</string>
|
|
671
|
+
</array>
|
|
672
|
+
<key>RunAtLoad</key>
|
|
673
|
+
<true/>
|
|
674
|
+
<key>KeepAlive</key>
|
|
675
|
+
<true/>
|
|
676
|
+
</dict>
|
|
677
|
+
</plist>
|
|
678
|
+
PLIST
|
|
679
|
+
# Restart under launchd DETERMINISTICALLY. bootout is async: bootstrapping before the old
|
|
680
|
+
# service is fully torn down races, and when it loses the label is left UNLOADED — the app
|
|
681
|
+
# dies with no KeepAlive to save it (that bricked the in-app updater, which is itself a child
|
|
682
|
+
# of the very app being replaced). And a bare pkill while the service is still loaded just
|
|
683
|
+
# trips KeepAlive into respawning the old binary mid-swap. So: bootout, POLL until the label
|
|
684
|
+
# is actually gone, kill any stray non-launchd instance, bootstrap the new plist, and
|
|
685
|
+
# kickstart -k to guarantee it comes up (RunAtLoad alone is not proof it started).
|
|
686
|
+
local svc="gui/$uid/live.drafted.desktop"
|
|
687
|
+
launchctl bootout "$svc" >/dev/null 2>&1 || true
|
|
688
|
+
local waited=0
|
|
689
|
+
while [ "$waited" -lt 40 ] && launchctl print "$svc" >/dev/null 2>&1; do
|
|
690
|
+
sleep 0.25
|
|
691
|
+
waited=$((waited + 1))
|
|
692
|
+
done
|
|
693
|
+
pkill -x "$exe_name" >/dev/null 2>&1 || true
|
|
694
|
+
launchctl bootstrap "gui/$uid" "$plist" >/dev/null 2>&1 || true
|
|
695
|
+
launchctl kickstart -k "$svc" >/dev/null 2>&1 || true
|
|
696
|
+
return 0
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
install_update_menu_icon() {
|
|
700
|
+
if [ "${DRAFTED_HEADLESS:-}" = "1" ]; then
|
|
701
|
+
echo -e " ${YELLOW}Headless mode: skipping update menu icon.${RESET}"
|
|
702
|
+
return 0
|
|
703
|
+
fi
|
|
704
|
+
if [ "$(uname -s)" != "Darwin" ]; then
|
|
705
|
+
echo -e " ${YELLOW}Update menu icon is only installed on macOS by this script.${RESET}"
|
|
706
|
+
return 0
|
|
707
|
+
fi
|
|
708
|
+
|
|
709
|
+
# Prefer the Tauri desktop app. If it installs (or is already present and valid), retire the
|
|
710
|
+
# legacy Swift updater so the two can never run side by side or be resurrected at next login.
|
|
711
|
+
if install_desktop_app; then
|
|
712
|
+
retire_legacy_updater
|
|
713
|
+
ok "Drafted desktop app"
|
|
714
|
+
return 0
|
|
715
|
+
fi
|
|
716
|
+
echo -e " ${YELLOW}Desktop app bundle not available yet; using the menu bar updater.${RESET}"
|
|
717
|
+
|
|
718
|
+
if ! command -v swiftc >/dev/null 2>&1; then
|
|
719
|
+
echo -e " ${YELLOW}Swift compiler not found; skipping macOS menu bar updater.${RESET}"
|
|
720
|
+
return 0
|
|
721
|
+
fi
|
|
722
|
+
|
|
723
|
+
local work_dir install_dir app_bundle app_macos app_resources swift_file exe_path plist_dir plist uid logo_path legacy_bundle
|
|
724
|
+
work_dir="$HOME/.drafted/updater"
|
|
725
|
+
install_dir="/Applications"
|
|
726
|
+
if [ ! -w "$install_dir" ]; then
|
|
727
|
+
install_dir="$HOME/Applications"
|
|
728
|
+
fi
|
|
729
|
+
app_bundle="$install_dir/Drafted Updater.app"
|
|
730
|
+
app_macos="$app_bundle/Contents/MacOS"
|
|
731
|
+
app_resources="$app_bundle/Contents/Resources"
|
|
732
|
+
swift_file="$work_dir/DraftedUpdater.swift"
|
|
733
|
+
exe_path="$app_macos/DraftedUpdater"
|
|
734
|
+
plist_dir="$HOME/Library/LaunchAgents"
|
|
735
|
+
plist="$plist_dir/live.drafted.updater.plist"
|
|
736
|
+
uid="$(id -u)"
|
|
737
|
+
legacy_bundle="$work_dir/DraftedUpdater.app"
|
|
738
|
+
mkdir -p "$work_dir" "$install_dir" "$app_macos" "$app_resources" "$plist_dir"
|
|
739
|
+
rm -rf "$legacy_bundle"
|
|
740
|
+
|
|
741
|
+
logo_path="$app_resources/logo.svg"
|
|
742
|
+
if [ -f "$SCRIPT_DIR/server/vendor/logo.svg" ]; then
|
|
743
|
+
cp "$SCRIPT_DIR/server/vendor/logo.svg" "$logo_path"
|
|
744
|
+
else
|
|
745
|
+
curl -fsSL "$INSTALL_SERVER/vendor/logo.svg" -o "$logo_path" >/dev/null 2>&1 || true
|
|
746
|
+
fi
|
|
747
|
+
if [ -f "$logo_path" ]; then
|
|
748
|
+
perl -0pi -e 's/fill="currentColor"/fill="#FFFFFF"/g; s/fill="var\(--logo-letter, #[^)]+\)"/fill="#0A2540"/g' "$logo_path" >/dev/null 2>&1 || true
|
|
749
|
+
fi
|
|
750
|
+
|
|
751
|
+
cat > "$swift_file" <<'SWIFT'
|
|
752
|
+
import SwiftUI
|
|
753
|
+
import AppKit
|
|
754
|
+
|
|
755
|
+
@main
|
|
756
|
+
struct DraftedUpdaterApp: App {
|
|
757
|
+
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
|
758
|
+
|
|
759
|
+
init() {
|
|
760
|
+
Telemetry.report(event: "drafted_update_helper_started", updateHelperStatus: "running")
|
|
761
|
+
Telemetry.report(event: "drafted_heartbeat", updateHelperStatus: "running")
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
var body: some Scene {
|
|
765
|
+
MenuBarExtra {
|
|
766
|
+
Button("Check for Updates…") { Actions.checkForUpdate(userInitiated: true) }
|
|
767
|
+
Button("Update Drafted") { Actions.updateDrafted() }
|
|
768
|
+
Button("View Logs") { Actions.viewLogs() }
|
|
769
|
+
Button("Open Drafted") { Actions.openDrafted() }
|
|
770
|
+
Divider()
|
|
771
|
+
Button("Quit") { NSApp.terminate(nil) }
|
|
772
|
+
} label: {
|
|
773
|
+
if let image = Actions.logoImage() {
|
|
774
|
+
Image(nsImage: image)
|
|
775
|
+
.resizable()
|
|
776
|
+
.frame(width: 16, height: 18)
|
|
777
|
+
.accessibilityLabel("Drafted")
|
|
778
|
+
} else {
|
|
779
|
+
Text("Drafted")
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
.menuBarExtraStyle(.menu)
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Drives the genuine auto-update: a background check shortly after login and
|
|
787
|
+
// every 6 hours while the menu-bar helper is resident. When a newer published
|
|
788
|
+
// version exists (and the user hasn't skipped it), it prompts Update / Skip.
|
|
789
|
+
final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
790
|
+
private var timer: Timer?
|
|
791
|
+
|
|
792
|
+
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
793
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 25) {
|
|
794
|
+
Actions.checkForUpdate(userInitiated: false)
|
|
795
|
+
}
|
|
796
|
+
let t = Timer(timeInterval: 6 * 3600, repeats: true) { _ in
|
|
797
|
+
Actions.checkForUpdate(userInitiated: false)
|
|
798
|
+
}
|
|
799
|
+
RunLoop.main.add(t, forMode: .common)
|
|
800
|
+
timer = t
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
final class UpdateStatusWindow: NSWindowController {
|
|
806
|
+
private let stack = NSStackView()
|
|
807
|
+
private let spinner = NSProgressIndicator()
|
|
808
|
+
private let titleField = NSTextField(labelWithString: "Updating Drafted…")
|
|
809
|
+
private let bodyField = NSTextField(labelWithString: "Downloading and running the installer. This can take a minute.")
|
|
810
|
+
private let closeButton = NSButton(title: "OK", target: nil, action: nil)
|
|
811
|
+
|
|
812
|
+
init() {
|
|
813
|
+
let panel = NSPanel(
|
|
814
|
+
contentRect: NSRect(x: 0, y: 0, width: 380, height: 168),
|
|
815
|
+
styleMask: [.titled, .closable],
|
|
816
|
+
backing: .buffered,
|
|
817
|
+
defer: false
|
|
818
|
+
)
|
|
819
|
+
panel.title = "Drafted Update"
|
|
820
|
+
panel.isReleasedWhenClosed = false
|
|
821
|
+
panel.level = .floating
|
|
822
|
+
super.init(window: panel)
|
|
823
|
+
|
|
824
|
+
stack.orientation = .vertical
|
|
825
|
+
stack.alignment = .centerX
|
|
826
|
+
stack.spacing = 12
|
|
827
|
+
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
828
|
+
|
|
829
|
+
spinner.style = .spinning
|
|
830
|
+
spinner.controlSize = .regular
|
|
831
|
+
|
|
832
|
+
titleField.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
|
|
833
|
+
titleField.alignment = .center
|
|
834
|
+
|
|
835
|
+
bodyField.font = NSFont.systemFont(ofSize: 13)
|
|
836
|
+
bodyField.textColor = .secondaryLabelColor
|
|
837
|
+
bodyField.alignment = .center
|
|
838
|
+
bodyField.maximumNumberOfLines = 3
|
|
839
|
+
bodyField.lineBreakMode = .byWordWrapping
|
|
840
|
+
|
|
841
|
+
closeButton.target = self
|
|
842
|
+
closeButton.action = #selector(closeClicked)
|
|
843
|
+
closeButton.isHidden = true
|
|
844
|
+
|
|
845
|
+
stack.addArrangedSubview(spinner)
|
|
846
|
+
stack.addArrangedSubview(titleField)
|
|
847
|
+
stack.addArrangedSubview(bodyField)
|
|
848
|
+
stack.addArrangedSubview(closeButton)
|
|
849
|
+
|
|
850
|
+
panel.contentView = NSView()
|
|
851
|
+
panel.contentView?.addSubview(stack)
|
|
852
|
+
NSLayoutConstraint.activate([
|
|
853
|
+
stack.leadingAnchor.constraint(equalTo: panel.contentView!.leadingAnchor, constant: 28),
|
|
854
|
+
stack.trailingAnchor.constraint(equalTo: panel.contentView!.trailingAnchor, constant: -28),
|
|
855
|
+
stack.centerYAnchor.constraint(equalTo: panel.contentView!.centerYAnchor)
|
|
856
|
+
])
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
860
|
+
|
|
861
|
+
func showUpdating() {
|
|
862
|
+
titleField.stringValue = "Updating Drafted…"
|
|
863
|
+
bodyField.stringValue = "Downloading and running the installer. This can take a minute."
|
|
864
|
+
closeButton.isHidden = true
|
|
865
|
+
spinner.isHidden = false
|
|
866
|
+
spinner.startAnimation(nil)
|
|
867
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
868
|
+
showWindow(nil)
|
|
869
|
+
window?.center()
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
func showCompleted(success: Bool, message: String) {
|
|
873
|
+
spinner.stopAnimation(nil)
|
|
874
|
+
spinner.isHidden = true
|
|
875
|
+
titleField.stringValue = success ? "Update completed" : "Update failed"
|
|
876
|
+
bodyField.stringValue = message
|
|
877
|
+
closeButton.isHidden = false
|
|
878
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
879
|
+
showWindow(nil)
|
|
880
|
+
window?.center()
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
@objc private func closeClicked() { window?.close() }
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
struct Actions {
|
|
887
|
+
static let home = FileManager.default.homeDirectoryForCurrentUser
|
|
888
|
+
|
|
889
|
+
static func logoImage() -> NSImage? {
|
|
890
|
+
guard
|
|
891
|
+
let url = Bundle.main.url(forResource: "logo", withExtension: "svg"),
|
|
892
|
+
let image = NSImage(contentsOf: url)
|
|
893
|
+
else { return nil }
|
|
894
|
+
image.size = NSSize(width: 16, height: 18)
|
|
895
|
+
image.isTemplate = false
|
|
896
|
+
return image
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
private static var isUpdating = false
|
|
900
|
+
private static var updateWindow: UpdateStatusWindow?
|
|
901
|
+
|
|
902
|
+
// MARK: - Auto-update check
|
|
903
|
+
|
|
904
|
+
// Compare the installed CLI version to npm's published `latest`. Prompt only
|
|
905
|
+
// when newer and not skipped (background), or always report (user-initiated).
|
|
906
|
+
static func checkForUpdate(userInitiated: Bool) {
|
|
907
|
+
let installed = installedVersion()
|
|
908
|
+
latestVersion { latest in
|
|
909
|
+
DispatchQueue.main.async {
|
|
910
|
+
guard let latest = latest else {
|
|
911
|
+
if userInitiated {
|
|
912
|
+
showInfo(title: "Couldn’t check for updates", body: "Please check your connection and try again.")
|
|
913
|
+
}
|
|
914
|
+
return
|
|
915
|
+
}
|
|
916
|
+
guard let installed = installed, isNewer(latest, than: installed) else {
|
|
917
|
+
if userInitiated {
|
|
918
|
+
showInfo(title: "Drafted is up to date", body: "You’re on \(installed ?? "the latest") version.")
|
|
919
|
+
}
|
|
920
|
+
return
|
|
921
|
+
}
|
|
922
|
+
if !userInitiated && skippedVersion() == latest { return }
|
|
923
|
+
presentUpdateAlert(latest: latest, installed: installed)
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
static func presentUpdateAlert(latest: String, installed: String) {
|
|
929
|
+
Telemetry.report(event: "drafted_update_available", updateHelperStatus: "running")
|
|
930
|
+
let alert = NSAlert()
|
|
931
|
+
alert.messageText = "Drafted \(latest) is available"
|
|
932
|
+
alert.informativeText = "You have \(installed). Update now? Your editor needs to be restarted afterward to load the new MCP tools."
|
|
933
|
+
alert.addButton(withTitle: "Update")
|
|
934
|
+
alert.addButton(withTitle: "Skip This Version")
|
|
935
|
+
alert.addButton(withTitle: "Later")
|
|
936
|
+
if let logo = logoImage() { alert.icon = logo }
|
|
937
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
938
|
+
switch alert.runModal() {
|
|
939
|
+
case .alertFirstButtonReturn:
|
|
940
|
+
updateDrafted()
|
|
941
|
+
case .alertSecondButtonReturn:
|
|
942
|
+
setSkippedVersion(latest)
|
|
943
|
+
Telemetry.report(event: "drafted_update_skipped", updateHelperStatus: "running")
|
|
944
|
+
default:
|
|
945
|
+
break
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
static func showInfo(title: String, body: String) {
|
|
950
|
+
let alert = NSAlert()
|
|
951
|
+
alert.messageText = title
|
|
952
|
+
alert.informativeText = body
|
|
953
|
+
alert.addButton(withTitle: "OK")
|
|
954
|
+
if let logo = logoImage() { alert.icon = logo }
|
|
955
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
956
|
+
alert.runModal()
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Installed version, resolved WITHOUT depending on the login-shell PATH —
|
|
960
|
+
// the installer's npm prefix (~/.drafted/npm-global) is never written to any
|
|
961
|
+
// login profile, so `bash -lc "drafted"` finds nothing. Read the installed
|
|
962
|
+
// package.json at the fixed prefix first (the source npm itself writes), then
|
|
963
|
+
// fall back to the absolute binary, then a login shell (custom prefixes on PATH).
|
|
964
|
+
static func installedVersion() -> String? {
|
|
965
|
+
let pkg = home.appendingPathComponent(".drafted/npm-global/lib/node_modules/drafted/package.json")
|
|
966
|
+
if
|
|
967
|
+
let data = try? Data(contentsOf: pkg),
|
|
968
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
969
|
+
let version = json["version"] as? String,
|
|
970
|
+
isSemver(version)
|
|
971
|
+
{ return version }
|
|
972
|
+
|
|
973
|
+
let bin = home.appendingPathComponent(".drafted/npm-global/bin/drafted").path
|
|
974
|
+
for command in ["\"\(bin)\" --version 2>/dev/null", "drafted --version 2>/dev/null"] {
|
|
975
|
+
let process = Process()
|
|
976
|
+
process.executableURL = URL(fileURLWithPath: "/bin/bash")
|
|
977
|
+
process.arguments = ["-lc", command]
|
|
978
|
+
let pipe = Pipe()
|
|
979
|
+
process.standardOutput = pipe
|
|
980
|
+
process.standardError = FileHandle.nullDevice
|
|
981
|
+
do { try process.run(); process.waitUntilExit() } catch { continue }
|
|
982
|
+
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
983
|
+
if let out = String(data: data, encoding: .utf8), let version = firstSemver(in: out) {
|
|
984
|
+
return version
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
return nil
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
static func latestVersion(_ completion: @escaping (String?) -> Void) {
|
|
991
|
+
guard let url = URL(string: "https://registry.npmjs.org/drafted/latest") else { completion(nil); return }
|
|
992
|
+
var request = URLRequest(url: url)
|
|
993
|
+
request.timeoutInterval = 15
|
|
994
|
+
URLSession.shared.dataTask(with: request) { data, _, _ in
|
|
995
|
+
guard
|
|
996
|
+
let data = data,
|
|
997
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
998
|
+
let version = json["version"] as? String
|
|
999
|
+
else { completion(nil); return }
|
|
1000
|
+
completion(version)
|
|
1001
|
+
}.resume()
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
static func skippedVersion() -> String? {
|
|
1005
|
+
let path = home.appendingPathComponent(".drafted/install.json")
|
|
1006
|
+
guard
|
|
1007
|
+
let data = try? Data(contentsOf: path),
|
|
1008
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
1009
|
+
else { return nil }
|
|
1010
|
+
return json["skippedVersion"] as? String
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
static func setSkippedVersion(_ version: String) {
|
|
1014
|
+
let path = home.appendingPathComponent(".drafted/install.json")
|
|
1015
|
+
var json = ((try? Data(contentsOf: path)).flatMap { try? JSONSerialization.jsonObject(with: $0) } as? [String: Any]) ?? [:]
|
|
1016
|
+
json["skippedVersion"] = version
|
|
1017
|
+
if let out = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) {
|
|
1018
|
+
try? out.write(to: path)
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// First dotted-numeric token in the text, e.g. "1.11.7" from "drafted 1.11.7".
|
|
1023
|
+
static func firstSemver(in text: String) -> String? {
|
|
1024
|
+
var current = ""
|
|
1025
|
+
for ch in text {
|
|
1026
|
+
if ch.isNumber || ch == "." {
|
|
1027
|
+
current.append(ch)
|
|
1028
|
+
} else {
|
|
1029
|
+
if isSemver(current) { return current }
|
|
1030
|
+
current = ""
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
return isSemver(current) ? current : nil
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
static func isSemver(_ s: String) -> Bool {
|
|
1037
|
+
let parts = s.split(separator: ".")
|
|
1038
|
+
return parts.count >= 2 && parts.allSatisfy { !$0.isEmpty && $0.allSatisfy { $0.isNumber } }
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// Numeric, dot-separated comparison (our versions have no prerelease tags).
|
|
1042
|
+
static func isNewer(_ a: String, than b: String) -> Bool {
|
|
1043
|
+
let pa = a.split(separator: ".").map { Int($0) ?? 0 }
|
|
1044
|
+
let pb = b.split(separator: ".").map { Int($0) ?? 0 }
|
|
1045
|
+
for i in 0..<max(pa.count, pb.count) {
|
|
1046
|
+
let x = i < pa.count ? pa[i] : 0
|
|
1047
|
+
let y = i < pb.count ? pb[i] : 0
|
|
1048
|
+
if x != y { return x > y }
|
|
1049
|
+
}
|
|
1050
|
+
return false
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
static func updateDrafted() {
|
|
1054
|
+
if isUpdating {
|
|
1055
|
+
updateWindow?.showUpdating()
|
|
1056
|
+
return
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
isUpdating = true
|
|
1060
|
+
let statusWindow = UpdateStatusWindow()
|
|
1061
|
+
updateWindow = statusWindow
|
|
1062
|
+
statusWindow.showUpdating()
|
|
1063
|
+
|
|
1064
|
+
let command = "tmp=$(mktemp); curl -fsSL https://drafted.live/install.sh -o \"$tmp\" && bash \"$tmp\""
|
|
1065
|
+
let process = Process()
|
|
1066
|
+
process.executableURL = URL(fileURLWithPath: "/bin/bash")
|
|
1067
|
+
process.arguments = ["-lc", command]
|
|
1068
|
+
process.terminationHandler = { proc in
|
|
1069
|
+
if proc.terminationStatus != 0 {
|
|
1070
|
+
Telemetry.report(event: "drafted_update_helper_failed", updateHelperStatus: "failed", errorCode: "installer_exit_\(proc.terminationStatus)")
|
|
1071
|
+
}
|
|
1072
|
+
DispatchQueue.main.async {
|
|
1073
|
+
isUpdating = false
|
|
1074
|
+
let success = proc.terminationStatus == 0
|
|
1075
|
+
statusWindow.showCompleted(
|
|
1076
|
+
success: success,
|
|
1077
|
+
message: success ? "Restart your editor to use the latest MCP tools." : "Run the Drafted installer again from drafted.live/install."
|
|
1078
|
+
)
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
do { try process.run() } catch {
|
|
1082
|
+
isUpdating = false
|
|
1083
|
+
Telemetry.report(event: "drafted_update_helper_failed", updateHelperStatus: "failed", errorCode: "process_run_failed")
|
|
1084
|
+
statusWindow.showCompleted(success: false, message: error.localizedDescription)
|
|
1085
|
+
return
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
static func viewLogs() {
|
|
1090
|
+
let fm = FileManager.default
|
|
1091
|
+
let home = fm.homeDirectoryForCurrentUser
|
|
1092
|
+
let fallback = home.appendingPathComponent(".drafted")
|
|
1093
|
+
try? fm.createDirectory(at: fallback, withIntermediateDirectories: true)
|
|
1094
|
+
let readme = fallback.appendingPathComponent("logs-readme.txt")
|
|
1095
|
+
if !fm.fileExists(atPath: readme.path) {
|
|
1096
|
+
let text = """
|
|
1097
|
+
Drafted MCP logs are written by the host app that launched drafted-mcp.
|
|
1098
|
+
|
|
1099
|
+
Most useful locations:
|
|
1100
|
+
- Drafted MCP client errors: ~/.drafted/mcp-client.log
|
|
1101
|
+
- Claude Desktop: ~/Library/Logs/Claude/mcp-server-drafted.log
|
|
1102
|
+
- Claude Code: ~/.claude/projects (session transcripts) or ~/.claude/logs when present
|
|
1103
|
+
- Drafted installer/updater: ~/.drafted
|
|
1104
|
+
|
|
1105
|
+
If a tool returns `fetch failed`, open the Claude log or transcript from the same run and look for `mcp-server-drafted` or `Drafted MCP`.
|
|
1106
|
+
"""
|
|
1107
|
+
try? text.write(to: readme, atomically: true, encoding: .utf8)
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
let candidates = [
|
|
1111
|
+
home.appendingPathComponent(".drafted/mcp-client.log"),
|
|
1112
|
+
home.appendingPathComponent("Library/Logs/Claude/mcp-server-drafted.log"),
|
|
1113
|
+
home.appendingPathComponent("Library/Logs/Claude"),
|
|
1114
|
+
home.appendingPathComponent(".claude/logs"),
|
|
1115
|
+
home.appendingPathComponent(".claude/projects"),
|
|
1116
|
+
fallback,
|
|
1117
|
+
]
|
|
1118
|
+
for url in candidates where fm.fileExists(atPath: url.path) {
|
|
1119
|
+
var isDirectory: ObjCBool = false
|
|
1120
|
+
fm.fileExists(atPath: url.path, isDirectory: &isDirectory)
|
|
1121
|
+
if isDirectory.boolValue {
|
|
1122
|
+
NSWorkspace.shared.open(url)
|
|
1123
|
+
} else {
|
|
1124
|
+
NSWorkspace.shared.activateFileViewerSelecting([url])
|
|
1125
|
+
}
|
|
1126
|
+
return
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
static func openDrafted() {
|
|
1131
|
+
NSWorkspace.shared.open(URL(string: "https://drafted.live")!)
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
struct Telemetry {
|
|
1136
|
+
static func report(event: String, updateHelperStatus: String, errorCode: String? = nil) {
|
|
1137
|
+
guard ProcessInfo.processInfo.environment["DRAFTED_TELEMETRY"] != "0" else { return }
|
|
1138
|
+
let path = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".drafted/install.json")
|
|
1139
|
+
guard
|
|
1140
|
+
let data = try? Data(contentsOf: path),
|
|
1141
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
1142
|
+
json["telemetry"] as? Bool != false,
|
|
1143
|
+
let installId = json["installId"] as? String
|
|
1144
|
+
else { return }
|
|
1145
|
+
var body: [String: Any] = [
|
|
1146
|
+
"installId": installId,
|
|
1147
|
+
"event": event,
|
|
1148
|
+
"schemaVersion": 1,
|
|
1149
|
+
"osFamily": "macos",
|
|
1150
|
+
"osVersion": ProcessInfo.processInfo.operatingSystemVersionString,
|
|
1151
|
+
"arch": SystemVersion.machine,
|
|
1152
|
+
"updateHelperStatus": updateHelperStatus,
|
|
1153
|
+
"source": "macos-helper"
|
|
1154
|
+
]
|
|
1155
|
+
if let errorCode = errorCode { body["errorCode"] = errorCode }
|
|
1156
|
+
guard
|
|
1157
|
+
let url = URL(string: "https://drafted.live/api/installations/report"),
|
|
1158
|
+
let payload = try? JSONSerialization.data(withJSONObject: body)
|
|
1159
|
+
else { return }
|
|
1160
|
+
var request = URLRequest(url: url)
|
|
1161
|
+
request.httpMethod = "POST"
|
|
1162
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
1163
|
+
request.httpBody = payload
|
|
1164
|
+
URLSession.shared.dataTask(with: request).resume()
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
enum SystemVersion {
|
|
1169
|
+
static var machine: String {
|
|
1170
|
+
#if arch(arm64)
|
|
1171
|
+
return "arm64"
|
|
1172
|
+
#elseif arch(x86_64)
|
|
1173
|
+
return "x64"
|
|
1174
|
+
#else
|
|
1175
|
+
return "unknown"
|
|
1176
|
+
#endif
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
SWIFT
|
|
1180
|
+
|
|
1181
|
+
cat > "$app_bundle/Contents/Info.plist" <<APPPLIST
|
|
1182
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
1183
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1184
|
+
<plist version="1.0">
|
|
1185
|
+
<dict>
|
|
1186
|
+
<key>CFBundleExecutable</key>
|
|
1187
|
+
<string>DraftedUpdater</string>
|
|
1188
|
+
<key>CFBundleIdentifier</key>
|
|
1189
|
+
<string>live.drafted.updater</string>
|
|
1190
|
+
<key>CFBundleName</key>
|
|
1191
|
+
<string>Drafted Updater</string>
|
|
1192
|
+
<key>CFBundleDisplayName</key>
|
|
1193
|
+
<string>Drafted Updater</string>
|
|
1194
|
+
<key>CFBundlePackageType</key>
|
|
1195
|
+
<string>APPL</string>
|
|
1196
|
+
<key>CFBundleShortVersionString</key>
|
|
1197
|
+
<string>1.0</string>
|
|
1198
|
+
<key>LSMinimumSystemVersion</key>
|
|
1199
|
+
<string>13.0</string>
|
|
1200
|
+
<key>LSUIElement</key>
|
|
1201
|
+
<true/>
|
|
1202
|
+
<key>NSHighResolutionCapable</key>
|
|
1203
|
+
<true/>
|
|
1204
|
+
</dict>
|
|
1205
|
+
</plist>
|
|
1206
|
+
APPPLIST
|
|
1207
|
+
|
|
1208
|
+
if ! swiftc -parse-as-library "$swift_file" -o "$exe_path" -framework SwiftUI -framework AppKit >/dev/null 2>&1; then
|
|
1209
|
+
echo -e " ${YELLOW}Could not build macOS menu bar updater; skipping.${RESET}"
|
|
1210
|
+
return 0
|
|
1211
|
+
fi
|
|
1212
|
+
codesign -s - -f "$exe_path" >/dev/null 2>&1 || true
|
|
1213
|
+
|
|
1214
|
+
cat > "$plist" <<PLIST
|
|
1215
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
1216
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1217
|
+
<plist version="1.0">
|
|
1218
|
+
<dict>
|
|
1219
|
+
<key>Label</key>
|
|
1220
|
+
<string>live.drafted.updater</string>
|
|
1221
|
+
<key>ProgramArguments</key>
|
|
1222
|
+
<array>
|
|
1223
|
+
<string>open</string>
|
|
1224
|
+
<string>-a</string>
|
|
1225
|
+
<string>$app_bundle</string>
|
|
1226
|
+
</array>
|
|
1227
|
+
<key>RunAtLoad</key>
|
|
1228
|
+
<true/>
|
|
1229
|
+
<key>KeepAlive</key>
|
|
1230
|
+
<false/>
|
|
1231
|
+
</dict>
|
|
1232
|
+
</plist>
|
|
1233
|
+
PLIST
|
|
1234
|
+
|
|
1235
|
+
launchctl bootout "gui/$uid" "$plist" >/dev/null 2>&1 || true
|
|
1236
|
+
pkill -x DraftedUpdater >/dev/null 2>&1 || true
|
|
1237
|
+
mdimport "$app_bundle" >/dev/null 2>&1 || true
|
|
1238
|
+
open "$app_bundle" >/dev/null 2>&1 || true
|
|
1239
|
+
ok "macOS menu bar updater"
|
|
1240
|
+
}
|
|
1241
|
+
if [ "$INSTALL_MODE" = "production" ]; then
|
|
1242
|
+
step "Installing update helper"
|
|
1243
|
+
install_update_menu_icon
|
|
1244
|
+
fi
|
|
1245
|
+
verify_no_legacy_http_config
|
|
1246
|
+
report_telemetry "drafted_mcp_configured" "installed"
|
|
1247
|
+
report_telemetry "drafted_install" "installed"
|
|
1248
|
+
|
|
1249
|
+
# ── Done ─────────────────────────────────────────────────────────
|
|
1250
|
+
|
|
1251
|
+
echo ""
|
|
1252
|
+
echo ""
|
|
1253
|
+
echo -e "${GREEN}${BOLD}You're all set!${RESET}"
|
|
1254
|
+
echo ""
|
|
1255
|
+
echo -e " ${DIM}MCP name:${RESET} ${BOLD}$INSTALL_NAME${RESET}"
|
|
1256
|
+
echo -e " ${DIM}Server:${RESET} ${BOLD}$INSTALL_SERVER${RESET}"
|
|
1257
|
+
echo -e " ${DIM}To update production:${RESET} rerun curl -fsSL https://drafted.live/install.sh | bash"
|
|
1258
|
+
echo -e " ${DIM}To uninstall:${RESET} npm uninstall -g drafted --prefix ~/.drafted/npm-global && rm -rf ~/.drafted"
|
|
1259
|
+
echo ""
|
|
1260
|
+
echo -e "${YELLOW}${BOLD}"
|
|
1261
|
+
echo " ┌─────────────────────────────────────────────────────────┐"
|
|
1262
|
+
echo " │ │"
|
|
1263
|
+
echo " │ >>> RESTART YOUR EDITOR TO ACTIVATE DRAFTED <<< │"
|
|
1264
|
+
echo " │ │"
|
|
1265
|
+
echo " │ Close and reopen Claude Desktop, Claude Code, │"
|
|
1266
|
+
echo " │ Codex, or Cursor so it picks up the new MCP server. │"
|
|
1267
|
+
echo " │ │"
|
|
1268
|
+
echo " └─────────────────────────────────────────────────────────┘"
|
|
1269
|
+
echo -e "${RESET}"
|