@simplepush/cli 0.1.1 → 0.2.1
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 +27 -0
- package/dist/main.mjs +904 -272
- package/dist/main.mjs.map +1 -1
- package/package.json +2 -2
package/dist/main.mjs
CHANGED
|
@@ -9,10 +9,10 @@ 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 {
|
|
13
|
-
import {
|
|
12
|
+
import { Client, Keyring, OrgClient, TypeFilter, cancelSubtask, cancelTask, cancelTaskGroup, decryptBytes, deriveKey, encrypt, fetchUserInfo, isSubtaskGroupResponse, tryDecryptEventData } from "@simplepush/sdk";
|
|
13
|
+
import { mkdir, stat, writeFile } from "node:fs/promises";
|
|
14
14
|
import { connect, createConnection } from "node:net";
|
|
15
|
-
import { basename, extname, join } from "node:path";
|
|
15
|
+
import path, { basename, extname, join } from "node:path";
|
|
16
16
|
//#region src/errors.ts
|
|
17
17
|
/** No CLI session saved — `sp auth login` hasn't been run (or was logged out). */
|
|
18
18
|
var NotLoggedIn = class extends Data.TaggedError("NotLoggedIn") {};
|
|
@@ -103,11 +103,17 @@ const MasterKeySchema = Schema.Struct({
|
|
|
103
103
|
version: Schema.Number,
|
|
104
104
|
key: Schema.Uint8ArrayFromSelf
|
|
105
105
|
});
|
|
106
|
+
const IntegrationPin = Schema.Struct({
|
|
107
|
+
id: Schema.String,
|
|
108
|
+
pubkeyB64: Schema.String,
|
|
109
|
+
name: Schema.String
|
|
110
|
+
});
|
|
106
111
|
const VaultContents = Schema.Struct({
|
|
107
112
|
adminPublicKey: Schema.Uint8ArrayFromSelf,
|
|
108
113
|
adminPrivateKey: Schema.Uint8ArrayFromSelf,
|
|
109
114
|
masterKeyCurrent: MasterKeySchema,
|
|
110
|
-
masterKeyHistory: Schema.Array(MasterKeySchema)
|
|
115
|
+
masterKeyHistory: Schema.Array(MasterKeySchema),
|
|
116
|
+
integrations: Schema.optionalWith(Schema.Array(IntegrationPin), { default: () => [] })
|
|
111
117
|
});
|
|
112
118
|
const MasterKeyJson = Schema.Struct({
|
|
113
119
|
version: Schema.Number,
|
|
@@ -118,7 +124,8 @@ const VaultJson = Schema.Struct({
|
|
|
118
124
|
adminPublicKey: Schema.Uint8ArrayFromBase64,
|
|
119
125
|
adminPrivateKey: Schema.Uint8ArrayFromBase64,
|
|
120
126
|
masterKeyCurrent: MasterKeyJson,
|
|
121
|
-
masterKeyHistory: Schema.Array(MasterKeyJson)
|
|
127
|
+
masterKeyHistory: Schema.Array(MasterKeyJson),
|
|
128
|
+
integrations: Schema.optionalWith(Schema.Array(IntegrationPin), { default: () => [] })
|
|
122
129
|
});
|
|
123
130
|
function normalizePassphrase(input) {
|
|
124
131
|
return input.trim().toLowerCase().split(/\s+/).join(" ");
|
|
@@ -147,10 +154,13 @@ var Sodium = class extends Effect.Service()("cli/Sodium", { effect: Effect.gen(f
|
|
|
147
154
|
});
|
|
148
155
|
const toB64 = (bytes) => sodium.to_base64(bytes, sodium.base64_variants.ORIGINAL);
|
|
149
156
|
const fromB64 = (s) => sodium.from_base64(s, sodium.base64_variants.ORIGINAL);
|
|
157
|
+
const toB64Url = (bytes) => sodium.to_base64(bytes, sodium.base64_variants.URLSAFE_NO_PADDING);
|
|
150
158
|
return {
|
|
151
159
|
toB64,
|
|
152
160
|
fromB64,
|
|
161
|
+
toB64Url,
|
|
153
162
|
randomBytes: (length) => Effect.sync(() => sodium.randombytes_buf(length)),
|
|
163
|
+
seedKeypair: (seed) => attempt("keypair derivation failed", () => sodium.crypto_box_seed_keypair(seed)),
|
|
154
164
|
generateVaultSalt: Effect.sync(() => sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES)),
|
|
155
165
|
generateMasterKey: Effect.sync(() => sodium.randombytes_buf(sodium.crypto_aead_xchacha20poly1305_ietf_KEYBYTES)),
|
|
156
166
|
generateAdminKeyPair: Effect.sync(() => {
|
|
@@ -284,7 +294,8 @@ const StoredVault = Schema.Struct({
|
|
|
284
294
|
masterKeyHistory: Schema.Array(Schema.Struct({
|
|
285
295
|
version: Schema.Number,
|
|
286
296
|
keyB64: Schema.Uint8ArrayFromBase64
|
|
287
|
-
}))
|
|
297
|
+
})),
|
|
298
|
+
integrations: Schema.optionalWith(Schema.Array(IntegrationPin), { default: () => [] })
|
|
288
299
|
});
|
|
289
300
|
const VaultFromStored = Schema.transform(StoredVault, VaultContents, {
|
|
290
301
|
strict: true,
|
|
@@ -298,7 +309,8 @@ const VaultFromStored = Schema.transform(StoredVault, VaultContents, {
|
|
|
298
309
|
masterKeyHistory: s.masterKeyHistory.map((m) => ({
|
|
299
310
|
version: m.version,
|
|
300
311
|
key: m.keyB64
|
|
301
|
-
}))
|
|
312
|
+
})),
|
|
313
|
+
integrations: s.integrations
|
|
302
314
|
}),
|
|
303
315
|
encode: (v) => ({
|
|
304
316
|
formatVersion: 1,
|
|
@@ -311,7 +323,8 @@ const VaultFromStored = Schema.transform(StoredVault, VaultContents, {
|
|
|
311
323
|
masterKeyHistory: v.masterKeyHistory.map((m) => ({
|
|
312
324
|
version: m.version,
|
|
313
325
|
keyB64: m.key
|
|
314
|
-
}))
|
|
326
|
+
})),
|
|
327
|
+
integrations: v.integrations ?? []
|
|
315
328
|
})
|
|
316
329
|
});
|
|
317
330
|
var VaultStore = class extends Effect.Service()("cli/VaultStore", { effect: jsonFile("vault.json", VaultFromStored) }) {};
|
|
@@ -365,6 +378,8 @@ const ErrorBody = Schema.Struct({
|
|
|
365
378
|
});
|
|
366
379
|
const decodeErrorBody = Schema.decodeUnknownOption(Schema.parseJson(ErrorBody));
|
|
367
380
|
const trimSlash = (url) => url.replace(/\/+$/, "");
|
|
381
|
+
const isRetryableCreateFailure = (e) => e instanceof TransportFailure || e instanceof ApiFailure && (e.status === 503 || e.status === 409 && e.code === "idempotency_in_flight");
|
|
382
|
+
const createRetrySchedule = Schedule.exponential("1 seconds").pipe(Schedule.intersect(Schedule.recurs(5)), Schedule.whileInput(isRetryableCreateFailure));
|
|
368
383
|
var Api = class extends Effect.Service()("cli/Api", {
|
|
369
384
|
dependencies: [AuthStore.Default],
|
|
370
385
|
effect: Effect.gen(function* () {
|
|
@@ -379,10 +394,12 @@ var Api = class extends Effect.Service()("cli/Api", {
|
|
|
379
394
|
const failWith = (action, res) => res.text.pipe(Effect.orElseSucceed(() => ""), Effect.flatMap((body) => {
|
|
380
395
|
const parsed = decodeErrorBody(body);
|
|
381
396
|
const detail = Option.isSome(parsed) && parsed.value.msg ? parsed.value.msg : body || `HTTP ${res.status}`;
|
|
397
|
+
const code = Option.isSome(parsed) ? parsed.value.error : void 0;
|
|
382
398
|
return Effect.fail(new ApiFailure({
|
|
383
399
|
action,
|
|
384
400
|
status: res.status,
|
|
385
|
-
detail
|
|
401
|
+
detail,
|
|
402
|
+
code
|
|
386
403
|
}));
|
|
387
404
|
}));
|
|
388
405
|
/** Authenticated request; resolves with the (scoped) response once the
|
|
@@ -404,6 +421,13 @@ var Api = class extends Effect.Service()("cli/Api", {
|
|
|
404
421
|
session,
|
|
405
422
|
getJson: (action, pathname, schema) => requestJson(action, "GET", pathname, schema),
|
|
406
423
|
postJson: (action, pathname, schema, body) => requestJson(action, "POST", pathname, schema, body),
|
|
424
|
+
/** POST for the org create endpoints, whose bodies carry an idempotency
|
|
425
|
+
* key (minted by the SDK's buildOrg*Request): transient failures —
|
|
426
|
+
* transport errors, 503 database-unavailable, 409 idempotency_in_flight —
|
|
427
|
+
* are retried with 1s→16s backoff (~31s total, outlasting a managed-
|
|
428
|
+
* Postgres failover); the backend replays a create that already
|
|
429
|
+
* committed, so a resend never double-sends. */
|
|
430
|
+
postJsonIdempotent: (action, pathname, schema, body) => requestJson(action, "POST", pathname, schema, body).pipe(Effect.retry(createRetrySchedule)),
|
|
407
431
|
putJson: (action, pathname, schema, body) => requestJson(action, "PUT", pathname, schema, body),
|
|
408
432
|
/** Fire-and-forget variants for endpoints whose response body we ignore. */
|
|
409
433
|
post: (action, pathname, body) => Effect.scoped(Effect.asVoid(request(action, "POST", pathname, body))),
|
|
@@ -523,7 +547,7 @@ var VaultAccess = class extends Effect.Service()("cli/VaultAccess", {
|
|
|
523
547
|
const DEFAULT_BASE_URL = "https://api.simplepu.sh";
|
|
524
548
|
const toHelp = (e) => HelpDoc.p(e instanceof Error ? e.message : String(e));
|
|
525
549
|
const topicOption = Options.text("topic").pipe(Options.withAlias("t"), Options.withDescription("Topic to send to (`task`) or filter on (`events`, repeatable). Omit on `task` for a self-send to your own devices."), Options.repeated);
|
|
526
|
-
const apiTokenOption = Options.text("api-token").pipe(Options.withDescription("API token. Required for `
|
|
550
|
+
const apiTokenOption = Options.text("api-token").pipe(Options.withDescription("API token. Required for `get`; `collect`, `events`, and `subtask` fall back to the logged-in org session when omitted. Defaults to $SP_API_TOKEN."), Options.withFallbackConfig(Config.string("SP_API_TOKEN")), Options.optional);
|
|
527
551
|
/** Personal-credential commands need the token; fail typed when absent. */
|
|
528
552
|
const requireApiToken = (token) => Option.match(token, {
|
|
529
553
|
onNone: () => Effect.fail(new MissingApiToken()),
|
|
@@ -1089,6 +1113,16 @@ function resolveSince(input) {
|
|
|
1089
1113
|
if (ms !== void 0) return new Date(Date.now() - ms).toISOString();
|
|
1090
1114
|
throw new Error(`could not parse \`--since ${input}\`: expected a duration (e.g. \`24h\`, \`7d\`) or ISO 8601 timestamp`);
|
|
1091
1115
|
}
|
|
1116
|
+
/** ISO 8601 string for a FUTURE deadline (`--expires`): a humantime duration
|
|
1117
|
+
* is added to now (`2h` = two hours from now), an ISO timestamp passes
|
|
1118
|
+
* through. The server rejects deadlines in the past. */
|
|
1119
|
+
function resolveExpiresAt(input) {
|
|
1120
|
+
const iso = parseIso(input);
|
|
1121
|
+
if (iso) return iso.toISOString();
|
|
1122
|
+
const ms = parseDurationMs(input);
|
|
1123
|
+
if (ms !== void 0) return new Date(Date.now() + ms).toISOString();
|
|
1124
|
+
throw new Error(`could not parse \`--expires ${input}\`: expected a duration (e.g. \`2h\`, \`7d\`) or ISO 8601 timestamp`);
|
|
1125
|
+
}
|
|
1092
1126
|
function resolveUntil(input) {
|
|
1093
1127
|
const iso = parseIso(input);
|
|
1094
1128
|
if (iso) return iso;
|
|
@@ -1130,15 +1164,22 @@ function formatSent(groupId, createdAt, members) {
|
|
|
1130
1164
|
type: "sent",
|
|
1131
1165
|
groupId: groupId ?? null,
|
|
1132
1166
|
createdAt: createdAt ?? null,
|
|
1133
|
-
|
|
1167
|
+
instances: members.map((m) => ({
|
|
1134
1168
|
...m.kind === "notification" ? { notificationId: m.id } : { taskId: m.id },
|
|
1169
|
+
...m.subtaskId !== void 0 ? { subtaskId: m.subtaskId } : {},
|
|
1135
1170
|
recipient: m.recipient
|
|
1136
1171
|
}))
|
|
1137
1172
|
});
|
|
1138
1173
|
}
|
|
1139
|
-
/** The member instance's entity id under its kind-specific key.
|
|
1174
|
+
/** The member instance's entity id under its kind-specific key. A subtask
|
|
1175
|
+
* instance keys on its PARENT task (the demux key) and stamps its own
|
|
1176
|
+
* `subtaskId`, so every line of a subtask collect carries both. */
|
|
1140
1177
|
function instanceId(instance) {
|
|
1141
1178
|
const inst = instance;
|
|
1179
|
+
if (inst.subtaskId !== void 0 && inst.parentTaskId !== void 0) return {
|
|
1180
|
+
taskId: inst.parentTaskId,
|
|
1181
|
+
subtaskId: inst.subtaskId
|
|
1182
|
+
};
|
|
1142
1183
|
return inst.taskId !== void 0 ? { taskId: inst.taskId } : { notificationId: inst.notificationId ?? null };
|
|
1143
1184
|
}
|
|
1144
1185
|
/** A single collected item — from `replies()`, `inputs()`, the combined
|
|
@@ -1177,6 +1218,12 @@ function formatItem(g, groupId) {
|
|
|
1177
1218
|
...base,
|
|
1178
1219
|
uploads: item.uploads ?? []
|
|
1179
1220
|
});
|
|
1221
|
+
case "subtaskCompleted": return line({
|
|
1222
|
+
type: "completed",
|
|
1223
|
+
...base,
|
|
1224
|
+
subtaskId: item.subtaskId ?? null,
|
|
1225
|
+
uploads: item.uploads ?? []
|
|
1226
|
+
});
|
|
1180
1227
|
case "notificationCompleted": return line({
|
|
1181
1228
|
type: "completed",
|
|
1182
1229
|
...base,
|
|
@@ -1186,6 +1233,47 @@ function formatItem(g, groupId) {
|
|
|
1186
1233
|
type: "deleted",
|
|
1187
1234
|
...base
|
|
1188
1235
|
});
|
|
1236
|
+
case "taskCanceled": return line({
|
|
1237
|
+
type: "canceled",
|
|
1238
|
+
...base,
|
|
1239
|
+
reason: item.reason ?? null,
|
|
1240
|
+
note: item.note ?? null,
|
|
1241
|
+
supersededBy: item.supersededBy ?? null
|
|
1242
|
+
});
|
|
1243
|
+
case "subtaskCanceled": return line({
|
|
1244
|
+
type: "canceled",
|
|
1245
|
+
...base,
|
|
1246
|
+
subtaskId: item.subtaskId ?? null,
|
|
1247
|
+
reason: item.reason ?? null,
|
|
1248
|
+
note: item.note ?? null,
|
|
1249
|
+
supersededBy: item.supersededBy ?? null
|
|
1250
|
+
});
|
|
1251
|
+
case "taskDeclinedByRecipient": return line({
|
|
1252
|
+
type: "declined",
|
|
1253
|
+
...base,
|
|
1254
|
+
reason: item.reason ?? null,
|
|
1255
|
+
note: item.note ?? null
|
|
1256
|
+
});
|
|
1257
|
+
case "taskDeclined": return line({
|
|
1258
|
+
type: "all-declined",
|
|
1259
|
+
...base
|
|
1260
|
+
});
|
|
1261
|
+
case "subtaskDeclinedByRecipient": return line({
|
|
1262
|
+
type: "declined",
|
|
1263
|
+
...base,
|
|
1264
|
+
subtaskId: item.subtaskId ?? null,
|
|
1265
|
+
reason: item.reason ?? null,
|
|
1266
|
+
note: item.note ?? null
|
|
1267
|
+
});
|
|
1268
|
+
case "subtaskDeclined": return line({
|
|
1269
|
+
type: "all-declined",
|
|
1270
|
+
...base,
|
|
1271
|
+
subtaskId: item.subtaskId ?? null
|
|
1272
|
+
});
|
|
1273
|
+
case "taskExpired": return line({
|
|
1274
|
+
type: "expired",
|
|
1275
|
+
...base
|
|
1276
|
+
});
|
|
1189
1277
|
}
|
|
1190
1278
|
}
|
|
1191
1279
|
function formatSubmission(s) {
|
|
@@ -1215,14 +1303,14 @@ function fileViewsOf(item) {
|
|
|
1215
1303
|
]).filter(isFileView);
|
|
1216
1304
|
}
|
|
1217
1305
|
/** The terminal line: reason the stream stopped, per-type counts, and (for
|
|
1218
|
-
* group modes) per-
|
|
1306
|
+
* group modes) per-instance status. Always the last line on a clean run. */
|
|
1219
1307
|
function formatEnd(reason, counts, members, errorMsg) {
|
|
1220
1308
|
const obj = {
|
|
1221
1309
|
type: "end",
|
|
1222
1310
|
reason,
|
|
1223
1311
|
counts
|
|
1224
1312
|
};
|
|
1225
|
-
if (members) obj.
|
|
1313
|
+
if (members) obj.instances = members;
|
|
1226
1314
|
if (errorMsg !== void 0) obj.error = errorMsg;
|
|
1227
1315
|
return line(obj);
|
|
1228
1316
|
}
|
|
@@ -1254,11 +1342,19 @@ function formatPrettyItem(g) {
|
|
|
1254
1342
|
case "reply": return ` ${who} (reply): ${item.body?.kind === "text" ? item.body.text ?? "" : JSON.stringify(item.body)}${prettyFileSuffix(item)}`;
|
|
1255
1343
|
case "input": return ` ${who} [${item.type}]${prettyFileSuffix(item)}`;
|
|
1256
1344
|
case "taskCompleted": return ` ${who} [completed]${prettyFileSuffix(item)}`;
|
|
1345
|
+
case "subtaskCompleted": return ` ${who} [subtask completed]${prettyFileSuffix(item)}`;
|
|
1257
1346
|
case "notificationCompleted": {
|
|
1258
1347
|
const r = item.reply;
|
|
1259
1348
|
return ` ${who} [answered]${r === void 0 ? "" : r.type === "text" ? `: ${r.value}` : r.type === "choice" ? `: ${r.selectedValue}` : `: ${r.selectedKey}`}`;
|
|
1260
1349
|
}
|
|
1261
1350
|
case "taskDeleted": return ` ${who} [deleted]`;
|
|
1351
|
+
case "taskCanceled": return ` ${who} [canceled${item.reason && item.reason !== "canceled" ? `: ${item.reason}` : ""}]${item.note ? ` ${item.note}` : ""}`;
|
|
1352
|
+
case "subtaskCanceled": return ` ${who} [subtask canceled${item.reason && item.reason !== "canceled" ? `: ${item.reason}` : ""}]`;
|
|
1353
|
+
case "taskDeclinedByRecipient": return ` ${who} [declined${item.reason === "failed" ? ": failed" : ""}]${item.note ? ` ${item.note}` : ""}`;
|
|
1354
|
+
case "taskDeclined": return ` ${who} [all declined]`;
|
|
1355
|
+
case "subtaskDeclinedByRecipient": return ` ${who} [subtask declined${item.reason === "failed" ? ": failed" : ""}]${item.note ? ` ${item.note}` : ""}`;
|
|
1356
|
+
case "subtaskDeclined": return ` ${who} [subtask all declined]`;
|
|
1357
|
+
case "taskExpired": return ` ${who} [expired]`;
|
|
1262
1358
|
}
|
|
1263
1359
|
}
|
|
1264
1360
|
function formatPrettySubmission(s) {
|
|
@@ -1367,9 +1463,10 @@ const SentLine = Schema.Struct({
|
|
|
1367
1463
|
type: Schema.Literal("sent"),
|
|
1368
1464
|
groupId: Schema.optional(Schema.NullOr(Schema.String)),
|
|
1369
1465
|
createdAt: Schema.optional(Schema.NullOr(Schema.String)),
|
|
1370
|
-
|
|
1466
|
+
instances: Schema.optional(Schema.Array(Schema.Struct({
|
|
1371
1467
|
taskId: Schema.optional(Schema.String),
|
|
1372
1468
|
notificationId: Schema.optional(Schema.String),
|
|
1469
|
+
subtaskId: Schema.optional(Schema.String),
|
|
1373
1470
|
recipient: Schema.optional(Schema.NullOr(Schema.Struct({
|
|
1374
1471
|
publicId: Schema.String,
|
|
1375
1472
|
name: Schema.optional(Schema.NullOr(Schema.String))
|
|
@@ -1428,9 +1525,9 @@ const loadOrgMasterKeys = Effect.gen(function* () {
|
|
|
1428
1525
|
});
|
|
1429
1526
|
const collectCommand = Command.make("collect", {
|
|
1430
1527
|
group: Options.text("group").pipe(Options.withDescription("Group id (grptsk_…) to collect over. Usually supplied via the piped `sent` line instead."), Options.optional),
|
|
1431
|
-
instance: Options.text("instance").pipe(Options.withDescription("
|
|
1528
|
+
instance: Options.text("instance").pipe(Options.withDescription("Instance id to collect: a task (tsk_…), a notification (ntf_…), or a subtask-scoped pair (tsk_…/sub_…). Repeatable. Augments/overrides the piped `sent` line's instances."), Options.repeated),
|
|
1432
1529
|
replies: Options.boolean("replies").pipe(Options.withDescription("Collect only replies. Default (no mode flag) is the full activity stream: inputs, replies, and completions.")),
|
|
1433
|
-
inputs: Options.boolean("inputs").pipe(Options.withDescription("Collect only input events (waits for every
|
|
1530
|
+
inputs: Options.boolean("inputs").pipe(Options.withDescription("Collect only input events (waits for every instance to complete by default).")),
|
|
1434
1531
|
submissions: Options.boolean("submissions").pipe(Options.withDescription("Collect submissions (your inbox) instead of a group's events.")),
|
|
1435
1532
|
since: mappedText("since", resolveSince).pipe(Options.withDescription("Resume point (`24h`, `7d`, or ISO 8601). Backfills group events or submissions from that point; defaults to the send's createdAt from the piped `sent` line. Implies --direct (the broker can't serve a deep backfill)."), Options.optional),
|
|
1436
1533
|
until: Options.text("until").pipe(Options.withDescription("Stop condition. Repeatable: complete | idle:<dur> | count:<n> | timeout:<dur> | forever (never stop; Ctrl-C to end). Default: complete for group collects; --replies / --submissions watch forever."), Options.repeated),
|
|
@@ -1491,24 +1588,32 @@ const collectCommand = Command.make("collect", {
|
|
|
1491
1588
|
const createdAt = sinceIso ?? sent?.createdAt ?? void 0;
|
|
1492
1589
|
const members = [];
|
|
1493
1590
|
const seen = /* @__PURE__ */ new Set();
|
|
1494
|
-
const addMember = (id, recipient) => {
|
|
1495
|
-
|
|
1496
|
-
|
|
1591
|
+
const addMember = (id, recipient, subtaskId) => {
|
|
1592
|
+
const key = subtaskId !== void 0 ? `${id}/${subtaskId}` : id;
|
|
1593
|
+
if (id && key && !seen.has(key)) {
|
|
1594
|
+
seen.add(key);
|
|
1497
1595
|
members.push({
|
|
1498
1596
|
id,
|
|
1499
1597
|
kind: id.startsWith("ntf_") ? "notification" : "task",
|
|
1500
|
-
recipient
|
|
1598
|
+
recipient,
|
|
1599
|
+
...subtaskId !== void 0 ? { subtaskId } : {}
|
|
1501
1600
|
});
|
|
1502
1601
|
}
|
|
1503
1602
|
};
|
|
1504
|
-
for (const m of sent?.
|
|
1603
|
+
for (const m of sent?.instances ?? []) addMember(m.taskId ?? m.notificationId, m.recipient ? {
|
|
1505
1604
|
publicId: m.recipient.publicId,
|
|
1506
1605
|
name: m.recipient.name ?? null
|
|
1507
|
-
} : null);
|
|
1508
|
-
for (const
|
|
1509
|
-
|
|
1606
|
+
} : null, m.subtaskId);
|
|
1607
|
+
for (const spec of args.instance) {
|
|
1608
|
+
const [id, subtaskId] = spec.split("/", 2);
|
|
1609
|
+
if (id.startsWith("sub_")) return yield* Effect.fail(new UserError({ message: `a subtask cannot be collected by its id alone (events route under the parent task) — pass --instance <parent tsk_…>/${id}` }));
|
|
1610
|
+
addMember(id, null, subtaskId);
|
|
1611
|
+
}
|
|
1612
|
+
if (members.length === 0) return yield* Effect.fail(new UserError({ message: "no instances to collect: pipe a send's `sent` line (`sp task --format json | sp collect`) or pass --instance <tsk_…|ntf_…|tsk_…/sub_…> (repeatable)" }));
|
|
1510
1613
|
const notifRoster = members[0].kind === "notification";
|
|
1511
|
-
if (members.some((m) => m.kind === "notification" !== notifRoster)) return yield* Effect.fail(new UserError({ message: "cannot mix task (tsk_…) and notification (ntf_…)
|
|
1614
|
+
if (members.some((m) => m.kind === "notification" !== notifRoster)) return yield* Effect.fail(new UserError({ message: "cannot mix task (tsk_…) and notification (ntf_…) instances in one collect — run one per kind" }));
|
|
1615
|
+
const subRoster = members[0].subtaskId !== void 0;
|
|
1616
|
+
if (members.some((m) => m.subtaskId !== void 0 !== subRoster)) return yield* Effect.fail(new UserError({ message: "cannot mix subtask-scoped instances (a piped `sp subtask --format json` line or --instance tsk_…/sub_…) with plain task/notification instances in one collect" }));
|
|
1512
1617
|
const streamOpts = {
|
|
1513
1618
|
replay: true,
|
|
1514
1619
|
...until.idleMs !== void 0 ? { idleMs: until.idleMs } : {}
|
|
@@ -1529,6 +1634,26 @@ const collectCommand = Command.make("collect", {
|
|
|
1529
1634
|
...streamOpts,
|
|
1530
1635
|
signal
|
|
1531
1636
|
});
|
|
1637
|
+
} else if (subRoster) {
|
|
1638
|
+
const group = client.watchSubtaskGroup({
|
|
1639
|
+
groupId: watchGroupId,
|
|
1640
|
+
...createdAt ? { createdAt } : {},
|
|
1641
|
+
members: members.map((m) => ({
|
|
1642
|
+
taskId: m.id,
|
|
1643
|
+
subtaskId: m.subtaskId,
|
|
1644
|
+
...m.recipient ? { recipient: m.recipient } : {}
|
|
1645
|
+
}))
|
|
1646
|
+
});
|
|
1647
|
+
source = (signal) => mode === "inputs" ? group.inputs({
|
|
1648
|
+
...streamOpts,
|
|
1649
|
+
signal
|
|
1650
|
+
}) : mode === "replies" ? group.replies({
|
|
1651
|
+
...streamOpts,
|
|
1652
|
+
signal
|
|
1653
|
+
}) : group.activity({
|
|
1654
|
+
...streamOpts,
|
|
1655
|
+
signal
|
|
1656
|
+
});
|
|
1532
1657
|
} else {
|
|
1533
1658
|
const group = client.watchTaskGroup({
|
|
1534
1659
|
groupId: watchGroupId,
|
|
@@ -1550,7 +1675,7 @@ const collectCommand = Command.make("collect", {
|
|
|
1550
1675
|
});
|
|
1551
1676
|
}
|
|
1552
1677
|
if (args.format === "json") yield* out.print(formatSent(groupId, createdAt, members));
|
|
1553
|
-
else yield* out.info(`collecting ${mode} over ${members.length}
|
|
1678
|
+
else yield* out.info(`collecting ${mode} over ${members.length} instance(s)${groupId ? ` of ${groupId}` : ""}`);
|
|
1554
1679
|
yield* collectGroup(watchGroupId, source, members, args.format, until, saveDir);
|
|
1555
1680
|
}));
|
|
1556
1681
|
}));
|
|
@@ -1593,26 +1718,34 @@ const collectGroup = (groupId, source, members, format, until, saveDir) => Effec
|
|
|
1593
1718
|
const counts = yield* Ref.make({});
|
|
1594
1719
|
const completed = yield* Ref.make(HashSet.empty());
|
|
1595
1720
|
const deleted = yield* Ref.make(HashSet.empty());
|
|
1721
|
+
const canceled = yield* Ref.make(HashSet.empty());
|
|
1722
|
+
const declined = yield* Ref.make(HashSet.empty());
|
|
1723
|
+
const expired = yield* Ref.make(HashSet.empty());
|
|
1596
1724
|
const total = yield* Ref.make(0);
|
|
1597
1725
|
const doneCount = Effect.gen(function* () {
|
|
1598
|
-
return HashSet.size(yield* Ref.get(completed)) + HashSet.size(yield* Ref.get(deleted));
|
|
1726
|
+
return HashSet.size(yield* Ref.get(completed)) + HashSet.size(yield* Ref.get(deleted)) + HashSet.size(yield* Ref.get(canceled)) + HashSet.size(yield* Ref.get(declined)) + HashSet.size(yield* Ref.get(expired));
|
|
1599
1727
|
});
|
|
1600
1728
|
const instanceIdOf = (g) => {
|
|
1601
1729
|
const inst = g.instance;
|
|
1730
|
+
if (inst.subtaskId !== void 0) return `${inst.parentTaskId}/${inst.subtaskId}`;
|
|
1602
1731
|
return inst.taskId ?? inst.notificationId ?? "";
|
|
1603
1732
|
};
|
|
1733
|
+
const subScoped = (g) => g.instance.subtaskId !== void 0;
|
|
1604
1734
|
const failed = yield* sdkStream("collect stream", source).pipe(Stream.tap((g) => Effect.gen(function* () {
|
|
1605
1735
|
yield* saveFiles(g.item, saveDir);
|
|
1606
1736
|
yield* out.print(format === "json" ? formatItem(g, groupId) : formatPrettyItem(g));
|
|
1607
1737
|
yield* Ref.update(total, (n) => n + 1);
|
|
1608
1738
|
const kind = g.item.kind;
|
|
1609
|
-
const label = kind === "reply" ? "reply" : kind === "input" ? "input" : kind === "taskDeleted" ? "deleted" : "completed";
|
|
1739
|
+
const label = kind === "reply" ? "reply" : kind === "input" ? "input" : kind === "taskDeleted" ? "deleted" : kind === "taskCanceled" || kind === "subtaskCanceled" ? "canceled" : kind === "taskDeclinedByRecipient" || kind === "subtaskDeclinedByRecipient" ? "declined" : kind === "taskDeclined" || kind === "subtaskDeclined" ? "all-declined" : kind === "taskExpired" ? "expired" : "completed";
|
|
1610
1740
|
yield* Ref.update(counts, (c) => ({
|
|
1611
1741
|
...c,
|
|
1612
1742
|
[label]: (c[label] ?? 0) + 1
|
|
1613
1743
|
}));
|
|
1614
|
-
if (kind === "taskCompleted" || kind === "notificationCompleted") yield* Ref.update(completed, HashSet.add(instanceIdOf(g)));
|
|
1744
|
+
if (kind === "taskCompleted" || kind === "notificationCompleted" || kind === "subtaskCompleted") yield* Ref.update(completed, HashSet.add(instanceIdOf(g)));
|
|
1615
1745
|
if (kind === "taskDeleted") yield* Ref.update(deleted, HashSet.add(instanceIdOf(g)));
|
|
1746
|
+
if (kind === "taskCanceled" || kind === "subtaskCanceled" && subScoped(g)) yield* Ref.update(canceled, HashSet.add(instanceIdOf(g)));
|
|
1747
|
+
if (kind === "taskDeclined" || kind === "subtaskDeclined" && subScoped(g)) yield* Ref.update(declined, HashSet.add(instanceIdOf(g)));
|
|
1748
|
+
if (kind === "taskExpired") yield* Ref.update(expired, HashSet.add(instanceIdOf(g)));
|
|
1616
1749
|
})), until.count !== void 0 ? Stream.takeUntilEffect(() => Effect.gen(function* () {
|
|
1617
1750
|
if ((yield* Ref.get(total)) < until.count) return false;
|
|
1618
1751
|
yield* reason.set("count");
|
|
@@ -1632,6 +1765,9 @@ const collectGroup = (groupId, source, members, format, until, saveDir) => Effec
|
|
|
1632
1765
|
total: members.length,
|
|
1633
1766
|
completed: HashSet.size(yield* Ref.get(completed)),
|
|
1634
1767
|
deleted: HashSet.size(yield* Ref.get(deleted)),
|
|
1768
|
+
canceled: HashSet.size(yield* Ref.get(canceled)),
|
|
1769
|
+
declined: HashSet.size(yield* Ref.get(declined)),
|
|
1770
|
+
expired: HashSet.size(yield* Ref.get(expired)),
|
|
1635
1771
|
pending: members.length - done
|
|
1636
1772
|
};
|
|
1637
1773
|
if (format === "json") yield* out.print(formatEnd(why, yield* Ref.get(counts), memberStatus, why === "error" ? "collect stream failed" : void 0));
|
|
@@ -1639,6 +1775,117 @@ const collectGroup = (groupId, source, members, format, until, saveDir) => Effec
|
|
|
1639
1775
|
if (failed) return yield* Effect.fail(new Aborted());
|
|
1640
1776
|
});
|
|
1641
1777
|
//#endregion
|
|
1778
|
+
//#region src/commands/cancel.ts
|
|
1779
|
+
const idArg$2 = Args.text({ name: "id" }).pipe(Args.withDescription("What to cancel: a task (`tsk_…`), a subtask (`sub_…`), or a task group (`grptsk_…`)."));
|
|
1780
|
+
const reasonOption = Options.choice("reason", [
|
|
1781
|
+
"canceled",
|
|
1782
|
+
"answered",
|
|
1783
|
+
"superseded"
|
|
1784
|
+
]).pipe(Options.withDescription("Why: plain withdrawal (default), another recipient's answer made the rest moot (`answered`), or a replacement exists (`superseded`)."), Options.withDefault("canceled"));
|
|
1785
|
+
const noteOption = Options.text("note").pipe(Options.withDescription("Free-text explanation shown on the recipients' canceled card. Encrypted under the org master key on the org path (default when enabled); on the personal path seal it with --password pw@topic (topic send) or a bare --password (self-send, account default key)."), Options.optional);
|
|
1786
|
+
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
|
+
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
|
+
const cancelCommand = Command.make("cancel", {
|
|
1789
|
+
id: idArg$2,
|
|
1790
|
+
reason: reasonOption,
|
|
1791
|
+
note: noteOption,
|
|
1792
|
+
"superseded-by": supersededByOption,
|
|
1793
|
+
password: passwordOption,
|
|
1794
|
+
"no-encrypt": noEncryptOption$3,
|
|
1795
|
+
"api-token": apiTokenOption,
|
|
1796
|
+
"base-url": baseUrlOption,
|
|
1797
|
+
quiet: quietOption
|
|
1798
|
+
}, (args) => Effect.gen(function* () {
|
|
1799
|
+
const out = yield* CliOutput;
|
|
1800
|
+
yield* out.setQuiet(args.quiet);
|
|
1801
|
+
const note = Option.getOrUndefined(args.note);
|
|
1802
|
+
const supersededBy = Option.getOrUndefined(args["superseded-by"]);
|
|
1803
|
+
if (supersededBy !== void 0 && args.reason !== "superseded") return yield* Effect.fail(new UserError({ message: "--superseded-by requires --reason superseded" }));
|
|
1804
|
+
const credential = yield* resolveCredential(args["api-token"], args["base-url"]);
|
|
1805
|
+
let noteFields = {};
|
|
1806
|
+
if (note !== void 0) if (credential.kind === "org") {
|
|
1807
|
+
const vault = yield* (yield* VaultAccess).forSendOrPlaintext(args["no-encrypt"]);
|
|
1808
|
+
if (vault) noteFields = {
|
|
1809
|
+
note: yield* sdkCall("encrypt note", () => encrypt(vault.masterKeyCurrent.key, note)),
|
|
1810
|
+
encryption: {
|
|
1811
|
+
type: "org",
|
|
1812
|
+
v: vault.masterKeyCurrent.version
|
|
1813
|
+
}
|
|
1814
|
+
};
|
|
1815
|
+
else {
|
|
1816
|
+
noteFields = { note };
|
|
1817
|
+
yield* out.warn("note sent unencrypted (org encryption disabled or --no-encrypt)");
|
|
1818
|
+
}
|
|
1819
|
+
} else {
|
|
1820
|
+
const passwords = args.password;
|
|
1821
|
+
if (passwords.length > 1) return yield* Effect.fail(new UserError({ message: "pass exactly one --password so the note's key is unambiguous" }));
|
|
1822
|
+
const flag = passwords[0];
|
|
1823
|
+
if (flag === void 0) {
|
|
1824
|
+
noteFields = { note };
|
|
1825
|
+
yield* out.warn("note sent unencrypted — seal it with --password <pw>@<topic> (topic send) or a bare --password (self-send)");
|
|
1826
|
+
} else if (Array.isArray(flag)) {
|
|
1827
|
+
const derived = yield* sdkCall("derive note key", () => deriveKey(flag[0], flag[1]));
|
|
1828
|
+
noteFields = {
|
|
1829
|
+
note: yield* sdkCall("encrypt note", () => encrypt(derived.symmetricKey, note)),
|
|
1830
|
+
encryption: {
|
|
1831
|
+
type: "personal",
|
|
1832
|
+
keyFingerprint: derived.fingerprint
|
|
1833
|
+
}
|
|
1834
|
+
};
|
|
1835
|
+
} else {
|
|
1836
|
+
const info = yield* sdkCall("fetch password salt", () => fetchUserInfo({
|
|
1837
|
+
baseUrl: new URL(credential.baseUrl),
|
|
1838
|
+
apiToken: credential.apiToken,
|
|
1839
|
+
fetch
|
|
1840
|
+
}));
|
|
1841
|
+
const derived = yield* sdkCall("derive note key", () => deriveKey(flag, info.passwordSalt));
|
|
1842
|
+
noteFields = {
|
|
1843
|
+
note: yield* sdkCall("encrypt note", () => encrypt(derived.symmetricKey, note)),
|
|
1844
|
+
encryption: {
|
|
1845
|
+
type: "personal",
|
|
1846
|
+
keyFingerprint: derived.fingerprint
|
|
1847
|
+
}
|
|
1848
|
+
};
|
|
1849
|
+
}
|
|
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` }));
|
|
1887
|
+
})).pipe(Command.withDescription("Cancel a pending task, subtask, or task group you sent (sender-side withdrawal)."));
|
|
1888
|
+
//#endregion
|
|
1642
1889
|
//#region src/commands/daemon.ts
|
|
1643
1890
|
const resolveDaemonCredential = (tokenOpt) => Effect.gen(function* () {
|
|
1644
1891
|
if (Option.isSome(tokenOpt)) return {
|
|
@@ -1670,21 +1917,36 @@ const daemonCommand = Command.make("daemon", {
|
|
|
1670
1917
|
}));
|
|
1671
1918
|
//#endregion
|
|
1672
1919
|
//#region src/commands/download.ts
|
|
1673
|
-
const
|
|
1920
|
+
const ID_GLOSSARY = "(ids: tsk_ task, sub_ subtask, sbm_ submission; files: inp_ input upload, rfl_ reply file, sbf_ submission file)";
|
|
1674
1921
|
const resolveScope = (scopeId, fileId) => {
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
const
|
|
1679
|
-
if (
|
|
1680
|
-
if (
|
|
1681
|
-
return Effect.succeed(
|
|
1922
|
+
const scope = scopeId.startsWith("tsk_") ? "task" : scopeId.startsWith("sub_") ? "subtask" : scopeId.startsWith("sbm_") ? "submission" : void 0;
|
|
1923
|
+
if (scope === void 0) return Effect.fail(new UserError({ message: `expected a task (tsk_…), subtask (sub_…), or submission (sbm_…) scope id, got '${scopeId}' ${ID_GLOSSARY}` }));
|
|
1924
|
+
const wantsChain = fileId.startsWith("inp_") || fileId.startsWith("rfl_");
|
|
1925
|
+
const wantsSubmission = fileId.startsWith("sbf_");
|
|
1926
|
+
if (!wantsChain && !wantsSubmission) return Effect.fail(new UserError({ message: `expected an input upload (inp_…), reply file (rfl_…), or submission file (sbf_…) id, got '${fileId}' ${ID_GLOSSARY}` }));
|
|
1927
|
+
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
|
+
return Effect.succeed(scope);
|
|
1929
|
+
};
|
|
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
|
+
/** Resolve where to write: an explicit file path, an existing directory (the
|
|
1936
|
+
* server-declared filename inside it), or the current directory. */
|
|
1937
|
+
const resolveTarget = async (outArg, filename) => {
|
|
1938
|
+
if (outArg === void 0) return path.join(process.cwd(), filename);
|
|
1939
|
+
try {
|
|
1940
|
+
if ((await stat(outArg)).isDirectory()) return path.join(outArg, filename);
|
|
1941
|
+
} catch {
|
|
1942
|
+
await mkdir(path.dirname(outArg), { recursive: true });
|
|
1943
|
+
}
|
|
1944
|
+
return outArg;
|
|
1682
1945
|
};
|
|
1683
1946
|
const downloadCommand = Command.make("download", {
|
|
1684
|
-
scopeId: Args.text({ name: "scope-id" }).pipe(Args.withDescription("The containing entity: a task (tsk_
|
|
1947
|
+
scopeId: Args.text({ name: "scope-id" }).pipe(Args.withDescription("The containing entity: a task (tsk_…), a subtask (sub_…), or a submission (sbm_…) — the id the collect line carries.")),
|
|
1685
1948
|
fileId: Args.text({ name: "file-id" }).pipe(Args.withDescription("The file to download: an input upload (inp_…), reply file (rfl_…), or submission file (sbf_…) — the `id` on the line's file object.")),
|
|
1686
1949
|
out: Options.text("out").pipe(Options.withDescription("Where to save: a file path, an existing directory (the upload's filename is used inside it), or omitted for the current directory."), Options.optional),
|
|
1687
|
-
since: Options.text("since").pipe(Options.withDescription("How far back to search the event stream for the file (`24h`, `90d`, or ISO 8601). Default: 7d."), Options.withDefault("7d")),
|
|
1688
1950
|
format: Options.choice("format", ["json", "pretty"]).pipe(Options.withDescription("Output format: json (one `downloaded` line) or pretty (the saved path)."), Options.withDefault("json")),
|
|
1689
1951
|
"api-token": apiTokenOption,
|
|
1690
1952
|
password: passwordOption,
|
|
@@ -1694,67 +1956,91 @@ const downloadCommand = Command.make("download", {
|
|
|
1694
1956
|
const out = yield* CliOutput;
|
|
1695
1957
|
yield* out.setQuiet(args.quiet);
|
|
1696
1958
|
const scope = yield* resolveScope(args.scopeId, args.fileId);
|
|
1697
|
-
const sinceIso = resolveSince(args.since);
|
|
1698
1959
|
const cred = yield* resolveCredential(args["api-token"], args["base-url"]);
|
|
1699
1960
|
const orgKeys = cred.kind === "org" ? yield* loadOrgMasterKeys : void 0;
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
if (
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1961
|
+
const baseUrl = cred.baseUrl.replace(/\/+$/, "");
|
|
1962
|
+
const authHeaders = cred.kind === "personal" ? { "API-Token": cred.apiToken } : { Authorization: `Bearer ${cred.bearer}` };
|
|
1963
|
+
const meta = yield* Effect.tryPromise({
|
|
1964
|
+
try: async () => {
|
|
1965
|
+
const res = await fetch(`${baseUrl}${downloadUrlPath(scope, args.scopeId, args.fileId)}`, {
|
|
1966
|
+
method: "POST",
|
|
1967
|
+
headers: authHeaders
|
|
1968
|
+
});
|
|
1969
|
+
if (!res.ok) {
|
|
1970
|
+
const body = await res.text().catch(() => "");
|
|
1971
|
+
let msg = `download-url failed (${res.status})`;
|
|
1972
|
+
try {
|
|
1973
|
+
const parsed = JSON.parse(body);
|
|
1974
|
+
if (parsed.msg) msg = `${msg}: ${parsed.msg}`;
|
|
1975
|
+
} catch {
|
|
1976
|
+
if (body) msg = `${msg}: ${body.slice(0, 200)}`;
|
|
1977
|
+
}
|
|
1978
|
+
throw new Error(msg);
|
|
1979
|
+
}
|
|
1980
|
+
return await res.json();
|
|
1981
|
+
},
|
|
1982
|
+
catch: (e) => new UserError({ message: e instanceof Error ? e.message : String(e) })
|
|
1983
|
+
});
|
|
1984
|
+
const stored = yield* Effect.tryPromise({
|
|
1985
|
+
try: async () => {
|
|
1986
|
+
const res = await fetch(meta.presignedGetUrl);
|
|
1987
|
+
if (!res.ok) throw new Error(`fetching the file failed (${res.status})`);
|
|
1988
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
1989
|
+
},
|
|
1990
|
+
catch: (e) => new UserError({ message: e instanceof Error ? e.message : String(e) })
|
|
1991
|
+
});
|
|
1992
|
+
if (meta.checksumSha256 !== void 0) {
|
|
1993
|
+
const actual = createHash("sha256").update(stored).digest("base64");
|
|
1994
|
+
if (actual !== meta.checksumSha256) return yield* Effect.fail(new UserError({ message: `checksum mismatch: stored blob hashes to ${actual}, server declared ${meta.checksumSha256}` }));
|
|
1995
|
+
}
|
|
1996
|
+
let bytes = stored;
|
|
1997
|
+
if (meta.encryption !== void 0) {
|
|
1998
|
+
const passwords = [...args.password];
|
|
1999
|
+
const passwordSalt = passwords.filter((p) => typeof p === "string").length > 0 && cred.kind === "personal" ? yield* Effect.tryPromise({
|
|
2000
|
+
try: async () => (await fetchUserInfo({
|
|
2001
|
+
baseUrl: new URL(baseUrl),
|
|
2002
|
+
apiToken: cred.apiToken,
|
|
2003
|
+
fetch
|
|
2004
|
+
})).passwordSalt,
|
|
2005
|
+
catch: (e) => new UserError({ message: `fetching the password salt failed: ${e instanceof Error ? e.message : String(e)}` })
|
|
2006
|
+
}) : void 0;
|
|
2007
|
+
const key = yield* Effect.tryPromise({
|
|
2008
|
+
try: async () => {
|
|
2009
|
+
const ring = await Keyring.build({
|
|
2010
|
+
passwords: [],
|
|
2011
|
+
topics: [],
|
|
2012
|
+
...orgKeys !== void 0 ? { orgMasterKeys: orgKeys } : {}
|
|
2013
|
+
});
|
|
2014
|
+
for (const p of passwords) if (typeof p !== "string") ring.add(await deriveKey(p[0], p[1]));
|
|
2015
|
+
else if (passwordSalt !== void 0) ring.add(await deriveKey(p, passwordSalt));
|
|
2016
|
+
return ring.keyForMarker(meta.encryption);
|
|
2017
|
+
},
|
|
2018
|
+
catch: (e) => new UserError({ message: `building the keyring failed: ${e instanceof Error ? e.message : String(e)}` })
|
|
1720
2019
|
});
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
const fileStream = scope === "task" ? countSeen(sdkStream("task activity", (signal) => client.watchTaskGroup({
|
|
1726
|
-
groupId: args.scopeId,
|
|
1727
|
-
createdAt: sinceIso,
|
|
1728
|
-
members: [{ taskId: args.scopeId }]
|
|
1729
|
-
}).activity({
|
|
1730
|
-
replay: true,
|
|
1731
|
-
idleMs: IDLE_MS,
|
|
1732
|
-
signal
|
|
1733
|
-
}))).pipe(Stream.filterMap((g) => matching(g.item))) : countSeen(sdkStream("submissions stream", (signal) => client.submissions({
|
|
1734
|
-
signal,
|
|
1735
|
-
since: sinceIso,
|
|
1736
|
-
idleMs: IDLE_MS
|
|
1737
|
-
}))).pipe(Stream.filterMap((s) => s.id === args.scopeId ? matching(s) : Option.none()));
|
|
1738
|
-
const found = yield* Stream.runHead(fileStream).pipe(Effect.mapError((e) => new UserError({ message: `event stream failed: ${e.cause instanceof Error ? e.cause.message : String(e.cause)} — check --base-url and the credential` })));
|
|
1739
|
-
if (Option.isNone(found)) {
|
|
1740
|
-
const message = health.lastError !== void 0 ? `could not read the event stream (${health.lastError.message}) — check --base-url and the credential` : health.eventsSeen === 0 ? `no events for ${args.scopeId} arrived since ${args.since} — check the ids and --base-url, or widen the search with --since (e.g. --since 90d)` : `${args.fileId} not seen on ${args.scopeId} since ${args.since} (${health.eventsSeen} events replayed) — check the file id, or widen the search with --since (e.g. --since 90d)`;
|
|
1741
|
-
return yield* Effect.fail(new UserError({ message }));
|
|
1742
|
-
}
|
|
1743
|
-
const file = found.value;
|
|
1744
|
-
const path = yield* Effect.tryPromise({
|
|
1745
|
-
try: () => file.save(Option.getOrUndefined(args.out)),
|
|
1746
|
-
catch: (e) => new UserError({ message: `download failed: ${e instanceof Error ? e.message : String(e)}` })
|
|
2020
|
+
if (key === void 0) return yield* Effect.fail(new UserError({ message: meta.encryption.type === "org" ? `the file is sealed under org master_key v${meta.encryption.v} — unlock the vault (org session) to decrypt` : "the file is encrypted — pass the matching -p/--password (`pw@topic`, or a bare account password)" }));
|
|
2021
|
+
bytes = yield* Effect.tryPromise({
|
|
2022
|
+
try: () => decryptBytes(key, stored),
|
|
2023
|
+
catch: (e) => new UserError({ message: `decryption failed: ${e instanceof Error ? e.message : String(e)}` })
|
|
1747
2024
|
});
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
}
|
|
1756
|
-
|
|
2025
|
+
}
|
|
2026
|
+
const filename = meta.filename ?? args.fileId;
|
|
2027
|
+
const target = yield* Effect.tryPromise({
|
|
2028
|
+
try: async () => {
|
|
2029
|
+
const t = await resolveTarget(Option.getOrUndefined(args.out), filename);
|
|
2030
|
+
await writeFile(t, bytes);
|
|
2031
|
+
return t;
|
|
2032
|
+
},
|
|
2033
|
+
catch: (e) => new UserError({ message: `saving failed: ${e instanceof Error ? e.message : String(e)}` })
|
|
2034
|
+
});
|
|
2035
|
+
if (args.format === "json") yield* out.print(JSON.stringify({
|
|
2036
|
+
type: "downloaded",
|
|
2037
|
+
id: args.fileId,
|
|
2038
|
+
path: target,
|
|
2039
|
+
filename: meta.filename ?? null,
|
|
2040
|
+
contentType: meta.contentType ?? null,
|
|
2041
|
+
size: meta.size ?? null
|
|
1757
2042
|
}));
|
|
2043
|
+
else yield* out.print(target);
|
|
1758
2044
|
}));
|
|
1759
2045
|
//#endregion
|
|
1760
2046
|
//#region src/output.ts
|
|
@@ -1775,7 +2061,7 @@ function formatEvent(event, format, decrypted) {
|
|
|
1775
2061
|
if (a.devicePublicId || a.deviceName) lines.push(` device: ${a.deviceName ? `${a.deviceName} (${a.devicePublicId ?? "?"})` : a.devicePublicId}`);
|
|
1776
2062
|
}
|
|
1777
2063
|
if (event.encryption) {
|
|
1778
|
-
const enc = event.encryption.type === "personal" ? `personal (${event.encryption.
|
|
2064
|
+
const enc = event.encryption.type === "personal" ? `personal (${event.encryption.keyFingerprint})` : `org (v${event.encryption.v})`;
|
|
1779
2065
|
lines.push(` encryption: ${enc}`);
|
|
1780
2066
|
}
|
|
1781
2067
|
if (decrypted !== void 0) lines.push(` decrypted: ${prettyValue(decrypted)}`);
|
|
@@ -1817,20 +2103,20 @@ const sinceOption = mappedText("since", resolveSince).pipe(Options.withDescripti
|
|
|
1817
2103
|
const untilOption = mappedText("until", resolveUntil).pipe(Options.withDescription("Stop at this timestamp. Forces a finite range, so `--follow` is ignored."), Options.optional);
|
|
1818
2104
|
const limitOption = Options.integer("limit").pipe(Options.withDescription("Maximum number of events to print, then exit."), Options.optional);
|
|
1819
2105
|
const followOption = Options.boolean("follow").pipe(Options.withAlias("f"), Options.withDescription("After history is drained, keep streaming live instead of exiting. Only meaningful with `--since`."));
|
|
1820
|
-
const formatOption$
|
|
2106
|
+
const formatOption$3 = Options.choice("format", [
|
|
1821
2107
|
"json",
|
|
1822
2108
|
"pretty",
|
|
1823
2109
|
"raw"
|
|
1824
2110
|
]).pipe(Options.withDescription("Output format."), Options.withDefault("json"));
|
|
1825
2111
|
const directOption = Options.boolean("direct").pipe(Options.withDescription("Open an independent WS connection. By default a LIVE stream (no --since) shares ONE broker connection across all sp processes; --direct bypasses it. A --since history replay always goes direct."));
|
|
1826
|
-
const
|
|
2112
|
+
const QUIET_EXIT_MS = 2e3;
|
|
1827
2113
|
const eventsCommand = Command.make("events", {
|
|
1828
2114
|
type: eventTypeOption,
|
|
1829
2115
|
since: sinceOption,
|
|
1830
2116
|
until: untilOption,
|
|
1831
2117
|
limit: limitOption,
|
|
1832
2118
|
follow: followOption,
|
|
1833
|
-
format: formatOption$
|
|
2119
|
+
format: formatOption$3,
|
|
1834
2120
|
direct: directOption,
|
|
1835
2121
|
topic: topicOption,
|
|
1836
2122
|
"api-token": apiTokenOption,
|
|
@@ -1840,52 +2126,83 @@ const eventsCommand = Command.make("events", {
|
|
|
1840
2126
|
}, (args) => Effect.gen(function* () {
|
|
1841
2127
|
const out = yield* CliOutput;
|
|
1842
2128
|
yield* out.setQuiet(args.quiet);
|
|
1843
|
-
const
|
|
1844
|
-
const baseUrl =
|
|
2129
|
+
const cred = yield* resolveCredential(args["api-token"], args["base-url"]);
|
|
2130
|
+
const baseUrl = cred.baseUrl;
|
|
1845
2131
|
const sinceIso = Option.getOrUndefined(args.since);
|
|
1846
2132
|
const untilDate = Option.getOrUndefined(args.until);
|
|
1847
2133
|
const limit = Option.getOrUndefined(args.limit);
|
|
1848
2134
|
const filter = new TypeFilter(args.type);
|
|
1849
2135
|
yield* Effect.forEach(filter.unknown, (u) => out.warn(`ignoring unknown --type \`${u}\``));
|
|
1850
2136
|
const webSocketFactory = args.direct || sinceIso !== void 0 ? Option.none() : yield* sharedWebSocketFactory({
|
|
1851
|
-
credential: {
|
|
2137
|
+
credential: cred.kind === "personal" ? {
|
|
1852
2138
|
kind: "personal",
|
|
1853
|
-
apiToken
|
|
2139
|
+
apiToken: cred.apiToken
|
|
2140
|
+
} : {
|
|
2141
|
+
kind: "org",
|
|
2142
|
+
bearer: cred.bearer
|
|
1854
2143
|
},
|
|
1855
2144
|
baseUrl
|
|
1856
2145
|
});
|
|
2146
|
+
const factoryConfig = Option.match(webSocketFactory, {
|
|
2147
|
+
onNone: () => ({}),
|
|
2148
|
+
onSome: (f) => ({ webSocketFactory: f })
|
|
2149
|
+
});
|
|
2150
|
+
const orgKeys = cred.kind === "org" ? yield* loadOrgMasterKeys : void 0;
|
|
2151
|
+
if (cred.kind === "org") yield* out.info(`streaming via org session (${baseUrl})`);
|
|
1857
2152
|
yield* Effect.scoped(Effect.gen(function* () {
|
|
1858
|
-
const client = yield* acquireClient({
|
|
2153
|
+
const client = cred.kind === "personal" ? yield* acquireClient({
|
|
1859
2154
|
baseUrl,
|
|
1860
|
-
apiToken,
|
|
2155
|
+
apiToken: cred.apiToken,
|
|
1861
2156
|
passwords: args.password,
|
|
1862
|
-
...
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
2157
|
+
...factoryConfig
|
|
2158
|
+
}) : yield* acquireOrgClient({
|
|
2159
|
+
baseUrl,
|
|
2160
|
+
bearerToken: cred.bearer,
|
|
2161
|
+
...orgKeys !== void 0 ? { orgMasterKeys: orgKeys } : {},
|
|
2162
|
+
...factoryConfig
|
|
1866
2163
|
});
|
|
1867
|
-
const keyring = args.password.length > 0 ? yield* sdkCall("keyring", () => client.keyring({ includePasswordSalt: true })) : void 0;
|
|
1868
|
-
if (keyring
|
|
2164
|
+
const 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;
|
|
2165
|
+
if (keyring && cred.kind === "org") yield* out.info(`keyring built with ${orgKeys.length} org master key(s)`);
|
|
2166
|
+
else if (keyring) if (keyring.size > 0) yield* out.info(`keyring built with ${keyring.size} fingerprint(s)`);
|
|
1869
2167
|
else yield* out.warn(`no symmetric keys derived from --password (count=${args.password.length})`);
|
|
1870
|
-
|
|
2168
|
+
const wsPath = cred.kind === "org" ? "/ws/v1/events/organization" : "/ws/v1/events";
|
|
2169
|
+
yield* out.info(`connecting to ${baseUrl.replace(/\/+$/, "")}${wsPath}${sinceIso ? `?since=${sinceIso}` : ""}`);
|
|
1871
2170
|
const exitWhenQuiet = sinceIso !== void 0 && !args.follow && untilDate === void 0;
|
|
1872
2171
|
const printed = yield* Ref.make(0);
|
|
1873
2172
|
const endNote = yield* Ref.make(Option.none());
|
|
1874
2173
|
const note = (msg) => Ref.set(endNote, Option.some(msg));
|
|
2174
|
+
const halt = yield* Deferred.make();
|
|
2175
|
+
const lastActivity = yield* Ref.make(Date.now());
|
|
2176
|
+
if (exitWhenQuiet) yield* Effect.forkScoped(Effect.gen(function* () {
|
|
2177
|
+
while (true) {
|
|
2178
|
+
yield* Effect.sleep("250 millis");
|
|
2179
|
+
const last = yield* Ref.get(lastActivity);
|
|
2180
|
+
if (Date.now() - last >= QUIET_EXIT_MS) {
|
|
2181
|
+
yield* note("history drained, exiting (use --follow to keep streaming)");
|
|
2182
|
+
yield* Deferred.succeed(halt, void 0);
|
|
2183
|
+
return;
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
}));
|
|
1875
2187
|
const untilReached = (ev) => {
|
|
1876
2188
|
if (untilDate === void 0 || !ev.createdAt) return false;
|
|
1877
2189
|
const t = new Date(ev.createdAt);
|
|
1878
2190
|
return Number.isFinite(t.getTime()) && t >= untilDate;
|
|
1879
2191
|
};
|
|
2192
|
+
const endController = new AbortController();
|
|
2193
|
+
yield* Effect.forkScoped(Deferred.await(halt).pipe(Effect.andThen(Effect.sync(() => endController.abort()))));
|
|
1880
2194
|
yield* sdkStream("events stream", (signal) => client.events({
|
|
1881
2195
|
...sinceIso !== void 0 ? { since: sinceIso } : {},
|
|
1882
|
-
signal
|
|
1883
|
-
})).pipe(
|
|
2196
|
+
signal: AbortSignal.any([signal, endController.signal])
|
|
2197
|
+
})).pipe(Stream.tap(() => Ref.set(lastActivity, Date.now())), Stream.interruptWhen(Deferred.await(halt)), untilDate !== void 0 ? Stream.takeUntilEffect((ev) => untilReached(ev) ? note("--until reached, exiting").pipe(Effect.zipRight(Deferred.succeed(halt, void 0)), Effect.as(true)) : Effect.succeed(false)) : (s) => s, Stream.filter((ev) => !untilReached(ev) && filter.matches(ev)), Stream.mapEffect((ev) => Effect.gen(function* () {
|
|
1884
2198
|
const decrypted = keyring ? yield* sdkCall("decrypt event", () => tryDecryptEventData(ev, keyring)) : void 0;
|
|
1885
2199
|
yield* out.print(formatEvent(ev, args.format, decrypted));
|
|
1886
2200
|
const n = yield* Ref.updateAndGet(printed, (x) => x + 1);
|
|
1887
|
-
if (limit !== void 0 && n >= limit)
|
|
1888
|
-
|
|
2201
|
+
if (limit !== void 0 && n >= limit) {
|
|
2202
|
+
yield* note(`--limit ${limit} reached, exiting`);
|
|
2203
|
+
yield* Deferred.succeed(halt, void 0);
|
|
2204
|
+
}
|
|
2205
|
+
})), limit !== void 0 ? Stream.take(limit) : (s) => s, Stream.runDrain, Effect.catchAll((e) => Effect.flatMap(Deferred.isDone(halt), (done) => done ? Effect.void : Effect.fail(e))));
|
|
1889
2206
|
yield* Ref.get(endNote).pipe(Effect.flatMap(Option.match({
|
|
1890
2207
|
onNone: () => Effect.void,
|
|
1891
2208
|
onSome: (msg) => out.info(msg)
|
|
@@ -2255,7 +2572,7 @@ const actionInputOption = Options.text("action-input").pipe(Options.withAlias("a
|
|
|
2255
2572
|
const textInputOption = Options.boolean("text-input").pipe(Options.withDescription("Add a free-text reply input the recipient types an answer into. Mutually exclusive with --choice-input / --action-input (a notification carries at most one input)."));
|
|
2256
2573
|
const choiceInputOption = Options.text("choice-input").pipe(Options.withAlias("c"), Options.withDescription("Add a single-choice input: a comma-separated options list (e.g. \"Approve,Deny\") the recipient picks one of. Notifications are single-select only. Mutually exclusive with --text-input / --action-input."), Options.optional);
|
|
2257
2574
|
const sharedOption$1 = Options.boolean("shared").pipe(Options.withDescription("Shared mode: ONE notification all recipients see and answer together (the first reply completes it for everyone). Default (without this flag) is independent mode: every recipient gets their own notification instance under a group."));
|
|
2258
|
-
const formatOption$
|
|
2575
|
+
const formatOption$2 = Options.choice("format", ["text", "json"]).pipe(Options.withDescription("stdout format for a send: `text` (the bare id, default) or `json` (a `sent` line piped to `sp collect`)."), Options.withDefault("text"));
|
|
2259
2576
|
/** Parse the (at most one) notification input off the three flags. */
|
|
2260
2577
|
const parseNotificationInput = (textInputOn, choiceRaw, actionRaw) => Effect.gen(function* () {
|
|
2261
2578
|
if ([
|
|
@@ -2302,7 +2619,7 @@ const notifyCommand = Command.make("notify", {
|
|
|
2302
2619
|
"choice-input": choiceInputOption,
|
|
2303
2620
|
"action-input": actionInputOption,
|
|
2304
2621
|
shared: sharedOption$1,
|
|
2305
|
-
format: formatOption$
|
|
2622
|
+
format: formatOption$2,
|
|
2306
2623
|
noEncrypt: noEncryptOption$2,
|
|
2307
2624
|
topic: topicOption,
|
|
2308
2625
|
"api-token": apiTokenOption,
|
|
@@ -2330,67 +2647,75 @@ const notifyCommand = Command.make("notify", {
|
|
|
2330
2647
|
if (imageUrl !== void 0 && audioUrl !== void 0) return yield* Effect.fail(new UserError({ message: "only one of --image or --audio may be set" }));
|
|
2331
2648
|
const mediaUrl = imageUrl ?? audioUrl;
|
|
2332
2649
|
const mediaKind = imageUrl !== void 0 ? "image" : "audio";
|
|
2333
|
-
let mediaContentType;
|
|
2334
2650
|
if (mediaUrl !== void 0) {
|
|
2335
2651
|
if (!/^https?:\/\//.test(mediaUrl)) return yield* Effect.fail(new UserError({ message: "notification media from the CLI must be an http(s) URL; file uploads aren't supported here (use the SDK)" }));
|
|
2336
|
-
|
|
2337
|
-
if (contentType === null) return yield* Effect.fail(new UserError({ message: `--${mediaKind} URL must point to a supported ${mediaKind} type (by extension); got "${mediaUrl}"` }));
|
|
2338
|
-
mediaContentType = contentType;
|
|
2652
|
+
if (notifyMediaContentType(mediaUrl, mediaKind) === null) return yield* Effect.fail(new UserError({ message: `--${mediaKind} URL must point to a supported ${mediaKind} type (by extension); got "${mediaUrl}"` }));
|
|
2339
2653
|
}
|
|
2340
2654
|
const input = yield* parseNotificationInput(args["text-input"], Option.getOrUndefined(args["choice-input"]), Option.getOrUndefined(args["action-input"]));
|
|
2341
2655
|
if (isOrgTarget) {
|
|
2342
2656
|
const api = yield* Api;
|
|
2343
2657
|
const vault = yield* (yield* VaultAccess).forSendOrPlaintext(args.noEncrypt);
|
|
2658
|
+
const auth = yield* api.session;
|
|
2344
2659
|
const target = memberName !== void 0 ? { member: memberName } : args.broadcast ? { broadcast: true } : { topic: orgTopicName };
|
|
2345
|
-
const
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
} :
|
|
2350
|
-
const
|
|
2351
|
-
key: vault.masterKeyCurrent.key,
|
|
2352
|
-
version: vault.masterKeyCurrent.version
|
|
2353
|
-
} : void 0;
|
|
2354
|
-
const opts = {
|
|
2660
|
+
const orgMasterKeys = vault ? [vault.masterKeyCurrent, ...vault.masterKeyHistory].map((k) => ({
|
|
2661
|
+
version: k.version,
|
|
2662
|
+
key: k.key
|
|
2663
|
+
})) : void 0;
|
|
2664
|
+
const encNote = vault ? ` (encrypted under master_key v${vault.masterKeyCurrent.version})` : " (plaintext)";
|
|
2665
|
+
const orgOpts = {
|
|
2355
2666
|
content: message,
|
|
2356
2667
|
...titleOpt !== void 0 ? { title: titleOpt } : {},
|
|
2357
2668
|
...tagOpt !== void 0 ? { tag: tagOpt } : {},
|
|
2358
2669
|
...input !== void 0 ? { input } : {},
|
|
2359
|
-
...
|
|
2670
|
+
...imageUrl !== void 0 ? { image: imageUrl } : {},
|
|
2671
|
+
...audioUrl !== void 0 ? { audio: audioUrl } : {}
|
|
2360
2672
|
};
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
const
|
|
2673
|
+
yield* Effect.scoped(Effect.gen(function* () {
|
|
2674
|
+
const client = yield* acquireOrgClient({
|
|
2675
|
+
baseUrl: auth.baseUrl,
|
|
2676
|
+
bearerToken: bearerToken(auth),
|
|
2677
|
+
...orgMasterKeys !== void 0 ? { orgMasterKeys } : {}
|
|
2678
|
+
});
|
|
2679
|
+
if (args.shared) {
|
|
2680
|
+
const note = yield* sdkCall("notify", () => client.sendNotification({
|
|
2681
|
+
...target,
|
|
2682
|
+
...orgOpts,
|
|
2683
|
+
shared: true
|
|
2684
|
+
}));
|
|
2685
|
+
yield* out.info(`Notification sent${encNote}.`);
|
|
2686
|
+
yield* out.info(`Id: ${note.notificationId}`);
|
|
2687
|
+
yield* out.info(`Created: ${note.createdAt}`);
|
|
2688
|
+
if (args.format === "json") yield* out.print(formatSent(void 0, note.createdAt, [{
|
|
2689
|
+
id: note.notificationId,
|
|
2690
|
+
kind: "notification",
|
|
2691
|
+
recipient: null
|
|
2692
|
+
}]));
|
|
2693
|
+
else yield* out.print(note.notificationId);
|
|
2694
|
+
return;
|
|
2695
|
+
}
|
|
2696
|
+
const group = yield* sdkCall("notify", () => client.sendNotification({
|
|
2697
|
+
...target,
|
|
2698
|
+
...orgOpts
|
|
2699
|
+
}));
|
|
2700
|
+
const n = group.instances.length;
|
|
2701
|
+
yield* out.info(`notification group created: ${group.groupId} (${n} recipient${n === 1 ? "" : "s"})${encNote}`);
|
|
2702
|
+
yield* Effect.forEach(group.instances, (inst) => {
|
|
2703
|
+
const who = inst.recipient ? `${inst.recipient.publicId}${inst.recipient.name ? ` (${inst.recipient.name})` : ""}` : "unknown";
|
|
2369
2704
|
return out.info(`instance: ${who} -> ${inst.notificationId}`);
|
|
2370
2705
|
});
|
|
2371
2706
|
if (n === 0) yield* out.warn("the target has no recipients — the group is empty");
|
|
2372
2707
|
if (args.format === "json") {
|
|
2373
|
-
const members =
|
|
2708
|
+
const members = group.instances.map((inst) => ({
|
|
2374
2709
|
id: inst.notificationId,
|
|
2375
2710
|
kind: "notification",
|
|
2376
|
-
recipient: {
|
|
2711
|
+
recipient: inst.recipient ? {
|
|
2377
2712
|
publicId: inst.recipient.publicId,
|
|
2378
2713
|
name: inst.recipient.name ?? null
|
|
2379
|
-
}
|
|
2714
|
+
} : null
|
|
2380
2715
|
}));
|
|
2381
|
-
yield* out.print(formatSent(
|
|
2382
|
-
} else yield* out.print(
|
|
2383
|
-
}
|
|
2384
|
-
yield* out.info(`Notification sent${encNote}.`);
|
|
2385
|
-
yield* out.info(`Id: ${payload.notificationId}`);
|
|
2386
|
-
yield* out.info(`Created: ${payload.createdAt}`);
|
|
2387
|
-
if (args.format === "json") yield* out.print(formatSent(void 0, payload.createdAt, [{
|
|
2388
|
-
id: payload.notificationId,
|
|
2389
|
-
kind: "notification",
|
|
2390
|
-
recipient: null
|
|
2391
|
-
}]));
|
|
2392
|
-
else yield* out.print(payload.notificationId);
|
|
2393
|
-
}
|
|
2716
|
+
yield* out.print(formatSent(group.groupId, group.createdAt, members));
|
|
2717
|
+
} else yield* out.print(group.groupId);
|
|
2718
|
+
}));
|
|
2394
2719
|
return;
|
|
2395
2720
|
}
|
|
2396
2721
|
const apiToken = yield* requireApiToken(args["api-token"]);
|
|
@@ -2457,6 +2782,125 @@ const notifyCommand = Command.make("notify", {
|
|
|
2457
2782
|
}));
|
|
2458
2783
|
}));
|
|
2459
2784
|
//#endregion
|
|
2785
|
+
//#region src/commands/integration.ts
|
|
2786
|
+
const WrappedKey$1 = Schema.Struct({
|
|
2787
|
+
version: Schema.Number,
|
|
2788
|
+
blob: Schema.String
|
|
2789
|
+
});
|
|
2790
|
+
const CreateOrgIntegrationResponse = Schema.Struct({
|
|
2791
|
+
id: Schema.String,
|
|
2792
|
+
credential: Schema.String
|
|
2793
|
+
});
|
|
2794
|
+
const OrgIntegrationSummary = Schema.Struct({
|
|
2795
|
+
id: Schema.String,
|
|
2796
|
+
name: Schema.String,
|
|
2797
|
+
scopes: Schema.Array(Schema.String),
|
|
2798
|
+
pubkeyB64: Schema.String,
|
|
2799
|
+
wrappedKeys: Schema.Array(WrappedKey$1),
|
|
2800
|
+
createdAt: Schema.String,
|
|
2801
|
+
revokedAt: Schema.optional(Schema.String),
|
|
2802
|
+
lastUsedAt: Schema.optional(Schema.String)
|
|
2803
|
+
});
|
|
2804
|
+
const ListOrgIntegrationsResponse = Schema.Struct({ integrations: Schema.Array(OrgIntegrationSummary) });
|
|
2805
|
+
/** Shared with the org-encryption rotation walk. */
|
|
2806
|
+
const fetchOrgIntegrations = Effect.gen(function* () {
|
|
2807
|
+
const { integrations } = yield* (yield* Api).getJson("fetch integrations", "/v1/org/integrations", ListOrgIntegrationsResponse);
|
|
2808
|
+
return integrations;
|
|
2809
|
+
});
|
|
2810
|
+
/** Shared with the org-encryption rotation walk. */
|
|
2811
|
+
const putIntegrationWraps = (id, wraps) => Effect.gen(function* () {
|
|
2812
|
+
yield* (yield* Api).put("integration wrap", `/v1/org/integrations/${encodeURIComponent(id)}/wraps`, { wraps });
|
|
2813
|
+
});
|
|
2814
|
+
const nameOption = Options.text("name").pipe(Options.withDescription("Human-readable label, shown in `sp integration list` and nowhere else."));
|
|
2815
|
+
const scopesOption = Options.text("scopes").pipe(Options.withDefault("send,events:read"), Options.withDescription("Comma-separated scopes (send, events:read, files:read). org:admin is refused — administration is a human act."));
|
|
2816
|
+
const createCommand = Command.make("create", {
|
|
2817
|
+
name: nameOption,
|
|
2818
|
+
scopes: scopesOption,
|
|
2819
|
+
quiet: quietOption
|
|
2820
|
+
}, (args) => Effect.gen(function* () {
|
|
2821
|
+
const out = yield* CliOutput;
|
|
2822
|
+
yield* out.setQuiet(args.quiet);
|
|
2823
|
+
const api = yield* Api;
|
|
2824
|
+
const access = yield* VaultAccess;
|
|
2825
|
+
const vaultStore = yield* VaultStore;
|
|
2826
|
+
const sodium = yield* Sodium;
|
|
2827
|
+
const scopes = args.scopes.split(",").map((s) => s.trim()).filter(Boolean);
|
|
2828
|
+
const cfg = yield* access.fetchConfig;
|
|
2829
|
+
const unlocked = cfg.enabled ? yield* access.unlockForRotation.pipe(Effect.map((u) => ({
|
|
2830
|
+
...u,
|
|
2831
|
+
cfg
|
|
2832
|
+
}))) : void 0;
|
|
2833
|
+
const seed = yield* sodium.randomBytes(32);
|
|
2834
|
+
const keypair = yield* sodium.seedKeypair(seed);
|
|
2835
|
+
const wraps = [];
|
|
2836
|
+
if (unlocked) {
|
|
2837
|
+
const versions = [...unlocked.vault.masterKeyHistory, unlocked.vault.masterKeyCurrent];
|
|
2838
|
+
for (const mk of versions) {
|
|
2839
|
+
const blob = yield* sodium.wrapMasterKey(mk.key, unlocked.vault.adminPrivateKey, keypair.publicKey);
|
|
2840
|
+
wraps.push({
|
|
2841
|
+
version: mk.version,
|
|
2842
|
+
blob: sodium.toB64(blob)
|
|
2843
|
+
});
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
const made = yield* api.postJson("create integration", "/v1/org/integrations", CreateOrgIntegrationResponse, {
|
|
2847
|
+
name: args.name,
|
|
2848
|
+
scopes,
|
|
2849
|
+
pubkeyB64: sodium.toB64(keypair.publicKey),
|
|
2850
|
+
wrappedKeys: wraps
|
|
2851
|
+
});
|
|
2852
|
+
if (unlocked) {
|
|
2853
|
+
const nextVault = {
|
|
2854
|
+
...unlocked.vault,
|
|
2855
|
+
integrations: [...unlocked.vault.integrations, {
|
|
2856
|
+
id: made.id,
|
|
2857
|
+
pubkeyB64: sodium.toB64(keypair.publicKey),
|
|
2858
|
+
name: args.name
|
|
2859
|
+
}]
|
|
2860
|
+
};
|
|
2861
|
+
const enabledCfg = yield* access.requireEnabled(unlocked.cfg);
|
|
2862
|
+
const newBlob = yield* sodium.encryptVault(nextVault, unlocked.vaultKey);
|
|
2863
|
+
yield* api.put("vault update", "/v1/org/encryption/vault", {
|
|
2864
|
+
vaultBlobB64: sodium.toB64(newBlob),
|
|
2865
|
+
vaultSaltB64: enabledCfg.vaultSaltB64,
|
|
2866
|
+
kdfParams: enabledCfg.kdfParams
|
|
2867
|
+
}).pipe(Effect.tap(() => vaultStore.save(nextVault)), Effect.catchAll(() => out.error("integration created, but pinning its pubkey in the vault failed — key rotation will SKIP this integration until a `create` or vault write succeeds again.")));
|
|
2868
|
+
}
|
|
2869
|
+
const token = `${made.credential}.${sodium.toB64Url(seed)}`;
|
|
2870
|
+
yield* out.info(`Integration '${args.name}' created (id ${made.id}, scopes: ${scopes.join(" ")}).`);
|
|
2871
|
+
yield* out.info("This token is shown ONCE and cannot be recovered — store it now:");
|
|
2872
|
+
yield* out.print(token);
|
|
2873
|
+
if (!cfg.enabled) yield* out.info("Org encryption is not enabled; sends made with this token go out plaintext.");
|
|
2874
|
+
yield* out.info(`Revoke with: sp integration revoke ${made.id}`);
|
|
2875
|
+
}));
|
|
2876
|
+
const listCommand = Command.make("list", { quiet: quietOption }, (args) => Effect.gen(function* () {
|
|
2877
|
+
const out = yield* CliOutput;
|
|
2878
|
+
yield* out.setQuiet(args.quiet);
|
|
2879
|
+
const integrations = yield* fetchOrgIntegrations;
|
|
2880
|
+
if (integrations.length === 0) return yield* out.info("No integrations. Mint one with `sp integration create --name <name>`.");
|
|
2881
|
+
for (const i of integrations) {
|
|
2882
|
+
const state = i.revokedAt ? `revoked ${i.revokedAt}` : "active";
|
|
2883
|
+
const used = i.lastUsedAt ? `last used ${i.lastUsedAt}` : "never used";
|
|
2884
|
+
const keys = i.wrappedKeys.length > 0 ? `keys v${i.wrappedKeys.map((w) => w.version).join(",v")}` : "no keys";
|
|
2885
|
+
yield* out.print(`${i.id} ${i.name} [${i.scopes.join(" ")}] ${state} ${used} ${keys}`);
|
|
2886
|
+
}
|
|
2887
|
+
}));
|
|
2888
|
+
const idArg$1 = Args.text({ name: "id" }).pipe(Args.withDescription("Integration id, from `sp integration list`."));
|
|
2889
|
+
const revokeCommand = Command.make("revoke", {
|
|
2890
|
+
id: idArg$1,
|
|
2891
|
+
quiet: quietOption
|
|
2892
|
+
}, (args) => Effect.gen(function* () {
|
|
2893
|
+
const out = yield* CliOutput;
|
|
2894
|
+
yield* out.setQuiet(args.quiet);
|
|
2895
|
+
yield* (yield* Api).delete("revoke integration", `/v1/org/integrations/${encodeURIComponent(args.id)}`);
|
|
2896
|
+
yield* out.info(`Integration ${args.id} revoked — its credential stops working immediately. It keeps any keys it already unwrapped: if it was compromised, also run \`sp org encryption key rotate\`.`);
|
|
2897
|
+
}));
|
|
2898
|
+
const integrationCommand = Command.make("integration").pipe(Command.withSubcommands([
|
|
2899
|
+
createCommand,
|
|
2900
|
+
listCommand,
|
|
2901
|
+
revokeCommand
|
|
2902
|
+
]));
|
|
2903
|
+
//#endregion
|
|
2460
2904
|
//#region src/commands/org-encryption.ts
|
|
2461
2905
|
const WrappedKey = Schema.Struct({
|
|
2462
2906
|
version: Schema.Number,
|
|
@@ -2473,7 +2917,7 @@ const fetchEncryptionDevices = Effect.gen(function* () {
|
|
|
2473
2917
|
const { devices } = yield* (yield* Api).getJson("fetch encryption devices", "/v1/org/encryption/devices", ListOrgEncryptionDevicesResponse);
|
|
2474
2918
|
return devices;
|
|
2475
2919
|
});
|
|
2476
|
-
const yesIWroteItDownOption = Options.boolean("i-saved-the-passphrase").pipe(Options.withDescription("
|
|
2920
|
+
const yesIWroteItDownOption = Options.boolean("i-saved-the-passphrase").pipe(Options.withDescription("Acknowledge that enable will print the org passphrase exactly once and that you will copy it somewhere safe. The passphrase is the only way to unlock the org's encryption vault on another machine; if lost, the only recovery is to re-enable encryption and re-onboard every device."));
|
|
2477
2921
|
const enableCommand = Command.make("enable", {
|
|
2478
2922
|
confirm: yesIWroteItDownOption,
|
|
2479
2923
|
quiet: quietOption
|
|
@@ -2488,6 +2932,17 @@ const enableCommand = Command.make("enable", {
|
|
|
2488
2932
|
yield* out.error("encryption is already enabled for this org. Use `sp org encryption key rotate` to rotate the key.");
|
|
2489
2933
|
return yield* Effect.fail(new Aborted());
|
|
2490
2934
|
}
|
|
2935
|
+
if (!args.confirm) {
|
|
2936
|
+
yield* out.info("Enabling encryption generates an org passphrase that is shown exactly once.");
|
|
2937
|
+
yield* out.info("It is the ONLY way to:");
|
|
2938
|
+
yield* out.info(" - unlock the encryption vault from another admin's machine");
|
|
2939
|
+
yield* out.info(" - recover access if your CLI state is lost");
|
|
2940
|
+
yield* out.info("It cannot be recovered if forgotten. Re-enabling encryption forces");
|
|
2941
|
+
yield* out.info("every member device to re-onboard from scratch.");
|
|
2942
|
+
yield* out.info("");
|
|
2943
|
+
yield* out.info("Re-run with --i-saved-the-passphrase to generate the passphrase and enable encryption.");
|
|
2944
|
+
return yield* Effect.fail(new Aborted());
|
|
2945
|
+
}
|
|
2491
2946
|
const passphrase = yield* sodium.generatePassphrase();
|
|
2492
2947
|
const vaultSalt = yield* sodium.generateVaultSalt;
|
|
2493
2948
|
const adminKp = yield* sodium.generateAdminKeyPair;
|
|
@@ -2499,7 +2954,8 @@ const enableCommand = Command.make("enable", {
|
|
|
2499
2954
|
version: 1,
|
|
2500
2955
|
key: masterKey
|
|
2501
2956
|
},
|
|
2502
|
-
masterKeyHistory: []
|
|
2957
|
+
masterKeyHistory: [],
|
|
2958
|
+
integrations: []
|
|
2503
2959
|
};
|
|
2504
2960
|
const vaultKey = yield* sodium.deriveVaultKey(passphrase, vaultSalt, DEFAULT_KDF_PARAMS);
|
|
2505
2961
|
const vaultBlob = yield* sodium.encryptVault(vaultContents, vaultKey);
|
|
@@ -2514,10 +2970,6 @@ const enableCommand = Command.make("enable", {
|
|
|
2514
2970
|
yield* out.info("It cannot be recovered if forgotten. Re-enabling encryption forces");
|
|
2515
2971
|
yield* out.info("every member device to re-onboard from scratch.");
|
|
2516
2972
|
yield* out.info("");
|
|
2517
|
-
if (!args.confirm) {
|
|
2518
|
-
yield* out.info("Re-run with --i-saved-the-passphrase to push this config to the server.");
|
|
2519
|
-
return yield* Effect.fail(new Aborted());
|
|
2520
|
-
}
|
|
2521
2973
|
yield* api.post("enable", "/v1/org/encryption/enable", {
|
|
2522
2974
|
adminPubkeyB64: sodium.toB64(adminKp.publicKey),
|
|
2523
2975
|
vaultBlobB64: sodium.toB64(vaultBlob),
|
|
@@ -2572,23 +3024,27 @@ const wrapCurrentKeyToAllDevices = (vault) => Effect.gen(function* () {
|
|
|
2572
3024
|
yield* Effect.forEach(devices, (dev) => Effect.gen(function* () {
|
|
2573
3025
|
const pubkey = sodium.fromB64(dev.devicePubkeyB64);
|
|
2574
3026
|
const storedHmac = sodium.fromB64(dev.inviteHmacB64);
|
|
2575
|
-
const
|
|
2576
|
-
if (
|
|
3027
|
+
const currentWrap = dev.wrappedKeys.find((w) => w.version === currentMaster.version);
|
|
3028
|
+
if (currentWrap !== void 0 && (yield* sodium.unwrapMasterKey(sodium.fromB64(currentWrap.blob), vault.adminPrivateKey, pubkey).pipe(Effect.map((key) => sodium.constantTimeEqual(key, currentMaster.key)), Effect.orElseSucceed(() => false)))) {
|
|
2577
3029
|
counts = {
|
|
2578
3030
|
...counts,
|
|
2579
|
-
|
|
3031
|
+
alreadyCurrent: counts.alreadyCurrent + 1
|
|
2580
3032
|
};
|
|
2581
|
-
return
|
|
3033
|
+
return;
|
|
2582
3034
|
}
|
|
2583
|
-
|
|
3035
|
+
const matchedInvite = knownInvites.find((inv) => sodium.constantTimeEqual(sodium.hmacInviteBinding(inv.code, pubkey), storedHmac));
|
|
3036
|
+
if (!matchedInvite) {
|
|
2584
3037
|
counts = {
|
|
2585
3038
|
...counts,
|
|
2586
|
-
|
|
3039
|
+
unverified: counts.unverified + 1
|
|
2587
3040
|
};
|
|
2588
|
-
return;
|
|
3041
|
+
return yield* out.error(`device ${dev.deviceId}: HMAC doesn't match any locally-known invite — skipping. (This device joined via an invite issued from a different CLI install, or the invite has been pruned.)`);
|
|
2589
3042
|
}
|
|
2590
3043
|
const blob = yield* sodium.wrapMasterKey(currentMaster.key, vault.adminPrivateKey, pubkey);
|
|
2591
|
-
const nextWraps =
|
|
3044
|
+
const nextWraps = currentWrap !== void 0 ? [{
|
|
3045
|
+
version: currentMaster.version,
|
|
3046
|
+
blob: sodium.toB64(blob)
|
|
3047
|
+
}] : [...dev.wrappedKeys, {
|
|
2592
3048
|
version: currentMaster.version,
|
|
2593
3049
|
blob: sodium.toB64(blob)
|
|
2594
3050
|
}];
|
|
@@ -2614,11 +3070,69 @@ const summarize = (counts) => `wrapped=${counts.wrapped} already-current=${count
|
|
|
2614
3070
|
const syncCommand = Command.make("sync", { quiet: quietOption }, (args) => Effect.gen(function* () {
|
|
2615
3071
|
const out = yield* CliOutput;
|
|
2616
3072
|
yield* out.setQuiet(args.quiet);
|
|
2617
|
-
const
|
|
2618
|
-
yield*
|
|
3073
|
+
const vault = yield* (yield* VaultAccess).getOrPrompt;
|
|
3074
|
+
const counts = yield* wrapCurrentKeyToAllDevices(vault);
|
|
3075
|
+
const integCounts = yield* wrapCurrentKeyToAllIntegrations(vault);
|
|
3076
|
+
yield* out.info(`Sync complete. Devices: ${summarize(counts)} Integrations: ${summarize(integCounts)}`);
|
|
2619
3077
|
if (counts.unverified > 0) yield* out.info("Unverified devices were left untouched — their pubkeys weren't bound to any invite code this CLI knows about.");
|
|
2620
|
-
if (counts.failed > 0) return yield* Effect.fail(new Aborted());
|
|
3078
|
+
if (counts.failed > 0 || integCounts.failed > 0) return yield* Effect.fail(new Aborted());
|
|
2621
3079
|
}));
|
|
3080
|
+
const wrapCurrentKeyToAllIntegrations = (vault) => Effect.gen(function* () {
|
|
3081
|
+
const out = yield* CliOutput;
|
|
3082
|
+
const sodium = yield* Sodium;
|
|
3083
|
+
const active = (yield* fetchOrgIntegrations).filter((i) => !i.revokedAt);
|
|
3084
|
+
const currentMaster = vault.masterKeyCurrent;
|
|
3085
|
+
let counts = {
|
|
3086
|
+
wrapped: 0,
|
|
3087
|
+
alreadyCurrent: 0,
|
|
3088
|
+
unverified: 0,
|
|
3089
|
+
failed: 0
|
|
3090
|
+
};
|
|
3091
|
+
yield* Effect.forEach(active, (integ) => Effect.gen(function* () {
|
|
3092
|
+
const pin = vault.integrations.find((p) => p.id === integ.id);
|
|
3093
|
+
if (!pin) {
|
|
3094
|
+
counts = {
|
|
3095
|
+
...counts,
|
|
3096
|
+
unverified: counts.unverified + 1
|
|
3097
|
+
};
|
|
3098
|
+
return yield* out.error(`integration ${integ.id} ('${integ.name}') has no pubkey pin in the vault — skipping. (Created before encryption was enabled, or its vault write failed. Revoke and re-create it.)`);
|
|
3099
|
+
}
|
|
3100
|
+
if (pin.pubkeyB64 !== integ.pubkeyB64) {
|
|
3101
|
+
counts = {
|
|
3102
|
+
...counts,
|
|
3103
|
+
unverified: counts.unverified + 1
|
|
3104
|
+
};
|
|
3105
|
+
return yield* out.error(`integration ${integ.id} ('${integ.name}'): server pubkey DIFFERS from the vault pin — skipping. This should never happen and may indicate backend tampering.`);
|
|
3106
|
+
}
|
|
3107
|
+
if (integ.wrappedKeys.some((w) => w.version === currentMaster.version)) {
|
|
3108
|
+
counts = {
|
|
3109
|
+
...counts,
|
|
3110
|
+
alreadyCurrent: counts.alreadyCurrent + 1
|
|
3111
|
+
};
|
|
3112
|
+
return;
|
|
3113
|
+
}
|
|
3114
|
+
const blob = yield* sodium.wrapMasterKey(currentMaster.key, vault.adminPrivateKey, sodium.fromB64(pin.pubkeyB64));
|
|
3115
|
+
const nextWraps = [...integ.wrappedKeys.filter((w) => w.version !== currentMaster.version), {
|
|
3116
|
+
version: currentMaster.version,
|
|
3117
|
+
blob: sodium.toB64(blob)
|
|
3118
|
+
}];
|
|
3119
|
+
yield* putIntegrationWraps(integ.id, nextWraps).pipe(Effect.matchEffect({
|
|
3120
|
+
onFailure: (e) => Effect.sync(() => {
|
|
3121
|
+
counts = {
|
|
3122
|
+
...counts,
|
|
3123
|
+
failed: counts.failed + 1
|
|
3124
|
+
};
|
|
3125
|
+
}).pipe(Effect.zipRight(out.error(`integration wrap failed: ${String(e)}`))),
|
|
3126
|
+
onSuccess: () => Effect.sync(() => {
|
|
3127
|
+
counts = {
|
|
3128
|
+
...counts,
|
|
3129
|
+
wrapped: counts.wrapped + 1
|
|
3130
|
+
};
|
|
3131
|
+
})
|
|
3132
|
+
}));
|
|
3133
|
+
}), { discard: true });
|
|
3134
|
+
return counts;
|
|
3135
|
+
});
|
|
2622
3136
|
const keyShowCommand = Command.make("show", { quiet: quietOption }, (args) => Effect.gen(function* () {
|
|
2623
3137
|
const out = yield* CliOutput;
|
|
2624
3138
|
yield* out.setQuiet(args.quiet);
|
|
@@ -2646,7 +3160,8 @@ const keyRotateCommand = Command.make("rotate", { quiet: quietOption }, (args) =
|
|
|
2646
3160
|
version: nextVersion,
|
|
2647
3161
|
key: yield* sodium.generateMasterKey
|
|
2648
3162
|
},
|
|
2649
|
-
masterKeyHistory: [...vault.masterKeyHistory, vault.masterKeyCurrent]
|
|
3163
|
+
masterKeyHistory: [...vault.masterKeyHistory, vault.masterKeyCurrent],
|
|
3164
|
+
integrations: vault.integrations
|
|
2650
3165
|
};
|
|
2651
3166
|
const cfg = yield* access.fetchConfig.pipe(Effect.flatMap(access.requireEnabled));
|
|
2652
3167
|
const newBlob = yield* sodium.encryptVault(nextVault, vaultKey);
|
|
@@ -2659,13 +3174,15 @@ const keyRotateCommand = Command.make("rotate", { quiet: quietOption }, (args) =
|
|
|
2659
3174
|
yield* out.info(`Generated master_key v${nextVersion} and updated the org vault.`);
|
|
2660
3175
|
yield* out.info("Wrapping the new key to every onboarded device...");
|
|
2661
3176
|
const counts = yield* wrapCurrentKeyToAllDevices(nextVault);
|
|
2662
|
-
yield* out.info(
|
|
3177
|
+
yield* out.info("Wrapping the new key to every integration...");
|
|
3178
|
+
const integCounts = yield* wrapCurrentKeyToAllIntegrations(nextVault);
|
|
3179
|
+
yield* out.info(`Rotation complete. Devices: ${summarize(counts)} Integrations: ${summarize(integCounts)}`);
|
|
2663
3180
|
yield* out.info("");
|
|
2664
3181
|
yield* out.info(`New encryption key (master_key v${nextVersion}):`);
|
|
2665
3182
|
yield* out.print(sodium.toB64(nextVault.masterKeyCurrent.key));
|
|
2666
3183
|
yield* out.info("Update any library clients with this new value. The previous key remains valid for decrypting historical notifications only.");
|
|
2667
3184
|
if (counts.unverified > 0) yield* out.info("Unverified devices were skipped — they'll need a fresh invite redeem before they can pick up the new key.");
|
|
2668
|
-
if (counts.failed > 0) return yield* Effect.fail(new Aborted());
|
|
3185
|
+
if (counts.failed > 0 || integCounts.failed > 0) return yield* Effect.fail(new Aborted());
|
|
2669
3186
|
}));
|
|
2670
3187
|
const keyCommand = Command.make("key").pipe(Command.withSubcommands([keyShowCommand, keyRotateCommand]));
|
|
2671
3188
|
const encryptionCommand = Command.make("encryption").pipe(Command.withSubcommands([
|
|
@@ -2937,21 +3454,6 @@ const buildFiles = (paths) => Effect.gen(function* () {
|
|
|
2937
3454
|
};
|
|
2938
3455
|
}));
|
|
2939
3456
|
});
|
|
2940
|
-
/** Drive the presign -> PUT -> complete lifecycle for an org send's prepared
|
|
2941
|
-
* attachments over the CLI bearer session (`/v1/org/attachments/...` — the
|
|
2942
|
-
* bearer-auth'd variants of the attachment lifecycle endpoints). No-op when the
|
|
2943
|
-
* send carried no files. Per-file failures are marked `failed` server-side and
|
|
2944
|
-
* skipped (the SDK's best-effort semantics) — the task/subtask itself stands. */
|
|
2945
|
-
const uploadOrgAttachments = (prepared, created) => Effect.gen(function* () {
|
|
2946
|
-
if (prepared.length === 0) return;
|
|
2947
|
-
const auth = yield* (yield* Api).session;
|
|
2948
|
-
const ctx = {
|
|
2949
|
-
baseUrl: new URL(`${auth.baseUrl.replace(/\/+$/, "")}/`),
|
|
2950
|
-
authHeaders: { Authorization: `Bearer ${bearerToken(auth)}` },
|
|
2951
|
-
basePath: "v1/org/attachments"
|
|
2952
|
-
};
|
|
2953
|
-
yield* sdkCall("upload attachments", () => uploadFileAttachments(ctx, prepared, created ?? []));
|
|
2954
|
-
});
|
|
2955
3457
|
const CONTENT_TYPES = {
|
|
2956
3458
|
png: "image/png",
|
|
2957
3459
|
jpg: "image/jpeg",
|
|
@@ -2994,8 +3496,8 @@ const locationInput$1 = repeatedText("location-input", void 0, "Add a location i
|
|
|
2994
3496
|
const linkOption$1 = repeatedText("link", "l", "Attach a remote URL (a link attachment). Repeatable. For local files use --file.");
|
|
2995
3497
|
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.");
|
|
2996
3498
|
const submitOption$1 = Options.boolean("submit").pipe(Options.withDescription("Require the recipient to explicitly submit the task. Without this, it auto-completes once the required inputs are filled."));
|
|
2997
|
-
const waitOption = Options.boolean("wait").pipe(Options.withDescription("Block until the task is completed; print the result to stdout. Requires exactly one input on the request."));
|
|
2998
|
-
const replyOption = Options.choice("reply", [
|
|
3499
|
+
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."));
|
|
3500
|
+
const replyOption$1 = Options.choice("reply", [
|
|
2999
3501
|
"one-shot",
|
|
3000
3502
|
"sticky",
|
|
3001
3503
|
"one-time-per-user"
|
|
@@ -3005,8 +3507,9 @@ const broadcastOption = Options.boolean("broadcast").pipe(Options.withAlias("b")
|
|
|
3005
3507
|
const orgTopicOption = Options.text("org-topic").pipe(Options.withAlias("o"), Options.withDescription("Send to an org topic by value (from `sp org topics list`). Org send; mutually exclusive with --member, --broadcast, and -k/--topic (which is the personal topic)."), Options.optional);
|
|
3006
3508
|
const noEncryptOption$1 = Options.boolean("no-encrypt").pipe(Options.withDescription("For org sends: send fields in plaintext even when the org vault is unlocked."));
|
|
3007
3509
|
const markdownOption$1 = Options.boolean("markdown").pipe(Options.withDescription("Render the task body as Markdown on the recipient's device (sets contentFormat=markdown)."));
|
|
3510
|
+
const expiresOption = Options.text("expires").pipe(Options.withDescription("Deadline: a duration from now (`2h`, `7d`) or an ISO 8601 timestamp. Past it, an unanswered task flips to expired (terminal; further answers are rejected)."), Options.optional);
|
|
3008
3511
|
const sharedOption = Options.boolean("shared").pipe(Options.withDescription("Shared mode: ONE task all recipients see and answer together (user A's input is visible to user B). Default (without this flag) is independent mode: every recipient gets their own task instance under a group."));
|
|
3009
|
-
const formatOption = Options.choice("format", ["text", "json"]).pipe(Options.withDescription("stdout format for a send: `text` (the bare id, default) or `json` (a `sent` line piped to `sp collect`)."), Options.withDefault("text"));
|
|
3512
|
+
const formatOption$1 = Options.choice("format", ["text", "json"]).pipe(Options.withDescription("stdout format for a send: `text` (the bare id, default) or `json` (a `sent` line piped to `sp collect`)."), Options.withDefault("text"));
|
|
3010
3513
|
const taskCommand = Command.make("task", {
|
|
3011
3514
|
title: titleOption$1,
|
|
3012
3515
|
content: contentOption$1,
|
|
@@ -3022,15 +3525,16 @@ const taskCommand = Command.make("task", {
|
|
|
3022
3525
|
link: linkOption$1,
|
|
3023
3526
|
file: fileOption$1,
|
|
3024
3527
|
submit: submitOption$1,
|
|
3025
|
-
wait: waitOption,
|
|
3026
|
-
reply: replyOption,
|
|
3528
|
+
wait: waitOption$1,
|
|
3529
|
+
reply: replyOption$1,
|
|
3027
3530
|
member: memberOption,
|
|
3028
3531
|
broadcast: broadcastOption,
|
|
3029
3532
|
"org-topic": orgTopicOption,
|
|
3030
3533
|
"no-encrypt": noEncryptOption$1,
|
|
3031
3534
|
markdown: markdownOption$1,
|
|
3032
3535
|
shared: sharedOption,
|
|
3033
|
-
|
|
3536
|
+
expires: expiresOption,
|
|
3537
|
+
format: formatOption$1,
|
|
3034
3538
|
topic: topicOption,
|
|
3035
3539
|
"api-token": apiTokenOption,
|
|
3036
3540
|
password: passwordOption,
|
|
@@ -3056,6 +3560,11 @@ const taskCommand = Command.make("task", {
|
|
|
3056
3560
|
const tag = Option.getOrElse(args.tag, () => process.env.SP_TAG ?? "");
|
|
3057
3561
|
const title = Option.getOrUndefined(args.title);
|
|
3058
3562
|
const content = Option.getOrUndefined(args.content);
|
|
3563
|
+
const expiresRaw = Option.getOrUndefined(args.expires);
|
|
3564
|
+
const expiresAt = expiresRaw !== void 0 ? yield* Effect.try({
|
|
3565
|
+
try: () => resolveExpiresAt(expiresRaw),
|
|
3566
|
+
catch: (e) => new UserError({ message: e instanceof Error ? e.message : String(e) })
|
|
3567
|
+
}) : void 0;
|
|
3059
3568
|
if (memberName !== void 0 || args.broadcast || orgTopicName !== void 0) return yield* sendOrgTask({
|
|
3060
3569
|
member: memberName,
|
|
3061
3570
|
broadcast: args.broadcast,
|
|
@@ -3072,6 +3581,7 @@ const taskCommand = Command.make("task", {
|
|
|
3072
3581
|
noEncrypt: args["no-encrypt"],
|
|
3073
3582
|
wait: args.wait,
|
|
3074
3583
|
shared: args.shared,
|
|
3584
|
+
expiresAt,
|
|
3075
3585
|
format: args.format
|
|
3076
3586
|
});
|
|
3077
3587
|
const passwords = args.password;
|
|
@@ -3094,7 +3604,8 @@ const taskCommand = Command.make("task", {
|
|
|
3094
3604
|
...files.length > 0 ? { files } : {},
|
|
3095
3605
|
autoCommit: !args.submit,
|
|
3096
3606
|
...Option.isSome(args.reply) ? { reply: args.reply.value } : {},
|
|
3097
|
-
...args.markdown ? { contentFormat: "markdown" } : {}
|
|
3607
|
+
...args.markdown ? { contentFormat: "markdown" } : {},
|
|
3608
|
+
...expiresAt !== void 0 ? { expiresAt } : {}
|
|
3098
3609
|
};
|
|
3099
3610
|
if (topic === void 0) {
|
|
3100
3611
|
const response = yield* sdkCall("task send", () => client.sendTask(baseOpts));
|
|
@@ -3182,75 +3693,106 @@ const waitForFirstCompletion = (tasks) => Effect.gen(function* () {
|
|
|
3182
3693
|
return yield* Effect.fail(new Aborted());
|
|
3183
3694
|
}
|
|
3184
3695
|
const ev = first.value;
|
|
3185
|
-
|
|
3696
|
+
yield* out.print(completionValue(ev.kind === "taskCompleted" ? ev.uploads : []));
|
|
3697
|
+
});
|
|
3698
|
+
/** stdout value for a completion's uploads (`--wait`): a single
|
|
3699
|
+
* text/choice/action/multi-choice answer prints as the bare value; anything
|
|
3700
|
+
* else (multiple inputs, binary uploads) prints as the uploads JSON. */
|
|
3701
|
+
const completionValue = (uploads) => {
|
|
3186
3702
|
const single = uploads.length === 1 ? uploads[0] : void 0;
|
|
3187
3703
|
const value = single && (single.kind === "text" || single.kind === "choice") ? single.value : single && single.kind === "action" ? single.key : single && single.kind === "multiChoice" ? (single.values ?? []).filter((v) => typeof v === "string").join(", ") : void 0;
|
|
3188
|
-
|
|
3189
|
-
}
|
|
3704
|
+
return typeof value === "string" ? value : JSON.stringify(uploads);
|
|
3705
|
+
};
|
|
3190
3706
|
/** Send a task to org recipients (a member, a broadcast, or an org topic) over
|
|
3191
|
-
* the CLI bearer session
|
|
3192
|
-
*
|
|
3193
|
-
*
|
|
3194
|
-
*
|
|
3195
|
-
*
|
|
3707
|
+
* the CLI bearer session via the SDK's OrgClient — the same send surface an
|
|
3708
|
+
* Api-Key client uses. Fields (tag/title/content/links + each input's
|
|
3709
|
+
* description/default/options) and file bytes are encrypted under the current
|
|
3710
|
+
* org master_key when the vault is unlocked. With --wait, the returned handles
|
|
3711
|
+
* stream off the org event hub until the first completion. */
|
|
3196
3712
|
const sendOrgTask = (params) => Effect.gen(function* () {
|
|
3197
3713
|
const out = yield* CliOutput;
|
|
3198
3714
|
const api = yield* Api;
|
|
3199
3715
|
const access = yield* VaultAccess;
|
|
3200
3716
|
if (params.content === void 0 && params.inputs.length === 0) return yield* Effect.fail(new UserError({ message: "an org task needs --content or at least one input" }));
|
|
3201
|
-
if (params.wait) yield* out.warn("--wait isn't supported on org sends; ignoring");
|
|
3202
3717
|
const vault = yield* access.forSendOrPlaintext(params.noEncrypt);
|
|
3718
|
+
const auth = yield* api.session;
|
|
3203
3719
|
const target = params.orgTopic !== void 0 ? { topic: params.orgTopic } : params.member !== void 0 ? { member: params.member } : { broadcast: true };
|
|
3720
|
+
const files = yield* buildFiles(params.files);
|
|
3204
3721
|
const opts = {
|
|
3205
3722
|
...params.tag ? { tag: params.tag } : {},
|
|
3206
3723
|
...params.title !== void 0 ? { title: params.title } : {},
|
|
3207
3724
|
...params.content !== void 0 ? { content: params.content } : {},
|
|
3208
3725
|
inputs: params.inputs,
|
|
3209
3726
|
links: params.links,
|
|
3727
|
+
...files.length > 0 ? { files } : {},
|
|
3210
3728
|
autoCommit: params.autoCommit,
|
|
3211
3729
|
...params.reply !== void 0 ? { reply: params.reply } : {},
|
|
3212
3730
|
...params.markdown ? { contentFormat: "markdown" } : {},
|
|
3213
|
-
...params.
|
|
3731
|
+
...params.expiresAt !== void 0 ? { expiresAt: params.expiresAt } : {}
|
|
3214
3732
|
};
|
|
3215
|
-
const
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
} : void 0;
|
|
3219
|
-
const fileAttachments = yield* buildFiles(params.files);
|
|
3220
|
-
const prepared = yield* sdkCall("prepare attachments", () => prepareFileAttachments(fileAttachments, masterKey?.key));
|
|
3221
|
-
const body = yield* sdkCall("build task request", () => buildOrgTaskRequest(target, opts, masterKey, prepared.map((p) => p.meta)));
|
|
3222
|
-
const payload = yield* api.postJson("task", "/v1/org/tasks/json", Schema.Unknown, body);
|
|
3223
|
-
yield* uploadOrgAttachments(prepared, payload.attachments);
|
|
3733
|
+
const orgMasterKeys = vault ? [vault.masterKeyCurrent, ...vault.masterKeyHistory].map((k) => ({
|
|
3734
|
+
version: k.version,
|
|
3735
|
+
key: k.key
|
|
3736
|
+
})) : void 0;
|
|
3224
3737
|
const enc = vault ? ` (encrypted under master_key v${vault.masterKeyCurrent.version})` : " (plaintext)";
|
|
3225
|
-
|
|
3738
|
+
yield* Effect.scoped(Effect.gen(function* () {
|
|
3739
|
+
const client = yield* acquireOrgClient({
|
|
3740
|
+
baseUrl: auth.baseUrl,
|
|
3741
|
+
bearerToken: bearerToken(auth),
|
|
3742
|
+
...orgMasterKeys !== void 0 ? { orgMasterKeys } : {}
|
|
3743
|
+
});
|
|
3744
|
+
if (params.shared) {
|
|
3745
|
+
const response = yield* sdkCall("task send", () => client.sendTask({
|
|
3746
|
+
...target,
|
|
3747
|
+
...opts,
|
|
3748
|
+
shared: true
|
|
3749
|
+
}));
|
|
3750
|
+
yield* out.info(`Org task sent${enc}.`);
|
|
3751
|
+
yield* out.info(`Id: ${response.taskId}`);
|
|
3752
|
+
yield* out.info(`Append: ${response.appendToken}`);
|
|
3753
|
+
if (!params.wait) {
|
|
3754
|
+
if (params.format === "json") yield* out.print(formatSent(void 0, response.createdAt, [{
|
|
3755
|
+
id: response.taskId,
|
|
3756
|
+
kind: "task",
|
|
3757
|
+
recipient: null
|
|
3758
|
+
}]));
|
|
3759
|
+
else yield* out.print(response.taskId);
|
|
3760
|
+
return;
|
|
3761
|
+
}
|
|
3762
|
+
yield* out.info(`waiting for completion of task ${response.taskId}`);
|
|
3763
|
+
return yield* waitForFirstCompletion([response]);
|
|
3764
|
+
}
|
|
3765
|
+
const group = yield* sdkCall("task send", () => client.sendTask({
|
|
3766
|
+
...target,
|
|
3767
|
+
...opts
|
|
3768
|
+
}));
|
|
3226
3769
|
yield* out.info(`Org task group sent${enc}.`);
|
|
3227
|
-
yield* out.info(`Group: ${
|
|
3228
|
-
yield* out.info(`Append: ${
|
|
3229
|
-
yield* Effect.forEach(
|
|
3230
|
-
const who = `${inst.recipient.publicId}${inst.recipient.name ? ` (${inst.recipient.name})` : ""}
|
|
3770
|
+
yield* out.info(`Group: ${group.groupId} (${group.instances.length} recipient${group.instances.length === 1 ? "" : "s"})`);
|
|
3771
|
+
yield* out.info(`Append: ${group.appendToken}`);
|
|
3772
|
+
yield* Effect.forEach(group.instances, (inst) => {
|
|
3773
|
+
const who = inst.recipient ? `${inst.recipient.publicId}${inst.recipient.name ? ` (${inst.recipient.name})` : ""}` : "unknown";
|
|
3231
3774
|
return out.info(`Instance: ${inst.taskId} -> ${who} append token: ${inst.appendToken}`);
|
|
3232
3775
|
});
|
|
3233
|
-
if (
|
|
3234
|
-
if (params.
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
}
|
|
3776
|
+
if (group.instances.length === 0) yield* out.warn("the target has no recipients — the group is empty");
|
|
3777
|
+
if (!params.wait) {
|
|
3778
|
+
if (params.format === "json") yield* out.print(formatSent(group.groupId, group.createdAt, group.instances.map((i) => ({
|
|
3779
|
+
id: i.taskId,
|
|
3780
|
+
kind: "task",
|
|
3781
|
+
recipient: i.recipient ? {
|
|
3782
|
+
publicId: i.recipient.publicId,
|
|
3783
|
+
name: i.recipient.name ?? null
|
|
3784
|
+
} : null
|
|
3785
|
+
}))));
|
|
3786
|
+
else yield* out.print(group.groupId);
|
|
3787
|
+
return;
|
|
3788
|
+
}
|
|
3789
|
+
if (group.instances.length === 0) {
|
|
3790
|
+
yield* out.warn("--wait requested but the group has no instances; nothing will complete");
|
|
3791
|
+
return yield* Effect.fail(new Aborted());
|
|
3792
|
+
}
|
|
3793
|
+
yield* out.info(`waiting for the first completion across ${group.instances.length} instance(s) of ${group.groupId}`);
|
|
3794
|
+
yield* waitForFirstCompletion(group.instances);
|
|
3795
|
+
}));
|
|
3254
3796
|
});
|
|
3255
3797
|
//#endregion
|
|
3256
3798
|
//#region src/commands/subtask.ts
|
|
@@ -3268,6 +3810,13 @@ const voiceRecordingInput = Options.text("voice-recording-input").pipe(Options.w
|
|
|
3268
3810
|
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);
|
|
3269
3811
|
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);
|
|
3270
3812
|
const submitOption = Options.boolean("submit").pipe(Options.withDescription("Require the recipient to explicitly submit the subtask. Without this, it auto-completes once the required inputs are filled."));
|
|
3813
|
+
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."));
|
|
3814
|
+
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"));
|
|
3815
|
+
const replyOption = Options.choice("reply", [
|
|
3816
|
+
"one-shot",
|
|
3817
|
+
"sticky",
|
|
3818
|
+
"one-time-per-user"
|
|
3819
|
+
]).pipe(Options.withDescription("Show a reply composer on the recipient's subtask: 'one-shot' (first reply wins, closes the slot), 'sticky' (open indefinitely), 'one-time-per-user' (one reply per user)."), Options.optional);
|
|
3271
3820
|
const markdownOption = Options.boolean("markdown").pipe(Options.withDescription("Render the subtask body as Markdown on the recipient's device (sets contentFormat=markdown)."));
|
|
3272
3821
|
const noEncryptOption = Options.boolean("no-encrypt").pipe(Options.withDescription("For org appends: send fields in plaintext even when the org vault is unlocked."));
|
|
3273
3822
|
const instanceOption = Options.text("instance").pipe(Options.withDescription("With a group append token (grptsk_ group): append only to these member task instances (tsk_ ids printed by `sp task`). Repeatable; without it the subtask goes to every member."), Options.repeated);
|
|
@@ -3286,6 +3835,9 @@ const subtaskCommand = Command.make("subtask", {
|
|
|
3286
3835
|
link: linkOption,
|
|
3287
3836
|
file: fileOption,
|
|
3288
3837
|
submit: submitOption,
|
|
3838
|
+
wait: waitOption,
|
|
3839
|
+
format: formatOption,
|
|
3840
|
+
reply: replyOption,
|
|
3289
3841
|
markdown: markdownOption,
|
|
3290
3842
|
"no-encrypt": noEncryptOption,
|
|
3291
3843
|
instance: instanceOption,
|
|
@@ -3306,18 +3858,20 @@ const subtaskCommand = Command.make("subtask", {
|
|
|
3306
3858
|
catch: (e) => new UserError({ message: e instanceof Error ? e.message : String(e) })
|
|
3307
3859
|
});
|
|
3308
3860
|
if (content === void 0 && inputs.length === 0) return yield* Effect.fail(new UserError({ message: "a subtask needs --content or at least one input" }));
|
|
3861
|
+
if (inputs.length === 0 && args.wait) yield* out.warn("--wait requested but no inputs were defined; the server will never produce a SubtaskCompleted event");
|
|
3309
3862
|
const opts = {
|
|
3310
3863
|
...title !== void 0 ? { title } : {},
|
|
3311
3864
|
...content !== void 0 ? { content } : {},
|
|
3312
3865
|
...inputs.length > 0 ? { inputs } : {},
|
|
3313
3866
|
links: [...args.link],
|
|
3314
3867
|
autoCommit: !args.submit,
|
|
3868
|
+
...Option.isSome(args.reply) ? { reply: args.reply.value } : {},
|
|
3315
3869
|
...args.markdown ? { contentFormat: "markdown" } : {}
|
|
3316
3870
|
};
|
|
3317
3871
|
const instances = args.instance.length > 0 ? [...args.instance] : void 0;
|
|
3318
|
-
|
|
3872
|
+
const apiToken = Option.getOrUndefined(args["api-token"]);
|
|
3873
|
+
if (apiToken !== void 0) {
|
|
3319
3874
|
const files = yield* buildFiles(args.file);
|
|
3320
|
-
const apiToken = yield* requireApiToken(args["api-token"]);
|
|
3321
3875
|
yield* Effect.scoped(Effect.gen(function* () {
|
|
3322
3876
|
const client = yield* acquireClient({
|
|
3323
3877
|
baseUrl: args["base-url"],
|
|
@@ -3325,53 +3879,129 @@ const subtaskCommand = Command.make("subtask", {
|
|
|
3325
3879
|
passwords: args.password
|
|
3326
3880
|
});
|
|
3327
3881
|
if (willEncrypt(args.password, topic)) yield* out.info("encrypting outgoing subtask (Argon2id, this takes a moment)");
|
|
3328
|
-
|
|
3882
|
+
const resp = yield* sdkCall("subtask append", () => client.appendSubtask({
|
|
3329
3883
|
appendToken,
|
|
3330
|
-
topic,
|
|
3884
|
+
...topic !== void 0 ? { topic } : {},
|
|
3331
3885
|
...instances !== void 0 ? { instances } : {},
|
|
3332
3886
|
...opts,
|
|
3333
3887
|
...files.length > 0 ? { files } : {}
|
|
3334
|
-
}))
|
|
3888
|
+
}));
|
|
3889
|
+
yield* printSubtaskResponse(resp, {
|
|
3890
|
+
format: args.format,
|
|
3891
|
+
wait: args.wait
|
|
3892
|
+
});
|
|
3893
|
+
if (args.wait) yield* waitForSubtaskCompletion(client, resp);
|
|
3335
3894
|
}));
|
|
3336
3895
|
return;
|
|
3337
3896
|
}
|
|
3897
|
+
if (topic !== void 0) return yield* Effect.fail(new UserError({ message: "a personal append (-t/--topic) needs an API token (--api-token or $SP_API_TOKEN)" }));
|
|
3338
3898
|
yield* sendOrgSubtask({
|
|
3339
3899
|
appendToken,
|
|
3340
3900
|
opts,
|
|
3341
3901
|
instances,
|
|
3342
3902
|
files: [...args.file],
|
|
3343
|
-
noEncrypt: args["no-encrypt"]
|
|
3903
|
+
noEncrypt: args["no-encrypt"],
|
|
3904
|
+
wait: args.wait,
|
|
3905
|
+
format: args.format
|
|
3344
3906
|
});
|
|
3345
3907
|
}));
|
|
3346
|
-
/** Shared output contract for both append paths.
|
|
3347
|
-
* on stdout
|
|
3348
|
-
*
|
|
3349
|
-
|
|
3908
|
+
/** Shared output contract for both append paths. `text`: the bare sub_ id(s)
|
|
3909
|
+
* on stdout (group: one per member, with the taskId -> subtaskId mapping on
|
|
3910
|
+
* the info channel). `json`: ONE `sent` line carrying the PARENT chain
|
|
3911
|
+
* instances (createdAt = the append time), the machine handle `sp collect`
|
|
3912
|
+
* consumes — collect streams the chain and stamps sub_ ids on subtask-scoped
|
|
3913
|
+
* items. With `--wait` the stdout contract is the completion value instead,
|
|
3914
|
+
* so the json line is suppressed (text ids still print, matching a bare
|
|
3915
|
+
* append). */
|
|
3916
|
+
const printSubtaskResponse = (resp, opts, suffix = "") => Effect.gen(function* () {
|
|
3350
3917
|
const out = yield* CliOutput;
|
|
3351
3918
|
if (isSubtaskGroupResponse(resp)) {
|
|
3352
3919
|
yield* out.info(`subtask appended to group ${resp.groupId} (${resp.subtasks.length} member${resp.subtasks.length === 1 ? "" : "s"})${suffix}`);
|
|
3353
|
-
yield* Effect.forEach(resp.subtasks, (s) => out.info(`instance: ${s.taskId} -> subtask ${s.subtaskId}`).pipe(Effect.zipRight(out.print(s.subtaskId))));
|
|
3920
|
+
yield* Effect.forEach(resp.subtasks, (s) => out.info(`instance: ${s.taskId} -> subtask ${s.subtaskId}`).pipe(Effect.zipRight(opts.format === "text" ? out.print(s.subtaskId) : Effect.void)));
|
|
3921
|
+
if (opts.format === "json" && !opts.wait) yield* out.print(formatSent(resp.groupId, resp.createdAt, resp.subtasks.map((s) => ({
|
|
3922
|
+
id: s.taskId,
|
|
3923
|
+
kind: "task",
|
|
3924
|
+
recipient: null,
|
|
3925
|
+
subtaskId: s.subtaskId
|
|
3926
|
+
}))));
|
|
3354
3927
|
} else {
|
|
3355
3928
|
yield* out.info(`subtask appended: ${resp.subtaskId}${suffix}`);
|
|
3356
|
-
yield* out.print(resp.subtaskId);
|
|
3929
|
+
if (opts.format === "text") yield* out.print(resp.subtaskId);
|
|
3930
|
+
else if (!opts.wait) yield* out.print(formatSent(void 0, resp.createdAt, [{
|
|
3931
|
+
id: resp.taskId,
|
|
3932
|
+
kind: "task",
|
|
3933
|
+
recipient: null,
|
|
3934
|
+
subtaskId: resp.subtaskId
|
|
3935
|
+
}]));
|
|
3357
3936
|
}
|
|
3358
3937
|
});
|
|
3359
|
-
/** Append a subtask to an org task over the CLI bearer session
|
|
3360
|
-
*
|
|
3361
|
-
* `sendOrgTask` in task.ts; the
|
|
3938
|
+
/** Append a subtask to an org task over the CLI bearer session via the SDK's
|
|
3939
|
+
* OrgClient, encrypting each field (and any file bytes) under the current org
|
|
3940
|
+
* master_key when the vault is unlocked. Mirrors `sendOrgTask` in task.ts; the
|
|
3941
|
+
* subtask inherits the parent's recipients. */
|
|
3362
3942
|
const sendOrgSubtask = (params) => Effect.gen(function* () {
|
|
3363
3943
|
const api = yield* Api;
|
|
3364
3944
|
const vault = yield* (yield* VaultAccess).forSendOrPlaintext(params.noEncrypt);
|
|
3365
|
-
const
|
|
3366
|
-
|
|
3367
|
-
version:
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
const
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3945
|
+
const auth = yield* api.session;
|
|
3946
|
+
const orgMasterKeys = vault ? [vault.masterKeyCurrent, ...vault.masterKeyHistory].map((k) => ({
|
|
3947
|
+
version: k.version,
|
|
3948
|
+
key: k.key
|
|
3949
|
+
})) : void 0;
|
|
3950
|
+
const files = yield* buildFiles(params.files);
|
|
3951
|
+
yield* Effect.scoped(Effect.gen(function* () {
|
|
3952
|
+
const client = yield* acquireOrgClient({
|
|
3953
|
+
baseUrl: auth.baseUrl,
|
|
3954
|
+
bearerToken: bearerToken(auth),
|
|
3955
|
+
...orgMasterKeys !== void 0 ? { orgMasterKeys } : {}
|
|
3956
|
+
});
|
|
3957
|
+
const payload = yield* sdkCall("subtask append", () => client.appendSubtask({
|
|
3958
|
+
appendToken: params.appendToken,
|
|
3959
|
+
...params.instances !== void 0 ? { instances: params.instances } : {},
|
|
3960
|
+
...params.opts,
|
|
3961
|
+
...files.length > 0 ? { files } : {}
|
|
3962
|
+
}));
|
|
3963
|
+
yield* printSubtaskResponse(payload, {
|
|
3964
|
+
format: params.format,
|
|
3965
|
+
wait: params.wait
|
|
3966
|
+
}, vault ? ` (encrypted under master_key v${vault.masterKeyCurrent.version})` : " (plaintext)");
|
|
3967
|
+
if (params.wait) yield* waitForSubtaskCompletion(client, payload);
|
|
3968
|
+
}));
|
|
3969
|
+
});
|
|
3970
|
+
/** Block until the appended subtask completes and print the answer, mirroring
|
|
3971
|
+
* `sp task --wait`. Rehydrates observe handles from the append response ids
|
|
3972
|
+
* (single: the one subtask; group: every minted sibling, first completion
|
|
3973
|
+
* wins) and streams their `inputs()` off the shared event hub. A sub-stream
|
|
3974
|
+
* that ends without completing (canceled, declined, chain deleted) ends
|
|
3975
|
+
* benignly; no completion anywhere is a failure (exit 1, empty stdout). */
|
|
3976
|
+
const waitForSubtaskCompletion = (client, resp) => Effect.gen(function* () {
|
|
3977
|
+
const out = yield* CliOutput;
|
|
3978
|
+
const watches = isSubtaskGroupResponse(resp) ? resp.subtasks.map((m) => client.watchSubtask({
|
|
3979
|
+
subtaskId: m.subtaskId,
|
|
3980
|
+
taskId: m.taskId,
|
|
3981
|
+
createdAt: resp.createdAt
|
|
3982
|
+
})) : [client.watchSubtask({
|
|
3983
|
+
subtaskId: resp.subtaskId,
|
|
3984
|
+
taskId: resp.taskId,
|
|
3985
|
+
createdAt: resp.createdAt
|
|
3986
|
+
})];
|
|
3987
|
+
if (watches.length === 0) {
|
|
3988
|
+
yield* out.warn("--wait requested but the append minted no subtasks; nothing will complete");
|
|
3989
|
+
return yield* Effect.fail(new Aborted());
|
|
3990
|
+
}
|
|
3991
|
+
yield* out.info(watches.length === 1 ? `waiting for completion of subtask ${watches[0].subtaskId}` : `waiting for the first completion across ${watches.length} subtask instance(s)`);
|
|
3992
|
+
const completions = watches.map((w) => sdkStream("subtask wait stream", (signal) => w.inputs({
|
|
3993
|
+
replay: true,
|
|
3994
|
+
signal
|
|
3995
|
+
})).pipe(Stream.filter((ev) => ev.kind === "subtaskCompleted"), Stream.take(1)));
|
|
3996
|
+
const first = yield* Stream.mergeAll(completions, { concurrency: "unbounded" }).pipe(Stream.runHead, Effect.mapError((e) => {
|
|
3997
|
+
return new UserError({ message: `stream failed while waiting: ${e.cause instanceof Error ? e.cause.message : String(e.cause)}` });
|
|
3998
|
+
}));
|
|
3999
|
+
if (Option.isNone(first)) {
|
|
4000
|
+
yield* out.warn("every subtask ended (canceled, declined, or chain deleted) before a completion");
|
|
4001
|
+
return yield* Effect.fail(new Aborted());
|
|
4002
|
+
}
|
|
4003
|
+
const ev = first.value;
|
|
4004
|
+
yield* out.print(completionValue(ev.kind === "subtaskCompleted" ? ev.uploads : []));
|
|
3375
4005
|
});
|
|
3376
4006
|
//#endregion
|
|
3377
4007
|
//#region src/main.ts
|
|
@@ -3384,17 +4014,19 @@ process.stderr.on("error", onPipeError(false));
|
|
|
3384
4014
|
const root = Command.make("simplepush").pipe(Command.withSubcommands([
|
|
3385
4015
|
authCommand,
|
|
3386
4016
|
orgCommand,
|
|
4017
|
+
integrationCommand,
|
|
3387
4018
|
eventsCommand,
|
|
3388
4019
|
collectCommand,
|
|
3389
4020
|
daemonCommand,
|
|
3390
4021
|
downloadCommand,
|
|
3391
4022
|
notifyCommand,
|
|
3392
4023
|
taskCommand,
|
|
3393
|
-
subtaskCommand
|
|
4024
|
+
subtaskCommand,
|
|
4025
|
+
cancelCommand
|
|
3394
4026
|
]));
|
|
3395
4027
|
const cli = Command.run(root, {
|
|
3396
4028
|
name: "Simplepush CLI",
|
|
3397
|
-
version: "0.
|
|
4029
|
+
version: "0.2.0"
|
|
3398
4030
|
});
|
|
3399
4031
|
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));
|
|
3400
4032
|
/** Render any failure through the typed-error table, then re-fail so the
|