three-blocks-login 0.0.1
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/login.js +272 -0
- package/package.json +16 -0
package/bin/login.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Minimal, dependency-free CLI
|
|
3
|
+
// Node 18+ required (built-in fetch)
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
// Simple ANSI color helpers (no deps)
|
|
10
|
+
const ESC = (n) => `\u001b[${n}m`;
|
|
11
|
+
const reset = ESC(0);
|
|
12
|
+
const bold = (s) => ESC(1) + s + reset;
|
|
13
|
+
const dim = (s) => ESC(2) + s + reset;
|
|
14
|
+
const red = (s) => ESC(31) + s + reset;
|
|
15
|
+
const green = (s) => ESC(32) + s + reset;
|
|
16
|
+
const yellow = (s) => ESC(33) + s + reset;
|
|
17
|
+
const cyan = (s) => ESC(36) + s + reset;
|
|
18
|
+
const plainBanner = "[three-blocks-login]";
|
|
19
|
+
const banner = bold(cyan(plainBanner));
|
|
20
|
+
|
|
21
|
+
const args = parseArgs(process.argv.slice(2));
|
|
22
|
+
|
|
23
|
+
const SCOPE = (args.scope || "@three-blocks").replace(/^\s+|\s+$/g, "");
|
|
24
|
+
|
|
25
|
+
const MODE = (args.mode || "env").toLowerCase(); // env | project | user
|
|
26
|
+
const QUIET = !!args.quiet;
|
|
27
|
+
const VERBOSE = !!args.verbose;
|
|
28
|
+
|
|
29
|
+
// Load .env from current working directory (no deps)
|
|
30
|
+
loadEnvFromDotfile(process.cwd());
|
|
31
|
+
|
|
32
|
+
const BROKER_URL =
|
|
33
|
+
args.endpoint ||
|
|
34
|
+
process.env.THREE_BLOCKS_BROKER_URL ||
|
|
35
|
+
"http://localhost:3000/api/npm/token"; // your Astro broker endpoint
|
|
36
|
+
|
|
37
|
+
const LICENSE =
|
|
38
|
+
args.license || process.env.THREE_BLOCKS_SECRET_KEY || process.env.THREE_BLOCKS_LICENSE_KEY;
|
|
39
|
+
|
|
40
|
+
// Sanitize + validate license early to avoid header ByteString errors
|
|
41
|
+
const LICENSE_CLEAN = sanitizeLicense(LICENSE);
|
|
42
|
+
const invalidIdx = findFirstNonByteChar(LICENSE_CLEAN);
|
|
43
|
+
if (invalidIdx !== -1) {
|
|
44
|
+
if (VERBOSE) log.warn(`[debug] license contains non-ASCII/byte char at index ${invalidIdx}`);
|
|
45
|
+
fail("License appears malformed. Please copy your tb_… key exactly without extra characters.");
|
|
46
|
+
}
|
|
47
|
+
if (!looksLikeLicense(LICENSE_CLEAN)) {
|
|
48
|
+
if (VERBOSE) log.warn(`[debug] license failed format check: ${truncate(LICENSE_CLEAN, 16)}`);
|
|
49
|
+
fail("License appears malformed. Please copy your tb_… key exactly.");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
// Pretty logger (respects --quiet except for errors)
|
|
54
|
+
const log = {
|
|
55
|
+
info: (msg) => { if (!QUIET) console.error(`${cyan("i")} ${msg}`); },
|
|
56
|
+
ok: (msg) => { if (!QUIET) console.error(`${green("✔")} ${msg}`); },
|
|
57
|
+
warn: (msg) => { if (!QUIET) console.error(`${yellow("⚠")} ${msg}`); },
|
|
58
|
+
error:(msg) => { console.error(`${red("✖")} ${msg}`); }
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (!LICENSE) {
|
|
62
|
+
fail(
|
|
63
|
+
"Missing license key. Provide --license <key> or set THREE_BLOCKS_SECRET_KEY/THREE_BLOCKS_LICENSE_KEY (env or .env)."
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
(async () => {
|
|
68
|
+
try {
|
|
69
|
+
const tokenData = await fetchToken(BROKER_URL, LICENSE_CLEAN);
|
|
70
|
+
const { registry, token, expiresAt } = tokenData;
|
|
71
|
+
|
|
72
|
+
if (!registry || !token) fail("Broker response missing registry/token.");
|
|
73
|
+
|
|
74
|
+
const u = new URL(ensureTrailingSlash(registry));
|
|
75
|
+
const hostPath = `${u.host}${u.pathname}`; // e.g. my-domain-.../npm/my-repo/
|
|
76
|
+
|
|
77
|
+
const npmrcContent = [
|
|
78
|
+
`${normalizeScope(SCOPE)}:registry=${u.href}`,
|
|
79
|
+
`//${hostPath}:_authToken=${token}`,
|
|
80
|
+
`//${hostPath}:always-auth=true`
|
|
81
|
+
].join(os.EOL) + os.EOL;
|
|
82
|
+
|
|
83
|
+
if (MODE === "env") {
|
|
84
|
+
// Write to a temp .npmrc and export env so the *current shell* can reuse it
|
|
85
|
+
const tmpFile = path.join(
|
|
86
|
+
os.tmpdir(),
|
|
87
|
+
`three-blocks-${Date.now()}-${Math.random().toString(36).slice(2)}.npmrc`
|
|
88
|
+
);
|
|
89
|
+
fs.writeFileSync(tmpFile, npmrcContent, { mode: 0o600 });
|
|
90
|
+
|
|
91
|
+
// Print shell exports; caller should `eval "$(npx -y three-blocks-login --mode env)"`
|
|
92
|
+
const lines = [
|
|
93
|
+
`# ${plainBanner} configure npm for current shell`,
|
|
94
|
+
`# scope: ${SCOPE} | registry: ${u.href} | expires: ${expiresAt ?? "unknown"}`,
|
|
95
|
+
`export NPM_CONFIG_USERCONFIG="${tmpFile}"`,
|
|
96
|
+
`export npm_config_userconfig="${tmpFile}"`,
|
|
97
|
+
`echo "${plainBanner} ${SCOPE} -> ${u.href} (expires ${expiresAt ?? "unknown"})"`
|
|
98
|
+
];
|
|
99
|
+
console.log(lines.join("\n"));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (MODE === "project") {
|
|
104
|
+
// Write to local ./.npmrc (recommend users gitignore this)
|
|
105
|
+
const out = path.resolve(process.cwd(), ".npmrc");
|
|
106
|
+
fs.writeFileSync(out, npmrcContent, { mode: 0o600 });
|
|
107
|
+
log.ok(`${banner} wrote ${bold(out)} ${dim(`(${SCOPE} → ${u.href}, expires ${expiresAt ?? "unknown"})`)}`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (MODE === "user") {
|
|
112
|
+
// Update user's npm config (requires npm on PATH)
|
|
113
|
+
await run("npm", ["config", "set", `${normalizeScope(SCOPE)}:registry`, u.href]);
|
|
114
|
+
await run("npm", [
|
|
115
|
+
"config",
|
|
116
|
+
"set",
|
|
117
|
+
`//${hostPath}:_authToken`,
|
|
118
|
+
token
|
|
119
|
+
]);
|
|
120
|
+
await run("npm", [
|
|
121
|
+
"config",
|
|
122
|
+
"set",
|
|
123
|
+
`//${hostPath}:always-auth`,
|
|
124
|
+
"true"
|
|
125
|
+
]);
|
|
126
|
+
log.ok(`${banner} updated user npm config ${dim(`(${SCOPE} → ${u.href}, expires ${expiresAt ?? "unknown"})`)}`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
fail(`Unknown --mode "${MODE}". Use env | project | user.`);
|
|
131
|
+
} catch (e) {
|
|
132
|
+
const msg = e?.message || String(e);
|
|
133
|
+
if (VERBOSE) {
|
|
134
|
+
log.error(`[debug] ${msg}`);
|
|
135
|
+
return process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
if (/ByteString/i.test(msg) || /character at index/i.test(msg)) {
|
|
138
|
+
return fail("License appears malformed. Please copy your tb_… key exactly and retry.");
|
|
139
|
+
}
|
|
140
|
+
return fail("Request failed. Please try again. Use --verbose for details.");
|
|
141
|
+
}
|
|
142
|
+
})();
|
|
143
|
+
|
|
144
|
+
function normalizeScope(scope) {
|
|
145
|
+
return scope.startsWith("@") ? scope : `@${scope}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function ensureTrailingSlash(url) {
|
|
149
|
+
return url.endsWith("/") ? url : url + "/";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function loadEnvFromDotfile(dir) {
|
|
153
|
+
try {
|
|
154
|
+
const file = path.join(dir, ".env");
|
|
155
|
+
if (!fs.existsSync(file)) return;
|
|
156
|
+
const txt = fs.readFileSync(file, "utf8");
|
|
157
|
+
for (const raw of txt.split(/\r?\n/)) {
|
|
158
|
+
const line = raw.trim();
|
|
159
|
+
if (!line || line.startsWith("#")) continue;
|
|
160
|
+
const eq = line.indexOf("=");
|
|
161
|
+
if (eq === -1) continue;
|
|
162
|
+
const key = line.slice(0, eq).trim();
|
|
163
|
+
let val = line.slice(eq + 1).trim();
|
|
164
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
165
|
+
val = val.slice(1, -1);
|
|
166
|
+
}
|
|
167
|
+
if (process.env[key] === undefined) process.env[key] = val;
|
|
168
|
+
}
|
|
169
|
+
} catch {}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function fetchToken(endpoint, license) {
|
|
173
|
+
const res = await fetch(endpoint, {
|
|
174
|
+
method: "GET",
|
|
175
|
+
headers: {
|
|
176
|
+
"authorization": `Bearer ${license}`,
|
|
177
|
+
"accept": "application/json"
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
if (!res.ok) {
|
|
181
|
+
let body = "";
|
|
182
|
+
try { body = await res.text(); } catch {}
|
|
183
|
+
if (VERBOSE) {
|
|
184
|
+
log.warn(`[debug] broker response: status=${res.status} body=${truncate(body, 300)}`);
|
|
185
|
+
}
|
|
186
|
+
let msg = "Request failed. Please try again.";
|
|
187
|
+
if (res.status === 401 || res.status === 403) {
|
|
188
|
+
msg = "Authentication failed. Verify your license or access rights.";
|
|
189
|
+
} else if (res.status === 404) {
|
|
190
|
+
msg = "Service not found or endpoint misconfigured.";
|
|
191
|
+
} else if (res.status === 429) {
|
|
192
|
+
msg = "Too many requests. Please retry later.";
|
|
193
|
+
} else if (res.status >= 500) {
|
|
194
|
+
msg = "Upstream service error. Please retry later.";
|
|
195
|
+
}
|
|
196
|
+
throw new Error(msg);
|
|
197
|
+
}
|
|
198
|
+
const json = await res.json();
|
|
199
|
+
// expected: { registry, token, expiresAt? }
|
|
200
|
+
return json;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function truncate(s, n) {
|
|
204
|
+
if (!s) return s;
|
|
205
|
+
return s.length > n ? s.slice(0, n) + "…" : s;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function sanitizeLicense(x) {
|
|
209
|
+
if (!x) return "";
|
|
210
|
+
let s = String(x);
|
|
211
|
+
// Strip surrounding quotes
|
|
212
|
+
s = s.trim().replace(/^['"]+|['"]+$/g, "");
|
|
213
|
+
// Remove common pasted/hidden chars: bullet, zero-width, NBSP
|
|
214
|
+
s = s
|
|
215
|
+
.replace(/[\u2022\u2023\u25E6\u2043\u2219]/g, "") // bullets
|
|
216
|
+
.replace(/[\u200B-\u200D\uFEFF]/g, "") // zero-width
|
|
217
|
+
.replace(/\u00A0/g, " ") // NBSP -> space
|
|
218
|
+
.replace(/\s+/g, ""); // remove whitespace
|
|
219
|
+
// Normalize fancy quotes to plain
|
|
220
|
+
s = s.replace(/[\u2018\u2019\u201C\u201D]/g, "");
|
|
221
|
+
return s;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function findFirstNonByteChar(s) {
|
|
225
|
+
if (!s) return -1;
|
|
226
|
+
for (let i = 0; i < s.length; i++) {
|
|
227
|
+
const code = s.charCodeAt(i);
|
|
228
|
+
if (code > 255) return i;
|
|
229
|
+
}
|
|
230
|
+
return -1;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function looksLikeLicense(s) {
|
|
234
|
+
if (!s) return false;
|
|
235
|
+
// tb_ followed by base64url-ish payload
|
|
236
|
+
return /^tb_[A-Za-z0-9_-]{10,}$/.test(s);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function run(cmd, args) {
|
|
240
|
+
const { spawn } = await import("node:child_process");
|
|
241
|
+
return new Promise((resolve, reject) => {
|
|
242
|
+
const p = spawn(cmd, args, { stdio: "inherit", shell: true });
|
|
243
|
+
p.on("close", (code) => {
|
|
244
|
+
if (code === 0) resolve(undefined);
|
|
245
|
+
else reject(new Error(`${cmd} ${args.join(" ")} exited with ${code}`));
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function parseArgs(argv) {
|
|
251
|
+
const out = {};
|
|
252
|
+
for (let i = 0; i < argv.length; i++) {
|
|
253
|
+
const a = argv[i];
|
|
254
|
+
if (!a.startsWith("-")) continue;
|
|
255
|
+
const key = a.replace(/^-+/, "");
|
|
256
|
+
const next = argv[i + 1];
|
|
257
|
+
if (["--quiet", "-q"].includes(a)) out.quiet = true;
|
|
258
|
+
else if (["--verbose", "-v"].includes(a)) out.verbose = true;
|
|
259
|
+
else if (next && !next.startsWith("-")) {
|
|
260
|
+
out[key] = next;
|
|
261
|
+
i++;
|
|
262
|
+
} else {
|
|
263
|
+
out[key] = true;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function fail(msg) {
|
|
270
|
+
console.error(`${red("✖")} ${banner} ${msg}`);
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "three-blocks-login",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Fetch a short-lived token from the three-blocks broker and configure npm for the current context.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"three-blocks-login": "bin/login.js"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"files": ["bin/"],
|
|
11
|
+
"keywords": ["npm", "login", "three-blocks", "token", "ci"],
|
|
12
|
+
"dependencies": {},
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
}
|
|
16
|
+
}
|