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.
- package/README.md +18 -26
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/drops.d.ts +5 -3
- package/dist/commands/drops.d.ts.map +1 -1
- package/dist/commands/drops.js +42 -9
- package/dist/commands/drops.js.map +1 -1
- package/dist/commands/essay.d.ts +18 -2
- package/dist/commands/essay.d.ts.map +1 -1
- package/dist/commands/essay.js +496 -279
- package/dist/commands/essay.js.map +1 -1
- package/dist/commands/project.d.ts +0 -22
- package/dist/commands/project.d.ts.map +1 -1
- package/dist/commands/project.js +308 -738
- package/dist/commands/project.js.map +1 -1
- package/dist/commands/scan.d.ts.map +1 -1
- package/dist/commands/scan.js +26 -11
- package/dist/commands/scan.js.map +1 -1
- package/dist/commands/sync.js +1 -1
- package/dist/commands/sync.js.map +1 -1
- package/dist/registry.js +4 -4
- package/dist/registry.js.map +1 -1
- package/dist/share-card.d.ts +2 -1
- package/dist/share-card.d.ts.map +1 -1
- package/dist/share-card.js +8 -1
- package/dist/share-card.js.map +1 -1
- package/dist/share.d.ts +11 -3
- package/dist/share.d.ts.map +1 -1
- package/dist/share.js +27 -16
- package/dist/share.js.map +1 -1
- package/dist/utils/waitForEnter.d.ts +6 -0
- package/dist/utils/waitForEnter.d.ts.map +1 -1
- package/dist/utils/waitForEnter.js +22 -7
- package/dist/utils/waitForEnter.js.map +1 -1
- package/package.json +1 -1
package/dist/commands/essay.js
CHANGED
|
@@ -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,
|
|
6
|
-
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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 (
|
|
51
|
-
|
|
52
|
-
|
|
94
|
+
if (!arg)
|
|
95
|
+
continue;
|
|
96
|
+
if (valueFlags.has(arg)) {
|
|
97
|
+
i++;
|
|
53
98
|
continue;
|
|
54
99
|
}
|
|
55
|
-
if (
|
|
100
|
+
if (!arg.startsWith('-'))
|
|
101
|
+
continue;
|
|
102
|
+
const name = arg.split('=')[0];
|
|
103
|
+
if (!name)
|
|
56
104
|
continue;
|
|
57
|
-
|
|
105
|
+
if (!allowed.has(name))
|
|
106
|
+
return name;
|
|
58
107
|
}
|
|
59
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
if (
|
|
120
|
-
|
|
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
|
-
|
|
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(
|
|
144
|
-
info(
|
|
145
|
-
info(
|
|
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
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
error(
|
|
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
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
212
|
-
|
|
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(
|
|
382
|
+
if (clack.isCancel(ok) || !ok) {
|
|
216
383
|
info('kept.');
|
|
217
384
|
return;
|
|
218
385
|
}
|
|
219
386
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
281
|
-
|
|
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
|
-
|
|
465
|
+
info(link(`${appUrl}${isOrg ? `/org/${subject}` : `/${subject}`}`));
|
|
286
466
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
515
|
+
const message = await readError(res);
|
|
516
|
+
if (json)
|
|
517
|
+
emitJsonError('error', message);
|
|
518
|
+
error(message);
|
|
519
|
+
process.exit(1);
|
|
306
520
|
}
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
527
|
+
renderEssay(body.essay, url);
|
|
311
528
|
}
|
|
312
|
-
async function essayList(args
|
|
313
|
-
const
|
|
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
|
-
|
|
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
|
|
320
|
-
const target = parseListTarget(positionals);
|
|
547
|
+
const target = parseListTarget(positionals(args, new Set(['--page'])));
|
|
321
548
|
if (target.kind === 'invalid') {
|
|
322
|
-
|
|
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
|
|
555
|
+
let session;
|
|
326
556
|
let subject;
|
|
557
|
+
let query;
|
|
327
558
|
if (target.kind === 'self') {
|
|
328
|
-
|
|
559
|
+
session = await requireSession(json);
|
|
329
560
|
if (!session.handle) {
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
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
|
|
346
|
-
const url = new URL(`${
|
|
347
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|