@simplepush/cli 0.2.1 → 0.4.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/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, DownloadError, OrgClient, TypeFilter, decryptSubmission, decryptTaskPayload, decryptTaskSummary, 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, {
@@ -1525,7 +1525,7 @@ 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("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),
1528
+ instance: Options.text("instance").pipe(Options.withDescription("Instance id to collect: a task (tsk_…), a notification (ntf_…), or a subtask (sub_…, or the tsk_…/sub_… pair). 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
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.")),
@@ -1606,14 +1606,16 @@ const collectCommand = Command.make("collect", {
1606
1606
  } : null, m.subtaskId);
1607
1607
  for (const spec of args.instance) {
1608
1608
  const [id, subtaskId] = spec.split("/", 2);
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
- addMember(id, null, subtaskId);
1609
+ if (id.startsWith("sub_")) {
1610
+ const parent = yield* sdkCall("read subtask", () => client.getSubtask(id));
1611
+ addMember(String(parent.parentTaskId), null, id);
1612
+ } else addMember(id, null, subtaskId);
1611
1613
  }
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)" }));
1614
+ 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_…|sub_…> (repeatable)" }));
1613
1615
  const notifRoster = members[0].kind === "notification";
1614
1616
  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
1617
  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 instances (a piped `sp subtask --format json` line or --instance tsk_…/sub_…) with plain task/notification instances in one collect" }));
1618
+ 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 sub_…) with plain task/notification instances in one collect" }));
1617
1619
  const streamOpts = {
1618
1620
  replay: true,
1619
1621
  ...until.idleMs !== void 0 ? { idleMs: until.idleMs } : {}
@@ -1782,7 +1784,7 @@ const reasonOption = Options.choice("reason", [
1782
1784
  "answered",
1783
1785
  "superseded"
1784
1786
  ]).pipe(Options.withDescription("Why: plain withdrawal (default), another recipient's answer made the rest moot (`answered`), or a replacement exists (`superseded`)."), Options.withDefault("canceled"));
1785
- const noteOption = Options.text("note").pipe(Options.withDescription("Free-text explanation shown on the recipients' canceled card. Encrypted under the org master key on the org path (default when enabled); on the personal path seal it with --password pw@topic (topic send) or a bare --password (self-send, account default key)."), Options.optional);
1787
+ const noteOption = Options.text("note").pipe(Options.withDescription("Free-text explanation shown on the recipients' canceled card. Sealed under the target's key when this CLI holds it: the org vault on an org session, or the matching -p/--password (`pw@topic`, or a bare `pw` for a self-send)."), Options.optional);
1786
1788
  const supersededByOption = Options.text("superseded-by").pipe(Options.withDescription("The replacement's id — a task id, a subtask id of the same chain, or (for a group cancel) the replacement group id. Requires --reason superseded."), Options.optional);
1787
1789
  const noEncryptOption$3 = Options.boolean("no-encrypt").pipe(Options.withDescription("For org cancels: send the note in plaintext even when the org vault is unlocked."));
1788
1790
  const cancelCommand = Command.make("cancel", {
@@ -1801,89 +1803,46 @@ const cancelCommand = Command.make("cancel", {
1801
1803
  const note = Option.getOrUndefined(args.note);
1802
1804
  const supersededBy = Option.getOrUndefined(args["superseded-by"]);
1803
1805
  if (supersededBy !== void 0 && args.reason !== "superseded") return yield* Effect.fail(new UserError({ message: "--superseded-by requires --reason superseded" }));
1804
- const credential = yield* resolveCredential(args["api-token"], args["base-url"]);
1805
- let noteFields = {};
1806
- if (note !== void 0) if (credential.kind === "org") {
1807
- const vault = yield* (yield* VaultAccess).forSendOrPlaintext(args["no-encrypt"]);
1808
- if (vault) noteFields = {
1809
- note: yield* sdkCall("encrypt note", () => encrypt(vault.masterKeyCurrent.key, note)),
1810
- encryption: {
1811
- type: "org",
1812
- v: vault.masterKeyCurrent.version
1813
- }
1806
+ const kind = args.id.startsWith("grptsk_") ? "group" : args.id.startsWith("tsk_") ? "task" : args.id.startsWith("sub_") ? "subtask" : void 0;
1807
+ if (kind === void 0) return yield* Effect.fail(new UserError({ message: `cannot cancel \`${args.id}\`: expected a tsk_…, sub_…, or grptsk_… id` }));
1808
+ const cred = yield* resolveCredential(args["api-token"], args["base-url"]);
1809
+ const orgKeys = cred.kind === "org" && !args["no-encrypt"] ? yield* loadOrgMasterKeys : void 0;
1810
+ return yield* Effect.scoped(Effect.gen(function* () {
1811
+ const client = cred.kind === "personal" ? yield* acquireClient({
1812
+ baseUrl: cred.baseUrl,
1813
+ apiToken: cred.apiToken,
1814
+ passwords: args.password
1815
+ }) : yield* acquireOrgClient({
1816
+ baseUrl: cred.baseUrl,
1817
+ bearerToken: cred.bearer,
1818
+ ...orgKeys !== void 0 ? { orgMasterKeys: orgKeys } : {}
1819
+ });
1820
+ const opts = {
1821
+ reason: args.reason,
1822
+ ...note !== void 0 ? { note } : {},
1823
+ ...supersededBy !== void 0 ? { supersededBy } : {}
1814
1824
  };
1815
- else {
1816
- noteFields = { note };
1817
- yield* out.warn("note sent unencrypted (org encryption disabled or --no-encrypt)");
1825
+ if (note !== void 0) {
1826
+ const marker = yield* sdkCall("read cancel target", async () => kind === "task" ? (await client.getTask(args.id)).encryption : kind === "subtask" ? (await client.getSubtask(args.id)).encryption : (await client.getTaskGroup(args.id)).tasks[0]?.encryption);
1827
+ if (marker !== void 0) {
1828
+ if ((yield* sdkCall("keyring", async () => (await client.keyring({ includePasswordSalt: marker.type === "personal" })).keyForMarker(marker))) === void 0) yield* out.warn(marker.type === "org" ? "note sent unencrypted (org vault locked, or --no-encrypt)" : "note sent unencrypted — seal it with --password <pw>@<topic> (topic send) or a bare --password (self-send)");
1829
+ }
1818
1830
  }
1819
- } else {
1820
- const passwords = args.password;
1821
- if (passwords.length > 1) return yield* Effect.fail(new UserError({ message: "pass exactly one --password so the note's key is unambiguous" }));
1822
- const flag = passwords[0];
1823
- if (flag === void 0) {
1824
- noteFields = { note };
1825
- yield* out.warn("note sent unencrypted — seal it with --password <pw>@<topic> (topic send) or a bare --password (self-send)");
1826
- } else if (Array.isArray(flag)) {
1827
- const derived = yield* sdkCall("derive note key", () => deriveKey(flag[0], flag[1]));
1828
- noteFields = {
1829
- note: yield* sdkCall("encrypt note", () => encrypt(derived.symmetricKey, note)),
1830
- encryption: {
1831
- type: "personal",
1832
- keyFingerprint: derived.fingerprint
1833
- }
1834
- };
1835
- } else {
1836
- const info = yield* sdkCall("fetch password salt", () => fetchUserInfo({
1837
- baseUrl: new URL(credential.baseUrl),
1838
- apiToken: credential.apiToken,
1839
- fetch
1831
+ if (kind === "group") {
1832
+ const result = yield* sdkCall("cancel task group", () => client.cancelTaskGroup(args.id, opts));
1833
+ yield* out.print(JSON.stringify({
1834
+ type: "canceled",
1835
+ groupId: args.id,
1836
+ ...result
1840
1837
  }));
1841
- const derived = yield* sdkCall("derive note key", () => deriveKey(flag, info.passwordSalt));
1842
- noteFields = {
1843
- note: yield* sdkCall("encrypt note", () => encrypt(derived.symmetricKey, note)),
1844
- encryption: {
1845
- type: "personal",
1846
- keyFingerprint: derived.fingerprint
1847
- }
1848
- };
1838
+ } else if (kind === "task") {
1839
+ yield* sdkCall("cancel task", () => client.cancelTask(args.id, opts));
1840
+ yield* out.info(`canceled ${args.id}`);
1841
+ } else {
1842
+ yield* sdkCall("cancel subtask", () => client.cancelSubtask(args.id, opts));
1843
+ yield* out.info(`canceled ${args.id}`);
1849
1844
  }
1850
- }
1851
- const body = {
1852
- reason: args.reason,
1853
- ...noteFields,
1854
- ...supersededBy !== void 0 ? { supersededBy } : {}
1855
- };
1856
- const baseUrl = new URL(credential.baseUrl);
1857
- const authHeaders = credential.kind === "personal" ? { "API-Token": credential.apiToken } : { Authorization: `Bearer ${credential.bearer}` };
1858
- if (args.id.startsWith("grptsk_")) {
1859
- const result = yield* sdkCall("cancel task group", () => cancelTaskGroup({
1860
- baseUrl,
1861
- groupId: args.id,
1862
- body,
1863
- authHeaders
1864
- }));
1865
- yield* out.print(JSON.stringify({
1866
- type: "canceled",
1867
- groupId: args.id,
1868
- ...result
1869
- }));
1870
- } else if (args.id.startsWith("tsk_")) {
1871
- yield* sdkCall("cancel task", () => cancelTask({
1872
- baseUrl,
1873
- taskId: args.id,
1874
- body,
1875
- authHeaders
1876
- }));
1877
- yield* out.info(`canceled ${args.id}`);
1878
- } else if (args.id.startsWith("sub_")) {
1879
- yield* sdkCall("cancel subtask", () => cancelSubtask({
1880
- baseUrl,
1881
- subtaskId: args.id,
1882
- body,
1883
- authHeaders
1884
- }));
1885
- yield* out.info(`canceled ${args.id}`);
1886
- } else return yield* Effect.fail(new UserError({ message: `cannot cancel \`${args.id}\`: expected a tsk_…, sub_…, or grptsk_… id` }));
1845
+ }));
1887
1846
  })).pipe(Command.withDescription("Cancel a pending task, subtask, or task group you sent (sender-side withdrawal)."));
1888
1847
  //#endregion
1889
1848
  //#region src/commands/daemon.ts
@@ -1916,6 +1875,133 @@ const daemonCommand = Command.make("daemon", {
1916
1875
  });
1917
1876
  }));
1918
1877
  //#endregion
1878
+ //#region src/commands/query-support.ts
1879
+ /** Options every read command shares: credential, decryption keys, paging, output. */
1880
+ const credentialOptions = {
1881
+ "api-token": apiTokenOption,
1882
+ password: passwordOption,
1883
+ "base-url": baseUrlOption,
1884
+ quiet: quietOption
1885
+ };
1886
+ const formatOption$4 = Options.choice("format", ["json", "pretty"]).pipe(Options.withDefault("json"), Options.withDescription("json = one JSON object per line (agents, jq); pretty = human-readable lines."));
1887
+ const sinceOption$1 = 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."));
1888
+ const untilOption$1 = mappedText("until", (raw) => resolveUntil(raw).toISOString()).pipe(Options.optional, Options.withDescription("Only items created at or before this instant (same forms as --since)."));
1889
+ const memberOption$3 = Options.text("member").pipe(Options.optional, Options.withDescription("Only items involving this person: a usr_ id, or a member name on an org."));
1890
+ const limitOption$1 = Options.integer("limit").pipe(Options.optional, Options.withDescription("Page size (server default 50). With --all this is the page size, not a cap."));
1891
+ const cursorOption = Options.text("cursor").pipe(Options.optional, Options.withDescription("Continue a previous page: pass the cursor it reported."));
1892
+ const allOption = Options.boolean("all").pipe(Options.withDescription("Follow cursors until the last page instead of printing one page and a cursor."));
1893
+ const STATUSES = [
1894
+ "pending",
1895
+ "completed",
1896
+ "canceled",
1897
+ "declined",
1898
+ "expired"
1899
+ ];
1900
+ /** `--status` accepts repeats and comma lists: `--status expired --status declined` or `--status expired,declined`. */
1901
+ const statusOption = Options.text("status").pipe(Options.repeated, Options.mapTryCatch((values) => {
1902
+ const flat = values.flatMap((v) => v.split(",")).map((s) => s.trim()).filter((s) => s.length > 0);
1903
+ const bad = flat.filter((s) => !STATUSES.includes(s));
1904
+ if (bad.length > 0) throw new Error(`unknown status ${bad.join(", ")} — use ${STATUSES.join(", ")}`);
1905
+ return flat;
1906
+ }, (e) => HelpDoc.p(e instanceof Error ? e.message : String(e))), Options.withDescription("Only tasks in these states (repeatable or comma-separated)."));
1907
+ /** Resolves the credential (personal API-Token or the saved org session),
1908
+ * builds the matching client and — when keys are available — a keyring, and
1909
+ * runs `body` inside the client's scope. Mirrors what `sp events` does. */
1910
+ const withQueryClient = (args, body) => Effect.gen(function* () {
1911
+ const out = yield* CliOutput;
1912
+ yield* out.setQuiet(args.quiet);
1913
+ const cred = yield* resolveCredential(args["api-token"], args["base-url"]);
1914
+ const orgKeys = cred.kind === "org" ? yield* loadOrgMasterKeys : void 0;
1915
+ return yield* Effect.scoped(Effect.gen(function* () {
1916
+ const client = cred.kind === "personal" ? yield* acquireClient({
1917
+ baseUrl: cred.baseUrl,
1918
+ apiToken: cred.apiToken,
1919
+ passwords: args.password
1920
+ }) : yield* acquireOrgClient({
1921
+ baseUrl: cred.baseUrl,
1922
+ bearerToken: cred.bearer,
1923
+ ...orgKeys !== void 0 ? { orgMasterKeys: orgKeys } : {}
1924
+ });
1925
+ return yield* body({
1926
+ client,
1927
+ 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,
1928
+ org: cred.kind === "org",
1929
+ out
1930
+ });
1931
+ }));
1932
+ });
1933
+ /** Decrypts what the keyring can and counts what it cannot, so a command can
1934
+ * report "N values left as ciphertext" once instead of per row. Each method
1935
+ * maps one wire shape onto its SDK field-schema decryptor. */
1936
+ var Decryptor = class {
1937
+ undecryptable = 0;
1938
+ constructor(keyring) {
1939
+ this.keyring = keyring;
1940
+ }
1941
+ run(value, go) {
1942
+ if (this.keyring === void 0) return Effect.succeed(value);
1943
+ const keyring = this.keyring;
1944
+ return Effect.promise(() => go(keyring)).pipe(Effect.map((r) => {
1945
+ this.undecryptable += r.undecryptable;
1946
+ return r.value;
1947
+ }));
1948
+ }
1949
+ /** A task or subtask payload (chain reads). */
1950
+ payload(value) {
1951
+ return this.run(value, (kr) => decryptTaskPayload(value, kr));
1952
+ }
1953
+ /** A task index / roster row. */
1954
+ summary(value) {
1955
+ return this.run(value, (kr) => decryptTaskSummary(value, kr));
1956
+ }
1957
+ /** A submission under its feed entry's marker. */
1958
+ submission(value, marker) {
1959
+ return this.run(value, (kr) => decryptSubmission(value, kr, marker));
1960
+ }
1961
+ report(out) {
1962
+ 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;
1963
+ }
1964
+ };
1965
+ /** Runs `page` for the first cursor, then keeps following cursors when `all`
1966
+ * is set; otherwise reports the cursor of the next page so the caller can
1967
+ * continue by hand. */
1968
+ const forEachPage = (ctx, format, all, first, page, handle) => Effect.gen(function* () {
1969
+ let cursor = first;
1970
+ for (;;) {
1971
+ const p = yield* page(cursor);
1972
+ yield* handle(p);
1973
+ if (p.nextCursor === void 0) return;
1974
+ if (!all) {
1975
+ yield* format === "json" ? ctx.out.print(JSON.stringify({
1976
+ type: "more",
1977
+ cursor: p.nextCursor
1978
+ })) : ctx.out.info(`more available — continue with --cursor ${p.nextCursor} (or pass --all)`);
1979
+ return;
1980
+ }
1981
+ cursor = p.nextCursor;
1982
+ }
1983
+ });
1984
+ function recipientLabels(t) {
1985
+ return t.recipients.map((r) => r.name ?? r.publicId);
1986
+ }
1987
+ /** One line per task: id, state, when, title, who, subtask counts, group. */
1988
+ function formatTaskSummary(t) {
1989
+ const subs = Object.entries(t.subtasks).map(([k, v]) => `${k} ${v}`).join(", ");
1990
+ return [
1991
+ `${t.taskId} [${t.status}] ${t.createdAt}`,
1992
+ t.title !== void 0 ? ` ${t.title}` : void 0,
1993
+ ` to: ${recipientLabels(t).join(", ") || "-"}`,
1994
+ t.topic !== void 0 ? ` topic: ${t.topic}` : void 0,
1995
+ t.tag !== void 0 ? ` tag: ${t.tag}` : void 0,
1996
+ t.inputs.length > 0 ? ` inputs: ${t.inputs.join(", ")}` : void 0,
1997
+ t.attachments.length > 0 ? ` attachments: ${t.attachments.join(", ")}` : void 0,
1998
+ t.reply !== void 0 ? ` reply: ${t.reply}` : void 0,
1999
+ subs ? ` subtasks: ${subs}` : void 0,
2000
+ t.expiresAt !== void 0 ? ` expires: ${t.expiresAt}` : void 0,
2001
+ t.groupId !== void 0 ? ` group: ${t.groupId}` : void 0
2002
+ ].filter((l) => l !== void 0).join("\n");
2003
+ }
2004
+ //#endregion
1919
2005
  //#region src/commands/download.ts
1920
2006
  const ID_GLOSSARY = "(ids: tsk_ task, sub_ subtask, sbm_ submission; files: inp_ input upload, rfl_ reply file, sbf_ submission file)";
1921
2007
  const resolveScope = (scopeId, fileId) => {
@@ -1927,11 +2013,6 @@ const resolveScope = (scopeId, fileId) => {
1927
2013
  if (wantsSubmission !== (scope === "submission")) return Effect.fail(new UserError({ message: wantsSubmission ? `${fileId} is a submission file — pass its sbm_… id, not ${scopeId} ${ID_GLOSSARY}` : `${fileId} lives on a task chain — pass the tsk_… or sub_… id it belongs to, not ${scopeId} ${ID_GLOSSARY}` }));
1928
2014
  return Effect.succeed(scope);
1929
2015
  };
1930
- const downloadUrlPath = (scope, scopeId, fileId) => {
1931
- if (scope === "submission") return `/v1/submissions/${scopeId}/files/${fileId}/download-url`;
1932
- const kind = fileId.startsWith("inp_") ? "inputs" : "replies";
1933
- return `/v1/${scope === "task" ? "tasks" : "subtasks"}/${scopeId}/${kind}/${fileId}/download-url`;
1934
- };
1935
2016
  /** Resolve where to write: an explicit file path, an existing directory (the
1936
2017
  * server-declared filename inside it), or the current directory. */
1937
2018
  const resolveTarget = async (outArg, filename) => {
@@ -1953,94 +2034,31 @@ const downloadCommand = Command.make("download", {
1953
2034
  "base-url": baseUrlOption,
1954
2035
  quiet: quietOption
1955
2036
  }, (args) => Effect.gen(function* () {
1956
- const out = yield* CliOutput;
1957
- yield* out.setQuiet(args.quiet);
1958
- const scope = yield* resolveScope(args.scopeId, args.fileId);
1959
- const cred = yield* resolveCredential(args["api-token"], args["base-url"]);
1960
- const orgKeys = cred.kind === "org" ? yield* loadOrgMasterKeys : void 0;
1961
- const baseUrl = cred.baseUrl.replace(/\/+$/, "");
1962
- const authHeaders = cred.kind === "personal" ? { "API-Token": cred.apiToken } : { Authorization: `Bearer ${cred.bearer}` };
1963
- const meta = yield* Effect.tryPromise({
1964
- try: async () => {
1965
- const res = await fetch(`${baseUrl}${downloadUrlPath(scope, args.scopeId, args.fileId)}`, {
1966
- method: "POST",
1967
- headers: authHeaders
1968
- });
1969
- if (!res.ok) {
1970
- const body = await res.text().catch(() => "");
1971
- let msg = `download-url failed (${res.status})`;
1972
- try {
1973
- const parsed = JSON.parse(body);
1974
- if (parsed.msg) msg = `${msg}: ${parsed.msg}`;
1975
- } catch {
1976
- if (body) msg = `${msg}: ${body.slice(0, 200)}`;
1977
- }
1978
- throw new Error(msg);
1979
- }
1980
- return await res.json();
1981
- },
1982
- catch: (e) => new UserError({ message: e instanceof Error ? e.message : String(e) })
1983
- });
1984
- const stored = yield* Effect.tryPromise({
1985
- try: async () => {
1986
- const res = await fetch(meta.presignedGetUrl);
1987
- if (!res.ok) throw new Error(`fetching the file failed (${res.status})`);
1988
- return new Uint8Array(await res.arrayBuffer());
1989
- },
1990
- catch: (e) => new UserError({ message: e instanceof Error ? e.message : String(e) })
1991
- });
1992
- if (meta.checksumSha256 !== void 0) {
1993
- const actual = createHash("sha256").update(stored).digest("base64");
1994
- if (actual !== meta.checksumSha256) return yield* Effect.fail(new UserError({ message: `checksum mismatch: stored blob hashes to ${actual}, server declared ${meta.checksumSha256}` }));
1995
- }
1996
- let bytes = stored;
1997
- if (meta.encryption !== void 0) {
1998
- const passwords = [...args.password];
1999
- const passwordSalt = passwords.filter((p) => typeof p === "string").length > 0 && cred.kind === "personal" ? yield* Effect.tryPromise({
2000
- try: async () => (await fetchUserInfo({
2001
- baseUrl: new URL(baseUrl),
2002
- apiToken: cred.apiToken,
2003
- fetch
2004
- })).passwordSalt,
2005
- catch: (e) => new UserError({ message: `fetching the password salt failed: ${e instanceof Error ? e.message : String(e)}` })
2006
- }) : void 0;
2007
- const key = yield* Effect.tryPromise({
2037
+ yield* resolveScope(args.scopeId, args.fileId);
2038
+ return yield* withQueryClient(args, (ctx) => Effect.gen(function* () {
2039
+ const file = yield* Effect.tryPromise({
2040
+ try: () => ctx.client.downloadFile(args.scopeId, args.fileId),
2041
+ catch: (e) => new UserError({ message: e instanceof DownloadError && e.message.includes("no matching key") ? ctx.org ? "the file is sealed under an org master key — unlock the vault (org session) to decrypt" : "the file is encrypted — pass the matching -p/--password (`pw@topic`, or a bare account password)" : e instanceof Error ? e.message : String(e) })
2042
+ });
2043
+ const filename = file.filename ?? args.fileId;
2044
+ const target = yield* Effect.tryPromise({
2008
2045
  try: async () => {
2009
- const ring = await Keyring.build({
2010
- passwords: [],
2011
- topics: [],
2012
- ...orgKeys !== void 0 ? { orgMasterKeys: orgKeys } : {}
2013
- });
2014
- for (const p of passwords) if (typeof p !== "string") ring.add(await deriveKey(p[0], p[1]));
2015
- else if (passwordSalt !== void 0) ring.add(await deriveKey(p, passwordSalt));
2016
- return ring.keyForMarker(meta.encryption);
2046
+ const t = await resolveTarget(Option.getOrUndefined(args.out), filename);
2047
+ await writeFile(t, file.bytes);
2048
+ return t;
2017
2049
  },
2018
- catch: (e) => new UserError({ message: `building the keyring failed: ${e instanceof Error ? e.message : String(e)}` })
2019
- });
2020
- if (key === void 0) return yield* Effect.fail(new UserError({ message: meta.encryption.type === "org" ? `the file is sealed under org master_key v${meta.encryption.v} — unlock the vault (org session) to decrypt` : "the file is encrypted — pass the matching -p/--password (`pw@topic`, or a bare account password)" }));
2021
- bytes = yield* Effect.tryPromise({
2022
- try: () => decryptBytes(key, stored),
2023
- catch: (e) => new UserError({ message: `decryption failed: ${e instanceof Error ? e.message : String(e)}` })
2050
+ catch: (e) => new UserError({ message: `saving failed: ${e instanceof Error ? e.message : String(e)}` })
2024
2051
  });
2025
- }
2026
- const filename = meta.filename ?? args.fileId;
2027
- const target = yield* Effect.tryPromise({
2028
- try: async () => {
2029
- const t = await resolveTarget(Option.getOrUndefined(args.out), filename);
2030
- await writeFile(t, bytes);
2031
- return t;
2032
- },
2033
- catch: (e) => new UserError({ message: `saving failed: ${e instanceof Error ? e.message : String(e)}` })
2034
- });
2035
- if (args.format === "json") yield* out.print(JSON.stringify({
2036
- type: "downloaded",
2037
- id: args.fileId,
2038
- path: target,
2039
- filename: meta.filename ?? null,
2040
- contentType: meta.contentType ?? null,
2041
- size: meta.size ?? null
2052
+ if (args.format === "json") yield* ctx.out.print(JSON.stringify({
2053
+ type: "downloaded",
2054
+ id: args.fileId,
2055
+ path: target,
2056
+ filename: file.filename ?? null,
2057
+ contentType: file.contentType ?? null,
2058
+ size: file.size ?? null
2059
+ }));
2060
+ else yield* ctx.out.print(target);
2042
2061
  }));
2043
- else yield* out.print(target);
2044
2062
  }));
2045
2063
  //#endregion
2046
2064
  //#region src/output.ts
@@ -2103,6 +2121,7 @@ const sinceOption = mappedText("since", resolveSince).pipe(Options.withDescripti
2103
2121
  const untilOption = mappedText("until", resolveUntil).pipe(Options.withDescription("Stop at this timestamp. Forces a finite range, so `--follow` is ignored."), Options.optional);
2104
2122
  const limitOption = Options.integer("limit").pipe(Options.withDescription("Maximum number of events to print, then exit."), Options.optional);
2105
2123
  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`."));
2124
+ const memberOption$2 = Options.text("member").pipe(Options.optional, Options.withDescription("Only events by this person: a usr_ id, or the actor's name."));
2106
2125
  const formatOption$3 = Options.choice("format", [
2107
2126
  "json",
2108
2127
  "pretty",
@@ -2112,13 +2131,14 @@ const directOption = Options.boolean("direct").pipe(Options.withDescription("Ope
2112
2131
  const QUIET_EXIT_MS = 2e3;
2113
2132
  const eventsCommand = Command.make("events", {
2114
2133
  type: eventTypeOption,
2134
+ member: memberOption$2,
2115
2135
  since: sinceOption,
2116
2136
  until: untilOption,
2117
2137
  limit: limitOption,
2118
2138
  follow: followOption,
2119
2139
  format: formatOption$3,
2120
2140
  direct: directOption,
2121
- topic: topicOption,
2141
+ topic: topicOption$1,
2122
2142
  "api-token": apiTokenOption,
2123
2143
  password: passwordOption,
2124
2144
  "base-url": baseUrlOption,
@@ -2132,6 +2152,8 @@ const eventsCommand = Command.make("events", {
2132
2152
  const untilDate = Option.getOrUndefined(args.until);
2133
2153
  const limit = Option.getOrUndefined(args.limit);
2134
2154
  const filter = new TypeFilter(args.type);
2155
+ const member = Option.getOrUndefined(args.member);
2156
+ const memberMatches = (ev) => member === void 0 || ev.actor?.publicId === member || ev.actor?.name === member;
2135
2157
  yield* Effect.forEach(filter.unknown, (u) => out.warn(`ignoring unknown --type \`${u}\``));
2136
2158
  const webSocketFactory = args.direct || sinceIso !== void 0 ? Option.none() : yield* sharedWebSocketFactory({
2137
2159
  credential: cred.kind === "personal" ? {
@@ -2194,7 +2216,7 @@ const eventsCommand = Command.make("events", {
2194
2216
  yield* sdkStream("events stream", (signal) => client.events({
2195
2217
  ...sinceIso !== void 0 ? { since: sinceIso } : {},
2196
2218
  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* () {
2219
+ })).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
2220
  const decrypted = keyring ? yield* sdkCall("decrypt event", () => tryDecryptEventData(ev, keyring)) : void 0;
2199
2221
  yield* out.print(formatEvent(ev, args.format, decrypted));
2200
2222
  const n = yield* Ref.updateAndGet(printed, (x) => x + 1);
@@ -2212,6 +2234,204 @@ const eventsCommand = Command.make("events", {
2212
2234
  }));
2213
2235
  }));
2214
2236
  //#endregion
2237
+ //#region src/commands/get.ts
2238
+ const topicOption = Options.text("topic").pipe(Options.optional, Options.withDescription("Only tasks sent to this topic: its value (name), or its id."));
2239
+ const groupOption = Options.text("group").pipe(Options.optional, Options.withDescription("Only the instances of this grptsk_ group."));
2240
+ /** Flags that only make sense on the paged listings. Id reads reject them by
2241
+ * name instead of silently ignoring them. */
2242
+ function rejectListFlags(args, target, allowStatus) {
2243
+ const set = [
2244
+ ...!allowStatus && args.status.length > 0 ? ["--status"] : [],
2245
+ ...Option.isSome(args.since) ? ["--since"] : [],
2246
+ ...Option.isSome(args.until) ? ["--until"] : [],
2247
+ ...Option.isSome(args.topic) ? ["--topic"] : [],
2248
+ ...Option.isSome(args.member) ? ["--member"] : [],
2249
+ ...Option.isSome(args.group) ? ["--group"] : [],
2250
+ ...Option.isSome(args.limit) ? ["--limit"] : [],
2251
+ ...Option.isSome(args.cursor) ? ["--cursor"] : [],
2252
+ ...args.all ? ["--all"] : []
2253
+ ];
2254
+ return set.length === 0 ? Effect.void : Effect.fail(new UserError({ message: `${set.join(", ")} does not apply when reading ${target}` }));
2255
+ }
2256
+ function getChain(args, ctx) {
2257
+ return Effect.gen(function* () {
2258
+ yield* rejectListFlags(args, "one task", false);
2259
+ const chain = yield* sdkCall("read task chain", () => ctx.client.getTaskChain(args.what));
2260
+ const dec = new Decryptor(ctx.keyring);
2261
+ const task = yield* dec.payload(chain.task);
2262
+ const subtasks = [];
2263
+ for (const s of chain.subtasks) subtasks.push({
2264
+ subtask: yield* dec.payload(s.subtask),
2265
+ createdAt: s.createdAt
2266
+ });
2267
+ const view = {
2268
+ task,
2269
+ createdAt: chain.createdAt,
2270
+ subtasks
2271
+ };
2272
+ if (args.format === "json") yield* ctx.out.print(JSON.stringify(view));
2273
+ else {
2274
+ yield* ctx.out.print(`${task.taskId} [${task.status}] ${chain.createdAt}`);
2275
+ yield* ctx.out.print(JSON.stringify(task, null, 2));
2276
+ for (const s of subtasks) {
2277
+ yield* ctx.out.print(`\n${s.subtask.subtaskId} [${s.subtask.status}] ${s.createdAt}`);
2278
+ yield* ctx.out.print(JSON.stringify(s.subtask, null, 2));
2279
+ }
2280
+ }
2281
+ yield* dec.report(ctx.out);
2282
+ });
2283
+ }
2284
+ function getSubtaskRead(args, ctx) {
2285
+ return Effect.gen(function* () {
2286
+ yield* rejectListFlags(args, "one subtask", false);
2287
+ const subtask = yield* sdkCall("read subtask", () => ctx.client.getSubtask(args.what));
2288
+ const dec = new Decryptor(ctx.keyring);
2289
+ const view = yield* dec.payload(subtask);
2290
+ if (args.format === "json") yield* ctx.out.print(JSON.stringify(view));
2291
+ else {
2292
+ yield* ctx.out.print(`${view.subtaskId} [${view.status}] parent ${String(view.parentTaskId ?? "-")}`);
2293
+ yield* ctx.out.print(JSON.stringify(view, null, 2));
2294
+ }
2295
+ yield* dec.report(ctx.out);
2296
+ });
2297
+ }
2298
+ function getRoster(args, ctx) {
2299
+ return Effect.gen(function* () {
2300
+ yield* rejectListFlags(args, "a task group roster", true);
2301
+ const roster = yield* sdkCall("read task group", () => ctx.client.getTaskGroup(args.what, args.status.length > 0 ? { status: args.status } : {}));
2302
+ const dec = new Decryptor(ctx.keyring);
2303
+ const counts = {};
2304
+ for (const t of roster.tasks) counts[t.status] = (counts[t.status] ?? 0) + 1;
2305
+ if (args.format === "json") {
2306
+ yield* ctx.out.print(JSON.stringify({
2307
+ type: "group",
2308
+ groupId: roster.groupId,
2309
+ counts
2310
+ }));
2311
+ for (const t of roster.tasks) yield* ctx.out.print(JSON.stringify(yield* dec.summary(t)));
2312
+ } else {
2313
+ const summary = Object.entries(counts).map(([k, v]) => `${k} ${v}`).join(", ") || "no instances";
2314
+ yield* ctx.out.print(`${roster.groupId} ${summary}`);
2315
+ for (const t of roster.tasks) yield* ctx.out.print(formatTaskSummary(yield* dec.summary(t)));
2316
+ }
2317
+ yield* dec.report(ctx.out);
2318
+ });
2319
+ }
2320
+ function getTasks(args, ctx) {
2321
+ return Effect.gen(function* () {
2322
+ const dec = new Decryptor(ctx.keyring);
2323
+ const opts = {
2324
+ ...args.status.length > 0 ? { status: args.status } : {},
2325
+ ...Option.match(args.since, {
2326
+ onNone: () => ({}),
2327
+ onSome: (since) => ({ since })
2328
+ }),
2329
+ ...Option.match(args.until, {
2330
+ onNone: () => ({}),
2331
+ onSome: (until) => ({ until })
2332
+ }),
2333
+ ...Option.match(args.topic, {
2334
+ onNone: () => ({}),
2335
+ onSome: (topic) => ({ topic })
2336
+ }),
2337
+ ...Option.match(args.member, {
2338
+ onNone: () => ({}),
2339
+ onSome: (member) => ({ member })
2340
+ }),
2341
+ ...Option.match(args.group, {
2342
+ onNone: () => ({}),
2343
+ onSome: (group) => ({ group })
2344
+ }),
2345
+ ...Option.match(args.limit, {
2346
+ onNone: () => ({}),
2347
+ onSome: (limit) => ({ limit })
2348
+ })
2349
+ };
2350
+ yield* forEachPage(ctx, args.format, args.all, Option.getOrUndefined(args.cursor), (cursor) => sdkCall("list tasks", () => ctx.client.listTasks({
2351
+ ...opts,
2352
+ ...cursor !== void 0 ? { cursor } : {}
2353
+ })), (page) => Effect.forEach(page.tasks, (t) => Effect.gen(function* () {
2354
+ const row = yield* dec.summary(t);
2355
+ yield* ctx.out.print(args.format === "json" ? JSON.stringify(row) : formatTaskSummary(row));
2356
+ }), { discard: true }));
2357
+ yield* dec.report(ctx.out);
2358
+ });
2359
+ }
2360
+ function getSubmissions(args, ctx) {
2361
+ return Effect.gen(function* () {
2362
+ const wrong = [
2363
+ ...args.status.length > 0 ? ["--status"] : [],
2364
+ ...Option.isSome(args.topic) ? ["--topic"] : [],
2365
+ ...Option.isSome(args.group) ? ["--group"] : []
2366
+ ];
2367
+ if (wrong.length > 0) return yield* Effect.fail(new UserError({ message: `${wrong.join(", ")} does not apply to submissions` }));
2368
+ const dec = new Decryptor(ctx.keyring);
2369
+ const opts = {
2370
+ ...Option.match(args.since, {
2371
+ onNone: () => ({}),
2372
+ onSome: (since) => ({ since })
2373
+ }),
2374
+ ...Option.match(args.until, {
2375
+ onNone: () => ({}),
2376
+ onSome: (until) => ({ until })
2377
+ }),
2378
+ ...Option.match(args.member, {
2379
+ onNone: () => ({}),
2380
+ onSome: (member) => ({ member })
2381
+ }),
2382
+ ...Option.match(args.limit, {
2383
+ onNone: () => ({}),
2384
+ onSome: (limit) => ({ limit })
2385
+ })
2386
+ };
2387
+ yield* forEachPage(ctx, args.format, args.all, Option.getOrUndefined(args.cursor), (cursor) => sdkCall("list submissions", () => ctx.client.listSubmissions({
2388
+ ...opts,
2389
+ ...cursor !== void 0 ? { cursor } : {}
2390
+ })), (page) => Effect.forEach(page.submissions, (entry) => Effect.gen(function* () {
2391
+ const submission = yield* dec.submission(entry.submission, entry.encryption);
2392
+ const by = entry.actor?.name ?? entry.actor?.publicId;
2393
+ if (args.format === "json") yield* ctx.out.print(JSON.stringify({
2394
+ ...submission,
2395
+ ...entry.actor !== void 0 ? { actor: entry.actor } : {}
2396
+ }));
2397
+ else {
2398
+ const body = submission.body;
2399
+ const parts = [
2400
+ body?.value !== void 0 ? body.value : void 0,
2401
+ submission.photo !== void 0 ? "[photo]" : void 0,
2402
+ submission.file !== void 0 ? "[file]" : void 0,
2403
+ submission.audio !== void 0 ? "[audio]" : void 0,
2404
+ submission.location !== void 0 ? "[location]" : void 0
2405
+ ].filter((p) => p !== void 0);
2406
+ yield* ctx.out.print(`${submission.id} ${submission.createdAt} ${by ?? "-"}: ${parts.join(" ") || "(empty)"}`);
2407
+ }
2408
+ }), { discard: true }));
2409
+ yield* dec.report(ctx.out);
2410
+ });
2411
+ }
2412
+ const getCommand = Command.make("get", {
2413
+ what: Args.text({ name: "what" }).pipe(Args.withDescription("What to read: tasks, submissions, a tsk_ id, a sub_ id, or a grptsk_ id.")),
2414
+ status: statusOption,
2415
+ since: sinceOption$1,
2416
+ until: untilOption$1,
2417
+ topic: topicOption,
2418
+ member: memberOption$3,
2419
+ group: groupOption,
2420
+ limit: limitOption$1,
2421
+ cursor: cursorOption,
2422
+ all: allOption,
2423
+ format: formatOption$4,
2424
+ ...credentialOptions
2425
+ }, (args) => withQueryClient(args, (ctx) => {
2426
+ if (args.what === "tasks") return getTasks(args, ctx);
2427
+ if (args.what === "submissions") return getSubmissions(args, ctx);
2428
+ if (args.what.startsWith("tsk_")) return getChain(args, ctx);
2429
+ if (args.what.startsWith("sub_")) return getSubtaskRead(args, ctx);
2430
+ if (args.what.startsWith("grptsk_")) return getRoster(args, ctx);
2431
+ const hint = 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, a sub_ id, or a grptsk_ id";
2432
+ return Effect.fail(new UserError({ message: `cannot read '${args.what}': ${hint}` }));
2433
+ })).pipe(Command.withDescription("Read what came back: 'tasks' or 'submissions' for filtered listings, a tsk_ id for one task with its subtasks, a sub_ id for one subtask, a grptsk_ id for a group's per-recipient roster."));
2434
+ //#endregion
2215
2435
  //#region src/input-spec.ts
2216
2436
  const SETTING_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
2217
2437
  function looksLikeSetting(seg) {
@@ -2621,7 +2841,7 @@ const notifyCommand = Command.make("notify", {
2621
2841
  shared: sharedOption$1,
2622
2842
  format: formatOption$2,
2623
2843
  noEncrypt: noEncryptOption$2,
2624
- topic: topicOption,
2844
+ topic: topicOption$1,
2625
2845
  "api-token": apiTokenOption,
2626
2846
  password: passwordOption,
2627
2847
  "base-url": baseUrlOption,
@@ -2812,7 +3032,7 @@ const putIntegrationWraps = (id, wraps) => Effect.gen(function* () {
2812
3032
  yield* (yield* Api).put("integration wrap", `/v1/org/integrations/${encodeURIComponent(id)}/wraps`, { wraps });
2813
3033
  });
2814
3034
  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."));
3035
+ 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
3036
  const createCommand = Command.make("create", {
2817
3037
  name: nameOption,
2818
3038
  scopes: scopesOption,
@@ -3535,7 +3755,7 @@ const taskCommand = Command.make("task", {
3535
3755
  shared: sharedOption,
3536
3756
  expires: expiresOption,
3537
3757
  format: formatOption$1,
3538
- topic: topicOption,
3758
+ topic: topicOption$1,
3539
3759
  "api-token": apiTokenOption,
3540
3760
  password: passwordOption,
3541
3761
  "base-url": baseUrlOption,
@@ -3841,7 +4061,7 @@ const subtaskCommand = Command.make("subtask", {
3841
4061
  markdown: markdownOption,
3842
4062
  "no-encrypt": noEncryptOption,
3843
4063
  instance: instanceOption,
3844
- topic: topicOption,
4064
+ topic: topicOption$1,
3845
4065
  "api-token": apiTokenOption,
3846
4066
  password: passwordOption,
3847
4067
  "base-url": baseUrlOption,
@@ -4022,7 +4242,8 @@ const root = Command.make("simplepush").pipe(Command.withSubcommands([
4022
4242
  notifyCommand,
4023
4243
  taskCommand,
4024
4244
  subtaskCommand,
4025
- cancelCommand
4245
+ cancelCommand,
4246
+ getCommand
4026
4247
  ]));
4027
4248
  const cli = Command.run(root, {
4028
4249
  name: "Simplepush CLI",