three-blocks-login 0.1.0 → 0.1.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 (2) hide show
  1. package/bin/login.js +358 -53
  2. package/package.json +12 -4
package/bin/login.js CHANGED
@@ -5,10 +5,12 @@
5
5
  import fs from "node:fs";
6
6
  import os from "node:os";
7
7
  import path from "node:path";
8
+ import readline from "node:readline";
8
9
  import { createRequire } from "node:module";
9
10
 
10
11
  const require = createRequire(import.meta.url);
11
12
  const pkg = require("../package.json");
13
+ const CLI_VERSION = pkg?.version ? String(pkg.version) : "?.?.?";
12
14
 
13
15
  // Simple ANSI color helpers (no deps)
14
16
  const ESC = (n) => `\u001b[${n}m`;
@@ -20,7 +22,209 @@ const green = (s) => ESC(32) + s + reset;
20
22
  const yellow = (s) => ESC(33) + s + reset;
21
23
  const cyan = (s) => ESC(36) + s + reset;
22
24
  const plainBanner = `[three-blocks-login@${pkg.version}]`;
23
- const banner = bold(cyan(plainBanner));
25
+ const banner = cyan(plainBanner);
26
+
27
+ const quoteShell = (value) => JSON.stringify(String(value ?? ''));
28
+
29
+ const HEADER_WIDTH = 96;
30
+ const LEFT_WIDTH = 60;
31
+ const RIGHT_WIDTH = HEADER_WIDTH - LEFT_WIDTH - 3;
32
+
33
+ const repeatChar = (ch, len) => ch.repeat(Math.max(0, len));
34
+ const stripAnsi = (value) => String(value ?? '').replace(/\u001b\[[0-9;]*m/g, '');
35
+ const ellipsize = (value, width) => {
36
+ const str = String(value ?? '');
37
+ const plain = stripAnsi(str);
38
+ if (plain.length <= width) return str;
39
+ if (width <= 1) return plain.slice(0, width);
40
+ const truncated = plain.slice(0, width - 1) + '…';
41
+ return plain === str ? truncated : truncated;
42
+ };
43
+ const visibleLength = (value) => stripAnsi(String(value ?? '')).length;
44
+ const padText = (value, width, align = 'left') => {
45
+ const text = ellipsize(value, width);
46
+ const current = visibleLength(text);
47
+ const remaining = width - current;
48
+ if (remaining <= 0) return text;
49
+ if (align === 'center') {
50
+ const left = Math.floor(remaining / 2);
51
+ const right = remaining - left;
52
+ return `${' '.repeat(left)}${text}${' '.repeat(right)}`;
53
+ }
54
+ if (align === 'right') {
55
+ return `${' '.repeat(remaining)}${text}`;
56
+ }
57
+ return `${text}${' '.repeat(remaining)}`;
58
+ };
59
+ const makeHeaderRow = (left, right = '', leftAlign = 'left', rightAlign = 'left') =>
60
+ `│${padText(left, LEFT_WIDTH, leftAlign)}│${padText(right, RIGHT_WIDTH, rightAlign)}│`;
61
+
62
+ const HEADER_COLOR = ESC(33);
63
+ const CONTENT_COLOR = ESC(90); // bright black (grey)
64
+ const reapplyColor = (value, color) => {
65
+ const str = String(value ?? '');
66
+ return `${color}${str.split(reset).join(`${reset}${color}`)}${reset}`;
67
+ };
68
+
69
+ const applyHeaderColor = (row, { keepContentYellow = false, tintContent = true } = {}) => {
70
+ if (keepContentYellow) return reapplyColor(row, HEADER_COLOR);
71
+ if (!row.startsWith('│') || !row.endsWith('│')) return reapplyColor(row, HEADER_COLOR);
72
+ const match = row.match(/^│(.*)│(.*)│$/);
73
+ if (!match) return reapplyColor(row, HEADER_COLOR);
74
+ const [, leftContent, rightContent] = match;
75
+ const leftSegment = tintContent ? reapplyColor(leftContent, CONTENT_COLOR) : leftContent;
76
+ const rightSegment = tintContent ? reapplyColor(rightContent, CONTENT_COLOR) : rightContent;
77
+ return `${HEADER_COLOR}│${reset}${leftSegment}${HEADER_COLOR}│${reset}${rightSegment}${HEADER_COLOR}│${reset}`;
78
+ };
79
+
80
+ const formatRegistryShort = (value) => {
81
+ if (!value) return '';
82
+ try {
83
+ const u = new URL(value);
84
+ const pathname = (u.pathname || '').replace(/\/$/, '');
85
+ return `${u.host}${pathname}`;
86
+ } catch {
87
+ return String(value);
88
+ }
89
+ };
90
+
91
+ const maskLicense = (s) => {
92
+ if (!s) return '••••';
93
+ const v = String(s);
94
+ if (v.length <= 8) return '••••';
95
+ return `${v.slice(0, 4)}••••${v.slice(-4)}`;
96
+ };
97
+
98
+ const capitalize = (value) => {
99
+ const str = String(value || '').trim();
100
+ if (!str) return '';
101
+ return str[0].toUpperCase() + str.slice(1);
102
+ };
103
+
104
+ const formatPlanLabel = (value) => {
105
+ const str = String(value || '').trim();
106
+ if (!str) return '';
107
+ return str
108
+ .split(/\s+/)
109
+ .map((part) => (part ? capitalize(part.toLowerCase()) : ''))
110
+ .join(' ')
111
+ .trim();
112
+ };
113
+
114
+ const normalizePlan = (plan) => {
115
+ const label = formatPlanLabel(plan);
116
+ if (!label) return 'Developer Plan';
117
+ return label.toLowerCase().includes('plan') ? label : `${label} Plan`;
118
+ };
119
+
120
+ const formatFirstName = (value) => {
121
+ const name = String(value || '').trim();
122
+ if (!name) return '';
123
+ const first = name.split(/\s+/)[0];
124
+ return capitalize(first.toLowerCase());
125
+ };
126
+
127
+ const getUserDisplayName = () => {
128
+ const candidate =
129
+ process.env.THREE_BLOCKS_USER_NAME ||
130
+ process.env.GIT_AUTHOR_NAME ||
131
+ process.env.USER ||
132
+ process.env.LOGNAME;
133
+ if (candidate) return formatFirstName(candidate);
134
+ try {
135
+ return formatFirstName(os.userInfo().username);
136
+ } catch {
137
+ return '';
138
+ }
139
+ };
140
+
141
+ const formatExpiryLabel = (iso) => {
142
+ if (!iso) return 'Expires: —';
143
+ const dt = new Date(iso);
144
+ if (Number.isNaN(dt.getTime())) return `Expires: ${iso}`;
145
+ return `Expires: ${dt.toISOString().replace('T', ' ').replace('Z', 'Z')}`;
146
+ };
147
+
148
+ const USER_DISPLAY_NAME = getUserDisplayName();
149
+
150
+ const renderEnvHeader = ({
151
+ scope,
152
+ registry,
153
+ expiresAt,
154
+ tmpFile,
155
+ licenseMasked,
156
+ licenseId,
157
+ channel,
158
+ status,
159
+ plan,
160
+ teamName,
161
+ teamId,
162
+ repository,
163
+ domain,
164
+ region,
165
+ userDisplayName,
166
+ }) => {
167
+ const title = `─── Three Blocks Login v${CLI_VERSION} `;
168
+ const separatorRow = makeHeaderRow(repeatChar('─', LEFT_WIDTH), repeatChar('─', RIGHT_WIDTH));
169
+ const channelDisplay = String(channel || '').toUpperCase() || 'STABLE';
170
+ const registryShort = formatRegistryShort(registry);
171
+
172
+ const displayName = userDisplayName || USER_DISPLAY_NAME;
173
+ const welcomeLine = displayName ? `Welcome back ${displayName}!` : 'Welcome back!';
174
+ const scopeLine = `Scope: ${scope}`;
175
+
176
+ const planLabel = normalizePlan(plan);
177
+ const teamLabel = teamName || teamId ? ` · Team: ${teamName || teamId}` : '';
178
+ const subscriptionLine = `Plan: ${planLabel}${teamLabel}`;
179
+ const channelLine = `Channel: ${channelDisplay}${region ? ` · Region: ${region}` : ''}`;
180
+
181
+ const repositoryBase = repository ? `Repository: ${repository}` : 'Repository: —';
182
+ const repositoryLine = registryShort ? `${repositoryBase} → ${registryShort}` : repositoryBase;
183
+ const registryLine = `Registry: ${registryShort || (registry || '—')}`;
184
+
185
+ let domainValue = domain || '';
186
+ if (!domainValue && registry) {
187
+ try {
188
+ domainValue = new URL(registry).host;
189
+ } catch {}
190
+ }
191
+ const domainLineText = `Domain: ${domainValue || '—'}`;
192
+ const regionLineText = `Region: ${region || '—'}`;
193
+
194
+ const licenseLine = `License: ${licenseMasked}${licenseId ? ` · ${licenseId}` : ''}`;
195
+ const expiresLine = formatExpiryLabel(expiresAt);
196
+
197
+ const ascii = [
198
+ 'THREE.JS',
199
+ ' ______ __ ______ ______ __ __ ______ ',
200
+ '/\\ == \\ /\\ \\ /\\ __ \\ /\\ ___\\ /\\ \\/ / /\\ ___\\ ',
201
+ '\\ \\ __< \\ \\ \\____ \\ \\ \\/\\ \\ \\ \\ \\____ \\ \\ _"-. \\ \\___ \\ ',
202
+ ' \\ \\_____\\ \\ \\_____\\ \\ \\_____\\ \\ \\_____\\ \\ \\_\\ \\_\\ \\/\\_____\\',
203
+ ' \\/_____\/ \\/_____/ \\/_____/ \\/_____/ \\/_/ \/_/ \\/_____/'
204
+ ];
205
+ const statusMessage = status?.message || (status?.ok ? 'access granted' : status ? 'no active access' : 'pending');
206
+ const statusText = status?.ok
207
+ ? green(bold(`Authenticated — ${statusMessage}`))
208
+ : status
209
+ ? red(bold(`Not authenticated — ${statusMessage}`))
210
+ : dim(bold(`Status: ${statusMessage}`));
211
+
212
+ const lines = [
213
+ applyHeaderColor(`╭${title}${repeatChar('─', HEADER_WIDTH - 2 - title.length)}╮`, { keepContentYellow: true }),
214
+ ...ascii.map((row) => applyHeaderColor(`│${padText(row, LEFT_WIDTH + RIGHT_WIDTH + 1, 'center')}│`, { keepContentYellow: true })),
215
+ applyHeaderColor(separatorRow, { keepContentYellow: true }),
216
+ applyHeaderColor(makeHeaderRow(welcomeLine, scopeLine, 'center', 'center')),
217
+ applyHeaderColor(separatorRow, { keepContentYellow: true }),
218
+ applyHeaderColor(makeHeaderRow(subscriptionLine, channelLine)),
219
+ applyHeaderColor(makeHeaderRow(repositoryLine, registryLine)),
220
+ applyHeaderColor(makeHeaderRow(domainLineText, regionLineText, 'left', 'center')),
221
+ applyHeaderColor(makeHeaderRow(licenseLine, expiresLine)),
222
+ applyHeaderColor(separatorRow, { keepContentYellow: true }),
223
+ applyHeaderColor(makeHeaderRow(statusText, '', 'left', 'center'), { tintContent: false }),
224
+ applyHeaderColor(`╰${repeatChar('─', HEADER_WIDTH - 2)}╯`, { keepContentYellow: true }),
225
+ ];
226
+ for (const row of lines) console.log(row);
227
+ };
24
228
 
25
229
  const args = parseArgs(process.argv.slice(2));
26
230
 
@@ -29,6 +233,10 @@ const SCOPE = (args.scope || "@three-blocks").replace(/^\s+|\s+$/g, "");
29
233
  const MODE = (args.mode || "env").toLowerCase(); // env | project | user
30
234
  const QUIET = !!args.quiet;
31
235
  const VERBOSE = !!args.verbose;
236
+ let CHANNEL = String(args.channel || process.env.THREE_BLOCKS_CHANNEL || "stable").toLowerCase();
237
+ if (!['stable','alpha','beta'].includes(CHANNEL)) CHANNEL = 'stable';
238
+ const PRINT_SHELL = !!(args['print-shell'] || args.printShell || process.env.THREE_BLOCKS_LOGIN_PRINT_SHELL === '1');
239
+ const NON_INTERACTIVE = !!(args['non-interactive'] || args.nonInteractive || process.env.CI === '1');
32
240
 
33
241
  // Load .env from current working directory (no deps)
34
242
  loadEnvFromDotfile(process.cwd());
@@ -38,32 +246,46 @@ const BROKER_URL =
38
246
  process.env.THREE_BLOCKS_BROKER_URL ||
39
247
  "http://localhost:3000/api/npm/token"; // your Astro broker endpoint
40
248
 
41
- const LICENSE =
42
- args.license || process.env.THREE_BLOCKS_SECRET_KEY || process.env.THREE_BLOCKS_LICENSE_KEY;
43
-
44
- if (!LICENSE || String(LICENSE).trim() === "") {
45
- fail(
46
- "Missing license key. Provide --license <key> or set THREE_BLOCKS_SECRET_KEY in your .env file."
47
- );
48
- }
49
-
50
- // Sanitize + validate license early to avoid header ByteString errors
51
- const LICENSE_CLEAN = sanitizeLicense(LICENSE);
52
- if (!LICENSE_CLEAN) {
53
- fail(
54
- "Missing license key. Provide --license <key> or set THREE_BLOCKS_SECRET_KEY in your .env file."
55
- );
56
- }
57
- const invalidIdx = findFirstNonByteChar(LICENSE_CLEAN);
58
- if (invalidIdx !== -1) {
59
- if (VERBOSE) log.warn(`[debug] license contains non-ASCII/byte char at index ${invalidIdx}`);
60
- fail("License appears malformed. Please copy your tb_… key exactly without extra characters.");
61
- }
62
- if (!looksLikeLicense(LICENSE_CLEAN)) {
63
- if (VERBOSE) log.warn(`[debug] license failed format check: ${truncate(LICENSE_CLEAN, 16)}`);
64
- fail("License appears malformed. Please copy your tb_… key exactly.");
65
- }
66
-
249
+ const promptHidden = async (label) => {
250
+ return await new Promise((resolve) => {
251
+ const stdin = process.stdin;
252
+ const stdout = process.stdout;
253
+ let value = '';
254
+ const cleanup = () => {
255
+ stdin.removeListener('data', onData);
256
+ if (stdin.isTTY) stdin.setRawMode(false);
257
+ stdin.pause();
258
+ };
259
+ const onData = (chunk) => {
260
+ const str = String(chunk);
261
+ for (const ch of str) {
262
+ if (ch === '\u0003') { cleanup(); process.exit(1); }
263
+ if (ch === '\r' || ch === '\n' || ch === '\u0004') {
264
+ stdout.write('\n');
265
+ cleanup();
266
+ return resolve(value.trim());
267
+ }
268
+ if (ch === '\b' || ch === '\u007f') {
269
+ if (value.length) {
270
+ value = value.slice(0, -1);
271
+ readline.moveCursor(stdout, -1, 0);
272
+ stdout.write(' ');
273
+ readline.moveCursor(stdout, -1, 0);
274
+ }
275
+ continue;
276
+ }
277
+ if (ch === '\u001b') continue;
278
+ value += ch;
279
+ stdout.write('•');
280
+ }
281
+ };
282
+ stdout.write(label);
283
+ stdin.setEncoding('utf8');
284
+ if (stdin.isTTY) stdin.setRawMode(true);
285
+ stdin.resume();
286
+ stdin.on('data', onData);
287
+ });
288
+ };
67
289
 
68
290
  // Pretty logger (respects --quiet except for errors)
69
291
  const log = {
@@ -75,8 +297,52 @@ const log = {
75
297
 
76
298
  (async () => {
77
299
  try {
78
- const tokenData = await fetchToken(BROKER_URL, LICENSE_CLEAN);
79
- const { registry, token, expiresAt } = tokenData;
300
+ let LICENSE = args.license || process.env.THREE_BLOCKS_SECRET_KEY;
301
+
302
+ if (!LICENSE || String(LICENSE).trim() === "") {
303
+ if (NON_INTERACTIVE) {
304
+ fail("Missing license key. Provide --license <key> or set THREE_BLOCKS_SECRET_KEY in your environment.");
305
+ }
306
+ console.log('');
307
+ log.info(bold(yellow('Three Blocks Login')) + ' ' + dim(`[mode: ${MODE}]`));
308
+ log.info(dim('Enter your license key to retrieve a scoped npm token.'));
309
+ log.info(dim('Tip: paste it here; input is hidden. Press Enter to submit.'));
310
+ LICENSE = await promptHidden(`${cyan('›')} License key ${dim('(tb_…)')}: `);
311
+ if (!LICENSE) fail('License key is required to continue.');
312
+ }
313
+
314
+ // Sanitize + validate license early to avoid header ByteString errors
315
+ const LICENSE_CLEAN = sanitizeLicense(LICENSE);
316
+ if (!LICENSE_CLEAN) {
317
+ fail(
318
+ "Missing license key. Provide --license <key> or set THREE_BLOCKS_SECRET_KEY in your .env file."
319
+ );
320
+ }
321
+ const invalidIdx = findFirstNonByteChar(LICENSE_CLEAN);
322
+ if (invalidIdx !== -1) {
323
+ if (VERBOSE) log.warn(`[debug] license contains non-ASCII/byte char at index ${invalidIdx}`);
324
+ fail("License appears malformed. Please copy your tb_… key exactly without extra characters.");
325
+ }
326
+ if (!looksLikeLicense(LICENSE_CLEAN)) {
327
+ if (VERBOSE) log.warn(`[debug] license failed format check: ${truncate(LICENSE_CLEAN, 16)}`);
328
+ fail("License appears malformed. Please copy your tb_… key exactly.");
329
+ }
330
+
331
+ const tokenData = await fetchToken(BROKER_URL, LICENSE_CLEAN, CHANNEL);
332
+ const {
333
+ registry,
334
+ token,
335
+ expiresAt,
336
+ status: rawStatus,
337
+ plan,
338
+ teamName,
339
+ teamId,
340
+ repository,
341
+ domain,
342
+ region,
343
+ licenseId,
344
+ } = tokenData;
345
+ const authStatus = rawStatus ?? { ok: true, message: 'access granted' };
80
346
 
81
347
  if (!registry || !token) fail("Broker response missing registry/token.");
82
348
 
@@ -97,14 +363,44 @@ const log = {
97
363
  fs.writeFileSync(tmpFile, npmrcContent, { mode: 0o600 });
98
364
 
99
365
  // Print shell exports; caller should `eval "$(npx -y three-blocks-login --mode env)"`
100
- const lines = [
101
- `# ${plainBanner} configure npm for current shell`,
102
- `# scope: ${SCOPE} | registry: ${u.href} | expires: ${expiresAt ?? "unknown"}`,
103
- `export NPM_CONFIG_USERCONFIG="${tmpFile}"`,
104
- `export npm_config_userconfig="${tmpFile}"`,
105
- `echo "${plainBanner} ${SCOPE} -> ${u.href} (expires ${expiresAt ?? "unknown"})"`
106
- ];
107
- console.log(lines.join("\n"));
366
+ const maskedLicense = maskLicense(LICENSE_CLEAN);
367
+ renderEnvHeader({
368
+ scope: SCOPE,
369
+ registry: u.href,
370
+ expiresAt,
371
+ tmpFile,
372
+ licenseMasked: maskedLicense,
373
+ licenseId,
374
+ channel: CHANNEL,
375
+ status: authStatus,
376
+ plan,
377
+ teamName,
378
+ teamId,
379
+ repository,
380
+ domain,
381
+ region,
382
+ userDisplayName: USER_DISPLAY_NAME,
383
+ });
384
+ if (PRINT_SHELL) {
385
+ const exportLines = [
386
+ `export NPM_CONFIG_USERCONFIG=${quoteShell(tmpFile)}`,
387
+ `export npm_config_userconfig=${quoteShell(tmpFile)}`,
388
+ `export THREE_BLOCKS_CHANNEL=${quoteShell(CHANNEL)}`,
389
+ ];
390
+ const summaryPrint = [
391
+ `cat <<'EOF'`,
392
+ `${plainBanner} scoped login ready (${authStatus.ok ? 'ok' : 'error'})`,
393
+ ` scope : ${SCOPE}`,
394
+ ` registry : ${u.href}`,
395
+ ` expires : ${expiresAt ?? 'unknown'}`,
396
+ ` license : ${maskedLicense}`,
397
+ ` npmrc : ${tmpFile}`,
398
+ `EOF`,
399
+ ];
400
+ console.log([...exportLines, '', ...summaryPrint].join('\n'));
401
+ } else {
402
+ log.info(`${plainBanner} temp npmrc ready. Use --print-shell to emit export commands.`);
403
+ }
108
404
  return;
109
405
  }
110
406
 
@@ -153,30 +449,39 @@ function ensureTrailingSlash(url) {
153
449
 
154
450
  function loadEnvFromDotfile(dir) {
155
451
  try {
156
- const file = path.join(dir, ".env");
157
- if (!fs.existsSync(file)) return;
158
- const txt = fs.readFileSync(file, "utf8");
159
- for (const raw of txt.split(/\r?\n/)) {
160
- const line = raw.trim();
161
- if (!line || line.startsWith("#")) continue;
162
- const eq = line.indexOf("=");
163
- if (eq === -1) continue;
164
- const key = line.slice(0, eq).trim();
165
- let val = line.slice(eq + 1).trim();
166
- if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
167
- val = val.slice(1, -1);
452
+ const files = [path.join(dir, ".env.local"), path.join(dir, ".env")];
453
+ for (const file of files) {
454
+ if (!fs.existsSync(file)) continue;
455
+ const txt = fs.readFileSync(file, "utf8");
456
+ for (const raw of txt.split(/\r?\n/)) {
457
+ const line = raw.trim();
458
+ if (!line || line.startsWith("#")) continue;
459
+ const eq = line.indexOf("=");
460
+ if (eq === -1) continue;
461
+ const key = line.slice(0, eq).trim();
462
+ let val = line.slice(eq + 1).trim();
463
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
464
+ val = val.slice(1, -1);
465
+ }
466
+ if (process.env[key] === undefined) process.env[key] = val;
168
467
  }
169
- if (process.env[key] === undefined) process.env[key] = val;
170
468
  }
171
469
  } catch {}
172
470
  }
173
471
 
174
- async function fetchToken(endpoint, license) {
175
- const res = await fetch(endpoint, {
472
+ async function fetchToken(endpoint, license, channel) {
473
+ let url = endpoint;
474
+ try {
475
+ const u = new URL(endpoint);
476
+ if (!u.searchParams.get('channel')) u.searchParams.set('channel', channel);
477
+ url = u.toString();
478
+ } catch {}
479
+ const res = await fetch(url, {
176
480
  method: "GET",
177
481
  headers: {
178
482
  "authorization": `Bearer ${license}`,
179
- "accept": "application/json"
483
+ "accept": "application/json",
484
+ "x-three-blocks-channel": channel
180
485
  }
181
486
  });
182
487
  if (!res.ok) {
package/package.json CHANGED
@@ -1,16 +1,24 @@
1
1
  {
2
2
  "name": "three-blocks-login",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Fetch a short-lived token from the three-blocks broker and configure npm for the current context.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "three-blocks-login": "bin/login.js"
8
8
  },
9
9
  "license": "MIT",
10
- "files": ["bin/"],
11
- "keywords": ["npm", "login", "three-blocks", "token", "ci"],
10
+ "files": [
11
+ "bin/"
12
+ ],
13
+ "keywords": [
14
+ "npm",
15
+ "login",
16
+ "three-blocks",
17
+ "token",
18
+ "ci"
19
+ ],
12
20
  "dependencies": {},
13
21
  "publishConfig": {
14
22
  "access": "public"
15
23
  }
16
- }
24
+ }