@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/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
- const res = await execFileP("git", args, {
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(), so cacheDir exists before the lock.
630
- const config = loadConfig();
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
- const handle = openDb(paths.stateDb);
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
- return { code: 0, out: args.printUi && res.ui ? `ui=${res.ui}\n` : "" };
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
- if (import.meta.url === `file://${process.argv[1]}`) {
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
  // ---------------------------------------------------------------------------
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }