airterm 1.0.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/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/cli.js ADDED
@@ -0,0 +1,504 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.tsx
4
+ import { render } from "ink";
5
+ import meow from "meow";
6
+
7
+ // src/components/App.tsx
8
+ import { useState as useState5 } from "react";
9
+
10
+ // src/lib/config.ts
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, chmodSync } from "fs";
12
+ import { homedir } from "os";
13
+ import { join } from "path";
14
+ var CONFIG_DIR = join(homedir(), ".airterm");
15
+ var CONFIG_FILE = join(CONFIG_DIR, "connections.json");
16
+ var KEYS_DIR = join(CONFIG_DIR, "keys");
17
+ function ensureDir() {
18
+ if (!existsSync(CONFIG_DIR)) {
19
+ mkdirSync(CONFIG_DIR, { recursive: true });
20
+ }
21
+ if (!existsSync(KEYS_DIR)) {
22
+ mkdirSync(KEYS_DIR, { recursive: true });
23
+ }
24
+ }
25
+ function loadConfig() {
26
+ if (!existsSync(CONFIG_FILE)) {
27
+ return { connections: [] };
28
+ }
29
+ try {
30
+ const raw = readFileSync(CONFIG_FILE, "utf-8");
31
+ return JSON.parse(raw);
32
+ } catch {
33
+ return { connections: [] };
34
+ }
35
+ }
36
+ function saveConfig(config) {
37
+ ensureDir();
38
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
39
+ }
40
+ function getConnections() {
41
+ return loadConfig().connections;
42
+ }
43
+ function addConnection(conn) {
44
+ const config = loadConfig();
45
+ config.connections = config.connections.filter((c) => c.id !== conn.id);
46
+ config.connections.push(conn);
47
+ if (config.connections.length === 1) {
48
+ config.defaultConnection = conn.id;
49
+ }
50
+ saveConfig(config);
51
+ }
52
+ function removeConnection(id) {
53
+ const config = loadConfig();
54
+ const conn = config.connections.find((c) => c.id === id);
55
+ if (conn) {
56
+ try {
57
+ if (existsSync(conn.keyPath)) {
58
+ rmSync(conn.keyPath);
59
+ }
60
+ } catch {
61
+ }
62
+ }
63
+ config.connections = config.connections.filter((c) => c.id !== id);
64
+ if (config.defaultConnection === id) {
65
+ config.defaultConnection = config.connections[0]?.id;
66
+ }
67
+ saveConfig(config);
68
+ }
69
+ function saveKey(machineId, keyData) {
70
+ ensureDir();
71
+ const keyPath = join(KEYS_DIR, `${machineId}.key`);
72
+ writeFileSync(keyPath, keyData, { mode: 384 });
73
+ chmodSync(keyPath, 384);
74
+ return keyPath;
75
+ }
76
+ function resetAll() {
77
+ const config = loadConfig();
78
+ const count = config.connections.length;
79
+ if (existsSync(CONFIG_DIR)) {
80
+ rmSync(CONFIG_DIR, { recursive: true, force: true });
81
+ }
82
+ return count;
83
+ }
84
+
85
+ // src/components/Welcome.tsx
86
+ import { useState } from "react";
87
+ import { Box as Box2, Text as Text2 } from "ink";
88
+ import TextInput from "ink-text-input";
89
+
90
+ // src/components/Header.tsx
91
+ import { Box, Text } from "ink";
92
+ import { jsx } from "react/jsx-runtime";
93
+ function Header() {
94
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", marginBottom: 1, children: /* @__PURE__ */ jsx(Box, { borderStyle: "round", paddingX: 2, children: /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: "AirTerm" }) }) });
95
+ }
96
+
97
+ // src/components/Welcome.tsx
98
+ import { jsx as jsx2, jsxs } from "react/jsx-runtime";
99
+ function Welcome({ onSubmitCode }) {
100
+ const [code, setCode] = useState("");
101
+ return /* @__PURE__ */ jsxs(Box2, { flexDirection: "column", children: [
102
+ /* @__PURE__ */ jsx2(Header, {}),
103
+ /* @__PURE__ */ jsx2(Box2, { marginBottom: 1, children: /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "No saved connections." }) }),
104
+ /* @__PURE__ */ jsxs(Box2, { children: [
105
+ /* @__PURE__ */ jsx2(Text2, { children: "Enter your access code: " }),
106
+ /* @__PURE__ */ jsx2(
107
+ TextInput,
108
+ {
109
+ value: code,
110
+ onChange: setCode,
111
+ onSubmit: (value) => {
112
+ const trimmed = value.trim();
113
+ if (trimmed) {
114
+ onSubmitCode(trimmed);
115
+ }
116
+ }
117
+ }
118
+ )
119
+ ] }),
120
+ /* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsxs(Text2, { dimColor: true, children: [
121
+ "Don't have one? Text your AirClaw:",
122
+ "\n",
123
+ "\u2192 sms:+14156058331&body=Give%20me%20terminal%20access"
124
+ ] }) })
125
+ ] });
126
+ }
127
+
128
+ // src/components/AddMachine.tsx
129
+ import { useState as useState2 } from "react";
130
+ import { Box as Box3, Text as Text3 } from "ink";
131
+ import TextInput2 from "ink-text-input";
132
+ import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
133
+ function AddMachine({ onSubmitCode }) {
134
+ const [code, setCode] = useState2("");
135
+ return /* @__PURE__ */ jsxs2(Box3, { flexDirection: "column", children: [
136
+ /* @__PURE__ */ jsx3(Header, {}),
137
+ /* @__PURE__ */ jsx3(Box3, { marginBottom: 1, children: /* @__PURE__ */ jsx3(Text3, { children: "Add a new machine" }) }),
138
+ /* @__PURE__ */ jsxs2(Box3, { children: [
139
+ /* @__PURE__ */ jsx3(Text3, { children: "Enter your access code: " }),
140
+ /* @__PURE__ */ jsx3(
141
+ TextInput2,
142
+ {
143
+ value: code,
144
+ onChange: setCode,
145
+ onSubmit: (value) => {
146
+ const trimmed = value.trim();
147
+ if (trimmed) {
148
+ onSubmitCode(trimmed);
149
+ }
150
+ }
151
+ }
152
+ )
153
+ ] })
154
+ ] });
155
+ }
156
+
157
+ // src/components/SelectMachine.tsx
158
+ import { useState as useState3 } from "react";
159
+ import { Box as Box4, Text as Text4, useApp, useInput } from "ink";
160
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
161
+ function SelectMachine({
162
+ connections,
163
+ onSelect,
164
+ onAddNew,
165
+ onDelete
166
+ }) {
167
+ const [cursor, setCursor] = useState3(0);
168
+ const [confirmDelete, setConfirmDelete] = useState3(null);
169
+ const { exit } = useApp();
170
+ const totalItems = connections.length + 1;
171
+ useInput((input, key) => {
172
+ if (confirmDelete) {
173
+ if (input === "y" || input === "Y") {
174
+ const conn = connections.find((c) => c.id === confirmDelete);
175
+ if (conn) onDelete(conn);
176
+ setConfirmDelete(null);
177
+ } else {
178
+ setConfirmDelete(null);
179
+ }
180
+ return;
181
+ }
182
+ if (key.upArrow) {
183
+ setCursor((prev) => prev > 0 ? prev - 1 : totalItems - 1);
184
+ } else if (key.downArrow) {
185
+ setCursor((prev) => prev < totalItems - 1 ? prev + 1 : 0);
186
+ } else if (key.return) {
187
+ if (cursor < connections.length) {
188
+ onSelect(connections[cursor]);
189
+ } else {
190
+ onAddNew();
191
+ }
192
+ } else if ((input === "d" || input === "x") && cursor < connections.length) {
193
+ setConfirmDelete(connections[cursor].id);
194
+ } else if (input === "q") {
195
+ exit();
196
+ }
197
+ });
198
+ if (confirmDelete) {
199
+ const conn = connections.find((c) => c.id === confirmDelete);
200
+ return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", children: [
201
+ /* @__PURE__ */ jsx4(Header, {}),
202
+ /* @__PURE__ */ jsxs3(Text4, { children: [
203
+ "Delete \u201C",
204
+ /* @__PURE__ */ jsx4(Text4, { bold: true, children: conn?.name }),
205
+ "\u201D? (y/n)"
206
+ ] })
207
+ ] });
208
+ }
209
+ return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", children: [
210
+ /* @__PURE__ */ jsx4(Header, {}),
211
+ /* @__PURE__ */ jsx4(Box4, { marginBottom: 1, children: /* @__PURE__ */ jsx4(Text4, { children: "Select a machine:" }) }),
212
+ connections.map((conn, i) => /* @__PURE__ */ jsx4(Box4, { children: /* @__PURE__ */ jsxs3(Text4, { color: cursor === i ? "cyan" : void 0, children: [
213
+ cursor === i ? "\u203A " : " ",
214
+ /* @__PURE__ */ jsx4(Text4, { bold: cursor === i, children: conn.name }),
215
+ /* @__PURE__ */ jsxs3(Text4, { dimColor: true, children: [
216
+ " (",
217
+ conn.id,
218
+ ")"
219
+ ] }),
220
+ /* @__PURE__ */ jsxs3(Text4, { dimColor: true, children: [
221
+ " ",
222
+ conn.hostname
223
+ ] })
224
+ ] }) }, conn.id)),
225
+ /* @__PURE__ */ jsx4(Box4, { marginTop: 0, children: /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: " \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }) }),
226
+ /* @__PURE__ */ jsx4(Box4, { children: /* @__PURE__ */ jsxs3(Text4, { color: cursor === connections.length ? "cyan" : void 0, children: [
227
+ cursor === connections.length ? "\u203A " : " ",
228
+ /* @__PURE__ */ jsx4(Text4, { bold: cursor === connections.length, children: "+ Add new machine" })
229
+ ] }) }),
230
+ /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "Enter: connect \xB7 d: delete \xB7 q: quit" }) })
231
+ ] });
232
+ }
233
+
234
+ // src/components/Connecting.tsx
235
+ import { useEffect, useState as useState4 } from "react";
236
+ import { Box as Box5, Text as Text5, useApp as useApp2 } from "ink";
237
+ import Spinner from "ink-spinner";
238
+
239
+ // src/lib/api.ts
240
+ var API_BASE = process.env.AIRCLAW_API_URL || "https://app.airclaw.com";
241
+ async function redeemCode(code) {
242
+ const res = await fetch(`${API_BASE}/api/airterm/redeem`, {
243
+ method: "POST",
244
+ headers: { "Content-Type": "application/json" },
245
+ body: JSON.stringify({ code })
246
+ });
247
+ const data = await res.json();
248
+ if (!res.ok) {
249
+ return { error: data.error || `HTTP ${res.status}` };
250
+ }
251
+ return data;
252
+ }
253
+ function isRedeemError(result) {
254
+ return "error" in result;
255
+ }
256
+ async function downloadKey(url) {
257
+ const res = await fetch(url);
258
+ if (!res.ok) {
259
+ throw new Error(`Failed to download key: HTTP ${res.status}`);
260
+ }
261
+ return await res.text();
262
+ }
263
+
264
+ // src/lib/ssh.ts
265
+ import { spawnSync } from "child_process";
266
+ function connectSSH(conn) {
267
+ const result = spawnSync(
268
+ "ssh",
269
+ [
270
+ "-i",
271
+ conn.keyPath,
272
+ "-p",
273
+ String(conn.port),
274
+ "-o",
275
+ "StrictHostKeyChecking=accept-new",
276
+ "-o",
277
+ "UserKnownHostsFile=~/.airterm/known_hosts",
278
+ `${conn.username}@${conn.hostname}`
279
+ ],
280
+ { stdio: "inherit" }
281
+ );
282
+ return result.status ?? 1;
283
+ }
284
+
285
+ // src/components/Connecting.tsx
286
+ import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
287
+ function Connecting({ code, connection, onError }) {
288
+ const [status, setStatus] = useState4(
289
+ code ? "Redeeming access code..." : "Connecting..."
290
+ );
291
+ const { exit } = useApp2();
292
+ useEffect(() => {
293
+ let cancelled = false;
294
+ async function run() {
295
+ let conn = connection;
296
+ if (code && !conn) {
297
+ const result = await redeemCode(code);
298
+ if (cancelled) return;
299
+ if (isRedeemError(result)) {
300
+ onError(result.error);
301
+ return;
302
+ }
303
+ setStatus("Downloading SSH key...");
304
+ let keyData;
305
+ try {
306
+ keyData = await downloadKey(result.keyUrl);
307
+ } catch (err) {
308
+ if (cancelled) return;
309
+ onError(`Failed to download SSH key: ${err}`);
310
+ return;
311
+ }
312
+ if (cancelled) return;
313
+ const keyPath = saveKey(result.machineId, keyData);
314
+ conn = {
315
+ id: result.machineId,
316
+ name: result.machineName,
317
+ hostname: result.hostname,
318
+ port: result.port,
319
+ username: result.username,
320
+ keyPath,
321
+ addedAt: (/* @__PURE__ */ new Date()).toISOString()
322
+ };
323
+ addConnection(conn);
324
+ setStatus(`Connecting to ${conn.name}...`);
325
+ }
326
+ if (!conn) {
327
+ onError("No connection specified");
328
+ return;
329
+ }
330
+ await new Promise((r) => setTimeout(r, 200));
331
+ if (cancelled) return;
332
+ exit();
333
+ const exitCode = connectSSH(conn);
334
+ process.exit(exitCode);
335
+ }
336
+ run().catch((err) => {
337
+ if (!cancelled) {
338
+ onError(String(err));
339
+ }
340
+ });
341
+ return () => {
342
+ cancelled = true;
343
+ };
344
+ }, [code, connection, exit, onError]);
345
+ return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", children: [
346
+ /* @__PURE__ */ jsx5(Header, {}),
347
+ /* @__PURE__ */ jsxs4(Box5, { children: [
348
+ /* @__PURE__ */ jsx5(Text5, { color: "cyan", children: /* @__PURE__ */ jsx5(Spinner, { type: "dots" }) }),
349
+ /* @__PURE__ */ jsxs4(Text5, { children: [
350
+ " ",
351
+ status
352
+ ] })
353
+ ] })
354
+ ] });
355
+ }
356
+
357
+ // src/components/ErrorView.tsx
358
+ import React5 from "react";
359
+ import { Box as Box6, Text as Text6, useApp as useApp3 } from "ink";
360
+ import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
361
+ function ErrorView({ message }) {
362
+ const { exit } = useApp3();
363
+ React5.useEffect(() => {
364
+ const timer = setTimeout(() => exit(), 100);
365
+ return () => clearTimeout(timer);
366
+ }, [exit]);
367
+ return /* @__PURE__ */ jsxs5(Box6, { flexDirection: "column", children: [
368
+ /* @__PURE__ */ jsx6(Header, {}),
369
+ /* @__PURE__ */ jsx6(Box6, { children: /* @__PURE__ */ jsxs5(Text6, { color: "red", children: [
370
+ "Error: ",
371
+ message
372
+ ] }) })
373
+ ] });
374
+ }
375
+
376
+ // src/components/App.tsx
377
+ import { jsx as jsx7 } from "react/jsx-runtime";
378
+ function App({ initialCode: initialCode2, initialScreen: initialScreen2 }) {
379
+ const connections = getConnections();
380
+ let startScreen;
381
+ if (initialCode2) {
382
+ startScreen = { type: "connecting", code: initialCode2 };
383
+ } else if (initialScreen2 === "add") {
384
+ startScreen = { type: "add" };
385
+ } else if (initialScreen2 === "list") {
386
+ startScreen = { type: "select" };
387
+ } else if (connections.length === 0) {
388
+ startScreen = { type: "welcome" };
389
+ } else if (connections.length === 1) {
390
+ startScreen = { type: "connecting", connection: connections[0] };
391
+ } else {
392
+ startScreen = { type: "select" };
393
+ }
394
+ const [screen, setScreen] = useState5(startScreen);
395
+ const handleCode = (code) => {
396
+ setScreen({ type: "connecting", code });
397
+ };
398
+ const handleError = (message) => {
399
+ setScreen({ type: "error", message });
400
+ };
401
+ const handleDelete = (conn) => {
402
+ removeConnection(conn.id);
403
+ const remaining = getConnections();
404
+ if (remaining.length === 0) {
405
+ setScreen({ type: "welcome" });
406
+ } else {
407
+ setScreen({ type: "select" });
408
+ }
409
+ };
410
+ switch (screen.type) {
411
+ case "welcome":
412
+ return /* @__PURE__ */ jsx7(Welcome, { onSubmitCode: handleCode });
413
+ case "add":
414
+ return /* @__PURE__ */ jsx7(AddMachine, { onSubmitCode: handleCode });
415
+ case "select":
416
+ return /* @__PURE__ */ jsx7(
417
+ SelectMachine,
418
+ {
419
+ connections: getConnections(),
420
+ onSelect: (conn) => setScreen({ type: "connecting", connection: conn }),
421
+ onAddNew: () => setScreen({ type: "add" }),
422
+ onDelete: handleDelete
423
+ }
424
+ );
425
+ case "connecting":
426
+ return /* @__PURE__ */ jsx7(
427
+ Connecting,
428
+ {
429
+ code: screen.code,
430
+ connection: screen.connection,
431
+ onError: handleError
432
+ }
433
+ );
434
+ case "error":
435
+ return /* @__PURE__ */ jsx7(ErrorView, { message: screen.message });
436
+ }
437
+ }
438
+
439
+ // src/cli.tsx
440
+ import { jsx as jsx8 } from "react/jsx-runtime";
441
+ var cli = meow(
442
+ `
443
+ AirTerm \u2014 SSH into your AirClaw machine
444
+
445
+ Usage:
446
+ airterm Connect to your machine
447
+ airterm <code> Redeem an access code and connect
448
+ airterm add [code] Add a machine with an access code
449
+ airterm list Manage saved connections
450
+ airterm reset Remove all saved connections and keys
451
+
452
+ Options:
453
+ -h, --help Show this help
454
+ -v, --version Show version
455
+
456
+ First time? Run \`airterm\` and paste the access code from your AirClaw agent.
457
+ `,
458
+ {
459
+ importMeta: import.meta,
460
+ flags: {}
461
+ }
462
+ );
463
+ var command = cli.input[0];
464
+ if (command === "reset") {
465
+ const count = resetAll();
466
+ if (count > 0) {
467
+ console.log(
468
+ `Removed ${count} saved connection${count !== 1 ? "s" : ""} and keys.`
469
+ );
470
+ }
471
+ console.log(
472
+ "AirTerm data wiped. Run `airterm add` to set up again."
473
+ );
474
+ process.exit(0);
475
+ }
476
+ if (command === "help") {
477
+ cli.showHelp(0);
478
+ }
479
+ var initialCode;
480
+ var initialScreen;
481
+ if (command === "add") {
482
+ const code = cli.input[1];
483
+ if (code) {
484
+ initialCode = code;
485
+ } else {
486
+ initialScreen = "add";
487
+ }
488
+ } else if (command === "list") {
489
+ const connections = getConnections();
490
+ if (connections.length === 0) {
491
+ console.log("No saved connections. Run `airterm add` to set up.");
492
+ process.exit(0);
493
+ }
494
+ initialScreen = "list";
495
+ } else if (command && !["add", "list", "help", "reset"].includes(command)) {
496
+ if (/^[A-Za-z0-9_-]{10,}$/.test(command)) {
497
+ initialCode = command;
498
+ } else {
499
+ console.error(`Unknown command: ${command}
500
+ `);
501
+ cli.showHelp(1);
502
+ }
503
+ }
504
+ render(/* @__PURE__ */ jsx8(App, { initialCode, initialScreen }));
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "airterm",
3
+ "version": "1.0.0",
4
+ "description": "SSH into your AirClaw machine",
5
+ "type": "module",
6
+ "bin": {
7
+ "airterm": "./dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsup src/cli.tsx --format esm --dts --outDir dist",
14
+ "dev": "tsup src/cli.tsx --format esm --watch --outDir dist"
15
+ },
16
+ "dependencies": {
17
+ "ink": "^5.1.0",
18
+ "ink-select-input": "^6.0.0",
19
+ "ink-spinner": "^5.0.0",
20
+ "ink-text-input": "^6.0.0",
21
+ "meow": "^13.0.0",
22
+ "react": "^18.3.1"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^22.0.0",
26
+ "@types/react": "^18.3.0",
27
+ "tsup": "^8.0.0",
28
+ "typescript": "^5.7.0"
29
+ }
30
+ }