hacklab 26.921.1 → 26.921.3

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 +17 -27
  2. package/dist/index.js +176 -90
  3. package/package.json +11 -7
package/README.md CHANGED
@@ -1,46 +1,36 @@
1
1
  # Hacklab CLI
2
2
 
3
- A Bun CLI for updating your Hacklab bio and social links with your coding agent.
4
-
5
- Requires Bun 1.4.2 or newer.
3
+ Sync coding agent usage to Hacklab. Requires Node.js 22+ or Bun 1.4.2+.
6
4
 
7
5
  ```sh
8
- bun add --global hacklab@beta
9
- hacklab --help
6
+ npm install --global hacklab@beta
7
+ hacklab sync
10
8
  ```
11
9
 
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.
13
-
14
- ## Preview a profile
10
+ The first sync opens your browser to sign in and authorize the CLI. Check that the code matches your terminal before approving. Later commands reuse saved authorization until it expires or is revoked.
15
11
 
16
- Create a local JSON file containing a one-line `bio` (at most 160 characters) and a `socials` object. Supported social keys are `github`, `linkedin`, `x`, `instagram`, `youtube`, `huggingface`, `producthunt`, and `goodreads`. Use full HTTPS profile URLs. Omitted links remain unchanged; `null` removes a link.
12
+ ## Commands
17
13
 
18
14
  ```sh
19
- hacklab onboard --file /path/to/profile.json
15
+ hacklab sync # Sign in and upload usage
16
+ hacklab login # Sign in without uploading
17
+ hacklab logout # Clear saved authorization
18
+ hacklab --help
19
+ hacklab --version
20
20
  ```
21
21
 
22
- This validates and previews locally without making a network request.
23
-
24
- ## Upload after approval
25
-
26
- Review the exact bio and links first. Agents must ask the user for approval before using `--confirm-upload`.
27
-
28
- ```sh
29
- hacklab onboard --file /path/to/profile.json --confirm-upload
30
- ```
22
+ The CLI runs bundled `ccusage@20.0.24 session --json` and uploads its output to `/api/token-usage`. Reports include session identifiers, tokens, costs, model breakdowns, and metadata that may include project paths. Source code and transcripts are not uploaded. Reports are limited to 20 MB. Repeated syncs update existing sessions; sessions from other machines are retained.
31
23
 
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.
24
+ Credentials are stored per server in `~/.config/hacklab/credentials/`, with owner-only permissions on Unix. Logout removes the local credential for the current server; it does not sign out your browser or revoke the server session.
33
25
 
34
- ## Local development
26
+ ## Development
35
27
 
36
- From the Hacklab repository:
28
+ Building requires Bun 1.4.2+.
37
29
 
38
30
  ```sh
39
31
  bun install --frozen-lockfile
40
- bunx turbo run build --filter=hacklab
41
- cd packages/cli
42
- bun link
43
- hacklab onboard --file /path/to/profile.json --url http://localhost:3000
32
+ bun run --cwd packages/cli build:dev
33
+ bun run --cwd packages/cli start --help
44
34
  ```
45
35
 
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.
36
+ Source runs and development builds 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. No `--url` override is supported. Development and production credentials are stored separately.
package/dist/index.js CHANGED
@@ -1,129 +1,215 @@
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.3";
8
7
 
9
- // 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;
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`);
16
20
  }
17
- async function onboard(options) {
18
- const origin = normalizeOrigin(options.origin);
19
- const file = Bun.file(options.file);
20
- if (file.size > 8192)
21
- throw new Error("Profile must be smaller than 8 KB.");
22
- const profile = await file.json();
23
- 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.");
25
- const hosts = {
26
- github: ["github.com"],
27
- linkedin: ["linkedin.com"],
28
- x: ["x.com", "twitter.com"],
29
- instagram: ["instagram.com"],
30
- youtube: ["youtube.com"],
31
- huggingface: ["huggingface.co"],
32
- producthunt: ["producthunt.com"],
33
- goodreads: ["goodreads.com"]
34
- };
35
- for (const [platform, value] of Object.entries(profile.socials)) {
36
- if (!Object.hasOwn(hosts, platform))
37
- throw new Error(`Unsupported social platform: ${platform}`);
38
- if (value === null)
39
- continue;
40
- if (typeof value !== "string" || value.length > 500)
41
- throw new Error(`Invalid ${platform} profile URL.`);
42
- const url = new URL(value);
43
- if (url.protocol !== "https:" || url.username || url.password || url.port || url.pathname === "/" || !hosts[platform].includes(url.hostname.replace(/^www\./, ""))) {
44
- throw new Error(`Use an HTTPS ${platform} profile URL.`);
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.`);
45
72
  }
73
+ await removeCredential(origin, directory);
74
+ console.log("Your saved Hacklab authorization expired or was revoked. Sign in again.");
46
75
  }
