Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 59 additions & 2 deletions packages/meetup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,68 @@ Drop a `coopkit.config.json` at the repo root:
{
"meetup": {
"groupUrlname": "your-group-slug",
"timezone": "America/Chicago",
"venues": {
"Venue Name, City, cc": 12345678,
"online": 23456789
}
"online": "online"
},
"hosts": {
"Rob Douglas": 13296813
},
"defaultHosts": ["Rob Douglas"]
}
}
```

| Key | Required | Purpose |
| --- | --- | --- |
| `groupUrlname` | yes | Meetup group slug the drafts are created in. |
| `venues` | yes | Maps frontmatter venue keys to Meetup venue IDs. |
| `timezone` | no | IANA zone of the group. **Set this if your event dates are true UTC** — see below. |
| `hosts` | no | Named Meetup member IDs, so you can refer to hosts by name. |
| `defaultHosts` | no | Names from `hosts` used when an event names none. |

### Online events

Meetup has no venue record for an online event, so `list-venues` will never
return one. Use the literal string `"online"` as the venue ID:

```json
"venues": { "online": "online" }
```

### Timezones — read this if your dates are UTC

Meetup interprets `startDateTime` as **wall time in the group's own timezone**;
the API accepts no offset. So `event.date` has two possible readings, and the
`timezone` key picks which one you mean:

- **`timezone` set** — `event.date` is a true instant and gets converted to wall
time in that zone. `date: 2026-08-22T16:00:00Z` with
`"timezone": "America/Chicago"` creates an **11:00** event. DST is handled per
date.
- **`timezone` omitted** — `event.date`'s UTC clock reading is used verbatim, so
`2026-08-14T18:00:00Z` creates an **18:00** local event. This is the original
behaviour and stays the default, because some adopters store local wall time
with a nominal `Z` suffix.

Omitting `timezone` when your dates really are UTC silently shifts every event
by the zone's offset, so set it whenever your source stores real instants.

### Hosts

`eventHosts` takes Meetup **member IDs**, and the account must be a member of
the group. Name them in `hosts` and select them per run with `--host`, or set
`defaultHosts` to apply the same host every time. When no host is resolved the
key is omitted from the payload entirely and Meetup falls back to the creating
organizer.

To find a member ID, query the group's organizer:

```graphql
query { groupByUrlname(urlname: "your-group-slug") { organizer { id name } } }
```

Discover venue IDs:

```bash
Expand All @@ -67,6 +121,9 @@ bunx coopkit-meetup list-venues --group your-group-slug
```bash
bunx coopkit-meetup create --dry-run events/2026-04-29-My-Event.md
bunx coopkit-meetup create events/2026-04-29-My-Event.md

# override the configured defaultHosts for one run
bunx coopkit-meetup create --host "Rob Douglas" events/2026-04-29-My-Event.md
```

Idempotent. Writes `event_url` + `event_id` back into the file's frontmatter on success.
Expand Down
27 changes: 26 additions & 1 deletion packages/meetup/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import fs from "node:fs";
import type { NormalizedEvent } from "@coopkit/core";
import { defineCommand, runMain } from "citty";
import { loadMeetupConfig } from "./config.js";
import { loadMeetupConfig, resolveHostIds } from "./config.js";
import { createMeetupDraft, createMeetupDraftFromFile } from "./create-event.js";
import { formatVenueKey, listVenues } from "./list-venues.js";
import { loadEnvFile } from "./load-env.js";
Expand All @@ -29,6 +29,15 @@ function writeResultFile(
fs.writeFileSync(outputPath, `${JSON.stringify(payload)}\n`);
}

/** Split a `--host "A,B"` value into trimmed names. */
function parseHostArg(value: unknown): string[] | undefined {
if (typeof value !== "string" || value.trim() === "") return undefined;
return value
.split(",")
.map((s) => s.trim())
.filter((s) => s !== "");
}

