@movp/cli 1.0.0 → 1.0.2

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 (2) hide show
  1. package/bin/cli.js +98 -73
  2. package/package.json +1 -1
package/bin/cli.js CHANGED
@@ -6,10 +6,11 @@
6
6
  // npx @movp/cli install --dir <path> — custom install location
7
7
  // npx @movp/cli install --version <tag> — specific release
8
8
  // npx @movp/cli install --init — also run init (opt-in)
9
- // npx @movp/cli init — auto-detect tool, write all config
10
- // npx @movp/cli init --cursor — Cursor-specific setup (MCP + rules only)
11
- // npx @movp/cli init --codex — Codex-specific setup (MCP config only)
9
+ // npx @movp/cli init — authenticate and open browser setup
10
+ // npx @movp/cli init --cursor — Cursor-specific setup
11
+ // npx @movp/cli init --codex — Codex-specific setup
12
12
  // npx @movp/cli init --no-rules — skip writing movp-review rule (use when loading the plugin)
13
+ // npx @movp/cli init --url-only — print setup URL to stdout (headless/SSH)
13
14
  // npx @movp/cli login — device auth login
14
15
  // npx @movp/cli hook — run as PostToolUse hook (reads stdin)
15
16
  // npx @movp/cli — alias for `hook`
@@ -36,7 +37,7 @@ if (command === "--help" || command === "-h") {
36
37
 
37
38
  Commands:
38
39
  install Install MoVP plugins to ~/.movp/plugins/
39
- init Write MCP + hook config into the current project
40
+ init Authenticate and open browser setup for the current project
40
41
  login Device auth login
41
42
  hook PostToolUse hook (default when no command given)
42
43
 
@@ -67,13 +68,15 @@ if (command === "install") {
67
68
  const tool = requireFlagValue(toolIdx, "--tool");
68
69
  const version = requireFlagValue(versionIdx, "--version");
69
70
  const runInitAfter = args.includes("--init");
70
- runInstall({ installDir, tool, version, runInitAfter }).catch((e) => { console.error(e.message); process.exit(1); });
71
+ const installUrlOnly = args.includes("--url-only");
72
+ runInstall({ installDir, tool, version, runInitAfter, urlOnly: installUrlOnly }).catch((e) => { console.error(e.message); process.exit(1); });
71
73
  } else if (command === "init") {
72
74
  const forcedTool = args.includes("--cursor") ? "cursor"
73
75
  : args.includes("--codex") ? "codex"
74
76
  : null;
75
77
  const noRules = args.includes("--no-rules");
76
- runInit(forcedTool, { noRules }).catch((e) => { console.error(e.message); process.exit(1); });
78
+ const urlOnly = args.includes("--url-only");
79
+ runInit(forcedTool, { noRules, urlOnly }).catch((e) => { console.error(e.message); process.exit(1); });
77
80
  } else if (command === "login") {
78
81
  runLogin().catch((e) => { console.error(e.message); process.exit(1); });
79
82
  } else {
@@ -94,6 +97,11 @@ function sleep(ms) {
94
97
  return new Promise((resolve) => setTimeout(resolve, ms));
95
98
  }
96
99
 
100
+ // BFF wraps all /api/* responses in { data: ... }
101
+ function unwrap(body) {
102
+ return (body && typeof body === "object" && "data" in body) ? body.data : body;
103
+ }
104
+
97
105
  function postJSON(baseUrl, urlPath, body, extraHeaders = {}) {
98
106
  return new Promise((resolve, reject) => {
99
107
  const data = JSON.stringify(body);
@@ -144,16 +152,18 @@ function loadCredentials() {
144
152
  }
145
153
  }
146
154
 
147
- function writeCredentials(bffUrl, userId, tenantId) {
155
+ function writeCredentials(bffUrl, userId, tenantId, accessToken) {
148
156
  const configDir = path.join(os.homedir(), ".config", "movp");
149
157
  fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
150
158
  const credPath = path.join(configDir, "credentials");
159
+ const tokenLine = accessToken ? `WORKDESK_ACCESS_TOKEN=${accessToken}\n` : "";
151
160
  const content =
152
161
  `# MoVP device credentials — written by 'npx @movp/cli login'\n` +
153
162
  `# Do not commit this file.\n` +
154
163
  `WORKDESK_URL=${bffUrl}\n` +
155
164
  `WORKDESK_USER=${userId}\n` +
156
- `WORKDESK_TENANT=${tenantId}\n`;
165
+ `WORKDESK_TENANT=${tenantId}\n` +
166
+ tokenLine;
157
167
  fs.writeFileSync(credPath, content, { mode: 0o600 });
158
168
  return credPath;
159
169
  }
@@ -292,6 +302,7 @@ function printInstallHelp() {
292
302
  --dir <path> Install to a custom directory instead of ~/.movp/plugins/
293
303
  --version <tag> Install a specific git tag/branch (default: main)
294
304
  --init Also run \`init\` for the installed tool after install
305
+ --url-only With --init: print setup URL to stdout instead of opening a browser
295
306
  -h, --help Show this help text
296
307
 
297
308
  Environment:
@@ -306,10 +317,11 @@ function printInstallHelp() {
306
317
  npx @movp/cli install --dir /opt/movp/plugins
307
318
  npx @movp/cli install --version v1.2.0
308
319
  npx @movp/cli install --tool cursor --init
320
+ npx @movp/cli install --tool claude --init --url-only
309
321
  `);
310
322
  }
311
323
 
312
- async function runInstall({ installDir, tool, version, runInitAfter }) {
324
+ async function runInstall({ installDir, tool, version, runInitAfter, urlOnly = false }) {
313
325
  const { spawnSync } = require("child_process");
314
326
 
315
327
  const targetDir = installDir
@@ -437,7 +449,7 @@ async function runInstall({ installDir, tool, version, runInitAfter }) {
437
449
  // Map install --tool names to runInit's expected tool identifiers
438
450
  const forcedTool = tool === "claude" ? "claude-code" : tool === "cursor" ? "cursor" : tool === "codex" ? "codex" : null;
439
451
  try {
440
- await runInit(forcedTool);
452
+ await runInit(forcedTool, { urlOnly });
441
453
  } catch (e) {
442
454
  // Plugins are already on disk — distinguish install success from init failure.
443
455
  const msg = e instanceof Error ? e.message : String(e);
@@ -547,8 +559,7 @@ function printInstallNextSteps(pluginsDir, tool) {
547
559
  // init
548
560
  // ─────────────────────────────────────────────────────────────────────────────
549
561
 
550
- async function runInit(forcedTool, { noRules = false } = {}) {
551
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
562
+ async function runInit(forcedTool, { noRules = false, urlOnly = false } = {}) {
552
563
  const cwd = process.cwd();
553
564
 
554
565
  console.log("\n MoVP CLI v1.0.0\n");
@@ -556,106 +567,96 @@ async function runInit(forcedTool, { noRules = false } = {}) {
556
567
  // ── Step 0: Determine tool ─────────────────────────────────────────────────
557
568
  let tool = forcedTool || detectTool();
558
569
  if (!tool) {
570
+ // Only open readline for tool detection when needed
571
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
559
572
  console.log(" Could not auto-detect AI coding tool.");
560
573
  const answer = (await prompt(rl, " Tool [claude-code|cursor|codex]: ")).trim().toLowerCase();
574
+ rl.close();
561
575
  tool = answer || "claude-code";
562
576
  } else {
563
577
  console.log(` Detected: ${tool}`);
564
578
  }
565
579
 
566
580
  if (!["claude-code", "cursor", "codex"].includes(tool)) {
567
- console.error(` Unknown tool: ${tool}`);
568
- rl.close();
581
+ console.error(` Unknown tool: ${tool}. Supported: claude-code, cursor, codex`);
569
582
  process.exit(1);
570
583
  }
571
584
 
572
585
  // ── Step 1: Authentication ────────────────────────────────────────────────
573
- console.log("\n Step 1/3: Authentication");
586
+ console.log("\n Step 1/2: Authentication");
574
587
  let creds = loadCredentials();
575
588
  let bffUrl = creds.WORKDESK_URL || process.env.WORKDESK_URL || "";
576
589
  let tenantId = creds.WORKDESK_TENANT || process.env.WORKDESK_TENANT || "";
577
- let userId = creds.WORKDESK_USER || "";
578
590
 
579
591
  if (bffUrl && tenantId) {
580
592
  console.log(` Already authenticated (tenant: ${tenantId})`);
581
593
  } else {
582
594
  console.log(" Opening browser for device login...");
583
- // runLogin() only writes to stdout and polls HTTP — rl stays open for prompts after
584
595
  await runLogin();
585
596
  creds = loadCredentials();
586
597
  bffUrl = creds.WORKDESK_URL || "";
587
598
  tenantId = creds.WORKDESK_TENANT || "";
588
- userId = creds.WORKDESK_USER || "";
589
599
  }
590
600
 
591
601
  if (!bffUrl || !tenantId) {
592
602
  console.error(" Authentication incomplete. Run `npx @movp/cli login` first.");
593
- rl.close();
594
603
  process.exit(1);
595
604
  }
596
605
 
597
- // ── Step 2: Agent Source ──────────────────────────────────────────────────
598
- console.log("\n Step 2/3: Agent Source");
599
- let apiKey = "";
606
+ // ── Step 2: Complete setup in browser ────────────────────────────────────
607
+ const frontendUrl = process.env.MOVP_FRONTEND_URL || "https://mostviableproduct.com";
608
+
609
+ // Validate URL: must be https (or http://localhost for dev). Prevents command
610
+ // injection via MOVP_FRONTEND_URL and blocks non-TLS origins in production.
600
611
  try {
601
- const res = await postJSON(bffUrl, "/api/workdesk/agents/setup", {
602
- agent_type: tool,
603
- user_id: userId,
604
- tenant_id: tenantId,
605
- }, {
606
- "X-Tenant-ID": tenantId,
607
- });
608
- if (res.status === 200 || res.status === 201) {
609
- apiKey = res.body.api_key || res.body.apiKey || "";
610
- if (apiKey) {
611
- console.log(" Agent source created.");
612
- // Persist api_key to credentials file
613
- const configDir = path.join(os.homedir(), ".config", "movp");
614
- const credPath = path.join(configDir, "credentials");
615
- let existing = "";
616
- try { existing = fs.readFileSync(credPath, "utf8"); } catch {}
617
- if (!existing.includes("WORKDESK_API_KEY=")) {
618
- fs.appendFileSync(credPath, `WORKDESK_API_KEY=${apiKey}\n`);
619
- }
620
- } else {
621
- console.log(" Agent source configured (no new key returned — using existing).");
622
- apiKey = process.env.WORKDESK_API_KEY || "";
623
- }
624
- } else {
625
- console.log(` Agent source setup returned HTTP ${res.status} — continuing with existing config.`);
626
- apiKey = process.env.WORKDESK_API_KEY || "";
612
+ const parsed = new URL(frontendUrl);
613
+ if (parsed.protocol !== "https:" && !parsed.hostname.match(/^(localhost|127\.0\.0\.1)$/)) {
614
+ console.error(` MOVP_FRONTEND_URL must be https (got ${parsed.protocol})`);
615
+ process.exit(1);
627
616
  }
628
- } catch (e) {
629
- console.log(` Could not reach ${bffUrl}: ${e.message}`);
630
- console.log(" Continuing with manual config...");
631
- apiKey = process.env.WORKDESK_API_KEY || "";
617
+ } catch {
618
+ console.error(` Invalid MOVP_FRONTEND_URL: ${frontendUrl}`);
619
+ process.exit(1);
632
620
  }
633
621
 
634
- // Ask for MCP server path (required for settings.json)
635
- let mcpServerPath = "";
636
- if (tool === "claude-code" || tool === "cursor") {
637
- const envPath = process.env.MOVP_MCP_SERVER_PATH || "";
638
- const inputPath = await prompt(rl, `\n MCP server path (dist/index.js) [${envPath || "skip"}]: `);
639
- mcpServerPath = inputPath.trim() || envPath;
640
- }
622
+ // Strip trailing slash to avoid "https://example.com//setup/agent"
623
+ const baseUrl = frontendUrl.replace(/\/+$/, "");
624
+ const setupUrl = `${baseUrl}/setup/agent?tool=${encodeURIComponent(tool)}&from=cli`;
641
625
 
642
- rl.close();
626
+ if (urlOnly) {
627
+ // Headless/SSH mode: URL to stdout (pipeable), hint to stderr
628
+ process.stdout.write(setupUrl + "\n");
629
+ process.stderr.write("Open this URL in a browser to complete setup.\n");
630
+ } else {
631
+ console.log("\n Step 2/2: Complete setup in your browser\n");
632
+ console.log(` ${setupUrl}\n`);
643
633
 
644
- // ── Step 3: Write configuration ───────────────────────────────────────────
645
- console.log("\n Step 3/3: Configuration");
634
+ // Open browser using execFileSync with array args (no shell interpolation).
635
+ // Non-fatal — URL is always printed above as fallback.
636
+ // Windows note: `start` is a cmd.exe built-in — use cmd.exe /c start.
637
+ try {
638
+ const { execFileSync } = require("child_process");
639
+ if (process.platform === "darwin") {
640
+ execFileSync("open", [setupUrl], { stdio: "ignore" });
641
+ } else if (process.platform === "win32") {
642
+ execFileSync("cmd.exe", ["/c", "start", "", setupUrl], { stdio: "ignore" });
643
+ } else {
644
+ execFileSync("xdg-open", [setupUrl], { stdio: "ignore" });
645
+ }
646
+ } catch { /* non-fatal — URL printed above */ }
646
647
 
647
- if (tool === "claude-code") {
648
- writeClaudeCodeConfig(cwd, bffUrl, apiKey, tenantId, mcpServerPath, noRules);
649
- } else if (tool === "cursor") {
650
- writeCursorConfig(cwd, bffUrl, apiKey, tenantId, mcpServerPath, noRules);
651
- } else if (tool === "codex") {
652
- writeCodexConfig(cwd, bffUrl, tenantId, apiKey);
648
+ console.log(" Follow the steps in your browser to get your config snippet.");
649
+ console.log(" Once done, paste the config into your project.\n");
653
650
  }
654
651
 
655
- // .movp/config.yaml (all tools)
652
+ // ── Write .movp/config.yaml ───────────────────────────────────────────────
653
+ // All other config (.claude/settings.json, env vars, API key) is handled
654
+ // by the browser setup page's download script.
656
655
  writeMovpConfig(cwd);
657
656
 
658
- console.log("\n Ready. Type /movp review to begin.\n");
657
+ if (!urlOnly) {
658
+ console.log(" Ready. Type /movp review to begin.\n");
659
+ }
659
660
  }
660
661
 
661
662
  // ─────────────────────────────────────────────────────────────────────────────
@@ -863,7 +864,7 @@ async function runLogin() {
863
864
  }
864
865
 
865
866
  const { device_code, user_code, verification_uri_complete, expires_in, interval } =
866
- authorizeRes.body;
867
+ unwrap(authorizeRes.body);
867
868
 
868
869
  console.log(` Your verification code: ${user_code}`);
869
870
  console.log(`\n Open this URL to approve:\n`);
@@ -899,16 +900,40 @@ async function runLogin() {
899
900
  process.exit(1);
900
901
  }
901
902
 
903
+ if (tokenRes.status === 409) {
904
+ console.log("\n This authorization was already completed in another session.");
905
+ console.log(" Run `npx @movp/cli login` to start a fresh device flow.\n");
906
+ process.exit(1);
907
+ }
908
+
909
+ if (tokenRes.status >= 500) {
910
+ const b = unwrap(tokenRes.body);
911
+ const msg = b?.error?.message || b?.message || `HTTP ${tokenRes.status}`;
912
+ console.log(`\n Server error while polling: ${msg}`);
913
+ console.log(" Try again in a moment.\n");
914
+ process.exit(1);
915
+ }
916
+
902
917
  if (tokenRes.status === 200) {
903
- const { status, user_id, tenant_id } = tokenRes.body;
918
+ const { status, user_id, tenant_id, access_token } = unwrap(tokenRes.body);
904
919
  if (status === "authorized") {
920
+ if (!access_token) {
921
+ console.log("\n Server returned authorized status but no access_token (contract error).");
922
+ console.log(" Please file an issue with the MoVP team.\n");
923
+ process.exit(1);
924
+ }
905
925
  console.log("\n\n Login successful!\n");
906
- const credPath = writeCredentials(bffUrl, user_id, tenant_id);
926
+ const credPath = writeCredentials(bffUrl, user_id, tenant_id, access_token);
907
927
  console.log(` Credentials: ${credPath}`);
908
928
  console.log(` User ID: ${user_id}`);
909
929
  console.log(` Tenant ID: ${tenant_id}\n`);
910
930
  return;
911
931
  }
932
+ if (status === "consumed") {
933
+ console.log("\n Device token was already issued to a previous session.");
934
+ console.log(" Run `npx @movp/cli login` to start a fresh device flow.\n");
935
+ process.exit(1);
936
+ }
912
937
  if (status === "denied") {
913
938
  console.log("\n Login denied by user.");
914
939
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@movp/cli",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "MoVP CLI — configure AI coding tools with the MoVP control plane (PostToolUse hook + MCP setup)",
5
5
  "main": "hook.js",
6
6
  "bin": {