@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.
- package/README.md +46 -14
- package/package.json +1 -1
- 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
|
|
23
|
-
t3ctl host add
|
|
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 <
|
|
84
|
+
### `t3ctl host add <origin> [token] [--name <name>]`
|
|
85
85
|
|
|
86
|
-
Register (or
|
|
87
|
-
|
|
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
|
|
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
|
|
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
|
|
110
|
-
desktop
|
|
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
|
|
268
|
-
t3ctl host add
|
|
269
|
-
t3ctl host add
|
|
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
|
|
278
|
-
|
|
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
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 <
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
if (!
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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 <
|
|
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}
|
|
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 <
|
|
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
|
|
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,
|