pairit 0.1.8 → 0.2.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.
Files changed (3) hide show
  1. package/README.md +29 -3
  2. package/dist/index.js +275 -78
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -12,6 +12,7 @@ A lightweight command line utility for working with Pairit experiment configurat
12
12
  - `media list` — list media objects in Cloud Storage
13
13
  - `media delete` — delete a media object from Cloud Storage
14
14
  - `data export` — export sessions, events, and chat messages for a config as CSV/JSON/JSONL
15
+ - `admin add-user` / `admin remove-user` / `admin list-users` — manage the manager allowlist (admin role required)
15
16
 
16
17
  ## Install
17
18
 
@@ -62,7 +63,7 @@ Add `--private` if you need to keep an object private. Use `--bucket <name>` onl
62
63
  ### Example
63
64
 
64
65
  ```bash
65
- # Point to local manager server (default is http://localhost:3002)
66
+ # Point at local manager (published CLI defaults to production)
66
67
  export PAIRIT_API_URL=http://localhost:3002
67
68
 
68
69
  bun run apps/manager/cli/src/index.ts config upload configs/simple-survey-basic.yaml --config-id simple-survey-basic
@@ -77,6 +78,24 @@ bun run apps/manager/cli/src/index.ts media delete onboarding/logo.png --force
77
78
 
78
79
  `config compile` writes `configs/simple-survey-basic.json` next to the source YAML. `config upload` defaults the config id to a 16-character base64url string derived from the SHA-256 hash of the compiled JSON (unless `--config-id` overrides it).
79
80
 
81
+ ## Manager allowlist (admin)
82
+
83
+ Manager sign-ins are gated on a Mongo allowlist. Anyone who isn't on it lands on `/access-denied` after Google sign-in. Use these commands to manage who can sign in. The calling CLI user must be an admin.
84
+
85
+ ```bash
86
+ pairit admin list-users # Show everyone on the allowlist
87
+ pairit admin add-user alice@example.com # Add as researcher
88
+ pairit admin add-user bob@example.com --admin # Add as admin (can manage the allowlist)
89
+ pairit admin remove-user alice@example.com # Remove and revoke their sessions
90
+ pairit admin remove-user alice@example.com --force # Skip confirmation prompt
91
+ ```
92
+
93
+ Notes:
94
+ - Bootstrap: `MANAGER_BOOTSTRAP_ADMIN_EMAIL` is seeded as admin on every manager boot. Set it before the first deploy or you'll have no admins.
95
+ - Day-1 migration: on first boot, existing config owners are backfilled as researchers (not the entire user collection, which is shared with lab participants).
96
+ - Self-removal is blocked.
97
+ - The rejection page contact is set by `MANAGER_ADMIN_CONTACT_EMAIL` (defaults to `harang@pairium.ai`).
98
+
80
99
  ## Per-experiment LLM credentials
81
100
 
82
101
  If your config uses AI agents, attach provider credentials when uploading:
@@ -99,9 +118,16 @@ Notes:
99
118
  - `{configId}-events.{format}` — session ID, event type, component info, payload, timestamps
100
119
  - `{configId}-chat-messages.{format}` — group ID, session ID, sender, content, timestamps
101
120
 
102
- All hosted commands require the manager service to be reachable (Cloud Run deployment or local server). By default the CLI talks to the deployed manager. Override via shell environment variables when needed:
121
+ All hosted commands require the manager service to be reachable. By default the CLI talks to production (`https://pairit-api.pairium.ai`). Add `--env staging` to use staging; each environment keeps its own saved login.
122
+
123
+ ```bash
124
+ pairit --env staging login
125
+ ```
126
+
127
+ Override via shell environment variables when needed:
103
128
 
104
- - `PAIRIT_API_URL` — manager base URL (e.g. `http://localhost:3002` for local dev)
129
+ - `PAIRIT_ENV` — `production` (default) or `staging`, same as `--env`
130
+ - `PAIRIT_API_URL` — manager base URL. Use `http://localhost:3002` for local dev.
105
131
  - `PAIRIT_LAB_URL` — lab base URL used to print the survey link after `config upload`
106
132
  - `PAIRIT_CREDENTIALS_BACKEND` — `keychain` (default when available) or `file`
107
133
  - `PAIRIT_MAX_INLINE_MEDIA_BYTES` — switch to signed-URL upload above this size (default 5 MiB)
package/dist/index.js CHANGED
@@ -8866,11 +8866,11 @@ var init_is_inside_container = __esm(() => {
8866
8866
  });
8867
8867
 
8868
8868
  // ../../../node_modules/.bun/is-wsl@3.1.0/node_modules/is-wsl/index.js
8869
- import process2 from "node:process";
8869
+ import process3 from "node:process";
8870
8870
  import os from "node:os";
8871
8871
  import fs3 from "node:fs";
