@staqon.one/cli 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,130 @@
1
+ import { Command } from 'commander';
2
+ import { proxy, MAX_LIST_LIMIT } from '../api/client.js';
3
+ import { fail, printJson, ok } from '../output.js';
4
+
5
+ function teamOpt(cmd: Command): Command {
6
+ return cmd.option('--team <idOrSlug>', 'team id or slug — only needed if the workspace has more than one eligible team');
7
+ }
8
+
9
+ export function registerMemberCommands(program: Command): void {
10
+ const members = program.command('members').description('Manage external team members');
11
+
12
+ teamOpt(
13
+ members
14
+ .command('list')
15
+ .description('List this team\'s external (guest) members')
16
+ .option('--page <n>', 'page number', '1')
17
+ .option('--limit <n>', `page size, 1-${MAX_LIST_LIMIT} (default 50)`, '50')
18
+ .option('--search <text>', 'match against name or email'),
19
+ ).action(async (opts: { page: string; limit: string; search?: string; team?: string }) => {
20
+ try {
21
+ const body = await proxy('external/members', {
22
+ method: 'GET',
23
+ team: opts.team,
24
+ query: { page: opts.page, limit: opts.limit, search: opts.search },
25
+ });
26
+ printJson(body);
27
+ } catch (e) {
28
+ fail(e);
29
+ }
30
+ });
31
+
32
+ teamOpt(
33
+ members
34
+ .command('get')
35
+ .description('Get one external member, by email or member_id')
36
+ .option('--email <email>')
37
+ .option('--member-id <id>'),
38
+ ).action(async (opts: { email?: string; memberId?: string; team?: string }) => {
39
+ try {
40
+ const body = await proxy('external/member', {
41
+ method: 'GET',
42
+ team: opts.team,
43
+ query: { email: opts.email, member_id: opts.memberId },
44
+ });
45
+ printJson(body);
46
+ } catch (e) {
47
+ fail(e);
48
+ }
49
+ });
50
+
51
+ teamOpt(
52
+ members
53
+ .command('add')
54
+ .description('Add (or re-add) a member to this external team')
55
+ .requiredOption('--name <name>')
56
+ .requiredOption('--email <email>'),
57
+ // NOTE: no --phone flag. add-member.php's request schema is {name, email} only (both
58
+ // required) — verified against openapi.yaml this session. The old CLI sent a --phone
59
+ // value the server silently ignored; that flag is dropped here rather than kept as a
60
+ // no-op, so nobody mistakes it for working.
61
+ ).action(async (opts: { name: string; email: string; team?: string }) => {
62
+ try {
63
+ const body = await proxy('external/add-member', {
64
+ method: 'POST',
65
+ team: opts.team,
66
+ json: { name: opts.name, email: opts.email },
67
+ });
68
+ printJson(body);
69
+ } catch (e) {
70
+ fail(e);
71
+ }
72
+ });
73
+
74
+ teamOpt(
75
+ members
76
+ .command('remove')
77
+ .description('Remove a member from this external team (archives their thread; history kept)')
78
+ .requiredOption('--email <email>'),
79
+ ).action(async (opts: { email: string; team?: string }) => {
80
+ try {
81
+ const body = await proxy('external/remove-member', {
82
+ method: 'POST',
83
+ team: opts.team,
84
+ json: { email: opts.email },
85
+ });
86
+ printJson(body);
87
+ } catch (e) {
88
+ fail(e);
89
+ }
90
+ });
91
+
92
+ teamOpt(
93
+ members
94
+ .command('teams')
95
+ .description('Which external teams has a linked member joined? (looked up by external_ref)')
96
+ .requiredOption('--external-ref <ref>', 'the external_ref originally sent to link-invite'),
97
+ ).action(async (opts: { externalRef: string; team?: string }) => {
98
+ try {
99
+ const body = await proxy('external/member-teams', {
100
+ method: 'GET',
101
+ team: opts.team,
102
+ query: { external_ref: opts.externalRef },
103
+ });
104
+ printJson(body);
105
+ } catch (e) {
106
+ fail(e);
107
+ }
108
+ });
109
+
110
+ // Internal-surface member listing — same family, different endpoint/scope (no guest filter, reports role not message stats).
111
+ teamOpt(
112
+ program
113
+ .command('internal-members')
114
+ .description('List this key\'s internal team members (role-based, no external thread stats)')
115
+ .option('--page <n>', 'page number', '1')
116
+ .option('--limit <n>', `page size, 1-${MAX_LIST_LIMIT} (default 50)`, '50')
117
+ .option('--search <text>', 'match against name, email or username'),
118
+ ).action(async (opts: { page: string; limit: string; search?: string; team?: string }) => {
119
+ try {
120
+ const body = await proxy('internal/members', {
121
+ method: 'GET',
122
+ team: opts.team,
123
+ query: { page: opts.page, limit: opts.limit, search: opts.search },
124
+ });
125
+ printJson(body);
126
+ } catch (e) {
127
+ fail(e);
128
+ }
129
+ });
130
+ }
@@ -0,0 +1,116 @@
1
+ import { Command } from 'commander';
2
+ import { proxy, MAX_LIST_LIMIT } from '../api/client.js';
3
+ import { fail, printJson } from '../output.js';
4
+
5
+ function teamOpt(cmd: Command): Command {
6
+ return cmd.option('--team <idOrSlug>', 'team id or slug — only needed if the workspace has more than one eligible team');
7
+ }
8
+
9
+ export function registerMessagingCommands(program: Command): void {
10
+ const messages = program.command('messages').description('Send and read External team member conversations');
11
+
12
+ teamOpt(
13
+ messages
14
+ .command('send')
15
+ .description('Send a message into a member\'s thread, as the workspace owner')
16
+ .requiredOption('--email <email>')
17
+ .requiredOption('--message <text>', 'max 60000 characters'),
18
+ ).action(async (opts: { email: string; message: string; team?: string }) => {
19
+ try {
20
+ const body = await proxy('external/message', {
21
+ method: 'POST',
22
+ team: opts.team,
23
+ json: { email: opts.email, message: opts.message },
24
+ });
25
+ printJson(body);
26
+ } catch (e) {
27
+ fail(e);
28
+ }
29
+ });
30
+
31
+ teamOpt(
32
+ messages
33
+ .command('comment')
34
+ .description(
35
+ 'Post a comment into a member\'s thread, attributed to the workspace (no consent link needed — writing in is the workspace\'s own authority)',
36
+ )
37
+ .requiredOption('--username <username>')
38
+ .requiredOption('--comment <text>', 'max 60000 characters')
39
+ .option(
40
+ '--external-ref <ref>',
41
+ 'idempotency key, max 190 chars — scoped to the WHOLE WORKSPACE, not per recipient; reusing it for a different recipient silently does not reach them',
42
+ ),
43
+ ).action(async (opts: { username: string; comment: string; externalRef?: string; team?: string }) => {
44
+ try {
45
+ const body = await proxy('external/comment', {
46
+ method: 'POST',
47
+ team: opts.team,
48
+ json: { username: opts.username, comment: opts.comment, external_ref: opts.externalRef },
49
+ });
50
+ printJson(body);
51
+ } catch (e) {
52
+ fail(e);
53
+ }
54
+ });
55
+
56
+ teamOpt(
57
+ messages
58
+ .command('read')
59
+ .description('Read a member\'s thread history — requires an APPROVED consent link for that member (403 if not present)')
60
+ .requiredOption('--username <username>')
61
+ .option('--limit <n>', `1-${MAX_LIST_LIMIT} (default 50)`, '50')
62
+ .option('--before <id>', 'message id cursor — older messages, newest-first')
63
+ .option('--after <id>', 'message id cursor — newer messages, oldest-first (mutually exclusive with --before)'),
64
+ ).action(async (opts: { username: string; limit: string; before?: string; after?: string; team?: string }) => {
65
+ if (opts.before && opts.after) fail(new Error('Use either --before or --after, not both.'));
66
+ try {
67
+ const body = await proxy('external/messages', {
68
+ method: 'GET',
69
+ team: opts.team,
70
+ query: { username: opts.username, limit: opts.limit, before: opts.before, after: opts.after },
71
+ });
72
+ printJson(body);
73
+ } catch (e) {
74
+ fail(e);
75
+ }
76
+ });
77
+
78
+ // Internal surface: one-way feed push, NOT a conversation (no replies/reactions/attachments).
79
+ // Requires an APPROVED link_type='internal' consent row (stricter than External's write path).
80
+ teamOpt(
81
+ program
82
+ .command('internal-message')
83
+ .description('Push a one-way message into a member\'s API Messages feed (internal surface — requires member consent)')
84
+ .requiredOption('--username <username>')
85
+ .requiredOption('--message <text>', 'max 60000 characters')
86
+ .option('--title <text>', 'max 200 characters')
87
+ .option('--link-url <url>', 'must be http(s)')
88
+ .option('--external-ref <ref>', 'idempotency key, max 190 chars — scoped per API key, not per recipient'),
89
+ ).action(
90
+ async (opts: {
91
+ username: string;
92
+ message: string;
93
+ title?: string;
94
+ linkUrl?: string;
95
+ externalRef?: string;
96
+ team?: string;
97
+ }) => {
98
+ try {
99
+ const body = await proxy('internal/message', {
100
+ method: 'POST',
101
+ team: opts.team,
102
+ json: {
103
+ username: opts.username,
104
+ message: opts.message,
105
+ title: opts.title,
106
+ link_url: opts.linkUrl,
107
+ external_ref: opts.externalRef,
108
+ },
109
+ });
110
+ printJson(body);
111
+ } catch (e) {
112
+ fail(e);
113
+ }
114
+ },
115
+ );
116
+ }
@@ -0,0 +1,109 @@
1
+ import { Command } from 'commander';
2
+ import { proxy, MAX_LIST_LIMIT } from '../api/client.js';
3
+ import { fail, printJson } from '../output.js';
4
+
5
+ function teamOpt(cmd: Command): Command {
6
+ return cmd.option('--team <idOrSlug>', 'team id or slug — only needed if the workspace has more than one eligible team');
7
+ }
8
+
9
+ export function registerMiscCommands(program: Command): void {
10
+ // ---- Escalation (External surface) ----
11
+ const escalation = program.command('escalation').description('Raise and read escalation matters against team members');
12
+
13
+ teamOpt(
14
+ escalation
15
+ .command('raise')
16
+ .description('Raise an escalation matter against a team member')
17
+ .requiredOption('--email <email>')
18
+ .requiredOption('--title <text>', 'min 2 characters')
19
+ .requiredOption('--description <text>')
20
+ .requiredOption('--due-date <YYYY-MM-DD>', 'must be strictly after today')
21
+ .option('--priority <level>', 'low|medium|high|critical', 'medium'),
22
+ ).action(
23
+ async (opts: { email: string; title: string; description: string; dueDate: string; priority: string; team?: string }) => {
24
+ try {
25
+ const body = await proxy('external/escalation', {
26
+ method: 'POST',
27
+ team: opts.team,
28
+ json: {
29
+ email: opts.email,
30
+ title: opts.title,
31
+ description: opts.description,
32
+ due_date: opts.dueDate,
33
+ priority: opts.priority,
34
+ },
35
+ });
36
+ printJson(body);
37
+ } catch (e) {
38
+ fail(e);
39
+ }
40
+ },
41
+ );
42
+
43
+ teamOpt(
44
+ escalation
45
+ .command('list')
46
+ .description('List escalation matters raised against this team\'s members (scoped to matters your key can see)')
47
+ .option('--page <n>', 'page number', '1')
48
+ .option('--limit <n>', `1-${MAX_LIST_LIMIT} (default 50)`, '50')
49
+ .option('--status <status>', 'open|in_progress|resolved'),
50
+ ).action(async (opts: { page: string; limit: string; status?: string; team?: string }) => {
51
+ try {
52
+ const body = await proxy('external/escalations', {
53
+ method: 'GET',
54
+ team: opts.team,
55
+ query: { page: opts.page, limit: opts.limit, status: opts.status },
56
+ });
57
+ printJson(body);
58
+ } catch (e) {
59
+ fail(e);
60
+ }
61
+ });
62
+
63
+ teamOpt(
64
+ escalation
65
+ .command('get')
66
+ .description('Fetch a single escalation matter by id')
67
+ .requiredOption('--matter-id <id>'),
68
+ ).action(async (opts: { matterId: string; team?: string }) => {
69
+ try {
70
+ const body = await proxy('external/escalations', { method: 'GET', team: opts.team, query: { matter_id: opts.matterId } });
71
+ printJson(body);
72
+ } catch (e) {
73
+ fail(e);
74
+ }
75
+ });
76
+
77
+ // ---- Status (key context + usage — External and Internal surfaces) ----
78
+ teamOpt(
79
+ program
80
+ .command('status')
81
+ .description('Key context + usage for this team (External surface) — real-only member counts and rate-limit usage')
82
+ .option('--internal', 'use the Internal surface instead (no guest-role filter)'),
83
+ ).action(async (opts: { internal?: boolean; team?: string }) => {
84
+ try {
85
+ const body = await proxy(opts.internal ? 'internal/status' : 'external/status', { method: 'GET', team: opts.team });
86
+ printJson(body);
87
+ } catch (e) {
88
+ fail(e);
89
+ }
90
+ });
91
+
92
+ // ---- Webhook health (External and Internal surfaces) ----
93
+ teamOpt(
94
+ program
95
+ .command('webhooks')
96
+ .description('Health of this team\'s webhooks (a webhook auto-disables after 10 consecutive non-2xx deliveries)')
97
+ .option('--internal', 'use the Internal surface instead'),
98
+ ).action(async (opts: { internal?: boolean; team?: string }) => {
99
+ try {
100
+ const body = await proxy(opts.internal ? 'internal/webhook-status' : 'external/webhook-status', {
101
+ method: 'GET',
102
+ team: opts.team,
103
+ });
104
+ printJson(body);
105
+ } catch (e) {
106
+ fail(e);
107
+ }
108
+ });
109
+ }
@@ -0,0 +1,75 @@
1
+ import { Command } from 'commander';
2
+ import { proxy } from '../api/client.js';
3
+ import { fail, printJson } from '../output.js';
4
+
5
+ function teamOpt(cmd: Command): Command {
6
+ return cmd.option('--team <idOrSlug>', 'team id or slug — only needed if the workspace has more than one eligible team');
7
+ }
8
+
9
+ export function registerVerifyCommands(program: Command): void {
10
+ const verify = program.command('verify').description('e-StaqOn Verify — ask a member to push-approve a sensitive action');
11
+
12
+ teamOpt(
13
+ verify
14
+ .command('start')
15
+ .description('Ask a member to confirm a sensitive action (push approval) — requires an approved, unexpired consent link')
16
+ .requiredOption('--username <username>')
17
+ .requiredOption('--action <text>', 'short label, max 100 chars')
18
+ .requiredOption('--context <text>', 'what the member is approving — shown to them verbatim, max 500 chars')
19
+ .option('--ttl <seconds>', 'how long the request stays open, 30-180 (default 30; out-of-range is clamped, not rejected)'),
20
+ ).action(
21
+ async (opts: { username: string; action: string; context: string; ttl?: string; team?: string }) => {
22
+ try {
23
+ const body = await proxy('external/staqon-verify-start', {
24
+ method: 'POST',
25
+ team: opts.team,
26
+ json: {
27
+ username: opts.username,
28
+ action: opts.action,
29
+ context: opts.context,
30
+ ttl_seconds: opts.ttl ? Number(opts.ttl) : undefined,
31
+ },
32
+ });
33
+ printJson(body);
34
+ } catch (e) {
35
+ fail(e);
36
+ }
37
+ },
38
+ );
39
+
40
+ teamOpt(
41
+ verify
42
+ .command('check')
43
+ .description('Poll the outcome of a verification request — only verified:true means the member approved in time')
44
+ .requiredOption('--id <verification_id>'),
45
+ ).action(async (opts: { id: string; team?: string }) => {
46
+ try {
47
+ const body = await proxy('external/staqon-verify-check', {
48
+ method: 'POST',
49
+ team: opts.team,
50
+ json: { verification_id: opts.id },
51
+ });
52
+ printJson(body);
53
+ } catch (e) {
54
+ fail(e);
55
+ }
56
+ });
57
+
58
+ teamOpt(
59
+ verify
60
+ .command('cancel')
61
+ .description('Withdraw a pending verification request — a 409 means the member already answered; that answer stands')
62
+ .requiredOption('--id <verification_id>'),
63
+ ).action(async (opts: { id: string; team?: string }) => {
64
+ try {
65
+ const body = await proxy('external/staqon-verify-cancel', {
66
+ method: 'POST',
67
+ team: opts.team,
68
+ json: { verification_id: opts.id },
69
+ });
70
+ printJson(body);
71
+ } catch (e) {
72
+ fail(e);
73
+ }
74
+ });
75
+ }
package/src/config.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { mkdir, readFile, writeFile, chmod } from 'node:fs/promises';
4
+
5
+ export const BASE_URL = process.env.STAQON_BASE_URL ?? 'https://staqon.one';
6
+ const CONFIG_DIR = join(homedir(), '.staqon');
7
+ const CREDS_PATH = join(CONFIG_DIR, 'credentials.json');
8
+
9
+ export interface StoredSession {
10
+ token: string; // stq_cli_<64 hex> — the CLI's own session token, never a tenant API key
11
+ workspace: { id: number; slug: string; name: string };
12
+ expires_at: string; // informational only; the server is the real authority on expiry
13
+ paired_at: string;
14
+ }
15
+
16
+ async function ensureDir(): Promise<void> {
17
+ await mkdir(CONFIG_DIR, { recursive: true });
18
+ }
19
+
20
+ export async function loadSession(): Promise<StoredSession | null> {
21
+ try {
22
+ const raw = await readFile(CREDS_PATH, 'utf8');
23
+ return JSON.parse(raw) as StoredSession;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ export async function saveSession(session: StoredSession): Promise<void> {
30
+ await ensureDir();
31
+ await writeFile(CREDS_PATH, JSON.stringify(session, null, 2), { mode: 0o600 });
32
+ // writeFile's mode option is only honored on file *creation* on some platforms — enforce it explicitly.
33
+ await chmod(CREDS_PATH, 0o600);
34
+ }
35
+
36
+ export async function clearSession(): Promise<void> {
37
+ await ensureDir();
38
+ try {
39
+ await writeFile(CREDS_PATH, '', { mode: 0o600 });
40
+ await import('node:fs/promises').then((fs) => fs.unlink(CREDS_PATH));
41
+ } catch {
42
+ // already gone — fine
43
+ }
44
+ }
45
+
46
+ export function credentialsPath(): string {
47
+ return CREDS_PATH;
48
+ }
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
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
+
10
+ const program = new Command();
11
+
12
+ program
13
+ .name('staqon')
14
+ .description(
15
+ 'Command-line tool for a StaqOn workspace owner. Sign in with `staqon login` — a device-pairing ' +
16
+ 'flow approved from your StaqOn dashboard. The CLI never holds or sees a raw API key.',
17
+ )
18
+ .version('0.1.0');
19
+
20
+ registerAuthCommands(program);
21
+ registerMemberCommands(program);
22
+ registerMessagingCommands(program);
23
+ registerVerifyCommands(program);
24
+ registerAdCommands(program);
25
+ registerMiscCommands(program);
26
+
27
+ program.parseAsync(process.argv).catch((e) => {
28
+ console.error('Error:', e instanceof Error ? e.message : e);
29
+ process.exit(1);
30
+ });
package/src/output.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { StaqOnApiError } from './api/client.js';
2
+
3
+ export function ok(message: string): void {
4
+ console.log(message);
5
+ }
6
+
7
+ export function fail(e: unknown): never {
8
+ if (e instanceof StaqOnApiError) {
9
+ console.error(`Error: ${e.message}`);
10
+ if (Object.keys(e.body).length) {
11
+ const extra = Object.fromEntries(Object.entries(e.body).filter(([k]) => k !== 'error' && k !== 'ok'));
12
+ if (Object.keys(extra).length) console.error(JSON.stringify(extra, null, 2));
13
+ }
14
+ } else if (e instanceof Error) {
15
+ console.error(`Error: ${e.message}`);
16
+ } else {
17
+ console.error('Error:', e);
18
+ }
19
+ process.exit(1);
20
+ }
21
+
22
+ export function printJson(data: unknown): void {
23
+ console.log(JSON.stringify(data, null, 2));
24
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "declaration": false,
12
+ "resolveJsonModule": true
13
+ },
14
+ "include": ["src/**/*"]
15
+ }