hacklab 0.9.0 → 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.
- package/README.md +60 -35
- package/dist/commands/drop.d.ts +2 -1
- package/dist/commands/drop.d.ts.map +1 -1
- package/dist/commands/drop.js +51 -11
- package/dist/commands/drop.js.map +1 -1
- package/dist/commands/hacker.d.ts.map +1 -1
- package/dist/commands/hacker.js +68 -6
- package/dist/commands/hacker.js.map +1 -1
- package/dist/commands/join.d.ts +1 -1
- package/dist/commands/join.d.ts.map +1 -1
- package/dist/commands/join.js +96 -184
- package/dist/commands/join.js.map +1 -1
- package/dist/commands/profile.d.ts +2 -1
- package/dist/commands/profile.d.ts.map +1 -1
- package/dist/commands/profile.js +66 -7
- package/dist/commands/profile.js.map +1 -1
- package/dist/commands/project.d.ts +19 -0
- package/dist/commands/project.d.ts.map +1 -1
- package/dist/commands/project.js +684 -27
- package/dist/commands/project.js.map +1 -1
- package/dist/commands/sync.js +7 -3
- package/dist/commands/sync.js.map +1 -1
- package/dist/commands/update.js +2 -2
- package/dist/commands/update.js.map +1 -1
- package/dist/project-infer.d.ts +13 -0
- package/dist/project-infer.d.ts.map +1 -1
- package/dist/project-infer.js +41 -2
- package/dist/project-infer.js.map +1 -1
- package/dist/registry.js +7 -7
- package/dist/registry.js.map +1 -1
- package/dist/share.d.ts +3 -12
- package/dist/share.d.ts.map +1 -1
- package/dist/share.js +41 -37
- package/dist/share.js.map +1 -1
- package/dist/sync.d.ts +4 -1
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +9 -2
- package/dist/sync.js.map +1 -1
- package/package.json +5 -3
package/dist/commands/project.js
CHANGED
|
@@ -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
|
-
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// for confirmation
|
|
12
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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 ?
|
|
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
|
}
|