@abitat_reece/cli 0.1.5 → 0.1.6

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Abitat CLI
2
2
 
3
- `abitat` connects a Mac host to the hosted Abitat Workspace control plane so an iPhone can control Codex off-network. The CLI installs `@abitat_reece/host-daemon` as a dependency and launches it directly.
3
+ `abitat` starts a Mac-local control server so a paired iPhone can control Codex through that Mac. The CLI installs `@abitat_reece/host-daemon` as a dependency and launches it directly; core iPhone control does not require `workspace.abitat.io` or a hosted database.
4
4
 
5
5
  ## Install
6
6
 
@@ -21,10 +21,24 @@ The npm package is also available:
21
21
  npm install -g @abitat_reece/cli
22
22
  ```
23
23
 
24
+ Install the Mac-side tunnel helper:
25
+
26
+ ```sh
27
+ brew install cloudflared
28
+ ```
29
+
24
30
  ## Use
25
31
 
26
32
  ```sh
27
33
  abitat iphone
28
34
  ```
29
35
 
30
- The command opens `https://workspace.abitat.io` for login if needed, registers the Mac to the signed-in account, starts the Codex app server and packaged Abitat host daemon, then opens the dashboard for iPhone pairing.
36
+ The command starts the packaged host daemon in local-control mode, starts a Cloudflare Quick Tunnel from the Mac, prints a QR/manual pairing payload with the generated `trycloudflare.com` URL, and bridges iPhone requests to the Mac's Codex app-server. The iPhone only needs the Abitat app.
37
+
38
+ Other endpoint modes are available when you want them:
39
+
40
+ ```sh
41
+ abitat iphone --transport local
42
+ abitat iphone --transport tailscale
43
+ abitat iphone --transport manual --endpoint https://your-endpoint.example
44
+ ```
package/dist/auth.js CHANGED
@@ -4,7 +4,7 @@ import { setTimeout as delay } from "node:timers/promises";
4
4
  const DEFAULT_START_ATTEMPTS = 4;
5
5
  const DEFAULT_START_RETRY_DELAY_MS = 1000;
6
6
  export function defaultApiUrl(env) {
7
- return env.ABITAT_API_URL ?? "https://workspace.abitat.io";
7
+ return env.ABITAT_API_URL ?? "http://127.0.0.1:3901";
8
8
  }
