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 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
@@ -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
+ }
@@ -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 };