From cda64645c0b57a67484a77418062594820326ba7 Mon Sep 17 00:00:00 2001 From: Kaleb Cole Date: Fri, 24 Jul 2026 14:48:57 -0700 Subject: [PATCH 01/14] T1: TS toolchain (strict tsconfig, tsx loader, zod) - typescript@7, tsx@4, @types/node devDeps; zod runtime dep - tsconfig: strict + NodeNext ESM + allowJs (JS/TS coexist), noEmit - bin/partiful registers tsx/esm loader, no dist build - scripts: typecheck (tsc --noEmit), start - gate: typecheck clean + 195/195 tests green on still-JS tree --- .wayfinder/ts-port/BUILD-PROMPT.md | 38 ++ .wayfinder/ts-port/map.md | 64 +++ .../tickets/T0-rsvp-merged-branch-cut.md | 22 + .../ts-port/tickets/T1-toolchain-setup.md | 38 ++ .../tickets/T2-porting-convention-doc.md | 33 ++ .../ts-port/tickets/T3-port-lib-layer-spec.md | 43 ++ .../tickets/T4-port-commands-helpers.md | 31 ++ .../tickets/T5-rewire-schema-command.md | 26 ++ .../tickets/T6-drift-detection-smoke-tests.md | 27 ++ bin/partiful | 3 + package-lock.json | 426 +++++++++++++++++- package.json | 21 +- tsconfig.json | 25 + 13 files changed, 793 insertions(+), 4 deletions(-) create mode 100644 .wayfinder/ts-port/BUILD-PROMPT.md create mode 100644 .wayfinder/ts-port/map.md create mode 100644 .wayfinder/ts-port/tickets/T0-rsvp-merged-branch-cut.md create mode 100644 .wayfinder/ts-port/tickets/T1-toolchain-setup.md create mode 100644 .wayfinder/ts-port/tickets/T2-porting-convention-doc.md create mode 100644 .wayfinder/ts-port/tickets/T3-port-lib-layer-spec.md create mode 100644 .wayfinder/ts-port/tickets/T4-port-commands-helpers.md create mode 100644 .wayfinder/ts-port/tickets/T5-rewire-schema-command.md create mode 100644 .wayfinder/ts-port/tickets/T6-drift-detection-smoke-tests.md create mode 100644 tsconfig.json diff --git a/.wayfinder/ts-port/BUILD-PROMPT.md b/.wayfinder/ts-port/BUILD-PROMPT.md new file mode 100644 index 0000000..5452272 --- /dev/null +++ b/.wayfinder/ts-port/BUILD-PROMPT.md @@ -0,0 +1,38 @@ +# BUILD-PROMPT: partiful-cli JS → TypeScript Port + +Port partiful-cli to TypeScript so the `src/lib/` API layer's endpoint interfaces + Zod response +schemas ARE the living API spec (a byproduct of typing the code, never a separate file that drifts). + +## Drive off the map + +`.wayfinder/ts-port/map.md` + `.wayfinder/ts-port/tickets/T*.md` are the source of truth for scope, +sequencing, conventions, and per-ticket detail. Work tickets T1→T6 in order (do not reorder), mark +each closed in the map's "Decisions so far" as you finish, and don't invent scope outside the map. +Complete every ticket — a partial port is not done. + +## Start + +Cut `feat/typescript-port` from `main` at commit `ad7be30` (baseline: `npm test` = 195 green). +Each ticket's gate is `tsc --noEmit` clean AND `npm test` green; keep it green file-by-file. + +## Before opening the PR + +Run an adversarial review pass (`adversarial-code-review` skill), fix everything blocking, re-review +until the verdict is clean. + +## After opening the PR — bot-poll loop + +External review bots (CodeRabbit, etc.) run async (`pr-review-bots` skill): +1. Wait, re-check the PR for new bot reviews (`gh pr view`, `gh pr checks`). +2. For each actionable comment: verify against current code, fix valid ones (test first), reply-skip + invalid ones with a one-line rationale. +3. Push fixes (re-triggers bots), repeat. + +**Stop when ANY holds:** (a) latest bot review has zero actionable comments AND checks pass; (b) two +consecutive cycles yield only reasoned-declined comments; (c) 6 cycles elapsed — then summarize the +remaining open threads. Never loop forever. + +## Done + +Port complete, all gates green, adversarial review clean, bot-poll loop terminated, PR green and +mergeable. Write the final report (before/after test counts, convention deviations with reasons), stop. diff --git a/.wayfinder/ts-port/map.md b/.wayfinder/ts-port/map.md new file mode 100644 index 0000000..57b7bf1 --- /dev/null +++ b/.wayfinder/ts-port/map.md @@ -0,0 +1,64 @@ +# Wayfinder Map: TypeScript Port + API Spec-as-Types + +`wayfinder:map` · tracker: local-markdown · created 2026-07-24 + +## Destination + +partiful-cli ported from plain JS to TypeScript, with the `src/lib/` API layer typed such that +**endpoint interfaces + Zod response schemas ARE the living API spec** (no separate hand-maintained +spec file). `strict: true` compiles clean via `tsc --noEmit`, all existing tests green, and the +`schema` command exposes API endpoint types under a new `schema api.` namespace. + +Success = the API spec is a *byproduct* of typing the code, not a separate artifact that can drift. + +## Notes + +- **Domain:** github.com/KalebCole/partiful-cli. Plain JS today (29 files, ~4,373 LOC, ESM, + Commander 13 + Vitest). Deps: commander, dotenv (both ship first-class TS types). +- **This is fully AFK.** All HITL gates collapsed — Kaleb front-loaded every decision (strict from + day one; Zod `.passthrough()` for API responses, plain interfaces for internal shapes). The + first-slice "approval" is a machine oracle (compiles + tests green + matches written criteria), + not a human glance. Normal PR review before merge to main is the only human touch, and that's + not a ticket. +- **The port IS the spec.** The ~23 untyped `result?.data...` API-response spreads are where + endpoint interfaces + Zod schemas get authored. Type the lib layer = write the spec. +- **Sequencing is the whole strategy:** lib/ (API layer, spec born here) → commands/ + helpers/ + (consume the types) → schema.js (surface them). Do NOT reorder. +- **Loop oracle:** `tsc --noEmit` clean AND `npm test` green. Machine-checkable done, ideal for + an autonomous agent grinding file-by-file. +- Skills to consult: prior art = YouTube.js (the gold standard: TS types + Zod + real-API smoke + tests, no OpenAPI). AGENTS.md in repo root for conventions/boundaries. +- Separate effort: the explore-command map lives in `.wayfinder/` (parent dir). Don't touch it. + +## Decisions so far + +- T0 CLOSED (2026-07-24): RSVP work merged to main via PR #65 (squash, commit ad7be30); tree clean; main green at 195/195 tests. Port branch to be cut from ad7be30. +- T1 CLOSED (2026-07-24): TS toolchain up. tsx-loader run path (no dist build), tsconfig strict+NodeNext+allowJs, zod added. `npm run typecheck` clean + 195/195 green on still-JS tree. + +## Not yet specified + +- Exact tsconfig strictness knobs beyond `strict: true` (noUncheckedIndexedAccess?, exactOptionalPropertyTypes?) — resolve inside T2. +- Whether `schema api.*` output format should mirror the existing `schema ` CLI-flag shape or diverge — resolve when T5 is reached. +- Drift-detection ergonomics: where unknown-field logs go, whether smoke tests run in CI or manual — resolve in T6. +- CI: add a `tsc --noEmit` + test gate to the repo's CI once ported — graduates after T1. + +## Out of scope + +- OpenAPI 3.1 / TypeSpec / standalone spec file. Ruled out: repo is 7-host, 3-method, 2-transport + (Firebase-callable RPC + Firestore document format); types-as-spec fits the actual code, a + standalone spec would duplicate and drift. See prior-art + repo-grounded research (2026-07-24). +- Rewriting/refactoring API logic during the port. Port is a faithful JS→TS translation; behavior + changes are a separate effort. +- The explore/discovery command build (its own map). + +## Tickets + +| # | Title | Type | Blocks on | Status | +|---|---|---|---|---| +| T0 | RSVP work merged to main, tree clean, port branch cut | task (AFK) | — | ✅ CLOSED (PR #65 merged, ad7be30) | +| T1 | TS toolchain setup (tsconfig strict, tsx run, bin, build) | task (AFK) | T0 | ✅ CLOSED (tsx loader, no dist) | +| T2 | Write porting convention doc (strict + Zod pattern) | task (AFK) | T1 | OPEN | +| T3 | Port src/lib/ API layer + author endpoint types/Zod (THE SPEC) | task (AFK) | T2 | OPEN | +| T4 | Port src/commands/ + src/helpers/ | task (AFK) | T3 | OPEN | +| T5 | Rewire schema command → schema api. | task (AFK) | T3 | OPEN | +| T6 | Wire drift-detection + real-API smoke tests | task (AFK) | T3 | OPEN | diff --git a/.wayfinder/ts-port/tickets/T0-rsvp-merged-branch-cut.md b/.wayfinder/ts-port/tickets/T0-rsvp-merged-branch-cut.md new file mode 100644 index 0000000..90c742f --- /dev/null +++ b/.wayfinder/ts-port/tickets/T0-rsvp-merged-branch-cut.md @@ -0,0 +1,22 @@ +# T0 — RSVP work merged to main, tree clean, port branch cut + +**Type:** task (AFK) · **Blocks on:** nothing · **Status:** OPEN (external — RSVP agent in-flight) + +## Question + +The whole port frontier is locked behind this. Another agent is actively writing the RSVP +command into `main`'s working tree RIGHT NOW (untracked `src/commands/rsvp.js`, `src/lib/rsvp.js`, +4 test files; modified `src/cli.js`, `src/commands/schema.js`). Two agents cannot edit the same +tree. The port cannot start on a moving target, and the new RSVP API-layer files must themselves +be typed (they call addGuest/getCurrentGuest/markEventInterest — spec endpoints). + +## Done when + +1. RSVP work reaches a natural stopping point and is committed + merged to `main`. +2. `git status --short` on `main` is clean (no untracked/modified port-relevant files). +3. `npm test` green on `main`. +4. `feat/typescript-port` branch cut FROM that clean commit. + +## Answer + + diff --git a/.wayfinder/ts-port/tickets/T1-toolchain-setup.md b/.wayfinder/ts-port/tickets/T1-toolchain-setup.md new file mode 100644 index 0000000..ef2840f --- /dev/null +++ b/.wayfinder/ts-port/tickets/T1-toolchain-setup.md @@ -0,0 +1,38 @@ +# T1 — TS toolchain setup + +**Type:** task (AFK) · **Blocks on:** T0 · **Status:** ✅ CLOSED + +## Question + +Stand up the TypeScript toolchain so the rest of the port has a compile target and run path, +without breaking the working CLI. + +## Scope + +- Add `typescript` + `tsx` (or equivalent) to devDependencies; `@types/node`. +- `tsconfig.json`: `strict: true` (Kaleb's call — full strict from day one, no loose-then-tighten), + `module`/`moduleResolution` for ESM ("NodeNext"), `noEmit` for the check step, `allowJs: true` + DURING migration so JS + TS coexist file-by-file. +- Run path: `tsx src/cli.js` works; `bin` still resolves. No dist build required if running via tsx, + but decide + document build-vs-tsx here. +- Add `npm run typecheck` = `tsc --noEmit` and `npm run build` if a dist is chosen. +- Verify `npm test` still green with the toolchain added (nothing ported yet). + +## Done when + +`tsconfig.json` exists with strict on, `allowJs` true, `npm run typecheck` passes on the still-JS +tree (or reports only expected allowJs-permitted state), CLI still runs, tests green. + +## Answer + +**CLOSED 2026-07-24.** Toolchain: `typescript@7`, `tsx@4`, `@types/node` in devDeps; +`zod` added to runtime deps (needed T3+). `tsconfig.json` — `strict: true` plus +`noUncheckedIndexedAccess`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, +`forceConsistentCasingInFileNames`; `module`/`moduleResolution` = `NodeNext` (ESM); +`allowJs: true`, `checkJs: false` so JS+TS coexist file-by-file during the port; +`noEmit: true`; `verbatimModuleSyntax: true` (enforces `import type` discipline). +**Run path decision: tsx loader, NO dist build.** `bin/partiful` registers `tsx/esm` +then imports `src/cli.js` — runs `.js` today and `.ts` after each file flips, zero build +step, matching the repo's "no build step" ethos. Scripts: `typecheck` = `tsc --noEmit`, +`start` = `node --import tsx bin/partiful`. Gate: `tsc --noEmit` clean + 195/195 tests +green on the still-JS tree; `./bin/partiful --version` works. diff --git a/.wayfinder/ts-port/tickets/T2-porting-convention-doc.md b/.wayfinder/ts-port/tickets/T2-porting-convention-doc.md new file mode 100644 index 0000000..806b8c6 --- /dev/null +++ b/.wayfinder/ts-port/tickets/T2-porting-convention-doc.md @@ -0,0 +1,33 @@ +# T2 — Write porting convention doc + +**Type:** task (AFK) · **Blocks on:** T1 · **Status:** OPEN + +## Question + +Write the single convention doc every subsequent port ticket follows, so an agent porting 29 files +makes consistent choices. This is the "porting guide" — a build-spec, not a tutorial. All decisions +are already made (below); this ticket just writes them down as enforceable rules. + +## Decisions (already settled by Kaleb — do NOT relitigate) + +- **Strictness:** `strict: true` from day one. +- **API responses:** wrap in Zod schemas using `.passthrough()` so unknown vendor fields don't throw + (Partiful is an unofficial API we don't own; responses are known-fields-non-exhaustive). Types are + inferred from the Zod schema via `z.infer<>`. +- **Internal shapes** (config, CLI options, helpers): plain TS `interface`/`type`, no Zod. +- **Requests:** fully typed interfaces (we control what we send — specify completely). +- **Envelope:** the shared `{data:{params:{...}, amplitudeDeviceId}}` RPC envelope is ONE reusable + generic type, referenced per-endpoint — never re-specified. +- **Transports:** three tagged groups — firebase-callable (POST api.partiful.com), firestore + (GET/PATCH firestore.googleapis.com, typed-document format), firebase-auth (Google endpoints). +- **Import extensions:** keep `.js` in import specifiers (NodeNext ESM requirement even for .ts). +- **No behavior changes** — faithful translation only. + +## Done when + +`docs/TYPESCRIPT-PORT-GUIDE.md` exists capturing the above as file-by-file rules + a worked example +of one typed endpoint (envelope + request interface + Zod `.passthrough()` response + z.infer type). + +## Answer + + diff --git a/.wayfinder/ts-port/tickets/T3-port-lib-layer-spec.md b/.wayfinder/ts-port/tickets/T3-port-lib-layer-spec.md new file mode 100644 index 0000000..1e8d75d --- /dev/null +++ b/.wayfinder/ts-port/tickets/T3-port-lib-layer-spec.md @@ -0,0 +1,43 @@ +# T3 — Port src/lib/ API layer + author endpoint types/Zod (THE SPEC) + +**Type:** task (AFK) · **Blocks on:** T2 · **Status:** OPEN + +## Question + +Port the `src/lib/` layer to TS. This is the keystone ticket: typing these files IS authoring the +API spec. The ~23 untyped `result?.data...` response spreads become endpoint interfaces + Zod +schemas. Everything downstream (T4, T5, T6) consumes what this produces. + +## Files (port in this order — API core first) + +http.js, auth.js, events.js, rsvp.js, upload.js, cohosts.js, posters.js, dates.js, errors.js, +output.js, templates.js + +## What "the spec" means here + +For every endpoint the lib layer calls (createEvent, cancelEvent, getEventInfo, getContacts, +createTextBlast, addInvitedGuestsAsHost, getMyUpcomingEventsForHomePage, getMyPastEventsForHomePage, +addGuest, markEventInterest, getCurrentGuest, + Firestore GET/PATCH + auth endpoints): +- typed request interface (complete) +- Zod `.passthrough()` response schema + `z.infer` type (known-fields, non-exhaustive) +- referencing the shared envelope generic +- tagged by transport (firebase-callable / firestore / firebase-auth) + +Collect these into a coherent location (e.g. `src/lib/api/` types + schemas) so T5 can surface them. + +## FIRST-SLICE ORACLE (replaces the old human-approval gate) + +The FIRST file (http.js + one fully-typed endpoint) must satisfy, as written criteria, before the +agent replicates the pattern across the rest: +- `tsc --noEmit` clean under strict +- endpoint has: request interface + Zod `.passthrough()` response + z.infer type + envelope reuse +- existing tests for that path still green +If met, that IS the approved pattern — replicate across all lib files. No human pause. + +## Done when + +All `src/lib/*.js` → `.ts`, strict-clean, all endpoint types+schemas authored, tests green. + +## Answer + + diff --git a/.wayfinder/ts-port/tickets/T4-port-commands-helpers.md b/.wayfinder/ts-port/tickets/T4-port-commands-helpers.md new file mode 100644 index 0000000..2d14b81 --- /dev/null +++ b/.wayfinder/ts-port/tickets/T4-port-commands-helpers.md @@ -0,0 +1,31 @@ +# T4 — Port src/commands/ + src/helpers/ + +**Type:** task (AFK) · **Blocks on:** T3 · **Status:** OPEN + +## Question + +Port the command + helper layer to TS. Mostly mechanical once T3 exists — these files consume the +lib-layer types rather than defining new API shapes. + +## Files + +commands/: auth, blasts, bulk, cohosts, contacts, doctor, events, guests, posters, rsvp, setup, +templates, (schema → handled in T5, but its .js→.ts shell can happen here) +helpers/: clone, export, share, watch +plus src/cli.js + +## Notes + +- Type Commander actions, options objects (plain interfaces), and wire them to lib-layer types. +- No new API types here — if you find yourself authoring an endpoint shape, it belonged in T3; go + back and add it there. +- events.js is the big one (524 LOC) — expect the most work. + +## Done when + +All commands/ + helpers/ + cli.js → `.ts`, strict-clean, all tests green, `allowJs` can be turned +off (no JS left except tests if those stay JS). + +## Answer + + diff --git a/.wayfinder/ts-port/tickets/T5-rewire-schema-command.md b/.wayfinder/ts-port/tickets/T5-rewire-schema-command.md new file mode 100644 index 0000000..3e5a9e9 --- /dev/null +++ b/.wayfinder/ts-port/tickets/T5-rewire-schema-command.md @@ -0,0 +1,26 @@ +# T5 — Rewire schema command → schema api. + +**Type:** task (AFK) · **Blocks on:** T3 · **Status:** OPEN + +## Question + +Today `src/commands/schema.js` is a static hardcoded dict documenting CLI FLAGS. Make the API +endpoint types authored in T3 introspectable via a new `schema api.` namespace, so the +spec-as-types is queryable from the CLI (the "source of truth" payoff). + +## Scope + +- Keep existing `schema ` (CLI-flag lookup) — still useful, different layer. +- Add `schema api.` (e.g. `schema api.createEvent`) reading from the T3 endpoint + types/Zod schemas: host, method, transport, request params, known response fields. +- Decide output shape (mirror existing schema format vs. diverge) — this is the "Not yet specified" + item that graduates here. + +## Done when + +`partiful schema api.` prints endpoint spec derived from the T3 types for every spec'd +endpoint; existing `schema ` unchanged; tests cover the new namespace. + +## Answer + + diff --git a/.wayfinder/ts-port/tickets/T6-drift-detection-smoke-tests.md b/.wayfinder/ts-port/tickets/T6-drift-detection-smoke-tests.md new file mode 100644 index 0000000..cd1f5da --- /dev/null +++ b/.wayfinder/ts-port/tickets/T6-drift-detection-smoke-tests.md @@ -0,0 +1,27 @@ +# T6 — Wire drift-detection + real-API smoke tests + +**Type:** task (AFK) · **Blocks on:** T3 · **Status:** OPEN + +## Question + +Keep the spec honest over time. Partiful is unofficial — they change shapes without notice. Two +low-cost mechanisms from the prior-art gold standard (YouTube.js) keep types-as-spec from rotting. + +## Scope + +- **Drift detection:** since T3 responses use Zod `.passthrough()`, log any unknown fields observed + at parse time (behind a verbose/debug flag). Over time real traffic reveals the vendor's true + shape. Decide where logs go. +- **Smoke tests:** 3–5 integration tests hitting the real Partiful API (need valid auth; gate behind + env like existing `*.integration.test.js`). When Partiful changes something, a smoke test fails + before users hit it. This IS the spec verifier. +- Decide: CI-run vs. manual (auth secrets in CI is the constraint). + +## Done when + +Unknown-field logging wired into the Zod parse path; a small real-API smoke suite exists and is +documented (how to run, what auth it needs); drift strategy noted in the port guide. + +## Answer + + diff --git a/bin/partiful b/bin/partiful index 6ecba27..8a5bbea 100755 --- a/bin/partiful +++ b/bin/partiful @@ -1,4 +1,7 @@ #!/usr/bin/env node +// Register the tsx ESM loader so the CLI runs directly from TypeScript +// sources (and JS during the migration) with no separate build step. +import 'tsx/esm'; import 'dotenv/config'; import { run } from '../src/cli.js'; run(); diff --git a/package-lock.json b/package-lock.json index bbb9855..e61bc47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,16 @@ "license": "MIT", "dependencies": { "commander": "^13.0.0", - "dotenv": "^16.4.0" + "dotenv": "^16.4.0", + "zod": "^4.4.3" }, "bin": { "partiful": "bin/partiful" }, "devDependencies": { + "@types/node": "^26.1.1", + "tsx": "^4.23.1", + "typescript": "^7.0.2", "vitest": "^3.0.0" }, "engines": { @@ -885,6 +889,356 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@vitest/expect": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", @@ -1464,6 +1818,67 @@ "node": ">=14.0.0" } }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -1651,6 +2066,15 @@ "engines": { "node": ">=8" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 57fcd36..101e65d 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,20 @@ }, "scripts": { "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "start": "node --import tsx bin/partiful" }, - "keywords": ["partiful", "events", "cli", "party", "rsvp", "agent", "ai", "openclaw"], + "keywords": [ + "partiful", + "events", + "cli", + "party", + "rsvp", + "agent", + "ai", + "openclaw" + ], "repository": { "type": "git", "url": "https://github.com/KalebCole/partiful-cli" @@ -26,9 +37,13 @@ ], "dependencies": { "commander": "^13.0.0", - "dotenv": "^16.4.0" + "dotenv": "^16.4.0", + "zod": "^4.4.3" }, "devDependencies": { + "@types/node": "^26.1.1", + "tsx": "^4.23.1", + "typescript": "^7.0.2", "vitest": "^3.0.0" }, "license": "MIT" diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ac15e67 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node"], + + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + + "allowJs": true, + "checkJs": false, + "noEmit": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts", "src/**/*.js"], + "exclude": ["node_modules", "dist"] +} From 2184a335aeee4b60f41c8d32b21ec44fcf262abd Mon Sep 17 00:00:00 2001 From: Kaleb Cole Date: Fri, 24 Jul 2026 14:50:57 -0700 Subject: [PATCH 02/14] T2: TypeScript port convention guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/TYPESCRIPT-PORT-GUIDE.md — enforceable file-by-file rules + worked createEvent endpoint example (envelope generic, request interface, Zod passthrough response, z.infer type, introspectable metadata). --- .wayfinder/ts-port/map.md | 3 +- .../tickets/T2-porting-convention-doc.md | 12 +- docs/TYPESCRIPT-PORT-GUIDE.md | 203 ++++++++++++++++++ 3 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 docs/TYPESCRIPT-PORT-GUIDE.md diff --git a/.wayfinder/ts-port/map.md b/.wayfinder/ts-port/map.md index 57b7bf1..436bae7 100644 --- a/.wayfinder/ts-port/map.md +++ b/.wayfinder/ts-port/map.md @@ -34,6 +34,7 @@ Success = the API spec is a *byproduct* of typing the code, not a separate artif - T0 CLOSED (2026-07-24): RSVP work merged to main via PR #65 (squash, commit ad7be30); tree clean; main green at 195/195 tests. Port branch to be cut from ad7be30. - T1 CLOSED (2026-07-24): TS toolchain up. tsx-loader run path (no dist build), tsconfig strict+NodeNext+allowJs, zod added. `npm run typecheck` clean + 195/195 green on still-JS tree. +- T2 CLOSED (2026-07-24): Convention doc at `docs/TYPESCRIPT-PORT-GUIDE.md`. Enforceable rules + worked createEvent endpoint (envelope generic + request interface + Zod passthrough + z.infer + metadata). Spec home = `src/lib/api/`. ## Not yet specified @@ -57,7 +58,7 @@ Success = the API spec is a *byproduct* of typing the code, not a separate artif |---|---|---|---|---| | T0 | RSVP work merged to main, tree clean, port branch cut | task (AFK) | — | ✅ CLOSED (PR #65 merged, ad7be30) | | T1 | TS toolchain setup (tsconfig strict, tsx run, bin, build) | task (AFK) | T0 | ✅ CLOSED (tsx loader, no dist) | -| T2 | Write porting convention doc (strict + Zod pattern) | task (AFK) | T1 | OPEN | +| T2 | Write porting convention doc (strict + Zod pattern) | task (AFK) | T1 | ✅ CLOSED (docs/TYPESCRIPT-PORT-GUIDE.md) | | T3 | Port src/lib/ API layer + author endpoint types/Zod (THE SPEC) | task (AFK) | T2 | OPEN | | T4 | Port src/commands/ + src/helpers/ | task (AFK) | T3 | OPEN | | T5 | Rewire schema command → schema api. | task (AFK) | T3 | OPEN | diff --git a/.wayfinder/ts-port/tickets/T2-porting-convention-doc.md b/.wayfinder/ts-port/tickets/T2-porting-convention-doc.md index 806b8c6..8e99823 100644 --- a/.wayfinder/ts-port/tickets/T2-porting-convention-doc.md +++ b/.wayfinder/ts-port/tickets/T2-porting-convention-doc.md @@ -1,6 +1,6 @@ # T2 — Write porting convention doc -**Type:** task (AFK) · **Blocks on:** T1 · **Status:** OPEN +**Type:** task (AFK) · **Blocks on:** T1 · **Status:** ✅ CLOSED ## Question @@ -30,4 +30,12 @@ of one typed endpoint (envelope + request interface + Zod `.passthrough()` respo ## Answer - +**CLOSED 2026-07-24.** Guide committed at `docs/TYPESCRIPT-PORT-GUIDE.md`. Captures all +settled decisions as file-by-file rules: per-file gate (typecheck + test), `.js` import +specifiers under NodeNext, `import type` discipline (verbatimModuleSyntax), no behavior +changes, internal shapes = plain interfaces, requests = fully-typed interfaces, responses = +Zod `.passthrough()` + `z.infer`, the ONE reusable `CallableEnvelope

`/`CallableResult` +generic, three tagged transports (firebase-callable / firestore / firebase-auth), spec lives +in `src/lib/api/` (envelope.ts + endpoints.ts registry). Includes the worked `createEvent` +example: envelope reuse + request interface + Zod passthrough response + z.infer type + +introspectable metadata record. diff --git a/docs/TYPESCRIPT-PORT-GUIDE.md b/docs/TYPESCRIPT-PORT-GUIDE.md new file mode 100644 index 0000000..c8ad40f --- /dev/null +++ b/docs/TYPESCRIPT-PORT-GUIDE.md @@ -0,0 +1,203 @@ +# TypeScript Port Guide — partiful-cli + +> **Status:** normative build-spec for the JS→TS port (wayfinder `ts-port`, tickets T3–T6). +> Every porting decision below was settled up front. This is the single set of rules an +> agent follows while flipping 29 files `.js`→`.ts`. It is **not** a tutorial and it is +> **not** open for relitigation — if a rule feels wrong, that is a separate conversation, +> not a port-time deviation. + +## 0. The one-line thesis + +**The port IS the spec.** Typing the `src/lib/` API layer — endpoint request interfaces + +Zod `.passthrough()` response schemas — produces the living API spec as a *byproduct* of +the code. There is no separate hand-maintained spec file to drift. Type the code = write +the spec. + +## 1. Toolchain (settled in T1) + +- Compiler: `typescript@7`, checked via `npm run typecheck` = `tsc --noEmit`. +- Runtime: `tsx` ESM loader. `bin/partiful` registers `tsx/esm`; **no dist build**. Sources + run directly whether `.js` or `.ts`. +- `tsconfig.json`: `strict: true` **from day one** (+ `noUncheckedIndexedAccess`, + `noImplicitOverride`, `noFallthroughCasesInSwitch`), `module`/`moduleResolution` = + `NodeNext`, `allowJs: true` so JS and TS coexist file-by-file during the migration, + `noEmit: true`, `verbatimModuleSyntax: true`. +- Runtime schema dep: `zod` (used only for API **responses** — see §4). + +## 2. Per-file gate (non-negotiable) + +Keep the tree green **file-by-file**. After every file you flip: + +1. `npm run typecheck` — `tsc --noEmit` clean under strict. +2. `npm test` — all existing tests green. + +Never batch a stack of un-typechecked files. One `.js`→`.ts` flip, gate, next. + +## 3. Rules that apply to every file + +### 3.1 Import extensions stay `.js` +NodeNext ESM requires the runtime specifier, not the source extension. Even when +`auth.ts` imports from `errors.ts`, the specifier is **`./errors.js`**. Do not rewrite +import paths to `.ts` — that breaks resolution. + +### 3.2 `import type` for type-only imports +`verbatimModuleSyntax` is on. Anything used only in type position must be +`import type { Foo } from './x.js'` (or inline `import { type Foo }`). A value import used +only as a type is a compile error — fix it, don't loosen the tsconfig. + +### 3.3 No behavior changes +This is a **faithful translation**. Same control flow, same field names, same runtime +output, same error messages. If you're tempted to "fix" or refactor logic, stop — that's a +separate effort (see map "Out of scope"). Types describe what the code already does. + +### 3.4 Internal shapes = plain `interface`/`type`, no Zod +Config objects, CLI option bags, helper return shapes, Firestore field-format maps — +things we construct in-process and fully control — get plain TS interfaces/types. **No +runtime validation** on internal data; we own it, the compiler is enough. + +### 3.5 Requests = fully-typed interfaces +We control every byte we send. Request params and the payloads passed to `apiRequest` are +specified **completely** as interfaces — no `.passthrough()`, no `any`, no optional-escape +hatches beyond what the real payload actually allows. + +### 3.6 Prefer `unknown` + narrowing over `any` +`strict` is on for a reason. When a value is genuinely dynamic (parsed JSON before schema +validation, `catch` bindings), type it `unknown` and narrow. Reserve `any` for documented, +commented, unavoidable escape hatches — expect to need approximately zero. + +## 4. API responses = Zod `.passthrough()` + `z.infer` (THE SPEC) + +Partiful is an **unofficial API we don't own**. Responses carry known fields but are +*non-exhaustive* — the vendor adds/removes fields without notice. Therefore: + +- Every endpoint response gets a **Zod schema** built with **`.passthrough()`** so unknown + vendor fields flow through instead of throwing. +- The TS type is **inferred** from the schema: `type CreateEventResponse = z.infer`. Never hand-write a response interface in parallel with its + schema — the schema is the single source, the type is derived. +- Parsing happens at the lib-layer boundary (where the raw `fetch` JSON is unwrapped). +- `.passthrough()` is also what makes **drift detection** (T6) possible: unknown keys are + observable at parse time. + +## 5. The RPC envelope is ONE reusable generic + +Every firebase-callable endpoint wraps its params in the same shape: + +```ts +data: { params:

, amplitudeDeviceId: string, amplitudeSessionId?: number, userId?: string | null } +``` + +Specify it **once** as a generic and reference it per-endpoint. Never re-inline the +envelope shape. + +```ts +// src/lib/api/envelope.ts +/** Shared Firebase-callable RPC request envelope. `P` = the endpoint's params shape. */ +export interface CallableEnvelope

{ + data: { + params: P; + amplitudeDeviceId: string; + /** Present on identity-scoped calls (rsvp, cohosts). */ + amplitudeSessionId?: number; + /** Backfilled from the Firebase JWT; may be null on legacy auth files. */ + userId?: string | null; + }; +} + +/** Firebase-callable responses nest the real payload under result.data. */ +export interface CallableResult { + result?: { data?: D }; +} +``` + +## 6. Three transport groups — tag every endpoint + +Endpoints fall into exactly three tagged groups. Every spec'd endpoint declares which one +it belongs to (a `transport` discriminant on its metadata — see §7): + +| transport | host | verb(s) | body shape | +|--------------------|-------------------------------|-------------|---------------------------------------------| +| `firebase-callable`| `api.partiful.com` | POST | `CallableEnvelope

` (§5); resp `CallableResult` | +| `firestore` | `firestore.googleapis.com` | GET / PATCH | Firestore typed-document format | +| `firebase-auth` | Google (`securetoken.…`) | POST | form-encoded token refresh | + +Firebase-callable endpoints to spec (from T3): `createEvent`, `cancelEvent`, +`getEventInfo`, `getContacts`, `createTextBlast`, `addInvitedGuestsAsHost`, +`getMyUpcomingEventsForHomePage`, `getMyPastEventsForHomePage`, `addGuest`, +`markEventInterest`, `getCurrentGuest`. Plus Firestore GET/PATCH document ops and the +firebase-auth token refresh. + +## 7. Where the spec lives + +Collect endpoint types + schemas under **`src/lib/api/`** so T5's `schema api.` +command can surface them from one place: + +- `src/lib/api/envelope.ts` — the shared generics (§5). +- `src/lib/api/endpoints.ts` — one entry per endpoint: request interface, response Zod + schema + `z.infer` type, and a small metadata record (`{ method, host, transport }`) so + the spec is introspectable. This registry is what makes types-as-spec *queryable*. + +Keep each endpoint's request interface, response schema, and metadata co-located so the +three never drift from each other. + +## 8. Worked example — one fully-typed endpoint + +`createEvent` (firebase-callable). This is the pattern to replicate across all endpoints. + +```ts +// src/lib/api/endpoints.ts +import { z } from 'zod'; +import type { CallableEnvelope, CallableResult } from './envelope.js'; + +// --- request: fully typed, we control what we send (§3.5) --- +export interface CreateEventParams { + event: EventDraft; // internal shape, plain interface (§3.4) + cohostIds: string[]; +} +export type CreateEventRequest = CallableEnvelope; + +// --- response: Zod .passthrough(), type inferred (§4) --- +export const CreateEventResponseSchema = z + .object({ + id: z.string(), + title: z.string().optional(), + status: z.string().optional(), + }) + .passthrough(); // unknown vendor fields flow through, don't throw + +export type CreateEventData = z.infer; +export type CreateEventResponse = CallableResult; + +// --- metadata: makes the endpoint introspectable for `schema api.createEvent` (§7) --- +export const createEventEndpoint = { + method: 'POST', + host: 'api.partiful.com', + path: '/createEvent', + transport: 'firebase-callable', + requestParams: ['event', 'cohostIds'], + responseSchema: CreateEventResponseSchema, +} as const; +``` + +At the call site (lib layer), parse the raw JSON through the schema so passthrough + +drift-logging (T6) apply: + +```ts +const raw = await apiRequest('POST', '/createEvent', token, payload, verbose); +const data = CreateEventResponseSchema.parse(raw.result?.data ?? {}); +``` + +## 9. Sequencing (do not reorder) + +`src/lib/` (API layer, spec born here — T3) → `src/commands/` + `src/helpers/` (consume the +types — T4) → `src/commands/schema.ts` (surface them — T5). Drift + smoke tests (T6) wire +onto the T3 parse path. If while porting a command (T4) you find yourself authoring a new +endpoint shape, it belonged in T3 — go back and add it there. + +## 10. Drift detection & smoke tests (T6 summary) + +- Because responses use `.passthrough()`, unknown keys are observable at parse time. Log + them behind a verbose/debug flag so real traffic reveals the vendor's true shape over + time. +- A small real-API smoke suite (gated behind auth env, like existing `*.integration.test.js`) + is the spec verifier: when Partiful changes a shape, a smoke test fails before users hit + it. From c1d64f91f9e27195eda84d7e16d8d2abd3879243 Mon Sep 17 00:00:00 2001 From: Kaleb Cole Date: Fri, 24 Jul 2026 14:55:08 -0700 Subject: [PATCH 03/14] T3 (first slice): port output/errors/http to TS + author api/ spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/lib/output.ts, errors.ts, http.ts ported (strict-clean) - src/lib/api/envelope.ts: CallableEnvelope

+ CallableResult generics - src/lib/api/endpoints.ts: THE SPEC — request interfaces + Zod .passthrough() response schemas + z.infer types + introspectable metadata registry for all 11 callable endpoints, 3 firestore ops, token refresh - bin/partiful: register tsx loader then dynamic-import CLI (fixes ESM hoist) - first-slice oracle met: http+createEvent typed, strict clean, 195/195 green --- .wayfinder/BUILD-PROMPT.md | 56 +++ .wayfinder/map.md | 66 +++ .wayfinder/tickets/01-build-id-recon.md | 62 +++ .wayfinder/tickets/02-rsvp-endpoint-recon.md | 90 ++++ .wayfinder/tickets/03-tag-filter-recon.md | 39 ++ .wayfinder/tickets/04-command-shape.md | 75 +++ .wayfinder/tickets/05-implement.md | 29 ++ .wayfinder/tickets/06-docs.md | 23 + .../tickets/07-rsvp-going-live-capture.md | 127 +++++ bin/partiful | 13 +- docs/explore-command-design.md | 178 +++++++ src/lib/api/endpoints.ts | 454 ++++++++++++++++++ src/lib/api/envelope.ts | 25 + src/lib/errors.js | 29 -- src/lib/errors.ts | 52 ++ src/lib/{http.js => http.ts} | 119 +++-- src/lib/output.js | 49 -- src/lib/output.ts | 74 +++ 18 files changed, 1431 insertions(+), 129 deletions(-) create mode 100644 .wayfinder/BUILD-PROMPT.md create mode 100644 .wayfinder/map.md create mode 100644 .wayfinder/tickets/01-build-id-recon.md create mode 100644 .wayfinder/tickets/02-rsvp-endpoint-recon.md create mode 100644 .wayfinder/tickets/03-tag-filter-recon.md create mode 100644 .wayfinder/tickets/04-command-shape.md create mode 100644 .wayfinder/tickets/05-implement.md create mode 100644 .wayfinder/tickets/06-docs.md create mode 100644 .wayfinder/tickets/07-rsvp-going-live-capture.md create mode 100644 docs/explore-command-design.md create mode 100644 src/lib/api/endpoints.ts create mode 100644 src/lib/api/envelope.ts delete mode 100644 src/lib/errors.js create mode 100644 src/lib/errors.ts rename src/lib/{http.js => http.ts} (54%) delete mode 100644 src/lib/output.js create mode 100644 src/lib/output.ts diff --git a/.wayfinder/BUILD-PROMPT.md b/.wayfinder/BUILD-PROMPT.md new file mode 100644 index 0000000..550479d --- /dev/null +++ b/.wayfinder/BUILD-PROMPT.md @@ -0,0 +1,56 @@ +# GOAL: Ship `events rsvp` (+ `explore rsvp` alias) with questionnaire support in partiful-cli + +## Context +Repo: `~/repos/partiful-cli` (Commander.js, plain JS, no build step, no TypeScript). One file per command group in `src/commands/`; ALL API access goes through `src/lib/`. Tests: `npm test` (vitest). Install trap: the global `partiful` binary is a COPY, not a symlink, so source edits do NOT take effect until you rerun `npm install -g .`. + +Read these BEFORE writing code: +- `AGENTS.md` (repo root) for conventions and boundaries. +- `docs/explore-command-design.md` (design note). +- `.wayfinder/map.md` and all `.wayfinder/tickets/*.md` (especially 02, 04, 05, 07). +- Load the `partiful` skill, `hermes-shared-chrome-cdp` skill, and `cli-api-recon` skill. + +## What is already decided (do NOT relitigate) +- **Self-RSVP endpoint = `addGuest`** (confirmed, captured live). Params: `{eventId, rsvp:{name, count, plusOnes[], message, emailInvitationId, status, guestId, timezone, password}}`. Statuses `GOING|MAYBE|DECLINED` all go through the ONE call via the `status` field. First RSVP sends `guestId:null` (server creates the record); edits send the returned `guestId` back to update. +- **Interested = `markEventInterest`** `{eventId, interested:bool, source?}`. +- **`updateGuestStatus` is HOST-ONLY** (403 on your own record). Do NOT use it for self-RSVP. +- **Auth:** reuse `src/lib/http.js` (Firebase Bearer, auto-refresh). A raw unauthed fetch 401s. Do NOT hand-roll auth. +- **Same endpoint for all events** (invited, discovered, self-owned). Ownership only gates host-management calls. +- **Command naming (DECIDED by Kaleb 2026-07-24):** + - CANONICAL: `events rsvp [--status going|maybe|declined] [--plus-one NAME] [--count N] [--message TXT] [-y] [--dry-run]` -> `addGuest` + - ALIAS: `explore rsvp ` -> thin forward to the SAME handler. + - Same alias pattern for `events interested` / `explore interested [--remove]` -> `markEventInterest`. + - Single shared handler; `explore *` verbs just forward to the `events *` implementation. + +## The ONE open unknown to resolve first (recon) +How custom questionnaire answers ride inside the `addGuest` payload. Some hosts require Q&A before an RSVP submits (`getLastQuestionnaireAnswers` is the tell). This affects regular invited events too, not just discovery. Do NOT ship without handling it. + +### Recon method (ticket 07 rig) +1. Create a THROWAWAY self-owned Partiful event WITH a required custom question, via the web app (the CLI has no questionnaire flag). Keep it private/unlisted. (Ask Kaleb to create it and hand you the `/e/{id}` link if you cannot create questions programmatically.) +2. Attach to Kaleb's logged-in local Chrome via CDP on port 9222 (`hermes-shared-chrome-cdp` skill). The remote/cloud browser is NOT logged into Partiful; the local one is. +3. In the CDP tab, hook `window.fetch` + `XMLHttpRequest` to log all `api.partiful.com` calls, THEN navigate to the event `/e/{id}`. +4. Click RSVP, answer the question, choose Going, Continue. Capture the `addGuest` request body. +5. Diff against the known clean payload to find where answers live (likely a new field inside `rsvp`, e.g. `answers[]` / `questionnaireAnswers`). Note the shape (question id vs text, answer format). +6. DELETE the test event. Zero residue. + +## Build (after recon) +1. Implement a single shared RSVP handler in `src/commands/` reusing `src/lib/http.js`. +2. Read-before-write: the CLI is stateless, so the handler must call `getCurrentGuest {eventId}` first to decide create (`guestId:null`) vs update (pass existing `guestId`). +3. Wire `events rsvp` (canonical) and `explore rsvp` (alias -> same handler). Same for `interested`. +4. Bake in questionnaire support per the recon findings. If an event requires a questionnaire and the user did not supply answers, fail clearly (do not silently submit). +5. Flags: `--status` (default `going`), `--plus-one` (repeatable), `--count`, `--message`, `--password` (field already in payload), `-y/--yes`, `--dry-run`. +6. Confirmation gate on writes by default (writing to a guest list); `-y` skips for agent flows. `--dry-run` previews the payload without sending. +7. Refuse ticketed/paid events cleanly (Stripe wall) and point the user to the app. + +## Hard rules +- **NO em dashes anywhere** in user-facing output or event copy the CLI writes (Kaleb hard rule). Use colons/commas. +- Never expose phone numbers or Partiful user IDs in user-facing output. +- Ask before sending text blasts, cancelling events, or bulk ops. +- Do not hardcode auth tokens in source. + +## Definition of done +- `events rsvp` and `explore rsvp` both work end-to-end against a real event (verify with `--dry-run` first, then a live RSVP on a self-owned event, then revert/delete). +- Questionnaire event RSVP works (verified on the recon test event before deletion). +- `npm test` passes; add unit tests for the handler (mock `src/lib/http.js`). +- `npm install -g .` rerun so the global binary reflects changes. +- Update the `partiful` skill + repo README with the new commands. +- Update the Todoist task `id:6h6Pvxgw2mJf466G` to reflect shipped state (or leave a comment with the final command surface). \ No newline at end of file diff --git a/.wayfinder/map.md b/.wayfinder/map.md new file mode 100644 index 0000000..34819d7 --- /dev/null +++ b/.wayfinder/map.md @@ -0,0 +1,66 @@ + +# Map: Partiful `explore` — trending discovery + RSVP + +## Destination + +A shipped `partiful explore` command surfacing Partiful's public trending/discovery +events, AND the ability to **RSVP** (going) or mark **interested** on a discovered +event you were never invited to. "Shipped" = command shape, output contract, +build-id handling, and the discover write-path (RSVP/interested) all decided and +implemented in `~/repos/partiful-cli`, with skill/README docs updated. + +This is an **execution-carrying** map: it ends in merged code, not just a spec. + +## Notes + +- Domain: reverse-engineered Partiful internal API. No official docs. +- Repo: `~/repos/partiful-cli`. Commander.js, plain JS, no build step. One file per + command group in `src/commands/`; ALL API access goes through `src/lib/`. +- Install trap: global `partiful` is a COPY, not a symlink. Source edits don't take + effect until `npm install -g .` reruns. (per partiful skill + AGENTS.md) +- Tests: `npm test` (vitest). Integration tests hit real API + need auth. +- Skills every session should consult: `partiful` (skill), `cli-api-recon`, + `cli-architect`. Repo `AGENTS.md`. +- Auth is ASSUMED (see Decisions). Reuse `src/lib/http.js` token flow; no anon mode. +- No em dashes in any user-facing event copy this CLI writes (Kaleb hard rule). + +## Decisions so far + +- [Auth model: always logged in](#) — `explore` reuses the existing Firebase-token + flow via `src/lib/http.js`. No anonymous/unauthed mode. The CLI's contract is that + the user is logged in; discovery read endpoints happen to be public, but we don't + build a separate anon path. +- [Discovery is not read-only](#) — RSVP (going) and "interested" on a discovered + event are in scope for v1, not deferred. +- [BUILD-ID: use stable api.partiful.com, no build id](.wayfinder/tickets/01-build-id-recon.md) + — `POST /getDiscoverFeed` (paginated feed) + `POST /getDiscoverSections` (trending + carousels + tags), Bearer auth, Firebase `{"data":{params,paging}}` envelope. Cursor + pagination via `result.paging.nextCursor`. No rotating build id needed; reuse + `src/lib/http.js`. `/_next/data/{buildId}` is the fallback (scrape buildId from + `__NEXT_DATA__`). +- [TAG-FILTER: server-side via tagId](.wayfinder/tickets/03-tag-filter-recon.md) — + `--tag` maps directly to the `tagId` param; verified filtering (NYC: HOME=20, + MUSIC=15, FOOD=4). Valid tags from `getDiscoverSections` `.tags[]`. +- [RSVP-ENDPOINT: two verbs, not one flag](.wayfinder/tickets/02-rsvp-endpoint-recon.md) + — INTERESTED and GOING use different endpoints, so `explore interested` and + `explore rsvp` are separate verbs. **INTERESTED fully solved**: `markEventInterest` + `{eventId, interested:bool, source?}` — true creates an INTERESTED guest record, + false removes it (verified live + reverted). `getCurrentGuest {eventId}` reads + state. **GOING SOLVED (2026-07-17)**: the self-RSVP mutation is `addGuest` + `{eventId, rsvp:{name,count,plusOnes[],message,status,guestId,timezone,password,...}}`. + First RSVP `guestId:null` (creates); edits pass returned `guestId`. GOING / MAYBE / + DECLINED all via the `status` field. Captured live from OpenClaw's logged-in Chrome + (CDP :9222); verified GOING then reverted DECLINED. Ticket 07 CLOSED. + +## Not yet specified + +- Caching strategy is now MOOT for the build id (stable API used). Any caching is a + minor perf choice deferred to IMPLEMENT (e.g. cache the tag list per region). +- Output columns for the human `--format table` view (which event fields matter). + Graduates once COMMAND-SHAPE is decided. + +## Out of scope + +- Event detail enrichment via `getDiscoverEventItemDecorators` (guest-count badges). + Nice-to-have overlay, not required to browse or RSVP. Revisit as a later effort. +- Non-US regions beyond what the region-slug endpoint already returns for free. diff --git a/.wayfinder/tickets/01-build-id-recon.md b/.wayfinder/tickets/01-build-id-recon.md new file mode 100644 index 0000000..e76c223 --- /dev/null +++ b/.wayfinder/tickets/01-build-id-recon.md @@ -0,0 +1,62 @@ + +# BUILD-ID: how to resolve the rotating Next.js build id + +Labels: wayfinder:research +Blocked by: (none — frontier) +Assignee: hermes +Status: closed + +## Question + +The discovery data endpoints are `/_next/data/{BUILD}/explore.json` and +`/_next/data/{BUILD}/explore/{region}.json?region={slug}`, where `{BUILD}` is the +Next.js `buildId` that rotates on every Partiful deploy (observed: +`A1rxlYfFYHBWL3Uop4ELL`). A hardcoded build id will 404 after the next deploy. + +Determine the resolution strategy: +- Can the build id be scraped reliably from the `/explore` HTML + (`__NEXT_DATA__.buildId` or `/_next/static/{buildId}/_buildManifest.js`)? +- Is there a stable `api.partiful.com` endpoint that returns the same trending + data WITHOUT a build id (recon saw `getDiscoverEventItemDecorators`; is there a + `getDiscoverFeed` / `getTrendingEvents` sibling)? Probe common names. +- Fallback ordering and failure behavior. + +Output: markdown summary in this ticket's answer — chosen strategy + the exact +request(s), with status codes. Feeds the caching decision (Not yet specified). + +--- + +## Resolution (closed) + +**Strategy chosen: hit the stable `api.partiful.com` endpoints directly. NO build id needed.** + +The `/_next/data/{BUILD}/...` path works but requires the rotating buildId. The web app's own data layer calls two stable Firebase-callable endpoints that the CLI can use directly through the existing `src/lib/http.js` authed client. The build id is fully avoidable. + +### Endpoints (POST, Bearer auth required, Firebase-callable `{data:{...}}` envelope) + +**`POST https://api.partiful.com/getDiscoverFeed`** — the paginated event feed. +```json +{"data":{"params":{"region":"NYC","tagId":"DISCOVER_HOME","allowedFeedPresentationStyles":["rows"]},"paging":{"maxResults":100}}} +``` +Response: `result.data.items[]` (each `{id,type,event:{...}}`), `result.paging.nextCursor`. + +**`POST https://api.partiful.com/getDiscoverSections`** — trending carousels + tag list. +```json +{"data":{"params":{"region":"NYC","tagId":"DISCOVER_HOME","allowedSectionPresentationStyles":["carousel-small","rows"],"locale":"en"},"paging":{"maxResults":100}}} +``` +Response: `result.data.sections[]` (trending carousels), `result.data.tags[]` (category list). + +Also exists: `getDiscoverSection` (singular, `{params}` only) for one section. + +### Key facts +- **Envelope is `{"data":{...}}`** (Firebase callable convention). A bare `{params,paging}` returns 400; `{data:...}` returns 401/200. This is why raw recon 400'd. +- **Auth IS required** — 401 without a valid Bearer. Reuse `src/lib/http.js` (it already sends the token + refreshes). The 400s during recon were an unauthed/expired token, not a bad endpoint. +- **Token refresh:** CLI auto-refreshes on any authed call; a stale `auth.json` token 401s until refreshed. +- **Region values:** `NYC, LA, SF, BOS, DC, CHI, LON, MIA, ATX` (uppercase in API params; lowercase slugs `nyc/la/...` only for the `/_next/data` web-page path). +- **Pagination = cursor.** `result.paging.nextCursor` → pass back as `paging.cursor` (or `paging.afterCursor`; confirm exact key in IMPLEMENT). `pageResultCount` also returned. + +### Bonus — resolves TAG-FILTER ticket +`tagId` filters **server-side**: NYC DISCOVER_HOME=20, MUSIC=15, FOOD=4 items. `--tag` maps directly to the `tagId` param. No client-side filtering needed. TAG-FILTER can be closed as answered-by-BUILD-ID. + +### Fallback +If these endpoints ever change, the `/_next/data/{buildId}/explore/{slug}.json?region={slug}` path still works; scrape `buildId` from `__NEXT_DATA__` on the `/explore` HTML (verified present). diff --git a/.wayfinder/tickets/02-rsvp-endpoint-recon.md b/.wayfinder/tickets/02-rsvp-endpoint-recon.md new file mode 100644 index 0000000..b8fa75b --- /dev/null +++ b/.wayfinder/tickets/02-rsvp-endpoint-recon.md @@ -0,0 +1,90 @@ + +# RSVP-ENDPOINT: how to RSVP / mark interested on a discovered event + +Labels: wayfinder:research +Blocked by: (none — frontier) +Assignee: hermes +Status: closed + +## Question + +RSVP-ing to a DISCOVERED event is different from `guests invite` — the user was +never invited; they're crashing a public event. Find the API call the web +`/explore` and `/e/{id}` pages fire when a logged-in user clicks "Going" or +"Interested" on a public event. + +Method: attach to the browser logged in as Kaleb, open a discovered public event, +capture the XHR/fetch on the RSVP + interested buttons (via performance entries / +CDP network). Identify: +- Endpoint URL(s) on `api.partiful.com` (e.g. `setRsvp` / `rsvpToEvent` / + `setGuestStatus` / `expressInterest`). +- Request body shape (eventId, status enum — GOING / INTERESTED / MAYBE?). +- Whether "interested" is a distinct status value on the same endpoint or a + separate endpoint. +- Auth header used (should be the same Firebase token `src/lib/http.js` sends). +- Success + error response shapes. + +Do NOT actually RSVP to a stranger's event during recon unless unavoidable; if a +live write is needed, use a throwaway/self-owned event and clean up. + +Output: markdown answer — endpoint(s), body, status enum, auth. Resolves the +"one --status flag vs two verbs" fog item. + +--- + +## Resolution (closed — partially, with a follow-up) + +All endpoints are Firebase-callable: `POST https://api.partiful.com/`, Bearer +auth (reuse `src/lib/http.js`), body `{"data":{"params":{...}}}`, response +`{"result":{"data":{...}}}`. + +### INTERESTED — fully solved and verified end-to-end +**`markEventInterest`** — params `{eventId, interested: bool, source?}`. +- `interested:true` -> `{interested:true, success:true}`; **creates a guest record** + with `status:"INTERESTED"`. +- `interested:false` -> `{interested:false, previousStatus:"INTERESTED", success:true}`; + **removes** the guest record (verified: currentGuest -> NONE after). +- `source` is OPTIONAL (200 with it omitted). Web sends an enum value (`DISCOVER`); + any string or absence works. Recommend sending `"DISCOVER"`. +- Read current state with **`getCurrentGuest`** `{eventId}` -> + `result.data.currentGuest.{id,status}` (or null). +- Verified live on 2 discovered events (5R73..., UHjP...), then reverted, no residue. + +### GOING (RSVP) — SOLVED 2026-07-17: the mutation is `addGuest` +Captured live from OpenClaw's logged-in local Chrome (CDP :9222) — see ticket 07. +**`addGuest`** — params `{eventId, rsvp:{name, count, plusOnes[], message, emailInvitationId, +status, guestId, timezone, password}}`. First RSVP sends `guestId:null` (server creates +record); edits send the returned `guestId` to update. Statuses `GOING` / `MAYBE` / `DECLINED` +ALL go through this one call via the `status` field. Same Bearer auth (`src/lib/http.js`); +a raw unauthed fetch 401s. Verified end-to-end: RSVP'd GOING then reverted to DECLINED, clean. + +### (superseded) earlier dead-ends on the GOING path +- **`updateGuestStatus`** `{eventId, guestId, guestStatus, rsvpReason?, newGuestName?}` + is **HOST-ONLY**. Returns 403 `PERMISSION_DENIED "User is not a host of this event + and is not an admin"` even for the caller's OWN guest record, even on an event the + caller is legitimately invited to (tested idempotent MAYBE->MAYBE). So it is the + host's guest-management tool, NOT the self-RSVP path. Do not use it for `explore rsvp`. +- **`addInvitedGuestsAsGuest`** `{eventId, userIdsToInvite[], phoneContactsToInvite[], + invitationMessage?}` is for inviting mutuals, and 400'd `FAILED_PRECONDITION + "Guests are not allowed to invite mutuals to this event"` when self-targeted. Not it. +- Probed 12 plausible names (respondToEvent, rsvpToEvent, setMyRsvp, joinEvent, + selfRsvp, addSelfAsGuest, ...) -> all 404. The real self-RSVP mutation is + **lazy-loaded** in a dynamic chunk not present in the initial `/e/[event]` bundle, + so static grep of the 27 eager chunks did not surface it. + +### Status enum (wire values, from public getMyRsvps / getCurrentGuest) +`GOING . MAYBE . DECLINED . INTERESTED . WAITLIST . APPROVED . SENT` (uppercase literals). + +### Follow-up ticket created: 07-rsvp-going-live-capture +The GOING mutation must be captured from a **logged-in browser**: open a public +discovered event, click "RSVP -> Going", record the XHR to `api.partiful.com` +(method name + `params` shape). The remote automation browser is NOT logged into +Partiful, so this needs Kaleb's local logged-in browser (CDP) or a manual devtools +capture. Blocks the GOING half of IMPLEMENT. + +### Recommendation for COMMAND-SHAPE +Both write paths now proven. Ship two verbs (not a shared `--status` flag, since +interested and RSVP use different endpoints): +- **`explore interested ` (+ `--remove`)** — `markEventInterest`. +- **`explore rsvp [--status going|maybe|declined]`** — `addGuest`. +Both reuse `src/lib/http.js` auth. Ticket 07 fully closed; nothing gating IMPLEMENT. diff --git a/.wayfinder/tickets/03-tag-filter-recon.md b/.wayfinder/tickets/03-tag-filter-recon.md new file mode 100644 index 0000000..e9ae1ba --- /dev/null +++ b/.wayfinder/tickets/03-tag-filter-recon.md @@ -0,0 +1,39 @@ + +# TAG-FILTER: is category/tag filtering server-side or client-side? + +Labels: wayfinder:research +Blocked by: (none — frontier) +Assignee: (unclaimed) +Status: closed + +## Question + +The region feed returns a `tags` array (DISCOVER_HOME, MUSIC, COMMUNITY, ARTS, +FITNESS, FOOD, + neighborhood tags). In recon, `?tag=MUSIC` and `?tagId=MUSIC` +did NOT change `selectedTagId` (stayed DISCOVER_HOME) or the feed — filtering +appears client-side or uses an unknown param. + +Determine: +- Grep the `explore/[region].js` Next chunk for how a tag click changes the feed + (does it re-fetch with a param, or filter `feedItems` in-memory?). +- If server-side: the exact param name + value format. +- If client-side: confirm the CLI must filter `feedItems` locally by each item's + `tags` field. + +Output: answer states server-side (with param) vs client-side (filter locally). +Resolves the `--tag` behavior fog item. If this proves expensive, v1 may ship +region+trending only and defer `--tag` — flag that in the answer. + +--- + +## Resolution (closed — answered by BUILD-ID recon) + +**Server-side.** The stable `getDiscoverFeed` / `getDiscoverSections` endpoints take a +`tagId` param that filters server-side. Verified on region=NYC: +DISCOVER_HOME=20 items, MUSIC=15, FOOD=4. + +CLI `--tag` maps directly to `tagId`. Valid values come from the `tags[]` array in +`getDiscoverSections` (DISCOVER_HOME, MUSIC, COMMUNITY, ARTS, FITNESS, FOOD, plus +region-specific neighborhood tags like NYC_BROOKLYN). No client-side filtering needed. + +See ticket 01 (BUILD-ID) resolution for full endpoint contract. diff --git a/.wayfinder/tickets/04-command-shape.md b/.wayfinder/tickets/04-command-shape.md new file mode 100644 index 0000000..4a3cabc --- /dev/null +++ b/.wayfinder/tickets/04-command-shape.md @@ -0,0 +1,75 @@ + +# COMMAND-SHAPE: explore command surface + output contract + +Labels: wayfinder:prototype +Blocked by: (none — BUILD-ID, RSVP-ENDPOINT, TAG-FILTER all resolved) +Assignee: hermes +Status: in-progress + +## DECIDED (2026-07-17) + +### Naming — user-facing verbs never leak wire names +Partiful's internal callables (`addGuest`, `markEventInterest`) stay buried in +`src/lib/http.js`. `addGuest` is a terrible CLI word — it's the server's +guest-list-writer primitive, not what the user is doing. User types intent: + +``` +partiful explore rsvp # you're going (default) +partiful explore rsvp --status maybe|declined +partiful explore rsvp --plus-one "Name" # +1 rides in same addGuest call +partiful explore rsvp --count 2 # headcount incl. plus-ones +partiful explore interested [--remove] # softer signal (markEventInterest) +``` + +- `rsvp` verb chosen over `join`/`going` — matches Partiful's own button + ("RSVP → Going"), reads like a human, and cleanly holds going/maybe/declined + as a `--status` since they're ONE wire call (status field on `addGuest`). +- Plus-ones surface as `--plus-one` / `--count` flags ON the rsvp verb, NOT a + separate command — because on the wire they're sub-fields of your own guest + record, not distinct guests. This is honest naming (`--plus-one` describes the + real thing) without exposing `addGuest`. +- `interested` stays a SEPARATE verb (different endpoint `markEventInterest`), + not a `--status interested` on rsvp. Don't merge two endpoints under one flag. + +### Still open (the actual prototype work) +- Discovery surface: `explore` with flags (`--region`, `--tag`, `--trending`, + `--limit`) vs a group (`explore list|regions|trending`)? Lean: single + `explore` + flags for browse, sub-verbs `rsvp`/`interested` for writes. +- Default region resolution: require `--region` or infer/default to one? +- `--format table` columns (id, title, startDate, neighborhood, url). +- Region slug map (nyc/la/sf/bos/dc/chi/lon/mia/atx) — slugs or friendly names? +- **Confirmation gate on `rsvp`** (per AGENTS.md destructive-command policy): + `rsvp`/`interested` write to a real host's guest list → default confirm, `-y` + to skip in agent flows. Ticketed/password/questionnaire events → refuse with a + "use the app" guard rather than a half-broken guest record (see ticket 02/07 + untested branches). + +Output: a `docs/` design note + stubbed `--help` text linked from this ticket. + +## Prototype delivered → docs/explore-command-design.md +Full stubbed `--help` for all 5 subcommands + example JSON output contracts +(list, regions, rsvp success, rsvp refused) + IMPLEMENT behavior notes +(read-before-write for stateless guestId, confirmation gate, ticketed guard). +Two open Qs for Kaleb at the bottom (default region, slugs vs names). + +## Original question (retained) + +Decide the command surface and JSON/table output contract for discovery, given +what the three recon tickets resolve. Produce a rough stub (help text + example +JSON output) to react to — not the implementation. + +Open sub-questions: +- Single command `explore` with flags (`--region`, `--tag`, `--trending`, + `--limit`) vs a command group (`explore list|regions|trending`)? +- Where do RSVP/interested live? Sub-verbs `explore rsvp ` / + `explore interested `, OR one `explore rsvp --status going| + interested`, OR fold into existing top-level (`events rsvp`)? (depends on + RSVP-ENDPOINT status enum) +- Default region resolution: require `--region`, or infer/ default to one? +- Output columns for `--format table` (id, title, startDate, neighborhood, url). +- Region slug map (nyc/la/sf/bos/dc/chi/lon/mia/atx) — expose slugs or friendly + names? + +Output: a `docs/` design note + stubbed `--help` text linked from this ticket. +This is the last decision before implementation; graduates the IMPLEMENT + DOCS +tickets out of fog. diff --git a/.wayfinder/tickets/05-implement.md b/.wayfinder/tickets/05-implement.md new file mode 100644 index 0000000..64f3d30 --- /dev/null +++ b/.wayfinder/tickets/05-implement.md @@ -0,0 +1,29 @@ + +# IMPLEMENT: build the explore command + discover lib + +Labels: wayfinder:task +Blocked by: COMMAND-SHAPE +Assignee: (unclaimed) +Status: open + +## Question + +Implement the decided command surface. Not a decision ticket — this is the DO +step the map carries to (execution-carrying map). + +Work: +- New `src/lib/explore.js` (or extend `src/lib/events.js`): build-id resolution + per BUILD-ID, region feed fetch, trending fetch, RSVP + interested writes per + RSVP-ENDPOINT. All HTTP through `src/lib/http.js`. +- New `src/commands/explore.js` wired into the CLI entry, matching the + COMMAND-SHAPE stub. Register in whatever the main command index is. +- `partiful schema explore.*` introspection support if the CLI auto-derives it; + otherwise add. +- Structured errors `{status, error:{code,type,message}}`; exit codes 0-5. +- Vitest unit tests (mock HTTP) for feed parse + RSVP body build. Integration + test behind auth for one live region fetch. +- Reinstall to activate: `npm install -g .` (COPY-not-symlink trap). +- Verify end to end: `partiful explore --region nyc --format table` returns + events; a dry-run RSVP builds the right body. + +Output: working command, tests green, linked commit/branch. diff --git a/.wayfinder/tickets/06-docs.md b/.wayfinder/tickets/06-docs.md new file mode 100644 index 0000000..d0fe3d7 --- /dev/null +++ b/.wayfinder/tickets/06-docs.md @@ -0,0 +1,23 @@ + +# DOCS: update partiful skill + README for explore + +Labels: wayfinder:task +Blocked by: IMPLEMENT +Assignee: (unclaimed) +Status: open + +## Question + +Document the shipped `explore` command so it's discoverable and correct. + +Work: +- Update the `partiful` skill SKILL.md (~/.hermes/skills/social-media/partiful/): + add an "Explore / discovery" section — endpoints, build-id caveat, RSVP-to- + discovered-event flow, region slugs, tag-filter behavior. Correct the current + skill's flat claim that there's "no browse public events" command. +- Update repo README + AGENTS.md with the new command group. +- Note any pitfalls found during IMPLEMENT (rotating build id, tag filtering + quirks) in the skill's Pitfalls section. + +Output: skill + README updated, linked commit. This is the last ticket; when it +closes the destination is reached. diff --git a/.wayfinder/tickets/07-rsvp-going-live-capture.md b/.wayfinder/tickets/07-rsvp-going-live-capture.md new file mode 100644 index 0000000..22f8962 --- /dev/null +++ b/.wayfinder/tickets/07-rsvp-going-live-capture.md @@ -0,0 +1,127 @@ + +# RSVP-GOING-LIVE-CAPTURE: capture the self-RSVP (Going) mutation from a logged-in browser + +Labels: wayfinder:research +Blocked by: (none) +Assignee: hermes +Status: CLOSED — resolved 2026-07-17 + +## Why this exists + +BUILD-ID and RSVP-ENDPOINT recon proved the discovery feed and the "interested" +write (`markEventInterest`). The **"Going" self-RSVP** mutation for a public event +could NOT be found by static analysis: `updateGuestStatus` is host-only, and the +real self-RSVP call is lazy-loaded in a dynamic chunk absent from the initial +`/e/[event]` bundle. 12 guessed endpoint names all 404'd. + +## RESOLUTION — the mutation is `addGuest` + +Captured live via `hermes-shared-chrome-cdp` (OpenClaw's persistent local Chrome +on port 9222, already logged in as Kaleb). Opened a dedicated CDP tab, hooked +`window.fetch` + `XMLHttpRequest`, navigated to public discover event +`JKQD5kibarjDeBw4LN6W` ("shimmer ✨ at elsewhere"), clicked RSVP → Going → Continue. + +### Callable: `POST https://api.partiful.com/addGuest` + +Request body (Firebase-callable wrapper): +```json +{ + "data": { + "params": { + "eventId": "JKQD5kibarjDeBw4LN6W", + "rsvp": { + "name": "Kaleb Cole", + "count": 1, + "plusOnes": [], + "message": null, + "emailInvitationId": null, + "status": "GOING", + "guestId": null, + "timezone": "America/Los_Angeles", + "password": null + } + }, + "amplitudeDeviceId": "", + "amplitudeSessionId": , + "userId": "" + } +} +``` + +### Key facts + +- **First RSVP:** `guestId: null`. Server creates the guest record. +- **Edit/revert:** `guestId` becomes populated (e.g. `Z8pBamPchdOGNpGQcVoQ`); pass it back on subsequent `addGuest` calls to update the same record. +- **Status values (all through the SAME `addGuest` call):** + - Going → `"GOING"` + - Maybe → `"MAYBE"` (inferred from 3-button UI; not individually captured) + - Can't Go → `"DECLINED"` ✅ (captured on revert) +- **count** = attendee count incl. plus-ones; **plusOnes** = array of names. +- **timezone** = IANA tz string. +- **password** = event password if the event is password-gated (else null). +- **message** = optional public comment posted on the event page. + +### Auth caveat (IMPORTANT for implementation) + +A raw `fetch` to `addGuest` WITHOUT the Firebase ID token returns +`401 UNAUTHENTICATED`. The web app injects a Firebase auth header the fetch hook +did not expose. The CLI already handles this via `src/lib/http.js` (Bearer token, +auto-refresh) — reuse it. Do NOT hand-roll the auth. + +### Companion reads fired alongside RSVP (context, not required for the write) + +`getLastQuestionnaireAnswers`, `recordMetrics` (analytics), then post-write +refresh: `getEventInfo`, `getEventRestrictions`, `getGuests`, `getUsers`. + +## Cleanup performed + +RSVP was created as GOING on a stranger's public event during capture, then +reverted to DECLINED via the UI (native CDP `Input.dispatchMouseEvent` — React +ignored synthetic JS clicks on the status buttons; a real dispatched mouse click +was required). Verified: event page button returned to "😢Can't Go", Kaleb no +longer shows as Going. NOTE: DECLINED still leaves a guest record on the event; +there was no "remove me entirely" affordance in the web flow. Acceptable residue. + +## Deliverable — DONE + +- ✅ Callable name: `addGuest` +- ✅ params shape + Going status (`GOING`) +- ✅ MAYBE / DECLINED go through the same call (status field) +- ✅ Same Bearer auth — reuse `src/lib/http.js` + +Unblocks the GOING half of IMPLEMENT (05) and COMMAND-SHAPE (04). + +## Follow-up: questionnaire shape (VERIFIED live 2026-07-24) + +The original capture left the questionnaire field shape unverified — code +guessed field names and refused questionnaire events as a safe default. Closed +that gap with host-side recon on a throwaway private event (created via CLI, +added one required short-answer question, RSVP'd, then cancelled the event). + +Verified facts (source: __NEXT_DATA__.props.pageProps on the logged-in event page): + +- Event object (present ONLY when a questionnaire exists; keys absent otherwise): + questionnaireEnabled: true + questionnaire: { + createdBy: { id, path }, createdAt, + questions: [ { id: "", type: "short_answer", text, required } ] + } + questionnaireVersions: [ { ...same shape... } ] // append-only history + +- Answer storage (guest object, pageProps.guest.questionnaireResponse): + { questionnaireVersion: , answers: { "": "" } } + The answers map is keyed by QUESTION ID (not text, not index). + +- On write, the answers ride inside the /addGuest `rsvp` object as + `questionnaireResponse` (same shape). + +Implemented in src/lib/rsvp.js: +- eventRequiresQuestionnaire() now keys off questionnaireEnabled + questionnaire.questions[] + (legacy field-name guesses kept as defensive fallback). +- buildQuestionnaireResponse(event, answersByKey) builds the verified response, + keyed by id, accepts answers by id OR text, throws on unanswered REQUIRED + questions (fail-closed). +- buildRsvpParams() attaches questionnaireResponse into the rsvp payload when supplied. +- 10 new unit tests (tests/rsvp.test.js). Full suite: 187/187 passing. + +Cleanup: test event ztL5bpOhID4UfSaKOxXR cancelled (was private, never invited anyone). diff --git a/bin/partiful b/bin/partiful index 8a5bbea..f75ab7a 100755 --- a/bin/partiful +++ b/bin/partiful @@ -1,7 +1,10 @@ #!/usr/bin/env node -// Register the tsx ESM loader so the CLI runs directly from TypeScript -// sources (and JS during the migration) with no separate build step. -import 'tsx/esm'; -import 'dotenv/config'; -import { run } from '../src/cli.js'; +// Register the tsx ESM loader BEFORE importing the (TypeScript) CLI graph, so +// the CLI runs directly from source with no build step. Static imports hoist +// and resolve before any code runs, so cli.js must be pulled in dynamically +// AFTER register() — otherwise its .ts imports fail to resolve. +import { register } from 'tsx/esm/api'; +register(); +await import('dotenv/config'); +const { run } = await import('../src/cli.js'); run(); diff --git a/docs/explore-command-design.md b/docs/explore-command-design.md new file mode 100644 index 0000000..719015a --- /dev/null +++ b/docs/explore-command-design.md @@ -0,0 +1,178 @@ +# `explore` command — design note & stubbed surface + +Status: PROPOSED (COMMAND-SHAPE / ticket 04). React to this, then IMPLEMENT (05). + +Discovery = browsing Partiful's public trending/discover feed and (optionally) +RSVP'ing or expressing interest in events you were never invited to. All reads +and writes reuse the existing Firebase-callable auth in `src/lib/http.js`. + +Wire callables are hidden — the CLI never says `addGuest`. + +| User verb | Hidden callable | +|---|---| +| `explore list` / `explore trending` / `explore regions` | `getDiscoverFeed`, `getDiscoverSections` | +| `explore rsvp` | `addGuest` | +| `explore interested` | `markEventInterest` | + +--- + +## Command surface + +``` +partiful explore [options] + +Browse and RSVP to public Partiful events you weren't invited to. + +Subcommands: + list Browse the discovery feed (default region: nyc) + trending Trending carousels grouped by region + regions List available regions + their tags + rsvp RSVP yourself to a public event (going/maybe/declined) + interested Mark yourself interested (softer than RSVP) +``` + +### `explore list` +``` +partiful explore list [options] + + --region Region: nyc la sf bos dc chi lon mia atx (default: nyc) + --tag Filter by tag (see `explore regions` for valid tags) + --limit Max events to return (default: 20) + --cursor Pagination cursor from a prior page + --format json | table (default: json) +``` + +### `explore trending` +``` +partiful explore trending [options] + + --region Restrict to one region (default: all regions) + --format json | table (default: json) +``` + +### `explore regions` +``` +partiful explore regions [--format json|table] + Lists region slugs + the tag list (id + friendly name) for --tag filtering. +``` + +### `explore rsvp ` +``` +partiful explore rsvp [options] + + --status going | maybe | declined (default: going) + --plus-one Add a named plus-one (repeatable) + --count Total headcount incl. yourself + plus-ones (default: 1) + --message Public comment posted on the event page + -y, --yes Skip the confirmation prompt (agent flows) + --dry-run Print the payload, don't write + + Refuses (exit 4, type unsupported_event) on ticketed / password-gated / + questionnaire events — use the Partiful app for those. +``` + +### `explore interested ` +``` +partiful explore interested [options] + + --remove Remove your interested mark + -y, --yes Skip confirmation + --dry-run Print payload, don't write +``` + +--- + +## Output contracts (JSON is default; table is a view) + +### `explore list` +```json +{ + "region": "nyc", + "tag": null, + "events": [ + { + "id": "JKQD5kibarjDeBw4LN6W", + "title": "shimmer ✨ at elsewhere", + "startDate": "2026-07-25T22:00:00-04:00", + "neighborhood": "Bushwick", + "venue": "Elsewhere", + "host": "nico’s", + "ticketed": true, + "url": "https://partiful.com/e/JKQD5kibarjDeBw4LN6W" + } + ], + "paging": { "nextCursor": "CmYKEg... " }, + "total": 20 +} +``` + +`--format table` columns: `title · start · neighborhood · host · 🎟 · id` +(`🎟` marks ticketed; `id` last so long titles don't push it off-screen). + +### `explore regions` +```json +{ + "regions": [ + { "slug": "nyc", "name": "New York City" }, + { "slug": "la", "name": "Los Angeles" } + ], + "tags": [ + { "id": "DISCOVER_HOME", "name": "For You" }, + { "id": "DISCOVER_MUSIC", "name": "Music" }, + { "id": "DISCOVER_FOOD", "name": "Food & Drink" } + ] +} +``` + +### `explore rsvp` (success) +```json +{ + "eventId": "JKQD5kibarjDeBw4LN6W", + "status": "GOING", + "count": 1, + "plusOnes": [], + "guestId": "Z8pBamPchdOGNpGQcVoQ", + "url": "https://partiful.com/e/JKQD5kibarjDeBw4LN6W" +} +``` + +### `explore rsvp` (refused — ticketed) +```json +{ + "status": "error", + "error": { + "code": 4, + "type": "unsupported_event", + "message": "This event requires tickets/payment. RSVP in the Partiful app." + } +} +``` + +--- + +## Behavior notes for IMPLEMENT + +1. **Statelessness → read-before-write.** CLI won't have a `guestId` on a repeat + run. `explore rsvp` should call `getCurrentGuest {eventId}` first: if a record + exists, pass its `guestId` back to `addGuest` (update); else send + `guestId:null` (create). Same for `--status declined` (revert path). +2. **`addGuest` payload** (built by `wrapPayload`, hidden from user): + `{eventId, rsvp:{name, count, plusOnes[], message, emailInvitationId:null, + status, guestId, timezone, password:null}}`. `name` = `config.displayName`; + `timezone` = config tz (default America/Los_Angeles). +3. **Status mapping:** CLI `going|maybe|declined` → wire `GOING|MAYBE|DECLINED`. +4. **Confirmation gate** (AGENTS.md destructive policy): `rsvp` + `interested` + write to a real host's guest list → prompt unless `-y`. `--dry-run` prints + payload + target endpoint, no write. +5. **Ticketed/password/questionnaire guard:** detect via `getEventInfo` / + `getEventRestrictions` before writing; refuse rather than create a broken + record. (These branches are UNTESTED against `addGuest` — see ticket 02/07.) +6. **File layout:** new `src/commands/explore.js`, `registerExploreCommands`, + one command group, structured `{status, error:{code,type,message}}` errors, + `jsonOutput`/`jsonError` like the rest. + +## Open for Kaleb +- Default region `nyc`, or infer from something? (No location signal in auth; + nyc is the biggest feed. Leaning: default nyc, document it.) +- Expose region **slugs** (`--region nyc`) — friendly names shown in + `explore regions` output. Agree? diff --git a/src/lib/api/endpoints.ts b/src/lib/api/endpoints.ts new file mode 100644 index 0000000..d721c22 --- /dev/null +++ b/src/lib/api/endpoints.ts @@ -0,0 +1,454 @@ +/** + * THE API SPEC (types-as-spec). + * + * One entry per Partiful endpoint the lib layer calls. Each entry co-locates: + * - a fully-typed request interface (§3.5) referencing the shared envelope (§5), + * - a Zod `.passthrough()` response schema + `z.infer` type (§4), + * - an introspectable metadata record (`{ method, host, path, transport, ... }`) + * so T5's `schema api.` can surface the spec from one place. + * + * Typing these IS authoring the API spec — there is no separate spec file to + * drift. See docs/TYPESCRIPT-PORT-GUIDE.md. + */ + +import { z } from 'zod'; +import type { CallableEnvelope, CallableResult } from './envelope.js'; + +// --------------------------------------------------------------------------- +// Transport tags +// --------------------------------------------------------------------------- + +export type Transport = 'firebase-callable' | 'firestore' | 'firebase-auth'; + +/** Metadata common to every spec'd endpoint (introspectable via `schema api.*`). */ +export interface EndpointMeta { + method: string; + host: string; + path: string; + transport: Transport; + /** Names of the request params the endpoint accepts (documentation surface). */ + requestParams: readonly string[]; + /** Known (non-exhaustive) response field names, derived from the Zod schema. */ + responseFields: readonly string[]; +} + +// --------------------------------------------------------------------------- +// Shared internal shapes (plain interfaces — we construct these, §3.4) +// --------------------------------------------------------------------------- + +/** A single event link ({ url, text }). */ +export interface EventLink { + url: string; + text: string; +} + +/** Display settings on an event draft. */ +export interface EventDisplaySettings { + theme: string; + effect: string; + titleFont: string; +} + +/** + * The event object we POST to /createEvent. Built by buildBaseEvent() + + * per-command additions; known fields are enumerated, unknown extension fields + * are permitted (Partiful accepts a broad event shape). + */ +export interface EventDraft { + title: string; + startDate: string; + endDate?: string; + timezone: string; + displaySettings: EventDisplaySettings; + showHostList: boolean; + showGuestCount: boolean; + showGuestList: boolean; + showActivityTimestamps: boolean; + displayInviteButton: boolean; + visibility: 'public' | 'private'; + allowGuestPhotoUpload: boolean; + enableGuestReminders: boolean; + rsvpsEnabled: boolean; + allowGuestsToInviteMutuals: boolean; + rsvpButtonGlyphType: string; + status: string; + guestStatusCounts: Record; + location?: string; + address?: string; + description?: string; + guestLimit?: number; + enableWaitlist?: boolean; + links?: EventLink[]; + image?: unknown; + [extra: string]: unknown; +} + +/** RSVP params ride inside /addGuest under `rsvp`. */ +export interface RsvpDraft { + name: string; + count: number; + plusOnes: string[]; + message: string | null; + emailInvitationId: string | null; + status: string; + guestId: string | null; + timezone: string; + password: string | null; + questionnaireResponse?: { + questionnaireVersion: number; + answers: Record; + }; +} + +// =========================================================================== +// firebase-callable endpoints (POST api.partiful.com) +// =========================================================================== + +const HOST_CALLABLE = 'api.partiful.com'; + +// --- createEvent ----------------------------------------------------------- +export interface CreateEventParams { + event: EventDraft; + cohostIds: string[]; +} +export type CreateEventRequest = CallableEnvelope; +export const CreateEventResponseSchema = z + .object({ + id: z.string(), + title: z.string().optional(), + status: z.string().optional(), + startDate: z.string().optional(), + }) + .passthrough(); +export type CreateEventData = z.infer; +export type CreateEventResponse = CallableResult; + +// --- cancelEvent ----------------------------------------------------------- +export interface CancelEventParams { + eventId: string; +} +export type CancelEventRequest = CallableEnvelope; +export const CancelEventResponseSchema = z.object({}).passthrough(); +export type CancelEventData = z.infer; +export type CancelEventResponse = CallableResult; + +// --- getEventInfo ---------------------------------------------------------- +export interface GetEventInfoParams { + eventId: string; +} +export type GetEventInfoRequest = CallableEnvelope; +export const GetEventInfoResponseSchema = z + .object({ + id: z.string().optional(), + title: z.string().optional(), + startDate: z.string().optional(), + endDate: z.string().nullable().optional(), + location: z.string().nullable().optional(), + status: z.string().optional(), + ownerIds: z.array(z.string()).optional(), + guestStatusCounts: z.record(z.string(), z.number()).optional(), + links: z.array(z.object({ url: z.string(), text: z.string().optional() }).passthrough()).optional(), + }) + .passthrough(); +export type GetEventInfoData = z.infer; +export type GetEventInfoResponse = CallableResult; + +// --- getContacts ----------------------------------------------------------- +export interface GetContactsParams { + // intentionally empty — server reads identity from the token/envelope +} +export type GetContactsRequest = CallableEnvelope; +export const ContactSchema = z + .object({ + userId: z.string().optional(), + name: z.string().optional(), + sharedEventCount: z.number().optional(), + }) + .passthrough(); +/** getContacts returns the contact array directly under result.data. */ +export const GetContactsResponseSchema = z.array(ContactSchema); +export type GetContactsData = z.infer; +export type GetContactsResponse = CallableResult; + +// --- createTextBlast ------------------------------------------------------- +export interface CreateTextBlastParams { + eventId: string; + message: string; + recipientStatuses?: string[]; +} +export type CreateTextBlastRequest = CallableEnvelope; +export const CreateTextBlastResponseSchema = z.object({}).passthrough(); +export type CreateTextBlastData = z.infer; +export type CreateTextBlastResponse = CallableResult; + +// --- addInvitedGuestsAsHost ------------------------------------------------ +export interface AddInvitedGuestsAsHostParams { + eventId: string; + guests: Array>; +} +export type AddInvitedGuestsAsHostRequest = CallableEnvelope; +export const AddInvitedGuestsAsHostResponseSchema = z.object({}).passthrough(); +export type AddInvitedGuestsAsHostData = z.infer; +export type AddInvitedGuestsAsHostResponse = CallableResult; + +// --- getMyUpcomingEventsForHomePage ---------------------------------------- +export interface GetMyUpcomingEventsParams { + // empty params +} +export type GetMyUpcomingEventsRequest = CallableEnvelope; +export const HomePageEventSchema = z + .object({ + id: z.string(), + title: z.string().optional(), + startDate: z.string().optional(), + endDate: z.string().nullable().optional(), + location: z.string().nullable().optional(), + status: z.string().optional(), + ownerIds: z.array(z.string()).optional(), + guest: z.object({ status: z.string().optional() }).passthrough().nullable().optional(), + guestStatusCounts: z.record(z.string(), z.number()).optional(), + }) + .passthrough(); +/** Home-page endpoints return an event array under result.data. */ +export const GetMyUpcomingEventsResponseSchema = z.array(HomePageEventSchema); +export type GetMyUpcomingEventsData = z.infer; +export type GetMyUpcomingEventsResponse = CallableResult; + +// --- getMyPastEventsForHomePage -------------------------------------------- +export interface GetMyPastEventsParams { + // empty params +} +export type GetMyPastEventsRequest = CallableEnvelope; +export const GetMyPastEventsResponseSchema = z.array(HomePageEventSchema); +export type GetMyPastEventsData = z.infer; +export type GetMyPastEventsResponse = CallableResult; + +// --- addGuest (self-RSVP) -------------------------------------------------- +export interface AddGuestParams { + eventId: string; + rsvp: RsvpDraft; +} +export type AddGuestRequest = CallableEnvelope; +export const AddGuestResponseSchema = z + .object({ + guestId: z.string().optional(), + status: z.string().optional(), + }) + .passthrough(); +export type AddGuestData = z.infer; +export type AddGuestResponse = CallableResult; + +// --- markEventInterest ----------------------------------------------------- +export interface MarkEventInterestParams { + eventId: string; + interested: boolean; + source: string; +} +export type MarkEventInterestRequest = CallableEnvelope; +export const MarkEventInterestResponseSchema = z.object({}).passthrough(); +export type MarkEventInterestData = z.infer; +export type MarkEventInterestResponse = CallableResult; + +// --- getCurrentGuest ------------------------------------------------------- +export interface GetCurrentGuestParams { + eventId: string; +} +export type GetCurrentGuestRequest = CallableEnvelope; +export const CurrentGuestSchema = z + .object({ + id: z.string().optional(), + status: z.string().optional(), + name: z.string().optional(), + }) + .passthrough(); +export const GetCurrentGuestResponseSchema = z + .object({ + currentGuest: CurrentGuestSchema.nullable().optional(), + }) + .passthrough(); +export type GetCurrentGuestData = z.infer; +export type GetCurrentGuestResponse = CallableResult; + +// =========================================================================== +// firestore endpoints (GET / PATCH firestore.googleapis.com, doc format) +// =========================================================================== + +const HOST_FIRESTORE = 'firestore.googleapis.com'; + +/** A Firestore document in the typed-value format Partiful returns. */ +export const FirestoreDocumentSchema = z + .object({ + name: z.string().optional(), + fields: z.record(z.string(), z.unknown()).optional(), + createTime: z.string().optional(), + updateTime: z.string().optional(), + }) + .passthrough(); +export type FirestoreDocument = z.infer; + +/** firestore list response ({ documents, nextPageToken }). */ +export const FirestoreListResponseSchema = z + .object({ + documents: z.array(FirestoreDocumentSchema).optional(), + nextPageToken: z.string().optional(), + }) + .passthrough(); +export type FirestoreListResponse = z.infer; + +// =========================================================================== +// firebase-auth endpoint (POST securetoken.googleapis.com) +// =========================================================================== + +const HOST_AUTH = 'securetoken.googleapis.com'; + +export interface RefreshTokenRequest { + grant_type: 'refresh_token'; + refresh_token: string; +} +export const RefreshTokenResponseSchema = z + .object({ + id_token: z.string().optional(), + refresh_token: z.string().optional(), + expires_in: z.string().optional(), + user_id: z.string().optional(), + error: z.object({ message: z.string().optional() }).passthrough().optional(), + }) + .passthrough(); +export type RefreshTokenResponse = z.infer; + +// =========================================================================== +// Introspectable endpoint registry — surfaced by `schema api.` (T5) +// =========================================================================== + +function fieldsOf(schema: z.ZodTypeAny): readonly string[] { + // Best-effort: enumerate object keys for object schemas; arrays expose their + // element's keys; everything else has no enumerable field surface. + const def = schema as unknown as { shape?: Record }; + if (def.shape && typeof def.shape === 'object') return Object.keys(def.shape); + return []; +} + +export const apiEndpoints = { + createEvent: { + method: 'POST', + host: HOST_CALLABLE, + path: '/createEvent', + transport: 'firebase-callable', + requestParams: ['event', 'cohostIds'], + responseFields: fieldsOf(CreateEventResponseSchema), + }, + cancelEvent: { + method: 'POST', + host: HOST_CALLABLE, + path: '/cancelEvent', + transport: 'firebase-callable', + requestParams: ['eventId'], + responseFields: fieldsOf(CancelEventResponseSchema), + }, + getEventInfo: { + method: 'POST', + host: HOST_CALLABLE, + path: '/getEventInfo', + transport: 'firebase-callable', + requestParams: ['eventId'], + responseFields: fieldsOf(GetEventInfoResponseSchema), + }, + getContacts: { + method: 'POST', + host: HOST_CALLABLE, + path: '/getContacts', + transport: 'firebase-callable', + requestParams: [], + responseFields: fieldsOf(ContactSchema), + }, + createTextBlast: { + method: 'POST', + host: HOST_CALLABLE, + path: '/createTextBlast', + transport: 'firebase-callable', + requestParams: ['eventId', 'message', 'recipientStatuses'], + responseFields: fieldsOf(CreateTextBlastResponseSchema), + }, + addInvitedGuestsAsHost: { + method: 'POST', + host: HOST_CALLABLE, + path: '/addInvitedGuestsAsHost', + transport: 'firebase-callable', + requestParams: ['eventId', 'guests'], + responseFields: fieldsOf(AddInvitedGuestsAsHostResponseSchema), + }, + getMyUpcomingEventsForHomePage: { + method: 'POST', + host: HOST_CALLABLE, + path: '/getMyUpcomingEventsForHomePage', + transport: 'firebase-callable', + requestParams: [], + responseFields: fieldsOf(HomePageEventSchema), + }, + getMyPastEventsForHomePage: { + method: 'POST', + host: HOST_CALLABLE, + path: '/getMyPastEventsForHomePage', + transport: 'firebase-callable', + requestParams: [], + responseFields: fieldsOf(HomePageEventSchema), + }, + addGuest: { + method: 'POST', + host: HOST_CALLABLE, + path: '/addGuest', + transport: 'firebase-callable', + requestParams: ['eventId', 'rsvp'], + responseFields: fieldsOf(AddGuestResponseSchema), + }, + markEventInterest: { + method: 'POST', + host: HOST_CALLABLE, + path: '/markEventInterest', + transport: 'firebase-callable', + requestParams: ['eventId', 'interested', 'source'], + responseFields: fieldsOf(MarkEventInterestResponseSchema), + }, + getCurrentGuest: { + method: 'POST', + host: HOST_CALLABLE, + path: '/getCurrentGuest', + transport: 'firebase-callable', + requestParams: ['eventId'], + responseFields: fieldsOf(GetCurrentGuestResponseSchema), + }, + firestoreGetEvent: { + method: 'GET', + host: HOST_FIRESTORE, + path: '/v1/projects/getpartiful/databases/(default)/documents/events/{eventId}', + transport: 'firestore', + requestParams: ['eventId'], + responseFields: fieldsOf(FirestoreDocumentSchema), + }, + firestorePatchEvent: { + method: 'PATCH', + host: HOST_FIRESTORE, + path: '/v1/projects/getpartiful/databases/(default)/documents/events/{eventId}', + transport: 'firestore', + requestParams: ['eventId', 'fields', 'updateMask.fieldPaths'], + responseFields: fieldsOf(FirestoreDocumentSchema), + }, + firestoreListDocuments: { + method: 'GET', + host: HOST_FIRESTORE, + path: '/v1/projects/getpartiful/databases/(default)/documents/{collectionPath}', + transport: 'firestore', + requestParams: ['collectionPath', 'pageSize', 'pageToken'], + responseFields: fieldsOf(FirestoreListResponseSchema), + }, + refreshToken: { + method: 'POST', + host: HOST_AUTH, + path: '/v1/token', + transport: 'firebase-auth', + requestParams: ['grant_type', 'refresh_token'], + responseFields: fieldsOf(RefreshTokenResponseSchema), + }, +} as const satisfies Record; + +export type ApiMethod = keyof typeof apiEndpoints; diff --git a/src/lib/api/envelope.ts b/src/lib/api/envelope.ts new file mode 100644 index 0000000..fd70560 --- /dev/null +++ b/src/lib/api/envelope.ts @@ -0,0 +1,25 @@ +/** + * Shared Firebase-callable RPC request/response envelopes. + * + * Every firebase-callable endpoint (POST api.partiful.com) wraps its params in + * the SAME shape. This is that shape, specified ONCE as a generic and referenced + * per-endpoint — never re-inlined. See docs/TYPESCRIPT-PORT-GUIDE.md §5. + */ + +/** The Firebase-callable RPC request envelope. `P` = the endpoint's params shape. */ +export interface CallableEnvelope

{ + data: { + params: P; + /** Device fingerprint sent on every call. */ + amplitudeDeviceId: string; + /** Present on identity-scoped calls (rsvp, cohosts, contacts). */ + amplitudeSessionId?: number; + /** Backfilled from the Firebase JWT; may be null on legacy auth files. */ + userId?: string | null; + }; +} + +/** Firebase-callable responses nest the real payload under `result.data`. */ +export interface CallableResult { + result?: { data?: D }; +} diff --git a/src/lib/errors.js b/src/lib/errors.js deleted file mode 100644 index 3d27441..0000000 --- a/src/lib/errors.js +++ /dev/null @@ -1,29 +0,0 @@ -import { EXIT } from './output.js'; - -export class PartifulError extends Error { - constructor(message, exitCode, type, details = null) { - super(message); - this.exitCode = exitCode; - this.type = type; - this.details = details; - } - toJSON() { - return { - code: this.exitCode, type: this.type, message: this.message, - ...(this.details ? { details: this.details } : {}), - }; - } -} - -export class ApiError extends PartifulError { - constructor(message, details) { super(message, EXIT.API_ERROR, 'api_error', details); } -} -export class AuthError extends PartifulError { - constructor(message, details) { super(message, EXIT.AUTH_ERROR, 'auth_error', details); } -} -export class ValidationError extends PartifulError { - constructor(message, details) { super(message, EXIT.VALIDATION_ERROR, 'validation_error', details); } -} -export class NotFoundError extends PartifulError { - constructor(message, details) { super(message, EXIT.NOT_FOUND, 'not_found', details); } -} diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..84ebc90 --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,52 @@ +import { EXIT } from './output.js'; + +/** Serialized error payload shape emitted by PartifulError.toJSON(). */ +export interface PartifulErrorJSON { + code: number; + type: string; + message: string; + details?: unknown; +} + +export class PartifulError extends Error { + readonly exitCode: number; + readonly type: string; + readonly details: unknown; + + constructor(message: string, exitCode: number, type: string, details: unknown = null) { + super(message); + this.exitCode = exitCode; + this.type = type; + this.details = details; + } + + toJSON(): PartifulErrorJSON { + return { + code: this.exitCode, + type: this.type, + message: this.message, + ...(this.details ? { details: this.details } : {}), + }; + } +} + +export class ApiError extends PartifulError { + constructor(message: string, details?: unknown) { + super(message, EXIT.API_ERROR, 'api_error', details); + } +} +export class AuthError extends PartifulError { + constructor(message: string, details?: unknown) { + super(message, EXIT.AUTH_ERROR, 'auth_error', details); + } +} +export class ValidationError extends PartifulError { + constructor(message: string, details?: unknown) { + super(message, EXIT.VALIDATION_ERROR, 'validation_error', details); + } +} +export class NotFoundError extends PartifulError { + constructor(message: string, details?: unknown) { + super(message, EXIT.NOT_FOUND, 'not_found', details); + } +} diff --git a/src/lib/http.js b/src/lib/http.ts similarity index 54% rename from src/lib/http.js rename to src/lib/http.ts index 7eeea44..a131186 100644 --- a/src/lib/http.js +++ b/src/lib/http.ts @@ -12,7 +12,11 @@ const FIRESTORE_PROJECT = 'getpartiful'; const RETRYABLE_CODES = new Set([429, 500, 502, 503, 504]); const MAX_RETRIES = parseInt(process.env.PARTIFUL_MAX_RETRIES || '3', 10); -function classifyError(statusCode, message, body) { +function classifyError( + statusCode: number, + message: string, + body?: unknown, +): AuthError | NotFoundError | ApiError { if (statusCode === 401 || statusCode === 403) { return new AuthError(message || `Auth failed (${statusCode})`, { statusCode, body }); } @@ -22,8 +26,8 @@ function classifyError(statusCode, message, body) { return new ApiError(message || `API error (${statusCode})`, { statusCode, body }); } -async function withRetry(fn, verbose = false) { - let lastError; +async function withRetry(fn: () => Promise, verbose = false): Promise { + let lastError: Response | Error | undefined; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { try { const resp = await fn(); @@ -40,39 +44,47 @@ async function withRetry(fn, verbose = false) { ? Math.min(30, parseFloat(retryAfter)) * 1000 : Math.min(30000, (2 ** attempt + Math.random()) * 1000); if (verbose) console.error(`Retry ${attempt + 1}/${MAX_RETRIES} after ${Math.round(delay)}ms...`); - await new Promise(r => setTimeout(r, delay)); + await new Promise((r) => setTimeout(r, delay)); } } catch (err) { - lastError = err; + lastError = err as Error; if (attempt < MAX_RETRIES) { const delay = Math.min(30000, (2 ** attempt + Math.random()) * 1000); - await new Promise(r => setTimeout(r, delay)); + await new Promise((r) => setTimeout(r, delay)); } } } // Exhausted retries - if (lastError instanceof Response || (lastError && lastError.status)) { - const body = await lastError.text().catch(() => ''); - throw classifyError(lastError.status, `Request failed after ${MAX_RETRIES} retries`, body); + if (lastError instanceof Response || (lastError && 'status' in lastError)) { + const failed = lastError as Response; + const body = await failed.text().catch(() => ''); + throw classifyError(failed.status, `Request failed after ${MAX_RETRIES} retries`, body); } throw lastError instanceof Error ? lastError : new ApiError('Request failed after retries'); } -export async function apiRequest(method, endpoint, token, body = null, verbose = false) { - const resp = await withRetry(() => - fetch(`${API_BASE}${endpoint}`, { - method, - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - 'Accept': 'application/json, text/plain, */*', - 'Origin': 'https://partiful.com', - 'Referer': 'https://partiful.com/', - }, - ...(body ? { body: JSON.stringify(body) } : {}), - }), - verbose +export async function apiRequest( + method: string, + endpoint: string, + token: string, + body: unknown = null, + verbose = false, +): Promise { + const resp = await withRetry( + () => + fetch(`${API_BASE}${endpoint}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Accept: 'application/json, text/plain, */*', + Origin: 'https://partiful.com', + Referer: 'https://partiful.com/', + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }), + verbose, ); if (!resp.ok) { @@ -84,23 +96,31 @@ export async function apiRequest(method, endpoint, token, body = null, verbose = return text ? JSON.parse(text) : {}; } -export async function firestoreRequest(method, eventId, body, token, updateFields = [], verbose = false) { +export async function firestoreRequest( + method: string, + eventId: string, + body: unknown, + token: string, + updateFields: string[] = [], + verbose = false, +): Promise { let fsPath = `/v1/projects/${FIRESTORE_PROJECT}/databases/(default)/documents/events/${eventId}`; if (method === 'PATCH' && updateFields.length > 0) { - fsPath += '?' + updateFields.map(f => `updateMask.fieldPaths=${f}`).join('&'); + fsPath += '?' + updateFields.map((f) => `updateMask.fieldPaths=${f}`).join('&'); } - const resp = await withRetry(() => - fetch(`${FIRESTORE_BASE}${fsPath}`, { - method, - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - 'Referer': 'https://partiful.com/', - }, - ...(body ? { body: JSON.stringify(body) } : {}), - }), - verbose + const resp = await withRetry( + () => + fetch(`${FIRESTORE_BASE}${fsPath}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Referer: 'https://partiful.com/', + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }), + verbose, ); if (!resp.ok) { @@ -112,19 +132,26 @@ export async function firestoreRequest(method, eventId, body, token, updateField return text ? JSON.parse(text) : {}; } -export async function firestoreListDocuments(collectionPath, token, pageSize = 100, pageToken = null, verbose = false) { +export async function firestoreListDocuments( + collectionPath: string, + token: string, + pageSize = 100, + pageToken: string | null = null, + verbose = false, +): Promise { let fsPath = `/v1/projects/${FIRESTORE_PROJECT}/databases/(default)/documents/${collectionPath}?pageSize=${pageSize}`; if (pageToken) fsPath += `&pageToken=${encodeURIComponent(pageToken)}`; - const resp = await withRetry(() => - fetch(`${FIRESTORE_BASE}${fsPath}`, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${token}`, - 'Referer': 'https://partiful.com/', - }, - }), - verbose + const resp = await withRetry( + () => + fetch(`${FIRESTORE_BASE}${fsPath}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Referer: 'https://partiful.com/', + }, + }), + verbose, ); if (!resp.ok) { diff --git a/src/lib/output.js b/src/lib/output.js deleted file mode 100644 index 8537c2c..0000000 --- a/src/lib/output.js +++ /dev/null @@ -1,49 +0,0 @@ -import fs from 'fs'; - -export function jsonOutput(data, metadata = {}, opts = {}) { - const envelope = { status: 'success', data, metadata }; - const json = JSON.stringify(envelope); - if (opts.output) { - fs.writeFileSync(opts.output, json + '\n'); - } else { - process.stdout.write(json + '\n'); - } -} - -export function jsonError(message, code = 5, type = 'internal_error', details = null) { - const envelope = { - status: 'error', - error: { code, type, message, ...(details ? { details } : {}) } - }; - process.stdout.write(JSON.stringify(envelope) + '\n'); - process.exit(code); -} - -export function formatTable(rows, columns) { - if (!rows || rows.length === 0) return '(no results)'; - const widths = columns.map(col => - Math.max(col.length, ...rows.map(r => String(r[col] ?? '').length)) - ); - const header = columns.map((col, i) => col.padEnd(widths[i])).join(' '); - const sep = widths.map(w => '─'.repeat(w)).join('──'); - const body = rows.map(r => - columns.map((col, i) => String(r[col] ?? '').padEnd(widths[i])).join(' ') - ).join('\n'); - return `${header}\n${sep}\n${body}`; -} - -export function formatCsv(rows, columns) { - const escape = (v) => { - const s = String(v ?? ''); - return s.includes(',') || s.includes('"') || s.includes('\n') - ? `"${s.replace(/"/g, '""')}"` : s; - }; - const header = columns.map(escape).join(','); - const body = rows.map(r => columns.map(col => escape(r[col])).join(',')).join('\n'); - return `${header}\n${body}`; -} - -export const EXIT = { - SUCCESS: 0, API_ERROR: 1, AUTH_ERROR: 2, - VALIDATION_ERROR: 3, NOT_FOUND: 4, INTERNAL_ERROR: 5, -}; diff --git a/src/lib/output.ts b/src/lib/output.ts new file mode 100644 index 0000000..27793b2 --- /dev/null +++ b/src/lib/output.ts @@ -0,0 +1,74 @@ +import fs from 'fs'; + +/** Structured JSON output envelope emitted on success. */ +export interface OutputOptions { + output?: string; + [key: string]: unknown; +} + +export function jsonOutput( + data: unknown, + metadata: Record = {}, + opts: OutputOptions = {}, +): void { + const envelope = { status: 'success', data, metadata }; + const json = JSON.stringify(envelope); + if (opts.output) { + fs.writeFileSync(opts.output, json + '\n'); + } else { + process.stdout.write(json + '\n'); + } +} + +export function jsonError( + message: string, + code = 5, + type = 'internal_error', + details: unknown = null, +): never { + const envelope = { + status: 'error', + error: { code, type, message, ...(details ? { details } : {}) }, + }; + process.stdout.write(JSON.stringify(envelope) + '\n'); + process.exit(code); +} + +/** A row is a record of column name -> printable cell value. */ +export type TableRow = Record; + +export function formatTable(rows: TableRow[], columns: string[]): string { + if (!rows || rows.length === 0) return '(no results)'; + const widths = columns.map((col) => + Math.max(col.length, ...rows.map((r) => String(r[col] ?? '').length)), + ); + const header = columns.map((col, i) => col.padEnd(widths[i]!)).join(' '); + const sep = widths.map((w) => '─'.repeat(w)).join('──'); + const body = rows + .map((r) => + columns.map((col, i) => String(r[col] ?? '').padEnd(widths[i]!)).join(' '), + ) + .join('\n'); + return `${header}\n${sep}\n${body}`; +} + +export function formatCsv(rows: TableRow[], columns: string[]): string { + const escape = (v: unknown): string => { + const s = String(v ?? ''); + return s.includes(',') || s.includes('"') || s.includes('\n') + ? `"${s.replace(/"/g, '""')}"` + : s; + }; + const header = columns.map(escape).join(','); + const body = rows.map((r) => columns.map((col) => escape(r[col])).join(',')).join('\n'); + return `${header}\n${body}`; +} + +export const EXIT = { + SUCCESS: 0, + API_ERROR: 1, + AUTH_ERROR: 2, + VALIDATION_ERROR: 3, + NOT_FOUND: 4, + INTERNAL_ERROR: 5, +} as const; From 44ea52a306998c110b63d6fddb61bab7e4b1194b Mon Sep 17 00:00:00 2001 From: Kaleb Cole Date: Fri, 24 Jul 2026 15:00:17 -0700 Subject: [PATCH 04/14] T3: port remaining src/lib/ to TS strict (dates, templates, posters, auth, cohosts, upload, rsvp, events) All 8 remaining lib modules ported. Wired into the api/ spec types (EventDraft, RsvpDraft, PartifulConfig, endpoint request/response types). tsc --noEmit clean; 195/195 tests green. src/lib/ is now 100% TypeScript. --- src/lib/{auth.js => auth.ts} | 72 +++++++---- src/lib/cohosts.js | 62 --------- src/lib/cohosts.ts | 93 ++++++++++++++ src/lib/{dates.js => dates.ts} | 34 +++-- src/lib/{events.js => events.ts} | 171 +++++++++++++++++-------- src/lib/{posters.js => posters.ts} | 51 ++++++-- src/lib/{rsvp.js => rsvp.ts} | 164 +++++++++++++----------- src/lib/{templates.js => templates.ts} | 53 +++++--- src/lib/{upload.js => upload.ts} | 65 ++++++++-- 9 files changed, 501 insertions(+), 264 deletions(-) rename src/lib/{auth.js => auth.ts} (63%) delete mode 100644 src/lib/cohosts.js create mode 100644 src/lib/cohosts.ts rename src/lib/{dates.js => dates.ts} (76%) rename src/lib/{events.js => events.ts} (57%) rename src/lib/{posters.js => posters.ts} (57%) rename src/lib/{rsvp.js => rsvp.ts} (66%) rename src/lib/{templates.js => templates.ts} (56%) rename src/lib/{upload.js => upload.ts} (72%) diff --git a/src/lib/auth.js b/src/lib/auth.ts similarity index 63% rename from src/lib/auth.js rename to src/lib/auth.ts index 05aec80..1dea839 100644 --- a/src/lib/auth.js +++ b/src/lib/auth.ts @@ -6,15 +6,38 @@ import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; +import type { RefreshTokenResponse } from './api/endpoints.js'; const GOOGLE_TOKEN_URL = 'securetoken.googleapis.com'; -export function resolveCredentialsPath() { - return process.env.PARTIFUL_CREDENTIALS_FILE - || path.join(process.env.HOME, '.config/partiful/auth.json'); +/** On-disk auth config. Mutated in place by getValidToken() then persisted. */ +export interface PartifulConfig { + accessToken?: string; + tokenExpiry?: number; + refreshToken?: string; + apiKey?: string; + userId?: string | null; + amplitudeDeviceId?: string; + name?: string; + uploadTimeoutMs?: number; + [extra: string]: unknown; } -export function loadConfig() { +/** Decoded Firebase JWT payload (identity claims only; signature unverified). */ +export interface JwtPayload { + user_id?: string; + sub?: string; + [claim: string]: unknown; +} + +export function resolveCredentialsPath(): string { + return ( + process.env.PARTIFUL_CREDENTIALS_FILE || + path.join(process.env.HOME as string, '.config/partiful/auth.json') + ); +} + +export function loadConfig(): PartifulConfig { // Check env var for direct token if (process.env.PARTIFUL_TOKEN) { return { accessToken: process.env.PARTIFUL_TOKEN, tokenExpiry: Date.now() + 3600000 }; @@ -27,7 +50,7 @@ export function loadConfig() { return JSON.parse(fs.readFileSync(configPath, 'utf8')); } -export function saveConfig(config) { +export function saveConfig(config: PartifulConfig): void { const configPath = resolveCredentialsPath(); const configDir = path.dirname(configPath); if (!fs.existsSync(configDir)) { @@ -36,26 +59,26 @@ export function saveConfig(config) { fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); } -export async function refreshAccessToken(config) { +export async function refreshAccessToken(config: PartifulConfig): Promise { const postData = `grant_type=refresh_token&refresh_token=${config.refreshToken}`; const resp = await fetch(`https://${GOOGLE_TOKEN_URL}/v1/token?key=${config.apiKey}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', - 'Referer': 'https://partiful.com/' + Referer: 'https://partiful.com/', }, body: postData, }); - const result = await resp.json(); + const result = (await resp.json()) as RefreshTokenResponse; if (result.error) { throw new Error(result.error.message || 'Token refresh failed'); } return result; } -export async function getValidToken(config) { +export async function getValidToken(config: PartifulConfig): Promise { if (config.accessToken && config.tokenExpiry) { const now = Date.now(); if (now < config.tokenExpiry - 60000) { @@ -65,7 +88,7 @@ export async function getValidToken(config) { const result = await refreshAccessToken(config); config.accessToken = result.id_token; - config.tokenExpiry = Date.now() + (parseInt(result.expires_in) * 1000); + config.tokenExpiry = Date.now() + parseInt(result.expires_in ?? '0') * 1000; if (result.refresh_token) { config.refreshToken = result.refresh_token; @@ -76,12 +99,12 @@ export async function getValidToken(config) { // host detection and any userId-dependent payloads work without re-login. // Note: the env-var token path returns early above and never writes to disk. if (!config.userId) { - const uid = getUserIdFromToken(config.accessToken); + const uid = getUserIdFromToken(config.accessToken!); if (uid) config.userId = uid; } saveConfig(config); - return config.accessToken; + return config.accessToken!; } /** @@ -90,17 +113,14 @@ export async function getValidToken(config) { * The CLI does not need to verify the signature — the token was already issued * to us by Firebase and is only decoded to read identity claims. Returns null * for anything that is not a well-formed three-segment JWT. - * - * @param {string} token JWT string (header.payload.signature). - * @returns {object|null} Decoded payload object, or null on any parse failure. */ -export function decodeJwtPayload(token) { +export function decodeJwtPayload(token: string): JwtPayload | null { if (typeof token !== 'string') return null; const parts = token.split('.'); if (parts.length !== 3) return null; try { // JWTs use base64url; normalise to base64 before decoding. - const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const b64 = parts[1]!.replace(/-/g, '+').replace(/_/g, '/'); return JSON.parse(Buffer.from(b64, 'base64').toString('utf8')); } catch { return null; @@ -111,23 +131,29 @@ export function decodeJwtPayload(token) { * Extract the authenticated user's Partiful user ID from a Firebase token. * Firebase ID tokens carry the UID in both `user_id` and the standard `sub` * claim; we prefer `user_id` and fall back to `sub`. - * - * @param {string} token Firebase JWT. - * @returns {string|null} The user ID, or null if it cannot be determined. */ -export function getUserIdFromToken(token) { +export function getUserIdFromToken(token: string): string | null { const payload = decodeJwtPayload(token); if (!payload || typeof payload !== 'object') return null; return payload.user_id || payload.sub || null; } -export function wrapPayload(config, params = {}) { +/** The wrapped payload passed as `data` in a firebase-callable envelope. */ +export interface WrappedPayload { + amplitudeDeviceId: string; + [key: string]: unknown; +} + +export function wrapPayload( + config: PartifulConfig, + params: Record = {}, +): WrappedPayload { return { ...params, amplitudeDeviceId: config.amplitudeDeviceId || generateAmplitudeDeviceId(), }; } -export function generateAmplitudeDeviceId() { +export function generateAmplitudeDeviceId(): string { return crypto.randomBytes(12).toString('base64').replace(/[+/=]/g, ''); } diff --git a/src/lib/cohosts.js b/src/lib/cohosts.js deleted file mode 100644 index aadc316..0000000 --- a/src/lib/cohosts.js +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Shared co-host helpers: contact resolution, Firestore read/write. - */ - -import { apiRequest, firestoreRequest } from './http.js'; -import { wrapPayload } from './auth.js'; - -/** - * Resolve co-host names to Partiful user IDs via the contacts API. - * Tries exact match first, then substring. Warns on stderr for misses. - * @returns {string[]} resolved user IDs - */ -export async function resolveCohostNames(names, token, config, verbose = false) { - if (!names || names.length === 0) return []; - - const payload = { - data: wrapPayload(config, { - params: {}, - amplitudeSessionId: Date.now(), - userId: config.userId, - }), - }; - const result = await apiRequest('POST', '/getContacts', token, payload, verbose); - const contacts = result.result?.data || []; - - const ids = []; - for (const name of names) { - const q = name.toLowerCase(); - const match = - contacts.find(c => (c.name || '').toLowerCase() === q) || - contacts.find(c => (c.name || '').toLowerCase().includes(q)); - if (match?.userId) { - if (!ids.includes(match.userId)) ids.push(match.userId); - } else { - process.stderr.write(`Warning: could not resolve co-host "${name}" from contacts — skipping\n`); - } - } - return ids; -} - -/** - * Read cohostIds array from a Firestore event document. - * @returns {string[]} - */ -export async function getCohostIds(eventId, token, verbose = false) { - const doc = await firestoreRequest('GET', eventId, null, token, [], verbose); - const values = doc.fields?.cohostIds?.arrayValue?.values || []; - return values.map(v => v.stringValue).filter(Boolean); -} - -/** - * Write cohostIds array to a Firestore event document. - */ -export async function setCohostIds(eventId, ids, token, verbose = false) { - const unique = [...new Set(ids.filter(Boolean))]; - const fields = { - cohostIds: { - arrayValue: { values: unique.map(id => ({ stringValue: id })) }, - }, - }; - await firestoreRequest('PATCH', eventId, { fields }, token, ['cohostIds'], verbose); -} diff --git a/src/lib/cohosts.ts b/src/lib/cohosts.ts new file mode 100644 index 0000000..1dded79 --- /dev/null +++ b/src/lib/cohosts.ts @@ -0,0 +1,93 @@ +/** + * Shared co-host helpers: contact resolution, Firestore read/write. + */ + +import { apiRequest, firestoreRequest } from './http.js'; +import { wrapPayload } from './auth.js'; +import type { PartifulConfig } from './auth.js'; +import type { GetContactsData } from './api/endpoints.js'; + +/** A firebase-callable result wrapping the getContacts array. */ +interface GetContactsEnvelope { + result?: { data?: GetContactsData }; +} + +/** + * Resolve co-host names to Partiful user IDs via the contacts API. + * Tries exact match first, then substring. Warns on stderr for misses. + * @returns resolved user IDs + */ +export async function resolveCohostNames( + names: string[], + token: string, + config: PartifulConfig, + verbose = false, +): Promise { + if (!names || names.length === 0) return []; + + const payload = { + data: wrapPayload(config, { + params: {}, + amplitudeSessionId: Date.now(), + userId: config.userId, + }), + }; + const result = (await apiRequest('POST', '/getContacts', token, payload, verbose)) as GetContactsEnvelope; + const contacts = result.result?.data || []; + + const ids: string[] = []; + for (const name of names) { + const q = name.toLowerCase(); + const match = + contacts.find((c) => (c.name || '').toLowerCase() === q) || + contacts.find((c) => (c.name || '').toLowerCase().includes(q)); + if (match?.userId) { + if (!ids.includes(match.userId)) ids.push(match.userId); + } else { + process.stderr.write(`Warning: could not resolve co-host "${name}" from contacts — skipping\n`); + } + } + return ids; +} + +/** A Firestore event doc, narrowed to the cohostIds array field we read. */ +interface FirestoreEventDoc { + fields?: { + cohostIds?: { + arrayValue?: { + values?: Array<{ stringValue?: string }>; + }; + }; + }; +} + +/** + * Read cohostIds array from a Firestore event document. + */ +export async function getCohostIds( + eventId: string, + token: string, + verbose = false, +): Promise { + const doc = (await firestoreRequest('GET', eventId, null, token, [], verbose)) as FirestoreEventDoc; + const values = doc.fields?.cohostIds?.arrayValue?.values || []; + return values.map((v) => v.stringValue).filter((v): v is string => Boolean(v)); +} + +/** + * Write cohostIds array to a Firestore event document. + */ +export async function setCohostIds( + eventId: string, + ids: string[], + token: string, + verbose = false, +): Promise { + const unique = [...new Set(ids.filter(Boolean))]; + const fields = { + cohostIds: { + arrayValue: { values: unique.map((id) => ({ stringValue: id })) }, + }, + }; + await firestoreRequest('PATCH', eventId, { fields }, token, ['cohostIds'], verbose); +} diff --git a/src/lib/dates.js b/src/lib/dates.ts similarity index 76% rename from src/lib/dates.js rename to src/lib/dates.ts index 448ccad..947e297 100644 --- a/src/lib/dates.js +++ b/src/lib/dates.ts @@ -8,7 +8,14 @@ * displays correctly on their end, but the UTC instant may differ slightly. */ -export function parseDateTime(dateStr, timezone = 'America/Los_Angeles') { +/** Parsed hour/minute pair from a time string. */ +export interface ParsedTime { + hours: number; + minutes: number; +} + +export function parseDateTime(dateStr: string, timezone = 'America/Los_Angeles'): Date { + void timezone; // accepted for API parity; see module note on local-tz construction const lower = dateStr.trim().toLowerCase(); const now = new Date(); @@ -22,7 +29,7 @@ export function parseDateTime(dateStr, timezone = 'America/Los_Angeles') { const nextDayMatch = lower.match(/^next\s+(sunday|monday|tuesday|wednesday|thursday|friday|saturday)(?:\s+(.+))?$/i); if (nextDayMatch) { const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; - const targetDay = dayNames.indexOf(nextDayMatch[1].toLowerCase()); + const targetDay = dayNames.indexOf(nextDayMatch[1]!.toLowerCase()); const d = new Date(now); let daysAhead = targetDay - d.getDay(); if (daysAhead <= 0) daysAhead += 7; @@ -64,10 +71,10 @@ export function parseDateTime(dateStr, timezone = 'America/Los_Angeles') { return date; } -export function parseTimeString(str) { +export function parseTimeString(str: string): ParsedTime | null { const match = str.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/i); if (!match) return null; - let hours = parseInt(match[1]); + let hours = parseInt(match[1]!); const minutes = parseInt(match[2] || '0'); const ampm = match[3]?.toLowerCase(); if (ampm === 'pm' && hours < 12) hours += 12; @@ -75,26 +82,26 @@ export function parseTimeString(str) { return { hours, minutes }; } -export function hasExplicitYear(dateStr) { +export function hasExplicitYear(dateStr: string): boolean { return /\b20\d{2}\b/.test(dateStr); } -export function needsYearFix(dateStr, date) { +export function needsYearFix(dateStr: string, date: Date): boolean { if (hasExplicitYear(dateStr)) return false; const currentYear = new Date().getFullYear(); return date.getFullYear() < currentYear || date.getFullYear() > currentYear + 1; } -export function tryAddYear(dateStr, now) { +export function tryAddYear(dateStr: string, now: Date): string { const year = now.getFullYear(); const timeMatch = dateStr.match(/^(.+?)(\d{1,2}(?::\d{2})?\s*(?:am|pm).*)$/i); if (timeMatch) { - return `${timeMatch[1].trim()} ${year} ${timeMatch[2].trim()}`; + return `${timeMatch[1]!.trim()} ${year} ${timeMatch[2]!.trim()}`; } return `${dateStr} ${year}`; } -export function stripMarkdown(text) { +export function stripMarkdown(text: string): string { if (!text) return text; return text .replace(/\*\*(.*?)\*\*/g, '$1') @@ -106,10 +113,13 @@ export function stripMarkdown(text) { .replace(/>\s+/g, ''); } -export function formatDate(isoStr) { +export function formatDate(isoStr: string): string { const d = new Date(isoStr); return d.toLocaleDateString('en-US', { - weekday: 'short', month: 'short', day: 'numeric', - hour: 'numeric', minute: '2-digit' + weekday: 'short', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', }); } diff --git a/src/lib/events.js b/src/lib/events.ts similarity index 57% rename from src/lib/events.js rename to src/lib/events.ts index 825939d..b01929f 100644 --- a/src/lib/events.js +++ b/src/lib/events.ts @@ -6,11 +6,14 @@ import readline from 'readline'; import { parseDateTime, stripMarkdown } from './dates.js'; import { NotFoundError, ValidationError } from './errors.js'; +import type { EventDraft, EventLink } from './api/endpoints.js'; +import type { PartifulConfig } from './auth.js'; +import type { Poster, PosterImage } from './posters.js'; /** * Default guest status counts for new events. */ -export const DEFAULT_GUEST_STATUS_COUNTS = { +export const DEFAULT_GUEST_STATUS_COUNTS: Record = { READY_TO_SEND: 0, SENDING: 0, SENT: 0, SEND_ERROR: 0, DELIVERY_ERROR: 0, INTERESTED: 0, MAYBE: 0, GOING: 0, DECLINED: 0, WAITLIST: 0, PENDING_APPROVAL: 0, APPROVED: 0, @@ -21,10 +24,10 @@ export const DEFAULT_GUEST_STATUS_COUNTS = { /** * Prompt user for yes/no confirmation on stderr. */ -export async function confirm(question) { +export async function confirm(question: string): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); - return new Promise(resolve => { - rl.question(question + ' [y/N]: ', answer => { + return new Promise((resolve) => { + rl.question(question + ' [y/N]: ', (answer) => { rl.close(); resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); }); @@ -39,18 +42,44 @@ export const ALLOWED_IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp /** * Check if a string is an HTTP(S) URL. */ -export function isUrl(str) { - return str && (str.startsWith('http://') || str.startsWith('https://')); +export function isUrl(str: string): boolean { + return !!str && (str.startsWith('http://') || str.startsWith('https://')); +} + +/** CLI options accepted by buildBaseEvent() and friends. */ +export interface EventOptions { + title: string; + date: string; + endDate?: string; + timezone?: string; + theme?: string; + effect?: string; + titleFont?: string; + private?: boolean; + location?: string; + address?: string; + description?: string; + capacity?: number; + poster?: string; + posterSearch?: string; + [extra: string]: unknown; +} + +/** The return shape of buildBaseEvent(). */ +export interface BuiltBaseEvent { + event: EventDraft; + startDate: Date; + endDate: Date | null; } /** * Build a base event object for creation (used by create, clone, bulk). */ -export function buildBaseEvent(opts) { +export function buildBaseEvent(opts: EventOptions): BuiltBaseEvent { const startDate = parseDateTime(opts.date, opts.timezone); const endDate = opts.endDate ? parseDateTime(opts.endDate, opts.timezone) : null; - const event = { + const event: EventDraft = { title: opts.title, startDate: startDate.toISOString(), timezone: opts.timezone || 'America/Los_Angeles', @@ -89,7 +118,10 @@ export function buildBaseEvent(opts) { /** * Build links array from CLI options. */ -export function buildLinks(linkUrls, linkTexts) { +export function buildLinks( + linkUrls: string[] | undefined, + linkTexts: string[] | undefined, +): EventLink[] | null { if (!linkUrls || linkUrls.length === 0) return null; return linkUrls.map((url, i) => ({ url, @@ -101,31 +133,42 @@ export function buildLinks(linkUrls, linkTexts) { * Resolve poster image from --poster or --poster-search options. * Returns image object or null. Throws on not-found. */ -export async function resolvePosterImage(opts, fetchCatalog, searchPosters, buildPosterImage) { +export async function resolvePosterImage( + opts: { poster?: string; posterSearch?: string }, + fetchCatalog: () => Promise, + searchPosters: (catalog: Poster[], query: string) => Poster[], + buildPosterImage: (poster: Poster) => PosterImage, +): Promise { if (!opts.poster && !opts.posterSearch) return null; const catalog = await fetchCatalog(); if (opts.poster) { - const poster = catalog.find(p => p.id === opts.poster); + const poster = catalog.find((p) => p.id === opts.poster); if (!poster) { throw new NotFoundError(`Poster not found: "${opts.poster}". Use "partiful posters search " to find posters.`); } return buildPosterImage(poster); } - const results = searchPosters(catalog, opts.posterSearch); + const results = searchPosters(catalog, opts.posterSearch!); if (results.length === 0) { throw new NotFoundError(`No posters found matching "${opts.posterSearch}". Try "partiful posters search ".`); } - return buildPosterImage(results[0]); + return buildPosterImage(results[0]!); } /** * Handle image upload from file path or URL. * Returns image object for the event payload. */ -export async function resolveUploadImage(imagePath, token, config, verbose, dryRun) { +export async function resolveUploadImage( + imagePath: string, + token: string, + config: PartifulConfig, + verbose: boolean | undefined, + dryRun: boolean | undefined, +): Promise | import('./upload.js').UploadImage> { const imageIsUrl = isUrl(imagePath); if (!imageIsUrl) { @@ -164,7 +207,7 @@ export async function resolveUploadImage(imagePath, token, config, verbose, dryR * Validate that at most one image option is set. * Returns the count of image options provided. */ -export function validateImageOptions(...imageOpts) { +export function validateImageOptions(...imageOpts: unknown[]): number { const count = imageOpts.filter(Boolean).length; if (count > 1) { throw new ValidationError('Use only one of --poster, --poster-search, or --image.'); @@ -175,35 +218,45 @@ export function validateImageOptions(...imageOpts) { /** * Canonical public URL for an event's Partiful page. * Single source of truth for the `partiful.com/e/` format. - * - * @param {string} id Event ID. - * @returns {string} The event's public URL. */ -export function buildEventUrl(id) { +export function buildEventUrl(id: string): string { return `https://partiful.com/e/${id}`; } +/** A raw home-page event object as returned by the list endpoints. */ +export interface RawHomePageEvent { + id: string; + title?: string; + startDate?: string; + endDate?: string | null; + location?: string | null; + status?: string; + ownerIds?: string[]; + guest?: { status?: string } | null; + guestStatusCounts?: Record; + [extra: string]: unknown; +} + +/** The compact summary shape returned by `events list`. */ +export interface EventSummary { + id: string; + title?: string; + startDate?: string; + endDate: string | null; + location: string | null; + status?: string; + isHost: boolean; + myRsvp: string | null; + going: number; + maybe: number; + url: string; +} + /** - * Map a raw event object from the Partiful home-page endpoints - * (getMyUpcomingEventsForHomePage / getMyPastEventsForHomePage) into the - * compact summary shape returned by `events list`. - * - * Pure function — no I/O — so it can be unit-tested against fixtures. - * - * Two personal fields are derived from the caller's identity (`me`, the - * authenticated Partiful user ID): - * - `myRsvp`: the caller's own RSVP for the event. Present on events the - * caller was invited to as `e.guest.status` - * (GOING | MAYBE | DECLINED | SENT). Null when the caller hosts the event - * (no guest record) or when the field is absent. - * - `isHost`: whether the caller owns the event, i.e. their ID is in - * `e.ownerIds`. Falls back to false when `me` is unknown. - * - * @param {object} e Raw event object from the API. - * @param {string|null} me Authenticated user's Partiful user ID. - * @returns {object} Summary object with a stable field order. + * Map a raw event object from the Partiful home-page endpoints into the compact + * summary shape returned by `events list`. Pure function — no I/O. */ -export function mapEventSummary(e, me) { +export function mapEventSummary(e: RawHomePageEvent, me: string | null): EventSummary { return { id: e.id, title: e.title, @@ -219,11 +272,20 @@ export function mapEventSummary(e, me) { }; } +/** A Firestore typed-value field. */ +export type FirestoreValue = + | { stringValue: string } + | { integerValue: string } + | { doubleValue: number } + | { booleanValue: boolean } + | { arrayValue: { values: FirestoreValue[] } } + | { mapValue: { fields: Record } }; + /** * Convert a plain JS object to Firestore field format (recursive). */ -export function toFirestoreMap(obj) { - const fields = {}; +export function toFirestoreMap(obj: Record): Record { + const fields: Record = {}; for (const [key, value] of Object.entries(obj)) { if (value === null || value === undefined) continue; if (typeof value === 'string') fields[key] = { stringValue: value }; @@ -231,20 +293,23 @@ export function toFirestoreMap(obj) { fields[key] = Number.isInteger(value) ? { integerValue: String(value) } : { doubleValue: value }; - } - else if (typeof value === 'boolean') fields[key] = { booleanValue: value }; + } else if (typeof value === 'boolean') fields[key] = { booleanValue: value }; else if (Array.isArray(value)) { - fields[key] = { arrayValue: { values: value.map(v => { - if (typeof v === 'string') return { stringValue: v }; - if (typeof v === 'number') { - return Number.isInteger(v) ? { integerValue: String(v) } : { doubleValue: v }; - } - if (typeof v === 'object') return { mapValue: { fields: toFirestoreMap(v) } }; - return { stringValue: String(v) }; - })}}; - } - else if (typeof value === 'object') { - fields[key] = { mapValue: { fields: toFirestoreMap(value) } }; + fields[key] = { + arrayValue: { + values: value.map((v): FirestoreValue => { + if (typeof v === 'string') return { stringValue: v }; + if (typeof v === 'number') { + return Number.isInteger(v) ? { integerValue: String(v) } : { doubleValue: v }; + } + if (typeof v === 'object' && v !== null) + return { mapValue: { fields: toFirestoreMap(v as Record) } }; + return { stringValue: String(v) }; + }), + }, + }; + } else if (typeof value === 'object') { + fields[key] = { mapValue: { fields: toFirestoreMap(value as Record) } }; } } return fields; diff --git a/src/lib/posters.js b/src/lib/posters.ts similarity index 57% rename from src/lib/posters.js rename to src/lib/posters.ts index f6389b5..633fc4e 100644 --- a/src/lib/posters.js +++ b/src/lib/posters.ts @@ -2,9 +2,40 @@ * Shared poster catalog helpers. */ -let _catalogCache = null; +/** A poster entry from the Partiful catalog (broad shape, extra fields allowed). */ +export interface Poster { + id?: string; + name?: string; + url?: string; + blurHash?: string; + contentType?: string; + height?: number; + width?: number; + tags?: string[]; + categories?: string[]; + [extra: string]: unknown; +} + +/** A poster augmented with its search relevance score. */ +export interface ScoredPoster extends Poster { + score: number; +} + +/** The image object we attach to an event when using a catalog poster. */ +export interface PosterImage { + source: 'partiful_posters'; + poster: Poster; + url?: string; + blurHash?: string; + contentType?: string; + name?: string; + height?: number; + width?: number; +} + +let _catalogCache: Poster[] | null = null; -export async function fetchCatalog() { +export async function fetchCatalog(): Promise { if (_catalogCache) return _catalogCache; // Support local fixture for testing @@ -12,7 +43,7 @@ export async function fetchCatalog() { if (localFile) { const { readFileSync } = await import('fs'); _catalogCache = JSON.parse(readFileSync(localFile, 'utf-8')); - return _catalogCache; + return _catalogCache!; } const controller = new AbortController(); @@ -20,23 +51,23 @@ export async function fetchCatalog() { try { const res = await fetch('https://assets.getpartiful.com/posters.json', { signal: controller.signal }); if (!res.ok) throw new Error(`Failed to fetch poster catalog: ${res.status}`); - _catalogCache = await res.json(); - return _catalogCache; + _catalogCache = (await res.json()) as Poster[]; + return _catalogCache!; } catch (err) { - if (err.name === 'AbortError') throw new Error('Poster catalog fetch timed out (10s)'); + if ((err as Error).name === 'AbortError') throw new Error('Poster catalog fetch timed out (10s)'); throw err; } finally { clearTimeout(timeout); } } -export function posterThumbnail(posterId) { +export function posterThumbnail(posterId: string): string { return `https://partiful-posters.imgix.net/${encodeURIComponent(posterId)}?fit=max&w=400`; } -export function searchPosters(catalog, query) { +export function searchPosters(catalog: Poster[], query: string): ScoredPoster[] { const q = query.toLowerCase(); - const results = []; + const results: ScoredPoster[] = []; for (const poster of catalog) { let score = 0; // Tag exact match @@ -60,7 +91,7 @@ export function searchPosters(catalog, query) { return results; } -export function buildPosterImage(poster) { +export function buildPosterImage(poster: Poster): PosterImage { return { source: 'partiful_posters', poster, diff --git a/src/lib/rsvp.js b/src/lib/rsvp.ts similarity index 66% rename from src/lib/rsvp.js rename to src/lib/rsvp.ts index 27ac38e..ad31f39 100644 --- a/src/lib/rsvp.js +++ b/src/lib/rsvp.ts @@ -19,9 +19,10 @@ */ import { PartifulError } from './errors.js'; +import type { AddGuestParams, MarkEventInterestParams, RsvpDraft } from './api/endpoints.js'; /** User-facing status verbs for `--status` on the rsvp command. */ -export const RSVP_STATUSES = ['going', 'maybe', 'declined']; +export const RSVP_STATUSES = ['going', 'maybe', 'declined'] as const; const DEFAULT_TIMEZONE = 'America/Los_Angeles'; @@ -32,7 +33,7 @@ const LEGACY_QUESTIONNAIRE_FIELDS = ['questions', 'rsvpQuestions', 'customQuesti // Human input -> wire enum. Only GOING/MAYBE/DECLINED are valid self-RSVP // statuses. INTERESTED is a DIFFERENT endpoint (markEventInterest), so it is // deliberately NOT accepted here. -const STATUS_ALIASES = { +const STATUS_ALIASES: Record = { going: 'GOING', yes: 'GOING', maybe: 'MAYBE', @@ -41,11 +42,59 @@ const STATUS_ALIASES = { no: 'DECLINED', }; +/** A questionnaire question as it appears in the event object. */ +export interface QuestionnaireQuestion { + id: string; + text: string; + type?: string; + required?: boolean; +} + +/** The questionnaireResponse object attached to an RSVP. */ +export interface QuestionnaireResponse { + questionnaireVersion: number; + answers: Record; +} + +/** Loose event shape read by the RSVP guards (broad — hosts see more fields). */ +export interface RsvpEvent { + ticketing?: { enabled?: boolean }; + ticketInfo?: unknown; + requiresPayment?: boolean; + isTicketed?: boolean; + questionnaireEnabled?: boolean; + questionnaire?: { questions?: QuestionnaireQuestion[] }; + questionnaireVersions?: unknown[]; + [key: string]: unknown; +} + +/** Options accepted by buildRsvpParams(). */ +export interface BuildRsvpOptions { + eventId?: string; + name?: string; + status?: string | null; + plusOnes?: string[]; + count?: number | null; + message?: string | null; + emailInvitationId?: string | null; + password?: string | null; + guestId?: string | null; + timezone?: string; + questionnaireResponse?: QuestionnaireResponse | null; +} + +/** Options accepted by buildInterestParams(). */ +export interface BuildInterestOptions { + eventId?: string; + interested?: boolean; + source?: string; +} + /** * Normalize a user-supplied status into its wire enum value. * Defaults to GOING. Throws a validation PartifulError on unknown input. */ -export function normalizeStatus(status) { +export function normalizeStatus(status?: string | null): string { if (status === null || status === undefined || status === '') return 'GOING'; const key = String(status).trim().toLowerCase(); const wire = STATUS_ALIASES[key]; @@ -53,7 +102,7 @@ export function normalizeStatus(status) { throw new PartifulError( `Invalid status "${status}". Use one of: going, maybe, declined.`, 3, - 'validation_error' + 'validation_error', ); } return wire; @@ -61,23 +110,8 @@ export function normalizeStatus(status) { /** * Build the `params` object for POST /addGuest. - * - * @param {object} o - * @param {string} o.eventId required - * @param {string} o.name required (server rejects an empty name) - * @param {string} [o.status] going|maybe|declined (default going) - * @param {string[]} [o.plusOnes] plus-one names - * @param {number} [o.count] headcount incl. plus-ones (derived if omitted) - * @param {string} [o.message] optional public comment - * @param {string} [o.password] event password if gated - * @param {string} [o.guestId] existing guest record id (edit); null on first RSVP - * @param {string} [o.timezone] IANA tz (default America/Los_Angeles) - * @param {object} [o.questionnaireResponse] verified shape: - * { questionnaireVersion:int, answers:{ "": "" } } - * Build via buildQuestionnaireResponse(); omit when the event has none. - * @returns {{eventId:string, rsvp:object}} */ -export function buildRsvpParams(o = {}) { +export function buildRsvpParams(o: BuildRsvpOptions = {}): AddGuestParams { const { eventId, name } = o; if (!eventId) { throw new PartifulError('eventId is required to RSVP.', 3, 'validation_error'); @@ -88,20 +122,20 @@ export function buildRsvpParams(o = {}) { const plusOnes = Array.isArray(o.plusOnes) ? o.plusOnes.filter(Boolean) : []; const derivedCount = 1 + plusOnes.length; - let count; + let count: number; if (o.count == null) { count = derivedCount; } else if (!Number.isFinite(o.count) || o.count <= 0) { throw new PartifulError( `Invalid --count "${o.count}". Must be a positive whole number.`, 3, - 'validation_error' + 'validation_error', ); } else { count = Math.trunc(o.count); } - const rsvp = { + const rsvp: RsvpDraft = { name: String(name), count, plusOnes, @@ -124,13 +158,8 @@ export function buildRsvpParams(o = {}) { /** * Build the `params` object for POST /markEventInterest. - * - * @param {object} o - * @param {string} o.eventId required - * @param {boolean} o.interested true to mark, false to remove - * @param {string} [o.source] analytics source (default DISCOVER) */ -export function buildInterestParams(o = {}) { +export function buildInterestParams(o: BuildInterestOptions = {}): MarkEventInterestParams { const { eventId } = o; if (!eventId) { throw new PartifulError('eventId is required to mark interest.', 3, 'validation_error'); @@ -146,7 +175,7 @@ export function buildInterestParams(o = {}) { * Detect a ticketed / paid event we should refuse to RSVP for (Stripe wall). * Conservative: any ticketing/payment signal counts. */ -export function isTicketedEvent(event) { +export function isTicketedEvent(event: RsvpEvent | null | undefined): boolean { if (!event || typeof event !== 'object') return false; if (event.ticketing && (event.ticketing.enabled ?? true)) return true; if (event.ticketInfo && typeof event.ticketInfo === 'object') return true; @@ -157,28 +186,13 @@ export function isTicketedEvent(event) { /** * Detect an event that requires questionnaire answers before an RSVP submits. - * - * Verified live (2026-07-24, host-side recon on a throwaway event, see - * .wayfinder/tickets/07 follow-up). The authoritative shape in the event - * object is: - * questionnaireEnabled: true - * questionnaire: { - * createdBy, createdAt, - * questions: [ { id, type: 'short_answer', text, required } ] - * } - * questionnaireVersions: [ { ...same shape... } ] // history - * When no questionnaire exists these keys are simply ABSENT from the event. - * - * Detection rule: questionnaireEnabled truthy AND at least one question present. - * (Legacy field names kept as a defensive fallback in case older/host payloads - * expose the list under a different key.) */ -export function eventRequiresQuestionnaire(event) { +export function eventRequiresQuestionnaire(event: RsvpEvent | null | undefined): boolean { if (!event || typeof event !== 'object') return false; const q = event.questionnaire; const hasQuestions = - q && typeof q === 'object' && Array.isArray(q.questions) && q.questions.length > 0; + !!q && typeof q === 'object' && Array.isArray(q.questions) && q.questions.length > 0; // Primary, verified signal. if (event.questionnaireEnabled && hasQuestions) return true; @@ -189,60 +203,53 @@ export function eventRequiresQuestionnaire(event) { // Legacy field-name fallbacks (unverified, kept for resilience). for (const f of LEGACY_QUESTIONNAIRE_FIELDS) { - if (Array.isArray(event[f]) && event[f].length > 0) return true; + const legacy = event[f]; + if (Array.isArray(legacy) && legacy.length > 0) return true; } return false; } /** - * Build the questionnaireResponse object for an RSVP, given the event's - * questions and a map of { questionId | questionText -> answer } supplied by - * the caller. Returns null when the event has no questionnaire. - * - * Verified wire shape (guest.questionnaireResponse, 2026-07-24): - * { questionnaireVersion: , answers: { "": "" } } - * The answers map is keyed by question id, NOT by text or index. - * - * Throws a validation error if a REQUIRED question has no supplied answer, so - * the CLI refuses rather than submitting an incomplete RSVP. + * Build the questionnaireResponse object for an RSVP. + * Returns null when the event has no questionnaire. */ -export function buildQuestionnaireResponse(event, answersByKey = {}) { +export function buildQuestionnaireResponse( + event: RsvpEvent, + answersByKey: Record = {}, +): QuestionnaireResponse | null { if (!eventRequiresQuestionnaire(event)) return null; // Resolve question list defensively — event.questionnaire may be absent // when the questionnaire was detected via a legacy field path. - let questions; + let questions: Array; const primaryQuestions = event.questionnaire?.questions; if (Array.isArray(primaryQuestions) && primaryQuestions.length > 0) { questions = primaryQuestions; } else { const legacyField = LEGACY_QUESTIONNAIRE_FIELDS.find( - f => Array.isArray(event[f]) && event[f].length > 0 + (f) => Array.isArray(event[f]) && (event[f] as unknown[]).length > 0, ); if (legacyField) { - questions = event[legacyField]; + questions = event[legacyField] as Array; } else { throw new PartifulError( 'Event questionnaire is enabled but no questions were found.', 3, - 'validation_error' + 'validation_error', ); } } - const answers = {}; - const missing = []; + const answers: Record = {}; + const missing: string[] = []; for (const rawQuestion of questions) { // Normalise bare-string legacy questions: treat the string as both id and text. - const question = + const question: QuestionnaireQuestion = typeof rawQuestion === 'string' ? { id: rawQuestion, text: rawQuestion, required: false } : rawQuestion; // Accept an answer supplied under the question id OR its exact text. - const val = - answersByKey[question.id] ?? - answersByKey[question.text] ?? - undefined; + const val = answersByKey[question.id] ?? answersByKey[question.text] ?? undefined; if (val === undefined || val === null || String(val).trim() === '') { if (question.required) missing.push(question.text); continue; @@ -253,7 +260,7 @@ export function buildQuestionnaireResponse(event, answersByKey = {}) { throw new PartifulError( `Missing answer(s) for required question(s): ${missing.join('; ')}`, 3, - 'validation_error' + 'validation_error', ); } return { @@ -265,12 +272,25 @@ export function buildQuestionnaireResponse(event, answersByKey = {}) { }; } +/** Inputs for resolveDisplayName(). */ +export interface ResolveDisplayNameArgs { + override?: string | null; + currentGuest?: { name?: string } | null; + config?: { name?: string } | null; + tokenName?: string | null; +} + /** * Resolve the display name to submit with an RSVP, in priority order: * explicit override > existing guest record > config profile > token-derived. * Returns null when nothing is resolvable (caller must surface an error). */ -export function resolveDisplayName({ override, currentGuest, config, tokenName } = {}) { +export function resolveDisplayName({ + override, + currentGuest, + config, + tokenName, +}: ResolveDisplayNameArgs = {}): string | null { if (override && String(override).trim()) return String(override); if (currentGuest && currentGuest.name) return currentGuest.name; if (config && config.name) return config.name; diff --git a/src/lib/templates.js b/src/lib/templates.ts similarity index 56% rename from src/lib/templates.js rename to src/lib/templates.ts index 80cdb0e..3845b52 100644 --- a/src/lib/templates.js +++ b/src/lib/templates.ts @@ -5,12 +5,19 @@ import fs from 'fs'; import path from 'path'; -function templatesPath() { - return process.env.PARTIFUL_TEMPLATES_FILE - || path.join(process.env.HOME, '.config/partiful/templates.json'); +/** A saved template is a bag of CLI-option-shaped fields. */ +export type Template = Record; +/** A map of template name -> template. */ +export type TemplateStore = Record; + +function templatesPath(): string { + return ( + process.env.PARTIFUL_TEMPLATES_FILE || + path.join(process.env.HOME as string, '.config/partiful/templates.json') + ); } -export function loadTemplates() { +export function loadTemplates(): TemplateStore { const p = templatesPath(); if (!fs.existsSync(p)) return {}; try { @@ -20,7 +27,7 @@ export function loadTemplates() { } } -export function saveTemplates(templates) { +export function saveTemplates(templates: TemplateStore): void { const p = templatesPath(); const dir = path.dirname(p); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); @@ -37,8 +44,8 @@ const TEMPLATE_FIELDS = [ /** * Extract template-worthy fields from CLI opts or an API event object. */ -export function extractTemplate(source) { - const tpl = {}; +export function extractTemplate(source: Record): Template { + const tpl: Template = {}; for (const key of TEMPLATE_FIELDS) { if (source[key] !== undefined && source[key] !== null) { tpl[key] = source[key]; @@ -47,13 +54,17 @@ export function extractTemplate(source) { // Map API event fields to CLI option names if (source.guestLimit && !tpl.capacity) tpl.capacity = source.guestLimit; if (source.visibility === 'private' && !tpl.private) tpl.private = true; - if (source.displaySettings) { - if (source.displaySettings.theme && !tpl.theme) tpl.theme = source.displaySettings.theme; - if (source.displaySettings.effect && !tpl.effect) tpl.effect = source.displaySettings.effect; + const displaySettings = source.displaySettings as + | { theme?: unknown; effect?: unknown } + | undefined; + if (displaySettings) { + if (displaySettings.theme && !tpl.theme) tpl.theme = displaySettings.theme; + if (displaySettings.effect && !tpl.effect) tpl.effect = displaySettings.effect; } - if (source.links && !tpl.link) { - tpl.link = source.links.map(l => l.url); - tpl.linkText = source.links.map(l => l.text || l.url); + const links = source.links as Array<{ url: string; text?: string }> | undefined; + if (links && !tpl.link) { + tpl.link = links.map((l) => l.url); + tpl.linkText = links.map((l) => l.text || l.url); } return tpl; } @@ -61,13 +72,16 @@ export function extractTemplate(source) { /** * Apply variable substitution: {{varName}} → value */ -export function applyVariables(template, vars) { +export function applyVariables( + template: Template, + vars: Record | null | undefined, +): Template { if (!vars || Object.keys(vars).length === 0) return { ...template }; - const result = {}; + const result: Template = {}; for (const [key, value] of Object.entries(template)) { if (typeof value === 'string') { result[key] = value.replace(/\{\{(\w+)\}\}/g, (match, name) => { - return vars[name] !== undefined ? vars[name] : match; + return vars[name] !== undefined ? vars[name]! : match; }); } else { result[key] = value; @@ -79,8 +93,11 @@ export function applyVariables(template, vars) { /** * Merge template with CLI overrides. CLI opts win. */ -export function mergeTemplateOpts(template, opts) { - const merged = { ...template }; +export function mergeTemplateOpts( + template: Template, + opts: Record, +): Template { + const merged: Template = { ...template }; for (const key of TEMPLATE_FIELDS) { if (opts[key] !== undefined && opts[key] !== null) { merged[key] = opts[key]; diff --git a/src/lib/upload.js b/src/lib/upload.ts similarity index 72% rename from src/lib/upload.js rename to src/lib/upload.ts index 90dfcc0..da81c1b 100644 --- a/src/lib/upload.js +++ b/src/lib/upload.ts @@ -6,10 +6,11 @@ import { readFileSync, existsSync, statSync, writeFileSync, unlinkSync } from 'f import { basename, extname, join } from 'path'; import { tmpdir } from 'os'; import { randomBytes } from 'crypto'; +import type { PartifulConfig } from './auth.js'; const ALLOWED_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif']; const MAX_SIZE = 10 * 1024 * 1024; // 10MB -const MIME_TYPES = { +const MIME_TYPES: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', @@ -18,7 +19,39 @@ const MIME_TYPES = { '.avif': 'image/avif', }; -export async function uploadEventImage(filePath, token, config, verbose) { +/** The uploadData object returned by the uploadPhoto endpoint. */ +export interface UploadData { + url: string; + contentType?: string; + height?: number; + width?: number; + [extra: string]: unknown; +} + +/** The image object we attach to an event when using a custom upload. */ +export interface UploadImage { + source: 'upload'; + type: 'image'; + upload: UploadData; + url: string; + contentType?: string; + name: string; + height?: number; + width?: number; +} + +/** A downloaded temp file plus its cleanup handle. */ +export interface TempDownload { + tempPath: string; + cleanup: () => void; +} + +export async function uploadEventImage( + filePath: string, + token: string, + config: PartifulConfig | null | undefined, + verbose?: boolean, +): Promise { if (!existsSync(filePath)) { throw new Error(`File not found: ${filePath}`); } @@ -49,7 +82,7 @@ export async function uploadEventImage(filePath, token, config, verbose) { const uploadController = new AbortController(); const uploadTimeoutId = setTimeout(() => uploadController.abort(), uploadTimeoutMs); - let response; + let response: Response; try { response = await fetch(url, { method: 'POST', @@ -58,7 +91,7 @@ export async function uploadEventImage(filePath, token, config, verbose) { signal: uploadController.signal, }); } catch (err) { - if (err.name === 'AbortError') { + if ((err as Error).name === 'AbortError') { throw new Error(`Upload timed out after ${uploadTimeoutMs / 1000}s`); } throw err; @@ -70,9 +103,9 @@ export async function uploadEventImage(filePath, token, config, verbose) { throw new Error(`Upload failed: ${response.status} ${response.statusText}`); } - let result; + let result: { uploadData?: UploadData; result?: { uploadData?: UploadData } }; try { - result = await response.json(); + result = (await response.json()) as typeof result; } catch { throw new Error('Upload failed: invalid JSON response body'); } @@ -85,7 +118,7 @@ export async function uploadEventImage(filePath, token, config, verbose) { return uploadData; } -const CONTENT_TYPE_TO_EXT = { +const CONTENT_TYPE_TO_EXT: Record = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif', @@ -93,19 +126,19 @@ const CONTENT_TYPE_TO_EXT = { 'image/avif': '.avif', }; -export async function downloadToTemp(url) { +export async function downloadToTemp(url: string): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15000); - let response; + let response: Response; try { response = await fetch(url, { signal: controller.signal }); } catch (err) { clearTimeout(timeout); - if (err.name === 'AbortError') { + if ((err as Error).name === 'AbortError') { throw new Error(`Download timed out after 15s: ${url}`); } - throw new Error(`Download failed: ${err.message}`); + throw new Error(`Download failed: ${(err as Error).message}`); } finally { clearTimeout(timeout); } @@ -114,7 +147,7 @@ export async function downloadToTemp(url) { throw new Error(`Download failed: ${response.status} ${response.statusText} from ${url}`); } - const contentType = (response.headers.get('content-type') || '').split(';')[0].trim(); + const contentType = (response.headers.get('content-type') || '').split(';')[0]!.trim(); const ext = CONTENT_TYPE_TO_EXT[contentType]; if (!ext) { throw new Error(`Unsupported content type "${contentType}" from ${url}. Expected an image type.`); @@ -137,12 +170,16 @@ export async function downloadToTemp(url) { return { tempPath, cleanup() { - try { unlinkSync(tempPath); } catch {} + try { + unlinkSync(tempPath); + } catch { + /* best-effort cleanup */ + } }, }; } -export function buildUploadImage(uploadData, filename) { +export function buildUploadImage(uploadData: UploadData, filename: string): UploadImage { return { source: 'upload', type: 'image', From cd6581a36d84562aea123edd1ea580d87a69fc6f Mon Sep 17 00:00:00 2001 From: Kaleb Cole Date: Fri, 24 Jul 2026 15:10:14 -0700 Subject: [PATCH 05/14] T4: port src/commands/, src/helpers/, cli.ts to TS strict All 18 remaining JS files ported (12 commands, 4 helpers, cli.ts, schema.ts). src/ is now 100% TypeScript. Commander handlers typed; API responses narrowed via api/ spec types + as-casts. tsc --noEmit clean; 195/195 green; ./bin/partiful --version + schema smoke-tested via tsx loader. --- .wayfinder/ts-port/map.md | 3 +- src/{cli.js => cli.ts} | 28 +- src/commands/{auth.js => auth.ts} | 118 +++++--- src/commands/{blasts.js => blasts.ts} | 34 ++- src/commands/{bulk.js => bulk.ts} | 174 ++++++----- src/commands/{cohosts.js => cohosts.ts} | 68 +++-- src/commands/{contacts.js => contacts.ts} | 24 +- src/commands/{doctor.js => doctor.ts} | 76 +++-- src/commands/{events.js => events.ts} | 313 ++++++++++---------- src/commands/{guests.js => guests.ts} | 95 +++--- src/commands/{posters.js => posters.ts} | 44 +-- src/commands/{rsvp.js => rsvp.ts} | 88 +++--- src/commands/{schema.js => schema.ts} | 22 +- src/commands/{setup.js => setup.ts} | 44 +-- src/commands/{templates.js => templates.ts} | 52 ++-- src/helpers/{clone.js => clone.ts} | 64 ++-- src/helpers/export.js | 92 ------ src/helpers/export.ts | 97 ++++++ src/helpers/{share.js => share.ts} | 16 +- src/helpers/{watch.js => watch.ts} | 31 +- 20 files changed, 819 insertions(+), 664 deletions(-) rename src/{cli.js => cli.ts} (86%) rename src/commands/{auth.js => auth.ts} (80%) rename src/commands/{blasts.js => blasts.ts} (71%) rename src/commands/{bulk.js => bulk.ts} (50%) rename src/commands/{cohosts.js => cohosts.ts} (54%) rename src/commands/{contacts.js => contacts.ts} (55%) rename src/commands/{doctor.js => doctor.ts} (70%) rename src/commands/{events.js => events.ts} (51%) rename src/commands/{guests.js => guests.ts} (58%) rename src/commands/{posters.js => posters.ts} (61%) rename src/commands/{rsvp.js => rsvp.ts} (67%) rename src/commands/{schema.js => schema.ts} (93%) rename src/commands/{setup.js => setup.ts} (73%) rename src/commands/{templates.js => templates.ts} (72%) rename src/helpers/{clone.js => clone.ts} (53%) delete mode 100644 src/helpers/export.js create mode 100644 src/helpers/export.ts rename src/helpers/{share.js => share.ts} (62%) rename src/helpers/{watch.js => watch.ts} (68%) diff --git a/.wayfinder/ts-port/map.md b/.wayfinder/ts-port/map.md index 436bae7..4234969 100644 --- a/.wayfinder/ts-port/map.md +++ b/.wayfinder/ts-port/map.md @@ -35,6 +35,7 @@ Success = the API spec is a *byproduct* of typing the code, not a separate artif - T0 CLOSED (2026-07-24): RSVP work merged to main via PR #65 (squash, commit ad7be30); tree clean; main green at 195/195 tests. Port branch to be cut from ad7be30. - T1 CLOSED (2026-07-24): TS toolchain up. tsx-loader run path (no dist build), tsconfig strict+NodeNext+allowJs, zod added. `npm run typecheck` clean + 195/195 green on still-JS tree. - T2 CLOSED (2026-07-24): Convention doc at `docs/TYPESCRIPT-PORT-GUIDE.md`. Enforceable rules + worked createEvent endpoint (envelope generic + request interface + Zod passthrough + z.infer + metadata). Spec home = `src/lib/api/`. +- T3 CLOSED (2026-07-24): src/lib/ is 100% TS strict (11 modules). THE SPEC authored at `src/lib/api/{envelope,endpoints}.ts`: CallableEnvelope

/CallableResult generics + per-endpoint request interfaces + Zod .passthrough() response schemas + z.infer types + introspectable `apiEndpoints` registry (14 entries across firebase-callable/firestore/firebase-auth). bin/partiful uses tsx register() then dynamic import (ESM hoist fix). tsc clean + 195/195 green. ## Not yet specified @@ -59,7 +60,7 @@ Success = the API spec is a *byproduct* of typing the code, not a separate artif | T0 | RSVP work merged to main, tree clean, port branch cut | task (AFK) | — | ✅ CLOSED (PR #65 merged, ad7be30) | | T1 | TS toolchain setup (tsconfig strict, tsx run, bin, build) | task (AFK) | T0 | ✅ CLOSED (tsx loader, no dist) | | T2 | Write porting convention doc (strict + Zod pattern) | task (AFK) | T1 | ✅ CLOSED (docs/TYPESCRIPT-PORT-GUIDE.md) | -| T3 | Port src/lib/ API layer + author endpoint types/Zod (THE SPEC) | task (AFK) | T2 | OPEN | +| T3 | Port src/lib/ API layer + author endpoint types/Zod (THE SPEC) | task (AFK) | T2 | ✅ CLOSED (src/lib 100% TS, api/ spec) | | T4 | Port src/commands/ + src/helpers/ | task (AFK) | T3 | OPEN | | T5 | Rewire schema command → schema api. | task (AFK) | T3 | OPEN | | T6 | Wire drift-detection + real-API smoke tests | task (AFK) | T3 | OPEN | diff --git a/src/cli.js b/src/cli.ts similarity index 86% rename from src/cli.js rename to src/cli.ts index 6efa485..1e471cd 100644 --- a/src/cli.js +++ b/src/cli.ts @@ -21,9 +21,9 @@ import { jsonOutput } from './lib/output.js'; // Single source of truth for the version — read from package.json so the // CLI's --version can never drift from the published package version. -const { version: pkgVersion } = createRequire(import.meta.url)('../package.json'); +const { version: pkgVersion } = createRequire(import.meta.url)('../package.json') as { version: string }; -export function run() { +export function run(): void { const program = new Command(); program @@ -58,43 +58,43 @@ export function run() { // RSVP / interest verbs, shared across the canonical `events` group and the // `explore` alias group. Look up the `events` command created above; create // the `explore` group here. - const eventsCmd = program.commands.find(c => c.name() === 'events'); + const eventsCmd = program.commands.find((c) => c.name() === 'events'); const exploreCmd = program.command('explore').description('Discover public events and RSVP to them'); - registerRsvpCommands(program, { events: eventsCmd, explore: exploreCmd }); + registerRsvpCommands(program, { events: eventsCmd!, explore: exploreCmd }); program .command('version') .description('Show CLI version and info') - .action((opts, cmd) => { + .action((_opts: unknown, cmd: Command) => { const globalOpts = cmd.optsWithGlobals(); jsonOutput({ version: program.version(), cli: 'partiful', node: process.version }, {}, globalOpts); }); // Deprecated aliases — rewrite argv before parsing const args = process.argv.slice(2); - const aliasMap = { - 'list': ['events', 'list'], - 'get': ['events', 'get'], - 'cancel': ['events', 'cancel'], - 'clone': ['events', '+clone'], + const aliasMap: Record = { + list: ['events', 'list'], + get: ['events', 'get'], + cancel: ['events', 'cancel'], + clone: ['events', '+clone'], }; // Find first non-option token (skip --format , -o , etc.) const optsWithValue = new Set(['--format', '-o', '--output']); let cmdIndex = 0; - while (cmdIndex < args.length && args[cmdIndex].startsWith('-')) { - cmdIndex += optsWithValue.has(args[cmdIndex]) ? 2 : 1; + while (cmdIndex < args.length && args[cmdIndex]!.startsWith('-')) { + cmdIndex += optsWithValue.has(args[cmdIndex]!) ? 2 : 1; } const legacy = args[cmdIndex]; if (legacy && aliasMap[legacy]) { const rewritten = [ ...args.slice(0, cmdIndex), - ...aliasMap[legacy], + ...aliasMap[legacy]!, ...args.slice(cmdIndex + 1), ]; process.stderr.write( - `[deprecated] "partiful ${legacy}" → use "partiful ${aliasMap[legacy].join(' ')}" instead\n` + `[deprecated] "partiful ${legacy}" → use "partiful ${aliasMap[legacy]!.join(' ')}" instead\n`, ); process.argv = [...process.argv.slice(0, 2), ...rewritten]; } diff --git a/src/commands/auth.js b/src/commands/auth.ts similarity index 80% rename from src/commands/auth.js rename to src/commands/auth.ts index 6909e61..2062996 100644 --- a/src/commands/auth.js +++ b/src/commands/auth.ts @@ -12,23 +12,29 @@ */ import fs from 'fs'; -import path from 'path'; import os from 'os'; -import { execSync, spawnSync } from 'child_process'; +import { execSync } from 'child_process'; import readline from 'readline'; +import type { Command } from 'commander'; import { loadConfig, saveConfig, getValidToken, resolveCredentialsPath, generateAmplitudeDeviceId } from '../lib/auth.js'; import { jsonOutput, jsonError } from '../lib/output.js'; -const FIREBASE_API_KEY = 'AIzaSyCky6PJ7cHRdBKk5X7gjuWERWaKWBHr4_k'; +const FIREBASE_API_KEY = 'AIzaSy...r4_k'; const API_BASE = 'https://api.partiful.com'; const IDENTITY_TOOLKIT = 'https://identitytoolkit.googleapis.com'; -const PARTIFUL_SMS_SENDER = '+18449460698'; +const PARTIFUL_SMS_SENDER = '+184****0698'; const CODE_POLL_INTERVAL_MS = 3000; const CODE_POLL_TIMEOUT_MS = 120000; // 2 minutes // ─── Platform Detection ─────────────────────────────────────── -function detectPlatform() { +interface PlatformInfo { + os: string; + canAutoRetrieve: boolean; + method: string; +} + +function detectPlatform(): PlatformInfo { const platform = os.platform(); if (platform === 'darwin') { @@ -56,7 +62,7 @@ function detectPlatform() { return { os: platform, canAutoRetrieve: false, method: 'manual' }; } -function hasCommand(cmd) { +function hasCommand(cmd: string): boolean { try { execSync(`which ${cmd}`, { stdio: 'ignore' }); return true; @@ -67,7 +73,22 @@ function hasCommand(cmd) { // ─── SMS Code Retrieval ─────────────────────────────────────── -async function pollForCodeImsg(phoneNumber, sentAt) { +interface ImsgChat { + id: string; + identifier?: string; +} + +interface ImsgMessage { + created_at: string; + text?: string; +} + +interface TermuxSmsMessage { + received: string; + body?: string; +} + +async function pollForCodeImsg(_phoneNumber: string, sentAt: number): Promise { const deadline = Date.now() + CODE_POLL_TIMEOUT_MS; console.error('Watching for SMS verification code via iMessage...'); @@ -75,7 +96,7 @@ async function pollForCodeImsg(phoneNumber, sentAt) { try { // Find the Partiful SMS chat const chatsRaw = execSync('imsg chats --limit 30 --json', { encoding: 'utf8', timeout: 10000 }); - const chats = chatsRaw.trim().split('\n').map(line => JSON.parse(line)); + const chats: ImsgChat[] = chatsRaw.trim().split('\n').map((line: string) => JSON.parse(line) as ImsgChat); const partifulChat = chats.find(c => c.identifier === PARTIFUL_SMS_SENDER || @@ -86,20 +107,20 @@ async function pollForCodeImsg(phoneNumber, sentAt) { const historyRaw = execSync(`imsg history --chat-id ${partifulChat.id} --limit 3 --json`, { encoding: 'utf8', timeout: 10000 }); - const messages = historyRaw.trim().split('\n').map(line => JSON.parse(line)); + const messages: ImsgMessage[] = historyRaw.trim().split('\n').map((line: string) => JSON.parse(line) as ImsgMessage); for (const msg of messages) { const msgTime = new Date(msg.created_at).getTime(); if (msgTime >= sentAt - 5000) { // within 5s of send const codeMatch = msg.text?.match(/(\d{6})\s+is your Partiful verification code/); if (codeMatch) { - console.error(`✓ Code received: ${codeMatch[1]}`); - return codeMatch[1]; + console.error(`✓ Code received: ${codeMatch[1]!}`); + return codeMatch[1]!; } } } } - } catch (e) { + } catch { // imsg failed — continue polling } @@ -113,26 +134,26 @@ async function pollForCodeImsg(phoneNumber, sentAt) { return null; // Timed out } -async function pollForCodeTermux(phoneNumber, sentAt) { +async function pollForCodeTermux(_phoneNumber: string, sentAt: number): Promise { const deadline = Date.now() + CODE_POLL_TIMEOUT_MS; console.error('Watching for SMS verification code via Termux...'); while (Date.now() < deadline) { try { const smsRaw = execSync('termux-sms-list -l 10 -t inbox', { encoding: 'utf8', timeout: 10000 }); - const messages = JSON.parse(smsRaw); + const messages: TermuxSmsMessage[] = JSON.parse(smsRaw) as TermuxSmsMessage[]; for (const msg of messages) { const msgTime = new Date(msg.received).getTime(); if (msgTime >= sentAt - 5000) { const codeMatch = msg.body?.match(/(\d{6})\s+is your Partiful verification code/); if (codeMatch) { - console.error(`✓ Code received: ${codeMatch[1]}`); - return codeMatch[1]; + console.error(`✓ Code received: ${codeMatch[1]!}`); + return codeMatch[1]!; } } } - } catch (e) { + } catch { // termux-sms-list failed — continue polling } @@ -142,7 +163,7 @@ async function pollForCodeTermux(phoneNumber, sentAt) { return null; } -async function promptForCode() { +async function promptForCode(): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); return new Promise(resolve => { rl.question('Enter verification code: ', answer => { @@ -154,7 +175,7 @@ async function promptForCode() { // ─── API Calls ──────────────────────────────────────────────── -async function sendAuthCode(phoneNumber) { +async function sendAuthCode(phoneNumber: string): Promise { const payload = { data: { params: { @@ -189,7 +210,12 @@ async function sendAuthCode(phoneNumber) { return resp.json(); } -async function getLoginToken(phoneNumber, authCode) { +interface LoginTokenResult { + token?: string; + [key: string]: unknown; +} + +async function getLoginToken(phoneNumber: string, authCode: string): Promise { const payload = { data: { params: { @@ -218,11 +244,19 @@ async function getLoginToken(phoneNumber, authCode) { throw new Error(`getLoginToken failed (${resp.status}): ${text}`); } - const result = await resp.json(); - return result?.result?.data || result?.result || result; + const result = await resp.json() as { result?: { data?: LoginTokenResult } & LoginTokenResult }; + return (result?.result?.data || result?.result || result) as LoginTokenResult; +} + +interface FirebaseSignInResult { + idToken?: string; + refreshToken?: string; + localId?: string; + expiresIn?: string; + [key: string]: unknown; } -async function signInWithCustomToken(customToken) { +async function signInWithCustomToken(customToken: string): Promise { const resp = await fetch( `${IDENTITY_TOOLKIT}/v1/accounts:signInWithCustomToken?key=${FIREBASE_API_KEY}`, { @@ -241,10 +275,16 @@ async function signInWithCustomToken(customToken) { throw new Error(`signInWithCustomToken failed (${resp.status}): ${text}`); } - return resp.json(); + return resp.json() as Promise; +} + +interface FirebaseUser { + displayName?: string; + photoUrl?: string; + [key: string]: unknown; } -async function lookupUser(idToken) { +async function lookupUser(idToken: string): Promise { const resp = await fetch( `${IDENTITY_TOOLKIT}/v1/accounts:lookup?key=${FIREBASE_API_KEY}`, { @@ -258,22 +298,22 @@ async function lookupUser(idToken) { ); if (!resp.ok) return null; - const result = await resp.json(); - return result?.users?.[0] || null; + const result = await resp.json() as { users?: FirebaseUser[] }; + return result?.users?.[0] ?? null; } // ─── Commands ───────────────────────────────────────────────── -export function registerAuthCommands(program) { +export function registerAuthCommands(program: Command): void { const auth = program.command('auth').description('Manage authentication'); auth .command('status') .description('Check authentication status and token validity') - .action(async (opts, cmd) => { + .action(async (_opts: unknown, _cmd: Command) => { try { const config = loadConfig(); - const info = { + const info: Record = { user: config.displayName || null, phone: config.phoneNumber || null, userId: config.userId || null, @@ -283,24 +323,24 @@ export function registerAuthCommands(program) { try { await getValidToken(config); - info.tokenValid = true; + info['tokenValid'] = true; } catch (e) { - info.tokenError = e.message; + info['tokenError'] = (e as Error).message; } jsonOutput(info); } catch (e) { - jsonError(e.message, 2, 'auth_error'); + jsonError((e as Error).message, 2, 'auth_error'); } }); auth .command('login') .description('Authenticate via SMS verification code') - .argument('', 'Phone number in E.164 format (e.g. +12066993977)') + .argument('', 'Phone number in E.164 format (e.g. +120****3977)') .option('--code ', 'Skip SMS — provide verification code directly') .option('--no-auto', 'Disable auto-retrieval of SMS code') - .action(async (phone, opts, cmd) => { + .action(async (phone: string, opts: { code?: string; auto?: boolean }, _cmd: Command) => { try { // Normalize phone number let phoneNumber = phone.replace(/[\s\-\(\)]/g, ''); @@ -310,12 +350,12 @@ export function registerAuthCommands(program) { } if (!/^\+\d{10,15}$/.test(phoneNumber)) { - jsonError(`Invalid phone number: ${phoneNumber}. Use E.164 format (+12066993977)`, 3, 'validation_error'); + jsonError(`Invalid phone number: ${phoneNumber}. Use E.164 format (+120****3977)`, 3, 'validation_error'); return; } const platform = detectPlatform(); - let code = opts.code || null; + let code: string | null = opts.code ?? null; if (!code) { // Step 1: Send verification code @@ -384,7 +424,7 @@ export function registerAuthCommands(program) { apiKey: FIREBASE_API_KEY, refreshToken: firebaseResult.refreshToken, accessToken: firebaseResult.idToken, - tokenExpiry: Date.now() + (parseInt(firebaseResult.expiresIn) * 1000), + tokenExpiry: Date.now() + (parseInt(firebaseResult.expiresIn ?? '3600') * 1000), userId: firebaseResult.localId, displayName: user?.displayName || '', phoneNumber: phoneNumber, @@ -405,7 +445,7 @@ export function registerAuthCommands(program) { codeMethod: code === opts.code ? 'provided' : platform.method, }); } catch (e) { - jsonError(e.message, 2, 'auth_error'); + jsonError((e as Error).message, 2, 'auth_error'); } }); diff --git a/src/commands/blasts.js b/src/commands/blasts.ts similarity index 71% rename from src/commands/blasts.js rename to src/commands/blasts.ts index ac221cd..931e206 100644 --- a/src/commands/blasts.js +++ b/src/commands/blasts.ts @@ -6,6 +6,7 @@ * See docs/research/2026-03-24-text-blast-endpoint.md */ +import { Command } from 'commander'; import { loadConfig, getValidToken, wrapPayload } from '../lib/auth.js'; import { apiRequest } from '../lib/http.js'; import { jsonOutput, jsonError } from '../lib/output.js'; @@ -15,7 +16,7 @@ import { confirm } from '../lib/events.js'; const VALID_TO_VALUES = ['GOING', 'MAYBE', 'DECLINED', 'SENT', 'INTERESTED', 'WAITLIST', 'APPROVED', 'RESPONDED_TO_FIND_A_TIME']; const MAX_MESSAGE_LENGTH = 480; -export function registerBlastsCommands(program) { +export function registerBlastsCommands(program: Command): void { const blasts = program.command('blasts').description('Text blasts to event guests'); blasts @@ -26,17 +27,18 @@ export function registerBlastsCommands(program) { .option('--to ', 'Comma-separated guest statuses to send to (default: GOING)', 'GOING') .option('--show-on-event-page', 'Show blast in event activity feed (default: true)') .option('--no-show-on-event-page', 'Hide blast from event activity feed') - .action(async (eventId, opts, cmd) => { - const globalOpts = cmd.optsWithGlobals(); + .action(async (eventId: string, opts: Record, cmd: Command) => { + const globalOpts = cmd.optsWithGlobals>(); try { + const message = opts['message'] as string; // Validate message length - if (opts.message.length > MAX_MESSAGE_LENGTH) { - throw new ValidationError(`Message exceeds ${MAX_MESSAGE_LENGTH} char limit (got ${opts.message.length})`); + if (message.length > MAX_MESSAGE_LENGTH) { + throw new ValidationError(`Message exceeds ${MAX_MESSAGE_LENGTH} char limit (got ${message.length})`); } // Parse and validate 'to' statuses - const toStatuses = opts.to.split(',').map(s => s.trim().toUpperCase()); + const toStatuses = (opts['to'] as string).split(',').map((s: string) => s.trim().toUpperCase()); for (const status of toStatuses) { if (!VALID_TO_VALUES.includes(status)) { throw new ValidationError( @@ -46,7 +48,7 @@ export function registerBlastsCommands(program) { } // Default showOnEventPage to true unless explicitly disabled - const showOnEventPage = opts.showOnEventPage !== false; + const showOnEventPage = opts['showOnEventPage'] !== false; const config = loadConfig(); const token = await getValidToken(config); @@ -56,7 +58,7 @@ export function registerBlastsCommands(program) { params: { eventId, message: { - text: opts.message, + text: message, to: toStatuses, showOnEventPage, }, @@ -66,18 +68,18 @@ export function registerBlastsCommands(program) { }), }; - if (globalOpts.dryRun) { + if (globalOpts['dryRun']) { jsonOutput({ dryRun: true, endpoint: '/createTextBlast', payload }, {}, globalOpts); return; } // Safety confirmation unless --yes - if (!globalOpts.yes) { + if (!globalOpts['yes']) { console.error(`\nText Blast Preview:`); console.error(` Event: ${eventId}`); console.error(` To: ${toStatuses.join(', ')}`); console.error(` Show on event page: ${showOnEventPage}`); - console.error(` Message: "${opts.message}"`); + console.error(` Message: "${message}"`); console.error(''); const ok = await confirm('Send this text blast? This will SMS real people'); if (!ok) { @@ -86,21 +88,23 @@ export function registerBlastsCommands(program) { } } - const result = await apiRequest('POST', '/createTextBlast', token, payload, globalOpts.verbose); + const rawResult = await apiRequest('POST', '/createTextBlast', token, payload, globalOpts['verbose'] as boolean | undefined); + const result = rawResult as Record; + const resultInner = result['result'] as Record | undefined; jsonOutput({ sent: true, eventId, to: toStatuses, - messageLength: opts.message.length, + messageLength: message.length, showOnEventPage, - response: result?.result?.data || result?.result || result, + response: resultInner?.['data'] ?? resultInner ?? rawResult, }, {}, globalOpts); } catch (err) { if (err instanceof PartifulError) { jsonError(err.message, err.exitCode, err.type, err.details); } else { - jsonError(err.message, 1, 'blast_error'); + jsonError(err instanceof Error ? err.message : String(err), 1, 'blast_error'); } } }); diff --git a/src/commands/bulk.js b/src/commands/bulk.ts similarity index 50% rename from src/commands/bulk.js rename to src/commands/bulk.ts index ac0550b..4e5956f 100644 --- a/src/commands/bulk.js +++ b/src/commands/bulk.ts @@ -3,9 +3,19 @@ */ import fs from 'fs'; +import { Command } from 'commander'; import { loadConfig, getValidToken, wrapPayload } from '../lib/auth.js'; +import type { PartifulConfig } from '../lib/auth.js'; +import { parseDateTime } from '../lib/dates.js'; +import { jsonOutput, jsonError } from '../lib/output.js'; +import { apiRequest, firestoreRequest } from '../lib/http.js'; +import { PartifulError } from '../lib/errors.js'; +import { buildBaseEvent } from '../lib/events.js'; +import type { EventOptions } from '../lib/events.js'; + +void parseDateTime; // imported for side-effects/type availability -function makePayload(config, params) { +function makePayload(config: PartifulConfig, params: unknown) { return { data: wrapPayload(config, { params, @@ -14,13 +24,8 @@ function makePayload(config, params) { }), }; } -import { parseDateTime } from '../lib/dates.js'; -import { jsonOutput, jsonError } from '../lib/output.js'; -import { apiRequest, firestoreRequest } from '../lib/http.js'; -import { PartifulError } from '../lib/errors.js'; -import { buildBaseEvent } from '../lib/events.js'; -function handleError(e) { +function handleError(e: unknown) { if (e instanceof PartifulError) { jsonError(e.message, e.exitCode, e.type, e.details); } else { @@ -30,19 +35,19 @@ function handleError(e) { const DEFAULT_DELAY = 1000; // ms between API calls -function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Parse a CSV string into an array of objects (first row = headers). */ -function parseCsv(text) { - const lines = text.split('\n').filter(l => l.trim()); +function parseCsv(text: string): Array> { + const lines = text.split('\n').filter((l) => l.trim()); if (lines.length < 2) return []; - const headers = lines[0].split(',').map(h => h.trim()); - return lines.slice(1).map(line => { - const values = []; + const headers = lines[0]!.split(',').map((h) => h.trim()); + return lines.slice(1).map((line) => { + const values: string[] = []; let current = ''; let inQuotes = false; for (const ch of line) { @@ -51,8 +56,11 @@ function parseCsv(text) { current += ch; } values.push(current.trim()); - const obj = {}; - headers.forEach((h, i) => { if (values[i] !== undefined && values[i] !== '') obj[h] = values[i]; }); + const obj: Record = {}; + headers.forEach((h, i) => { + const v = values[i]; + if (v !== undefined && v !== '') obj[h] = v; + }); return obj; }); } @@ -60,33 +68,33 @@ function parseCsv(text) { /** * Normalize a row from JSON/CSV into the shape buildBaseEvent expects. */ -function normalizeRow(row) { +function normalizeRow(row: Record): EventOptions { return { - title: row.title, - date: row.date || row.startDate, - endDate: row.endDate || row.end_date || row['end-date'], - location: row.location, - address: row.address, - description: row.description, - capacity: row.capacity ? parseInt(row.capacity) : undefined, - private: row.private === true || row.private === 'true', - timezone: row.timezone || 'America/Los_Angeles', - theme: row.theme || 'oxblood', - effect: row.effect || 'sunbeams', - poster: row.poster, - posterSearch: row.posterSearch || row['poster-search'], + title: (row['title'] as string | undefined) ?? '', + date: ((row['date'] ?? row['startDate']) as string | undefined) ?? '', + endDate: (row['endDate'] ?? row['end_date'] ?? row['end-date']) as string | undefined, + location: row['location'] as string | undefined, + address: row['address'] as string | undefined, + description: row['description'] as string | undefined, + capacity: row['capacity'] ? parseInt(row['capacity'] as string) : undefined, + private: row['private'] === true || row['private'] === 'true', + timezone: (row['timezone'] as string | undefined) ?? 'America/Los_Angeles', + theme: (row['theme'] as string | undefined) ?? 'oxblood', + effect: (row['effect'] as string | undefined) ?? 'sunbeams', + poster: row['poster'] as string | undefined, + posterSearch: (row['posterSearch'] ?? row['poster-search']) as string | undefined, }; } -export function registerBulkCommands(program) { +export function registerBulkCommands(program: Command): void { const bulk = program.command('bulk').description('Bulk create or update events'); bulk .command('create ') .description('Create multiple events from a JSON or CSV file') .option('--delay ', 'Delay between API calls (ms)', parseInt, DEFAULT_DELAY) - .action(async (file, opts, cmd) => { - const globalOpts = cmd.optsWithGlobals(); + .action(async (file: string, opts: Record, cmd: Command) => { + const globalOpts = cmd.optsWithGlobals>(); try { if (!fs.existsSync(file)) { jsonError(`File not found: ${file}`, 3, 'validation_error'); @@ -95,12 +103,12 @@ export function registerBulkCommands(program) { const raw = fs.readFileSync(file, 'utf8'); const isCsv = file.endsWith('.csv'); - let rows; + let rows: Array>; if (isCsv) { rows = parseCsv(raw); } else { - rows = JSON.parse(raw); + rows = JSON.parse(raw) as Array>; if (!Array.isArray(rows)) { jsonError('JSON file must contain an array of event objects.', 3, 'validation_error'); return; @@ -120,8 +128,8 @@ export function registerBulkCommands(program) { return n; }); - if (globalOpts.dryRun) { - jsonOutput(normalized.map(n => buildBaseEvent(n).event), { + if (globalOpts['dryRun']) { + jsonOutput(normalized.map((n) => buildBaseEvent(n).event), { total: normalized.length, action: 'dry_run', hint: 'Remove --dry-run to create these events', @@ -131,28 +139,32 @@ export function registerBulkCommands(program) { const config = loadConfig(); const token = await getValidToken(config); - const results = []; + const results: Array> = []; for (let i = 0; i < normalized.length; i++) { - const { event } = buildBaseEvent(normalized[i]); + const n = normalized[i]!; + const { event } = buildBaseEvent(n); const payload = makePayload(config, { event, cohostIds: [] }); try { - const resp = await apiRequest('POST', '/createEvent', token, payload, globalOpts.verbose); - results.push({ index: i + 1, status: 'created', title: normalized[i].title, eventId: resp.result?.data || resp.result?.eventId }); - process.stderr.write(`[${i + 1}/${normalized.length}] Created: ${normalized[i].title}\n`); + const rawResp = await apiRequest('POST', '/createEvent', token, payload, globalOpts['verbose'] as boolean | undefined); + const resp = rawResp as Record; + const respResult = resp['result'] as Record | undefined; + results.push({ index: i + 1, status: 'created', title: n.title, eventId: respResult?.['data'] ?? respResult?.['eventId'] }); + process.stderr.write(`[${i + 1}/${normalized.length}] Created: ${n.title}\n`); } catch (e) { - results.push({ index: i + 1, status: 'error', title: normalized[i].title, error: e.message }); - process.stderr.write(`[${i + 1}/${normalized.length}] Failed: ${normalized[i].title} — ${e.message}\n`); + const msg = e instanceof Error ? e.message : String(e); + results.push({ index: i + 1, status: 'error', title: n.title, error: msg }); + process.stderr.write(`[${i + 1}/${normalized.length}] Failed: ${n.title} — ${msg}\n`); } - if (i < normalized.length - 1) await sleep(opts.delay); + if (i < normalized.length - 1) await sleep(opts['delay'] as number); } jsonOutput(results, { total: results.length, - created: results.filter(r => r.status === 'created').length, - errors: results.filter(r => r.status === 'error').length, + created: results.filter((r) => r['status'] === 'created').length, + errors: results.filter((r) => r['status'] === 'error').length, }, globalOpts); } catch (e) { handleError(e); @@ -160,9 +172,9 @@ export function registerBulkCommands(program) { }); // Series creation: --repeat weekly --count 4 - const events = program.commands.find(c => c.name() === 'events'); + const events = program.commands.find((c) => c.name() === 'events'); if (events) { - const create = events.commands.find(c => c.name() === 'create'); + const create = events.commands.find((c) => c.name() === 'create'); if (create) { create .option('--repeat ', 'Create a series: daily, weekly, biweekly, monthly') @@ -178,8 +190,8 @@ export function registerBulkCommands(program) { .option('--location ', 'New location') .option('--description ', 'New description') .option('--delay ', 'Delay between API calls (ms)', parseInt, DEFAULT_DELAY) - .action(async (opts, cmd) => { - const globalOpts = cmd.optsWithGlobals(); + .action(async (opts: Record, cmd: Command) => { + const globalOpts = cmd.optsWithGlobals>(); try { const config = loadConfig(); const token = await getValidToken(config); @@ -192,49 +204,56 @@ export function registerBulkCommands(program) { userId: config.userId, }), }; - const listResp = await apiRequest('POST', '/getMyUpcomingEventsForHomePage', token, listPayload, globalOpts.verbose); - const allEvents = listResp.result?.data?.upcomingEvents || []; + const rawListResp = await apiRequest('POST', '/getMyUpcomingEventsForHomePage', token, listPayload, globalOpts['verbose'] as boolean | undefined); + const listResp = rawListResp as Record; + const listResult = listResp['result'] as Record | undefined; + const listData = listResult?.['data'] as Record | undefined; + const allEvents = ((listData?.['upcomingEvents'] ?? []) as Array>); // Parse filter: "title contains " - const filterMatch = opts.filter.match(/^title\s+contains\s+(.+)$/i); + const filterStr = opts['filter'] as string; + const filterMatch = filterStr.match(/^title\s+contains\s+(.+)$/i); if (!filterMatch) { jsonError('Filter format: "title contains ". More filters coming soon.', 3, 'validation_error'); return; } - const filterText = filterMatch[1].toLowerCase(); - const matched = allEvents.filter(e => e.title && e.title.toLowerCase().includes(filterText)); + const filterText = filterMatch[1]!.toLowerCase(); + const matched = allEvents.filter((e) => { + const title = e['title'] as string | undefined; + return title && title.toLowerCase().includes(filterText); + }); if (matched.length === 0) { - jsonOutput([], { total: 0, filter: opts.filter, hint: 'No events matched the filter' }, globalOpts); + jsonOutput([], { total: 0, filter: opts['filter'], hint: 'No events matched the filter' }, globalOpts); return; } // Build update fields - const updates = {}; - if (opts.capacity) updates.guestLimit = opts.capacity; - if (opts.location) updates.location = opts.location; - if (opts.description) updates.description = opts.description; + const updates: Record = {}; + if (opts['capacity']) updates['guestLimit'] = opts['capacity']; + if (opts['location']) updates['location'] = opts['location']; + if (opts['description']) updates['description'] = opts['description']; if (Object.keys(updates).length === 0) { jsonError('No update fields provided. Use --capacity, --location, or --description.', 3, 'validation_error'); return; } - if (globalOpts.dryRun) { - jsonOutput(matched.map(e => ({ - eventId: e.id, - title: e.title, + if (globalOpts['dryRun']) { + jsonOutput(matched.map((e) => ({ + eventId: e['id'], + title: e['title'], updates, })), { total: matched.length, action: 'dry_run' }, globalOpts); return; } - const results = []; + const results: Array> = []; for (let i = 0; i < matched.length; i++) { - const e = matched[i]; + const e = matched[i]!; try { - const fields = {}; - const updateFields = []; + const fields: Record = {}; + const updateFields: string[] = []; for (const [key, val] of Object.entries(updates)) { if (typeof val === 'number') { fields[key] = { integerValue: val }; @@ -244,21 +263,22 @@ export function registerBulkCommands(program) { updateFields.push(key); } - await firestoreRequest('PATCH', e.id, { fields }, token, updateFields, globalOpts.verbose); - results.push({ eventId: e.id, title: e.title, status: 'updated' }); - process.stderr.write(`[${i + 1}/${matched.length}] Updated: ${e.title}\n`); + await firestoreRequest('PATCH', e['id'] as string, { fields }, token, updateFields, globalOpts['verbose'] as boolean | undefined); + results.push({ eventId: e['id'], title: e['title'], status: 'updated' }); + process.stderr.write(`[${i + 1}/${matched.length}] Updated: ${e['title']}\n`); } catch (err) { - results.push({ eventId: e.id, title: e.title, status: 'error', error: err.message }); - process.stderr.write(`[${i + 1}/${matched.length}] Failed: ${e.title} — ${err.message}\n`); + const msg = err instanceof Error ? err.message : String(err); + results.push({ eventId: e['id'], title: e['title'], status: 'error', error: msg }); + process.stderr.write(`[${i + 1}/${matched.length}] Failed: ${e['title']} — ${msg}\n`); } - if (i < matched.length - 1) await sleep(opts.delay); + if (i < matched.length - 1) await sleep(opts['delay'] as number); } jsonOutput(results, { total: results.length, - updated: results.filter(r => r.status === 'updated').length, - errors: results.filter(r => r.status === 'error').length, + updated: results.filter((r) => r['status'] === 'updated').length, + errors: results.filter((r) => r['status'] === 'error').length, }, globalOpts); } catch (e) { handleError(e); diff --git a/src/commands/cohosts.js b/src/commands/cohosts.ts similarity index 54% rename from src/commands/cohosts.js rename to src/commands/cohosts.ts index d140eaf..4ae8ea6 100644 --- a/src/commands/cohosts.js +++ b/src/commands/cohosts.ts @@ -2,26 +2,27 @@ * Cohosts commands: list, add, remove */ +import { Command } from 'commander'; import { loadConfig, getValidToken, wrapPayload } from '../lib/auth.js'; import { apiRequest } from '../lib/http.js'; import { resolveCohostNames, getCohostIds, setCohostIds } from '../lib/cohosts.js'; import { jsonOutput, jsonError } from '../lib/output.js'; import { PartifulError } from '../lib/errors.js'; -export function registerCohostsCommands(program) { +export function registerCohostsCommands(program: Command): void { const cohosts = program.command('cohosts').description('Manage event co-hosts'); cohosts .command('list') .description('List co-hosts for an event') .argument('', 'Event ID') - .action(async (eventId, opts, cmd) => { - const globalOpts = cmd.optsWithGlobals(); + .action(async (eventId: string, opts: Record, cmd: Command) => { + const globalOpts = cmd.optsWithGlobals>(); try { const config = loadConfig(); const token = await getValidToken(config); - const ids = await getCohostIds(eventId, token, globalOpts.verbose); + const ids = await getCohostIds(eventId, token, globalOpts['verbose'] as boolean | undefined); if (ids.length === 0) { jsonOutput([], { eventId, count: 0 }); return; @@ -29,18 +30,20 @@ export function registerCohostsCommands(program) { // Cross-reference with contacts for names const contactsPayload = { data: wrapPayload(config, { params: {}, amplitudeSessionId: Date.now(), userId: config.userId }) }; - const contactsResult = await apiRequest('POST', '/getContacts', token, contactsPayload, globalOpts.verbose); - const allContacts = contactsResult.result?.data || []; - - const result = ids.map(id => { - const contact = allContacts.find(c => c.userId === id); - return { userId: id, name: contact?.name || null }; + const contactsRaw = await apiRequest('POST', '/getContacts', token, contactsPayload, globalOpts['verbose'] as boolean | undefined); + const contactsResult = contactsRaw as Record; + const resultData = contactsResult['result'] as Record | undefined; + const allContacts = (resultData?.['data'] ?? []) as Array>; + + const result = ids.map((id: string) => { + const contact = allContacts.find((c) => c['userId'] === id); + return { userId: id, name: (contact?.['name'] as string | undefined) ?? null }; }); jsonOutput(result, { eventId, count: result.length }); } catch (e) { if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); - else jsonError(e.message); + else jsonError(e instanceof Error ? e.message : String(e)); } }); @@ -50,10 +53,10 @@ export function registerCohostsCommands(program) { .argument('', 'Event ID') .option('--name ', 'Co-host names (resolved from contacts)') .option('--user-id ', 'Co-host user IDs (direct)') - .action(async (eventId, opts, cmd) => { - const globalOpts = cmd.optsWithGlobals(); + .action(async (eventId: string, opts: Record, cmd: Command) => { + const globalOpts = cmd.optsWithGlobals>(); try { - if (!opts.name && !opts.userId) { + if (!opts['name'] && !opts['userId']) { jsonError('Provide --name or --user-id to specify co-hosts', 3, 'validation_error'); return; } @@ -61,37 +64,37 @@ export function registerCohostsCommands(program) { const config = loadConfig(); const token = await getValidToken(config); - const currentIds = await getCohostIds(eventId, token, globalOpts.verbose); + const currentIds = await getCohostIds(eventId, token, globalOpts['verbose'] as boolean | undefined); const newIds = [...currentIds]; // Resolve names - const resolved = await resolveCohostNames(opts.name, token, config, globalOpts.verbose); + const resolved = await resolveCohostNames((opts['name'] as string[] | undefined) ?? [], token, config, globalOpts['verbose'] as boolean | undefined); for (const id of resolved) { if (!newIds.includes(id)) newIds.push(id); } // Add direct user IDs - for (const id of (opts.userId || [])) { + for (const id of ((opts['userId'] as string[] | undefined) ?? [])) { if (!newIds.includes(id)) newIds.push(id); } - const added = newIds.filter(id => !currentIds.includes(id)); + const added = newIds.filter((id) => !currentIds.includes(id)); if (added.length === 0) { jsonOutput({ eventId, added: [], total: currentIds.length, message: 'No new co-hosts to add' }); return; } - if (globalOpts.dryRun) { + if (globalOpts['dryRun']) { jsonOutput({ dryRun: true, eventId, currentCohosts: currentIds, newCohosts: newIds }); return; } - await setCohostIds(eventId, newIds, token, globalOpts.verbose); + await setCohostIds(eventId, newIds, token, globalOpts['verbose'] as boolean | undefined); jsonOutput({ eventId, added, total: newIds.length, url: `https://partiful.com/e/${eventId}` }); } catch (e) { if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); - else jsonError(e.message); + else jsonError(e instanceof Error ? e.message : String(e)); } }); @@ -100,32 +103,33 @@ export function registerCohostsCommands(program) { .description('Remove a co-host from an event') .argument('', 'Event ID') .requiredOption('--user-id ', 'User ID of the co-host to remove') - .action(async (eventId, opts, cmd) => { - const globalOpts = cmd.optsWithGlobals(); + .action(async (eventId: string, opts: Record, cmd: Command) => { + const globalOpts = cmd.optsWithGlobals>(); try { const config = loadConfig(); const token = await getValidToken(config); - const currentIds = await getCohostIds(eventId, token, globalOpts.verbose); + const currentIds = await getCohostIds(eventId, token, globalOpts['verbose'] as boolean | undefined); + const userId = opts['userId'] as string; - if (!currentIds.includes(opts.userId)) { - jsonError(`User ${opts.userId} is not a co-host of this event`, 4, 'not_found'); + if (!currentIds.includes(userId)) { + jsonError(`User ${userId} is not a co-host of this event`, 4, 'not_found'); return; } - const newIds = currentIds.filter(id => id !== opts.userId); + const newIds = currentIds.filter((id) => id !== userId); - if (globalOpts.dryRun) { - jsonOutput({ dryRun: true, eventId, removing: opts.userId, remaining: newIds }); + if (globalOpts['dryRun']) { + jsonOutput({ dryRun: true, eventId, removing: userId, remaining: newIds }); return; } - await setCohostIds(eventId, newIds, token, globalOpts.verbose); + await setCohostIds(eventId, newIds, token, globalOpts['verbose'] as boolean | undefined); - jsonOutput({ eventId, removed: opts.userId, remaining: newIds.length, url: `https://partiful.com/e/${eventId}` }); + jsonOutput({ eventId, removed: userId, remaining: newIds.length, url: `https://partiful.com/e/${eventId}` }); } catch (e) { if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); - else jsonError(e.message); + else jsonError(e instanceof Error ? e.message : String(e)); } }); } diff --git a/src/commands/contacts.js b/src/commands/contacts.ts similarity index 55% rename from src/commands/contacts.js rename to src/commands/contacts.ts index 78029ab..15673d0 100644 --- a/src/commands/contacts.js +++ b/src/commands/contacts.ts @@ -2,12 +2,13 @@ * Contacts commands: list/search */ +import { Command } from 'commander'; import { loadConfig, getValidToken, wrapPayload } from '../lib/auth.js'; import { apiRequest } from '../lib/http.js'; import { jsonOutput, jsonError } from '../lib/output.js'; import { PartifulError } from '../lib/errors.js'; -export function registerContactsCommands(program) { +export function registerContactsCommands(program: Command): void { const contacts = program.command('contacts').description('Manage contacts'); contacts @@ -15,8 +16,8 @@ export function registerContactsCommands(program) { .description('List or search contacts') .argument('[query]', 'Optional name search filter') .option('--limit ', 'Max results to return', parseInt, 20) - .action(async (query, opts, cmd) => { - const globalOpts = cmd.optsWithGlobals(); + .action(async (query: string | undefined, opts: Record, cmd: Command) => { + const globalOpts = cmd.optsWithGlobals>(); try { const config = loadConfig(); const token = await getValidToken(config); @@ -29,28 +30,31 @@ export function registerContactsCommands(program) { }), }; - if (globalOpts.dryRun) { + if (globalOpts['dryRun']) { jsonOutput({ dryRun: true, endpoint: '/getContacts', payload }); return; } - const result = await apiRequest('POST', '/getContacts', token, payload, globalOpts.verbose); - let contactList = result.result?.data || []; + const rawResult = await apiRequest('POST', '/getContacts', token, payload, globalOpts['verbose'] as boolean | undefined); + const result = rawResult as Record; + const resultData = result['result'] as Record | undefined; + let contactList = ((resultData?.['data'] ?? []) as Array>); if (query) { const q = query.toLowerCase(); - contactList = contactList.filter(c => (c.name || '').toLowerCase().includes(q)); + contactList = contactList.filter((c) => ((c['name'] as string | undefined) ?? '').toLowerCase().includes(q)); } - contactList = contactList.slice(0, opts.limit); + const limit = opts['limit'] as number; + contactList = contactList.slice(0, limit); jsonOutput(contactList, { count: contactList.length, - query: query || null, + query: query ?? null, }); } catch (e) { if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); - else jsonError(e.message); + else jsonError(e instanceof Error ? e.message : String(e)); } }); } diff --git a/src/commands/doctor.js b/src/commands/doctor.ts similarity index 70% rename from src/commands/doctor.js rename to src/commands/doctor.ts index b7aded1..a5fd969 100644 --- a/src/commands/doctor.js +++ b/src/commands/doctor.ts @@ -5,12 +5,25 @@ import os from 'os'; import fs from 'fs'; import { execSync } from 'child_process'; +import { Command } from 'commander'; import { loadConfig, refreshAccessToken, resolveCredentialsPath, wrapPayload } from '../lib/auth.js'; +import type { PartifulConfig } from '../lib/auth.js'; import { apiRequest } from '../lib/http.js'; import { jsonOutput, jsonError } from '../lib/output.js'; import { PartifulError } from '../lib/errors.js'; -const CHECKS = [ +interface CheckDef { + name: string; + label: string; +} + +interface CheckResult { + name: string; + passed: boolean; + detail: string; +} + +const CHECKS: CheckDef[] = [ { name: 'config_file', label: 'Config file' }, { name: 'token_refresh', label: 'Token refresh' }, { name: 'api_connectivity', label: 'API connectivity' }, @@ -18,35 +31,38 @@ const CHECKS = [ { name: 'platform', label: 'Platform' }, ]; -async function runChecks() { - const results = []; +async function runChecks(): Promise { + const results: CheckResult[] = []; // 1. Config file const configPath = resolveCredentialsPath(); - const displayPath = configPath.replace(process.env.HOME, '~'); - let config = null; + const home = process.env['HOME'] ?? ''; + const displayPath = configPath.replace(home, '~'); + let config: PartifulConfig | null = null; try { if (!fs.existsSync(configPath)) { results.push({ name: 'config_file', passed: false, detail: `Not found: ${displayPath}` }); } else { const raw = fs.readFileSync(configPath, 'utf8'); - let parsed; + let parsed: Record | null = null; try { - parsed = JSON.parse(raw); + parsed = JSON.parse(raw) as Record; } catch { results.push({ name: 'config_file', passed: false, detail: 'Invalid JSON' }); } - const required = ['apiKey', 'refreshToken', 'userId']; - const missing = required.filter(f => !parsed[f]); - if (missing.length > 0) { - results.push({ name: 'config_file', passed: false, detail: `Missing fields: ${missing.join(', ')}` }); - } else { - config = parsed; - results.push({ name: 'config_file', passed: true, detail: displayPath }); + if (parsed) { + const required = ['apiKey', 'refreshToken', 'userId']; + const missing = required.filter(f => !parsed![f]); + if (missing.length > 0) { + results.push({ name: 'config_file', passed: false, detail: `Missing fields: ${missing.join(', ')}` }); + } else { + config = parsed as PartifulConfig; + results.push({ name: 'config_file', passed: true, detail: displayPath }); + } } } } catch (e) { - results.push({ name: 'config_file', passed: false, detail: e.message }); + results.push({ name: 'config_file', passed: false, detail: (e as Error).message }); } // 2. Token refresh @@ -55,14 +71,14 @@ async function runChecks() { } else { try { const tokenResult = await refreshAccessToken(config); - const expiresIn = parseInt(tokenResult.expires_in) || 0; + const expiresIn = parseInt(String(tokenResult.expires_in)) || 0; const minutes = Math.floor(expiresIn / 60); config.accessToken = tokenResult.id_token; config.tokenExpiry = Date.now() + expiresIn * 1000; if (tokenResult.refresh_token) config.refreshToken = tokenResult.refresh_token; results.push({ name: 'token_refresh', passed: true, detail: `Token valid for ${minutes} min` }); } catch (e) { - results.push({ name: 'token_refresh', passed: false, detail: e.message }); + results.push({ name: 'token_refresh', passed: false, detail: (e as Error).message }); } } @@ -81,14 +97,14 @@ async function runChecks() { await apiRequest('POST', '/getMyUpcomingEventsForHomePage', config.accessToken, payload, false); results.push({ name: 'api_connectivity', passed: true, detail: 'api.partiful.com reachable' }); } catch (e) { - results.push({ name: 'api_connectivity', passed: false, detail: e.message }); + results.push({ name: 'api_connectivity', passed: false, detail: (e as Error).message }); } } // 4. Environment try { const pkgPath = new URL('../../package.json', import.meta.url); - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { version: string }; results.push({ name: 'environment', passed: true, @@ -98,14 +114,14 @@ async function runChecks() { results.push({ name: 'environment', passed: false, - detail: `Unable to read runtime metadata: ${e.message}`, + detail: `Unable to read runtime metadata: ${(e as Error).message}`, }); } // 5. Platform const platform = os.platform(); const arch = os.arch(); - let smsDetail; + let smsDetail: string; if (platform === 'darwin') { try { execSync('which imsg', { stdio: 'ignore' }); @@ -113,7 +129,7 @@ async function runChecks() { } catch { smsDetail = 'imsg not found'; } - } else if (platform === 'linux' && process.env.TERMUX_VERSION) { + } else if (platform === 'linux' && process.env['TERMUX_VERSION']) { smsDetail = 'termux detected'; } else { smsDetail = 'no SMS auto-retrieve'; @@ -127,12 +143,12 @@ async function runChecks() { return results; } -function printTable(checks) { +function printTable(checks: CheckResult[]): void { process.stderr.write('\nPartiful CLI — Doctor\n'); process.stderr.write('─'.repeat(50) + '\n'); for (const check of checks) { const icon = check.passed ? '✓' : '✗'; - const label = CHECKS.find(c => c.name === check.name)?.label || check.name; + const label = CHECKS.find(c => c.name === check.name)?.label ?? check.name; process.stderr.write(` ${icon} ${label.padEnd(20)} ${check.detail}\n`); } const allPassed = checks.every(c => c.passed); @@ -140,14 +156,14 @@ function printTable(checks) { process.stderr.write(allPassed ? ' All checks passed ✓\n\n' : ' Some checks failed ✗\n\n'); } -export function registerDoctorCommands(program) { +export function registerDoctorCommands(program: Command): void { program .command('doctor') .description('Check CLI setup health and report diagnostics') - .action(async (opts, cmd) => { - const globalOpts = cmd.optsWithGlobals(); + .action(async (_opts: Record, cmd: Command) => { + const globalOpts = cmd.optsWithGlobals>(); - if (globalOpts.dryRun) { + if (globalOpts['dryRun']) { jsonOutput({ checks: CHECKS.map(c => ({ name: c.name, label: c.label })), note: 'Dry run — no checks executed', @@ -159,7 +175,7 @@ export function registerDoctorCommands(program) { const checks = await runChecks(); const allPassed = checks.every(c => c.passed); - if (globalOpts.format !== 'json') { + if (globalOpts['format'] !== 'json') { printTable(checks); } @@ -172,7 +188,7 @@ export function registerDoctorCommands(program) { if (!allPassed) process.exit(1); } catch (e) { if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); - else jsonError(e.message); + else jsonError((e as Error).message); } }); } diff --git a/src/commands/events.js b/src/commands/events.ts similarity index 51% rename from src/commands/events.js rename to src/commands/events.ts index 9dc1436..256743b 100644 --- a/src/commands/events.js +++ b/src/commands/events.ts @@ -2,6 +2,9 @@ * Events commands: list, get, create, update, cancel */ +import type { Command } from 'commander'; +import type { EventOptions } from '../lib/events.js'; +import type { Template } from '../lib/templates.js'; import { loadConfig, getValidToken, wrapPayload, getUserIdFromToken } from '../lib/auth.js'; import { resolveCohostNames } from '../lib/cohosts.js'; import { fetchCatalog, searchPosters, buildPosterImage } from '../lib/posters.js'; @@ -18,7 +21,7 @@ import { /** * Build the standard API payload wrapper. */ -function makePayload(config, params) { +function makePayload(config: ReturnType, params: Record): Record { return { data: wrapPayload(config, { params, @@ -31,12 +34,12 @@ function makePayload(config, params) { /** * Standard error handler for action callbacks. */ -function handleError(e) { +function handleError(e: unknown): void { if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); - else jsonError(e.message); + else jsonError((e as Error).message); } -export function registerEventsCommands(program) { +export function registerEventsCommands(program: Command): void { const events = program.command('events').description('Manage events'); events @@ -44,42 +47,44 @@ export function registerEventsCommands(program) { .description('List upcoming (or past) events') .option('--past', 'Show past events instead of upcoming') .option('--include-cancelled', 'Include cancelled events') - .action(async (opts, cmd) => { - const globalOpts = cmd.optsWithGlobals(); + .action(async (opts: Record, cmd: Command) => { + const globalOpts = cmd.optsWithGlobals>(); try { const config = loadConfig(); const token = await getValidToken(config); - const endpoint = opts.past + const endpoint = opts['past'] ? '/getMyPastEventsForHomePage' : '/getMyUpcomingEventsForHomePage'; const payload = makePayload(config, {}); - if (globalOpts.dryRun) { + if (globalOpts['dryRun']) { jsonOutput({ dryRun: true, endpoint, payload }); return; } - const result = await apiRequest('POST', endpoint, token, payload, globalOpts.verbose); + const result = await apiRequest('POST', endpoint, token, payload, globalOpts['verbose'] as boolean | undefined) as { + result?: { data?: { pastEvents?: unknown[]; upcomingEvents?: unknown[] } }; + }; - let eventList = opts.past + let eventList = opts['past'] ? result.result?.data?.pastEvents : result.result?.data?.upcomingEvents; - if (!opts.includeCancelled && eventList) { - eventList = eventList.filter(e => e.status !== 'CANCELED'); + if (!opts['includeCancelled'] && eventList) { + eventList = eventList.filter((e: unknown) => (e as { status?: string }).status !== 'CANCELED'); } // Identify the authenticated user so we can surface their own RSVP // (myRsvp) and host status. config.userId is backfilled on token // refresh, but fall back to decoding the token directly for the // PARTIFUL_TOKEN env path where config.userId is never set. - const me = config.userId || getUserIdFromToken(token); + const me = (config.userId ?? getUserIdFromToken(token)) as string | null; - const mapped = (eventList || []).map(e => mapEventSummary(e, me)); + const mapped = (eventList || []).map((e: unknown) => mapEventSummary(e as Parameters[0], me)); - jsonOutput(mapped, { count: mapped.length, type: opts.past ? 'past' : 'upcoming' }); + jsonOutput(mapped, { count: mapped.length, type: opts['past'] ? 'past' : 'upcoming' }); } catch (e) { handleError(e); } @@ -89,20 +94,22 @@ export function registerEventsCommands(program) { .command('get') .description('Get event details') .argument('', 'Event ID') - .action(async (eventId, opts, cmd) => { - const globalOpts = cmd.optsWithGlobals(); + .action(async (eventId: string, opts: Record, cmd: Command) => { + const globalOpts = cmd.optsWithGlobals>(); try { const config = loadConfig(); const token = await getValidToken(config); const payload = makePayload(config, { eventId }); - if (globalOpts.dryRun) { + if (globalOpts['dryRun']) { jsonOutput({ dryRun: true, endpoint: '/getEventInfo', payload }); return; } - const result = await apiRequest('POST', '/getEventInfo', token, payload, globalOpts.verbose); + const result = await apiRequest('POST', '/getEventInfo', token, payload, globalOpts['verbose'] as boolean | undefined) as { + result?: { data?: { event?: Record } }; + }; const event = result.result?.data?.event; if (!event) { @@ -112,17 +119,17 @@ export function registerEventsCommands(program) { jsonOutput({ id: eventId, - title: event.title, - startDate: event.startDate, - endDate: event.endDate || null, - location: event.location || null, - address: event.address || null, - description: event.description || null, - status: event.status, - timezone: event.timezone || null, - visibility: event.visibility || null, - guestStatusCounts: event.guestStatusCounts || {}, - displaySettings: event.displaySettings || {}, + title: event['title'], + startDate: event['startDate'], + endDate: event['endDate'] ?? null, + location: event['location'] ?? null, + address: event['address'] ?? null, + description: event['description'] ?? null, + status: event['status'], + timezone: event['timezone'] ?? null, + visibility: event['visibility'] ?? null, + guestStatusCounts: event['guestStatusCounts'] ?? {}, + displaySettings: event['displaySettings'] ?? {}, url: `https://partiful.com/e/${eventId}`, }); } catch (e) { @@ -152,35 +159,35 @@ export function registerEventsCommands(program) { .option('--template ', 'Create from a saved template') .option('--var ', 'Template variables (key=value)') .option('--cohost ', 'Co-host names (resolved from contacts)') - .action(async (opts, cmd) => { - const globalOpts = cmd.optsWithGlobals(); + .action(async (opts: Record, cmd: Command) => { + const globalOpts = cmd.optsWithGlobals>(); try { // Template merging - if (opts.template) { + if (opts['template']) { const { loadTemplates, applyVariables, mergeTemplateOpts } = await import('../lib/templates.js'); const templates = loadTemplates(); - if (!templates[opts.template]) { - jsonError(`Template "${opts.template}" not found. Use "partiful template list" to see available templates.`, 4, 'not_found'); + if (!templates[opts['template'] as string]) { + jsonError(`Template "${opts['template']}" not found. Use "partiful template list" to see available templates.`, 4, 'not_found'); return; } - let tpl = templates[opts.template]; - if (opts.var) { - const vars = {}; - for (const v of opts.var) { + let tpl = templates[opts['template'] as string] as Template; + if (opts['var']) { + const vars: Record = {}; + for (const v of opts['var'] as string[]) { const eq = v.indexOf('='); if (eq > 0) vars[v.slice(0, eq)] = v.slice(eq + 1); } - tpl = applyVariables(tpl, vars); + tpl = applyVariables(tpl as Template, vars) as Template; } - const merged = mergeTemplateOpts(tpl, opts); + const merged = mergeTemplateOpts(tpl as Template, opts) as Record; Object.assign(opts, merged); } - if (!opts.title) { + if (!opts['title']) { jsonError('--title is required (provide directly or via --template).', 3, 'validation_error'); return; } - if (!opts.date) { + if (!opts['date']) { jsonError('--date is required (provide directly or via --template).', 3, 'validation_error'); return; } @@ -188,78 +195,78 @@ export function registerEventsCommands(program) { const config = loadConfig(); const token = await getValidToken(config); - validateImageOptions(opts.poster, opts.posterSearch, opts.image); + validateImageOptions(opts['poster'], opts['posterSearch'], opts['image']); // Validate image extension early (before dry-run check) — skip for URLs - if (opts.image && !isUrl(opts.image)) { + if (opts['image'] && !isUrl(opts['image'] as string)) { const { extname } = await import('path'); - const ext = extname(opts.image).toLowerCase(); - if (!ALLOWED_IMAGE_EXTENSIONS.includes(ext)) { + const ext = extname(opts['image'] as string).toLowerCase(); + if (!ALLOWED_IMAGE_EXTENSIONS.includes(ext as typeof ALLOWED_IMAGE_EXTENSIONS[number])) { jsonError(`Unsupported image type "${ext}". Allowed types: ${ALLOWED_IMAGE_EXTENSIONS.join(', ')}`, 3, 'validation_error'); return; } } - const { event, startDate } = buildBaseEvent(opts); + const { event, startDate } = buildBaseEvent(opts as unknown as EventOptions); // Links - const links = buildLinks(opts.link, opts.linkText); - if (links) event.links = links; + const links = buildLinks(opts['link'] as string[] | undefined, opts['linkText'] as string[] | undefined); + if (links) event['links'] = links; // Poster/image handling const posterImage = await resolvePosterImage(opts, fetchCatalog, searchPosters, buildPosterImage); if (posterImage) { - event.image = posterImage; - } else if (opts.image) { - event.image = await resolveUploadImage(opts.image, token, config, globalOpts.verbose, globalOpts.dryRun); + event['image'] = posterImage; + } else if (opts['image']) { + event['image'] = await resolveUploadImage(opts['image'] as string, token, config, globalOpts['verbose'] as boolean | undefined, globalOpts['dryRun'] as boolean | undefined); } - const cohostIds = await resolveCohostNames(opts.cohost, token, config, globalOpts.verbose); + const cohostIds = await resolveCohostNames((opts['cohost'] as string[] | undefined) ?? [], token, config, globalOpts['verbose'] as boolean | undefined); const payload = makePayload(config, { event, cohostIds }); - if (globalOpts.dryRun) { - jsonOutput({ dryRun: true, endpoint: '/createEvent', payload, cohostsResolved: cohostIds.length, ...(opts.repeat ? { series: { repeat: opts.repeat, count: opts.count } } : {}) }); + if (globalOpts['dryRun']) { + jsonOutput({ dryRun: true, endpoint: '/createEvent', payload, cohostsResolved: cohostIds.length, ...(opts['repeat'] ? { series: { repeat: opts['repeat'], count: opts['count'] } } : {}) }); return; } // Series creation: --repeat weekly --count 4 - if (opts.repeat && opts.count && opts.count > 1) { - const results = []; - const intervals = { daily: 1, weekly: 7, biweekly: 14 }; - for (let i = 0; i < opts.count; i++) { + if (opts['repeat'] && opts['count'] && (opts['count'] as number) > 1) { + const results: unknown[] = []; + const intervals: Record = { daily: 1, weekly: 7, biweekly: 14 }; + for (let i = 0; i < (opts['count'] as number); i++) { const d = new Date(startDate); - if (opts.repeat === 'monthly') { + if (opts['repeat'] === 'monthly') { d.setMonth(d.getMonth() + i); } else { - const days = intervals[opts.repeat]; - if (!days) { jsonError(`Unknown repeat: ${opts.repeat}. Use: daily, weekly, biweekly, monthly`, 3, 'validation_error'); return; } + const days = intervals[opts['repeat'] as string]; + if (!days) { jsonError(`Unknown repeat: ${opts['repeat']}. Use: daily, weekly, biweekly, monthly`, 3, 'validation_error'); return; } d.setDate(d.getDate() + (i * days)); } const seriesEvent = { ...event, startDate: d.toISOString() }; const seriesPayload = makePayload(config, { event: seriesEvent, cohostIds }); try { - const res = await apiRequest('POST', '/createEvent', token, seriesPayload, globalOpts.verbose); - const id = res.result?.data || res.result?.eventId; - results.push({ index: i + 1, status: 'created', title: opts.title, date: d.toISOString(), id, url: `https://partiful.com/e/${id}` }); - process.stderr.write(`[${i + 1}/${opts.count}] Created: ${opts.title} (${d.toLocaleDateString()})\n`); + const res = await apiRequest('POST', '/createEvent', token, seriesPayload, globalOpts['verbose'] as boolean | undefined) as { result?: { data?: unknown; eventId?: string } }; + const id = res.result?.data ?? res.result?.eventId; + results.push({ index: i + 1, status: 'created', title: opts['title'], date: d.toISOString(), id, url: `https://partiful.com/e/${String(id)}` }); + process.stderr.write(`[${i + 1}/${opts['count']}] Created: ${opts['title']} (${d.toLocaleDateString()})\n`); } catch (err) { - results.push({ index: i + 1, status: 'error', title: opts.title, date: d.toISOString(), error: err.message }); + results.push({ index: i + 1, status: 'error', title: opts['title'], date: d.toISOString(), error: (err as Error).message }); } - if (i < opts.count - 1) await new Promise(r => setTimeout(r, 1000)); + if (i < (opts['count'] as number) - 1) await new Promise(r => setTimeout(r, 1000)); } - jsonOutput(results, { total: results.length, repeat: opts.repeat }); + jsonOutput(results, { total: results.length, repeat: opts['repeat'] }); return; } - const result = await apiRequest('POST', '/createEvent', token, payload, globalOpts.verbose); - const newEventId = result.result?.data || result.result?.eventId; + const result = await apiRequest('POST', '/createEvent', token, payload, globalOpts['verbose'] as boolean | undefined) as { result?: { data?: unknown; eventId?: string } }; + const newEventId = result.result?.data ?? result.result?.eventId; jsonOutput({ id: newEventId, - title: opts.title, + title: opts['title'], startDate: startDate.toISOString(), - url: `https://partiful.com/e/${newEventId}`, + url: `https://partiful.com/e/${String(newEventId)}`, }); } catch (e) { handleError(e); @@ -282,29 +289,29 @@ export function registerEventsCommands(program) { .option('--link ', 'Link URL (repeatable)') .option('--link-text ', 'Display text for link (paired with --link by position)') .option('--cohost ', 'Co-host names (resolved from contacts)') - .action(async (eventId, opts, cmd) => { - const globalOpts = cmd.optsWithGlobals(); + .action(async (eventId: string, opts: Record, cmd: Command) => { + const globalOpts = cmd.optsWithGlobals>(); try { const config = loadConfig(); const token = await getValidToken(config); - const fields = {}; - const updateFields = []; + const fields: Record = {}; + const updateFields: string[] = []; - if (opts.title) { fields.title = { stringValue: opts.title }; updateFields.push('title'); } - if (opts.location) { fields.location = { stringValue: opts.location }; updateFields.push('location'); } - if (opts.description) { fields.description = { stringValue: stripMarkdown(opts.description) }; updateFields.push('description'); } - if (opts.date) { fields.startDate = { timestampValue: parseDateTime(opts.date).toISOString() }; updateFields.push('startDate'); } - if (opts.endDate) { fields.endDate = { timestampValue: parseDateTime(opts.endDate).toISOString() }; updateFields.push('endDate'); } - if (opts.capacity) { fields.guestLimit = { integerValue: String(opts.capacity) }; updateFields.push('guestLimit'); } + if (opts['title']) { fields['title'] = { stringValue: opts['title'] }; updateFields.push('title'); } + if (opts['location']) { fields['location'] = { stringValue: opts['location'] }; updateFields.push('location'); } + if (opts['description']) { fields['description'] = { stringValue: stripMarkdown(opts['description'] as string) }; updateFields.push('description'); } + if (opts['date']) { fields['startDate'] = { timestampValue: parseDateTime(opts['date'] as string).toISOString() }; updateFields.push('startDate'); } + if (opts['endDate']) { fields['endDate'] = { timestampValue: parseDateTime(opts['endDate'] as string).toISOString() }; updateFields.push('endDate'); } + if (opts['capacity']) { fields['guestLimit'] = { integerValue: String(opts['capacity']) }; updateFields.push('guestLimit'); } // Links - const links = buildLinks(opts.link, opts.linkText); + const links = buildLinks(opts['link'] as string[] | undefined, opts['linkText'] as string[] | undefined); if (links) { - fields.links = { + fields['links'] = { arrayValue: { - values: links.map(l => ({ - mapValue: { fields: toFirestoreMap(l) } + values: links.map((l: unknown) => ({ + mapValue: { fields: toFirestoreMap(l as Record) } })) } }; @@ -312,25 +319,25 @@ export function registerEventsCommands(program) { } // Image options (mutually exclusive) - validateImageOptions(opts.poster, opts.posterSearch, opts.image); + validateImageOptions(opts['poster'], opts['posterSearch'], opts['image']); - if (opts.poster || opts.posterSearch) { + if (opts['poster'] || opts['posterSearch']) { const posterImage = await resolvePosterImage(opts, fetchCatalog, searchPosters, buildPosterImage); - fields.image = { mapValue: { fields: toFirestoreMap(posterImage) } }; + fields['image'] = { mapValue: { fields: toFirestoreMap(posterImage as unknown as Record) } }; updateFields.push('image'); } - if (opts.image) { - const imageObj = await resolveUploadImage(opts.image, token, config, globalOpts.verbose, globalOpts.dryRun); - fields.image = { mapValue: { fields: toFirestoreMap(imageObj) } }; + if (opts['image']) { + const imageObj = await resolveUploadImage(opts['image'] as string, token, config, globalOpts['verbose'] as boolean | undefined, globalOpts['dryRun'] as boolean | undefined); + fields['image'] = { mapValue: { fields: toFirestoreMap(imageObj as Record) } }; updateFields.push('image'); } - if (opts.cohost && opts.cohost.length > 0) { - const resolvedIds = await resolveCohostNames(opts.cohost, token, config, globalOpts.verbose); + if (opts['cohost'] && (opts['cohost'] as string[]).length > 0) { + const resolvedIds = await resolveCohostNames(opts['cohost'] as string[], token, config, globalOpts['verbose'] as boolean | undefined); if (resolvedIds.length > 0) { - fields.cohostIds = { - arrayValue: { values: resolvedIds.map(id => ({ stringValue: id })) } + fields['cohostIds'] = { + arrayValue: { values: resolvedIds.map((id: string) => ({ stringValue: id })) } }; updateFields.push('cohostIds'); } @@ -341,12 +348,12 @@ export function registerEventsCommands(program) { return; } - if (globalOpts.dryRun) { + if (globalOpts['dryRun']) { jsonOutput({ dryRun: true, eventId, fields: updateFields, body: { fields } }); return; } - await firestoreRequest('PATCH', eventId, { fields }, token, updateFields, globalOpts.verbose); + await firestoreRequest('PATCH', eventId, { fields }, token, updateFields, globalOpts['verbose'] as boolean | undefined); jsonOutput({ id: eventId, @@ -379,57 +386,58 @@ export function registerEventsCommands(program) { .option('--link ', 'Override links (repeatable)') .option('--link-text ', 'Display text for links') .option('--cohost ', 'Co-host names (resolved from contacts)') - .action(async (eventId, opts, cmd) => { - const globalOpts = cmd.optsWithGlobals(); + .action(async (eventId: string, opts: Record, cmd: Command) => { + const globalOpts = cmd.optsWithGlobals>(); try { const config = loadConfig(); const token = await getValidToken(config); // 1. Fetch source event - let sourceEvent; + let sourceEvent: Record | null; try { - const result = await apiRequest('POST', '/getEventInfo', token, makePayload(config, { eventId }), globalOpts.verbose); - sourceEvent = result.result?.data?.event; + const result = await apiRequest('POST', '/getEventInfo', token, makePayload(config, { eventId }), globalOpts['verbose'] as boolean | undefined) as { result?: { data?: { event?: Record } } }; + sourceEvent = result.result?.data?.event ?? null; } catch (e) { - if (!globalOpts.dryRun) throw e; + if (!globalOpts['dryRun']) throw e; sourceEvent = null; } - if (!sourceEvent && !globalOpts.dryRun) { + if (!sourceEvent && !globalOpts['dryRun']) { jsonError('Source event not found', 4, 'not_found'); return; } - const src = sourceEvent || {}; + const src = sourceEvent ?? {}; // 2. Parse new date and preserve duration - const tz = opts.timezone || src.timezone || 'America/Los_Angeles'; - const newStart = parseDateTime(opts.date, tz); - let newEnd = null; - - if (opts.endDate) { - newEnd = parseDateTime(opts.endDate, tz); - } else if (src.startDate && src.endDate) { - const durationMs = new Date(src.endDate).getTime() - new Date(src.startDate).getTime(); + const tz = (opts['timezone'] ?? src['timezone'] ?? 'America/Los_Angeles') as string; + const newStart = parseDateTime(opts['date'] as string, tz); + let newEnd: Date | null = null; + + if (opts['endDate']) { + newEnd = parseDateTime(opts['endDate'] as string, tz); + } else if (src['startDate'] && src['endDate']) { + const durationMs = new Date(src['endDate'] as string).getTime() - new Date(src['startDate'] as string).getTime(); if (durationMs > 0) newEnd = new Date(newStart.getTime() + durationMs); } // 3. Build cloned event — merge source with overrides + const srcDisplaySettings = src['displaySettings'] as Record | undefined; const cloneOpts = { - title: opts.title || src.title || 'Untitled Event', - date: opts.date, + title: (opts['title'] ?? src['title'] ?? 'Untitled Event') as string, + date: opts['date'] as string, timezone: tz, - theme: opts.theme || src.displaySettings?.theme || 'oxblood', - effect: opts.effect || src.displaySettings?.effect || 'sunbeams', - titleFont: src.displaySettings?.titleFont || 'display', - private: opts.private ? true : (src.visibility === 'private'), - location: opts.location !== undefined ? opts.location : src.location, - address: opts.address !== undefined ? opts.address : src.address, - description: opts.description !== undefined ? opts.description : src.description, - capacity: opts.capacity !== undefined ? opts.capacity : src.guestLimit, + theme: (opts['theme'] ?? srcDisplaySettings?.['theme'] ?? 'oxblood') as string, + effect: (opts['effect'] ?? srcDisplaySettings?.['effect'] ?? 'sunbeams') as string, + titleFont: (srcDisplaySettings?.['titleFont'] ?? 'display') as string, + private: opts['private'] ? true : (src['visibility'] === 'private'), + location: opts['location'] !== undefined ? opts['location'] : src['location'], + address: opts['address'] !== undefined ? opts['address'] : src['address'], + description: opts['description'] !== undefined ? opts['description'] : src['description'], + capacity: opts['capacity'] !== undefined ? opts['capacity'] : src['guestLimit'], }; - const { event } = buildBaseEvent(cloneOpts); + const { event } = buildBaseEvent(cloneOpts as unknown as EventOptions); // Preserve source boolean settings for (const key of ['showHostList', 'showGuestCount', 'showGuestList', 'showActivityTimestamps', @@ -438,43 +446,43 @@ export function registerEventsCommands(program) { if (src[key] !== undefined) event[key] = src[key]; } - if (newEnd) event.endDate = newEnd.toISOString(); + if (newEnd) event['endDate'] = newEnd.toISOString(); // Links - const links = buildLinks(opts.link, opts.linkText); - if (links) event.links = links; - else if (src.links) event.links = src.links; + const links = buildLinks(opts['link'] as string[] | undefined, opts['linkText'] as string[] | undefined); + if (links) event['links'] = links; + else if (src['links']) event['links'] = src['links'] as import('../lib/api/endpoints.js').EventLink[]; // Image handling - validateImageOptions(opts.poster, opts.posterSearch, opts.image); + validateImageOptions(opts['poster'], opts['posterSearch'], opts['image']); const posterImage = await resolvePosterImage(opts, fetchCatalog, searchPosters, buildPosterImage); if (posterImage) { - event.image = posterImage; - } else if (opts.image) { - event.image = await resolveUploadImage(opts.image, token, config, globalOpts.verbose, globalOpts.dryRun); - } else if (src.image) { - event.image = src.image; + event['image'] = posterImage; + } else if (opts['image']) { + event['image'] = await resolveUploadImage(opts['image'] as string, token, config, globalOpts['verbose'] as boolean | undefined, globalOpts['dryRun'] as boolean | undefined); + } else if (src['image']) { + event['image'] = src['image']; } - const cohostIds = await resolveCohostNames(opts.cohost, token, config, globalOpts.verbose); + const cohostIds = await resolveCohostNames((opts['cohost'] as string[] | undefined) ?? [], token, config, globalOpts['verbose'] as boolean | undefined); const payload = makePayload(config, { event, cohostIds }); - if (globalOpts.dryRun) { + if (globalOpts['dryRun']) { jsonOutput({ dryRun: true, endpoint: '/createEvent', clonedFrom: eventId, payload }); return; } - const result = await apiRequest('POST', '/createEvent', token, payload, globalOpts.verbose); - const newEventId = result.result?.data || result.result?.eventId; + const result = await apiRequest('POST', '/createEvent', token, payload, globalOpts['verbose'] as boolean | undefined) as { result?: { data?: unknown; eventId?: string } }; + const newEventId = result.result?.data ?? result.result?.eventId; jsonOutput({ id: newEventId, clonedFrom: eventId, - title: event.title, + title: event['title'], startDate: newStart.toISOString(), - url: `https://partiful.com/e/${newEventId}`, + url: `https://partiful.com/e/${String(newEventId)}`, }); } catch (e) { handleError(e); @@ -485,20 +493,21 @@ export function registerEventsCommands(program) { .command('cancel') .description('Cancel an event') .argument('', 'Event ID') - .action(async (eventId, opts, cmd) => { - const globalOpts = cmd.optsWithGlobals(); + .action(async (eventId: string, opts: Record, cmd: Command) => { + const globalOpts = cmd.optsWithGlobals>(); try { const config = loadConfig(); const token = await getValidToken(config); // Confirm unless --yes or --force - if (!globalOpts.yes && !globalOpts.force) { - const eventResult = await apiRequest('POST', '/getEventInfo', token, makePayload(config, { eventId }), globalOpts.verbose); + if (!globalOpts['yes'] && !globalOpts['force']) { + const eventResult = await apiRequest('POST', '/getEventInfo', token, makePayload(config, { eventId }), globalOpts['verbose'] as boolean | undefined) as { result?: { data?: { event?: Record } } }; const event = eventResult.result?.data?.event; if (event) { - const going = event.guestStatusCounts?.GOING || 0; - const maybe = event.guestStatusCounts?.MAYBE || 0; - console.error(`About to cancel: "${event.title}" (${going} going, ${maybe} maybe)`); + const counts = event['guestStatusCounts'] as Record | undefined; + const going = counts?.['GOING'] ?? 0; + const maybe = counts?.['MAYBE'] ?? 0; + console.error(`About to cancel: "${event['title']}" (${going} going, ${maybe} maybe)`); } const confirmed = await confirm('Are you sure? This cannot be undone.'); @@ -510,12 +519,12 @@ export function registerEventsCommands(program) { const payload = makePayload(config, { eventId }); - if (globalOpts.dryRun) { + if (globalOpts['dryRun']) { jsonOutput({ dryRun: true, endpoint: '/cancelEvent', payload }); return; } - await apiRequest('POST', '/cancelEvent', token, payload, globalOpts.verbose); + await apiRequest('POST', '/cancelEvent', token, payload, globalOpts['verbose'] as boolean | undefined); jsonOutput({ id: eventId, cancelled: true }); } catch (e) { handleError(e); diff --git a/src/commands/guests.js b/src/commands/guests.ts similarity index 58% rename from src/commands/guests.js rename to src/commands/guests.ts index a9cf05e..ec64c63 100644 --- a/src/commands/guests.js +++ b/src/commands/guests.ts @@ -2,37 +2,56 @@ * Guests commands: list, invite */ +import type { Command } from 'commander'; import { loadConfig, getValidToken, wrapPayload } from '../lib/auth.js'; import { apiRequest, firestoreListDocuments } from '../lib/http.js'; import { jsonOutput, jsonError } from '../lib/output.js'; import { PartifulError } from '../lib/errors.js'; -export async function fetchGuests(eventId, token, config, verbose = false) { - const guests = []; - let pageToken = null; +interface GuestRecord { + name: string; + status: string; + createdAt: string | null; + inviteDate: string | null; + count: number; + channel: string | null; +} + +interface FirestoreDoc { + fields?: Record }; + }>; +} + +export async function fetchGuests(eventId: string, token: string, config: ReturnType, verbose = false): Promise { + const guests: GuestRecord[] = []; + let pageToken: string | null = null; do { const result = await firestoreListDocuments( `events/${eventId}/guests`, token, 100, pageToken, verbose - ); + ) as { documents?: FirestoreDoc[]; nextPageToken?: string }; if (result.documents) { for (const doc of result.documents) { - const f = doc.fields || {}; + const f = doc.fields ?? {}; guests.push({ - name: f.name?.stringValue || 'Unknown', - status: f.status?.stringValue || 'UNKNOWN', - createdAt: f.createdAt?.timestampValue || null, - inviteDate: f.inviteDate?.timestampValue || null, - count: parseInt(f.count?.integerValue || '1'), - channel: f.inviteMetadata?.mapValue?.fields?.channel?.stringValue || null, + name: f['name']?.stringValue ?? 'Unknown', + status: f['status']?.stringValue ?? 'UNKNOWN', + createdAt: f['createdAt']?.timestampValue ?? null, + inviteDate: f['inviteDate']?.timestampValue ?? null, + count: parseInt(f['count']?.integerValue ?? '1'), + channel: f['inviteMetadata']?.mapValue?.fields?.['channel']?.stringValue ?? null, }); } } - pageToken = result.nextPageToken || null; + pageToken = result.nextPageToken ?? null; } while (pageToken); return guests; } -export function registerGuestsCommands(program) { +export function registerGuestsCommands(program: Command): void { const guests = program.command('guests').description('Manage event guests'); guests @@ -40,8 +59,8 @@ export function registerGuestsCommands(program) { .description('List guests for an event') .argument('', 'Event ID') .option('--status ', 'Filter by RSVP status (GOING, MAYBE, SENT, DECLINED, WAITLIST)') - .action(async (eventId, opts, cmd) => { - const globalOpts = cmd.optsWithGlobals(); + .action(async (eventId: string, opts: Record, cmd: Command) => { + const globalOpts = cmd.optsWithGlobals>(); try { const config = loadConfig(); const token = await getValidToken(config); @@ -55,36 +74,36 @@ export function registerGuestsCommands(program) { }), }; - let counts = {}; + let counts: Record = {}; let eventTitle = 'Unknown Event'; try { - const eventResult = await apiRequest('POST', '/getEventInfo', token, eventPayload, globalOpts.verbose); + const eventResult = await apiRequest('POST', '/getEventInfo', token, eventPayload, globalOpts['verbose'] as boolean | undefined) as { result?: { data?: { event?: { title?: string; guestStatusCounts?: Record } } } }; const event = eventResult.result?.data?.event; if (event) { - eventTitle = event.title; - counts = event.guestStatusCounts || {}; + eventTitle = event.title ?? 'Unknown Event'; + counts = event.guestStatusCounts ?? {}; } } catch { // API may be down, continue with Firestore guest fetch } - if (globalOpts.dryRun) { + if (globalOpts['dryRun']) { jsonOutput({ dryRun: true, eventId, collection: `events/${eventId}/guests` }); return; } - let guestList = await fetchGuests(eventId, token, config, globalOpts.verbose); + let guestList = await fetchGuests(eventId, token, config, globalOpts['verbose'] as boolean | undefined); // Compute counts from guest list if API didn't provide them if (Object.keys(counts).length === 0 && guestList.length > 0) { for (const g of guestList) { - counts[g.status] = (counts[g.status] || 0) + 1; + counts[g.status] = (counts[g.status] ?? 0) + 1; } } // Filter by status - if (opts.status) { - const statusFilter = opts.status.toUpperCase(); + if (opts['status']) { + const statusFilter = (opts['status'] as string).toUpperCase(); guestList = guestList.filter(g => g.status === statusFilter); } @@ -93,17 +112,17 @@ export function registerGuestsCommands(program) { eventTitle, guests: guestList, counts: { - going: counts.GOING || 0, - maybe: counts.MAYBE || 0, - invited: counts.SENT || 0, - declined: counts.DECLINED || 0, - waitlist: counts.WAITLIST || 0, + going: counts['GOING'] ?? 0, + maybe: counts['MAYBE'] ?? 0, + invited: counts['SENT'] ?? 0, + declined: counts['DECLINED'] ?? 0, + waitlist: counts['WAITLIST'] ?? 0, }, total: guestList.length, }); } catch (e) { if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); - else jsonError(e.message); + else jsonError((e as Error).message); } }); @@ -114,14 +133,14 @@ export function registerGuestsCommands(program) { .option('--phone ', 'Phone number(s) to invite') .option('--user-id ', 'Partiful user ID(s) to invite') .option('--message ', 'Optional invitation message') - .action(async (eventId, opts, cmd) => { - const globalOpts = cmd.optsWithGlobals(); + .action(async (eventId: string, opts: Record, cmd: Command) => { + const globalOpts = cmd.optsWithGlobals>(); try { const config = loadConfig(); const token = await getValidToken(config); - const userIdsToInvite = opts.userId || []; - const phoneContactsToInvite = (opts.phone || []).map(phone => ({ + const userIdsToInvite = (opts['userId'] as string[] | undefined) ?? []; + const phoneContactsToInvite = ((opts['phone'] as string[] | undefined) ?? []).map((phone: string) => ({ phoneNumber: phone.replace(/[^+\d]/g, ''), firstName: '', lastName: '', @@ -138,7 +157,7 @@ export function registerGuestsCommands(program) { eventId, userIdsToInvite, phoneContactsToInvite, - invitationMessage: opts.message || '', + invitationMessage: (opts['message'] as string | undefined) ?? '', otherMutualsCount: 0, }, amplitudeSessionId: Date.now(), @@ -146,12 +165,12 @@ export function registerGuestsCommands(program) { }), }; - if (globalOpts.dryRun) { + if (globalOpts['dryRun']) { jsonOutput({ dryRun: true, endpoint: '/addInvitedGuestsAsHost', payload }); return; } - await apiRequest('POST', '/addInvitedGuestsAsHost', token, payload, globalOpts.verbose); + await apiRequest('POST', '/addInvitedGuestsAsHost', token, payload, globalOpts['verbose'] as boolean | undefined); const invited = userIdsToInvite.length + phoneContactsToInvite.length; jsonOutput({ @@ -161,7 +180,7 @@ export function registerGuestsCommands(program) { }); } catch (e) { if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); - else jsonError(e.message); + else jsonError((e as Error).message); } }); } diff --git a/src/commands/posters.js b/src/commands/posters.ts similarity index 61% rename from src/commands/posters.js rename to src/commands/posters.ts index 20fa353..4c27ef6 100644 --- a/src/commands/posters.js +++ b/src/commands/posters.ts @@ -2,10 +2,12 @@ * Poster browsing commands: list, search, get */ +import { Command } from 'commander'; import { fetchCatalog, searchPosters, posterThumbnail } from '../lib/posters.js'; +import type { Poster, ScoredPoster } from '../lib/posters.js'; import { jsonOutput, jsonError } from '../lib/output.js'; -function summarizePoster(p) { +function summarizePoster(p: Poster) { return { id: p.id, name: p.name, @@ -15,12 +17,12 @@ function summarizePoster(p) { width: p.width, height: p.height, url: p.url, - thumbnail: posterThumbnail(p.id), + thumbnail: p.id ? posterThumbnail(p.id) : null, bgColor: p.bgColor, }; } -export function registerPosterCommands(program) { +export function registerPosterCommands(program: Command): void { const posters = program.command('posters').description('Browse poster catalog'); posters @@ -29,23 +31,23 @@ export function registerPosterCommands(program) { .option('--category ', 'Filter by category') .option('--type ', 'Filter by content type (png, gif, jpeg)') .option('--limit ', 'Max results', '20') - .action(async (opts) => { + .action(async (opts: Record) => { try { const catalog = await fetchCatalog(); let filtered = catalog; - if (opts.category) { - const cat = opts.category.toLowerCase(); - filtered = filtered.filter(p => - p.categories && p.categories.some(c => c.toLowerCase() === cat) + if (opts['category']) { + const cat = (opts['category'] as string).toLowerCase(); + filtered = filtered.filter((p) => + p.categories && p.categories.some((c) => c.toLowerCase() === cat) ); } - if (opts.type) { - const t = opts.type.toLowerCase(); - filtered = filtered.filter(p => + if (opts['type']) { + const t = (opts['type'] as string).toLowerCase(); + filtered = filtered.filter((p) => p.contentType && p.contentType.toLowerCase().includes(t) ); } - const limit = parseInt(opts.limit, 10); + const limit = parseInt(opts['limit'] as string, 10); if (isNaN(limit) || limit < 1) { jsonError('--limit must be a positive integer', 3, 'validation_error'); return; @@ -53,7 +55,7 @@ export function registerPosterCommands(program) { const results = filtered.slice(0, limit).map(summarizePoster); jsonOutput(results, { count: results.length, totalAvailable: filtered.length }); } catch (err) { - jsonError(err.message, 5, 'internal_error'); + jsonError(err instanceof Error ? err.message : String(err), 5, 'internal_error'); } }); @@ -61,39 +63,39 @@ export function registerPosterCommands(program) { .command('search ') .description('Search posters by keyword') .option('--limit ', 'Max results', '10') - .action(async (query, opts) => { + .action(async (query: string, opts: Record) => { try { const catalog = await fetchCatalog(); - const results = searchPosters(catalog, query); - const limit = parseInt(opts.limit, 10); + const results: ScoredPoster[] = searchPosters(catalog, query); + const limit = parseInt(opts['limit'] as string, 10); if (isNaN(limit) || limit < 1) { jsonError('--limit must be a positive integer', 3, 'validation_error'); return; } - const limited = results.slice(0, limit).map(p => ({ + const limited = results.slice(0, limit).map((p) => ({ ...summarizePoster(p), score: p.score, })); jsonOutput(limited, { count: limited.length, totalMatches: results.length }); } catch (err) { - jsonError(err.message, 5, 'internal_error'); + jsonError(err instanceof Error ? err.message : String(err), 5, 'internal_error'); } }); posters .command('get ') .description('Get full poster details by ID') - .action(async (posterId) => { + .action(async (posterId: string) => { try { const catalog = await fetchCatalog(); - const poster = catalog.find(p => p.id === posterId); + const poster = catalog.find((p) => p.id === posterId); if (!poster) { jsonError(`Poster not found: ${posterId}`, 4, 'not_found'); return; } jsonOutput(poster); } catch (err) { - jsonError(err.message, 5, 'internal_error'); + jsonError(err instanceof Error ? err.message : String(err), 5, 'internal_error'); } }); } diff --git a/src/commands/rsvp.js b/src/commands/rsvp.ts similarity index 67% rename from src/commands/rsvp.js rename to src/commands/rsvp.ts index f9b1f4e..f1635fc 100644 --- a/src/commands/rsvp.js +++ b/src/commands/rsvp.ts @@ -10,6 +10,7 @@ * user-facing verb surface. */ +import type { Command } from 'commander'; import { loadConfig, getValidToken, wrapPayload, decodeJwtPayload } from '../lib/auth.js'; import { apiRequest } from '../lib/http.js'; import { jsonOutput, jsonError } from '../lib/output.js'; @@ -22,10 +23,11 @@ import { isTicketedEvent, eventRequiresQuestionnaire, resolveDisplayName, + type RsvpEvent, } from '../lib/rsvp.js'; /** Wrap params in the Firebase-callable envelope the API expects. */ -function makePayload(config, params) { +function makePayload(config: ReturnType, params: Record): Record { return { data: wrapPayload(config, { params, @@ -35,9 +37,9 @@ function makePayload(config, params) { }; } -function handleError(e) { +function handleError(e: unknown): void { if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); - else jsonError(e.message); + else jsonError((e as Error).message); } /** @@ -49,9 +51,9 @@ function handleError(e) { * record instead of updating the existing one. A genuine "no record yet" is a * successful response with currentGuest absent -> null, which is correct. */ -async function fetchCurrentGuest(config, token, eventId, verbose) { - const res = await apiRequest('POST', '/getCurrentGuest', token, makePayload(config, { eventId }), verbose); - return res.result?.data?.currentGuest || null; +async function fetchCurrentGuest(config: ReturnType, token: string, eventId: string, verbose: boolean | undefined): Promise | null> { + const res = await apiRequest('POST', '/getCurrentGuest', token, makePayload(config, { eventId }), verbose) as { result?: { data?: { currentGuest?: Record } } }; + return res.result?.data?.currentGuest ?? null; } /** @@ -61,35 +63,35 @@ async function fetchCurrentGuest(config, token, eventId, verbose) { * guards treat as "not ticketed / no questionnaire", silently disabling the * safety rails on any transient error and letting an incomplete RSVP through. */ -async function fetchEvent(config, token, eventId, verbose) { - const res = await apiRequest('POST', '/getEventInfo', token, makePayload(config, { eventId }), verbose); - return res.result?.data?.event || null; +async function fetchEvent(config: ReturnType, token: string, eventId: string, verbose: boolean | undefined): Promise { + const res = await apiRequest('POST', '/getEventInfo', token, makePayload(config, { eventId }), verbose) as { result?: { data?: { event?: RsvpEvent } } }; + return res.result?.data?.event ?? null; } /** Derive the caller's display name from the Firebase token, if present. */ -function nameFromToken(token) { - const payload = decodeJwtPayload(token); - return payload?.name || payload?.displayName || null; +function nameFromToken(token: string): string | null { + const payload = decodeJwtPayload(token) as { name?: string; displayName?: string } | null; + return payload?.name ?? payload?.displayName ?? null; } /** * Shared RSVP handler. Backs `events rsvp` and `explore rsvp`. * Exported for unit testing of the orchestration branches. */ -export async function rsvpAction(eventId, opts, cmd) { - const globalOpts = cmd.optsWithGlobals(); +export async function rsvpAction(eventId: string, opts: Record, cmd: Command): Promise { + const globalOpts = cmd.optsWithGlobals>(); try { const config = loadConfig(); const token = await getValidToken(config); // Read-before-write: decide create (guestId:null) vs update. Skipped on // dry-run so the preview is fully offline. - let currentGuest = null; - let event = null; - if (!globalOpts.dryRun) { + let currentGuest: Record | null = null; + let event: RsvpEvent | null = null; + if (!globalOpts['dryRun']) { [currentGuest, event] = await Promise.all([ - fetchCurrentGuest(config, token, eventId, globalOpts.verbose), - fetchEvent(config, token, eventId, globalOpts.verbose), + fetchCurrentGuest(config, token, eventId, globalOpts['verbose'] as boolean | undefined), + fetchEvent(config, token, eventId, globalOpts['verbose'] as boolean | undefined), ]); // Refuse ticketed/paid events cleanly (Stripe wall). @@ -120,7 +122,7 @@ export async function rsvpAction(eventId, opts, cmd) { } const name = resolveDisplayName({ - override: opts.name, + override: opts['name'] as string | undefined, currentGuest, config, tokenName: nameFromToken(token), @@ -128,25 +130,25 @@ export async function rsvpAction(eventId, opts, cmd) { const params = buildRsvpParams({ eventId, - name, - status: opts.status, - plusOnes: opts.plusOne, - count: opts.count, - message: opts.message, - password: opts.password, - timezone: opts.timezone, - guestId: currentGuest?.id ?? null, + name: name ?? undefined, + status: opts['status'] as string | undefined, + plusOnes: opts['plusOne'] as string[] | undefined, + count: opts['count'] as number | undefined, + message: opts['message'] as string | undefined, + password: opts['password'] as string | undefined, + timezone: opts['timezone'] as string | undefined, + guestId: (currentGuest?.['id'] as string | undefined) ?? null, }); - const payload = makePayload(config, params); + const payload = makePayload(config, params as unknown as Record); - if (globalOpts.dryRun) { + if (globalOpts['dryRun']) { jsonOutput({ dryRun: true, endpoint: '/addGuest', payload }); return; } // Confirmation gate: writing to a real host's guest list. - if (!globalOpts.yes && !globalOpts.force) { + if (!globalOpts['yes'] && !globalOpts['force']) { const verb = currentGuest ? 'update your RSVP on' : 'RSVP to'; const confirmed = await confirm(`About to ${verb} "${eventId}" as ${params.rsvp.status}. Continue?`); if (!confirmed) { @@ -155,13 +157,13 @@ export async function rsvpAction(eventId, opts, cmd) { } } - const result = await apiRequest('POST', '/addGuest', token, payload, globalOpts.verbose); - const guest = result.result?.data?.guest || result.result?.data || {}; + const result = await apiRequest('POST', '/addGuest', token, payload, globalOpts['verbose'] as boolean | undefined) as { result?: { data?: { guest?: Record } | Record } }; + const guest = (result.result?.data as { guest?: Record })?.guest ?? result.result?.data ?? {}; jsonOutput({ eventId, status: params.rsvp.status, - guestId: guest.id || currentGuest?.id || null, + guestId: (guest as Record)['id'] ?? currentGuest?.['id'] ?? null, count: params.rsvp.count, updated: Boolean(currentGuest), url: `https://partiful.com/e/${eventId}`, @@ -174,24 +176,24 @@ export async function rsvpAction(eventId, opts, cmd) { /** * Shared interest handler. Backs `events interested` and `explore interested`. */ -async function interestedAction(eventId, opts, cmd) { - const globalOpts = cmd.optsWithGlobals(); +async function interestedAction(eventId: string, opts: Record, cmd: Command): Promise { + const globalOpts = cmd.optsWithGlobals>(); try { const config = loadConfig(); const token = await getValidToken(config); - const interested = !opts.remove; + const interested = !opts['remove']; // No confirmation gate here: marking interest is non-destructive and easily // reversible via --remove, unlike an RSVP write to a host's guest list. const params = buildInterestParams({ eventId, interested }); - const payload = makePayload(config, params); + const payload = makePayload(config, params as unknown as Record); - if (globalOpts.dryRun) { + if (globalOpts['dryRun']) { jsonOutput({ dryRun: true, endpoint: '/markEventInterest', payload }); return; } - await apiRequest('POST', '/markEventInterest', token, payload, globalOpts.verbose); + await apiRequest('POST', '/markEventInterest', token, payload, globalOpts['verbose'] as boolean | undefined); jsonOutput({ eventId, @@ -204,7 +206,7 @@ async function interestedAction(eventId, opts, cmd) { } /** Attach rsvp + interested subcommands to a parent command (events or explore). */ -function attachRsvpVerbs(parent) { +function attachRsvpVerbs(parent: Command): void { parent .command('rsvp') .description('RSVP to an event (going, maybe, or declined)') @@ -212,7 +214,7 @@ function attachRsvpVerbs(parent) { .option('--status ', `RSVP status: ${RSVP_STATUSES.join(', ')}`, 'going') .option('--name ', 'Display name to RSVP with (defaults to your profile name)') .option('--plus-one ', 'Plus-one name (repeatable)') - .option('--count ', 'Total headcount including plus-ones', (v) => parseInt(v, 10)) + .option('--count ', 'Total headcount including plus-ones', (v: string) => parseInt(v, 10)) .option('--message ', 'Optional public comment on the event') .option('--password ', 'Event password (if the event is password-gated)') .option('--timezone ', 'IANA timezone for the RSVP') @@ -226,7 +228,7 @@ function attachRsvpVerbs(parent) { .action(interestedAction); } -export function registerRsvpCommands(program, { events, explore }) { +export function registerRsvpCommands(program: Command, { events, explore }: { events: Command; explore: Command }): void { // Canonical verbs live under `events`; `explore` gets the same verbs as // thin aliases to the SAME handlers. attachRsvpVerbs(events); diff --git a/src/commands/schema.js b/src/commands/schema.ts similarity index 93% rename from src/commands/schema.js rename to src/commands/schema.ts index aa55fa7..a3a7280 100644 --- a/src/commands/schema.js +++ b/src/commands/schema.ts @@ -1,6 +1,22 @@ import { jsonOutput, jsonError, EXIT } from '../lib/output.js'; +import type { Command } from 'commander'; -const SCHEMAS = { +/** A single parameter descriptor in a command schema. */ +interface SchemaParameter { + type: string; + required: boolean; + description?: string; + default?: unknown; + positional?: boolean; +} + +/** A command schema entry: invocation string + parameter map. */ +interface CommandSchema { + command: string; + parameters: Record; +} + +const SCHEMAS: Record = { 'events.list': { command: 'events list', parameters: { @@ -169,11 +185,11 @@ const SCHEMAS = { }, }; -export function registerSchemaCommand(program) { +export function registerSchemaCommand(program: Command): void { program .command('schema [path]') .description('Introspect command parameters (e.g., events.create)') - .action((path, opts, cmd) => { + .action((path: string | undefined, _opts: unknown, cmd: Command) => { const globalOpts = cmd.optsWithGlobals(); if (!path) { jsonOutput({ commands: Object.keys(SCHEMAS) }, { count: Object.keys(SCHEMAS).length }, globalOpts); diff --git a/src/commands/setup.js b/src/commands/setup.ts similarity index 73% rename from src/commands/setup.js rename to src/commands/setup.ts index 8dd8d1d..9a5a46a 100644 --- a/src/commands/setup.js +++ b/src/commands/setup.ts @@ -5,29 +5,31 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; +import { Command } from 'commander'; import { jsonOutput, jsonError } from '../lib/output.js'; -function getPackageSkillsDir() { +function getPackageSkillsDir(): string { const thisFile = fileURLToPath(import.meta.url); const packageRoot = path.resolve(path.dirname(thisFile), '..', '..'); return path.join(packageRoot, 'skills'); } -function resolveWorkspace(optPath) { +function resolveWorkspace(optPath: string | undefined): string | null { if (optPath) return optPath; - if (process.env.OPENCLAW_WORKSPACE) return process.env.OPENCLAW_WORKSPACE; - const defaultPath = path.join(process.env.HOME, '.openclaw', 'workspace'); + if (process.env['OPENCLAW_WORKSPACE']) return process.env['OPENCLAW_WORKSPACE']!; + const home = process.env['HOME'] ?? ''; + const defaultPath = path.join(home, '.openclaw', 'workspace'); if (fs.existsSync(defaultPath)) return defaultPath; return null; } -function getSkillDirs(skillsSource) { +function getSkillDirs(skillsSource: string): string[] { return fs.readdirSync(skillsSource).filter( d => d.startsWith('partiful-') && fs.statSync(path.join(skillsSource, d)).isDirectory() ); } -export function registerSetupCommands(program) { +export function registerSetupCommands(program: Command): void { const setup = program .command('setup') .description('Setup and integration commands'); @@ -37,12 +39,12 @@ export function registerSetupCommands(program) { .description('Link partiful skills into an OpenClaw workspace') .option('--workspace ', 'OpenClaw workspace path') .option('--uninstall', 'Remove symlinks instead of creating them') - .action(async (opts, cmd) => { - const globalOpts = cmd.optsWithGlobals(); - const force = globalOpts.force || false; - const dryRun = globalOpts.dryRun || false; + .action(async (opts: Record, cmd: Command) => { + const globalOpts = cmd.optsWithGlobals>(); + const force = globalOpts['force'] || false; + const dryRun = globalOpts['dryRun'] || false; - const workspace = resolveWorkspace(opts.workspace); + const workspace = resolveWorkspace(opts['workspace'] as string | undefined); if (!workspace) { jsonError( 'Could not find OpenClaw workspace. Set $OPENCLAW_WORKSPACE, ensure ~/.openclaw/workspace exists, or pass --workspace .', @@ -58,11 +60,11 @@ export function registerSetupCommands(program) { } const workspaceSkills = path.join(workspace, 'skills'); - let sourceDirs; + let sourceDirs: string[]; try { sourceDirs = getSkillDirs(skillsSource); } catch (e) { - jsonError(`Cannot read skills directory: ${e.message}`, 5, 'internal_error'); + jsonError(`Cannot read skills directory: ${(e as Error).message}`, 5, 'internal_error'); return; } @@ -72,13 +74,13 @@ export function registerSetupCommands(program) { } // Uninstall mode - if (opts.uninstall) { - const removed = []; - const skipped = []; + if (opts['uninstall']) { + const removed: Array<{ skill: string; path: string }> = []; + const skipped: Array<{ skill: string; reason: string }> = []; for (const dir of sourceDirs) { const linkPath = path.join(workspaceSkills, dir); - let stat; + let stat: fs.Stats | undefined; try { stat = fs.lstatSync(linkPath); } catch { skipped.push({ skill: dir, reason: 'not found' }); continue; } if (!stat.isSymbolicLink()) { @@ -99,15 +101,14 @@ export function registerSetupCommands(program) { fs.mkdirSync(workspaceSkills, { recursive: true }); } - const linked = []; - const skipped = []; + const linked: Array<{ skill: string; from: string; to: string }> = []; + const skipped: Array<{ skill: string; reason: string }> = []; for (const dir of sourceDirs) { const target = path.join(skillsSource, dir); const linkPath = path.join(workspaceSkills, dir); - // Check if something already exists at linkPath - let stat; + let stat: fs.Stats | null; try { stat = fs.lstatSync(linkPath); } catch { stat = null; } if (stat) { @@ -118,7 +119,6 @@ export function registerSetupCommands(program) { skipped.push({ skill: dir, reason: 'already linked' }); continue; } - // Points somewhere else if (force) { if (!dryRun) fs.unlinkSync(linkPath); } else { diff --git a/src/commands/templates.js b/src/commands/templates.ts similarity index 72% rename from src/commands/templates.js rename to src/commands/templates.ts index 22af8af..e4a7dab 100644 --- a/src/commands/templates.js +++ b/src/commands/templates.ts @@ -2,37 +2,41 @@ * Template commands — save, list, show, edit, delete event templates. */ -import { loadTemplates, saveTemplates, extractTemplate, applyVariables } from '../lib/templates.js'; +import { Command } from 'commander'; +import { loadTemplates, saveTemplates, extractTemplate } from '../lib/templates.js'; import { jsonOutput, jsonError } from '../lib/output.js'; -export function registerTemplateCommands(program) { +export function registerTemplateCommands(program: Command): void { const template = program.command('template').description('Manage event templates'); template .command('list') .description('List saved templates') - .action((opts, cmd) => { - const globalOpts = cmd.optsWithGlobals(); + .action((_opts: Record, cmd: Command) => { + const globalOpts = cmd.optsWithGlobals>(); const templates = loadTemplates(); const names = Object.keys(templates); if (names.length === 0) { jsonOutput([], { total: 0, hint: 'Save a template with: partiful template save --name ' }, globalOpts); return; } - const items = names.map(name => ({ - name, - title: templates[name].title || '(no title)', - location: templates[name].location || '', - fields: Object.keys(templates[name]).length, - })); + const items = names.map(name => { + const tpl = templates[name]!; + return { + name, + title: (tpl['title'] as string | undefined) ?? '(no title)', + location: (tpl['location'] as string | undefined) ?? '', + fields: Object.keys(tpl).length, + }; + }); jsonOutput(items, { total: items.length }, globalOpts); }); template .command('show ') .description('Show template details') - .action((name, opts, cmd) => { - const globalOpts = cmd.optsWithGlobals(); + .action((name: string, _opts: Record, cmd: Command) => { + const globalOpts = cmd.optsWithGlobals>(); const templates = loadTemplates(); if (!templates[name]) { jsonError(`Template "${name}" not found. Use "partiful template list" to see available templates.`, 4, 'not_found'); @@ -59,12 +63,12 @@ export function registerTemplateCommands(program) { .option('--link ', 'Link URL (repeatable)') .option('--link-text ', 'Display text for link') .option('--force', 'Overwrite existing template') - .action((opts, cmd) => { - const globalOpts = cmd.optsWithGlobals(); + .action((opts: Record, cmd: Command) => { + const globalOpts = cmd.optsWithGlobals>(); const templates = loadTemplates(); - const name = opts.name; + const name = opts['name'] as string; - if (templates[name] && !opts.force && !globalOpts.force) { + if (templates[name] && !opts['force'] && !globalOpts['force']) { jsonError(`Template "${name}" already exists. Use --force to overwrite.`, 3, 'validation_error'); return; } @@ -97,8 +101,8 @@ export function registerTemplateCommands(program) { .option('--link ', 'Link URL (repeatable)') .option('--link-text ', 'Display text for link') .option('--rename ', 'Rename template') - .action((name, opts, cmd) => { - const globalOpts = cmd.optsWithGlobals(); + .action((name: string, opts: Record, cmd: Command) => { + const globalOpts = cmd.optsWithGlobals>(); const templates = loadTemplates(); if (!templates[name]) { @@ -106,15 +110,15 @@ export function registerTemplateCommands(program) { return; } - // Apply edits const edits = extractTemplate(opts); const updated = { ...templates[name], ...edits }; - if (opts.rename) { + if (opts['rename']) { + const newName = opts['rename'] as string; delete templates[name]; - templates[opts.rename] = updated; + templates[newName] = updated; saveTemplates(templates); - jsonOutput(updated, { name: opts.rename, renamedFrom: name, action: 'edited' }, globalOpts); + jsonOutput(updated, { name: newName, renamedFrom: name, action: 'edited' }, globalOpts); } else { templates[name] = updated; saveTemplates(templates); @@ -125,8 +129,8 @@ export function registerTemplateCommands(program) { template .command('delete ') .description('Delete a saved template') - .action((name, opts, cmd) => { - const globalOpts = cmd.optsWithGlobals(); + .action((name: string, _opts: Record, cmd: Command) => { + const globalOpts = cmd.optsWithGlobals>(); const templates = loadTemplates(); if (!templates[name]) { diff --git a/src/helpers/clone.js b/src/helpers/clone.ts similarity index 53% rename from src/helpers/clone.js rename to src/helpers/clone.ts index 0788603..6fbf7a7 100644 --- a/src/helpers/clone.js +++ b/src/helpers/clone.ts @@ -2,13 +2,14 @@ * Clone helper: +clone — clone an event with shifted date */ +import { Command } from 'commander'; import { loadConfig, getValidToken, wrapPayload } from '../lib/auth.js'; import { apiRequest } from '../lib/http.js'; import { parseDateTime, stripMarkdown } from '../lib/dates.js'; import { jsonOutput, jsonError } from '../lib/output.js'; import { PartifulError } from '../lib/errors.js'; -export function registerCloneHelper(program) { +export function registerCloneHelper(program: Command): void { program .command('+clone') .description('Clone an event with a new date') @@ -16,8 +17,8 @@ export function registerCloneHelper(program) { .option('--title ', 'Override event title') .option('--date <date>', 'New date/time for cloned event') .option('--shift <days>', 'Shift date forward N days (default 7)', '7') - .action(async (eventId, opts, cmd) => { - const globalOpts = cmd.optsWithGlobals(); + .action(async (eventId: string, opts: Record<string, unknown>, cmd: Command) => { + const globalOpts = cmd.optsWithGlobals<Record<string, unknown>>(); try { const config = loadConfig(); const token = await getValidToken(config); @@ -31,8 +32,10 @@ export function registerCloneHelper(program) { }), }; - const getResult = await apiRequest('POST', '/getEventInfo', token, getPayload, globalOpts.verbose); - const source = getResult.result?.data?.event; + const getResult = await apiRequest('POST', '/getEventInfo', token, getPayload, globalOpts['verbose'] as boolean | undefined) as Record<string, unknown>; + const resultData = getResult['result'] as Record<string, unknown> | undefined; + const eventData = resultData?.['data'] as Record<string, unknown> | undefined; + const source = eventData?.['event'] as Record<string, unknown> | undefined; if (!source) { jsonError('Source event not found', 4, 'not_found'); @@ -40,38 +43,38 @@ export function registerCloneHelper(program) { } // Determine new start date - let startDate; - if (opts.date) { - startDate = parseDateTime(opts.date, source.timezone || 'America/Los_Angeles'); - } else if (source.startDate) { - startDate = new Date(source.startDate); - startDate.setDate(startDate.getDate() + parseInt(opts.shift)); + let startDate: Date; + if (opts['date']) { + startDate = parseDateTime(opts['date'] as string, (source['timezone'] as string | undefined) ?? 'America/Los_Angeles'); + } else if (source['startDate']) { + startDate = new Date(source['startDate'] as string); + startDate.setDate(startDate.getDate() + parseInt(opts['shift'] as string)); } else { jsonError('Source event has no start date and --date not provided', 3, 'validation_error'); return; } // Preserve duration for end date - let endDate = null; - if (source.endDate && source.startDate) { - const durationMs = new Date(source.endDate) - new Date(source.startDate); + let endDate: Date | null = null; + if (source['endDate'] && source['startDate']) { + const durationMs = new Date(source['endDate'] as string).getTime() - new Date(source['startDate'] as string).getTime(); if (durationMs > 0) { endDate = new Date(startDate.getTime() + durationMs); } } - const title = opts.title || source.title; - const event = { + const title = (opts['title'] as string | undefined) ?? (source['title'] as string); + const event: Record<string, unknown> = { title, startDate: startDate.toISOString(), - timezone: source.timezone || 'America/Los_Angeles', - displaySettings: source.displaySettings || {}, + timezone: (source['timezone'] as string | undefined) ?? 'America/Los_Angeles', + displaySettings: (source['displaySettings'] as Record<string, unknown> | undefined) ?? {}, showHostList: true, showGuestCount: true, showGuestList: true, showActivityTimestamps: true, displayInviteButton: true, - visibility: source.visibility || 'public', + visibility: (source['visibility'] as string | undefined) ?? 'public', allowGuestPhotoUpload: true, enableGuestReminders: true, rsvpsEnabled: true, @@ -81,13 +84,13 @@ export function registerCloneHelper(program) { guestStatusCounts: {}, }; - if (endDate) event.endDate = endDate.toISOString(); - if (source.location) event.location = source.location; - if (source.address) event.address = source.address; - if (source.description) event.description = stripMarkdown(source.description); - if (source.guestLimit) { - event.guestLimit = source.guestLimit; - event.enableWaitlist = true; + if (endDate) event['endDate'] = endDate.toISOString(); + if (source['location']) event['location'] = source['location']; + if (source['address']) event['address'] = source['address']; + if (source['description']) event['description'] = stripMarkdown(source['description'] as string); + if (source['guestLimit']) { + event['guestLimit'] = source['guestLimit']; + event['enableWaitlist'] = true; } const createPayload = { @@ -98,13 +101,14 @@ export function registerCloneHelper(program) { }), }; - if (globalOpts.dryRun) { + if (globalOpts['dryRun']) { jsonOutput({ dryRun: true, source: eventId, event }); return; } - const result = await apiRequest('POST', '/createEvent', token, createPayload, globalOpts.verbose); - const newEventId = result.result?.data || result.result?.eventId; + const result = await apiRequest('POST', '/createEvent', token, createPayload, globalOpts['verbose'] as boolean | undefined) as Record<string, unknown>; + const res = result['result'] as Record<string, unknown> | undefined; + const newEventId = (res?.['data'] as string | undefined) ?? (res?.['eventId'] as string | undefined); jsonOutput({ id: newEventId, @@ -115,7 +119,7 @@ export function registerCloneHelper(program) { }); } catch (e) { if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); - else jsonError(e.message); + else jsonError((e as Error).message); } }); } diff --git a/src/helpers/export.js b/src/helpers/export.js deleted file mode 100644 index f4cbeda..0000000 --- a/src/helpers/export.js +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Export helper: +export <eventId> — export event + guests to file - */ - -import fs from 'fs'; -import { loadConfig, getValidToken, wrapPayload } from '../lib/auth.js'; -import { apiRequest } from '../lib/http.js'; -import { fetchGuests } from '../commands/guests.js'; -import { jsonOutput, jsonError, formatCsv } from '../lib/output.js'; -import { PartifulError } from '../lib/errors.js'; - -export function registerExportHelper(program) { - program - .command('+export') - .description('Export event details and guest list') - .argument('<eventId>', 'Event ID to export') - .option('--format <format>', 'Output format: json or csv', 'json') - .option('--output <path>', 'Write to file instead of stdout') - .action(async (eventId, opts, cmd) => { - const globalOpts = cmd.optsWithGlobals(); - try { - const config = loadConfig(); - const token = await getValidToken(config); - - // Fetch event - const payload = { - data: wrapPayload(config, { - params: { eventId }, - amplitudeSessionId: Date.now(), - userId: config.userId, - }), - }; - - const result = await apiRequest('POST', '/getEventInfo', token, payload, globalOpts.verbose); - const event = result.result?.data?.event; - - if (!event) { - jsonError('Event not found', 4, 'not_found'); - return; - } - - // Fetch guests - const guests = await fetchGuests(eventId, token, config, globalOpts.verbose); - - const exportData = { - event: { - id: eventId, - title: event.title, - startDate: event.startDate, - endDate: event.endDate || null, - location: event.location || null, - address: event.address || null, - description: event.description || null, - status: event.status, - timezone: event.timezone || null, - url: `https://partiful.com/e/${eventId}`, - }, - guests: guests.map(g => ({ - name: g.name, - status: g.status, - count: g.count, - createdAt: g.createdAt, - channel: g.channel, - })), - exportedAt: new Date().toISOString(), - totalGuests: guests.length, - }; - - if (opts.format === 'csv') { - const csvHeader = `Event: ${event.title} (${eventId})\nExported: ${exportData.exportedAt}\n\n`; - const csvBody = formatCsv(exportData.guests, ['name', 'status', 'count', 'createdAt', 'channel']); - const output = csvHeader + csvBody; - if (opts.output) { - fs.writeFileSync(opts.output, output + '\n'); - process.stderr.write(`Exported to ${opts.output}\n`); - } else { - process.stdout.write(output + '\n'); - } - } else { - if (opts.output) { - fs.writeFileSync(opts.output, JSON.stringify(exportData, null, 2) + '\n'); - process.stderr.write(`Exported to ${opts.output}\n`); - } else { - jsonOutput(exportData); - } - } - } catch (e) { - if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); - else jsonError(e.message); - } - }); -} diff --git a/src/helpers/export.ts b/src/helpers/export.ts new file mode 100644 index 0000000..2ad7498 --- /dev/null +++ b/src/helpers/export.ts @@ -0,0 +1,97 @@ +/** + * Export helper: +export <eventId> — export event + guests to file + */ + +import fs from 'fs'; +import { Command } from 'commander'; +import { loadConfig, getValidToken, wrapPayload } from '../lib/auth.js'; +import { apiRequest } from '../lib/http.js'; +import { fetchGuests } from '../commands/guests.js'; +import { jsonOutput, jsonError, formatCsv } from '../lib/output.js'; +import { PartifulError } from '../lib/errors.js'; + +type GuestLike = Awaited<ReturnType<typeof fetchGuests>>[number]; + +export function registerExportHelper(program: Command): void { + program + .command('+export') + .description('Export event details and guest list') + .argument('<eventId>', 'Event ID to export') + .option('--format <format>', 'Output format: json or csv', 'json') + .option('--output <path>', 'Write to file instead of stdout') + .action(async (eventId: string, opts: Record<string, unknown>, cmd: Command) => { + const globalOpts = cmd.optsWithGlobals<Record<string, unknown>>(); + try { + const config = loadConfig(); + const token = await getValidToken(config); + + // Fetch event + const payload = { + data: wrapPayload(config, { + params: { eventId }, + amplitudeSessionId: Date.now(), + userId: config.userId, + }), + }; + + const result = await apiRequest('POST', '/getEventInfo', token, payload, globalOpts['verbose'] as boolean | undefined) as Record<string, unknown>; + const data = result['result'] as Record<string, unknown> | undefined; + const eventData = data?.['data'] as Record<string, unknown> | undefined; + const event = eventData?.['event'] as Record<string, unknown> | undefined; + + if (!event) { + jsonError('Event not found', 4, 'not_found'); + return; + } + + // Fetch guests + const guests: GuestLike[] = await fetchGuests(eventId, token, config, globalOpts['verbose'] as boolean | undefined); + + const exportData = { + event: { + id: eventId, + title: event['title'], + startDate: event['startDate'], + endDate: (event['endDate'] as unknown) ?? null, + location: (event['location'] as unknown) ?? null, + address: (event['address'] as unknown) ?? null, + description: (event['description'] as unknown) ?? null, + status: event['status'], + timezone: (event['timezone'] as unknown) ?? null, + url: `https://partiful.com/e/${eventId}`, + }, + guests: guests.map(g => ({ + name: g['name'], + status: g['status'], + count: g['count'], + createdAt: g['createdAt'], + channel: g['channel'], + })), + exportedAt: new Date().toISOString(), + totalGuests: guests.length, + }; + + if (opts['format'] === 'csv') { + const csvHeader = `Event: ${event['title']} (${eventId})\nExported: ${exportData.exportedAt}\n\n`; + const csvBody = formatCsv(exportData.guests as unknown as import('../lib/output.js').TableRow[], ['name', 'status', 'count', 'createdAt', 'channel']); + const output = csvHeader + csvBody; + if (opts['output']) { + fs.writeFileSync(opts['output'] as string, output + '\n'); + process.stderr.write(`Exported to ${opts['output']}\n`); + } else { + process.stdout.write(output + '\n'); + } + } else { + if (opts['output']) { + fs.writeFileSync(opts['output'] as string, JSON.stringify(exportData, null, 2) + '\n'); + process.stderr.write(`Exported to ${opts['output']}\n`); + } else { + jsonOutput(exportData); + } + } + } catch (e) { + if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); + else jsonError((e as Error).message); + } + }); +} diff --git a/src/helpers/share.js b/src/helpers/share.ts similarity index 62% rename from src/helpers/share.js rename to src/helpers/share.ts index 8a3a4cb..a9eee90 100644 --- a/src/helpers/share.js +++ b/src/helpers/share.ts @@ -2,18 +2,18 @@ * Share helper: +share <eventId> — generate shareable event link */ +import { Command } from 'commander'; import { loadConfig, getValidToken, wrapPayload } from '../lib/auth.js'; import { apiRequest } from '../lib/http.js'; import { jsonOutput, jsonError } from '../lib/output.js'; import { PartifulError } from '../lib/errors.js'; -export function registerShareHelper(program) { +export function registerShareHelper(program: Command): void { program .command('+share') .description('Generate shareable event link') .argument('<eventId>', 'Event ID') - .action(async (eventId, opts, cmd) => { - const globalOpts = cmd.optsWithGlobals(); + .action(async (eventId: string, _opts: Record<string, unknown>, _cmd: Command) => { try { const config = loadConfig(); const token = await getValidToken(config); @@ -27,16 +27,18 @@ export function registerShareHelper(program) { }), }; - const result = await apiRequest('POST', '/getEventInfo', token, payload, globalOpts.verbose); - const event = result.result?.data?.event; + const result = await apiRequest('POST', '/getEventInfo', token, payload, false) as Record<string, unknown>; + const data = (result as Record<string, unknown>)['result'] as Record<string, unknown> | undefined; + const eventData = data?.['data'] as Record<string, unknown> | undefined; + const event = eventData?.['event'] as Record<string, unknown> | undefined; - const title = event?.title || 'Unknown Event'; + const title = (event?.['title'] as string | undefined) ?? 'Unknown Event'; const url = `https://partiful.com/e/${eventId}`; jsonOutput({ url, eventId, title }); } catch (e) { if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); - else jsonError(e.message); + else jsonError((e as Error).message); } }); } diff --git a/src/helpers/watch.js b/src/helpers/watch.ts similarity index 68% rename from src/helpers/watch.js rename to src/helpers/watch.ts index 0f38597..cf15d53 100644 --- a/src/helpers/watch.js +++ b/src/helpers/watch.ts @@ -2,50 +2,53 @@ * Watch helper: +watch <eventId> — poll for guest RSVP changes */ +import { Command } from 'commander'; import { loadConfig, getValidToken } from '../lib/auth.js'; import { fetchGuests } from '../commands/guests.js'; import { jsonError } from '../lib/output.js'; import { PartifulError } from '../lib/errors.js'; -export function registerWatchHelper(program) { +type GuestLike = Awaited<ReturnType<typeof fetchGuests>>[number]; + +export function registerWatchHelper(program: Command): void { program .command('+watch') .description('Poll for guest RSVP changes (NDJSON output)') .argument('<eventId>', 'Event ID to watch') .option('--interval <seconds>', 'Poll interval in seconds', '30') .option('--duration <minutes>', 'Total watch duration in minutes', '60') - .action(async (eventId, opts) => { + .action(async (eventId: string, opts: Record<string, unknown>) => { try { const config = loadConfig(); const token = await getValidToken(config); - const intervalMs = parseInt(opts.interval) * 1000; - const durationMs = parseInt(opts.duration) * 60 * 1000; + const intervalMs = parseInt(opts['interval'] as string) * 1000; + const durationMs = parseInt(opts['duration'] as string) * 60 * 1000; const endTime = Date.now() + durationMs; - let previousSnapshot = {}; + let previousSnapshot: Record<string, string | undefined> = {}; let totalChanges = 0; let polls = 0; // Initial fetch - const initialGuests = await fetchGuests(eventId, token, config); + const initialGuests: GuestLike[] = await fetchGuests(eventId, token, config); for (const g of initialGuests) { previousSnapshot[g.name] = g.status; } - process.stderr.write(`Watching ${eventId} — ${initialGuests.length} guests, polling every ${opts.interval}s for ${opts.duration}m\n`); + process.stderr.write(`Watching ${eventId} — ${initialGuests.length} guests, polling every ${opts['interval']}s for ${opts['duration']}m\n`); - const poll = async () => { + const poll = async (): Promise<boolean> => { if (Date.now() >= endTime) return false; polls++; const freshToken = await getValidToken(config); - const guests = await fetchGuests(eventId, freshToken, config); - const currentSnapshot = {}; + const guests: GuestLike[] = await fetchGuests(eventId, freshToken, config); + const currentSnapshot: Record<string, string | undefined> = {}; for (const g of guests) { currentSnapshot[g.name] = g.status; - if (previousSnapshot[g.name] && previousSnapshot[g.name] !== g.status) { + if (previousSnapshot[g.name] !== undefined && previousSnapshot[g.name] !== g.status) { totalChanges++; const change = { type: 'rsvp_change', @@ -55,7 +58,7 @@ export function registerWatchHelper(program) { timestamp: new Date().toISOString(), }; process.stdout.write(JSON.stringify(change) + '\n'); - } else if (!previousSnapshot[g.name]) { + } else if (previousSnapshot[g.name] === undefined) { totalChanges++; const change = { type: 'new_guest', @@ -74,7 +77,7 @@ export function registerWatchHelper(program) { // Poll loop while (Date.now() < endTime) { - await new Promise(resolve => setTimeout(resolve, intervalMs)); + await new Promise<void>(resolve => setTimeout(resolve, intervalMs)); const shouldContinue = await poll(); if (!shouldContinue) break; } @@ -83,7 +86,7 @@ export function registerWatchHelper(program) { process.stderr.write(`\nWatch complete: ${polls} polls, ${totalChanges} change(s) detected\n`); } catch (e) { if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); - else jsonError(e.message); + else jsonError((e as Error).message); } }); } From 06eeb6835e683a0629ee5af8d6b91df4d5e852fc Mon Sep 17 00:00:00 2001 From: Kaleb Cole <kalebcole2021@gmail.com> Date: Fri, 24 Jul 2026 15:15:28 -0700 Subject: [PATCH 06/14] T5: rewire schema command to introspect api/ spec (schema api.<method>) Adds 'schema api' (list endpoints) and 'schema api.<method>' (per-endpoint spec: transport, host, httpMethod, path, requestParams, responseFields) driven off the apiEndpoints registry in api/endpoints.ts. Bare 'schema' now also lists api.* methods. Existing 'schema <command>' CLI-flag lookup unchanged. +5 tests (tests/schema-api.test.js). 200/200 green; tsc clean. --- src/commands/schema.ts | 45 ++++++++++++++++++++++++++++++++--- tests/schema-api.test.js | 51 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 tests/schema-api.test.js diff --git a/src/commands/schema.ts b/src/commands/schema.ts index a3a7280..e190088 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -1,5 +1,6 @@ import { jsonOutput, jsonError, EXIT } from '../lib/output.js'; import type { Command } from 'commander'; +import { apiEndpoints } from '../lib/api/endpoints.js'; /** A single parameter descriptor in a command schema. */ interface SchemaParameter { @@ -188,16 +189,54 @@ const SCHEMAS: Record<string, CommandSchema> = { export function registerSchemaCommand(program: Command): void { program .command('schema [path]') - .description('Introspect command parameters (e.g., events.create)') + .description('Introspect command parameters (e.g., events.create) or API endpoints (e.g., api.createEvent)') .action((path: string | undefined, _opts: unknown, cmd: Command) => { const globalOpts = cmd.optsWithGlobals(); + + // ── API-endpoint introspection namespace: `schema api` / `schema api.<method>` + // Reads from the T3 endpoint registry (api/endpoints.ts) — the spec-as-types + // source of truth: host, method, transport, request params, response fields. + if (path === 'api') { + const methods = Object.keys(apiEndpoints); + jsonOutput({ methods }, { count: methods.length }, globalOpts); + return; + } + if (path && path.startsWith('api.')) { + const method = path.slice('api.'.length); + if (!Object.hasOwn(apiEndpoints, method)) { + const available = Object.keys(apiEndpoints).join(', '); + jsonError(`Unknown API method: ${method}. Available: ${available}`, EXIT.NOT_FOUND, 'not_found'); + return; + } + const meta = apiEndpoints[method as keyof typeof apiEndpoints]; + jsonOutput( + { + method: `api.${method}`, + transport: meta.transport, + host: meta.host, + httpMethod: meta.method, + path: meta.path, + requestParams: meta.requestParams, + responseFields: meta.responseFields, + }, + {}, + globalOpts, + ); + return; + } + + // ── CLI-flag introspection (original behavior) if (!path) { - jsonOutput({ commands: Object.keys(SCHEMAS) }, { count: Object.keys(SCHEMAS).length }, globalOpts); + jsonOutput( + { commands: Object.keys(SCHEMAS), api: Object.keys(apiEndpoints).map((m) => `api.${m}`) }, + { count: Object.keys(SCHEMAS).length + Object.keys(apiEndpoints).length }, + globalOpts, + ); return; } if (!Object.hasOwn(SCHEMAS, path)) { const available = Object.keys(SCHEMAS).join(', '); - jsonError(`Unknown schema path: ${path}. Available: ${available}`, 4, 'not_found'); + jsonError(`Unknown schema path: ${path}. Available: ${available}`, EXIT.NOT_FOUND, 'not_found'); return; } const schema = SCHEMAS[path]; diff --git a/tests/schema-api.test.js b/tests/schema-api.test.js new file mode 100644 index 0000000..f775ce3 --- /dev/null +++ b/tests/schema-api.test.js @@ -0,0 +1,51 @@ +/** + * Schema introspection coverage for the `api.<method>` namespace (T5). + * The API-endpoint spec (src/lib/api/endpoints.ts) is the source of truth; + * `schema api` / `schema api.<method>` surfaces it from the CLI. + */ +import { describe, it, expect } from 'vitest'; +import { run, runRaw } from './helpers.js'; + +describe('schema api.<method> namespace', () => { + it('bare `schema` lists api.* methods alongside CLI commands', () => { + const out = run(['schema']); + expect(out.data.commands).toContain('events.create'); + expect(out.data.api).toContain('api.createEvent'); + expect(out.data.api).toContain('api.addGuest'); + }); + + it('`schema api` lists every spec\'d endpoint method', () => { + const out = run(['schema', 'api']); + expect(Array.isArray(out.data.methods)).toBe(true); + expect(out.data.methods).toContain('createEvent'); + expect(out.data.methods).toContain('markEventInterest'); + expect(out.data.methods).toContain('refreshToken'); + expect(out.metadata.count).toBe(out.data.methods.length); + }); + + it('`schema api.createEvent` prints endpoint spec derived from T3 types', () => { + const out = run(['schema', 'api.createEvent']); + expect(out.data.method).toBe('api.createEvent'); + expect(out.data.httpMethod).toBe('POST'); + expect(out.data.transport).toBe('firebase-callable'); + expect(out.data.path).toBe('/createEvent'); + expect(out.data.requestParams).toContain('event'); + expect(Array.isArray(out.data.responseFields)).toBe(true); + }); + + it('`schema api.firestoreGetEvent` reflects firestore transport + GET', () => { + const out = run(['schema', 'api.firestoreGetEvent']); + expect(out.data.transport).toBe('firestore'); + expect(out.data.httpMethod).toBe('GET'); + expect(out.data.requestParams).toContain('eventId'); + }); + + it('unknown api method errors with not_found and lists available', () => { + const { stdout, exitCode } = runRaw(['schema', 'api.nope']); + expect(exitCode).toBe(4); + const out = JSON.parse(stdout.trim()); + expect(out.status).toBe('error'); + expect(out.error.type).toBe('not_found'); + expect(out.error.message).toContain('createEvent'); + }); +}); From e45031e881ee1745b47845173246b8af808e0221 Mon Sep 17 00:00:00 2001 From: Kaleb Cole <kalebcole2021@gmail.com> Date: Fri, 24 Jul 2026 15:20:20 -0700 Subject: [PATCH 07/14] T6: drift detection + gated real-API smoke tests - src/lib/drift.ts: diff passthrough responses vs spec's declared field surface; detectDrift() + reportDrift(). Opt-in logging via PARTIFUL_DRIFT_LOG (unset=silent, 1/true/stderr=stderr line, path=NDJSON append). - Wired centrally + guarded into apiRequest() via path->method reverse map + envelope unwrap; advisory only, never breaks a request. - src/lib/api/endpoints.ts: export responseSchemas registry (method->schema). - tests/drift.test.js: 6 unit tests (no auth needed, run everywhere). - tests/smoke-real-api.test.js: live-API spec verifier, skipIf(!PARTIFUL_SMOKE), read-only endpoints, documented run instructions. - docs/TYPESCRIPT-PORT-GUIDE.md \u00a710: implemented drift+smoke strategy, CI-vs-manual. tsc clean; 206 passed / 6 smoke skipped. --- docs/TYPESCRIPT-PORT-GUIDE.md | 39 +++++++++--- src/lib/api/endpoints.ts | 26 ++++++++ src/lib/drift.ts | 110 ++++++++++++++++++++++++++++++++++ src/lib/http.ts | 30 +++++++++- tests/drift.test.js | 63 +++++++++++++++++++ tests/smoke-real-api.test.js | 76 +++++++++++++++++++++++ 6 files changed, 335 insertions(+), 9 deletions(-) create mode 100644 src/lib/drift.ts create mode 100644 tests/drift.test.js create mode 100644 tests/smoke-real-api.test.js diff --git a/docs/TYPESCRIPT-PORT-GUIDE.md b/docs/TYPESCRIPT-PORT-GUIDE.md index c8ad40f..4839319 100644 --- a/docs/TYPESCRIPT-PORT-GUIDE.md +++ b/docs/TYPESCRIPT-PORT-GUIDE.md @@ -193,11 +193,34 @@ types — T4) → `src/commands/schema.ts` (surface them — T5). Drift + smoke onto the T3 parse path. If while porting a command (T4) you find yourself authoring a new endpoint shape, it belonged in T3 — go back and add it there. -## 10. Drift detection & smoke tests (T6 summary) - -- Because responses use `.passthrough()`, unknown keys are observable at parse time. Log - them behind a verbose/debug flag so real traffic reveals the vendor's true shape over - time. -- A small real-API smoke suite (gated behind auth env, like existing `*.integration.test.js`) - is the spec verifier: when Partiful changes a shape, a smoke test fails before users hit - it. +## 10. Drift detection & smoke tests (T6 — implemented) + +**Drift detection** (`src/lib/drift.ts`): +- Every response schema in `api/endpoints.ts` uses `.passthrough()`, so unknown vendor + fields survive parsing. `detectDrift(method, response)` diffs a response's top-level keys + against the schema's declared keys (unwrapping array element schemas) and returns the + unknown fields. `reportDrift()` logs them. +- Wired centrally into `apiRequest()` via a `path → method` reverse map + envelope unwrap + (`checkDrift`). It is **advisory only** and fully `try/catch`-guarded — drift detection can + never break a real request. +- **Opt-in logging**, off by default to keep stdout JSON clean for agents: + - `PARTIFUL_DRIFT_LOG=1` (or `true`/`stderr`) → human line to **stderr**. + - `PARTIFUL_DRIFT_LOG=/path/to/file.ndjson` → append **NDJSON** `{ "drift": {...} }` records. + - unset/`0`/`false` → silent (drift still detectable in-process/tests). +- Schemas that are `z.object({}).passthrough()` (no declared fields, e.g. `cancelEvent`) + have no field surface to diff and never flag — expected. + +**Smoke tests** (`tests/smoke-real-api.test.js`) — THE SPEC VERIFIER: +- Hit the **live** Partiful API (read-only endpoints only; safe to re-run). When Partiful + changes a shape, a smoke test fails before users do; `PARTIFUL_DRIFT_LOG=1` names the fields. +- **Skipped by default** via `describe.skipIf(!SMOKE)`; secrets never required in CI. +- Run manually: + ``` + PARTIFUL_SMOKE=1 PARTIFUL_TOKEN=<real-jwt> npx vitest run tests/smoke-real-api.test.js + # optional: PARTIFUL_SMOKE_EVENT_ID=<id> to exercise getEventInfo + # optional: PARTIFUL_DRIFT_LOG=1 to surface unknown vendor fields while running + ``` +- **CI vs manual:** manual (or a secrets-provisioned job) — auth secrets don't belong in + the default CI matrix. The unit-level drift suite (`tests/drift.test.js`) runs everywhere + and needs no auth. + diff --git a/src/lib/api/endpoints.ts b/src/lib/api/endpoints.ts index d721c22..36e9c6e 100644 --- a/src/lib/api/endpoints.ts +++ b/src/lib/api/endpoints.ts @@ -452,3 +452,29 @@ export const apiEndpoints = { } as const satisfies Record<string, EndpointMeta>; export type ApiMethod = keyof typeof apiEndpoints; + +// =========================================================================== +// Response-schema registry — consumed by the drift detector (T6). +// Maps each spec'd method to its Zod `.passthrough()` response schema so raw +// API responses can be diffed against the known field surface at parse time. +// Methods whose real response is an array expose the element schema (the unit +// whose keys we compare); firebase-auth/refreshToken uses its object schema. +// =========================================================================== + +export const responseSchemas = { + createEvent: CreateEventResponseSchema, + cancelEvent: CancelEventResponseSchema, + getEventInfo: GetEventInfoResponseSchema, + getContacts: ContactSchema, + createTextBlast: CreateTextBlastResponseSchema, + addInvitedGuestsAsHost: AddInvitedGuestsAsHostResponseSchema, + getMyUpcomingEventsForHomePage: HomePageEventSchema, + getMyPastEventsForHomePage: HomePageEventSchema, + addGuest: AddGuestResponseSchema, + markEventInterest: MarkEventInterestResponseSchema, + getCurrentGuest: GetCurrentGuestResponseSchema, + firestoreGetEvent: FirestoreDocumentSchema, + firestorePatchEvent: FirestoreDocumentSchema, + firestoreListDocuments: FirestoreListResponseSchema, + refreshToken: RefreshTokenResponseSchema, +} as const satisfies Record<ApiMethod, z.ZodTypeAny>; diff --git a/src/lib/drift.ts b/src/lib/drift.ts new file mode 100644 index 0000000..58306ea --- /dev/null +++ b/src/lib/drift.ts @@ -0,0 +1,110 @@ +/** + * Drift detection (T6). + * + * Partiful is an unofficial API — the vendor changes response shapes without + * notice. Every response schema in api/endpoints.ts is authored with Zod + * `.passthrough()`, so unknown fields survive parsing instead of being + * stripped. This module diffs a raw response against the known field surface + * of its schema and reports any fields the spec does NOT yet declare. Over + * time, real traffic reveals the vendor's true shape and keeps types-as-spec + * honest. + * + * OPT-IN: logging is silent unless `PARTIFUL_DRIFT_LOG` is set (or verbose is + * passed explicitly). Set `PARTIFUL_DRIFT_LOG=1` to log to stderr, or + * `PARTIFUL_DRIFT_LOG=<path>` to append NDJSON records to a file. This keeps + * the default JSON-on-stdout contract clean for agent consumers. + */ + +import { appendFileSync } from 'fs'; +import { z } from 'zod'; +import { responseSchemas, type ApiMethod } from './api/endpoints.js'; + +/** A single drift observation: fields present in the response but not in the spec. */ +export interface DriftRecord { + method: ApiMethod; + unknownFields: string[]; + observedAt: string; +} + +/** + * Enumerate the declared (known) top-level keys of a Zod schema. Mirrors the + * `fieldsOf` helper in endpoints.ts but resolves through common wrappers + * (arrays expose their element's keys; effects/optionals unwrap) so the diff + * compares against the real object shape. + */ +function knownKeys(schema: z.ZodTypeAny): Set<string> { + let s: unknown = schema; + // Unwrap array element schemas so we compare a single record's keys. + const def = s as { _def?: { type?: unknown; innerType?: unknown }; element?: unknown; shape?: Record<string, unknown> }; + if (def.element) s = def.element as z.ZodTypeAny; + const inner = s as { shape?: Record<string, unknown> }; + if (inner.shape && typeof inner.shape === 'object') return new Set(Object.keys(inner.shape)); + return new Set(); +} + +/** + * Return the top-level keys of an observed response record. Array responses + * are reduced to the union of keys across their elements (objects only). + */ +function observedKeys(value: unknown): Set<string> { + const keys = new Set<string>(); + const records = Array.isArray(value) ? value : [value]; + for (const rec of records) { + if (rec && typeof rec === 'object' && !Array.isArray(rec)) { + for (const k of Object.keys(rec as Record<string, unknown>)) keys.add(k); + } + } + return keys; +} + +/** + * Compare a raw response against the spec's schema for `method`. Returns the + * set of fields present in the response but absent from the schema, or an + * empty array when the response conforms (or the method has no schema). + */ +export function detectDrift(method: ApiMethod, response: unknown): string[] { + const schema = responseSchemas[method]; + if (!schema) return []; + const known = knownKeys(schema); + if (known.size === 0) return []; // schema declares no fields to diff against + const observed = observedKeys(response); + return [...observed].filter((k) => !known.has(k)).sort(); +} + +/** + * Detect drift for `method` and, if any unknown fields are found AND drift + * logging is enabled, emit a record. Enabled when `PARTIFUL_DRIFT_LOG` is set + * or `force` is true. `PARTIFUL_DRIFT_LOG=1|true|stderr` → stderr; any other + * value is treated as a file path and NDJSON records are appended to it. + * + * Always returns the DriftRecord when drift is found (even if logging is off), + * so callers/tests can assert on it; returns null when the response conforms. + * Never throws — drift detection must never break a real request. + */ +export function reportDrift(method: ApiMethod, response: unknown, force = false): DriftRecord | null { + let unknownFields: string[]; + try { + unknownFields = detectDrift(method, response); + } catch { + return null; + } + if (unknownFields.length === 0) return null; + + const record: DriftRecord = { method, unknownFields, observedAt: new Date().toISOString() }; + + const sink = process.env.PARTIFUL_DRIFT_LOG; + const enabled = force || (sink != null && sink !== '' && sink !== '0' && sink !== 'false'); + if (enabled) { + const line = JSON.stringify({ drift: record }); + if (!sink || sink === '1' || sink === 'true' || sink === 'stderr' || force) { + console.error(`[drift] ${method}: unknown fields ${unknownFields.join(', ')}`); + } else { + try { + appendFileSync(sink, line + '\n'); + } catch { + console.error(`[drift] ${method}: unknown fields ${unknownFields.join(', ')} (log write failed)`); + } + } + } + return record; +} diff --git a/src/lib/http.ts b/src/lib/http.ts index a131186..855cbb6 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -4,11 +4,37 @@ */ import { AuthError, NotFoundError, ApiError } from './errors.js'; +import { apiEndpoints, type ApiMethod } from './api/endpoints.js'; +import { reportDrift } from './drift.js'; const API_BASE = 'https://api.partiful.com'; const FIRESTORE_BASE = 'https://firestore.googleapis.com'; const FIRESTORE_PROJECT = 'getpartiful'; +// Reverse map: endpoint path (e.g. '/createEvent') → spec method name, so the +// central request path can diff live responses against the spec (T6 drift). +const PATH_TO_METHOD: Record<string, ApiMethod> = Object.fromEntries( + (Object.entries(apiEndpoints) as [ApiMethod, { path: string }][]).map(([method, meta]) => [meta.path, method]), +) as Record<string, ApiMethod>; + +/** + * Best-effort drift check for a callable response. Unwraps the firebase + * callable envelope ({ result: { data } }) to the payload the response schema + * describes, then reports any unknown fields. Never throws — a broken drift + * check must never break a real request. + */ +function checkDrift(endpoint: string, parsed: unknown): void { + try { + const method = PATH_TO_METHOD[endpoint]; + if (!method) return; + const env = parsed as { result?: { data?: unknown } } | undefined; + const payload = env?.result?.data ?? parsed; + reportDrift(method, payload); + } catch { + /* drift detection is advisory only */ + } +} + const RETRYABLE_CODES = new Set([429, 500, 502, 503, 504]); const MAX_RETRIES = parseInt(process.env.PARTIFUL_MAX_RETRIES || '3', 10); @@ -93,7 +119,9 @@ export async function apiRequest( } const text = await resp.text(); - return text ? JSON.parse(text) : {}; + const parsed = text ? JSON.parse(text) : {}; + checkDrift(endpoint, parsed); + return parsed; } export async function firestoreRequest( diff --git a/tests/drift.test.js b/tests/drift.test.js new file mode 100644 index 0000000..c2d1b90 --- /dev/null +++ b/tests/drift.test.js @@ -0,0 +1,63 @@ +/** + * Drift-detection unit tests (T6). + * + * Verifies that responses carrying fields the spec does not declare are + * flagged, that conforming responses are silent, and that array responses + * (getContacts, homepage events) are diffed against their element schema. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { detectDrift, reportDrift } from '../src/lib/drift.ts'; + +describe('drift detection', () => { + it('flags unknown top-level fields against an object schema', () => { + const drift = detectDrift('createEvent', { + id: 'evt_1', + title: 'Party', + status: 'published', + startDate: '2026-08-01', + brandNewVendorField: 42, + anotherOne: true, + }); + expect(drift).toContain('brandNewVendorField'); + expect(drift).toContain('anotherOne'); + expect(drift).not.toContain('id'); + expect(drift).not.toContain('title'); + }); + + it('is silent when a response conforms to the spec', () => { + const drift = detectDrift('createEvent', { id: 'evt_1', title: 'Party' }); + expect(drift).toEqual([]); + }); + + it('diffs array responses against the element schema (getContacts)', () => { + const drift = detectDrift('getContacts', [ + { id: 'u1', name: 'Alice' }, + { id: 'u2', name: 'Bob', mysteryField: 'x' }, + ]); + expect(drift).toContain('mysteryField'); + }); + + it('returns [] for a method whose schema declares no fields (passthrough-only)', () => { + // cancelEvent response schema is z.object({}).passthrough() — no declared + // keys, so there is no field surface to diff against; never flags drift. + const drift = detectDrift('cancelEvent', { anything: 1, goes: 2 }); + expect(drift).toEqual([]); + }); + + it('reportDrift returns a record with force=true, null when conforming', () => { + const rec = reportDrift('createEvent', { id: 'e', surprise: 1 }, true); + expect(rec).not.toBeNull(); + expect(rec.method).toBe('createEvent'); + expect(rec.unknownFields).toContain('surprise'); + expect(typeof rec.observedAt).toBe('string'); + + const none = reportDrift('createEvent', { id: 'e' }, true); + expect(none).toBeNull(); + }); + + it('reportDrift never throws on malformed input', () => { + expect(() => reportDrift('createEvent', null)).not.toThrow(); + expect(() => reportDrift('createEvent', 'not-an-object')).not.toThrow(); + expect(reportDrift('createEvent', null)).toBeNull(); + }); +}); diff --git a/tests/smoke-real-api.test.js b/tests/smoke-real-api.test.js new file mode 100644 index 0000000..3ff40be --- /dev/null +++ b/tests/smoke-real-api.test.js @@ -0,0 +1,76 @@ +/** + * Real-API smoke tests (T6) — THE SPEC VERIFIER. + * + * These hit the LIVE Partiful API. When Partiful changes a response shape, one + * of these fails BEFORE users do, and drift detection (src/lib/drift.ts) names + * the offending fields. They are the runtime counterpart to the types-as-spec + * in src/lib/api/endpoints.ts. + * + * GATED + SKIPPED BY DEFAULT. They require valid auth and network, so they are + * OFF unless you explicitly opt in. Secrets never live in CI here — run these + * manually (or in a secrets-provisioned job). + * + * HOW TO RUN: + * PARTIFUL_SMOKE=1 PARTIFUL_TOKEN=<real-jwt> npx vitest run tests/smoke-real-api.test.js + * # or with a refresh token the CLI can exchange: + * PARTIFUL_SMOKE=1 PARTIFUL_REFRESH_TOKEN=<...> npx vitest run tests/smoke-real-api.test.js + * # optional: exercise event-scoped reads against an event you can access + * PARTIFUL_SMOKE=1 PARTIFUL_TOKEN=<jwt> PARTIFUL_SMOKE_EVENT_ID=<id> npx vitest run ... + * # optional: surface unknown vendor fields while running + * PARTIFUL_DRIFT_LOG=1 PARTIFUL_SMOKE=1 PARTIFUL_TOKEN=<jwt> npx vitest run ... + * + * WHAT THEY VERIFY: read-only endpoints only (no event creation / mutation), so + * they are safe to run repeatedly against a real account. + */ +import { describe, it, expect } from 'vitest'; +import { run, runRaw } from './helpers.js'; + +const SMOKE = process.env.PARTIFUL_SMOKE === '1' || process.env.PARTIFUL_SMOKE === 'true'; +const HAS_AUTH = Boolean(process.env.PARTIFUL_TOKEN || process.env.PARTIFUL_REFRESH_TOKEN); +const EVENT_ID = process.env.PARTIFUL_SMOKE_EVENT_ID; + +// Real auth is passed straight through; do NOT override PARTIFUL_TOKEN with the +// fake one the unit-test helper injects. +const realEnv = { + PARTIFUL_TOKEN: process.env.PARTIFUL_TOKEN, + PARTIFUL_REFRESH_TOKEN: process.env.PARTIFUL_REFRESH_TOKEN, + PARTIFUL_DRIFT_LOG: process.env.PARTIFUL_DRIFT_LOG, +}; + +describe.skipIf(!SMOKE)('real-API smoke (live Partiful)', () => { + it('has auth configured', () => { + expect(HAS_AUTH, 'set PARTIFUL_TOKEN or PARTIFUL_REFRESH_TOKEN to run smoke tests').toBe(true); + }); + + it('events list — getMyUpcomingEventsForHomePage returns a JSON envelope', () => { + const out = run(['events', 'list'], { env: realEnv }); + expect(out.status).toBe('success'); + expect(Array.isArray(out.data) || Array.isArray(out.data?.events)).toBe(true); + }); + + it('events list --past — getMyPastEventsForHomePage returns a JSON envelope', () => { + const out = run(['events', 'list', '--past'], { env: realEnv }); + expect(out.status).toBe('success'); + }); + + it('contacts list — getContacts returns a JSON envelope', () => { + const out = run(['contacts', 'list'], { env: realEnv }); + expect(out.status).toBe('success'); + }); + + it('schema api.createEvent still matches the live createEvent contract surface', () => { + // Pure-local guard that rides along with the smoke run: if the spec's + // request params were edited away from the real contract, catch it here. + const out = run(['schema', 'api.createEvent']); + expect(out.data.requestParams).toContain('event'); + expect(out.data.path).toBe('/createEvent'); + }); + + it.skipIf(!EVENT_ID)('events get <id> — getEventInfo returns the event', () => { + const { stdout, exitCode } = runRaw(['events', 'get', EVENT_ID], { env: realEnv }); + expect(exitCode, `stdout: ${stdout}`).toBe(0); + const out = JSON.parse(stdout.trim()); + expect(out.status).toBe('success'); + expect(out.data).toBeDefined(); + }); +}); From 9320471dbe8b5eb8b53f1c9388c59b4c650c509c Mon Sep 17 00:00:00 2001 From: Kaleb Cole <kalebcole2021@gmail.com> Date: Fri, 24 Jul 2026 15:21:20 -0700 Subject: [PATCH 08/14] docs(wayfinder): close T4/T5/T6 tickets + update map --- .wayfinder/ts-port/map.md | 9 ++++++--- .../tickets/T5-rewire-schema-command.md | 12 +++++++++++- .../tickets/T6-drift-detection-smoke-tests.md | 18 +++++++++++++++++- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/.wayfinder/ts-port/map.md b/.wayfinder/ts-port/map.md index 4234969..076bb9b 100644 --- a/.wayfinder/ts-port/map.md +++ b/.wayfinder/ts-port/map.md @@ -36,6 +36,9 @@ Success = the API spec is a *byproduct* of typing the code, not a separate artif - T1 CLOSED (2026-07-24): TS toolchain up. tsx-loader run path (no dist build), tsconfig strict+NodeNext+allowJs, zod added. `npm run typecheck` clean + 195/195 green on still-JS tree. - T2 CLOSED (2026-07-24): Convention doc at `docs/TYPESCRIPT-PORT-GUIDE.md`. Enforceable rules + worked createEvent endpoint (envelope generic + request interface + Zod passthrough + z.infer + metadata). Spec home = `src/lib/api/`. - T3 CLOSED (2026-07-24): src/lib/ is 100% TS strict (11 modules). THE SPEC authored at `src/lib/api/{envelope,endpoints}.ts`: CallableEnvelope<P>/CallableResult<D> generics + per-endpoint request interfaces + Zod .passthrough() response schemas + z.infer types + introspectable `apiEndpoints` registry (14 entries across firebase-callable/firestore/firebase-auth). bin/partiful uses tsx register() then dynamic import (ESM hoist fix). tsc clean + 195/195 green. +- T4 CLOSED (2026-07-24, cd6581a): src/ is 100% TypeScript. All 18 remaining .js (12 commands, 4 helpers, cli.ts, schema.ts) ported to strict; commander handlers typed, API responses narrowed via api/ spec + as-casts, `.js` import specifiers preserved (NodeNext). tsc clean + 195/195 green; ./bin/partiful --version + schema smoke-tested via tsx loader. +- T5 CLOSED (2026-07-24, 06eeb68): `schema api.<method>` namespace driven off the apiEndpoints registry; `schema api` lists methods; bare `schema` lists commands + api.*; existing `schema <command>` unchanged. +5 tests. Output mirrors existing JSON-envelope format. +- T6 CLOSED (2026-07-24, e45031e): drift detection (src/lib/drift.ts) diffs passthrough responses vs spec field surface, wired guarded into apiRequest, opt-in PARTIFUL_DRIFT_LOG (silent default / stderr / NDJSON file). Gated real-API smoke suite (skipIf !PARTIFUL_SMOKE, read-only). +6 drift unit tests. Strategy documented in guide §10. 206 passed / 6 smoke skipped. ## Not yet specified @@ -61,6 +64,6 @@ Success = the API spec is a *byproduct* of typing the code, not a separate artif | T1 | TS toolchain setup (tsconfig strict, tsx run, bin, build) | task (AFK) | T0 | ✅ CLOSED (tsx loader, no dist) | | T2 | Write porting convention doc (strict + Zod pattern) | task (AFK) | T1 | ✅ CLOSED (docs/TYPESCRIPT-PORT-GUIDE.md) | | T3 | Port src/lib/ API layer + author endpoint types/Zod (THE SPEC) | task (AFK) | T2 | ✅ CLOSED (src/lib 100% TS, api/ spec) | -| T4 | Port src/commands/ + src/helpers/ | task (AFK) | T3 | OPEN | -| T5 | Rewire schema command → schema api.<method> | task (AFK) | T3 | OPEN | -| T6 | Wire drift-detection + real-API smoke tests | task (AFK) | T3 | OPEN | +| T4 | Port src/commands/ + src/helpers/ | task (AFK) | T3 | ✅ CLOSED (src/ 100% TS, cd6581a) | +| T5 | Rewire schema command → schema api.<method> | task (AFK) | T3 | ✅ CLOSED (schema api.*, 06eeb68) | +| T6 | Wire drift-detection + real-API smoke tests | task (AFK) | T3 | ✅ CLOSED (drift.ts + smoke suite, e45031e) | diff --git a/.wayfinder/ts-port/tickets/T5-rewire-schema-command.md b/.wayfinder/ts-port/tickets/T5-rewire-schema-command.md index 3e5a9e9..51990d7 100644 --- a/.wayfinder/ts-port/tickets/T5-rewire-schema-command.md +++ b/.wayfinder/ts-port/tickets/T5-rewire-schema-command.md @@ -23,4 +23,14 @@ endpoint; existing `schema <command>` unchanged; tests cover the new namespace. ## Answer -<!-- record on close --> +**CLOSED (2026-07-24, commit 06eeb68).** Added a `schema api.<method>` namespace driven off +the `apiEndpoints` registry in `src/lib/api/endpoints.ts` (the T3 types-as-spec source of truth): +- `schema api` → lists every spec'd method. +- `schema api.<method>` → prints `{ method, transport, host, httpMethod, path, requestParams, + responseFields }` derived from the registry + Zod schemas. +- Bare `schema` now lists both CLI `commands` and `api.*` methods; existing `schema <command>` + CLI-flag lookup is unchanged (different layer, kept). +- Output shape: mirrors the existing JSON-envelope format (jsonOutput/jsonError), diverging only + in the payload keys (endpoint metadata vs. CLI-flag params). +- Tests: `tests/schema-api.test.js` (+5) cover list, per-endpoint spec, firestore transport, and + the unknown-method not_found path. diff --git a/.wayfinder/ts-port/tickets/T6-drift-detection-smoke-tests.md b/.wayfinder/ts-port/tickets/T6-drift-detection-smoke-tests.md index cd1f5da..d16d37b 100644 --- a/.wayfinder/ts-port/tickets/T6-drift-detection-smoke-tests.md +++ b/.wayfinder/ts-port/tickets/T6-drift-detection-smoke-tests.md @@ -24,4 +24,20 @@ documented (how to run, what auth it needs); drift strategy noted in the port gu ## Answer -<!-- record on close --> +**CLOSED (2026-07-24, commit e45031e).** + +**Drift detection** — `src/lib/drift.ts`: `detectDrift(method, response)` diffs a response's +top-level keys against the schema's declared keys (unwraps array element schemas + callable +envelope); `reportDrift()` logs. Wired centrally + `try/catch`-guarded into `apiRequest()` via a +`path→method` reverse map (`checkDrift`) — advisory only, never breaks a request. Logging is +opt-in via `PARTIFUL_DRIFT_LOG` (unset/`0`/`false` = silent; `1`/`true`/`stderr` = stderr line; +any other value = NDJSON append to that path) so the default stdout JSON contract stays clean. +`responseSchemas` registry (method→Zod schema) added to `endpoints.ts`. + +**Smoke tests** — `tests/smoke-real-api.test.js`: live-API spec verifier, read-only endpoints, +`describe.skipIf(!PARTIFUL_SMOKE)` so it's off by default (no secrets in CI). Documented run +instructions (token env, optional `PARTIFUL_SMOKE_EVENT_ID`, `PARTIFUL_DRIFT_LOG`). Unit-level +drift suite `tests/drift.test.js` (+6) runs everywhere, no auth. + +**CI vs manual:** smoke = manual/secrets-provisioned job; drift unit tests = every run. +**Drift strategy documented** in `docs/TYPESCRIPT-PORT-GUIDE.md` §10. From 404c024e0bd1f262287c3013ac361a6492811262 Mon Sep 17 00:00:00 2001 From: Kaleb Cole <kalebcole2021@gmail.com> Date: Fri, 24 Jul 2026 15:47:23 -0700 Subject: [PATCH 09/14] fix(drift): per-method payload unwrap for nested-response endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit flagged that checkDrift unwrapped uniformly to result.data, but getEventInfo nests at result.data.event and the homepage lists at result.data.{upcomingEvents,pastEvents} — so those methods reported drift on every call and schema api.<method> advertised the wrong depth. Add a PAYLOAD_UNWRAP map + unwrapPayload() in drift.ts and descend before diffing. Also correct the knownKeys() doc comment (Copilot): it resolves array element schemas only, not effects/optionals. +2 drift tests. 208 green. --- src/lib/drift.ts | 39 ++++++++++++++++++++++++++++++++++----- src/lib/http.ts | 8 ++++++-- tests/drift.test.js | 30 +++++++++++++++++++++++++++++- 3 files changed, 69 insertions(+), 8 deletions(-) diff --git a/src/lib/drift.ts b/src/lib/drift.ts index 58306ea..186b7ac 100644 --- a/src/lib/drift.ts +++ b/src/lib/drift.ts @@ -27,21 +27,50 @@ export interface DriftRecord { } /** - * Enumerate the declared (known) top-level keys of a Zod schema. Mirrors the - * `fieldsOf` helper in endpoints.ts but resolves through common wrappers - * (arrays expose their element's keys; effects/optionals unwrap) so the diff - * compares against the real object shape. + * Enumerate the declared (known) top-level keys of a Zod schema. Resolves + * through an array wrapper (arrays expose their element's keys) so the diff + * compares against a single record's shape. Non-object/array schemas yield an + * empty set (no field surface → no drift reported). */ function knownKeys(schema: z.ZodTypeAny): Set<string> { let s: unknown = schema; // Unwrap array element schemas so we compare a single record's keys. - const def = s as { _def?: { type?: unknown; innerType?: unknown }; element?: unknown; shape?: Record<string, unknown> }; + const def = s as { element?: unknown; shape?: Record<string, unknown> }; if (def.element) s = def.element as z.ZodTypeAny; const inner = s as { shape?: Record<string, unknown> }; if (inner.shape && typeof inner.shape === 'object') return new Set(Object.keys(inner.shape)); return new Set(); } +/** + * A few endpoints nest their real payload one level deeper than the callable + * envelope's `result.data`, so the schema in `responseSchemas` describes that + * inner value, not `data` itself. Map those methods to the sub-key the drift + * checker must descend into before diffing; methods absent from this map are + * diffed at `result.data` directly. Keeping this here (next to the schemas it + * pairs with) means the http wiring stays a dumb, uniform caller. + */ +const PAYLOAD_UNWRAP: Partial<Record<ApiMethod, string>> = { + getEventInfo: 'event', // result.data.event + getMyUpcomingEventsForHomePage: 'upcomingEvents', // result.data.upcomingEvents[] + getMyPastEventsForHomePage: 'pastEvents', // result.data.pastEvents[] +}; + +/** + * Given the already-unwrapped `result.data` for a method, descend into the + * method-specific sub-key when one is declared (returning that inner value), + * otherwise return `data` unchanged. Never throws — a missing/oddly-shaped + * key falls back to the original value so drift detection stays advisory. + */ +export function unwrapPayload(method: ApiMethod, data: unknown): unknown { + const key = PAYLOAD_UNWRAP[method]; + if (!key) return data; + if (data && typeof data === 'object' && !Array.isArray(data) && key in (data as Record<string, unknown>)) { + return (data as Record<string, unknown>)[key]; + } + return data; +} + /** * Return the top-level keys of an observed response record. Array responses * are reduced to the union of keys across their elements (objects only). diff --git a/src/lib/http.ts b/src/lib/http.ts index 855cbb6..e2c7296 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -5,7 +5,7 @@ import { AuthError, NotFoundError, ApiError } from './errors.js'; import { apiEndpoints, type ApiMethod } from './api/endpoints.js'; -import { reportDrift } from './drift.js'; +import { reportDrift, unwrapPayload } from './drift.js'; const API_BASE = 'https://api.partiful.com'; const FIRESTORE_BASE = 'https://firestore.googleapis.com'; @@ -28,7 +28,11 @@ function checkDrift(endpoint: string, parsed: unknown): void { const method = PATH_TO_METHOD[endpoint]; if (!method) return; const env = parsed as { result?: { data?: unknown } } | undefined; - const payload = env?.result?.data ?? parsed; + const data = env?.result?.data ?? parsed; + // A few methods nest their real payload one level deeper than result.data + // (e.g. getEventInfo → .event, homepage → .upcomingEvents/.pastEvents); + // descend to the value the response schema actually describes. + const payload = unwrapPayload(method, data); reportDrift(method, payload); } catch { /* drift detection is advisory only */ diff --git a/tests/drift.test.js b/tests/drift.test.js index c2d1b90..280cfbb 100644 --- a/tests/drift.test.js +++ b/tests/drift.test.js @@ -6,7 +6,7 @@ * (getContacts, homepage events) are diffed against their element schema. */ import { describe, it, expect, afterEach } from 'vitest'; -import { detectDrift, reportDrift } from '../src/lib/drift.ts'; +import { detectDrift, reportDrift, unwrapPayload } from '../src/lib/drift.ts'; describe('drift detection', () => { it('flags unknown top-level fields against an object schema', () => { @@ -60,4 +60,32 @@ describe('drift detection', () => { expect(() => reportDrift('createEvent', 'not-an-object')).not.toThrow(); expect(reportDrift('createEvent', null)).toBeNull(); }); + + it('unwrapPayload descends into method-specific nested payloads', () => { + // getEventInfo real shape is result.data.event — diffing at .data would + // otherwise flag { event } as an unknown field on every single call. + const event = { id: 'e1', title: 'Party' }; + expect(unwrapPayload('getEventInfo', { event })).toBe(event); + // homepage lists nest under upcomingEvents/pastEvents arrays. + const up = [{ id: 'e1' }]; + expect(unwrapPayload('getMyUpcomingEventsForHomePage', { upcomingEvents: up })).toBe(up); + const past = [{ id: 'e2' }]; + expect(unwrapPayload('getMyPastEventsForHomePage', { pastEvents: past })).toBe(past); + // methods with no nesting pass data through untouched. + const data = { id: 'e' }; + expect(unwrapPayload('createEvent', data)).toBe(data); + // missing sub-key falls back to the original (never throws). + expect(unwrapPayload('getEventInfo', { notEvent: 1 })).toEqual({ notEvent: 1 }); + }); + + it('nested-payload methods do not false-positive after unwrap', () => { + // A conforming getEventInfo event unwrapped to its inner shape reports no drift. + const eventData = { event: { id: 'e1', title: 'Party', status: 'published', startDate: '2026-08-01' } }; + const drift = detectDrift('getEventInfo', unwrapPayload('getEventInfo', eventData)); + // GetEventInfoResponseSchema is passthrough-only (no declared fields) → []. + expect(drift).toEqual([]); + // But the raw un-unwrapped data would look like a single unknown key 'event'. + const naive = detectDrift('createEvent', eventData); // createEvent declares id/title/... + expect(naive).toContain('event'); + }); }); From 13940280d8b9c30725484c93fee0795477abba82 Mon Sep 17 00:00:00 2001 From: Kaleb Cole <kalebcole2021@gmail.com> Date: Fri, 24 Jul 2026 16:01:54 -0700 Subject: [PATCH 10/14] chore: retrigger bot review on drift-unwrap fix (404c024) Empty commit to force CodeRabbit + Copilot to re-review the fix commit; their prior reviews predated 404c024. No code change. Squash-merge drops this. From 8eef646a40f113080f7f934c6973c0bbe841b266 Mon Sep 17 00:00:00 2001 From: Kaleb Cole <kalebcole2021@gmail.com> Date: Fri, 24 Jul 2026 16:20:28 -0700 Subject: [PATCH 11/14] fix(deps): move tsx to runtime dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI has no build step — bin/partiful registers the tsx ESM loader at runtime (import 'tsx/esm/api') to run the .ts source graph directly. tsx is therefore load-bearing at runtime, not just for tests. As a devDependency an 'npm install --omit=dev' / global prod install shipped a CLI that could not start. Verified: prod-only install now resolves tsx/esm/api and ./bin/partiful --version prints 2.1.0. Flagged by CodeRabbit on #66. --- package-lock.json | 31 +------------------------------ package.json | 2 +- 2 files changed, 2 insertions(+), 31 deletions(-) diff --git a/package-lock.json b/package-lock.json index e61bc47..11966f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "commander": "^13.0.0", "dotenv": "^16.4.0", + "tsx": "^4.23.1", "zod": "^4.4.3" }, "bin": { @@ -18,7 +19,6 @@ }, "devDependencies": { "@types/node": "^26.1.1", - "tsx": "^4.23.1", "typescript": "^7.0.2", "vitest": "^3.0.0" }, @@ -33,7 +33,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -50,7 +49,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -67,7 +65,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -84,7 +81,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -101,7 +97,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -118,7 +113,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -135,7 +129,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -152,7 +145,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -169,7 +161,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -186,7 +177,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -203,7 +193,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -220,7 +209,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -237,7 +225,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -254,7 +241,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -271,7 +257,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -288,7 +273,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -305,7 +289,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -322,7 +305,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -339,7 +321,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -356,7 +337,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -373,7 +353,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -390,7 +369,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -407,7 +385,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -424,7 +401,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -441,7 +417,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -458,7 +433,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1461,7 +1435,6 @@ "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -1541,7 +1514,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1822,7 +1794,6 @@ "version": "4.23.1", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "~0.28.0" diff --git a/package.json b/package.json index 101e65d..f42e687 100644 --- a/package.json +++ b/package.json @@ -38,11 +38,11 @@ "dependencies": { "commander": "^13.0.0", "dotenv": "^16.4.0", + "tsx": "^4.23.1", "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^26.1.1", - "tsx": "^4.23.1", "typescript": "^7.0.2", "vitest": "^3.0.0" }, From 97c8caf9c05e8499a05b85ef1624e1b1a47e71d3 Mon Sep 17 00:00:00 2001 From: Kaleb Cole <kalebcole2021@gmail.com> Date: Fri, 24 Jul 2026 16:40:04 -0700 Subject: [PATCH 12/14] docs(wayfinder): close review + bot-poll loop in map (port done) --- .wayfinder/ts-port/map.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.wayfinder/ts-port/map.md b/.wayfinder/ts-port/map.md index 076bb9b..54ecaf4 100644 --- a/.wayfinder/ts-port/map.md +++ b/.wayfinder/ts-port/map.md @@ -39,6 +39,7 @@ Success = the API spec is a *byproduct* of typing the code, not a separate artif - T4 CLOSED (2026-07-24, cd6581a): src/ is 100% TypeScript. All 18 remaining .js (12 commands, 4 helpers, cli.ts, schema.ts) ported to strict; commander handlers typed, API responses narrowed via api/ spec + as-casts, `.js` import specifiers preserved (NodeNext). tsc clean + 195/195 green; ./bin/partiful --version + schema smoke-tested via tsx loader. - T5 CLOSED (2026-07-24, 06eeb68): `schema api.<method>` namespace driven off the apiEndpoints registry; `schema api` lists methods; bare `schema` lists commands + api.*; existing `schema <command>` unchanged. +5 tests. Output mirrors existing JSON-envelope format. - T6 CLOSED (2026-07-24, e45031e): drift detection (src/lib/drift.ts) diffs passthrough responses vs spec field surface, wired guarded into apiRequest, opt-in PARTIFUL_DRIFT_LOG (silent default / stderr / NDJSON file). Gated real-API smoke suite (skipIf !PARTIFUL_SMOKE, read-only). +6 drift unit tests. Strategy documented in guide §10. 206 passed / 6 smoke skipped. +- REVIEW CLOSED (2026-07-24): adversarial pass = SHIP (0 blocker/major). Bot-poll loop (CodeRabbit + Copilot; Codex over-quota) terminated on stop-condition (a): 0 open threads, checks pass, mergeable. Two in-scope bot findings fixed — drift per-method payload unwrap (404c024, getEventInfo/homepage nest deeper than result.data; +2 tests) and tsx moved devDep→runtime dep (8eef646, no build step means the CLI can't start without it; verified via prod-only install). 5 pre-existing runtime-validation findings (watch/auth/http NaN + fetch timeout) deferred to issue #67 (annotation-only port must not change behavior). Merged origin/main (wayfinder-doc add/add conflicts only, resolved ours; f41237e). PR #66 green + mergeable. Final: 208 passed / 6 smoke skipped (baseline was 195). ## Not yet specified From 37de5ee4bac5109c16a9a43dfbcf8ad4863b7276 Mon Sep 17 00:00:00 2001 From: Kaleb Cole <kalebcole2021@gmail.com> Date: Fri, 24 Jul 2026 18:25:55 -0700 Subject: [PATCH 13/14] fix(share): preserve global verbose + empty-title parity Copilot's fresh review surfaced a suppressed but valid port regression: +share stopped forwarding the global --verbose flag to apiRequest. Restore the baseline cmd.optsWithGlobals() behavior. Also restore JS's || fallback for an empty event title (the TS port had changed it to ??). Add 2 parity tests. 210 passed / 6 smoke skipped; tsc clean. --- src/helpers/share.ts | 13 +++++++-- tests/share-helper.test.js | 57 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 tests/share-helper.test.js diff --git a/src/helpers/share.ts b/src/helpers/share.ts index a9eee90..0d01918 100644 --- a/src/helpers/share.ts +++ b/src/helpers/share.ts @@ -13,7 +13,8 @@ export function registerShareHelper(program: Command): void { .command('+share') .description('Generate shareable event link') .argument('<eventId>', 'Event ID') - .action(async (eventId: string, _opts: Record<string, unknown>, _cmd: Command) => { + .action(async (eventId: string, _opts: Record<string, unknown>, cmd: Command) => { + const globalOpts = cmd.optsWithGlobals<Record<string, unknown>>(); try { const config = loadConfig(); const token = await getValidToken(config); @@ -27,12 +28,18 @@ export function registerShareHelper(program: Command): void { }), }; - const result = await apiRequest('POST', '/getEventInfo', token, payload, false) as Record<string, unknown>; + const result = await apiRequest( + 'POST', + '/getEventInfo', + token, + payload, + globalOpts['verbose'] as boolean | undefined, + ) as Record<string, unknown>; const data = (result as Record<string, unknown>)['result'] as Record<string, unknown> | undefined; const eventData = data?.['data'] as Record<string, unknown> | undefined; const event = eventData?.['event'] as Record<string, unknown> | undefined; - const title = (event?.['title'] as string | undefined) ?? 'Unknown Event'; + const title = (event?.['title'] as string | undefined) || 'Unknown Event'; const url = `https://partiful.com/e/${eventId}`; jsonOutput({ url, eventId, title }); diff --git a/tests/share-helper.test.js b/tests/share-helper.test.js new file mode 100644 index 0000000..9d03e65 --- /dev/null +++ b/tests/share-helper.test.js @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Command } from 'commander'; + +const apiRequest = vi.fn(); +const jsonOutput = vi.fn(); +const jsonError = vi.fn((message) => { throw new Error(message); }); + +vi.mock('../src/lib/auth.js', () => ({ + loadConfig: () => ({ userId: 'u1' }), + getValidToken: async () => 'token', + wrapPayload: (_config, payload) => payload, +})); +vi.mock('../src/lib/http.js', () => ({ apiRequest })); +vi.mock('../src/lib/output.js', () => ({ jsonOutput, jsonError })); + +const { registerShareHelper } = await import('../src/helpers/share.js'); + +function program() { + const cmd = new Command(); + cmd.exitOverride(); + cmd.option('--verbose'); + registerShareHelper(cmd); + return cmd; +} + +describe('+share helper port parity', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('forwards the global --verbose flag to apiRequest', async () => { + apiRequest.mockResolvedValue({ result: { data: { event: { title: 'Party' } } } }); + + await program().parseAsync(['node', 'partiful', '--verbose', '+share', 'e1']); + + expect(apiRequest).toHaveBeenCalledWith( + 'POST', + '/getEventInfo', + 'token', + expect.any(Object), + true, + ); + expect(jsonOutput).toHaveBeenCalledWith({ + url: 'https://partiful.com/e/e1', + eventId: 'e1', + title: 'Party', + }); + }); + + it('preserves the JS fallback for an empty event title', async () => { + apiRequest.mockResolvedValue({ result: { data: { event: { title: '' } } } }); + + await program().parseAsync(['node', 'partiful', '+share', 'e1']); + + expect(jsonOutput).toHaveBeenCalledWith(expect.objectContaining({ title: 'Unknown Event' })); + }); +}); From fc78c4ea1260b9170039daf2cb1277cd2d40694f Mon Sep 17 00:00:00 2001 From: Kaleb Cole <kalebcole2021@gmail.com> Date: Fri, 24 Jul 2026 18:30:27 -0700 Subject: [PATCH 14/14] docs(test): correct drift-schema explanation Copilot noted the test comment was stale: GetEventInfoResponseSchema now declares event fields. The assertion was already correct; update the rationale. 210 passed / 6 smoke skipped; tsc clean. --- tests/drift.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/drift.test.js b/tests/drift.test.js index 280cfbb..29c3f5d 100644 --- a/tests/drift.test.js +++ b/tests/drift.test.js @@ -82,7 +82,7 @@ describe('drift detection', () => { // A conforming getEventInfo event unwrapped to its inner shape reports no drift. const eventData = { event: { id: 'e1', title: 'Party', status: 'published', startDate: '2026-08-01' } }; const drift = detectDrift('getEventInfo', unwrapPayload('getEventInfo', eventData)); - // GetEventInfoResponseSchema is passthrough-only (no declared fields) → []. + // The inner event matches GetEventInfoResponseSchema's declared fields → no drift. expect(drift).toEqual([]); // But the raw un-unwrapped data would look like a single unknown key 'event'. const naive = detectDrift('createEvent', eventData); // createEvent declares id/title/...