8872
8872
  var isWsl = () => {
8873
- if (process2.platform !== "linux") {
8873
+ if (process3.platform !== "linux") {
8874
8874
  return false;
8875
8875
  }
8876
8876
  if (os.release().toLowerCase().includes("microsoft")) {
@@ -8887,15 +8887,15 @@ var isWsl = () => {
8887
8887
  }, is_wsl_default;
8888
8888
  var init_is_wsl = __esm(() => {
8889
8889
  init_is_inside_container();
8890
- is_wsl_default = process2.env.__IS_WSL_TEST__ ? isWsl : isWsl();
8890
+ is_wsl_default = process3.env.__IS_WSL_TEST__ ? isWsl : isWsl();
8891
8891
  });
8892
8892
 
8893
8893
  // ../../../node_modules/.bun/powershell-utils@0.1.0/node_modules/powershell-utils/index.js
8894
- import process3 from "node:process";
8894
+ import process4 from "node:process";
8895
8895
  import { Buffer as Buffer2 } from "node:buffer";
8896
8896
  import { promisify } from "node:util";
8897
8897
  import childProcess from "node:child_process";
8898
- var execFile, powerShellPath = () => `${process3.env.SYSTEMROOT || process3.env.windir || String.raw`C:\Windows`}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`, executePowerShell = async (command, options = {}) => {
8898
+ var execFile, powerShellPath = () => `${process4.env.SYSTEMROOT || process4.env.windir || String.raw`C:\Windows`}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`, executePowerShell = async (command, options = {}) => {
8899
8899
  const {
8900
8900
  powerShellPath: psPath,
8901
8901
  ...execFileOptions
@@ -9010,10 +9010,10 @@ function defineLazyProperty(object, propertyName, valueGetter) {
9010
9010
 
9011
9011
  // ../../../node_modules/.bun/default-browser-id@5.0.1/node_modules/default-browser-id/index.js
9012
9012
  import { promisify as promisify3 } from "node:util";
9013
- import process4 from "node:process";
9013
+ import process5 from "node:process";
9014
9014
  import { execFile as execFile3 } from "node:child_process";
9015
9015
  async function defaultBrowserId() {
9016
- if (process4.platform !== "darwin") {
9016
+ if (process5.platform !== "darwin") {
9017
9017
  throw new Error("macOS only");
9018
9018
  }
9019
9019
  const { stdout } = await execFileAsync("defaults", ["read", "com.apple.LaunchServices/com.apple.launchservices.secure", "LSHandlers"]);
@@ -9030,11 +9030,11 @@ var init_default_browser_id = __esm(() => {
9030
9030
  });
9031
9031
 
9032
9032
  // ../../../node_modules/.bun/run-applescript@7.1.0/node_modules/run-applescript/index.js
9033
- import process5 from "node:process";
9033
+ import process6 from "node:process";
9034
9034
  import { promisify as promisify4 } from "node:util";
9035
9035
  import { execFile as execFile4, execFileSync } from "node:child_process";
9036
9036
  async function runAppleScript(script, { humanReadableOutput = true, signal } = {}) {
9037
- if (process5.platform !== "darwin") {
9037
+ if (process6.platform !== "darwin") {
9038
9038
  throw new Error("macOS only");
9039
9039
  }
9040
9040
  const outputArguments = humanReadableOutput ? [] : ["-ss"];
@@ -9108,21 +9108,21 @@ var init_windows = __esm(() => {
9108
9108
 
9109
9109
  // ../../../node_modules/.bun/default-browser@5.4.0/node_modules/default-browser/index.js
9110
9110
  import { promisify as promisify6 } from "node:util";
9111
- import process6 from "node:process";
9111
+ import process7 from "node:process";
9112
9112
  import { execFile as execFile6 } from "node:child_process";
9113
9113
  async function defaultBrowser2() {
9114
- if (process6.platform === "darwin") {
9114
+ if (process7.platform === "darwin") {
9115
9115
  const id = await defaultBrowserId();
9116
9116
  const name = await bundleName(id);
9117
9117
  return { name, id };
9118
9118
  }
9119
- if (process6.platform === "linux") {
9119
+ if (process7.platform === "linux") {
9120
9120
  const { stdout } = await execFileAsync4("xdg-mime", ["query", "default", "x-scheme-handler/http"]);
9121
9121
  const id = stdout.trim();
9122
9122
  const name = titleize(id.replace(/.desktop$/, "").replace("-", " "));
9123
9123
  return { name, id };
9124
9124
  }
9125
- if (process6.platform === "win32") {
9125
+ if (process7.platform === "win32") {
9126
9126
  return defaultBrowser();
9127
9127
  }
9128
9128
  throw new Error("Only macOS, Linux, and Windows are supported");
@@ -9137,10 +9137,10 @@ var init_default_browser = __esm(() => {
9137
9137
  });
9138
9138
 
9139
9139
  // ../../../node_modules/.bun/is-in-ssh@1.0.0/node_modules/is-in-ssh/index.js
9140
- import process7 from "node:process";
9140
+ import process8 from "node:process";
9141
9141
  var isInSsh, is_in_ssh_default;
9142
9142
  var init_is_in_ssh = __esm(() => {
9143
- isInSsh = Boolean(process7.env.SSH_CONNECTION || process7.env.SSH_CLIENT || process7.env.SSH_TTY);
9143
+ isInSsh = Boolean(process8.env.SSH_CONNECTION || process8.env.SSH_CLIENT || process8.env.SSH_TTY);
9144
9144
  is_in_ssh_default = isInSsh;
9145
9145
  });
9146
9146
 
@@ -9151,7 +9151,7 @@ __export(exports_open, {
9151
9151
  default: () => open_default,
9152
9152
  apps: () => apps
9153
9153
  });
9154
- import process8 from "node:process";
9154
+ import process9 from "node:process";
9155
9155
  import path from "node:path";
9156
9156
  import { fileURLToPath } from "node:url";
9157
9157
  import childProcess3 from "node:child_process";
@@ -9322,7 +9322,7 @@ var fallbackAttemptSymbol, __dirname2, localXdgOpenPath, platform, arch, tryEach
9322
9322
  await fs5.access(localXdgOpenPath, fsConstants2.X_OK);
9323
9323
  exeLocalXdgOpen = true;
9324
9324
  } catch {}
9325
- const useSystemXdgOpen = process8.versions.electron ?? (platform === "android" || isBundled || !exeLocalXdgOpen);
9325
+ const useSystemXdgOpen = process9.versions.electron ?? (platform === "android" || isBundled || !exeLocalXdgOpen);
9326
9326
  command = useSystemXdgOpen ? "xdg-open" : localXdgOpenPath;
9327
9327
  }
9328
9328
  if (appArguments.length > 0) {
@@ -9409,7 +9409,7 @@ var init_open = __esm(() => {
9409
9409
  fallbackAttemptSymbol = Symbol("fallbackAttempt");
9410
9410
  __dirname2 = import.meta.url ? path.dirname(fileURLToPath(import.meta.url)) : "";
9411
9411
  localXdgOpenPath = path.join(__dirname2, "xdg-open");
9412
- ({ platform, arch } = process8);
9412
+ ({ platform, arch } = process9);
9413
9413
  apps = {
9414
9414
  browser: "browser",
9415
9415
  browserPrivate: "browserPrivate"
@@ -9456,9 +9456,9 @@ var init_open = __esm(() => {
9456
9456
 
9457
9457
  // src/index.ts
9458
9458
  import { createHash } from "node:crypto";
9459
- import { mkdir as mkdir2, readFile as readFile2, stat, writeFile as writeFile2 } from "node:fs/promises";
9460
- import path2 from "node:path";
9461
- import process9 from "node:process";
9459
+ import { mkdir as mkdir2, readFile as readFile3, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
9460
+ import path3 from "node:path";
9461
+ import process11 from "node:process";
9462
9462
  import { fileURLToPath as fileURLToPath2 } from "node:url";
9463
9463
 
9464
9464
  // ../../../node_modules/.bun/commander@12.1.0/node_modules/commander/esm.mjs
@@ -9484,15 +9484,58 @@ var import_yaml = __toESM(require_dist(), 1);
9484
9484
  import { mkdir, readFile, writeFile } from "node:fs/promises";
9485
9485
  import { homedir } from "node:os";
9486
9486
  import { join } from "node:path";
9487
+
9488
+ // src/env.ts
9489
+ import process2 from "node:process";
9490
+ var ENVIRONMENTS = {
9491
+ production: {
9492
+ api: "https://pairit-api.pairium.ai",
9493
+ lab: "https://pairit.pairium.ai"
9494
+ },
9495
+ staging: {
9496
+ api: "https://pairit-api-staging.pairium.ai",
9497
+ lab: "https://pairit-staging.pairium.ai"
9498
+ }
9499
+ };
9500
+ var selected;
9501
+ function parseEnvName(value) {
9502
+ if (value === "production" || value === "staging")
9503
+ return value;
9504
+ throw new Error(`Unknown environment "${value}". Use staging or production.`);
9505
+ }
9506
+ function setEnv(name) {
9507
+ selected = name;
9508
+ }
9509
+ function getEnvName() {
9510
+ if (selected)
9511
+ return selected;
9512
+ const fromEnv = process2.env.PAIRIT_ENV;
9513
+ return fromEnv ? parseEnvName(fromEnv) : "production";
9514
+ }
9515
+ function getApiUrl() {
9516
+ return process2.env.PAIRIT_API_URL || ENVIRONMENTS[getEnvName()].api;
9517
+ }
9518
+ function getLabUrl() {
9519
+ return process2.env.PAIRIT_LAB_URL || ENVIRONMENTS[getEnvName()].lab;
9520
+ }
9521
+ function getCredentialKey() {
9522
+ const api = getApiUrl();
9523
+ if (api === ENVIRONMENTS.production.api)
9524
+ return "default";
9525
+ return new URL(api).host;
9526
+ }
9527
+
9528
+ // src/auth.ts
9487
9529
  var CONFIG_DIR = join(homedir(), ".pairit");
9488
- var CREDENTIALS_FILE = join(CONFIG_DIR, "credentials.json");
9530
+ function getCredentialsFile() {
9531
+ const key = getCredentialKey();
9532
+ return join(CONFIG_DIR, key === "default" ? "credentials.json" : `credentials-${key.replace(/[^a-zA-Z0-9.-]/g, "_")}.json`);
9533
+ }
9489
9534
  var CREDENTIALS_BACKEND = process.env.PAIRIT_CREDENTIALS_BACKEND || "keychain";
9490
9535
  var KEYCHAIN_SERVICE = "pairit-cli";
9491
- var KEYCHAIN_ACCOUNT = "default";
9492
9536
  var KEYTAR_MODULE_SPECIFIER = "keytar";
9493
- var BASE_URL = process.env.PAIRIT_API_URL || "https://manager-432501290611.us-central1.run.app";
9494
9537
  async function exchangeCodeForToken(code) {
9495
- const response = await fetch(`${BASE_URL}/api/cli/exchange`, {
9538
+ const response = await fetch(`${getApiUrl()}/api/cli/exchange`, {
9496
9539
  method: "POST",
9497
9540
  headers: { "Content-Type": "application/json" },
9498
9541
  body: JSON.stringify({ code })
@@ -9508,8 +9551,8 @@ async function exchangeCodeForToken(code) {
9508
9551
  return data.token;
9509
9552
  }
9510
9553
  async function login() {
9511
- console.log("Initiating login...");
9512
- const loginUrl = `${BASE_URL}/login`;
9554
+ console.log(`Initiating login to ${getApiUrl()}...`);
9555
+ const loginUrl = `${getApiUrl()}/login`;
9513
9556
  const manualLoginUrl = `${loginUrl}?manual=1`;
9514
9557
  if (canAttemptBrowserLogin()) {
9515
9558
  try {
@@ -9599,12 +9642,12 @@ async function saveCredentials(creds) {
9599
9642
  if (CREDENTIALS_BACKEND !== "file") {
9600
9643
  const keytar = await loadKeytar();
9601
9644
  if (keytar) {
9602
- await keytar.setPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, JSON.stringify(creds));
9645
+ await keytar.setPassword(KEYCHAIN_SERVICE, getCredentialKey(), JSON.stringify(creds));
9603
9646
  return;
9604
9647
  }
9605
9648
  }
9606
9649
  await mkdir(CONFIG_DIR, { recursive: true });
9607
- await writeFile(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), {
9650
+ await writeFile(getCredentialsFile(), JSON.stringify(creds, null, 2), {
9608
9651
  mode: 384
9609
9652
  });
9610
9653
  }
@@ -9612,7 +9655,7 @@ async function getCredentials() {
9612
9655
  if (CREDENTIALS_BACKEND !== "file") {
9613
9656
  const keytar = await loadKeytar();
9614
9657
  if (keytar) {
9615
- const stored = await keytar.getPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
9658
+ const stored = await keytar.getPassword(KEYCHAIN_SERVICE, getCredentialKey());
9616
9659
  if (stored) {
9617
9660
  try {
9618
9661
  return JSON.parse(stored);
@@ -9623,7 +9666,7 @@ async function getCredentials() {
9623
9666
  }
9624
9667
  }
9625
9668
  try {
9626
- const data = await readFile(CREDENTIALS_FILE, "utf8");
9669
+ const data = await readFile(getCredentialsFile(), "utf8");
9627
9670
  return JSON.parse(data);
9628
9671
  } catch {
9629
9672
  return null;
@@ -9664,9 +9707,115 @@ async function loadKeytar() {
9664
9707
  }
9665
9708
  }
9666
9709
 
9710
+ // src/html-assets.ts
9711
+ import { readFile as readFile2, stat } from "node:fs/promises";
9712
+ import path2 from "node:path";
9713
+ import process10 from "node:process";
9714
+ var HTML_MAX_BYTES = 1024 * 1024;
9715
+ function isRecord(value) {
9716
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9717
+ }
9718
+ function formatBytes(bytes) {
9719
+ if (bytes < 1024)
9720
+ return `${bytes} B`;
9721
+ if (bytes < 1024 * 1024)
9722
+ return `${(bytes / 1024).toFixed(1)} KB`;
9723
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
9724
+ }
9725
+ function listHtmlComponents(config) {
9726
+ const pages = config.pages;
9727
+ const pageList = Array.isArray(pages) ? pages.filter(isRecord) : isRecord(pages) ? Object.values(pages).filter(isRecord) : [];
9728
+ const result = [];
9729
+ for (const page of pageList) {
9730
+ const components = page.components;
9731
+ if (!Array.isArray(components))
9732
+ continue;
9733
+ for (const comp of components) {
9734
+ if (!isRecord(comp) || comp.type !== "html")
9735
+ continue;
9736
+ const props = isRecord(comp.props) ? comp.props : {};
9737
+ const id = typeof comp.id === "string" ? comp.id : "html";
9738
+ result.push({ id, props });
9739
+ }
9740
+ }
9741
+ return result;
9742
+ }
9743
+ async function resolveHtmlSource(src, componentId, configDir) {
9744
+ if (typeof src !== "string" || src.trim() === "") {
9745
+ throw new Error(`html ${componentId}: missing src`);
9746
+ }
9747
+ const trimmed = src.trim();
9748
+ if (/^https?:\/\//i.test(trimmed)) {
9749
+ throw new Error(`html ${componentId}: remote URLs are not allowed`);
9750
+ }
9751
+ if (path2.extname(trimmed).toLowerCase() !== ".html") {
9752
+ throw new Error(`html ${componentId}: src must be a .html file`);
9753
+ }
9754
+ const absolutePath = path2.resolve(configDir, trimmed);
9755
+ const rel = path2.relative(configDir, absolutePath);
9756
+ if (rel.startsWith("..") || path2.isAbsolute(rel)) {
9757
+ throw new Error(`html ${componentId}: src must stay inside the config directory`);
9758
+ }
9759
+ let info;
9760
+ try {
9761
+ info = await stat(absolutePath);
9762
+ } catch {
9763
+ throw new Error(`html ${componentId}: file not found: ${trimmed}`);
9764
+ }
9765
+ if (!info.isFile()) {
9766
+ throw new Error(`html ${componentId}: file not found: ${trimmed}`);
9767
+ }
9768
+ if (info.size > HTML_MAX_BYTES) {
9769
+ throw new Error(`html ${componentId}: file too large (${formatBytes(info.size)}); max is 1 MB`);
9770
+ }
9771
+ return { src: trimmed, absolutePath, bytes: info.size };
9772
+ }
9773
+ async function resolveConfigDir(configPath) {
9774
+ const absolute = path2.isAbsolute(configPath) ? configPath : path2.resolve(process10.cwd(), configPath);
9775
+ return path2.dirname(absolute);
9776
+ }
9777
+ async function inspectHtmlSources(configPath, config) {
9778
+ const configDir = await resolveConfigDir(configPath);
9779
+ const errors = [];
9780
+ const results = [];
9781
+ for (const comp of listHtmlComponents(config)) {
9782
+ try {
9783
+ const resolved = await resolveHtmlSource(comp.props.src, comp.id, configDir);
9784
+ results.push({ componentId: comp.id, ...resolved });
9785
+ } catch (error) {
9786
+ errors.push(error instanceof Error ? error.message : String(error));
9787
+ }
9788
+ }
9789
+ if (errors.length) {
9790
+ throw new Error(errors.join(`
9791
+ `));
9792
+ }
9793
+ return results;
9794
+ }
9795
+ async function attachHtmlToCompiledConfig(configPath, compiled) {
9796
+ const configDir = await resolveConfigDir(configPath);
9797
+ const sources = [];
9798
+ for (const comp of listHtmlComponents(compiled)) {
9799
+ const resolved = await resolveHtmlSource(comp.props.src, comp.id, configDir);
9800
+ comp.props.html = await readFile2(resolved.absolutePath, "utf8");
9801
+ sources.push({ componentId: comp.id, ...resolved });
9802
+ }
9803
+ return sources;
9804
+ }
9805
+
9667
9806
  // src/index.ts
9668
9807
  var program2 = new Command;
9669
- program2.name("pairit").description("CLI for Pairit experiment configs").version("0.1.8");
9808
+ program2.name("pairit").description("CLI for Pairit experiment configs").version("0.2.0").option("--env <name>", "Target environment: staging or production", (value) => {
9809
+ try {
9810
+ return parseEnvName(value);
9811
+ } catch (error) {
9812
+ throw new InvalidArgumentError(error.message);
9813
+ }
9814
+ }).hook("preAction", (command) => {
9815
+ const name = command.optsWithGlobals().env;
9816
+ if (name)
9817
+ setEnv(name);
9818
+ });
9670
9819
  program2.command("login").description("Login to Pairit Manager").action(async () => {
9671
9820
  try {
9672
9821
  await login();
@@ -9677,15 +9826,18 @@ program2.command("login").description("Login to Pairit Manager").action(async ()
9677
9826
  var configCommand = program2.command("config").description("Manage experiment configs");
9678
9827
  configCommand.command("lint").argument("<config>", "Path to YAML config").description("Run minimal validation on a config file").action(async (configPath) => {
9679
9828
  try {
9680
- await lintConfig(configPath);
9829
+ const htmlFiles = await lintConfig(configPath);
9681
9830
  console.log(`✓ ${configPath} passed lint checks`);
9831
+ for (const file of htmlFiles) {
9832
+ console.log(` html ${file.componentId} ← ${file.src} (${formatBytes(file.bytes)})`);
9833
+ }
9682
9834
  } catch (error) {
9683
9835
  if (error instanceof Error) {
9684
9836
  console.error(`Lint failed: ${error.message}`);
9685
9837
  } else {
9686
9838
  console.error("Lint failed with unknown error");
9687
9839
  }
9688
- process9.exitCode = 1;
9840
+ process11.exitCode = 1;
9689
9841
  }
9690
9842
  });
9691
9843
  configCommand.command("compile").argument("<config>", "Path to YAML config").description("Compile YAML config to canonical JSON next to source").action(async (configPath) => {
@@ -9698,17 +9850,20 @@ configCommand.command("compile").argument("<config>", "Path to YAML config").des
9698
9850
  } else {
9699
9851
  console.error("Compile failed with unknown error");
9700
9852
  }
9701
- process9.exitCode = 1;
9853
+ process11.exitCode = 1;
9702
9854
  }
9703
9855
  });
9704
9856
  configCommand.command("upload").argument("<config>", "Path to YAML config").option("--config-id <configId>", "Config id (defaults to hash)").option("--metadata <json>", "Optional metadata JSON string").option("--openai-api-key <key>", "OpenAI API key for this experiment").option("--anthropic-api-key <key>", "Anthropic API key for this experiment").description("Compile and upload config via Pairit Functions").action(async (configPath, options) => {
9705
9857
  try {
9706
- const { payload, checksum } = await buildUploadPayload(configPath, options);
9858
+ const { payload, checksum, attachments } = await buildUploadPayload(configPath, options);
9707
9859
  const response = await callFunctions("/configs/upload", {
9708
9860
  method: "POST",
9709
9861
  body: JSON.stringify(payload),
9710
9862
  headers: { "Content-Type": "application/json" }
9711
9863
  });
9864
+ for (const file of attachments) {
9865
+ console.log(`✓ Attached ${path3.basename(file.src)} (${formatBytes(file.bytes)})`);
9866
+ }
9712
9867
  console.log(`✓ Uploaded ${payload.configId} (${checksum})`);
9713
9868
  const labUrl = getLabUrl();
9714
9869
  console.log(`Survey Link: ${labUrl}/${payload.configId}`);
@@ -9817,9 +9972,60 @@ dataCommand.command("export").argument("<configId>", "Config ID to export data f
9817
9972
  reportCliError("Export failed", error);
9818
9973
  }
9819
9974
  });
9820
- program2.parseAsync(process9.argv).catch((err) => {
9975
+ var adminCommand = program2.command("admin").description("Manage manager allowlist (admin only)");
9976
+ adminCommand.command("add-user").argument("<email>", "Email to allow").option("--admin", "Grant admin privileges").description("Add a user to the manager allowlist").action(async (email, options) => {
9977
+ try {
9978
+ const response = await callFunctions("/admin/users", {
9979
+ method: "POST",
9980
+ headers: { "Content-Type": "application/json" },
9981
+ body: JSON.stringify({ email, isAdmin: Boolean(options.admin) })
9982
+ });
9983
+ const entry = response?.user;
9984
+ if (!entry)
9985
+ throw new Error("No user returned");
9986
+ console.log(`✓ Added ${entry.email} (${entry.isAdmin ? "admin" : "researcher"})`);
9987
+ } catch (error) {
9988
+ reportCliError("Add user failed", error);
9989
+ }
9990
+ });
9991
+ adminCommand.command("remove-user").argument("<email>", "Email to remove").option("--force", "Skip confirmation").description("Remove a user from the manager allowlist and revoke sessions").action(async (email, options) => {
9992
+ try {
9993
+ if (!options.force) {
9994
+ const confirmed = await promptConfirm(`Remove ${email} from allowlist? (y/N)`);
9995
+ if (!confirmed) {
9996
+ console.log("Removal aborted");
9997
+ return;
9998
+ }
9999
+ }
10000
+ await callFunctions(`/admin/users/${encodeURIComponent(email)}`, {
10001
+ method: "DELETE"
10002
+ });
10003
+ console.log(`✓ Removed ${email}`);
10004
+ } catch (error) {
10005
+ reportCliError("Remove user failed", error);
10006
+ }
10007
+ });
10008
+ adminCommand.command("list-users").description("List users on the manager allowlist").action(async () => {
10009
+ try {
10010
+ const response = await callFunctions("/admin/users", {
10011
+ method: "GET"
10012
+ });
10013
+ const users = response?.users ?? [];
10014
+ if (!users.length) {
10015
+ console.log("No users on the allowlist");
10016
+ return;
10017
+ }
10018
+ users.forEach((u) => {
10019
+ const role = u.isAdmin ? "admin" : "researcher";
10020
+ console.log(`${u.email} | ${role} | addedBy=${u.addedBy} | addedAt=${u.addedAt}`);
10021
+ });
10022
+ } catch (error) {
10023
+ reportCliError("List users failed", error);
10024
+ }
10025
+ });
10026
+ program2.parseAsync(process11.argv).catch((err) => {
9821
10027
  console.error(err);
9822
- process9.exit(1);
10028
+ process11.exit(1);
9823
10029
  });
9824
10030
  async function lintConfig(configPath) {
9825
10031
  const config = await loadConfig(configPath);
@@ -9843,6 +10049,7 @@ async function lintConfig(configPath) {
9843
10049
  if (errors.length) {
9844
10050
  throw new Error(errors.join(", "));
9845
10051
  }
10052
+ return inspectHtmlSources(configPath, config);
9846
10053
  }
9847
10054
  async function compileConfig(configPath) {
9848
10055
  const config = await loadConfig(configPath);
@@ -9894,7 +10101,7 @@ async function compileConfig(configPath) {
9894
10101
  output.matchmaking = config.matchmaking;
9895
10102
  }
9896
10103
  const resolvedPath = await resolvePath(configPath);
9897
- const outPath = path2.join(path2.dirname(resolvedPath), `${path2.basename(resolvedPath, path2.extname(resolvedPath))}.json`);
10104
+ const outPath = path3.join(path3.dirname(resolvedPath), `${path3.basename(resolvedPath, path3.extname(resolvedPath))}.json`);
9898
10105
  await writeFile2(outPath, JSON.stringify(output, null, 2), "utf8");
9899
10106
  return outPath;
9900
10107
  }
@@ -9903,14 +10110,15 @@ async function buildUploadPayload(configPath, options) {
9903
10110
  const allowRetake = sourceConfig.allowRetake === true;
9904
10111
  const requireAuth = sourceConfig.requireAuth;
9905
10112
  const compiledPath = await compileConfig(configPath);
9906
- const compiledContent = await readFile2(compiledPath, "utf8");
9907
- const hashBuffer = createHash("sha256").update(compiledContent).digest();
9908
- const checksum = hashBuffer.toString("hex");
10113
+ const compiledContent = await readFile3(compiledPath, "utf8");
9909
10114
  const parsed = JSON.parse(compiledContent);
10115
+ const attachments = await attachHtmlToCompiledConfig(configPath, parsed);
10116
+ const hashBuffer = createHash("sha256").update(JSON.stringify(parsed)).digest();
10117
+ const checksum = hashBuffer.toString("hex");
9910
10118
  const configId = options.configId ?? toBase64Url(hashBuffer.subarray(0, 12));
9911
10119
  const metadata = options.metadata ? JSON.parse(options.metadata) : {};
9912
10120
  if (!metadata.originalFilename) {
9913
- metadata.originalFilename = path2.basename(configPath);
10121
+ metadata.originalFilename = path3.basename(configPath);
9914
10122
  }
9915
10123
  const llmCredentials = {
9916
10124
  ...options.openaiApiKey && { openaiApiKey: options.openaiApiKey },
@@ -9928,15 +10136,16 @@ async function buildUploadPayload(configPath, options) {
9928
10136
  allowRetake,
9929
10137
  ...requireAuth !== undefined && { requireAuth }
9930
10138
  },
9931
- checksum
10139
+ checksum,
10140
+ attachments
9932
10141
  };
9933
10142
  }
9934
10143
  async function uploadMedia(filePath, options) {
9935
10144
  const resolved = await resolvePath(filePath);
9936
- const fileBuffer = await readFile2(resolved);
10145
+ const fileBuffer = await readFile3(resolved);
9937
10146
  const hashBuffer = createHash("sha256").update(fileBuffer).digest();
9938
10147
  const checksum = hashBuffer.toString("hex");
9939
- const defaultObject = `${toBase64Url(hashBuffer.subarray(0, 12))}${path2.extname(resolved)}`;
10148
+ const defaultObject = `${toBase64Url(hashBuffer.subarray(0, 12))}${path3.extname(resolved)}`;
9940
10149
  const object = options.object ?? defaultObject;
9941
10150
  const shouldBePublic = typeof options.public === "boolean" ? options.public : true;
9942
10151
  const maxInlineBytes = getInlineMediaLimit();
@@ -10005,7 +10214,7 @@ function toBase64Url(buffer) {
10005
10214
  }
10006
10215
  async function loadConfig(configPath) {
10007
10216
  const resolvedPath = await resolvePath(configPath);
10008
- const content = await readFile2(resolvedPath, "utf8");
10217
+ const content = await readFile3(resolvedPath, "utf8");
10009
10218
  const parsed = import_yaml.default.parse(content);
10010
10219
  if (!parsed || typeof parsed !== "object") {
10011
10220
  throw new Error("config must be an object");
@@ -10013,9 +10222,9 @@ async function loadConfig(configPath) {
10013
10222
  return parsed;
10014
10223
  }
10015
10224
  async function resolvePath(configPath) {
10016
- const cwd = process9.cwd();
10017
- const absolute = path2.isAbsolute(configPath) ? configPath : path2.resolve(cwd, configPath);
10018
- await stat(absolute);
10225
+ const cwd = process11.cwd();
10226
+ const absolute = path3.isAbsolute(configPath) ? configPath : path3.resolve(cwd, configPath);
10227
+ await stat2(absolute);
10019
10228
  return absolute;
10020
10229
  }
10021
10230
  async function fetchAllPages(pathname, extract) {
@@ -10032,7 +10241,7 @@ async function fetchAllPages(pathname, extract) {
10032
10241
  return all;
10033
10242
  }
10034
10243
  async function callFunctions(pathname, init = {}) {
10035
- const baseUrl = getFunctionsBaseUrl();
10244
+ const baseUrl = getApiUrl();
10036
10245
  const url = new URL(pathname, baseUrl).toString();
10037
10246
  const authHeaders = await getAuthHeaders();
10038
10247
  const headers = {
@@ -10053,33 +10262,21 @@ async function callFunctions(pathname, init = {}) {
10053
10262
  throw new Error(`Invalid JSON response: ${text}`);
10054
10263
  }
10055
10264
  }
10056
- function getFunctionsBaseUrl() {
10057
- if (process9.env.PAIRIT_API_URL) {
10058
- return process9.env.PAIRIT_API_URL;
10059
- }
10060
- return "https://manager-432501290611.us-central1.run.app";
10061
- }
10062
- function getLabUrl() {
10063
- if (process9.env.PAIRIT_LAB_URL) {
10064
- return process9.env.PAIRIT_LAB_URL;
10065
- }
10066
- return "https://lab-432501290611.us-central1.run.app";
10067
- }
10068
10265
  function getInlineMediaLimit() {
10069
- const fromEnv = Number(process9.env.PAIRIT_MAX_INLINE_MEDIA_BYTES);
10266
+ const fromEnv = Number(process11.env.PAIRIT_MAX_INLINE_MEDIA_BYTES);
10070
10267
  if (Number.isFinite(fromEnv) && fromEnv > 0)
10071
10268
  return fromEnv;
10072
10269
  return 5 * 1024 * 1024;
10073
10270
  }
10074
10271
  async function promptConfirm(prompt2) {
10075
- process9.stdout.write(`${prompt2} `);
10272
+ process11.stdout.write(`${prompt2} `);
10076
10273
  return new Promise((resolve) => {
10077
- process9.stdin.setEncoding("utf8");
10078
- process9.stdin.resume();
10079
- process9.stdin.once("data", (data) => {
10274
+ process11.stdin.setEncoding("utf8");
10275
+ process11.stdin.resume();
10276
+ process11.stdin.once("data", (data) => {
10080
10277
  const input = data.toString().trim().toLowerCase();
10081
10278
  resolve(input === "y" || input === "yes");
10082
- process9.stdin.pause();
10279
+ process11.stdin.pause();
10083
10280
  });
10084
10281
  });
10085
10282
  }
@@ -10089,20 +10286,20 @@ function reportCliError(prefix, error) {
10089
10286
  } else {
10090
10287
  console.error(`${prefix}: unknown error`);
10091
10288
  }
10092
- process9.exitCode = 1;
10289
+ process11.exitCode = 1;
10093
10290
  }
10094
10291
  async function exportData(configId, options) {
10095
10292
  const format = options.format;
10096
- const outDir = path2.resolve(process9.cwd(), options.out);
10293
+ const outDir = path3.resolve(process11.cwd(), options.out);
10097
10294
  await mkdir2(outDir, { recursive: true });
10098
10295
  console.log(`Exporting data for ${configId} in ${format} format...`);
10099
10296
  const cfg = encodeURIComponent(configId);
10100
- await fetchAndWriteExport(`/data/${cfg}/sessions`, path2.join(outDir, `${configId}-sessions`), (r) => r.sessions ?? [], format, flattenSession, "sessions");
10101
- await fetchAndWriteExport(`/data/${cfg}/events`, path2.join(outDir, `${configId}-events`), (r) => r.events ?? [], format, flattenEvent, "events");
10102
- await fetchAndWriteExport(`/data/${cfg}/chat-messages`, path2.join(outDir, `${configId}-chat-messages`), (r) => r.messages ?? [], format, (msg) => msg, "chat messages");
10103
- await fetchAndWriteExport(`/data/${cfg}/groups`, path2.join(outDir, `${configId}-groups`), (r) => r.groups ?? [], format, (group) => group, "groups");
10104
- await fetchAndWriteExport(`/data/${cfg}/survey-responses`, path2.join(outDir, `${configId}-survey-responses`), (r) => r.surveyResponses ?? [], format, flattenSurveyResponse, "survey responses");
10105
- await fetchAndWriteExport(`/data/${cfg}/workspace-documents`, path2.join(outDir, `${configId}-workspace-documents`), (r) => r.workspaceDocuments ?? [], format, flattenWorkspaceDocument, "workspace documents");
10297
+ await fetchAndWriteExport(`/data/${cfg}/sessions`, path3.join(outDir, `${configId}-sessions`), (r) => r.sessions ?? [], format, flattenSession, "sessions");
10298
+ await fetchAndWriteExport(`/data/${cfg}/events`, path3.join(outDir, `${configId}-events`), (r) => r.events ?? [], format, flattenEvent, "events");
10299
+ await fetchAndWriteExport(`/data/${cfg}/chat-messages`, path3.join(outDir, `${configId}-chat-messages`), (r) => r.messages ?? [], format, (msg) => msg, "chat messages");
10300
+ await fetchAndWriteExport(`/data/${cfg}/groups`, path3.join(outDir, `${configId}-groups`), (r) => r.groups ?? [], format, (group) => group, "groups");
10301
+ await fetchAndWriteExport(`/data/${cfg}/survey-responses`, path3.join(outDir, `${configId}-survey-responses`), (r) => r.surveyResponses ?? [], format, flattenSurveyResponse, "survey responses");
10302
+ await fetchAndWriteExport(`/data/${cfg}/workspace-documents`, path3.join(outDir, `${configId}-workspace-documents`), (r) => r.workspaceDocuments ?? [], format, flattenWorkspaceDocument, "workspace documents");
10106
10303
  console.log(`
10107
10304
  Files written to: ${outDir}`);
10108
10305
  }
@@ -10224,7 +10421,7 @@ function escapeCsvValue(value) {
10224
10421
  return str;
10225
10422
  }
10226
10423
  var __filename2 = fileURLToPath2(import.meta.url);
10227
- var __dirname3 = path2.dirname(__filename2);
10424
+ var __dirname3 = path3.dirname(__filename2);
10228
10425
  export {
10229
10426
  __filename2 as __filename,
10230
10427
  __dirname3 as __dirname
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pairit",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "description": "CLI for the Pairit behavioral science experiment platform",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,7 +25,7 @@
25
25
  "url": "https://github.com/pairium/pairit.git",
26
26
  "directory": "apps/manager/cli"
27
27
  },
28
- "homepage": "https://pairium.github.io/pairit/",
28
+ "homepage": "https://docs.pairium.ai/pairit/",
29
29
  "license": "MIT",
30
30
  "optionalDependencies": {
31
31
  "keytar": "^7.9.0"