@abitat_reece/cli 0.1.4 → 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 +16 -2
- package/dist/auth.js +2 -2
- package/dist/index.js +36 -56
- package/dist/iphone.js +11 -95
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Abitat CLI
|
|
2
2
|
|
|
3
|
-
`abitat`
|
|
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
|
|
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 ?? "
|
|
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");
|
|
@@ -111,7 +111,7 @@ async function pollDeviceLogin(apiUrl, deviceLoginId, fetchFn) {
|
|
|
111
111
|
return parsePollResponse(await response.json());
|
|
112
112
|
}
|
|
113
113
|
function isTransientHostedStatus(status) {
|
|
114
|
-
return status === 429 || status === 502 || status === 503 || status === 504;
|
|
114
|
+
return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
|
|
115
115
|
}
|
|
116
116
|
function isTransientFetchError(error) {
|
|
117
117
|
return error instanceof TypeError;
|
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 {
|
|
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 {
|
|
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(
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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.
|
|
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 = 4;
|
|
3
|
-
const DEFAULT_HOST_REGISTER_RETRY_DELAY_MS = 1000;
|
|
4
1
|
export function createIphoneStartupPlan(input) {
|
|
5
2
|
return [
|
|
6
3
|
{
|
|
7
|
-
name: "
|
|
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: [
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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 === 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.
|
|
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.
|
|
18
|
+
"@abitat_reece/host-daemon": "0.1.6"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@types/node": "25.6.0",
|