47
- const payload = JSON.stringify({ bio: profile.bio.trim(), socials: profile.socials }, null, 2);
48
- console.log(`Hacklab profile preview for ${origin}:
49
- ${payload}`);
50
- if (!options.confirmUpload) {
51
- console.log("Nothing uploaded. Ask the user to approve this exact profile, then run again with --confirm-upload.");
52
- return;
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.");
53
90
  }
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;
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
+ }
125
+
126
+ // src/origin.ts
127
+ function cliOrigin() {
128
+ return "https://hacklab.so";
129
+ }
130
+
131
+ // src/sync.ts
132
+ import { spawn as spawn2 } from "node:child_process";
133
+ import { once } from "node:events";
134
+ import { createRequire } from "node:module";
135
+ import { text } from "node:stream/consumers";
136
+ async function collectUsage() {
137
+ const require2 = createRequire(import.meta.url);
138
+ const child = spawn2(process.execPath, [require2.resolve("ccusage/src/cli.js"), "session", "--json"], { stdio: ["ignore", "pipe", "inherit"] });
139
+ const [body, [exitCode]] = await Promise.all([text(child.stdout), once(child, "close")]);
140
+ if (exitCode !== 0)
141
+ throw new Error(`ccusage failed (exit ${exitCode}). Nothing synced.`);
142
+ let report;
58
143
  try {
59
- claims = JSON.parse(Buffer.from(token.split(".")[0], "base64url").toString());
144
+ report = JSON.parse(body);
60
145
  } catch {
61
- throw new Error("Invalid onboarding token. Copy fresh agent instructions.");
146
+ throw new Error("ccusage returned invalid JSON. Nothing synced.");
62
147
  }
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.");
148
+ if (!Array.isArray(report?.session) || !report.totals) {
149
+ throw new Error("Unexpected ccusage report. Nothing synced.");
65
150
  }
66
- const response = await fetch(`${origin}/api/onboarding`, {
151
+ if (Buffer.byteLength(body) > 20 * 1024 * 1024)
152
+ throw new Error("Usage report exceeds 20 MB. Nothing synced.");
153
+ return body;
154
+ }
155
+ async function uploadUsage(origin, token, body) {
156
+ const response = await fetch(`${origin}/api/token-usage`, {
67
157
  method: "POST",
68
158
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
69
- body: payload,
159
+ body,
70
160
  redirect: "error",
71
- signal: AbortSignal.timeout(30000)
161
+ signal: AbortSignal.timeout(60000)
72
162
  });
73
- if (!response.ok) {
74
- throw new Error(`Hacklab rejected the profile (HTTP ${response.status}). Check the profile and token before retrying.`);
75
- }
163
+ if (!response.ok)
164
+ throw new Error(`Hacklab rejected the usage report (HTTP ${response.status}). Run hacklab sync to retry.`);
76
165
  const result = await response.json();
77
166
  if (result.ok !== true)
78
- throw new Error("Hacklab did not confirm the update. Check the website before retrying.");
79
- console.log("Your Hacklab bio and social links were saved.");
167
+ throw new Error("Hacklab did not confirm the sync. Run hacklab sync to retry.");
168
+ console.log(`Synced ${result.sessions} usage sessions to Hacklab.`);
169
+ if (result.sessions === 0)
170
+ console.log("No usage found. Use your coding agent, then run hacklab sync again.");
171
+ }
172
+ async function sync() {
173
+ const origin = cliOrigin();
174
+ const token = await authenticate(origin);
175
+ console.log("Collecting ccusage session data…");
176
+ const body = await collectUsage();
177
+ await uploadUsage(origin, token, body);
80
178
  }
81
179
 
82
180
  // src/index.ts
83
181
  var help = `Hacklab CLI
84
182
 
85
183
  Usage:
86
- hacklab onboard --file <profile.json> [--url <origin>] [--confirm-upload]
87
- hacklab --help
88
- hacklab --version
89
-
90
- Onboarding:
91
- --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
94
-
95
- 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.`;
184
+ hacklab sync Sign in and upload usage
185
+ hacklab login Sign in without uploading
186
+ hacklab logout Clear saved authorization
187
+ hacklab --help Show help
188
+ hacklab --version Show version`;
98
189
  try {
99
190
  const { values, positionals } = parseArgs({
100
- args: Bun.argv.slice(2),
191
+ args: process.argv.slice(2),
101
192
  options: {
102
193
  help: { type: "boolean", short: "h" },
103
- version: { type: "boolean", short: "v" },
104
- file: { type: "string" },
105
- url: { type: "string" },
106
- "confirm-upload": { type: "boolean" }
194
+ version: { type: "boolean", short: "v" }
107
195
  },
108
196
  allowPositionals: true
109
197
  });
110
- if (positionals.length > 1 || positionals[0] && positionals[0] !== "onboard") {
111
- throw new Error("Unknown command. Use hacklab onboard or hacklab --help.");
198
+ if (positionals.length > 1 || positionals[0] && !["sync", "login", "logout"].includes(positionals[0])) {
199
+ throw new Error("Unknown command. Run hacklab --help for usage.");
112
200
  }
113
201
  if (values.help) {
114
202
  console.log(help);
115
203
  } else if (values.version) {
116
204
  console.log(version);
117
- } else if (positionals[0] === "onboard") {
118
- if (!values.file)
119
- throw new Error("Pass --file with the local profile JSON to preview or upload.");
120
- await onboard({
121
- file: values.file,
122
- origin: values.url ?? "https://beta.hacklab.so",
123
- confirmUpload: values["confirm-upload"] ?? false
124
- });
125
- } else if (values.file || values.url || values["confirm-upload"]) {
126
- throw new Error("Profile options require the onboard command.");
205
+ } else if (positionals[0] === "login") {
206
+ await authenticate(cliOrigin());
207
+ console.log("Signed in to Hacklab. Run hacklab sync to upload usage.");
208
+ } else if (positionals[0] === "logout") {
209
+ await removeCredential(cliOrigin());
210
+ console.log("Saved CLI authorization cleared. Run hacklab sync to sign in again.");
211
+ } else if (positionals[0] === "sync") {
212
+ await sync();
127
213
  } else {
128
214
  console.log(help);
129
215
  }
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.3",
4
+ "description": "CLI for syncing coding agent usage to Hacklab",
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
  }