attachpage 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +59 -2
- package/bin/attachpage.mjs +761 -0
- package/package.json +31 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 JP Technology Services
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,60 @@
|
|
|
1
|
-
#
|
|
1
|
+
# attachpage
|
|
2
|
+
|
|
3
|
+
Send a website like an attachment. Point it at a folder with an `index.html`; get a link.
|
|
4
|
+
|
|
5
|
+
Sign in once with your Google or Microsoft account:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx attachpage connect # add AttachPage to Claude Code, Codex, Cursor, VS Code, Gemini CLI
|
|
9
|
+
npx attachpage login # approve in your browser, once
|
|
10
|
+
npx attachpage ./dist # publish (private by default)
|
|
11
|
+
npx attachpage send ./dist --to client@example.com --message "Here's the new version"
|
|
12
|
+
npx attachpage feedback <pageId> # what they said, and where
|
|
13
|
+
npx attachpage ./dist --site <pageId> --notify "Applied your feedback"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Pages are private by default: only the people you send them to can open them, each with a
|
|
17
|
+
personal link you can revoke. Recipients can comment on the page and approve it; `feedback`
|
|
18
|
+
lists what they said with the element they pointed at, which is handy for agents. Add `--public`
|
|
19
|
+
to make a page open to anyone with the link. Without signing in, `npx attachpage ./dist` publishes a
|
|
20
|
+
public page that expires after 48 hours and prints a link to keep it.
|
|
21
|
+
|
|
22
|
+
- No dependencies, Node 18+.
|
|
23
|
+
- Your files are uploaded exactly as they are. Nothing is built, bundled or rewritten.
|
|
24
|
+
- `login` keeps your key in the system password store: Windows Credential Manager, the macOS Keychain,
|
|
25
|
+
or the Linux keyring (Secret Service via `secret-tool`). Use `login --store file` on machines without one;
|
|
26
|
+
the key then goes to `~/.attachpage/credentials`, readable only by you. `ATTACHPAGE_API_KEY` overrides both.
|
|
27
|
+
`whoami` shows where the key came from; `logout` removes it from every place.
|
|
28
|
+
- `--json` on any command prints machine-readable output.
|
|
29
|
+
|
|
30
|
+
## Agents and MCP
|
|
31
|
+
|
|
32
|
+
This package is a command line, not an MCP server. AttachPage's MCP server is remote
|
|
33
|
+
(Streamable HTTP with OAuth sign-in) at `https://attachpage.com/mcp`; add that URL to your MCP
|
|
34
|
+
client. For clients that only run local stdio servers, bridge it with
|
|
35
|
+
[mcp-remote](https://www.npmjs.com/package/mcp-remote):
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"mcpServers": {
|
|
40
|
+
"attachpage": {
|
|
41
|
+
"command": "npx",
|
|
42
|
+
"args": ["-y", "mcp-remote", "https://attachpage.com/mcp"]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Setup for Claude, ChatGPT, Cursor, VS Code and Codex: https://attachpage.com/agents.
|
|
49
|
+
Agent instructions: https://attachpage.com/skill.md.
|
|
50
|
+
|
|
51
|
+
Support: support@attachpage.com. MIT licensed, by JP Technology Services.
|
|
52
|
+
|
|
53
|
+
## Connect your AI apps
|
|
54
|
+
|
|
55
|
+
`npx attachpage connect` adds the AttachPage MCP server (`https://attachpage.com/mcp`) to the AI apps
|
|
56
|
+
it finds: Claude Code, OpenAI Codex and Gemini CLI through their own commands, and Cursor and VS Code
|
|
57
|
+
through their user config files (backed up first; existing entries and files it cannot parse are left
|
|
58
|
+
alone). `--dry-run` shows what it would change; `--client cursor,vscode` limits it to some apps. Each
|
|
59
|
+
app asks you to sign in to AttachPage the first time you use it.
|
|
2
60
|
|
|
3
|
-
This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
|
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* AttachPage command line: publish a folder, send it to people, read their feedback.
|
|
4
|
+
*
|
|
5
|
+
* npx attachpage ./dist publish (signed in: private; signed out: public for 48 h)
|
|
6
|
+
* npx attachpage send ./dist --to a@b.com publish privately and email personal links
|
|
7
|
+
* npx attachpage login sign in from the terminal (approve in your browser)
|
|
8
|
+
* npx attachpage feedback <siteId> what recipients said (open items)
|
|
9
|
+
* npx attachpage connect add AttachPage to the AI apps on this computer
|
|
10
|
+
*
|
|
11
|
+
* No dependencies. Credentials: ATTACHPAGE_API_KEY, or the key `login` saved in the operating system's
|
|
12
|
+
* password store (or in ~/.attachpage/credentials with `login --store file`).
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { chmod, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
18
|
+
|
|
19
|
+
const VERSION = "0.1.1";
|
|
20
|
+
const CREDENTIALS = process.env.ATTACHPAGE_CREDENTIALS_FILE || join(homedir(), ".attachpage", "credentials");
|
|
21
|
+
const SKIP_DIRS = new Set([".git", "node_modules", ".svn", ".hg"]);
|
|
22
|
+
const SKIP_FILES = new Set([".DS_Store", "Thumbs.db", "desktop.ini"]);
|
|
23
|
+
|
|
24
|
+
// ---------- arguments ----------
|
|
25
|
+
|
|
26
|
+
const argv = process.argv.slice(2);
|
|
27
|
+
const flags = new Map();
|
|
28
|
+
const positional = [];
|
|
29
|
+
for (let i = 0; i < argv.length; i++) {
|
|
30
|
+
const a = argv[i];
|
|
31
|
+
if (a.startsWith("--")) {
|
|
32
|
+
const [k, inline] = a.slice(2).split("=", 2);
|
|
33
|
+
if (inline !== undefined) flags.set(k, inline);
|
|
34
|
+
else if (argv[i + 1] && !argv[i + 1].startsWith("--")) flags.set(k, argv[++i]);
|
|
35
|
+
else flags.set(k, true);
|
|
36
|
+
} else positional.push(a);
|
|
37
|
+
}
|
|
38
|
+
const asJson = flags.has("json");
|
|
39
|
+
|
|
40
|
+
function out(human, data) {
|
|
41
|
+
if (asJson) console.log(JSON.stringify(data, null, 2));
|
|
42
|
+
else console.log(human);
|
|
43
|
+
}
|
|
44
|
+
function fail(message, code = 1) {
|
|
45
|
+
if (asJson) console.log(JSON.stringify({ error: message }));
|
|
46
|
+
else console.error(`attachpage: ${message}`);
|
|
47
|
+
process.exit(code);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------- credentials ----------
|
|
51
|
+
//
|
|
52
|
+
// Where the key lives, first match wins: ATTACHPAGE_API_KEY, the credentials file (written only with
|
|
53
|
+
// `login --store file` or when no password store exists), then the operating system's password
|
|
54
|
+
// store: Windows Credential Manager (Web Credentials), the macOS Keychain, or the Secret Service
|
|
55
|
+
// keyring through secret-tool. Entries are per server: resource/service "attachpage", account = the
|
|
56
|
+
// server origin. Keys reach these tools on standard input, never on the command line.
|
|
57
|
+
|
|
58
|
+
const STORE_RESOURCE = "attachpage";
|
|
59
|
+
/** Characters allowed in keys and origins handed to password-store tools (no quoting needed). */
|
|
60
|
+
const SAFE = /^[A-Za-z0-9._:/-]+$/;
|
|
61
|
+
|
|
62
|
+
function runTool(file, args, { input = "", env } = {}) {
|
|
63
|
+
return new Promise((done) => {
|
|
64
|
+
let child;
|
|
65
|
+
try {
|
|
66
|
+
child = spawn(file, args, {
|
|
67
|
+
env: env ? { ...process.env, ...env } : process.env,
|
|
68
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
69
|
+
windowsHide: true,
|
|
70
|
+
});
|
|
71
|
+
} catch {
|
|
72
|
+
return done({ code: -1, missing: true, stdout: "", stderr: "" });
|
|
73
|
+
}
|
|
74
|
+
let stdout = "";
|
|
75
|
+
let stderr = "";
|
|
76
|
+
const timer = setTimeout(() => child.kill(), 30_000);
|
|
77
|
+
child.stdout.on("data", (d) => {
|
|
78
|
+
stdout += d;
|
|
79
|
+
});
|
|
80
|
+
child.stderr.on("data", (d) => {
|
|
81
|
+
stderr += d;
|
|
82
|
+
});
|
|
83
|
+
child.on("error", (e) => {
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
done({ code: -1, missing: e.code === "ENOENT", stdout, stderr: e.message });
|
|
86
|
+
});
|
|
87
|
+
child.on("close", (code) => {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
done({ code: code ?? -1, missing: false, stdout, stderr: stderr.trim() });
|
|
90
|
+
});
|
|
91
|
+
child.stdin.on("error", () => {});
|
|
92
|
+
child.stdin.end(input);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Windows PowerShell 5.1 projects the WinRT PasswordVault (Credential Manager > Web Credentials).
|
|
97
|
+
const VAULT_SCRIPT = `
|
|
98
|
+
$ErrorActionPreference = 'Stop'
|
|
99
|
+
try {
|
|
100
|
+
[void][Windows.Security.Credentials.PasswordVault, Windows.Security.Credentials, ContentType = WindowsRuntime]
|
|
101
|
+
$vault = New-Object Windows.Security.Credentials.PasswordVault
|
|
102
|
+
} catch { [Console]::Error.Write($_.Exception.Message); exit 2 }
|
|
103
|
+
$resource = $env:ATTACHPAGE_STORE_RESOURCE
|
|
104
|
+
$account = $env:ATTACHPAGE_STORE_ACCOUNT
|
|
105
|
+
function Find-Entry { try { return $vault.Retrieve($resource, $account) } catch { return $null } }
|
|
106
|
+
switch ($env:ATTACHPAGE_STORE_OP) {
|
|
107
|
+
'get' { $c = Find-Entry; if ($null -eq $c) { exit 3 }; $c.RetrievePassword(); [Console]::Out.Write($c.Password) }
|
|
108
|
+
'set' {
|
|
109
|
+
$key = [Console]::In.ReadToEnd().Trim()
|
|
110
|
+
$old = Find-Entry; if ($null -ne $old) { $vault.Remove($old) }
|
|
111
|
+
$vault.Add((New-Object Windows.Security.Credentials.PasswordCredential($resource, $account, $key)))
|
|
112
|
+
}
|
|
113
|
+
'delete' { $c = Find-Entry; if ($null -eq $c) { exit 3 }; $vault.Remove($c) }
|
|
114
|
+
}
|
|
115
|
+
`;
|
|
116
|
+
|
|
117
|
+
async function windowsVault(op, origin, input) {
|
|
118
|
+
// Full path, so a powershell.exe in the current folder can never run instead.
|
|
119
|
+
const ps = join(
|
|
120
|
+
process.env.SystemRoot || "C:\\Windows",
|
|
121
|
+
"System32",
|
|
122
|
+
"WindowsPowerShell",
|
|
123
|
+
"v1.0",
|
|
124
|
+
"powershell.exe",
|
|
125
|
+
);
|
|
126
|
+
const script = Buffer.from(VAULT_SCRIPT, "utf16le").toString("base64");
|
|
127
|
+
return runTool(
|
|
128
|
+
ps,
|
|
129
|
+
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", script],
|
|
130
|
+
{
|
|
131
|
+
input,
|
|
132
|
+
env: {
|
|
133
|
+
ATTACHPAGE_STORE_OP: op,
|
|
134
|
+
ATTACHPAGE_STORE_RESOURCE: STORE_RESOURCE,
|
|
135
|
+
ATTACHPAGE_STORE_ACCOUNT: origin,
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The password store for this platform: `get` → key | null, `set`, `remove` → removed?; throw when unusable. */
|
|
142
|
+
const passwordStore = {
|
|
143
|
+
win32: {
|
|
144
|
+
name: "Windows Credential Manager",
|
|
145
|
+
async get(origin) {
|
|
146
|
+
const r = await windowsVault("get", origin);
|
|
147
|
+
if (r.code === 3) return null;
|
|
148
|
+
if (r.code !== 0)
|
|
149
|
+
throw new Error(r.missing ? "Windows PowerShell was not found" : r.stderr || `exit ${r.code}`);
|
|
150
|
+
return r.stdout.trim() || null;
|
|
151
|
+
},
|
|
152
|
+
async set(origin, key) {
|
|
153
|
+
const r = await windowsVault("set", origin, key);
|
|
154
|
+
if (r.code !== 0) throw new Error(r.stderr || `exit ${r.code}`);
|
|
155
|
+
},
|
|
156
|
+
async remove(origin) {
|
|
157
|
+
const r = await windowsVault("delete", origin);
|
|
158
|
+
if (r.code !== 0 && r.code !== 3) throw new Error(r.stderr || `exit ${r.code}`);
|
|
159
|
+
return r.code === 0;
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
darwin: {
|
|
163
|
+
name: "the macOS Keychain",
|
|
164
|
+
async get(origin) {
|
|
165
|
+
const r = await runTool("/usr/bin/security", [
|
|
166
|
+
"find-generic-password",
|
|
167
|
+
"-s",
|
|
168
|
+
STORE_RESOURCE,
|
|
169
|
+
"-a",
|
|
170
|
+
origin,
|
|
171
|
+
"-w",
|
|
172
|
+
]);
|
|
173
|
+
if (r.code === 44) return null; // item not found
|
|
174
|
+
if (r.code !== 0) throw new Error(r.stderr || `exit ${r.code}`);
|
|
175
|
+
return r.stdout.trim() || null;
|
|
176
|
+
},
|
|
177
|
+
async set(origin, key) {
|
|
178
|
+
// `security -i` reads the command from stdin, which keeps the key out of the process list.
|
|
179
|
+
const r = await runTool("/usr/bin/security", ["-i"], {
|
|
180
|
+
input: `add-generic-password -U -s ${STORE_RESOURCE} -a ${origin} -w ${key}\n`,
|
|
181
|
+
});
|
|
182
|
+
if (r.code !== 0) throw new Error(r.stderr || `exit ${r.code}`);
|
|
183
|
+
},
|
|
184
|
+
async remove(origin) {
|
|
185
|
+
const r = await runTool("/usr/bin/security", [
|
|
186
|
+
"delete-generic-password",
|
|
187
|
+
"-s",
|
|
188
|
+
STORE_RESOURCE,
|
|
189
|
+
"-a",
|
|
190
|
+
origin,
|
|
191
|
+
]);
|
|
192
|
+
if (r.code === 44) return false;
|
|
193
|
+
if (r.code !== 0) throw new Error(r.stderr || `exit ${r.code}`);
|
|
194
|
+
return true;
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
linux: {
|
|
198
|
+
name: "the system keyring (Secret Service)",
|
|
199
|
+
async get(origin) {
|
|
200
|
+
const r = await runTool("secret-tool", ["lookup", "service", STORE_RESOURCE, "account", origin]);
|
|
201
|
+
if (r.missing) throw new Error("secret-tool is not installed");
|
|
202
|
+
if (r.code !== 0 && !r.stderr) return null;
|
|
203
|
+
if (r.code !== 0) throw new Error(r.stderr);
|
|
204
|
+
return r.stdout.trim() || null;
|
|
205
|
+
},
|
|
206
|
+
async set(origin, key) {
|
|
207
|
+
const label = `AttachPage key (${origin})`;
|
|
208
|
+
const r = await runTool(
|
|
209
|
+
"secret-tool",
|
|
210
|
+
["store", "--label", label, "service", STORE_RESOURCE, "account", origin],
|
|
211
|
+
{
|
|
212
|
+
input: key,
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
if (r.missing) throw new Error("secret-tool is not installed");
|
|
216
|
+
if (r.code !== 0) throw new Error(r.stderr || `exit ${r.code}`);
|
|
217
|
+
},
|
|
218
|
+
async remove(origin) {
|
|
219
|
+
if (!(await this.get(origin))) return false;
|
|
220
|
+
const r = await runTool("secret-tool", ["clear", "service", STORE_RESOURCE, "account", origin]);
|
|
221
|
+
if (r.code !== 0) throw new Error(r.stderr || `exit ${r.code}`);
|
|
222
|
+
return true;
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
}[process.platform === "win32" || process.platform === "darwin" ? process.platform : "linux"];
|
|
226
|
+
|
|
227
|
+
async function readCredentialsFile() {
|
|
228
|
+
const found = { key: null, origin: null };
|
|
229
|
+
try {
|
|
230
|
+
for (const line of (await readFile(CREDENTIALS, "utf8")).split(/\r?\n/)) {
|
|
231
|
+
const at = line.indexOf("=");
|
|
232
|
+
if (at < 0) continue;
|
|
233
|
+
const k = line.slice(0, at).trim();
|
|
234
|
+
const v = line.slice(at + 1).trim();
|
|
235
|
+
if (k === "ATTACHPAGE_API_KEY" && v) found.key ??= v;
|
|
236
|
+
if (k === "ATTACHPAGE_ORIGIN" && v) found.origin ??= v;
|
|
237
|
+
}
|
|
238
|
+
} catch {
|
|
239
|
+
/* no file */
|
|
240
|
+
}
|
|
241
|
+
return found;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Key and server for this run, plus where the key came from. */
|
|
245
|
+
async function readCredentials() {
|
|
246
|
+
const file = await readCredentialsFile();
|
|
247
|
+
const origin = (
|
|
248
|
+
(typeof flags.get("origin") === "string" ? flags.get("origin") : null) ||
|
|
249
|
+
process.env.ATTACHPAGE_ORIGIN ||
|
|
250
|
+
file.origin ||
|
|
251
|
+
"https://attachpage.com"
|
|
252
|
+
).replace(/\/$/, "");
|
|
253
|
+
if (process.env.ATTACHPAGE_API_KEY)
|
|
254
|
+
return { key: process.env.ATTACHPAGE_API_KEY, origin, source: "environment" };
|
|
255
|
+
if (file.key) return { key: file.key, origin, source: CREDENTIALS };
|
|
256
|
+
if (SAFE.test(origin)) {
|
|
257
|
+
try {
|
|
258
|
+
const key = await passwordStore.get(origin);
|
|
259
|
+
if (key) return { key, origin, source: passwordStore.name };
|
|
260
|
+
} catch {
|
|
261
|
+
/* no usable password store: not signed in */
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return { key: null, origin, source: null };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Write the credentials file (owner-only), keeping or dropping the key line. */
|
|
268
|
+
async function writeCredentialsFile({ key, origin }) {
|
|
269
|
+
const lines = [];
|
|
270
|
+
if (key) lines.push(`ATTACHPAGE_API_KEY=${key}`);
|
|
271
|
+
if (origin && origin !== "https://attachpage.com") lines.push(`ATTACHPAGE_ORIGIN=${origin}`);
|
|
272
|
+
if (!lines.length) return rm(CREDENTIALS, { force: true });
|
|
273
|
+
await mkdir(dirname(CREDENTIALS), { recursive: true, mode: 0o700 });
|
|
274
|
+
await writeFile(CREDENTIALS, `${lines.join("\n")}\n`, { mode: 0o600 });
|
|
275
|
+
await chmod(CREDENTIALS, 0o600).catch(() => {});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Save a new key: the password store unless `--store file` (or none is usable), verified by reading
|
|
280
|
+
* it back. Any key left in the plain file is removed once the store holds it.
|
|
281
|
+
* @returns where it was saved.
|
|
282
|
+
*/
|
|
283
|
+
async function saveCredentials(key, origin) {
|
|
284
|
+
const wantFile = flags.get("store") === "file";
|
|
285
|
+
if (!wantFile && SAFE.test(key) && SAFE.test(origin)) {
|
|
286
|
+
try {
|
|
287
|
+
await passwordStore.set(origin, key);
|
|
288
|
+
if ((await passwordStore.get(origin)) === key) {
|
|
289
|
+
await writeCredentialsFile({ key: null, origin });
|
|
290
|
+
return passwordStore.name;
|
|
291
|
+
}
|
|
292
|
+
} catch (e) {
|
|
293
|
+
console.error(
|
|
294
|
+
`attachpage: could not use ${passwordStore.name} (${e.message}); saving to ${CREDENTIALS}.`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
await writeCredentialsFile({ key, origin });
|
|
299
|
+
return `${CREDENTIALS} (readable only by you)`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------- HTTP ----------
|
|
303
|
+
|
|
304
|
+
async function api(origin, method, path, { key, body } = {}) {
|
|
305
|
+
const res = await fetch(path.startsWith("http") ? path : `${origin}${path}`, {
|
|
306
|
+
method,
|
|
307
|
+
headers: {
|
|
308
|
+
...(key ? { Authorization: `Bearer ${key}` } : {}),
|
|
309
|
+
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
310
|
+
"User-Agent": `attachpage-cli/${VERSION}`,
|
|
311
|
+
},
|
|
312
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
313
|
+
});
|
|
314
|
+
const text = await res.text();
|
|
315
|
+
let data = {};
|
|
316
|
+
try {
|
|
317
|
+
data = text ? JSON.parse(text) : {};
|
|
318
|
+
} catch {
|
|
319
|
+
data = { error: { message: text.slice(0, 200) } };
|
|
320
|
+
}
|
|
321
|
+
if (!res.ok) {
|
|
322
|
+
const err = data.error || {};
|
|
323
|
+
const findings = err.details?.findings;
|
|
324
|
+
const detail = Array.isArray(findings)
|
|
325
|
+
? `\n${findings.map((f) => ` - ${f.path ?? ""} ${f.message}`).join("\n")}`
|
|
326
|
+
: "";
|
|
327
|
+
const e = new Error(`${err.message || res.statusText} (${res.status})${detail}`);
|
|
328
|
+
e.status = res.status;
|
|
329
|
+
throw e;
|
|
330
|
+
}
|
|
331
|
+
return data;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---------- files ----------
|
|
335
|
+
|
|
336
|
+
async function walk(dir, root = dir, list = []) {
|
|
337
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
338
|
+
if (entry.isSymbolicLink()) continue;
|
|
339
|
+
const full = join(dir, entry.name);
|
|
340
|
+
if (entry.isDirectory()) {
|
|
341
|
+
if (!SKIP_DIRS.has(entry.name)) await walk(full, root, list);
|
|
342
|
+
} else if (entry.isFile() && !SKIP_FILES.has(entry.name)) {
|
|
343
|
+
list.push({ full, path: relative(root, full).split(sep).join("/"), size: (await stat(full)).size });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return list;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function loadFolder(folderArg) {
|
|
350
|
+
const folder = resolve(folderArg || ".");
|
|
351
|
+
let info;
|
|
352
|
+
try {
|
|
353
|
+
info = await stat(folder);
|
|
354
|
+
} catch {
|
|
355
|
+
fail(`${folder} does not exist`);
|
|
356
|
+
}
|
|
357
|
+
if (!info.isDirectory()) fail(`${folder} is not a folder`);
|
|
358
|
+
const files = await walk(folder);
|
|
359
|
+
if (!files.some((f) => f.path === "index.html")) {
|
|
360
|
+
const hint = files.some((f) => f.path.startsWith("dist/index.html"))
|
|
361
|
+
? " Did you mean ./dist?"
|
|
362
|
+
: files.some((f) => f.path.startsWith("build/index.html"))
|
|
363
|
+
? " Did you mean ./build?"
|
|
364
|
+
: "";
|
|
365
|
+
fail(`${folder} has no index.html at the top level.${hint}`);
|
|
366
|
+
}
|
|
367
|
+
return { folder, files };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function uploadAll(targets, files, bearer, origin) {
|
|
371
|
+
const byPath = new Map(files.map((f) => [f.path, f]));
|
|
372
|
+
let next = 0;
|
|
373
|
+
let done = 0;
|
|
374
|
+
const worker = async () => {
|
|
375
|
+
while (next < targets.length) {
|
|
376
|
+
const t = targets[next++];
|
|
377
|
+
const file = byPath.get(t.path);
|
|
378
|
+
if (!file) continue;
|
|
379
|
+
const headers = { ...(t.headers || {}) };
|
|
380
|
+
if (t.url.startsWith(origin)) headers.Authorization = `Bearer ${bearer}`;
|
|
381
|
+
const res = await fetch(t.url, { method: t.method || "PUT", headers, body: await readFile(file.full) });
|
|
382
|
+
if (!res.ok)
|
|
383
|
+
throw new Error(`upload of ${t.path} failed (${res.status}): ${(await res.text()).slice(0, 200)}`);
|
|
384
|
+
done++;
|
|
385
|
+
if (!asJson && process.stderr.isTTY)
|
|
386
|
+
process.stderr.write(`\r uploaded ${done}/${targets.length} files`);
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
await Promise.all(Array.from({ length: Math.min(6, targets.length) }, worker));
|
|
390
|
+
if (!asJson && process.stderr.isTTY) process.stderr.write("\n");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---------- commands ----------
|
|
394
|
+
|
|
395
|
+
async function publish({ send = false } = {}) {
|
|
396
|
+
const { key, origin } = await readCredentials();
|
|
397
|
+
const { folder, files } = await loadFolder(positional[send ? 1 : positional[0] === "publish" ? 1 : 0]);
|
|
398
|
+
const manifest = files.map((f) => ({ path: f.path, size: f.size }));
|
|
399
|
+
const name = typeof flags.get("name") === "string" ? flags.get("name") : folder.split(sep).pop();
|
|
400
|
+
const slug = typeof flags.get("slug") === "string" ? flags.get("slug") : undefined;
|
|
401
|
+
const siteId = typeof flags.get("site") === "string" ? flags.get("site") : undefined;
|
|
402
|
+
|
|
403
|
+
if (!key) {
|
|
404
|
+
if (send) fail("Sending needs an account. Run `npx attachpage login` first.");
|
|
405
|
+
const anon = await api(origin, "POST", "/api/v1/anonymous/publish", {
|
|
406
|
+
body: { siteName: name, slug, files: manifest },
|
|
407
|
+
}).catch((e) =>
|
|
408
|
+
fail(e.status === 403 ? `${e.message}\nSign in to publish: npx attachpage login` : e.message),
|
|
409
|
+
);
|
|
410
|
+
await uploadAll(anon.targets, files, anon.uploadToken, origin);
|
|
411
|
+
const fin = await api(origin, "POST", anon.next, { key: anon.uploadToken, body: {} });
|
|
412
|
+
const url = fin.url || anon.url;
|
|
413
|
+
return out(
|
|
414
|
+
`Published (public, expires in 48 hours):\n${url}\n\nKeep it: open this link while signed in:\n${anon.claimUrl}`,
|
|
415
|
+
{ url, claimUrl: anon.claimUrl, expiresAt: anon.expiresAt, siteId: anon.siteId },
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const created = await api(origin, "POST", "/api/v1/publish", {
|
|
420
|
+
key,
|
|
421
|
+
body: {
|
|
422
|
+
siteName: name,
|
|
423
|
+
...(siteId ? { siteId } : slug ? { slug } : {}),
|
|
424
|
+
...(flags.has("public") ? { visibility: "public" } : {}),
|
|
425
|
+
files: manifest,
|
|
426
|
+
},
|
|
427
|
+
}).catch((e) => fail(e.message));
|
|
428
|
+
await uploadAll(created.targets, files, key, origin);
|
|
429
|
+
const fin = await api(origin, "POST", created.next, { key, body: {} });
|
|
430
|
+
let sent = null;
|
|
431
|
+
if (send) {
|
|
432
|
+
const to = String(flags.get("to") || "")
|
|
433
|
+
.split(",")
|
|
434
|
+
.map((s) => s.trim())
|
|
435
|
+
.filter(Boolean);
|
|
436
|
+
if (to.length === 0) fail("Add --to name@example.com (comma-separated for several people).");
|
|
437
|
+
sent = await api(origin, "POST", `/api/v1/sites/${fin.siteId}/send`, {
|
|
438
|
+
key,
|
|
439
|
+
body: {
|
|
440
|
+
recipients: to,
|
|
441
|
+
message: typeof flags.get("message") === "string" ? flags.get("message") : "",
|
|
442
|
+
...(flags.has("expires") ? { expiresInDays: Number(flags.get("expires")) } : {}),
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
let notified = null;
|
|
447
|
+
if (siteId && flags.has("notify")) {
|
|
448
|
+
notified = await api(origin, "POST", `/api/v1/sites/${siteId}/notify-update`, {
|
|
449
|
+
key,
|
|
450
|
+
body: { note: typeof flags.get("notify") === "string" ? flags.get("notify") : "" },
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
const lines = [fin.url, ""];
|
|
454
|
+
lines.push(
|
|
455
|
+
fin.visibility === "public"
|
|
456
|
+
? "Public: anyone with the link can open it."
|
|
457
|
+
: "Private: people need an invitation to open it.",
|
|
458
|
+
);
|
|
459
|
+
if (sent) lines.push(`Sent to ${String(flags.get("to"))}. Each person got their own link.`);
|
|
460
|
+
if (notified)
|
|
461
|
+
lines.push(
|
|
462
|
+
`Told ${notified.notified} ${notified.notified === 1 ? "person" : "people"} about the update.`,
|
|
463
|
+
);
|
|
464
|
+
lines.push(`Page id: ${fin.siteId}`);
|
|
465
|
+
out(lines.join("\n"), { ...fin, sent, notified });
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function login() {
|
|
469
|
+
const { origin } = await readCredentials();
|
|
470
|
+
const start = await api(origin, "POST", "/api/v1/device/start", {
|
|
471
|
+
body: { clientName: "AttachPage CLI" },
|
|
472
|
+
}).catch((e) => fail(e.message));
|
|
473
|
+
console.error(`Open ${start.verificationUrl} and press Allow (code ${start.userCode}).`);
|
|
474
|
+
if (!flags.has("no-browser")) openBrowser(start.verificationUrl);
|
|
475
|
+
const deadline = Date.now() + (start.expiresIn || 600) * 1000;
|
|
476
|
+
while (Date.now() < deadline) {
|
|
477
|
+
await new Promise((r) => setTimeout(r, (start.interval || 3) * 1000));
|
|
478
|
+
const poll = await api(origin, "POST", "/api/v1/device/poll", { body: { deviceCode: start.deviceCode } });
|
|
479
|
+
if (poll.status === "approved" && poll.apiKey) {
|
|
480
|
+
const savedTo = await saveCredentials(poll.apiKey, origin);
|
|
481
|
+
const me = await api(origin, "GET", "/api/auth/me", { key: poll.apiKey }).catch(() => ({}));
|
|
482
|
+
return out(`Signed in${me.user ? ` as ${me.user.email}` : ""}. Key saved to ${savedTo}.`, {
|
|
483
|
+
ok: true,
|
|
484
|
+
email: me.user?.email ?? null,
|
|
485
|
+
savedTo,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
if (poll.status === "denied" || poll.status === "expired" || poll.status === "claimed")
|
|
489
|
+
fail(`Sign-in ${poll.status}.`);
|
|
490
|
+
}
|
|
491
|
+
fail("Sign-in timed out.");
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function openBrowser(url) {
|
|
495
|
+
const cmd = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open";
|
|
496
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
497
|
+
try {
|
|
498
|
+
spawn(cmd, args, { stdio: "ignore", detached: true })
|
|
499
|
+
.on("error", () => {})
|
|
500
|
+
.unref();
|
|
501
|
+
} catch {
|
|
502
|
+
/* printing the link is enough */
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function logout() {
|
|
507
|
+
const { origin } = await readCredentials();
|
|
508
|
+
const removed = [];
|
|
509
|
+
const file = await readCredentialsFile();
|
|
510
|
+
if (file.key) {
|
|
511
|
+
await writeCredentialsFile({ key: null, origin: file.origin });
|
|
512
|
+
removed.push(CREDENTIALS);
|
|
513
|
+
}
|
|
514
|
+
if (SAFE.test(origin)) {
|
|
515
|
+
try {
|
|
516
|
+
if (await passwordStore.remove(origin)) removed.push(passwordStore.name);
|
|
517
|
+
} catch {
|
|
518
|
+
/* no usable password store */
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
out(
|
|
522
|
+
removed.length
|
|
523
|
+
? `Signed out on this machine: removed the key from ${removed.join(" and ")}. (Revoke it under For agents if it may have leaked.)`
|
|
524
|
+
: "No saved key on this machine.",
|
|
525
|
+
{ ok: true, removedFrom: removed },
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function feedback() {
|
|
530
|
+
const { key, origin } = await readCredentials();
|
|
531
|
+
if (!key) fail("Run `npx attachpage login` first.");
|
|
532
|
+
const siteId = positional[1];
|
|
533
|
+
if (!siteId) fail("Usage: attachpage feedback <siteId> [--all]");
|
|
534
|
+
const data = await api(
|
|
535
|
+
origin,
|
|
536
|
+
"GET",
|
|
537
|
+
`/api/v1/sites/${siteId}/feedback${flags.has("all") ? "" : "?status=open"}`,
|
|
538
|
+
{
|
|
539
|
+
key,
|
|
540
|
+
},
|
|
541
|
+
).catch((e) => fail(e.message));
|
|
542
|
+
if (asJson) return out("", data);
|
|
543
|
+
for (const d of data.decisions)
|
|
544
|
+
console.log(`${d.decision === "approval" ? "APPROVED" : "CHANGES REQUESTED"} ${d.email}`);
|
|
545
|
+
if (data.items.length === 0) return console.log("No open feedback.");
|
|
546
|
+
for (const i of data.items) {
|
|
547
|
+
const where = i.anchor ? ` on "${i.anchor.text || i.anchor.selector}"` : "";
|
|
548
|
+
console.log(`\n[${i.id}] ${i.authorEmail}${where} (${i.pagePath})\n ${i.body.replace(/\n/g, "\n ")}`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function whoami() {
|
|
553
|
+
const { key, origin, source } = await readCredentials();
|
|
554
|
+
if (!key) return out(`Not signed in (${origin}).`, { signedIn: false, origin });
|
|
555
|
+
const me = await api(origin, "GET", "/api/auth/me", { key }).catch((e) => fail(e.message));
|
|
556
|
+
out(`${me.user?.email ?? "unknown"} on ${origin} (key from ${source})`, {
|
|
557
|
+
signedIn: true,
|
|
558
|
+
email: me.user?.email,
|
|
559
|
+
origin,
|
|
560
|
+
source,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ---------- connect ----------
|
|
565
|
+
//
|
|
566
|
+
// Add the AttachPage MCP server to the AI apps on this computer. Apps with their own command line
|
|
567
|
+
// (Claude Code, Codex, Gemini CLI) are set up through it; Cursor and VS Code through their user
|
|
568
|
+
// config file, backed up first. Existing AttachPage entries are left alone, and a file that cannot be
|
|
569
|
+
// read as plain JSON is never rewritten (the snippet to paste is printed instead).
|
|
570
|
+
|
|
571
|
+
const CONNECT_HOME = process.env.ATTACHPAGE_CONNECT_HOME || homedir();
|
|
572
|
+
|
|
573
|
+
/** Run an app's own CLI. Windows npm shims are .cmd files, which need a shell; every argument is a
|
|
574
|
+
* fixed token or the validated server URL, so nothing needs quoting. */
|
|
575
|
+
function runCli(bin, args) {
|
|
576
|
+
return new Promise((done) => {
|
|
577
|
+
let child;
|
|
578
|
+
try {
|
|
579
|
+
child = spawn(bin, args, {
|
|
580
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
581
|
+
shell: process.platform === "win32",
|
|
582
|
+
windowsHide: true,
|
|
583
|
+
});
|
|
584
|
+
} catch {
|
|
585
|
+
return done({ code: -1, missing: true, out: "" });
|
|
586
|
+
}
|
|
587
|
+
let out = "";
|
|
588
|
+
child.stdout.on("data", (d) => {
|
|
589
|
+
out += d;
|
|
590
|
+
});
|
|
591
|
+
child.stderr.on("data", (d) => {
|
|
592
|
+
out += d;
|
|
593
|
+
});
|
|
594
|
+
child.on("error", () => done({ code: -1, missing: true, out }));
|
|
595
|
+
child.on("close", (code) =>
|
|
596
|
+
done({ code: code ?? -1, missing: code === 9009 || /not recognized|not found/i.test(out), out }),
|
|
597
|
+
);
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async function readJsonFile(file) {
|
|
602
|
+
try {
|
|
603
|
+
return { ok: true, data: JSON.parse(await readFile(file, "utf8")) };
|
|
604
|
+
} catch (e) {
|
|
605
|
+
return e.code === "ENOENT" ? { ok: true, data: {} } : { ok: false };
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async function dirExists(dir) {
|
|
610
|
+
try {
|
|
611
|
+
return (await stat(dir)).isDirectory();
|
|
612
|
+
} catch {
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Add `entry` under `root[key].attachpage` in a JSON config file. */
|
|
618
|
+
async function addToJsonConfig(file, key, entry, dryRun) {
|
|
619
|
+
const read = await readJsonFile(file);
|
|
620
|
+
if (!read.ok)
|
|
621
|
+
return {
|
|
622
|
+
status: "manual",
|
|
623
|
+
note: `could not read ${file} as plain JSON; add this yourself:\n${JSON.stringify({ [key]: { attachpage: entry } }, null, 2)}`,
|
|
624
|
+
};
|
|
625
|
+
const data = read.data && typeof read.data === "object" ? read.data : {};
|
|
626
|
+
if (data[key]?.attachpage) return { status: "already" };
|
|
627
|
+
if (dryRun) return { status: "would-add", note: file };
|
|
628
|
+
try {
|
|
629
|
+
const existing = await readFile(file, "utf8");
|
|
630
|
+
await writeFile(`${file}.bak-attachpage`, existing, { flag: "wx" }).catch(() => {});
|
|
631
|
+
} catch {
|
|
632
|
+
/* new file */
|
|
633
|
+
}
|
|
634
|
+
data[key] = { ...(data[key] ?? {}), attachpage: entry };
|
|
635
|
+
await mkdir(dirname(file), { recursive: true });
|
|
636
|
+
await writeFile(file, `${JSON.stringify(data, null, 2)}\n`);
|
|
637
|
+
return { status: "added", note: file };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function vscodeUserDir() {
|
|
641
|
+
if (process.platform === "win32")
|
|
642
|
+
return join(process.env.APPDATA || join(CONNECT_HOME, "AppData", "Roaming"), "Code", "User");
|
|
643
|
+
if (process.platform === "darwin")
|
|
644
|
+
return join(CONNECT_HOME, "Library", "Application Support", "Code", "User");
|
|
645
|
+
return join(CONNECT_HOME, ".config", "Code", "User");
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function connect() {
|
|
649
|
+
const { origin } = await readCredentials();
|
|
650
|
+
if (!SAFE.test(origin)) fail("That --origin cannot be used here.");
|
|
651
|
+
const url = `${origin}/mcp`;
|
|
652
|
+
const dryRun = flags.has("dry-run");
|
|
653
|
+
const only = typeof flags.get("client") === "string" ? String(flags.get("client")).split(",") : null;
|
|
654
|
+
const viaCli = [
|
|
655
|
+
{
|
|
656
|
+
id: "claude-code",
|
|
657
|
+
name: "Claude Code",
|
|
658
|
+
bin: "claude",
|
|
659
|
+
args: ["mcp", "add", "--transport", "http", "--scope", "user", "attachpage", url],
|
|
660
|
+
next: "In Claude Code, run /mcp and sign in to AttachPage.",
|
|
661
|
+
},
|
|
662
|
+
{
|
|
663
|
+
id: "codex",
|
|
664
|
+
name: "OpenAI Codex",
|
|
665
|
+
bin: "codex",
|
|
666
|
+
args: ["mcp", "add", "attachpage", "--url", url],
|
|
667
|
+
next: "Run: codex mcp login attachpage",
|
|
668
|
+
},
|
|
669
|
+
{
|
|
670
|
+
id: "gemini",
|
|
671
|
+
name: "Gemini CLI",
|
|
672
|
+
bin: "gemini",
|
|
673
|
+
args: ["mcp", "add", "--transport", "http", "attachpage", url],
|
|
674
|
+
next: "In Gemini CLI, run /mcp auth attachpage.",
|
|
675
|
+
},
|
|
676
|
+
];
|
|
677
|
+
const results = [];
|
|
678
|
+
for (const c of viaCli) {
|
|
679
|
+
if (only && !only.includes(c.id)) continue;
|
|
680
|
+
const probe = await runCli(c.bin, ["--version"]);
|
|
681
|
+
if (probe.missing || probe.code !== 0) {
|
|
682
|
+
results.push({ app: c.name, status: "not-found" });
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
if (dryRun) {
|
|
686
|
+
results.push({ app: c.name, status: "would-add", note: `${c.bin} ${c.args.join(" ")}` });
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
const r = await runCli(c.bin, c.args);
|
|
690
|
+
if (r.code === 0) results.push({ app: c.name, status: "added", note: c.next });
|
|
691
|
+
else if (/already exists|already configured|already added/i.test(r.out))
|
|
692
|
+
results.push({ app: c.name, status: "already" });
|
|
693
|
+
else results.push({ app: c.name, status: "failed", note: r.out.trim().split("\n").slice(-1)[0] });
|
|
694
|
+
}
|
|
695
|
+
if (!only || only.includes("cursor")) {
|
|
696
|
+
const dir = join(CONNECT_HOME, ".cursor");
|
|
697
|
+
results.push(
|
|
698
|
+
(await dirExists(dir))
|
|
699
|
+
? { app: "Cursor", ...(await addToJsonConfig(join(dir, "mcp.json"), "mcpServers", { url }, dryRun)) }
|
|
700
|
+
: { app: "Cursor", status: "not-found" },
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
if (!only || only.includes("vscode")) {
|
|
704
|
+
const dir = vscodeUserDir();
|
|
705
|
+
results.push(
|
|
706
|
+
(await dirExists(dir))
|
|
707
|
+
? {
|
|
708
|
+
app: "VS Code",
|
|
709
|
+
...(await addToJsonConfig(join(dir, "mcp.json"), "servers", { type: "http", url }, dryRun)),
|
|
710
|
+
}
|
|
711
|
+
: { app: "VS Code", status: "not-found" },
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
const label = {
|
|
715
|
+
added: "added",
|
|
716
|
+
already: "already set up",
|
|
717
|
+
"would-add": "would add",
|
|
718
|
+
"not-found": "not installed",
|
|
719
|
+
manual: "needs a manual step",
|
|
720
|
+
failed: "failed",
|
|
721
|
+
};
|
|
722
|
+
const lines = results.map((r) => ` ${r.app.padEnd(14)} ${label[r.status]}${r.note ? `: ${r.note}` : ""}`);
|
|
723
|
+
lines.push(
|
|
724
|
+
"",
|
|
725
|
+
`Server: ${url}`,
|
|
726
|
+
"Each app asks you to sign in to AttachPage the first time you use it.",
|
|
727
|
+
"Claude and ChatGPT on the web: Settings > Connectors > Add custom connector, with the server above.",
|
|
728
|
+
);
|
|
729
|
+
out(lines.join("\n"), { server: url, dryRun, results });
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const HELP = `attachpage ${VERSION}: send websites like attachments
|
|
733
|
+
|
|
734
|
+
Usage
|
|
735
|
+
attachpage <folder> [--name "Q3 report"] [--slug q3-report] [--public] [--site <id> [--notify "note"]]
|
|
736
|
+
attachpage send <folder> --to a@b.com,c@d.com [--message "..."] [--expires 7]
|
|
737
|
+
attachpage login [--no-browser] [--store file] | logout | whoami
|
|
738
|
+
attachpage connect [--client claude-code,codex,gemini,cursor,vscode] [--dry-run]
|
|
739
|
+
attachpage feedback <siteId> [--all]
|
|
740
|
+
|
|
741
|
+
Sign in once with \`attachpage login\` (Google or Microsoft account). Signed-in pages are private by
|
|
742
|
+
default: only the people you send them to can open them. Without signing in, a folder is published
|
|
743
|
+
as a public page that expires in 48 hours, with a link to keep it.
|
|
744
|
+
Options: --json for machine-readable output, --origin to use another server.
|
|
745
|
+
\`login\` keeps the key in your system's password store (Windows Credential Manager, macOS Keychain,
|
|
746
|
+
or the Linux keyring); --store file keeps it in ~/.attachpage/credentials instead.`;
|
|
747
|
+
|
|
748
|
+
const command = positional[0];
|
|
749
|
+
try {
|
|
750
|
+
if (flags.has("version")) console.log(VERSION);
|
|
751
|
+
else if (!command || flags.has("help")) console.log(HELP);
|
|
752
|
+
else if (command === "login") await login();
|
|
753
|
+
else if (command === "logout") await logout();
|
|
754
|
+
else if (command === "whoami") await whoami();
|
|
755
|
+
else if (command === "connect") await connect();
|
|
756
|
+
else if (command === "feedback") await feedback();
|
|
757
|
+
else if (command === "send") await publish({ send: true });
|
|
758
|
+
else await publish();
|
|
759
|
+
} catch (err) {
|
|
760
|
+
fail(err.message);
|
|
761
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "attachpage",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Send websites like attachments: publish a folder, email personal links, read feedback. Command line for AttachPage.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"publish",
|
|
7
|
+
"static-site",
|
|
8
|
+
"share",
|
|
9
|
+
"html",
|
|
10
|
+
"agents",
|
|
11
|
+
"mcp",
|
|
12
|
+
"attachpage"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://attachpage.com",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"email": "support@attachpage.com"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "JP Technology Services (https://jptechnologyservices.com)",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"bin": {
|
|
22
|
+
"attachpage": "bin/attachpage.mjs"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"bin/attachpage.mjs"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node --test test/credentials.test.mjs test/connect.test.mjs"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
}
|
|
33
|
+
}
|