@abitat_reece/cli 0.1.0

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 ADDED
@@ -0,0 +1,30 @@
1
+ # Abitat CLI
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.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ brew tap Abitat/abitat
9
+ brew install abitat
10
+ ```
11
+
12
+ If the formula is accepted into Homebrew core, this becomes:
13
+
14
+ ```sh
15
+ brew install abitat
16
+ ```
17
+
18
+ The npm package is also available:
19
+
20
+ ```sh
21
+ npm install -g @abitat_reece/cli
22
+ ```
23
+
24
+ ## Use
25
+
26
+ ```sh
27
+ abitat iphone
28
+ ```
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.
package/dist/auth.js ADDED
@@ -0,0 +1,118 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ export function defaultApiUrl(env) {
5
+ return env.ABITAT_API_URL ?? "https://workspace.abitat.io";
6
+ }
7
+ export function sessionConfigPath(homeDir) {
8
+ return join(homeDir, "Library", "Application Support", "Abitat", "config.json");
9
+ }
10
+ export async function saveCliSession(session, configPath) {
11
+ await mkdir(dirname(configPath), { recursive: true });
12
+ await writeFile(configPath, `${JSON.stringify(session, null, 2)}\n`, "utf8");
13
+ }
14
+ export async function loadCliSession(configPath) {
15
+ const raw = await readFile(configPath, "utf8").catch((error) => {
16
+ if (isNotFoundError(error)) {
17
+ return null;
18
+ }
19
+ throw error;
20
+ });
21
+ if (!raw) {
22
+ return null;
23
+ }
24
+ const parsed = JSON.parse(raw);
25
+ if (typeof parsed.apiUrl !== "string" ||
26
+ typeof parsed.cliToken !== "string" ||
27
+ typeof parsed.userId !== "string") {
28
+ return null;
29
+ }
30
+ return {
31
+ apiUrl: parsed.apiUrl,
32
+ cliToken: parsed.cliToken,
33
+ userId: parsed.userId
34
+ };
35
+ }
36
+ export async function deleteCliSession(configPath) {
37
+ await rm(configPath, { force: true });
38
+ }
39
+ export async function runLoginCommand(input) {
40
+ const fetchFn = input.fetchFn ?? fetch;
41
+ const start = await startDeviceLogin(input.apiUrl, fetchFn);
42
+ input.openUrl(new URL(start.verificationPath, input.apiUrl).toString());
43
+ const maxPolls = input.maxPolls ?? 120;
44
+ const pollIntervalMs = input.pollIntervalMs ?? 1000;
45
+ for (let attempt = 0; attempt < maxPolls; attempt += 1) {
46
+ const poll = await pollDeviceLogin(input.apiUrl, start.deviceLoginId, fetchFn);
47
+ if (poll.status === "approved") {
48
+ const session = {
49
+ apiUrl: input.apiUrl,
50
+ cliToken: poll.cliToken,
51
+ userId: poll.userId
52
+ };
53
+ await saveCliSession(session, input.configPath);
54
+ return session;
55
+ }
56
+ if (pollIntervalMs > 0) {
57
+ await delay(pollIntervalMs);
58
+ }
59
+ }
60
+ throw new Error("Timed out waiting for browser login approval");
61
+ }
62
+ async function startDeviceLogin(apiUrl, fetchFn) {
63
+ const response = await fetchFn(`${trimTrailingSlash(apiUrl)}/api/cli/device-login/start`, {
64
+ method: "POST"
65
+ });
66
+ if (!response.ok) {
67
+ throw new Error(`Unable to start CLI login (${response.status})`);
68
+ }
69
+ return parseStartResponse(await response.json());
70
+ }
71
+ async function pollDeviceLogin(apiUrl, deviceLoginId, fetchFn) {
72
+ const response = await fetchFn(`${trimTrailingSlash(apiUrl)}/api/cli/device-login/poll`, {
73
+ method: "POST",
74
+ headers: { "content-type": "application/json" },
75
+ body: JSON.stringify({ deviceLoginId })
76
+ });
77
+ if (!response.ok) {
78
+ throw new Error(`Unable to poll CLI login (${response.status})`);
79
+ }
80
+ return parsePollResponse(await response.json());
81
+ }
82
+ function parseStartResponse(value) {
83
+ const candidate = value;
84
+ if (typeof candidate.code !== "string" ||
85
+ typeof candidate.deviceLoginId !== "string" ||
86
+ typeof candidate.expiresAt !== "string" ||
87
+ typeof candidate.verificationPath !== "string") {
88
+ throw new Error("CLI login start response was invalid");
89
+ }
90
+ return {
91
+ code: candidate.code,
92
+ deviceLoginId: candidate.deviceLoginId,
93
+ expiresAt: candidate.expiresAt,
94
+ verificationPath: candidate.verificationPath
95
+ };
96
+ }
97
+ function parsePollResponse(value) {
98
+ const candidate = value;
99
+ if (candidate.status === "pending") {
100
+ return { status: "pending" };
101
+ }
102
+ if (candidate.status === "approved" &&
103
+ typeof candidate.cliToken === "string" &&
104
+ typeof candidate.userId === "string") {
105
+ return {
106
+ status: "approved",
107
+ cliToken: candidate.cliToken,
108
+ userId: candidate.userId
109
+ };
110
+ }
111
+ throw new Error("CLI login poll response was invalid");
112
+ }
113
+ function trimTrailingSlash(value) {
114
+ return value.replace(/\/+$/u, "");
115
+ }
116
+ function isNotFoundError(error) {
117
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
118
+ }
package/dist/index.js ADDED
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { realpathSync } from "node:fs";
4
+ import { homedir, hostname } from "node:os";
5
+ import { resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { defaultApiUrl, deleteCliSession, loadCliSession, runLoginCommand, sessionConfigPath } from "./auth.js";
8
+ import { prepareIphoneCommand } from "./iphone.js";
9
+ const DEFAULT_CODEX_APP_SERVER_URL = "ws://127.0.0.1:47777";
10
+ const HOST_DAEMON_CLI_EXPORT = "@abitat_reece/host-daemon/cli";
11
+ export function parseCommand(args) {
12
+ const command = args[0];
13
+ if (command === "doctor" || command === "iphone" || command === "login" || command === "logout") {
14
+ return { command };
15
+ }
16
+ return { command: "help" };
17
+ }
18
+ export async function runCli(args, input = {}) {
19
+ const parsed = parseCommand(args);
20
+ const env = input.env ?? process.env;
21
+ const output = input.output ?? console.log;
22
+ const openUrl = input.openUrl ?? openUrlInBrowser;
23
+ const homeDir = input.homeDir ?? homedir();
24
+ const configPath = env.ABITAT_CLI_CONFIG_PATH ?? sessionConfigPath(homeDir);
25
+ const apiUrl = defaultApiUrl(env);
26
+ if (parsed.command === "help") {
27
+ output("Usage: abitat login | abitat iphone | abitat doctor | abitat logout");
28
+ return 0;
29
+ }
30
+ if (parsed.command === "login") {
31
+ const session = await runLoginCommand({
32
+ apiUrl,
33
+ configPath,
34
+ fetchFn: input.fetchFn,
35
+ openUrl,
36
+ pollIntervalMs: input.pollIntervalMs
37
+ });
38
+ output(`Logged in to ${session.apiUrl}`);
39
+ return 0;
40
+ }
41
+ if (parsed.command === "logout") {
42
+ await deleteCliSession(configPath);
43
+ output("Logged out");
44
+ return 0;
45
+ }
46
+ if (parsed.command === "doctor") {
47
+ const session = await loadCliSession(configPath);
48
+ output(session ? `Logged in to ${session.apiUrl}` : "Not logged in. Run `abitat login`.");
49
+ return 0;
50
+ }
51
+ const session = (await loadCliSession(configPath)) ??
52
+ (await runLoginCommand({
53
+ apiUrl,
54
+ configPath,
55
+ fetchFn: input.fetchFn,
56
+ openUrl,
57
+ pollIntervalMs: input.pollIntervalMs
58
+ }));
59
+ const result = await prepareIphoneCommand({
60
+ codexServerUrl: env.CODEX_APP_SERVER_URL ?? DEFAULT_CODEX_APP_SERVER_URL,
61
+ fetchFn: input.fetchFn,
62
+ machineName: env.ABITAT_MACHINE_NAME ?? hostname(),
63
+ platform: input.platform ?? process.platform,
64
+ session
65
+ });
66
+ for (const process of result.startupPlan) {
67
+ (input.startProcess ?? startProcess)(process);
68
+ }
69
+ openUrl(session.apiUrl);
70
+ output(`Mac host registered as ${result.registration.machineId}`);
71
+ output(`Open the iPhone app and pair from ${session.apiUrl}`);
72
+ return 0;
73
+ }
74
+ function startProcess(process) {
75
+ const resolved = resolveStartupProcess(process);
76
+ spawn(resolved.command, resolved.args, {
77
+ env: { ...globalThis.process.env, ...resolved.env },
78
+ stdio: "inherit"
79
+ });
80
+ }
81
+ export function resolveStartupProcess(process, options = {}) {
82
+ if (process.name !== "host-daemon") {
83
+ return process;
84
+ }
85
+ try {
86
+ const resolvePackageExport = options.resolvePackageExport ?? ((specifier) => import.meta.resolve(specifier));
87
+ const daemonUrl = resolvePackageExport(HOST_DAEMON_CLI_EXPORT);
88
+ return {
89
+ ...process,
90
+ command: options.nodePath ?? globalThis.process.execPath,
91
+ args: [fileURLToPath(daemonUrl), ...process.args]
92
+ };
93
+ }
94
+ catch {
95
+ return process;
96
+ }
97
+ }
98
+ function openUrlInBrowser(url) {
99
+ const command = process.platform === "darwin" ? "open" : "xdg-open";
100
+ const child = spawn(command, [url], {
101
+ detached: true,
102
+ stdio: "ignore"
103
+ });
104
+ child.unref();
105
+ }
106
+ export function isCliEntrypoint(importMetaUrl, argvPath = process.argv[1]) {
107
+ if (!argvPath) {
108
+ return false;
109
+ }
110
+ const modulePath = resolve(fileURLToPath(importMetaUrl));
111
+ const invokedPath = resolve(argvPath);
112
+ if (modulePath === invokedPath) {
113
+ return true;
114
+ }
115
+ try {
116
+ return realpathSync(modulePath) === realpathSync(invokedPath);
117
+ }
118
+ catch {
119
+ return false;
120
+ }
121
+ }
122
+ if (isCliEntrypoint(import.meta.url)) {
123
+ runCli(process.argv.slice(2)).catch((error) => {
124
+ console.error(error instanceof Error ? error.message : error);
125
+ process.exitCode = 1;
126
+ });
127
+ }
package/dist/iphone.js ADDED
@@ -0,0 +1,68 @@
1
+ export function createIphoneStartupPlan(input) {
2
+ return [
3
+ {
4
+ name: "codex-app-server",
5
+ command: "codex",
6
+ args: ["app-server", "--listen", input.codexServerUrl, "--analytics-default-enabled"]
7
+ },
8
+ {
9
+ name: "host-daemon",
10
+ command: "abitat-host",
11
+ args: ["start"],
12
+ env: {
13
+ ABITAT_API_URL: input.apiUrl,
14
+ ABITAT_HOST_TOKEN: input.hostToken,
15
+ ABITAT_MACHINE_ID: input.machineId
16
+ }
17
+ }
18
+ ];
19
+ }
20
+ export async function registerHost(input) {
21
+ const fetchFn = input.fetchFn ?? fetch;
22
+ const response = await fetchFn(`${trimTrailingSlash(input.apiUrl)}/api/hosts/register`, {
23
+ method: "POST",
24
+ headers: {
25
+ authorization: `Bearer ${input.cliToken}`,
26
+ "content-type": "application/json"
27
+ },
28
+ body: JSON.stringify({
29
+ machineName: input.machineName,
30
+ platform: input.platform
31
+ })
32
+ });
33
+ if (!response.ok) {
34
+ throw new Error(`Unable to register host (${response.status})`);
35
+ }
36
+ const body = (await response.json());
37
+ if (typeof body.machineId !== "string" ||
38
+ typeof body.workspaceId !== "string" ||
39
+ typeof body.hostToken !== "string") {
40
+ throw new Error("Host registration response was invalid");
41
+ }
42
+ return {
43
+ machineId: body.machineId,
44
+ workspaceId: body.workspaceId,
45
+ hostToken: body.hostToken
46
+ };
47
+ }
48
+ export async function prepareIphoneCommand(input) {
49
+ const registration = await registerHost({
50
+ apiUrl: input.session.apiUrl,
51
+ cliToken: input.session.cliToken,
52
+ fetchFn: input.fetchFn,
53
+ machineName: input.machineName,
54
+ platform: input.platform
55
+ });
56
+ return {
57
+ registration,
58
+ startupPlan: createIphoneStartupPlan({
59
+ apiUrl: input.session.apiUrl,
60
+ codexServerUrl: input.codexServerUrl,
61
+ hostToken: registration.hostToken,
62
+ machineId: registration.machineId
63
+ })
64
+ };
65
+ }
66
+ function trimTrailingSlash(value) {
67
+ return value.replace(/\/+$/u, "");
68
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@abitat_reece/cli",
3
+ "private": false,
4
+ "version": "0.1.0",
5
+ "description": "Remote Codex control from Mac and iPhone",
6
+ "type": "module",
7
+ "bin": {
8
+ "abitat": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=22"
16
+ },
17
+ "dependencies": {
18
+ "@abitat_reece/host-daemon": "0.1.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "25.6.0",
22
+ "tsx": "4.21.0",
23
+ "typescript": "6.0.3",
24
+ "vitest": "4.1.5"
25
+ },
26
+ "scripts": {
27
+ "build": "tsc -p tsconfig.json",
28
+ "dev": "tsx src/index.ts",
29
+ "test": "vitest run",
30
+ "typecheck": "tsc -p tsconfig.json --noEmit"
31
+ }
32
+ }