@bli-cockpit/cli 0.2.67 → 0.2.69
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/autostart-darwin-plist.js +15 -1
- package/dist/commands/autostart-reading.js +173 -0
- package/dist/commands/cal.js +364 -0
- package/dist/commands/doctor-registration.js +25 -29
- package/dist/commands/local-args-tower-cal.js +165 -0
- package/dist/commands/local-args-tower.js +3 -1
- package/dist/commands/local-args.js +3 -1
- package/dist/commands/local-help-commands-tower.js +163 -0
- package/dist/commands/local-help-commands.js +9 -121
- package/dist/commands/local-help.js +2 -0
- package/dist/commands/local.js +3 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/setup-receipt.js +25 -44
- package/package.json +2 -2
|
@@ -81,8 +81,22 @@ export async function writeDarwinAutostartPlist(options) {
|
|
|
81
81
|
*/
|
|
82
82
|
export async function darwinAgentProblems(options, settings) {
|
|
83
83
|
const homeDir = options.homeDir ?? os.homedir();
|
|
84
|
-
const nodeExecutable = (await schedulerNodeExecutable(options, "darwin")).path;
|
|
85
84
|
const plist = await readFile(plistPathFor(homeDir), "utf8").catch(() => "");
|
|
85
|
+
if (!plist)
|
|
86
|
+
return ["plist could not be read"];
|
|
87
|
+
// A caller that named no roots gave us nothing to compare against:
|
|
88
|
+
// `resolveAutostartSettings` fills `work_dirs` from `process.cwd()`, so the
|
|
89
|
+
// comparison below would reject a perfectly good plist for naming a
|
|
90
|
+
// workspace nobody asked us to expect (BLI-3793 — that is how the setup
|
|
91
|
+
// receipt printed `autostart_not_loaded` beside a green doctor row). The
|
|
92
|
+
// Windows arm has refused this comparison since it was written
|
|
93
|
+
// (`syncScriptProblem`); macOS refuses it now too. Whether launchd HAS the
|
|
94
|
+
// agent is still answered — by `launchctl list`, in the sibling module.
|
|
95
|
+
if (!options.repoRoots || options.repoRoots.length === 0) {
|
|
96
|
+
console.error("[autostart] registration not compared against expectations", JSON.stringify({ reason: "no_roots_supplied", label: AUTOSTART_LABEL }));
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
const nodeExecutable = (await schedulerNodeExecutable(options, "darwin")).path;
|
|
86
100
|
return darwinAgentRegistrationProblems(plist, {
|
|
87
101
|
discoveryArgs: await savedDiscoveryLimitArgs(homeDir),
|
|
88
102
|
workDirs: settings.work_dirs,
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { autostartStatus, registeredRuntimePathProblems, } from "../autostart.js";
|
|
2
|
+
import { redactedHealthDetail } from "../health-detail.js";
|
|
3
|
+
import { getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
|
|
4
|
+
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
5
|
+
/**
|
|
6
|
+
* "Is background collection actually registered and running on this host?" —
|
|
7
|
+
* asked ONCE, here (BLI-3793).
|
|
8
|
+
*
|
|
9
|
+
* Two surfaces print that answer: doctor's `autostart-alive` row and the setup
|
|
10
|
+
* receipt's `collector autostart` word. Until this module they asked the same
|
|
11
|
+
* function DIFFERENTLY, and so printed opposite answers in one run:
|
|
12
|
+
*
|
|
13
|
+
* ✅ autostart-alive background sync is running
|
|
14
|
+
* collector autostart ✗ (autostart_not_loaded) — Run `cockpit autostart install`.
|
|
15
|
+
*
|
|
16
|
+
* The doctor row handed `autostartStatus` the machine's saved collection
|
|
17
|
+
* roots; the receipt handed it `{homeDir, exec}` and nothing else. With no
|
|
18
|
+
* roots, `resolveAutostartSettings` fills `work_dirs` from `process.cwd()`,
|
|
19
|
+
* the launchd read-back compares the plist against a registration nobody ever
|
|
20
|
+
* asked for, the comparison fails, and a perfectly loaded agent reads
|
|
21
|
+
* `not_loaded`. The receipt therefore said "✓" only when the operator happened
|
|
22
|
+
* to be standing in their own collection root. Proven on the reference Mac:
|
|
23
|
+
* the same plist read `loaded` with roots passed and `not_loaded` without,
|
|
24
|
+
* flipping back to `loaded` when the process chdir'd into the saved root.
|
|
25
|
+
*
|
|
26
|
+
* So: one reader, one state, ONE reason label. `autostartDoctorRow` and
|
|
27
|
+
* `autostartSetupPiece` below only choose words for a state this file already
|
|
28
|
+
* decided — they never re-ask the host. Both host families go through the same
|
|
29
|
+
* `autostartStatus` front door, so the Windows probe keeps its own
|
|
30
|
+
* implementation behind this same contract.
|
|
31
|
+
*/
|
|
32
|
+
const TAG = "[autostart reading]";
|
|
33
|
+
/**
|
|
34
|
+
* The machine's approved collection roots as the SCHEDULER should have them:
|
|
35
|
+
* an explicit `--workspace` when one was given, otherwise what the config on
|
|
36
|
+
* this host saved. Honours `homeDir` so a `--home` run reads the same machine
|
|
37
|
+
* its receipt reads.
|
|
38
|
+
*/
|
|
39
|
+
export async function autostartRegistrationRoots(homeDir, repoRoot) {
|
|
40
|
+
if (repoRoot)
|
|
41
|
+
return [repoRoot];
|
|
42
|
+
const config = await readLocalCollectorConfig(getCollectorRuntimePaths(homeDir)).catch(() => null);
|
|
43
|
+
return normalizeCollectionRoots(config?.default_repo_paths ?? []);
|
|
44
|
+
}
|
|
45
|
+
/** The ONE read of the host's scheduler. Never throws; an unaskable host is
|
|
46
|
+
* `unreadable` with a reason, never "absent" (BLI-2541: never report a state
|
|
47
|
+
* you did not observe). */
|
|
48
|
+
export async function readAutostartRegistration(options) {
|
|
49
|
+
const exec = options.exec;
|
|
50
|
+
if (!exec) {
|
|
51
|
+
return {
|
|
52
|
+
state: "unreadable",
|
|
53
|
+
reason: "autostart_runner_unavailable",
|
|
54
|
+
message: "the operating-system scheduler could not be asked: no process runner on this run",
|
|
55
|
+
roots: [],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const roots = await autostartRegistrationRoots(options.homeDir, options.repoRoot).catch(() => []);
|
|
59
|
+
const result = await autostartStatus({
|
|
60
|
+
exec,
|
|
61
|
+
repoRoots: roots,
|
|
62
|
+
...(roots[0] ? { repoRoot: roots[0] } : {}),
|
|
63
|
+
...(options.homeDir ? { homeDir: options.homeDir } : {}),
|
|
64
|
+
...(options.dashboardUrl ? { dashboardUrl: options.dashboardUrl } : {}),
|
|
65
|
+
...(options.platform ? { platform: options.platform } : {}),
|
|
66
|
+
}).catch((error) => {
|
|
67
|
+
console.error(`${TAG} scheduler probe threw`, JSON.stringify({
|
|
68
|
+
reason: "autostart_probe_failed",
|
|
69
|
+
error_name: error instanceof Error ? error.name : typeof error,
|
|
70
|
+
}));
|
|
71
|
+
return null;
|
|
72
|
+
});
|
|
73
|
+
const reading = await stateFor(result, roots, options);
|
|
74
|
+
console.error(`${TAG} read back`, JSON.stringify({
|
|
75
|
+
reason: reading.reason,
|
|
76
|
+
state: reading.state,
|
|
77
|
+
root_count: reading.roots.length,
|
|
78
|
+
platform: options.platform ?? process.platform,
|
|
79
|
+
}));
|
|
80
|
+
return reading;
|
|
81
|
+
}
|
|
82
|
+
async function stateFor(result, roots, options) {
|
|
83
|
+
if (!result) {
|
|
84
|
+
return {
|
|
85
|
+
state: "unreadable",
|
|
86
|
+
reason: "autostart_probe_failed",
|
|
87
|
+
message: "the operating-system scheduler could not be read this run",
|
|
88
|
+
roots,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const detail = result.message
|
|
92
|
+
? { detail: redactedHealthDetail(result.message) }
|
|
93
|
+
: {};
|
|
94
|
+
if (result.status === "unsupported") {
|
|
95
|
+
return {
|
|
96
|
+
state: "unsupported",
|
|
97
|
+
reason: "platform_unsupported",
|
|
98
|
+
message: result.message ?? "autostart is not supported on this host",
|
|
99
|
+
roots,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (result.status === "absent" || result.status === "uninstalled") {
|
|
103
|
+
return {
|
|
104
|
+
state: "absent",
|
|
105
|
+
reason: "autostart_absent",
|
|
106
|
+
message: "background sync is not installed on this machine",
|
|
107
|
+
roots,
|
|
108
|
+
...detail,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (result.status === "not_loaded") {
|
|
112
|
+
return {
|
|
113
|
+
state: "not_loaded",
|
|
114
|
+
reason: "autostart_not_loaded",
|
|
115
|
+
message: "background sync is not running",
|
|
116
|
+
roots,
|
|
117
|
+
...detail,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// BLI-3553: "loaded" only means the scheduler accepted the registration. It
|
|
121
|
+
// says nothing about whether the binary that registration names still exists
|
|
122
|
+
// — and `brew upgrade node` deletes exactly that. Ask the filesystem about
|
|
123
|
+
// the paths the PLATFORM holds, not the ones this process runs under.
|
|
124
|
+
const missing = options.exec
|
|
125
|
+
? await registeredRuntimePathProblems({
|
|
126
|
+
exec: options.exec,
|
|
127
|
+
...(options.homeDir ? { homeDir: options.homeDir } : {}),
|
|
128
|
+
...(options.platform ? { platform: options.platform } : {}),
|
|
129
|
+
}).catch(() => [])
|
|
130
|
+
: [];
|
|
131
|
+
if (missing.length > 0) {
|
|
132
|
+
return {
|
|
133
|
+
state: "runtime_path_missing",
|
|
134
|
+
reason: "autostart_runtime_path_missing",
|
|
135
|
+
message: `background sync is registered but cannot run: ${missing.join("; ")}`,
|
|
136
|
+
roots,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
state: "loaded",
|
|
141
|
+
reason: "autostart_loaded",
|
|
142
|
+
message: "background sync is running",
|
|
143
|
+
roots,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The setup receipt's `collector autostart` word (BLI-3731).
|
|
148
|
+
*
|
|
149
|
+
* `ok` only when the host says it is registered AND loaded AND the runtime it
|
|
150
|
+
* names still exists. A plist nobody loaded runs nothing, which is exactly the
|
|
151
|
+
* failure that looks fine from the outside.
|
|
152
|
+
*/
|
|
153
|
+
export function autostartSetupPiece(reading) {
|
|
154
|
+
switch (reading.state) {
|
|
155
|
+
case "loaded":
|
|
156
|
+
return { status: "ok", reason: reading.reason };
|
|
157
|
+
case "unsupported":
|
|
158
|
+
return { status: "skipped", reason: reading.reason };
|
|
159
|
+
case "unreadable":
|
|
160
|
+
return { status: "unknown", reason: reading.reason };
|
|
161
|
+
default:
|
|
162
|
+
return { status: "missing", reason: reading.reason };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** Doctor's `autostart-alive` row status for the same reading. The `code` a
|
|
166
|
+
* caller pairs with this is `reading.reason` — there is no second vocabulary. */
|
|
167
|
+
export function autostartDoctorStatus(reading) {
|
|
168
|
+
if (reading.state === "loaded")
|
|
169
|
+
return "ok";
|
|
170
|
+
if (reading.state === "unsupported")
|
|
171
|
+
return "skipped";
|
|
172
|
+
return "needs_fix";
|
|
173
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit cal` — the calendars a person attached, from a terminal
|
|
3
|
+
* (BLI-3709).
|
|
4
|
+
*
|
|
5
|
+
* Ten verbs over `/api/cal/**`, the same doors the browser will use when the
|
|
6
|
+
* UI lands. Every one of those routes already accepts the collector device
|
|
7
|
+
* token (`resolveCaller({ allowDeviceToken: true })`), so this command is a
|
|
8
|
+
* terminal in front of an existing door and adds no authority of its own.
|
|
9
|
+
*
|
|
10
|
+
* The CLI is the FIRST surface for this wave, deliberately (AGENTS.md, "CLI is
|
|
11
|
+
* king"): "when is standup" is a question with a one-line answer, and every
|
|
12
|
+
* bug fixed here is fixed for the browser and MCP halves that share the doors.
|
|
13
|
+
*
|
|
14
|
+
* TWO RULES WORTH KNOWING BEFORE READING THE CODE:
|
|
15
|
+
*
|
|
16
|
+
* - **The secret address never touches argv.** `add-ical` reads it from STDIN
|
|
17
|
+
* and there is no flag that would take it (`local-args-tower-cal.ts` says
|
|
18
|
+
* why in full). It is the one credential this surface handles.
|
|
19
|
+
* - **The zone is always sent.** Every read passes `--tz`, or this machine's
|
|
20
|
+
* own zone from `Intl`, because a window computed server-side would be
|
|
21
|
+
* computed in UTC and "today" would end at 5pm in Vancouver.
|
|
22
|
+
*/
|
|
23
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
|
|
24
|
+
import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
25
|
+
const TAG = "[cal cli]";
|
|
26
|
+
const READ_DEADLINE_MS = 30_000;
|
|
27
|
+
const WRITE_DEADLINE_MS = 60_000;
|
|
28
|
+
/** A first read of a crowded calendar can take a while. */
|
|
29
|
+
const SYNC_DEADLINE_MS = 120_000;
|
|
30
|
+
const URL_MAX_CHARS = 2_000;
|
|
31
|
+
export async function runCal(command, io) {
|
|
32
|
+
const door = await openAgentDoor("cal", command, io);
|
|
33
|
+
switch (command.action) {
|
|
34
|
+
case "today":
|
|
35
|
+
case "week":
|
|
36
|
+
case "next":
|
|
37
|
+
return window(command, door);
|
|
38
|
+
case "find":
|
|
39
|
+
return find(command, door);
|
|
40
|
+
case "calendars":
|
|
41
|
+
return listCalendars(door);
|
|
42
|
+
case "add-ical":
|
|
43
|
+
return addIcal(command, door);
|
|
44
|
+
case "create":
|
|
45
|
+
return create(command, door);
|
|
46
|
+
case "share":
|
|
47
|
+
return share(command, door);
|
|
48
|
+
case "detach":
|
|
49
|
+
return detach(command, door);
|
|
50
|
+
case "sync":
|
|
51
|
+
return sync(command, door);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* This machine's own zone, which is what a person means by "today" unless they
|
|
56
|
+
* say otherwise. `Intl` knows it; there is nothing to configure and nothing to
|
|
57
|
+
* get out of step with the operating system.
|
|
58
|
+
*/
|
|
59
|
+
export function localTimeZone() {
|
|
60
|
+
try {
|
|
61
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return "UTC";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function windowQuery(command) {
|
|
68
|
+
const query = new URLSearchParams({ tz: command.timeZone ?? localTimeZone() });
|
|
69
|
+
if (command.calendarId)
|
|
70
|
+
query.set("calendar_id", command.calendarId);
|
|
71
|
+
if (command.limit !== undefined)
|
|
72
|
+
query.set("limit", String(command.limit));
|
|
73
|
+
if (command.includeDeselected)
|
|
74
|
+
query.set("all", "1");
|
|
75
|
+
if (command.offset !== undefined)
|
|
76
|
+
query.set("offset", String(command.offset));
|
|
77
|
+
if (command.hours !== undefined)
|
|
78
|
+
query.set("hours", String(command.hours));
|
|
79
|
+
if (command.from)
|
|
80
|
+
query.set("from", command.from);
|
|
81
|
+
if (command.to)
|
|
82
|
+
query.set("to", command.to);
|
|
83
|
+
return query;
|
|
84
|
+
}
|
|
85
|
+
async function window(command, door) {
|
|
86
|
+
// `--from`/`--to` beat a named window, on any of the three verbs: somebody
|
|
87
|
+
// who named two dates meant them.
|
|
88
|
+
const path = command.from && command.to ? "events" : command.action;
|
|
89
|
+
const answer = await askAgentDoor(door, {
|
|
90
|
+
path: `/api/cal/${path}?${windowQuery(command).toString()}`,
|
|
91
|
+
method: "GET",
|
|
92
|
+
label: `cal ${command.action}`,
|
|
93
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
94
|
+
});
|
|
95
|
+
if (!answer.ok)
|
|
96
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
97
|
+
const body = answer.body;
|
|
98
|
+
if (door.json)
|
|
99
|
+
return emitAgentDoor(door, { ok: true, ...body });
|
|
100
|
+
const zone = body.window?.time_zone ?? localTimeZone();
|
|
101
|
+
const events = body.events ?? [];
|
|
102
|
+
if (events.length === 0) {
|
|
103
|
+
writeLine(door.io.stdout, (body.calendars ?? []).length === 0
|
|
104
|
+
? "No calendars attached yet. `cockpit cal add-ical` for a personal one, or connect Google in the browser."
|
|
105
|
+
: `Nothing on ${describeWindow(body, zone)}.`);
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
writeLine(door.io.stdout, describeWindow(body, zone));
|
|
109
|
+
writeLine(door.io.stdout, "");
|
|
110
|
+
let day = "";
|
|
111
|
+
for (const event of events) {
|
|
112
|
+
const eventDay = dayLabel(event.starts_at, zone);
|
|
113
|
+
if (eventDay !== day) {
|
|
114
|
+
if (day !== "")
|
|
115
|
+
writeLine(door.io.stdout, "");
|
|
116
|
+
writeLine(door.io.stdout, eventDay);
|
|
117
|
+
day = eventDay;
|
|
118
|
+
}
|
|
119
|
+
writeLine(door.io.stdout, eventLine(event, zone));
|
|
120
|
+
}
|
|
121
|
+
writeLine(door.io.stdout, "");
|
|
122
|
+
writeLine(door.io.stdout, `${events.length} event(s) across ${(body.calendars ?? []).length} calendar(s)${body.truncated ? " — more than fitted in one page" : ""}.`);
|
|
123
|
+
// Never silent about a series this Tower could not expand.
|
|
124
|
+
for (const note of body.notes ?? []) {
|
|
125
|
+
writeLine(door.io.stdout, `Note: "${note.title}" repeats on a rule Tower does not expand (${note.detail.join(", ")}), so only its first occurrence is shown.`);
|
|
126
|
+
}
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
function describeWindow(body, zone) {
|
|
130
|
+
if (!body.window)
|
|
131
|
+
return "That window";
|
|
132
|
+
const from = dayLabel(body.window.from, zone);
|
|
133
|
+
const to = dayLabel(new Date(new Date(body.window.to).getTime() - 1).toISOString(), zone);
|
|
134
|
+
if (body.window.label === "next")
|
|
135
|
+
return `Next, from now (${zone})`;
|
|
136
|
+
return from === to ? `${from} (${zone})` : `${from} to ${to} (${zone})`;
|
|
137
|
+
}
|
|
138
|
+
function eventLine(event, zone) {
|
|
139
|
+
const when = event.all_day ? "all day" : `${clock(event.starts_at, zone)}–${clock(event.ends_at, zone)}`;
|
|
140
|
+
const marks = `${event.recurring ? "↻" : " "}${event.status === "tentative" ? "?" : " "}`;
|
|
141
|
+
const where = event.location ? ` @ ${event.location.slice(0, 40)}` : "";
|
|
142
|
+
const who = event.attendee_count > 0 ? ` (${event.attendee_count})` : "";
|
|
143
|
+
return `${marks} ${when.padEnd(13)} ${event.title.slice(0, 52).padEnd(52)}${where}${who}`;
|
|
144
|
+
}
|
|
145
|
+
/** `Mon 7 Sep`, in the reader's zone. */
|
|
146
|
+
function dayLabel(instant, zone) {
|
|
147
|
+
try {
|
|
148
|
+
return new Intl.DateTimeFormat("en-GB", {
|
|
149
|
+
timeZone: zone,
|
|
150
|
+
weekday: "short",
|
|
151
|
+
day: "numeric",
|
|
152
|
+
month: "short",
|
|
153
|
+
}).format(new Date(instant));
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return instant.slice(0, 10);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function clock(instant, zone) {
|
|
160
|
+
try {
|
|
161
|
+
return new Intl.DateTimeFormat("en-GB", {
|
|
162
|
+
timeZone: zone,
|
|
163
|
+
hour: "2-digit",
|
|
164
|
+
minute: "2-digit",
|
|
165
|
+
hour12: false,
|
|
166
|
+
}).format(new Date(instant));
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return instant.slice(11, 16);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async function find(command, door) {
|
|
173
|
+
const query = new URLSearchParams({ q: command.query ?? "" });
|
|
174
|
+
if (command.calendarId)
|
|
175
|
+
query.set("calendar_id", command.calendarId);
|
|
176
|
+
if (command.limit !== undefined)
|
|
177
|
+
query.set("limit", String(command.limit));
|
|
178
|
+
const answer = await askAgentDoor(door, {
|
|
179
|
+
path: `/api/cal/find?${query.toString()}`,
|
|
180
|
+
method: "GET",
|
|
181
|
+
label: "cal find",
|
|
182
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
183
|
+
});
|
|
184
|
+
if (!answer.ok)
|
|
185
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
186
|
+
const matches = answer.body.matches ?? [];
|
|
187
|
+
if (door.json)
|
|
188
|
+
return emitAgentDoor(door, { ok: true, query: command.query, matches });
|
|
189
|
+
if (matches.length === 0) {
|
|
190
|
+
writeLine(door.io.stdout, `Nothing in your calendars matched "${command.query}".`);
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
193
|
+
const zone = command.timeZone ?? localTimeZone();
|
|
194
|
+
for (const match of matches) {
|
|
195
|
+
const when = match.next_occurrence
|
|
196
|
+
? `${dayLabel(match.next_occurrence, zone)} ${clock(match.next_occurrence, zone)}`
|
|
197
|
+
: `last on ${dayLabel(match.starts_at, zone)}`;
|
|
198
|
+
writeLine(door.io.stdout, `${match.recurring ? "↻" : " "} ${when.padEnd(20)} ${match.title.slice(0, 50).padEnd(50)} ${match.calendar_name}`);
|
|
199
|
+
}
|
|
200
|
+
writeLine(door.io.stdout, "");
|
|
201
|
+
writeLine(door.io.stdout, `${matches.length} match(es). Times are ${zone}.`);
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
async function listCalendars(door) {
|
|
205
|
+
const answer = await askAgentDoor(door, {
|
|
206
|
+
path: "/api/cal/calendars",
|
|
207
|
+
method: "GET",
|
|
208
|
+
label: "cal calendars",
|
|
209
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
210
|
+
});
|
|
211
|
+
if (!answer.ok)
|
|
212
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
213
|
+
const calendars = answer.body.calendars ?? [];
|
|
214
|
+
if (door.json)
|
|
215
|
+
return emitAgentDoor(door, { ok: true, calendars });
|
|
216
|
+
if (calendars.length === 0) {
|
|
217
|
+
writeLine(door.io.stdout, "No calendars attached yet.");
|
|
218
|
+
writeLine(door.io.stdout, "");
|
|
219
|
+
writeLine(door.io.stdout, "A personal calendar: printf \"%s\" \"<secret iCal address>\" | cockpit cal add-ical");
|
|
220
|
+
writeLine(door.io.stdout, "A work calendar: connect Google in the browser, then `cockpit cal calendars` again.");
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
|
223
|
+
for (const calendar of calendars) {
|
|
224
|
+
const health = calendar.status === "active"
|
|
225
|
+
? (calendar.last_sync_at ? `synced ${calendar.last_sync_at.slice(0, 16).replace("T", " ")}` : "never synced")
|
|
226
|
+
: `${calendar.status}: ${calendar.status_reason ?? "no reason recorded"}`;
|
|
227
|
+
const flags = `${calendar.org_visible ? "shared" : "private"}${calendar.selected ? "" : ", hidden"}`;
|
|
228
|
+
writeLine(door.io.stdout, `${calendar.name.slice(0, 32).padEnd(32)} ${calendar.provider.padEnd(12)} ${flags.padEnd(16)} ${health}`);
|
|
229
|
+
}
|
|
230
|
+
writeLine(door.io.stdout, "");
|
|
231
|
+
writeLine(door.io.stdout, `${calendars.length} calendar(s). ids: ${calendars.map((calendar) => calendar.id).join(", ")}`);
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
async function addIcal(command, door) {
|
|
235
|
+
// The address comes from stdin and nowhere else. An interactive terminal
|
|
236
|
+
// with nothing piped in is told how, rather than left waiting on a prompt
|
|
237
|
+
// that would echo a permanent credential into the scrollback.
|
|
238
|
+
if (isInteractiveStdin(door.io)) {
|
|
239
|
+
return failAgentDoor(door, TAG, "address_required_on_stdin", 'The calendar\'s secret address is read from stdin, never from a flag. Try: printf "%s" "https://calendar.google.com/calendar/ical/…/basic.ics" | cockpit cal add-ical --name "Personal"');
|
|
240
|
+
}
|
|
241
|
+
let icalUrl;
|
|
242
|
+
try {
|
|
243
|
+
icalUrl = (await readPipedText(door.io.stdin, {
|
|
244
|
+
maxChars: URL_MAX_CHARS,
|
|
245
|
+
overflowMessage: "That is longer than any calendar address; nothing was attached.",
|
|
246
|
+
})).trim();
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
return failAgentDoor(door, TAG, "address_unreadable", error instanceof Error ? error.message : String(error));
|
|
250
|
+
}
|
|
251
|
+
if (icalUrl === "") {
|
|
252
|
+
return failAgentDoor(door, TAG, "address_required_on_stdin", "Nothing arrived on stdin, so nothing was attached.");
|
|
253
|
+
}
|
|
254
|
+
const answer = await askAgentDoor(door, {
|
|
255
|
+
path: "/api/cal/calendars",
|
|
256
|
+
method: "POST",
|
|
257
|
+
label: "cal add-ical",
|
|
258
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
259
|
+
body: {
|
|
260
|
+
ical_url: icalUrl,
|
|
261
|
+
name: command.name ?? null,
|
|
262
|
+
...(command.orgVisible ? { org_visible: true } : {}),
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
if (!answer.ok)
|
|
266
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
267
|
+
const calendar = answer.body.calendar;
|
|
268
|
+
if (door.json)
|
|
269
|
+
return emitAgentDoor(door, { ok: true, calendar });
|
|
270
|
+
writeLine(door.io.stdout, `Attached (${calendar?.id ?? "no id returned"}).`);
|
|
271
|
+
writeLine(door.io.stdout, `Nothing is read until a sync runs: \`cockpit cal sync ${calendar?.id ?? "<id>"}\`, or wait for the cron.`);
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
async function create(command, door) {
|
|
275
|
+
const answer = await askAgentDoor(door, {
|
|
276
|
+
path: "/api/cal/events",
|
|
277
|
+
method: "POST",
|
|
278
|
+
label: "cal create",
|
|
279
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
280
|
+
body: {
|
|
281
|
+
calendar_id: command.calendarId,
|
|
282
|
+
title: command.title,
|
|
283
|
+
starts_at: command.startsAt,
|
|
284
|
+
ends_at: command.endsAt,
|
|
285
|
+
...(command.location ? { location: command.location } : {}),
|
|
286
|
+
...(command.allDay ? { all_day: true } : {}),
|
|
287
|
+
...(command.attendees.length > 0 ? { attendees: command.attendees } : {}),
|
|
288
|
+
time_zone: command.timeZone ?? localTimeZone(),
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
if (!answer.ok)
|
|
292
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
293
|
+
const event = answer.body.event;
|
|
294
|
+
if (door.json)
|
|
295
|
+
return emitAgentDoor(door, { ok: true, event });
|
|
296
|
+
const zone = command.timeZone ?? localTimeZone();
|
|
297
|
+
writeLine(door.io.stdout, `Created "${event?.title ?? command.title}" on ${event ? `${dayLabel(event.starts_at, zone)} ${clock(event.starts_at, zone)}` : "that calendar"}.`);
|
|
298
|
+
if (event?.html_link)
|
|
299
|
+
writeLine(door.io.stdout, event.html_link);
|
|
300
|
+
if (command.attendees.length > 0) {
|
|
301
|
+
writeLine(door.io.stdout, `Invitations went to ${command.attendees.join(", ")} from Google.`);
|
|
302
|
+
}
|
|
303
|
+
return 0;
|
|
304
|
+
}
|
|
305
|
+
async function share(command, door) {
|
|
306
|
+
const orgVisible = command.orgVisible && !command.makePrivate;
|
|
307
|
+
const answer = await askAgentDoor(door, {
|
|
308
|
+
path: `/api/cal/calendars?calendar_id=${encodeURIComponent(command.subjectId ?? "")}`,
|
|
309
|
+
method: "PATCH",
|
|
310
|
+
label: "cal share",
|
|
311
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
312
|
+
body: { org_visible: orgVisible },
|
|
313
|
+
});
|
|
314
|
+
if (!answer.ok)
|
|
315
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
316
|
+
const calendar = answer.body.calendar;
|
|
317
|
+
if (door.json)
|
|
318
|
+
return emitAgentDoor(door, { ok: true, calendar });
|
|
319
|
+
writeLine(door.io.stdout, orgVisible
|
|
320
|
+
? `"${calendar?.name ?? command.subjectId}" is now readable by everyone at BLI. Nobody but you can change it.`
|
|
321
|
+
: `"${calendar?.name ?? command.subjectId}" is private again — only you can read it.`);
|
|
322
|
+
return 0;
|
|
323
|
+
}
|
|
324
|
+
async function detach(command, door) {
|
|
325
|
+
const answer = await askAgentDoor(door, {
|
|
326
|
+
path: `/api/cal/calendars?calendar_id=${encodeURIComponent(command.subjectId ?? "")}`,
|
|
327
|
+
method: "DELETE",
|
|
328
|
+
label: "cal detach",
|
|
329
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
330
|
+
});
|
|
331
|
+
if (!answer.ok)
|
|
332
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
333
|
+
if (door.json)
|
|
334
|
+
return emitAgentDoor(door, { ok: true, detached: command.subjectId });
|
|
335
|
+
writeLine(door.io.stdout, `Detached ${command.subjectId}. Its stored events went with it; the calendar itself is untouched at Google.`);
|
|
336
|
+
return 0;
|
|
337
|
+
}
|
|
338
|
+
async function sync(command, door) {
|
|
339
|
+
const answer = await askAgentDoor(door, {
|
|
340
|
+
path: `/api/cal/calendars/${encodeURIComponent(command.subjectId ?? "")}/sync${command.full ? "?full=1" : ""}`,
|
|
341
|
+
method: "POST",
|
|
342
|
+
label: "cal sync",
|
|
343
|
+
timeoutMs: SYNC_DEADLINE_MS,
|
|
344
|
+
});
|
|
345
|
+
if (!answer.ok)
|
|
346
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
347
|
+
const run = answer.body.run;
|
|
348
|
+
if (door.json)
|
|
349
|
+
return emitAgentDoor(door, { ok: true, run });
|
|
350
|
+
if (!run) {
|
|
351
|
+
writeLine(door.io.stdout, "Tower answered without a run. Nothing to report.");
|
|
352
|
+
return 0;
|
|
353
|
+
}
|
|
354
|
+
if (run.reason === "ok") {
|
|
355
|
+
writeLine(door.io.stdout, run.detail === "feed_unchanged"
|
|
356
|
+
? "Nothing has changed on that calendar since the last read."
|
|
357
|
+
: `Synced: ${run.eventsAdded} new, ${run.eventsUpdated} updated, ${run.eventsDeleted} gone.`);
|
|
358
|
+
return 0;
|
|
359
|
+
}
|
|
360
|
+
// A failure names itself; the exit code says it failed.
|
|
361
|
+
writeLine(door.io.stderr, `${TAG} sync ${JSON.stringify({ reason: run.reason, detail: run.detail })}`);
|
|
362
|
+
writeLine(door.io.stdout, `That calendar did not sync: ${run.reason}${run.detail ? ` (${run.detail})` : ""}.`);
|
|
363
|
+
return 1;
|
|
364
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { installAutostartAgent } from "../autostart.js";
|
|
2
2
|
import { fail, needsFix, ok, skipped } from "./doctor-report.js";
|
|
3
|
-
import {
|
|
3
|
+
import { autostartDoctorStatus, autostartRegistrationRoots, readAutostartRegistration, } from "./autostart-reading.js";
|
|
4
4
|
import { installMemoryIntegration, inspectMemoryIntegration, } from "./memory-install.js";
|
|
5
5
|
/**
|
|
6
6
|
* The `autostart-alive` and `memory-registered` check family: registrations
|
|
@@ -8,44 +8,40 @@ import { installMemoryIntegration, inspectMemoryIntegration, } from "./memory-in
|
|
|
8
8
|
* background sync scheduler and BLI Memory's MCP/hook wiring. Both fixes
|
|
9
9
|
* write host configuration only; neither installs software.
|
|
10
10
|
*/
|
|
11
|
+
/**
|
|
12
|
+
* The row and the setup receipt's `collector autostart` word are the SAME
|
|
13
|
+
* reading now (BLI-3793) — `autostart-reading.ts` asks the host once and hands
|
|
14
|
+
* back one state and one reason label. This function only chooses the row
|
|
15
|
+
* status for it; it does not decide anything about the machine.
|
|
16
|
+
*/
|
|
11
17
|
export async function checkAutostartState(context) {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
const roots = await doctorRoots(context);
|
|
17
|
-
const result = await autostartStatus({
|
|
18
|
-
repoRoot: roots[0],
|
|
19
|
-
repoRoots: roots,
|
|
18
|
+
const reading = await readAutostartRegistration({
|
|
19
|
+
exec: context.io.exec,
|
|
20
|
+
homeDir: context.command.homeDir,
|
|
20
21
|
dashboardUrl: context.command.dashboardUrl,
|
|
21
|
-
|
|
22
|
+
repoRoot: context.command.repoRoot,
|
|
22
23
|
});
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
// happens to be running under.
|
|
29
|
-
const missing = await registeredRuntimePathProblems({ exec });
|
|
30
|
-
if (missing.length > 0) {
|
|
31
|
-
return needsFix("autostart-alive", "runtime_path_missing", `background sync is registered but cannot run: ${missing.join("; ")}`);
|
|
32
|
-
}
|
|
33
|
-
return ok("autostart-alive", "already_installed", "background sync is running");
|
|
34
|
-
}
|
|
35
|
-
if (result.status === "unsupported") {
|
|
36
|
-
return skipped("autostart-alive", "unsupported", result.message ?? "unsupported");
|
|
24
|
+
const status = autostartDoctorStatus(reading);
|
|
25
|
+
if (status === "ok")
|
|
26
|
+
return ok("autostart-alive", reading.reason, reading.message);
|
|
27
|
+
if (status === "skipped") {
|
|
28
|
+
return skipped("autostart-alive", reading.reason, reading.message);
|
|
37
29
|
}
|
|
38
|
-
return needsFix("autostart-alive",
|
|
30
|
+
return needsFix("autostart-alive", reading.reason, reading.message);
|
|
39
31
|
}
|
|
40
32
|
export async function fixAutostartState(context) {
|
|
41
33
|
const exec = context.io.exec;
|
|
42
34
|
if (!exec) {
|
|
43
35
|
return fail("autostart-alive", "runner_unavailable", "autostart runner unavailable");
|
|
44
36
|
}
|
|
45
|
-
|
|
37
|
+
// The same roots the CHECK read against, resolved by the same function — a
|
|
38
|
+
// fix that registers a different boundary than the check inspected would
|
|
39
|
+
// leave the row red forever (BLI-3793).
|
|
40
|
+
const roots = await autostartRegistrationRoots(context.command.homeDir, context.command.repoRoot);
|
|
46
41
|
const result = await installAutostartAgent({
|
|
47
|
-
repoRoot:
|
|
48
|
-
repoRoots:
|
|
42
|
+
...(roots[0] ? { repoRoot: roots[0] } : {}),
|
|
43
|
+
repoRoots: roots,
|
|
44
|
+
...(context.command.homeDir ? { homeDir: context.command.homeDir } : {}),
|
|
49
45
|
dashboardUrl: context.command.dashboardUrl,
|
|
50
46
|
exec,
|
|
51
47
|
});
|