@simplepush/cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,6 +22,7 @@ sp cancel Withdraw a pending task, subtask, or task group you sent.
22
22
  sp notify Send a fire-and-forget notification.
23
23
  sp collect Collect replies, inputs, or submissions as bounded NDJSON.
24
24
  sp download Download one file a collect line referenced, by its ids.
25
+ sp get Read what came back: task listings, one task with its subtasks, a task group, submissions.
25
26
  sp events Stream or replay the raw event feed.
26
27
  sp auth Log in to an organization (login / logout / status).
27
28
  sp org Administer an organization: members, invites, topics, API key, encryption.
@@ -192,3 +193,18 @@ Bun can compile the CLI into a single-file native binary:
192
193
  bun run build:binary
193
194
  ./bin/simplepush --help
194
195
  ```
196
+
197
+ ### Query what came back
198
+
199
+ The read commands mirror the MCP query tools, so an agent can ask questions like "every task that expired unanswered this week, grouped by assignee":
200
+
201
+ ```bash
202
+ sp get tasks --status expired --since 7d --format json # one JSON object per line
203
+ sp get tasks --status pending --member anna --all # follow cursors to the end
204
+ sp get tsk_… # the chain: root + subtasks, decrypted where keys are held
205
+ sp get grptsk_… # per-recipient status of a group send
206
+ sp get submissions --since 24h --member anna
207
+ sp events --since 7d --member anna # events replay, narrowed client-side
208
+ ```
209
+
210
+ A page that was cut off ends with `{"type":"more","cursor":"…"}` (`json`) or an info line (`pretty`); pass the cursor back with `--cursor`, or `--all` to page automatically. `--since`/`--until` take ISO-8601 instants or relative windows (`7d`, `12h`). For personal accounts the HTTP query surface (`sp get`) is a subscription feature — `sp events`, `sp collect` and `sp download` stay free.
package/dist/main.mjs CHANGED
@@ -9,7 +9,7 @@ import { wordlist } from "@scure/bip39/wordlists/english.js";
9
9
  import { homedir, tmpdir } from "node:os";
10
10
  import { createServer } from "node:http";
11
11
  import { spawn } from "node:child_process";
12
- import { Client, Keyring, OrgClient, TypeFilter, cancelSubtask, cancelTask, cancelTaskGroup, decryptBytes, deriveKey, encrypt, fetchUserInfo, isSubtaskGroupResponse, tryDecryptEventData } from "@simplepush/sdk";
12
+ import { Client, Keyring, OrgClient, TypeFilter, cancelSubtask, cancelTask, cancelTaskGroup, decryptBytes, decryptSubmission, decryptTaskPayload, decryptTaskSummary, deriveKey, encrypt, fetchUserInfo, isSubtaskGroupResponse, tryDecryptEventData } from "@simplepush/sdk";
13
13
  import { mkdir, stat, writeFile } from "node:fs/promises";
14
14
  import { connect, createConnection } from "node:net";
15
15
  import path, { basename, extname, join } from "node:path";
@@ -546,7 +546,7 @@ var VaultAccess = class extends Effect.Service()("cli/VaultAccess", {
546
546
  //#region src/global-options.ts
547
547
  const DEFAULT_BASE_URL = "https://api.simplepu.sh";
548
548
  const toHelp = (e) => HelpDoc.p(e instanceof Error ? e.message : String(e));
549
- const topicOption = Options.text("topic").pipe(Options.withAlias("t"), Options.withDescription("Topic to send to (`task`) or filter on (`events`, repeatable). Omit on `task` for a self-send to your own devices."), Options.repeated);
549
+ const topicOption$1 = Options.text("topic").pipe(Options.withAlias("t"), Options.withDescription("Topic to send to (`task`) or filter on (`events`, repeatable). Omit on `task` for a self-send to your own devices."), Options.repeated);
550
550
  const apiTokenOption = Options.text("api-token").pipe(Options.withDescription("API token. Required for `get`; `collect`, `events`, and `subtask` fall back to the logged-in org session when omitted. Defaults to $SP_API_TOKEN."), Options.withFallbackConfig(Config.string("SP_API_TOKEN")), Options.optional);
551
551
  /** Personal-credential commands need the token; fail typed when absent. */
552
552
  const requireApiToken = (token) => Option.match(token, {
@@ -1164,7 +1164,7 @@ function formatSent(groupId, createdAt, members) {
1164
1164
  type: "sent",
1165
1165
  groupId: groupId ?? null,
1166
1166
  createdAt: createdAt ?? null,
1167
- members: members.map((m) => ({
1167
+ instances: members.map((m) => ({
1168
1168
  ...m.kind === "notification" ? { notificationId: m.id } : { taskId: m.id },
1169
1169
  ...m.subtaskId !== void 0 ? { subtaskId: m.subtaskId } : {},
1170
1170
  recipient: m.recipient
@@ -1303,14 +1303,14 @@ function fileViewsOf(item) {
1303
1303
  ]).filter(isFileView);
1304
1304
  }
1305
1305
  /** The terminal line: reason the stream stopped, per-type counts, and (for
1306
- * group modes) per-member status. Always the last line on a clean run. */
1306
+ * group modes) per-instance status. Always the last line on a clean run. */
1307
1307
  function formatEnd(reason, counts, members, errorMsg) {
1308
1308
  const obj = {
1309
1309
  type: "end",
1310
1310
  reason,
1311
1311
  counts
1312
1312
  };
1313
- if (members) obj.members = members;
1313
+ if (members) obj.instances = members;
1314
1314
  if (errorMsg !== void 0) obj.error = errorMsg;
1315
1315
  return line(obj);
1316
1316
  }
@@ -1463,7 +1463,7 @@ const SentLine = Schema.Struct({
1463
1463
  type: Schema.Literal("sent"),
1464
1464
  groupId: Schema.optional(Schema.NullOr(Schema.String)),
1465
1465
  createdAt: Schema.optional(Schema.NullOr(Schema.String)),
1466
- members: Schema.optional(Schema.Array(Schema.Struct({
1466
+ instances: Schema.optional(Schema.Array(Schema.Struct({
1467
1467
  taskId: Schema.optional(Schema.String),
1468
1468
  notificationId: Schema.optional(Schema.String),
1469
1469
  subtaskId: Schema.optional(Schema.String),
@@ -1525,9 +1525,9 @@ const loadOrgMasterKeys = Effect.gen(function* () {
1525
1525
  });
1526
1526
  const collectCommand = Command.make("collect", {
1527
1527
  group: Options.text("group").pipe(Options.withDescription("Group id (grptsk_…) to collect over. Usually supplied via the piped `sent` line instead."), Options.optional),
1528
- instance: Options.text("instance").pipe(Options.withDescription("Member instance id to collect: a task (tsk_…), a notification (ntf_…), or a subtask-scoped pair (tsk_…/sub_…). Repeatable. Augments/overrides the piped `sent` line's members."), Options.repeated),
1528
+ instance: Options.text("instance").pipe(Options.withDescription("Instance id to collect: a task (tsk_…), a notification (ntf_…), or a subtask-scoped pair (tsk_…/sub_…). Repeatable. Augments/overrides the piped `sent` line's instances."), Options.repeated),
1529
1529
  replies: Options.boolean("replies").pipe(Options.withDescription("Collect only replies. Default (no mode flag) is the full activity stream: inputs, replies, and completions.")),
1530
- inputs: Options.boolean("inputs").pipe(Options.withDescription("Collect only input events (waits for every member to complete by default).")),
1530
+ inputs: Options.boolean("inputs").pipe(Options.withDescription("Collect only input events (waits for every instance to complete by default).")),
1531
1531
  submissions: Options.boolean("submissions").pipe(Options.withDescription("Collect submissions (your inbox) instead of a group's events.")),
1532
1532
  since: mappedText("since", resolveSince).pipe(Options.withDescription("Resume point (`24h`, `7d`, or ISO 8601). Backfills group events or submissions from that point; defaults to the send's createdAt from the piped `sent` line. Implies --direct (the broker can't serve a deep backfill)."), Options.optional),
1533
1533
  until: Options.text("until").pipe(Options.withDescription("Stop condition. Repeatable: complete | idle:<dur> | count:<n> | timeout:<dur> | forever (never stop; Ctrl-C to end). Default: complete for group collects; --replies / --submissions watch forever."), Options.repeated),
@@ -1600,7 +1600,7 @@ const collectCommand = Command.make("collect", {
1600
1600
  });
1601
1601
  }
1602
1602
  };
1603
- for (const m of sent?.members ?? []) addMember(m.taskId ?? m.notificationId, m.recipient ? {
1603
+ for (const m of sent?.instances ?? []) addMember(m.taskId ?? m.notificationId, m.recipient ? {
1604
1604
  publicId: m.recipient.publicId,
1605
1605
  name: m.recipient.name ?? null
1606
1606
  } : null, m.subtaskId);
@@ -1609,11 +1609,11 @@ const collectCommand = Command.make("collect", {
1609
1609
  if (id.startsWith("sub_")) return yield* Effect.fail(new UserError({ message: `a subtask cannot be collected by its id alone (events route under the parent task) — pass --instance <parent tsk_…>/${id}` }));
1610
1610
  addMember(id, null, subtaskId);
1611
1611
  }
1612
- if (members.length === 0) return yield* Effect.fail(new UserError({ message: "no members to collect: pipe a send's `sent` line (`sp task --format json | sp collect`) or pass --instance <tsk_…|ntf_…|tsk_…/sub_…> (repeatable)" }));
1612
+ if (members.length === 0) return yield* Effect.fail(new UserError({ message: "no instances to collect: pipe a send's `sent` line (`sp task --format json | sp collect`) or pass --instance <tsk_…|ntf_…|tsk_…/sub_…> (repeatable)" }));
1613
1613
  const notifRoster = members[0].kind === "notification";
1614
- if (members.some((m) => m.kind === "notification" !== notifRoster)) return yield* Effect.fail(new UserError({ message: "cannot mix task (tsk_…) and notification (ntf_…) members in one collect — run one per kind" }));
1614
+ if (members.some((m) => m.kind === "notification" !== notifRoster)) return yield* Effect.fail(new UserError({ message: "cannot mix task (tsk_…) and notification (ntf_…) instances in one collect — run one per kind" }));
1615
1615
  const subRoster = members[0].subtaskId !== void 0;
1616
- if (members.some((m) => m.subtaskId !== void 0 !== subRoster)) return yield* Effect.fail(new UserError({ message: "cannot mix subtask-scoped members (a piped `sp subtask --format json` line or --instance tsk_…/sub_…) with plain task/notification members in one collect" }));
1616
+ if (members.some((m) => m.subtaskId !== void 0 !== subRoster)) return yield* Effect.fail(new UserError({ message: "cannot mix subtask-scoped instances (a piped `sp subtask --format json` line or --instance tsk_…/sub_…) with plain task/notification instances in one collect" }));
1617
1617
  const streamOpts = {
1618
1618
  replay: true,
1619
1619
  ...until.idleMs !== void 0 ? { idleMs: until.idleMs } : {}
@@ -1675,7 +1675,7 @@ const collectCommand = Command.make("collect", {
1675
1675
  });
1676
1676
  }
1677
1677
  if (args.format === "json") yield* out.print(formatSent(groupId, createdAt, members));
1678
- else yield* out.info(`collecting ${mode} over ${members.length} member(s)${groupId ? ` of ${groupId}` : ""}`);
1678
+ else yield* out.info(`collecting ${mode} over ${members.length} instance(s)${groupId ? ` of ${groupId}` : ""}`);
1679
1679
  yield* collectGroup(watchGroupId, source, members, args.format, until, saveDir);
1680
1680
  }));
1681
1681
  }));
@@ -2099,11 +2099,12 @@ function extractRaw(v) {
2099
2099
  //#endregion
2100
2100
  //#region src/commands/events.ts
2101
2101
  const eventTypeOption = Options.text("type").pipe(Options.withDescription("Filter by event type. Repeatable."), Options.repeated);
2102
- const sinceOption = mappedText("since", resolveSince).pipe(Options.withDescription("Replay from this point. Accepts `24h`, `7d`, or an ISO 8601 timestamp."), Options.optional);
2103
- const untilOption = mappedText("until", resolveUntil).pipe(Options.withDescription("Stop at this timestamp. Forces a finite range, so `--follow` is ignored."), Options.optional);
2104
- const limitOption = Options.integer("limit").pipe(Options.withDescription("Maximum number of events to print, then exit."), Options.optional);
2102
+ const sinceOption$1 = mappedText("since", resolveSince).pipe(Options.withDescription("Replay from this point. Accepts `24h`, `7d`, or an ISO 8601 timestamp."), Options.optional);
2103
+ const untilOption$1 = mappedText("until", resolveUntil).pipe(Options.withDescription("Stop at this timestamp. Forces a finite range, so `--follow` is ignored."), Options.optional);
2104
+ const limitOption$1 = Options.integer("limit").pipe(Options.withDescription("Maximum number of events to print, then exit."), Options.optional);
2105
2105
  const followOption = Options.boolean("follow").pipe(Options.withAlias("f"), Options.withDescription("After history is drained, keep streaming live instead of exiting. Only meaningful with `--since`."));
2106
- const formatOption$3 = Options.choice("format", [
2106
+ const memberOption$3 = Options.text("member").pipe(Options.optional, Options.withDescription("Only events by this person: a usr_ id, or the actor's name."));
2107
+ const formatOption$4 = Options.choice("format", [
2107
2108
  "json",
2108
2109
  "pretty",
2109
2110
  "raw"
@@ -2112,13 +2113,14 @@ const directOption = Options.boolean("direct").pipe(Options.withDescription("Ope
2112
2113
  const QUIET_EXIT_MS = 2e3;
2113
2114
  const eventsCommand = Command.make("events", {
2114
2115
  type: eventTypeOption,
2115
- since: sinceOption,
2116
- until: untilOption,
2117
- limit: limitOption,
2116
+ member: memberOption$3,
2117
+ since: sinceOption$1,
2118
+ until: untilOption$1,
2119
+ limit: limitOption$1,
2118
2120
  follow: followOption,
2119
- format: formatOption$3,
2121
+ format: formatOption$4,
2120
2122
  direct: directOption,
2121
- topic: topicOption,
2123
+ topic: topicOption$1,
2122
2124
  "api-token": apiTokenOption,
2123
2125
  password: passwordOption,
2124
2126
  "base-url": baseUrlOption,
@@ -2132,6 +2134,8 @@ const eventsCommand = Command.make("events", {
2132
2134
  const untilDate = Option.getOrUndefined(args.until);
2133
2135
  const limit = Option.getOrUndefined(args.limit);
2134
2136
  const filter = new TypeFilter(args.type);
2137
+ const member = Option.getOrUndefined(args.member);
2138
+ const memberMatches = (ev) => member === void 0 || ev.actor?.publicId === member || ev.actor?.name === member;
2135
2139
  yield* Effect.forEach(filter.unknown, (u) => out.warn(`ignoring unknown --type \`${u}\``));
