pi-roundtable 0.1.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 +28 -0
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/docs/plugins.md +924 -0
- package/examples/channels.test.ts +13 -0
- package/examples/channels.ts +27 -0
- package/examples/dashboard.test.ts +11 -0
- package/examples/dashboard.ts +9 -0
- package/examples/events.test.ts +26 -0
- package/examples/events.ts +25 -0
- package/examples/guide.test.ts +52 -0
- package/examples/holds.test.ts +16 -0
- package/examples/holds.ts +30 -0
- package/examples/http.test.ts +12 -0
- package/examples/http.ts +17 -0
- package/examples/interactions.test.ts +25 -0
- package/examples/interactions.ts +28 -0
- package/examples/migrations.test.ts +31 -0
- package/examples/migrations.ts +42 -0
- package/examples/packages.test.ts +9 -0
- package/examples/packages.ts +9 -0
- package/examples/preflight.test.ts +11 -0
- package/examples/preflight.ts +19 -0
- package/examples/prompt.test.ts +25 -0
- package/examples/prompt.ts +20 -0
- package/examples/providers.test.ts +10 -0
- package/examples/providers.ts +17 -0
- package/examples/seeds.test.ts +12 -0
- package/examples/seeds.ts +18 -0
- package/examples/selection.test.ts +13 -0
- package/examples/selection.ts +14 -0
- package/examples/services.test.ts +20 -0
- package/examples/services.ts +30 -0
- package/examples/session-tools.test.ts +24 -0
- package/examples/session-tools.ts +34 -0
- package/examples/tools.test.ts +18 -0
- package/examples/tools.ts +28 -0
- package/package.json +55 -0
- package/src/cli/add-plugin.test.ts +107 -0
- package/src/cli/add-plugin.ts +75 -0
- package/src/cli/checks/basic.test.ts +276 -0
- package/src/cli/checks/bun.ts +26 -0
- package/src/cli/checks/configuration.ts +28 -0
- package/src/cli/checks/database.test.ts +117 -0
- package/src/cli/checks/database.ts +100 -0
- package/src/cli/checks/discord.test.ts +200 -0
- package/src/cli/checks/discord.ts +218 -0
- package/src/cli/checks/environment.ts +27 -0
- package/src/cli/checks/model.ts +28 -0
- package/src/cli/checks/public-url.ts +40 -0
- package/src/cli/cli.test.ts +122 -0
- package/src/cli/cli.ts +166 -0
- package/src/cli/config-edit.test.ts +130 -0
- package/src/cli/config-edit.ts +199 -0
- package/src/cli/discord-api.ts +96 -0
- package/src/cli/doctor.test.ts +184 -0
- package/src/cli/doctor.ts +92 -0
- package/src/cli/http.ts +23 -0
- package/src/cli/init.test.ts +127 -0
- package/src/cli/init.ts +82 -0
- package/src/cli/main.ts +13 -0
- package/src/cli/project.ts +123 -0
- package/src/cli/report.ts +68 -0
- package/src/cli/roundtable.mjs +22 -0
- package/src/cli/runtime.ts +53 -0
- package/src/cli/size.test.ts +9 -0
- package/src/cli/start.ts +33 -0
- package/src/cli/templates.test.ts +129 -0
- package/src/cli/templates.ts +119 -0
- package/src/cli/testing/fixtures.ts +106 -0
- package/src/core/agents/agent-claim.test.ts +142 -0
- package/src/core/agents/agent-claim.ts +170 -0
- package/src/core/agents/agent-dashboard.test.ts +120 -0
- package/src/core/agents/agent-dashboard.ts +185 -0
- package/src/core/agents/agent-guild.test.ts +124 -0
- package/src/core/agents/agent-messages.ts +189 -0
- package/src/core/agents/agent-ports.ts +103 -0
- package/src/core/agents/agent-prompt.ts +163 -0
- package/src/core/agents/agent-rules.ts +106 -0
- package/src/core/agents/agent-schema.ts +115 -0
- package/src/core/agents/agent-settings.ts +49 -0
- package/src/core/agents/agent-store.test.ts +205 -0
- package/src/core/agents/agent-store.ts +384 -0
- package/src/core/agents/agent-team.ts +445 -0
- package/src/core/agents/agent-tools.ts +386 -0
- package/src/core/agents/avatar-studio.test.ts +88 -0
- package/src/core/agents/avatar-studio.ts +147 -0
- package/src/core/agents/group-messages.ts +99 -0
- package/src/core/agents/group-round.test.ts +143 -0
- package/src/core/agents/group-round.ts +117 -0
- package/src/core/agents/group-turns.ts +132 -0
- package/src/core/agents/owner-identity.test.ts +172 -0
- package/src/core/agents/team-editing.ts +205 -0
- package/src/core/agents/team-keys.ts +40 -0
- package/src/core/agents/team-layout.ts +135 -0
- package/src/core/agents/team-lifecycle.ts +188 -0
- package/src/core/agents/team-options.ts +52 -0
- package/src/core/agents/team-status.ts +80 -0
- package/src/core/agents/team-text.ts +60 -0
- package/src/core/agents/team-turn-types.ts +107 -0
- package/src/core/agents/team-turns.test.ts +278 -0
- package/src/core/agents/team-turns.ts +323 -0
- package/src/core/assets/neutral.png +0 -0
- package/src/core/assets/prompts/shared-guest.md +11 -0
- package/src/core/assets/prompts/shared.md +11 -0
- package/src/core/assets/skills/writing-skills/SKILL.md +21 -0
- package/src/core/attachments/attachment-dir.ts +15 -0
- package/src/core/attachments/attachment-fetcher.ts +83 -0
- package/src/core/attachments/attachments.test.ts +85 -0
- package/src/core/attachments/image-prep.ts +50 -0
- package/src/core/attachments/prompt-block.ts +37 -0
- package/src/core/attachments/turn-attachments.ts +79 -0
- package/src/core/boundary.test.ts +45 -0
- package/src/core/builtin/agent-server.ts +275 -0
- package/src/core/builtin/discord.ts +95 -0
- package/src/core/builtin/modules.test.ts +127 -0
- package/src/core/builtin/modules.ts +176 -0
- package/src/core/builtin/seeds.ts +10 -0
- package/src/core/builtin/stores.ts +42 -0
- package/src/core/config/config.test.ts +135 -0
- package/src/core/config/config.ts +285 -0
- package/src/core/config/schema.ts +181 -0
- package/src/core/contract/channels.ts +152 -0
- package/src/core/contract/discord.ts +33 -0
- package/src/core/contract/providers.ts +67 -0
- package/src/core/db/guild-scope.ts +53 -0
- package/src/core/db/migrations.test.ts +237 -0
- package/src/core/db/migrations.ts +37 -0
- package/src/core/define-roundtable.test.ts +134 -0
- package/src/core/define-roundtable.ts +195 -0
- package/src/core/define.test.ts +144 -0
- package/src/core/define.ts +154 -0
- package/src/core/discord/agent-commands.test.ts +58 -0
- package/src/core/discord/agent-commands.ts +385 -0
- package/src/core/discord/agent-discord.test.ts +70 -0
- package/src/core/discord/agent-discord.ts +367 -0
- package/src/core/discord/channel-executor.ts +404 -0
- package/src/core/discord/channel-operations.ts +329 -0
- package/src/core/discord/discord-surface.ts +431 -0
- package/src/core/discord/dispatch-thread-host.test.ts +114 -0
- package/src/core/discord/dispatch-thread-host.ts +72 -0
- package/src/core/discord/dispatch-threads.test.ts +94 -0
- package/src/core/discord/dispatch-threads.ts +180 -0
- package/src/core/discord/interaction-module.ts +1 -0
- package/src/core/discord/owner-cards.test.ts +435 -0
- package/src/core/discord/owner-cards.ts +463 -0
- package/src/core/discord/owner-command.ts +126 -0
- package/src/core/discord/owner-discord-access.ts +183 -0
- package/src/core/discord/owner-discord-threads.ts +160 -0
- package/src/core/discord/owner-discord.test.ts +325 -0
- package/src/core/discord/owner-discord.ts +282 -0
- package/src/core/discord/owner-panel.ts +103 -0
- package/src/core/discord/schedule-commands.ts +149 -0
- package/src/core/discord/stop-button.ts +20 -0
- package/src/core/domain/attachment.ts +39 -0
- package/src/core/domain/conversation.ts +42 -0
- package/src/core/domain/errors.ts +31 -0
- package/src/core/domain/expression.ts +21 -0
- package/src/core/domain/owner-prompts.ts +45 -0
- package/src/core/domain/ports.ts +91 -0
- package/src/core/domain/profile.ts +32 -0
- package/src/core/drain.test.ts +46 -0
- package/src/core/drain.ts +33 -0
- package/src/core/errors.ts +29 -0
- package/src/core/events.test.ts +81 -0
- package/src/core/events.ts +55 -0
- package/src/core/holds.test.ts +54 -0
- package/src/core/holds.ts +46 -0
- package/src/core/host.test.ts +536 -0
- package/src/core/host.ts +297 -0
- package/src/core/http/listeners.test.ts +164 -0
- package/src/core/http/listeners.ts +151 -0
- package/src/core/i18n/agent-panel.ts +96 -0
- package/src/core/i18n/cards.ts +64 -0
- package/src/core/i18n/channels.ts +28 -0
- package/src/core/i18n/conversation.ts +49 -0
- package/src/core/i18n/dashboard.ts +72 -0
- package/src/core/i18n/discord.ts +23 -0
- package/src/core/i18n/en.ts +27 -0
- package/src/core/i18n/i18n.test.ts +139 -0
- package/src/core/i18n/index.ts +58 -0
- package/src/core/i18n/owner.ts +35 -0
- package/src/core/i18n/schedules.ts +92 -0
- package/src/core/i18n/time-zones.ts +27 -0
- package/src/core/i18n/types.ts +7 -0
- package/src/core/i18n/zh-tw.ts +26 -0
- package/src/core/identity.test.ts +18 -0
- package/src/core/identity.ts +31 -0
- package/src/core/judging/confirmation-judge.ts +68 -0
- package/src/core/judging/effort-judge.test.ts +112 -0
- package/src/core/judging/effort-judge.ts +158 -0
- package/src/core/judging/model-judge.test.ts +126 -0
- package/src/core/judging/model-judge.ts +193 -0
- package/src/core/log.ts +45 -0
- package/src/core/models.ts +52 -0
- package/src/core/modules/background/background-turns.ts +78 -0
- package/src/core/modules/delegation/delegate.ts +54 -0
- package/src/core/modules/delegation/delegator.test.ts +170 -0
- package/src/core/modules/delegation/delegator.ts +191 -0
- package/src/core/modules/delegation/sol-worker.ts +78 -0
- package/src/core/modules/discord-admin/discord-admin.ts +288 -0
- package/src/core/modules/host-shell/shell-policy.ts +386 -0
- package/src/core/modules/memory/owner-memory-store.test.ts +244 -0
- package/src/core/modules/memory/owner-memory-store.ts +228 -0
- package/src/core/modules/memory/owner-memory.ts +153 -0
- package/src/core/modules/notify/notify.ts +26 -0
- package/src/core/modules/schedules/recurrence.ts +206 -0
- package/src/core/modules/schedules/schedule-store.ts +213 -0
- package/src/core/modules/schedules/schedule-tools.ts +196 -0
- package/src/core/modules/schedules/schedule.test.ts +358 -0
- package/src/core/modules/schedules/scheduler.ts +121 -0
- package/src/core/modules/schedules/schedules.ts +87 -0
- package/src/core/modules/skills/repo-name.ts +11 -0
- package/src/core/modules/skills/skill-kind.test.ts +55 -0
- package/src/core/modules/skills/skill-link.ts +69 -0
- package/src/core/modules/skills/skill-listing.ts +128 -0
- package/src/core/modules/skills/skill-registry.test.ts +376 -0
- package/src/core/modules/skills/skill-registry.ts +323 -0
- package/src/core/modules/skills/skill-rules.ts +120 -0
- package/src/core/modules/skills/skill-store.ts +285 -0
- package/src/core/modules/skills/skill-tools.ts +207 -0
- package/src/core/ops/error-reporter.test.ts +283 -0
- package/src/core/ops/error-reporter.ts +271 -0
- package/src/core/plugin.ts +200 -0
- package/src/core/presentation/card-cadence.ts +41 -0
- package/src/core/presentation/headline.ts +34 -0
- package/src/core/presentation/presentation.test.ts +166 -0
- package/src/core/presentation/quiet-links.ts +36 -0
- package/src/core/presentation/reply-splitter.ts +110 -0
- package/src/core/presentation/thinking-line.ts +24 -0
- package/src/core/public-entry.test.ts +59 -0
- package/src/core/registry/contributions.test.ts +215 -0
- package/src/core/registry/contributions.ts +249 -0
- package/src/core/registry/interactions.test.ts +87 -0
- package/src/core/registry/interactions.ts +55 -0
- package/src/core/registry/providers.test.ts +103 -0
- package/src/core/registry/providers.ts +55 -0
- package/src/core/routing/channel-queue.test.ts +31 -0
- package/src/core/routing/channel-queue.ts +44 -0
- package/src/core/routing/channel-router.test.ts +326 -0
- package/src/core/routing/channel-router.ts +157 -0
- package/src/core/routing/conversation-kind.ts +14 -0
- package/src/core/routing/forward-join.ts +65 -0
- package/src/core/routing/message-text.ts +16 -0
- package/src/core/routing/settle-turn.test.ts +21 -0
- package/src/core/routing/settle-turn.ts +26 -0
- package/src/core/runtime/compaction-tiers.test.ts +227 -0
- package/src/core/runtime/compaction-tiers.ts +203 -0
- package/src/core/runtime/conversation-sessions.ts +162 -0
- package/src/core/runtime/extensions/agent-prompt.ts +16 -0
- package/src/core/runtime/extensions/ask-user.test.ts +114 -0
- package/src/core/runtime/extensions/ask-user.ts +98 -0
- package/src/core/runtime/extensions/confirmation-gate.ts +247 -0
- package/src/core/runtime/extensions/self-compact-guard.test.ts +50 -0
- package/src/core/runtime/extensions/self-compact-guard.ts +23 -0
- package/src/core/runtime/mcp.ts +23 -0
- package/src/core/runtime/pending-confirmation-store.test.ts +52 -0
- package/src/core/runtime/pending-confirmation-store.ts +75 -0
- package/src/core/runtime/pi-agent-runtime.ts +477 -0
- package/src/core/runtime/prompt-slot.ts +81 -0
- package/src/core/runtime/runtime-types.ts +150 -0
- package/src/core/runtime/session-archive.test.ts +20 -0
- package/src/core/runtime/session-archive.ts +17 -0
- package/src/core/runtime/session-factory.ts +291 -0
- package/src/core/runtime/steerable-run.test.ts +264 -0
- package/src/core/runtime/steerable-run.ts +109 -0
- package/src/core/runtime/text-tools.test.ts +110 -0
- package/src/core/runtime/text-tools.ts +66 -0
- package/src/core/runtime/turn-answer.test.ts +72 -0
- package/src/core/runtime/turn-answer.ts +79 -0
- package/src/core/runtime/worker-task.test.ts +82 -0
- package/src/core/runtime/worker-task.ts +65 -0
- package/src/core/services.test.ts +28 -0
- package/src/core/services.ts +107 -0
- package/src/core/sessions.test.ts +85 -0
- package/src/core/sessions.ts +149 -0
- package/src/core/shared/attachment-reader.ts +91 -0
- package/src/core/shared/delegate-tool.ts +15 -0
- package/src/core/shared/mcp-adapter.ts +58 -0
- package/src/core/shared/package-dir.ts +9 -0
- package/src/core/shared/profile-tools.ts +16 -0
- package/src/core/shared/read-attachment-tool.ts +43 -0
- package/src/core/shared/schedule-tools.ts +118 -0
- package/src/core/shared/session-messages.ts +50 -0
- package/src/core/shared/tool-result.ts +9 -0
- package/src/core/shared/unix-server.ts +18 -0
- package/src/core/size.test.ts +9 -0
- package/src/core/speakers.test.ts +78 -0
- package/src/core/speakers.ts +143 -0
- package/src/core/testing/database.ts +52 -0
- package/src/core/testing/file-size.ts +34 -0
- package/src/core/testing/locale.ts +8 -0
- package/src/core/testing/modules.ts +97 -0
- package/src/core/testing/owner.ts +15 -0
- package/src/core/testing/thread-host.ts +60 -0
- package/src/core/time.test.ts +166 -0
- package/src/core/time.ts +67 -0
- package/src/core/tool-tiers.test.ts +117 -0
- package/src/core/tool-tiers.ts +127 -0
- package/src/entries.test.ts +112 -0
- package/src/index.ts +28 -0
- package/src/testing.test.ts +156 -0
- package/src/testing.ts +182 -0
- package/templates/.env.example +22 -0
- package/templates/README.md +11 -0
- package/templates/_gitignore +3 -0
- package/templates/agents.ts +12 -0
- package/templates/biome.json.tmpl +7 -0
- package/templates/docker-compose.yml +16 -0
- package/templates/package.json.tmpl +24 -0
- package/templates/persona/shared.md +2 -0
- package/templates/plugin/plugin.test.ts.tmpl +13 -0
- package/templates/plugin/plugin.ts +18 -0
- package/templates/roundtable.config.ts +24 -0
- package/templates/tsconfig.json.tmpl +16 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Http } from "../http.ts";
|
|
2
|
+
import type { Project } from "../project.ts";
|
|
3
|
+
import { fail, ok, type Result, skipped } from "../report.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The public address is a well-formed http(s) URL; with `reachable`, it also answers, which is
|
|
7
|
+
* only true while the bot runs, so it is opt-in.
|
|
8
|
+
*/
|
|
9
|
+
export async function checkPublicUrl(
|
|
10
|
+
project: Project,
|
|
11
|
+
http: Http,
|
|
12
|
+
reachable: boolean,
|
|
13
|
+
): Promise<Result> {
|
|
14
|
+
const value = await project.text("http", "publicUrl");
|
|
15
|
+
if (!value) return skipped("http.publicUrl has no value");
|
|
16
|
+
let url: URL;
|
|
17
|
+
try {
|
|
18
|
+
url = new URL(value);
|
|
19
|
+
} catch {
|
|
20
|
+
return fail(
|
|
21
|
+
`${JSON.stringify(value)} is not a URL.`,
|
|
22
|
+
"Write the full address, such as https://bot.example.com, in PUBLIC_URL in .env.",
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
26
|
+
return fail(
|
|
27
|
+
`${value} does not start with http:// or https://.`,
|
|
28
|
+
"Discord fetches the agents' avatars from this address; use an http(s) URL.",
|
|
29
|
+
);
|
|
30
|
+
if (!reachable) return ok(`${value} is well formed`);
|
|
31
|
+
try {
|
|
32
|
+
const { status } = await http.get(value);
|
|
33
|
+
return ok(`${value} answered ${status}`);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return fail(
|
|
36
|
+
`${value} did not answer: ${error instanceof Error ? error.message : String(error)}`,
|
|
37
|
+
"Start the bot (`roundtable start`) and check the tunnel or proxy that publishes this address.",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { type CliEnvironment, readPackage, runCli } from "./cli.ts";
|
|
5
|
+
import { fakePorts, tempDir, validConfig } from "./testing/fixtures.ts";
|
|
6
|
+
|
|
7
|
+
const dirs: ReturnType<typeof tempDir>[] = [];
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
for (const dir of dirs.splice(0)) dir.done();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
function cli(overrides: Partial<CliEnvironment> = {}) {
|
|
13
|
+
const dir = tempDir();
|
|
14
|
+
dirs.push(dir);
|
|
15
|
+
const out: string[] = [];
|
|
16
|
+
const err: string[] = [];
|
|
17
|
+
const launched: unknown[] = [];
|
|
18
|
+
const io: CliEnvironment = {
|
|
19
|
+
cwd: dir.path,
|
|
20
|
+
env: {},
|
|
21
|
+
bun: () => ({ version: "1.3.10", required: ">=1.3.0" }),
|
|
22
|
+
version: () => "1.2.3",
|
|
23
|
+
ports: fakePorts(),
|
|
24
|
+
launch: async (defined) => void launched.push(defined),
|
|
25
|
+
out: (line) => out.push(line),
|
|
26
|
+
err: (line) => err.push(line),
|
|
27
|
+
...overrides,
|
|
28
|
+
};
|
|
29
|
+
return { io, out, err, launched, dir };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test("no command prints the usage and exits non-zero; help and --version exit zero", async () => {
|
|
33
|
+
const none = cli();
|
|
34
|
+
expect(await runCli([], none.io)).toBe(1);
|
|
35
|
+
expect(none.out.join("\n")).toContain("roundtable init");
|
|
36
|
+
const help = cli();
|
|
37
|
+
expect(await runCli(["--help"], help.io)).toBe(0);
|
|
38
|
+
const version = cli();
|
|
39
|
+
expect(await runCli(["--version"], version.io)).toBe(0);
|
|
40
|
+
expect(version.out).toEqual(["1.2.3"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("an unknown command or a wrong argument list prints the problem and exits non-zero", async () => {
|
|
44
|
+
for (const argv of [
|
|
45
|
+
["frobnicate"],
|
|
46
|
+
["add"],
|
|
47
|
+
["add", "plugin"],
|
|
48
|
+
["add", "thing", "x"],
|
|
49
|
+
["add", "plugin", "a", "b"],
|
|
50
|
+
["init", "a", "b"],
|
|
51
|
+
["doctor", "extra"],
|
|
52
|
+
["start", "--reachable"],
|
|
53
|
+
]) {
|
|
54
|
+
const run = cli();
|
|
55
|
+
expect(await runCli(argv, run.io)).toBe(1);
|
|
56
|
+
expect(run.err.length).toBeGreaterThan(0);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("init prints what it created and the three next steps, and exits zero; a second run refuses", async () => {
|
|
61
|
+
const run = cli();
|
|
62
|
+
expect(await runCli(["init", "bot"], run.io)).toBe(0);
|
|
63
|
+
const text = run.out.join("\n");
|
|
64
|
+
expect(text).toContain("roundtable.config.ts");
|
|
65
|
+
expect(text).toContain("1. cd bot && bun install");
|
|
66
|
+
expect(text).toContain("3. bunx roundtable start");
|
|
67
|
+
expect(existsSync(join(run.dir.path, "bot/plugins/hello.ts"))).toBe(true);
|
|
68
|
+
const again = await runCli(["init", "bot"], run.io);
|
|
69
|
+
expect(again).toBe(1);
|
|
70
|
+
expect(run.err.join("\n")).toContain("already exist");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("init on an old Bun refuses with the install page and creates nothing", async () => {
|
|
74
|
+
const run = cli({ bun: () => ({ version: "1.0.0", required: ">=1.3.0" }) });
|
|
75
|
+
expect(await runCli(["init"], run.io)).toBe(1);
|
|
76
|
+
expect(run.err.join("\n")).toContain("https://bun.sh/docs/installation");
|
|
77
|
+
expect(existsSync(join(run.dir.path, "package.json"))).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("add plugin exits zero after init and non-zero on a bad name", async () => {
|
|
81
|
+
const run = cli();
|
|
82
|
+
await runCli(["init"], run.io);
|
|
83
|
+
expect(await runCli(["add", "plugin", "notes"], run.io)).toBe(0);
|
|
84
|
+
expect(
|
|
85
|
+
readFileSync(join(run.dir.path, "roundtable.config.ts"), "utf8"),
|
|
86
|
+
).toContain("plugins: [hello, notes]");
|
|
87
|
+
expect(await runCli(["add", "plugin", "Bad_Name"], run.io)).toBe(1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("doctor exits non-zero when a check fails and zero when none does", async () => {
|
|
91
|
+
const failing = cli({
|
|
92
|
+
ports: fakePorts(validConfig, { login: async () => undefined }),
|
|
93
|
+
});
|
|
94
|
+
expect(await runCli(["doctor"], failing.io)).toBe(1);
|
|
95
|
+
expect(failing.out.join("\n")).toContain("✗ model login");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("start exits non-zero without launching when an offline check fails, and launches otherwise", async () => {
|
|
99
|
+
const failing = cli({ ports: fakePorts({}) });
|
|
100
|
+
expect(await runCli(["start"], failing.io)).toBe(1);
|
|
101
|
+
expect(failing.launched).toEqual([]);
|
|
102
|
+
expect(failing.err.join("\n")).toContain("✗ configuration");
|
|
103
|
+
const working = cli();
|
|
104
|
+
expect(await runCli(["start"], working.io)).toBe(0);
|
|
105
|
+
expect(working.launched).toHaveLength(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("readPackage reads the version and Bun range, and refuses a package.json without them", () => {
|
|
109
|
+
const dir = tempDir();
|
|
110
|
+
dirs.push(dir);
|
|
111
|
+
dir.write(
|
|
112
|
+
"package.json",
|
|
113
|
+
JSON.stringify({ version: "0.1.0", engines: { bun: ">=1.3.0" } }),
|
|
114
|
+
);
|
|
115
|
+
expect(readPackage(dir.path)).toEqual({ version: "0.1.0", bun: ">=1.3.0" });
|
|
116
|
+
dir.write("package.json", JSON.stringify({ name: "private" }));
|
|
117
|
+
expect(() => readPackage(dir.path)).toThrow(
|
|
118
|
+
"installed pi-roundtable package",
|
|
119
|
+
);
|
|
120
|
+
dir.write("package.json", "{ nope");
|
|
121
|
+
expect(() => readPackage(dir.path)).toThrow("not valid JSON");
|
|
122
|
+
});
|
package/src/cli/cli.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import type { DefinedRoundtable } from "../core/define-roundtable.ts";
|
|
4
|
+
import { Roundtable } from "../core/host.ts";
|
|
5
|
+
import { addPlugin } from "./add-plugin.ts";
|
|
6
|
+
import type { BunFacts } from "./checks/bun.ts";
|
|
7
|
+
import { postgres } from "./checks/database.ts";
|
|
8
|
+
import { doctor } from "./doctor.ts";
|
|
9
|
+
import { fetchHttp } from "./http.ts";
|
|
10
|
+
import { init } from "./init.ts";
|
|
11
|
+
import { loadConfigFile, type Ports } from "./project.ts";
|
|
12
|
+
import { formatOutcomes } from "./report.ts";
|
|
13
|
+
import { assemble, providerLogin } from "./runtime.ts";
|
|
14
|
+
import { start } from "./start.ts";
|
|
15
|
+
|
|
16
|
+
/** What the command line reads from its surroundings; tests replace every part. */
|
|
17
|
+
export interface CliEnvironment {
|
|
18
|
+
cwd: string;
|
|
19
|
+
env: Record<string, string | undefined>;
|
|
20
|
+
bun: () => BunFacts;
|
|
21
|
+
version: () => string;
|
|
22
|
+
ports: Ports;
|
|
23
|
+
launch(defined: DefinedRoundtable): Promise<void>;
|
|
24
|
+
out(line: string): void;
|
|
25
|
+
err(line: string): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const USAGE = `roundtable: a Discord agent server on Pi
|
|
29
|
+
|
|
30
|
+
roundtable init [dir] create a project (default: the current directory)
|
|
31
|
+
roundtable doctor [--reachable] check the setup and say how to fix what is wrong
|
|
32
|
+
roundtable start run the checks that need no network, then the bot
|
|
33
|
+
roundtable add plugin <name> add plugins/<name>.ts and its test, and list it in the config
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
/** The package's own version and the Bun range it needs, from the package.json beside the source. */
|
|
37
|
+
export function readPackage(dir = join(import.meta.dir, "../..")): {
|
|
38
|
+
version: string;
|
|
39
|
+
bun: string;
|
|
40
|
+
} {
|
|
41
|
+
const path = join(dir, "package.json");
|
|
42
|
+
let manifest: { version?: string; engines?: { bun?: string } } = {};
|
|
43
|
+
if (existsSync(path)) {
|
|
44
|
+
try {
|
|
45
|
+
manifest = JSON.parse(readFileSync(path, "utf8"));
|
|
46
|
+
} catch (error) {
|
|
47
|
+
throw new Error(`${path} is not valid JSON: ${String(error)}`, {
|
|
48
|
+
cause: error,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (!manifest.version || !manifest.engines?.bun)
|
|
53
|
+
throw new Error(
|
|
54
|
+
`${path} has no version and engines.bun; the roundtable command runs from an installed pi-roundtable package.`,
|
|
55
|
+
);
|
|
56
|
+
return { version: manifest.version, bun: manifest.engines.bun };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The real surroundings of the process. */
|
|
60
|
+
export function processEnvironment(): CliEnvironment {
|
|
61
|
+
return {
|
|
62
|
+
cwd: process.cwd(),
|
|
63
|
+
env: process.env,
|
|
64
|
+
bun: () => ({
|
|
65
|
+
version: typeof Bun === "undefined" ? undefined : Bun.version,
|
|
66
|
+
required: readPackage().bun,
|
|
67
|
+
}),
|
|
68
|
+
version: () => readPackage().version,
|
|
69
|
+
ports: {
|
|
70
|
+
loadConfig: loadConfigFile,
|
|
71
|
+
define: assemble,
|
|
72
|
+
login: providerLogin,
|
|
73
|
+
},
|
|
74
|
+
launch: async (defined) => {
|
|
75
|
+
const roundtable = new Roundtable(defined.options, defined.plugins);
|
|
76
|
+
roundtable.listen();
|
|
77
|
+
await roundtable.run();
|
|
78
|
+
},
|
|
79
|
+
out: (line) => console.log(line),
|
|
80
|
+
err: (line) => console.error(line),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Parses the arguments, runs one command, prints its report, and returns the exit code. */
|
|
85
|
+
export async function runCli(
|
|
86
|
+
argv: readonly string[],
|
|
87
|
+
io: CliEnvironment,
|
|
88
|
+
): Promise<number> {
|
|
89
|
+
const [command, ...rest] = argv;
|
|
90
|
+
if (command === undefined || command === "help" || command === "--help") {
|
|
91
|
+
io.out(USAGE);
|
|
92
|
+
return command === undefined ? 1 : 0;
|
|
93
|
+
}
|
|
94
|
+
if (command === "--version") {
|
|
95
|
+
io.out(io.version());
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
if (command === "init") {
|
|
99
|
+
const [dir, ...extra] = rest;
|
|
100
|
+
if (extra.length > 0) return usage(io, "init takes at most one directory");
|
|
101
|
+
const report = init({
|
|
102
|
+
cwd: io.cwd,
|
|
103
|
+
...(dir === undefined ? {} : { dir }),
|
|
104
|
+
bun: io.bun(),
|
|
105
|
+
version: io.version(),
|
|
106
|
+
});
|
|
107
|
+
if (!report.ok) {
|
|
108
|
+
for (const line of report.problems) io.err(line);
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
111
|
+
io.out(`Created ${report.created.length} files in ${report.root}:`);
|
|
112
|
+
for (const path of report.created) io.out(` ${path}`);
|
|
113
|
+
io.out("\nNext:");
|
|
114
|
+
for (const [index, step] of report.nextSteps.entries())
|
|
115
|
+
io.out(` ${index + 1}. ${step}`);
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
if (command === "add") {
|
|
119
|
+
const [what, name, ...extra] = rest;
|
|
120
|
+
if (what !== "plugin" || name === undefined || extra.length > 0)
|
|
121
|
+
return usage(io, "usage: roundtable add plugin <name>");
|
|
122
|
+
const report = addPlugin({ cwd: io.cwd, name });
|
|
123
|
+
if (!report.ok) {
|
|
124
|
+
for (const line of report.problems) io.err(line);
|
|
125
|
+
return 1;
|
|
126
|
+
}
|
|
127
|
+
io.out(`Added the plugin ${name}:`);
|
|
128
|
+
for (const path of report.changed) io.out(` ${path}`);
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
if (command === "doctor" || command === "start") {
|
|
132
|
+
const flags = rest.filter((arg) => arg.startsWith("-"));
|
|
133
|
+
const reachable = flags.includes("--reachable");
|
|
134
|
+
if (
|
|
135
|
+
rest.length > 0 &&
|
|
136
|
+
!(rest.length === 1 && reachable && command === "doctor")
|
|
137
|
+
)
|
|
138
|
+
return usage(io, `${command} takes no arguments`);
|
|
139
|
+
const inputs = {
|
|
140
|
+
cwd: resolve(io.cwd),
|
|
141
|
+
env: io.env,
|
|
142
|
+
bun: io.bun(),
|
|
143
|
+
ports: io.ports,
|
|
144
|
+
database: postgres,
|
|
145
|
+
http: fetchHttp,
|
|
146
|
+
};
|
|
147
|
+
if (command === "doctor") {
|
|
148
|
+
const report = await doctor({ ...inputs, reachable });
|
|
149
|
+
for (const line of formatOutcomes(report.outcomes)) io.out(line);
|
|
150
|
+
return report.ok ? 0 : 1;
|
|
151
|
+
}
|
|
152
|
+
const report = await start({ ...inputs, launch: io.launch });
|
|
153
|
+
if (!report.started) {
|
|
154
|
+
for (const line of formatOutcomes(report.outcomes)) io.err(line);
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
return usage(io, `unknown command ${JSON.stringify(command)}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function usage(io: CliEnvironment, message: string): number {
|
|
163
|
+
io.err(message);
|
|
164
|
+
io.err(USAGE);
|
|
165
|
+
return 1;
|
|
166
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { addPluginToConfig, ConfigEditError } from "./config-edit.ts";
|
|
3
|
+
|
|
4
|
+
const notes = { name: "my-notes", ident: "myNotes" };
|
|
5
|
+
const edit = (source: string) => addPluginToConfig(source, notes);
|
|
6
|
+
const refusal = (source: string): string => {
|
|
7
|
+
try {
|
|
8
|
+
edit(source);
|
|
9
|
+
} catch (error) {
|
|
10
|
+
if (error instanceof ConfigEditError) return error.message;
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
13
|
+
return "";
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe("addPluginToConfig", () => {
|
|
17
|
+
test("adds the import after the imports and the entry after the last one", () => {
|
|
18
|
+
const source = `import type { Config } from "node:fs";
|
|
19
|
+
import { hello } from "./plugins/hello.ts";
|
|
20
|
+
|
|
21
|
+
export default {
|
|
22
|
+
name: "Bot",
|
|
23
|
+
plugins: [hello],
|
|
24
|
+
} satisfies Config;
|
|
25
|
+
`;
|
|
26
|
+
expect(edit(source)).toBe(`import type { Config } from "node:fs";
|
|
27
|
+
import { hello } from "./plugins/hello.ts";
|
|
28
|
+
import { myNotes } from "./plugins/my-notes.ts";
|
|
29
|
+
|
|
30
|
+
export default {
|
|
31
|
+
name: "Bot",
|
|
32
|
+
plugins: [hello, myNotes],
|
|
33
|
+
} satisfies Config;
|
|
34
|
+
`);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("puts the import in sorted place among the relative imports", () => {
|
|
38
|
+
const source = `import { agents } from "./agents.ts";
|
|
39
|
+
import { hello } from "./plugins/hello.ts";
|
|
40
|
+
import { zed } from "./plugins/zed.ts";
|
|
41
|
+
|
|
42
|
+
export default { plugins: [hello, zed] };
|
|
43
|
+
`;
|
|
44
|
+
const lines = edit(source).split("\n");
|
|
45
|
+
expect(lines.slice(0, 4)).toEqual([
|
|
46
|
+
'import { agents } from "./agents.ts";',
|
|
47
|
+
'import { hello } from "./plugins/hello.ts";',
|
|
48
|
+
'import { myNotes } from "./plugins/my-notes.ts";',
|
|
49
|
+
'import { zed } from "./plugins/zed.ts";',
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("keeps a multi-line list's layout, with or without a trailing comma", () => {
|
|
54
|
+
const bare = `import { a } from "./plugins/a.ts";
|
|
55
|
+
export default {
|
|
56
|
+
plugins: [
|
|
57
|
+
a,
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
`;
|
|
61
|
+
expect(edit(bare)).toContain("\t\ta,\n\t\tmyNotes,\n\t],");
|
|
62
|
+
const noComma = bare.replace("a,\n\t]", "a\n\t]");
|
|
63
|
+
expect(edit(noComma)).toContain("\t\ta,\n\t\tmyNotes\n\t],");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("fills an empty list and adds the import to a file without imports", () => {
|
|
67
|
+
const edited = edit("export default { plugins: [] };\n");
|
|
68
|
+
expect(edited).toBe(
|
|
69
|
+
'import { myNotes } from "./plugins/my-notes.ts";\n\nexport default { plugins: [myNotes] };\n',
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("finds the list under as, satisfies, parentheses, and a named constant", () => {
|
|
74
|
+
for (const source of [
|
|
75
|
+
"export default { plugins: [a] } as const;",
|
|
76
|
+
"export default ({ plugins: [a] });",
|
|
77
|
+
"export default { plugins: [a] as Plugin[] };",
|
|
78
|
+
"const config = { plugins: [a] } satisfies X;\nexport default config;",
|
|
79
|
+
'export default { "plugins": [a] };',
|
|
80
|
+
])
|
|
81
|
+
expect(edit(source)).toContain("a, myNotes]");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("leaves comments and every other byte as they were", () => {
|
|
85
|
+
const source = `// the bot
|
|
86
|
+
import { a } from "./plugins/a.ts";
|
|
87
|
+
|
|
88
|
+
export default {
|
|
89
|
+
// who
|
|
90
|
+
owner: { id: "1" },
|
|
91
|
+
plugins: [a /* first */], // done
|
|
92
|
+
};
|
|
93
|
+
`;
|
|
94
|
+
const edited = edit(source);
|
|
95
|
+
expect(edited).toContain("// the bot");
|
|
96
|
+
expect(edited).toContain("// who");
|
|
97
|
+
// The entry goes right after the last element, before anything that follows it on the line.
|
|
98
|
+
expect(edited).toContain("plugins: [a, myNotes /* first */], // done");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("refuses a default export with no plugin list, naming the file and what to add", () => {
|
|
102
|
+
for (const source of [
|
|
103
|
+
"export default { name: 'x' };",
|
|
104
|
+
"export default { plugins: somePlugins };",
|
|
105
|
+
"export default makeConfig();",
|
|
106
|
+
"export const config = { plugins: [] };",
|
|
107
|
+
"export default { other: { plugins: [] } };",
|
|
108
|
+
]) {
|
|
109
|
+
const text = refusal(source);
|
|
110
|
+
expect(text).toContain("roundtable.config.ts");
|
|
111
|
+
expect(text).toContain("cannot find the plugin list");
|
|
112
|
+
expect(text).toContain('import { myNotes } from "./plugins/my-notes.ts"');
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("refuses a name the file already uses, and a plugin already listed", () => {
|
|
117
|
+
expect(
|
|
118
|
+
refusal(
|
|
119
|
+
'import { myNotes } from "./plugins/my-notes.ts";\nexport default { plugins: [myNotes] };',
|
|
120
|
+
),
|
|
121
|
+
).toContain("myNotes is already used");
|
|
122
|
+
expect(
|
|
123
|
+
refusal("const myNotes = 1;\nexport default { plugins: [] };"),
|
|
124
|
+
).toContain("already used");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("refuses a file that does not parse and says so", () => {
|
|
128
|
+
expect(refusal("export default { plugins: [ ")).toContain("does not parse");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { parse } from "@babel/parser";
|
|
2
|
+
|
|
3
|
+
/** The parts of a syntax node this edit reads; the parser's own types are not needed beyond these. */
|
|
4
|
+
interface Node {
|
|
5
|
+
type: string;
|
|
6
|
+
start: number;
|
|
7
|
+
end: number;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A configuration this edit cannot change, and why; the message says what to do by hand. */
|
|
12
|
+
export class ConfigEditError extends Error {
|
|
13
|
+
override name = "ConfigEditError";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const WRAPPERS = new Set([
|
|
17
|
+
"TSSatisfiesExpression",
|
|
18
|
+
"TSAsExpression",
|
|
19
|
+
"TSNonNullExpression",
|
|
20
|
+
"ParenthesizedExpression",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const child = (node: Node, key: string): Node | undefined => {
|
|
24
|
+
const value = node[key];
|
|
25
|
+
return typeof value === "object" && value !== null
|
|
26
|
+
? (value as Node)
|
|
27
|
+
: undefined;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** The expression under `satisfies`, `as`, `!`, and parentheses. */
|
|
31
|
+
function unwrap(node: Node | undefined): Node | undefined {
|
|
32
|
+
let current = node;
|
|
33
|
+
while (current && WRAPPERS.has(current.type))
|
|
34
|
+
current = child(current, "expression");
|
|
35
|
+
return current;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseModule(source: string, file: string): Node[] {
|
|
39
|
+
try {
|
|
40
|
+
// SAFETY: every node the parser builds has a type, start, and end, which is all Node states.
|
|
41
|
+
return parse(source, {
|
|
42
|
+
sourceType: "module",
|
|
43
|
+
plugins: ["typescript"],
|
|
44
|
+
}).program.body as unknown as Node[];
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw new ConfigEditError(
|
|
47
|
+
`${file} does not parse (${error instanceof Error ? error.message : String(error)}). Fix it, or add the plugin to its list by hand.`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const nameOf = (node: Node | undefined): string | undefined => {
|
|
53
|
+
if (node?.type === "Identifier") return node.name as string;
|
|
54
|
+
if (node?.type === "StringLiteral") return node.value as string;
|
|
55
|
+
return undefined;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Every name the module already binds at its top level, so an import cannot shadow one. */
|
|
59
|
+
function boundNames(body: readonly Node[]): Set<string> {
|
|
60
|
+
const names = new Set<string>();
|
|
61
|
+
for (const statement of body) {
|
|
62
|
+
const declaration =
|
|
63
|
+
statement.type === "ExportNamedDeclaration"
|
|
64
|
+
? child(statement, "declaration")
|
|
65
|
+
: statement;
|
|
66
|
+
if (statement.type === "ImportDeclaration")
|
|
67
|
+
for (const specifier of statement.specifiers as Node[]) {
|
|
68
|
+
const local = nameOf(child(specifier, "local"));
|
|
69
|
+
if (local) names.add(local);
|
|
70
|
+
}
|
|
71
|
+
if (declaration?.type === "VariableDeclaration")
|
|
72
|
+
for (const declarator of declaration.declarations as Node[]) {
|
|
73
|
+
const id = nameOf(child(declarator, "id"));
|
|
74
|
+
if (id) names.add(id);
|
|
75
|
+
}
|
|
76
|
+
const id = nameOf(declaration ? child(declaration, "id") : undefined);
|
|
77
|
+
if (id) names.add(id);
|
|
78
|
+
}
|
|
79
|
+
return names;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The object the config exports: the default export itself, or the top-level constant it names. */
|
|
83
|
+
function exportedObject(body: readonly Node[]): Node | undefined {
|
|
84
|
+
const exported = body.find(
|
|
85
|
+
(node) => node.type === "ExportDefaultDeclaration",
|
|
86
|
+
);
|
|
87
|
+
const value = unwrap(exported ? child(exported, "declaration") : undefined);
|
|
88
|
+
if (value?.type === "ObjectExpression") return value;
|
|
89
|
+
if (value?.type !== "Identifier") return undefined;
|
|
90
|
+
for (const statement of body) {
|
|
91
|
+
const declaration =
|
|
92
|
+
statement.type === "ExportNamedDeclaration"
|
|
93
|
+
? child(statement, "declaration")
|
|
94
|
+
: statement;
|
|
95
|
+
if (declaration?.type !== "VariableDeclaration") continue;
|
|
96
|
+
for (const declarator of declaration.declarations as Node[]) {
|
|
97
|
+
if (nameOf(child(declarator, "id")) !== value.name) continue;
|
|
98
|
+
const init = unwrap(child(declarator, "init"));
|
|
99
|
+
if (init?.type === "ObjectExpression") return init;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function pluginList(object: Node): Node | undefined {
|
|
106
|
+
for (const property of object.properties as Node[]) {
|
|
107
|
+
if (property.type !== "ObjectProperty") continue;
|
|
108
|
+
if (nameOf(child(property, "key")) !== "plugins") continue;
|
|
109
|
+
const list = unwrap(child(property, "value"));
|
|
110
|
+
if (list?.type === "ArrayExpression") return list;
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface Insertion {
|
|
116
|
+
at: number;
|
|
117
|
+
text: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The text to add so `ident` joins the list, in the list's own layout. */
|
|
121
|
+
function listInsertion(source: string, list: Node, ident: string): Insertion {
|
|
122
|
+
const elements = (list.elements as (Node | null)[]).filter(
|
|
123
|
+
(element): element is Node => element !== null,
|
|
124
|
+
);
|
|
125
|
+
const last = elements.at(-1);
|
|
126
|
+
if (!last) return { at: list.start + 1, text: ident };
|
|
127
|
+
const between = source.slice(last.end, list.end - 1);
|
|
128
|
+
const comma = between.search(/[^\s]/);
|
|
129
|
+
const trailing = between[comma] === "," ? last.end + comma + 1 : undefined;
|
|
130
|
+
const multiline = source.slice(list.start, last.start).includes("\n");
|
|
131
|
+
const lineStart = source.lastIndexOf("\n", last.start) + 1;
|
|
132
|
+
const indent = /^[ \t]*/.exec(source.slice(lineStart, last.start))?.[0] ?? "";
|
|
133
|
+
if (trailing !== undefined)
|
|
134
|
+
return {
|
|
135
|
+
at: trailing,
|
|
136
|
+
text: multiline ? `\n${indent}${ident},` : ` ${ident},`,
|
|
137
|
+
};
|
|
138
|
+
return {
|
|
139
|
+
at: last.end,
|
|
140
|
+
text: multiline ? `,\n${indent}${ident}` : `, ${ident}`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Where the new import goes: before the first later-sorting relative import, else after the last import. */
|
|
145
|
+
function importInsertion(
|
|
146
|
+
body: readonly Node[],
|
|
147
|
+
specifier: string,
|
|
148
|
+
line: string,
|
|
149
|
+
): Insertion {
|
|
150
|
+
const imports = body.filter((node) => node.type === "ImportDeclaration");
|
|
151
|
+
const later = imports.find((node) => {
|
|
152
|
+
const from = (child(node, "source")?.value ?? "") as string;
|
|
153
|
+
return from.startsWith(".") && from > specifier;
|
|
154
|
+
});
|
|
155
|
+
if (later) return { at: later.start, text: `${line}\n` };
|
|
156
|
+
const last = imports.at(-1);
|
|
157
|
+
return last
|
|
158
|
+
? { at: last.end, text: `\n${line}` }
|
|
159
|
+
: { at: 0, text: `${line}\n\n` };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Adds the plugin's import line and lists it in the `plugins` array of the default export, in the
|
|
164
|
+
* file's own layout. It refuses, changing nothing, a file that does not parse, a default export
|
|
165
|
+
* with no `plugins` list it can find, and a name already bound.
|
|
166
|
+
*/
|
|
167
|
+
export function addPluginToConfig(
|
|
168
|
+
source: string,
|
|
169
|
+
plugin: { name: string; ident: string },
|
|
170
|
+
file = "roundtable.config.ts",
|
|
171
|
+
): string {
|
|
172
|
+
const body = parseModule(source, file);
|
|
173
|
+
const object = exportedObject(body);
|
|
174
|
+
const list = object ? pluginList(object) : undefined;
|
|
175
|
+
if (!list)
|
|
176
|
+
throw new ConfigEditError(
|
|
177
|
+
`${file}: cannot find the plugin list. Expected \`export default { ..., plugins: [...] }\`; add \`import { ${plugin.ident} } from "./plugins/${plugin.name}.ts"\` and list ${plugin.ident} in plugins by hand.`,
|
|
178
|
+
);
|
|
179
|
+
const specifier = `./plugins/${plugin.name}.ts`;
|
|
180
|
+
if (boundNames(body).has(plugin.ident))
|
|
181
|
+
throw new ConfigEditError(
|
|
182
|
+
`${file}: the name ${plugin.ident} is already used. Import the plugin by hand under another name.`,
|
|
183
|
+
);
|
|
184
|
+
const edits = [
|
|
185
|
+
listInsertion(source, list, plugin.ident),
|
|
186
|
+
importInsertion(
|
|
187
|
+
body,
|
|
188
|
+
specifier,
|
|
189
|
+
// JSON.stringify quotes the specifier; the export script reads import lines in source text, so none may hold a placeholder.
|
|
190
|
+
`import { ${plugin.ident} } from ${JSON.stringify(specifier)};`,
|
|
191
|
+
),
|
|
192
|
+
].sort((a, b) => b.at - a.at);
|
|
193
|
+
let edited = source;
|
|
194
|
+
for (const edit of edits)
|
|
195
|
+
edited = edited.slice(0, edit.at) + edit.text + edited.slice(edit.at);
|
|
196
|
+
// A result that does not parse is a bug here; never write it.
|
|
197
|
+
parseModule(edited, file);
|
|
198
|
+
return edited;
|
|
199
|
+
}
|