9
9
  export function sessionConfigPath(homeDir) {
10
10
  return join(homeDir, "Library", "Application Support", "Abitat", "config.json");
package/dist/index.js CHANGED
@@ -1,12 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
3
  import { realpathSync } from "node:fs";
4
- import { connect } from "node:net";
5
- import { homedir, hostname } from "node:os";
4
+ import { homedir } from "node:os";
6
5
  import { resolve } from "node:path";
7
6
  import { fileURLToPath } from "node:url";
8
7
  import { defaultApiUrl, deleteCliSession, loadCliSession, runLoginCommand, sessionConfigPath } from "./auth.js";
9
- import { prepareIphoneCommand } from "./iphone.js";
8
+ import { createIphoneStartupPlan } from "./iphone.js";
10
9
  const DEFAULT_CODEX_APP_SERVER_URL = "ws://127.0.0.1:47777";
11
10
  const HOST_DAEMON_CLI_EXPORT = "@abitat_reece/host-daemon/cli";
12
11
  export function parseCommand(args) {
@@ -19,7 +18,6 @@ export function parseCommand(args) {
19
18
  export async function runCli(args, input = {}) {
20
19
  const parsed = parseCommand(args);
21
20
  const env = input.env ?? process.env;
22
- const isEndpointListening = input.isEndpointListening ?? isTcpEndpointListening;
23
21
  const output = input.output ?? console.log;
24
22
  const openUrl = input.openUrl ?? openUrlInBrowser;
25
23
  const homeDir = input.homeDir ?? homedir();
@@ -47,65 +45,25 @@ export async function runCli(args, input = {}) {
47
45
  }
48
46
  if (parsed.command === "doctor") {
49
47
  const session = await loadCliSession(configPath);
50
- output(session ? `Logged in to ${session.apiUrl}` : "Not logged in. Run `abitat login`.");
48
+ output("Local iPhone control does not require an Abitat hosted login.");
49
+ if (session) {
50
+ output(`Hosted web session: ${session.apiUrl}`);
51
+ }
51
52
  return 0;
52
53
  }
53
- const session = (await loadCliSession(configPath)) ??
54
- (await runLoginCommand({
55
- apiUrl,
56
- configPath,
57
- fetchFn: input.fetchFn,
58
- openUrl,
59
- pollIntervalMs: input.pollIntervalMs
60
- }));
61
- const result = await prepareIphoneCommand({
54
+ const startupPlan = createIphoneStartupPlan({
62
55
  codexServerUrl: env.CODEX_APP_SERVER_URL ?? DEFAULT_CODEX_APP_SERVER_URL,
63
- fetchFn: input.fetchFn,
64
- machineName: env.ABITAT_MACHINE_NAME ?? hostname(),
65
- platform: input.platform ?? process.platform,
66
- session
56
+ endpoint: readOption(args, "--endpoint") ?? env.ABITAT_LOCAL_CONTROL_ENDPOINT,
57
+ port: numberOption(args, "--port", Number(env.ABITAT_LOCAL_CONTROL_PORT ?? 3901)),
58
+ transport: transportOption(readOption(args, "--transport") ?? "quick-tunnel")
67
59
  });
68
- for (const process of result.startupPlan) {
69
- const existingServerUrl = codexAppServerListenUrl(process);
70
- if (existingServerUrl && (await isEndpointListening(existingServerUrl))) {
71
- output(`Using existing Codex app server at ${existingServerUrl}`);
72
- continue;
73
- }
60
+ output("Starting local-first iPhone control on this Mac.");
61
+ output("The Mac will print a QR/manual pairing payload. No hosted domain or database is used.");
62
+ for (const process of startupPlan) {
74
63
  (input.startProcess ?? startProcess)(process);
75
64
  }
76
- openUrl(session.apiUrl);
77
- output(`Mac host registered as ${result.registration.machineId}`);
78
- output(`Open the iPhone app and pair from ${session.apiUrl}`);
79
65
  return 0;
80
66
  }
81
- function codexAppServerListenUrl(process) {
82
- if (process.name !== "codex-app-server") {
83
- return null;
84
- }
85
- const listenIndex = process.args.indexOf("--listen");
86
- return listenIndex >= 0 ? (process.args[listenIndex + 1] ?? null) : null;
87
- }
88
- function isTcpEndpointListening(url, timeoutMs = 250) {
89
- return new Promise((resolve) => {
90
- let settled = false;
91
- const endpoint = new URL(url);
92
- const socket = connect({
93
- host: endpoint.hostname,
94
- port: Number(endpoint.port || (endpoint.protocol === "wss:" ? 443 : 80))
95
- });
96
- const finish = (listening) => {
97
- if (settled) {
98
- return;
99
- }
100
- settled = true;
101
- socket.destroy();
102
- resolve(listening);
103
- };
104
- socket.setTimeout(timeoutMs, () => finish(false));
105
- socket.on("connect", () => finish(true));
106
- socket.on("error", () => finish(false));
107
- });
108
- }
109
67
  function startProcess(process) {
110
68
  const resolved = resolveStartupProcess(process);
111
69
  spawn(resolved.command, resolved.args, {
@@ -114,7 +72,7 @@ function startProcess(process) {
114
72
  });
115
73
  }
116
74
  export function resolveStartupProcess(process, options = {}) {
117
- if (process.name !== "host-daemon") {
75
+ if (process.command !== "abitat-host") {
118
76
  return process;
119
77
  }
120
78
  try {
@@ -138,6 +96,28 @@ function openUrlInBrowser(url) {
138
96
  });
139
97
  child.unref();
140
98
  }
99
+ function readOption(args, option) {
100
+ const index = args.indexOf(option);
101
+ return index >= 0 ? args[index + 1] : undefined;
102
+ }
103
+ function numberOption(args, option, fallback) {
104
+ const value = readOption(args, option);
105
+ if (!value) {
106
+ return fallback;
107
+ }
108
+ const parsed = Number(value);
109
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
110
+ }
111
+ function transportOption(value) {
112
+ if (value === "auto" ||
113
+ value === "local" ||
114
+ value === "tailscale" ||
115
+ value === "quick-tunnel" ||
116
+ value === "manual") {
117
+ return value;
118
+ }
119
+ return "auto";
120
+ }
141
121
  export function isCliEntrypoint(importMetaUrl, argvPath = process.argv[1]) {
142
122
  if (!argvPath) {
143
123
  return false;
package/dist/iphone.js CHANGED
@@ -1,102 +1,18 @@
1
- import { setTimeout as delay } from "node:timers/promises";
2
- const DEFAULT_HOST_REGISTER_ATTEMPTS = 8;
3
- const DEFAULT_HOST_REGISTER_RETRY_DELAY_MS = 1000;
4
1
  export function createIphoneStartupPlan(input) {
5
2
  return [
6
3
  {
7
- name: "codex-app-server",
8
- command: "codex",
9
- args: ["app-server", "--listen", input.codexServerUrl, "--analytics-default-enabled"]
10
- },
11
- {
12
- name: "host-daemon",
4
+ name: "local-control-server",
13
5
  command: "abitat-host",
14
- args: ["start"],
15
- env: {
16
- ABITAT_API_URL: input.apiUrl,
17
- ABITAT_HOST_TOKEN: input.hostToken,
18
- ABITAT_MACHINE_ID: input.machineId,
19
- CODEX_APP_SERVER_URL: input.codexServerUrl
20
- }
6
+ args: [
7
+ "iphone",
8
+ "--port",
9
+ String(input.port),
10
+ "--transport",
11
+ input.transport,
12
+ "--codex-server-url",
13
+ input.codexServerUrl,
14
+ ...(input.endpoint ? ["--endpoint", input.endpoint] : [])
15
+ ]
21
16
  }
22
17
  ];
23
18
  }
24
- export async function registerHost(input) {
25
- const fetchFn = input.fetchFn ?? fetch;
26
- let lastError;
27
- const maxAttempts = Math.max(1, input.maxAttempts ?? DEFAULT_HOST_REGISTER_ATTEMPTS);
28
- const retryDelayMs = input.retryDelayMs ?? DEFAULT_HOST_REGISTER_RETRY_DELAY_MS;
29
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
30
- try {
31
- const response = await fetchFn(`${trimTrailingSlash(input.apiUrl)}/api/hosts/register`, {
32
- method: "POST",
33
- headers: {
34
- authorization: `Bearer ${input.cliToken}`,
35
- "content-type": "application/json"
36
- },
37
- body: JSON.stringify({
38
- machineName: input.machineName,
39
- platform: input.platform
40
- })
41
- });
42
- if (response.ok) {
43
- return parseHostRegistration(await response.json());
44
- }
45
- const error = new Error(`Unable to register host (${response.status})`);
46
- if (!isTransientHostedStatus(response.status) || attempt >= maxAttempts) {
47
- throw error;
48
- }
49
- lastError = error;
50
- }
51
- catch (error) {
52
- if (!isTransientFetchError(error) || attempt >= maxAttempts) {
53
- throw error;
54
- }
55
- lastError = error;
56
- }
57
- if (retryDelayMs > 0) {
58
- await delay(retryDelayMs);
59
- }
60
- }
61
- throw lastError instanceof Error ? lastError : new Error("Unable to register host");
62
- }
63
- function parseHostRegistration(value) {
64
- const body = value;
65
- if (typeof body.machineId !== "string" ||
66
- typeof body.workspaceId !== "string" ||
67
- typeof body.hostToken !== "string") {
68
- throw new Error("Host registration response was invalid");
69
- }
70
- return {
71
- machineId: body.machineId,
72
- workspaceId: body.workspaceId,
73
- hostToken: body.hostToken
74
- };
75
- }
76
- export async function prepareIphoneCommand(input) {
77
- const registration = await registerHost({
78
- apiUrl: input.session.apiUrl,
79
- cliToken: input.session.cliToken,
80
- fetchFn: input.fetchFn,
81
- machineName: input.machineName,
82
- platform: input.platform
83
- });
84
- return {
85
- registration,
86
- startupPlan: createIphoneStartupPlan({
87
- apiUrl: input.session.apiUrl,
88
- codexServerUrl: input.codexServerUrl,
89
- hostToken: registration.hostToken,
90
- machineId: registration.machineId
91
- })
92
- };
93
- }
94
- function trimTrailingSlash(value) {
95
- return value.replace(/\/+$/u, "");
96
- }
97
- function isTransientHostedStatus(status) {
98
- return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
99
- }
100
- function isTransientFetchError(error) {
101
- return error instanceof TypeError;
102
- }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@abitat_reece/cli",
3
3
  "private": false,
4
- "version": "0.1.5",
4
+ "version": "0.1.6",
5
5
  "description": "Remote Codex control from Mac and iPhone",
6
6
  "type": "module",
7
7
  "bin": {
@@ -15,7 +15,7 @@
15
15
  "node": ">=22"
16
16
  },
17
17
  "dependencies": {
18
- "@abitat_reece/host-daemon": "0.1.5"
18
+ "@abitat_reece/host-daemon": "0.1.6"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@types/node": "25.6.0",