promaster 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 +57 -0
- package/bin/promaster.js +46 -0
- package/package.json +32 -0
- package/src/commands/list.js +67 -0
- package/src/config.js +44 -0
- package/src/download.js +26 -0
- package/src/github.js +87 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cavid Selimov
|
|
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
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# promaster
|
|
2
|
+
|
|
3
|
+
Interactive CLI to browse and download Markdown from a **public GitHub repo**,
|
|
4
|
+
grouped into three categories: **blog**, **memory**, **books**.
|
|
5
|
+
|
|
6
|
+
The package ships only the CLI. Your Markdown content lives in your own GitHub
|
|
7
|
+
repo and is fetched at runtime — it is never bundled into npm.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g promaster
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Content repo layout
|
|
16
|
+
|
|
17
|
+
One public repo with three top-level folders, each holding `.md` files:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
your-repo/
|
|
21
|
+
├── blog/
|
|
22
|
+
├── memory/
|
|
23
|
+
└── books/
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
promaster list
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
1. Pick a category: `blog` / `memory` / `books`.
|
|
33
|
+
2. Files are listed newest → oldest (by last git commit date).
|
|
34
|
+
3. Toggle one or more with space, confirm with enter.
|
|
35
|
+
4. Selected files download to `./promaster-data/<category>/`, where your app reads them.
|
|
36
|
+
|
|
37
|
+
## Configuration
|
|
38
|
+
|
|
39
|
+
Source repo is resolved in this order:
|
|
40
|
+
|
|
41
|
+
1. `PROMASTER_REPO=owner/repo` environment variable
|
|
42
|
+
2. `"promaster": { "repo": "owner/repo" }` in the current directory's `package.json`
|
|
43
|
+
3. The `DEFAULT_REPO` constant in `src/config.js`
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
PROMASTER_REPO=cavid/my-notes promaster list
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Optional `GITHUB_TOKEN` raises the unauthenticated rate limit (60/hr → 5000/hr):
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
GITHUB_TOKEN=ghp_xxx promaster list
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
MIT
|
package/bin/promaster.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runList } from "../src/commands/list.js";
|
|
3
|
+
import { HttpError } from "../src/github.js";
|
|
4
|
+
|
|
5
|
+
const USAGE = `promaster - browse & download Markdown from a GitHub repo
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
promaster list Pick a category (blog/memory/books), then file(s) to download.
|
|
9
|
+
|
|
10
|
+
Config:
|
|
11
|
+
PROMASTER_REPO=owner/repo Source repo (or "promaster".repo in package.json).
|
|
12
|
+
GITHUB_TOKEN=... Optional, raises the GitHub API rate limit.`;
|
|
13
|
+
|
|
14
|
+
async function main() {
|
|
15
|
+
const cmd = process.argv[2];
|
|
16
|
+
switch (cmd) {
|
|
17
|
+
case "list":
|
|
18
|
+
await runList();
|
|
19
|
+
break;
|
|
20
|
+
case undefined:
|
|
21
|
+
case "-h":
|
|
22
|
+
case "--help":
|
|
23
|
+
case "help":
|
|
24
|
+
console.log(USAGE);
|
|
25
|
+
break;
|
|
26
|
+
default:
|
|
27
|
+
console.error(`Unknown command: ${cmd}\n`);
|
|
28
|
+
console.log(USAGE);
|
|
29
|
+
process.exitCode = 1;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
main().catch((err) => {
|
|
34
|
+
if (err && err.name === "ExitPromptError") {
|
|
35
|
+
// user pressed Ctrl+C in a prompt
|
|
36
|
+
process.exitCode = 130;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (err instanceof HttpError) {
|
|
40
|
+
console.error(err.message);
|
|
41
|
+
process.exitCode = 1;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
console.error(err?.message || String(err));
|
|
45
|
+
process.exitCode = 1;
|
|
46
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "promaster",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Interactive CLI to browse and download Markdown (blog/memory/books) from a public GitHub repo.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"promaster": "./bin/promaster.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"cli",
|
|
23
|
+
"markdown",
|
|
24
|
+
"github",
|
|
25
|
+
"blog",
|
|
26
|
+
"notes"
|
|
27
|
+
],
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@inquirer/prompts": "^7.0.0"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { select, checkbox } from "@inquirer/prompts";
|
|
2
|
+
import { resolve, dirname } from "node:path";
|
|
3
|
+
import { resolveRepo, CATEGORIES } from "../config.js";
|
|
4
|
+
import { listMarkdown, lastCommitDate, fetchRaw, HttpError } from "../github.js";
|
|
5
|
+
import { save, OUTPUT_ROOT } from "../download.js";
|
|
6
|
+
|
|
7
|
+
const CONCURRENCY = 5;
|
|
8
|
+
|
|
9
|
+
// Run async mapper over items with a bounded number of in-flight tasks.
|
|
10
|
+
async function mapLimit(items, limit, mapper) {
|
|
11
|
+
const results = new Array(items.length);
|
|
12
|
+
let next = 0;
|
|
13
|
+
async function worker() {
|
|
14
|
+
while (next < items.length) {
|
|
15
|
+
const i = next++;
|
|
16
|
+
results[i] = await mapper(items[i], i);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
20
|
+
return results;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function fmtDate(ms) {
|
|
24
|
+
return ms ? new Date(ms).toISOString().slice(0, 10) : "????-??-??";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function runList() {
|
|
28
|
+
const ctx = resolveRepo();
|
|
29
|
+
|
|
30
|
+
const category = await select({
|
|
31
|
+
message: "Choose a category",
|
|
32
|
+
choices: CATEGORIES.map((c) => ({ name: c, value: c })),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const files = await listMarkdown(category, ctx);
|
|
36
|
+
if (files.length === 0) {
|
|
37
|
+
console.log(`No .md files found in "${category}".`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const dates = await mapLimit(files, CONCURRENCY, (f) => lastCommitDate(f.path, ctx));
|
|
42
|
+
const sorted = files
|
|
43
|
+
.map((f, i) => ({ ...f, date: dates[i] }))
|
|
44
|
+
.sort((a, b) => b.date - a.date); // newest first
|
|
45
|
+
|
|
46
|
+
const picked = await checkbox({
|
|
47
|
+
message: "Select file(s) (space to toggle, enter to confirm)",
|
|
48
|
+
choices: sorted.map((f) => ({ name: `${f.name} (${fmtDate(f.date)})`, value: f })),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (picked.length === 0) {
|
|
52
|
+
console.log("Nothing selected.");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const saved = [];
|
|
57
|
+
for (const file of picked) {
|
|
58
|
+
const content = await fetchRaw(file.download_url, ctx);
|
|
59
|
+
saved.push(await save(category, file, content));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log("\nSaved:");
|
|
63
|
+
for (const p of saved) console.log(` ${p}`);
|
|
64
|
+
console.log(`\nOutput dir: ${resolve(process.cwd(), OUTPUT_ROOT, category)}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export { HttpError };
|
package/src/config.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
// Default content repo (this git repo). Override with PROMASTER_REPO if the repo name differs.
|
|
5
|
+
const DEFAULT_REPO = "jsznpm/proman";
|
|
6
|
+
|
|
7
|
+
export const CATEGORIES = ["blog", "memory", "books"];
|
|
8
|
+
|
|
9
|
+
function parseRepo(value) {
|
|
10
|
+
if (typeof value !== "string") return null;
|
|
11
|
+
const match = value.trim().match(/^([^/\s]+)\/([^/\s]+)$/);
|
|
12
|
+
if (!match) return null;
|
|
13
|
+
return { owner: match[1], repo: match[2] };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function fromPackageJson() {
|
|
17
|
+
try {
|
|
18
|
+
const raw = readFileSync(resolve(process.cwd(), "package.json"), "utf8");
|
|
19
|
+
const pkg = JSON.parse(raw);
|
|
20
|
+
return pkg?.promaster?.repo ?? null;
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the source repo in priority order:
|
|
28
|
+
* 1. env PROMASTER_REPO
|
|
29
|
+
* 2. "promaster".repo in the current dir's package.json
|
|
30
|
+
* 3. DEFAULT_REPO constant
|
|
31
|
+
* Returns { owner, repo, token }. Throws if none is a valid "owner/repo".
|
|
32
|
+
*/
|
|
33
|
+
export function resolveRepo() {
|
|
34
|
+
const candidates = [process.env.PROMASTER_REPO, fromPackageJson(), DEFAULT_REPO];
|
|
35
|
+
for (const candidate of candidates) {
|
|
36
|
+
const parsed = parseRepo(candidate);
|
|
37
|
+
if (parsed) {
|
|
38
|
+
return { ...parsed, token: process.env.GITHUB_TOKEN || null };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
throw new Error(
|
|
42
|
+
"No source repo configured. Set PROMASTER_REPO=owner/repo, add { \"promaster\": { \"repo\": \"owner/repo\" } } to package.json, or edit DEFAULT_REPO."
|
|
43
|
+
);
|
|
44
|
+
}
|
package/src/download.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const OUTPUT_ROOT = "promaster-data";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Sanitize a GitHub file name to a safe basename (no traversal, no slashes).
|
|
8
|
+
*/
|
|
9
|
+
export function safeName(name) {
|
|
10
|
+
const base = basename(String(name).replace(/\\/g, "/"));
|
|
11
|
+
if (!base || base === "." || base === "..") {
|
|
12
|
+
throw new Error(`Unsafe file name: ${name}`);
|
|
13
|
+
}
|
|
14
|
+
return base;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Save raw content under ./promaster-data/<category>/<name>. Returns absolute path.
|
|
19
|
+
*/
|
|
20
|
+
export async function save(category, file, content) {
|
|
21
|
+
const dir = resolve(process.cwd(), OUTPUT_ROOT, safeName(category));
|
|
22
|
+
await mkdir(dir, { recursive: true });
|
|
23
|
+
const dest = resolve(dir, safeName(file.name));
|
|
24
|
+
await writeFile(dest, content, "utf8");
|
|
25
|
+
return dest;
|
|
26
|
+
}
|
package/src/github.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const API = "https://api.github.com";
|
|
2
|
+
|
|
3
|
+
function headers({ token }) {
|
|
4
|
+
const h = {
|
|
5
|
+
Accept: "application/vnd.github+json",
|
|
6
|
+
"User-Agent": "promaster-cli",
|
|
7
|
+
};
|
|
8
|
+
if (token) h.Authorization = `Bearer ${token}`;
|
|
9
|
+
return h;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
class HttpError extends Error {
|
|
13
|
+
constructor(message, { status, rateLimited } = {}) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "HttpError";
|
|
16
|
+
this.status = status;
|
|
17
|
+
this.rateLimited = Boolean(rateLimited);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function request(url, ctx) {
|
|
22
|
+
const res = await fetch(url, { headers: headers(ctx) });
|
|
23
|
+
if (res.ok) return res;
|
|
24
|
+
const rateLimited =
|
|
25
|
+
res.status === 403 && res.headers.get("x-ratelimit-remaining") === "0";
|
|
26
|
+
if (rateLimited) {
|
|
27
|
+
throw new HttpError(
|
|
28
|
+
"GitHub API rate limit reached (60/hr unauthenticated). Wait and retry, or set GITHUB_TOKEN.",
|
|
29
|
+
{ status: 403, rateLimited: true }
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
let snippet = "";
|
|
33
|
+
try {
|
|
34
|
+
snippet = (await res.text()).slice(0, 200);
|
|
35
|
+
} catch {
|
|
36
|
+
/* ignore */
|
|
37
|
+
}
|
|
38
|
+
throw new HttpError(`GitHub API ${res.status} ${res.statusText}: ${snippet}`, {
|
|
39
|
+
status: res.status,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* List .md files in a top-level category folder of the repo.
|
|
45
|
+
* Returns [{ name, path, download_url }].
|
|
46
|
+
*/
|
|
47
|
+
export async function listMarkdown(category, ctx) {
|
|
48
|
+
const url = `${API}/repos/${ctx.owner}/${ctx.repo}/contents/${encodeURIComponent(category)}`;
|
|
49
|
+
let res;
|
|
50
|
+
try {
|
|
51
|
+
res = await request(url, ctx);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
if (err instanceof HttpError && err.status === 404) return [];
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
const items = await res.json();
|
|
57
|
+
if (!Array.isArray(items)) return [];
|
|
58
|
+
return items
|
|
59
|
+
.filter((it) => it.type === "file" && it.name.endsWith(".md"))
|
|
60
|
+
.map((it) => ({ name: it.name, path: it.path, download_url: it.download_url }));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Last commit date (ms epoch) touching a path. Returns 0 on failure.
|
|
65
|
+
*/
|
|
66
|
+
export async function lastCommitDate(path, ctx) {
|
|
67
|
+
const url = `${API}/repos/${ctx.owner}/${ctx.repo}/commits?path=${encodeURIComponent(path)}&per_page=1`;
|
|
68
|
+
try {
|
|
69
|
+
const res = await request(url, ctx);
|
|
70
|
+
const commits = await res.json();
|
|
71
|
+
const iso = commits?.[0]?.commit?.committer?.date;
|
|
72
|
+
const ms = iso ? Date.parse(iso) : NaN;
|
|
73
|
+
return Number.isNaN(ms) ? 0 : ms;
|
|
74
|
+
} catch {
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Fetch raw file content (utf-8 text).
|
|
81
|
+
*/
|
|
82
|
+
export async function fetchRaw(url, ctx) {
|
|
83
|
+
const res = await request(url, ctx);
|
|
84
|
+
return res.text();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export { HttpError };
|