hacklab 0.8.5 → 0.10.0-staging.16.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.
Files changed (57) hide show
  1. package/README.md +78 -33
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.d.ts.map +1 -0
  4. package/dist/cli.js +147 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/commands/drop.d.ts +2 -1
  7. package/dist/commands/drop.d.ts.map +1 -1
  8. package/dist/commands/drop.js +51 -11
  9. package/dist/commands/drop.js.map +1 -1
  10. package/dist/commands/hacker.d.ts.map +1 -1
  11. package/dist/commands/hacker.js +68 -6
  12. package/dist/commands/hacker.js.map +1 -1
  13. package/dist/commands/join.d.ts +1 -1
  14. package/dist/commands/join.d.ts.map +1 -1
  15. package/dist/commands/join.js +96 -184
  16. package/dist/commands/join.js.map +1 -1
  17. package/dist/commands/profile.d.ts +2 -1
  18. package/dist/commands/profile.d.ts.map +1 -1
  19. package/dist/commands/profile.js +66 -7
  20. package/dist/commands/profile.js.map +1 -1
  21. package/dist/commands/project.d.ts +19 -0
  22. package/dist/commands/project.d.ts.map +1 -1
  23. package/dist/commands/project.js +684 -27
  24. package/dist/commands/project.js.map +1 -1
  25. package/dist/commands/sync.js +7 -3
  26. package/dist/commands/sync.js.map +1 -1
  27. package/dist/commands/update.js +2 -2
  28. package/dist/commands/update.js.map +1 -1
  29. package/dist/daily-sync.d.ts +16 -1
  30. package/dist/daily-sync.d.ts.map +1 -1
  31. package/dist/daily-sync.js +66 -2
  32. package/dist/daily-sync.js.map +1 -1
  33. package/dist/index.js +18 -150
  34. package/dist/index.js.map +1 -1
  35. package/dist/project-infer.d.ts +13 -0
  36. package/dist/project-infer.d.ts.map +1 -1
  37. package/dist/project-infer.js +41 -2
  38. package/dist/project-infer.js.map +1 -1
  39. package/dist/registry.js +7 -7
  40. package/dist/registry.js.map +1 -1
  41. package/dist/scanners/index.d.ts +6 -0
  42. package/dist/scanners/index.d.ts.map +1 -1
  43. package/dist/scanners/index.js +48 -51
  44. package/dist/scanners/index.js.map +1 -1
  45. package/dist/scanners/sqlite.d.ts +18 -0
  46. package/dist/scanners/sqlite.d.ts.map +1 -0
  47. package/dist/scanners/sqlite.js +63 -0
  48. package/dist/scanners/sqlite.js.map +1 -0
  49. package/dist/share.d.ts +3 -12
  50. package/dist/share.d.ts.map +1 -1
  51. package/dist/share.js +41 -37
  52. package/dist/share.js.map +1 -1
  53. package/dist/sync.d.ts +4 -1
  54. package/dist/sync.d.ts.map +1 -1
  55. package/dist/sync.js +9 -2
  56. package/dist/sync.js.map +1 -1
  57. package/package.json +7 -3
@@ -1,17 +1,21 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { basename } from 'node:path';
1
3
  import * as clack from '@clack/prompts';
4
+ import { parse as parseYaml } from 'yaml';
2
5
  import { captureEvent } from '../posthog.js';
3
- import { findRepoRoot, inferProject, slugFromName, } from '../project-infer.js';
6
+ import { findRepoRoot, inferProject, isGithubRepoUrl, normalizeRepoUrl, probeRepoPrivate, slugFromName, } from '../project-infer.js';
4
7
  import { resolveCommand } from '../resolve-command.js';
5
8
  import { loadSession, resolveAppUrl, unauthorizedHint, } from '../session.js';
6
9
  import { fetchApi } from '../sync.js';
7
- import { bold, dim, error, info, success } from '../ui.js';
8
- // `hacklab project` — publish and manage the projects on your profile. The
9
- // old `brag` flow asked you to transcribe your repo into a YAML manifest; this
10
- // one reads the repo instead (git remote, README, package.json) and only asks
11
- // for confirmation. `add` is idempotent per repo: the slug derives from the
12
- // repo name, and a re-run refreshes the same project without touching its
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
+ // reads the repo you're in (git remote, README, package.json) and only asks
14
+ // for confirmation; `apply` takes a declarative YAML/JSON manifest for agents
15
+ // or long-form content. `add` is idempotent per repo: the slug derives from
16
+ // the repo name, and a re-run refreshes the same project without touching its
13
17
  // publish date or losing screenshots.
14
- const SUBCOMMANDS = ['add', 'list', 'delete'];
18
+ const SUBCOMMANDS = ['add', 'apply', 'list', 'view', 'edit', 'delete'];
15
19
  const PROJECTS_PATH = '/api/projects';
