@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 +13 -3
- package/dist/auth.js +1 -1
- package/dist/index.js +39 -56
- package/dist/iphone.js +12 -95
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
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 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
|
|
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
|
|
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 ?? "
|
|
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 {
|
|
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,26 @@ 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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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.
|
|
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: "
|
|
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.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.
|
|
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.
|
|
18
|
+
"@abitat_reece/host-daemon": "0.1.7"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@types/node": "25.6.0",
|