@itsl-solutions/npm-registry-shield 0.1.0

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/src/config.ts ADDED
@@ -0,0 +1,88 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ export interface ShieldConfig {
6
+ port: number;
7
+ upstream: string;
8
+ quarantineDays: number;
9
+ cacheTtlMinutes: number;
10
+ dashboard: boolean;
11
+ passthrough: string[];
12
+ }
13
+
14
+ const DEFAULT_CONFIG: ShieldConfig = {
15
+ port: 4873,
16
+ upstream: "https://registry.npmjs.org",
17
+ quarantineDays: 3,
18
+ cacheTtlMinutes: 10,
19
+ dashboard: true,
20
+ passthrough: [],
21
+ };
22
+
23
+ export const CONFIG_DIR = join(homedir(), ".npm-shield");
24
+ export const CONFIG_PATH = join(CONFIG_DIR, "config.json");
25
+
26
+ function ensureConfigDir(): void {
27
+ if (!existsSync(CONFIG_DIR)) {
28
+ mkdirSync(CONFIG_DIR, { recursive: true });
29
+ }
30
+ }
31
+
32
+ export function loadConfig(): ShieldConfig {
33
+ ensureConfigDir();
34
+ if (!existsSync(CONFIG_PATH)) {
35
+ saveConfig(DEFAULT_CONFIG);
36
+ return { ...DEFAULT_CONFIG };
37
+ }
38
+ try {
39
+ const raw = readFileSync(CONFIG_PATH, "utf-8");
40
+ const parsed = JSON.parse(raw) as Partial<ShieldConfig>;
41
+ return { ...DEFAULT_CONFIG, ...parsed };
42
+ } catch {
43
+ console.warn("[npm-registry-shield] Config file corrupted, resetting to defaults");
44
+ const config = { ...DEFAULT_CONFIG };
45
+ saveConfig(config);
46
+ return config;
47
+ }
48
+ }
49
+
50
+ export function saveConfig(config: ShieldConfig): void {
51
+ ensureConfigDir();
52
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
53
+ }
54
+
55
+ export function getConfigValue(key: string): unknown {
56
+ const config = loadConfig();
57
+ if (!(key in config)) {
58
+ throw new Error(`Unknown config key: ${key}. Valid keys: ${Object.keys(DEFAULT_CONFIG).join(", ")}`);
59
+ }
60
+ return config[key as keyof ShieldConfig];
61
+ }
62
+
63
+ export function setConfigValue(key: string, value: string): void {
64
+ const config = loadConfig();
65
+ if (!(key in config)) {
66
+ throw new Error(`Unknown config key: ${key}. Valid keys: ${Object.keys(DEFAULT_CONFIG).join(", ")}`);
67
+ }
68
+
69
+ const k = key as keyof ShieldConfig;
70
+ if (k === "dashboard") {
71
+ if (value !== "true" && value !== "false") {
72
+ throw new Error(`${key} must be true or false`);
73
+ }
74
+ config.dashboard = value === "true";
75
+ } else if (k === "port" || k === "quarantineDays" || k === "cacheTtlMinutes") {
76
+ const num = Number(value);
77
+ if (isNaN(num) || num <= 0) {
78
+ throw new Error(`${key} must be a positive number`);
79
+ }
80
+ config[k] = num;
81
+ } else if (k === "passthrough") {
82
+ throw new Error("Use 'npm-registry-shield allow/remove' to manage the passthrough list");
83
+ } else if (k === "upstream") {
84
+ config.upstream = value;
85
+ }
86
+
87
+ saveConfig(config);
88
+ }
package/src/daemon.ts ADDED
@@ -0,0 +1,206 @@
1
+ import { existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs";
2
+ import { execSync } from "node:child_process";
3
+ import { homedir, platform } from "node:os";
4
+ import { join } from "node:path";
5
+ import { CONFIG_DIR } from "./config.js";
6
+
7
+ const LABEL = "com.npm-registry-shield";
8
+ const LOG_PATH = join(CONFIG_DIR, "daemon.log");
9
+
10
+ function getLaunchdPlistPath(): string {
11
+ const dir = join(homedir(), "Library", "LaunchAgents");
12
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
13
+ return join(dir, `${LABEL}.plist`);
14
+ }
15
+
16
+ function getSystemdUnitPath(): string {
17
+ const dir = join(homedir(), ".config", "systemd", "user");
18
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
19
+ return join(dir, "npm-registry-shield.service");
20
+ }
21
+
22
+ function findBunPath(): string {
23
+ try {
24
+ return execSync("which bun", { encoding: "utf-8" }).trim();
25
+ } catch {
26
+ throw new Error("bun not found on PATH. Install bun: https://bun.sh");
27
+ }
28
+ }
29
+
30
+ function findCliPath(): string {
31
+ try {
32
+ const out = execSync("which npm-registry-shield", { encoding: "utf-8" }).trim();
33
+ if (out) return out;
34
+ } catch {
35
+ // fall through
36
+ }
37
+ throw new Error(
38
+ "npm-registry-shield is not on PATH. Install it: 'bun add -g npm-registry-shield' (or 'bun link' from a clone)."
39
+ );
40
+ }
41
+
42
+ export function generateLaunchdPlist(): string {
43
+ const bunPath = findBunPath();
44
+ const cliPath = findCliPath();
45
+
46
+ return `<?xml version="1.0" encoding="UTF-8"?>
47
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
48
+ <plist version="1.0">
49
+ <dict>
50
+ <key>Label</key>
51
+ <string>${LABEL}</string>
52
+ <key>ProgramArguments</key>
53
+ <array>
54
+ <string>${bunPath}</string>
55
+ <string>${cliPath}</string>
56
+ <string>start</string>
57
+ <string>--foreground</string>
58
+ </array>
59
+ <key>RunAtLoad</key>
60
+ <true/>
61
+ <key>KeepAlive</key>
62
+ <true/>
63
+ <key>StandardOutPath</key>
64
+ <string>${LOG_PATH}</string>
65
+ <key>StandardErrorPath</key>
66
+ <string>${LOG_PATH}</string>
67
+ <key>EnvironmentVariables</key>
68
+ <dict>
69
+ <key>PATH</key>
70
+ <string>${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}</string>
71
+ </dict>
72
+ </dict>
73
+ </plist>
74
+ `;
75
+ }
76
+
77
+ export function generateSystemdUnit(): string {
78
+ const bunPath = findBunPath();
79
+ const cliPath = findCliPath();
80
+ const quote = (p: string): string => (p.includes(" ") ? `"${p}"` : p);
81
+
82
+ return `[Unit]
83
+ Description=npm-registry-shield proxy
84
+ After=network.target
85
+
86
+ [Service]
87
+ Type=simple
88
+ ExecStart=${quote(bunPath)} ${quote(cliPath)} start --foreground
89
+ Restart=on-failure
90
+ RestartSec=5
91
+ StandardOutput=append:${LOG_PATH}
92
+ StandardError=append:${LOG_PATH}
93
+ Environment=PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}
94
+
95
+ [Install]
96
+ WantedBy=default.target
97
+ `;
98
+ }
99
+
100
+ export function installDaemon(): void {
101
+ const os = platform();
102
+ if (os === "darwin") return installLaunchd();
103
+ if (os === "linux") return installSystemd();
104
+ throw new Error("DAEMON_UNSUPPORTED");
105
+ }
106
+
107
+ export function uninstallDaemon(): void {
108
+ const os = platform();
109
+ if (os === "darwin") return uninstallLaunchd();
110
+ if (os === "linux") return uninstallSystemd();
111
+ }
112
+
113
+ export function isDaemonRunning(): boolean {
114
+ const os = platform();
115
+ if (os === "darwin") {
116
+ try {
117
+ const out = execSync(`launchctl list ${LABEL} 2>/dev/null`, { encoding: "utf-8" });
118
+ return out.includes(LABEL);
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
123
+ if (os === "linux") {
124
+ try {
125
+ const out = execSync("systemctl --user is-active npm-registry-shield 2>/dev/null", { encoding: "utf-8" });
126
+ return out.trim() === "active";
127
+ } catch {
128
+ return false;
129
+ }
130
+ }
131
+ return false;
132
+ }
133
+
134
+ export function getDaemonType(): string {
135
+ const os = platform();
136
+ if (os === "darwin") return "launchd";
137
+ if (os === "linux") return "systemd";
138
+ return "unsupported";
139
+ }
140
+
141
+ function installLaunchd(): void {
142
+ const plistPath = getLaunchdPlistPath();
143
+ if (existsSync(plistPath)) {
144
+ try {
145
+ execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { stdio: "ignore" });
146
+ } catch {
147
+ // ignore
148
+ }
149
+ }
150
+ writeFileSync(plistPath, generateLaunchdPlist(), "utf-8");
151
+ execSync(`launchctl load "${plistPath}"`);
152
+ console.log(`[npm-registry-shield] Daemon installed and started (launchd)`);
153
+ console.log(`[npm-registry-shield] Plist: ${plistPath}`);
154
+ console.log(`[npm-registry-shield] Logs: ${LOG_PATH}`);
155
+ }
156
+
157
+ function uninstallLaunchd(): void {
158
+ const plistPath = getLaunchdPlistPath();
159
+ if (!existsSync(plistPath)) return;
160
+ try {
161
+ execSync(`launchctl unload "${plistPath}"`, { stdio: "ignore" });
162
+ } catch {
163
+ // may already be unloaded
164
+ }
165
+ unlinkSync(plistPath);
166
+ console.log("[npm-registry-shield] Daemon stopped and removed (launchd)");
167
+ }
168
+
169
+ function installSystemd(): void {
170
+ const unitPath = getSystemdUnitPath();
171
+ writeFileSync(unitPath, generateSystemdUnit(), "utf-8");
172
+ execSync("systemctl --user daemon-reload");
173
+ execSync("systemctl --user enable npm-registry-shield");
174
+ execSync("systemctl --user start npm-registry-shield");
175
+ console.log("[npm-registry-shield] Daemon installed and started (systemd)");
176
+ console.log(`[npm-registry-shield] Unit: ${unitPath}`);
177
+ console.log("[npm-registry-shield] Logs: journalctl --user -u npm-registry-shield");
178
+ }
179
+
180
+ function uninstallSystemd(): void {
181
+ const unitPath = getSystemdUnitPath();
182
+ if (!existsSync(unitPath)) return;
183
+ try {
184
+ execSync("systemctl --user stop npm-registry-shield", { stdio: "ignore" });
185
+ execSync("systemctl --user disable npm-registry-shield", { stdio: "ignore" });
186
+ } catch {
187
+ // may already be stopped
188
+ }
189
+ unlinkSync(unitPath);
190
+ execSync("systemctl --user daemon-reload");
191
+ console.log("[npm-registry-shield] Daemon stopped and removed (systemd)");
192
+ }
193
+
194
+ export function printDaemonInstructions(target: "launchd" | "systemd"): void {
195
+ if (target === "launchd") {
196
+ console.error("# Save this output to ~/Library/LaunchAgents/com.npm-registry-shield.plist");
197
+ console.error("# Then run: launchctl load ~/Library/LaunchAgents/com.npm-registry-shield.plist");
198
+ console.error("");
199
+ process.stdout.write(generateLaunchdPlist());
200
+ return;
201
+ }
202
+ console.error("# Save this output to ~/.config/systemd/user/npm-registry-shield.service");
203
+ console.error("# Then run: systemctl --user daemon-reload && systemctl --user enable --now npm-registry-shield");
204
+ console.error("");
205
+ process.stdout.write(generateSystemdUnit());
206
+ }
@@ -0,0 +1,230 @@
1
+ export function getDashboardHtml(): string {
2
+ return `<!DOCTYPE html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>npm-registry-shield dashboard</title>
8
+ <script src="https://cdn.jsdelivr.net/npm/@maleta/blokjs/dist/blokjs.min.js"><\/script>
9
+ <style>
10
+ * { margin: 0; padding: 0; box-sizing: border-box; }
11
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace; background: #0d1117; color: #c9d1d9; }
12
+ #app { max-width: 960px; margin: 0 auto; padding: 24px; }
13
+ </style>
14
+ </head>
15
+ <body>
16
+ <div id="app"></div>
17
+ <script>
18
+ blok.mount('#app', {
19
+ state: {
20
+ stats: null,
21
+ config: null,
22
+ lastUpdate: '',
23
+ fetchError: '',
24
+ showAll: false
25
+ },
26
+
27
+ computed: {
28
+ packages() {
29
+ if (!this.stats || !this.stats.packages) return [];
30
+ return Object.entries(this.stats.packages)
31
+ .map(function(entry) {
32
+ var origins = entry[1].origins || {};
33
+ var tools = entry[1].tools || {};
34
+ var originsList = Object.keys(origins)
35
+ .map(function(p) { return { dir: p, count: origins[p] }; })
36
+ .sort(function(a, b) { return b.count - a.count; })
37
+ .slice(0, 5);
38
+ var toolsList = Object.keys(tools)
39
+ .map(function(t) { return { name: t, count: tools[t] }; })
40
+ .sort(function(a, b) { return b.count - a.count; });
41
+ return {
42
+ name: entry[0],
43
+ requests: entry[1].requests,
44
+ versionsFiltered: entry[1].versionsFiltered,
45
+ blocked: entry[1].blocked,
46
+ warnings: entry[1].warnings,
47
+ lastChecked: entry[1].lastChecked,
48
+ origins: originsList,
49
+ tools: toolsList,
50
+ hasOrigins: originsList.length > 0 || toolsList.length > 0
51
+ };
52
+ })
53
+ .sort(function(a, b) { return b.requests - a.requests; });
54
+ },
55
+ visiblePackages() {
56
+ if (this.showAll) return this.packages;
57
+ return this.packages.filter(function(p) { return p.requests > 10; });
58
+ },
59
+ hiddenCount() {
60
+ return this.packages.length - this.visiblePackages.length;
61
+ },
62
+ hasPackages() {
63
+ return this.packages.length > 0;
64
+ },
65
+ toggleLabel() {
66
+ if (this.showAll) return 'Collapse';
67
+ return 'Show all (' + this.hiddenCount + ' hidden ≤ 10 req)';
68
+ }
69
+ },
70
+
71
+ methods: {
72
+ async fetchData() {
73
+ try {
74
+ var statsRes = await fetch('/_/stats.json');
75
+ this.stats = await statsRes.json();
76
+ var configRes = await fetch('/_/config.json');
77
+ this.config = await configRes.json();
78
+ this.lastUpdate = new Date().toLocaleTimeString();
79
+ this.fetchError = '';
80
+ } catch (e) {
81
+ this.fetchError = 'Failed to fetch data';
82
+ }
83
+ }
84
+ },
85
+
86
+ mount() {
87
+ this.fetchData();
88
+ setInterval(this.fetchData.bind(this), 10000);
89
+ },
90
+
91
+ view: function($) {
92
+ return {
93
+ div: { children: [
94
+ { div: { style: 'display:flex; align-items:center; justify-content:space-between; margin-bottom:32px; border-bottom:1px solid #21262d; padding-bottom:16px', children: [
95
+ { h1: { style: 'font-size:24px; font-weight:600; color:#58a6ff', text: 'npm-registry-shield' } },
96
+ { span: { style: 'font-size:13px; color:#8b949e', text: $.lastUpdate } }
97
+ ]}},
98
+
99
+ { when: $.fetchError, children: [
100
+ { div: { style: 'background:#3d1f1f; border:1px solid #f85149; border-radius:6px; padding:12px; margin-bottom:24px; color:#f85149', text: $.fetchError } }
101
+ ]},
102
+
103
+ { when: $.stats, children: [
104
+ { div: { style: 'display:grid; grid-template-columns:repeat(4, 1fr); gap:16px; margin-bottom:32px', children: [
105
+ { div: { style: 'background:#161b22; border:1px solid #21262d; border-radius:8px; padding:16px', children: [
106
+ { div: { style: 'font-size:12px; color:#8b949e; text-transform:uppercase; letter-spacing:0.5px; margin-bottom:4px', text: 'Requests' } },
107
+ { div: { style: 'font-size:28px; font-weight:700; color:#c9d1d9', text: $.stats.totalRequests } }
108
+ ]}},
109
+ { div: { style: 'background:#161b22; border:1px solid #21262d; border-radius:8px; padding:16px', children: [
110
+ { div: { style: 'font-size:12px; color:#8b949e; text-transform:uppercase; letter-spacing:0.5px; margin-bottom:4px', text: 'Hidden versions', title: 'Total quarantined versions stripped from served packuments (summed across all responses)' } },
111
+ { div: { style: 'font-size:28px; font-weight:700; color:#d29922', text: $.stats.totalFiltered } }
112
+ ]}},
113
+ { div: { style: 'background:#161b22; border:1px solid #21262d; border-radius:8px; padding:16px', children: [
114
+ { div: { style: 'font-size:12px; color:#8b949e; text-transform:uppercase; letter-spacing:0.5px; margin-bottom:4px', text: 'Blocked' } },
115
+ { div: { style: 'font-size:28px; font-weight:700; color:#f85149', text: $.stats.totalBlocked } }
116
+ ]}},
117
+ { div: { style: 'background:#161b22; border:1px solid #21262d; border-radius:8px; padding:16px', children: [
118
+ { div: { style: 'font-size:12px; color:#8b949e; text-transform:uppercase; letter-spacing:0.5px; margin-bottom:4px', text: 'Warnings' } },
119
+ { div: { style: 'font-size:28px; font-weight:700; color:#d29922', text: $.stats.totalWarnings } }
120
+ ]}}
121
+ ]}},
122
+
123
+ { when: $.config, children: [
124
+ { div: { style: 'background:#161b22; border:1px solid #21262d; border-radius:8px; padding:16px; margin-bottom:24px', children: [
125
+ { div: { style: 'font-size:14px; font-weight:600; margin-bottom:12px; color:#58a6ff', text: 'Configuration' } },
126
+ { div: { style: 'display:flex; gap:24px; font-size:13px', children: [
127
+ { span: { style: 'color:#8b949e', children: [
128
+ { span: { text: 'Quarantine: ' } },
129
+ { span: { style: 'color:#c9d1d9; font-weight:500', text: $.config.quarantineDays } },
130
+ { span: { text: ' days' } }
131
+ ]}},
132
+ { span: { style: 'color:#8b949e', children: [
133
+ { span: { text: 'Upstream: ' } },
134
+ { span: { style: 'color:#c9d1d9; font-weight:500', text: $.config.upstream } }
135
+ ]}}
136
+ ]}}
137
+ ]}}
138
+ ]},
139
+
140
+ { div: { style: 'background:#161b22; border:1px solid #21262d; border-radius:8px; padding:16px; margin-bottom:24px; font-size:13px; line-height:1.6', children: [
141
+ { div: { style: 'font-size:14px; font-weight:600; margin-bottom:12px; color:#58a6ff', text: 'What the columns mean' } },
142
+ { div: { style: 'display:grid; grid-template-columns:max-content 1fr; column-gap:16px; row-gap:6px; color:#8b949e', children: [
143
+ { div: { style: 'color:#c9d1d9; font-weight:500', text: 'Requests' } },
144
+ { div: { text: 'proxy hits for the package’s metadata' } },
145
+
146
+ { div: { style: 'color:#d29922; font-weight:500', text: 'Hidden' } },
147
+ { div: { text: 'quarantined versions stripped from served responses (cumulative across requests, so a high Hidden:Requests ratio means the package publishes frequently)' } },
148
+
149
+ { div: { style: 'color:#f85149; font-weight:500', text: 'Blocked' } },
150
+ { div: { text: 'times every version of the package was within the quarantine window and the install was refused with 404' } },
151
+
152
+ { div: { style: 'color:#d29922; font-weight:500', text: 'Warnings' } },
153
+ { div: { text: 'times a quarantined version was served anyway, either via a passthrough rule (allow pkg@version) or a direct single-version request' } }
154
+ ]}}
155
+ ]}},
156
+
157
+ { when: $.hasPackages, children: [
158
+ { div: { style: 'background:#161b22; border:1px solid #21262d; border-radius:8px; overflow:hidden', children: [
159
+ { div: { style: 'padding:16px; border-bottom:1px solid #21262d; display:flex; align-items:center; justify-content:space-between', children: [
160
+ { div: { style: 'font-size:14px; font-weight:600; color:#58a6ff', text: 'Packages' } },
161
+ { when: $.hiddenCount, children: [
162
+ { button: { style: 'background:transparent; border:1px solid #30363d; color:#58a6ff; font-size:12px; padding:4px 10px; border-radius:6px; cursor:pointer; font-family:inherit', click: 'showAll = !showAll', text: $.toggleLabel } }
163
+ ]}
164
+ ]}},
165
+ { table: { style: 'width:100%; border-collapse:collapse; font-size:13px', children: [
166
+ { thead: { children: [
167
+ { tr: { style: 'border-bottom:1px solid #21262d', children: [
168
+ { th: { style: 'text-align:left; padding:8px 16px; color:#8b949e; font-weight:500', text: 'Package' } },
169
+ { th: { style: 'text-align:right; padding:8px 16px; color:#8b949e; font-weight:500', text: 'Requests' } },
170
+ { th: { style: 'text-align:right; padding:8px 16px; color:#8b949e; font-weight:500', title: 'Quarantined versions stripped from served responses (cumulative)', text: 'Hidden' } },
171
+ { th: { style: 'text-align:right; padding:8px 16px; color:#8b949e; font-weight:500', text: 'Blocked' } },
172
+ { th: { style: 'text-align:right; padding:8px 16px; color:#8b949e; font-weight:500', text: 'Warnings' } }
173
+ ]}}
174
+ ]}},
175
+ { tbody: { children: [
176
+ { each: $.visiblePackages, as: 'pkg', key: 'name', children: [
177
+ { tr: { style: 'border-bottom:1px solid #21262d', children: [
178
+ { td: { style: 'padding:8px 16px; color:#c9d1d9; font-family:monospace; vertical-align:top', children: [
179
+ { div: { text: $.pkg.name } },
180
+ { when: $.pkg.hasOrigins, children: [
181
+ { div: { style: 'margin-top:6px; font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace; font-size:11px; color:#8b949e', children: [
182
+ { when: $.pkg.tools, children: [
183
+ { each: $.pkg.tools, as: 't', key: 'name', children: [
184
+ { span: { style: 'display:inline-block; margin-right:8px; padding:1px 6px; background:#21262d; border-radius:3px; color:#8b949e', children: [
185
+ { span: { style: 'color:#58a6ff', text: $.t.name } },
186
+ { span: { text: ' x' } },
187
+ { span: { text: $.t.count } }
188
+ ]}}
189
+ ]}
190
+ ]},
191
+ { each: $.pkg.origins, as: 'origin', key: 'dir', children: [
192
+ { div: { style: 'margin-top:2px', children: [
193
+ { span: { style: 'color:#8b949e', text: $.origin.dir } },
194
+ { span: { style: 'color:#6e7681', children: [
195
+ { span: { text: ' (' } },
196
+ { span: { text: $.origin.count } },
197
+ { span: { text: ')' } }
198
+ ]}}
199
+ ]}}
200
+ ]}
201
+ ]}}
202
+ ]}
203
+ ]}},
204
+ { td: { style: 'text-align:right; padding:8px 16px; color:#c9d1d9; vertical-align:top', text: $.pkg.requests } },
205
+ { td: { style: 'text-align:right; padding:8px 16px; color:#d29922; vertical-align:top', text: $.pkg.versionsFiltered } },
206
+ { td: { style: 'text-align:right; padding:8px 16px; color:#f85149; vertical-align:top', text: $.pkg.blocked } },
207
+ { td: { style: 'text-align:right; padding:8px 16px; color:#d29922; vertical-align:top', text: $.pkg.warnings } }
208
+ ]}}
209
+ ]}
210
+ ]}}
211
+ ]}}
212
+ ]}}
213
+ ]},
214
+
215
+ { when: $.not.hasPackages, children: [
216
+ { div: { style: 'text-align:center; padding:48px; color:#8b949e; font-size:14px', text: 'No packages proxied yet. Run npm install to see data here.' } }
217
+ ]}
218
+ ]},
219
+
220
+ { when: $.not.stats, children: [
221
+ { div: { style: 'text-align:center; padding:48px; color:#8b949e', text: 'Loading...' } }
222
+ ]}
223
+ ]}
224
+ };
225
+ }
226
+ });
227
+ <\/script>
228
+ </body>
229
+ </html>`;
230
+ }