clay-server 2.11.0-beta.10 → 2.11.0-beta.12

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/cli.js CHANGED
@@ -131,8 +131,8 @@ for (var i = 0; i < args.length; i++) {
131
131
  console.log(" --remove <path> Remove a project directory");
132
132
  console.log(" --list List all registered projects");
133
133
  console.log(" --headless Start daemon and exit immediately (implies --yes)");
134
- console.log(" --multi-user Enable multi-user mode (generates setup code)");
135
- console.log(" --os-users Enable OS-level user isolation (Linux only, requires root + --multi-user)");
134
+ console.log(" --multi-user Start in multi-user mode (use with --yes for headless)");
135
+ console.log(" --os-users Enable OS-level user isolation (Linux, requires root + --multi-user)");
136
136
  console.log(" --dangerously-skip-permissions");
137
137
  console.log(" Bypass all permission prompts");
138
138
  process.exit(0);
@@ -271,59 +271,8 @@ if (listMode) {
271
271
  return;
272
272
  }
273
273
 
274
- // --- Handle --multi-user before anything else ---
275
- if (multiUserMode) {
276
- var muResult = enableMultiUser();
277
- if (muResult.alreadyEnabled && muResult.hasAdmin) {
278
- console.log("");
279
- console.log("Multi-user mode is already enabled and an admin account exists.");
280
- console.log("No changes made.");
281
- console.log("");
282
- } else if (muResult.setupCode) {
283
- console.log("");
284
- console.log("\x1b[33m⚠ Experimental Feature\x1b[0m");
285
- console.log("");
286
- console.log(" Multi-user mode is experimental and may change in future releases.");
287
- console.log(" Sharing access to AI-powered tools may be subject to your provider's");
288
- console.log(" terms of service. Please review the applicable usage policies before");
289
- console.log(" granting access to other users.");
290
- console.log("");
291
- console.log("\x1b[32mMulti-user mode enabled.\x1b[0m");
292
- console.log("");
293
- console.log("Setup code: \x1b[1m" + muResult.setupCode + "\x1b[0m");
294
- console.log("");
295
- console.log("Open Clay in your browser and enter this code to create the admin account.");
296
- console.log("The code is single-use and will be cleared once the admin is set up.");
297
- console.log("");
298
- }
299
- if (!osUsersMode) {
300
- process.exit(0);
301
- }
302
- }
303
-
304
- // --- Handle --os-users validation ---
305
- if (osUsersMode) {
306
- if (process.platform !== "linux") {
307
- console.error("\x1b[31mError: --os-users requires Linux.\x1b[0m");
308
- console.error("OS-level user isolation depends on setfacl, getent, and uid/gid process spawning.");
309
- process.exit(1);
310
- }
311
- if (typeof process.getuid === "function" && process.getuid() !== 0) {
312
- console.error("\x1b[31mError: --os-users requires running as root.\x1b[0m");
313
- console.error("The daemon must run as root to spawn worker processes as different users.");
314
- console.error("Use: sudo clay --multi-user --os-users");
315
- process.exit(1);
316
- }
317
- if (!isMultiUser()) {
318
- console.error("\x1b[31mError: --os-users requires --multi-user mode.\x1b[0m");
319
- console.error("Enable multi-user mode first: clay --multi-user");
320
- process.exit(1);
321
- }
322
- console.log("\x1b[32mOS-level user isolation enabled.\x1b[0m");
323
- console.log("Worker processes will run as mapped Linux users.");
324
- console.log("Linux accounts will be auto-provisioned for existing users on startup.");
325
- console.log("");
326
- }
274
+ // --multi-user / --os-users are now handled in the main entry flow (setup wizard or repeat run)
275
+ // Flags are parsed above and applied during forkDaemon()
327
276
 
328
277
  var cwd = process.cwd();
329
278
 
@@ -1310,44 +1259,70 @@ function setup(callback) {
1310
1259
  }
1311
1260
  port = p;
1312
1261
  log(sym.bar);
1262
+ askMode();
1263
+ });
1264
+ });
1265
+ }
1313
1266
 
1314
- function askPin() {
1315
- promptPin(function (pin) {
1316
- if (dangerouslySkipPermissions && !pin) {
1317
- log(sym.bar);
1318
- log(sym.warn + " " + a.yellow + "WARNING: No PIN + skip permissions = anyone with the URL" + a.reset);
1319
- log(sym.bar + " " + a.yellow + "can execute any command without approval." + a.reset);
1320
- log(sym.bar);
1321
- promptToggle("Continue without PIN?", null, false, function (confirmed) {
1322
- if (!confirmed) {
1323
- clearUp(6);
1324
- log(sym.done + " PIN protection " + a.dim + "·" + a.reset + " " + a.yellow + "Required for skip permissions" + a.reset);
1325
- log(sym.bar);
1326
- askPin();
1327
- return;
1328
- }
1329
- afterPin(pin);
1330
- });
1331
- } else {
1332
- afterPin(pin);
1333
- }
1334
- });
1335
- }
1267
+ function askMode() {
1268
+ promptSelect("How will you use Clay?", [
1269
+ { label: "Just me (single user)", value: "single" },
1270
+ { label: "Multiple users", value: "multi" },
1271
+ ], function (mode) {
1272
+ if (mode === "single") {
1273
+ finishSetup(mode, false);
1274
+ } else {
1275
+ askOsUsers(mode);
1276
+ }
1277
+ });
1278
+ }
1336
1279
 
