hubbits 0.1.0
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/bin/hubbits.js +2 -0
- package/dist/index.js +4316 -0
- package/package.json +65 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4316 @@
|
|
|
1
|
+
import { defineCommand, runMain } from 'citty';
|
|
2
|
+
import * as p5 from '@clack/prompts';
|
|
3
|
+
import pc5 from 'picocolors';
|
|
4
|
+
import open from 'open';
|
|
5
|
+
import { ofetch, FetchError } from 'ofetch';
|
|
6
|
+
import { existsSync, readdirSync, mkdirSync, writeFileSync, readFileSync, createWriteStream, createReadStream, unlinkSync } from 'fs';
|
|
7
|
+
import { homedir } from 'os';
|
|
8
|
+
import { join, resolve, basename, relative } from 'path';
|
|
9
|
+
import ora from 'ora';
|
|
10
|
+
import yaml from 'js-yaml';
|
|
11
|
+
import { mkdir, readdir, stat } from 'fs/promises';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import { pipeline } from 'stream/promises';
|
|
14
|
+
import { createGunzip, createGzip } from 'zlib';
|
|
15
|
+
import { extract as extract$1, pack as pack$1 } from 'tar-stream';
|
|
16
|
+
|
|
17
|
+
// src/index.ts
|
|
18
|
+
var HUBBITS_DIR = join(homedir(), ".hubbits");
|
|
19
|
+
var CONFIG_PATH = join(HUBBITS_DIR, "config.json");
|
|
20
|
+
var CREDENTIALS_PATH = join(HUBBITS_DIR, "credentials.json");
|
|
21
|
+
var DEFAULT_REGISTRY_URL = "https://api.hubbits.dev";
|
|
22
|
+
function ensureDir() {
|
|
23
|
+
if (!existsSync(HUBBITS_DIR)) {
|
|
24
|
+
mkdirSync(HUBBITS_DIR, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function readJsonFile(path) {
|
|
28
|
+
try {
|
|
29
|
+
if (!existsSync(path)) return null;
|
|
30
|
+
const raw = readFileSync(path, "utf-8");
|
|
31
|
+
return JSON.parse(raw);
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function writeJsonFile(path, data) {
|
|
37
|
+
ensureDir();
|
|
38
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + "\n", { mode: 384 });
|
|
39
|
+
}
|
|
40
|
+
var DEFAULT_CONFIG = {
|
|
41
|
+
registry_url: DEFAULT_REGISTRY_URL
|
|
42
|
+
};
|
|
43
|
+
function readConfig() {
|
|
44
|
+
const stored = readJsonFile(CONFIG_PATH);
|
|
45
|
+
return { ...DEFAULT_CONFIG, ...stored };
|
|
46
|
+
}
|
|
47
|
+
function readCredentials() {
|
|
48
|
+
return readJsonFile(CREDENTIALS_PATH);
|
|
49
|
+
}
|
|
50
|
+
function writeCredentials(credentials) {
|
|
51
|
+
writeJsonFile(CREDENTIALS_PATH, credentials);
|
|
52
|
+
}
|
|
53
|
+
function deleteCredentials() {
|
|
54
|
+
try {
|
|
55
|
+
if (existsSync(CREDENTIALS_PATH)) {
|
|
56
|
+
unlinkSync(CREDENTIALS_PATH);
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
({
|
|
62
|
+
/** ~/.hubbits/cache/ */
|
|
63
|
+
cacheDir: join(HUBBITS_DIR, "cache"),
|
|
64
|
+
/** ~/.hubbits/progress/ */
|
|
65
|
+
progressDir: join(HUBBITS_DIR, "progress")
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// src/lib/auth.ts
|
|
69
|
+
var TOKEN_ENV_VAR = "HUBBITS_TOKEN";
|
|
70
|
+
function getToken() {
|
|
71
|
+
const envToken = process.env[TOKEN_ENV_VAR];
|
|
72
|
+
if (envToken) return envToken;
|
|
73
|
+
const creds = readCredentials();
|
|
74
|
+
if (!creds) return null;
|
|
75
|
+
return creds.token;
|
|
76
|
+
}
|
|
77
|
+
function saveToken(credentials) {
|
|
78
|
+
writeCredentials(credentials);
|
|
79
|
+
}
|
|
80
|
+
function clearToken() {
|
|
81
|
+
deleteCredentials();
|
|
82
|
+
}
|
|
83
|
+
function getAuthState() {
|
|
84
|
+
const envToken = process.env[TOKEN_ENV_VAR];
|
|
85
|
+
if (envToken) {
|
|
86
|
+
return { token: envToken };
|
|
87
|
+
}
|
|
88
|
+
const creds = readCredentials();
|
|
89
|
+
if (!creds) return null;
|
|
90
|
+
if (creds.expires_at) {
|
|
91
|
+
const expiresAt = new Date(creds.expires_at);
|
|
92
|
+
if (Date.now() > expiresAt.getTime() - 6e4) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return creds;
|
|
97
|
+
}
|
|
98
|
+
async function pollDeviceFlow(deviceCode, pollFn, options) {
|
|
99
|
+
const { interval, expiresIn, onTick } = options;
|
|
100
|
+
const startTime = Date.now();
|
|
101
|
+
const timeoutMs = expiresIn * 1e3;
|
|
102
|
+
const intervalMs = interval * 1e3;
|
|
103
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
104
|
+
await sleep(intervalMs);
|
|
105
|
+
onTick?.();
|
|
106
|
+
const result = await pollFn(deviceCode);
|
|
107
|
+
if (result.status === "success") {
|
|
108
|
+
saveToken({
|
|
109
|
+
token: result.token,
|
|
110
|
+
expires_at: result.expires_at,
|
|
111
|
+
username: result.username,
|
|
112
|
+
email: result.email
|
|
113
|
+
});
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
if (result.status === "expired" || result.status === "error") {
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { status: "expired" };
|
|
121
|
+
}
|
|
122
|
+
function sleep(ms) {
|
|
123
|
+
return new Promise((resolve7) => setTimeout(resolve7, ms));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// src/lib/api.ts
|
|
127
|
+
var ApiError = class extends Error {
|
|
128
|
+
statusCode;
|
|
129
|
+
code;
|
|
130
|
+
details;
|
|
131
|
+
constructor(statusCode, code, message, details) {
|
|
132
|
+
super(message);
|
|
133
|
+
this.name = "ApiError";
|
|
134
|
+
this.statusCode = statusCode;
|
|
135
|
+
this.code = code;
|
|
136
|
+
this.details = details;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
function createApiClient(options = {}) {
|
|
140
|
+
const getBaseURL = () => options.baseURL ?? readConfig().registry_url;
|
|
141
|
+
async function request(path, fetchOptions = {}) {
|
|
142
|
+
const baseURL = getBaseURL();
|
|
143
|
+
const token = options.token ?? getToken();
|
|
144
|
+
const incomingHeaders = fetchOptions.headers ?? {};
|
|
145
|
+
const headers = {
|
|
146
|
+
"User-Agent": "hubbits-cli/0.1.0",
|
|
147
|
+
...incomingHeaders
|
|
148
|
+
};
|
|
149
|
+
if (token) {
|
|
150
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
151
|
+
}
|
|
152
|
+
const retry = fetchOptions.retry ?? 2;
|
|
153
|
+
try {
|
|
154
|
+
const response = await ofetch(path, {
|
|
155
|
+
baseURL,
|
|
156
|
+
method: fetchOptions.method,
|
|
157
|
+
body: fetchOptions.body,
|
|
158
|
+
headers,
|
|
159
|
+
retry,
|
|
160
|
+
retryDelay: 1e3
|
|
161
|
+
});
|
|
162
|
+
return response;
|
|
163
|
+
} catch (error2) {
|
|
164
|
+
if (error2 instanceof FetchError) {
|
|
165
|
+
const status = error2.statusCode ?? 0;
|
|
166
|
+
const body = error2.data;
|
|
167
|
+
if (status === 401) {
|
|
168
|
+
clearToken();
|
|
169
|
+
throw new ApiError(
|
|
170
|
+
401,
|
|
171
|
+
body?.error?.code ?? "UNAUTHORIZED",
|
|
172
|
+
"Authentication expired. Please run `hubbits login` to re-authenticate.",
|
|
173
|
+
body?.error?.details
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (body?.error) {
|
|
177
|
+
throw new ApiError(
|
|
178
|
+
status,
|
|
179
|
+
body.error.code,
|
|
180
|
+
body.error.message,
|
|
181
|
+
body.error.details
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
throw new ApiError(
|
|
185
|
+
status,
|
|
186
|
+
"NETWORK_ERROR",
|
|
187
|
+
`Request failed: ${error2.message}`
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
throw error2;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
/** Raw request method */
|
|
195
|
+
request,
|
|
196
|
+
// -- Auth --
|
|
197
|
+
/** Start device flow (get device code + user code) */
|
|
198
|
+
async startDeviceFlow() {
|
|
199
|
+
return request("/api/v1/auth/device", {
|
|
200
|
+
method: "POST"
|
|
201
|
+
});
|
|
202
|
+
},
|
|
203
|
+
/** Poll device flow authorization */
|
|
204
|
+
async pollDeviceFlow(deviceCode) {
|
|
205
|
+
return request(`/api/v1/auth/device/poll?device_code=${encodeURIComponent(deviceCode)}`, {
|
|
206
|
+
retry: 0
|
|
207
|
+
// Don't retry polling requests
|
|
208
|
+
});
|
|
209
|
+
},
|
|
210
|
+
/** Validate a PAT token */
|
|
211
|
+
async validateToken(token) {
|
|
212
|
+
return request("/api/v1/users/me", {
|
|
213
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
214
|
+
});
|
|
215
|
+
},
|
|
216
|
+
/** Get current authenticated user info */
|
|
217
|
+
async getCurrentUser() {
|
|
218
|
+
return request("/api/v1/users/me");
|
|
219
|
+
},
|
|
220
|
+
// -- Search --
|
|
221
|
+
/** Search packages */
|
|
222
|
+
async searchPackages(query, params) {
|
|
223
|
+
const searchParams = new URLSearchParams({ q: query });
|
|
224
|
+
if (params?.category) searchParams.set("category", params.category);
|
|
225
|
+
if (params?.difficulty) searchParams.set("difficulty", params.difficulty);
|
|
226
|
+
if (params?.pricing) searchParams.set("pricing", params.pricing);
|
|
227
|
+
if (params?.sort) searchParams.set("sort", params.sort);
|
|
228
|
+
if (params?.page) searchParams.set("page", String(params.page));
|
|
229
|
+
if (params?.per_page) searchParams.set("per_page", String(params.per_page));
|
|
230
|
+
return request(`/api/v1/packages?${searchParams.toString()}`);
|
|
231
|
+
},
|
|
232
|
+
// -- Package --
|
|
233
|
+
/** Get package details */
|
|
234
|
+
async getPackage(name) {
|
|
235
|
+
return request(`/api/v1/packages/${encodeURIComponent(name)}`);
|
|
236
|
+
},
|
|
237
|
+
/** Get package details by scope/name */
|
|
238
|
+
async getPackageByScope(scope, name) {
|
|
239
|
+
return request(`/api/v1/packages/${encodeURIComponent(scope)}/${encodeURIComponent(name)}`);
|
|
240
|
+
},
|
|
241
|
+
/** Get specific version details */
|
|
242
|
+
async getPackageVersion(scope, name, version) {
|
|
243
|
+
return request(`/api/v1/packages/${encodeURIComponent(scope)}/${encodeURIComponent(name)}/${encodeURIComponent(version)}`);
|
|
244
|
+
},
|
|
245
|
+
// -- Download --
|
|
246
|
+
/** Get download URL for a package version */
|
|
247
|
+
async getDownloadUrl(scope, name, version) {
|
|
248
|
+
return request(`/api/v1/download/${encodeURIComponent(scope)}/${encodeURIComponent(name)}/${encodeURIComponent(version)}`);
|
|
249
|
+
},
|
|
250
|
+
/** Download a file from a URL (returns raw ArrayBuffer) */
|
|
251
|
+
async downloadFile(url) {
|
|
252
|
+
const response = await ofetch(url, {
|
|
253
|
+
responseType: "arrayBuffer",
|
|
254
|
+
retry: 2,
|
|
255
|
+
retryDelay: 1e3,
|
|
256
|
+
headers: {
|
|
257
|
+
"User-Agent": "hubbits-cli/0.1.0"
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
return response;
|
|
261
|
+
},
|
|
262
|
+
// -- Publish --
|
|
263
|
+
/** Request a presigned upload URL for publishing (step 1) */
|
|
264
|
+
async requestPublish(manifest, digest, fileSize) {
|
|
265
|
+
return request("/api/v1/packages/publish", {
|
|
266
|
+
method: "PUT",
|
|
267
|
+
body: { manifest, digest, file_size: fileSize }
|
|
268
|
+
});
|
|
269
|
+
},
|
|
270
|
+
/** Confirm a publish after uploading (step 2) */
|
|
271
|
+
async confirmPublish(scope, name, version, digest, manifest) {
|
|
272
|
+
return request("/api/v1/packages/publish/confirm", {
|
|
273
|
+
method: "POST",
|
|
274
|
+
body: { scope, name, version, digest, manifest }
|
|
275
|
+
});
|
|
276
|
+
},
|
|
277
|
+
/** Upload a file to a presigned URL */
|
|
278
|
+
async uploadFile(url, data) {
|
|
279
|
+
await ofetch(url, {
|
|
280
|
+
method: "PUT",
|
|
281
|
+
body: data,
|
|
282
|
+
headers: {
|
|
283
|
+
"Content-Type": "application/gzip",
|
|
284
|
+
"Content-Length": String(data.length)
|
|
285
|
+
},
|
|
286
|
+
retry: 2,
|
|
287
|
+
retryDelay: 1e3
|
|
288
|
+
});
|
|
289
|
+
},
|
|
290
|
+
// -- GitHub --
|
|
291
|
+
/** Fetch GitHub repo metadata for import pre-fill */
|
|
292
|
+
async getGithubRepoInfo(repoUrl) {
|
|
293
|
+
return request(`/api/v1/github/repo-info?url=${encodeURIComponent(repoUrl)}`);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
var _jsonMode = false;
|
|
298
|
+
var _quietMode = false;
|
|
299
|
+
var _verboseMode = false;
|
|
300
|
+
function setOutputMode(opts) {
|
|
301
|
+
_jsonMode = opts.json ?? false;
|
|
302
|
+
_quietMode = opts.quiet ?? false;
|
|
303
|
+
_verboseMode = opts.verbose ?? false;
|
|
304
|
+
}
|
|
305
|
+
function isJsonMode() {
|
|
306
|
+
return _jsonMode;
|
|
307
|
+
}
|
|
308
|
+
function outputJson(data) {
|
|
309
|
+
console.log(JSON.stringify(data, null, 2));
|
|
310
|
+
}
|
|
311
|
+
function success(message) {
|
|
312
|
+
if (_quietMode) return;
|
|
313
|
+
if (_jsonMode) return;
|
|
314
|
+
console.log(`${pc5.green("\u2713")} ${message}`);
|
|
315
|
+
}
|
|
316
|
+
function error(message) {
|
|
317
|
+
if (_quietMode) return;
|
|
318
|
+
if (_jsonMode) return;
|
|
319
|
+
console.error(`${pc5.red("\u2717")} ${message}`);
|
|
320
|
+
}
|
|
321
|
+
function warning(message) {
|
|
322
|
+
if (_quietMode) return;
|
|
323
|
+
if (_jsonMode) return;
|
|
324
|
+
console.warn(`${pc5.yellow("\u26A0")} ${message}`);
|
|
325
|
+
}
|
|
326
|
+
function info(message) {
|
|
327
|
+
if (_quietMode) return;
|
|
328
|
+
if (_jsonMode) return;
|
|
329
|
+
console.log(`${pc5.blue("\u2139")} ${message}`);
|
|
330
|
+
}
|
|
331
|
+
function hint(message) {
|
|
332
|
+
if (_quietMode) return;
|
|
333
|
+
if (_jsonMode) return;
|
|
334
|
+
console.log(`${pc5.dim("\u2192")} ${message}`);
|
|
335
|
+
}
|
|
336
|
+
function debug(message) {
|
|
337
|
+
if (!_verboseMode) return;
|
|
338
|
+
if (_quietMode) return;
|
|
339
|
+
if (_jsonMode) return;
|
|
340
|
+
console.log(`${pc5.dim("[debug]")} ${pc5.dim(message)}`);
|
|
341
|
+
}
|
|
342
|
+
function newline() {
|
|
343
|
+
if (_quietMode) return;
|
|
344
|
+
if (_jsonMode) return;
|
|
345
|
+
console.log();
|
|
346
|
+
}
|
|
347
|
+
function field(label, value) {
|
|
348
|
+
if (_quietMode) return;
|
|
349
|
+
if (_jsonMode) return;
|
|
350
|
+
console.log(` ${pc5.dim(label + ":")} ${value}`);
|
|
351
|
+
}
|
|
352
|
+
function table(columns, rows) {
|
|
353
|
+
if (_quietMode) return;
|
|
354
|
+
if (_jsonMode) {
|
|
355
|
+
outputJson(rows);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (rows.length === 0) {
|
|
359
|
+
info("No results found.");
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const widths = {};
|
|
363
|
+
for (const col of columns) {
|
|
364
|
+
widths[col.key] = col.width ?? col.label.length;
|
|
365
|
+
for (const row of rows) {
|
|
366
|
+
const val = col.format ? col.format(row[col.key]) : String(row[col.key] ?? "");
|
|
367
|
+
widths[col.key] = Math.max(widths[col.key], val.length);
|
|
368
|
+
}
|
|
369
|
+
widths[col.key] = Math.min(widths[col.key], 50);
|
|
370
|
+
}
|
|
371
|
+
const header = columns.map((col) => pc5.bold(pad(col.label, widths[col.key], col.align))).join(" ");
|
|
372
|
+
console.log(header);
|
|
373
|
+
console.log(pc5.dim(columns.map((col) => "\u2500".repeat(widths[col.key])).join(" ")));
|
|
374
|
+
for (const row of rows) {
|
|
375
|
+
const line = columns.map((col) => {
|
|
376
|
+
const val = col.format ? col.format(row[col.key]) : String(row[col.key] ?? "");
|
|
377
|
+
return pad(truncate(val, widths[col.key]), widths[col.key], col.align);
|
|
378
|
+
}).join(" ");
|
|
379
|
+
console.log(line);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
function spinner(text3) {
|
|
383
|
+
if (_quietMode || _jsonMode) {
|
|
384
|
+
return {
|
|
385
|
+
start: () => spinner(text3),
|
|
386
|
+
stop: () => spinner(text3),
|
|
387
|
+
succeed: () => spinner(text3),
|
|
388
|
+
fail: () => spinner(text3),
|
|
389
|
+
warn: () => spinner(text3),
|
|
390
|
+
info: () => spinner(text3),
|
|
391
|
+
text: "",
|
|
392
|
+
isSpinning: false
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
return ora({ text: text3, color: "cyan" }).start();
|
|
396
|
+
}
|
|
397
|
+
function errorWithFix(message, fix) {
|
|
398
|
+
error(message);
|
|
399
|
+
if (fix) {
|
|
400
|
+
hint(`Fix: ${fix}`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function pad(str, width, align = "left") {
|
|
404
|
+
if (str.length >= width) return str;
|
|
405
|
+
const padding = " ".repeat(width - str.length);
|
|
406
|
+
return align === "right" ? padding + str : str + padding;
|
|
407
|
+
}
|
|
408
|
+
function truncate(str, maxWidth) {
|
|
409
|
+
if (str.length <= maxWidth) return str;
|
|
410
|
+
return str.slice(0, maxWidth - 1) + "\u2026";
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// src/commands/login.ts
|
|
414
|
+
var login_default = defineCommand({
|
|
415
|
+
meta: {
|
|
416
|
+
name: "login",
|
|
417
|
+
description: "Authenticate with the Hubbits registry"
|
|
418
|
+
},
|
|
419
|
+
args: {
|
|
420
|
+
token: {
|
|
421
|
+
type: "string",
|
|
422
|
+
description: "Personal access token (for CI/CD)"
|
|
423
|
+
},
|
|
424
|
+
json: {
|
|
425
|
+
type: "boolean",
|
|
426
|
+
description: "Output as JSON",
|
|
427
|
+
default: false
|
|
428
|
+
},
|
|
429
|
+
quiet: {
|
|
430
|
+
type: "boolean",
|
|
431
|
+
description: "Suppress output",
|
|
432
|
+
default: false
|
|
433
|
+
},
|
|
434
|
+
verbose: {
|
|
435
|
+
type: "boolean",
|
|
436
|
+
description: "Show debug information",
|
|
437
|
+
default: false
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
async run({ args }) {
|
|
441
|
+
setOutputMode({ json: args.json, quiet: args.quiet, verbose: args.verbose });
|
|
442
|
+
const existing = getAuthState();
|
|
443
|
+
if (existing && !args.token) {
|
|
444
|
+
const displayName = existing.username ?? existing.email ?? "authenticated user";
|
|
445
|
+
if (isJsonMode()) {
|
|
446
|
+
outputJson({ status: "already_authenticated", username: existing.username, email: existing.email });
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
info(`Already logged in as ${pc5.bold(displayName)}.`);
|
|
450
|
+
hint("Run `hubbits logout` first to switch accounts.");
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const api2 = createApiClient();
|
|
454
|
+
if (args.token) {
|
|
455
|
+
debug(`Validating provided token...`);
|
|
456
|
+
const s = spinner("Validating token...");
|
|
457
|
+
try {
|
|
458
|
+
const result = await api2.validateToken(args.token);
|
|
459
|
+
s.succeed("Token validated.");
|
|
460
|
+
if (result.data) {
|
|
461
|
+
saveToken({
|
|
462
|
+
token: args.token,
|
|
463
|
+
username: result.data.username,
|
|
464
|
+
email: result.data.email
|
|
465
|
+
});
|
|
466
|
+
if (isJsonMode()) {
|
|
467
|
+
outputJson({ status: "ok", username: result.data.username, email: result.data.email });
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
success(`Logged in as ${pc5.bold(result.data.username)} (${result.data.email})`);
|
|
471
|
+
hint("Next: `hubbits search` to find packages or `hubbits create` to start building.");
|
|
472
|
+
}
|
|
473
|
+
} catch (err) {
|
|
474
|
+
s.fail("Token validation failed.");
|
|
475
|
+
if (err instanceof ApiError) {
|
|
476
|
+
errorWithFix(err.message, "Check that your token is correct and not expired.");
|
|
477
|
+
} else {
|
|
478
|
+
error("Failed to validate token.");
|
|
479
|
+
}
|
|
480
|
+
process.exit(1);
|
|
481
|
+
}
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
p5.intro(pc5.bold("Hubbits Login"));
|
|
485
|
+
await loginWithDeviceFlow(api2);
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
async function loginWithDeviceFlow(api2) {
|
|
489
|
+
const s = spinner("Starting device authorization...");
|
|
490
|
+
debug("POST /api/v1/auth/device");
|
|
491
|
+
try {
|
|
492
|
+
const result = await api2.startDeviceFlow();
|
|
493
|
+
if (!result.data) {
|
|
494
|
+
s.fail("Failed to start device flow.");
|
|
495
|
+
error("Server returned an unexpected response.");
|
|
496
|
+
process.exit(1);
|
|
497
|
+
}
|
|
498
|
+
const { device_code, user_code, verification_url, expires_in, interval } = result.data;
|
|
499
|
+
s.stop();
|
|
500
|
+
newline();
|
|
501
|
+
info(`Open the following URL in your browser:`);
|
|
502
|
+
console.log(` ${pc5.bold(pc5.underline(verification_url))}`);
|
|
503
|
+
newline();
|
|
504
|
+
info(`Enter the code: ${pc5.bold(pc5.cyan(user_code))}`);
|
|
505
|
+
newline();
|
|
506
|
+
try {
|
|
507
|
+
await open(verification_url);
|
|
508
|
+
info("Browser opened automatically.");
|
|
509
|
+
} catch {
|
|
510
|
+
}
|
|
511
|
+
const pollSpinner = spinner("Waiting for authorization...");
|
|
512
|
+
const pollFn = async (deviceCode) => {
|
|
513
|
+
try {
|
|
514
|
+
const tokenResult = await api2.pollDeviceFlow(deviceCode);
|
|
515
|
+
if (tokenResult.data?.access_token) {
|
|
516
|
+
return {
|
|
517
|
+
status: "success",
|
|
518
|
+
token: tokenResult.data.access_token
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
return { status: "pending" };
|
|
522
|
+
} catch (err) {
|
|
523
|
+
if (err instanceof ApiError) {
|
|
524
|
+
if (err.code === "AUTHORIZATION_PENDING") return { status: "pending" };
|
|
525
|
+
if (err.code === "SLOW_DOWN") return { status: "pending" };
|
|
526
|
+
if (err.code === "EXPIRED" || err.code === "EXPIRED_TOKEN") return { status: "expired" };
|
|
527
|
+
return { status: "error", message: err.message };
|
|
528
|
+
}
|
|
529
|
+
return { status: "pending" };
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
const pollResult = await pollDeviceFlow(device_code, pollFn, {
|
|
533
|
+
interval,
|
|
534
|
+
expiresIn: expires_in
|
|
535
|
+
});
|
|
536
|
+
if (pollResult.status === "success") {
|
|
537
|
+
let username;
|
|
538
|
+
let email;
|
|
539
|
+
try {
|
|
540
|
+
const userResult = await api2.validateToken(pollResult.token);
|
|
541
|
+
if (userResult.data) {
|
|
542
|
+
username = userResult.data.username;
|
|
543
|
+
email = userResult.data.email;
|
|
544
|
+
}
|
|
545
|
+
} catch {
|
|
546
|
+
}
|
|
547
|
+
saveToken({
|
|
548
|
+
token: pollResult.token,
|
|
549
|
+
username,
|
|
550
|
+
email
|
|
551
|
+
});
|
|
552
|
+
pollSpinner.succeed("Authorized!");
|
|
553
|
+
if (isJsonMode()) {
|
|
554
|
+
outputJson({ status: "ok", username, email });
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const displayName = username ?? email ?? "user";
|
|
558
|
+
success(`Logged in as ${pc5.bold(displayName)}`);
|
|
559
|
+
hint("Next: `hubbits search` to find packages or `hubbits create` to start building.");
|
|
560
|
+
} else if (pollResult.status === "expired") {
|
|
561
|
+
pollSpinner.fail("Authorization expired.");
|
|
562
|
+
errorWithFix("The verification code has expired.", "Run `hubbits login` to try again.");
|
|
563
|
+
process.exit(1);
|
|
564
|
+
} else if (pollResult.status === "error") {
|
|
565
|
+
pollSpinner.fail("Authorization failed.");
|
|
566
|
+
errorWithFix(pollResult.message, "Run `hubbits login` to try again.");
|
|
567
|
+
process.exit(1);
|
|
568
|
+
}
|
|
569
|
+
} catch (err) {
|
|
570
|
+
s.fail("Failed to start device flow.");
|
|
571
|
+
if (err instanceof ApiError) {
|
|
572
|
+
errorWithFix(err.message, "Check your network connection and try again.");
|
|
573
|
+
} else {
|
|
574
|
+
errorWithFix("An unexpected error occurred.", "Check your network connection and try again.");
|
|
575
|
+
}
|
|
576
|
+
process.exit(1);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
var logout_default = defineCommand({
|
|
580
|
+
meta: {
|
|
581
|
+
name: "logout",
|
|
582
|
+
description: "Log out from the Hubbits registry"
|
|
583
|
+
},
|
|
584
|
+
args: {
|
|
585
|
+
force: {
|
|
586
|
+
type: "boolean",
|
|
587
|
+
alias: "f",
|
|
588
|
+
description: "Skip confirmation prompt",
|
|
589
|
+
default: false
|
|
590
|
+
},
|
|
591
|
+
json: {
|
|
592
|
+
type: "boolean",
|
|
593
|
+
description: "Output as JSON",
|
|
594
|
+
default: false
|
|
595
|
+
},
|
|
596
|
+
quiet: {
|
|
597
|
+
type: "boolean",
|
|
598
|
+
description: "Suppress output",
|
|
599
|
+
default: false
|
|
600
|
+
},
|
|
601
|
+
verbose: {
|
|
602
|
+
type: "boolean",
|
|
603
|
+
description: "Show debug information",
|
|
604
|
+
default: false
|
|
605
|
+
}
|
|
606
|
+
},
|
|
607
|
+
async run({ args }) {
|
|
608
|
+
setOutputMode({ json: args.json, quiet: args.quiet, verbose: args.verbose });
|
|
609
|
+
const auth = getAuthState();
|
|
610
|
+
if (!auth) {
|
|
611
|
+
if (isJsonMode()) {
|
|
612
|
+
outputJson({ status: "not_authenticated" });
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
info("You are not currently logged in.");
|
|
616
|
+
hint("Run `hubbits login` to authenticate.");
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const displayName = auth.username ?? auth.email ?? "current session";
|
|
620
|
+
if (!args.force && !args.json && !args.quiet) {
|
|
621
|
+
const confirm4 = await p5.confirm({
|
|
622
|
+
message: `Log out from ${pc5.bold(displayName)}?`
|
|
623
|
+
});
|
|
624
|
+
if (p5.isCancel(confirm4) || !confirm4) {
|
|
625
|
+
p5.cancel("Logout cancelled.");
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
clearToken();
|
|
630
|
+
if (isJsonMode()) {
|
|
631
|
+
outputJson({ status: "ok", message: "Logged out successfully." });
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
success(`Logged out from ${pc5.bold(displayName)}.`);
|
|
635
|
+
hint("Run `hubbits login` to log in again.");
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
var search_default = defineCommand({
|
|
639
|
+
meta: {
|
|
640
|
+
name: "search",
|
|
641
|
+
description: "Search for hubbits on the registry"
|
|
642
|
+
},
|
|
643
|
+
args: {
|
|
644
|
+
query: {
|
|
645
|
+
type: "positional",
|
|
646
|
+
description: "Search query",
|
|
647
|
+
required: true
|
|
648
|
+
},
|
|
649
|
+
category: {
|
|
650
|
+
type: "string",
|
|
651
|
+
alias: "c",
|
|
652
|
+
description: "Filter by category (learning, entertainment, productivity, wellness)"
|
|
653
|
+
},
|
|
654
|
+
difficulty: {
|
|
655
|
+
type: "string",
|
|
656
|
+
alias: "d",
|
|
657
|
+
description: "Filter by difficulty (beginner, intermediate, advanced)"
|
|
658
|
+
},
|
|
659
|
+
pricing: {
|
|
660
|
+
type: "string",
|
|
661
|
+
description: "Filter by pricing (free, paid, freemium)"
|
|
662
|
+
},
|
|
663
|
+
sort: {
|
|
664
|
+
type: "string",
|
|
665
|
+
alias: "s",
|
|
666
|
+
description: "Sort by (relevance, downloads, stars, updated, created)",
|
|
667
|
+
default: "relevance"
|
|
668
|
+
},
|
|
669
|
+
page: {
|
|
670
|
+
type: "string",
|
|
671
|
+
description: "Page number",
|
|
672
|
+
default: "1"
|
|
673
|
+
},
|
|
674
|
+
limit: {
|
|
675
|
+
type: "string",
|
|
676
|
+
description: "Results per page",
|
|
677
|
+
default: "20"
|
|
678
|
+
},
|
|
679
|
+
json: {
|
|
680
|
+
type: "boolean",
|
|
681
|
+
description: "Output as JSON",
|
|
682
|
+
default: false
|
|
683
|
+
},
|
|
684
|
+
quiet: {
|
|
685
|
+
type: "boolean",
|
|
686
|
+
description: "Suppress output",
|
|
687
|
+
default: false
|
|
688
|
+
},
|
|
689
|
+
verbose: {
|
|
690
|
+
type: "boolean",
|
|
691
|
+
description: "Show debug information",
|
|
692
|
+
default: false
|
|
693
|
+
}
|
|
694
|
+
},
|
|
695
|
+
async run({ args }) {
|
|
696
|
+
setOutputMode({ json: args.json, quiet: args.quiet, verbose: args.verbose });
|
|
697
|
+
const query = args.query;
|
|
698
|
+
const page = parseInt(args.page, 10) || 1;
|
|
699
|
+
const perPage = parseInt(args.limit, 10) || 20;
|
|
700
|
+
const api2 = createApiClient();
|
|
701
|
+
const s = spinner(`Searching for "${query}"...`);
|
|
702
|
+
debug(`GET /api/v1/packages?q=${query}&page=${page}&per_page=${perPage}`);
|
|
703
|
+
try {
|
|
704
|
+
const result = await api2.searchPackages(query, {
|
|
705
|
+
category: args.category,
|
|
706
|
+
difficulty: args.difficulty,
|
|
707
|
+
pricing: args.pricing,
|
|
708
|
+
sort: args.sort,
|
|
709
|
+
page,
|
|
710
|
+
per_page: perPage
|
|
711
|
+
});
|
|
712
|
+
s.stop();
|
|
713
|
+
if (isJsonMode()) {
|
|
714
|
+
outputJson(result);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
const items = result.data?.items ?? [];
|
|
718
|
+
const total = result.data?.pagination.total ?? 0;
|
|
719
|
+
if (items.length === 0) {
|
|
720
|
+
info(`No packages found for "${pc5.bold(query)}".`);
|
|
721
|
+
hint("Try a different search term or check `hubbits search --help` for filters.");
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
success(`Found ${total} package${total === 1 ? "" : "s"} for "${pc5.bold(query)}"`);
|
|
725
|
+
newline();
|
|
726
|
+
const columns = [
|
|
727
|
+
{
|
|
728
|
+
key: "name",
|
|
729
|
+
label: "Name",
|
|
730
|
+
width: 30,
|
|
731
|
+
format: (v) => pc5.cyan(String(v))
|
|
732
|
+
},
|
|
733
|
+
{
|
|
734
|
+
key: "description",
|
|
735
|
+
label: "Description",
|
|
736
|
+
width: 40
|
|
737
|
+
},
|
|
738
|
+
{
|
|
739
|
+
key: "category",
|
|
740
|
+
label: "Category",
|
|
741
|
+
width: 14,
|
|
742
|
+
format: (v) => formatCategory(String(v ?? ""))
|
|
743
|
+
},
|
|
744
|
+
{
|
|
745
|
+
key: "downloads",
|
|
746
|
+
label: "Downloads",
|
|
747
|
+
width: 10,
|
|
748
|
+
align: "right",
|
|
749
|
+
format: (v) => formatNumber(Number(v))
|
|
750
|
+
},
|
|
751
|
+
{
|
|
752
|
+
key: "stars",
|
|
753
|
+
label: "Stars",
|
|
754
|
+
width: 6,
|
|
755
|
+
align: "right",
|
|
756
|
+
format: (v) => formatNumber(Number(v))
|
|
757
|
+
}
|
|
758
|
+
];
|
|
759
|
+
table(columns, items.map((item) => ({
|
|
760
|
+
name: item.name,
|
|
761
|
+
description: item.description,
|
|
762
|
+
category: item.category,
|
|
763
|
+
downloads: item.downloads,
|
|
764
|
+
stars: item.stars
|
|
765
|
+
})));
|
|
766
|
+
if ((result.data?.pagination.total_pages ?? 0) > 1) {
|
|
767
|
+
newline();
|
|
768
|
+
info(
|
|
769
|
+
`Page ${result.data?.pagination.page}/${result.data?.pagination.total_pages} (${total} total results)`
|
|
770
|
+
);
|
|
771
|
+
if ((result.data?.pagination.page ?? 0) < (result.data?.pagination.total_pages ?? 0)) {
|
|
772
|
+
hint(`Next page: \`hubbits search "${query}" --page ${(result.data?.pagination.page ?? 0) + 1}\``);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
newline();
|
|
776
|
+
hint(`Download a package: \`hubbits pull <package-name>\``);
|
|
777
|
+
} catch (err) {
|
|
778
|
+
s.fail("Search failed.");
|
|
779
|
+
if (err instanceof ApiError) {
|
|
780
|
+
errorWithFix(err.message, "Check your network connection and try again.");
|
|
781
|
+
} else {
|
|
782
|
+
errorWithFix("An unexpected error occurred.", "Check your network connection and try again.");
|
|
783
|
+
}
|
|
784
|
+
process.exit(1);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
function formatNumber(n) {
|
|
789
|
+
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
|
|
790
|
+
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
|
|
791
|
+
return String(n);
|
|
792
|
+
}
|
|
793
|
+
function formatCategory(cat) {
|
|
794
|
+
const colors = {
|
|
795
|
+
learning: pc5.blue,
|
|
796
|
+
entertainment: pc5.magenta,
|
|
797
|
+
productivity: pc5.green,
|
|
798
|
+
wellness: pc5.yellow
|
|
799
|
+
};
|
|
800
|
+
const colorFn = colors[cat] ?? pc5.dim;
|
|
801
|
+
return colorFn(cat || "-");
|
|
802
|
+
}
|
|
803
|
+
var template_default = defineCommand({
|
|
804
|
+
meta: {
|
|
805
|
+
name: "template",
|
|
806
|
+
description: "Scaffold a new hubbit with cross-platform AI editor support"
|
|
807
|
+
},
|
|
808
|
+
args: {
|
|
809
|
+
name: {
|
|
810
|
+
type: "positional",
|
|
811
|
+
description: "Package name (lowercase, hyphens allowed)",
|
|
812
|
+
required: false
|
|
813
|
+
},
|
|
814
|
+
category: {
|
|
815
|
+
type: "string",
|
|
816
|
+
alias: "c",
|
|
817
|
+
description: "Package category (learning, entertainment, productivity, wellness)"
|
|
818
|
+
},
|
|
819
|
+
difficulty: {
|
|
820
|
+
type: "string",
|
|
821
|
+
alias: "d",
|
|
822
|
+
description: "Difficulty level (beginner, intermediate, advanced)"
|
|
823
|
+
},
|
|
824
|
+
description: {
|
|
825
|
+
type: "string",
|
|
826
|
+
description: "Package description"
|
|
827
|
+
},
|
|
828
|
+
author: {
|
|
829
|
+
type: "string",
|
|
830
|
+
alias: "a",
|
|
831
|
+
description: "Author name"
|
|
832
|
+
},
|
|
833
|
+
json: {
|
|
834
|
+
type: "boolean",
|
|
835
|
+
description: "Output as JSON",
|
|
836
|
+
default: false
|
|
837
|
+
},
|
|
838
|
+
quiet: {
|
|
839
|
+
type: "boolean",
|
|
840
|
+
description: "Suppress output",
|
|
841
|
+
default: false
|
|
842
|
+
},
|
|
843
|
+
verbose: {
|
|
844
|
+
type: "boolean",
|
|
845
|
+
description: "Show debug information",
|
|
846
|
+
default: false
|
|
847
|
+
}
|
|
848
|
+
},
|
|
849
|
+
async run({ args }) {
|
|
850
|
+
setOutputMode({ json: args.json, quiet: args.quiet, verbose: args.verbose });
|
|
851
|
+
let options;
|
|
852
|
+
if (args.name !== void 0 && !isValidName(args.name)) {
|
|
853
|
+
errorWithFix(
|
|
854
|
+
`Invalid package name: "${args.name}"`,
|
|
855
|
+
"Use lowercase letters, numbers, and hyphens. Must start with a letter."
|
|
856
|
+
);
|
|
857
|
+
process.exit(1);
|
|
858
|
+
}
|
|
859
|
+
if (args.name && args.category && args.difficulty && args.description) {
|
|
860
|
+
options = {
|
|
861
|
+
name: args.name,
|
|
862
|
+
category: args.category,
|
|
863
|
+
difficulty: args.difficulty,
|
|
864
|
+
description: args.description,
|
|
865
|
+
author: args.author
|
|
866
|
+
};
|
|
867
|
+
} else {
|
|
868
|
+
options = await interactiveCreate(args.name);
|
|
869
|
+
}
|
|
870
|
+
const targetDir = resolve(process.cwd(), options.name);
|
|
871
|
+
if (existsSync(targetDir)) {
|
|
872
|
+
const entries = readdirSync(targetDir);
|
|
873
|
+
if (entries.length > 0) {
|
|
874
|
+
errorWithFix(
|
|
875
|
+
`Directory "${options.name}" already exists and is not empty.`,
|
|
876
|
+
"Choose a different name, or remove the existing directory first."
|
|
877
|
+
);
|
|
878
|
+
process.exit(1);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
const files = scaffoldTemplate(targetDir, options);
|
|
882
|
+
if (isJsonMode()) {
|
|
883
|
+
outputJson({ status: "ok", name: options.name, path: targetDir, files });
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
newline();
|
|
887
|
+
success(`Created ${pc5.bold(options.name)}/`);
|
|
888
|
+
for (const file of files) {
|
|
889
|
+
console.log(` ${pc5.dim("\u251C\u2500\u2500")} ${file}`);
|
|
890
|
+
}
|
|
891
|
+
newline();
|
|
892
|
+
printSetupGuide(options.name);
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
async function interactiveCreate(initialName) {
|
|
896
|
+
p5.intro(pc5.bold("Create a new Hubbit"));
|
|
897
|
+
const name = initialName ?? await (async () => {
|
|
898
|
+
const val = await p5.text({
|
|
899
|
+
message: "Package name:",
|
|
900
|
+
placeholder: "my-awesome-hubbit",
|
|
901
|
+
validate(value) {
|
|
902
|
+
if (!value) return "Package name is required.";
|
|
903
|
+
if (!isValidName(value)) {
|
|
904
|
+
return "Use lowercase letters, numbers, and hyphens. Must start with a letter and not end with a hyphen.";
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
if (p5.isCancel(val)) {
|
|
909
|
+
p5.cancel("Cancelled.");
|
|
910
|
+
process.exit(0);
|
|
911
|
+
}
|
|
912
|
+
return val;
|
|
913
|
+
})();
|
|
914
|
+
const category = await p5.select({
|
|
915
|
+
message: "Category:",
|
|
916
|
+
options: [
|
|
917
|
+
{ value: "learning", label: "Learning", hint: "Educational courses and tutorials" },
|
|
918
|
+
{ value: "entertainment", label: "Entertainment", hint: "Games, stories, and fun" },
|
|
919
|
+
{ value: "productivity", label: "Productivity", hint: "Tools and workflows" },
|
|
920
|
+
{ value: "wellness", label: "Wellness", hint: "Health and wellbeing" }
|
|
921
|
+
]
|
|
922
|
+
});
|
|
923
|
+
if (p5.isCancel(category)) {
|
|
924
|
+
p5.cancel("Cancelled.");
|
|
925
|
+
process.exit(0);
|
|
926
|
+
}
|
|
927
|
+
const difficulty = await p5.select({
|
|
928
|
+
message: "Difficulty:",
|
|
929
|
+
options: [
|
|
930
|
+
{ value: "beginner", label: "Beginner" },
|
|
931
|
+
{ value: "intermediate", label: "Intermediate" },
|
|
932
|
+
{ value: "advanced", label: "Advanced" },
|
|
933
|
+
{ value: "beginner-to-intermediate", label: "Beginner to Intermediate" },
|
|
934
|
+
{ value: "intermediate-to-advanced", label: "Intermediate to Advanced" }
|
|
935
|
+
]
|
|
936
|
+
});
|
|
937
|
+
if (p5.isCancel(difficulty)) {
|
|
938
|
+
p5.cancel("Cancelled.");
|
|
939
|
+
process.exit(0);
|
|
940
|
+
}
|
|
941
|
+
const description = await p5.text({
|
|
942
|
+
message: "Description:",
|
|
943
|
+
placeholder: "A brief description of your Hubbit",
|
|
944
|
+
validate(value) {
|
|
945
|
+
if (!value) return "Description is required.";
|
|
946
|
+
if (value.length > 500) return "Description must be 500 characters or fewer.";
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
if (p5.isCancel(description)) {
|
|
950
|
+
p5.cancel("Cancelled.");
|
|
951
|
+
process.exit(0);
|
|
952
|
+
}
|
|
953
|
+
const author = await p5.text({
|
|
954
|
+
message: "Author (optional):",
|
|
955
|
+
placeholder: "Your Name"
|
|
956
|
+
});
|
|
957
|
+
if (p5.isCancel(author)) {
|
|
958
|
+
p5.cancel("Cancelled.");
|
|
959
|
+
process.exit(0);
|
|
960
|
+
}
|
|
961
|
+
p5.outro(pc5.dim("Scaffolding..."));
|
|
962
|
+
return {
|
|
963
|
+
name,
|
|
964
|
+
category,
|
|
965
|
+
difficulty,
|
|
966
|
+
description,
|
|
967
|
+
author: author || void 0
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
function scaffoldTemplate(targetDir, options) {
|
|
971
|
+
const files = [];
|
|
972
|
+
const dirs = [
|
|
973
|
+
"",
|
|
974
|
+
".claude",
|
|
975
|
+
".claude/skills",
|
|
976
|
+
".claude/skills/play",
|
|
977
|
+
".claude/skills/hint",
|
|
978
|
+
".claude/skills/next",
|
|
979
|
+
".claude/skills/progress",
|
|
980
|
+
".claude/skills/verify",
|
|
981
|
+
".github",
|
|
982
|
+
"engine",
|
|
983
|
+
"content",
|
|
984
|
+
`content/ch01-getting-started`,
|
|
985
|
+
`content/ch01-getting-started/challenges`,
|
|
986
|
+
".player"
|
|
987
|
+
];
|
|
988
|
+
for (const dir of dirs) {
|
|
989
|
+
mkdirSync(join(targetDir, dir), { recursive: true });
|
|
990
|
+
}
|
|
991
|
+
function write(relPath, content, mode) {
|
|
992
|
+
writeFileSync(join(targetDir, relPath), content, void 0);
|
|
993
|
+
files.push(relPath);
|
|
994
|
+
}
|
|
995
|
+
write("AGENTS.md", generateAgents(options));
|
|
996
|
+
write("CLAUDE.md", generateClaudeMd(options));
|
|
997
|
+
write("GEMINI.md", generateThinWrapper(options));
|
|
998
|
+
write(".cursorrules", generateThinWrapper(options));
|
|
999
|
+
write(".clinerules", generateThinWrapper(options));
|
|
1000
|
+
write(".windsurfrules", generateThinWrapper(options));
|
|
1001
|
+
write(".github/copilot-instructions.md", generateThinWrapper(options));
|
|
1002
|
+
write(".claude/skills/play/SKILL.md", generatePlaySkill(options));
|
|
1003
|
+
write(".claude/skills/hint/SKILL.md", generateHintSkill(options));
|
|
1004
|
+
write(".claude/skills/next/SKILL.md", generateNextSkill(options));
|
|
1005
|
+
write(".claude/skills/progress/SKILL.md", generateProgressSkill(options));
|
|
1006
|
+
write(".claude/skills/verify/SKILL.md", generateVerifySkill(options));
|
|
1007
|
+
write("engine/rules.md", generateRules(options));
|
|
1008
|
+
write("engine/narrator.md", generateNarrator(options));
|
|
1009
|
+
write("engine/validation.md", generateValidation(options));
|
|
1010
|
+
write("content/ch01-getting-started/README.md", generateChapterReadme(options));
|
|
1011
|
+
write("content/ch01-getting-started/challenges/.gitkeep", "");
|
|
1012
|
+
write(".player/progress.yaml.template", generateProgressTemplate(options));
|
|
1013
|
+
write(".gitignore", generateGitignore());
|
|
1014
|
+
write("hubbit.yaml", generateManifest(options));
|
|
1015
|
+
write("README.md", generateReadme(options));
|
|
1016
|
+
return files;
|
|
1017
|
+
}
|
|
1018
|
+
function printSetupGuide(name) {
|
|
1019
|
+
const divider = pc5.dim("\u2500".repeat(56));
|
|
1020
|
+
console.log(pc5.bold(" Next: publish your hubbit on GitHub"));
|
|
1021
|
+
newline();
|
|
1022
|
+
console.log(divider);
|
|
1023
|
+
newline();
|
|
1024
|
+
console.log(` ${pc5.cyan(pc5.bold("Step 1"))} Initialize git`);
|
|
1025
|
+
newline();
|
|
1026
|
+
console.log(` Run in your terminal:`);
|
|
1027
|
+
console.log(` ${pc5.yellow(`cd ${name} && git init && git add . && git commit -m "feat: init"`)}`);
|
|
1028
|
+
newline();
|
|
1029
|
+
console.log(` Or paste this into your AI editor:`);
|
|
1030
|
+
newline();
|
|
1031
|
+
console.log(pc5.dim(" \u250C" + "\u2500".repeat(54) + "\u2510"));
|
|
1032
|
+
console.log(pc5.dim(" \u2502") + ` Initialize git for my new project in ./${name}. ` + pc5.dim("\u2502"));
|
|
1033
|
+
console.log(pc5.dim(" \u2502") + ` Run: git init && git add . && ` + pc5.dim("\u2502"));
|
|
1034
|
+
console.log(pc5.dim(" \u2502") + ` git commit -m "feat: init" ` + pc5.dim("\u2502"));
|
|
1035
|
+
console.log(pc5.dim(" \u2514" + "\u2500".repeat(54) + "\u2518"));
|
|
1036
|
+
newline();
|
|
1037
|
+
console.log(divider);
|
|
1038
|
+
newline();
|
|
1039
|
+
console.log(` ${pc5.cyan(pc5.bold("Step 2"))} Create a GitHub repo and push`);
|
|
1040
|
+
newline();
|
|
1041
|
+
console.log(` Or paste this into your AI editor:`);
|
|
1042
|
+
newline();
|
|
1043
|
+
const truncatedName = name.length > 40 ? name.slice(0, 37) + "..." : name;
|
|
1044
|
+
console.log(pc5.dim(" \u250C" + "\u2500".repeat(54) + "\u2510"));
|
|
1045
|
+
console.log(pc5.dim(" \u2502") + ` Create a public GitHub repo named: ` + pc5.dim("\u2502"));
|
|
1046
|
+
console.log(pc5.dim(" \u2502") + ` "${truncatedName}"` + " ".repeat(Math.max(0, 53 - truncatedName.length - 2)) + pc5.dim("\u2502"));
|
|
1047
|
+
console.log(pc5.dim(" \u2502") + ` Push my local commits to it. Use the gh CLI or ` + pc5.dim("\u2502"));
|
|
1048
|
+
console.log(pc5.dim(" \u2502") + ` guide me through the GitHub web UI. ` + pc5.dim("\u2502"));
|
|
1049
|
+
console.log(pc5.dim(" \u2514" + "\u2500".repeat(54) + "\u2518"));
|
|
1050
|
+
newline();
|
|
1051
|
+
console.log(divider);
|
|
1052
|
+
newline();
|
|
1053
|
+
console.log(` ${pc5.cyan(pc5.bold("Step 3"))} Register on Hubbits`);
|
|
1054
|
+
newline();
|
|
1055
|
+
console.log(` Once your repo is live, run:`);
|
|
1056
|
+
console.log(` ${pc5.yellow(`hubbits create import https://github.com/<you>/${name}`)}`);
|
|
1057
|
+
newline();
|
|
1058
|
+
console.log(divider);
|
|
1059
|
+
newline();
|
|
1060
|
+
hint("Run `hubbits validate` at any time to check your hubbit.yaml");
|
|
1061
|
+
newline();
|
|
1062
|
+
}
|
|
1063
|
+
function generateAgents(options) {
|
|
1064
|
+
return `# ${options.name} \u2014 Interactive AI Experience
|
|
1065
|
+
|
|
1066
|
+
You are the guide for **${options.name}**. ${options.description}
|
|
1067
|
+
|
|
1068
|
+
## Quick Start
|
|
1069
|
+
|
|
1070
|
+
When the user opens this project and says anything like "let's play", "start", or "begin":
|
|
1071
|
+
|
|
1072
|
+
1. Read \`engine/rules.md\` \u2014 your core behavior rules
|
|
1073
|
+
2. Read \`engine/narrator.md\` \u2014 tone, persona, story context
|
|
1074
|
+
3. Read \`.player/progress.yaml\` \u2014 check their current state
|
|
1075
|
+
4. Welcome them and guide them to where they left off
|
|
1076
|
+
|
|
1077
|
+
## Critical Rules
|
|
1078
|
+
|
|
1079
|
+
- ALWAYS read \`engine/rules.md\` before interacting
|
|
1080
|
+
- NEVER solve challenges for the user \u2014 only give hints
|
|
1081
|
+
- Track progress by updating \`.player/progress.yaml\` after each milestone
|
|
1082
|
+
- Respond in the user's language (auto-detect from their first message)
|
|
1083
|
+
|
|
1084
|
+
## Commands
|
|
1085
|
+
|
|
1086
|
+
These work as natural conversation prompts:
|
|
1087
|
+
|
|
1088
|
+
- "let's play" / "start" \u2014 Start or resume
|
|
1089
|
+
- "hint" / "I'm stuck" \u2014 Get a progressive hint
|
|
1090
|
+
- "verify" / "check my work" \u2014 Verify challenge solution
|
|
1091
|
+
- "progress" \u2014 See how far they've come
|
|
1092
|
+
- "next" \u2014 Move to the next lesson or challenge
|
|
1093
|
+
|
|
1094
|
+
## File Structure
|
|
1095
|
+
|
|
1096
|
+
\`\`\`
|
|
1097
|
+
engine/ \u2014 Rules, narrator persona, validation logic
|
|
1098
|
+
content/ \u2014 Chapters with lessons and challenges
|
|
1099
|
+
.player/ \u2014 Player state (do not commit progress.yaml)
|
|
1100
|
+
\`\`\`
|
|
1101
|
+
`;
|
|
1102
|
+
}
|
|
1103
|
+
function generateClaudeMd(options) {
|
|
1104
|
+
return `# ${options.name}
|
|
1105
|
+
|
|
1106
|
+
You are the guide for **${options.name}**. Read and follow all instructions in \`AGENTS.md\`.
|
|
1107
|
+
|
|
1108
|
+
**Critical:** NEVER solve challenges for the user \u2014 only give hints.
|
|
1109
|
+
|
|
1110
|
+
## Claude Code Skills
|
|
1111
|
+
|
|
1112
|
+
This project includes slash commands in \`.claude/skills/\`:
|
|
1113
|
+
|
|
1114
|
+
- \`/play\` \u2014 Start or resume the experience
|
|
1115
|
+
- \`/hint\` \u2014 Get a progressive hint for the current challenge
|
|
1116
|
+
- \`/next\` \u2014 Move to the next lesson or challenge
|
|
1117
|
+
- \`/progress\` \u2014 Show current progress
|
|
1118
|
+
- \`/verify\` \u2014 Verify challenge solution
|
|
1119
|
+
`;
|
|
1120
|
+
}
|
|
1121
|
+
function generateThinWrapper(options) {
|
|
1122
|
+
return `# ${options.name}
|
|
1123
|
+
|
|
1124
|
+
You are the guide for **${options.name}**. Read and follow all instructions in \`AGENTS.md\`.
|
|
1125
|
+
|
|
1126
|
+
**Critical:** NEVER solve challenges for the user \u2014 only give hints.
|
|
1127
|
+
`;
|
|
1128
|
+
}
|
|
1129
|
+
function generatePlaySkill(options) {
|
|
1130
|
+
return `---
|
|
1131
|
+
name: play
|
|
1132
|
+
description: Start or resume the ${options.name} experience. Use when the user says "let's play", "start", "begin", or opens the project for the first time.
|
|
1133
|
+
---
|
|
1134
|
+
|
|
1135
|
+
# Start / Resume
|
|
1136
|
+
|
|
1137
|
+
You are the guide for ${options.name}. Follow these steps:
|
|
1138
|
+
|
|
1139
|
+
## Step 1: Load Engine
|
|
1140
|
+
|
|
1141
|
+
Read in order:
|
|
1142
|
+
- \`engine/rules.md\` \u2014 your behavior rules (teaching mode vs challenge mode)
|
|
1143
|
+
- \`engine/narrator.md\` \u2014 your persona, tone, and story context
|
|
1144
|
+
|
|
1145
|
+
## Step 2: Check Player State
|
|
1146
|
+
|
|
1147
|
+
Read \`.player/progress.yaml\`.
|
|
1148
|
+
|
|
1149
|
+
### New Player (started_at is empty)
|
|
1150
|
+
|
|
1151
|
+
1. Detect the user's language from their first message (default: en)
|
|
1152
|
+
2. Update \`.player/progress.yaml\`: set \`started_at\`, \`language\`
|
|
1153
|
+
3. Welcome them warmly
|
|
1154
|
+
4. Introduce Chapter 1 (read \`content/ch01-getting-started/README.md\`)
|
|
1155
|
+
5. Ask: "Ready to start the first lesson, or jump straight to the challenge?"
|
|
1156
|
+
|
|
1157
|
+
### Returning Player
|
|
1158
|
+
|
|
1159
|
+
1. Welcome them back
|
|
1160
|
+
2. Summarize where they left off
|
|
1161
|
+
3. Offer: continue, replay chapter, or jump ahead
|
|
1162
|
+
|
|
1163
|
+
## Step 3: Begin
|
|
1164
|
+
|
|
1165
|
+
Navigate to the correct lesson or challenge based on progress.
|
|
1166
|
+
|
|
1167
|
+
$ARGUMENTS
|
|
1168
|
+
`;
|
|
1169
|
+
}
|
|
1170
|
+
function generateHintSkill(options) {
|
|
1171
|
+
return `---
|
|
1172
|
+
name: hint
|
|
1173
|
+
description: Give a progressive hint for the current challenge in ${options.name}. Use when the user says "hint", "I'm stuck", "help", or "I don't know what to do".
|
|
1174
|
+
---
|
|
1175
|
+
|
|
1176
|
+
# Give a Hint
|
|
1177
|
+
|
|
1178
|
+
Read \`engine/rules.md\` for hint level behavior.
|
|
1179
|
+
|
|
1180
|
+
## Steps
|
|
1181
|
+
|
|
1182
|
+
1. Check \`.player/progress.yaml\` for the current challenge
|
|
1183
|
+
2. Read the challenge file to understand the expected outcome
|
|
1184
|
+
3. Check the hint count already used (track in progress.yaml)
|
|
1185
|
+
4. Give a hint at the appropriate level:
|
|
1186
|
+
- **Hint 1**: Conceptual \u2014 "Think about how X relates to Y..."
|
|
1187
|
+
- **Hint 2**: Directional \u2014 "Try looking at Z..."
|
|
1188
|
+
- **Hint 3**: Near-solution \u2014 "The answer involves doing W to..."
|
|
1189
|
+
5. Do NOT give the solution directly
|
|
1190
|
+
|
|
1191
|
+
$ARGUMENTS
|
|
1192
|
+
`;
|
|
1193
|
+
}
|
|
1194
|
+
function generateNextSkill(options) {
|
|
1195
|
+
return `---
|
|
1196
|
+
name: next
|
|
1197
|
+
description: Move to the next lesson or challenge in ${options.name}. Use when the user says "next", "continue", "what's next", or finishes a lesson.
|
|
1198
|
+
---
|
|
1199
|
+
|
|
1200
|
+
# Next Lesson or Challenge
|
|
1201
|
+
|
|
1202
|
+
## Steps
|
|
1203
|
+
|
|
1204
|
+
1. Read \`.player/progress.yaml\` to find current position
|
|
1205
|
+
2. Determine what comes next:
|
|
1206
|
+
- If in a lesson \u2192 present the next lesson (or the chapter challenge if done)
|
|
1207
|
+
- If challenge was completed \u2192 move to next chapter
|
|
1208
|
+
- If all chapters done \u2192 congratulate and suggest contributing
|
|
1209
|
+
3. Update \`progress.yaml\` to reflect the new position
|
|
1210
|
+
4. Present the next content
|
|
1211
|
+
|
|
1212
|
+
$ARGUMENTS
|
|
1213
|
+
`;
|
|
1214
|
+
}
|
|
1215
|
+
function generateProgressSkill(options) {
|
|
1216
|
+
return `---
|
|
1217
|
+
name: progress
|
|
1218
|
+
description: Show the user's progress in ${options.name}. Use when the user says "progress", "how am I doing", "show my progress", or "where am I".
|
|
1219
|
+
---
|
|
1220
|
+
|
|
1221
|
+
# Show Progress
|
|
1222
|
+
|
|
1223
|
+
## Steps
|
|
1224
|
+
|
|
1225
|
+
1. Read \`.player/progress.yaml\`
|
|
1226
|
+
2. Display a summary:
|
|
1227
|
+
- Chapters completed vs total
|
|
1228
|
+
- Current chapter and lesson
|
|
1229
|
+
- Challenges solved
|
|
1230
|
+
- Time spent (if tracked)
|
|
1231
|
+
3. Encourage them with a short motivational message
|
|
1232
|
+
|
|
1233
|
+
$ARGUMENTS
|
|
1234
|
+
`;
|
|
1235
|
+
}
|
|
1236
|
+
function generateVerifySkill(options) {
|
|
1237
|
+
return `---
|
|
1238
|
+
name: verify
|
|
1239
|
+
description: Verify the user's solution to the current challenge in ${options.name}. Use when the user says "verify", "check my work", "did I get it right", or "check".
|
|
1240
|
+
---
|
|
1241
|
+
|
|
1242
|
+
# Verify Solution
|
|
1243
|
+
|
|
1244
|
+
Read \`engine/validation.md\` for verification rules.
|
|
1245
|
+
|
|
1246
|
+
## Steps
|
|
1247
|
+
|
|
1248
|
+
1. Read \`.player/progress.yaml\` for current challenge
|
|
1249
|
+
2. Read the challenge file for expected outcome
|
|
1250
|
+
3. Ask the user to show their work (output, code, or result)
|
|
1251
|
+
4. Compare against expected outcome:
|
|
1252
|
+
- **Correct**: celebrate, update \`progress.yaml\` to mark challenge completed, suggest \`/next\`
|
|
1253
|
+
- **Incorrect**: give a specific hint about what's wrong (no solution)
|
|
1254
|
+
|
|
1255
|
+
$ARGUMENTS
|
|
1256
|
+
`;
|
|
1257
|
+
}
|
|
1258
|
+
function generateRules(options) {
|
|
1259
|
+
return `# ${options.name} \u2014 Rules
|
|
1260
|
+
|
|
1261
|
+
## Two Modes
|
|
1262
|
+
|
|
1263
|
+
### Teaching Mode (during lessons)
|
|
1264
|
+
|
|
1265
|
+
- Explain concepts clearly with examples
|
|
1266
|
+
- Ask questions to check understanding
|
|
1267
|
+
- Use analogies to make ideas concrete
|
|
1268
|
+
- Pace yourself \u2014 don't overwhelm
|
|
1269
|
+
|
|
1270
|
+
### Challenge Mode (during challenges)
|
|
1271
|
+
|
|
1272
|
+
- NEVER give the answer directly
|
|
1273
|
+
- Use progressive hints (conceptual \u2192 directional \u2192 near-solution)
|
|
1274
|
+
- Let the user struggle productively \u2014 that's where learning happens
|
|
1275
|
+
- Celebrate when they solve it
|
|
1276
|
+
|
|
1277
|
+
## Session Flow
|
|
1278
|
+
|
|
1279
|
+
1. Read \`engine/narrator.md\` to load your persona
|
|
1280
|
+
2. Read \`.player/progress.yaml\` to check state
|
|
1281
|
+
3. Welcome the user and orient them
|
|
1282
|
+
4. Present current lesson or challenge
|
|
1283
|
+
5. Track progress after each milestone
|
|
1284
|
+
|
|
1285
|
+
## Language Detection
|
|
1286
|
+
|
|
1287
|
+
- Detect the user's language from their first message
|
|
1288
|
+
- Respond in that language throughout
|
|
1289
|
+
- Keep chapter/lesson titles and technical terms in English
|
|
1290
|
+
- Record detected language as IETF tag in \`progress.yaml\` \u2192 \`player.language\`
|
|
1291
|
+
|
|
1292
|
+
## Progress Tracking
|
|
1293
|
+
|
|
1294
|
+
Update \`.player/progress.yaml\` when:
|
|
1295
|
+
- A lesson is completed
|
|
1296
|
+
- A challenge is solved
|
|
1297
|
+
- The session ends (update \`last_played\`)
|
|
1298
|
+
`;
|
|
1299
|
+
}
|
|
1300
|
+
function generateNarrator(options) {
|
|
1301
|
+
return `# ${options.name} \u2014 Narrator
|
|
1302
|
+
|
|
1303
|
+
## Persona
|
|
1304
|
+
|
|
1305
|
+
- **Name**: Guide
|
|
1306
|
+
- **Tone**: Encouraging, patient, and knowledgeable
|
|
1307
|
+
- **Style**: Clear explanations with real-world examples
|
|
1308
|
+
- **Approach**: Socratic \u2014 ask questions, don't just lecture
|
|
1309
|
+
|
|
1310
|
+
## Story Context
|
|
1311
|
+
|
|
1312
|
+
${options.description}
|
|
1313
|
+
|
|
1314
|
+
## Greeting Templates
|
|
1315
|
+
|
|
1316
|
+
### New User
|
|
1317
|
+
"Welcome! I'm your guide for ${options.name}.
|
|
1318
|
+
${options.description}
|
|
1319
|
+
Ready to dive in? Let's start with Chapter 1."
|
|
1320
|
+
|
|
1321
|
+
### Returning User
|
|
1322
|
+
"Welcome back! Last time you [summary from progress.yaml].
|
|
1323
|
+
Ready to pick up where you left off?"
|
|
1324
|
+
|
|
1325
|
+
### After Completing a Challenge
|
|
1326
|
+
"Excellent work! You've solved [challenge name].
|
|
1327
|
+
Here's the key insight: [brief explanation].
|
|
1328
|
+
Up next: [preview of what's coming]"
|
|
1329
|
+
|
|
1330
|
+
### When Stuck
|
|
1331
|
+
"No worries \u2014 this one is tricky.
|
|
1332
|
+
Think about [concept]. What happens when you [action]?
|
|
1333
|
+
Take your time."
|
|
1334
|
+
`;
|
|
1335
|
+
}
|
|
1336
|
+
function generateValidation(options) {
|
|
1337
|
+
return `# ${options.name} \u2014 Validation
|
|
1338
|
+
|
|
1339
|
+
## How to Verify Challenges
|
|
1340
|
+
|
|
1341
|
+
When the user asks to verify their work:
|
|
1342
|
+
|
|
1343
|
+
1. Read the current challenge requirements from the challenge file
|
|
1344
|
+
2. Ask the user to show their result (output, code, screenshot, etc.)
|
|
1345
|
+
3. Compare against the expected outcome
|
|
1346
|
+
4. Give feedback:
|
|
1347
|
+
|
|
1348
|
+
### Correct
|
|
1349
|
+
|
|
1350
|
+
- Celebrate clearly: "That's exactly right!"
|
|
1351
|
+
- Explain why it works (reinforce the learning)
|
|
1352
|
+
- Update \`.player/progress.yaml\`:
|
|
1353
|
+
\`\`\`yaml
|
|
1354
|
+
chapters:
|
|
1355
|
+
ch01-getting-started:
|
|
1356
|
+
challenges:
|
|
1357
|
+
challenge_01: completed
|
|
1358
|
+
\`\`\`
|
|
1359
|
+
- Suggest \`/next\` to continue
|
|
1360
|
+
|
|
1361
|
+
### Incorrect
|
|
1362
|
+
|
|
1363
|
+
- Be specific: "The result is close, but [what's different]"
|
|
1364
|
+
- Give a targeted hint (not the solution)
|
|
1365
|
+
- Let them try again
|
|
1366
|
+
|
|
1367
|
+
## Progress Updates
|
|
1368
|
+
|
|
1369
|
+
Always update \`last_played\` at the end of each session:
|
|
1370
|
+
|
|
1371
|
+
\`\`\`yaml
|
|
1372
|
+
player:
|
|
1373
|
+
last_played: <today's date>
|
|
1374
|
+
\`\`\`
|
|
1375
|
+
`;
|
|
1376
|
+
}
|
|
1377
|
+
function generateChapterReadme(options) {
|
|
1378
|
+
return `# Chapter 1: Getting Started
|
|
1379
|
+
|
|
1380
|
+
> Part of [${options.name}](../../hubbit.yaml)
|
|
1381
|
+
|
|
1382
|
+
## Overview
|
|
1383
|
+
|
|
1384
|
+
[TODO: Write your chapter overview here. What will the user learn? Why does it matter?]
|
|
1385
|
+
|
|
1386
|
+
## Lessons
|
|
1387
|
+
|
|
1388
|
+
### Lesson 1: Introduction
|
|
1389
|
+
|
|
1390
|
+
[TODO: Write your first lesson here. Introduce the core concept.]
|
|
1391
|
+
|
|
1392
|
+
### Lesson 2: Core Concepts
|
|
1393
|
+
|
|
1394
|
+
[TODO: Dive deeper. Explain the main ideas with examples.]
|
|
1395
|
+
|
|
1396
|
+
### Lesson 3: Putting It Together
|
|
1397
|
+
|
|
1398
|
+
[TODO: Connect the concepts. Show how they work together in practice.]
|
|
1399
|
+
|
|
1400
|
+
## Challenge
|
|
1401
|
+
|
|
1402
|
+
See the \`challenges/\` directory for the hands-on exercise.
|
|
1403
|
+
|
|
1404
|
+
After completing the challenge, run \`/verify\` to check your work.
|
|
1405
|
+
`;
|
|
1406
|
+
}
|
|
1407
|
+
function generateProgressTemplate(options) {
|
|
1408
|
+
return `# Progress template for ${options.name}
|
|
1409
|
+
# This file is committed to git as the clean starting state.
|
|
1410
|
+
# The live progress.yaml (in .player/) is gitignored.
|
|
1411
|
+
|
|
1412
|
+
version: 1
|
|
1413
|
+
|
|
1414
|
+
player:
|
|
1415
|
+
started_at: null
|
|
1416
|
+
last_played: null
|
|
1417
|
+
language: auto
|
|
1418
|
+
|
|
1419
|
+
chapters:
|
|
1420
|
+
ch01-getting-started:
|
|
1421
|
+
status: not_started # not_started | in_progress | completed
|
|
1422
|
+
started_at: null
|
|
1423
|
+
completed_at: null
|
|
1424
|
+
lessons:
|
|
1425
|
+
lesson_01: not_started
|
|
1426
|
+
lesson_02: not_started
|
|
1427
|
+
lesson_03: not_started
|
|
1428
|
+
challenges:
|
|
1429
|
+
challenge_01: not_started
|
|
1430
|
+
`;
|
|
1431
|
+
}
|
|
1432
|
+
function generateGitignore() {
|
|
1433
|
+
return `.player/progress.yaml
|
|
1434
|
+
!.player/progress.yaml.template
|
|
1435
|
+
.DS_Store
|
|
1436
|
+
Thumbs.db
|
|
1437
|
+
.vscode/
|
|
1438
|
+
.idea/
|
|
1439
|
+
node_modules/
|
|
1440
|
+
*.log
|
|
1441
|
+
`;
|
|
1442
|
+
}
|
|
1443
|
+
function generateManifest(options) {
|
|
1444
|
+
const authorLine = options.author ? `author: "${options.author}"` : '# author: "Your Name"';
|
|
1445
|
+
return `# Hubbit Manifest
|
|
1446
|
+
# Documentation: https://hubbits.dev/docs/manifest
|
|
1447
|
+
|
|
1448
|
+
spec_version: 1
|
|
1449
|
+
name: "${options.name}"
|
|
1450
|
+
version: "0.1.0"
|
|
1451
|
+
${authorLine}
|
|
1452
|
+
description: "${escapeYaml(options.description)}"
|
|
1453
|
+
|
|
1454
|
+
category: ${options.category}
|
|
1455
|
+
difficulty: ${options.difficulty}
|
|
1456
|
+
language: auto
|
|
1457
|
+
estimated_time: "1-2 hours"
|
|
1458
|
+
license: MIT
|
|
1459
|
+
|
|
1460
|
+
tags:
|
|
1461
|
+
- ${options.category}
|
|
1462
|
+
# Add more tags here
|
|
1463
|
+
|
|
1464
|
+
pricing: free
|
|
1465
|
+
|
|
1466
|
+
# GitHub repository (set by \`hubbits create import\`)
|
|
1467
|
+
# repository: "https://github.com/<you>/${options.name}"
|
|
1468
|
+
|
|
1469
|
+
# Compatible AI editors
|
|
1470
|
+
compatible_with:
|
|
1471
|
+
- claude-code
|
|
1472
|
+
- cursor
|
|
1473
|
+
- windsurf
|
|
1474
|
+
- copilot
|
|
1475
|
+
|
|
1476
|
+
# AI persona configuration
|
|
1477
|
+
persona:
|
|
1478
|
+
name: "Guide"
|
|
1479
|
+
tone: "encouraging"
|
|
1480
|
+
traits:
|
|
1481
|
+
- "patient"
|
|
1482
|
+
- "knowledgeable"
|
|
1483
|
+
constraints:
|
|
1484
|
+
- "Never give answers directly; guide with hints"
|
|
1485
|
+
- "Celebrate progress"
|
|
1486
|
+
|
|
1487
|
+
# Curriculum structure
|
|
1488
|
+
curriculum:
|
|
1489
|
+
type: linear
|
|
1490
|
+
chapters:
|
|
1491
|
+
- id: ch01-getting-started
|
|
1492
|
+
title: "Chapter 1: Getting Started"
|
|
1493
|
+
lessons: 3
|
|
1494
|
+
challenges: 1
|
|
1495
|
+
unlock_condition: null
|
|
1496
|
+
|
|
1497
|
+
# Runtime configuration
|
|
1498
|
+
runtime:
|
|
1499
|
+
local: true
|
|
1500
|
+
cloud: false
|
|
1501
|
+
|
|
1502
|
+
# AI engine requirements
|
|
1503
|
+
engines:
|
|
1504
|
+
ai:
|
|
1505
|
+
min_context_window: 100000
|
|
1506
|
+
modalities:
|
|
1507
|
+
- text
|
|
1508
|
+
features:
|
|
1509
|
+
- tool_use
|
|
1510
|
+
`;
|
|
1511
|
+
}
|
|
1512
|
+
function generateReadme(options) {
|
|
1513
|
+
return `# ${options.name}
|
|
1514
|
+
|
|
1515
|
+
> ${options.description}
|
|
1516
|
+
|
|
1517
|
+
An interactive AI-driven experience. Open this project in your AI editor and type **"let's play"** to begin.
|
|
1518
|
+
|
|
1519
|
+
---
|
|
1520
|
+
|
|
1521
|
+
## How It Works
|
|
1522
|
+
|
|
1523
|
+
1. Pull this package: \`hubbits pull <scope>/${options.name}\`
|
|
1524
|
+
2. Open it in your AI editor (Claude Code, Cursor, Windsurf, Copilot, etc.)
|
|
1525
|
+
3. Type **"let's play"**
|
|
1526
|
+
4. Your AI becomes your guide
|
|
1527
|
+
|
|
1528
|
+
---
|
|
1529
|
+
|
|
1530
|
+
## Commands
|
|
1531
|
+
|
|
1532
|
+
\`\`\`
|
|
1533
|
+
"let's play" Start or resume
|
|
1534
|
+
"hint" Get a progressive hint
|
|
1535
|
+
"verify" Check your work
|
|
1536
|
+
"progress" See how far you've come
|
|
1537
|
+
"next" Move forward
|
|
1538
|
+
\`\`\`
|
|
1539
|
+
|
|
1540
|
+
---
|
|
1541
|
+
|
|
1542
|
+
## Project Structure
|
|
1543
|
+
|
|
1544
|
+
\`\`\`
|
|
1545
|
+
AGENTS.md \u2190 Universal AI entry point
|
|
1546
|
+
CLAUDE.md \u2190 Claude Code + skills
|
|
1547
|
+
.cursorrules \u2190 Cursor
|
|
1548
|
+
.windsurfrules \u2190 Windsurf
|
|
1549
|
+
.github/copilot-instructions.md \u2190 GitHub Copilot
|
|
1550
|
+
|
|
1551
|
+
engine/ \u2190 AI behavior (rules, narrator, validation)
|
|
1552
|
+
content/ \u2190 Chapters with lessons and challenges
|
|
1553
|
+
.player/ \u2190 Your progress (not committed)
|
|
1554
|
+
\`\`\`
|
|
1555
|
+
|
|
1556
|
+
---
|
|
1557
|
+
|
|
1558
|
+
## License
|
|
1559
|
+
|
|
1560
|
+
MIT
|
|
1561
|
+
`;
|
|
1562
|
+
}
|
|
1563
|
+
function isValidName(name) {
|
|
1564
|
+
if (name.length === 0 || name.length > 214) return false;
|
|
1565
|
+
return /^[a-z][a-z0-9-]*[a-z0-9]$/.test(name) || /^[a-z]$/.test(name);
|
|
1566
|
+
}
|
|
1567
|
+
function escapeYaml(str) {
|
|
1568
|
+
return str.replace(/"/g, '\\"');
|
|
1569
|
+
}
|
|
1570
|
+
var import_default = defineCommand({
|
|
1571
|
+
meta: {
|
|
1572
|
+
name: "import",
|
|
1573
|
+
description: "Import a GitHub repo as a hubbit"
|
|
1574
|
+
},
|
|
1575
|
+
args: {
|
|
1576
|
+
url: {
|
|
1577
|
+
type: "positional",
|
|
1578
|
+
description: "GitHub repository URL (e.g. https://github.com/user/repo)",
|
|
1579
|
+
required: true
|
|
1580
|
+
},
|
|
1581
|
+
yes: {
|
|
1582
|
+
type: "boolean",
|
|
1583
|
+
alias: "y",
|
|
1584
|
+
description: "Skip confirmation prompt",
|
|
1585
|
+
default: false
|
|
1586
|
+
},
|
|
1587
|
+
json: {
|
|
1588
|
+
type: "boolean",
|
|
1589
|
+
description: "Output as JSON",
|
|
1590
|
+
default: false
|
|
1591
|
+
},
|
|
1592
|
+
quiet: {
|
|
1593
|
+
type: "boolean",
|
|
1594
|
+
description: "Suppress output",
|
|
1595
|
+
default: false
|
|
1596
|
+
},
|
|
1597
|
+
verbose: {
|
|
1598
|
+
type: "boolean",
|
|
1599
|
+
description: "Show debug information",
|
|
1600
|
+
default: false
|
|
1601
|
+
}
|
|
1602
|
+
},
|
|
1603
|
+
async run({ args }) {
|
|
1604
|
+
setOutputMode({ json: args.json, quiet: args.quiet, verbose: args.verbose });
|
|
1605
|
+
const repoUrl = args.url;
|
|
1606
|
+
if (!isGithubUrl(repoUrl)) {
|
|
1607
|
+
errorWithFix(
|
|
1608
|
+
`Invalid GitHub URL: "${repoUrl}"`,
|
|
1609
|
+
"Provide a full GitHub repo URL, e.g. https://github.com/user/repo"
|
|
1610
|
+
);
|
|
1611
|
+
process.exit(1);
|
|
1612
|
+
}
|
|
1613
|
+
const packageDir = resolve(process.cwd());
|
|
1614
|
+
const fetchSpinner = spinner("Fetching repo info from GitHub...");
|
|
1615
|
+
const api2 = createApiClient();
|
|
1616
|
+
let repoInfo;
|
|
1617
|
+
try {
|
|
1618
|
+
const result = await api2.getGithubRepoInfo(repoUrl);
|
|
1619
|
+
if (!result.data) {
|
|
1620
|
+
fetchSpinner.fail("Failed to fetch repo info.");
|
|
1621
|
+
errorWithFix("No data returned from GitHub.", "Check that the repo is public.");
|
|
1622
|
+
process.exit(1);
|
|
1623
|
+
}
|
|
1624
|
+
repoInfo = result.data;
|
|
1625
|
+
fetchSpinner.succeed(`Found: ${pc5.bold(repoInfo.name)} \u2014 ${repoInfo.description || pc5.dim("no description")}`);
|
|
1626
|
+
} catch (err) {
|
|
1627
|
+
fetchSpinner.fail("Failed to fetch repo info.");
|
|
1628
|
+
if (err instanceof ApiError) {
|
|
1629
|
+
errorWithFix(err.message, "Make sure the repo is public and the URL is correct.");
|
|
1630
|
+
} else {
|
|
1631
|
+
errorWithFix("Network error.", "Check your connection and try again.");
|
|
1632
|
+
}
|
|
1633
|
+
process.exit(1);
|
|
1634
|
+
}
|
|
1635
|
+
const manifestPath = findManifestFile(packageDir);
|
|
1636
|
+
if (manifestPath) {
|
|
1637
|
+
const updated = setRepositoryField(manifestPath, repoUrl);
|
|
1638
|
+
if (updated) {
|
|
1639
|
+
success(`Updated ${pc5.dim("hubbit.yaml")} \u2014 set repository: ${pc5.cyan(repoUrl)}`);
|
|
1640
|
+
} else {
|
|
1641
|
+
info(`${pc5.dim("hubbit.yaml")} already has a repository field.`);
|
|
1642
|
+
}
|
|
1643
|
+
} else {
|
|
1644
|
+
if (!args.yes && !args.json && !args.quiet) {
|
|
1645
|
+
newline();
|
|
1646
|
+
info("No hubbit.yaml found in the current directory.");
|
|
1647
|
+
info("A minimal manifest will be created from GitHub metadata:");
|
|
1648
|
+
newline();
|
|
1649
|
+
field("Name", repoInfo.name);
|
|
1650
|
+
field("Description", repoInfo.description || "(empty)");
|
|
1651
|
+
field("License", repoInfo.license || "(none)");
|
|
1652
|
+
if (repoInfo.tags.length > 0) field("Tags", repoInfo.tags.slice(0, 5).join(", "));
|
|
1653
|
+
newline();
|
|
1654
|
+
const confirmed = await p5.confirm({ message: "Create hubbit.yaml?" });
|
|
1655
|
+
if (p5.isCancel(confirmed) || !confirmed) {
|
|
1656
|
+
p5.cancel("Import cancelled.");
|
|
1657
|
+
process.exit(0);
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
const manifestContent = generateMinimalManifest(repoInfo, repoUrl);
|
|
1661
|
+
writeFileSync(join(packageDir, "hubbit.yaml"), manifestContent, "utf-8");
|
|
1662
|
+
success(`Created ${pc5.dim("hubbit.yaml")} from GitHub metadata.`);
|
|
1663
|
+
}
|
|
1664
|
+
if (isJsonMode()) {
|
|
1665
|
+
outputJson({
|
|
1666
|
+
status: "ok",
|
|
1667
|
+
repository: repoUrl,
|
|
1668
|
+
manifest: manifestPath ?? join(packageDir, "hubbit.yaml"),
|
|
1669
|
+
next: "hubbits publish"
|
|
1670
|
+
});
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
newline();
|
|
1674
|
+
success("Ready to publish!");
|
|
1675
|
+
newline();
|
|
1676
|
+
field("Repository", pc5.cyan(repoUrl));
|
|
1677
|
+
field("Directory", packageDir);
|
|
1678
|
+
newline();
|
|
1679
|
+
hint(`Run ${pc5.bold("hubbits publish")} to publish your hubbit to the registry.`);
|
|
1680
|
+
hint("Run `hubbits validate` first to check for any issues.");
|
|
1681
|
+
newline();
|
|
1682
|
+
}
|
|
1683
|
+
});
|
|
1684
|
+
function findManifestFile(dir) {
|
|
1685
|
+
for (const name of ["hubbit.yaml", "hubbit.yml"]) {
|
|
1686
|
+
const p7 = join(dir, name);
|
|
1687
|
+
if (existsSync(p7)) return p7;
|
|
1688
|
+
}
|
|
1689
|
+
return null;
|
|
1690
|
+
}
|
|
1691
|
+
function setRepositoryField(manifestPath, repoUrl) {
|
|
1692
|
+
const content = readFileSync(manifestPath, "utf-8");
|
|
1693
|
+
const repoLine = `repository: "${repoUrl}"`;
|
|
1694
|
+
const realMatch = content.match(/^repository:(.*)$/m);
|
|
1695
|
+
if (realMatch) {
|
|
1696
|
+
if (realMatch[0] === repoLine) return false;
|
|
1697
|
+
const updated2 = content.replace(/^repository:.*$/gm, repoLine);
|
|
1698
|
+
writeFileSync(manifestPath, updated2, "utf-8");
|
|
1699
|
+
return true;
|
|
1700
|
+
}
|
|
1701
|
+
if (/^#\s*repository:/m.test(content)) {
|
|
1702
|
+
const updated2 = content.replace(/^#\s*repository:.*$/m, repoLine);
|
|
1703
|
+
writeFileSync(manifestPath, updated2, "utf-8");
|
|
1704
|
+
return true;
|
|
1705
|
+
}
|
|
1706
|
+
const updated = content.trimEnd() + `
|
|
1707
|
+
repository: "${repoUrl}"
|
|
1708
|
+
`;
|
|
1709
|
+
writeFileSync(manifestPath, updated, "utf-8");
|
|
1710
|
+
return true;
|
|
1711
|
+
}
|
|
1712
|
+
function generateMinimalManifest(info2, repoUrl) {
|
|
1713
|
+
const name = toHubbitName(info2.name);
|
|
1714
|
+
const description = escapeYaml2(info2.description || `A hubbit based on ${info2.name}`);
|
|
1715
|
+
const license = info2.license ? `license: ${info2.license}` : "# license: MIT";
|
|
1716
|
+
const tagsBlock = info2.tags.length > 0 ? "tags:\n" + info2.tags.slice(0, 10).map((t) => ` - ${t}`).join("\n") : "# tags: []";
|
|
1717
|
+
return `# Hubbit Manifest
|
|
1718
|
+
# Documentation: https://hubbits.dev/docs/manifest
|
|
1719
|
+
|
|
1720
|
+
spec_version: 1
|
|
1721
|
+
name: "${name}"
|
|
1722
|
+
version: "${info2.version ?? "0.1.0"}"
|
|
1723
|
+
description: "${description}"
|
|
1724
|
+
|
|
1725
|
+
${license}
|
|
1726
|
+
${tagsBlock}
|
|
1727
|
+
|
|
1728
|
+
category: learning
|
|
1729
|
+
difficulty: beginner
|
|
1730
|
+
language: auto
|
|
1731
|
+
pricing: free
|
|
1732
|
+
repository: "${repoUrl}"
|
|
1733
|
+
|
|
1734
|
+
compatible_with:
|
|
1735
|
+
- claude-code
|
|
1736
|
+
- cursor
|
|
1737
|
+
- windsurf
|
|
1738
|
+
- copilot
|
|
1739
|
+
|
|
1740
|
+
runtime:
|
|
1741
|
+
local: true
|
|
1742
|
+
cloud: false
|
|
1743
|
+
`;
|
|
1744
|
+
}
|
|
1745
|
+
function isGithubUrl(url) {
|
|
1746
|
+
try {
|
|
1747
|
+
const u = new URL(url);
|
|
1748
|
+
return u.hostname === "github.com" && u.pathname.split("/").filter(Boolean).length >= 2;
|
|
1749
|
+
} catch {
|
|
1750
|
+
return false;
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
function toHubbitName(name) {
|
|
1754
|
+
const result = name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
1755
|
+
return result.length > 0 ? result : "my-hubbit";
|
|
1756
|
+
}
|
|
1757
|
+
function escapeYaml2(str) {
|
|
1758
|
+
return str.replace(/"/g, '\\"');
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
// src/commands/create/index.ts
|
|
1762
|
+
var create_default = defineCommand({
|
|
1763
|
+
meta: {
|
|
1764
|
+
name: "create",
|
|
1765
|
+
description: "Create a new hubbit or import from GitHub"
|
|
1766
|
+
},
|
|
1767
|
+
subCommands: {
|
|
1768
|
+
template: template_default,
|
|
1769
|
+
import: import_default
|
|
1770
|
+
}
|
|
1771
|
+
});
|
|
1772
|
+
var init_default = defineCommand({
|
|
1773
|
+
meta: {
|
|
1774
|
+
name: "init",
|
|
1775
|
+
description: "Initialize a hubbit in the current directory"
|
|
1776
|
+
},
|
|
1777
|
+
args: {
|
|
1778
|
+
json: {
|
|
1779
|
+
type: "boolean",
|
|
1780
|
+
description: "Output as JSON",
|
|
1781
|
+
default: false
|
|
1782
|
+
},
|
|
1783
|
+
quiet: {
|
|
1784
|
+
type: "boolean",
|
|
1785
|
+
description: "Suppress output",
|
|
1786
|
+
default: false
|
|
1787
|
+
},
|
|
1788
|
+
verbose: {
|
|
1789
|
+
type: "boolean",
|
|
1790
|
+
description: "Show debug information",
|
|
1791
|
+
default: false
|
|
1792
|
+
}
|
|
1793
|
+
},
|
|
1794
|
+
async run({ args }) {
|
|
1795
|
+
setOutputMode({ json: args.json, quiet: args.quiet, verbose: args.verbose });
|
|
1796
|
+
const cwd = process.cwd();
|
|
1797
|
+
const cwdName = basename(cwd);
|
|
1798
|
+
const yamlPath = join(cwd, "hubbit.yaml");
|
|
1799
|
+
const authState = getAuthState();
|
|
1800
|
+
const loggedInUsername = authState?.username ?? void 0;
|
|
1801
|
+
if (existsSync(yamlPath)) {
|
|
1802
|
+
if (isJsonMode()) {
|
|
1803
|
+
outputJson({ status: "exists", path: yamlPath });
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
info("hubbit.yaml already exists in this directory.");
|
|
1807
|
+
newline();
|
|
1808
|
+
hint("Validate: `hubbits validate`");
|
|
1809
|
+
hint("Publish: `hubbits publish`");
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
const entries = readdirSync(cwd).filter(
|
|
1813
|
+
(e) => e !== ".git" && e !== ".DS_Store" && e !== "Thumbs.db"
|
|
1814
|
+
);
|
|
1815
|
+
if (entries.length === 0) {
|
|
1816
|
+
const meta2 = await interactiveFullMeta(cwdName, loggedInUsername);
|
|
1817
|
+
const files = scaffoldInto(cwd, meta2);
|
|
1818
|
+
if (isJsonMode()) {
|
|
1819
|
+
outputJson({ status: "ok", mode: "scaffold", path: cwd, files });
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
newline();
|
|
1823
|
+
success(`Scaffolded hubbit into ${pc5.bold(cwdName)}/`);
|
|
1824
|
+
for (const file of files) {
|
|
1825
|
+
console.log(` ${pc5.dim("\u251C\u2500\u2500")} ${file}`);
|
|
1826
|
+
}
|
|
1827
|
+
newline();
|
|
1828
|
+
printPostScaffoldGuide(cwdName);
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
const meta = await interactiveYamlOnly(cwdName, loggedInUsername);
|
|
1832
|
+
writeFileSync(yamlPath, generateManifest2(meta));
|
|
1833
|
+
if (isJsonMode()) {
|
|
1834
|
+
outputJson({ status: "ok", mode: "yaml_only", path: yamlPath });
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
newline();
|
|
1838
|
+
success(`Created ${pc5.bold("hubbit.yaml")}`);
|
|
1839
|
+
newline();
|
|
1840
|
+
console.log(` ${pc5.dim("\u251C\u2500\u2500")} hubbit.yaml`);
|
|
1841
|
+
newline();
|
|
1842
|
+
console.log(pc5.bold(" Next steps:"));
|
|
1843
|
+
newline();
|
|
1844
|
+
console.log(` 1. Review ${pc5.yellow("hubbit.yaml")} and fill in any details`);
|
|
1845
|
+
console.log(` 2. Commit and push to GitHub`);
|
|
1846
|
+
console.log(` 3. Run ${pc5.yellow("hubbits publish")} to publish`);
|
|
1847
|
+
newline();
|
|
1848
|
+
hint("Validate your manifest: `hubbits validate`");
|
|
1849
|
+
newline();
|
|
1850
|
+
}
|
|
1851
|
+
});
|
|
1852
|
+
async function interactiveFullMeta(defaultName, username) {
|
|
1853
|
+
p5.intro(pc5.bold("Initialize a Hubbit"));
|
|
1854
|
+
const name = await p5.text({
|
|
1855
|
+
message: "Package name:",
|
|
1856
|
+
placeholder: defaultName || "my-awesome-hubbit",
|
|
1857
|
+
initialValue: isValidName2(defaultName) ? defaultName : "",
|
|
1858
|
+
validate(value) {
|
|
1859
|
+
if (!value) return "Package name is required.";
|
|
1860
|
+
if (!isValidName2(value)) {
|
|
1861
|
+
return "Use lowercase letters, numbers, and hyphens. Must start with a letter.";
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
});
|
|
1865
|
+
if (p5.isCancel(name)) {
|
|
1866
|
+
p5.cancel("Cancelled.");
|
|
1867
|
+
process.exit(0);
|
|
1868
|
+
}
|
|
1869
|
+
const category = await p5.select({
|
|
1870
|
+
message: "Category:",
|
|
1871
|
+
options: [
|
|
1872
|
+
{ value: "learning", label: "Learning", hint: "Educational courses and tutorials" },
|
|
1873
|
+
{ value: "entertainment", label: "Entertainment", hint: "Games, stories, and fun" },
|
|
1874
|
+
{ value: "productivity", label: "Productivity", hint: "Tools and workflows" },
|
|
1875
|
+
{ value: "wellness", label: "Wellness", hint: "Health and wellbeing" }
|
|
1876
|
+
]
|
|
1877
|
+
});
|
|
1878
|
+
if (p5.isCancel(category)) {
|
|
1879
|
+
p5.cancel("Cancelled.");
|
|
1880
|
+
process.exit(0);
|
|
1881
|
+
}
|
|
1882
|
+
const difficulty = await p5.select({
|
|
1883
|
+
message: "Difficulty:",
|
|
1884
|
+
options: [
|
|
1885
|
+
{ value: "beginner", label: "Beginner" },
|
|
1886
|
+
{ value: "intermediate", label: "Intermediate" },
|
|
1887
|
+
{ value: "advanced", label: "Advanced" },
|
|
1888
|
+
{ value: "beginner-to-intermediate", label: "Beginner to Intermediate" },
|
|
1889
|
+
{ value: "intermediate-to-advanced", label: "Intermediate to Advanced" }
|
|
1890
|
+
]
|
|
1891
|
+
});
|
|
1892
|
+
if (p5.isCancel(difficulty)) {
|
|
1893
|
+
p5.cancel("Cancelled.");
|
|
1894
|
+
process.exit(0);
|
|
1895
|
+
}
|
|
1896
|
+
const description = await p5.text({
|
|
1897
|
+
message: "Description:",
|
|
1898
|
+
placeholder: "A brief description of your Hubbit",
|
|
1899
|
+
validate(value) {
|
|
1900
|
+
if (!value) return "Description is required.";
|
|
1901
|
+
if (value.length > 500) return "Description must be 500 characters or fewer.";
|
|
1902
|
+
}
|
|
1903
|
+
});
|
|
1904
|
+
if (p5.isCancel(description)) {
|
|
1905
|
+
p5.cancel("Cancelled.");
|
|
1906
|
+
process.exit(0);
|
|
1907
|
+
}
|
|
1908
|
+
let author = username;
|
|
1909
|
+
if (!author) {
|
|
1910
|
+
const authorInput = await p5.text({
|
|
1911
|
+
message: "Author (optional):",
|
|
1912
|
+
placeholder: "Your Name"
|
|
1913
|
+
});
|
|
1914
|
+
if (p5.isCancel(authorInput)) {
|
|
1915
|
+
p5.cancel("Cancelled.");
|
|
1916
|
+
process.exit(0);
|
|
1917
|
+
}
|
|
1918
|
+
author = authorInput || void 0;
|
|
1919
|
+
}
|
|
1920
|
+
p5.outro(pc5.dim("Scaffolding..."));
|
|
1921
|
+
return {
|
|
1922
|
+
name,
|
|
1923
|
+
category,
|
|
1924
|
+
difficulty,
|
|
1925
|
+
description,
|
|
1926
|
+
version: "0.1.0",
|
|
1927
|
+
pricing: "free",
|
|
1928
|
+
author
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
async function interactiveYamlOnly(defaultName, username) {
|
|
1932
|
+
p5.intro(pc5.bold("Create hubbit.yaml"));
|
|
1933
|
+
const name = await p5.text({
|
|
1934
|
+
message: "Package name:",
|
|
1935
|
+
placeholder: defaultName || "my-awesome-hubbit",
|
|
1936
|
+
initialValue: isValidName2(defaultName) ? defaultName : "",
|
|
1937
|
+
validate(value) {
|
|
1938
|
+
if (!value) return "Package name is required.";
|
|
1939
|
+
if (!isValidName2(value)) {
|
|
1940
|
+
return "Use lowercase letters, numbers, and hyphens. Must start with a letter.";
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
});
|
|
1944
|
+
if (p5.isCancel(name)) {
|
|
1945
|
+
p5.cancel("Cancelled.");
|
|
1946
|
+
process.exit(0);
|
|
1947
|
+
}
|
|
1948
|
+
const version = await p5.text({
|
|
1949
|
+
message: "Version:",
|
|
1950
|
+
placeholder: "0.1.0",
|
|
1951
|
+
initialValue: "0.1.0",
|
|
1952
|
+
validate(value) {
|
|
1953
|
+
if (!value) return "Version is required.";
|
|
1954
|
+
if (!/^\d+\.\d+\.\d+/.test(value)) return "Must be a valid SemVer (e.g., 0.1.0).";
|
|
1955
|
+
}
|
|
1956
|
+
});
|
|
1957
|
+
if (p5.isCancel(version)) {
|
|
1958
|
+
p5.cancel("Cancelled.");
|
|
1959
|
+
process.exit(0);
|
|
1960
|
+
}
|
|
1961
|
+
const description = await p5.text({
|
|
1962
|
+
message: "Description:",
|
|
1963
|
+
placeholder: "A brief description of your Hubbit",
|
|
1964
|
+
validate(value) {
|
|
1965
|
+
if (!value) return "Description is required.";
|
|
1966
|
+
if (value.length > 500) return "Description must be 500 characters or fewer.";
|
|
1967
|
+
}
|
|
1968
|
+
});
|
|
1969
|
+
if (p5.isCancel(description)) {
|
|
1970
|
+
p5.cancel("Cancelled.");
|
|
1971
|
+
process.exit(0);
|
|
1972
|
+
}
|
|
1973
|
+
const category = await p5.select({
|
|
1974
|
+
message: "Category:",
|
|
1975
|
+
options: [
|
|
1976
|
+
{ value: "learning", label: "Learning" },
|
|
1977
|
+
{ value: "entertainment", label: "Entertainment" },
|
|
1978
|
+
{ value: "productivity", label: "Productivity" },
|
|
1979
|
+
{ value: "wellness", label: "Wellness" }
|
|
1980
|
+
]
|
|
1981
|
+
});
|
|
1982
|
+
if (p5.isCancel(category)) {
|
|
1983
|
+
p5.cancel("Cancelled.");
|
|
1984
|
+
process.exit(0);
|
|
1985
|
+
}
|
|
1986
|
+
const difficulty = await p5.select({
|
|
1987
|
+
message: "Difficulty:",
|
|
1988
|
+
options: [
|
|
1989
|
+
{ value: "beginner", label: "Beginner" },
|
|
1990
|
+
{ value: "intermediate", label: "Intermediate" },
|
|
1991
|
+
{ value: "advanced", label: "Advanced" },
|
|
1992
|
+
{ value: "beginner-to-intermediate", label: "Beginner to Intermediate" },
|
|
1993
|
+
{ value: "intermediate-to-advanced", label: "Intermediate to Advanced" }
|
|
1994
|
+
]
|
|
1995
|
+
});
|
|
1996
|
+
if (p5.isCancel(difficulty)) {
|
|
1997
|
+
p5.cancel("Cancelled.");
|
|
1998
|
+
process.exit(0);
|
|
1999
|
+
}
|
|
2000
|
+
const pricing = await p5.select({
|
|
2001
|
+
message: "Pricing:",
|
|
2002
|
+
options: [
|
|
2003
|
+
{ value: "free", label: "Free" },
|
|
2004
|
+
{ value: "paid", label: "Paid" }
|
|
2005
|
+
]
|
|
2006
|
+
});
|
|
2007
|
+
if (p5.isCancel(pricing)) {
|
|
2008
|
+
p5.cancel("Cancelled.");
|
|
2009
|
+
process.exit(0);
|
|
2010
|
+
}
|
|
2011
|
+
p5.outro(pc5.dim("Generating hubbit.yaml..."));
|
|
2012
|
+
return {
|
|
2013
|
+
name,
|
|
2014
|
+
version,
|
|
2015
|
+
description,
|
|
2016
|
+
category,
|
|
2017
|
+
difficulty,
|
|
2018
|
+
pricing,
|
|
2019
|
+
author: username
|
|
2020
|
+
};
|
|
2021
|
+
}
|
|
2022
|
+
function scaffoldInto(targetDir, meta) {
|
|
2023
|
+
const files = [];
|
|
2024
|
+
const dirs = [
|
|
2025
|
+
".claude/skills/play",
|
|
2026
|
+
".claude/skills/hint",
|
|
2027
|
+
".claude/skills/next",
|
|
2028
|
+
".claude/skills/progress",
|
|
2029
|
+
".claude/skills/verify",
|
|
2030
|
+
".github",
|
|
2031
|
+
"engine",
|
|
2032
|
+
"content/ch01-getting-started/challenges",
|
|
2033
|
+
".player"
|
|
2034
|
+
];
|
|
2035
|
+
for (const dir of dirs) {
|
|
2036
|
+
mkdirSync(join(targetDir, dir), { recursive: true });
|
|
2037
|
+
}
|
|
2038
|
+
function write(relPath, content) {
|
|
2039
|
+
writeFileSync(join(targetDir, relPath), content);
|
|
2040
|
+
files.push(relPath);
|
|
2041
|
+
}
|
|
2042
|
+
write("AGENTS.md", `# ${meta.name} \u2014 Interactive AI Experience
|
|
2043
|
+
|
|
2044
|
+
You are the guide for **${meta.name}**. ${meta.description}
|
|
2045
|
+
|
|
2046
|
+
## Quick Start
|
|
2047
|
+
|
|
2048
|
+
When the user opens this project and says anything like "let's play", "start", or "begin":
|
|
2049
|
+
|
|
2050
|
+
1. Read \`engine/rules.md\` \u2014 your core behavior rules
|
|
2051
|
+
2. Read \`engine/narrator.md\` \u2014 tone, persona, story context
|
|
2052
|
+
3. Read \`.player/progress.yaml\` \u2014 check their current state
|
|
2053
|
+
4. Welcome them and guide them to where they left off
|
|
2054
|
+
|
|
2055
|
+
## Critical Rules
|
|
2056
|
+
|
|
2057
|
+
- ALWAYS read \`engine/rules.md\` before interacting
|
|
2058
|
+
- NEVER solve challenges for the user \u2014 only give hints
|
|
2059
|
+
- Track progress by updating \`.player/progress.yaml\` after each milestone
|
|
2060
|
+
- Respond in the user's language (auto-detect from their first message)
|
|
2061
|
+
`);
|
|
2062
|
+
write("CLAUDE.md", `# ${meta.name}
|
|
2063
|
+
|
|
2064
|
+
You are the guide for **${meta.name}**. Read and follow all instructions in \`AGENTS.md\`.
|
|
2065
|
+
|
|
2066
|
+
**Critical:** NEVER solve challenges for the user \u2014 only give hints.
|
|
2067
|
+
|
|
2068
|
+
## Claude Code Skills
|
|
2069
|
+
|
|
2070
|
+
- \`/play\` \u2014 Start or resume the experience
|
|
2071
|
+
- \`/hint\` \u2014 Get a progressive hint for the current challenge
|
|
2072
|
+
- \`/next\` \u2014 Move to the next lesson or challenge
|
|
2073
|
+
- \`/progress\` \u2014 Show current progress
|
|
2074
|
+
- \`/verify\` \u2014 Verify challenge solution
|
|
2075
|
+
`);
|
|
2076
|
+
write("GEMINI.md", `# ${meta.name}
|
|
2077
|
+
|
|
2078
|
+
You are the guide for **${meta.name}**. Read and follow all instructions in \`AGENTS.md\`.
|
|
2079
|
+
|
|
2080
|
+
**Critical:** NEVER solve challenges for the user \u2014 only give hints.
|
|
2081
|
+
`);
|
|
2082
|
+
write(".cursorrules", `# ${meta.name}
|
|
2083
|
+
|
|
2084
|
+
You are the guide for **${meta.name}**. Read and follow all instructions in \`AGENTS.md\`.
|
|
2085
|
+
|
|
2086
|
+
**Critical:** NEVER solve challenges for the user \u2014 only give hints.
|
|
2087
|
+
`);
|
|
2088
|
+
write(".windsurfrules", `# ${meta.name}
|
|
2089
|
+
|
|
2090
|
+
You are the guide for **${meta.name}**. Read and follow all instructions in \`AGENTS.md\`.
|
|
2091
|
+
|
|
2092
|
+
**Critical:** NEVER solve challenges for the user \u2014 only give hints.
|
|
2093
|
+
`);
|
|
2094
|
+
write(".github/copilot-instructions.md", `# ${meta.name}
|
|
2095
|
+
|
|
2096
|
+
You are the guide for **${meta.name}**. Read and follow all instructions in \`AGENTS.md\`.
|
|
2097
|
+
|
|
2098
|
+
**Critical:** NEVER solve challenges for the user \u2014 only give hints.
|
|
2099
|
+
`);
|
|
2100
|
+
write(".claude/skills/play/SKILL.md", `---
|
|
2101
|
+
name: play
|
|
2102
|
+
description: Start or resume the ${meta.name} experience.
|
|
2103
|
+
---
|
|
2104
|
+
|
|
2105
|
+
# Start / Resume
|
|
2106
|
+
|
|
2107
|
+
1. Read \`engine/rules.md\` and \`engine/narrator.md\`
|
|
2108
|
+
2. Read \`.player/progress.yaml\` to check player state
|
|
2109
|
+
3. Welcome them and guide them to the right starting point
|
|
2110
|
+
|
|
2111
|
+
$ARGUMENTS
|
|
2112
|
+
`);
|
|
2113
|
+
write(".claude/skills/hint/SKILL.md", `---
|
|
2114
|
+
name: hint
|
|
2115
|
+
description: Give a progressive hint for the current challenge in ${meta.name}.
|
|
2116
|
+
---
|
|
2117
|
+
|
|
2118
|
+
# Give a Hint
|
|
2119
|
+
|
|
2120
|
+
1. Check \`.player/progress.yaml\` for the current challenge
|
|
2121
|
+
2. Give a hint at the appropriate level (conceptual \u2192 directional \u2192 near-solution)
|
|
2122
|
+
3. Do NOT give the solution directly
|
|
2123
|
+
|
|
2124
|
+
$ARGUMENTS
|
|
2125
|
+
`);
|
|
2126
|
+
write(".claude/skills/next/SKILL.md", `---
|
|
2127
|
+
name: next
|
|
2128
|
+
description: Move to the next lesson or challenge in ${meta.name}.
|
|
2129
|
+
---
|
|
2130
|
+
|
|
2131
|
+
# Next Lesson
|
|
2132
|
+
|
|
2133
|
+
1. Read \`.player/progress.yaml\` to find current position
|
|
2134
|
+
2. Present the next lesson or challenge
|
|
2135
|
+
3. Update progress.yaml
|
|
2136
|
+
|
|
2137
|
+
$ARGUMENTS
|
|
2138
|
+
`);
|
|
2139
|
+
write(".claude/skills/progress/SKILL.md", `---
|
|
2140
|
+
name: progress
|
|
2141
|
+
description: Show the user's progress in ${meta.name}.
|
|
2142
|
+
---
|
|
2143
|
+
|
|
2144
|
+
# Show Progress
|
|
2145
|
+
|
|
2146
|
+
1. Read \`.player/progress.yaml\`
|
|
2147
|
+
2. Show chapters completed, current position, and time spent
|
|
2148
|
+
|
|
2149
|
+
$ARGUMENTS
|
|
2150
|
+
`);
|
|
2151
|
+
write(".claude/skills/verify/SKILL.md", `---
|
|
2152
|
+
name: verify
|
|
2153
|
+
description: Verify the user's solution to the current challenge in ${meta.name}.
|
|
2154
|
+
---
|
|
2155
|
+
|
|
2156
|
+
# Verify Solution
|
|
2157
|
+
|
|
2158
|
+
1. Read \`.player/progress.yaml\` for current challenge
|
|
2159
|
+
2. Ask user to show their work
|
|
2160
|
+
3. Compare against expected outcome \u2014 celebrate if correct, hint if not
|
|
2161
|
+
|
|
2162
|
+
$ARGUMENTS
|
|
2163
|
+
`);
|
|
2164
|
+
write("engine/rules.md", `# ${meta.name} \u2014 Rules
|
|
2165
|
+
|
|
2166
|
+
## Teaching Mode
|
|
2167
|
+
|
|
2168
|
+
- Explain concepts clearly with examples
|
|
2169
|
+
- Ask questions to check understanding
|
|
2170
|
+
|
|
2171
|
+
## Challenge Mode
|
|
2172
|
+
|
|
2173
|
+
- NEVER give the answer directly
|
|
2174
|
+
- Use progressive hints (conceptual \u2192 directional \u2192 near-solution)
|
|
2175
|
+
- Celebrate when they solve it
|
|
2176
|
+
`);
|
|
2177
|
+
write("engine/narrator.md", `# ${meta.name} \u2014 Narrator
|
|
2178
|
+
|
|
2179
|
+
## Persona
|
|
2180
|
+
|
|
2181
|
+
- **Tone**: Encouraging, patient, knowledgeable
|
|
2182
|
+
- **Style**: Socratic \u2014 ask questions, don't just lecture
|
|
2183
|
+
|
|
2184
|
+
## Story Context
|
|
2185
|
+
|
|
2186
|
+
${meta.description}
|
|
2187
|
+
`);
|
|
2188
|
+
write("engine/validation.md", `# ${meta.name} \u2014 Validation
|
|
2189
|
+
|
|
2190
|
+
When the user asks to verify their work:
|
|
2191
|
+
|
|
2192
|
+
1. Read the current challenge requirements
|
|
2193
|
+
2. Ask the user to show their result
|
|
2194
|
+
3. Compare and give feedback \u2014 celebrate correct, hint if not
|
|
2195
|
+
4. Update \`.player/progress.yaml\` on success
|
|
2196
|
+
`);
|
|
2197
|
+
write("content/ch01-getting-started/README.md", `# Chapter 1: Getting Started
|
|
2198
|
+
|
|
2199
|
+
> Part of [${meta.name}](../../hubbit.yaml)
|
|
2200
|
+
|
|
2201
|
+
## Overview
|
|
2202
|
+
|
|
2203
|
+
[TODO: Write your chapter overview here.]
|
|
2204
|
+
|
|
2205
|
+
## Lessons
|
|
2206
|
+
|
|
2207
|
+
### Lesson 1: Introduction
|
|
2208
|
+
|
|
2209
|
+
[TODO: Write your first lesson here.]
|
|
2210
|
+
|
|
2211
|
+
## Challenge
|
|
2212
|
+
|
|
2213
|
+
See the \`challenges/\` directory.
|
|
2214
|
+
`);
|
|
2215
|
+
write("content/ch01-getting-started/challenges/.gitkeep", "");
|
|
2216
|
+
write(".player/progress.yaml.template", `version: 1
|
|
2217
|
+
|
|
2218
|
+
player:
|
|
2219
|
+
started_at: null
|
|
2220
|
+
last_played: null
|
|
2221
|
+
language: auto
|
|
2222
|
+
|
|
2223
|
+
chapters:
|
|
2224
|
+
ch01-getting-started:
|
|
2225
|
+
status: not_started
|
|
2226
|
+
lessons:
|
|
2227
|
+
lesson_01: not_started
|
|
2228
|
+
challenges:
|
|
2229
|
+
challenge_01: not_started
|
|
2230
|
+
`);
|
|
2231
|
+
write(".gitignore", `.player/progress.yaml
|
|
2232
|
+
!.player/progress.yaml.template
|
|
2233
|
+
.DS_Store
|
|
2234
|
+
Thumbs.db
|
|
2235
|
+
`);
|
|
2236
|
+
write("hubbit.yaml", generateManifest2(meta));
|
|
2237
|
+
write("README.md", `# ${meta.name}
|
|
2238
|
+
|
|
2239
|
+
> ${meta.description}
|
|
2240
|
+
|
|
2241
|
+
Open this project in your AI editor and type **"let's play"** to begin.
|
|
2242
|
+
|
|
2243
|
+
## How It Works
|
|
2244
|
+
|
|
2245
|
+
1. Pull this package: \`hubbits pull <scope>/${meta.name}\`
|
|
2246
|
+
2. Open in your AI editor (Claude Code, Cursor, Windsurf, Copilot, etc.)
|
|
2247
|
+
3. Type **"let's play"**
|
|
2248
|
+
|
|
2249
|
+
## License
|
|
2250
|
+
|
|
2251
|
+
MIT
|
|
2252
|
+
`);
|
|
2253
|
+
return files;
|
|
2254
|
+
}
|
|
2255
|
+
function generateManifest2(meta) {
|
|
2256
|
+
const authorLine = meta.author ? `author: "${meta.author}"` : '# author: "Your Name"';
|
|
2257
|
+
return `# Hubbit Manifest
|
|
2258
|
+
# Documentation: https://hubbits.dev/docs/manifest
|
|
2259
|
+
|
|
2260
|
+
spec_version: 1
|
|
2261
|
+
name: "${meta.name}"
|
|
2262
|
+
version: "${meta.version}"
|
|
2263
|
+
${authorLine}
|
|
2264
|
+
description: "${meta.description.replace(/"/g, '\\"')}"
|
|
2265
|
+
|
|
2266
|
+
category: ${meta.category}
|
|
2267
|
+
difficulty: ${meta.difficulty}
|
|
2268
|
+
language: auto
|
|
2269
|
+
${meta.pricing === "paid" ? "" : "# "}estimated_time: "1-2 hours"
|
|
2270
|
+
license: MIT
|
|
2271
|
+
|
|
2272
|
+
tags:
|
|
2273
|
+
- ${meta.category}
|
|
2274
|
+
# Add more tags here
|
|
2275
|
+
|
|
2276
|
+
pricing: ${meta.pricing}
|
|
2277
|
+
|
|
2278
|
+
# GitHub repository (required for CLI publish)
|
|
2279
|
+
# repository: "https://github.com/<you>/${meta.name}"
|
|
2280
|
+
|
|
2281
|
+
# Compatible AI editors
|
|
2282
|
+
compatible_with:
|
|
2283
|
+
- claude-code
|
|
2284
|
+
- cursor
|
|
2285
|
+
- windsurf
|
|
2286
|
+
- copilot
|
|
2287
|
+
|
|
2288
|
+
# AI persona configuration
|
|
2289
|
+
persona:
|
|
2290
|
+
name: "Guide"
|
|
2291
|
+
tone: "encouraging"
|
|
2292
|
+
traits:
|
|
2293
|
+
- "patient"
|
|
2294
|
+
- "knowledgeable"
|
|
2295
|
+
constraints:
|
|
2296
|
+
- "Never give answers directly; guide with hints"
|
|
2297
|
+
- "Celebrate progress"
|
|
2298
|
+
`;
|
|
2299
|
+
}
|
|
2300
|
+
function printPostScaffoldGuide(name) {
|
|
2301
|
+
const divider = pc5.dim("\u2500".repeat(56));
|
|
2302
|
+
console.log(pc5.bold(" Next: publish your hubbit on GitHub"));
|
|
2303
|
+
newline();
|
|
2304
|
+
console.log(divider);
|
|
2305
|
+
newline();
|
|
2306
|
+
console.log(` ${pc5.cyan(pc5.bold("Step 1"))} Commit and push to GitHub`);
|
|
2307
|
+
newline();
|
|
2308
|
+
console.log(` ${pc5.yellow(`git add . && git commit -m "feat: init ${name}"`)}`);
|
|
2309
|
+
console.log(` ${pc5.yellow(`git push -u origin main`)}`);
|
|
2310
|
+
newline();
|
|
2311
|
+
console.log(divider);
|
|
2312
|
+
newline();
|
|
2313
|
+
console.log(` ${pc5.cyan(pc5.bold("Step 2"))} Publish to Hubbits`);
|
|
2314
|
+
newline();
|
|
2315
|
+
console.log(` ${pc5.yellow(`hubbits publish`)}`);
|
|
2316
|
+
newline();
|
|
2317
|
+
console.log(divider);
|
|
2318
|
+
newline();
|
|
2319
|
+
hint("Validate your manifest at any time: `hubbits validate`");
|
|
2320
|
+
newline();
|
|
2321
|
+
}
|
|
2322
|
+
function isValidName2(name) {
|
|
2323
|
+
if (!name || name.length === 0 || name.length > 214) return false;
|
|
2324
|
+
return /^[a-z][a-z0-9-]*[a-z0-9]$/.test(name) || /^[a-z]$/.test(name);
|
|
2325
|
+
}
|
|
2326
|
+
var VALID_CATEGORIES = ["learning", "entertainment", "productivity", "wellness"];
|
|
2327
|
+
var VALID_DIFFICULTIES = ["beginner", "intermediate", "advanced", "beginner-to-intermediate", "intermediate-to-advanced"];
|
|
2328
|
+
var VALID_PRICING = ["free", "paid", "freemium"];
|
|
2329
|
+
var VALID_MODALITIES = ["text", "image", "audio"];
|
|
2330
|
+
var VALID_FEATURES = ["tool_use", "vision", "code_execution"];
|
|
2331
|
+
var VALID_CURRICULUM_TYPES = ["linear", "branching", "open-world"];
|
|
2332
|
+
var NAME_PATTERN = /^[a-z][a-z0-9-]*[a-z0-9]$/;
|
|
2333
|
+
var SINGLE_CHAR_NAME = /^[a-z]$/;
|
|
2334
|
+
var SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?(\+[a-zA-Z0-9.]+)?$/;
|
|
2335
|
+
var validate_default = defineCommand({
|
|
2336
|
+
meta: {
|
|
2337
|
+
name: "validate",
|
|
2338
|
+
description: "Validate a hubbit.yaml manifest"
|
|
2339
|
+
},
|
|
2340
|
+
args: {
|
|
2341
|
+
path: {
|
|
2342
|
+
type: "positional",
|
|
2343
|
+
description: "Path to hubbit.yaml or directory containing it",
|
|
2344
|
+
required: false
|
|
2345
|
+
},
|
|
2346
|
+
strict: {
|
|
2347
|
+
type: "boolean",
|
|
2348
|
+
description: "Treat warnings as errors (for publishing)",
|
|
2349
|
+
default: false
|
|
2350
|
+
},
|
|
2351
|
+
json: {
|
|
2352
|
+
type: "boolean",
|
|
2353
|
+
description: "Output as JSON",
|
|
2354
|
+
default: false
|
|
2355
|
+
},
|
|
2356
|
+
quiet: {
|
|
2357
|
+
type: "boolean",
|
|
2358
|
+
description: "Suppress output",
|
|
2359
|
+
default: false
|
|
2360
|
+
},
|
|
2361
|
+
verbose: {
|
|
2362
|
+
type: "boolean",
|
|
2363
|
+
description: "Show debug information",
|
|
2364
|
+
default: false
|
|
2365
|
+
}
|
|
2366
|
+
},
|
|
2367
|
+
async run({ args }) {
|
|
2368
|
+
setOutputMode({ json: args.json, quiet: args.quiet, verbose: args.verbose });
|
|
2369
|
+
const inputPath = args.path ?? process.cwd();
|
|
2370
|
+
const resolvedPath = resolve(inputPath);
|
|
2371
|
+
const yamlPath = resolvedPath.endsWith(".yaml") || resolvedPath.endsWith(".yml") ? resolvedPath : existsSync(join(resolvedPath, "hubbit.yaml")) ? join(resolvedPath, "hubbit.yaml") : join(resolvedPath, "hubbit.yml");
|
|
2372
|
+
debug(`Validating: ${yamlPath}`);
|
|
2373
|
+
if (!existsSync(yamlPath)) {
|
|
2374
|
+
if (isJsonMode()) {
|
|
2375
|
+
outputJson({ valid: false, errors: [{ field: "file", message: "hubbit.yaml not found" }], warnings: [] });
|
|
2376
|
+
process.exit(1);
|
|
2377
|
+
}
|
|
2378
|
+
errorWithFix(
|
|
2379
|
+
`hubbit.yaml not found in ${basename(resolvedPath) === "hubbit.yaml" ? "the specified path" : resolvedPath}`,
|
|
2380
|
+
"Run `hubbits create` to scaffold a new Hubbit, or check you are in the correct directory."
|
|
2381
|
+
);
|
|
2382
|
+
process.exit(1);
|
|
2383
|
+
}
|
|
2384
|
+
let rawContent;
|
|
2385
|
+
let parsed;
|
|
2386
|
+
try {
|
|
2387
|
+
rawContent = readFileSync(yamlPath, "utf-8");
|
|
2388
|
+
} catch {
|
|
2389
|
+
errorWithFix("Failed to read hubbit.yaml.", "Check file permissions.");
|
|
2390
|
+
process.exit(1);
|
|
2391
|
+
}
|
|
2392
|
+
try {
|
|
2393
|
+
parsed = yaml.load(rawContent, { schema: yaml.FAILSAFE_SCHEMA });
|
|
2394
|
+
} catch (err) {
|
|
2395
|
+
const yamlError = err;
|
|
2396
|
+
if (isJsonMode()) {
|
|
2397
|
+
outputJson({ valid: false, errors: [{ field: "yaml", message: yamlError.message }], warnings: [] });
|
|
2398
|
+
process.exit(1);
|
|
2399
|
+
}
|
|
2400
|
+
errorWithFix(
|
|
2401
|
+
`Invalid YAML syntax: ${yamlError.message}`,
|
|
2402
|
+
"Check your hubbit.yaml for syntax errors (indentation, colons, quotes)."
|
|
2403
|
+
);
|
|
2404
|
+
process.exit(1);
|
|
2405
|
+
}
|
|
2406
|
+
if (!parsed || typeof parsed !== "object") {
|
|
2407
|
+
if (isJsonMode()) {
|
|
2408
|
+
outputJson({ valid: false, errors: [{ field: "yaml", message: "hubbit.yaml is empty or not an object" }], warnings: [] });
|
|
2409
|
+
process.exit(1);
|
|
2410
|
+
}
|
|
2411
|
+
errorWithFix(
|
|
2412
|
+
"hubbit.yaml is empty or not a valid manifest.",
|
|
2413
|
+
"A hubbit.yaml must contain at least `spec_version` and `name`."
|
|
2414
|
+
);
|
|
2415
|
+
process.exit(1);
|
|
2416
|
+
}
|
|
2417
|
+
const manifest = parsed;
|
|
2418
|
+
const result = validateManifest(manifest);
|
|
2419
|
+
if (args.strict) {
|
|
2420
|
+
for (const w of result.warnings) {
|
|
2421
|
+
result.errors.push({ ...w, severity: "error" });
|
|
2422
|
+
}
|
|
2423
|
+
result.warnings = [];
|
|
2424
|
+
result.valid = result.errors.length === 0;
|
|
2425
|
+
}
|
|
2426
|
+
if (isJsonMode()) {
|
|
2427
|
+
outputJson({
|
|
2428
|
+
valid: result.valid,
|
|
2429
|
+
errors: result.errors.map((e) => ({ field: e.field, message: e.message })),
|
|
2430
|
+
warnings: result.warnings.map((w) => ({ field: w.field, message: w.message })),
|
|
2431
|
+
manifest: result.valid ? result.manifest : void 0
|
|
2432
|
+
});
|
|
2433
|
+
if (!result.valid) process.exit(1);
|
|
2434
|
+
return;
|
|
2435
|
+
}
|
|
2436
|
+
if (result.errors.length > 0) {
|
|
2437
|
+
error(`Validation failed with ${result.errors.length} error${result.errors.length === 1 ? "" : "s"}:`);
|
|
2438
|
+
newline();
|
|
2439
|
+
for (const err of result.errors) {
|
|
2440
|
+
console.log(` ${pc5.red("\u2717")} ${pc5.bold(err.field)}: ${err.message}`);
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
if (result.warnings.length > 0) {
|
|
2444
|
+
if (result.errors.length > 0) newline();
|
|
2445
|
+
warning(`${result.warnings.length} warning${result.warnings.length === 1 ? "" : "s"}:`);
|
|
2446
|
+
newline();
|
|
2447
|
+
for (const w of result.warnings) {
|
|
2448
|
+
console.log(` ${pc5.yellow("\u26A0")} ${pc5.bold(w.field)}: ${w.message}`);
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
if (result.valid && result.manifest) {
|
|
2452
|
+
const m = result.manifest;
|
|
2453
|
+
const nameVersion = m.version ? `${m.name}@${m.version}` : m.name;
|
|
2454
|
+
newline();
|
|
2455
|
+
success(`Valid hubbit.yaml (${pc5.bold(nameVersion)})`);
|
|
2456
|
+
if (result.warnings.length > 0) {
|
|
2457
|
+
hint("Fix warnings before publishing with `hubbits publish`.");
|
|
2458
|
+
} else {
|
|
2459
|
+
hint("Ready to publish? Run `hubbits publish`");
|
|
2460
|
+
}
|
|
2461
|
+
} else {
|
|
2462
|
+
newline();
|
|
2463
|
+
hint("Fix the errors above and run `hubbits validate` again.");
|
|
2464
|
+
process.exit(1);
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
});
|
|
2468
|
+
function validateManifest(raw) {
|
|
2469
|
+
const errors = [];
|
|
2470
|
+
const warnings = [];
|
|
2471
|
+
coerceTypes(raw);
|
|
2472
|
+
if (raw.spec_version === void 0) {
|
|
2473
|
+
errors.push({ field: "spec_version", message: "Required. Must be 1.", severity: "error" });
|
|
2474
|
+
} else if (raw.spec_version !== 1) {
|
|
2475
|
+
errors.push({ field: "spec_version", message: `Must be 1, got ${JSON.stringify(raw.spec_version)}.`, severity: "error" });
|
|
2476
|
+
}
|
|
2477
|
+
if (!raw.name || typeof raw.name !== "string") {
|
|
2478
|
+
errors.push({ field: "name", message: "Required. Must be a non-empty string.", severity: "error" });
|
|
2479
|
+
} else {
|
|
2480
|
+
const name = raw.name;
|
|
2481
|
+
if (name.length > 214) {
|
|
2482
|
+
errors.push({ field: "name", message: "Must be 214 characters or fewer.", severity: "error" });
|
|
2483
|
+
} else if (!NAME_PATTERN.test(name) && !SINGLE_CHAR_NAME.test(name)) {
|
|
2484
|
+
errors.push({ field: "name", message: "Must be lowercase letters, numbers, and hyphens. Must start with a letter and not end with a hyphen.", severity: "error" });
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
if (raw.version !== void 0) {
|
|
2488
|
+
if (typeof raw.version !== "string") {
|
|
2489
|
+
errors.push({ field: "version", message: "Must be a string.", severity: "error" });
|
|
2490
|
+
} else if (!SEMVER_PATTERN.test(raw.version)) {
|
|
2491
|
+
errors.push({ field: "version", message: `Must be valid SemVer (e.g., "1.0.0"), got "${raw.version}".`, severity: "error" });
|
|
2492
|
+
}
|
|
2493
|
+
} else {
|
|
2494
|
+
warnings.push({ field: "version", message: "Missing. Required for publishing.", severity: "warning" });
|
|
2495
|
+
}
|
|
2496
|
+
if (raw.author !== void 0) {
|
|
2497
|
+
if (typeof raw.author !== "string" && typeof raw.author !== "object") {
|
|
2498
|
+
errors.push({ field: "author", message: "Must be a string or object { name, url?, email? }.", severity: "error" });
|
|
2499
|
+
} else if (typeof raw.author === "object" && raw.author !== null) {
|
|
2500
|
+
const a = raw.author;
|
|
2501
|
+
if (!a.name || typeof a.name !== "string") {
|
|
2502
|
+
errors.push({ field: "author.name", message: "Required when author is an object.", severity: "error" });
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
} else {
|
|
2506
|
+
warnings.push({ field: "author", message: "Missing. Required for publishing.", severity: "warning" });
|
|
2507
|
+
}
|
|
2508
|
+
if (raw.description !== void 0) {
|
|
2509
|
+
if (typeof raw.description !== "string") {
|
|
2510
|
+
errors.push({ field: "description", message: "Must be a string.", severity: "error" });
|
|
2511
|
+
} else if (raw.description.length > 500) {
|
|
2512
|
+
errors.push({ field: "description", message: "Must be 500 characters or fewer.", severity: "error" });
|
|
2513
|
+
}
|
|
2514
|
+
} else {
|
|
2515
|
+
warnings.push({ field: "description", message: "Missing. Required for publishing.", severity: "warning" });
|
|
2516
|
+
}
|
|
2517
|
+
if (raw.category !== void 0) {
|
|
2518
|
+
if (!VALID_CATEGORIES.includes(raw.category)) {
|
|
2519
|
+
errors.push({ field: "category", message: `Must be one of: ${VALID_CATEGORIES.join(", ")}. Got "${raw.category}".`, severity: "error" });
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
if (raw.difficulty !== void 0) {
|
|
2523
|
+
if (!VALID_DIFFICULTIES.includes(raw.difficulty)) {
|
|
2524
|
+
errors.push({ field: "difficulty", message: `Must be one of: ${VALID_DIFFICULTIES.join(", ")}. Got "${raw.difficulty}".`, severity: "error" });
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
if (raw.pricing !== void 0) {
|
|
2528
|
+
if (!VALID_PRICING.includes(raw.pricing)) {
|
|
2529
|
+
errors.push({ field: "pricing", message: `Must be one of: ${VALID_PRICING.join(", ")}. Got "${raw.pricing}".`, severity: "error" });
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
if (raw.tags !== void 0) {
|
|
2533
|
+
if (!Array.isArray(raw.tags)) {
|
|
2534
|
+
errors.push({ field: "tags", message: "Must be an array of strings.", severity: "error" });
|
|
2535
|
+
} else {
|
|
2536
|
+
if (raw.tags.length > 30) {
|
|
2537
|
+
errors.push({ field: "tags", message: "Must have 30 or fewer tags.", severity: "error" });
|
|
2538
|
+
}
|
|
2539
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2540
|
+
for (const tag of raw.tags) {
|
|
2541
|
+
if (typeof tag !== "string") {
|
|
2542
|
+
errors.push({ field: "tags", message: `Each tag must be a string. Got ${JSON.stringify(tag)}.`, severity: "error" });
|
|
2543
|
+
break;
|
|
2544
|
+
}
|
|
2545
|
+
if (!/^[a-z0-9-]+$/.test(tag)) {
|
|
2546
|
+
errors.push({ field: "tags", message: `Tag "${tag}" must be lowercase letters, numbers, and hyphens.`, severity: "error" });
|
|
2547
|
+
}
|
|
2548
|
+
if (seen.has(tag)) {
|
|
2549
|
+
warnings.push({ field: "tags", message: `Duplicate tag: "${tag}".`, severity: "warning" });
|
|
2550
|
+
}
|
|
2551
|
+
seen.add(tag);
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
if (raw.language !== void 0) {
|
|
2556
|
+
if (typeof raw.language !== "string") {
|
|
2557
|
+
errors.push({ field: "language", message: 'Must be a string ("auto" or ISO 639-1 code).', severity: "error" });
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
if (raw.estimated_time !== void 0) {
|
|
2561
|
+
if (typeof raw.estimated_time !== "string") {
|
|
2562
|
+
errors.push({ field: "estimated_time", message: 'Must be a string (e.g., "10-15 hours").', severity: "error" });
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
if (raw.runtime !== void 0) {
|
|
2566
|
+
if (typeof raw.runtime !== "object" || raw.runtime === null) {
|
|
2567
|
+
errors.push({ field: "runtime", message: "Must be an object.", severity: "error" });
|
|
2568
|
+
} else {
|
|
2569
|
+
const rt = raw.runtime;
|
|
2570
|
+
if (rt.local !== void 0 && typeof rt.local !== "boolean") {
|
|
2571
|
+
errors.push({ field: "runtime.local", message: "Must be a boolean.", severity: "error" });
|
|
2572
|
+
}
|
|
2573
|
+
if (rt.cloud !== void 0 && typeof rt.cloud !== "boolean") {
|
|
2574
|
+
errors.push({ field: "runtime.cloud", message: "Must be a boolean.", severity: "error" });
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
if (raw.engines !== void 0) {
|
|
2579
|
+
if (typeof raw.engines !== "object" || raw.engines === null) {
|
|
2580
|
+
errors.push({ field: "engines", message: "Must be an object.", severity: "error" });
|
|
2581
|
+
} else {
|
|
2582
|
+
const eng = raw.engines;
|
|
2583
|
+
if (eng.ai !== void 0) {
|
|
2584
|
+
if (typeof eng.ai !== "object" || eng.ai === null) {
|
|
2585
|
+
errors.push({ field: "engines.ai", message: "Must be an object.", severity: "error" });
|
|
2586
|
+
} else {
|
|
2587
|
+
const ai = eng.ai;
|
|
2588
|
+
if (ai.min_context_window !== void 0 && typeof ai.min_context_window !== "number") {
|
|
2589
|
+
errors.push({ field: "engines.ai.min_context_window", message: "Must be a number.", severity: "error" });
|
|
2590
|
+
}
|
|
2591
|
+
if (ai.modalities !== void 0) {
|
|
2592
|
+
if (!Array.isArray(ai.modalities)) {
|
|
2593
|
+
errors.push({ field: "engines.ai.modalities", message: "Must be an array.", severity: "error" });
|
|
2594
|
+
} else {
|
|
2595
|
+
for (const m of ai.modalities) {
|
|
2596
|
+
if (!VALID_MODALITIES.includes(m)) {
|
|
2597
|
+
errors.push({ field: "engines.ai.modalities", message: `Invalid modality: "${m}". Must be one of: ${VALID_MODALITIES.join(", ")}.`, severity: "error" });
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
if (ai.features !== void 0) {
|
|
2603
|
+
if (!Array.isArray(ai.features)) {
|
|
2604
|
+
errors.push({ field: "engines.ai.features", message: "Must be an array.", severity: "error" });
|
|
2605
|
+
} else {
|
|
2606
|
+
for (const f of ai.features) {
|
|
2607
|
+
if (!VALID_FEATURES.includes(f)) {
|
|
2608
|
+
errors.push({ field: "engines.ai.features", message: `Invalid feature: "${f}". Must be one of: ${VALID_FEATURES.join(", ")}.`, severity: "error" });
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
if (raw.persona !== void 0) {
|
|
2618
|
+
if (typeof raw.persona !== "object" || raw.persona === null) {
|
|
2619
|
+
errors.push({ field: "persona", message: "Must be an object.", severity: "error" });
|
|
2620
|
+
} else {
|
|
2621
|
+
const p7 = raw.persona;
|
|
2622
|
+
if (p7.name !== void 0 && typeof p7.name !== "string") {
|
|
2623
|
+
errors.push({ field: "persona.name", message: "Must be a string.", severity: "error" });
|
|
2624
|
+
}
|
|
2625
|
+
if (p7.traits !== void 0 && !Array.isArray(p7.traits)) {
|
|
2626
|
+
errors.push({ field: "persona.traits", message: "Must be an array of strings.", severity: "error" });
|
|
2627
|
+
}
|
|
2628
|
+
if (p7.constraints !== void 0 && !Array.isArray(p7.constraints)) {
|
|
2629
|
+
errors.push({ field: "persona.constraints", message: "Must be an array of strings.", severity: "error" });
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
if (raw.curriculum !== void 0) {
|
|
2634
|
+
if (typeof raw.curriculum !== "object" || raw.curriculum === null) {
|
|
2635
|
+
errors.push({ field: "curriculum", message: "Must be an object.", severity: "error" });
|
|
2636
|
+
} else {
|
|
2637
|
+
const cur = raw.curriculum;
|
|
2638
|
+
if (cur.type !== void 0 && !VALID_CURRICULUM_TYPES.includes(cur.type)) {
|
|
2639
|
+
errors.push({ field: "curriculum.type", message: `Must be one of: ${VALID_CURRICULUM_TYPES.join(", ")}. Got "${cur.type}".`, severity: "error" });
|
|
2640
|
+
}
|
|
2641
|
+
if (cur.chapters !== void 0) {
|
|
2642
|
+
if (!Array.isArray(cur.chapters)) {
|
|
2643
|
+
errors.push({ field: "curriculum.chapters", message: "Must be an array.", severity: "error" });
|
|
2644
|
+
} else {
|
|
2645
|
+
const chapterIds = /* @__PURE__ */ new Set();
|
|
2646
|
+
for (let i = 0; i < cur.chapters.length; i++) {
|
|
2647
|
+
const ch = cur.chapters[i];
|
|
2648
|
+
if (!ch.id || typeof ch.id !== "string") {
|
|
2649
|
+
errors.push({ field: `curriculum.chapters[${i}].id`, message: "Required. Must be a string.", severity: "error" });
|
|
2650
|
+
} else {
|
|
2651
|
+
if (chapterIds.has(ch.id)) {
|
|
2652
|
+
errors.push({ field: `curriculum.chapters[${i}].id`, message: `Duplicate chapter ID: "${ch.id}".`, severity: "error" });
|
|
2653
|
+
}
|
|
2654
|
+
chapterIds.add(ch.id);
|
|
2655
|
+
}
|
|
2656
|
+
if (!ch.title || typeof ch.title !== "string") {
|
|
2657
|
+
errors.push({ field: `curriculum.chapters[${i}].title`, message: "Required. Must be a string.", severity: "error" });
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
if (raw.compatible_with !== void 0) {
|
|
2665
|
+
if (!Array.isArray(raw.compatible_with)) {
|
|
2666
|
+
errors.push({ field: "compatible_with", message: "Must be an array of strings.", severity: "error" });
|
|
2667
|
+
} else {
|
|
2668
|
+
for (const c of raw.compatible_with) {
|
|
2669
|
+
if (typeof c !== "string") {
|
|
2670
|
+
errors.push({ field: "compatible_with", message: "Each item must be a string.", severity: "error" });
|
|
2671
|
+
break;
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
return {
|
|
2677
|
+
valid: errors.length === 0,
|
|
2678
|
+
errors,
|
|
2679
|
+
warnings,
|
|
2680
|
+
manifest: errors.length === 0 ? raw : void 0
|
|
2681
|
+
};
|
|
2682
|
+
}
|
|
2683
|
+
function coerceTypes(raw) {
|
|
2684
|
+
if (typeof raw.spec_version === "string") {
|
|
2685
|
+
const n = Number(raw.spec_version);
|
|
2686
|
+
if (!Number.isNaN(n)) raw.spec_version = n;
|
|
2687
|
+
}
|
|
2688
|
+
if (raw.runtime && typeof raw.runtime === "object") {
|
|
2689
|
+
const rt = raw.runtime;
|
|
2690
|
+
coerceBool(rt, "local");
|
|
2691
|
+
coerceBool(rt, "cloud");
|
|
2692
|
+
if (rt.cloud_requires && typeof rt.cloud_requires === "object") {
|
|
2693
|
+
coerceBool(rt.cloud_requires, "sandbox");
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
if (raw.engines && typeof raw.engines === "object") {
|
|
2697
|
+
const eng = raw.engines;
|
|
2698
|
+
if (eng.ai && typeof eng.ai === "object") {
|
|
2699
|
+
const ai = eng.ai;
|
|
2700
|
+
coerceNum(ai, "min_context_window");
|
|
2701
|
+
coerceNum(ai, "recommended_context_window");
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
if (raw.curriculum && typeof raw.curriculum === "object") {
|
|
2705
|
+
const cur = raw.curriculum;
|
|
2706
|
+
if (Array.isArray(cur.chapters)) {
|
|
2707
|
+
for (const ch of cur.chapters) {
|
|
2708
|
+
if (ch && typeof ch === "object") {
|
|
2709
|
+
const chapter = ch;
|
|
2710
|
+
coerceNum(chapter, "lessons");
|
|
2711
|
+
coerceNum(chapter, "challenges");
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
function coerceBool(obj, key) {
|
|
2718
|
+
if (typeof obj[key] === "string") {
|
|
2719
|
+
if (obj[key] === "true") obj[key] = true;
|
|
2720
|
+
else if (obj[key] === "false") obj[key] = false;
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
function coerceNum(obj, key) {
|
|
2724
|
+
if (typeof obj[key] === "string") {
|
|
2725
|
+
const n = Number(obj[key]);
|
|
2726
|
+
if (!Number.isNaN(n)) obj[key] = n;
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
var AuthorObjectSchema = z.object({
|
|
2730
|
+
name: z.string().min(1),
|
|
2731
|
+
url: z.string().url().optional(),
|
|
2732
|
+
email: z.string().email().optional()
|
|
2733
|
+
});
|
|
2734
|
+
var AuthorSchema = z.union([
|
|
2735
|
+
z.string().min(1),
|
|
2736
|
+
AuthorObjectSchema
|
|
2737
|
+
]);
|
|
2738
|
+
var CloudRequiresSchema = z.object({
|
|
2739
|
+
sandbox: z.preprocess((v) => v === "true" || v === true, z.boolean()).optional()
|
|
2740
|
+
});
|
|
2741
|
+
var RuntimeSchema = z.object({
|
|
2742
|
+
local: z.preprocess((v) => v === "true" || v === true, z.boolean()).optional(),
|
|
2743
|
+
cloud: z.preprocess((v) => v === "true" || v === true, z.boolean()).optional(),
|
|
2744
|
+
cloud_requires: CloudRequiresSchema.optional()
|
|
2745
|
+
});
|
|
2746
|
+
var AIModalitySchema = z.enum(["text", "image", "audio"]);
|
|
2747
|
+
var AIFeatureSchema = z.enum(["tool_use", "vision", "code_execution"]);
|
|
2748
|
+
var AIEngineSchema = z.object({
|
|
2749
|
+
min_context_window: z.coerce.number().int().min(1).optional(),
|
|
2750
|
+
recommended_context_window: z.coerce.number().int().min(1).optional(),
|
|
2751
|
+
modalities: z.array(AIModalitySchema).optional(),
|
|
2752
|
+
features: z.array(AIFeatureSchema).optional()
|
|
2753
|
+
});
|
|
2754
|
+
var EnginesSchema = z.object({
|
|
2755
|
+
ai: AIEngineSchema.optional()
|
|
2756
|
+
});
|
|
2757
|
+
var RequiresSchema = z.object({
|
|
2758
|
+
tools: z.array(z.string().min(1)).optional(),
|
|
2759
|
+
optional: z.array(z.string().min(1)).optional()
|
|
2760
|
+
});
|
|
2761
|
+
var PersonaSchema = z.object({
|
|
2762
|
+
name: z.string().min(1).optional(),
|
|
2763
|
+
tone: z.string().min(1).optional(),
|
|
2764
|
+
traits: z.array(z.string().min(1)).optional(),
|
|
2765
|
+
constraints: z.array(z.string().min(1)).optional()
|
|
2766
|
+
});
|
|
2767
|
+
var ChapterSchema = z.object({
|
|
2768
|
+
id: z.string().min(2).regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/),
|
|
2769
|
+
title: z.string().min(1),
|
|
2770
|
+
lessons: z.coerce.number().int().min(0).optional(),
|
|
2771
|
+
challenges: z.coerce.number().int().min(0).optional(),
|
|
2772
|
+
unlock_condition: z.string().nullable().optional()
|
|
2773
|
+
});
|
|
2774
|
+
var CurriculumSchema = z.object({
|
|
2775
|
+
type: z.enum(["linear", "branching", "open-world"]).optional(),
|
|
2776
|
+
chapters: z.array(ChapterSchema).optional()
|
|
2777
|
+
});
|
|
2778
|
+
var HubbitManifestSchema = z.object({
|
|
2779
|
+
/** Manifest 格式版本,目前唯一合法值為 1 */
|
|
2780
|
+
spec_version: z.coerce.number().pipe(z.literal(1)),
|
|
2781
|
+
/** 包名稱(小寫字母、數字、連字號) */
|
|
2782
|
+
name: z.string().min(2).max(214).regex(/^[a-z][a-z0-9-]*[a-z0-9]$/, "Name must start with a lowercase letter, end with a letter or digit, and contain only lowercase letters, digits, and hyphens"),
|
|
2783
|
+
/** 語意化版本號(SemVer 2.0.0) */
|
|
2784
|
+
version: z.string().regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/, "Version must be a valid SemVer 2.0.0 string").optional(),
|
|
2785
|
+
/** 包的作者 */
|
|
2786
|
+
author: AuthorSchema.optional(),
|
|
2787
|
+
/** 包的簡短描述 */
|
|
2788
|
+
description: z.string().min(1).max(500).optional(),
|
|
2789
|
+
/** SPDX 授權識別碼 */
|
|
2790
|
+
license: z.string().optional(),
|
|
2791
|
+
/** 搜尋用標籤 */
|
|
2792
|
+
tags: z.array(z.string().min(2).max(50).regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/)).max(30).optional(),
|
|
2793
|
+
/** 預定義的包分類 */
|
|
2794
|
+
category: z.enum(["learning", "entertainment", "productivity", "wellness"]).optional(),
|
|
2795
|
+
/** 內容難度級別 */
|
|
2796
|
+
difficulty: z.enum(["beginner", "intermediate", "advanced", "beginner-to-intermediate", "intermediate-to-advanced"]).optional(),
|
|
2797
|
+
/** 預估完成時間 */
|
|
2798
|
+
estimated_time: z.string().max(50).optional(),
|
|
2799
|
+
/** 內容語言 */
|
|
2800
|
+
language: z.string().optional(),
|
|
2801
|
+
/** 運行時支援設定 */
|
|
2802
|
+
runtime: RuntimeSchema.optional(),
|
|
2803
|
+
/** AI 模型運行環境需求 */
|
|
2804
|
+
engines: EnginesSchema.optional(),
|
|
2805
|
+
/** 系統工具依賴 */
|
|
2806
|
+
requires: RequiresSchema.optional(),
|
|
2807
|
+
/** 已測試相容的 AI 編輯器或客戶端列表 */
|
|
2808
|
+
compatible_with: z.array(z.string().min(1)).optional(),
|
|
2809
|
+
/** AI 角色定義 */
|
|
2810
|
+
persona: PersonaSchema.optional(),
|
|
2811
|
+
/** 課程或劇情結構 */
|
|
2812
|
+
curriculum: CurriculumSchema.optional(),
|
|
2813
|
+
/** 定價模式 */
|
|
2814
|
+
pricing: z.enum(["free", "paid", "freemium"]).optional(),
|
|
2815
|
+
/** 第三方工具的擴展資料命名空間 */
|
|
2816
|
+
extensions: z.record(z.unknown()).optional(),
|
|
2817
|
+
/** 原始碼倉庫 URL */
|
|
2818
|
+
repository: z.string().url().optional(),
|
|
2819
|
+
/** 包的首頁 URL */
|
|
2820
|
+
homepage: z.string().url().optional(),
|
|
2821
|
+
/** 圖示檔案的相對路徑 */
|
|
2822
|
+
icon: z.string().optional(),
|
|
2823
|
+
/** Banner 圖片的相對路徑 */
|
|
2824
|
+
banner: z.string().optional()
|
|
2825
|
+
});
|
|
2826
|
+
function parseManifest(yamlString) {
|
|
2827
|
+
if (/!![\w.]+/.test(yamlString)) {
|
|
2828
|
+
return {
|
|
2829
|
+
success: false,
|
|
2830
|
+
errors: ["YAML custom type tags (!! syntax) are not allowed for security reasons"]
|
|
2831
|
+
};
|
|
2832
|
+
}
|
|
2833
|
+
let raw;
|
|
2834
|
+
try {
|
|
2835
|
+
raw = yaml.load(yamlString, {
|
|
2836
|
+
schema: yaml.FAILSAFE_SCHEMA
|
|
2837
|
+
});
|
|
2838
|
+
} catch (err) {
|
|
2839
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2840
|
+
return {
|
|
2841
|
+
success: false,
|
|
2842
|
+
errors: [`YAML parse error: ${message}`]
|
|
2843
|
+
};
|
|
2844
|
+
}
|
|
2845
|
+
if (raw == null || typeof raw !== "object") {
|
|
2846
|
+
return {
|
|
2847
|
+
success: false,
|
|
2848
|
+
errors: ["YAML content must be a mapping (object)"]
|
|
2849
|
+
};
|
|
2850
|
+
}
|
|
2851
|
+
const result = HubbitManifestSchema.safeParse(raw);
|
|
2852
|
+
if (!result.success) {
|
|
2853
|
+
const errors = result.error.issues.map((issue) => {
|
|
2854
|
+
const path = issue.path.join(".");
|
|
2855
|
+
return path ? `${path}: ${issue.message}` : issue.message;
|
|
2856
|
+
});
|
|
2857
|
+
return { success: false, errors };
|
|
2858
|
+
}
|
|
2859
|
+
return {
|
|
2860
|
+
success: true,
|
|
2861
|
+
data: result.data,
|
|
2862
|
+
errors: []
|
|
2863
|
+
};
|
|
2864
|
+
}
|
|
2865
|
+
function validateManifest2(data) {
|
|
2866
|
+
const schemaResult = HubbitManifestSchema.safeParse(data);
|
|
2867
|
+
if (!schemaResult.success) {
|
|
2868
|
+
const errors = schemaResult.error.issues.map((issue) => {
|
|
2869
|
+
const path = issue.path.join(".");
|
|
2870
|
+
return path ? `${path}: ${issue.message}` : issue.message;
|
|
2871
|
+
});
|
|
2872
|
+
return { success: false, errors };
|
|
2873
|
+
}
|
|
2874
|
+
const manifest = schemaResult.data;
|
|
2875
|
+
const publishErrors = [];
|
|
2876
|
+
if (!manifest.version) {
|
|
2877
|
+
publishErrors.push("version is required for publishing");
|
|
2878
|
+
}
|
|
2879
|
+
if (!manifest.author) {
|
|
2880
|
+
publishErrors.push("author is required for publishing");
|
|
2881
|
+
}
|
|
2882
|
+
if (!manifest.description) {
|
|
2883
|
+
publishErrors.push("description is required for publishing");
|
|
2884
|
+
}
|
|
2885
|
+
if (publishErrors.length > 0) {
|
|
2886
|
+
return { success: false, data: manifest, errors: publishErrors };
|
|
2887
|
+
}
|
|
2888
|
+
return {
|
|
2889
|
+
success: true,
|
|
2890
|
+
data: manifest,
|
|
2891
|
+
errors: []
|
|
2892
|
+
};
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
// ../../packages/core/dist/hash.js
|
|
2896
|
+
async function sha256(data) {
|
|
2897
|
+
const input = typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data);
|
|
2898
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", input);
|
|
2899
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
2900
|
+
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
2901
|
+
}
|
|
2902
|
+
var MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
2903
|
+
var MAX_TOTAL_SIZE = 50 * 1024 * 1024;
|
|
2904
|
+
var MAX_FILE_COUNT = 500;
|
|
2905
|
+
var ArchiveSecurityError = class extends Error {
|
|
2906
|
+
constructor(message) {
|
|
2907
|
+
super(message);
|
|
2908
|
+
this.name = "ArchiveSecurityError";
|
|
2909
|
+
}
|
|
2910
|
+
};
|
|
2911
|
+
async function pack(directory) {
|
|
2912
|
+
const resolvedDir = resolve(directory);
|
|
2913
|
+
const files = await collectFiles(resolvedDir, resolvedDir);
|
|
2914
|
+
if (files.length > MAX_FILE_COUNT) {
|
|
2915
|
+
throw new ArchiveSecurityError(`Too many files: ${files.length} exceeds the limit of ${MAX_FILE_COUNT}`);
|
|
2916
|
+
}
|
|
2917
|
+
let totalSize = 0;
|
|
2918
|
+
for (const file of files) {
|
|
2919
|
+
totalSize += file.size;
|
|
2920
|
+
}
|
|
2921
|
+
if (totalSize > MAX_TOTAL_SIZE) {
|
|
2922
|
+
throw new ArchiveSecurityError(`Total size ${totalSize} bytes exceeds the limit of ${MAX_TOTAL_SIZE} bytes`);
|
|
2923
|
+
}
|
|
2924
|
+
return new Promise((resolvePromise, reject) => {
|
|
2925
|
+
const packStream = pack$1();
|
|
2926
|
+
const chunks = [];
|
|
2927
|
+
const gzip = createGzip();
|
|
2928
|
+
gzip.on("data", (chunk) => chunks.push(chunk));
|
|
2929
|
+
gzip.on("end", () => resolvePromise(Buffer.concat(chunks)));
|
|
2930
|
+
gzip.on("error", reject);
|
|
2931
|
+
packStream.pipe(gzip);
|
|
2932
|
+
(async () => {
|
|
2933
|
+
for (const file of files) {
|
|
2934
|
+
await new Promise((res, rej) => {
|
|
2935
|
+
const entry = packStream.entry({ name: file.relativePath, size: file.size }, (err) => {
|
|
2936
|
+
if (err)
|
|
2937
|
+
rej(err);
|
|
2938
|
+
else
|
|
2939
|
+
res();
|
|
2940
|
+
});
|
|
2941
|
+
const readStream = createReadStream(file.absolutePath);
|
|
2942
|
+
readStream.on("error", rej);
|
|
2943
|
+
readStream.pipe(entry);
|
|
2944
|
+
});
|
|
2945
|
+
}
|
|
2946
|
+
packStream.finalize();
|
|
2947
|
+
})().catch(reject);
|
|
2948
|
+
});
|
|
2949
|
+
}
|
|
2950
|
+
async function extract(archive, destination) {
|
|
2951
|
+
const resolvedDest = resolve(destination);
|
|
2952
|
+
await mkdir(resolvedDest, { recursive: true });
|
|
2953
|
+
let totalSize = 0;
|
|
2954
|
+
let fileCount = 0;
|
|
2955
|
+
const extractStream = extract$1();
|
|
2956
|
+
const processEntries = new Promise((resolvePromise, reject) => {
|
|
2957
|
+
extractStream.on("entry", (header, stream, next) => {
|
|
2958
|
+
try {
|
|
2959
|
+
if (header.type === "symlink" || header.type === "link") {
|
|
2960
|
+
stream.resume();
|
|
2961
|
+
reject(new ArchiveSecurityError(`Symlinks/hardlinks not allowed: ${header.name}`));
|
|
2962
|
+
return;
|
|
2963
|
+
}
|
|
2964
|
+
const entryPath = resolve(resolvedDest, header.name ?? "");
|
|
2965
|
+
if (!entryPath.startsWith(resolvedDest + "/") && entryPath !== resolvedDest) {
|
|
2966
|
+
stream.resume();
|
|
2967
|
+
reject(new ArchiveSecurityError(`Path traversal detected: ${header.name}`));
|
|
2968
|
+
return;
|
|
2969
|
+
}
|
|
2970
|
+
const entrySize = header.size ?? 0;
|
|
2971
|
+
if (entrySize > MAX_FILE_SIZE) {
|
|
2972
|
+
stream.resume();
|
|
2973
|
+
reject(new ArchiveSecurityError(`File too large: ${header.name} (${entrySize} bytes, limit ${MAX_FILE_SIZE})`));
|
|
2974
|
+
return;
|
|
2975
|
+
}
|
|
2976
|
+
totalSize += entrySize;
|
|
2977
|
+
if (totalSize > MAX_TOTAL_SIZE) {
|
|
2978
|
+
stream.resume();
|
|
2979
|
+
reject(new ArchiveSecurityError(`Total extracted size exceeds limit of ${MAX_TOTAL_SIZE} bytes`));
|
|
2980
|
+
return;
|
|
2981
|
+
}
|
|
2982
|
+
fileCount++;
|
|
2983
|
+
if (fileCount > MAX_FILE_COUNT) {
|
|
2984
|
+
stream.resume();
|
|
2985
|
+
reject(new ArchiveSecurityError(`Too many files: exceeds the limit of ${MAX_FILE_COUNT}`));
|
|
2986
|
+
return;
|
|
2987
|
+
}
|
|
2988
|
+
if (header.type === "directory") {
|
|
2989
|
+
mkdir(entryPath, { recursive: true }).then(() => {
|
|
2990
|
+
stream.resume();
|
|
2991
|
+
next();
|
|
2992
|
+
}).catch(reject);
|
|
2993
|
+
return;
|
|
2994
|
+
}
|
|
2995
|
+
const dir = entryPath.substring(0, entryPath.lastIndexOf("/"));
|
|
2996
|
+
mkdir(dir, { recursive: true }).then(() => {
|
|
2997
|
+
const writeStream = createWriteStream(entryPath);
|
|
2998
|
+
stream.pipe(writeStream);
|
|
2999
|
+
writeStream.on("finish", next);
|
|
3000
|
+
writeStream.on("error", reject);
|
|
3001
|
+
}).catch(reject);
|
|
3002
|
+
} catch (err) {
|
|
3003
|
+
stream.resume();
|
|
3004
|
+
reject(err);
|
|
3005
|
+
}
|
|
3006
|
+
});
|
|
3007
|
+
extractStream.on("finish", resolvePromise);
|
|
3008
|
+
extractStream.on("error", reject);
|
|
3009
|
+
});
|
|
3010
|
+
const { Readable } = await import('stream');
|
|
3011
|
+
const sourceStream = Readable.from(archive);
|
|
3012
|
+
const gunzip = createGunzip();
|
|
3013
|
+
await Promise.all([
|
|
3014
|
+
pipeline(sourceStream, gunzip, extractStream),
|
|
3015
|
+
processEntries
|
|
3016
|
+
]);
|
|
3017
|
+
}
|
|
3018
|
+
async function collectFiles(dir, rootDir) {
|
|
3019
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
3020
|
+
const files = [];
|
|
3021
|
+
for (const entry of entries) {
|
|
3022
|
+
const absolutePath = join(dir, entry.name);
|
|
3023
|
+
if (entry.isSymbolicLink()) {
|
|
3024
|
+
throw new ArchiveSecurityError(`Symlinks not allowed: ${absolutePath}`);
|
|
3025
|
+
}
|
|
3026
|
+
if (entry.isDirectory()) {
|
|
3027
|
+
const subFiles = await collectFiles(absolutePath, rootDir);
|
|
3028
|
+
files.push(...subFiles);
|
|
3029
|
+
} else if (entry.isFile()) {
|
|
3030
|
+
const fileStat = await stat(absolutePath);
|
|
3031
|
+
if (fileStat.size > MAX_FILE_SIZE) {
|
|
3032
|
+
throw new ArchiveSecurityError(`File too large: ${absolutePath} (${fileStat.size} bytes, limit ${MAX_FILE_SIZE})`);
|
|
3033
|
+
}
|
|
3034
|
+
files.push({
|
|
3035
|
+
absolutePath,
|
|
3036
|
+
relativePath: relative(rootDir, absolutePath),
|
|
3037
|
+
size: fileStat.size
|
|
3038
|
+
});
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
3041
|
+
return files;
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
// ../../packages/core/dist/scanner.js
|
|
3045
|
+
var THREAT_PATTERNS = [
|
|
3046
|
+
// === 直接注入 ===
|
|
3047
|
+
{
|
|
3048
|
+
id: "INJ-001",
|
|
3049
|
+
category: "injection",
|
|
3050
|
+
severity: "critical",
|
|
3051
|
+
pattern: /ignore\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|prompts?|rules?)/i,
|
|
3052
|
+
description: "Ignore previous instructions pattern"
|
|
3053
|
+
},
|
|
3054
|
+
{
|
|
3055
|
+
id: "INJ-002",
|
|
3056
|
+
category: "injection",
|
|
3057
|
+
severity: "critical",
|
|
3058
|
+
pattern: /disregard\s+(all\s+)?(previous|prior|your)\s+(instructions?|programming|guidelines?)/i,
|
|
3059
|
+
description: "Disregard instructions pattern"
|
|
3060
|
+
},
|
|
3061
|
+
{
|
|
3062
|
+
id: "INJ-003",
|
|
3063
|
+
category: "injection",
|
|
3064
|
+
severity: "high",
|
|
3065
|
+
pattern: /you\s+are\s+now\s+(a|an|in)\s+(unrestricted|unfiltered|uncensored)/i,
|
|
3066
|
+
description: "Unrestricted mode activation"
|
|
3067
|
+
},
|
|
3068
|
+
{
|
|
3069
|
+
id: "INJ-004",
|
|
3070
|
+
category: "injection",
|
|
3071
|
+
severity: "high",
|
|
3072
|
+
pattern: /new\s+(instructions?|role|persona|system\s*prompt)/i,
|
|
3073
|
+
description: "System prompt override attempt"
|
|
3074
|
+
},
|
|
3075
|
+
{
|
|
3076
|
+
id: "INJ-005",
|
|
3077
|
+
category: "injection",
|
|
3078
|
+
severity: "critical",
|
|
3079
|
+
pattern: /<\|(?:im_start|im_end|endoftext|system|user|assistant)\|>/,
|
|
3080
|
+
description: "LLM special token injection"
|
|
3081
|
+
},
|
|
3082
|
+
// === Jailbreak ===
|
|
3083
|
+
{
|
|
3084
|
+
id: "JB-001",
|
|
3085
|
+
category: "jailbreak",
|
|
3086
|
+
severity: "critical",
|
|
3087
|
+
pattern: /\bDAN\b.*\bdo\s+anything\s+now\b/i,
|
|
3088
|
+
description: "DAN jailbreak pattern"
|
|
3089
|
+
},
|
|
3090
|
+
{
|
|
3091
|
+
id: "JB-002",
|
|
3092
|
+
category: "jailbreak",
|
|
3093
|
+
severity: "high",
|
|
3094
|
+
pattern: /developer\s+mode|god\s+mode|admin\s+mode|debug\s+mode/i,
|
|
3095
|
+
description: "Privileged mode activation"
|
|
3096
|
+
},
|
|
3097
|
+
{
|
|
3098
|
+
id: "JB-003",
|
|
3099
|
+
category: "jailbreak",
|
|
3100
|
+
severity: "high",
|
|
3101
|
+
pattern: /bypass\s+(safety|content|ethical)\s+(filter|restriction|guideline)/i,
|
|
3102
|
+
description: "Safety bypass attempt"
|
|
3103
|
+
},
|
|
3104
|
+
// === 資料外洩 ===
|
|
3105
|
+
{
|
|
3106
|
+
id: "EXF-001",
|
|
3107
|
+
category: "exfiltration",
|
|
3108
|
+
severity: "critical",
|
|
3109
|
+
pattern: /base64\s*(encode|decode).*\.(env|credentials|key|secret|token|password)/i,
|
|
3110
|
+
description: "Credential exfiltration via encoding"
|
|
3111
|
+
},
|
|
3112
|
+
{
|
|
3113
|
+
id: "EXF-002",
|
|
3114
|
+
category: "exfiltration",
|
|
3115
|
+
severity: "critical",
|
|
3116
|
+
pattern: /(curl|wget|fetch|http)\s+.*(webhook|ngrok|requestbin|pipedream)/i,
|
|
3117
|
+
description: "Data exfiltration via HTTP"
|
|
3118
|
+
},
|
|
3119
|
+
{
|
|
3120
|
+
id: "EXF-003",
|
|
3121
|
+
category: "exfiltration",
|
|
3122
|
+
severity: "high",
|
|
3123
|
+
pattern: /read\s+(and\s+)?(send|transmit|post|upload)\s+.*\.(env|config|key)/i,
|
|
3124
|
+
description: "File read and transmit pattern"
|
|
3125
|
+
},
|
|
3126
|
+
// === Persona 劫持 ===
|
|
3127
|
+
{
|
|
3128
|
+
id: "PH-001",
|
|
3129
|
+
category: "persona_hijack",
|
|
3130
|
+
severity: "high",
|
|
3131
|
+
pattern: /your\s+(new\s+)?(personality|identity|character|name)\s+(is|will\s+be|shall\s+be)/i,
|
|
3132
|
+
description: "Persona override attempt"
|
|
3133
|
+
},
|
|
3134
|
+
{
|
|
3135
|
+
id: "PH-002",
|
|
3136
|
+
category: "persona_hijack",
|
|
3137
|
+
severity: "medium",
|
|
3138
|
+
pattern: /from\s+now\s+on.*(act|behave|respond|pretend)\s+as/i,
|
|
3139
|
+
description: "Behavioral override"
|
|
3140
|
+
},
|
|
3141
|
+
// === HTML / Markdown 注入 ===
|
|
3142
|
+
{
|
|
3143
|
+
id: "HTML-001",
|
|
3144
|
+
category: "html_injection",
|
|
3145
|
+
severity: "critical",
|
|
3146
|
+
pattern: /<script[\s>]/i,
|
|
3147
|
+
description: "Embedded script tag"
|
|
3148
|
+
},
|
|
3149
|
+
{
|
|
3150
|
+
id: "HTML-002",
|
|
3151
|
+
category: "html_injection",
|
|
3152
|
+
severity: "high",
|
|
3153
|
+
pattern: /javascript\s*:/i,
|
|
3154
|
+
description: "JavaScript URI scheme"
|
|
3155
|
+
},
|
|
3156
|
+
{
|
|
3157
|
+
id: "HTML-003",
|
|
3158
|
+
category: "html_injection",
|
|
3159
|
+
severity: "high",
|
|
3160
|
+
pattern: /on(error|load|click|mouseover|focus|blur|submit|change)\s*=/i,
|
|
3161
|
+
description: "HTML event handler injection"
|
|
3162
|
+
},
|
|
3163
|
+
// === 隱藏內容 ===
|
|
3164
|
+
{
|
|
3165
|
+
id: "HID-001",
|
|
3166
|
+
category: "hidden",
|
|
3167
|
+
severity: "critical",
|
|
3168
|
+
pattern: /\u200b|\u200c|\u200d|\u2060|\ufeff/,
|
|
3169
|
+
description: "Zero-width characters detected \u2014 may contain hidden instructions"
|
|
3170
|
+
},
|
|
3171
|
+
{
|
|
3172
|
+
id: "HID-002",
|
|
3173
|
+
category: "hidden",
|
|
3174
|
+
severity: "high",
|
|
3175
|
+
pattern: /<!--[\s\S]*?(?:ignore|disregard|override|act as)[\s\S]*?-->/i,
|
|
3176
|
+
description: "Hidden instructions in HTML comment"
|
|
3177
|
+
},
|
|
3178
|
+
// === Shell 危險指令 ===
|
|
3179
|
+
{
|
|
3180
|
+
id: "SH-001",
|
|
3181
|
+
category: "shell",
|
|
3182
|
+
severity: "critical",
|
|
3183
|
+
pattern: /rm\s+-rf\s+[/~]/,
|
|
3184
|
+
description: "Destructive rm -rf command"
|
|
3185
|
+
},
|
|
3186
|
+
{
|
|
3187
|
+
id: "SH-002",
|
|
3188
|
+
category: "shell",
|
|
3189
|
+
severity: "critical",
|
|
3190
|
+
pattern: /curl\s+[^|]*\|\s*(?:ba)?sh/i,
|
|
3191
|
+
description: "Remote code execution via curl | bash"
|
|
3192
|
+
},
|
|
3193
|
+
{
|
|
3194
|
+
id: "SH-003",
|
|
3195
|
+
category: "shell",
|
|
3196
|
+
severity: "high",
|
|
3197
|
+
pattern: /(?:nc|netcat|ncat)\s+.*-e\s+.*sh/i,
|
|
3198
|
+
description: "Reverse shell pattern"
|
|
3199
|
+
}
|
|
3200
|
+
];
|
|
3201
|
+
var PERMISSION_PATTERNS = [
|
|
3202
|
+
// file_access
|
|
3203
|
+
{
|
|
3204
|
+
type: "file_access",
|
|
3205
|
+
pattern: /\bread\s+(the\s+)?file\b/i,
|
|
3206
|
+
description: "Reads files from the filesystem"
|
|
3207
|
+
},
|
|
3208
|
+
{
|
|
3209
|
+
type: "file_access",
|
|
3210
|
+
pattern: /\bwrite\s+to\b/i,
|
|
3211
|
+
description: "Writes data to the filesystem"
|
|
3212
|
+
},
|
|
3213
|
+
{
|
|
3214
|
+
type: "file_access",
|
|
3215
|
+
pattern: /\bopen\s+the\s+file\b/i,
|
|
3216
|
+
description: "Opens files from the filesystem"
|
|
3217
|
+
},
|
|
3218
|
+
{
|
|
3219
|
+
type: "file_access",
|
|
3220
|
+
pattern: /\baccess\s+\S*\.json\b/i,
|
|
3221
|
+
description: "Accesses JSON files"
|
|
3222
|
+
},
|
|
3223
|
+
{
|
|
3224
|
+
type: "file_access",
|
|
3225
|
+
pattern: /~\/\./,
|
|
3226
|
+
description: "Accesses dotfiles in home directory"
|
|
3227
|
+
},
|
|
3228
|
+
// network_access
|
|
3229
|
+
{
|
|
3230
|
+
type: "network_access",
|
|
3231
|
+
pattern: /\bcurl\b/i,
|
|
3232
|
+
description: "Uses curl for network requests"
|
|
3233
|
+
},
|
|
3234
|
+
{
|
|
3235
|
+
type: "network_access",
|
|
3236
|
+
pattern: /\bwget\b/i,
|
|
3237
|
+
description: "Uses wget for network requests"
|
|
3238
|
+
},
|
|
3239
|
+
{
|
|
3240
|
+
type: "network_access",
|
|
3241
|
+
pattern: /\bfetch\s*\(/i,
|
|
3242
|
+
description: "Uses fetch() for network requests"
|
|
3243
|
+
},
|
|
3244
|
+
{
|
|
3245
|
+
type: "network_access",
|
|
3246
|
+
pattern: /https?:\/\//i,
|
|
3247
|
+
description: "References HTTP/HTTPS URLs"
|
|
3248
|
+
},
|
|
3249
|
+
{
|
|
3250
|
+
type: "network_access",
|
|
3251
|
+
pattern: /\bAPI\s+endpoint\b/i,
|
|
3252
|
+
description: "References API endpoints"
|
|
3253
|
+
},
|
|
3254
|
+
// shell_execution
|
|
3255
|
+
{
|
|
3256
|
+
type: "shell_execution",
|
|
3257
|
+
pattern: /\.sh\b/,
|
|
3258
|
+
description: "References shell script files"
|
|
3259
|
+
},
|
|
3260
|
+
{
|
|
3261
|
+
type: "shell_execution",
|
|
3262
|
+
pattern: /\b(?:bash|sh|zsh)\s+-c\b/i,
|
|
3263
|
+
description: "Invokes shell with command flag"
|
|
3264
|
+
},
|
|
3265
|
+
{
|
|
3266
|
+
type: "shell_execution",
|
|
3267
|
+
pattern: /\bexec\s*\(/i,
|
|
3268
|
+
description: "Uses exec() to run commands"
|
|
3269
|
+
},
|
|
3270
|
+
// env_access
|
|
3271
|
+
{
|
|
3272
|
+
type: "env_access",
|
|
3273
|
+
pattern: /\bprocess\.env\b/,
|
|
3274
|
+
description: "Accesses process environment variables"
|
|
3275
|
+
},
|
|
3276
|
+
{
|
|
3277
|
+
type: "env_access",
|
|
3278
|
+
pattern: /\$HOME\b/,
|
|
3279
|
+
description: "References $HOME environment variable"
|
|
3280
|
+
},
|
|
3281
|
+
{
|
|
3282
|
+
type: "env_access",
|
|
3283
|
+
pattern: /\$PATH\b/,
|
|
3284
|
+
description: "References $PATH environment variable"
|
|
3285
|
+
},
|
|
3286
|
+
{
|
|
3287
|
+
type: "env_access",
|
|
3288
|
+
pattern: /\.env\b/,
|
|
3289
|
+
description: "References .env file"
|
|
3290
|
+
},
|
|
3291
|
+
{
|
|
3292
|
+
type: "env_access",
|
|
3293
|
+
pattern: /\benvironment\s+variable\b/i,
|
|
3294
|
+
description: "References environment variables"
|
|
3295
|
+
}
|
|
3296
|
+
];
|
|
3297
|
+
function detectPermissions(content, filename) {
|
|
3298
|
+
const permissions = [];
|
|
3299
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3300
|
+
for (const pp of PERMISSION_PATTERNS) {
|
|
3301
|
+
const match = pp.pattern.exec(content);
|
|
3302
|
+
if (match) {
|
|
3303
|
+
const key = `${pp.type}:${pp.description}`;
|
|
3304
|
+
if (!seen.has(key)) {
|
|
3305
|
+
seen.add(key);
|
|
3306
|
+
permissions.push({
|
|
3307
|
+
type: pp.type,
|
|
3308
|
+
description: pp.description,
|
|
3309
|
+
evidence: match[0].slice(0, 100),
|
|
3310
|
+
file: filename
|
|
3311
|
+
});
|
|
3312
|
+
}
|
|
3313
|
+
}
|
|
3314
|
+
}
|
|
3315
|
+
return permissions;
|
|
3316
|
+
}
|
|
3317
|
+
function determineRiskLevel(findings) {
|
|
3318
|
+
const hasCriticalOrHigh = findings.some((f) => f.severity === "critical" || f.severity === "high");
|
|
3319
|
+
const hasMediumOrLow = findings.some((f) => f.severity === "medium" || f.severity === "low");
|
|
3320
|
+
if (hasCriticalOrHigh)
|
|
3321
|
+
return "danger";
|
|
3322
|
+
if (hasMediumOrLow)
|
|
3323
|
+
return "warning";
|
|
3324
|
+
return "safe";
|
|
3325
|
+
}
|
|
3326
|
+
function findLineNumber(content, pattern) {
|
|
3327
|
+
const match = pattern.exec(content);
|
|
3328
|
+
if (!match)
|
|
3329
|
+
return void 0;
|
|
3330
|
+
const beforeMatch = content.slice(0, match.index);
|
|
3331
|
+
return beforeMatch.split("\n").length;
|
|
3332
|
+
}
|
|
3333
|
+
function scanPromptInjection(content, filename) {
|
|
3334
|
+
const findings = [];
|
|
3335
|
+
for (const threat of THREAT_PATTERNS) {
|
|
3336
|
+
if (threat.pattern.test(content)) {
|
|
3337
|
+
findings.push({
|
|
3338
|
+
ruleId: threat.id,
|
|
3339
|
+
category: threat.category,
|
|
3340
|
+
severity: threat.severity,
|
|
3341
|
+
description: threat.description,
|
|
3342
|
+
file: filename,
|
|
3343
|
+
line: findLineNumber(content, threat.pattern)
|
|
3344
|
+
});
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
const riskLevel = determineRiskLevel(findings);
|
|
3348
|
+
const permissions = detectPermissions(content, filename);
|
|
3349
|
+
return {
|
|
3350
|
+
riskLevel,
|
|
3351
|
+
safe: riskLevel === "safe",
|
|
3352
|
+
findings,
|
|
3353
|
+
issues: findings.map((f) => `[${f.severity.toUpperCase()}] ${f.ruleId}: ${f.description}`),
|
|
3354
|
+
permissions,
|
|
3355
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3356
|
+
scannerVersion: "2.0.0"
|
|
3357
|
+
};
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
// src/commands/pull.ts
|
|
3361
|
+
function parseIdentifier(input) {
|
|
3362
|
+
const versionSplit = input.split("@");
|
|
3363
|
+
if (versionSplit.length > 2) {
|
|
3364
|
+
return null;
|
|
3365
|
+
}
|
|
3366
|
+
const nameWithScope = versionSplit[0];
|
|
3367
|
+
const version = versionSplit.length === 2 ? versionSplit[1] : void 0;
|
|
3368
|
+
if (!nameWithScope) return null;
|
|
3369
|
+
const slashIndex = nameWithScope.indexOf("/");
|
|
3370
|
+
if (slashIndex === -1) {
|
|
3371
|
+
return { scope: "", name: nameWithScope, version };
|
|
3372
|
+
}
|
|
3373
|
+
const scope = nameWithScope.slice(0, slashIndex);
|
|
3374
|
+
const name = nameWithScope.slice(slashIndex + 1);
|
|
3375
|
+
if (!scope || !name) return null;
|
|
3376
|
+
return { scope, name, version };
|
|
3377
|
+
}
|
|
3378
|
+
var pull_default = defineCommand({
|
|
3379
|
+
meta: {
|
|
3380
|
+
name: "pull",
|
|
3381
|
+
description: "Download a hubbit from the registry"
|
|
3382
|
+
},
|
|
3383
|
+
args: {
|
|
3384
|
+
package: {
|
|
3385
|
+
type: "positional",
|
|
3386
|
+
description: "Package identifier (scope/name or scope/name@version)",
|
|
3387
|
+
required: true
|
|
3388
|
+
},
|
|
3389
|
+
output: {
|
|
3390
|
+
type: "string",
|
|
3391
|
+
alias: "o",
|
|
3392
|
+
description: "Output directory (defaults to package name)"
|
|
3393
|
+
},
|
|
3394
|
+
force: {
|
|
3395
|
+
type: "boolean",
|
|
3396
|
+
alias: "f",
|
|
3397
|
+
description: "Overwrite existing directory",
|
|
3398
|
+
default: false
|
|
3399
|
+
},
|
|
3400
|
+
"skip-verify": {
|
|
3401
|
+
type: "boolean",
|
|
3402
|
+
description: "Skip SHA-256 integrity verification",
|
|
3403
|
+
default: false
|
|
3404
|
+
},
|
|
3405
|
+
json: {
|
|
3406
|
+
type: "boolean",
|
|
3407
|
+
description: "Output as JSON",
|
|
3408
|
+
default: false
|
|
3409
|
+
},
|
|
3410
|
+
quiet: {
|
|
3411
|
+
type: "boolean",
|
|
3412
|
+
description: "Suppress output",
|
|
3413
|
+
default: false
|
|
3414
|
+
},
|
|
3415
|
+
verbose: {
|
|
3416
|
+
type: "boolean",
|
|
3417
|
+
description: "Show debug information",
|
|
3418
|
+
default: false
|
|
3419
|
+
}
|
|
3420
|
+
},
|
|
3421
|
+
async run({ args }) {
|
|
3422
|
+
setOutputMode({ json: args.json, quiet: args.quiet, verbose: args.verbose });
|
|
3423
|
+
const parsed = parseIdentifier(args.package);
|
|
3424
|
+
if (!parsed) {
|
|
3425
|
+
errorWithFix(
|
|
3426
|
+
`Invalid package identifier: "${args.package}"`,
|
|
3427
|
+
'Use the format: scope/name or scope/name@version (e.g., "eric/learn-docker@1.0.0")'
|
|
3428
|
+
);
|
|
3429
|
+
process.exit(1);
|
|
3430
|
+
}
|
|
3431
|
+
if (!parsed.scope) {
|
|
3432
|
+
errorWithFix(
|
|
3433
|
+
`Missing scope in package identifier: "${args.package}"`,
|
|
3434
|
+
'Use the format: scope/name (e.g., "eric/learn-docker")'
|
|
3435
|
+
);
|
|
3436
|
+
process.exit(1);
|
|
3437
|
+
}
|
|
3438
|
+
const { scope, name, version: requestedVersion } = parsed;
|
|
3439
|
+
const fullName = `${scope}/${name}`;
|
|
3440
|
+
debug(`Parsed: scope=${scope}, name=${name}, version=${requestedVersion ?? "latest"}`);
|
|
3441
|
+
const api2 = createApiClient();
|
|
3442
|
+
const s = spinner(`Resolving ${pc5.cyan(fullName)}...`);
|
|
3443
|
+
let resolvedVersion;
|
|
3444
|
+
let downloadSha256;
|
|
3445
|
+
try {
|
|
3446
|
+
if (requestedVersion) {
|
|
3447
|
+
debug(`GET /api/v1/packages/${scope}/${name}/${requestedVersion}`);
|
|
3448
|
+
const versionResult = await api2.getPackageVersion(scope, name, requestedVersion);
|
|
3449
|
+
if (!versionResult.data) {
|
|
3450
|
+
s.fail("Package version not found.");
|
|
3451
|
+
errorWithFix(
|
|
3452
|
+
`Version "${requestedVersion}" not found for "${fullName}".`,
|
|
3453
|
+
`Run \`hubbits search ${name}\` to find available packages.`
|
|
3454
|
+
);
|
|
3455
|
+
process.exit(1);
|
|
3456
|
+
}
|
|
3457
|
+
resolvedVersion = versionResult.data.version;
|
|
3458
|
+
downloadSha256 = versionResult.data.digest;
|
|
3459
|
+
} else {
|
|
3460
|
+
debug(`GET /api/v1/packages/${scope}/${name}`);
|
|
3461
|
+
const pkgResult = await api2.getPackageByScope(scope, name);
|
|
3462
|
+
if (!pkgResult.data) {
|
|
3463
|
+
s.fail("Package not found.");
|
|
3464
|
+
errorWithFix(
|
|
3465
|
+
`Package "${fullName}" not found.`,
|
|
3466
|
+
`Run \`hubbits search ${name}\` to find available packages.`
|
|
3467
|
+
);
|
|
3468
|
+
process.exit(1);
|
|
3469
|
+
}
|
|
3470
|
+
resolvedVersion = pkgResult.data.latest_version;
|
|
3471
|
+
if (!resolvedVersion || resolvedVersion === "0.0.0") {
|
|
3472
|
+
s.fail("No published version.");
|
|
3473
|
+
errorWithFix(
|
|
3474
|
+
`Package "${fullName}" has no published versions.`,
|
|
3475
|
+
"The package may still be in development."
|
|
3476
|
+
);
|
|
3477
|
+
process.exit(1);
|
|
3478
|
+
}
|
|
3479
|
+
}
|
|
3480
|
+
s.text = `Resolved ${pc5.cyan(fullName)}@${pc5.yellow(resolvedVersion)}`;
|
|
3481
|
+
debug(`Resolved version: ${resolvedVersion}`);
|
|
3482
|
+
} catch (err) {
|
|
3483
|
+
s.fail("Failed to resolve package.");
|
|
3484
|
+
handleApiError(err, fullName);
|
|
3485
|
+
process.exit(1);
|
|
3486
|
+
}
|
|
3487
|
+
s.text = `Downloading ${pc5.cyan(fullName)}@${pc5.yellow(resolvedVersion)}...`;
|
|
3488
|
+
let downloadUrl;
|
|
3489
|
+
let expectedSha256;
|
|
3490
|
+
try {
|
|
3491
|
+
debug(`GET /api/v1/download/${scope}/${name}/${resolvedVersion}`);
|
|
3492
|
+
const dlResult = await api2.getDownloadUrl(scope, name, resolvedVersion);
|
|
3493
|
+
if (!dlResult.data) {
|
|
3494
|
+
s.fail("Failed to get download URL.");
|
|
3495
|
+
error("Server returned an unexpected response.");
|
|
3496
|
+
process.exit(1);
|
|
3497
|
+
}
|
|
3498
|
+
downloadUrl = dlResult.data.download_url;
|
|
3499
|
+
expectedSha256 = dlResult.data.sha256 ?? downloadSha256 ?? "";
|
|
3500
|
+
debug(`Download URL: ${downloadUrl}`);
|
|
3501
|
+
debug(`Expected SHA-256: ${expectedSha256}`);
|
|
3502
|
+
} catch (err) {
|
|
3503
|
+
s.fail("Failed to get download URL.");
|
|
3504
|
+
handleApiError(err, fullName);
|
|
3505
|
+
process.exit(1);
|
|
3506
|
+
}
|
|
3507
|
+
let archiveBuffer;
|
|
3508
|
+
try {
|
|
3509
|
+
debug("Downloading tarball...");
|
|
3510
|
+
const arrayBuffer = await api2.downloadFile(downloadUrl);
|
|
3511
|
+
archiveBuffer = Buffer.from(arrayBuffer);
|
|
3512
|
+
debug(`Downloaded ${archiveBuffer.length} bytes`);
|
|
3513
|
+
} catch {
|
|
3514
|
+
s.fail("Download failed.");
|
|
3515
|
+
errorWithFix(
|
|
3516
|
+
"Failed to download the package archive.",
|
|
3517
|
+
"Check your network connection and try again."
|
|
3518
|
+
);
|
|
3519
|
+
process.exit(1);
|
|
3520
|
+
}
|
|
3521
|
+
if (!args["skip-verify"] && expectedSha256) {
|
|
3522
|
+
s.text = "Verifying integrity...";
|
|
3523
|
+
debug("Computing SHA-256 digest...");
|
|
3524
|
+
const computedHash = await sha256(archiveBuffer);
|
|
3525
|
+
debug(`Computed SHA-256: ${computedHash}`);
|
|
3526
|
+
if (computedHash !== expectedSha256) {
|
|
3527
|
+
s.fail("Integrity check failed.");
|
|
3528
|
+
errorWithFix(
|
|
3529
|
+
`SHA-256 mismatch!
|
|
3530
|
+
Expected: ${expectedSha256}
|
|
3531
|
+
Got: ${computedHash}`,
|
|
3532
|
+
"The downloaded file may be corrupted. Try running `hubbits pull` again."
|
|
3533
|
+
);
|
|
3534
|
+
if (isJsonMode()) {
|
|
3535
|
+
outputJson({
|
|
3536
|
+
status: "error",
|
|
3537
|
+
error: "integrity_mismatch",
|
|
3538
|
+
expected_sha256: expectedSha256,
|
|
3539
|
+
computed_sha256: computedHash
|
|
3540
|
+
});
|
|
3541
|
+
}
|
|
3542
|
+
process.exit(1);
|
|
3543
|
+
}
|
|
3544
|
+
debug("SHA-256 verified.");
|
|
3545
|
+
} else if (args["skip-verify"]) {
|
|
3546
|
+
debug("Skipping SHA-256 verification (--skip-verify)");
|
|
3547
|
+
} else {
|
|
3548
|
+
debug("No expected SHA-256 available, skipping verification");
|
|
3549
|
+
}
|
|
3550
|
+
const outputDir = args.output ? resolve(args.output) : resolve(process.cwd(), name);
|
|
3551
|
+
if (existsSync(outputDir) && !args.force) {
|
|
3552
|
+
s.fail("Directory already exists.");
|
|
3553
|
+
errorWithFix(
|
|
3554
|
+
`Directory "${name}" already exists.`,
|
|
3555
|
+
"Use `--force` to overwrite or `--output` to specify a different directory."
|
|
3556
|
+
);
|
|
3557
|
+
if (isJsonMode()) {
|
|
3558
|
+
outputJson({
|
|
3559
|
+
status: "error",
|
|
3560
|
+
error: "directory_exists",
|
|
3561
|
+
path: outputDir
|
|
3562
|
+
});
|
|
3563
|
+
}
|
|
3564
|
+
process.exit(1);
|
|
3565
|
+
}
|
|
3566
|
+
s.text = `Extracting to ${pc5.dim(outputDir)}...`;
|
|
3567
|
+
debug(`Extracting to: ${outputDir}`);
|
|
3568
|
+
try {
|
|
3569
|
+
await mkdir(outputDir, { recursive: true });
|
|
3570
|
+
await extract(archiveBuffer, outputDir);
|
|
3571
|
+
} catch (err) {
|
|
3572
|
+
s.fail("Extraction failed.");
|
|
3573
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
3574
|
+
if (errMsg.includes("Symlinks") || errMsg.includes("Path traversal") || errMsg.includes("too large")) {
|
|
3575
|
+
errorWithFix(
|
|
3576
|
+
`Security check failed: ${errMsg}`,
|
|
3577
|
+
"This package archive contains unsafe content and cannot be extracted."
|
|
3578
|
+
);
|
|
3579
|
+
} else {
|
|
3580
|
+
errorWithFix(
|
|
3581
|
+
`Failed to extract package: ${errMsg}`,
|
|
3582
|
+
"The archive may be corrupted. Try running `hubbits pull` again."
|
|
3583
|
+
);
|
|
3584
|
+
}
|
|
3585
|
+
process.exit(1);
|
|
3586
|
+
}
|
|
3587
|
+
s.succeed(`Downloaded ${pc5.cyan(fullName)}@${pc5.yellow(resolvedVersion)}`);
|
|
3588
|
+
if (isJsonMode()) {
|
|
3589
|
+
outputJson({
|
|
3590
|
+
status: "ok",
|
|
3591
|
+
package: fullName,
|
|
3592
|
+
version: resolvedVersion,
|
|
3593
|
+
sha256: expectedSha256 || null,
|
|
3594
|
+
path: outputDir,
|
|
3595
|
+
size: archiveBuffer.length
|
|
3596
|
+
});
|
|
3597
|
+
return;
|
|
3598
|
+
}
|
|
3599
|
+
newline();
|
|
3600
|
+
field("Package", pc5.cyan(fullName));
|
|
3601
|
+
field("Version", pc5.yellow(resolvedVersion));
|
|
3602
|
+
if (expectedSha256) {
|
|
3603
|
+
field("SHA-256", pc5.dim(expectedSha256.slice(0, 16) + "..."));
|
|
3604
|
+
}
|
|
3605
|
+
field("Size", formatBytes(archiveBuffer.length));
|
|
3606
|
+
field("Path", outputDir);
|
|
3607
|
+
newline();
|
|
3608
|
+
hint(`Open \`${name}/\` in your AI editor and type "let's play" to begin`);
|
|
3609
|
+
hint(`View contents: \`ls ${name}/\``);
|
|
3610
|
+
}
|
|
3611
|
+
});
|
|
3612
|
+
function handleApiError(err, packageName) {
|
|
3613
|
+
if (err instanceof ApiError) {
|
|
3614
|
+
switch (err.statusCode) {
|
|
3615
|
+
case 404:
|
|
3616
|
+
errorWithFix(
|
|
3617
|
+
`Package "${packageName}" not found.`,
|
|
3618
|
+
`Run \`hubbits search\` to find available packages.`
|
|
3619
|
+
);
|
|
3620
|
+
break;
|
|
3621
|
+
case 401:
|
|
3622
|
+
errorWithFix(
|
|
3623
|
+
"Authentication required.",
|
|
3624
|
+
"Run `hubbits login` to authenticate first."
|
|
3625
|
+
);
|
|
3626
|
+
break;
|
|
3627
|
+
case 403:
|
|
3628
|
+
errorWithFix(
|
|
3629
|
+
err.message || "Access denied.",
|
|
3630
|
+
"You may need to purchase this package first. Visit https://hubbits.dev to buy it."
|
|
3631
|
+
);
|
|
3632
|
+
break;
|
|
3633
|
+
default:
|
|
3634
|
+
errorWithFix(err.message, "Check your network connection and try again.");
|
|
3635
|
+
}
|
|
3636
|
+
} else {
|
|
3637
|
+
errorWithFix(
|
|
3638
|
+
"An unexpected error occurred.",
|
|
3639
|
+
"Check your network connection and try again."
|
|
3640
|
+
);
|
|
3641
|
+
}
|
|
3642
|
+
}
|
|
3643
|
+
function formatBytes(bytes) {
|
|
3644
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
3645
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
3646
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
3647
|
+
}
|
|
3648
|
+
var SCANNABLE_EXTENSIONS = [".yaml", ".yml", ".md", ".txt"];
|
|
3649
|
+
var MAX_PACKAGE_SIZE = 50 * 1024 * 1024;
|
|
3650
|
+
var publish_default = defineCommand({
|
|
3651
|
+
meta: {
|
|
3652
|
+
name: "publish",
|
|
3653
|
+
description: "Publish the current hubbit to the registry"
|
|
3654
|
+
},
|
|
3655
|
+
args: {
|
|
3656
|
+
path: {
|
|
3657
|
+
type: "positional",
|
|
3658
|
+
description: "Path to the package directory (defaults to current directory)",
|
|
3659
|
+
required: false
|
|
3660
|
+
},
|
|
3661
|
+
"dry-run": {
|
|
3662
|
+
type: "boolean",
|
|
3663
|
+
description: "Validate and pack but do not upload",
|
|
3664
|
+
default: false
|
|
3665
|
+
},
|
|
3666
|
+
"skip-scan": {
|
|
3667
|
+
type: "boolean",
|
|
3668
|
+
description: "Skip security scan (not recommended)",
|
|
3669
|
+
default: false
|
|
3670
|
+
},
|
|
3671
|
+
yes: {
|
|
3672
|
+
type: "boolean",
|
|
3673
|
+
alias: "y",
|
|
3674
|
+
description: "Skip confirmation prompt",
|
|
3675
|
+
default: false
|
|
3676
|
+
},
|
|
3677
|
+
json: {
|
|
3678
|
+
type: "boolean",
|
|
3679
|
+
description: "Output as JSON",
|
|
3680
|
+
default: false
|
|
3681
|
+
},
|
|
3682
|
+
quiet: {
|
|
3683
|
+
type: "boolean",
|
|
3684
|
+
description: "Suppress output",
|
|
3685
|
+
default: false
|
|
3686
|
+
},
|
|
3687
|
+
verbose: {
|
|
3688
|
+
type: "boolean",
|
|
3689
|
+
description: "Show debug information",
|
|
3690
|
+
default: false
|
|
3691
|
+
}
|
|
3692
|
+
},
|
|
3693
|
+
async run({ args }) {
|
|
3694
|
+
setOutputMode({ json: args.json, quiet: args.quiet, verbose: args.verbose });
|
|
3695
|
+
const packageDir = resolve(args.path ?? process.cwd());
|
|
3696
|
+
const auth = getAuthState();
|
|
3697
|
+
if (!auth) {
|
|
3698
|
+
errorWithFix(
|
|
3699
|
+
"You must be logged in to publish packages.",
|
|
3700
|
+
"Run `hubbits login` to authenticate first."
|
|
3701
|
+
);
|
|
3702
|
+
if (isJsonMode()) {
|
|
3703
|
+
outputJson({ status: "error", error: "not_authenticated" });
|
|
3704
|
+
}
|
|
3705
|
+
process.exit(1);
|
|
3706
|
+
}
|
|
3707
|
+
debug(`Authenticated as: ${auth.username ?? auth.email ?? "token"}`);
|
|
3708
|
+
const yamlPath = findManifestFile2(packageDir);
|
|
3709
|
+
if (!yamlPath) {
|
|
3710
|
+
errorWithFix(
|
|
3711
|
+
`No hubbit.yaml found in ${packageDir}`,
|
|
3712
|
+
"Run `hubbits create` to scaffold a new package, or check you are in the correct directory."
|
|
3713
|
+
);
|
|
3714
|
+
if (isJsonMode()) {
|
|
3715
|
+
outputJson({ status: "error", error: "manifest_not_found", path: packageDir });
|
|
3716
|
+
}
|
|
3717
|
+
process.exit(1);
|
|
3718
|
+
}
|
|
3719
|
+
debug(`Reading manifest: ${yamlPath}`);
|
|
3720
|
+
let rawYaml;
|
|
3721
|
+
try {
|
|
3722
|
+
rawYaml = readFileSync(yamlPath, "utf-8");
|
|
3723
|
+
} catch {
|
|
3724
|
+
errorWithFix("Failed to read hubbit.yaml.", "Check file permissions.");
|
|
3725
|
+
process.exit(1);
|
|
3726
|
+
}
|
|
3727
|
+
const parseResult = parseManifest(rawYaml);
|
|
3728
|
+
if (!parseResult.success) {
|
|
3729
|
+
if (isJsonMode()) {
|
|
3730
|
+
outputJson({ status: "error", error: "invalid_manifest", errors: parseResult.errors });
|
|
3731
|
+
process.exit(1);
|
|
3732
|
+
}
|
|
3733
|
+
error("Invalid hubbit.yaml:");
|
|
3734
|
+
newline();
|
|
3735
|
+
for (const err of parseResult.errors) {
|
|
3736
|
+
console.log(` ${pc5.red("\u2717")} ${err}`);
|
|
3737
|
+
}
|
|
3738
|
+
newline();
|
|
3739
|
+
hint("Run `hubbits validate` for detailed validation.");
|
|
3740
|
+
process.exit(1);
|
|
3741
|
+
}
|
|
3742
|
+
const manifest = {
|
|
3743
|
+
...parseResult.data,
|
|
3744
|
+
...!parseResult.data.author && auth.username ? { author: auth.username } : {}
|
|
3745
|
+
};
|
|
3746
|
+
const validationResult = validateManifest2(manifest);
|
|
3747
|
+
if (!validationResult.success) {
|
|
3748
|
+
if (isJsonMode()) {
|
|
3749
|
+
outputJson({ status: "error", error: "validation_failed", errors: validationResult.errors });
|
|
3750
|
+
process.exit(1);
|
|
3751
|
+
}
|
|
3752
|
+
error("Manifest validation failed for publishing:");
|
|
3753
|
+
newline();
|
|
3754
|
+
for (const err of validationResult.errors) {
|
|
3755
|
+
console.log(` ${pc5.red("\u2717")} ${err}`);
|
|
3756
|
+
}
|
|
3757
|
+
newline();
|
|
3758
|
+
hint("Run `hubbits validate --strict` to check all publishing requirements.");
|
|
3759
|
+
process.exit(1);
|
|
3760
|
+
}
|
|
3761
|
+
const packageName = manifest.name;
|
|
3762
|
+
const packageVersion = manifest.version;
|
|
3763
|
+
debug(`Package: ${packageName}@${packageVersion}`);
|
|
3764
|
+
if (!args["skip-scan"]) {
|
|
3765
|
+
const scanSpinner = spinner("Scanning for security threats...");
|
|
3766
|
+
const scanIssues = await scanDirectory(packageDir);
|
|
3767
|
+
if (scanIssues.length > 0) {
|
|
3768
|
+
const criticalOrHigh = scanIssues.filter(
|
|
3769
|
+
(i) => i.startsWith("[CRITICAL]") || i.startsWith("[HIGH]")
|
|
3770
|
+
);
|
|
3771
|
+
if (criticalOrHigh.length > 0) {
|
|
3772
|
+
scanSpinner.fail("Security scan found threats.");
|
|
3773
|
+
newline();
|
|
3774
|
+
error(`Found ${criticalOrHigh.length} security issue${criticalOrHigh.length === 1 ? "" : "s"}:`);
|
|
3775
|
+
newline();
|
|
3776
|
+
for (const issue of scanIssues) {
|
|
3777
|
+
const color = issue.startsWith("[CRITICAL]") ? pc5.red : issue.startsWith("[HIGH]") ? pc5.yellow : pc5.dim;
|
|
3778
|
+
console.log(` ${color(issue)}`);
|
|
3779
|
+
}
|
|
3780
|
+
newline();
|
|
3781
|
+
if (isJsonMode()) {
|
|
3782
|
+
outputJson({ status: "error", error: "security_scan_failed", issues: scanIssues });
|
|
3783
|
+
process.exit(1);
|
|
3784
|
+
}
|
|
3785
|
+
errorWithFix(
|
|
3786
|
+
"Package contains potential security threats and cannot be published.",
|
|
3787
|
+
"Review and fix the issues above. Use `--skip-scan` to bypass (not recommended)."
|
|
3788
|
+
);
|
|
3789
|
+
process.exit(1);
|
|
3790
|
+
}
|
|
3791
|
+
scanSpinner.warn(`Security scan found ${scanIssues.length} warning${scanIssues.length === 1 ? "" : "s"}.`);
|
|
3792
|
+
for (const issue of scanIssues) {
|
|
3793
|
+
console.log(` ${pc5.dim(issue)}`);
|
|
3794
|
+
}
|
|
3795
|
+
} else {
|
|
3796
|
+
scanSpinner.succeed("Security scan passed.");
|
|
3797
|
+
}
|
|
3798
|
+
} else {
|
|
3799
|
+
debug("Skipping security scan (--skip-scan)");
|
|
3800
|
+
}
|
|
3801
|
+
const packSpinner = spinner("Packing archive...");
|
|
3802
|
+
let archiveBuffer;
|
|
3803
|
+
try {
|
|
3804
|
+
debug(`Packing directory: ${packageDir}`);
|
|
3805
|
+
archiveBuffer = await pack(packageDir);
|
|
3806
|
+
debug(`Archive size: ${archiveBuffer.length} bytes`);
|
|
3807
|
+
if (archiveBuffer.length > MAX_PACKAGE_SIZE) {
|
|
3808
|
+
packSpinner.fail("Package too large.");
|
|
3809
|
+
errorWithFix(
|
|
3810
|
+
`Package size (${formatBytes2(archiveBuffer.length)}) exceeds the maximum of ${formatBytes2(MAX_PACKAGE_SIZE)}.`,
|
|
3811
|
+
"Remove unnecessary files or large assets to reduce the package size."
|
|
3812
|
+
);
|
|
3813
|
+
process.exit(1);
|
|
3814
|
+
}
|
|
3815
|
+
packSpinner.succeed(`Packed ${formatBytes2(archiveBuffer.length)}`);
|
|
3816
|
+
} catch (err) {
|
|
3817
|
+
packSpinner.fail("Packing failed.");
|
|
3818
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
3819
|
+
errorWithFix(
|
|
3820
|
+
`Failed to pack archive: ${errMsg}`,
|
|
3821
|
+
"Check file permissions and ensure no symlinks exist in the package directory."
|
|
3822
|
+
);
|
|
3823
|
+
process.exit(1);
|
|
3824
|
+
}
|
|
3825
|
+
const digestSpinner = spinner("Computing digest...");
|
|
3826
|
+
const digest = await sha256(archiveBuffer);
|
|
3827
|
+
digestSpinner.succeed(`Digest: ${pc5.dim(digest.slice(0, 16) + "...")}`);
|
|
3828
|
+
debug(`SHA-256: ${digest}`);
|
|
3829
|
+
if (args["dry-run"]) {
|
|
3830
|
+
newline();
|
|
3831
|
+
success(`Dry run complete for ${pc5.bold(packageName)}@${pc5.yellow(packageVersion)}`);
|
|
3832
|
+
newline();
|
|
3833
|
+
field("Package", packageName);
|
|
3834
|
+
field("Version", packageVersion);
|
|
3835
|
+
field("Size", formatBytes2(archiveBuffer.length));
|
|
3836
|
+
field("SHA-256", digest);
|
|
3837
|
+
newline();
|
|
3838
|
+
hint("Remove `--dry-run` to publish for real.");
|
|
3839
|
+
if (isJsonMode()) {
|
|
3840
|
+
outputJson({
|
|
3841
|
+
status: "dry_run",
|
|
3842
|
+
package: packageName,
|
|
3843
|
+
version: packageVersion,
|
|
3844
|
+
size: archiveBuffer.length,
|
|
3845
|
+
sha256: digest
|
|
3846
|
+
});
|
|
3847
|
+
}
|
|
3848
|
+
return;
|
|
3849
|
+
}
|
|
3850
|
+
if (!args.yes && !args.json && !args.quiet) {
|
|
3851
|
+
newline();
|
|
3852
|
+
info(`About to publish:`);
|
|
3853
|
+
field("Package", pc5.cyan(packageName));
|
|
3854
|
+
field("Version", pc5.yellow(packageVersion));
|
|
3855
|
+
field("Size", formatBytes2(archiveBuffer.length));
|
|
3856
|
+
if (auth.username) {
|
|
3857
|
+
field("Author", auth.username);
|
|
3858
|
+
}
|
|
3859
|
+
newline();
|
|
3860
|
+
const confirmed = await p5.confirm({
|
|
3861
|
+
message: `Publish ${pc5.bold(packageName)}@${pc5.yellow(packageVersion)}?`
|
|
3862
|
+
});
|
|
3863
|
+
if (p5.isCancel(confirmed) || !confirmed) {
|
|
3864
|
+
p5.cancel("Publish cancelled.");
|
|
3865
|
+
process.exit(0);
|
|
3866
|
+
}
|
|
3867
|
+
}
|
|
3868
|
+
const api2 = createApiClient();
|
|
3869
|
+
const publishSpinner = spinner("Requesting upload URL...");
|
|
3870
|
+
let uploadUrl;
|
|
3871
|
+
let resolvedScope;
|
|
3872
|
+
let resolvedName;
|
|
3873
|
+
for (let attempt = 0; ; attempt++) {
|
|
3874
|
+
try {
|
|
3875
|
+
debug("PUT /api/v1/packages/publish");
|
|
3876
|
+
const result = await api2.requestPublish(
|
|
3877
|
+
manifest,
|
|
3878
|
+
digest,
|
|
3879
|
+
archiveBuffer.length
|
|
3880
|
+
);
|
|
3881
|
+
if (!result.data) {
|
|
3882
|
+
publishSpinner.fail("Failed to initiate publish.");
|
|
3883
|
+
error("Server returned an unexpected response.");
|
|
3884
|
+
process.exit(1);
|
|
3885
|
+
}
|
|
3886
|
+
uploadUrl = result.data.upload_url;
|
|
3887
|
+
resolvedScope = result.data.scope;
|
|
3888
|
+
resolvedName = result.data.name;
|
|
3889
|
+
debug(`Upload URL received for ${resolvedScope}/${resolvedName}@${result.data.version}`);
|
|
3890
|
+
break;
|
|
3891
|
+
} catch (err) {
|
|
3892
|
+
if (err instanceof ApiError && err.statusCode === 429 && attempt < 2) {
|
|
3893
|
+
const waitSec = parseRetryAfter(err.message) ?? 60;
|
|
3894
|
+
for (let t = waitSec; t > 0; t--) {
|
|
3895
|
+
publishSpinner.text = `Rate limited \u2014 retrying in ${t}s...`;
|
|
3896
|
+
await sleep2(1e3);
|
|
3897
|
+
}
|
|
3898
|
+
publishSpinner.text = "Requesting upload URL...";
|
|
3899
|
+
continue;
|
|
3900
|
+
}
|
|
3901
|
+
publishSpinner.fail("Publish request failed.");
|
|
3902
|
+
handlePublishError(err, packageName, packageVersion);
|
|
3903
|
+
process.exit(1);
|
|
3904
|
+
}
|
|
3905
|
+
}
|
|
3906
|
+
publishSpinner.text = "Uploading package...";
|
|
3907
|
+
try {
|
|
3908
|
+
debug(`Uploading ${archiveBuffer.length} bytes...`);
|
|
3909
|
+
await api2.uploadFile(uploadUrl, archiveBuffer);
|
|
3910
|
+
debug("Upload complete.");
|
|
3911
|
+
} catch {
|
|
3912
|
+
publishSpinner.fail("Upload failed.");
|
|
3913
|
+
errorWithFix(
|
|
3914
|
+
"Failed to upload the package archive.",
|
|
3915
|
+
"Check your network connection and try again."
|
|
3916
|
+
);
|
|
3917
|
+
process.exit(1);
|
|
3918
|
+
}
|
|
3919
|
+
publishSpinner.text = "Confirming publish...";
|
|
3920
|
+
let confirmResult;
|
|
3921
|
+
for (let attempt = 0; ; attempt++) {
|
|
3922
|
+
try {
|
|
3923
|
+
debug("POST /api/v1/packages/publish/confirm");
|
|
3924
|
+
confirmResult = await api2.confirmPublish(
|
|
3925
|
+
resolvedScope,
|
|
3926
|
+
resolvedName,
|
|
3927
|
+
packageVersion,
|
|
3928
|
+
digest,
|
|
3929
|
+
manifest
|
|
3930
|
+
);
|
|
3931
|
+
break;
|
|
3932
|
+
} catch (err) {
|
|
3933
|
+
if (err instanceof ApiError && err.statusCode === 429 && attempt < 2) {
|
|
3934
|
+
const waitSec = parseRetryAfter(err.message) ?? 60;
|
|
3935
|
+
for (let t = waitSec; t > 0; t--) {
|
|
3936
|
+
publishSpinner.text = `Rate limited \u2014 retrying confirmation in ${t}s...`;
|
|
3937
|
+
await sleep2(1e3);
|
|
3938
|
+
}
|
|
3939
|
+
publishSpinner.text = "Confirming publish...";
|
|
3940
|
+
continue;
|
|
3941
|
+
}
|
|
3942
|
+
publishSpinner.fail("Publish confirmation failed.");
|
|
3943
|
+
handlePublishError(err, packageName, packageVersion);
|
|
3944
|
+
process.exit(1);
|
|
3945
|
+
}
|
|
3946
|
+
}
|
|
3947
|
+
if (!confirmResult?.data) {
|
|
3948
|
+
publishSpinner.fail("Publish confirmation failed.");
|
|
3949
|
+
error("Server returned an unexpected response.");
|
|
3950
|
+
process.exit(1);
|
|
3951
|
+
}
|
|
3952
|
+
publishSpinner.succeed("Published!");
|
|
3953
|
+
const confirmedData = confirmResult.data;
|
|
3954
|
+
const fullPublishedName = confirmedData.name;
|
|
3955
|
+
const publishUrl = confirmedData.url;
|
|
3956
|
+
if (isJsonMode()) {
|
|
3957
|
+
outputJson({
|
|
3958
|
+
status: "ok",
|
|
3959
|
+
package: fullPublishedName,
|
|
3960
|
+
version: packageVersion,
|
|
3961
|
+
sha256: digest,
|
|
3962
|
+
size: archiveBuffer.length,
|
|
3963
|
+
signature: confirmedData.signature,
|
|
3964
|
+
url: publishUrl
|
|
3965
|
+
});
|
|
3966
|
+
return;
|
|
3967
|
+
}
|
|
3968
|
+
newline();
|
|
3969
|
+
success(`Published ${pc5.bold(pc5.cyan(fullPublishedName))}@${pc5.yellow(packageVersion)}`);
|
|
3970
|
+
newline();
|
|
3971
|
+
field("URL", publishUrl);
|
|
3972
|
+
field("SHA-256", pc5.dim(digest.slice(0, 16) + "..."));
|
|
3973
|
+
field("Size", formatBytes2(archiveBuffer.length));
|
|
3974
|
+
if (confirmedData.signature) {
|
|
3975
|
+
field("Signature", pc5.dim(confirmedData.signature.slice(0, 16) + "..."));
|
|
3976
|
+
}
|
|
3977
|
+
newline();
|
|
3978
|
+
hint(`View: ${publishUrl}`);
|
|
3979
|
+
hint(`Install: \`hubbits pull ${fullPublishedName}\``);
|
|
3980
|
+
}
|
|
3981
|
+
});
|
|
3982
|
+
async function scanDirectory(dir) {
|
|
3983
|
+
const allIssues = [];
|
|
3984
|
+
try {
|
|
3985
|
+
const files = collectScannableFiles(dir, dir);
|
|
3986
|
+
debug(`Scanning ${files.length} file(s) for security threats...`);
|
|
3987
|
+
for (const filePath of files) {
|
|
3988
|
+
try {
|
|
3989
|
+
const content = readFileSync(filePath, "utf-8");
|
|
3990
|
+
const result = scanPromptInjection(content);
|
|
3991
|
+
if (result.issues.length > 0) {
|
|
3992
|
+
const relPath = filePath.slice(dir.length + 1);
|
|
3993
|
+
for (const issue of result.issues) {
|
|
3994
|
+
allIssues.push(`${relPath}: ${issue}`);
|
|
3995
|
+
}
|
|
3996
|
+
}
|
|
3997
|
+
} catch {
|
|
3998
|
+
debug(`Skipping unreadable file: ${filePath}`);
|
|
3999
|
+
}
|
|
4000
|
+
}
|
|
4001
|
+
} catch (err) {
|
|
4002
|
+
debug(`Security scan error: ${err instanceof Error ? err.message : String(err)}`);
|
|
4003
|
+
}
|
|
4004
|
+
return allIssues;
|
|
4005
|
+
}
|
|
4006
|
+
function collectScannableFiles(dir, rootDir) {
|
|
4007
|
+
const files = [];
|
|
4008
|
+
try {
|
|
4009
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
4010
|
+
for (const entry of entries) {
|
|
4011
|
+
const fullPath = join(dir, entry.name);
|
|
4012
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") {
|
|
4013
|
+
continue;
|
|
4014
|
+
}
|
|
4015
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
4016
|
+
files.push(...collectScannableFiles(fullPath, rootDir));
|
|
4017
|
+
} else if (entry.isFile()) {
|
|
4018
|
+
const ext = entry.name.slice(entry.name.lastIndexOf("."));
|
|
4019
|
+
if (SCANNABLE_EXTENSIONS.includes(ext)) {
|
|
4020
|
+
files.push(fullPath);
|
|
4021
|
+
}
|
|
4022
|
+
}
|
|
4023
|
+
}
|
|
4024
|
+
} catch {
|
|
4025
|
+
}
|
|
4026
|
+
return files;
|
|
4027
|
+
}
|
|
4028
|
+
function findManifestFile2(dir) {
|
|
4029
|
+
const candidates = ["hubbit.yaml", "hubbit.yml"];
|
|
4030
|
+
for (const name of candidates) {
|
|
4031
|
+
const filePath = join(dir, name);
|
|
4032
|
+
if (existsSync(filePath)) return filePath;
|
|
4033
|
+
}
|
|
4034
|
+
return null;
|
|
4035
|
+
}
|
|
4036
|
+
function handlePublishError(err, packageName, version) {
|
|
4037
|
+
if (err instanceof ApiError) {
|
|
4038
|
+
switch (err.statusCode) {
|
|
4039
|
+
case 401:
|
|
4040
|
+
errorWithFix(
|
|
4041
|
+
"Authentication expired.",
|
|
4042
|
+
"Run `hubbits login` to re-authenticate."
|
|
4043
|
+
);
|
|
4044
|
+
break;
|
|
4045
|
+
case 403:
|
|
4046
|
+
errorWithFix(
|
|
4047
|
+
err.message || "You do not have permission to publish this package.",
|
|
4048
|
+
"Check that you are the package owner, or create a new package with a different name."
|
|
4049
|
+
);
|
|
4050
|
+
break;
|
|
4051
|
+
case 409:
|
|
4052
|
+
errorWithFix(
|
|
4053
|
+
`Version "${version}" already exists for "${packageName}".`,
|
|
4054
|
+
`Bump the version in hubbit.yaml and try again.`
|
|
4055
|
+
);
|
|
4056
|
+
break;
|
|
4057
|
+
case 422:
|
|
4058
|
+
error(`Validation error: ${err.message}`);
|
|
4059
|
+
if (err.details) {
|
|
4060
|
+
for (const [fieldName, msgs] of Object.entries(err.details)) {
|
|
4061
|
+
for (const msg of msgs) {
|
|
4062
|
+
console.log(` ${pc5.red("\u2717")} ${fieldName}: ${msg}`);
|
|
4063
|
+
}
|
|
4064
|
+
}
|
|
4065
|
+
}
|
|
4066
|
+
hint("Fix the validation errors above and try again.");
|
|
4067
|
+
break;
|
|
4068
|
+
default:
|
|
4069
|
+
errorWithFix(err.message, "Check your network connection and try again.");
|
|
4070
|
+
}
|
|
4071
|
+
} else {
|
|
4072
|
+
errorWithFix(
|
|
4073
|
+
"An unexpected error occurred.",
|
|
4074
|
+
"Check your network connection and try again."
|
|
4075
|
+
);
|
|
4076
|
+
}
|
|
4077
|
+
}
|
|
4078
|
+
function formatBytes2(bytes) {
|
|
4079
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
4080
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
4081
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
4082
|
+
}
|
|
4083
|
+
function parseRetryAfter(message) {
|
|
4084
|
+
const match = message.match(/retry after (\d+)/i);
|
|
4085
|
+
return match ? parseInt(match[1], 10) : null;
|
|
4086
|
+
}
|
|
4087
|
+
function sleep2(ms) {
|
|
4088
|
+
return new Promise((resolve7) => setTimeout(resolve7, ms));
|
|
4089
|
+
}
|
|
4090
|
+
var COMMANDS = [
|
|
4091
|
+
"login",
|
|
4092
|
+
"logout",
|
|
4093
|
+
"search",
|
|
4094
|
+
"init",
|
|
4095
|
+
"create",
|
|
4096
|
+
"validate",
|
|
4097
|
+
"pull",
|
|
4098
|
+
"publish",
|
|
4099
|
+
"completion"
|
|
4100
|
+
];
|
|
4101
|
+
var SHELLS = ["zsh", "bash", "fish", "powershell"];
|
|
4102
|
+
var completion_default = defineCommand({
|
|
4103
|
+
meta: {
|
|
4104
|
+
name: "completion",
|
|
4105
|
+
description: "Generate shell tab-completion script"
|
|
4106
|
+
},
|
|
4107
|
+
args: {
|
|
4108
|
+
shell: {
|
|
4109
|
+
type: "positional",
|
|
4110
|
+
description: "Shell to generate completions for (zsh | bash | fish | powershell)",
|
|
4111
|
+
required: false
|
|
4112
|
+
}
|
|
4113
|
+
},
|
|
4114
|
+
run({ args }) {
|
|
4115
|
+
const shell = args.shell ?? detectShell();
|
|
4116
|
+
if (!shell || !SHELLS.includes(shell)) {
|
|
4117
|
+
errorWithFix(
|
|
4118
|
+
`Unknown shell: "${shell ?? ""}"`,
|
|
4119
|
+
`Run \`hubbits completion <shell>\` where shell is one of: ${SHELLS.join(", ")}`
|
|
4120
|
+
);
|
|
4121
|
+
process.exit(1);
|
|
4122
|
+
}
|
|
4123
|
+
const script = generateScript(shell);
|
|
4124
|
+
process.stdout.write(script + "\n");
|
|
4125
|
+
}
|
|
4126
|
+
});
|
|
4127
|
+
function detectShell() {
|
|
4128
|
+
const shell = process.env.SHELL ?? "";
|
|
4129
|
+
if (shell.endsWith("zsh")) return "zsh";
|
|
4130
|
+
if (shell.endsWith("bash")) return "bash";
|
|
4131
|
+
if (shell.endsWith("fish")) return "fish";
|
|
4132
|
+
if (process.platform === "win32") return "powershell";
|
|
4133
|
+
return null;
|
|
4134
|
+
}
|
|
4135
|
+
function generateScript(shell) {
|
|
4136
|
+
switch (shell) {
|
|
4137
|
+
case "zsh":
|
|
4138
|
+
return zshScript();
|
|
4139
|
+
case "bash":
|
|
4140
|
+
return bashScript();
|
|
4141
|
+
case "fish":
|
|
4142
|
+
return fishScript();
|
|
4143
|
+
case "powershell":
|
|
4144
|
+
return powershellScript();
|
|
4145
|
+
}
|
|
4146
|
+
}
|
|
4147
|
+
function zshScript() {
|
|
4148
|
+
return `# Hubbits CLI \u2014 zsh completion
|
|
4149
|
+
# Add to ~/.zshrc: source <(hubbits completion zsh)
|
|
4150
|
+
|
|
4151
|
+
_hubbits_completions() {
|
|
4152
|
+
local -a commands
|
|
4153
|
+
commands=(
|
|
4154
|
+
${COMMANDS.map((c) => `'${c}:${cmdDescription(c)}'`).join("\n ")}
|
|
4155
|
+
)
|
|
4156
|
+
|
|
4157
|
+
if (( CURRENT == 2 )); then
|
|
4158
|
+
_describe 'hubbits command' commands
|
|
4159
|
+
elif (( CURRENT >= 3 )); then
|
|
4160
|
+
case \${words[2]} in
|
|
4161
|
+
completion)
|
|
4162
|
+
local -a shells
|
|
4163
|
+
shells=('zsh:zsh completion' 'bash:bash completion' 'fish:fish completion' 'powershell:PowerShell completion')
|
|
4164
|
+
_describe 'shell' shells
|
|
4165
|
+
;;
|
|
4166
|
+
pull|validate)
|
|
4167
|
+
_files
|
|
4168
|
+
;;
|
|
4169
|
+
esac
|
|
4170
|
+
fi
|
|
4171
|
+
}
|
|
4172
|
+
|
|
4173
|
+
if [[ -n "\${ZSH_VERSION:-}" ]]; then
|
|
4174
|
+
if (( \${+functions[compdef]} )); then
|
|
4175
|
+
compdef _hubbits_completions hubbits
|
|
4176
|
+
else
|
|
4177
|
+
autoload -Uz compinit && compinit
|
|
4178
|
+
compdef _hubbits_completions hubbits
|
|
4179
|
+
fi
|
|
4180
|
+
fi`;
|
|
4181
|
+
}
|
|
4182
|
+
function bashScript() {
|
|
4183
|
+
const cmdsQuoted = COMMANDS.map((c) => `"${c}"`).join(" ");
|
|
4184
|
+
return `# Hubbits CLI \u2014 bash completion
|
|
4185
|
+
# Add to ~/.bashrc: source <(hubbits completion bash)
|
|
4186
|
+
|
|
4187
|
+
_hubbits_completions() {
|
|
4188
|
+
local cur prev words
|
|
4189
|
+
_init_completion 2>/dev/null || {
|
|
4190
|
+
COMPREPLY=()
|
|
4191
|
+
cur=\${COMP_WORDS[COMP_CWORD]}
|
|
4192
|
+
prev=\${COMP_WORDS[COMP_CWORD-1]}
|
|
4193
|
+
}
|
|
4194
|
+
|
|
4195
|
+
local commands=(${cmdsQuoted})
|
|
4196
|
+
|
|
4197
|
+
if [[ \${COMP_CWORD} -eq 1 ]]; then
|
|
4198
|
+
COMPREPLY=( $(compgen -W "\${commands[*]}" -- "$cur") )
|
|
4199
|
+
return
|
|
4200
|
+
fi
|
|
4201
|
+
|
|
4202
|
+
case "$prev" in
|
|
4203
|
+
completion)
|
|
4204
|
+
COMPREPLY=( $(compgen -W "zsh bash fish powershell" -- "$cur") )
|
|
4205
|
+
;;
|
|
4206
|
+
pull|validate)
|
|
4207
|
+
COMPREPLY=( $(compgen -f -- "$cur") )
|
|
4208
|
+
;;
|
|
4209
|
+
esac
|
|
4210
|
+
}
|
|
4211
|
+
|
|
4212
|
+
complete -F _hubbits_completions hubbits`;
|
|
4213
|
+
}
|
|
4214
|
+
function fishScript() {
|
|
4215
|
+
const cmdCompletions = COMMANDS.map(
|
|
4216
|
+
(c) => `complete -c hubbits -f -n '__fish_use_subcommand' -a '${c}' -d '${cmdDescription(c)}'`
|
|
4217
|
+
).join("\n");
|
|
4218
|
+
return `# Hubbits CLI \u2014 fish completion
|
|
4219
|
+
# Save to ~/.config/fish/completions/hubbits.fish
|
|
4220
|
+
|
|
4221
|
+
# Disable file completions for hubbits
|
|
4222
|
+
complete -c hubbits -f
|
|
4223
|
+
|
|
4224
|
+
# Top-level subcommands
|
|
4225
|
+
${cmdCompletions}
|
|
4226
|
+
|
|
4227
|
+
# completion subcommand \u2014 shell argument
|
|
4228
|
+
complete -c hubbits -n '__fish_seen_subcommand_from completion' -a 'zsh' -d 'zsh completion script'
|
|
4229
|
+
complete -c hubbits -n '__fish_seen_subcommand_from completion' -a 'bash' -d 'bash completion script'
|
|
4230
|
+
complete -c hubbits -n '__fish_seen_subcommand_from completion' -a 'fish' -d 'fish completion script'
|
|
4231
|
+
complete -c hubbits -n '__fish_seen_subcommand_from completion' -a 'powershell' -d 'PowerShell completion script'
|
|
4232
|
+
|
|
4233
|
+
# pull / validate \u2014 allow file paths
|
|
4234
|
+
complete -c hubbits -n '__fish_seen_subcommand_from pull validate' -a '(__fish_complete_path)'`;
|
|
4235
|
+
}
|
|
4236
|
+
function powershellScript() {
|
|
4237
|
+
const cmdsArray = COMMANDS.map((c) => `'${c}'`).join(", ");
|
|
4238
|
+
return `# Hubbits CLI \u2014 PowerShell completion
|
|
4239
|
+
# Add to your PowerShell profile ($PROFILE):
|
|
4240
|
+
# Invoke-Expression (hubbits completion powershell)
|
|
4241
|
+
|
|
4242
|
+
Register-ArgumentCompleter -Native -CommandName @('hubbits') -ScriptBlock {
|
|
4243
|
+
param($wordToComplete, $commandAst, $cursorPosition)
|
|
4244
|
+
|
|
4245
|
+
$commands = @(${cmdsArray})
|
|
4246
|
+
$shells = @('zsh', 'bash', 'fish', 'powershell')
|
|
4247
|
+
|
|
4248
|
+
$tokens = $commandAst.CommandElements
|
|
4249
|
+
$subCmd = if ($tokens.Count -ge 2) { $tokens[1].ToString() } else { $null }
|
|
4250
|
+
|
|
4251
|
+
if ($tokens.Count -le 2) {
|
|
4252
|
+
# Complete top-level commands
|
|
4253
|
+
$commands | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
|
|
4254
|
+
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
|
|
4255
|
+
}
|
|
4256
|
+
} elseif ($subCmd -eq 'completion') {
|
|
4257
|
+
$shells | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
|
|
4258
|
+
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', "$_ completion script")
|
|
4259
|
+
}
|
|
4260
|
+
}
|
|
4261
|
+
}`;
|
|
4262
|
+
}
|
|
4263
|
+
function cmdDescription(cmd) {
|
|
4264
|
+
const descriptions = {
|
|
4265
|
+
login: "Log in to your Hubbits account",
|
|
4266
|
+
logout: "Log out of your Hubbits account",
|
|
4267
|
+
search: "Search for hubbits",
|
|
4268
|
+
init: "Initialize a new hubbit in the current directory",
|
|
4269
|
+
create: "Create a new hubbit or import from GitHub",
|
|
4270
|
+
validate: "Validate a hubbit.yaml manifest",
|
|
4271
|
+
pull: "Download a hubbit package",
|
|
4272
|
+
publish: "Publish a hubbit package",
|
|
4273
|
+
completion: "Generate shell tab-completion script"
|
|
4274
|
+
};
|
|
4275
|
+
return descriptions[cmd] ?? cmd;
|
|
4276
|
+
}
|
|
4277
|
+
|
|
4278
|
+
// src/index.ts
|
|
4279
|
+
if (process.env.HUBBITS_CWD) {
|
|
4280
|
+
process.chdir(process.env.HUBBITS_CWD);
|
|
4281
|
+
}
|
|
4282
|
+
var main = defineCommand({
|
|
4283
|
+
meta: {
|
|
4284
|
+
name: "hubbits",
|
|
4285
|
+
version: "0.1.0",
|
|
4286
|
+
description: "Hubbits CLI - Interactive AI experience manager"
|
|
4287
|
+
},
|
|
4288
|
+
args: {
|
|
4289
|
+
version: {
|
|
4290
|
+
type: "boolean",
|
|
4291
|
+
alias: "v",
|
|
4292
|
+
description: "Show version"
|
|
4293
|
+
}
|
|
4294
|
+
},
|
|
4295
|
+
subCommands: {
|
|
4296
|
+
login: login_default,
|
|
4297
|
+
logout: logout_default,
|
|
4298
|
+
search: search_default,
|
|
4299
|
+
init: init_default,
|
|
4300
|
+
create: create_default,
|
|
4301
|
+
validate: validate_default,
|
|
4302
|
+
pull: pull_default,
|
|
4303
|
+
publish: publish_default,
|
|
4304
|
+
completion: completion_default
|
|
4305
|
+
},
|
|
4306
|
+
run({ args, rawArgs }) {
|
|
4307
|
+
if (args.version) {
|
|
4308
|
+
console.log("hubbits/0.1.0");
|
|
4309
|
+
return;
|
|
4310
|
+
}
|
|
4311
|
+
if (rawArgs.some((a) => !a.startsWith("-"))) return;
|
|
4312
|
+
console.log("Hubbits CLI v0.1.0");
|
|
4313
|
+
console.log("Run `hubbits --help` for available commands.");
|
|
4314
|
+
}
|
|
4315
|
+
});
|
|
4316
|
+
runMain(main);
|