sofia-post 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +37 -2
  3. package/main.js +1336 -0
  4. package/package.json +20 -4
package/main.js ADDED
@@ -0,0 +1,1336 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // libs/sophia-cli/feature-cli/src/lib/cli/compose-cli.ts
5
+ var import_node_os = require("node:os");
6
+
7
+ // libs/sophia-cli/feature-cli/src/lib/cli/cli-error.ts
8
+ var CliError = class extends Error {
9
+ constructor(code, message, details) {
10
+ super(message);
11
+ this.code = code;
12
+ this.details = details;
13
+ this.name = "CliError";
14
+ }
15
+ toEnvelope() {
16
+ const base = { code: this.code, message: this.message };
17
+ return { error: this.details === void 0 ? base : { ...base, details: this.details } };
18
+ }
19
+ };
20
+
21
+ // libs/sophia-cli/feature-cli/src/lib/cli/catalog/help-text.ts
22
+ function generalHelp(commands, version2) {
23
+ const width = Math.max(...commands.map((command) => command.name.length));
24
+ const rows = commands.map((command) => ` ${command.name.padEnd(width)} ${command.summary}`);
25
+ return [
26
+ `sofia ${version2} \u2014 command line for the Sofia API (https://www.sofia-post.com/developers/cli)`,
27
+ "",
28
+ "Usage: sofia <command> [arguments] [options]",
29
+ "",
30
+ "Commands:",
31
+ ...rows,
32
+ "",
33
+ 'Authentication: run "sofia auth:login", or set SOFIA_API_KEY.',
34
+ "Environment: SOFIA_API_KEY, SOFIA_API_URL, SOFIA_CLIENT_ID.",
35
+ "Every command prints JSON on stdout; errors are JSON on stderr with exit code 1.",
36
+ 'Run "sofia <command> --help" for the options of a command.'
37
+ ].join("\n");
38
+ }
39
+ function commandHelp(command) {
40
+ const positional = command.positional === void 0 ? "" : ` <${command.positional}>`;
41
+ const options = Object.entries(command.options).map(([name, spec]) => optionLine(name, spec));
42
+ return [
43
+ `${command.summary}`,
44
+ "",
45
+ `Usage: sofia ${command.name}${positional} [options]`,
46
+ ...options.length > 0 ? ["", "Options:", ...options] : []
47
+ ].join("\n");
48
+ }
49
+ function optionLine(name, spec) {
50
+ const short = spec.short === void 0 ? " " : `-${spec.short}, `;
51
+ const value = spec.type === "string" ? " <value>" : "";
52
+ const required = spec.required === true ? " (required)" : "";
53
+ return ` ${short}--${name}${value} ${spec.description}${required}`;
54
+ }
55
+
56
+ // libs/sophia-cli/feature-cli/src/lib/cli/parse-command-line.ts
57
+ var import_node_util = require("node:util");
58
+ var HELP_FLAGS = ["--help", "-h"];
59
+ var VERSION_FLAGS = ["--version", "-v"];
60
+ function parseCommandLine(argv, commands) {
61
+ const [name, ...rest] = argv;
62
+ const control = controlInvocation(name);
63
+ if (control !== void 0) {
64
+ return control;
65
+ }
66
+ const command = commands.find((candidate) => candidate.name === name);
67
+ if (command === void 0) {
68
+ throw new CliError(
69
+ "unknown_command",
70
+ `Unknown command "${name}". Run "sofia --help" to list the commands.`
71
+ );
72
+ }
73
+ if (rest.some((argument) => HELP_FLAGS.includes(argument))) {
74
+ return { kind: "help", command };
75
+ }
76
+ return { kind: "run", command, args: readArguments(command, rest) };
77
+ }
78
+ function controlInvocation(name) {
79
+ if (name === void 0 || name === "help" || HELP_FLAGS.includes(name)) {
80
+ return { kind: "help" };
81
+ }
82
+ return VERSION_FLAGS.includes(name) ? { kind: "version" } : void 0;
83
+ }
84
+ function readArguments(command, rest) {
85
+ const parsed = tokenize(command, rest);
86
+ assertPositionals(command, parsed);
87
+ assertRequiredOptions(command, parsed);
88
+ return parsed;
89
+ }
90
+ function tokenize(command, rest) {
91
+ try {
92
+ const { values, positionals } = (0, import_node_util.parseArgs)({
93
+ args: [...rest],
94
+ options: command.options,
95
+ allowPositionals: true,
96
+ strict: true
97
+ });
98
+ return { positionals, options: values };
99
+ } catch (error) {
100
+ throw new CliError("invalid_arguments", error instanceof Error ? error.message : String(error));
101
+ }
102
+ }
103
+ function assertPositionals(command, parsed) {
104
+ const expected = command.positional === void 0 ? 0 : 1;
105
+ if (parsed.positionals.length < expected) {
106
+ throw new CliError("missing_argument", `Missing required argument <${command.positional}>.`);
107
+ }
108
+ if (parsed.positionals.length > expected) {
109
+ throw new CliError(
110
+ "invalid_arguments",
111
+ `Unexpected argument "${parsed.positionals[expected]}".`
112
+ );
113
+ }
114
+ }
115
+ function assertRequiredOptions(command, parsed) {
116
+ const missing = Object.entries(command.options).find(
117
+ ([name, spec]) => spec.required === true && parsed.options[name] === void 0
118
+ );
119
+ if (missing !== void 0) {
120
+ throw new CliError("missing_option", `Missing required option --${missing[0]}.`);
121
+ }
122
+ }
123
+
124
+ // libs/sophia-cli/feature-cli/src/lib/cli/sofia-cli.ts
125
+ var SofiaCli = class {
126
+ constructor(dependencies) {
127
+ this.dependencies = dependencies;
128
+ }
129
+ /** Runs one invocation and returns the process exit code. */
130
+ async run(argv) {
131
+ try {
132
+ await this.execute(parseCommandLine(argv, this.dependencies.commands));
133
+ return 0;
134
+ } catch (error) {
135
+ this.dependencies.terminal.err(JSON.stringify(toCliError(error).toEnvelope(), void 0, 2));
136
+ return 1;
137
+ }
138
+ }
139
+ async execute(invocation) {
140
+ const { terminal, commands, version: version2 } = this.dependencies;
141
+ if (invocation.kind === "version") {
142
+ terminal.out(version2);
143
+ } else if (invocation.kind === "help") {
144
+ terminal.out(
145
+ invocation.command === void 0 ? generalHelp(commands, version2) : commandHelp(invocation.command)
146
+ );
147
+ } else {
148
+ const result = await invocation.command.run(invocation.args);
149
+ terminal.out(JSON.stringify(result, void 0, 2));
150
+ }
151
+ }
152
+ };
153
+ function toCliError(error) {
154
+ if (error instanceof CliError) {
155
+ return error;
156
+ }
157
+ return new CliError("unexpected_error", error instanceof Error ? error.message : String(error));
158
+ }
159
+
160
+ // libs/sophia-cli/feature-cli/src/lib/cli/terminal/process-terminal.ts
161
+ var ProcessTerminal = class {
162
+ out(text) {
163
+ process.stdout.write(`${text}
164
+ `);
165
+ }
166
+ err(text) {
167
+ process.stderr.write(`${text}
168
+ `);
169
+ }
170
+ };
171
+
172
+ // libs/sophia-cli/feature-cli/src/lib/auth/login/browser/system-browser-opener.ts
173
+ var import_node_child_process = require("node:child_process");
174
+ var COMMANDS = {
175
+ darwin: (url) => ["open", url],
176
+ win32: (url) => ["rundll32", "url.dll,FileProtocolHandler", url]
177
+ };
178
+ var SystemBrowserOpener = class {
179
+ open(url) {
180
+ const build = COMMANDS[process.platform] ?? ((target) => ["xdg-open", target]);
181
+ const [command, ...args] = build(url);
182
+ return new Promise((resolve, reject) => {
183
+ const child = (0, import_node_child_process.spawn)(command, args, { detached: true, stdio: "ignore" });
184
+ child.once("error", reject);
185
+ child.once("spawn", () => {
186
+ child.unref();
187
+ resolve();
188
+ });
189
+ });
190
+ }
191
+ };
192
+
193
+ // libs/sophia-cli/feature-cli/src/lib/auth/session/auth-session.ts
194
+ var EXPIRY_MARGIN_MS = 3e4;
195
+ var AuthSession = class {
196
+ constructor(dependencies) {
197
+ this.dependencies = dependencies;
198
+ }
199
+ async bearerToken() {
200
+ const stored = await this.storedForThisApi();
201
+ if (stored !== void 0) {
202
+ const token = await this.tokenOf(stored);
203
+ if (token !== void 0) {
204
+ return token;
205
+ }
206
+ }
207
+ const { apiKey } = this.dependencies.config;
208
+ if (apiKey !== void 0) {
209
+ return apiKey;
210
+ }
211
+ throw new CliError(
212
+ stored === void 0 ? "not_authenticated" : "session_expired",
213
+ stored === void 0 ? 'Not authenticated. Run "sofia auth:login", or set SOFIA_API_KEY.' : 'The session has expired. Run "sofia auth:login" again, or set SOFIA_API_KEY.'
214
+ );
215
+ }
216
+ async storedForThisApi() {
217
+ const stored = await this.dependencies.store.read();
218
+ return stored?.apiUrl === this.dependencies.config.apiUrl ? stored : void 0;
219
+ }
220
+ async tokenOf(stored) {
221
+ if (this.isFresh(stored)) {
222
+ return stored.accessToken;
223
+ }
224
+ return stored.refreshToken === void 0 ? void 0 : this.refreshedToken();
225
+ }
226
+ /** Under the lock the file is read again: another process may have refreshed while we waited. */
227
+ refreshedToken() {
228
+ return this.dependencies.store.withRefreshLock(async () => {
229
+ const current = await this.storedForThisApi();
230
+ if (current === void 0) {
231
+ return void 0;
232
+ }
233
+ if (this.isFresh(current)) {
234
+ return current.accessToken;
235
+ }
236
+ return current.refreshToken === void 0 ? void 0 : (await this.refreshed(current, current.refreshToken)).accessToken;
237
+ });
238
+ }
239
+ isFresh(stored) {
240
+ const expiresAt = Date.parse(stored.expiresAt);
241
+ return expiresAt - EXPIRY_MARGIN_MS > this.dependencies.clock.now().getTime();
242
+ }
243
+ async refreshed(stored, refreshToken) {
244
+ const { tokens, store, clock } = this.dependencies;
245
+ const grant = await tokens.refresh({
246
+ apiUrl: stored.apiUrl,
247
+ clientId: stored.clientId,
248
+ refreshToken
249
+ });
250
+ const credentials = credentialsFrom(stored, grant, clock.now());
251
+ await store.write(credentials);
252
+ return credentials;
253
+ }
254
+ };
255
+ function credentialsFrom(base, grant, now) {
256
+ return {
257
+ apiUrl: base.apiUrl,
258
+ clientId: base.clientId,
259
+ accessToken: grant.accessToken,
260
+ ...grant.refreshToken === void 0 ? {} : { refreshToken: grant.refreshToken },
261
+ expiresAt: new Date(now.getTime() + grant.expiresIn * 1e3).toISOString(),
262
+ scope: grant.scope
263
+ };
264
+ }
265
+
266
+ // libs/sophia-cli/feature-cli/src/lib/auth/login/authorize-url.ts
267
+ function authorizeUrl(input) {
268
+ const url = new URL(`${input.apiUrl}/api/v1/oauth/authorize`);
269
+ url.searchParams.set("response_type", "code");
270
+ url.searchParams.set("client_id", input.clientId);
271
+ url.searchParams.set("redirect_uri", input.redirectUri);
272
+ url.searchParams.set("scope", input.scope);
273
+ url.searchParams.set("state", input.state);
274
+ url.searchParams.set("code_challenge", input.codeChallenge);
275
+ url.searchParams.set("code_challenge_method", "S256");
276
+ return url.toString();
277
+ }
278
+
279
+ // libs/sophia-cli/feature-cli/src/lib/auth/login/pkce.ts
280
+ var import_node_crypto = require("node:crypto");
281
+ function challengeOf(verifier) {
282
+ return (0, import_node_crypto.createHash)("sha256").update(verifier).digest("base64url");
283
+ }
284
+ function createPkcePair() {
285
+ const verifier = (0, import_node_crypto.randomBytes)(32).toString("base64url");
286
+ return { verifier, challenge: challengeOf(verifier) };
287
+ }
288
+ function createState() {
289
+ return (0, import_node_crypto.randomBytes)(16).toString("base64url");
290
+ }
291
+
292
+ // libs/sophia-cli/feature-cli/src/lib/auth/login/login-flow.ts
293
+ var LoginFlow = class {
294
+ constructor(dependencies) {
295
+ this.dependencies = dependencies;
296
+ }
297
+ async execute(input) {
298
+ const { config, receiver, tokens, store, clock } = this.dependencies;
299
+ const { clientId } = config;
300
+ const pkce = createPkcePair();
301
+ const listener = await receiver.listen();
302
+ try {
303
+ const code = await this.authorize(listener, input, { clientId, challenge: pkce.challenge });
304
+ const grant = await tokens.exchangeCode({
305
+ apiUrl: config.apiUrl,
306
+ clientId,
307
+ code,
308
+ codeVerifier: pkce.verifier,
309
+ redirectUri: listener.redirectUri
310
+ });
311
+ const credentials = credentialsFrom({ apiUrl: config.apiUrl, clientId }, grant, clock.now());
312
+ await store.write(credentials);
313
+ return {
314
+ authenticated: true,
315
+ apiUrl: credentials.apiUrl,
316
+ scope: credentials.scope,
317
+ expiresAt: credentials.expiresAt
318
+ };
319
+ } finally {
320
+ await listener.close();
321
+ }
322
+ }
323
+ async authorize(listener, input, pkce) {
324
+ const state = createState();
325
+ const code = listener.waitForCode(state);
326
+ code.catch(() => void 0);
327
+ await this.announce(
328
+ authorizeUrl({
329
+ apiUrl: this.dependencies.config.apiUrl,
330
+ clientId: pkce.clientId,
331
+ redirectUri: listener.redirectUri,
332
+ scope: input.scope,
333
+ state,
334
+ codeChallenge: pkce.challenge
335
+ }),
336
+ input.openBrowser
337
+ );
338
+ return code;
339
+ }
340
+ async announce(url, openBrowser) {
341
+ this.dependencies.terminal.err(
342
+ JSON.stringify({ notice: "Open this address in your browser to authorize the CLI", url })
343
+ );
344
+ if (openBrowser) {
345
+ await this.dependencies.browser.open(url).catch(() => void 0);
346
+ }
347
+ }
348
+ };
349
+
350
+ // libs/sophia-cli/feature-cli/src/lib/auth/login/loopback/node-loopback-receiver.ts
351
+ var import_node_http = require("node:http");
352
+
353
+ // libs/sophia-cli/feature-cli/src/lib/auth/login/loopback/callback-page.ts
354
+ function callbackPage(title, message) {
355
+ const escape = (text) => text.replace(/[&<>"]/g, (char) => `&#${char.charCodeAt(0)};`);
356
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${escape(title)}</title><meta name="viewport" content="width=device-width, initial-scale=1"></head><body style="font-family: system-ui, sans-serif; text-align: center; margin-top: 20vh"><h1>${escape(title)}</h1><p>${escape(message)}</p></body></html>`;
357
+ }
358
+
359
+ // libs/sophia-cli/feature-cli/src/lib/auth/login/loopback/node-loopback-receiver.ts
360
+ var FIVE_MINUTES_MS = 5 * 60 * 1e3;
361
+ var NodeLoopbackReceiver = class {
362
+ constructor(timeoutMs = FIVE_MINUTES_MS) {
363
+ this.timeoutMs = timeoutMs;
364
+ }
365
+ async listen() {
366
+ let waiting;
367
+ const server = (0, import_node_http.createServer)((request, response) => {
368
+ const decision = decide(request, waiting);
369
+ answer(response, decision);
370
+ if (waiting !== void 0) {
371
+ decision.settle?.(waiting);
372
+ }
373
+ });
374
+ await new Promise((resolve, reject) => {
375
+ server.once("error", reject);
376
+ server.listen(0, "127.0.0.1", resolve);
377
+ });
378
+ const port = server.address().port;
379
+ return {
380
+ redirectUri: `http://127.0.0.1:${port}/callback`,
381
+ waitForCode: (state) => new Promise((resolve, reject) => {
382
+ waiting = withTimeout({ state, resolve, reject }, this.timeoutMs);
383
+ }),
384
+ close: () => closeServer(server)
385
+ };
386
+ }
387
+ };
388
+ function withTimeout(waiting, timeoutMs) {
389
+ const timer = setTimeout(
390
+ () => waiting.reject(new CliError("login_timeout", "Nobody authorized the CLI in 5 minutes.")),
391
+ timeoutMs
392
+ );
393
+ return {
394
+ state: waiting.state,
395
+ resolve: (code) => {
396
+ clearTimeout(timer);
397
+ waiting.resolve(code);
398
+ },
399
+ reject: (error) => {
400
+ clearTimeout(timer);
401
+ waiting.reject(error);
402
+ }
403
+ };
404
+ }
405
+ function parseCallback(rawUrl) {
406
+ const url = new URL(rawUrl ?? "/", "http://127.0.0.1");
407
+ if (url.pathname !== "/callback") {
408
+ return void 0;
409
+ }
410
+ const param = (name) => url.searchParams.get(name) ?? void 0;
411
+ return {
412
+ state: param("state"),
413
+ error: param("error"),
414
+ errorDescription: param("error_description") ?? "Authorization refused.",
415
+ code: param("code")
416
+ };
417
+ }
418
+ function decide(request, waiting) {
419
+ const callback = parseCallback(request.url);
420
+ if (callback === void 0 || waiting === void 0) {
421
+ return { status: 404, title: "Not found", message: "Nothing to see here." };
422
+ }
423
+ if (callback.state !== waiting.state) {
424
+ return {
425
+ status: 400,
426
+ title: "Not this login",
427
+ message: "This answer does not belong to the login in progress."
428
+ };
429
+ }
430
+ if (callback.error !== void 0) {
431
+ return refusal(callback.error, callback.errorDescription);
432
+ }
433
+ if (callback.code === void 0) {
434
+ return refusal("missing_code", "The authorization answer carries no code.");
435
+ }
436
+ const { code } = callback;
437
+ return {
438
+ status: 200,
439
+ title: "Sofia CLI authorized",
440
+ message: "You can close this tab and go back to your terminal.",
441
+ settle: (target) => target.resolve(code)
442
+ };
443
+ }
444
+ function refusal(code, message) {
445
+ return {
446
+ status: 400,
447
+ title: "Authorization refused",
448
+ message: "You can close this tab and go back to your terminal.",
449
+ settle: (target) => target.reject(new CliError(code, message))
450
+ };
451
+ }
452
+ function answer(response, decision) {
453
+ response.writeHead(decision.status, {
454
+ "Content-Type": "text/html; charset=utf-8",
455
+ "Cache-Control": "no-store"
456
+ });
457
+ response.end(callbackPage(decision.title, decision.message));
458
+ }
459
+ function closeServer(server) {
460
+ return new Promise((resolve) => {
461
+ server.close(() => resolve());
462
+ server.closeAllConnections();
463
+ });
464
+ }
465
+
466
+ // libs/sophia-cli/feature-cli/src/lib/auth/session/cli-config.ts
467
+ var SOFIA_CLI_CLIENT_ID = "sofia_cli";
468
+ var DEFAULT_API_URL = "https://api.sofia-post.com";
469
+ function present(value) {
470
+ return value === void 0 || value.trim() === "" ? void 0 : value.trim();
471
+ }
472
+ function readCliConfig(env) {
473
+ const apiUrl = present(env["SOFIA_API_URL"]) ?? DEFAULT_API_URL;
474
+ return {
475
+ apiUrl: apiUrl.replace(/\/+$/, ""),
476
+ apiKey: present(env["SOFIA_API_KEY"]),
477
+ clientId: present(env["SOFIA_CLIENT_ID"]) ?? SOFIA_CLI_CLIENT_ID
478
+ };
479
+ }
480
+
481
+ // libs/sophia-cli/feature-cli/src/lib/auth/session/clock.ts
482
+ var SystemClock = class {
483
+ now() {
484
+ return /* @__PURE__ */ new Date();
485
+ }
486
+ };
487
+
488
+ // libs/sophia-cli/feature-cli/src/lib/auth/session/credentials/file-credentials-store.ts
489
+ var import_promises3 = require("node:fs/promises");
490
+ var import_node_path2 = require("node:path");
491
+
492
+ // libs/sophia-cli/feature-cli/src/lib/auth/session/credentials/file-lock.ts
493
+ var import_promises = require("node:fs/promises");
494
+ var import_node_path = require("node:path");
495
+ var import_promises2 = require("node:timers/promises");
496
+ var DEFAULT_TIMINGS = { waitMs: 1e4, staleMs: 3e4, retryMs: 50 };
497
+ var FileLock = class {
498
+ constructor(path, timings = DEFAULT_TIMINGS) {
499
+ this.path = path;
500
+ this.timings = timings;
501
+ }
502
+ async run(task) {
503
+ await this.acquire();
504
+ try {
505
+ return await task();
506
+ } finally {
507
+ await (0, import_promises.rm)(this.path, { force: true });
508
+ }
509
+ }
510
+ async acquire() {
511
+ await (0, import_promises.mkdir)((0, import_node_path.dirname)(this.path), { recursive: true, mode: 448 });
512
+ const deadline = Date.now() + this.timings.waitMs;
513
+ while (!await this.tryCreate()) {
514
+ await this.removeIfStale();
515
+ if (Date.now() >= deadline) {
516
+ throw new CliError(
517
+ "credentials_locked",
518
+ `Another sofia process holds ${this.path}. Remove it if no sofia is running.`
519
+ );
520
+ }
521
+ await (0, import_promises2.setTimeout)(this.timings.retryMs);
522
+ }
523
+ }
524
+ async tryCreate() {
525
+ try {
526
+ const handle = await (0, import_promises.open)(this.path, "wx", 384);
527
+ await handle.close();
528
+ return true;
529
+ } catch (error) {
530
+ if (error.code === "EEXIST") {
531
+ return false;
532
+ }
533
+ throw error;
534
+ }
535
+ }
536
+ async removeIfStale() {
537
+ try {
538
+ const { mtimeMs } = await (0, import_promises.stat)(this.path);
539
+ if (Date.now() - mtimeMs > this.timings.staleMs) {
540
+ await (0, import_promises.rm)(this.path, { force: true });
541
+ }
542
+ } catch {
543
+ }
544
+ }
545
+ };
546
+
547
+ // libs/sophia-cli/feature-cli/src/lib/auth/session/credentials/file-credentials-store.ts
548
+ function defaultCredentialsPath(home) {
549
+ return (0, import_node_path2.join)(home, ".sofia", "credentials.json");
550
+ }
551
+ var REQUIRED_TEXT_FIELDS = ["apiUrl", "clientId", "accessToken", "expiresAt", "scope"];
552
+ function isStoredCredentials(value) {
553
+ if (typeof value !== "object" || !value) {
554
+ return false;
555
+ }
556
+ const fields = value;
557
+ return REQUIRED_TEXT_FIELDS.every((name) => typeof fields[name] === "string");
558
+ }
559
+ var FileCredentialsStore = class {
560
+ constructor(path) {
561
+ this.path = path;
562
+ this.lock = new FileLock(`${path}.lock`);
563
+ }
564
+ withRefreshLock(task) {
565
+ return this.lock.run(task);
566
+ }
567
+ async read() {
568
+ const text = await this.readText();
569
+ if (text === void 0) {
570
+ return void 0;
571
+ }
572
+ const parsed = parseJson(text);
573
+ if (!isStoredCredentials(parsed)) {
574
+ throw new CliError(
575
+ "credentials_unreadable",
576
+ `${this.path} is not valid: run "sofia auth:logout", then "sofia auth:login".`
577
+ );
578
+ }
579
+ return parsed;
580
+ }
581
+ /** Written aside then renamed: a refresh token is single-use, a torn file would lose the login. */
582
+ async write(credentials) {
583
+ const directory = (0, import_node_path2.dirname)(this.path);
584
+ await (0, import_promises3.mkdir)(directory, { recursive: true, mode: 448 });
585
+ await (0, import_promises3.chmod)(directory, 448);
586
+ const aside = `${this.path}.tmp`;
587
+ await (0, import_promises3.writeFile)(aside, JSON.stringify(credentials, void 0, 2), { mode: 384 });
588
+ await (0, import_promises3.chmod)(aside, 384);
589
+ await (0, import_promises3.rename)(aside, this.path);
590
+ }
591
+ async clear() {
592
+ await (0, import_promises3.rm)(this.path, { force: true });
593
+ }
594
+ async readText() {
595
+ try {
596
+ return await (0, import_promises3.readFile)(this.path, "utf8");
597
+ } catch (error) {
598
+ if (error.code === "ENOENT") {
599
+ return void 0;
600
+ }
601
+ throw new CliError("credentials_unreadable", `Cannot read ${this.path}.`);
602
+ }
603
+ }
604
+ };
605
+ function parseJson(text) {
606
+ try {
607
+ return JSON.parse(text);
608
+ } catch {
609
+ return void 0;
610
+ }
611
+ }
612
+
613
+ // libs/sophia-cli/feature-cli/src/lib/gateway/error-from-response.ts
614
+ function asFields(value) {
615
+ return typeof value === "object" && value ? value : {};
616
+ }
617
+ var isText = (value) => typeof value === "string";
618
+ function errorFromResponse(status, body) {
619
+ const fields = asFields(body);
620
+ const envelope = asFields(fields["error"]);
621
+ if (isText(envelope["code"]) && isText(envelope["message"])) {
622
+ return new CliError(envelope["code"], envelope["message"], envelope["details"]);
623
+ }
624
+ const code = fields["error"];
625
+ if (isText(code)) {
626
+ const description = fields["error_description"];
627
+ return new CliError(code, isText(description) ? description : code);
628
+ }
629
+ return new CliError(`http_${status}`, `Sofia API answered ${status}.`, body);
630
+ }
631
+
632
+ // libs/sophia-cli/feature-cli/src/lib/gateway/http-sofia-api-gateway.ts
633
+ var HttpSofiaApiGateway = class {
634
+ constructor(options) {
635
+ this.options = options;
636
+ }
637
+ async send(request) {
638
+ const token = await this.options.auth.bearerToken();
639
+ const response = await this.dispatch(request, token);
640
+ const body = await readBody(response);
641
+ if (!response.ok) {
642
+ throw errorFromResponse(response.status, body);
643
+ }
644
+ return { status: response.status, body };
645
+ }
646
+ async dispatch(request, token) {
647
+ try {
648
+ return await this.options.fetch(this.urlOf(request), {
649
+ method: request.method,
650
+ headers: this.headersOf(request, token),
651
+ body: bodyOf(request)
652
+ });
653
+ } catch (error) {
654
+ throw new CliError(
655
+ "network_error",
656
+ `Could not reach ${this.options.apiUrl}: ${error instanceof Error ? error.message : String(error)}`
657
+ );
658
+ }
659
+ }
660
+ urlOf(request) {
661
+ const url = new URL(`${this.options.apiUrl}${request.path}`);
662
+ Object.entries(request.query ?? {}).forEach(([name, value]) => {
663
+ if (value !== void 0) {
664
+ url.searchParams.set(name, value);
665
+ }
666
+ });
667
+ return url.toString();
668
+ }
669
+ headersOf(request, token) {
670
+ return {
671
+ Authorization: `Bearer ${token}`,
672
+ Accept: "application/json",
673
+ "User-Agent": this.options.userAgent,
674
+ ...request.body?.kind === "json" ? { "Content-Type": "application/json" } : {},
675
+ ...request.idempotencyKey === void 0 ? {} : { "Idempotency-Key": request.idempotencyKey }
676
+ };
677
+ }
678
+ };
679
+ function bodyOf(request) {
680
+ const body = request.body;
681
+ if (body === void 0) {
682
+ return void 0;
683
+ }
684
+ if (body.kind === "json") {
685
+ return JSON.stringify(body.value);
686
+ }
687
+ const form = new FormData();
688
+ Object.entries(body.fields).forEach(([name, value]) => form.append(name, value));
689
+ form.append(body.file.field, new Blob([new Uint8Array(body.file.bytes)]), body.file.name);
690
+ return form;
691
+ }
692
+ async function readBody(response) {
693
+ const text = await response.text();
694
+ if (text === "") {
695
+ return void 0;
696
+ }
697
+ try {
698
+ return JSON.parse(text);
699
+ } catch {
700
+ return text;
701
+ }
702
+ }
703
+
704
+ // libs/sophia-cli/feature-cli/src/lib/auth/session/token/http-oauth-token-gateway.ts
705
+ var TOKEN_REQUEST_TIMEOUT_MS = 15e3;
706
+ var HttpOAuthTokenGateway = class {
707
+ constructor(fetchFn) {
708
+ this.fetchFn = fetchFn;
709
+ }
710
+ exchangeCode(input) {
711
+ return this.post(input.apiUrl, {
712
+ grant_type: "authorization_code",
713
+ client_id: input.clientId,
714
+ code: input.code,
715
+ code_verifier: input.codeVerifier,
716
+ redirect_uri: input.redirectUri
717
+ });
718
+ }
719
+ refresh(input) {
720
+ return this.post(input.apiUrl, {
721
+ grant_type: "refresh_token",
722
+ client_id: input.clientId,
723
+ refresh_token: input.refreshToken
724
+ });
725
+ }
726
+ async post(apiUrl, form) {
727
+ const response = await this.request(apiUrl, form);
728
+ const body = await readBody(response);
729
+ if (!response.ok) {
730
+ throw errorFromResponse(response.status, body);
731
+ }
732
+ return toGrant(body);
733
+ }
734
+ async request(apiUrl, form) {
735
+ try {
736
+ return await this.fetchFn(`${apiUrl}/api/v1/oauth/token`, {
737
+ method: "POST",
738
+ headers: {
739
+ "Content-Type": "application/x-www-form-urlencoded",
740
+ Accept: "application/json"
741
+ },
742
+ body: new URLSearchParams(form).toString(),
743
+ signal: AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS)
744
+ });
745
+ } catch (error) {
746
+ throw new CliError(
747
+ "network_error",
748
+ `Could not reach ${apiUrl}: ${error instanceof Error ? error.message : String(error)}`
749
+ );
750
+ }
751
+ }
752
+ };
753
+ function toGrant(body) {
754
+ const fields = body ?? {};
755
+ if (typeof fields["access_token"] !== "string" || typeof fields["expires_in"] !== "number") {
756
+ throw new CliError("invalid_token_response", "The token endpoint answered without a token.");
757
+ }
758
+ return {
759
+ accessToken: fields["access_token"],
760
+ refreshToken: typeof fields["refresh_token"] === "string" ? fields["refresh_token"] : void 0,
761
+ expiresIn: fields["expires_in"],
762
+ scope: typeof fields["scope"] === "string" ? fields["scope"] : ""
763
+ };
764
+ }
765
+
766
+ // libs/sophia-cli/feature-cli/src/lib/cli/arguments.ts
767
+ function optionText(args, name) {
768
+ const value = args.options[name];
769
+ return typeof value === "string" && value !== "" ? value : void 0;
770
+ }
771
+ function optionFlag(args, name) {
772
+ return args.options[name] === true;
773
+ }
774
+ function requiredOption(args, name) {
775
+ const value = optionText(args, name);
776
+ if (value === void 0) {
777
+ throw new CliError("missing_option", `Missing required option --${name}.`);
778
+ }
779
+ return value;
780
+ }
781
+ function firstPositional(args) {
782
+ const [value] = args.positionals;
783
+ if (value === void 0) {
784
+ throw new CliError("missing_argument", "Missing required argument.");
785
+ }
786
+ return value;
787
+ }
788
+ function splitList(text) {
789
+ return (text ?? "").split(",").map((item) => item.trim()).filter((item) => item !== "");
790
+ }
791
+
792
+ // libs/sophia-cli/feature-cli/src/lib/gateway/api-command.ts
793
+ function apiCommand(api, spec) {
794
+ const { toRequest, ...definition } = spec;
795
+ return { ...definition, run: async (args) => callApi(api, toRequest(args)) };
796
+ }
797
+ async function callApi(api, request) {
798
+ const response = await api.send(request);
799
+ if (response.status === 204) {
800
+ return request.method === "DELETE" ? { deleted: true, id: request.path.split("/").pop() } : { ok: true };
801
+ }
802
+ return response.body;
803
+ }
804
+
805
+ // libs/sophia-cli/feature-cli/src/lib/gateway/api-path.ts
806
+ function apiPath(...segments) {
807
+ return `/api/v1/${segments.map(encodeURIComponent).join("/")}`;
808
+ }
809
+
810
+ // libs/sophia-cli/feature-cli/src/lib/accounts/accounts-commands.ts
811
+ function accountsCommands(api) {
812
+ return [listPlatforms(api), listEstablishments(api), listAccounts(api), connectAccount(api)];
813
+ }
814
+ var listPlatforms = (api) => apiCommand(api, {
815
+ name: "platforms:list",
816
+ summary: "List the platforms Sofia publishes to",
817
+ options: {},
818
+ toRequest: () => ({ method: "GET", path: apiPath("platforms") })
819
+ });
820
+ var listEstablishments = (api) => apiCommand(api, {
821
+ name: "establishments:list",
822
+ summary: "List your establishments",
823
+ options: {},
824
+ toRequest: () => ({ method: "GET", path: apiPath("establishments") })
825
+ });
826
+ var listAccounts = (api) => apiCommand(api, {
827
+ name: "accounts:list",
828
+ summary: "List the connected accounts of an establishment",
829
+ positional: "establishmentId",
830
+ options: {},
831
+ toRequest: (args) => ({
832
+ method: "GET",
833
+ path: apiPath("establishments", firstPositional(args), "accounts")
834
+ })
835
+ });
836
+ var connectAccount = (api) => apiCommand(api, {
837
+ name: "accounts:connect",
838
+ summary: "Open a link that connects a platform account to an establishment",
839
+ positional: "establishmentId",
840
+ options: {
841
+ platform: {
842
+ type: "string",
843
+ short: "p",
844
+ description: "instagram, facebook, tiktok, linkedin or x",
845
+ required: true
846
+ },
847
+ "redirect-url": { type: "string", description: "Where to send the person afterwards" }
848
+ },
849
+ toRequest: (args) => ({
850
+ method: "POST",
851
+ path: apiPath("establishments", firstPositional(args), "connection-links"),
852
+ body: {
853
+ kind: "json",
854
+ value: {
855
+ platform: requiredOption(args, "platform"),
856
+ redirectUrl: optionText(args, "redirect-url")
857
+ }
858
+ }
859
+ })
860
+ });
861
+
862
+ // libs/sophia-cli/feature-cli/src/lib/auth/login/scopes.ts
863
+ var RESOURCE_SCOPES = [
864
+ "establishments:read",
865
+ "accounts:read",
866
+ "accounts:write",
867
+ "media:read",
868
+ "media:write",
869
+ "posts:read",
870
+ "posts:write",
871
+ "insights:read",
872
+ "webhooks:read",
873
+ "webhooks:write"
874
+ ];
875
+ var DEFAULT_LOGIN_SCOPES = [
876
+ ...RESOURCE_SCOPES.filter((scope) => !scope.startsWith("webhooks:")),
877
+ "offline_access"
878
+ ].join(" ");
879
+
880
+ // libs/sophia-cli/feature-cli/src/lib/auth/auth-commands.ts
881
+ function authCommands(api, login, store) {
882
+ return [loginCommand(login), authStatus(api), logoutCommand(store), me(api)];
883
+ }
884
+ var loginCommand = (login) => ({
885
+ name: "auth:login",
886
+ summary: "Sign in with your browser (stores credentials in ~/.sofia/credentials.json)",
887
+ options: {
888
+ scopes: {
889
+ type: "string",
890
+ description: "Space-separated scopes (default: every resource scope but webhooks:*, + offline_access)"
891
+ },
892
+ "no-browser": { type: "boolean", description: "Print the address instead of opening it" }
893
+ },
894
+ run: (args) => login.execute({
895
+ scope: optionText(args, "scopes") ?? DEFAULT_LOGIN_SCOPES,
896
+ openBrowser: !optionFlag(args, "no-browser")
897
+ })
898
+ });
899
+ var authStatus = (api) => apiCommand(api, {
900
+ name: "auth:status",
901
+ summary: "Show who the CLI acts as",
902
+ options: {},
903
+ toRequest: () => ({ method: "GET", path: apiPath("me") })
904
+ });
905
+ var logoutCommand = (store) => ({
906
+ name: "auth:logout",
907
+ summary: "Delete the stored browser credentials",
908
+ options: {},
909
+ run: async () => {
910
+ await store.clear();
911
+ return {
912
+ loggedOut: true,
913
+ note: "Local credentials removed. Sofia API has no revocation endpoint: revoke the access in Sofia \u203A Settings \u203A Connected apps to end it server-side."
914
+ };
915
+ }
916
+ });
917
+ var me = (api) => apiCommand(api, {
918
+ name: "me",
919
+ summary: "Show the token, the application and the workspaces behind the credentials",
920
+ options: {},
921
+ toRequest: () => ({ method: "GET", path: apiPath("me") })
922
+ });
923
+
924
+ // libs/sophia-cli/feature-cli/src/lib/insights/insights-commands.ts
925
+ function insightsCommands(api) {
926
+ return [
927
+ apiCommand(api, {
928
+ name: "insights:get",
929
+ summary: "Read the statistics of an establishment",
930
+ positional: "establishmentId",
931
+ options: {
932
+ period: {
933
+ type: "string",
934
+ description: "last24Hours, last7Days or last28Days (API default: last28Days)"
935
+ }
936
+ },
937
+ toRequest: (args) => ({
938
+ method: "GET",
939
+ path: apiPath("establishments", firstPositional(args), "insights"),
940
+ query: { period: optionText(args, "period") }
941
+ })
942
+ })
943
+ ];
944
+ }
945
+
946
+ // libs/sophia-cli/feature-cli/src/lib/publishing/media/upload-media.ts
947
+ var import_node_path3 = require("node:path");
948
+
949
+ // libs/sophia-cli/feature-cli/src/lib/publishing/media/media-reference.ts
950
+ var VIDEO_EXTENSIONS = [".mp4", ".mov", ".webm", ".m4v"];
951
+ function isRemoteUrl(value) {
952
+ return /^https?:\/\//i.test(value);
953
+ }
954
+ function inferMediaType(url) {
955
+ const pathname = new URL(url).pathname.toLowerCase();
956
+ return VIDEO_EXTENSIONS.some((extension) => pathname.endsWith(extension)) ? "video" : "image";
957
+ }
958
+ function toPostMedia(value) {
959
+ return isRemoteUrl(value) ? { url: value, type: inferMediaType(value) } : { id: value };
960
+ }
961
+
962
+ // libs/sophia-cli/feature-cli/src/lib/publishing/media/upload-media.ts
963
+ async function buildUploadRequest(files, input) {
964
+ const { source, establishmentId, idempotencyKey } = input;
965
+ if (isRemoteUrl(source)) {
966
+ return {
967
+ method: "POST",
968
+ path: apiPath("media"),
969
+ body: { kind: "json", value: { establishmentId, url: source } },
970
+ idempotencyKey
971
+ };
972
+ }
973
+ return {
974
+ method: "POST",
975
+ path: apiPath("media"),
976
+ body: {
977
+ kind: "multipart",
978
+ fields: { establishmentId },
979
+ file: { field: "file", name: (0, import_node_path3.basename)(source), bytes: await files.readBytes(source) }
980
+ },
981
+ idempotencyKey
982
+ };
983
+ }
984
+
985
+ // libs/sophia-cli/feature-cli/src/lib/publishing/media/media-commands.ts
986
+ var ESTABLISHMENT = {
987
+ type: "string",
988
+ short: "e",
989
+ description: "Establishment id",
990
+ required: true
991
+ };
992
+ var IDEMPOTENCY_KEY = {
993
+ type: "string",
994
+ description: "Replays the same upload as one"
995
+ };
996
+ var PAGING = {
997
+ limit: { type: "string", description: "Page size (1-100)" },
998
+ cursor: { type: "string", description: "Cursor of the next page" }
999
+ };
1000
+ function mediaCommands(api, files) {
1001
+ return [uploadMedia(api, files), listMedia(api), getMedia(api), deleteMedia(api)];
1002
+ }
1003
+ var uploadMedia = (api, files) => ({
1004
+ name: "media:upload",
1005
+ summary: "Add a media to the library from a local file or an https address",
1006
+ positional: "file|url",
1007
+ options: { establishment: ESTABLISHMENT, "idempotency-key": IDEMPOTENCY_KEY },
1008
+ run: async (args) => callApi(
1009
+ api,
1010
+ await buildUploadRequest(files, {
1011
+ source: firstPositional(args),
1012
+ establishmentId: requiredOption(args, "establishment"),
1013
+ idempotencyKey: optionText(args, "idempotency-key")
1014
+ })
1015
+ )
1016
+ });
1017
+ var listMedia = (api) => apiCommand(api, {
1018
+ name: "media:list",
1019
+ summary: "List the media of an establishment library",
1020
+ options: { establishment: ESTABLISHMENT, ...PAGING },
1021
+ toRequest: (args) => ({
1022
+ method: "GET",
1023
+ path: apiPath("media"),
1024
+ query: {
1025
+ establishmentId: requiredOption(args, "establishment"),
1026
+ limit: optionText(args, "limit"),
1027
+ cursor: optionText(args, "cursor")
1028
+ }
1029
+ })
1030
+ });
1031
+ var getMedia = (api) => apiCommand(api, {
1032
+ name: "media:get",
1033
+ summary: "Read one media",
1034
+ positional: "id",
1035
+ options: {},
1036
+ toRequest: (args) => ({ method: "GET", path: apiPath("media", firstPositional(args)) })
1037
+ });
1038
+ var deleteMedia = (api) => apiCommand(api, {
1039
+ name: "media:delete",
1040
+ summary: "Remove a media from the library",
1041
+ positional: "id",
1042
+ options: {},
1043
+ toRequest: (args) => ({ method: "DELETE", path: apiPath("media", firstPositional(args)) })
1044
+ });
1045
+
1046
+ // libs/sophia-cli/feature-cli/src/lib/files/read-json-file.ts
1047
+ async function readJsonFile(files, path) {
1048
+ const text = await files.readText(path);
1049
+ try {
1050
+ return JSON.parse(text);
1051
+ } catch {
1052
+ throw new CliError("invalid_json_file", `The file "${path}" does not hold valid JSON.`);
1053
+ }
1054
+ }
1055
+
1056
+ // libs/sophia-cli/feature-cli/src/lib/publishing/posts/post-targets.ts
1057
+ function buildTargets(input) {
1058
+ const media = input.media.map(toPostMedia);
1059
+ return input.platforms.map((platform) => ({
1060
+ platform,
1061
+ text: input.content,
1062
+ ...media.length > 0 ? { media } : {},
1063
+ ...input.format === void 0 ? {} : { format: input.format },
1064
+ ...input.account === void 0 ? {} : { accountId: input.account }
1065
+ }));
1066
+ }
1067
+
1068
+ // libs/sophia-cli/feature-cli/src/lib/publishing/posts/create-post.ts
1069
+ var CREATE_OPTIONS = {
1070
+ establishment: { type: "string", short: "e", description: "Establishment id" },
1071
+ platforms: {
1072
+ type: "string",
1073
+ short: "p",
1074
+ description: "Comma-separated platforms (instagram,facebook,x,linkedin,tiktok)"
1075
+ },
1076
+ content: { type: "string", short: "c", description: "Text of the post" },
1077
+ media: {
1078
+ type: "string",
1079
+ short: "m",
1080
+ description: "Comma-separated media ids or https addresses"
1081
+ },
1082
+ format: { type: "string", short: "f", description: "Format (post, reel, story...)" },
1083
+ account: { type: "string", short: "a", description: "Connected account id" },
1084
+ date: {
1085
+ type: "string",
1086
+ short: "s",
1087
+ description: "ISO 8601 date to schedule at; absent: publish now"
1088
+ },
1089
+ draft: { type: "boolean", description: "Save a draft that nothing sends" },
1090
+ "dry-run": { type: "boolean", description: "Run the checks, write nothing" },
1091
+ "idempotency-key": { type: "string", description: "Replays the same request as one" },
1092
+ json: {
1093
+ type: "string",
1094
+ short: "j",
1095
+ description: "File holding the full request body (replaces the content options)"
1096
+ }
1097
+ };
1098
+ function createPostCommand(api, files) {
1099
+ return {
1100
+ name: "posts:create",
1101
+ summary: "Publish, schedule or draft a post",
1102
+ options: CREATE_OPTIONS,
1103
+ run: async (args) => callApi(api, {
1104
+ method: "POST",
1105
+ path: apiPath("posts"),
1106
+ query: { dry_run: optionFlag(args, "dry-run") ? "true" : void 0 },
1107
+ body: { kind: "json", value: await postBody(files, args) },
1108
+ idempotencyKey: optionText(args, "idempotency-key")
1109
+ })
1110
+ };
1111
+ }
1112
+ async function postBody(files, args) {
1113
+ const file = optionText(args, "json");
1114
+ if (file !== void 0) {
1115
+ return readJsonFile(files, file);
1116
+ }
1117
+ const platforms = splitList(requiredOption(args, "platforms"));
1118
+ return {
1119
+ establishmentId: requiredOption(args, "establishment"),
1120
+ targets: buildTargets({
1121
+ platforms,
1122
+ content: requiredText(args),
1123
+ media: splitList(optionText(args, "media")),
1124
+ format: optionText(args, "format"),
1125
+ account: optionText(args, "account")
1126
+ }),
1127
+ ...optionText(args, "date") === void 0 ? {} : { scheduledAt: optionText(args, "date") },
1128
+ ...optionFlag(args, "draft") ? { status: "draft" } : {}
1129
+ };
1130
+ }
1131
+ function requiredText(args) {
1132
+ const content = optionText(args, "content");
1133
+ if (content === void 0) {
1134
+ throw new CliError("missing_option", "Missing required option --content (or use --json).");
1135
+ }
1136
+ return content;
1137
+ }
1138
+
1139
+ // libs/sophia-cli/feature-cli/src/lib/publishing/posts/posts-commands.ts
1140
+ var LIST_OPTIONS = {
1141
+ establishment: { type: "string", short: "e", description: "Only this establishment" },
1142
+ status: {
1143
+ type: "string",
1144
+ description: "draft, scheduled, publishing, published, sent_to_inbox or failed"
1145
+ },
1146
+ from: { type: "string", description: "Earliest date (ISO 8601)" },
1147
+ to: { type: "string", description: "Latest date (ISO 8601)" },
1148
+ "created-by": { type: "string", description: "me or app" },
1149
+ limit: { type: "string", description: "Page size (1-100)" },
1150
+ cursor: { type: "string", description: "Cursor of the next page" }
1151
+ };
1152
+ var UPDATE_OPTIONS = {
1153
+ date: { type: "string", short: "s", description: "New ISO 8601 date" },
1154
+ status: { type: "string", description: "draft or scheduled" },
1155
+ json: { type: "string", short: "j", description: "File holding the full request body" }
1156
+ };
1157
+ function postsCommands(api, files) {
1158
+ return [
1159
+ createPostCommand(api, files),
1160
+ listPosts(api),
1161
+ getPost(api),
1162
+ updatePostCommand(api, files),
1163
+ deletePost(api)
1164
+ ];
1165
+ }
1166
+ var listPosts = (api) => apiCommand(api, {
1167
+ name: "posts:list",
1168
+ summary: "List posts",
1169
+ options: LIST_OPTIONS,
1170
+ toRequest: (args) => ({
1171
+ method: "GET",
1172
+ path: apiPath("posts"),
1173
+ query: {
1174
+ establishmentId: optionText(args, "establishment"),
1175
+ status: optionText(args, "status"),
1176
+ from: optionText(args, "from"),
1177
+ to: optionText(args, "to"),
1178
+ createdBy: optionText(args, "created-by"),
1179
+ limit: optionText(args, "limit"),
1180
+ cursor: optionText(args, "cursor")
1181
+ }
1182
+ })
1183
+ });
1184
+ var getPost = (api) => apiCommand(api, {
1185
+ name: "posts:get",
1186
+ summary: "Read one post",
1187
+ positional: "id",
1188
+ options: {},
1189
+ toRequest: (args) => ({ method: "GET", path: apiPath("posts", firstPositional(args)) })
1190
+ });
1191
+ var deletePost = (api) => apiCommand(api, {
1192
+ name: "posts:delete",
1193
+ summary: "Delete a post",
1194
+ positional: "id",
1195
+ options: {},
1196
+ toRequest: (args) => ({ method: "DELETE", path: apiPath("posts", firstPositional(args)) })
1197
+ });
1198
+ function updatePostCommand(api, files) {
1199
+ return {
1200
+ name: "posts:update",
1201
+ summary: "Reschedule a post, or turn it into a draft",
1202
+ positional: "id",
1203
+ options: UPDATE_OPTIONS,
1204
+ run: async (args) => callApi(api, {
1205
+ method: "PATCH",
1206
+ path: apiPath("posts", firstPositional(args)),
1207
+ body: { kind: "json", value: await updateBody(files, args) }
1208
+ })
1209
+ };
1210
+ }
1211
+ async function updateBody(files, args) {
1212
+ const file = optionText(args, "json");
1213
+ if (file !== void 0) {
1214
+ return readJsonFile(files, file);
1215
+ }
1216
+ const scheduledAt = optionText(args, "date");
1217
+ const status = optionText(args, "status");
1218
+ if (scheduledAt === void 0 && status === void 0) {
1219
+ throw new CliError("missing_option", "Give --date, --status or --json to change the post.");
1220
+ }
1221
+ return {
1222
+ ...scheduledAt === void 0 ? {} : { scheduledAt },
1223
+ ...status === void 0 ? {} : { status }
1224
+ };
1225
+ }
1226
+
1227
+ // libs/sophia-cli/feature-cli/src/lib/webhooks/webhooks-commands.ts
1228
+ var DEFAULT_EVENTS = "post.published,post.failed";
1229
+ function webhooksCommands(api) {
1230
+ return [createWebhook(api), listWebhooks(api), deleteWebhook(api), testWebhook(api)];
1231
+ }
1232
+ var createWebhook = (api) => apiCommand(api, {
1233
+ name: "webhooks:create",
1234
+ summary: "Register a webhook (its signing secret is shown once)",
1235
+ options: {
1236
+ url: { type: "string", short: "u", description: "https address", required: true },
1237
+ events: { type: "string", description: `Comma-separated events (default ${DEFAULT_EVENTS})` }
1238
+ },
1239
+ toRequest: (args) => ({
1240
+ method: "POST",
1241
+ path: apiPath("webhooks"),
1242
+ body: {
1243
+ kind: "json",
1244
+ value: {
1245
+ url: requiredOption(args, "url"),
1246
+ events: splitList(optionText(args, "events") ?? DEFAULT_EVENTS)
1247
+ }
1248
+ }
1249
+ })
1250
+ });
1251
+ var listWebhooks = (api) => apiCommand(api, {
1252
+ name: "webhooks:list",
1253
+ summary: "List your webhooks",
1254
+ options: {},
1255
+ toRequest: () => ({ method: "GET", path: apiPath("webhooks") })
1256
+ });
1257
+ var deleteWebhook = (api) => apiCommand(api, {
1258
+ name: "webhooks:delete",
1259
+ summary: "Delete a webhook",
1260
+ positional: "id",
1261
+ options: {},
1262
+ toRequest: (args) => ({ method: "DELETE", path: apiPath("webhooks", firstPositional(args)) })
1263
+ });
1264
+ var testWebhook = (api) => apiCommand(api, {
1265
+ name: "webhooks:test",
1266
+ summary: "Send a webhook.test event to a webhook",
1267
+ positional: "id",
1268
+ options: {},
1269
+ toRequest: (args) => ({
1270
+ method: "POST",
1271
+ path: apiPath("webhooks", firstPositional(args), "test")
1272
+ })
1273
+ });
1274
+
1275
+ // libs/sophia-cli/feature-cli/src/lib/cli/catalog/command-catalog.ts
1276
+ function buildCommandCatalog(dependencies) {
1277
+ const { api, files, login, store } = dependencies;
1278
+ return [
1279
+ ...authCommands(api, login, store),
1280
+ ...accountsCommands(api),
1281
+ ...postsCommands(api, files),
1282
+ ...mediaCommands(api, files),
1283
+ ...insightsCommands(api),
1284
+ ...webhooksCommands(api)
1285
+ ];
1286
+ }
1287
+
1288
+ // libs/sophia-cli/feature-cli/src/lib/files/node-local-files.ts
1289
+ var import_promises4 = require("node:fs/promises");
1290
+ var NodeLocalFiles = class {
1291
+ async readText(path) {
1292
+ return (await this.readBytes(path)).toString();
1293
+ }
1294
+ async readBytes(path) {
1295
+ try {
1296
+ return await (0, import_promises4.readFile)(path);
1297
+ } catch {
1298
+ throw new CliError("file_not_readable", `Cannot read the file "${path}".`);
1299
+ }
1300
+ }
1301
+ };
1302
+
1303
+ // libs/sophia-cli/feature-cli/src/lib/cli/compose-cli.ts
1304
+ function composeCli(options) {
1305
+ const config = readCliConfig(options.env);
1306
+ const terminal = new ProcessTerminal();
1307
+ const clock = new SystemClock();
1308
+ const store = new FileCredentialsStore(defaultCredentialsPath((0, import_node_os.homedir)()));
1309
+ const tokens = new HttpOAuthTokenGateway(fetch);
1310
+ const auth = new AuthSession({ config, store, tokens, clock });
1311
+ const api = new HttpSofiaApiGateway({
1312
+ apiUrl: config.apiUrl,
1313
+ auth,
1314
+ userAgent: `sofia-post/${options.version}`,
1315
+ fetch
1316
+ });
1317
+ const login = new LoginFlow({
1318
+ config,
1319
+ tokens,
1320
+ store,
1321
+ clock,
1322
+ terminal,
1323
+ receiver: new NodeLoopbackReceiver(),
1324
+ browser: new SystemBrowserOpener()
1325
+ });
1326
+ const commands = buildCommandCatalog({ api, files: new NodeLocalFiles(), login, store });
1327
+ return new SofiaCli({ commands, terminal, version: options.version });
1328
+ }
1329
+
1330
+ // apps/sophia-cli/package.json
1331
+ var version = "0.1.0";
1332
+
1333
+ // apps/sophia-cli/src/main.ts
1334
+ void composeCli({ version, env: process.env }).run(process.argv.slice(2)).then((exitCode) => {
1335
+ process.exitCode = exitCode;
1336
+ });