attachpage 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +49 -2
- package/bin/attachpage.mjs +592 -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,50 @@
|
|
|
1
|
-
#
|
|
1
|
+
# attachpage
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Send a website like an attachment. Point it at a folder with an `index.html`; get a link.
|
|
4
|
+
|
|
5
|
+
AttachPage is in an invite-only private beta. Sign in once with an invited account:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx attachpage login # approve in your browser, once
|
|
9
|
+
npx attachpage ./dist # publish (private by default)
|
|
10
|
+
npx attachpage send ./dist --to client@example.com --message "Here's the new version"
|
|
11
|
+
npx attachpage feedback <pageId> # what they said, and where
|
|
12
|
+
npx attachpage ./dist --site <pageId> --notify "Applied your feedback"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Pages are private by default: only the people you send them to can open them, each with a
|
|
16
|
+
personal link you can revoke. Recipients can comment on the page and approve it; `feedback`
|
|
17
|
+
lists what they said with the element they pointed at, which is handy for agents. Add `--public`
|
|
18
|
+
to make a page open to anyone with the link. Publishing without an account (public pages that
|
|
19
|
+
expire after 48 hours) is paused during the beta.
|
|
20
|
+
|
|
21
|
+
- No dependencies, Node 18+.
|
|
22
|
+
- Your files are uploaded exactly as they are. Nothing is built, bundled or rewritten.
|
|
23
|
+
- `login` keeps your key in the system password store: Windows Credential Manager, the macOS Keychain,
|
|
24
|
+
or the Linux keyring (Secret Service via `secret-tool`). Use `login --store file` on machines without one;
|
|
25
|
+
the key then goes to `~/.attachpage/credentials`, readable only by you. `ATTACHPAGE_API_KEY` overrides both.
|
|
26
|
+
`whoami` shows where the key came from; `logout` removes it from every place.
|
|
27
|
+
- `--json` on any command prints machine-readable output.
|
|
28
|
+
|
|
29
|
+
## Agents and MCP
|
|
30
|
+
|
|
31
|
+
This package is a command line, not an MCP server. AttachPage's MCP server is remote
|
|
32
|
+
(Streamable HTTP with OAuth sign-in) at `https://attachpage.com/mcp`; add that URL to your MCP
|
|
33
|
+
client. For clients that only run local stdio servers, bridge it with
|
|
34
|
+
[mcp-remote](https://www.npmjs.com/package/mcp-remote):
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"mcpServers": {
|
|
39
|
+
"attachpage": {
|
|
40
|
+
"command": "npx",
|
|
41
|
+
"args": ["-y", "mcp-remote", "https://attachpage.com/mcp"]
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Setup for Claude, ChatGPT, Cursor, VS Code and Codex: https://attachpage.com/agents.
|
|
48
|
+
Agent instructions: https://attachpage.com/skill.md.
|
|
49
|
+
|
|
50
|
+
Support: support@attachpage.com. MIT licensed, by JP Technology Services.
|
|
@@ -0,0 +1,592 @@
|
|
|
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 as you (anonymous 48 h pages are paused in the beta)
|
|
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
|
+
*
|
|
10
|
+
* No dependencies. Credentials: ATTACHPAGE_API_KEY, or the key `login` saved in the operating system's
|
|
11
|
+
* password store (or in ~/.attachpage/credentials with `login --store file`).
|
|
12
|
+
*/
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { chmod, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
17
|
+
|
|
18
|
+
const VERSION = "0.1.0";
|
|
19
|
+
const CREDENTIALS = process.env.ATTACHPAGE_CREDENTIALS_FILE || join(homedir(), ".attachpage", "credentials");
|
|
20
|
+
const SKIP_DIRS = new Set([".git", "node_modules", ".svn", ".hg"]);
|
|
21
|
+
const SKIP_FILES = new Set([".DS_Store", "Thumbs.db", "desktop.ini"]);
|
|
22
|
+
|
|
23
|
+
// ---------- arguments ----------
|
|
24
|
+
|
|
25
|
+
const argv = process.argv.slice(2);
|
|
26
|
+
const flags = new Map();
|
|
27
|
+
const positional = [];
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
const a = argv[i];
|
|
30
|
+
if (a.startsWith("--")) {
|
|
31
|
+
const [k, inline] = a.slice(2).split("=", 2);
|
|
32
|
+
if (inline !== undefined) flags.set(k, inline);
|
|
33
|
+
else if (argv[i + 1] && !argv[i + 1].startsWith("--")) flags.set(k, argv[++i]);
|
|
34
|
+
else flags.set(k, true);
|
|
35
|
+
} else positional.push(a);
|
|
36
|
+
}
|
|
37
|
+
const asJson = flags.has("json");
|
|
38
|
+
|
|
39
|
+
function out(human, data) {
|
|
40
|
+
if (asJson) console.log(JSON.stringify(data, null, 2));
|
|
41
|
+
else console.log(human);
|
|
42
|
+
}
|
|
43
|
+
function fail(message, code = 1) {
|
|
44
|
+
if (asJson) console.log(JSON.stringify({ error: message }));
|
|
45
|
+
else console.error(`attachpage: ${message}`);
|
|
46
|
+
process.exit(code);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------- credentials ----------
|
|
50
|
+
//
|
|
51
|
+
// Where the key lives, first match wins: ATTACHPAGE_API_KEY, the credentials file (written only with
|
|
52
|
+
// `login --store file` or when no password store exists), then the operating system's password
|
|
53
|
+
// store: Windows Credential Manager (Web Credentials), the macOS Keychain, or the Secret Service
|
|
54
|
+
// keyring through secret-tool. Entries are per server: resource/service "attachpage", account = the
|
|
55
|
+
// server origin. Keys reach these tools on standard input, never on the command line.
|
|
56
|
+
|
|
57
|
+
const STORE_RESOURCE = "attachpage";
|
|
58
|
+
/** Characters allowed in keys and origins handed to password-store tools (no quoting needed). */
|
|
59
|
+
const SAFE = /^[A-Za-z0-9._:/-]+$/;
|
|
60
|
+
|
|
61
|
+
function runTool(file, args, { input = "", env } = {}) {
|
|
62
|
+
return new Promise((done) => {
|
|
63
|
+
let child;
|
|
64
|
+
try {
|
|
65
|
+
child = spawn(file, args, {
|
|
66
|
+
env: env ? { ...process.env, ...env } : process.env,
|
|
67
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
68
|
+
windowsHide: true,
|
|
69
|
+
});
|
|
70
|
+
} catch {
|
|
71
|
+
return done({ code: -1, missing: true, stdout: "", stderr: "" });
|
|
72
|
+
}
|
|
73
|
+
let stdout = "";
|
|
74
|
+
let stderr = "";
|
|
75
|
+
const timer = setTimeout(() => child.kill(), 30_000);
|
|
76
|
+
child.stdout.on("data", (d) => {
|
|
77
|
+
stdout += d;
|
|
78
|
+
});
|
|
79
|
+
child.stderr.on("data", (d) => {
|
|
80
|
+
stderr += d;
|
|
81
|
+
});
|
|
82
|
+
child.on("error", (e) => {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
done({ code: -1, missing: e.code === "ENOENT", stdout, stderr: e.message });
|
|
85
|
+
});
|
|
86
|
+
child.on("close", (code) => {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
done({ code: code ?? -1, missing: false, stdout, stderr: stderr.trim() });
|
|
89
|
+
});
|
|
90
|
+
child.stdin.on("error", () => {});
|
|
91
|
+
child.stdin.end(input);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Windows PowerShell 5.1 projects the WinRT PasswordVault (Credential Manager > Web Credentials).
|
|
96
|
+
const VAULT_SCRIPT = `
|
|
97
|
+
$ErrorActionPreference = 'Stop'
|
|
98
|
+
try {
|
|
99
|
+
[void][Windows.Security.Credentials.PasswordVault, Windows.Security.Credentials, ContentType = WindowsRuntime]
|
|
100
|
+
$vault = New-Object Windows.Security.Credentials.PasswordVault
|
|
101
|
+
} catch { [Console]::Error.Write($_.Exception.Message); exit 2 }
|
|
102
|
+
$resource = $env:ATTACHPAGE_STORE_RESOURCE
|
|
103
|
+
$account = $env:ATTACHPAGE_STORE_ACCOUNT
|
|
104
|
+
function Find-Entry { try { return $vault.Retrieve($resource, $account) } catch { return $null } }
|
|
105
|
+
switch ($env:ATTACHPAGE_STORE_OP) {
|
|
106
|
+
'get' { $c = Find-Entry; if ($null -eq $c) { exit 3 }; $c.RetrievePassword(); [Console]::Out.Write($c.Password) }
|
|
107
|
+
'set' {
|
|
108
|
+
$key = [Console]::In.ReadToEnd().Trim()
|
|
109
|
+
$old = Find-Entry; if ($null -ne $old) { $vault.Remove($old) }
|
|
110
|
+
$vault.Add((New-Object Windows.Security.Credentials.PasswordCredential($resource, $account, $key)))
|
|
111
|
+
}
|
|
112
|
+
'delete' { $c = Find-Entry; if ($null -eq $c) { exit 3 }; $vault.Remove($c) }
|
|
113
|
+
}
|
|
114
|
+
`;
|
|
115
|
+
|
|
116
|
+
async function windowsVault(op, origin, input) {
|
|
117
|
+
// Full path, so a powershell.exe in the current folder can never run instead.
|
|
118
|
+
const ps = join(
|
|
119
|
+
process.env.SystemRoot || "C:\\Windows",
|
|
120
|
+
"System32",
|
|
121
|
+
"WindowsPowerShell",
|
|
122
|
+
"v1.0",
|
|
123
|
+
"powershell.exe",
|
|
124
|
+
);
|
|
125
|
+
const script = Buffer.from(VAULT_SCRIPT, "utf16le").toString("base64");
|
|
126
|
+
return runTool(
|
|
127
|
+
ps,
|
|
128
|
+
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", script],
|
|
129
|
+
{
|
|
130
|
+
input,
|
|
131
|
+
env: {
|
|
132
|
+
ATTACHPAGE_STORE_OP: op,
|
|
133
|
+
ATTACHPAGE_STORE_RESOURCE: STORE_RESOURCE,
|
|
134
|
+
ATTACHPAGE_STORE_ACCOUNT: origin,
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The password store for this platform: `get` → key | null, `set`, `remove` → removed?; throw when unusable. */
|
|
141
|
+
const passwordStore = {
|
|
142
|
+
win32: {
|
|
143
|
+
name: "Windows Credential Manager",
|
|
144
|
+
async get(origin) {
|
|
145
|
+
const r = await windowsVault("get", origin);
|
|
146
|
+
if (r.code === 3) return null;
|
|
147
|
+
if (r.code !== 0)
|
|
148
|
+
throw new Error(r.missing ? "Windows PowerShell was not found" : r.stderr || `exit ${r.code}`);
|
|
149
|
+
return r.stdout.trim() || null;
|
|
150
|
+
},
|
|
151
|
+
async set(origin, key) {
|
|
152
|
+
const r = await windowsVault("set", origin, key);
|
|
153
|
+
if (r.code !== 0) throw new Error(r.stderr || `exit ${r.code}`);
|
|
154
|
+
},
|
|
155
|
+
async remove(origin) {
|
|
156
|
+
const r = await windowsVault("delete", origin);
|
|
157
|
+
if (r.code !== 0 && r.code !== 3) throw new Error(r.stderr || `exit ${r.code}`);
|
|
158
|
+
return r.code === 0;
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
darwin: {
|
|
162
|
+
name: "the macOS Keychain",
|
|
163
|
+
async get(origin) {
|
|
164
|
+
const r = await runTool("/usr/bin/security", [
|
|
165
|
+
"find-generic-password",
|
|
166
|
+
"-s",
|
|
167
|
+
STORE_RESOURCE,
|
|
168
|
+
"-a",
|
|
169
|
+
origin,
|
|
170
|
+
"-w",
|
|
171
|
+
]);
|
|
172
|
+
if (r.code === 44) return null; // item not found
|
|
173
|
+
if (r.code !== 0) throw new Error(r.stderr || `exit ${r.code}`);
|
|
174
|
+
return r.stdout.trim() || null;
|
|
175
|
+
},
|
|
176
|
+
async set(origin, key) {
|
|
177
|
+
// `security -i` reads the command from stdin, which keeps the key out of the process list.
|
|
178
|
+
const r = await runTool("/usr/bin/security", ["-i"], {
|
|
179
|
+
input: `add-generic-password -U -s ${STORE_RESOURCE} -a ${origin} -w ${key}\n`,
|
|
180
|
+
});
|
|
181
|
+
if (r.code !== 0) throw new Error(r.stderr || `exit ${r.code}`);
|
|
182
|
+
},
|
|
183
|
+
async remove(origin) {
|
|
184
|
+
const r = await runTool("/usr/bin/security", [
|
|
185
|
+
"delete-generic-password",
|
|
186
|
+
"-s",
|
|
187
|
+
STORE_RESOURCE,
|
|
188
|
+
"-a",
|
|
189
|
+
origin,
|
|
190
|
+
]);
|
|
191
|
+
if (r.code === 44) return false;
|
|
192
|
+
if (r.code !== 0) throw new Error(r.stderr || `exit ${r.code}`);
|
|
193
|
+
return true;
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
linux: {
|
|
197
|
+
name: "the system keyring (Secret Service)",
|
|
198
|
+
async get(origin) {
|
|
199
|
+
const r = await runTool("secret-tool", ["lookup", "service", STORE_RESOURCE, "account", origin]);
|
|
200
|
+
if (r.missing) throw new Error("secret-tool is not installed");
|
|
201
|
+
if (r.code !== 0 && !r.stderr) return null;
|
|
202
|
+
if (r.code !== 0) throw new Error(r.stderr);
|
|
203
|
+
return r.stdout.trim() || null;
|
|
204
|
+
},
|
|
205
|
+
async set(origin, key) {
|
|
206
|
+
const label = `AttachPage key (${origin})`;
|
|
207
|
+
const r = await runTool(
|
|
208
|
+
"secret-tool",
|
|
209
|
+
["store", "--label", label, "service", STORE_RESOURCE, "account", origin],
|
|
210
|
+
{
|
|
211
|
+
input: key,
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
if (r.missing) throw new Error("secret-tool is not installed");
|
|
215
|
+
if (r.code !== 0) throw new Error(r.stderr || `exit ${r.code}`);
|
|
216
|
+
},
|
|
217
|
+
async remove(origin) {
|
|
218
|
+
if (!(await this.get(origin))) return false;
|
|
219
|
+
const r = await runTool("secret-tool", ["clear", "service", STORE_RESOURCE, "account", origin]);
|
|
220
|
+
if (r.code !== 0) throw new Error(r.stderr || `exit ${r.code}`);
|
|
221
|
+
return true;
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
}[process.platform === "win32" || process.platform === "darwin" ? process.platform : "linux"];
|
|
225
|
+
|
|
226
|
+
async function readCredentialsFile() {
|
|
227
|
+
const found = { key: null, origin: null };
|
|
228
|
+
try {
|
|
229
|
+
for (const line of (await readFile(CREDENTIALS, "utf8")).split(/\r?\n/)) {
|
|
230
|
+
const at = line.indexOf("=");
|
|
231
|
+
if (at < 0) continue;
|
|
232
|
+
const k = line.slice(0, at).trim();
|
|
233
|
+
const v = line.slice(at + 1).trim();
|
|
234
|
+
if (k === "ATTACHPAGE_API_KEY" && v) found.key ??= v;
|
|
235
|
+
if (k === "ATTACHPAGE_ORIGIN" && v) found.origin ??= v;
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
/* no file */
|
|
239
|
+
}
|
|
240
|
+
return found;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Key and server for this run, plus where the key came from. */
|
|
244
|
+
async function readCredentials() {
|
|
245
|
+
const file = await readCredentialsFile();
|
|
246
|
+
const origin = (
|
|
247
|
+
(typeof flags.get("origin") === "string" ? flags.get("origin") : null) ||
|
|
248
|
+
process.env.ATTACHPAGE_ORIGIN ||
|
|
249
|
+
file.origin ||
|
|
250
|
+
"https://attachpage.com"
|
|
251
|
+
).replace(/\/$/, "");
|
|
252
|
+
if (process.env.ATTACHPAGE_API_KEY)
|
|
253
|
+
return { key: process.env.ATTACHPAGE_API_KEY, origin, source: "environment" };
|
|
254
|
+
if (file.key) return { key: file.key, origin, source: CREDENTIALS };
|
|
255
|
+
if (SAFE.test(origin)) {
|
|
256
|
+
try {
|
|
257
|
+
const key = await passwordStore.get(origin);
|
|
258
|
+
if (key) return { key, origin, source: passwordStore.name };
|
|
259
|
+
} catch {
|
|
260
|
+
/* no usable password store: not signed in */
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return { key: null, origin, source: null };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Write the credentials file (owner-only), keeping or dropping the key line. */
|
|
267
|
+
async function writeCredentialsFile({ key, origin }) {
|
|
268
|
+
const lines = [];
|
|
269
|
+
if (key) lines.push(`ATTACHPAGE_API_KEY=${key}`);
|
|
270
|
+
if (origin && origin !== "https://attachpage.com") lines.push(`ATTACHPAGE_ORIGIN=${origin}`);
|
|
271
|
+
if (!lines.length) return rm(CREDENTIALS, { force: true });
|
|
272
|
+
await mkdir(dirname(CREDENTIALS), { recursive: true, mode: 0o700 });
|
|
273
|
+
await writeFile(CREDENTIALS, `${lines.join("\n")}\n`, { mode: 0o600 });
|
|
274
|
+
await chmod(CREDENTIALS, 0o600).catch(() => {});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Save a new key: the password store unless `--store file` (or none is usable), verified by reading
|
|
279
|
+
* it back. Any key left in the plain file is removed once the store holds it.
|
|
280
|
+
* @returns where it was saved.
|
|
281
|
+
*/
|
|
282
|
+
async function saveCredentials(key, origin) {
|
|
283
|
+
const wantFile = flags.get("store") === "file";
|
|
284
|
+
if (!wantFile && SAFE.test(key) && SAFE.test(origin)) {
|
|
285
|
+
try {
|
|
286
|
+
await passwordStore.set(origin, key);
|
|
287
|
+
if ((await passwordStore.get(origin)) === key) {
|
|
288
|
+
await writeCredentialsFile({ key: null, origin });
|
|
289
|
+
return passwordStore.name;
|
|
290
|
+
}
|
|
291
|
+
} catch (e) {
|
|
292
|
+
console.error(
|
|
293
|
+
`attachpage: could not use ${passwordStore.name} (${e.message}); saving to ${CREDENTIALS}.`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
await writeCredentialsFile({ key, origin });
|
|
298
|
+
return `${CREDENTIALS} (readable only by you)`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---------- HTTP ----------
|
|
302
|
+
|
|
303
|
+
async function api(origin, method, path, { key, body } = {}) {
|
|
304
|
+
const res = await fetch(path.startsWith("http") ? path : `${origin}${path}`, {
|
|
305
|
+
method,
|
|
306
|
+
headers: {
|
|
307
|
+
...(key ? { Authorization: `Bearer ${key}` } : {}),
|
|
308
|
+
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
309
|
+
"User-Agent": `attachpage-cli/${VERSION}`,
|
|
310
|
+
},
|
|
311
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
312
|
+
});
|
|
313
|
+
const text = await res.text();
|
|
314
|
+
let data = {};
|
|
315
|
+
try {
|
|
316
|
+
data = text ? JSON.parse(text) : {};
|
|
317
|
+
} catch {
|
|
318
|
+
data = { error: { message: text.slice(0, 200) } };
|
|
319
|
+
}
|
|
320
|
+
if (!res.ok) {
|
|
321
|
+
const err = data.error || {};
|
|
322
|
+
const findings = err.details?.findings;
|
|
323
|
+
const detail = Array.isArray(findings)
|
|
324
|
+
? `\n${findings.map((f) => ` - ${f.path ?? ""} ${f.message}`).join("\n")}`
|
|
325
|
+
: "";
|
|
326
|
+
const e = new Error(`${err.message || res.statusText} (${res.status})${detail}`);
|
|
327
|
+
e.status = res.status;
|
|
328
|
+
throw e;
|
|
329
|
+
}
|
|
330
|
+
return data;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ---------- files ----------
|
|
334
|
+
|
|
335
|
+
async function walk(dir, root = dir, list = []) {
|
|
336
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
337
|
+
if (entry.isSymbolicLink()) continue;
|
|
338
|
+
const full = join(dir, entry.name);
|
|
339
|
+
if (entry.isDirectory()) {
|
|
340
|
+
if (!SKIP_DIRS.has(entry.name)) await walk(full, root, list);
|
|
341
|
+
} else if (entry.isFile() && !SKIP_FILES.has(entry.name)) {
|
|
342
|
+
list.push({ full, path: relative(root, full).split(sep).join("/"), size: (await stat(full)).size });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return list;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function loadFolder(folderArg) {
|
|
349
|
+
const folder = resolve(folderArg || ".");
|
|
350
|
+
let info;
|
|
351
|
+
try {
|
|
352
|
+
info = await stat(folder);
|
|
353
|
+
} catch {
|
|
354
|
+
fail(`${folder} does not exist`);
|
|
355
|
+
}
|
|
356
|
+
if (!info.isDirectory()) fail(`${folder} is not a folder`);
|
|
357
|
+
const files = await walk(folder);
|
|
358
|
+
if (!files.some((f) => f.path === "index.html")) {
|
|
359
|
+
const hint = files.some((f) => f.path.startsWith("dist/index.html"))
|
|
360
|
+
? " Did you mean ./dist?"
|
|
361
|
+
: files.some((f) => f.path.startsWith("build/index.html"))
|
|
362
|
+
? " Did you mean ./build?"
|
|
363
|
+
: "";
|
|
364
|
+
fail(`${folder} has no index.html at the top level.${hint}`);
|
|
365
|
+
}
|
|
366
|
+
return { folder, files };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function uploadAll(targets, files, bearer, origin) {
|
|
370
|
+
const byPath = new Map(files.map((f) => [f.path, f]));
|
|
371
|
+
let next = 0;
|
|
372
|
+
let done = 0;
|
|
373
|
+
const worker = async () => {
|
|
374
|
+
while (next < targets.length) {
|
|
375
|
+
const t = targets[next++];
|
|
376
|
+
const file = byPath.get(t.path);
|
|
377
|
+
if (!file) continue;
|
|
378
|
+
const headers = { ...(t.headers || {}) };
|
|
379
|
+
if (t.url.startsWith(origin)) headers.Authorization = `Bearer ${bearer}`;
|
|
380
|
+
const res = await fetch(t.url, { method: t.method || "PUT", headers, body: await readFile(file.full) });
|
|
381
|
+
if (!res.ok)
|
|
382
|
+
throw new Error(`upload of ${t.path} failed (${res.status}): ${(await res.text()).slice(0, 200)}`);
|
|
383
|
+
done++;
|
|
384
|
+
if (!asJson && process.stderr.isTTY)
|
|
385
|
+
process.stderr.write(`\r uploaded ${done}/${targets.length} files`);
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
await Promise.all(Array.from({ length: Math.min(6, targets.length) }, worker));
|
|
389
|
+
if (!asJson && process.stderr.isTTY) process.stderr.write("\n");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ---------- commands ----------
|
|
393
|
+
|
|
394
|
+
async function publish({ send = false } = {}) {
|
|
395
|
+
const { key, origin } = await readCredentials();
|
|
396
|
+
const { folder, files } = await loadFolder(positional[send ? 1 : positional[0] === "publish" ? 1 : 0]);
|
|
397
|
+
const manifest = files.map((f) => ({ path: f.path, size: f.size }));
|
|
398
|
+
const name = typeof flags.get("name") === "string" ? flags.get("name") : folder.split(sep).pop();
|
|
399
|
+
const slug = typeof flags.get("slug") === "string" ? flags.get("slug") : undefined;
|
|
400
|
+
const siteId = typeof flags.get("site") === "string" ? flags.get("site") : undefined;
|
|
401
|
+
|
|
402
|
+
if (!key) {
|
|
403
|
+
if (send) fail("Sending needs an account. Run `npx attachpage login` first.");
|
|
404
|
+
const anon = await api(origin, "POST", "/api/v1/anonymous/publish", {
|
|
405
|
+
body: { siteName: name, slug, files: manifest },
|
|
406
|
+
}).catch((e) =>
|
|
407
|
+
fail(
|
|
408
|
+
e.status === 403 ? `${e.message}\nSign in with an invited account: npx attachpage login` : e.message,
|
|
409
|
+
),
|
|
410
|
+
);
|
|
411
|
+
await uploadAll(anon.targets, files, anon.uploadToken, origin);
|
|
412
|
+
const fin = await api(origin, "POST", anon.next, { key: anon.uploadToken, body: {} });
|
|
413
|
+
const url = fin.url || anon.url;
|
|
414
|
+
return out(
|
|
415
|
+
`Published (public, expires in 48 hours):\n${url}\n\nKeep it: open this link while signed in:\n${anon.claimUrl}`,
|
|
416
|
+
{ url, claimUrl: anon.claimUrl, expiresAt: anon.expiresAt, siteId: anon.siteId },
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const created = await api(origin, "POST", "/api/v1/publish", {
|
|
421
|
+
key,
|
|
422
|
+
body: {
|
|
423
|
+
siteName: name,
|
|
424
|
+
...(siteId ? { siteId } : slug ? { slug } : {}),
|
|
425
|
+
...(flags.has("public") ? { visibility: "public" } : {}),
|
|
426
|
+
files: manifest,
|
|
427
|
+
},
|
|
428
|
+
}).catch((e) => fail(e.message));
|
|
429
|
+
await uploadAll(created.targets, files, key, origin);
|
|
430
|
+
const fin = await api(origin, "POST", created.next, { key, body: {} });
|
|
431
|
+
let sent = null;
|
|
432
|
+
if (send) {
|
|
433
|
+
const to = String(flags.get("to") || "")
|
|
434
|
+
.split(",")
|
|
435
|
+
.map((s) => s.trim())
|
|
436
|
+
.filter(Boolean);
|
|
437
|
+
if (to.length === 0) fail("Add --to name@example.com (comma-separated for several people).");
|
|
438
|
+
sent = await api(origin, "POST", `/api/v1/sites/${fin.siteId}/send`, {
|
|
439
|
+
key,
|
|
440
|
+
body: {
|
|
441
|
+
recipients: to,
|
|
442
|
+
message: typeof flags.get("message") === "string" ? flags.get("message") : "",
|
|
443
|
+
...(flags.has("expires") ? { expiresInDays: Number(flags.get("expires")) } : {}),
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
let notified = null;
|
|
448
|
+
if (siteId && flags.has("notify")) {
|
|
449
|
+
notified = await api(origin, "POST", `/api/v1/sites/${siteId}/notify-update`, {
|
|
450
|
+
key,
|
|
451
|
+
body: { note: typeof flags.get("notify") === "string" ? flags.get("notify") : "" },
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
const lines = [fin.url, ""];
|
|
455
|
+
lines.push(
|
|
456
|
+
fin.visibility === "public"
|
|
457
|
+
? "Public: anyone with the link can open it."
|
|
458
|
+
: "Private: people need an invitation to open it.",
|
|
459
|
+
);
|
|
460
|
+
if (sent) lines.push(`Sent to ${String(flags.get("to"))}. Each person got their own link.`);
|
|
461
|
+
if (notified)
|
|
462
|
+
lines.push(
|
|
463
|
+
`Told ${notified.notified} ${notified.notified === 1 ? "person" : "people"} about the update.`,
|
|
464
|
+
);
|
|
465
|
+
lines.push(`Page id: ${fin.siteId}`);
|
|
466
|
+
out(lines.join("\n"), { ...fin, sent, notified });
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function login() {
|
|
470
|
+
const { origin } = await readCredentials();
|
|
471
|
+
const start = await api(origin, "POST", "/api/v1/device/start", {
|
|
472
|
+
body: { clientName: "AttachPage CLI" },
|
|
473
|
+
}).catch((e) => fail(e.message));
|
|
474
|
+
console.error(`Open ${start.verificationUrl} and press Allow (code ${start.userCode}).`);
|
|
475
|
+
if (!flags.has("no-browser")) openBrowser(start.verificationUrl);
|
|
476
|
+
const deadline = Date.now() + (start.expiresIn || 600) * 1000;
|
|
477
|
+
while (Date.now() < deadline) {
|
|
478
|
+
await new Promise((r) => setTimeout(r, (start.interval || 3) * 1000));
|
|
479
|
+
const poll = await api(origin, "POST", "/api/v1/device/poll", { body: { deviceCode: start.deviceCode } });
|
|
480
|
+
if (poll.status === "approved" && poll.apiKey) {
|
|
481
|
+
const savedTo = await saveCredentials(poll.apiKey, origin);
|
|
482
|
+
const me = await api(origin, "GET", "/api/auth/me", { key: poll.apiKey }).catch(() => ({}));
|
|
483
|
+
return out(`Signed in${me.user ? ` as ${me.user.email}` : ""}. Key saved to ${savedTo}.`, {
|
|
484
|
+
ok: true,
|
|
485
|
+
email: me.user?.email ?? null,
|
|
486
|
+
savedTo,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
if (poll.status === "denied" || poll.status === "expired" || poll.status === "claimed")
|
|
490
|
+
fail(`Sign-in ${poll.status}.`);
|
|
491
|
+
}
|
|
492
|
+
fail("Sign-in timed out.");
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function openBrowser(url) {
|
|
496
|
+
const cmd = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open";
|
|
497
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
498
|
+
try {
|
|
499
|
+
spawn(cmd, args, { stdio: "ignore", detached: true })
|
|
500
|
+
.on("error", () => {})
|
|
501
|
+
.unref();
|
|
502
|
+
} catch {
|
|
503
|
+
/* printing the link is enough */
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function logout() {
|
|
508
|
+
const { origin } = await readCredentials();
|
|
509
|
+
const removed = [];
|
|
510
|
+
const file = await readCredentialsFile();
|
|
511
|
+
if (file.key) {
|
|
512
|
+
await writeCredentialsFile({ key: null, origin: file.origin });
|
|
513
|
+
removed.push(CREDENTIALS);
|
|
514
|
+
}
|
|
515
|
+
if (SAFE.test(origin)) {
|
|
516
|
+
try {
|
|
517
|
+
if (await passwordStore.remove(origin)) removed.push(passwordStore.name);
|
|
518
|
+
} catch {
|
|
519
|
+
/* no usable password store */
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
out(
|
|
523
|
+
removed.length
|
|
524
|
+
? `Signed out on this machine: removed the key from ${removed.join(" and ")}. (Revoke it under For agents if it may have leaked.)`
|
|
525
|
+
: "No saved key on this machine.",
|
|
526
|
+
{ ok: true, removedFrom: removed },
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function feedback() {
|
|
531
|
+
const { key, origin } = await readCredentials();
|
|
532
|
+
if (!key) fail("Run `npx attachpage login` first.");
|
|
533
|
+
const siteId = positional[1];
|
|
534
|
+
if (!siteId) fail("Usage: attachpage feedback <siteId> [--all]");
|
|
535
|
+
const data = await api(
|
|
536
|
+
origin,
|
|
537
|
+
"GET",
|
|
538
|
+
`/api/v1/sites/${siteId}/feedback${flags.has("all") ? "" : "?status=open"}`,
|
|
539
|
+
{
|
|
540
|
+
key,
|
|
541
|
+
},
|
|
542
|
+
).catch((e) => fail(e.message));
|
|
543
|
+
if (asJson) return out("", data);
|
|
544
|
+
for (const d of data.decisions)
|
|
545
|
+
console.log(`${d.decision === "approval" ? "APPROVED" : "CHANGES REQUESTED"} ${d.email}`);
|
|
546
|
+
if (data.items.length === 0) return console.log("No open feedback.");
|
|
547
|
+
for (const i of data.items) {
|
|
548
|
+
const where = i.anchor ? ` on "${i.anchor.text || i.anchor.selector}"` : "";
|
|
549
|
+
console.log(`\n[${i.id}] ${i.authorEmail}${where} (${i.pagePath})\n ${i.body.replace(/\n/g, "\n ")}`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async function whoami() {
|
|
554
|
+
const { key, origin, source } = await readCredentials();
|
|
555
|
+
if (!key) return out(`Not signed in (${origin}).`, { signedIn: false, origin });
|
|
556
|
+
const me = await api(origin, "GET", "/api/auth/me", { key }).catch((e) => fail(e.message));
|
|
557
|
+
out(`${me.user?.email ?? "unknown"} on ${origin} (key from ${source})`, {
|
|
558
|
+
signedIn: true,
|
|
559
|
+
email: me.user?.email,
|
|
560
|
+
origin,
|
|
561
|
+
source,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const HELP = `attachpage ${VERSION}: send websites like attachments
|
|
566
|
+
|
|
567
|
+
Usage
|
|
568
|
+
attachpage <folder> [--name "Q3 report"] [--slug q3-report] [--public] [--site <id> [--notify "note"]]
|
|
569
|
+
attachpage send <folder> --to a@b.com,c@d.com [--message "..."] [--expires 7]
|
|
570
|
+
attachpage login [--no-browser] [--store file] | logout | whoami
|
|
571
|
+
attachpage feedback <siteId> [--all]
|
|
572
|
+
|
|
573
|
+
AttachPage is an invite-only private beta: run \`attachpage login\` with an invited account.
|
|
574
|
+
Pages are private by default: only the people you send them to can open them.
|
|
575
|
+
(Publishing without an account, as public 48-hour pages, is paused during the beta.)
|
|
576
|
+
Options: --json for machine-readable output, --origin to use another server.
|
|
577
|
+
\`login\` keeps the key in your system's password store (Windows Credential Manager, macOS Keychain,
|
|
578
|
+
or the Linux keyring); --store file keeps it in ~/.attachpage/credentials instead.`;
|
|
579
|
+
|
|
580
|
+
const command = positional[0];
|
|
581
|
+
try {
|
|
582
|
+
if (flags.has("version")) console.log(VERSION);
|
|
583
|
+
else if (!command || flags.has("help")) console.log(HELP);
|
|
584
|
+
else if (command === "login") await login();
|
|
585
|
+
else if (command === "logout") await logout();
|
|
586
|
+
else if (command === "whoami") await whoami();
|
|
587
|
+
else if (command === "feedback") await feedback();
|
|
588
|
+
else if (command === "send") await publish({ send: true });
|
|
589
|
+
else await publish();
|
|
590
|
+
} catch (err) {
|
|
591
|
+
fail(err.message);
|
|
592
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "attachpage",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Send websites like attachments: publish a folder, email personal links, read feedback. Command line for AttachPage (invite-only private beta).",
|
|
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"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
}
|
|
33
|
+
}
|