three-blocks-login 0.1.1 → 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 +330 -37
  2. package/package.json +1 -1
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
 
@@ -31,6 +235,8 @@ const QUIET = !!args.quiet;
31
235
  const VERBOSE = !!args.verbose;
32
236
  let CHANNEL = String(args.channel || process.env.THREE_BLOCKS_CHANNEL || "stable").toLowerCase();
33
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');
34
240
 
35
241
  // Load .env from current working directory (no deps)
36
242
  loadEnvFromDotfile(process.cwd());
@@ -40,32 +246,46 @@ const BROKER_URL =
40
246
  process.env.THREE_BLOCKS_BROKER_URL ||
41
247
  "http://localhost:3000/api/npm/token"; // your Astro broker endpoint
42
248
 
43
- const LICENSE =
44
- args.license || process.env.THREE_BLOCKS_SECRET_KEY;
45
-
46
- if (!LICENSE || String(LICENSE).trim() === "") {
47
- fail(
48
- "Missing license key. Provide --license <key> or set THREE_BLOCKS_SECRET_KEY in your .env file."
49
- );
50
- }
51
-
52
- // Sanitize + validate license early to avoid header ByteString errors
53
- const LICENSE_CLEAN = sanitizeLicense(LICENSE);
54
- if (!LICENSE_CLEAN) {
55
- fail(
56
- "Missing license key. Provide --license <key> or set THREE_BLOCKS_SECRET_KEY in your .env file."
57
- );
58
- }
59
- const invalidIdx = findFirstNonByteChar(LICENSE_CLEAN);
60
- if (invalidIdx !== -1) {
61
- if (VERBOSE) log.warn(`[debug] license contains non-ASCII/byte char at index ${invalidIdx}`);
62
- fail("License appears malformed. Please copy your tb_… key exactly without extra characters.");
63
- }
64
- if (!looksLikeLicense(LICENSE_CLEAN)) {
65
- if (VERBOSE) log.warn(`[debug] license failed format check: ${truncate(LICENSE_CLEAN, 16)}`);
66
- fail("License appears malformed. Please copy your tb_… key exactly.");
67
- }
68
-
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
+ };
69
289
 
70
290
  // Pretty logger (respects --quiet except for errors)
71
291
  const log = {
@@ -77,8 +297,52 @@ const log = {
77
297
 
78
298
  (async () => {
79
299
  try {
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
+
80
331
  const tokenData = await fetchToken(BROKER_URL, LICENSE_CLEAN, CHANNEL);
81
- const { registry, token, expiresAt } = tokenData;
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' };
82
346
 
83
347
  if (!registry || !token) fail("Broker response missing registry/token.");
84
348
 
@@ -99,15 +363,44 @@ const log = {
99
363
  fs.writeFileSync(tmpFile, npmrcContent, { mode: 0o600 });
100
364
 
101
365
  // Print shell exports; caller should `eval "$(npx -y three-blocks-login --mode env)"`
102
- const lines = [
103
- `# ${plainBanner} configure npm for current shell`,
104
- `# scope: ${SCOPE} | registry: ${u.href} | expires: ${expiresAt ?? "unknown"}`,
105
- `export NPM_CONFIG_USERCONFIG="${tmpFile}"`,
106
- `export npm_config_userconfig="${tmpFile}"`,
107
- `export THREE_BLOCKS_CHANNEL="${CHANNEL}"`,
108
- `echo "${plainBanner} ${SCOPE} -> ${u.href} (expires ${expiresAt ?? "unknown"})"`
109
- ];
110
- 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
+ }
111
404
  return;
112
405
  }
113
406
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "three-blocks-login",
3
- "version": "0.1.1",
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": {