lapseed 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.
- package/README.md +33 -2
- package/dist/cli.js +554 -0
- package/package.json +33 -4
package/README.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
-
#
|
|
1
|
+
# lapseed
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Lapseed command line: view any developer's CV in your terminal and manage the leases that let recruiters try your live projects on [lapseed.app](https://lapseed.app).
|
|
4
|
+
|
|
5
|
+
No dependencies. Needs Node.js 20.12 or newer.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx lapseed <username> # one-off, no install
|
|
9
|
+
npm i -g lapseed # installs `lseed` (and the `lapseed` alias)
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
lseed <username> # box-drawn CV with stats, projects and links
|
|
16
|
+
lseed login [--key lpk_…] [--api https://lapseed.com] # prompts for login/password if no key is given
|
|
17
|
+
lseed whoami
|
|
18
|
+
lseed logout
|
|
19
|
+
|
|
20
|
+
lseed lease generate demo --project hello --uses 3 --time 30m --read-only
|
|
21
|
+
lseed lease manage demo --add-uses 2 --time 1h --rps 10
|
|
22
|
+
lseed lease pause|resume|show|revoke demo
|
|
23
|
+
lseed lease ls [--project hello]
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Lease changes apply to live sessions immediately; revoking one disconnects its visitors.
|
|
27
|
+
|
|
28
|
+
## Configuration
|
|
29
|
+
|
|
30
|
+
- `~/.lapseed/config.json` (mode 600) stores your API URL and key after `lseed login`.
|
|
31
|
+
- `LAPSEED_API_URL` and `LAPSEED_API_KEY` override the saved config.
|
|
32
|
+
- `NO_COLOR` disables colour.
|
|
33
|
+
|
|
34
|
+
To expose a local app through Lapseed, use the separate [`servlink`](https://www.npmjs.com/package/servlink) package (`slink`).
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* lseed (alias: lapseed) — terminal CV viewer and lease control for Lapseed.
|
|
4
|
+
*
|
|
5
|
+
* lseed <username> | npx lapseed <username>
|
|
6
|
+
* lseed login | logout | whoami
|
|
7
|
+
* lseed lease generate <name> --project <slug> --uses <X> --time <Ym> [--read-only] [--rps n] [--body-mb n]
|
|
8
|
+
* lseed lease manage <name> [--add-uses n] [--time Ym] [--rps n] [--body-mb n] [--read-only|--read-write] [--pause|--resume]
|
|
9
|
+
* lseed lease pause|resume|revoke|show <name>
|
|
10
|
+
* lseed lease ls [--project <slug>]
|
|
11
|
+
*/
|
|
12
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { createInterface } from 'node:readline';
|
|
16
|
+
import { parseArgs } from 'node:util';
|
|
17
|
+
const VERSION = '1.0.0';
|
|
18
|
+
// ─── terminal styling ───────────────────────────────────────────────────────
|
|
19
|
+
const useColor = process.stdout.isTTY === true && !process.env.NO_COLOR && process.env.TERM !== 'dumb';
|
|
20
|
+
const esc = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : String(s));
|
|
21
|
+
const c = {
|
|
22
|
+
emerald: esc('38;2;16;185;129'),
|
|
23
|
+
orange: esc('38;2;255;77;0'),
|
|
24
|
+
mint: esc('38;2;110;231;183'),
|
|
25
|
+
dim: esc('2'),
|
|
26
|
+
bold: esc('1'),
|
|
27
|
+
red: esc('38;2;239;68;68'),
|
|
28
|
+
};
|
|
29
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
30
|
+
const visibleLength = (s) => [...s.replace(ANSI_RE, '')].length;
|
|
31
|
+
const padVisible = (s, width) => s + ' '.repeat(Math.max(0, width - visibleLength(s)));
|
|
32
|
+
function truncate(s, width) {
|
|
33
|
+
const chars = [...s];
|
|
34
|
+
return chars.length <= width ? s : `${chars.slice(0, Math.max(0, width - 1)).join('')}…`;
|
|
35
|
+
}
|
|
36
|
+
function wrap(text, width) {
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const para of text.split(/\r?\n/)) {
|
|
39
|
+
let line = '';
|
|
40
|
+
for (const word of para.split(/\s+/).filter(Boolean)) {
|
|
41
|
+
const w = truncate(word, width);
|
|
42
|
+
if (!line)
|
|
43
|
+
line = w;
|
|
44
|
+
else if (visibleLength(line) + 1 + visibleLength(w) <= width)
|
|
45
|
+
line += ` ${w}`;
|
|
46
|
+
else {
|
|
47
|
+
out.push(line);
|
|
48
|
+
line = w;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
out.push(line);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
function fail(message, code = 1) {
|
|
56
|
+
process.stderr.write(`${c.orange('✗')} ${message}\n`);
|
|
57
|
+
process.exit(code);
|
|
58
|
+
}
|
|
59
|
+
const CONFIG_DIR = join(homedir(), '.lapseed');
|
|
60
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
61
|
+
function loadConfig() {
|
|
62
|
+
try {
|
|
63
|
+
return existsSync(CONFIG_FILE) ? JSON.parse(readFileSync(CONFIG_FILE, 'utf8')) : {};
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function saveConfig(cfg) {
|
|
70
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
71
|
+
writeFileSync(CONFIG_FILE, `${JSON.stringify(cfg, null, 2)}\n`, { mode: 0o600 });
|
|
72
|
+
try {
|
|
73
|
+
chmodSync(CONFIG_FILE, 0o600);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
/* not supported on this filesystem */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const cfg = loadConfig();
|
|
80
|
+
const apiUrl = (process.env.LAPSEED_API_URL ?? cfg.apiUrl ?? 'https://lapseed.com').replace(/\/+$/, '');
|
|
81
|
+
const apiKey = process.env.LAPSEED_API_KEY ?? cfg.apiKey;
|
|
82
|
+
class ApiError extends Error {
|
|
83
|
+
status;
|
|
84
|
+
code;
|
|
85
|
+
constructor(status, message, code) {
|
|
86
|
+
super(message);
|
|
87
|
+
this.status = status;
|
|
88
|
+
this.code = code;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function api(method, path, body, opts = {}) {
|
|
92
|
+
const headers = { accept: 'application/json', 'user-agent': `lapseed-cli/${VERSION}` };
|
|
93
|
+
if (body !== undefined)
|
|
94
|
+
headers['content-type'] = 'application/json';
|
|
95
|
+
if (opts.auth !== false && apiKey)
|
|
96
|
+
headers.authorization = `Bearer ${apiKey}`;
|
|
97
|
+
let res;
|
|
98
|
+
try {
|
|
99
|
+
res = await fetch(`${apiUrl}${path}`, {
|
|
100
|
+
method,
|
|
101
|
+
headers,
|
|
102
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
103
|
+
signal: AbortSignal.timeout(20_000),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
throw new ApiError(0, `Cannot reach ${apiUrl} (${err.message})`);
|
|
108
|
+
}
|
|
109
|
+
const text = await res.text();
|
|
110
|
+
let json = null;
|
|
111
|
+
try {
|
|
112
|
+
json = text ? JSON.parse(text) : null;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
/* non-JSON error body */
|
|
116
|
+
}
|
|
117
|
+
if (!res.ok) {
|
|
118
|
+
const e = json?.error;
|
|
119
|
+
throw new ApiError(res.status, e?.message ?? `HTTP ${res.status}`, e?.code);
|
|
120
|
+
}
|
|
121
|
+
return json;
|
|
122
|
+
}
|
|
123
|
+
function requireKey() {
|
|
124
|
+
if (!apiKey)
|
|
125
|
+
fail(`Not logged in. Run ${c.emerald('lseed login')} first (or set LAPSEED_API_KEY).`);
|
|
126
|
+
}
|
|
127
|
+
// ─── prompts ────────────────────────────────────────────────────────────────
|
|
128
|
+
function prompt(question, hidden = false) {
|
|
129
|
+
return new Promise((resolve) => {
|
|
130
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: process.stdin.isTTY === true });
|
|
131
|
+
let muted = false;
|
|
132
|
+
const rlInternal = rl;
|
|
133
|
+
const original = rlInternal._writeToOutput.bind(rl);
|
|
134
|
+
rlInternal._writeToOutput = (s) => {
|
|
135
|
+
if (!muted)
|
|
136
|
+
original(s);
|
|
137
|
+
else if (s.includes('\n') || s.includes('\r'))
|
|
138
|
+
original('\n');
|
|
139
|
+
};
|
|
140
|
+
rl.question(question, (answer) => {
|
|
141
|
+
rl.close();
|
|
142
|
+
if (hidden)
|
|
143
|
+
process.stdout.write('\n');
|
|
144
|
+
resolve(answer.trim());
|
|
145
|
+
});
|
|
146
|
+
muted = hidden;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
const stripScheme = (u) => u.replace(/^https?:\/\//, '');
|
|
150
|
+
const statusDot = (s) => (s === 'ONLINE' ? `${c.emerald('●')} ${c.emerald('ONLINE')}` : `${c.dim('○ DORMANT')}`);
|
|
151
|
+
const verifiedMark = (v) => (v ? ` ${c.emerald('✓ verified')}` : ` ${c.dim('unverified')}`);
|
|
152
|
+
function renderCv(cv) {
|
|
153
|
+
const W = Math.max(60, Math.min(process.stdout.columns ?? 80, 84));
|
|
154
|
+
const inner = W - 4;
|
|
155
|
+
const B = c.emerald;
|
|
156
|
+
const out = [];
|
|
157
|
+
const row = (content = '') => out.push(`${B('│')} ${padVisible(content, inner)} ${B('│')}`);
|
|
158
|
+
const section = (title) => {
|
|
159
|
+
const label = ` ${title} `;
|
|
160
|
+
out.push(B('├─') + c.orange(c.bold(label)) + B(`${'─'.repeat(Math.max(0, W - 3 - visibleLength(label)))}┤`));
|
|
161
|
+
};
|
|
162
|
+
out.push(B(`┌${'─'.repeat(W - 2)}┐`));
|
|
163
|
+
const brand = `${c.emerald(c.bold('~>'))} ${c.bold('lapseed')}`;
|
|
164
|
+
const handle = c.orange(`@${cv.username}`);
|
|
165
|
+
row(brand + ' '.repeat(Math.max(1, inner - visibleLength(brand) - visibleLength(handle))) + handle);
|
|
166
|
+
out.push(B(`├${'─'.repeat(W - 2)}┤`));
|
|
167
|
+
row(c.bold(truncate(cv.name, inner)));
|
|
168
|
+
if (cv.bio)
|
|
169
|
+
for (const l of wrap(cv.bio, inner))
|
|
170
|
+
row(l);
|
|
171
|
+
if (cv.education)
|
|
172
|
+
row(c.mint(truncate(`edu ${cv.education}`, inner)));
|
|
173
|
+
section('ROLE TAGS');
|
|
174
|
+
const tags = cv.role_tags.map((t) => c.emerald(`[${t}]`));
|
|
175
|
+
let line = '';
|
|
176
|
+
for (const t of tags) {
|
|
177
|
+
if (line && visibleLength(line) + 1 + visibleLength(t) > inner) {
|
|
178
|
+
row(line);
|
|
179
|
+
line = t;
|
|
180
|
+
}
|
|
181
|
+
else
|
|
182
|
+
line = line ? `${line} ${t}` : t;
|
|
183
|
+
}
|
|
184
|
+
row(line || c.dim('none'));
|
|
185
|
+
section('LIVE NODE');
|
|
186
|
+
row(statusDot(cv.node.status) + c.dim(cv.node.status === 'ONLINE' ? ' slink tunnel connected' : ' slink tunnel offline'));
|
|
187
|
+
section('LEETCODE');
|
|
188
|
+
if (cv.leetcode) {
|
|
189
|
+
const lc = cv.leetcode;
|
|
190
|
+
row(`${c.bold(lc.username)}${verifiedMark(lc.verified)}`);
|
|
191
|
+
row(`${c.emerald('Easy')} ${lc.easy} ${c.orange('Medium')} ${lc.medium} ${c.red('Hard')} ${lc.hard} ${c.bold('Total')} ${lc.total}`);
|
|
192
|
+
const extras = [
|
|
193
|
+
lc.contestRating !== null ? `Contest rating ${c.bold(lc.contestRating)}` : null,
|
|
194
|
+
lc.contestTopPercentage !== null ? `top ${lc.contestTopPercentage}%` : null,
|
|
195
|
+
lc.ranking !== null ? `global rank #${lc.ranking.toLocaleString('en-US')}` : null,
|
|
196
|
+
].filter(Boolean);
|
|
197
|
+
if (extras.length)
|
|
198
|
+
row(c.dim(extras.join(' · ')));
|
|
199
|
+
}
|
|
200
|
+
else
|
|
201
|
+
row(c.dim('not linked'));
|
|
202
|
+
section('GITHUB');
|
|
203
|
+
if (cv.github) {
|
|
204
|
+
const gh = cv.github;
|
|
205
|
+
row(`${c.bold(gh.login)}${verifiedMark(gh.verified)}`);
|
|
206
|
+
row(`${gh.publicRepos} repos ${c.orange('★')} ${gh.totalStars} stars ${gh.followers} followers`);
|
|
207
|
+
const barWidth = Math.max(10, inner - 26);
|
|
208
|
+
for (const lang of gh.languages.slice(0, 5)) {
|
|
209
|
+
const filled = Math.max(1, Math.round((lang.percent / 100) * barWidth));
|
|
210
|
+
const bar = c.emerald('█'.repeat(filled)) + c.dim('░'.repeat(Math.max(0, barWidth - filled)));
|
|
211
|
+
row(`${padVisible(truncate(lang.name, 14), 14)} ${bar} ${String(lang.percent).padStart(5)}%`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
else
|
|
215
|
+
row(c.dim('not linked'));
|
|
216
|
+
section('PROJECTS');
|
|
217
|
+
if (cv.projects.length === 0)
|
|
218
|
+
row(c.dim('no projects yet'));
|
|
219
|
+
cv.projects.forEach((p, i) => {
|
|
220
|
+
if (i > 0)
|
|
221
|
+
row();
|
|
222
|
+
row(`${c.orange('▸')} ${c.bold(truncate(p.title, inner - 4))}`);
|
|
223
|
+
row(` ${c.dim('pitch')} ${c.mint(truncate(stripScheme(p.pitch_url), inner - 8))}`);
|
|
224
|
+
const live = ` ${c.dim('live ')} ${c.mint(stripScheme(p.live_url))}`;
|
|
225
|
+
const dot = statusDot(p.node_status);
|
|
226
|
+
row(visibleLength(live) + 2 + visibleLength(dot) <= inner ? `${live} ${dot}` : live);
|
|
227
|
+
});
|
|
228
|
+
out.push(B(`└${'─'.repeat(W - 2)}┘`));
|
|
229
|
+
out.push(` ${c.dim(stripScheme(cv.profile_url))}`);
|
|
230
|
+
return out.join('\n');
|
|
231
|
+
}
|
|
232
|
+
/** Parse `30m`, `2h`, `1h30m`, `1d`, or a bare number of minutes. */
|
|
233
|
+
function parseDuration(input) {
|
|
234
|
+
const s = input.trim().toLowerCase();
|
|
235
|
+
if (/^\d+$/.test(s))
|
|
236
|
+
return Number(s);
|
|
237
|
+
const re = /(\d+)\s*(d|h|m)/g;
|
|
238
|
+
let total = 0;
|
|
239
|
+
let consumed = '';
|
|
240
|
+
for (const m of s.matchAll(re)) {
|
|
241
|
+
const n = Number(m[1]);
|
|
242
|
+
total += m[2] === 'd' ? n * 1440 : m[2] === 'h' ? n * 60 : n;
|
|
243
|
+
consumed += m[0];
|
|
244
|
+
}
|
|
245
|
+
if (!total || consumed.replace(/\s/g, '') !== s.replace(/\s/g, '')) {
|
|
246
|
+
fail(`Invalid duration "${input}". Use e.g. 30m, 2h, 1h30m, 1d.`);
|
|
247
|
+
}
|
|
248
|
+
return total;
|
|
249
|
+
}
|
|
250
|
+
function formatMinutes(min) {
|
|
251
|
+
if (min % 1440 === 0)
|
|
252
|
+
return `${min / 1440}d`;
|
|
253
|
+
if (min >= 60)
|
|
254
|
+
return min % 60 ? `${Math.floor(min / 60)}h${min % 60}m` : `${min / 60}h`;
|
|
255
|
+
return `${min}m`;
|
|
256
|
+
}
|
|
257
|
+
function parseInt10(name, v) {
|
|
258
|
+
if (v === undefined)
|
|
259
|
+
return undefined;
|
|
260
|
+
if (!/^-?\d+$/.test(v))
|
|
261
|
+
fail(`--${name} must be an integer`);
|
|
262
|
+
return Number(v);
|
|
263
|
+
}
|
|
264
|
+
function leaseState(l) {
|
|
265
|
+
if (l.revoked)
|
|
266
|
+
return c.red('revoked');
|
|
267
|
+
if (l.paused)
|
|
268
|
+
return c.orange('paused');
|
|
269
|
+
if (l.remaining_uses === 0)
|
|
270
|
+
return c.dim('exhausted');
|
|
271
|
+
return c.emerald('active');
|
|
272
|
+
}
|
|
273
|
+
function printLease(l, headline) {
|
|
274
|
+
const kv = (k, v) => console.log(` ${c.dim(k.padEnd(10))} ${v}`);
|
|
275
|
+
console.log(`${c.emerald(c.bold('~>'))} ${headline} ${c.bold(l.name)}`);
|
|
276
|
+
kv('project', l.project_slug);
|
|
277
|
+
kv('sessions', `${c.bold(l.remaining_uses)}/${l.max_uses} left · ${l.active_sessions} active now`);
|
|
278
|
+
kv('duration', `${formatMinutes(l.duration_minutes)} per session`);
|
|
279
|
+
kv('mode', l.read_only ? c.orange('read-only') : 'read-write');
|
|
280
|
+
kv('limits', `${l.max_rps} req/s · ${l.max_body_mb} MB body`);
|
|
281
|
+
kv('state', leaseState(l));
|
|
282
|
+
kv('node', `${l.node_id} ${statusDot(l.node_status)}`);
|
|
283
|
+
console.log(`\n ${c.orange(l.link)}\n`);
|
|
284
|
+
}
|
|
285
|
+
function printLeaseTable(list) {
|
|
286
|
+
if (list.length === 0) {
|
|
287
|
+
console.log(c.dim('No leases yet. Create one with: lseed lease generate <name> --project <slug> --uses 3 --time 30m'));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const headers = ['NAME', 'PROJECT', 'USES', 'TIME', 'MODE', 'RPS', 'LIVE', 'STATE', 'NODE'];
|
|
291
|
+
const rows = list.map((l) => [
|
|
292
|
+
c.bold(l.name),
|
|
293
|
+
l.project_slug,
|
|
294
|
+
`${l.remaining_uses}/${l.max_uses}`,
|
|
295
|
+
formatMinutes(l.duration_minutes),
|
|
296
|
+
l.read_only ? c.orange('ro') : 'rw',
|
|
297
|
+
String(l.max_rps),
|
|
298
|
+
String(l.active_sessions),
|
|
299
|
+
leaseState(l),
|
|
300
|
+
l.node_status === 'ONLINE' ? c.emerald('●') : c.dim('○'),
|
|
301
|
+
]);
|
|
302
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => visibleLength(r[i]))));
|
|
303
|
+
console.log(headers.map((h, i) => c.dim(h.padEnd(widths[i]))).join(' '));
|
|
304
|
+
for (const r of rows)
|
|
305
|
+
console.log(r.map((cell, i) => padVisible(cell, widths[i])).join(' '));
|
|
306
|
+
}
|
|
307
|
+
// ─── commands ───────────────────────────────────────────────────────────────
|
|
308
|
+
async function cmdCv(username) {
|
|
309
|
+
if (!/^[a-z0-9-]{3,32}$/i.test(username))
|
|
310
|
+
fail(`"${username}" is not a valid Lapseed username`);
|
|
311
|
+
const { cv } = await api('GET', `/api/cv/${encodeURIComponent(username.toLowerCase())}`, undefined, { auth: false });
|
|
312
|
+
console.log(renderCv(cv));
|
|
313
|
+
}
|
|
314
|
+
async function cmdLogin(argv) {
|
|
315
|
+
const { values } = parseArgs({
|
|
316
|
+
args: argv,
|
|
317
|
+
options: { key: { type: 'string' }, login: { type: 'string' }, api: { type: 'string' } },
|
|
318
|
+
allowPositionals: false,
|
|
319
|
+
});
|
|
320
|
+
const target = (values.api ?? apiUrl).replace(/\/+$/, '');
|
|
321
|
+
if (values.key) {
|
|
322
|
+
if (!values.key.startsWith('lpk_'))
|
|
323
|
+
fail('API keys start with lpk_');
|
|
324
|
+
const res = await fetch(`${target}/api/auth/me`, { headers: { authorization: `Bearer ${values.key}` } });
|
|
325
|
+
if (!res.ok)
|
|
326
|
+
fail('That API key was rejected');
|
|
327
|
+
const { user } = (await res.json());
|
|
328
|
+
saveConfig({ ...cfg, apiUrl: target, apiKey: values.key, username: user.username });
|
|
329
|
+
console.log(`${c.emerald('✓')} Logged in as ${c.bold(`@${user.username}`)}`);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const login = values.login ?? (await prompt(`${c.emerald('~>')} email or username: `));
|
|
333
|
+
const password = await prompt(`${c.emerald('~>')} password: `, true);
|
|
334
|
+
if (!login || !password)
|
|
335
|
+
fail('Login and password are required');
|
|
336
|
+
const res = await fetch(`${target}/api/auth/cli-token`, {
|
|
337
|
+
method: 'POST',
|
|
338
|
+
headers: { 'content-type': 'application/json' },
|
|
339
|
+
body: JSON.stringify({ login, password, label: `lapseed-cli@${process.platform}` }),
|
|
340
|
+
});
|
|
341
|
+
const json = (await res.json().catch(() => ({})));
|
|
342
|
+
if (!res.ok || !json.api_key)
|
|
343
|
+
fail(json.error?.message ?? `Login failed (HTTP ${res.status})`);
|
|
344
|
+
saveConfig({ ...cfg, apiUrl: target, apiKey: json.api_key, username: json.username });
|
|
345
|
+
console.log(`${c.emerald('✓')} Logged in as ${c.bold(`@${json.username}`)} ${c.dim(`(key saved to ${CONFIG_FILE})`)}`);
|
|
346
|
+
}
|
|
347
|
+
async function cmdWhoami() {
|
|
348
|
+
requireKey();
|
|
349
|
+
const { user } = await api('GET', '/api/auth/me');
|
|
350
|
+
console.log(`${c.emerald('~>')} ${c.bold(`@${user.username}`)} ${c.dim(`${user.role} · ${user.email}`)}`);
|
|
351
|
+
if (user.server) {
|
|
352
|
+
const s = user.server;
|
|
353
|
+
console.log(` ${c.dim('node')} ${s.node_id ?? c.dim('not bound')} ${s.node_id ? statusDot(s.status) : ''}`);
|
|
354
|
+
}
|
|
355
|
+
console.log(` ${c.dim('api ')} ${apiUrl}`);
|
|
356
|
+
}
|
|
357
|
+
async function cmdLease(argv) {
|
|
358
|
+
const [sub, ...rest] = argv;
|
|
359
|
+
if (!sub || sub === 'help' || sub === '--help') {
|
|
360
|
+
printHelp();
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
requireKey();
|
|
364
|
+
switch (sub) {
|
|
365
|
+
case 'generate':
|
|
366
|
+
case 'gen':
|
|
367
|
+
case 'create': {
|
|
368
|
+
const { values, positionals } = parseArgs({
|
|
369
|
+
args: rest,
|
|
370
|
+
allowPositionals: true,
|
|
371
|
+
options: {
|
|
372
|
+
project: { type: 'string', short: 'p' },
|
|
373
|
+
uses: { type: 'string', short: 'u' },
|
|
374
|
+
time: { type: 'string', short: 't' },
|
|
375
|
+
'read-only': { type: 'boolean', default: false },
|
|
376
|
+
rps: { type: 'string' },
|
|
377
|
+
'body-mb': { type: 'string' },
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
const name = positionals[0];
|
|
381
|
+
if (!name)
|
|
382
|
+
fail('Usage: lseed lease generate <name> --project <slug> --uses <X> --time <Ym> [--read-only]');
|
|
383
|
+
if (!values.project)
|
|
384
|
+
fail('--project <slug> is required');
|
|
385
|
+
if (!values.uses)
|
|
386
|
+
fail('--uses <X> is required');
|
|
387
|
+
if (!values.time)
|
|
388
|
+
fail('--time <Ym> is required (e.g. 30m)');
|
|
389
|
+
const { lease } = await api('POST', '/api/leases', {
|
|
390
|
+
name,
|
|
391
|
+
project_slug: values.project,
|
|
392
|
+
max_uses: parseInt10('uses', values.uses),
|
|
393
|
+
duration_minutes: parseDuration(values.time),
|
|
394
|
+
read_only: values['read-only'],
|
|
395
|
+
max_rps: parseInt10('rps', values.rps),
|
|
396
|
+
max_body_mb: parseInt10('body-mb', values['body-mb']),
|
|
397
|
+
});
|
|
398
|
+
printLease(lease, 'minted');
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
case 'manage':
|
|
402
|
+
case 'update': {
|
|
403
|
+
const { values, positionals } = parseArgs({
|
|
404
|
+
args: rest,
|
|
405
|
+
allowPositionals: true,
|
|
406
|
+
options: {
|
|
407
|
+
'add-uses': { type: 'string' },
|
|
408
|
+
uses: { type: 'string' },
|
|
409
|
+
time: { type: 'string', short: 't' },
|
|
410
|
+
rps: { type: 'string' },
|
|
411
|
+
'body-mb': { type: 'string' },
|
|
412
|
+
'read-only': { type: 'boolean' },
|
|
413
|
+
'read-write': { type: 'boolean' },
|
|
414
|
+
pause: { type: 'boolean' },
|
|
415
|
+
resume: { type: 'boolean' },
|
|
416
|
+
rename: { type: 'string' },
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
const name = positionals[0];
|
|
420
|
+
if (!name)
|
|
421
|
+
fail('Usage: lseed lease manage <name> --add-uses <n> --time <Ym> --rps <n>');
|
|
422
|
+
if (values['read-only'] && values['read-write'])
|
|
423
|
+
fail('Choose either --read-only or --read-write');
|
|
424
|
+
if (values.pause && values.resume)
|
|
425
|
+
fail('Choose either --pause or --resume');
|
|
426
|
+
const patch = {
|
|
427
|
+
add_uses: parseInt10('add-uses', values['add-uses']),
|
|
428
|
+
max_uses: parseInt10('uses', values.uses),
|
|
429
|
+
duration_minutes: values.time ? parseDuration(values.time) : undefined,
|
|
430
|
+
max_rps: parseInt10('rps', values.rps),
|
|
431
|
+
max_body_mb: parseInt10('body-mb', values['body-mb']),
|
|
432
|
+
read_only: values['read-only'] ? true : values['read-write'] ? false : undefined,
|
|
433
|
+
paused: values.pause ? true : values.resume ? false : undefined,
|
|
434
|
+
name: values.rename,
|
|
435
|
+
};
|
|
436
|
+
for (const k of Object.keys(patch))
|
|
437
|
+
if (patch[k] === undefined)
|
|
438
|
+
delete patch[k];
|
|
439
|
+
if (Object.keys(patch).length === 0)
|
|
440
|
+
fail('Nothing to change. Try --add-uses 2, --time 45m or --rps 10');
|
|
441
|
+
const { lease } = await api('PATCH', `/api/leases/${encodeURIComponent(name)}`, patch);
|
|
442
|
+
printLease(lease, 'updated (hot, tunnel untouched)');
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
case 'pause':
|
|
446
|
+
case 'resume': {
|
|
447
|
+
const name = rest[0];
|
|
448
|
+
if (!name)
|
|
449
|
+
fail(`Usage: lseed lease ${sub} <name>`);
|
|
450
|
+
const { lease } = await api('PATCH', `/api/leases/${encodeURIComponent(name)}`, { paused: sub === 'pause' });
|
|
451
|
+
console.log(`${c.emerald('✓')} ${c.bold(lease.name)} is now ${leaseState(lease)}`);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
case 'revoke':
|
|
455
|
+
case 'rm': {
|
|
456
|
+
const name = rest[0];
|
|
457
|
+
if (!name)
|
|
458
|
+
fail('Usage: lseed lease revoke <name>');
|
|
459
|
+
await api('DELETE', `/api/leases/${encodeURIComponent(name)}`);
|
|
460
|
+
console.log(`${c.emerald('✓')} Revoked ${c.bold(name)} — link disabled and all live sessions terminated.`);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
case 'show':
|
|
464
|
+
case 'get': {
|
|
465
|
+
const name = rest[0];
|
|
466
|
+
if (!name)
|
|
467
|
+
fail('Usage: lseed lease show <name>');
|
|
468
|
+
const { lease } = await api('GET', `/api/leases/${encodeURIComponent(name)}`);
|
|
469
|
+
printLease(lease, 'lease');
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
case 'ls':
|
|
473
|
+
case 'list': {
|
|
474
|
+
const { values } = parseArgs({ args: rest, options: { project: { type: 'string', short: 'p' } } });
|
|
475
|
+
const q = values.project ? `?project=${encodeURIComponent(values.project)}` : '';
|
|
476
|
+
const { leases } = await api('GET', `/api/leases${q}`);
|
|
477
|
+
printLeaseTable(leases);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
default:
|
|
481
|
+
fail(`Unknown lease command "${sub}". Run lseed help.`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
function printHelp() {
|
|
485
|
+
const e = c.emerald;
|
|
486
|
+
console.log(`${e(c.bold('~>'))} ${c.bold('lapseed')} ${c.dim(`v${VERSION}`)}
|
|
487
|
+
|
|
488
|
+
${c.orange('VIEW')}
|
|
489
|
+
${e('lseed <username>')} Render a developer's terminal CV
|
|
490
|
+
|
|
491
|
+
${c.orange('ACCOUNT')}
|
|
492
|
+
${e('lseed login')} [--key lpk_…] [--api URL] Sign in and store a CLI API key
|
|
493
|
+
${e('lseed logout')} Forget the stored key
|
|
494
|
+
${e('lseed whoami')} Show the signed-in account
|
|
495
|
+
|
|
496
|
+
${c.orange('LEASES')} ${c.dim('(live access links to your slink server)')}
|
|
497
|
+
${e('lseed lease generate')} <name> --project <slug> --uses <X> --time <Ym> [--read-only] [--rps n] [--body-mb n]
|
|
498
|
+
${e('lseed lease manage')} <name> [--add-uses n] [--uses n] [--time Ym] [--rps n] [--body-mb n]
|
|
499
|
+
[--read-only|--read-write] [--pause|--resume] [--rename new]
|
|
500
|
+
${e('lseed lease pause|resume')} <name>
|
|
501
|
+
${e('lseed lease revoke')} <name>
|
|
502
|
+
${e('lseed lease show')} <name>
|
|
503
|
+
${e('lseed lease ls')} [--project <slug>]
|
|
504
|
+
|
|
505
|
+
${c.dim('Alias: lapseed (e.g. npx lapseed <username>)')}
|
|
506
|
+
${c.dim('Env: LAPSEED_API_URL (default https://lapseed.com), LAPSEED_API_KEY, NO_COLOR')}`);
|
|
507
|
+
}
|
|
508
|
+
async function main() {
|
|
509
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
510
|
+
switch (cmd) {
|
|
511
|
+
case undefined:
|
|
512
|
+
case 'help':
|
|
513
|
+
case '--help':
|
|
514
|
+
case '-h':
|
|
515
|
+
printHelp();
|
|
516
|
+
return;
|
|
517
|
+
case '--version':
|
|
518
|
+
case '-v':
|
|
519
|
+
case 'version':
|
|
520
|
+
console.log(VERSION);
|
|
521
|
+
return;
|
|
522
|
+
case 'login':
|
|
523
|
+
return cmdLogin(rest);
|
|
524
|
+
case 'logout':
|
|
525
|
+
if (existsSync(CONFIG_FILE)) {
|
|
526
|
+
const { apiUrl: keepUrl } = cfg;
|
|
527
|
+
rmSync(CONFIG_FILE);
|
|
528
|
+
if (keepUrl)
|
|
529
|
+
saveConfig({ apiUrl: keepUrl });
|
|
530
|
+
}
|
|
531
|
+
console.log(`${c.emerald('✓')} Logged out`);
|
|
532
|
+
return;
|
|
533
|
+
case 'whoami':
|
|
534
|
+
return cmdWhoami();
|
|
535
|
+
case 'lease':
|
|
536
|
+
case 'leases':
|
|
537
|
+
return cmdLease(rest);
|
|
538
|
+
default:
|
|
539
|
+
if (cmd.startsWith('-'))
|
|
540
|
+
fail(`Unknown option ${cmd}. Run lseed help.`);
|
|
541
|
+
return cmdCv(cmd.replace(/^@/, ''));
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
main().catch((err) => {
|
|
545
|
+
if (err instanceof ApiError) {
|
|
546
|
+
if (err.status === 401)
|
|
547
|
+
fail(`${err.message}. Run ${c.emerald('lseed login')}.`);
|
|
548
|
+
fail(err.message);
|
|
549
|
+
}
|
|
550
|
+
if (err instanceof Error && 'code' in err && String(err.code).startsWith('ERR_PARSE_ARGS')) {
|
|
551
|
+
fail(err.message);
|
|
552
|
+
}
|
|
553
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
554
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lapseed",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "lseed: view any Lapseed developer's CV in your terminal and manage project leases",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"lseed": "./dist/cli.js",
|
|
8
|
+
"lapseed": "./dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist/cli.js",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20.12"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc -p tsconfig.json",
|
|
19
|
+
"prepack": "npm run build",
|
|
20
|
+
"dev": "tsx src/cli.ts"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"lapseed",
|
|
24
|
+
"cli",
|
|
25
|
+
"cv",
|
|
26
|
+
"resume",
|
|
27
|
+
"terminal"
|
|
28
|
+
],
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^26.6.6",
|
|
31
|
+
"tsx": "^4.23.15",
|
|
32
|
+
"typescript": "^7.0.2"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT"
|
|
35
|
+
}
|