goto-now 0.0.0-stage → 1.0.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.
Files changed (3) hide show
  1. package/README.md +15 -2
  2. package/bin/goto.js +156 -0
  3. package/package.json +12 -4
package/README.md CHANGED
@@ -1,3 +1,16 @@
1
- # Temporary Holding Version
1
+ # Link Shortener
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Create and manage GoTo.now short links from your terminal.
4
+
5
+ ```sh
6
+ npm install --global goto-now
7
+ goto login
8
+ goto shorten https://example.com/long-page --slug example
9
+ goto list
10
+ goto get example
11
+ goto analytics example
12
+ goto qr example -o example.png
13
+ goto delete example
14
+ ```
15
+
16
+ Create an approved API key in your GoTo.now dashboard. For CI, set `GOTO_API_KEY`.
package/bin/goto.js ADDED
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const pkg = require('../package.json');
8
+
9
+ const defaultBase = 'https://goto.now';
10
+
11
+ function configFile() {
12
+ const root = process.env.XDG_CONFIG_HOME || (process.platform === 'win32' && process.env.APPDATA) || path.join(os.homedir(), '.config');
13
+ return path.join(root, 'goto-now', 'config.json');
14
+ }
15
+
16
+ function config() {
17
+ try { return JSON.parse(fs.readFileSync(configFile(), 'utf8')); }
18
+ catch (error) { if (error.code === 'ENOENT') return {}; throw error; }
19
+ }
20
+
21
+ function save(value) {
22
+ fs.mkdirSync(path.dirname(configFile()), { recursive: true, mode: 0o700 });
23
+ fs.writeFileSync(configFile(), `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
24
+ try { fs.chmodSync(configFile(), 0o600); } catch {}
25
+ }
26
+
27
+ function secret(label) {
28
+ if (!process.stdin.isTTY || !process.stdout.isTTY || !process.stdin.setRawMode) throw new Error('Interactive login requires a terminal; use GOTO_API_KEY in scripts.');
29
+ return new Promise((resolve, reject) => {
30
+ let value = '';
31
+ process.stdout.write(label); process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.setEncoding('utf8');
32
+ const done = (error) => { process.stdin.setRawMode(false); process.stdin.pause(); process.stdin.off('data', input); process.stdout.write('\n'); error ? reject(error) : resolve(value.trim()); };
33
+ const input = (key) => {
34
+ if (key === '\u0003') return done(new Error('Cancelled.'));
35
+ if (key === '\r' || key === '\n') return done();
36
+ if (key === '\u007f' || key === '\b') value = value.slice(0, -1); else if (key >= ' ') value += key;
37
+ };
38
+ process.stdin.on('data', input);
39
+ });
40
+ }
41
+
42
+ function credentials() {
43
+ const stored = config();
44
+ return {
45
+ key: process.env.GOTO_API_KEY || stored.apiKey,
46
+ base: (process.env.GOTO_BASE_URL || stored.baseUrl || defaultBase).replace(/\/$/, ''),
47
+ };
48
+ }
49
+
50
+ async function request(endpoint, { method = 'GET', body, binary = false, headers = {} } = {}) {
51
+ const auth = credentials();
52
+ if (!auth.key) throw new Error('No API key found. Run `goto login` or set GOTO_API_KEY.');
53
+ const response = await fetch(`${auth.base}${endpoint}`, {
54
+ method,
55
+ headers: { Authorization: `Bearer ${auth.key}`, 'User-Agent': `goto-now/${pkg.version}`, ...(body ? { 'Content-Type': 'application/json' } : {}), ...headers },
56
+ body: body ? JSON.stringify(body) : undefined,
57
+ signal: AbortSignal.timeout(30_000),
58
+ });
59
+ if (binary && response.ok) return Buffer.from(await response.arrayBuffer());
60
+ const data = await response.json().catch(() => ({}));
61
+ if (!response.ok) throw new Error(data.error || `GoTo.now returned HTTP ${response.status}.`);
62
+ return data;
63
+ }
64
+
65
+ function help() {
66
+ return `Link Shortener ${pkg.version}
67
+
68
+ Usage:
69
+ goto login
70
+ goto logout
71
+ goto shorten <url> [--slug NAME] [--domain DOMAIN] [--reuse]
72
+ goto list [--limit 50] [--query TEXT] [--tag TAG]
73
+ goto get <short-code>
74
+ goto update <short-code> --url <destination>
75
+ goto analytics <short-code>
76
+ goto qr <short-code> [-o file.png] [--format png|svg|pdf]
77
+ goto suggest <url>
78
+ goto delete <short-code> --yes
79
+
80
+ Options:
81
+ --json Print JSON`;
82
+ }
83
+
84
+ function parse(argv) {
85
+ const args = [...argv];
86
+ const command = args.shift();
87
+ const flag = (name, fallback) => {
88
+ const index = args.indexOf(name);
89
+ if (index < 0) return fallback;
90
+ const value = args[index + 1];
91
+ if (!value || value.startsWith('--')) throw new Error(`${name} requires a value.`);
92
+ args.splice(index, 2); return value;
93
+ };
94
+ const boolean = (name) => { const found = args.includes(name); if (found) args.splice(args.indexOf(name), 1); return found; };
95
+ return {
96
+ command, json: boolean('--json'), yes: boolean('--yes'), reuse: boolean('--reuse'),
97
+ output: flag('-o', flag('--output')), slug: flag('--slug'), domain: flag('--domain'),
98
+ url: flag('--url'), limit: flag('--limit', '50'), query: flag('--query'), tag: flag('--tag'),
99
+ format: flag('--format', 'png'), args,
100
+ };
101
+ }
102
+
103
+ async function main() {
104
+ const argv = process.argv.slice(2);
105
+ if (!argv.length || argv.includes('--help') || argv.includes('-h')) return process.stdout.write(`${help()}\n`);
106
+ if (argv.includes('--version') || argv.includes('-v')) return process.stdout.write(`${pkg.version}\n`);
107
+ const options = parse(argv);
108
+ if (options.command === 'login') {
109
+ const key = await secret('GoTo.now API key: ');
110
+ if (!key.startsWith('gtn_')) throw new Error('That does not look like a GoTo.now API key.');
111
+ const stored = config(); stored.apiKey = key; save(stored);
112
+ process.stdout.write(`API key saved in ${configFile()}\n`); return;
113
+ }
114
+ if (options.command === 'logout') {
115
+ const stored = config(); delete stored.apiKey; save(stored); process.stdout.write('API key removed.\n'); return;
116
+ }
117
+ let data;
118
+ if (options.command === 'shorten') {
119
+ if (!options.args[0]) throw new Error('shorten requires a URL.');
120
+ data = await request('/api/v1/links', { method: 'POST', body: { url: options.args[0], ...(options.slug ? { slug: options.slug } : {}), ...(options.domain ? { domain: options.domain } : {}), findIfExists: options.reuse } });
121
+ } else if (options.command === 'list') {
122
+ const params = new URLSearchParams({ limit: options.limit });
123
+ if (options.query) params.set('q', options.query); if (options.tag) params.set('tag', options.tag);
124
+ data = await request(`/api/v1/links?${params}`);
125
+ } else if (['get', 'analytics'].includes(options.command)) {
126
+ if (!options.args[0]) throw new Error(`${options.command} requires a short code.`);
127
+ data = await request(`/api/v1/links/${encodeURIComponent(options.args[0])}${options.command === 'analytics' ? '/analytics' : ''}`);
128
+ } else if (options.command === 'update') {
129
+ if (!options.args[0] || !options.url) throw new Error('Usage: goto update <short-code> --url <destination>');
130
+ data = await request(`/api/v1/links/${encodeURIComponent(options.args[0])}`, { method: 'PATCH', body: { url: options.url } });
131
+ } else if (options.command === 'delete') {
132
+ if (!options.args[0] || !options.yes) throw new Error('Deletion is permanent. Repeat with `goto delete <short-code> --yes`.');
133
+ await request(`/api/v1/links/${encodeURIComponent(options.args[0])}`, { method: 'DELETE' });
134
+ process.stdout.write(`Deleted ${options.args[0]}.\n`); return;
135
+ } else if (options.command === 'suggest') {
136
+ if (!options.args[0]) throw new Error('suggest requires a URL.');
137
+ data = await request('/api/v1/slug-suggestions', { method: 'POST', body: { url: options.args[0] } });
138
+ } else if (options.command === 'qr') {
139
+ if (!options.args[0]) throw new Error('qr requires a short code.');
140
+ if (!['png', 'svg', 'pdf'].includes(options.format)) throw new Error('--format must be png, svg, or pdf.');
141
+ const bytes = await request(`/api/v1/links/${encodeURIComponent(options.args[0])}/qr?format=${options.format}`, { binary: true });
142
+ const output = path.resolve(options.output || `${options.args[0]}.${options.format}`);
143
+ if (fs.existsSync(output)) throw new Error(`Output already exists: ${output}`);
144
+ fs.writeFileSync(output, bytes, { mode: 0o600 }); process.stdout.write(`Saved ${output}\n`); return;
145
+ } else throw new Error(`Unknown command: ${options.command || ''}`);
146
+
147
+ if (options.json) process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
148
+ else if (options.command === 'list') process.stdout.write(`${(data.data || []).map((item) => `${item.short_url}\t${item.original_url}`).join('\n')}\n`);
149
+ else if (options.command === 'shorten') process.stdout.write(`${data.shortUrl}\n`);
150
+ else if (options.command === 'suggest') process.stdout.write(`${(data.data || []).join('\n')}\n`);
151
+ else process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
152
+ }
153
+
154
+ if (require.main === module) main().catch((error) => { process.stderr.write(`goto: ${error.message}\n`); process.exitCode = 1; });
155
+
156
+ module.exports = { parse };
package/package.json CHANGED
@@ -1,6 +1,14 @@
1
1
  {
2
2
  "name": "goto-now",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Create, manage, inspect, and measure short links from your terminal.",
5
+ "bin": { "goto": "bin/goto.js" },
6
+ "files": ["bin", "README.md"],
7
+ "scripts": { "test": "node --check bin/goto.js && node bin/goto.js --version", "prepublishOnly": "npm test" },
8
+ "engines": { "node": ">=20" },
9
+ "keywords": ["url-shortener", "short-link", "link-management", "analytics", "qr-code", "cli"],
10
+ "homepage": "https://goto.now/developers",
11
+ "license": "UNLICENSED",
12
+ "author": "GoTo.now",
13
+ "publishConfig": { "access": "public" }
14
+ }