pi-roundtable 0.8.0 → 0.9.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.
Files changed (150) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +35 -11
  3. package/README.zh-TW.md +27 -10
  4. package/docs/migration-0.9.md +247 -0
  5. package/docs/plugins.md +606 -78
  6. package/examples/channel-context.test.ts +88 -0
  7. package/examples/channel-context.ts +62 -0
  8. package/examples/echo-runtime.test.ts +1 -0
  9. package/examples/fake-surface.test.ts +69 -6
  10. package/examples/fake-surface.ts +73 -7
  11. package/examples/headless.ts +5 -1
  12. package/examples/prechecks.test.ts +1 -0
  13. package/examples/principals.test.ts +60 -0
  14. package/examples/principals.ts +48 -0
  15. package/examples/prompt.test.ts +1 -1
  16. package/examples/study-room.test.ts +29 -1
  17. package/examples/study-room.ts +16 -19
  18. package/examples/support-desk.test.ts +3 -1
  19. package/examples/tools.test.ts +6 -1
  20. package/package.json +5 -2
  21. package/src/cli/checks/database.ts +11 -0
  22. package/src/cli/checks/identity.ts +225 -0
  23. package/src/cli/checks/model.ts +21 -3
  24. package/src/cli/cli.ts +115 -1
  25. package/src/cli/config-edit.ts +83 -13
  26. package/src/cli/doctor.ts +7 -0
  27. package/src/cli/line-diff.ts +72 -0
  28. package/src/cli/principal.ts +412 -0
  29. package/src/cli/project.ts +5 -0
  30. package/src/cli/report.ts +12 -3
  31. package/src/cli/upgrade-rewrite.ts +102 -0
  32. package/src/cli/upgrade-source.ts +430 -0
  33. package/src/cli/upgrade.ts +176 -0
  34. package/src/core/agents/agent-claim.ts +56 -34
  35. package/src/core/agents/team-turns.ts +8 -4
  36. package/src/core/builtin/agent-server.ts +9 -7
  37. package/src/core/builtin/discord-admin.ts +4 -1
  38. package/src/core/builtin/discord.ts +71 -13
  39. package/src/core/builtin/modules.ts +140 -85
  40. package/src/core/builtin/stores.ts +40 -21
  41. package/src/core/config/access.ts +229 -0
  42. package/src/core/config/adapters.ts +64 -1
  43. package/src/core/config/config.ts +78 -41
  44. package/src/core/contract/channel-context.ts +53 -0
  45. package/src/core/contract/channels.ts +77 -9
  46. package/src/core/contract/runtime.ts +23 -7
  47. package/src/core/contract/surface.ts +22 -6
  48. package/src/core/conversations/conversation-registry.ts +14 -0
  49. package/src/core/conversations/conversation-store.ts +13 -0
  50. package/src/core/define-roundtable.ts +41 -11
  51. package/src/core/discord/agent-panel.ts +2 -2
  52. package/src/core/discord/card-audience.ts +148 -0
  53. package/src/core/discord/channel-context.ts +348 -0
  54. package/src/core/discord/connection.ts +15 -3
  55. package/src/core/discord/direct-channel.ts +54 -0
  56. package/src/core/discord/discord-owners.ts +133 -0
  57. package/src/core/discord/discord-surface.ts +69 -14
  58. package/src/core/discord/fresh-marker.ts +24 -0
  59. package/src/core/discord/inbound-message.ts +21 -7
  60. package/src/core/discord/interaction-module.ts +11 -1
  61. package/src/core/discord/owner-cards-fixture.ts +80 -0
  62. package/src/core/discord/owner-cards.ts +58 -97
  63. package/src/core/discord/owner-command.ts +27 -13
  64. package/src/core/discord/stop-button.ts +4 -4
  65. package/src/core/domain/conversation.ts +7 -2
  66. package/src/core/domain/errors.ts +5 -0
  67. package/src/core/domain/ports.ts +27 -2
  68. package/src/core/host.ts +27 -8
  69. package/src/core/i18n/cards.ts +8 -4
  70. package/src/core/identity/access-policy.ts +227 -0
  71. package/src/core/identity/actor-facts.ts +65 -0
  72. package/src/core/identity/caching-principal-store.ts +111 -0
  73. package/src/core/identity/config-owner.ts +145 -0
  74. package/src/core/identity/contact.ts +27 -0
  75. package/src/core/identity/identity-plugin.ts +56 -0
  76. package/src/core/identity/identity-schema.ts +282 -0
  77. package/src/core/identity/identity-service.ts +489 -0
  78. package/src/core/identity/identity-view.ts +77 -0
  79. package/src/core/identity/plugin-identities.ts +109 -0
  80. package/src/core/identity/principal-reads.ts +38 -0
  81. package/src/core/identity/principal-store.ts +478 -0
  82. package/src/core/identity/principal-tables.ts +53 -0
  83. package/src/core/identity/seen-throttle.ts +57 -0
  84. package/src/core/identity/ulid.ts +36 -0
  85. package/src/core/identity.ts +34 -5
  86. package/src/core/interactions/prompts.ts +127 -0
  87. package/src/core/modules/background/background-turns.ts +56 -17
  88. package/src/core/modules/background/personal-target.ts +50 -0
  89. package/src/core/modules/delegation/delegate.ts +28 -14
  90. package/src/core/modules/delegation/delegator.ts +23 -7
  91. package/src/core/modules/memory/owner-memory.ts +123 -50
  92. package/src/core/modules/notify/notify.ts +53 -13
  93. package/src/core/modules/notify/session-notify.ts +110 -0
  94. package/src/core/modules/schedules/prechecks.ts +8 -2
  95. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  96. package/src/core/modules/schedules/schedule-store.ts +51 -20
  97. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  98. package/src/core/modules/schedules/scheduler.ts +43 -2
  99. package/src/core/modules/schedules/schedules.ts +43 -14
  100. package/src/core/plugin.ts +48 -0
  101. package/src/core/presence/direct-channels.ts +104 -0
  102. package/src/core/registry/contributions.ts +63 -3
  103. package/src/core/routing/channel-router.ts +218 -17
  104. package/src/core/routing/conversation-turns.ts +38 -8
  105. package/src/core/routing/surface-port.ts +32 -2
  106. package/src/core/routing/system-turns.ts +29 -0
  107. package/src/core/runtime/bridge-guard.ts +225 -0
  108. package/src/core/runtime/compaction-tiers.ts +5 -1
  109. package/src/core/runtime/conversation-sessions.ts +124 -12
  110. package/src/core/runtime/extensions/ask-user.ts +1 -1
  111. package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
  112. package/src/core/runtime/extensions/private-memory.ts +447 -0
  113. package/src/core/runtime/pending-confirmation-store.ts +30 -5
  114. package/src/core/runtime/pi-agent-runtime.ts +135 -134
  115. package/src/core/runtime/preflight-tools.ts +47 -0
  116. package/src/core/runtime/prompt-slot.ts +4 -4
  117. package/src/core/runtime/reader-history.ts +129 -0
  118. package/src/core/runtime/runtime-plugin.ts +50 -5
  119. package/src/core/runtime/runtime-types.ts +40 -12
  120. package/src/core/runtime/session-conversation.ts +143 -0
  121. package/src/core/runtime/session-factory.ts +115 -15
  122. package/src/core/runtime/session-scope.ts +78 -0
  123. package/src/core/runtime/steerable-run.ts +9 -0
  124. package/src/core/runtime/tool-call-scope.ts +43 -0
  125. package/src/core/runtime/turn-answer.ts +30 -1
  126. package/src/core/services.ts +27 -1
  127. package/src/core/sessions.ts +30 -0
  128. package/src/core/speakers.ts +21 -19
  129. package/src/core/testing/background.ts +31 -0
  130. package/src/core/testing/fixture-database.ts +55 -0
  131. package/src/core/testing/map-identity.ts +76 -0
  132. package/src/core/testing/module-sessions.ts +134 -0
  133. package/src/core/testing/modules.ts +108 -21
  134. package/src/core/testing/owner.ts +17 -0
  135. package/src/core/testing/surface-contract.ts +118 -41
  136. package/src/core/testing/test-host.ts +79 -16
  137. package/src/core/testing/tool-set.ts +32 -0
  138. package/src/core/tool-set.snapshot.json +4 -4
  139. package/src/core/tool-tiers.ts +57 -3
  140. package/src/discord/index.ts +8 -0
  141. package/src/index.ts +47 -8
  142. package/src/kit/compaction.ts +13 -0
  143. package/src/kit/index.ts +10 -0
  144. package/src/kit/support.ts +1 -1
  145. package/src/testing.ts +55 -16
  146. package/templates/.env.example +1 -1
  147. package/templates/adapters/web/README.md +7 -0
  148. package/templates/adapters/web/roundtable.config.ts +14 -10
  149. package/templates/roundtable.config.ts +12 -1
  150. package/src/core/domain/owner-prompts.ts +0 -45
