hacklab 0.12.8 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -26
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/drops.d.ts +5 -3
- package/dist/commands/drops.d.ts.map +1 -1
- package/dist/commands/drops.js +42 -9
- package/dist/commands/drops.js.map +1 -1
- package/dist/commands/essay.d.ts +18 -2
- package/dist/commands/essay.d.ts.map +1 -1
- package/dist/commands/essay.js +496 -279
- package/dist/commands/essay.js.map +1 -1
- package/dist/commands/project.d.ts +0 -22
- package/dist/commands/project.d.ts.map +1 -1
- package/dist/commands/project.js +308 -738
- package/dist/commands/project.js.map +1 -1
- package/dist/commands/scan.d.ts.map +1 -1
- package/dist/commands/scan.js +26 -11
- package/dist/commands/scan.js.map +1 -1
- package/dist/commands/sync.js +1 -1
- package/dist/commands/sync.js.map +1 -1
- package/dist/registry.js +4 -4
- package/dist/registry.js.map +1 -1
- package/dist/share-card.d.ts +2 -1
- package/dist/share-card.d.ts.map +1 -1
- package/dist/share-card.js +8 -1
- package/dist/share-card.js.map +1 -1
- package/dist/share.d.ts +11 -3
- package/dist/share.d.ts.map +1 -1
- package/dist/share.js +27 -16
- package/dist/share.js.map +1 -1
- package/dist/utils/waitForEnter.d.ts +6 -0
- package/dist/utils/waitForEnter.d.ts.map +1 -1
- package/dist/utils/waitForEnter.js +22 -7
- package/dist/utils/waitForEnter.js.map +1 -1
- package/package.json +1 -1
package/dist/commands/project.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
6
|
+
import { resolveAppUrl } from '../session.js';
|
|
9
7
|
import { fetchApi } from '../sync.js';
|
|
10
|
-
import { bold, dim, error, info,
|
|
11
|
-
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
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
|
|
23
|
-
console.log(
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
97
|
+
return found.length === 1 ? found[0] : undefined;
|
|
110
98
|
}
|
|
111
|
-
|
|
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
|
|
120
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
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
|
|
512
|
-
|
|
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',
|
|
531
|
-
error(
|
|
204
|
+
emitJsonError('invalid_fields', `unknown flag: ${unknown}`);
|
|
205
|
+
error(`unknown flag: ${unknown}`);
|
|
532
206
|
process.exit(1);
|
|
533
207
|
}
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
555
|
-
|
|
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('
|
|
220
|
+
emitJsonError('missing_url', message);
|
|
587
221
|
error(message);
|
|
588
222
|
process.exit(1);
|
|
589
223
|
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
|
617
|
-
slug
|
|
618
|
-
description:
|
|
619
|
-
tags:
|
|
620
|
-
repoUrl:
|
|
621
|
-
liveUrl:
|
|
622
|
-
private:
|
|
623
|
-
screenshots,
|
|
624
|
-
|
|
625
|
-
|
|
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
|
|
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
|
|
270
|
+
slug,
|
|
655
271
|
refreshed: Boolean(existing),
|
|
656
|
-
has_repo: Boolean(
|
|
657
|
-
|
|
658
|
-
|
|
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}/${
|
|
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
|
|
281
|
+
slug,
|
|
668
282
|
path,
|
|
669
283
|
});
|
|
670
284
|
return;
|
|
671
285
|
}
|
|
672
|
-
success(`${existing ? 'refreshed' : 'published'} ${bold(
|
|
673
|
-
info(`${resolveAppUrl(session)}${path}`);
|
|
286
|
+
success(`${existing ? 'refreshed' : 'published'} ${bold(title)}`);
|
|
287
|
+
info(link(`${resolveAppUrl(session)}${path}`));
|
|
674
288
|
}
|
|
675
|
-
|
|
676
|
-
|
|
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(...
|
|
699
|
-
for (const p of
|
|
700
|
-
|
|
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(
|
|
301
|
+
info(link(`${appUrl}/${handle}`));
|
|
704
302
|
}
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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(` ${
|
|
727
|
-
|
|
728
|
-
|
|
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(
|
|
316
|
+
info(link(`${appUrl}${project.path}`));
|
|
732
317
|
}
|
|
733
|
-
async function
|
|
318
|
+
async function projectList(args) {
|
|
734
319
|
const json = args.includes('--json');
|
|
735
|
-
const
|
|
736
|
-
|
|
737
|
-
if (!slug) {
|
|
320
|
+
const unknown = unknownFlag(args, new Set(['--json']), new Set());
|
|
321
|
+
if (unknown) {
|
|
738
322
|
if (json)
|
|
739
|
-
emitJsonError('
|
|
740
|
-
error(
|
|
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
|
-
|
|
745
|
-
|
|
746
|
-
|
|
328
|
+
const list = await ownProjectsOrExit(session, json);
|
|
329
|
+
if (json) {
|
|
330
|
+
printJson({ schemaVersion: 1, ...list });
|
|
331
|
+
return;
|
|
747
332
|
}
|
|
748
|
-
|
|
749
|
-
|
|
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('
|
|
341
|
+
emitJsonError('usage', message);
|
|
752
342
|
error(message);
|
|
753
343
|
process.exit(1);
|
|
754
344
|
}
|
|
755
|
-
const
|
|
756
|
-
|
|
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',
|
|
759
|
-
error(
|
|
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
|
-
|
|
362
|
+
renderProject(found, resolveAppUrl(session));
|
|
778
363
|
}
|
|
779
364
|
async function projectEdit(args) {
|
|
780
365
|
const json = args.includes('--json');
|
|
781
|
-
const
|
|
782
|
-
|
|
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',
|
|
786
|
-
error(
|
|
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
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
-
|
|
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',
|
|
824
|
-
error(
|
|
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
|
-
//
|
|
831
|
-
//
|
|
832
|
-
if (existing.source === 'github') {
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
-
//
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
if (
|
|
855
|
-
|
|
856
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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(
|
|
939
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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')
|