@awesomate/hosting-mcp 0.14.0 → 0.16.0
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/dist/index.js +8 -4
- package/package.json +1 -1
- package/skill/awesomate-credentials/SKILL.md +79 -26
- package/skill/awesomate-credentials/references/where-secrets-go.md +11 -8
- package/skill/awesomate-credentials/scripts/secret-drop.mjs +279 -0
- package/skill/awesomate-hosting/SKILL.md +3 -2
- package/skill/awesomate-support/SKILL.md +9 -5
package/dist/index.js
CHANGED
|
@@ -40031,7 +40031,7 @@ server.registerTool(
|
|
|
40031
40031
|
server.registerTool(
|
|
40032
40032
|
"awesomate_get_context",
|
|
40033
40033
|
{
|
|
40034
|
-
description: "Call this FIRST each session. Returns the connected Awesomate account: plan, capabilities (shell access), plan limits, scopes this token can exercise, token expiry (warn the user if within 7 days), cPanel routing when provisioned, and skill.updateAvailable \u2014 if true, the local
|
|
40034
|
+
description: "Call this FIRST each session. Returns the connected Awesomate account: plan, capabilities (shell access), plan limits, scopes this token can exercise, token expiry (warn the user if within 7 days), cPanel routing when provisioned, and skill.updateAvailable \u2014 if true, the local Awesomate skill files are older than this server; run awesomate_skill_update to refresh them in place (new content loads next session \u2014 mention once, finish the current task first). If the token is also near expiry, re-running Connect Claude Code from hub.awesomate.ai/sites refreshes skills AND renews the token in one go.",
|
|
40035
40035
|
inputSchema: {}
|
|
40036
40036
|
},
|
|
40037
40037
|
async () => {
|
|
@@ -40285,10 +40285,10 @@ server.registerTool(
|
|
|
40285
40285
|
server.registerTool(
|
|
40286
40286
|
"awesomate_support",
|
|
40287
40287
|
{
|
|
40288
|
-
description: "Help the user with the service itself \u2014 questions, being stuck, or reaching a human. action 'faq' {q} searches the published help library (answer FROM the results; never invent policy or pricing). action 'create_ticket' {subject, message} opens a support ticket \u2014 draft it in the user's words, SHOW it, and get an explicit yes before sending; it emails the Awesomate team and returns a portal URL. action 'list_tickets' shows their tickets and status. Use the awesomate-support skill for the full flow. Not for building \u2014 route n8n work to awesomate-n8n and hosting to awesomate-hosting.",
|
|
40288
|
+
description: "Help the user with the service itself \u2014 questions, being stuck, or reaching a human. action 'faq' {q} searches the published help library (answer FROM the results; never invent policy or pricing). action 'help_docs' {q} searches the full Help Centre articles live \u2014 use when the FAQ has no good answer, and cite the returned article URLs. action 'create_ticket' {subject, message} opens a support ticket \u2014 draft it in the user's words, SHOW it, and get an explicit yes before sending; it emails the Awesomate team and returns a portal URL. action 'list_tickets' shows their tickets and status. Use the awesomate-support skill for the full flow. Not for building \u2014 route n8n work to awesomate-n8n and hosting to awesomate-hosting.",
|
|
40289
40289
|
inputSchema: {
|
|
40290
|
-
action: external_exports.enum(["faq", "create_ticket", "list_tickets"]),
|
|
40291
|
-
q: external_exports.string().optional().describe("faq
|
|
40290
|
+
action: external_exports.enum(["faq", "help_docs", "create_ticket", "list_tickets"]),
|
|
40291
|
+
q: external_exports.string().optional().describe("faq and help_docs: the question or topic"),
|
|
40292
40292
|
subject: external_exports.string().optional().describe("create_ticket only"),
|
|
40293
40293
|
message: external_exports.string().optional().describe("create_ticket only: the body, in the user's words")
|
|
40294
40294
|
}
|
|
@@ -40299,6 +40299,10 @@ server.registerTool(
|
|
|
40299
40299
|
if (action === "faq") {
|
|
40300
40300
|
return textResult(await hubGet(cfg, `/api/help/faq${q ? `?q=${encodeURIComponent(q)}` : ""}`));
|
|
40301
40301
|
}
|
|
40302
|
+
if (action === "help_docs") {
|
|
40303
|
+
if (!q) return errorResult(new Error("help_docs needs q"));
|
|
40304
|
+
return textResult(await hubGet(cfg, `/api/help/docs?q=${encodeURIComponent(q)}`));
|
|
40305
|
+
}
|
|
40302
40306
|
if (action === "list_tickets") {
|
|
40303
40307
|
return textResult(await hubGet(cfg, "/api/support/tickets"));
|
|
40304
40308
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awesomate/hosting-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Awesomate MCP server \u2014 lets Claude manage your Awesomate WordPress hosting, plan, limits, n8n automations, and build Node/static apps + databases",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: awesomate-credentials
|
|
3
|
-
description: Safely capture and store API keys, tokens, database URLs and other secrets for the user's Awesomate apps — encrypted in the hub and injected into the app's .env, never committed to git, never echoed back. Use whenever the user
|
|
3
|
+
description: Safely capture and store API keys, tokens, database URLs and other secrets for the user's Awesomate apps — via a local secret-drop link so the value never enters the chat, encrypted in the hub and injected into the app's .env, never committed to git, never echoed back. Use whenever the user needs to provide a secret ("here's my API key", "I need to give you a key / token / password", "add this key", "store this secret", "save my credentials"), pastes something that looks like a secret, or drops a key into a file. Companion to awesomate-app-builder. If the secret is for an n8n workflow or automation, route to the awesomate-n8n skill instead — n8n credentials are created on the n8n instance, never in .env.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Awesomate Credentials — handle secrets safely
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
The best moment to protect a secret is BEFORE it is typed. When the user needs
|
|
9
|
+
to provide one, don't ask them to paste it — serve a secret-drop link (below).
|
|
10
|
+
If they've already pasted it, store it fast and say the rotation line.
|
|
11
|
+
|
|
12
|
+
## Direct invocation (`/awesomate-credentials`)
|
|
13
|
+
|
|
14
|
+
When the user invokes this skill by name without revealing a secret, go
|
|
15
|
+
straight to the link flow — the link IS the answer, not a description of it.
|
|
16
|
+
If the conversation already says which app the key is for, launch hub mode for
|
|
17
|
+
that app now. Otherwise ask exactly one question — *"is this for one of your
|
|
18
|
+
apps, or just a key to keep on this machine?"* — then launch.
|
|
11
19
|
|
|
12
20
|
## The rules (non-negotiable)
|
|
13
21
|
|
|
@@ -17,46 +25,91 @@ never leak them.
|
|
|
17
25
|
2. **Never commit a secret.** App secrets live in `.env` (gitignored) or the
|
|
18
26
|
hub's encrypted store — never in tracked files. If you see a secret in code
|
|
19
27
|
about to be committed, stop and move it.
|
|
20
|
-
3. **A secret
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
28
|
+
3. **A secret that entered the conversation is exposed** — pasted into chat OR
|
|
29
|
+
read from a file into your context. Store it, then tell the user plainly:
|
|
30
|
+
*"treat it as exposed — rotate it when convenient; next time I'll give you a
|
|
31
|
+
secret-drop link so it never touches the chat."*
|
|
32
|
+
4. **The secret-drop link is the only zero-exposure flow.** Prefer it whenever
|
|
33
|
+
the value hasn't been revealed yet.
|
|
34
|
+
|
|
35
|
+
## The secret-drop link (preferred)
|
|
36
|
+
|
|
37
|
+
A one-time form served on the user's own machine (binds 127.0.0.1 only). The
|
|
38
|
+
value goes browser → destination directly; you only ever see the key NAMES.
|
|
39
|
+
|
|
40
|
+
1. Pick a free port (45000–49000) and invent a 32-char hex token. Both may
|
|
41
|
+
appear in chat — they gate the link, not the secret.
|
|
42
|
+
2. Start it in the background. Two destinations:
|
|
43
|
+
|
|
44
|
+
**App runtime secret** (an API key the backend calls, a token — goes to the
|
|
45
|
+
app's encrypted hub env, then its `.env`, then the app restarts):
|
|
46
|
+
```bash
|
|
47
|
+
node ~/.claude/skills/awesomate-credentials/scripts/secret-drop.mjs \
|
|
48
|
+
--hub-app <appId> --env dev --keys STRIPE_SECRET_KEY --port <port> --token <hex>
|
|
49
|
+
```
|
|
50
|
+
Get `<appId>` from `awesomate_app_list`. Keys are UPPER_SNAKE (lowercase is
|
|
51
|
+
auto-uppercased). Node apps only — static sites have no server env. The
|
|
52
|
+
script posts each value to the hub with the user's own PAT
|
|
53
|
+
(`~/.awesomate/credentials.json`) — the same route `awesomate_app_set_env`
|
|
54
|
+
uses, so encryption, `.env` injection and restart all behave identically.
|
|
55
|
+
|
|
56
|
+
**A personal key just to keep** (not an app runtime secret):
|
|
57
|
+
```bash
|
|
58
|
+
node ~/.claude/skills/awesomate-credentials/scripts/secret-drop.mjs \
|
|
59
|
+
--keys OPENAI_API_KEY --port <port> --token <hex>
|
|
60
|
+
```
|
|
61
|
+
Writes to `~/.config/api-keys.env` (mode 600); `--file .env` targets a local
|
|
62
|
+
dev env file instead (gitignored files only).
|
|
63
|
+
|
|
64
|
+
3. Give the user the link as clickable markdown —
|
|
65
|
+
`http://127.0.0.1:<port>/?t=<token>` — and say: *"click this and paste your
|
|
66
|
+
key there; I never see the value."*
|
|
67
|
+
4. The background task prints `SAVED: <names>` (names only, never values). In
|
|
68
|
+
hub mode a failed key prints `FAILED: <name> (status …)` and the form stays
|
|
69
|
+
up so the user can resubmit — report the reason and have them retry.
|
|
70
|
+
5. `EADDRINUSE` on start → relaunch on another port with a fresh token.
|
|
71
|
+
6. Confirm with key names only. No rotation warning needed — the value never
|
|
72
|
+
entered the conversation.
|
|
24
73
|
|
|
25
74
|
## Detect and offer
|
|
26
75
|
|
|
27
76
|
If the user's message contains something shaped like a secret — `sk-…`,
|
|
28
77
|
`ghp_`/`gho_…`, `AKIA…`, `xox[bp]-…`, a bearer token, a `postgres://…` /
|
|
29
|
-
`mysql://…` URL
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
78
|
+
`mysql://…` URL — it is already exposed: store it immediately (below), redact
|
|
79
|
+
it from your responses, and say the rotation line. But if they ANNOUNCE a
|
|
80
|
+
secret without pasting it ("I've got the Stripe key ready"), that is the moment
|
|
81
|
+
the link flow exists for.
|
|
33
82
|
|
|
34
83
|
## Where a secret goes
|
|
35
84
|
|
|
36
85
|
Read [references/where-secrets-go.md](references/where-secrets-go.md) for the
|
|
37
86
|
full map. Short version:
|
|
38
87
|
|
|
39
|
-
- **A running app needs it** (API key the backend calls, a DB URL) →
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
(default `dev`). Node apps only —
|
|
43
|
-
|
|
44
|
-
|
|
88
|
+
- **A running app needs it** (API key the backend calls, a DB URL) → the app's
|
|
89
|
+
encrypted hub env + `.env` (0600) + restart. Via the secret-drop `--hub-app`
|
|
90
|
+
link when the value hasn't been revealed, or **`awesomate_app_set_env`**
|
|
91
|
+
(tool) when it already has. Ask which env (default `dev`). Node apps only —
|
|
92
|
+
static sites have no server env.
|
|
93
|
+
- **A personal key just to keep** → `~/.config/api-keys.env` (chmod 600), via
|
|
94
|
+
the file-mode link; add a rotation note if it came through the chat.
|
|
45
95
|
- **Never** a tracked file, a commit, or the chat.
|
|
46
96
|
|
|
47
|
-
##
|
|
97
|
+
## Fallback: the file-drop flow
|
|
48
98
|
|
|
49
|
-
|
|
99
|
+
Only when the link flow can't work (e.g. Claude is running on a remote machine
|
|
100
|
+
the user's browser can't reach as 127.0.0.1):
|
|
50
101
|
|
|
51
102
|
1. Tell them: *"Create a file `.awesomate/secret-inbox.txt` in your project and
|
|
52
103
|
paste the value there — I'll grab it and wipe the file."* (`.awesomate/` is
|
|
53
104
|
gitignored by the github skill.)
|
|
54
|
-
2. Read the file
|
|
55
|
-
then **scrub the file**:
|
|
105
|
+
2. Read the file, store via `awesomate_app_set_env`, then **scrub the file**:
|
|
56
106
|
`node ~/.claude/skills/awesomate-credentials/scripts/capture-secret.mjs .awesomate/secret-inbox.txt`
|
|
57
|
-
|
|
58
|
-
|
|
107
|
+
3. Be honest with the user: reading the file put the value in the conversation
|
|
108
|
+
record, so the rotation nudge still applies — softer than a chat paste, not
|
|
109
|
+
zero.
|
|
59
110
|
|
|
60
111
|
## After storing
|
|
61
|
-
|
|
62
|
-
|
|
112
|
+
|
|
113
|
+
Tell the user what you set (key + env + where), that it's encrypted and
|
|
114
|
+
injected, and — if the value ever entered the conversation — the one-line
|
|
115
|
+
rotation nudge. Never restate the value.
|
|
@@ -2,25 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
| The secret is… | Put it… | How |
|
|
4
4
|
|---|---|---|
|
|
5
|
-
| An app runtime secret (API key the backend calls, a third-party token, a DB URL) | The app's `.env` **and** the hub's encrypted store | `awesomate_app_set_env` (tool)
|
|
5
|
+
| An app runtime secret (API key the backend calls, a third-party token, a DB URL) | The app's `.env` **and** the hub's encrypted store | Not yet revealed → secret-drop link with `--hub-app` (value never enters the chat). Already exposed → `awesomate_app_set_env` (tool). Both encrypt hub-side + inject into `.env` (0600) + restart the app |
|
|
6
6
|
| An n8n webhook URL the app calls (`N8N_WEBHOOK_URL`) | Same as above | `awesomate_app_set_env` — it's not secret-secret, but it belongs in `.env` with the rest |
|
|
7
|
-
| A personal key the user just wants kept (not used by a running app) | `~/.config/api-keys.env` (chmod 600) |
|
|
7
|
+
| A personal key the user just wants kept (not used by a running app) | `~/.config/api-keys.env` (chmod 600) | Secret-drop link in file mode (the default); add a `# pasted in chat <date> — rotate` note if it came through chat instead |
|
|
8
8
|
| Anything, ever | **NOT** in code, a tracked file, a commit, or the chat transcript | — |
|
|
9
9
|
|
|
10
10
|
## Why two places for app secrets
|
|
11
11
|
The hub's encrypted `hosted_app_env_secrets` row is the **durable source of
|
|
12
12
|
truth** (survives redeploys, is auditable, uses the same AES-256-GCM as the DB
|
|
13
13
|
password). The host `.env` is what the **running process actually reads**
|
|
14
|
-
(`dotenv/config`).
|
|
14
|
+
(`dotenv/config`). Both the secret-drop `--hub-app` flow and
|
|
15
|
+
`awesomate_app_set_env` write both in one step — they share the same hub route.
|
|
15
16
|
|
|
16
17
|
## Redaction pattern
|
|
17
18
|
When you confirm, show at most a short prefix:
|
|
18
19
|
- `sk-proj-abc…` → `OPENAI_API_KEY = sk-proj-…redacted`
|
|
19
20
|
- a DB URL → `DATABASE_URL = postgresql://…redacted`
|
|
20
|
-
Never the full value.
|
|
21
|
+
Never the full value. Keys captured through the secret-drop link need no
|
|
22
|
+
redaction gymnastics — you never saw the value; confirm with the name alone.
|
|
21
23
|
|
|
22
|
-
## Rotation note (
|
|
23
|
-
A value pasted into the conversation
|
|
24
|
-
After storing it, tell the user once:
|
|
25
|
-
|
|
24
|
+
## Rotation note (keys that entered the conversation)
|
|
25
|
+
A value pasted into the conversation — or read from a file into context — is in
|
|
26
|
+
the transcript; treat it as exposed. After storing it, tell the user once:
|
|
27
|
+
*"rotate this when convenient, and next time I'll give you a secret-drop link
|
|
28
|
+
so it never touches the chat."*
|
|
26
29
|
High-value keys (payment, production DB, cloud root) — recommend rotating now.
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// secret-drop — one-time localhost form for handing secrets to Claude
|
|
3
|
+
// without them entering the chat. Values are never printed; only key names.
|
|
4
|
+
//
|
|
5
|
+
// File mode (default): writes KEY=value into an env file (mode 600).
|
|
6
|
+
// node secret-drop.mjs [--file ~/.config/api-keys.env] [--keys A,B]
|
|
7
|
+
// Hub mode: posts each key to the Awesomate hub's encrypted app env —
|
|
8
|
+
// the same POST /api/my-apps/apps/:id/env route as awesomate_app_set_env,
|
|
9
|
+
// authenticated with the user's own PAT from ~/.awesomate/credentials.json.
|
|
10
|
+
// node secret-drop.mjs --hub-app <appId> [--env dev|staging|prod] [--keys A,B]
|
|
11
|
+
// Common: [--port 0] [--token <hex>] [--ttl 15] [--stay]
|
|
12
|
+
|
|
13
|
+
import http from 'node:http';
|
|
14
|
+
import crypto from 'node:crypto';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
const argVal = (name, def) => {
|
|
21
|
+
const i = args.indexOf(`--${name}`);
|
|
22
|
+
return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : def;
|
|
23
|
+
};
|
|
24
|
+
const expandHome = (p) => (p === '~' || p.startsWith('~/')) ? path.join(os.homedir(), p.slice(1)) : p;
|
|
25
|
+
|
|
26
|
+
const hubAppId = argVal('hub-app', '');
|
|
27
|
+
const hubEnv = argVal('env', 'dev');
|
|
28
|
+
const hubMode = hubAppId !== '';
|
|
29
|
+
const filePath = path.resolve(expandHome(argVal('file', '~/.config/api-keys.env')));
|
|
30
|
+
const ttlMs = Number(argVal('ttl', '15')) * 60_000;
|
|
31
|
+
const port = Number(argVal('port', '0'));
|
|
32
|
+
const token = argVal('token', crypto.randomBytes(16).toString('hex'));
|
|
33
|
+
const stay = args.includes('--stay');
|
|
34
|
+
|
|
35
|
+
const FILE_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
36
|
+
const HUB_KEY_RE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
37
|
+
const SAFE_VAL_RE = /^[A-Za-z0-9_@%+=:,.\/-]*$/;
|
|
38
|
+
|
|
39
|
+
const fail = (msg) => { console.error(`secret-drop: ${msg}`); process.exit(1); };
|
|
40
|
+
|
|
41
|
+
if (hubMode && (!/^[1-9]\d*$/.test(hubAppId))) fail(`--hub-app must be a positive app id, got: ${hubAppId}`);
|
|
42
|
+
if (hubMode && !['dev', 'staging', 'prod'].includes(hubEnv)) fail(`--env must be dev|staging|prod, got: ${hubEnv}`);
|
|
43
|
+
|
|
44
|
+
const normalizeKey = (name) => (hubMode ? name.toUpperCase() : name);
|
|
45
|
+
const keyValid = (name) => (hubMode ? HUB_KEY_RE.test(name) : FILE_KEY_RE.test(name));
|
|
46
|
+
|
|
47
|
+
const expectedKeys = (argVal('keys', '') || '').split(',').map((s) => normalizeKey(s.trim())).filter(Boolean);
|
|
48
|
+
for (const k of expectedKeys) {
|
|
49
|
+
if (!keyValid(k)) fail(`invalid key name: ${k}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let hubAuth = null;
|
|
53
|
+
if (hubMode) {
|
|
54
|
+
let pat = process.env.AWESOMATE_PAT || '';
|
|
55
|
+
let base = process.env.AWESOMATE_API_BASE || '';
|
|
56
|
+
if (!pat || !base) {
|
|
57
|
+
try {
|
|
58
|
+
const raw = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.awesomate', 'credentials.json'), 'utf8'));
|
|
59
|
+
const prof = raw.profiles
|
|
60
|
+
? raw.profiles[process.env.AWESOMATE_ACCOUNT || raw.defaultProfile] || raw
|
|
61
|
+
: raw;
|
|
62
|
+
pat = pat || prof.pat || '';
|
|
63
|
+
base = base || prof.apiBase || '';
|
|
64
|
+
} catch { /* handled below */ }
|
|
65
|
+
}
|
|
66
|
+
if (!pat) fail('no Awesomate PAT found — connect your hosting first (~/.awesomate/credentials.json), or set AWESOMATE_PAT');
|
|
67
|
+
hubAuth = { pat, base: (base || 'https://hub.awesomate.ai').replace(/\/+$/, '') };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const destinationLabel = hubMode
|
|
71
|
+
? `app #${hubAppId} (${hubEnv} environment) — encrypted in your Awesomate hub`
|
|
72
|
+
: filePath;
|
|
73
|
+
|
|
74
|
+
const tokenOk = (t) => {
|
|
75
|
+
if (typeof t !== 'string') return false;
|
|
76
|
+
const a = Buffer.from(t);
|
|
77
|
+
const b = Buffer.from(token);
|
|
78
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const esc = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
82
|
+
|
|
83
|
+
const page = (title, body) => `<!doctype html><html><head><meta charset="utf-8">
|
|
84
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
85
|
+
<title>${title}</title><style>
|
|
86
|
+
body{background:#1c1917;color:#e7e5e4;font:15px/1.5 -apple-system,system-ui,sans-serif;
|
|
87
|
+
display:flex;justify-content:center;padding:48px 16px}
|
|
88
|
+
main{width:100%;max-width:560px}
|
|
89
|
+
h1{font-size:20px;font-weight:600}
|
|
90
|
+
.card{background:#292524;border:1px solid #44403c;border-radius:12px;padding:24px;margin-top:16px}
|
|
91
|
+
label{display:block;font-size:13px;color:#a8a29e;margin:14px 0 4px}
|
|
92
|
+
input.sec,textarea{width:100%;box-sizing:border-box;background:#1c1917;
|
|
93
|
+
color:#e7e5e4;border:1px solid #44403c;border-radius:8px;padding:10px;font:13px ui-monospace,monospace}
|
|
94
|
+
textarea{min-height:110px;resize:vertical}
|
|
95
|
+
button{margin-top:18px;background:#6366f1;color:#fff;border:0;border-radius:8px;
|
|
96
|
+
padding:10px 18px;font-size:14px;font-weight:600;cursor:pointer}
|
|
97
|
+
.muted{color:#a8a29e;font-size:13px}
|
|
98
|
+
code{background:#1c1917;padding:1px 5px;border-radius:4px;font-size:12.5px}
|
|
99
|
+
ul{padding-left:20px}
|
|
100
|
+
.ok{color:#4ade80}
|
|
101
|
+
.bad{color:#f87171}
|
|
102
|
+
</style></head><body><main>${body}</main></body></html>`;
|
|
103
|
+
|
|
104
|
+
const formPage = () => {
|
|
105
|
+
const fields = expectedKeys.map((k) =>
|
|
106
|
+
`<label for="k_${k}">${k}</label><input type="password" class="sec" id="k_${k}" name="k_${k}" autocomplete="off" spellcheck="false">`
|
|
107
|
+
).join('');
|
|
108
|
+
return page('Secret drop', `
|
|
109
|
+
<h1>Secret drop</h1>
|
|
110
|
+
<p class="muted">Values submitted here go straight to <code>${esc(destinationLabel)}</code>.
|
|
111
|
+
They never enter the Claude chat — Claude only sees the key names.</p>
|
|
112
|
+
<div class="card"><form method="post" action="/save">
|
|
113
|
+
<input type="hidden" name="t" value="${token}">
|
|
114
|
+
${fields}
|
|
115
|
+
<label for="bulk">${expectedKeys.length ? 'Or paste' : 'Paste'} one or more <code>KEY=value</code> lines</label>
|
|
116
|
+
<textarea id="bulk" name="bulk" autocomplete="off" spellcheck="false" placeholder="${hubMode ? 'STRIPE_SECRET_KEY=sk_live_...' : 'OPENROUTER_API_KEY=sk-or-... RESEND_API_KEY=re_...'}"></textarea>
|
|
117
|
+
${expectedKeys.length ? `<label style="display:flex;align-items:center;gap:6px;margin-top:10px">
|
|
118
|
+
<input type="checkbox" onchange="document.querySelectorAll('.sec').forEach(i=>i.type=this.checked?'text':'password')" style="width:auto"> show values
|
|
119
|
+
</label>` : ''}
|
|
120
|
+
<button type="submit">Save securely</button>
|
|
121
|
+
</form></div>`);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
function quoteVal(v) {
|
|
125
|
+
if ((v.startsWith('"') && v.endsWith('"') && v.length >= 2) ||
|
|
126
|
+
(v.startsWith("'") && v.endsWith("'") && v.length >= 2)) return v;
|
|
127
|
+
if (SAFE_VAL_RE.test(v)) return v;
|
|
128
|
+
return `'${v.replace(/'/g, `'\\''`)}'`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function writeEnv(file, updates) {
|
|
132
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
133
|
+
let lines = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n') : [];
|
|
134
|
+
while (lines.length && lines[lines.length - 1] === '') lines.pop();
|
|
135
|
+
const replaced = new Set();
|
|
136
|
+
lines = lines.map((line) => {
|
|
137
|
+
const m = line.match(/^(export\s+)?([A-Za-z_][A-Za-z0-9_]*)=/);
|
|
138
|
+
if (m && updates.has(m[2])) {
|
|
139
|
+
replaced.add(m[2]);
|
|
140
|
+
return `${m[1] || ''}${m[2]}=${quoteVal(updates.get(m[2]))}`;
|
|
141
|
+
}
|
|
142
|
+
return line;
|
|
143
|
+
});
|
|
144
|
+
for (const [k, v] of updates) {
|
|
145
|
+
if (!replaced.has(k)) lines.push(`${k}=${quoteVal(v)}`);
|
|
146
|
+
}
|
|
147
|
+
fs.writeFileSync(file, lines.join('\n') + '\n', { mode: 0o600 });
|
|
148
|
+
fs.chmodSync(file, 0o600);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function hubSet(key, value) {
|
|
152
|
+
const res = await fetch(`${hubAuth.base}/api/my-apps/apps/${hubAppId}/env`, {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${hubAuth.pat}` },
|
|
155
|
+
body: JSON.stringify({ key, value, env: hubEnv }),
|
|
156
|
+
signal: AbortSignal.timeout(20_000),
|
|
157
|
+
});
|
|
158
|
+
let detail = '';
|
|
159
|
+
try {
|
|
160
|
+
const j = await res.json();
|
|
161
|
+
detail = String(j.error || j.detail || '').slice(0, 200);
|
|
162
|
+
} catch { /* non-JSON body */ }
|
|
163
|
+
return { ok: res.ok, status: res.status, detail };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parseSubmission(params) {
|
|
167
|
+
const updates = new Map();
|
|
168
|
+
const invalid = [];
|
|
169
|
+
const add = (rawName, rawVal) => {
|
|
170
|
+
const name = normalizeKey(rawName.trim());
|
|
171
|
+
const val = rawVal.replace(/\r/g, '').trim();
|
|
172
|
+
if (!val) return;
|
|
173
|
+
if (keyValid(name)) updates.set(name, val);
|
|
174
|
+
else invalid.push(rawName.trim());
|
|
175
|
+
};
|
|
176
|
+
for (const [k, v] of params) {
|
|
177
|
+
if (k.startsWith('k_')) add(k.slice(2), v);
|
|
178
|
+
}
|
|
179
|
+
for (const line of (params.get('bulk') || '').split('\n')) {
|
|
180
|
+
const s = line.replace(/\r$/, '').trim();
|
|
181
|
+
if (!s || s.startsWith('#')) continue;
|
|
182
|
+
const eq = s.indexOf('=');
|
|
183
|
+
if (eq <= 0) continue;
|
|
184
|
+
add(s.slice(0, eq).replace(/^export\s+/, ''), s.slice(eq + 1));
|
|
185
|
+
}
|
|
186
|
+
return { updates, invalid };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const server = http.createServer((req, res) => {
|
|
190
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
191
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
192
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
193
|
+
|
|
194
|
+
if (req.method === 'GET' && url.pathname === '/') {
|
|
195
|
+
if (!tokenOk(url.searchParams.get('t'))) { res.writeHead(403); return res.end('forbidden'); }
|
|
196
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
197
|
+
return res.end(formPage());
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (req.method === 'POST' && url.pathname === '/save') {
|
|
201
|
+
let body = '';
|
|
202
|
+
req.on('data', (c) => {
|
|
203
|
+
body += c;
|
|
204
|
+
if (body.length > 1_048_576) req.destroy();
|
|
205
|
+
});
|
|
206
|
+
req.on('end', async () => {
|
|
207
|
+
const params = new URLSearchParams(body);
|
|
208
|
+
if (!tokenOk(params.get('t'))) { res.writeHead(403); return res.end('forbidden'); }
|
|
209
|
+
const { updates, invalid } = parseSubmission(params);
|
|
210
|
+
const invalidNote = invalid.length
|
|
211
|
+
? `<p class="bad">Skipped invalid key name${invalid.length > 1 ? 's' : ''}: <code>${invalid.map(esc).join(', ')}</code>${hubMode ? ' (letters, digits and underscores only, starting with a letter)' : ''}</p>`
|
|
212
|
+
: '';
|
|
213
|
+
if (!updates.size) {
|
|
214
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
215
|
+
return res.end(page('Secret drop', `<h1>Nothing saved</h1>${invalidNote}
|
|
216
|
+
<p class="muted">No valid <code>KEY=value</code> entries found. Go back and try again.</p>`));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (hubMode) {
|
|
220
|
+
const results = [];
|
|
221
|
+
for (const [key, value] of updates) {
|
|
222
|
+
try {
|
|
223
|
+
results.push({ key, ...(await hubSet(key, value)) });
|
|
224
|
+
} catch (e) {
|
|
225
|
+
results.push({ key, ok: false, status: 0, detail: String((e && e.message) || e).slice(0, 200) });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const saved = results.filter((r) => r.ok).map((r) => r.key);
|
|
229
|
+
const failed = results.filter((r) => !r.ok);
|
|
230
|
+
if (saved.length) console.log(`SAVED: ${saved.join(',')} -> hub app ${hubAppId} (${hubEnv})`);
|
|
231
|
+
for (const f of failed) console.log(`FAILED: ${f.key} (status ${f.status}${f.detail ? `: ${f.detail}` : ''})`);
|
|
232
|
+
const allOk = failed.length === 0;
|
|
233
|
+
const rows = results.map((r) => r.ok
|
|
234
|
+
? `<li class="ok">✓ ${esc(r.key)}</li>`
|
|
235
|
+
: `<li class="bad">✗ ${esc(r.key)} — ${r.status ? `error ${r.status}` : 'request failed'}${r.detail ? `: ${esc(r.detail)}` : ''}</li>`
|
|
236
|
+
).join('');
|
|
237
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
238
|
+
res.end(page('Secret drop', `<h1 class="${allOk ? 'ok' : 'bad'}">${allOk ? 'Saved' : 'Partly saved'}</h1>
|
|
239
|
+
${invalidNote}<ul>${rows}</ul>
|
|
240
|
+
<p class="muted">Destination: <code>${esc(destinationLabel)}</code>.
|
|
241
|
+
${allOk && !stay ? 'You can close this tab — the link is now dead.' : 'Go back to retry the failed keys.'}</p>`));
|
|
242
|
+
if (allOk && !stay) setTimeout(() => { server.close(); process.exit(0); }, 750);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
writeEnv(filePath, updates);
|
|
248
|
+
} catch (e) {
|
|
249
|
+
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
250
|
+
return res.end(page('Secret drop', `<h1>Write failed</h1><p class="muted">${esc(String((e && e.message) || e))}</p>`));
|
|
251
|
+
}
|
|
252
|
+
const names = [...updates.keys()];
|
|
253
|
+
console.log(`SAVED: ${names.join(',')} -> ${filePath}`);
|
|
254
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
255
|
+
res.end(page('Secret drop', `<h1 class="ok">Saved</h1>${invalidNote}
|
|
256
|
+
<p>Wrote <strong>${names.map(esc).join(', ')}</strong> to <code>${esc(filePath)}</code>.</p>
|
|
257
|
+
<p class="muted">You can close this tab${stay ? '' : ' — the link is now dead'}.</p>`));
|
|
258
|
+
if (!stay) setTimeout(() => { server.close(); process.exit(0); }, 750);
|
|
259
|
+
});
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
res.writeHead(404);
|
|
264
|
+
res.end('not found');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
server.listen(port, '127.0.0.1', () => {
|
|
268
|
+
const p = server.address().port;
|
|
269
|
+
console.log('secret-drop listening (127.0.0.1 only)');
|
|
270
|
+
console.log(` dest: ${destinationLabel}`);
|
|
271
|
+
if (expectedKeys.length) console.log(` keys: ${expectedKeys.join(', ')}`);
|
|
272
|
+
console.log(` open: http://127.0.0.1:${p}/?t=${token}`);
|
|
273
|
+
console.log(` expires in ${Math.round(ttlMs / 60000)} min${stay ? '' : '; exits after first successful save'}`);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
setTimeout(() => {
|
|
277
|
+
console.log('secret-drop: expired');
|
|
278
|
+
process.exit(0);
|
|
279
|
+
}, ttlMs);
|
|
@@ -37,8 +37,9 @@ once (plus `awesomate_get_limits` before any create/change) and cache:
|
|
|
37
37
|
- **`patExpiresAt`** — within ~7 days, or any 401: re-run the setup prompt at
|
|
38
38
|
**hub.awesomate.ai/sites → Connect Claude Code** to refresh the token.
|
|
39
39
|
- **`skill.updateAvailable`** — this skill's local files are older than the MCP
|
|
40
|
-
server. Mention once (don't nag);
|
|
41
|
-
|
|
40
|
+
server. Mention once (don't nag); run `awesomate_skill_update` to refresh in
|
|
41
|
+
place (new content loads next session). If the token is also near expiry, the
|
|
42
|
+
Connect re-run above does both. Not urgent — finish the user's request first.
|
|
42
43
|
- **`cpanel`** routing (host/user), present once hosting is provisioned.
|
|
43
44
|
|
|
44
45
|
If `awesomate_get_context` fails with a connectivity error, it's the user's
|
|
@@ -20,18 +20,22 @@ also carries the plan). Describe what a plan or upgrade INCLUDES from
|
|
|
20
20
|
balance without spending anything. Never quote a plan, price, or balance
|
|
21
21
|
from memory.
|
|
22
22
|
|
|
23
|
-
## 1. FAQ first
|
|
23
|
+
## 1. FAQ first, then help docs
|
|
24
24
|
|
|
25
25
|
For any "how do I", "why does", "what is" question about the service:
|
|
26
26
|
|
|
27
27
|
1. Call `awesomate_support {action:'faq', q:'<their question>'}`.
|
|
28
28
|
2. Answer FROM the returned entries, in your own plain words. Cite nothing
|
|
29
29
|
the FAQ and tools didn't say — no invented policy, no guessed pricing.
|
|
30
|
-
3. If the FAQ
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
3. If the FAQ misses or only partially answers, call
|
|
31
|
+
`awesomate_support {action:'help_docs', q:'<topic>'}` — a live search of
|
|
32
|
+
the full Help Centre articles. Answer from the excerpts and give the
|
|
33
|
+
article's URL so the user can read the full steps.
|
|
34
|
+
4. If neither answers it: "I don't have a confirmed answer for that. Want
|
|
33
35
|
me to ask the Awesomate team? I'll draft a support ticket for you to
|
|
34
|
-
approve first." Then go to §2 if they say yes.
|
|
36
|
+
approve first." Then go to §2 if they say yes. (An empty help_docs
|
|
37
|
+
result can also mean the search is temporarily down — the response's
|
|
38
|
+
help_centre_url is always safe to offer for browsing.)
|
|
35
39
|
|
|
36
40
|
## 2. Ticket flow — talking to a human
|
|
37
41
|
|