@rubytech/create-maxy 1.0.781 → 1.0.783

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy",
3
- "version": "1.0.781",
3
+ "version": "1.0.783",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy": "./dist/index.js"
@@ -38,11 +38,34 @@ const CONNECT_FIFO = args["connect-fifo"] || "/tmp/wifi-provision-connect";
38
38
  const RESULT_FILE = args["result-file"] || "/tmp/wifi-provision-result";
39
39
  const BRAND_JSON = args["brand-json"] || "";
40
40
  const HOSTNAME = args.hostname || "maxy";
41
-
42
- // ── Load brand config ────────────────────────────────────────────���──
41
+ const PORTAL_HOST = args["portal-host"] || "";
42
+ // Brand service port (e.g. 19200). Distinct from PORT, which is the
43
+ // captive portal's own listen port (always 80). Used only to render the
44
+ // post-connect URL.
45
+ const DEVICE_PORT = parseInt(args["device-port"] || "19200", 10);
46
+
47
+ // ── Load brand config ─────────────────────────────────────────────────
48
+ // All visual surfaces (colour, font, logo) come from brand.json so the
49
+ // captive portal matches the rest of the product. Earlier this file
50
+ // hard-coded a generic dark navy + system font fallback that bore no
51
+ // relation to the brand's actual look — the operator-side complaint
52
+ // that surfaced this. Now: read the brand's `defaultColors`,
53
+ // `defaultFonts`, and `assets.logo`, fall through to neutral defaults
54
+ // only if a field is missing from brand.json.
43
55
  let brandName = "Maxy";
44
- let brandColor = "#1a1a2e";
45
- let brandAccent = "#4a90d9";
56
+ const colors = {
57
+ primary: "#1a1a2e",
58
+ primaryHover: "#0f0f1f",
59
+ background: "#FAFAF8",
60
+ text: "#1a1a2e",
61
+ card: "rgba(0,0,0,0.04)",
62
+ cardBorder: "rgba(0,0,0,0.08)",
63
+ muted: "#6b6b6b",
64
+ };
65
+ const fonts = {
66
+ display: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
67
+ body: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
68
+ };
46
69
  let logoBase64 = "";
47
70
 
