@awesomate/hosting-mcp 0.7.1 → 0.7.3

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 CHANGED
@@ -14,14 +14,24 @@ which redeems a setup code for a scoped access token (stored at
14
14
  (Support Plus+), and registers this server. The **`awesomate-hosting` skill**
15
15
  (bundled under `skill/`) makes Claude plan-aware and enforces snapshot-before-change.
16
16
 
17
- Manual registration:
18
-
19
- ```bash
20
- claude mcp add awesomate-hosting --env AWESOMATE_PAT=amt_pat_... -- npx -y @awesomate/hosting-mcp
17
+ Manual registration — add to `~/.claude.json` under `mcpServers` (the
18
+ bootstrap does this automatically; never bake `AWESOMATE_PAT` into the env,
19
+ that pins the registration to one stale account forever):
20
+
21
+ ```json
22
+ "awesomate-hosting": {
23
+ "command": "npx",
24
+ "args": ["-y", "--package=@awesomate/hosting-mcp", "awesomate-hosting-mcp"]
25
+ }
21
26
  ```
22
27
 
23
- Config resolution: `AWESOMATE_PAT` / `AWESOMATE_API_BASE` env vars, falling
24
- back to `~/.awesomate/credentials.json` (`{ "pat": "...", "apiBase": "..." }`).
28
+ If you use `claude mcp add` instead, keep the `--package=` form — the claude
29
+ CLI pre-scans the whole argv for its own global `-p`/`--print` flag (even
30
+ after `--`), so a bare `-p <pkg>` breaks its option parsing.
31
+
32
+ Account resolution: `AWESOMATE_PAT` env → `AWESOMATE_ACCOUNT` env →
33
+ `.awesomate.json` folder pin → sole/default profile in
34
+ `~/.awesomate/credentials.json` (multi-account since 0.7.0).
25
35
 
26
36
  ## Tools (v0.1 — read-only)
27
37
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awesomate/hosting-mcp",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "Awesomate MCP server — lets Claude manage your Awesomate WordPress hosting, plan, limits, and n8n automations",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -97,6 +97,33 @@ fallback** — that's deliberate.
97
97
  `GET {apiBase}/api/hosting-access/context` (401 without a token still proves
98
98
  reachability).
99
99
 
100
+ ### When you're stuck: generate a support report
101
+
102
+ If a connect or tool failure survives the documented fixes above (wrong
103
+ account, proxy, resume, restart, legacy registrations), don't keep guessing —
104
+ hand Awesomate a diagnostic they can act on:
105
+
106
+ 1. Run `node ~/.claude/skills/awesomate-hosting/scripts/support-report.mjs
107
+ --note "<one line: what the user was doing and what happened>"`.
108
+ It writes a **fully redacted** report to
109
+ `~/.awesomate/support-report-<timestamp>.md` — tokens reduced to
110
+ prefix+last4, no key material, proxy credentials stripped. It includes
111
+ versions, profiles/pin/registration state, live connectivity probes, and
112
+ the last bootstrap log automatically.
113
+ 2. Show the user the file path and the headline findings (the ACTIVE line,
114
+ any ⚠ LEGACY registration flags, and the probe results).
115
+ 3. **Ask the user before submitting.** With their OK, re-run with `--submit`
116
+ — it POSTs the report to Awesomate and returns a reference like
117
+ `ASR-XXXXXXXX`. Tell the user to quote that reference to
118
+ support@awesomate.ai or their Awesomate contact; the report is already
119
+ attached to it server-side. Submission works even when the token is
120
+ broken (that's usually why you're here).
121
+ 4. If `--submit` fails too (fully offline), the user emails the file itself —
122
+ it's safe to send as-is.
123
+
124
+ Never edit the report to add raw tokens, codes, or keys, and never submit
125
+ without the user's explicit go-ahead.
126
+
100
127
  If this skill is loaded but **no `awesomate_*` tools exist in the session at
101
128
  all**, the MCP server was registered after Claude Code started (the bootstrap
102
129
  just ran). Don't investigate settings files or reinstall anything — and don't
@@ -215,11 +242,19 @@ Git skills drive the Git workflow; this skill just deploys the result.
215
242
  effort) generates an SSH keypair and registers the public key. If the SSH
216
243
  step fails (e.g. hosting not fully provisioned), the token + skill + MCP are
217
244
  still set up. The Connect-Claude-Code prompt from the hub runs this via
218
- `npx -p @awesomate/hosting-mcp awesomate-hosting-bootstrap`.
245
+ `npx -y --package=@awesomate/hosting-mcp awesomate-hosting-bootstrap`.
246
+ (Always the `--package=` form — a bare `-p` breaks the claude CLI's argv
247
+ pre-scan if the command is ever wrapped in `claude mcp add`.)
219
248
  - `ssh-connect.sh` — open a jailed SSH session (or run one remote command).
220
249
  - `wp.sh` — run a WP-CLI command against a live site over that SSH.
221
250
  - `deploy.sh` — snapshot-first Studio→live deploy (files, optional DB).
222
251
  - `pull-live.sh` — clone a live site down to a local folder (read-only on live).
252
+ - `resolve-account.mjs` — shared account resolver (pin/env/profile precedence)
253
+ used by the shell scripts and the REST fallback; `--api` emits API/PAT/ACCT,
254
+ `--ssh` emits the ssh block.
255
+ - `support-report.mjs` — redacted diagnostic bundle for Awesomate support
256
+ (see "When you're stuck" above); `--submit` delivers it and returns a
257
+ reference ID.
223
258
 
224
259
  All scripts read `~/.awesomate/credentials.json`; none take secrets on the
225
260
  command line.
@@ -61,6 +61,37 @@ const credPath = join(dir, 'credentials.json');
61
61
  const keyPath = join(keysDir, 'id_ed25519');
62
62
  const PIN_FILENAME = '.awesomate.json';
63
63
 
64
+ // ---------------------------------------------------------------------------
65
+ // Connect log: every run's output (redacted) lands in ~/.awesomate/
66
+ // last-connect.log so a failure is diagnosable AFTER the fact — the
67
+ // support-report script attaches it automatically. Tokens/codes never reach
68
+ // the log: bootstrap output doesn't print them and the argv line is redacted.
69
+ // ---------------------------------------------------------------------------
70
+ const CONNECT_LOG = [`--- awesomate connect ${new Date().toISOString()} ---`];
71
+ function redactSecrets(s) {
72
+ return String(s)
73
+ .replace(/amt_pat_[A-Za-z0-9_-]{8,}/g, (t) => `${t.slice(0, 12)}…${t.slice(-4)}`)
74
+ .replace(/amt_bs_[A-Za-z0-9_-]{4,}/g, 'amt_bs_[redacted]');
75
+ }
76
+ CONNECT_LOG.push(`argv: ${process.argv.slice(2).map(redactSecrets).join(' ')}`);
77
+ for (const method of ['log', 'error']) {
78
+ const original = console[method].bind(console);
79
+ console[method] = (...args) => {
80
+ CONNECT_LOG.push(redactSecrets(args.join(' ')));
81
+ original(...args);
82
+ };
83
+ }
84
+ process.on('exit', (code) => {
85
+ try {
86
+ CONNECT_LOG.push(`exit: ${code}`);
87
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
88
+ writeFileSync(join(dir, 'last-connect.log'), `${CONNECT_LOG.join('\n')}\n`, { mode: 0o600 });
89
+ } catch { /* logging must never block exit */ }
90
+ });
91
+
92
+ const SUPPORT_HINT =
93
+ 'Stuck? Generate a support report: node ~/.claude/skills/awesomate-hosting/scripts/support-report.mjs --note "<what happened>" (add --submit to send it to Awesomate and get a reference ID).';
94
+
64
95
  // ---------------------------------------------------------------------------
65
96
  // Proxy-aware fetch. Node's built-in fetch does NOT honor HTTP(S)_PROXY —
66
97
  // in sandboxes that force egress through a proxy, requests bypass it and the
@@ -287,9 +318,16 @@ function installSkill() {
287
318
  return true;
288
319
  }
289
320
 
321
+ // --package= (with the =) instead of -p: the claude CLI pre-scans the FULL
322
+ // argv for its own global -p/--print flag — even after the `--` separator —
323
+ // so any `claude mcp add … -- npx -y -p pkg bin` flips it into print mode and
324
+ // it rejects the scope flag as "unknown option" (marlins bug report,
325
+ // 2026-07-28). npx treats both forms identically at runtime; the = form keeps
326
+ // the value glued to the token so these args are safe to copy anywhere.
327
+ // The explicit package must stay: this package ships two bins.
290
328
  const CLEAN_ENTRY = {
291
329
  command: 'npx',
292
- args: ['-y', '-p', '@awesomate/hosting-mcp', 'awesomate-hosting-mcp'],
330
+ args: ['-y', '--package=@awesomate/hosting-mcp', 'awesomate-hosting-mcp'],
293
331
  };
294
332
  const MANUAL_ADD_JSON = JSON.stringify({ mcpServers: { 'awesomate-hosting': CLEAN_ENTRY } }, null, 2);
295
333
 
@@ -349,7 +387,15 @@ function ensureMcpRegistration() {
349
387
  }
350
388
  }
351
389
 
352
- if (changed) writeJsonFile(claudeJsonPath, cfg);
390
+ if (changed) {
391
+ writeJsonFile(claudeJsonPath, cfg);
392
+ // Read back and verify — never trust a write (or a CLI exit code) alone.
393
+ const verify = readJsonFile(claudeJsonPath);
394
+ const written = verify?.mcpServers?.['awesomate-hosting'];
395
+ if (!written || written.command !== CLEAN_ENTRY.command || !JSON.stringify(written.args ?? []).includes('awesomate-hosting-mcp')) {
396
+ throw new Error(`wrote ${claudeJsonPath} but the awesomate-hosting entry did not verify on read-back`);
397
+ }
398
+ }
353
399
 
354
400
  // Project-scope .mcp.json in cwd.
355
401
  const mcpJsonPath = join(cwd, '.mcp.json');
@@ -487,6 +533,7 @@ async function main() {
487
533
  console.log('Setup complete. Next: run awesomate_whoami to confirm the account, then ask me to read your plan and list your sites.');
488
534
  console.log('(The skill works in your current Claude session right away via its REST fallback — restart Claude Code when convenient to load the MCP tools.)');
489
535
  if (issues.length) {
536
+ console.log(SUPPORT_HINT);
490
537
  console.log(`AWESOMATE CONNECT: PARTIAL account=${slug} pin=${pinPath ?? 'none'} issues=${issues.join(',')}`);
491
538
  process.exit(1);
492
539
  }
@@ -498,6 +545,7 @@ main().catch((err) => {
498
545
  if (err.isApiError && err.status === 401) {
499
546
  console.error('Grab a fresh code from hub.awesomate.ai/sites — codes last 10 minutes (and stay re-runnable within that window).');
500
547
  }
548
+ console.error(SUPPORT_HINT);
501
549
  console.error('AWESOMATE CONNECT: FAILED reason=error');
502
550
  process.exit(1);
503
551
  });
@@ -0,0 +1,261 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Awesomate support report — turn a broken setup into something support can
4
+ * actually act on.
5
+ *
6
+ * Collects a REDACTED diagnostic snapshot of this machine's Awesomate
7
+ * connection state (versions, profiles, pins, MCP registrations, proxy env,
8
+ * live connectivity probes, ssh tooling, the last bootstrap log) and writes
9
+ * it to ~/.awesomate/support-report-<timestamp>.md.
10
+ *
11
+ * node support-report.mjs [--note "what went wrong"] [--category connect|tools|ssh|deploy|n8n|other]
12
+ * node support-report.mjs --submit # also POST it to Awesomate (returns a reference ID)
13
+ *
14
+ * Redaction guarantees (safe to email or submit):
15
+ * - access tokens → first 12 chars + … + last 4 (identifies the row, useless as a credential)
16
+ * - setup codes → amt_bs_[redacted]
17
+ * - ssh → fingerprint + paths only, never key material
18
+ * - proxy URLs → user:pass@ stripped
19
+ *
20
+ * Node 18+ built-ins only.
21
+ */
22
+
23
+ import { execFileSync } from 'node:child_process';
24
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs';
25
+ import { homedir, hostname, platform, release, arch } from 'node:os';
26
+ import { join, dirname, resolve } from 'node:path';
27
+
28
+ function arg(name, fallback) {
29
+ const i = process.argv.indexOf(`--${name}`);
30
+ return i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--') ? process.argv[i + 1] : fallback;
31
+ }
32
+ const SUBMIT = process.argv.includes('--submit');
33
+ const NOTE = arg('note', null);
34
+ const CATEGORY = arg('category', 'connect');
35
+
36
+ const HOME = homedir();
37
+ const AWM_DIR = join(HOME, '.awesomate');
38
+ const CRED_PATH = join(AWM_DIR, 'credentials.json');
39
+ const PIN_FILENAME = '.awesomate.json';
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Redaction — applied to individual values AND as a final pass over the whole
43
+ // document, so nothing secret can leak through a path we forgot.
44
+ // ---------------------------------------------------------------------------
45
+ function redact(s) {
46
+ return String(s)
47
+ .replace(/amt_pat_[A-Za-z0-9_-]{8,}/g, (t) => `${t.slice(0, 12)}…${t.slice(-4)}`)
48
+ .replace(/amt_bs_[A-Za-z0-9_-]{4,}/g, 'amt_bs_[redacted]')
49
+ .replace(/(https?:\/\/)[^/@\s]+@/g, '$1[credentials-redacted]@')
50
+ .replace(/-----BEGIN[\s\S]*?-----END [A-Z ]*KEY-----/g, '[key-material-redacted]');
51
+ }
52
+
53
+ function readJson(path) {
54
+ try {
55
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
56
+ return parsed && typeof parsed === 'object' ? parsed : null;
57
+ } catch { return null; }
58
+ }
59
+
60
+ function tryExec(cmd, args) {
61
+ try {
62
+ return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 8000 }).trim();
63
+ } catch { return null; }
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Account resolution — same precedence as the MCP server / resolve-account.mjs
68
+ // (duplicated minimally here so the report works even when those are broken).
69
+ // ---------------------------------------------------------------------------
70
+ function findPin() {
71
+ const home = resolve(HOME);
72
+ let dir = resolve(process.cwd());
73
+ for (;;) {
74
+ const candidate = join(dir, PIN_FILENAME);
75
+ if (existsSync(candidate)) return candidate;
76
+ if (dir === home) return null;
77
+ const parent = dirname(dir);
78
+ if (parent === dir) return null;
79
+ dir = parent;
80
+ }
81
+ }
82
+
83
+ function profileSummary(key, p) {
84
+ return {
85
+ profile: key,
86
+ slug: p.slug ?? null,
87
+ contactId: p.contactId ?? null,
88
+ email: p.email ?? null,
89
+ plan: p.plan ?? null,
90
+ apiBase: p.apiBase ?? null,
91
+ tokenRedacted: p.pat ? redact(p.pat) : null,
92
+ tokenExpiresAt: p.expiresAt ?? null,
93
+ ssh: p.ssh ? { host: p.ssh.host, user: p.ssh.user, port: p.ssh.port ?? 22, fingerprint: p.ssh.fingerprint ?? null, keyPath: p.ssh.keyPath ?? null } : null,
94
+ };
95
+ }
96
+
97
+ async function main() {
98
+ const lines = [];
99
+ const push = (s = '') => lines.push(s);
100
+
101
+ // ── Environment ──────────────────────────────────────────────────────────
102
+ push('# Awesomate support report');
103
+ push('');
104
+ push(`- generated: ${new Date().toISOString()}`);
105
+ push(`- host: ${hostname()} (${platform()} ${release()}, ${arch()})`);
106
+ push(`- node: ${process.version}`);
107
+ push(`- claude CLI: ${tryExec('claude', ['--version']) ?? 'not found on PATH'}`);
108
+ const installedVersion = (() => {
109
+ try { return readFileSync(join(HOME, '.claude', 'skills', 'awesomate-hosting', '.installed-version'), 'utf8').trim(); } catch { return null; }
110
+ })();
111
+ push(`- installed skill version: ${installedVersion ?? 'no marker (pre-0.6 install?)'}`);
112
+ push(`- cwd: ${process.cwd()}`);
113
+ if (NOTE) { push(''); push(`## What the user reports`); push(''); push(NOTE); }
114
+
115
+ // ── Proxy environment ────────────────────────────────────────────────────
116
+ push(''); push('## Proxy environment'); push('');
117
+ const proxyVars = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'NO_PROXY', 'no_proxy', 'NODE_USE_ENV_PROXY'];
118
+ const setVars = proxyVars.filter((v) => process.env[v]);
119
+ if (setVars.length === 0) push('- none set');
120
+ for (const v of setVars) push(`- ${v}=${redact(process.env[v])}`);
121
+
122
+ // ── Credentials + account resolution ─────────────────────────────────────
123
+ push(''); push('## Credentials & account resolution'); push('');
124
+ const creds = readJson(CRED_PATH);
125
+ let profiles = {};
126
+ let activeApiBase = 'https://hub.awesomate.ai';
127
+ let activePat = null;
128
+ let activeSlug = null;
129
+ if (!creds) {
130
+ push(`- ${CRED_PATH}: ${existsSync(CRED_PATH) ? 'EXISTS BUT UNPARSEABLE' : 'missing (never connected on this machine?)'}`);
131
+ } else {
132
+ if (creds.profiles && typeof creds.profiles === 'object') profiles = creds.profiles;
133
+ else if (creds.pat) profiles = { [creds.slug || 'default']: creds };
134
+ push(`- credentials file: v${creds.version ?? 1}, defaultProfile: ${creds.defaultProfile ?? '(none)'}`);
135
+ for (const [key, p] of Object.entries(profiles)) {
136
+ push(`- profile ${JSON.stringify(profileSummary(key, p))}`);
137
+ }
138
+ }
139
+ const pinPath = findPin();
140
+ const pin = pinPath ? readJson(pinPath) : null;
141
+ push(`- folder pin: ${pinPath ? `${pinPath} → ${JSON.stringify(pin)}` : 'none found (walk-up from cwd)'}`);
142
+ const names = Object.keys(profiles);
143
+ if (process.env.AWESOMATE_PAT) { activePat = process.env.AWESOMATE_PAT; activeSlug = '(env PAT)'; push('- ACTIVE: env AWESOMATE_PAT override'); }
144
+ else if (process.env.AWESOMATE_ACCOUNT && profiles[process.env.AWESOMATE_ACCOUNT]) { const p = profiles[process.env.AWESOMATE_ACCOUNT]; activePat = p.pat; activeSlug = p.slug; activeApiBase = p.apiBase ?? activeApiBase; push(`- ACTIVE: env AWESOMATE_ACCOUNT=${process.env.AWESOMATE_ACCOUNT}`); }
145
+ else if (pin?.account && profiles[pin.account]) { const p = profiles[pin.account]; activePat = p.pat; activeSlug = p.slug; activeApiBase = p.apiBase ?? activeApiBase; push(`- ACTIVE: pin → ${pin.account}`); }
146
+ else if (pin?.account && !profiles[pin.account]) { push(`- ACTIVE: NONE — pin names "${pin.account}" but no such profile exists (this is a hard error by design)`); }
147
+ else if (names.length === 1) { const p = profiles[names[0]]; activePat = p.pat; activeSlug = p.slug; activeApiBase = p.apiBase ?? activeApiBase; push(`- ACTIVE: sole profile ${names[0]}`); }
148
+ else if (creds?.defaultProfile && profiles[creds.defaultProfile]) { const p = profiles[creds.defaultProfile]; activePat = p.pat; activeSlug = p.slug; activeApiBase = p.apiBase ?? activeApiBase; push(`- ACTIVE: defaultProfile ${creds.defaultProfile}`); }
149
+ else { push(`- ACTIVE: NONE (${names.length} profiles, no pin/default)`); }
150
+
151
+ // ── MCP registrations ────────────────────────────────────────────────────
152
+ push(''); push('## MCP registrations'); push('');
153
+ const claudeJson = readJson(join(HOME, '.claude.json'));
154
+ const userEntry = claudeJson?.mcpServers?.['awesomate-hosting'];
155
+ push(`- user scope (~/.claude.json): ${userEntry ? JSON.stringify({ command: userEntry.command, args: userEntry.args, envKeys: Object.keys(userEntry.env ?? {}) }) : 'none'}`);
156
+ if (userEntry?.env?.AWESOMATE_PAT) push(' ⚠ LEGACY: token baked into env — this pins the registration to one stale account. Re-run Connect to fix.');
157
+ for (const [projectDir, project] of Object.entries(claudeJson?.projects ?? {})) {
158
+ const e = project?.mcpServers?.['awesomate-hosting'];
159
+ if (e) push(`- local scope (${projectDir}): envKeys=${JSON.stringify(Object.keys(e.env ?? {}))}${e.env?.AWESOMATE_PAT ? ' ⚠ LEGACY env token' : ''}`);
160
+ }
161
+ const mcpJson = readJson(join(process.cwd(), '.mcp.json'));
162
+ const projEntry = mcpJson?.mcpServers?.['awesomate-hosting'];
163
+ if (projEntry) push(`- project scope (cwd/.mcp.json): envKeys=${JSON.stringify(Object.keys(projEntry.env ?? {}))}${projEntry.env?.AWESOMATE_PAT ? ' ⚠ LEGACY env token' : ''}`);
164
+
165
+ // ── Connectivity probes (proxy-aware) ────────────────────────────────────
166
+ push(''); push('## Connectivity probes'); push('');
167
+ let doFetch = fetch;
168
+ if (process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy) {
169
+ try {
170
+ const undici = await import('undici');
171
+ const dispatcher = new undici.EnvHttpProxyAgent();
172
+ doFetch = (url, opts = {}) => undici.fetch(url, { ...opts, dispatcher });
173
+ push('- probe transport: undici EnvHttpProxyAgent (proxy honored)');
174
+ } catch {
175
+ push('- probe transport: built-in fetch (undici unavailable — proxy NOT honored unless NODE_USE_ENV_PROXY=1)');
176
+ }
177
+ } else {
178
+ push('- probe transport: built-in fetch (no proxy configured)');
179
+ }
180
+ async function probe(label, url, headers) {
181
+ try {
182
+ const res = await doFetch(url, { headers, signal: AbortSignal.timeout(10_000) });
183
+ const body = await res.text();
184
+ const hasApiBody = (() => { try { const j = JSON.parse(body); return j && typeof j === 'object'; } catch { return false; } })();
185
+ push(`- ${label}: HTTP ${res.status} (${hasApiBody ? 'API response body' : 'NON-API body — likely a gateway/proxy in the way'})`);
186
+ } catch (err) {
187
+ push(`- ${label}: FAILED (${err?.cause?.code ?? err?.name ?? err?.message})`);
188
+ }
189
+ }
190
+ await probe('GET /api/hosting-access/context (no auth — 401 proves reachability)', `${activeApiBase}/api/hosting-access/context`);
191
+ if (activePat) {
192
+ await probe(`GET /api/hosting-access/context (as ${activeSlug})`, `${activeApiBase}/api/hosting-access/context`, { Authorization: `Bearer ${activePat}` });
193
+ } else {
194
+ push('- authenticated probe skipped: no active profile resolved');
195
+ }
196
+
197
+ // ── SSH tooling ──────────────────────────────────────────────────────────
198
+ push(''); push('## SSH tooling'); push('');
199
+ push(`- ssh: ${tryExec('ssh', ['-V']) ?? tryExec('sh', ['-c', 'ssh -V 2>&1']) ?? 'not found'}`);
200
+ push(`- ssh-keygen: ${tryExec('sh', ['-c', 'command -v ssh-keygen']) ?? 'not found'}`);
201
+ const keyPath = join(AWM_DIR, 'keys', 'id_ed25519');
202
+ push(`- key file: ${existsSync(keyPath) ? `present — ${tryExec('ssh-keygen', ['-lf', keyPath]) ?? 'fingerprint unavailable'}` : 'absent'}`);
203
+
204
+ // ── Last bootstrap log ───────────────────────────────────────────────────
205
+ push(''); push('## Last connect attempt (~/.awesomate/last-connect.log)'); push('');
206
+ const logPath = join(AWM_DIR, 'last-connect.log');
207
+ if (existsSync(logPath)) {
208
+ push('```');
209
+ push(redact(readFileSync(logPath, 'utf8')).trim().split('\n').slice(-60).join('\n'));
210
+ push('```');
211
+ } else {
212
+ push('- no log (bootstrap predates 0.7.3, or never ran on this machine)');
213
+ }
214
+
215
+ // ── Write + optionally submit ────────────────────────────────────────────
216
+ const report = redact(lines.join('\n')) + '\n';
217
+ mkdirSync(AWM_DIR, { recursive: true, mode: 0o700 });
218
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
219
+ const outPath = join(AWM_DIR, `support-report-${stamp}.md`);
220
+ writeFileSync(outPath, report);
221
+ chmodSync(outPath, 0o600);
222
+ console.log(`✓ Support report written to ${outPath} (all tokens redacted — safe to send).`);
223
+
224
+ if (!SUBMIT) {
225
+ console.log('Send it to support@awesomate.ai, or re-run with --submit to deliver it directly and get a reference ID.');
226
+ return;
227
+ }
228
+
229
+ const submitBody = {
230
+ report,
231
+ summary: NOTE ? NOTE.slice(0, 255) : `Diagnostic from ${activeSlug ?? 'unconnected machine'} (${hostname()})`,
232
+ slug: activeSlug && activeSlug !== '(env PAT)' ? activeSlug : null,
233
+ email: null,
234
+ category: CATEGORY,
235
+ clientVersion: installedVersion,
236
+ };
237
+ try {
238
+ const res = await doFetch(`${activeApiBase}/api/hosting-access/support-report`, {
239
+ method: 'POST',
240
+ headers: { 'Content-Type': 'application/json', ...(activePat ? { Authorization: `Bearer ${activePat}` } : {}) },
241
+ body: JSON.stringify(submitBody),
242
+ signal: AbortSignal.timeout(15_000),
243
+ });
244
+ const json = await res.json().catch(() => null);
245
+ if (res.ok && json?.reference) {
246
+ console.log(`✓ Submitted to Awesomate support. Reference: ${json.reference}`);
247
+ console.log(` ${json.next}`);
248
+ } else {
249
+ console.error(`Submit failed (HTTP ${res.status}${json?.error ? `: ${json.error}` : ''}) — email the file above to support@awesomate.ai instead.`);
250
+ process.exitCode = 1;
251
+ }
252
+ } catch (err) {
253
+ console.error(`Submit failed (${err?.cause?.code ?? err?.message}) — email the file above to support@awesomate.ai instead.`);
254
+ process.exitCode = 1;
255
+ }
256
+ }
257
+
258
+ main().catch((err) => {
259
+ console.error('support-report failed:', err?.stack ?? err?.message ?? String(err));
260
+ process.exit(1);
261
+ });