@indigo-gr/claude-code-station 0.3.3 → 0.5.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/CHANGELOG.md +71 -0
- package/LICENSE +1 -1
- package/README.md +89 -5
- package/bin/ccs +739 -30
- package/bin/ccs-config-edit.ts +431 -0
- package/bin/ccs-config.ts +324 -44
- package/bin/ccs-delete.sh +2 -1
- package/bin/ccs-doctor.ts +432 -0
- package/bin/ccs-groups.ts +167 -0
- package/bin/ccs-init.ts +58 -14
- package/bin/ccs-list-json.ts +124 -0
- package/bin/ccs-list.ts +121 -10
- package/bin/ccs-machine.ts +467 -0
- package/bin/ccs-open.ts +304 -0
- package/bin/ccs-preview-session.ts +14 -5
- package/bin/ccs-preview.ts +75 -6
- package/bin/ccs-repo.ts +433 -0
- package/bin/ccs-sanitize.ts +18 -4
- package/bin/ccs-scan.ts +81 -10
- package/bin/ccs-sessions.ts +174 -0
- package/bin/ccs-tsx.sh +16 -0
- package/bin/ccs-update-check.ts +131 -0
- package/bin/ccs-utils.ts +5 -0
- package/bin/package.json +3 -0
- package/docs/design/machine-interface.md +860 -0
- package/docs/design/repos-yml-schema.md +225 -76
- package/docs/v0.2.0-review-notes.md +1 -1
- package/docs/v0.2.1-backlog.md +2 -2
- package/package.json +4 -3
package/bin/ccs-scan.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { execFile } from "node:child_process";
|
|
17
17
|
import { promisify } from "node:util";
|
|
18
|
-
import { open, readdir, stat } from "node:fs/promises";
|
|
18
|
+
import { open, readdir, realpath, stat } from "node:fs/promises";
|
|
19
19
|
import {
|
|
20
20
|
closeSync,
|
|
21
21
|
existsSync,
|
|
@@ -28,6 +28,7 @@ import { join } from "node:path";
|
|
|
28
28
|
import type Database from "better-sqlite3";
|
|
29
29
|
|
|
30
30
|
import { loadConfig, getPaths, type RepoEntry } from "./ccs-config.ts";
|
|
31
|
+
import { groupsToMeta, readLogicalGroups } from "./ccs-groups.ts";
|
|
31
32
|
import {
|
|
32
33
|
openDb,
|
|
33
34
|
upsertRepo,
|
|
@@ -40,6 +41,7 @@ import { maskSecrets } from "./ccs-secrets.ts";
|
|
|
40
41
|
import { stripControlChars } from "./ccs-sanitize.ts";
|
|
41
42
|
import { nowIso, type UiMode } from "./ccs-utils.ts";
|
|
42
43
|
import { scanSessions } from "./ccs-scan-sessions.ts";
|
|
44
|
+
import { pathToFileURL } from "node:url";
|
|
43
45
|
|
|
44
46
|
const execFileP = promisify(execFile);
|
|
45
47
|
|
|
@@ -52,6 +54,12 @@ export interface ScanOptions {
|
|
|
52
54
|
ttlSeconds?: number;
|
|
53
55
|
parallelism?: number;
|
|
54
56
|
scanSessions?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* The machine interface's scan (`ccs sessions`): repos.yml is read without
|
|
59
|
+
* scaffolding (no template, no README; only the cache dir) and its
|
|
60
|
+
* warnings are not printed.
|
|
61
|
+
*/
|
|
62
|
+
quietConfig?: boolean;
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
export interface ScanResult {
|
|
@@ -65,6 +73,8 @@ export interface ScanResult {
|
|
|
65
73
|
lockSkipped?: boolean;
|
|
66
74
|
/** repos.yml `defaults.ui` as loaded by this run (set even when lock-skipped). */
|
|
67
75
|
ui?: UiMode;
|
|
76
|
+
/** repos.yml `defaults.update_check` (set even when lock-skipped). */
|
|
77
|
+
updateCheck?: boolean;
|
|
68
78
|
}
|
|
69
79
|
|
|
70
80
|
// ---------------------------------------------------------------------------
|
|
@@ -81,12 +91,25 @@ const PREVIEW_FILE_LIMIT = 10;
|
|
|
81
91
|
// Utilities
|
|
82
92
|
// ---------------------------------------------------------------------------
|
|
83
93
|
|
|
94
|
+
const SAFE_GIT_ARGS = [
|
|
95
|
+
"--no-optional-locks",
|
|
96
|
+
"-c",
|
|
97
|
+
"diff.autoRefreshIndex=false",
|
|
98
|
+
"-c",
|
|
99
|
+
"core.fsmonitor=false",
|
|
100
|
+
"-c",
|
|
101
|
+
"core.hooksPath=/dev/null",
|
|
102
|
+
];
|
|
103
|
+
|
|
84
104
|
async function runGit(
|
|
85
105
|
cwd: string,
|
|
86
106
|
args: string[],
|
|
87
107
|
): Promise<{ stdout: string; stderr: string } | null> {
|
|
88
108
|
try {
|
|
89
|
-
|
|
109
|
+
// A repo's own .git/config can name programs. These are the ones a scan
|
|
110
|
+
// would run: an fsmonitor, and the post-index-change hook when status or
|
|
111
|
+
// diff rewrites the index. A scan only reads, so neither rewrites it.
|
|
112
|
+
const res = await execFileP("git", [...SAFE_GIT_ARGS, ...args], {
|
|
90
113
|
cwd,
|
|
91
114
|
timeout: GIT_TIMEOUT_MS,
|
|
92
115
|
maxBuffer: 4 * 1024 * 1024,
|
|
@@ -216,12 +239,24 @@ async function readFirstLine(filePath: string): Promise<string | null> {
|
|
|
216
239
|
}
|
|
217
240
|
}
|
|
218
241
|
|
|
242
|
+
/** A symlink is followed only when it ends up inside the repo. */
|
|
243
|
+
async function realPathInside(path: string, realRoot: string): Promise<string | null> {
|
|
244
|
+
const real = await realpath(path);
|
|
245
|
+
return real === realRoot || real.startsWith(`${realRoot}/`) ? real : null;
|
|
246
|
+
}
|
|
247
|
+
|
|
219
248
|
async function listDirPreview(
|
|
220
249
|
dirPath: string,
|
|
250
|
+
repoPath: string,
|
|
221
251
|
): Promise<{ count: number; previews: PreviewFile[] }> {
|
|
222
252
|
if (!existsSync(dirPath)) return { count: 0, previews: [] };
|
|
223
253
|
let entries: string[];
|
|
254
|
+
let realRoot: string;
|
|
224
255
|
try {
|
|
256
|
+
realRoot = await realpath(repoPath);
|
|
257
|
+
if ((await realPathInside(dirPath, realRoot)) === null) {
|
|
258
|
+
return { count: 0, previews: [] };
|
|
259
|
+
}
|
|
225
260
|
entries = await readdir(dirPath);
|
|
226
261
|
} catch {
|
|
227
262
|
return { count: 0, previews: [] };
|
|
@@ -230,8 +265,9 @@ async function listDirPreview(
|
|
|
230
265
|
|
|
231
266
|
const results = await Promise.all(
|
|
232
267
|
visible.map(async (name): Promise<PreviewFile | null> => {
|
|
233
|
-
const full = join(dirPath, name);
|
|
234
268
|
try {
|
|
269
|
+
const full = await realPathInside(join(dirPath, name), realRoot);
|
|
270
|
+
if (full === null) return null;
|
|
235
271
|
const st = await stat(full);
|
|
236
272
|
if (!st.isFile()) return null;
|
|
237
273
|
const line = await readFirstLine(full);
|
|
@@ -391,11 +427,11 @@ async function scanOneRepo(
|
|
|
391
427
|
}
|
|
392
428
|
|
|
393
429
|
// --- Workspace dirs ---
|
|
394
|
-
const handoff = await listDirPreview(join(repo.path, "handoff"));
|
|
430
|
+
const handoff = await listDirPreview(join(repo.path, "handoff"), repo.path);
|
|
395
431
|
stats.handoff_count = handoff.count;
|
|
396
432
|
handoffPreviews = handoff.previews;
|
|
397
433
|
|
|
398
|
-
const pending = await listDirPreview(join(repo.path, "pendings"));
|
|
434
|
+
const pending = await listDirPreview(join(repo.path, "pendings"), repo.path);
|
|
399
435
|
stats.pending_count = pending.count;
|
|
400
436
|
pendingPreviews = pending.previews;
|
|
401
437
|
|
|
@@ -626,8 +662,11 @@ export async function scan(opts: ScanOptions = {}): Promise<ScanResult> {
|
|
|
626
662
|
const parallelism = opts.parallelism ?? DEFAULT_PARALLELISM;
|
|
627
663
|
const scanSessionsFlag = opts.scanSessions ?? true;
|
|
628
664
|
|
|
629
|
-
// loadConfig() runs ensureConfigDir(),
|
|
630
|
-
|
|
665
|
+
// loadConfig() runs ensureConfigDir() (or, quiet, creates the cache dir),
|
|
666
|
+
// so cacheDir exists before the lock.
|
|
667
|
+
const config = opts.quietConfig
|
|
668
|
+
? loadConfig({ scaffold: false, sink: () => {} })
|
|
669
|
+
: loadConfig();
|
|
631
670
|
const paths = getPaths();
|
|
632
671
|
|
|
633
672
|
const releaseLock = acquireScanLock(paths.cacheDir);
|
|
@@ -644,10 +683,19 @@ export async function scan(opts: ScanOptions = {}): Promise<ScanResult> {
|
|
|
644
683
|
durationMs: Date.now() - started,
|
|
645
684
|
lockSkipped: true,
|
|
646
685
|
ui: config.defaults.ui,
|
|
686
|
+
updateCheck: config.settings.updateCheck,
|
|
647
687
|
};
|
|
648
688
|
}
|
|
649
689
|
|
|
650
|
-
|
|
690
|
+
// A DB that cannot open must not leave the lock behind: every later scan
|
|
691
|
+
// would say "another scan is in progress" and hide the real error.
|
|
692
|
+
let handle: ReturnType<typeof openDb>;
|
|
693
|
+
try {
|
|
694
|
+
handle = openDb(paths.stateDb);
|
|
695
|
+
} catch (err) {
|
|
696
|
+
releaseLock();
|
|
697
|
+
throw err;
|
|
698
|
+
}
|
|
651
699
|
|
|
652
700
|
try {
|
|
653
701
|
syncReposToDb(handle.db, config.repos);
|
|
@@ -658,6 +706,16 @@ export async function scan(opts: ScanOptions = {}): Promise<ScanResult> {
|
|
|
658
706
|
// repos.yml `defaults.ui`, for the `ccs --no-scan` path (bin/ccs reads it
|
|
659
707
|
// back through `ccs-list --print-ui` when no scan ran this launch).
|
|
660
708
|
setMeta(handle.db, "defaults_ui", config.defaults.ui);
|
|
709
|
+
// repos.yml `defaults.update_check`, read back the same way (`ccs-list
|
|
710
|
+
// --print-settings`) when no scan ran this launch.
|
|
711
|
+
setMeta(handle.db, "defaults_update_check", config.settings.updateCheck ? "on" : "off");
|
|
712
|
+
// defaults.groups_dir: the logical groups, matched to repos.yml by path,
|
|
713
|
+
// for the list to read (an empty list when the setting is off, so
|
|
714
|
+
// removing it clears the headers).
|
|
715
|
+
const groups = config.settings.groupsDir
|
|
716
|
+
? readLogicalGroups(config.settings.groupsDir, config.repos).groups
|
|
717
|
+
: [];
|
|
718
|
+
setMeta(handle.db, "logical_groups", groupsToMeta(groups));
|
|
661
719
|
|
|
662
720
|
// Order matters (audit C-1): scanOneRepo aggregates session counts FROM
|
|
663
721
|
// the sessions table, so sessions must be refreshed first — otherwise
|
|
@@ -686,6 +744,7 @@ export async function scan(opts: ScanOptions = {}): Promise<ScanResult> {
|
|
|
686
744
|
sessionsSkipped,
|
|
687
745
|
durationMs: Date.now() - started,
|
|
688
746
|
ui: config.defaults.ui,
|
|
747
|
+
updateCheck: config.settings.updateCheck,
|
|
689
748
|
};
|
|
690
749
|
} finally {
|
|
691
750
|
handle.close();
|
|
@@ -702,12 +761,14 @@ function parseArgs(argv: string[]): {
|
|
|
702
761
|
noSessions: boolean;
|
|
703
762
|
quiet: boolean;
|
|
704
763
|
printUi: boolean;
|
|
764
|
+
printSettings: boolean;
|
|
705
765
|
} {
|
|
706
766
|
return {
|
|
707
767
|
force: argv.includes("--force"),
|
|
708
768
|
noSessions: argv.includes("--no-sessions"),
|
|
709
769
|
quiet: argv.includes("--quiet"),
|
|
710
770
|
printUi: argv.includes("--print-ui"),
|
|
771
|
+
printSettings: argv.includes("--print-settings"),
|
|
711
772
|
};
|
|
712
773
|
}
|
|
713
774
|
|
|
@@ -733,7 +794,15 @@ async function main(): Promise<{ code: number; out: string }> {
|
|
|
733
794
|
`(${res.sessionsSkipped} cached) in ${secs}s\n`,
|
|
734
795
|
);
|
|
735
796
|
}
|
|
736
|
-
|
|
797
|
+
// --print-settings: `ui=` and `update_check=` (bin/ccs's pre-scan);
|
|
798
|
+
// --print-ui: `ui=` alone, as before.
|
|
799
|
+
let out = "";
|
|
800
|
+
if (args.printSettings && res.ui) {
|
|
801
|
+
out = `ui=${res.ui}\nupdate_check=${res.updateCheck === false ? "off" : "on"}\n`;
|
|
802
|
+
} else if (args.printUi && res.ui) {
|
|
803
|
+
out = `ui=${res.ui}\n`;
|
|
804
|
+
}
|
|
805
|
+
return { code: 0, out };
|
|
737
806
|
} catch (err) {
|
|
738
807
|
process.stderr.write(
|
|
739
808
|
`[ccs-scan] fatal: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
@@ -742,7 +811,9 @@ async function main(): Promise<{ code: number; out: string }> {
|
|
|
742
811
|
}
|
|
743
812
|
}
|
|
744
813
|
|
|
745
|
-
|
|
814
|
+
// pathToFileURL, not `file://${argv[1]}`: a folder with a space (or any
|
|
815
|
+
// character a URL escapes) in its name would never match.
|
|
816
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
746
817
|
main().then(
|
|
747
818
|
({ code, out }) => {
|
|
748
819
|
// Exit only after stdout drained: pipe writes are async on macOS, and
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ccs-sessions.ts — `ccs sessions --json [--project <name>] [--limit <n>]
|
|
3
|
+
* [--with-titles] [--no-scan]`.
|
|
4
|
+
*
|
|
5
|
+
* Past conversations per project, newest first, at most --limit each. Only
|
|
6
|
+
* what a screen needs to offer "resume": the project's name, the time, the
|
|
7
|
+
* UUID to hand to `ccs open --session`, and (only with --with-titles) the
|
|
8
|
+
* first request's one line. Never the summary, the working folder, the
|
|
9
|
+
* branch or the conversation itself. Contract:
|
|
10
|
+
* docs/design/machine-interface.md 2.3.
|
|
11
|
+
*
|
|
12
|
+
* Before reading, the same incremental scan the list runs refreshes the cache
|
|
13
|
+
* (not with --no-scan, not without a repos.yml). Its stderr is held: the
|
|
14
|
+
* scan's lines can name repos and paths.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync } from "node:fs";
|
|
18
|
+
|
|
19
|
+
import { getPaths, readConfig } from "./ccs-config.ts";
|
|
20
|
+
import {
|
|
21
|
+
cliOutcome,
|
|
22
|
+
configFailure,
|
|
23
|
+
okOutcome,
|
|
24
|
+
parseFlags,
|
|
25
|
+
type Outcome,
|
|
26
|
+
} from "./ccs-machine.ts";
|
|
27
|
+
import { maskSecrets } from "./ccs-secrets.ts";
|
|
28
|
+
import { stripControlChars } from "./ccs-sanitize.ts";
|
|
29
|
+
|
|
30
|
+
const LIMIT_DEFAULT = 5;
|
|
31
|
+
const LIMIT_MAX = 50;
|
|
32
|
+
const TITLE_MAX = 80;
|
|
33
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
34
|
+
|
|
35
|
+
/** One line, no control or reordering characters, at most 80 code points (+ …). */
|
|
36
|
+
export function sessionTitle(topic: string | null): string | null {
|
|
37
|
+
if (topic === null) return null;
|
|
38
|
+
const clean = stripControlChars(maskSecrets(stripControlChars(topic)));
|
|
39
|
+
if (clean === "") return null;
|
|
40
|
+
const points = [...clean];
|
|
41
|
+
return points.length > TITLE_MAX ? points.slice(0, TITLE_MAX).join("") + "…" : clean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isoOrNull(value: string | null): string | null {
|
|
45
|
+
if (!value) return null;
|
|
46
|
+
const t = Date.parse(value);
|
|
47
|
+
return Number.isNaN(t) ? null : new Date(t).toISOString();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Run the list's scan in this process with stderr and console.warn held back. */
|
|
51
|
+
async function quietScan(): Promise<boolean> {
|
|
52
|
+
const write = process.stderr.write;
|
|
53
|
+
const warn = console.warn;
|
|
54
|
+
process.stderr.write = (() => true) as typeof process.stderr.write;
|
|
55
|
+
console.warn = () => {};
|
|
56
|
+
try {
|
|
57
|
+
const { scan } = await import("./ccs-scan.ts");
|
|
58
|
+
const res = await scan({ quietConfig: true });
|
|
59
|
+
return !res.lockSkipped;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
} finally {
|
|
63
|
+
process.stderr.write = write;
|
|
64
|
+
console.warn = warn;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface Row {
|
|
69
|
+
uuid: string;
|
|
70
|
+
repo_name: string | null;
|
|
71
|
+
last_activity_at: string | null;
|
|
72
|
+
topic: string | null;
|
|
73
|
+
mapped: number;
|
|
74
|
+
disabled: number | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function sessionsOutcome(args: readonly string[]): Promise<Outcome> {
|
|
78
|
+
const flags = parseFlags(args, {
|
|
79
|
+
booleans: ["--json", "--with-titles", "--no-scan"],
|
|
80
|
+
values: ["--project", "--limit"],
|
|
81
|
+
});
|
|
82
|
+
if (!flags.ok) return cliOutcome(flags.code);
|
|
83
|
+
if (flags.positionals.length > 0 || flags.rest.length > 0) return cliOutcome("extra_argument");
|
|
84
|
+
if (!flags.booleans.has("--json")) {
|
|
85
|
+
return cliOutcome("json_required", {}, "`ccs sessions` needs --json (to pick a conversation by hand, run ccs --resume)");
|
|
86
|
+
}
|
|
87
|
+
let limit = LIMIT_DEFAULT;
|
|
88
|
+
const rawLimit = flags.values.get("--limit");
|
|
89
|
+
if (rawLimit !== undefined) {
|
|
90
|
+
if (!/^[0-9]{1,3}$/.test(rawLimit) || Number(rawLimit) < 1 || Number(rawLimit) > LIMIT_MAX) {
|
|
91
|
+
return cliOutcome("invalid_argument", {}, `--limit takes 1–${LIMIT_MAX}`);
|
|
92
|
+
}
|
|
93
|
+
limit = Number(rawLimit);
|
|
94
|
+
}
|
|
95
|
+
const project = flags.values.get("--project");
|
|
96
|
+
const withTitles = flags.booleans.has("--with-titles");
|
|
97
|
+
const paths = getPaths();
|
|
98
|
+
|
|
99
|
+
// --project must name an entry of repos.yml as it is now.
|
|
100
|
+
if (project !== undefined) {
|
|
101
|
+
try {
|
|
102
|
+
const result = readConfig();
|
|
103
|
+
if (!result.found) {
|
|
104
|
+
return cliOutcome("config_missing", {}, "no repos.yml yet (config_missing)");
|
|
105
|
+
}
|
|
106
|
+
if (!result.config.repos.some((r) => r.name === project)) {
|
|
107
|
+
return cliOutcome("repo_not_found");
|
|
108
|
+
}
|
|
109
|
+
} catch (err) {
|
|
110
|
+
return configFailure(err);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let fresh = false;
|
|
115
|
+
if (!flags.booleans.has("--no-scan") && existsSync(paths.reposYml)) {
|
|
116
|
+
fresh = await quietScan();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!existsSync(paths.stateDb)) {
|
|
120
|
+
return cliOutcome("cache_missing", {}, "no cache yet (cache_missing); open the list once, or run ccs --refresh");
|
|
121
|
+
}
|
|
122
|
+
let rows: Row[];
|
|
123
|
+
try {
|
|
124
|
+
const { openDb } = await import("./ccs-db.ts");
|
|
125
|
+
const handle = openDb(paths.stateDb, { readonly: true, skipMigrate: true });
|
|
126
|
+
try {
|
|
127
|
+
rows = handle.db
|
|
128
|
+
.prepare(
|
|
129
|
+
`SELECT s.uuid, s.repo_name, s.last_activity_at, s.topic,
|
|
130
|
+
(r.name IS NOT NULL) AS mapped, r.disabled
|
|
131
|
+
FROM sessions s
|
|
132
|
+
LEFT JOIN repos r ON r.name = s.repo_name
|
|
133
|
+
ORDER BY s.last_activity_at DESC`,
|
|
134
|
+
)
|
|
135
|
+
.all() as Row[];
|
|
136
|
+
} finally {
|
|
137
|
+
handle.db.close();
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
return cliOutcome("cache_not_readable", {}, "cannot open the cache (cache_not_readable)");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let unmapped = 0;
|
|
144
|
+
const byProject = new Map<string, Row[]>();
|
|
145
|
+
for (const row of rows) {
|
|
146
|
+
if (!UUID_RE.test(row.uuid)) continue;
|
|
147
|
+
if (!row.mapped || !row.repo_name) {
|
|
148
|
+
unmapped++;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (row.disabled) continue;
|
|
152
|
+
if (project !== undefined && row.repo_name !== project) continue;
|
|
153
|
+
const list = byProject.get(row.repo_name) ?? [];
|
|
154
|
+
list.push(row);
|
|
155
|
+
byProject.set(row.repo_name, list);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Rows come newest first, so the Map's insertion order is already the
|
|
159
|
+
// projects' order by their newest conversation.
|
|
160
|
+
const projects = [...byProject.entries()].map(([name, list]) => ({
|
|
161
|
+
name,
|
|
162
|
+
sessions: list.slice(0, limit).map((s) => {
|
|
163
|
+
const item: Record<string, unknown> = {
|
|
164
|
+
uuid: s.uuid,
|
|
165
|
+
last_activity_at: isoOrNull(s.last_activity_at),
|
|
166
|
+
};
|
|
167
|
+
if (withTitles) item.title = sessionTitle(s.topic);
|
|
168
|
+
return item;
|
|
169
|
+
}),
|
|
170
|
+
more: Math.max(0, list.length - limit),
|
|
171
|
+
}));
|
|
172
|
+
|
|
173
|
+
return okOutcome({ stale: !fresh, limit, projects, unmapped });
|
|
174
|
+
}
|
package/bin/ccs-tsx.sh
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ccs-tsx.sh — start a ccs module with tsx from ccs's own folder.
|
|
3
|
+
#
|
|
4
|
+
# Usage (from bin/ccs, the fzf bindings and ccs-delete.sh):
|
|
5
|
+
# CCS_TSX_REAL=<tsx> bash ccs-tsx.sh <module.ts> [args…]
|
|
6
|
+
#
|
|
7
|
+
# Node resolves tsx's preload (--require) from the CURRENT folder and reads
|
|
8
|
+
# the nearest package.json above it on the way; a package.json there that
|
|
9
|
+
# Node cannot parse stops every module before it starts, even though ccs's
|
|
10
|
+
# own files are fine. So the module runs from the folder holding these
|
|
11
|
+
# scripts, and the folder the user ran ccs in is passed on in CCS_CWD (read
|
|
12
|
+
# where it matters: `ccs .` and `ccs repo add <relative path>`).
|
|
13
|
+
set -euo pipefail
|
|
14
|
+
export CCS_CWD="${CCS_CWD:-$PWD}"
|
|
15
|
+
cd "$(dirname "${BASH_SOURCE[0]}")"
|
|
16
|
+
exec "${CCS_TSX_REAL:-tsx}" "$@"
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* ccs-update-check.ts — ask the npm registry for the package's latest version
|
|
4
|
+
* and remember the answer for bin/ccs.
|
|
5
|
+
*
|
|
6
|
+
* bin/ccs starts this in the background (at most once a day) and never waits
|
|
7
|
+
* for it: the launcher only reads the cache this file writes, so a slow or
|
|
8
|
+
* missing network cannot delay or stop the picker. Nothing is printed and the
|
|
9
|
+
* exit code is always 0.
|
|
10
|
+
*
|
|
11
|
+
* Sent: one HTTPS GET for the package's public dist-tags. No ccs version, repo,
|
|
12
|
+
* session or machine details are in it.
|
|
13
|
+
*
|
|
14
|
+
* Cache: <cacheDir>/update-check, two key=value lines:
|
|
15
|
+
* latest=<X.Y.Z, or empty when the registry's latest is not a plain release>
|
|
16
|
+
* checked=<unix seconds>
|
|
17
|
+
* The registry's answer is untrusted text that ends up in a terminal and in an
|
|
18
|
+
* fzf option, so only a plain X.Y.Z is ever stored. bin/ccs validates the file
|
|
19
|
+
* again when it reads it.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
|
|
25
|
+
import { getPaths } from "./ccs-config.ts";
|
|
26
|
+
|
|
27
|
+
const DEFAULT_REGISTRY = "https://registry.npmjs.org";
|
|
28
|
+
const REQUEST_TIMEOUT_MS = 3_000;
|
|
29
|
+
const HARD_EXIT_MS = 5_000;
|
|
30
|
+
// bin/ccs asks again once `checked` is a day old (UPDATE_INTERVAL there). A
|
|
31
|
+
// failed attempt is back-dated so the next try comes after an hour, not a day.
|
|
32
|
+
const CHECK_INTERVAL_S = 86_400;
|
|
33
|
+
const RETRY_AFTER_FAILURE_S = 3_600;
|
|
34
|
+
|
|
35
|
+
const PLAIN_RELEASE = /^[0-9]{1,9}\.[0-9]{1,9}\.[0-9]{1,9}$/;
|
|
36
|
+
|
|
37
|
+
export function isPlainRelease(version: unknown): version is string {
|
|
38
|
+
return typeof version === "string" && PLAIN_RELEASE.test(version);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Registry base URL: CCS_UPDATE_REGISTRY when it is http(s), else npm's. */
|
|
42
|
+
export function registryBase(value: string | undefined): string {
|
|
43
|
+
if (value && /^https?:\/\/[^\s]+$/.test(value)) return value.replace(/\/+$/, "");
|
|
44
|
+
return DEFAULT_REGISTRY;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function distTagsUrl(base: string, pkg: string): string {
|
|
48
|
+
return `${base}/-/package/${encodeURIComponent(pkg)}/dist-tags`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The version the cache should hold after a successful answer: a plain release,
|
|
53
|
+
* or "" when `latest` is a prerelease. Throws on an answer with no usable
|
|
54
|
+
* `latest` (treated like a failed request).
|
|
55
|
+
*/
|
|
56
|
+
export function latestFromDistTags(body: unknown): string {
|
|
57
|
+
if (typeof body !== "object" || body === null) throw new Error("not an object");
|
|
58
|
+
const latest = (body as { latest?: unknown }).latest;
|
|
59
|
+
if (typeof latest !== "string") throw new Error("no latest tag");
|
|
60
|
+
return isPlainRelease(latest) ? latest : "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function renderCache(latest: string, checked: number): string {
|
|
64
|
+
return `latest=${latest}\nchecked=${checked}\n`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The last good version in an existing cache file ("" when there is none). */
|
|
68
|
+
export function cachedLatest(text: string): string {
|
|
69
|
+
for (const line of text.split("\n")) {
|
|
70
|
+
if (line.startsWith("latest=")) {
|
|
71
|
+
const v = line.slice("latest=".length);
|
|
72
|
+
return isPlainRelease(v) ? v : "";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return "";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function writeCache(cacheDir: string, latest: string, checked: number): void {
|
|
79
|
+
mkdirSync(cacheDir, { recursive: true, mode: 0o700 });
|
|
80
|
+
const file = join(cacheDir, "update-check");
|
|
81
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
82
|
+
writeFileSync(tmp, renderCache(latest, checked), { mode: 0o600 });
|
|
83
|
+
renameSync(tmp, file);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readCachedLatest(cacheDir: string): string {
|
|
87
|
+
try {
|
|
88
|
+
return cachedLatest(readFileSync(join(cacheDir, "update-check"), "utf-8"));
|
|
89
|
+
} catch {
|
|
90
|
+
return "";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function fetchLatest(url: string): Promise<string> {
|
|
95
|
+
const res = await fetch(url, {
|
|
96
|
+
headers: { accept: "application/json" },
|
|
97
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
98
|
+
});
|
|
99
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
100
|
+
return latestFromDistTags(await res.json());
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function main(): Promise<void> {
|
|
104
|
+
const pkg = process.argv[2];
|
|
105
|
+
if (!pkg) return;
|
|
106
|
+
// A stalled DNS lookup or body must not leave a process behind.
|
|
107
|
+
setTimeout(() => process.exit(0), HARD_EXIT_MS);
|
|
108
|
+
|
|
109
|
+
const { cacheDir } = getPaths();
|
|
110
|
+
const now = Math.floor(Date.now() / 1000);
|
|
111
|
+
try {
|
|
112
|
+
const url = distTagsUrl(registryBase(process.env.CCS_UPDATE_REGISTRY), pkg);
|
|
113
|
+
writeCache(cacheDir, await fetchLatest(url), now);
|
|
114
|
+
} catch {
|
|
115
|
+
// Offline, timed out, bad answer: keep what the last good answer said.
|
|
116
|
+
try {
|
|
117
|
+
const retryAt = now - CHECK_INTERVAL_S + RETRY_AFTER_FAILURE_S;
|
|
118
|
+
writeCache(cacheDir, readCachedLatest(cacheDir), retryAt);
|
|
119
|
+
} catch {
|
|
120
|
+
// Cache not writable: nothing to remember, nothing to report.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Run only as a script, so the test suite can import the helpers.
|
|
126
|
+
if (process.argv[1]?.endsWith("ccs-update-check.ts")) {
|
|
127
|
+
main().then(
|
|
128
|
+
() => process.exit(0),
|
|
129
|
+
() => process.exit(0),
|
|
130
|
+
);
|
|
131
|
+
}
|
package/bin/ccs-utils.ts
CHANGED
|
@@ -46,6 +46,11 @@ export function truncate(s: string, max: number): string {
|
|
|
46
46
|
return flat.length <= max ? flat : flat.slice(0, max - 1) + "…";
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** A repos.yml icon as drawn: never a tab or an escape, 📁 when nothing is left. */
|
|
50
|
+
export function displayIcon(icon: string | null | undefined): string {
|
|
51
|
+
return stripControlChars(icon ?? "") || "📁";
|
|
52
|
+
}
|
|
53
|
+
|
|
49
54
|
// ---------------------------------------------------------------------------
|
|
50
55
|
// Repo display name & nesting
|
|
51
56
|
// ---------------------------------------------------------------------------
|
package/bin/package.json
ADDED