@bli-cockpit/cli 0.2.286 → 0.2.288
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/capture-opt-outs.js +129 -0
- package/dist/collection-scope.js +201 -0
- package/dist/commands/agent-session-report.js +3 -0
- package/dist/commands/agreements-render.js +61 -0
- package/dist/commands/agreements.js +196 -0
- package/dist/commands/analyze.js +2 -0
- package/dist/commands/backfill-scan.js +16 -11
- package/dist/commands/backfill-scope.js +16 -4
- package/dist/commands/backfill-session-report.js +2 -0
- package/dist/commands/books-files.js +159 -0
- package/dist/commands/books-usage.js +14 -0
- package/dist/commands/books.js +214 -0
- package/dist/commands/cli-io.js +1 -3
- package/dist/commands/docs-share.js +21 -11
- package/dist/commands/doctor-access.js +7 -0
- package/dist/commands/doctor-registration.js +39 -0
- package/dist/commands/doctor-report.js +1 -0
- package/dist/commands/doctor-shelf.js +62 -0
- package/dist/commands/doctor-update.js +2 -0
- package/dist/commands/doctor-usage-telemetry.js +18 -2
- package/dist/commands/doctor.js +9 -0
- package/dist/commands/heartbeat-post.js +19 -0
- package/dist/commands/heartbeat.js +13 -2
- package/dist/commands/install-receipts.js +4 -0
- package/dist/commands/local-args-collector-setup.js +2 -0
- package/dist/commands/local-args-collector-status.js +7 -21
- package/dist/commands/local-args-tower-agreements.js +80 -0
- package/dist/commands/local-args-tower-docs-msg.js +17 -5
- package/dist/commands/local-args-tower.js +2 -0
- package/dist/commands/local-args.js +6 -1
- package/dist/commands/local-command-shapes-agreements.js +5 -0
- package/dist/commands/local-discovery.js +2 -2
- package/dist/commands/local-help-agreements.js +24 -0
- package/dist/commands/local-help-commands-tower-ops.js +3 -3
- package/dist/commands/local-help-commands-tower.js +13 -6
- package/dist/commands/local-help-commands.js +20 -5
- package/dist/commands/local-help.js +8 -2
- package/dist/commands/local.js +9 -0
- package/dist/commands/memory-capture-prompt.js +130 -0
- package/dist/commands/memory-install-claude.js +51 -4
- package/dist/commands/memory-install-report.js +19 -3
- package/dist/commands/memory-install-toml.js +22 -0
- package/dist/commands/memory-install.js +110 -11
- package/dist/commands/memory-store.js +5 -0
- package/dist/commands/memory-switch.js +165 -0
- package/dist/commands/memory-uninstall.js +187 -0
- package/dist/commands/memory-verb-list.js +6 -2
- package/dist/commands/onboard.js +5 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/roots-label.js +85 -0
- package/dist/commands/roots.js +28 -10
- package/dist/commands/session-sync-plan.js +8 -4
- package/dist/commands/session-sync-scan.js +25 -3
- package/dist/commands/session-sync-shelf.js +143 -0
- package/dist/commands/session-sync-upload.js +6 -2
- package/dist/commands/session-sync.js +68 -8
- package/dist/commands/settings.js +1 -1
- package/dist/commands/shelf.js +165 -0
- package/dist/commands/sources-check.js +2 -2
- package/dist/commands/sources-reconciliation.js +2 -1
- package/dist/commands/speed-seen.js +2 -2
- package/dist/commands/sync-doctor.js +2 -1
- package/dist/commands/sync-receipt.js +1 -0
- package/dist/commands/sync-report.js +1 -0
- package/dist/commands/sync-stub-heal.js +2 -2
- package/dist/commands/sync.js +12 -0
- package/dist/error-message.js +11 -0
- package/dist/evidence-reconcile-client.js +2 -0
- package/dist/evidence-redelivery-plan.js +6 -0
- package/dist/evidence-redelivery.js +6 -1
- package/dist/evidence-stubs-client.js +2 -0
- package/dist/evidence-transcripts-client.js +2 -0
- package/dist/evidence-upload-transport.js +2 -0
- package/dist/founder-standing-file.js +52 -0
- package/dist/founder-standing.js +117 -0
- package/dist/image-describe-client.js +2 -1
- package/dist/live-founder-memo.js +57 -0
- package/dist/local-state-config.js +13 -7
- package/dist/memory-capture-choice.js +73 -0
- package/dist/root-work.js +55 -7
- package/dist/shelf-decision.js +52 -0
- package/dist/shelf-headers.js +15 -0
- package/dist/shelf-reports.js +167 -0
- package/dist/shelf-state.js +112 -0
- package/dist/source-opt-out.js +83 -6
- package/dist/state-dir-audit.js +2 -0
- package/dist/tower-client.js +4 -1
- package/dist/upload-agent-artifacts.js +2 -0
- package/dist/upload-envelope-build.js +2 -2
- package/dist/upload-ingest-receipt.js +2 -0
- package/dist/upload-session-reports-wire.js +2 -0
- package/package.json +4 -4
|
@@ -18,12 +18,14 @@ import { liveWindowMsFor, scopeBackfillCandidates } from "./backfill-live-window
|
|
|
18
18
|
import { getCollectorRuntimePaths } from "../local-state.js";
|
|
19
19
|
import { candidateCursorKey, compareBackfillCandidates, isAfterCursor, } from "./backfill-candidates.js";
|
|
20
20
|
import { addReadOnlyGuardIssues, addRepoDiscoveryIssues, backfillScanIssues, countReadOnlyGuards, deterministicSkipCandidateKeys, oversizedBackfillCandidateKeys, retryableCandidateKeys, sortScanIssuesByPriority, } from "./backfill-issues.js";
|
|
21
|
-
import { labelOutsideRootCandidates, outsideRootsWorkspace
|
|
21
|
+
import { labelOutsideRootCandidates, outsideRootsWorkspace } from "./backfill-outside-roots.js";
|
|
22
22
|
import { reasonCountsFor } from "./backfill-reasons.js";
|
|
23
23
|
import { blockedBackfillResult } from "./backfill-result.js";
|
|
24
24
|
import { emptyStubReofferPlan, loadStubReofferPlan, reopenStubbedSessions, } from "./backfill-stub-reoffer.js";
|
|
25
25
|
import { loadStubCursorHashes, locateStubbedSessions } from "./backfill-stub-locate.js";
|
|
26
26
|
import { resolveBackfillScope } from "./backfill-scope.js";
|
|
27
|
+
import { scopeHistoryScans } from "../collection-scope.js";
|
|
28
|
+
import { decideShelfMode, OWNER_CHECK_UNAVAILABLE, shelveBackfillScan, teamWorktreesOnly } from "./session-sync-shelf.js";
|
|
27
29
|
/**
|
|
28
30
|
* SCAN: is this machine paired, what roots/sources/window apply, and what did
|
|
29
31
|
* the archived Codex + Claude session stores actually contain. Returns either
|
|
@@ -44,14 +46,18 @@ export async function scanBackfillRun(command, io, now) {
|
|
|
44
46
|
};
|
|
45
47
|
}
|
|
46
48
|
const { collectionRoots, sources, worktreeDiscovery } = scope;
|
|
49
|
+
const shelfDecision = await decideShelfMode({ homeDir: command.homeDir, dashboardUrl: scope.dashboardUrl, fetchImpl: io.fetch }); // BLI-23478
|
|
50
|
+
if (shelfDecision.mode === "hold")
|
|
51
|
+
return { kind: "blocked", result: blockedBackfillResult(command, { now, dashboardUrl: scope.dashboardUrl, reason: OWNER_CHECK_UNAVAILABLE, cursor: await readBackfillCursor(paths) }) };
|
|
52
|
+
const worktrees = teamWorktreesOnly(worktreeDiscovery.worktrees, shelfDecision);
|
|
47
53
|
const { scopedCursor, cursor, pointerlessTerminalSessions, stubReoffer, stubCursorHashes } = await reopenUndeliveredHistory(command, io, paths, scope);
|
|
48
54
|
const scan = await scanBackfillSessions({
|
|
49
55
|
command,
|
|
50
|
-
homeDir: command.homeDir ?? os.homedir(),
|
|
51
|
-
worktrees
|
|
56
|
+
homeDir: command.homeDir ?? os.homedir(), shelfDecision,
|
|
57
|
+
worktrees,
|
|
52
58
|
collectionRoots,
|
|
53
59
|
sources,
|
|
54
|
-
window: scope.window,
|
|
60
|
+
window: scope.window, collectionScope: scope.collectionScope,
|
|
55
61
|
cursor,
|
|
56
62
|
pointerlessTerminalSessions,
|
|
57
63
|
stubReoffer,
|
|
@@ -67,7 +73,7 @@ export async function scanBackfillRun(command, io, now) {
|
|
|
67
73
|
sources,
|
|
68
74
|
window: scope.window,
|
|
69
75
|
collectionRoots,
|
|
70
|
-
worktrees
|
|
76
|
+
worktrees,
|
|
71
77
|
retryCommand: scope.retryCommand,
|
|
72
78
|
scopedCursor,
|
|
73
79
|
cursor,
|
|
@@ -132,12 +138,11 @@ async function scanBackfillSessions(options) {
|
|
|
132
138
|
const scanLimit = options.command.maxFiles ?? 10_000;
|
|
133
139
|
const codexSessionDirs = defaultCodexSessionDirs(options.homeDir);
|
|
134
140
|
const claudeProjectsDir = path.join(options.homeDir, ".claude", "projects");
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
: null;
|
|
141
|
+
const codexScanned = options.sources.includes("codex") ? await scanCodexHistory(options, codexSessionDirs, scanLimit) : null;
|
|
142
|
+
const claudeScanned = options.sources.includes("claude_code") ? await scanClaudeHistory(options, claudeProjectsDir, scanLimit) : null;
|
|
143
|
+
// BLI-23590 withholds sessions outside the approved roots first; BLI-23478 then keeps drawer sessions home.
|
|
144
|
+
const scoped = scopeHistoryScans(options, codexScanned, claudeScanned);
|
|
145
|
+
const { codexAttribution, claudeAttribution } = shelveBackfillScan(scoped.codex, scoped.claude, options.shelfDecision);
|
|
141
146
|
// BLI-11119: a stubbed session is found by the id in its content or by the
|
|
142
147
|
// file hash its cursor entry recorded, across every root the scan walked.
|
|
143
148
|
const scanned = locateStubbedSessions({
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* which stores and how far back was it asked for, and can it reach the
|
|
5
5
|
* dashboard at all. Nothing here opens a transcript.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { settleCollectionScope } from "../collection-scope.js";
|
|
8
|
+
import { configOptOutLabels, resolveSourceSwitches } from "../source-opt-out.js";
|
|
9
|
+
import { liveStandingForDecision } from "../live-founder-memo.js";
|
|
8
10
|
import path from "node:path";
|
|
9
11
|
import { readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, } from "../local-state.js";
|
|
10
12
|
import { DEFAULT_DISCOVERY_MAX_DEPTH, DEFAULT_DISCOVERY_MAX_REPOS, collectionRootPathAliases, discoverGitWorktreesInRootsWithStatus, } from "../repo-identity.js";
|
|
@@ -45,8 +47,11 @@ export async function resolveBackfillScope(command, io, now, paths) {
|
|
|
45
47
|
collectionRoots,
|
|
46
48
|
worktreeDiscovery,
|
|
47
49
|
retryCommand: backfillDiscoveryRetryCommand(command, worktreeDiscovery),
|
|
48
|
-
sources: honourSourceOptOuts(selectedSources(command.source), config
|
|
50
|
+
sources: honourSourceOptOuts(selectedSources(command.source), config,
|
|
51
|
+
// Tower's live word, asked only when the config holds an opt-out (BLI-22640).
|
|
52
|
+
configOptOutLabels(config).length > 0 ? (await liveStandingForDecision(paths, { fetch: io.fetch, dashboardUrl })).standing : "unknown"),
|
|
49
53
|
window,
|
|
54
|
+
collectionScope: await settleCollectionScope(paths.state_dir, config.collection_scope),
|
|
50
55
|
};
|
|
51
56
|
}
|
|
52
57
|
/**
|
|
@@ -58,8 +63,15 @@ export async function resolveBackfillScope(command, io, now, paths) {
|
|
|
58
63
|
* Backfill never reads the store of a source the machine said no to, and logs
|
|
59
64
|
* the reason label (ticket 22557).
|
|
60
65
|
*/
|
|
61
|
-
function honourSourceOptOuts(sources, config) {
|
|
62
|
-
|
|
66
|
+
function honourSourceOptOuts(sources, config, standing) {
|
|
67
|
+
// Founders only (BLI-22640): a non-founder's opt-out is ignored and named here.
|
|
68
|
+
const switches = resolveSourceSwitches(config, standing);
|
|
69
|
+
if (switches.ignored.length > 0) {
|
|
70
|
+
console.error("[backfill] opt-out ignored; only a founder's opt-out is obeyed, so these sessions upload", JSON.stringify({ reason: switches.verdict, ignored: switches.ignored }));
|
|
71
|
+
}
|
|
72
|
+
if (switches.held) {
|
|
73
|
+
console.error("[backfill] opt-out held; Tower could not say whether this person is a founder, so the sources it names send nothing", JSON.stringify({ reason: switches.verdict, held: configOptOutLabels(config) }));
|
|
74
|
+
}
|
|
63
75
|
const kept = sources.filter((source) => (source === "codex" ? switches.codex : switches.claude));
|
|
64
76
|
if (kept.length === sources.length)
|
|
65
77
|
return sources;
|
|
@@ -83,6 +83,8 @@ function sessionAttributionRow(candidate, uploads, now) {
|
|
|
83
83
|
path_score: candidate.path_score,
|
|
84
84
|
// BLI-11091: the same inside/outside label the sync path writes (PR 1453).
|
|
85
85
|
root_scope: rootScopeForReason(candidate.reason),
|
|
86
|
+
// BLI-23478: a team-shelf row; a drawer session never becomes a backfill candidate (`backfill-scan.ts`).
|
|
87
|
+
shelf: "team",
|
|
86
88
|
signals: candidate.signals,
|
|
87
89
|
...(candidate.content_hash_sha256
|
|
88
90
|
? { session_file_hash_sha256: candidate.content_hash_sha256 }
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit books export|import` (BLI-23711, PR A): a book as a folder, `<dir>/<collection>/<doc_id>.json`, each file
|
|
3
|
+
* the record's data only, pretty JSON with a trailing newline. That is the layout the Claude artifact export already
|
|
4
|
+
* writes, so the scripts that read a book's export read Tower's the same way.
|
|
5
|
+
*
|
|
6
|
+
* Export walks every collection through the list door (keyset pages of 500). Import reads the same layout and writes
|
|
7
|
+
* through the batch door, 50 writes (and at most ~3 MB) per batch, keeping doc ids and data unchanged, then prints what
|
|
8
|
+
* it wrote per collection. A doc id holding a character a file name cannot (`<>:"/\|?*%`, control characters, a
|
|
9
|
+
* trailing dot or space) is written with that character as %XX, and import decodes it back.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
14
|
+
import { writeLine } from "./cli-io.js";
|
|
15
|
+
export const BOOK_COLLECTIONS = ["meta", "timeline", "actions", "documents", "meetings", "conversations", "workstreams", "sources", "syncs"];
|
|
16
|
+
const BATCH_WRITES = 50;
|
|
17
|
+
const BATCH_CHARS = 3_000_000;
|
|
18
|
+
const TAG = "[books cli]";
|
|
19
|
+
/** One door call; a refusal keeps the door's own reason and message. */
|
|
20
|
+
export async function booksRequest(door, method, path, label, body) {
|
|
21
|
+
return askAgentDoor(door, { path, method, label: `books ${label}`, timeoutMs: 60_000, ...(body === undefined ? {} : { body }) });
|
|
22
|
+
}
|
|
23
|
+
// eslint-disable-next-line no-control-regex
|
|
24
|
+
const UNSAFE = /[<>:"/\\|?*%\u0000-\u001f]|[. ]$/g;
|
|
25
|
+
/** A doc id as a file name (without `.json`). Reversible: `%` itself is encoded. */
|
|
26
|
+
export function fileNameFor(docId) {
|
|
27
|
+
return docId.replace(UNSAFE, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
|
|
28
|
+
}
|
|
29
|
+
export function docIdFor(fileName) {
|
|
30
|
+
return fileName.replace(/%([0-9A-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
31
|
+
}
|
|
32
|
+
/** Keys in a stable order, so two exports of the same book are byte-identical. Values are untouched. */
|
|
33
|
+
function sorted(value) {
|
|
34
|
+
if (Array.isArray(value))
|
|
35
|
+
return value.map(sorted);
|
|
36
|
+
if (value && typeof value === "object") {
|
|
37
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sorted(value[key])]));
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
async function isEmptyOrMissing(dir) {
|
|
42
|
+
try {
|
|
43
|
+
return (await readdir(dir)).length === 0;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error.code === "ENOENT")
|
|
47
|
+
return true;
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export async function exportBook(door, book, dir) {
|
|
52
|
+
if (!(await isEmptyOrMissing(dir)))
|
|
53
|
+
return failAgentDoor(door, TAG, "export_dir_not_empty", `${dir} is not empty. Export into a new or empty folder so no old record is mixed in.`);
|
|
54
|
+
const counts = {};
|
|
55
|
+
for (const collection of BOOK_COLLECTIONS) {
|
|
56
|
+
counts[collection] = 0;
|
|
57
|
+
let after = null;
|
|
58
|
+
do {
|
|
59
|
+
const query = new URLSearchParams({ collection, limit: "500" });
|
|
60
|
+
if (after !== null)
|
|
61
|
+
query.set("after", after);
|
|
62
|
+
const answer = await booksRequest(door, "GET", `/api/books/${encodeURIComponent(book)}/records?${query}`, "export");
|
|
63
|
+
if (!answer.ok)
|
|
64
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail, { collection, written: counts });
|
|
65
|
+
const page = answer.body;
|
|
66
|
+
if (page.records.length > 0)
|
|
67
|
+
await mkdir(join(dir, collection), { recursive: true });
|
|
68
|
+
for (const record of page.records) {
|
|
69
|
+
await writeFile(join(dir, collection, `${fileNameFor(record.doc_id)}.json`), `${JSON.stringify(sorted(record.data), null, 2)}\n`, "utf8");
|
|
70
|
+
counts[collection] += 1;
|
|
71
|
+
}
|
|
72
|
+
after = page.next_cursor;
|
|
73
|
+
} while (after !== null);
|
|
74
|
+
}
|
|
75
|
+
const total = Object.values(counts).reduce((sum, n) => sum + n, 0);
|
|
76
|
+
console.error(`${TAG} exported ${JSON.stringify({ book, total })}`);
|
|
77
|
+
if (door.json)
|
|
78
|
+
return emitAgentDoor(door, { ok: true, book, dir, counts, total });
|
|
79
|
+
for (const collection of BOOK_COLLECTIONS)
|
|
80
|
+
writeLine(door.io.stdout, `${collection}\t${counts[collection]}`);
|
|
81
|
+
writeLine(door.io.stdout, `Exported ${total} records of ${book} to ${dir}.`);
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
async function readLayout(dir) {
|
|
85
|
+
const writes = [];
|
|
86
|
+
const skipped = [];
|
|
87
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
88
|
+
if (!entry.isDirectory() || !BOOK_COLLECTIONS.includes(entry.name)) {
|
|
89
|
+
skipped.push(entry.name);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
for (const file of (await readdir(join(dir, entry.name))).sort()) {
|
|
93
|
+
if (!file.endsWith(".json")) {
|
|
94
|
+
skipped.push(`${entry.name}/${file}`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const text = (await readFile(join(dir, entry.name, file), "utf8")).replace(/^/, "");
|
|
98
|
+
let data;
|
|
99
|
+
try {
|
|
100
|
+
data = JSON.parse(text);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
throw new Error(`${entry.name}/${file} is not valid JSON.`);
|
|
104
|
+
}
|
|
105
|
+
writes.push({ op: "put", collection: entry.name, doc_id: docIdFor(file.slice(0, -".json".length)), data, merge: false });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { writes, skipped };
|
|
109
|
+
}
|
|
110
|
+
/** Batches of at most 50 writes and ~3 MB, in file order. */
|
|
111
|
+
export function batchesOf(writes) {
|
|
112
|
+
const batches = [];
|
|
113
|
+
let current = [];
|
|
114
|
+
let chars = 0;
|
|
115
|
+
for (const write of writes) {
|
|
116
|
+
const size = JSON.stringify(write).length;
|
|
117
|
+
if (current.length > 0 && (current.length >= BATCH_WRITES || chars + size > BATCH_CHARS)) {
|
|
118
|
+
batches.push(current);
|
|
119
|
+
current = [];
|
|
120
|
+
chars = 0;
|
|
121
|
+
}
|
|
122
|
+
current.push(write);
|
|
123
|
+
chars += size;
|
|
124
|
+
}
|
|
125
|
+
if (current.length > 0)
|
|
126
|
+
batches.push(current);
|
|
127
|
+
return batches;
|
|
128
|
+
}
|
|
129
|
+
export async function importBook(door, book, dir) {
|
|
130
|
+
let layout;
|
|
131
|
+
try {
|
|
132
|
+
layout = await readLayout(dir);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
return failAgentDoor(door, TAG, "import_read_failed", error.message);
|
|
136
|
+
}
|
|
137
|
+
const counts = Object.fromEntries(BOOK_COLLECTIONS.map((c) => [c, 0]));
|
|
138
|
+
const batches = batchesOf(layout.writes);
|
|
139
|
+
for (const [index, batch] of batches.entries()) {
|
|
140
|
+
const answer = await booksRequest(door, "POST", `/api/books/${encodeURIComponent(book)}/batch`, "import", { writes: batch });
|
|
141
|
+
if (!answer.ok) {
|
|
142
|
+
const failed = answer.body?.index;
|
|
143
|
+
const at = typeof failed === "number" ? batch[failed] : undefined;
|
|
144
|
+
return failAgentDoor(door, TAG, answer.reason, `${answer.detail} Batch ${index + 1} of ${batches.length} was not written; earlier batches were.`, { written: counts, ...(at ? { record: `${at.collection}/${at.doc_id}` } : {}) });
|
|
145
|
+
}
|
|
146
|
+
for (const write of batch)
|
|
147
|
+
counts[write.collection] = (counts[write.collection] ?? 0) + 1;
|
|
148
|
+
}
|
|
149
|
+
const total = layout.writes.length;
|
|
150
|
+
console.error(`${TAG} imported ${JSON.stringify({ book, total, batches: batches.length, skipped: layout.skipped.length })}`);
|
|
151
|
+
if (door.json)
|
|
152
|
+
return emitAgentDoor(door, { ok: true, book, counts, total, batches: batches.length, skipped: layout.skipped });
|
|
153
|
+
for (const collection of BOOK_COLLECTIONS)
|
|
154
|
+
writeLine(door.io.stdout, `${collection}\t${counts[collection]}`);
|
|
155
|
+
if (layout.skipped.length)
|
|
156
|
+
writeLine(door.io.stdout, `Skipped (not a collection or not .json): ${layout.skipped.join(", ")}`);
|
|
157
|
+
writeLine(door.io.stdout, `Imported ${total} records into ${book} in ${batches.length} batches.`);
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** `cockpit books` usage text (BLI-23711), in its own module so the help table can print it without loading the command. */
|
|
2
|
+
export const BOOKS_USAGE = [
|
|
3
|
+
"Usage: cockpit books list [--json]",
|
|
4
|
+
" cockpit books create <book> --title \"<title>\" [--json] (founders)",
|
|
5
|
+
" cockpit books members list|add|remove <book> [<email or user id>] [--edit] [--json] (founders)",
|
|
6
|
+
" cockpit books ls <book> <collection> [--after <doc_id>] [--limit <1-500>] [--deleted] [--json]",
|
|
7
|
+
" cockpit books get <book> <collection> <doc_id> [--deleted] [--json]",
|
|
8
|
+
" cockpit books put <book> <collection> <doc_id> [--merge] [--version <n>] [--data '<json>'] [--json] (data on stdin if no --data)",
|
|
9
|
+
" cockpit books delete <book> <collection> <doc_id> [--version <n>] [--json] (soft: the record is kept)",
|
|
10
|
+
" cockpit books batch <book> [--json] ({\"writes\": [...]} on stdin, 1 to 50 writes, all or nothing)",
|
|
11
|
+
" cockpit books export <book> <dir> [--json] (<dir>/<collection>/<doc_id>.json)",
|
|
12
|
+
" cockpit books import <book> <dir> [--json] (the export layout, through the batch door)",
|
|
13
|
+
"Collections: meta, timeline, actions, documents, meetings, conversations, workstreams, sources, syncs.",
|
|
14
|
+
].join("\n");
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
|
|
2
|
+
import { booksRequest, exportBook, importBook } from "./books-files.js";
|
|
3
|
+
import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
4
|
+
import { BOOKS_USAGE } from "./books-usage.js";
|
|
5
|
+
import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
|
|
6
|
+
export { BOOKS_USAGE };
|
|
7
|
+
const ACTIONS = ["list", "create", "members", "ls", "get", "put", "delete", "batch", "export", "import"];
|
|
8
|
+
const BODY_MAX_CHARS = 4_000_000;
|
|
9
|
+
function usage() {
|
|
10
|
+
throw new Error(BOOKS_USAGE);
|
|
11
|
+
}
|
|
12
|
+
function wholeNumber(raw, min, max) {
|
|
13
|
+
if (raw === undefined)
|
|
14
|
+
return undefined;
|
|
15
|
+
if (!/^\d+$/.test(raw))
|
|
16
|
+
usage();
|
|
17
|
+
const n = Number(raw);
|
|
18
|
+
if (n < min || n > max)
|
|
19
|
+
usage();
|
|
20
|
+
return n;
|
|
21
|
+
}
|
|
22
|
+
export function parseBooksArgs(args) {
|
|
23
|
+
const values = parseNamedArgs(args, {
|
|
24
|
+
allowedFlags: ["--title", "--edit", "--after", "--limit", "--deleted", "--merge", "--version", "--data", "--json", "--home", "--dashboard-url"],
|
|
25
|
+
valueFlags: ["--title", "--after", "--limit", "--version", "--data", "--home", "--dashboard-url"],
|
|
26
|
+
});
|
|
27
|
+
const [action, ...rest] = values.positionals;
|
|
28
|
+
if (!ACTIONS.includes(action ?? ""))
|
|
29
|
+
usage();
|
|
30
|
+
const flags = values.flags;
|
|
31
|
+
const has = (flag) => flags.has(flag) || values.booleans.has(flag);
|
|
32
|
+
const allowOnly = (...allowed) => {
|
|
33
|
+
for (const flag of ["--title", "--edit", "--after", "--limit", "--deleted", "--merge", "--version", "--data"])
|
|
34
|
+
if (has(flag) && !allowed.includes(flag))
|
|
35
|
+
usage();
|
|
36
|
+
};
|
|
37
|
+
const command = {
|
|
38
|
+
kind: "books",
|
|
39
|
+
action: action,
|
|
40
|
+
json: values.booleans.has("--json"),
|
|
41
|
+
homeDir: optionalNonEmpty(flags.get("--home")),
|
|
42
|
+
dashboardUrl: optionalUrl(flags.get("--dashboard-url")),
|
|
43
|
+
};
|
|
44
|
+
switch (command.action) {
|
|
45
|
+
case "list":
|
|
46
|
+
if (rest.length)
|
|
47
|
+
usage();
|
|
48
|
+
allowOnly();
|
|
49
|
+
return command;
|
|
50
|
+
case "create": {
|
|
51
|
+
const title = optionalNonEmpty(flags.get("--title"));
|
|
52
|
+
if (rest.length !== 1 || !title)
|
|
53
|
+
usage();
|
|
54
|
+
allowOnly("--title");
|
|
55
|
+
return { ...command, book: rest[0], title };
|
|
56
|
+
}
|
|
57
|
+
case "members": {
|
|
58
|
+
const [sub, book, member, ...extra] = rest;
|
|
59
|
+
if (extra.length || !book || (sub !== "list" && sub !== "add" && sub !== "remove"))
|
|
60
|
+
usage();
|
|
61
|
+
if (sub === "list" ? member !== undefined : member === undefined)
|
|
62
|
+
usage();
|
|
63
|
+
allowOnly(...(sub === "add" ? ["--edit"] : []));
|
|
64
|
+
return { ...command, membersAction: sub, book, ...(member === undefined ? {} : { member }), ...(sub === "add" ? { canEdit: values.booleans.has("--edit") } : {}) };
|
|
65
|
+
}
|
|
66
|
+
case "ls": {
|
|
67
|
+
if (rest.length !== 2)
|
|
68
|
+
usage();
|
|
69
|
+
allowOnly("--after", "--limit", "--deleted");
|
|
70
|
+
const limit = wholeNumber(flags.get("--limit"), 1, 500);
|
|
71
|
+
const after = optionalNonEmpty(flags.get("--after"));
|
|
72
|
+
return { ...command, book: rest[0], collection: rest[1], ...(after === undefined ? {} : { after }), ...(limit === undefined ? {} : { limit }), deleted: values.booleans.has("--deleted") };
|
|
73
|
+
}
|
|
74
|
+
case "get":
|
|
75
|
+
if (rest.length !== 3)
|
|
76
|
+
usage();
|
|
77
|
+
allowOnly("--deleted");
|
|
78
|
+
return { ...command, book: rest[0], collection: rest[1], docId: rest[2], deleted: values.booleans.has("--deleted") };
|
|
79
|
+
case "put": {
|
|
80
|
+
if (rest.length !== 3)
|
|
81
|
+
usage();
|
|
82
|
+
allowOnly("--merge", "--version", "--data");
|
|
83
|
+
const version = wholeNumber(flags.get("--version"), 0, 2_147_483_647);
|
|
84
|
+
const data = flags.get("--data");
|
|
85
|
+
return { ...command, book: rest[0], collection: rest[1], docId: rest[2], merge: values.booleans.has("--merge"), ...(version === undefined ? {} : { version }), ...(data === undefined ? {} : { data }) };
|
|
86
|
+
}
|
|
87
|
+
case "delete": {
|
|
88
|
+
if (rest.length !== 3)
|
|
89
|
+
usage();
|
|
90
|
+
allowOnly("--version");
|
|
91
|
+
const version = wholeNumber(flags.get("--version"), 0, 2_147_483_647);
|
|
92
|
+
return { ...command, book: rest[0], collection: rest[1], docId: rest[2], ...(version === undefined ? {} : { version }) };
|
|
93
|
+
}
|
|
94
|
+
case "batch":
|
|
95
|
+
if (rest.length !== 1)
|
|
96
|
+
usage();
|
|
97
|
+
allowOnly();
|
|
98
|
+
return { ...command, book: rest[0] };
|
|
99
|
+
case "export":
|
|
100
|
+
case "import":
|
|
101
|
+
if (rest.length !== 2)
|
|
102
|
+
usage();
|
|
103
|
+
allowOnly();
|
|
104
|
+
return { ...command, book: rest[0], dir: rest[1] };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const seg = (value) => encodeURIComponent(value ?? "");
|
|
108
|
+
async function readJsonInput(io, inline, what) {
|
|
109
|
+
let text = inline;
|
|
110
|
+
if (text === undefined) {
|
|
111
|
+
if (isInteractiveStdin(io))
|
|
112
|
+
return { ok: false, detail: `Pipe the ${what} as JSON on stdin (or pass --data '<json>').` };
|
|
113
|
+
text = await readPipedText(io.stdin, { maxChars: BODY_MAX_CHARS, overflowMessage: `The JSON on stdin is limited to ${BODY_MAX_CHARS} characters.` });
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
return { ok: true, value: JSON.parse(text) };
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return { ok: false, detail: `The ${what} is not valid JSON.` };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const TAG = "[books cli]";
|
|
123
|
+
export async function runBooks(command, io) {
|
|
124
|
+
const door = await openAgentDoor("books", command, io);
|
|
125
|
+
const book = seg(command.book);
|
|
126
|
+
const record = `/api/books/${book}/records/${seg(command.collection)}/${seg(command.docId)}`;
|
|
127
|
+
if (command.action === "export")
|
|
128
|
+
return exportBook(door, command.book, command.dir);
|
|
129
|
+
if (command.action === "import")
|
|
130
|
+
return importBook(door, command.book, command.dir);
|
|
131
|
+
let answer;
|
|
132
|
+
switch (command.action) {
|
|
133
|
+
case "list":
|
|
134
|
+
answer = await booksRequest(door, "GET", "/api/books", "list");
|
|
135
|
+
if (answer.ok && !command.json) {
|
|
136
|
+
const books = answer.body.books;
|
|
137
|
+
if (books.length === 0)
|
|
138
|
+
writeLine(io.stdout, "No books you can open.");
|
|
139
|
+
for (const b of books)
|
|
140
|
+
writeLine(io.stdout, `${b.id}\t${b.access}${b.founder ? " (founder)" : ""}\t${b.title}`);
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
case "create":
|
|
145
|
+
answer = await booksRequest(door, "POST", "/api/books", "create", { id: command.book, title: command.title });
|
|
146
|
+
break;
|
|
147
|
+
case "members":
|
|
148
|
+
if (command.membersAction === "list") {
|
|
149
|
+
answer = await booksRequest(door, "GET", `/api/books/${book}/members`, "members list");
|
|
150
|
+
if (answer.ok && !command.json) {
|
|
151
|
+
for (const m of answer.body.members) {
|
|
152
|
+
writeLine(io.stdout, `${m.email ?? m.user_id}\t${m.can_edit ? "edit" : "read"}`);
|
|
153
|
+
}
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else if (command.membersAction === "add") {
|
|
158
|
+
answer = await booksRequest(door, "POST", `/api/books/${book}/members`, "members add", { member: command.member, can_edit: command.canEdit === true });
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
answer = await booksRequest(door, "DELETE", `/api/books/${book}/members?member=${seg(command.member)}`, "members remove");
|
|
162
|
+
}
|
|
163
|
+
break;
|
|
164
|
+
case "ls": {
|
|
165
|
+
const query = new URLSearchParams({ collection: command.collection ?? "" });
|
|
166
|
+
if (command.after !== undefined)
|
|
167
|
+
query.set("after", command.after);
|
|
168
|
+
if (command.limit !== undefined)
|
|
169
|
+
query.set("limit", String(command.limit));
|
|
170
|
+
if (command.deleted)
|
|
171
|
+
query.set("include_deleted", "1");
|
|
172
|
+
answer = await booksRequest(door, "GET", `/api/books/${book}/records?${query}`, "ls");
|
|
173
|
+
if (answer.ok && !command.json) {
|
|
174
|
+
const page = answer.body;
|
|
175
|
+
for (const r of page.records)
|
|
176
|
+
writeLine(io.stdout, `${r.doc_id}\tv${r.version}\t${r.updated_at}${r.deleted_at ? "\tdeleted" : ""}`);
|
|
177
|
+
if (page.next_cursor)
|
|
178
|
+
writeLine(io.stdout, `More: --after ${page.next_cursor}`);
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
case "get":
|
|
184
|
+
answer = await booksRequest(door, "GET", `${record}${command.deleted ? "?include_deleted=1" : ""}`, "get");
|
|
185
|
+
if (answer.ok && !command.json) {
|
|
186
|
+
writeLine(io.stdout, JSON.stringify(answer.body.record.data, null, 2));
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
break;
|
|
190
|
+
case "put": {
|
|
191
|
+
const data = await readJsonInput(io, command.data, "record data");
|
|
192
|
+
if (!data.ok)
|
|
193
|
+
return failAgentDoor(door, TAG, "invalid_data", data.detail);
|
|
194
|
+
answer = await booksRequest(door, "PUT", record, "put", { data: data.value, merge: command.merge === true, ...(command.version === undefined ? {} : { expected_version: command.version }) });
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
case "delete":
|
|
198
|
+
answer = await booksRequest(door, "DELETE", `${record}${command.version === undefined ? "" : `?expected_version=${command.version}`}`, "delete");
|
|
199
|
+
break;
|
|
200
|
+
case "batch": {
|
|
201
|
+
const body = await readJsonInput(io, undefined, "batch");
|
|
202
|
+
if (!body.ok)
|
|
203
|
+
return failAgentDoor(door, TAG, "invalid_batch", body.detail);
|
|
204
|
+
// A bare array is accepted as the writes list.
|
|
205
|
+
answer = await booksRequest(door, "POST", `/api/books/${book}/batch`, "batch", Array.isArray(body.value) ? { writes: body.value } : body.value);
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (!answer.ok) {
|
|
210
|
+
const extra = answer.body && typeof answer.body === "object" ? Object.fromEntries(Object.entries(answer.body).filter(([key]) => key === "current_version" || key === "index")) : {};
|
|
211
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail, extra);
|
|
212
|
+
}
|
|
213
|
+
return emitAgentDoor(door, answer.body);
|
|
214
|
+
}
|
package/dist/commands/cli-io.js
CHANGED
|
@@ -24,9 +24,7 @@ export function writeFragment(stream, text) {
|
|
|
24
24
|
return;
|
|
25
25
|
stream.write(text);
|
|
26
26
|
}
|
|
27
|
-
export
|
|
28
|
-
return error instanceof Error ? error.message : String(error);
|
|
29
|
-
}
|
|
27
|
+
export { errorMessage } from "../error-message.js";
|
|
30
28
|
// ------------------------------------------------------------------- styling
|
|
31
29
|
const SGR_DIM = "\x1b[2m";
|
|
32
30
|
const SGR_RESET = "\x1b[0m";
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `cockpit docs share` and `cockpit docs access` — named page
|
|
2
|
+
* `cockpit docs share` and `cockpit docs access` — the named people on a page, from a terminal (BLI-21313, BLI-23558).
|
|
3
3
|
* A sibling of `docs.ts`, like `docs-notion.ts`.
|
|
4
4
|
*
|
|
5
|
-
* `access <id|slug>`
|
|
6
|
-
* `share <id|slug> --add <email
|
|
7
|
-
* `share <id|slug> --remove <email>`
|
|
5
|
+
* `access <id|slug>` → `GET /api/docs/documents/[id]/grants`
|
|
6
|
+
* `share <id|slug> --add <email> [--role viewer|editor]` → `POST /api/docs/documents/[id]/grants`
|
|
7
|
+
* `share <id|slug> --remove <email>` → `DELETE /api/docs/documents/[id]/grants`
|
|
8
8
|
*
|
|
9
9
|
* The door answers with the shared permission explanation, and every refusal keeps its own reason
|
|
10
|
-
* label verbatim (`
|
|
10
|
+
* label verbatim (`not_grant_manager`, `person_not_found`, `grantee_is_author`, ...).
|
|
11
11
|
* A person is an exact email; the server resolves it, so this file never guesses who someone is.
|
|
12
12
|
*/
|
|
13
13
|
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
@@ -31,6 +31,8 @@ function printPermission(door, permission) {
|
|
|
31
31
|
writeLine(door.io.stdout, line);
|
|
32
32
|
for (const editor of permission.editors)
|
|
33
33
|
writeLine(door.io.stdout, ` editor: ${editor.display_name}${editor.email ? ` <${editor.email}>` : ""}`);
|
|
34
|
+
for (const viewer of permission.viewers ?? [])
|
|
35
|
+
writeLine(door.io.stdout, ` viewer: ${viewer.display_name}${viewer.email ? ` <${viewer.email}>` : ""}`);
|
|
34
36
|
}
|
|
35
37
|
export async function accessDocs(command, door) {
|
|
36
38
|
const target = await resolveOrFail(command, door);
|
|
@@ -45,12 +47,12 @@ export async function accessDocs(command, door) {
|
|
|
45
47
|
if (!answer.ok)
|
|
46
48
|
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
47
49
|
const body = answer.body;
|
|
48
|
-
writeLine(door.io.stderr, `${TAG} access ${JSON.stringify({ document_id: target.id, role: body.permission.role, editors: body.permission.editors.length })}`);
|
|
50
|
+
writeLine(door.io.stderr, `${TAG} access ${JSON.stringify({ document_id: target.id, role: body.permission.role, editors: body.permission.editors.length, viewers: (body.permission.viewers ?? []).length })}`);
|
|
49
51
|
if (door.json)
|
|
50
52
|
return emitAgentDoor(door, { ok: true, ...body });
|
|
51
53
|
printPermission(door, body.permission);
|
|
52
54
|
for (const event of body.events ?? []) {
|
|
53
|
-
writeLine(door.io.stdout, ` ${event.created_at}: ${event.actor_name} ${event.action === "granted" ? "added" : "removed"} ${event.grantee_name}`);
|
|
55
|
+
writeLine(door.io.stdout, ` ${event.created_at}: ${event.actor_name} ${event.action === "granted" ? "added" : "removed"} ${event.grantee_name}${event.role ? ` (${event.role})` : ""}`);
|
|
54
56
|
}
|
|
55
57
|
return 0;
|
|
56
58
|
}
|
|
@@ -64,18 +66,26 @@ export async function shareDocs(command, door) {
|
|
|
64
66
|
method: adding ? "POST" : "DELETE",
|
|
65
67
|
label: adding ? "docs share add" : "docs share remove",
|
|
66
68
|
timeoutMs: WRITE_DEADLINE_MS,
|
|
67
|
-
body: {
|
|
69
|
+
body: {
|
|
70
|
+
person: adding ? command.addPerson : command.removePerson,
|
|
71
|
+
...(adding && command.shareRole ? { role: command.shareRole } : {}),
|
|
72
|
+
...(command.reason ? { reason: command.reason } : {}),
|
|
73
|
+
},
|
|
68
74
|
});
|
|
69
75
|
if (!answer.ok)
|
|
70
76
|
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
71
77
|
const body = answer.body;
|
|
72
|
-
writeLine(door.io.stderr, `${TAG} share ${JSON.stringify({ document_id: target.id, action: adding ? "add" : "remove", outcome: body.outcome, reason: body.reason, request_id: body.request_id })}`);
|
|
78
|
+
writeLine(door.io.stderr, `${TAG} share ${JSON.stringify({ document_id: target.id, action: adding ? "add" : "remove", outcome: body.outcome, reason: body.reason, role: body.role ?? null, request_id: body.request_id })}`);
|
|
73
79
|
if (door.json)
|
|
74
80
|
return emitAgentDoor(door, { ok: true, ...body });
|
|
75
81
|
const person = adding ? command.addPerson : command.removePerson;
|
|
82
|
+
const role = body.role ?? command.shareRole ?? "editor";
|
|
83
|
+
const aRole = `${role === "editor" ? "an" : "a"} ${role}`;
|
|
76
84
|
writeLine(door.io.stdout, body.outcome === "unchanged"
|
|
77
|
-
? `Nothing changed: ${person} ${adding ?
|
|
78
|
-
:
|
|
85
|
+
? `Nothing changed: ${person} ${adding ? `is already ${aRole}` : "was not named on this page"}.`
|
|
86
|
+
: body.reason === "role_changed"
|
|
87
|
+
? `Changed ${person} to ${role} on ${target.id}.`
|
|
88
|
+
: `${adding ? "Added" : "Removed"} ${person} ${adding ? `as ${aRole} of` : "from"} ${target.id}.`);
|
|
79
89
|
printPermission(door, body.permission);
|
|
80
90
|
return 0;
|
|
81
91
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import { readRootWorkScan, outsideWorkDirectories, rootsAddCommand, sessionCoverageLine } from "../root-work.js";
|
|
3
3
|
import { scanWorkDirectories } from "./local-discovery.js";
|
|
4
|
+
import { settleCollectionScope } from "../collection-scope.js";
|
|
4
5
|
import { sendCollectorHeartbeatBestEffort } from "./heartbeat.js";
|
|
5
6
|
import { isInteractiveDoctorFix } from "./doctor-report.js";
|
|
6
7
|
import { describeError } from "../health-detail.js";
|
|
@@ -175,6 +176,12 @@ function shellQuote(value) {
|
|
|
175
176
|
export async function readRootsCoverWork(context) {
|
|
176
177
|
const homeDir = context.command.homeDir ?? os.homedir();
|
|
177
178
|
const paths = getCollectorRuntimePaths(homeDir);
|
|
179
|
+
// BLI-23590: under `roots_only` (Tower cleared this person) a session outside the roots is deliberately not
|
|
180
|
+
// collected. That is a choice, so the row is ok and says so; it never counts the withheld sessions as a gap.
|
|
181
|
+
const scope = await settleCollectionScope(paths.state_dir, (await readLocalCollectorConfig(paths).catch(() => null))?.collection_scope);
|
|
182
|
+
if (scope.scope === "roots_only") {
|
|
183
|
+
return ok("roots-cover-work", "work_outside_roots_deliberately_withheld", `Collection scope is roots only (${scope.source === "server" ? "set in Tower" : "your local setting, confirmed by Tower"}): sessions whose working folder is outside your approved roots are deliberately not read or uploaded, and only a count reaches Tower. This is a choice, not a gap; nothing needs fixing. To add a folder to what is collected: cockpit roots add <dir>`);
|
|
184
|
+
}
|
|
178
185
|
const scan = await readRootWorkScan(paths) ?? await scanWorkDirectories(homeDir, false);
|
|
179
186
|
const outside = outsideWorkDirectories(scan, (await readLocalCollectorConfig(paths))?.default_repo_paths ?? []);
|
|
180
187
|
const outsideCount = outside.reduce((sum, entry) => sum + entry.count, 0);
|