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,74 +1,41 @@
1
- import { readFile } from 'node:fs/promises';
2
- import { basename } from 'node:path';
3
1
  import * as clack from '@clack/prompts';
4
- import { parse as parseYaml } from 'yaml';
2
+ import { apiErrorMessage, emitJsonError, requireSession, } from '../api-client.js';
5
3
  import { captureEvent } from '../posthog.js';
6
4
  import { isGithubRepoUrl, normalizeRepoUrl, probeRepoPrivate, slugFromName, } from '../project-fields.js';
7
5
  import { resolveCommand } from '../resolve-command.js';
8
- import { loadSession, resolveAppUrl, unauthorizedHint, } from '../session.js';
6
+ import { resolveAppUrl } from '../session.js';
9
7
  import { fetchApi } from '../sync.js';
10
- import { bold, dim, error, info, linkBlue, success } from '../ui.js';
11
- import { openBrowser } from '../utils/openBrowser.js';
12
- // `hacklab project` — publish and manage the projects on your profile. `add`
13
- // takes explicit flags (`--title` plus optional URLs/tags/content); `apply`
14
- // takes a declarative YAML/JSON manifest for agents or long-form content.
15
- // Both are idempotent per slug: a re-run refreshes the same project without
16
- // touching its publish date or losing screenshots.
17
- const SUBCOMMANDS = ['add', 'apply', 'list', 'view', 'edit', 'delete'];
8
+ import { bold, dim, error, info, link, stripControl, success } from '../ui.js';
9
+ // `hacklab project` — agent help on the bare command; `add` publishes a
10
+ // project from flags the agent already has (no cwd, no git, no files).
11
+ // `list`/`view`/`edit`/`delete` work on your own projects. Re-running `add`
12
+ // with the same title refreshes the same slug.
13
+ const SUBCOMMANDS = ['add', 'list', 'view', 'edit', 'delete'];
18
14
  const PROJECTS_PATH = '/api/projects';
19
15
  function printJson(data) {
20
16
  console.log(JSON.stringify(data, null, 2));
21
17
  }
22
- function emitJsonError(code, message) {
23
- console.log(JSON.stringify({ schemaVersion: 1, error: { code, message } }));
24
- process.exit(1);
18
+ function printHelp() {
19
+ console.log(`hacklab project add --title <t> [--repo <url>] [--url <url>] [--desc <d>] [--json]`);
20
+ console.log(dim(' publish one; same title again updates'));
21
+ console.log(dim(' --repo is the source, --url the live site; one is enough'));
22
+ console.log(dim(' --private/--public override the repo visibility probe'));
23
+ console.log('');
24
+ console.log(`hacklab project list [--json]`);
25
+ console.log(dim(' your projects'));
26
+ console.log(`hacklab project view <slug> [--json]`);
27
+ console.log(dim(' one of yours, full page'));
28
+ console.log('');
29
+ console.log(`hacklab project edit <slug> [--title <t>] [--desc <d>] [--repo <url>] [--url <url>] [--yes] [--json]`);
30
+ console.log(dim(' change fields, keep the slug; --private/--public too'));
31
+ console.log(dim(' --yes to edit a github-synced project (ends the sync)'));
32
+ console.log(`hacklab project delete <slug> [--yes] [--json]`);
33
+ console.log(dim(' yours only; --yes skips the confirm'));
25
34
  }
26
35
  function usage(exitCode = 1) {
27
- if (exitCode === 0)
28
- info('usage: hacklab project [add|apply|list|view|edit|delete]');
29
- else
30
- error('usage: hacklab project [add|apply|list|view|edit|delete]');
31
- info(` hacklab project ${dim('add --title <t> [--yes] [--json]')} publish a project (re-run to refresh)`);
32
- info(` hacklab project ${dim('add --desc/--url/--repo/--live/--tags/--slug')} set any field`);
33
- info(` hacklab project ${dim('add --content <md> | --content-file <path>')} set the long-form content`);
34
- info(` hacklab project ${dim('apply <file> [--yes] [--json]')} publish from a yaml/json manifest`);
35
- info(` hacklab project ${dim('list [--json]')} your projects`);
36
- info(` hacklab project ${dim('view <slug> [--web] [--json]')} show one`);
37
- info(` hacklab project ${dim('edit <slug> --title/--desc/--url/--repo/--tags')} change fields`);
38
- info(` hacklab project ${dim('edit <slug> --clear-repo|--clear-live')} drop a URL`);
39
- info(` hacklab project ${dim('delete <slug> [--yes] [--json]')} remove one`);
36
+ printHelp();
40
37
  process.exit(exitCode);
41
38
  }
42
- async function requireSession(json) {
43
- const session = await loadSession();
44
- if (!session) {
45
- if (json)
46
- emitJsonError('unauthorized', 'not logged in');
47
- error('not logged in');
48
- info(`run ${dim('hacklab login')} first`);
49
- process.exit(1);
50
- }
51
- return session;
52
- }
53
- async function readEnvelopeError(res, session) {
54
- if (res.status === 401)
55
- return unauthorizedHint(session);
56
- const data = (await res.json().catch(() => null));
57
- if (typeof data?.error === 'string')
58
- return data.error;
59
- return data?.error?.message ?? `request failed (${res.status})`;
60
- }
61
- async function fetchProjects(session) {
62
- const res = await fetchApi(session, PROJECTS_PATH, {
63
- headers: { Authorization: `Bearer ${session.token}` },
64
- });
65
- if (!res.ok)
66
- throw new Error(await readEnvelopeError(res, session));
67
- const data = (await res.json().catch(() => null));
68
- if (!data?.projects)
69
- throw new Error('got a malformed response from hacklab');
70
- return data;
71
- }
72
39
  function flagValue(args, ...names) {
73
40
  for (const name of names) {
74
41
  const i = args.indexOf(name);
@@ -80,281 +47,92 @@ function flagValue(args, ...names) {
80
47
  }
81
48
  return undefined;
82
49
  }
83
- /** Split a `--tags a,b,c` value into clean tag names. */
84
- export function parseTags(value) {
85
- return value
86
- .split(',')
87
- .map((t) => t.trim().toLowerCase())
88
- .filter(Boolean)
89
- .slice(0, 20);
50
+ // Flags that take a following value. Every arg-walker has to skip that value,
51
+ // or a legitimate `--title "-30 days"` reads as a flag of its own.
52
+ const VALUE_FLAGS = new Set([
53
+ '--title',
54
+ '--repo',
55
+ '--url',
56
+ '--desc',
57
+ '--description',
58
+ ]);
59
+ const ADD_FLAGS = new Set([...VALUE_FLAGS, '--private', '--public', '--json']);
60
+ const EDIT_FLAGS = new Set([...ADD_FLAGS, '--yes', '-y']);
61
+ function unknownFlag(args, allowed, valueFlags) {
62
+ for (let i = 0; i < args.length; i++) {
63
+ const arg = args[i];
64
+ if (!arg)
65
+ continue;
66
+ if (valueFlags.has(arg)) {
67
+ i++;
68
+ continue;
69
+ }
70
+ if (!arg.startsWith('-'))
71
+ continue;
72
+ const name = arg.split('=')[0];
73
+ if (!name)
74
+ continue;
75
+ if (!allowed.has(name))
76
+ return name;
77
+ }
78
+ return undefined;
90
79
  }
91
- // og:image discovery: one GET of the live page, one regex. Both content-first
92
- // and property-first attribute orders appear in the wild.
93
- const OG_IMAGE_PATTERNS = [
94
- /<meta[^>]+(?:property|name)=["']og:image["'][^>]*content=["']([^"']+)["']/i,
95
- /<meta[^>]+content=["']([^"']+)["'][^>]*(?:property|name)=["']og:image["']/i,
96
- ];
97
- export function extractOgImage(html, baseUrl) {
98
- for (const pattern of OG_IMAGE_PATTERNS) {
99
- const match = html.match(pattern);
100
- if (match?.[1]) {
101
- try {
102
- return new URL(match[1], baseUrl).toString();
103
- }
104
- catch {
105
- return null;
106
- }
80
+ /** The single non-flag argument, skipping the values that belong to a flag. */
81
+ function positional(args, valueFlags) {
82
+ const found = [];
83
+ for (let i = 0; i < args.length; i++) {
84
+ const arg = args[i];
85
+ if (!arg || arg === '--json')
86
+ continue;
87
+ if (valueFlags.has(arg)) {
88
+ i++;
89
+ continue;
107
90
  }
91
+ if ([...valueFlags].some((name) => arg.startsWith(`${name}=`)))
92
+ continue;
93
+ if (arg.startsWith('-'))
94
+ continue;
95
+ found.push(arg);
108
96
  }
109
- return null;
97
+ return found.length === 1 ? found[0] : undefined;
110
98
  }
111
- const SCREENSHOT_MAX_BYTES = 4 * 1024 * 1024;
112
- /**
113
- * Best-effort screenshot from the live site's og:image. Any failure —
114
- * unreachable site, no tag, oversized or non-png/jpeg image, upload error —
115
- * returns null and costs nothing but the attempt.
116
- */
117
- async function captureOgScreenshot(session, liveUrl) {
99
+ function parseHttpUrl(value) {
118
100
  try {
119
- const page = await fetch(liveUrl, {
120
- signal: AbortSignal.timeout(8000),
121
- headers: { 'User-Agent': 'HacklabCLI (+https://hacklab.so)' },
122
- });
123
- if (!page.ok)
124
- return null;
125
- const imageUrl = extractOgImage(await page.text(), liveUrl);
126
- if (!imageUrl)
127
- return null;
128
- const image = await fetch(imageUrl, {
129
- signal: AbortSignal.timeout(8000),
130
- headers: { 'User-Agent': 'HacklabCLI (+https://hacklab.so)' },
131
- });
132
- if (!image.ok)
101
+ const url = new URL(value);
102
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
133
103
  return null;
134
- const contentType = image.headers.get('content-type')?.split(';')[0];
135
- if (contentType !== 'image/png' && contentType !== 'image/jpeg')
136
- return null;
137
- const bytes = Buffer.from(await image.arrayBuffer());
138
- if (bytes.length === 0 || bytes.length > SCREENSHOT_MAX_BYTES)
139
- return null;
140
- const upload = await fetchApi(session, '/api/screenshots', {
141
- method: 'POST',
142
- headers: {
143
- 'Content-Type': 'application/json',
144
- Authorization: `Bearer ${session.token}`,
145
- },
146
- body: JSON.stringify({
147
- image: bytes.toString('base64'),
148
- filename: contentType === 'image/png' ? 'og.png' : 'og.jpg',
149
- contentType,
150
- }),
151
- });
152
- const data = (await upload.json().catch(() => null));
153
- if (!upload.ok || !data?.url)
154
- return null;
155
- return { url: data.url, caption: '' };
104
+ return value.trim();
156
105
  }
157
106
  catch {
158
107
  return null;
159
108
  }
160
109
  }
110
+ async function readError(res, session) {
111
+ const body = (await res.json().catch(() => null));
112
+ const message = typeof body?.error === 'string' ? body.error : body?.error?.message;
113
+ return apiErrorMessage(res.status, { error: { message } }, session);
114
+ }
161
115
  function summarize(value, max = 72) {
162
116
  if (!value)
163
- return dim('(none)');
117
+ return '';
164
118
  const oneLine = value.replace(/\s+/g, ' ').trim();
165
119
  return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
166
120
  }
167
- // Keep in sync with PROJECT_SCREENSHOT_CONTENT_TYPES in
168
- // apps/web/app/(app)/api/screenshots/route.ts (server-side counterpart).
169
- const SCREENSHOT_CONTENT_TYPES = new Map([
170
- ['image/png', 'png'],
171
- ['image/jpeg', 'jpg'],
172
- ['image/webp', 'webp'],
173
- ]);
174
- function httpUrl(value, field) {
175
- if (value === undefined || value === null || value === '')
176
- return null;
177
- if (typeof value !== 'string')
178
- throw new Error(`${field} must be a URL`);
179
- const url = new URL(value);
180
- if (url.protocol !== 'http:' && url.protocol !== 'https:') {
181
- throw new Error(`${field} must use http or https`);
182
- }
183
- return url.toString();
184
- }
185
- function requiredHttpUrl(value, field) {
186
- const url = httpUrl(value, field);
187
- if (!url)
188
- throw new Error(`${field} is required`);
189
- return url;
190
- }
191
- /** Parse the agent-friendly project.yaml/json shape before sending it. */
192
- export function parseProjectDocument(doc) {
193
- if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) {
194
- return { ok: false, error: 'expected a project mapping' };
195
- }
196
- const input = doc;
197
- const allowed = new Set([
198
- 'title',
199
- 'slug',
200
- 'description',
201
- 'tags',
202
- 'repoUrl',
203
- 'liveUrl',
204
- 'content',
205
- 'screenshots',
206
- ]);
207
- const unknown = Object.keys(input).find((key) => !allowed.has(key));
208
- if (unknown)
209
- return { ok: false, error: `unknown field "${unknown}"` };
210
- try {
211
- if (input.repoUrl !== undefined &&
212
- input.repoUrl !== null &&
213
- typeof input.repoUrl !== 'string') {
214
- throw new Error('repoUrl must be a git URL');
215
- }
216
- const repo = typeof input.repoUrl === 'string' ? normalizeRepoUrl(input.repoUrl) : null;
217
- if (input.repoUrl && !repo) {
218
- throw new Error('repoUrl is not a valid git URL');
219
- }
220
- const rawTitle = input.title;
221
- if (rawTitle !== undefined && typeof rawTitle !== 'string') {
222
- throw new Error('title must be text');
223
- }
224
- const title = rawTitle?.trim() || repo?.name;
225
- if (!title)
226
- throw new Error('title is required when repoUrl is missing');
227
- if (input.description !== undefined &&
228
- input.description !== null &&
229
- typeof input.description !== 'string') {
230
- throw new Error('description must be text');
231
- }
232
- if (input.content !== undefined &&
233
- input.content !== null &&
234
- typeof input.content !== 'string') {
235
- throw new Error('content must be text');
236
- }
237
- let tags = [];
238
- if (typeof input.tags === 'string')
239
- tags = parseTags(input.tags);
240
- else if (Array.isArray(input.tags)) {
241
- if (!input.tags.every((tag) => typeof tag === 'string')) {
242
- throw new Error('tags must contain only text');
243
- }
244
- tags = input.tags
245
- .map((tag) => tag.trim().toLowerCase())
246
- .filter(Boolean)
247
- .slice(0, 20);
248
- }
249
- else if (input.tags !== undefined) {
250
- throw new Error('tags must be a list or comma-separated text');
251
- }
252
- let screenshots = [];
253
- if (input.screenshots !== undefined) {
254
- if (!Array.isArray(input.screenshots)) {
255
- throw new Error('screenshots must be a list');
256
- }
257
- if (input.screenshots.length > 5) {
258
- throw new Error('screenshots supports at most 5 images');
259
- }
260
- screenshots = input.screenshots.map((shot, index) => {
261
- if (typeof shot === 'string') {
262
- return {
263
- url: requiredHttpUrl(shot, `screenshots[${index}]`),
264
- caption: '',
265
- };
266
- }
267
- if (typeof shot !== 'object' || shot === null || Array.isArray(shot)) {
268
- throw new Error(`screenshots[${index}] must be a URL or mapping`);
269
- }
270
- const value = shot;
271
- if (typeof value.caption !== 'string' && value.caption !== undefined) {
272
- throw new Error(`screenshots[${index}].caption must be text`);
273
- }
274
- return {
275
- url: requiredHttpUrl(value.url, `screenshots[${index}].url`),
276
- caption: value.caption?.trim() ?? '',
277
- };
278
- });
279
- }
280
- const rawSlug = input.slug;
281
- if (rawSlug !== undefined && typeof rawSlug !== 'string') {
282
- throw new Error('slug must be text');
283
- }
284
- return {
285
- ok: true,
286
- project: {
287
- title,
288
- slug: slugFromName(rawSlug?.trim() || repo?.name || title),
289
- description: typeof input.description === 'string'
290
- ? input.description.trim() || null
291
- : null,
292
- tags,
293
- repoUrl: repo?.url ?? null,
294
- liveUrl: httpUrl(input.liveUrl, 'liveUrl'),
295
- // Manifests don't declare privacy; `publishProject` probes the repo.
296
- private: false,
297
- content: typeof input.content === 'string' ? input.content : null,
298
- ...(input.screenshots !== undefined ? { screenshots } : {}),
299
- },
300
- };
301
- }
302
- catch (err) {
303
- return {
304
- ok: false,
305
- error: err instanceof Error ? err.message : String(err),
306
- };
307
- }
308
- }
309
- async function uploadScreenshotFromUrl(session, screenshot, index) {
310
- const sourceUrl = httpUrl(screenshot.url, `screenshots[${index}]`);
311
- if (!sourceUrl)
312
- throw new Error(`screenshots[${index}] needs a URL`);
313
- const image = await fetch(sourceUrl, {
314
- signal: AbortSignal.timeout(12_000),
315
- headers: { 'User-Agent': 'HacklabCLI (+https://hacklab.so)' },
316
- });
317
- if (!image.ok) {
318
- throw new Error(`could not download screenshot ${index + 1} (${image.status})`);
319
- }
320
- const contentType = image.headers.get('content-type')?.split(';')[0] ?? '';
321
- const extension = SCREENSHOT_CONTENT_TYPES.get(contentType);
322
- if (!extension) {
323
- throw new Error(`screenshot ${index + 1} must be PNG, JPEG, or WebP`);
324
- }
325
- const bytes = Buffer.from(await image.arrayBuffer());
326
- if (!bytes.length)
327
- throw new Error(`screenshot ${index + 1} is empty`);
328
- if (bytes.length > SCREENSHOT_MAX_BYTES) {
329
- throw new Error(`screenshot ${index + 1} is too large`);
330
- }
331
- const sourceName = basename(new URL(sourceUrl).pathname);
332
- const filename = sourceName || `screenshot-${index + 1}.${extension}`;
333
- const upload = await fetchApi(session, '/api/screenshots', {
334
- method: 'POST',
335
- headers: {
336
- 'Content-Type': 'application/json',
337
- Authorization: `Bearer ${session.token}`,
338
- },
339
- body: JSON.stringify({
340
- image: bytes.toString('base64'),
341
- filename,
342
- contentType,
343
- }),
121
+ async function fetchOwnProjects(session) {
122
+ const res = await fetchApi(session, PROJECTS_PATH, {
123
+ headers: { Authorization: `Bearer ${session.token}` },
344
124
  });
345
- const data = (await upload.json().catch(() => null));
346
- if (!upload.ok || !data?.url) {
347
- throw new Error(data?.error ?? `could not upload screenshot ${index + 1}`);
348
- }
349
- return { url: data.url, caption: screenshot.caption };
125
+ if (!res.ok)
126
+ throw new Error(await readError(res, session));
127
+ const data = (await res.json().catch(() => null));
128
+ if (!data?.projects)
129
+ throw new Error('got a malformed response from hacklab');
130
+ return data;
350
131
  }
351
- // Shared publish path for `apply`: idempotent by slug, keeps the original
352
- // publish date on refresh, and round-trips `sourceYaml`/`content`.
353
- async function publishProject(session, draft, options) {
354
- const { json, yes } = options;
355
- let existing;
132
+ /** Fetch your projects or exit with the mode-appropriate error. */
133
+ async function ownProjectsOrExit(session, json) {
356
134
  try {
357
- existing = (await fetchProjects(session)).projects.find((project) => project.slug === draft.slug);
135
+ return await fetchOwnProjects(session);
358
136
  }
359
137
  catch (err) {
360
138
  const message = err instanceof Error ? err.message : String(err);
@@ -363,266 +141,104 @@ async function publishProject(session, draft, options) {
363
141
  error(message);
364
142
  process.exit(1);
365
143
  }
366
- // Manifests don't declare privacy; probe a github repoUrl like `add` does.
367
- const isPrivate = await probeRepoPrivate(draft.repoUrl);
368
- if (!json) {
369
- console.log(` ${bold(existing ? 'refreshing' : 'publishing')} ${bold(draft.title)} ${dim(`(${draft.slug})`)}`);
370
- console.log(` ${dim('description')} ${summarize(draft.description)}`);
371
- console.log(` ${dim('repo')} ${summarize(draft.repoUrl)}${isPrivate ? dim(' (private — hidden on web)') : ''}`);
372
- console.log(` ${dim('live')} ${summarize(draft.liveUrl)}`);
373
- console.log(` ${dim('tags')} ${draft.tags.length ? draft.tags.join(', ') : dim('(none)')}`);
374
- console.log(` ${dim('content')} ${draft.content ? `${draft.content.length} chars` : dim('(none)')}`);
375
- console.log(` ${dim('screenshots')} ${draft.screenshots?.length ?? existing?.screenshots.length ?? 0}`);
376
- }
377
- if (!json && !yes && process.stdout.isTTY) {
378
- const go = await clack.confirm({ message: 'publish it?' });
379
- if (clack.isCancel(go) || !go) {
380
- clack.outro(dim('cancelled.'));
381
- return;
144
+ }
145
+ /** Nearest slug for a "did you mean" hint: prefix/substring match, else null. */
146
+ function nearestSlug(projects, slug) {
147
+ const q = slug.toLowerCase();
148
+ const hit = projects.find((p) => p.slug.startsWith(q)) ??
149
+ projects.find((p) => p.slug.includes(q));
150
+ return hit?.slug ?? null;
151
+ }
152
+ /**
153
+ * An explicit `--private`/`--public`, or undefined to fall back to the probe.
154
+ * A private repo's link 404s for visitors and it's absent from the public
155
+ * pinned-repo snapshot, so the web hides its repo link, button, and stats.
156
+ */
157
+ function explicitPrivacy(args) {
158
+ if (args.includes('--private'))
159
+ return true;
160
+ if (args.includes('--public'))
161
+ return false;
162
+ return undefined;
163
+ }
164
+ /**
165
+ * Split the two link flags. `--repo` takes any git host; `--url` is the live
166
+ * site, except that a github.com `--url` with no `--repo` routes to the repo —
167
+ * the one flag a "just give it a URL" project needs.
168
+ */
169
+ function resolveLinks(args, json) {
170
+ const repoFlag = flagValue(args, '--repo');
171
+ const urlFlag = flagValue(args, '--url');
172
+ const urlIsRepo = repoFlag === undefined && urlFlag !== undefined && isGithubRepoUrl(urlFlag);
173
+ const rawRepo = repoFlag ?? (urlIsRepo ? urlFlag : undefined);
174
+ let repoUrl = null;
175
+ if (rawRepo !== undefined) {
176
+ const repo = normalizeRepoUrl(rawRepo);
177
+ if (!repo) {
178
+ const message = `${repoFlag !== undefined ? '--repo' : '--url'} is not a valid git URL`;
179
+ if (json)
180
+ emitJsonError('invalid_fields', message);
181
+ error(message);
182
+ process.exit(1);
382
183
  }
184
+ repoUrl = repo.url;
383
185
  }
384
- let screenshots = existing?.screenshots ?? [];
385
- try {
386
- if (draft.screenshots !== undefined) {
387
- if (!json && draft.screenshots.length) {
388
- info(dim(`uploading ${draft.screenshots.length} screenshot(s)…`));
389
- }
390
- screenshots = [];
391
- for (const [index, screenshot] of draft.screenshots.entries()) {
392
- screenshots.push(await uploadScreenshotFromUrl(session, screenshot, index));
393
- }
394
- }
395
- else if (draft.liveUrl) {
396
- if (!json)
397
- info(dim('looking for a screenshot (og:image)…'));
398
- const shot = await captureOgScreenshot(session, draft.liveUrl);
399
- if (shot)
400
- screenshots = [shot];
186
+ let liveUrl = null;
187
+ if (urlFlag !== undefined && !urlIsRepo) {
188
+ liveUrl = parseHttpUrl(urlFlag);
189
+ if (!liveUrl) {
190
+ const message = '--url must be an http(s) URL';
191
+ if (json)
192
+ emitJsonError('invalid_fields', message);
193
+ error(message);
194
+ process.exit(1);
401
195
  }
402
196
  }
403
- catch (err) {
404
- const message = err instanceof Error ? err.message : String(err);
405
- if (json)
406
- emitJsonError('screenshot_failed', message);
407
- error(message);
408
- process.exit(1);
409
- }
410
- const payload = {
411
- title: draft.title,
412
- slug: draft.slug,
413
- description: draft.description ?? undefined,
414
- tags: draft.tags,
415
- repoUrl: draft.repoUrl ?? undefined,
416
- liveUrl: draft.liveUrl ?? undefined,
417
- private: isPrivate,
418
- screenshots,
419
- content: draft.content ?? existing?.content ?? undefined,
420
- sourceYaml: draft.sourceYaml ?? existing?.sourceYaml ?? undefined,
421
- publishedAt: existing?.publishedAt ?? new Date().toISOString(),
422
- };
423
- let res;
424
- try {
425
- res = await fetchApi(session, PROJECTS_PATH, {
426
- method: 'POST',
427
- headers: {
428
- 'Content-Type': 'application/json',
429
- Authorization: `Bearer ${session.token}`,
430
- },
431
- body: JSON.stringify(payload),
432
- });
433
- }
434
- catch (err) {
435
- const message = err instanceof Error ? err.message : String(err);
436
- if (json)
437
- emitJsonError('network', message);
438
- error(message);
439
- process.exit(1);
440
- }
441
- if (!res.ok) {
442
- const message = await readEnvelopeError(res, session);
443
- if (json)
444
- emitJsonError('error', message);
445
- error(message);
446
- process.exit(1);
447
- }
448
- await captureEvent(session.handle, 'cli_project_added', {
449
- slug: draft.slug,
450
- refreshed: Boolean(existing),
451
- private: isPrivate,
452
- has_live_url: Boolean(draft.liveUrl),
453
- has_screenshot: screenshots.length > 0,
454
- tag_count: draft.tags.length,
455
- via: 'apply',
456
- });
457
- const path = `/${session.handle}/${draft.slug}`;
458
- if (json) {
459
- printJson({
460
- schemaVersion: 1,
461
- [existing ? 'refreshed' : 'published']: true,
462
- slug: draft.slug,
463
- path,
464
- screenshots,
465
- });
466
- return;
467
- }
468
- success(`${existing ? 'refreshed' : 'published'} ${bold(draft.title)}`);
469
- info(`${resolveAppUrl(session)}${path}`);
470
- }
471
- async function projectApply(args) {
472
- const json = args.includes('--json');
473
- const yes = args.includes('--yes');
474
- const path = args.find((arg) => !arg.startsWith('-'));
475
- if (!path)
476
- usage();
477
- let sourceYaml;
478
- try {
479
- sourceYaml = await readFile(path, 'utf8');
480
- }
481
- catch {
482
- const message = `could not read ${path}`;
483
- if (json)
484
- emitJsonError('read_failed', message);
485
- error(message);
486
- process.exit(1);
487
- }
488
- let doc;
489
- try {
490
- doc = parseYaml(sourceYaml);
491
- }
492
- catch (err) {
493
- const message = `could not parse ${path}: ${err instanceof Error ? err.message : String(err)}`;
494
- if (json)
495
- emitJsonError('parse_failed', message);
496
- error(message);
497
- process.exit(1);
498
- }
499
- const parsed = parseProjectDocument(doc);
500
- if (!parsed.ok) {
501
- if (json)
502
- emitJsonError('invalid_fields', parsed.error);
503
- error(parsed.error);
504
- process.exit(1);
505
- }
506
- const session = await requireSession(json);
507
- await publishProject(session, { ...parsed.project, sourceYaml }, { json, yes });
197
+ return { repoUrl, liveUrl, touchedRepo: rawRepo !== undefined };
508
198
  }
509
199
  async function projectAdd(args) {
510
200
  const json = args.includes('--json');
511
- const yes = args.includes('--yes');
512
- const fields = {
513
- title: flagValue(args, '--title'),
514
- description: flagValue(args, '--description', '--desc'),
515
- liveUrl: flagValue(args, '--live'),
516
- repoUrl: flagValue(args, '--repo'),
517
- // `--url` auto-routes: a github.com URL becomes the repo, anything else the
518
- // live link — the one flag a "just give it a URL" project needs.
519
- url: flagValue(args, '--url'),
520
- slug: flagValue(args, '--slug'),
521
- tags: flagValue(args, '--tags'),
522
- // Long-form project content: `--content` inline, or `--content-file
523
- // <path>` to read it from disk.
524
- content: flagValue(args, '--content'),
525
- contentFile: flagValue(args, '--content-file'),
526
- };
527
- if (fields.content !== undefined && fields.contentFile !== undefined) {
528
- const message = 'use either --content or --content-file, not both';
201
+ const unknown = unknownFlag(args, ADD_FLAGS, VALUE_FLAGS);
202
+ if (unknown) {
529
203
  if (json)
530
- emitJsonError('invalid_fields', message);
531
- error(message);
204
+ emitJsonError('invalid_fields', `unknown flag: ${unknown}`);
205
+ error(`unknown flag: ${unknown}`);
532
206
  process.exit(1);
533
207
  }
534
- let content = fields.content;
535
- if (fields.contentFile) {
536
- try {
537
- content = await readFile(fields.contentFile, 'utf8');
538
- }
539
- catch {
540
- const message = `could not read ${fields.contentFile}`;
541
- if (json)
542
- emitJsonError('read_failed', message);
543
- error(message);
544
- process.exit(1);
545
- }
546
- }
547
- if (!fields.title) {
208
+ const title = flagValue(args, '--title');
209
+ const description = flagValue(args, '--description', '--desc');
210
+ if (!title) {
548
211
  const message = 'a project needs a title: --title "My Project"';
549
212
  if (json)
550
213
  emitJsonError('missing_title', message);
551
214
  error(message);
552
215
  process.exit(1);
553
216
  }
554
- const urlIsRepo = fields.url ? isGithubRepoUrl(fields.url) : false;
555
- const urlAsRepo = urlIsRepo ? fields.url : undefined;
556
- const urlAsLive = fields.url && !urlIsRepo ? fields.url : undefined;
557
- const draft = {
558
- slug: slugFromName(fields.slug ?? fields.title),
559
- title: fields.title,
560
- description: fields.description ?? null,
561
- tags: fields.tags ? parseTags(fields.tags) : [],
562
- repoUrl: fields.repoUrl ?? urlAsRepo ?? null,
563
- liveUrl: fields.liveUrl ?? urlAsLive ?? null,
564
- private: false,
565
- content: content ?? null,
566
- };
567
- // Privacy: explicit --private/--public win; otherwise probe a github repoUrl.
568
- // A private repo's link 404s for visitors and it's absent from the public
569
- // pinned-repo snapshot, so the web hides its repo link, GitHub button + stats.
570
- if (args.includes('--private'))
571
- draft.private = true;
572
- else if (args.includes('--public'))
573
- draft.private = false;
574
- else
575
- draft.private = await probeRepoPrivate(draft.repoUrl);
576
- const session = await requireSession(json);
577
- // The existing row (if any) decides create-vs-refresh, keeps the original
578
- // publish date, and provides fallback screenshots.
579
- let existing;
580
- try {
581
- existing = (await fetchProjects(session)).projects.find((p) => p.slug === draft.slug);
582
- }
583
- catch (err) {
584
- const message = err instanceof Error ? err.message : String(err);
217
+ if (!flagValue(args, '--repo') && !flagValue(args, '--url')) {
218
+ const message = 'a project needs a link: --repo <git url> or --url https://…';
585
219
  if (json)
586
- emitJsonError('error', message);
220
+ emitJsonError('missing_url', message);
587
221
  error(message);
588
222
  process.exit(1);
589
223
  }
590
- if (!json) {
591
- console.log(` ${bold(existing ? 'refreshing' : 'publishing')} ${bold(draft.title)} ${dim(`(${draft.slug})`)}`);
592
- console.log(` ${dim('description')} ${summarize(draft.description)}`);
593
- console.log(` ${dim('repo')} ${summarize(draft.repoUrl)}${draft.private ? dim(' (private — hidden on web)') : ''}`);
594
- console.log(` ${dim('live')} ${summarize(draft.liveUrl)}`);
595
- console.log(` ${dim('tags')} ${draft.tags.length ? draft.tags.join(', ') : dim('(none)')}`);
596
- console.log(` ${dim('content')} ${draft.content ? `${draft.content.length} chars` : dim('(none)')}`);
597
- }
598
- if (!json && !yes && process.stdout.isTTY) {
599
- const go = await clack.confirm({ message: 'publish it?' });
600
- if (clack.isCancel(go) || !go) {
601
- clack.outro(dim('cancelled.'));
602
- return;
603
- }
604
- }
605
- // Fresh og:image capture when the site has one; otherwise whatever the
606
- // project already shows keeps showing (POST overwrites screenshots).
607
- let screenshots = existing?.screenshots ?? [];
608
- if (draft.liveUrl) {
609
- if (!json)
610
- info(dim('looking for a screenshot (og:image)…'));
611
- const shot = await captureOgScreenshot(session, draft.liveUrl);
612
- if (shot)
613
- screenshots = [shot];
614
- }
224
+ const links = resolveLinks(args, json);
225
+ const slug = slugFromName(title);
226
+ const session = await requireSession(json);
227
+ const existing = (await ownProjectsOrExit(session, json)).projects.find((p) => p.slug === slug);
228
+ const repoUrl = links.repoUrl ?? existing?.repoUrl ?? null;
229
+ const liveUrl = links.liveUrl ?? existing?.liveUrl ?? null;
230
+ const isPrivate = explicitPrivacy(args) ?? (await probeRepoPrivate(repoUrl));
615
231
  const payload = {
616
- title: draft.title,
617
- slug: draft.slug,
618
- description: draft.description ?? undefined,
619
- tags: draft.tags,
620
- repoUrl: draft.repoUrl ?? undefined,
621
- liveUrl: draft.liveUrl ?? undefined,
622
- private: draft.private,
623
- screenshots,
624
- // A flag-less refresh keeps whatever content the project already has.
625
- content: draft.content ?? existing?.content ?? undefined,
232
+ title,
233
+ slug,
234
+ description: description ?? existing?.description ?? undefined,
235
+ tags: existing?.tags ?? [],
236
+ repoUrl: repoUrl ?? undefined,
237
+ liveUrl: liveUrl ?? undefined,
238
+ private: isPrivate,
239
+ screenshots: existing?.screenshots ?? [],
240
+ content: existing?.content ?? undefined,
241
+ sourceYaml: existing?.sourceYaml ?? undefined,
626
242
  publishedAt: existing?.publishedAt ?? new Date().toISOString(),
627
243
  };
628
244
  let res;
@@ -644,244 +260,182 @@ async function projectAdd(args) {
644
260
  process.exit(1);
645
261
  }
646
262
  if (!res.ok) {
647
- const message = await readEnvelopeError(res, session);
263
+ const message = await readError(res, session);
648
264
  if (json)
649
265
  emitJsonError('error', message);
650
266
  error(message);
651
267
  process.exit(1);
652
268
  }
653
269
  await captureEvent(session.handle, 'cli_project_added', {
654
- slug: draft.slug,
270
+ slug,
655
271
  refreshed: Boolean(existing),
656
- has_repo: Boolean(draft.repoUrl),
657
- private: draft.private,
658
- has_live_url: Boolean(draft.liveUrl),
659
- has_screenshot: screenshots.length > 0,
660
- tag_count: draft.tags.length,
272
+ has_repo: Boolean(repoUrl),
273
+ has_live_url: Boolean(liveUrl),
274
+ private: isPrivate,
661
275
  });
662
- const path = `/${session.handle}/${draft.slug}`;
276
+ const path = `/${session.handle}/${slug}`;
663
277
  if (json) {
664
278
  printJson({
665
279
  schemaVersion: 1,
666
280
  [existing ? 'refreshed' : 'published']: true,
667
- slug: draft.slug,
281
+ slug,
668
282
  path,
669
283
  });
670
284
  return;
671
285
  }
672
- success(`${existing ? 'refreshed' : 'published'} ${bold(draft.title)}`);
673
- info(`${resolveAppUrl(session)}${path}`);
286
+ success(`${existing ? 'refreshed' : 'published'} ${bold(title)}`);
287
+ info(link(`${resolveAppUrl(session)}${path}`));
674
288
  }
675
- async function projectList(args) {
676
- const json = args.includes('--json');
677
- const session = await requireSession(json);
678
- let list;
679
- try {
680
- list = await fetchProjects(session);
681
- }
682
- catch (err) {
683
- const message = err instanceof Error ? err.message : String(err);
684
- if (json)
685
- emitJsonError('error', message);
686
- error(message);
687
- process.exit(1);
688
- }
689
- if (json) {
690
- printJson({ schemaVersion: 1, ...list });
691
- return;
692
- }
693
- if (list.projects.length === 0) {
289
+ function renderList(handle, projects, appUrl) {
290
+ if (projects.length === 0) {
694
291
  info('no projects yet');
695
- info(`run ${dim('hacklab project add --title "…"')} to publish one`);
292
+ info(`run ${dim('hacklab project add --title "…" --repo <url>')} to publish one`);
696
293
  return;
697
294
  }
698
- const width = Math.max(...list.projects.map((p) => p.slug.length));
699
- for (const p of list.projects) {
700
- console.log(` ${bold(p.slug.padEnd(width))} ${summarize(p.title, 48)} ${dim(`(${p.source})`)}`);
295
+ const width = Math.max(...projects.map((p) => p.slug.length));
296
+ for (const p of projects) {
297
+ const desc = summarize(p.description, 56);
298
+ console.log(` ${bold(stripControl(p.slug).padEnd(width))}${desc ? ` ${stripControl(desc)}` : ''}`);
701
299
  }
702
300
  console.log('');
703
- info(dim(`${resolveAppUrl(session)}/${list.handle}`));
301
+ info(link(`${appUrl}/${handle}`));
704
302
  }
705
- /** Nearest slug for a "did you mean" hint: prefix/substring match, else null. */
706
- function nearestSlug(projects, slug) {
707
- const q = slug.toLowerCase();
708
- const hit = projects.find((p) => p.slug.startsWith(q)) ??
709
- projects.find((p) => p.slug.includes(q));
710
- return hit?.slug ?? null;
711
- }
712
- function renderProjectCard(project, session) {
713
- console.log('');
714
- console.log(` ${bold(project.title)} ${dim(`(${project.slug})`)}`);
715
- if (project.description) {
716
- console.log(` ${summarize(project.description, 96)}`);
717
- }
718
- console.log('');
719
- if (project.tags.length) {
720
- console.log(` ${dim('tags')} ${project.tags.join(', ')}`);
721
- }
722
- if (project.repoUrl) {
723
- console.log(` ${dim('repo')} ${project.repoUrl}${project.private ? dim(' (private — hidden on web)') : ''}`);
724
- }
303
+ function renderProject(project, appUrl) {
304
+ console.log(` ${bold(stripControl(project.title))}`);
305
+ if (project.description)
306
+ console.log(` ${stripControl(project.description)}`);
725
307
  if (project.liveUrl)
726
- console.log(` ${dim('live')} ${project.liveUrl}`);
727
- console.log(` ${dim('source')} ${project.source}${project.content
728
- ? dim(` · README (${project.content.length} chars)`)
729
- : ''}`);
308
+ console.log(` ${link(project.liveUrl)}`);
309
+ else if (project.repoUrl)
310
+ console.log(` ${link(project.repoUrl)}`);
311
+ if (project.content) {
312
+ console.log('');
313
+ console.log(stripControl(project.content).trimEnd());
314
+ }
730
315
  console.log('');
731
- info(dim(`${resolveAppUrl(session)}${project.path}`));
316
+ info(link(`${appUrl}${project.path}`));
732
317
  }
733
- async function projectView(args) {
318
+ async function projectList(args) {
734
319
  const json = args.includes('--json');
735
- const web = args.includes('--web');
736
- const slug = args.find((a) => !a.startsWith('-'));
737
- if (!slug) {
320
+ const unknown = unknownFlag(args, new Set(['--json']), new Set());
321
+ if (unknown) {
738
322
  if (json)
739
- emitJsonError('usage', 'usage: hacklab project view <slug>');
740
- error('usage: hacklab project view <slug>');
323
+ emitJsonError('invalid_fields', `unknown flag: ${unknown}`);
324
+ error(`unknown flag: ${unknown}`);
741
325
  process.exit(1);
742
326
  }
743
327
  const session = await requireSession(json);
744
- let list;
745
- try {
746
- list = await fetchProjects(session);
328
+ const list = await ownProjectsOrExit(session, json);
329
+ if (json) {
330
+ printJson({ schemaVersion: 1, ...list });
331
+ return;
747
332
  }
748
- catch (err) {
749
- const message = err instanceof Error ? err.message : String(err);
333
+ renderList(list.handle, list.projects, resolveAppUrl(session));
334
+ }
335
+ async function projectView(args) {
336
+ const json = args.includes('--json');
337
+ const slug = positional(args, VALUE_FLAGS);
338
+ if (!slug || slug.includes('/')) {
339
+ const message = 'usage: hacklab project view <slug> (one of your own)';
750
340
  if (json)
751
- emitJsonError('error', message);
341
+ emitJsonError('usage', message);
752
342
  error(message);
753
343
  process.exit(1);
754
344
  }
755
- const project = list.projects.find((p) => p.slug === slug);
756
- if (!project) {
345
+ const session = await requireSession(json);
346
+ const list = await ownProjectsOrExit(session, json);
347
+ const found = list.projects.find((p) => p.slug === slug);
348
+ if (!found) {
349
+ const message = `no project named "${slug}"`;
757
350
  if (json)
758
- emitJsonError('not_found', `no project named "${slug}"`);
759
- error(`no project named "${slug}"`);
351
+ emitJsonError('not_found', message);
352
+ error(message);
760
353
  const near = nearestSlug(list.projects, slug);
761
354
  if (near)
762
355
  info(`did you mean ${dim(`hacklab project view ${near}`)}?`);
763
356
  process.exit(1);
764
357
  }
765
- const url = `${resolveAppUrl(session)}${project.path}`;
766
- if (web) {
767
- const opened = await openBrowser(url);
768
- info(opened
769
- ? `opened ${linkBlue(url)}`
770
- : `could not open a browser — ${linkBlue(url)}`);
771
- return;
772
- }
773
358
  if (json) {
774
- printJson({ schemaVersion: 1, project });
359
+ printJson({ schemaVersion: 1, project: found });
775
360
  return;
776
361
  }
777
- renderProjectCard(project, session);
362
+ renderProject(found, resolveAppUrl(session));
778
363
  }
779
364
  async function projectEdit(args) {
780
365
  const json = args.includes('--json');
781
- const yes = args.includes('--yes');
782
- const slug = args.find((a) => !a.startsWith('-'));
366
+ const unknown = unknownFlag(args, EDIT_FLAGS, VALUE_FLAGS);
367
+ if (unknown) {
368
+ if (json)
369
+ emitJsonError('invalid_fields', `unknown flag: ${unknown}`);
370
+ error(`unknown flag: ${unknown}`);
371
+ process.exit(1);
372
+ }
373
+ const yes = args.includes('--yes') || args.includes('-y');
374
+ const slug = positional(args, VALUE_FLAGS);
783
375
  if (!slug) {
376
+ const message = 'usage: hacklab project edit <slug> [--title …]';
784
377
  if (json)
785
- emitJsonError('usage', 'usage: hacklab project edit <slug> [--title …]');
786
- error('usage: hacklab project edit <slug> [--title/--desc/--url/--repo/--tags]');
378
+ emitJsonError('usage', message);
379
+ error(message);
787
380
  process.exit(1);
788
381
  }
789
382
  const titleFlag = flagValue(args, '--title');
790
383
  const descFlag = flagValue(args, '--description', '--desc');
791
- const liveFlag = flagValue(args, '--live');
792
384
  const repoFlag = flagValue(args, '--repo');
793
385
  const urlFlag = flagValue(args, '--url');
794
- const tagsFlag = flagValue(args, '--tags');
795
- const clearRepo = args.includes('--clear-repo');
796
- const clearLive = args.includes('--clear-live');
797
- const urlIsRepo = urlFlag ? isGithubRepoUrl(urlFlag) : false;
798
- const changed = [titleFlag, descFlag, liveFlag, repoFlag, urlFlag, tagsFlag].some((v) => v !== undefined) ||
799
- clearRepo ||
800
- clearLive;
801
- if (!changed) {
802
- const message = 'nothing to edit — pass --title, --desc, --url, --repo, --live, --tags, --clear-repo or --clear-live';
386
+ const explicit = explicitPrivacy(args);
387
+ if ([titleFlag, descFlag, repoFlag, urlFlag].every((v) => v === undefined) &&
388
+ explicit === undefined) {
389
+ const message = 'nothing to edit — pass --title, --desc, --repo, --url, --private or --public';
803
390
  if (json)
804
391
  emitJsonError('no_change', message);
805
392
  error(message);
806
393
  process.exit(1);
807
394
  }
395
+ const links = resolveLinks(args, json);
808
396
  const session = await requireSession(json);
809
- let list;
810
- try {
811
- list = await fetchProjects(session);
812
- }
813
- catch (err) {
814
- const message = err instanceof Error ? err.message : String(err);
815
- if (json)
816
- emitJsonError('error', message);
817
- error(message);
818
- process.exit(1);
819
- }
397
+ const list = await ownProjectsOrExit(session, json);
820
398
  const existing = list.projects.find((p) => p.slug === slug);
821
399
  if (!existing) {
400
+ const message = `no project named "${slug}"`;
822
401
  if (json)
823
- emitJsonError('not_found', `no project named "${slug}"`);
824
- error(`no project named "${slug}"`);
402
+ emitJsonError('not_found', message);
403
+ error(message);
825
404
  const near = nearestSlug(list.projects, slug);
826
405
  if (near)
827
406
  info(`did you mean ${dim(`hacklab project edit ${near}`)}?`);
828
407
  process.exit(1);
829
408
  }
830
- // Editing a GitHub-synced project converts it to a manual one (the POST route
831
- // relabels it `cli`), so it stops updating from GitHub. Confirm first.
832
- if (existing.source === 'github') {
833
- if (!json && !yes && process.stdout.isTTY) {
834
- const go = await clack.confirm({
835
- message: `${bold(slug)} is synced from GitHub — editing stops that sync. continue?`,
836
- });
837
- if (clack.isCancel(go) || !go) {
838
- clack.outro(dim('cancelled.'));
839
- return;
840
- }
841
- }
842
- else if (!json && !yes) {
843
- error(`${slug} is synced from GitHub — re-run with --yes to edit it anyway`);
844
- process.exit(1);
845
- }
409
+ // The POST route relabels an edited project `cli`, so editing one that
410
+ // GitHub syncs silently ends that sync. Make the trade explicit.
411
+ if (existing.source === 'github' && !yes) {
412
+ const message = `${slug} is synced from GitHub — re-run with --yes to edit it anyway`;
413
+ if (json)
414
+ emitJsonError('synced', message);
415
+ error(message);
416
+ process.exit(1);
846
417
  }
847
- // Merge: only passed fields change; everything else round-trips unchanged.
848
- let repoUrl = existing.repoUrl;
849
- let liveUrl = existing.liveUrl;
850
- if (repoFlag !== undefined)
851
- repoUrl = repoFlag;
852
- if (liveFlag !== undefined)
853
- liveUrl = liveFlag;
854
- if (urlFlag !== undefined) {
855
- if (urlIsRepo)
856
- repoUrl = urlFlag;
857
- else
858
- liveUrl = urlFlag;
859
- }
860
- if (clearRepo)
861
- repoUrl = null;
862
- if (clearLive)
863
- liveUrl = null;
864
- // Re-probe privacy only when the repo URL itself changed.
865
- const repoChanged = repoFlag !== undefined || (urlFlag !== undefined && urlIsRepo) || clearRepo;
866
- let isPrivate = existing.private;
867
- if (args.includes('--private'))
868
- isPrivate = true;
869
- else if (args.includes('--public'))
870
- isPrivate = false;
871
- else if (repoChanged)
418
+ // Only the passed fields change; everything else round-trips unchanged, so
419
+ // editing one field never wipes the rest.
420
+ const repoUrl = links.touchedRepo ? links.repoUrl : existing.repoUrl;
421
+ const liveUrl = links.liveUrl ?? existing.liveUrl;
422
+ // Re-probe only when the repo URL itself moved; otherwise the stored
423
+ // visibility stands.
424
+ let isPrivate = existing.private ?? false;
425
+ if (explicit !== undefined)
426
+ isPrivate = explicit;
427
+ else if (links.touchedRepo)
872
428
  isPrivate = await probeRepoPrivate(repoUrl);
873
429
  const payload = {
874
430
  title: titleFlag ?? existing.title,
875
431
  slug: existing.slug,
876
432
  description: descFlag !== undefined ? descFlag : (existing.description ?? undefined),
877
- tags: tagsFlag !== undefined ? parseTags(tagsFlag) : existing.tags,
433
+ tags: existing.tags,
878
434
  repoUrl: repoUrl ?? undefined,
879
435
  liveUrl: liveUrl ?? undefined,
880
436
  private: isPrivate,
881
- screenshots: existing.screenshots,
437
+ screenshots: existing.screenshots ?? [],
882
438
  content: existing.content ?? undefined,
883
- // A partial edit round-trips everything it doesn't touch — including the
884
- // `apply` manifest source — so editing one field never wipes the rest.
885
439
  sourceYaml: existing.sourceYaml ?? undefined,
886
440
  publishedAt: existing.publishedAt ?? new Date().toISOString(),
887
441
  };
@@ -904,7 +458,7 @@ async function projectEdit(args) {
904
458
  process.exit(1);
905
459
  }
906
460
  if (!res.ok) {
907
- const message = await readEnvelopeError(res, session);
461
+ const message = await readError(res, session);
908
462
  if (json)
909
463
  emitJsonError('error', message);
910
464
  error(message);
@@ -912,31 +466,45 @@ async function projectEdit(args) {
912
466
  }
913
467
  await captureEvent(session.handle, 'cli_project_edited', {
914
468
  slug,
915
- private: isPrivate,
916
469
  changed_title: titleFlag !== undefined,
917
- changed_repo: repoChanged,
470
+ changed_repo: links.touchedRepo,
471
+ private: isPrivate,
918
472
  });
919
- const path = existing.path;
920
473
  if (json) {
921
- printJson({ schemaVersion: 1, edited: true, slug, path });
474
+ printJson({ schemaVersion: 1, edited: true, slug, path: existing.path });
922
475
  return;
923
476
  }
924
- success(`edited ${bold(payload.title)}`);
925
- info(`${resolveAppUrl(session)}${path}`);
477
+ success(`edited ${bold(stripControl(payload.title))}`);
478
+ info(link(`${resolveAppUrl(session)}${existing.path}`));
926
479
  }
927
480
  async function projectDelete(args) {
928
481
  const json = args.includes('--json');
929
- const yes = args.includes('--yes');
482
+ const yes = args.includes('--yes') || args.includes('-y');
930
483
  const slug = args.find((a) => !a.startsWith('-'));
931
- if (!slug)
932
- usage();
484
+ if (!slug) {
485
+ const message = 'usage: hacklab project delete <slug> [--yes]';
486
+ if (json)
487
+ emitJsonError('usage', message);
488
+ error(message);
489
+ process.exit(1);
490
+ }
933
491
  const session = await requireSession(json);
934
- if (!json && !yes && process.stdout.isTTY) {
935
- const go = await clack.confirm({
936
- message: `delete ${bold(slug)} from your profile?`,
492
+ // Deletion is irreversible and `project d <slug>` resolves here by prefix, so
493
+ // a typo must not be enough on its own.
494
+ if (!yes) {
495
+ if (json || !process.stdin.isTTY) {
496
+ const message = 'refusing to delete without confirmation — pass --yes';
497
+ if (json)
498
+ emitJsonError('confirm', message);
499
+ error(message);
500
+ process.exit(1);
501
+ }
502
+ const ok = await clack.confirm({
503
+ message: `delete ${bold(slug)} from your profile? this cannot be undone.`,
504
+ initialValue: false,
937
505
  });
938
- if (clack.isCancel(go) || !go) {
939
- clack.outro(dim('cancelled.'));
506
+ if (clack.isCancel(ok) || !ok) {
507
+ info('kept.');
940
508
  return;
941
509
  }
942
510
  }
@@ -955,7 +523,14 @@ async function projectDelete(args) {
955
523
  process.exit(1);
956
524
  }
957
525
  if (!res.ok) {
958
- const message = await readEnvelopeError(res, session);
526
+ if (res.status === 404) {
527
+ const message = `no project named "${slug}"`;
528
+ if (json)
529
+ emitJsonError('not_found', message);
530
+ error(message);
531
+ process.exit(1);
532
+ }
533
+ const message = await readError(res, session);
959
534
  if (json)
960
535
  emitJsonError('error', message);
961
536
  error(message);
@@ -967,7 +542,7 @@ async function projectDelete(args) {
967
542
  source: data?.deleted?.source,
968
543
  });
969
544
  if (json) {
970
- printJson({ schemaVersion: 1, deleted: data?.deleted ?? { slug } });
545
+ printJson({ schemaVersion: 1, deleted: true, slug });
971
546
  return;
972
547
  }
973
548
  success(`deleted ${bold(slug)}`);
@@ -977,17 +552,14 @@ async function projectDelete(args) {
977
552
  }
978
553
  export async function project(args) {
979
554
  const [subToken, ...rest] = args;
980
- if (subToken === '--help' || subToken === '-h' || subToken === 'help') {
981
- usage(0);
982
- }
983
- // Bare `hacklab project` prints the help — publishing is an explicit
984
- // `add` away.
985
- if (!subToken) {
555
+ if (!subToken ||
556
+ subToken === '--help' ||
557
+ subToken === '-h' ||
558
+ subToken === 'help') {
986
559
  usage(0);
987
560
  }
988
- if (subToken.startsWith('-')) {
561
+ if (subToken.startsWith('-'))
989
562
  usage();
990
- }
991
563
  const resolved = resolveCommand(subToken, SUBCOMMANDS);
992
564
  if (resolved.kind === 'ambiguous') {
993
565
  error(`ambiguous: project ${subToken} (${resolved.matches.join(', ')})`);
@@ -999,8 +571,6 @@ export async function project(args) {
999
571
  }
1000
572
  if (resolved.name === 'add')
1001
573
  return projectAdd(rest);
1002
- if (resolved.name === 'apply')
1003
- return projectApply(rest);
1004
574
  if (resolved.name === 'list')
1005
575
  return projectList(rest);
1006
576
  if (resolved.name === 'view')