@rerout/cli 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/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # @rerout/cli
2
+
3
+ Branded short links, QR codes, and analytics from your terminal — a thin CLI over
4
+ [`@rerout/sdk`](https://www.npmjs.com/package/@rerout/sdk).
5
+
6
+ ```bash
7
+ npm install -g @rerout/cli
8
+ ```
9
+
10
+ ## Authenticate
11
+
12
+ Mint a project API key in the dashboard (Owner/Admin), then:
13
+
14
+ ```bash
15
+ rerout login --api-key rrk_...
16
+ ```
17
+
18
+ The key is verified against the API and stored at
19
+ `$XDG_CONFIG_HOME/rerout/config.json` (default `~/.config/rerout/config.json`)
20
+ with `0600` permissions. Alternatively set `REROUT_API_KEY` in the environment
21
+ (it takes precedence over the saved key). Use `--base-url` / `REROUT_BASE_URL`
22
+ for staging or self-hosted APIs.
23
+
24
+ ## Commands
25
+
26
+ ```bash
27
+ rerout whoami # show the key's project
28
+ rerout create https://example.com/sale \ # create a link
29
+ --domain go.brand.com --code sale
30
+ rerout ls --limit 20 # list links
31
+ rerout get sale # show one link
32
+ rerout update sale --inactive # patch a link
33
+ rerout rm sale # soft-delete a link
34
+ rerout stats # project stats (--days 30)
35
+ rerout stats sale --days 7 # per-link stats
36
+ rerout qr sale # print the QR URL
37
+ rerout qr sale --output sale.svg # write the QR SVG
38
+ rerout webhooks ls
39
+ rerout webhooks create --name "Orders" --url https://example.com/hook \
40
+ --events link.created,link.clicked
41
+ rerout webhooks rm wh_...
42
+ ```
43
+
44
+ Add `--json` to any command for raw, machine-readable output.
45
+
46
+ ## Development
47
+
48
+ ```bash
49
+ npm install
50
+ npm run typecheck
51
+ npm test # vitest — network is stubbed via an injected fetch
52
+ npm run build # emits dist/ (CLI bin + ./core)
53
+ ```
54
+
55
+ The reusable pieces (config, client factory, formatting) are exported from
56
+ `@rerout/cli/core` so other surfaces — like the planned Raycast extension — can
57
+ share one engine instead of re-implementing auth and rendering.
58
+
59
+ > Note: tag management is not yet exposed here; it lands once `@rerout/sdk` adds
60
+ > a `tags` namespace for the `/v1/projects/me/tags` endpoints.
61
+
62
+ ## Publishing
63
+
64
+ ```bash
65
+ npm run build
66
+ npm publish --access public
67
+ ```
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+ import { ReroutError } from '@rerout/sdk';
3
+ import { Command, Option } from 'commander';
4
+ import { createClient } from './core/client.js';
5
+ import { consoleIO } from './core/format.js';
6
+ import { login } from './commands/login.js';
7
+ import { createLink, getLink, listLinks, removeLink, updateLink, } from './commands/links.js';
8
+ import { linkStats, projectStats, whoami } from './commands/project.js';
9
+ import { qr } from './commands/qr.js';
10
+ import { createWebhook, listWebhooks, removeWebhook } from './commands/webhooks.js';
11
+ const program = new Command();
12
+ program
13
+ .name('rerout')
14
+ .description('Branded short links, QR codes, and analytics from your terminal.')
15
+ .version('0.1.0');
16
+ function parseIntOption(value) {
17
+ const parsed = Number.parseInt(value, 10);
18
+ if (Number.isNaN(parsed)) {
19
+ throw new Error(`Expected an integer, got "${value}".`);
20
+ }
21
+ return parsed;
22
+ }
23
+ program
24
+ .command('login')
25
+ .description('Store and verify a project API key (rrk_…).')
26
+ .requiredOption('--api-key <key>', 'Project API key')
27
+ .option('--base-url <url>', 'Override the API base URL')
28
+ .action(async (opts) => {
29
+ await login({ apiKey: opts.apiKey, baseUrl: opts.baseUrl }, consoleIO);
30
+ });
31
+ program
32
+ .command('whoami')
33
+ .description('Show the project that owns the current API key.')
34
+ .option('--json', 'Output raw JSON')
35
+ .action(async (opts) => {
36
+ await whoami(createClient(), opts, consoleIO);
37
+ });
38
+ program
39
+ .command('create')
40
+ .description('Create a short link.')
41
+ .argument('<url>', 'Destination URL')
42
+ .option('--domain <hostname>', 'Verified custom domain to host the link on')
43
+ .option('--code <code>', 'Custom path (requires --domain)')
44
+ .option('--expires <unix>', 'Expiry as unix seconds', parseIntOption)
45
+ .option('--title <title>', 'Social preview title')
46
+ .option('--description <text>', 'Social preview description')
47
+ .option('--json', 'Output raw JSON')
48
+ .action(async (url, opts) => {
49
+ await createLink(createClient(), url, opts, consoleIO);
50
+ });
51
+ program
52
+ .command('ls')
53
+ .alias('list')
54
+ .description('List links in the project.')
55
+ .option('--limit <n>', 'Page size', parseIntOption)
56
+ .option('--cursor <n>', 'Pagination cursor', parseIntOption)
57
+ .option('--json', 'Output raw JSON')
58
+ .action(async (opts) => {
59
+ await listLinks(createClient(), opts, consoleIO);
60
+ });
61
+ program
62
+ .command('get')
63
+ .description('Show a single link.')
64
+ .argument('<code>', 'Link code')
65
+ .option('--json', 'Output raw JSON')
66
+ .action(async (code, opts) => {
67
+ await getLink(createClient(), code, opts, consoleIO);
68
+ });
69
+ program
70
+ .command('update')
71
+ .description('Update a link.')
72
+ .argument('<code>', 'Link code')
73
+ .option('--target <url>', 'New destination URL')
74
+ .addOption(new Option('--active', 'Activate the link').conflicts('inactive'))
75
+ .addOption(new Option('--inactive', 'Deactivate the link').conflicts('active'))
76
+ .option('--expires <unix>', 'Expiry as unix seconds', parseIntOption)
77
+ .option('--title <title>', 'Social preview title')
78
+ .option('--json', 'Output raw JSON')
79
+ .action(async (code, opts) => {
80
+ const active = opts.active ? true : opts.inactive ? false : undefined;
81
+ await updateLink(createClient(), code, {
82
+ target: opts.target,
83
+ active,
84
+ expires: opts.expires,
85
+ title: opts.title,
86
+ json: opts.json,
87
+ }, consoleIO);
88
+ });
89
+ program
90
+ .command('rm')
91
+ .alias('delete')
92
+ .description('Delete (soft) a link.')
93
+ .argument('<code>', 'Link code')
94
+ .option('--json', 'Output raw JSON')
95
+ .action(async (code, opts) => {
96
+ await removeLink(createClient(), code, opts, consoleIO);
97
+ });
98
+ program
99
+ .command('stats')
100
+ .description('Show project stats, or per-link stats with a code.')
101
+ .argument('[code]', 'Optional link code')
102
+ .option('--days <n>', 'Window in days', parseIntOption)
103
+ .option('--json', 'Output raw JSON')
104
+ .action(async (code, opts) => {
105
+ const client = createClient();
106
+ if (code) {
107
+ await linkStats(client, code, opts, consoleIO);
108
+ }
109
+ else {
110
+ await projectStats(client, opts, consoleIO);
111
+ }
112
+ });
113
+ program
114
+ .command('qr')
115
+ .description('Print a link QR URL, or write the SVG with --output.')
116
+ .argument('<code>', 'Link code')
117
+ .option('--size <px>', 'Module size (1–32)', parseIntOption)
118
+ .option('--margin <modules>', 'Quiet zone (0–16)', parseIntOption)
119
+ .addOption(new Option('--ecc <level>', 'Error correction').choices(['L', 'M', 'Q', 'H']))
120
+ .option('--domain <hostname>', 'Encode a specific verified domain')
121
+ .option('--output <file>', 'Write the SVG to this file')
122
+ .option('--json', 'Output raw JSON')
123
+ .action(async (code, opts) => {
124
+ await qr(createClient(), code, opts, consoleIO);
125
+ });
126
+ const webhooks = program
127
+ .command('webhooks')
128
+ .description('Manage project webhook endpoints.');
129
+ webhooks
130
+ .command('ls')
131
+ .description('List webhook endpoints.')
132
+ .option('--json', 'Output raw JSON')
133
+ .action(async (opts) => {
134
+ await listWebhooks(createClient(), opts, consoleIO);
135
+ });
136
+ webhooks
137
+ .command('create')
138
+ .description('Create a webhook endpoint.')
139
+ .requiredOption('--name <name>', 'Endpoint label')
140
+ .requiredOption('--url <url>', 'Public https:// delivery URL')
141
+ .requiredOption('--events <list>', 'Comma-separated event types', (v) => v.split(',').map((e) => e.trim()).filter(Boolean))
142
+ .option('--json', 'Output raw JSON')
143
+ .action(async (opts) => {
144
+ await createWebhook(createClient(), { name: opts.name, url: opts.url, events: opts.events, json: opts.json }, consoleIO);
145
+ });
146
+ webhooks
147
+ .command('rm')
148
+ .alias('delete')
149
+ .description('Delete a webhook endpoint.')
150
+ .argument('<endpoint_id>', 'Endpoint id')
151
+ .option('--json', 'Output raw JSON')
152
+ .action(async (endpointId, opts) => {
153
+ await removeWebhook(createClient(), endpointId, opts, consoleIO);
154
+ });
155
+ async function main() {
156
+ try {
157
+ await program.parseAsync(process.argv);
158
+ }
159
+ catch (error) {
160
+ if (error instanceof ReroutError) {
161
+ consoleIO.err(`Error (${error.code}): ${error.message}`);
162
+ }
163
+ else if (error instanceof Error) {
164
+ consoleIO.err(`Error: ${error.message}`);
165
+ }
166
+ else {
167
+ consoleIO.err(`Unexpected error: ${String(error)}`);
168
+ }
169
+ process.exitCode = 1;
170
+ }
171
+ }
172
+ void main();
@@ -0,0 +1,31 @@
1
+ import type { Rerout } from '@rerout/sdk';
2
+ import { type IO } from '../core/format.js';
3
+ export interface CreateOptions {
4
+ domain?: string;
5
+ code?: string;
6
+ expires?: number;
7
+ title?: string;
8
+ description?: string;
9
+ json?: boolean;
10
+ }
11
+ export declare function createLink(client: Rerout, targetUrl: string, opts: CreateOptions, io: IO): Promise<void>;
12
+ export interface ListOptions {
13
+ limit?: number;
14
+ cursor?: number;
15
+ json?: boolean;
16
+ }
17
+ export declare function listLinks(client: Rerout, opts: ListOptions, io: IO): Promise<void>;
18
+ export declare function getLink(client: Rerout, code: string, opts: {
19
+ json?: boolean;
20
+ }, io: IO): Promise<void>;
21
+ export interface UpdateOptions {
22
+ target?: string;
23
+ active?: boolean;
24
+ expires?: number | null;
25
+ title?: string | null;
26
+ json?: boolean;
27
+ }
28
+ export declare function updateLink(client: Rerout, code: string, opts: UpdateOptions, io: IO): Promise<void>;
29
+ export declare function removeLink(client: Rerout, code: string, opts: {
30
+ json?: boolean;
31
+ }, io: IO): Promise<void>;
@@ -0,0 +1,70 @@
1
+ import { formatLinkLine, formatLinksTable } from '../core/format.js';
2
+ export async function createLink(client, targetUrl, opts, io) {
3
+ const input = { target_url: targetUrl };
4
+ if (opts.domain)
5
+ input.domain_hostname = opts.domain;
6
+ if (opts.code)
7
+ input.code = opts.code;
8
+ if (opts.expires !== undefined)
9
+ input.expires_at = opts.expires;
10
+ if (opts.title !== undefined)
11
+ input.seo_title = opts.title;
12
+ if (opts.description !== undefined)
13
+ input.seo_description = opts.description;
14
+ const link = await client.links.create(input);
15
+ if (opts.json) {
16
+ io.json(link);
17
+ return;
18
+ }
19
+ io.out(link.short_url);
20
+ }
21
+ export async function listLinks(client, opts, io) {
22
+ const result = await client.links.list({
23
+ ...(opts.limit !== undefined ? { limit: opts.limit } : {}),
24
+ ...(opts.cursor !== undefined ? { cursor: opts.cursor } : {}),
25
+ });
26
+ if (opts.json) {
27
+ io.json(result);
28
+ return;
29
+ }
30
+ io.out(formatLinksTable(result.links));
31
+ if (result.next_cursor != null) {
32
+ io.out(`\nMore results: rerout ls --cursor ${result.next_cursor}`);
33
+ }
34
+ }
35
+ export async function getLink(client, code, opts, io) {
36
+ const link = await client.links.get(code);
37
+ if (opts.json) {
38
+ io.json(link);
39
+ return;
40
+ }
41
+ io.out(formatLinkLine(link));
42
+ }
43
+ export async function updateLink(client, code, opts, io) {
44
+ const input = {};
45
+ if (opts.target !== undefined)
46
+ input.target_url = opts.target;
47
+ if (opts.active !== undefined)
48
+ input.is_active = opts.active;
49
+ if (opts.expires !== undefined)
50
+ input.expires_at = opts.expires;
51
+ if (opts.title !== undefined)
52
+ input.seo_title = opts.title;
53
+ if (Object.keys(input).length === 0) {
54
+ throw new Error('Nothing to update. Pass at least one of --target, --active, --expires, --title.');
55
+ }
56
+ const link = await client.links.update(code, input);
57
+ if (opts.json) {
58
+ io.json(link);
59
+ return;
60
+ }
61
+ io.out(formatLinkLine(link));
62
+ }
63
+ export async function removeLink(client, code, opts, io) {
64
+ const result = await client.links.delete(code);
65
+ if (opts.json) {
66
+ io.json(result);
67
+ return;
68
+ }
69
+ io.out(result.deleted ? `Deleted ${code}.` : `Nothing deleted for ${code}.`);
70
+ }
@@ -0,0 +1,12 @@
1
+ import { type IO } from '../core/index.js';
2
+ export interface LoginOptions {
3
+ apiKey: string;
4
+ baseUrl?: string;
5
+ /** Skip the verifying API call (used in tests / offline setup). */
6
+ skipVerify?: boolean;
7
+ }
8
+ /**
9
+ * Store an API key after confirming it authenticates. The key is verified with
10
+ * a `project.me()` call so a bad key fails immediately rather than on first use.
11
+ */
12
+ export declare function login(opts: LoginOptions, io: IO): Promise<void>;
@@ -0,0 +1,28 @@
1
+ import { Rerout } from '@rerout/sdk';
2
+ import { configPath, loadConfig, saveConfig } from '../core/config.js';
3
+ import { CLI_USER_AGENT } from '../core/index.js';
4
+ /**
5
+ * Store an API key after confirming it authenticates. The key is verified with
6
+ * a `project.me()` call so a bad key fails immediately rather than on first use.
7
+ */
8
+ export async function login(opts, io) {
9
+ const apiKey = opts.apiKey.trim();
10
+ if (!apiKey) {
11
+ throw new Error('An API key is required. Pass --api-key rrk_…');
12
+ }
13
+ if (!opts.skipVerify) {
14
+ const client = new Rerout({
15
+ apiKey,
16
+ ...(opts.baseUrl ? { baseUrl: opts.baseUrl } : {}),
17
+ defaultHeaders: { 'user-agent': CLI_USER_AGENT },
18
+ });
19
+ const project = await client.project.me();
20
+ io.out(`Authenticated as ${project.name} (${project.slug}).`);
21
+ }
22
+ const next = loadConfig();
23
+ next.apiKey = apiKey;
24
+ if (opts.baseUrl)
25
+ next.baseUrl = opts.baseUrl;
26
+ saveConfig(next);
27
+ io.out(`Saved credentials to ${configPath()}`);
28
+ }
@@ -0,0 +1,13 @@
1
+ import type { Rerout } from '@rerout/sdk';
2
+ import { type IO } from '../core/format.js';
3
+ export declare function whoami(client: Rerout, opts: {
4
+ json?: boolean;
5
+ }, io: IO): Promise<void>;
6
+ export declare function projectStats(client: Rerout, opts: {
7
+ days?: number;
8
+ json?: boolean;
9
+ }, io: IO): Promise<void>;
10
+ export declare function linkStats(client: Rerout, code: string, opts: {
11
+ days?: number;
12
+ json?: boolean;
13
+ }, io: IO): Promise<void>;
@@ -0,0 +1,34 @@
1
+ export async function whoami(client, opts, io) {
2
+ const project = await client.project.me();
3
+ if (opts.json) {
4
+ io.json(project);
5
+ return;
6
+ }
7
+ io.out(`${project.name} (${project.slug}) · ${project.id}`);
8
+ }
9
+ export async function projectStats(client, opts, io) {
10
+ const stats = await client.project.stats(opts.days);
11
+ if (opts.json) {
12
+ io.json(stats);
13
+ return;
14
+ }
15
+ io.out(`Last ${stats.days} days`);
16
+ io.out(` Clicks: ${stats.total_clicks}`);
17
+ io.out(` QR scans: ${stats.qr_scans}`);
18
+ if (stats.top_codes.length > 0) {
19
+ io.out(' Top links:');
20
+ for (const row of stats.top_codes.slice(0, 5)) {
21
+ io.out(` ${row.value} ${row.clicks}`);
22
+ }
23
+ }
24
+ }
25
+ export async function linkStats(client, code, opts, io) {
26
+ const stats = await client.links.stats(code, opts.days);
27
+ if (opts.json) {
28
+ io.json(stats);
29
+ return;
30
+ }
31
+ io.out(`${code} · last ${stats.days} days`);
32
+ io.out(` Clicks: ${stats.total_clicks}`);
33
+ io.out(` QR scans: ${stats.qr_scans}`);
34
+ }
@@ -0,0 +1,9 @@
1
+ import type { QrUrlOptions, Rerout } from '@rerout/sdk';
2
+ import { type IO } from '../core/format.js';
3
+ export interface QrOptions extends QrUrlOptions {
4
+ /** Write the SVG to this file instead of printing the URL. */
5
+ output?: string;
6
+ /** Print the QR endpoint URL (default) rather than fetching the SVG. */
7
+ json?: boolean;
8
+ }
9
+ export declare function qr(client: Rerout, code: string, opts: QrOptions, io: IO): Promise<void>;
@@ -0,0 +1,24 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ export async function qr(client, code, opts, io) {
3
+ const urlOptions = {};
4
+ if (opts.size !== undefined)
5
+ urlOptions.size = opts.size;
6
+ if (opts.margin !== undefined)
7
+ urlOptions.margin = opts.margin;
8
+ if (opts.ecc !== undefined)
9
+ urlOptions.ecc = opts.ecc;
10
+ if (opts.domain !== undefined)
11
+ urlOptions.domain = opts.domain;
12
+ if (opts.output) {
13
+ const svg = await client.qr.svg(code, urlOptions);
14
+ await writeFile(opts.output, svg, 'utf8');
15
+ io.out(`Wrote ${opts.output}`);
16
+ return;
17
+ }
18
+ const url = client.qr.url(code, urlOptions);
19
+ if (opts.json) {
20
+ io.json({ code, url });
21
+ return;
22
+ }
23
+ io.out(url);
24
+ }
@@ -0,0 +1,15 @@
1
+ import type { Rerout } from '@rerout/sdk';
2
+ import { type IO } from '../core/format.js';
3
+ export declare function listWebhooks(client: Rerout, opts: {
4
+ json?: boolean;
5
+ }, io: IO): Promise<void>;
6
+ export interface CreateWebhookOptions {
7
+ name: string;
8
+ url: string;
9
+ events: string[];
10
+ json?: boolean;
11
+ }
12
+ export declare function createWebhook(client: Rerout, opts: CreateWebhookOptions, io: IO): Promise<void>;
13
+ export declare function removeWebhook(client: Rerout, endpointId: string, opts: {
14
+ json?: boolean;
15
+ }, io: IO): Promise<void>;
@@ -0,0 +1,37 @@
1
+ export async function listWebhooks(client, opts, io) {
2
+ const result = await client.webhooks.list();
3
+ if (opts.json) {
4
+ io.json(result);
5
+ return;
6
+ }
7
+ if (result.endpoints.length === 0) {
8
+ io.out('No webhook endpoints yet.');
9
+ return;
10
+ }
11
+ for (const endpoint of result.endpoints) {
12
+ const state = endpoint.is_active ? 'active' : 'inactive';
13
+ io.out(`${endpoint.id} ${state} ${endpoint.url}`);
14
+ io.out(` events: ${endpoint.events.join(', ')}`);
15
+ }
16
+ }
17
+ export async function createWebhook(client, opts, io) {
18
+ const created = await client.webhooks.create({
19
+ name: opts.name,
20
+ url: opts.url,
21
+ events: opts.events,
22
+ });
23
+ if (opts.json) {
24
+ io.json(created);
25
+ return;
26
+ }
27
+ io.out(`Created ${created.endpoint.id}`);
28
+ io.out(`Signing secret (shown once): ${created.signing_secret}`);
29
+ }
30
+ export async function removeWebhook(client, endpointId, opts, io) {
31
+ const result = await client.webhooks.delete(endpointId);
32
+ if (opts.json) {
33
+ io.json(result);
34
+ return;
35
+ }
36
+ io.out(result.deleted ? `Deleted ${endpointId}.` : `Nothing deleted for ${endpointId}.`);
37
+ }
@@ -0,0 +1,13 @@
1
+ import { Rerout, type ReroutClientOptions } from '@rerout/sdk';
2
+ /** Sent on every request so the API can attribute CLI traffic. */
3
+ export declare const CLI_USER_AGENT = "rerout-cli";
4
+ /** Thrown when no API key can be resolved from flags, env, or config. */
5
+ export declare class MissingApiKeyError extends Error {
6
+ constructor();
7
+ }
8
+ /**
9
+ * Resolve an authenticated Rerout SDK client. Key/baseUrl precedence:
10
+ * explicit override → environment → saved config. `fetch` and other SDK
11
+ * options can be injected via `overrides` (used by tests and Raycast).
12
+ */
13
+ export declare function createClient(overrides?: Partial<ReroutClientOptions>): Rerout;
@@ -0,0 +1,30 @@
1
+ import { Rerout } from '@rerout/sdk';
2
+ import { loadConfig } from './config.js';
3
+ /** Sent on every request so the API can attribute CLI traffic. */
4
+ export const CLI_USER_AGENT = 'rerout-cli';
5
+ /** Thrown when no API key can be resolved from flags, env, or config. */
6
+ export class MissingApiKeyError extends Error {
7
+ constructor() {
8
+ super('No API key found. Run `rerout login` or set the REROUT_API_KEY environment variable.');
9
+ this.name = 'MissingApiKeyError';
10
+ }
11
+ }
12
+ /**
13
+ * Resolve an authenticated Rerout SDK client. Key/baseUrl precedence:
14
+ * explicit override → environment → saved config. `fetch` and other SDK
15
+ * options can be injected via `overrides` (used by tests and Raycast).
16
+ */
17
+ export function createClient(overrides = {}) {
18
+ const config = loadConfig();
19
+ const apiKey = overrides.apiKey ?? process.env.REROUT_API_KEY?.trim() ?? config.apiKey;
20
+ if (!apiKey) {
21
+ throw new MissingApiKeyError();
22
+ }
23
+ const baseUrl = overrides.baseUrl ?? process.env.REROUT_BASE_URL?.trim() ?? config.baseUrl;
24
+ return new Rerout({
25
+ apiKey,
26
+ ...(baseUrl ? { baseUrl } : {}),
27
+ defaultHeaders: { 'user-agent': CLI_USER_AGENT },
28
+ ...overrides,
29
+ });
30
+ }
@@ -0,0 +1,13 @@
1
+ /** Persisted CLI configuration. The API key is the only secret stored. */
2
+ export interface CliConfig {
3
+ apiKey?: string;
4
+ /** Optional API base URL override (staging / self-hosted). */
5
+ baseUrl?: string;
6
+ }
7
+ /** Directory holding the config file. Respects `XDG_CONFIG_HOME`. */
8
+ export declare function configDir(): string;
9
+ export declare function configPath(): string;
10
+ /** Load config, returning an empty object when the file is missing or invalid. */
11
+ export declare function loadConfig(): CliConfig;
12
+ /** Persist config with owner-only (0600) permissions — it holds the API key. */
13
+ export declare function saveConfig(next: CliConfig): void;
@@ -0,0 +1,37 @@
1
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ /** Directory holding the config file. Respects `XDG_CONFIG_HOME`. */
5
+ export function configDir() {
6
+ const base = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), '.config');
7
+ return join(base, 'rerout');
8
+ }
9
+ export function configPath() {
10
+ return join(configDir(), 'config.json');
11
+ }
12
+ /** Load config, returning an empty object when the file is missing or invalid. */
13
+ export function loadConfig() {
14
+ try {
15
+ const parsed = JSON.parse(readFileSync(configPath(), 'utf8'));
16
+ if (parsed && typeof parsed === 'object') {
17
+ return parsed;
18
+ }
19
+ return {};
20
+ }
21
+ catch {
22
+ return {};
23
+ }
24
+ }
25
+ /** Persist config with owner-only (0600) permissions — it holds the API key. */
26
+ export function saveConfig(next) {
27
+ mkdirSync(configDir(), { recursive: true });
28
+ const path = configPath();
29
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
30
+ // writeFileSync `mode` is ignored when the file already exists; enforce it.
31
+ try {
32
+ chmodSync(path, 0o600);
33
+ }
34
+ catch {
35
+ // Best effort (e.g. on filesystems without POSIX perms).
36
+ }
37
+ }
@@ -0,0 +1,15 @@
1
+ import type { Link } from '@rerout/sdk';
2
+ /** Sink for command output so commands stay testable (no direct console use). */
3
+ export interface IO {
4
+ out(line: string): void;
5
+ err(line: string): void;
6
+ json(data: unknown): void;
7
+ }
8
+ /** Default IO bound to the process streams. */
9
+ export declare const consoleIO: IO;
10
+ /** Format a unix-seconds timestamp as an ISO date, or `—` when absent. */
11
+ export declare function formatTimestamp(seconds: number | null | undefined): string;
12
+ /** One-line summary of a link for human output. */
13
+ export declare function formatLinkLine(link: Link): string;
14
+ /** Compact, aligned table of links for `rerout ls`. */
15
+ export declare function formatLinksTable(links: Link[]): string;
@@ -0,0 +1,35 @@
1
+ /** Default IO bound to the process streams. */
2
+ export const consoleIO = {
3
+ out: (line) => process.stdout.write(`${line}\n`),
4
+ err: (line) => process.stderr.write(`${line}\n`),
5
+ json: (data) => process.stdout.write(`${JSON.stringify(data, null, 2)}\n`),
6
+ };
7
+ /** Format a unix-seconds timestamp as an ISO date, or `—` when absent. */
8
+ export function formatTimestamp(seconds) {
9
+ if (!seconds)
10
+ return '—';
11
+ return new Date(seconds * 1000).toISOString().replace('.000Z', 'Z');
12
+ }
13
+ /** One-line summary of a link for human output. */
14
+ export function formatLinkLine(link) {
15
+ const status = link.is_active ? '' : ' [inactive]';
16
+ return `${link.short_url}${status} → ${link.target_url}`;
17
+ }
18
+ /** Compact, aligned table of links for `rerout ls`. */
19
+ export function formatLinksTable(links) {
20
+ if (links.length === 0)
21
+ return 'No links yet.';
22
+ const rows = links.map((l) => [
23
+ l.code,
24
+ l.short_url,
25
+ l.is_active ? 'active' : 'inactive',
26
+ truncate(l.target_url, 48),
27
+ ]);
28
+ const headers = ['CODE', 'SHORT URL', 'STATUS', 'TARGET'];
29
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
30
+ const render = (cells) => cells.map((c, i) => c.padEnd(widths[i])).join(' ').trimEnd();
31
+ return [render(headers), ...rows.map(render)].join('\n');
32
+ }
33
+ function truncate(value, max) {
34
+ return value.length > max ? `${value.slice(0, max - 1)}…` : value;
35
+ }
@@ -0,0 +1,3 @@
1
+ export * from './config.js';
2
+ export * from './client.js';
3
+ export * from './format.js';
@@ -0,0 +1,5 @@
1
+ // Shared core re-used by the CLI binary and (phase 2) the Raycast extension.
2
+ // Importable via `@rerout/cli/core`.
3
+ export * from './config.js';
4
+ export * from './client.js';
5
+ export * from './format.js';
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@rerout/cli",
3
+ "version": "0.1.0",
4
+ "description": "Command-line interface for Rerout — branded short links, QR codes, and analytics from your terminal.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "rerout": "dist/cli.js"
9
+ },
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/cli.d.ts",
13
+ "import": "./dist/cli.js"
14
+ },
15
+ "./core": {
16
+ "types": "./dist/core/index.d.ts",
17
+ "import": "./dist/core/index.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "README.md"
23
+ ],
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "scripts": {
28
+ "build": "tsc -p tsconfig.json",
29
+ "typecheck": "tsc -p tsconfig.json --noEmit",
30
+ "test": "vitest run",
31
+ "prepublishOnly": "npm run build"
32
+ },
33
+ "keywords": [
34
+ "rerout",
35
+ "url-shortener",
36
+ "short-links",
37
+ "qr-code",
38
+ "cli"
39
+ ],
40
+ "dependencies": {
41
+ "@rerout/sdk": "^0.3.0",
42
+ "commander": "^12.1.0"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^20.14.0",
46
+ "typescript": "^5.6.0",
47
+ "vitest": "^2.1.0"
48
+ }
49
+ }