@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/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 { mkdir } from "node:fs/promises";
13
- import { Client, OrgClient, TypeFilter, buildOrgNotificationRequest, buildOrgSubtaskRequest, buildOrgTaskRequest, isNotificationGroupResponse, isSubtaskGroupResponse, isTaskGroupResponse, prepareFileAttachments, tryDecryptEventData, uploadFileAttachments } from "@simplepush/sdk";
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 `events` and `get`; `collect` falls back to the logged-in org session when omitted. Defaults to $SP_API_TOKEN."), Options.withFallbackConfig(Config.string("SP_API_TOKEN")), Options.optional);
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
- members: members.map((m) => ({
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-member status. Always the last line on a clean run. */
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.members = members;
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
- members: Schema.optional(Schema.Array(Schema.Struct({
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("Member instance id to collect: a task (tsk_…) or a notification (ntf_…). Repeatable. Augments/overrides the piped `sent` line's members."), Options.repeated),
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 member to complete by default).")),
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
- if (id && !seen.has(id)) {
1496
- seen.add(id);
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?.members ?? []) addMember(m.taskId ?? m.notificationId, m.recipient ? {
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 id of args.instance) addMember(id, null);
1509
- if (members.length === 0) return yield* Effect.fail(new UserError({ message: "no members to collect: pipe a send's `sent` line (`sp task --format json | sp collect`) or pass --instance <tsk_…|ntf_…> (repeatable)" }));
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_…) members in one collect — run one per kind" }));
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} member(s)${groupId ? ` of ${groupId}` : ""}`);
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 IDLE_MS = 1e4;
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
- if (scopeId.startsWith("sub_")) return Effect.fail(new UserError({ message: "subtask files are addressed by their parent task: pass the tsk_… id (collect reply/input lines carry it as taskId)" }));
1676
- const kind = scopeId.startsWith("tsk_") ? "task" : scopeId.startsWith("sbm_") ? "submission" : void 0;
1677
- if (kind === void 0) return Effect.fail(new UserError({ message: `expected a task (tsk_…) or submission (sbm_…) scope id, got '${scopeId}'` }));
1678
- const wantedScope = fileId.startsWith("inp_") || fileId.startsWith("rfl_") ? "task" : fileId.startsWith("sbf_") ? "submission" : void 0;
1679
- if (wantedScope === void 0) return Effect.fail(new UserError({ message: `expected an input upload (inp_…), reply file (rfl_…), or submission file (sbf_…) id, got '${fileId}'` }));
1680
- if (wantedScope !== kind) return Effect.fail(new UserError({ message: wantedScope === "task" ? `${fileId} is a task-scoped file — pass its tsk_… id, not ${scopeId}` : `${fileId} is a submission file — pass its sbm_… id, not ${scopeId}` }));
1681
- return Effect.succeed(kind);
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_…, the `taskId` on collect reply/input lines) or a submission (sbm_…, the `id` on submission lines).")),
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
- yield* Effect.scoped(Effect.gen(function* () {
1701
- const health = {
1702
- reconnects: 0,
1703
- lastError: void 0,
1704
- eventsSeen: 0
1705
- };
1706
- const onReconnect = (_attempt, _backoffMs, lastError) => {
1707
- health.reconnects += 1;
1708
- if (lastError) health.lastError = lastError;
1709
- };
1710
- const client = cred.kind === "personal" ? yield* acquireClient({
1711
- baseUrl: cred.baseUrl,
1712
- apiToken: cred.apiToken,
1713
- passwords: [...args.password],
1714
- onReconnect
1715
- }) : yield* acquireOrgClient({
1716
- baseUrl: cred.baseUrl,
1717
- bearerToken: cred.bearer,
1718
- ...orgKeys !== void 0 ? { orgMasterKeys: orgKeys } : {},
1719
- onReconnect
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
- const matching = (item) => Option.fromNullable(fileViewsOf(item).find((f) => f.id === args.fileId));
1722
- const countSeen = (s) => s.pipe(Stream.tap(() => Effect.sync(() => {
1723
- health.eventsSeen += 1;
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
- if (args.format === "json") yield* out.print(JSON.stringify({
1749
- type: "downloaded",
1750
- id: file.id ?? null,
1751
- path,
1752
- filename: file.filename ?? null,
1753
- contentType: file.contentType ?? null,
1754
- size: file.size ?? null
1755
- }));
1756
- else yield* out.print(path);
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.passwordFingerprint})` : `org (v${event.encryption.v})`;
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$2 = Options.choice("format", [
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 QUIET_EXIT_AFTER_DRAIN = "2 seconds";
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$2,
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 apiToken = yield* requireApiToken(args["api-token"]);
1844
- const baseUrl = args["base-url"];
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
- ...Option.match(webSocketFactory, {
1863
- onNone: () => ({}),
1864
- onSome: (f) => ({ webSocketFactory: f })
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) if (keyring.size > 0) yield* out.info(`keyring built with ${keyring.size} fingerprint(s)`);
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
- yield* out.info(`connecting to ${baseUrl.replace(/\/+$/, "")}/ws/v1/events${sinceIso ? `?since=${sinceIso}` : ""}`);
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(exitWhenQuiet ? Stream.timeoutTo(QUIET_EXIT_AFTER_DRAIN, Stream.drain(Stream.fromEffect(note("history drained, exiting (use --follow to keep streaming)")))) : (s) => s, untilDate !== void 0 ? Stream.takeUntilEffect((ev) => untilReached(ev) ? note("--until reached, exiting").pipe(Effect.as(true)) : Effect.succeed(false)) : (s) => s, Stream.filter((ev) => !untilReached(ev) && filter.matches(ev)), Stream.mapEffect((ev) => Effect.gen(function* () {
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) yield* note(`--limit ${limit} reached, exiting`);
1888
- })), limit !== void 0 ? Stream.take(limit) : (s) => s, Stream.runDrain);
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$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"));
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$1,
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
- const contentType = notifyMediaContentType(mediaUrl, mediaKind);
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 media = mediaUrl !== void 0 ? {
2346
- type: "link",
2347
- url: mediaUrl,
2348
- contentType: mediaContentType
2349
- } : void 0;
2350
- const masterKey = vault ? {
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
- ...args.shared ? { shared: true } : {}
2670
+ ...imageUrl !== void 0 ? { image: imageUrl } : {},
2671
+ ...audioUrl !== void 0 ? { audio: audioUrl } : {}
2360
2672
  };
2361
- const body = yield* sdkCall("build notification request", () => buildOrgNotificationRequest(target, opts, media, masterKey));
2362
- const payload = yield* api.postJson("notify", "/v1/org/notifications/json", Schema.Unknown, body);
2363
- const encNote = vault ? ` (encrypted under master_key v${vault.masterKeyCurrent.version})` : " (plaintext)";
2364
- if (isNotificationGroupResponse(payload)) {
2365
- const n = payload.instances.length;
2366
- yield* out.info(`notification group created: ${payload.groupId} (${n} recipient${n === 1 ? "" : "s"})${encNote}`);
2367
- yield* Effect.forEach(payload.instances, (inst) => {
2368
- const who = `${inst.recipient.publicId}${inst.recipient.name ? ` (${inst.recipient.name})` : ""}`;
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 = payload.instances.map((inst) => ({
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(payload.groupId, payload.createdAt, members));
2382
- } else yield* out.print(payload.groupId);
2383
- } else {
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("Confirm you have copied the passphrase 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."));
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 matchedInvite = knownInvites.find((inv) => sodium.constantTimeEqual(sodium.hmacInviteBinding(inv.code, pubkey), storedHmac));
2576
- if (!matchedInvite) {
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
- unverified: counts.unverified + 1
3031
+ alreadyCurrent: counts.alreadyCurrent + 1
2580
3032
  };
2581
- 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.)`);
3033
+ return;
2582
3034
  }
2583
- if (dev.wrappedKeys.some((w) => w.version === currentMaster.version)) {
3035
+ const matchedInvite = knownInvites.find((inv) => sodium.constantTimeEqual(sodium.hmacInviteBinding(inv.code, pubkey), storedHmac));
3036
+ if (!matchedInvite) {
2584
3037
  counts = {
2585
3038
  ...counts,
2586
- alreadyCurrent: counts.alreadyCurrent + 1
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 = [...dev.wrappedKeys.filter((w) => w.version !== currentMaster.version), {
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 counts = yield* wrapCurrentKeyToAllDevices(yield* (yield* VaultAccess).getOrPrompt);
2618
- yield* out.info(`Sync complete. ${summarize(counts)}`);
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(`Rotation complete. ${summarize(counts)}`);
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
- format: formatOption,
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
- const uploads = ev.kind === "taskCompleted" ? ev.uploads : [];
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
- yield* out.print(typeof value === "string" ? value : JSON.stringify(uploads));
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, encrypting each user-visible field under the current
3192
- * org master_key when the vault is unlocked. Mirrors `sp notify` and the SDK's
3193
- * OrgClient.sendTask field set (tag/title/content/links + each input's
3194
- * description/default/options). File attachments are not supported (the bearer
3195
- * session can't drive uploads). */
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.shared ? { shared: true } : {}
3731
+ ...params.expiresAt !== void 0 ? { expiresAt: params.expiresAt } : {}
3214
3732
  };
3215
- const masterKey = vault ? {
3216
- key: vault.masterKeyCurrent.key,
3217
- version: vault.masterKeyCurrent.version
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
- if (isTaskGroupResponse(payload)) {
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: ${payload.groupId} (${payload.instances.length} recipient${payload.instances.length === 1 ? "" : "s"})`);
3228
- yield* out.info(`Append: ${payload.groupAppendToken}`);
3229
- yield* Effect.forEach(payload.instances, (inst) => {
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 (payload.instances.length === 0) yield* out.warn("the target has no recipients — the group is empty");
3234
- if (params.format === "json") yield* out.print(formatSent(payload.groupId, payload.createdAt, payload.instances.map((i) => ({
3235
- id: i.taskId,
3236
- kind: "task",
3237
- recipient: {
3238
- publicId: i.recipient.publicId,
3239
- name: i.recipient.name ?? null
3240
- }
3241
- }))));
3242
- else yield* out.print(payload.groupId);
3243
- } else {
3244
- yield* out.info(`Org task sent${enc}.`);
3245
- yield* out.info(`Id: ${payload.taskId}`);
3246
- yield* out.info(`Append: ${payload.appendToken}`);
3247
- if (params.format === "json") yield* out.print(formatSent(void 0, payload.createdAt, [{
3248
- id: payload.taskId,
3249
- kind: "task",
3250
- recipient: null
3251
- }]));
3252
- else yield* out.print(payload.taskId);
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
- if (topic !== void 0) {
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
- yield* printSubtaskResponse(yield* sdkCall("subtask append", () => client.appendSubtask({
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. Single append: one subtask id
3347
- * on stdout. Group append (grptsk_ token): one subtask id per member on stdout,
3348
- * with the taskId -> subtaskId mapping on the info channel. */
3349
- const printSubtaskResponse = (resp, suffix = "") => Effect.gen(function* () {
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, encrypting each
3360
- * field under the current org master_key when the vault is unlocked. Mirrors
3361
- * `sendOrgTask` in task.ts; the subtask inherits the parent's recipients. */
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 masterKey = vault ? {
3366
- key: vault.masterKeyCurrent.key,
3367
- version: vault.masterKeyCurrent.version
3368
- } : void 0;
3369
- const fileAttachments = yield* buildFiles(params.files);
3370
- const prepared = yield* sdkCall("prepare attachments", () => prepareFileAttachments(fileAttachments, masterKey?.key));
3371
- const body = yield* sdkCall("build subtask request", () => buildOrgSubtaskRequest(params.appendToken, params.opts, masterKey, params.instances, prepared.map((p) => p.meta)));
3372
- const payload = yield* api.postJson("subtask append", "/v1/org/subtasks/json", Schema.Unknown, body);
3373
- yield* uploadOrgAttachments(prepared, payload.attachments);
3374
- yield* printSubtaskResponse(payload, vault ? ` (encrypted under master_key v${vault.masterKeyCurrent.version})` : " (plaintext)");
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.1.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