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.
Files changed (3) hide show
  1. package/README.md +33 -2
  2. package/dist/cli.js +554 -0
  3. package/package.json +33 -4
package/README.md CHANGED
@@ -1,3 +1,34 @@
1
- # Temporary Holding Version
1
+ # lapseed
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
+ 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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }