@uplift-beyond/upgo 0.0.0-stage → 0.4.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 +111 -2
- package/dist/cli.js +668 -0
- package/dist/server.js +267 -0
- package/dist/src/core/check.js +60 -0
- package/dist/src/core/import.js +515 -0
- package/dist/src/core/mutate.js +164 -0
- package/dist/src/core/ops.js +69 -0
- package/dist/src/core/registry.js +92 -0
- package/dist/src/core/render.js +40 -0
- package/dist/src/core/rules.js +35 -0
- package/dist/src/core/schema.js +68 -0
- package/dist/src/core/store.js +122 -0
- package/dist/src/core/suggestions.js +60 -0
- package/dist/src/core/summary.js +27 -0
- package/dist/src/core/util.js +36 -0
- package/dist/src/core/vocab.js +14 -0
- package/package.json +65 -4
- package/skill/SKILL.md +33 -0
- package/web/dist/assets/index-DqysoOG2.css +1 -0
- package/web/dist/assets/index-uUyiGXev.js +17 -0
- package/web/dist/fonts/OFL.txt +93 -0
- package/web/dist/fonts/outfit-variable.woff2 +0 -0
- package/web/dist/index.html +14 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
5
|
+
import fsp from "node:fs/promises";
|
|
6
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
|
+
import { checkProject } from "./src/core/check.js";
|
|
9
|
+
import * as m from "./src/core/mutate.js";
|
|
10
|
+
import { checkRegistrable, findEntry, forget, readRegistry, register } from "./src/core/registry.js";
|
|
11
|
+
import { isManaged, isOpen, PHASE_LABEL, PHASES, POINTER_KEYS, SIZES, STATUSES } from "./src/core/schema.js";
|
|
12
|
+
import { findProjectDir, GOALS, MD, readGoals, saveGoals, serialize, updateGoals, writeAtomic } from "./src/core/store.js";
|
|
13
|
+
import { addSuggestion, readSuggestions, removeSuggestions, SUGGESTIONS, updateSuggestions } from "./src/core/suggestions.js";
|
|
14
|
+
import { slugify, today, UpgoError } from "./src/core/util.js";
|
|
15
|
+
const CLI = fileURLToPath(import.meta.url).replace(/\\/g, "/");
|
|
16
|
+
const HOOK_MARK = "upgo";
|
|
17
|
+
// A shebang naming a POSIX-ish shell, directly or through env.
|
|
18
|
+
const SHELL = /^#!\s*(?:\S*\/)?(?:env\s+(?:-\S+\s+)*)?(?:\S*\/)?(?:sh|bash|dash|zsh|ksh)(?:\s|$)/;
|
|
19
|
+
const HELP = `upgo: structured goals for this project. Never edit goals.json or goals.md by hand.
|
|
20
|
+
|
|
21
|
+
Reading
|
|
22
|
+
upgo list [--phase now,next] [--status done] [--all] [--section N] [--search text]
|
|
23
|
+
upgo show <id | project:id>
|
|
24
|
+
upgo projects registered projects with open counts
|
|
25
|
+
|
|
26
|
+
Writing (each command validates and regenerates goals.md)
|
|
27
|
+
upgo add "<title>" --section N [--phase later] [--status planned] [--size S|M|L] [--scope a,b]
|
|
28
|
+
[--body text] [--id slug] [--detail path] [--related project:id,...] [--note text]
|
|
29
|
+
[--files x --files y] [--seam ..] [--depends ..] [--trap ..] [--how ..] [--check ..] [--options ..] [--where ..]
|
|
30
|
+
upgo edit <id> [same fields as add] [--clear body,size,scope,pointers,detail,related,note]
|
|
31
|
+
upgo done <id> [--note text] upgo drop <id> --reason text
|
|
32
|
+
upgo reopen <id> upgo delete <id>
|
|
33
|
+
upgo move <id> [--section N] [--before id | --after id | --up | --down]
|
|
34
|
+
upgo section list | add "<title>" [--n N] | rename N "<title>" | move N [--before M] | delete N
|
|
35
|
+
upgo trap list | add "<title>" [--body text] [--id slug] | edit <id> [--title t] [--body text] | delete <id>
|
|
36
|
+
upgo purpose ["<text>" | --clear]
|
|
37
|
+
upgo suggestion list | add "<title>" [--body text] | delete <id...> ideas for the user, only when asked
|
|
38
|
+
upgo suggestion promote <id> --section N [--phase later] turn a suggestion into a goal and remove it
|
|
39
|
+
|
|
40
|
+
Setup and checks
|
|
41
|
+
upgo init [--id slug] [--purpose text] [--no-hooks] register this folder, install git + Claude hooks
|
|
42
|
+
upgo check [--all] validate (schema, rules, related, detail files, suggestions.json, goals.md in sync)
|
|
43
|
+
upgo render rewrite goals.md from goals.json
|
|
44
|
+
upgo import <goals.md> [--completed path] [--out file] [--id slug] [--force]
|
|
45
|
+
upgo forget <id> drop a project from the registry
|
|
46
|
+
upgo ui [--port 4141] [--no-open] start the dashboard
|
|
47
|
+
|
|
48
|
+
Every command takes --dir <path> or --project <id> to target another project.
|
|
49
|
+
Phases: ${PHASES.join(", ")}. Statuses: ${STATUSES.join(", ")}.
|
|
50
|
+
`;
|
|
51
|
+
const TARGET = { dir: { type: "string" }, project: { type: "string" } };
|
|
52
|
+
const POINTER_OPTS = Object.fromEntries(POINTER_KEYS.map((k) => [k, { type: "string", multiple: true }]));
|
|
53
|
+
const FIELDS = {
|
|
54
|
+
...TARGET,
|
|
55
|
+
...POINTER_OPTS,
|
|
56
|
+
title: { type: "string" },
|
|
57
|
+
body: { type: "string" },
|
|
58
|
+
phase: { type: "string" },
|
|
59
|
+
status: { type: "string" },
|
|
60
|
+
size: { type: "string" },
|
|
61
|
+
scope: { type: "string" },
|
|
62
|
+
section: { type: "string" },
|
|
63
|
+
detail: { type: "string" },
|
|
64
|
+
related: { type: "string" },
|
|
65
|
+
note: { type: "string" },
|
|
66
|
+
id: { type: "string" },
|
|
67
|
+
};
|
|
68
|
+
function parse(args, options) {
|
|
69
|
+
try {
|
|
70
|
+
return parseArgs({ args, options, allowPositionals: true, strict: true });
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
throw new UpgoError(`${e.message} (see \`upgo help\`)`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function oneOf(name, value, allowed) {
|
|
77
|
+
if (value === undefined)
|
|
78
|
+
return undefined;
|
|
79
|
+
if (!allowed.includes(value))
|
|
80
|
+
throw new UpgoError(`--${name} must be one of: ${allowed.join(", ")}`);
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
function int(name, value) {
|
|
84
|
+
if (value === undefined)
|
|
85
|
+
return undefined;
|
|
86
|
+
if (!/^\d+$/.test(value))
|
|
87
|
+
throw new UpgoError(`--${name} must be a whole number`);
|
|
88
|
+
return Number(value);
|
|
89
|
+
}
|
|
90
|
+
const csv = (value) => value?.split(",").map((v) => v.trim()).filter(Boolean);
|
|
91
|
+
// Values only, so searching for a field name like `status` does not match every item.
|
|
92
|
+
const valuesText = (v) => (v && typeof v === "object" ? Object.values(v).map(valuesText).join("\n") : String(v));
|
|
93
|
+
function need(value, what) {
|
|
94
|
+
if (value === undefined || value === "")
|
|
95
|
+
throw new UpgoError(`missing ${what} (see \`upgo help\`)`);
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
function projectDir(values) {
|
|
99
|
+
if (values.project) {
|
|
100
|
+
const entry = findEntry(values.project);
|
|
101
|
+
if (!entry)
|
|
102
|
+
throw new UpgoError(`no registered project \`${values.project}\` (see \`upgo projects\`)`);
|
|
103
|
+
return entry.path;
|
|
104
|
+
}
|
|
105
|
+
const start = resolve(values.dir ?? process.cwd());
|
|
106
|
+
const dir = findProjectDir(start);
|
|
107
|
+
if (!dir)
|
|
108
|
+
throw new UpgoError(`no goals.json in ${start} or above (run \`upgo init\` in the project folder)`);
|
|
109
|
+
return dir;
|
|
110
|
+
}
|
|
111
|
+
function pointersFrom(values) {
|
|
112
|
+
const entries = POINTER_KEYS.flatMap((k) => (Array.isArray(values[k]) ? [[k, values[k]]] : []));
|
|
113
|
+
return entries.length ? Object.fromEntries(entries) : undefined;
|
|
114
|
+
}
|
|
115
|
+
function fieldsFrom(values) {
|
|
116
|
+
const str = (k) => values[k];
|
|
117
|
+
return {
|
|
118
|
+
title: str("title"),
|
|
119
|
+
body: str("body"),
|
|
120
|
+
phase: oneOf("phase", str("phase"), PHASES),
|
|
121
|
+
status: oneOf("status", str("status"), STATUSES),
|
|
122
|
+
size: oneOf("size", str("size"), SIZES),
|
|
123
|
+
scope: csv(str("scope")),
|
|
124
|
+
section: int("section", str("section")),
|
|
125
|
+
detail: str("detail"),
|
|
126
|
+
related: csv(str("related")),
|
|
127
|
+
note: str("note"),
|
|
128
|
+
pointers: pointersFrom(values),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function itemText(item, file) {
|
|
132
|
+
const section = file.sections.find((s) => s.n === item.section);
|
|
133
|
+
const meta = [
|
|
134
|
+
`§${item.section} ${section?.title ?? ""}`.trim(),
|
|
135
|
+
PHASE_LABEL[item.phase],
|
|
136
|
+
item.status,
|
|
137
|
+
item.size,
|
|
138
|
+
item.scope?.join(", "),
|
|
139
|
+
].filter(Boolean);
|
|
140
|
+
const lines = [`\`${item.id}\` ${item.title}`, meta.join(" · ")];
|
|
141
|
+
if (item.body?.trim())
|
|
142
|
+
lines.push("", item.body.trim());
|
|
143
|
+
const pointers = POINTER_KEYS.filter((k) => item.pointers?.[k]?.length);
|
|
144
|
+
if (pointers.length)
|
|
145
|
+
lines.push("", ...pointers.flatMap((k) => item.pointers[k].map((v) => `${k}: ${v}`)));
|
|
146
|
+
if (item.detail)
|
|
147
|
+
lines.push(`detail: ${item.detail}`);
|
|
148
|
+
if (item.related?.length)
|
|
149
|
+
lines.push(`related: ${item.related.join(", ")}`);
|
|
150
|
+
if (item.note?.trim())
|
|
151
|
+
lines.push(`note: ${item.note.trim()}`);
|
|
152
|
+
lines.push(`created ${item.created}, updated ${item.updated}`);
|
|
153
|
+
return lines.join("\n") + "\n";
|
|
154
|
+
}
|
|
155
|
+
function listText(file, items) {
|
|
156
|
+
if (!items.length)
|
|
157
|
+
return "no matching items\n";
|
|
158
|
+
const out = [];
|
|
159
|
+
for (const s of file.sections) {
|
|
160
|
+
const mine = items.filter((i) => i.section === s.n);
|
|
161
|
+
if (!mine.length)
|
|
162
|
+
continue;
|
|
163
|
+
out.push(`## ${s.n}. ${s.title}`);
|
|
164
|
+
for (const i of mine) {
|
|
165
|
+
const tags = [PHASE_LABEL[i.phase], i.status === "planned" ? "" : i.status].filter(Boolean).join(", ");
|
|
166
|
+
out.push(`- \`${i.id}\` ${i.title} (${tags})`);
|
|
167
|
+
}
|
|
168
|
+
out.push("");
|
|
169
|
+
}
|
|
170
|
+
out.push(`${items.length} item${items.length === 1 ? "" : "s"}`);
|
|
171
|
+
return out.join("\n") + "\n";
|
|
172
|
+
}
|
|
173
|
+
async function installGitHook(dir) {
|
|
174
|
+
const git = (...args) => execFileSync("git", args, { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
175
|
+
let hooks, top;
|
|
176
|
+
try {
|
|
177
|
+
hooks = git("rev-parse", "--git-path", "hooks");
|
|
178
|
+
top = git("rev-parse", "--show-toplevel");
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return "git hook: skipped (not a git repo)";
|
|
182
|
+
}
|
|
183
|
+
const hooksDir = resolve(dir, hooks);
|
|
184
|
+
const path = join(hooksDir, "pre-commit");
|
|
185
|
+
// Hooks run from the repo root, so a relative dir survives the repo moving.
|
|
186
|
+
const rel = relative(resolve(top), dir).replace(/\\/g, "/") || ".";
|
|
187
|
+
const line = `node "${CLI}" check --dir "${rel}" || exit 1`;
|
|
188
|
+
const block = `# ${HOOK_MARK}: refuse a commit with an invalid goals.json\n${line}\n`;
|
|
189
|
+
const existing = existsSync(path) ? readFileSync(path, "utf8") : null;
|
|
190
|
+
// Per project dir, so a second project in the same repo gets its own check.
|
|
191
|
+
if (existing?.includes(line))
|
|
192
|
+
return `git hook: already installed (${path})`;
|
|
193
|
+
// Same check through an older CLI path (upgo moved or reinstalled): repoint it.
|
|
194
|
+
const stale = existing?.split("\n").find((l) => l.startsWith('node "') && l.endsWith(`" check --dir "${rel}" || exit 1`));
|
|
195
|
+
let next;
|
|
196
|
+
if (stale)
|
|
197
|
+
next = existing.replace(stale, () => line);
|
|
198
|
+
else if (existing === null)
|
|
199
|
+
next = `#!/bin/sh\n${block}`;
|
|
200
|
+
else if (existing.startsWith("#!")) {
|
|
201
|
+
const nl = existing.indexOf("\n");
|
|
202
|
+
if (!SHELL.test(nl < 0 ? existing : existing.slice(0, nl)))
|
|
203
|
+
return `git hook: skipped (${path} is not a shell script; make it run \`${line}\`)`;
|
|
204
|
+
next = nl < 0 ? `${existing}\n${block}` : `${existing.slice(0, nl + 1)}${block}${existing.slice(nl + 1)}`;
|
|
205
|
+
}
|
|
206
|
+
else
|
|
207
|
+
next = `#!/bin/sh\n${block}${existing}`;
|
|
208
|
+
await fsp.mkdir(hooksDir, { recursive: true });
|
|
209
|
+
await fsp.writeFile(path, next, { mode: 0o755 });
|
|
210
|
+
return `git hook: ${stale ? "updated" : "installed"} (${path})`;
|
|
211
|
+
}
|
|
212
|
+
// Suggestions are scratch for the user, not project history.
|
|
213
|
+
async function ignoreSuggestions(dir) {
|
|
214
|
+
try {
|
|
215
|
+
// --no-index: a tracked file still counts as ignored once the pattern is in, so a re-init adds no duplicate.
|
|
216
|
+
execFileSync("git", ["check-ignore", "-q", "--no-index", SUGGESTIONS], { cwd: dir, stdio: "ignore" });
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
catch (e) {
|
|
220
|
+
// 1: not ignored yet. Anything else: not a git repo.
|
|
221
|
+
if (e.status !== 1)
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
const path = join(dir, ".gitignore");
|
|
225
|
+
const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
226
|
+
await fsp.writeFile(path, `${existing}${existing && !existing.endsWith("\n") ? "\n" : ""}/${SUGGESTIONS}\n`);
|
|
227
|
+
return `.gitignore: added /${SUGGESTIONS}`;
|
|
228
|
+
}
|
|
229
|
+
async function installClaudeHook(dir) {
|
|
230
|
+
const path = join(dir, ".claude", "settings.json");
|
|
231
|
+
let settings = {};
|
|
232
|
+
if (existsSync(path)) {
|
|
233
|
+
try {
|
|
234
|
+
settings = JSON.parse(readFileSync(path, "utf8"));
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
throw new UpgoError(`${path} is not valid JSON; fix it, then run \`upgo init\` again`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const hooks = (settings.hooks ??= {});
|
|
241
|
+
const pre = (hooks.PreToolUse ??= []);
|
|
242
|
+
const all = pre.flatMap((e) => e.hooks ?? []);
|
|
243
|
+
if (all.some((h) => h.command?.includes(`${CLI}" hook`)))
|
|
244
|
+
return `Claude hook: already installed (${path})`;
|
|
245
|
+
const command = `node "${CLI}" hook`;
|
|
246
|
+
// Older CLI path (upgo moved or reinstalled): repoint it.
|
|
247
|
+
const stale = all.find((h) => /^node "[^"]*\/cli\.[jt]s" hook$/.test(h.command ?? ""));
|
|
248
|
+
if (stale)
|
|
249
|
+
stale.command = command;
|
|
250
|
+
else
|
|
251
|
+
pre.push({ matcher: "Edit|Write|MultiEdit|NotebookEdit", hooks: [{ type: "command", command }] });
|
|
252
|
+
await fsp.mkdir(dirname(path), { recursive: true });
|
|
253
|
+
await writeAtomic(path, JSON.stringify(settings, null, 2) + "\n");
|
|
254
|
+
return `Claude hook: ${stale ? "updated" : "installed"} (${path})`;
|
|
255
|
+
}
|
|
256
|
+
// PreToolUse hook: blocks an agent's direct Edit/Write of an upgo-managed goals.json or goals.md.
|
|
257
|
+
async function hook(io) {
|
|
258
|
+
let input;
|
|
259
|
+
try {
|
|
260
|
+
input = JSON.parse(await io.stdin());
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return 0;
|
|
264
|
+
}
|
|
265
|
+
const raw = input.tool_input?.file_path ?? input.tool_input?.notebook_path;
|
|
266
|
+
if (typeof raw !== "string")
|
|
267
|
+
return 0;
|
|
268
|
+
const path = resolve(input.cwd ?? process.cwd(), raw);
|
|
269
|
+
const name = basename(path).toLowerCase();
|
|
270
|
+
const goals = join(dirname(path), GOALS);
|
|
271
|
+
// Managed means upgo wrote that goals.json; another tool's file of the same name is not ours to guard.
|
|
272
|
+
if ((name === GOALS || name === MD || name === SUGGESTIONS) && existsSync(goals) && isManaged(readFileSync(goals, "utf8"))) {
|
|
273
|
+
io.err(`${name} is managed by upgo and must not be edited directly. Use the CLI: \`upgo add|edit|done|drop|move|suggestion ...\` (\`upgo help\`).\n`);
|
|
274
|
+
return 2;
|
|
275
|
+
}
|
|
276
|
+
return 0;
|
|
277
|
+
}
|
|
278
|
+
async function run(argv, io) {
|
|
279
|
+
const [cmd = "help", ...rest] = argv;
|
|
280
|
+
const day = today();
|
|
281
|
+
switch (cmd) {
|
|
282
|
+
case "help":
|
|
283
|
+
case "--help":
|
|
284
|
+
case "-h":
|
|
285
|
+
io.out(HELP);
|
|
286
|
+
return 0;
|
|
287
|
+
case "hook":
|
|
288
|
+
return hook(io);
|
|
289
|
+
case "init": {
|
|
290
|
+
const { values } = parse(rest, { dir: { type: "string" }, id: { type: "string" }, purpose: { type: "string" }, "no-hooks": { type: "boolean" } });
|
|
291
|
+
const dir = resolve(values.dir ?? process.cwd());
|
|
292
|
+
let file;
|
|
293
|
+
if (existsSync(join(dir, GOALS))) {
|
|
294
|
+
file = readGoals(dir);
|
|
295
|
+
if (values.id && values.id !== file.project)
|
|
296
|
+
throw new UpgoError(`goals.json already names this project \`${file.project}\``);
|
|
297
|
+
if (values.purpose)
|
|
298
|
+
file = await updateGoals(dir, (f) => m.setPurpose(f, values.purpose));
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
const id = values.id ?? slugify(basename(dir));
|
|
302
|
+
// Before anything is written, or a taken id leaves a goals.json that blocks the retry with --id.
|
|
303
|
+
checkRegistrable(id, dir);
|
|
304
|
+
file = await saveGoals(dir, { version: 1, project: id, ...(values.purpose ? { purpose: values.purpose } : {}), sections: [], traps: [], items: [] });
|
|
305
|
+
}
|
|
306
|
+
await register(file.project, dir);
|
|
307
|
+
const lines = [`registered \`${file.project}\` at ${dir}`];
|
|
308
|
+
if (!values["no-hooks"])
|
|
309
|
+
lines.push(await installGitHook(dir), await installClaudeHook(dir));
|
|
310
|
+
const ignored = await ignoreSuggestions(dir);
|
|
311
|
+
if (ignored)
|
|
312
|
+
lines.push(ignored);
|
|
313
|
+
io.out(lines.join("\n") + "\n");
|
|
314
|
+
return 0;
|
|
315
|
+
}
|
|
316
|
+
case "list": {
|
|
317
|
+
const { values } = parse(rest, { ...TARGET, phase: { type: "string" }, status: { type: "string" }, section: { type: "string" }, search: { type: "string" }, all: { type: "boolean" } });
|
|
318
|
+
const file = readGoals(projectDir(values));
|
|
319
|
+
const phases = csv(values.phase)?.map((p) => oneOf("phase", p, PHASES));
|
|
320
|
+
const statuses = csv(values.status)?.map((s) => oneOf("status", s, STATUSES));
|
|
321
|
+
const section = int("section", values.section);
|
|
322
|
+
const q = values.search?.toLowerCase();
|
|
323
|
+
const items = file.items.filter((i) => (statuses ? statuses.includes(i.status) : values.all || isOpen(i)) &&
|
|
324
|
+
(!phases || phases.includes(i.phase)) &&
|
|
325
|
+
(section === undefined || i.section === section) &&
|
|
326
|
+
(!q || valuesText(i).toLowerCase().includes(q)));
|
|
327
|
+
io.out(listText(file, items));
|
|
328
|
+
return 0;
|
|
329
|
+
}
|
|
330
|
+
case "show": {
|
|
331
|
+
const { values, positionals } = parse(rest, TARGET);
|
|
332
|
+
const target = need(positionals[0], "item id");
|
|
333
|
+
const [project, id] = target.includes(":") ? target.split(":") : [undefined, target];
|
|
334
|
+
const file = readGoals(projectDir(project ? { project } : values));
|
|
335
|
+
const item = file.items.find((i) => i.id === id);
|
|
336
|
+
const trap = file.traps.find((t) => t.id === id);
|
|
337
|
+
if (item)
|
|
338
|
+
io.out(itemText(item, file));
|
|
339
|
+
else if (trap)
|
|
340
|
+
io.out(`trap \`${trap.id}\` ${trap.title}\n${trap.body ? `\n${trap.body.trim()}\n` : ""}`);
|
|
341
|
+
else
|
|
342
|
+
throw new UpgoError(`no item \`${id}\` in ${file.project}`);
|
|
343
|
+
return 0;
|
|
344
|
+
}
|
|
345
|
+
case "projects": {
|
|
346
|
+
const entries = readRegistry();
|
|
347
|
+
if (!entries.length)
|
|
348
|
+
io.out("no projects yet, run `upgo init` in a project folder\n");
|
|
349
|
+
for (const e of entries) {
|
|
350
|
+
try {
|
|
351
|
+
const f = readGoals(e.path);
|
|
352
|
+
io.out(`${e.id} ${f.items.filter(isOpen).length} open ${e.path}\n`);
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
io.out(`${e.id} unreadable: ${err.message.split("\n")[0]} ${e.path}\n`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return 0;
|
|
359
|
+
}
|
|
360
|
+
case "add": {
|
|
361
|
+
const { values, positionals } = parse(rest, FIELDS);
|
|
362
|
+
const f = fieldsFrom(values);
|
|
363
|
+
const title = need(positionals[0] ?? f.title, "title");
|
|
364
|
+
const section = need(f.section, "--section");
|
|
365
|
+
const dir = projectDir(values);
|
|
366
|
+
let added = "";
|
|
367
|
+
await updateGoals(dir, (file) => {
|
|
368
|
+
const { pointers, scope, related, ...plain } = f;
|
|
369
|
+
const r = m.addItem(file, {
|
|
370
|
+
...Object.fromEntries(Object.entries(plain).filter(([, v]) => v !== undefined)),
|
|
371
|
+
title,
|
|
372
|
+
section,
|
|
373
|
+
phase: f.phase ?? "later",
|
|
374
|
+
...(pointers ? { pointers } : {}),
|
|
375
|
+
...(scope?.length ? { scope } : {}),
|
|
376
|
+
...(related?.length ? { related } : {}),
|
|
377
|
+
...(values.id ? { id: values.id } : {}),
|
|
378
|
+
}, day);
|
|
379
|
+
added = r.id;
|
|
380
|
+
return r.file;
|
|
381
|
+
});
|
|
382
|
+
io.out(`added ${added}\n`);
|
|
383
|
+
return 0;
|
|
384
|
+
}
|
|
385
|
+
case "edit": {
|
|
386
|
+
const { values, positionals } = parse(rest, { ...FIELDS, clear: { type: "string" } });
|
|
387
|
+
const id = need(positionals[0], "item id");
|
|
388
|
+
if (values.id)
|
|
389
|
+
throw new UpgoError("ids cannot be changed");
|
|
390
|
+
const f = fieldsFrom(values);
|
|
391
|
+
const clearable = ["body", "size", "scope", "pointers", "detail", "related", "note"];
|
|
392
|
+
const clear = (csv(values.clear) ?? []).map((c) => oneOf("clear", c, clearable));
|
|
393
|
+
await updateGoals(projectDir(values), (file) => {
|
|
394
|
+
const current = file.items.find((i) => i.id === id);
|
|
395
|
+
const patch = Object.fromEntries(Object.entries(f).filter(([, v]) => v !== undefined));
|
|
396
|
+
if (f.pointers && current)
|
|
397
|
+
patch.pointers = { ...current.pointers, ...f.pointers };
|
|
398
|
+
for (const c of clear)
|
|
399
|
+
patch[c] = null;
|
|
400
|
+
if (!Object.keys(patch).length)
|
|
401
|
+
throw new UpgoError("nothing to change");
|
|
402
|
+
return m.editItem(file, id, patch, day);
|
|
403
|
+
});
|
|
404
|
+
io.out(`edited ${id}\n`);
|
|
405
|
+
return 0;
|
|
406
|
+
}
|
|
407
|
+
case "done":
|
|
408
|
+
case "drop":
|
|
409
|
+
case "reopen": {
|
|
410
|
+
const { values, positionals } = parse(rest, { ...TARGET, note: { type: "string" }, reason: { type: "string" } });
|
|
411
|
+
const id = need(positionals[0], "item id");
|
|
412
|
+
const status = cmd === "done" ? "done" : cmd === "drop" ? "dropped" : "planned";
|
|
413
|
+
const note = cmd === "drop" ? need(values.reason, "--reason") : values.note;
|
|
414
|
+
await updateGoals(projectDir(values), (file) => m.setStatus(file, id, status, note, day));
|
|
415
|
+
io.out(`${cmd === "reopen" ? "reopened" : status} ${id}\n`);
|
|
416
|
+
return 0;
|
|
417
|
+
}
|
|
418
|
+
case "delete": {
|
|
419
|
+
const { values, positionals } = parse(rest, TARGET);
|
|
420
|
+
const id = need(positionals[0], "item id");
|
|
421
|
+
await updateGoals(projectDir(values), (file) => m.deleteItem(file, id));
|
|
422
|
+
io.out(`deleted ${id}\n`);
|
|
423
|
+
return 0;
|
|
424
|
+
}
|
|
425
|
+
case "move": {
|
|
426
|
+
const { values, positionals } = parse(rest, { ...TARGET, section: { type: "string" }, before: { type: "string" }, after: { type: "string" }, up: { type: "boolean" }, down: { type: "boolean" } });
|
|
427
|
+
const id = need(positionals[0], "item id");
|
|
428
|
+
const section = int("section", values.section);
|
|
429
|
+
if (values.up || values.down) {
|
|
430
|
+
if (values.up && values.down)
|
|
431
|
+
throw new UpgoError("give either --up or --down");
|
|
432
|
+
await updateGoals(projectDir(values), (file) => m.nudgeItem(file, id, values.up ? "up" : "down"));
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
if (section === undefined && !values.before && !values.after)
|
|
436
|
+
throw new UpgoError("give --section, --before, --after, --up or --down");
|
|
437
|
+
await updateGoals(projectDir(values), (file) => m.moveItem(file, id, { section, before: values.before, after: values.after }, day));
|
|
438
|
+
}
|
|
439
|
+
io.out(`moved ${id}\n`);
|
|
440
|
+
return 0;
|
|
441
|
+
}
|
|
442
|
+
case "section": {
|
|
443
|
+
const [sub, ...args] = rest;
|
|
444
|
+
const { values, positionals } = parse(args, { ...TARGET, n: { type: "string" }, before: { type: "string" } });
|
|
445
|
+
const dir = projectDir(values);
|
|
446
|
+
const num = () => need(int("section number", positionals[0]), "section number");
|
|
447
|
+
if (sub === "list") {
|
|
448
|
+
const file = readGoals(dir);
|
|
449
|
+
for (const s of file.sections)
|
|
450
|
+
io.out(`${s.n}. ${s.title} (${file.items.filter((i) => i.section === s.n && isOpen(i)).length} open)\n`);
|
|
451
|
+
}
|
|
452
|
+
else if (sub === "add") {
|
|
453
|
+
let n = 0;
|
|
454
|
+
await updateGoals(dir, (file) => {
|
|
455
|
+
const r = m.addSection(file, need(positionals[0], "section title"), int("n", values.n));
|
|
456
|
+
n = r.n;
|
|
457
|
+
return r.file;
|
|
458
|
+
});
|
|
459
|
+
io.out(`added section ${n}\n`);
|
|
460
|
+
}
|
|
461
|
+
else if (sub === "rename") {
|
|
462
|
+
await updateGoals(dir, (file) => m.renameSection(file, num(), need(positionals[1], "section title")));
|
|
463
|
+
io.out(`renamed section ${positionals[0]}\n`);
|
|
464
|
+
}
|
|
465
|
+
else if (sub === "move") {
|
|
466
|
+
await updateGoals(dir, (file) => m.moveSection(file, num(), int("before", values.before) ?? null));
|
|
467
|
+
io.out(`moved section ${positionals[0]}\n`);
|
|
468
|
+
}
|
|
469
|
+
else if (sub === "delete") {
|
|
470
|
+
await updateGoals(dir, (file) => m.deleteSection(file, num()));
|
|
471
|
+
io.out(`deleted section ${positionals[0]}\n`);
|
|
472
|
+
}
|
|
473
|
+
else
|
|
474
|
+
throw new UpgoError("usage: upgo section list | add | rename | move | delete");
|
|
475
|
+
return 0;
|
|
476
|
+
}
|
|
477
|
+
case "trap": {
|
|
478
|
+
const [sub, ...args] = rest;
|
|
479
|
+
const { values, positionals } = parse(args, { ...TARGET, title: { type: "string" }, body: { type: "string" }, id: { type: "string" } });
|
|
480
|
+
const dir = projectDir(values);
|
|
481
|
+
if (sub === "list") {
|
|
482
|
+
for (const t of readGoals(dir).traps)
|
|
483
|
+
io.out(`\`${t.id}\` ${t.title}\n`);
|
|
484
|
+
}
|
|
485
|
+
else if (sub === "add") {
|
|
486
|
+
let id = "";
|
|
487
|
+
await updateGoals(dir, (file) => {
|
|
488
|
+
const r = m.addTrap(file, { title: need(positionals[0] ?? values.title, "trap title"), ...(values.body ? { body: values.body } : {}), ...(values.id ? { id: values.id } : {}) });
|
|
489
|
+
id = r.id;
|
|
490
|
+
return r.file;
|
|
491
|
+
});
|
|
492
|
+
io.out(`added trap ${id}\n`);
|
|
493
|
+
}
|
|
494
|
+
else if (sub === "edit") {
|
|
495
|
+
const id = need(positionals[0], "trap id");
|
|
496
|
+
await updateGoals(dir, (file) => m.editTrap(file, id, { title: values.title, body: values.body === "" ? null : values.body }));
|
|
497
|
+
io.out(`edited trap ${id}\n`);
|
|
498
|
+
}
|
|
499
|
+
else if (sub === "delete") {
|
|
500
|
+
const id = need(positionals[0], "trap id");
|
|
501
|
+
await updateGoals(dir, (file) => m.deleteTrap(file, id));
|
|
502
|
+
io.out(`deleted trap ${id}\n`);
|
|
503
|
+
}
|
|
504
|
+
else
|
|
505
|
+
throw new UpgoError("usage: upgo trap list | add | edit | delete");
|
|
506
|
+
return 0;
|
|
507
|
+
}
|
|
508
|
+
case "suggestion": {
|
|
509
|
+
const [sub, ...args] = rest;
|
|
510
|
+
const { values, positionals } = parse(args, { ...TARGET, body: { type: "string" }, section: { type: "string" }, phase: { type: "string" } });
|
|
511
|
+
const dir = projectDir(values);
|
|
512
|
+
if (sub === "promote") {
|
|
513
|
+
const sid = need(positionals[0], "suggestion id");
|
|
514
|
+
const section = need(int("section", values.section), "--section");
|
|
515
|
+
const phase = oneOf("phase", values.phase, PHASES) ?? "later";
|
|
516
|
+
const s = readSuggestions(dir).find((x) => x.id === sid);
|
|
517
|
+
if (!s)
|
|
518
|
+
throw new UpgoError(`no suggestion \`${sid}\``);
|
|
519
|
+
let added = "";
|
|
520
|
+
await updateGoals(dir, (file) => {
|
|
521
|
+
const r = m.addItem(file, { title: s.title, section, phase, ...(s.body ? { body: s.body } : {}) }, day);
|
|
522
|
+
added = r.id;
|
|
523
|
+
return r.file;
|
|
524
|
+
});
|
|
525
|
+
// The goal is saved: one dismissed meanwhile must not turn this into an error.
|
|
526
|
+
await updateSuggestions(dir, (list) => list.filter((x) => x.id !== sid));
|
|
527
|
+
io.out(`promoted ${sid} to ${added}\n`);
|
|
528
|
+
}
|
|
529
|
+
else if (sub === "list") {
|
|
530
|
+
const list = readSuggestions(dir);
|
|
531
|
+
if (!list.length)
|
|
532
|
+
io.out("no suggestions\n");
|
|
533
|
+
for (const s of list)
|
|
534
|
+
io.out(`\`${s.id}\` ${s.title}\n`);
|
|
535
|
+
}
|
|
536
|
+
else if (sub === "add") {
|
|
537
|
+
const title = need(positionals[0], "suggestion title");
|
|
538
|
+
let id = "";
|
|
539
|
+
await updateSuggestions(dir, (list) => {
|
|
540
|
+
const r = addSuggestion(list, { title, body: values.body }, day);
|
|
541
|
+
id = r.id;
|
|
542
|
+
return r.list;
|
|
543
|
+
});
|
|
544
|
+
io.out(`suggested ${id}\n`);
|
|
545
|
+
}
|
|
546
|
+
else if (sub === "delete") {
|
|
547
|
+
need(positionals[0], "suggestion id");
|
|
548
|
+
await updateSuggestions(dir, (list) => removeSuggestions(list, positionals));
|
|
549
|
+
io.out(`deleted ${positionals.join(", ")}\n`);
|
|
550
|
+
}
|
|
551
|
+
else
|
|
552
|
+
throw new UpgoError("usage: upgo suggestion list | add | delete | promote");
|
|
553
|
+
return 0;
|
|
554
|
+
}
|
|
555
|
+
case "purpose": {
|
|
556
|
+
const { values, positionals } = parse(rest, { ...TARGET, clear: { type: "boolean" } });
|
|
557
|
+
const text = values.clear ? null : need(positionals[0], "purpose text");
|
|
558
|
+
await updateGoals(projectDir(values), (file) => m.setPurpose(file, text));
|
|
559
|
+
io.out(text ? "purpose set\n" : "purpose cleared\n");
|
|
560
|
+
return 0;
|
|
561
|
+
}
|
|
562
|
+
case "check": {
|
|
563
|
+
const { values } = parse(rest, { ...TARGET, all: { type: "boolean" } });
|
|
564
|
+
const registry = readRegistry();
|
|
565
|
+
const targets = values.all ? registry.map((e) => ({ id: e.id, path: e.path })) : [{ id: "", path: projectDir(values) }];
|
|
566
|
+
let failed = 0;
|
|
567
|
+
for (const t of targets) {
|
|
568
|
+
const issues = checkProject(t.path, registry);
|
|
569
|
+
const name = t.id || basename(t.path);
|
|
570
|
+
if (issues.length) {
|
|
571
|
+
failed++;
|
|
572
|
+
io.err(`${name}: ${issues.length} issue${issues.length === 1 ? "" : "s"}\n${issues.map((i) => ` - ${i}`).join("\n")}\n`);
|
|
573
|
+
}
|
|
574
|
+
else
|
|
575
|
+
io.out(`ok: ${name}\n`);
|
|
576
|
+
}
|
|
577
|
+
return failed ? 1 : 0;
|
|
578
|
+
}
|
|
579
|
+
case "render": {
|
|
580
|
+
const { values } = parse(rest, TARGET);
|
|
581
|
+
const dir = projectDir(values);
|
|
582
|
+
await updateGoals(dir, (file) => file);
|
|
583
|
+
io.out(`rendered ${join(dir, MD)}\n`);
|
|
584
|
+
return 0;
|
|
585
|
+
}
|
|
586
|
+
case "import": {
|
|
587
|
+
const { values, positionals } = parse(rest, { completed: { type: "string" }, out: { type: "string" }, id: { type: "string" }, force: { type: "boolean" } });
|
|
588
|
+
const md = resolve(need(positionals[0], "path to goals.md"));
|
|
589
|
+
if (!existsSync(md))
|
|
590
|
+
throw new UpgoError(`${md} does not exist`);
|
|
591
|
+
const sibling = join(dirname(md), "goals-completed.md");
|
|
592
|
+
const completed = values.completed ? resolve(values.completed) : existsSync(sibling) ? sibling : undefined;
|
|
593
|
+
const out = resolve(values.out ?? join(dirname(md), GOALS));
|
|
594
|
+
if (existsSync(out) && !values.force)
|
|
595
|
+
throw new UpgoError(`${out} already exists (pass --force to overwrite)`);
|
|
596
|
+
const { importMarkdown } = await import("./src/core/import.js");
|
|
597
|
+
const { file, report } = importMarkdown(readFileSync(md, "utf8"), {
|
|
598
|
+
project: values.id ?? slugify(basename(dirname(md))),
|
|
599
|
+
today: day,
|
|
600
|
+
...(completed ? { completedMd: readFileSync(completed, "utf8") } : {}),
|
|
601
|
+
});
|
|
602
|
+
await fsp.mkdir(dirname(out), { recursive: true });
|
|
603
|
+
await writeAtomic(out, serialize(file));
|
|
604
|
+
const lines = [
|
|
605
|
+
`wrote ${out}`,
|
|
606
|
+
`${report.sections} sections, ${report.open} open, ${report.done} done, ${report.dropped} dropped, ${report.traps} traps`,
|
|
607
|
+
];
|
|
608
|
+
if (report.skipped.length)
|
|
609
|
+
lines.push(`skipped ${report.skipped.length}:`, ...report.skipped.map((s) => ` - ${s}`));
|
|
610
|
+
io.out(lines.join("\n") + "\n");
|
|
611
|
+
return 0;
|
|
612
|
+
}
|
|
613
|
+
case "forget": {
|
|
614
|
+
const { positionals } = parse(rest, {});
|
|
615
|
+
const id = need(positionals[0], "project id");
|
|
616
|
+
await forget(id);
|
|
617
|
+
io.out(`forgot ${id}\n`);
|
|
618
|
+
return 0;
|
|
619
|
+
}
|
|
620
|
+
case "ui": {
|
|
621
|
+
const { values } = parse(rest, { port: { type: "string" }, "no-open": { type: "boolean" } });
|
|
622
|
+
const { startServer } = await import("./server.js");
|
|
623
|
+
const { url } = await startServer({ port: int("port", values.port) ?? 4141 });
|
|
624
|
+
io.out(`upgo dashboard on ${url} (Ctrl+C to stop)\n`);
|
|
625
|
+
if (!values["no-open"])
|
|
626
|
+
openBrowser(url);
|
|
627
|
+
await new Promise(() => { });
|
|
628
|
+
return 0;
|
|
629
|
+
}
|
|
630
|
+
default:
|
|
631
|
+
throw new UpgoError(`unknown command \`${cmd}\` (see \`upgo help\`)`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
function openBrowser(url) {
|
|
635
|
+
const [bin, args] = process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
|
|
636
|
+
try {
|
|
637
|
+
execFileSync(bin, args, { stdio: "ignore" });
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
// The url is printed either way.
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
export async function main(argv, io) {
|
|
644
|
+
try {
|
|
645
|
+
return await run(argv, io);
|
|
646
|
+
}
|
|
647
|
+
catch (e) {
|
|
648
|
+
if (e instanceof UpgoError) {
|
|
649
|
+
io.err(`upgo: ${e.message}\n`);
|
|
650
|
+
return 1;
|
|
651
|
+
}
|
|
652
|
+
throw e;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
async function readStdin() {
|
|
656
|
+
const chunks = [];
|
|
657
|
+
for await (const chunk of process.stdin)
|
|
658
|
+
chunks.push(chunk);
|
|
659
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
660
|
+
}
|
|
661
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
662
|
+
const code = await main(process.argv.slice(2), {
|
|
663
|
+
out: (s) => process.stdout.write(s),
|
|
664
|
+
err: (s) => process.stderr.write(s),
|
|
665
|
+
stdin: readStdin,
|
|
666
|
+
});
|
|
667
|
+
process.exitCode = code;
|
|
668
|
+
}
|