@atyaio/ledgr 0.0.0-stage → 0.1.1
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/dist/index.js +456 -0
- package/package.json +39 -3
- package/README.md +0 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import pc8 from "picocolors";
|
|
6
|
+
|
|
7
|
+
// src/commands/init.ts
|
|
8
|
+
import * as fs3 from "fs";
|
|
9
|
+
import * as path3 from "path";
|
|
10
|
+
import * as p from "@clack/prompts";
|
|
11
|
+
import pc2 from "picocolors";
|
|
12
|
+
import { DEFAULT_EXPENSE_CATEGORIES, DEFAULT_REVENUE_CATEGORIES, slugifyKey } from "@atyaio/ledgr-shared";
|
|
13
|
+
|
|
14
|
+
// src/api.ts
|
|
15
|
+
import * as fs from "fs";
|
|
16
|
+
import * as path from "path";
|
|
17
|
+
import { createRequire } from "module";
|
|
18
|
+
var require2 = createRequire(import.meta.url);
|
|
19
|
+
function loadApi(projectDir) {
|
|
20
|
+
const local = projectDir && localApiEntry(projectDir);
|
|
21
|
+
if (local) return require2(local);
|
|
22
|
+
return require2("@atyaio/ledgr-api");
|
|
23
|
+
}
|
|
24
|
+
function localApiEntry(projectDir) {
|
|
25
|
+
const entry = path.join(projectDir, "apps", "api", "dist", "main.js");
|
|
26
|
+
return fs.existsSync(entry) ? entry : void 0;
|
|
27
|
+
}
|
|
28
|
+
function resolveWebDist(projectDir) {
|
|
29
|
+
if (projectDir) {
|
|
30
|
+
const local = path.join(projectDir, "apps", "web", "dist");
|
|
31
|
+
if (fs.existsSync(path.join(local, "index.html"))) return local;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const pkg2 = require2.resolve("@atyaio/ledgr-web/package.json");
|
|
35
|
+
const dist = path.join(path.dirname(pkg2), "dist");
|
|
36
|
+
if (fs.existsSync(path.join(dist, "index.html"))) return dist;
|
|
37
|
+
} catch {
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
return loadApi().resolveWebDist();
|
|
41
|
+
} catch {
|
|
42
|
+
return void 0;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// src/source.ts
|
|
47
|
+
import * as fs2 from "fs";
|
|
48
|
+
import * as path2 from "path";
|
|
49
|
+
import { spawnSync } from "child_process";
|
|
50
|
+
import pc from "picocolors";
|
|
51
|
+
var UPSTREAM_URL = "https://github.com/atyaio/ledgr.git";
|
|
52
|
+
var UPSTREAM_REMOTE = "ledgr";
|
|
53
|
+
function hasLocalSource(projectDir) {
|
|
54
|
+
return fs2.existsSync(path2.join(projectDir, "apps", "api", "package.json")) && fs2.existsSync(path2.join(projectDir, "packages", "cli", "package.json"));
|
|
55
|
+
}
|
|
56
|
+
function localSourceBuilt(projectDir) {
|
|
57
|
+
return fs2.existsSync(path2.join(projectDir, "apps", "api", "dist", "main.js")) && fs2.existsSync(path2.join(projectDir, "apps", "web", "dist", "index.html"));
|
|
58
|
+
}
|
|
59
|
+
function run(cmd, args, cwd, opts = {}) {
|
|
60
|
+
const r = spawnSync(cmd, args, { cwd, stdio: opts.quiet ? "pipe" : "inherit", env: process.env });
|
|
61
|
+
if (r.status !== 0) {
|
|
62
|
+
const err = opts.quiet ? r.stderr?.toString() ?? "" : "";
|
|
63
|
+
throw new Error(`${cmd} ${args.join(" ")} failed${err ? `: ${err.trim()}` : ""}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function has(cmd) {
|
|
67
|
+
const r = spawnSync(cmd, ["--version"], { stdio: "pipe" });
|
|
68
|
+
return r.status === 0;
|
|
69
|
+
}
|
|
70
|
+
function cloneSource(dir, from = UPSTREAM_URL) {
|
|
71
|
+
if (!has("git")) throw new Error("git is required for --standalone (not found on PATH).");
|
|
72
|
+
if (fs2.existsSync(dir) && fs2.readdirSync(dir).length > 0) throw new Error(`${dir} is not empty. Use a new folder for a standalone project.`);
|
|
73
|
+
fs2.mkdirSync(path2.dirname(dir), { recursive: true });
|
|
74
|
+
run("git", ["clone", "--origin", UPSTREAM_REMOTE, from, dir], process.cwd());
|
|
75
|
+
if (from !== UPSTREAM_URL) run("git", ["remote", "set-url", UPSTREAM_REMOTE, UPSTREAM_URL], dir, { quiet: true });
|
|
76
|
+
fs2.writeFileSync(path2.join(dir, "ledgr.upstream.json"), JSON.stringify({ repository: UPSTREAM_URL, remote: UPSTREAM_REMOTE, clonedFrom: from, clonedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2) + "\n");
|
|
77
|
+
run("git", ["add", "ledgr.upstream.json"], dir, { quiet: true });
|
|
78
|
+
run("git", ["-c", "user.name=ledgr", "-c", "user.email=ledgr@localhost", "commit", "-q", "-m", "chore: standalone ledgr project"], dir, { quiet: true });
|
|
79
|
+
}
|
|
80
|
+
function installAndBuild(dir) {
|
|
81
|
+
if (!has("pnpm")) {
|
|
82
|
+
throw new Error(`pnpm is required to build ledgr. Install it with ${pc.cyan("corepack enable")} (ships with Node) or ${pc.cyan("npm i -g pnpm")}, then run ${pc.cyan("ledgr upgrade")} in the project folder.`);
|
|
83
|
+
}
|
|
84
|
+
run("pnpm", ["install"], dir);
|
|
85
|
+
run("pnpm", ["build"], dir);
|
|
86
|
+
}
|
|
87
|
+
function upgradeSource(dir) {
|
|
88
|
+
if (!has("git")) throw new Error("git is required (not found on PATH).");
|
|
89
|
+
const rev = () => spawnSync("git", ["rev-parse", "--short", "HEAD"], { cwd: dir, stdio: "pipe" }).stdout.toString().trim();
|
|
90
|
+
const before = rev();
|
|
91
|
+
const dirty = spawnSync("git", ["status", "--porcelain", "--untracked-files=no"], { cwd: dir, stdio: "pipe" }).stdout.toString().trim();
|
|
92
|
+
if (dirty) throw new Error("You have uncommitted changes. Commit or stash them first so the upgrade can be merged cleanly.");
|
|
93
|
+
run("git", ["fetch", UPSTREAM_REMOTE, "main"], dir);
|
|
94
|
+
const merge = spawnSync("git", ["merge", "--no-edit", "--quiet", `${UPSTREAM_REMOTE}/main`], { cwd: dir, stdio: "inherit" });
|
|
95
|
+
if (merge.status !== 0) {
|
|
96
|
+
throw new Error(`Merge has conflicts. Resolve them, run ${pc.cyan("git commit")}, then ${pc.cyan("pnpm install && pnpm build")}.`);
|
|
97
|
+
}
|
|
98
|
+
const after = rev();
|
|
99
|
+
return { before, after, changed: before !== after };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/commands/init.ts
|
|
103
|
+
var bail = (v) => {
|
|
104
|
+
if (p.isCancel(v)) {
|
|
105
|
+
p.cancel("Cancelled.");
|
|
106
|
+
process.exit(0);
|
|
107
|
+
}
|
|
108
|
+
throw new Error("unexpected");
|
|
109
|
+
};
|
|
110
|
+
var ask = async (v) => {
|
|
111
|
+
const r = await v;
|
|
112
|
+
if (p.isCancel(r)) bail(r);
|
|
113
|
+
return r;
|
|
114
|
+
};
|
|
115
|
+
async function initCommand(opts) {
|
|
116
|
+
const projectDir = path3.resolve(opts.dir);
|
|
117
|
+
p.intro(pc2.bgYellow(pc2.black(" ledgr ")) + pc2.dim(opts.standalone ? " init --standalone" : " init"));
|
|
118
|
+
if (opts.standalone) {
|
|
119
|
+
if (hasLocalSource(projectDir)) {
|
|
120
|
+
p.log.info("This folder already carries the ledgr source.");
|
|
121
|
+
} else {
|
|
122
|
+
p.log.step(`Cloning the ledgr source into ${pc2.cyan(path3.relative(process.cwd(), projectDir) || ".")} (upstream remote: ${pc2.cyan("ledgr")})`);
|
|
123
|
+
cloneSource(projectDir, opts.from);
|
|
124
|
+
}
|
|
125
|
+
if (opts.build !== false && !localSourceBuilt(projectDir)) {
|
|
126
|
+
p.log.step("Installing dependencies and building (this takes a minute the first time)\u2026");
|
|
127
|
+
installAndBuild(projectDir);
|
|
128
|
+
}
|
|
129
|
+
p.log.success("Source ready. Customise anything under apps/ and packages/; run `ledgr upgrade` to merge upstream changes later.");
|
|
130
|
+
}
|
|
131
|
+
const api = loadApi(projectDir);
|
|
132
|
+
const paths = api.resolvePaths(path3.join(projectDir, ".ledgr"));
|
|
133
|
+
if (!fs3.existsSync(projectDir)) {
|
|
134
|
+
fs3.mkdirSync(projectDir, { recursive: true });
|
|
135
|
+
p.log.info(`Created folder ${pc2.cyan(path3.relative(process.cwd(), projectDir) || ".")}`);
|
|
136
|
+
} else if (projectDir !== process.cwd()) {
|
|
137
|
+
p.log.info(`Setting up ${pc2.cyan(path3.relative(process.cwd(), projectDir))}`);
|
|
138
|
+
}
|
|
139
|
+
if (fs3.existsSync(paths.configPath)) {
|
|
140
|
+
p.log.warn(`A ledgr project already exists at ${pc2.cyan(paths.dir)}`);
|
|
141
|
+
const again = opts.yes ? false : await ask(p.confirm({ message: "Reconfigure it? (data is kept)", initialValue: false }));
|
|
142
|
+
if (!again) {
|
|
143
|
+
p.outro(`Run ${pc2.cyan("ledgr start")} to open it.`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const folderName = path3.basename(projectDir);
|
|
148
|
+
const name = opts.name ?? (opts.yes ? folderName : await ask(p.text({ message: "Project / business name", placeholder: folderName, defaultValue: folderName })));
|
|
149
|
+
const nameAr = opts.yes ? "" : await ask(p.text({ message: `Arabic name ${pc2.dim("(optional, shown when the dashboard is in Arabic)")}`, defaultValue: "", placeholder: "\u0627\u0633\u0645 \u0627\u0644\u0645\u0634\u0631\u0648\u0639" }));
|
|
150
|
+
const currency = (opts.currency ?? (opts.yes ? "USD" : await ask(p.text({ message: "Currency (ISO 4217)", placeholder: "EGP", defaultValue: "USD", validate: (v) => v && !/^[A-Za-z]{3}$/.test(v) ? "Three letters, e.g. EGP" : void 0 })))).toUpperCase();
|
|
151
|
+
const locale = opts.yes ? "en-US" : await ask(p.text({ message: "Locale for number & date formatting", placeholder: "en-EG", defaultValue: "en-US" }));
|
|
152
|
+
const weekStartsOn = opts.yes ? 1 : await ask(p.select({ message: "Week starts on", initialValue: 1, options: [{ value: 6, label: "Saturday" }, { value: 0, label: "Sunday" }, { value: 1, label: "Monday" }] }));
|
|
153
|
+
let auth = { mode: "none" };
|
|
154
|
+
const wantAuth = opts.auth === false ? false : opts.yes ? false : await ask(p.confirm({ message: "Protect the dashboard with Supabase Auth? (admins only)", initialValue: true }));
|
|
155
|
+
if (wantAuth) {
|
|
156
|
+
const supabaseUrl = await ask(p.text({ message: "Supabase project URL", placeholder: "https://xxxx.supabase.co", validate: (v) => /^https?:\/\/.+/.test(v ?? "") ? void 0 : "Enter the project URL" }));
|
|
157
|
+
const supabaseAnonKey = await ask(p.password({ message: "Supabase anon (public) key", validate: (v) => v && v.length > 10 ? void 0 : "Paste the anon key" }));
|
|
158
|
+
auth = { mode: "supabase", supabaseUrl: supabaseUrl.replace(/\/$/, ""), supabaseAnonKey };
|
|
159
|
+
p.note(
|
|
160
|
+
`Only users with ${pc2.cyan('app_metadata.role = "admin"')} can sign in.
|
|
161
|
+
Grant it from the Supabase SQL Editor (the dashboard has no app-metadata editor):
|
|
162
|
+
${pc2.dim(`update auth.users set raw_app_meta_data = coalesce(raw_app_meta_data,'{}'::jsonb) || '{"role":"admin"}'::jsonb where email = 'you@example.com';`)}
|
|
163
|
+
After running it, a page reload is enough; no sign-out needed.`,
|
|
164
|
+
"Who gets access"
|
|
165
|
+
);
|
|
166
|
+
} else {
|
|
167
|
+
p.log.warn("No auth: anyone who can reach the port can use the dashboard. Keep it on localhost.");
|
|
168
|
+
}
|
|
169
|
+
const stakeholders = [];
|
|
170
|
+
if (!opts.yes) {
|
|
171
|
+
const add = await ask(p.confirm({ message: "Add stakeholders (partners with an ownership %) now?", initialValue: true }));
|
|
172
|
+
let remaining = 100;
|
|
173
|
+
while (add) {
|
|
174
|
+
const sname = await ask(p.text({ message: `Stakeholder ${stakeholders.length + 1} name ${pc2.dim("(empty to finish)")}` }));
|
|
175
|
+
if (!sname?.trim()) break;
|
|
176
|
+
const share = await ask(p.text({ message: `${sname}'s ownership %`, defaultValue: String(Math.max(0, remaining)), placeholder: String(Math.max(0, remaining)), validate: (v) => v && (Number.isNaN(Number(v)) || Number(v) < 0 || Number(v) > 100) ? "0\u2013100" : void 0 }));
|
|
177
|
+
const pct = Number(share || remaining);
|
|
178
|
+
stakeholders.push({ name: sname.trim(), sharePercent: pct });
|
|
179
|
+
remaining = Math.round((remaining - pct) * 100) / 100;
|
|
180
|
+
if (remaining <= 0) {
|
|
181
|
+
const more = await ask(p.confirm({ message: "Shares reach 100%. Add another anyway?", initialValue: false }));
|
|
182
|
+
if (!more) break;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
let expenseCategories = [...DEFAULT_EXPENSE_CATEGORIES];
|
|
187
|
+
let revenueCategories = [...DEFAULT_REVENUE_CATEGORIES];
|
|
188
|
+
if (!opts.yes) {
|
|
189
|
+
expenseCategories = await ask(p.multiselect({ message: "Expense categories to start with", options: DEFAULT_EXPENSE_CATEGORIES.map((c) => ({ value: c, label: c })), initialValues: [...DEFAULT_EXPENSE_CATEGORIES], required: false }));
|
|
190
|
+
const extra = await ask(p.text({ message: `Extra expense categories ${pc2.dim("(comma separated, optional)")}`, defaultValue: "" }));
|
|
191
|
+
expenseCategories.push(...(extra ?? "").split(",").map((s2) => s2.trim()).filter(Boolean));
|
|
192
|
+
revenueCategories = await ask(p.multiselect({ message: "Revenue categories to start with", options: DEFAULT_REVENUE_CATEGORIES.map((c) => ({ value: c, label: c })), initialValues: [...DEFAULT_REVENUE_CATEGORIES], required: false }));
|
|
193
|
+
}
|
|
194
|
+
const customFields = [];
|
|
195
|
+
if (!opts.yes) {
|
|
196
|
+
const want = await ask(p.confirm({ message: 'Add custom columns (e.g. "Client" on revenue, "Invoice no." on expenses)?', initialValue: true }));
|
|
197
|
+
while (want) {
|
|
198
|
+
const entity = await ask(p.select({ message: "Add a column to\u2026", options: [{ value: "revenue", label: "Revenue (profit table)" }, { value: "expense", label: "Expenses" }, { value: "stakeholder", label: "Stakeholders" }, { value: "done", label: pc2.dim("Done adding columns") }] }));
|
|
199
|
+
if (entity === "done") break;
|
|
200
|
+
const label = await ask(p.text({ message: "Column label", placeholder: entity === "revenue" ? "Client" : entity === "expense" ? "Invoice no." : "IBAN", validate: (v) => v?.trim() ? void 0 : "Required" }));
|
|
201
|
+
const type = await ask(p.select({ message: "Column type", options: [{ value: "text", label: "Text" }, { value: "number", label: "Number" }, { value: "money", label: `Money (${currency})` }, { value: "date", label: "Date" }, { value: "boolean", label: "Yes / no" }, { value: "select", label: "Select from options" }] }));
|
|
202
|
+
let options;
|
|
203
|
+
if (type === "select") {
|
|
204
|
+
const raw = await ask(p.text({ message: "Options (comma separated)", placeholder: "Direct, Upwork, Referral", validate: (v) => v?.trim() ? void 0 : "At least one option" }));
|
|
205
|
+
options = raw.split(",").map((s2) => s2.trim()).filter(Boolean);
|
|
206
|
+
}
|
|
207
|
+
const required = await ask(p.confirm({ message: "Required?", initialValue: false }));
|
|
208
|
+
let key = slugifyKey(label);
|
|
209
|
+
if (customFields.some((f) => f.entity === entity && f.key === key)) key = `${key}_${customFields.length}`;
|
|
210
|
+
customFields.push({ entity, label: label.trim(), key, type, options, required });
|
|
211
|
+
p.log.success(`${entity} \xB7 ${label} (${type})`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const port = opts.yes ? 4848 : Number(await ask(p.text({ message: "Port", defaultValue: "4848", placeholder: "4848", validate: (v) => v && Number.isNaN(Number(v)) ? "Number" : void 0 })));
|
|
215
|
+
const config = {
|
|
216
|
+
...api.defaultConfig(),
|
|
217
|
+
name: name.trim(),
|
|
218
|
+
...nameAr?.trim() ? { nameAr: nameAr.trim() } : {},
|
|
219
|
+
slug: name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "project",
|
|
220
|
+
currency,
|
|
221
|
+
locale: locale || "en-US",
|
|
222
|
+
weekStartsOn,
|
|
223
|
+
fiscalYearStartMonth: 1,
|
|
224
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
|
225
|
+
port: port || 4848,
|
|
226
|
+
auth
|
|
227
|
+
};
|
|
228
|
+
const s = p.spinner();
|
|
229
|
+
s.start("Writing .ledgr/config.json and creating the database");
|
|
230
|
+
api.writeConfig(paths, config);
|
|
231
|
+
const ctx = api.loadContext(paths.dir);
|
|
232
|
+
const result = await api.applyInitialSetup(ctx, { stakeholders, expenseCategories, revenueCategories, customFields });
|
|
233
|
+
s.stop(`Created ${pc2.cyan(path3.relative(process.cwd(), paths.dir) || ".ledgr")} \xB7 ${result.stakeholders} stakeholders \xB7 ${result.categories} categories \xB7 ${result.customFields} custom columns`);
|
|
234
|
+
const gi = path3.join(projectDir, ".gitignore");
|
|
235
|
+
const line = ".ledgr/*.db*";
|
|
236
|
+
const existing = fs3.existsSync(gi) ? fs3.readFileSync(gi, "utf8") : "";
|
|
237
|
+
if (!existing.split("\n").some((l) => l.trim() === line)) {
|
|
238
|
+
const addIgnore = opts.yes ? true : await ask(p.confirm({ message: `Add ${pc2.cyan(line)} to .gitignore? (keeps the database out of git; config.json stays)`, initialValue: true }));
|
|
239
|
+
if (addIgnore) fs3.writeFileSync(gi, `${existing.replace(/\n*$/, existing ? "\n" : "")}
|
|
240
|
+
# ledgr database (config.json is safe to commit if it has no secrets)
|
|
241
|
+
${line}
|
|
242
|
+
`);
|
|
243
|
+
}
|
|
244
|
+
if (auth.mode === "supabase") p.log.info(`${pc2.yellow("Note:")} config.json contains the Supabase anon key. It is a public key, but add ${pc2.cyan(".ledgr/")} to .gitignore if you prefer to keep it out of the repo.`);
|
|
245
|
+
if (opts.standalone) {
|
|
246
|
+
p.note(
|
|
247
|
+
[
|
|
248
|
+
`${pc2.cyan("ledgr start --open")} run the dashboard from this folder's own code`,
|
|
249
|
+
`${pc2.cyan("ledgr upgrade")} merge the latest ledgr into your copy`,
|
|
250
|
+
`${pc2.cyan("docker compose up -d")} host it (data stays in ./.ledgr)`,
|
|
251
|
+
`${pc2.cyan("git remote add origin \u2026")} push the folder as your own repository`
|
|
252
|
+
].join("\n"),
|
|
253
|
+
"Standalone project"
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
p.outro(`Done. Run ${pc2.cyan("ledgr start --open")} in ${pc2.cyan(path3.relative(process.cwd(), projectDir) || "this folder")}${auth.mode === "none" ? "" : " and sign in as an admin"}.`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// src/commands/start.ts
|
|
260
|
+
import * as path4 from "path";
|
|
261
|
+
import pc3 from "picocolors";
|
|
262
|
+
import open from "open";
|
|
263
|
+
async function startCommand(opts) {
|
|
264
|
+
const projectDir = path4.resolve(opts.dir);
|
|
265
|
+
if (hasLocalSource(projectDir) && !localSourceBuilt(projectDir)) {
|
|
266
|
+
throw new Error(`This folder carries its own ledgr source but it is not built. Run ${pc3.cyan("pnpm install && pnpm build")} here (or ${pc3.cyan("ledgr upgrade")}).`);
|
|
267
|
+
}
|
|
268
|
+
const api = loadApi(projectDir);
|
|
269
|
+
const dir = path4.join(projectDir, ".ledgr");
|
|
270
|
+
const webDist = opts.apiOnly ? null : resolveWebDist(projectDir);
|
|
271
|
+
if (!opts.apiOnly && !webDist) {
|
|
272
|
+
console.error(pc3.yellow("Dashboard build not found (@atyaio/ledgr-web/dist). Serving the API only. Run `pnpm build` in the ledgr repo, or use --api-only to silence this."));
|
|
273
|
+
}
|
|
274
|
+
let started;
|
|
275
|
+
try {
|
|
276
|
+
started = await api.startApp({ dir, host: opts.host, port: opts.port ? Number(opts.port) : void 0, webDist: webDist ?? null });
|
|
277
|
+
} catch (e) {
|
|
278
|
+
if (e instanceof Error && /EADDRINUSE/.test(e.message)) {
|
|
279
|
+
const port = opts.port ?? "4848";
|
|
280
|
+
throw new Error(`Port ${port} is already in use (another ledgr or app is running there). Stop it, or start on another port: ${pc3.cyan(`ledgr start --port ${Number(port) + 1}`)}`);
|
|
281
|
+
}
|
|
282
|
+
throw e;
|
|
283
|
+
}
|
|
284
|
+
const { url, ctx } = started;
|
|
285
|
+
if (ctx.config.auth.mode === "none" && opts.host !== "127.0.0.1" && opts.host !== "localhost") {
|
|
286
|
+
console.error(pc3.red(`WARNING: auth.mode is "none" and you are binding to ${opts.host}. Anyone who can reach this host can edit the ledger.`));
|
|
287
|
+
}
|
|
288
|
+
console.log(`
|
|
289
|
+
${pc3.bgYellow(pc3.black(" ledgr "))} ${pc3.bold(ctx.config.name)} \u2192 ${pc3.cyan(url)}
|
|
290
|
+
${pc3.dim(`data: ${ctx.paths.dbPath}`)}${hasLocalSource(projectDir) ? pc3.dim("\n code: this folder (standalone)") : ""}
|
|
291
|
+
`);
|
|
292
|
+
if (opts.open) await open(url);
|
|
293
|
+
const stop = () => process.exit(0);
|
|
294
|
+
process.on("SIGINT", stop);
|
|
295
|
+
process.on("SIGTERM", stop);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// src/commands/doctor.ts
|
|
299
|
+
import * as fs4 from "fs";
|
|
300
|
+
import * as path5 from "path";
|
|
301
|
+
import pc4 from "picocolors";
|
|
302
|
+
async function doctorCommand(opts) {
|
|
303
|
+
const projectDir = path5.resolve(opts.dir);
|
|
304
|
+
const standalone = hasLocalSource(projectDir);
|
|
305
|
+
if (standalone && !localSourceBuilt(projectDir)) {
|
|
306
|
+
console.log(`${pc4.red("\u2716")} standalone source present but not built \u2014 run ${pc4.cyan("pnpm install && pnpm build")} here.`);
|
|
307
|
+
process.exit(1);
|
|
308
|
+
}
|
|
309
|
+
const api = loadApi(projectDir);
|
|
310
|
+
const paths = api.resolvePaths(path5.join(projectDir, ".ledgr"));
|
|
311
|
+
const ok = (m) => console.log(`${pc4.green("\u2714")} ${m}`);
|
|
312
|
+
const bad = (m) => console.log(`${pc4.red("\u2716")} ${m}`);
|
|
313
|
+
const warn = (m) => console.log(`${pc4.yellow("\u25B2")} ${m}`);
|
|
314
|
+
let failures = 0;
|
|
315
|
+
if (!fs4.existsSync(paths.configPath)) {
|
|
316
|
+
bad(`No config at ${paths.configPath}. Run ${pc4.cyan("ledgr init")}.`);
|
|
317
|
+
process.exit(1);
|
|
318
|
+
}
|
|
319
|
+
let ctx;
|
|
320
|
+
try {
|
|
321
|
+
ctx = api.loadContext(paths.dir);
|
|
322
|
+
ok(`config.json valid \xB7 ${ctx.config.name} \xB7 ${ctx.config.currency} \xB7 auth ${ctx.config.auth.mode}`);
|
|
323
|
+
} catch (e) {
|
|
324
|
+
bad(`config.json: ${e.message}`);
|
|
325
|
+
process.exit(1);
|
|
326
|
+
}
|
|
327
|
+
if (ctx.config.auth.mode === "none") warn("auth.mode = none \u2014 fine on localhost, not for hosting.");
|
|
328
|
+
try {
|
|
329
|
+
const { applied } = api.runMigrations(paths.dbPath);
|
|
330
|
+
ok(applied.length ? `database migrated (${applied.length} new migration(s))` : "database up to date");
|
|
331
|
+
const counts = await api.dbCounts(ctx);
|
|
332
|
+
ok(`${counts.stakeholders} stakeholders \xB7 ${counts.categories} categories \xB7 ${counts.expenses} expenses \xB7 ${counts.revenue} revenue`);
|
|
333
|
+
if (!counts.stakeholders) warn("no stakeholders yet \u2014 expenses will not be split until you add partners.");
|
|
334
|
+
} catch (e) {
|
|
335
|
+
bad(`database: ${e.message}`);
|
|
336
|
+
failures++;
|
|
337
|
+
}
|
|
338
|
+
if (standalone) ok("standalone project: using the ledgr source in this folder");
|
|
339
|
+
const web = resolveWebDist(projectDir);
|
|
340
|
+
if (web) ok(`dashboard build found at ${web}`);
|
|
341
|
+
else {
|
|
342
|
+
warn("dashboard build missing (@atyaio/ledgr-web/dist) \u2014 `ledgr start` will serve the API only.");
|
|
343
|
+
}
|
|
344
|
+
console.log(failures ? pc4.red(`
|
|
345
|
+
${failures} problem(s).`) : pc4.green("\nAll good."));
|
|
346
|
+
process.exit(failures ? 1 : 0);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// src/commands/seed.ts
|
|
350
|
+
import * as path6 from "path";
|
|
351
|
+
import pc5 from "picocolors";
|
|
352
|
+
async function seedCommand(opts) {
|
|
353
|
+
const projectDir = path6.resolve(opts.dir);
|
|
354
|
+
const api = loadApi(projectDir);
|
|
355
|
+
if (!api.seedDemo) throw new Error("This build of @atyaio/ledgr-api has no demo seeder.");
|
|
356
|
+
const ctx = api.loadContext(path6.join(projectDir, ".ledgr"));
|
|
357
|
+
console.log(pc5.dim(`Seeding ${ctx.config.name} with ${opts.months} months of demo data\u2026`));
|
|
358
|
+
await api.seedDemo(ctx, { months: Number(opts.months) || 12, force: opts.force });
|
|
359
|
+
console.log(pc5.green("Demo data loaded.") + pc5.dim(" Run `ledgr start --open`."));
|
|
360
|
+
process.exit(0);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// src/commands/upgrade.ts
|
|
364
|
+
import * as path7 from "path";
|
|
365
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
366
|
+
import pc6 from "picocolors";
|
|
367
|
+
|
|
368
|
+
// src/package.ts
|
|
369
|
+
import { createRequire as createRequire2 } from "module";
|
|
370
|
+
var require3 = createRequire2(import.meta.url);
|
|
371
|
+
var pkg = require3("../package.json");
|
|
372
|
+
var PACKAGE_NAME = pkg.name;
|
|
373
|
+
var PACKAGE_VERSION = pkg.version;
|
|
374
|
+
function runningFromNpxCache() {
|
|
375
|
+
return /[\\/]_npx[\\/]/.test(new URL(import.meta.url).pathname);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// src/commands/upgrade.ts
|
|
379
|
+
async function upgradeCommand(opts) {
|
|
380
|
+
const projectDir = path7.resolve(opts.dir);
|
|
381
|
+
if (!hasLocalSource(projectDir)) {
|
|
382
|
+
upgradePackage();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
console.log(pc6.dim("Fetching and merging upstream ledgr/main\u2026"));
|
|
386
|
+
const r = upgradeSource(projectDir);
|
|
387
|
+
if (!r.changed) console.log(pc6.green("Already up to date.") + pc6.dim(` (${r.after})`));
|
|
388
|
+
else console.log(pc6.green(`Merged ${r.before} \u2192 ${r.after}.`));
|
|
389
|
+
if (opts.build !== false) {
|
|
390
|
+
console.log(pc6.dim("Installing and building\u2026"));
|
|
391
|
+
installAndBuild(projectDir);
|
|
392
|
+
}
|
|
393
|
+
console.log(pc6.green("Done.") + pc6.dim(" Restart `ledgr start` to pick up the changes."));
|
|
394
|
+
}
|
|
395
|
+
function upgradePackage() {
|
|
396
|
+
const spec = `${PACKAGE_NAME}@latest`;
|
|
397
|
+
if (runningFromNpxCache()) {
|
|
398
|
+
console.log(`${pc6.green("Nothing to do.")} ${pc6.dim(`npx ${PACKAGE_NAME} always runs the latest published version (this one is ${PACKAGE_VERSION}).`)}`);
|
|
399
|
+
console.log(pc6.dim(`For a fixed install on a server: ${pc6.cyan(`npm install -g ${spec}`)}, then ${pc6.cyan("ledgr start")}.`));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
console.log(pc6.dim(`Installed ${PACKAGE_NAME} ${PACKAGE_VERSION}. Running ${pc6.cyan(`npm install -g ${spec}`)}\u2026`));
|
|
403
|
+
run("npm", ["install", "-g", spec], process.cwd());
|
|
404
|
+
const after = versionOf(PACKAGE_NAME);
|
|
405
|
+
console.log(pc6.green(after && after !== PACKAGE_VERSION ? `Upgraded ${PACKAGE_VERSION} \u2192 ${after}.` : `Already up to date (${after ?? PACKAGE_VERSION}).`) + pc6.dim(" Restart `ledgr start` to pick up the changes."));
|
|
406
|
+
}
|
|
407
|
+
function versionOf(name) {
|
|
408
|
+
try {
|
|
409
|
+
const r = spawnSync2("npm", ["ls", "-g", name, "--json", "--depth=0"], { stdio: "pipe" });
|
|
410
|
+
const json = JSON.parse(r.stdout.toString() || "{}");
|
|
411
|
+
return json.dependencies?.[name]?.version;
|
|
412
|
+
} catch {
|
|
413
|
+
return void 0;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// src/commands/backup.ts
|
|
418
|
+
import * as fs5 from "fs";
|
|
419
|
+
import * as path8 from "path";
|
|
420
|
+
import pc7 from "picocolors";
|
|
421
|
+
async function backupCommand(opts) {
|
|
422
|
+
const projectDir = path8.resolve(opts.dir);
|
|
423
|
+
const api = loadApi(projectDir);
|
|
424
|
+
if (!api.backupDatabase) {
|
|
425
|
+
throw new Error("The installed @atyaio/ledgr-api is too old for `ledgr backup`. Upgrade the ledgr package (or run `ledgr upgrade` in a standalone folder).");
|
|
426
|
+
}
|
|
427
|
+
const paths = api.resolvePaths(path8.join(projectDir, ".ledgr"));
|
|
428
|
+
const ctx = api.loadContext(paths.dir);
|
|
429
|
+
const toFile = !!opts.out && opts.out.endsWith(".db");
|
|
430
|
+
const folder = toFile ? path8.dirname(path8.resolve(opts.out)) : path8.resolve(opts.out ?? path8.join(paths.dir, "backups"));
|
|
431
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace(/[-:]/g, "").replace("T", "-");
|
|
432
|
+
const prefix = `${ctx.config.slug}-`;
|
|
433
|
+
const outPath = toFile ? path8.resolve(opts.out) : path8.join(folder, `${prefix}${stamp}.db`);
|
|
434
|
+
const { bytes } = await api.backupDatabase(paths.dbPath, outPath);
|
|
435
|
+
console.log(`${pc7.green("\u2714")} ${outPath} ${pc7.dim(`(${(bytes / 1024 / 1024).toFixed(1)} MB)`)}`);
|
|
436
|
+
const keep = Number(opts.keep);
|
|
437
|
+
if (!toFile && Number.isFinite(keep) && keep > 0) {
|
|
438
|
+
const old = fs5.readdirSync(folder).filter((n) => n.startsWith(prefix) && n.endsWith(".db")).sort().slice(0, -keep);
|
|
439
|
+
for (const n of old) fs5.rmSync(path8.join(folder, n), { force: true });
|
|
440
|
+
if (old.length) console.log(pc7.dim(` pruned ${old.length} older backup(s), keeping ${keep}`));
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// src/index.ts
|
|
445
|
+
var program = new Command();
|
|
446
|
+
program.name("ledgr").description("Self-contained finance dashboard for any project: expenses, revenue, stakeholders, splits, analytics.").version(PACKAGE_VERSION, "-v, --version", `print the installed ${PACKAGE_NAME} version`);
|
|
447
|
+
program.command("init [folder]").description("Set up a project: creates <folder>/.ledgr (config + SQLite). Defaults to the current folder.").option("-d, --dir <dir>", "alias for the folder argument", process.cwd()).option("-y, --yes", "accept defaults without prompting").option("--name <name>", "project name").option("--currency <code>", "ISO currency, e.g. EGP").option("--no-auth", "skip Supabase auth (local only)").option("-s, --standalone", "also clone the ledgr source into the folder so the project can customise it (upgrade later with `ledgr upgrade`)").option("--from <repoOrPath>", "where to clone the source from (default: GitHub atyaio/ledgr; a local checkout path works too)").option("--no-build", "standalone: skip pnpm install && pnpm build").action((folder, opts) => initCommand({ ...opts, dir: folder ?? opts.dir }));
|
|
448
|
+
program.command("upgrade").description(`Update ledgr: shared installs run npm install -g ${PACKAGE_NAME}@latest; standalone projects merge the latest source from GitHub and rebuild`).option("-d, --dir <dir>", "project folder", process.cwd()).option("--no-build", "standalone: skip pnpm install && pnpm build after merging").action(upgradeCommand);
|
|
449
|
+
program.command("start").description("Start the API + dashboard for the project in this folder").option("-d, --dir <dir>", "project folder containing .ledgr", process.cwd()).option("-p, --port <port>", "port (default: config.port or 4848)").option("-H, --host <host>", "bind host", "127.0.0.1").option("--open", "open the dashboard in the browser", false).option("--api-only", "do not serve the dashboard", false).action(startCommand);
|
|
450
|
+
program.command("doctor").description("Check config, database and dashboard build").option("-d, --dir <dir>", "project folder containing .ledgr", process.cwd()).action(doctorCommand);
|
|
451
|
+
program.command("backup").description("Copy the database to a timestamped file; safe while the app is running").option("-d, --dir <dir>", "project folder containing .ledgr", process.cwd()).option("-o, --out <path>", "target folder, or a file name ending in .db (default: .ledgr/backups/)").option("--keep <n>", "keep only the newest n backups in the target folder (0 = keep all)", "14").action(backupCommand);
|
|
452
|
+
program.command("seed").description("Insert demo data (a year of realistic entries) into this project").option("-d, --dir <dir>", "project folder containing .ledgr", process.cwd()).option("--demo", "load the demo dataset", true).option("--months <n>", "months of history", "12").option("--force", "seed even if the ledger already has entries", false).action(seedCommand);
|
|
453
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
454
|
+
console.error(pc8.red(err instanceof Error ? err.message : String(err)));
|
|
455
|
+
process.exit(1);
|
|
456
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atyaio/ledgr",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Self-contained finance dashboard for any project: expenses, revenue, stakeholders, splits, analytics.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ledgr": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=20"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/atyaio/ledgr.git",
|
|
18
|
+
"directory": "packages/cli"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@clack/prompts": "^1.8.1",
|
|
25
|
+
"commander": "^15.0.0",
|
|
26
|
+
"open": "^11.0.4",
|
|
27
|
+
"picocolors": "^1.1.1",
|
|
28
|
+
"@atyaio/ledgr-api": "0.1.1",
|
|
29
|
+
"@atyaio/ledgr-web": "0.1.1",
|
|
30
|
+
"@atyaio/ledgr-shared": "0.1.1"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^24.0.0",
|
|
34
|
+
"tsup": "^9.0.0",
|
|
35
|
+
"typescript": "~5.9.3"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsup",
|
|
39
|
+
"dev": "tsup --watch",
|
|
40
|
+
"typecheck": "tsc --noEmit"
|
|
41
|
+
}
|
|
6
42
|
}
|
package/README.md
DELETED