16
20
  function printJson(data) {
17
21
  console.log(JSON.stringify(data, null, 2));
@@ -20,12 +24,22 @@ function emitJsonError(code, message) {
20
24
  console.log(JSON.stringify({ schemaVersion: 1, error: { code, message } }));
21
25
  process.exit(1);
22
26
  }
27
+ function printProjectHelp() {
28
+ info('usage: hacklab project [add|apply|list|view|edit|delete]');
29
+ info(` hacklab project ${dim('add [path] [--yes] [--json]')} publish the repo you're in (re-run to refresh)`);
30
+ info(` hacklab project ${dim('add --no-repo --title <t> [--url <u>] [--desc <d>]')} a project with no repo`);
31
+ info(` hacklab project ${dim('add --title/--desc/--url/--repo/--live/--tags/--slug')} override any field`);
32
+ info(` hacklab project ${dim('add --content <md> | --content-file <path>')} set the long-form content`);
33
+ info(` hacklab project ${dim('apply <file> [--yes] [--json]')} publish from a yaml/json manifest`);
34
+ info(` hacklab project ${dim('list [--json]')} your projects`);
35
+ info(` hacklab project ${dim('view <slug> [--web] [--json]')} show one`);
36
+ info(` hacklab project ${dim('edit <slug> --title/--desc/--url/--repo/--tags')} change fields`);
37
+ info(` hacklab project ${dim('edit <slug> --clear-repo|--clear-live')} drop a URL`);
38
+ info(` hacklab project ${dim('delete <slug> [--yes] [--json]')} remove one`);
39
+ }
23
40
  function usage() {
24
- error('usage: hacklab project [add|list|delete]');
25
- info(` hacklab project ${dim('add [path] [--yes] [--json]')} publish the repo you're in (re-run to refresh)`);
26
- info(` hacklab project ${dim('add --title/--description/--live/--tags/--slug')} override any inferred field`);
27
- info(` hacklab project ${dim('list [--json]')} your projects`);
28
- info(` hacklab project ${dim('delete <slug> [--yes] [--json]')} remove one`);
41
+ error('usage: hacklab project [add|apply|list|view|edit|delete]');
42
+ printProjectHelp();
29
43
  process.exit(1);
30
44
  }
31
45
  async function requireSession(json) {
@@ -153,38 +167,449 @@ function summarize(value, max = 72) {
153
167
  const oneLine = value.replace(/\s+/g, ' ').trim();
154
168
  return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
155
169
  }
170
+ // Keep in sync with PROJECT_SCREENSHOT_CONTENT_TYPES in
171
+ // apps/web/app/(app)/api/screenshots/route.ts (server-side counterpart).
172
+ const SCREENSHOT_CONTENT_TYPES = new Map([
173
+ ['image/png', 'png'],
174
+ ['image/jpeg', 'jpg'],
175
+ ['image/webp', 'webp'],
176
+ ]);
177
+ function httpUrl(value, field) {
178
+ if (value === undefined || value === null || value === '')
179
+ return null;
180
+ if (typeof value !== 'string')
181
+ throw new Error(`${field} must be a URL`);
182
+ const url = new URL(value);
183
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
184
+ throw new Error(`${field} must use http or https`);
185
+ }
186
+ return url.toString();
187
+ }
188
+ function requiredHttpUrl(value, field) {
189
+ const url = httpUrl(value, field);
190
+ if (!url)
191
+ throw new Error(`${field} is required`);
192
+ return url;
193
+ }
194
+ /** Parse the agent-friendly project.yaml/json shape before sending it. */
195
+ export function parseProjectDocument(doc) {
196
+ if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) {
197
+ return { ok: false, error: 'expected a project mapping' };
198
+ }
199
+ const input = doc;
200
+ const allowed = new Set([
201
+ 'title',
202
+ 'slug',
203
+ 'description',
204
+ 'tags',
205
+ 'repoUrl',
206
+ 'liveUrl',
207
+ 'content',
208
+ 'screenshots',
209
+ ]);
210
+ const unknown = Object.keys(input).find((key) => !allowed.has(key));
211
+ if (unknown)
212
+ return { ok: false, error: `unknown field "${unknown}"` };
213
+ try {
214
+ if (input.repoUrl !== undefined &&
215
+ input.repoUrl !== null &&
216
+ typeof input.repoUrl !== 'string') {
217
+ throw new Error('repoUrl must be a git URL');
218
+ }
219
+ const repo = typeof input.repoUrl === 'string' ? normalizeRepoUrl(input.repoUrl) : null;
220
+ if (input.repoUrl && !repo) {
221
+ throw new Error('repoUrl is not a valid git URL');
222
+ }
223
+ const rawTitle = input.title;
224
+ if (rawTitle !== undefined && typeof rawTitle !== 'string') {
225
+ throw new Error('title must be text');
226
+ }
227
+ const title = rawTitle?.trim() || repo?.name;
228
+ if (!title)
229
+ throw new Error('title is required when repoUrl is missing');
230
+ if (input.description !== undefined &&
231
+ input.description !== null &&
232
+ typeof input.description !== 'string') {
233
+ throw new Error('description must be text');
234
+ }
235
+ if (input.content !== undefined &&
236
+ input.content !== null &&
237
+ typeof input.content !== 'string') {
238
+ throw new Error('content must be text');
239
+ }
240
+ let tags = [];
241
+ if (typeof input.tags === 'string')
242
+ tags = parseTags(input.tags);
243
+ else if (Array.isArray(input.tags)) {
244
+ if (!input.tags.every((tag) => typeof tag === 'string')) {
245
+ throw new Error('tags must contain only text');
246
+ }
247
+ tags = input.tags
248
+ .map((tag) => tag.trim().toLowerCase())
249
+ .filter(Boolean)
250
+ .slice(0, 20);
251
+ }
252
+ else if (input.tags !== undefined) {
253
+ throw new Error('tags must be a list or comma-separated text');
254
+ }
255
+ let screenshots = [];
256
+ if (input.screenshots !== undefined) {
257
+ if (!Array.isArray(input.screenshots)) {
258
+ throw new Error('screenshots must be a list');
259
+ }
260
+ if (input.screenshots.length > 5) {
261
+ throw new Error('screenshots supports at most 5 images');
262
+ }
263
+ screenshots = input.screenshots.map((shot, index) => {
264
+ if (typeof shot === 'string') {
265
+ return {
266
+ url: requiredHttpUrl(shot, `screenshots[${index}]`),
267
+ caption: '',
268
+ };
269
+ }
270
+ if (typeof shot !== 'object' || shot === null || Array.isArray(shot)) {
271
+ throw new Error(`screenshots[${index}] must be a URL or mapping`);
272
+ }
273
+ const value = shot;
274
+ if (typeof value.caption !== 'string' && value.caption !== undefined) {
275
+ throw new Error(`screenshots[${index}].caption must be text`);
276
+ }
277
+ return {
278
+ url: requiredHttpUrl(value.url, `screenshots[${index}].url`),
279
+ caption: value.caption?.trim() ?? '',
280
+ };
281
+ });
282
+ }
283
+ const rawSlug = input.slug;
284
+ if (rawSlug !== undefined && typeof rawSlug !== 'string') {
285
+ throw new Error('slug must be text');
286
+ }
287
+ return {
288
+ ok: true,
289
+ project: {
290
+ title,
291
+ slug: slugFromName(rawSlug?.trim() || repo?.name || title),
292
+ description: typeof input.description === 'string'
293
+ ? input.description.trim() || null
294
+ : null,
295
+ tags,
296
+ repoUrl: repo?.url ?? null,
297
+ liveUrl: httpUrl(input.liveUrl, 'liveUrl'),
298
+ // Manifests don't declare privacy; `publishProject` probes the repo.
299
+ private: false,
300
+ content: typeof input.content === 'string' ? input.content : null,
301
+ ...(input.screenshots !== undefined ? { screenshots } : {}),
302
+ },
303
+ };
304
+ }
305
+ catch (err) {
306
+ return {
307
+ ok: false,
308
+ error: err instanceof Error ? err.message : String(err),
309
+ };
310
+ }
311
+ }
312
+ async function uploadScreenshotFromUrl(session, screenshot, index) {
313
+ const sourceUrl = httpUrl(screenshot.url, `screenshots[${index}]`);
314
+ if (!sourceUrl)
315
+ throw new Error(`screenshots[${index}] needs a URL`);
316
+ const image = await fetch(sourceUrl, {
317
+ signal: AbortSignal.timeout(12_000),
318
+ headers: { 'User-Agent': 'HacklabCLI (+https://hacklab.so)' },
319
+ });
320
+ if (!image.ok) {
321
+ throw new Error(`could not download screenshot ${index + 1} (${image.status})`);
322
+ }
323
+ const contentType = image.headers.get('content-type')?.split(';')[0] ?? '';
324
+ const extension = SCREENSHOT_CONTENT_TYPES.get(contentType);
325
+ if (!extension) {
326
+ throw new Error(`screenshot ${index + 1} must be PNG, JPEG, or WebP`);
327
+ }
328
+ const bytes = Buffer.from(await image.arrayBuffer());
329
+ if (!bytes.length)
330
+ throw new Error(`screenshot ${index + 1} is empty`);
331
+ if (bytes.length > SCREENSHOT_MAX_BYTES) {
332
+ throw new Error(`screenshot ${index + 1} is too large`);
333
+ }
334
+ const sourceName = basename(new URL(sourceUrl).pathname);
335
+ const filename = sourceName || `screenshot-${index + 1}.${extension}`;
336
+ const upload = await fetchApi(session, '/api/screenshots', {
337
+ method: 'POST',
338
+ headers: {
339
+ 'Content-Type': 'application/json',
340
+ Authorization: `Bearer ${session.token}`,
341
+ },
342
+ body: JSON.stringify({
343
+ image: bytes.toString('base64'),
344
+ filename,
345
+ contentType,
346
+ }),
347
+ });
348
+ const data = (await upload.json().catch(() => null));
349
+ if (!upload.ok || !data?.url) {
350
+ throw new Error(data?.error ?? `could not upload screenshot ${index + 1}`);
351
+ }
352
+ return { url: data.url, caption: screenshot.caption };
353
+ }
354
+ // Shared publish path for `apply`: idempotent by slug, keeps the original
355
+ // publish date on refresh, and round-trips `sourceYaml`/`content`.
356
+ async function publishProject(session, draft, options) {
357
+ const { json, yes } = options;
358
+ let existing;
359
+ try {
360
+ existing = (await fetchProjects(session)).projects.find((project) => project.slug === draft.slug);
361
+ }
362
+ catch (err) {
363
+ const message = err instanceof Error ? err.message : String(err);
364
+ if (json)
365
+ emitJsonError('error', message);
366
+ error(message);
367
+ process.exit(1);
368
+ }
369
+ // Manifests don't declare privacy; probe a github repoUrl like `add` does.
370
+ const isPrivate = await probeRepoPrivate(draft.repoUrl);
371
+ if (!json) {
372
+ console.log(` ${bold(existing ? 'refreshing' : 'publishing')} ${bold(draft.title)} ${dim(`(${draft.slug})`)}`);
373
+ console.log(` ${dim('description')} ${summarize(draft.description)}`);
374
+ console.log(` ${dim('repo')} ${summarize(draft.repoUrl)}${isPrivate ? dim(' (private — hidden on web)') : ''}`);
375
+ console.log(` ${dim('live')} ${summarize(draft.liveUrl)}`);
376
+ console.log(` ${dim('tags')} ${draft.tags.length ? draft.tags.join(', ') : dim('(none)')}`);
377
+ console.log(` ${dim('content')} ${draft.content ? `${draft.content.length} chars` : dim('(none)')}`);
378
+ console.log(` ${dim('screenshots')} ${draft.screenshots?.length ?? existing?.screenshots.length ?? 0}`);
379
+ }
380
+ if (!json && !yes && process.stdout.isTTY) {
381
+ const go = await clack.confirm({ message: 'publish it?' });
382
+ if (clack.isCancel(go) || !go) {
383
+ clack.outro(dim('cancelled.'));
384
+ return;
385
+ }
386
+ }
387
+ let screenshots = existing?.screenshots ?? [];
388
+ try {
389
+ if (draft.screenshots !== undefined) {
390
+ if (!json && draft.screenshots.length) {
391
+ info(dim(`uploading ${draft.screenshots.length} screenshot(s)…`));
392
+ }
393
+ screenshots = [];
394
+ for (const [index, screenshot] of draft.screenshots.entries()) {
395
+ screenshots.push(await uploadScreenshotFromUrl(session, screenshot, index));
396
+ }
397
+ }
398
+ else if (draft.liveUrl) {
399
+ if (!json)
400
+ info(dim('looking for a screenshot (og:image)…'));
401
+ const shot = await captureOgScreenshot(session, draft.liveUrl);
402
+ if (shot)
403
+ screenshots = [shot];
404
+ }
405
+ }
406
+ catch (err) {
407
+ const message = err instanceof Error ? err.message : String(err);
408
+ if (json)
409
+ emitJsonError('screenshot_failed', message);
410
+ error(message);
411
+ process.exit(1);
412
+ }
413
+ const payload = {
414
+ title: draft.title,
415
+ slug: draft.slug,
416
+ description: draft.description ?? undefined,
417
+ tags: draft.tags,
418
+ repoUrl: draft.repoUrl ?? undefined,
419
+ liveUrl: draft.liveUrl ?? undefined,
420
+ private: isPrivate,
421
+ screenshots,
422
+ content: draft.content ?? existing?.content ?? undefined,
423
+ sourceYaml: draft.sourceYaml ?? existing?.sourceYaml ?? undefined,
424
+ publishedAt: existing?.publishedAt ?? new Date().toISOString(),
425
+ };
426
+ let res;
427
+ try {
428
+ res = await fetchApi(session, PROJECTS_PATH, {
429
+ method: 'POST',
430
+ headers: {
431
+ 'Content-Type': 'application/json',
432
+ Authorization: `Bearer ${session.token}`,
433
+ },
434
+ body: JSON.stringify(payload),
435
+ });
436
+ }
437
+ catch (err) {
438
+ const message = err instanceof Error ? err.message : String(err);
439
+ if (json)
440
+ emitJsonError('network', message);
441
+ error(message);
442
+ process.exit(1);
443
+ }
444
+ if (!res.ok) {
445
+ const message = await readEnvelopeError(res, session);
446
+ if (json)
447
+ emitJsonError('error', message);
448
+ error(message);
449
+ process.exit(1);
450
+ }
451
+ await captureEvent(session.handle, 'cli_project_added', {
452
+ slug: draft.slug,
453
+ refreshed: Boolean(existing),
454
+ private: isPrivate,
455
+ has_live_url: Boolean(draft.liveUrl),
456
+ has_screenshot: screenshots.length > 0,
457
+ tag_count: draft.tags.length,
458
+ via: 'apply',
459
+ });
460
+ const path = `/${session.handle}/${draft.slug}`;
461
+ if (json) {
462
+ printJson({
463
+ schemaVersion: 1,
464
+ [existing ? 'refreshed' : 'published']: true,
465
+ slug: draft.slug,
466
+ path,
467
+ screenshots,
468
+ });
469
+ return;
470
+ }
471
+ success(`${existing ? 'refreshed' : 'published'} ${bold(draft.title)}`);
472
+ info(`${resolveAppUrl(session)}${path}`);
473
+ }
474
+ async function projectApply(args) {
475
+ const json = args.includes('--json');
476
+ const yes = args.includes('--yes');
477
+ const path = args.find((arg) => !arg.startsWith('-'));
478
+ if (!path)
479
+ usage();
480
+ let sourceYaml;
481
+ try {
482
+ sourceYaml = await readFile(path, 'utf8');
483
+ }
484
+ catch {
485
+ const message = `could not read ${path}`;
486
+ if (json)
487
+ emitJsonError('read_failed', message);
488
+ error(message);
489
+ process.exit(1);
490
+ }
491
+ let doc;
492
+ try {
493
+ doc = parseYaml(sourceYaml);
494
+ }
495
+ catch (err) {
496
+ const message = `could not parse ${path}: ${err instanceof Error ? err.message : String(err)}`;
497
+ if (json)
498
+ emitJsonError('parse_failed', message);
499
+ error(message);
500
+ process.exit(1);
501
+ }
502
+ const parsed = parseProjectDocument(doc);
503
+ if (!parsed.ok) {
504
+ if (json)
505
+ emitJsonError('invalid_fields', parsed.error);
506
+ error(parsed.error);
507
+ process.exit(1);
508
+ }
509
+ const session = await requireSession(json);
510
+ await publishProject(session, { ...parsed.project, sourceYaml }, { json, yes });
511
+ }
156
512
  async function projectAdd(args) {
157
513
  const json = args.includes('--json');
158
514
  const yes = args.includes('--yes');
515
+ const noRepo = args.includes('--no-repo');
159
516
  const overrides = {
160
517
  title: flagValue(args, '--title'),
161
518
  description: flagValue(args, '--description', '--desc'),
162
519
  liveUrl: flagValue(args, '--live'),
163
520
  repoUrl: flagValue(args, '--repo'),
521
+ // `--url` auto-routes: a github.com URL becomes the repo, anything else the
522
+ // live link — the one flag a "just give it a URL" manual project needs.
523
+ url: flagValue(args, '--url'),
164
524
  slug: flagValue(args, '--slug'),
165
525
  tags: flagValue(args, '--tags'),
526
+ // Override the long-form project content (defaults to the repo README):
527
+ // `--content` inline, or `--content-file <path>` to read it from disk.
528
+ content: flagValue(args, '--content'),
529
+ contentFile: flagValue(args, '--content-file'),
166
530
  };
167
531
  const flagValues = new Set(Object.values(overrides).filter((v) => v !== undefined));
532
+ if (overrides.content !== undefined && overrides.contentFile !== undefined) {
533
+ const message = 'use either --content or --content-file, not both';
534
+ if (json)
535
+ emitJsonError('invalid_fields', message);
536
+ error(message);
537
+ process.exit(1);
538
+ }
539
+ let contentOverride = overrides.content;
540
+ if (overrides.contentFile) {
541
+ try {
542
+ contentOverride = await readFile(overrides.contentFile, 'utf8');
543
+ }
544
+ catch {
545
+ const message = `could not read ${overrides.contentFile}`;
546
+ if (json)
547
+ emitJsonError('read_failed', message);
548
+ error(message);
549
+ process.exit(1);
550
+ }
551
+ }
168
552
  const pathArg = args.find((a) => !a.startsWith('-') && !flagValues.has(a));
169
- const root = await findRepoRoot(pathArg ?? process.cwd());
170
- if (!root) {
553
+ // Manual (no-repo) project: explicit --no-repo, or a --title given from a
554
+ // non-git directory. Otherwise infer from the repo you're standing in.
555
+ const root = noRepo ? null : await findRepoRoot(pathArg ?? process.cwd());
556
+ const manual = noRepo || (!root && Boolean(overrides.title));
557
+ if (!manual && !root) {
171
558
  const message = `not a git repository: ${pathArg ?? process.cwd()}`;
172
559
  if (json)
173
560
  emitJsonError('not_a_repo', message);
174
561
  error(message);
175
562
  info('run from inside the repo you want to publish');
563
+ info(`or add a project with no repo: ${dim('project add --no-repo --title "…"')}`);
176
564
  process.exit(1);
177
565
  }
178
- const inferred = await inferProject(root);
179
- const draft = {
180
- ...inferred,
181
- title: overrides.title ?? inferred.title,
182
- description: overrides.description ?? inferred.description,
183
- liveUrl: overrides.liveUrl ?? inferred.liveUrl,
184
- repoUrl: overrides.repoUrl ?? inferred.repoUrl,
185
- slug: overrides.slug ? slugFromName(overrides.slug) : inferred.slug,
186
- tags: overrides.tags ? parseTags(overrides.tags) : inferred.tags,
187
- };
566
+ const urlIsRepo = overrides.url ? isGithubRepoUrl(overrides.url) : false;
567
+ const urlAsRepo = urlIsRepo ? overrides.url : undefined;
568
+ const urlAsLive = overrides.url && !urlIsRepo ? overrides.url : undefined;
569
+ let draft;
570
+ if (manual) {
571
+ if (!overrides.title) {
572
+ const message = 'a project with no repo needs a title: --title "My Project"';
573
+ if (json)
574
+ emitJsonError('missing_title', message);
575
+ error(message);
576
+ process.exit(1);
577
+ }
578
+ draft = {
579
+ slug: slugFromName(overrides.slug ?? overrides.title),
580
+ title: overrides.title,
581
+ description: overrides.description ?? null,
582
+ tags: overrides.tags ? parseTags(overrides.tags) : [],
583
+ repoUrl: overrides.repoUrl ?? urlAsRepo ?? null,
584
+ liveUrl: overrides.liveUrl ?? urlAsLive ?? null,
585
+ private: false,
586
+ content: null,
587
+ };
588
+ }
589
+ else {
590
+ // biome-ignore lint/style/noNonNullAssertion: `manual` is false here so root is set.
591
+ const inferred = await inferProject(root);
592
+ draft = {
593
+ ...inferred,
594
+ title: overrides.title ?? inferred.title,
595
+ description: overrides.description ?? inferred.description,
596
+ liveUrl: overrides.liveUrl ?? urlAsLive ?? inferred.liveUrl,
597
+ repoUrl: overrides.repoUrl ?? urlAsRepo ?? inferred.repoUrl,
598
+ slug: overrides.slug ? slugFromName(overrides.slug) : inferred.slug,
599
+ tags: overrides.tags ? parseTags(overrides.tags) : inferred.tags,
600
+ };
601
+ }
602
+ if (contentOverride !== undefined)
603
+ draft.content = contentOverride;
604
+ // Privacy: explicit --private/--public win; otherwise probe a github repoUrl.
605
+ // A private repo's link 404s for visitors and it's absent from the public
606
+ // pinned-repo snapshot, so the web hides its repo link, GitHub button + stats.
607
+ if (args.includes('--private'))
608
+ draft.private = true;
609
+ else if (args.includes('--public'))
610
+ draft.private = false;
611
+ else
612
+ draft.private = await probeRepoPrivate(draft.repoUrl);
188
613
  const session = await requireSession(json);
189
614
  // The existing row (if any) decides create-vs-refresh, keeps the original
190
615
  // publish date, and provides fallback screenshots.
@@ -202,10 +627,10 @@ async function projectAdd(args) {
202
627
  if (!json) {
203
628
  console.log(` ${bold(existing ? 'refreshing' : 'publishing')} ${bold(draft.title)} ${dim(`(${draft.slug})`)}`);
204
629
  console.log(` ${dim('description')} ${summarize(draft.description)}`);
205
- console.log(` ${dim('repo')} ${summarize(draft.repoUrl)}`);
630
+ console.log(` ${dim('repo')} ${summarize(draft.repoUrl)}${draft.private ? dim(' (private — hidden on web)') : ''}`);
206
631
  console.log(` ${dim('live')} ${summarize(draft.liveUrl)}`);
207
632
  console.log(` ${dim('tags')} ${draft.tags.length ? draft.tags.join(', ') : dim('(none)')}`);
208
- console.log(` ${dim('content')} ${draft.content ? `README (${draft.content.length} chars)` : dim('(none)')}`);
633
+ console.log(` ${dim('content')} ${draft.content ? `${draft.content.length} chars` : dim('(none)')}`);
209
634
  }
210
635
  if (!json && !yes && process.stdout.isTTY) {
211
636
  const go = await clack.confirm({ message: 'publish it?' });
@@ -231,6 +656,7 @@ async function projectAdd(args) {
231
656
  tags: draft.tags,
232
657
  repoUrl: draft.repoUrl ?? undefined,
233
658
  liveUrl: draft.liveUrl ?? undefined,
659
+ private: draft.private,
234
660
  screenshots,
235
661
  content: draft.content ?? undefined,
236
662
  publishedAt: existing?.publishedAt ?? new Date().toISOString(),
@@ -263,6 +689,9 @@ async function projectAdd(args) {
263
689
  await captureEvent(session.handle, 'cli_project_added', {
264
690
  slug: draft.slug,
265
691
  refreshed: Boolean(existing),
692
+ manual,
693
+ has_repo: Boolean(draft.repoUrl),
694
+ private: draft.private,
266
695
  has_live_url: Boolean(draft.liveUrl),
267
696
  has_screenshot: screenshots.length > 0,
268
697
  tag_count: draft.tags.length,
@@ -310,6 +739,228 @@ async function projectList(args) {
310
739
  console.log('');
311
740
  info(dim(`${resolveAppUrl(session)}/${list.handle}`));
312
741
  }
742
+ /** Nearest slug for a "did you mean" hint: prefix/substring match, else null. */
743
+ function nearestSlug(projects, slug) {
744
+ const q = slug.toLowerCase();
745
+ const hit = projects.find((p) => p.slug.startsWith(q)) ??
746
+ projects.find((p) => p.slug.includes(q));
747
+ return hit?.slug ?? null;
748
+ }
749
+ function renderProjectCard(project, session) {
750
+ console.log('');
751
+ console.log(` ${bold(project.title)} ${dim(`(${project.slug})`)}`);
752
+ if (project.description) {
753
+ console.log(` ${summarize(project.description, 96)}`);
754
+ }
755
+ console.log('');
756
+ if (project.tags.length) {
757
+ console.log(` ${dim('tags')} ${project.tags.join(', ')}`);
758
+ }
759
+ if (project.repoUrl) {
760
+ console.log(` ${dim('repo')} ${project.repoUrl}${project.private ? dim(' (private — hidden on web)') : ''}`);
761
+ }
762
+ if (project.liveUrl)
763
+ console.log(` ${dim('live')} ${project.liveUrl}`);
764
+ console.log(` ${dim('source')} ${project.source}${project.content
765
+ ? dim(` · README (${project.content.length} chars)`)
766
+ : ''}`);
767
+ console.log('');
768
+ info(dim(`${resolveAppUrl(session)}${project.path}`));
769
+ }
770
+ async function projectView(args) {
771
+ const json = args.includes('--json');
772
+ const web = args.includes('--web');
773
+ const slug = args.find((a) => !a.startsWith('-'));
774
+ if (!slug) {
775
+ if (json)
776
+ emitJsonError('usage', 'usage: hacklab project view <slug>');
777
+ error('usage: hacklab project view <slug>');
778
+ process.exit(1);
779
+ }
780
+ const session = await requireSession(json);
781
+ let list;
782
+ try {
783
+ list = await fetchProjects(session);
784
+ }
785
+ catch (err) {
786
+ const message = err instanceof Error ? err.message : String(err);
787
+ if (json)
788
+ emitJsonError('error', message);
789
+ error(message);
790
+ process.exit(1);
791
+ }
792
+ const project = list.projects.find((p) => p.slug === slug);
793
+ if (!project) {
794
+ if (json)
795
+ emitJsonError('not_found', `no project named "${slug}"`);
796
+ error(`no project named "${slug}"`);
797
+ const near = nearestSlug(list.projects, slug);
798
+ if (near)
799
+ info(`did you mean ${dim(`hacklab project view ${near}`)}?`);
800
+ process.exit(1);
801
+ }
802
+ const url = `${resolveAppUrl(session)}${project.path}`;
803
+ if (web) {
804
+ const opened = await openBrowser(url);
805
+ info(opened
806
+ ? `opened ${linkBlue(url)}`
807
+ : `could not open a browser — ${linkBlue(url)}`);
808
+ return;
809
+ }
810
+ if (json) {
811
+ printJson({ schemaVersion: 1, project });
812
+ return;
813
+ }
814
+ renderProjectCard(project, session);
815
+ }
816
+ async function projectEdit(args) {
817
+ const json = args.includes('--json');
818
+ const yes = args.includes('--yes');
819
+ const slug = args.find((a) => !a.startsWith('-'));
820
+ if (!slug) {
821
+ if (json)
822
+ emitJsonError('usage', 'usage: hacklab project edit <slug> [--title …]');
823
+ error('usage: hacklab project edit <slug> [--title/--desc/--url/--repo/--tags]');
824
+ process.exit(1);
825
+ }
826
+ const titleFlag = flagValue(args, '--title');
827
+ const descFlag = flagValue(args, '--description', '--desc');
828
+ const liveFlag = flagValue(args, '--live');
829
+ const repoFlag = flagValue(args, '--repo');
830
+ const urlFlag = flagValue(args, '--url');
831
+ const tagsFlag = flagValue(args, '--tags');
832
+ const clearRepo = args.includes('--clear-repo');
833
+ const clearLive = args.includes('--clear-live');
834
+ const urlIsRepo = urlFlag ? isGithubRepoUrl(urlFlag) : false;
835
+ const changed = [titleFlag, descFlag, liveFlag, repoFlag, urlFlag, tagsFlag].some((v) => v !== undefined) ||
836
+ clearRepo ||
837
+ clearLive;
838
+ if (!changed) {
839
+ const message = 'nothing to edit — pass --title, --desc, --url, --repo, --live, --tags, --clear-repo or --clear-live';
840
+ if (json)
841
+ emitJsonError('no_change', message);
842
+ error(message);
843
+ process.exit(1);
844
+ }
845
+ const session = await requireSession(json);
846
+ let list;
847
+ try {
848
+ list = await fetchProjects(session);
849
+ }
850
+ catch (err) {
851
+ const message = err instanceof Error ? err.message : String(err);
852
+ if (json)
853
+ emitJsonError('error', message);
854
+ error(message);
855
+ process.exit(1);
856
+ }
857
+ const existing = list.projects.find((p) => p.slug === slug);
858
+ if (!existing) {
859
+ if (json)
860
+ emitJsonError('not_found', `no project named "${slug}"`);
861
+ error(`no project named "${slug}"`);
862
+ const near = nearestSlug(list.projects, slug);
863
+ if (near)
864
+ info(`did you mean ${dim(`hacklab project edit ${near}`)}?`);
865
+ process.exit(1);
866
+ }
867
+ // Editing a GitHub-synced project converts it to a manual one (the POST route
868
+ // relabels it `cli`), so it stops updating from GitHub. Confirm first.
869
+ if (existing.source === 'github') {
870
+ if (!json && !yes && process.stdout.isTTY) {
871
+ const go = await clack.confirm({
872
+ message: `${bold(slug)} is synced from GitHub — editing stops that sync. continue?`,
873
+ });
874
+ if (clack.isCancel(go) || !go) {
875
+ clack.outro(dim('cancelled.'));
876
+ return;
877
+ }
878
+ }
879
+ else if (!json && !yes) {
880
+ error(`${slug} is synced from GitHub — re-run with --yes to edit it anyway`);
881
+ process.exit(1);
882
+ }
883
+ }
884
+ // Merge: only passed fields change; everything else round-trips unchanged.
885
+ let repoUrl = existing.repoUrl;
886
+ let liveUrl = existing.liveUrl;
887
+ if (repoFlag !== undefined)
888
+ repoUrl = repoFlag;
889
+ if (liveFlag !== undefined)
890
+ liveUrl = liveFlag;
891
+ if (urlFlag !== undefined) {
892
+ if (urlIsRepo)
893
+ repoUrl = urlFlag;
894
+ else
895
+ liveUrl = urlFlag;
896
+ }
897
+ if (clearRepo)
898
+ repoUrl = null;
899
+ if (clearLive)
900
+ liveUrl = null;
901
+ // Re-probe privacy only when the repo URL itself changed.
902
+ const repoChanged = repoFlag !== undefined || (urlFlag !== undefined && urlIsRepo) || clearRepo;
903
+ let isPrivate = existing.private;
904
+ if (args.includes('--private'))
905
+ isPrivate = true;
906
+ else if (args.includes('--public'))
907
+ isPrivate = false;
908
+ else if (repoChanged)
909
+ isPrivate = await probeRepoPrivate(repoUrl);
910
+ const payload = {
911
+ title: titleFlag ?? existing.title,
912
+ slug: existing.slug,
913
+ description: descFlag !== undefined ? descFlag : (existing.description ?? undefined),
914
+ tags: tagsFlag !== undefined ? parseTags(tagsFlag) : existing.tags,
915
+ repoUrl: repoUrl ?? undefined,
916
+ liveUrl: liveUrl ?? undefined,
917
+ private: isPrivate,
918
+ screenshots: existing.screenshots,
919
+ content: existing.content ?? undefined,
920
+ // A partial edit round-trips everything it doesn't touch — including the
921
+ // `apply` manifest source — so editing one field never wipes the rest.
922
+ sourceYaml: existing.sourceYaml ?? undefined,
923
+ publishedAt: existing.publishedAt ?? new Date().toISOString(),
924
+ };
925
+ let res;
926
+ try {
927
+ res = await fetchApi(session, PROJECTS_PATH, {
928
+ method: 'POST',
929
+ headers: {
930
+ 'Content-Type': 'application/json',
931
+ Authorization: `Bearer ${session.token}`,
932
+ },
933
+ body: JSON.stringify(payload),
934
+ });
935
+ }
936
+ catch (err) {
937
+ const message = err instanceof Error ? err.message : String(err);
938
+ if (json)
939
+ emitJsonError('network', message);
940
+ error(message);
941
+ process.exit(1);
942
+ }
943
+ if (!res.ok) {
944
+ const message = await readEnvelopeError(res, session);
945
+ if (json)
946
+ emitJsonError('error', message);
947
+ error(message);
948
+ process.exit(1);
949
+ }
950
+ await captureEvent(session.handle, 'cli_project_edited', {
951
+ slug,
952
+ private: isPrivate,
953
+ changed_title: titleFlag !== undefined,
954
+ changed_repo: repoChanged,
955
+ });
956
+ const path = existing.path;
957
+ if (json) {
958
+ printJson({ schemaVersion: 1, edited: true, slug, path });
959
+ return;
960
+ }
961
+ success(`edited ${bold(payload.title)}`);
962
+ info(`${resolveAppUrl(session)}${path}`);
963
+ }
313
964
  async function projectDelete(args) {
314
965
  const json = args.includes('--json');
315
966
  const yes = args.includes('--yes');
@@ -379,8 +1030,14 @@ export async function project(args) {
379
1030
  }
380
1031
  if (resolved.name === 'add')
381
1032
  return projectAdd(rest);
1033
+ if (resolved.name === 'apply')
1034
+ return projectApply(rest);
382
1035
  if (resolved.name === 'list')
383
1036
  return projectList(rest);
1037
+ if (resolved.name === 'view')
1038
+ return projectView(rest);
1039
+ if (resolved.name === 'edit')
1040
+ return projectEdit(rest);
384
1041
  if (resolved.name === 'delete')
385
1042
  return projectDelete(rest);
386
1043
  }