hacklab 26.921.1 → 26.921.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +39 -8
  2. package/dist/index.js +225 -40
  3. package/package.json +11 -7
package/README.md CHANGED
@@ -1,15 +1,17 @@
1
1
  # Hacklab CLI
2
2
 
3
- A Bun CLI for updating your Hacklab bio and social links with your coding agent.
3
+ A CLI for updating your Hacklab bio and social links with your coding agent.
4
4
 
5
- Requires Bun 1.4.2 or newer.
5
+ Requires Node.js 22 or newer. Bun 1.4.2 or newer is also supported.
6
6
 
7
7
  ```sh
8
- bun add --global hacklab@beta
8
+ npm install --global hacklab@beta
9
9
  hacklab --help
10
10
  ```
11
11
 
12
- Sign in at [Hacklab](https://beta.hacklab.so/?onboarding=1) and copy the agent instructions. They include a one-hour credential scoped to your profile. Keep it private and pass it as `HACKLAB_ONBOARDING_TOKEN` in the upload command's environment.
12
+ For Bun, use `bun install --global hacklab@beta` and `bunx --bun hacklab@beta --help`.
13
+
14
+ Copy the agent instructions from the signed-in onboarding dialog. They contain no credentials. When you upload, the CLI opens a browser page to sign in with GitHub and authorize the CLI through Better Auth. Check that the code matches your terminal before approving.
13
15
 
14
16
  ## Preview a profile
15
17
 
@@ -29,18 +31,47 @@ Review the exact bio and links first. Agents must ask the user for approval befo
29
31
  hacklab onboard --file /path/to/profile.json --confirm-upload
30
32
  ```
31
33
 
32
- Only the bio and social links are uploaded. The CLI does not scan projects or upload source code, local paths, session transcripts, or usage statistics.
34
+ Confirmed onboarding first syncs usage, then saves the profile, sharing one browser authorization. Approval must cover both the profile and the usage report. Preview mode does neither. If syncing fails, onboarding stops before saving the profile.
35
+
36
+ ## Sync usage
37
+
38
+ ```sh
39
+ hacklab sync
40
+ ```
41
+
42
+ The CLI includes pinned `ccusage@20.0.24` and runs `ccusage session --json`. No separate ccusage installation is needed. Its stdout is posted unchanged to `/api/token-usage`. This report includes agent/session identifiers, tokens, costs, model breakdowns, and metadata, which can include project paths. Source code and transcript contents are not uploaded.
43
+
44
+ The API accepts the exact `{ session, totals }` envelope. `token_usage` stores each session with the upstream camelCase field names and nested JSON unchanged; only the authenticated `user_id` is added by the server. Totals are validated but not stored because they can be derived from the session rows. Repeated syncs replace counts for the same user, agent, and period rather than adding them. Missing sessions are retained, so syncing another machine does not delete earlier uploads. Reports are limited to 20 MB. The first upload uses browser authorization. Later commands reuse the saved session, checking that it is still valid; expired or revoked sessions open the browser again. Credentials are stored per Hacklab origin in `~/.config/hacklab/credentials/`, with owner-only directory and file permissions on Unix. Run `hacklab logout` to forget local authorization for the current Hacklab server.
45
+
46
+ ## Log in
47
+
48
+ ```sh
49
+ hacklab login
50
+ ```
51
+
52
+ Authorizes the CLI and saves the session without collecting or uploading usage. An existing valid session is reused. To check the authorization dialog again, run `hacklab logout` followed by `hacklab login`.
53
+
54
+ ## Log out
55
+
56
+ ```sh
57
+ hacklab logout
58
+ hacklab sync
59
+ ```
60
+
61
+ Logout clears the saved CLI credential for the current Hacklab server, even when offline. The next sync opens the authorization dialog on the Arena again. Your browser stays signed in. Other Hacklab servers' credentials are preserved; this command does not revoke the server session.
33
62
 
34
63
  ## Local development
35
64
 
65
+ Building from source requires Bun 1.4.2 or newer; the published package runs with Node.js without Bun.
66
+
36
67
  From the Hacklab repository:
37
68
 
38
69
  ```sh
39
70
  bun install --frozen-lockfile
40
- bunx turbo run build --filter=hacklab
71
+ bun run --cwd packages/cli build:dev
41
72
  cd packages/cli
42
73
  bun link
43
- hacklab onboard --file /path/to/profile.json --url http://localhost:3000
74
+ hacklab onboard --file /path/to/profile.json
44
75
  ```
45
76
 
46
- Use instructions and a credential from the local onboarding dialog. Credentials are bound to their issuing origin and cannot be used interchangeably between localhost and production.
77
+ Source runs and `build:dev` always use `http://localhost:3000`. Production builds embed `BETTER_AUTH_URL` from the build environment or `apps/web/.env` / `.env.local`; it must be an HTTPS origin. Set the production URL before running `bun run build` or publishing. No `--url` override is supported. Credentials are saved locally for reuse and are never copied into the prompt. Development and production credentials are stored separately.
package/dist/index.js CHANGED
@@ -1,27 +1,197 @@
1
- #!/usr/bin/env bun
2
- // @bun
1
+ #!/usr/bin/env node
3
2
 
4
3
  // src/index.ts
5
- import { parseArgs } from "util";
4
+ import { parseArgs } from "node:util";
6
5
  // package.json
7
- var version = "26.921.1";
6
+ var version = "26.921.2";
7
+
8
+ // src/auth.ts
9
+ import { spawn } from "node:child_process";
10
+ import { setTimeout as sleep } from "node:timers/promises";
11
+
12
+ // src/credentials.ts
13
+ import { createHash, randomUUID } from "node:crypto";
14
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
17
+ var credentialsDirectory = join(homedir(), ".config", "hacklab", "credentials");
18
+ function credentialPath(origin, directory) {
19
+ return join(directory, `${createHash("sha256").update(origin).digest("hex")}.json`);
20
+ }
21
+ async function readCredential(origin, directory = credentialsDirectory) {
22
+ try {
23
+ const value = JSON.parse(await readFile(credentialPath(origin, directory), "utf8"));
24
+ return value?.origin === origin && typeof value.token === "string" && value.token.length > 0 ? value.token : null;
25
+ } catch (error) {
26
+ if (error instanceof SyntaxError || error.code === "ENOENT")
27
+ return null;
28
+ throw new Error("Could not read saved Hacklab authorization.");
29
+ }
30
+ }
31
+ async function saveCredential(origin, token, directory = credentialsDirectory) {
32
+ await mkdir(directory, { recursive: true, mode: 448 });
33
+ await chmod(directory, 448);
34
+ const path = credentialPath(origin, directory);
35
+ const temporary = `${path}.${randomUUID()}.tmp`;
36
+ try {
37
+ await writeFile(temporary, JSON.stringify({ origin, token }), { mode: 384, flag: "wx" });
38
+ await rename(temporary, path);
39
+ } finally {
40
+ await rm(temporary, { force: true });
41
+ }
42
+ }
43
+ async function removeCredential(origin, directory = credentialsDirectory) {
44
+ await rm(credentialPath(origin, directory), { force: true });
45
+ }
46
+
47
+ // src/auth.ts
48
+ var clientId = "hacklab-cli";
49
+ function openBrowser(url) {
50
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32" : "xdg-open";
51
+ const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
52
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
53
+ child.on("error", () => console.log("Open the authorization URL above in your browser."));
54
+ child.unref();
55
+ }
56
+ async function authenticate(origin, launchBrowser = openBrowser, directory = credentialsDirectory) {
57
+ const saved = await readCredential(origin, directory);
58
+ if (saved) {
59
+ const response = await fetch(`${origin}/api/auth/get-session`, {
60
+ headers: { Authorization: `Bearer ${saved}` },
61
+ redirect: "error",
62
+ signal: AbortSignal.timeout(30000)
63
+ });
64
+ if (response.ok) {
65
+ const session = await response.json();
66
+ if (session?.session?.id && session.user?.id)
67
+ return saved;
68
+ if (session !== null)
69
+ throw new Error("Invalid saved authorization response. Try again later.");
70
+ } else if (response.status !== 401) {
71
+ throw new Error(`Could not verify saved Hacklab authorization (HTTP ${response.status}). Try again later.`);
72
+ }
73
+ await removeCredential(origin, directory);
74
+ console.log("Your saved Hacklab authorization expired or was revoked. Sign in again.");
75
+ }
76
+ const request = (path, body) => fetch(`${origin}/api/auth${path}`, {
77
+ method: "POST",
78
+ headers: { "Content-Type": "application/json" },
79
+ body: JSON.stringify(body),
80
+ redirect: "error",
81
+ signal: AbortSignal.timeout(30000)
82
+ });
83
+ const response = await request("/device/code", { client_id: clientId });
84
+ if (!response.ok)
85
+ throw new Error(`Could not start CLI sign-in (HTTP ${response.status}).`);
86
+ const code = await response.json();
87
+ const verification = new URL(code.verification_uri_complete, origin);
88
+ if (verification.origin !== origin || !code.device_code || !code.user_code || !Number.isFinite(code.expires_in) || code.expires_in <= 0 || !Number.isFinite(code.interval) || code.interval <= 0) {
89
+ throw new Error("Invalid CLI authorization response.");
90
+ }
91
+ console.log(`Opening your browser to sign in to Hacklab.
92
+ If it does not open, visit: ${verification.href}
93
+ Waiting for you to connect your account…`);
94
+ launchBrowser(verification.href);
95
+ const deadline = Date.now() + code.expires_in * 1000;
96
+ let interval = code.interval * 1000;
97
+ while (Date.now() < deadline) {
98
+ await sleep(Math.min(interval, deadline - Date.now()));
99
+ if (Date.now() >= deadline)
100
+ break;
101
+ const tokenResponse = await request("/device/token", {
102
+ client_id: clientId,
103
+ device_code: code.device_code,
104
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code"
105
+ });
106
+ const result = await tokenResponse.json();
107
+ if (tokenResponse.ok && result.access_token) {
108
+ await saveCredential(origin, result.access_token, directory);
109
+ return result.access_token;
110
+ }
111
+ if (result.error === "authorization_pending")
112
+ continue;
113
+ if (result.error === "slow_down") {
114
+ interval += 5000;
115
+ continue;
116
+ }
117
+ if (result.error === "access_denied")
118
+ throw new Error("CLI authorization was denied. Nothing uploaded.");
119
+ if (result.error === "expired_token")
120
+ break;
121
+ throw new Error(`CLI sign-in failed (HTTP ${tokenResponse.status}). Run the command again to retry.`);
122
+ }
123
+ throw new Error("CLI sign-in expired. Run the command again to retry.");
124
+ }
8
125
 
9
126
  // src/onboard.ts
10
- function normalizeOrigin(value) {
11
- const url = new URL(value);
12
- const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
13
- if (url.username || url.password || url.search || url.hash || url.pathname !== "/" || url.protocol !== "https:" && !(local && url.protocol === "http:"))
14
- throw new Error("Use an HTTPS Hacklab origin, or HTTP localhost for development.");
15
- return url.origin;
127
+ import { readFile as readFile2, stat } from "node:fs/promises";
128
+
129
+ // src/origin.ts
130
+ function cliOrigin() {
131
+ return "https://beta.hacklab.so";
16
132
  }
133
+
134
+ // src/sync.ts
135
+ import { spawn as spawn2 } from "node:child_process";
136
+ import { once } from "node:events";
137
+ import { createRequire } from "node:module";
138
+ import { text } from "node:stream/consumers";
139
+ async function collectUsage() {
140
+ const require2 = createRequire(import.meta.url);
141
+ const child = spawn2(process.execPath, [require2.resolve("ccusage/src/cli.js"), "session", "--json"], { stdio: ["ignore", "pipe", "inherit"] });
142
+ const [body, [exitCode]] = await Promise.all([text(child.stdout), once(child, "close")]);
143
+ if (exitCode !== 0)
144
+ throw new Error(`ccusage failed (exit ${exitCode}). Nothing synced.`);
145
+ let report;
146
+ try {
147
+ report = JSON.parse(body);
148
+ } catch {
149
+ throw new Error("ccusage returned invalid JSON. Nothing synced.");
150
+ }
151
+ if (!Array.isArray(report?.session) || !report.totals) {
152
+ throw new Error("Unexpected ccusage report. Nothing synced.");
153
+ }
154
+ if (Buffer.byteLength(body) > 20 * 1024 * 1024)
155
+ throw new Error("Usage report exceeds 20 MB. Nothing synced.");
156
+ return body;
157
+ }
158
+ async function uploadUsage(origin, token, body) {
159
+ const response = await fetch(`${origin}/api/token-usage`, {
160
+ method: "POST",
161
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
162
+ body,
163
+ redirect: "error",
164
+ signal: AbortSignal.timeout(60000)
165
+ });
166
+ if (!response.ok)
167
+ throw new Error(`Hacklab rejected the usage report (HTTP ${response.status}). Run hacklab sync to retry.`);
168
+ const result = await response.json();
169
+ if (result.ok !== true)
170
+ throw new Error("Hacklab did not confirm the sync. Run hacklab sync to retry.");
171
+ console.log(`Synced ${result.sessions} usage sessions to Hacklab.`);
172
+ if (result.sessions === 0)
173
+ console.log("No usage found. Use your coding agent, then run hacklab sync again to complete onboarding.");
174
+ }
175
+ async function sync() {
176
+ const origin = cliOrigin();
177
+ const token = await authenticate(origin);
178
+ console.log("Collecting ccusage session data…");
179
+ const body = await collectUsage();
180
+ await uploadUsage(origin, token, body);
181
+ }
182
+
183
+ // src/onboard.ts
17
184
  async function onboard(options) {
18
- const origin = normalizeOrigin(options.origin);
19
- const file = Bun.file(options.file);
185
+ const origin = cliOrigin();
186
+ const file = await stat(options.file);
20
187
  if (file.size > 8192)
21
188
  throw new Error("Profile must be smaller than 8 KB.");
22
- const profile = await file.json();
189
+ const contents = await readFile2(options.file);
190
+ if (contents.byteLength > 8192)
191
+ throw new Error("Profile must be smaller than 8 KB.");
192
+ const profile = JSON.parse(contents.toString("utf8"));
23
193
  if (!profile || typeof profile !== "object" || Array.isArray(profile) || Object.keys(profile).some((key) => !["bio", "socials"].includes(key)) || typeof profile.bio !== "string" || !profile.bio.trim() || profile.bio.trim().length > 160 || /[\r\n]/.test(profile.bio) || !profile.socials || typeof profile.socials !== "object" || Array.isArray(profile.socials))
24
- throw new Error("Expected a one-line bio (1\u2013160 characters) and a socials object, with no other fields.");
194
+ throw new Error("Expected a one-line bio (1–160 characters) and a socials object, with no other fields.");
25
195
  const hosts = {
26
196
  github: ["github.com"],
27
197
  linkedin: ["linkedin.com"],
@@ -48,21 +218,12 @@ async function onboard(options) {
48
218
  console.log(`Hacklab profile preview for ${origin}:
49
219
  ${payload}`);
50
220
  if (!options.confirmUpload) {
51
- console.log("Nothing uploaded. Ask the user to approve this exact profile, then run again with --confirm-upload.");
221
+ console.log("Nothing uploaded. Ask the user to approve this exact profile and the first ccusage session sync, then run again with --confirm-upload.");
52
222
  return;
53
223
  }
54
- const token = Bun.env.HACKLAB_ONBOARDING_TOKEN;
55
- if (!token)
56
- throw new Error("Set HACKLAB_ONBOARDING_TOKEN from the signed-in onboarding dialog.");
57
- let claims;
58
- try {
59
- claims = JSON.parse(Buffer.from(token.split(".")[0], "base64url").toString());
60
- } catch {
61
- throw new Error("Invalid onboarding token. Copy fresh agent instructions.");
62
- }
63
- if (claims.audience !== origin || typeof claims.expiresAt !== "number" || claims.expiresAt <= Date.now()) {
64
- throw new Error("Onboarding token is expired or belongs to another Hacklab origin. Copy fresh agent instructions.");
65
- }
224
+ const usage = await collectUsage();
225
+ const token = await authenticate(origin);
226
+ await uploadUsage(origin, token, usage);
66
227
  const response = await fetch(`${origin}/api/onboarding`, {
67
228
  method: "POST",
68
229
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
@@ -71,7 +232,7 @@ ${payload}`);
71
232
  signal: AbortSignal.timeout(30000)
72
233
  });
73
234
  if (!response.ok) {
74
- throw new Error(`Hacklab rejected the profile (HTTP ${response.status}). Check the profile and token before retrying.`);
235
+ throw new Error(`Hacklab rejected the profile (HTTP ${response.status}). Check the profile and sign-in before retrying.`);
75
236
  }
76
237
  const result = await response.json();
77
238
  if (result.ok !== true)
@@ -82,33 +243,44 @@ ${payload}`);
82
243
  // src/index.ts
83
244
  var help = `Hacklab CLI
84
245
 
246
+ Get started:
247
+ npm install -g hacklab@beta
248
+ hacklab sync
249
+
85
250
  Usage:
86
- hacklab onboard --file <profile.json> [--url <origin>] [--confirm-upload]
251
+ hacklab sync
252
+ hacklab onboard --file <profile.json> [--confirm-upload]
253
+ hacklab login
254
+ hacklab logout
87
255
  hacklab --help
88
256
  hacklab --version
89
257
 
90
- Onboarding:
258
+ Optional profile update:
91
259
  --file Local JSON containing bio and socials
92
- --url Hacklab origin (default: https://beta.hacklab.so)
93
- --confirm-upload Upload only after the user approves the exact profile
260
+ --confirm-upload Upload after approval of the profile and first usage sync
94
261
 
95
262
  Without --confirm-upload, onboard validates and previews locally without uploading.
96
- Uploads require HACKLAB_ONBOARDING_TOKEN from the signed-in onboarding dialog.
97
- Bun 1.4.2 or newer is required.`;
263
+ Confirmed onboarding also runs the first usage sync.
264
+ Sync signs in automatically and uploads unmodified ccusage session --json data.
265
+ Onboarding completes when Hacklab receives your usage.
266
+ The first upload opens your browser to authorize the CLI.
267
+ Later uploads reuse saved authorization until it expires or is revoked.
268
+ Login authorizes the CLI without uploading any data.
269
+ Logout clears saved authorization for this Hacklab server.
270
+ Requires Node.js 22 or newer. Bun 1.4.2 or newer is also supported.`;
98
271
  try {
99
272
  const { values, positionals } = parseArgs({
100
- args: Bun.argv.slice(2),
273
+ args: process.argv.slice(2),
101
274
  options: {
102
275
  help: { type: "boolean", short: "h" },
103
276
  version: { type: "boolean", short: "v" },
104
277
  file: { type: "string" },
105
- url: { type: "string" },
106
278
  "confirm-upload": { type: "boolean" }
107
279
  },
108
280
  allowPositionals: true
109
281
  });
110
- if (positionals.length > 1 || positionals[0] && positionals[0] !== "onboard") {
111
- throw new Error("Unknown command. Use hacklab onboard or hacklab --help.");
282
+ if (positionals.length > 1 || positionals[0] && !["onboard", "sync", "login", "logout"].includes(positionals[0])) {
283
+ throw new Error("Unknown command. Use hacklab onboard, hacklab sync, hacklab login, hacklab logout, or hacklab --help.");
112
284
  }
113
285
  if (values.help) {
114
286
  console.log(help);
@@ -119,10 +291,23 @@ try {
119
291
  throw new Error("Pass --file with the local profile JSON to preview or upload.");
120
292
  await onboard({
121
293
  file: values.file,
122
- origin: values.url ?? "https://beta.hacklab.so",
123
294
  confirmUpload: values["confirm-upload"] ?? false
124
295
  });
125
- } else if (values.file || values.url || values["confirm-upload"]) {
296
+ } else if (positionals[0] === "login") {
297
+ if (values.file || values["confirm-upload"])
298
+ throw new Error("Profile options require the onboard command.");
299
+ await authenticate(cliOrigin());
300
+ console.log("Signed in to Hacklab. Run hacklab sync to upload usage.");
301
+ } else if (positionals[0] === "logout") {
302
+ if (values.file || values["confirm-upload"])
303
+ throw new Error("Profile options require the onboard command.");
304
+ await removeCredential(cliOrigin());
305
+ console.log("Saved CLI authorization cleared. Run hacklab sync to sign in again.");
306
+ } else if (positionals[0] === "sync") {
307
+ if (values.file || values["confirm-upload"])
308
+ throw new Error("Profile options require the onboard command.");
309
+ await sync();
310
+ } else if (values.file || values["confirm-upload"]) {
126
311
  throw new Error("Profile options require the onboard command.");
127
312
  } else {
128
313
  console.log(help);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hacklab",
3
- "version": "26.921.1",
4
- "description": "Bun CLI for agent-assisted Hacklab profile onboarding",
3
+ "version": "26.921.2",
4
+ "description": "CLI for agent-assisted Hacklab profile onboarding",
5
5
  "bin": {
6
6
  "hacklab": "./dist/index.js"
7
7
  },
@@ -14,18 +14,22 @@
14
14
  "tag": "beta"
15
15
  },
16
16
  "scripts": {
17
- "build": "bun build src/index.ts --target=bun --outdir=dist && chmod +x dist/index.js",
17
+ "build": "bun --env-file=../../apps/web/.env --env-file=../../apps/web/.env.local build.ts",
18
18
  "start": "bun src/index.ts",
19
19
  "lint": "oxlint .",
20
20
  "check-types": "tsc --noEmit",
21
21
  "prepack": "bun run build",
22
- "test": "bun test"
22
+ "build:dev": "bun build.ts --development"
23
+ },
24
+ "dependencies": {
25
+ "ccusage": "20.0.24"
23
26
  },
24
27
  "devDependencies": {
25
- "@types/bun": "^1.4.2",
26
- "typescript": "^7.0.2"
28
+ "@types/bun": "catalog:",
29
+ "@types/node": "catalog:",
30
+ "typescript": "catalog:"
27
31
  },
28
32
  "engines": {
29
- "bun": ">=1.4.2"
33
+ "node": ">=22"
30
34
  }
31
35
  }