@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/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/README.md +167 -0
- package/dist/main.mjs +3417 -0
- package/dist/main.mjs.map +1 -0
- package/package.json +64 -0
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
|
+
"&": "&",
|
|
573
|
+
"<": "<",
|
|
574
|
+
">": ">",
|
|
575
|
+
"\"": """,
|
|
576
|
+
"'": "'"
|
|
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
|