@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/README.md +3 -2
- package/dist/main.mjs +226 -317
- package/dist/main.mjs.map +1 -1
- package/package.json +2 -2
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,
|
|
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
|
|
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_"))
|
|
1610
|
-
|
|
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_…|
|
|
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
|
|
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.
|
|
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
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
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
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
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);
|
|
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: `
|
|
2050
|
+
catch: (e) => new UserError({ message: `saving failed: ${e instanceof Error ? e.message : String(e)}` })
|
|
2019
2051
|
});
|
|
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
|
|
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
|
|
2103
|
-
const untilOption
|
|
2104
|
-
const limitOption
|
|
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$
|
|
2107
|
-
const formatOption$
|
|
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$
|
|
2117
|
-
since: sinceOption
|
|
2118
|
-
until: untilOption
|
|
2119
|
-
limit: limitOption
|
|
2134
|
+
member: memberOption$2,
|
|
2135
|
+
since: sinceOption,
|
|
2136
|
+
until: untilOption,
|
|
2137
|
+
limit: limitOption,
|
|
2120
2138
|
follow: followOption,
|
|
2121
|
-
format: formatOption$
|
|
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$
|
|
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$
|
|
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("
|
|
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_]*=/;
|