@simplepush/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.mjs ADDED
@@ -0,0 +1,3417 @@
1
+ #!/usr/bin/env node
2
+ import { Args, Command, HelpDoc, Options, Prompt, ValidationError } from "@effect/cli";
3
+ import { FetchHttpClient, FileSystem, HttpBody, HttpClient, HttpClientRequest, HttpClientResponse, Path, Terminal } from "@effect/platform";
4
+ import { NodeContext, NodeRuntime, NodeSocketServer } from "@effect/platform-node";
5
+ import { Cause, Chunk, Config, Data, Deferred, Duration, Effect, Exit, Fiber, HashSet, Layer, Option, ParseResult, PubSub, Redacted, Ref, Schedule, Schema, Stream, SubscriptionRef } from "effect";
6
+ import { createHash, randomBytes } from "node:crypto";
7
+ import _sodium from "libsodium-wrappers-sumo";
8
+ import { wordlist } from "@scure/bip39/wordlists/english.js";
9
+ import { homedir, tmpdir } from "node:os";
10
+ import { createServer } from "node:http";
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";
14
+ import { connect, createConnection } from "node:net";
15
+ import { basename, extname, join } from "node:path";
16
+ //#region src/errors.ts
17
+ /** No CLI session saved — `sp auth login` hasn't been run (or was logged out). */
18
+ var NotLoggedIn = class extends Data.TaggedError("NotLoggedIn") {};
19
+ /** A personal-credential command was invoked without --api-token / $SP_API_TOKEN. */
20
+ var MissingApiToken = class extends Data.TaggedError("MissingApiToken") {};
21
+ /** The backend answered non-2xx. `action` names the operation for the message. */
22
+ var ApiFailure = class extends Data.TaggedError("ApiFailure") {};
23
+ /** The request never produced an HTTP response (DNS, refused, TLS...). */
24
+ var TransportFailure = class extends Data.TaggedError("TransportFailure") {};
25
+ /** A promise-based SDK call or stream failed. */
26
+ var SdkFailure = class extends Data.TaggedError("SdkFailure") {};
27
+ /** A libsodium operation failed or was fed mis-sized material. */
28
+ var CryptoFailure = class extends Data.TaggedError("CryptoFailure") {};
29
+ /** Vault blob would not decrypt — wrong passphrase or corrupted blob. */
30
+ var VaultUnlockFailed = class extends Data.TaggedError("VaultUnlockFailed") {};
31
+ /** The org has no encryption config enabled. */
32
+ var EncryptionDisabled = class extends Data.TaggedError("EncryptionDisabled") {};
33
+ /** A user-facing validation / usage error with a ready-to-print message. */
34
+ var UserError = class extends Data.TaggedError("UserError") {};
35
+ /** The failure has already been reported on stderr — exit 1 silently. */
36
+ var Aborted = class extends Data.TaggedError("Aborted") {};
37
+ const causeMessage = (cause) => cause instanceof Error ? cause.message : String(cause);
38
+ /** One error -> one stderr line (sans the `error: ` prefix added by the
39
+ * renderer). `undefined` means "print nothing" — the error was already
40
+ * reported (Aborted), or another layer printed it (@effect/cli usage text,
41
+ * an interrupted prompt). */
42
+ function renderError(e) {
43
+ if (e instanceof NotLoggedIn) return "not logged in. Run `sp auth login` first.";
44
+ if (e instanceof MissingApiToken) return "an API token is required: pass --api-token or set $SP_API_TOKEN";
45
+ if (e instanceof ApiFailure) return `${e.action} failed (${e.status}): ${e.detail}`;
46
+ if (e instanceof TransportFailure) return `${e.action} failed: ${causeMessage(e.cause)}`;
47
+ if (e instanceof SdkFailure) return `${e.action} failed: ${causeMessage(e.cause)}`;
48
+ if (e instanceof CryptoFailure) return e.message;
49
+ if (e instanceof VaultUnlockFailed) return "could not unlock the vault — passphrase is wrong, or the vault is corrupted.";
50
+ if (e instanceof EncryptionDisabled) return "encryption is not enabled for this org. Run `org encryption enable` first.";
51
+ if (e instanceof UserError) return e.message;
52
+ if (e instanceof Aborted) return void 0;
53
+ if (ValidationError.isValidationError(e)) return void 0;
54
+ if (e instanceof Terminal.QuitException) return void 0;
55
+ if (ParseResult.isParseError(e)) return ParseResult.TreeFormatter.formatErrorSync(e);
56
+ return causeMessage(e);
57
+ }
58
+ //#endregion
59
+ //#region src/services/output.ts
60
+ const isEpipe = (e) => e instanceof Error && e.code === "EPIPE";
61
+ var CliOutput = class extends Effect.Service()("cli/CliOutput", { effect: Effect.gen(function* () {
62
+ const quietRef = yield* Ref.make(false);
63
+ const stderr = (line) => Effect.sync(() => {
64
+ try {
65
+ process.stderr.write(line + "\n");
66
+ } catch (e) {
67
+ if (!isEpipe(e)) throw e;
68
+ }
69
+ });
70
+ return {
71
+ setQuiet: (quiet) => Ref.set(quietRef, quiet),
72
+ info: (msg) => Effect.flatMap(Ref.get(quietRef), (quiet) => quiet ? Effect.void : stderr(`info: ${msg}`)),
73
+ warn: (msg) => stderr(`warn: ${msg}`),
74
+ error: (msg) => stderr(`error: ${msg}`),
75
+ /** One payload line on stdout, flushed immediately. EPIPE means the
76
+ * downstream reader is gone (`sp collect | head`, a dead pipeline
77
+ * neighbor): stop writing and exit quietly, like any pipeline citizen. */
78
+ print: (line) => Effect.sync(() => {
79
+ try {
80
+ process.stdout.write(line + "\n");
81
+ } catch (e) {
82
+ if (isEpipe(e)) process.exit(0);
83
+ throw e;
84
+ }
85
+ })
86
+ };
87
+ }) }) {};
88
+ //#endregion
89
+ //#region src/crypto/params.ts
90
+ const KdfParams = Schema.Struct({
91
+ algo: Schema.Literal("argon2id"),
92
+ t: Schema.Number,
93
+ m: Schema.Number,
94
+ p: Schema.Number
95
+ });
96
+ const DEFAULT_KDF_PARAMS = {
97
+ algo: "argon2id",
98
+ t: 3,
99
+ m: 64 * 1024 * 1024,
100
+ p: 1
101
+ };
102
+ const MasterKeySchema = Schema.Struct({
103
+ version: Schema.Number,
104
+ key: Schema.Uint8ArrayFromSelf
105
+ });
106
+ const VaultContents = Schema.Struct({
107
+ adminPublicKey: Schema.Uint8ArrayFromSelf,
108
+ adminPrivateKey: Schema.Uint8ArrayFromSelf,
109
+ masterKeyCurrent: MasterKeySchema,
110
+ masterKeyHistory: Schema.Array(MasterKeySchema)
111
+ });
112
+ const MasterKeyJson = Schema.Struct({
113
+ version: Schema.Number,
114
+ key: Schema.Uint8ArrayFromBase64
115
+ });
116
+ const VaultJson = Schema.Struct({
117
+ formatVersion: Schema.Literal(1),
118
+ adminPublicKey: Schema.Uint8ArrayFromBase64,
119
+ adminPrivateKey: Schema.Uint8ArrayFromBase64,
120
+ masterKeyCurrent: MasterKeyJson,
121
+ masterKeyHistory: Schema.Array(MasterKeyJson)
122
+ });
123
+ function normalizePassphrase(input) {
124
+ return input.trim().toLowerCase().split(/\s+/).join(" ");
125
+ }
126
+ function normalizeInviteCode(input) {
127
+ return input.trim().toUpperCase().replace(/[-\s]/g, "");
128
+ }
129
+ function hashInviteCode(plain) {
130
+ return createHash("sha256").update(normalizeInviteCode(plain), "utf8").digest("hex");
131
+ }
132
+ //#endregion
133
+ //#region src/crypto/sodium.ts
134
+ const WRAP_NONCE_BYTES = 24;
135
+ const VAULT_NONCE_BYTES = 24;
136
+ const InviteAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
137
+ const InviteGroupSize = 4;
138
+ const InviteGroups = 2;
139
+ const encodeVaultJson = Schema.encodeSync(VaultJson);
140
+ const decodeVaultJson = Schema.decodeUnknownSync(Schema.parseJson(VaultJson));
141
+ var Sodium = class extends Effect.Service()("cli/Sodium", { effect: Effect.gen(function* () {
142
+ const sodium = yield* Effect.promise(() => _sodium.ready.then(() => _sodium));
143
+ const fail = (message) => new CryptoFailure({ message });
144
+ const attempt = (message, f) => Effect.try({
145
+ try: f,
146
+ catch: (e) => fail(e instanceof CryptoFailure ? e.message : message)
147
+ });
148
+ const toB64 = (bytes) => sodium.to_base64(bytes, sodium.base64_variants.ORIGINAL);
149
+ const fromB64 = (s) => sodium.from_base64(s, sodium.base64_variants.ORIGINAL);
150
+ return {
151
+ toB64,
152
+ fromB64,
153
+ randomBytes: (length) => Effect.sync(() => sodium.randombytes_buf(length)),
154
+ generateVaultSalt: Effect.sync(() => sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES)),
155
+ generateMasterKey: Effect.sync(() => sodium.randombytes_buf(sodium.crypto_aead_xchacha20poly1305_ietf_KEYBYTES)),
156
+ generateAdminKeyPair: Effect.sync(() => {
157
+ const kp = sodium.crypto_box_keypair();
158
+ return {
159
+ publicKey: kp.publicKey,
160
+ privateKey: kp.privateKey
161
+ };
162
+ }),
163
+ generatePassphrase: (wordCount = 8) => Effect.gen(function* () {
164
+ if (!Number.isInteger(wordCount) || wordCount < 1) return yield* fail(`wordCount must be a positive integer (got ${wordCount})`);
165
+ if (wordlist.length !== 2048) return yield* fail(`unexpected wordlist length: ${wordlist.length}`);
166
+ const words = [];
167
+ for (let i = 0; i < wordCount; i++) words.push(wordlist[sodium.randombytes_uniform(wordlist.length)]);
168
+ return words.join(" ");
169
+ }),
170
+ generateInviteCode: Effect.sync(() => {
171
+ const bytes = randomBytes(InviteGroupSize * InviteGroups);
172
+ const groups = [];
173
+ for (let g = 0; g < InviteGroups; g++) {
174
+ let group = "";
175
+ for (let i = 0; i < InviteGroupSize; i++) group += InviteAlphabet.charAt(bytes[g * InviteGroupSize + i] % 31);
176
+ groups.push(group);
177
+ }
178
+ return groups.join("-");
179
+ }),
180
+ deriveVaultKey: (passphrase, salt, params = DEFAULT_KDF_PARAMS) => Effect.gen(function* () {
181
+ if (params.algo !== "argon2id") return yield* fail(`unsupported KDF algorithm: ${params.algo}`);
182
+ if (salt.length !== sodium.crypto_pwhash_SALTBYTES) return yield* fail(`salt must be ${sodium.crypto_pwhash_SALTBYTES} bytes (got ${salt.length})`);
183
+ return yield* attempt("key derivation failed", () => sodium.crypto_pwhash(32, passphrase, salt, params.t, params.m, sodium.crypto_pwhash_ALG_ARGON2ID13));
184
+ }),
185
+ encryptVault: (contents, vaultKey) => Effect.gen(function* () {
186
+ if (vaultKey.length !== sodium.crypto_aead_xchacha20poly1305_ietf_KEYBYTES) return yield* fail(`vaultKey must be ${sodium.crypto_aead_xchacha20poly1305_ietf_KEYBYTES} bytes`);
187
+ return yield* attempt("vault encryption failed", () => {
188
+ const json = encodeVaultJson({
189
+ formatVersion: 1,
190
+ ...contents
191
+ });
192
+ const plaintext = new TextEncoder().encode(JSON.stringify(json));
193
+ const nonce = sodium.randombytes_buf(VAULT_NONCE_BYTES);
194
+ const ct = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(plaintext, null, null, nonce, vaultKey);
195
+ const out = new Uint8Array(nonce.length + ct.length);
196
+ out.set(nonce, 0);
197
+ out.set(ct, nonce.length);
198
+ return out;
199
+ });
200
+ }),
201
+ decryptVault: (blob, vaultKey) => Effect.gen(function* () {
202
+ if (vaultKey.length !== sodium.crypto_aead_xchacha20poly1305_ietf_KEYBYTES) return yield* fail(`vaultKey must be ${sodium.crypto_aead_xchacha20poly1305_ietf_KEYBYTES} bytes`);
203
+ if (blob.length < VAULT_NONCE_BYTES + sodium.crypto_aead_xchacha20poly1305_ietf_ABYTES) return yield* fail("vault blob is truncated");
204
+ return yield* attempt("vault decryption failed", () => {
205
+ const nonce = blob.slice(0, VAULT_NONCE_BYTES);
206
+ const ct = blob.slice(VAULT_NONCE_BYTES);
207
+ const plaintext = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(null, ct, null, nonce, vaultKey);
208
+ return decodeVaultJson(new TextDecoder().decode(plaintext));
209
+ });
210
+ }),
211
+ wrapMasterKey: (masterKey, adminPrivateKey, devicePublicKey) => Effect.gen(function* () {
212
+ if (adminPrivateKey.length !== sodium.crypto_box_SECRETKEYBYTES) return yield* fail(`adminPrivateKey must be ${sodium.crypto_box_SECRETKEYBYTES} bytes`);
213
+ if (devicePublicKey.length !== sodium.crypto_box_PUBLICKEYBYTES) return yield* fail(`devicePublicKey must be ${sodium.crypto_box_PUBLICKEYBYTES} bytes`);
214
+ return yield* attempt("master-key wrap failed", () => {
215
+ const nonce = sodium.randombytes_buf(WRAP_NONCE_BYTES);
216
+ const ct = sodium.crypto_box_easy(masterKey, nonce, devicePublicKey, adminPrivateKey);
217
+ const out = new Uint8Array(nonce.length + ct.length);
218
+ out.set(nonce, 0);
219
+ out.set(ct, nonce.length);
220
+ return out;
221
+ });
222
+ }),
223
+ unwrapMasterKey: (blob, devicePrivateKey, adminPublicKey) => Effect.gen(function* () {
224
+ if (devicePrivateKey.length !== sodium.crypto_box_SECRETKEYBYTES) return yield* fail(`devicePrivateKey must be ${sodium.crypto_box_SECRETKEYBYTES} bytes`);
225
+ if (adminPublicKey.length !== sodium.crypto_box_PUBLICKEYBYTES) return yield* fail(`adminPublicKey must be ${sodium.crypto_box_PUBLICKEYBYTES} bytes`);
226
+ if (blob.length < WRAP_NONCE_BYTES + sodium.crypto_box_MACBYTES) return yield* fail("wrapped blob is truncated");
227
+ return yield* attempt("master-key unwrap failed", () => sodium.crypto_box_open_easy(blob.slice(WRAP_NONCE_BYTES), blob.slice(0, WRAP_NONCE_BYTES), adminPublicKey, devicePrivateKey));
228
+ }),
229
+ hmacInviteBinding: (inviteCode, devicePublicKey) => {
230
+ const state = sodium.crypto_auth_hmacsha256_init(normalizeInviteCode(inviteCode));
231
+ sodium.crypto_auth_hmacsha256_update(state, devicePublicKey);
232
+ return sodium.crypto_auth_hmacsha256_final(state);
233
+ },
234
+ constantTimeEqual: (a, b) => a.length === b.length && sodium.memcmp(a, b)
235
+ };
236
+ }) }) {};
237
+ //#endregion
238
+ //#region src/services/stores.ts
239
+ function configDir() {
240
+ if (process.platform === "win32") return `${process.env.APPDATA ?? `${homedir()}/AppData/Roaming`}/simplepush`;
241
+ const xdg = process.env.XDG_CONFIG_HOME;
242
+ return xdg ? `${xdg}/simplepush` : `${homedir()}/.config/simplepush`;
243
+ }
244
+ const isNotFound = (e) => e._tag === "SystemError" && e.reason === "NotFound";
245
+ /** One Schema-validated JSON file with 0600 perms. `load` distinguishes
246
+ * "absent" (Option.none) from real IO/decode failures. */
247
+ const jsonFile = (fileName, schema) => Effect.gen(function* () {
248
+ const fs = yield* FileSystem.FileSystem;
249
+ const path = yield* Path.Path;
250
+ const decode = Schema.decodeUnknown(Schema.parseJson(schema));
251
+ const encode = Schema.encode(schema);
252
+ const filePath = path.join(configDir(), fileName);
253
+ const load = fs.readFileString(filePath).pipe(Effect.flatMap(decode), Effect.map(Option.some), Effect.catchIf(isNotFound, () => Effect.succeed(Option.none())));
254
+ const save = (value) => Effect.gen(function* () {
255
+ yield* fs.makeDirectory(configDir(), { recursive: true }).pipe(Effect.ignore);
256
+ yield* fs.chmod(configDir(), 448).pipe(Effect.ignore);
257
+ const encoded = yield* encode(value);
258
+ yield* fs.writeFileString(filePath, JSON.stringify(encoded, null, 2));
259
+ if (process.platform !== "win32") yield* fs.chmod(filePath, 384);
260
+ return filePath;
261
+ });
262
+ return {
263
+ filePath,
264
+ load,
265
+ save,
266
+ clear: fs.remove(filePath).pipe(Effect.as(true), Effect.catchIf(isNotFound, () => Effect.succeed(false)))
267
+ };
268
+ });
269
+ const StoredAuth = Schema.Struct({
270
+ baseUrl: Schema.String,
271
+ token: Schema.Redacted(Schema.String),
272
+ loggedInAt: Schema.String
273
+ });
274
+ const bearerToken = (auth) => Redacted.value(auth.token);
275
+ var AuthStore = class extends Effect.Service()("cli/AuthStore", { effect: jsonFile("auth.json", StoredAuth) }) {};
276
+ const StoredVault = Schema.Struct({
277
+ formatVersion: Schema.Literal(1),
278
+ adminPublicKeyB64: Schema.Uint8ArrayFromBase64,
279
+ adminPrivateKeyB64: Schema.Uint8ArrayFromBase64,
280
+ masterKeyCurrent: Schema.Struct({
281
+ version: Schema.Number,
282
+ keyB64: Schema.Uint8ArrayFromBase64
283
+ }),
284
+ masterKeyHistory: Schema.Array(Schema.Struct({
285
+ version: Schema.Number,
286
+ keyB64: Schema.Uint8ArrayFromBase64
287
+ }))
288
+ });
289
+ const VaultFromStored = Schema.transform(StoredVault, VaultContents, {
290
+ strict: true,
291
+ decode: (s) => ({
292
+ adminPublicKey: s.adminPublicKeyB64,
293
+ adminPrivateKey: s.adminPrivateKeyB64,
294
+ masterKeyCurrent: {
295
+ version: s.masterKeyCurrent.version,
296
+ key: s.masterKeyCurrent.keyB64
297
+ },
298
+ masterKeyHistory: s.masterKeyHistory.map((m) => ({
299
+ version: m.version,
300
+ key: m.keyB64
301
+ }))
302
+ }),
303
+ encode: (v) => ({
304
+ formatVersion: 1,
305
+ adminPublicKeyB64: v.adminPublicKey,
306
+ adminPrivateKeyB64: v.adminPrivateKey,
307
+ masterKeyCurrent: {
308
+ version: v.masterKeyCurrent.version,
309
+ keyB64: v.masterKeyCurrent.key
310
+ },
311
+ masterKeyHistory: v.masterKeyHistory.map((m) => ({
312
+ version: m.version,
313
+ keyB64: m.key
314
+ }))
315
+ })
316
+ });
317
+ var VaultStore = class extends Effect.Service()("cli/VaultStore", { effect: jsonFile("vault.json", VaultFromStored) }) {};
318
+ const IssuedInvite = Schema.Struct({
319
+ code: Schema.String,
320
+ name: Schema.String,
321
+ role: Schema.Literal("member", "admin"),
322
+ issuedAt: Schema.String,
323
+ expiresAt: Schema.String
324
+ });
325
+ const InvitesFile = Schema.Struct({ invites: Schema.Array(IssuedInvite) });
326
+ var InviteStore = class extends Effect.Service()("cli/InviteStore", { effect: Effect.gen(function* () {
327
+ const file = yield* jsonFile("invites.json", InvitesFile);
328
+ const loadAll = file.load.pipe(Effect.map(Option.match({
329
+ onNone: () => [],
330
+ onSome: (f) => f.invites
331
+ })), Effect.catchTag("ParseError", () => Effect.succeed([])));
332
+ return {
333
+ filePath: file.filePath,
334
+ clear: file.clear,
335
+ /** Drops any prior entry for the same plaintext code so re-issuance under
336
+ * the same code (shouldn't happen, but defensively) can't duplicate. */
337
+ append: (invite) => Effect.gen(function* () {
338
+ const existing = yield* loadAll;
339
+ yield* file.save({ invites: [...existing.filter((i) => i.code !== invite.code), invite] });
340
+ }),
341
+ /** Currently-valid invites only — past-expiration entries are pruned from
342
+ * disk as a side effect, shrinking the HMAC candidate set per device. */
343
+ listValid: Effect.gen(function* () {
344
+ const now = /* @__PURE__ */ new Date();
345
+ const all = yield* loadAll;
346
+ const fresh = all.filter((i) => new Date(i.expiresAt) > now);
347
+ if (fresh.length !== all.length) yield* file.save({ invites: fresh });
348
+ return fresh;
349
+ }),
350
+ /** Removes the given code (post-sync consumption). Idempotent. */
351
+ consume: (code) => Effect.gen(function* () {
352
+ const all = yield* loadAll;
353
+ const next = all.filter((i) => i.code !== code);
354
+ if (next.length === all.length) return false;
355
+ yield* file.save({ invites: next });
356
+ return true;
357
+ })
358
+ };
359
+ }) }) {};
360
+ //#endregion
361
+ //#region src/services/api.ts
362
+ const ErrorBody = Schema.Struct({
363
+ error: Schema.String,
364
+ msg: Schema.String
365
+ });
366
+ const decodeErrorBody = Schema.decodeUnknownOption(Schema.parseJson(ErrorBody));
367
+ const trimSlash = (url) => url.replace(/\/+$/, "");
368
+ var Api = class extends Effect.Service()("cli/Api", {
369
+ dependencies: [AuthStore.Default],
370
+ effect: Effect.gen(function* () {
371
+ const http = yield* HttpClient.HttpClient;
372
+ /** The saved CLI session, or NotLoggedIn. */
373
+ const session = (yield* AuthStore).load.pipe(Effect.orElseSucceed(() => Option.none()), Effect.flatMap(Option.match({
374
+ onNone: () => Effect.fail(new NotLoggedIn()),
375
+ onSome: Effect.succeed
376
+ })));
377
+ /** Extract the human-readable message from a failed response: the standard
378
+ * `{error, msg}` body when present, the raw body text otherwise. */
379
+ const failWith = (action, res) => res.text.pipe(Effect.orElseSucceed(() => ""), Effect.flatMap((body) => {
380
+ const parsed = decodeErrorBody(body);
381
+ const detail = Option.isSome(parsed) && parsed.value.msg ? parsed.value.msg : body || `HTTP ${res.status}`;
382
+ return Effect.fail(new ApiFailure({
383
+ action,
384
+ status: res.status,
385
+ detail
386
+ }));
387
+ }));
388
+ /** Authenticated request; resolves with the (scoped) response once the
389
+ * status is 2xx, fails with ApiFailure/TransportFailure otherwise. */
390
+ const request = (action, method, pathname, body) => Effect.gen(function* () {
391
+ const auth = yield* session;
392
+ const base = HttpClientRequest.make(method)(`${trimSlash(auth.baseUrl)}${pathname}`).pipe(HttpClientRequest.bearerToken(bearerToken(auth)));
393
+ const req = body === void 0 ? base : HttpClientRequest.setBody(base, HttpBody.unsafeJson(body));
394
+ const res = yield* http.execute(req).pipe(Effect.mapError((cause) => new TransportFailure({
395
+ action,
396
+ cause
397
+ })));
398
+ if (res.status >= 400) return yield* failWith(action, res);
399
+ return res;
400
+ });
401
+ /** Request + Schema-decode the JSON success body. */
402
+ const requestJson = (action, method, pathname, schema, body) => Effect.scoped(request(action, method, pathname, body).pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(schema))));
403
+ return {
404
+ session,
405
+ getJson: (action, pathname, schema) => requestJson(action, "GET", pathname, schema),
406
+ postJson: (action, pathname, schema, body) => requestJson(action, "POST", pathname, schema, body),
407
+ putJson: (action, pathname, schema, body) => requestJson(action, "PUT", pathname, schema, body),
408
+ /** Fire-and-forget variants for endpoints whose response body we ignore. */
409
+ post: (action, pathname, body) => Effect.scoped(Effect.asVoid(request(action, "POST", pathname, body))),
410
+ put: (action, pathname, body) => Effect.scoped(Effect.asVoid(request(action, "PUT", pathname, body))),
411
+ delete: (action, pathname) => Effect.scoped(Effect.asVoid(request(action, "DELETE", pathname))),
412
+ /** Unauthenticated POST against an explicit base URL (the `sp auth login`
413
+ * flows run before any session exists). Returns status + body text so
414
+ * callers can branch on OAuth-style error payloads. */
415
+ unauthedPost: (action, url, body) => Effect.scoped(Effect.gen(function* () {
416
+ const base = HttpClientRequest.post(url);
417
+ const req = body === void 0 ? base : HttpClientRequest.setBody(base, HttpBody.unsafeJson(body));
418
+ const res = yield* http.execute(req).pipe(Effect.mapError((cause) => new TransportFailure({
419
+ action,
420
+ cause
421
+ })));
422
+ const text = yield* res.text.pipe(Effect.orElseSucceed(() => ""));
423
+ return {
424
+ status: res.status,
425
+ body: text
426
+ };
427
+ }))
428
+ };
429
+ })
430
+ }) {};
431
+ //#endregion
432
+ //#region src/services/vault-access.ts
433
+ const OrgEncryptionConfig = Schema.Struct({
434
+ enabled: Schema.Boolean,
435
+ adminPubkeyB64: Schema.optional(Schema.NullOr(Schema.String)),
436
+ vaultBlobB64: Schema.optional(Schema.NullOr(Schema.String)),
437
+ vaultSaltB64: Schema.optional(Schema.NullOr(Schema.String)),
438
+ kdfParams: Schema.optional(Schema.NullOr(KdfParams))
439
+ });
440
+ async function readWholeStdin() {
441
+ const chunks = [];
442
+ for await (const chunk of process.stdin) chunks.push(chunk);
443
+ return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
444
+ }
445
+ var VaultAccess = class extends Effect.Service()("cli/VaultAccess", {
446
+ dependencies: [
447
+ Api.Default,
448
+ VaultStore.Default,
449
+ Sodium.Default,
450
+ CliOutput.Default
451
+ ],
452
+ effect: Effect.gen(function* () {
453
+ const api = yield* Api;
454
+ const vaultStore = yield* VaultStore;
455
+ const sodium = yield* Sodium;
456
+ const out = yield* CliOutput;
457
+ /** Hidden-input passphrase prompt on a TTY; piped stdin passes through
458
+ * unchanged (trailing newline trimmed) for scripted flows. */
459
+ const readPassphrase = (message) => process.stdin.isTTY ? Prompt.run(Prompt.password({ message })).pipe(Effect.map(Redacted.value)) : Effect.promise(readWholeStdin);
460
+ const fetchConfig = api.getJson("fetch encryption config", "/v1/org/encryption", OrgEncryptionConfig);
461
+ /** Narrows a config to its enabled shape or fails EncryptionDisabled. */
462
+ const requireEnabled = (cfg) => !cfg.enabled || !cfg.vaultBlobB64 || !cfg.vaultSaltB64 || !cfg.kdfParams ? Effect.fail(new EncryptionDisabled()) : Effect.succeed({
463
+ vaultBlobB64: cfg.vaultBlobB64,
464
+ vaultSaltB64: cfg.vaultSaltB64,
465
+ kdfParams: cfg.kdfParams
466
+ });
467
+ /** Prompt → Argon2id → AEAD-open. Any decrypt failure collapses to
468
+ * VaultUnlockFailed (wrong passphrase and corrupt blob are
469
+ * indistinguishable by design of the AEAD). */
470
+ const unlock = (cfg, promptText) => Effect.gen(function* () {
471
+ const passphrase = yield* readPassphrase(promptText);
472
+ const derived = yield* sodium.deriveVaultKey(normalizePassphrase(passphrase), sodium.fromB64(cfg.vaultSaltB64), cfg.kdfParams).pipe(Effect.mapError(() => new VaultUnlockFailed()));
473
+ return {
474
+ vault: yield* sodium.decryptVault(sodium.fromB64(cfg.vaultBlobB64), derived).pipe(Effect.mapError(() => new VaultUnlockFailed())),
475
+ vaultKey: derived
476
+ };
477
+ });
478
+ /** True when the cached vault belongs to THIS org's current encryption
479
+ * config: the config is enabled and its admin pubkey matches the cache's.
480
+ * A cache left behind by a different login (or a re-enabled config) fails
481
+ * this and must not be used — sends encrypted under it would be
482
+ * undecryptable by every recipient device. */
483
+ const cacheMatches = (vault, cfg) => cfg.enabled && typeof cfg.adminPubkeyB64 === "string" && sodium.constantTimeEqual(vault.adminPublicKey, sodium.fromB64(cfg.adminPubkeyB64));
484
+ /** The decrypted vault: the local cache when it matches the org's current
485
+ * config, otherwise prompt, unlock, and cache for next time. The config is
486
+ * fetched even on the cache path — one GET per encrypted send buys the
487
+ * staleness check above (without it, a `vault.json` surviving a re-login
488
+ * would silently encrypt under the previous org's key). */
489
+ const getOrPrompt = Effect.gen(function* () {
490
+ const cached = yield* vaultStore.load;
491
+ const cfg = yield* fetchConfig;
492
+ if (cached._tag === "Some") {
493
+ if (cacheMatches(cached.value, cfg)) return cached.value;
494
+ yield* vaultStore.clear;
495
+ yield* out.warn("cached vault doesn't match this org's encryption config (stale login?) — cleared it");
496
+ }
497
+ const { vault } = yield* unlock(yield* requireEnabled(cfg), "Org encryption passphrase: ");
498
+ yield* vaultStore.save(vault);
499
+ return vault;
500
+ });
501
+ return {
502
+ readPassphrase,
503
+ fetchConfig,
504
+ requireEnabled,
505
+ getOrPrompt,
506
+ /** The auto-encrypt decision for org sends: the unlocked vault when the
507
+ * org has encryption (prompting inline on a fresh machine rather than
508
+ * silently going plaintext), `undefined` when encryption is disabled or
509
+ * the caller opted out with --no-encrypt. */
510
+ forSendOrPlaintext: (noEncrypt) => noEncrypt ? Effect.succeed(void 0) : getOrPrompt.pipe(Effect.map((vault) => vault), Effect.catchTag("EncryptionDisabled", () => Effect.succeed(void 0))),
511
+ /** Rotation needs `vault_key` itself (not just the decrypted contents) to
512
+ * re-encrypt the updated vault, and the cache deliberately doesn't store
513
+ * it — so this ALWAYS re-prompts, which is also defensible on its own
514
+ * merits for a privileged op. */
515
+ unlockForRotation: Effect.gen(function* () {
516
+ return yield* unlock(yield* fetchConfig.pipe(Effect.flatMap(requireEnabled)), "Org encryption passphrase (required for rotation): ");
517
+ })
518
+ };
519
+ })
520
+ }) {};
521
+ //#endregion
522
+ //#region src/global-options.ts
523
+ const DEFAULT_BASE_URL = "https://api.simplepu.sh";
524
+ const toHelp = (e) => HelpDoc.p(e instanceof Error ? e.message : String(e));
525
+ 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);
527
+ /** Personal-credential commands need the token; fail typed when absent. */
528
+ const requireApiToken = (token) => Option.match(token, {
529
+ onNone: () => Effect.fail(new MissingApiToken()),
530
+ onSome: Effect.succeed
531
+ });
532
+ function parsePasswordFlag(value) {
533
+ const at = value.lastIndexOf("@");
534
+ if (at === -1) return value;
535
+ const password = value.slice(0, at);
536
+ const topic = value.slice(at + 1);
537
+ if (!password || !topic) throw new Error(`invalid --password \`${value}\`: use \`password@topic\` for a topic password, or a bare password for the account default`);
538
+ return [password, topic];
539
+ }
540
+ const passwordOption = Options.text("password").pipe(Options.withAlias("p"), Options.withDescription("End-to-end encryption password. `password@topic` sets a topic's password (encrypts sends to it and decrypts its content); a bare `password` is your account default (decrypts your submissions). Repeatable."), Options.repeated, Options.mapTryCatch((values) => values.map(parsePasswordFlag), toHelp));
541
+ /** True when the SDK will E2E-encrypt a send: a `password@topic` pair matching
542
+ * the topic, or (for a note-to-self) a bare account-default password. */
543
+ const willEncrypt = (passwords, topic) => topic !== void 0 ? passwords.some((p) => Array.isArray(p) && p[1] === topic) : passwords.some((p) => typeof p === "string");
544
+ const baseUrlOption = Options.text("base-url").pipe(Options.withDescription(`API base URL. Defaults to $SP_BASE_URL or ${DEFAULT_BASE_URL}.`), Options.withFallbackConfig(Config.string("SP_BASE_URL")), Options.withDefault(DEFAULT_BASE_URL));
545
+ const quietOption = Options.boolean("quiet").pipe(Options.withAlias("q"), Options.withDescription("Suppress informational output, only print payloads."));
546
+ /** Shared helper for repeatable validated text options. */
547
+ const mappedText = (name, parse) => Options.text(name).pipe(Options.mapTryCatch(parse, toHelp));
548
+ //#endregion
549
+ //#region src/format.ts
550
+ function formatInstant(iso) {
551
+ const d = new Date(iso);
552
+ if (Number.isNaN(d.getTime())) return iso;
553
+ return d.toISOString().replace("T", " ").replace(/\..+/, " UTC");
554
+ }
555
+ function maskToken(t) {
556
+ if (t.length <= 8) return "*".repeat(t.length);
557
+ return `${t.slice(0, 4)}…${t.slice(-4)}`;
558
+ }
559
+ //#endregion
560
+ //#region src/commands/auth.ts
561
+ const LOGIN_TIMEOUT = Duration.minutes(5);
562
+ const successPage = `<!DOCTYPE html>
563
+ <html><head><meta charset="utf-8"><title>Logged in</title>
564
+ <style>body{font-family:-apple-system,sans-serif;display:grid;place-items:center;min-height:100vh;margin:0;color:#1d1d1f;}
565
+ @media (prefers-color-scheme:dark){body{background:#1c1c1e;color:#f5f5f7;}}</style></head>
566
+ <body><div><h1 style="font-weight:600;">Logged in</h1><p>You can close this tab and return to your terminal.</p></div></body></html>`;
567
+ const errorPage = (msg) => `<!DOCTYPE html>
568
+ <html><head><meta charset="utf-8"><title>Error</title></head>
569
+ <body style="font-family:-apple-system,sans-serif;padding:2rem;"><h1>Authentication failed</h1><p>${escapeHtml(msg)}</p></body></html>`;
570
+ function escapeHtml(s) {
571
+ return s.replace(/[&<>"']/g, (c) => ({
572
+ "&": "&amp;",
573
+ "<": "&lt;",
574
+ ">": "&gt;",
575
+ "\"": "&quot;",
576
+ "'": "&#39;"
577
+ })[c] ?? c);
578
+ }
579
+ const ExchangePayload = Schema.Struct({
580
+ token: Schema.String,
581
+ apiKey: Schema.optional(Schema.NullOr(Schema.String)),
582
+ apiKeyInfo: Schema.Struct({
583
+ prefix: Schema.String,
584
+ createdAt: Schema.String,
585
+ lastRotatedAt: Schema.optional(Schema.NullOr(Schema.String))
586
+ })
587
+ });
588
+ const DeviceStartResponse = Schema.Struct({
589
+ deviceCode: Schema.String,
590
+ userCode: Schema.String,
591
+ expiresIn: Schema.Number,
592
+ interval: Schema.Number
593
+ });
594
+ const DeviceTokenError = Schema.Struct({ error: Schema.optional(Schema.String) });
595
+ const decodeJson = (schema) => Schema.decodeUnknown(Schema.parseJson(schema));
596
+ /** One-shot loopback server: resolves the Deferred with the redirected
597
+ * code+state. Scoped — releasing tears down the server AND its keep-alive
598
+ * sockets (Connection: close + closeAllConnections; without both, the process
599
+ * would hang on the browser's held-open socket). */
600
+ const startCallbackServer = Effect.gen(function* () {
601
+ const callback = yield* Deferred.make();
602
+ const noKeepAlive = {
603
+ "Content-Type": "text/html; charset=utf-8",
604
+ Connection: "close"
605
+ };
606
+ const server = yield* Effect.acquireRelease(Effect.sync(() => createServer((req, res) => {
607
+ const path = req.url ?? "/";
608
+ if (!path.startsWith("/callback")) {
609
+ res.writeHead(404, { Connection: "close" }).end();
610
+ return;
611
+ }
612
+ const url = new URL(path, "http://localhost");
613
+ const code = url.searchParams.get("code");
614
+ const state = url.searchParams.get("state");
615
+ if (!code || !state) {
616
+ res.writeHead(400, noKeepAlive).end(errorPage("Missing code or state."));
617
+ Deferred.unsafeDone(callback, Exit.fail(new UserError({ message: "callback missing code or state" })));
618
+ return;
619
+ }
620
+ res.writeHead(200, noKeepAlive).end(successPage);
621
+ Deferred.unsafeDone(callback, Exit.succeed({
622
+ code,
623
+ state
624
+ }));
625
+ })), (server) => Effect.sync(() => {
626
+ server.closeAllConnections();
627
+ server.close();
628
+ }));
629
+ return {
630
+ port: yield* Effect.async((resume) => {
631
+ server.once("error", (e) => resume(Effect.fail(new UserError({ message: `callback server failed: ${e.message}` }))));
632
+ server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server.address().port)));
633
+ }),
634
+ awaitCallback: Deferred.await(callback)
635
+ };
636
+ });
637
+ const openInBrowser = (url) => Effect.sync(() => {
638
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
639
+ const args = process.platform === "win32" ? [
640
+ "/c",
641
+ "start",
642
+ "",
643
+ url
644
+ ] : [url];
645
+ try {
646
+ spawn(cmd, args, {
647
+ detached: true,
648
+ stdio: "ignore"
649
+ }).unref();
650
+ } catch {}
651
+ });
652
+ function preferDeviceFlow() {
653
+ if (process.env.SP_AUTH_DEVICE === "1") return true;
654
+ if (process.env.SSH_CONNECTION || process.env.SSH_TTY) return true;
655
+ if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return true;
656
+ return false;
657
+ }
658
+ const finishLogin = (baseUrl, payload) => Effect.gen(function* () {
659
+ const out = yield* CliOutput;
660
+ const store = yield* AuthStore;
661
+ if (!payload.token) return yield* Effect.fail(new UserError({ message: "exchange returned empty token" }));
662
+ const path = yield* store.save({
663
+ baseUrl,
664
+ token: Redacted.make(payload.token),
665
+ loggedInAt: (/* @__PURE__ */ new Date()).toISOString()
666
+ });
667
+ yield* out.info(`saved credentials to ${path}`);
668
+ yield* out.print("Logged in.");
669
+ if (payload.apiKey) {
670
+ yield* out.print("\nOrganization API key (shown once, copy now):");
671
+ yield* out.print(` ${payload.apiKey}`);
672
+ } else yield* out.print(`\nOrganization API key already provisioned (prefix ${payload.apiKeyInfo.prefix}…).\nRun \`simplepush org api-key rotate\` to surface a fresh one.`);
673
+ });
674
+ const runLocalhostFlow = (baseUrl) => Effect.scoped(Effect.gen(function* () {
675
+ const out = yield* CliOutput;
676
+ const api = yield* Api;
677
+ const state = randomBytes(16).toString("hex");
678
+ const { port, awaitCallback } = yield* startCallbackServer;
679
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
680
+ const authUrl = `${baseUrl}/cli/auth/approve?redirect_uri=${encodeURIComponent(redirectUri)}&state=${encodeURIComponent(state)}`;
681
+ yield* out.info(`opening browser: ${authUrl}`);
682
+ yield* out.info("(if it didn't open, paste that URL into your browser)");
683
+ yield* openInBrowser(authUrl);
684
+ const callback = yield* awaitCallback.pipe(Effect.timeoutFail({
685
+ duration: LOGIN_TIMEOUT,
686
+ onTimeout: () => new UserError({ message: `login timed out after ${Duration.toSeconds(LOGIN_TIMEOUT)}s` })
687
+ }));
688
+ if (callback.state !== state) return yield* Effect.fail(new UserError({ message: "state mismatch — refusing to exchange (possible CSRF)" }));
689
+ yield* out.info("approved, exchanging code...");
690
+ const res = yield* api.unauthedPost("exchange", `${baseUrl}/cli/auth/exchange`, { code: callback.code });
691
+ if (res.status >= 400) return yield* Effect.fail(new UserError({ message: `exchange failed: ${res.status} ${res.body}` }));
692
+ yield* finishLogin(baseUrl, yield* decodeJson(ExchangePayload)(res.body));
693
+ }));
694
+ const runDeviceFlow = (baseUrl) => Effect.gen(function* () {
695
+ const out = yield* CliOutput;
696
+ const startRes = yield* (yield* Api).unauthedPost("device start", `${baseUrl}/cli/auth/device/start`);
697
+ if (startRes.status >= 400) return yield* Effect.fail(new UserError({ message: `device start failed: ${startRes.status} ${startRes.body}` }));
698
+ const start = yield* decodeJson(DeviceStartResponse)(startRes.body);
699
+ const verifyUrl = `${baseUrl}/cli/auth/device`;
700
+ const verifyUrlComplete = `${verifyUrl}?user_code=${encodeURIComponent(start.userCode)}`;
701
+ yield* out.print(`\nTo authorize this CLI, open:\n ${verifyUrl}`);
702
+ yield* out.print(`and enter the code:\n ${start.userCode}\n`);
703
+ if (!preferDeviceFlow()) yield* openInBrowser(verifyUrlComplete);
704
+ yield* out.info("waiting for approval... (Ctrl-C to cancel)");
705
+ const poll = (intervalSeconds) => Effect.gen(function* () {
706
+ yield* Effect.sleep(Duration.seconds(intervalSeconds));
707
+ const res = yield* (yield* Api).unauthedPost("device token", `${baseUrl}/cli/auth/device/token`, { deviceCode: start.deviceCode }).pipe(Effect.mapError((e) => new UserError({ message: `device token exchange failed: ${String(e.cause)}` })));
708
+ if (res.status < 400) return yield* decodeJson(ExchangePayload)(res.body).pipe(Effect.mapError(() => new UserError({ message: "device token exchange returned an unreadable payload" })));
709
+ switch ((yield* decodeJson(DeviceTokenError)(res.body).pipe(Effect.orElseSucceed(() => ({ error: void 0 })))).error) {
710
+ case "authorization_pending": return yield* poll(intervalSeconds);
711
+ case "slow_down": return yield* poll(intervalSeconds + 5);
712
+ case "access_denied": return yield* Effect.fail(new UserError({ message: "authorization was denied" }));
713
+ case "expired_token": return yield* Effect.fail(new UserError({ message: "the code expired — run `sp auth login` again" }));
714
+ default: return yield* Effect.fail(new UserError({ message: `device token exchange failed: ${res.status}` }));
715
+ }
716
+ });
717
+ yield* finishLogin(baseUrl, yield* poll(start.interval > 0 ? start.interval : 5).pipe(Effect.timeoutFail({
718
+ duration: Duration.seconds(start.expiresIn),
719
+ onTimeout: () => new UserError({ message: "device authorization timed out" })
720
+ })));
721
+ });
722
+ const deviceFlagOption = Options.boolean("device").pipe(Options.withDescription("Use the device-code flow (for SSH / headless machines). Auto-selected when no local browser is detected."));
723
+ const webFlagOption = Options.boolean("web").pipe(Options.withDescription("Use the localhost-callback browser flow (default on a desktop with a browser)."));
724
+ const authLogin = Command.make("login", {
725
+ "base-url": baseUrlOption,
726
+ quiet: quietOption,
727
+ device: deviceFlagOption,
728
+ web: webFlagOption
729
+ }, (args) => Effect.gen(function* () {
730
+ yield* (yield* CliOutput).setQuiet(args.quiet);
731
+ const baseUrl = args["base-url"].replace(/\/+$/, "");
732
+ yield* args.device || !args.web && preferDeviceFlow() ? runDeviceFlow(baseUrl) : runLocalhostFlow(baseUrl);
733
+ }));
734
+ const authLogout = Command.make("logout", { quiet: quietOption }, (args) => Effect.gen(function* () {
735
+ const out = yield* CliOutput;
736
+ yield* out.setQuiet(args.quiet);
737
+ const auth = yield* AuthStore;
738
+ const removed = yield* auth.clear;
739
+ yield* (yield* VaultStore).clear;
740
+ yield* (yield* InviteStore).clear;
741
+ yield* out.print(removed ? `Logged out (deleted ${auth.filePath}).` : `No saved credentials at ${auth.filePath}.`);
742
+ }));
743
+ const authStatus = Command.make("status", { quiet: quietOption }, (args) => Effect.gen(function* () {
744
+ const out = yield* CliOutput;
745
+ yield* out.setQuiet(args.quiet);
746
+ const store = yield* AuthStore;
747
+ const auth = yield* store.load;
748
+ if (Option.isNone(auth)) {
749
+ yield* out.print("Not logged in. Run `sp auth login` to authenticate.");
750
+ return yield* Effect.fail(new Aborted());
751
+ }
752
+ yield* out.print("Logged in.");
753
+ yield* out.print(` base url: ${auth.value.baseUrl}`);
754
+ yield* out.print(` token: ${maskToken(bearerToken(auth.value))}`);
755
+ yield* out.print(` since: ${auth.value.loggedInAt}`);
756
+ yield* out.print(` file: ${store.filePath}`);
757
+ }));
758
+ const authCommand = Command.make("auth").pipe(Command.withSubcommands([
759
+ authLogin,
760
+ authLogout,
761
+ authStatus
762
+ ]));
763
+ //#endregion
764
+ //#region src/services/sdk.ts
765
+ /** A Client whose websocket/http resources are released with the scope. */
766
+ const acquireClient = (config) => Effect.acquireRelease(Effect.sync(() => new Client(config)), (client) => Effect.promise(async () => client.close()).pipe(Effect.ignore));
767
+ /** An OrgClient (Api-Key or CLI-session bearer) scoped like `acquireClient`. */
768
+ const acquireOrgClient = (config) => Effect.acquireRelease(Effect.sync(() => new OrgClient(config)), (client) => Effect.promise(async () => client.close()).pipe(Effect.ignore));
769
+ /** One promise-returning SDK call as a typed Effect. */
770
+ const sdkCall = (action, f) => Effect.tryPromise({
771
+ try: f,
772
+ catch: (cause) => new SdkFailure({
773
+ action,
774
+ cause
775
+ })
776
+ });
777
+ /** An SDK async-iterable as a Stream.
778
+ *
779
+ * Termination MUST go through the AbortSignal, never a bare generator
780
+ * `.return()`: the SDK's reconnect backoff only unblocks on signal-abort, so a
781
+ * plain return() deadlocks against the backoff sleep. The iterator wrapper
782
+ * below aborts FIRST (which breaks the sleep), then lets the generator's own
783
+ * cleanup run. The controller is also aborted by the scope finalizer, covering
784
+ * interruption (timeouts, races, Ctrl-C). */
785
+ const sdkStream = (action, make) => Stream.unwrapScoped(Effect.map(Effect.acquireRelease(Effect.sync(() => new AbortController()), (controller) => Effect.sync(() => controller.abort())), (controller) => Stream.fromAsyncIterable(abortFirst(make(controller.signal), controller), (cause) => new SdkFailure({
786
+ action,
787
+ cause
788
+ }))));
789
+ function abortFirst(iterable, controller) {
790
+ return { [Symbol.asyncIterator]() {
791
+ const it = iterable[Symbol.asyncIterator]();
792
+ return {
793
+ next: () => it.next(),
794
+ return: async () => {
795
+ controller.abort();
796
+ try {
797
+ await it.return?.();
798
+ } catch {}
799
+ return {
800
+ done: true,
801
+ value: void 0
802
+ };
803
+ },
804
+ throw: (e) => it.throw?.(e) ?? Promise.reject(e instanceof Error ? e : new Error(String(e)))
805
+ };
806
+ } };
807
+ }
808
+ //#endregion
809
+ //#region src/daemon/paths.ts
810
+ /** Directory for broker sockets. Prefer `$XDG_RUNTIME_DIR` (0700, user-only,
811
+ * tmpfs) when set; otherwise the OS temp dir. */
812
+ function daemonDir() {
813
+ const xdg = process.env.XDG_RUNTIME_DIR;
814
+ return xdg && xdg.length > 0 ? join(xdg, "simplepush") : join(tmpdir(), "simplepush");
815
+ }
816
+ /** Socket path for a given credential. The hash covers the credential kind,
817
+ * the secret AND the base URL so two accounts, an account vs its org session,
818
+ * or prod vs a local backend never collide on one broker. The kind also
819
+ * appears in the filename for debuggability. */
820
+ function daemonSocketPath(credential, baseUrl) {
821
+ const secret = credential.kind === "personal" ? credential.apiToken : credential.bearer;
822
+ const key = createHash("sha256").update(`${credential.kind}\n${baseUrl}\n${secret}`).digest("hex").slice(0, 16);
823
+ return join(daemonDir(), `events-${credential.kind}-${key}.sock`);
824
+ }
825
+ //#endregion
826
+ //#region src/daemon/server.ts
827
+ const RING_MAX = 1e3;
828
+ const IDLE_EXIT = "60 seconds";
829
+ const UPSTREAM_LOOKBACK_MS = 10 * 6e4;
830
+ /** True if a broker is already listening on `path` (so we don't double-bind). */
831
+ const probeDaemon = (path, timeoutMs = 500) => Effect.async((resume) => {
832
+ const sock = createConnection(path);
833
+ const done = (live) => {
834
+ sock.destroy();
835
+ resume(Effect.succeed(live));
836
+ };
837
+ const timer = setTimeout(() => done(false), timeoutMs);
838
+ sock.once("connect", () => {
839
+ clearTimeout(timer);
840
+ done(true);
841
+ });
842
+ sock.once("error", () => {
843
+ clearTimeout(timer);
844
+ done(false);
845
+ });
846
+ return Effect.sync(() => {
847
+ clearTimeout(timer);
848
+ sock.destroy();
849
+ });
850
+ });
851
+ /** Wait for a just-spawned broker to come up. */
852
+ const waitForDaemon = (path, timeout) => probeDaemon(path, 200).pipe(Effect.filterOrFail((up) => up, () => "not-up"), Effect.retry(Schedule.spaced("100 millis")), Effect.timeoutOption(timeout), Effect.map(Option.isSome), Effect.orElseSucceed(() => false));
853
+ const parseSubscribeLine = (line) => {
854
+ try {
855
+ return JSON.parse(line).since;
856
+ } catch {
857
+ return;
858
+ }
859
+ };
860
+ /** Run the broker for a credential until the upstream dies or it idle-exits.
861
+ * Resolves when the broker has fully shut down. */
862
+ const runDaemon = (opts) => Effect.gen(function* () {
863
+ const out = yield* CliOutput;
864
+ const fs = yield* FileSystem.FileSystem;
865
+ const path = daemonSocketPath(opts.credential, opts.baseUrl);
866
+ yield* fs.makeDirectory(daemonDir(), { recursive: true }).pipe(Effect.ignore);
867
+ yield* fs.chmod(daemonDir(), 448).pipe(Effect.ignore);
868
+ if (yield* probeDaemon(path)) return yield* out.info("a broker is already running for this credential; exiting");
869
+ yield* fs.remove(path).pipe(Effect.ignore);
870
+ const reason = yield* Effect.scoped(Effect.gen(function* () {
871
+ const server = yield* NodeSocketServer.make({ path });
872
+ yield* Effect.addFinalizer(() => fs.remove(path).pipe(Effect.ignore));
873
+ yield* fs.chmod(path, 384).pipe(Effect.ignore);
874
+ const events = yield* PubSub.unbounded();
875
+ const ring = yield* Ref.make(Chunk.empty());
876
+ const clients = yield* SubscriptionRef.make(0);
877
+ const handleClient = (socket) => Effect.scoped(Effect.gen(function* () {
878
+ yield* SubscriptionRef.update(clients, (n) => n + 1);
879
+ yield* Effect.addFinalizer(() => SubscriptionRef.update(clients, (n) => n - 1));
880
+ const write = yield* socket.writer;
881
+ const firstLine = yield* Deferred.make();
882
+ const readState = {
883
+ buf: "",
884
+ done: false
885
+ };
886
+ const reader = yield* Effect.fork(socket.run((data) => {
887
+ if (readState.done) return;
888
+ readState.buf += Buffer.from(data).toString("utf8");
889
+ const nl = readState.buf.indexOf("\n");
890
+ if (nl === -1) return;
891
+ readState.done = true;
892
+ return Deferred.succeed(firstLine, readState.buf.slice(0, nl));
893
+ }));
894
+ const dequeue = yield* PubSub.subscribe(events);
895
+ const line = yield* Deferred.await(firstLine).pipe(Effect.timeoutOption("100 millis"));
896
+ const since = Option.match(line, {
897
+ onNone: () => void 0,
898
+ onSome: parseSubscribeLine
899
+ });
900
+ const snapshot = Chunk.toReadonlyArray(yield* Ref.get(ring));
901
+ const backlog = since === void 0 ? snapshot : snapshot.filter((e) => e.createdAt !== void 0 && e.createdAt >= since);
902
+ const seen = new Set(snapshot);
903
+ for (const ev of backlog) yield* write(JSON.stringify(ev) + "\n");
904
+ const live = Stream.fromQueue(dequeue).pipe(Stream.filterEffect((ev) => Effect.sync(() => !seen.delete(ev))), Stream.runForEach((ev) => write(JSON.stringify(ev) + "\n")));
905
+ yield* Effect.raceFirst(live, Fiber.join(reader));
906
+ })).pipe(Effect.catchAllCause(() => Effect.void));
907
+ const acceptLoop = server.run(handleClient);
908
+ const idleExit = clients.changes.pipe(Stream.debounce(IDLE_EXIT), Stream.filter((n) => n === 0), Stream.take(1), Stream.runDrain, Effect.as("idle, no clients"));
909
+ const upstream = Effect.scoped(Effect.gen(function* () {
910
+ const client = opts.credential.kind === "personal" ? yield* acquireClient({
911
+ baseUrl: opts.baseUrl,
912
+ apiToken: opts.credential.apiToken
913
+ }) : yield* acquireOrgClient({
914
+ baseUrl: opts.baseUrl,
915
+ bearerToken: opts.credential.bearer
916
+ });
917
+ const since = new Date(Date.now() - UPSTREAM_LOOKBACK_MS).toISOString();
918
+ yield* sdkStream("upstream events", (signal) => client.events({
919
+ since,
920
+ signal
921
+ })).pipe(Stream.runForEach((ev) => Ref.update(ring, (r) => {
922
+ const next = Chunk.append(r, ev);
923
+ return Chunk.size(next) > RING_MAX ? Chunk.drop(next, 1) : next;
924
+ }).pipe(Effect.zipRight(PubSub.publish(events, ev)))));
925
+ return "upstream closed";
926
+ })).pipe(Effect.catchTag("SdkFailure", (e) => out.warn(`upstream events stream failed: ${e.cause instanceof Error ? e.cause.message : String(e.cause)}`).pipe(Effect.as("upstream error"))));
927
+ yield* out.info(`broker listening at ${path}`);
928
+ return yield* Effect.raceAll([
929
+ upstream,
930
+ idleExit,
931
+ acceptLoop
932
+ ]);
933
+ }));
934
+ yield* out.info(`broker shut down (${reason})`);
935
+ });
936
+ //#endregion
937
+ //#region src/daemon/transport.ts
938
+ /** Adapt a connected Unix socket carrying newline-delimited event JSON into a
939
+ * `SimplepushWebSocket`. The broker only ever sends us event frames, so
940
+ * `messages()` yields each line verbatim. */
941
+ function adaptSocket(socket, since) {
942
+ const queue = [];
943
+ let waiter = null;
944
+ const push = (f) => {
945
+ if (waiter) {
946
+ const w = waiter;
947
+ waiter = null;
948
+ w(f);
949
+ } else queue.push(f);
950
+ };
951
+ let closed = false;
952
+ let resolveClosed;
953
+ const closedPromise = new Promise((res) => {
954
+ resolveClosed = res;
955
+ });
956
+ socket.once("connect", () => {
957
+ socket.write(JSON.stringify({ since }) + "\n");
958
+ });
959
+ let buf = "";
960
+ socket.on("data", (chunk) => {
961
+ buf += chunk.toString("utf8");
962
+ let nl;
963
+ while ((nl = buf.indexOf("\n")) !== -1) {
964
+ const ln = buf.slice(0, nl);
965
+ buf = buf.slice(nl + 1);
966
+ if (ln.trim().length > 0) push({
967
+ kind: "msg",
968
+ text: ln
969
+ });
970
+ }
971
+ });
972
+ socket.on("close", () => {
973
+ if (!closed) {
974
+ closed = true;
975
+ push({ kind: "end" });
976
+ resolveClosed();
977
+ }
978
+ });
979
+ socket.on("error", (err) => push({
980
+ kind: "error",
981
+ err
982
+ }));
983
+ async function* messages() {
984
+ while (true) {
985
+ const next = queue.length > 0 ? queue.shift() : await new Promise((res) => {
986
+ waiter = res;
987
+ });
988
+ if (next.kind === "msg") yield next.text;
989
+ else if (next.kind === "end") return;
990
+ else throw next.err;
991
+ }
992
+ }
993
+ return {
994
+ closed: closedPromise,
995
+ messages,
996
+ close: () => {
997
+ try {
998
+ socket.destroy();
999
+ } catch {}
1000
+ }
1001
+ };
1002
+ }
1003
+ /** A factory that connects to the broker at `path`. The SDK passes the ws url
1004
+ * (with `?since=`), which we forward to the broker as the backlog cursor. */
1005
+ function daemonWebSocketFactory(path) {
1006
+ return (url) => {
1007
+ let since;
1008
+ try {
1009
+ since = new URL(url).searchParams.get("since") ?? void 0;
1010
+ } catch {}
1011
+ return adaptSocket(connect(path), since);
1012
+ };
1013
+ }
1014
+ /** Spawn the broker detached; credentials/base-url go via env, NOT argv, so
1015
+ * they don't show up in `ps`. Org mode passes the session bearer explicitly
1016
+ * (SP_DAEMON_BEARER) rather than letting the child re-read auth.json,
1017
+ * so the child's socket path deterministically matches the one we probe. */
1018
+ const spawnDetachedDaemon = (opts) => Effect.sync(() => {
1019
+ const credentialEnv = opts.credential.kind === "personal" ? { SP_API_TOKEN: opts.credential.apiToken } : { SP_DAEMON_BEARER: opts.credential.bearer };
1020
+ spawn(process.execPath, [process.argv[1], "daemon"], {
1021
+ detached: true,
1022
+ stdio: "ignore",
1023
+ env: {
1024
+ ...process.env,
1025
+ ...credentialEnv,
1026
+ SP_BASE_URL: opts.baseUrl
1027
+ }
1028
+ }).unref();
1029
+ });
1030
+ /** Resolve the WebSocket transport for a shared-mode client: attach to a running
1031
+ * broker, auto-spawning one (detached) if absent. Returns `Option.none` to fall
1032
+ * back to a direct connection when the broker can't be reached — the broker is
1033
+ * an optimisation and must never break `sp`. */
1034
+ const sharedWebSocketFactory = (opts) => Effect.gen(function* () {
1035
+ const out = yield* CliOutput;
1036
+ const path = daemonSocketPath(opts.credential, opts.baseUrl);
1037
+ return yield* Effect.gen(function* () {
1038
+ if (yield* probeDaemon(path)) {
1039
+ yield* out.info("attached to the shared events broker");
1040
+ return Option.some(daemonWebSocketFactory(path));
1041
+ }
1042
+ yield* spawnDetachedDaemon(opts);
1043
+ if (!(yield* waitForDaemon(path, "3 seconds"))) {
1044
+ yield* out.warn("could not start the shared events broker; using a direct connection");
1045
+ return Option.none();
1046
+ }
1047
+ yield* out.info("started shared events broker");
1048
+ return Option.some(daemonWebSocketFactory(path));
1049
+ }).pipe(Effect.catchAllCause((cause) => out.warn(`shared broker unavailable (${cause.toString()}); using a direct connection`).pipe(Effect.as(Option.none()))));
1050
+ });
1051
+ //#endregion
1052
+ //#region src/since.ts
1053
+ const UNIT_MS = {
1054
+ ms: 1,
1055
+ s: 1e3,
1056
+ sec: 1e3,
1057
+ secs: 1e3,
1058
+ m: 6e4,
1059
+ min: 6e4,
1060
+ mins: 6e4,
1061
+ h: 36e5,
1062
+ hr: 36e5,
1063
+ hrs: 36e5,
1064
+ d: 864e5,
1065
+ day: 864e5,
1066
+ days: 864e5,
1067
+ w: 6048e5,
1068
+ wk: 6048e5,
1069
+ wks: 6048e5
1070
+ };
1071
+ const HUMANTIME_RE = /^\s*(\d+)\s*([a-zA-Z]+)\s*$/;
1072
+ function parseDurationMs(input) {
1073
+ const m = HUMANTIME_RE.exec(input);
1074
+ if (!m) return void 0;
1075
+ const n = Number(m[1]);
1076
+ const factor = UNIT_MS[m[2].toLowerCase()];
1077
+ if (factor === void 0) return void 0;
1078
+ return n * factor;
1079
+ }
1080
+ function parseIso(input) {
1081
+ const d = new Date(input);
1082
+ return Number.isFinite(d.getTime()) ? d : void 0;
1083
+ }
1084
+ /** ISO 8601 string suitable for the `--since` query param. */
1085
+ function resolveSince(input) {
1086
+ const iso = parseIso(input);
1087
+ if (iso) return iso.toISOString();
1088
+ const ms = parseDurationMs(input);
1089
+ if (ms !== void 0) return new Date(Date.now() - ms).toISOString();
1090
+ throw new Error(`could not parse \`--since ${input}\`: expected a duration (e.g. \`24h\`, \`7d\`) or ISO 8601 timestamp`);
1091
+ }
1092
+ function resolveUntil(input) {
1093
+ const iso = parseIso(input);
1094
+ if (iso) return iso;
1095
+ const ms = parseDurationMs(input);
1096
+ if (ms !== void 0) return new Date(Date.now() - ms);
1097
+ throw new Error(`could not parse \`--until ${input}\`: expected a duration or ISO 8601 timestamp`);
1098
+ }
1099
+ //#endregion
1100
+ //#region src/collect-output.ts
1101
+ /** Drops circular / non-serialisable internals from SDK view objects: `raw`
1102
+ * (the whole wire Event — circular) and `_ctx` (a download context that
1103
+ * captures the client). Download methods (`read`/`save`/`downloadUrl` when a
1104
+ * function) are omitted by JSON.stringify automatically, leaving file views as
1105
+ * their plain metadata (id / contentType / filename / size / checksum). */
1106
+ function cleanReplacer(key, value) {
1107
+ if (key === "raw" || key === "_ctx") return void 0;
1108
+ return value;
1109
+ }
1110
+ function line(obj) {
1111
+ return JSON.stringify(obj, cleanReplacer);
1112
+ }
1113
+ function createdAtOf(item) {
1114
+ return item.createdAt ?? item.raw?.createdAt;
1115
+ }
1116
+ /** Wire-absent → null, and absent inner fields → null, per the envelope's
1117
+ * `?? null` convention. */
1118
+ function actorOf(item) {
1119
+ const a = item.actor;
1120
+ if (!a) return null;
1121
+ return {
1122
+ publicId: a.publicId,
1123
+ name: a.name ?? null,
1124
+ devicePublicId: a.devicePublicId ?? null,
1125
+ deviceName: a.deviceName ?? null
1126
+ };
1127
+ }
1128
+ function formatSent(groupId, createdAt, members) {
1129
+ return line({
1130
+ type: "sent",
1131
+ groupId: groupId ?? null,
1132
+ createdAt: createdAt ?? null,
1133
+ members: members.map((m) => ({
1134
+ ...m.kind === "notification" ? { notificationId: m.id } : { taskId: m.id },
1135
+ recipient: m.recipient
1136
+ }))
1137
+ });
1138
+ }
1139
+ /** The member instance's entity id under its kind-specific key. */
1140
+ function instanceId(instance) {
1141
+ const inst = instance;
1142
+ return inst.taskId !== void 0 ? { taskId: inst.taskId } : { notificationId: inst.notificationId ?? null };
1143
+ }
1144
+ /** A single collected item — from `replies()`, `inputs()`, the combined
1145
+ * `activity()`, or a notification group's answers — into one envelope line,
1146
+ * keyed off `item.kind`. Reply, input, terminal completion, and deletion all
1147
+ * funnel through here so every collect mode emits the same schema. */
1148
+ function formatItem(g, groupId) {
1149
+ const item = g.item;
1150
+ const base = {
1151
+ groupId: groupId ?? null,
1152
+ ...instanceId(g.instance),
1153
+ recipient: g.recipient ?? null,
1154
+ actor: actorOf(item),
1155
+ createdAt: createdAtOf(item) ?? null
1156
+ };
1157
+ switch (item.kind) {
1158
+ case "reply": return line({
1159
+ type: "reply",
1160
+ ...base,
1161
+ subtaskId: item.subtaskId ?? null,
1162
+ id: item.id ?? null,
1163
+ body: item.body ?? null,
1164
+ photo: item.photo ?? null,
1165
+ file: item.file ?? null,
1166
+ audio: item.audio ?? null,
1167
+ location: item.location ?? null
1168
+ });
1169
+ case "input": return line({
1170
+ type: "input",
1171
+ ...base,
1172
+ inputType: item.type,
1173
+ uploads: item.uploads ?? []
1174
+ });
1175
+ case "taskCompleted": return line({
1176
+ type: "completed",
1177
+ ...base,
1178
+ uploads: item.uploads ?? []
1179
+ });
1180
+ case "notificationCompleted": return line({
1181
+ type: "completed",
1182
+ ...base,
1183
+ reply: item.reply ?? null
1184
+ });
1185
+ case "taskDeleted": return line({
1186
+ type: "deleted",
1187
+ ...base
1188
+ });
1189
+ }
1190
+ }
1191
+ function formatSubmission(s) {
1192
+ return line({
1193
+ type: "submission",
1194
+ id: s.id ?? null,
1195
+ actor: actorOf(s),
1196
+ body: s.body ?? null,
1197
+ photo: s.photo ?? null,
1198
+ file: s.file ?? null,
1199
+ audio: s.audio ?? null,
1200
+ location: s.location ?? null,
1201
+ createdAt: s.createdAt ?? null
1202
+ });
1203
+ }
1204
+ const isFileView = (v) => typeof v === "object" && v !== null && typeof v.save === "function";
1205
+ /** The downloadable file views an item carries: its `uploads` array (photo /
1206
+ * voice / file kinds — text/choice/… carry no `save` and drop out) or its
1207
+ * reply/submission `photo`/`file`/`audio` fields. Items with neither (deleted
1208
+ * markers, notification answers) yield []. */
1209
+ function fileViewsOf(item) {
1210
+ const o = item;
1211
+ return (Array.isArray(o.uploads) ? o.uploads : [
1212
+ o.photo,
1213
+ o.file,
1214
+ o.audio
1215
+ ]).filter(isFileView);
1216
+ }
1217
+ /** 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. */
1219
+ function formatEnd(reason, counts, members, errorMsg) {
1220
+ const obj = {
1221
+ type: "end",
1222
+ reason,
1223
+ counts
1224
+ };
1225
+ if (members) obj.members = members;
1226
+ if (errorMsg !== void 0) obj.error = errorMsg;
1227
+ return line(obj);
1228
+ }
1229
+ function fmtBytes(n) {
1230
+ if (n < 1024) return `${n}B`;
1231
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
1232
+ return `${(n / (1024 * 1024)).toFixed(1)}MB`;
1233
+ }
1234
+ /** `inp_1a2b… cat.jpg 2.1MB -> files/inp_1a2b…-cat.jpg` — the id feeds
1235
+ * `sp download`, the path (when saved) locates the local copy. */
1236
+ function prettyFile(f) {
1237
+ const parts = [f.id ?? "?"];
1238
+ if (f.filename) parts.push(f.filename);
1239
+ else if (f.contentType) parts.push(f.contentType);
1240
+ if (f.size !== void 0) parts.push(fmtBytes(f.size));
1241
+ if (f.path) parts.push(`-> ${f.path}`);
1242
+ return parts.join(" ");
1243
+ }
1244
+ function prettyFileSuffix(item) {
1245
+ const files = fileViewsOf(item);
1246
+ return files.length === 0 ? "" : ` files: ${files.map(prettyFile).join(", ")}`;
1247
+ }
1248
+ function formatPrettyItem(g) {
1249
+ const a = g.item.actor;
1250
+ const inst = g.instance;
1251
+ const who = a?.name ?? g.recipient?.name ?? a?.publicId ?? g.recipient?.publicId ?? inst.taskId ?? inst.notificationId;
1252
+ const item = g.item;
1253
+ switch (item.kind) {
1254
+ case "reply": return ` ${who} (reply): ${item.body?.kind === "text" ? item.body.text ?? "" : JSON.stringify(item.body)}${prettyFileSuffix(item)}`;
1255
+ case "input": return ` ${who} [${item.type}]${prettyFileSuffix(item)}`;
1256
+ case "taskCompleted": return ` ${who} [completed]${prettyFileSuffix(item)}`;
1257
+ case "notificationCompleted": {
1258
+ const r = item.reply;
1259
+ return ` ${who} [answered]${r === void 0 ? "" : r.type === "text" ? `: ${r.value}` : r.type === "choice" ? `: ${r.selectedValue}` : `: ${r.selectedKey}`}`;
1260
+ }
1261
+ case "taskDeleted": return ` ${who} [deleted]`;
1262
+ }
1263
+ }
1264
+ function formatPrettySubmission(s) {
1265
+ const text = s.body?.kind === "text" ? s.body.text ?? "" : JSON.stringify(s.body);
1266
+ const who = s.actor ? s.actor.name ?? s.actor.publicId : void 0;
1267
+ const device = s.actor?.deviceName;
1268
+ return ` submission${who ? ` from ${who}${device ? ` (${device})` : ""}` : ""}: ${text}${prettyFileSuffix(s)}`;
1269
+ }
1270
+ //#endregion
1271
+ //#region src/save-files.ts
1272
+ const EXT = {
1273
+ "image/jpeg": ".jpg",
1274
+ "image/png": ".png",
1275
+ "audio/ogg": ".ogg",
1276
+ "audio/wav": ".wav",
1277
+ "audio/mp4": ".m4a",
1278
+ "video/mp4": ".mp4",
1279
+ "application/pdf": ".pdf",
1280
+ "application/zip": ".zip",
1281
+ "text/plain": ".txt"
1282
+ };
1283
+ /** The unique in-directory name for a file view: `<id>-<filename>` when the
1284
+ * uploader named it (basename'd — a filename is client-supplied wire data and
1285
+ * must not traverse), else `<id>` + a content-type extension. */
1286
+ function targetName(f) {
1287
+ const id = f.id ?? "file";
1288
+ if (f.filename) return `${id}-${basename(f.filename)}`;
1289
+ return `${id}${EXT[f.contentType ?? ""] ?? ""}`;
1290
+ }
1291
+ /** Download every file the item carries into `dir`, stamping each view's
1292
+ * `path`. Never throws: per-file failures stamp `path: null` and come back as
1293
+ * warning strings. */
1294
+ async function saveItemFiles(item, dir) {
1295
+ const warnings = [];
1296
+ for (const f of fileViewsOf(item)) try {
1297
+ f.path = await f.save(join(dir, targetName(f)));
1298
+ } catch (err) {
1299
+ f.path = null;
1300
+ warnings.push(`could not save ${f.id ?? "file"}: ${err instanceof Error ? err.message : String(err)}`);
1301
+ }
1302
+ return warnings;
1303
+ }
1304
+ //#endregion
1305
+ //#region src/until.ts
1306
+ function parseDuration(s) {
1307
+ const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)$/.exec(s.trim());
1308
+ if (!m) throw new Error(`invalid duration \`${s}\`: use e.g. 500ms, 30s, 2m, 1h`);
1309
+ const n = Number(m[1]);
1310
+ const unit = m[2];
1311
+ return Math.round(n * (unit === "ms" ? 1 : unit === "s" ? 1e3 : unit === "m" ? 6e4 : 36e5));
1312
+ }
1313
+ function parseUntil(specs) {
1314
+ const cfg = { complete: false };
1315
+ for (const raw of specs) {
1316
+ const spec = raw.trim();
1317
+ if (spec === "complete") {
1318
+ cfg.complete = true;
1319
+ continue;
1320
+ }
1321
+ if (spec === "forever") {
1322
+ cfg.forever = true;
1323
+ continue;
1324
+ }
1325
+ const colon = spec.indexOf(":");
1326
+ if (colon === -1) throw new Error(`invalid --until \`${spec}\`: expected complete | idle:<dur> | count:<n> | timeout:<dur> | forever`);
1327
+ const key = spec.slice(0, colon);
1328
+ const val = spec.slice(colon + 1);
1329
+ switch (key) {
1330
+ case "idle":
1331
+ cfg.idleMs = parseDuration(val);
1332
+ break;
1333
+ case "timeout":
1334
+ cfg.timeoutMs = parseDuration(val);
1335
+ break;
1336
+ case "count": {
1337
+ const n = Number(val);
1338
+ if (!Number.isInteger(n) || n <= 0) throw new Error(`invalid --until count:\`${val}\`: expected a positive integer`);
1339
+ cfg.count = n;
1340
+ break;
1341
+ }
1342
+ default: throw new Error(`invalid --until \`${spec}\`: unknown condition \`${key}\` (expected idle|count|timeout|complete|forever)`);
1343
+ }
1344
+ }
1345
+ if (cfg.forever && (cfg.complete || cfg.idleMs !== void 0 || cfg.count !== void 0 || cfg.timeoutMs !== void 0)) throw new Error("--until forever cannot be combined with other stop conditions");
1346
+ return cfg;
1347
+ }
1348
+ /** Fill in a sensible default when the caller passed no `--until`: inputs /
1349
+ * activity (which have a natural terminal) default to waiting for every member
1350
+ * to finish; replies / submissions (no terminal) run `forever` — they're
1351
+ * watchers, ended by Ctrl-C or an explicit `--until`. */
1352
+ function withDefaults(cfg, mode) {
1353
+ if (cfg.forever) return cfg;
1354
+ if (!(!cfg.complete && cfg.idleMs === void 0 && cfg.count === void 0 && cfg.timeoutMs === void 0)) return cfg;
1355
+ if (mode === "inputs" || mode === "activity") return {
1356
+ ...cfg,
1357
+ complete: true
1358
+ };
1359
+ return {
1360
+ ...cfg,
1361
+ forever: true
1362
+ };
1363
+ }
1364
+ //#endregion
1365
+ //#region src/commands/collect.ts
1366
+ const SentLine = Schema.Struct({
1367
+ type: Schema.Literal("sent"),
1368
+ groupId: Schema.optional(Schema.NullOr(Schema.String)),
1369
+ createdAt: Schema.optional(Schema.NullOr(Schema.String)),
1370
+ members: Schema.optional(Schema.Array(Schema.Struct({
1371
+ taskId: Schema.optional(Schema.String),
1372
+ notificationId: Schema.optional(Schema.String),
1373
+ recipient: Schema.optional(Schema.NullOr(Schema.Struct({
1374
+ publicId: Schema.String,
1375
+ name: Schema.optional(Schema.NullOr(Schema.String))
1376
+ })))
1377
+ })))
1378
+ });
1379
+ const parseSentLine = Schema.decodeUnknownOption(Schema.parseJson(SentLine));
1380
+ /** Read the first `{"type":"sent",…}` line off stdin (when piped), returning as
1381
+ * soon as it's found — it never blocks waiting for the producer to close. */
1382
+ const readSentLine = Effect.suspend(() => {
1383
+ if (process.stdin.isTTY) return Effect.succeedNone;
1384
+ return Stream.fromAsyncIterable(process.stdin, (e) => e).pipe(Stream.map((chunk) => chunk.toString("utf8")), Stream.splitLines, Stream.filterMap((line) => parseSentLine(line.trim())), Stream.runHead, Effect.orElseSucceed(() => Option.none()), Effect.ensuring(Effect.sync(() => {
1385
+ try {
1386
+ process.stdin.unref?.();
1387
+ } catch {}
1388
+ })));
1389
+ });
1390
+ /** Personal mode when `--api-token` (or $SP_API_TOKEN) is given;
1391
+ * otherwise fall back to the saved CLI session (org mode) — the same session
1392
+ * that powers org sends, so a logged-in admin collects without extra
1393
+ * credentials. Org mode follows the session's base URL; an explicitly
1394
+ * different `--base-url` is a conflict, not a silent override. */
1395
+ const resolveCredential = (tokenOpt, baseUrlArg) => Effect.gen(function* () {
1396
+ if (Option.isSome(tokenOpt)) return {
1397
+ kind: "personal",
1398
+ apiToken: tokenOpt.value,
1399
+ baseUrl: baseUrlArg
1400
+ };
1401
+ const auth = yield* (yield* AuthStore).load.pipe(Effect.orElseSucceed(Option.none));
1402
+ if (Option.isNone(auth)) return yield* Effect.fail(new UserError({ message: "no credential: pass --api-token (or set $SP_API_TOKEN) to collect your personal stream, or `sp auth login` to collect your organization's" }));
1403
+ const session = auth.value;
1404
+ if (baseUrlArg !== "https://api.simplepu.sh" && baseUrlArg !== session.baseUrl) return yield* Effect.fail(new UserError({ message: `the CLI session is for ${session.baseUrl}, not ${baseUrlArg} — log in there, or pass --api-token to collect a personal stream instead` }));
1405
+ return {
1406
+ kind: "org",
1407
+ bearer: bearerToken(session),
1408
+ baseUrl: session.baseUrl
1409
+ };
1410
+ });
1411
+ /** Org master keys for decryption: the cached vault when present; a missing
1412
+ * cache prompts for the passphrase on a TTY (same UX as an encrypted org
1413
+ * send). Piped runs never prompt — stdin belongs to the `sent` line — and
1414
+ * proceed keyless with a warning (encrypted content passes through
1415
+ * undecrypted). `EncryptionDisabled` means keyless is simply correct. */
1416
+ const loadOrgMasterKeys = Effect.gen(function* () {
1417
+ const out = yield* CliOutput;
1418
+ const cached = yield* (yield* VaultStore).load.pipe(Effect.orElseSucceed(Option.none));
1419
+ const vault = Option.isSome(cached) ? cached.value : process.stdin.isTTY ? yield* (yield* VaultAccess).getOrPrompt.pipe(Effect.catchTag("EncryptionDisabled", () => Effect.succeed(void 0))) : void 0;
1420
+ if (vault === void 0) {
1421
+ if (Option.isNone(cached) && !process.stdin.isTTY) yield* out.warn("org vault is locked on this machine — encrypted org content will not decrypt (run `sp collect` once on a TTY to unlock)");
1422
+ return;
1423
+ }
1424
+ return [vault.masterKeyCurrent, ...vault.masterKeyHistory].map((k) => ({
1425
+ version: k.version,
1426
+ key: k.key
1427
+ }));
1428
+ });
1429
+ const collectCommand = Command.make("collect", {
1430
+ 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),
1432
+ 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).")),
1434
+ submissions: Options.boolean("submissions").pipe(Options.withDescription("Collect submissions (your inbox) instead of a group's events.")),
1435
+ 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
+ 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),
1437
+ format: Options.choice("format", ["json", "pretty"]).pipe(Options.withDescription("Output format: json (NDJSON, agent contract) or pretty (human)."), Options.withDefault("json")),
1438
+ direct: Options.boolean("direct").pipe(Options.withDescription("Open an independent WS connection. By default sp shares ONE broker connection across all processes (auto-started); --direct bypasses it.")),
1439
+ "save-files": Options.text("save-files").pipe(Options.withDescription("Download every collected file (photo/voice/file uploads, reply and submission files) into this directory as it streams in, decrypted and checksum-verified. Each file object on the emitted line gains `path`: the saved location, or null when its download failed."), Options.optional),
1440
+ "api-token": apiTokenOption,
1441
+ password: passwordOption,
1442
+ "base-url": baseUrlOption,
1443
+ quiet: quietOption
1444
+ }, (args) => Effect.gen(function* () {
1445
+ const out = yield* CliOutput;
1446
+ yield* out.setQuiet(args.quiet);
1447
+ const cred = yield* resolveCredential(args["api-token"], args["base-url"]);
1448
+ const baseUrl = cred.baseUrl;
1449
+ const sinceIso = Option.getOrUndefined(args.since);
1450
+ const mode = args.submissions ? "submissions" : args.inputs && args.replies ? "activity" : args.inputs ? "inputs" : args.replies ? "replies" : "activity";
1451
+ const until = withDefaults(yield* Effect.try({
1452
+ try: () => parseUntil(args.until),
1453
+ catch: (e) => new UserError({ message: e instanceof Error ? e.message : String(e) })
1454
+ }), mode);
1455
+ const webSocketFactory = args.direct || sinceIso !== void 0 ? Option.none() : yield* sharedWebSocketFactory({
1456
+ credential: cred.kind === "personal" ? {
1457
+ kind: "personal",
1458
+ apiToken: cred.apiToken
1459
+ } : {
1460
+ kind: "org",
1461
+ bearer: cred.bearer
1462
+ },
1463
+ baseUrl
1464
+ });
1465
+ const factoryConfig = Option.match(webSocketFactory, {
1466
+ onNone: () => ({}),
1467
+ onSome: (f) => ({ webSocketFactory: f })
1468
+ });
1469
+ const orgKeys = cred.kind === "org" ? yield* loadOrgMasterKeys : void 0;
1470
+ if (cred.kind === "org") yield* out.info(`collecting via org session (${baseUrl})`);
1471
+ const saveDir = Option.getOrUndefined(args["save-files"]);
1472
+ if (saveDir !== void 0) yield* Effect.tryPromise({
1473
+ try: () => mkdir(saveDir, { recursive: true }),
1474
+ catch: (e) => new UserError({ message: `cannot create --save-files directory ${saveDir}: ${e instanceof Error ? e.message : String(e)}` })
1475
+ });
1476
+ yield* Effect.scoped(Effect.gen(function* () {
1477
+ const client = cred.kind === "personal" ? yield* acquireClient({
1478
+ baseUrl,
1479
+ apiToken: cred.apiToken,
1480
+ passwords: [...args.password],
1481
+ ...factoryConfig
1482
+ }) : yield* acquireOrgClient({
1483
+ baseUrl,
1484
+ bearerToken: cred.bearer,
1485
+ ...orgKeys !== void 0 ? { orgMasterKeys: orgKeys } : {},
1486
+ ...factoryConfig
1487
+ });
1488
+ if (mode === "submissions") return yield* collectSubmissions(client, args.format, until, sinceIso, saveDir);
1489
+ const sent = Option.getOrUndefined(yield* readSentLine);
1490
+ const groupId = Option.getOrUndefined(args.group) ?? sent?.groupId ?? void 0;
1491
+ const createdAt = sinceIso ?? sent?.createdAt ?? void 0;
1492
+ const members = [];
1493
+ const seen = /* @__PURE__ */ new Set();
1494
+ const addMember = (id, recipient) => {
1495
+ if (id && !seen.has(id)) {
1496
+ seen.add(id);
1497
+ members.push({
1498
+ id,
1499
+ kind: id.startsWith("ntf_") ? "notification" : "task",
1500
+ recipient
1501
+ });
1502
+ }
1503
+ };
1504
+ for (const m of sent?.members ?? []) addMember(m.taskId ?? m.notificationId, m.recipient ? {
1505
+ publicId: m.recipient.publicId,
1506
+ 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)" }));
1510
+ 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" }));
1512
+ const streamOpts = {
1513
+ replay: true,
1514
+ ...until.idleMs !== void 0 ? { idleMs: until.idleMs } : {}
1515
+ };
1516
+ const watchGroupId = groupId ?? members[0].id;
1517
+ let source;
1518
+ if (notifRoster) {
1519
+ if (mode === "replies") return yield* Effect.fail(new UserError({ message: "notifications have no replies — collect their answers with --inputs or no mode flag" }));
1520
+ const group = client.watchNotificationGroup({
1521
+ groupId: watchGroupId,
1522
+ ...createdAt ? { createdAt } : {},
1523
+ members: members.map((m) => m.recipient ? {
1524
+ notificationId: m.id,
1525
+ recipient: m.recipient
1526
+ } : { notificationId: m.id })
1527
+ });
1528
+ source = (signal) => group.inputs({
1529
+ ...streamOpts,
1530
+ signal
1531
+ });
1532
+ } else {
1533
+ const group = client.watchTaskGroup({
1534
+ groupId: watchGroupId,
1535
+ ...createdAt ? { createdAt } : {},
1536
+ members: members.map((m) => m.recipient ? {
1537
+ taskId: m.id,
1538
+ recipient: m.recipient
1539
+ } : { taskId: m.id })
1540
+ });
1541
+ source = (signal) => mode === "inputs" ? group.inputs({
1542
+ ...streamOpts,
1543
+ signal
1544
+ }) : mode === "replies" ? group.replies({
1545
+ ...streamOpts,
1546
+ signal
1547
+ }) : group.activity({
1548
+ ...streamOpts,
1549
+ signal
1550
+ });
1551
+ }
1552
+ 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}` : ""}`);
1554
+ yield* collectGroup(watchGroupId, source, members, args.format, until, saveDir);
1555
+ }));
1556
+ }));
1557
+ /** A set-once end-reason cell: the first condition to trip names the reason. */
1558
+ const makeReason = Effect.map(Ref.make(Option.none()), (ref) => ({
1559
+ set: (r) => Ref.update(ref, Option.orElse(() => Option.some(r))),
1560
+ get: Ref.get(ref)
1561
+ }));
1562
+ /** Download the item's files into `dir` (stamping each view's `path`) before
1563
+ * its line is emitted; failures warn on stderr and stamp `path: null`. */
1564
+ const saveFiles = (item, dir) => dir === void 0 ? Effect.void : Effect.gen(function* () {
1565
+ const out = yield* CliOutput;
1566
+ const warnings = yield* Effect.promise(() => saveItemFiles(item, dir));
1567
+ for (const w of warnings) yield* out.warn(w);
1568
+ });
1569
+ const collectSubmissions = (client, format, until, since, saveDir) => Effect.gen(function* () {
1570
+ const out = yield* CliOutput;
1571
+ const reason = yield* makeReason;
1572
+ const counts = yield* Ref.make(0);
1573
+ const failed = yield* sdkStream("submissions stream", (signal) => client.submissions({
1574
+ signal,
1575
+ ...since !== void 0 ? { since } : {}
1576
+ })).pipe(until.idleMs !== void 0 ? Stream.timeoutTo(Duration.millis(until.idleMs), Stream.drain(Stream.fromEffect(reason.set("idle")))) : (s) => s, Stream.tap((s) => saveFiles(s, saveDir).pipe(Effect.zipRight(out.print(format === "json" ? formatSubmission(s) : formatPrettySubmission(s))), Effect.zipRight(Ref.update(counts, (n) => n + 1)))), until.count !== void 0 ? Stream.takeUntilEffect(() => Effect.gen(function* () {
1577
+ if ((yield* Ref.get(counts)) < until.count) return false;
1578
+ yield* reason.set("count");
1579
+ return true;
1580
+ })) : (s) => s, until.timeoutMs !== void 0 ? Stream.interruptWhen(Effect.sleep(Duration.millis(until.timeoutMs)).pipe(Effect.zipRight(reason.set("timeout")))) : (s) => s, Stream.runDrain, Effect.matchEffect({
1581
+ onFailure: (e) => reason.set("error").pipe(Effect.zipRight(out.error(`submissions stream failed: ${e.cause instanceof Error ? e.cause.message : String(e.cause)}`)), Effect.as(true)),
1582
+ onSuccess: () => Effect.succeed(false)
1583
+ }));
1584
+ const total = yield* Ref.get(counts);
1585
+ const why = Option.getOrElse(yield* reason.get, () => "closed");
1586
+ if (format === "json") yield* out.print(formatEnd(why, total > 0 ? { submission: total } : {}, void 0, why === "error" ? "submissions stream failed" : void 0));
1587
+ else yield* out.info(`done (${why}): ${JSON.stringify(total > 0 ? { submission: total } : {})}`);
1588
+ if (failed) return yield* Effect.fail(new Aborted());
1589
+ });
1590
+ const collectGroup = (groupId, source, members, format, until, saveDir) => Effect.gen(function* () {
1591
+ const out = yield* CliOutput;
1592
+ const reason = yield* makeReason;
1593
+ const counts = yield* Ref.make({});
1594
+ const completed = yield* Ref.make(HashSet.empty());
1595
+ const deleted = yield* Ref.make(HashSet.empty());
1596
+ const total = yield* Ref.make(0);
1597
+ const doneCount = Effect.gen(function* () {
1598
+ return HashSet.size(yield* Ref.get(completed)) + HashSet.size(yield* Ref.get(deleted));
1599
+ });
1600
+ const instanceIdOf = (g) => {
1601
+ const inst = g.instance;
1602
+ return inst.taskId ?? inst.notificationId ?? "";
1603
+ };
1604
+ const failed = yield* sdkStream("collect stream", source).pipe(Stream.tap((g) => Effect.gen(function* () {
1605
+ yield* saveFiles(g.item, saveDir);
1606
+ yield* out.print(format === "json" ? formatItem(g, groupId) : formatPrettyItem(g));
1607
+ yield* Ref.update(total, (n) => n + 1);
1608
+ const kind = g.item.kind;
1609
+ const label = kind === "reply" ? "reply" : kind === "input" ? "input" : kind === "taskDeleted" ? "deleted" : "completed";
1610
+ yield* Ref.update(counts, (c) => ({
1611
+ ...c,
1612
+ [label]: (c[label] ?? 0) + 1
1613
+ }));
1614
+ if (kind === "taskCompleted" || kind === "notificationCompleted") yield* Ref.update(completed, HashSet.add(instanceIdOf(g)));
1615
+ if (kind === "taskDeleted") yield* Ref.update(deleted, HashSet.add(instanceIdOf(g)));
1616
+ })), until.count !== void 0 ? Stream.takeUntilEffect(() => Effect.gen(function* () {
1617
+ if ((yield* Ref.get(total)) < until.count) return false;
1618
+ yield* reason.set("count");
1619
+ return true;
1620
+ })) : (s) => s, until.complete && members.length > 0 ? Stream.takeUntilEffect(() => Effect.gen(function* () {
1621
+ if ((yield* doneCount) < members.length) return false;
1622
+ yield* reason.set("complete");
1623
+ return true;
1624
+ })) : (s) => s, until.timeoutMs !== void 0 ? Stream.interruptWhen(Effect.sleep(Duration.millis(until.timeoutMs)).pipe(Effect.zipRight(reason.set("timeout")))) : (s) => s, Stream.runDrain, Effect.matchEffect({
1625
+ onFailure: (e) => reason.set("error").pipe(Effect.zipRight(out.error(`collect stream failed: ${e.cause instanceof Error ? e.cause.message : String(e.cause)}`)), Effect.as(true)),
1626
+ onSuccess: () => Effect.succeed(false)
1627
+ }));
1628
+ const done = yield* doneCount;
1629
+ const fallback = members.length > 0 && done >= members.length ? "complete" : until.idleMs !== void 0 ? "idle" : "closed";
1630
+ const why = Option.getOrElse(yield* reason.get, () => fallback);
1631
+ const memberStatus = {
1632
+ total: members.length,
1633
+ completed: HashSet.size(yield* Ref.get(completed)),
1634
+ deleted: HashSet.size(yield* Ref.get(deleted)),
1635
+ pending: members.length - done
1636
+ };
1637
+ if (format === "json") yield* out.print(formatEnd(why, yield* Ref.get(counts), memberStatus, why === "error" ? "collect stream failed" : void 0));
1638
+ else yield* out.info(`done (${why}): ${JSON.stringify(yield* Ref.get(counts))}`);
1639
+ if (failed) return yield* Effect.fail(new Aborted());
1640
+ });
1641
+ //#endregion
1642
+ //#region src/commands/daemon.ts
1643
+ const resolveDaemonCredential = (tokenOpt) => Effect.gen(function* () {
1644
+ if (Option.isSome(tokenOpt)) return {
1645
+ kind: "personal",
1646
+ apiToken: tokenOpt.value
1647
+ };
1648
+ const spawnedBearer = process.env.SP_DAEMON_BEARER;
1649
+ if (spawnedBearer) return {
1650
+ kind: "org",
1651
+ bearer: spawnedBearer
1652
+ };
1653
+ const auth = yield* (yield* AuthStore).load.pipe(Effect.orElseSucceed(Option.none));
1654
+ if (Option.isSome(auth)) return {
1655
+ kind: "org",
1656
+ bearer: bearerToken(auth.value)
1657
+ };
1658
+ return yield* Effect.fail(new UserError({ message: "no credential: pass --api-token (or set $SP_API_TOKEN) for a personal broker, or `sp auth login` for an org broker" }));
1659
+ });
1660
+ const daemonCommand = Command.make("daemon", {
1661
+ "api-token": apiTokenOption,
1662
+ "base-url": baseUrlOption,
1663
+ quiet: quietOption
1664
+ }, (args) => Effect.gen(function* () {
1665
+ yield* (yield* CliOutput).setQuiet(args.quiet);
1666
+ yield* runDaemon({
1667
+ credential: yield* resolveDaemonCredential(args["api-token"]),
1668
+ baseUrl: args["base-url"]
1669
+ });
1670
+ }));
1671
+ //#endregion
1672
+ //#region src/commands/download.ts
1673
+ const IDLE_MS = 1e4;
1674
+ 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);
1682
+ };
1683
+ 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).")),
1685
+ 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
+ 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
+ format: Options.choice("format", ["json", "pretty"]).pipe(Options.withDescription("Output format: json (one `downloaded` line) or pretty (the saved path)."), Options.withDefault("json")),
1689
+ "api-token": apiTokenOption,
1690
+ password: passwordOption,
1691
+ "base-url": baseUrlOption,
1692
+ quiet: quietOption
1693
+ }, (args) => Effect.gen(function* () {
1694
+ const out = yield* CliOutput;
1695
+ yield* out.setQuiet(args.quiet);
1696
+ const scope = yield* resolveScope(args.scopeId, args.fileId);
1697
+ const sinceIso = resolveSince(args.since);
1698
+ const cred = yield* resolveCredential(args["api-token"], args["base-url"]);
1699
+ 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
1720
+ });
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)}` })
1747
+ });
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);
1757
+ }));
1758
+ }));
1759
+ //#endregion
1760
+ //#region src/output.ts
1761
+ function formatEvent(event, format, decrypted) {
1762
+ switch (format) {
1763
+ case "json": {
1764
+ const value = { ...event };
1765
+ if (decrypted !== void 0) value.decrypted = decrypted;
1766
+ return JSON.stringify(value);
1767
+ }
1768
+ case "pretty": {
1769
+ const lines = [`=== ${event.eventType} ===`];
1770
+ if (event.createdAt) lines.push(` at: ${event.createdAt}`);
1771
+ if (event.streamId) lines.push(` stream: ${event.streamId}`);
1772
+ if (event.actor) {
1773
+ const a = event.actor;
1774
+ lines.push(` actor: ${a.name ? `${a.name} (${a.publicId})` : a.publicId}`);
1775
+ if (a.devicePublicId || a.deviceName) lines.push(` device: ${a.deviceName ? `${a.deviceName} (${a.devicePublicId ?? "?"})` : a.devicePublicId}`);
1776
+ }
1777
+ if (event.encryption) {
1778
+ const enc = event.encryption.type === "personal" ? `personal (${event.encryption.passwordFingerprint})` : `org (v${event.encryption.v})`;
1779
+ lines.push(` encryption: ${enc}`);
1780
+ }
1781
+ if (decrypted !== void 0) lines.push(` decrypted: ${prettyValue(decrypted)}`);
1782
+ else lines.push(` data: ${prettyValue(event.data)}`);
1783
+ return lines.join("\n") + "\n";
1784
+ }
1785
+ case "raw": {
1786
+ const payload = decrypted ?? event.data;
1787
+ return extractRaw(payload) ?? JSON.stringify(payload);
1788
+ }
1789
+ }
1790
+ }
1791
+ function prettyValue(v) {
1792
+ try {
1793
+ return JSON.stringify(v, null, 2);
1794
+ } catch {
1795
+ return String(v);
1796
+ }
1797
+ }
1798
+ function extractRaw(v) {
1799
+ if (!v || typeof v !== "object") return void 0;
1800
+ const obj = v;
1801
+ for (const k of [
1802
+ "text",
1803
+ "value",
1804
+ "selectedValue",
1805
+ "url",
1806
+ "presignedGetUrl",
1807
+ "objectKey"
1808
+ ]) {
1809
+ const x = obj[k];
1810
+ if (typeof x === "string") return x;
1811
+ }
1812
+ }
1813
+ //#endregion
1814
+ //#region src/commands/events.ts
1815
+ const eventTypeOption = Options.text("type").pipe(Options.withDescription("Filter by event type. Repeatable."), Options.repeated);
1816
+ const sinceOption = mappedText("since", resolveSince).pipe(Options.withDescription("Replay from this point. Accepts `24h`, `7d`, or an ISO 8601 timestamp."), Options.optional);
1817
+ const untilOption = mappedText("until", resolveUntil).pipe(Options.withDescription("Stop at this timestamp. Forces a finite range, so `--follow` is ignored."), Options.optional);
1818
+ const limitOption = Options.integer("limit").pipe(Options.withDescription("Maximum number of events to print, then exit."), Options.optional);
1819
+ 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", [
1821
+ "json",
1822
+ "pretty",
1823
+ "raw"
1824
+ ]).pipe(Options.withDescription("Output format."), Options.withDefault("json"));
1825
+ 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";
1827
+ const eventsCommand = Command.make("events", {
1828
+ type: eventTypeOption,
1829
+ since: sinceOption,
1830
+ until: untilOption,
1831
+ limit: limitOption,
1832
+ follow: followOption,
1833
+ format: formatOption$2,
1834
+ direct: directOption,
1835
+ topic: topicOption,
1836
+ "api-token": apiTokenOption,
1837
+ password: passwordOption,
1838
+ "base-url": baseUrlOption,
1839
+ quiet: quietOption
1840
+ }, (args) => Effect.gen(function* () {
1841
+ const out = yield* CliOutput;
1842
+ yield* out.setQuiet(args.quiet);
1843
+ const apiToken = yield* requireApiToken(args["api-token"]);
1844
+ const baseUrl = args["base-url"];
1845
+ const sinceIso = Option.getOrUndefined(args.since);
1846
+ const untilDate = Option.getOrUndefined(args.until);
1847
+ const limit = Option.getOrUndefined(args.limit);
1848
+ const filter = new TypeFilter(args.type);
1849
+ yield* Effect.forEach(filter.unknown, (u) => out.warn(`ignoring unknown --type \`${u}\``));
1850
+ const webSocketFactory = args.direct || sinceIso !== void 0 ? Option.none() : yield* sharedWebSocketFactory({
1851
+ credential: {
1852
+ kind: "personal",
1853
+ apiToken
1854
+ },
1855
+ baseUrl
1856
+ });
1857
+ yield* Effect.scoped(Effect.gen(function* () {
1858
+ const client = yield* acquireClient({
1859
+ baseUrl,
1860
+ apiToken,
1861
+ passwords: args.password,
1862
+ ...Option.match(webSocketFactory, {
1863
+ onNone: () => ({}),
1864
+ onSome: (f) => ({ webSocketFactory: f })
1865
+ })
1866
+ });
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)`);
1869
+ 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}` : ""}`);
1871
+ const exitWhenQuiet = sinceIso !== void 0 && !args.follow && untilDate === void 0;
1872
+ const printed = yield* Ref.make(0);
1873
+ const endNote = yield* Ref.make(Option.none());
1874
+ const note = (msg) => Ref.set(endNote, Option.some(msg));
1875
+ const untilReached = (ev) => {
1876
+ if (untilDate === void 0 || !ev.createdAt) return false;
1877
+ const t = new Date(ev.createdAt);
1878
+ return Number.isFinite(t.getTime()) && t >= untilDate;
1879
+ };
1880
+ yield* sdkStream("events stream", (signal) => client.events({
1881
+ ...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* () {
1884
+ const decrypted = keyring ? yield* sdkCall("decrypt event", () => tryDecryptEventData(ev, keyring)) : void 0;
1885
+ yield* out.print(formatEvent(ev, args.format, decrypted));
1886
+ 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);
1889
+ yield* Ref.get(endNote).pipe(Effect.flatMap(Option.match({
1890
+ onNone: () => Effect.void,
1891
+ onSome: (msg) => out.info(msg)
1892
+ })));
1893
+ const total = yield* Ref.get(printed);
1894
+ if (args.format === "raw" && total === 0) yield* out.warn("no events matched");
1895
+ }));
1896
+ }));
1897
+ //#endregion
1898
+ //#region src/input-spec.ts
1899
+ const SETTING_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
1900
+ function looksLikeSetting(seg) {
1901
+ return SETTING_RE.test(seg);
1902
+ }
1903
+ function supportedKeys(kind) {
1904
+ if (kind === "text") return ["required", "defaultValue"];
1905
+ if (kind === "choice") return [
1906
+ "required",
1907
+ "multi",
1908
+ "minSelections",
1909
+ "maxSelections"
1910
+ ];
1911
+ return ["required"];
1912
+ }
1913
+ function parseBool(s) {
1914
+ if (s === "true" || s === "yes" || s === "1") return true;
1915
+ if (s === "false" || s === "no" || s === "0") return false;
1916
+ throw new Error(`expected boolean (true/false), got \`${s}\``);
1917
+ }
1918
+ function parseCount(s, floor) {
1919
+ const n = Number(s);
1920
+ if (!Number.isInteger(n) || n < floor) throw new Error(`expected an integer >= ${floor}, got \`${s}\``);
1921
+ return n;
1922
+ }
1923
+ function applySetting(spec, seg, kind) {
1924
+ const eq = seg.indexOf("=");
1925
+ if (eq < 0) throw new Error(`expected \`key=value\` segment, got \`${seg}\``);
1926
+ const key = seg.slice(0, eq).trim();
1927
+ const value = seg.slice(eq + 1).trim();
1928
+ if (key === "required") {
1929
+ spec.required = parseBool(value);
1930
+ return;
1931
+ }
1932
+ if (key === "defaultValue") {
1933
+ if (kind !== "text") throw new Error("`defaultValue=` is not supported on this input type (only --text-input)");
1934
+ spec.defaultValue = value;
1935
+ return;
1936
+ }
1937
+ if (key === "multi") {
1938
+ if (kind !== "choice") throw new Error("`multi=` is not supported on this input type (only --choice-input)");
1939
+ spec.multi = parseBool(value);
1940
+ return;
1941
+ }
1942
+ if (key === "minSelections") {
1943
+ if (kind !== "choice") throw new Error("`minSelections=` is not supported on this input type (only --choice-input)");
1944
+ spec.minSelections = parseCount(value, 0);
1945
+ return;
1946
+ }
1947
+ if (key === "maxSelections") {
1948
+ if (kind !== "choice") throw new Error("`maxSelections=` is not supported on this input type (only --choice-input)");
1949
+ spec.maxSelections = parseCount(value, 1);
1950
+ return;
1951
+ }
1952
+ throw new Error(`unknown input setting \`${key}=\`; supported: ${supportedKeys(kind).join(", ")}`);
1953
+ }
1954
+ function splitUnescaped(s, sep) {
1955
+ const out = [];
1956
+ let cur = "";
1957
+ for (let i = 0; i < s.length; i++) {
1958
+ const c = s[i];
1959
+ if (c === "\\") {
1960
+ const next = s[i + 1];
1961
+ if (next === sep || next === "\\") {
1962
+ cur += next;
1963
+ i += 1;
1964
+ continue;
1965
+ }
1966
+ cur += c;
1967
+ continue;
1968
+ }
1969
+ if (c === sep) {
1970
+ out.push(cur);
1971
+ cur = "";
1972
+ continue;
1973
+ }
1974
+ cur += c;
1975
+ }
1976
+ out.push(cur);
1977
+ return out;
1978
+ }
1979
+ function parseInputSpec(raw, kind) {
1980
+ const segments = splitUnescaped(raw, ";");
1981
+ const first = segments.shift() ?? "";
1982
+ const spec = { required: true };
1983
+ if (first !== "") spec.description = first;
1984
+ for (const seg of segments) applySetting(spec, seg, kind);
1985
+ return spec;
1986
+ }
1987
+ function parseChoiceSpec(raw) {
1988
+ const segments = splitUnescaped(raw, ";");
1989
+ const nonSettings = [];
1990
+ const settings = [];
1991
+ for (const seg of segments) (looksLikeSetting(seg) ? settings : nonSettings).push(seg);
1992
+ let description;
1993
+ let optionsRaw;
1994
+ if (nonSettings.length === 0) throw new Error("--choice-input requires a comma-separated options list");
1995
+ else if (nonSettings.length === 1) optionsRaw = nonSettings[0];
1996
+ else if (nonSettings.length === 2) {
1997
+ description = nonSettings[0] !== "" ? nonSettings[0] : void 0;
1998
+ optionsRaw = nonSettings[1];
1999
+ } else throw new Error("--choice-input has too many `;`-separated non-setting segments (expected `[description;]options[;key=value...]`)");
2000
+ const options = optionsRaw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
2001
+ if (options.length === 0) throw new Error("--choice-input options list is empty");
2002
+ const spec = { required: true };
2003
+ if (description !== void 0) spec.description = description;
2004
+ for (const seg of settings) applySetting(spec, seg, "choice");
2005
+ return {
2006
+ spec,
2007
+ options
2008
+ };
2009
+ }
2010
+ const VALID_ACTION_STYLES = new Set([
2011
+ "default",
2012
+ "primary",
2013
+ "destructive"
2014
+ ]);
2015
+ function parseActionToken(token) {
2016
+ const eq = token.indexOf("=");
2017
+ if (eq < 0) throw new Error(`each action must be \`key=Label[:style]\`, got \`${token}\``);
2018
+ const key = token.slice(0, eq).trim();
2019
+ let label = token.slice(eq + 1).trim();
2020
+ if (key.length === 0) throw new Error(`action key must not be empty in \`${token}\``);
2021
+ if (label.length === 0) throw new Error(`action label must not be empty in \`${token}\``);
2022
+ let style;
2023
+ const lastColon = label.lastIndexOf(":");
2024
+ if (lastColon >= 0) {
2025
+ const maybe = label.slice(lastColon + 1).trim();
2026
+ if (VALID_ACTION_STYLES.has(maybe)) {
2027
+ style = maybe;
2028
+ label = label.slice(0, lastColon).trim();
2029
+ if (label.length === 0) throw new Error(`action label must not be empty in \`${token}\``);
2030
+ }
2031
+ }
2032
+ return style ? {
2033
+ key,
2034
+ label,
2035
+ style
2036
+ } : {
2037
+ key,
2038
+ label
2039
+ };
2040
+ }
2041
+ function parseActionsSpec(raw) {
2042
+ const segments = splitUnescaped(raw, ";");
2043
+ const settings = [];
2044
+ const nonSettings = [];
2045
+ for (const seg of segments) (/^\s*required\s*=/.test(seg) ? settings : nonSettings).push(seg);
2046
+ let description;
2047
+ let actionsRaw;
2048
+ if (nonSettings.length === 0) throw new Error("--action-input requires a comma-separated `key=Label[:style]` list");
2049
+ else if (nonSettings.length === 1) actionsRaw = nonSettings[0];
2050
+ else if (nonSettings.length === 2) {
2051
+ description = nonSettings[0] !== "" ? nonSettings[0] : void 0;
2052
+ actionsRaw = nonSettings[1];
2053
+ } else throw new Error("--action-input has too many `;`-separated non-setting segments (expected `[description;]key=Label[:style],...[;required=...]`)");
2054
+ const actions = splitUnescaped(actionsRaw, ",").map((s) => s.trim()).filter((s) => s.length > 0).map(parseActionToken);
2055
+ if (actions.length === 0) throw new Error("--action-input list is empty");
2056
+ const seenKeys = /* @__PURE__ */ new Set();
2057
+ for (const a of actions) {
2058
+ if (seenKeys.has(a.key)) throw new Error(`duplicate action key: \`${a.key}\``);
2059
+ seenKeys.add(a.key);
2060
+ }
2061
+ const spec = { required: true };
2062
+ if (description !== void 0) spec.description = description;
2063
+ for (const seg of settings) applySetting(spec, seg, "actions");
2064
+ return {
2065
+ spec,
2066
+ actions
2067
+ };
2068
+ }
2069
+ function parseNum(s, name) {
2070
+ const n = Number(s);
2071
+ if (!Number.isFinite(n)) throw new Error(`slider \`${name}\` must be a number, got \`${s}\``);
2072
+ return n;
2073
+ }
2074
+ function parseSliderSpec(raw) {
2075
+ const segments = splitUnescaped(raw, ";");
2076
+ let description;
2077
+ const settings = [];
2078
+ segments.forEach((seg, i) => {
2079
+ if (i === 0 && !looksLikeSetting(seg)) {
2080
+ if (seg !== "") description = seg;
2081
+ } else settings.push(seg);
2082
+ });
2083
+ let min, max, step, defaultValue;
2084
+ let unit;
2085
+ let required = true;
2086
+ for (const seg of settings) {
2087
+ const eq = seg.indexOf("=");
2088
+ if (eq < 0) throw new Error(`expected \`key=value\` segment, got \`${seg}\``);
2089
+ const key = seg.slice(0, eq).trim();
2090
+ const value = seg.slice(eq + 1).trim();
2091
+ switch (key) {
2092
+ case "min":
2093
+ min = parseNum(value, "min");
2094
+ break;
2095
+ case "max":
2096
+ max = parseNum(value, "max");
2097
+ break;
2098
+ case "step":
2099
+ step = parseNum(value, "step");
2100
+ break;
2101
+ case "unit":
2102
+ unit = value;
2103
+ break;
2104
+ case "default":
2105
+ defaultValue = parseNum(value, "default");
2106
+ break;
2107
+ case "required":
2108
+ required = parseBool(value);
2109
+ break;
2110
+ default: throw new Error(`unknown slider setting \`${key}=\`; supported: min, max, step, unit, default, required`);
2111
+ }
2112
+ }
2113
+ if (min === void 0 || max === void 0) throw new Error("--slider-input requires `min=` and `max=`");
2114
+ if (min >= max) throw new Error("--slider-input `min` must be less than `max`");
2115
+ if (step !== void 0 && step <= 0) throw new Error("--slider-input `step` must be positive");
2116
+ if (defaultValue !== void 0 && (defaultValue < min || defaultValue > max)) throw new Error("--slider-input `default` must be within [min, max]");
2117
+ const spec = { required };
2118
+ if (description !== void 0) spec.description = description;
2119
+ return {
2120
+ spec,
2121
+ slider: {
2122
+ min,
2123
+ max,
2124
+ ...step !== void 0 ? { step } : {},
2125
+ ...unit !== void 0 && unit !== "" ? { unit } : {},
2126
+ ...defaultValue !== void 0 ? { defaultValue } : {}
2127
+ }
2128
+ };
2129
+ }
2130
+ function textInputJson(spec) {
2131
+ const out = {
2132
+ type: "text",
2133
+ required: spec.required
2134
+ };
2135
+ if (spec.description !== void 0) out.description = spec.description;
2136
+ if (spec.defaultValue !== void 0) out.defaultValue = spec.defaultValue;
2137
+ return out;
2138
+ }
2139
+ function simpleInputJson(type, spec) {
2140
+ const out = {
2141
+ type,
2142
+ required: spec.required
2143
+ };
2144
+ if (spec.description !== void 0) out.description = spec.description;
2145
+ return out;
2146
+ }
2147
+ function choiceInputJson(spec, options) {
2148
+ const out = {
2149
+ type: "choice",
2150
+ required: spec.required,
2151
+ options
2152
+ };
2153
+ if (spec.description !== void 0) out.description = spec.description;
2154
+ if (spec.multi) out.multi = true;
2155
+ if (spec.minSelections !== void 0) out.minSelections = spec.minSelections;
2156
+ if (spec.maxSelections !== void 0) out.maxSelections = spec.maxSelections;
2157
+ return out;
2158
+ }
2159
+ function sliderInputJson(spec, slider) {
2160
+ const out = {
2161
+ type: "slider",
2162
+ required: spec.required,
2163
+ min: slider.min,
2164
+ max: slider.max,
2165
+ ...slider.step !== void 0 ? { step: slider.step } : {},
2166
+ ...slider.unit !== void 0 ? { unit: slider.unit } : {},
2167
+ ...slider.defaultValue !== void 0 ? { defaultValue: slider.defaultValue } : {}
2168
+ };
2169
+ if (spec.description !== void 0) out.description = spec.description;
2170
+ return out;
2171
+ }
2172
+ function actionsInputJson(spec, actions) {
2173
+ const out = {
2174
+ type: "actions",
2175
+ required: spec.required,
2176
+ actions
2177
+ };
2178
+ if (spec.description !== void 0) out.description = spec.description;
2179
+ return out;
2180
+ }
2181
+ /** Parse every `--*-input` flag into the ordered `Input[]` sent on the wire.
2182
+ * Shared between `sp task` and `sp subtask`. Inputs are emitted grouped by kind
2183
+ * (text, choice, actions, slider, photo, voice, file, location). */
2184
+ function buildInputs(args) {
2185
+ const out = [];
2186
+ for (const raw of args["text-input"] ?? []) out.push(textInputJson(parseInputSpec(raw, "text")));
2187
+ for (const raw of args["choice-input"] ?? []) {
2188
+ const { spec, options } = parseChoiceSpec(raw);
2189
+ out.push(choiceInputJson(spec, options));
2190
+ }
2191
+ for (const raw of args["action-input"] ?? []) {
2192
+ const { spec, actions } = parseActionsSpec(raw);
2193
+ out.push(actionsInputJson(spec, actions));
2194
+ }
2195
+ for (const raw of args["slider-input"] ?? []) {
2196
+ const { spec, slider } = parseSliderSpec(raw);
2197
+ out.push(sliderInputJson(spec, slider));
2198
+ }
2199
+ for (const raw of args["photo-input"] ?? []) out.push(simpleInputJson("photo", parseInputSpec(raw, "photo")));
2200
+ for (const raw of args["voice-recording-input"] ?? []) out.push(simpleInputJson("voiceRecording", parseInputSpec(raw, "voiceRecording")));
2201
+ for (const raw of args["file-input"] ?? []) out.push(simpleInputJson("file", parseInputSpec(raw, "file")));
2202
+ for (const raw of args["location-input"] ?? []) out.push(simpleInputJson("location", parseInputSpec(raw, "location")));
2203
+ return out;
2204
+ }
2205
+ //#endregion
2206
+ //#region src/commands/notify.ts
2207
+ const NOTIFY_MEDIA_TYPES = {
2208
+ image: new Set([
2209
+ "image/jpeg",
2210
+ "image/png",
2211
+ "image/gif"
2212
+ ]),
2213
+ audio: new Set([
2214
+ "audio/aiff",
2215
+ "audio/x-aiff",
2216
+ "audio/wav",
2217
+ "audio/x-wav",
2218
+ "audio/vnd.wave",
2219
+ "audio/mpeg",
2220
+ "audio/mp3",
2221
+ "audio/mp4",
2222
+ "audio/aac",
2223
+ "audio/x-m4a"
2224
+ ])
2225
+ };
2226
+ const NOTIFY_MEDIA_EXT = {
2227
+ png: "image/png",
2228
+ jpg: "image/jpeg",
2229
+ jpeg: "image/jpeg",
2230
+ gif: "image/gif",
2231
+ aiff: "audio/aiff",
2232
+ aif: "audio/aiff",
2233
+ wav: "audio/wav",
2234
+ mp3: "audio/mpeg",
2235
+ m4a: "audio/mp4",
2236
+ aac: "audio/aac"
2237
+ };
2238
+ /** Derive + validate the media content type from a URL's extension, or null if
2239
+ * unsupported for the kind. */
2240
+ function notifyMediaContentType(url, kind) {
2241
+ const clean = url.split("?")[0] ?? url;
2242
+ const ct = NOTIFY_MEDIA_EXT[clean.slice(clean.lastIndexOf(".") + 1).toLowerCase()];
2243
+ return ct && NOTIFY_MEDIA_TYPES[kind].has(ct) ? ct : null;
2244
+ }
2245
+ const contentOption$2 = Options.text("content").pipe(Options.withDescription("Notification body. Encrypted under the org master_key when the vault is unlocked, plaintext otherwise."));
2246
+ const titleOption$2 = Options.text("title").pipe(Options.withDescription("Optional notification title shown above the body on the recipient's lock screen."), Options.optional);
2247
+ const memberOption$1 = Options.text("member").pipe(Options.withAlias("m"), Options.withDescription("Send to a single org member by display name (case-insensitive). Org send; mutually exclusive with --broadcast, --org-topic, and -k/--topic."), Options.optional);
2248
+ const broadcastOption$1 = Options.boolean("broadcast").pipe(Options.withAlias("b"), Options.withDescription("Send to every member of the org. Org send; mutually exclusive with --member, --org-topic, and -k/--topic."));
2249
+ const orgTopicOption$1 = 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 (the personal topic)."), Options.optional);
2250
+ const tagOption$1 = Options.text("tag").pipe(Options.withDescription("Optional notification tag — recipients can use it to coalesce / replace prior notifications with the same tag."), Options.optional);
2251
+ const imageOption = Options.text("image").pipe(Options.withDescription("Image URL to show in the push (PNG/JPEG/GIF). Renders on iOS + Android. Mutually exclusive with --audio. URLs only — file uploads are SDK-only."), Options.optional);
2252
+ const audioOption = Options.text("audio").pipe(Options.withDescription("Audio URL to play inline in the push (AIFF/WAV/MP3/M4A; iOS only). Mutually exclusive with --image. URLs only."), Options.optional);
2253
+ const noEncryptOption$2 = Options.boolean("no-encrypt").pipe(Options.withDescription("Send the body in plaintext even when an unlocked vault is available."));
2254
+ const actionInputOption = Options.text("action-input").pipe(Options.withAlias("a"), Options.withDescription("Add an actions input: tap-buttons the recipient answers with (e.g. Accept/Deny). Format: `[description;]key=Label[:style],...`, actions comma-separated; style is default|destructive. Use `\\,` for a literal comma in a label. A notification carries at most one input."), Options.optional);
2255
+ 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
+ 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
+ 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"));
2259
+ /** Parse the (at most one) notification input off the three flags. */
2260
+ const parseNotificationInput = (textInputOn, choiceRaw, actionRaw) => Effect.gen(function* () {
2261
+ if ([
2262
+ textInputOn,
2263
+ choiceRaw !== void 0,
2264
+ actionRaw !== void 0
2265
+ ].filter(Boolean).length > 1) return yield* Effect.fail(new UserError({ message: "a notification carries at most one input: pass only one of --text-input, --choice-input, or --action-input" }));
2266
+ if (textInputOn) return { type: "text" };
2267
+ if (choiceRaw !== void 0) {
2268
+ const options = choiceRaw.split(",").map((o) => o.trim()).filter((o) => o.length > 0);
2269
+ if (options.length === 0) return yield* Effect.fail(new UserError({ message: "--choice-input needs at least one comma-separated option" }));
2270
+ return {
2271
+ type: "choice",
2272
+ options
2273
+ };
2274
+ }
2275
+ if (actionRaw !== void 0) {
2276
+ const actions = yield* Effect.try({
2277
+ try: () => parseActionsSpec(actionRaw).actions,
2278
+ catch: (e) => new UserError({ message: e instanceof Error ? e.message : String(e) })
2279
+ });
2280
+ const primary = actions.find((a) => a.style === "primary");
2281
+ if (primary !== void 0) return yield* Effect.fail(new UserError({ message: `notification action styles must be 'default' or 'destructive' (got 'primary' on key \`${primary.key}\`) — 'primary' is task-only` }));
2282
+ return {
2283
+ type: "actions",
2284
+ actions: actions.map((a) => ({
2285
+ key: a.key,
2286
+ label: a.label,
2287
+ ...a.style !== void 0 ? { style: a.style } : {}
2288
+ }))
2289
+ };
2290
+ }
2291
+ });
2292
+ const notifyCommand = Command.make("notify", {
2293
+ content: contentOption$2,
2294
+ title: titleOption$2,
2295
+ member: memberOption$1,
2296
+ broadcast: broadcastOption$1,
2297
+ "org-topic": orgTopicOption$1,
2298
+ tag: tagOption$1,
2299
+ image: imageOption,
2300
+ audio: audioOption,
2301
+ "text-input": textInputOption,
2302
+ "choice-input": choiceInputOption,
2303
+ "action-input": actionInputOption,
2304
+ shared: sharedOption$1,
2305
+ format: formatOption$1,
2306
+ noEncrypt: noEncryptOption$2,
2307
+ topic: topicOption,
2308
+ "api-token": apiTokenOption,
2309
+ password: passwordOption,
2310
+ "base-url": baseUrlOption,
2311
+ quiet: quietOption
2312
+ }, (args) => Effect.gen(function* () {
2313
+ const out = yield* CliOutput;
2314
+ yield* out.setQuiet(args.quiet);
2315
+ const memberName = Option.getOrUndefined(args.member);
2316
+ const orgTopicName = Option.getOrUndefined(args["org-topic"]);
2317
+ const personalTopic = args.topic[0];
2318
+ const isOrgTarget = memberName !== void 0 || args.broadcast || orgTopicName !== void 0;
2319
+ if ([
2320
+ memberName !== void 0,
2321
+ args.broadcast,
2322
+ orgTopicName !== void 0,
2323
+ personalTopic !== void 0
2324
+ ].filter(Boolean).length > 1) return yield* Effect.fail(new UserError({ message: "pass at most one target: -m <member> | -b (broadcast) | -o <org-topic> | -t <topic> (omit all for a self-send)" }));
2325
+ const titleOpt = Option.getOrUndefined(args.title);
2326
+ const tagOpt = Option.getOrUndefined(args.tag);
2327
+ const message = args.content;
2328
+ const imageUrl = Option.getOrUndefined(args.image);
2329
+ const audioUrl = Option.getOrUndefined(args.audio);
2330
+ if (imageUrl !== void 0 && audioUrl !== void 0) return yield* Effect.fail(new UserError({ message: "only one of --image or --audio may be set" }));
2331
+ const mediaUrl = imageUrl ?? audioUrl;
2332
+ const mediaKind = imageUrl !== void 0 ? "image" : "audio";
2333
+ let mediaContentType;
2334
+ if (mediaUrl !== void 0) {
2335
+ 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;
2339
+ }
2340
+ const input = yield* parseNotificationInput(args["text-input"], Option.getOrUndefined(args["choice-input"]), Option.getOrUndefined(args["action-input"]));
2341
+ if (isOrgTarget) {
2342
+ const api = yield* Api;
2343
+ const vault = yield* (yield* VaultAccess).forSendOrPlaintext(args.noEncrypt);
2344
+ 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 = {
2355
+ content: message,
2356
+ ...titleOpt !== void 0 ? { title: titleOpt } : {},
2357
+ ...tagOpt !== void 0 ? { tag: tagOpt } : {},
2358
+ ...input !== void 0 ? { input } : {},
2359
+ ...args.shared ? { shared: true } : {}
2360
+ };
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})` : ""}`;
2369
+ return out.info(`instance: ${who} -> ${inst.notificationId}`);
2370
+ });
2371
+ if (n === 0) yield* out.warn("the target has no recipients — the group is empty");
2372
+ if (args.format === "json") {
2373
+ const members = payload.instances.map((inst) => ({
2374
+ id: inst.notificationId,
2375
+ kind: "notification",
2376
+ recipient: {
2377
+ publicId: inst.recipient.publicId,
2378
+ name: inst.recipient.name ?? null
2379
+ }
2380
+ }));
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
+ }
2394
+ return;
2395
+ }
2396
+ const apiToken = yield* requireApiToken(args["api-token"]);
2397
+ const encrypting = willEncrypt(args.password, personalTopic);
2398
+ if (encrypting) yield* out.info("encrypting outgoing notification (Argon2id, this takes a moment)");
2399
+ const encNote = encrypting ? " (encrypted)" : " (plaintext)";
2400
+ const baseOpts = {
2401
+ content: message,
2402
+ ...titleOpt !== void 0 ? { title: titleOpt } : {},
2403
+ ...tagOpt !== void 0 ? { tag: tagOpt } : {},
2404
+ ...input !== void 0 ? { input } : {},
2405
+ ...imageUrl !== void 0 ? { image: imageUrl } : {},
2406
+ ...audioUrl !== void 0 ? { audio: audioUrl } : {}
2407
+ };
2408
+ yield* Effect.scoped(Effect.gen(function* () {
2409
+ const client = yield* acquireClient({
2410
+ baseUrl: args["base-url"],
2411
+ apiToken,
2412
+ passwords: [...args.password]
2413
+ });
2414
+ const printSingle = (note) => args.format === "json" ? out.print(formatSent(void 0, note.createdAt, [{
2415
+ id: note.notificationId,
2416
+ kind: "notification",
2417
+ recipient: null
2418
+ }])) : out.print(note.notificationId);
2419
+ if (personalTopic === void 0) {
2420
+ const note = yield* sdkCall("notify", () => client.sendNotification(baseOpts));
2421
+ yield* out.info(`self-send notification sent${encNote}.`);
2422
+ yield* out.info(`Id: ${note.notificationId}`);
2423
+ yield* printSingle(note);
2424
+ } else if (args.shared) {
2425
+ const note = yield* sdkCall("notify", () => client.sendNotification({
2426
+ ...baseOpts,
2427
+ topic: personalTopic,
2428
+ shared: true
2429
+ }));
2430
+ yield* out.info(`notification sent${encNote}.`);
2431
+ yield* out.info(`Id: ${note.notificationId}`);
2432
+ yield* printSingle(note);
2433
+ } else {
2434
+ const group = yield* sdkCall("notify", () => client.sendNotification({
2435
+ ...baseOpts,
2436
+ topic: personalTopic
2437
+ }));
2438
+ const n = group.instances.length;
2439
+ yield* out.info(`notification group created: ${group.groupId} (${n} recipient${n === 1 ? "" : "s"})${encNote}`);
2440
+ yield* Effect.forEach(group.instances, (inst) => {
2441
+ const who = `${inst.recipient?.publicId ?? "unknown"}${inst.recipient?.name ? ` (${inst.recipient.name})` : ""}`;
2442
+ return out.info(`instance: ${who} -> ${inst.notificationId}`);
2443
+ });
2444
+ if (n === 0) yield* out.warn("the topic has no recipients — the group is empty");
2445
+ if (args.format === "json") {
2446
+ const members = group.instances.map((inst) => ({
2447
+ id: inst.notificationId,
2448
+ kind: "notification",
2449
+ recipient: inst.recipient ? {
2450
+ publicId: inst.recipient.publicId,
2451
+ name: inst.recipient.name ?? null
2452
+ } : null
2453
+ }));
2454
+ yield* out.print(formatSent(group.groupId, group.createdAt, members));
2455
+ } else yield* out.print(group.groupId);
2456
+ }
2457
+ }));
2458
+ }));
2459
+ //#endregion
2460
+ //#region src/commands/org-encryption.ts
2461
+ const WrappedKey = Schema.Struct({
2462
+ version: Schema.Number,
2463
+ blob: Schema.String
2464
+ });
2465
+ const OrgEncryptionDeviceSummary = Schema.Struct({
2466
+ deviceId: Schema.String,
2467
+ devicePubkeyB64: Schema.String,
2468
+ inviteHmacB64: Schema.String,
2469
+ wrappedKeys: Schema.Array(WrappedKey)
2470
+ });
2471
+ const ListOrgEncryptionDevicesResponse = Schema.Struct({ devices: Schema.Array(OrgEncryptionDeviceSummary) });
2472
+ const fetchEncryptionDevices = Effect.gen(function* () {
2473
+ const { devices } = yield* (yield* Api).getJson("fetch encryption devices", "/v1/org/encryption/devices", ListOrgEncryptionDevicesResponse);
2474
+ return devices;
2475
+ });
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."));
2477
+ const enableCommand = Command.make("enable", {
2478
+ confirm: yesIWroteItDownOption,
2479
+ quiet: quietOption
2480
+ }, (args) => Effect.gen(function* () {
2481
+ const out = yield* CliOutput;
2482
+ yield* out.setQuiet(args.quiet);
2483
+ const api = yield* Api;
2484
+ const access = yield* VaultAccess;
2485
+ const vaultStore = yield* VaultStore;
2486
+ const sodium = yield* Sodium;
2487
+ if ((yield* access.fetchConfig).enabled) {
2488
+ yield* out.error("encryption is already enabled for this org. Use `sp org encryption key rotate` to rotate the key.");
2489
+ return yield* Effect.fail(new Aborted());
2490
+ }
2491
+ const passphrase = yield* sodium.generatePassphrase();
2492
+ const vaultSalt = yield* sodium.generateVaultSalt;
2493
+ const adminKp = yield* sodium.generateAdminKeyPair;
2494
+ const masterKey = yield* sodium.generateMasterKey;
2495
+ const vaultContents = {
2496
+ adminPublicKey: adminKp.publicKey,
2497
+ adminPrivateKey: adminKp.privateKey,
2498
+ masterKeyCurrent: {
2499
+ version: 1,
2500
+ key: masterKey
2501
+ },
2502
+ masterKeyHistory: []
2503
+ };
2504
+ const vaultKey = yield* sodium.deriveVaultKey(passphrase, vaultSalt, DEFAULT_KDF_PARAMS);
2505
+ const vaultBlob = yield* sodium.encryptVault(vaultContents, vaultKey);
2506
+ yield* out.info("");
2507
+ yield* out.info("=== ORG ENCRYPTION PASSPHRASE — copy this now, it will not be shown again ===");
2508
+ yield* out.info("");
2509
+ yield* out.print(` ${passphrase}`);
2510
+ yield* out.info("");
2511
+ yield* out.info("This passphrase is the ONLY way to:");
2512
+ yield* out.info(" - unlock the encryption vault from another admin's machine");
2513
+ yield* out.info(" - recover access if your CLI state is lost");
2514
+ yield* out.info("It cannot be recovered if forgotten. Re-enabling encryption forces");
2515
+ yield* out.info("every member device to re-onboard from scratch.");
2516
+ 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
+ yield* api.post("enable", "/v1/org/encryption/enable", {
2522
+ adminPubkeyB64: sodium.toB64(adminKp.publicKey),
2523
+ vaultBlobB64: sodium.toB64(vaultBlob),
2524
+ vaultSaltB64: sodium.toB64(vaultSalt),
2525
+ kdfParams: DEFAULT_KDF_PARAMS
2526
+ });
2527
+ yield* vaultStore.save(vaultContents);
2528
+ yield* out.info("Encryption enabled. master_key version 1 generated.");
2529
+ yield* out.info("Local vault cached at ~/.config/simplepush/vault.json (subsequent commands won't prompt).");
2530
+ }));
2531
+ const statusCommand = Command.make("status", { quiet: quietOption }, (args) => Effect.gen(function* () {
2532
+ const out = yield* CliOutput;
2533
+ yield* out.setQuiet(args.quiet);
2534
+ const access = yield* VaultAccess;
2535
+ const vaultStore = yield* VaultStore;
2536
+ const sodium = yield* Sodium;
2537
+ const cfg = yield* access.fetchConfig;
2538
+ if (!cfg.enabled) {
2539
+ yield* out.print("Encryption: disabled");
2540
+ return yield* out.info("Run `org encryption enable` to set it up.");
2541
+ }
2542
+ yield* out.print("Encryption: enabled");
2543
+ yield* out.print(`Admin pubkey: ${cfg.adminPubkeyB64 ?? "?"}`);
2544
+ const vault = Option.getOrUndefined(yield* vaultStore.load);
2545
+ const cacheStale = vault !== void 0 && typeof cfg.adminPubkeyB64 === "string" && !sodium.constantTimeEqual(vault.adminPublicKey, sodium.fromB64(cfg.adminPubkeyB64));
2546
+ yield* out.print(`Vault cache: ${vault ? cacheStale ? "STALE (doesn't match this org — next encryption command re-prompts)" : `present (master_key v${vault.masterKeyCurrent.version})` : "absent (next encryption command will prompt)"}`);
2547
+ const devices = yield* fetchEncryptionDevices;
2548
+ const currentVersion = cacheStale ? void 0 : vault?.masterKeyCurrent.version;
2549
+ const upToDate = currentVersion === void 0 ? 0 : devices.filter((d) => d.wrappedKeys.some((w) => w.version === currentVersion)).length;
2550
+ const pending = devices.length - upToDate;
2551
+ yield* out.print(`Devices onboarded: ${devices.length}`);
2552
+ if (currentVersion !== void 0) {
2553
+ yield* out.print(` current key wrapped: ${upToDate}`);
2554
+ yield* out.print(` pending sync: ${pending}`);
2555
+ if (pending > 0) yield* out.info("Run `org encryption sync` to wrap the current master key to pending devices.");
2556
+ } else yield* out.info("Local vault cache is absent — `org encryption sync` will prompt for the passphrase.");
2557
+ }));
2558
+ const wrapCurrentKeyToAllDevices = (vault) => Effect.gen(function* () {
2559
+ const out = yield* CliOutput;
2560
+ const api = yield* Api;
2561
+ const invites = yield* InviteStore;
2562
+ const sodium = yield* Sodium;
2563
+ const devices = yield* fetchEncryptionDevices;
2564
+ const knownInvites = yield* invites.listValid;
2565
+ const currentMaster = vault.masterKeyCurrent;
2566
+ let counts = {
2567
+ wrapped: 0,
2568
+ alreadyCurrent: 0,
2569
+ unverified: 0,
2570
+ failed: 0
2571
+ };
2572
+ yield* Effect.forEach(devices, (dev) => Effect.gen(function* () {
2573
+ const pubkey = sodium.fromB64(dev.devicePubkeyB64);
2574
+ const storedHmac = sodium.fromB64(dev.inviteHmacB64);
2575
+ const matchedInvite = knownInvites.find((inv) => sodium.constantTimeEqual(sodium.hmacInviteBinding(inv.code, pubkey), storedHmac));
2576
+ if (!matchedInvite) {
2577
+ counts = {
2578
+ ...counts,
2579
+ unverified: counts.unverified + 1
2580
+ };
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.)`);
2582
+ }
2583
+ if (dev.wrappedKeys.some((w) => w.version === currentMaster.version)) {
2584
+ counts = {
2585
+ ...counts,
2586
+ alreadyCurrent: counts.alreadyCurrent + 1
2587
+ };
2588
+ return;
2589
+ }
2590
+ const blob = yield* sodium.wrapMasterKey(currentMaster.key, vault.adminPrivateKey, pubkey);
2591
+ const nextWraps = [...dev.wrappedKeys.filter((w) => w.version !== currentMaster.version), {
2592
+ version: currentMaster.version,
2593
+ blob: sodium.toB64(blob)
2594
+ }];
2595
+ yield* api.put("device wrap", `/v1/org/encryption/devices/${encodeURIComponent(dev.deviceId)}/wraps`, { wraps: nextWraps }).pipe(Effect.matchEffect({
2596
+ onFailure: (e) => Effect.sync(() => {
2597
+ counts = {
2598
+ ...counts,
2599
+ failed: counts.failed + 1
2600
+ };
2601
+ }).pipe(Effect.zipRight(out.error(`device wrap failed (${"status" in e ? e.status : "?"}): ${"detail" in e ? e.detail : String(e)}`))),
2602
+ onSuccess: () => Effect.gen(function* () {
2603
+ counts = {
2604
+ ...counts,
2605
+ wrapped: counts.wrapped + 1
2606
+ };
2607
+ yield* invites.consume(matchedInvite.code).pipe(Effect.ignore);
2608
+ })
2609
+ }));
2610
+ }), { discard: true });
2611
+ return counts;
2612
+ });
2613
+ const summarize = (counts) => `wrapped=${counts.wrapped} already-current=${counts.alreadyCurrent} unverified=${counts.unverified}`;
2614
+ const syncCommand = Command.make("sync", { quiet: quietOption }, (args) => Effect.gen(function* () {
2615
+ const out = yield* CliOutput;
2616
+ yield* out.setQuiet(args.quiet);
2617
+ const counts = yield* wrapCurrentKeyToAllDevices(yield* (yield* VaultAccess).getOrPrompt);
2618
+ yield* out.info(`Sync complete. ${summarize(counts)}`);
2619
+ 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());
2621
+ }));
2622
+ const keyShowCommand = Command.make("show", { quiet: quietOption }, (args) => Effect.gen(function* () {
2623
+ const out = yield* CliOutput;
2624
+ yield* out.setQuiet(args.quiet);
2625
+ const access = yield* VaultAccess;
2626
+ const sodium = yield* Sodium;
2627
+ const vault = yield* access.getOrPrompt;
2628
+ yield* out.info(`Current encryption key (master_key v${vault.masterKeyCurrent.version}):`);
2629
+ yield* out.print(sodium.toB64(vault.masterKeyCurrent.key));
2630
+ yield* out.info("Use this value plus the org API key when configuring library clients.");
2631
+ yield* out.info("If it ever leaks, run `sp org encryption key rotate` to invalidate it.");
2632
+ }));
2633
+ const keyRotateCommand = Command.make("rotate", { quiet: quietOption }, (args) => Effect.gen(function* () {
2634
+ const out = yield* CliOutput;
2635
+ yield* out.setQuiet(args.quiet);
2636
+ const api = yield* Api;
2637
+ const access = yield* VaultAccess;
2638
+ const vaultStore = yield* VaultStore;
2639
+ const sodium = yield* Sodium;
2640
+ const { vault, vaultKey } = yield* access.unlockForRotation;
2641
+ const nextVersion = vault.masterKeyCurrent.version + 1;
2642
+ const nextVault = {
2643
+ adminPublicKey: vault.adminPublicKey,
2644
+ adminPrivateKey: vault.adminPrivateKey,
2645
+ masterKeyCurrent: {
2646
+ version: nextVersion,
2647
+ key: yield* sodium.generateMasterKey
2648
+ },
2649
+ masterKeyHistory: [...vault.masterKeyHistory, vault.masterKeyCurrent]
2650
+ };
2651
+ const cfg = yield* access.fetchConfig.pipe(Effect.flatMap(access.requireEnabled));
2652
+ const newBlob = yield* sodium.encryptVault(nextVault, vaultKey);
2653
+ yield* api.put("vault update", "/v1/org/encryption/vault", {
2654
+ vaultBlobB64: sodium.toB64(newBlob),
2655
+ vaultSaltB64: cfg.vaultSaltB64,
2656
+ kdfParams: cfg.kdfParams
2657
+ });
2658
+ yield* vaultStore.save(nextVault);
2659
+ yield* out.info(`Generated master_key v${nextVersion} and updated the org vault.`);
2660
+ yield* out.info("Wrapping the new key to every onboarded device...");
2661
+ const counts = yield* wrapCurrentKeyToAllDevices(nextVault);
2662
+ yield* out.info(`Rotation complete. ${summarize(counts)}`);
2663
+ yield* out.info("");
2664
+ yield* out.info(`New encryption key (master_key v${nextVersion}):`);
2665
+ yield* out.print(sodium.toB64(nextVault.masterKeyCurrent.key));
2666
+ yield* out.info("Update any library clients with this new value. The previous key remains valid for decrypting historical notifications only.");
2667
+ 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());
2669
+ }));
2670
+ const keyCommand = Command.make("key").pipe(Command.withSubcommands([keyShowCommand, keyRotateCommand]));
2671
+ const encryptionCommand = Command.make("encryption").pipe(Command.withSubcommands([
2672
+ enableCommand,
2673
+ statusCommand,
2674
+ syncCommand,
2675
+ keyCommand
2676
+ ]));
2677
+ //#endregion
2678
+ //#region src/commands/org.ts
2679
+ const optionalString = Schema.optional(Schema.NullOr(Schema.String));
2680
+ const InviteResponse = Schema.Struct({
2681
+ role: Schema.String,
2682
+ expiresAt: Schema.String,
2683
+ seatsUsed: Schema.Number,
2684
+ seatsTotal: Schema.Number
2685
+ });
2686
+ const InviteSummary = Schema.Struct({
2687
+ id: Schema.String,
2688
+ name: Schema.String,
2689
+ email: optionalString,
2690
+ role: Schema.String,
2691
+ expiresAt: Schema.String,
2692
+ createdAt: Schema.String
2693
+ });
2694
+ const MemberSummary = Schema.Struct({
2695
+ id: Schema.String,
2696
+ name: optionalString,
2697
+ email: optionalString,
2698
+ createdAt: Schema.String
2699
+ });
2700
+ const MembersResponse = Schema.Struct({ members: Schema.Array(MemberSummary) });
2701
+ const InvitesResponse = Schema.Struct({ invites: Schema.Array(InviteSummary) });
2702
+ const ApiKeyInfoResponse = Schema.Struct({
2703
+ prefix: Schema.String,
2704
+ createdAt: Schema.String,
2705
+ lastRotatedAt: optionalString
2706
+ });
2707
+ const RotateApiKeyResponse = Schema.Struct({
2708
+ apiKey: Schema.String,
2709
+ prefix: Schema.String,
2710
+ createdAt: Schema.String,
2711
+ lastRotatedAt: optionalString
2712
+ });
2713
+ const OrgTopicSummary = Schema.Struct({
2714
+ id: Schema.String,
2715
+ value: Schema.String,
2716
+ createdAt: Schema.String
2717
+ });
2718
+ const ListOrgTopicsResponse = Schema.Struct({ topics: Schema.Array(OrgTopicSummary) });
2719
+ const ListOrgTopicMembersResponse = Schema.Struct({ members: Schema.Array(Schema.Struct({
2720
+ id: Schema.String,
2721
+ name: optionalString,
2722
+ email: optionalString
2723
+ })) });
2724
+ const nameArg = Args.text({ name: "name" }).pipe(Args.withDescription("Display name of the person being invited (used to address them)."));
2725
+ const idArg = Args.text({ name: "id" }).pipe(Args.withDescription("Resource id (UUID) — copy from the matching `list` output."));
2726
+ const memberNameArg = Args.text({ name: "name" }).pipe(Args.withDescription("Display name of the member to remove (case-insensitive). Per-org names are unique."));
2727
+ const roleOption = Options.choice("role", ["member", "admin"]).pipe(Options.withDescription("Role for the invitee. Members consume a seat; admins do not."), Options.withDefault("member"));
2728
+ const emailOption = Options.text("email").pipe(Options.withDescription("Optional contact email. Saved on the invite and propagated to user.email on redemption. Not used for sending — just a label."), Options.optional);
2729
+ const inviteCommand = Command.make("invite", {
2730
+ name: nameArg,
2731
+ role: roleOption,
2732
+ email: emailOption,
2733
+ quiet: quietOption
2734
+ }, (args) => Effect.gen(function* () {
2735
+ const out = yield* CliOutput;
2736
+ yield* out.setQuiet(args.quiet);
2737
+ const api = yield* Api;
2738
+ const invites = yield* InviteStore;
2739
+ const sodium = yield* Sodium;
2740
+ const email = Option.getOrUndefined(args.email);
2741
+ const code = yield* sodium.generateInviteCode;
2742
+ const codeHash = hashInviteCode(code);
2743
+ const body = {
2744
+ name: args.name,
2745
+ role: args.role,
2746
+ codeHash
2747
+ };
2748
+ if (email !== void 0) body.email = email;
2749
+ const payload = yield* api.postJson("invite", "/v1/org/members/invites", InviteResponse, body);
2750
+ yield* invites.append({
2751
+ code,
2752
+ name: args.name,
2753
+ role: args.role,
2754
+ issuedAt: (/* @__PURE__ */ new Date()).toISOString(),
2755
+ expiresAt: payload.expiresAt
2756
+ }).pipe(Effect.catchAll((err) => out.error(`(warning) failed to persist invite locally for encryption sync: ${err instanceof Error ? err.message : String(err)}`)));
2757
+ yield* out.info(`Invite created for ${args.name}${email ? ` <${email}>` : ""} (role: ${payload.role}).`);
2758
+ yield* out.info(`Expires: ${formatInstant(payload.expiresAt)}`);
2759
+ if (payload.role === "member") yield* out.info(`Seats used: ${payload.seatsUsed} / ${payload.seatsTotal}`);
2760
+ yield* out.print(`Login code: ${code}`);
2761
+ }));
2762
+ const membersListCommand = Command.make("list", { quiet: quietOption }, (args) => Effect.gen(function* () {
2763
+ const out = yield* CliOutput;
2764
+ yield* out.setQuiet(args.quiet);
2765
+ const { members } = yield* (yield* Api).getJson("list", "/v1/org/members", MembersResponse);
2766
+ if (members.length === 0) return yield* out.info("no active members");
2767
+ yield* Effect.forEach(members, (m) => out.print(`${m.name ?? "(unnamed)"}\t${m.email ?? "-"}\t${formatInstant(m.createdAt)}`));
2768
+ }));
2769
+ const yesOption = Options.boolean("yes").pipe(Options.withAlias("y"), Options.withDescription("Confirm the removal. Without this flag the command only prints a warning and exits."));
2770
+ const resolveMemberByName = (name) => Effect.gen(function* () {
2771
+ const { members } = yield* (yield* Api).getJson("resolve member", "/v1/org/members", MembersResponse);
2772
+ const target = name.trim().toLowerCase();
2773
+ const match = members.find((m) => (m.name ?? "").toLowerCase() === target);
2774
+ if (!match) return yield* Effect.fail(new UserError({ message: `no member named '${name}' — run \`simplepush org members list\` to see members` }));
2775
+ return match;
2776
+ });
2777
+ const membersRemoveCommand = Command.make("remove", {
2778
+ name: memberNameArg,
2779
+ yes: yesOption,
2780
+ quiet: quietOption
2781
+ }, (args) => Effect.gen(function* () {
2782
+ const out = yield* CliOutput;
2783
+ yield* out.setQuiet(args.quiet);
2784
+ const api = yield* Api;
2785
+ const match = yield* resolveMemberByName(args.name).pipe(Effect.mapError((e) => e._tag === "UserError" ? new UserError({ message: `no member named '${args.name}' in this org` }) : e));
2786
+ yield* out.info(`Removing '${match.name ?? args.name}' permanently deletes all of their associated data. Only event history is retained.`);
2787
+ if (!args.yes) {
2788
+ yield* out.info("Re-run with --yes (or -y) to confirm.");
2789
+ return yield* Effect.fail(new Aborted());
2790
+ }
2791
+ yield* api.delete("remove", `/v1/org/members/${encodeURIComponent(match.id)}`);
2792
+ yield* out.info(`removed member ${match.name ?? args.name}`);
2793
+ }));
2794
+ const invitesListCommand = Command.make("list", { quiet: quietOption }, (args) => Effect.gen(function* () {
2795
+ const out = yield* CliOutput;
2796
+ yield* out.setQuiet(args.quiet);
2797
+ const { invites } = yield* (yield* Api).getJson("list", "/v1/org/members/invites", InvitesResponse);
2798
+ if (invites.length === 0) return yield* out.info("no pending invites");
2799
+ yield* Effect.forEach(invites, (inv) => out.print(`${inv.id}\t${inv.name}\t${inv.email ?? "-"}\t${inv.role}\texpires ${formatInstant(inv.expiresAt)}`));
2800
+ }));
2801
+ const invitesRevokeCommand = Command.make("revoke", {
2802
+ id: idArg,
2803
+ quiet: quietOption
2804
+ }, (args) => Effect.gen(function* () {
2805
+ const out = yield* CliOutput;
2806
+ yield* out.setQuiet(args.quiet);
2807
+ yield* (yield* Api).delete("revoke", `/v1/org/members/invites/${encodeURIComponent(args.id)}`);
2808
+ yield* out.info(`revoked invite ${args.id}`);
2809
+ }));
2810
+ const membersCommand = Command.make("members").pipe(Command.withSubcommands([
2811
+ inviteCommand,
2812
+ membersListCommand,
2813
+ membersRemoveCommand
2814
+ ]));
2815
+ const invitesCommand = Command.make("invites").pipe(Command.withSubcommands([invitesListCommand, invitesRevokeCommand]));
2816
+ const apiKeyInfoCommand = Command.make("info", { quiet: quietOption }, (args) => Effect.gen(function* () {
2817
+ const out = yield* CliOutput;
2818
+ yield* out.setQuiet(args.quiet);
2819
+ const payload = yield* (yield* Api).getJson("info", "/v1/org/api-key", ApiKeyInfoResponse);
2820
+ yield* out.print(`Prefix: ${payload.prefix}…`);
2821
+ yield* out.print(`Created: ${formatInstant(payload.createdAt)}`);
2822
+ yield* out.print(`Last rotated: ${payload.lastRotatedAt ? formatInstant(payload.lastRotatedAt) : "never"}`);
2823
+ yield* out.info("(plaintext is unrecoverable; run `api-key rotate` to surface a new key)");
2824
+ }));
2825
+ const apiKeyRotateCommand = Command.make("rotate", { quiet: quietOption }, (args) => Effect.gen(function* () {
2826
+ const out = yield* CliOutput;
2827
+ yield* out.setQuiet(args.quiet);
2828
+ const payload = yield* (yield* Api).postJson("rotate", "/v1/org/api-key/rotate", RotateApiKeyResponse);
2829
+ yield* out.info("The previous key (if any) is now invalid.");
2830
+ yield* out.print("\nOrganization API key (shown once, copy now):");
2831
+ yield* out.print(` ${payload.apiKey}`);
2832
+ }));
2833
+ const apiKeyCommand = Command.make("api-key").pipe(Command.withSubcommands([apiKeyInfoCommand, apiKeyRotateCommand]));
2834
+ const topicValueArg = Args.text({ name: "value" }).pipe(Args.withDescription("Topic value (no whitespace, ≤ 255 chars). Case-insensitive uniqueness within the org."));
2835
+ const resolveOrgTopicIdByValue = (value) => Effect.gen(function* () {
2836
+ const { topics } = yield* (yield* Api).getJson("list org topics", "/v1/org/topics", ListOrgTopicsResponse);
2837
+ const target = value.trim().toLowerCase();
2838
+ const match = topics.find((t) => t.value.toLowerCase() === target);
2839
+ if (!match) return yield* Effect.fail(new UserError({ message: `no org topic '${value}' — run \`simplepush org topics list\` to see available topics` }));
2840
+ return match.id;
2841
+ });
2842
+ const topicsCreateCommand = Command.make("create", {
2843
+ value: topicValueArg,
2844
+ quiet: quietOption
2845
+ }, (args) => Effect.gen(function* () {
2846
+ const out = yield* CliOutput;
2847
+ yield* out.setQuiet(args.quiet);
2848
+ const payload = yield* (yield* Api).postJson("create", "/v1/org/topics", OrgTopicSummary, { value: args.value });
2849
+ yield* out.info(`Created org topic '${payload.value}'.`);
2850
+ }));
2851
+ const topicsListCommand = Command.make("list", { quiet: quietOption }, (args) => Effect.gen(function* () {
2852
+ const out = yield* CliOutput;
2853
+ yield* out.setQuiet(args.quiet);
2854
+ const { topics } = yield* (yield* Api).getJson("list", "/v1/org/topics", ListOrgTopicsResponse);
2855
+ if (topics.length === 0) return yield* out.info("no org topics");
2856
+ yield* Effect.forEach(topics, (t) => out.print(`${t.value}\tcreated ${formatInstant(t.createdAt)}`));
2857
+ }));
2858
+ const topicsDeleteCommand = Command.make("delete", {
2859
+ value: topicValueArg,
2860
+ quiet: quietOption
2861
+ }, (args) => Effect.gen(function* () {
2862
+ const out = yield* CliOutput;
2863
+ yield* out.setQuiet(args.quiet);
2864
+ const api = yield* Api;
2865
+ const orgTopicId = yield* resolveOrgTopicIdByValue(args.value);
2866
+ yield* api.delete("delete", `/v1/org/topics/${encodeURIComponent(orgTopicId)}`);
2867
+ yield* out.info(`Deleted org topic '${args.value}'.`);
2868
+ }));
2869
+ const topicValueAssignArg = Args.text({ name: "topic" }).pipe(Args.withDescription("Org topic value (from `topics list`)."));
2870
+ const memberNameAssignArg = Args.text({ name: "member" }).pipe(Args.withDescription("Member display name (case-insensitive, from `members list`)."));
2871
+ const topicsAssignCommand = Command.make("assign", {
2872
+ topic: topicValueAssignArg,
2873
+ member: memberNameAssignArg,
2874
+ quiet: quietOption
2875
+ }, (args) => Effect.gen(function* () {
2876
+ const out = yield* CliOutput;
2877
+ yield* out.setQuiet(args.quiet);
2878
+ const api = yield* Api;
2879
+ const orgTopicId = yield* resolveOrgTopicIdByValue(args.topic);
2880
+ const member = yield* resolveMemberByName(args.member);
2881
+ yield* api.put("assign", `/v1/org/topics/${encodeURIComponent(orgTopicId)}/members/${encodeURIComponent(member.id)}`);
2882
+ yield* out.info(`Assigned ${args.member} to org topic '${args.topic}'.`);
2883
+ }));
2884
+ const topicsUnassignCommand = Command.make("unassign", {
2885
+ topic: topicValueAssignArg,
2886
+ member: memberNameAssignArg,
2887
+ quiet: quietOption
2888
+ }, (args) => Effect.gen(function* () {
2889
+ const out = yield* CliOutput;
2890
+ yield* out.setQuiet(args.quiet);
2891
+ const api = yield* Api;
2892
+ const orgTopicId = yield* resolveOrgTopicIdByValue(args.topic);
2893
+ const member = yield* resolveMemberByName(args.member);
2894
+ yield* api.delete("unassign", `/v1/org/topics/${encodeURIComponent(orgTopicId)}/members/${encodeURIComponent(member.id)}`);
2895
+ yield* out.info(`Unassigned ${args.member} from org topic '${args.topic}'.`);
2896
+ }));
2897
+ const topicsMembersCommand = Command.make("members", {
2898
+ topic: topicValueAssignArg,
2899
+ quiet: quietOption
2900
+ }, (args) => Effect.gen(function* () {
2901
+ const out = yield* CliOutput;
2902
+ yield* out.setQuiet(args.quiet);
2903
+ const api = yield* Api;
2904
+ const orgTopicId = yield* resolveOrgTopicIdByValue(args.topic);
2905
+ const { members } = yield* api.getJson("members", `/v1/org/topics/${encodeURIComponent(orgTopicId)}/members`, ListOrgTopicMembersResponse);
2906
+ if (members.length === 0) return yield* out.info("no members assigned");
2907
+ yield* Effect.forEach(members, (m) => out.print(`${m.name ?? "(unnamed)"}\t${m.email ?? "-"}`));
2908
+ }));
2909
+ const topicsCommand = Command.make("topics").pipe(Command.withSubcommands([
2910
+ topicsCreateCommand,
2911
+ topicsListCommand,
2912
+ topicsDeleteCommand,
2913
+ topicsAssignCommand,
2914
+ topicsUnassignCommand,
2915
+ topicsMembersCommand
2916
+ ]));
2917
+ const orgCommand = Command.make("org").pipe(Command.withSubcommands([
2918
+ membersCommand,
2919
+ invitesCommand,
2920
+ apiKeyCommand,
2921
+ topicsCommand,
2922
+ encryptionCommand
2923
+ ]));
2924
+ //#endregion
2925
+ //#region src/files.ts
2926
+ /** Read each `--file` path into a `FileAttachment` the SDK can upload: bytes +
2927
+ * basename + a best-effort content type guessed from the extension (the SDK
2928
+ * defaults to application/octet-stream when undefined). */
2929
+ const buildFiles = (paths) => Effect.gen(function* () {
2930
+ const fs = yield* FileSystem.FileSystem;
2931
+ return yield* Effect.forEach(paths, (p) => Effect.map(fs.readFile(p), (data) => {
2932
+ const contentType = guessContentType(p);
2933
+ return {
2934
+ filename: basename(p),
2935
+ data,
2936
+ ...contentType !== void 0 ? { contentType } : {}
2937
+ };
2938
+ }));
2939
+ });
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
+ const CONTENT_TYPES = {
2956
+ png: "image/png",
2957
+ jpg: "image/jpeg",
2958
+ jpeg: "image/jpeg",
2959
+ gif: "image/gif",
2960
+ webp: "image/webp",
2961
+ heic: "image/heic",
2962
+ svg: "image/svg+xml",
2963
+ pdf: "application/pdf",
2964
+ txt: "text/plain",
2965
+ json: "application/json",
2966
+ csv: "text/csv",
2967
+ zip: "application/zip",
2968
+ mp4: "video/mp4",
2969
+ mov: "video/quicktime",
2970
+ mp3: "audio/mpeg",
2971
+ m4a: "audio/mp4",
2972
+ wav: "audio/wav"
2973
+ };
2974
+ function guessContentType(path) {
2975
+ return CONTENT_TYPES[extname(path).slice(1).toLowerCase()];
2976
+ }
2977
+ //#endregion
2978
+ //#region src/commands/task.ts
2979
+ const titleOption$1 = Options.text("title").pipe(Options.withDescription("Task title."), Options.optional);
2980
+ const contentOption$1 = Options.text("content").pipe(Options.withDescription("Task description / body content."), Options.optional);
2981
+ const tagOption = Options.text("tag").pipe(Options.withDescription("Tag the task for receiver-side filtering. Defaults to $SP_TAG."), Options.optional);
2982
+ const repeatedText = (name, alias, description) => {
2983
+ const base = Options.text(name).pipe(Options.withDescription(description), Options.repeated);
2984
+ return alias ? Options.withAlias(alias)(base) : base;
2985
+ };
2986
+ const textInput$1 = repeatedText("text-input", void 0, "Add a text input. Format: `description[;key=value...]`. Settings: `required=true|false` (default true), `defaultValue=...`. Repeatable.");
2987
+ const choiceInput$1 = repeatedText("choice-input", "c", "Add a choice input. Format: `[description;]options[;key=value...]`, options comma-separated. Settings: `required=true|false`, `multi=true|false` (allow picking more than one option, default false), `minSelections=<int>`/`maxSelections=<int>` (only with multi). Use `\\;` for a literal semicolon. Repeatable.");
2988
+ const actionInput$1 = repeatedText("action-input", "a", "Add an actions input (buttons the recipient taps, e.g. Accept/Deny). Format: `[description;]key=Label[:style],...[;required=true|false]`, actions comma-separated; style is default|primary|destructive. Use `\\,` for a literal comma in a label. Repeatable.");
2989
+ const sliderInput$1 = repeatedText("slider-input", "s", "Add a slider input (the recipient picks a number on a scale). Format: `[description;]min=0;max=14;step=0.1;unit=pH;default=7`. `min`/`max` are required; `step`/`unit`/`default` optional. Repeatable.");
2990
+ const photoInput$1 = repeatedText("photo-input", void 0, "Add a photo input. Format: `description[;key=value...]`. Settings: `required=true|false`. Repeatable.");
2991
+ const voiceRecordingInput$1 = repeatedText("voice-recording-input", void 0, "Add a voice recording input. Format: `description[;key=value...]`. Settings: `required=true|false`. Repeatable.");
2992
+ const fileInput$1 = repeatedText("file-input", void 0, "Add a file upload input. Format: `description[;key=value...]`. Settings: `required=true|false`. Repeatable.");
2993
+ const locationInput$1 = repeatedText("location-input", void 0, "Add a location input (the recipient shares their device GPS position from the app). Format: `description[;key=value...]`. Settings: `required=true|false`. Repeatable.");
2994
+ const linkOption$1 = repeatedText("link", "l", "Attach a remote URL (a link attachment). Repeatable. For local files use --file.");
2995
+ 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
+ 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", [
2999
+ "one-shot",
3000
+ "sticky",
3001
+ "one-time-per-user"
3002
+ ]).pipe(Options.withDescription("Show a reply composer on the recipient's task: 'one-shot' (first reply wins, closes the slot), 'sticky' (open indefinitely), 'one-time-per-user' (one reply per user)."), Options.optional);
3003
+ const memberOption = Options.text("member").pipe(Options.withAlias("m"), Options.withDescription("Send to a single org member by display name (case-insensitive). Org send; mutually exclusive with --broadcast, --org-topic, and -k/--topic."), Options.optional);
3004
+ const broadcastOption = Options.boolean("broadcast").pipe(Options.withAlias("b"), Options.withDescription("Send to every member of the org. Org send; mutually exclusive with --member, --org-topic, and -k/--topic."));
3005
+ 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
+ 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
+ const markdownOption$1 = Options.boolean("markdown").pipe(Options.withDescription("Render the task body as Markdown on the recipient's device (sets contentFormat=markdown)."));
3008
+ 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"));
3010
+ const taskCommand = Command.make("task", {
3011
+ title: titleOption$1,
3012
+ content: contentOption$1,
3013
+ tag: tagOption,
3014
+ "text-input": textInput$1,
3015
+ "choice-input": choiceInput$1,
3016
+ "action-input": actionInput$1,
3017
+ "slider-input": sliderInput$1,
3018
+ "photo-input": photoInput$1,
3019
+ "voice-recording-input": voiceRecordingInput$1,
3020
+ "file-input": fileInput$1,
3021
+ "location-input": locationInput$1,
3022
+ link: linkOption$1,
3023
+ file: fileOption$1,
3024
+ submit: submitOption$1,
3025
+ wait: waitOption,
3026
+ reply: replyOption,
3027
+ member: memberOption,
3028
+ broadcast: broadcastOption,
3029
+ "org-topic": orgTopicOption,
3030
+ "no-encrypt": noEncryptOption$1,
3031
+ markdown: markdownOption$1,
3032
+ shared: sharedOption,
3033
+ format: formatOption,
3034
+ topic: topicOption,
3035
+ "api-token": apiTokenOption,
3036
+ password: passwordOption,
3037
+ "base-url": baseUrlOption,
3038
+ quiet: quietOption
3039
+ }, (args) => Effect.gen(function* () {
3040
+ const out = yield* CliOutput;
3041
+ yield* out.setQuiet(args.quiet);
3042
+ const memberName = Option.getOrUndefined(args.member);
3043
+ const orgTopicName = Option.getOrUndefined(args["org-topic"]);
3044
+ const topic = args.topic[0];
3045
+ if ([
3046
+ memberName !== void 0,
3047
+ args.broadcast,
3048
+ orgTopicName !== void 0,
3049
+ topic !== void 0
3050
+ ].filter(Boolean).length > 1) return yield* Effect.fail(new UserError({ message: "pass at most one target: -m <member> | -b (broadcast) | --org-topic <value> | -t <topic> (omit all for a self-send)" }));
3051
+ const inputs = yield* Effect.try({
3052
+ try: () => buildInputs(args),
3053
+ catch: (e) => new UserError({ message: e instanceof Error ? e.message : String(e) })
3054
+ });
3055
+ if (inputs.length === 0 && args.wait) yield* out.warn("--wait requested but no inputs were defined; the server will never produce a TaskCompleted event");
3056
+ const tag = Option.getOrElse(args.tag, () => process.env.SP_TAG ?? "");
3057
+ const title = Option.getOrUndefined(args.title);
3058
+ const content = Option.getOrUndefined(args.content);
3059
+ if (memberName !== void 0 || args.broadcast || orgTopicName !== void 0) return yield* sendOrgTask({
3060
+ member: memberName,
3061
+ broadcast: args.broadcast,
3062
+ orgTopic: orgTopicName,
3063
+ tag,
3064
+ title,
3065
+ content,
3066
+ inputs,
3067
+ links: [...args.link],
3068
+ files: [...args.file],
3069
+ autoCommit: !args.submit,
3070
+ reply: Option.getOrUndefined(args.reply),
3071
+ markdown: args.markdown,
3072
+ noEncrypt: args["no-encrypt"],
3073
+ wait: args.wait,
3074
+ shared: args.shared,
3075
+ format: args.format
3076
+ });
3077
+ const passwords = args.password;
3078
+ const encrypting = willEncrypt(passwords, topic);
3079
+ const files = yield* buildFiles(args.file);
3080
+ const apiToken = yield* requireApiToken(args["api-token"]);
3081
+ yield* Effect.scoped(Effect.gen(function* () {
3082
+ const client = yield* acquireClient({
3083
+ baseUrl: args["base-url"],
3084
+ apiToken,
3085
+ passwords: [...passwords]
3086
+ });
3087
+ if (encrypting) yield* out.info("encrypting outgoing task (Argon2id, this takes a moment)");
3088
+ const baseOpts = {
3089
+ ...tag ? { tag } : {},
3090
+ ...title !== void 0 ? { title } : {},
3091
+ ...content !== void 0 ? { content } : {},
3092
+ inputs,
3093
+ links: [...args.link],
3094
+ ...files.length > 0 ? { files } : {},
3095
+ autoCommit: !args.submit,
3096
+ ...Option.isSome(args.reply) ? { reply: args.reply.value } : {},
3097
+ ...args.markdown ? { contentFormat: "markdown" } : {}
3098
+ };
3099
+ if (topic === void 0) {
3100
+ const response = yield* sdkCall("task send", () => client.sendTask(baseOpts));
3101
+ yield* out.info(`self-send task created: ${response.taskId}`);
3102
+ yield* out.info(`append token: ${response.appendToken}`);
3103
+ if (!args.wait) {
3104
+ if (args.format === "json") yield* out.print(formatSent(void 0, response.createdAt, [{
3105
+ id: response.taskId,
3106
+ kind: "task",
3107
+ recipient: null
3108
+ }]));
3109
+ else yield* out.print(response.taskId);
3110
+ return;
3111
+ }
3112
+ yield* out.info(`waiting for completion of task ${response.taskId}`);
3113
+ return yield* waitForFirstCompletion([response]);
3114
+ }
3115
+ const sendOpts = {
3116
+ ...baseOpts,
3117
+ topic
3118
+ };
3119
+ if (args.shared) {
3120
+ const response = yield* sdkCall("task send", () => client.sendTask({
3121
+ ...sendOpts,
3122
+ shared: true
3123
+ }));
3124
+ yield* out.info(`task created: ${response.taskId}`);
3125
+ yield* out.info(`append token: ${response.appendToken}`);
3126
+ if (!args.wait) {
3127
+ if (args.format === "json") yield* out.print(formatSent(void 0, response.createdAt, [{
3128
+ id: response.taskId,
3129
+ kind: "task",
3130
+ recipient: null
3131
+ }]));
3132
+ else yield* out.print(response.taskId);
3133
+ return;
3134
+ }
3135
+ yield* out.info(`waiting for completion of task ${response.taskId}`);
3136
+ return yield* waitForFirstCompletion([response]);
3137
+ }
3138
+ const group = yield* sdkCall("task send", () => client.sendTask(sendOpts));
3139
+ yield* out.info(`task group created: ${group.groupId} (${group.instances.length} recipient${group.instances.length === 1 ? "" : "s"})`);
3140
+ yield* out.info(`group append token: ${group.appendToken}`);
3141
+ yield* Effect.forEach(group.instances, (inst) => {
3142
+ const who = inst.recipient ? `${inst.recipient.publicId}${inst.recipient.name ? ` (${inst.recipient.name})` : ""}` : "unknown";
3143
+ return out.info(`instance: ${inst.taskId} -> ${who} append token: ${inst.appendToken}`);
3144
+ });
3145
+ if (group.instances.length === 0) yield* out.warn("the topic has no recipients — the group is empty");
3146
+ if (!args.wait) {
3147
+ if (args.format === "json") yield* out.print(formatSent(group.groupId, group.createdAt, group.instances.map((i) => ({
3148
+ id: i.taskId,
3149
+ kind: "task",
3150
+ recipient: i.recipient ? {
3151
+ publicId: i.recipient.publicId,
3152
+ name: i.recipient.name ?? null
3153
+ } : null
3154
+ }))));
3155
+ else yield* out.print(group.groupId);
3156
+ return;
3157
+ }
3158
+ if (group.instances.length === 0) {
3159
+ yield* out.warn("--wait requested but the group has no instances; nothing will complete");
3160
+ return yield* Effect.fail(new Aborted());
3161
+ }
3162
+ yield* out.info(`waiting for the first completion across ${group.instances.length} instance(s) of ${group.groupId}`);
3163
+ yield* waitForFirstCompletion(group.instances);
3164
+ }));
3165
+ }));
3166
+ /** Merge every instance's input stream and take the FIRST completed answer
3167
+ * (mirrors the backend's curl Wait semantics: one answer from any recipient).
3168
+ * Per instance, a `taskDeleted` benignly ends that sub-stream. No completion
3169
+ * anywhere is a FAILURE (exit 1, empty stdout); a real stream error (auth,
3170
+ * transport) fails the merge and is surfaced as such. */
3171
+ const waitForFirstCompletion = (tasks) => Effect.gen(function* () {
3172
+ const out = yield* CliOutput;
3173
+ const completions = tasks.map((t) => sdkStream("task wait stream", (signal) => t.inputs({
3174
+ replay: true,
3175
+ signal
3176
+ })).pipe(Stream.takeWhile((ev) => ev.kind !== "taskDeleted"), Stream.filter((ev) => ev.kind === "taskCompleted"), Stream.take(1)));
3177
+ const first = yield* Stream.mergeAll(completions, { concurrency: "unbounded" }).pipe(Stream.runHead, Effect.mapError((e) => {
3178
+ return new UserError({ message: `stream failed while waiting: ${e.cause instanceof Error ? e.cause.message : String(e.cause)}` });
3179
+ }));
3180
+ if (Option.isNone(first)) {
3181
+ yield* out.warn("every instance ended (deleted or stream closed) before a completion");
3182
+ return yield* Effect.fail(new Aborted());
3183
+ }
3184
+ const ev = first.value;
3185
+ const uploads = ev.kind === "taskCompleted" ? ev.uploads : [];
3186
+ const single = uploads.length === 1 ? uploads[0] : void 0;
3187
+ 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
+ });
3190
+ /** 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). */
3196
+ const sendOrgTask = (params) => Effect.gen(function* () {
3197
+ const out = yield* CliOutput;
3198
+ const api = yield* Api;
3199
+ const access = yield* VaultAccess;
3200
+ 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
+ const vault = yield* access.forSendOrPlaintext(params.noEncrypt);
3203
+ const target = params.orgTopic !== void 0 ? { topic: params.orgTopic } : params.member !== void 0 ? { member: params.member } : { broadcast: true };
3204
+ const opts = {
3205
+ ...params.tag ? { tag: params.tag } : {},
3206
+ ...params.title !== void 0 ? { title: params.title } : {},
3207
+ ...params.content !== void 0 ? { content: params.content } : {},
3208
+ inputs: params.inputs,
3209
+ links: params.links,
3210
+ autoCommit: params.autoCommit,
3211
+ ...params.reply !== void 0 ? { reply: params.reply } : {},
3212
+ ...params.markdown ? { contentFormat: "markdown" } : {},
3213
+ ...params.shared ? { shared: true } : {}
3214
+ };
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);
3224
+ const enc = vault ? ` (encrypted under master_key v${vault.masterKeyCurrent.version})` : " (plaintext)";
3225
+ if (isTaskGroupResponse(payload)) {
3226
+ 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})` : ""}`;
3231
+ return out.info(`Instance: ${inst.taskId} -> ${who} append token: ${inst.appendToken}`);
3232
+ });
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
+ }
3254
+ });
3255
+ //#endregion
3256
+ //#region src/commands/subtask.ts
3257
+ const appendTokenOption = Options.text("append-token").pipe(Options.withDescription("The parent task's append token (the `appendToken` printed by `sp task`)."));
3258
+ const titleOption = Options.text("title").pipe(Options.withDescription("Subtask title."), Options.optional);
3259
+ const contentOption = Options.text("content").pipe(Options.withDescription("Subtask description / body content."), Options.optional);
3260
+ const linkOption = Options.text("link").pipe(Options.withAlias("l"), Options.withDescription("Attach a remote URL (a link attachment). Repeatable. For local files use --file."), Options.repeated);
3261
+ const fileOption = Options.text("file").pipe(Options.withAlias("f"), Options.withDescription("Attach a local file, uploaded as a file attachment (encrypted under the parent chain's key — topic password or org master key — when the chain is encrypted). Repeatable."), Options.repeated);
3262
+ const textInput = Options.text("text-input").pipe(Options.withDescription("Add a text input. Format: `description[;key=value...]`. Settings: `required=true|false` (default true), `defaultValue=...`. Repeatable."), Options.repeated);
3263
+ const actionInput = Options.text("action-input").pipe(Options.withAlias("a"), Options.withDescription("Add an actions input (buttons the recipient taps, e.g. Accept/Deny). Format: `[description;]key=Label[:style],...[;required=true|false]`, actions comma-separated; style is default|primary|destructive. Repeatable."), Options.repeated);
3264
+ const choiceInput = Options.text("choice-input").pipe(Options.withAlias("c"), Options.withDescription("Add a choice input. Format: `[description;]options[;key=value...]`, options comma-separated. Settings: `required=true|false`, `multi=true|false` (allow picking more than one option, default false), `minSelections=<int>`/`maxSelections=<int>` (only with multi). Use `\\;` for a literal semicolon. Repeatable."), Options.repeated);
3265
+ const sliderInput = Options.text("slider-input").pipe(Options.withAlias("s"), Options.withDescription("Add a slider input (the recipient picks a number on a scale). Format: `[description;]min=0;max=14;step=0.1;unit=pH;default=7`. `min`/`max` required; `step`/`unit`/`default` optional. Repeatable."), Options.repeated);
3266
+ const photoInput = Options.text("photo-input").pipe(Options.withDescription("Add a photo input. Format: `description[;key=value...]`. Settings: `required=true|false`. Repeatable."), Options.repeated);
3267
+ const voiceRecordingInput = Options.text("voice-recording-input").pipe(Options.withDescription("Add a voice recording input. Format: `description[;key=value...]`. Settings: `required=true|false`. Repeatable."), Options.repeated);
3268
+ 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
+ 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
+ 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."));
3271
+ const markdownOption = Options.boolean("markdown").pipe(Options.withDescription("Render the subtask body as Markdown on the recipient's device (sets contentFormat=markdown)."));
3272
+ const noEncryptOption = Options.boolean("no-encrypt").pipe(Options.withDescription("For org appends: send fields in plaintext even when the org vault is unlocked."));
3273
+ 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);
3274
+ const subtaskCommand = Command.make("subtask", {
3275
+ "append-token": appendTokenOption,
3276
+ title: titleOption,
3277
+ content: contentOption,
3278
+ "text-input": textInput,
3279
+ "choice-input": choiceInput,
3280
+ "action-input": actionInput,
3281
+ "slider-input": sliderInput,
3282
+ "photo-input": photoInput,
3283
+ "voice-recording-input": voiceRecordingInput,
3284
+ "file-input": fileInput,
3285
+ "location-input": locationInput,
3286
+ link: linkOption,
3287
+ file: fileOption,
3288
+ submit: submitOption,
3289
+ markdown: markdownOption,
3290
+ "no-encrypt": noEncryptOption,
3291
+ instance: instanceOption,
3292
+ topic: topicOption,
3293
+ "api-token": apiTokenOption,
3294
+ password: passwordOption,
3295
+ "base-url": baseUrlOption,
3296
+ quiet: quietOption
3297
+ }, (args) => Effect.gen(function* () {
3298
+ const out = yield* CliOutput;
3299
+ yield* out.setQuiet(args.quiet);
3300
+ const appendToken = args["append-token"];
3301
+ const title = Option.getOrUndefined(args.title);
3302
+ const content = Option.getOrUndefined(args.content);
3303
+ const topic = args.topic[0];
3304
+ const inputs = yield* Effect.try({
3305
+ try: () => buildInputs(args),
3306
+ catch: (e) => new UserError({ message: e instanceof Error ? e.message : String(e) })
3307
+ });
3308
+ if (content === void 0 && inputs.length === 0) return yield* Effect.fail(new UserError({ message: "a subtask needs --content or at least one input" }));
3309
+ const opts = {
3310
+ ...title !== void 0 ? { title } : {},
3311
+ ...content !== void 0 ? { content } : {},
3312
+ ...inputs.length > 0 ? { inputs } : {},
3313
+ links: [...args.link],
3314
+ autoCommit: !args.submit,
3315
+ ...args.markdown ? { contentFormat: "markdown" } : {}
3316
+ };
3317
+ const instances = args.instance.length > 0 ? [...args.instance] : void 0;
3318
+ if (topic !== void 0) {
3319
+ const files = yield* buildFiles(args.file);
3320
+ const apiToken = yield* requireApiToken(args["api-token"]);
3321
+ yield* Effect.scoped(Effect.gen(function* () {
3322
+ const client = yield* acquireClient({
3323
+ baseUrl: args["base-url"],
3324
+ apiToken,
3325
+ passwords: args.password
3326
+ });
3327
+ 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({
3329
+ appendToken,
3330
+ topic,
3331
+ ...instances !== void 0 ? { instances } : {},
3332
+ ...opts,
3333
+ ...files.length > 0 ? { files } : {}
3334
+ })));
3335
+ }));
3336
+ return;
3337
+ }
3338
+ yield* sendOrgSubtask({
3339
+ appendToken,
3340
+ opts,
3341
+ instances,
3342
+ files: [...args.file],
3343
+ noEncrypt: args["no-encrypt"]
3344
+ });
3345
+ }));
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* () {
3350
+ const out = yield* CliOutput;
3351
+ if (isSubtaskGroupResponse(resp)) {
3352
+ 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))));
3354
+ } else {
3355
+ yield* out.info(`subtask appended: ${resp.subtaskId}${suffix}`);
3356
+ yield* out.print(resp.subtaskId);
3357
+ }
3358
+ });
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. */
3362
+ const sendOrgSubtask = (params) => Effect.gen(function* () {
3363
+ const api = yield* Api;
3364
+ 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)");
3375
+ });
3376
+ //#endregion
3377
+ //#region src/main.ts
3378
+ const onPipeError = (exit) => (e) => {
3379
+ if (e.code !== "EPIPE") throw e;
3380
+ if (exit) process.exit(0);
3381
+ };
3382
+ process.stdout.on("error", onPipeError(true));
3383
+ process.stderr.on("error", onPipeError(false));
3384
+ const root = Command.make("simplepush").pipe(Command.withSubcommands([
3385
+ authCommand,
3386
+ orgCommand,
3387
+ eventsCommand,
3388
+ collectCommand,
3389
+ daemonCommand,
3390
+ downloadCommand,
3391
+ notifyCommand,
3392
+ taskCommand,
3393
+ subtaskCommand
3394
+ ]));
3395
+ const cli = Command.run(root, {
3396
+ name: "Simplepush CLI",
3397
+ version: "0.1.0"
3398
+ });
3399
+ 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
+ /** Render any failure through the typed-error table, then re-fail so the
3401
+ * runtime exits non-zero. Defects (bugs) keep their full pretty cause. */
3402
+ const reportErrors = (effect) => effect.pipe(Effect.catchAllCause((cause) => Effect.gen(function* () {
3403
+ const out = yield* CliOutput;
3404
+ if (!Cause.isInterruptedOnly(cause)) {
3405
+ const failure = Cause.failureOption(cause);
3406
+ if (Option.isSome(failure)) {
3407
+ const message = renderError(failure.value);
3408
+ if (message !== void 0) yield* out.error(message);
3409
+ } else yield* out.error(Cause.pretty(cause));
3410
+ }
3411
+ return yield* Effect.failCause(cause);
3412
+ })));
3413
+ cli(process.argv).pipe(reportErrors, Effect.provide(MainLive), NodeRuntime.runMain({ disableErrorReporting: true }));
3414
+ //#endregion
3415
+ export {};
3416
+
3417
+ //# sourceMappingURL=main.mjs.map