@simplepush/cli 0.3.0 → 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, decryptSubmission, decryptTaskPayload, decryptTaskSummary, 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";
@@ -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)}` })
2050
+ catch: (e) => new UserError({ message: `saving failed: ${e instanceof Error ? e.message : String(e)}` })
2019
2051
  });
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)}` })
2024
- });
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
@@ -2099,12 +2117,12 @@ function extractRaw(v) {
2099
2117
  //#endregion
2100
2118
  //#region src/commands/events.ts
2101
2119
  const eventTypeOption = Options.text("type").pipe(Options.withDescription("Filter by event type. Repeatable."), Options.repeated);
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);
2120
+ const sinceOption = mappedText("since", resolveSince).pipe(Options.withDescription("Replay from this point. Accepts `24h`, `7d`, or an ISO 8601 timestamp."), Options.optional);
2121
+ const untilOption = mappedText("until", resolveUntil).pipe(Options.withDescription("Stop at this timestamp. Forces a finite range, so `--follow` is ignored."), Options.optional);
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`."));
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", [
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."));
2125
+ const formatOption$3 = Options.choice("format", [
2108
2126
  "json",
2109
2127
  "pretty",
2110
2128
  "raw"
@@ -2113,12 +2131,12 @@ const directOption = Options.boolean("direct").pipe(Options.withDescription("Ope
2113
2131
  const QUIET_EXIT_MS = 2e3;
2114
2132
  const eventsCommand = Command.make("events", {
2115
2133
  type: eventTypeOption,
2116
- member: memberOption$3,
2117
- since: sinceOption$1,
2118
- until: untilOption$1,
2119
- limit: limitOption$1,
2134
+ member: memberOption$2,
2135
+ since: sinceOption,
2136
+ until: untilOption,
2137
+ limit: limitOption,
2120
2138
  follow: followOption,
2121
- format: formatOption$4,
2139
+ format: formatOption$3,
2122
2140
  direct: directOption,
2123
2141
  topic: topicOption$1,
2124
2142
  "api-token": apiTokenOption,
@@ -2216,130 +2234,6 @@ const eventsCommand = Command.make("events", {
2216
2234
  }));
2217
2235
  }));
2218
2236
  //#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
2237
  //#region src/commands/get.ts
2344
2238
  const topicOption = Options.text("topic").pipe(Options.optional, Options.withDescription("Only tasks sent to this topic: its value (name), or its id."));
2345
2239
  const groupOption = Options.text("group").pipe(Options.optional, Options.withDescription("Only the instances of this grptsk_ group."));
@@ -2387,6 +2281,20 @@ function getChain(args, ctx) {
2387
2281
  yield* dec.report(ctx.out);
2388
2282
  });
2389
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
+ }
2390
2298
  function getRoster(args, ctx) {
2391
2299
  return Effect.gen(function* () {
2392
2300
  yield* rejectListFlags(args, "a task group roster", true);
@@ -2502,26 +2410,27 @@ function getSubmissions(args, ctx) {
2502
2410
  });
2503
2411
  }
2504
2412
  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.")),
2413
+ what: Args.text({ name: "what" }).pipe(Args.withDescription("What to read: tasks, submissions, a tsk_ id, a sub_ id, or a grptsk_ id.")),
2506
2414
  status: statusOption,
2507
- since: sinceOption,
2508
- until: untilOption,
2415
+ since: sinceOption$1,
2416
+ until: untilOption$1,
2509
2417
  topic: topicOption,
2510
- member: memberOption$2,
2418
+ member: memberOption$3,
2511
2419
  group: groupOption,
2512
- limit: limitOption,
2420
+ limit: limitOption$1,
2513
2421
  cursor: cursorOption,
2514
2422
  all: allOption,
2515
- format: formatOption$3,
2423
+ format: formatOption$4,
2516
2424
  ...credentialOptions
2517
2425
  }, (args) => withQueryClient(args, (ctx) => {
2518
2426
  if (args.what === "tasks") return getTasks(args, ctx);
2519
2427
  if (args.what === "submissions") return getSubmissions(args, ctx);
2520
2428
  if (args.what.startsWith("tsk_")) return getChain(args, ctx);
2429
+ if (args.what.startsWith("sub_")) return getSubtaskRead(args, ctx);
2521
2430
  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";
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";
2523
2432
  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."));
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."));
2525
2434
  //#endregion
2526
2435
  //#region src/input-spec.ts
2527
2436
  const SETTING_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;