@oodarun/cli 0.1.12 → 0.1.14

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/dist/cli.js CHANGED
@@ -298,7 +298,7 @@ createRoot(document.getElementById("root")!).render(
298
298
  });
299
299
 
300
300
  // src/cli/index.ts
301
- import path9 from "path";
301
+ import path11 from "path";
302
302
  import readline2 from "readline";
303
303
  import { select as select3, confirm, Separator as Separator3 } from "@inquirer/prompts";
304
304
 
@@ -460,6 +460,7 @@ function parseConfig(jsonString) {
460
460
  }
461
461
  return {
462
462
  name: userConfig.name,
463
+ slug: userConfig.slug,
463
464
  description: userConfig.description,
464
465
  tools: mergedTools,
465
466
  claude: userConfig.claude || DEFAULT_CONFIG.claude
@@ -1036,22 +1037,13 @@ function promptToken(question) {
1036
1037
  process.stdin.on("data", onData);
1037
1038
  });
1038
1039
  }
1039
- async function projectSubMenu(project, isOwner = true) {
1040
+ async function projectSubMenu(project, isOwner = true, publishedUrl = null) {
1040
1041
  const icon = statusIcon(project.status);
1041
1042
  const ownerLabel = project.owner ? ` ${c.gray}(${project.owner})${c.reset}` : "";
1042
1043
  if (!isOwner) {
1043
- let publishedUrl2 = null;
1044
- try {
1045
- const res = await fetch(`https://ooda.run/api/publish/${project.name}`);
1046
- if (res.ok) {
1047
- const info = await res.json();
1048
- publishedUrl2 = info.latestUrl;
1049
- }
1050
- } catch {
1051
- }
1052
1044
  const choices2 = [];
1053
- if (publishedUrl2) {
1054
- choices2.push({ name: `Open published site ${c.gray}${publishedUrl2}${c.reset}`, value: "open-published" });
1045
+ if (publishedUrl) {
1046
+ choices2.push({ name: `Open published site ${c.gray}${publishedUrl}${c.reset}`, value: "open-published" });
1055
1047
  }
1056
1048
  choices2.push(
1057
1049
  new Separator(` ${c.gray}You don't have access to manage this project${c.reset}`),
@@ -1068,15 +1060,6 @@ async function projectSubMenu(project, isOwner = true) {
1068
1060
  throw err;
1069
1061
  }
1070
1062
  }
1071
- let publishedUrl = null;
1072
- try {
1073
- const res = await fetch(`https://ooda.run/api/publish/${project.name}`);
1074
- if (res.ok) {
1075
- const info = await res.json();
1076
- publishedUrl = info.latestUrl;
1077
- }
1078
- } catch {
1079
- }
1080
1063
  const choices = [
1081
1064
  { name: "Connect (launch Claude)", value: "connect" },
1082
1065
  { name: "Re-upload files from local folder", value: "reupload" }
@@ -1234,7 +1217,8 @@ async function completeJwtLogin(result) {
1234
1217
  console.log("");
1235
1218
  return "org";
1236
1219
  }
1237
- async function ensureAuth() {
1220
+ async function ensureAuth(opts = {}) {
1221
+ const requireClaudeToken = opts.requireClaudeToken !== false;
1238
1222
  let apiToken = null;
1239
1223
  const orgClaude = isOrgMode() ? getOrgClaudeConfig() : null;
1240
1224
  let claudeToken = getClaudeToken(orgClaude?.apiKeyHelper || void 0);
@@ -1253,6 +1237,9 @@ async function ensureAuth() {
1253
1237
  `);
1254
1238
  apiToken = await handleEmailPasswordAuth();
1255
1239
  }
1240
+ if (!requireClaudeToken) {
1241
+ return { apiToken, claudeToken: "", claudeSource: "" };
1242
+ }
1256
1243
  async function promptForClaudeToken() {
1257
1244
  const tokenType = await select2({
1258
1245
  message: "Claude token type:",
@@ -1946,19 +1933,27 @@ import path from 'path';
1946
1933
 
1947
1934
  const MAX_FILE_SIZE = 10 * 1024 * 1024;
1948
1935
 
1949
- // Resolve publish URL and auth \u2014 org mode uses JWT via org proxy
1950
- let PUBLISH_URL = 'https://ooda.run/api/publish';
1951
- let publishToken = process.env.PUBLISH_TOKEN || '';
1936
+ // Resolve publish URL and auth. Publishing goes exclusively through the
1937
+ // authenticated org proxy (POST /org/{orgId}/publish), which records the site
1938
+ // in D1 and materializes its access-control policy. The old unauthenticated
1939
+ // loader endpoint is retired, so org credentials are REQUIRED.
1940
+ let PUBLISH_URL = '';
1941
+ let publishToken = '';
1952
1942
  let orgProjectName = '';
1953
1943
  try {
1954
1944
  const orgCreds = JSON.parse(fs.readFileSync('/tmp/ooda-org.json', 'utf-8'));
1945
+ const apiBase = orgCreds.apiBase || 'https://api.ooda.run';
1955
1946
  if (orgCreds.orgId && orgCreds.jwt) {
1956
- PUBLISH_URL = 'https://api.ooda.run/org/' + orgCreds.orgId + '/publish';
1947
+ PUBLISH_URL = apiBase + '/org/' + orgCreds.orgId + '/publish';
1957
1948
  publishToken = orgCreds.jwt;
1958
1949
  }
1959
1950
  if (orgCreds.projectName) orgProjectName = orgCreds.projectName;
1960
1951
  } catch {
1961
- // No org credentials \u2014 use default publish endpoint
1952
+ // handled below
1953
+ }
1954
+ if (!PUBLISH_URL || !publishToken) {
1955
+ console.error('ERROR: Missing ooda org credentials (/tmp/ooda-org.json). Reconnect the project with the ooda CLI to refresh credentials, then publish again.');
1956
+ process.exit(1);
1962
1957
  }
1963
1958
 
1964
1959
  const projectDir = process.argv[2] || process.cwd();
@@ -2658,103 +2653,6 @@ export default function designBrowserSource() {
2658
2653
  };
2659
2654
  }
2660
2655
  `.trim();
2661
- var PUBLISH_SCRIPT2 = `
2662
- import fs from 'fs';
2663
- import path from 'path';
2664
- import { execSync } from 'child_process';
2665
-
2666
- const PUBLISH_URL = 'https://ooda.run/api/publish';
2667
- const publishToken = process.env.PUBLISH_TOKEN || '';
2668
- const MAX_FILE_SIZE = 10 * 1024 * 1024;
2669
-
2670
- // Find the project directory (first arg or cwd)
2671
- const projectDir = process.argv[2] || process.cwd();
2672
-
2673
- // Detect build output directory
2674
- const candidates = ['dist', 'build', 'out', '.output/public', '.next/static'];
2675
- let outputDir = null;
2676
- for (const c of candidates) {
2677
- const p = path.join(projectDir, c);
2678
- if (fs.existsSync(p) && fs.statSync(p).isDirectory()) {
2679
- outputDir = p;
2680
- break;
2681
- }
2682
- }
2683
-
2684
- if (!outputDir) {
2685
- console.error('ERROR: No build output directory found. Run your build command first.');
2686
- console.error('Looked for: ' + candidates.join(', '));
2687
- process.exit(1);
2688
- }
2689
-
2690
- console.log('Found build output: ' + outputDir);
2691
-
2692
- // Derive slug from project name (hostname)
2693
- let slug;
2694
- try {
2695
- slug = execSync('hostname', { encoding: 'utf-8' }).trim();
2696
- } catch {
2697
- slug = path.basename(projectDir);
2698
- }
2699
- // Clean slug to match requirements
2700
- slug = slug.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
2701
- if (slug.length < 3) slug = slug + '-site';
2702
- if (slug.length > 64) slug = slug.slice(0, 64);
2703
-
2704
- console.log('Publishing as: ' + slug);
2705
-
2706
- // Collect all files recursively
2707
- function collectFiles(dir, base) {
2708
- const results = [];
2709
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
2710
- const full = path.join(dir, entry.name);
2711
- if (entry.isDirectory()) {
2712
- results.push(...collectFiles(full, base));
2713
- } else if (entry.isFile()) {
2714
- const stat = fs.statSync(full);
2715
- if (stat.size <= MAX_FILE_SIZE) {
2716
- const rel = path.relative(base, full);
2717
- const content = fs.readFileSync(full).toString('base64');
2718
- results.push({ path: rel, content });
2719
- } else {
2720
- console.log('Skipping large file: ' + path.relative(base, full));
2721
- }
2722
- }
2723
- }
2724
- return results;
2725
- }
2726
-
2727
- const files = collectFiles(outputDir, outputDir);
2728
- console.log('Collected ' + files.length + ' files');
2729
-
2730
- if (files.length === 0) {
2731
- console.error('ERROR: No files found in ' + outputDir);
2732
- process.exit(1);
2733
- }
2734
-
2735
- // Upload
2736
- const body = JSON.stringify({ slug, files });
2737
- console.log('Uploading (' + (body.length / 1024 / 1024).toFixed(1) + 'MB)...');
2738
-
2739
- const res = await fetch(PUBLISH_URL, {
2740
- method: 'POST',
2741
- headers: { 'Content-Type': 'application/json', ...(publishToken ? { 'Authorization': 'Bearer ' + publishToken } : {}) },
2742
- body,
2743
- });
2744
-
2745
- const result = await res.json();
2746
- if (result.ok) {
2747
- console.log('');
2748
- console.log('Published successfully!');
2749
- console.log('URL: ' + result.url);
2750
- console.log('Version: ' + result.version);
2751
- console.log('Files: ' + result.fileCount);
2752
- console.log('Size: ' + (result.totalSize / 1024).toFixed(1) + 'KB');
2753
- } else {
2754
- console.error('Publish failed: ' + (result.error || JSON.stringify(result)));
2755
- process.exit(1);
2756
- }
2757
- `.trim();
2758
2656
  var CONFIG_PATCH_SCRIPT = `
2759
2657
  import fs from 'fs';
2760
2658
  import path from 'path';
@@ -3028,7 +2926,7 @@ async function provisionFromFolder(localPath, projectName, token, onProgress, br
3028
2926
  await setupClaudeAuth(projectName, token, effectiveClaudeToken, progress, claudeEnv);
3029
2927
  }
3030
2928
  await deployClaudeConfig(projectName, token, projectRoot, project.framework, progress, gitInfo || null, branchName || null);
3031
- await writeRemoteFile("/home/user/.ooda/publish.mjs", PUBLISH_SCRIPT2);
2929
+ await writeRemoteFile("/home/user/.ooda/publish.mjs", PUBLISH_SCRIPT);
3032
2930
  await patchServerConfig(projectName, token, projectRoot, progress);
3033
2931
  await installSourcePlugin(projectName, token, projectRoot, project.framework, progress);
3034
2932
  try {
@@ -3276,7 +3174,7 @@ async function provisionFromGitHub(parsed, projectName, token, onProgress, githu
3276
3174
  gitWorkflowPatch.trim() + "\n"
3277
3175
  );
3278
3176
  await writeRemoteFile(`${projectRoot}/CLAUDE.md`, updatedClaudeMd);
3279
- await writeRemoteFile(`${homeDir}/.ooda/publish.mjs`, PUBLISH_SCRIPT2);
3177
+ await writeRemoteFile(`${homeDir}/.ooda/publish.mjs`, PUBLISH_SCRIPT);
3280
3178
  await patchServerConfig(projectName, token, projectRoot, progress);
3281
3179
  await installSourcePlugin(projectName, token, projectRoot, project.framework, progress);
3282
3180
  try {
@@ -3379,7 +3277,7 @@ async function provisionFromTemplate(projectName, templateId, token, onProgress,
3379
3277
  await setupClaudeAuth(projectName, token, effectiveClaudeToken, progress, claudeEnv);
3380
3278
  }
3381
3279
  await deployClaudeConfig(projectName, token, projectRoot, "vite", progress);
3382
- await writeFile("/home/user/.ooda/publish.mjs", PUBLISH_SCRIPT2);
3280
+ await writeFile("/home/user/.ooda/publish.mjs", PUBLISH_SCRIPT);
3383
3281
  try {
3384
3282
  await deployToolsViaRest(projectName, token, projectRoot, (msg) => {
3385
3283
  progress({ step: msg });
@@ -3719,14 +3617,14 @@ async function pullChangesFromProject(projectName, token, projectRoot, localProj
3719
3617
  }
3720
3618
  execSync3(`git checkout -b "${localBranch}"`, localOpts);
3721
3619
  try {
3722
- const fs7 = await import("fs");
3620
+ const fs9 = await import("fs");
3723
3621
  const os2 = await import("os");
3724
- const path10 = await import("path");
3725
- const patchFile = path10.join(os2.tmpdir(), `ooda-patch-${Date.now()}.patch`);
3726
- fs7.writeFileSync(patchFile, patchContent, "utf-8");
3622
+ const path12 = await import("path");
3623
+ const patchFile = path12.join(os2.tmpdir(), `ooda-patch-${Date.now()}.patch`);
3624
+ fs9.writeFileSync(patchFile, patchContent, "utf-8");
3727
3625
  execSync3(`git am "${patchFile}"`, localOpts);
3728
3626
  try {
3729
- fs7.unlinkSync(patchFile);
3627
+ fs9.unlinkSync(patchFile);
3730
3628
  } catch {
3731
3629
  }
3732
3630
  } catch (amError) {
@@ -4087,6 +3985,85 @@ function startServer(opts) {
4087
3985
  });
4088
3986
  }
4089
3987
 
3988
+ // src/cli/sites-client.ts
3989
+ function buildSitesUrl(apiBase, orgId) {
3990
+ return `${apiBase}/org/${encodeURIComponent(orgId)}/dashboard/sites`;
3991
+ }
3992
+ function buildSiteUrl(apiBase, orgId, slug, sub) {
3993
+ const base = `${buildSitesUrl(apiBase, orgId)}/${encodeURIComponent(slug)}`;
3994
+ return sub ? `${base}/${sub}` : base;
3995
+ }
3996
+ var SitesApiError = class extends Error {
3997
+ constructor(status, message) {
3998
+ super(message);
3999
+ this.status = status;
4000
+ this.name = "SitesApiError";
4001
+ }
4002
+ };
4003
+ async function readJson(res) {
4004
+ const text = await res.text();
4005
+ let data;
4006
+ try {
4007
+ data = text ? JSON.parse(text) : {};
4008
+ } catch {
4009
+ data = {};
4010
+ }
4011
+ if (!res.ok) {
4012
+ const msg = data?.error || `Request failed (${res.status})`;
4013
+ throw new SitesApiError(res.status, msg);
4014
+ }
4015
+ return data;
4016
+ }
4017
+ function authHeaders(jwt, json = false) {
4018
+ return {
4019
+ Authorization: `Bearer ${jwt}`,
4020
+ ...json ? { "Content-Type": "application/json" } : {}
4021
+ };
4022
+ }
4023
+ async function listSites(auth) {
4024
+ const res = await fetch(buildSitesUrl(auth.apiBase, auth.orgId), {
4025
+ headers: authHeaders(auth.jwt)
4026
+ });
4027
+ const data = await readJson(res);
4028
+ return data.sites ?? [];
4029
+ }
4030
+ async function updateSiteAccess(auth, slug, body) {
4031
+ const res = await fetch(buildSiteUrl(auth.apiBase, auth.orgId, slug), {
4032
+ method: "PATCH",
4033
+ headers: authHeaders(auth.jwt, true),
4034
+ body: JSON.stringify(body)
4035
+ });
4036
+ return readJson(res);
4037
+ }
4038
+ async function revealSitePassword(auth, slug) {
4039
+ const res = await fetch(buildSiteUrl(auth.apiBase, auth.orgId, slug, "password"), {
4040
+ headers: authHeaders(auth.jwt)
4041
+ });
4042
+ return readJson(res);
4043
+ }
4044
+ async function deleteSite(auth, slug) {
4045
+ const res = await fetch(buildSiteUrl(auth.apiBase, auth.orgId, slug), {
4046
+ method: "DELETE",
4047
+ headers: authHeaders(auth.jwt)
4048
+ });
4049
+ return readJson(res);
4050
+ }
4051
+ async function fetchPublishedSiteUrl(opts) {
4052
+ try {
4053
+ const res = await fetch(buildSitesUrl(opts.apiBase, opts.orgId), {
4054
+ headers: { Authorization: `Bearer ${opts.jwt}` }
4055
+ });
4056
+ if (!res.ok) return null;
4057
+ const data = await res.json();
4058
+ const match = (data.sites ?? []).find(
4059
+ (s) => s.projectName === opts.projectName || s.slug === opts.projectName
4060
+ );
4061
+ return match?.url ?? null;
4062
+ } catch {
4063
+ return null;
4064
+ }
4065
+ }
4066
+
4090
4067
  // src/urls.ts
4091
4068
  function parseLoaderBase() {
4092
4069
  const raw = process.env.OODA_LOADER_BASE;
@@ -4135,10 +4112,9 @@ function buildSelectorChoices(state) {
4135
4112
  } else {
4136
4113
  projectChoices = state.projects.map((p) => {
4137
4114
  const icon = statusIcon(p.status);
4138
- const url = p.url ? ` ${c.gray}${toPublicUrl(p.url)}${c.reset}` : "";
4139
4115
  const owner = p.owner ? ` ${c.gray}(${p.owner})${c.reset}` : "";
4140
4116
  return {
4141
- name: ` ${p.name}${owner} ${icon} ${p.status}${url}`,
4117
+ name: ` ${p.name}${owner} ${icon} ${p.status}`,
4142
4118
  value: { kind: "project", project: p }
4143
4119
  };
4144
4120
  });
@@ -4644,8 +4620,42 @@ function mergeOnboardingConfig(existing, projectRoot) {
4644
4620
  return d;
4645
4621
  }
4646
4622
 
4623
+ // src/cli/backup-client.ts
4624
+ function buildBackupUrl(apiBase, orgId, projectName) {
4625
+ return `${apiBase}/org/${encodeURIComponent(orgId)}/project/${encodeURIComponent(projectName)}/backup`;
4626
+ }
4627
+ function buildRestoreUrl(apiBase, orgId, projectName) {
4628
+ return `${apiBase}/org/${encodeURIComponent(orgId)}/project/${encodeURIComponent(projectName)}/restore`;
4629
+ }
4630
+ async function restoreProject(opts) {
4631
+ try {
4632
+ const res = await fetch(buildRestoreUrl(opts.apiBase, opts.orgId, opts.projectName), {
4633
+ method: "POST",
4634
+ headers: { Authorization: `Bearer ${opts.jwt}` }
4635
+ });
4636
+ if (!res.ok) return { restored: false, reason: `http-${res.status}` };
4637
+ const data = await res.json();
4638
+ return { restored: Boolean(data.restored), reason: data.reason, dir: data.dir };
4639
+ } catch {
4640
+ return { restored: false, reason: "network-error" };
4641
+ }
4642
+ }
4643
+ async function backupProject(opts) {
4644
+ try {
4645
+ const res = await fetch(buildBackupUrl(opts.apiBase, opts.orgId, opts.projectName), {
4646
+ method: "POST",
4647
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.jwt}` },
4648
+ body: JSON.stringify({ projectRoot: opts.projectRoot })
4649
+ });
4650
+ return res.ok;
4651
+ } catch {
4652
+ return false;
4653
+ }
4654
+ }
4655
+
4647
4656
  // src/cli/session.ts
4648
4657
  var IDLE_TIMEOUT_MS = 30 * 60 * 1e3;
4658
+ var BACKUP_INTERVAL_MS = 3 * 60 * 1e3;
4649
4659
  var IDLE_WARNING_MS = 25 * 60 * 1e3;
4650
4660
  var IDLE_CHECK_INTERVAL_MS = 60 * 1e3;
4651
4661
  var _activeProject = null;
@@ -4715,8 +4725,27 @@ async function connectAndRunClaude(projectName, apiToken, claudeToken, projectUr
4715
4725
  `);
4716
4726
  return;
4717
4727
  }
4718
- const projectRoot = await findProjectRoot(projectName, apiToken);
4719
4728
  console.log(` ${c.green}${c.bold}\u2713${c.reset} Connected ${c.gray}${elapsed()}${c.reset}`);
4729
+ const backupApiBase = process.env.OODA_API_BASE || "https://api.ooda.run";
4730
+ const backupOrgId = isOrgMode() ? getOrgId() : null;
4731
+ if (backupOrgId) {
4732
+ const r = await restoreProject({
4733
+ apiBase: backupApiBase,
4734
+ orgId: backupOrgId,
4735
+ projectName,
4736
+ jwt: getAccessToken() ?? ""
4737
+ });
4738
+ if (r.restored) {
4739
+ const where = r.dir ? ` ${c.gray}(${r.dir})${c.reset}` : "";
4740
+ console.log(` ${c.green}${c.bold}\u2713${c.reset} ${c.gray}Restored project files from last snapshot${c.reset}${where}`);
4741
+ } else if (r.reason === "no-backup") {
4742
+ console.log(` ${c.gray}\xB7 No snapshot to restore yet${c.reset}`);
4743
+ } else {
4744
+ console.log(` ${c.yellow}!${c.reset} ${c.gray}Snapshot restore failed (${r.reason ?? "unknown"})${c.reset}`);
4745
+ }
4746
+ }
4747
+ const projectRoot = await findProjectRoot(projectName, apiToken);
4748
+ const canBackup = !!backupOrgId && projectRoot !== "/home/user" && projectRoot !== "/root";
4720
4749
  pushPhase("setup", "Writing config files");
4721
4750
  try {
4722
4751
  const memRaw = await client.exec("free -m 2>/dev/null || cat /proc/meminfo 2>/dev/null | head -3");
@@ -4753,7 +4782,11 @@ async function connectAndRunClaude(projectName, apiToken, claudeToken, projectUr
4753
4782
  const orgId = getOrgId();
4754
4783
  const jwt = getAccessToken();
4755
4784
  if (orgId && jwt) {
4756
- await client.writeFile("/tmp/ooda-org.json", JSON.stringify({ orgId, jwt, projectName }));
4785
+ const apiBase = process.env.OODA_API_BASE;
4786
+ await client.writeFile(
4787
+ "/tmp/ooda-org.json",
4788
+ JSON.stringify({ orgId, jwt, projectName, ...apiBase ? { apiBase } : {} })
4789
+ );
4757
4790
  }
4758
4791
  }
4759
4792
  const tokenType = getClaudeTokenType(claudeToken);
@@ -4996,6 +5029,23 @@ ${text}`;
4996
5029
  claudeExec
4997
5030
  ].join("; ");
4998
5031
  }
5032
+ let backupTimer = null;
5033
+ let backupInFlight = false;
5034
+ if (canBackup) {
5035
+ backupTimer = setInterval(() => {
5036
+ if (backupInFlight) return;
5037
+ backupInFlight = true;
5038
+ void backupProject({
5039
+ apiBase: backupApiBase,
5040
+ orgId: backupOrgId,
5041
+ projectName,
5042
+ projectRoot,
5043
+ jwt: getAccessToken() ?? ""
5044
+ }).finally(() => {
5045
+ backupInFlight = false;
5046
+ });
5047
+ }, BACKUP_INTERVAL_MS);
5048
+ }
4999
5049
  let tokenRefreshTimer = null;
5000
5050
  const orgApiKey = claudeEnv?.ANTHROPIC_API_KEY;
5001
5051
  const useServerRefresh = !apiKeyHelper && orgApiKey && isOrgMode();
@@ -5144,6 +5194,22 @@ ${text}`;
5144
5194
  currentCmdAlive = false;
5145
5195
  _activeProject = null;
5146
5196
  if (tokenRefreshTimer) clearInterval(tokenRefreshTimer);
5197
+ if (backupTimer) clearInterval(backupTimer);
5198
+ if (canBackup) {
5199
+ console.log(` ${c.gray}Saving project snapshot...${c.reset}`);
5200
+ const saved = await backupProject({
5201
+ apiBase: backupApiBase,
5202
+ orgId: backupOrgId,
5203
+ projectName,
5204
+ projectRoot,
5205
+ jwt: getAccessToken() ?? ""
5206
+ });
5207
+ if (saved) {
5208
+ console.log(` ${c.green}${c.bold}\u2713${c.reset} ${c.gray}Snapshot saved${c.reset}`);
5209
+ } else {
5210
+ console.log(` ${c.yellow}!${c.reset} ${c.gray}Snapshot save failed (project not backed up)${c.reset}`);
5211
+ }
5212
+ }
5147
5213
  console.log(` ${c.gray}Stopping project processes...${c.reset}`);
5148
5214
  const result = await stopProject(projectName, apiToken);
5149
5215
  if (result.ok) {
@@ -5372,8 +5438,382 @@ async function deployFromGitHubFlow(target, apiToken, claudeToken) {
5372
5438
  }
5373
5439
  }
5374
5440
 
5441
+ // src/cli/publish.ts
5442
+ import fs8 from "fs";
5443
+ import path10 from "path";
5444
+
5445
+ // src/publish-core.ts
5446
+ import fs7 from "fs";
5447
+ import path9 from "path";
5448
+ var MAX_FILE_SIZE2 = 10 * 1024 * 1024;
5449
+ var BUILD_DIR_CANDIDATES = [
5450
+ "dist",
5451
+ "build",
5452
+ "out",
5453
+ ".output/public",
5454
+ ".next/static"
5455
+ ];
5456
+ function detectBuildDir(projectDir) {
5457
+ for (const candidate of BUILD_DIR_CANDIDATES) {
5458
+ const full = path9.join(projectDir, candidate);
5459
+ try {
5460
+ if (fs7.existsSync(full) && fs7.statSync(full).isDirectory()) {
5461
+ return full;
5462
+ }
5463
+ } catch {
5464
+ }
5465
+ }
5466
+ return null;
5467
+ }
5468
+ function deriveSlug(rawName) {
5469
+ let slug = rawName.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
5470
+ if (slug.length < 3) slug = `${slug}-site`;
5471
+ if (slug.length > 64) slug = slug.slice(0, 64);
5472
+ return slug;
5473
+ }
5474
+ function collectFiles2(dir, onSkip) {
5475
+ const results = [];
5476
+ const walk = (current) => {
5477
+ for (const entry of fs7.readdirSync(current, { withFileTypes: true })) {
5478
+ const full = path9.join(current, entry.name);
5479
+ if (entry.isDirectory()) {
5480
+ walk(full);
5481
+ } else if (entry.isFile()) {
5482
+ const rel = path9.relative(dir, full);
5483
+ if (fs7.statSync(full).size > MAX_FILE_SIZE2) {
5484
+ onSkip?.(rel);
5485
+ continue;
5486
+ }
5487
+ results.push({ path: rel, content: fs7.readFileSync(full).toString("base64") });
5488
+ }
5489
+ }
5490
+ };
5491
+ walk(dir);
5492
+ return results;
5493
+ }
5494
+ function appendSuffix(base, suffix) {
5495
+ const maxBase = 64 - suffix.length - 1;
5496
+ const trimmed = base.length > maxBase ? base.slice(0, maxBase).replace(/-+$/, "") : base;
5497
+ return `${trimmed}-${suffix}`;
5498
+ }
5499
+ function randomSlugSuffix() {
5500
+ let s = "";
5501
+ const bytes = crypto.getRandomValues(new Uint8Array(4));
5502
+ for (const b of bytes) s += (b % 36).toString(36);
5503
+ return s;
5504
+ }
5505
+ function buildPublishBody(opts) {
5506
+ return {
5507
+ slug: opts.slug,
5508
+ ...opts.projectName ? { projectName: opts.projectName } : {},
5509
+ files: opts.files
5510
+ };
5511
+ }
5512
+
5513
+ // src/cli/publish.ts
5514
+ var MAX_SLUG_ATTEMPTS = 5;
5515
+ async function publishLocalDir(opts) {
5516
+ const { projectDir, slugOverride, json } = opts;
5517
+ if (!isOrgMode()) {
5518
+ fail(json, "Publishing requires an organisation login. Run `ooda` and log in first.");
5519
+ return;
5520
+ }
5521
+ const orgId = getOrgId();
5522
+ const jwt = getAccessToken();
5523
+ if (!orgId || !jwt) {
5524
+ fail(json, "No active org session. Run `ooda` and log in first.");
5525
+ return;
5526
+ }
5527
+ const apiBase = process.env.OODA_API_BASE || "https://api.ooda.run";
5528
+ const auth = { apiBase, orgId, jwt };
5529
+ const outputDir = detectBuildDir(projectDir);
5530
+ if (!outputDir) {
5531
+ fail(
5532
+ json,
5533
+ `No build output found in ${projectDir}. Run your build command first.
5534
+ Looked for: ${BUILD_DIR_CANDIDATES.join(", ")}`
5535
+ );
5536
+ return;
5537
+ }
5538
+ const config = loadConfig(projectDir);
5539
+ const explicit = Boolean(slugOverride && slugOverride.trim());
5540
+ const persisted = !explicit && Boolean(config.slug);
5541
+ const base = deriveSlug(
5542
+ explicit ? slugOverride : config.slug || config.name || path10.basename(projectDir)
5543
+ );
5544
+ const allowSuffix = !explicit && !persisted;
5545
+ const skipped = [];
5546
+ const files = collectFiles2(outputDir, (rel) => skipped.push(rel));
5547
+ if (files.length === 0) {
5548
+ fail(json, `No files found in ${outputDir}.`);
5549
+ return;
5550
+ }
5551
+ let candidate = base;
5552
+ if (allowSuffix) {
5553
+ try {
5554
+ const taken = new Set((await listSites(auth)).map((s) => s.slug));
5555
+ if (taken.has(candidate)) candidate = appendSuffix(base, randomSlugSuffix());
5556
+ } catch {
5557
+ }
5558
+ }
5559
+ if (!json) {
5560
+ console.log(` ${c.gray}Build output: ${outputDir}${c.reset}`);
5561
+ for (const rel of skipped) console.log(` ${c.yellow}!${c.reset} ${c.gray}Skipped large file: ${rel}${c.reset}`);
5562
+ }
5563
+ let result = null;
5564
+ let finalSlug = candidate;
5565
+ let lastClashError = "";
5566
+ for (let attempt = 0; attempt < (allowSuffix ? MAX_SLUG_ATTEMPTS : 1); attempt++) {
5567
+ if (!json) console.log(` ${c.gray}Publishing ${files.length} files as ${c.reset}${c.bold}${candidate}${c.reset}${c.gray}...${c.reset}`);
5568
+ const { status, data } = await postPublish(apiBase, orgId, jwt, candidate, files);
5569
+ if (status >= 200 && status < 300 && data.ok) {
5570
+ result = data;
5571
+ finalSlug = candidate;
5572
+ break;
5573
+ }
5574
+ if (status === 403 && allowSuffix) {
5575
+ lastClashError = data.error || "slug taken";
5576
+ candidate = appendSuffix(base, randomSlugSuffix());
5577
+ continue;
5578
+ }
5579
+ if (status === 403) {
5580
+ fail(json, `Slug "${candidate}" is taken by another organisation. Choose another with --slug.`);
5581
+ return;
5582
+ }
5583
+ fail(json, data.error || `Publish failed (${status})`);
5584
+ return;
5585
+ }
5586
+ if (!result) {
5587
+ fail(json, `Couldn't find a free slug after ${MAX_SLUG_ATTEMPTS} attempts (last: ${lastClashError}).`);
5588
+ return;
5589
+ }
5590
+ const wrote = persistSlug(projectDir, finalSlug);
5591
+ const suffixed = finalSlug !== base;
5592
+ const publicUrl = result.url ? toPublicUrl(result.url) : "";
5593
+ if (json) {
5594
+ console.log(JSON.stringify({ ...result, slug: finalSlug, url: publicUrl || result.url, savedToConfig: wrote }, null, 2));
5595
+ return;
5596
+ }
5597
+ console.log(`
5598
+ ${c.green}${c.bold}\u2713${c.reset} Published`);
5599
+ console.log(` ${c.cyan}${publicUrl}${c.reset}`);
5600
+ if (result.version !== void 0) console.log(` ${c.gray}Version ${result.version} \xB7 ${result.fileCount} files \xB7 ${((result.totalSize ?? 0) / 1024).toFixed(1)}KB${c.reset}`);
5601
+ if (suffixed) console.log(` ${c.gray}Slug auto-suffixed to avoid a collision.${c.reset}`);
5602
+ if (wrote) console.log(` ${c.gray}Saved slug to ooda.json so re-publishes reuse this URL.${c.reset}`);
5603
+ console.log("");
5604
+ }
5605
+ async function postPublish(apiBase, orgId, jwt, slug, files) {
5606
+ try {
5607
+ const res = await fetch(`${apiBase}/org/${encodeURIComponent(orgId)}/publish`, {
5608
+ method: "POST",
5609
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${jwt}` },
5610
+ body: JSON.stringify(buildPublishBody({ slug, files }))
5611
+ });
5612
+ const data = await res.json().catch(() => ({}));
5613
+ return { status: res.status, data };
5614
+ } catch (err) {
5615
+ return { status: 0, data: { error: `Could not reach the server: ${err instanceof Error ? err.message : String(err)}` } };
5616
+ }
5617
+ }
5618
+ function persistSlug(projectDir, slug) {
5619
+ const p = path10.join(projectDir, "ooda.json");
5620
+ let obj = {};
5621
+ if (fs8.existsSync(p)) {
5622
+ try {
5623
+ const parsed = JSON.parse(fs8.readFileSync(p, "utf-8"));
5624
+ if (parsed && typeof parsed === "object") obj = parsed;
5625
+ else return false;
5626
+ } catch {
5627
+ return false;
5628
+ }
5629
+ }
5630
+ if (obj.slug === slug) return false;
5631
+ obj.slug = slug;
5632
+ fs8.writeFileSync(p, JSON.stringify(obj, null, 2) + "\n");
5633
+ return true;
5634
+ }
5635
+ function fail(json, message) {
5636
+ if (json) {
5637
+ console.log(JSON.stringify({ ok: false, error: message }));
5638
+ } else {
5639
+ console.log(`
5640
+ ${c.red}${message}${c.reset}
5641
+ `);
5642
+ }
5643
+ process.exitCode = 1;
5644
+ }
5645
+
5646
+ // src/cli/sites.ts
5647
+ var ACCESS_MODES = ["public", "password", "login"];
5648
+ async function runSitesCommand(args) {
5649
+ if (!isOrgMode()) return fail2(args.json, "Managing sites requires an organisation login. Run `ooda` and log in first.");
5650
+ const orgId = getOrgId();
5651
+ const jwt = getAccessToken();
5652
+ if (!orgId || !jwt) return fail2(args.json, "No active org session. Run `ooda` and log in first.");
5653
+ const apiBase = process.env.OODA_API_BASE || "https://api.ooda.run";
5654
+ const auth = { apiBase, orgId, jwt };
5655
+ const sub = args.subcommand || "list";
5656
+ try {
5657
+ switch (sub) {
5658
+ case "list":
5659
+ return await listCmd(auth, args.json);
5660
+ case "access":
5661
+ return await accessCmd(auth, args);
5662
+ case "password":
5663
+ return await passwordCmd(auth, args);
5664
+ case "delete":
5665
+ case "unpublish":
5666
+ return await deleteCmd(auth, args);
5667
+ default:
5668
+ return fail2(args.json, `Unknown sites subcommand: ${sub}. Use list | access | password | delete.`);
5669
+ }
5670
+ } catch (err) {
5671
+ if (err instanceof SitesApiError) return fail2(args.json, err.message);
5672
+ return fail2(args.json, err instanceof Error ? err.message : String(err));
5673
+ }
5674
+ }
5675
+ async function listCmd(auth, json) {
5676
+ const sites = await listSites(auth);
5677
+ if (json) {
5678
+ console.log(JSON.stringify(sites.map((s) => ({ ...s, url: toPublicUrl(s.url) })), null, 2));
5679
+ return;
5680
+ }
5681
+ if (sites.length === 0) {
5682
+ console.log(` ${c.gray}No published sites.${c.reset}
5683
+ `);
5684
+ return;
5685
+ }
5686
+ console.log("");
5687
+ for (const s of sites) {
5688
+ const own = s.isOwn ? `${c.green}*${c.reset}` : " ";
5689
+ console.log(` ${own} ${c.bold}${s.slug}${c.reset} ${c.gray}${s.effectiveMode}${c.reset}`);
5690
+ console.log(` ${c.cyan}${toPublicUrl(s.url)}${c.reset} ${c.gray}${s.publishedByName}${c.reset}`);
5691
+ }
5692
+ console.log("");
5693
+ }
5694
+ async function accessCmd(auth, args) {
5695
+ if (!args.slug) return fail2(args.json, "Usage: ooda sites access <slug> --mode <public|password|login>");
5696
+ const body = {};
5697
+ if (args.clearMode) {
5698
+ body.accessMode = null;
5699
+ } else if (args.mode !== void 0) {
5700
+ if (!ACCESS_MODES.includes(args.mode)) {
5701
+ return fail2(args.json, `Invalid --mode. Must be one of: ${ACCESS_MODES.join(", ")}`);
5702
+ }
5703
+ body.accessMode = args.mode;
5704
+ }
5705
+ if (args.clearPassword) {
5706
+ body.password = null;
5707
+ } else if (args.password !== void 0) {
5708
+ body.password = args.password;
5709
+ }
5710
+ if (Object.keys(body).length === 0) {
5711
+ return fail2(args.json, "Nothing to change. Pass --mode, --password, --clear-password, or --clear-mode.");
5712
+ }
5713
+ const result = await updateSiteAccess(auth, args.slug, body);
5714
+ if (args.json) {
5715
+ console.log(JSON.stringify(result, null, 2));
5716
+ return;
5717
+ }
5718
+ console.log(`
5719
+ ${c.green}${c.bold}\u2713${c.reset} ${c.bold}${args.slug}${c.reset} \u2192 ${c.cyan}${result.effectiveMode}${c.reset}${c.gray}${result.accessMode === null ? " (inherits org default)" : ""}${c.reset}
5720
+ `);
5721
+ }
5722
+ async function passwordCmd(auth, args) {
5723
+ if (!args.slug) return fail2(args.json, "Usage: ooda sites password <slug>");
5724
+ const result = await revealSitePassword(auth, args.slug);
5725
+ if (args.json) {
5726
+ console.log(JSON.stringify(result, null, 2));
5727
+ return;
5728
+ }
5729
+ if (!result.password) {
5730
+ console.log(`
5731
+ ${c.gray}No password set for ${args.slug} (not password-protected).${c.reset}
5732
+ `);
5733
+ return;
5734
+ }
5735
+ console.log(`
5736
+ ${c.bold}${args.slug}${c.reset} password: ${c.cyan}${result.password}${c.reset} ${c.gray}(source: ${result.source})${c.reset}
5737
+ `);
5738
+ }
5739
+ async function deleteCmd(auth, args) {
5740
+ if (!args.slug) return fail2(args.json, "Usage: ooda sites delete <slug>");
5741
+ await deleteSite(auth, args.slug);
5742
+ if (args.json) {
5743
+ console.log(JSON.stringify({ ok: true, slug: args.slug }));
5744
+ return;
5745
+ }
5746
+ console.log(`
5747
+ ${c.green}${c.bold}\u2713${c.reset} Unpublished ${c.bold}${args.slug}${c.reset}
5748
+ `);
5749
+ }
5750
+ function fail2(json, message) {
5751
+ if (json) {
5752
+ console.log(JSON.stringify({ ok: false, error: message }));
5753
+ } else {
5754
+ console.log(`
5755
+ ${c.red}${message}${c.reset}
5756
+ `);
5757
+ }
5758
+ process.exitCode = 1;
5759
+ }
5760
+
5761
+ // src/cli/help.ts
5762
+ function buildHelpText(version) {
5763
+ return `ooda v${version} \u2014 Cloud dev environments for Claude Code
5764
+
5765
+ Usage:
5766
+ ooda [command] [options]
5767
+
5768
+ Commands:
5769
+ (no command) Open the interactive project menu
5770
+ list, ls List your projects
5771
+ connect <project> Connect to a project and run Claude (interactive)
5772
+ deploy [path|github-url] Deploy a local folder or GitHub repo as a project
5773
+ publish [path] Publish a built static site to {slug}-p.ooda.run
5774
+ sites [list] List your org's published sites
5775
+ sites access <slug> Change a published site's access policy
5776
+ sites password <slug> Reveal a site's effective password
5777
+ sites delete <slug> Unpublish a site
5778
+ help, --help, -h Show this help
5779
+
5780
+ publish [path]
5781
+ Publishes an already-built static site (no build is run). Looks for a build
5782
+ output dir (dist, build, out, .output/public, .next/static) in [path] (default:
5783
+ current dir). Requires an org login.
5784
+ --slug <slug> Override the slug (default: ooda.json name, else folder name)
5785
+ --json Machine-readable JSON output
5786
+
5787
+ sites access <slug>
5788
+ --mode <public|password|login> Set the access mode
5789
+ --password <pw> Set a per-site password (use with --mode password)
5790
+ --clear-password Remove the per-site password
5791
+ --clear-mode Clear the override (inherit the org default)
5792
+ --json Machine-readable JSON output
5793
+
5794
+ sites list | password | delete
5795
+ --json Machine-readable JSON output
5796
+
5797
+ Global:
5798
+ --port <n> Dashboard port for the interactive menu (default 4444)
5799
+
5800
+ Headless auth (for agents/CI):
5801
+ Set OODA_ACCESS_TOKEN and OODA_ORG_ID to skip interactive login. publish and
5802
+ sites then run fully non-interactively and exit 0 on success, non-zero on error.
5803
+
5804
+ Local dev:
5805
+ Set OODA_API_BASE (server) and OODA_LOADER_BASE (loader) to target a local
5806
+ stack instead of production.
5807
+
5808
+ Examples:
5809
+ ooda publish ./dist --slug my-app
5810
+ ooda sites list --json
5811
+ ooda sites access my-app --mode password --password hunter2
5812
+ ooda sites delete my-app --json`;
5813
+ }
5814
+
5375
5815
  // src/cli/index.ts
5376
- var CLI_VERSION = "0.1.11";
5816
+ var CLI_VERSION = "0.1.14";
5377
5817
  function formatMutationError(result) {
5378
5818
  const parts = [];
5379
5819
  if (result.status !== void 0) parts.push(String(result.status));
@@ -5411,27 +5851,59 @@ function waitForEventOrCancel(emitter, event) {
5411
5851
  }
5412
5852
  var DEFAULT_PORT = 4444;
5413
5853
  function parseArgs(argv) {
5414
- let port = DEFAULT_PORT;
5854
+ const rest = argv.slice(2);
5855
+ const first = rest[0];
5415
5856
  let command = "menu";
5416
- let connectTarget;
5417
- let deployTarget;
5418
- const firstArg = argv[2];
5419
- if (firstArg === "connect") {
5420
- command = "connect";
5421
- connectTarget = argv[3];
5422
- } else if (firstArg === "list" || firstArg === "ls") {
5423
- command = "list";
5424
- } else if (firstArg === "deploy") {
5425
- command = "deploy";
5426
- deployTarget = argv[3];
5427
- }
5428
- for (let i = 2; i < argv.length; i++) {
5429
- if (argv[i] === "--port" && argv[i + 1]) {
5430
- port = parseInt(argv[i + 1], 10) || DEFAULT_PORT;
5857
+ if (first === "connect") command = "connect";
5858
+ else if (first === "list" || first === "ls") command = "list";
5859
+ else if (first === "deploy") command = "deploy";
5860
+ else if (first === "publish") command = "publish";
5861
+ else if (first === "sites") command = "sites";
5862
+ else if (first === "help") command = "help";
5863
+ if (rest.includes("--help") || rest.includes("-h")) command = "help";
5864
+ const positionals = [];
5865
+ const flags = {};
5866
+ let port = DEFAULT_PORT;
5867
+ const startIdx = command === "menu" ? 0 : 1;
5868
+ for (let i = startIdx; i < rest.length; i++) {
5869
+ const arg = rest[i];
5870
+ if (arg === "--port" && rest[i + 1]) {
5871
+ port = parseInt(rest[i + 1], 10) || DEFAULT_PORT;
5431
5872
  i++;
5432
- }
5873
+ } else if (arg === "--slug" && rest[i + 1]) {
5874
+ flags.slug = rest[i + 1];
5875
+ i++;
5876
+ } else if (arg === "--mode" && rest[i + 1]) {
5877
+ flags.mode = rest[i + 1];
5878
+ i++;
5879
+ } else if (arg === "--password" && rest[i + 1] !== void 0) {
5880
+ flags.password = rest[i + 1];
5881
+ i++;
5882
+ } else if (arg === "--clear-password") flags.clearPassword = true;
5883
+ else if (arg === "--clear-mode") flags.clearMode = true;
5884
+ else if (arg === "--json") flags.json = true;
5885
+ else if (!arg.startsWith("--")) positionals.push(arg);
5886
+ }
5887
+ const args = { port, command, json: Boolean(flags.json) };
5888
+ if (command === "connect") {
5889
+ args.connectTarget = positionals[0];
5890
+ } else if (command === "deploy") {
5891
+ args.deployTarget = positionals[0];
5892
+ } else if (command === "publish") {
5893
+ args.publishTarget = positionals[0];
5894
+ args.publishSlug = flags.slug;
5895
+ } else if (command === "sites") {
5896
+ args.sites = {
5897
+ subcommand: positionals[0],
5898
+ slug: positionals[1],
5899
+ mode: flags.mode,
5900
+ password: flags.password,
5901
+ clearPassword: Boolean(flags.clearPassword),
5902
+ clearMode: Boolean(flags.clearMode),
5903
+ json: Boolean(flags.json)
5904
+ };
5433
5905
  }
5434
- return { port, command, connectTarget, deployTarget };
5906
+ return args;
5435
5907
  }
5436
5908
  function eraseRenderedPrompt(visibleLines) {
5437
5909
  const total = visibleLines + 2;
@@ -5560,14 +6032,24 @@ async function selectorLoop(apiToken, claudeToken, dashboardUrl, cwd) {
5560
6032
  const project = action.project;
5561
6033
  const currentUserName = isOrgMode() ? getOrgDisplayName() : null;
5562
6034
  const isOwner = !isOrgMode() || !project.owner || project.owner === currentUserName;
5563
- const subAction = await projectSubMenu(project, isOwner);
6035
+ let publishedUrl = null;
6036
+ const subOrgId = isOrgMode() ? getOrgId() : null;
6037
+ if (subOrgId) {
6038
+ const apiBase = process.env.OODA_API_BASE || "https://api.ooda.run";
6039
+ publishedUrl = await fetchPublishedSiteUrl({
6040
+ apiBase,
6041
+ orgId: subOrgId,
6042
+ projectName: project.name,
6043
+ jwt: getAccessToken() ?? ""
6044
+ });
6045
+ }
6046
+ const subAction = await projectSubMenu(project, isOwner, publishedUrl);
5564
6047
  switch (subAction) {
5565
6048
  case "connect":
5566
6049
  await connectAndRunClaude(project.name, apiToken, claudeToken, project.url, void 0, project.previewUrl);
5567
6050
  break;
5568
6051
  case "open-published": {
5569
- const publishUrl = `https://${project.name}-p.ooda.run`;
5570
- await openUrl(publishUrl);
6052
+ await openUrl(publishedUrl ?? `https://${project.name}-p.ooda.run`);
5571
6053
  break;
5572
6054
  }
5573
6055
  case "reupload": {
@@ -5841,6 +6323,10 @@ async function main() {
5841
6323
  const args = parseArgs(process.argv);
5842
6324
  const cwd = process.cwd();
5843
6325
  printLogo(CLI_VERSION);
6326
+ if (args.command === "help") {
6327
+ console.log(buildHelpText(CLI_VERSION));
6328
+ process.exit(0);
6329
+ }
5844
6330
  if (args.command === "list") {
5845
6331
  await listProjectsCmd();
5846
6332
  process.exit(0);
@@ -5851,11 +6337,22 @@ async function main() {
5851
6337
  if (target && isGitHubTarget(target)) {
5852
6338
  await deployFromGitHubFlow(target, apiToken, claudeToken);
5853
6339
  } else {
5854
- const deployPath = target ? path9.resolve(target) : cwd;
6340
+ const deployPath = target ? path11.resolve(target) : cwd;
5855
6341
  await deployCurrentDir(deployPath, apiToken, claudeToken);
5856
6342
  }
5857
6343
  process.exit(0);
5858
6344
  }
6345
+ if (args.command === "publish") {
6346
+ await ensureAuth({ requireClaudeToken: false });
6347
+ const dir = args.publishTarget ? path11.resolve(args.publishTarget) : cwd;
6348
+ await publishLocalDir({ projectDir: dir, slugOverride: args.publishSlug, json: args.json });
6349
+ process.exit(process.exitCode ?? 0);
6350
+ }
6351
+ if (args.command === "sites") {
6352
+ await ensureAuth({ requireClaudeToken: false });
6353
+ await runSitesCommand(args.sites ?? {});
6354
+ process.exit(process.exitCode ?? 0);
6355
+ }
5859
6356
  if (args.command === "connect") {
5860
6357
  if (!args.connectTarget) {
5861
6358
  console.log(` ${c.red}Usage: ooda connect <project-name>${c.reset}`);