@samva/cli 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -2
- package/README.md +8 -5
- package/bin/samva.cjs +57 -0
- package/dist/bin.mjs +4624 -0
- package/package.json +19 -19
- package/bin/samva +0 -3
- package/install.cjs +0 -35
package/dist/bin.mjs
ADDED
|
@@ -0,0 +1,4624 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { BunRuntime, BunServices } from "@effect/platform-bun";
|
|
3
|
+
import * as Console from "effect/Console";
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
5
|
+
import * as Layer from "effect/Layer";
|
|
6
|
+
import * as Logger from "effect/Logger";
|
|
7
|
+
import * as LogLevel from "effect/LogLevel";
|
|
8
|
+
import * as Predicate from "effect/Predicate";
|
|
9
|
+
import * as CliError from "effect/unstable/cli/CliError";
|
|
10
|
+
import * as CliOutput from "effect/unstable/cli/CliOutput";
|
|
11
|
+
import * as Command from "effect/unstable/cli/Command";
|
|
12
|
+
import { identity } from "effect/Function";
|
|
13
|
+
import * as Option from "effect/Option";
|
|
14
|
+
import * as References from "effect/References";
|
|
15
|
+
import * as Argument from "effect/unstable/cli/Argument";
|
|
16
|
+
import * as Flag from "effect/unstable/cli/Flag";
|
|
17
|
+
import * as Campaigns from "samva/effect/campaigns";
|
|
18
|
+
import * as Schema from "effect/Schema";
|
|
19
|
+
import { ConflictError } from "samva/effect";
|
|
20
|
+
import * as Clock from "effect/Clock";
|
|
21
|
+
import * as Client from "samva/effect/client";
|
|
22
|
+
import * as FileSystem from "effect/FileSystem";
|
|
23
|
+
import * as GlobalFlag from "effect/unstable/cli/GlobalFlag";
|
|
24
|
+
import * as Path from "effect/Path";
|
|
25
|
+
import { isatty } from "node:tty";
|
|
26
|
+
import * as Prompt from "effect/unstable/cli/Prompt";
|
|
27
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
28
|
+
import { dirname, join, resolve } from "node:path";
|
|
29
|
+
import { formatDiagnostic } from "@samva/markup/diagnostics";
|
|
30
|
+
import { flattenFontTokens } from "@samva/markup/theme";
|
|
31
|
+
import { parseThemeCss, printThemeCss } from "@samva/markup/theme-css";
|
|
32
|
+
import { Result } from "effect";
|
|
33
|
+
import * as Design from "samva/effect/design";
|
|
34
|
+
import * as Email from "samva/effect/email";
|
|
35
|
+
import * as Organizations from "samva/effect/organizations";
|
|
36
|
+
import * as Duration from "effect/Duration";
|
|
37
|
+
import * as Schedule from "effect/Schedule";
|
|
38
|
+
import * as Messages from "samva/effect/messages";
|
|
39
|
+
import * as Ref from "effect/Ref";
|
|
40
|
+
import * as ScheduledMessages from "samva/effect/scheduled-messages";
|
|
41
|
+
import MarkupPackageJson from "@samva/markup/package.json" with { type: "json" };
|
|
42
|
+
import VitePackageJson from "@samva/vite/package.json" with { type: "json" };
|
|
43
|
+
import * as Templates from "samva/effect/templates";
|
|
44
|
+
import { createHash } from "node:crypto";
|
|
45
|
+
import * as Redacted from "effect/Redacted";
|
|
46
|
+
import * as Webhooks from "samva/effect/webhooks";
|
|
47
|
+
//#region package.json
|
|
48
|
+
var version = "0.1.1";
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/errors.ts
|
|
51
|
+
/**
|
|
52
|
+
* A user-facing CLI error. Its `message` is printed to stderr and the process
|
|
53
|
+
* exits non-zero. Use this for expected failures (missing key, API errors,
|
|
54
|
+
* bad input) — not for programmer bugs, which should surface as defects.
|
|
55
|
+
*/
|
|
56
|
+
var SamvaCliError = class extends Schema.TaggedErrorClass()("SamvaCliError", { message: Schema.String }) {};
|
|
57
|
+
/** Expected process failures remain typed through the CLI boundary. */
|
|
58
|
+
var CliUsageError = class extends Schema.TaggedErrorClass()("CliUsageError", {
|
|
59
|
+
message: Schema.String,
|
|
60
|
+
code: Schema.String
|
|
61
|
+
}) {};
|
|
62
|
+
var CliConfigError = class extends Schema.TaggedErrorClass()("CliConfigError", {
|
|
63
|
+
message: Schema.String,
|
|
64
|
+
code: Schema.String
|
|
65
|
+
}) {};
|
|
66
|
+
var CliAuthError = class extends Schema.TaggedErrorClass()("CliAuthError", {
|
|
67
|
+
message: Schema.String,
|
|
68
|
+
code: Schema.String
|
|
69
|
+
}) {};
|
|
70
|
+
var CliTimeoutError = class extends Schema.TaggedErrorClass()("CliTimeoutError", {
|
|
71
|
+
message: Schema.String,
|
|
72
|
+
code: Schema.String
|
|
73
|
+
}) {};
|
|
74
|
+
/** A completed operation failed, but its structured result remains authoritative. */
|
|
75
|
+
var CliResultError = class extends Schema.TaggedErrorClass()("CliResultError", {
|
|
76
|
+
message: Schema.String,
|
|
77
|
+
code: Schema.String,
|
|
78
|
+
result: Schema.Unknown,
|
|
79
|
+
human: Schema.String
|
|
80
|
+
}) {};
|
|
81
|
+
/**
|
|
82
|
+
* A lifecycle command rendered its last authoritative Message before failing.
|
|
83
|
+
* The process boundary preserves that output by branching on this class
|
|
84
|
+
* (`instanceof`), so no marker field is needed on the error itself.
|
|
85
|
+
*/
|
|
86
|
+
var CliMessageLifecycleError = class extends Schema.TaggedErrorClass()("CliMessageLifecycleError", {
|
|
87
|
+
message: Schema.String,
|
|
88
|
+
code: Schema.String,
|
|
89
|
+
partialMessage: Schema.Unknown,
|
|
90
|
+
next: Schema.String,
|
|
91
|
+
human: Schema.String
|
|
92
|
+
}) {};
|
|
93
|
+
const processErrorCode = (error) => {
|
|
94
|
+
if (CliError.isCliError(error)) return 2;
|
|
95
|
+
if (error instanceof CliUsageError || error instanceof CliConfigError) return 2;
|
|
96
|
+
if (error instanceof CliAuthError) return 4;
|
|
97
|
+
if (error instanceof CliTimeoutError) return 124;
|
|
98
|
+
if (error instanceof CliResultError) return 1;
|
|
99
|
+
if (error instanceof CliMessageLifecycleError) return error.code === "message_wait_timeout" ? 124 : 1;
|
|
100
|
+
return 1;
|
|
101
|
+
};
|
|
102
|
+
const processErrorReason = (error) => {
|
|
103
|
+
if (CliError.isCliError(error)) return "usage_error";
|
|
104
|
+
if (error instanceof CliUsageError) return error.code;
|
|
105
|
+
if (error instanceof CliConfigError) return error.code;
|
|
106
|
+
if (error instanceof CliAuthError) return error.code;
|
|
107
|
+
if (error instanceof CliTimeoutError) return error.code;
|
|
108
|
+
if (error instanceof CliResultError) return error.code;
|
|
109
|
+
if (error instanceof CliMessageLifecycleError) return error.code;
|
|
110
|
+
return "operational_failure";
|
|
111
|
+
};
|
|
112
|
+
const processErrorNext = (error) => error instanceof CliMessageLifecycleError ? error.next : void 0;
|
|
113
|
+
/**
|
|
114
|
+
* Build a friendly one-line message from an SDK error. The Effect SDK's error
|
|
115
|
+
* channel carries the server's real tagged error classes (or `HttpClientError`
|
|
116
|
+
* / `SchemaError`) directly — the failure value itself has the message, not a
|
|
117
|
+
* `.cause` wrapper. Most tagged errors expose `.message`; a few
|
|
118
|
+
* (`FlagDisabledError`, `WebhookEnqueueError`) only expose `.errorMessage`.
|
|
119
|
+
*/
|
|
120
|
+
const formatApiError = (prefix, error) => {
|
|
121
|
+
if (Predicate.isObject(error)) {
|
|
122
|
+
if (Predicate.hasProperty(error, "message") && typeof error["message"] === "string") return `${prefix}: ${error["message"]}`;
|
|
123
|
+
if (Predicate.hasProperty(error, "errorMessage") && typeof error["errorMessage"] === "string") return `${prefix}: ${error["errorMessage"]}`;
|
|
124
|
+
}
|
|
125
|
+
return prefix;
|
|
126
|
+
};
|
|
127
|
+
const getConflictError = (error) => error instanceof ConflictError ? error : void 0;
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region src/env.ts
|
|
130
|
+
/**
|
|
131
|
+
* The CLI's environment module — the single legitimate reader of `process.env`.
|
|
132
|
+
*
|
|
133
|
+
* Cloudflare Workers (apps/api, apps/web) get their env from bindings, so the
|
|
134
|
+
* `node/no-process-env` lint rule steers them to typed env modules. A CLI runs
|
|
135
|
+
* as a normal Node process, where `process.env` *is* the source of truth; this
|
|
136
|
+
* file is the env module for it (the rule exempts `**/env.*`). Read env here and
|
|
137
|
+
* nowhere else.
|
|
138
|
+
*/
|
|
139
|
+
const env = process.env;
|
|
140
|
+
/** API key for authenticated calls (`samva email send`, `samva org list`, …). */
|
|
141
|
+
const SAMVA_API_KEY = env["SAMVA_API_KEY"];
|
|
142
|
+
/** API base URL override; falls back to config then the production default. */
|
|
143
|
+
const SAMVA_API_URL = env["SAMVA_API_URL"];
|
|
144
|
+
/** Named execution profile override for humans, CI, and agents. */
|
|
145
|
+
const SAMVA_PROFILE = env["SAMVA_PROFILE"];
|
|
146
|
+
/** OAuth organization override; rejected when SAMVA_API_KEY is in use. */
|
|
147
|
+
const SAMVA_ORG = env["SAMVA_ORG"];
|
|
148
|
+
/** XDG config root; the CLI stores state under `$XDG_CONFIG_HOME/samva`. */
|
|
149
|
+
const XDG_CONFIG_HOME = env["XDG_CONFIG_HOME"];
|
|
150
|
+
/** User home directory (used when `XDG_CONFIG_HOME` is unset). */
|
|
151
|
+
const HOME = env["HOME"] ?? env["USERPROFILE"];
|
|
152
|
+
//#endregion
|
|
153
|
+
//#region src/lib/runtime.ts
|
|
154
|
+
/** Process-wide output and execution settings. */
|
|
155
|
+
const Json = GlobalFlag.setting("json")({ flag: Flag.boolean("json").pipe(Flag.withDefault(false), Flag.withDescription("Output machine-readable JSON")) });
|
|
156
|
+
const JsonLines = GlobalFlag.setting("jsonl")({ flag: Flag.boolean("jsonl").pipe(Flag.withDefault(false), Flag.withDescription("Output newline-delimited JSON result envelopes")) });
|
|
157
|
+
const Quiet = GlobalFlag.setting("quiet")({ flag: Flag.boolean("quiet").pipe(Flag.withDefault(false), Flag.withDescription("Suppress all successful output")) });
|
|
158
|
+
const Profile = GlobalFlag.setting("profile")({ flag: Flag.string("profile").pipe(Flag.optional, Flag.withDescription("Execution profile (overrides SAMVA_PROFILE and the active profile)")) });
|
|
159
|
+
const ApiUrl = GlobalFlag.setting("api-url")({ flag: Flag.string("api-url").pipe(Flag.optional, Flag.withDescription("Explicit API URL (development/E2E escape hatch)")) });
|
|
160
|
+
const Org = GlobalFlag.setting("org")({ flag: Flag.string("org").pipe(Flag.optional, Flag.withDescription("Organization slug for OAuth profiles")) });
|
|
161
|
+
const NoInput = GlobalFlag.setting("no-input")({ flag: Flag.boolean("no-input").pipe(Flag.withDefault(false), Flag.withDescription("Never prompt or open a browser")) });
|
|
162
|
+
const globalFlags = [
|
|
163
|
+
Json,
|
|
164
|
+
JsonLines,
|
|
165
|
+
Quiet,
|
|
166
|
+
Profile,
|
|
167
|
+
ApiUrl,
|
|
168
|
+
Org,
|
|
169
|
+
NoInput
|
|
170
|
+
];
|
|
171
|
+
const outputFlags = /* @__PURE__ */ new Set([
|
|
172
|
+
"--json",
|
|
173
|
+
"--jsonl",
|
|
174
|
+
"--quiet"
|
|
175
|
+
]);
|
|
176
|
+
const outputAssignments = /^(--json|--jsonl|--quiet)=/;
|
|
177
|
+
/** Final M3 process-boundary metadata, solely for stable envelope command names. */
|
|
178
|
+
const commandTree = {
|
|
179
|
+
login: {},
|
|
180
|
+
logout: {},
|
|
181
|
+
org: {
|
|
182
|
+
list: {},
|
|
183
|
+
use: {},
|
|
184
|
+
current: {}
|
|
185
|
+
},
|
|
186
|
+
email: {
|
|
187
|
+
send: {},
|
|
188
|
+
doctor: {},
|
|
189
|
+
receiving: {
|
|
190
|
+
status: {},
|
|
191
|
+
enable: {}
|
|
192
|
+
},
|
|
193
|
+
domains: {
|
|
194
|
+
list: {},
|
|
195
|
+
add: {},
|
|
196
|
+
check: {},
|
|
197
|
+
remove: {}
|
|
198
|
+
},
|
|
199
|
+
senders: {
|
|
200
|
+
list: {},
|
|
201
|
+
add: {},
|
|
202
|
+
check: {},
|
|
203
|
+
remove: {}
|
|
204
|
+
},
|
|
205
|
+
theme: {
|
|
206
|
+
pull: {},
|
|
207
|
+
push: {}
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
messages: {
|
|
211
|
+
list: {},
|
|
212
|
+
get: {},
|
|
213
|
+
events: {},
|
|
214
|
+
wait: {}
|
|
215
|
+
},
|
|
216
|
+
campaigns: {
|
|
217
|
+
create: {},
|
|
218
|
+
list: {},
|
|
219
|
+
get: {},
|
|
220
|
+
update: {},
|
|
221
|
+
archive: {},
|
|
222
|
+
runs: {
|
|
223
|
+
create: {},
|
|
224
|
+
list: {},
|
|
225
|
+
get: {},
|
|
226
|
+
pause: {},
|
|
227
|
+
resume: {},
|
|
228
|
+
cancel: {},
|
|
229
|
+
recipients: {}
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
"scheduled-messages": {
|
|
233
|
+
create: {},
|
|
234
|
+
list: {},
|
|
235
|
+
get: {},
|
|
236
|
+
cancel: {}
|
|
237
|
+
},
|
|
238
|
+
templates: {
|
|
239
|
+
init: {},
|
|
240
|
+
push: {},
|
|
241
|
+
pull: {},
|
|
242
|
+
list: {},
|
|
243
|
+
get: {},
|
|
244
|
+
create: {},
|
|
245
|
+
update: {},
|
|
246
|
+
publish: {},
|
|
247
|
+
unpublish: {},
|
|
248
|
+
remove: {},
|
|
249
|
+
versions: {
|
|
250
|
+
list: {},
|
|
251
|
+
restore: {}
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
webhooks: {
|
|
255
|
+
list: {},
|
|
256
|
+
get: {},
|
|
257
|
+
create: {},
|
|
258
|
+
update: {},
|
|
259
|
+
test: {},
|
|
260
|
+
logs: {},
|
|
261
|
+
stats: {},
|
|
262
|
+
retry: {},
|
|
263
|
+
"rotate-secret": {},
|
|
264
|
+
remove: {}
|
|
265
|
+
},
|
|
266
|
+
profile: {
|
|
267
|
+
list: {},
|
|
268
|
+
show: {},
|
|
269
|
+
create: {},
|
|
270
|
+
use: {},
|
|
271
|
+
delete: {}
|
|
272
|
+
},
|
|
273
|
+
help: {}
|
|
274
|
+
};
|
|
275
|
+
/** Command handlers remain the grammar authority. */
|
|
276
|
+
const isKnownRootCommand = (token) => token in commandTree;
|
|
277
|
+
const globalOptionsWithValue = /* @__PURE__ */ new Set([
|
|
278
|
+
"--profile",
|
|
279
|
+
"--api-url",
|
|
280
|
+
"--org",
|
|
281
|
+
"--log-level"
|
|
282
|
+
]);
|
|
283
|
+
/**
|
|
284
|
+
* Return the released command path in its stable dotted form. This only reads
|
|
285
|
+
* tokens before `--`; positional data after a recognized leaf never becomes
|
|
286
|
+
* command metadata.
|
|
287
|
+
*/
|
|
288
|
+
const commandNameFromArgv = (argv) => {
|
|
289
|
+
let tree = commandTree;
|
|
290
|
+
const path = [];
|
|
291
|
+
for (let index = 0; index < argv.length; index++) {
|
|
292
|
+
const token = argv[index];
|
|
293
|
+
if (token === "--") break;
|
|
294
|
+
if (globalOptionsWithValue.has(token)) {
|
|
295
|
+
index++;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (token.startsWith("--profile=") || token.startsWith("--api-url=") || token.startsWith("--org=") || token.startsWith("--log-level=")) continue;
|
|
299
|
+
if (token.startsWith("-")) continue;
|
|
300
|
+
if (path.length === 0) {
|
|
301
|
+
if (token in tree) {
|
|
302
|
+
path.push(token);
|
|
303
|
+
tree = tree[token];
|
|
304
|
+
}
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (token in tree) {
|
|
308
|
+
path.push(token);
|
|
309
|
+
tree = tree[token];
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
return path.length === 0 ? "samva" : path.join(".");
|
|
315
|
+
};
|
|
316
|
+
/**
|
|
317
|
+
* Validate the tiny set of process-wide syntax rules before Effect CLI begins
|
|
318
|
+
* parsing command-local flags. Stops at `--`, so a positional value cannot
|
|
319
|
+
* accidentally select machine mode.
|
|
320
|
+
*/
|
|
321
|
+
const validateProcessArgs = (argv) => Effect.gen(function* () {
|
|
322
|
+
const selected = /* @__PURE__ */ new Set();
|
|
323
|
+
for (const token of argv) {
|
|
324
|
+
if (token === "--") break;
|
|
325
|
+
if (outputAssignments.test(token)) return yield* new CliUsageError({
|
|
326
|
+
message: "Use --json, --jsonl, or --quiet without =true or =false.",
|
|
327
|
+
code: "invalid_output_mode_syntax"
|
|
328
|
+
});
|
|
329
|
+
if (outputFlags.has(token)) selected.add(token);
|
|
330
|
+
}
|
|
331
|
+
if (selected.size > 1) return yield* new CliUsageError({
|
|
332
|
+
message: "Use only one of --json, --jsonl, or --quiet.",
|
|
333
|
+
code: "mutually_exclusive_output_mode"
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
const outputModeFromArgv = (argv) => {
|
|
337
|
+
let selected = "human";
|
|
338
|
+
for (const token of argv) {
|
|
339
|
+
if (token === "--") break;
|
|
340
|
+
if (token === "--jsonl" || token.startsWith("--jsonl=")) selected = "jsonl";
|
|
341
|
+
else if (token === "--json" || token.startsWith("--json=")) {
|
|
342
|
+
if (selected !== "jsonl") selected = "json";
|
|
343
|
+
} else if (token === "--quiet" || token.startsWith("--quiet=")) {
|
|
344
|
+
if (selected === "human") selected = "quiet";
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return selected;
|
|
348
|
+
};
|
|
349
|
+
const isJsonRequested = () => outputModeFromArgv(process.argv.slice(2)) === "json";
|
|
350
|
+
const isJsonLinesRequested = () => outputModeFromArgv(process.argv.slice(2)) === "jsonl";
|
|
351
|
+
//#endregion
|
|
352
|
+
//#region src/config/credential-store.ts
|
|
353
|
+
const SERVICE = "dev.samva.cli";
|
|
354
|
+
const readCredentialCommand = (platform, command, profile) => platform === "darwin" ? { args: [
|
|
355
|
+
command,
|
|
356
|
+
"find-generic-password",
|
|
357
|
+
"-s",
|
|
358
|
+
SERVICE,
|
|
359
|
+
"-a",
|
|
360
|
+
profile,
|
|
361
|
+
"-w"
|
|
362
|
+
] } : { args: [
|
|
363
|
+
command,
|
|
364
|
+
"lookup",
|
|
365
|
+
"service",
|
|
366
|
+
SERVICE,
|
|
367
|
+
"profile",
|
|
368
|
+
profile
|
|
369
|
+
] };
|
|
370
|
+
const writeCredentialCommand = (platform, command, profile, secret) => platform === "darwin" ? {
|
|
371
|
+
args: [command, "-i"],
|
|
372
|
+
input: `add-generic-password -U -s ${SERVICE} -a ${profile} -X ${Buffer.from(secret, "utf8").toString("hex")}\n`
|
|
373
|
+
} : {
|
|
374
|
+
args: [
|
|
375
|
+
command,
|
|
376
|
+
"store",
|
|
377
|
+
"--label",
|
|
378
|
+
`Samva CLI profile ${profile}`,
|
|
379
|
+
"service",
|
|
380
|
+
SERVICE,
|
|
381
|
+
"profile",
|
|
382
|
+
profile
|
|
383
|
+
],
|
|
384
|
+
input: secret
|
|
385
|
+
};
|
|
386
|
+
const commandForPlatform = () => {
|
|
387
|
+
if (process.platform === "darwin") return "/usr/bin/security";
|
|
388
|
+
if (process.platform === "linux") return "secret-tool";
|
|
389
|
+
};
|
|
390
|
+
const runSecretCommand = (args, input) => Effect.tryPromise({
|
|
391
|
+
try: async () => {
|
|
392
|
+
const child = Bun.spawn({
|
|
393
|
+
cmd: [...args],
|
|
394
|
+
stdin: input === void 0 ? "ignore" : new Blob([input]),
|
|
395
|
+
stdout: "pipe",
|
|
396
|
+
stderr: "pipe"
|
|
397
|
+
});
|
|
398
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
399
|
+
new Response(child.stdout).text(),
|
|
400
|
+
new Response(child.stderr).text(),
|
|
401
|
+
child.exited
|
|
402
|
+
]);
|
|
403
|
+
return {
|
|
404
|
+
stdout: stdout.trim(),
|
|
405
|
+
stderr: stderr.trim(),
|
|
406
|
+
exitCode
|
|
407
|
+
};
|
|
408
|
+
},
|
|
409
|
+
catch: () => new CliConfigError({
|
|
410
|
+
code: "credential_store_unavailable",
|
|
411
|
+
message: "Could not run the operating-system credential store."
|
|
412
|
+
})
|
|
413
|
+
});
|
|
414
|
+
const keyring = () => {
|
|
415
|
+
const command = commandForPlatform();
|
|
416
|
+
return command === void 0 ? Effect.fail(new CliConfigError({
|
|
417
|
+
code: "credential_store_unsupported",
|
|
418
|
+
message: "Samva CLI credentials require macOS Keychain or Linux secret-tool."
|
|
419
|
+
})) : Effect.succeed(command);
|
|
420
|
+
};
|
|
421
|
+
const isMissingSecretResult = (platform, result) => platform === "darwin" ? result.exitCode === 44 : platform === "linux" ? result.exitCode === 1 && result.stdout.length === 0 && result.stderr.length === 0 : false;
|
|
422
|
+
/** Read one profile secret. A missing item is normal; no plaintext fallback exists. */
|
|
423
|
+
const readCredentialSecret = (profile) => Effect.gen(function* () {
|
|
424
|
+
const command = yield* keyring();
|
|
425
|
+
const spec = readCredentialCommand(process.platform, command, profile);
|
|
426
|
+
const result = yield* runSecretCommand(spec.args, spec.input);
|
|
427
|
+
if (result.exitCode !== 0) {
|
|
428
|
+
if (isMissingSecretResult(process.platform, result)) return void 0;
|
|
429
|
+
return yield* new CliConfigError({
|
|
430
|
+
code: "credential_store_read_failed",
|
|
431
|
+
message: "Could not read credentials from the operating-system credential store."
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
return result.stdout;
|
|
435
|
+
});
|
|
436
|
+
/** Store a secret in the OS keyring. Failure is explicit and never falls back to a file. */
|
|
437
|
+
const writeCredentialSecret = (profile, secret) => Effect.gen(function* () {
|
|
438
|
+
const command = yield* keyring();
|
|
439
|
+
const spec = writeCredentialCommand(process.platform, command, profile, secret);
|
|
440
|
+
if ((yield* runSecretCommand(spec.args, spec.input)).exitCode !== 0) return yield* new CliConfigError({
|
|
441
|
+
code: "credential_store_write_failed",
|
|
442
|
+
message: "Could not save credentials in the operating-system credential store."
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
const clearCredentialSecret = (profile) => Effect.gen(function* () {
|
|
446
|
+
const command = yield* keyring();
|
|
447
|
+
const result = process.platform === "darwin" ? yield* runSecretCommand([
|
|
448
|
+
command,
|
|
449
|
+
"delete-generic-password",
|
|
450
|
+
"-s",
|
|
451
|
+
SERVICE,
|
|
452
|
+
"-a",
|
|
453
|
+
profile
|
|
454
|
+
]) : yield* runSecretCommand([
|
|
455
|
+
command,
|
|
456
|
+
"clear",
|
|
457
|
+
"service",
|
|
458
|
+
SERVICE,
|
|
459
|
+
"profile",
|
|
460
|
+
profile
|
|
461
|
+
]);
|
|
462
|
+
if (result.exitCode !== 0 && !isMissingSecretResult(process.platform, result)) return yield* new CliConfigError({
|
|
463
|
+
code: "credential_store_delete_failed",
|
|
464
|
+
message: "Could not remove credentials from the operating-system credential store."
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
//#endregion
|
|
468
|
+
//#region src/config/paths.ts
|
|
469
|
+
/**
|
|
470
|
+
* Absolute path to the Samva CLI config directory:
|
|
471
|
+
* `$XDG_CONFIG_HOME/samva` when set, otherwise `~/.config/samva`.
|
|
472
|
+
*/
|
|
473
|
+
const configDir = Effect.gen(function* () {
|
|
474
|
+
const path = yield* Path.Path;
|
|
475
|
+
const base = XDG_CONFIG_HOME && XDG_CONFIG_HOME.length > 0 ? XDG_CONFIG_HOME : HOME && HOME.length > 0 ? path.join(HOME, ".config") : yield* new SamvaCliError({ message: "Could not resolve config directory. Set XDG_CONFIG_HOME, HOME, or USERPROFILE." });
|
|
476
|
+
return path.join(base, "samva");
|
|
477
|
+
});
|
|
478
|
+
/** Path to `config.json` (api url + active org). */
|
|
479
|
+
const configFilePath = Effect.gen(function* () {
|
|
480
|
+
const path = yield* Path.Path;
|
|
481
|
+
const dir = yield* configDir;
|
|
482
|
+
return path.join(dir, "config.json");
|
|
483
|
+
});
|
|
484
|
+
//#endregion
|
|
485
|
+
//#region src/config/store.ts
|
|
486
|
+
const DEFAULT_API_URL = "https://api.samva.dev";
|
|
487
|
+
const LOCAL_API_URL = "http://127.0.0.1:8787";
|
|
488
|
+
const ProfileNameSchema = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/));
|
|
489
|
+
const ProfileSchema = Schema.Struct({
|
|
490
|
+
target: Schema.Literals(["production", "local"]),
|
|
491
|
+
orgSlug: Schema.optional(Schema.String),
|
|
492
|
+
credentialIdentity: Schema.Literals(["none", "oauth"])
|
|
493
|
+
});
|
|
494
|
+
const ConfigSchema = Schema.Struct({
|
|
495
|
+
version: Schema.Literal(2),
|
|
496
|
+
activeProfile: Schema.optional(Schema.String),
|
|
497
|
+
profiles: Schema.Record(ProfileNameSchema, ProfileSchema)
|
|
498
|
+
});
|
|
499
|
+
const CredentialsSchema = Schema.Struct({
|
|
500
|
+
accessToken: Schema.String,
|
|
501
|
+
tokenType: Schema.String,
|
|
502
|
+
expiresAt: Schema.Finite,
|
|
503
|
+
scope: Schema.optional(Schema.String)
|
|
504
|
+
});
|
|
505
|
+
const emptyConfig = {
|
|
506
|
+
version: 2,
|
|
507
|
+
profiles: {}
|
|
508
|
+
};
|
|
509
|
+
const validateProfileName = (name) => Schema.decodeUnknownEffect(ProfileNameSchema)(name).pipe(Effect.mapError(() => new CliUsageError({
|
|
510
|
+
code: "invalid_profile_name",
|
|
511
|
+
message: "Profile names must be 1-64 characters and contain only letters, numbers, dots, underscores, or hyphens."
|
|
512
|
+
})));
|
|
513
|
+
const parseConfig = (raw, file) => Effect.gen(function* () {
|
|
514
|
+
const rawValue = yield* Effect.try({
|
|
515
|
+
try: () => JSON.parse(raw),
|
|
516
|
+
catch: () => new CliConfigError({
|
|
517
|
+
code: "invalid_config",
|
|
518
|
+
message: `Could not parse CLI config at ${file}. Fix or delete it.`
|
|
519
|
+
})
|
|
520
|
+
});
|
|
521
|
+
return yield* Schema.decodeUnknownEffect(ConfigSchema)(rawValue).pipe(Effect.mapError(() => new CliConfigError({
|
|
522
|
+
code: "invalid_config",
|
|
523
|
+
message: `Could not parse CLI config at ${file}. Fix or delete it.`
|
|
524
|
+
})));
|
|
525
|
+
});
|
|
526
|
+
const readConfig = Effect.gen(function* () {
|
|
527
|
+
const fs = yield* FileSystem.FileSystem;
|
|
528
|
+
const file = yield* configFilePath;
|
|
529
|
+
if (!(yield* fs.exists(file))) return emptyConfig;
|
|
530
|
+
return yield* parseConfig(yield* fs.readFileString(file), file);
|
|
531
|
+
});
|
|
532
|
+
const writeConfig = Effect.fnUntraced(function* (config) {
|
|
533
|
+
const fs = yield* FileSystem.FileSystem;
|
|
534
|
+
yield* fs.makeDirectory(yield* configDir, { recursive: true });
|
|
535
|
+
yield* fs.writeFileString(yield* configFilePath, `${JSON.stringify(config, null, 2)}\n`);
|
|
536
|
+
});
|
|
537
|
+
/** Resolve flags/profile/target/identity without reading or decoding a secret. */
|
|
538
|
+
const resolveExecutionSelection = Effect.gen(function* () {
|
|
539
|
+
const profileFlag = Option.getOrUndefined(yield* Profile);
|
|
540
|
+
const apiUrlFlag = Option.getOrUndefined(yield* ApiUrl);
|
|
541
|
+
const orgFlag = Option.getOrUndefined(yield* Org);
|
|
542
|
+
const config = yield* readConfig;
|
|
543
|
+
const profile = profileFlag ?? SAMVA_PROFILE ?? config.activeProfile ?? "default";
|
|
544
|
+
if (profile !== "default" && config.profiles[profile] === void 0) return yield* new CliConfigError({
|
|
545
|
+
code: "unknown_profile",
|
|
546
|
+
message: `Profile ${profile} does not exist. Create it with \`samva profile create ${profile}\`.`
|
|
547
|
+
});
|
|
548
|
+
const stored = config.profiles[profile] ?? {
|
|
549
|
+
target: "production",
|
|
550
|
+
credentialIdentity: "none"
|
|
551
|
+
};
|
|
552
|
+
const apiUrl = apiUrlFlag ?? SAMVA_API_URL ?? (stored.target === "local" ? LOCAL_API_URL : DEFAULT_API_URL);
|
|
553
|
+
const orgSlug = orgFlag ?? SAMVA_ORG ?? stored.orgSlug;
|
|
554
|
+
const apiKey = SAMVA_API_KEY && SAMVA_API_KEY.length > 0 ? SAMVA_API_KEY : void 0;
|
|
555
|
+
if (apiKey && (orgFlag !== void 0 || SAMVA_ORG !== void 0)) return yield* new CliAuthError({
|
|
556
|
+
code: "api_key_org_override",
|
|
557
|
+
message: "--org/SAMVA_ORG cannot override an organization-scoped SAMVA_API_KEY."
|
|
558
|
+
});
|
|
559
|
+
if (apiKey) return {
|
|
560
|
+
profile,
|
|
561
|
+
target: stored.target,
|
|
562
|
+
apiUrl,
|
|
563
|
+
apiKey
|
|
564
|
+
};
|
|
565
|
+
return {
|
|
566
|
+
profile,
|
|
567
|
+
target: stored.target,
|
|
568
|
+
apiUrl,
|
|
569
|
+
orgSlug,
|
|
570
|
+
profileOrgSlug: stored.orgSlug,
|
|
571
|
+
apiKey
|
|
572
|
+
};
|
|
573
|
+
});
|
|
574
|
+
const resolveExecutionContext = Effect.gen(function* () {
|
|
575
|
+
const selection = yield* resolveExecutionSelection;
|
|
576
|
+
if (selection.apiKey) return {
|
|
577
|
+
...selection,
|
|
578
|
+
credentials: void 0
|
|
579
|
+
};
|
|
580
|
+
const rawCredentials = ((yield* readConfig).profiles[selection.profile] ?? {
|
|
581
|
+
target: "production",
|
|
582
|
+
credentialIdentity: "none"
|
|
583
|
+
}).credentialIdentity === "oauth" ? yield* readCredentialSecret(selection.profile) : void 0;
|
|
584
|
+
const credentials = rawCredentials ? yield* decodeStoredCredentials(selection.profile, rawCredentials) : void 0;
|
|
585
|
+
return {
|
|
586
|
+
...selection,
|
|
587
|
+
credentials
|
|
588
|
+
};
|
|
589
|
+
});
|
|
590
|
+
/** Decode a keyring payload only after its non-secret profile selection is fixed. */
|
|
591
|
+
const decodeStoredCredentials = (profile, rawCredentials) => Effect.try({
|
|
592
|
+
try: () => JSON.parse(rawCredentials),
|
|
593
|
+
catch: () => new CliConfigError({
|
|
594
|
+
code: "invalid_credential_record",
|
|
595
|
+
message: `Stored credentials for profile ${profile} are invalid. Run \`samva logout\` then \`samva login\`.`
|
|
596
|
+
})
|
|
597
|
+
}).pipe(Effect.flatMap(Schema.decodeUnknownEffect(CredentialsSchema)), Effect.mapError(() => new CliConfigError({
|
|
598
|
+
code: "invalid_credential_record",
|
|
599
|
+
message: `Stored credentials for profile ${profile} are invalid. Run \`samva logout\` then \`samva login\`.`
|
|
600
|
+
})));
|
|
601
|
+
/** URL-only callers must not decode credentials before they begin recovery. */
|
|
602
|
+
const resolveApiUrl = Effect.map(resolveExecutionSelection, (selection) => selection.apiUrl);
|
|
603
|
+
/**
|
|
604
|
+
* OAuth access tokens carry a second-resolution expiry. Compared against the
|
|
605
|
+
* Effect `Clock` (not `Date.now`) so the check is deterministic under TestClock.
|
|
606
|
+
*/
|
|
607
|
+
const isCredentialExpired = (credentials, nowMillis) => credentials.expiresAt <= Math.floor(nowMillis / 1e3);
|
|
608
|
+
Effect.gen(function* () {
|
|
609
|
+
const context = yield* resolveExecutionContext;
|
|
610
|
+
if (!context.credentials) return void 0;
|
|
611
|
+
if (isCredentialExpired(context.credentials, yield* Clock.currentTimeMillis)) return yield* new CliAuthError({
|
|
612
|
+
code: "expired_credentials",
|
|
613
|
+
message: "Your session has expired. Run `samva login` to sign in again."
|
|
614
|
+
});
|
|
615
|
+
return context.credentials.accessToken;
|
|
616
|
+
});
|
|
617
|
+
const writeCredentials = Effect.fnUntraced(function* (credentials) {
|
|
618
|
+
const context = yield* resolveExecutionSelection;
|
|
619
|
+
const config = yield* readConfig;
|
|
620
|
+
const current = config.profiles[context.profile] ?? {
|
|
621
|
+
target: context.target,
|
|
622
|
+
credentialIdentity: "none"
|
|
623
|
+
};
|
|
624
|
+
yield* writeConfig({
|
|
625
|
+
...config,
|
|
626
|
+
profiles: {
|
|
627
|
+
...config.profiles,
|
|
628
|
+
[context.profile]: {
|
|
629
|
+
...current,
|
|
630
|
+
credentialIdentity: "oauth"
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
yield* writeCredentialSecret(context.profile, JSON.stringify(credentials));
|
|
635
|
+
});
|
|
636
|
+
const readCredentials = Effect.map(resolveExecutionContext, (context) => context.credentials);
|
|
637
|
+
const clearCredentials = Effect.gen(function* () {
|
|
638
|
+
const context = yield* resolveExecutionSelection;
|
|
639
|
+
const config = yield* readConfig;
|
|
640
|
+
const current = config.profiles[context.profile];
|
|
641
|
+
if (!current || current.credentialIdentity === "none") return;
|
|
642
|
+
yield* writeConfig({
|
|
643
|
+
...config,
|
|
644
|
+
profiles: {
|
|
645
|
+
...config.profiles,
|
|
646
|
+
[context.profile]: {
|
|
647
|
+
...current,
|
|
648
|
+
credentialIdentity: "none"
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
yield* clearCredentialSecret(context.profile);
|
|
653
|
+
});
|
|
654
|
+
//#endregion
|
|
655
|
+
//#region src/lib/client.ts
|
|
656
|
+
const missingOrgMessage = "No active organization. Run `samva org use <slug>` after `samva login`, or use SAMVA_API_KEY for single-org automation.";
|
|
657
|
+
const provider = (config) => ({ provide: (effect) => Effect.provide(effect, Client.layerFetch(config)) });
|
|
658
|
+
/** Build an authenticated SDK operation provider for an explicit API base URL. */
|
|
659
|
+
const getClientProviderForContext = (context, baseUrl) => Effect.gen(function* () {
|
|
660
|
+
if (context.apiKey) return provider({
|
|
661
|
+
apiKey: context.apiKey,
|
|
662
|
+
baseUrl
|
|
663
|
+
});
|
|
664
|
+
if (context.credentials) {
|
|
665
|
+
if (isCredentialExpired(context.credentials, yield* Clock.currentTimeMillis)) return yield* new CliAuthError({
|
|
666
|
+
code: "expired_credentials",
|
|
667
|
+
message: "Your session has expired. Run `samva login` to sign in again."
|
|
668
|
+
});
|
|
669
|
+
const authToken = context.credentials.accessToken;
|
|
670
|
+
const activeOrgSlug = context.orgSlug;
|
|
671
|
+
if (!activeOrgSlug) return yield* new CliAuthError({
|
|
672
|
+
code: "missing_organization",
|
|
673
|
+
message: missingOrgMessage
|
|
674
|
+
});
|
|
675
|
+
return provider({
|
|
676
|
+
authToken,
|
|
677
|
+
baseUrl,
|
|
678
|
+
headers: { "x-org-slug": activeOrgSlug }
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
return yield* new CliAuthError({
|
|
682
|
+
code: "missing_credentials",
|
|
683
|
+
message: "No credentials found. Run `samva login`, or set SAMVA_API_KEY for automation."
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
/**
|
|
687
|
+
* Build an authenticated Samva SDK operation provider. This is the single seam all command
|
|
688
|
+
* handlers go through. `SAMVA_API_KEY` takes precedence over stored OAuth
|
|
689
|
+
* credentials. OAuth is the interactive fallback; its user-scoped token sends
|
|
690
|
+
* the active org (`samva org use`) as the `x-org-slug` header.
|
|
691
|
+
*/
|
|
692
|
+
const getClientProvider = Effect.flatMap(resolveExecutionContext, (context) => getClientProviderForContext(context, context.apiUrl));
|
|
693
|
+
//#endregion
|
|
694
|
+
//#region src/lib/confirm.ts
|
|
695
|
+
/** Confirm a destructive action in a real TTY; automation must pass --yes. */
|
|
696
|
+
const confirmDestructive = (options) => Effect.gen(function* () {
|
|
697
|
+
if (options.yes) return;
|
|
698
|
+
if ((yield* NoInput) || options.json || !isatty(0) || !isatty(1)) return yield* new CliUsageError({
|
|
699
|
+
code: "confirmation_required",
|
|
700
|
+
message: options.nonInteractiveMessage
|
|
701
|
+
});
|
|
702
|
+
if (!(yield* Prompt.run(Prompt.confirm({
|
|
703
|
+
message: options.message,
|
|
704
|
+
initial: false
|
|
705
|
+
})))) return yield* new CliUsageError({
|
|
706
|
+
code: "confirmation_declined",
|
|
707
|
+
message: "Cancelled; no changes were made."
|
|
708
|
+
});
|
|
709
|
+
});
|
|
710
|
+
//#endregion
|
|
711
|
+
//#region src/lib/output.ts
|
|
712
|
+
const CLI_SCHEMA_VERSION = 1;
|
|
713
|
+
const resultEnvelope = (command, data, partial) => ({
|
|
714
|
+
schemaVersion: CLI_SCHEMA_VERSION,
|
|
715
|
+
type: "result",
|
|
716
|
+
command,
|
|
717
|
+
data,
|
|
718
|
+
partial
|
|
719
|
+
});
|
|
720
|
+
const errorEnvelope = (error, command, message) => ({
|
|
721
|
+
schemaVersion: CLI_SCHEMA_VERSION,
|
|
722
|
+
type: "error",
|
|
723
|
+
command,
|
|
724
|
+
reason: processErrorReason(error),
|
|
725
|
+
message,
|
|
726
|
+
next: processErrorNext(error)
|
|
727
|
+
});
|
|
728
|
+
/**
|
|
729
|
+
* Shared `--json` flag for read commands. Hidden from help because the global
|
|
730
|
+
* `--json` (see {@link Json} in lib/runtime) already documents it — otherwise it
|
|
731
|
+
* lists twice, e.g. `samva org current --help`. Parsing behavior is unchanged.
|
|
732
|
+
*/
|
|
733
|
+
const jsonFlag = Flag.boolean("json").pipe(Flag.withDefault(isJsonRequested() || isJsonLinesRequested()), Flag.withDescription("Output machine-readable JSON instead of formatted text"), Flag.withHidden);
|
|
734
|
+
/** Print a value as pretty-printed JSON. */
|
|
735
|
+
const printJson = (value) => {
|
|
736
|
+
if (isJsonLinesRequested()) return Effect.sync(() => process.stdout.write(`${JSON.stringify(resultEnvelope(commandNameFromArgv(process.argv.slice(2)), value))}\n`));
|
|
737
|
+
return Console.log(JSON.stringify(value, null, 2));
|
|
738
|
+
};
|
|
739
|
+
/** A stable diagnostics schema paired with the versioned result envelope. */
|
|
740
|
+
const printJsonError = (error, message) => {
|
|
741
|
+
const payload = errorEnvelope(error, commandNameFromArgv(process.argv.slice(2)), message);
|
|
742
|
+
return Console.error(JSON.stringify(payload, null, isJsonLinesRequested() ? void 0 : 2));
|
|
743
|
+
};
|
|
744
|
+
/** NDJSON is intentionally unbuffered and versioned, one outer envelope per resource. */
|
|
745
|
+
const printJsonLine = (value) => Effect.sync(() => process.stdout.write(`${JSON.stringify(resultEnvelope(commandNameFromArgv(process.argv.slice(2)), value))}\n`));
|
|
746
|
+
const printResult = (value, human) => isJsonRequested() || isJsonLinesRequested() ? printJson(value) : Console.log(human);
|
|
747
|
+
/**
|
|
748
|
+
* Whether to emit ANSI styling. `Bun.enableANSIColors` already accounts for a
|
|
749
|
+
* TTY, `NO_COLOR`, and `FORCE_COLOR`, so styling stays off when stdout is piped
|
|
750
|
+
* or the machine path is in use. Read lazily (not at module load) so importing
|
|
751
|
+
* this module stays free of runtime-global side effects — process e2e tests run
|
|
752
|
+
* the CLI under Bun, but unit tests import these helpers under Node/vitest.
|
|
753
|
+
*/
|
|
754
|
+
const colorsEnabled = () => Bun.enableANSIColors;
|
|
755
|
+
const sgr = (open, close) => (text) => colorsEnabled() ? `\x1b[${open}m${text}\x1b[${close}m` : text;
|
|
756
|
+
/** Dim text (gated on {@link colorsEnabled}). */
|
|
757
|
+
const dim = sgr(2, 22);
|
|
758
|
+
const MAX_DUMP_LINES = 40;
|
|
759
|
+
const MAX_DUMP_CHARS = 4e3;
|
|
760
|
+
/**
|
|
761
|
+
* Bound a large human-facing text dump (an email body, a template source, a
|
|
762
|
+
* payload preview) so a huge input can't flood the terminal or an agent's
|
|
763
|
+
* context. Appends an explicit truncation tail stating what was dropped. The
|
|
764
|
+
* `--json` path is never truncated — an agent that asked for the payload gets
|
|
765
|
+
* all of it.
|
|
766
|
+
*/
|
|
767
|
+
const truncateForDisplay = (text, maxLines = MAX_DUMP_LINES, maxChars = MAX_DUMP_CHARS) => {
|
|
768
|
+
let out = text;
|
|
769
|
+
const lines = out.split("\n");
|
|
770
|
+
if (lines.length > maxLines) out = `${lines.slice(0, maxLines).join("\n")}\n${dim(`… ${lines.length - maxLines} more line(s) truncated`)}`;
|
|
771
|
+
if (out.length > maxChars) out = `${out.slice(0, maxChars)}\n${dim(`… truncated at ${maxChars} characters`)}`;
|
|
772
|
+
return out;
|
|
773
|
+
};
|
|
774
|
+
/** Print aligned `Label: value` lines, skipping nullish/empty values. */
|
|
775
|
+
const printFields = (fields) => {
|
|
776
|
+
const visible = fields.filter(([, value]) => value !== null && value !== void 0 && value !== "");
|
|
777
|
+
const width = Math.max(0, ...visible.map(([label]) => Bun.stringWidth(label)));
|
|
778
|
+
const lines = visible.map(([label, value]) => {
|
|
779
|
+
return `${label}${" ".repeat(Math.max(0, width - Bun.stringWidth(label)))} ${String(value)}`;
|
|
780
|
+
});
|
|
781
|
+
return Console.log(lines.join("\n"));
|
|
782
|
+
};
|
|
783
|
+
//#endregion
|
|
784
|
+
//#region src/commands/campaigns.ts
|
|
785
|
+
const CAMPAIGN_STATUSES = ["draft", "archived"];
|
|
786
|
+
const RUN_STATUSES = [
|
|
787
|
+
"scheduled",
|
|
788
|
+
"resolving",
|
|
789
|
+
"dispatching",
|
|
790
|
+
"paused",
|
|
791
|
+
"completed",
|
|
792
|
+
"cancelled",
|
|
793
|
+
"failed"
|
|
794
|
+
];
|
|
795
|
+
const RECIPIENT_STATUSES = [
|
|
796
|
+
"pending",
|
|
797
|
+
"dispatching",
|
|
798
|
+
"dispatched",
|
|
799
|
+
"failed",
|
|
800
|
+
"skipped"
|
|
801
|
+
];
|
|
802
|
+
const ABSOLUTE_INSTANT_PATTERN$1 = /^(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
803
|
+
const isAbsoluteInstant$1 = (value) => {
|
|
804
|
+
const match = ABSOLUTE_INSTANT_PATTERN$1.exec(value);
|
|
805
|
+
if (!match || Number.isNaN(Date.parse(value))) return false;
|
|
806
|
+
const year = Number(match[1]);
|
|
807
|
+
const month = Number(match[2]);
|
|
808
|
+
const day = Number(match[3]);
|
|
809
|
+
const calendarDate = new Date(Date.UTC(year, month - 1, day));
|
|
810
|
+
return calendarDate.getUTCFullYear() === year && calendarDate.getUTCMonth() === month - 1 && calendarDate.getUTCDate() === day;
|
|
811
|
+
};
|
|
812
|
+
/**
|
|
813
|
+
* Flatten repeated and comma-separated flag values into a clean tag list.
|
|
814
|
+
* `--include-tags a,b --include-tags c` and `--include-tags a --include-tags b`
|
|
815
|
+
* both collapse to `["a", "b", ...]`. Pure so it can be unit-tested.
|
|
816
|
+
*/
|
|
817
|
+
const parseTagList = (values) => values.flatMap((value) => value.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0));
|
|
818
|
+
/**
|
|
819
|
+
* Assemble the `campaigns.create` email payload from resolved flags. Pure so the
|
|
820
|
+
* flag-to-request mapping can be unit-tested without the network.
|
|
821
|
+
*/
|
|
822
|
+
const assembleCampaignCreatePayload = (input) => {
|
|
823
|
+
const hasPolicy = input.unsubscribeGroupId !== void 0 || input.pageSize !== void 0;
|
|
824
|
+
return {
|
|
825
|
+
name: input.name,
|
|
826
|
+
channel: "email",
|
|
827
|
+
content: {
|
|
828
|
+
channel: "email",
|
|
829
|
+
email: {
|
|
830
|
+
subject: input.subject,
|
|
831
|
+
html: input.html,
|
|
832
|
+
text: input.text,
|
|
833
|
+
templateId: input.templateId,
|
|
834
|
+
templateSlug: input.templateSlug,
|
|
835
|
+
templateData: input.templateData,
|
|
836
|
+
...input.tracking === void 0 ? {} : { tracking: input.tracking }
|
|
837
|
+
}
|
|
838
|
+
},
|
|
839
|
+
audience: {
|
|
840
|
+
includeTags: input.includeTags.length > 0 ? [...input.includeTags] : void 0,
|
|
841
|
+
excludeTags: input.excludeTags.length > 0 ? [...input.excludeTags] : void 0,
|
|
842
|
+
contactIds: input.contactIds.length > 0 ? [...input.contactIds] : void 0
|
|
843
|
+
},
|
|
844
|
+
policy: hasPolicy ? {
|
|
845
|
+
unsubscribeGroupId: input.unsubscribeGroupId,
|
|
846
|
+
pageSize: input.pageSize
|
|
847
|
+
} : void 0
|
|
848
|
+
};
|
|
849
|
+
};
|
|
850
|
+
const parseTemplateData$2 = (raw) => Option.match(raw, {
|
|
851
|
+
onNone: () => Effect.succeed(void 0),
|
|
852
|
+
onSome: (value) => Effect.try({
|
|
853
|
+
try: () => JSON.parse(value),
|
|
854
|
+
catch: () => new SamvaCliError({ message: "--template-data must be a JSON object." })
|
|
855
|
+
}).pipe(Effect.flatMap((parsed) => Predicate.isObject(parsed) ? Effect.succeed(parsed) : Effect.fail(new SamvaCliError({ message: "--template-data must be a JSON object." }))))
|
|
856
|
+
});
|
|
857
|
+
const nameFlag = Flag.string("name").pipe(Flag.withDescription("Campaign name"));
|
|
858
|
+
const subjectFlag$2 = Flag.string("subject").pipe(Flag.withDescription("Email subject line"), Flag.optional);
|
|
859
|
+
const htmlFlag$2 = Flag.string("html").pipe(Flag.withDescription("HTML body"), Flag.optional);
|
|
860
|
+
const textFlag$2 = Flag.string("text").pipe(Flag.withDescription("Plain-text body"), Flag.optional);
|
|
861
|
+
const templateIdFlag$2 = Flag.string("template-id").pipe(Flag.withDescription("Template id (uuid) to render instead of inline body"), Flag.optional);
|
|
862
|
+
const templateSlugFlag$2 = Flag.string("template-slug").pipe(Flag.withDescription("Template handle to render instead of inline body, e.g. welcome-email"), Flag.optional);
|
|
863
|
+
const templateDataFlag$2 = Flag.string("template-data").pipe(Flag.withDescription("JSON object of variables for the template"), Flag.optional);
|
|
864
|
+
const includeTagsFlag = Flag.string("include-tags").pipe(Flag.withDescription("Contact tags to include (comma-separated or repeated)"), Flag.atLeast(0));
|
|
865
|
+
const excludeTagsFlag = Flag.string("exclude-tags").pipe(Flag.withDescription("Contact tags to exclude from the include set (comma-separated or repeated)"), Flag.atLeast(0));
|
|
866
|
+
const contactIdsFlag = Flag.string("contact-ids").pipe(Flag.withDescription("Explicit contact ids to target (comma-separated or repeated)"), Flag.atLeast(0));
|
|
867
|
+
const unsubscribeGroupIdFlag = Flag.string("unsubscribe-group-id").pipe(Flag.withDescription("Unsubscribe group id applied to every send"), Flag.optional);
|
|
868
|
+
const pageSizeFlag = Flag.integer("page-size").pipe(Flag.withDescription("Recipients dispatched per batch"), Flag.optional);
|
|
869
|
+
const dryRunFlag$4 = Flag.boolean("dry-run").pipe(Flag.withDescription("Validate inputs and print the request payload without calling the API"), Flag.withDefault(false));
|
|
870
|
+
const trackOpensFlag$2 = Flag.choice("track-opens", ["on", "off"]).pipe(Flag.withDescription("Override open tracking for campaign sends; recipient opt-out wins"), Flag.optional);
|
|
871
|
+
const trackClicksFlag$2 = Flag.choice("track-clicks", ["on", "off"]).pipe(Flag.withDescription("Override click tracking for campaign sends; recipient opt-out wins"), Flag.optional);
|
|
872
|
+
const atFlag$1 = Flag.string("at").pipe(Flag.withDescription("Absolute run start instant as ISO 8601 (defaults to now)"), Flag.optional);
|
|
873
|
+
const idempotencyKeyFlag$2 = Flag.string("idempotency-key").pipe(Flag.withDescription("Organization-scoped key so retries return the original run"), Flag.optional);
|
|
874
|
+
const campaignStatusFlag = Flag.choice("status", CAMPAIGN_STATUSES).pipe(Flag.withDescription("Filter by status"), Flag.optional);
|
|
875
|
+
const runStatusFlag = Flag.choice("status", RUN_STATUSES).pipe(Flag.withDescription("Filter by run status"), Flag.optional);
|
|
876
|
+
const recipientStatusFlag = Flag.choice("status", RECIPIENT_STATUSES).pipe(Flag.withDescription("Filter by recipient status"), Flag.optional);
|
|
877
|
+
const pageFlag$2 = Flag.integer("page").pipe(Flag.withDescription("Page number (1-based)"), Flag.optional);
|
|
878
|
+
const limitFlag$2 = Flag.integer("limit").pipe(Flag.withDescription("Items per page"), Flag.optional);
|
|
879
|
+
const allFlag$2 = Flag.boolean("all").pipe(Flag.withDescription("Traverse every page (requires --jsonl)"), Flag.withDefault(false));
|
|
880
|
+
const create$3 = Command.make("create", {
|
|
881
|
+
name: nameFlag,
|
|
882
|
+
subject: subjectFlag$2,
|
|
883
|
+
html: htmlFlag$2,
|
|
884
|
+
text: textFlag$2,
|
|
885
|
+
templateId: templateIdFlag$2,
|
|
886
|
+
templateSlug: templateSlugFlag$2,
|
|
887
|
+
templateData: templateDataFlag$2,
|
|
888
|
+
includeTags: includeTagsFlag,
|
|
889
|
+
excludeTags: excludeTagsFlag,
|
|
890
|
+
contactIds: contactIdsFlag,
|
|
891
|
+
unsubscribeGroupId: unsubscribeGroupIdFlag,
|
|
892
|
+
pageSize: pageSizeFlag,
|
|
893
|
+
trackOpens: trackOpensFlag$2,
|
|
894
|
+
trackClicks: trackClicksFlag$2,
|
|
895
|
+
dryRun: dryRunFlag$4,
|
|
896
|
+
json: jsonFlag
|
|
897
|
+
}).pipe(Command.withDescription("Create an email campaign targeting tags or explicit contacts"), Command.withExamples([{
|
|
898
|
+
command: "samva campaigns create --name \"Spring launch\" --subject \"We are live\" --html \"<h1>Hi</h1>\" --include-tags newsletter,customers",
|
|
899
|
+
description: "Campaign to tagged contacts"
|
|
900
|
+
}, {
|
|
901
|
+
command: "samva campaigns create --name \"VIP note\" --subject Hi --html \"<p>Hello</p>\" --text Hello --contact-ids c_1 --contact-ids c_2 --dry-run",
|
|
902
|
+
description: "Target explicit contacts and preview the payload"
|
|
903
|
+
}]), Command.withHandler(({ name, subject, html, text, templateId, templateSlug, templateData, includeTags, excludeTags, contactIds, unsubscribeGroupId, pageSize, trackOpens, trackClicks, dryRun, json }) => Effect.gen(function* () {
|
|
904
|
+
const htmlValue = Option.getOrUndefined(html);
|
|
905
|
+
const textValue = Option.getOrUndefined(text);
|
|
906
|
+
const templateIdValue = Option.getOrUndefined(templateId);
|
|
907
|
+
const templateSlugValue = Option.getOrUndefined(templateSlug);
|
|
908
|
+
const templateDataValue = yield* parseTemplateData$2(templateData);
|
|
909
|
+
if (templateIdValue && templateSlugValue) return yield* new SamvaCliError({ message: "Use either --template-id or --template-slug, not both." });
|
|
910
|
+
const templateRefValue = templateSlugValue ?? templateIdValue;
|
|
911
|
+
if (templateRefValue && (Option.isSome(subject) || htmlValue || textValue)) return yield* new SamvaCliError({ message: "Choose one content source: a template, or inline --subject with --html (and optional --text)." });
|
|
912
|
+
if (!templateRefValue && Option.isNone(subject)) return yield* new SamvaCliError({ message: "Provide --subject with inline --html, or reference a published template." });
|
|
913
|
+
if (!templateRefValue && !htmlValue) return yield* new SamvaCliError({ message: "Provide --html for inline email; --text is an optional fallback." });
|
|
914
|
+
const includeTagsValue = parseTagList(includeTags);
|
|
915
|
+
const excludeTagsValue = parseTagList(excludeTags);
|
|
916
|
+
const contactIdsValue = parseTagList(contactIds);
|
|
917
|
+
if (includeTagsValue.length === 0 && contactIdsValue.length === 0) return yield* new SamvaCliError({ message: "Provide an audience: at least one of --include-tags or --contact-ids (--exclude-tags only narrows an include set)." });
|
|
918
|
+
const payload = assembleCampaignCreatePayload({
|
|
919
|
+
name,
|
|
920
|
+
subject: Option.getOrUndefined(subject),
|
|
921
|
+
html: htmlValue,
|
|
922
|
+
text: textValue,
|
|
923
|
+
templateId: templateIdValue,
|
|
924
|
+
templateSlug: templateSlugValue,
|
|
925
|
+
templateData: templateDataValue,
|
|
926
|
+
includeTags: includeTagsValue,
|
|
927
|
+
excludeTags: excludeTagsValue,
|
|
928
|
+
contactIds: contactIdsValue,
|
|
929
|
+
unsubscribeGroupId: Option.getOrUndefined(unsubscribeGroupId),
|
|
930
|
+
pageSize: Option.getOrUndefined(pageSize),
|
|
931
|
+
tracking: Option.isSome(trackOpens) || Option.isSome(trackClicks) ? {
|
|
932
|
+
...Option.isSome(trackOpens) ? { opens: trackOpens.value === "on" } : {},
|
|
933
|
+
...Option.isSome(trackClicks) ? { clicks: trackClicks.value === "on" } : {}
|
|
934
|
+
} : void 0
|
|
935
|
+
});
|
|
936
|
+
if (dryRun) {
|
|
937
|
+
if (json) return yield* printJson({
|
|
938
|
+
dryRun: true,
|
|
939
|
+
request: payload
|
|
940
|
+
});
|
|
941
|
+
yield* Effect.logInfo("Dry run — no campaign created.\n");
|
|
942
|
+
yield* printFields([
|
|
943
|
+
["Name", name],
|
|
944
|
+
["Include tags", includeTagsValue.join(", ")],
|
|
945
|
+
["Exclude tags", excludeTagsValue.join(", ")],
|
|
946
|
+
["Contact ids", contactIdsValue.join(", ")]
|
|
947
|
+
]);
|
|
948
|
+
return yield* Console.log(`\nPayload:\n${truncateForDisplay(JSON.stringify(payload, null, 2))}`);
|
|
949
|
+
}
|
|
950
|
+
const client = yield* getClientProvider;
|
|
951
|
+
const data = yield* Campaigns.create(payload).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Failed to create campaign", error) })));
|
|
952
|
+
if (json) return yield* printJson(data);
|
|
953
|
+
yield* Effect.logInfo("Campaign created.\n");
|
|
954
|
+
yield* printFields([
|
|
955
|
+
["ID", data.id],
|
|
956
|
+
["Name", data.name],
|
|
957
|
+
["Channel", data.channel],
|
|
958
|
+
["Status", data.status]
|
|
959
|
+
]);
|
|
960
|
+
yield* Effect.logInfo(`\nCreate a run: samva campaigns runs create ${data.id}`);
|
|
961
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
962
|
+
const list$7 = Command.make("list", {
|
|
963
|
+
status: campaignStatusFlag,
|
|
964
|
+
page: pageFlag$2,
|
|
965
|
+
limit: limitFlag$2,
|
|
966
|
+
all: allFlag$2,
|
|
967
|
+
json: jsonFlag
|
|
968
|
+
}).pipe(Command.withDescription("List campaigns"), Command.withExamples([
|
|
969
|
+
{ command: "samva campaigns list" },
|
|
970
|
+
{ command: "samva campaigns list --status draft --json" },
|
|
971
|
+
{
|
|
972
|
+
command: "samva campaigns list --all --jsonl",
|
|
973
|
+
description: "Stream every page as JSONL"
|
|
974
|
+
}
|
|
975
|
+
]), Command.withHandler(({ status, page, limit, all, json }) => Effect.gen(function* () {
|
|
976
|
+
if (all && !isJsonLinesRequested()) return yield* new CliUsageError({
|
|
977
|
+
code: "all_requires_jsonl",
|
|
978
|
+
message: "--all is an unbounded traversal and requires --jsonl."
|
|
979
|
+
});
|
|
980
|
+
const pageValue = Option.getOrUndefined(page);
|
|
981
|
+
const limitValue = Option.getOrUndefined(limit);
|
|
982
|
+
const client = yield* getClientProvider;
|
|
983
|
+
const query = {
|
|
984
|
+
status: Option.getOrUndefined(status),
|
|
985
|
+
page: pageValue?.toString(),
|
|
986
|
+
limit: limitValue?.toString()
|
|
987
|
+
};
|
|
988
|
+
if (all) {
|
|
989
|
+
let nextPage = pageValue ?? 1;
|
|
990
|
+
for (;;) {
|
|
991
|
+
const data = yield* Campaigns.list({
|
|
992
|
+
...query,
|
|
993
|
+
page: nextPage.toString()
|
|
994
|
+
}).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not list campaigns", error) })));
|
|
995
|
+
for (const item of data.items) yield* printJsonLine(item);
|
|
996
|
+
if (nextPage >= data.pagination.totalPages || data.items.length === 0) return;
|
|
997
|
+
nextPage += 1;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
const data = yield* Campaigns.list(query).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not list campaigns", error) })));
|
|
1001
|
+
if (isJsonLinesRequested()) {
|
|
1002
|
+
for (const item of data.items) yield* printJsonLine(item);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
if (json) return yield* printJson(data);
|
|
1006
|
+
if (data.items.length === 0) return yield* Console.log("No campaigns yet. Create one with `samva campaigns create`.");
|
|
1007
|
+
for (const campaign of data.items) {
|
|
1008
|
+
yield* printFields([
|
|
1009
|
+
["ID", campaign.id],
|
|
1010
|
+
["Name", campaign.name],
|
|
1011
|
+
["Channel", campaign.channel],
|
|
1012
|
+
["Status", campaign.status]
|
|
1013
|
+
]);
|
|
1014
|
+
yield* Console.log("");
|
|
1015
|
+
}
|
|
1016
|
+
yield* Console.log(`Page ${String(data.pagination.page)} of ${String(data.pagination.totalPages)} (${String(data.pagination.total)} total)`);
|
|
1017
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
1018
|
+
const printCampaign = (campaign) => printFields([
|
|
1019
|
+
["ID", campaign.id],
|
|
1020
|
+
["Name", campaign.name],
|
|
1021
|
+
["Channel", campaign.channel],
|
|
1022
|
+
["Status", campaign.status],
|
|
1023
|
+
["Include tags", (campaign.audience.includeTags ?? []).join(", ")],
|
|
1024
|
+
["Exclude tags", (campaign.audience.excludeTags ?? []).join(", ")],
|
|
1025
|
+
["Contact ids", (campaign.audience.contactIds ?? []).join(", ")],
|
|
1026
|
+
["Created", campaign.createdAt],
|
|
1027
|
+
["Updated", campaign.updatedAt]
|
|
1028
|
+
]);
|
|
1029
|
+
const get$3 = Command.make("get", {
|
|
1030
|
+
id: Argument.string("id"),
|
|
1031
|
+
json: jsonFlag
|
|
1032
|
+
}).pipe(Command.withDescription("Show a campaign by id"), Command.withExamples([{ command: "samva campaigns get camp_abc123" }]), Command.withHandler(({ id, json }) => Effect.gen(function* () {
|
|
1033
|
+
const client = yield* getClientProvider;
|
|
1034
|
+
const data = yield* Campaigns.get({ id }).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not fetch campaign", error) })));
|
|
1035
|
+
if (json) return yield* printJson(data);
|
|
1036
|
+
yield* printCampaign(data);
|
|
1037
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
1038
|
+
const update$1 = Command.make("update", {
|
|
1039
|
+
id: Argument.string("id"),
|
|
1040
|
+
name: Flag.string("name").pipe(Flag.withDescription("New campaign name"), Flag.optional),
|
|
1041
|
+
subject: subjectFlag$2,
|
|
1042
|
+
html: htmlFlag$2,
|
|
1043
|
+
text: textFlag$2,
|
|
1044
|
+
templateId: templateIdFlag$2,
|
|
1045
|
+
templateSlug: templateSlugFlag$2,
|
|
1046
|
+
templateData: templateDataFlag$2,
|
|
1047
|
+
includeTags: includeTagsFlag,
|
|
1048
|
+
excludeTags: excludeTagsFlag,
|
|
1049
|
+
contactIds: contactIdsFlag,
|
|
1050
|
+
unsubscribeGroupId: unsubscribeGroupIdFlag,
|
|
1051
|
+
pageSize: pageSizeFlag,
|
|
1052
|
+
trackOpens: trackOpensFlag$2,
|
|
1053
|
+
trackClicks: trackClicksFlag$2,
|
|
1054
|
+
json: jsonFlag
|
|
1055
|
+
}).pipe(Command.withDescription("Update a draft campaign before its first run"), Command.withExamples([{ command: "samva campaigns update camp_abc123 --name \"August launch\"" }, {
|
|
1056
|
+
command: "samva campaigns update camp_abc123 --template-slug august-launch --include-tags newsletter",
|
|
1057
|
+
description: "Replace content and audience while the definition is still editable"
|
|
1058
|
+
}]), Command.withHandler(({ id, name, subject, html, text, templateId, templateSlug, templateData, includeTags, excludeTags, contactIds, unsubscribeGroupId, pageSize, trackOpens, trackClicks, json }) => Effect.gen(function* () {
|
|
1059
|
+
const htmlValue = Option.getOrUndefined(html);
|
|
1060
|
+
const textValue = Option.getOrUndefined(text);
|
|
1061
|
+
const templateIdValue = Option.getOrUndefined(templateId);
|
|
1062
|
+
const templateSlugValue = Option.getOrUndefined(templateSlug);
|
|
1063
|
+
if (templateIdValue && templateSlugValue) return yield* new SamvaCliError({ message: "Use either --template-id or --template-slug, not both." });
|
|
1064
|
+
const templateDataValue = yield* parseTemplateData$2(templateData);
|
|
1065
|
+
const templateRefValue = templateSlugValue ?? templateIdValue;
|
|
1066
|
+
const hasContentFlags = Option.isSome(subject) || Boolean(htmlValue || textValue || templateRefValue) || Option.isSome(templateData) || Option.isSome(trackOpens) || Option.isSome(trackClicks);
|
|
1067
|
+
if (templateRefValue && (Option.isSome(subject) || htmlValue || textValue)) return yield* new SamvaCliError({ message: "Choose one content source: a template, or inline --subject with --html (and optional --text)." });
|
|
1068
|
+
if (hasContentFlags && !templateRefValue && Option.isNone(subject)) return yield* new SamvaCliError({ message: "Provide --subject with inline --html, or reference a published template." });
|
|
1069
|
+
if (hasContentFlags && !templateRefValue && !htmlValue) return yield* new SamvaCliError({ message: "Provide --html for inline email; --text is an optional fallback." });
|
|
1070
|
+
const includeTagsValue = parseTagList(includeTags);
|
|
1071
|
+
const excludeTagsValue = parseTagList(excludeTags);
|
|
1072
|
+
const contactIdsValue = parseTagList(contactIds);
|
|
1073
|
+
const hasAudience = includeTagsValue.length > 0 || excludeTagsValue.length > 0 || contactIdsValue.length > 0;
|
|
1074
|
+
const hasPolicy = Option.isSome(unsubscribeGroupId) || Option.isSome(pageSize);
|
|
1075
|
+
const hasContent = hasContentFlags;
|
|
1076
|
+
const tracking = Option.isSome(trackOpens) || Option.isSome(trackClicks) ? {
|
|
1077
|
+
...Option.isSome(trackOpens) ? { opens: trackOpens.value === "on" } : {},
|
|
1078
|
+
...Option.isSome(trackClicks) ? { clicks: trackClicks.value === "on" } : {}
|
|
1079
|
+
} : void 0;
|
|
1080
|
+
if (hasAudience && includeTagsValue.length === 0 && contactIdsValue.length === 0) return yield* new SamvaCliError({ message: "A replacement audience requires --include-tags or --contact-ids; --exclude-tags only narrows it." });
|
|
1081
|
+
if (!Option.isSome(name) && !hasContent && !hasAudience && !hasPolicy) return yield* new SamvaCliError({ message: "Provide at least one field to update. Run `samva campaigns update --help`." });
|
|
1082
|
+
const payload = {
|
|
1083
|
+
name: Option.getOrUndefined(name),
|
|
1084
|
+
content: hasContent ? {
|
|
1085
|
+
channel: "email",
|
|
1086
|
+
email: {
|
|
1087
|
+
subject: Option.getOrUndefined(subject),
|
|
1088
|
+
html: htmlValue,
|
|
1089
|
+
text: textValue,
|
|
1090
|
+
templateId: templateIdValue,
|
|
1091
|
+
templateSlug: templateSlugValue,
|
|
1092
|
+
templateData: templateDataValue,
|
|
1093
|
+
...tracking === void 0 ? {} : { tracking }
|
|
1094
|
+
}
|
|
1095
|
+
} : void 0,
|
|
1096
|
+
audience: hasAudience ? {
|
|
1097
|
+
includeTags: includeTagsValue.length > 0 ? includeTagsValue : void 0,
|
|
1098
|
+
excludeTags: excludeTagsValue.length > 0 ? excludeTagsValue : void 0,
|
|
1099
|
+
contactIds: contactIdsValue.length > 0 ? contactIdsValue : void 0
|
|
1100
|
+
} : void 0,
|
|
1101
|
+
policy: hasPolicy ? {
|
|
1102
|
+
unsubscribeGroupId: Option.getOrUndefined(unsubscribeGroupId),
|
|
1103
|
+
pageSize: Option.getOrUndefined(pageSize)
|
|
1104
|
+
} : void 0
|
|
1105
|
+
};
|
|
1106
|
+
const client = yield* getClientProvider;
|
|
1107
|
+
const data = yield* Campaigns.update({
|
|
1108
|
+
id,
|
|
1109
|
+
...payload
|
|
1110
|
+
}).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not update campaign", error) })));
|
|
1111
|
+
if (json) return yield* printJson(data);
|
|
1112
|
+
yield* Effect.logInfo("Campaign updated. Definitions freeze after the first run.\n");
|
|
1113
|
+
yield* printCampaign(data);
|
|
1114
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
1115
|
+
const archive = Command.make("archive", {
|
|
1116
|
+
id: Argument.string("id"),
|
|
1117
|
+
dryRun: Flag.boolean("dry-run").pipe(Flag.withDefault(false)),
|
|
1118
|
+
yes: Flag.boolean("yes").pipe(Flag.withDefault(false)),
|
|
1119
|
+
json: jsonFlag
|
|
1120
|
+
}).pipe(Command.withDescription("Archive a campaign"), Command.withExamples([{ command: "samva campaigns archive camp_abc123 --dry-run" }, { command: "samva campaigns archive camp_abc123 --yes" }]), Command.withHandler(({ id, dryRun, yes, json }) => Effect.gen(function* () {
|
|
1121
|
+
if (dryRun) return yield* json ? printJson({
|
|
1122
|
+
dryRun: true,
|
|
1123
|
+
action: "campaigns.archive",
|
|
1124
|
+
id
|
|
1125
|
+
}) : Console.log(`[dry-run] Would archive campaign ${id}.`);
|
|
1126
|
+
yield* confirmDestructive({
|
|
1127
|
+
yes,
|
|
1128
|
+
json,
|
|
1129
|
+
message: `Archive campaign ${id}? It will no longer be eligible to run.`,
|
|
1130
|
+
nonInteractiveMessage: "Campaign archival requires --yes in non-interactive mode. Preview with --dry-run."
|
|
1131
|
+
});
|
|
1132
|
+
const client = yield* getClientProvider;
|
|
1133
|
+
const data = yield* Campaigns.archive({ id }).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not archive campaign", error) })));
|
|
1134
|
+
if (json) return yield* printJson(data);
|
|
1135
|
+
yield* Effect.logInfo("Campaign archived.\n");
|
|
1136
|
+
yield* printFields([
|
|
1137
|
+
["ID", data.id],
|
|
1138
|
+
["Name", data.name],
|
|
1139
|
+
["Status", data.status]
|
|
1140
|
+
]);
|
|
1141
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
1142
|
+
const printRun = (run) => printFields([
|
|
1143
|
+
["Run ID", run.id],
|
|
1144
|
+
["Campaign", run.campaignId],
|
|
1145
|
+
["Status", run.status],
|
|
1146
|
+
["Scheduled for", run.scheduledFor],
|
|
1147
|
+
["Started", run.startedAt],
|
|
1148
|
+
["Completed", run.completedAt],
|
|
1149
|
+
["Recipients", String(run.totalRecipients)],
|
|
1150
|
+
["Dispatched", String(run.dispatchedCount)],
|
|
1151
|
+
["Failed", String(run.failedCount)],
|
|
1152
|
+
["Skipped", String(run.skippedCount)],
|
|
1153
|
+
["Failure", run.failureReason]
|
|
1154
|
+
]);
|
|
1155
|
+
const runCreate = Command.make("create", {
|
|
1156
|
+
campaignId: Argument.string("campaignId"),
|
|
1157
|
+
at: atFlag$1,
|
|
1158
|
+
idempotencyKey: idempotencyKeyFlag$2,
|
|
1159
|
+
dryRun: dryRunFlag$4,
|
|
1160
|
+
json: jsonFlag
|
|
1161
|
+
}).pipe(Command.withDescription("Create a run for a campaign (starts immediately without --at)"), Command.withExamples([{
|
|
1162
|
+
command: "samva campaigns runs create camp_abc123",
|
|
1163
|
+
description: "Start now"
|
|
1164
|
+
}, {
|
|
1165
|
+
command: "samva campaigns runs create camp_abc123 --at 2026-08-01T09:00:00Z",
|
|
1166
|
+
description: "Start at a future time"
|
|
1167
|
+
}]), Command.withHandler(({ campaignId, at, idempotencyKey, dryRun, json }) => Effect.gen(function* () {
|
|
1168
|
+
const atValue = Option.getOrUndefined(at);
|
|
1169
|
+
if (atValue !== void 0 && !isAbsoluteInstant$1(atValue)) return yield* new SamvaCliError({ message: "--at must be an absolute ISO 8601 instant with Z or an offset, e.g. 2026-08-01T09:00:00Z." });
|
|
1170
|
+
const idempotencyKeyValue = Option.getOrUndefined(idempotencyKey);
|
|
1171
|
+
if (dryRun) return yield* json ? printJson({
|
|
1172
|
+
dryRun: true,
|
|
1173
|
+
action: "campaigns.runs.create",
|
|
1174
|
+
campaignId,
|
|
1175
|
+
scheduledFor: atValue,
|
|
1176
|
+
idempotencyKey: idempotencyKeyValue
|
|
1177
|
+
}) : Console.log(`[dry-run] Would create a run for campaign ${campaignId}${atValue ? ` at ${atValue}` : " now"}.`);
|
|
1178
|
+
const client = yield* getClientProvider;
|
|
1179
|
+
const data = yield* Campaigns.scheduleRun({
|
|
1180
|
+
id: campaignId,
|
|
1181
|
+
scheduledFor: atValue === void 0 ? void 0 : new Date(atValue),
|
|
1182
|
+
idempotencyKey: idempotencyKeyValue
|
|
1183
|
+
}).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not schedule run", error) })));
|
|
1184
|
+
if (json) return yield* printJson(data);
|
|
1185
|
+
yield* Effect.logInfo("Run scheduled.\n");
|
|
1186
|
+
yield* printRun(data);
|
|
1187
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
1188
|
+
const runList = Command.make("list", {
|
|
1189
|
+
campaignId: Argument.string("campaignId"),
|
|
1190
|
+
status: runStatusFlag,
|
|
1191
|
+
page: pageFlag$2,
|
|
1192
|
+
limit: limitFlag$2,
|
|
1193
|
+
all: allFlag$2,
|
|
1194
|
+
json: jsonFlag
|
|
1195
|
+
}).pipe(Command.withDescription("List runs for a campaign"), Command.withExamples([{ command: "samva campaigns runs list camp_abc123" }]), Command.withHandler(({ campaignId, status, page, limit, all, json }) => Effect.gen(function* () {
|
|
1196
|
+
if (all && !isJsonLinesRequested()) return yield* new CliUsageError({
|
|
1197
|
+
code: "all_requires_jsonl",
|
|
1198
|
+
message: "--all is an unbounded traversal and requires --jsonl."
|
|
1199
|
+
});
|
|
1200
|
+
const pageValue = Option.getOrUndefined(page);
|
|
1201
|
+
const limitValue = Option.getOrUndefined(limit);
|
|
1202
|
+
const client = yield* getClientProvider;
|
|
1203
|
+
const query = {
|
|
1204
|
+
id: campaignId,
|
|
1205
|
+
status: Option.getOrUndefined(status),
|
|
1206
|
+
page: pageValue?.toString(),
|
|
1207
|
+
limit: limitValue?.toString()
|
|
1208
|
+
};
|
|
1209
|
+
if (all) {
|
|
1210
|
+
let nextPage = pageValue ?? 1;
|
|
1211
|
+
for (;;) {
|
|
1212
|
+
const data = yield* Campaigns.listRuns({
|
|
1213
|
+
...query,
|
|
1214
|
+
page: nextPage.toString()
|
|
1215
|
+
}).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not list runs", error) })));
|
|
1216
|
+
for (const item of data.items) yield* printJsonLine(item);
|
|
1217
|
+
if (nextPage >= data.pagination.totalPages || data.items.length === 0) return;
|
|
1218
|
+
nextPage += 1;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
const data = yield* Campaigns.listRuns(query).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not list runs", error) })));
|
|
1222
|
+
if (isJsonLinesRequested()) {
|
|
1223
|
+
for (const item of data.items) yield* printJsonLine(item);
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
if (json) return yield* printJson(data);
|
|
1227
|
+
if (data.items.length === 0) return yield* Console.log("No runs yet. Create one with `samva campaigns runs create`.");
|
|
1228
|
+
for (const run of data.items) {
|
|
1229
|
+
yield* printFields([
|
|
1230
|
+
["Run ID", run.id],
|
|
1231
|
+
["Status", run.status],
|
|
1232
|
+
["Scheduled for", run.scheduledFor],
|
|
1233
|
+
["Dispatched", String(run.dispatchedCount)],
|
|
1234
|
+
["Failed", String(run.failedCount)],
|
|
1235
|
+
["Skipped", String(run.skippedCount)]
|
|
1236
|
+
]);
|
|
1237
|
+
yield* Console.log("");
|
|
1238
|
+
}
|
|
1239
|
+
yield* Console.log(`Page ${String(data.pagination.page)} of ${String(data.pagination.totalPages)} (${String(data.pagination.total)} total)`);
|
|
1240
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
1241
|
+
const runGet = Command.make("get", {
|
|
1242
|
+
campaignId: Argument.string("campaignId"),
|
|
1243
|
+
runId: Argument.string("runId"),
|
|
1244
|
+
json: jsonFlag
|
|
1245
|
+
}).pipe(Command.withDescription("Show a campaign run"), Command.withExamples([{ command: "samva campaigns runs get camp_abc123 run_def456" }]), Command.withHandler(({ campaignId, runId, json }) => Effect.gen(function* () {
|
|
1246
|
+
const client = yield* getClientProvider;
|
|
1247
|
+
const data = yield* Campaigns.getRun({
|
|
1248
|
+
id: campaignId,
|
|
1249
|
+
runId
|
|
1250
|
+
}).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not fetch run", error) })));
|
|
1251
|
+
if (json) return yield* printJson(data);
|
|
1252
|
+
yield* printRun(data);
|
|
1253
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
1254
|
+
const runPause = Command.make("pause", {
|
|
1255
|
+
campaignId: Argument.string("campaignId"),
|
|
1256
|
+
runId: Argument.string("runId"),
|
|
1257
|
+
dryRun: dryRunFlag$4,
|
|
1258
|
+
json: jsonFlag
|
|
1259
|
+
}).pipe(Command.withDescription("Pause a running campaign run"), Command.withExamples([{ command: "samva campaigns runs pause camp_abc123 run_def456" }]), Command.withHandler(({ campaignId, runId, dryRun, json }) => Effect.gen(function* () {
|
|
1260
|
+
if (dryRun) return yield* json ? printJson({
|
|
1261
|
+
dryRun: true,
|
|
1262
|
+
action: "campaigns.runs.pause",
|
|
1263
|
+
campaignId,
|
|
1264
|
+
runId
|
|
1265
|
+
}) : Console.log(`[dry-run] Would pause campaign run ${runId}.`);
|
|
1266
|
+
const client = yield* getClientProvider;
|
|
1267
|
+
const data = yield* Campaigns.pauseRun({
|
|
1268
|
+
id: campaignId,
|
|
1269
|
+
runId
|
|
1270
|
+
}).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not pause run", error) })));
|
|
1271
|
+
if (json) return yield* printJson(data);
|
|
1272
|
+
yield* Effect.logInfo("Run paused.\n");
|
|
1273
|
+
yield* printRun(data.item);
|
|
1274
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
1275
|
+
const runResume = Command.make("resume", {
|
|
1276
|
+
campaignId: Argument.string("campaignId"),
|
|
1277
|
+
runId: Argument.string("runId"),
|
|
1278
|
+
dryRun: dryRunFlag$4,
|
|
1279
|
+
json: jsonFlag
|
|
1280
|
+
}).pipe(Command.withDescription("Resume a paused campaign run"), Command.withExamples([{ command: "samva campaigns runs resume camp_abc123 run_def456" }]), Command.withHandler(({ campaignId, runId, dryRun, json }) => Effect.gen(function* () {
|
|
1281
|
+
if (dryRun) return yield* json ? printJson({
|
|
1282
|
+
dryRun: true,
|
|
1283
|
+
action: "campaigns.runs.resume",
|
|
1284
|
+
campaignId,
|
|
1285
|
+
runId
|
|
1286
|
+
}) : Console.log(`[dry-run] Would resume campaign run ${runId}.`);
|
|
1287
|
+
const client = yield* getClientProvider;
|
|
1288
|
+
const data = yield* Campaigns.resumeRun({
|
|
1289
|
+
id: campaignId,
|
|
1290
|
+
runId
|
|
1291
|
+
}).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not resume run", error) })));
|
|
1292
|
+
if (json) return yield* printJson(data);
|
|
1293
|
+
yield* Effect.logInfo("Run resumed.\n");
|
|
1294
|
+
yield* printRun(data.item);
|
|
1295
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
1296
|
+
const runCancel = Command.make("cancel", {
|
|
1297
|
+
campaignId: Argument.string("campaignId"),
|
|
1298
|
+
runId: Argument.string("runId"),
|
|
1299
|
+
dryRun: Flag.boolean("dry-run").pipe(Flag.withDefault(false)),
|
|
1300
|
+
yes: Flag.boolean("yes").pipe(Flag.withDefault(false)),
|
|
1301
|
+
json: jsonFlag
|
|
1302
|
+
}).pipe(Command.withDescription("Cancel a campaign run (best effort once dispatch has started; terminal when scheduled)"), Command.withExamples([{ command: "samva campaigns runs cancel camp_abc123 run_def456 --dry-run" }, { command: "samva campaigns runs cancel camp_abc123 run_def456 --yes" }]), Command.withHandler(({ campaignId, runId, dryRun, yes, json }) => Effect.gen(function* () {
|
|
1303
|
+
if (dryRun) return yield* json ? printJson({
|
|
1304
|
+
dryRun: true,
|
|
1305
|
+
action: "campaigns.runs.cancel",
|
|
1306
|
+
campaignId,
|
|
1307
|
+
runId
|
|
1308
|
+
}) : Console.log(`[dry-run] Would cancel campaign run ${runId}.`);
|
|
1309
|
+
yield* confirmDestructive({
|
|
1310
|
+
yes,
|
|
1311
|
+
json,
|
|
1312
|
+
message: `Cancel campaign run ${runId}? Dispatches already in progress may still complete.`,
|
|
1313
|
+
nonInteractiveMessage: "Campaign run cancellation requires --yes in non-interactive mode. Preview with --dry-run."
|
|
1314
|
+
});
|
|
1315
|
+
const client = yield* getClientProvider;
|
|
1316
|
+
const data = yield* Campaigns.cancelRun({
|
|
1317
|
+
id: campaignId,
|
|
1318
|
+
runId
|
|
1319
|
+
}).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not cancel run", error) })));
|
|
1320
|
+
if (json) return yield* printJson(data);
|
|
1321
|
+
yield* Effect.logInfo("Run cancelled.\n");
|
|
1322
|
+
yield* printRun(data.item);
|
|
1323
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
1324
|
+
const runRecipients = Command.make("recipients", {
|
|
1325
|
+
campaignId: Argument.string("campaignId"),
|
|
1326
|
+
runId: Argument.string("runId"),
|
|
1327
|
+
status: recipientStatusFlag,
|
|
1328
|
+
page: pageFlag$2,
|
|
1329
|
+
limit: limitFlag$2,
|
|
1330
|
+
all: allFlag$2,
|
|
1331
|
+
json: jsonFlag
|
|
1332
|
+
}).pipe(Command.withDescription("List recipients for a campaign run"), Command.withExamples([{ command: "samva campaigns runs recipients camp_abc123 run_def456" }, {
|
|
1333
|
+
command: "samva campaigns runs recipients camp_abc123 run_def456 --status failed",
|
|
1334
|
+
description: "Only failed recipients"
|
|
1335
|
+
}]), Command.withHandler(({ campaignId, runId, status, page, limit, all, json }) => Effect.gen(function* () {
|
|
1336
|
+
if (all && !isJsonLinesRequested()) return yield* new CliUsageError({
|
|
1337
|
+
code: "all_requires_jsonl",
|
|
1338
|
+
message: "--all is an unbounded traversal and requires --jsonl."
|
|
1339
|
+
});
|
|
1340
|
+
const pageValue = Option.getOrUndefined(page);
|
|
1341
|
+
const limitValue = Option.getOrUndefined(limit);
|
|
1342
|
+
const client = yield* getClientProvider;
|
|
1343
|
+
const query = {
|
|
1344
|
+
id: campaignId,
|
|
1345
|
+
runId,
|
|
1346
|
+
status: Option.getOrUndefined(status),
|
|
1347
|
+
page: pageValue?.toString(),
|
|
1348
|
+
limit: limitValue?.toString()
|
|
1349
|
+
};
|
|
1350
|
+
if (all) {
|
|
1351
|
+
let nextPage = pageValue ?? 1;
|
|
1352
|
+
for (;;) {
|
|
1353
|
+
const data = yield* Campaigns.listRecipients({
|
|
1354
|
+
...query,
|
|
1355
|
+
page: nextPage.toString()
|
|
1356
|
+
}).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not list recipients", error) })));
|
|
1357
|
+
for (const item of data.items) yield* printJsonLine(item);
|
|
1358
|
+
if (nextPage >= data.pagination.totalPages || data.items.length === 0) return;
|
|
1359
|
+
nextPage += 1;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
const data = yield* Campaigns.listRecipients(query).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not list recipients", error) })));
|
|
1363
|
+
if (isJsonLinesRequested()) {
|
|
1364
|
+
for (const item of data.items) yield* printJsonLine(item);
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
if (json) return yield* printJson(data);
|
|
1368
|
+
if (data.items.length === 0) return yield* Console.log("No recipients for this run.");
|
|
1369
|
+
for (const recipient of data.items) {
|
|
1370
|
+
yield* printFields([
|
|
1371
|
+
["Contact", recipient.contactId],
|
|
1372
|
+
["Status", recipient.status],
|
|
1373
|
+
["Message ID", recipient.messageId],
|
|
1374
|
+
["Skipped", recipient.skippedReason],
|
|
1375
|
+
["Failure", recipient.failureReason]
|
|
1376
|
+
]);
|
|
1377
|
+
yield* Console.log("");
|
|
1378
|
+
}
|
|
1379
|
+
yield* Console.log(`Page ${String(data.pagination.page)} of ${String(data.pagination.totalPages)} (${String(data.pagination.total)} total)`);
|
|
1380
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
1381
|
+
const runsCommand = Command.make("runs").pipe(Command.withDescription("Manage campaign runs"), Command.withExamples([{
|
|
1382
|
+
command: "samva campaigns runs create camp_abc123",
|
|
1383
|
+
description: "Start a run now"
|
|
1384
|
+
}, {
|
|
1385
|
+
command: "samva campaigns runs list camp_abc123",
|
|
1386
|
+
description: "List runs"
|
|
1387
|
+
}]), Command.withSubcommands([
|
|
1388
|
+
runCreate,
|
|
1389
|
+
runList,
|
|
1390
|
+
runGet,
|
|
1391
|
+
runPause,
|
|
1392
|
+
runResume,
|
|
1393
|
+
runCancel,
|
|
1394
|
+
runRecipients
|
|
1395
|
+
]));
|
|
1396
|
+
const campaignsCommand = Command.make("campaigns").pipe(Command.withDescription("Create and operate audience-targeted campaigns"), Command.withExamples([{
|
|
1397
|
+
command: "samva campaigns create --name \"Spring launch\" --subject Hi --html \"<h1>Hi</h1>\" --include-tags newsletter",
|
|
1398
|
+
description: "Create a campaign"
|
|
1399
|
+
}, {
|
|
1400
|
+
command: "samva campaigns runs create camp_abc123",
|
|
1401
|
+
description: "Create a run"
|
|
1402
|
+
}]), Command.withSubcommands([
|
|
1403
|
+
create$3,
|
|
1404
|
+
list$7,
|
|
1405
|
+
get$3,
|
|
1406
|
+
update$1,
|
|
1407
|
+
archive,
|
|
1408
|
+
runsCommand
|
|
1409
|
+
]));
|
|
1410
|
+
//#endregion
|
|
1411
|
+
//#region src/commands/design.ts
|
|
1412
|
+
const fileFlag = Flag.file("file").pipe(Flag.withDescription("Path to the Tailwind-v4 @theme CSS file"), Flag.withDefault("theme.css"));
|
|
1413
|
+
const dryRunFlag$3 = Flag.boolean("dry-run").pipe(Flag.withDescription("Preview without updating the API (theme push may read current tokens)"), Flag.withDefault(false));
|
|
1414
|
+
const toMarkupFontToken = (token) => ({
|
|
1415
|
+
family: token.family,
|
|
1416
|
+
fallback: token.fallback,
|
|
1417
|
+
faces: token.faces == null ? void 0 : token.faces.map((face) => ({
|
|
1418
|
+
weight: face.weight,
|
|
1419
|
+
style: face.style,
|
|
1420
|
+
src: face.src,
|
|
1421
|
+
unicodeRange: face.unicodeRange ?? void 0
|
|
1422
|
+
}))
|
|
1423
|
+
});
|
|
1424
|
+
const toMarkupFontFamilies = (fontFamilies) => {
|
|
1425
|
+
const result = {};
|
|
1426
|
+
for (const [name, token] of Object.entries(fontFamilies)) result[name] = toMarkupFontToken(token);
|
|
1427
|
+
return result;
|
|
1428
|
+
};
|
|
1429
|
+
/**
|
|
1430
|
+
* The GET response folds the persisted font tokens onto the compiler's inline
|
|
1431
|
+
* stack string (the same `flattenFontTokens` transform apps/api and the web
|
|
1432
|
+
* canvas share), and every other namespace's `null` onto `undefined` so the
|
|
1433
|
+
* printer sees the ParsedTheme shape it expects.
|
|
1434
|
+
*/
|
|
1435
|
+
const fromServerTheme = (theme) => ({
|
|
1436
|
+
colors: theme.colors ?? void 0,
|
|
1437
|
+
colorsDark: theme.colorsDark ?? void 0,
|
|
1438
|
+
radiusPx: theme.radiusPx ?? void 0,
|
|
1439
|
+
fontFamilies: theme.fontFamilies == null ? void 0 : flattenFontTokens(toMarkupFontFamilies(theme.fontFamilies)).fontFamilies,
|
|
1440
|
+
fontWeights: theme.fontWeights ?? void 0,
|
|
1441
|
+
text: theme.text ?? void 0,
|
|
1442
|
+
leading: theme.leading ?? void 0,
|
|
1443
|
+
spacingPx: theme.spacingPx ?? void 0,
|
|
1444
|
+
containerPx: theme.containerPx ?? void 0,
|
|
1445
|
+
tracking: theme.tracking ?? void 0
|
|
1446
|
+
});
|
|
1447
|
+
const FALLBACK_STACK_DEFAULT = "sans-serif";
|
|
1448
|
+
const unquote = (value) => value.startsWith("'") && value.endsWith("'") ? value.slice(1, -1) : value;
|
|
1449
|
+
/** Split a flattened inline stack (`'Family', fallback, generic`) into the
|
|
1450
|
+
* leading family (quotes stripped) and the remaining fallback, for building a
|
|
1451
|
+
* system font token from a `--font-*` line that doesn't match any existing
|
|
1452
|
+
* server-hosted token. */
|
|
1453
|
+
const splitFontStack = (stack) => {
|
|
1454
|
+
const commaIndex = stack.indexOf(",");
|
|
1455
|
+
if (commaIndex === -1) return {
|
|
1456
|
+
family: unquote(stack.trim()),
|
|
1457
|
+
fallback: FALLBACK_STACK_DEFAULT
|
|
1458
|
+
};
|
|
1459
|
+
const family = unquote(stack.slice(0, commaIndex).trim());
|
|
1460
|
+
const fallback = stack.slice(commaIndex + 1).trim();
|
|
1461
|
+
return {
|
|
1462
|
+
family,
|
|
1463
|
+
fallback: fallback.length > 0 ? fallback : FALLBACK_STACK_DEFAULT
|
|
1464
|
+
};
|
|
1465
|
+
};
|
|
1466
|
+
/**
|
|
1467
|
+
* Resolve the PUT payload's `fontFamilies` from the parsed CSS against the
|
|
1468
|
+
* org's current server tokens. A `--font-*` line whose flattened value is
|
|
1469
|
+
* unchanged from the server's keeps that server token verbatim (preserving a
|
|
1470
|
+
* hosted google/custom token's faces); a new or edited line becomes a
|
|
1471
|
+
* `source: "system"` token built from the stack text. Names absent from the
|
|
1472
|
+
* parsed CSS are dropped — full-replace, matching every other namespace.
|
|
1473
|
+
*/
|
|
1474
|
+
const buildPushFontFamilies = (parsedFontFamilies, serverFontFamilies) => {
|
|
1475
|
+
if (parsedFontFamilies === void 0) return {
|
|
1476
|
+
fontFamilies: void 0,
|
|
1477
|
+
notices: []
|
|
1478
|
+
};
|
|
1479
|
+
const serverFlattened = serverFontFamilies === void 0 ? {} : flattenFontTokens(toMarkupFontFamilies(serverFontFamilies)).fontFamilies;
|
|
1480
|
+
const notices = [];
|
|
1481
|
+
const result = {};
|
|
1482
|
+
for (const [name, stack] of Object.entries(parsedFontFamilies)) {
|
|
1483
|
+
const serverToken = serverFontFamilies?.[name];
|
|
1484
|
+
if (serverToken !== void 0 && serverFlattened[name] === stack) {
|
|
1485
|
+
result[name] = serverToken;
|
|
1486
|
+
continue;
|
|
1487
|
+
}
|
|
1488
|
+
if (serverToken !== void 0 && serverToken.source !== "system") notices.push(`Editing --font-${name} in CSS converts it to a system font stack; use the dashboard font picker for hosted/Google fonts.`);
|
|
1489
|
+
const { family, fallback } = splitFontStack(stack);
|
|
1490
|
+
result[name] = {
|
|
1491
|
+
source: "system",
|
|
1492
|
+
family,
|
|
1493
|
+
fallback
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
return {
|
|
1497
|
+
fontFamilies: result,
|
|
1498
|
+
notices
|
|
1499
|
+
};
|
|
1500
|
+
};
|
|
1501
|
+
/** Fold ParsedTheme onto the persisted shape for the PUT payload. `fontFamilies`
|
|
1502
|
+
* comes from `buildPushFontFamilies` (the parsed stack strings alone can't
|
|
1503
|
+
* recover a hosted token's source/faces). */
|
|
1504
|
+
const toServerTheme = (parsed, fontFamilies) => {
|
|
1505
|
+
const tracking = parsed.tracking ?? parsed.trackingEm;
|
|
1506
|
+
return {
|
|
1507
|
+
...parsed.colors === void 0 ? {} : { colors: parsed.colors },
|
|
1508
|
+
...parsed.colorsDark === void 0 ? {} : { colorsDark: parsed.colorsDark },
|
|
1509
|
+
...parsed.radiusPx === void 0 ? {} : { radiusPx: parsed.radiusPx },
|
|
1510
|
+
...fontFamilies === void 0 ? {} : { fontFamilies },
|
|
1511
|
+
...parsed.fontWeights === void 0 ? {} : { fontWeights: parsed.fontWeights },
|
|
1512
|
+
...parsed.text === void 0 ? {} : { text: parsed.text },
|
|
1513
|
+
...parsed.leading === void 0 ? {} : { leading: parsed.leading },
|
|
1514
|
+
...parsed.spacingPx === void 0 ? {} : { spacingPx: parsed.spacingPx },
|
|
1515
|
+
...parsed.containerPx === void 0 ? {} : { containerPx: parsed.containerPx },
|
|
1516
|
+
...tracking === void 0 ? {} : { tracking }
|
|
1517
|
+
};
|
|
1518
|
+
};
|
|
1519
|
+
const pull$1 = Command.make("pull", {
|
|
1520
|
+
file: fileFlag,
|
|
1521
|
+
dryRun: dryRunFlag$3,
|
|
1522
|
+
json: jsonFlag
|
|
1523
|
+
}).pipe(Command.withDescription("Write the org's email theme to a Tailwind-v4 @theme CSS file"), Command.withExamples([{
|
|
1524
|
+
command: "samva email design pull",
|
|
1525
|
+
description: "Write the org theme to theme.css"
|
|
1526
|
+
}, {
|
|
1527
|
+
command: "samva email design pull --file emails/theme.css",
|
|
1528
|
+
description: "Write the org theme to a specific path"
|
|
1529
|
+
}]), Command.withHandler(({ file, dryRun, json }) => Effect.gen(function* () {
|
|
1530
|
+
const target = resolve(file);
|
|
1531
|
+
if (dryRun) return yield* json ? printJson({
|
|
1532
|
+
dryRun: true,
|
|
1533
|
+
action: "theme.pull",
|
|
1534
|
+
file: target
|
|
1535
|
+
}) : Console.log(`[dry-run] Would write the org theme to ${target}.`);
|
|
1536
|
+
const client = yield* getClientProvider;
|
|
1537
|
+
const theme = (yield* Design.get().pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not read design", error) })))).design?.theme ?? null;
|
|
1538
|
+
if (theme === null) {
|
|
1539
|
+
if (json) return yield* printJson({
|
|
1540
|
+
action: "design.pull",
|
|
1541
|
+
file: target,
|
|
1542
|
+
theme: null,
|
|
1543
|
+
written: false
|
|
1544
|
+
});
|
|
1545
|
+
return yield* Console.log("This org has no configured theme yet. Author one and run `samva email design push`.");
|
|
1546
|
+
}
|
|
1547
|
+
const css = printThemeCss(fromServerTheme(theme));
|
|
1548
|
+
yield* Effect.tryPromise({
|
|
1549
|
+
try: async () => {
|
|
1550
|
+
await mkdir(dirname(target), { recursive: true });
|
|
1551
|
+
await writeFile(target, css, "utf8");
|
|
1552
|
+
},
|
|
1553
|
+
catch: (cause) => new SamvaCliError({ message: `Could not write ${target}: ${String(cause)}` })
|
|
1554
|
+
});
|
|
1555
|
+
if (json) return yield* printJson({
|
|
1556
|
+
action: "design.pull",
|
|
1557
|
+
file: target,
|
|
1558
|
+
theme,
|
|
1559
|
+
written: true
|
|
1560
|
+
});
|
|
1561
|
+
yield* Effect.logInfo(`Wrote ${target}`);
|
|
1562
|
+
yield* Console.log(`\n${css}`);
|
|
1563
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
1564
|
+
const push$1 = Command.make("push", {
|
|
1565
|
+
file: fileFlag,
|
|
1566
|
+
dryRun: dryRunFlag$3,
|
|
1567
|
+
json: jsonFlag
|
|
1568
|
+
}).pipe(Command.withDescription("Parse a @theme CSS file and set it as the org's email theme"), Command.withExamples([{
|
|
1569
|
+
command: "samva email design push",
|
|
1570
|
+
description: "Parse theme.css and set it as the org theme"
|
|
1571
|
+
}, {
|
|
1572
|
+
command: "samva email design push --dry-run",
|
|
1573
|
+
description: "Preview without updating the org theme (may read current tokens)"
|
|
1574
|
+
}]), Command.withHandler(({ file, dryRun, json }) => Effect.gen(function* () {
|
|
1575
|
+
const target = resolve(file);
|
|
1576
|
+
const css = yield* Effect.tryPromise({
|
|
1577
|
+
try: () => readFile(target, "utf8"),
|
|
1578
|
+
catch: () => new SamvaCliError({ message: `Could not read ${target}. Run \`samva email design pull\` first, or point --file at your @theme CSS.` })
|
|
1579
|
+
});
|
|
1580
|
+
const parsed = parseThemeCss(css);
|
|
1581
|
+
if (Result.isFailure(parsed)) {
|
|
1582
|
+
const diagnostics = parsed.failure;
|
|
1583
|
+
if (json) yield* printJson({
|
|
1584
|
+
action: "theme.push",
|
|
1585
|
+
file: target,
|
|
1586
|
+
ok: false,
|
|
1587
|
+
diagnostics: diagnostics.map((diagnostic) => ({
|
|
1588
|
+
code: diagnostic.code,
|
|
1589
|
+
message: diagnostic.message,
|
|
1590
|
+
span: diagnostic.span
|
|
1591
|
+
}))
|
|
1592
|
+
});
|
|
1593
|
+
else {
|
|
1594
|
+
yield* Effect.logWarning(`${target} has ${diagnostics.length} theme error(s):\n`);
|
|
1595
|
+
for (const diagnostic of diagnostics) yield* Effect.logWarning(` ✗ ${formatDiagnostic(css, diagnostic)}`);
|
|
1596
|
+
}
|
|
1597
|
+
return yield* new SamvaCliError({ message: `Theme not pushed — fix ${diagnostics.length} diagnostic(s) in ${target}.` });
|
|
1598
|
+
}
|
|
1599
|
+
const client = yield* getClientProvider;
|
|
1600
|
+
const current = yield* Design.get().pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not read design", error) })));
|
|
1601
|
+
const { fontFamilies, notices } = buildPushFontFamilies(parsed.success.fontFamilies, current.design?.theme?.fontFamilies ?? void 0);
|
|
1602
|
+
for (const notice of notices) yield* Effect.logWarning(notice);
|
|
1603
|
+
const theme = toServerTheme(parsed.success, fontFamilies);
|
|
1604
|
+
const design = {
|
|
1605
|
+
...current.design,
|
|
1606
|
+
theme
|
|
1607
|
+
};
|
|
1608
|
+
if (dryRun) {
|
|
1609
|
+
if (json) return yield* printJson({
|
|
1610
|
+
action: "design.push",
|
|
1611
|
+
file: target,
|
|
1612
|
+
dryRun: true,
|
|
1613
|
+
theme
|
|
1614
|
+
});
|
|
1615
|
+
yield* Effect.logInfo(`Parsed ${target}`);
|
|
1616
|
+
return yield* Console.log(`\n${truncateForDisplay(JSON.stringify(theme, null, 2))}`);
|
|
1617
|
+
}
|
|
1618
|
+
yield* Design.update({ design }).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not set design", error) })));
|
|
1619
|
+
if (json) return yield* printJson({
|
|
1620
|
+
action: "design.push",
|
|
1621
|
+
file: target,
|
|
1622
|
+
dryRun: false,
|
|
1623
|
+
theme
|
|
1624
|
+
});
|
|
1625
|
+
yield* Effect.logInfo(`✓ Set org theme from ${target}`);
|
|
1626
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
1627
|
+
const designCommand = Command.make("design").pipe(Command.withDescription("Pull and push the org's email theme as Tailwind-v4 @theme CSS"), Command.withExamples([{
|
|
1628
|
+
command: "samva email design pull",
|
|
1629
|
+
description: "Write the org theme to theme.css"
|
|
1630
|
+
}, {
|
|
1631
|
+
command: "samva email design push --dry-run",
|
|
1632
|
+
description: "Parse theme.css and preview the tokens"
|
|
1633
|
+
}]), Command.withSubcommands([pull$1, push$1]));
|
|
1634
|
+
//#endregion
|
|
1635
|
+
//#region src/lib/http.ts
|
|
1636
|
+
const postJson = (url, payload) => Effect.tryPromise({
|
|
1637
|
+
try: async () => {
|
|
1638
|
+
const res = await fetch(url, {
|
|
1639
|
+
method: "POST",
|
|
1640
|
+
headers: { "content-type": "application/json" },
|
|
1641
|
+
body: JSON.stringify(payload)
|
|
1642
|
+
});
|
|
1643
|
+
return {
|
|
1644
|
+
ok: res.ok,
|
|
1645
|
+
status: res.status,
|
|
1646
|
+
body: await res.text()
|
|
1647
|
+
};
|
|
1648
|
+
},
|
|
1649
|
+
catch: (cause) => new SamvaCliError({ message: `Request to ${url} failed: ${String(cause)}` })
|
|
1650
|
+
});
|
|
1651
|
+
const getText = (url) => Effect.tryPromise({
|
|
1652
|
+
try: async () => {
|
|
1653
|
+
const res = await fetch(url);
|
|
1654
|
+
return {
|
|
1655
|
+
ok: res.ok,
|
|
1656
|
+
status: res.status,
|
|
1657
|
+
body: await res.text()
|
|
1658
|
+
};
|
|
1659
|
+
},
|
|
1660
|
+
catch: (cause) => new SamvaCliError({ message: `Request to ${url} failed: ${String(cause)}` })
|
|
1661
|
+
});
|
|
1662
|
+
//#endregion
|
|
1663
|
+
//#region src/commands/email/doctor.ts
|
|
1664
|
+
/** Surface the real failure text so a failed check is actionable, not uniform. */
|
|
1665
|
+
const detailFromError = (error) => Predicate.isObject(error) && Predicate.hasProperty(error, "message") && typeof error["message"] === "string" ? error["message"] : "Read failed";
|
|
1666
|
+
const check$2 = (name, run) => run.pipe(Effect.match({
|
|
1667
|
+
onFailure: (error) => ({
|
|
1668
|
+
name,
|
|
1669
|
+
status: "failed",
|
|
1670
|
+
detail: detailFromError(error)
|
|
1671
|
+
}),
|
|
1672
|
+
onSuccess: (detail) => ({
|
|
1673
|
+
name,
|
|
1674
|
+
status: "passed",
|
|
1675
|
+
detail
|
|
1676
|
+
})
|
|
1677
|
+
}));
|
|
1678
|
+
const reachability = (apiUrl) => check$2("API reachability", getText(`${apiUrl.replace(/\/+$/, "")}/v1/health/ping`).pipe(Effect.flatMap((response) => response.ok ? Effect.succeed("Health endpoint responded") : Effect.fail(new SamvaCliError({ message: `Health endpoint returned ${response.status}` })))));
|
|
1679
|
+
const orgAccess = check$2("Organization permissions", getClientProvider.pipe(Effect.flatMap((client) => Organizations.get().pipe(client.provide, Effect.map((organization) => `${organization.name} (${organization.slug})`), Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Organization read failed", error) }))))));
|
|
1680
|
+
const domains = check$2("Sending domains", getClientProvider.pipe(Effect.flatMap((client) => Email.listDomains({ limit: "1" }).pipe(client.provide, Effect.map((result) => {
|
|
1681
|
+
const verified = result.items.filter((domain) => domain.status === "verified").length;
|
|
1682
|
+
return `${result.pagination.total} configured; ${verified} verified on this page`;
|
|
1683
|
+
}), Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Domain read failed", error) }))))));
|
|
1684
|
+
const senders = check$2("Senders", getClientProvider.pipe(Effect.flatMap((client) => Email.listSenders({ limit: "1" }).pipe(client.provide, Effect.map((result) => {
|
|
1685
|
+
const verified = result.items.filter((sender) => sender.verificationStatus === "verified").length;
|
|
1686
|
+
return `${result.pagination.total} configured; ${verified} verified on this page`;
|
|
1687
|
+
}), Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Sender read failed", error) }))))));
|
|
1688
|
+
const domainVerificationReadback = getClientProvider.pipe(Effect.flatMap((client) => Email.listDomains({ limit: "1" }).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Domain verification read failed", error) })), Effect.flatMap((result) => {
|
|
1689
|
+
const domain = result.items[0];
|
|
1690
|
+
if (!domain) return Effect.succeed({
|
|
1691
|
+
name: "Domain verification readback",
|
|
1692
|
+
status: "unavailable",
|
|
1693
|
+
detail: "No sending domain configured"
|
|
1694
|
+
});
|
|
1695
|
+
return Email.getDomainStatus({ id: domain.id }).pipe(client.provide, Effect.map((status) => {
|
|
1696
|
+
const verification = status.status.toLowerCase();
|
|
1697
|
+
return {
|
|
1698
|
+
name: "Domain verification readback",
|
|
1699
|
+
status: verification === "verified" ? "passed" : verification === "failed" ? "failed" : "unavailable",
|
|
1700
|
+
detail: `${status.domain}: ${status.status}`
|
|
1701
|
+
};
|
|
1702
|
+
}), Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Domain verification read failed", error) })));
|
|
1703
|
+
}))), Effect.catch((error) => Effect.succeed({
|
|
1704
|
+
name: "Domain verification readback",
|
|
1705
|
+
status: "failed",
|
|
1706
|
+
detail: detailFromError(error)
|
|
1707
|
+
})));
|
|
1708
|
+
const doctorCommand = Command.make("doctor", { json: jsonFlag }).pipe(Command.withDescription("Read local configuration and remote email state"), Command.withExamples([{ command: "samva email doctor --json" }]), Command.withHandler(({ json }) => Effect.gen(function* () {
|
|
1709
|
+
const context = yield* resolveExecutionContext;
|
|
1710
|
+
const authSource = context.apiKey ? "SAMVA_API_KEY" : context.credentials ? "stored OAuth credential" : "none";
|
|
1711
|
+
const checks = yield* Effect.all([
|
|
1712
|
+
reachability(context.apiUrl),
|
|
1713
|
+
orgAccess,
|
|
1714
|
+
domains,
|
|
1715
|
+
senders,
|
|
1716
|
+
domainVerificationReadback
|
|
1717
|
+
]);
|
|
1718
|
+
const ok = checks.every((result) => result.status !== "failed");
|
|
1719
|
+
const payload = {
|
|
1720
|
+
schemaVersion: 1,
|
|
1721
|
+
mode: "read-only",
|
|
1722
|
+
ok,
|
|
1723
|
+
limitations: "Checks CLI/API visibility only. It does not send, mutate, replay fixtures, verify DNS, or verify provider readiness.",
|
|
1724
|
+
resolved: {
|
|
1725
|
+
profile: context.profile,
|
|
1726
|
+
target: context.target,
|
|
1727
|
+
apiUrl: context.apiUrl,
|
|
1728
|
+
authSource,
|
|
1729
|
+
organization: context.orgSlug ?? null
|
|
1730
|
+
},
|
|
1731
|
+
checks
|
|
1732
|
+
};
|
|
1733
|
+
if (!ok) yield* Effect.sync(() => void (process.exitCode = 1));
|
|
1734
|
+
if (json) return yield* printJson(payload);
|
|
1735
|
+
yield* printFields([
|
|
1736
|
+
["Profile", payload.resolved.profile],
|
|
1737
|
+
["Target", payload.resolved.target],
|
|
1738
|
+
["API", payload.resolved.apiUrl],
|
|
1739
|
+
["Auth source", payload.resolved.authSource],
|
|
1740
|
+
["Organization", payload.resolved.organization]
|
|
1741
|
+
]);
|
|
1742
|
+
yield* Console.log("\nRead-only checks:");
|
|
1743
|
+
for (const result of checks) {
|
|
1744
|
+
const glyph = result.status === "passed" ? "✓" : result.status === "unavailable" ? "!" : "✗";
|
|
1745
|
+
yield* Console.log(`${glyph} ${result.name}: ${result.detail}`);
|
|
1746
|
+
}
|
|
1747
|
+
yield* Console.log(`\n${payload.limitations}`);
|
|
1748
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
1749
|
+
//#endregion
|
|
1750
|
+
//#region src/commands/email/domains.ts
|
|
1751
|
+
const list$6 = Command.make("list", {
|
|
1752
|
+
page: Flag.integer("page").pipe(Flag.withDescription("Page number"), Flag.optional),
|
|
1753
|
+
limit: Flag.integer("limit").pipe(Flag.withDescription("Items per page"), Flag.optional),
|
|
1754
|
+
all: Flag.boolean("all").pipe(Flag.withDescription("Traverse every page (requires --jsonl)"), Flag.withDefault(false)),
|
|
1755
|
+
json: jsonFlag
|
|
1756
|
+
}).pipe(Command.withDescription("List email sending domains"), Command.withExamples([{ command: "samva email domains list" }, { command: "samva email domains list --json" }]), Command.withHandler(({ page, limit, all, json }) => Effect.gen(function* () {
|
|
1757
|
+
if (all && !isJsonLinesRequested()) return yield* new CliUsageError({
|
|
1758
|
+
code: "all_requires_jsonl",
|
|
1759
|
+
message: "--all is an unbounded traversal and requires --jsonl."
|
|
1760
|
+
});
|
|
1761
|
+
const client = yield* getClientProvider;
|
|
1762
|
+
const pageValue = Option.getOrUndefined(page);
|
|
1763
|
+
const limitValue = Option.getOrUndefined(limit);
|
|
1764
|
+
const query = {
|
|
1765
|
+
page: pageValue?.toString(),
|
|
1766
|
+
limit: limitValue?.toString()
|
|
1767
|
+
};
|
|
1768
|
+
if (all) {
|
|
1769
|
+
let nextPage = pageValue ?? 1;
|
|
1770
|
+
for (;;) {
|
|
1771
|
+
const data = yield* Email.listDomains({
|
|
1772
|
+
...query,
|
|
1773
|
+
page: nextPage.toString()
|
|
1774
|
+
}).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not list domains", error) })));
|
|
1775
|
+
for (const item of data.items) yield* printJsonLine(item);
|
|
1776
|
+
if (nextPage >= data.pagination.totalPages || data.items.length === 0) return;
|
|
1777
|
+
nextPage += 1;
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
const data = yield* Email.listDomains(query).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not list domains", error) })));
|
|
1781
|
+
if (isJsonLinesRequested()) {
|
|
1782
|
+
for (const item of data.items) yield* printJsonLine(item);
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
if (json) return yield* printJson(data.items);
|
|
1786
|
+
if (data.items.length === 0) return yield* Console.log("No domains yet. Add one with `samva email domains add --domain example.com`.");
|
|
1787
|
+
for (const domain of data.items) {
|
|
1788
|
+
yield* printFields([
|
|
1789
|
+
["Domain", domain.domain],
|
|
1790
|
+
["Status", domain.status],
|
|
1791
|
+
["ID", domain.id]
|
|
1792
|
+
]);
|
|
1793
|
+
yield* Console.log("");
|
|
1794
|
+
}
|
|
1795
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
1796
|
+
const add$1 = Command.make("add", {
|
|
1797
|
+
domain: Flag.string("domain").pipe(Flag.withDescription("Domain to add, e.g. example.com")),
|
|
1798
|
+
dryRun: Flag.boolean("dry-run").pipe(Flag.withDescription("Print the planned change without calling the API"), Flag.withDefault(false)),
|
|
1799
|
+
json: jsonFlag
|
|
1800
|
+
}).pipe(Command.withDescription("Add an email sending domain"), Command.withExamples([{
|
|
1801
|
+
command: "samva email domains add --domain example.com --dry-run",
|
|
1802
|
+
description: "Preview add"
|
|
1803
|
+
}, {
|
|
1804
|
+
command: "samva email domains add --domain example.com",
|
|
1805
|
+
description: "Apply domain add"
|
|
1806
|
+
}]), Command.withHandler(({ domain, dryRun, json }) => Effect.gen(function* () {
|
|
1807
|
+
if (dryRun) {
|
|
1808
|
+
if (json) return yield* printJson({
|
|
1809
|
+
dryRun: true,
|
|
1810
|
+
action: "email.domains.add",
|
|
1811
|
+
domain
|
|
1812
|
+
});
|
|
1813
|
+
return yield* Console.log(`[dry-run] Would add domain "${domain}".`);
|
|
1814
|
+
}
|
|
1815
|
+
const client = yield* getClientProvider;
|
|
1816
|
+
yield* Effect.matchEffect(Email.addDomain({ domain }).pipe(client.provide), {
|
|
1817
|
+
onFailure: (error) => {
|
|
1818
|
+
const conflict = getConflictError(error);
|
|
1819
|
+
if (!conflict) return Effect.fail(new SamvaCliError({ message: formatApiError("Could not add domain", error) }));
|
|
1820
|
+
const findExisting = Effect.gen(function* () {
|
|
1821
|
+
if (typeof conflict.id === "string" && conflict.id.length > 0) {
|
|
1822
|
+
const fetched = yield* Email.getDomainById({ id: conflict.id }).pipe(client.provide, Effect.catchTag("ResourceNotFoundError", () => Effect.void), Effect.mapError((lookupError) => new SamvaCliError({ message: formatApiError("Domain already exists but could not fetch it by id", lookupError) })));
|
|
1823
|
+
if (fetched) return fetched;
|
|
1824
|
+
}
|
|
1825
|
+
let page = 1;
|
|
1826
|
+
while (true) {
|
|
1827
|
+
const listed = yield* Email.listDomains({
|
|
1828
|
+
page: page.toString(),
|
|
1829
|
+
limit: "100"
|
|
1830
|
+
}).pipe(client.provide, Effect.mapError((listError) => new SamvaCliError({ message: formatApiError("Domain may already exist but could not verify idempotently", listError) })));
|
|
1831
|
+
const existing = listed.items.find((item) => item.domain === domain);
|
|
1832
|
+
if (existing) return existing;
|
|
1833
|
+
const totalPages = Number(listed.pagination.totalPages);
|
|
1834
|
+
if (!Number.isFinite(totalPages) || page >= totalPages) break;
|
|
1835
|
+
page += 1;
|
|
1836
|
+
}
|
|
1837
|
+
return yield* new SamvaCliError({ message: formatApiError("Could not add domain", error) });
|
|
1838
|
+
});
|
|
1839
|
+
return Effect.gen(function* () {
|
|
1840
|
+
const existing = yield* findExisting;
|
|
1841
|
+
if (json) yield* printJson({
|
|
1842
|
+
...existing,
|
|
1843
|
+
idempotent: true
|
|
1844
|
+
});
|
|
1845
|
+
else yield* Effect.logWarning(`Domain "${existing.domain}" already exists (status: ${existing.status}).`);
|
|
1846
|
+
});
|
|
1847
|
+
},
|
|
1848
|
+
onSuccess: (data) => Effect.gen(function* () {
|
|
1849
|
+
if (json) return yield* printJson(data);
|
|
1850
|
+
yield* Effect.logInfo(`Domain "${data.domain}" added (status: ${data.status}). Add these DNS records:\n`);
|
|
1851
|
+
for (const record of data.dnsRecords) {
|
|
1852
|
+
yield* printFields([
|
|
1853
|
+
["Type", record.type],
|
|
1854
|
+
["Name", record.name],
|
|
1855
|
+
["Value", record.value],
|
|
1856
|
+
["TTL", record.ttl],
|
|
1857
|
+
["Priority", record.priority]
|
|
1858
|
+
]);
|
|
1859
|
+
yield* Console.log("");
|
|
1860
|
+
}
|
|
1861
|
+
yield* Effect.logInfo(`Then verify: samva email domains check ${data.id}`);
|
|
1862
|
+
})
|
|
1863
|
+
});
|
|
1864
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
1865
|
+
const check$1 = Command.make("check", {
|
|
1866
|
+
id: Argument.string("id").pipe(Argument.withDescription("Domain id to check")),
|
|
1867
|
+
json: jsonFlag
|
|
1868
|
+
}).pipe(Command.withDescription("Check an email domain's verification status"), Command.withExamples([{
|
|
1869
|
+
command: "samva email domains check dom_abc123",
|
|
1870
|
+
description: "Poll domain verification"
|
|
1871
|
+
}]), Command.withHandler(({ id, json }) => Effect.gen(function* () {
|
|
1872
|
+
const client = yield* getClientProvider;
|
|
1873
|
+
const data = yield* Email.checkDomainVerification({ id }).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not check domain", error) })));
|
|
1874
|
+
if (json) return yield* printJson(data);
|
|
1875
|
+
yield* printFields([
|
|
1876
|
+
["Domain", data.domain],
|
|
1877
|
+
["Verified", data.isVerified ? "yes" : "no"],
|
|
1878
|
+
["Verification status", data.verificationStatus],
|
|
1879
|
+
["Mail-from", data.mailFromStatus]
|
|
1880
|
+
]);
|
|
1881
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
1882
|
+
const remove$3 = Command.make("remove", {
|
|
1883
|
+
id: Argument.string("id").pipe(Argument.withDescription("Domain id to permanently remove")),
|
|
1884
|
+
dryRun: Flag.boolean("dry-run").pipe(Flag.withDescription("Print the deletion plan without calling the API"), Flag.withDefault(false)),
|
|
1885
|
+
yes: Flag.boolean("yes").pipe(Flag.withDescription("Confirm permanent deletion (required for non-interactive use)"), Flag.withDefault(false)),
|
|
1886
|
+
json: jsonFlag
|
|
1887
|
+
}).pipe(Command.withDescription("Permanently remove an email sending domain"), Command.withExamples([{ command: "samva email domains remove dom_abc123 --dry-run" }, { command: "samva email domains remove dom_abc123 --yes" }]), Command.withHandler(({ id, dryRun, yes, json }) => Effect.gen(function* () {
|
|
1888
|
+
if (dryRun) return yield* json ? printJson({
|
|
1889
|
+
dryRun: true,
|
|
1890
|
+
action: "email.domains.remove",
|
|
1891
|
+
id
|
|
1892
|
+
}) : Console.log(`[dry-run] Would permanently remove domain ${id}.`);
|
|
1893
|
+
yield* confirmDestructive({
|
|
1894
|
+
yes,
|
|
1895
|
+
json,
|
|
1896
|
+
message: `Permanently remove domain ${id}?`,
|
|
1897
|
+
nonInteractiveMessage: "Permanent domain deletion requires --yes in non-interactive mode. Preview with --dry-run."
|
|
1898
|
+
});
|
|
1899
|
+
const client = yield* getClientProvider;
|
|
1900
|
+
const data = yield* Email.removeDomain({ id }).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not remove domain", error) })));
|
|
1901
|
+
if (json) return yield* printJson(data);
|
|
1902
|
+
yield* Console.log(`Removed domain ${id}.`);
|
|
1903
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
1904
|
+
const printBrandedTracking = (data) => Effect.gen(function* () {
|
|
1905
|
+
if (!data.enabled || data.brandedTracking === null) return yield* Console.log("Branded tracking is not enabled for this domain.");
|
|
1906
|
+
const branded = data.brandedTracking;
|
|
1907
|
+
yield* printFields([
|
|
1908
|
+
["Status", branded.status],
|
|
1909
|
+
["Host", branded.hostname],
|
|
1910
|
+
["Region", branded.region],
|
|
1911
|
+
["Certificate", branded.sslStatus ?? "pending"],
|
|
1912
|
+
["CAA blocked", branded.caaBlocked ? "yes" : "no"]
|
|
1913
|
+
]);
|
|
1914
|
+
yield* Console.log("\nCreate these DNS records:");
|
|
1915
|
+
for (const record of [branded.dnsRecords.tracking, branded.dnsRecords.dcvDelegation]) {
|
|
1916
|
+
if (record === null) continue;
|
|
1917
|
+
yield* printFields([
|
|
1918
|
+
["Type", record.type],
|
|
1919
|
+
["Name", record.name],
|
|
1920
|
+
["Value", record.value]
|
|
1921
|
+
]);
|
|
1922
|
+
yield* Console.log("");
|
|
1923
|
+
}
|
|
1924
|
+
});
|
|
1925
|
+
const brandedEnable = Command.make("enable", {
|
|
1926
|
+
id: Argument.string("id").pipe(Argument.withDescription("Domain id to enable branded tracking for")),
|
|
1927
|
+
label: Flag.string("label").pipe(Flag.withDescription("First-level label under the domain (default: track)"), Flag.optional),
|
|
1928
|
+
dryRun: Flag.boolean("dry-run").pipe(Flag.withDescription("Print the planned change without calling the API"), Flag.withDefault(false)),
|
|
1929
|
+
json: jsonFlag
|
|
1930
|
+
}).pipe(Command.withDescription("Enable a branded tracking domain"), Command.withExamples([{ command: "samva email domains branded-tracking enable dom_abc123" }, { command: "samva email domains branded-tracking enable dom_abc123 --label links" }]), Command.withHandler(({ id, label, dryRun, json }) => Effect.gen(function* () {
|
|
1931
|
+
const chosenLabel = Option.getOrUndefined(label);
|
|
1932
|
+
if (dryRun) return yield* json ? printJson({
|
|
1933
|
+
dryRun: true,
|
|
1934
|
+
action: "email.domains.branded-tracking.enable",
|
|
1935
|
+
id,
|
|
1936
|
+
label: chosenLabel
|
|
1937
|
+
}) : Console.log(`[dry-run] Would enable branded tracking for ${id}.`);
|
|
1938
|
+
const client = yield* getClientProvider;
|
|
1939
|
+
const data = yield* Email.enableBrandedTracking({
|
|
1940
|
+
id,
|
|
1941
|
+
...chosenLabel === void 0 ? {} : { label: chosenLabel }
|
|
1942
|
+
}).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not enable branded tracking", error) })));
|
|
1943
|
+
if (json) return yield* printJson(data);
|
|
1944
|
+
yield* printBrandedTracking(data);
|
|
1945
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
1946
|
+
const brandedStatus = Command.make("status", {
|
|
1947
|
+
id: Argument.string("id").pipe(Argument.withDescription("Domain id to inspect")),
|
|
1948
|
+
json: jsonFlag
|
|
1949
|
+
}).pipe(Command.withDescription("Show a domain's branded tracking status"), Command.withExamples([{ command: "samva email domains branded-tracking status dom_abc123" }]), Command.withHandler(({ id, json }) => Effect.gen(function* () {
|
|
1950
|
+
const client = yield* getClientProvider;
|
|
1951
|
+
const data = yield* Email.getBrandedTracking({ id }).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not get branded tracking status", error) })));
|
|
1952
|
+
if (json) return yield* printJson(data);
|
|
1953
|
+
yield* printBrandedTracking(data);
|
|
1954
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
1955
|
+
const brandedDisable = Command.make("disable", {
|
|
1956
|
+
id: Argument.string("id").pipe(Argument.withDescription("Domain id to disable branded tracking for")),
|
|
1957
|
+
dryRun: Flag.boolean("dry-run").pipe(Flag.withDescription("Print the teardown plan without calling the API"), Flag.withDefault(false)),
|
|
1958
|
+
yes: Flag.boolean("yes").pipe(Flag.withDescription("Confirm teardown (required for non-interactive use)"), Flag.withDefault(false)),
|
|
1959
|
+
json: jsonFlag
|
|
1960
|
+
}).pipe(Command.withDescription("Disable and tear down a branded tracking domain"), Command.withExamples([{ command: "samva email domains branded-tracking disable dom_abc123 --dry-run" }, { command: "samva email domains branded-tracking disable dom_abc123 --yes" }]), Command.withHandler(({ id, dryRun, yes, json }) => Effect.gen(function* () {
|
|
1961
|
+
if (dryRun) return yield* json ? printJson({
|
|
1962
|
+
dryRun: true,
|
|
1963
|
+
action: "email.domains.branded-tracking.disable",
|
|
1964
|
+
id
|
|
1965
|
+
}) : Console.log(`[dry-run] Would disable branded tracking for ${id}.`);
|
|
1966
|
+
yield* confirmDestructive({
|
|
1967
|
+
yes,
|
|
1968
|
+
json,
|
|
1969
|
+
message: `Disable branded tracking for ${id}? Existing branded links in delivered mail will break.`,
|
|
1970
|
+
nonInteractiveMessage: "Branded tracking teardown requires --yes in non-interactive mode. Preview with --dry-run."
|
|
1971
|
+
});
|
|
1972
|
+
const client = yield* getClientProvider;
|
|
1973
|
+
const data = yield* Email.disableBrandedTracking({ id }).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not disable branded tracking", error) })));
|
|
1974
|
+
if (json) return yield* printJson(data);
|
|
1975
|
+
yield* Console.log(`Disabled branded tracking for ${id}.`);
|
|
1976
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
1977
|
+
const brandedTracking = Command.make("branded-tracking").pipe(Command.withDescription("Manage a domain's branded tracking (custom open/click subdomain)"), Command.withExamples([{ command: "samva email domains branded-tracking enable dom_abc123" }]), Command.withSubcommands([
|
|
1978
|
+
brandedEnable,
|
|
1979
|
+
brandedStatus,
|
|
1980
|
+
brandedDisable
|
|
1981
|
+
]));
|
|
1982
|
+
const domainsCommand = Command.make("domains").pipe(Command.withDescription("Manage email sending domains"), Command.withExamples([{
|
|
1983
|
+
command: "samva email domains list",
|
|
1984
|
+
description: "List configured domains"
|
|
1985
|
+
}]), Command.withSubcommands([
|
|
1986
|
+
list$6,
|
|
1987
|
+
add$1,
|
|
1988
|
+
check$1,
|
|
1989
|
+
remove$3,
|
|
1990
|
+
brandedTracking
|
|
1991
|
+
]));
|
|
1992
|
+
//#endregion
|
|
1993
|
+
//#region src/commands/email/receiving.ts
|
|
1994
|
+
const status = Command.make("status", {
|
|
1995
|
+
domainId: Argument.string("domain-id").pipe(Argument.withDescription("Sending domain id")),
|
|
1996
|
+
json: jsonFlag
|
|
1997
|
+
}).pipe(Command.withDescription("Get a domain's receiving prerequisite status"), Command.withExamples([{ command: "samva email receiving status dom_abc123 --json" }]), Command.withHandler(({ domainId, json }) => Effect.gen(function* () {
|
|
1998
|
+
const client = yield* getClientProvider;
|
|
1999
|
+
const data = yield* Email.getDomainStatus({ id: domainId }).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not get receiving status", error) })));
|
|
2000
|
+
if (json) return yield* printJson(data);
|
|
2001
|
+
yield* printFields([["Domain", data.domain], ["Status", data.status]]);
|
|
2002
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
2003
|
+
const enable = Command.make("enable", {
|
|
2004
|
+
domainId: Argument.string("domain-id").pipe(Argument.withDescription("Sending domain id")),
|
|
2005
|
+
domain: Flag.string("domain").pipe(Flag.withDescription("Verified domain name")),
|
|
2006
|
+
address: Flag.string("address").pipe(Flag.withDescription("Recipient address to receive (repeatable)"), Flag.atLeast(1)),
|
|
2007
|
+
catchAll: Flag.boolean("catch-all").pipe(Flag.withDescription("Also receive unmatched addresses"), Flag.withDefault(false)),
|
|
2008
|
+
dryRun: Flag.boolean("dry-run").pipe(Flag.withDescription("Print the provider setup request without calling the API"), Flag.withDefault(false)),
|
|
2009
|
+
json: jsonFlag
|
|
2010
|
+
}).pipe(Command.withDescription("Enable inbound receiving for a verified domain"), Command.withExamples([{ command: "samva email receiving enable dom_abc123 --domain example.com --address replies@example.com --dry-run" }, { command: "samva email receiving enable dom_abc123 --domain example.com --address replies@example.com" }]), Command.withHandler(({ domainId, domain, address, catchAll, dryRun, json }) => Effect.gen(function* () {
|
|
2011
|
+
const request = {
|
|
2012
|
+
domain,
|
|
2013
|
+
catchAll,
|
|
2014
|
+
addresses: [...address]
|
|
2015
|
+
};
|
|
2016
|
+
if (dryRun) return yield* json ? printJson({
|
|
2017
|
+
dryRun: true,
|
|
2018
|
+
action: "email.receiving.enable",
|
|
2019
|
+
domainId,
|
|
2020
|
+
request
|
|
2021
|
+
}) : Console.log(`[dry-run] Would enable receiving for ${domain} (${address.join(", ")}).`);
|
|
2022
|
+
const client = yield* getClientProvider;
|
|
2023
|
+
const data = yield* Email.enableReceiving({
|
|
2024
|
+
id: domainId,
|
|
2025
|
+
...request
|
|
2026
|
+
}).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not enable receiving", error) })));
|
|
2027
|
+
if (json) return yield* printJson(data);
|
|
2028
|
+
yield* printFields([["Rule", data.ruleName], ["Recipients", data.recipients.join(", ")]]);
|
|
2029
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
2030
|
+
const receivingCommand = Command.make("receiving").pipe(Command.withDescription("Configure inbound email receiving"), Command.withExamples([{ command: "samva email receiving status dom_abc123" }]), Command.withSubcommands([status, enable]));
|
|
2031
|
+
//#endregion
|
|
2032
|
+
//#region src/lib/stdin.ts
|
|
2033
|
+
/** Read piped stdin as UTF-8 (fd 0). Fails fast when stdin is a TTY (no pipe). */
|
|
2034
|
+
const readStdinUtf8 = Effect.gen(function* () {
|
|
2035
|
+
if (isatty(0)) return yield* new SamvaCliError({ message: "Expected piped stdin for `-`; redirect input (e.g. `echo … | samva …`) or pass the body inline instead of `-`." });
|
|
2036
|
+
return yield* Effect.tryPromise({
|
|
2037
|
+
try: () => readFile(0, "utf8"),
|
|
2038
|
+
catch: () => new SamvaCliError({ message: "Could not read stdin." })
|
|
2039
|
+
});
|
|
2040
|
+
});
|
|
2041
|
+
//#endregion
|
|
2042
|
+
//#region src/commands/messages.ts
|
|
2043
|
+
/** The deliberately small Message shape shared by send, get, and wait. */
|
|
2044
|
+
const projectMessage = (message) => ({
|
|
2045
|
+
id: message.id,
|
|
2046
|
+
channel: message.channel,
|
|
2047
|
+
status: message.status,
|
|
2048
|
+
conversationId: message.conversationId,
|
|
2049
|
+
createdAt: message.createdAt,
|
|
2050
|
+
updatedAt: message.updatedAt,
|
|
2051
|
+
deliveries: message.deliveries.map((delivery) => ({
|
|
2052
|
+
status: delivery.status,
|
|
2053
|
+
recipient: delivery.recipientId
|
|
2054
|
+
}))
|
|
2055
|
+
});
|
|
2056
|
+
const printMessage = (message) => printFields([
|
|
2057
|
+
["Message ID", message.id],
|
|
2058
|
+
["Channel", message.channel],
|
|
2059
|
+
["Status", message.status],
|
|
2060
|
+
["Conversation", message.conversationId],
|
|
2061
|
+
["Created", message.createdAt],
|
|
2062
|
+
["Updated", message.updatedAt]
|
|
2063
|
+
]);
|
|
2064
|
+
const classifyMessageLifecycle = (message) => {
|
|
2065
|
+
const failed = (status) => status === "failed" || status === "bounced" || status === "complained";
|
|
2066
|
+
const succeeded = (status) => status === "delivered" || status === "read";
|
|
2067
|
+
const messageStatus = message.status.toLowerCase();
|
|
2068
|
+
const deliveryStatuses = message.deliveries.map((delivery) => delivery.status.toLowerCase());
|
|
2069
|
+
if (failed(messageStatus) || deliveryStatuses.some(failed)) return "failure";
|
|
2070
|
+
if (succeeded(messageStatus) || deliveryStatuses.length > 0 && deliveryStatuses.every(succeeded)) return "success";
|
|
2071
|
+
return "pending";
|
|
2072
|
+
};
|
|
2073
|
+
var MessageStillPending = class extends Schema.TaggedErrorClass()("MessageStillPending", { message: Schema.String }) {};
|
|
2074
|
+
const messageLifecycleError = (code, message, partialMessage) => new CliMessageLifecycleError({
|
|
2075
|
+
code,
|
|
2076
|
+
message,
|
|
2077
|
+
partialMessage,
|
|
2078
|
+
next: `samva messages wait ${partialMessage.id}`,
|
|
2079
|
+
human: [
|
|
2080
|
+
`Message ID ${partialMessage.id}`,
|
|
2081
|
+
`Status ${partialMessage.status}`,
|
|
2082
|
+
`Next samva messages wait ${partialMessage.id}`
|
|
2083
|
+
].join("\n")
|
|
2084
|
+
});
|
|
2085
|
+
/**
|
|
2086
|
+
* `--timeout` is modelled as an optional flag rather than one with a baked-in
|
|
2087
|
+
* default so `Option.isSome` tells callers whether the user actually passed it.
|
|
2088
|
+
* The parser owns detection, so a flag *value* of `--timeout` (e.g.
|
|
2089
|
+
* `--subject "--timeout"`) and a trailing `--` no longer false-positive.
|
|
2090
|
+
*/
|
|
2091
|
+
const timeoutFlag = Flag.string("timeout").pipe(Flag.withDescription("Maximum wait duration (default: 2m; only used with --wait)"), Flag.optional);
|
|
2092
|
+
/** Duration.fromInput unit word for each `--timeout` compact-form suffix. */
|
|
2093
|
+
const DURATION_UNIT_WORD_BY_SUFFIX = {
|
|
2094
|
+
ms: "millis",
|
|
2095
|
+
s: "seconds",
|
|
2096
|
+
m: "minutes"
|
|
2097
|
+
};
|
|
2098
|
+
const durationFromFlag = (raw) => {
|
|
2099
|
+
const invalidTimeout = () => Effect.fail(new CliUsageError({
|
|
2100
|
+
code: "invalid_timeout",
|
|
2101
|
+
message: "--timeout must be a positive duration, for example `--timeout 2m` or `--timeout \"2 minutes\"`."
|
|
2102
|
+
}));
|
|
2103
|
+
const compact = /^(\d+(?:\.\d+)?)(ms|s|m|h)$/i.exec(raw);
|
|
2104
|
+
const value = compact ? `${compact[1]} ${DURATION_UNIT_WORD_BY_SUFFIX[compact[2]?.toLowerCase() ?? ""] ?? "hours"}` : raw;
|
|
2105
|
+
const isDurationInput = (input) => /^\d+(?:\.\d+)?\s+(?:nanos?|micros?|millis?|seconds?|minutes?|hours?)$/.test(input);
|
|
2106
|
+
if (!isDurationInput(value)) return invalidTimeout();
|
|
2107
|
+
return Option.match(Duration.fromInput(value), {
|
|
2108
|
+
onNone: invalidTimeout,
|
|
2109
|
+
onSome: (duration) => Duration.isFinite(duration) && Duration.toMillis(duration) > 0 ? Effect.succeed(duration) : invalidTimeout()
|
|
2110
|
+
});
|
|
2111
|
+
};
|
|
2112
|
+
const getMessage = (id) => getClientProvider.pipe(Effect.flatMap((client) => Messages.get({ id }).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not get message", error) })))));
|
|
2113
|
+
/** Poll with an owned Effect timeout; interruption cancels the active request/fiber. */
|
|
2114
|
+
const waitForMessage = (initial, timeout) => Effect.gen(function* () {
|
|
2115
|
+
let latest = initial;
|
|
2116
|
+
if (classifyMessageLifecycle(latest) !== "pending") return latest;
|
|
2117
|
+
const result = yield* Effect.gen(function* () {
|
|
2118
|
+
latest = yield* getMessage(initial.id);
|
|
2119
|
+
if (classifyMessageLifecycle(latest) === "pending") return yield* new MessageStillPending({ message: `Message ${latest.id} is ${latest.status}.` });
|
|
2120
|
+
return latest;
|
|
2121
|
+
}).pipe(Effect.retry({
|
|
2122
|
+
schedule: Schedule.spaced("1 second"),
|
|
2123
|
+
while: (error) => error instanceof MessageStillPending
|
|
2124
|
+
})).pipe(Effect.timeoutOption(timeout), Effect.catch((error) => messageLifecycleError("message_observation_failed", formatApiError(`Could not continue observing message ${latest.id}`, error), projectMessage(latest))));
|
|
2125
|
+
return Option.getOrElse(result, () => latest);
|
|
2126
|
+
});
|
|
2127
|
+
const lifecycleResult = (message, timeout) => Effect.gen(function* () {
|
|
2128
|
+
const result = yield* waitForMessage(message, timeout);
|
|
2129
|
+
const resultTerminal = classifyMessageLifecycle(result);
|
|
2130
|
+
return {
|
|
2131
|
+
message: result,
|
|
2132
|
+
resultTerminal,
|
|
2133
|
+
timedOut: resultTerminal === "pending"
|
|
2134
|
+
};
|
|
2135
|
+
});
|
|
2136
|
+
const pageFlag$1 = Flag.integer("page").pipe(Flag.withDescription("Page number (1-based)"), Flag.optional);
|
|
2137
|
+
const limitFlag$1 = Flag.integer("limit").pipe(Flag.withDescription("Maximum messages per page"), Flag.optional);
|
|
2138
|
+
const statusFlag$1 = Flag.string("status").pipe(Flag.withDescription("Filter by message status"), Flag.optional);
|
|
2139
|
+
const channelFlag = Flag.string("channel").pipe(Flag.withDescription("Filter by channel"), Flag.optional);
|
|
2140
|
+
const conversationIdFlag = Flag.string("conversation-id").pipe(Flag.withDescription("Filter by conversation id"), Flag.optional);
|
|
2141
|
+
const contactIdFlag = Flag.string("contact-id").pipe(Flag.withDescription("Filter by contact id"), Flag.optional);
|
|
2142
|
+
const startDateFlag = Flag.string("start-date").pipe(Flag.withDescription("Filter messages created on or after this ISO 8601 instant"), Flag.optional);
|
|
2143
|
+
const endDateFlag = Flag.string("end-date").pipe(Flag.withDescription("Filter messages created on or before this ISO 8601 instant"), Flag.optional);
|
|
2144
|
+
const allFlag$1 = Flag.boolean("all").pipe(Flag.withDescription("Traverse every page (requires --jsonl)"), Flag.withDefault(false));
|
|
2145
|
+
const list$5 = Command.make("list", {
|
|
2146
|
+
page: pageFlag$1,
|
|
2147
|
+
limit: limitFlag$1,
|
|
2148
|
+
status: statusFlag$1,
|
|
2149
|
+
channel: channelFlag,
|
|
2150
|
+
conversationId: conversationIdFlag,
|
|
2151
|
+
contactId: contactIdFlag,
|
|
2152
|
+
startDate: startDateFlag,
|
|
2153
|
+
endDate: endDateFlag,
|
|
2154
|
+
all: allFlag$1,
|
|
2155
|
+
json: jsonFlag
|
|
2156
|
+
}).pipe(Command.withDescription("List messages"), Command.withExamples([
|
|
2157
|
+
{ command: "samva messages list --channel email --limit 25" },
|
|
2158
|
+
{ command: "samva messages list --status failed --json" },
|
|
2159
|
+
{
|
|
2160
|
+
command: "samva messages list --all --jsonl",
|
|
2161
|
+
description: "Stream every page as JSONL"
|
|
2162
|
+
}
|
|
2163
|
+
]), Command.withHandler(({ page, limit, status, channel, conversationId, contactId, startDate, endDate, all, json }) => Effect.gen(function* () {
|
|
2164
|
+
if (all && !isJsonLinesRequested()) return yield* new CliUsageError({
|
|
2165
|
+
code: "all_requires_jsonl",
|
|
2166
|
+
message: "--all is an unbounded traversal and requires --jsonl."
|
|
2167
|
+
});
|
|
2168
|
+
const client = yield* getClientProvider;
|
|
2169
|
+
const pageValue = Option.getOrUndefined(page);
|
|
2170
|
+
const limitValue = Option.getOrUndefined(limit);
|
|
2171
|
+
const query = {
|
|
2172
|
+
channel: Option.getOrUndefined(channel),
|
|
2173
|
+
status: Option.getOrUndefined(status),
|
|
2174
|
+
conversationId: Option.getOrUndefined(conversationId),
|
|
2175
|
+
contactId: Option.getOrUndefined(contactId),
|
|
2176
|
+
startDate: Option.getOrUndefined(startDate),
|
|
2177
|
+
endDate: Option.getOrUndefined(endDate),
|
|
2178
|
+
page: pageValue?.toString(),
|
|
2179
|
+
limit: limitValue?.toString()
|
|
2180
|
+
};
|
|
2181
|
+
if (all) {
|
|
2182
|
+
let nextPage = pageValue ?? 1;
|
|
2183
|
+
for (;;) {
|
|
2184
|
+
const data = yield* Messages.list({
|
|
2185
|
+
...query,
|
|
2186
|
+
page: nextPage.toString()
|
|
2187
|
+
}).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not list messages", error) })));
|
|
2188
|
+
for (const item of data.items) yield* printJsonLine(projectMessage(item));
|
|
2189
|
+
if (nextPage >= data.pagination.totalPages || data.items.length === 0) return;
|
|
2190
|
+
nextPage += 1;
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
const data = yield* Messages.list(query).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not list messages", error) })));
|
|
2194
|
+
const payload = {
|
|
2195
|
+
items: data.items.map(projectMessage),
|
|
2196
|
+
pagination: data.pagination
|
|
2197
|
+
};
|
|
2198
|
+
if (isJsonLinesRequested()) {
|
|
2199
|
+
for (const item of payload.items) yield* printJsonLine(item);
|
|
2200
|
+
return;
|
|
2201
|
+
}
|
|
2202
|
+
if (json) return yield* printJson(payload);
|
|
2203
|
+
if (payload.items.length === 0) return yield* Console.log("No messages found.");
|
|
2204
|
+
for (const message of payload.items) {
|
|
2205
|
+
yield* printMessage(message);
|
|
2206
|
+
yield* Console.log("");
|
|
2207
|
+
}
|
|
2208
|
+
yield* printFields([["Page", payload.pagination.page], ["Total", payload.pagination.total]]);
|
|
2209
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
2210
|
+
const get$2 = Command.make("get", {
|
|
2211
|
+
id: Argument.string("id"),
|
|
2212
|
+
json: jsonFlag
|
|
2213
|
+
}).pipe(Command.withDescription("Get a message"), Command.withExamples([{ command: "samva messages get msg_abc123 --json" }]), Command.withHandler(({ id, json }) => getMessage(id).pipe(Effect.flatMap((message) => json ? printJson(projectMessage(message)) : printMessage(projectMessage(message))), (effect) => json ? effect.pipe(Effect.provideService(References.MinimumLogLevel, "None")) : effect)));
|
|
2214
|
+
const events = Command.make("events", {
|
|
2215
|
+
id: Argument.string("id"),
|
|
2216
|
+
json: jsonFlag
|
|
2217
|
+
}).pipe(Command.withDescription("List lifecycle events for a message"), Command.withExamples([{ command: "samva messages events msg_abc123 --json" }]), Command.withHandler(({ id, json }) => Effect.gen(function* () {
|
|
2218
|
+
const client = yield* getClientProvider;
|
|
2219
|
+
const data = yield* Messages.getEvents({ id }).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not list message events", error) })));
|
|
2220
|
+
if (json) return yield* printJson(data);
|
|
2221
|
+
for (const event of data) yield* printFields([
|
|
2222
|
+
["Event", event.eventType],
|
|
2223
|
+
["Status", event.status],
|
|
2224
|
+
["Time", event.timestamp]
|
|
2225
|
+
]);
|
|
2226
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
2227
|
+
const wait = Command.make("wait", {
|
|
2228
|
+
id: Argument.string("id"),
|
|
2229
|
+
timeout: timeoutFlag,
|
|
2230
|
+
json: jsonFlag
|
|
2231
|
+
}).pipe(Command.withDescription("Wait for a message to be delivered or read"), Command.withExamples([{ command: "samva messages wait msg_abc123" }, { command: "samva messages wait msg_abc123 --timeout 30s --json" }]), Command.withHandler(({ id, timeout, json }) => Effect.gen(function* () {
|
|
2232
|
+
const duration = yield* durationFromFlag(Option.getOrElse(timeout, () => "2m"));
|
|
2233
|
+
const initial = yield* getMessage(id);
|
|
2234
|
+
const result = yield* lifecycleResult(initial, duration);
|
|
2235
|
+
const projection = projectMessage(result.message);
|
|
2236
|
+
if (result.timedOut) return yield* messageLifecycleError("message_wait_timeout", `Timed out waiting for message ${projection.id}; it remains ${projection.status}.`, projection);
|
|
2237
|
+
if (result.resultTerminal === "failure") return yield* messageLifecycleError("message_terminal_failure", `Message ${projection.id} reached terminal status ${projection.status}.`, projection);
|
|
2238
|
+
if (json) return yield* printJson(projection);
|
|
2239
|
+
yield* printMessage(projection);
|
|
2240
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
2241
|
+
const messagesCommand = Command.make("messages").pipe(Command.withDescription("Inspect messages and their lifecycle"), Command.withExamples([{ command: "samva messages list --channel email --json" }]), Command.withSubcommands([
|
|
2242
|
+
list$5,
|
|
2243
|
+
get$2,
|
|
2244
|
+
events,
|
|
2245
|
+
wait
|
|
2246
|
+
]));
|
|
2247
|
+
//#endregion
|
|
2248
|
+
//#region src/commands/email/send.ts
|
|
2249
|
+
/** Human status line for a definite (non-`undefined`) lifecycle outcome. */
|
|
2250
|
+
const STATUS_LINE_BY_OUTCOME = {
|
|
2251
|
+
success: "Email lifecycle completed.",
|
|
2252
|
+
failure: "Email delivery failed."
|
|
2253
|
+
};
|
|
2254
|
+
const toFlag$1 = Flag.string("to").pipe(Flag.withDescription("Recipient email address (repeatable)"), Flag.atLeast(1));
|
|
2255
|
+
const ccFlag$1 = Flag.string("cc").pipe(Flag.withDescription("Cc recipient email address"), Flag.optional);
|
|
2256
|
+
const bccFlag$1 = Flag.string("bcc").pipe(Flag.withDescription("Bcc recipient email address"), Flag.optional);
|
|
2257
|
+
const subjectFlag$1 = Flag.string("subject").pipe(Flag.withDescription("Email subject line (required for inline content; omit for templates)"), Flag.optional);
|
|
2258
|
+
const htmlFlag$1 = Flag.string("html").pipe(Flag.withDescription("HTML body (use `-` to read from stdin)"), Flag.optional);
|
|
2259
|
+
const textFlag$1 = Flag.string("text").pipe(Flag.withDescription("Plain-text body (use `-` to read from stdin)"), Flag.optional);
|
|
2260
|
+
const templateIdFlag$1 = Flag.string("template-id").pipe(Flag.withDescription("Template id (uuid) to render instead of inline body"), Flag.optional);
|
|
2261
|
+
const templateSlugFlag$1 = Flag.string("template-slug").pipe(Flag.withDescription("Template handle to render instead of inline body, e.g. welcome-email"), Flag.optional);
|
|
2262
|
+
const templateDataFlag$1 = Flag.string("template-data").pipe(Flag.withDescription("JSON object of variables for the template"), Flag.optional);
|
|
2263
|
+
const replyToMessageFlag = Flag.string("reply-to-message-id").pipe(Flag.withDescription("Message id this email replies to (threads the conversation)"), Flag.optional);
|
|
2264
|
+
const replyToAddressFlag = Flag.string("reply-to").pipe(Flag.withDescription("Reply-To header email address"), Flag.optional);
|
|
2265
|
+
const idempotencyKeyFlag$1 = Flag.string("idempotency-key").pipe(Flag.withDescription("Key (1-255 characters) so identical retries return the original email"), Flag.optional);
|
|
2266
|
+
const trackOpensFlag$1 = Flag.choice("track-opens", ["on", "off"]).pipe(Flag.withDescription("Override open tracking for this send; recipient opt-out still wins"), Flag.optional);
|
|
2267
|
+
const trackClicksFlag$1 = Flag.choice("track-clicks", ["on", "off"]).pipe(Flag.withDescription("Override click tracking for this send; recipient opt-out still wins"), Flag.optional);
|
|
2268
|
+
const dryRunFlag$2 = Flag.boolean("dry-run").pipe(Flag.withDescription("Validate inputs and print the request payload without calling the API"), Flag.withDefault(false));
|
|
2269
|
+
const bodySourceName = (input) => {
|
|
2270
|
+
if (input.templateRefValue) return "template";
|
|
2271
|
+
if (input.stdin) return "stdin";
|
|
2272
|
+
if (input.resolvedHtml) return "html";
|
|
2273
|
+
return "text";
|
|
2274
|
+
};
|
|
2275
|
+
const parseTemplateData$1 = (raw) => Option.match(raw, {
|
|
2276
|
+
onNone: () => Effect.succeed(void 0),
|
|
2277
|
+
onSome: (value) => Effect.try({
|
|
2278
|
+
try: () => JSON.parse(value),
|
|
2279
|
+
catch: () => new SamvaCliError({ message: "--template-data must be a JSON object." })
|
|
2280
|
+
}).pipe(Effect.flatMap((parsed) => Predicate.isObject(parsed) ? Effect.succeed(parsed) : Effect.fail(new SamvaCliError({ message: "--template-data must be a JSON object." }))))
|
|
2281
|
+
});
|
|
2282
|
+
const send = Command.make("send", {
|
|
2283
|
+
to: toFlag$1,
|
|
2284
|
+
cc: ccFlag$1,
|
|
2285
|
+
bcc: bccFlag$1,
|
|
2286
|
+
subject: subjectFlag$1,
|
|
2287
|
+
html: htmlFlag$1,
|
|
2288
|
+
text: textFlag$1,
|
|
2289
|
+
templateId: templateIdFlag$1,
|
|
2290
|
+
templateSlug: templateSlugFlag$1,
|
|
2291
|
+
templateData: templateDataFlag$1,
|
|
2292
|
+
replyTo: replyToMessageFlag,
|
|
2293
|
+
replyToAddress: replyToAddressFlag,
|
|
2294
|
+
idempotencyKey: idempotencyKeyFlag$1,
|
|
2295
|
+
trackOpens: trackOpensFlag$1,
|
|
2296
|
+
trackClicks: trackClicksFlag$1,
|
|
2297
|
+
dryRun: dryRunFlag$2,
|
|
2298
|
+
wait: Flag.boolean("wait").pipe(Flag.withDescription("Wait for delivered/read or a terminal delivery failure"), Flag.withDefault(false)),
|
|
2299
|
+
timeout: timeoutFlag,
|
|
2300
|
+
json: jsonFlag
|
|
2301
|
+
}).pipe(Command.withDescription("Send an email"), Command.withExamples([
|
|
2302
|
+
{
|
|
2303
|
+
command: "samva email send --to you@example.com --subject Hi --text Hello",
|
|
2304
|
+
description: "Plain-text body (SAMVA_API_KEY)"
|
|
2305
|
+
},
|
|
2306
|
+
{
|
|
2307
|
+
command: "samva email send --to you@example.com --template-slug welcome-email --template-data '{\"name\":\"Ada\"}'",
|
|
2308
|
+
description: "Render a template by handle"
|
|
2309
|
+
},
|
|
2310
|
+
{
|
|
2311
|
+
command: "echo \"Hello\" | samva email send --to you@example.com --subject Hi --text -",
|
|
2312
|
+
description: "Pipe plain-text body from stdin"
|
|
2313
|
+
},
|
|
2314
|
+
{
|
|
2315
|
+
command: "samva email send --to you@example.com --subject Hi --text Hello --dry-run",
|
|
2316
|
+
description: "Validate and print the request payload without sending"
|
|
2317
|
+
},
|
|
2318
|
+
{
|
|
2319
|
+
command: "samva email send --to you@example.com --subject Hi --text Hello --idempotency-key welcome-email-ada",
|
|
2320
|
+
description: "Retry a send safely with an idempotency key"
|
|
2321
|
+
}
|
|
2322
|
+
]), Command.withHandler(({ to, cc, bcc, subject, html, text, templateId, templateSlug, templateData, replyTo, replyToAddress, idempotencyKey, trackOpens, trackClicks, dryRun, wait, timeout, json }) => Effect.gen(function* () {
|
|
2323
|
+
const subjectValue = Option.getOrUndefined(subject);
|
|
2324
|
+
const htmlValue = Option.getOrUndefined(html);
|
|
2325
|
+
const textValue = Option.getOrUndefined(text);
|
|
2326
|
+
const templateIdValue = Option.getOrUndefined(templateId);
|
|
2327
|
+
const templateSlugValue = Option.getOrUndefined(templateSlug);
|
|
2328
|
+
const replyToValue = Option.getOrUndefined(replyTo);
|
|
2329
|
+
const replyToAddressValue = Option.getOrUndefined(replyToAddress);
|
|
2330
|
+
const idempotencyKeyValue = Option.getOrUndefined(idempotencyKey);
|
|
2331
|
+
const trackOpensValue = Option.getOrUndefined(trackOpens);
|
|
2332
|
+
const trackClicksValue = Option.getOrUndefined(trackClicks);
|
|
2333
|
+
const ccValue = Option.getOrUndefined(cc);
|
|
2334
|
+
const bccValue = Option.getOrUndefined(bcc);
|
|
2335
|
+
const templateDataValue = yield* parseTemplateData$1(templateData);
|
|
2336
|
+
if (idempotencyKeyValue !== void 0 && (idempotencyKeyValue.length < 1 || idempotencyKeyValue.length > 255)) return yield* new SamvaCliError({ message: "--idempotency-key must contain between 1 and 255 characters." });
|
|
2337
|
+
if (templateIdValue && templateSlugValue) return yield* new SamvaCliError({ message: "Use either --template-id or --template-slug, not both." });
|
|
2338
|
+
const templateRefValue = templateSlugValue ?? templateIdValue;
|
|
2339
|
+
if (templateRefValue && subjectValue) return yield* new SamvaCliError({ message: "Template sends derive their subject from the template. Omit --subject." });
|
|
2340
|
+
if (!templateRefValue && !subjectValue) return yield* new CliUsageError({
|
|
2341
|
+
code: "missing_subject",
|
|
2342
|
+
message: "Inline email content requires --subject. Run `samva email send --help` for examples."
|
|
2343
|
+
});
|
|
2344
|
+
if (textValue === "-" && htmlValue === "-") return yield* new CliUsageError({
|
|
2345
|
+
code: "multiple_stdin_bodies",
|
|
2346
|
+
message: "Only one body can be read from stdin: use --text - or --html -, not both."
|
|
2347
|
+
});
|
|
2348
|
+
const resolvedText = textValue === "-" ? yield* readStdinUtf8 : textValue;
|
|
2349
|
+
const resolvedHtml = htmlValue === "-" ? yield* readStdinUtf8 : htmlValue;
|
|
2350
|
+
if (!resolvedHtml && !resolvedText && !templateRefValue) return yield* new SamvaCliError({ message: "Provide a body: one of --html, --text (or --text - for stdin), --template-id, or --template-slug." });
|
|
2351
|
+
if ([
|
|
2352
|
+
resolvedHtml,
|
|
2353
|
+
resolvedText,
|
|
2354
|
+
templateRefValue
|
|
2355
|
+
].filter(Boolean).length > 1) return yield* new SamvaCliError({ message: "Choose exactly one body source: --html, --text, or a template (--template-id/--template-slug)." });
|
|
2356
|
+
const request = {
|
|
2357
|
+
to: [...to],
|
|
2358
|
+
...ccValue ? { cc: [{ email: ccValue }] } : {},
|
|
2359
|
+
...bccValue ? { bcc: [{ email: bccValue }] } : {},
|
|
2360
|
+
...subjectValue === void 0 ? {} : { subject: subjectValue },
|
|
2361
|
+
...resolvedHtml === void 0 ? {} : { html: resolvedHtml },
|
|
2362
|
+
...resolvedText === void 0 ? {} : { text: resolvedText },
|
|
2363
|
+
...templateIdValue === void 0 ? {} : { templateId: templateIdValue },
|
|
2364
|
+
...templateSlugValue === void 0 ? {} : { templateSlug: templateSlugValue },
|
|
2365
|
+
...templateDataValue === void 0 ? {} : { templateData: templateDataValue },
|
|
2366
|
+
...replyToValue === void 0 ? {} : { inReplyToMessageId: replyToValue },
|
|
2367
|
+
...replyToAddressValue === void 0 ? {} : { replyTo: replyToAddressValue },
|
|
2368
|
+
...trackOpensValue !== void 0 || trackClicksValue !== void 0 ? { tracking: {
|
|
2369
|
+
...trackOpensValue !== void 0 ? { opens: trackOpensValue === "on" } : {},
|
|
2370
|
+
...trackClicksValue !== void 0 ? { clicks: trackClicksValue === "on" } : {}
|
|
2371
|
+
} } : {}
|
|
2372
|
+
};
|
|
2373
|
+
const sendOptions = idempotencyKeyValue !== void 0 ? { idempotencyKey: idempotencyKeyValue } : {};
|
|
2374
|
+
const source = bodySourceName({
|
|
2375
|
+
templateRefValue,
|
|
2376
|
+
resolvedHtml,
|
|
2377
|
+
stdin: textValue === "-" || htmlValue === "-"
|
|
2378
|
+
});
|
|
2379
|
+
if (dryRun && wait) return yield* new CliUsageError({
|
|
2380
|
+
code: "dry_run_wait_conflict",
|
|
2381
|
+
message: "--wait cannot be combined with --dry-run; a dry run sends nothing to wait on."
|
|
2382
|
+
});
|
|
2383
|
+
if (!wait && Option.isSome(timeout)) return yield* new CliUsageError({
|
|
2384
|
+
code: "timeout_requires_wait",
|
|
2385
|
+
message: "--timeout is meaningful only with --wait."
|
|
2386
|
+
});
|
|
2387
|
+
const waitDuration = wait ? yield* durationFromFlag(Option.getOrElse(timeout, () => "2m")) : void 0;
|
|
2388
|
+
if (dryRun) {
|
|
2389
|
+
if (json) return yield* printJson({
|
|
2390
|
+
dryRun: true,
|
|
2391
|
+
bodySource: source,
|
|
2392
|
+
request,
|
|
2393
|
+
idempotencyKey: idempotencyKeyValue
|
|
2394
|
+
});
|
|
2395
|
+
yield* Effect.logInfo("Dry run — no credentials, configuration, DNS, or API request used.");
|
|
2396
|
+
yield* printFields([["Body source", source]]);
|
|
2397
|
+
yield* Console.log("");
|
|
2398
|
+
yield* printFields([
|
|
2399
|
+
["To", request.to.join(", ")],
|
|
2400
|
+
...ccValue ? [["Cc", ccValue]] : [],
|
|
2401
|
+
...bccValue ? [["Bcc", bccValue]] : [],
|
|
2402
|
+
["Subject", request.subject],
|
|
2403
|
+
...request.templateSlug ? [["Template", `${request.templateSlug} (handle)`]] : request.templateId ? [["Template", request.templateId]] : [],
|
|
2404
|
+
...request.inReplyToMessageId ? [["Reply to", request.inReplyToMessageId]] : [],
|
|
2405
|
+
...request.replyTo ? [["Reply-To", request.replyTo]] : [],
|
|
2406
|
+
...idempotencyKeyValue ? [["Idempotency key", idempotencyKeyValue]] : [],
|
|
2407
|
+
...request.tracking ? [["Track opens", request.tracking.opens], ["Track clicks", request.tracking.clicks]] : []
|
|
2408
|
+
]);
|
|
2409
|
+
if (request.text) yield* Console.log(`\nText body (${request.text.length} chars):\n${truncateForDisplay(request.text)}`);
|
|
2410
|
+
if (request.html) yield* Console.log(`\nHTML body (${request.html.length} chars):\n${truncateForDisplay(request.html)}`);
|
|
2411
|
+
if (request.templateData) yield* Console.log(`\nTemplate data:\n${truncateForDisplay(JSON.stringify(request.templateData, null, 2))}`);
|
|
2412
|
+
return;
|
|
2413
|
+
}
|
|
2414
|
+
const client = yield* getClientProvider;
|
|
2415
|
+
const data = yield* Email.send(request, sendOptions).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Failed to send email", error) })));
|
|
2416
|
+
const finalMessage = waitDuration === void 0 ? data : yield* waitForMessage(data, waitDuration);
|
|
2417
|
+
const projection = projectMessage(finalMessage);
|
|
2418
|
+
const outcome = wait ? classifyMessageLifecycle(finalMessage) : void 0;
|
|
2419
|
+
if (outcome === "pending") return yield* messageLifecycleError("message_wait_timeout", `Timed out waiting for message ${projection.id}; it remains ${projection.status}.`, projection);
|
|
2420
|
+
if (outcome === "failure") return yield* messageLifecycleError("message_terminal_failure", `Message ${projection.id} reached terminal status ${projection.status}.`, projection);
|
|
2421
|
+
if (json) yield* printJson(projection);
|
|
2422
|
+
else {
|
|
2423
|
+
const statusLine = outcome === void 0 ? "Email queued." : STATUS_LINE_BY_OUTCOME[outcome] ?? "Email delivery timed out.";
|
|
2424
|
+
yield* Effect.logInfo(`${statusLine}\n`);
|
|
2425
|
+
yield* printFields([
|
|
2426
|
+
["Message ID", projection.id],
|
|
2427
|
+
["Status", projection.status],
|
|
2428
|
+
["Conversation", projection.conversationId]
|
|
2429
|
+
]);
|
|
2430
|
+
}
|
|
2431
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
2432
|
+
//#endregion
|
|
2433
|
+
//#region src/commands/email/senders.ts
|
|
2434
|
+
const list$4 = Command.make("list", {
|
|
2435
|
+
page: Flag.integer("page").pipe(Flag.withDescription("Page number"), Flag.optional),
|
|
2436
|
+
limit: Flag.integer("limit").pipe(Flag.withDescription("Items per page"), Flag.optional),
|
|
2437
|
+
all: Flag.boolean("all").pipe(Flag.withDescription("Traverse every page (requires --jsonl)"), Flag.withDefault(false)),
|
|
2438
|
+
json: jsonFlag
|
|
2439
|
+
}).pipe(Command.withDescription("List verified sender addresses"), Command.withExamples([{ command: "samva email senders list" }, { command: "samva email senders list --json" }]), Command.withHandler(({ page, limit, all, json }) => Effect.gen(function* () {
|
|
2440
|
+
if (all && !isJsonLinesRequested()) return yield* new CliUsageError({
|
|
2441
|
+
code: "all_requires_jsonl",
|
|
2442
|
+
message: "--all is an unbounded traversal and requires --jsonl."
|
|
2443
|
+
});
|
|
2444
|
+
const client = yield* getClientProvider;
|
|
2445
|
+
const pageValue = Option.getOrUndefined(page);
|
|
2446
|
+
const limitValue = Option.getOrUndefined(limit);
|
|
2447
|
+
const query = {
|
|
2448
|
+
page: pageValue?.toString(),
|
|
2449
|
+
limit: limitValue?.toString()
|
|
2450
|
+
};
|
|
2451
|
+
if (all) {
|
|
2452
|
+
let nextPage = pageValue ?? 1;
|
|
2453
|
+
for (;;) {
|
|
2454
|
+
const data = yield* Email.listSenders({
|
|
2455
|
+
...query,
|
|
2456
|
+
page: nextPage.toString()
|
|
2457
|
+
}).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not list senders", error) })));
|
|
2458
|
+
for (const item of data.items) yield* printJsonLine(item);
|
|
2459
|
+
if (nextPage >= data.pagination.totalPages || data.items.length === 0) return;
|
|
2460
|
+
nextPage += 1;
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
const data = yield* Email.listSenders(query).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not list senders", error) })));
|
|
2464
|
+
if (isJsonLinesRequested()) {
|
|
2465
|
+
for (const item of data.items) yield* printJsonLine(item);
|
|
2466
|
+
return;
|
|
2467
|
+
}
|
|
2468
|
+
if (json) return yield* printJson(data.items);
|
|
2469
|
+
if (data.items.length === 0) return yield* Console.log("No senders yet. Add one with `samva email senders add --email you@example.com`.");
|
|
2470
|
+
for (const sender of data.items) {
|
|
2471
|
+
yield* printFields([
|
|
2472
|
+
["Email", sender.email],
|
|
2473
|
+
["Name", sender.name],
|
|
2474
|
+
["Status", sender.verificationStatus],
|
|
2475
|
+
["Default", sender.isDefault ? "yes" : "no"],
|
|
2476
|
+
["ID", sender.id]
|
|
2477
|
+
]);
|
|
2478
|
+
yield* Console.log("");
|
|
2479
|
+
}
|
|
2480
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
2481
|
+
const add = Command.make("add", {
|
|
2482
|
+
email: Flag.string("email").pipe(Flag.withDescription("Sender email address")),
|
|
2483
|
+
name: Flag.string("name").pipe(Flag.withDescription("Display name for the sender"), Flag.optional),
|
|
2484
|
+
dryRun: Flag.boolean("dry-run").pipe(Flag.withDescription("Print the planned change without calling the API"), Flag.withDefault(false)),
|
|
2485
|
+
json: jsonFlag
|
|
2486
|
+
}).pipe(Command.withDescription("Add a sender address"), Command.withExamples([{
|
|
2487
|
+
command: "samva email senders add --email hello@example.com --dry-run",
|
|
2488
|
+
description: "Preview add"
|
|
2489
|
+
}, {
|
|
2490
|
+
command: "samva email senders add --email hello@example.com",
|
|
2491
|
+
description: "Apply sender add"
|
|
2492
|
+
}]), Command.withHandler(({ email, name, dryRun, json }) => Effect.gen(function* () {
|
|
2493
|
+
const nameValue = Option.getOrUndefined(name);
|
|
2494
|
+
if (dryRun) {
|
|
2495
|
+
if (json) return yield* printJson({
|
|
2496
|
+
dryRun: true,
|
|
2497
|
+
action: "email.senders.add",
|
|
2498
|
+
email,
|
|
2499
|
+
name: nameValue
|
|
2500
|
+
});
|
|
2501
|
+
return yield* Console.log(`[dry-run] Would add sender "${email}".`);
|
|
2502
|
+
}
|
|
2503
|
+
const client = yield* getClientProvider;
|
|
2504
|
+
yield* Effect.matchEffect(Email.addSender({
|
|
2505
|
+
email,
|
|
2506
|
+
...nameValue === void 0 ? {} : { name: nameValue }
|
|
2507
|
+
}).pipe(client.provide), {
|
|
2508
|
+
onFailure: (error) => {
|
|
2509
|
+
const conflict = getConflictError(error);
|
|
2510
|
+
if (!conflict) return Effect.fail(new SamvaCliError({ message: formatApiError("Could not add sender", error) }));
|
|
2511
|
+
return Effect.gen(function* () {
|
|
2512
|
+
let page = 1;
|
|
2513
|
+
while (true) {
|
|
2514
|
+
const listed = yield* Email.listSenders({
|
|
2515
|
+
page: page.toString(),
|
|
2516
|
+
limit: "100"
|
|
2517
|
+
}).pipe(client.provide, Effect.mapError((listError) => new SamvaCliError({ message: formatApiError("Sender may already exist but could not verify idempotently", listError) })));
|
|
2518
|
+
const existing = (conflict.id ? listed.items.find((item) => item.id === conflict.id) : void 0) ?? listed.items.find((item) => item.email === email);
|
|
2519
|
+
if (existing) {
|
|
2520
|
+
if (json) yield* printJson({
|
|
2521
|
+
...existing,
|
|
2522
|
+
idempotent: true
|
|
2523
|
+
});
|
|
2524
|
+
else {
|
|
2525
|
+
yield* printFields([
|
|
2526
|
+
["Email", existing.email],
|
|
2527
|
+
["Status", existing.verificationStatus],
|
|
2528
|
+
["ID", existing.id]
|
|
2529
|
+
]);
|
|
2530
|
+
yield* Effect.logWarning("\nSender already exists.");
|
|
2531
|
+
}
|
|
2532
|
+
return;
|
|
2533
|
+
}
|
|
2534
|
+
const totalPages = Number(listed.pagination.totalPages);
|
|
2535
|
+
if (!Number.isFinite(totalPages) || page >= totalPages) break;
|
|
2536
|
+
page += 1;
|
|
2537
|
+
}
|
|
2538
|
+
return yield* new SamvaCliError({ message: formatApiError("Could not add sender", error) });
|
|
2539
|
+
});
|
|
2540
|
+
},
|
|
2541
|
+
onSuccess: (data) => Effect.gen(function* () {
|
|
2542
|
+
if (json) return yield* printJson(data);
|
|
2543
|
+
yield* printFields([
|
|
2544
|
+
["Email", data.email],
|
|
2545
|
+
["Status", data.status],
|
|
2546
|
+
["ID", data.id]
|
|
2547
|
+
]);
|
|
2548
|
+
yield* Effect.logInfo(`\nVerify: samva email senders check ${data.id}`);
|
|
2549
|
+
})
|
|
2550
|
+
});
|
|
2551
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
2552
|
+
const check = Command.make("check", {
|
|
2553
|
+
id: Argument.string("id").pipe(Argument.withDescription("Sender id to check")),
|
|
2554
|
+
json: jsonFlag
|
|
2555
|
+
}).pipe(Command.withDescription("Check a sender's verification status"), Command.withExamples([{
|
|
2556
|
+
command: "samva email senders check snd_abc123",
|
|
2557
|
+
description: "Poll sender verification"
|
|
2558
|
+
}]), Command.withHandler(({ id, json }) => Effect.gen(function* () {
|
|
2559
|
+
const client = yield* getClientProvider;
|
|
2560
|
+
const data = yield* Email.checkSenderVerification({ id }).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not check sender", error) })));
|
|
2561
|
+
if (json) return yield* printJson(data);
|
|
2562
|
+
yield* printFields([
|
|
2563
|
+
["Email", data.email],
|
|
2564
|
+
["Status", data.status],
|
|
2565
|
+
["Verified at", data.verifiedAt ?? null]
|
|
2566
|
+
]);
|
|
2567
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
2568
|
+
const remove$2 = Command.make("remove", {
|
|
2569
|
+
id: Argument.string("id").pipe(Argument.withDescription("Sender id to permanently remove")),
|
|
2570
|
+
dryRun: Flag.boolean("dry-run").pipe(Flag.withDescription("Print the deletion plan without calling the API"), Flag.withDefault(false)),
|
|
2571
|
+
yes: Flag.boolean("yes").pipe(Flag.withDescription("Confirm permanent deletion (required for non-interactive use)"), Flag.withDefault(false)),
|
|
2572
|
+
json: jsonFlag
|
|
2573
|
+
}).pipe(Command.withDescription("Permanently remove a sender identity"), Command.withExamples([{ command: "samva email senders remove snd_abc123 --dry-run" }, { command: "samva email senders remove snd_abc123 --yes" }]), Command.withHandler(({ id, dryRun, yes, json }) => Effect.gen(function* () {
|
|
2574
|
+
if (dryRun) return yield* json ? printJson({
|
|
2575
|
+
dryRun: true,
|
|
2576
|
+
action: "email.senders.remove",
|
|
2577
|
+
id
|
|
2578
|
+
}) : Console.log(`[dry-run] Would permanently remove sender ${id}.`);
|
|
2579
|
+
yield* confirmDestructive({
|
|
2580
|
+
yes,
|
|
2581
|
+
json,
|
|
2582
|
+
message: `Permanently remove sender ${id}?`,
|
|
2583
|
+
nonInteractiveMessage: "Permanent sender deletion requires --yes in non-interactive mode. Preview with --dry-run."
|
|
2584
|
+
});
|
|
2585
|
+
const client = yield* getClientProvider;
|
|
2586
|
+
const data = yield* Email.removeSender({ id }).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not remove sender", error) })));
|
|
2587
|
+
if (json) return yield* printJson(data);
|
|
2588
|
+
yield* Console.log(`Removed sender ${id}.`);
|
|
2589
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
2590
|
+
const sendersCommand = Command.make("senders").pipe(Command.withDescription("Manage sender addresses"), Command.withExamples([{
|
|
2591
|
+
command: "samva email senders list",
|
|
2592
|
+
description: "List sender addresses"
|
|
2593
|
+
}]), Command.withSubcommands([
|
|
2594
|
+
list$4,
|
|
2595
|
+
add,
|
|
2596
|
+
check,
|
|
2597
|
+
remove$2
|
|
2598
|
+
]));
|
|
2599
|
+
//#endregion
|
|
2600
|
+
//#region src/commands/email/tracking.ts
|
|
2601
|
+
const toggle = (name, description) => Flag.choice(name, ["on", "off"]).pipe(Flag.withDescription(description), Flag.optional);
|
|
2602
|
+
const validateRecipientEmail = (email) => Schema.decodeUnknownEffect(Email.TrackingRecipientEmail)(email).pipe(Effect.mapError(() => new CliUsageError({
|
|
2603
|
+
code: "invalid_recipient_email",
|
|
2604
|
+
message: "Recipient must be a valid email address."
|
|
2605
|
+
})));
|
|
2606
|
+
const defaultsGet = Command.make("get", { json: jsonFlag }).pipe(Command.withDescription("Get organization email tracking defaults"), Command.withExamples([{ command: "samva email tracking defaults get --json" }]), Command.withHandler(({ json }) => Effect.gen(function* () {
|
|
2607
|
+
const client = yield* getClientProvider;
|
|
2608
|
+
const data = yield* Email.getTrackingDefaults().pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not get email tracking defaults", error) })));
|
|
2609
|
+
if (json) return yield* printJson(data);
|
|
2610
|
+
yield* printFields([["Track opens", data.opens ? "on" : "off"], ["Track clicks", data.clicks ? "on" : "off"]]);
|
|
2611
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
2612
|
+
const defaultsSet = Command.make("set", {
|
|
2613
|
+
opens: toggle("opens", "Set the organization open-tracking default (on or off)"),
|
|
2614
|
+
clicks: toggle("clicks", "Set the organization click-tracking default (on or off)"),
|
|
2615
|
+
dryRun: Flag.boolean("dry-run").pipe(Flag.withDescription("Print the planned change without calling the API"), Flag.withDefault(false)),
|
|
2616
|
+
json: jsonFlag
|
|
2617
|
+
}).pipe(Command.withDescription("Update one or both organization email tracking defaults"), Command.withExamples([{ command: "samva email tracking defaults set --opens off --dry-run" }, { command: "samva email tracking defaults set --opens on --clicks off" }]), Command.withHandler(({ opens, clicks, dryRun, json }) => Effect.gen(function* () {
|
|
2618
|
+
const opensValue = Option.getOrUndefined(opens);
|
|
2619
|
+
const clicksValue = Option.getOrUndefined(clicks);
|
|
2620
|
+
if (opensValue === void 0 && clicksValue === void 0) return yield* new CliUsageError({
|
|
2621
|
+
code: "missing_tracking_default",
|
|
2622
|
+
message: "Provide --opens, --clicks, or both."
|
|
2623
|
+
});
|
|
2624
|
+
const request = {
|
|
2625
|
+
...opensValue === void 0 ? {} : { opens: opensValue === "on" },
|
|
2626
|
+
...clicksValue === void 0 ? {} : { clicks: clicksValue === "on" }
|
|
2627
|
+
};
|
|
2628
|
+
if (dryRun) {
|
|
2629
|
+
if (json) return yield* printJson({
|
|
2630
|
+
dryRun: true,
|
|
2631
|
+
action: "email.tracking.defaults.set",
|
|
2632
|
+
request
|
|
2633
|
+
});
|
|
2634
|
+
return yield* Console.log(`[dry-run] Would update email tracking defaults: ${JSON.stringify(request)}.`);
|
|
2635
|
+
}
|
|
2636
|
+
const client = yield* getClientProvider;
|
|
2637
|
+
const data = yield* Email.updateTrackingDefaults(request).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not update email tracking defaults", error) })));
|
|
2638
|
+
if (json) return yield* printJson(data);
|
|
2639
|
+
yield* printFields([["Track opens", data.opens ? "on" : "off"], ["Track clicks", data.clicks ? "on" : "off"]]);
|
|
2640
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
2641
|
+
const recipientGet = Command.make("get", {
|
|
2642
|
+
email: Argument.string("email").pipe(Argument.withDescription("Recipient email address")),
|
|
2643
|
+
json: jsonFlag
|
|
2644
|
+
}).pipe(Command.withDescription("Get a recipient's email tracking preference"), Command.withExamples([{ command: "samva email tracking recipient get person@example.com --json" }]), Command.withHandler(({ email, json }) => Effect.gen(function* () {
|
|
2645
|
+
const validatedEmail = yield* validateRecipientEmail(email);
|
|
2646
|
+
const client = yield* getClientProvider;
|
|
2647
|
+
const data = yield* Email.getRecipientTrackingPreference({ email: validatedEmail }).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not get recipient tracking preference", error) })));
|
|
2648
|
+
if (json) return yield* printJson(data);
|
|
2649
|
+
yield* printFields([
|
|
2650
|
+
["Email", data.email],
|
|
2651
|
+
["Tracking", data.optedOut ? "opted out" : "opted in"],
|
|
2652
|
+
["Opted out at", data.trackingOptedOutAt ?? null],
|
|
2653
|
+
["Updated at", data.updatedAt ?? null]
|
|
2654
|
+
]);
|
|
2655
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
2656
|
+
const recipientSet = Command.make("set", {
|
|
2657
|
+
email: Argument.string("email").pipe(Argument.withDescription("Recipient email address")),
|
|
2658
|
+
state: Flag.choice("state", ["opted-in", "opted-out"]).pipe(Flag.withDescription("Set the recipient tracking state (opted-in or opted-out)")),
|
|
2659
|
+
dryRun: Flag.boolean("dry-run").pipe(Flag.withDescription("Print the planned change without calling the API"), Flag.withDefault(false)),
|
|
2660
|
+
json: jsonFlag
|
|
2661
|
+
}).pipe(Command.withDescription("Opt a recipient in or out of email engagement tracking"), Command.withExamples([{ command: "samva email tracking recipient set person@example.com --state opted-out --dry-run" }, { command: "samva email tracking recipient set person@example.com --state opted-in" }]), Command.withHandler(({ email, state, dryRun, json }) => Effect.gen(function* () {
|
|
2662
|
+
const request = {
|
|
2663
|
+
email: yield* validateRecipientEmail(email),
|
|
2664
|
+
optedOut: state === "opted-out"
|
|
2665
|
+
};
|
|
2666
|
+
if (dryRun) {
|
|
2667
|
+
if (json) return yield* printJson({
|
|
2668
|
+
dryRun: true,
|
|
2669
|
+
action: "email.tracking.recipient.set",
|
|
2670
|
+
request
|
|
2671
|
+
});
|
|
2672
|
+
return yield* Console.log(`[dry-run] Would set ${email} to ${state} for email engagement tracking.`);
|
|
2673
|
+
}
|
|
2674
|
+
const client = yield* getClientProvider;
|
|
2675
|
+
const data = yield* Email.updateRecipientTrackingPreference(request).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not update recipient tracking preference", error) })));
|
|
2676
|
+
if (json) return yield* printJson(data);
|
|
2677
|
+
yield* printFields([
|
|
2678
|
+
["Email", data.email],
|
|
2679
|
+
["Tracking", data.optedOut ? "opted out" : "opted in"],
|
|
2680
|
+
["Opted out at", data.trackingOptedOutAt ?? null],
|
|
2681
|
+
["Updated at", data.updatedAt ?? null]
|
|
2682
|
+
]);
|
|
2683
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
2684
|
+
const defaults = Command.make("defaults").pipe(Command.withDescription("Read or update organization tracking defaults"), Command.withSubcommands([defaultsGet, defaultsSet]));
|
|
2685
|
+
const recipient = Command.make("recipient").pipe(Command.withDescription("Read or update a recipient tracking preference"), Command.withSubcommands([recipientGet, recipientSet]));
|
|
2686
|
+
const trackingCommand = Command.make("tracking").pipe(Command.withDescription("Configure email engagement tracking"), Command.withExamples([{ command: "samva email tracking defaults get" }]), Command.withSubcommands([defaults, recipient]));
|
|
2687
|
+
//#endregion
|
|
2688
|
+
//#region src/commands/email.ts
|
|
2689
|
+
const emailCommand = Command.make("email").pipe(Command.withDescription("Send, inspect, and configure email"), Command.withExamples([{
|
|
2690
|
+
command: "samva email send --to you@example.com --subject Hi --text Hello",
|
|
2691
|
+
description: "Send a plain-text email"
|
|
2692
|
+
}, {
|
|
2693
|
+
command: "samva email doctor",
|
|
2694
|
+
description: "Inspect read-only email readiness"
|
|
2695
|
+
}]), Command.withSubcommands([
|
|
2696
|
+
send,
|
|
2697
|
+
doctorCommand,
|
|
2698
|
+
receivingCommand,
|
|
2699
|
+
domainsCommand,
|
|
2700
|
+
sendersCommand,
|
|
2701
|
+
trackingCommand,
|
|
2702
|
+
designCommand
|
|
2703
|
+
]));
|
|
2704
|
+
//#endregion
|
|
2705
|
+
//#region src/lib/examples.ts
|
|
2706
|
+
/** Shown in help descriptions for automation-oriented commands. */
|
|
2707
|
+
const AGENT_AUTH_HINT = "For CI and agents, set SAMVA_API_KEY instead of samva login.";
|
|
2708
|
+
//#endregion
|
|
2709
|
+
//#region src/lib/oauth.ts
|
|
2710
|
+
/**
|
|
2711
|
+
* OAuth device-authorization flow for `samva login`. These call Better Auth's
|
|
2712
|
+
* device endpoints under `/v1/auth/*` directly (not the product SDK) — the same
|
|
2713
|
+
* documented exception as `lib/http` for auth/ingress/health endpoints.
|
|
2714
|
+
*/
|
|
2715
|
+
/** First-party public client id; allow-listed by the AS `validateClient`. */
|
|
2716
|
+
const SAMVA_CLI_CLIENT_ID = "samva-cli";
|
|
2717
|
+
const DEVICE_SCOPE = "openid profile email";
|
|
2718
|
+
const parseJson = (body, context) => Effect.try({
|
|
2719
|
+
try: () => JSON.parse(body),
|
|
2720
|
+
catch: () => new SamvaCliError({ message: `Unexpected response from ${context}.` })
|
|
2721
|
+
});
|
|
2722
|
+
/** RFC 8628 §3.1 — request a device code + user code. */
|
|
2723
|
+
const requestDeviceCode = (apiUrl) => Effect.gen(function* () {
|
|
2724
|
+
const res = yield* postJson(`${apiUrl}/v1/auth/device/code`, {
|
|
2725
|
+
client_id: SAMVA_CLI_CLIENT_ID,
|
|
2726
|
+
scope: DEVICE_SCOPE
|
|
2727
|
+
});
|
|
2728
|
+
if (!res.ok) return yield* new SamvaCliError({ message: `Could not start login (device/code returned ${res.status}). ${res.body}` });
|
|
2729
|
+
return yield* parseJson(res.body, "device/code");
|
|
2730
|
+
});
|
|
2731
|
+
/**
|
|
2732
|
+
* Internal retry signal for the RFC 8628 poll loop: the grant is not yet
|
|
2733
|
+
* decided (`authorization_pending` / `slow_down`). Never escapes
|
|
2734
|
+
* `pollForToken` — the retry schedule always recurs on it and only the
|
|
2735
|
+
* `expires_in` deadline ends the loop.
|
|
2736
|
+
*/
|
|
2737
|
+
var DeviceAuthorizationPending = class extends Schema.TaggedErrorClass()("DeviceAuthorizationPending", {}) {};
|
|
2738
|
+
/**
|
|
2739
|
+
* RFC 8628 §3.4 — poll the token endpoint until the user approves, denies, or
|
|
2740
|
+
* the code expires. Honors `authorization_pending` / `slow_down` backoff.
|
|
2741
|
+
*
|
|
2742
|
+
* Clock/Schedule-driven: waits come from a retry schedule whose delay reads a
|
|
2743
|
+
* `Ref` holding the current interval (§3.5 `slow_down` grows it by 5s), and
|
|
2744
|
+
* the `expires_in` deadline is an `Effect.timeoutOrElse` — all TestClock-
|
|
2745
|
+
* advanceable.
|
|
2746
|
+
*/
|
|
2747
|
+
const pollForToken = (apiUrl, deviceCode, intervalSeconds, expiresInSeconds) => Effect.gen(function* () {
|
|
2748
|
+
const initialInterval = Duration.seconds(Math.max(intervalSeconds, 1));
|
|
2749
|
+
const interval = yield* Ref.make(initialInterval);
|
|
2750
|
+
const pollOnce = Effect.gen(function* () {
|
|
2751
|
+
const res = yield* postJson(`${apiUrl}/v1/auth/device/token`, {
|
|
2752
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
2753
|
+
device_code: deviceCode,
|
|
2754
|
+
client_id: SAMVA_CLI_CLIENT_ID
|
|
2755
|
+
});
|
|
2756
|
+
if (res.ok) return yield* parseJson(res.body, "device/token");
|
|
2757
|
+
const { error, error_description } = yield* parseJson(res.body, "device/token");
|
|
2758
|
+
if (error === "authorization_pending") return yield* new DeviceAuthorizationPending();
|
|
2759
|
+
if (error === "slow_down") {
|
|
2760
|
+
yield* Ref.update(interval, Duration.sum(Duration.seconds(5)));
|
|
2761
|
+
return yield* new DeviceAuthorizationPending();
|
|
2762
|
+
}
|
|
2763
|
+
if (error === "access_denied") return yield* new CliAuthError({
|
|
2764
|
+
code: "authorization_denied",
|
|
2765
|
+
message: "Login was denied in the browser."
|
|
2766
|
+
});
|
|
2767
|
+
if (error === "expired_token") return yield* new CliTimeoutError({
|
|
2768
|
+
code: "login_timeout",
|
|
2769
|
+
message: "The login request expired before approval. Run `samva login` again."
|
|
2770
|
+
});
|
|
2771
|
+
return yield* new SamvaCliError({ message: `Login failed: ${error_description ?? error ?? res.body}` });
|
|
2772
|
+
});
|
|
2773
|
+
const pendingRetry = Schedule.forever.pipe(Schedule.setInputType(), Schedule.while(({ input }) => input instanceof DeviceAuthorizationPending), Schedule.modifyDelay(() => Ref.get(interval)));
|
|
2774
|
+
return yield* Effect.gen(function* () {
|
|
2775
|
+
yield* Effect.sleep(initialInterval);
|
|
2776
|
+
return yield* pollOnce.pipe(Effect.retry(pendingRetry));
|
|
2777
|
+
}).pipe(Effect.timeoutOrElse({
|
|
2778
|
+
duration: Duration.seconds(expiresInSeconds),
|
|
2779
|
+
orElse: () => Effect.fail(new CliTimeoutError({
|
|
2780
|
+
code: "login_timeout",
|
|
2781
|
+
message: "Login timed out. Run `samva login` again."
|
|
2782
|
+
}))
|
|
2783
|
+
}), Effect.catchTag("DeviceAuthorizationPending", () => Effect.fail(new CliTimeoutError({
|
|
2784
|
+
code: "login_timeout",
|
|
2785
|
+
message: "Login timed out. Run `samva login` again."
|
|
2786
|
+
}))));
|
|
2787
|
+
});
|
|
2788
|
+
/** Open a URL in the system browser. Best-effort — never fails the flow. */
|
|
2789
|
+
const openBrowser = (url) => {
|
|
2790
|
+
const platform = process.platform;
|
|
2791
|
+
const [command, args] = platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", [
|
|
2792
|
+
"/c",
|
|
2793
|
+
"start",
|
|
2794
|
+
"",
|
|
2795
|
+
url
|
|
2796
|
+
]] : ["xdg-open", [url]];
|
|
2797
|
+
return Effect.tryPromise(async () => {
|
|
2798
|
+
const { spawn } = await import("node:child_process");
|
|
2799
|
+
const child = spawn(command, [...args], {
|
|
2800
|
+
stdio: "ignore",
|
|
2801
|
+
detached: true
|
|
2802
|
+
});
|
|
2803
|
+
child.on("error", () => void 0);
|
|
2804
|
+
child.unref();
|
|
2805
|
+
}).pipe(Effect.catchTag("UnknownError", () => Effect.void));
|
|
2806
|
+
};
|
|
2807
|
+
/** Best-effort server-side session revoke on logout (Better Auth `/sign-out`). */
|
|
2808
|
+
const revokeSession = (apiUrl, accessToken) => Effect.tryPromise({
|
|
2809
|
+
try: async () => {
|
|
2810
|
+
await fetch(`${apiUrl}/v1/auth/sign-out`, {
|
|
2811
|
+
method: "POST",
|
|
2812
|
+
headers: {
|
|
2813
|
+
"content-type": "application/json",
|
|
2814
|
+
authorization: `Bearer ${accessToken}`
|
|
2815
|
+
}
|
|
2816
|
+
});
|
|
2817
|
+
},
|
|
2818
|
+
catch: (cause) => new SamvaCliError({ message: `Sign-out request failed: ${String(cause)}` })
|
|
2819
|
+
});
|
|
2820
|
+
/**
|
|
2821
|
+
* List the organizations the signed-in user belongs to (Better Auth org plugin
|
|
2822
|
+
* `/organization/list`). User-scoped — unlike the product `organizations.get()`
|
|
2823
|
+
* endpoint it needs no `x-org-slug`, so it works immediately after `samva login`.
|
|
2824
|
+
*/
|
|
2825
|
+
const listOrganizations = (apiUrl, accessToken) => Effect.gen(function* () {
|
|
2826
|
+
const result = yield* Effect.tryPromise({
|
|
2827
|
+
try: async () => {
|
|
2828
|
+
const res = await fetch(`${apiUrl}/v1/auth/organization/list`, { headers: { authorization: `Bearer ${accessToken}` } });
|
|
2829
|
+
return {
|
|
2830
|
+
ok: res.ok,
|
|
2831
|
+
status: res.status,
|
|
2832
|
+
body: await res.text()
|
|
2833
|
+
};
|
|
2834
|
+
},
|
|
2835
|
+
catch: (cause) => new SamvaCliError({ message: `Could not list organizations: ${String(cause)}` })
|
|
2836
|
+
});
|
|
2837
|
+
if (!result.ok) return yield* new SamvaCliError({ message: `Could not list organizations (returned ${result.status}). ${result.body}` });
|
|
2838
|
+
return yield* parseJson(result.body, "organization/list");
|
|
2839
|
+
});
|
|
2840
|
+
//#endregion
|
|
2841
|
+
//#region src/commands/login.ts
|
|
2842
|
+
const noBrowserFlag = Flag.boolean("no-browser").pipe(Flag.withDescription("Print the verification URL instead of opening a browser"));
|
|
2843
|
+
const loginCommand = Command.make("login", { noBrowser: noBrowserFlag }).pipe(Command.withDescription(`Sign in to Samva in your browser (OAuth device flow). ${AGENT_AUTH_HINT}`), Command.withExamples([{
|
|
2844
|
+
command: "samva login",
|
|
2845
|
+
description: "Interactive OAuth (opens browser)"
|
|
2846
|
+
}, {
|
|
2847
|
+
command: "samva login --no-browser",
|
|
2848
|
+
description: "Print verification URL instead of opening a browser"
|
|
2849
|
+
}]), Command.withHandler(Effect.fnUntraced(function* ({ noBrowser }) {
|
|
2850
|
+
if ((yield* Json) || (yield* JsonLines) || (yield* NoInput)) return yield* new CliUsageError({
|
|
2851
|
+
code: "interactive_login_disabled",
|
|
2852
|
+
message: "Login is interactive and unavailable with --json, --jsonl, or --no-input. Use human mode, or set SAMVA_API_KEY for automation."
|
|
2853
|
+
});
|
|
2854
|
+
if (!process.stdin.isTTY && !noBrowser) return yield* new CliUsageError({
|
|
2855
|
+
code: "non_interactive_login_requires_device_flow",
|
|
2856
|
+
message: "Login without a terminal requires SAMVA_API_KEY for automation. To use OAuth device flow explicitly, run `samva login --no-browser`."
|
|
2857
|
+
});
|
|
2858
|
+
const apiUrl = yield* resolveApiUrl;
|
|
2859
|
+
const device = yield* requestDeviceCode(apiUrl);
|
|
2860
|
+
const verifyUrl = device.verification_uri_complete ?? device.verification_uri;
|
|
2861
|
+
yield* Console.error(`\nTo authorize the Samva CLI, visit:\n\n ${device.verification_uri}\n`);
|
|
2862
|
+
yield* Console.error(`and enter the code:\n\n ${device.user_code}\n`);
|
|
2863
|
+
if (noBrowser) yield* Console.error(`Or open this URL directly:\n\n ${verifyUrl}\n`);
|
|
2864
|
+
else {
|
|
2865
|
+
yield* openBrowser(verifyUrl);
|
|
2866
|
+
yield* Effect.logInfo("Opened your browser to finish login. Waiting for approval…");
|
|
2867
|
+
}
|
|
2868
|
+
const token = yield* pollForToken(apiUrl, device.device_code, device.interval, device.expires_in);
|
|
2869
|
+
const nowMs = yield* Clock.currentTimeMillis;
|
|
2870
|
+
yield* writeCredentials({
|
|
2871
|
+
accessToken: token.access_token,
|
|
2872
|
+
tokenType: token.token_type,
|
|
2873
|
+
expiresAt: Math.floor(nowMs / 1e3) + token.expires_in,
|
|
2874
|
+
scope: token.scope
|
|
2875
|
+
});
|
|
2876
|
+
yield* printResult({ action: "login" }, "Logged in.");
|
|
2877
|
+
})));
|
|
2878
|
+
//#endregion
|
|
2879
|
+
//#region src/commands/logout.ts
|
|
2880
|
+
const logoutCommand = Command.make("logout").pipe(Command.withDescription("Sign out and remove stored credentials"), Command.withExamples([{
|
|
2881
|
+
command: "samva logout",
|
|
2882
|
+
description: "Clear stored OAuth tokens"
|
|
2883
|
+
}]), Command.withHandler(Effect.fnUntraced(function* () {
|
|
2884
|
+
const credentials = yield* readCredentials.pipe(Effect.catchIf((error) => error instanceof CliConfigError && error.code === "invalid_credential_record", () => Effect.void));
|
|
2885
|
+
if (credentials) {
|
|
2886
|
+
const apiUrl = yield* resolveApiUrl;
|
|
2887
|
+
yield* revokeSession(apiUrl, credentials.accessToken).pipe(Effect.catchTag("SamvaCliError", () => Effect.void));
|
|
2888
|
+
}
|
|
2889
|
+
yield* clearCredentials;
|
|
2890
|
+
yield* printResult({ action: "logout" }, "Logged out.");
|
|
2891
|
+
})));
|
|
2892
|
+
//#endregion
|
|
2893
|
+
//#region src/commands/usage.ts
|
|
2894
|
+
const periodFlag = Flag.choice("period", [
|
|
2895
|
+
"current",
|
|
2896
|
+
"last_30_days",
|
|
2897
|
+
"last_90_days",
|
|
2898
|
+
"last_year"
|
|
2899
|
+
]).pipe(Flag.withDescription("Reporting period"), Flag.withDefault("current"));
|
|
2900
|
+
const usageCommand = Command.make("usage", {
|
|
2901
|
+
period: periodFlag,
|
|
2902
|
+
json: jsonFlag
|
|
2903
|
+
}).pipe(Command.withDescription("Get organization usage"), Command.withExamples([{ command: "samva org usage --period last_30_days --json" }]), Command.withHandler(({ period, json }) => Effect.gen(function* () {
|
|
2904
|
+
const client = yield* getClientProvider;
|
|
2905
|
+
const data = yield* Organizations.getUsage({ period }).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not get usage", error) })));
|
|
2906
|
+
if (json) return yield* printJson(data);
|
|
2907
|
+
const { periodStart, periodEnd, ...counts } = data.usage;
|
|
2908
|
+
yield* printFields([
|
|
2909
|
+
["Start", periodStart],
|
|
2910
|
+
["End", periodEnd],
|
|
2911
|
+
["Plan emails", data.billing.includedEmails === null ? "—" : `${data.billing.usedEmails} of ${data.billing.includedEmails}`],
|
|
2912
|
+
["Overage", data.billing.overageCost === null ? "—" : `${data.billing.overageCost} ${data.billing.currency}`]
|
|
2913
|
+
]);
|
|
2914
|
+
yield* printJson(counts);
|
|
2915
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
2916
|
+
//#endregion
|
|
2917
|
+
//#region src/commands/org.ts
|
|
2918
|
+
const list$3 = Command.make("list", { json: jsonFlag }).pipe(Command.withDescription("List the organizations you can access (multi-org under OAuth; single org under API key)"), Command.withExamples([{
|
|
2919
|
+
command: "samva org list",
|
|
2920
|
+
description: "List orgs (OAuth) or show API-key org"
|
|
2921
|
+
}, {
|
|
2922
|
+
command: "samva org list --json",
|
|
2923
|
+
description: "Machine-readable org list"
|
|
2924
|
+
}]), Command.withHandler(({ json }) => Effect.gen(function* () {
|
|
2925
|
+
const context = yield* resolveExecutionContext;
|
|
2926
|
+
if (context.credentials) {
|
|
2927
|
+
if (isCredentialExpired(context.credentials, yield* Clock.currentTimeMillis)) return yield* new CliAuthError({
|
|
2928
|
+
code: "expired_credentials",
|
|
2929
|
+
message: "Your session has expired. Run `samva login` to sign in again."
|
|
2930
|
+
});
|
|
2931
|
+
const orgs = yield* listOrganizations(context.apiUrl, context.credentials.accessToken);
|
|
2932
|
+
if (json) return yield* printJson(orgs);
|
|
2933
|
+
if (orgs.length === 0) return yield* Console.log("You are not a member of any organizations yet.");
|
|
2934
|
+
const lines = orgs.map((org) => {
|
|
2935
|
+
return `${org.slug === context.profileOrgSlug ? "* " : " "}${org.slug} ${org.name}`;
|
|
2936
|
+
});
|
|
2937
|
+
yield* Console.log(lines.join("\n"));
|
|
2938
|
+
return yield* Effect.logInfo("\nSet the active organization with `samva org use <slug>`.");
|
|
2939
|
+
}
|
|
2940
|
+
const client = yield* getClientProvider;
|
|
2941
|
+
const data = yield* Organizations.get().pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not load organization", error) })));
|
|
2942
|
+
if (json) return yield* printJson([data]);
|
|
2943
|
+
yield* printFields([
|
|
2944
|
+
["Name", data.name],
|
|
2945
|
+
["Slug", data.slug],
|
|
2946
|
+
["ID", data.id],
|
|
2947
|
+
["Plan", data.plan]
|
|
2948
|
+
]);
|
|
2949
|
+
yield* Effect.logInfo("\nNote: an API key is scoped to a single organization. Run `samva login` for multi-org access.");
|
|
2950
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
2951
|
+
const use$1 = Command.make("use", { slug: Argument.string("slug") }).pipe(Command.withDescription("Set the active organization for OAuth sessions (ignored when using SAMVA_API_KEY)"), Command.withExamples([{
|
|
2952
|
+
command: "samva org use my-org-slug",
|
|
2953
|
+
description: "Set active org after samva login"
|
|
2954
|
+
}]), Command.withHandler(Effect.fnUntraced(function* ({ slug }) {
|
|
2955
|
+
const config = yield* readConfig;
|
|
2956
|
+
const context = yield* resolveExecutionContext;
|
|
2957
|
+
if (context.apiKey) return yield* new CliAuthError({
|
|
2958
|
+
code: "api_key_org_override",
|
|
2959
|
+
message: "samva org use is unavailable with SAMVA_API_KEY; API keys are organization-scoped."
|
|
2960
|
+
});
|
|
2961
|
+
const current = config.profiles[context.profile] ?? {
|
|
2962
|
+
target: "production",
|
|
2963
|
+
credentialIdentity: "none"
|
|
2964
|
+
};
|
|
2965
|
+
yield* writeConfig({
|
|
2966
|
+
...config,
|
|
2967
|
+
profiles: {
|
|
2968
|
+
...config.profiles,
|
|
2969
|
+
[context.profile]: {
|
|
2970
|
+
...current,
|
|
2971
|
+
orgSlug: slug
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2974
|
+
});
|
|
2975
|
+
yield* printResult({
|
|
2976
|
+
action: "org.use",
|
|
2977
|
+
slug
|
|
2978
|
+
}, `Active organization set to "${slug}".`);
|
|
2979
|
+
})));
|
|
2980
|
+
const current = Command.make("current", { json: jsonFlag }).pipe(Command.withDescription("Show the active organization slug"), Command.withExamples([{
|
|
2981
|
+
command: "samva org current",
|
|
2982
|
+
description: "Print active org slug"
|
|
2983
|
+
}, {
|
|
2984
|
+
command: "samva org current --json",
|
|
2985
|
+
description: "JSON: { \"activeOrgSlug\": \"...\" }"
|
|
2986
|
+
}]), Command.withHandler(({ json }) => Effect.gen(function* () {
|
|
2987
|
+
const slug = (yield* resolveExecutionContext).orgSlug;
|
|
2988
|
+
if (json) {
|
|
2989
|
+
if (!slug) return yield* new CliAuthError({
|
|
2990
|
+
code: "missing_organization",
|
|
2991
|
+
message: "No active organization set. Run `samva org use <slug>` or `samva org list`."
|
|
2992
|
+
});
|
|
2993
|
+
return yield* printJson({ activeOrgSlug: slug });
|
|
2994
|
+
}
|
|
2995
|
+
if (!slug) return yield* new CliAuthError({
|
|
2996
|
+
code: "missing_organization",
|
|
2997
|
+
message: "No active organization set. Run `samva org use <slug>` or `samva org list`."
|
|
2998
|
+
});
|
|
2999
|
+
yield* Console.log(slug);
|
|
3000
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
3001
|
+
const orgCommand = Command.make("org").pipe(Command.withDescription(`Manage organizations. ${AGENT_AUTH_HINT}`), Command.withExamples([{
|
|
3002
|
+
command: "samva org list",
|
|
3003
|
+
description: "List accessible organizations"
|
|
3004
|
+
}, {
|
|
3005
|
+
command: "samva org use my-org-slug",
|
|
3006
|
+
description: "Set active org (OAuth)"
|
|
3007
|
+
}]), Command.withSubcommands([
|
|
3008
|
+
list$3,
|
|
3009
|
+
use$1,
|
|
3010
|
+
current,
|
|
3011
|
+
usageCommand
|
|
3012
|
+
]));
|
|
3013
|
+
//#endregion
|
|
3014
|
+
//#region src/commands/profile.ts
|
|
3015
|
+
const list$2 = Command.make("list").pipe(Command.withDescription("List user-level execution profiles"), Command.withHandler(Effect.fnUntraced(function* () {
|
|
3016
|
+
const config = yield* readConfig;
|
|
3017
|
+
if ((yield* Json) || (yield* JsonLines)) return yield* printJson({
|
|
3018
|
+
activeProfile: config.activeProfile ?? "default",
|
|
3019
|
+
profiles: config.profiles
|
|
3020
|
+
});
|
|
3021
|
+
const active = config.activeProfile ?? "default";
|
|
3022
|
+
const lines = Object.entries(config.profiles).map(([name, profile]) => `${name === active ? "* " : " "}${name}\t${profile.target}${profile.orgSlug ? `\t${profile.orgSlug}` : ""}`);
|
|
3023
|
+
yield* Console.log(lines.length > 0 ? lines.join("\n") : "No profiles yet. Use `samva profile create <name>`.");
|
|
3024
|
+
})));
|
|
3025
|
+
const show = Command.make("show", { name: Argument.string("name").pipe(Argument.optional) }).pipe(Command.withDescription("Show the resolved execution context"), Command.withHandler(Effect.fnUntraced(function* ({ name }) {
|
|
3026
|
+
const context = yield* resolveExecutionContext;
|
|
3027
|
+
const selected = Option.getOrUndefined(name) ?? context.profile;
|
|
3028
|
+
const profile = (yield* readConfig).profiles[selected];
|
|
3029
|
+
if (!profile && selected !== "default") return yield* new CliUsageError({
|
|
3030
|
+
code: "unknown_profile",
|
|
3031
|
+
message: `Profile ${selected} does not exist.`
|
|
3032
|
+
});
|
|
3033
|
+
const output = {
|
|
3034
|
+
name: selected,
|
|
3035
|
+
...profile ?? {
|
|
3036
|
+
target: "production",
|
|
3037
|
+
credentialIdentity: "none"
|
|
3038
|
+
},
|
|
3039
|
+
apiUrl: selected === context.profile ? context.apiUrl : void 0
|
|
3040
|
+
};
|
|
3041
|
+
if ((yield* Json) || (yield* JsonLines)) return yield* printJson(output);
|
|
3042
|
+
yield* Console.log(`${output.name}\t${output.target}${output.orgSlug ? `\t${output.orgSlug}` : ""}`);
|
|
3043
|
+
})));
|
|
3044
|
+
const create$2 = Command.make("create", {
|
|
3045
|
+
name: Argument.string("name"),
|
|
3046
|
+
target: Flag.choice("target", ["production", "local"]).pipe(Flag.withDefault("production")),
|
|
3047
|
+
org: Flag.string("org").pipe(Flag.optional)
|
|
3048
|
+
}).pipe(Command.withDescription("Create a non-secret execution profile"), Command.withHandler(Effect.fnUntraced(function* ({ name, target, org }) {
|
|
3049
|
+
yield* validateProfileName(name);
|
|
3050
|
+
const config = yield* readConfig;
|
|
3051
|
+
if (config.profiles[name]) return yield* new CliUsageError({
|
|
3052
|
+
code: "profile_exists",
|
|
3053
|
+
message: `Profile ${name} already exists. Delete it first or choose another name.`
|
|
3054
|
+
});
|
|
3055
|
+
const orgSlug = Option.getOrUndefined(org);
|
|
3056
|
+
yield* writeConfig({
|
|
3057
|
+
...config,
|
|
3058
|
+
profiles: {
|
|
3059
|
+
...config.profiles,
|
|
3060
|
+
[name]: {
|
|
3061
|
+
target,
|
|
3062
|
+
orgSlug,
|
|
3063
|
+
credentialIdentity: "none"
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
});
|
|
3067
|
+
yield* printResult({
|
|
3068
|
+
action: "profile.create",
|
|
3069
|
+
name,
|
|
3070
|
+
target,
|
|
3071
|
+
orgSlug
|
|
3072
|
+
}, `Created profile ${name}.`);
|
|
3073
|
+
})));
|
|
3074
|
+
const use = Command.make("use", { name: Argument.string("name") }).pipe(Command.withDescription("Select the active user-level execution profile"), Command.withHandler(Effect.fnUntraced(function* ({ name }) {
|
|
3075
|
+
const config = yield* readConfig;
|
|
3076
|
+
if (!config.profiles[name]) return yield* new CliUsageError({
|
|
3077
|
+
code: "unknown_profile",
|
|
3078
|
+
message: `Profile ${name} does not exist. Create it with \`samva profile create ${name}\`.`
|
|
3079
|
+
});
|
|
3080
|
+
yield* writeConfig({
|
|
3081
|
+
...config,
|
|
3082
|
+
activeProfile: name
|
|
3083
|
+
});
|
|
3084
|
+
yield* printResult({
|
|
3085
|
+
action: "profile.use",
|
|
3086
|
+
name
|
|
3087
|
+
}, `Active profile set to ${name}.`);
|
|
3088
|
+
})));
|
|
3089
|
+
const remove$1 = Command.make("delete", {
|
|
3090
|
+
name: Argument.string("name"),
|
|
3091
|
+
yes: Flag.boolean("yes").pipe(Flag.withDefault(false))
|
|
3092
|
+
}).pipe(Command.withDescription("Delete a profile and its OS-keyring credential"), Command.withHandler(Effect.fnUntraced(function* ({ name, yes }) {
|
|
3093
|
+
const config = yield* readConfig;
|
|
3094
|
+
const profile = config.profiles[name];
|
|
3095
|
+
if (!profile) return yield* new CliUsageError({
|
|
3096
|
+
code: "unknown_profile",
|
|
3097
|
+
message: `Profile ${name} does not exist.`
|
|
3098
|
+
});
|
|
3099
|
+
const machine = (yield* Json) || (yield* JsonLines);
|
|
3100
|
+
yield* confirmDestructive({
|
|
3101
|
+
yes,
|
|
3102
|
+
json: machine,
|
|
3103
|
+
message: `Delete profile ${name} and its saved credential?`,
|
|
3104
|
+
nonInteractiveMessage: "Profile deletion requires --yes in non-interactive mode."
|
|
3105
|
+
});
|
|
3106
|
+
const { [name]: _, ...profiles } = config.profiles;
|
|
3107
|
+
yield* writeConfig(config.activeProfile === name ? {
|
|
3108
|
+
version: 2,
|
|
3109
|
+
profiles
|
|
3110
|
+
} : {
|
|
3111
|
+
...config,
|
|
3112
|
+
profiles
|
|
3113
|
+
});
|
|
3114
|
+
if (profile.credentialIdentity === "oauth") yield* clearCredentialSecret(name);
|
|
3115
|
+
yield* printResult({
|
|
3116
|
+
action: "profile.delete",
|
|
3117
|
+
name
|
|
3118
|
+
}, `Deleted profile ${name}.`);
|
|
3119
|
+
})));
|
|
3120
|
+
const profileCommand = Command.make("profile").pipe(Command.withDescription("Manage user-level target, credential identity, and organization contexts"), Command.withSubcommands([
|
|
3121
|
+
list$2,
|
|
3122
|
+
show,
|
|
3123
|
+
create$2,
|
|
3124
|
+
use,
|
|
3125
|
+
remove$1
|
|
3126
|
+
]));
|
|
3127
|
+
//#endregion
|
|
3128
|
+
//#region src/commands/scheduled.ts
|
|
3129
|
+
const SCHEDULED_STATUSES = [
|
|
3130
|
+
"pending",
|
|
3131
|
+
"dispatching",
|
|
3132
|
+
"dispatched",
|
|
3133
|
+
"cancelled",
|
|
3134
|
+
"failed"
|
|
3135
|
+
];
|
|
3136
|
+
const ABSOLUTE_INSTANT_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
3137
|
+
const isAbsoluteInstant = (value) => {
|
|
3138
|
+
const match = ABSOLUTE_INSTANT_PATTERN.exec(value);
|
|
3139
|
+
if (!match || Number.isNaN(Date.parse(value))) return false;
|
|
3140
|
+
const year = Number(match[1]);
|
|
3141
|
+
const month = Number(match[2]);
|
|
3142
|
+
const day = Number(match[3]);
|
|
3143
|
+
const calendarDate = new Date(Date.UTC(year, month - 1, day));
|
|
3144
|
+
return calendarDate.getUTCFullYear() === year && calendarDate.getUTCMonth() === month - 1 && calendarDate.getUTCDate() === day;
|
|
3145
|
+
};
|
|
3146
|
+
/**
|
|
3147
|
+
* Assemble the `scheduledMessages.create` email payload from resolved flags.
|
|
3148
|
+
* Pure so the flag-to-request mapping can be unit-tested without the network.
|
|
3149
|
+
*/
|
|
3150
|
+
const assembleScheduledEmailPayload = (input) => ({
|
|
3151
|
+
send: {
|
|
3152
|
+
channel: "email",
|
|
3153
|
+
to: input.to.map((email) => ({ email })),
|
|
3154
|
+
cc: input.cc ? [{ email: input.cc }] : void 0,
|
|
3155
|
+
bcc: input.bcc ? [{ email: input.bcc }] : void 0,
|
|
3156
|
+
email: {
|
|
3157
|
+
subject: input.subject,
|
|
3158
|
+
html: input.html,
|
|
3159
|
+
text: input.text,
|
|
3160
|
+
templateId: input.templateId,
|
|
3161
|
+
templateSlug: input.templateSlug,
|
|
3162
|
+
templateData: input.templateData,
|
|
3163
|
+
...input.tracking === void 0 ? {} : { tracking: input.tracking }
|
|
3164
|
+
}
|
|
3165
|
+
},
|
|
3166
|
+
scheduledFor: input.scheduledFor,
|
|
3167
|
+
timezone: input.timezone,
|
|
3168
|
+
idempotencyKey: input.idempotencyKey,
|
|
3169
|
+
referenceId: input.referenceId
|
|
3170
|
+
});
|
|
3171
|
+
const parseTemplateData = (raw) => Option.match(raw, {
|
|
3172
|
+
onNone: () => Effect.succeed(void 0),
|
|
3173
|
+
onSome: (value) => Effect.try({
|
|
3174
|
+
try: () => JSON.parse(value),
|
|
3175
|
+
catch: () => new SamvaCliError({ message: "--template-data must be a JSON object." })
|
|
3176
|
+
}).pipe(Effect.flatMap((parsed) => Predicate.isObject(parsed) ? Effect.succeed(parsed) : Effect.fail(new SamvaCliError({ message: "--template-data must be a JSON object." }))))
|
|
3177
|
+
});
|
|
3178
|
+
const toFlag = Flag.string("to").pipe(Flag.withDescription("Recipient email address (repeatable)"), Flag.atLeast(1));
|
|
3179
|
+
const ccFlag = Flag.string("cc").pipe(Flag.withDescription("Cc recipient email address"), Flag.optional);
|
|
3180
|
+
const bccFlag = Flag.string("bcc").pipe(Flag.withDescription("Bcc recipient email address"), Flag.optional);
|
|
3181
|
+
const subjectFlag = Flag.string("subject").pipe(Flag.withDescription("Email subject line (required with inline --html; omit for templates)"), Flag.optional);
|
|
3182
|
+
const htmlFlag = Flag.string("html").pipe(Flag.withDescription("HTML body"), Flag.optional);
|
|
3183
|
+
const textFlag = Flag.string("text").pipe(Flag.withDescription("Plain-text body"), Flag.optional);
|
|
3184
|
+
const templateIdFlag = Flag.string("template-id").pipe(Flag.withDescription("Template id (uuid) to render instead of inline body"), Flag.optional);
|
|
3185
|
+
const templateSlugFlag = Flag.string("template-slug").pipe(Flag.withDescription("Template handle to render instead of inline body, e.g. welcome-email"), Flag.optional);
|
|
3186
|
+
const templateDataFlag = Flag.string("template-data").pipe(Flag.withDescription("JSON object of variables for the template"), Flag.optional);
|
|
3187
|
+
const atFlag = Flag.string("at").pipe(Flag.withDescription("Absolute send instant as an ISO 8601 datetime, e.g. 2026-08-01T09:00:00Z"));
|
|
3188
|
+
const timezoneFlag = Flag.string("timezone").pipe(Flag.withDescription("IANA timezone for the schedule, e.g. America/New_York"), Flag.optional);
|
|
3189
|
+
const idempotencyKeyFlag = Flag.string("idempotency-key").pipe(Flag.withDescription("Idempotency key so retries do not schedule duplicates"), Flag.optional);
|
|
3190
|
+
const referenceIdFlag = Flag.string("reference-id").pipe(Flag.withDescription("Your own reference id to correlate this schedule"), Flag.optional);
|
|
3191
|
+
const dryRunFlag$1 = Flag.boolean("dry-run").pipe(Flag.withDescription("Validate inputs and print the request payload without calling the API"), Flag.withDefault(false));
|
|
3192
|
+
const trackOpensFlag = Flag.choice("track-opens", ["on", "off"]).pipe(Flag.withDescription("Override open tracking for this scheduled send; recipient opt-out wins"), Flag.optional);
|
|
3193
|
+
const trackClicksFlag = Flag.choice("track-clicks", ["on", "off"]).pipe(Flag.withDescription("Override click tracking for this scheduled send; recipient opt-out wins"), Flag.optional);
|
|
3194
|
+
const statusFlag = Flag.choice("status", SCHEDULED_STATUSES).pipe(Flag.withDescription("Filter by status"), Flag.optional);
|
|
3195
|
+
const pageFlag = Flag.integer("page").pipe(Flag.withDescription("Page number (1-based)"), Flag.optional);
|
|
3196
|
+
const limitFlag = Flag.integer("limit").pipe(Flag.withDescription("Items per page"), Flag.optional);
|
|
3197
|
+
const allFlag = Flag.boolean("all").pipe(Flag.withDescription("Traverse every page (requires --jsonl)"), Flag.withDefault(false));
|
|
3198
|
+
const create$1 = Command.make("create", {
|
|
3199
|
+
to: toFlag,
|
|
3200
|
+
cc: ccFlag,
|
|
3201
|
+
bcc: bccFlag,
|
|
3202
|
+
subject: subjectFlag,
|
|
3203
|
+
html: htmlFlag,
|
|
3204
|
+
text: textFlag,
|
|
3205
|
+
templateId: templateIdFlag,
|
|
3206
|
+
templateSlug: templateSlugFlag,
|
|
3207
|
+
templateData: templateDataFlag,
|
|
3208
|
+
at: atFlag,
|
|
3209
|
+
timezone: timezoneFlag,
|
|
3210
|
+
idempotencyKey: idempotencyKeyFlag,
|
|
3211
|
+
referenceId: referenceIdFlag,
|
|
3212
|
+
trackOpens: trackOpensFlag,
|
|
3213
|
+
trackClicks: trackClicksFlag,
|
|
3214
|
+
dryRun: dryRunFlag$1,
|
|
3215
|
+
json: jsonFlag
|
|
3216
|
+
}).pipe(Command.withDescription("Schedule an email to send at a future time"), Command.withExamples([
|
|
3217
|
+
{
|
|
3218
|
+
command: "samva scheduled-messages create --to you@example.com --subject Hi --html \"<p>Hello</p>\" --text Hello --at 2026-08-01T09:00:00Z",
|
|
3219
|
+
description: "Schedule an inline HTML email with a text fallback"
|
|
3220
|
+
},
|
|
3221
|
+
{
|
|
3222
|
+
command: "samva scheduled-messages create --to you@example.com --template-slug welcome-email --template-data '{\"name\":\"Ada\"}' --at 2026-08-01T09:00:00Z --timezone America/New_York",
|
|
3223
|
+
description: "Schedule a published template; timezone is descriptive metadata"
|
|
3224
|
+
},
|
|
3225
|
+
{
|
|
3226
|
+
command: "samva scheduled-messages create --to you@example.com --subject Hi --html \"<p>Hello</p>\" --at 2026-08-01T09:00:00Z --dry-run",
|
|
3227
|
+
description: "Validate and print the payload without scheduling"
|
|
3228
|
+
}
|
|
3229
|
+
]), Command.withHandler(({ to, cc, bcc, subject, html, text, templateId, templateSlug, templateData, at, timezone, idempotencyKey, referenceId, trackOpens, trackClicks, dryRun, json }) => Effect.gen(function* () {
|
|
3230
|
+
const htmlValue = Option.getOrUndefined(html);
|
|
3231
|
+
const textValue = Option.getOrUndefined(text);
|
|
3232
|
+
const templateIdValue = Option.getOrUndefined(templateId);
|
|
3233
|
+
const templateSlugValue = Option.getOrUndefined(templateSlug);
|
|
3234
|
+
const templateDataValue = yield* parseTemplateData(templateData);
|
|
3235
|
+
if (templateIdValue && templateSlugValue) return yield* new SamvaCliError({ message: "Use either --template-id or --template-slug, not both." });
|
|
3236
|
+
const templateRefValue = templateSlugValue ?? templateIdValue;
|
|
3237
|
+
if (templateRefValue && (Option.isSome(subject) || htmlValue || textValue)) return yield* new SamvaCliError({ message: "Choose one content source: a template, or inline --subject with --html (and optional --text)." });
|
|
3238
|
+
if (!templateRefValue && Option.isNone(subject)) return yield* new SamvaCliError({ message: "Provide --subject with inline --html, or reference a published template." });
|
|
3239
|
+
if (!templateRefValue && !htmlValue) return yield* new SamvaCliError({ message: "Provide --html for inline email; --text is an optional fallback, not a body source." });
|
|
3240
|
+
if (!isAbsoluteInstant(at)) return yield* new SamvaCliError({ message: "--at must be an absolute ISO 8601 instant with Z or an offset, e.g. 2026-08-01T09:00:00Z." });
|
|
3241
|
+
const payload = assembleScheduledEmailPayload({
|
|
3242
|
+
to: [...to],
|
|
3243
|
+
subject: Option.getOrUndefined(subject),
|
|
3244
|
+
html: htmlValue,
|
|
3245
|
+
text: textValue,
|
|
3246
|
+
templateId: templateIdValue,
|
|
3247
|
+
templateSlug: templateSlugValue,
|
|
3248
|
+
templateData: templateDataValue,
|
|
3249
|
+
cc: Option.getOrUndefined(cc),
|
|
3250
|
+
bcc: Option.getOrUndefined(bcc),
|
|
3251
|
+
scheduledFor: new Date(at),
|
|
3252
|
+
timezone: Option.getOrUndefined(timezone),
|
|
3253
|
+
idempotencyKey: Option.getOrUndefined(idempotencyKey),
|
|
3254
|
+
referenceId: Option.getOrUndefined(referenceId),
|
|
3255
|
+
tracking: Option.isSome(trackOpens) || Option.isSome(trackClicks) ? {
|
|
3256
|
+
...Option.isSome(trackOpens) ? { opens: trackOpens.value === "on" } : {},
|
|
3257
|
+
...Option.isSome(trackClicks) ? { clicks: trackClicks.value === "on" } : {}
|
|
3258
|
+
} : void 0
|
|
3259
|
+
});
|
|
3260
|
+
if (dryRun) {
|
|
3261
|
+
if (json) return yield* printJson({
|
|
3262
|
+
dryRun: true,
|
|
3263
|
+
request: payload
|
|
3264
|
+
});
|
|
3265
|
+
yield* Effect.logInfo("Dry run — no schedule created.\n");
|
|
3266
|
+
yield* printFields([
|
|
3267
|
+
["To", [...to].join(", ")],
|
|
3268
|
+
["Subject", Option.getOrUndefined(subject)],
|
|
3269
|
+
["Send at", at],
|
|
3270
|
+
["Timezone", Option.getOrUndefined(timezone)]
|
|
3271
|
+
]);
|
|
3272
|
+
return yield* Console.log(`\nPayload:\n${truncateForDisplay(JSON.stringify(payload, null, 2))}`);
|
|
3273
|
+
}
|
|
3274
|
+
const client = yield* getClientProvider;
|
|
3275
|
+
const data = yield* ScheduledMessages.create(payload).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Failed to schedule message", error) })));
|
|
3276
|
+
if (json) return yield* printJson(data);
|
|
3277
|
+
yield* Effect.logInfo("Message scheduled.\n");
|
|
3278
|
+
yield* printFields([
|
|
3279
|
+
["ID", data.id],
|
|
3280
|
+
["Status", data.status],
|
|
3281
|
+
["Send at", data.scheduledFor],
|
|
3282
|
+
["Timezone", data.timezone]
|
|
3283
|
+
]);
|
|
3284
|
+
yield* Effect.logInfo(`\nInspect it: samva scheduled-messages get ${data.id}`);
|
|
3285
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
3286
|
+
const list$1 = Command.make("list", {
|
|
3287
|
+
status: statusFlag,
|
|
3288
|
+
page: pageFlag,
|
|
3289
|
+
limit: limitFlag,
|
|
3290
|
+
all: allFlag,
|
|
3291
|
+
json: jsonFlag
|
|
3292
|
+
}).pipe(Command.withDescription("List scheduled messages"), Command.withExamples([
|
|
3293
|
+
{
|
|
3294
|
+
command: "samva scheduled-messages list",
|
|
3295
|
+
description: "List all scheduled messages"
|
|
3296
|
+
},
|
|
3297
|
+
{
|
|
3298
|
+
command: "samva scheduled-messages list --status pending",
|
|
3299
|
+
description: "Only pending schedules"
|
|
3300
|
+
},
|
|
3301
|
+
{ command: "samva scheduled-messages list --json" },
|
|
3302
|
+
{
|
|
3303
|
+
command: "samva scheduled-messages list --all --jsonl",
|
|
3304
|
+
description: "Stream every page as JSONL"
|
|
3305
|
+
}
|
|
3306
|
+
]), Command.withHandler(({ status, page, limit, all, json }) => Effect.gen(function* () {
|
|
3307
|
+
if (all && !isJsonLinesRequested()) return yield* new CliUsageError({
|
|
3308
|
+
code: "all_requires_jsonl",
|
|
3309
|
+
message: "--all is an unbounded traversal and requires --jsonl."
|
|
3310
|
+
});
|
|
3311
|
+
const pageValue = Option.getOrUndefined(page);
|
|
3312
|
+
const limitValue = Option.getOrUndefined(limit);
|
|
3313
|
+
const client = yield* getClientProvider;
|
|
3314
|
+
const query = {
|
|
3315
|
+
status: Option.getOrUndefined(status),
|
|
3316
|
+
page: pageValue?.toString(),
|
|
3317
|
+
limit: limitValue?.toString()
|
|
3318
|
+
};
|
|
3319
|
+
if (all) {
|
|
3320
|
+
let nextPage = pageValue ?? 1;
|
|
3321
|
+
for (;;) {
|
|
3322
|
+
const data = yield* ScheduledMessages.list({
|
|
3323
|
+
...query,
|
|
3324
|
+
page: nextPage.toString()
|
|
3325
|
+
}).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not list scheduled messages", error) })));
|
|
3326
|
+
for (const item of data.items) yield* printJsonLine(item);
|
|
3327
|
+
if (nextPage >= data.pagination.totalPages || data.items.length === 0) return;
|
|
3328
|
+
nextPage += 1;
|
|
3329
|
+
}
|
|
3330
|
+
}
|
|
3331
|
+
const data = yield* ScheduledMessages.list(query).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not list scheduled messages", error) })));
|
|
3332
|
+
if (isJsonLinesRequested()) {
|
|
3333
|
+
for (const item of data.items) yield* printJsonLine(item);
|
|
3334
|
+
return;
|
|
3335
|
+
}
|
|
3336
|
+
if (json) return yield* printJson(data);
|
|
3337
|
+
if (data.items.length === 0) return yield* Console.log("No scheduled messages. Create one with `samva scheduled-messages create`.");
|
|
3338
|
+
for (const item of data.items) {
|
|
3339
|
+
yield* printFields([
|
|
3340
|
+
["ID", item.id],
|
|
3341
|
+
["Status", item.status],
|
|
3342
|
+
["Channel", item.send.channel],
|
|
3343
|
+
["Send at", item.scheduledFor],
|
|
3344
|
+
["Timezone", item.timezone]
|
|
3345
|
+
]);
|
|
3346
|
+
yield* Console.log("");
|
|
3347
|
+
}
|
|
3348
|
+
yield* Console.log(`Page ${String(data.pagination.page)} of ${String(data.pagination.totalPages)} (${String(data.pagination.total)} total)`);
|
|
3349
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
3350
|
+
const get$1 = Command.make("get", {
|
|
3351
|
+
id: Argument.string("id"),
|
|
3352
|
+
json: jsonFlag
|
|
3353
|
+
}).pipe(Command.withDescription("Show a scheduled message by id"), Command.withExamples([{ command: "samva scheduled-messages get sched_abc123" }]), Command.withHandler(({ id, json }) => Effect.gen(function* () {
|
|
3354
|
+
const client = yield* getClientProvider;
|
|
3355
|
+
const data = yield* ScheduledMessages.get({ id }).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not fetch scheduled message", error) })));
|
|
3356
|
+
if (json) return yield* printJson(data);
|
|
3357
|
+
yield* printFields([
|
|
3358
|
+
["ID", data.id],
|
|
3359
|
+
["Status", data.status],
|
|
3360
|
+
["Channel", data.send.channel],
|
|
3361
|
+
["Send at", data.scheduledFor],
|
|
3362
|
+
["Timezone", data.timezone],
|
|
3363
|
+
["Message ID", data.messageId],
|
|
3364
|
+
["Reference", data.referenceId],
|
|
3365
|
+
["Failure", data.failureReason]
|
|
3366
|
+
]);
|
|
3367
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
3368
|
+
const cancel = Command.make("cancel", {
|
|
3369
|
+
id: Argument.string("id"),
|
|
3370
|
+
dryRun: Flag.boolean("dry-run").pipe(Flag.withDefault(false)),
|
|
3371
|
+
yes: Flag.boolean("yes").pipe(Flag.withDefault(false)),
|
|
3372
|
+
json: jsonFlag
|
|
3373
|
+
}).pipe(Command.withDescription("Cancel a pending scheduled message"), Command.withExamples([{ command: "samva scheduled-messages cancel sched_abc123 --dry-run" }, { command: "samva scheduled-messages cancel sched_abc123 --yes" }]), Command.withHandler(({ id, dryRun, yes, json }) => Effect.gen(function* () {
|
|
3374
|
+
if (dryRun) return yield* json ? printJson({
|
|
3375
|
+
dryRun: true,
|
|
3376
|
+
action: "scheduled.cancel",
|
|
3377
|
+
id
|
|
3378
|
+
}) : Console.log(`[dry-run] Would cancel scheduled message ${id}.`);
|
|
3379
|
+
yield* confirmDestructive({
|
|
3380
|
+
yes,
|
|
3381
|
+
json,
|
|
3382
|
+
message: `Cancel scheduled message ${id}?`,
|
|
3383
|
+
nonInteractiveMessage: "Scheduled message cancellation requires --yes in non-interactive mode. Preview with --dry-run."
|
|
3384
|
+
});
|
|
3385
|
+
const client = yield* getClientProvider;
|
|
3386
|
+
const data = yield* ScheduledMessages.cancel({ id }).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not cancel scheduled message", error) })));
|
|
3387
|
+
if (json) return yield* printJson(data);
|
|
3388
|
+
yield* Effect.logInfo("Scheduled message cancelled.\n");
|
|
3389
|
+
yield* printFields([["ID", data.item.id], ["Status", data.item.status]]);
|
|
3390
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
3391
|
+
const scheduledMessagesCommand = Command.make("scheduled-messages").pipe(Command.withDescription("Schedule messages to send at a future time"), Command.withExamples([{
|
|
3392
|
+
command: "samva scheduled-messages create --to you@example.com --subject Hi --html \"<p>Hello</p>\" --at 2026-08-01T09:00:00Z",
|
|
3393
|
+
description: "Schedule an email"
|
|
3394
|
+
}, {
|
|
3395
|
+
command: "samva scheduled-messages list",
|
|
3396
|
+
description: "List scheduled messages"
|
|
3397
|
+
}]), Command.withSubcommands([
|
|
3398
|
+
create$1,
|
|
3399
|
+
list$1,
|
|
3400
|
+
get$1,
|
|
3401
|
+
cancel
|
|
3402
|
+
]));
|
|
3403
|
+
//#endregion
|
|
3404
|
+
//#region src/commands/templates/plan.ts
|
|
3405
|
+
const emptyLock = {
|
|
3406
|
+
version: 1,
|
|
3407
|
+
entries: {}
|
|
3408
|
+
};
|
|
3409
|
+
const sha256 = (value) => createHash("sha256").update(value, "utf8").digest("hex");
|
|
3410
|
+
/**
|
|
3411
|
+
* Derives a platform slug from a file-backed template name.
|
|
3412
|
+
* `welcome-email` → `welcome-email`, `accountUpdate2` → `account-update-2`.
|
|
3413
|
+
*/
|
|
3414
|
+
const slugFromName = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2").replace(/[^a-zA-Z0-9]+/g, "-").toLowerCase().replace(/^-+|-+$/g, "");
|
|
3415
|
+
/**
|
|
3416
|
+
* Builds the push plan. `platformSlugs` are the slugs currently on the platform
|
|
3417
|
+
* (from listTemplates) so we distinguish create vs update. `platformBaseHash`
|
|
3418
|
+
* maps a slug to the platform's current source hash (the CLI derives it from the
|
|
3419
|
+
* `source` the list now returns); when absent, divergence falls back to the lock.
|
|
3420
|
+
*/
|
|
3421
|
+
const planPush = (input) => {
|
|
3422
|
+
const items = input.templates.map((template) => {
|
|
3423
|
+
if (template.source === void 0 || template.diagnostics.length > 0) return {
|
|
3424
|
+
file: template.file,
|
|
3425
|
+
name: template.name,
|
|
3426
|
+
slug: template.slug,
|
|
3427
|
+
action: "blocked",
|
|
3428
|
+
sourceHash: void 0,
|
|
3429
|
+
reason: template.diagnostics[0]?.message ?? "template did not emit valid SML"
|
|
3430
|
+
};
|
|
3431
|
+
const hash = sha256(template.source);
|
|
3432
|
+
const lockEntry = input.lock.entries[template.slug];
|
|
3433
|
+
const onPlatform = input.platformSlugs.has(template.slug);
|
|
3434
|
+
const platformHash = input.platformBaseHash?.[template.slug];
|
|
3435
|
+
if (!input.force && onPlatform && lockEntry !== void 0 && platformHash !== void 0 && platformHash !== lockEntry.sourceHash) return {
|
|
3436
|
+
file: template.file,
|
|
3437
|
+
name: template.name,
|
|
3438
|
+
slug: template.slug,
|
|
3439
|
+
action: "diverged",
|
|
3440
|
+
sourceHash: hash,
|
|
3441
|
+
reason: "the platform's template changed since your last push — re-run with --force to overwrite"
|
|
3442
|
+
};
|
|
3443
|
+
const platformMatchesLocal = platformHash === void 0 || platformHash === hash;
|
|
3444
|
+
if (!input.force && lockEntry !== void 0 && lockEntry.sourceHash === hash && onPlatform && platformMatchesLocal) return {
|
|
3445
|
+
file: template.file,
|
|
3446
|
+
name: template.name,
|
|
3447
|
+
slug: template.slug,
|
|
3448
|
+
action: "unchanged",
|
|
3449
|
+
sourceHash: hash,
|
|
3450
|
+
reason: void 0
|
|
3451
|
+
};
|
|
3452
|
+
return {
|
|
3453
|
+
file: template.file,
|
|
3454
|
+
name: template.name,
|
|
3455
|
+
slug: template.slug,
|
|
3456
|
+
action: onPlatform ? "update" : "create",
|
|
3457
|
+
sourceHash: hash,
|
|
3458
|
+
reason: void 0
|
|
3459
|
+
};
|
|
3460
|
+
});
|
|
3461
|
+
return {
|
|
3462
|
+
items,
|
|
3463
|
+
hasBlocking: items.some((item) => item.action === "blocked" || item.action === "diverged")
|
|
3464
|
+
};
|
|
3465
|
+
};
|
|
3466
|
+
/** Applies a successful push to the lock, replacing entries for pushed slugs. */
|
|
3467
|
+
const applyPushToLock = (lock, pushed, compilerVersion) => {
|
|
3468
|
+
const entries = { ...lock.entries };
|
|
3469
|
+
for (const item of pushed) entries[item.slug] = {
|
|
3470
|
+
slug: item.slug,
|
|
3471
|
+
sourceHash: item.sourceHash,
|
|
3472
|
+
compilerVersion
|
|
3473
|
+
};
|
|
3474
|
+
return {
|
|
3475
|
+
version: 1,
|
|
3476
|
+
entries
|
|
3477
|
+
};
|
|
3478
|
+
};
|
|
3479
|
+
/**
|
|
3480
|
+
* Compares the CLI's compiler version (from @samva/markup) against the platform's
|
|
3481
|
+
* reported compilerVersion. A mismatch is preview-staleness only — the platform
|
|
3482
|
+
* recompiles on its side — so this returns a warning string, never an error.
|
|
3483
|
+
*/
|
|
3484
|
+
const skewWarning = (cliVersion, platformVersion) => {
|
|
3485
|
+
if (platformVersion === void 0 || platformVersion === cliVersion) return void 0;
|
|
3486
|
+
return `compiler version skew: your @samva/markup compiles v${cliVersion}, the platform runs v${platformVersion}. Local previews may differ from sent mail; the platform recompiles from the SML you push, so nothing wrong is sent. Update @samva/markup to match.`;
|
|
3487
|
+
};
|
|
3488
|
+
//#endregion
|
|
3489
|
+
//#region src/commands/templates.ts
|
|
3490
|
+
/**
|
|
3491
|
+
* `samva templates push/pull/init` — the local-repo authoring workflow for the
|
|
3492
|
+
* TSX email framework (design brief §7b). `push` emits SML from your `.tsx`
|
|
3493
|
+
* templates and (git-style) create-or-updates them on the platform; `pull`
|
|
3494
|
+
* writes the platform's SML back to `.samva` files; `init` scaffolds a project.
|
|
3495
|
+
*
|
|
3496
|
+
* Push sends SML ONLY — the platform recompiles server-side with the org's theme
|
|
3497
|
+
* layers. The templates API accepts a code-origin SML `source` (+ `origin:
|
|
3498
|
+
* "tsx"`) on create/update and returns it on list/get. Everything local (emit,
|
|
3499
|
+
* plan, divergence, skew) runs first; use --dry-run to review the SML that would
|
|
3500
|
+
* be sent without touching the API.
|
|
3501
|
+
*/
|
|
3502
|
+
const LOCK_FILE = ".samva/push-lock.json";
|
|
3503
|
+
const dirFlag = Flag.directory("dir").pipe(Flag.withDescription("Directory containing your .tsx / .samva templates"), Flag.withDefault("emails"));
|
|
3504
|
+
const forceFlag = Flag.boolean("force").pipe(Flag.withDescription("Overwrite templates that changed on the platform since your last push"), Flag.withDefault(false));
|
|
3505
|
+
const dryRunFlag = Flag.boolean("dry-run").pipe(Flag.withDescription("Emit SML and print the push plan without calling the API"), Flag.withDefault(false));
|
|
3506
|
+
const templateIdArgument = Argument.string("id").pipe(Argument.withDescription("Remote template id"));
|
|
3507
|
+
const parseObject$1 = (raw, flag) => Effect.try({
|
|
3508
|
+
try: () => JSON.parse(raw),
|
|
3509
|
+
catch: () => new SamvaCliError({ message: `${flag} must be a valid JSON object.` })
|
|
3510
|
+
}).pipe(Effect.flatMap((value) => Predicate.isObject(value) && !Array.isArray(value) ? Effect.succeed(value) : Effect.fail(new SamvaCliError({ message: `${flag} must be a non-null JSON object.` }))));
|
|
3511
|
+
const decodePayload$1 = (raw, flag, schema) => parseObject$1(raw, flag).pipe(Effect.flatMap(Schema.decodeUnknownEffect(schema)), Effect.mapError((error) => error instanceof SamvaCliError ? error : new SamvaCliError({ message: `${flag} does not match the API schema: ${String(error)}` })));
|
|
3512
|
+
const buildDir = (dir) => Effect.tryPromise({
|
|
3513
|
+
try: async () => {
|
|
3514
|
+
const child = Bun.spawn([
|
|
3515
|
+
"node",
|
|
3516
|
+
"--input-type=module",
|
|
3517
|
+
"--eval",
|
|
3518
|
+
"import { buildTemplates } from \"@samva/vite/build\"; const result = await buildTemplates(process.argv[1]); process.stdout.write(JSON.stringify(result));",
|
|
3519
|
+
resolve(dir)
|
|
3520
|
+
], {
|
|
3521
|
+
cwd: process.cwd(),
|
|
3522
|
+
stdout: "pipe",
|
|
3523
|
+
stderr: "pipe"
|
|
3524
|
+
});
|
|
3525
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
3526
|
+
new Response(child.stdout).text(),
|
|
3527
|
+
new Response(child.stderr).text(),
|
|
3528
|
+
child.exited
|
|
3529
|
+
]);
|
|
3530
|
+
if (exitCode !== 0) return Promise.reject(new SamvaCliError({ message: stderr.trim() || "Template build process failed." }));
|
|
3531
|
+
return JSON.parse(stdout);
|
|
3532
|
+
},
|
|
3533
|
+
catch: (cause) => new SamvaCliError({ message: `Could not build templates in ${dir}. Install Node.js 22+, @samva/markup, @samva/vite, and vite in this project: ${String(cause)}` })
|
|
3534
|
+
});
|
|
3535
|
+
/** Compile-stage diagnostics across the channel-polymorphic preview payload. */
|
|
3536
|
+
const compileDiagnostics = (compiled) => {
|
|
3537
|
+
if (compiled.channel === "email") return compiled.bases.flatMap((base) => base.ok ? [] : base.diagnostics ?? []);
|
|
3538
|
+
return compiled.ok ? [] : compiled.diagnostics ?? [];
|
|
3539
|
+
};
|
|
3540
|
+
/** Flattens a build result into per-template BuiltTemplate rows for planning. */
|
|
3541
|
+
const toBuiltTemplates = (result) => result.files.flatMap((file) => {
|
|
3542
|
+
const templates = file.preview.templates.map((template) => ({
|
|
3543
|
+
file: file.id,
|
|
3544
|
+
name: template.name,
|
|
3545
|
+
slug: slugFromName(template.name),
|
|
3546
|
+
source: template.source,
|
|
3547
|
+
variables: template.variables.map((variable) => ({
|
|
3548
|
+
name: variable.name,
|
|
3549
|
+
required: variable.required
|
|
3550
|
+
})),
|
|
3551
|
+
diagnostics: [...template.diagnostics.map((diagnostic) => ({
|
|
3552
|
+
code: diagnostic.code,
|
|
3553
|
+
message: diagnostic.message
|
|
3554
|
+
})), ...compileDiagnostics(template.compiled).map((diagnostic) => ({
|
|
3555
|
+
code: diagnostic.code,
|
|
3556
|
+
message: diagnostic.message
|
|
3557
|
+
}))]
|
|
3558
|
+
}));
|
|
3559
|
+
if (templates.length > 0) return templates;
|
|
3560
|
+
const errors = file.preview.diagnostics.filter((diagnostic) => diagnostic.code !== "missing-default-export");
|
|
3561
|
+
if (errors.length === 0) return [];
|
|
3562
|
+
const name = file.id.slice(file.id.lastIndexOf("/") + 1).replace(/\.(?:tsx|samva)$/, "");
|
|
3563
|
+
return [{
|
|
3564
|
+
file: file.id,
|
|
3565
|
+
name,
|
|
3566
|
+
slug: slugFromName(name),
|
|
3567
|
+
source: void 0,
|
|
3568
|
+
variables: [],
|
|
3569
|
+
diagnostics: errors.map((diagnostic) => ({
|
|
3570
|
+
code: diagnostic.code,
|
|
3571
|
+
message: diagnostic.message
|
|
3572
|
+
}))
|
|
3573
|
+
}];
|
|
3574
|
+
});
|
|
3575
|
+
const buildWarnings = (result) => result.files.flatMap((file) => file.preview.diagnostics.filter((diagnostic) => diagnostic.code === "missing-default-export").map((diagnostic) => ({
|
|
3576
|
+
file: file.id,
|
|
3577
|
+
code: diagnostic.code,
|
|
3578
|
+
message: diagnostic.message
|
|
3579
|
+
})));
|
|
3580
|
+
const compilerVersionOf = (result) => result.files[0]?.preview.compilerVersion ?? "unknown";
|
|
3581
|
+
/**
|
|
3582
|
+
* The lock lives at the PROJECT root (cwd), not inside the templates
|
|
3583
|
+
* directory — it's per-project push state, like a lockfile.
|
|
3584
|
+
*/
|
|
3585
|
+
const readLock = () => Effect.tryPromise({
|
|
3586
|
+
try: async () => {
|
|
3587
|
+
const text = await readFile(join(process.cwd(), LOCK_FILE), "utf8").catch(() => void 0);
|
|
3588
|
+
if (text === void 0) return emptyLock;
|
|
3589
|
+
const parsed = JSON.parse(text);
|
|
3590
|
+
return parsed.version === 1 && parsed.entries ? parsed : emptyLock;
|
|
3591
|
+
},
|
|
3592
|
+
catch: () => new SamvaCliError({ message: `Could not read ${join(process.cwd(), LOCK_FILE)}` })
|
|
3593
|
+
});
|
|
3594
|
+
/**
|
|
3595
|
+
* Persist the push lock (git-index-style base) after a successful push. The lock
|
|
3596
|
+
* lives at the project root and is only advanced for slugs that actually landed.
|
|
3597
|
+
*/
|
|
3598
|
+
const writeLock = (lock) => Effect.tryPromise({
|
|
3599
|
+
try: async () => {
|
|
3600
|
+
const target = join(process.cwd(), LOCK_FILE);
|
|
3601
|
+
await mkdir(dirname(target), { recursive: true });
|
|
3602
|
+
await writeFile(target, `${JSON.stringify(lock, null, 2)}\n`, "utf8");
|
|
3603
|
+
},
|
|
3604
|
+
catch: () => new SamvaCliError({ message: `Could not write ${join(process.cwd(), LOCK_FILE)}` })
|
|
3605
|
+
});
|
|
3606
|
+
/**
|
|
3607
|
+
* Page through the full template list — the slug→id map and create-vs-update
|
|
3608
|
+
* classification must see EVERY platform template, or an org past one page
|
|
3609
|
+
* would misclassify existing templates as creates (409s on push).
|
|
3610
|
+
*/
|
|
3611
|
+
const listAllTemplates = (client) => Effect.gen(function* () {
|
|
3612
|
+
const items = [];
|
|
3613
|
+
let page = 1;
|
|
3614
|
+
for (;;) {
|
|
3615
|
+
const response = yield* Templates.list({
|
|
3616
|
+
page: page.toString(),
|
|
3617
|
+
limit: "200"
|
|
3618
|
+
}).pipe(client.provide);
|
|
3619
|
+
items.push(...response.items);
|
|
3620
|
+
const totalPages = Number(response.pagination.totalPages);
|
|
3621
|
+
if (!Number.isFinite(totalPages) || page >= totalPages || response.items.length === 0) return items;
|
|
3622
|
+
page += 1;
|
|
3623
|
+
}
|
|
3624
|
+
});
|
|
3625
|
+
const printPushPlan = (plan, compilerVersion, sources) => Effect.gen(function* () {
|
|
3626
|
+
for (const item of plan.items) {
|
|
3627
|
+
yield* printFields([[item.action.toUpperCase(), `${item.name} → ${item.slug}`], ...item.reason ? [["reason", item.reason]] : []]);
|
|
3628
|
+
if (item.action !== "blocked") {
|
|
3629
|
+
const source = sources.get(item.slug);
|
|
3630
|
+
if (source) yield* Console.log(`\n${truncateForDisplay(source)}\n`);
|
|
3631
|
+
}
|
|
3632
|
+
}
|
|
3633
|
+
yield* Console.log(`compiler v${compilerVersion} · ${plan.items.length} template(s)`);
|
|
3634
|
+
});
|
|
3635
|
+
const push = Command.make("push", {
|
|
3636
|
+
dir: dirFlag,
|
|
3637
|
+
force: forceFlag,
|
|
3638
|
+
dryRun: dryRunFlag,
|
|
3639
|
+
json: jsonFlag
|
|
3640
|
+
}).pipe(Command.withDescription("Emit SML from your TSX templates and push them to the platform"), Command.withExamples([{
|
|
3641
|
+
command: "samva templates push --dir emails --dry-run",
|
|
3642
|
+
description: "Emit SML and print the push plan without calling the API"
|
|
3643
|
+
}, {
|
|
3644
|
+
command: "samva templates push --dir emails --force",
|
|
3645
|
+
description: "Push, overwriting templates changed on the platform since your last push"
|
|
3646
|
+
}]), Command.withHandler(({ dir, force, dryRun, json }) => Effect.gen(function* () {
|
|
3647
|
+
const built = yield* buildDir(dir);
|
|
3648
|
+
const templates = toBuiltTemplates(built);
|
|
3649
|
+
const warnings = buildWarnings(built);
|
|
3650
|
+
if (templates.length === 0) return yield* new SamvaCliError({ message: `No templates found in ${dir}. Each TSX template file must default-export one <Email>, <Sms>, or <WhatsApp> template.` });
|
|
3651
|
+
const compilerVersion = compilerVersionOf(built);
|
|
3652
|
+
const lock = yield* readLock();
|
|
3653
|
+
const sources = /* @__PURE__ */ new Map();
|
|
3654
|
+
for (const template of templates) if (template.source) sources.set(template.slug, template.source);
|
|
3655
|
+
const client = dryRun ? void 0 : yield* getClientProvider;
|
|
3656
|
+
const platform = dryRun ? [] : yield* listAllTemplates(client).pipe(Effect.catch(() => Effect.succeed([])));
|
|
3657
|
+
const slugToId = /* @__PURE__ */ new Map();
|
|
3658
|
+
const platformBaseHash = {};
|
|
3659
|
+
for (const item of platform) {
|
|
3660
|
+
slugToId.set(item.slug, item.id);
|
|
3661
|
+
if (item.source) platformBaseHash[item.slug] = sha256(item.source);
|
|
3662
|
+
}
|
|
3663
|
+
const platformSlugs = dryRun ? new Set(Object.keys(lock.entries)) : new Set(slugToId.keys());
|
|
3664
|
+
const plan = planPush({
|
|
3665
|
+
templates,
|
|
3666
|
+
lock,
|
|
3667
|
+
platformSlugs,
|
|
3668
|
+
platformBaseHash,
|
|
3669
|
+
force
|
|
3670
|
+
});
|
|
3671
|
+
const skew = skewWarning(compilerVersion, void 0);
|
|
3672
|
+
if (json && dryRun) return yield* printJson({
|
|
3673
|
+
action: "templates.push",
|
|
3674
|
+
dryRun,
|
|
3675
|
+
compilerVersion,
|
|
3676
|
+
skewWarning: skew,
|
|
3677
|
+
plan: plan.items,
|
|
3678
|
+
sources: Object.fromEntries(sources),
|
|
3679
|
+
warnings
|
|
3680
|
+
});
|
|
3681
|
+
if (!json) {
|
|
3682
|
+
for (const warning of warnings) yield* Console.error(`⚠ ${warning.file}: ${warning.message}`);
|
|
3683
|
+
if (skew) yield* Console.error(`⚠ ${skew}\n`);
|
|
3684
|
+
yield* printPushPlan(plan, compilerVersion, sources);
|
|
3685
|
+
}
|
|
3686
|
+
if (dryRun) {
|
|
3687
|
+
yield* Effect.logInfo("\nDry run — no API request sent.");
|
|
3688
|
+
return;
|
|
3689
|
+
}
|
|
3690
|
+
if (plan.hasBlocking) {
|
|
3691
|
+
const blocked = plan.items.filter((item) => item.action === "blocked" || item.action === "diverged");
|
|
3692
|
+
if (json) yield* printJson({
|
|
3693
|
+
action: "templates.push",
|
|
3694
|
+
dryRun: false,
|
|
3695
|
+
compilerVersion,
|
|
3696
|
+
skewWarning: skew,
|
|
3697
|
+
warnings,
|
|
3698
|
+
blocked: blocked.map((item) => ({
|
|
3699
|
+
slug: item.slug,
|
|
3700
|
+
action: item.action,
|
|
3701
|
+
reason: item.reason
|
|
3702
|
+
})),
|
|
3703
|
+
results: []
|
|
3704
|
+
});
|
|
3705
|
+
return yield* new SamvaCliError({ message: `Cannot push: ${blocked.length} template(s) blocked or diverged (${blocked.map((item) => `${item.slug} [${item.action}]`).join(", ")}). Fix the diagnostics or re-run with --force for diverged templates.` });
|
|
3706
|
+
}
|
|
3707
|
+
const results = [];
|
|
3708
|
+
const landed = [];
|
|
3709
|
+
const runItem = (send, label) => send.pipe(Effect.as({
|
|
3710
|
+
ok: true,
|
|
3711
|
+
error: void 0
|
|
3712
|
+
}), Effect.catch((error) => Effect.succeed({
|
|
3713
|
+
ok: false,
|
|
3714
|
+
error: formatApiError(`${label} failed`, error)
|
|
3715
|
+
})));
|
|
3716
|
+
for (const item of plan.items) {
|
|
3717
|
+
if (item.action !== "create" && item.action !== "update") continue;
|
|
3718
|
+
const source = sources.get(item.slug);
|
|
3719
|
+
if (source === void 0 || item.sourceHash === void 0) continue;
|
|
3720
|
+
const outcome = yield* runItem(client.provide(item.action === "create" ? Templates.create({
|
|
3721
|
+
name: item.name,
|
|
3722
|
+
slug: item.slug,
|
|
3723
|
+
origin: "tsx",
|
|
3724
|
+
source
|
|
3725
|
+
}) : Templates.update({
|
|
3726
|
+
id: slugToId.get(item.slug),
|
|
3727
|
+
data: {
|
|
3728
|
+
origin: "tsx",
|
|
3729
|
+
source
|
|
3730
|
+
}
|
|
3731
|
+
})), item.action);
|
|
3732
|
+
results.push({
|
|
3733
|
+
...item,
|
|
3734
|
+
...outcome
|
|
3735
|
+
});
|
|
3736
|
+
if (outcome.ok) landed.push({
|
|
3737
|
+
slug: item.slug,
|
|
3738
|
+
sourceHash: item.sourceHash
|
|
3739
|
+
});
|
|
3740
|
+
}
|
|
3741
|
+
if (landed.length > 0) yield* writeLock(applyPushToLock(lock, landed, compilerVersion));
|
|
3742
|
+
const failed = results.filter((result) => !result.ok);
|
|
3743
|
+
if (json) return yield* printJson({
|
|
3744
|
+
action: "templates.push",
|
|
3745
|
+
dryRun: false,
|
|
3746
|
+
compilerVersion,
|
|
3747
|
+
skewWarning: skew,
|
|
3748
|
+
warnings,
|
|
3749
|
+
results
|
|
3750
|
+
});
|
|
3751
|
+
for (const result of results) yield* result.ok ? Effect.logInfo(`✓ ${result.action} ${result.slug}`) : Effect.logWarning(`✗ ${result.action} ${result.slug} — ${result.error}`);
|
|
3752
|
+
const created = results.filter((result) => result.ok && result.action === "create").length;
|
|
3753
|
+
const updated = results.filter((result) => result.ok && result.action === "update").length;
|
|
3754
|
+
yield* Effect.logInfo(`\n${created} created · ${updated} updated · ${failed.length} failed`);
|
|
3755
|
+
if (failed.length > 0) return yield* new SamvaCliError({ message: `${failed.length} template(s) failed to push (${failed.map((result) => result.slug).join(", ")}).` });
|
|
3756
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
3757
|
+
const pull = Command.make("pull", {
|
|
3758
|
+
dir: dirFlag,
|
|
3759
|
+
dryRun: dryRunFlag,
|
|
3760
|
+
json: jsonFlag
|
|
3761
|
+
}).pipe(Command.withDescription("Write the platform's template SML to local .samva files (one-way)"), Command.withExamples([{
|
|
3762
|
+
command: "samva templates pull emails",
|
|
3763
|
+
description: "Write each platform template's SML to emails/<slug>.samva"
|
|
3764
|
+
}]), Command.withHandler(({ dir, dryRun, json }) => Effect.gen(function* () {
|
|
3765
|
+
const root = resolve(dir);
|
|
3766
|
+
if (dryRun) return yield* json ? printJson({
|
|
3767
|
+
dryRun: true,
|
|
3768
|
+
action: "templates.pull",
|
|
3769
|
+
dir: root
|
|
3770
|
+
}) : Console.log(`[dry-run] Would write remote template SML to ${root}.`);
|
|
3771
|
+
const client = yield* getClientProvider;
|
|
3772
|
+
const listed = yield* listAllTemplates(client).pipe(Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not list templates", error) })));
|
|
3773
|
+
const withSource = listed.filter((item) => typeof item.source === "string" && item.source.length > 0);
|
|
3774
|
+
const skipped = listed.filter((item) => typeof item.source !== "string" || item.source.length === 0);
|
|
3775
|
+
yield* Effect.tryPromise({
|
|
3776
|
+
try: () => Promise.all(withSource.map(async (item) => {
|
|
3777
|
+
const target = join(root, `${item.slug}.samva`);
|
|
3778
|
+
await mkdir(dirname(target), { recursive: true });
|
|
3779
|
+
await writeFile(target, item.source, "utf8");
|
|
3780
|
+
})),
|
|
3781
|
+
catch: (cause) => new SamvaCliError({ message: `Could not write .samva files: ${String(cause)}` })
|
|
3782
|
+
});
|
|
3783
|
+
if (json) return yield* printJson({
|
|
3784
|
+
action: "templates.pull",
|
|
3785
|
+
dir: root,
|
|
3786
|
+
written: withSource.map((item) => ({
|
|
3787
|
+
slug: item.slug,
|
|
3788
|
+
name: item.name,
|
|
3789
|
+
file: `${item.slug}.samva`
|
|
3790
|
+
})),
|
|
3791
|
+
skipped: skipped.map((item) => ({
|
|
3792
|
+
slug: item.slug,
|
|
3793
|
+
name: item.name
|
|
3794
|
+
}))
|
|
3795
|
+
});
|
|
3796
|
+
yield* printFields([
|
|
3797
|
+
["Target dir", root],
|
|
3798
|
+
["Written", withSource.length],
|
|
3799
|
+
["Skipped (no SML source)", skipped.length]
|
|
3800
|
+
]);
|
|
3801
|
+
for (const item of withSource) yield* Effect.logInfo(`✓ ${item.slug}.samva`);
|
|
3802
|
+
for (const item of skipped) yield* Effect.logWarning(`⤫ ${item.slug} — no SML source, skipped`);
|
|
3803
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
3804
|
+
const STARTER_WELCOME = `import { A, Button, Container, Email, Heading, Preview, Strong, Text } from "@samva/markup/components";
|
|
3805
|
+
import type { Variables } from "@samva/markup/variables";
|
|
3806
|
+
|
|
3807
|
+
export default function WelcomeEmail(v: Variables<{ firstName: string; workspace: string; ctaUrl: string }>) {
|
|
3808
|
+
return (
|
|
3809
|
+
<Email class="bg-gray-100">
|
|
3810
|
+
<Preview>Welcome aboard, {v.firstName}</Preview>
|
|
3811
|
+
<Container class="max-w-[600px] rounded-lg bg-white px-8 py-6">
|
|
3812
|
+
<Heading level={1} class="text-2xl font-bold text-gray-900">
|
|
3813
|
+
Welcome, {v.firstName}
|
|
3814
|
+
</Heading>
|
|
3815
|
+
<Text class="text-base text-gray-700 dark:text-gray-200">
|
|
3816
|
+
Thanks for joining <Strong>Samva</Strong>. Your workspace {v.workspace} is ready — open the{" "}
|
|
3817
|
+
<A href="https://samva.dev/docs">docs</A> to begin.
|
|
3818
|
+
</Text>
|
|
3819
|
+
<Button href="{{ctaUrl}}" class="rounded-md bg-blue-600 px-6 py-3 font-semibold text-white dark:bg-blue-400">
|
|
3820
|
+
Open dashboard
|
|
3821
|
+
</Button>
|
|
3822
|
+
</Container>
|
|
3823
|
+
</Email>
|
|
3824
|
+
);
|
|
3825
|
+
}
|
|
3826
|
+
`;
|
|
3827
|
+
const STARTER_VITE_CONFIG = `import { samvaEditor } from "@samva/vite";
|
|
3828
|
+
import { defineConfig } from "vite";
|
|
3829
|
+
|
|
3830
|
+
// Run \`npm run samva:dev\` and open http://localhost:5173/ for the Samva editor.
|
|
3831
|
+
export default defineConfig({
|
|
3832
|
+
plugins: [samvaEditor({ templatesDir: "emails" })],
|
|
3833
|
+
});
|
|
3834
|
+
`;
|
|
3835
|
+
const STARTER_TSCONFIG = `{
|
|
3836
|
+
"compilerOptions": {
|
|
3837
|
+
"target": "ES2022",
|
|
3838
|
+
"module": "Preserve",
|
|
3839
|
+
"moduleResolution": "Bundler",
|
|
3840
|
+
"strict": true,
|
|
3841
|
+
"skipLibCheck": true,
|
|
3842
|
+
"noEmit": true,
|
|
3843
|
+
"jsx": "react-jsx",
|
|
3844
|
+
"jsxImportSource": "@samva/markup"
|
|
3845
|
+
},
|
|
3846
|
+
"include": ["emails", "samva.vite.config.ts"]
|
|
3847
|
+
}
|
|
3848
|
+
`;
|
|
3849
|
+
const AUTHORING_DEV_DEPENDENCIES = {
|
|
3850
|
+
"@samva/markup": `^${MarkupPackageJson.version}`,
|
|
3851
|
+
"@samva/vite": `^${VitePackageJson.version}`,
|
|
3852
|
+
vite: "^8.0.0"
|
|
3853
|
+
};
|
|
3854
|
+
const AUTHORING_SCRIPTS = {
|
|
3855
|
+
"samva:dev": "vite --config samva.vite.config.ts",
|
|
3856
|
+
"samva:push": "samva templates push --dir emails"
|
|
3857
|
+
};
|
|
3858
|
+
const STARTER_PACKAGE_JSON = `${JSON.stringify({
|
|
3859
|
+
private: true,
|
|
3860
|
+
type: "module",
|
|
3861
|
+
scripts: {
|
|
3862
|
+
dev: "npm run samva:dev",
|
|
3863
|
+
push: "npm run samva:push",
|
|
3864
|
+
...AUTHORING_SCRIPTS
|
|
3865
|
+
},
|
|
3866
|
+
devDependencies: AUTHORING_DEV_DEPENDENCIES
|
|
3867
|
+
}, null, 2)}\n`;
|
|
3868
|
+
const reconcilePackageJson = async (target) => {
|
|
3869
|
+
const raw = await readFile(target, "utf8").catch((error) => error.code === "ENOENT" ? void 0 : Promise.reject(error));
|
|
3870
|
+
if (raw === void 0) {
|
|
3871
|
+
await writeFile(target, STARTER_PACKAGE_JSON, { flag: "wx" });
|
|
3872
|
+
return {
|
|
3873
|
+
path: "package.json",
|
|
3874
|
+
status: "created"
|
|
3875
|
+
};
|
|
3876
|
+
}
|
|
3877
|
+
const manifest = JSON.parse(raw);
|
|
3878
|
+
if (!Predicate.isObject(manifest) || Array.isArray(manifest)) return Promise.reject(new SamvaCliError({ message: "package.json must contain an object." }));
|
|
3879
|
+
const dependencies = manifest.dependencies;
|
|
3880
|
+
if (dependencies !== void 0 && (!Predicate.isObject(dependencies) || Array.isArray(dependencies))) return Promise.reject(new SamvaCliError({ message: "package.json dependencies must contain an object." }));
|
|
3881
|
+
const devDependencies = manifest.devDependencies;
|
|
3882
|
+
if (devDependencies !== void 0 && (!Predicate.isObject(devDependencies) || Array.isArray(devDependencies))) return Promise.reject(new SamvaCliError({ message: "package.json devDependencies must contain an object." }));
|
|
3883
|
+
const scripts = manifest.scripts;
|
|
3884
|
+
if (scripts !== void 0 && (!Predicate.isObject(scripts) || Array.isArray(scripts))) return Promise.reject(new SamvaCliError({ message: "package.json scripts must contain an object." }));
|
|
3885
|
+
const nextDevDependencies = { ...devDependencies };
|
|
3886
|
+
for (const [name, version] of Object.entries(AUTHORING_DEV_DEPENDENCIES)) {
|
|
3887
|
+
if (Object.hasOwn(dependencies ?? {}, name) || Object.hasOwn(nextDevDependencies, name)) continue;
|
|
3888
|
+
nextDevDependencies[name] = version;
|
|
3889
|
+
}
|
|
3890
|
+
const nextScripts = { ...scripts };
|
|
3891
|
+
for (const [name, command] of Object.entries(AUTHORING_SCRIPTS)) if (!Object.hasOwn(nextScripts, name)) nextScripts[name] = command;
|
|
3892
|
+
if (Object.keys(nextDevDependencies).length === Object.keys(devDependencies ?? {}).length && Object.keys(nextScripts).length === Object.keys(scripts ?? {}).length) return {
|
|
3893
|
+
path: "package.json",
|
|
3894
|
+
status: "unchanged"
|
|
3895
|
+
};
|
|
3896
|
+
await writeFile(target, `${JSON.stringify({
|
|
3897
|
+
...manifest,
|
|
3898
|
+
scripts: nextScripts,
|
|
3899
|
+
devDependencies: nextDevDependencies
|
|
3900
|
+
}, null, 2)}\n`);
|
|
3901
|
+
return {
|
|
3902
|
+
path: "package.json",
|
|
3903
|
+
status: "updated"
|
|
3904
|
+
};
|
|
3905
|
+
};
|
|
3906
|
+
const init = Command.make("init", {
|
|
3907
|
+
dir: Flag.directory("dir").pipe(Flag.withDescription("Project directory to scaffold into"), Flag.withDefault(".")),
|
|
3908
|
+
dryRun: dryRunFlag,
|
|
3909
|
+
json: jsonFlag
|
|
3910
|
+
}).pipe(Command.withDescription("Scaffold a Samva email templates project (vite.config, tsconfig, example)"), Command.withExamples([{
|
|
3911
|
+
command: "samva templates init",
|
|
3912
|
+
description: "Scaffold into the current directory"
|
|
3913
|
+
}, {
|
|
3914
|
+
command: "samva templates init --dir my-emails",
|
|
3915
|
+
description: "Scaffold into ./my-emails"
|
|
3916
|
+
}]), Command.withHandler(({ dir, dryRun, json }) => Effect.gen(function* () {
|
|
3917
|
+
const root = resolve(dir);
|
|
3918
|
+
const files = [
|
|
3919
|
+
["samva.vite.config.ts", STARTER_VITE_CONFIG],
|
|
3920
|
+
["tsconfig.json", STARTER_TSCONFIG],
|
|
3921
|
+
["emails/welcome.tsx", STARTER_WELCOME]
|
|
3922
|
+
];
|
|
3923
|
+
const paths = ["package.json", ...files.map(([path]) => path)];
|
|
3924
|
+
if (dryRun) return yield* json ? printJson({
|
|
3925
|
+
dryRun: true,
|
|
3926
|
+
action: "templates.init",
|
|
3927
|
+
dir: root,
|
|
3928
|
+
files: paths
|
|
3929
|
+
}) : Console.log(`[dry-run] Would scaffold Samva email templates in ${root}.`);
|
|
3930
|
+
const results = yield* Effect.tryPromise({
|
|
3931
|
+
try: async () => {
|
|
3932
|
+
await mkdir(root, { recursive: true });
|
|
3933
|
+
return Promise.all([reconcilePackageJson(join(root, "package.json")), ...files.map(async ([relPath, contents]) => {
|
|
3934
|
+
const target = join(root, relPath);
|
|
3935
|
+
await mkdir(dirname(target), { recursive: true });
|
|
3936
|
+
return writeFile(target, contents, { flag: "wx" }).then(() => ({
|
|
3937
|
+
path: relPath,
|
|
3938
|
+
status: "created"
|
|
3939
|
+
})).catch((error) => error.code === "EEXIST" ? {
|
|
3940
|
+
path: relPath,
|
|
3941
|
+
status: "unchanged"
|
|
3942
|
+
} : Promise.reject(error));
|
|
3943
|
+
})]);
|
|
3944
|
+
},
|
|
3945
|
+
catch: (cause) => new SamvaCliError({ message: `Could not scaffold project: ${String(cause)}` })
|
|
3946
|
+
});
|
|
3947
|
+
if (json) return yield* printJson({
|
|
3948
|
+
action: "templates.init",
|
|
3949
|
+
dir: root,
|
|
3950
|
+
files: paths,
|
|
3951
|
+
created: results.filter(({ status }) => status === "created").map(({ path }) => path),
|
|
3952
|
+
updated: results.filter(({ status }) => status === "updated").map(({ path }) => path),
|
|
3953
|
+
unchanged: results.filter(({ status }) => status === "unchanged").map(({ path }) => path)
|
|
3954
|
+
});
|
|
3955
|
+
yield* Effect.logInfo(`Initialized Samva email templates in ${root}:\n`);
|
|
3956
|
+
yield* printFields(results.map(({ path, status }) => [path, status]));
|
|
3957
|
+
yield* Effect.logInfo("\nNext:\n npm install\n npm run samva:dev # preview at http://localhost:5173");
|
|
3958
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
3959
|
+
const listRemote = Command.make("list", {
|
|
3960
|
+
page: Flag.integer("page").pipe(Flag.withDescription("Page number"), Flag.optional),
|
|
3961
|
+
limit: Flag.integer("limit").pipe(Flag.withDescription("Items per page"), Flag.optional),
|
|
3962
|
+
all: Flag.boolean("all").pipe(Flag.withDescription("Traverse every page (requires --jsonl)"), Flag.withDefault(false)),
|
|
3963
|
+
json: jsonFlag
|
|
3964
|
+
}).pipe(Command.withDescription("List remote templates"), Command.withExamples([{ command: "samva templates list --json" }]), Command.withHandler(({ page, limit, all, json }) => Effect.gen(function* () {
|
|
3965
|
+
if (all && !isJsonLinesRequested()) return yield* new CliUsageError({
|
|
3966
|
+
code: "all_requires_jsonl",
|
|
3967
|
+
message: "--all is an unbounded traversal and requires --jsonl."
|
|
3968
|
+
});
|
|
3969
|
+
const client = yield* getClientProvider;
|
|
3970
|
+
const pageValue = Option.getOrUndefined(page);
|
|
3971
|
+
const limitValue = Option.getOrUndefined(limit);
|
|
3972
|
+
const query = {
|
|
3973
|
+
page: pageValue?.toString(),
|
|
3974
|
+
limit: limitValue?.toString()
|
|
3975
|
+
};
|
|
3976
|
+
if (all) {
|
|
3977
|
+
let nextPage = pageValue ?? 1;
|
|
3978
|
+
for (;;) {
|
|
3979
|
+
const data = yield* Templates.list({
|
|
3980
|
+
...query,
|
|
3981
|
+
page: nextPage.toString()
|
|
3982
|
+
}).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not list templates", error) })));
|
|
3983
|
+
for (const item of data.items) yield* printJsonLine(item);
|
|
3984
|
+
if (nextPage >= data.pagination.totalPages || data.items.length === 0) return;
|
|
3985
|
+
nextPage += 1;
|
|
3986
|
+
}
|
|
3987
|
+
}
|
|
3988
|
+
const data = yield* Templates.list(query).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not list templates", error) })));
|
|
3989
|
+
if (isJsonLinesRequested()) {
|
|
3990
|
+
for (const item of data.items) yield* printJsonLine(item);
|
|
3991
|
+
return;
|
|
3992
|
+
}
|
|
3993
|
+
if (json) return yield* printJson(data);
|
|
3994
|
+
for (const item of data.items) yield* printFields([
|
|
3995
|
+
["ID", item.id],
|
|
3996
|
+
["Name", item.name],
|
|
3997
|
+
["Slug", item.slug],
|
|
3998
|
+
["Status", item.status]
|
|
3999
|
+
]);
|
|
4000
|
+
yield* printFields([["Page", data.pagination.page], ["Total", data.pagination.total]]);
|
|
4001
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
4002
|
+
const getRemote = Command.make("get", {
|
|
4003
|
+
id: templateIdArgument,
|
|
4004
|
+
json: jsonFlag
|
|
4005
|
+
}).pipe(Command.withDescription("Get a remote template"), Command.withExamples([{ command: "samva templates get tmpl_abc123 --json" }]), Command.withHandler(({ id, json }) => Effect.gen(function* () {
|
|
4006
|
+
const client = yield* getClientProvider;
|
|
4007
|
+
const data = yield* Templates.getById({ id }).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not get template", error) })));
|
|
4008
|
+
if (json) return yield* printJson(data);
|
|
4009
|
+
yield* printJson(data);
|
|
4010
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
4011
|
+
const createRemote = Command.make("create", {
|
|
4012
|
+
data: Flag.string("data").pipe(Flag.withDescription("Complete template JSON payload")),
|
|
4013
|
+
dryRun: dryRunFlag,
|
|
4014
|
+
json: jsonFlag
|
|
4015
|
+
}).pipe(Command.withDescription("Create a remote template from JSON"), Command.withExamples([{ command: `samva templates create --data '{"name":"Welcome","slug":"welcome","subject":"Welcome","source":"<Email />"}' --dry-run` }]), Command.withHandler(({ data, dryRun, json }) => Effect.gen(function* () {
|
|
4016
|
+
const payload = yield* decodePayload$1(data, "--data", Templates.CreatePayload);
|
|
4017
|
+
if (dryRun) return yield* json ? printJson({
|
|
4018
|
+
dryRun: true,
|
|
4019
|
+
action: "templates.create",
|
|
4020
|
+
request: payload
|
|
4021
|
+
}) : printJson(payload);
|
|
4022
|
+
const client = yield* getClientProvider;
|
|
4023
|
+
const result = yield* Templates.create(payload).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not create template", error) })));
|
|
4024
|
+
if (json) return yield* printJson(result);
|
|
4025
|
+
yield* printJson(result);
|
|
4026
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
4027
|
+
const updateRemote = Command.make("update", {
|
|
4028
|
+
id: templateIdArgument,
|
|
4029
|
+
data: Flag.string("data").pipe(Flag.withDescription("JSON object of template fields to update")),
|
|
4030
|
+
dryRun: dryRunFlag,
|
|
4031
|
+
json: jsonFlag
|
|
4032
|
+
}).pipe(Command.withDescription("Update a remote template from JSON"), Command.withExamples([{ command: `samva templates update tmpl_abc123 --data '{"subject":"Welcome back"}' --dry-run` }]), Command.withHandler(({ id, data, dryRun, json }) => Effect.gen(function* () {
|
|
4033
|
+
const changes = yield* parseObject$1(data, "--data");
|
|
4034
|
+
const payload = yield* decodePayload$1(JSON.stringify({ data: changes }), "--data", Templates.UpdatePayload);
|
|
4035
|
+
if (dryRun) return yield* json ? printJson({
|
|
4036
|
+
dryRun: true,
|
|
4037
|
+
action: "templates.update",
|
|
4038
|
+
id,
|
|
4039
|
+
request: payload
|
|
4040
|
+
}) : printJson(payload);
|
|
4041
|
+
const client = yield* getClientProvider;
|
|
4042
|
+
const result = yield* Templates.update({
|
|
4043
|
+
id,
|
|
4044
|
+
...payload
|
|
4045
|
+
}).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not update template", error) })));
|
|
4046
|
+
if (json) return yield* printJson(result);
|
|
4047
|
+
yield* printJson(result);
|
|
4048
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
4049
|
+
const lifecycleMutation = (name) => Command.make(name, {
|
|
4050
|
+
id: templateIdArgument,
|
|
4051
|
+
dryRun: dryRunFlag,
|
|
4052
|
+
yes: Flag.boolean("yes").pipe(Flag.withDescription("Confirm destructive state change"), Flag.withDefault(false)),
|
|
4053
|
+
json: jsonFlag
|
|
4054
|
+
}).pipe(Command.withDescription(name === "publish" ? "Publish a remote template" : name === "unpublish" ? "Unpublish a remote template" : "Permanently delete a remote template"), Command.withExamples([{ command: `samva templates ${name} tmpl_abc123 --dry-run` }]), Command.withHandler(({ id, dryRun, yes, json }) => Effect.gen(function* () {
|
|
4055
|
+
if (dryRun) return yield* json ? printJson({
|
|
4056
|
+
dryRun: true,
|
|
4057
|
+
action: `templates.${name}`,
|
|
4058
|
+
id
|
|
4059
|
+
}) : Console.log(`[dry-run] Would ${name} template ${id}.`);
|
|
4060
|
+
if (name !== "publish") yield* confirmDestructive({
|
|
4061
|
+
yes,
|
|
4062
|
+
json,
|
|
4063
|
+
message: `${name === "remove" ? "Permanently remove" : "Unpublish"} template ${id}?`,
|
|
4064
|
+
nonInteractiveMessage: `${name} requires --yes in non-interactive mode. Preview with --dry-run.`
|
|
4065
|
+
});
|
|
4066
|
+
const client = yield* getClientProvider;
|
|
4067
|
+
let result;
|
|
4068
|
+
if (name === "publish") result = yield* Templates.publish({ id }).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not publish template", error) })));
|
|
4069
|
+
else if (name === "unpublish") result = yield* Templates.unpublish({ id }).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not unpublish template", error) })));
|
|
4070
|
+
else result = yield* Templates.remove({ id }).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not remove template", error) })));
|
|
4071
|
+
if (json) return yield* printJson(result);
|
|
4072
|
+
yield* printJson(result);
|
|
4073
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
4074
|
+
const versionsList = Command.make("list", {
|
|
4075
|
+
id: templateIdArgument,
|
|
4076
|
+
json: jsonFlag
|
|
4077
|
+
}).pipe(Command.withDescription("List remote template version history"), Command.withExamples([{ command: "samva templates versions list tmpl_abc123 --json" }]), Command.withHandler(({ id, json }) => Effect.gen(function* () {
|
|
4078
|
+
const client = yield* getClientProvider;
|
|
4079
|
+
const result = yield* Templates.versionHistory({ id }).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not list template versions", error) })));
|
|
4080
|
+
if (json) return yield* printJson(result);
|
|
4081
|
+
yield* printJson(result);
|
|
4082
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
4083
|
+
const restore = Command.make("restore", {
|
|
4084
|
+
id: templateIdArgument,
|
|
4085
|
+
versionId: Argument.string("version-id").pipe(Argument.withDescription("Version id to restore")),
|
|
4086
|
+
dryRun: dryRunFlag,
|
|
4087
|
+
yes: Flag.boolean("yes").pipe(Flag.withDefault(false)),
|
|
4088
|
+
json: jsonFlag
|
|
4089
|
+
}).pipe(Command.withDescription("Restore a previous remote template version"), Command.withExamples([{ command: "samva templates versions restore tmpl_abc123 tv_abc123 --dry-run" }]), Command.withHandler(({ id, versionId, dryRun, yes, json }) => Effect.gen(function* () {
|
|
4090
|
+
if (dryRun) return yield* json ? printJson({
|
|
4091
|
+
dryRun: true,
|
|
4092
|
+
action: "templates.restore",
|
|
4093
|
+
id,
|
|
4094
|
+
versionId
|
|
4095
|
+
}) : Console.log(`[dry-run] Would restore ${id} to ${versionId}.`);
|
|
4096
|
+
yield* confirmDestructive({
|
|
4097
|
+
yes,
|
|
4098
|
+
json,
|
|
4099
|
+
message: `Restore template ${id} to version ${versionId}?`,
|
|
4100
|
+
nonInteractiveMessage: "Template restore requires --yes in non-interactive mode. Preview with --dry-run."
|
|
4101
|
+
});
|
|
4102
|
+
const client = yield* getClientProvider;
|
|
4103
|
+
const result = yield* Templates.restoreVersion({
|
|
4104
|
+
id,
|
|
4105
|
+
versionId
|
|
4106
|
+
}).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not restore template", error) })));
|
|
4107
|
+
if (json) return yield* printJson(result);
|
|
4108
|
+
yield* printJson(result);
|
|
4109
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
4110
|
+
const versions = Command.make("versions").pipe(Command.withDescription("List and restore remote template versions"), Command.withExamples([{ command: "samva templates versions list tmpl_abc123 --json" }]), Command.withSubcommands([versionsList, restore]));
|
|
4111
|
+
const templatesCommand = Command.make("templates").pipe(Command.withDescription("Author, preview, and push email templates from local TSX"), Command.withExamples([{
|
|
4112
|
+
command: "samva templates init",
|
|
4113
|
+
description: "Scaffold a new templates project"
|
|
4114
|
+
}, {
|
|
4115
|
+
command: "samva templates push --dir emails --dry-run",
|
|
4116
|
+
description: "Emit SML and preview the push plan"
|
|
4117
|
+
}]), Command.withSubcommands([
|
|
4118
|
+
listRemote,
|
|
4119
|
+
getRemote,
|
|
4120
|
+
createRemote,
|
|
4121
|
+
updateRemote,
|
|
4122
|
+
lifecycleMutation("publish"),
|
|
4123
|
+
lifecycleMutation("unpublish"),
|
|
4124
|
+
lifecycleMutation("remove"),
|
|
4125
|
+
versions,
|
|
4126
|
+
push,
|
|
4127
|
+
pull,
|
|
4128
|
+
init
|
|
4129
|
+
]));
|
|
4130
|
+
//#endregion
|
|
4131
|
+
//#region src/commands/webhooks.ts
|
|
4132
|
+
const idArgument = Argument.string("id").pipe(Argument.withDescription("Webhook id"));
|
|
4133
|
+
const parseObject = (raw, flag) => Effect.try({
|
|
4134
|
+
try: () => JSON.parse(raw),
|
|
4135
|
+
catch: () => new SamvaCliError({ message: `${flag} must be a valid JSON object.` })
|
|
4136
|
+
}).pipe(Effect.flatMap((value) => Predicate.isObject(value) && !Array.isArray(value) ? Effect.succeed(value) : Effect.fail(new SamvaCliError({ message: `${flag} must be a non-null JSON object.` }))));
|
|
4137
|
+
const decodePayload = (raw, flag, schema) => parseObject(raw, flag).pipe(Effect.flatMap(Schema.decodeUnknownEffect(schema)), Effect.mapError((error) => error instanceof SamvaCliError ? error : new SamvaCliError({ message: `${flag} does not match the API schema: ${String(error)}` })));
|
|
4138
|
+
const readData = (raw) => {
|
|
4139
|
+
if (!raw.startsWith("@")) return Effect.succeed(raw);
|
|
4140
|
+
const path = raw.slice(1);
|
|
4141
|
+
if (path.length === 0) return Effect.fail(new CliUsageError({
|
|
4142
|
+
code: "missing_data_file",
|
|
4143
|
+
message: "--data @ requires a file path."
|
|
4144
|
+
}));
|
|
4145
|
+
return Effect.tryPromise({
|
|
4146
|
+
try: () => readFile(path, "utf8"),
|
|
4147
|
+
catch: () => new CliUsageError({
|
|
4148
|
+
code: "data_file_unreadable",
|
|
4149
|
+
message: `Could not read webhook test data from ${path}.`
|
|
4150
|
+
})
|
|
4151
|
+
});
|
|
4152
|
+
};
|
|
4153
|
+
const list = Command.make("list", {
|
|
4154
|
+
page: Flag.integer("page").pipe(Flag.withDescription("Page number"), Flag.optional),
|
|
4155
|
+
limit: Flag.integer("limit").pipe(Flag.withDescription("Items per page"), Flag.optional),
|
|
4156
|
+
all: Flag.boolean("all").pipe(Flag.withDescription("Traverse every page (requires --jsonl)"), Flag.withDefault(false)),
|
|
4157
|
+
json: jsonFlag
|
|
4158
|
+
}).pipe(Command.withDescription("List customer webhooks"), Command.withExamples([{ command: "samva webhooks list --json" }]), Command.withHandler(({ page, limit, all, json }) => Effect.gen(function* () {
|
|
4159
|
+
if (all && !isJsonLinesRequested()) return yield* new CliUsageError({
|
|
4160
|
+
code: "all_requires_jsonl",
|
|
4161
|
+
message: "--all is an unbounded traversal and requires --jsonl."
|
|
4162
|
+
});
|
|
4163
|
+
const client = yield* getClientProvider;
|
|
4164
|
+
const pageValue = Option.getOrUndefined(page);
|
|
4165
|
+
const limitValue = Option.getOrUndefined(limit);
|
|
4166
|
+
const query = {
|
|
4167
|
+
page: pageValue?.toString(),
|
|
4168
|
+
limit: limitValue?.toString()
|
|
4169
|
+
};
|
|
4170
|
+
if (all) {
|
|
4171
|
+
let nextPage = pageValue ?? 1;
|
|
4172
|
+
for (;;) {
|
|
4173
|
+
const data = yield* Webhooks.list({
|
|
4174
|
+
...query,
|
|
4175
|
+
page: nextPage.toString()
|
|
4176
|
+
}).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not list webhooks", error) })));
|
|
4177
|
+
for (const item of data.items) yield* printJsonLine(item);
|
|
4178
|
+
if (nextPage >= data.pagination.totalPages || data.items.length === 0) return;
|
|
4179
|
+
nextPage += 1;
|
|
4180
|
+
}
|
|
4181
|
+
}
|
|
4182
|
+
const data = yield* Webhooks.list(query).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not list webhooks", error) })));
|
|
4183
|
+
if (isJsonLinesRequested()) {
|
|
4184
|
+
for (const item of data.items) yield* printJsonLine(item);
|
|
4185
|
+
return;
|
|
4186
|
+
}
|
|
4187
|
+
if (json) return yield* printJson(data);
|
|
4188
|
+
if (data.items.length === 0) return yield* Console.log("No webhooks found.");
|
|
4189
|
+
for (const hook of data.items) yield* printFields([
|
|
4190
|
+
["ID", hook.id],
|
|
4191
|
+
["Name", hook.name],
|
|
4192
|
+
["Status", hook.status],
|
|
4193
|
+
["URL", hook.url]
|
|
4194
|
+
]);
|
|
4195
|
+
yield* printFields([["Page", data.pagination.page], ["Total", data.pagination.total]]);
|
|
4196
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
4197
|
+
const get = Command.make("get", {
|
|
4198
|
+
id: idArgument,
|
|
4199
|
+
json: jsonFlag
|
|
4200
|
+
}).pipe(Command.withDescription("Get a customer webhook"), Command.withExamples([{ command: "samva webhooks get wh_abc123 --json" }]), Command.withHandler(({ id, json }) => Effect.gen(function* () {
|
|
4201
|
+
const client = yield* getClientProvider;
|
|
4202
|
+
const data = yield* Webhooks.get({ id }).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not get webhook", error) })));
|
|
4203
|
+
if (json) return yield* printJson(data);
|
|
4204
|
+
yield* printFields([
|
|
4205
|
+
["ID", data.id],
|
|
4206
|
+
["Name", data.name],
|
|
4207
|
+
["Status", data.status],
|
|
4208
|
+
["URL", data.url]
|
|
4209
|
+
]);
|
|
4210
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
4211
|
+
const logs = Command.make("logs", {
|
|
4212
|
+
id: idArgument,
|
|
4213
|
+
limit: Flag.integer("limit").pipe(Flag.optional),
|
|
4214
|
+
json: jsonFlag
|
|
4215
|
+
}).pipe(Command.withDescription("List webhook delivery logs"), Command.withExamples([{ command: "samva webhooks logs wh_abc123 --limit 25 --json" }]), Command.withHandler(({ id, limit, json }) => Effect.gen(function* () {
|
|
4216
|
+
const client = yield* getClientProvider;
|
|
4217
|
+
const limitValue = Option.getOrUndefined(limit);
|
|
4218
|
+
const data = yield* Webhooks.getLogs({
|
|
4219
|
+
webhookId: id,
|
|
4220
|
+
...limitValue === void 0 ? {} : { limit: limitValue.toString() }
|
|
4221
|
+
}).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not list webhook logs", error) })));
|
|
4222
|
+
if (json) return yield* printJson(data);
|
|
4223
|
+
yield* printJson(data.data);
|
|
4224
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
4225
|
+
const stats = Command.make("stats", {
|
|
4226
|
+
id: idArgument,
|
|
4227
|
+
json: jsonFlag
|
|
4228
|
+
}).pipe(Command.withDescription("Get webhook delivery statistics"), Command.withExamples([{ command: "samva webhooks stats wh_abc123 --json" }]), Command.withHandler(({ id, json }) => Effect.gen(function* () {
|
|
4229
|
+
const client = yield* getClientProvider;
|
|
4230
|
+
const data = yield* Webhooks.getStats({ webhookId: id }).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not get webhook stats", error) })));
|
|
4231
|
+
if (json) return yield* printJson(data);
|
|
4232
|
+
yield* printJson(data);
|
|
4233
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
4234
|
+
const test = Command.make("test", {
|
|
4235
|
+
id: idArgument,
|
|
4236
|
+
data: Flag.string("data").pipe(Flag.withDescription("JSON object or @file to send as the signed test payload"), Flag.optional),
|
|
4237
|
+
dryRun: Flag.boolean("dry-run").pipe(Flag.withDescription("Validate and preview without sending a request"), Flag.withDefault(false)),
|
|
4238
|
+
json: jsonFlag
|
|
4239
|
+
}).pipe(Command.withDescription("Send a signed test request to a webhook endpoint"), Command.withExamples([
|
|
4240
|
+
{ command: "samva webhooks test wep_abc123 --dry-run" },
|
|
4241
|
+
{ command: `samva webhooks test wep_abc123 --data '{"event":"webhook.test"}' --json` },
|
|
4242
|
+
{ command: "samva webhooks test wep_abc123 --data @payload.json --json" }
|
|
4243
|
+
]), Command.withHandler(({ id, data, dryRun, json }) => Effect.gen(function* () {
|
|
4244
|
+
const raw = Option.getOrUndefined(data);
|
|
4245
|
+
const payload = raw === void 0 ? yield* Schema.decodeUnknownEffect(Webhooks.TestPayload)({}) : yield* readData(raw).pipe(Effect.flatMap((content) => parseObject(content, "--data")), Effect.flatMap((value) => Schema.decodeUnknownEffect(Webhooks.TestPayload)({ data: value })), Effect.mapError((error) => error instanceof CliUsageError || error instanceof SamvaCliError ? error : new SamvaCliError({ message: `--data does not match the API schema: ${String(error)}` })));
|
|
4246
|
+
if (dryRun) return yield* json ? printJson({
|
|
4247
|
+
dryRun: true,
|
|
4248
|
+
action: "webhooks.test",
|
|
4249
|
+
id,
|
|
4250
|
+
request: payload
|
|
4251
|
+
}) : printFields([
|
|
4252
|
+
["Action", "webhooks.test"],
|
|
4253
|
+
["Webhook", id],
|
|
4254
|
+
["Payload", payload.data ? JSON.stringify(payload.data) : "default"]
|
|
4255
|
+
]);
|
|
4256
|
+
const client = yield* getClientProvider;
|
|
4257
|
+
const result = yield* Webhooks.test({
|
|
4258
|
+
id,
|
|
4259
|
+
...payload
|
|
4260
|
+
}).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not test webhook", error) })));
|
|
4261
|
+
if (json) return yield* printJson(result);
|
|
4262
|
+
yield* printFields([
|
|
4263
|
+
["Status", "Queued"],
|
|
4264
|
+
["Event", result.eventId],
|
|
4265
|
+
["Delivery", result.deliveryId]
|
|
4266
|
+
]);
|
|
4267
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
4268
|
+
const retry = Command.make("retry", {
|
|
4269
|
+
id: idArgument,
|
|
4270
|
+
deliveryId: Argument.string("delivery-id").pipe(Argument.withDescription("Webhook delivery id")),
|
|
4271
|
+
dryRun: Flag.boolean("dry-run").pipe(Flag.withDefault(false)),
|
|
4272
|
+
json: jsonFlag
|
|
4273
|
+
}).pipe(Command.withDescription("Retry a failed webhook delivery"), Command.withExamples([{ command: "samva webhooks retry wh_abc123 del_abc123 --dry-run" }]), Command.withHandler(({ id, deliveryId, dryRun, json }) => Effect.gen(function* () {
|
|
4274
|
+
if (dryRun) return yield* json ? printJson({
|
|
4275
|
+
dryRun: true,
|
|
4276
|
+
action: "webhooks.retry",
|
|
4277
|
+
id,
|
|
4278
|
+
deliveryId
|
|
4279
|
+
}) : Console.log(`[dry-run] Would retry delivery ${deliveryId} for webhook ${id}.`);
|
|
4280
|
+
const client = yield* getClientProvider;
|
|
4281
|
+
const data = yield* Webhooks.retryDelivery({
|
|
4282
|
+
webhookId: id,
|
|
4283
|
+
deliveryId
|
|
4284
|
+
}).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not retry webhook delivery", error) })));
|
|
4285
|
+
if (json) return yield* printJson(data);
|
|
4286
|
+
yield* printJson(data);
|
|
4287
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
4288
|
+
const rotate = Command.make("rotate-secret", {
|
|
4289
|
+
id: idArgument,
|
|
4290
|
+
dryRun: Flag.boolean("dry-run").pipe(Flag.withDefault(false)),
|
|
4291
|
+
yes: Flag.boolean("yes").pipe(Flag.withDefault(false)),
|
|
4292
|
+
json: jsonFlag
|
|
4293
|
+
}).pipe(Command.withDescription("Rotate a webhook signing secret"), Command.withExamples([{ command: "samva webhooks rotate-secret wh_abc123 --dry-run" }, { command: "samva webhooks rotate-secret wh_abc123 --yes --json" }]), Command.withHandler(({ id, dryRun, yes, json }) => Effect.gen(function* () {
|
|
4294
|
+
if (dryRun) return yield* json ? printJson({
|
|
4295
|
+
dryRun: true,
|
|
4296
|
+
action: "webhooks.rotate-secret",
|
|
4297
|
+
id
|
|
4298
|
+
}) : Console.log(`[dry-run] Would rotate the signing secret for webhook ${id}.`);
|
|
4299
|
+
yield* confirmDestructive({
|
|
4300
|
+
yes,
|
|
4301
|
+
json,
|
|
4302
|
+
message: `Rotate the signing secret for webhook ${id}? The current secret will stop working.`,
|
|
4303
|
+
nonInteractiveMessage: "Secret rotation requires --yes in non-interactive mode. Preview with --dry-run."
|
|
4304
|
+
});
|
|
4305
|
+
const client = yield* getClientProvider;
|
|
4306
|
+
const data = yield* Webhooks.regenerateSecret({ webhookId: id }).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not rotate webhook secret", error) })));
|
|
4307
|
+
const warning = "This signing secret is shown once. Store it now; the previous secret remains valid for 24 hours.";
|
|
4308
|
+
const secret = Redacted.value(data.secret);
|
|
4309
|
+
if (json) return yield* printJson({
|
|
4310
|
+
secret,
|
|
4311
|
+
displayOnce: true,
|
|
4312
|
+
warning
|
|
4313
|
+
});
|
|
4314
|
+
yield* Console.error(`WARNING: ${warning}`);
|
|
4315
|
+
yield* Console.log(secret);
|
|
4316
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
4317
|
+
const create = Command.make("create", {
|
|
4318
|
+
data: Flag.string("data").pipe(Flag.withDescription("Webhook JSON: name, url, events, and optional retry settings")),
|
|
4319
|
+
dryRun: Flag.boolean("dry-run").pipe(Flag.withDefault(false)),
|
|
4320
|
+
json: jsonFlag
|
|
4321
|
+
}).pipe(Command.withDescription("Create a customer webhook from JSON"), Command.withExamples([{ command: `samva webhooks create --data '{"name":"delivery","url":"https://example.com/hook","events":["message.delivered"]}' --dry-run` }]), Command.withHandler(({ data, dryRun, json }) => Effect.gen(function* () {
|
|
4322
|
+
const payload = yield* decodePayload(data, "--data", Webhooks.CreatePayload);
|
|
4323
|
+
if (dryRun) return yield* json ? printJson({
|
|
4324
|
+
dryRun: true,
|
|
4325
|
+
action: "webhooks.create",
|
|
4326
|
+
request: payload
|
|
4327
|
+
}) : printJson(payload);
|
|
4328
|
+
const client = yield* getClientProvider;
|
|
4329
|
+
const result = yield* Webhooks.create(payload).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not create webhook", error) })));
|
|
4330
|
+
if (json) return yield* printJson(result);
|
|
4331
|
+
yield* printJson(result);
|
|
4332
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
4333
|
+
const update = Command.make("update", {
|
|
4334
|
+
id: idArgument,
|
|
4335
|
+
data: Flag.string("data").pipe(Flag.withDescription("JSON object of webhook fields to update")),
|
|
4336
|
+
dryRun: Flag.boolean("dry-run").pipe(Flag.withDefault(false)),
|
|
4337
|
+
json: jsonFlag
|
|
4338
|
+
}).pipe(Command.withDescription("Update a customer webhook from JSON"), Command.withExamples([{ command: `samva webhooks update wh_abc123 --data '{"url":"https://example.com/new"}' --dry-run` }]), Command.withHandler(({ id, data, dryRun, json }) => Effect.gen(function* () {
|
|
4339
|
+
const payload = yield* decodePayload(data, "--data", Webhooks.UpdatePayload);
|
|
4340
|
+
if (dryRun) return yield* json ? printJson({
|
|
4341
|
+
dryRun: true,
|
|
4342
|
+
action: "webhooks.update",
|
|
4343
|
+
id,
|
|
4344
|
+
request: payload
|
|
4345
|
+
}) : printJson(payload);
|
|
4346
|
+
const client = yield* getClientProvider;
|
|
4347
|
+
const result = yield* Webhooks.update({
|
|
4348
|
+
id,
|
|
4349
|
+
...payload
|
|
4350
|
+
}).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not update webhook", error) })));
|
|
4351
|
+
if (json) return yield* printJson(result);
|
|
4352
|
+
yield* printJson(result);
|
|
4353
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
4354
|
+
const remove = Command.make("remove", {
|
|
4355
|
+
id: idArgument,
|
|
4356
|
+
dryRun: Flag.boolean("dry-run").pipe(Flag.withDefault(false)),
|
|
4357
|
+
yes: Flag.boolean("yes").pipe(Flag.withDefault(false)),
|
|
4358
|
+
json: jsonFlag
|
|
4359
|
+
}).pipe(Command.withDescription("Permanently delete a customer webhook"), Command.withExamples([{ command: "samva webhooks remove wh_abc123 --dry-run" }, { command: "samva webhooks remove wh_abc123 --yes" }]), Command.withHandler(({ id, dryRun, yes, json }) => Effect.gen(function* () {
|
|
4360
|
+
if (dryRun) return yield* json ? printJson({
|
|
4361
|
+
dryRun: true,
|
|
4362
|
+
action: "webhooks.remove",
|
|
4363
|
+
id
|
|
4364
|
+
}) : Console.log(`[dry-run] Would permanently remove webhook ${id}.`);
|
|
4365
|
+
yield* confirmDestructive({
|
|
4366
|
+
yes,
|
|
4367
|
+
json,
|
|
4368
|
+
message: `Permanently remove webhook ${id}?`,
|
|
4369
|
+
nonInteractiveMessage: "Permanent webhook deletion requires --yes in non-interactive mode. Preview with --dry-run."
|
|
4370
|
+
});
|
|
4371
|
+
const client = yield* getClientProvider;
|
|
4372
|
+
const result = yield* Webhooks.remove({ id }).pipe(client.provide, Effect.mapError((error) => new SamvaCliError({ message: formatApiError("Could not remove webhook", error) })));
|
|
4373
|
+
if (json) return yield* printJson(result);
|
|
4374
|
+
yield* Console.log(`Removed webhook ${id}.`);
|
|
4375
|
+
}).pipe(json ? Effect.provideService(References.MinimumLogLevel, "None") : identity)));
|
|
4376
|
+
const webhooksCommand = Command.make("webhooks").pipe(Command.withDescription("Inspect, test, and recover customer webhooks"), Command.withExamples([{ command: "samva webhooks list --json" }]), Command.withSubcommands([
|
|
4377
|
+
list,
|
|
4378
|
+
get,
|
|
4379
|
+
create,
|
|
4380
|
+
update,
|
|
4381
|
+
test,
|
|
4382
|
+
logs,
|
|
4383
|
+
stats,
|
|
4384
|
+
retry,
|
|
4385
|
+
rotate,
|
|
4386
|
+
remove
|
|
4387
|
+
]));
|
|
4388
|
+
//#endregion
|
|
4389
|
+
//#region src/lib/help-manifest.ts
|
|
4390
|
+
const helpManifest = {
|
|
4391
|
+
schemaVersion: 1,
|
|
4392
|
+
name: "samva",
|
|
4393
|
+
commands: [
|
|
4394
|
+
"login",
|
|
4395
|
+
"logout",
|
|
4396
|
+
"org",
|
|
4397
|
+
"email",
|
|
4398
|
+
"messages",
|
|
4399
|
+
"campaigns",
|
|
4400
|
+
"scheduled-messages",
|
|
4401
|
+
"templates",
|
|
4402
|
+
"webhooks",
|
|
4403
|
+
"profile",
|
|
4404
|
+
"help"
|
|
4405
|
+
],
|
|
4406
|
+
globalOptions: [
|
|
4407
|
+
"--json",
|
|
4408
|
+
"--jsonl",
|
|
4409
|
+
"--quiet",
|
|
4410
|
+
"--profile",
|
|
4411
|
+
"--api-url",
|
|
4412
|
+
"--org",
|
|
4413
|
+
"--no-input",
|
|
4414
|
+
"--completions"
|
|
4415
|
+
]
|
|
4416
|
+
};
|
|
4417
|
+
//#endregion
|
|
4418
|
+
//#region src/cli.ts
|
|
4419
|
+
const rootCommands = [
|
|
4420
|
+
loginCommand,
|
|
4421
|
+
logoutCommand,
|
|
4422
|
+
orgCommand,
|
|
4423
|
+
emailCommand,
|
|
4424
|
+
messagesCommand,
|
|
4425
|
+
campaignsCommand,
|
|
4426
|
+
scheduledMessagesCommand,
|
|
4427
|
+
templatesCommand,
|
|
4428
|
+
webhooksCommand,
|
|
4429
|
+
profileCommand
|
|
4430
|
+
];
|
|
4431
|
+
const helpCommand = Command.make("help").pipe(Command.withDescription("Describe released commands and global options"), Command.withHandler(Effect.fnUntraced(function* () {
|
|
4432
|
+
if ((yield* Json) || (yield* JsonLines)) return yield* printJson(helpManifest);
|
|
4433
|
+
if (yield* Quiet) return;
|
|
4434
|
+
yield* Console.log("Run `samva <command> --help` for command-specific usage, or `samva --completions <bash|zsh|fish|sh>` for shell completions.");
|
|
4435
|
+
})));
|
|
4436
|
+
/** Root `samva` command. Subcommands are thin wrappers over the `samva` SDK. */
|
|
4437
|
+
const cli = Command.make("samva").pipe(Command.withDescription(`Samva — email and platform operations from your terminal. ${AGENT_AUTH_HINT}`), Command.withExamples([
|
|
4438
|
+
{
|
|
4439
|
+
command: "samva email send --to you@example.com --subject Hi --text Hello",
|
|
4440
|
+
description: "Send email with SAMVA_API_KEY set"
|
|
4441
|
+
},
|
|
4442
|
+
{
|
|
4443
|
+
command: "samva org current",
|
|
4444
|
+
description: "Show active org (OAuth sessions; API keys are single-org)"
|
|
4445
|
+
},
|
|
4446
|
+
{
|
|
4447
|
+
command: "samva --completions fish > ~/.config/fish/completions/samva.fish",
|
|
4448
|
+
description: "Generate shell completions"
|
|
4449
|
+
}
|
|
4450
|
+
]), Command.withSubcommands([...rootCommands, helpCommand]), Command.withGlobalFlags(globalFlags));
|
|
4451
|
+
//#endregion
|
|
4452
|
+
//#region src/bin.ts
|
|
4453
|
+
const argv = process.argv.slice(2);
|
|
4454
|
+
const outputMode = outputModeFromArgv(argv);
|
|
4455
|
+
const machineMode = outputMode === "json" || outputMode === "jsonl";
|
|
4456
|
+
const optionTerminator = argv.indexOf("--");
|
|
4457
|
+
const processArgs = optionTerminator === -1 ? argv : argv.slice(0, optionTerminator);
|
|
4458
|
+
const rootOptionsWithValue = /* @__PURE__ */ new Set([
|
|
4459
|
+
"--profile",
|
|
4460
|
+
"--api-url",
|
|
4461
|
+
"--org",
|
|
4462
|
+
"--log-level"
|
|
4463
|
+
]);
|
|
4464
|
+
const rootMachineHelp = (() => {
|
|
4465
|
+
let requested = false;
|
|
4466
|
+
for (let index = 0; index < processArgs.length; index++) {
|
|
4467
|
+
const token = processArgs[index];
|
|
4468
|
+
if (token === "--help" || token === "-h") {
|
|
4469
|
+
requested = true;
|
|
4470
|
+
continue;
|
|
4471
|
+
}
|
|
4472
|
+
if (rootOptionsWithValue.has(token)) {
|
|
4473
|
+
index++;
|
|
4474
|
+
continue;
|
|
4475
|
+
}
|
|
4476
|
+
if (token.startsWith("-")) continue;
|
|
4477
|
+
return {
|
|
4478
|
+
requested,
|
|
4479
|
+
command: token
|
|
4480
|
+
};
|
|
4481
|
+
}
|
|
4482
|
+
return {
|
|
4483
|
+
requested,
|
|
4484
|
+
command: void 0
|
|
4485
|
+
};
|
|
4486
|
+
})();
|
|
4487
|
+
const isRootMachineHelp = machineMode && rootMachineHelp.requested && rootMachineHelp.command === void 0;
|
|
4488
|
+
const isUnknownRootMachineHelp = machineMode && rootMachineHelp.requested && rootMachineHelp.command !== void 0 && !isKnownRootCommand(rootMachineHelp.command);
|
|
4489
|
+
/** Status belongs on a TTY stderr; stdout is result data only. */
|
|
4490
|
+
const cliLogger = Logger.make(({ logLevel, message }) => {
|
|
4491
|
+
const text = (Array.isArray(message) ? message : [message]).map(String).join(" ");
|
|
4492
|
+
if (LogLevel.isGreaterThanOrEqualTo(logLevel, "Warn")) process.stderr.write(`${text}\n`);
|
|
4493
|
+
else if (outputMode === "human" && process.stderr.isTTY) process.stderr.write(`${text}\n`);
|
|
4494
|
+
});
|
|
4495
|
+
const messageFor = (error) => {
|
|
4496
|
+
if (error instanceof Error) return error.message;
|
|
4497
|
+
if (Predicate.isObject(error) && Predicate.hasProperty(error, "message")) {
|
|
4498
|
+
const message = error.message;
|
|
4499
|
+
if (typeof message === "string") return message;
|
|
4500
|
+
}
|
|
4501
|
+
return "Samva CLI failed unexpectedly.";
|
|
4502
|
+
};
|
|
4503
|
+
/**
|
|
4504
|
+
* A bare root or parent command (no subcommand) fails as `ShowHelp` with no
|
|
4505
|
+
* parse errors. The framework already rendered its help into the stdout buffer,
|
|
4506
|
+
* so this failure is really a help request — not a diagnostic.
|
|
4507
|
+
*/
|
|
4508
|
+
const isBareHelp = (error) => error instanceof CliError.ShowHelp && error.errors.length === 0;
|
|
4509
|
+
/**
|
|
4510
|
+
* On a parse failure the framework renders a full-width `ERROR` block to stderr
|
|
4511
|
+
* via `Console.error`. We render our own single diagnostic (a `✗` line or the
|
|
4512
|
+
* JSON envelope), so blank the framework's error rendering here and drop the
|
|
4513
|
+
* resulting empty write inside the buffer. Help-doc rendering is untouched, so
|
|
4514
|
+
* `--help` and bare-command help are unaffected.
|
|
4515
|
+
*/
|
|
4516
|
+
const formatter = {
|
|
4517
|
+
...CliOutput.defaultFormatter(),
|
|
4518
|
+
formatError: () => "",
|
|
4519
|
+
formatErrors: () => ""
|
|
4520
|
+
};
|
|
4521
|
+
const flushSuccessfulResults = (state, partial = false) => Effect.gen(function* () {
|
|
4522
|
+
if (outputMode === "quiet" || outputMode === "jsonl") return;
|
|
4523
|
+
if (outputMode === "human") {
|
|
4524
|
+
yield* Effect.sync(() => state.lines.forEach((line) => state.originalLog(line)));
|
|
4525
|
+
return;
|
|
4526
|
+
}
|
|
4527
|
+
const data = yield* Effect.try({
|
|
4528
|
+
try: () => {
|
|
4529
|
+
if (state.lines.length !== 1) return {
|
|
4530
|
+
invalid: true,
|
|
4531
|
+
count: state.lines.length
|
|
4532
|
+
};
|
|
4533
|
+
return {
|
|
4534
|
+
invalid: false,
|
|
4535
|
+
data: JSON.parse(state.lines[0])
|
|
4536
|
+
};
|
|
4537
|
+
},
|
|
4538
|
+
catch: () => ({
|
|
4539
|
+
invalid: true,
|
|
4540
|
+
count: state.lines.length
|
|
4541
|
+
})
|
|
4542
|
+
});
|
|
4543
|
+
if (data.invalid) {
|
|
4544
|
+
process.exitCode = 1;
|
|
4545
|
+
yield* printJsonError(void 0, "The command did not produce exactly one machine-readable result.");
|
|
4546
|
+
return;
|
|
4547
|
+
}
|
|
4548
|
+
yield* Effect.sync(() => state.originalLog(JSON.stringify(resultEnvelope(commandNameFromArgv(argv), data.data, partial ? true : void 0), null, 2)));
|
|
4549
|
+
});
|
|
4550
|
+
const flushLifecycleResult = (state, error) => Effect.sync(() => {
|
|
4551
|
+
state.lines.length = 0;
|
|
4552
|
+
if (outputMode === "quiet") return;
|
|
4553
|
+
if (outputMode === "human") {
|
|
4554
|
+
state.originalLog(error.human);
|
|
4555
|
+
return;
|
|
4556
|
+
}
|
|
4557
|
+
const result = resultEnvelope(commandNameFromArgv(argv), error.partialMessage, true);
|
|
4558
|
+
state.originalLog(JSON.stringify(result, null, outputMode === "jsonl" ? void 0 : 2));
|
|
4559
|
+
});
|
|
4560
|
+
const flushFailedResult = (state, error) => Effect.sync(() => {
|
|
4561
|
+
state.lines.length = 0;
|
|
4562
|
+
if (outputMode === "quiet") return;
|
|
4563
|
+
if (outputMode === "human") {
|
|
4564
|
+
state.originalLog(error.human);
|
|
4565
|
+
return;
|
|
4566
|
+
}
|
|
4567
|
+
const result = resultEnvelope(commandNameFromArgv(argv), error.result);
|
|
4568
|
+
state.originalLog(JSON.stringify(result, null, outputMode === "jsonl" ? void 0 : 2));
|
|
4569
|
+
});
|
|
4570
|
+
const withStdoutBuffer = (program) => Effect.acquireUseRelease(Effect.sync(() => {
|
|
4571
|
+
const originalLog = console.log;
|
|
4572
|
+
const originalError = console.error;
|
|
4573
|
+
const lines = [];
|
|
4574
|
+
console.log = ((...values) => {
|
|
4575
|
+
lines.push(values.map(String).join(" "));
|
|
4576
|
+
});
|
|
4577
|
+
console.error = ((...values) => {
|
|
4578
|
+
if (values.map(String).join(" ").length === 0) return;
|
|
4579
|
+
originalError(...values);
|
|
4580
|
+
});
|
|
4581
|
+
return {
|
|
4582
|
+
originalLog,
|
|
4583
|
+
originalError,
|
|
4584
|
+
lines
|
|
4585
|
+
};
|
|
4586
|
+
}), (state) => program.pipe(Effect.tap(() => flushSuccessfulResults(state)), Effect.tapError((error) => {
|
|
4587
|
+
if (isBareHelp(error) && outputMode === "human") return Effect.sync(() => state.lines.forEach((line) => state.originalLog(line)));
|
|
4588
|
+
if (error instanceof CliMessageLifecycleError) return flushLifecycleResult(state, error);
|
|
4589
|
+
if (error instanceof CliResultError) return flushFailedResult(state, error);
|
|
4590
|
+
return Effect.sync(() => {
|
|
4591
|
+
state.lines.length = 0;
|
|
4592
|
+
});
|
|
4593
|
+
})), (state) => Effect.sync(() => {
|
|
4594
|
+
console.log = state.originalLog;
|
|
4595
|
+
console.error = state.originalError;
|
|
4596
|
+
state.lines.length = 0;
|
|
4597
|
+
}));
|
|
4598
|
+
const renderFailure = (error) => Effect.gen(function* () {
|
|
4599
|
+
process.exitCode = processErrorCode(error);
|
|
4600
|
+
if (isBareHelp(error)) {
|
|
4601
|
+
if (machineMode) yield* printJsonError(error, messageFor(error));
|
|
4602
|
+
return;
|
|
4603
|
+
}
|
|
4604
|
+
const message = error instanceof CliError.ShowHelp && error.errors.length > 0 ? `${error.errors[0]?.message ?? "Invalid command input."}\nTry: ${error.commandPath.join(" ")} --help` : messageFor(error);
|
|
4605
|
+
if (!machineMode) {
|
|
4606
|
+
yield* Console.error(`✗ ${message}`);
|
|
4607
|
+
return;
|
|
4608
|
+
}
|
|
4609
|
+
yield* printJsonError(error, message);
|
|
4610
|
+
});
|
|
4611
|
+
const runMachineHelp = Effect.sync(() => {
|
|
4612
|
+
const payload = resultEnvelope(commandNameFromArgv(argv), helpManifest);
|
|
4613
|
+
process.stdout.write(`${JSON.stringify(payload, null, outputMode === "jsonl" ? void 0 : 2)}\n`);
|
|
4614
|
+
});
|
|
4615
|
+
const program = (isRootMachineHelp ? validateProcessArgs(argv).pipe(Effect.flatMap(() => runMachineHelp)) : isUnknownRootMachineHelp ? validateProcessArgs(argv).pipe(Effect.flatMap(() => new CliUsageError({
|
|
4616
|
+
code: "unknown_subcommand",
|
|
4617
|
+
message: `Unknown subcommand ${JSON.stringify(rootMachineHelp.command)} for samva.`
|
|
4618
|
+
}))) : withStdoutBuffer(validateProcessArgs(argv).pipe(Effect.flatMap(() => Command.run(cli, { version }))))).pipe(Effect.provide(Layer.mergeAll(Logger.layer([cliLogger]), BunServices.layer, CliOutput.layer(formatter))), Effect.matchEffect({
|
|
4619
|
+
onFailure: renderFailure,
|
|
4620
|
+
onSuccess: () => Effect.void
|
|
4621
|
+
}));
|
|
4622
|
+
BunRuntime.runMain(program, { disableErrorReporting: true });
|
|
4623
|
+
//#endregion
|
|
4624
|
+
export {};
|