@simplepush/cli 0.3.0 → 0.5.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 +3 -2
- package/dist/main.mjs +331 -324
- package/dist/main.mjs.map +1 -1
- package/package.json +2 -2
package/dist/main.mjs
CHANGED
|
@@ -9,10 +9,13 @@ 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,
|
|
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";
|
|
16
|
+
//#region package.json
|
|
17
|
+
var version = "0.5.0";
|
|
18
|
+
//#endregion
|
|
16
19
|
//#region src/errors.ts
|
|
17
20
|
/** No CLI session saved — `sp auth login` hasn't been run (or was logged out). */
|
|
18
21
|
var NotLoggedIn = class extends Data.TaggedError("NotLoggedIn") {};
|
|
@@ -1525,7 +1528,7 @@ const loadOrgMasterKeys = Effect.gen(function* () {
|
|
|
1525
1528
|
});
|
|
1526
1529
|
const collectCommand = Command.make("collect", {
|
|
1527
1530
|
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
|
|
1531
|
+
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
1532
|
replies: Options.boolean("replies").pipe(Options.withDescription("Collect only replies. Default (no mode flag) is the full activity stream: inputs, replies, and completions.")),
|
|
1530
1533
|
inputs: Options.boolean("inputs").pipe(Options.withDescription("Collect only input events (waits for every instance to complete by default).")),
|
|
1531
1534
|
submissions: Options.boolean("submissions").pipe(Options.withDescription("Collect submissions (your inbox) instead of a group's events.")),
|
|
@@ -1606,14 +1609,16 @@ const collectCommand = Command.make("collect", {
|
|
|
1606
1609
|
} : null, m.subtaskId);
|
|
1607
1610
|
for (const spec of args.instance) {
|
|
1608
1611
|
const [id, subtaskId] = spec.split("/", 2);
|
|
1609
|
-
if (id.startsWith("sub_"))
|
|
1610
|
-
|
|
1612
|
+
if (id.startsWith("sub_")) {
|
|
1613
|
+
const parent = yield* sdkCall("read subtask", () => client.getSubtask(id));
|
|
1614
|
+
addMember(String(parent.parentTaskId), null, id);
|
|
1615
|
+
} else addMember(id, null, subtaskId);
|
|
1611
1616
|
}
|
|
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_…|
|
|
1617
|
+
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
1618
|
const notifRoster = members[0].kind === "notification";
|
|
1614
1619
|
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
1620
|
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
|
|
1621
|
+
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
1622
|
const streamOpts = {
|
|
1618
1623
|
replay: true,
|
|
1619
1624
|
...until.idleMs !== void 0 ? { idleMs: until.idleMs } : {}
|
|
@@ -1782,7 +1787,7 @@ const reasonOption = Options.choice("reason", [
|
|
|
1782
1787
|
"answered",
|
|
1783
1788
|
"superseded"
|
|
1784
1789
|
]).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.
|
|
1790
|
+
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
1791
|
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
1792
|
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
1793
|
const cancelCommand = Command.make("cancel", {
|
|
@@ -1801,89 +1806,46 @@ const cancelCommand = Command.make("cancel", {
|
|
|
1801
1806
|
const note = Option.getOrUndefined(args.note);
|
|
1802
1807
|
const supersededBy = Option.getOrUndefined(args["superseded-by"]);
|
|
1803
1808
|
if (supersededBy !== void 0 && args.reason !== "superseded") return yield* Effect.fail(new UserError({ message: "--superseded-by requires --reason superseded" }));
|
|
1804
|
-
const
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1809
|
+
const kind = args.id.startsWith("grptsk_") ? "group" : args.id.startsWith("tsk_") ? "task" : args.id.startsWith("sub_") ? "subtask" : void 0;
|
|
1810
|
+
if (kind === void 0) return yield* Effect.fail(new UserError({ message: `cannot cancel \`${args.id}\`: expected a tsk_…, sub_…, or grptsk_… id` }));
|
|
1811
|
+
const cred = yield* resolveCredential(args["api-token"], args["base-url"]);
|
|
1812
|
+
const orgKeys = cred.kind === "org" && !args["no-encrypt"] ? yield* loadOrgMasterKeys : void 0;
|
|
1813
|
+
return yield* Effect.scoped(Effect.gen(function* () {
|
|
1814
|
+
const client = cred.kind === "personal" ? yield* acquireClient({
|
|
1815
|
+
baseUrl: cred.baseUrl,
|
|
1816
|
+
apiToken: cred.apiToken,
|
|
1817
|
+
passwords: args.password
|
|
1818
|
+
}) : yield* acquireOrgClient({
|
|
1819
|
+
baseUrl: cred.baseUrl,
|
|
1820
|
+
bearerToken: cred.bearer,
|
|
1821
|
+
...orgKeys !== void 0 ? { orgMasterKeys: orgKeys } : {}
|
|
1822
|
+
});
|
|
1823
|
+
const opts = {
|
|
1824
|
+
reason: args.reason,
|
|
1825
|
+
...note !== void 0 ? { note } : {},
|
|
1826
|
+
...supersededBy !== void 0 ? { supersededBy } : {}
|
|
1814
1827
|
};
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1828
|
+
if (note !== void 0) {
|
|
1829
|
+
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);
|
|
1830
|
+
if (marker !== void 0) {
|
|
1831
|
+
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)");
|
|
1832
|
+
}
|
|
1818
1833
|
}
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
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
|
|
1834
|
+
if (kind === "group") {
|
|
1835
|
+
const result = yield* sdkCall("cancel task group", () => client.cancelTaskGroup(args.id, opts));
|
|
1836
|
+
yield* out.print(JSON.stringify({
|
|
1837
|
+
type: "canceled",
|
|
1838
|
+
groupId: args.id,
|
|
1839
|
+
...result
|
|
1840
1840
|
}));
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
}
|
|
1848
|
-
};
|
|
1841
|
+
} else if (kind === "task") {
|
|
1842
|
+
yield* sdkCall("cancel task", () => client.cancelTask(args.id, opts));
|
|
1843
|
+
yield* out.info(`canceled ${args.id}`);
|
|
1844
|
+
} else {
|
|
1845
|
+
yield* sdkCall("cancel subtask", () => client.cancelSubtask(args.id, opts));
|
|
1846
|
+
yield* out.info(`canceled ${args.id}`);
|
|
1849
1847
|
}
|
|
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` }));
|
|
1848
|
+
}));
|
|
1887
1849
|
})).pipe(Command.withDescription("Cancel a pending task, subtask, or task group you sent (sender-side withdrawal)."));
|
|
1888
1850
|
//#endregion
|
|
1889
1851
|
//#region src/commands/daemon.ts
|
|
@@ -1916,6 +1878,133 @@ const daemonCommand = Command.make("daemon", {
|
|
|
1916
1878
|
});
|
|
1917
1879
|
}));
|
|
1918
1880
|
//#endregion
|
|
1881
|
+
//#region src/commands/query-support.ts
|
|
1882
|
+
/** Options every read command shares: credential, decryption keys, paging, output. */
|
|
1883
|
+
const credentialOptions = {
|
|
1884
|
+
"api-token": apiTokenOption,
|
|
1885
|
+
password: passwordOption,
|
|
1886
|
+
"base-url": baseUrlOption,
|
|
1887
|
+
quiet: quietOption
|
|
1888
|
+
};
|
|
1889
|
+
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."));
|
|
1890
|
+
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."));
|
|
1891
|
+
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)."));
|
|
1892
|
+
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."));
|
|
1893
|
+
const limitOption$2 = Options.integer("limit").pipe(Options.optional, Options.withDescription("Page size (server default 50). With --all this is the page size, not a cap."));
|
|
1894
|
+
const cursorOption = Options.text("cursor").pipe(Options.optional, Options.withDescription("Continue a previous page: pass the cursor it reported."));
|
|
1895
|
+
const allOption = Options.boolean("all").pipe(Options.withDescription("Follow cursors until the last page instead of printing one page and a cursor."));
|
|
1896
|
+
const STATUSES = [
|
|
1897
|
+
"pending",
|
|
1898
|
+
"completed",
|
|
1899
|
+
"canceled",
|
|
1900
|
+
"declined",
|
|
1901
|
+
"expired"
|
|
1902
|
+
];
|
|
1903
|
+
/** `--status` accepts repeats and comma lists: `--status expired --status declined` or `--status expired,declined`. */
|
|
1904
|
+
const statusOption = Options.text("status").pipe(Options.repeated, Options.mapTryCatch((values) => {
|
|
1905
|
+
const flat = values.flatMap((v) => v.split(",")).map((s) => s.trim()).filter((s) => s.length > 0);
|
|
1906
|
+
const bad = flat.filter((s) => !STATUSES.includes(s));
|
|
1907
|
+
if (bad.length > 0) throw new Error(`unknown status ${bad.join(", ")} — use ${STATUSES.join(", ")}`);
|
|
1908
|
+
return flat;
|
|
1909
|
+
}, (e) => HelpDoc.p(e instanceof Error ? e.message : String(e))), Options.withDescription("Only tasks in these states (repeatable or comma-separated)."));
|
|
1910
|
+
/** Resolves the credential (personal API-Token or the saved org session),
|
|
1911
|
+
* builds the matching client and — when keys are available — a keyring, and
|
|
1912
|
+
* runs `body` inside the client's scope. Mirrors what `sp events` does. */
|
|
1913
|
+
const withQueryClient = (args, body) => Effect.gen(function* () {
|
|
1914
|
+
const out = yield* CliOutput;
|
|
1915
|
+
yield* out.setQuiet(args.quiet);
|
|
1916
|
+
const cred = yield* resolveCredential(args["api-token"], args["base-url"]);
|
|
1917
|
+
const orgKeys = cred.kind === "org" ? yield* loadOrgMasterKeys : void 0;
|
|
1918
|
+
return yield* Effect.scoped(Effect.gen(function* () {
|
|
1919
|
+
const client = cred.kind === "personal" ? yield* acquireClient({
|
|
1920
|
+
baseUrl: cred.baseUrl,
|
|
1921
|
+
apiToken: cred.apiToken,
|
|
1922
|
+
passwords: args.password
|
|
1923
|
+
}) : yield* acquireOrgClient({
|
|
1924
|
+
baseUrl: cred.baseUrl,
|
|
1925
|
+
bearerToken: cred.bearer,
|
|
1926
|
+
...orgKeys !== void 0 ? { orgMasterKeys: orgKeys } : {}
|
|
1927
|
+
});
|
|
1928
|
+
return yield* body({
|
|
1929
|
+
client,
|
|
1930
|
+
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,
|
|
1931
|
+
org: cred.kind === "org",
|
|
1932
|
+
out
|
|
1933
|
+
});
|
|
1934
|
+
}));
|
|
1935
|
+
});
|
|
1936
|
+
/** Decrypts what the keyring can and counts what it cannot, so a command can
|
|
1937
|
+
* report "N values left as ciphertext" once instead of per row. Each method
|
|
1938
|
+
* maps one wire shape onto its SDK field-schema decryptor. */
|
|
1939
|
+
var Decryptor = class {
|
|
1940
|
+
undecryptable = 0;
|
|
1941
|
+
constructor(keyring) {
|
|
1942
|
+
this.keyring = keyring;
|
|
1943
|
+
}
|
|
1944
|
+
run(value, go) {
|
|
1945
|
+
if (this.keyring === void 0) return Effect.succeed(value);
|
|
1946
|
+
const keyring = this.keyring;
|
|
1947
|
+
return Effect.promise(() => go(keyring)).pipe(Effect.map((r) => {
|
|
1948
|
+
this.undecryptable += r.undecryptable;
|
|
1949
|
+
return r.value;
|
|
1950
|
+
}));
|
|
1951
|
+
}
|
|
1952
|
+
/** A task or subtask payload (chain reads). */
|
|
1953
|
+
payload(value) {
|
|
1954
|
+
return this.run(value, (kr) => decryptTaskPayload(value, kr));
|
|
1955
|
+
}
|
|
1956
|
+
/** A task index / roster row. */
|
|
1957
|
+
summary(value) {
|
|
1958
|
+
return this.run(value, (kr) => decryptTaskSummary(value, kr));
|
|
1959
|
+
}
|
|
1960
|
+
/** A submission under its feed entry's marker. */
|
|
1961
|
+
submission(value, marker) {
|
|
1962
|
+
return this.run(value, (kr) => decryptSubmission(value, kr, marker));
|
|
1963
|
+
}
|
|
1964
|
+
report(out) {
|
|
1965
|
+
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;
|
|
1966
|
+
}
|
|
1967
|
+
};
|
|
1968
|
+
/** Runs `page` for the first cursor, then keeps following cursors when `all`
|
|
1969
|
+
* is set; otherwise reports the cursor of the next page so the caller can
|
|
1970
|
+
* continue by hand. */
|
|
1971
|
+
const forEachPage = (ctx, format, all, first, page, handle) => Effect.gen(function* () {
|
|
1972
|
+
let cursor = first;
|
|
1973
|
+
for (;;) {
|
|
1974
|
+
const p = yield* page(cursor);
|
|
1975
|
+
yield* handle(p);
|
|
1976
|
+
if (p.nextCursor === void 0) return;
|
|
1977
|
+
if (!all) {
|
|
1978
|
+
yield* format === "json" ? ctx.out.print(JSON.stringify({
|
|
1979
|
+
type: "more",
|
|
1980
|
+
cursor: p.nextCursor
|
|
1981
|
+
})) : ctx.out.info(`more available — continue with --cursor ${p.nextCursor} (or pass --all)`);
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
cursor = p.nextCursor;
|
|
1985
|
+
}
|
|
1986
|
+
});
|
|
1987
|
+
function recipientLabels(t) {
|
|
1988
|
+
return t.recipients.map((r) => r.name ?? r.publicId);
|
|
1989
|
+
}
|
|
1990
|
+
/** One line per task: id, state, when, title, who, subtask counts, group. */
|
|
1991
|
+
function formatTaskSummary(t) {
|
|
1992
|
+
const subs = Object.entries(t.subtasks).map(([k, v]) => `${k} ${v}`).join(", ");
|
|
1993
|
+
return [
|
|
1994
|
+
`${t.taskId} [${t.status}] ${t.createdAt}`,
|
|
1995
|
+
t.title !== void 0 ? ` ${t.title}` : void 0,
|
|
1996
|
+
` to: ${recipientLabels(t).join(", ") || "-"}`,
|
|
1997
|
+
t.topic !== void 0 ? ` topic: ${t.topic}` : void 0,
|
|
1998
|
+
t.tag !== void 0 ? ` tag: ${t.tag}` : void 0,
|
|
1999
|
+
t.inputs.length > 0 ? ` inputs: ${t.inputs.join(", ")}` : void 0,
|
|
2000
|
+
t.attachments.length > 0 ? ` attachments: ${t.attachments.join(", ")}` : void 0,
|
|
2001
|
+
t.reply !== void 0 ? ` reply: ${t.reply}` : void 0,
|
|
2002
|
+
subs ? ` subtasks: ${subs}` : void 0,
|
|
2003
|
+
t.expiresAt !== void 0 ? ` expires: ${t.expiresAt}` : void 0,
|
|
2004
|
+
t.groupId !== void 0 ? ` group: ${t.groupId}` : void 0
|
|
2005
|
+
].filter((l) => l !== void 0).join("\n");
|
|
2006
|
+
}
|
|
2007
|
+
//#endregion
|
|
1919
2008
|
//#region src/commands/download.ts
|
|
1920
2009
|
const ID_GLOSSARY = "(ids: tsk_ task, sub_ subtask, sbm_ submission; files: inp_ input upload, rfl_ reply file, sbf_ submission file)";
|
|
1921
2010
|
const resolveScope = (scopeId, fileId) => {
|
|
@@ -1927,11 +2016,6 @@ const resolveScope = (scopeId, fileId) => {
|
|
|
1927
2016
|
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
2017
|
return Effect.succeed(scope);
|
|
1929
2018
|
};
|
|
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
2019
|
/** Resolve where to write: an explicit file path, an existing directory (the
|
|
1936
2020
|
* server-declared filename inside it), or the current directory. */
|
|
1937
2021
|
const resolveTarget = async (outArg, filename) => {
|
|
@@ -1953,94 +2037,31 @@ const downloadCommand = Command.make("download", {
|
|
|
1953
2037
|
"base-url": baseUrlOption,
|
|
1954
2038
|
quiet: quietOption
|
|
1955
2039
|
}, (args) => Effect.gen(function* () {
|
|
1956
|
-
|
|
1957
|
-
yield*
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
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({
|
|
2040
|
+
yield* resolveScope(args.scopeId, args.fileId);
|
|
2041
|
+
return yield* withQueryClient(args, (ctx) => Effect.gen(function* () {
|
|
2042
|
+
const file = yield* Effect.tryPromise({
|
|
2043
|
+
try: () => ctx.client.downloadFile(args.scopeId, args.fileId),
|
|
2044
|
+
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) })
|
|
2045
|
+
});
|
|
2046
|
+
const filename = file.filename ?? args.fileId;
|
|
2047
|
+
const target = yield* Effect.tryPromise({
|
|
2008
2048
|
try: async () => {
|
|
2009
|
-
const
|
|
2010
|
-
|
|
2011
|
-
|
|
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);
|
|
2049
|
+
const t = await resolveTarget(Option.getOrUndefined(args.out), filename);
|
|
2050
|
+
await writeFile(t, file.bytes);
|
|
2051
|
+
return t;
|
|
2017
2052
|
},
|
|
2018
|
-
catch: (e) => new UserError({ message: `
|
|
2053
|
+
catch: (e) => new UserError({ message: `saving failed: ${e instanceof Error ? e.message : String(e)}` })
|
|
2019
2054
|
});
|
|
2020
|
-
if (
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
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
|
|
2055
|
+
if (args.format === "json") yield* ctx.out.print(JSON.stringify({
|
|
2056
|
+
type: "downloaded",
|
|
2057
|
+
id: args.fileId,
|
|
2058
|
+
path: target,
|
|
2059
|
+
filename: file.filename ?? null,
|
|
2060
|
+
contentType: file.contentType ?? null,
|
|
2061
|
+
size: file.size ?? null
|
|
2062
|
+
}));
|
|
2063
|
+
else yield* ctx.out.print(target);
|
|
2042
2064
|
}));
|
|
2043
|
-
else yield* out.print(target);
|
|
2044
2065
|
}));
|
|
2045
2066
|
//#endregion
|
|
2046
2067
|
//#region src/output.ts
|
|
@@ -2099,12 +2120,12 @@ function extractRaw(v) {
|
|
|
2099
2120
|
//#endregion
|
|
2100
2121
|
//#region src/commands/events.ts
|
|
2101
2122
|
const eventTypeOption = Options.text("type").pipe(Options.withDescription("Filter by event type. Repeatable."), Options.repeated);
|
|
2102
|
-
const sinceOption
|
|
2103
|
-
const untilOption
|
|
2123
|
+
const sinceOption = mappedText("since", resolveSince).pipe(Options.withDescription("Replay from this point. Accepts `24h`, `7d`, or an ISO 8601 timestamp."), Options.optional);
|
|
2124
|
+
const untilOption = mappedText("until", resolveUntil).pipe(Options.withDescription("Stop at this timestamp. Forces a finite range, so `--follow` is ignored."), Options.optional);
|
|
2104
2125
|
const limitOption$1 = Options.integer("limit").pipe(Options.withDescription("Maximum number of events to print, then exit."), Options.optional);
|
|
2105
2126
|
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$
|
|
2107
|
-
const formatOption$
|
|
2127
|
+
const memberOption$2 = Options.text("member").pipe(Options.optional, Options.withDescription("Only events by this person: a usr_ id, or the actor's name."));
|
|
2128
|
+
const formatOption$3 = Options.choice("format", [
|
|
2108
2129
|
"json",
|
|
2109
2130
|
"pretty",
|
|
2110
2131
|
"raw"
|
|
@@ -2113,12 +2134,12 @@ const directOption = Options.boolean("direct").pipe(Options.withDescription("Ope
|
|
|
2113
2134
|
const QUIET_EXIT_MS = 2e3;
|
|
2114
2135
|
const eventsCommand = Command.make("events", {
|
|
2115
2136
|
type: eventTypeOption,
|
|
2116
|
-
member: memberOption$
|
|
2117
|
-
since: sinceOption
|
|
2118
|
-
until: untilOption
|
|
2137
|
+
member: memberOption$2,
|
|
2138
|
+
since: sinceOption,
|
|
2139
|
+
until: untilOption,
|
|
2119
2140
|
limit: limitOption$1,
|
|
2120
2141
|
follow: followOption,
|
|
2121
|
-
format: formatOption$
|
|
2142
|
+
format: formatOption$3,
|
|
2122
2143
|
direct: directOption,
|
|
2123
2144
|
topic: topicOption$1,
|
|
2124
2145
|
"api-token": apiTokenOption,
|
|
@@ -2216,130 +2237,6 @@ const eventsCommand = Command.make("events", {
|
|
|
2216
2237
|
}));
|
|
2217
2238
|
}));
|
|
2218
2239
|
//#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
2240
|
//#region src/commands/get.ts
|
|
2344
2241
|
const topicOption = Options.text("topic").pipe(Options.optional, Options.withDescription("Only tasks sent to this topic: its value (name), or its id."));
|
|
2345
2242
|
const groupOption = Options.text("group").pipe(Options.optional, Options.withDescription("Only the instances of this grptsk_ group."));
|
|
@@ -2387,6 +2284,20 @@ function getChain(args, ctx) {
|
|
|
2387
2284
|
yield* dec.report(ctx.out);
|
|
2388
2285
|
});
|
|
2389
2286
|
}
|
|
2287
|
+
function getSubtaskRead(args, ctx) {
|
|
2288
|
+
return Effect.gen(function* () {
|
|
2289
|
+
yield* rejectListFlags(args, "one subtask", false);
|
|
2290
|
+
const subtask = yield* sdkCall("read subtask", () => ctx.client.getSubtask(args.what));
|
|
2291
|
+
const dec = new Decryptor(ctx.keyring);
|
|
2292
|
+
const view = yield* dec.payload(subtask);
|
|
2293
|
+
if (args.format === "json") yield* ctx.out.print(JSON.stringify(view));
|
|
2294
|
+
else {
|
|
2295
|
+
yield* ctx.out.print(`${view.subtaskId} [${view.status}] parent ${String(view.parentTaskId ?? "-")}`);
|
|
2296
|
+
yield* ctx.out.print(JSON.stringify(view, null, 2));
|
|
2297
|
+
}
|
|
2298
|
+
yield* dec.report(ctx.out);
|
|
2299
|
+
});
|
|
2300
|
+
}
|
|
2390
2301
|
function getRoster(args, ctx) {
|
|
2391
2302
|
return Effect.gen(function* () {
|
|
2392
2303
|
yield* rejectListFlags(args, "a task group roster", true);
|
|
@@ -2502,26 +2413,121 @@ function getSubmissions(args, ctx) {
|
|
|
2502
2413
|
});
|
|
2503
2414
|
}
|
|
2504
2415
|
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.")),
|
|
2416
|
+
what: Args.text({ name: "what" }).pipe(Args.withDescription("What to read: tasks, submissions, a tsk_ id, a sub_ id, or a grptsk_ id.")),
|
|
2506
2417
|
status: statusOption,
|
|
2507
|
-
since: sinceOption,
|
|
2508
|
-
until: untilOption,
|
|
2418
|
+
since: sinceOption$1,
|
|
2419
|
+
until: untilOption$1,
|
|
2509
2420
|
topic: topicOption,
|
|
2510
|
-
member: memberOption$
|
|
2421
|
+
member: memberOption$3,
|
|
2511
2422
|
group: groupOption,
|
|
2512
|
-
limit: limitOption,
|
|
2423
|
+
limit: limitOption$2,
|
|
2513
2424
|
cursor: cursorOption,
|
|
2514
2425
|
all: allOption,
|
|
2515
|
-
format: formatOption$
|
|
2426
|
+
format: formatOption$4,
|
|
2516
2427
|
...credentialOptions
|
|
2517
2428
|
}, (args) => withQueryClient(args, (ctx) => {
|
|
2518
2429
|
if (args.what === "tasks") return getTasks(args, ctx);
|
|
2519
2430
|
if (args.what === "submissions") return getSubmissions(args, ctx);
|
|
2520
2431
|
if (args.what.startsWith("tsk_")) return getChain(args, ctx);
|
|
2432
|
+
if (args.what.startsWith("sub_")) return getSubtaskRead(args, ctx);
|
|
2521
2433
|
if (args.what.startsWith("grptsk_")) return getRoster(args, ctx);
|
|
2522
|
-
const hint = args.what.startsWith("
|
|
2434
|
+
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
2435
|
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."));
|
|
2436
|
+
})).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."));
|
|
2437
|
+
//#endregion
|
|
2438
|
+
//#region src/commands/search.ts
|
|
2439
|
+
const KINDS = [
|
|
2440
|
+
"task",
|
|
2441
|
+
"subtask",
|
|
2442
|
+
"answer",
|
|
2443
|
+
"reply",
|
|
2444
|
+
"notification",
|
|
2445
|
+
"notification_answer",
|
|
2446
|
+
"submission"
|
|
2447
|
+
];
|
|
2448
|
+
const kindOption = Options.text("kind").pipe(Options.repeated, Options.mapTryCatch((values) => {
|
|
2449
|
+
const flat = values.flatMap((v) => v.split(",")).map((s) => s.trim()).filter((s) => s.length > 0);
|
|
2450
|
+
const bad = flat.filter((s) => !KINDS.includes(s));
|
|
2451
|
+
if (bad.length > 0) throw new Error(`unknown kind ${bad.join(", ")} — use ${KINDS.join(", ")}`);
|
|
2452
|
+
return flat;
|
|
2453
|
+
}, (e) => HelpDoc.p(e instanceof Error ? e.message : String(e))), Options.withDescription("Only these hit kinds (repeatable or comma-separated)."));
|
|
2454
|
+
function parsePoint(raw) {
|
|
2455
|
+
const parts = raw.split(",");
|
|
2456
|
+
if (parts.length === 2) {
|
|
2457
|
+
const latitude = Number(parts[0].trim());
|
|
2458
|
+
const longitude = Number(parts[1].trim());
|
|
2459
|
+
if (Number.isFinite(latitude) && Number.isFinite(longitude) && Math.abs(latitude) <= 90 && Math.abs(longitude) <= 180) return {
|
|
2460
|
+
latitude,
|
|
2461
|
+
longitude
|
|
2462
|
+
};
|
|
2463
|
+
}
|
|
2464
|
+
throw new Error(`expected \`lat,lng\` with lat in [-90,90] and lng in [-180,180], got '${raw}'`);
|
|
2465
|
+
}
|
|
2466
|
+
const nearOption = Options.text("near").pipe(Options.mapTryCatch(parsePoint, (e) => HelpDoc.p(e instanceof Error ? e.message : String(e))), Options.optional, Options.withDescription("Center of a radius filter as `lat,lng`; requires --radius. Hits come back with their point and its distance, nearest first when no words are given."));
|
|
2467
|
+
const radiusOption = Options.integer("radius").pipe(Options.optional, Options.withDescription("Radius around --near, in meters (1..1000000)."));
|
|
2468
|
+
const withinOption = Options.text("within").pipe(Options.mapTryCatch((raw) => {
|
|
2469
|
+
const points = raw.split(";").map((s) => s.trim()).filter((s) => s.length > 0).map(parsePoint);
|
|
2470
|
+
if (points.length < 3) throw new Error("--within needs at least 3 `lat,lng` points separated by `;`");
|
|
2471
|
+
return points;
|
|
2472
|
+
}, (e) => HelpDoc.p(e instanceof Error ? e.message : String(e))), Options.optional, Options.withDescription("Polygon to search inside: `lat,lng` corners joined with `;` (at least 3). Not combinable with --near/--radius; hits carry no distance and come newest first without words."));
|
|
2473
|
+
const limitOption = Options.integer("limit").pipe(Options.optional, Options.withDescription("Best hits to return; server default 20, at most 100."));
|
|
2474
|
+
/** One block per hit: id, kind, when, then whatever the hit carries. */
|
|
2475
|
+
function formatHit(h) {
|
|
2476
|
+
return [
|
|
2477
|
+
`${h.ref} [${h.kind}] ${h.createdAt}`,
|
|
2478
|
+
h.title !== void 0 ? ` ${h.title}` : void 0,
|
|
2479
|
+
h.actor !== void 0 ? ` by: ${h.actor}` : void 0,
|
|
2480
|
+
h.snippet !== void 0 ? ` ${h.snippet}` : void 0,
|
|
2481
|
+
h.location !== void 0 ? ` at: ${h.location.latitude},${h.location.longitude}${h.location.distanceMeters !== void 0 ? ` (${Math.round(h.location.distanceMeters)}m away)` : ""}` : void 0
|
|
2482
|
+
].filter((l) => l !== void 0).join("\n");
|
|
2483
|
+
}
|
|
2484
|
+
const searchCommand = Command.make("search", {
|
|
2485
|
+
words: Args.text({ name: "words" }).pipe(Args.withDescription("The words to look for; all must appear. Quote a phrase (nested quotes) for adjacency. Optional when --near/--radius or --within is given."), Args.repeated),
|
|
2486
|
+
kind: kindOption,
|
|
2487
|
+
since: sinceOption$1,
|
|
2488
|
+
until: untilOption$1,
|
|
2489
|
+
member: memberOption$3,
|
|
2490
|
+
near: nearOption,
|
|
2491
|
+
radius: radiusOption,
|
|
2492
|
+
within: withinOption,
|
|
2493
|
+
limit: limitOption,
|
|
2494
|
+
format: formatOption$4,
|
|
2495
|
+
...credentialOptions
|
|
2496
|
+
}, (args) => withQueryClient(args, (ctx) => Effect.gen(function* () {
|
|
2497
|
+
const query = args.words.join(" ").trim();
|
|
2498
|
+
const near = Option.getOrUndefined(args.near);
|
|
2499
|
+
const radius = Option.getOrUndefined(args.radius);
|
|
2500
|
+
const within = Option.getOrUndefined(args.within);
|
|
2501
|
+
const problem = near !== void 0 && radius === void 0 ? "--near requires --radius (meters)" : radius !== void 0 && near === void 0 ? "--radius requires --near" : within !== void 0 && near !== void 0 ? "--within cannot be combined with --near/--radius" : query === "" && near === void 0 && within === void 0 ? "give words to search for, or an area (--near with --radius, or --within)" : void 0;
|
|
2502
|
+
if (problem !== void 0) return yield* Effect.fail(new UserError({ message: problem }));
|
|
2503
|
+
const opts = {
|
|
2504
|
+
...args.kind.length > 0 ? { kind: args.kind } : {},
|
|
2505
|
+
...Option.match(args.since, {
|
|
2506
|
+
onNone: () => ({}),
|
|
2507
|
+
onSome: (since) => ({ since })
|
|
2508
|
+
}),
|
|
2509
|
+
...Option.match(args.until, {
|
|
2510
|
+
onNone: () => ({}),
|
|
2511
|
+
onSome: (until) => ({ until })
|
|
2512
|
+
}),
|
|
2513
|
+
...Option.match(args.member, {
|
|
2514
|
+
onNone: () => ({}),
|
|
2515
|
+
onSome: (member) => ({ member })
|
|
2516
|
+
}),
|
|
2517
|
+
...Option.match(args.limit, {
|
|
2518
|
+
onNone: () => ({}),
|
|
2519
|
+
onSome: (limit) => ({ limit })
|
|
2520
|
+
}),
|
|
2521
|
+
...near !== void 0 && radius !== void 0 ? {
|
|
2522
|
+
near,
|
|
2523
|
+
radiusMeters: radius
|
|
2524
|
+
} : {},
|
|
2525
|
+
...within !== void 0 ? { within } : {}
|
|
2526
|
+
};
|
|
2527
|
+
const res = yield* sdkCall("search", () => ctx.client.search(query === "" ? void 0 : query, opts));
|
|
2528
|
+
for (const h of res.hits) yield* ctx.out.print(args.format === "json" ? JSON.stringify(h) : formatHit(h));
|
|
2529
|
+
if (res.hits.length === 0 && args.format === "pretty") yield* ctx.out.info("no hits");
|
|
2530
|
+
}))).pipe(Command.withDescription("Ranked full-text and location search over everything this credential reads, across all time. Words match literally plus stemmed in the org's configured languages; --near/--radius or --within search by place instead of or on top of words."));
|
|
2525
2531
|
//#endregion
|
|
2526
2532
|
//#region src/input-spec.ts
|
|
2527
2533
|
const SETTING_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
@@ -3806,7 +3812,7 @@ const fileInput$1 = repeatedText("file-input", void 0, "Add a file upload input.
|
|
|
3806
3812
|
const locationInput$1 = repeatedText("location-input", void 0, "Add a location input (the recipient shares their device GPS position from the app). Format: `description[;key=value...]`. Settings: `required=true|false`. Repeatable.");
|
|
3807
3813
|
const linkOption$1 = repeatedText("link", "l", "Attach a remote URL (a link attachment). Repeatable. For local files use --file.");
|
|
3808
3814
|
const fileOption$1 = repeatedText("file", "f", "Attach a local file, uploaded as a file attachment (encrypted under the send's key — topic password or org master key — when the send is encrypted). Repeatable.");
|
|
3809
|
-
const
|
|
3815
|
+
const autoCommitOption$1 = Options.boolean("auto-commit").pipe(Options.withDescription("Complete the task as inputs are filled, without an explicit Submit. Default is form mode: the recipient submits everything at once."));
|
|
3810
3816
|
const waitOption$1 = Options.boolean("wait").pipe(Options.withDescription("Block until the task is completed; print the result to stdout. Requires exactly one input on the request."));
|
|
3811
3817
|
const replyOption$1 = Options.choice("reply", [
|
|
3812
3818
|
"one-shot",
|
|
@@ -3835,7 +3841,7 @@ const taskCommand = Command.make("task", {
|
|
|
3835
3841
|
"location-input": locationInput$1,
|
|
3836
3842
|
link: linkOption$1,
|
|
3837
3843
|
file: fileOption$1,
|
|
3838
|
-
|
|
3844
|
+
"auto-commit": autoCommitOption$1,
|
|
3839
3845
|
wait: waitOption$1,
|
|
3840
3846
|
reply: replyOption$1,
|
|
3841
3847
|
member: memberOption,
|
|
@@ -3886,7 +3892,7 @@ const taskCommand = Command.make("task", {
|
|
|
3886
3892
|
inputs,
|
|
3887
3893
|
links: [...args.link],
|
|
3888
3894
|
files: [...args.file],
|
|
3889
|
-
autoCommit:
|
|
3895
|
+
autoCommit: args["auto-commit"],
|
|
3890
3896
|
reply: Option.getOrUndefined(args.reply),
|
|
3891
3897
|
markdown: args.markdown,
|
|
3892
3898
|
noEncrypt: args["no-encrypt"],
|
|
@@ -3913,7 +3919,7 @@ const taskCommand = Command.make("task", {
|
|
|
3913
3919
|
inputs,
|
|
3914
3920
|
links: [...args.link],
|
|
3915
3921
|
...files.length > 0 ? { files } : {},
|
|
3916
|
-
autoCommit:
|
|
3922
|
+
autoCommit: args["auto-commit"],
|
|
3917
3923
|
...Option.isSome(args.reply) ? { reply: args.reply.value } : {},
|
|
3918
3924
|
...args.markdown ? { contentFormat: "markdown" } : {},
|
|
3919
3925
|
...expiresAt !== void 0 ? { expiresAt } : {}
|
|
@@ -4120,7 +4126,7 @@ const photoInput = Options.text("photo-input").pipe(Options.withDescription("Add
|
|
|
4120
4126
|
const voiceRecordingInput = Options.text("voice-recording-input").pipe(Options.withDescription("Add a voice recording input. Format: `description[;key=value...]`. Settings: `required=true|false`. Repeatable."), Options.repeated);
|
|
4121
4127
|
const fileInput = Options.text("file-input").pipe(Options.withDescription("Add a file upload input. Format: `description[;key=value...]`. Settings: `required=true|false`. Repeatable."), Options.repeated);
|
|
4122
4128
|
const locationInput = Options.text("location-input").pipe(Options.withDescription("Add a location input (the recipient shares their device GPS position from the app). Format: `description[;key=value...]`. Settings: `required=true|false`. Repeatable."), Options.repeated);
|
|
4123
|
-
const
|
|
4129
|
+
const autoCommitOption = Options.boolean("auto-commit").pipe(Options.withDescription("Complete the subtask as inputs are filled, without an explicit Submit. Default is form mode: the recipient submits everything at once."));
|
|
4124
4130
|
const waitOption = Options.boolean("wait").pipe(Options.withDescription("Block until the subtask is completed; print the result to stdout. Requires at least one input on the append. On a group append, the first member's completion wins."));
|
|
4125
4131
|
const formatOption = Options.choice("format", ["text", "json"]).pipe(Options.withDescription("stdout format for an append: `text` (the bare sub_ id(s), default) or `json` (a `sent` line piped to `sp collect`)."), Options.withDefault("text"));
|
|
4126
4132
|
const replyOption = Options.choice("reply", [
|
|
@@ -4145,7 +4151,7 @@ const subtaskCommand = Command.make("subtask", {
|
|
|
4145
4151
|
"location-input": locationInput,
|
|
4146
4152
|
link: linkOption,
|
|
4147
4153
|
file: fileOption,
|
|
4148
|
-
|
|
4154
|
+
"auto-commit": autoCommitOption,
|
|
4149
4155
|
wait: waitOption,
|
|
4150
4156
|
format: formatOption,
|
|
4151
4157
|
reply: replyOption,
|
|
@@ -4175,7 +4181,7 @@ const subtaskCommand = Command.make("subtask", {
|
|
|
4175
4181
|
...content !== void 0 ? { content } : {},
|
|
4176
4182
|
...inputs.length > 0 ? { inputs } : {},
|
|
4177
4183
|
links: [...args.link],
|
|
4178
|
-
autoCommit:
|
|
4184
|
+
autoCommit: args["auto-commit"],
|
|
4179
4185
|
...Option.isSome(args.reply) ? { reply: args.reply.value } : {},
|
|
4180
4186
|
...args.markdown ? { contentFormat: "markdown" } : {}
|
|
4181
4187
|
};
|
|
@@ -4334,11 +4340,12 @@ const root = Command.make("simplepush").pipe(Command.withSubcommands([
|
|
|
4334
4340
|
taskCommand,
|
|
4335
4341
|
subtaskCommand,
|
|
4336
4342
|
cancelCommand,
|
|
4337
|
-
getCommand
|
|
4343
|
+
getCommand,
|
|
4344
|
+
searchCommand
|
|
4338
4345
|
]));
|
|
4339
4346
|
const cli = Command.run(root, {
|
|
4340
4347
|
name: "Simplepush CLI",
|
|
4341
|
-
version
|
|
4348
|
+
version
|
|
4342
4349
|
});
|
|
4343
4350
|
const MainLive = Layer.mergeAll(CliOutput.Default, Sodium.Default, AuthStore.Default, VaultStore.Default, InviteStore.Default, Api.Default, VaultAccess.Default).pipe(Layer.provideMerge(FetchHttpClient.layer), Layer.provideMerge(NodeContext.layer));
|
|
4344
4351
|
/** Render any failure through the typed-error table, then re-fail so the
|