@staqon/cli 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.
- package/LICENSE +6 -0
- package/README.md +231 -2
- package/bin/staqon.js +2 -0
- package/dist/cli.js +1134 -0
- package/package.json +51 -6
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1134 @@
|
|
|
1
|
+
// src/cli.ts
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
|
|
4
|
+
// src/commands/auth.ts
|
|
5
|
+
import { exec } from "child_process";
|
|
6
|
+
import pc3 from "picocolors";
|
|
7
|
+
|
|
8
|
+
// src/context.ts
|
|
9
|
+
var ctx = { json: false, verbose: false, interactive: false };
|
|
10
|
+
|
|
11
|
+
// src/errors.ts
|
|
12
|
+
var Exit = {
|
|
13
|
+
OK: 0,
|
|
14
|
+
GENERAL: 1,
|
|
15
|
+
USAGE: 2,
|
|
16
|
+
AUTH: 3,
|
|
17
|
+
RATE_LIMIT: 4,
|
|
18
|
+
TEAM_OR_KEY: 5,
|
|
19
|
+
CONSENT: 6,
|
|
20
|
+
INTERRUPTED: 130
|
|
21
|
+
};
|
|
22
|
+
var CliError = class extends Error {
|
|
23
|
+
constructor(message, exitCode = Exit.GENERAL, details = {}) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.exitCode = exitCode;
|
|
26
|
+
this.details = details;
|
|
27
|
+
this.name = "CliError";
|
|
28
|
+
}
|
|
29
|
+
exitCode;
|
|
30
|
+
details;
|
|
31
|
+
};
|
|
32
|
+
var ApiError = class extends CliError {
|
|
33
|
+
constructor(message, exitCode, status, body, traceId, details = {}) {
|
|
34
|
+
super(message, exitCode, details);
|
|
35
|
+
this.status = status;
|
|
36
|
+
this.body = body;
|
|
37
|
+
this.traceId = traceId;
|
|
38
|
+
this.name = "ApiError";
|
|
39
|
+
}
|
|
40
|
+
status;
|
|
41
|
+
body;
|
|
42
|
+
traceId;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// src/output.ts
|
|
46
|
+
import pc from "picocolors";
|
|
47
|
+
var TOKEN_RE = /stq_cli_[0-9a-fA-F]{6,}/g;
|
|
48
|
+
function redact(s) {
|
|
49
|
+
return s.replace(TOKEN_RE, "stq_cli_***");
|
|
50
|
+
}
|
|
51
|
+
function out(line = "") {
|
|
52
|
+
process.stdout.write(redact(line) + "\n");
|
|
53
|
+
}
|
|
54
|
+
function err(line = "") {
|
|
55
|
+
process.stderr.write(redact(line) + "\n");
|
|
56
|
+
}
|
|
57
|
+
function debug(line) {
|
|
58
|
+
if (ctx.verbose) err(pc.dim(line));
|
|
59
|
+
}
|
|
60
|
+
function printData(body) {
|
|
61
|
+
if (typeof body === "string") return out(body);
|
|
62
|
+
out(JSON.stringify(body, null, ctx.json ? void 0 : 2));
|
|
63
|
+
}
|
|
64
|
+
function reportError(e) {
|
|
65
|
+
if (e instanceof CliError) {
|
|
66
|
+
if (ctx.json) {
|
|
67
|
+
err(
|
|
68
|
+
JSON.stringify({
|
|
69
|
+
error: e.message,
|
|
70
|
+
exit_code: e.exitCode,
|
|
71
|
+
...e instanceof ApiError ? { status: e.status, trace_id: e.traceId, body: e.body } : {},
|
|
72
|
+
...Object.fromEntries(Object.entries(e.details).filter(([k]) => k !== "showBody"))
|
|
73
|
+
})
|
|
74
|
+
);
|
|
75
|
+
} else {
|
|
76
|
+
err(pc.red("Error: ") + e.message);
|
|
77
|
+
if (e instanceof ApiError) {
|
|
78
|
+
if (e.traceId) err(pc.dim(`trace id: ${e.traceId}`));
|
|
79
|
+
if (e.details["showBody"] && e.body !== void 0 && e.body !== "")
|
|
80
|
+
err(typeof e.body === "string" ? e.body : JSON.stringify(e.body, null, 2));
|
|
81
|
+
}
|
|
82
|
+
const hint = e.details["hint"];
|
|
83
|
+
if (typeof hint === "string") err(pc.dim(hint));
|
|
84
|
+
}
|
|
85
|
+
return e.exitCode;
|
|
86
|
+
}
|
|
87
|
+
err(pc.red("Unexpected error: ") + (e instanceof Error ? e.stack ?? e.message : String(e)));
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/store/config.ts
|
|
92
|
+
import fs from "fs";
|
|
93
|
+
import path2 from "path";
|
|
94
|
+
|
|
95
|
+
// src/store/paths.ts
|
|
96
|
+
import os from "os";
|
|
97
|
+
import path from "path";
|
|
98
|
+
function configDir() {
|
|
99
|
+
if (process.platform === "win32")
|
|
100
|
+
return path.join(process.env["APPDATA"] ?? path.join(os.homedir(), "AppData", "Roaming"), "staqon");
|
|
101
|
+
return path.join(process.env["XDG_CONFIG_HOME"] ?? path.join(os.homedir(), ".config"), "staqon");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/store/config.ts
|
|
105
|
+
var DEFAULT_BASE_URL = "https://staqon.one";
|
|
106
|
+
var file = () => path2.join(configDir(), "config.json");
|
|
107
|
+
function loadConfig() {
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(fs.readFileSync(file(), "utf8"));
|
|
110
|
+
} catch {
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function saveConfig(c) {
|
|
115
|
+
fs.mkdirSync(configDir(), { recursive: true });
|
|
116
|
+
fs.writeFileSync(file(), JSON.stringify(c, null, 2) + "\n");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/store/token.ts
|
|
120
|
+
import fs2 from "fs";
|
|
121
|
+
import path3 from "path";
|
|
122
|
+
var SERVICE = "staqon-cli";
|
|
123
|
+
var ACCOUNT = "default";
|
|
124
|
+
var credFile = () => path3.join(configDir(), "credentials.json");
|
|
125
|
+
async function entry() {
|
|
126
|
+
try {
|
|
127
|
+
const m = await import("@napi-rs/keyring");
|
|
128
|
+
return new m.Entry(SERVICE, ACCOUNT);
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async function saveSession(s) {
|
|
134
|
+
const json = JSON.stringify(s);
|
|
135
|
+
try {
|
|
136
|
+
const e = await entry();
|
|
137
|
+
if (e) {
|
|
138
|
+
e.setPassword(json);
|
|
139
|
+
if (e.getPassword() === json) {
|
|
140
|
+
fs2.rmSync(credFile(), { force: true });
|
|
141
|
+
return "keychain";
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
}
|
|
146
|
+
fs2.mkdirSync(configDir(), { recursive: true, mode: 448 });
|
|
147
|
+
fs2.writeFileSync(credFile(), json, { mode: 384 });
|
|
148
|
+
if (process.platform !== "win32") fs2.chmodSync(credFile(), 384);
|
|
149
|
+
return "file";
|
|
150
|
+
}
|
|
151
|
+
async function loadSession() {
|
|
152
|
+
const envTok = process.env["STAQON_TOKEN"];
|
|
153
|
+
if (envTok)
|
|
154
|
+
return {
|
|
155
|
+
backend: "env",
|
|
156
|
+
session: {
|
|
157
|
+
token: envTok,
|
|
158
|
+
baseUrl: process.env["STAQON_BASE_URL"] ?? DEFAULT_BASE_URL,
|
|
159
|
+
savedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
try {
|
|
163
|
+
const e = await entry();
|
|
164
|
+
const raw = e?.getPassword();
|
|
165
|
+
if (raw) return { backend: "keychain", session: JSON.parse(raw) };
|
|
166
|
+
} catch {
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
return { backend: "file", session: JSON.parse(fs2.readFileSync(credFile(), "utf8")) };
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async function deleteSession() {
|
|
175
|
+
try {
|
|
176
|
+
const e = await entry();
|
|
177
|
+
if (e) (e.deletePassword ?? e.deleteCredential)?.call(e);
|
|
178
|
+
} catch {
|
|
179
|
+
}
|
|
180
|
+
fs2.rmSync(credFile(), { force: true });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// src/team.ts
|
|
184
|
+
async function chooseTeam(teams, traceId) {
|
|
185
|
+
const list = teams.map((t) => ` ${t.team_slug ?? t.team_id} (${t.team_name ?? ""} id=${t.team_id})`).join("\n");
|
|
186
|
+
if (!ctx.interactive || teams.length === 0)
|
|
187
|
+
throw new ApiError("This call is eligible for several teams; choose one.", Exit.TEAM_OR_KEY, 409, { teams }, traceId, {
|
|
188
|
+
teams,
|
|
189
|
+
hint: `Teams:
|
|
190
|
+
${list}
|
|
191
|
+
Use --team <team>, or save a default: staqon use <team>`
|
|
192
|
+
});
|
|
193
|
+
const { select, confirm } = await import("@inquirer/prompts");
|
|
194
|
+
const pick = await select(
|
|
195
|
+
{
|
|
196
|
+
message: "Which team should act?",
|
|
197
|
+
choices: teams.map((t) => ({
|
|
198
|
+
name: `${t.team_name ?? t.team_slug ?? t.team_id} (${t.team_slug ?? t.team_id})`,
|
|
199
|
+
value: String(t.team_slug ?? t.team_id)
|
|
200
|
+
}))
|
|
201
|
+
},
|
|
202
|
+
{ output: process.stderr }
|
|
203
|
+
);
|
|
204
|
+
if (await confirm({ message: `Save "${pick}" as your default team?`, default: false }, { output: process.stderr })) {
|
|
205
|
+
saveConfig({ ...loadConfig(), defaultTeam: pick });
|
|
206
|
+
err(`Default team set to ${pick}.`);
|
|
207
|
+
}
|
|
208
|
+
return pick;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// src/version.ts
|
|
212
|
+
import { createRequire } from "module";
|
|
213
|
+
var require2 = createRequire(import.meta.url);
|
|
214
|
+
var VERSION = require2("../package.json").version;
|
|
215
|
+
|
|
216
|
+
// src/api/client.ts
|
|
217
|
+
function baseUrlFor(session) {
|
|
218
|
+
const url = ctx.baseUrl ?? process.env["STAQON_BASE_URL"] ?? session?.baseUrl ?? loadConfig().baseUrl ?? DEFAULT_BASE_URL;
|
|
219
|
+
return url.replace(/\/+$/, "");
|
|
220
|
+
}
|
|
221
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
222
|
+
var LIST_TIMEOUT_MS = 12e4;
|
|
223
|
+
var MAX_LIST_LIMIT = 100;
|
|
224
|
+
function requestTimeoutMs(perCall) {
|
|
225
|
+
const env = Number(process.env["STAQON_TIMEOUT_MS"]);
|
|
226
|
+
if (Number.isInteger(env) && env > 0) return env;
|
|
227
|
+
return perCall ?? DEFAULT_TIMEOUT_MS;
|
|
228
|
+
}
|
|
229
|
+
async function rawRequest(url, init = {}, timeoutMs) {
|
|
230
|
+
const headers = new Headers(init.headers);
|
|
231
|
+
headers.set("User-Agent", `staqon-cli/${VERSION}`);
|
|
232
|
+
headers.set("Accept", "application/json");
|
|
233
|
+
debug(`${init.method ?? "GET"} ${url}`);
|
|
234
|
+
let res;
|
|
235
|
+
try {
|
|
236
|
+
res = await fetch(url, { ...init, headers, redirect: "manual", signal: AbortSignal.timeout(requestTimeoutMs(timeoutMs)) });
|
|
237
|
+
} catch (e) {
|
|
238
|
+
const msg = e instanceof Error ? e.cause instanceof Error ? e.cause.message : e.message : String(e);
|
|
239
|
+
throw new CliError(`Network error: ${msg}`, Exit.GENERAL, { hint: `Could not reach ${new URL(url).origin}.` });
|
|
240
|
+
}
|
|
241
|
+
const text = await res.text();
|
|
242
|
+
let body = text === "" ? void 0 : text;
|
|
243
|
+
if (text !== "") {
|
|
244
|
+
try {
|
|
245
|
+
body = JSON.parse(text);
|
|
246
|
+
} catch {
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const traceId = res.headers.get("x-staqon-trace-id") ?? void 0;
|
|
250
|
+
debug(`<- ${res.status} trace=${traceId ?? "-"}`);
|
|
251
|
+
return {
|
|
252
|
+
status: res.status,
|
|
253
|
+
headers: res.headers,
|
|
254
|
+
body,
|
|
255
|
+
traceId,
|
|
256
|
+
team: res.headers.get("x-staqon-cli-team") ?? void 0
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
async function requireSession() {
|
|
260
|
+
const s = await loadSession();
|
|
261
|
+
if (!s) throw new CliError("Not logged in.", Exit.AUTH, { hint: "Run: staqon login" });
|
|
262
|
+
return s.session;
|
|
263
|
+
}
|
|
264
|
+
function asObj(b) {
|
|
265
|
+
return b && typeof b === "object" ? b : {};
|
|
266
|
+
}
|
|
267
|
+
function msgOf(b, fallback) {
|
|
268
|
+
const o = asObj(b);
|
|
269
|
+
for (const k of ["error", "message"]) if (typeof o[k] === "string") return o[k];
|
|
270
|
+
return fallback;
|
|
271
|
+
}
|
|
272
|
+
function teamsOf(b) {
|
|
273
|
+
const t = asObj(b)["teams"];
|
|
274
|
+
return Array.isArray(t) ? t : [];
|
|
275
|
+
}
|
|
276
|
+
function authError(r) {
|
|
277
|
+
if (r.status === 401)
|
|
278
|
+
return new ApiError("Session missing, expired or revoked.", Exit.AUTH, 401, r.body, r.traceId, {
|
|
279
|
+
hint: "Run: staqon login"
|
|
280
|
+
});
|
|
281
|
+
return new ApiError(msgOf(r.body, "Access denied."), Exit.AUTH, r.status, r.body, r.traceId, {
|
|
282
|
+
hint: "CLI access may be switched off, the workspace owner changed, or the workspace is not active."
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
var chosenTeam;
|
|
286
|
+
var CONSENT_RE = /not approved|no approved|approved|consent/i;
|
|
287
|
+
async function proxy(opts) {
|
|
288
|
+
const session = await requireSession();
|
|
289
|
+
const base = baseUrlFor(session);
|
|
290
|
+
if (new URL(base).origin !== new URL(session.baseUrl).origin)
|
|
291
|
+
throw new CliError(`This token was issued by ${session.baseUrl}, not ${base}.`, Exit.USAGE, {
|
|
292
|
+
hint: "Run staqon login against that host, or drop --base-url."
|
|
293
|
+
});
|
|
294
|
+
const qs = new URLSearchParams({ _ep: opts.ep });
|
|
295
|
+
for (const [k, v] of Object.entries(opts.query ?? {})) qs.append(k, v);
|
|
296
|
+
const url = `${base}/api/v1/cli-proxy.php?${qs}`;
|
|
297
|
+
let team = opts.noTeam ? void 0 : opts.team ?? ctx.team ?? loadConfig().defaultTeam ?? chosenTeam;
|
|
298
|
+
for (let attempt = 0; ; attempt++) {
|
|
299
|
+
const headers = new Headers({ Authorization: `Bearer ${session.token}` });
|
|
300
|
+
if (team) headers.set("X-StaqOn-Team", team);
|
|
301
|
+
let body;
|
|
302
|
+
if (opts.form) body = opts.form;
|
|
303
|
+
else if (opts.json !== void 0) {
|
|
304
|
+
body = JSON.stringify(opts.json);
|
|
305
|
+
headers.set("Content-Type", "application/json");
|
|
306
|
+
}
|
|
307
|
+
const method = opts.method ?? (body ? "POST" : "GET");
|
|
308
|
+
const r = await rawRequest(url, { method, headers, body }, opts.timeoutMs);
|
|
309
|
+
if (r.status >= 200 && r.status < 300) return r;
|
|
310
|
+
if (r.status === 403 && CONSENT_RE.test(msgOf(r.body, ""))) {
|
|
311
|
+
const server = msgOf(r.body, "");
|
|
312
|
+
const friendly = /api messages/i.test(server) ? "This member hasn't approved API messages from this workspace." : "This member hasn't approved linking with this workspace.";
|
|
313
|
+
throw new ApiError(friendly, Exit.CONSENT, 403, r.body, r.traceId, {
|
|
314
|
+
hint: `Server said: ${server}
|
|
315
|
+
The member has to approve the link in their app; the CLI can't do it for them.`
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
if (r.status === 401 || r.status === 403) throw authError(r);
|
|
319
|
+
if (r.status === 409) {
|
|
320
|
+
const code = asObj(r.body)["error"];
|
|
321
|
+
const teams = teamsOf(r.body);
|
|
322
|
+
if (code === "team_required") {
|
|
323
|
+
if (attempt > 0)
|
|
324
|
+
throw new ApiError("Team still required after retry.", Exit.TEAM_OR_KEY, 409, r.body, r.traceId);
|
|
325
|
+
team = chosenTeam = await chooseTeam(teams, r.traceId);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (code === "team_not_eligible")
|
|
329
|
+
throw new ApiError(`Team "${team ?? "?"}" has no key for this call.`, Exit.TEAM_OR_KEY, 409, r.body, r.traceId, {
|
|
330
|
+
teams,
|
|
331
|
+
hint: "Eligible teams: " + (teams.map((t) => t.team_slug ?? t.team_id).join(", ") || "(none listed)") + "\nUse --team <team>, or staqon use <team> (staqon use --clear drops a stale default)."
|
|
332
|
+
});
|
|
333
|
+
if (code === "no_key")
|
|
334
|
+
throw new ApiError(
|
|
335
|
+
msgOf(r.body, "The workspace has no active key of that kind."),
|
|
336
|
+
Exit.TEAM_OR_KEY,
|
|
337
|
+
409,
|
|
338
|
+
r.body,
|
|
339
|
+
r.traceId,
|
|
340
|
+
{ hint: "The workspace owner must create one in settings \u2192 API. The CLI cannot." }
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
if (r.status === 429) {
|
|
344
|
+
const limit = asObj(r.body)["limit"];
|
|
345
|
+
const ra = r.headers.get("retry-after");
|
|
346
|
+
const wait = ra ? ` Retry after ${ra}s.` : "";
|
|
347
|
+
let m;
|
|
348
|
+
switch (limit) {
|
|
349
|
+
case "cli_token":
|
|
350
|
+
m = "CLI session rate limit reached (30/min).";
|
|
351
|
+
break;
|
|
352
|
+
case "cli_token_day":
|
|
353
|
+
m = "CLI session daily limit reached (3,000/day).";
|
|
354
|
+
break;
|
|
355
|
+
case "key_ceiling":
|
|
356
|
+
case "key_ceiling_day":
|
|
357
|
+
m = "Your production API key is at its own limit right now; backing off so production traffic wins.";
|
|
358
|
+
break;
|
|
359
|
+
default:
|
|
360
|
+
m = msgOf(r.body, "Rate limited by the endpoint.");
|
|
361
|
+
}
|
|
362
|
+
throw new ApiError(m + wait, Exit.RATE_LIMIT, 429, r.body, r.traceId, {
|
|
363
|
+
limit,
|
|
364
|
+
retry_after: ra ? Number(ra) : void 0
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
throw new ApiError(msgOf(r.body, `HTTP ${r.status}`), Exit.GENERAL, r.status, r.body, r.traceId, {
|
|
368
|
+
showBody: true
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// src/auth/pair.ts
|
|
374
|
+
import os2 from "os";
|
|
375
|
+
import pc2 from "picocolors";
|
|
376
|
+
var obj = (b) => b && typeof b === "object" ? b : {};
|
|
377
|
+
var sleep = (ms, signal) => new Promise((res) => {
|
|
378
|
+
const t = setTimeout(res, ms);
|
|
379
|
+
signal.addEventListener("abort", () => (clearTimeout(t), res()), { once: true });
|
|
380
|
+
});
|
|
381
|
+
async function codeRequest(base) {
|
|
382
|
+
const r = await rawRequest(`${base}/api/cli-pair.php?action=code_request`, {
|
|
383
|
+
method: "POST",
|
|
384
|
+
headers: { "Content-Type": "application/json" },
|
|
385
|
+
body: JSON.stringify({
|
|
386
|
+
device_label: os2.hostname(),
|
|
387
|
+
os: `${process.platform}-${os2.arch()}`,
|
|
388
|
+
cli_version: VERSION
|
|
389
|
+
})
|
|
390
|
+
});
|
|
391
|
+
const b = obj(r.body);
|
|
392
|
+
if (r.status === 429)
|
|
393
|
+
throw new ApiError(
|
|
394
|
+
"Too many login attempts (limit: 5 per 10 minutes from this IP).",
|
|
395
|
+
Exit.RATE_LIMIT,
|
|
396
|
+
429,
|
|
397
|
+
r.body,
|
|
398
|
+
r.traceId,
|
|
399
|
+
{ hint: "Wait a few minutes and try again." }
|
|
400
|
+
);
|
|
401
|
+
if (r.status < 200 || r.status >= 300 || !b["ok"] || typeof b["code"] !== "string")
|
|
402
|
+
throw new ApiError(
|
|
403
|
+
String(b["error"] ?? `Could not start login (HTTP ${r.status}).`),
|
|
404
|
+
Exit.GENERAL,
|
|
405
|
+
r.status,
|
|
406
|
+
r.body,
|
|
407
|
+
r.traceId
|
|
408
|
+
);
|
|
409
|
+
return b;
|
|
410
|
+
}
|
|
411
|
+
var TERMINAL = {
|
|
412
|
+
denied: "Login was denied by the workspace owner.",
|
|
413
|
+
expired: "The login code expired.",
|
|
414
|
+
consumed: "This login code was already used."
|
|
415
|
+
};
|
|
416
|
+
async function pollUntilApproved(base, p, signal) {
|
|
417
|
+
const deadline = Date.now() + p.expires_in * 1e3;
|
|
418
|
+
const interval = Math.max(1, p.interval || 3) * 1e3;
|
|
419
|
+
const tty = Boolean(process.stderr.isTTY) && !ctx.json;
|
|
420
|
+
const nl = () => tty && process.stderr.write("\n");
|
|
421
|
+
let failures = 0;
|
|
422
|
+
let lastLine = 0;
|
|
423
|
+
while (!signal.aborted) {
|
|
424
|
+
const left = Math.max(0, Math.round((deadline - Date.now()) / 1e3));
|
|
425
|
+
if (left === 0) {
|
|
426
|
+
nl();
|
|
427
|
+
throw new CliError("The login code expired before it was approved.", Exit.GENERAL, { hint: "Run: staqon login" });
|
|
428
|
+
}
|
|
429
|
+
const mm = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, "0")}`;
|
|
430
|
+
if (tty) process.stderr.write(`\r${pc2.dim(`Waiting for approval\u2026 (expires in ${mm})`)} `);
|
|
431
|
+
else if (Date.now() - lastLine >= 3e4) {
|
|
432
|
+
lastLine = Date.now();
|
|
433
|
+
err(`Waiting for approval\u2026 (expires in ${mm})`);
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
const r = await rawRequest(`${base}/api/cli-pair.php?action=poll`, {
|
|
437
|
+
method: "POST",
|
|
438
|
+
headers: { "Content-Type": "application/json" },
|
|
439
|
+
body: JSON.stringify({ pair_id: p.pair_id, poll_secret: p.poll_secret })
|
|
440
|
+
});
|
|
441
|
+
const b = obj(r.body);
|
|
442
|
+
if (r.status === 403) {
|
|
443
|
+
nl();
|
|
444
|
+
throw new ApiError(String(b["error"] ?? "Login refused by the server."), Exit.AUTH, 403, r.body, r.traceId, {
|
|
445
|
+
hint: "CLI access may have been switched off, or the workspace owner changed."
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
if (r.status >= 500 || r.status === 429) throw new Error(`HTTP ${r.status}`);
|
|
449
|
+
const status = b["status"];
|
|
450
|
+
if (status === "approved" && typeof b["token"] === "string") {
|
|
451
|
+
if (tty) process.stderr.write("\r" + " ".repeat(60) + "\r");
|
|
452
|
+
return b;
|
|
453
|
+
}
|
|
454
|
+
if (typeof status === "string" && TERMINAL[status]) {
|
|
455
|
+
nl();
|
|
456
|
+
throw new CliError(TERMINAL[status], Exit.AUTH, { hint: "Run: staqon login" });
|
|
457
|
+
}
|
|
458
|
+
if (status !== "pending") throw new Error(`unexpected poll response (HTTP ${r.status})`);
|
|
459
|
+
failures = 0;
|
|
460
|
+
} catch (e) {
|
|
461
|
+
if (e instanceof CliError) throw e;
|
|
462
|
+
if (++failures >= 5) {
|
|
463
|
+
nl();
|
|
464
|
+
throw new CliError(`Lost contact with the server while waiting: ${e instanceof Error ? e.message : e}`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
await sleep(interval * (failures ? 1 + failures : 1), signal);
|
|
468
|
+
}
|
|
469
|
+
nl();
|
|
470
|
+
throw new CliError("Login cancelled.", Exit.INTERRUPTED);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// src/commands/auth.ts
|
|
474
|
+
function warnIfFile(b) {
|
|
475
|
+
if (b === "file")
|
|
476
|
+
err(
|
|
477
|
+
pc3.yellow("Note: OS keychain unavailable; token stored in a user-only file (") + pc3.yellow("credentials.json in the staqon config directory).")
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
function openBrowser(url) {
|
|
481
|
+
const cmd = process.platform === "win32" ? `start "" "${url}"` : process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
|
|
482
|
+
exec(cmd, () => void 0);
|
|
483
|
+
}
|
|
484
|
+
async function confirmYes(q) {
|
|
485
|
+
if (!ctx.interactive) return false;
|
|
486
|
+
const { confirm } = await import("@inquirer/prompts");
|
|
487
|
+
return confirm({ message: q, default: false }, { output: process.stderr });
|
|
488
|
+
}
|
|
489
|
+
function registerAuth(program2) {
|
|
490
|
+
program2.command("login").description("Sign in via device code (the workspace owner approves in the browser)").option("--open", "open the verification URL in your browser").option("--force", "overwrite an existing login without asking").action(async (o) => {
|
|
491
|
+
const existing = await loadSession();
|
|
492
|
+
if (existing && existing.backend !== "env" && !o.force) {
|
|
493
|
+
const ws = existing.session.workspace?.name ?? "a workspace";
|
|
494
|
+
if (!await confirmYes(`Already logged in to ${ws}. Replace it?`))
|
|
495
|
+
throw new CliError(`Already logged in to ${ws}.`, Exit.USAGE, { hint: "Use --force to replace it." });
|
|
496
|
+
}
|
|
497
|
+
const base = baseUrlFor();
|
|
498
|
+
const pair = await codeRequest(base);
|
|
499
|
+
err("");
|
|
500
|
+
err(` Open: ${pc3.cyan(pair.verification_url)}`);
|
|
501
|
+
err(` Code: ${pc3.bold(pc3.green(pair.code))}`);
|
|
502
|
+
err(pc3.dim(" Only the workspace owner can approve (MPIN may be asked if your web session is old)."));
|
|
503
|
+
err("");
|
|
504
|
+
if (o.open) openBrowser(pair.verification_url);
|
|
505
|
+
const ac = new AbortController();
|
|
506
|
+
const onSigint = () => ac.abort();
|
|
507
|
+
process.once("SIGINT", onSigint);
|
|
508
|
+
let approved;
|
|
509
|
+
try {
|
|
510
|
+
approved = await pollUntilApproved(base, pair, ac.signal);
|
|
511
|
+
} finally {
|
|
512
|
+
process.off("SIGINT", onSigint);
|
|
513
|
+
}
|
|
514
|
+
const backend = await saveSession({
|
|
515
|
+
token: approved.token,
|
|
516
|
+
baseUrl: base,
|
|
517
|
+
workspace: approved.workspace,
|
|
518
|
+
expiresAt: approved.expires_at,
|
|
519
|
+
savedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
520
|
+
});
|
|
521
|
+
const check = await loadSession();
|
|
522
|
+
if (check?.session.token !== approved.token)
|
|
523
|
+
throw new CliError("Approved, but the token could not be saved; this login is lost.", Exit.GENERAL, {
|
|
524
|
+
hint: "Run staqon login again."
|
|
525
|
+
});
|
|
526
|
+
warnIfFile(backend);
|
|
527
|
+
if (ctx.json)
|
|
528
|
+
out(JSON.stringify({ ok: true, workspace: approved.workspace, expires_at: approved.expires_at, storage: backend }));
|
|
529
|
+
else out(`${pc3.green("\u2714")} Logged in to ${pc3.bold(approved.workspace.name)} (${approved.workspace.slug})${approved.expires_at ? `, expires ${approved.expires_at}` : ""}`);
|
|
530
|
+
});
|
|
531
|
+
program2.command("logout").description("Forget the local token (revoking it is done by the owner in the web UI)").action(async () => {
|
|
532
|
+
await deleteSession();
|
|
533
|
+
if (ctx.json) out(JSON.stringify({ ok: true }));
|
|
534
|
+
else {
|
|
535
|
+
out("Local token removed.");
|
|
536
|
+
if (process.env["STAQON_TOKEN"]) err(pc3.yellow("STAQON_TOKEN is still set in your environment."));
|
|
537
|
+
err(pc3.dim("The session stays valid on the server until it expires; the owner can revoke it at /cli."));
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
program2.command("refresh").alias("reimport").description("Rotate the session token (old one works 5 more minutes)").action(async () => {
|
|
541
|
+
const cur = await loadSession();
|
|
542
|
+
if (!cur) throw new CliError("Not logged in.", Exit.AUTH, { hint: "Run: staqon login" });
|
|
543
|
+
if (cur.backend === "env")
|
|
544
|
+
throw new CliError("Token comes from STAQON_TOKEN; refresh would rotate it where the CLI cannot save it.", Exit.USAGE, {
|
|
545
|
+
hint: "Unset STAQON_TOKEN and use staqon login."
|
|
546
|
+
});
|
|
547
|
+
const base = baseUrlFor(cur.session);
|
|
548
|
+
const r = await rawRequest(`${base}/api/cli-pair.php?action=refresh`, {
|
|
549
|
+
method: "POST",
|
|
550
|
+
headers: { Authorization: `Bearer ${cur.session.token}` }
|
|
551
|
+
});
|
|
552
|
+
if (r.status === 401 || r.status === 403) throw authError(r);
|
|
553
|
+
const newTok = r.body?.token;
|
|
554
|
+
if (r.status < 200 || r.status >= 300 || typeof newTok !== "string")
|
|
555
|
+
throw new ApiError(`Refresh failed (HTTP ${r.status}).`, Exit.GENERAL, r.status, r.body, r.traceId, { showBody: true });
|
|
556
|
+
let backend;
|
|
557
|
+
try {
|
|
558
|
+
backend = await saveSession({ ...cur.session, token: newTok, savedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
559
|
+
const back = await loadSession();
|
|
560
|
+
if (back?.session.token !== newTok) throw new Error("read-back mismatch");
|
|
561
|
+
} catch (e) {
|
|
562
|
+
err(pc3.red("Could not save the new token. It is printed once below; the old token works for ~5 minutes:"));
|
|
563
|
+
process.stderr.write(newTok + "\n");
|
|
564
|
+
throw new CliError(`Saving the rotated token failed: ${e instanceof Error ? e.message : e}`);
|
|
565
|
+
}
|
|
566
|
+
warnIfFile(backend);
|
|
567
|
+
if (ctx.json) out(JSON.stringify({ ok: true, storage: backend }));
|
|
568
|
+
else out(`${pc3.green("\u2714")} Token rotated and saved.`);
|
|
569
|
+
});
|
|
570
|
+
program2.command("whoami").description("Show the workspace, session, limits and which teams can do what").action(async () => {
|
|
571
|
+
const r = await proxy({ ep: "whoami", method: "GET", noTeam: true });
|
|
572
|
+
if (ctx.json) return printData(r.body);
|
|
573
|
+
const b = r.body;
|
|
574
|
+
const ws = b["workspace"];
|
|
575
|
+
out(`${pc3.bold("Workspace")} ${typeof ws === "object" && ws ? `${ws.name} (${ws.slug})` : String(ws)}`);
|
|
576
|
+
const s = b["session"] ?? {};
|
|
577
|
+
out(`${pc3.bold("Session")} ${s.device_label ?? "?"} created ${s.created_at ?? "?"}`);
|
|
578
|
+
out(` expires ${s.expires_at ?? "?"} (hard cap ${s.absolute_expires_at ?? "?"})`);
|
|
579
|
+
const l = b["limits"] ?? {};
|
|
580
|
+
out(`${pc3.bold("Limits")} ${l.used_last_minute ?? "?"}/${l.per_minute ?? "?"} per min, ${l.used_today ?? "?"}/${l.per_day ?? "?"} today`);
|
|
581
|
+
out(pc3.bold("Access"));
|
|
582
|
+
for (const a of b["access"] ?? []) {
|
|
583
|
+
const teams = (a.teams ?? []).map((t) => t.team_slug ?? t.team_id).join(", ") || "-";
|
|
584
|
+
out(` ${a.surface ?? ""}${a.purpose ? ` / ${a.purpose}` : ""} teams: ${teams}${a.automatic ? " (automatic)" : ""}`);
|
|
585
|
+
if (ctx.verbose) out(pc3.dim(` endpoints: ${(a.endpoints ?? []).join(", ")}`));
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
program2.command("use [team]").description("Save (or show / clear) the default team sent as X-StaqOn-Team").option("--clear", "remove the saved default").action(async (team, o) => {
|
|
589
|
+
const cfg = loadConfig();
|
|
590
|
+
if (o.clear) {
|
|
591
|
+
delete cfg.defaultTeam;
|
|
592
|
+
saveConfig(cfg);
|
|
593
|
+
return void out(ctx.json ? JSON.stringify({ default_team: null }) : "Default team cleared.");
|
|
594
|
+
}
|
|
595
|
+
if (!team) return void out(ctx.json ? JSON.stringify({ default_team: cfg.defaultTeam ?? null }) : cfg.defaultTeam ? `Default team: ${cfg.defaultTeam}` : "No default team set.");
|
|
596
|
+
try {
|
|
597
|
+
await requireSession();
|
|
598
|
+
const r = await proxy({ ep: "whoami", method: "GET", noTeam: true });
|
|
599
|
+
const known = (r.body?.access ?? []).flatMap((a) => a.teams ?? []);
|
|
600
|
+
if (known.length && !known.some((t) => String(t.team_slug) === team || String(t.team_id) === team))
|
|
601
|
+
err(pc3.yellow(`Warning: "${team}" is not among this workspace's teams (${known.map((t) => t.team_slug ?? t.team_id).join(", ")}).`));
|
|
602
|
+
} catch {
|
|
603
|
+
}
|
|
604
|
+
saveConfig({ ...cfg, defaultTeam: team });
|
|
605
|
+
out(ctx.json ? JSON.stringify({ default_team: team }) : `Default team set to ${team}.`);
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// src/commands/raw.ts
|
|
610
|
+
import fs3 from "fs";
|
|
611
|
+
import path4 from "path";
|
|
612
|
+
var ENDPOINTS = [
|
|
613
|
+
"external/message",
|
|
614
|
+
"external/messages",
|
|
615
|
+
"external/comment",
|
|
616
|
+
"external/members",
|
|
617
|
+
"external/member",
|
|
618
|
+
"external/ad",
|
|
619
|
+
"external/ad-image",
|
|
620
|
+
"external/ad-status",
|
|
621
|
+
"external/staqon-verify-start",
|
|
622
|
+
"external/staqon-verify-check",
|
|
623
|
+
"external/staqon-verify-cancel",
|
|
624
|
+
"internal/message",
|
|
625
|
+
"internal/members"
|
|
626
|
+
];
|
|
627
|
+
function kv(items, flag) {
|
|
628
|
+
return (items ?? []).map((s) => {
|
|
629
|
+
const i = s.indexOf("=");
|
|
630
|
+
if (i < 1) throw new CliError(`${flag} expects key=value, got "${s}".`, Exit.USAGE);
|
|
631
|
+
return [s.slice(0, i), s.slice(i + 1)];
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
async function readStdin() {
|
|
635
|
+
const chunks = [];
|
|
636
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
637
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
638
|
+
}
|
|
639
|
+
async function readJson(spec) {
|
|
640
|
+
const text = spec === "-" ? await readStdin() : spec.startsWith("@") ? fs3.readFileSync(spec.slice(1), "utf8") : spec;
|
|
641
|
+
try {
|
|
642
|
+
return JSON.parse(text);
|
|
643
|
+
} catch (e) {
|
|
644
|
+
throw new CliError(`--data is not valid JSON: ${e instanceof Error ? e.message : e}`, Exit.USAGE);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
async function buildForm(items) {
|
|
648
|
+
const fd = new FormData();
|
|
649
|
+
for (const [k, v] of kv(items, "--form")) {
|
|
650
|
+
if (v.startsWith("@")) {
|
|
651
|
+
const p = v.slice(1);
|
|
652
|
+
if (!fs3.existsSync(p)) throw new CliError(`File not found: ${p}`, Exit.USAGE);
|
|
653
|
+
fd.append(k, await fs3.openAsBlob(p), path4.basename(p));
|
|
654
|
+
} else fd.append(k, v);
|
|
655
|
+
}
|
|
656
|
+
return fd;
|
|
657
|
+
}
|
|
658
|
+
async function runRaw(ep, o) {
|
|
659
|
+
if (o.data !== void 0 && o.form?.length) throw new CliError("Use either --data or --form, not both.", Exit.USAGE);
|
|
660
|
+
const method = o.method ? o.method.toUpperCase() : void 0;
|
|
661
|
+
if (method && method !== "GET" && method !== "POST") throw new CliError("--method must be GET or POST.", Exit.USAGE);
|
|
662
|
+
const r = await proxy({
|
|
663
|
+
ep,
|
|
664
|
+
method,
|
|
665
|
+
query: Object.fromEntries(kv(o.query, "--query")),
|
|
666
|
+
json: o.data !== void 0 ? await readJson(o.data) : void 0,
|
|
667
|
+
form: o.form?.length ? await buildForm(o.form) : void 0
|
|
668
|
+
});
|
|
669
|
+
if (!ctx.json && r.team) debug(`acted as team: ${r.team}`);
|
|
670
|
+
if (r.body === void 0) {
|
|
671
|
+
if (ctx.json) process.stdout.write("null\n");
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
printData(r.body);
|
|
675
|
+
}
|
|
676
|
+
function addRawFlags(c) {
|
|
677
|
+
return c.option("-q, --query <k=v...>", "extra query param for the endpoint (repeatable)", (v, a = []) => [...a, v]).option("-d, --data <json>", "JSON request body: inline, @file.json, or - for stdin").option("-F, --form <k=v...>", "multipart field; k=@path attaches a file (repeatable)", (v, a = []) => [...a, v]).option("-X, --method <GET|POST>", "override the HTTP method (default: POST if a body is given, else GET)");
|
|
678
|
+
}
|
|
679
|
+
function registerRaw(program2) {
|
|
680
|
+
addRawFlags(
|
|
681
|
+
program2.command("api <endpoint>").description(`Call any CLI-proxy endpoint directly. Endpoints: ${ENDPOINTS.join(", ")}`)
|
|
682
|
+
).action((endpoint, o) => runRaw(endpoint, o));
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// src/commands/typed.ts
|
|
686
|
+
import path5 from "path";
|
|
687
|
+
import pc4 from "picocolors";
|
|
688
|
+
|
|
689
|
+
// src/validate.ts
|
|
690
|
+
import fs4 from "fs";
|
|
691
|
+
var usage = (msg, hint) => new CliError(msg, Exit.USAGE, hint ? { hint } : {});
|
|
692
|
+
var len = (s) => [...s].length;
|
|
693
|
+
function maxLen(name, s, max) {
|
|
694
|
+
if (len(s) > max) throw usage(`${name} is too long (${len(s)} > ${max} characters).`);
|
|
695
|
+
return s;
|
|
696
|
+
}
|
|
697
|
+
function range(name, s, min, max) {
|
|
698
|
+
const n = len(s);
|
|
699
|
+
if (n < min || n > max) throw usage(`${name} must be ${min}-${max} characters (got ${n}).`);
|
|
700
|
+
return s;
|
|
701
|
+
}
|
|
702
|
+
function required(name, s) {
|
|
703
|
+
if (s === void 0 || s.trim() === "") throw usage(`${name} is required.`);
|
|
704
|
+
return s;
|
|
705
|
+
}
|
|
706
|
+
function httpsUrl(name, s) {
|
|
707
|
+
let u;
|
|
708
|
+
try {
|
|
709
|
+
u = new URL(s);
|
|
710
|
+
} catch {
|
|
711
|
+
throw usage(`${name} is not a valid URL.`);
|
|
712
|
+
}
|
|
713
|
+
if (u.protocol !== "https:") throw usage(`${name} must be an https URL.`);
|
|
714
|
+
return s;
|
|
715
|
+
}
|
|
716
|
+
function intOpt(name, min, max) {
|
|
717
|
+
return (v) => {
|
|
718
|
+
const n = Number(v);
|
|
719
|
+
if (!Number.isInteger(n) || n < min || n > max) throw usage(`${name} must be an integer ${min}-${max}.`);
|
|
720
|
+
return n;
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
async function readStdin2() {
|
|
724
|
+
const chunks = [];
|
|
725
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
726
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
727
|
+
}
|
|
728
|
+
async function textFrom(flag, value, file2) {
|
|
729
|
+
if (value !== void 0 && file2 !== void 0) throw usage(`Use either --${flag} or --${flag}-file, not both.`);
|
|
730
|
+
if (file2 !== void 0) {
|
|
731
|
+
if (!fs4.existsSync(file2)) throw usage(`File not found: ${file2}`);
|
|
732
|
+
return fs4.readFileSync(file2, "utf8").replace(/\r\n$|\n$/, "");
|
|
733
|
+
}
|
|
734
|
+
if (value === "-") return (await readStdin2()).replace(/\r?\n$/, "");
|
|
735
|
+
return required(`--${flag}`, value);
|
|
736
|
+
}
|
|
737
|
+
async function readJsonArg(name, spec) {
|
|
738
|
+
const text = spec === "-" ? await readStdin2() : spec.startsWith("@") ? fs4.readFileSync(spec.slice(1), "utf8") : spec;
|
|
739
|
+
try {
|
|
740
|
+
return JSON.parse(text);
|
|
741
|
+
} catch (e) {
|
|
742
|
+
throw usage(`${name} is not valid JSON: ${e instanceof Error ? e.message : e}`);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
var COLORS = ["#1F7A54", "#059669", "#D97706", "#DC2626", "#2563EB", "#4B5563", "#7C3AED", "#0D9488", "#DB2777", "#0891B2"];
|
|
746
|
+
var ICONS = ["check", "warning", "document", "security", "wallet", "person", "alert", "info", "reassign", "pending"];
|
|
747
|
+
var CARD_KEYS = ["header_color", "title", "body", "meta_title", "sub_body", "icon", "button", "table"];
|
|
748
|
+
var BUTTON_KEYS = ["label", "url", "color"];
|
|
749
|
+
var isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
750
|
+
var str = (name, v, max, req = false) => {
|
|
751
|
+
if (v === void 0) {
|
|
752
|
+
if (req) throw usage(`card.${name} is required.`);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
if (typeof v !== "string") throw usage(`card.${name} must be a string.`);
|
|
756
|
+
if (req && v === "") throw usage(`card.${name} is required.`);
|
|
757
|
+
maxLen(`card.${name}`, v, max);
|
|
758
|
+
};
|
|
759
|
+
function validateCard(card, opts) {
|
|
760
|
+
if (!isObj(card)) throw usage("--card must be a JSON object.");
|
|
761
|
+
for (const k of Object.keys(card)) if (!CARD_KEYS.includes(k)) throw usage(`Unknown card key "${k}".`, `Allowed: ${CARD_KEYS.join(", ")}`);
|
|
762
|
+
if (typeof card["header_color"] !== "string" || !COLORS.includes(card["header_color"]))
|
|
763
|
+
throw usage("card.header_color is required and must be one of: " + COLORS.join(" "));
|
|
764
|
+
str("title", card["title"], 200, true);
|
|
765
|
+
str("body", card["body"], 2e3, true);
|
|
766
|
+
str("meta_title", card["meta_title"], 60);
|
|
767
|
+
str("sub_body", card["sub_body"], 2e3);
|
|
768
|
+
if (card["icon"] !== void 0 && !ICONS.includes(card["icon"]))
|
|
769
|
+
throw usage("card.icon must be one of: " + ICONS.join(", "));
|
|
770
|
+
if (card["button"] !== void 0) {
|
|
771
|
+
if (!opts.allowButton) throw usage("Verification cards cannot have a button.");
|
|
772
|
+
const b = card["button"];
|
|
773
|
+
if (!isObj(b)) throw usage("card.button must be an object.");
|
|
774
|
+
for (const k of Object.keys(b)) if (!BUTTON_KEYS.includes(k)) throw usage(`Unknown card.button key "${k}".`);
|
|
775
|
+
str("button.label", b["label"], 40, true);
|
|
776
|
+
str("button.url", b["url"], 2048, true);
|
|
777
|
+
if (!/^(https:\/\/|tel:|mailto:)/i.test(b["url"])) throw usage("card.button.url must be https, tel or mailto.");
|
|
778
|
+
}
|
|
779
|
+
if (card["table"] !== void 0) {
|
|
780
|
+
const t = card["table"];
|
|
781
|
+
if (!isObj(t)) throw usage("card.table must be an object.");
|
|
782
|
+
const cols = t["columns"];
|
|
783
|
+
const rows = t["rows"];
|
|
784
|
+
if (!Array.isArray(cols) || cols.length < 2 || cols.length > 6) throw usage("card.table.columns needs 2-6 columns.");
|
|
785
|
+
if (!Array.isArray(rows) || rows.length < 1 || rows.length > 500) throw usage("card.table.rows needs 1-500 rows.");
|
|
786
|
+
for (const c of cols) {
|
|
787
|
+
if (!isObj(c)) throw usage("Each table column must be an object.");
|
|
788
|
+
str("table.column.label", c["label"], 60, true);
|
|
789
|
+
if (c["align"] !== void 0 && !["left", "center", "right"].includes(c["align"]))
|
|
790
|
+
throw usage("table column align must be left, center or right.");
|
|
791
|
+
}
|
|
792
|
+
let chars = 0;
|
|
793
|
+
rows.forEach((r, i) => {
|
|
794
|
+
if (!Array.isArray(r) || r.length !== cols.length || r.some((x) => typeof x !== "string"))
|
|
795
|
+
throw usage(`card.table.rows[${i}] must be ${cols.length} strings (one per column).`);
|
|
796
|
+
chars += r.reduce((n, x) => n + len(x), 0);
|
|
797
|
+
});
|
|
798
|
+
if (chars > 6e4) throw usage("card.table is over 60000 characters in total.");
|
|
799
|
+
}
|
|
800
|
+
return card;
|
|
801
|
+
}
|
|
802
|
+
async function cardArg(spec, allowButton) {
|
|
803
|
+
if (spec === void 0) return void 0;
|
|
804
|
+
return validateCard(await readJsonArg("--card", spec), { allowButton });
|
|
805
|
+
}
|
|
806
|
+
function checkImageFile(file2) {
|
|
807
|
+
if (!fs4.existsSync(file2) || !fs4.statSync(file2).isFile()) throw usage(`File not found: ${file2}`);
|
|
808
|
+
const bytes = fs4.readFileSync(file2);
|
|
809
|
+
if (bytes.length > 300 * 1024) throw usage(`Image is ${(bytes.length / 1024).toFixed(0)} KB; the limit is 300 KB.`, "Compress or resize it first.");
|
|
810
|
+
let type;
|
|
811
|
+
if (bytes.subarray(0, 3).equals(Buffer.from([255, 216, 255]))) type = "jpeg";
|
|
812
|
+
else if (bytes.subarray(0, 4).equals(Buffer.from([137, 80, 78, 71]))) type = "png";
|
|
813
|
+
else if (bytes.subarray(0, 4).toString("latin1") === "RIFF" && bytes.subarray(8, 12).toString("latin1") === "WEBP") type = "webp";
|
|
814
|
+
if (!type) throw usage("Only JPEG, PNG and WebP images are accepted (checked on the file contents).");
|
|
815
|
+
return { bytes, type };
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// src/identity.ts
|
|
819
|
+
var MAX_DIRECTORY_PAGES = 50;
|
|
820
|
+
var obj2 = (b) => b && typeof b === "object" ? b : {};
|
|
821
|
+
async function resolveIdentity(who2, want, dir = "external") {
|
|
822
|
+
const given = [who2.to, who2.email, who2.username].filter((x) => x !== void 0 && x !== "");
|
|
823
|
+
if (given.length === 0) throw usage(`Say who: --${want}, or --to <email|username>.`);
|
|
824
|
+
if (given.length > 1) throw usage("Use only one of --to, --email, --username.");
|
|
825
|
+
const value = (who2.to ?? who2.email ?? who2.username).trim();
|
|
826
|
+
const isEmail = who2.email !== void 0 ? true : who2.username !== void 0 ? false : value.includes("@");
|
|
827
|
+
if (isEmail && who2.username === void 0 && !value.includes("@")) throw usage(`"${value}" is not an email address.`);
|
|
828
|
+
if (isEmail === (want === "email")) return value;
|
|
829
|
+
debug(`resolving ${isEmail ? "email" : "username"} "${value}" to ${want} via ${dir} members`);
|
|
830
|
+
if (isEmail && dir === "external") {
|
|
831
|
+
const r = await proxy({ ep: "external/member", method: "GET", query: { email: value } });
|
|
832
|
+
const u = obj2(r.body)["member"]?.username;
|
|
833
|
+
if (typeof u !== "string" || !u) throw new CliError(`Member ${value} has no username.`, Exit.GENERAL);
|
|
834
|
+
return u;
|
|
835
|
+
}
|
|
836
|
+
const matches = (m) => isEmail ? String(m["email"]).toLowerCase() === value.toLowerCase() : String(m["username"]).toLowerCase() === value.toLowerCase();
|
|
837
|
+
const fetchPage = async (query) => {
|
|
838
|
+
const r = await proxy({ ep: `${dir}/members`, method: "GET", query, timeoutMs: LIST_TIMEOUT_MS });
|
|
839
|
+
const b = obj2(r.body);
|
|
840
|
+
return { list: b["members"] ?? [], pages: Number(b["pages"]) || 1 };
|
|
841
|
+
};
|
|
842
|
+
let hit = (await fetchPage({ search: value, limit: "50" })).list.find(matches);
|
|
843
|
+
if (!hit && !isEmail && dir === "external") {
|
|
844
|
+
try {
|
|
845
|
+
const m = await proxy({ ep: "external/messages", method: "GET", query: { username: value, limit: "1" } });
|
|
846
|
+
const name = obj2(obj2(m.body)["member"])["name"];
|
|
847
|
+
if (typeof name === "string" && name) hit = (await fetchPage({ search: name, limit: "50" })).list.find(matches);
|
|
848
|
+
} catch (e) {
|
|
849
|
+
debug(`name lookup via messages failed (${e instanceof Error ? e.message : e}); falling back to paging`);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
if (!hit) {
|
|
853
|
+
debug(`search found no exact match for "${value}"; paging through the members directory`);
|
|
854
|
+
for (let page = 1, pages = 1; !hit && page <= pages && page <= MAX_DIRECTORY_PAGES; page++) {
|
|
855
|
+
const res = await fetchPage({ limit: "50", page: String(page) });
|
|
856
|
+
pages = res.pages;
|
|
857
|
+
hit = res.list.find(matches);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
const out2 = hit?.[want];
|
|
861
|
+
if (typeof out2 !== "string" || !out2)
|
|
862
|
+
throw new CliError(`No member in this team matches "${value}".`, Exit.GENERAL, {
|
|
863
|
+
hint: "Check with: staqon members list --search " + value
|
|
864
|
+
});
|
|
865
|
+
return out2;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// src/commands/typed.ts
|
|
869
|
+
var obj3 = (b) => b && typeof b === "object" ? b : {};
|
|
870
|
+
var withWho = (c) => c.option("--to <email|username>", "the member, as an email or a username (converted if needed)").option("--email <email>", "the member's email").option("--username <username>", "the member's username");
|
|
871
|
+
var who = (o) => ({ to: o["to"], email: o["email"], username: o["username"] });
|
|
872
|
+
function note(r) {
|
|
873
|
+
if (r.team) debug(`acted as team: ${r.team}`);
|
|
874
|
+
}
|
|
875
|
+
function show(r, human) {
|
|
876
|
+
note(r);
|
|
877
|
+
if (ctx.json) return void (r.body === void 0 ? out("null") : printData(r.body));
|
|
878
|
+
if (r.body === void 0 || typeof r.body !== "object") return void (r.body !== void 0 && printData(r.body));
|
|
879
|
+
human(obj3(r.body));
|
|
880
|
+
}
|
|
881
|
+
var ok = (s) => out(`${pc4.green("\u2714")} ${s}`);
|
|
882
|
+
function table(rows, head) {
|
|
883
|
+
const all = [head, ...rows];
|
|
884
|
+
const w = head.map((_, i) => Math.max(...all.map((r) => (r[i] ?? "").length)));
|
|
885
|
+
const line = (r) => r.map((c, i) => (c ?? "").padEnd(w[i])).join(" ").trimEnd();
|
|
886
|
+
out(pc4.bold(line(head)));
|
|
887
|
+
rows.forEach((r) => out(line(r)));
|
|
888
|
+
}
|
|
889
|
+
function registerTyped(program2) {
|
|
890
|
+
const message = program2.command("message").description("Send messages as the workspace");
|
|
891
|
+
withWho(message.command("send").description("Send a message to a member (endpoint takes an email)")).option("-m, --message <text>", 'message text, max 60000 chars; "-" reads stdin').option("--message-file <path>", "read the message from a file").addHelpText("after", '\nExamples:\n staqon message send --email ada@example.com -m "Hello"\n staqon message send --to ada -m "Hello" (username is resolved to an email)').action(async (o) => {
|
|
892
|
+
const text = maxLen("message", await textFrom("message", o["message"], o["messageFile"]), 6e4);
|
|
893
|
+
const email = await resolveIdentity(who(o), "email");
|
|
894
|
+
const r = await proxy({ ep: "external/message", json: { email, message: text } });
|
|
895
|
+
show(r, (b) => ok(`Sent. message_id=${b["message_id"]} conversation_id=${b["conversation_id"]}`));
|
|
896
|
+
});
|
|
897
|
+
withWho(program2.command("messages").description("Read a member's conversation (needs the member's approved link)")).option("--limit <n>", "1-200 (default 50)", intOpt("--limit", 1, 200)).option("--before <id>", "messages older than this id").option("--after <id>", "messages newer than this id").action(async (o) => {
|
|
898
|
+
if (o["before"] && o["after"]) throw usage("Use --before or --after, not both.");
|
|
899
|
+
const username = await resolveIdentity(who(o), "username");
|
|
900
|
+
const query = { username };
|
|
901
|
+
if (o["limit"]) query["limit"] = String(o["limit"]);
|
|
902
|
+
if (o["before"]) query["before"] = o["before"];
|
|
903
|
+
if (o["after"]) query["after"] = o["after"];
|
|
904
|
+
const r = await proxy({ ep: "external/messages", method: "GET", query });
|
|
905
|
+
show(r, (b) => {
|
|
906
|
+
out(pc4.bold(`Conversation ${b["conversation_id"]} with ${b["member"]?.name ?? ""} (@${b["member"]?.username ?? username})`));
|
|
907
|
+
for (const m of b["messages"] ?? []) {
|
|
908
|
+
const flags = [m["edited"] && "edited", m["deleted"] && "deleted", m["source"]].filter(Boolean).join(", ");
|
|
909
|
+
out(`${pc4.dim(`#${m["id"]} ${m["sent_at"]}`)} ${pc4.cyan(m["sender_name"] ?? m["sender_side"])}${flags ? pc4.dim(` (${flags})`) : ""}`);
|
|
910
|
+
out(` ${m["deleted"] ? pc4.dim("[deleted]") : m["content"] ?? ""}`);
|
|
911
|
+
if (m["card"]) out(pc4.dim(` [card: ${m["card"].title ?? ""}]`));
|
|
912
|
+
for (const a of m["attachments"] ?? [])
|
|
913
|
+
out(pc4.dim(` \u{1F4CE} ${a["name"]} (${a["mime"]}, ${a["size"]} B)${a["url_available"] ? " " + a["url"] : " [unavailable]"}`));
|
|
914
|
+
}
|
|
915
|
+
if (b["paging"]) out(pc4.dim(`paging: ${JSON.stringify(b["paging"])} (signed attachment URLs last 1 day)`));
|
|
916
|
+
});
|
|
917
|
+
});
|
|
918
|
+
withWho(program2.command("comment").description("Post a comment on a member's thread (endpoint takes a username)")).option("-c, --comment <text>", 'comment text, max 60000 chars; "-" reads stdin').option("--comment-file <path>", "read the comment from a file").option("--external-ref <ref>", "idempotency key, max 190 chars; a repeat is rejected, nothing is posted").option("--card <json>", "card object: inline JSON, @file.json, or - for stdin").action(async (o) => {
|
|
919
|
+
const text = maxLen("comment", await textFrom("comment", o["comment"], o["commentFile"]), 6e4);
|
|
920
|
+
if (o["externalRef"] !== void 0) maxLen("--external-ref", o["externalRef"], 190);
|
|
921
|
+
const card = await cardArg(o["card"], true);
|
|
922
|
+
const username = await resolveIdentity(who(o), "username");
|
|
923
|
+
const json = { username, comment: text };
|
|
924
|
+
if (o["externalRef"] !== void 0) json["external_ref"] = o["externalRef"];
|
|
925
|
+
if (card) json["card"] = card;
|
|
926
|
+
try {
|
|
927
|
+
const r = await proxy({ ep: "external/comment", json });
|
|
928
|
+
show(r, (b) => ok(`Posted. message_id=${b["message_id"]} conversation_id=${b["conversation_id"]} sent_at=${b["sent_at"]}`));
|
|
929
|
+
} catch (e) {
|
|
930
|
+
if (e instanceof ApiError && e.status === 409 && obj3(e.body)["message_id"] !== void 0)
|
|
931
|
+
throw new ApiError(
|
|
932
|
+
`Duplicate external_ref: nothing was posted (existing message_id ${obj3(e.body)["message_id"]}, sent ${obj3(e.body)["sent_at"]}).`,
|
|
933
|
+
Exit.GENERAL,
|
|
934
|
+
409,
|
|
935
|
+
e.body,
|
|
936
|
+
e.traceId
|
|
937
|
+
);
|
|
938
|
+
throw e;
|
|
939
|
+
}
|
|
940
|
+
});
|
|
941
|
+
const listOpts = (c) => c.option("--page <n>", "page number", intOpt("--page", 1, 1e6)).option("--limit <n>", `1-${MAX_LIST_LIMIT} (default 50; the directory is slow, ~0.35s per row)`, intOpt("--limit", 1, MAX_LIST_LIMIT)).option("--search <text>", "filter by name or email");
|
|
942
|
+
const memberQuery = (o) => {
|
|
943
|
+
const q = {};
|
|
944
|
+
if (o["page"]) q["page"] = String(o["page"]);
|
|
945
|
+
if (o["limit"]) q["limit"] = String(o["limit"]);
|
|
946
|
+
if (o["search"]) q["search"] = o["search"];
|
|
947
|
+
return q;
|
|
948
|
+
};
|
|
949
|
+
const memberTable = (b, extra) => {
|
|
950
|
+
const rows = (b["members"] ?? []).map((m) => [String(m["id"]), m["name"] ?? "", m["email"] ?? "", m["username"] ?? "", m["status"] ?? "", m[extra] === void 0 ? "" : String(m[extra])]);
|
|
951
|
+
table(rows, ["ID", "NAME", "EMAIL", "USERNAME", "STATUS", extra.toUpperCase()]);
|
|
952
|
+
out(pc4.dim(`page ${b["page"]}/${b["pages"]}, ${b["total"]} total`));
|
|
953
|
+
};
|
|
954
|
+
const members = program2.command("members").description("Workspace members");
|
|
955
|
+
listOpts(members.command("list").description("List members (shows both email and username)")).action(async (o) => {
|
|
956
|
+
const r = await proxy({ ep: "external/members", method: "GET", query: memberQuery(o), timeoutMs: LIST_TIMEOUT_MS });
|
|
957
|
+
show(r, (b) => memberTable(b, "last_message_at"));
|
|
958
|
+
});
|
|
959
|
+
program2.command("member [email-or-id]").description("Look up one member by email or member id").option("--email <email>", "look up by email").option("--member-id <id>", "look up by member id").action(async (arg, o) => {
|
|
960
|
+
const given = [arg, o["email"], o["memberId"]].filter((x) => x);
|
|
961
|
+
if (given.length !== 1) throw usage("Give exactly one of: <email-or-id>, --email, --member-id.");
|
|
962
|
+
const query = {};
|
|
963
|
+
if (o["memberId"]) query["member_id"] = o["memberId"];
|
|
964
|
+
else {
|
|
965
|
+
const v = arg ?? o["email"];
|
|
966
|
+
if (v.includes("@")) query["email"] = v;
|
|
967
|
+
else query["member_id"] = v;
|
|
968
|
+
}
|
|
969
|
+
const r = await proxy({ ep: "external/member", method: "GET", query });
|
|
970
|
+
show(r, (b) => {
|
|
971
|
+
const m = obj3(b["member"]);
|
|
972
|
+
for (const k of ["id", "name", "email", "username", "joined_at", "last_active", "messages_sent", "has_mpin"])
|
|
973
|
+
out(`${pc4.bold(k.padEnd(14))} ${m[k] ?? ""}`);
|
|
974
|
+
});
|
|
975
|
+
});
|
|
976
|
+
const internal = program2.command("internal").description("Internal (one-way API Messages feed)");
|
|
977
|
+
withWho(internal.command("message").description("Send a one-way message to a member's API Messages feed (needs their approval)")).option("-m, --message <text>", 'message text, max 60000 chars; "-" reads stdin').option("--message-file <path>", "read the message from a file").option("--title <title>", "max 200 chars").option("--external-ref <ref>", "idempotency key, max 190 chars").option("--link-url <url>", "http(s) URL").option("--card <json>", "card object: inline JSON, @file.json, or - for stdin").action(async (o) => {
|
|
978
|
+
const text = maxLen("message", await textFrom("message", o["message"], o["messageFile"]), 6e4);
|
|
979
|
+
if (o["title"] !== void 0) maxLen("--title", o["title"], 200);
|
|
980
|
+
if (o["externalRef"] !== void 0) maxLen("--external-ref", o["externalRef"], 190);
|
|
981
|
+
if (o["linkUrl"] !== void 0 && !/^https?:\/\//i.test(o["linkUrl"])) throw usage("--link-url must be an http(s) URL.");
|
|
982
|
+
const card = await cardArg(o["card"], true);
|
|
983
|
+
const username = await resolveIdentity(who(o), "username", "internal");
|
|
984
|
+
const json = { username, message: text };
|
|
985
|
+
if (o["title"] !== void 0) json["title"] = o["title"];
|
|
986
|
+
if (o["externalRef"] !== void 0) json["external_ref"] = o["externalRef"];
|
|
987
|
+
if (o["linkUrl"] !== void 0) json["link_url"] = o["linkUrl"];
|
|
988
|
+
if (card) json["card"] = card;
|
|
989
|
+
try {
|
|
990
|
+
const r = await proxy({ ep: "internal/message", json });
|
|
991
|
+
show(r, (b) => ok(`Delivered. id=${b["id"]} delivered_at=${b["delivered_at"]}`));
|
|
992
|
+
} catch (e) {
|
|
993
|
+
if (e instanceof ApiError && e.status === 404 && /internal link/i.test(String(obj3(e.body)["error"] ?? obj3(e.body)["message"])))
|
|
994
|
+
throw new ApiError("Internal Link is not set up for this workspace.", Exit.TEAM_OR_KEY, 404, e.body, e.traceId, {
|
|
995
|
+
hint: "The workspace owner has to set it up in settings."
|
|
996
|
+
});
|
|
997
|
+
if (e instanceof ApiError && e.status === 409 && obj3(e.body)["id"] !== void 0)
|
|
998
|
+
throw new ApiError(`Duplicate external_ref: nothing was sent (existing id ${obj3(e.body)["id"]}, delivered ${obj3(e.body)["delivered_at"]}).`, Exit.GENERAL, 409, e.body, e.traceId);
|
|
999
|
+
throw e;
|
|
1000
|
+
}
|
|
1001
|
+
});
|
|
1002
|
+
listOpts(internal.command("members").description("List members of the internal directory (search also matches username)")).action(async (o) => {
|
|
1003
|
+
const r = await proxy({ ep: "internal/members", method: "GET", query: memberQuery(o), timeoutMs: LIST_TIMEOUT_MS });
|
|
1004
|
+
show(r, (b) => memberTable(b, "role"));
|
|
1005
|
+
});
|
|
1006
|
+
const ad = program2.command("ad").description("Ads (asynchronous: delivered by a background worker)");
|
|
1007
|
+
const uploadImage = async (file2) => {
|
|
1008
|
+
const { bytes, type } = checkImageFile(file2);
|
|
1009
|
+
const fd = new FormData();
|
|
1010
|
+
fd.append("image", new Blob([new Uint8Array(bytes)], { type: `image/${type}` }), path5.basename(file2));
|
|
1011
|
+
return proxy({ ep: "external/ad-image", form: fd });
|
|
1012
|
+
};
|
|
1013
|
+
const imageHuman = (b) => {
|
|
1014
|
+
ok(`Uploaded ${b["width"]}x${b["height"]}, ${b["bytes"]} B (auto-cropped to 16:9, metadata stripped).`);
|
|
1015
|
+
out(`image_url: ${b["image_url"]}`);
|
|
1016
|
+
err(pc4.dim(`Use it with: staqon ad create --image-url "${b["image_url"]}" ...`));
|
|
1017
|
+
};
|
|
1018
|
+
ad.command("image <file>").description("Upload an ad image (JPEG/PNG/WebP, max 300 KB; cropped to 16:9, max 1280x720)").action(async (file2) => show(await uploadImage(file2), imageHuman));
|
|
1019
|
+
ad.command("create").description("Create an ad. Success is 202 + an ad_request_id; track it with: staqon ad status <id>").option("--body <text>", 'ad text, 50-900 chars; "-" reads stdin').option("--body-file <path>", "read the body from a file").option("--button-label <label>", "max 30 chars").option("--button-url <url>", "https URL").option("--image-url <url>", 'URL returned by "staqon ad image" (same workspace and team)').option("--image <file>", "upload this image first, then use it (same as ad image + --image-url)").option("--external-ref <ref>", "idempotency key, max 150 chars").option("--wait", "poll until the ad finishes (every 5s, up to 10 min)").action(async (o) => {
|
|
1020
|
+
const body = range("body", await textFrom("body", o["body"], o["bodyFile"]), 50, 900);
|
|
1021
|
+
const label = maxLen("--button-label", required("--button-label", o["buttonLabel"]), 30);
|
|
1022
|
+
const url = httpsUrl("--button-url", required("--button-url", o["buttonUrl"]));
|
|
1023
|
+
if (o["externalRef"] !== void 0) maxLen("--external-ref", o["externalRef"], 150);
|
|
1024
|
+
if (o["image"] && o["imageUrl"]) throw usage("Use either --image or --image-url, not both.");
|
|
1025
|
+
if (o["image"]) checkImageFile(o["image"]);
|
|
1026
|
+
let imageUrl = o["imageUrl"];
|
|
1027
|
+
if (o["image"]) {
|
|
1028
|
+
const up = await uploadImage(o["image"]);
|
|
1029
|
+
imageUrl = obj3(up.body)["image_url"];
|
|
1030
|
+
info(`Uploaded image: ${imageUrl}`);
|
|
1031
|
+
}
|
|
1032
|
+
const json = { body, button_label: label, button_url: url };
|
|
1033
|
+
if (imageUrl) json["image_url"] = imageUrl;
|
|
1034
|
+
if (o["externalRef"] !== void 0) json["external_ref"] = o["externalRef"];
|
|
1035
|
+
let r;
|
|
1036
|
+
try {
|
|
1037
|
+
r = await proxy({ ep: "external/ad", json });
|
|
1038
|
+
} catch (e) {
|
|
1039
|
+
if (e instanceof ApiError && imageUrl && o["image"])
|
|
1040
|
+
err(pc4.dim(`The image was uploaded; to retry without re-uploading use --image-url "${imageUrl}"`));
|
|
1041
|
+
if (e instanceof ApiError && e.status === 409) throw new ApiError("Duplicate external_ref: no new ad was queued.", Exit.GENERAL, 409, e.body, e.traceId, { showBody: true });
|
|
1042
|
+
throw e;
|
|
1043
|
+
}
|
|
1044
|
+
const id = obj3(r.body)["ad_request_id"];
|
|
1045
|
+
if (!o["wait"]) return show(r, (b) => {
|
|
1046
|
+
ok(`Queued (202). ad_request_id=${b["ad_request_id"]} external_ref=${b["external_ref"] ?? ""} status=${b["status"]}`);
|
|
1047
|
+
out(`Track it with: staqon ad status ${b["ad_request_id"]}`);
|
|
1048
|
+
});
|
|
1049
|
+
info(`Queued: ad_request_id=${id}. Waiting\u2026`);
|
|
1050
|
+
const final = await waitForAd(String(id));
|
|
1051
|
+
show(final, adStatusHuman);
|
|
1052
|
+
if (obj3(final.body)["status"] === "failed") process.exitCode = Exit.GENERAL;
|
|
1053
|
+
});
|
|
1054
|
+
const adStatusHuman = (b) => {
|
|
1055
|
+
const col = b["status"] === "completed" ? pc4.green : b["status"] === "failed" ? pc4.red : pc4.yellow;
|
|
1056
|
+
out(`${pc4.bold("ad " + b["ad_request_id"])} ${col(String(b["status"]))}${b["legacy"] ? pc4.dim(" (legacy ref)") : ""}`);
|
|
1057
|
+
out(`targets ${b["total_targets"]}: delivered ${b["delivered"]}, failed ${b["failed"]}, skipped ${b["skipped"]}, processed ${b["processed"]}`);
|
|
1058
|
+
out(pc4.dim(`created ${b["created_at"] ?? "-"} started ${b["started_at"] ?? "-"} finished ${b["finished_at"] ?? "-"} (${b["duration_ms"] ?? "-"} ms) webhook: ${b["webhook_status"] ?? "-"}`));
|
|
1059
|
+
if (b["error"]) out(pc4.red(`error: ${b["error"]}`));
|
|
1060
|
+
};
|
|
1061
|
+
const info = (s) => ctx.json || err(s);
|
|
1062
|
+
async function waitForAd(id) {
|
|
1063
|
+
const deadline = Date.now() + 10 * 6e4;
|
|
1064
|
+
for (; ; ) {
|
|
1065
|
+
const r = await proxy({ ep: "external/ad-status", method: "GET", query: { ad_request_id: id } });
|
|
1066
|
+
const st = obj3(r.body)["status"];
|
|
1067
|
+
if (st === "completed" || st === "failed") return r;
|
|
1068
|
+
if (Date.now() > deadline) throw new CliError(`Still ${st} after 10 minutes; check later with: staqon ad status ${id}`);
|
|
1069
|
+
await new Promise((res) => setTimeout(res, 5e3));
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
ad.command("status [ad_request_id]").description("Check an ad by request id or external ref").option("--external-ref <ref>", "look up by external_ref instead").action(async (id, o) => {
|
|
1073
|
+
if (!!id === !!o["externalRef"]) throw usage("Give exactly one of: <ad_request_id>, --external-ref.");
|
|
1074
|
+
const query = id ? { ad_request_id: id } : { external_ref: o["externalRef"] };
|
|
1075
|
+
show(await proxy({ ep: "external/ad-status", method: "GET", query }), adStatusHuman);
|
|
1076
|
+
});
|
|
1077
|
+
const verify = program2.command("verify").description("StaqOn Verify (needs the member's approved link)");
|
|
1078
|
+
withWho(verify.command("start").description("Ask a member to approve an action")).option("--action <text>", "what is being approved, max 100 chars").option("--context <text>", "extra context, max 500 chars").option("--ttl <seconds>", "how long the request stays open (clamped to 30-180, default 30)", intOpt("--ttl", 1, 1e5)).option("--card <json>", "card (no button allowed): inline JSON, @file.json, or - for stdin").action(async (o) => {
|
|
1079
|
+
const action = maxLen("--action", required("--action", o["action"]), 100);
|
|
1080
|
+
const context = maxLen("--context", required("--context", o["context"]), 500);
|
|
1081
|
+
const card = await cardArg(o["card"], false);
|
|
1082
|
+
const username = await resolveIdentity(who(o), "username");
|
|
1083
|
+
const json = { username, action, context };
|
|
1084
|
+
if (o["ttl"] !== void 0) json["ttl_seconds"] = o["ttl"];
|
|
1085
|
+
if (card) json["card"] = card;
|
|
1086
|
+
const r = await proxy({ ep: "external/staqon-verify-start", json });
|
|
1087
|
+
show(r, (b) => {
|
|
1088
|
+
ok(`Verification started. verification_id=${b["verification_id"]} expires_at=${b["expires_at"]}`);
|
|
1089
|
+
out(`Check it with: staqon verify check ${b["verification_id"]}`);
|
|
1090
|
+
});
|
|
1091
|
+
});
|
|
1092
|
+
verify.command("check <verification_id>").description("Has the member approved? (verified true/false + reason)").option("--strict", "exit 1 unless the result is verified (pending/declined/timeout/cancelled all exit 1)").action(async (id, o) => {
|
|
1093
|
+
const r = await proxy({ ep: "external/staqon-verify-check", json: { verification_id: id } });
|
|
1094
|
+
show(
|
|
1095
|
+
r,
|
|
1096
|
+
(b) => b["verified"] ? ok("Verified.") : out(`${pc4.yellow("Not verified")}: ${b["reason"]}${b["reason"] === "pending" ? pc4.dim(" (still waiting; check again)") : ""}`)
|
|
1097
|
+
);
|
|
1098
|
+
if (o["strict"] && obj3(r.body)["verified"] !== true) process.exitCode = Exit.GENERAL;
|
|
1099
|
+
});
|
|
1100
|
+
verify.command("cancel <verification_id>").description("Cancel a pending verification").action(async (id) => {
|
|
1101
|
+
try {
|
|
1102
|
+
const r = await proxy({ ep: "external/staqon-verify-cancel", json: { verification_id: id } });
|
|
1103
|
+
show(r, (b) => ok(b["already"] ? "Already cancelled." : "Cancelled."));
|
|
1104
|
+
} catch (e) {
|
|
1105
|
+
if (e instanceof ApiError && e.status === 409 && obj3(e.body)["reason"] === "already_resolved")
|
|
1106
|
+
throw new ApiError(`Too late to cancel: the member already answered (status ${obj3(e.body)["status"]}, verified ${obj3(e.body)["verified"]}).`, Exit.GENERAL, 409, e.body, e.traceId);
|
|
1107
|
+
if (e instanceof ApiError && e.status === 410)
|
|
1108
|
+
throw new ApiError("This verification has already expired.", Exit.GENERAL, 410, e.body, e.traceId);
|
|
1109
|
+
throw e;
|
|
1110
|
+
}
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// src/cli.ts
|
|
1115
|
+
var program = new Command("staqon").description("StaqOn command-line client").version(VERSION).option("--json", "machine-readable output (raw response bodies on stdout, errors as JSON on stderr)").option("--team <team>", "team id or slug to act as (overrides the saved default)").option("--base-url <url>", "API host (default https://staqon.one; also STAQON_BASE_URL)").option("--no-color", "disable colors").option("--verbose", "print request lines and trace ids to stderr").showHelpAfterError().exitOverride();
|
|
1116
|
+
program.hook("preAction", (cmd) => {
|
|
1117
|
+
const o = cmd.optsWithGlobals();
|
|
1118
|
+
ctx.json = Boolean(o.json);
|
|
1119
|
+
ctx.team = o.team;
|
|
1120
|
+
ctx.baseUrl = o.baseUrl;
|
|
1121
|
+
ctx.verbose = Boolean(o.verbose);
|
|
1122
|
+
ctx.interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY) && !ctx.json;
|
|
1123
|
+
});
|
|
1124
|
+
registerAuth(program);
|
|
1125
|
+
registerTyped(program);
|
|
1126
|
+
registerRaw(program);
|
|
1127
|
+
try {
|
|
1128
|
+
await program.parseAsync(process.argv);
|
|
1129
|
+
} catch (e) {
|
|
1130
|
+
const code = e.code;
|
|
1131
|
+
if (code === "commander.helpDisplayed" || code === "commander.version" || code === "commander.help") process.exit(0);
|
|
1132
|
+
if (typeof code === "string" && code.startsWith("commander.")) process.exit(Exit.USAGE);
|
|
1133
|
+
process.exit(reportError(e));
|
|
1134
|
+
}
|