1337
- function afterPin(pin) {
1338
- if (process.platform === "darwin") {
1339
- promptToggle("Keep awake", "Prevent system sleep while relay is running", false, function (keepAwake) {
1340
- callback(pin, keepAwake);
1341
- });
1342
- } else {
1343
- callback(pin, false);
1344
- }
1280
+ function askOsUsers(mode) {
1281
+ // Only offer OS user isolation on Linux
1282
+ if (process.platform !== "linux") {
1283
+ finishSetup(mode, false);
1284
+ return;
1285
+ }
1286
+ log(sym.bar);
1287
+ promptToggle("Enable OS-level user isolation?", "Run each user's sessions as a separate Linux account", false, function (wantOsUsers) {
1288
+ if (wantOsUsers) {
1289
+ var isRoot = typeof process.getuid === "function" && process.getuid() === 0;
1290
+ if (!isRoot) {
1291
+ // Save config so sudo clay can pick it up
1292
+ var partialConfig = {
1293
+ port: port,
1294
+ host: host,
1295
+ mode: "multi",
1296
+ osUsers: true,
1297
+ setupCompleted: true,
1298
+ dangerouslySkipPermissions: dangerouslySkipPermissions,
1299
+ };
1300
+ saveConfig(partialConfig);
1301
+ log(sym.bar);
1302
+ log(sym.warn + " " + a.yellow + "OS user isolation requires root." + a.reset);
1303
+ log(sym.bar + " Run:");
1304
+ log(sym.bar + " " + a.bold + "sudo clay" + a.reset);
1305
+ log(sym.end);
1306
+ log("");
1307
+ process.exit(0);
1308
+ return;
1345
1309
  }
1310
+ }
1311
+ finishSetup(mode, wantOsUsers);
1312
+ });
1313
+ }
1346
1314
 
1347
- askPin();
1315
+ function finishSetup(mode, wantOsUsers) {
1316
+ if (process.platform === "darwin") {
1317
+ log(sym.bar);
1318
+ promptToggle("Keep awake", "Prevent system sleep while relay is running", false, function (keepAwake) {
1319
+ callback(mode, keepAwake, wantOsUsers);
1348
1320
  });
1349
- });
1321
+ } else {
1322
+ callback(mode, false, wantOsUsers);
1323
+ }
1350
1324
  }
1325
+
1351
1326
  askPort();
1352
1327
  });
1353
1328
  }