@@ -0,0 +1,72 @@
1
+ /** Every line of `before` and `after` in order: kept, removed, or added. */
2
+ function lineOps(
3
+ before: readonly string[],
4
+ after: readonly string[],
5
+ ): { kind: " " | "-" | "+"; line: string }[] {
6
+ // The longest common subsequence, from the end, so the walk below reads forward.
7
+ const rows = before.length + 1;
8
+ const columns = after.length + 1;
9
+ const common = new Uint32Array(rows * columns);
10
+ for (let i = before.length - 1; i >= 0; i--)
11
+ for (let j = after.length - 1; j >= 0; j--)
12
+ common[i * columns + j] =
13
+ before[i] === after[j]
14
+ ? (common[(i + 1) * columns + j + 1] ?? 0) + 1
15
+ : Math.max(
16
+ common[(i + 1) * columns + j] ?? 0,
17
+ common[i * columns + j + 1] ?? 0,
18
+ );
19
+ const ops: { kind: " " | "-" | "+"; line: string }[] = [];
20
+ let i = 0;
21
+ let j = 0;
22
+ while (i < before.length || j < after.length) {
23
+ if (i < before.length && j < after.length && before[i] === after[j]) {
24
+ ops.push({ kind: " ", line: before[i++] ?? "" });
25
+ j++;
26
+ } else if (
27
+ j < after.length &&
28
+ (i === before.length ||
29
+ (common[i * columns + j + 1] ?? 0) >
30
+ (common[(i + 1) * columns + j] ?? 0))
31
+ )
32
+ ops.push({ kind: "+", line: after[j++] ?? "" });
33
+ else ops.push({ kind: "-", line: before[i++] ?? "" });
34
+ }
35
+ return ops;
36
+ }
37
+
38
+ const CONTEXT = 3;
39
+
40
+ /** The change from `before` to `after` as a unified diff with three lines of context; empty when they are the same. */
41
+ export function lineDiff(
42
+ before: string,
43
+ after: string,
44
+ file: string,
45
+ ): string[] {
46
+ const ops = lineOps(before.split("\n"), after.split("\n"));
47
+ const changed = ops.flatMap((op, index) => (op.kind === " " ? [] : [index]));
48
+ if (changed.length === 0) return [];
49
+ // Changes closer than twice the context share a hunk.
50
+ const hunks: [number, number][] = [];
51
+ for (const index of changed) {
52
+ const last = hunks.at(-1);
53
+ if (last && index - last[1] <= 2 * CONTEXT) last[1] = index;
54
+ else hunks.push([index, index]);
55
+ }
56
+ const lines = [`--- ${file}`, `+++ ${file} (upgraded)`];
57
+ for (const [first, last] of hunks) {
58
+ const start = Math.max(0, first - CONTEXT);
59
+ const end = Math.min(ops.length - 1, last + CONTEXT);
60
+ const before = ops.slice(0, start);
61
+ const span = ops.slice(start, end + 1);
62
+ const count = (list: typeof ops, kind: "-" | "+") =>
63
+ list.filter((op) => op.kind !== (kind === "-" ? "+" : "-")).length;
64
+ const oldLength = count(span, "-");
65
+ const newLength = count(span, "+");
66
+ lines.push(
67
+ `@@ -${count(before, "-") + (oldLength > 0 ? 1 : 0)},${oldLength} +${count(before, "+") + (newLength > 0 ? 1 : 0)},${newLength} @@`,
68
+ ...span.map((op) => `${op.kind}${op.line}`),
69
+ );
70
+ }
71
+ return lines;
72
+ }
@@ -0,0 +1,412 @@
1
+ import type { SQL } from "bun";
2
+ import { openPool } from "../core/db/migrations.ts";
3
+ import { IdentityError } from "../core/domain/errors.ts";
4
+ import type { AccessOwner } from "../core/identity/access-policy.ts";
5
+ import { identityOf, parseIdentity } from "../core/identity/actor-facts.ts";
6
+ import type { DeclaredIdentity } from "../core/identity/plugin-identities.ts";
7
+ import {
8
+ type IdentityLink,
9
+ PgPrincipalStore,
10
+ type PrincipalRecord,
11
+ type Pronouns,
12
+ type RoleGrant,
13
+ } from "../core/identity/principal-store.ts";
14
+ import type { Tier } from "../core/speakers.ts";
15
+ import { CONFIG_FILE } from "./project.ts";
16
+
17
+ export const PRINCIPAL_USAGE = ` roundtable principal list every principal, with its roles and its identities written as
18
+ access in roundtable.config.ts takes them
19
+ roundtable principal show <principal>
20
+ roundtable principal create --name <name> [--pronouns he|she|they]
21
+ roundtable principal link <principal> <provider>:<subject>
22
+ roundtable principal unlink <provider>:<subject>
23
+ roundtable principal grant <principal> member|admin|owner
24
+ roundtable principal revoke <principal> member|admin|owner
25
+ roundtable principal disable <principal>
26
+ roundtable principal enable <principal>
27
+ <principal> is a principal id, or an identity linked to it`;
28
+
29
+ /** What every change prints: a running host reads its own copy for up to 30 seconds. */
30
+ export const HOST_DELAY =
31
+ "A running host sees this within 30 seconds; a host that starts sees it at once.";
32
+
33
+ const TIERS: readonly Tier[] = ["member", "admin", "owner"];
34
+ const PRONOUNS: readonly Pronouns[] = ["he", "she", "they"];
35
+
36
+ export interface PrincipalIo {
37
+ out(line: string): void;
38
+ err(line: string): void;
39
+ }
40
+
41
+ /** The owners the project's configuration names, or undefined when it does not load. */
42
+ type ConfiguredOwners = readonly AccessOwner[] | undefined;
43
+
44
+ /** The identities the project's plugins declare, or undefined when the configuration does not load. */
45
+ export type ConfiguredIdentities = readonly DeclaredIdentity[] | undefined;
46
+
47
+ /** What the project's configuration says, read by its schema alone. */
48
+ export interface Configured {
49
+ owners: ConfiguredOwners;
50
+ identities: ConfiguredIdentities;
51
+ }
52
+
53
+ /** A refusal: the message is the whole answer, printed as it is. */
54
+ class Refusal extends Error {}
55
+
56
+ const describe = (principal: PrincipalRecord): string =>
57
+ `${principal.displayName}${principal.pronouns ? ` (${principal.pronouns})` : ""}`;
58
+
59
+ const named = (principal: PrincipalRecord): string =>
60
+ `principal ${principal.id} (${principal.displayName})`;
61
+
62
+ const stamp = (date: Date): string => date.toISOString();
63
+
64
+ /**
65
+ * The principal a reference names: its id, or the principal an identity is linked to. A 0.8 id
66
+ * can be both, such as a web user's `oidc:…` id once that identity is linked to someone else;
67
+ * then it is refused rather than guessed.
68
+ */
69
+ async function find(
70
+ store: PgPrincipalStore,
71
+ reference: string | undefined,
72
+ ): Promise<PrincipalRecord> {
73
+ if (reference === undefined) throw new Refusal("name a principal");
74
+ const byId = await store.get(reference);
75
+ const identity = parseIdentity(reference);
76
+ const link =
77
+ identity && (await store.identity(identity.provider, identity.subject));
78
+ const linked = link ? await store.get(link.principalId) : undefined;
79
+ if (byId && linked && byId.id !== linked.id)
80
+ throw new Refusal(
81
+ `${reference} names two principals: ${named(byId)} by its id, and ${named(linked)} by the identity linked to it. Name ${linked.id} by its id; to reach ${byId.id}, unlink ${reference} first, then link it to ${linked.id} again`,
82
+ );
83
+ const found = byId ?? linked;
84
+ if (found) return found;
85
+ throw new Refusal(
86
+ `there is no principal ${reference}, and no identity ${reference} is linked; roundtable principal list shows them`,
87
+ );
88
+ }
89
+
90
+ function identityArgument(text: string | undefined) {
91
+ const identity = text === undefined ? undefined : parseIdentity(text);
92
+ if (!identity)
93
+ throw new Refusal(
94
+ `expected an identity written <provider>:<subject>, such as discord:<user id>, got ${JSON.stringify(text ?? "")}`,
95
+ );
96
+ return identity;
97
+ }
98
+
99
+ function tierArgument(text: string | undefined): Tier {
100
+ if (!TIERS.includes(text as Tier))
101
+ throw new Refusal(
102
+ `expected a role, one of member, admin, or owner, got ${JSON.stringify(text ?? "")}`,
103
+ );
104
+ return text as Tier;
105
+ }
106
+
107
+ /** The plugin of the configuration that declares the identity; undefined when none does, unknown when the configuration does not load. */
108
+ function declarer(
109
+ identities: ConfiguredIdentities,
110
+ link: IdentityLink,
111
+ ): string | undefined | "unknown" {
112
+ if (identities === undefined) return "unknown";
113
+ const text = identityOf(link);
114
+ return identities.find((declared) => declared.identity === text)?.plugin;
115
+ }
116
+
117
+ /** Where a link came from, and for a plugin's, which plugin declares it. */
118
+ function sourceText(link: IdentityLink, configured: Configured): string {
119
+ if (link.source !== "plugin") return link.source;
120
+ const plugin = declarer(configured.identities, link);
121
+ if (plugin === "unknown") return "plugin";
122
+ return plugin === undefined
123
+ ? "plugin, which no plugin declares any more: the next start unlinks it"
124
+ : `plugin ${plugin}`;
125
+ }
126
+
127
+ const linkLine = (link: IdentityLink, configured: Configured): string =>
128
+ ` ${identityOf(link)} (${sourceText(link, configured)})`;
129
+
130
+ const rolesText = (roles: readonly RoleGrant[]): string =>
131
+ roles.length === 0
132
+ ? "none"
133
+ : roles.map((grant) => `${grant.role} (${grant.source})`).join(", ");
134
+
135
+ /** Whether the person of its 0.8 id still links their own identity at first contact, as the identity service claims it. */
136
+ const claimable = (
137
+ principal: PrincipalRecord,
138
+ roles: readonly RoleGrant[],
139
+ ): boolean =>
140
+ principal.claimable && !roles.some((grant) => grant.role === "owner");
141
+
142
+ async function list(
143
+ store: PgPrincipalStore,
144
+ io: PrincipalIo,
145
+ configured: Configured,
146
+ ): Promise<void> {
147
+ const principals = await store.list();
148
+ for (const principal of principals) {
149
+ const roles = await store.rolesOf(principal.id);
150
+ io.out(
151
+ `${principal.id} ${describe(principal)}${principal.disabled ? " disabled" : ""} roles: ${rolesText(roles)}`,
152
+ );
153
+ const links = await store.identitiesOf(principal.id);
154
+ for (const link of links) io.out(linkLine(link, configured));
155
+ if (links.length === 0)
156
+ io.out(
157
+ claimable(principal, roles)
158
+ ? ` no identity linked yet; carried over from 0.8, so ${principal.id} links its own at its first contact`
159
+ : ` no identity linked yet; link one with roundtable principal link ${principal.id} <provider>:<subject>`,
160
+ );
161
+ }
162
+ io.out(
163
+ `${principals.length} ${principals.length === 1 ? "principal" : "principals"}. Each identity is written <provider>:<subject>, as access in roundtable.config.ts and roundtable principal link take it.`,
164
+ );
165
+ }
166
+
167
+ async function show(
168
+ store: PgPrincipalStore,
169
+ principal: PrincipalRecord,
170
+ io: PrincipalIo,
171
+ configured: Configured,
172
+ ): Promise<void> {
173
+ io.out(`principal ${principal.id}`);
174
+ io.out(` name: ${describe(principal)}`);
175
+ io.out(` status: ${principal.disabled ? "disabled" : "enabled"}`);
176
+ io.out(` created: ${stamp(principal.createdAt)}`);
177
+ io.out(
178
+ ` last seen: ${principal.lastSeenAt ? `${stamp(principal.lastSeenAt)} as ${principal.lastTier ?? "unknown"}` : "never"}`,
179
+ );
180
+ io.out(` roles: ${rolesText(await store.rolesOf(principal.id))}`);
181
+ const links = await store.identitiesOf(principal.id);
182
+ io.out(` identities: ${links.length === 0 ? "none" : ""}`.trimEnd());
183
+ for (const link of links)
184
+ io.out(
185
+ ` ${identityOf(link)} (${sourceText(link, configured)}, linked ${stamp(link.linkedAt)})`,
186
+ );
187
+ }
188
+
189
+ /** The value after `flag`, and the arguments without both. */
190
+ function option(
191
+ args: readonly string[],
192
+ flag: string,
193
+ ): { value: string | undefined; rest: string[] } {
194
+ const at = args.indexOf(flag);
195
+ if (at === -1) return { value: undefined, rest: [...args] };
196
+ return { value: args[at + 1], rest: args.toSpliced(at, 2) };
197
+ }
198
+
199
+ /** Each subcommand and how many arguments it takes. */
200
+ const ARITY: Record<string, number> = {
201
+ list: 0,
202
+ show: 1,
203
+ link: 2,
204
+ unlink: 1,
205
+ grant: 2,
206
+ revoke: 2,
207
+ disable: 1,
208
+ enable: 1,
209
+ };
210
+
211
+ /**
212
+ * Where the configuration lists an identity it linked: under the owner of the principal it is
213
+ * linked to (or an owner found by their identities), under another owner, nowhere any more, or
214
+ * unknown when the configuration does not load.
215
+ */
216
+ function listedUnder(
217
+ owners: ConfiguredOwners,
218
+ link: IdentityLink,
219
+ ): "this principal" | "another owner" | "nowhere" | "unknown" {
220
+ if (owners === undefined) return "unknown";
221
+ const text = identityOf(link);
222
+ const holders = owners.filter((owner) =>
223
+ owner.identities.some((written) => {
224
+ const ref = parseIdentity(written);
225
+ return ref !== undefined && identityOf(ref) === text;
226
+ }),
227
+ );
228
+ if (holders.length === 0) return "nowhere";
229
+ return holders.some(
230
+ (owner) =>
231
+ owner.principal === undefined || owner.principal === link.principalId,
232
+ )
233
+ ? "this principal"
234
+ : "another owner";
235
+ }
236
+
237
+ async function run(
238
+ store: PgPrincipalStore,
239
+ args: readonly string[],
240
+ io: PrincipalIo,
241
+ configured: Configured,
242
+ ): Promise<void> {
243
+ const { owners } = configured;
244
+ const [command, ...rest] = args;
245
+ if (command === "create") {
246
+ const name = option(rest, "--name");
247
+ const pronouns = option(name.rest, "--pronouns");
248
+ if (!name.value?.trim() || name.value.startsWith("--"))
249
+ throw new Refusal(
250
+ "create takes --name <name>, the name to address them by",
251
+ );
252
+ if (pronouns.rest.length > 0)
253
+ throw new Refusal(
254
+ `create takes --name and --pronouns, not ${pronouns.rest.join(" ")}`,
255
+ );
256
+ if (
257
+ pronouns.value !== undefined &&
258
+ !PRONOUNS.includes(pronouns.value as Pronouns)
259
+ )
260
+ throw new Refusal(
261
+ `--pronouns takes he, she, or they, not ${JSON.stringify(pronouns.value)}`,
262
+ );
263
+ const made = await store.create({
264
+ displayName: name.value.trim(),
265
+ ...(pronouns.value ? { pronouns: pronouns.value as Pronouns } : {}),
266
+ });
267
+ io.out(
268
+ `Created ${named(made)}. Link an identity to it with roundtable principal link ${made.id} <provider>:<subject>.`,
269
+ );
270
+ io.out(HOST_DELAY);
271
+ return;
272
+ }
273
+ const arity = command === undefined ? undefined : ARITY[command];
274
+ if (arity === undefined)
275
+ throw new Refusal(`unknown principal command. Usage:\n${PRINCIPAL_USAGE}`);
276
+ if (arity !== rest.length)
277
+ throw new Refusal(
278
+ `${command} takes ${arity} ${arity === 1 ? "argument" : "arguments"}. Usage:\n${PRINCIPAL_USAGE}`,
279
+ );
280
+ if (command === "list") return list(store, io, configured);
281
+ if (command === "unlink") {
282
+ const identity = identityArgument(rest[0]);
283
+ const link = await store.identity(identity.provider, identity.subject);
284
+ // A plugin's identity is linked again by the next start while the plugin declares it.
285
+ const plugin =
286
+ link?.source === "plugin"
287
+ ? declarer(configured.identities, link)
288
+ : undefined;
289
+ if (link && plugin === "unknown")
290
+ throw new Refusal(
291
+ `${identityOf(identity)} is linked to principal ${link.principalId} as an identity a plugin declares, and ${CONFIG_FILE} does not load here to say whether a plugin still declares it; fix it, then try again`,
292
+ );
293
+ if (link && plugin !== undefined)
294
+ throw new Refusal(
295
+ `${identityOf(identity)} is linked to principal ${link.principalId} as an identity plugin ${plugin} declares; change that plugin's options in ${CONFIG_FILE} instead, and the next start moves or unlinks it`,
296
+ );
297
+ // Whoever linked it (the configuration, a first contact, the CLI, or a 0.8 claim), an identity
298
+ // an owner is listed by would be admitted by a running host as someone new once unlinked here,
299
+ // and the next start would fail on it.
300
+ const listed = link ? listedUnder(owners, link) : "nowhere";
301
+ if (link && (listed === "this principal" || listed === "unknown"))
302
+ throw new Refusal(
303
+ `${identityOf(identity)} is linked to principal ${link.principalId}, ${listed === "unknown" ? `and ${CONFIG_FILE} does not load here to say whether it lists it under an owner; fix it, or` : "which the configuration lists under an owner;"} remove it from access.owners[*].identities in ${CONFIG_FILE} instead, ${link.source === "config" ? "and the next start unlinks it" : "then unlink it here once the host has started with that configuration"}`,
304
+ );
305
+ if (!link || !(await store.unlink(identity.provider, identity.subject)))
306
+ throw new Refusal(
307
+ `${identityOf(identity)} is not linked to any principal`,
308
+ );
309
+ io.out(
310
+ `Unlinked ${identityOf(identity)} from principal ${link.principalId}.`,
311
+ );
312
+ if (listed === "another owner")
313
+ io.out(
314
+ "The configuration lists it under another owner, which a running host does not read: stop the host, then start it with this configuration. If a new p_… principal appears with this identity before then, unlink it again.",
315
+ );
316
+ io.out(HOST_DELAY);
317
+ return;
318
+ }
319
+ const principal = await find(store, rest[0]);
320
+ if (command === "show") return show(store, principal, io, configured);
321
+ if (command === "link") {
322
+ const identity = identityArgument(rest[1]);
323
+ const spends = claimable(principal, await store.rolesOf(principal.id));
324
+ await store.link(principal.id, identity, "cli");
325
+ io.out(`Linked ${identityOf(identity)} to ${named(principal)}.`);
326
+ if (spends)
327
+ io.out(
328
+ `This uses up the 0.8 claim of principal ${principal.id}: its 0.8 id no longer links its own identity at first contact, even once this one is unlinked. Link each of their other identities here.`,
329
+ );
330
+ } else if (command === "grant") {
331
+ const role = tierArgument(rest[1]);
332
+ // Granting it again would make it the CLI's, outliving its removal from the configuration.
333
+ const held = (await store.rolesOf(principal.id)).find(
334
+ (grant) => grant.role === role,
335
+ );
336
+ if (held?.source === "config") {
337
+ io.out(
338
+ `${named(principal)} already holds ${role}, granted by the configuration; nothing changed.`,
339
+ );
340
+ return;
341
+ }
342
+ await store.grant(principal.id, role, "cli");
343
+ io.out(`Granted ${role} to ${named(principal)}.`);
344
+ if (role === "owner")
345
+ io.out(
346
+ "The owner role is granted only here and in the configuration; no role a surface reports makes an owner.",
347
+ );
348
+ } else if (command === "revoke") {
349
+ const role = tierArgument(rest[1]);
350
+ const grant = (await store.rolesOf(principal.id)).find(
351
+ (held) => held.role === role,
352
+ );
353
+ if (!grant)
354
+ throw new Refusal(`principal ${principal.id} does not hold ${role}`);
355
+ if (grant.source === "config")
356
+ throw new Refusal(
357
+ `principal ${principal.id} holds ${role} from the configuration, which grants it again at every start; remove them from access.owners in roundtable.config.ts instead`,
358
+ );
359
+ await store.revoke(principal.id, role, "cli");
360
+ io.out(`Revoked ${role} from ${named(principal)}.`);
361
+ } else if (command === "disable") {
362
+ await store.disable(principal.id);
363
+ io.out(
364
+ `Disabled ${named(principal)}: no surface serves them, and their background turns are skipped.`,
365
+ );
366
+ } else {
367
+ await store.enable(principal.id);
368
+ io.out(`Enabled ${named(principal)}.`);
369
+ }
370
+ io.out(HOST_DELAY);
371
+ }
372
+
373
+ /** Whether the host has made the principal tables, which its first start on 0.9 does. */
374
+ async function hasTables(sql: SQL): Promise<boolean> {
375
+ const [row] = await sql`
376
+ SELECT to_regclass('principals') IS NOT NULL
377
+ AND to_regclass('principal_identities') IS NOT NULL
378
+ AND to_regclass('principal_roles') IS NOT NULL AS made`;
379
+ return row?.made === true;
380
+ }
381
+
382
+ /**
383
+ * Runs one `roundtable principal` command on the database at `url`, beside a host that may be
384
+ * running, and returns the exit code. It changes the database directly; a running host's cache
385
+ * catches up within 30 seconds, which every change says.
386
+ */
387
+ export async function principalCommand(
388
+ url: string,
389
+ args: readonly string[],
390
+ io: PrincipalIo,
391
+ configured: Configured,
392
+ ): Promise<number> {
393
+ const sql = openPool(url);
394
+ try {
395
+ if (!(await hasTables(sql))) {
396
+ io.err(
397
+ "This database has no principals yet: start the host on pi-roundtable 0.9 once, and its migrations make them from the people it already knows.",
398
+ );
399
+ return 1;
400
+ }
401
+ await run(await PgPrincipalStore.attach(sql), args, io, configured);
402
+ return 0;
403
+ } catch (error) {
404
+ if (error instanceof Refusal || error instanceof IdentityError) {
405
+ io.err(error.message);
406
+ return 1;
407
+ }
408
+ throw error;
409
+ } finally {
410
+ await sql.close();
411
+ }
412
+ }
@@ -32,6 +32,11 @@ export async function loadConfigFile(cwd: string): Promise<unknown> {
32
32
  throw new Error(
33
33
  `${CONFIG_FILE} is not in ${cwd}. Run this in the project directory, or create a project with \`roundtable init\`.`,
34
34
  );
35
+ return loadDefault(path);
36
+ }
37
+
38
+ /** The value a configuration file exports by default, imported with Bun. */
39
+ export async function loadDefault(path: string): Promise<unknown> {
35
40
  const module = (await import(pathToFileURL(path).href)) as {
36
41
  default?: unknown;
37
42
  };
package/src/cli/report.ts CHANGED
@@ -1,6 +1,10 @@
1
- /** How one check ended: passed, failed with what to do, or not run because it needs something an earlier check reports. */
1
+ /**
2
+ * How one check ended: passed, passed with a warning and what to do about it, failed with what to
3
+ * do, or not run because it needs something an earlier check reports. A warning fails nothing.
4
+ */
2
5
  export type Result =
3
6
  | { status: "ok"; detail?: string }
7
+ | { status: "warn"; problem: string; fix: string }
4
8
  | { status: "fail"; problem: string; fix: string }
5
9
  | { status: "skipped"; reason: string };
6
10
 
@@ -23,6 +27,11 @@ export const fail = (problem: string, fix: string): Result => ({
23
27
  problem,
24
28
  fix,
25
29
  });
30
+ export const warn = (problem: string, fix: string): Result => ({
31
+ status: "warn",
32
+ problem,
33
+ fix,
34
+ });
26
35
  export const skipped = (reason: string): Result => ({
27
36
  status: "skipped",
28
37
  reason,
@@ -53,7 +62,7 @@ export async function runChecks(checks: readonly Check[]): Promise<Outcome[]> {
53
62
  export const failed = (outcomes: readonly Outcome[]): Outcome[] =>
54
63
  outcomes.filter(({ result }) => result.status === "fail");
55
64
 
56
- /** One line per check, and for a failure its problem and fix underneath. */
65
+ /** One line per check, and for a warning or a failure its problem and fix underneath. */
57
66
  export function formatOutcomes(outcomes: readonly Outcome[]): string[] {
58
67
  return outcomes.flatMap(({ name, result }) => {
59
68
  if (result.status === "ok")
@@ -61,7 +70,7 @@ export function formatOutcomes(outcomes: readonly Outcome[]): string[] {
61
70
  if (result.status === "skipped")
62
71
  return [`- ${name}: skipped, ${result.reason}`];
63
72
  return [
64
- `✗ ${name}: ${result.problem}`,
73
+ `${result.status === "warn" ? "!" : "✗"} ${name}: ${result.problem}`,
65
74
  ...result.fix.split("\n").map((line) => ` ${line}`),
66
75
  ];
67
76
  });
@@ -0,0 +1,102 @@
1
+ import { ConfigEditError, type Node } from "./config-edit.ts";
2
+
3
+ /** One replacement of the source text: [start, end) becomes `text`. */
4
+ export interface Edit {
5
+ start: number;
6
+ end: number;
7
+ text: string;
8
+ }
9
+
10
+ /**
11
+ * Rewrites one configuration's text. Each place it cannot read with confidence, such as an owner
12
+ * built elsewhere or spread into the object, is a ConfigEditError naming the file, line, and column.
13
+ */
14
+ export class Rewrite {
15
+ readonly source: string;
16
+ readonly #file: string;
17
+ readonly #comments: readonly Node[];
18
+ readonly edits: Edit[] = [];
19
+ readonly moved: Node[] = [];
20
+
21
+ constructor(source: string, file: string, comments: readonly Node[]) {
22
+ this.source = source;
23
+ this.#file = file;
24
+ this.#comments = comments;
25
+ }
26
+
27
+ text(node: Node): string {
28
+ return this.source.slice(node.start, node.end);
29
+ }
30
+
31
+ /** A ConfigEditError at the node, as `file:line:column: message`. */
32
+ at(node: Node, message: string): ConfigEditError {
33
+ const before = this.source.slice(0, node.start);
34
+ const line = before.split("\n").length;
35
+ const column = node.start - (before.lastIndexOf("\n") + 1) + 1;
36
+ return new ConfigEditError(`${this.#file}:${line}:${column}: ${message}`);
37
+ }
38
+
39
+ /** The indentation of the node's line, when the node starts it; undefined when it shares the line. */
40
+ indentOf(node: Node): string | undefined {
41
+ const lineStart = this.source.lastIndexOf("\n", node.start - 1) + 1;
42
+ const before = this.source.slice(lineStart, node.start);
43
+ return /^[ \t]*$/.test(before) ? before : undefined;
44
+ }
45
+
46
+ /** The comments inside [start, end). */
47
+ commentsIn(start: number, end: number): Node[] {
48
+ return this.#comments.filter(
49
+ (comment) => comment.start >= start && comment.end <= end,
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Where the property and what belongs to it end when it is removed: the comment lines right
55
+ * above it, its comma, and a comment after it on its line, through the line's end when it has
56
+ * the line to itself.
57
+ */
58
+ removal(property: Node): { start: number; end: number } {
59
+ const source = this.source;
60
+ let end = property.end;
61
+ const after = /^[ \t]*,?[ \t]*(\/\/[^\n]*|\/\*[^\n]*?\*\/)?[ \t]*/.exec(
62
+ source.slice(end),
63
+ );
64
+ end += after?.[0].length ?? 0;
65
+ const indent = this.indentOf(property);
66
+ if (indent === undefined || (source[end] !== "\n" && end < source.length))
67
+ return { start: property.start, end };
68
+ let start = property.start - indent.length;
69
+ for (;;) {
70
+ const previousEnd = start - 1;
71
+ if (previousEnd < 0) break;
72
+ const previousStart = source.lastIndexOf("\n", previousEnd - 1) + 1;
73
+ const line = source.slice(previousStart, previousEnd).trim();
74
+ if (
75
+ !(
76
+ line.startsWith("//") ||
77
+ (line.startsWith("/*") && line.endsWith("*/"))
78
+ )
79
+ )
80
+ break;
81
+ start = previousStart;
82
+ }
83
+ return { start, end: end + (source[end] === "\n" ? 1 : 0) };
84
+ }
85
+
86
+ /** The comment lines to put back above a rewritten property, at its indentation. */
87
+ commentLines(comments: readonly Node[], indent: string | undefined): string {
88
+ if (comments.length === 0) return "";
89
+ if (indent === undefined) {
90
+ const line = comments.find((comment) => comment.type === "CommentLine");
91
+ if (line)
92
+ throw this.at(
93
+ line,
94
+ "this comment is inside what the upgrade rewrites, on a line it shares with other keys; move it out, or rewrite the key by hand",
95
+ );
96
+ return `${comments.map((comment) => this.text(comment)).join(" ")} `;
97
+ }
98
+ return comments
99
+ .map((comment) => `${this.text(comment)}\n${indent}`)
100
+ .join("");
101
+ }
102
+ }