hacklab 0.12.8 → 0.13.1

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.
@@ -1,35 +1,79 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import * as clack from '@clack/prompts';
3
+ import { emitJsonError, requireSession } from '../api-client.js';
3
4
  import { captureEvent } from '../posthog.js';
5
+ import { resolveCommand } from '../resolve-command.js';
4
6
  import { loadSession, resolveAppUrl, unauthorizedHint, } from '../session.js';
5
- import { bold, dim, error, info, linkBlue, success } from '../ui.js';
6
- import { openBrowser } from '../utils/openBrowser.js';
7
- // `hacklab essay` — post and manage the essays on your profile. Thin wrappers
8
- // over the /api/essays routes: the markdown file is read locally but all real
9
- // validation (title, size, rendering, sanitization) happens on the backend.
10
- // Every subcommand takes --json so agents can drive it programmatically.
7
+ import { bold, dim, error, info, link, stripControl, success } from '../ui.js';
8
+ // `hacklab essay` — agent help on the bare command. `post` publishes markdown
9
+ // the agent already has (`--content`) or a file on disk (`--file`, or a bare
10
+ // path). `update` replaces the body at a stable URL. `view` reads one essay by
11
+ // id. `list` reads yours, a hacker's, or an org's. `delete` removes yours.
12
+ const SUBCOMMANDS = ['post', 'update', 'view', 'list', 'delete'];
11
13
  const ESSAYS_BASE = '/api/essays';
14
+ // Same rule as the server: 4..36 chars of a lowercased uuid / prefix.
15
+ const ID_PREFIX_RE = /^[0-9a-f][0-9a-f-]{3,35}$/;
16
+ /**
17
+ * `view` takes an essay id only. Anything that can't be an id prefix is a
18
+ * usage error pointing at `essay list <handle>` — a handle and a short hex id
19
+ * are indistinguishable, so guessing between them silently reads the wrong
20
+ * thing.
21
+ */
22
+ export function parseViewTarget(token) {
23
+ if (!token)
24
+ return { kind: 'missing' };
25
+ const raw = token.replace(/^@/, '');
26
+ if (!raw)
27
+ return { kind: 'missing' };
28
+ if (ID_PREFIX_RE.test(raw.toLowerCase())) {
29
+ return { kind: 'one', id: raw.toLowerCase() };
30
+ }
31
+ return { kind: 'not-an-id', token: raw };
32
+ }
33
+ /**
34
+ * The `essay list` target grammar, decided by argument count so a user
35
+ * literally named "org" still works as the bare one-arg form:
36
+ * (none) → your essays
37
+ * <handle> → that user's essays
38
+ * org <slug> → that org's essays
39
+ * org/<slug> → same, mirroring the web URL /org/<slug>
40
+ */
41
+ export function parseListTarget(positionals) {
42
+ if (positionals.length === 0)
43
+ return { kind: 'self' };
44
+ if (positionals.length === 1) {
45
+ const arg = positionals[0];
46
+ if (arg.startsWith('org/')) {
47
+ const slug = arg.slice('org/'.length);
48
+ return slug ? { kind: 'org', slug } : { kind: 'invalid' };
49
+ }
50
+ return { kind: 'user', handle: arg.replace(/^@/, '') };
51
+ }
52
+ if (positionals.length === 2 && positionals[0] === 'org') {
53
+ return { kind: 'org', slug: positionals[1] };
54
+ }
55
+ return { kind: 'invalid' };
56
+ }
12
57
  function printJson(data) {
13
58
  console.log(JSON.stringify(data, null, 2));
14
59
  }
