@bli-cockpit/cli 0.1.23 → 0.1.24
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 +40 -31
- package/dist/agent-rules.js +41 -18
- package/dist/autostart.js +23 -2
- package/dist/commands/local-args.js +19 -1
- package/dist/commands/local.js +227 -68
- package/dist/commands/public-root.js +4 -3
- package/dist/local-state.js +20 -3
- package/dist/onboarding-roots.js +104 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,27 +11,35 @@ device pairing.
|
|
|
11
11
|
|
|
12
12
|
## One-paste install
|
|
13
13
|
|
|
14
|
-
Run this
|
|
14
|
+
Run this in a terminal. If you are inside `~/BLI`, Cockpit suggests that
|
|
15
|
+
workspace root; otherwise it looks for likely roots such as `~/BLI` and asks
|
|
16
|
+
before collecting. Later reruns remember the approved roots:
|
|
15
17
|
|
|
16
18
|
```bash
|
|
17
19
|
npm install -g @bli-cockpit/cli@latest
|
|
18
|
-
cockpit onboard
|
|
20
|
+
cockpit onboard
|
|
19
21
|
```
|
|
20
22
|
|
|
21
23
|
Interactive onboarding prompts for the dashboard email. Enter the approved
|
|
22
24
|
intern/operator email. For reused laptops, VMs, or headless/agent-run setup,
|
|
23
|
-
pass the email explicitly:
|
|
25
|
+
pass the email and root explicitly:
|
|
24
26
|
|
|
25
27
|
```bash
|
|
26
|
-
cockpit onboard --email <APPROVED_EMAIL> --workspace
|
|
28
|
+
cockpit onboard --email <APPROVED_EMAIL> --workspace ~/BLI
|
|
27
29
|
```
|
|
28
30
|
|
|
29
|
-
If a work folder contains multiple repos,
|
|
31
|
+
If a work folder contains multiple repos, confirm the parent folder as the
|
|
32
|
+
collection root. In a headless setup, pass it explicitly:
|
|
30
33
|
|
|
31
34
|
```bash
|
|
32
|
-
cd ~/Downloads/bluepearl-workspace
|
|
33
35
|
npm install -g @bli-cockpit/cli@latest
|
|
34
|
-
cockpit onboard --workspace
|
|
36
|
+
cockpit onboard --email <APPROVED_EMAIL> --workspace ~/BLI
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
For multiple unrelated collection roots, repeat `--workspace`:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
cockpit onboard --email <APPROVED_EMAIL> --workspace ~/BLI --workspace ~/side-projects
|
|
35
43
|
```
|
|
36
44
|
|
|
37
45
|
The CLI defaults to the production dashboard. Normal intern/operator setup,
|
|
@@ -39,25 +47,23 @@ updates, and syncs omit `--dashboard-url`. Pass `--dashboard-url` only for
|
|
|
39
47
|
staging, a custom dashboard, or deliberately forcing a different dashboard
|
|
40
48
|
pairing. Already-onboarded users update with
|
|
41
49
|
`npm install -g @bli-cockpit/cli@latest`, then run
|
|
42
|
-
`cockpit
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
and every 15 minutes. If skipped, run
|
|
56
|
-
`cockpit autostart install --workspace "$PWD"` later.
|
|
50
|
+
`cockpit onboard` from anywhere to refresh pairing, agent rules, autostart, and
|
|
51
|
+
an initial sync against saved roots. `--repo <path>` remains supported for older
|
|
52
|
+
prompts and the agent ticket-binding guardrail.
|
|
53
|
+
|
|
54
|
+
On machines where Codex or Claude agents will do ticketed work, `cockpit
|
|
55
|
+
onboard` refreshes `~/.codex/AGENTS.md` and `~/.claude/CLAUDE.md` after harvest
|
|
56
|
+
proof. The managed Cockpit ticket-binding block is replaced in place rather
|
|
57
|
+
than duplicated and is scoped to the confirmed root set, so agents should ignore
|
|
58
|
+
it in private chats or unrelated repos.
|
|
59
|
+
|
|
60
|
+
For intern machines, `cockpit onboard` refreshes the launchd autostart agent so
|
|
61
|
+
Cockpit syncs confirmed roots at login and every 15 minutes. For a manual
|
|
62
|
+
repair path, run `cockpit autostart install --workspace <root>` later.
|
|
57
63
|
|
|
58
64
|
```bash
|
|
59
|
-
# Repair/manual path
|
|
60
|
-
cockpit agent-rules install --workspace
|
|
65
|
+
# Repair/manual path.
|
|
66
|
+
cockpit agent-rules install --workspace <root>
|
|
61
67
|
```
|
|
62
68
|
|
|
63
69
|
The direct command updates `~/.codex/AGENTS.md` and `~/.claude/CLAUDE.md` with
|
|
@@ -87,18 +93,21 @@ What happens:
|
|
|
87
93
|
4. Admin approves the persistent email access request from Ambient -> Collector
|
|
88
94
|
approvals. Pair codes still expire, but the access request remains. If the
|
|
89
95
|
terminal is still polling, approval pairs it immediately; if not, the intern
|
|
90
|
-
reruns `cockpit onboard --email <APPROVED_EMAIL> --workspace
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
Supabase invite flow
|
|
96
|
+
reruns `cockpit onboard --email <APPROVED_EMAIL> --workspace ~/BLI` or
|
|
97
|
+
simply `cockpit onboard` after roots are saved, and the stored approval
|
|
98
|
+
auto-pairs without another Admin action. Admin can still paste the printed
|
|
99
|
+
code there as a rescue path. The signed-in intern can still use the printed
|
|
100
|
+
URL. If the claimed email is not an app user yet, a super admin approval
|
|
101
|
+
creates the user, adds team membership, and sends the Supabase invite flow
|
|
102
|
+
automatically.
|
|
96
103
|
5. The CLI starts general ambient capture, uploads private raw evidence objects
|
|
97
104
|
when present (chunked and resumable, with identical content acknowledged
|
|
98
105
|
instead of re-uploaded), then uploads one safe metadata/ref envelope. Text
|
|
99
106
|
evidence with secret-shaped values is sanitized before storage and marked as
|
|
100
107
|
partial evidence with redaction metadata; env files are still never read.
|
|
101
|
-
6. The CLI prints `PASS: Cockpit collector is ready for harvest.`
|
|
108
|
+
6. The CLI prints `PASS: Cockpit collector is ready for harvest.` and a final
|
|
109
|
+
`You're live.` block with collection roots, pairing state, background sync
|
|
110
|
+
state, dashboard link, and `cockpit status` as the next command.
|
|
102
111
|
|
|
103
112
|
`--device-name` is optional and only a readable label in Cockpit. Pass it only
|
|
104
113
|
when you want a specific label such as `"Savina MacBook"` or `"Box VM 42"`.
|
package/dist/agent-rules.js
CHANGED
|
@@ -15,7 +15,11 @@ export async function installAgentRules(options = {}) {
|
|
|
15
15
|
}
|
|
16
16
|
async function installAgentRulesForHost(host, options = {}) {
|
|
17
17
|
const rulesFile = agentRulesFile(host, options.homeDir);
|
|
18
|
-
const block = cockpitAgentRulesBlock({
|
|
18
|
+
const block = cockpitAgentRulesBlock({
|
|
19
|
+
scopePath: options.scopePath,
|
|
20
|
+
scopePaths: options.scopePaths,
|
|
21
|
+
});
|
|
22
|
+
const scopePaths = normalizeScopePaths(options);
|
|
19
23
|
let existing = "";
|
|
20
24
|
let existed = true;
|
|
21
25
|
try {
|
|
@@ -24,7 +28,7 @@ async function installAgentRulesForHost(host, options = {}) {
|
|
|
24
28
|
catch {
|
|
25
29
|
existed = false;
|
|
26
30
|
}
|
|
27
|
-
const prepared = prepareManagedBlockInstall(existing, block,
|
|
31
|
+
const prepared = prepareManagedBlockInstall(existing, block, scopePaths);
|
|
28
32
|
if (!prepared.next) {
|
|
29
33
|
return agentRulesResult(host, rulesFile, "unchanged", block, prepared.state);
|
|
30
34
|
}
|
|
@@ -78,7 +82,11 @@ export async function inspectAgentRules(options = {}) {
|
|
|
78
82
|
}
|
|
79
83
|
async function inspectAgentRulesForHost(host, options = {}) {
|
|
80
84
|
const rulesFile = agentRulesFile(host, options.homeDir);
|
|
81
|
-
const block = cockpitAgentRulesBlock({
|
|
85
|
+
const block = cockpitAgentRulesBlock({
|
|
86
|
+
scopePath: options.scopePath,
|
|
87
|
+
scopePaths: options.scopePaths,
|
|
88
|
+
});
|
|
89
|
+
const scopePaths = normalizeScopePaths(options);
|
|
82
90
|
let existing = "";
|
|
83
91
|
try {
|
|
84
92
|
existing = await readFile(rulesFile, "utf8");
|
|
@@ -89,7 +97,7 @@ async function inspectAgentRulesForHost(host, options = {}) {
|
|
|
89
97
|
installed: false,
|
|
90
98
|
};
|
|
91
99
|
}
|
|
92
|
-
const state = inspectAgentRulesContents(existing, block,
|
|
100
|
+
const state = inspectAgentRulesContents(existing, block, scopePaths);
|
|
93
101
|
const installed = state === "managed" || state === "equivalent";
|
|
94
102
|
return {
|
|
95
103
|
...agentRulesResult(host, rulesFile, installed ? "unchanged" : "missing", block, state),
|
|
@@ -97,10 +105,12 @@ async function inspectAgentRulesForHost(host, options = {}) {
|
|
|
97
105
|
};
|
|
98
106
|
}
|
|
99
107
|
export function cockpitAgentRulesBlock(options = {}) {
|
|
100
|
-
const
|
|
101
|
-
const scopeLine =
|
|
102
|
-
? `- This guidance only applies when the current working directory is inside the Cockpit-onboarded workspace/repo: \`${
|
|
103
|
-
:
|
|
108
|
+
const scopePaths = normalizeScopePaths(options);
|
|
109
|
+
const scopeLine = scopePaths.length === 1
|
|
110
|
+
? `- This guidance only applies when the current working directory is inside the Cockpit-onboarded workspace/repo: \`${scopePaths[0]}\`. Outside that folder, do not run Cockpit ticket binding or sync commands for private chats or unrelated repos.`
|
|
111
|
+
: scopePaths.length > 1
|
|
112
|
+
? `- This guidance only applies when the current working directory is inside one of these Cockpit-onboarded workspace roots: ${scopePaths.map((scopePath) => `\`${scopePath}\``).join(", ")}. Outside those folders, do not run Cockpit ticket binding or sync commands for private chats or unrelated repos.`
|
|
113
|
+
: "- This guidance only applies when the current working directory is inside the workspace/repo that ran `cockpit onboard` or `cockpit agent-rules install`. Outside that folder, do not run Cockpit ticket binding or sync commands for private chats or unrelated repos.";
|
|
104
114
|
return [
|
|
105
115
|
MANAGED_BLOCK_START,
|
|
106
116
|
"## Cockpit Ticket Binding",
|
|
@@ -114,6 +124,19 @@ export function cockpitAgentRulesBlock(options = {}) {
|
|
|
114
124
|
MANAGED_BLOCK_END,
|
|
115
125
|
].join("\n");
|
|
116
126
|
}
|
|
127
|
+
function normalizeScopePaths(options) {
|
|
128
|
+
const raw = options.scopePaths ?? (options.scopePath ? [options.scopePath] : []);
|
|
129
|
+
const seen = new Set();
|
|
130
|
+
const normalized = [];
|
|
131
|
+
for (const scopePath of raw) {
|
|
132
|
+
const resolved = path.resolve(scopePath);
|
|
133
|
+
if (seen.has(resolved))
|
|
134
|
+
continue;
|
|
135
|
+
seen.add(resolved);
|
|
136
|
+
normalized.push(resolved);
|
|
137
|
+
}
|
|
138
|
+
return normalized;
|
|
139
|
+
}
|
|
117
140
|
export function hasManagedBlock(contents) {
|
|
118
141
|
return contents.includes(MANAGED_BLOCK_START) && contents.includes(MANAGED_BLOCK_END);
|
|
119
142
|
}
|
|
@@ -131,7 +154,7 @@ export function removeManagedBlock(contents) {
|
|
|
131
154
|
return contents;
|
|
132
155
|
return contents.replace(managedBlockPattern(), "").replace(/\n{3,}/gu, "\n\n").trimEnd() + "\n";
|
|
133
156
|
}
|
|
134
|
-
function prepareManagedBlockInstall(contents, block,
|
|
157
|
+
function prepareManagedBlockInstall(contents, block, scopePaths = []) {
|
|
135
158
|
if (!contents.trim())
|
|
136
159
|
return { next: `${block}\n`, state: "missing" };
|
|
137
160
|
if (hasManagedBlock(contents)) {
|
|
@@ -140,10 +163,10 @@ function prepareManagedBlockInstall(contents, block, scopePath) {
|
|
|
140
163
|
state: extractManagedBlock(contents) === block ? "managed" : "stale",
|
|
141
164
|
};
|
|
142
165
|
}
|
|
143
|
-
if (hasEquivalentUnmanagedTicketBinding(contents,
|
|
166
|
+
if (hasEquivalentUnmanagedTicketBinding(contents, scopePaths)) {
|
|
144
167
|
return { next: null, state: "equivalent" };
|
|
145
168
|
}
|
|
146
|
-
const staleBlock = findStaleUnmanagedTicketBindingBlock(contents,
|
|
169
|
+
const staleBlock = findStaleUnmanagedTicketBindingBlock(contents, scopePaths);
|
|
147
170
|
if (staleBlock) {
|
|
148
171
|
return {
|
|
149
172
|
next: replaceLineSpan(contents, staleBlock.startLine, staleBlock.endLine, block),
|
|
@@ -152,13 +175,13 @@ function prepareManagedBlockInstall(contents, block, scopePath) {
|
|
|
152
175
|
}
|
|
153
176
|
return { next: `${contents.replace(/\s+$/u, "")}\n\n${block}\n`, state: "missing" };
|
|
154
177
|
}
|
|
155
|
-
function inspectAgentRulesContents(contents, block,
|
|
178
|
+
function inspectAgentRulesContents(contents, block, scopePaths = []) {
|
|
156
179
|
if (hasManagedBlock(contents)) {
|
|
157
180
|
return extractManagedBlock(contents) === block ? "managed" : "stale";
|
|
158
181
|
}
|
|
159
|
-
if (hasEquivalentUnmanagedTicketBinding(contents,
|
|
182
|
+
if (hasEquivalentUnmanagedTicketBinding(contents, scopePaths))
|
|
160
183
|
return "equivalent";
|
|
161
|
-
if (findStaleUnmanagedTicketBindingBlock(contents,
|
|
184
|
+
if (findStaleUnmanagedTicketBindingBlock(contents, scopePaths))
|
|
162
185
|
return "stale";
|
|
163
186
|
return "missing";
|
|
164
187
|
}
|
|
@@ -200,13 +223,13 @@ function managedBlockPattern() {
|
|
|
200
223
|
function extractManagedBlock(contents) {
|
|
201
224
|
return contents.match(managedBlockPattern())?.[0] ?? null;
|
|
202
225
|
}
|
|
203
|
-
function hasEquivalentUnmanagedTicketBinding(contents,
|
|
226
|
+
function hasEquivalentUnmanagedTicketBinding(contents, scopePaths = []) {
|
|
204
227
|
const text = normalizeRuleText(contents);
|
|
205
228
|
if (!hasTicketBindingCues(text))
|
|
206
229
|
return false;
|
|
207
230
|
if (!hasRepoScopeGuard(text))
|
|
208
231
|
return false;
|
|
209
|
-
if (scopePath
|
|
232
|
+
if (scopePaths.some((scopePath) => !hasScopePath(text, scopePath)))
|
|
210
233
|
return false;
|
|
211
234
|
if (!hasTicketLookupOrCreationCue(text))
|
|
212
235
|
return false;
|
|
@@ -231,7 +254,7 @@ function hasRepoScopeGuard(text) {
|
|
|
231
254
|
/outside\s+that\s+(?:folder|workspace|repo).*(?:do\s+not|dont)\s+run\s+cockpit/u.test(text) ||
|
|
232
255
|
/private\s+chats\s+or\s+unrelated\s+repos/u.test(text));
|
|
233
256
|
}
|
|
234
|
-
function findStaleUnmanagedTicketBindingBlock(contents,
|
|
257
|
+
function findStaleUnmanagedTicketBindingBlock(contents, scopePaths = []) {
|
|
235
258
|
const lines = contents.split("\n");
|
|
236
259
|
for (let index = 0; index < lines.length; index += 1) {
|
|
237
260
|
if (!/^#{1,6}\s+.*(?:cockpit\s+)?ticket\s+binding\b/iu.test(lines[index] ?? "")) {
|
|
@@ -247,7 +270,7 @@ function findStaleUnmanagedTicketBindingBlock(contents, scopePath) {
|
|
|
247
270
|
const candidate = lines.slice(index, endLine).join("\n");
|
|
248
271
|
const normalized = normalizeRuleText(candidate);
|
|
249
272
|
if (hasTicketBindingCues(normalized) &&
|
|
250
|
-
!hasEquivalentUnmanagedTicketBinding(candidate,
|
|
273
|
+
!hasEquivalentUnmanagedTicketBinding(candidate, scopePaths)) {
|
|
251
274
|
return { startLine: index, endLine };
|
|
252
275
|
}
|
|
253
276
|
}
|
package/dist/autostart.js
CHANGED
|
@@ -29,7 +29,9 @@ export async function installAutostartAgent(options) {
|
|
|
29
29
|
if (platform !== "darwin")
|
|
30
30
|
return unsupportedResult(options.homeDir);
|
|
31
31
|
const homeDir = options.homeDir ?? os.homedir();
|
|
32
|
-
const
|
|
32
|
+
const workDirs = normalizeWorkDirs(options.repoRoots);
|
|
33
|
+
const workDir = path.resolve(options.repoRoot ?? workDirs[0] ?? process.cwd());
|
|
34
|
+
const resolvedWorkDirs = workDirs.length > 0 ? workDirs : [workDir];
|
|
33
35
|
const dashboardUrl = options.dashboardUrl ?? DEFAULT_DASHBOARD_URL;
|
|
34
36
|
const intervalSeconds = options.intervalSeconds ?? DEFAULT_AUTOSTART_INTERVAL_SECONDS;
|
|
35
37
|
const paths = getCollectorRuntimePaths(homeDir);
|
|
@@ -44,6 +46,7 @@ export async function installAutostartAgent(options) {
|
|
|
44
46
|
await mkdir(paths.state_dir, { recursive: true });
|
|
45
47
|
await writeFile(plistPath, renderPlist({
|
|
46
48
|
workDir,
|
|
49
|
+
workDirs: resolvedWorkDirs,
|
|
47
50
|
dashboardUrl,
|
|
48
51
|
intervalSeconds,
|
|
49
52
|
stdoutPath,
|
|
@@ -62,6 +65,7 @@ export async function installAutostartAgent(options) {
|
|
|
62
65
|
loaded,
|
|
63
66
|
interval_seconds: intervalSeconds,
|
|
64
67
|
work_dir: workDir,
|
|
68
|
+
work_dirs: resolvedWorkDirs,
|
|
65
69
|
dashboard_url: dashboardUrl,
|
|
66
70
|
...(loaded
|
|
67
71
|
? {}
|
|
@@ -131,7 +135,10 @@ function renderPlist(options) {
|
|
|
131
135
|
const dashboardArg = options.dashboardUrl === DEFAULT_DASHBOARD_URL
|
|
132
136
|
? ""
|
|
133
137
|
: ` --dashboard-url ${shellQuote(options.dashboardUrl)}`;
|
|
134
|
-
const
|
|
138
|
+
const roots = options.workDirs.length > 0 ? options.workDirs : [options.workDir];
|
|
139
|
+
const command = roots
|
|
140
|
+
.map((root) => `cockpit sync --workspace ${shellQuote(root)}${dashboardArg} --json`)
|
|
141
|
+
.join("; ");
|
|
135
142
|
return [
|
|
136
143
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
137
144
|
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
@@ -169,6 +176,20 @@ function renderPlist(options) {
|
|
|
169
176
|
"",
|
|
170
177
|
].join("\n");
|
|
171
178
|
}
|
|
179
|
+
function normalizeWorkDirs(repoRoots) {
|
|
180
|
+
if (!repoRoots)
|
|
181
|
+
return [];
|
|
182
|
+
const seen = new Set();
|
|
183
|
+
const normalized = [];
|
|
184
|
+
for (const root of repoRoots) {
|
|
185
|
+
const resolved = path.resolve(root);
|
|
186
|
+
if (seen.has(resolved))
|
|
187
|
+
continue;
|
|
188
|
+
seen.add(resolved);
|
|
189
|
+
normalized.push(resolved);
|
|
190
|
+
}
|
|
191
|
+
return normalized;
|
|
192
|
+
}
|
|
172
193
|
/**
|
|
173
194
|
* Renders the `<key>WatchPaths</key><array>…</array>` lines, or nothing when no
|
|
174
195
|
* transcript dir exists yet (an empty array would tell launchd to watch
|
|
@@ -74,6 +74,7 @@ function parseOnboardArgs(args) {
|
|
|
74
74
|
kind: "onboard",
|
|
75
75
|
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
76
76
|
repoRoot: optionalNonEmpty(workRootFlagValue(values)),
|
|
77
|
+
collectionRoots: optionalNonEmptyList(workRootFlagValues(values)),
|
|
77
78
|
dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
|
|
78
79
|
claimedOwnerEmail: optionalEmail(values.flags.get("--email")),
|
|
79
80
|
deviceName: optionalNonEmpty(values.flags.get("--device-name")),
|
|
@@ -403,6 +404,7 @@ function parseNamedArgs(args, options) {
|
|
|
403
404
|
const allowed = new Set(options.allowedFlags);
|
|
404
405
|
const valueFlags = new Set(options.valueFlags);
|
|
405
406
|
const flags = new Map();
|
|
407
|
+
const flagValues = new Map();
|
|
406
408
|
const booleans = new Set();
|
|
407
409
|
const positionals = [];
|
|
408
410
|
for (let index = 0; index < args.length; index += 1) {
|
|
@@ -422,6 +424,9 @@ function parseNamedArgs(args, options) {
|
|
|
422
424
|
}
|
|
423
425
|
rejectServiceRoleLikeArgument(value);
|
|
424
426
|
flags.set(flag, value);
|
|
427
|
+
const existing = flagValues.get(flag) ?? [];
|
|
428
|
+
existing.push(value);
|
|
429
|
+
flagValues.set(flag, existing);
|
|
425
430
|
if (inlineValue === undefined)
|
|
426
431
|
index += 1;
|
|
427
432
|
}
|
|
@@ -431,7 +436,7 @@ function parseNamedArgs(args, options) {
|
|
|
431
436
|
booleans.add(flag);
|
|
432
437
|
}
|
|
433
438
|
}
|
|
434
|
-
return { flags, booleans, positionals };
|
|
439
|
+
return { flags, flagValues, booleans, positionals };
|
|
435
440
|
}
|
|
436
441
|
function workRootFlagValue(values) {
|
|
437
442
|
const provided = WORK_ROOT_FLAGS.filter((flag) => values.flags.has(flag));
|
|
@@ -443,6 +448,19 @@ function workRootFlagValue(values) {
|
|
|
443
448
|
}
|
|
444
449
|
return values.flags.get("--workspace") ?? values.flags.get("--repo");
|
|
445
450
|
}
|
|
451
|
+
function workRootFlagValues(values) {
|
|
452
|
+
const roots = [];
|
|
453
|
+
for (const flag of WORK_ROOT_FLAGS) {
|
|
454
|
+
roots.push(...(values.flagValues.get(flag) ?? []));
|
|
455
|
+
}
|
|
456
|
+
return roots;
|
|
457
|
+
}
|
|
458
|
+
function optionalNonEmptyList(values) {
|
|
459
|
+
const filtered = values
|
|
460
|
+
.map((value) => optionalNonEmpty(value))
|
|
461
|
+
.filter((value) => Boolean(value));
|
|
462
|
+
return filtered.length > 0 ? filtered : undefined;
|
|
463
|
+
}
|
|
446
464
|
function assertNoPositionals(positionals, command) {
|
|
447
465
|
if (positionals.length > 0) {
|
|
448
466
|
throw new Error(`${command} does not accept positional arguments.`);
|
package/dist/commands/local.js
CHANGED
|
@@ -4,13 +4,14 @@ import path from "node:path";
|
|
|
4
4
|
import { createCollectorServer } from "../server.js";
|
|
5
5
|
import { inspectAgentRules, installAgentRules, uninstallAgentRules, } from "../agent-rules.js";
|
|
6
6
|
import { parseLocalArgs, normalizeUrl } from "./local-args.js";
|
|
7
|
-
import { autostartStatus, installAutostartAgent, uninstallAutostartAgent } from "../autostart.js";
|
|
8
|
-
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext } from "../local-state.js";
|
|
7
|
+
import { autostartStatus, installAutostartAgent, uninstallAutostartAgent, } from "../autostart.js";
|
|
8
|
+
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
|
|
9
9
|
import { scanAndAttributeCodexSessions } from "../adapters/codex-attribution.js";
|
|
10
10
|
import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
|
|
11
11
|
import { acquireSyncLock } from "../sync-lock.js";
|
|
12
12
|
import { discoverGitWorktrees } from "../repo-identity.js";
|
|
13
|
-
import { runAttributedWorktreeSync } from "./session-sync.js";
|
|
13
|
+
import { runAttributedWorktreeSync, } from "./session-sync.js";
|
|
14
|
+
import { COLLECTION_ROOT_REQUIRED, resolveOnboardingRoots, } from "../onboarding-roots.js";
|
|
14
15
|
export const rootCommandNames = new Set([
|
|
15
16
|
"onboard",
|
|
16
17
|
"install",
|
|
@@ -281,68 +282,112 @@ async function promptOnboardEmail(io) {
|
|
|
281
282
|
}
|
|
282
283
|
return answer;
|
|
283
284
|
}
|
|
285
|
+
function onboardingRootPrompt(io) {
|
|
286
|
+
return {
|
|
287
|
+
confirm: async (message) => yesByDefault(await readLine(io, message)),
|
|
288
|
+
input: (message) => readLine(io, message),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
function yesByDefault(raw) {
|
|
292
|
+
const answer = raw.trim().split(/\s+/u)[0]?.toLowerCase() ?? "";
|
|
293
|
+
return answer !== "n" && answer !== "no";
|
|
294
|
+
}
|
|
295
|
+
async function resolveOnboardEmail(command, roots, config, io) {
|
|
296
|
+
if (command.claimedOwnerEmail)
|
|
297
|
+
return command.claimedOwnerEmail;
|
|
298
|
+
const inferred = await inferOnboardEmail(command.homeDir, roots, config);
|
|
299
|
+
const interactive = !command.json && isInteractiveStdin(io);
|
|
300
|
+
if (!inferred) {
|
|
301
|
+
return interactive ? promptOnboardEmail(io) : undefined;
|
|
302
|
+
}
|
|
303
|
+
if (inferred.source === "session") {
|
|
304
|
+
if (!command.json)
|
|
305
|
+
writeLine(io.stdout, `Dashboard email: ${inferred.email}`);
|
|
306
|
+
return inferred.email;
|
|
307
|
+
}
|
|
308
|
+
if (!interactive)
|
|
309
|
+
return inferred.email;
|
|
310
|
+
const answer = (await readLine(io, `Use ${inferred.email} for Cockpit pairing? [Y/n] `)).trim().toLowerCase();
|
|
311
|
+
const typedEmail = answer.split(/\s+/u).find((part) => part.includes("@"));
|
|
312
|
+
if (typedEmail)
|
|
313
|
+
return typedEmail;
|
|
314
|
+
if (yesByDefault(answer)) {
|
|
315
|
+
return inferred.email;
|
|
316
|
+
}
|
|
317
|
+
return promptOnboardEmail(io);
|
|
318
|
+
}
|
|
319
|
+
async function inferOnboardEmail(homeDir, roots, config) {
|
|
320
|
+
const session = await readOnboardSessionReuseCandidate(homeDir).catch(() => null);
|
|
321
|
+
const sessionEmail = normalizeEmailForComparison(session?.email);
|
|
322
|
+
if (session?.session_state === "valid" && sessionEmail) {
|
|
323
|
+
return { email: sessionEmail, source: "session" };
|
|
324
|
+
}
|
|
325
|
+
const configEmail = normalizeEmailForComparison(config?.claimed_owner_email);
|
|
326
|
+
if (configEmail)
|
|
327
|
+
return { email: configEmail, source: "config" };
|
|
328
|
+
const gitEmail = await inferUniqueGitEmail(roots);
|
|
329
|
+
return gitEmail ? { email: gitEmail, source: "git" } : null;
|
|
330
|
+
}
|
|
331
|
+
async function inferUniqueGitEmail(roots) {
|
|
332
|
+
const emails = new Set();
|
|
333
|
+
for (const root of roots) {
|
|
334
|
+
const email = await readGitConfigEmail(root);
|
|
335
|
+
if (email)
|
|
336
|
+
emails.add(email);
|
|
337
|
+
}
|
|
338
|
+
return emails.size === 1 ? [...emails][0] ?? null : null;
|
|
339
|
+
}
|
|
340
|
+
async function readGitConfigEmail(root) {
|
|
341
|
+
return new Promise((resolve) => {
|
|
342
|
+
execFile("git", ["config", "user.email"], { cwd: root, encoding: "utf8" }, (error, stdout) => {
|
|
343
|
+
if (error) {
|
|
344
|
+
resolve(null);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const email = normalizeEmailForComparison(stdout);
|
|
348
|
+
resolve(email?.includes("@") ? email : null);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
}
|
|
284
352
|
/**
|
|
285
|
-
* After a successful onboard,
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
* exec runner (`io.exec`): headless / piped / spawned onboards and tests that
|
|
289
|
-
* pass no exec skip it entirely. Declining leaves onboarding's success untouched.
|
|
353
|
+
* After a successful onboard, refreshes the launchd autostart agent so intern
|
|
354
|
+
* machines keep syncing without anyone re-running Cockpit. Runtimes without an
|
|
355
|
+
* exec runner skip this because launchd installation cannot be attempted there.
|
|
290
356
|
*/
|
|
291
|
-
async function
|
|
292
|
-
if (
|
|
293
|
-
return;
|
|
294
|
-
const answer = (await readLine(io, "Keep Cockpit syncing in the background, even after restart? [Y/n] "))
|
|
295
|
-
.trim()
|
|
296
|
-
.toLowerCase();
|
|
297
|
-
if (answer === "n" || answer === "no") {
|
|
298
|
-
writeLine(io.stdout, "Skipped background autostart. Run `cockpit autostart install` anytime.");
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
357
|
+
async function refreshOnboardAutostart(command, roots, io) {
|
|
358
|
+
if (!io.exec)
|
|
359
|
+
return null;
|
|
301
360
|
const result = await installAutostartAgent({
|
|
302
361
|
homeDir: command.homeDir,
|
|
303
|
-
repoRoot:
|
|
362
|
+
repoRoot: roots[0],
|
|
363
|
+
repoRoots: roots,
|
|
304
364
|
dashboardUrl: command.dashboardUrl,
|
|
305
365
|
exec: io.exec,
|
|
306
366
|
});
|
|
367
|
+
if (command.json)
|
|
368
|
+
return result;
|
|
307
369
|
if (result.status === "unsupported") {
|
|
308
370
|
writeLine(io.stdout, `Background autostart unsupported: ${result.message}`);
|
|
309
|
-
return;
|
|
371
|
+
return result;
|
|
310
372
|
}
|
|
311
373
|
writeLine(io.stdout, result.loaded
|
|
312
|
-
? "Background autostart
|
|
313
|
-
: "Background autostart
|
|
374
|
+
? "Background autostart refreshed; Cockpit syncs at login and every 15 min."
|
|
375
|
+
: "Background autostart refreshed, but launchctl load reported a problem; check `cockpit autostart status`.");
|
|
314
376
|
writeLine(io.stdout, `Plist: ${result.plist_path}`);
|
|
377
|
+
return result;
|
|
315
378
|
}
|
|
316
|
-
async function
|
|
317
|
-
|
|
318
|
-
return;
|
|
319
|
-
const scopePath = path.resolve(command.repoRoot ?? process.cwd());
|
|
320
|
-
const current = await inspectAgentRules({
|
|
379
|
+
async function refreshOnboardAgentRules(command, roots, io) {
|
|
380
|
+
const result = await installAgentRules({
|
|
321
381
|
homeDir: command.homeDir,
|
|
322
|
-
|
|
382
|
+
scopePaths: roots,
|
|
323
383
|
});
|
|
324
|
-
if (
|
|
325
|
-
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
const answer = (await readLine(io, `Add Cockpit ticket-binding rules scoped to ${scopePath} to AGENTS.md and CLAUDE.md? [Y/n] `))
|
|
329
|
-
.trim()
|
|
330
|
-
.toLowerCase();
|
|
331
|
-
if (answer === "n" || answer === "no") {
|
|
332
|
-
writeLine(io.stdout, "Skipped agent rules. Run `cockpit agent-rules install --workspace \"$PWD\"` anytime.");
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
335
|
-
const result = await installAgentRules({ homeDir: command.homeDir, scopePath });
|
|
384
|
+
if (command.json)
|
|
385
|
+
return result;
|
|
336
386
|
writeLine(io.stdout, `Agent rules: ${onboardAgentRulesInstallLine(result)}`);
|
|
337
387
|
for (const target of result.targets) {
|
|
338
388
|
writeLine(io.stdout, `${agentRuleHostLabel(target.host)}: ${target.rules_file}`);
|
|
339
389
|
}
|
|
340
|
-
|
|
341
|
-
function onboardAgentRulesAlreadyInstalledLine(result) {
|
|
342
|
-
const hasEquivalent = result.targets.some((target) => target.state === "equivalent");
|
|
343
|
-
return hasEquivalent
|
|
344
|
-
? "Agent rules: matching Cockpit ticket-binding guidance already exists."
|
|
345
|
-
: "Agent rules: already current in AGENTS.md and CLAUDE.md.";
|
|
390
|
+
return result;
|
|
346
391
|
}
|
|
347
392
|
function onboardAgentRulesInstallLine(result) {
|
|
348
393
|
if (result.targets.some((target) => target.stale_block_replaced)) {
|
|
@@ -359,24 +404,78 @@ function onboardAgentRulesInstallLine(result) {
|
|
|
359
404
|
return "not installed.";
|
|
360
405
|
}
|
|
361
406
|
}
|
|
407
|
+
function writeOnboardLiveStatus(io, command, roots, options) {
|
|
408
|
+
writeLine(io.stdout, "You're live.");
|
|
409
|
+
writeLine(io.stdout, "Collecting from:");
|
|
410
|
+
for (const root of roots)
|
|
411
|
+
writeLine(io.stdout, `- ${root}`);
|
|
412
|
+
writeLine(io.stdout, `Paired user: ${options.pair?.session.email ??
|
|
413
|
+
options.pair?.session.auth_subject_id ??
|
|
414
|
+
(options.status?.session_state === "valid" ? "existing valid session" : "pending")}`);
|
|
415
|
+
writeLine(io.stdout, `Background sync: ${backgroundSyncLine(options.autostart)}`);
|
|
416
|
+
writeLine(io.stdout, `Initial sync: ${options.initialSyncOk ? options.sync?.status ?? "uploaded" : "blocked"}`);
|
|
417
|
+
writeLine(io.stdout, `Agent rules: ${options.agentRules ? onboardAgentRulesInstallLine(options.agentRules) : "not refreshed"}`);
|
|
418
|
+
writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}/my-work`);
|
|
419
|
+
writeLine(io.stdout, "Next: cockpit status");
|
|
420
|
+
}
|
|
421
|
+
function backgroundSyncLine(result) {
|
|
422
|
+
if (!result)
|
|
423
|
+
return "not refreshed (launchd runner unavailable)";
|
|
424
|
+
if (result.status === "unsupported")
|
|
425
|
+
return `unsupported (${result.message})`;
|
|
426
|
+
if (result.status === "installed" && result.loaded)
|
|
427
|
+
return "installed";
|
|
428
|
+
if (result.status === "installed") {
|
|
429
|
+
return `installed with warning (${result.message ?? "launchctl load failed"})`;
|
|
430
|
+
}
|
|
431
|
+
return result.status;
|
|
432
|
+
}
|
|
362
433
|
async function runOnboard(command, io) {
|
|
363
434
|
let install = null;
|
|
364
435
|
let pair = null;
|
|
365
436
|
let sync = null;
|
|
366
437
|
let status = null;
|
|
438
|
+
let rootsResult = null;
|
|
439
|
+
let agentRules = null;
|
|
440
|
+
let autostart = null;
|
|
367
441
|
try {
|
|
368
442
|
if (!command.json) {
|
|
369
443
|
writeLine(io.stdout, "Cockpit harvest onboarding");
|
|
370
444
|
writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}`);
|
|
371
445
|
writeLine(io.stdout, `Ticket: ${command.activeTicketId ?? "general ambient"}`);
|
|
372
446
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
447
|
+
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
448
|
+
const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
|
|
449
|
+
const interactive = !command.json && isInteractiveStdin(io);
|
|
450
|
+
rootsResult = await resolveOnboardingRoots({
|
|
451
|
+
homeDir: command.homeDir,
|
|
452
|
+
explicitRoots: command.collectionRoots ?? (command.repoRoot ? [command.repoRoot] : []),
|
|
453
|
+
config: existingConfig,
|
|
454
|
+
interactive,
|
|
455
|
+
prompt: interactive ? onboardingRootPrompt(io) : undefined,
|
|
456
|
+
});
|
|
457
|
+
const collectionRoots = rootsResult.roots;
|
|
458
|
+
const primaryRoot = collectionRoots[0];
|
|
459
|
+
if (!primaryRoot) {
|
|
460
|
+
throw new Error(`${COLLECTION_ROOT_REQUIRED}: no collection root confirmed.`);
|
|
461
|
+
}
|
|
462
|
+
const resolvedCommand = {
|
|
463
|
+
...command,
|
|
464
|
+
repoRoot: primaryRoot,
|
|
465
|
+
collectionRoots,
|
|
466
|
+
};
|
|
467
|
+
const replaceRepoRoots = rootsResult.source === "prompt" &&
|
|
468
|
+
!command.collectionRoots?.length &&
|
|
469
|
+
(existingConfig?.default_repo_paths.length ?? 0) > 0;
|
|
470
|
+
if (!command.json) {
|
|
471
|
+
writeLine(io.stdout, `Collecting from: ${collectionRoots.join(", ")}`);
|
|
376
472
|
}
|
|
473
|
+
const claimedOwnerEmail = await resolveOnboardEmail(resolvedCommand, collectionRoots, existingConfig, io);
|
|
377
474
|
install = await installLocalCollector({
|
|
378
475
|
homeDir: command.homeDir,
|
|
379
|
-
repoRoot:
|
|
476
|
+
repoRoot: primaryRoot,
|
|
477
|
+
repoRoots: collectionRoots,
|
|
478
|
+
replaceRepoRoots,
|
|
380
479
|
dashboardUrl: command.dashboardUrl,
|
|
381
480
|
deviceName: command.deviceName,
|
|
382
481
|
});
|
|
@@ -386,7 +485,7 @@ async function runOnboard(command, io) {
|
|
|
386
485
|
}
|
|
387
486
|
const installedStatus = await inspectLocalCollectorStatus({
|
|
388
487
|
homeDir: command.homeDir,
|
|
389
|
-
repoRoot:
|
|
488
|
+
repoRoot: primaryRoot,
|
|
390
489
|
branch: command.branch,
|
|
391
490
|
});
|
|
392
491
|
const installedSession = await readOnboardSessionReuseCandidate(command.homeDir);
|
|
@@ -421,26 +520,41 @@ async function runOnboard(command, io) {
|
|
|
421
520
|
writeLine(io.stdout, `Device: ${pair.session.device_name ?? pair.session.device_id ?? "unknown"}`);
|
|
422
521
|
}
|
|
423
522
|
}
|
|
424
|
-
const worktrees = await discoverCommandWorktrees(
|
|
523
|
+
const worktrees = await discoverCommandWorktrees(collectionRoots, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
|
|
425
524
|
if (worktrees.length > 1) {
|
|
426
|
-
const multi = await runMultiRepoOnboard(
|
|
525
|
+
const multi = await runMultiRepoOnboard(resolvedCommand, io, worktrees);
|
|
526
|
+
if (multi.ok) {
|
|
527
|
+
agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
|
|
528
|
+
autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
|
|
529
|
+
}
|
|
427
530
|
if (command.json) {
|
|
428
531
|
writeLine(io.stdout, JSON.stringify({
|
|
429
|
-
...onboardResult(multi.ok ? "pass" : "blocked",
|
|
532
|
+
...onboardResult(multi.ok ? "pass" : "blocked", resolvedCommand, install, pair, null, null),
|
|
533
|
+
collection_roots: collectionRoots,
|
|
534
|
+
root_resolution: rootsResult,
|
|
535
|
+
agent_rules: agentRules,
|
|
536
|
+
autostart,
|
|
430
537
|
mode: "multi_repo",
|
|
431
538
|
repos: multi.results,
|
|
432
539
|
codex_sessions: multi.codex_sessions,
|
|
433
540
|
}, null, 2));
|
|
434
541
|
}
|
|
435
|
-
if (multi.ok) {
|
|
436
|
-
|
|
437
|
-
|
|
542
|
+
if (multi.ok && !command.json) {
|
|
543
|
+
writeOnboardLiveStatus(io, resolvedCommand, collectionRoots, {
|
|
544
|
+
pair,
|
|
545
|
+
status,
|
|
546
|
+
sync: null,
|
|
547
|
+
agentRules,
|
|
548
|
+
autostart,
|
|
549
|
+
initialSyncOk: true,
|
|
550
|
+
});
|
|
438
551
|
}
|
|
439
552
|
return multi.ok ? 0 : 1;
|
|
440
553
|
}
|
|
554
|
+
const worktreeRoot = worktrees[0]?.repo_root ?? primaryRoot;
|
|
441
555
|
const context = await startLocalWorkContext({
|
|
442
556
|
homeDir: command.homeDir,
|
|
443
|
-
repoRoot:
|
|
557
|
+
repoRoot: worktreeRoot,
|
|
444
558
|
branch: command.branch,
|
|
445
559
|
activeTicketId: command.activeTicketId,
|
|
446
560
|
});
|
|
@@ -464,13 +578,15 @@ async function runOnboard(command, io) {
|
|
|
464
578
|
}
|
|
465
579
|
status = await inspectLocalCollectorStatus({
|
|
466
580
|
homeDir: command.homeDir,
|
|
467
|
-
repoRoot:
|
|
581
|
+
repoRoot: worktreeRoot,
|
|
468
582
|
branch: command.branch,
|
|
469
583
|
});
|
|
470
584
|
if (sync.status !== "uploaded") {
|
|
471
585
|
if (command.json) {
|
|
472
586
|
writeLine(io.stdout, JSON.stringify({
|
|
473
|
-
...onboardResult("blocked",
|
|
587
|
+
...onboardResult("blocked", resolvedCommand, install, pair, sync, status),
|
|
588
|
+
collection_roots: collectionRoots,
|
|
589
|
+
root_resolution: rootsResult,
|
|
474
590
|
codex_sessions: run.summary,
|
|
475
591
|
}, null, 2));
|
|
476
592
|
}
|
|
@@ -482,8 +598,14 @@ async function runOnboard(command, io) {
|
|
|
482
598
|
return 1;
|
|
483
599
|
}
|
|
484
600
|
if (command.json) {
|
|
601
|
+
agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
|
|
602
|
+
autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
|
|
485
603
|
writeLine(io.stdout, JSON.stringify({
|
|
486
|
-
...onboardResult("pass",
|
|
604
|
+
...onboardResult("pass", resolvedCommand, install, pair, sync, status),
|
|
605
|
+
collection_roots: collectionRoots,
|
|
606
|
+
root_resolution: rootsResult,
|
|
607
|
+
agent_rules: agentRules,
|
|
608
|
+
autostart,
|
|
487
609
|
codex_sessions: run.summary,
|
|
488
610
|
}, null, 2));
|
|
489
611
|
return 0;
|
|
@@ -500,22 +622,35 @@ async function runOnboard(command, io) {
|
|
|
500
622
|
writeLine(io.stdout, `Upload state: ${status.upload_state}`);
|
|
501
623
|
writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
|
|
502
624
|
writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
|
|
503
|
-
await
|
|
504
|
-
await
|
|
625
|
+
agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
|
|
626
|
+
autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
|
|
627
|
+
writeOnboardLiveStatus(io, resolvedCommand, collectionRoots, {
|
|
628
|
+
pair,
|
|
629
|
+
status,
|
|
630
|
+
sync,
|
|
631
|
+
agentRules,
|
|
632
|
+
autostart,
|
|
633
|
+
initialSyncOk: true,
|
|
634
|
+
});
|
|
505
635
|
return 0;
|
|
506
636
|
}
|
|
507
637
|
catch (error) {
|
|
508
638
|
const message = errorMessage(error);
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
639
|
+
const statusRoot = rootsResult?.roots[0] ?? command.repoRoot;
|
|
640
|
+
status = statusRoot
|
|
641
|
+
? await inspectLocalCollectorStatus({
|
|
642
|
+
homeDir: command.homeDir,
|
|
643
|
+
repoRoot: statusRoot,
|
|
644
|
+
branch: command.branch,
|
|
645
|
+
}).catch(() => null)
|
|
646
|
+
: null;
|
|
514
647
|
const blocker = classifyOnboardBlocker(message);
|
|
515
648
|
const nextStep = nextStepForOnboardBlocker(blocker);
|
|
516
649
|
if (command.json) {
|
|
517
650
|
writeLine(io.stdout, JSON.stringify({
|
|
518
651
|
...onboardResult("blocked", command, install, pair, sync, status),
|
|
652
|
+
collection_roots: rootsResult?.roots ?? [],
|
|
653
|
+
root_resolution: rootsResult,
|
|
519
654
|
blocker,
|
|
520
655
|
message,
|
|
521
656
|
next_step: nextStep,
|
|
@@ -625,6 +760,13 @@ function cursorStatusLine(sync) {
|
|
|
625
760
|
const DEFAULT_DISCOVERY_MAX_DEPTH = 3;
|
|
626
761
|
const DEFAULT_DISCOVERY_MAX_REPOS = 50;
|
|
627
762
|
async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
|
|
763
|
+
if (Array.isArray(repoRoot)) {
|
|
764
|
+
const discovered = [];
|
|
765
|
+
for (const root of repoRoot) {
|
|
766
|
+
discovered.push(...(await discoverCommandWorktrees(root, discovery, io)));
|
|
767
|
+
}
|
|
768
|
+
return dedupeWorktrees(discovered);
|
|
769
|
+
}
|
|
628
770
|
const maxWorktrees = discovery.maxRepos ?? DEFAULT_DISCOVERY_MAX_REPOS;
|
|
629
771
|
const worktrees = await discoverGitWorktrees(repoRoot ?? process.cwd(), {
|
|
630
772
|
maxDepth: discovery.maxDepth ?? DEFAULT_DISCOVERY_MAX_DEPTH,
|
|
@@ -638,6 +780,18 @@ async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
|
|
|
638
780
|
}
|
|
639
781
|
return worktrees;
|
|
640
782
|
}
|
|
783
|
+
function dedupeWorktrees(worktrees) {
|
|
784
|
+
const seen = new Set();
|
|
785
|
+
const deduped = [];
|
|
786
|
+
for (const worktree of worktrees) {
|
|
787
|
+
const key = worktree.worktree_fingerprint || path.resolve(worktree.repo_root);
|
|
788
|
+
if (seen.has(key))
|
|
789
|
+
continue;
|
|
790
|
+
seen.add(key);
|
|
791
|
+
deduped.push(worktree);
|
|
792
|
+
}
|
|
793
|
+
return deduped;
|
|
794
|
+
}
|
|
641
795
|
async function runMultiRepoOnboard(command, io, worktrees) {
|
|
642
796
|
if (!command.json) {
|
|
643
797
|
writeLine(io.stdout, `3/5 Parent folder mode: discovered ${worktrees.length} git worktree(s).`);
|
|
@@ -743,6 +897,7 @@ function onboardResult(resultStatus, command, install, pair, sync, status) {
|
|
|
743
897
|
work_context_id: sync?.work_context_id ?? status?.work_context_id ?? null,
|
|
744
898
|
config_file: install?.paths.config_file ?? status?.config_file ?? null,
|
|
745
899
|
session_file: install?.paths.session_file ?? status?.session_file ?? null,
|
|
900
|
+
collection_roots: command.collectionRoots ?? (command.repoRoot ? [command.repoRoot] : []),
|
|
746
901
|
paired_user: pair?.session.email ?? pair?.session.auth_subject_id ?? null,
|
|
747
902
|
paired_device: pair?.session.device_name ?? pair?.session.device_id ?? null,
|
|
748
903
|
upload_status: sync?.status ?? null,
|
|
@@ -756,6 +911,8 @@ function onboardResult(resultStatus, command, install, pair, sync, status) {
|
|
|
756
911
|
};
|
|
757
912
|
}
|
|
758
913
|
function classifyOnboardBlocker(message) {
|
|
914
|
+
if (message.includes(COLLECTION_ROOT_REQUIRED))
|
|
915
|
+
return COLLECTION_ROOT_REQUIRED;
|
|
759
916
|
if (/ticket/i.test(message))
|
|
760
917
|
return "ticket";
|
|
761
918
|
if (/pair|paired|approval|expired|revoked/i.test(message))
|
|
@@ -772,6 +929,8 @@ function classifyOnboardBlocker(message) {
|
|
|
772
929
|
}
|
|
773
930
|
function nextStepForOnboardBlocker(blocker) {
|
|
774
931
|
switch (blocker) {
|
|
932
|
+
case COLLECTION_ROOT_REQUIRED:
|
|
933
|
+
return "Run `cockpit onboard --workspace <path>` or rerun in a terminal and confirm the collection root.";
|
|
775
934
|
case "ticket":
|
|
776
935
|
case "ticket_binding":
|
|
777
936
|
return "Run `cockpit start --ticket <id>` when actual ticket work begins, then run `cockpit sync`.";
|
|
@@ -23,9 +23,10 @@ function cockpitHelp() {
|
|
|
23
23
|
localCommandHelp(),
|
|
24
24
|
"",
|
|
25
25
|
"Install/update: `npm install -g @bli-cockpit/cli@latest`.",
|
|
26
|
-
"Intern path: run `cockpit onboard
|
|
27
|
-
"
|
|
28
|
-
"
|
|
26
|
+
"Intern path: run `cockpit onboard`; it confirms a `/BLI` collection root before syncing.",
|
|
27
|
+
"Headless/reused laptop path: `cockpit onboard --email <email> --workspace ~/BLI`.",
|
|
28
|
+
"Already onboarded: rerun `cockpit onboard` from anywhere to refresh pairing, roots, agent rules, autostart, and sync.",
|
|
29
|
+
"Agent setup: `cockpit onboard` refreshes AGENTS.md/CLAUDE.md rules; use `cockpit agent-rules install --workspace ~/BLI` for repair.",
|
|
29
30
|
"Dashboard URL is optional for normal production use; pass `--dashboard-url` only for staging/custom dashboards or forced re-pairing.",
|
|
30
31
|
"Manual collector path: `install`, `login`, `start [--ticket <id>] [--topic <label>] [--intent <intent>] [--phase <phase>]`, `sync`, `status`, `agent-rules`.",
|
|
31
32
|
].join("\n");
|
package/dist/local-state.js
CHANGED
|
@@ -21,12 +21,15 @@ export function getCollectorRuntimePaths(homeDir = os.homedir()) {
|
|
|
21
21
|
}
|
|
22
22
|
export async function installLocalCollector(options = {}) {
|
|
23
23
|
const homeDir = options.homeDir ?? os.homedir();
|
|
24
|
-
const
|
|
24
|
+
const repoRoots = normalizeRepoRoots(options.repoRoots);
|
|
25
|
+
const repoRoot = path.resolve(options.repoRoot ?? repoRoots[0] ?? process.cwd());
|
|
25
26
|
const paths = getCollectorRuntimePaths(homeDir);
|
|
26
27
|
await ensureRuntimeDirectories(paths);
|
|
27
28
|
const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
|
|
28
|
-
const defaultRepoPaths = new Set(existingConfig?.default_repo_paths ?? []);
|
|
29
|
-
|
|
29
|
+
const defaultRepoPaths = new Set(options.replaceRepoRoots ? [] : (existingConfig?.default_repo_paths ?? []));
|
|
30
|
+
for (const root of repoRoots.length > 0 ? repoRoots : [repoRoot]) {
|
|
31
|
+
defaultRepoPaths.add(root);
|
|
32
|
+
}
|
|
30
33
|
const rawEvidenceUpload = existingConfig?.raw_evidence_upload === "disabled" ||
|
|
31
34
|
existingConfig?.raw_evidence_upload === "remote_short_retention_opt_in"
|
|
32
35
|
? "remote_durable_opt_in"
|
|
@@ -55,6 +58,20 @@ export async function installLocalCollector(options = {}) {
|
|
|
55
58
|
message: "Local collector installed. Pair/login is still required before remote upload.",
|
|
56
59
|
};
|
|
57
60
|
}
|
|
61
|
+
function normalizeRepoRoots(repoRoots) {
|
|
62
|
+
if (!repoRoots)
|
|
63
|
+
return [];
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
const normalized = [];
|
|
66
|
+
for (const root of repoRoots) {
|
|
67
|
+
const resolved = path.resolve(root);
|
|
68
|
+
if (seen.has(resolved))
|
|
69
|
+
continue;
|
|
70
|
+
seen.add(resolved);
|
|
71
|
+
normalized.push(resolved);
|
|
72
|
+
}
|
|
73
|
+
return normalized;
|
|
74
|
+
}
|
|
58
75
|
export async function pairLocalCollector(options = {}) {
|
|
59
76
|
const homeDir = options.homeDir ?? os.homedir();
|
|
60
77
|
const paths = getCollectorRuntimePaths(homeDir);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export const COLLECTION_ROOT_REQUIRED = "collection_root_required";
|
|
5
|
+
export async function resolveOnboardingRoots(options) {
|
|
6
|
+
const explicitRoots = normalizeRoots(options.explicitRoots ?? []);
|
|
7
|
+
if (explicitRoots.length > 0) {
|
|
8
|
+
return { roots: explicitRoots, source: "explicit", confirmed: true };
|
|
9
|
+
}
|
|
10
|
+
const savedRoots = normalizeRoots([
|
|
11
|
+
...(options.savedRoots ?? []),
|
|
12
|
+
...(options.config?.default_repo_paths ?? []),
|
|
13
|
+
]);
|
|
14
|
+
if (savedRoots.length > 0) {
|
|
15
|
+
if (!options.interactive) {
|
|
16
|
+
return { roots: savedRoots, source: "saved", confirmed: false };
|
|
17
|
+
}
|
|
18
|
+
const confirmed = await requirePrompt(options).confirm(savedRootsPrompt(savedRoots));
|
|
19
|
+
if (confirmed) {
|
|
20
|
+
return { roots: savedRoots, source: "saved", confirmed: true };
|
|
21
|
+
}
|
|
22
|
+
return promptForRoots(options, "Collection root(s), comma-separated: ");
|
|
23
|
+
}
|
|
24
|
+
if (!options.interactive) {
|
|
25
|
+
throw new Error(`${COLLECTION_ROOT_REQUIRED}: pass --workspace <path> or run cockpit onboard in a terminal to confirm a collection root.`);
|
|
26
|
+
}
|
|
27
|
+
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
28
|
+
const cwdRoot = likelyBliRootFromCwd(cwd);
|
|
29
|
+
if (cwdRoot) {
|
|
30
|
+
const confirmed = await requirePrompt(options).confirm(`Collect from ${cwdRoot}? [Y/n] `);
|
|
31
|
+
if (confirmed) {
|
|
32
|
+
return { roots: [cwdRoot], source: "cwd_likely_root", confirmed: true };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const homeRoot = path.join(options.homeDir ?? os.homedir(), "BLI");
|
|
36
|
+
if (await directoryExists(homeRoot)) {
|
|
37
|
+
const confirmed = await requirePrompt(options).confirm(`I found ${homeRoot}. Collect from there? [Y/n] `);
|
|
38
|
+
if (confirmed) {
|
|
39
|
+
return { roots: [path.resolve(homeRoot)], source: "home_likely_root", confirmed: true };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return promptForRoots(options, "Collection root(s), comma-separated: ");
|
|
43
|
+
}
|
|
44
|
+
export function normalizeRoots(roots) {
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
const normalized = [];
|
|
47
|
+
for (const root of roots) {
|
|
48
|
+
const trimmed = root.trim();
|
|
49
|
+
if (!trimmed)
|
|
50
|
+
continue;
|
|
51
|
+
for (const candidate of splitRootInput(trimmed)) {
|
|
52
|
+
const resolved = path.resolve(candidate);
|
|
53
|
+
if (resolved === path.parse(resolved).root)
|
|
54
|
+
continue;
|
|
55
|
+
if (resolved === os.homedir())
|
|
56
|
+
continue;
|
|
57
|
+
if (seen.has(resolved))
|
|
58
|
+
continue;
|
|
59
|
+
seen.add(resolved);
|
|
60
|
+
normalized.push(resolved);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return normalized;
|
|
64
|
+
}
|
|
65
|
+
function splitRootInput(value) {
|
|
66
|
+
return value
|
|
67
|
+
.split(/[,\n]/u)
|
|
68
|
+
.map((part) => part.trim())
|
|
69
|
+
.filter(Boolean);
|
|
70
|
+
}
|
|
71
|
+
async function promptForRoots(options, message) {
|
|
72
|
+
const prompt = requirePrompt(options);
|
|
73
|
+
const roots = normalizeRoots([await prompt.input(message)]);
|
|
74
|
+
if (roots.length === 0) {
|
|
75
|
+
throw new Error(`${COLLECTION_ROOT_REQUIRED}: no collection root confirmed; pass --workspace <path>.`);
|
|
76
|
+
}
|
|
77
|
+
return { roots, source: "prompt", confirmed: true };
|
|
78
|
+
}
|
|
79
|
+
function savedRootsPrompt(roots) {
|
|
80
|
+
if (roots.length === 1) {
|
|
81
|
+
return `I'm collecting from ${roots[0]}. Still true? [Y/n] `;
|
|
82
|
+
}
|
|
83
|
+
return [
|
|
84
|
+
"I'm collecting from:",
|
|
85
|
+
...roots.map((root) => `- ${root}`),
|
|
86
|
+
"Still true? [Y/n] ",
|
|
87
|
+
].join("\n");
|
|
88
|
+
}
|
|
89
|
+
function requirePrompt(options) {
|
|
90
|
+
if (!options.prompt) {
|
|
91
|
+
throw new Error(`${COLLECTION_ROOT_REQUIRED}: interactive prompt unavailable.`);
|
|
92
|
+
}
|
|
93
|
+
return options.prompt;
|
|
94
|
+
}
|
|
95
|
+
function likelyBliRootFromCwd(cwd) {
|
|
96
|
+
const parts = cwd.split(path.sep).filter(Boolean);
|
|
97
|
+
const index = parts.lastIndexOf("BLI");
|
|
98
|
+
if (index < 0)
|
|
99
|
+
return null;
|
|
100
|
+
return `${path.sep}${parts.slice(0, index + 1).join(path.sep)}`;
|
|
101
|
+
}
|
|
102
|
+
async function directoryExists(dir) {
|
|
103
|
+
return fs.stat(dir).then((stats) => stats.isDirectory(), () => false);
|
|
104
|
+
}
|