| allowBuilds: | ||
| better-sqlite3: true | ||
| esbuild: true | ||
| onlyBuiltDependencies: | ||
| - better-sqlite3 |
+133
-34
@@ -5,31 +5,126 @@ #!/usr/bin/env node | ||
| import Table from "cli-table3"; | ||
| import consola, { consola as consola$1 } from "consola"; | ||
| import { mkdirSync, readFileSync, writeFileSync } from "fs"; | ||
| import { homedir } from "os"; | ||
| import path from "path"; | ||
| import { consola } from "consola"; | ||
| import Database from "better-sqlite3"; | ||
| import { mkdirSync } from "node:fs"; | ||
| import path from "node:path"; | ||
| import { homedir } from "node:os"; | ||
| //#region src/basefs.ts | ||
| const dir = path.join(homedir(), ".config", "subscription-cli", "subscription.json"); | ||
| let _db = null; | ||
| function getDbDir() { | ||
| return process.env.SUBSC_CLI_DB_DIR ?? path.join(homedir(), ".config", "subsc-cli"); | ||
| } | ||
| function getDb() { | ||
| if (_db) return _db; | ||
| const dbdir = getDbDir(); | ||
| mkdirSync(dbdir, { recursive: true }); | ||
| _db = new Database(path.join(dbdir, "subscriptions.db")); | ||
| _db.pragma("journal_mode = WAL"); | ||
| _db.pragma("foreign_keys = ON"); | ||
| _db.exec(` | ||
| CREATE TABLE IF NOT EXISTS subscriptions ( | ||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| name TEXT NOT NULL, | ||
| price INTEGER NOT NULL, | ||
| currency TEXT NOT NULL, | ||
| cycle TEXT NOT NULL | ||
| ); | ||
| `); | ||
| _db.exec(` | ||
| CREATE TABLE IF NOT EXISTS tags ( | ||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| name TEXT NOT NULL UNIQUE | ||
| ); | ||
| `); | ||
| _db.exec(` | ||
| CREATE TABLE IF NOT EXISTS subscription_tags ( | ||
| subscription_id INTEGER NOT NULL, | ||
| tag_id INTEGER NOT NULL, | ||
| PRIMARY KEY (subscription_id, tag_id), | ||
| FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE, | ||
| FOREIGN KEY (tag_id) REFERENCES tags(id) | ||
| ); | ||
| `); | ||
| return _db; | ||
| } | ||
| function mapTags(subs) { | ||
| if (subs.length === 0) return subs; | ||
| const getTags = getDb().prepare(` | ||
| SELECT tags.name FROM tags | ||
| JOIN subscription_tags ON subscription_tags.tag_id = tags.id | ||
| WHERE subscription_tags.subscription_id = ? | ||
| `); | ||
| for (const sub of subs) sub.tags = getTags.all(sub.id).map((r) => r.name); | ||
| return subs; | ||
| } | ||
| const getSubscriptions = () => { | ||
| try { | ||
| const result = readFileSync(dir, "utf-8"); | ||
| return JSON.parse(result).filter(Boolean); | ||
| } catch (err) { | ||
| return []; | ||
| return mapTags(getDb().prepare("SELECT id, name, price, currency, cycle FROM subscriptions ORDER BY id").all()); | ||
| } catch (error) { | ||
| consola.error("Failed to fetch subscriptions:", error); | ||
| throw error; | ||
| } | ||
| }; | ||
| const writeSubscription = (SharedArgs) => { | ||
| mkdirSync(path.dirname(dir), { recursive: true }); | ||
| const json = JSON.stringify(SharedArgs, null, 2); | ||
| const writeSubscription = (data) => { | ||
| try { | ||
| writeFileSync(dir, json, "utf-8"); | ||
| consola.success("done add subscription"); | ||
| const db = getDb(); | ||
| const uniqueTags = Array.from(new Set(data.tags)); | ||
| db.transaction(() => { | ||
| const result = db.prepare(` | ||
| INSERT INTO subscriptions (name, price, currency, cycle) | ||
| VALUES (?, ?, ?, ?) | ||
| `).run(data.name, data.price, data.currency, data.cycle); | ||
| const subscriptionId = Number(result.lastInsertRowid); | ||
| const insertTag = db.prepare(` | ||
| INSERT OR IGNORE INTO tags (name) | ||
| VALUES (?) | ||
| `); | ||
| const getTagId = db.prepare(` | ||
| SELECT id FROM tags WHERE name = ? | ||
| `); | ||
| const insertRel = db.prepare(` | ||
| INSERT INTO subscription_tags (subscription_id, tag_id) | ||
| VALUES (?, ?) | ||
| `); | ||
| for (const t of uniqueTags) { | ||
| insertTag.run(t); | ||
| const tagRow = getTagId.get(t); | ||
| if (tagRow) insertRel.run(subscriptionId, tagRow.id); | ||
| } | ||
| })(); | ||
| } catch (error) { | ||
| consola.error("ファイルを作成することができませんでした"); | ||
| consola.error("Failed to add subscription:", error); | ||
| throw error; | ||
| } | ||
| }; | ||
| const deleteSubscription = (id) => { | ||
| writeSubscription(getSubscriptions().filter((n) => n.id !== id)); | ||
| try { | ||
| if (getDb().prepare("DELETE FROM subscriptions WHERE id = ?").run(id).changes === 0) consola.warn(`No subscription found with id ${id}`); | ||
| } catch (error) { | ||
| consola.error("Failed to delete subscription:", error); | ||
| throw error; | ||
| } | ||
| }; | ||
| const tagsSubscription = (tags) => { | ||
| return getSubscriptions().filter((item) => tags.every((n) => item.tags.includes(n))); | ||
| const tagsSubscription = (tag) => { | ||
| try { | ||
| const db = getDb(); | ||
| const tags = Array.from(new Set(Array.isArray(tag) ? tag : [tag])); | ||
| if (tags.length === 0) return []; | ||
| const placeholders = tags.map(() => "?").join(","); | ||
| const ids = db.prepare(` | ||
| SELECT subscription_tags.subscription_id | ||
| FROM subscription_tags | ||
| JOIN tags ON tags.id = subscription_tags.tag_id | ||
| WHERE tags.name IN (${placeholders}) | ||
| GROUP BY subscription_tags.subscription_id | ||
| HAVING COUNT(DISTINCT tags.name) = ? | ||
| `).all(...tags, tags.length).map((r) => r.subscription_id); | ||
| if (ids.length === 0) return []; | ||
| return mapTags(db.prepare(` | ||
| SELECT id, name, price, currency, cycle FROM subscriptions | ||
| WHERE id IN (${ids.map(() => "?").join(",")}) | ||
| `).all(...ids)); | ||
| } catch (error) { | ||
| consola.error("Failed to filter by tags:", error); | ||
| throw error; | ||
| } | ||
| }; | ||
@@ -41,3 +136,3 @@ //#endregion | ||
| if (list.length === 0) { | ||
| consola$1.info("No subscriptions found"); | ||
| consola.info("No subscriptions found"); | ||
| return; | ||
@@ -54,4 +149,4 @@ } | ||
| String(sub.cycle), | ||
| String(sub.tags.join(" | ")), | ||
| sub.currency === "USD" ? String(`$${sub.price}`) : String(`\\${sub.price}`) | ||
| sub.tags.length > 0 ? sub.tags.join(", ") : "-", | ||
| sub.currency === "USD" ? String(`$${sub.price}`) : String(`¥${sub.price}`) | ||
| ]); | ||
@@ -64,3 +159,3 @@ const usdtotal = list.filter((n) => n.currency === "USD").reduce((sum, n) => sum + n.price, 0); | ||
| "JPY TOTAL", | ||
| `\\${jpytotal}` | ||
| `¥${jpytotal}` | ||
| ], [ | ||
@@ -72,3 +167,3 @@ "", | ||
| ]); | ||
| consola$1.log(table.toString()); | ||
| consola.log(table.toString()); | ||
| }; | ||
@@ -85,10 +180,17 @@ //#endregion | ||
| const name = await input({ message: "subscription name" }); | ||
| const price = await input({ message: "payment subscribe service" }); | ||
| const price = await input({ | ||
| message: "monthly payment amount", | ||
| validate: (value) => { | ||
| if (value.trim() === "") return "Please enter a valid number"; | ||
| if (isNaN(Number(value)) || Number(value) < 0) return "Please enter a valid non-negative number"; | ||
| return true; | ||
| } | ||
| }); | ||
| const currency = await select({ | ||
| message: "currency", | ||
| choices: [{ | ||
| label: "JPY", | ||
| name: "JPY", | ||
| value: "JPY" | ||
| }, { | ||
| label: "USD", | ||
| name: "USD", | ||
| value: "USD" | ||
@@ -100,6 +202,6 @@ }] | ||
| choices: [{ | ||
| label: "monthly", | ||
| name: "monthly", | ||
| value: "monthly" | ||
| }, { | ||
| label: "yearly", | ||
| name: "yearly", | ||
| value: "yearly" | ||
@@ -109,5 +211,3 @@ }] | ||
| const tag = (await input({ message: "tags" })).split(",").map((tag) => tag.trim()).filter(Boolean); | ||
| const get = getSubscriptions(); | ||
| get.push({ | ||
| id: Math.max(0, ...get.map((s) => s.id)) + 1, | ||
| writeSubscription({ | ||
| name, | ||
@@ -119,3 +219,2 @@ price: Number(price), | ||
| }); | ||
| writeSubscription(get); | ||
| }); | ||
@@ -125,4 +224,4 @@ program.command("delete").argument("<number>").action((number) => { | ||
| }); | ||
| program.command("tags").argument("<...text>").action((text) => { | ||
| spreadSubscription(tagsSubscription(text)); | ||
| program.command("tags").argument("<taglist...>").action((taglist) => { | ||
| spreadSubscription(tagsSubscription(taglist)); | ||
| }); | ||
@@ -129,0 +228,0 @@ program.parse(); |
+16
-9
| { | ||
| "name": "subsc-cli", | ||
| "version": "0.3.3", | ||
| "version": "1.0.0", | ||
| "type": "module", | ||
| "license": "MIT", | ||
| "packageManager": "pnpm@11.5.1", | ||
| "bin": { | ||
@@ -16,16 +17,22 @@ "subsc-cli": "./dist/index.mjs" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/bun": "latest", | ||
| "tsdown": "^0.22.2" | ||
| "scripts": { | ||
| "build": "tsdown", | ||
| "start": "tsx src/index.ts", | ||
| "test": "vitest run", | ||
| "test:watch": "vitest" | ||
| }, | ||
| "peerDependencies": { | ||
| "typescript": "^5" | ||
| }, | ||
| "dependencies": { | ||
| "@inquirer/prompts": "^8.5.2", | ||
| "better-sqlite3": "^11.0.0", | ||
| "cli-table3": "^0.6.5", | ||
| "commander": "^15.0.0", | ||
| "consola": "^3.4.2", | ||
| "table": "^6.9.0" | ||
| "consola": "^3.4.2" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/better-sqlite3": "^7.6.12", | ||
| "tsdown": "^0.22.2", | ||
| "tsx": "^4.19.0", | ||
| "typescript": "^5", | ||
| "vitest": "^3.0.0" | ||
| } | ||
| } |
+1
-1
@@ -28,3 +28,3 @@ # subsc-cli | ||
| > [!TIP] | ||
| > The binary name is `sb`. Run via `bunx sb <command>` after `bun install`. | ||
| > The binary name is `subsc-cli`. Run via `bunx subsc-cli <command>` after `bun install`. | ||
@@ -31,0 +31,0 @@ ## Data |
| { | ||
| "compilerOptions": { | ||
| // Environment setup & latest features | ||
| "lib": ["ESNext"], | ||
| "target": "ESNext", | ||
| "module": "Preserve", | ||
| "moduleDetection": "force", | ||
| "jsx": "react-jsx", | ||
| "allowJs": true, | ||
| "types": ["bun"], | ||
| // Bundler mode | ||
| "moduleResolution": "bundler", | ||
| "allowImportingTsExtensions": true, | ||
| "verbatimModuleSyntax": true, | ||
| "noEmit": true, | ||
| // Best practices | ||
| "strict": true, | ||
| "skipLibCheck": true, | ||
| "noFallthroughCasesInSwitch": true, | ||
| "noUncheckedIndexedAccess": true, | ||
| "noImplicitOverride": true, | ||
| // Some stricter flags (disabled by default) | ||
| "noUnusedLocals": false, | ||
| "noUnusedParameters": false, | ||
| "noPropertyAccessFromIndexSignature": false | ||
| } | ||
| } |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
No tests
QualityPackage does not have any tests. This is a strong signal of a poorly maintained or low quality package.
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
9248
41.1%5
-16.67%222
48%1
-66.67%5
150%2
100%1
Infinity%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed