@gobius/t3ctl 0.2.1 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +46 -14
  2. package/package.json +1 -1
  3. package/t3ctl.mjs +133 -20
package/README.md CHANGED
@@ -19,8 +19,8 @@ npm i -g @gobius/t3ctl
19
19
  # 2. mint a token — run this on the machine hosting T3 Code
20
20
  npx t3 auth session issue --label t3ctl --ttl 30d --token-only
21
21
 
22
- # 3. register that host under a short name of your choosing
23
- t3ctl host add laptop http://localhost:3773 eyJ2Ijox...
22
+ # 3. register that host — t3ctl probes it and names it after the machine
23
+ t3ctl host add http://localhost:3773 eyJ2Ijox...
24
24
 
25
25
  # 4. see everything
26
26
  t3ctl ls
@@ -81,15 +81,41 @@ t3ctl ls --json | jq -r '.projects[].threads[] | select(.status=="running") | .t
81
81
  The JSON shape is `{projects: [{host, id, title, workspaceRoot, threads: [{id,
82
82
  title, branch, status, provider, updatedAt}]}], unreachable: [{host, error}]}`.
83
83
 
84
- ### `t3ctl host add <name> <origin> <token>`
84
+ ### `t3ctl host add <origin> [token] [--name <name>]`
85
85
 
86
- Register (or overwrite) a host. `<name>` is whatever you want to call it locally;
87
- `<origin>` is a scheme + host + optional port, with any trailing slash trimmed.
86
+ Register (or update) a host. `<origin>` is a scheme + host + optional port, with
87
+ any trailing slash trimmed — the scheme is required.
88
+
89
+ Before writing anything, t3ctl fetches the host's environment descriptor from
90
+ `/.well-known/t3/environment`. That endpoint is unauthenticated, so it confirms
91
+ you are pointed at a real T3 Code server *before* a token is involved: a wrong
92
+ origin fails with `not a T3 Code server` instead of a confusing 401 on your first
93
+ `ls`. The descriptor's `environmentId`, `label` and `serverVersion` are stored
94
+ alongside the token.
88
95
 
89
96
  ```sh
90
- t3ctl host add desktop https://studio.tailnet-1234.ts.net eyJ2Ijox...
97
+ t3ctl host add https://studio.tailnet-1234.ts.net eyJ2Ijox...
91
98
  ```
92
99
 
100
+ The host is named after the machine's own label (`SPR-Gobius-D` becomes
101
+ `spr-gobius-d`); pass `--name` to choose your own. Re-running `host add` for an
102
+ origin you already have updates that entry in place and keeps the stored token if
103
+ you don't pass a new one.
104
+
105
+ t3ctl warns you when:
106
+
107
+ - the new host's `serverVersion` differs from your other hosts — the API is not a
108
+ stable public interface, so a version split is worth knowing about
109
+ - an origin you already registered now reports a **different `environmentId`**,
110
+ meaning it points at a different machine than it used to and the stored token
111
+ belongs to the old one
112
+
113
+ The token is optional so you can register a host before minting one, but reads
114
+ will fail until you add it.
115
+
116
+ The older `t3ctl host add <name> <origin> <token>` form still works and prints a
117
+ deprecation notice.
118
+
93
119
  ### `t3ctl host rm <name>`
94
120
 
95
121
  ```sh
@@ -98,7 +124,8 @@ t3ctl host rm desktop
98
124
 
99
125
  ### `t3ctl hosts`
100
126
 
101
- List registered hosts with truncated tokens. `t3ctl host` with no subcommand does
127
+ List registered hosts, probing each one in parallel for its label, environment id
128
+ (shortened), server version and reachability. `t3ctl host` with no subcommand does
102
129
  the same thing.
103
130
 
104
131
  ```sh
@@ -106,10 +133,13 @@ t3ctl hosts
106
133
  ```
107
134
 
108
135
  ```
109
- laptop http://localhost:3773 token:eyJ2Ijox…
110
- desktop https://studio.tailnet-1234.ts.net token:eyJ2Ijox…
136
+ ● laptop SPR-Gobius-D 9d9d9921 0.0.38-nightly.20260901.1250 http://localhost:3773
137
+ ✕ desktop Desktop 11111111 0.0.31-nightly.20260801.0900 https://studio.tailnet-1234.ts.net cannot reach …
111
138
  ```
112
139
 
140
+ Unreachable hosts are dimmed and show the values last recorded, not live ones.
141
+ `ls` deliberately does *not* probe — it stays a single request per host.
142
+
113
143
  ### `t3ctl project create <title> <workspace-root>`
114
144
 
115
145
  Register an existing directory on the host as a project. The path is resolved
@@ -264,9 +294,9 @@ reachable URL works.** There's nothing to configure beyond `host add`.
264
294
  and give each host its own short name:
265
295
 
266
296
  ```sh
267
- t3ctl host add laptop http://localhost:3773 eyJ2Ijox...
268
- t3ctl host add desktop https://studio.tailnet-1234.ts.net eyJ2Ijox...
269
- t3ctl host add builder http://10.0.0.42:3773 eyJ2Ijox...
297
+ t3ctl host add http://localhost:3773 eyJ2Ijox...
298
+ t3ctl host add https://studio.tailnet-1234.ts.net eyJ2Ijox...
299
+ t3ctl host add http://10.0.0.42:3773 eyJ2Ijox...
270
300
  t3ctl ls -t
271
301
  ```
272
302
 
@@ -274,8 +304,10 @@ t3ctl ls -t
274
304
  up as `unreachable` and don't block the rest.
275
305
 
276
306
  T3 Code's own **T3 Connect relay** (what the mobile app uses when you're off your
277
- tailnet) is **not implemented in t3ctl** — the transports above are the options
278
- today.
307
+ tailnet) is **not planned**: the relay's `dpop-token` exchange only accepts a
308
+ Clerk *session* JWT carrying the relay audience, and its allowed scopes are keyed
309
+ by `client_id`, which is pinned to `t3-mobile` and `t3-web`. A third-party CLI has
310
+ no way to present either. The transports above are the options.
279
311
 
280
312
  ## Limitations
281
313
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobius/t3ctl",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Controller CLI for T3 Code hosts — list and drive threads across machines",
6
6
  "keywords": [
package/t3ctl.mjs CHANGED
@@ -22,7 +22,7 @@ const writeHosts = (hosts) => {
22
22
 
23
23
  const snapshot = async (host) => {
24
24
  const res = await fetch(`${host.origin}/api/orchestration/snapshot`, {
25
- headers: { authorization: `Bearer ${host.token}` },
25
+ headers: host.token ? { authorization: `Bearer ${host.token}` } : {},
26
26
  signal: AbortSignal.timeout(host.timeoutMs ?? 15000),
27
27
  });
28
28
  if (!res.ok) throw new Error(`${host.name}: HTTP ${res.status} ${await res.text().catch(() => '')}`.trim());
@@ -67,7 +67,7 @@ const cmdLs = async (args) => {
67
67
  const showAll = args.includes('--all') || args.includes('-a');
68
68
  const asJson = args.includes('--json');
69
69
  const hosts = readHosts();
70
- if (!hosts.length) return console.error('No hosts registered. Run: t3ctl host add <name> <origin> <token>');
70
+ if (!hosts.length) return console.error('No hosts registered. Run: t3ctl host add <origin> <token>');
71
71
 
72
72
  const { ok, failed } = await collect(hosts);
73
73
 
@@ -111,22 +111,135 @@ const cmdLs = async (args) => {
111
111
  for (const f of failed) console.error(`\n\x1b[31munreachable\x1b[0m ${f.host.name}: ${f.error}`);
112
112
  };
113
113
 
114
- const cmdHost = (args) => {
114
+ // ---- host registry ------------------------------------------------------
115
+ // The descriptor at /.well-known/t3/environment is UNAUTHENTICATED, so probing
116
+ // it answers "is anyone home, and is it T3 Code?" without a token — a wrong
117
+ // origin fails here instead of as a baffling 401 on the first real call.
118
+ // Schema: ExecutionEnvironmentDescriptor in packages/contracts/src/environment.ts.
119
+
120
+ const DESCRIPTOR_PATH = '/.well-known/t3/environment';
121
+
122
+ const probe = async (origin, timeoutMs = 5000) => {
123
+ let res;
124
+ try {
125
+ res = await fetch(`${origin}${DESCRIPTOR_PATH}`, { signal: AbortSignal.timeout(timeoutMs) });
126
+ } catch (error) {
127
+ // Node's fetch reports a bare "fetch failed"; the cause carries the real reason.
128
+ const why = error.name === 'TimeoutError' ? `no response in ${timeoutMs}ms`
129
+ : (error.cause?.message ?? error.message);
130
+ throw new Error(`cannot reach ${origin}: ${why}`);
131
+ }
132
+ const notT3 = (why) => new Error(`not a T3 Code server (${DESCRIPTOR_PATH} ${why})`);
133
+ if (!res.ok) throw notT3(`returned HTTP ${res.status}`);
134
+ let d;
135
+ try { d = await res.json(); } catch { throw notT3('is not JSON'); }
136
+ const missing = ['environmentId', 'label', 'serverVersion'].filter((k) => typeof d?.[k] !== 'string' || !d[k]);
137
+ if (missing.length) throw notT3(`is missing ${missing.join(', ')}`);
138
+ return { environmentId: d.environmentId, label: d.label, serverVersion: d.serverVersion };
139
+ };
140
+
141
+ const shortId = (id) => (id ? id.slice(0, 8) : '-');
142
+ const warn = (message) => console.error(`\x1b[33mwarning\x1b[0m ${message}`);
143
+
144
+ // The name is what you type in --host, so derive a typeable slug from the label
145
+ // rather than using the label verbatim.
146
+ const slugify = (label) => label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'host';
147
+
148
+ const uniqueName = (base, hosts) => {
149
+ if (!hosts.some((h) => h.name === base)) return base;
150
+ for (let n = 2; ; n++) if (!hosts.some((h) => h.name === `${base}-${n}`)) return `${base}-${n}`;
151
+ };
152
+
153
+ const isOrigin = (value) => /^https?:\/\//i.test(value ?? '');
154
+
155
+ // Two accepted shapes, told apart by the scheme:
156
+ // host add <origin> [token] [--name <n>] current
157
+ // host add <name> <origin> <token> legacy, still works
158
+ const parseHostAdd = (pos) => {
159
+ if (isOrigin(pos[0])) return { origin: pos[0], token: pos[1] ?? null, legacy: false };
160
+ if (isOrigin(pos[1])) return { name: pos[0], origin: pos[1], token: pos[2] ?? null, legacy: true };
161
+ return null;
162
+ };
163
+
164
+ const HOST_ADD_USAGE = 'usage: t3ctl host add <origin> [token] [--name <name>]\n' +
165
+ ' an origin needs a scheme, e.g. http://localhost:3773';
166
+
167
+ const cmdHostAdd = async (pos, flags, hosts) => {
168
+ // Validate before any network or registry work so a bare `host add` prints
169
+ // usage instead of stalling on a probe.
170
+ const parsed = parseHostAdd(pos);
171
+ if (!parsed) return usage(HOST_ADD_USAGE);
172
+ if ('name' in flags && !flags.name) return usage(HOST_ADD_USAGE);
173
+ if (parsed.legacy) warn('"host add <name> <origin> <token>" is deprecated — use: t3ctl host add <origin> [token] [--name <name>]');
174
+
175
+ const origin = parsed.origin.replace(/\/$/, '');
176
+ const descriptor = await probe(origin);
177
+ const existing = hosts.find((h) => h.origin === origin);
178
+
179
+ // A changed environmentId on a known origin means the origin now points at a
180
+ // different machine — the stored token almost certainly belongs to the old one.
181
+ if (existing?.environmentId && existing.environmentId !== descriptor.environmentId) {
182
+ warn(`${origin} is now a DIFFERENT environment\n` +
183
+ ` was ${existing.environmentId} (${existing.label ?? 'unknown'})\n` +
184
+ ` now ${descriptor.environmentId} (${descriptor.label})\n` +
185
+ ` the token stored for "${existing.name}" was issued by the old one and will likely fail`);
186
+ }
187
+
188
+ const name = flags.name ?? parsed.name ?? existing?.name ??
189
+ uniqueName(slugify(descriptor.label), hosts);
190
+ const token = parsed.token ?? existing?.token ?? null;
191
+
192
+ const others = hosts.filter((h) => h.name !== name && h.origin !== origin && h.serverVersion);
193
+ const skewed = [...new Set(others.map((h) => h.serverVersion))].filter((v) => v !== descriptor.serverVersion);
194
+ if (skewed.length) warn(`serverVersion ${descriptor.serverVersion} differs from other hosts: ${skewed.join(', ')}`);
195
+
196
+ writeHosts(hosts.filter((h) => h.name !== name && h.origin !== origin).concat({
197
+ name, origin, token,
198
+ environmentId: descriptor.environmentId,
199
+ label: descriptor.label,
200
+ serverVersion: descriptor.serverVersion,
201
+ }));
202
+ console.log(`added ${bold(name)} -> ${origin}\n label ${descriptor.label}\n` +
203
+ ` env ${descriptor.environmentId}\n version ${descriptor.serverVersion}`);
204
+ if (!token) warn(`no token stored for ${name} — reads will fail until you run: t3ctl host add ${origin} <token>`);
205
+ };
206
+
207
+ const cmdHostsList = async (hosts) => {
208
+ if (!hosts.length) return console.log('(no hosts)');
209
+ const probes = await Promise.allSettled(hosts.map((h) => probe(h.origin)));
210
+ const drifted = [];
211
+ hosts.forEach((h, i) => {
212
+ const p = probes[i];
213
+ const live = p.status === 'fulfilled' ? p.value : null;
214
+ if (live && h.environmentId && h.environmentId !== live.environmentId) drifted.push({ h, live });
215
+ const icon = live ? ICON.running : ICON.error;
216
+ const label = live?.label ?? h.label ?? '-';
217
+ const env = shortId(live?.environmentId ?? h.environmentId);
218
+ const version = live?.serverVersion ?? h.serverVersion ?? '-';
219
+ // Values for an unreachable host are whatever was last stored, so dim the
220
+ // whole row to keep remembered data visually distinct from probed data.
221
+ const cell = (text, width) => (live ? text.padEnd(width) : dim(text.padEnd(width)));
222
+ console.log(`${icon} ${live ? bold(h.name.padEnd(14)) : dim(h.name.padEnd(14))} ${cell(label, 18)} ${dim(env.padEnd(9))} ${cell(version, 28)} ${dim(h.origin)}` +
223
+ (live ? '' : ` ${dim(p.reason.message)}`));
224
+ });
225
+ for (const { h, live } of drifted) {
226
+ warn(`${h.name} (${h.origin}) is now a DIFFERENT environment\n` +
227
+ ` was ${h.environmentId} (${h.label ?? 'unknown'})\n now ${live.environmentId} (${live.label})`);
228
+ }
229
+ };
230
+
231
+ const cmdHost = async (args) => {
115
232
  const [sub, ...rest] = args;
233
+ const { flags, pos } = parseArgs(rest);
116
234
  const hosts = readHosts();
117
- if (sub === 'add') {
118
- const [name, origin, token] = rest;
119
- if (!name || !origin || !token) return usage('usage: t3ctl host add <name> <origin> <token>');
120
- const next = hosts.filter((h) => h.name !== name).concat({ name, origin: origin.replace(/\/$/, ''), token });
121
- writeHosts(next);
122
- console.log(`added ${name} -> ${origin}`);
123
- } else if (sub === 'rm') {
124
- writeHosts(hosts.filter((h) => h.name !== rest[0]));
125
- console.log(`removed ${rest[0]}`);
126
- } else {
127
- if (!hosts.length) return console.log('(no hosts)');
128
- for (const h of hosts) console.log(`${h.name.padEnd(16)} ${h.origin} ${dim('token:' + h.token.slice(0, 8) + '…')}`);
235
+ if (sub === 'add') return cmdHostAdd(pos, flags, hosts);
236
+ if (sub === 'rm') {
237
+ if (!pos[0]) return usage('usage: t3ctl host rm <name>');
238
+ writeHosts(hosts.filter((h) => h.name !== pos[0]));
239
+ console.log(`removed ${pos[0]}`);
240
+ return;
129
241
  }
242
+ return cmdHostsList(hosts);
130
243
  };
131
244
 
132
245
 
@@ -135,7 +248,7 @@ const cmdHost = (args) => {
135
248
  // commandId is the idempotency key, so retries are safe.
136
249
  // Schemas: packages/contracts/src/orchestration.ts in pingdotgg/t3code.
137
250
 
138
- const VALUE_FLAGS = ['host', 'model', 'branch', 'runtime-mode', 'interaction-mode', 'worktree'];
251
+ const VALUE_FLAGS = ['host', 'model', 'branch', 'runtime-mode', 'interaction-mode', 'worktree', 'name'];
139
252
 
140
253
  const parseArgs = (argv) => {
141
254
  const flags = {}, pos = [];
@@ -155,7 +268,7 @@ const pickHost = (flags) => {
155
268
  if (!h) throw new Error(`no such host: ${flags.host}`);
156
269
  return h;
157
270
  }
158
- if (!hosts.length) throw new Error('no hosts registered — run: t3ctl host add <name> <origin> <token>');
271
+ if (!hosts.length) throw new Error('no hosts registered — run: t3ctl host add <origin> <token>');
159
272
  if (hosts.length > 1) throw new Error(`multiple hosts; pass --host <${hosts.map((h) => h.name).join('|')}>`);
160
273
  return hosts[0];
161
274
  };
@@ -163,7 +276,7 @@ const pickHost = (flags) => {
163
276
  const dispatch = async (host, command) => {
164
277
  const res = await fetch(`${host.origin}/api/orchestration/dispatch`, {
165
278
  method: 'POST',
166
- headers: { authorization: `Bearer ${host.token}`, 'content-type': 'application/json' },
279
+ headers: { ...(host.token ? { authorization: `Bearer ${host.token}` } : {}), 'content-type': 'application/json' },
167
280
  body: JSON.stringify(command),
168
281
  signal: AbortSignal.timeout(host.timeoutMs ?? 15000),
169
282
  });
@@ -309,9 +422,9 @@ if (!commands[cmd]) {
309
422
  console.log(`t3ctl — control T3 Code hosts
310
423
 
311
424
  t3ctl ls [-t|--threads] [-a|--all] [--json] list projects and threads across hosts
312
- t3ctl host add <name> <origin> <token> register a host
425
+ t3ctl host add <origin> [token] [--name <n>] register a host (probes it first)
313
426
  t3ctl host rm <name> remove a host
314
- t3ctl hosts list registered hosts
427
+ t3ctl hosts list hosts and probe each one
315
428
 
316
429
  t3ctl project create <title> <root> create a project for an existing dir
317
430
  t3ctl thread create <project> <title> start a thread (--model inst/model,