pwahatch-cli 1.0.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/README.md +95 -0
- package/bin/pwahatch.mjs +312 -0
- package/package.json +19 -0
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# pwahatch
|
|
2
|
+
|
|
3
|
+
Submit PWAs to [Hatch](https://pwahatch.vercel.app) from your terminal. Zero dependencies.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npx pwahatch login
|
|
9
|
+
npx pwahatch submit https://your-pwa.com
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The CLI crawls your site's web app manifest, shows a preview, then walks you through category and visibility before submitting.
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
Crawling https://monieplan.xyz...
|
|
16
|
+
|
|
17
|
+
✓ Found manifest
|
|
18
|
+
Name: MoniePlan
|
|
19
|
+
Description: Your personalized budget in minutes.
|
|
20
|
+
Icons: 6 found
|
|
21
|
+
Theme: #009957
|
|
22
|
+
|
|
23
|
+
Categories:
|
|
24
|
+
1. business
|
|
25
|
+
2. education
|
|
26
|
+
...
|
|
27
|
+
12. productivity
|
|
28
|
+
|
|
29
|
+
Pick a number (enter to skip): 12
|
|
30
|
+
Public? (Y/n): Y
|
|
31
|
+
|
|
32
|
+
Submitting...
|
|
33
|
+
|
|
34
|
+
✓ Submitted! Install page: https://pwahatch.vercel.app/monieplan-a3kx9f
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Commands
|
|
38
|
+
|
|
39
|
+
### `pwahatch login`
|
|
40
|
+
|
|
41
|
+
Authenticate with your Hatch account. Prompts for email and password, then stores the session token locally.
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
pwahatch login # defaults to production
|
|
45
|
+
pwahatch login --url http://localhost:3000 # point at a local dev server
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### `pwahatch submit <url>`
|
|
49
|
+
|
|
50
|
+
Submit a PWA. The URL must point to a site with a valid web app manifest.
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
pwahatch submit https://your-pwa.com
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Skip the interactive prompts with flags:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
pwahatch submit https://your-pwa.com --category productivity --private
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
| Flag | Description |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `--category <name>` | Set the category (see list below) |
|
|
65
|
+
| `--private` | List the app as private |
|
|
66
|
+
| `--public` | List the app as public (default) |
|
|
67
|
+
|
|
68
|
+
### `pwahatch logout`
|
|
69
|
+
|
|
70
|
+
Clear the stored session token.
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
pwahatch logout
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Categories
|
|
77
|
+
|
|
78
|
+
`business` `education` `entertainment` `finance` `food & drink` `games` `health & fitness` `lifestyle` `music` `news` `photo & video` `productivity` `shopping` `social` `sports` `travel` `utilities` `weather`
|
|
79
|
+
|
|
80
|
+
Pass any of these to `--category`. The value is case-insensitive.
|
|
81
|
+
|
|
82
|
+
## Configuration
|
|
83
|
+
|
|
84
|
+
Session data is stored at `~/.pwahatch/config.json` with `0600` permissions. The file contains your session token and the base URL of the Hatch server you logged into.
|
|
85
|
+
|
|
86
|
+
To switch servers, log in again with a different `--url`. To reset everything, run `pwahatch logout`.
|
|
87
|
+
|
|
88
|
+
## Requirements
|
|
89
|
+
|
|
90
|
+
- Node.js 18.15 or later
|
|
91
|
+
- A [Hatch](https://pwahatch.vercel.app) account
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
package/bin/pwahatch.mjs
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const CONFIG_DIR = join(homedir(), ".pwahatch");
|
|
8
|
+
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
9
|
+
const DEFAULT_URL = "http://localhost:3000";
|
|
10
|
+
|
|
11
|
+
const CATEGORIES = [
|
|
12
|
+
"business",
|
|
13
|
+
"education",
|
|
14
|
+
"entertainment",
|
|
15
|
+
"finance",
|
|
16
|
+
"food & drink",
|
|
17
|
+
"games",
|
|
18
|
+
"health & fitness",
|
|
19
|
+
"lifestyle",
|
|
20
|
+
"music",
|
|
21
|
+
"news",
|
|
22
|
+
"photo & video",
|
|
23
|
+
"productivity",
|
|
24
|
+
"shopping",
|
|
25
|
+
"social",
|
|
26
|
+
"sports",
|
|
27
|
+
"travel",
|
|
28
|
+
"utilities",
|
|
29
|
+
"weather",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// ── Config ──────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
function loadConfig() {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
37
|
+
} catch {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function saveConfig(config) {
|
|
43
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
44
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", {
|
|
45
|
+
mode: 0o600,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Prompts ─────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
function ask(question) {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
process.stdout.write(question);
|
|
54
|
+
process.stdin.setEncoding("utf-8");
|
|
55
|
+
process.stdin.resume();
|
|
56
|
+
process.stdin.once("data", (data) => {
|
|
57
|
+
process.stdin.pause();
|
|
58
|
+
resolve(data.toString().trim());
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function askPassword(question) {
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
process.stdout.write(question);
|
|
66
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
67
|
+
process.stdin.resume();
|
|
68
|
+
process.stdin.setEncoding("utf-8");
|
|
69
|
+
|
|
70
|
+
let input = "";
|
|
71
|
+
const onData = (ch) => {
|
|
72
|
+
if (ch === "\n" || ch === "\r") {
|
|
73
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
74
|
+
process.stdin.pause();
|
|
75
|
+
process.stdin.removeListener("data", onData);
|
|
76
|
+
process.stdout.write("\n");
|
|
77
|
+
resolve(input);
|
|
78
|
+
} else if (ch === "\u0003") {
|
|
79
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
80
|
+
process.exit();
|
|
81
|
+
} else if (ch === "\u007f" || ch === "\b") {
|
|
82
|
+
input = input.slice(0, -1);
|
|
83
|
+
} else {
|
|
84
|
+
input += ch;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
process.stdin.on("data", onData);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Manifest preview ────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
async function crawlManifest(siteUrl) {
|
|
94
|
+
const url = siteUrl.startsWith("http") ? siteUrl : `https://${siteUrl}`;
|
|
95
|
+
|
|
96
|
+
const res = await fetch(url, {
|
|
97
|
+
headers: { "User-Agent": "pwahatch-cli/1.0" },
|
|
98
|
+
});
|
|
99
|
+
if (!res.ok) throw new Error(`Failed to fetch ${url} (${res.status})`);
|
|
100
|
+
|
|
101
|
+
const html = await res.text();
|
|
102
|
+
const match = html.match(
|
|
103
|
+
/<link[^>]*rel=["']manifest["'][^>]*href=["']([^"']+)["']|<link[^>]*href=["']([^"']+)["'][^>]*rel=["']manifest["']/i
|
|
104
|
+
);
|
|
105
|
+
if (!match) throw new Error("No web app manifest found on that page");
|
|
106
|
+
|
|
107
|
+
const manifestUrl = new URL(match[1] || match[2], url).href;
|
|
108
|
+
const mRes = await fetch(manifestUrl, {
|
|
109
|
+
headers: { "User-Agent": "pwahatch-cli/1.0" },
|
|
110
|
+
});
|
|
111
|
+
if (!mRes.ok)
|
|
112
|
+
throw new Error(`Failed to fetch manifest (${mRes.status})`);
|
|
113
|
+
|
|
114
|
+
const manifest = await mRes.json();
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
name: manifest.name || manifest.short_name || "Untitled",
|
|
118
|
+
description: manifest.description || null,
|
|
119
|
+
icons: Array.isArray(manifest.icons) ? manifest.icons.length : 0,
|
|
120
|
+
themeColor: manifest.theme_color || null,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Commands ────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
async function login(args) {
|
|
127
|
+
let baseUrl = DEFAULT_URL;
|
|
128
|
+
const urlIdx = args.indexOf("--url");
|
|
129
|
+
if (urlIdx !== -1 && args[urlIdx + 1]) {
|
|
130
|
+
baseUrl = args[urlIdx + 1].replace(/\/$/, "");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
console.log();
|
|
134
|
+
const email = await ask(" Email: ");
|
|
135
|
+
const password = await askPassword(" Password: ");
|
|
136
|
+
|
|
137
|
+
if (!email || !password) {
|
|
138
|
+
console.error("\n \x1b[31m✗\x1b[0m Email and password required");
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const res = await fetch(`${baseUrl}/api/auth/sign-in/email`, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: { "Content-Type": "application/json" },
|
|
145
|
+
body: JSON.stringify({ email, password }),
|
|
146
|
+
redirect: "manual",
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (!res.ok && res.status !== 302) {
|
|
150
|
+
const data = await res.json().catch(() => ({}));
|
|
151
|
+
console.error(
|
|
152
|
+
`\n \x1b[31m✗\x1b[0m ${data.message || "Invalid credentials"}`
|
|
153
|
+
);
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Extract session token from Set-Cookie
|
|
158
|
+
const cookies = res.headers.getSetCookie?.() || [];
|
|
159
|
+
let token = null;
|
|
160
|
+
for (const c of cookies) {
|
|
161
|
+
const m = c.match(
|
|
162
|
+
/(?:better-auth\.session_token|session_token)=([^;]+)/
|
|
163
|
+
);
|
|
164
|
+
if (m) {
|
|
165
|
+
token = m[1];
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Fallback: check response body
|
|
171
|
+
if (!token) {
|
|
172
|
+
try {
|
|
173
|
+
const text = await res.text();
|
|
174
|
+
const data = JSON.parse(text);
|
|
175
|
+
token = data.session?.token || data.token;
|
|
176
|
+
} catch {}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!token) {
|
|
180
|
+
console.error("\n \x1b[31m✗\x1b[0m Could not extract session token");
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
saveConfig({ token, baseUrl });
|
|
185
|
+
console.log(`\n \x1b[32m✓\x1b[0m Logged in as ${email}\n`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function submit(args) {
|
|
189
|
+
const config = loadConfig();
|
|
190
|
+
if (!config.token) {
|
|
191
|
+
console.error("\n \x1b[31m✗\x1b[0m Not logged in. Run: pwahatch login\n");
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Parse args
|
|
196
|
+
let url = null;
|
|
197
|
+
let category = null;
|
|
198
|
+
let isPublic = true;
|
|
199
|
+
let hasVisFlag = false;
|
|
200
|
+
|
|
201
|
+
for (let i = 0; i < args.length; i++) {
|
|
202
|
+
if (args[i] === "--category" && args[i + 1]) {
|
|
203
|
+
category = args[++i].toLowerCase();
|
|
204
|
+
} else if (args[i] === "--private") {
|
|
205
|
+
isPublic = false;
|
|
206
|
+
hasVisFlag = true;
|
|
207
|
+
} else if (args[i] === "--public") {
|
|
208
|
+
isPublic = true;
|
|
209
|
+
hasVisFlag = true;
|
|
210
|
+
} else if (!args[i].startsWith("--")) {
|
|
211
|
+
url = args[i];
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!url) {
|
|
216
|
+
console.error("\n \x1b[31m✗\x1b[0m Usage: pwahatch submit <url>\n");
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Preview
|
|
221
|
+
console.log(`\n Crawling ${url}...`);
|
|
222
|
+
let manifest;
|
|
223
|
+
try {
|
|
224
|
+
manifest = await crawlManifest(url);
|
|
225
|
+
} catch (e) {
|
|
226
|
+
console.error(`\n \x1b[31m✗\x1b[0m ${e.message}\n`);
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
console.log(`\n \x1b[32m✓\x1b[0m Found manifest`);
|
|
231
|
+
console.log(` Name: ${manifest.name}`);
|
|
232
|
+
if (manifest.description)
|
|
233
|
+
console.log(` Description: ${manifest.description}`);
|
|
234
|
+
console.log(` Icons: ${manifest.icons} found`);
|
|
235
|
+
if (manifest.themeColor)
|
|
236
|
+
console.log(` Theme: ${manifest.themeColor}`);
|
|
237
|
+
console.log();
|
|
238
|
+
|
|
239
|
+
// Prompt for category
|
|
240
|
+
if (!category) {
|
|
241
|
+
console.log(" Categories:");
|
|
242
|
+
CATEGORIES.forEach((c, i) =>
|
|
243
|
+
console.log(` ${String(i + 1).padStart(2)}. ${c}`)
|
|
244
|
+
);
|
|
245
|
+
console.log();
|
|
246
|
+
const choice = await ask(" Pick a number (enter to skip): ");
|
|
247
|
+
const idx = parseInt(choice, 10);
|
|
248
|
+
if (idx >= 1 && idx <= CATEGORIES.length) {
|
|
249
|
+
category = CATEGORIES[idx - 1];
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Prompt for visibility
|
|
254
|
+
if (!hasVisFlag) {
|
|
255
|
+
const vis = await ask(" Public? (Y/n): ");
|
|
256
|
+
isPublic = vis.toLowerCase() !== "n";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Submit
|
|
260
|
+
console.log("\n Submitting...");
|
|
261
|
+
const baseUrl = config.baseUrl || DEFAULT_URL;
|
|
262
|
+
const res = await fetch(`${baseUrl}/api/apps`, {
|
|
263
|
+
method: "POST",
|
|
264
|
+
headers: {
|
|
265
|
+
"Content-Type": "application/json",
|
|
266
|
+
Cookie: `better-auth.session_token=${config.token}`,
|
|
267
|
+
},
|
|
268
|
+
body: JSON.stringify({ url, category, isPublic }),
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const data = await res.json();
|
|
272
|
+
|
|
273
|
+
if (!res.ok) {
|
|
274
|
+
console.error(`\n \x1b[31m✗\x1b[0m ${data.error || "Submission failed"}\n`);
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
console.log(
|
|
279
|
+
`\n \x1b[32m✓\x1b[0m Submitted! Install page: ${baseUrl}/${data.app.slug}\n`
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function logout() {
|
|
284
|
+
try {
|
|
285
|
+
rmSync(CONFIG_FILE);
|
|
286
|
+
} catch {}
|
|
287
|
+
console.log("\n \x1b[32m✓\x1b[0m Logged out\n");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── Main ────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
const [, , command, ...args] = process.argv;
|
|
293
|
+
const commands = { login, submit, logout };
|
|
294
|
+
|
|
295
|
+
if (!command || !commands[command]) {
|
|
296
|
+
console.log(`
|
|
297
|
+
\x1b[1mpwahatch\x1b[0m — Submit PWAs from your terminal
|
|
298
|
+
|
|
299
|
+
\x1b[2mUsage:\x1b[0m
|
|
300
|
+
pwahatch login [--url <base>] Log in with email/password
|
|
301
|
+
pwahatch submit <url> Submit a PWA
|
|
302
|
+
--category <name> Set category (skip prompt)
|
|
303
|
+
--private List as private
|
|
304
|
+
pwahatch logout Clear stored session
|
|
305
|
+
`);
|
|
306
|
+
process.exit(command ? 1 : 0);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
commands[command](args).catch((err) => {
|
|
310
|
+
console.error(`\n \x1b[31m✗\x1b[0m ${err.message}\n`);
|
|
311
|
+
process.exit(1);
|
|
312
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pwahatch-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Submit PWAs to Hatch from your terminal",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"url": "https://github.com/codellyson/pwahatch-cli.git"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"pwahatch": "./bin/pwahatch.mjs"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18.15.0"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin/"
|
|
17
|
+
],
|
|
18
|
+
"license": "MIT"
|
|
19
|
+
}
|