2136
2140
  const webSocketFactory = args.direct || sinceIso !== void 0 ? Option.none() : yield* sharedWebSocketFactory({
2137
2141
  credential: cred.kind === "personal" ? {
@@ -2194,7 +2198,7 @@ const eventsCommand = Command.make("events", {
2194
2198
  yield* sdkStream("events stream", (signal) => client.events({
2195
2199
  ...sinceIso !== void 0 ? { since: sinceIso } : {},
2196
2200
  signal: AbortSignal.any([signal, endController.signal])
2197
- })).pipe(Stream.tap(() => Ref.set(lastActivity, Date.now())), Stream.interruptWhen(Deferred.await(halt)), untilDate !== void 0 ? Stream.takeUntilEffect((ev) => untilReached(ev) ? note("--until reached, exiting").pipe(Effect.zipRight(Deferred.succeed(halt, void 0)), Effect.as(true)) : Effect.succeed(false)) : (s) => s, Stream.filter((ev) => !untilReached(ev) && filter.matches(ev)), Stream.mapEffect((ev) => Effect.gen(function* () {
2201
+ })).pipe(Stream.tap(() => Ref.set(lastActivity, Date.now())), Stream.interruptWhen(Deferred.await(halt)), untilDate !== void 0 ? Stream.takeUntilEffect((ev) => untilReached(ev) ? note("--until reached, exiting").pipe(Effect.zipRight(Deferred.succeed(halt, void 0)), Effect.as(true)) : Effect.succeed(false)) : (s) => s, Stream.filter((ev) => !untilReached(ev) && filter.matches(ev) && memberMatches(ev)), Stream.mapEffect((ev) => Effect.gen(function* () {
2198
2202
  const decrypted = keyring ? yield* sdkCall("decrypt event", () => tryDecryptEventData(ev, keyring)) : void 0;
2199
2203
  yield* out.print(formatEvent(ev, args.format, decrypted));
2200
2204
  const n = yield* Ref.updateAndGet(printed, (x) => x + 1);
@@ -2212,6 +2216,313 @@ const eventsCommand = Command.make("events", {
2212
2216
  }));
2213
2217
  }));
2214
2218
  //#endregion
2219
+ //#region src/commands/query-support.ts
2220
+ /** Options every read command shares: credential, decryption keys, paging, output. */
2221
+ const credentialOptions = {
2222
+ "api-token": apiTokenOption,
2223
+ password: passwordOption,
2224
+ "base-url": baseUrlOption,
2225
+ quiet: quietOption
2226
+ };
2227
+ const formatOption$3 = Options.choice("format", ["json", "pretty"]).pipe(Options.withDefault("json"), Options.withDescription("json = one JSON object per line (agents, jq); pretty = human-readable lines."));
2228
+ const sinceOption = mappedText("since", resolveSince).pipe(Options.optional, Options.withDescription("Only items created at or after this instant: ISO-8601, or a relative window like 7d, 12h, 30m."));
2229
+ const untilOption = mappedText("until", (raw) => resolveUntil(raw).toISOString()).pipe(Options.optional, Options.withDescription("Only items created at or before this instant (same forms as --since)."));
2230
+ const memberOption$2 = Options.text("member").pipe(Options.optional, Options.withDescription("Only items involving this person: a usr_ id, or a member name on an org."));
2231
+ const limitOption = Options.integer("limit").pipe(Options.optional, Options.withDescription("Page size (server default 50). With --all this is the page size, not a cap."));
2232
+ const cursorOption = Options.text("cursor").pipe(Options.optional, Options.withDescription("Continue a previous page: pass the cursor it reported."));
2233
+ const allOption = Options.boolean("all").pipe(Options.withDescription("Follow cursors until the last page instead of printing one page and a cursor."));
2234
+ const STATUSES = [
2235
+ "pending",
2236
+ "completed",
2237
+ "canceled",
2238
+ "declined",
2239
+ "expired"
2240
+ ];
2241
+ /** `--status` accepts repeats and comma lists: `--status expired --status declined` or `--status expired,declined`. */
2242
+ const statusOption = Options.text("status").pipe(Options.repeated, Options.mapTryCatch((values) => {
2243
+ const flat = values.flatMap((v) => v.split(",")).map((s) => s.trim()).filter((s) => s.length > 0);
2244
+ const bad = flat.filter((s) => !STATUSES.includes(s));
2245
+ if (bad.length > 0) throw new Error(`unknown status ${bad.join(", ")} — use ${STATUSES.join(", ")}`);
2246
+ return flat;
2247
+ }, (e) => HelpDoc.p(e instanceof Error ? e.message : String(e))), Options.withDescription("Only tasks in these states (repeatable or comma-separated)."));
2248
+ /** Resolves the credential (personal API-Token or the saved org session),
2249
+ * builds the matching client and — when keys are available — a keyring, and
2250
+ * runs `body` inside the client's scope. Mirrors what `sp events` does. */
2251
+ const withQueryClient = (args, body) => Effect.gen(function* () {
2252
+ const out = yield* CliOutput;
2253
+ yield* out.setQuiet(args.quiet);
2254
+ const cred = yield* resolveCredential(args["api-token"], args["base-url"]);
2255
+ const orgKeys = cred.kind === "org" ? yield* loadOrgMasterKeys : void 0;
2256
+ return yield* Effect.scoped(Effect.gen(function* () {
2257
+ const client = cred.kind === "personal" ? yield* acquireClient({
2258
+ baseUrl: cred.baseUrl,
2259
+ apiToken: cred.apiToken,
2260
+ passwords: args.password
2261
+ }) : yield* acquireOrgClient({
2262
+ baseUrl: cred.baseUrl,
2263
+ bearerToken: cred.bearer,
2264
+ ...orgKeys !== void 0 ? { orgMasterKeys: orgKeys } : {}
2265
+ });
2266
+ return yield* body({
2267
+ client,
2268
+ keyring: cred.kind === "org" ? orgKeys !== void 0 ? yield* sdkCall("keyring", () => client.keyring()) : void 0 : args.password.length > 0 ? yield* sdkCall("keyring", () => client.keyring({ includePasswordSalt: true })) : void 0,
2269
+ org: cred.kind === "org",
2270
+ out
2271
+ });
2272
+ }));
2273
+ });
2274
+ /** Decrypts what the keyring can and counts what it cannot, so a command can
2275
+ * report "N values left as ciphertext" once instead of per row. Each method
2276
+ * maps one wire shape onto its SDK field-schema decryptor. */
2277
+ var Decryptor = class {
2278
+ undecryptable = 0;
2279
+ constructor(keyring) {
2280
+ this.keyring = keyring;
2281
+ }
2282
+ run(value, go) {
2283
+ if (this.keyring === void 0) return Effect.succeed(value);
2284
+ const keyring = this.keyring;
2285
+ return Effect.promise(() => go(keyring)).pipe(Effect.map((r) => {
2286
+ this.undecryptable += r.undecryptable;
2287
+ return r.value;
2288
+ }));
2289
+ }
2290
+ /** A task or subtask payload (chain reads). */
2291
+ payload(value) {
2292
+ return this.run(value, (kr) => decryptTaskPayload(value, kr));
2293
+ }
2294
+ /** A task index / roster row. */
2295
+ summary(value) {
2296
+ return this.run(value, (kr) => decryptTaskSummary(value, kr));
2297
+ }
2298
+ /** A submission under its feed entry's marker. */
2299
+ submission(value, marker) {
2300
+ return this.run(value, (kr) => decryptSubmission(value, kr, marker));
2301
+ }
2302
+ report(out) {
2303
+ return this.undecryptable > 0 ? out.warn(`${this.undecryptable} value(s) are encrypted under a key this CLI does not hold and were left as ciphertext`) : Effect.void;
2304
+ }
2305
+ };
2306
+ /** Runs `page` for the first cursor, then keeps following cursors when `all`
2307
+ * is set; otherwise reports the cursor of the next page so the caller can
2308
+ * continue by hand. */
2309
+ const forEachPage = (ctx, format, all, first, page, handle) => Effect.gen(function* () {
2310
+ let cursor = first;
2311
+ for (;;) {
2312
+ const p = yield* page(cursor);
2313
+ yield* handle(p);
2314
+ if (p.nextCursor === void 0) return;
2315
+ if (!all) {
2316
+ yield* format === "json" ? ctx.out.print(JSON.stringify({
2317
+ type: "more",
2318
+ cursor: p.nextCursor
2319
+ })) : ctx.out.info(`more available — continue with --cursor ${p.nextCursor} (or pass --all)`);
2320
+ return;
2321
+ }
2322
+ cursor = p.nextCursor;
2323
+ }
2324
+ });
2325
+ function recipientLabels(t) {
2326
+ return t.recipients.map((r) => r.name ?? r.publicId);
2327
+ }
2328
+ /** One line per task: id, state, when, title, who, subtask counts, group. */
2329
+ function formatTaskSummary(t) {
2330
+ const subs = Object.entries(t.subtasks).map(([k, v]) => `${k} ${v}`).join(", ");
2331
+ return [
2332
+ `${t.taskId} [${t.status}] ${t.createdAt}`,
2333
+ t.title !== void 0 ? ` ${t.title}` : void 0,
2334
+ ` to: ${recipientLabels(t).join(", ") || "-"}`,
2335
+ t.inputs.length > 0 ? ` inputs: ${t.inputs.join(", ")}` : void 0,
2336
+ t.reply !== void 0 ? ` reply: ${t.reply}` : void 0,
2337
+ subs ? ` subtasks: ${subs}` : void 0,
2338
+ t.expiresAt !== void 0 ? ` expires: ${t.expiresAt}` : void 0,
2339
+ t.groupId !== void 0 ? ` group: ${t.groupId}` : void 0
2340
+ ].filter((l) => l !== void 0).join("\n");
2341
+ }
2342
+ //#endregion
2343
+ //#region src/commands/get.ts
2344
+ const topicOption = Options.text("topic").pipe(Options.optional, Options.withDescription("Only tasks sent to this topic: its value (name), or its id."));
2345
+ const groupOption = Options.text("group").pipe(Options.optional, Options.withDescription("Only the instances of this grptsk_ group."));
2346
+ /** Flags that only make sense on the paged listings. Id reads reject them by
2347
+ * name instead of silently ignoring them. */
2348
+ function rejectListFlags(args, target, allowStatus) {
2349
+ const set = [
2350
+ ...!allowStatus && args.status.length > 0 ? ["--status"] : [],
2351
+ ...Option.isSome(args.since) ? ["--since"] : [],
2352
+ ...Option.isSome(args.until) ? ["--until"] : [],
2353
+ ...Option.isSome(args.topic) ? ["--topic"] : [],
2354
+ ...Option.isSome(args.member) ? ["--member"] : [],
2355
+ ...Option.isSome(args.group) ? ["--group"] : [],
2356
+ ...Option.isSome(args.limit) ? ["--limit"] : [],
2357
+ ...Option.isSome(args.cursor) ? ["--cursor"] : [],
2358
+ ...args.all ? ["--all"] : []
2359
+ ];
2360
+ return set.length === 0 ? Effect.void : Effect.fail(new UserError({ message: `${set.join(", ")} does not apply when reading ${target}` }));
2361
+ }
2362
+ function getChain(args, ctx) {
2363
+ return Effect.gen(function* () {
2364
+ yield* rejectListFlags(args, "one task", false);
2365
+ const chain = yield* sdkCall("read task chain", () => ctx.client.getTaskChain(args.what));
2366
+ const dec = new Decryptor(ctx.keyring);
2367
+ const task = yield* dec.payload(chain.task);
2368
+ const subtasks = [];
2369
+ for (const s of chain.subtasks) subtasks.push({
2370
+ subtask: yield* dec.payload(s.subtask),
2371
+ createdAt: s.createdAt
2372
+ });
2373
+ const view = {
2374
+ task,
2375
+ createdAt: chain.createdAt,
2376
+ subtasks
2377
+ };
2378
+ if (args.format === "json") yield* ctx.out.print(JSON.stringify(view));
2379
+ else {
2380
+ yield* ctx.out.print(`${task.taskId} [${task.status}] ${chain.createdAt}`);
2381
+ yield* ctx.out.print(JSON.stringify(task, null, 2));
2382
+ for (const s of subtasks) {
2383
+ yield* ctx.out.print(`\n${s.subtask.subtaskId} [${s.subtask.status}] ${s.createdAt}`);
2384
+ yield* ctx.out.print(JSON.stringify(s.subtask, null, 2));
2385
+ }
2386
+ }
2387
+ yield* dec.report(ctx.out);
2388
+ });
2389
+ }
2390
+ function getRoster(args, ctx) {
2391
+ return Effect.gen(function* () {
2392
+ yield* rejectListFlags(args, "a task group roster", true);
2393
+ const roster = yield* sdkCall("read task group", () => ctx.client.getTaskGroup(args.what, args.status.length > 0 ? { status: args.status } : {}));
2394
+ const dec = new Decryptor(ctx.keyring);
2395
+ const counts = {};
2396
+ for (const t of roster.tasks) counts[t.status] = (counts[t.status] ?? 0) + 1;
2397
+ if (args.format === "json") {
2398
+ yield* ctx.out.print(JSON.stringify({
2399
+ type: "group",
2400
+ groupId: roster.groupId,
2401
+ counts
2402
+ }));
2403
+ for (const t of roster.tasks) yield* ctx.out.print(JSON.stringify(yield* dec.summary(t)));
2404
+ } else {
2405
+ const summary = Object.entries(counts).map(([k, v]) => `${k} ${v}`).join(", ") || "no instances";
2406
+ yield* ctx.out.print(`${roster.groupId} ${summary}`);
2407
+ for (const t of roster.tasks) yield* ctx.out.print(formatTaskSummary(yield* dec.summary(t)));
2408
+ }
2409
+ yield* dec.report(ctx.out);
2410
+ });
2411
+ }
2412
+ function getTasks(args, ctx) {
2413
+ return Effect.gen(function* () {
2414
+ const dec = new Decryptor(ctx.keyring);
2415
+ const opts = {
2416
+ ...args.status.length > 0 ? { status: args.status } : {},
2417
+ ...Option.match(args.since, {
2418
+ onNone: () => ({}),
2419
+ onSome: (since) => ({ since })
2420
+ }),
2421
+ ...Option.match(args.until, {
2422
+ onNone: () => ({}),
2423
+ onSome: (until) => ({ until })
2424
+ }),
2425
+ ...Option.match(args.topic, {
2426
+ onNone: () => ({}),
2427
+ onSome: (topic) => ({ topic })
2428
+ }),
2429
+ ...Option.match(args.member, {
2430
+ onNone: () => ({}),
2431
+ onSome: (member) => ({ member })
2432
+ }),
2433
+ ...Option.match(args.group, {
2434
+ onNone: () => ({}),
2435
+ onSome: (group) => ({ group })
2436
+ }),
2437
+ ...Option.match(args.limit, {
2438
+ onNone: () => ({}),
2439
+ onSome: (limit) => ({ limit })
2440
+ })
2441
+ };
2442
+ yield* forEachPage(ctx, args.format, args.all, Option.getOrUndefined(args.cursor), (cursor) => sdkCall("list tasks", () => ctx.client.listTasks({
2443
+ ...opts,
2444
+ ...cursor !== void 0 ? { cursor } : {}
2445
+ })), (page) => Effect.forEach(page.tasks, (t) => Effect.gen(function* () {
2446
+ const row = yield* dec.summary(t);
2447
+ yield* ctx.out.print(args.format === "json" ? JSON.stringify(row) : formatTaskSummary(row));
2448
+ }), { discard: true }));
2449
+ yield* dec.report(ctx.out);
2450
+ });
2451
+ }
2452
+ function getSubmissions(args, ctx) {
2453
+ return Effect.gen(function* () {
2454
+ const wrong = [
2455
+ ...args.status.length > 0 ? ["--status"] : [],
2456
+ ...Option.isSome(args.topic) ? ["--topic"] : [],
2457
+ ...Option.isSome(args.group) ? ["--group"] : []
2458
+ ];
2459
+ if (wrong.length > 0) return yield* Effect.fail(new UserError({ message: `${wrong.join(", ")} does not apply to submissions` }));
2460
+ const dec = new Decryptor(ctx.keyring);
2461
+ const opts = {
2462
+ ...Option.match(args.since, {
2463
+ onNone: () => ({}),
2464
+ onSome: (since) => ({ since })
2465
+ }),
2466
+ ...Option.match(args.until, {
2467
+ onNone: () => ({}),
2468
+ onSome: (until) => ({ until })
2469
+ }),
2470
+ ...Option.match(args.member, {
2471
+ onNone: () => ({}),
2472
+ onSome: (member) => ({ member })
2473
+ }),
2474
+ ...Option.match(args.limit, {
2475
+ onNone: () => ({}),
2476
+ onSome: (limit) => ({ limit })
2477
+ })
2478
+ };
2479
+ yield* forEachPage(ctx, args.format, args.all, Option.getOrUndefined(args.cursor), (cursor) => sdkCall("list submissions", () => ctx.client.listSubmissions({
2480
+ ...opts,
2481
+ ...cursor !== void 0 ? { cursor } : {}
2482
+ })), (page) => Effect.forEach(page.submissions, (entry) => Effect.gen(function* () {
2483
+ const submission = yield* dec.submission(entry.submission, entry.encryption);
2484
+ const by = entry.actor?.name ?? entry.actor?.publicId;
2485
+ if (args.format === "json") yield* ctx.out.print(JSON.stringify({
2486
+ ...submission,
2487
+ ...entry.actor !== void 0 ? { actor: entry.actor } : {}
2488
+ }));
2489
+ else {
2490
+ const body = submission.body;
2491
+ const parts = [
2492
+ body?.value !== void 0 ? body.value : void 0,
2493
+ submission.photo !== void 0 ? "[photo]" : void 0,
2494
+ submission.file !== void 0 ? "[file]" : void 0,
2495
+ submission.audio !== void 0 ? "[audio]" : void 0,
2496
+ submission.location !== void 0 ? "[location]" : void 0
2497
+ ].filter((p) => p !== void 0);
2498
+ yield* ctx.out.print(`${submission.id} ${submission.createdAt} ${by ?? "-"}: ${parts.join(" ") || "(empty)"}`);
2499
+ }
2500
+ }), { discard: true }));
2501
+ yield* dec.report(ctx.out);
2502
+ });
2503
+ }
2504
+ const getCommand = Command.make("get", {
2505
+ what: Args.text({ name: "what" }).pipe(Args.withDescription("What to read: tasks, submissions, a tsk_ id, or a grptsk_ id.")),
2506
+ status: statusOption,
2507
+ since: sinceOption,
2508
+ until: untilOption,
2509
+ topic: topicOption,
2510
+ member: memberOption$2,
2511
+ group: groupOption,
2512
+ limit: limitOption,
2513
+ cursor: cursorOption,
2514
+ all: allOption,
2515
+ format: formatOption$3,
2516
+ ...credentialOptions
2517
+ }, (args) => withQueryClient(args, (ctx) => {
2518
+ if (args.what === "tasks") return getTasks(args, ctx);
2519
+ if (args.what === "submissions") return getSubmissions(args, ctx);
2520
+ if (args.what.startsWith("tsk_")) return getChain(args, ctx);
2521
+ if (args.what.startsWith("grptsk_")) return getRoster(args, ctx);
2522
+ const hint = args.what.startsWith("sub_") ? "a subtask is read through its parent: sp get tsk_…" : args.what.startsWith("sbm_") ? "a single submission has no read endpoint; find it in the listing: sp get submissions --since … --format json" : "expected tasks, submissions, a tsk_ id, or a grptsk_ id";
2523
+ return Effect.fail(new UserError({ message: `cannot read '${args.what}': ${hint}` }));
2524
+ })).pipe(Command.withDescription("Read what came back: 'tasks' or 'submissions' for filtered listings, a tsk_ id for one task with its subtasks, a grptsk_ id for a group's per-recipient roster."));
2525
+ //#endregion
2215
2526
  //#region src/input-spec.ts
2216
2527
  const SETTING_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
2217
2528
  function looksLikeSetting(seg) {
@@ -2621,7 +2932,7 @@ const notifyCommand = Command.make("notify", {
2621
2932
  shared: sharedOption$1,
2622
2933
  format: formatOption$2,
2623
2934
  noEncrypt: noEncryptOption$2,
2624
- topic: topicOption,
2935
+ topic: topicOption$1,
2625
2936
  "api-token": apiTokenOption,
2626
2937
  password: passwordOption,
2627
2938
  "base-url": baseUrlOption,
@@ -2812,7 +3123,7 @@ const putIntegrationWraps = (id, wraps) => Effect.gen(function* () {
2812
3123
  yield* (yield* Api).put("integration wrap", `/v1/org/integrations/${encodeURIComponent(id)}/wraps`, { wraps });
2813
3124
  });
2814
3125
  const nameOption = Options.text("name").pipe(Options.withDescription("Human-readable label, shown in `sp integration list` and nowhere else."));
2815
- const scopesOption = Options.text("scopes").pipe(Options.withDefault("send,events:read"), Options.withDescription("Comma-separated scopes (send, events:read, files:read). org:admin is refused — administration is a human act."));
3126
+ const scopesOption = Options.text("scopes").pipe(Options.withDefault("send,read"), Options.withDescription("Comma-separated scopes (send, read, files:read). org:admin is refused — administration is a human act."));
2816
3127
  const createCommand = Command.make("create", {
2817
3128
  name: nameOption,
2818
3129
  scopes: scopesOption,
@@ -3535,7 +3846,7 @@ const taskCommand = Command.make("task", {
3535
3846
  shared: sharedOption,
3536
3847
  expires: expiresOption,
3537
3848
  format: formatOption$1,
3538
- topic: topicOption,
3849
+ topic: topicOption$1,
3539
3850
  "api-token": apiTokenOption,
3540
3851
  password: passwordOption,
3541
3852
  "base-url": baseUrlOption,
@@ -3841,7 +4152,7 @@ const subtaskCommand = Command.make("subtask", {
3841
4152
  markdown: markdownOption,
3842
4153
  "no-encrypt": noEncryptOption,
3843
4154
  instance: instanceOption,
3844
- topic: topicOption,
4155
+ topic: topicOption$1,
3845
4156
  "api-token": apiTokenOption,
3846
4157
  password: passwordOption,
3847
4158
  "base-url": baseUrlOption,
@@ -4022,7 +4333,8 @@ const root = Command.make("simplepush").pipe(Command.withSubcommands([
4022
4333
  notifyCommand,
4023
4334
  taskCommand,
4024
4335
  subtaskCommand,
4025
- cancelCommand
4336
+ cancelCommand,
4337
+ getCommand
4026
4338
  ]));
4027
4339
  const cli = Command.run(root, {
4028
4340
  name: "Simplepush CLI",