checkpointer 0.1.1 → 0.2.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/README.md +88 -5
- package/dist/cli.js +885 -36
- package/package.json +11 -6
- package/skills/checkpointer/SKILL.md +35 -2
package/dist/cli.js
CHANGED
|
@@ -5,6 +5,8 @@ import { Command } from "commander";
|
|
|
5
5
|
import pc3 from "picocolors";
|
|
6
6
|
|
|
7
7
|
// src/actions.ts
|
|
8
|
+
import { writeFileSync as writeFileSync4 } from "fs";
|
|
9
|
+
import { resolve } from "path";
|
|
8
10
|
import pc2 from "picocolors";
|
|
9
11
|
|
|
10
12
|
// src/core/store.ts
|
|
@@ -42,8 +44,37 @@ var CheckpointerError = class extends Error {
|
|
|
42
44
|
function fail(message, code) {
|
|
43
45
|
throw new CheckpointerError(message, code);
|
|
44
46
|
}
|
|
47
|
+
var GENERIC_EXIT = 1;
|
|
48
|
+
var EXIT_CODES = {
|
|
49
|
+
// 3 — environment not ready
|
|
50
|
+
not_initialized: 3,
|
|
51
|
+
not_a_repo: 3,
|
|
52
|
+
lock_held: 3,
|
|
53
|
+
corrupt_metadata: 3,
|
|
54
|
+
unsupported_meta_version: 3,
|
|
55
|
+
skill_missing: 3,
|
|
56
|
+
// 4 — bad input
|
|
57
|
+
no_match: 4,
|
|
58
|
+
name_taken: 4,
|
|
59
|
+
invalid_status: 4,
|
|
60
|
+
missing_message: 4,
|
|
61
|
+
auto_target: 4,
|
|
62
|
+
bad_path: 4,
|
|
63
|
+
// 5 — safety refusal
|
|
64
|
+
broken_refusal: 5,
|
|
65
|
+
detached_head: 5,
|
|
66
|
+
abandoned_target: 5,
|
|
67
|
+
// 6 — benign no-op
|
|
68
|
+
nothing_to_ship: 6,
|
|
69
|
+
nothing_to_commit: 6,
|
|
70
|
+
no_sources: 6
|
|
71
|
+
};
|
|
72
|
+
function exitCodeFor(code) {
|
|
73
|
+
return EXIT_CODES[code] ?? GENERIC_EXIT;
|
|
74
|
+
}
|
|
45
75
|
|
|
46
76
|
// src/core/git.ts
|
|
77
|
+
var EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
47
78
|
var IDENTITY = [
|
|
48
79
|
"-c",
|
|
49
80
|
"user.name=checkpointer",
|
|
@@ -82,6 +113,21 @@ var ShadowGit = class {
|
|
|
82
113
|
stageAll() {
|
|
83
114
|
this.capture(["add", "-A"], "stage");
|
|
84
115
|
}
|
|
116
|
+
// Paths staged as git submodule links (mode 160000). `git add -A` records a
|
|
117
|
+
// gitlink for any nested repo, but the shadow store never holds that repo's
|
|
118
|
+
// objects — so a snapshot would be a dangling pointer and a restore would
|
|
119
|
+
// leave the directory empty. Callers detect these to warn and drop them.
|
|
120
|
+
stagedGitlinks() {
|
|
121
|
+
const out = this.capture(["ls-files", "-s"], "ls-files");
|
|
122
|
+
if (!out) return [];
|
|
123
|
+
return out.split("\n").filter((line) => line.startsWith("160000 ")).map((line) => line.split(" ").slice(1).join(" ")).filter(Boolean);
|
|
124
|
+
}
|
|
125
|
+
// Drop paths from the index without touching the working tree, so the
|
|
126
|
+
// recorded tree contains neither a populated copy nor a dangling gitlink.
|
|
127
|
+
unstage(paths) {
|
|
128
|
+
if (!paths.length) return;
|
|
129
|
+
this.capture(["rm", "--cached", "-f", "-q", "--ignore-unmatch", "--", ...paths], "rm");
|
|
130
|
+
}
|
|
85
131
|
commit(message) {
|
|
86
132
|
const args = [...IDENTITY, "commit", "-q", "--allow-empty", "-m", message];
|
|
87
133
|
const result = this.run(args);
|
|
@@ -109,6 +155,18 @@ var ShadowGit = class {
|
|
|
109
155
|
const out = this.capture(["ls-tree", "-r", "--name-only", sha], "ls-tree");
|
|
110
156
|
return out ? out.split("\n") : [];
|
|
111
157
|
}
|
|
158
|
+
// Paths whose staged content differs from the given commit/tree. Call after
|
|
159
|
+
// stageAll() so the index reflects the working tree (excludes already
|
|
160
|
+
// applied), then this never reports excluded files like .env.
|
|
161
|
+
changedFiles(sha) {
|
|
162
|
+
const out = this.capture(["diff-index", "--cached", "--name-only", sha], "diff-index");
|
|
163
|
+
return out ? out.split("\n") : [];
|
|
164
|
+
}
|
|
165
|
+
// The tree object the current index would commit to. Lets callers compare
|
|
166
|
+
// working-tree state to a checkpoint without making a commit.
|
|
167
|
+
writeTree() {
|
|
168
|
+
return this.capture(["write-tree"], "write-tree");
|
|
169
|
+
}
|
|
112
170
|
diffStat(from, to) {
|
|
113
171
|
return this.capture(["diff", "--stat", from, to], "diff");
|
|
114
172
|
}
|
|
@@ -132,11 +190,17 @@ function read(file, root) {
|
|
|
132
190
|
try {
|
|
133
191
|
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
134
192
|
} catch {
|
|
135
|
-
fail(`checkpoint metadata at ${file} is corrupt (invalid JSON)
|
|
193
|
+
fail(`checkpoint metadata at ${file} is corrupt (invalid JSON)`, "corrupt_metadata");
|
|
136
194
|
}
|
|
137
195
|
const data = parsed;
|
|
196
|
+
if (typeof data?.version === "number" && data.version > 1) {
|
|
197
|
+
fail(
|
|
198
|
+
`checkpoint metadata at ${file} was written by a newer checkpointer (schema v${data.version}) \u2014 upgrade checkpointer`,
|
|
199
|
+
"unsupported_meta_version"
|
|
200
|
+
);
|
|
201
|
+
}
|
|
138
202
|
if (data?.version !== 1 || !Array.isArray(data.checkpoints) || typeof data.shippedSeq !== "number") {
|
|
139
|
-
fail(`checkpoint metadata at ${file} is invalid or
|
|
203
|
+
fail(`checkpoint metadata at ${file} is invalid or unreadable`, "corrupt_metadata");
|
|
140
204
|
}
|
|
141
205
|
return data;
|
|
142
206
|
}
|
|
@@ -362,7 +426,10 @@ function withLock(file, fn) {
|
|
|
362
426
|
continue;
|
|
363
427
|
}
|
|
364
428
|
if (Date.now() > deadline) {
|
|
365
|
-
fail(
|
|
429
|
+
fail(
|
|
430
|
+
`another checkpointer operation is in progress (lock: ${file}) \u2014 try again in a moment`,
|
|
431
|
+
"lock_held"
|
|
432
|
+
);
|
|
366
433
|
}
|
|
367
434
|
sleep(RETRY_MS);
|
|
368
435
|
}
|
|
@@ -418,8 +485,15 @@ var Store = class _Store {
|
|
|
418
485
|
return withLock(this.paths.lockFile, () => {
|
|
419
486
|
this.meta.reload();
|
|
420
487
|
writeExcludes(this.paths);
|
|
421
|
-
|
|
488
|
+
this.git.stageAll();
|
|
489
|
+
const nestedRepos = this.git.stagedGitlinks();
|
|
490
|
+
if (nestedRepos.length) this.git.unstage(nestedRepos);
|
|
422
491
|
const auto = input.auto ?? false;
|
|
492
|
+
const latest = this.meta.latest();
|
|
493
|
+
if (!auto && !input.allowEmpty && input.restoredToSeq === void 0 && input.name === void 0 && input.message === void 0 && input.intent === null && latest && !latest.auto && input.status === latest.status && this.git.writeTree() === this.git.treeOf(latest.sha)) {
|
|
494
|
+
return { ...latest, noop: true, ...nestedRepos.length ? { nestedRepos } : {} };
|
|
495
|
+
}
|
|
496
|
+
const seq = this.meta.nextSeq();
|
|
423
497
|
let name;
|
|
424
498
|
if (input.name === void 0) {
|
|
425
499
|
name = this.meta.uniqueName(`cp-${seq}`);
|
|
@@ -431,7 +505,6 @@ var Store = class _Store {
|
|
|
431
505
|
}
|
|
432
506
|
name = input.name;
|
|
433
507
|
}
|
|
434
|
-
this.git.stageAll();
|
|
435
508
|
const message = input.message ?? name;
|
|
436
509
|
const sha = this.git.commit(`[${seq}] ${name}: ${message}`);
|
|
437
510
|
const checkpoint = {
|
|
@@ -449,9 +522,21 @@ var Store = class _Store {
|
|
|
449
522
|
...input.restoredToSeq !== void 0 ? { restoredToSeq: input.restoredToSeq } : {}
|
|
450
523
|
};
|
|
451
524
|
this.meta.add(checkpoint);
|
|
452
|
-
return checkpoint;
|
|
525
|
+
return nestedRepos.length ? { ...checkpoint, nestedRepos } : checkpoint;
|
|
453
526
|
});
|
|
454
527
|
}
|
|
528
|
+
// Whether the working tree differs from a checkpoint, without committing.
|
|
529
|
+
// Defaults to the latest checkpoint of any kind, so the answer predicts
|
|
530
|
+
// whether `save` would create a new checkpoint or be a no-op. Read-only:
|
|
531
|
+
// stages into the shadow index (applying excludes) but writes no commit.
|
|
532
|
+
changed(ref) {
|
|
533
|
+
const against = ref ? this.meta.require(ref) : this.meta.latest();
|
|
534
|
+
this.git.stageAll();
|
|
535
|
+
const nested = this.git.stagedGitlinks();
|
|
536
|
+
if (nested.length) this.git.unstage(nested);
|
|
537
|
+
const files = against ? this.git.changedFiles(against.sha) : this.git.changedFiles(EMPTY_TREE);
|
|
538
|
+
return { changed: files.length > 0, against, files };
|
|
539
|
+
}
|
|
455
540
|
setStatus(seq, status2) {
|
|
456
541
|
withLock(this.paths.lockFile, () => {
|
|
457
542
|
this.meta.reload();
|
|
@@ -485,24 +570,41 @@ function resolveIdentity(root, overrides = {}) {
|
|
|
485
570
|
const author = overrides.author ?? process.env.CHECKPOINTER_AUTHOR ?? gitUser(root) ?? (agent ? agent : "you");
|
|
486
571
|
return { author, agent, session, intent };
|
|
487
572
|
}
|
|
488
|
-
function
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
573
|
+
function gitUserName(root) {
|
|
574
|
+
return gitConfig(root, "user.name");
|
|
575
|
+
}
|
|
576
|
+
function gitUserEmail(root) {
|
|
577
|
+
return gitConfig(root, "user.email");
|
|
492
578
|
}
|
|
579
|
+
function gitConfig(root, key) {
|
|
580
|
+
const result = run("git", ["config", key], { cwd: root });
|
|
581
|
+
const value = result.stdout.trim();
|
|
582
|
+
return result.status === 0 && value ? value : null;
|
|
583
|
+
}
|
|
584
|
+
var gitUser = gitUserName;
|
|
493
585
|
|
|
494
586
|
// src/core/ship.ts
|
|
495
587
|
function planShip(store, uptoRef) {
|
|
496
|
-
const upto = uptoRef ? store.meta.require(uptoRef) :
|
|
588
|
+
const upto = uptoRef ? store.meta.require(uptoRef) : latestLiveOrFail(store);
|
|
497
589
|
if (upto.auto) {
|
|
498
590
|
fail(`'${upto.name}' is an auto safety checkpoint \u2014 ship to a manual checkpoint instead`, "auto_target");
|
|
499
591
|
}
|
|
592
|
+
if (store.meta.isAbandoned(upto.seq)) {
|
|
593
|
+
fail(
|
|
594
|
+
`'${upto.name}' was set aside by a restore \u2014 it is not part of the current line of work.
|
|
595
|
+
Restore it first if you want to ship it, or ship a live checkpoint instead.`,
|
|
596
|
+
"abandoned_target"
|
|
597
|
+
);
|
|
598
|
+
}
|
|
500
599
|
const all = store.meta.all();
|
|
501
600
|
const shippedSeq = store.meta.shippedSeq();
|
|
502
601
|
const from = all.find((cp) => cp.seq === shippedSeq) ?? null;
|
|
503
602
|
const included = all.filter(
|
|
504
603
|
(cp) => cp.seq > shippedSeq && cp.seq <= upto.seq && !cp.auto && !store.meta.isAbandoned(cp.seq)
|
|
505
604
|
);
|
|
605
|
+
if (!included.length) {
|
|
606
|
+
fail(`nothing to ship \u2014 no unshipped checkpoints up to ${upto.name}`, "nothing_to_ship");
|
|
607
|
+
}
|
|
506
608
|
return {
|
|
507
609
|
upto,
|
|
508
610
|
from,
|
|
@@ -518,10 +620,19 @@ function executeShip(store, plan, message, author) {
|
|
|
518
620
|
fail(`nothing to ship \u2014 no unshipped checkpoints up to ${plan.upto.name}`, "nothing_to_ship");
|
|
519
621
|
}
|
|
520
622
|
const root = store.paths.root;
|
|
623
|
+
const parent = run("git", ["rev-parse", "--verify", "-q", "HEAD"], { cwd: root }).stdout.trim();
|
|
624
|
+
if (parent) {
|
|
625
|
+
const onBranch = run("git", ["symbolic-ref", "-q", "HEAD"], { cwd: root }).status === 0;
|
|
626
|
+
if (!onBranch) {
|
|
627
|
+
fail(
|
|
628
|
+
"HEAD is detached \u2014 checkpointer won't ship here, the commit could be lost on checkout.\nCheck out a branch first (e.g. 'git switch -c my-feature'), then ship.",
|
|
629
|
+
"detached_head"
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
521
633
|
const fetch = run("git", ["fetch", "--no-tags", "-q", store.git.path, "refs/heads/checkpoints"], { cwd: root });
|
|
522
634
|
if (fetch.status !== 0) fail(`could not read checkpoints into the repo: ${fetch.stderr.trim()}`);
|
|
523
635
|
const tree = store.git.treeOf(plan.upto.sha);
|
|
524
|
-
const parent = run("git", ["rev-parse", "--verify", "-q", "HEAD"], { cwd: root }).stdout.trim();
|
|
525
636
|
if (parent) {
|
|
526
637
|
const headTree = run("git", ["rev-parse", "HEAD^{tree}"], { cwd: root }).stdout.trim();
|
|
527
638
|
if (headTree === tree) {
|
|
@@ -530,10 +641,17 @@ function executeShip(store, plan, message, author) {
|
|
|
530
641
|
}
|
|
531
642
|
const args = ["commit-tree", tree, "-m", message];
|
|
532
643
|
if (parent) args.push("-p", parent);
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
644
|
+
const env = {
|
|
645
|
+
...process.env,
|
|
646
|
+
GIT_AUTHOR_NAME: author,
|
|
647
|
+
GIT_COMMITTER_NAME: author
|
|
648
|
+
};
|
|
649
|
+
const isHuman = author === gitUserName(root) && gitUserEmail(root) !== null;
|
|
650
|
+
if (!isHuman) {
|
|
651
|
+
env.GIT_AUTHOR_EMAIL = "checkpointer@local";
|
|
652
|
+
env.GIT_COMMITTER_EMAIL = "checkpointer@local";
|
|
653
|
+
}
|
|
654
|
+
const built = run("git", args, { cwd: root, env });
|
|
537
655
|
if (built.status !== 0) fail(`git commit-tree failed: ${built.stderr.trim()}`);
|
|
538
656
|
const commit = built.stdout.trim();
|
|
539
657
|
const update = run("git", ["update-ref", "HEAD", commit], { cwd: root });
|
|
@@ -543,10 +661,350 @@ function executeShip(store, plan, message, author) {
|
|
|
543
661
|
return { commit, message };
|
|
544
662
|
});
|
|
545
663
|
}
|
|
546
|
-
function
|
|
547
|
-
const
|
|
548
|
-
if (!
|
|
549
|
-
return
|
|
664
|
+
function latestLiveOrFail(store) {
|
|
665
|
+
const live = [...store.meta.all()].reverse().find((cp) => !cp.auto && !store.meta.isAbandoned(cp.seq));
|
|
666
|
+
if (!live) fail("no checkpoints to ship \u2014 run 'checkpointer save' first", "nothing_to_ship");
|
|
667
|
+
return live;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// src/core/analyze.ts
|
|
671
|
+
import { existsSync as existsSync4, readdirSync } from "fs";
|
|
672
|
+
import { join as join3, relative, sep } from "path";
|
|
673
|
+
import ts from "typescript";
|
|
674
|
+
var SOURCE_EXTS = /* @__PURE__ */ new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
675
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
676
|
+
"node_modules",
|
|
677
|
+
".git",
|
|
678
|
+
"dist",
|
|
679
|
+
"build",
|
|
680
|
+
"out",
|
|
681
|
+
"coverage",
|
|
682
|
+
".next",
|
|
683
|
+
".nuxt",
|
|
684
|
+
".cache",
|
|
685
|
+
"vendor"
|
|
686
|
+
]);
|
|
687
|
+
function scanSources(root) {
|
|
688
|
+
const found = [];
|
|
689
|
+
const walk = (dir) => {
|
|
690
|
+
let entries;
|
|
691
|
+
try {
|
|
692
|
+
entries = readdirSync(dir, { withFileTypes: true, encoding: "utf8" });
|
|
693
|
+
} catch {
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
for (const entry of entries) {
|
|
697
|
+
const full = join3(dir, entry.name);
|
|
698
|
+
if (entry.isDirectory()) {
|
|
699
|
+
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
|
|
700
|
+
walk(full);
|
|
701
|
+
} else if (entry.isFile()) {
|
|
702
|
+
if (entry.name.endsWith(".d.ts")) continue;
|
|
703
|
+
const dot = entry.name.lastIndexOf(".");
|
|
704
|
+
if (dot >= 0 && SOURCE_EXTS.has(entry.name.slice(dot))) found.push(full);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
walk(root);
|
|
709
|
+
return found;
|
|
710
|
+
}
|
|
711
|
+
function collectFiles(root) {
|
|
712
|
+
const configPath = join3(root, "tsconfig.json");
|
|
713
|
+
if (existsSync4(configPath)) {
|
|
714
|
+
const config = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
715
|
+
if (!config.error) {
|
|
716
|
+
const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, root);
|
|
717
|
+
if (parsed.fileNames.length) {
|
|
718
|
+
return { files: parsed.fileNames, options: { ...parsed.options, allowJs: true, noEmit: true } };
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return {
|
|
723
|
+
files: scanSources(root),
|
|
724
|
+
options: { allowJs: true, noEmit: true, target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext }
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
function rel(root, file) {
|
|
728
|
+
return relative(root, file).split(sep).join("/");
|
|
729
|
+
}
|
|
730
|
+
function isFunctionLike(node) {
|
|
731
|
+
return ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node) || ts.isConstructorDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isGetAccessor(node) || ts.isSetAccessor(node);
|
|
732
|
+
}
|
|
733
|
+
function describeCallable(node) {
|
|
734
|
+
if (ts.isFunctionDeclaration(node)) {
|
|
735
|
+
return node.name ? { name: node.name.text, kind: "function", commentNode: node } : null;
|
|
736
|
+
}
|
|
737
|
+
if (ts.isConstructorDeclaration(node)) {
|
|
738
|
+
const cls = node.parent;
|
|
739
|
+
const clsName = ts.isClassLike(cls) && cls.name ? cls.name.text : "(anonymous class)";
|
|
740
|
+
return { name: `${clsName}.constructor`, kind: "constructor", commentNode: node };
|
|
741
|
+
}
|
|
742
|
+
if (ts.isMethodDeclaration(node) || ts.isGetAccessor(node) || ts.isSetAccessor(node)) {
|
|
743
|
+
const cls = node.parent;
|
|
744
|
+
const clsName = ts.isClassLike(cls) && cls.name ? cls.name.text : "";
|
|
745
|
+
const member = node.name.getText();
|
|
746
|
+
return { name: clsName ? `${clsName}.${member}` : member, kind: "method", commentNode: node };
|
|
747
|
+
}
|
|
748
|
+
const parent = node.parent;
|
|
749
|
+
if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
|
|
750
|
+
const stmt = parent.parent.parent;
|
|
751
|
+
return { name: parent.name.text, kind: "function", commentNode: ts.isVariableStatement(stmt) ? stmt : node };
|
|
752
|
+
}
|
|
753
|
+
if ((ts.isPropertyAssignment(parent) || ts.isPropertyDeclaration(parent)) && (ts.isIdentifier(parent.name) || ts.isStringLiteral(parent.name))) {
|
|
754
|
+
const member = parent.name.text;
|
|
755
|
+
if (ts.isPropertyDeclaration(parent) && ts.isClassLike(parent.parent) && parent.parent.name) {
|
|
756
|
+
return { name: `${parent.parent.name.text}.${member}`, kind: "method", commentNode: parent };
|
|
757
|
+
}
|
|
758
|
+
return { name: member, kind: "function", commentNode: parent };
|
|
759
|
+
}
|
|
760
|
+
return null;
|
|
761
|
+
}
|
|
762
|
+
function extractDescription(commentNode, sf) {
|
|
763
|
+
const full = sf.getFullText();
|
|
764
|
+
const ranges = ts.getLeadingCommentRanges(full, commentNode.getFullStart());
|
|
765
|
+
if (!ranges?.length) return "";
|
|
766
|
+
const tail = full.slice(ranges[ranges.length - 1].end, commentNode.getStart());
|
|
767
|
+
if ((tail.match(/\n/g) ?? []).length > 1) return "";
|
|
768
|
+
const group = [ranges[ranges.length - 1]];
|
|
769
|
+
for (let i = ranges.length - 2; i >= 0; i--) {
|
|
770
|
+
const gap = full.slice(ranges[i].end, ranges[i + 1].pos);
|
|
771
|
+
if ((gap.match(/\n/g) ?? []).length > 1) break;
|
|
772
|
+
group.unshift(ranges[i]);
|
|
773
|
+
}
|
|
774
|
+
const raw = group.map((r) => full.slice(r.pos, r.end)).join("\n");
|
|
775
|
+
const cleaned = raw.split("\n").map(
|
|
776
|
+
(line) => line.replace(/^\s*\/\*\*?/, "").replace(/\*\/\s*$/, "").replace(/^\s*\*\s?/, "").replace(/^\s*\/\/\s?/, "").trimEnd()
|
|
777
|
+
).join("\n").trim();
|
|
778
|
+
const summary = cleaned.split("\n").filter((line) => !line.trimStart().startsWith("@")).join(" ").replace(/\s+/g, " ").trim();
|
|
779
|
+
return summary.length > 280 ? summary.slice(0, 277) + "\u2026" : summary;
|
|
780
|
+
}
|
|
781
|
+
function jsdocTags(node) {
|
|
782
|
+
const params = /* @__PURE__ */ new Map();
|
|
783
|
+
let returns = "";
|
|
784
|
+
for (const tag2 of ts.getJSDocTags(node)) {
|
|
785
|
+
const text = typeof tag2.comment === "string" ? tag2.comment : ts.getTextOfJSDocComment(tag2.comment) ?? "";
|
|
786
|
+
if (ts.isJSDocParameterTag(tag2)) {
|
|
787
|
+
params.set(tag2.name.getText(), text.replace(/^[-–\s]+/, "").trim());
|
|
788
|
+
} else if (tag2.tagName.text === "returns" || tag2.tagName.text === "return") {
|
|
789
|
+
returns = text.replace(/^[-–\s]+/, "").trim();
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return { params, returns };
|
|
793
|
+
}
|
|
794
|
+
function clampType(t) {
|
|
795
|
+
const flat = t.replace(/\s+/g, " ").trim();
|
|
796
|
+
return flat.length > 80 ? flat.slice(0, 77) + "\u2026" : flat;
|
|
797
|
+
}
|
|
798
|
+
function signatureOf(node, name, checker) {
|
|
799
|
+
const docs = jsdocTags(node);
|
|
800
|
+
const params = node.parameters.map((p) => {
|
|
801
|
+
const pname = p.name.getText();
|
|
802
|
+
let type = "";
|
|
803
|
+
if (p.type) {
|
|
804
|
+
type = p.type.getText();
|
|
805
|
+
} else {
|
|
806
|
+
try {
|
|
807
|
+
type = checker.typeToString(checker.getTypeAtLocation(p));
|
|
808
|
+
} catch {
|
|
809
|
+
type = "";
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
return {
|
|
813
|
+
name: pname,
|
|
814
|
+
type: clampType(type),
|
|
815
|
+
optional: !!p.questionToken || !!p.initializer || !!p.dotDotDotToken,
|
|
816
|
+
doc: docs.params.get(pname.replace(/[.{}[\]]/g, "").split(/[,:]/)[0].trim()) ?? docs.params.get(pname) ?? ""
|
|
817
|
+
};
|
|
818
|
+
});
|
|
819
|
+
let returns = "";
|
|
820
|
+
try {
|
|
821
|
+
const sig = checker.getSignatureFromDeclaration(node);
|
|
822
|
+
if (sig) returns = clampType(checker.typeToString(checker.getReturnTypeOfSignature(sig)));
|
|
823
|
+
} catch {
|
|
824
|
+
returns = "";
|
|
825
|
+
}
|
|
826
|
+
if (!returns && node.type) returns = clampType(node.type.getText());
|
|
827
|
+
const paramStr = params.map((p) => `${p.name}${p.optional ? "?" : ""}: ${p.type || "any"}`).join(", ");
|
|
828
|
+
const signature = `${name}(${paramStr})${returns ? `: ${returns}` : ""}`;
|
|
829
|
+
return { params, returns, signature };
|
|
830
|
+
}
|
|
831
|
+
function sourceOf(node, sf) {
|
|
832
|
+
const text = node.getText(sf);
|
|
833
|
+
const MAX = 8e3;
|
|
834
|
+
return text.length > MAX ? text.slice(0, MAX) + "\n/* \u2026 truncated \u2026 */" : text;
|
|
835
|
+
}
|
|
836
|
+
function resolveCallee(expr, checker, declToId) {
|
|
837
|
+
let symbol = checker.getSymbolAtLocation(expr.expression);
|
|
838
|
+
if (!symbol) return null;
|
|
839
|
+
if (symbol.flags & ts.SymbolFlags.Alias) {
|
|
840
|
+
try {
|
|
841
|
+
symbol = checker.getAliasedSymbol(symbol);
|
|
842
|
+
} catch {
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
for (const decl of symbol.declarations ?? []) {
|
|
846
|
+
const id = declToId.get(decl);
|
|
847
|
+
if (id) return id;
|
|
848
|
+
}
|
|
849
|
+
if (ts.isNewExpression(expr)) {
|
|
850
|
+
for (const decl of symbol.declarations ?? []) {
|
|
851
|
+
if (ts.isClassLike(decl)) {
|
|
852
|
+
for (const member of decl.members) {
|
|
853
|
+
if (ts.isConstructorDeclaration(member)) {
|
|
854
|
+
const id = declToId.get(member);
|
|
855
|
+
if (id) return id;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
return null;
|
|
862
|
+
}
|
|
863
|
+
function analyzeProject(root) {
|
|
864
|
+
const { files, options } = collectFiles(root);
|
|
865
|
+
if (!files.length) {
|
|
866
|
+
fail(
|
|
867
|
+
`no TypeScript/JavaScript sources found under ${root} to graph. Today 'graph' analyzes TS/JS (it resolves calls via the TypeScript compiler); support for more languages is planned. Your other commands (save/restore/ship) work in any repo.`,
|
|
868
|
+
"no_sources"
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
const program2 = ts.createProgram(files, options);
|
|
872
|
+
const checker = program2.getTypeChecker();
|
|
873
|
+
const sources = program2.getSourceFiles().filter((sf) => !sf.isDeclarationFile && !sf.fileName.includes("node_modules"));
|
|
874
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
875
|
+
const declToId = /* @__PURE__ */ new Map();
|
|
876
|
+
const moduleId = /* @__PURE__ */ new Map();
|
|
877
|
+
const makeId = (base) => {
|
|
878
|
+
if (!nodes.has(base)) return base;
|
|
879
|
+
for (let n = 2; ; n++) if (!nodes.has(`${base}~${n}`)) return `${base}~${n}`;
|
|
880
|
+
};
|
|
881
|
+
for (const sf of sources) {
|
|
882
|
+
const file = rel(root, sf.fileName);
|
|
883
|
+
moduleId.set(sf, `${file}::<module>`);
|
|
884
|
+
const discover = (node) => {
|
|
885
|
+
if (isFunctionLike(node)) {
|
|
886
|
+
const info2 = describeCallable(node);
|
|
887
|
+
if (info2) {
|
|
888
|
+
const { line } = sf.getLineAndCharacterOfPosition(node.getStart());
|
|
889
|
+
const id = makeId(`${file}#${info2.name}`);
|
|
890
|
+
const modifiers = ts.canHaveModifiers(node) ? ts.getCombinedModifierFlags(node) : ts.ModifierFlags.None;
|
|
891
|
+
const { params, returns, signature } = signatureOf(node, info2.name, checker);
|
|
892
|
+
nodes.set(id, {
|
|
893
|
+
id,
|
|
894
|
+
name: info2.name,
|
|
895
|
+
kind: info2.kind,
|
|
896
|
+
file,
|
|
897
|
+
line: line + 1,
|
|
898
|
+
description: extractDescription(info2.commentNode, sf),
|
|
899
|
+
signature,
|
|
900
|
+
exported: (modifiers & ts.ModifierFlags.Export) !== 0,
|
|
901
|
+
async: (modifiers & ts.ModifierFlags.Async) !== 0,
|
|
902
|
+
params,
|
|
903
|
+
returns,
|
|
904
|
+
returnDoc: jsdocTags(node).returns,
|
|
905
|
+
code: sourceOf(info2.commentNode, sf)
|
|
906
|
+
});
|
|
907
|
+
declToId.set(node, id);
|
|
908
|
+
if (ts.isVariableDeclaration(node.parent)) declToId.set(node.parent, id);
|
|
909
|
+
if (ts.isPropertyAssignment(node.parent) || ts.isPropertyDeclaration(node.parent)) {
|
|
910
|
+
declToId.set(node.parent, id);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
ts.forEachChild(node, discover);
|
|
915
|
+
};
|
|
916
|
+
discover(sf);
|
|
917
|
+
}
|
|
918
|
+
const edgeSet = /* @__PURE__ */ new Set();
|
|
919
|
+
const edges = [];
|
|
920
|
+
const addEdge = (from, to) => {
|
|
921
|
+
if (from === to) return;
|
|
922
|
+
const key = `${from}\0${to}`;
|
|
923
|
+
if (edgeSet.has(key)) return;
|
|
924
|
+
edgeSet.add(key);
|
|
925
|
+
edges.push({ from, to });
|
|
926
|
+
};
|
|
927
|
+
for (const sf of sources) {
|
|
928
|
+
const visit = (node, scope) => {
|
|
929
|
+
if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
|
|
930
|
+
const target = resolveCallee(node, checker, declToId);
|
|
931
|
+
if (target) addEdge(scope, target);
|
|
932
|
+
}
|
|
933
|
+
const nextScope = isFunctionLike(node) ? declToId.get(node) ?? scope : scope;
|
|
934
|
+
ts.forEachChild(node, (child) => visit(child, nextScope));
|
|
935
|
+
};
|
|
936
|
+
visit(sf, moduleId.get(sf));
|
|
937
|
+
}
|
|
938
|
+
const usedModules = new Set(edges.filter((e) => e.from.endsWith("::<module>")).map((e) => e.from));
|
|
939
|
+
for (const sf of sources) {
|
|
940
|
+
const id = moduleId.get(sf);
|
|
941
|
+
if (!usedModules.has(id)) continue;
|
|
942
|
+
const file = rel(root, sf.fileName);
|
|
943
|
+
nodes.set(id, {
|
|
944
|
+
id,
|
|
945
|
+
name: `${file} (top-level)`,
|
|
946
|
+
kind: "module",
|
|
947
|
+
file,
|
|
948
|
+
line: 1,
|
|
949
|
+
description: "Top-level module code \u2014 runs on import.",
|
|
950
|
+
signature: `${file} (top-level)`,
|
|
951
|
+
exported: false,
|
|
952
|
+
async: false,
|
|
953
|
+
params: [],
|
|
954
|
+
returns: "",
|
|
955
|
+
returnDoc: "",
|
|
956
|
+
code: ""
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
const liveEdges = edges.filter((e) => nodes.has(e.from) && nodes.has(e.to));
|
|
960
|
+
const hasIncoming = new Set(liveEdges.map((e) => e.to));
|
|
961
|
+
const roots = [...nodes.keys()].filter((id) => !hasIncoming.has(id)).sort();
|
|
962
|
+
return {
|
|
963
|
+
root,
|
|
964
|
+
fileCount: sources.length,
|
|
965
|
+
nodes: [...nodes.values()].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line),
|
|
966
|
+
edges: liveEdges,
|
|
967
|
+
roots
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// src/core/graph-server.ts
|
|
972
|
+
import { createServer } from "http";
|
|
973
|
+
import { spawn } from "child_process";
|
|
974
|
+
import { platform } from "os";
|
|
975
|
+
function serveGraph(html, opts) {
|
|
976
|
+
return new Promise((resolve2, reject) => {
|
|
977
|
+
const server = createServer((req, res) => {
|
|
978
|
+
if (req.url === "/favicon.ico") {
|
|
979
|
+
res.writeHead(204).end();
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
res.writeHead(200, {
|
|
983
|
+
"content-type": "text/html; charset=utf-8",
|
|
984
|
+
"cache-control": "no-store"
|
|
985
|
+
});
|
|
986
|
+
res.end(html);
|
|
987
|
+
});
|
|
988
|
+
server.on("error", reject);
|
|
989
|
+
server.listen(opts.port ?? 0, "127.0.0.1", () => {
|
|
990
|
+
const addr = server.address();
|
|
991
|
+
const port = typeof addr === "object" && addr ? addr.port : opts.port;
|
|
992
|
+
const url = `http://127.0.0.1:${port}/`;
|
|
993
|
+
if (opts.open !== false) openBrowser(url);
|
|
994
|
+
resolve2({ url, close: () => server.close() });
|
|
995
|
+
});
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
function openBrowser(url) {
|
|
999
|
+
const cmd = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
|
|
1000
|
+
const args = platform() === "win32" ? ["/c", "start", "", url] : [url];
|
|
1001
|
+
try {
|
|
1002
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
1003
|
+
child.on("error", () => {
|
|
1004
|
+
});
|
|
1005
|
+
child.unref();
|
|
1006
|
+
} catch {
|
|
1007
|
+
}
|
|
550
1008
|
}
|
|
551
1009
|
|
|
552
1010
|
// src/ui/format.ts
|
|
@@ -587,8 +1045,319 @@ function heading(text) {
|
|
|
587
1045
|
return pc.bold(pc.underline(text));
|
|
588
1046
|
}
|
|
589
1047
|
var ok = (text) => pc.green(text);
|
|
1048
|
+
var warn = (text) => pc.yellow(text);
|
|
590
1049
|
var dim = (text) => pc.dim(text);
|
|
591
1050
|
|
|
1051
|
+
// src/ui/graph-html.ts
|
|
1052
|
+
function renderGraphHtml(graph2, meta) {
|
|
1053
|
+
const data = JSON.stringify(graph2).replace(/</g, "\\u003c").replace(/>/g, "\\u003e");
|
|
1054
|
+
const projectName = graph2.root.split("/").filter(Boolean).at(-1) ?? "project";
|
|
1055
|
+
return `<!DOCTYPE html>
|
|
1056
|
+
<html lang="en">
|
|
1057
|
+
<head>
|
|
1058
|
+
<meta charset="utf-8" />
|
|
1059
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1060
|
+
<title>${escapeHtml(projectName)} \xB7 function graph</title>
|
|
1061
|
+
<style>${CSS}</style>
|
|
1062
|
+
</head>
|
|
1063
|
+
<body>
|
|
1064
|
+
<header>
|
|
1065
|
+
<div class="title">
|
|
1066
|
+
<span class="logo">\u2387</span>
|
|
1067
|
+
<div>
|
|
1068
|
+
<h1>${escapeHtml(projectName)}</h1>
|
|
1069
|
+
<div class="sub"><span id="stat-fn">0</span> functions \xB7 <span id="stat-file">${graph2.fileCount}</span> files \xB7 <span id="stat-edge">0</span> calls \xB7 generated ${escapeHtml(meta.generatedAt)}</div>
|
|
1070
|
+
</div>
|
|
1071
|
+
</div>
|
|
1072
|
+
<div class="search">
|
|
1073
|
+
<input id="q" type="search" placeholder="Search a function\u2026 (\u2191\u2193 to move, \u21B5 to focus)" autocomplete="off" spellcheck="false" />
|
|
1074
|
+
<div id="results"></div>
|
|
1075
|
+
</div>
|
|
1076
|
+
</header>
|
|
1077
|
+
<main>
|
|
1078
|
+
<section id="pane-tree" class="pane">
|
|
1079
|
+
<div class="pane-head">
|
|
1080
|
+
<h2>Call tree</h2>
|
|
1081
|
+
<div class="pane-actions">
|
|
1082
|
+
<button id="btn-expand">Expand all</button>
|
|
1083
|
+
<button id="btn-collapse">Collapse</button>
|
|
1084
|
+
</div>
|
|
1085
|
+
</div>
|
|
1086
|
+
<div class="hint">Top-down from entrypoints. Click a row to expand the functions it calls. \u25B8 has callees.</div>
|
|
1087
|
+
<div id="tree" class="tree"></div>
|
|
1088
|
+
</section>
|
|
1089
|
+
<section id="pane-focus" class="pane hidden">
|
|
1090
|
+
<div class="pane-head">
|
|
1091
|
+
<h2 id="focus-title">Function</h2>
|
|
1092
|
+
<button id="btn-back" title="Back to full tree">\u2715 close</button>
|
|
1093
|
+
</div>
|
|
1094
|
+
<div id="focus-meta" class="focus-meta"></div>
|
|
1095
|
+
<div class="focus-cols">
|
|
1096
|
+
<div class="focus-col">
|
|
1097
|
+
<h3>Ancestors <span class="muted">\u2014 who reaches this (up to entrypoints)</span></h3>
|
|
1098
|
+
<div id="ancestors" class="tree"></div>
|
|
1099
|
+
</div>
|
|
1100
|
+
<div class="focus-col">
|
|
1101
|
+
<h3>Descendants <span class="muted">\u2014 what this calls</span></h3>
|
|
1102
|
+
<div id="descendants" class="tree"></div>
|
|
1103
|
+
</div>
|
|
1104
|
+
</div>
|
|
1105
|
+
</section>
|
|
1106
|
+
</main>
|
|
1107
|
+
<script id="graph-data" type="application/json">${data}</script>
|
|
1108
|
+
<script>${JS}</script>
|
|
1109
|
+
</body>
|
|
1110
|
+
</html>`;
|
|
1111
|
+
}
|
|
1112
|
+
function escapeHtml(s) {
|
|
1113
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1114
|
+
}
|
|
1115
|
+
var CSS = `
|
|
1116
|
+
:root{
|
|
1117
|
+
--bg:#0d1117;--panel:#161b22;--panel2:#1c2330;--line:#283040;--fg:#e6edf3;--muted:#8b949e;
|
|
1118
|
+
--accent:#58a6ff;--green:#3fb950;--yellow:#d29922;--purple:#bc8cff;--row:#21262d;
|
|
1119
|
+
}
|
|
1120
|
+
*{box-sizing:border-box}
|
|
1121
|
+
body{margin:0;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;background:var(--bg);color:var(--fg)}
|
|
1122
|
+
header{position:sticky;top:0;z-index:5;background:linear-gradient(180deg,#0d1117,#0d1117f0);border-bottom:1px solid var(--line);padding:14px 20px;display:flex;gap:24px;align-items:flex-start;flex-wrap:wrap}
|
|
1123
|
+
.title{display:flex;gap:12px;align-items:center}
|
|
1124
|
+
.logo{font-size:26px;color:var(--accent)}
|
|
1125
|
+
h1{margin:0;font-size:17px;font-weight:600}
|
|
1126
|
+
.sub{color:var(--muted);font-size:12px}
|
|
1127
|
+
.search{position:relative;flex:1;min-width:280px;max-width:520px}
|
|
1128
|
+
#q{width:100%;padding:9px 12px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;color:var(--fg);font:inherit;outline:none}
|
|
1129
|
+
#q:focus{border-color:var(--accent)}
|
|
1130
|
+
#results{position:absolute;top:42px;left:0;right:0;background:var(--panel);border:1px solid var(--line);border-radius:8px;max-height:340px;overflow:auto;display:none;box-shadow:0 12px 30px #0008;z-index:6}
|
|
1131
|
+
#results.open{display:block}
|
|
1132
|
+
.result{padding:7px 12px;cursor:pointer;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;gap:10px}
|
|
1133
|
+
.result:last-child{border-bottom:none}
|
|
1134
|
+
.result.active,.result:hover{background:var(--row)}
|
|
1135
|
+
.result .nm{color:var(--accent)}
|
|
1136
|
+
.result .fl{color:var(--muted);font-size:11px;white-space:nowrap}
|
|
1137
|
+
main{padding:18px 20px;display:block}
|
|
1138
|
+
.pane{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin-bottom:18px}
|
|
1139
|
+
.pane.hidden{display:none}
|
|
1140
|
+
.pane-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px}
|
|
1141
|
+
.pane-head h2{margin:0;font-size:14px;font-weight:600}
|
|
1142
|
+
.pane-actions button,#btn-back{background:var(--panel2);border:1px solid var(--line);color:var(--fg);border-radius:6px;padding:5px 10px;cursor:pointer;font:inherit;font-size:12px}
|
|
1143
|
+
.pane-actions button:hover,#btn-back:hover{border-color:var(--accent)}
|
|
1144
|
+
.hint{color:var(--muted);font-size:12px;margin-bottom:10px}
|
|
1145
|
+
.tree{font-size:13px}
|
|
1146
|
+
.node{margin:0}
|
|
1147
|
+
.row{display:flex;align-items:baseline;gap:8px;padding:3px 6px;border-radius:6px;cursor:pointer;white-space:nowrap;overflow:hidden}
|
|
1148
|
+
.row:hover{background:var(--row)}
|
|
1149
|
+
.tw{width:14px;display:inline-block;color:var(--muted);flex:0 0 auto;text-align:center}
|
|
1150
|
+
.tw.leaf{color:transparent}
|
|
1151
|
+
.kind{font-size:10px;padding:1px 5px;border-radius:4px;flex:0 0 auto;text-transform:uppercase;letter-spacing:.04em}
|
|
1152
|
+
.kind.function{background:#1f6feb33;color:var(--accent)}
|
|
1153
|
+
.kind.method{background:#bc8cff22;color:var(--purple)}
|
|
1154
|
+
.kind.constructor{background:#d2992222;color:var(--yellow)}
|
|
1155
|
+
.kind.module{background:#8b949e22;color:var(--muted)}
|
|
1156
|
+
.nm{color:var(--fg);font-weight:500;flex:0 0 auto}
|
|
1157
|
+
.async{color:var(--yellow);font-size:11px;flex:0 0 auto}
|
|
1158
|
+
.desc{color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis}
|
|
1159
|
+
.loc{color:#6e7681;font-size:11px;flex:0 0 auto;margin-left:auto;padding-left:12px}
|
|
1160
|
+
.children{margin-left:16px;border-left:1px solid var(--line);padding-left:4px}
|
|
1161
|
+
.children.collapsed{display:none}
|
|
1162
|
+
.cycle{color:var(--yellow);font-size:11px}
|
|
1163
|
+
.empty{color:var(--muted);font-style:italic;padding:6px}
|
|
1164
|
+
.focus-meta{background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:10px 12px;margin:8px 0 14px}
|
|
1165
|
+
.focus-meta .d{color:var(--muted);margin-top:6px}
|
|
1166
|
+
.fline{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
1167
|
+
.sig{margin:8px 0 4px;padding:8px 10px;background:var(--bg);border:1px solid var(--line);border-radius:6px;color:var(--accent);font-size:12.5px;white-space:pre-wrap;word-break:break-word;overflow-x:auto}
|
|
1168
|
+
table.params{width:100%;border-collapse:collapse;margin:8px 0 4px;font-size:12px}
|
|
1169
|
+
table.params th{text-align:left;color:var(--muted);font-weight:500;padding:3px 8px;border-bottom:1px solid var(--line);text-transform:uppercase;letter-spacing:.04em;font-size:10px}
|
|
1170
|
+
table.params td{padding:3px 8px;border-bottom:1px solid var(--line);vertical-align:top}
|
|
1171
|
+
table.params tr:last-child td{border-bottom:none}
|
|
1172
|
+
.pn{color:var(--fg);white-space:nowrap}
|
|
1173
|
+
.pn .opt{color:var(--yellow)}
|
|
1174
|
+
.pt{color:var(--green);white-space:pre-wrap;word-break:break-word}
|
|
1175
|
+
.pd{color:var(--muted)}
|
|
1176
|
+
.ret{margin:6px 0 2px;font-size:12px}
|
|
1177
|
+
.ret .rk{color:var(--muted);text-transform:uppercase;letter-spacing:.04em;font-size:10px;margin-right:6px}
|
|
1178
|
+
.codewrap{margin-top:10px}
|
|
1179
|
+
.codebtn{background:var(--panel2);border:1px solid var(--line);color:var(--accent);border-radius:6px;padding:4px 10px;cursor:pointer;font:inherit;font-size:12px}
|
|
1180
|
+
.codebtn:hover{border-color:var(--accent)}
|
|
1181
|
+
.code{margin:8px 0 0;padding:10px 12px;background:var(--bg);border:1px solid var(--line);border-radius:6px;font-size:12px;line-height:1.45;overflow:auto;max-height:480px;white-space:pre;tab-size:2}
|
|
1182
|
+
.code.hidden{display:none}
|
|
1183
|
+
.focus-cols{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
|
1184
|
+
@media(max-width:820px){.focus-cols{grid-template-columns:1fr}}
|
|
1185
|
+
.focus-col h3{font-size:12px;font-weight:600;margin:0 0 8px;color:var(--fg)}
|
|
1186
|
+
.muted{color:var(--muted);font-weight:400}
|
|
1187
|
+
mark{background:#d2992255;color:#fff;border-radius:2px;padding:0 1px}
|
|
1188
|
+
`;
|
|
1189
|
+
var JS = String.raw`
|
|
1190
|
+
const GRAPH = JSON.parse(document.getElementById('graph-data').textContent);
|
|
1191
|
+
const byId = new Map(GRAPH.nodes.map(n => [n.id, n]));
|
|
1192
|
+
const callees = new Map(), callers = new Map();
|
|
1193
|
+
for (const n of GRAPH.nodes){ callees.set(n.id, []); callers.set(n.id, []); }
|
|
1194
|
+
for (const e of GRAPH.edges){
|
|
1195
|
+
if (callees.has(e.from)) callees.get(e.from).push(e.to);
|
|
1196
|
+
if (callers.has(e.to)) callers.get(e.to).push(e.from);
|
|
1197
|
+
}
|
|
1198
|
+
for (const m of [callees, callers]) for (const [k,v] of m) m.set(k, [...new Set(v)].sort((a,b)=>label(a).localeCompare(label(b))));
|
|
1199
|
+
|
|
1200
|
+
document.getElementById('stat-fn').textContent = GRAPH.nodes.length;
|
|
1201
|
+
document.getElementById('stat-edge').textContent = GRAPH.edges.length;
|
|
1202
|
+
|
|
1203
|
+
function label(id){ const n = byId.get(id); return n ? n.name : id; }
|
|
1204
|
+
function esc(s){ return s.replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
|
1205
|
+
|
|
1206
|
+
// Build one tree row. \`dir\` is 'down' (callees) or 'up' (callers). \`path\` is the
|
|
1207
|
+
// set of ancestor ids on the current branch, to detect and stop cycles.
|
|
1208
|
+
function makeNode(id, dir, path){
|
|
1209
|
+
const n = byId.get(id);
|
|
1210
|
+
const wrap = document.createElement('div'); wrap.className = 'node';
|
|
1211
|
+
const row = document.createElement('div'); row.className = 'row';
|
|
1212
|
+
const kids = (dir === 'down' ? callees : callers).get(id) || [];
|
|
1213
|
+
const isCycle = path.has(id);
|
|
1214
|
+
const hasKids = kids.length > 0 && !isCycle;
|
|
1215
|
+
|
|
1216
|
+
const tw = document.createElement('span');
|
|
1217
|
+
tw.className = 'tw' + (hasKids ? '' : ' leaf');
|
|
1218
|
+
tw.textContent = hasKids ? '▸' : '•';
|
|
1219
|
+
row.appendChild(tw);
|
|
1220
|
+
|
|
1221
|
+
const kind = document.createElement('span'); kind.className = 'kind ' + n.kind; kind.textContent = n.kind; row.appendChild(kind);
|
|
1222
|
+
const nm = document.createElement('span'); nm.className = 'nm'; nm.textContent = n.name; row.appendChild(nm);
|
|
1223
|
+
if (n.async){ const a = document.createElement('span'); a.className='async'; a.textContent='async'; row.appendChild(a); }
|
|
1224
|
+
if (isCycle){ const c=document.createElement('span'); c.className='cycle'; c.textContent='↻ cycle'; row.appendChild(c); }
|
|
1225
|
+
if (n.description){ const d=document.createElement('span'); d.className='desc'; d.textContent='— '+n.description; row.appendChild(d); }
|
|
1226
|
+
const loc=document.createElement('span'); loc.className='loc'; loc.textContent=n.file+':'+n.line; row.appendChild(loc);
|
|
1227
|
+
wrap.appendChild(row);
|
|
1228
|
+
|
|
1229
|
+
const childBox = document.createElement('div'); childBox.className = 'children collapsed';
|
|
1230
|
+
wrap.appendChild(childBox);
|
|
1231
|
+
let built = false;
|
|
1232
|
+
const toggle = () => {
|
|
1233
|
+
if (!hasKids){ openFocus(id); return; }
|
|
1234
|
+
if (!built){
|
|
1235
|
+
const nextPath = new Set(path); nextPath.add(id);
|
|
1236
|
+
for (const k of kids) childBox.appendChild(makeNode(k, dir, nextPath));
|
|
1237
|
+
built = true;
|
|
1238
|
+
}
|
|
1239
|
+
const collapsed = childBox.classList.toggle('collapsed');
|
|
1240
|
+
tw.textContent = collapsed ? '▸' : '▾';
|
|
1241
|
+
};
|
|
1242
|
+
row.addEventListener('click', (e) => { if (e.metaKey || e.ctrlKey){ openFocus(id); return; } toggle(); });
|
|
1243
|
+
row._expand = () => { if (hasKids && childBox.classList.contains('collapsed')) toggle(); };
|
|
1244
|
+
return wrap;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function renderTree(container, ids, dir){
|
|
1248
|
+
container.innerHTML = '';
|
|
1249
|
+
if (!ids.length){ container.innerHTML = '<div class="empty">none</div>'; return; }
|
|
1250
|
+
for (const id of ids) container.appendChild(makeNode(id, dir, new Set()));
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
const treeEl = document.getElementById('tree');
|
|
1254
|
+
renderTree(treeEl, GRAPH.roots.length ? GRAPH.roots : GRAPH.nodes.map(n=>n.id), 'down');
|
|
1255
|
+
|
|
1256
|
+
document.getElementById('btn-expand').onclick = () => {
|
|
1257
|
+
// Expand a bounded number of levels to avoid runaway DOM on huge graphs.
|
|
1258
|
+
for (let depth=0; depth<6; depth++){
|
|
1259
|
+
treeEl.querySelectorAll('.row').forEach(r => r._expand && r._expand());
|
|
1260
|
+
}
|
|
1261
|
+
};
|
|
1262
|
+
document.getElementById('btn-collapse').onclick = () => renderTree(treeEl, GRAPH.roots.length ? GRAPH.roots : GRAPH.nodes.map(n=>n.id), 'down');
|
|
1263
|
+
|
|
1264
|
+
// ---- Focus view: ancestors + descendants of one function ----
|
|
1265
|
+
function openFocus(id){
|
|
1266
|
+
const n = byId.get(id);
|
|
1267
|
+
document.getElementById('pane-tree').classList.add('hidden');
|
|
1268
|
+
const pane = document.getElementById('pane-focus'); pane.classList.remove('hidden');
|
|
1269
|
+
document.getElementById('focus-title').textContent = n.name;
|
|
1270
|
+
const m = document.getElementById('focus-meta');
|
|
1271
|
+
const cers = (callers.get(id)||[]).length, cees = (callees.get(id)||[]).length;
|
|
1272
|
+
|
|
1273
|
+
let html = '<div class="fline"><span class="kind '+n.kind+'">'+n.kind+'</span>'
|
|
1274
|
+
+ (n.async?' <span class="async">async</span>':'')
|
|
1275
|
+
+ ' <span class="loc">'+esc(n.file)+':'+n.line+'</span>'
|
|
1276
|
+
+ ' · '+cers+' caller(s), '+cees+' callee(s)</div>';
|
|
1277
|
+
if (n.signature) html += '<pre class="sig">'+esc(n.signature)+'</pre>';
|
|
1278
|
+
html += n.description ? '<div class="d">'+esc(n.description)+'</div>'
|
|
1279
|
+
: '<div class="d muted">no written description — signature derived from types</div>';
|
|
1280
|
+
|
|
1281
|
+
// Parameter table: every param shows its resolved type; the doc column is
|
|
1282
|
+
// filled from JSDoc @param when the author wrote one.
|
|
1283
|
+
if (n.params && n.params.length){
|
|
1284
|
+
html += '<table class="params"><thead><tr><th>param</th><th>type</th><th>notes</th></tr></thead><tbody>'
|
|
1285
|
+
+ n.params.map(p =>
|
|
1286
|
+
'<tr><td class="pn">'+esc(p.name)+(p.optional?'<span class="opt">?</span>':'')+'</td>'
|
|
1287
|
+
+ '<td class="pt">'+esc(p.type||'any')+'</td>'
|
|
1288
|
+
+ '<td class="pd">'+(p.doc?esc(p.doc):'<span class="muted">—</span>')+'</td></tr>').join('')
|
|
1289
|
+
+ '</tbody></table>';
|
|
1290
|
+
}
|
|
1291
|
+
if (n.returns){
|
|
1292
|
+
html += '<div class="ret"><span class="rk">returns</span> <span class="pt">'+esc(n.returns)+'</span>'
|
|
1293
|
+
+ (n.returnDoc?' <span class="pd">— '+esc(n.returnDoc)+'</span>':'')+'</div>';
|
|
1294
|
+
}
|
|
1295
|
+
// View-code toggle: reveals the function's own source, fetched from nothing —
|
|
1296
|
+
// it's embedded in the graph data.
|
|
1297
|
+
if (n.code){
|
|
1298
|
+
html += '<div class="codewrap"><button id="btn-code" class="codebtn">▸ view code</button>'
|
|
1299
|
+
+ '<pre id="codeblock" class="code hidden"></pre></div>';
|
|
1300
|
+
}
|
|
1301
|
+
m.innerHTML = html;
|
|
1302
|
+
if (n.code){
|
|
1303
|
+
const btn = document.getElementById('btn-code'), block = document.getElementById('codeblock');
|
|
1304
|
+
block.textContent = n.code;
|
|
1305
|
+
btn.onclick = () => {
|
|
1306
|
+
const hidden = block.classList.toggle('hidden');
|
|
1307
|
+
btn.textContent = (hidden?'▸':'▾') + ' view code';
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
renderTree(document.getElementById('ancestors'), callers.get(id)||[], 'up');
|
|
1311
|
+
renderTree(document.getElementById('descendants'), callees.get(id)||[], 'down');
|
|
1312
|
+
window.scrollTo({top:0,behavior:'smooth'});
|
|
1313
|
+
}
|
|
1314
|
+
document.getElementById('btn-back').onclick = () => {
|
|
1315
|
+
document.getElementById('pane-focus').classList.add('hidden');
|
|
1316
|
+
document.getElementById('pane-tree').classList.remove('hidden');
|
|
1317
|
+
};
|
|
1318
|
+
|
|
1319
|
+
// ---- Search ----
|
|
1320
|
+
const q = document.getElementById('q'), results = document.getElementById('results');
|
|
1321
|
+
let active = -1, hits = [];
|
|
1322
|
+
function search(term){
|
|
1323
|
+
term = term.trim().toLowerCase();
|
|
1324
|
+
if (!term){ results.classList.remove('open'); hits=[]; return; }
|
|
1325
|
+
hits = GRAPH.nodes.filter(n => n.name.toLowerCase().includes(term) || n.file.toLowerCase().includes(term)).slice(0,50);
|
|
1326
|
+
active = hits.length ? 0 : -1;
|
|
1327
|
+
results.innerHTML = hits.map((n,i) =>
|
|
1328
|
+
'<div class="result'+(i===active?' active':'')+'" data-id="'+esc(n.id)+'">'
|
|
1329
|
+
+ '<span class="nm">'+hl(n.name,term)+'</span><span class="fl">'+esc(n.file)+':'+n.line+'</span></div>').join('')
|
|
1330
|
+
|| '<div class="result"><span class="fl">no matches</span></div>';
|
|
1331
|
+
results.classList.add('open');
|
|
1332
|
+
results.querySelectorAll('.result[data-id]').forEach(el =>
|
|
1333
|
+
el.onclick = () => { pick(el.getAttribute('data-id')); });
|
|
1334
|
+
}
|
|
1335
|
+
function hl(s,t){ const i=s.toLowerCase().indexOf(t); if(i<0)return esc(s); return esc(s.slice(0,i))+'<mark>'+esc(s.slice(i,i+t.length))+'</mark>'+esc(s.slice(i+t.length)); }
|
|
1336
|
+
function pick(id){ results.classList.remove('open'); q.value=''; openFocus(id); }
|
|
1337
|
+
q.addEventListener('input', () => search(q.value));
|
|
1338
|
+
q.addEventListener('keydown', (e) => {
|
|
1339
|
+
if (!results.classList.contains('open')) return;
|
|
1340
|
+
if (e.key==='ArrowDown'){ e.preventDefault(); active=Math.min(active+1,hits.length-1); search(q.value); }
|
|
1341
|
+
else if (e.key==='ArrowUp'){ e.preventDefault(); active=Math.max(active-1,0); search(q.value); }
|
|
1342
|
+
else if (e.key==='Enter'){ e.preventDefault(); if(hits[active]) pick(hits[active].id); }
|
|
1343
|
+
else if (e.key==='Escape'){ results.classList.remove('open'); }
|
|
1344
|
+
});
|
|
1345
|
+
document.addEventListener('click', (e) => { if(!e.target.closest('.search')) results.classList.remove('open'); });
|
|
1346
|
+
document.addEventListener('keydown', (e) => { if(e.key==='/' && document.activeElement!==q){ e.preventDefault(); q.focus(); } });
|
|
1347
|
+
`;
|
|
1348
|
+
|
|
1349
|
+
// src/ui/json.ts
|
|
1350
|
+
var SCHEMA = "checkpointer/v1";
|
|
1351
|
+
function okEnvelope(data) {
|
|
1352
|
+
return { schema: SCHEMA, ok: true, data };
|
|
1353
|
+
}
|
|
1354
|
+
function errEnvelope(message, code) {
|
|
1355
|
+
return { schema: SCHEMA, ok: false, error: { message, code } };
|
|
1356
|
+
}
|
|
1357
|
+
function writeJson(value) {
|
|
1358
|
+
process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
1359
|
+
}
|
|
1360
|
+
|
|
592
1361
|
// src/actions.ts
|
|
593
1362
|
function parseStatus(value, fallback) {
|
|
594
1363
|
if (!value) return fallback;
|
|
@@ -599,11 +1368,18 @@ function parseStatus(value, fallback) {
|
|
|
599
1368
|
}
|
|
600
1369
|
function emit(json2, data, render) {
|
|
601
1370
|
if (json2) {
|
|
602
|
-
|
|
1371
|
+
writeJson(okEnvelope(data));
|
|
603
1372
|
} else {
|
|
604
1373
|
render();
|
|
605
1374
|
}
|
|
606
1375
|
}
|
|
1376
|
+
function warnNestedRepos(nested) {
|
|
1377
|
+
if (!nested?.length) return;
|
|
1378
|
+
process.stderr.write(
|
|
1379
|
+
pc2.yellow(`warning: ${nested.length} nested git repo(s) cannot be checkpointed and were skipped:
|
|
1380
|
+
`) + nested.map((p) => ` - ${p}`).join("\n") + "\n" + pc2.dim(" their contents are left on disk untouched, but a restore will not recreate them\n")
|
|
1381
|
+
);
|
|
1382
|
+
}
|
|
607
1383
|
function init(opts = {}) {
|
|
608
1384
|
const root = resolveRoot();
|
|
609
1385
|
requireGitRepo(root);
|
|
@@ -629,9 +1405,19 @@ function save(name, opts) {
|
|
|
629
1405
|
name,
|
|
630
1406
|
message: opts.message,
|
|
631
1407
|
status: parseStatus(opts.status, "wip"),
|
|
1408
|
+
allowEmpty: opts.allowEmpty,
|
|
632
1409
|
...id
|
|
633
1410
|
});
|
|
634
|
-
|
|
1411
|
+
const noop = cp.noop ?? false;
|
|
1412
|
+
const nestedRepos = cp.nestedRepos ?? [];
|
|
1413
|
+
warnNestedRepos(cp.nestedRepos);
|
|
1414
|
+
emit(opts.json ?? false, { ...cp, noop, nestedRepos }, () => {
|
|
1415
|
+
if (noop) {
|
|
1416
|
+
console.log(
|
|
1417
|
+
`${dim("\xB7 no change")} \u2014 reusing ${pc2.cyan(`#${cp.seq}`)} ${pc2.bold(cp.name)} ${dim("(pass --allow-empty to force)")}`
|
|
1418
|
+
);
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
635
1421
|
console.log(`${ok("\u2713 saved")} ${pc2.cyan(`#${cp.seq}`)} ${pc2.bold(cp.name)} ${statusBadge(cp.status)}`);
|
|
636
1422
|
if (cp.intent) console.log(dim(` intent: ${cp.intent}`));
|
|
637
1423
|
});
|
|
@@ -692,11 +1478,39 @@ function restore(ref, opts = {}) {
|
|
|
692
1478
|
const target = store.meta.require(ref);
|
|
693
1479
|
const id = resolveIdentity(store.paths.root);
|
|
694
1480
|
const safety = store.restore(target, id.author);
|
|
695
|
-
|
|
1481
|
+
warnNestedRepos(safety.nestedRepos);
|
|
1482
|
+
emit(opts.json ?? false, { restored: target.name, safety: safety.name, nestedRepos: safety.nestedRepos ?? [] }, () => {
|
|
696
1483
|
console.log(`${ok("\u2713 restored")} working tree to ${pc2.bold(target.name)} ${pc2.cyan(`#${target.seq}`)}`);
|
|
697
1484
|
console.log(dim(` current state was auto-saved as '${safety.name}' (#${safety.seq}) \u2014 restore it to undo`));
|
|
698
1485
|
});
|
|
699
1486
|
}
|
|
1487
|
+
function changed(ref, opts = {}) {
|
|
1488
|
+
const root = resolveRoot();
|
|
1489
|
+
requireGitRepo(root);
|
|
1490
|
+
if (!Store.isInitialized(root)) {
|
|
1491
|
+
emit(
|
|
1492
|
+
opts.json ?? false,
|
|
1493
|
+
{ changed: true, against: null, fileCount: 0, files: [] },
|
|
1494
|
+
() => console.log(`${warn("changed")} ${dim("(no checkpoints yet)")}`)
|
|
1495
|
+
);
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
const store = Store.open(root);
|
|
1499
|
+
const { changed: changed2, against, files } = store.changed(ref);
|
|
1500
|
+
emit(
|
|
1501
|
+
opts.json ?? false,
|
|
1502
|
+
{ changed: changed2, against: against ? { seq: against.seq, name: against.name } : null, fileCount: files.length, files },
|
|
1503
|
+
() => {
|
|
1504
|
+
if (changed2) {
|
|
1505
|
+
const since = against ? ` since ${pc2.bold(against.name)}` : "";
|
|
1506
|
+
console.log(`${warn("changed")}${dim(since)} ${dim(`(${files.length} file(s))`)}`);
|
|
1507
|
+
} else {
|
|
1508
|
+
console.log(`${dim("unchanged")}${against ? dim(` since ${against.name}`) : ""}`);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
);
|
|
1512
|
+
if (!opts.json && !changed2) process.exitCode = 100;
|
|
1513
|
+
}
|
|
700
1514
|
function tag(ref, status2, opts = {}) {
|
|
701
1515
|
const store = Store.open();
|
|
702
1516
|
const cp = store.meta.require(ref);
|
|
@@ -776,6 +1590,33 @@ function renderPlan(plan) {
|
|
|
776
1590
|
console.log(dim("\n" + (plan.diffStat || "(no changes)")));
|
|
777
1591
|
console.log(dim('\nrun without --dry-run and with -m "message" to commit'));
|
|
778
1592
|
}
|
|
1593
|
+
async function graph(opts) {
|
|
1594
|
+
const root = opts.root ? resolve(opts.root) : resolveRoot();
|
|
1595
|
+
const g = analyzeProject(root);
|
|
1596
|
+
if (opts.json) {
|
|
1597
|
+
writeJson(okEnvelope(g));
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
const html = renderGraphHtml(g, { generatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
1601
|
+
if (opts.out) {
|
|
1602
|
+
const file = resolve(opts.out);
|
|
1603
|
+
writeFileSync4(file, html);
|
|
1604
|
+
console.log(`${ok("\u2713 wrote graph")} ${pc2.bold(file)}`);
|
|
1605
|
+
console.log(dim(` ${g.nodes.length} function(s), ${g.edges.length} call edge(s) across ${g.fileCount} file(s)`));
|
|
1606
|
+
console.log(dim(` open it in any browser \u2014 it's fully self-contained`));
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
const port = opts.port !== void 0 ? Number(opts.port) : void 0;
|
|
1610
|
+
if (port !== void 0 && (!Number.isInteger(port) || port < 0 || port > 65535)) {
|
|
1611
|
+
fail(`invalid --port '${opts.port}' (expected 0\u201365535)`, "bad_path");
|
|
1612
|
+
}
|
|
1613
|
+
const { url } = await serveGraph(html, { port, open: opts.open });
|
|
1614
|
+
console.log(`${ok("\u2713 function graph")} for ${pc2.bold(root)}`);
|
|
1615
|
+
console.log(` ${g.nodes.length} function(s), ${g.edges.length} call edge(s) across ${g.fileCount} file(s)`);
|
|
1616
|
+
console.log(` serving at ${pc2.cyan(url)}`);
|
|
1617
|
+
console.log(dim(opts.open === false ? " open the URL in your browser" : " opened in your browser"));
|
|
1618
|
+
console.log(dim(" press Ctrl-C to stop"));
|
|
1619
|
+
}
|
|
779
1620
|
function info(opts = {}) {
|
|
780
1621
|
const root = resolveRoot();
|
|
781
1622
|
const store = Store.isInitialized(root) ? Store.open(root) : null;
|
|
@@ -796,28 +1637,28 @@ function info(opts = {}) {
|
|
|
796
1637
|
}
|
|
797
1638
|
|
|
798
1639
|
// src/skill.ts
|
|
799
|
-
import { cpSync, existsSync as
|
|
1640
|
+
import { cpSync, existsSync as existsSync5, mkdirSync as mkdirSync4, rmSync } from "fs";
|
|
800
1641
|
import { homedir as homedir2 } from "os";
|
|
801
|
-
import { dirname as dirname2, join as
|
|
1642
|
+
import { dirname as dirname2, join as join4 } from "path";
|
|
802
1643
|
import { fileURLToPath } from "url";
|
|
803
1644
|
function packagedSkillDir() {
|
|
804
1645
|
const here = dirname2(fileURLToPath(import.meta.url));
|
|
805
1646
|
const candidates = [
|
|
806
|
-
|
|
807
|
-
|
|
1647
|
+
join4(here, "..", "skills", "checkpointer"),
|
|
1648
|
+
join4(here, "..", "..", "skills", "checkpointer")
|
|
808
1649
|
];
|
|
809
|
-
const found = candidates.find((dir) =>
|
|
810
|
-
if (!found) fail("bundled skill not found \u2014 reinstall the package");
|
|
1650
|
+
const found = candidates.find((dir) => existsSync5(join4(dir, "SKILL.md")));
|
|
1651
|
+
if (!found) fail("bundled skill not found \u2014 reinstall the package", "skill_missing");
|
|
811
1652
|
return found;
|
|
812
1653
|
}
|
|
813
1654
|
function installSkill(opts = {}) {
|
|
814
1655
|
const source = packagedSkillDir();
|
|
815
|
-
const target = opts.dir ??
|
|
1656
|
+
const target = opts.dir ?? join4(homedir2(), ".claude", "skills", "checkpointer");
|
|
816
1657
|
mkdirSync4(dirname2(target), { recursive: true });
|
|
817
1658
|
rmSync(target, { recursive: true, force: true });
|
|
818
1659
|
cpSync(source, target, { recursive: true });
|
|
819
1660
|
if (opts.json) {
|
|
820
|
-
|
|
1661
|
+
writeJson(okEnvelope({ installed: true, target }));
|
|
821
1662
|
return;
|
|
822
1663
|
}
|
|
823
1664
|
console.log(`${ok("\u2713 installed")} checkpointer skill to ${target}`);
|
|
@@ -826,14 +1667,14 @@ function installSkill(opts = {}) {
|
|
|
826
1667
|
function skillPath(opts = {}) {
|
|
827
1668
|
const source = packagedSkillDir();
|
|
828
1669
|
if (opts.json) {
|
|
829
|
-
|
|
1670
|
+
writeJson(okEnvelope({ path: source }));
|
|
830
1671
|
return;
|
|
831
1672
|
}
|
|
832
1673
|
console.log(source);
|
|
833
1674
|
}
|
|
834
1675
|
|
|
835
1676
|
// src/cli.ts
|
|
836
|
-
var VERSION = "0.1.0";
|
|
1677
|
+
var VERSION = true ? "0.2.1" : "0.0.0-dev";
|
|
837
1678
|
var program = new Command();
|
|
838
1679
|
program.name("checkpointer").description(
|
|
839
1680
|
"Git-isolated checkpoints that humans and AI agents share.\n\nSave working states during a long task, revert when something breaks,\nthen bundle a range of checkpoints into one clean commit on your repo.\nYour project's real git history is never touched until you 'ship'."
|
|
@@ -858,12 +1699,16 @@ Docs: https://github.com/checkpointer/checkpointer`
|
|
|
858
1699
|
);
|
|
859
1700
|
var json = ["--json", "output machine-readable JSON"];
|
|
860
1701
|
program.command("init").description("initialize checkpointing for this project").option(...json).action((opts) => init(opts));
|
|
861
|
-
program.command("save [name]").description("snapshot the working tree as a checkpoint").option("-m, --message <text>", "describe what this checkpoint contains").option("-s, --status <status>", "working | wip | broken (default: wip)").option("--intent <text>", "why this checkpoint is being made (useful for agents)").option("--author <name>", "override the author").option("--agent <name>", "agent making the checkpoint (or set CHECKPOINTER_AGENT)").option("--session <id>", "agent session id (or set CHECKPOINTER_SESSION)").option(...json).addHelpText(
|
|
1702
|
+
program.command("save [name]").description("snapshot the working tree as a checkpoint").option("-m, --message <text>", "describe what this checkpoint contains").option("-s, --status <status>", "working | wip | broken (default: wip)").option("--intent <text>", "why this checkpoint is being made (useful for agents)").option("--allow-empty", "save even if nothing changed since the latest checkpoint").option("--author <name>", "override the author").option("--agent <name>", "agent making the checkpoint (or set CHECKPOINTER_AGENT)").option("--session <id>", "agent session id (or set CHECKPOINTER_SESSION)").option(...json).addHelpText(
|
|
862
1703
|
"after",
|
|
863
1704
|
'\nExamples:\n $ checkpointer save\n $ checkpointer save before-refactor --intent "extracting auth module"\n $ checkpointer save v1 --status working -m "first working version"'
|
|
864
1705
|
).action((name, opts) => save(name, opts));
|
|
865
1706
|
program.command("list").alias("ls").description("list all checkpoints").option(...json).action((opts) => list(opts));
|
|
866
1707
|
program.command("status").description("show how many checkpoints are unshipped").option(...json).action((opts) => status(opts));
|
|
1708
|
+
program.command("changed [id]").description("probe whether the working tree differs from a checkpoint (exit 100 if unchanged)").option(...json).addHelpText(
|
|
1709
|
+
"after",
|
|
1710
|
+
"\nExit codes: 0 = changed, 100 = unchanged. With --json it always exits 0;\nread the 'changed' field instead. Defaults to the latest checkpoint.\n\nExamples:\n $ checkpointer changed && checkpointer save # save only when something changed\n $ checkpointer changed login --json"
|
|
1711
|
+
).action((id, opts) => changed(id, opts));
|
|
867
1712
|
program.command("show <id>").description("show details and tracked files of a checkpoint").option(...json).action((id, opts) => show(id, opts));
|
|
868
1713
|
program.command("diff <a> [b]").description("diff two checkpoints, or one checkpoint vs the working tree").option(...json).addHelpText(
|
|
869
1714
|
"after",
|
|
@@ -882,6 +1727,10 @@ Examples:
|
|
|
882
1727
|
$ checkpointer ship -m "Add login flow"
|
|
883
1728
|
$ checkpointer ship --upto v1 -m "Ship first version"`
|
|
884
1729
|
).action((opts) => ship(opts));
|
|
1730
|
+
program.command("graph").description("visualize the project's functions and their call graph in a browser").option("--out <file>", "write a standalone HTML file instead of serving it").option("--port <n>", "port to serve on (default: a free ephemeral port)").option("--no-open", "don't auto-open the browser; just print the URL").option("--root <path>", "project to analyze (default: current git repo)").option(...json).addHelpText(
|
|
1731
|
+
"after",
|
|
1732
|
+
"\nBuilds a top-down call tree from a TypeScript/JavaScript project: click a\nfunction to expand what it calls, search any function, and view its\nancestors (callers up to entrypoints) and descendants.\n\nExamples:\n $ checkpointer graph serve + open in browser\n $ checkpointer graph --no-open print the localhost URL only\n $ checkpointer graph --out graph.html save a shareable file\n $ checkpointer graph --json emit the raw graph data"
|
|
1733
|
+
).action((opts) => graph(opts));
|
|
885
1734
|
program.command("info").description("show storage location and excluded paths").option(...json).action((opts) => info(opts));
|
|
886
1735
|
var skill = program.command("skill").description("manage the Claude / agent skill");
|
|
887
1736
|
skill.command("install").description("install the skill into ~/.claude/skills so Claude Code can use checkpointer").option("--dir <path>", "install to a custom directory").option(...json).action((opts) => installSkill(opts));
|
|
@@ -892,11 +1741,11 @@ async function main() {
|
|
|
892
1741
|
} catch (error) {
|
|
893
1742
|
if (error instanceof CheckpointerError) {
|
|
894
1743
|
if (process.argv.includes("--json")) {
|
|
895
|
-
|
|
1744
|
+
writeJson(errEnvelope(error.message, error.code));
|
|
896
1745
|
} else {
|
|
897
1746
|
process.stderr.write(pc3.red("error: ") + error.message + "\n");
|
|
898
1747
|
}
|
|
899
|
-
process.exit(
|
|
1748
|
+
process.exit(exitCodeFor(error.code));
|
|
900
1749
|
}
|
|
901
1750
|
throw error;
|
|
902
1751
|
}
|