48
71
  try {
@@ -50,15 +73,29 @@ try {
50
73
  const brand = JSON.parse(fs.readFileSync(BRAND_JSON, "utf-8"));
51
74
  if (brand.productName) brandName = brand.productName;
52
75
 
53
- // Load logo if available (brand assets are alongside brand.json)
76
+ if (brand.defaultColors) {
77
+ const c = brand.defaultColors;
78
+ if (c.primary) colors.primary = c.primary;
79
+ if (c.primaryHover) colors.primaryHover = c.primaryHover;
80
+ if (c.background) colors.background = c.background;
81
+ if (c.agentBubble) colors.card = c.agentBubble;
82
+ }
83
+ if (brand.defaultFonts) {
84
+ if (brand.defaultFonts.display) fonts.display = brand.defaultFonts.display;
85
+ if (brand.defaultFonts.body) fonts.body = brand.defaultFonts.body;
86
+ }
87
+
88
+ // Load logo. Prefer assets.logo (the larger brand mark) and fall
89
+ // back to assets.icon.
54
90
  const brandDir = path.dirname(BRAND_JSON);
55
- if (brand.assets && brand.assets.icon) {
56
- const iconPath = path.join(brandDir, "assets", brand.assets.icon);
57
- if (fs.existsSync(iconPath)) {
58
- const iconData = fs.readFileSync(iconPath);
59
- const ext = path.extname(iconPath).slice(1);
91
+ const logoFile = (brand.assets && (brand.assets.logo || brand.assets.icon)) || "";
92
+ if (logoFile) {
93
+ const logoPath = path.join(brandDir, "assets", logoFile);
94
+ if (fs.existsSync(logoPath)) {
95
+ const logoData = fs.readFileSync(logoPath);
96
+ const ext = path.extname(logoPath).slice(1);
60
97
  const mime = ext === "png" ? "image/png" : ext === "ico" ? "image/x-icon" : "image/" + ext;
61
- logoBase64 = `data:${mime};base64,${iconData.toString("base64")}`;
98
+ logoBase64 = `data:${mime};base64,${logoData.toString("base64")}`;
62
99
  }
63
100
  }
64
101
  }
@@ -95,25 +132,29 @@ function portalHTML() {
95
132
  <style>
96
133
  * { margin: 0; padding: 0; box-sizing: border-box; }
97
134
  body {
98
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
99
- background: ${brandColor};
100
- color: #e8e8e8;
135
+ font-family: ${fonts.body};
136
+ background: ${colors.background};
137
+ color: ${colors.text};
101
138
  min-height: 100vh;
102
139
  display: flex;
103
140
  flex-direction: column;
104
141
  align-items: center;
105
142
  padding: 24px 16px;
106
143
  }
107
- .logo { width: 48px; height: 48px; margin-bottom: 8px; border-radius: 12px; }
144
+ .logo { width: 56px; height: 56px; margin-bottom: 12px; border-radius: 12px; }
108
145
  .logo-text {
109
- font-size: 24px; font-weight: 700; margin-bottom: 8px;
110
- color: ${brandAccent};
146
+ font-family: ${fonts.display};
147
+ font-size: 28px; font-weight: 600; margin-bottom: 8px;
148
+ color: ${colors.primary};
149
+ }
150
+ h1 {
151
+ font-family: ${fonts.display};
152
+ font-size: 22px; font-weight: 500; margin-bottom: 4px;
111
153
  }
112
- h1 { font-size: 20px; font-weight: 600; margin-bottom: 4px; }
113
- .subtitle { font-size: 14px; color: #999; margin-bottom: 24px; }
154
+ .subtitle { font-size: 14px; color: ${colors.muted}; margin-bottom: 24px; }
114
155
  .card {
115
- background: rgba(255,255,255,0.06);
116
- border: 1px solid rgba(255,255,255,0.1);
156
+ background: ${colors.card};
157
+ border: 1px solid ${colors.cardBorder};
117
158
  border-radius: 12px;
118
159
  width: 100%; max-width: 360px;
119
160
  padding: 16px;
@@ -128,17 +169,17 @@ function portalHTML() {
128
169
  transition: background 0.15s;
129
170
  }
130
171
  .network-item:hover, .network-item.selected {
131
- background: rgba(255,255,255,0.1);
172
+ background: ${colors.cardBorder};
132
173
  }
133
- .network-item.selected { border: 1px solid ${brandAccent}; }
174
+ .network-item.selected { border: 1px solid ${colors.primary}; }
134
175
  .network-name { font-size: 15px; font-weight: 500; }
135
- .network-meta { font-size: 12px; color: #888; }
176
+ .network-meta { font-size: 12px; color: ${colors.muted}; }
136
177
  .signal-bars { display: flex; gap: 2px; align-items: flex-end; height: 16px; }
137
178
  .signal-bar {
138
179
  width: 4px; border-radius: 1px;
139
- background: rgba(255,255,255,0.2);
180
+ background: ${colors.cardBorder};
140
181
  }
141
- .signal-bar.active { background: ${brandAccent}; }
182
+ .signal-bar.active { background: ${colors.primary}; }
142
183
  .password-group {
143
184
  position: relative;
144
185
  margin-top: 16px;
@@ -146,34 +187,35 @@ function portalHTML() {
146
187
  .password-group input {
147
188
  width: 100%;
148
189
  padding: 12px 44px 12px 12px;
149
- background: rgba(255,255,255,0.08);
150
- border: 1px solid rgba(255,255,255,0.15);
190
+ background: ${colors.background};
191
+ border: 1px solid ${colors.cardBorder};
151
192
  border-radius: 8px;
152
- color: #e8e8e8;
193
+ color: ${colors.text};
153
194
  font-size: 15px;
154
195
  outline: none;
155
196
  }
156
- .password-group input:focus { border-color: ${brandAccent}; }
197
+ .password-group input:focus { border-color: ${colors.primary}; }
157
198
  .toggle-pw {
158
199
  position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
159
- background: none; border: none; color: #888; cursor: pointer;
200
+ background: none; border: none; color: ${colors.muted}; cursor: pointer;
160
201
  font-size: 13px; padding: 4px 8px;
161
202
  }
162
203
  .btn {
163
204
  width: 100%; padding: 14px;
164
- background: ${brandAccent};
205
+ background: ${colors.primary};
165
206
  color: #fff; font-size: 16px; font-weight: 600;
166
207
  border: none; border-radius: 8px;
167
208
  cursor: pointer; margin-top: 16px;
168
- transition: opacity 0.15s;
209
+ transition: background 0.15s;
169
210
  min-height: 48px;
170
211
  }
171
- .btn:disabled { opacity: 0.5; cursor: not-allowed; }
212
+ .btn:hover:not(:disabled) { background: ${colors.primaryHover}; }
213
+ .btn:disabled { opacity: 0.4; cursor: not-allowed; }
172
214
  .error {
173
- color: #ff6b6b; font-size: 13px;
215
+ color: #c0392b; font-size: 13px;
174
216
  margin-top: 8px; display: none;
175
217
  }
176
- .empty { text-align: center; padding: 24px; color: #888; font-size: 14px; }
218
+ .empty { text-align: center; padding: 24px; color: ${colors.muted}; font-size: 14px; }
177
219
 
178
220
  /* Progress / success screens */
179
221
  .screen { display: none; width: 100%; max-width: 360px; text-align: center; }
@@ -181,31 +223,35 @@ function portalHTML() {
181
223
  @keyframes spin { to { transform: rotate(360deg); } }
182
224
  .spinner {
183
225
  width: 40px; height: 40px;
184
- border: 3px solid rgba(255,255,255,0.15);
185
- border-top-color: ${brandAccent};
226
+ border: 3px solid ${colors.cardBorder};
227
+ border-top-color: ${colors.primary};
186
228
  border-radius: 50%;
187
229
  animation: spin 0.8s linear infinite;
188
230
  margin: 24px auto;
189
231
  }
190
232
  .success-icon {
191
233
  width: 48px; height: 48px; margin: 24px auto;
192
- border-radius: 50%; background: #2ecc71;
234
+ border-radius: 50%; background: ${colors.primary};
193
235
  display: flex; align-items: center; justify-content: center;
194
236
  font-size: 24px; color: #fff;
195
237
  }
196
238
  .address-box {
197
- background: rgba(255,255,255,0.08);
198
- border: 1px solid rgba(255,255,255,0.15);
239
+ background: ${colors.card};
240
+ border: 1px solid ${colors.cardBorder};
199
241
  border-radius: 8px;
200
242
  padding: 12px 16px;
201
243
  margin: 16px auto;
202
244
  display: inline-block;
245
+ max-width: 100%;
246
+ word-break: break-all;
203
247
  }
204
248
  .address-box a {
205
- color: ${brandAccent};
206
- text-decoration: none;
249
+ color: ${colors.primary};
250
+ text-decoration: underline;
207
251
  font-size: 16px;
208
252
  font-weight: 600;
253
+ display: block;
254
+ -webkit-tap-highlight-color: ${colors.primary};
209
255
  }
210
256
  .hint { color: #888; font-size: 13px; margin-top: 12px; line-height: 1.5; }
211
257
  </style>
@@ -245,13 +291,13 @@ function portalHTML() {
245
291
  <div class="card" style="padding:32px 16px">
246
292
  <div class="success-icon">&#10003;</div>
247
293
  <h1>Connected!</h1>
248
- <p class="hint">${escapedBrandName} is now online. Redirecting in <span id="redirect-countdown">5</span>s…</p>
294
+ <p class="hint">${escapedBrandName} is now online. Redirecting in <span id="redirect-countdown">20</span>s…</p>
249
295
  <div class="address-box">
250
- <a id="device-link" href="#" target="_blank"></a>
296
+ <a id="device-link" href="#"></a>
251
297
  </div>
252
298
  <p class="hint" id="ip-fallback" style="display:none">If the page above doesn't load (some Android browsers don't resolve <code>.local</code>), use this direct IP:</p>
253
299
  <div class="address-box" id="ip-fallback-box" style="display:none">
254
- <a id="device-link-ip" href="#" target="_blank"></a>
300
+ <a id="device-link-ip" href="#"></a>
255
301
  </div>
256
302
  <p class="hint">This access point will close shortly.<br>Your phone will reconnect to your WiFi automatically.</p>
257
303
  </div>
@@ -261,7 +307,10 @@ function portalHTML() {
261
307
  (function() {
262
308
  "use strict";
263
309
  var selectedSSID = "";
264
- var devicePort = ${PORT === 80 ? '""' : JSON.stringify(":" + PORT)};
310
+ // The brand service port (19200, configurable via --port), NOT the
311
+ // captive portal port (always 80). devicePort is appended to the
312
+ // post-connect URL so the link points at the admin server.
313
+ var devicePort = ${DEVICE_PORT === 80 ? '""' : JSON.stringify(":" + DEVICE_PORT)};
265
314
  var deviceHostname = ${JSON.stringify(HOSTNAME)};
266
315
 
267
316
  // Safe text insertion — no innerHTML for user-controlled content
@@ -370,10 +419,15 @@ function portalHTML() {
370
419
  ? "http://" + data.hostname + ".local" + devicePort
371
420
  : null;
372
421
  var ipAddr = data.ip ? "http://" + data.ip + devicePort : null;
373
- var primary = hostnameAddr || ipAddr;
422
+ // Display: prefer the friendly .local URL.
423
+ // Auto-redirect target: the raw IP. On Android (notably Brave) the
424
+ // mDNS .local lookup fails and the auto-navigate would dead-end;
425
+ // the IP works on every platform.
426
+ var displayAddr = hostnameAddr || ipAddr;
427
+ var redirectAddr = ipAddr || hostnameAddr;
374
428
  var link = document.getElementById("device-link");
375
- link.href = primary;
376
- link.textContent = primary;
429
+ link.href = displayAddr;
430
+ link.textContent = displayAddr;
377
431
  if (hostnameAddr && ipAddr && hostnameAddr !== ipAddr) {
378
432
  var ipLink = document.getElementById("device-link-ip");
379
433
  ipLink.href = ipAddr;
@@ -382,16 +436,20 @@ function portalHTML() {
382
436
  document.getElementById("ip-fallback-box").style.display = "block";
383
437
  }
384
438
  showScreen("success-screen");
385
- // Auto-redirect after 5s gives the phone time to drop the AP and
386
- // rejoin home WiFi before navigating to the .local / IP address.
387
- var remaining = 5;
439
+ // Countdown timing: the Pi keeps the AP up for ~10s after writing the
440
+ // success result (so this poll can land), then tears it down. The
441
+ // phone then needs ~5s to drop the AP SSID and auto-rejoin its home
442
+ // WiFi. A 20s countdown crosses both gates so the redirect lands
443
+ // when the phone is back on its home network and the device is
444
+ // routable on its new IP.
445
+ var remaining = 20;
388
446
  var countdownEl = document.getElementById("redirect-countdown");
389
447
  var ticker = setInterval(function() {
390
448
  remaining -= 1;
391
449
  if (countdownEl) countdownEl.textContent = String(remaining);
392
450
  if (remaining <= 0) {
393
451
  clearInterval(ticker);
394
- window.location.href = primary;
452
+ window.location.href = redirectAddr;
395
453
  }
396
454
  }, 1000);
397
455
  }
@@ -500,7 +558,7 @@ function handleRequest(req, res) {
500
558
 
501
559
  // Captive portal detection — iOS
502
560
  if (pathname === "/hotspot-detect.html" || pathname === "/library/test/success.html") {
503
- res.writeHead(302, { Location: `http://${AP_IP}/` });
561
+ res.writeHead(302, { Location: `http://${PORTAL_HOST || AP_IP}/` });
504
562
  res.end();
505
563
  return;
506
564
  }
@@ -508,14 +566,14 @@ function handleRequest(req, res) {
508
566
  // Captive portal detection — Android
509
567
  if (pathname === "/generate_204" || pathname === "/gen_204" ||
510
568
  pathname === "/connecttest.txt" || pathname === "/ncsi.txt") {
511
- res.writeHead(302, { Location: `http://${AP_IP}/` });
569
+ res.writeHead(302, { Location: `http://${PORTAL_HOST || AP_IP}/` });
512
570
  res.end();
513
571
  return;
514
572
  }
515
573
 
516
574
  // Captive portal detection — Windows
517
575
  if (pathname === "/redirect" || pathname === "/fwlink/") {
518
- res.writeHead(302, { Location: `http://${AP_IP}/` });
576
+ res.writeHead(302, { Location: `http://${PORTAL_HOST || AP_IP}/` });
519
577
  res.end();
520
578
  return;
521
579
  }
@@ -645,7 +703,17 @@ function handleRequest(req, res) {
645
703
  return;
646
704
  }
647
705
 
648
- // Default: serve portal page
706
+ // Default: serve portal page. If the client hit the raw AP IP (e.g.
707
+ // 192.168.4.1) and we have a friendly portal host configured, redirect
708
+ // so the URL bar shows the brand-aware name (e.g. http://maxy.setup/).
709
+ // dnsmasq's wildcard DNS resolves PORTAL_HOST → AP_IP transparently
710
+ // while the AP is up.
711
+ const incomingHost = (req.headers.host || "").split(":")[0];
712
+ if (PORTAL_HOST && incomingHost === AP_IP) {
713
+ res.writeHead(302, { Location: `http://${PORTAL_HOST}/` });
714
+ res.end();
715
+ return;
716
+ }
649
717
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
650
718
  res.end(portalHTML());
651
719
  }
@@ -60,9 +60,33 @@ LOG_FILE="${LOG_DIR}/wifi-provision.log"
60
60
 
61
61
  mkdir -p "$LOG_DIR"
62
62
 
63
+ # Resolve the brand service's public port so the captive portal can render
64
+ # the post-connect URL with the correct port (the captive portal itself
65
+ # runs on :80, but the device's admin URL is on the brand port). Mirrors
66
+ # the same precedence as the installer: ~/{configDir}/.env override, then
67
+ # the systemd unit's Environment=PORT, then the documented default.
68
+ BRAND_PORT="19200"
69
+ if [ -f "${MAXY_DIR}/.env" ]; then
70
+ _p=$(grep -E '^PORT=' "${MAXY_DIR}/.env" 2>/dev/null | tail -1 | cut -d= -f2 | tr -d '"' | tr -d "'")
71
+ [ -n "$_p" ] && BRAND_PORT="$_p"
72
+ fi
73
+ if [ "$BRAND_PORT" = "19200" ]; then
74
+ _svc="${INSTALL_HOME}/.config/systemd/user/${HOSTNAME_NAME}.service"
75
+ if [ -f "$_svc" ]; then
76
+ _p=$(grep -E '^Environment=PORT=' "$_svc" 2>/dev/null | tail -1 | cut -d= -f3)
77
+ [ -n "$_p" ] && BRAND_PORT="$_p"
78
+ fi
79
+ fi
80
+
63
81
  # ── Derived constants ────────────────────────────────────────────────
64
- # SSID: remove spaces from product name, append -Setup
65
- SSID="${PRODUCT_NAME// /}-Setup"
82
+ # SSID: lowercase the product name and replace spaces with hyphens — gives
83
+ # `maxy`, `real-agent`, etc. (kebab-case, no "-Setup" suffix). The user's
84
+ # phone-side WiFi list is the brand surface; an extra "-Setup" suffix is
85
+ # operator jargon, not customer language.
86
+ SSID="$(echo "$PRODUCT_NAME" | tr '[:upper:]' '[:lower:]' | tr ' ' '-')"
87
+ # Captive-portal hostname served by dnsmasq's wildcard — gives the phone
88
+ # a friendly URL bar (`http://maxy.setup/`) instead of `http://192.168.4.1/`.
89
+ PORTAL_HOST="${SSID}.setup"
66
90
  AP_IP="192.168.4.1"
67
91
  AP_SUBNET="192.168.4.0/24"
68
92
  DHCP_RANGE_START="192.168.4.2"
@@ -283,6 +307,8 @@ DNSMASQ_EOF
283
307
  --result-file "$RESULT_FILE" \
284
308
  --brand-json "$BRAND_JSON" \
285
309
  --hostname "$HOSTNAME_NAME" \
310
+ --portal-host "$PORTAL_HOST" \
311
+ --device-port "$BRAND_PORT" \
286
312
  >> "$LOG_FILE" 2>&1 &
287
313
  HTTP_SERVER_PID=$!
288
314
  sleep 1