@straitlink/cli 0.1.4 → 0.2.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 CHANGED
@@ -12,7 +12,9 @@ One command sets up Strait deep links in your app. Every link open and install i
12
12
  `@straitlink/cli`
13
13
 
14
14
  ```bash
15
- npx @straitlink/cli init
15
+ npx @straitlink/cli init # set up deep links in your app
16
+ npx @straitlink/cli doctor --host acme.strait.link # check the link host lists your app
17
+ npx @straitlink/cli links create summer https://acme.com/s # create a link (secret key in env)
16
18
  ```
17
19
 
18
20
  `strait init` sets up Strait deep links in a React Native, Expo, Flutter or Android (Kotlin) app:
@@ -56,6 +58,44 @@ From a checkout of this repo: `node bin/strait.mjs init ../my-app`, or `npm link
56
58
 
57
59
  Exit codes: `0` done, `2` usage problem (message on stderr), `1` unexpected error.
58
60
 
61
+ ## Check your setup: `strait doctor`
62
+
63
+ ```bash
64
+ npx @straitlink/cli doctor --host acme.strait.link --android com.acme.shop --ios com.acme.shop
65
+ ```
66
+
67
+ Runs the same checks, with the same finding ids and wording, as `doctor()` in the Strait SDKs (React Native 0.9.3, Swift 0.9.4, Android 0.8.4, Flutter 0.9.4; contract B23, the 43 shared cases in `test/doctor-vectors.json`). It fetches `https://<host>/.well-known/assetlinks.json` and `apple-app-site-association` once each and tells you whether your package, bundle ID and (with `--cert`) signing certificate are listed, with the fix for each problem:
68
+
69
+ ```
70
+ Android (assetlinks.json)
71
+ error app.missing acme.strait.link doesn't list com.acme.shop
72
+ Fix: Add com.acme.shop in Dashboard → Apps & stores (listed now: com.acme.app).
73
+ ```
74
+
75
+ | Option | |
76
+ |---|---|
77
+ | `--host` | Your link domain. Env: `STRAIT_LINK_HOST` |
78
+ | `--android <package>` / `--ios <bundleId>` | The app to look for. With neither, both files are checked and the apps they list are shown |
79
+ | `--cert <sha256>` | Android signing certificate fingerprint, any format; repeat for debug, release and Play App Signing |
80
+ | `--key` | Also check the publishable key. Env: `STRAIT_PUBLISHABLE_KEY`. Left out, the key check is skipped |
81
+ | `--json` | The reports as JSON, for CI and agents |
82
+
83
+ Exit codes: `0` no errors (warnings allowed), `1` a check failed, `2` usage problem. What a terminal can't see (intent filters, the Associated Domains entitlement) is checked by `doctor()` inside a debug build and by the [free tools](https://straitlink.in/tools/).
84
+
85
+ ## Manage links: `strait links`
86
+
87
+ ```bash
88
+ export STRAIT_SECRET_KEY=st_live_… # Dashboard → Settings → Secret keys
89
+ npx @straitlink/cli links create summer https://acme.com/summer --campaign sale --tag email
90
+ npx @straitlink/cli links list --status active --limit 20
91
+ npx @straitlink/cli links qr summer --size 512 # writes summer.png
92
+ npx @straitlink/cli links qr summer --format svg --out summer.svg
93
+ ```
94
+
95
+ These call the [public API](https://straitlink.in/docs/api/) (`POST /v1/links`, `GET /v1/links`, `GET /v1/links/{slug}/qr`) with a secret key from `STRAIT_SECRET_KEY` or `--secret-key` (the environment is better: flags stay in your shell history). The key is never printed, not even inside an API error. A publishable key is refused here. `--api-url` (env `STRAIT_API_URL`) points at another engine and must be https. `--json` prints the API answer as is. A QR file is never overwritten without `--force`.
96
+
97
+ Exit codes: `0` done, `1` the API said no or can't be reached (its message on stderr), `2` usage problem.
98
+
59
99
  ## Install lines: one constant
60
100
 
61
101
  Every install line the CLI prints comes from `INSTALL` in `src/install.mjs` (`source` + `githubOwner`). It mirrors `install` in `site/site.config.json` and `INSTALL` in `dashboard/src/lib/install.ts`, and `test/install.test.mjs` fails if they drift while the repos sit side by side. Moving the SDK repos to an organisation means changing `githubOwner`. Publishing to npm, pub.dev and Maven Central means changing `source` to `"registry"`.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@straitlink/cli",
3
- "version": "0.1.4",
4
- "description": "npx @straitlink/cli init: set up Strait deep links in a React Native, Expo, Flutter or Android app.",
3
+ "version": "0.2.0",
4
+ "description": "Strait deep links from the terminal: init sets them up in a React Native, Expo, Flutter or Android app, doctor checks your link host, links creates and lists links and saves QR codes.",
5
5
  "keywords": [
6
6
  "strait",
7
7
  "deep-linking",
@@ -12,6 +12,7 @@
12
12
  "expo",
13
13
  "flutter",
14
14
  "android",
15
+ "qr-code",
15
16
  "cli"
16
17
  ],
17
18
  "homepage": "https://straitlink.in/docs/",
package/src/cli.mjs CHANGED
@@ -6,13 +6,55 @@ import { detectProject } from './detect.mjs';
6
6
  import { formatPlan, planInit, writeFiles } from './init.mjs';
7
7
  import { UsageError, normalizeHost, normalizeKey, normalizeScheme, suggestScheme } from './validate.mjs';
8
8
  import { resolve } from 'node:path';
9
+ import { formatDoctor, runDoctor } from './doctor.mjs';
10
+ import { ApiError, DEFAULT_API_URL, apiClient, linksCommand, secretKey } from './links.mjs';
11
+ import { CLI_VERSION } from './version.mjs';
9
12
 
10
- export const HELP = `strait: set up Strait deep links in your app
13
+ const OPTIONS = {
14
+ init: {
15
+ key: { type: 'string' },
16
+ host: { type: 'string' },
17
+ scheme: { type: 'string' },
18
+ framework: { type: 'string' },
19
+ 'dry-run': { type: 'boolean', default: false },
20
+ force: { type: 'boolean', default: false },
21
+ yes: { type: 'boolean', short: 'y', default: false },
22
+ },
23
+ doctor: {
24
+ host: { type: 'string' },
25
+ key: { type: 'string' },
26
+ android: { type: 'string' },
27
+ ios: { type: 'string' },
28
+ cert: { type: 'string', multiple: true },
29
+ json: { type: 'boolean', default: false },
30
+ },
31
+ links: {
32
+ 'secret-key': { type: 'string' },
33
+ 'api-url': { type: 'string' },
34
+ title: { type: 'string' },
35
+ campaign: { type: 'string' },
36
+ tag: { type: 'string', multiple: true },
37
+ limit: { type: 'string' },
38
+ status: { type: 'string' },
39
+ cursor: { type: 'string' },
40
+ format: { type: 'string' },
41
+ size: { type: 'string' },
42
+ out: { type: 'string' },
43
+ force: { type: 'boolean', default: false },
44
+ json: { type: 'boolean', default: false },
45
+ },
46
+ };
47
+
48
+ export const HELP = `strait: set up and check Strait deep links, and manage links
11
49
 
12
50
  Usage
13
- strait init [dir] [options]
51
+ strait init [dir] [options] Write the Strait integration into your app
52
+ strait doctor --host <host> [options] Check your link host is set up for your app
53
+ strait links create <slug> <url> [options] Create a link
54
+ strait links list [options] List links
55
+ strait links qr <slug> [options] Save a link's QR code
14
56
 
15
- Options
57
+ init options
16
58
  --key <st_pub_live_…> Publishable key (Dashboard → Get started)
17
59
  --host <host> Your link domain, e.g. acme.strait.link
18
60
  --scheme <scheme> Your app's custom scheme, the one in Dashboard → Apps & stores,
@@ -21,14 +63,34 @@ Options
21
63
  --dry-run Print what would be written; write nothing
22
64
  --force Replace an existing integration file
23
65
  --yes, -y Never prompt; fail if something is missing
24
- --help, -h Show this help
66
+
67
+ doctor options (the same checks and messages as doctor() in the SDKs)
68
+ --host <host> Your link domain (env STRAIT_LINK_HOST)
69
+ --android <package> Android package, e.g. com.acme.shop
70
+ --ios <bundleId> iPhone bundle ID, e.g. com.acme.shop
71
+ --cert <sha256> Android signing certificate fingerprint (repeatable)
72
+ --key <st_pub_live_…> Also check the publishable key (env STRAIT_PUBLISHABLE_KEY)
73
+ --json Print the reports as JSON
74
+ With neither --android nor --ios, both files are checked and the apps they list are shown.
75
+ Exit 1 when a check fails.
76
+
77
+ links options
78
+ --secret-key <st_live_…> Secret key (Dashboard → Settings → Secret keys). Prefer the
79
+ env STRAIT_SECRET_KEY: flags stay in your shell history
80
+ --api-url <url> API base (env STRAIT_API_URL, default https://strait.link)
81
+ --title, --campaign, --tag <tag> (repeatable) create: link details
82
+ --limit <n>, --status active|paused, --cursor <c> list: paging and filter
83
+ --format png|svg, --size <px>, --out <file>, --force qr: output (default <slug>.png)
84
+ --json Print the API answer as JSON
25
85
 
26
86
  Environment
27
- STRAIT_PUBLISHABLE_KEY, STRAIT_LINK_HOST are used when the flags are absent.
87
+ STRAIT_PUBLISHABLE_KEY, STRAIT_LINK_HOST, STRAIT_SECRET_KEY, STRAIT_API_URL
28
88
 
29
89
  Examples
30
90
  npx @straitlink/cli init
31
91
  npx @straitlink/cli init --key st_pub_live_… --host acme.strait.link --dry-run
92
+ npx @straitlink/cli doctor --host acme.strait.link --android com.acme.shop --ios com.acme.shop
93
+ STRAIT_SECRET_KEY=st_live_… npx @straitlink/cli links create summer https://acme.com/summer
32
94
  `;
33
95
 
34
96
  /**
@@ -40,35 +102,40 @@ export async function main(argv, io = {}) {
40
102
  const err = io.stderr ?? process.stderr;
41
103
  const env = io.env ?? process.env;
42
104
  const isTTY = io.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
105
+ const cmd = argv[0]?.startsWith('-') ? undefined : argv[0];
106
+ if (!cmd || !(cmd in OPTIONS)) {
107
+ const help = !cmd && argv.some((a) => a === '--help' || a === '-h');
108
+ if (cmd) err.write(`Unknown command "${cmd}".\n\n${HELP}`);
109
+ else out.write(HELP);
110
+ return help ? 0 : 2;
111
+ }
43
112
  let args;
44
113
  try {
45
- args = parseArgs({
46
- args: argv,
47
- allowPositionals: true,
48
- options: {
49
- key: { type: 'string' },
50
- host: { type: 'string' },
51
- scheme: { type: 'string' },
52
- framework: { type: 'string' },
53
- 'dry-run': { type: 'boolean', default: false },
54
- force: { type: 'boolean', default: false },
55
- yes: { type: 'boolean', short: 'y', default: false },
56
- help: { type: 'boolean', short: 'h', default: false },
57
- },
58
- });
114
+ args = parseArgs({ args: argv.slice(1), allowPositionals: true, options: { ...OPTIONS[cmd], help: { type: 'boolean', short: 'h', default: false } } });
59
115
  } catch (e) {
60
116
  err.write(`${e.message}\n\n${HELP}`);
61
117
  return 2;
62
118
  }
63
- const [cmd, dirArg] = args.positionals;
64
- if (args.values.help || !cmd) {
119
+ if (args.values.help) {
65
120
  out.write(HELP);
66
- return args.values.help ? 0 : 2;
121
+ return 0;
67
122
  }
68
123
  if (cmd !== 'init') {
69
- err.write(`Unknown command "${cmd}".\n\n${HELP}`);
70
- return 2;
124
+ try {
125
+ return await (cmd === 'doctor' ? doctor : links)(args, { out, err, env, io });
126
+ } catch (e) {
127
+ if (e instanceof UsageError) {
128
+ err.write(`strait: ${e.message}\n`);
129
+ return 2;
130
+ }
131
+ if (e instanceof ApiError) {
132
+ err.write(`strait: ${e.message}\n`);
133
+ return 1;
134
+ }
135
+ throw e;
136
+ }
71
137
  }
138
+ const [dirArg] = args.positionals;
72
139
 
73
140
  const v = args.values;
74
141
  const dir = resolve(dirArg ?? '.');
@@ -129,3 +196,23 @@ async function askValid(ask, question, normalize, err) {
129
196
  async function terminalAsker(input, output) {
130
197
  return createInterface({ input, output });
131
198
  }
199
+
200
+ async function doctor({ values: v }, { out, env, io }) {
201
+ const hostIn = v.host ?? env.STRAIT_LINK_HOST;
202
+ if (!hostIn) throw new UsageError('Missing --host: your link domain from Dashboard → Get started, e.g. --host acme.strait.link.');
203
+ const host = normalizeHost(hostIn);
204
+ const keyIn = v.key ?? env.STRAIT_PUBLISHABLE_KEY;
205
+ const key = keyIn ? String(keyIn).trim() : undefined;
206
+ const result = await runDoctor({ host, key, android: v.android?.trim(), ios: v.ios?.trim(), certs: v.cert ?? [], fetch: io.fetch ?? fetch });
207
+ out.write((v.json ? JSON.stringify(result, null, 2) : formatDoctor(result)) + '\n');
208
+ return result.ok ? 0 : 1;
209
+ }
210
+
211
+ async function links({ values: v, positionals }, { out, env, io }) {
212
+ const [sub, ...rest] = positionals;
213
+ if (!['create', 'list', 'qr'].includes(sub)) return linksCommand(sub, rest, v, {});
214
+ const key = secretKey(v['secret-key'] ?? env.STRAIT_SECRET_KEY);
215
+ const call = apiClient({ apiUrl: v['api-url'] ?? env.STRAIT_API_URL ?? DEFAULT_API_URL, key, fetch: io.fetch ?? fetch, userAgent: `strait-cli/${CLI_VERSION}` });
216
+ out.write((await linksCommand(sub, rest, v, { call, cwd: io.cwd ?? process.cwd() })) + '\n');
217
+ return 0;
218
+ }
package/src/doctor.mjs ADDED
@@ -0,0 +1,146 @@
1
+ // `strait doctor`: the SDK setup check (contract B23, shared-spec docs/adr/0002-sdk-doctor.md) from a
2
+ // terminal. `doctorFindings` is a line-for-line port of strait-sdk-react-native 0.9.3 src/doctor.ts:
3
+ // same finding ids, severities and wording as every SDK (test/doctor-vectors.json, 43 shared cases).
4
+
5
+ /** The endpoint's host when it is an `https://` URL with a host, else null. */
6
+ export function doctorHost(endpoint) {
7
+ const m = /^https:\/\/([^/?#\s@]+)(?:[/?#]|$)/i.exec(String(endpoint ?? '').trim());
8
+ return m ? m[1].toLowerCase() : null;
9
+ }
10
+
11
+ /** Upper-case, colon-separated, as assetlinks.json writes it. */
12
+ const normFp = (s) => (s.replace(/[^0-9a-f]/gi, '').toUpperCase().match(/../g) ?? []).join(':');
13
+
14
+ const ok = (id, title) => ({ id, severity: 'ok', title, fix: '' });
15
+ const err = (id, title, fix) => ({ id, severity: 'error', title, fix });
16
+
17
+ /** Android: package → fingerprints; iOS: full appIDs (value unused). Null = not the JSON expected. */
18
+ function listedApps(platform, body) {
19
+ let json;
20
+ try {
21
+ json = JSON.parse(body);
22
+ } catch {
23
+ return null;
24
+ }
25
+ const apps = new Map();
26
+ if (platform === 'android') {
27
+ if (!Array.isArray(json)) return null;
28
+ for (const s of json) {
29
+ const t = s?.target;
30
+ if (t?.namespace !== 'android_app' || typeof t.package_name !== 'string') continue;
31
+ const fps = Array.isArray(t.sha256_cert_fingerprints) ? t.sha256_cert_fingerprints.filter((f) => typeof f === 'string') : [];
32
+ apps.set(t.package_name, [...(apps.get(t.package_name) ?? []), ...fps.map(normFp)]);
33
+ }
34
+ return apps;
35
+ }
36
+ const details = json?.applinks?.details;
37
+ if (!Array.isArray(details)) return null;
38
+ for (const d of details) {
39
+ for (const id of [...(Array.isArray(d?.appIDs) ? d.appIDs : []), d?.appID]) if (typeof id === 'string') apps.set(id, []);
40
+ }
41
+ return apps;
42
+ }
43
+
44
+ /**
45
+ * @param {{ publishableKey: string, endpoint: string, platform: 'android'|'ios', appId?: string,
46
+ * certSha256?: string[], file?: { status?: number, body?: string } }} input
47
+ * @returns {{ ok: boolean, findings: { id: string, severity: 'ok'|'warn'|'error', title: string, fix: string }[] }}
48
+ */
49
+ export function doctorFindings(input) {
50
+ const findings = [];
51
+ const key = input.publishableKey ?? '';
52
+ if (key.startsWith('st_pub_')) findings.push(ok('key.ok', 'Publishable key is set'));
53
+ else if (key.startsWith('st_live_') || key.startsWith('st_test_'))
54
+ findings.push(err('key.secret', 'This is a secret key, not the publishable key', 'Use the publishable key (st_pub_…) from Dashboard → Get started, and revoke this secret key in Dashboard → Settings → Secret keys: it has been inside your app.'));
55
+ else findings.push(err('key.invalid', 'Not a publishable key', 'Copy the publishable key (st_pub_…) from Dashboard → Get started.'));
56
+
57
+ const host = doctorHost(input.endpoint ?? '');
58
+ if (!host) {
59
+ findings.push(err('endpoint.invalid', "The endpoint isn't an https link domain", 'Set endpoint to your link domain from Dashboard → Get started, e.g. https://<handle>.strait.link.'));
60
+ } else if (input.file) findings.push(...hostFindings(input, host, input.file));
61
+ return { ok: !findings.some((f) => f.severity === 'error'), findings };
62
+ }
63
+
64
+ function hostFindings(input, host, file) {
65
+ const android = input.platform === 'android';
66
+ if (file.status === undefined)
67
+ return [err('host.unreachable', `Can't reach ${host}`, 'Check that endpoint is your link domain (Dashboard → Get started) and that this device is online.')];
68
+ const apps = file.status === 200 ? listedApps(input.platform, file.body ?? '') : null;
69
+ if (!apps)
70
+ return [err('host.file_missing', `${host} doesn't serve ${android ? 'assetlinks.json' : 'apple-app-site-association'}`, 'Use your Strait link domain as endpoint (Dashboard → Get started); Strait serves this file there.')];
71
+ if (!apps.size) return [err('app.none', `No ${android ? 'Android' : 'iOS'} app is set up for ${host}`, 'Add your app in Dashboard → Apps & stores.')];
72
+
73
+ const listed = [...apps.keys()].join(', ');
74
+ const app = input.appId?.trim();
75
+ if (!app) return [{ id: 'app.unknown', severity: 'warn', title: `${host} lists ${listed}`, fix: 'Make sure your app is one of these.' }];
76
+ const match = android ? (apps.has(app) ? app : undefined) : [...apps.keys()].find((id) => id.endsWith(`.${app}`));
77
+ if (!match) return [err('app.missing', `${host} doesn't list ${app}`, `Add ${app} in Dashboard → Apps & stores (listed now: ${listed}).`)];
78
+
79
+ const out = [ok('app.listed', `${host} lists ${match}`)];
80
+ const certs = android ? (input.certSha256 ?? []).map(normFp).filter(Boolean) : [];
81
+ if (certs.length) {
82
+ const fps = apps.get(match);
83
+ out.push(
84
+ certs.some((c) => fps.includes(c))
85
+ ? ok('cert.listed', "This build's signing certificate is listed")
86
+ : err('cert.missing', `This build's signing certificate isn't listed for ${app}`, `Add ${certs.join(', ')} in Dashboard → Apps & stores. Debug, release and Play App Signing keys each have their own fingerprint.`),
87
+ );
88
+ }
89
+ return out;
90
+ }
91
+
92
+ const FILES = { android: 'assetlinks.json', ios: 'apple-app-site-association' };
93
+
94
+ /** One GET of the platform's well-known file, like the SDKs do. No status = the GET failed. */
95
+ async function fetchFile(fetchImpl, host, platform) {
96
+ try {
97
+ const res = await fetchImpl(`https://${host}/.well-known/${FILES[platform]}`, { method: 'GET', signal: AbortSignal.timeout(10_000) });
98
+ return { status: res.status, body: await res.text() };
99
+ } catch {
100
+ return {};
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Runs the doctor for each platform asked (both when neither app id is given). With no publishable
106
+ * key the key check is left out rather than failed: the CLI checks the host, the key lives in the app.
107
+ * @returns {Promise<{ ok: boolean, host: string, android?: object, ios?: object }>}
108
+ */
109
+ export async function runDoctor({ host, key, android, ios, certs = [], fetch: fetchImpl = fetch }) {
110
+ const platforms = android || ios ? [android && 'android', ios && 'ios'].filter(Boolean) : ['android', 'ios'];
111
+ const result = { ok: true, host };
112
+ await Promise.all(
113
+ platforms.map(async (platform) => {
114
+ const file = await fetchFile(fetchImpl, host, platform);
115
+ const report = doctorFindings({
116
+ publishableKey: key ?? 'st_pub_',
117
+ endpoint: `https://${host}`,
118
+ platform,
119
+ appId: platform === 'android' ? android : ios,
120
+ certSha256: platform === 'android' ? certs : [],
121
+ file,
122
+ });
123
+ if (!key) report.findings = report.findings.filter((f) => !f.id.startsWith('key.'));
124
+ report.ok = !report.findings.some((f) => f.severity === 'error');
125
+ result[platform] = report;
126
+ if (!report.ok) result.ok = false;
127
+ }),
128
+ );
129
+ return result;
130
+ }
131
+
132
+ const LABEL = { android: 'Android (assetlinks.json)', ios: 'iPhone (apple-app-site-association)' };
133
+
134
+ /** Plain text: the key line once, then each platform's checks with their fixes. */
135
+ export function formatDoctor(result) {
136
+ const line = (f) => ` ${f.severity.padEnd(5)} ${f.id.padEnd(17)} ${f.title}${f.fix ? `\n${' '.repeat(27)}Fix: ${f.fix}` : ''}`;
137
+ const reports = ['android', 'ios'].filter((p) => result[p]);
138
+ const keyLines = result[reports[0]].findings.filter((f) => f.id.startsWith('key.'));
139
+ const out = [`Strait doctor for ${result.host}`];
140
+ if (keyLines.length) out.push('', 'Key', ...keyLines.map(line));
141
+ for (const p of reports) out.push('', LABEL[p], ...result[p].findings.filter((f) => !f.id.startsWith('key.')).map(line));
142
+ const warned = reports.some((p) => result[p].findings.some((f) => f.severity === 'warn'));
143
+ out.push('', !result.ok ? 'Fix the errors above, then run strait doctor again.' : warned ? 'No errors. Check the warnings above.' : 'All checks passed.');
144
+ out.push("Intent filters and the Associated Domains entitlement aren't visible from here: call doctor() in a debug build, or see https://straitlink.in/tools/.");
145
+ return out.join('\n');
146
+ }
package/src/install.mjs CHANGED
@@ -23,8 +23,8 @@ export const sourceFor = (src, registry) => (typeof src.source === 'string' ? sr
23
23
  export const VERSIONS = {
24
24
  version: '0.9.4', // Swift (Unity is not written by this CLI)
25
25
  rnVersion: '0.9.3',
26
- flutterVersion: '0.9.3',
27
- androidVersion: '0.8.3',
26
+ flutterVersion: '0.9.4',
27
+ androidVersion: '0.8.4',
28
28
  };
29
29
 
30
30
  export const PACKAGES = {
package/src/links.mjs ADDED
@@ -0,0 +1,104 @@
1
+ // `strait links create|list|qr`: the public link API (https://straitlink.in/docs/api/) with a secret key.
2
+ // The key is read from --secret-key or STRAIT_SECRET_KEY (same name as @straitlink/mcp) and is never
3
+ // printed: anything we print goes through `redact` first.
4
+ import { existsSync, writeFileSync } from 'node:fs';
5
+ import { resolve } from 'node:path';
6
+ import { UsageError } from './validate.mjs';
7
+
8
+ export const DEFAULT_API_URL = 'https://strait.link';
9
+ const SECRET = /^st_(live|test)_[A-Za-z0-9]{8,}$/;
10
+
11
+ /** A runtime failure (API said no, network down): exit 1 with this message. */
12
+ export class ApiError extends Error {}
13
+
14
+ /** The secret key for the link API, or a UsageError that says where to get one. Never echoes the value. */
15
+ export function secretKey(value) {
16
+ const k = String(value ?? '').trim().replace(/^['"]+|['"]+$/g, '').replace(/^Bearer\s+/i, '');
17
+ const where = 'Create one in Dashboard → Settings → Secret keys and set STRAIT_SECRET_KEY (or pass --secret-key).';
18
+ if (!k) throw new UsageError(`The link API needs a secret key (st_live_…). ${where}`);
19
+ if (k.startsWith('st_pub_')) throw new UsageError(`That is the publishable key (st_pub_…), which only works inside apps. The link API needs a secret key (st_live_…). ${where}`);
20
+ if (!SECRET.test(k)) throw new UsageError(`That is not a secret key (st_live_… or st_test_…). ${where}`);
21
+ return k;
22
+ }
23
+
24
+ /** Replaces every Strait secret key in text with its prefix. */
25
+ export const redact = (text, key) => {
26
+ let s = String(text);
27
+ if (key) s = s.split(key).join(`${key.slice(0, key.indexOf('_', 3) + 1)}…`);
28
+ return s.replace(/\bst_(live|test)_[A-Za-z0-9]{8,}/g, 'st_$1_…');
29
+ };
30
+
31
+ export function apiClient({ apiUrl = DEFAULT_API_URL, key, fetch: fetchImpl = fetch, userAgent }) {
32
+ const base = String(apiUrl).replace(/\/+$/, '');
33
+ let parsed;
34
+ try {
35
+ parsed = new URL(base);
36
+ } catch {
37
+ throw new UsageError(`"${apiUrl}" is not a URL (--api-url / STRAIT_API_URL).`);
38
+ }
39
+ if (parsed.protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(parsed.hostname)) {
40
+ throw new UsageError('--api-url must be https (the secret key travels with every request).');
41
+ }
42
+ return async function call(method, path, { query = {}, body, accept = 'application/json' } = {}) {
43
+ const url = new URL(base + path);
44
+ for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== '') url.searchParams.set(k, String(v));
45
+ const headers = { accept, authorization: `Bearer ${key}` };
46
+ if (body !== undefined) headers['content-type'] = 'application/json';
47
+ if (userAgent) headers['user-agent'] = userAgent;
48
+ let res;
49
+ try {
50
+ res = await fetchImpl(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30_000) });
51
+ } catch {
52
+ throw new ApiError(`Can't reach ${base}. Check your connection, or https://straitlink.in/platform-status/.`);
53
+ }
54
+ if (!res.ok) {
55
+ const text = await res.text();
56
+ let msg = text.trim().slice(0, 300) || res.statusText;
57
+ try {
58
+ const j = JSON.parse(text);
59
+ if (j && typeof j === 'object' && 'error' in j) msg = typeof j.error === 'string' ? j.error : JSON.stringify(j.error);
60
+ } catch {
61
+ // not JSON: keep the text
62
+ }
63
+ throw new ApiError(redact(`${method} ${path}: ${msg} (HTTP ${res.status})`, key).replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, ''));
64
+ }
65
+ return res;
66
+ };
67
+ }
68
+
69
+ const enc = encodeURIComponent;
70
+
71
+ /** @returns {Promise<string>} what to print */
72
+ export async function linksCommand(sub, positionals, v, { call, cwd }) {
73
+ if (sub === 'create') {
74
+ const [slug, longUrl] = positionals;
75
+ if (!slug || !longUrl) throw new UsageError('Usage: strait links create <slug> <url> [--title …] [--campaign …] [--tag …]');
76
+ const body = { slug, longUrl };
77
+ if (v.title) body.title = v.title;
78
+ if (v.campaign) body.campaign = v.campaign;
79
+ if (v.tag?.length) body.tags = v.tag;
80
+ const link = await (await call('POST', '/v1/links', { body })).json();
81
+ return v.json ? JSON.stringify(link, null, 2) : `Created ${link.shortUrl}\n → ${link.longUrl}`;
82
+ }
83
+ if (sub === 'list') {
84
+ const page = await (await call('GET', '/v1/links', { query: { limit: v.limit, cursor: v.cursor, status: v.status } })).json();
85
+ if (v.json) return JSON.stringify(page, null, 2);
86
+ const rows = (page.links ?? []).map((l) => `${l.shortUrl} ${l.longUrl}${l.isActive === false ? ' (paused)' : ''}`);
87
+ if (!rows.length) rows.push('No links yet. Create one: strait links create <slug> <url>');
88
+ if (page.nextCursor) rows.push('', `More: strait links list --cursor ${page.nextCursor}`);
89
+ return rows.join('\n');
90
+ }
91
+ if (sub === 'qr') {
92
+ const [slug] = positionals;
93
+ if (!slug) throw new UsageError('Usage: strait links qr <slug> [--format png|svg] [--size 512] [--out file]');
94
+ const format = v.format ?? 'png';
95
+ if (!['png', 'svg'].includes(format)) throw new UsageError('--format is png or svg.');
96
+ const out = resolve(cwd, v.out ?? `${slug.replace(/[^A-Za-z0-9._-]/g, '_')}.${format}`);
97
+ if (existsSync(out) && !v.force) throw new UsageError(`${out} already exists. Pass --force to replace it, or --out to pick another file.`);
98
+ const res = await call('GET', `/v1/links/${enc(slug)}/qr`, { query: { format: v.format, size: v.size }, accept: format === 'svg' ? 'image/svg+xml' : 'image/png' });
99
+ const bytes = new Uint8Array(await res.arrayBuffer());
100
+ writeFileSync(out, bytes);
101
+ return `Saved the QR code for ${slug} to ${out} (${bytes.byteLength} bytes).`;
102
+ }
103
+ throw new UsageError(`strait links takes create, list or qr${sub ? `, not "${sub}"` : ''}.`);
104
+ }
@@ -0,0 +1,2 @@
1
+ // Kept equal to package.json version (test/links.test.mjs checks it). Sent as the API User-Agent.
2
+ export const CLI_VERSION = '0.2.0';