@staqon.one/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.
@@ -0,0 +1,94 @@
1
+ import { proxy, MAX_LIST_LIMIT } from '../api/client.js';
2
+ import { fail, printJson } from '../output.js';
3
+ function teamOpt(cmd) {
4
+ return cmd.option('--team <idOrSlug>', 'team id or slug — only needed if the workspace has more than one eligible team');
5
+ }
6
+ export function registerMiscCommands(program) {
7
+ // ---- Escalation (External surface) ----
8
+ const escalation = program.command('escalation').description('Raise and read escalation matters against team members');
9
+ teamOpt(escalation
10
+ .command('raise')
11
+ .description('Raise an escalation matter against a team member')
12
+ .requiredOption('--email <email>')
13
+ .requiredOption('--title <text>', 'min 2 characters')
14
+ .requiredOption('--description <text>')
15
+ .requiredOption('--due-date <YYYY-MM-DD>', 'must be strictly after today')
16
+ .option('--priority <level>', 'low|medium|high|critical', 'medium')).action(async (opts) => {
17
+ try {
18
+ const body = await proxy('external/escalation', {
19
+ method: 'POST',
20
+ team: opts.team,
21
+ json: {
22
+ email: opts.email,
23
+ title: opts.title,
24
+ description: opts.description,
25
+ due_date: opts.dueDate,
26
+ priority: opts.priority,
27
+ },
28
+ });
29
+ printJson(body);
30
+ }
31
+ catch (e) {
32
+ fail(e);
33
+ }
34
+ });
35
+ teamOpt(escalation
36
+ .command('list')
37
+ .description('List escalation matters raised against this team\'s members (scoped to matters your key can see)')
38
+ .option('--page <n>', 'page number', '1')
39
+ .option('--limit <n>', `1-${MAX_LIST_LIMIT} (default 50)`, '50')
40
+ .option('--status <status>', 'open|in_progress|resolved')).action(async (opts) => {
41
+ try {
42
+ const body = await proxy('external/escalations', {
43
+ method: 'GET',
44
+ team: opts.team,
45
+ query: { page: opts.page, limit: opts.limit, status: opts.status },
46
+ });
47
+ printJson(body);
48
+ }
49
+ catch (e) {
50
+ fail(e);
51
+ }
52
+ });
53
+ teamOpt(escalation
54
+ .command('get')
55
+ .description('Fetch a single escalation matter by id')
56
+ .requiredOption('--matter-id <id>')).action(async (opts) => {
57
+ try {
58
+ const body = await proxy('external/escalations', { method: 'GET', team: opts.team, query: { matter_id: opts.matterId } });
59
+ printJson(body);
60
+ }
61
+ catch (e) {
62
+ fail(e);
63
+ }
64
+ });
65
+ // ---- Status (key context + usage — External and Internal surfaces) ----
66
+ teamOpt(program
67
+ .command('status')
68
+ .description('Key context + usage for this team (External surface) — real-only member counts and rate-limit usage')
69
+ .option('--internal', 'use the Internal surface instead (no guest-role filter)')).action(async (opts) => {
70
+ try {
71
+ const body = await proxy(opts.internal ? 'internal/status' : 'external/status', { method: 'GET', team: opts.team });
72
+ printJson(body);
73
+ }
74
+ catch (e) {
75
+ fail(e);
76
+ }
77
+ });
78
+ // ---- Webhook health (External and Internal surfaces) ----
79
+ teamOpt(program
80
+ .command('webhooks')
81
+ .description('Health of this team\'s webhooks (a webhook auto-disables after 10 consecutive non-2xx deliveries)')
82
+ .option('--internal', 'use the Internal surface instead')).action(async (opts) => {
83
+ try {
84
+ const body = await proxy(opts.internal ? 'internal/webhook-status' : 'external/webhook-status', {
85
+ method: 'GET',
86
+ team: opts.team,
87
+ });
88
+ printJson(body);
89
+ }
90
+ catch (e) {
91
+ fail(e);
92
+ }
93
+ });
94
+ }
@@ -0,0 +1,64 @@
1
+ import { proxy } from '../api/client.js';
2
+ import { fail, printJson } from '../output.js';
3
+ function teamOpt(cmd) {
4
+ return cmd.option('--team <idOrSlug>', 'team id or slug — only needed if the workspace has more than one eligible team');
5
+ }
6
+ export function registerVerifyCommands(program) {
7
+ const verify = program.command('verify').description('e-StaqOn Verify — ask a member to push-approve a sensitive action');
8
+ teamOpt(verify
9
+ .command('start')
10
+ .description('Ask a member to confirm a sensitive action (push approval) — requires an approved, unexpired consent link')
11
+ .requiredOption('--username <username>')
12
+ .requiredOption('--action <text>', 'short label, max 100 chars')
13
+ .requiredOption('--context <text>', 'what the member is approving — shown to them verbatim, max 500 chars')
14
+ .option('--ttl <seconds>', 'how long the request stays open, 30-180 (default 30; out-of-range is clamped, not rejected)')).action(async (opts) => {
15
+ try {
16
+ const body = await proxy('external/staqon-verify-start', {
17
+ method: 'POST',
18
+ team: opts.team,
19
+ json: {
20
+ username: opts.username,
21
+ action: opts.action,
22
+ context: opts.context,
23
+ ttl_seconds: opts.ttl ? Number(opts.ttl) : undefined,
24
+ },
25
+ });
26
+ printJson(body);
27
+ }
28
+ catch (e) {
29
+ fail(e);
30
+ }
31
+ });
32
+ teamOpt(verify
33
+ .command('check')
34
+ .description('Poll the outcome of a verification request — only verified:true means the member approved in time')
35
+ .requiredOption('--id <verification_id>')).action(async (opts) => {
36
+ try {
37
+ const body = await proxy('external/staqon-verify-check', {
38
+ method: 'POST',
39
+ team: opts.team,
40
+ json: { verification_id: opts.id },
41
+ });
42
+ printJson(body);
43
+ }
44
+ catch (e) {
45
+ fail(e);
46
+ }
47
+ });
48
+ teamOpt(verify
49
+ .command('cancel')
50
+ .description('Withdraw a pending verification request — a 409 means the member already answered; that answer stands')
51
+ .requiredOption('--id <verification_id>')).action(async (opts) => {
52
+ try {
53
+ const body = await proxy('external/staqon-verify-cancel', {
54
+ method: 'POST',
55
+ team: opts.team,
56
+ json: { verification_id: opts.id },
57
+ });
58
+ printJson(body);
59
+ }
60
+ catch (e) {
61
+ fail(e);
62
+ }
63
+ });
64
+ }
package/dist/config.js ADDED
@@ -0,0 +1,37 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { mkdir, readFile, writeFile, chmod } from 'node:fs/promises';
4
+ export const BASE_URL = process.env.STAQON_BASE_URL ?? 'https://staqon.one';
5
+ const CONFIG_DIR = join(homedir(), '.staqon');
6
+ const CREDS_PATH = join(CONFIG_DIR, 'credentials.json');
7
+ async function ensureDir() {
8
+ await mkdir(CONFIG_DIR, { recursive: true });
9
+ }
10
+ export async function loadSession() {
11
+ try {
12
+ const raw = await readFile(CREDS_PATH, 'utf8');
13
+ return JSON.parse(raw);
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ export async function saveSession(session) {
20
+ await ensureDir();
21
+ await writeFile(CREDS_PATH, JSON.stringify(session, null, 2), { mode: 0o600 });
22
+ // writeFile's mode option is only honored on file *creation* on some platforms — enforce it explicitly.
23
+ await chmod(CREDS_PATH, 0o600);
24
+ }
25
+ export async function clearSession() {
26
+ await ensureDir();
27
+ try {
28
+ await writeFile(CREDS_PATH, '', { mode: 0o600 });
29
+ await import('node:fs/promises').then((fs) => fs.unlink(CREDS_PATH));
30
+ }
31
+ catch {
32
+ // already gone — fine
33
+ }
34
+ }
35
+ export function credentialsPath() {
36
+ return CREDS_PATH;
37
+ }
package/dist/index.js ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { registerAuthCommands } from './commands/auth.js';
4
+ import { registerMemberCommands } from './commands/members.js';
5
+ import { registerMessagingCommands } from './commands/messaging.js';
6
+ import { registerVerifyCommands } from './commands/verify.js';
7
+ import { registerAdCommands } from './commands/ad.js';
8
+ import { registerMiscCommands } from './commands/misc.js';
9
+ const program = new Command();
10
+ program
11
+ .name('staqon')
12
+ .description('Command-line tool for a StaqOn workspace owner. Sign in with `staqon login` — a device-pairing ' +
13
+ 'flow approved from your StaqOn dashboard. The CLI never holds or sees a raw API key.')
14
+ .version('0.1.0');
15
+ registerAuthCommands(program);
16
+ registerMemberCommands(program);
17
+ registerMessagingCommands(program);
18
+ registerVerifyCommands(program);
19
+ registerAdCommands(program);
20
+ registerMiscCommands(program);
21
+ program.parseAsync(process.argv).catch((e) => {
22
+ console.error('Error:', e instanceof Error ? e.message : e);
23
+ process.exit(1);
24
+ });
package/dist/output.js ADDED
@@ -0,0 +1,24 @@
1
+ import { StaqOnApiError } from './api/client.js';
2
+ export function ok(message) {
3
+ console.log(message);
4
+ }
5
+ export function fail(e) {
6
+ if (e instanceof StaqOnApiError) {
7
+ console.error(`Error: ${e.message}`);
8
+ if (Object.keys(e.body).length) {
9
+ const extra = Object.fromEntries(Object.entries(e.body).filter(([k]) => k !== 'error' && k !== 'ok'));
10
+ if (Object.keys(extra).length)
11
+ console.error(JSON.stringify(extra, null, 2));
12
+ }
13
+ }
14
+ else if (e instanceof Error) {
15
+ console.error(`Error: ${e.message}`);
16
+ }
17
+ else {
18
+ console.error('Error:', e);
19
+ }
20
+ process.exit(1);
21
+ }
22
+ export function printJson(data) {
23
+ console.log(JSON.stringify(data, null, 2));
24
+ }
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "@staqon.one/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Command-line tool for StaqOn workspace owners — send messages, post ads, run e-StaqOn Verify approvals, manage external/internal team members, and raise escalations, all without ever handling a raw API key.",
5
+ "bin": {
6
+ "staqon": "./dist/index.js"
7
+ },
8
+ "type": "module",
9
+ "main": "dist/index.js",
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "dev": "tsx src/index.ts",
13
+ "prepublishOnly": "npm run build"
14
+ },
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "dependencies": {
19
+ "commander": "^12.1.0",
20
+ "open": "^10.1.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^20.14.0",
24
+ "tsx": "^4.16.0",
25
+ "typescript": "^5.5.0"
26
+ },
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/aroraashish292-creator/staqon-cli.git"
31
+ }
32
+ }
@@ -0,0 +1,84 @@
1
+ import { BASE_URL, loadSession, saveSession, clearSession } from '../config.js';
2
+
3
+ export class StaqOnApiError extends Error {
4
+ constructor(
5
+ public status: number,
6
+ public body: Record<string, unknown>,
7
+ ) {
8
+ super(typeof body.error === 'string' ? body.error : `HTTP ${status}`);
9
+ }
10
+ }
11
+
12
+ /**
13
+ * The real, server-enforced ceiling for every paginated CLI list endpoint
14
+ * (members.php, internal/members.php, messages.php): Swagger says
15
+ * `maximum: 200` on all three. The OLD cli had a client-side MAX_LIST_LIMIT
16
+ * of 100 — that was a self-imposed, unnecessarily tight cap, not a server
17
+ * fact. Verified against openapi.yaml this session (Nov 11 2026).
18
+ */
19
+ export const MAX_LIST_LIMIT = 200;
20
+
21
+ interface CallOptions {
22
+ method?: 'GET' | 'POST';
23
+ query?: Record<string, string | number | undefined>;
24
+ json?: Record<string, unknown>;
25
+ team?: string; // X-StaqOn-Team — only needed when a workspace has 2+ eligible teams for that command's family
26
+ }
27
+
28
+ function buildQuery(params: Record<string, string | number | undefined> = {}): string {
29
+ const qs = new URLSearchParams();
30
+ for (const [k, v] of Object.entries(params)) {
31
+ if (v !== undefined) qs.set(k, String(v));
32
+ }
33
+ const s = qs.toString();
34
+ return s ? `?${s}` : '';
35
+ }
36
+
37
+ /**
38
+ * The CLI's ONE front door, matching /api/v1/cli-proxy.php exactly:
39
+ * GET|POST /api/v1/cli-proxy.php?_ep=<name>&...their own query
40
+ * Authorization: Bearer stq_cli_<64 hex>
41
+ * X-StaqOn-Team: <team id or slug> (only when the workspace has >1 eligible team)
42
+ * Never sends X-API-Key — the proxy drops it server-side anyway; the CLI
43
+ * never holds, or needs, a raw tenant key.
44
+ */
45
+ export async function proxy(ep: string, opts: CallOptions = {}): Promise<Record<string, unknown>> {
46
+ const session = await loadSession();
47
+ if (!session) {
48
+ throw new StaqOnApiError(401, { error: 'Not logged in. Run: staqon login' });
49
+ }
50
+
51
+ const method = opts.method ?? (opts.json ? 'POST' : 'GET');
52
+ const url = `${BASE_URL}/api/v1/cli-proxy.php${buildQuery({ _ep: ep, ...opts.query })}`;
53
+
54
+ const headers: Record<string, string> = {
55
+ Authorization: `Bearer ${session.token}`,
56
+ };
57
+ if (opts.team) headers['X-StaqOn-Team'] = opts.team;
58
+ if (opts.json) headers['Content-Type'] = 'application/json';
59
+
60
+ const res = await fetch(url, {
61
+ method,
62
+ headers,
63
+ body: opts.json ? JSON.stringify(opts.json) : undefined,
64
+ });
65
+
66
+ const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
67
+
68
+ if (res.status === 401) {
69
+ // Session is gone/expired/revoked server-side — don't leave a dead token lying around locally.
70
+ await clearSession();
71
+ throw new StaqOnApiError(401, {
72
+ error: (body.error as string) ?? 'This CLI session is missing, expired or revoked. Run: staqon login',
73
+ });
74
+ }
75
+ if (!res.ok) throw new StaqOnApiError(res.status, body);
76
+ return body;
77
+ }
78
+
79
+ /** `_ep=whoami` — no backing file; the proxy answers directly with session/workspace/limits/access. */
80
+ export async function whoami(): Promise<Record<string, unknown>> {
81
+ return proxy('whoami', { method: 'GET' });
82
+ }
83
+
84
+ export { BASE_URL, loadSession, saveSession, clearSession };
package/src/auth.ts ADDED
@@ -0,0 +1,132 @@
1
+ import { BASE_URL, saveSession, clearSession, loadSession } from './config.js';
2
+
3
+ const CLI_VERSION = '0.1.0'; // keep in sync with package.json
4
+
5
+ interface CodeRequestResponse {
6
+ ok: true;
7
+ pair_id: string;
8
+ code: string; // shown as "XXXX-XXXX"
9
+ verification_url: string;
10
+ poll_secret: string;
11
+ expires_in: number; // seconds (600 server-side, i.e. 10 min)
12
+ interval: number; // seconds between polls (3 server-side)
13
+ }
14
+
15
+ type PollStatus = 'pending' | 'approved' | 'denied' | 'expired' | 'consumed';
16
+
17
+ interface PollResponse {
18
+ ok: boolean;
19
+ status: PollStatus;
20
+ token?: string; // present only on the one successful 'approved' poll — shown once
21
+ workspace?: { id: number; slug: string; name: string };
22
+ expires_at?: string;
23
+ error?: string;
24
+ }
25
+
26
+ async function postJson<T>(path: string, body: Record<string, unknown>): Promise<T> {
27
+ const res = await fetch(`${BASE_URL}/api/cli-pair.php?action=${path}`, {
28
+ method: 'POST',
29
+ headers: { 'Content-Type': 'application/json' },
30
+ body: JSON.stringify(body),
31
+ });
32
+ const data = (await res.json()) as T & { error?: string };
33
+ if (!res.ok || (data as { ok?: boolean }).ok === false) {
34
+ throw new Error((data as { error?: string }).error ?? `Login failed (HTTP ${res.status})`);
35
+ }
36
+ return data;
37
+ }
38
+
39
+ function osLabel(): string {
40
+ const p = process.platform;
41
+ if (p === 'darwin') return 'macOS';
42
+ if (p === 'win32') return 'Windows';
43
+ if (p === 'linux') return 'Linux';
44
+ return p;
45
+ }
46
+
47
+ function sleep(ms: number): Promise<void> {
48
+ return new Promise((r) => setTimeout(r, ms));
49
+ }
50
+
51
+ /**
52
+ * Device-pairing login, matching api/cli-pair.php exactly:
53
+ * 1. code_request -> {pair_id, code, verification_url, poll_secret, expires_in, interval}
54
+ * 2. show the code + verification_url to the user
55
+ * 3. poll {pair_id, poll_secret} every `interval` seconds until approved/denied/expired
56
+ * 4. on 'approved', the token is minted and returned EXACTLY ONCE on that poll response —
57
+ * save it immediately; a repeat poll after that returns 'consumed', not the token again.
58
+ *
59
+ * No API key is ever involved. The workspace owner approves the device at
60
+ * <verification_url> (StaqOn's own /cli page), picking which workspace and,
61
+ * if their web session is stale, re-entering their MPIN — all server-side.
62
+ */
63
+ export async function login(deviceLabel?: string): Promise<{ workspace: { id: number; slug: string; name: string } }> {
64
+ const label = deviceLabel ?? `${osLabel()} computer`;
65
+
66
+ const start = await postJson<CodeRequestResponse>('code_request', {
67
+ device_label: label,
68
+ os: osLabel(),
69
+ cli_version: CLI_VERSION,
70
+ });
71
+
72
+ console.log('');
73
+ console.log(` To finish signing in, visit: ${start.verification_url}`);
74
+ console.log(` And enter this code: ${start.code}`);
75
+ console.log('');
76
+ console.log(' Waiting for approval (this code expires in 10 minutes)...');
77
+
78
+ const deadline = Date.now() + start.expires_in * 1000;
79
+ const intervalMs = Math.max(start.interval, 1) * 1000;
80
+
81
+ while (Date.now() < deadline) {
82
+ await sleep(intervalMs);
83
+ const poll = await postJson<PollResponse>('poll', {
84
+ pair_id: start.pair_id,
85
+ poll_secret: start.poll_secret,
86
+ });
87
+
88
+ if (poll.status === 'pending') continue;
89
+
90
+ if (poll.status === 'approved' && poll.token && poll.workspace) {
91
+ await saveSession({
92
+ token: poll.token,
93
+ workspace: poll.workspace,
94
+ expires_at: poll.expires_at ?? '',
95
+ paired_at: new Date().toISOString(),
96
+ });
97
+ return { workspace: poll.workspace };
98
+ }
99
+
100
+ if (poll.status === 'denied') throw new Error('Login request was denied.');
101
+ if (poll.status === 'expired') throw new Error('Login code expired. Run: staqon login');
102
+ if (poll.status === 'consumed') throw new Error('This login code was already used. Run: staqon login');
103
+
104
+ throw new Error(poll.error ?? `Unexpected login status: ${poll.status}`);
105
+ }
106
+
107
+ throw new Error('Login timed out. Run: staqon login');
108
+ }
109
+
110
+ /**
111
+ * Rotates the current session token via ?action=refresh. The OLD token
112
+ * stays valid for 5 more minutes server-side (CLI_ROTATE_GRACE=300), so a
113
+ * failed save here doesn't strand the user mid-rotation.
114
+ */
115
+ export async function refresh(): Promise<void> {
116
+ const session = await loadSession();
117
+ if (!session) throw new Error('Not logged in. Run: staqon login');
118
+
119
+ const res = await fetch(`${BASE_URL}/api/cli-pair.php?action=refresh`, {
120
+ method: 'POST',
121
+ headers: { Authorization: `Bearer ${session.token}` },
122
+ });
123
+ const data = (await res.json()) as { ok: boolean; token?: string; error?: string };
124
+ if (!res.ok || !data.ok || !data.token) {
125
+ throw new Error(data.error ?? 'Could not refresh. Run: staqon login');
126
+ }
127
+ await saveSession({ ...session, token: data.token });
128
+ }
129
+
130
+ export async function logout(): Promise<void> {
131
+ await clearSession();
132
+ }
@@ -0,0 +1,100 @@
1
+ import { Command } from 'commander';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { proxy, BASE_URL, loadSession } from '../api/client.js';
4
+ import { fail, printJson } from '../output.js';
5
+
6
+ function teamOpt(cmd: Command): Command {
7
+ return cmd.option('--team <idOrSlug>', 'team id or slug — only needed if the workspace has more than one eligible team');
8
+ }
9
+
10
+ export function registerAdCommands(program: Command): void {
11
+ const ad = program.command('ad').description('Advertisement API — send a card to every member thread of a team');
12
+
13
+ teamOpt(
14
+ ad
15
+ .command('upload-image')
16
+ .description('Upload one banner image (JPEG/PNG/WebP, max 300KB) and get back an image_url for `ad send --image-url`')
17
+ .requiredOption('--file <path>', 'local path to the image'),
18
+ ).action(async (opts: { file: string; team?: string }) => {
19
+ try {
20
+ const session = await loadSession();
21
+ if (!session) throw new Error('Not logged in. Run: staqon login');
22
+ const data = await readFile(opts.file);
23
+ const form = new FormData();
24
+ form.append('image', new Blob([data]), opts.file.split('/').pop() ?? 'image');
25
+
26
+ const headers: Record<string, string> = { Authorization: `Bearer ${session.token}` };
27
+ if (opts.team) headers['X-StaqOn-Team'] = opts.team;
28
+
29
+ const res = await fetch(`${BASE_URL}/api/v1/cli-proxy.php?_ep=external/ad-image`, {
30
+ method: 'POST',
31
+ headers,
32
+ body: form,
33
+ });
34
+ const body = (await res.json()) as Record<string, unknown>;
35
+ if (!res.ok) throw new Error(typeof body.error === 'string' ? body.error : `HTTP ${res.status}`);
36
+ printJson(body);
37
+ } catch (e) {
38
+ fail(e);
39
+ }
40
+ });
41
+
42
+ teamOpt(
43
+ ad
44
+ .command('send')
45
+ .description(
46
+ 'Queue an ad card to every active thread of the team — asynchronous: returns 202 immediately, poll `ad status` for progress',
47
+ )
48
+ .requiredOption('--body <text>', '50-900 characters')
49
+ .requiredOption('--button-label <text>', '1-30 characters')
50
+ .requiredOption('--button-url <url>', 'must start with https://')
51
+ .option('--image-url <url>', 'from `ad upload-image`, same workspace+team')
52
+ .option('--external-ref <ref>', 'idempotency key — repeating it returns the original send\'s counts, sends nothing twice'),
53
+ ).action(
54
+ async (opts: {
55
+ body: string;
56
+ buttonLabel: string;
57
+ buttonUrl: string;
58
+ imageUrl?: string;
59
+ externalRef?: string;
60
+ team?: string;
61
+ }) => {
62
+ try {
63
+ const body = await proxy('external/ad', {
64
+ method: 'POST',
65
+ team: opts.team,
66
+ json: {
67
+ body: opts.body,
68
+ button_label: opts.buttonLabel,
69
+ button_url: opts.buttonUrl,
70
+ image_url: opts.imageUrl,
71
+ external_ref: opts.externalRef,
72
+ },
73
+ });
74
+ printJson(body);
75
+ } catch (e) {
76
+ fail(e);
77
+ }
78
+ },
79
+ );
80
+
81
+ teamOpt(
82
+ ad
83
+ .command('status')
84
+ .description('Progress or final result of one ad send — look up by external_ref or ad_request_id')
85
+ .option('--external-ref <ref>')
86
+ .option('--request-id <id>'),
87
+ ).action(async (opts: { externalRef?: string; requestId?: string; team?: string }) => {
88
+ if (!opts.externalRef && !opts.requestId) fail(new Error('Pass --external-ref or --request-id.'));
89
+ try {
90
+ const body = await proxy('external/ad-status', {
91
+ method: 'GET',
92
+ team: opts.team,
93
+ query: { external_ref: opts.externalRef, ad_request_id: opts.requestId },
94
+ });
95
+ printJson(body);
96
+ } catch (e) {
97
+ fail(e);
98
+ }
99
+ });
100
+ }
@@ -0,0 +1,39 @@
1
+ import { Command } from 'commander';
2
+ import { login, logout } from '../auth.js';
3
+ import { whoami as fetchWhoami } from '../api/client.js';
4
+ import { ok, fail } from '../output.js';
5
+
6
+ export function registerAuthCommands(program: Command): void {
7
+ program
8
+ .command('login')
9
+ .description('Pair this computer with a StaqOn workspace (device-code flow — no API key needed)')
10
+ .option('--label <name>', 'a name for this computer, shown to the workspace owner when they approve')
11
+ .action(async (opts: { label?: string }) => {
12
+ try {
13
+ const { workspace } = await login(opts.label);
14
+ ok(`Paired with workspace "${workspace.name}" (${workspace.slug}).`);
15
+ } catch (e) {
16
+ fail(e);
17
+ }
18
+ });
19
+
20
+ program
21
+ .command('logout')
22
+ .description('Forget this computer\'s paired session (does not revoke it on the server — use the dashboard for that)')
23
+ .action(async () => {
24
+ await logout();
25
+ ok('Logged out locally. The server-side session, if still live, can be revoked from the workspace\'s CLI access page.');
26
+ });
27
+
28
+ program
29
+ .command('whoami')
30
+ .description('Show the paired workspace, session info, rate-limit usage, and which commands are available')
31
+ .action(async () => {
32
+ try {
33
+ const body = await fetchWhoami();
34
+ console.log(JSON.stringify(body, null, 2));
35
+ } catch (e) {
36
+ fail(e);
37
+ }
38
+ });
39
+ }