@@ -1355,7 +1330,7 @@ function setup(callback) {
1355
1330
  // ==============================
1356
1331
  // Fork the daemon process
1357
1332
  // ==============================
1358
- async function forkDaemon(pin, keepAwake, extraProjects, addCwd) {
1333
+ async function forkDaemon(mode, keepAwake, extraProjects, addCwd, wantOsUsers) {
1359
1334
  var ip = getLocalIP();
1360
1335
  var hasTls = false;
1361
1336
 
@@ -1434,12 +1409,14 @@ async function forkDaemon(pin, keepAwake, extraProjects, addCwd) {
1434
1409
  pid: null,
1435
1410
  port: port,
1436
1411
  host: host,
1437
- pinHash: pin ? generateAuthToken(pin) : null,
1412
+ pinHash: mode === "multi" && cliPin ? generateAuthToken(cliPin) : null,
1438
1413
  tls: hasTls,
1439
1414
  debug: debugMode,
1440
1415
  keepAwake: keepAwake,
1441
1416
  dangerouslySkipPermissions: dangerouslySkipPermissions,
1442
- osUsers: osUsersMode,
1417
+ osUsers: wantOsUsers || osUsersMode,
1418
+ mode: mode || "single",
1419
+ setupCompleted: true,
1443
1420
  projects: allProjects,
1444
1421
  };
1445
1422
 
@@ -1481,6 +1458,18 @@ async function forkDaemon(pin, keepAwake, extraProjects, addCwd) {
1481
1458
  return;
1482
1459
  }
1483
1460
 
1461
+ // Enable multi-user mode if requested
1462
+ if (config.mode === "multi") {
1463
+ var muResult = enableMultiUser();
1464
+ if (muResult.setupCode) {
1465
+ log("");
1466
+ log(sym.done + " " + a.green + "Multi-user mode enabled." + a.reset);
1467
+ log(sym.bar + " Setup code: " + a.bold + muResult.setupCode + a.reset);
1468
+ log(sym.bar + " Open Clay in your browser and enter this code to create the admin account.");
1469
+ log("");
1470
+ }
1471
+ }
1472
+
1484
1473
  // Headless mode — print status and exit immediately
1485
1474
  if (headlessMode) {
1486
1475
  var protocol = config.tls ? "https" : "http";
@@ -1499,7 +1488,7 @@ async function forkDaemon(pin, keepAwake, extraProjects, addCwd) {
1499
1488
  // ==============================
1500
1489
  // Dev mode — foreground daemon with file watching
1501
1490
  // ==============================
1502
- async function devMode(pin, keepAwake, existingPinHash) {
1491
+ async function devMode(mode, keepAwake, existingPinHash) {
1503
1492
  var ip = getLocalIP();
1504
1493
  var hasTls = false;
1505
1494
 
@@ -1565,11 +1554,13 @@ async function devMode(pin, keepAwake, existingPinHash) {
1565
1554
  pid: null,
1566
1555
  port: port,
1567
1556
  host: host,
1568
- pinHash: existingPinHash || (pin ? generateAuthToken(pin) : null),
1557
+ pinHash: existingPinHash || null,
1569
1558
  tls: hasTls,
1570
1559
  debug: true,
1571
1560
  keepAwake: keepAwake || false,
1572
1561
  dangerouslySkipPermissions: dangerouslySkipPermissions,
1562
+ mode: mode || "single",
1563
+ setupCompleted: true,
1573
1564
  projects: allProjects,
1574
1565
  };
1575
1566
 
@@ -2337,6 +2328,11 @@ function showSettingsMenu(config, ip) {
2337
2328
  ? a.green + "Enabled" + a.reset
2338
2329
  : a.dim + "Off" + a.reset;
2339
2330
 
2331
+ var modeLabel = config.mode === "multi" ? "Multi-user" : "Single user";
2332
+ var modeStatus = config.mode === "multi"
2333
+ ? a.clay + modeLabel + a.reset
2334
+ : a.dim + modeLabel + a.reset;
2335
+ log(sym.bar + " Mode " + modeStatus);
2340
2336
  log(sym.bar + " PIN " + pinStatus);
2341
2337
  log(sym.bar + " Multi-user " + muStatus);
2342
2338
  var osUsersStatus = isOsUsers
@@ -2375,6 +2371,7 @@ function showSettingsMenu(config, ip) {
2375
2371
  items.push({ label: isAwake ? "Disable keep awake" : "Enable keep awake", value: "awake" });
2376
2372
  }
2377
2373
  items.push({ label: "View logs", value: "logs" });
2374
+ items.push({ label: "Re-run setup wizard", value: "rerun_setup" });
2378
2375
  items.push({ label: "Back", value: "back" });
2379
2376
 
2380
2377
  promptSelect("Select", items, function (choice) {
@@ -2572,6 +2569,70 @@ function showSettingsMenu(config, ip) {
2572
2569
  });
2573
2570
  break;
2574
2571
 
2572
+ case "rerun_setup":
2573
+ log(sym.bar);
2574
+ log(sym.bar + " " + a.yellow + sym.warn + " Re-run setup wizard?" + a.reset);
2575
+ log(sym.bar);
2576
+ log(sym.bar + " " + a.dim + "This will shut down the running daemon, reset your setup" + a.reset);
2577
+ log(sym.bar + " " + a.dim + "preferences (mode, port), and walk you through the wizard again." + a.reset);
2578
+ log(sym.bar + " " + a.dim + "Your projects and user accounts will be preserved." + a.reset);
2579
+ log(sym.bar);
2580
+ promptSelect("Confirm", [
2581
+ { label: "Re-run setup wizard", value: "confirm" },
2582
+ { label: "Cancel", value: "cancel" },
2583
+ ], function (confirmChoice) {
2584
+ if (confirmChoice === "confirm") {
2585
+ // Clear setupCompleted so setup() runs fresh
2586
+ var cfg = loadConfig() || {};
2587
+ delete cfg.setupCompleted;
2588
+ delete cfg.mode;
2589
+ cfg.pid = null;
2590
+ saveConfig(cfg);
2591
+ // Shut down the daemon
2592
+ sendIPCCommand(socketPath(), { cmd: "shutdown" }).then(function () {
2593
+ clearStaleConfig();
2594
+ // Run the setup wizard, then fork a new daemon
2595
+ setup(function (mode, keepAwake, wantOsUsers) {
2596
+ var rc = loadClayrc();
2597
+ var restorable = (rc.recentProjects || []).filter(function (p) {
2598
+ return p.path !== cwd && fs.existsSync(p.path);
2599
+ });
2600
+ if (restorable.length > 0) {
2601
+ promptRestoreProjects(restorable, function (selected) {
2602
+ forkDaemon(mode, keepAwake, selected, false, wantOsUsers);
2603
+ });
2604
+ } else {
2605
+ log(sym.bar);
2606
+ log(sym.end + " " + a.dim + "Starting relay..." + a.reset);
2607
+ log("");
2608
+ forkDaemon(mode, keepAwake, undefined, true, wantOsUsers);
2609
+ }
2610
+ });
2611
+ }).catch(function () {
2612
+ clearStaleConfig();
2613
+ setup(function (mode, keepAwake, wantOsUsers) {
2614
+ var rc = loadClayrc();
2615
+ var restorable = (rc.recentProjects || []).filter(function (p) {
2616
+ return p.path !== cwd && fs.existsSync(p.path);
2617
+ });
2618
+ if (restorable.length > 0) {
2619
+ promptRestoreProjects(restorable, function (selected) {
2620
+ forkDaemon(mode, keepAwake, selected, false, wantOsUsers);
2621
+ });
2622
+ } else {
2623
+ log(sym.bar);
2624
+ log(sym.end + " " + a.dim + "Starting relay..." + a.reset);
2625
+ log("");
2626
+ forkDaemon(mode, keepAwake, undefined, true, wantOsUsers);
2627
+ }
2628
+ });
2629
+ });
2630
+ } else {
2631
+ showSettingsMenu(config, ip);
2632
+ }
2633
+ });
2634
+ break;
2635
+
2575
2636
  case "logs":
2576
2637
  console.clear();
2577
2638
  log(a.bold + "Daemon logs" + a.reset + " " + a.dim + "(" + logPath() + ")" + a.reset);
@@ -2633,14 +2694,14 @@ var currentVersion = require("../package.json").version;
2633
2694
  if (devConfig.pid) clearStaleConfig();
2634
2695
  devConfig = null;
2635
2696
  }
2636
- // No config — go through setup (disclaimer, port, PIN, etc.)
2697
+ // No config — go through setup (disclaimer, port, mode, etc.)
2637
2698
  if (!devConfig) {
2638
- setup(function (pin, keepAwake) {
2639
- devMode(pin, keepAwake, null);
2699
+ setup(function (mode, keepAwake, wantOsUsers) {
2700
+ devMode(mode, keepAwake, null);
2640
2701
  });
2641
2702
  } else {
2642
- // Reuse existing PIN hash from previous config
2643
- await devMode(cliPin || null, devConfig.keepAwake || false, devConfig.pinHash || null);
2703
+ // Reuse existing config (repeat run)
2704
+ await devMode(devConfig.mode || "single", devConfig.keepAwake || false, devConfig.pinHash || null);
2644
2705
  }
2645
2706
  return;
2646
2707
  }
@@ -2717,19 +2778,55 @@ var currentVersion = require("../package.json").version;
2717
2778
  showMainMenu(config || { pid: status.pid, port: status.port, tls: status.tls }, ip);
2718
2779
  }
2719
2780
  } else {
2720
- // No daemon running — first-time setup
2721
- if (autoYes) {
2722
- var pin = cliPin || null;
2723
- console.log(" " + sym.done + " Auto-accepted disclaimer");
2724
- console.log(" " + sym.done + " PIN: " + (pin ? "Enabled" : "Skipped"));
2725
- if (dangerouslySkipPermissions) {
2726
- console.log(" " + sym.warn + " " + a.yellow + "Skip permissions mode enabled" + (pin ? "" : " (no PIN)") + a.reset);
2781
+ // No daemon running — check for saved config (repeat run)
2782
+ var savedConfig = loadConfig();
2783
+ var isRepeatRun = savedConfig && savedConfig.setupCompleted;
2784
+
2785
+ // --multi-user / --os-users CLI flags set config directly for headless/scripted usage
2786
+ if (multiUserMode) {
2787
+ if (!savedConfig) savedConfig = {};
2788
+ savedConfig.mode = "multi";
2789
+ savedConfig.setupCompleted = true;
2790
+ }
2791
+ if (osUsersMode) {
2792
+ if (!savedConfig) savedConfig = {};
2793
+ savedConfig.osUsers = true;
2794
+ savedConfig.mode = "multi";
2795
+ savedConfig.setupCompleted = true;
2796
+ }
2797
+ isRepeatRun = savedConfig && savedConfig.setupCompleted;
2798
+
2799
+ if (isRepeatRun || autoYes) {
2800
+ // Repeat run or --yes: skip wizard, reuse saved config
2801
+ var savedMode = (savedConfig && savedConfig.mode) || "single";
2802
+ var savedKeepAwake = (savedConfig && savedConfig.keepAwake) || false;
2803
+ var savedOsUsers = (savedConfig && savedConfig.osUsers) || false;
2804
+
2805
+ // os-users requires root
2806
+ if (savedOsUsers && typeof process.getuid === "function" && process.getuid() !== 0) {
2807
+ console.error(a.red + "OS user isolation requires root." + a.reset);
2808
+ console.error("Run: " + a.bold + "sudo clay" + a.reset);
2809
+ process.exit(1);
2810
+ return;
2727
2811
  }
2812
+
2813
+ if (savedConfig && savedConfig.port) port = savedConfig.port;
2814
+ if (savedConfig && savedConfig.host) host = savedConfig.host;
2815
+ if (savedConfig && savedConfig.dangerouslySkipPermissions) dangerouslySkipPermissions = true;
2816
+
2817
+ if (autoYes) {
2818
+ console.log(" " + sym.done + " Auto-accepted disclaimer");
2819
+ console.log(" " + sym.done + " Mode: " + savedMode);
2820
+ if (dangerouslySkipPermissions) {
2821
+ console.log(" " + sym.warn + " " + a.yellow + "Skip permissions mode enabled" + a.reset);
2822
+ }
2823
+ }
2824
+
2728
2825
  var autoRc = loadClayrc();
2729
2826
  var autoRestorable = (autoRc.recentProjects || []).filter(function (p) {
2730
2827
  return p.path !== cwd && fs.existsSync(p.path);
2731
2828
  });
2732
- if (autoRestorable.length > 0) {
2829
+ if (autoRestorable.length > 0 && autoYes) {
2733
2830
  console.log(" " + sym.done + " Restoring " + autoRestorable.length + " previous project(s)");
2734
2831
  }
2735
2832
  // Add cwd if it has history in .clayrc, or if there are no other projects to restore
@@ -2737,9 +2834,10 @@ var currentVersion = require("../package.json").version;
2737
2834
  return p.path === cwd;
2738
2835
  });
2739
2836
  var addCwd = cwdInRc || autoRestorable.length === 0;
2740
- await forkDaemon(pin, false, autoRestorable.length > 0 ? autoRestorable : undefined, addCwd);
2837
+ await forkDaemon(savedMode, savedKeepAwake, autoRestorable.length > 0 ? autoRestorable : undefined, addCwd, savedOsUsers);
2741
2838
  } else {
2742
- setup(function (pin, keepAwake) {
2839
+ // First run: interactive wizard
2840
+ setup(function (mode, keepAwake, wantOsUsers) {
2743
2841
  // Check ~/.clayrc for previous projects to restore
2744
2842
  var rc = loadClayrc();
2745
2843
  var restorable = (rc.recentProjects || []).filter(function (p) {
@@ -2748,13 +2846,13 @@ var currentVersion = require("../package.json").version;
2748
2846
 
2749
2847
  if (restorable.length > 0) {
2750
2848
  promptRestoreProjects(restorable, function (selected) {
2751
- forkDaemon(pin, keepAwake, selected, false);
2849
+ forkDaemon(mode, keepAwake, selected, false, wantOsUsers);
2752
2850
  });
2753
2851
  } else {
2754
2852
  log(sym.bar);
2755
2853
  log(sym.end + " " + a.dim + "Starting relay..." + a.reset);
2756
2854
  log("");
2757
- forkDaemon(pin, keepAwake, undefined, true);
2855
+ forkDaemon(mode, keepAwake, undefined, true, wantOsUsers);
2758
2856
  }
2759
2857
  });
2760
2858
  }
package/lib/os-users.js CHANGED
@@ -174,6 +174,52 @@ function linuxUserExists(username) {
174
174
  }
175
175
  }
176
176
 
177
+ /**
178
+ * Install Claude CLI for a Linux user account.
179
+ * Downloads and runs the install script, then ensures PATH is configured.
180
+ * Non-fatal: logs errors but does not throw.
181
+ */
182
+ function installClaudeCli(linuxName) {
183
+ try {
184
+ // Download and run the Claude CLI install script as the target user
185
+ execSync(
186
+ "su - " + linuxName + " -c \"curl -fsSL https://claude.ai/install.sh | bash\"",
187
+ { encoding: "utf8", timeout: 60000, stdio: "pipe" }
188
+ );
189
+ console.log("[os-users] Claude CLI installed for " + linuxName);
190
+ } catch (e) {
191
+ var msg = (e.stderr || e.message || "").trim();
192
+ console.error("[os-users] Failed to install Claude CLI for " + linuxName + ": " + msg);
193
+ return;
194
+ }
195
+
196
+ // Append PATH export to the user's shell config if not already present
197
+ try {
198
+ var home = "/home/" + linuxName;
199
+ var rcFile;
200
+ if (fs.existsSync(home + "/.zshrc")) {
201
+ rcFile = "~/.zshrc";
202
+ } else {
203
+ rcFile = "~/.bashrc";
204
+ }
205
+
206
+ // Check if PATH export is already present before appending
207
+ var checkCmd = "su - " + linuxName + " -c \"grep -qF '/.local/bin' " + rcFile + "\"";
208
+ try {
209
+ execSync(checkCmd, { encoding: "utf8", timeout: 5000, stdio: "pipe" });
210
+ console.log("[os-users] PATH already configured in " + rcFile + " for " + linuxName);
211
+ } catch (grepErr) {
212
+ // grep returned non-zero, meaning the line is not present; append it
213
+ var appendCmd = "su - " + linuxName + " -c 'echo \"export PATH=\\\"\\$HOME/.local/bin:\\$PATH\\\"\" >> " + rcFile + "'";
214
+ execSync(appendCmd, { encoding: "utf8", timeout: 5000, stdio: "pipe" });
215
+ console.log("[os-users] PATH export appended to " + rcFile + " for " + linuxName);
216
+ }
217
+ } catch (e) {
218
+ var rcMsg = (e.stderr || e.message || "").trim();
219
+ console.error("[os-users] Failed to configure PATH for " + linuxName + ": " + rcMsg);
220
+ }
221
+ }
222
+
177
223
  /**
178
224
  * Provision a Linux user account for a Clay user.
179
225
  * Creates the account via useradd with a home directory.
@@ -197,6 +243,7 @@ function provisionLinuxUser(clayUsername) {
197
243
  stdio: "pipe",
198
244
  });
199
245
  console.log("[os-users] Provisioned Linux user: " + linuxName + " (Clay user: " + clayUsername + ")");
246
+ installClaudeCli(linuxName);
200
247
  return { ok: true, linuxUser: linuxName };
201
248
  } catch (e) {
202
249
  var msg = (e.stderr || e.message || "").trim();
@@ -296,6 +343,7 @@ module.exports = {
296
343
  provisionLinuxUser: provisionLinuxUser,
297
344
  provisionAllUsers: provisionAllUsers,
298
345
  grantAllUsersAccess: grantAllUsersAccess,
346
+ installClaudeCli: installClaudeCli,
299
347
  deactivateLinuxUser: deactivateLinuxUser,
300
348
  ensureProjectsDir: ensureProjectsDir,
301
349
  };
package/lib/pages.js CHANGED
@@ -966,39 +966,33 @@ function multiUserLoginPageHtml() {
966
966
  '.catch(function(){errs[1].textContent="Connection error";btns[1].disabled=false})}' +
967
967
  'btns[1].onclick=doLogin;' +
968
968
 
969
- // Force PIN change overlay
969
+ // Force PIN change: reuse the same login screen, switch step1 to "Set new PIN" mode
970
970
  'function showChangePinOverlay(){' +
971
- 'var ov=document.createElement("div");ov.className="c";' +
972
- 'ov.style.cssText="position:fixed;inset:0;background:var(--bg,#0e0e10);z-index:9999;display:flex;align-items:center;justify-content:center";' +
973
- 'ov.innerHTML=\'<div style="width:100%;max-width:380px;padding:24px"><h1>Set your new PIN</h1>\'+' +
974
- '\'<div class="sub">Your temporary PIN has expired. Please set a new 6-digit PIN to continue.</div>\'+' +
975
- '\'<div id="new-pin-boxes" class="pin-boxes">\'+' +
976
- '\'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">\'+' +
977
- '\'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">\'+' +
978
- '\'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">\'+' +
979
- '\'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">\'+' +
980
- '\'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">\'+' +
981
- '\'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">\'+' +
982
- '\'</div><input type="hidden" id="new-pin">\'+' +
983
- '\'<button class="btn" id="save-new-pin" disabled style="margin-top:20px">Save PIN</button>\'+' +
984
- '\'<div class="err" id="new-pin-err"></div></div>\';' +
985
- 'document.body.appendChild(ov);' +
986
- 'var newPinEl=ov.querySelector("#new-pin");' +
987
- 'var saveBtn=ov.querySelector("#save-new-pin");' +
988
- 'var errEl=ov.querySelector("#new-pin-err");' +
989
- 'initPinBoxes("new-pin-boxes","new-pin",function(){if(!saveBtn.disabled)doSavePin()});' +
990
- 'var nb=ov.querySelectorAll(".pin-digit");' +
991
- 'for(var i=0;i<nb.length;i++)nb[i].addEventListener("input",function(){saveBtn.disabled=newPinEl.value.length!==6});' +
992
- 'function doSavePin(){' +
993
- 'saveBtn.disabled=true;errEl.textContent="";' +
971
+ // Hide step dots (no longer a 2-step flow)
972
+ 'var bar=document.querySelector(".steps-bar");if(bar)bar.style.display="none";' +
973
+ // Hide step 0 (username), show step 1 (PIN) with new labels
974
+ 'steps[0].classList.remove("active");' +
975
+ 'steps[1].classList.add("active");' +
976
+ 'var h1=steps[1].querySelector("h1");if(h1)h1.textContent="Set your new PIN";' +
977
+ 'var sub=steps[1].querySelector(".sub");if(sub)sub.textContent="Your temporary PIN has expired. Please set a new 6-digit PIN to continue.";' +
978
+ 'btns[1].textContent="Save PIN";' +
979
+ // Re-init PIN boxes for fresh input
980
+ 'resetPin();' +
981
+ 'initPinBoxes("pin-boxes","pin",function(){if(!btns[1].disabled)doSaveNewPin()});' +
982
+ 'var boxes=document.querySelectorAll(".pin-digit");' +
983
+ 'for(var i=0;i<boxes.length;i++)boxes[i].addEventListener("input",function(){btns[1].disabled=pinEl.value.length!==6});' +
984
+ // Override button handler to save new PIN instead of login
985
+ 'btns[1].onclick=doSaveNewPin;' +
986
+ 'function doSaveNewPin(){' +
987
+ 'btns[1].disabled=true;errs[1].textContent="";' +
994
988
  'fetch("/api/user/pin",{method:"PUT",headers:{"Content-Type":"application/json"},' +
995
- 'body:JSON.stringify({newPin:newPinEl.value})})' +
989
+ 'body:JSON.stringify({newPin:pinEl.value})})' +
996
990
  '.then(function(r){return r.json()})' +
997
991
  '.then(function(d){' +
998
992
  'if(d.ok){location.reload();return}' +
999
- 'errEl.textContent=d.error||"Failed to save PIN";saveBtn.disabled=false})' +
1000
- '.catch(function(){errEl.textContent="Connection error";saveBtn.disabled=false})}' +
1001
- 'saveBtn.onclick=doSavePin}' +
993
+ 'errs[1].textContent=d.error||"Failed to save PIN";btns[1].disabled=false})' +
994
+ '.catch(function(){errs[1].textContent="Connection error";btns[1].disabled=false})}' +
995
+ '}' +
1002
996
 
1003
997
  '</script></div></body></html>';
1004
998
  }
@@ -1472,7 +1472,7 @@ pre.mermaid-error {
1472
1472
  ========================================================================== */
1473
1473
 
1474
1474
  .auth-required-msg {
1475
- max-width: var(--content-width);
1475
+ max-width: 420px;
1476
1476
  margin: 12px auto;
1477
1477
  padding: 14px 18px;
1478
1478
  background: color-mix(in srgb, var(--warning) 8%, var(--bg));
@@ -24,6 +24,27 @@ function showConfirmDialog(message, onConfirm) {
24
24
  });
25
25
  }
26
26
 
27
+ function showConfirmResetPin(name, onConfirm) {
28
+ var modal = document.createElement("div");
29
+ modal.className = "admin-modal-overlay";
30
+ var html = '<div class="admin-modal">' +
31
+ '<div class="admin-modal-body" style="padding:20px 16px 16px">' +
32
+ '<p class="admin-modal-desc" style="margin:0;font-size:14px;color:var(--text)">Reset PIN for <strong>' + escapeHtml(name) + '</strong>? A new temporary PIN will be generated and they will need to change it on next login.</p>' +
33
+ '</div>' +
34
+ '<div class="admin-modal-footer">' +
35
+ '<button class="admin-modal-save">Reset PIN</button>' +
36
+ '<button class="admin-modal-cancel">Cancel</button>' +
37
+ '</div></div>';
38
+ modal.innerHTML = html;
39
+ document.body.appendChild(modal);
40
+ modal.querySelector(".admin-modal-cancel").addEventListener("click", function () { modal.remove(); });
41
+ modal.addEventListener("click", function (e) { if (e.target === modal) modal.remove(); });
42
+ modal.querySelector(".admin-modal-save").addEventListener("click", function () {
43
+ modal.remove();
44
+ onConfirm();
45
+ });
46
+ }
47
+
27
48
  var ctx = null;
28
49
  var cachedUsers = [];
29
50
  var cachedInvites = [];
@@ -111,8 +132,13 @@ function renderUsersTab(body) {
111
132
  html += '</div>';
112
133
  html += '<div class="admin-user-meta">' + escapeHtml(u.username) + ' · joined ' + created + '</div>';
113
134
  html += '</div>';
114
- if (!isMe && u.role !== "admin") {
115
- html += '<button class="admin-remove-btn" data-user-id="' + u.id + '" title="Remove user">' + iconHtml("trash-2") + '</button>';
135
+ if (!isMe) {
136
+ html += '<div style="display:flex;align-items:center;gap:2px">';
137
+ html += '<button class="admin-remove-btn admin-reset-pin-btn" data-user-id="' + u.id + '" title="Reset PIN">' + iconHtml("key-round") + '</button>';
138
+ if (u.role !== "admin") {
139
+ html += '<button class="admin-remove-btn" data-user-id="' + u.id + '" title="Remove user">' + iconHtml("trash-2") + '</button>';
140
+ }
141
+ html += '</div>';
116
142
  }
117
143
  html += '</div>';
118
144
  }
@@ -129,10 +155,32 @@ function renderUsersTab(body) {
129
155
  });
130
156
  }
131
157
 
158
+ // Bind reset PIN buttons
159
+ var resetBtns = body.querySelectorAll(".admin-reset-pin-btn");
160
+ for (var j = 0; j < resetBtns.length; j++) {
161
+ resetBtns[j].addEventListener("click", function (e) {
162
+ e.stopPropagation();
163
+ var userId = this.dataset.userId;
164
+ var user = cachedUsers.find(function (u) { return u.id === userId; });
165
+ var name = user ? (user.displayName || user.username) : "this user";
166
+ showConfirmResetPin(name, function () {
167
+ apiPost("/api/admin/users/" + userId + "/reset-pin").then(function (data) {
168
+ if (data.ok) {
169
+ showTempPinModal({ username: user.username, displayName: user.displayName || user.username }, data.tempPin);
170
+ } else {
171
+ showToast(data.error || "Failed to reset PIN");
172
+ }
173
+ }).catch(function () {
174
+ showToast("Failed to reset PIN");
175
+ });
176
+ });
177
+ });
178
+ }
179
+
132
180
  // Bind remove buttons
133
- var removeBtns = body.querySelectorAll(".admin-remove-btn");
134
- for (var j = 0; j < removeBtns.length; j++) {
135
- removeBtns[j].addEventListener("click", function () {
181
+ var removeBtns = body.querySelectorAll(".admin-remove-btn:not(.admin-reset-pin-btn)");
182
+ for (var k = 0; k < removeBtns.length; k++) {
183
+ removeBtns[k].addEventListener("click", function () {
136
184
  var userId = this.dataset.userId;
137
185
  var user = cachedUsers.find(function (u) { return u.id === userId; });
138
186
  var name = user ? (user.displayName || user.username) : "this user";
@@ -15,6 +15,106 @@ var resizeObserver = null;
15
15
  var toolbarBound = false;
16
16
  var termCtxMenu = null;
17
17
 
18
+ // --- Multi-line link provider ---
19
+ // xterm's WebLinksAddon only detects URLs on a single line.
20
+ // This provider reconstructs "logical lines" from wrapped buffer lines
21
+ // and detects URLs that span multiple rows.
22
+ function createMultiLineLinkProvider(xterm) {
23
+ var URL_RE = /https?:\/\/[^\s'"\]>)}{]+/g;
24
+
25
+ function getLogicalLine(buffer, y) {
26
+ // Walk backward to find the start of the logical line
27
+ var startY = y;
28
+ while (startY > 0) {
29
+ var line = buffer.getLine(startY);
30
+ if (!line || !line.isWrapped) break;
31
+ startY--;
32
+ }
33
+
34
+ // Walk forward to collect all wrapped continuation lines
35
+ var endY = startY;
36
+ var cols = xterm.cols;
37
+ while (endY < buffer.length - 1) {
38
+ var next = buffer.getLine(endY + 1);
39
+ if (!next || !next.isWrapped) break;
40
+ endY++;
41
+ }
42
+
43
+ // Build the full logical line text and track row boundaries
44
+ var text = "";
45
+ var rowOffsets = []; // { y, startOffset, length }
46
+ for (var row = startY; row <= endY; row++) {
47
+ var line = buffer.getLine(row);
48
+ if (!line) break;
49
+ var trimRight = (row === endY); // only trim trailing spaces on last row
50
+ var rowText = line.translateToString(trimRight);
51
+ rowOffsets.push({ y: row, startOffset: text.length, length: rowText.length });
52
+ text += rowText;
53
+ }
54
+
55
+ return { text: text, startY: startY, endY: endY, rowOffsets: rowOffsets };
56
+ }
57
+
58
+ function offsetToBufferPos(rowOffsets, offset) {
59
+ for (var i = 0; i < rowOffsets.length; i++) {
60
+ var ro = rowOffsets[i];
61
+ if (offset < ro.startOffset + ro.length) {
62
+ return { x: offset - ro.startOffset + 1, y: ro.y + 1 }; // 1-based
63
+ }
64
+ }
65
+ // Past end, clamp to last row
66
+ var last = rowOffsets[rowOffsets.length - 1];
67
+ return { x: last.length + 1, y: last.y + 1 };
68
+ }
69
+
70
+ return {
71
+ provideLinks: function (y, callback) {
72
+ var buffer = xterm.buffer.active;
73
+ // y is 1-based in provideLinks
74
+ var bufferY = y - 1;
75
+ var logical = getLogicalLine(buffer, bufferY);
76
+
77
+ // Only process if this logical line spans multiple rows
78
+ if (logical.startY === logical.endY) {
79
+ callback(undefined);
80
+ return;
81
+ }
82
+
83
+ // Only trigger on the first row of the logical line to avoid duplicates
84
+ if (bufferY !== logical.startY) {
85
+ callback(undefined);
86
+ return;
87
+ }
88
+
89
+ var links = [];
90
+ var match;
91
+ URL_RE.lastIndex = 0;
92
+ while ((match = URL_RE.exec(logical.text)) !== null) {
93
+ var urlStart = match.index;
94
+ var urlEnd = match.index + match[0].length - 1;
95
+
96
+ var startPos = offsetToBufferPos(logical.rowOffsets, urlStart);
97
+ var endPos = offsetToBufferPos(logical.rowOffsets, urlEnd);
98
+
99
+ // Only include if the URL actually spans multiple rows
100
+ if (startPos.y !== endPos.y) {
101
+ (function (url) {
102
+ links.push({
103
+ range: { start: startPos, end: endPos },
104
+ text: url,
105
+ activate: function () {
106
+ window.open(url, "_blank", "noopener");
107
+ },
108
+ });
109
+ })(match[0]);
110
+ }
111
+ }
112
+
113
+ callback(links.length > 0 ? links : undefined);
114
+ },
115
+ };
116
+ }
117
+
18
118
  // --- Init ---
19
119
  export function initTerminal(_ctx) {
20
120
  ctx = _ctx;
@@ -225,11 +325,14 @@ function createXtermForTab(tab) {
225
325
  xterm.loadAddon(fitAddon);
226
326
  }
227
327
 
228
- // Web links addon: make URLs clickable
328
+ // Web links addon: make URLs clickable (single-line)
229
329
  if (typeof WebLinksAddon !== "undefined") {
230
330
  xterm.loadAddon(new WebLinksAddon.WebLinksAddon());
231
331
  }
232
332
 
333
+ // Custom multi-line link provider: detect URLs that wrap across lines
334
+ xterm.registerLinkProvider(createMultiLineLinkProvider(xterm));
335
+
233
336
  // Create a container div for this tab's terminal
234
337
  var bodyEl = document.createElement("div");
235
338
  bodyEl.className = "terminal-tab-body";
package/lib/server.js CHANGED
@@ -1118,6 +1118,48 @@ function createServer(opts) {
1118
1118
  return;
1119
1119
  }
1120
1120
 
1121
+ // Reset user PIN (admin only) — generates a new temp PIN
1122
+ if (req.method === "POST" && fullUrl.match(/^\/api\/admin\/users\/[^/]+\/reset-pin$/)) {
1123
+ if (!users.isMultiUser()) {
1124
+ res.writeHead(404, { "Content-Type": "application/json" });
1125
+ res.end('{"error":"Not found"}');
1126
+ return;
1127
+ }
1128
+ var mu = getMultiUserFromReq(req);
1129
+ if (!mu || mu.role !== "admin") {
1130
+ res.writeHead(403, { "Content-Type": "application/json" });
1131
+ res.end('{"error":"Admin access required"}');
1132
+ return;
1133
+ }
1134
+ var urlParts = fullUrl.split("/");
1135
+ var targetUserId = urlParts[4]; // /api/admin/users/{userId}/reset-pin
1136
+ var targetUser = users.findUserById(targetUserId);
1137
+ if (!targetUser) {
1138
+ res.writeHead(404, { "Content-Type": "application/json" });
1139
+ res.end('{"error":"User not found"}');
1140
+ return;
1141
+ }
1142
+ var newPin = users.generatePin();
1143
+ var pinResult = users.updateUserPin(targetUserId, newPin);
1144
+ if (pinResult.error) {
1145
+ res.writeHead(400, { "Content-Type": "application/json" });
1146
+ res.end(JSON.stringify({ error: pinResult.error }));
1147
+ return;
1148
+ }
1149
+ // Mark as must change on next login
1150
+ var data = users.loadUsers();
1151
+ for (var i = 0; i < data.users.length; i++) {
1152
+ if (data.users[i].id === targetUserId) {
1153
+ data.users[i].mustChangePin = true;
1154
+ users.saveUsers(data);
1155
+ break;
1156
+ }
1157
+ }
1158
+ res.writeHead(200, { "Content-Type": "application/json" });
1159
+ res.end(JSON.stringify({ ok: true, tempPin: newPin }));
1160
+ return;
1161
+ }
1162
+
1121
1163
  // Set Linux user mapping (admin only, OS-level multi-user)
1122
1164
  if (req.method === "PUT" && fullUrl.match(/^\/api\/admin\/users\/[^/]+\/linux-user$/)) {
1123
1165
  if (!users.isMultiUser()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.11.0-beta.10",
3
+ "version": "2.11.0-beta.12",
4
4
  "description": "Web UI for Claude Code. Any device. Push notifications.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",