15
- async function requireSession() {
16
- const session = await loadSession();
17
- if (!session) {
18
- error('not logged in');
19
- info(`run ${dim('hacklab login')} first`);
20
- process.exit(1);
21
- }
22
- return session;
60
+ function printHelp() {
61
+ console.log(`hacklab essay post --title <t> --content <md> [--json]`);
62
+ console.log(dim(' or --file <path.md>, or a bare <path.md>'));
63
+ console.log(`hacklab essay update <id> --content <md> [--json]`);
64
+ console.log(dim(' same URL'));
65
+ console.log('');
66
+ console.log(`hacklab essay list [<handle> | org <slug>] [--page N] [--json]`);
67
+ console.log(dim(' yours with no argument'));
68
+ console.log(`hacklab essay view <id> [--json]`);
69
+ console.log(dim(' one essay, full text'));
70
+ console.log('');
71
+ console.log(`hacklab essay delete <id> [--yes] [--json]`);
72
+ console.log(dim(' yours only; --yes skips the confirm'));
23
73
  }
24
- // A 401 here is nearly always a per-backend token mismatch rather than a real
25
- // auth failure, and the server's bare "Unauthorized" can't say so — only the
26
- // client knows which backend the session was minted against. Swap in the hint
27
- // that names the fix. Public reads pass no session and keep the server's text.
28
- export async function readError(res, session) {
29
- if (res.status === 401 && session)
30
- return unauthorizedHint(session);
31
- const data = (await res.json().catch(() => null));
32
- return data?.error ?? `request failed (${res.status})`;
74
+ function usage(exitCode = 1) {
75
+ printHelp();
76
+ process.exit(exitCode);
33
77
  }
34
78
  function flagValue(args, ...names) {
35
79
  for (const name of names) {
@@ -42,21 +86,51 @@ function flagValue(args, ...names) {
42
86
  }
43
87
  return undefined;
44
88
  }
45
- /** Drop a flag and (when it isn't `--flag=value`) its value from args. */
46
- function stripFlag(args, withValue, ...names) {
47
- const out = [];
89
+ // Flags that take a following value. Every arg-walker has to skip that value,
90
+ // or a legitimate `--content "- bullet"` reads as a flag of its own.
91
+ function unknownFlag(args, allowed, valueFlags) {
48
92
  for (let i = 0; i < args.length; i++) {
49
93
  const arg = args[i];
50
- if (names.includes(arg)) {
51
- if (withValue)
52
- i++;
94
+ if (!arg)
95
+ continue;
96
+ if (valueFlags.has(arg)) {
97
+ i++;
53
98
  continue;
54
99
  }
55
- if (names.some((name) => arg.startsWith(`${name}=`)))
100
+ if (!arg.startsWith('-'))
101
+ continue;
102
+ const name = arg.split('=')[0];
103
+ if (!name)
56
104
  continue;
57
- out.push(arg);
105
+ if (!allowed.has(name))
106
+ return name;
58
107
  }
59
- return out;
108
+ return undefined;
109
+ }
110
+ /** Non-flag arguments, in order, skipping the values that belong to a flag. */
111
+ function positionals(args, valueFlags) {
112
+ const found = [];
113
+ for (let i = 0; i < args.length; i++) {
114
+ const arg = args[i];
115
+ if (!arg || arg === '--json')
116
+ continue;
117
+ if (valueFlags.has(arg)) {
118
+ i++;
119
+ continue;
120
+ }
121
+ if ([...valueFlags].some((name) => arg.startsWith(`${name}=`)))
122
+ continue;
123
+ if (arg.startsWith('-'))
124
+ continue;
125
+ found.push(arg);
126
+ }
127
+ return found;
128
+ }
129
+ export async function readError(res, session) {
130
+ if (res.status === 401 && session)
131
+ return unauthorizedHint(session);
132
+ const data = (await res.json().catch(() => null));
133
+ return data?.error ?? `request failed (${res.status})`;
60
134
  }
61
135
  const MONTHS = [
62
136
  'jan',
@@ -72,21 +146,21 @@ const MONTHS = [
72
146
  'nov',
73
147
  'dec',
74
148
  ];
75
- // Essays get absolute dates ("jul 12 2026"), not chat's relative prefixes —
76
- // a list of long-lived posts reads like an archive, not a conversation.
77
149
  export function formatEssayDate(iso) {
78
150
  const d = new Date(iso);
79
151
  if (Number.isNaN(d.getTime()))
80
152
  return iso;
81
153
  return `${MONTHS[d.getMonth()]} ${d.getDate()} ${d.getFullYear()}`;
82
154
  }
83
- /** The short id shown in human output. Full ids stay in --json. */
84
155
  function shortId(id) {
85
156
  return id.slice(0, 8);
86
157
  }
87
- async function readMarkdownFile(path) {
158
+ async function readMarkdownFile(path, json) {
88
159
  if (!/\.(md|markdown)$/i.test(path)) {
89
- error(`expected a markdown file (.md), got: ${path}`);
160
+ const message = `expected a markdown file (.md), got: ${path}`;
161
+ if (json)
162
+ emitJsonError('invalid_fields', message);
163
+ error(message);
90
164
  process.exit(1);
91
165
  }
92
166
  try {
@@ -94,341 +168,484 @@ async function readMarkdownFile(path) {
94
168
  }
95
169
  catch (err) {
96
170
  const code = err.code;
97
- error(code === 'ENOENT' ? `file not found: ${path}` : `could not read ${path}`);
171
+ const message = code === 'ENOENT' ? `file not found: ${path}` : `could not read ${path}`;
172
+ if (json)
173
+ emitJsonError('read_failed', message);
174
+ error(message);
98
175
  process.exit(1);
99
176
  }
100
177
  }
101
- /** Meta line under a list entry: id · date · reading time (· synced) (· by author). */
102
- function metaLine(item, withAuthor) {
103
- const parts = [];
104
- if (withAuthor && item.authorHandle)
105
- parts.push(`by ${item.authorHandle}`);
106
- parts.push(shortId(item.id));
107
- parts.push(formatEssayDate(item.publishedAt));
108
- if (item.readingTimeMinutes)
109
- parts.push(`${item.readingTimeMinutes} min`);
110
- if (item.source === 'sync')
111
- parts.push('synced');
112
- return dim(` ${parts.join(' · ')}`);
178
+ /**
179
+ * The markdown body, from `--content`, `--file`, or the positional path the
180
+ * pre-flag CLI took (`essay post note.md`). Exactly one source.
181
+ */
182
+ async function resolveMarkdown(args, json, file) {
183
+ const content = flagValue(args, '--content');
184
+ const fileFlag = flagValue(args, '--file');
185
+ const sources = [content, fileFlag, file].filter((v) => v !== undefined);
186
+ if (sources.length > 1) {
187
+ const message = content !== undefined && fileFlag !== undefined
188
+ ? 'use either --content or --file, not both'
189
+ : 'use one markdown source: --content, --file, or a <file.md> path';
190
+ if (json)
191
+ emitJsonError('invalid_fields', message);
192
+ error(message);
193
+ process.exit(1);
194
+ }
195
+ if (content !== undefined) {
196
+ if (!content.trim()) {
197
+ const message = 'an essay needs --content or --file';
198
+ if (json)
199
+ emitJsonError('missing_content', message);
200
+ error(message);
201
+ process.exit(1);
202
+ }
203
+ return content;
204
+ }
205
+ const path = fileFlag ?? file;
206
+ if (path)
207
+ return readMarkdownFile(path, json);
208
+ const message = 'an essay needs --content or --file';
209
+ if (json)
210
+ emitJsonError('missing_content', message);
211
+ error(message);
212
+ process.exit(1);
113
213
  }
114
- // ── post / update ───────────────────────────────────────────────────────────
115
- async function essayPost(args, json) {
116
- const title = flagValue(args, '--title', '-t');
117
- const positionals = stripFlag(args, true, '--title', '-t').filter((a) => !a.startsWith('-'));
118
- const file = positionals[0];
119
- if (!file || positionals.length > 1) {
120
- error('usage: hacklab essay post <file.md> [--title "..."]');
214
+ const BODY_FLAGS = new Set(['--title', '--content', '--file', '--json']);
215
+ const BODY_VALUE_FLAGS = new Set(['--title', '--content', '--file']);
216
+ async function essayPost(args) {
217
+ const json = args.includes('--json');
218
+ const unknown = unknownFlag(args, BODY_FLAGS, BODY_VALUE_FLAGS);
219
+ if (unknown) {
220
+ if (json)
221
+ emitJsonError('invalid_fields', `unknown flag: ${unknown}`);
222
+ error(`unknown flag: ${unknown}`);
223
+ process.exit(1);
224
+ }
225
+ const rest = positionals(args, BODY_VALUE_FLAGS);
226
+ if (rest.length > 1) {
227
+ const message = 'usage: hacklab essay post --title <t> --content <md>';
228
+ if (json)
229
+ emitJsonError('usage', message);
230
+ error(message);
231
+ process.exit(1);
232
+ }
233
+ const title = flagValue(args, '--title');
234
+ if (!title) {
235
+ const message = 'an essay needs a title: --title "Why I built this"';
236
+ if (json)
237
+ emitJsonError('missing_title', message);
238
+ error(message);
239
+ process.exit(1);
240
+ }
241
+ const markdown = await resolveMarkdown(args, json, rest[0]);
242
+ const session = await requireSession(json);
243
+ let res;
244
+ try {
245
+ res = await fetch(`${resolveAppUrl(session)}${ESSAYS_BASE}`, {
246
+ method: 'POST',
247
+ headers: {
248
+ 'Content-Type': 'application/json',
249
+ Authorization: `Bearer ${session.token}`,
250
+ },
251
+ body: JSON.stringify({ markdown, title }),
252
+ });
253
+ }
254
+ catch (err) {
255
+ const message = err instanceof Error ? err.message : String(err);
256
+ if (json)
257
+ emitJsonError('network', message);
258
+ error(message);
121
259
  process.exit(1);
122
260
  }
123
- const session = await requireSession();
124
- const markdown = await readMarkdownFile(file);
125
- const res = await fetch(`${resolveAppUrl(session)}${ESSAYS_BASE}`, {
126
- method: 'POST',
127
- headers: {
128
- 'Content-Type': 'application/json',
129
- Authorization: `Bearer ${session.token}`,
130
- },
131
- body: JSON.stringify({ markdown, ...(title ? { title } : {}) }),
132
- });
133
261
  if (!res.ok) {
134
- error(await readError(res, session));
262
+ const message = await readError(res, session);
263
+ if (json)
264
+ emitJsonError('error', message);
265
+ error(message);
135
266
  process.exit(1);
136
267
  }
137
268
  const data = (await res.json());
138
269
  const url = `${resolveAppUrl(session)}${data.path}`;
270
+ await captureEvent(session.handle, 'cli_essay_published', {
271
+ essay_id: data.id,
272
+ has_custom_title: true,
273
+ });
139
274
  if (json) {
140
- printJson({ ...data, url });
275
+ printJson({ schemaVersion: 1, ...data, url });
141
276
  return;
142
277
  }
143
- success(`published ${bold(`"${data.title}"`)}`);
144
- info(`id ${bold(shortId(data.id))}`);
145
- info(linkBlue(url));
146
- console.log(dim(`\n update it later: hacklab essay update ${shortId(data.id)} <file.md>`));
147
- const publishSession = await loadSession();
148
- await captureEvent(publishSession?.handle, 'cli_essay_published', {
149
- essay_id: data.id,
150
- has_custom_title: title !== undefined,
151
- });
278
+ success(`published ${bold(stripControl(data.title))}`);
279
+ info(`${dim('id')} ${bold(shortId(data.id))}`);
280
+ info(link(url));
152
281
  }
153
- async function essayUpdate(args, json) {
154
- const title = flagValue(args, '--title', '-t');
155
- const positionals = stripFlag(args, true, '--title', '-t').filter((a) => !a.startsWith('-'));
156
- const [id, file] = positionals;
157
- if (!id || !file || positionals.length > 2) {
158
- error('usage: hacklab essay update <id> <file.md> [--title "..."]');
282
+ async function essayUpdate(args) {
283
+ const json = args.includes('--json');
284
+ const unknown = unknownFlag(args, BODY_FLAGS, BODY_VALUE_FLAGS);
285
+ if (unknown) {
286
+ if (json)
287
+ emitJsonError('invalid_fields', `unknown flag: ${unknown}`);
288
+ error(`unknown flag: ${unknown}`);
289
+ process.exit(1);
290
+ }
291
+ const rest = positionals(args, BODY_VALUE_FLAGS);
292
+ const id = rest[0];
293
+ if (!id || rest.length > 2) {
294
+ const message = 'usage: hacklab essay update <id> --content <md>';
295
+ if (json)
296
+ emitJsonError('usage', message);
297
+ error(message);
298
+ process.exit(1);
299
+ }
300
+ const title = flagValue(args, '--title');
301
+ const markdown = await resolveMarkdown(args, json, rest[1]);
302
+ const session = await requireSession(json);
303
+ let res;
304
+ try {
305
+ res = await fetch(`${resolveAppUrl(session)}${ESSAYS_BASE}/${encodeURIComponent(id)}`, {
306
+ method: 'PATCH',
307
+ headers: {
308
+ 'Content-Type': 'application/json',
309
+ Authorization: `Bearer ${session.token}`,
310
+ },
311
+ body: JSON.stringify({ markdown, ...(title ? { title } : {}) }),
312
+ });
313
+ }
314
+ catch (err) {
315
+ const message = err instanceof Error ? err.message : String(err);
316
+ if (json)
317
+ emitJsonError('network', message);
318
+ error(message);
159
319
  process.exit(1);
160
320
  }
161
- const session = await requireSession();
162
- const markdown = await readMarkdownFile(file);
163
- const res = await fetch(`${resolveAppUrl(session)}${ESSAYS_BASE}/${encodeURIComponent(id)}`, {
164
- method: 'PATCH',
165
- headers: {
166
- 'Content-Type': 'application/json',
167
- Authorization: `Bearer ${session.token}`,
168
- },
169
- body: JSON.stringify({ markdown, ...(title ? { title } : {}) }),
170
- });
171
321
  if (!res.ok) {
172
- error(await readError(res, session));
322
+ const message = await readError(res, session);
323
+ if (json)
324
+ emitJsonError('error', message);
325
+ error(message);
173
326
  process.exit(1);
174
327
  }
175
328
  const data = (await res.json());
176
329
  const url = `${resolveAppUrl(session)}${data.path}`;
177
- if (json) {
178
- printJson({ ...data, url });
179
- return;
180
- }
181
- success(`updated ${bold(`"${data.title}"`)}`);
182
- info(linkBlue(url));
183
330
  await captureEvent(session.handle, 'cli_essay_updated', {
184
331
  essay_id: data.id,
185
332
  has_custom_title: title !== undefined,
186
333
  });
334
+ if (json) {
335
+ printJson({ schemaVersion: 1, ...data, url });
336
+ return;
337
+ }
338
+ success(`updated ${bold(stripControl(data.title))}`);
339
+ info(link(url));
187
340
  }
188
- // ── delete ──────────────────────────────────────────────────────────────────
189
- async function essayDelete(args, json) {
190
- const yes = args.includes('--yes') || args.includes('-y');
191
- const positionals = stripFlag(args, false, '--yes', '-y').filter((a) => !a.startsWith('-'));
192
- const id = positionals[0];
193
- if (!id || positionals.length > 1) {
194
- error('usage: hacklab essay delete <id> [--yes]');
341
+ async function essayDelete(args) {
342
+ const json = args.includes('--json');
343
+ const unknown = unknownFlag(args, new Set(['--json', '--yes', '-y']), new Set());
344
+ if (unknown) {
345
+ if (json)
346
+ emitJsonError('invalid_fields', `unknown flag: ${unknown}`);
347
+ error(`unknown flag: ${unknown}`);
195
348
  process.exit(1);
196
349
  }
197
- const session = await requireSession();
198
- const base = resolveAppUrl(session);
199
- // Fetch first so the confirmation names the essay, not just an id.
200
- const viewRes = await fetch(`${base}${ESSAYS_BASE}/${encodeURIComponent(id)}`);
201
- if (!viewRes.ok) {
202
- error(await readError(viewRes));
350
+ const yes = args.includes('--yes') || args.includes('-y');
351
+ const id = args.find((a) => !a.startsWith('-'));
352
+ if (!id) {
353
+ const message = 'usage: hacklab essay delete <id> [--yes]';
354
+ if (json)
355
+ emitJsonError('usage', message);
356
+ error(message);
203
357
  process.exit(1);
204
358
  }
205
- const { essay } = (await viewRes.json());
359
+ const session = await requireSession(json);
360
+ const base = resolveAppUrl(session);
361
+ // Deletion is irreversible and `essay d <id>` resolves here by prefix, so a
362
+ // typo must not be enough on its own. The prompt names the essay, which
363
+ // costs one read — skipped entirely on the --yes and agent paths.
206
364
  if (!yes) {
207
- if (!process.stdin.isTTY) {
208
- error('refusing to delete without confirmation — pass --yes');
365
+ if (json || !process.stdin.isTTY) {
366
+ const message = 'refusing to delete without confirmation — pass --yes';
367
+ if (json)
368
+ emitJsonError('confirm', message);
369
+ error(message);
209
370
  process.exit(1);
210
371
  }
211
- const confirmed = await clack.confirm({
212
- message: `delete "${essay.title}"? this cannot be undone.`,
372
+ const preview = await fetch(`${base}${ESSAYS_BASE}/${encodeURIComponent(id)}`);
373
+ if (!preview.ok) {
374
+ error(await readError(preview, session));
375
+ process.exit(1);
376
+ }
377
+ const { essay: found } = (await preview.json());
378
+ const ok = await clack.confirm({
379
+ message: `delete ${bold(stripControl(found.title))}? this cannot be undone.`,
213
380
  initialValue: false,
214
381
  });
215
- if (clack.isCancel(confirmed) || !confirmed) {
382
+ if (clack.isCancel(ok) || !ok) {
216
383
  info('kept.');
217
384
  return;
218
385
  }
219
386
  }
220
- const res = await fetch(`${base}${ESSAYS_BASE}/${encodeURIComponent(id)}`, {
221
- method: 'DELETE',
222
- headers: { Authorization: `Bearer ${session.token}` },
223
- });
387
+ let res;
388
+ try {
389
+ res = await fetch(`${base}${ESSAYS_BASE}/${encodeURIComponent(id)}`, {
390
+ method: 'DELETE',
391
+ headers: { Authorization: `Bearer ${session.token}` },
392
+ });
393
+ }
394
+ catch (err) {
395
+ const message = err instanceof Error ? err.message : String(err);
396
+ if (json)
397
+ emitJsonError('network', message);
398
+ error(message);
399
+ process.exit(1);
400
+ }
224
401
  if (!res.ok) {
225
- error(await readError(res, session));
402
+ const message = await readError(res, session);
403
+ if (json)
404
+ emitJsonError('error', message);
405
+ error(message);
226
406
  process.exit(1);
227
407
  }
228
408
  const data = (await res.json());
229
- if (json) {
230
- printJson(data);
231
- return;
232
- }
233
- success(`deleted ${bold(`"${data.title}"`)}`);
234
409
  await captureEvent(session.handle, 'cli_essay_deleted', {
235
410
  essay_id: data.id,
236
411
  });
237
- }
238
- // ── view ────────────────────────────────────────────────────────────────────
239
- async function essayView(args, json) {
240
- const web = args.includes('--web') || args.includes('-w');
241
- const positionals = stripFlag(args, false, '--web', '-w').filter((a) => !a.startsWith('-'));
242
- const id = positionals[0];
243
- if (!id || positionals.length > 1) {
244
- error('usage: hacklab essay view <id> [--web]');
245
- process.exit(1);
246
- }
247
- // Viewing is public — a session only improves the base URL resolution.
248
- const session = await loadSession();
249
- const base = resolveAppUrl(session);
250
- const res = await fetch(`${base}${ESSAYS_BASE}/${encodeURIComponent(id)}`);
251
- if (!res.ok) {
252
- error(await readError(res));
253
- process.exit(1);
254
- }
255
- const { essay } = (await res.json());
256
- const url = `${base}${essay.path}`;
257
412
  if (json) {
258
- printJson({ essay: { ...essay, url } });
259
- return;
260
- }
261
- if (web) {
262
- const opened = await openBrowser(url);
263
- if (opened) {
264
- info(`opened ${linkBlue(url)}`);
265
- return;
266
- }
267
- info(`could not open a browser — ${linkBlue(url)}`);
413
+ printJson({ schemaVersion: 1, deleted: true, id: data.id });
268
414
  return;
269
415
  }
416
+ success(`deleted ${bold(stripControl(data.title))}`);
417
+ }
418
+ function renderEssay(essay, url) {
270
419
  const byline = [
271
420
  essay.authorDisplayName ?? essay.authorHandle,
272
421
  formatEssayDate(essay.publishedAt),
273
422
  essay.readingTimeMinutes ? `${essay.readingTimeMinutes} min` : null,
274
423
  ].filter(Boolean);
275
- console.log('');
276
- console.log(` ${bold(essay.title)}`);
424
+ console.log(` ${bold(stripControl(essay.title))}`);
277
425
  console.log(dim(` ${byline.join(' · ')}`));
278
- console.log('');
279
426
  if (essay.contentText) {
280
- for (const line of essay.contentText.split('\n')) {
281
- console.log(` ${line}`);
427
+ console.log('');
428
+ console.log(stripControl(essay.contentText).trimEnd());
429
+ }
430
+ console.log('');
431
+ info(link(url));
432
+ }
433
+ /** Meta line under a list entry: (by author ·) id · date · reading time (· synced). */
434
+ function metaLine(item, withAuthor) {
435
+ const parts = [];
436
+ if (withAuthor && item.authorHandle)
437
+ parts.push(`by ${item.authorHandle}`);
438
+ parts.push(shortId(item.id));
439
+ parts.push(formatEssayDate(item.publishedAt));
440
+ if (item.readingTimeMinutes)
441
+ parts.push(`${item.readingTimeMinutes} min`);
442
+ if (item.source === 'sync')
443
+ parts.push('synced');
444
+ return dim(` ${stripControl(parts.join(' · '))}`);
445
+ }
446
+ function renderList(data, subject, appUrl, self) {
447
+ const isOrg = data.kind === 'org';
448
+ if (data.items.length === 0) {
449
+ info(isOrg ? `no essays on ${subject}` : `no essays on @${subject}`);
450
+ if (self) {
451
+ info(`run ${dim('hacklab essay post --title "…" --content <md>')} to publish one`);
282
452
  }
453
+ return;
454
+ }
455
+ for (const item of data.items) {
456
+ console.log(` ${bold(stripControl(item.title))}`);
457
+ console.log(metaLine(item, isOrg));
458
+ }
459
+ console.log('');
460
+ if (data.page < data.totalPages) {
461
+ const target = isOrg ? `org ${subject}` : subject;
462
+ console.log(dim(` next → hacklab essay list ${target} --page ${data.page + 1}`));
283
463
  console.log('');
284
464
  }
285
- console.log(` ${linkBlue(url)}`);
465
+ info(link(`${appUrl}${isOrg ? `/org/${subject}` : `/${subject}`}`));
286
466
  }
287
- // ── list ────────────────────────────────────────────────────────────────────
288
- /**
289
- * The `essay list` target grammar, decided by argument count so a user
290
- * literally named "org" still works as the bare one-arg form:
291
- * (none) → your essays
292
- * <handle> → that user's essays
293
- * org <slug> → that org's essays
294
- * org/<slug> → same, mirroring the web URL /org/<slug>
295
- */
296
- export function parseListTarget(positionals) {
297
- if (positionals.length === 0)
298
- return { kind: 'self' };
299
- if (positionals.length === 1) {
300
- const arg = positionals[0];
301
- if (arg.startsWith('org/')) {
302
- const slug = arg.slice('org/'.length);
303
- return slug ? { kind: 'org', slug } : { kind: 'invalid' };
467
+ async function essayView(args) {
468
+ const json = args.includes('--json');
469
+ const unknown = unknownFlag(args, new Set(['--json']), new Set());
470
+ if (unknown) {
471
+ if (json)
472
+ emitJsonError('invalid_fields', `unknown flag: ${unknown}`);
473
+ error(`unknown flag: ${unknown}`);
474
+ process.exit(1);
475
+ }
476
+ const rest = positionals(args, new Set());
477
+ const target = parseViewTarget(rest.length === 1 ? rest[0] : undefined);
478
+ if (target.kind === 'missing') {
479
+ const message = 'usage: hacklab essay view <id>';
480
+ if (json)
481
+ emitJsonError('usage', message);
482
+ error(message);
483
+ process.exit(1);
484
+ }
485
+ if (target.kind === 'not-an-id') {
486
+ const message = `not an essay id: "${target.token}" — for a hacker's essays run: hacklab essay list ${target.token}`;
487
+ if (json)
488
+ emitJsonError('usage', message);
489
+ error(message);
490
+ process.exit(1);
491
+ }
492
+ // Viewing is public — a session only improves the base URL resolution.
493
+ const session = await loadSession();
494
+ const appUrl = resolveAppUrl(session);
495
+ let res;
496
+ try {
497
+ res = await fetch(`${appUrl}${ESSAYS_BASE}/${encodeURIComponent(target.id)}`);
498
+ }
499
+ catch (err) {
500
+ const message = err instanceof Error ? err.message : String(err);
501
+ if (json)
502
+ emitJsonError('network', message);
503
+ error(message);
504
+ process.exit(1);
505
+ }
506
+ if (!res.ok) {
507
+ if (res.status === 404) {
508
+ const message = `no essay named "${target.id}"`;
509
+ if (json)
510
+ emitJsonError('not_found', message);
511
+ error(message);
512
+ info(`if that's a handle, run ${dim(`hacklab essay list ${target.id}`)} instead`);
513
+ process.exit(1);
304
514
  }
305
- return { kind: 'user', handle: arg };
515
+ const message = await readError(res);
516
+ if (json)
517
+ emitJsonError('error', message);
518
+ error(message);
519
+ process.exit(1);
306
520
  }
307
- if (positionals.length === 2 && positionals[0] === 'org') {
308
- return { kind: 'org', slug: positionals[1] };
521
+ const body = (await res.json());
522
+ const url = `${appUrl}${body.essay.path}`;
523
+ if (json) {
524
+ printJson({ schemaVersion: 1, essay: { ...body.essay, url } });
525
+ return;
309
526
  }
310
- return { kind: 'invalid' };
527
+ renderEssay(body.essay, url);
311
528
  }
312
- async function essayList(args, json) {
313
- const rawPage = flagValue(args, '--page', '-p');
529
+ async function essayList(args) {
530
+ const json = args.includes('--json');
531
+ const unknown = unknownFlag(args, new Set(['--json', '--page']), new Set(['--page']));
532
+ if (unknown) {
533
+ if (json)
534
+ emitJsonError('invalid_fields', `unknown flag: ${unknown}`);
535
+ error(`unknown flag: ${unknown}`);
536
+ process.exit(1);
537
+ }
538
+ const rawPage = flagValue(args, '--page');
314
539
  const page = rawPage ? Number.parseInt(rawPage, 10) : 1;
315
- if (!Number.isInteger(page) || page < 1) {
316
- error(`--page must be a positive integer, got: ${rawPage}`);
540
+ if (rawPage !== undefined && (!Number.isInteger(page) || page < 1)) {
541
+ const message = `--page must be a positive integer, got: ${rawPage}`;
542
+ if (json)
543
+ emitJsonError('invalid_fields', message);
544
+ error(message);
317
545
  process.exit(1);
318
546
  }
319
- const positionals = stripFlag(args, true, '--page', '-p').filter((a) => !a.startsWith('-'));
320
- const target = parseListTarget(positionals);
547
+ const target = parseListTarget(positionals(args, new Set(['--page'])));
321
548
  if (target.kind === 'invalid') {
322
- error('usage: hacklab essay list [<handle> | org <slug>] [--page N]');
549
+ const message = 'usage: hacklab essay list [<handle> | org <slug>] [--page N]';
550
+ if (json)
551
+ emitJsonError('usage', message);
552
+ error(message);
323
553
  process.exit(1);
324
554
  }
325
- let query;
555
+ let session;
326
556
  let subject;
557
+ let query;
327
558
  if (target.kind === 'self') {
328
- const session = await requireSession();
559
+ session = await requireSession(json);
329
560
  if (!session.handle) {
330
- error('no username on this session');
331
- info(`run ${dim('hacklab login')} (or pass a username explicitly)`);
561
+ const message = 'no username on this session';
562
+ if (json)
563
+ emitJsonError('unauthorized', message);
564
+ error(message);
565
+ info(`run ${dim('hacklab login')} (or pass a handle explicitly)`);
332
566
  process.exit(1);
333
567
  }
334
- query = { user: session.handle };
335
568
  subject = session.handle;
336
- }
337
- else if (target.kind === 'user') {
338
- query = { user: target.handle };
339
- subject = target.handle;
569
+ query = { key: 'user', value: session.handle };
340
570
  }
341
571
  else {
342
- query = { org: target.slug };
343
- subject = target.slug;
572
+ session = await loadSession();
573
+ subject = target.kind === 'user' ? target.handle : target.slug;
574
+ query = { key: target.kind === 'user' ? 'user' : 'org', value: subject };
344
575
  }
345
- const session = await loadSession();
346
- const url = new URL(`${resolveAppUrl(session)}${ESSAYS_BASE}`);
347
- if ('user' in query)
348
- url.searchParams.set('user', query.user);
349
- else
350
- url.searchParams.set('org', query.org);
576
+ const appUrl = resolveAppUrl(session);
577
+ const url = new URL(`${appUrl}${ESSAYS_BASE}`);
578
+ url.searchParams.set(query.key, query.value);
351
579
  if (page > 1)
352
580
  url.searchParams.set('page', String(page));
353
- const res = await fetch(url);
581
+ let res;
582
+ try {
583
+ res = await fetch(url);
584
+ }
585
+ catch (err) {
586
+ const message = err instanceof Error ? err.message : String(err);
587
+ if (json)
588
+ emitJsonError('network', message);
589
+ error(message);
590
+ process.exit(1);
591
+ }
354
592
  if (!res.ok) {
355
- error(await readError(res));
593
+ if (res.status === 404) {
594
+ const message = query.key === 'org'
595
+ ? `no org named "${subject}"`
596
+ : `no hacker named "${subject}"`;
597
+ if (json)
598
+ emitJsonError('not_found', message);
599
+ error(message);
600
+ process.exit(1);
601
+ }
602
+ const message = await readError(res, session);
603
+ if (json)
604
+ emitJsonError('error', message);
605
+ error(message);
356
606
  process.exit(1);
357
607
  }
358
608
  const data = (await res.json());
359
609
  if (json) {
360
- printJson(data);
361
- return;
362
- }
363
- const isOrg = data.kind === 'org';
364
- const heading = isOrg
365
- ? `essays from ${bold(data.org.name)} (${data.total})`
366
- : `essays by ${bold(data.author.handle)} (${data.total})`;
367
- const pageSuffix = data.totalPages > 1 ? dim(` — page ${data.page}/${data.totalPages}`) : '';
368
- console.log('');
369
- console.log(` ${heading}${pageSuffix}`);
370
- if (data.items.length === 0) {
371
- console.log('');
372
- info('no essays yet.');
373
- if (!isOrg && session?.handle === data.author?.handle) {
374
- info(`post one: ${dim('hacklab essay post <file.md>')}`);
375
- }
610
+ printJson({ schemaVersion: 1, ...data });
376
611
  return;
377
612
  }
378
- for (const item of data.items) {
379
- console.log('');
380
- console.log(` ${bold(item.title)}`);
381
- console.log(metaLine(item, isOrg));
382
- }
383
- if (data.page < data.totalPages) {
384
- const target = isOrg ? `org ${subject}` : subject;
385
- console.log('');
386
- console.log(dim(` next → hacklab essay list ${target} --page ${data.page + 1}`));
387
- }
388
- console.log('');
389
- }
390
- // ── dispatch ────────────────────────────────────────────────────────────────
391
- function printEssayHelp() {
392
- console.log(`
393
- ${bold('hacklab essay')} — essays on your profile
394
-
395
- ${bold('post')} <file.md> [--title "..."] publish a markdown file as an essay
396
- ${bold('update')} <id> <file.md> replace an essay's content (URL stays stable)
397
- ${bold('delete')} <id> [--yes] delete your essay
398
- ${bold('view')} <id> [--web] read an essay (--web opens the browser)
399
- ${bold('list')} [<handle> | org <slug>] your essays, a user's, or an org's
400
- [--page N] 12 per page
401
-
402
- all subcommands take ${bold('--json')} for machine-readable output.
403
- ids are shown by post/list — any unique prefix works (e.g. 3f9c).
404
- `);
613
+ renderList(data, data.kind === 'org'
614
+ ? (data.org?.slug ?? subject)
615
+ : (data.author?.handle ?? subject), appUrl, target.kind === 'self');
405
616
  }
406
617
  export async function essay(args) {
407
618
  const json = args.includes('--json');
408
619
  const rest = args.filter((a) => a !== '--json');
409
- const sub = rest[0];
410
- const subArgs = rest.slice(1);
411
- switch (sub) {
412
- case 'post':
413
- return essayPost(subArgs, json);
414
- case 'update':
415
- return essayUpdate(subArgs, json);
416
- case 'delete':
417
- return essayDelete(subArgs, json);
418
- case 'view':
419
- return essayView(subArgs, json);
420
- case 'list':
421
- return essayList(subArgs, json);
422
- case undefined:
423
- case 'help':
424
- case '--help':
425
- case '-h':
426
- printEssayHelp();
427
- return;
428
- default:
429
- error(`unknown subcommand: ${sub}`);
430
- printEssayHelp();
431
- process.exit(1);
620
+ const [subToken, ...subArgs] = rest;
621
+ if (json)
622
+ subArgs.push('--json');
623
+ if (!subToken ||
624
+ subToken === '--help' ||
625
+ subToken === '-h' ||
626
+ subToken === 'help') {
627
+ usage(0);
628
+ }
629
+ if (subToken.startsWith('-'))
630
+ usage();
631
+ const resolved = resolveCommand(subToken, SUBCOMMANDS);
632
+ if (resolved.kind === 'ambiguous') {
633
+ error(`ambiguous: essay ${subToken} (${resolved.matches.join(', ')})`);
634
+ process.exit(1);
432
635
  }
636
+ if (resolved.kind === 'unknown') {
637
+ error(`unknown subcommand: essay ${subToken}`);
638
+ usage();
639
+ }
640
+ if (resolved.name === 'post')
641
+ return essayPost(subArgs);
642
+ if (resolved.name === 'update')
643
+ return essayUpdate(subArgs);
644
+ if (resolved.name === 'view')
645
+ return essayView(subArgs);
646
+ if (resolved.name === 'list')
647
+ return essayList(subArgs);
648
+ if (resolved.name === 'delete')
649
+ return essayDelete(subArgs);
433
650
  }
434
651
  //# sourceMappingURL=essay.js.map