@wirecat/cli-core 0.564.0 → 0.565.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/dist/calendar/busy.d.ts +10 -10
- package/dist/calendar/busy.js +9 -9
- package/dist/calendar/cli/answers.d.ts +7 -8
- package/dist/calendar/cli/answers.js +4 -5
- package/dist/calendar/cli/bodies.d.ts +33 -0
- package/dist/calendar/cli/bodies.js +52 -0
- package/dist/calendar/cli/files.d.ts +8 -0
- package/dist/calendar/cli/files.js +41 -0
- package/dist/calendar/cli/index.d.ts +58 -51
- package/dist/calendar/cli/index.js +311 -284
- package/dist/calendar/cli/remind.d.ts +2 -11
- package/dist/calendar/cli/remind.js +81 -81
- package/dist/calendar/cli/search-all.d.ts +3 -8
- package/dist/calendar/cli/search-all.js +47 -42
- package/dist/calendar/mcp/index.d.ts +13 -13
- package/dist/calendar/prepare.d.ts +1 -1
- package/dist/calendar/testing/answers.d.ts +27 -5
- package/dist/calendar/testing/answers.js +88 -17
- package/dist/calendar/testing/writer.d.ts +1 -0
- package/dist/calendar/testing/writer.js +6 -0
- package/dist/calendar/writer.d.ts +6 -1
- package/dist/calendar/writes.d.ts +3 -1
- package/dist/calendar/writes.js +42 -20
- package/dist/cli/config-command.js +1 -1
- package/dist/cli/config-issues.d.ts +3 -1
- package/dist/cli/config-issues.js +2 -2
- package/dist/cli/contract-evidence.d.ts +1 -1
- package/dist/cli/early-refusal.d.ts +2 -1
- package/dist/cli/early-refusal.js +4 -2
- package/dist/cli/entry.d.ts +6 -2
- package/dist/cli/entry.js +4 -1
- package/dist/cli/envelope.d.ts +2 -2
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +1 -0
- package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
- package/dist/cli/permission-keys.d.ts +3 -1
- package/dist/cli/permission-keys.js +2 -1
- package/dist/cli/preview.d.ts +12 -0
- package/dist/cli/preview.js +8 -1
- package/dist/cli/settings.d.ts +2 -0
- package/dist/cli/settings.js +8 -1
- package/dist/errors/reasons.js +14 -1
- package/dist/mcp/meeting-preparation.d.ts +1 -1
- package/dist/mcp/meeting-project-candidates.d.ts +1 -1
- package/dist/mcp/meeting-series-inventory.d.ts +1 -1
- package/dist/meetings/mcp/passage.d.ts +1 -1
- package/dist/meetings/mcp/project-evidence.d.ts +1 -1
- package/dist/meetings/mcp/search.d.ts +1 -1
- package/dist/services/metadata.d.ts +1 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,54 @@ shape of this file.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.565.0 — 05.11.2026
|
|
11
|
+
|
|
12
|
+
### Changed — may break callers
|
|
13
|
+
|
|
14
|
+
- **The shared calendar commands print their own answers and open the profile per command.**
|
|
15
|
+
`addCalendarCommands(program, { app, open, freeBusy?, serverSearch?, feeds?, writes?, readFile?, writeFile? })`
|
|
16
|
+
replaces `CalendarCommandDeps`. `open(command, { readOnly })` returns a `CalendarRun` (event store, shared store,
|
|
17
|
+
account, profile, `locked`, and `source()`, `writes()`, `feeds()` built on first use); it is closed before the
|
|
18
|
+
answer prints. Each command answers in the standard envelope (`renderAnswer`) with its `meta` as built, so nothing
|
|
19
|
+
in between can drop it, and `write`, `note`, `embed`, `store`, `accountId` and the other per-profile fields are gone
|
|
20
|
+
from the options. The tree is built once with no state per call (WC-ARCH-22). `events export --output` and
|
|
21
|
+
`events import` read and write files themselves (0600, `file.not-found`, `file.not-a-file`, `file.denied`) unless
|
|
22
|
+
the app passes `readFile` / `writeFile`. `search all` is always mounted.
|
|
23
|
+
- **`calendars busy` answers and queries `starts_at` / `ends_at`**, as every event answer does, in place of `start`
|
|
24
|
+
/ `end` (WC-OUT-12): `calendars busy starts_at>=2026-11-02 ends_at<=2026-11-06`.
|
|
25
|
+
- **`sync` keeps what the other calendars brought when one fails** (WC-OUT-8). It answers with `meta.complete: false`
|
|
26
|
+
and an issue per failed calendar (`sync.partial`, source `calendar:<id>`) instead of a `provider_error` with an
|
|
27
|
+
empty stdout.
|
|
28
|
+
- **`events create --text` takes `--operation-id`.** A quick add whose answer was lost names an id starting with
|
|
29
|
+
`q`; run again with it, the create looks for the event that attempt made through the writer's new optional
|
|
30
|
+
`quickAdded(calendar, since)` before it sends the line again (WC-CMD-6). Two candidates are refused
|
|
31
|
+
(`event.quick-add-ambiguous`).
|
|
32
|
+
- **`EntryContext.openBrowser(url, browser?, command?)` opens nothing where nobody can use a browser** (WC-CMD-15):
|
|
33
|
+
under `CI`, `--json`, `--jsonl`, `--no-input`, or with no terminal. The caller prints the URL either way.
|
|
34
|
+
- **Shared reasons for CLIs over a provider's API:** `offline.refused`, `api.unknown-method`, `api.disabled`,
|
|
35
|
+
`provider.usage-limit`, `oauth.scope-missing`, `sign-in.client-rejected`, `file.not-found`, `file.not-a-file` and
|
|
36
|
+
`file.denied` are in cli-core's list. A CLI that registered one of them itself must drop its copy: two entries of
|
|
37
|
+
one reason are refused.
|
|
38
|
+
- **`speech_model` and `picture_search` are messenger settings**, and `config show` of a CLI that is not a
|
|
39
|
+
messenger lists no messenger store setting (`phone_countries`, `task_categories.*`, `task_agent_close*`,
|
|
40
|
+
`task_first_pass_per_chat`).
|
|
41
|
+
- **cli-core accepts `@wirecat/cli-base` 0.76 and 0.77 (peer range `>=0.75.0 <0.78.0`) and builds with
|
|
42
|
+
0.77.1**, whose vocabulary has the `schedule` resource, the `agent`, `command` and `exec` verbs and `--trimmed`. The command
|
|
43
|
+
lint now checks `schedule agent|command|exec` instead of skipping them in `lint/baseline`. A CLI that mounts
|
|
44
|
+
`schedule` (tg, max, wa, gcal, mail, memo) removes the same entries from its own baseline when it bumps cli-base
|
|
45
|
+
to 0.77.0: they are stale there and fail its lint. 0.76.0 adds the `conflict` error code; nothing here uses it yet.
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
|
|
49
|
+
- **`actorOf(command)` is exported from `./cli`:** who acts in this run, the owner or the agent by its name, as a
|
|
50
|
+
`Decider` for the document knowledge's confirm, stale and reverse needs it.
|
|
51
|
+
- **`checkConfigText(text, { repair: false })` and `settingsFor(app, { configRepair: false })`** for a CLI with its
|
|
52
|
+
own `config` that has no `repair`: an invalid file's issue then names no `config repair` to run.
|
|
53
|
+
- **`commandPermissions({ alsoChecks })`:** the other keys a command's real run checks. A `--dry-run` preview shows
|
|
54
|
+
each with its level under `permission.also` and refuses as the run would, so gcal's `api.calendar.*` keys show.
|
|
55
|
+
- **A CLI that is not a messenger previews a `--dry-run` under `<PREFIX>_READ_ONLY`** (WC-PERM-12); it sends nothing.
|
|
56
|
+
A messenger's preview stays refused under the lock.
|
|
57
|
+
|
|
10
58
|
## 0.564.0 — 05.11.2026
|
|
11
59
|
|
|
12
60
|
### Changed — may break callers
|
package/dist/calendar/busy.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export interface BusyQuery {
|
|
|
8
8
|
people: string[];
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
|
-
* `calendars busy`'s query: the window by the answer's own `
|
|
11
|
+
* `calendars busy`'s query: the window by the answer's own `starts_at` and `ends_at`, and whom to ask. `calendar` and
|
|
12
12
|
* `person` repeat, and every one named is asked: the free stretches are those they all share.
|
|
13
13
|
*/
|
|
14
14
|
export declare const BUSY_QUERY: QueryRegistry;
|
|
@@ -47,29 +47,29 @@ export declare const busyAnswer: (source: CalendarSource, query: BusyQuery, opti
|
|
|
47
47
|
hours: WorkingHours;
|
|
48
48
|
};
|
|
49
49
|
}) => Promise<{
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
starts_at: string;
|
|
51
|
+
ends_at: string;
|
|
52
52
|
timezone: string;
|
|
53
53
|
calendars: ({
|
|
54
54
|
id: string;
|
|
55
55
|
calendar: string;
|
|
56
56
|
busy: {
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
starts_at: string;
|
|
58
|
+
ends_at: string;
|
|
59
59
|
}[];
|
|
60
60
|
error?: string | undefined;
|
|
61
61
|
} | {
|
|
62
62
|
id: string;
|
|
63
63
|
person: string;
|
|
64
64
|
busy: {
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
starts_at: string;
|
|
66
|
+
ends_at: string;
|
|
67
67
|
}[];
|
|
68
68
|
error?: string | undefined;
|
|
69
69
|
})[];
|
|
70
70
|
slots?: {
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
starts_at: string;
|
|
72
|
+
ends_at: string;
|
|
73
73
|
}[] | undefined;
|
|
74
74
|
unknown?: string[] | undefined;
|
|
75
75
|
meta: {
|
|
@@ -78,7 +78,7 @@ export declare const busyAnswer: (source: CalendarSource, query: BusyQuery, opti
|
|
|
78
78
|
timezone: string;
|
|
79
79
|
complete: boolean;
|
|
80
80
|
issues?: {
|
|
81
|
-
code: "authentication_error" | "cancelled" | "configuration_error" | "confirmation_required" | "invalid_response" | "network_error" | "not_found" | "outcome_unknown" | "permission_error" | "provider_error" | "provider_unavailable" | "rate_limited" | "timeout" | "validation_error";
|
|
81
|
+
code: "authentication_error" | "cancelled" | "configuration_error" | "confirmation_required" | "conflict" | "invalid_response" | "network_error" | "not_found" | "outcome_unknown" | "permission_error" | "provider_error" | "provider_unavailable" | "rate_limited" | "timeout" | "validation_error";
|
|
82
82
|
reason?: string | undefined;
|
|
83
83
|
source: string;
|
|
84
84
|
}[] | undefined;
|
package/dist/calendar/busy.js
CHANGED
|
@@ -19,14 +19,14 @@ const bound = (field, side, example) => (node) => {
|
|
|
19
19
|
queryError("query.unsupported-operator", node.span, `${field} takes only ${side === "lower" ? "the window's start" : "the window's end"}, such as ${example}`, { command: BUSY, guide: LIST_QUERY_GUIDE });
|
|
20
20
|
};
|
|
21
21
|
/**
|
|
22
|
-
* `calendars busy`'s query: the window by the answer's own `
|
|
22
|
+
* `calendars busy`'s query: the window by the answer's own `starts_at` and `ends_at`, and whom to ask. `calendar` and
|
|
23
23
|
* `person` repeat, and every one named is asked: the free stretches are those they all share.
|
|
24
24
|
*/
|
|
25
25
|
export const BUSY_QUERY = {
|
|
26
26
|
entity: "calendar_busy",
|
|
27
27
|
backend: "native",
|
|
28
28
|
fields: queryFieldsOf(calendarBusy, {
|
|
29
|
-
native: ["
|
|
29
|
+
native: ["starts_at", "ends_at", "calendar", "person"],
|
|
30
30
|
relations: {
|
|
31
31
|
calendar: {
|
|
32
32
|
type: "kw",
|
|
@@ -35,16 +35,16 @@ export const BUSY_QUERY = {
|
|
|
35
35
|
},
|
|
36
36
|
person: { type: "kw", example: "person:ana@example.com", relation: "someone else, by address; repeat for more" },
|
|
37
37
|
},
|
|
38
|
-
examples: {
|
|
38
|
+
examples: { starts_at: "starts_at>=2026-11-02", ends_at: "ends_at<=2026-11-06" },
|
|
39
39
|
validate: {
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
starts_at: bound("starts_at", "lower", "starts_at>=2026-11-02"),
|
|
41
|
+
ends_at: bound("ends_at", "upper", "ends_at<=2026-11-06"),
|
|
42
42
|
},
|
|
43
43
|
}).map((field) => field.native
|
|
44
44
|
? { ...field, operators: field.type === "time" ? ["range"] : ["term", "phrase"] }
|
|
45
45
|
: field),
|
|
46
46
|
};
|
|
47
|
-
export const BUSY_QUERY_NOTE = "\nWithout
|
|
47
|
+
export const BUSY_QUERY_NOTE = "\nWithout starts_at: from the start of today, in your time zone. Without ends_at: seven days after the start. " +
|
|
48
48
|
"Without calendar: every calendar that syncs.";
|
|
49
49
|
export const busyQuery = async (query, context) => {
|
|
50
50
|
const { zone, now } = context;
|
|
@@ -56,9 +56,9 @@ export const busyQuery = async (query, context) => {
|
|
|
56
56
|
const terms = nativeTermLists(listQuery(typedQuery(query), BUSY_QUERY, BUSY), BUSY, ["calendar", "person"]);
|
|
57
57
|
for (const [field, nodes] of terms)
|
|
58
58
|
for (const node of nodes) {
|
|
59
|
-
if (field === "
|
|
59
|
+
if (field === "starts_at")
|
|
60
60
|
from = timeWindow(node, zone, now).lower;
|
|
61
|
-
else if (field === "
|
|
61
|
+
else if (field === "ends_at")
|
|
62
62
|
to = timeWindow(node, zone, now).upper;
|
|
63
63
|
else if (field === "calendar") {
|
|
64
64
|
const calendar = await calendarIn(context.store, context.accountId, node.value);
|
|
@@ -135,7 +135,7 @@ export const freeSlots = (busy, window, options) => {
|
|
|
135
135
|
return slots;
|
|
136
136
|
};
|
|
137
137
|
const iso = (at) => new Date(at).toISOString();
|
|
138
|
-
const times = ({ from, to }) => ({
|
|
138
|
+
const times = ({ from, to }) => ({ starts_at: iso(from), ends_at: iso(to) });
|
|
139
139
|
/**
|
|
140
140
|
* `calendars busy`: the provider's busy times for the query, and with `slot` the free stretches every calendar and
|
|
141
141
|
* person shares. Nothing is stored, and a person appears only by the address the caller gave.
|
|
@@ -38,8 +38,8 @@ export declare const calendarFeedRemoval: v.StrictObjectSchema<{
|
|
|
38
38
|
readonly events: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
|
|
39
39
|
}, undefined>;
|
|
40
40
|
export declare const calendarBusy: v.StrictObjectSchema<{
|
|
41
|
-
readonly
|
|
42
|
-
readonly
|
|
41
|
+
readonly starts_at: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>;
|
|
42
|
+
readonly ends_at: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>;
|
|
43
43
|
readonly timezone: v.StringSchema<undefined>;
|
|
44
44
|
readonly calendars: v.ArraySchema<v.StrictObjectSchema<{
|
|
45
45
|
/** One of the account's calendars, by its id; or `person`, by the address asked. */
|
|
@@ -47,14 +47,14 @@ export declare const calendarBusy: v.StrictObjectSchema<{
|
|
|
47
47
|
readonly person: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
|
|
48
48
|
readonly id: v.StringSchema<undefined>;
|
|
49
49
|
readonly busy: v.ArraySchema<v.StrictObjectSchema<{
|
|
50
|
-
readonly
|
|
51
|
-
readonly
|
|
50
|
+
readonly starts_at: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>;
|
|
51
|
+
readonly ends_at: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>;
|
|
52
52
|
}, undefined>, undefined>;
|
|
53
53
|
readonly error: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
|
|
54
54
|
}, undefined>, undefined>;
|
|
55
55
|
readonly slots: v.OptionalSchema<v.ArraySchema<v.StrictObjectSchema<{
|
|
56
|
-
readonly
|
|
57
|
-
readonly
|
|
56
|
+
readonly starts_at: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>;
|
|
57
|
+
readonly ends_at: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>;
|
|
58
58
|
}, undefined>, undefined>, undefined>;
|
|
59
59
|
readonly unknown: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>;
|
|
60
60
|
}, undefined>;
|
|
@@ -505,8 +505,7 @@ export declare const calendarLinkResolution: v.StrictObjectSchema<{
|
|
|
505
505
|
readonly chosen: v.NullableSchema<v.StringSchema<undefined>, undefined>;
|
|
506
506
|
}, undefined>;
|
|
507
507
|
/**
|
|
508
|
-
* Each command's answer entity: one item of a list, or the one value
|
|
509
|
-
* keys in snake_case, though a command hands its host some keys in camelCase (`notSynced`, `hasMore`).
|
|
508
|
+
* Each command's answer entity: one item of a list, or the one value, as the command prints it.
|
|
510
509
|
*/
|
|
511
510
|
export declare const declareCalendarAnswers: (program: Command, commands: readonly Command[]) => void;
|
|
512
511
|
export declare const CALENDAR_ANSWER_PATHS: string[];
|
|
@@ -7,7 +7,7 @@ import { count, id, reference, text, time, untrusted } from "../../entities/fiel
|
|
|
7
7
|
import { event } from "../../meetings/index.js";
|
|
8
8
|
const nullableText = v.nullable(text);
|
|
9
9
|
const nullableTime = v.nullable(time);
|
|
10
|
-
const stretch = v.strictObject({
|
|
10
|
+
const stretch = v.strictObject({ starts_at: time, ends_at: time });
|
|
11
11
|
const epochWindow = v.strictObject({ from: v.number(), to: v.number() });
|
|
12
12
|
export const calendar = v.strictObject({
|
|
13
13
|
id,
|
|
@@ -32,8 +32,8 @@ const busyRow = v.strictObject({
|
|
|
32
32
|
error: v.optional(text),
|
|
33
33
|
});
|
|
34
34
|
export const calendarBusy = v.strictObject({
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
starts_at: time,
|
|
36
|
+
ends_at: time,
|
|
37
37
|
timezone: text,
|
|
38
38
|
calendars: v.array(busyRow),
|
|
39
39
|
slots: v.optional(v.array(stretch)),
|
|
@@ -274,8 +274,7 @@ const ANSWERS = {
|
|
|
274
274
|
"links resolve": { name: "calendar_link_resolution", schema: calendarLinkResolution },
|
|
275
275
|
};
|
|
276
276
|
/**
|
|
277
|
-
* Each command's answer entity: one item of a list, or the one value
|
|
278
|
-
* keys in snake_case, though a command hands its host some keys in camelCase (`notSynced`, `hasMore`).
|
|
277
|
+
* Each command's answer entity: one item of a list, or the one value, as the command prints it.
|
|
279
278
|
*/
|
|
280
279
|
export const declareCalendarAnswers = (program, commands) => {
|
|
281
280
|
const pathOf = (command) => {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { NextStep } from "@wirecat/cli-base/standards";
|
|
2
|
+
import type { Command } from "commander";
|
|
3
|
+
import { type Body } from "../../cli/envelope.js";
|
|
4
|
+
import type { MessageStore } from "../../store/store.js";
|
|
5
|
+
import type { CalendarRun } from "./index.js";
|
|
6
|
+
/** The command's words below the program, as `page.next` and `sync`'s next step rerun it. */
|
|
7
|
+
export declare const pathOf: (command: Command) => string[];
|
|
8
|
+
interface Listed {
|
|
9
|
+
items: object[];
|
|
10
|
+
page?: number;
|
|
11
|
+
limit?: number;
|
|
12
|
+
hasMore?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/** A list the shared reads answer (`listed`), as the envelope: `page.next` reruns the same query one page on. */
|
|
15
|
+
export declare const listBody: (command: Command, { items, page, limit, hasMore }: Listed, meta?: object) => Body;
|
|
16
|
+
export declare const itemBody: (value: object, extra?: {
|
|
17
|
+
meta?: object;
|
|
18
|
+
next?: NextStep[];
|
|
19
|
+
}) => Body;
|
|
20
|
+
interface Hits {
|
|
21
|
+
hits: object[];
|
|
22
|
+
has_more?: boolean;
|
|
23
|
+
next_offset?: number;
|
|
24
|
+
meaning_skipped?: string;
|
|
25
|
+
untrusted?: string[];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A search's hits as a list with cursor paging (WC-OUT-4, WC-OUT-5); each hit names its own untrusted fields, as
|
|
29
|
+
* `events list` does. Without a cursor there is no next page to run: the provider's search pages per calendar.
|
|
30
|
+
*/
|
|
31
|
+
export declare const hitsBody: (command: Command, value: Hits) => Body;
|
|
32
|
+
export declare const messagesOf: (run: CalendarRun, what: string) => MessageStore;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { itemOf, listOf, snakeKeys } from "../../cli/envelope.js";
|
|
3
|
+
/** The command's words below the program, as `page.next` and `sync`'s next step rerun it. */
|
|
4
|
+
export const pathOf = (command) => command.parent?.parent ? [...pathOf(command.parent), command.name()] : [command.name()];
|
|
5
|
+
const snakeMeta = (meta) => meta === undefined ? undefined : snakeKeys(meta);
|
|
6
|
+
/** A list the shared reads answer (`listed`), as the envelope: `page.next` reruns the same query one page on. */
|
|
7
|
+
export const listBody = (command, { items, page, limit, hasMore }, meta) => {
|
|
8
|
+
const query = command.processedArgs[0];
|
|
9
|
+
const facts = snakeMeta(meta);
|
|
10
|
+
return listOf(pathOf(command).join(" "), {
|
|
11
|
+
items: items.map((item) => snakeKeys(item)),
|
|
12
|
+
limit: Math.max(1, limit ?? items.length),
|
|
13
|
+
has_more: hasMore === true,
|
|
14
|
+
...(page === undefined ? {} : { page }),
|
|
15
|
+
...(Array.isArray(query) && query.length > 0 ? { args: { query } } : {}),
|
|
16
|
+
...(facts ? { meta: facts } : {}),
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
export const itemBody = (value, extra = {}) => {
|
|
20
|
+
const meta = snakeMeta(extra.meta);
|
|
21
|
+
return itemOf(snakeKeys(value), { ...(meta ? { meta } : {}), ...(extra.next ? { next: extra.next } : {}) });
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* A search's hits as a list with cursor paging (WC-OUT-4, WC-OUT-5); each hit names its own untrusted fields, as
|
|
25
|
+
* `events list` does. Without a cursor there is no next page to run: the provider's search pages per calendar.
|
|
26
|
+
*/
|
|
27
|
+
export const hitsBody = (command, value) => {
|
|
28
|
+
const opts = command.opts();
|
|
29
|
+
const untrusted = (value.untrusted ?? []).filter((field) => field.startsWith("hits.")).map((f) => f.slice(5));
|
|
30
|
+
const { next, ...list } = listOf(pathOf(command).join(" "), {
|
|
31
|
+
items: value.hits.map((hit) => snakeKeys({ ...hit, untrusted })),
|
|
32
|
+
limit: opts.limit,
|
|
33
|
+
has_more: value.has_more === true,
|
|
34
|
+
paging: "cursor",
|
|
35
|
+
...(typeof value.next_offset === "number" ? { cursor: String(value.next_offset) } : {}),
|
|
36
|
+
args: {
|
|
37
|
+
query: command.processedArgs[0],
|
|
38
|
+
...(opts.exact ? { exact: true } : {}),
|
|
39
|
+
...(opts.newest ? { newest: true } : {}),
|
|
40
|
+
},
|
|
41
|
+
...(typeof value.meaning_skipped === "string"
|
|
42
|
+
? { missing: [{ field: "items", why: `not searched by meaning: ${value.meaning_skipped}` }] }
|
|
43
|
+
: {}),
|
|
44
|
+
});
|
|
45
|
+
const kept = typeof value.next_offset === "number" ? next : next?.filter((one) => one.step !== "page.next");
|
|
46
|
+
return { ...list, ...(kept?.length ? { next: kept } : {}) };
|
|
47
|
+
};
|
|
48
|
+
export const messagesOf = (run, what) => {
|
|
49
|
+
if (!run.messages)
|
|
50
|
+
throw new CliError("configuration_error", `${what} needs the shared store`);
|
|
51
|
+
return run.messages;
|
|
52
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The largest .ics file `events import` reads; checked before the file is read whole. */
|
|
2
|
+
export declare const ICS_MAX_BYTES: number;
|
|
3
|
+
export declare const readIcsFile: (path: string) => Promise<string>;
|
|
4
|
+
/**
|
|
5
|
+
* 0600 even over an existing file, written whole or not at all: an export holds the owner's meetings, attendees and
|
|
6
|
+
* descriptions. writeSecurely would make a missing folder; an export names one that exists.
|
|
7
|
+
*/
|
|
8
|
+
export declare const writeIcsFile: (path: string, content: string) => Promise<void>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { CliError, writeSecurely } from "@wirecat/cli-base";
|
|
4
|
+
/** The largest .ics file `events import` reads; checked before the file is read whole. */
|
|
5
|
+
export const ICS_MAX_BYTES = 10 * 1024 * 1024;
|
|
6
|
+
const FILE_ERRORS = {
|
|
7
|
+
ENOENT: ["not_found", "file.not-found", "does not exist"],
|
|
8
|
+
ENOTDIR: ["not_found", "file.not-found", "is not in a folder that exists"],
|
|
9
|
+
EISDIR: ["validation_error", "file.not-a-file", "is a folder, not a file"],
|
|
10
|
+
EACCES: ["permission_error", "file.denied", "cannot be opened: permission denied"],
|
|
11
|
+
EPERM: ["permission_error", "file.denied", "cannot be opened: permission denied"],
|
|
12
|
+
};
|
|
13
|
+
/** A file the owner named that cannot be read or written is their error, named by its path, never a crash. */
|
|
14
|
+
const named = async (path, act) => {
|
|
15
|
+
try {
|
|
16
|
+
return await act();
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
const known = FILE_ERRORS[error.code ?? ""];
|
|
20
|
+
if (!known)
|
|
21
|
+
throw error;
|
|
22
|
+
const [code, reason, words] = known;
|
|
23
|
+
throw new CliError(code, `${path} ${words}`, { reason });
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
export const readIcsFile = (path) => named(path, async () => {
|
|
27
|
+
const info = await stat(path);
|
|
28
|
+
if (!info.isFile())
|
|
29
|
+
throw new CliError("validation_error", `${path} is not a file`, { reason: "file.not-a-file" });
|
|
30
|
+
if (info.size > ICS_MAX_BYTES)
|
|
31
|
+
throw new CliError("validation_error", `${path} is larger than 10 MB`, { reason: "ics.too-large" });
|
|
32
|
+
return readFile(path, "utf8");
|
|
33
|
+
});
|
|
34
|
+
/**
|
|
35
|
+
* 0600 even over an existing file, written whole or not at all: an export holds the owner's meetings, attendees and
|
|
36
|
+
* descriptions. writeSecurely would make a missing folder; an export names one that exists.
|
|
37
|
+
*/
|
|
38
|
+
export const writeIcsFile = (path, content) => named(path, async () => {
|
|
39
|
+
await stat(dirname(path));
|
|
40
|
+
writeSecurely(path, content, 0o600);
|
|
41
|
+
});
|
|
@@ -1,70 +1,77 @@
|
|
|
1
1
|
import { type Command } from "commander";
|
|
2
|
+
import { type Body } from "../../cli/envelope.js";
|
|
2
3
|
import type { EventStore } from "../../meetings/index.js";
|
|
3
4
|
import type { AppIdentity } from "../../ports/app.js";
|
|
4
5
|
import type { AccountKey, MessageStore } from "../../store/store.js";
|
|
5
6
|
import { type CalendarFeeds } from "../feeds.js";
|
|
6
7
|
import type { CalendarExpander, CalendarSource } from "../source.js";
|
|
7
8
|
import { type CalendarWrites } from "../writes.js";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
/**
|
|
10
|
+
* The selected profile's calendar, opened by the app for one command and closed once its answer is built, before
|
|
11
|
+
* anything is printed (WC-LIFE-4). Nothing of it outlives the command, so concurrent runs share no state (WC-ARCH-22).
|
|
12
|
+
*/
|
|
13
|
+
export interface CalendarRun {
|
|
14
|
+
/** The profile's name, which `store status` gives as each sync's `profile`. */
|
|
15
|
+
profile: string;
|
|
16
|
+
/** The profile's calendar account as the shared store names it: every command reads and syncs only it. */
|
|
13
17
|
account: AccountKey;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
/** The shared store: `search`, `people link` and
|
|
18
|
+
accountId: number;
|
|
19
|
+
store: EventStore;
|
|
20
|
+
/** The shared store: `search`, `people link`, `--source` and an attendee's person read it. */
|
|
17
21
|
messages?: MessageStore;
|
|
18
|
-
/** The provider
|
|
19
|
-
source
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
freeBusy?: boolean;
|
|
25
|
-
/**
|
|
26
|
-
* The source answers `search`: `search` takes `--backend server` and asks the provider. A flag, as `freeBusy`, so the
|
|
27
|
-
* source can stay unbuilt until a profile is open.
|
|
28
|
-
*/
|
|
29
|
-
serverSearch?: boolean;
|
|
22
|
+
/** The provider, built on first use: a command that only reads the store never reads the sign-in. */
|
|
23
|
+
source: () => CalendarSource;
|
|
24
|
+
/** The provider's writer and the profile's guard, for the write commands. */
|
|
25
|
+
writes?: () => CalendarWrites;
|
|
26
|
+
/** The iCalendar feeds the profile follows; `sync` and `calendars list` read them after the provider. */
|
|
27
|
+
feeds?: () => CalendarFeeds;
|
|
30
28
|
/** For a source with `expands: "local"`. */
|
|
31
29
|
expand?: CalendarExpander;
|
|
30
|
+
env?: NodeJS.ProcessEnv;
|
|
32
31
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*/
|
|
36
|
-
feeds?: CalendarFeeds;
|
|
37
|
-
/** After `sync --embed`: embeds new events for search by meaning (`embedEvents`), as drive's `sync --embed`. */
|
|
38
|
-
embed?: (max: number) => Promise<{
|
|
39
|
-
model: string;
|
|
40
|
-
embedded: number;
|
|
41
|
-
left: boolean;
|
|
42
|
-
}>;
|
|
43
|
-
/**
|
|
44
|
-
* `view` is the text a person reads in place of the default rendering: the times on the zone's wall clock, and the
|
|
45
|
-
* zone named. It takes the host's own body (`renderAnswer` passes it) or, without one, renders `value`.
|
|
32
|
+
* A profile lock pins the process (WC-ACCT-5): `events list --source` may then read only the profile's account, and
|
|
33
|
+
* `links review` lists only its choices.
|
|
46
34
|
*/
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
35
|
+
locked?: boolean;
|
|
36
|
+
close?: () => Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
/** What the provider can do, known when the commands are built, before any profile is open. */
|
|
39
|
+
export interface CalendarCommandOptions {
|
|
40
|
+
/** The CLI itself: the first word of examples, the store's command name, and `search all`. */
|
|
41
|
+
app: AppIdentity;
|
|
50
42
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
43
|
+
* Opens the selected profile's calendar for one command. `readOnly` is true when the command only reads the store, so
|
|
44
|
+
* parallel reads take no write lock; the app may still open it writable, for a profile's first run say.
|
|
53
45
|
*/
|
|
54
|
-
|
|
46
|
+
open: (command: Command, access: {
|
|
47
|
+
readOnly: boolean;
|
|
48
|
+
}) => Promise<CalendarRun>;
|
|
49
|
+
/** The source answers `freeBusy`: mounts `calendars busy`. */
|
|
50
|
+
freeBusy?: boolean;
|
|
51
|
+
/** The source answers `search`: `search events` takes `--backend server|both`. */
|
|
52
|
+
serverSearch?: boolean;
|
|
53
|
+
/** The profile may follow iCalendar feeds: mounts `calendars add|remove`. */
|
|
54
|
+
feeds?: boolean;
|
|
55
|
+
/** The provider takes writes: mounts `events create|update|delete|respond|remind`, and what its writer adds. */
|
|
56
|
+
writes?: {
|
|
57
|
+
quickAdd?: boolean;
|
|
58
|
+
move?: boolean;
|
|
59
|
+
importEvent?: boolean;
|
|
60
|
+
};
|
|
61
|
+
/** Reads `events import`'s file; a file on disk, at most 10 MB, when absent. */
|
|
62
|
+
readFile?: (path: string) => Promise<string>;
|
|
63
|
+
/** Writes `events export --output`'s file; owner-only (0600), whole or not at all, when absent. */
|
|
64
|
+
writeFile?: (path: string, content: string) => Promise<void>;
|
|
55
65
|
now?: () => number;
|
|
56
66
|
/** The zone `today`, `week` and a bare date are read in; the computer's when absent. */
|
|
57
67
|
timezone?: string;
|
|
58
|
-
/** The provider's writer and the profile's guard: `events create|update|delete|respond` exist only with it. */
|
|
59
|
-
writes?: CalendarWrites;
|
|
60
|
-
/** Writes `events export --output`'s file; without it the export is printed only. */
|
|
61
|
-
writeFile?: (path: string, content: string) => Promise<void>;
|
|
62
|
-
/** Reads `events import`'s file; without it, and the writer's `import`, there is no `events import`. */
|
|
63
|
-
readFile?: (path: string) => Promise<string>;
|
|
64
|
-
/**
|
|
65
|
-
* A profile lock pins the process (WC-ACCT-5): `events list --source` may then read only the profile's account, and
|
|
66
|
-
* `links review` lists only its choices.
|
|
67
|
-
*/
|
|
68
|
-
locked?: boolean;
|
|
69
68
|
}
|
|
70
|
-
|
|
69
|
+
/** Opens the profile for one command, builds its answer and prints it once the profile is closed. */
|
|
70
|
+
export type Within = (command: Command, work: (run: CalendarRun) => Promise<Body>, view?: (body: Body) => string) => Promise<void>;
|
|
71
|
+
export interface CommandHelpers {
|
|
72
|
+
within: Within;
|
|
73
|
+
now: () => number;
|
|
74
|
+
zone: () => string;
|
|
75
|
+
example: (command: Command, ...lines: string[]) => Command;
|
|
76
|
+
}
|
|
77
|
+
export declare const addCalendarCommands: (program: Command, options: CalendarCommandOptions) => void;
|