const createCmd = defineCommand({
meta: {
name: "create",
Expand All @@ -50,6 +59,11 @@ const createCmd = defineCommand({
type: "string",
description: "Path to coopkit.config.json (default: ./coopkit.config.json).",
},
host: {
type: "string",
description:
"Host name(s) from meetup.hosts, comma-separated. Defaults to meetup.defaultHosts.",
},
output: {
type: "string",
description:
Expand All @@ -59,10 +73,13 @@ const createCmd = defineCommand({
async run({ args }) {
loadEnvFile();
const config = loadMeetupConfig(args.config);
const hosts = resolveHostIds(config, parseHostArg(args.host));
const result = await createMeetupDraftFromFile({
eventFile: args.eventFile,
groupUrlname: config.groupUrlname,
venues: config.venues,
...(config.timezone !== undefined ? { timezone: config.timezone } : {}),
...(hosts.length > 0 ? { hosts } : {}),
dryRun: Boolean(args["dry-run"]),
});
writeResultFile(args.output, result);
Expand Down Expand Up @@ -115,6 +132,11 @@ const createFromJsonCmd = defineCommand({
type: "string",
description: "Path to coopkit.config.json (default: ./coopkit.config.json).",
},
host: {
type: "string",
description:
"Host name(s) from meetup.hosts, comma-separated. Defaults to meetup.defaultHosts.",
},
output: {
type: "string",
description:
Expand All @@ -126,10 +148,13 @@ const createFromJsonCmd = defineCommand({
const config = loadMeetupConfig(args.config);
const raw = args.file ? fs.readFileSync(args.file, "utf8") : readStdin();
const event = parseNormalizedEvent(raw);
const hosts = resolveHostIds(config, parseHostArg(args.host));
const result = await createMeetupDraft({
event,
groupUrlname: config.groupUrlname,
venues: config.venues,
...(config.timezone !== undefined ? { timezone: config.timezone } : {}),
...(hosts.length > 0 ? { hosts } : {}),
dryRun: Boolean(args["dry-run"]),
});
writeResultFile(args.output, result);
Expand Down
91 changes: 91 additions & 0 deletions packages/meetup/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, it } from "bun:test";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { type MeetupConfig, loadMeetupConfig, resolveHostIds } from "./config.js";

function writeConfig(contents: unknown): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "coopkit-config-"));
const file = path.join(dir, "coopkit.config.json");
fs.writeFileSync(file, typeof contents === "string" ? contents : JSON.stringify(contents));
return file;
}

const BASE = {
groupUrlname: "chicago-c-cpp-users-group",
venues: { online: "online" },
};

describe("loadMeetupConfig hosts/timezone", () => {
it("accepts a config with neither hosts nor timezone", () => {
const cfg = loadMeetupConfig(writeConfig({ meetup: BASE }));
expect(cfg.timezone).toBeUndefined();
expect(cfg.hosts).toBeUndefined();
});

it("loads timezone and hosts", () => {
const cfg = loadMeetupConfig(
writeConfig({
meetup: { ...BASE, timezone: "America/Chicago", hosts: { "Rob Douglas": 13296813 } },
})
);
expect(cfg.timezone).toBe("America/Chicago");
expect(cfg.hosts).toEqual({ "Rob Douglas": 13296813 });
});

it("rejects a non-string timezone", () => {
expect(() => loadMeetupConfig(writeConfig({ meetup: { ...BASE, timezone: 5 } }))).toThrow(
/meetup.timezone must be an IANA timezone string/
);
});

it("rejects a non-integer member ID", () => {
expect(() =>
loadMeetupConfig(writeConfig({ meetup: { ...BASE, hosts: { Rob: "13296813" } } }))
).toThrow(/positive integer Meetup member ID/);
});

it("rejects a defaultHosts name that is not in hosts", () => {
expect(() =>
loadMeetupConfig(
writeConfig({ meetup: { ...BASE, hosts: { Rob: 13296813 }, defaultHosts: ["Bob"] } })
)
).toThrow(/defaultHosts entry "Bob" is not a key of meetup.hosts/);
});

it("rejects defaultHosts when hosts is absent entirely", () => {
expect(() =>
loadMeetupConfig(writeConfig({ meetup: { ...BASE, defaultHosts: ["Rob"] } }))
).toThrow(/is not a key of meetup.hosts/);
});
});

describe("resolveHostIds", () => {
const cfg: MeetupConfig = {
...BASE,
venues: { online: "online" },
hosts: { "Rob Douglas": 13296813, "Alex Smith": 256192100 },
defaultHosts: ["Rob Douglas"],
};

it("falls back to defaultHosts when no names are given", () => {
expect(resolveHostIds(cfg)).toEqual([13296813]);
});

it("falls back to defaultHosts for an empty name list", () => {
expect(resolveHostIds(cfg, [])).toEqual([13296813]);
});

it("resolves explicit names in order, overriding the default", () => {
expect(resolveHostIds(cfg, ["Alex Smith", "Rob Douglas"])).toEqual([256192100, 13296813]);
});

it("returns [] when neither names nor defaultHosts exist", () => {
expect(resolveHostIds({ ...BASE, venues: { online: "online" } })).toEqual([]);
});

it("throws on an unknown name and lists the known ones", () => {
expect(() => resolveHostIds(cfg, ["Nobody"])).toThrow(/Unknown host "Nobody"/);
expect(() => resolveHostIds(cfg, ["Nobody"])).toThrow(/Known hosts: "Rob Douglas"/);
});
});
60 changes: 60 additions & 0 deletions packages/meetup/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ import type { VenueMap } from "./venues.js";
export interface MeetupConfig {
groupUrlname: string;
venues: VenueMap;
/**
* IANA timezone of the group (e.g. "America/Chicago"). Set this when your
* event dates are true UTC instants; see `BuildPayloadInput.timezone` for
* why omitting it means "the date is already local wall time".
*/
timezone?: string;
/** Named Meetup member IDs, so config and CLI can refer to people by name. */
hosts?: Record<string, number>;
/** Names from `hosts` to list as event hosts when an event names none itself. */
defaultHosts?: string[];
}

export interface CoopkitConfig {
Expand Down Expand Up @@ -45,5 +55,55 @@ export function loadMeetupConfig(configPath?: string): MeetupConfig {
if (!meetup.venues || typeof meetup.venues !== "object") {
throw new Error(`${resolved}: meetup.venues must be an object mapping venue names to IDs.`);
}
if (meetup.timezone !== undefined && typeof meetup.timezone !== "string") {
throw new Error(`${resolved}: meetup.timezone must be an IANA timezone string.`);
}
if (meetup.hosts !== undefined) {
if (typeof meetup.hosts !== "object" || meetup.hosts === null) {
throw new Error(`${resolved}: meetup.hosts must be an object mapping names to member IDs.`);
}
for (const [name, id] of Object.entries(meetup.hosts)) {
if (typeof id !== "number" || !Number.isInteger(id) || id <= 0) {
throw new Error(
`${resolved}: meetup.hosts[${JSON.stringify(name)}] must be a positive integer Meetup member ID (got ${JSON.stringify(id)}).`
);
}
}
}
if (meetup.defaultHosts !== undefined) {
if (!Array.isArray(meetup.defaultHosts)) {
throw new Error(
`${resolved}: meetup.defaultHosts must be an array of names from meetup.hosts.`
);
}
// Fail here rather than at call time: a typo'd name would otherwise
// silently create the event with no host at all.
for (const name of meetup.defaultHosts) {
if (typeof name !== "string" || !meetup.hosts || !(name in meetup.hosts)) {
throw new Error(
`${resolved}: meetup.defaultHosts entry ${JSON.stringify(name)} is not a key of meetup.hosts.`
);
}
}
}
return meetup;
}

/**
* Resolve host names to Meetup member IDs, falling back to `defaultHosts`.
* Returns [] when neither is configured, which leaves `eventHosts` off the
* payload entirely (Meetup then defaults to the creating organizer).
*/
export function resolveHostIds(config: MeetupConfig, names?: string[]): number[] {
const wanted = names && names.length > 0 ? names : (config.defaultHosts ?? []);
return wanted.map((name) => {
const id = config.hosts?.[name];
if (id === undefined) {
const known = Object.keys(config.hosts ?? {});
const suffix =
known.length > 0 ? ` Known hosts: ${known.map((k) => JSON.stringify(k)).join(", ")}.` : "";
throw new Error(`Unknown host ${JSON.stringify(name)}. Add it to meetup.hosts.${suffix}`);
}
return id;
});
}
18 changes: 15 additions & 3 deletions packages/meetup/src/create-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,19 @@ import type { EventFrontmatter, NormalizedEvent } from "@coopkit/core";
import { frontmatterToNormalizedEvent } from "@coopkit/core";
import matter from "gray-matter";
import {
createMeetupClient,
MeetupApiError,
type MeetupClient,
type MeetupCredentials,
createMeetupClient,
} from "./client.js";
import {
buildCreateEventPayload,
type CreateEventPayload,
buildCreateEventPayload,
detectContentType,
isEventAlreadyCreated,
stripLeadingHeading,
} from "./payload.js";
import { resolveVenueId, type VenueMap } from "./venues.js";
import { type VenueMap, resolveVenueId } from "./venues.js";

const CREATE_EVENT_MUTATION = `
mutation CreateDraftEvent($input: CreateEventInput!) {
Expand Down Expand Up @@ -145,6 +145,10 @@ export interface CreateMeetupDraftOptions {
event: NormalizedEvent;
groupUrlname: string;
venues: VenueMap;
/** IANA timezone of the group. See `BuildPayloadInput.timezone`. */
timezone?: string;
/** Meetup member IDs to list as event hosts. */
hosts?: number[];
dryRun?: boolean;
credentials?: MeetupCredentials;
log?: (message: string) => void;
Expand Down Expand Up @@ -178,6 +182,8 @@ export async function createMeetupDraft(
event: options.event,
groupUrlname: options.groupUrlname,
resolveVenue: (name) => resolveVenueId(name, options.venues),
...(options.timezone !== undefined ? { timezone: options.timezone } : {}),
...(options.hosts !== undefined ? { hosts: options.hosts } : {}),
});

if (options.dryRun) {
Expand Down Expand Up @@ -217,6 +223,10 @@ export interface CreateMeetupDraftFromFileOptions {
eventFile: string;
groupUrlname: string;
venues: VenueMap;
/** IANA timezone of the group. See `BuildPayloadInput.timezone`. */
timezone?: string;
/** Meetup member IDs to list as event hosts. */
hosts?: number[];
dryRun?: boolean;
credentials?: MeetupCredentials;
log?: (message: string) => void;
Expand Down Expand Up @@ -260,6 +270,8 @@ export async function createMeetupDraftFromFile(
event,
groupUrlname: options.groupUrlname,
venues: options.venues,
...(options.timezone !== undefined ? { timezone: options.timezone } : {}),
...(options.hosts !== undefined ? { hosts: options.hosts } : {}),
dryRun: options.dryRun,
credentials: options.credentials,
log,
Expand Down
Loading
Loading