@abitat_reece/cli 0.1.5 → 0.1.7

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,11 +1,11 @@
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 uses `workspace.abitat.io` only as an encrypted relay and does not require a hosted database.
4
4
 
5
5
  ## Install
6
6
 
7
7
  ```sh
8
- brew tap Abitat/abitat
8
+ brew tap AbitatDoorothy/abitat
9
9
  brew install abitat
10
10
  ```
11
11
 
@@ -27,4 +27,14 @@ npm install -g @abitat_reece/cli
27
27
  abitat iphone
28
28
  ```
29
29
 
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.
30
+ The command starts the packaged host daemon in local-control mode, connects the Mac outbound to the `workspace.abitat.io` relay, prints a QR/manual pairing payload with a relay id, and bridges encrypted iPhone requests to the Mac's Codex app-server. The iPhone only needs the Abitat app.
31
+
32
+ Other endpoint modes are available when you want them:
33
+
34
+ ```sh
35
+ abitat iphone --transport local
36
+ abitat iphone --transport tailscale
37
+ abitat iphone --transport temporary-tunnel
38
+ abitat iphone --transport quick-tunnel
39
+ abitat iphone --transport manual --endpoint https://your-endpoint.example
40
+ ```
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,26 @@ 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
+ relayEndpoint: readOption(args, "--relay-endpoint") ?? env.ABITAT_RELAY_ENDPOINT,
59
+ transport: transportOption(readOption(args, "--transport") ?? "relay")
67
60
  });
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
- }
61
+ output("Starting local-first iPhone control on this Mac.");
62
+ output("The Mac will print a QR/manual pairing payload. No hosted domain or database is used.");
63
+ for (const process of startupPlan) {
74
64
  (input.startProcess ?? startProcess)(process);
75
65
  }
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
66
  return 0;
80
67
  }
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
68
  function startProcess(process) {
110
69
  const resolved = resolveStartupProcess(process);
111
70
  spawn(resolved.command, resolved.args, {
@@ -114,7 +73,7 @@ function startProcess(process) {
114
73
  });
115
74
  }
116
75
  export function resolveStartupProcess(process, options = {}) {
117
- if (process.name !== "host-daemon") {
76
+ if (process.command !== "abitat-host") {
118
77
  return process;
119
78
  }
120
79
  try {
@@ -138,6 +97,30 @@ function openUrlInBrowser(url) {
138
97
  });
139
98
  child.unref();
140
99
  }
100
+ function readOption(args, option) {
101
+ const index = args.indexOf(option);
102
+ return index >= 0 ? args[index + 1] : undefined;
103
+ }
104
+ function numberOption(args, option, fallback) {
105
+ const value = readOption(args, option);
106
+ if (!value) {
107
+ return fallback;
108
+ }
109
+ const parsed = Number(value);
110
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
111
+ }
112
+ function transportOption(value) {
113
+ if (value === "auto" ||
114
+ value === "local" ||
115
+ value === "tailscale" ||
116
+ value === "relay" ||
117
+ value === "temporary-tunnel" ||
118
+ value === "quick-tunnel" ||
119
+ value === "manual") {
120
+ return value;
121
+ }
122
+ return "auto";
123
+ }
141
124
  export function isCliEntrypoint(importMetaUrl, argvPath = process.argv[1]) {
142
125
  if (!argvPath) {
143
126
  return false;
package/dist/iphone.js CHANGED
@@ -1,102 +1,19 @@
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.relayEndpoint ? ["--relay-endpoint", input.relayEndpoint] : []),
15
+ ...(input.endpoint ? ["--endpoint", input.endpoint] : [])
16
+ ]
21
17
  }
22
18
  ];
23
19
  }
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.7",
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.7"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@types/node": "25.6.0",