@shardflux/cli 0.11.0 → 0.13.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/CHANGELOG.md +65 -1
- package/README.md +85 -15
- package/dist/account-args.js +2 -0
- package/dist/account-commands.js +39 -0
- package/dist/agent-commands.d.ts +8 -0
- package/dist/agent-commands.js +223 -0
- package/dist/agent-config-files.d.ts +23 -0
- package/dist/agent-config-files.js +203 -0
- package/dist/agent-config-mcp.d.ts +36 -0
- package/dist/agent-config-mcp.js +225 -0
- package/dist/agent-config-sync.d.ts +58 -0
- package/dist/agent-config-sync.js +366 -0
- package/dist/agent-credentials.d.ts +14 -0
- package/dist/agent-credentials.js +93 -0
- package/dist/agent-git-commands.d.ts +10 -0
- package/dist/agent-git-commands.js +294 -0
- package/dist/agent-git.d.ts +47 -0
- package/dist/agent-git.js +284 -0
- package/dist/agent-reachability.d.ts +4 -0
- package/dist/agent-reachability.js +13 -0
- package/dist/agent-repository.d.ts +13 -0
- package/dist/agent-repository.js +182 -0
- package/dist/agent-state.d.ts +16 -0
- package/dist/agent-state.js +63 -0
- package/dist/agent-target-commands.d.ts +10 -0
- package/dist/agent-target-commands.js +171 -0
- package/dist/agent-targets.d.ts +33 -0
- package/dist/agent-targets.js +267 -0
- package/dist/agent-terminal.d.ts +35 -0
- package/dist/agent-terminal.js +209 -0
- package/dist/args.d.ts +8 -4
- package/dist/args.js +120 -10
- package/dist/assisted-setup.d.ts +24 -0
- package/dist/assisted-setup.js +77 -0
- package/dist/browser-commands.d.ts +3 -0
- package/dist/browser-commands.js +83 -0
- package/dist/browser-profile-copy.d.ts +2 -0
- package/dist/browser-profile-copy.js +2 -0
- package/dist/browser-readers.d.ts +13 -0
- package/dist/browser-readers.js +249 -0
- package/dist/browser-sites.d.ts +1 -0
- package/dist/browser-sites.js +13 -0
- package/dist/claude-cloud-login.d.ts +9 -0
- package/dist/claude-cloud-login.js +143 -0
- package/dist/command-center-actions.d.ts +6 -0
- package/dist/command-center-actions.js +132 -0
- package/dist/command-center-keys.d.ts +6 -0
- package/dist/command-center-keys.js +38 -0
- package/dist/command-center-model.d.ts +29 -0
- package/dist/command-center-model.js +90 -0
- package/dist/command-center-renderer.d.ts +25 -0
- package/dist/command-center-renderer.js +100 -0
- package/dist/command-center.d.ts +18 -0
- package/dist/command-center.js +320 -0
- package/dist/commands.d.ts +8 -2
- package/dist/commands.js +123 -8
- package/dist/credential-prompts.d.ts +22 -0
- package/dist/credential-prompts.js +193 -0
- package/dist/filesystem-commands.d.ts +2 -0
- package/dist/filesystem-commands.js +63 -0
- package/dist/filesystem-state.d.ts +3 -0
- package/dist/filesystem-state.js +12 -0
- package/dist/git-remote-bin.d.ts +2 -0
- package/dist/git-remote-bin.js +37 -0
- package/dist/git-remote.d.ts +14 -0
- package/dist/git-remote.js +136 -0
- package/dist/handlers.js +9 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/input.d.ts +5 -0
- package/dist/main.js +21 -5
- package/dist/menubar.d.ts +32 -0
- package/dist/menubar.js +180 -0
- package/dist/project-identity.d.ts +14 -0
- package/dist/project-identity.js +61 -0
- package/dist/project-sync-manifest.d.ts +27 -0
- package/dist/project-sync-manifest.js +225 -0
- package/dist/project-sync-transfer.d.ts +14 -0
- package/dist/project-sync-transfer.js +240 -0
- package/dist/project-sync.d.ts +38 -0
- package/dist/project-sync.js +243 -0
- package/dist/project-template-setup.d.ts +17 -0
- package/dist/project-template-setup.js +89 -0
- package/dist/vault.d.ts +16 -0
- package/dist/vault.js +153 -0
- package/package.json +11 -3
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { accessSync, constants, chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { writePrivateFile } from "./credentials.js";
|
|
7
|
+
import { UsageError } from "./errors.js";
|
|
8
|
+
import { projectWorkspacePath } from "./agent-repository.js";
|
|
9
|
+
const BEGIN = '# BEGIN Shardflux managed targets';
|
|
10
|
+
const END = '# END Shardflux managed targets';
|
|
11
|
+
const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
12
|
+
const shellQuote = (s) => "'" + s.replaceAll("'", "'\\''").replaceAll('%', '%%') + "'";
|
|
13
|
+
const quote = (s) => '"' + s.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%') + '"';
|
|
14
|
+
export function targetIdentity(key) {
|
|
15
|
+
const parts = key.split('/');
|
|
16
|
+
if (parts.length !== 2 || parts.some(p => !/^[a-z0-9][a-z0-9._-]{0,95}$/.test(p)))
|
|
17
|
+
throw new UsageError('desktop targets require a <project>/<agent> workspace key');
|
|
18
|
+
const [project, agent] = parts;
|
|
19
|
+
return { host: `shardflux-${project}-${agent}`, workspace: key, project, agent };
|
|
20
|
+
}
|
|
21
|
+
/** Replace only a marked block, retaining every byte outside it. Put Includes before user Host blocks. */
|
|
22
|
+
export function managedBlock(text, body) {
|
|
23
|
+
const start = text.indexOf(BEGIN), end = text.indexOf(END);
|
|
24
|
+
if ((start < 0) !== (end < 0) || (start >= 0 && (end < start || text.indexOf(BEGIN, start + BEGIN.length) >= 0 || text.indexOf(END, end + END.length) >= 0)))
|
|
25
|
+
throw new UsageError('malformed Shardflux managed block; repair it before updating targets');
|
|
26
|
+
const block = body ? `${BEGIN}\n${body}\n${END}\n` : '';
|
|
27
|
+
if (start < 0)
|
|
28
|
+
return block + text;
|
|
29
|
+
const stop = end + END.length + (text[end + END.length] === '\n' ? 1 : 0);
|
|
30
|
+
return text.slice(0, start) + block + text.slice(stop);
|
|
31
|
+
}
|
|
32
|
+
export function installedTargets(env, platform = process.platform) {
|
|
33
|
+
const home = env.HOME || homedir();
|
|
34
|
+
const hasApp = (name, bundle) => platform === 'darwin' && [join('/Applications', name), join(home, 'Applications', name)].some(path => {
|
|
35
|
+
const info = join(path, 'Contents', 'Info.plist');
|
|
36
|
+
if (!existsSync(info))
|
|
37
|
+
return false;
|
|
38
|
+
const result = spawnSync('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', info], { input: '', encoding: 'utf8', timeout: 1000 });
|
|
39
|
+
return result.status === 0 && result.stdout.trim() === bundle;
|
|
40
|
+
});
|
|
41
|
+
const executable = (name) => (env.PATH ?? '').split(':').filter(Boolean).some(p => { try {
|
|
42
|
+
accessSync(join(p, name), constants.X_OK);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
} });
|
|
48
|
+
const clients = [hasApp('Claude.app', 'com.anthropic.claudefordesktop') && 'claude-desktop', (hasApp('Codex.app', 'com.openai.codex') || hasApp('ChatGPT.app', 'com.openai.codex')) && 'codex-app', executable('claude') && 'claude', executable('codex') && 'codex'].filter((v) => !!v);
|
|
49
|
+
return { targets: [...(platform === 'darwin' ? clients.filter(c => c.endsWith('-app') || c === 'claude-desktop') : []), 'web'], clients };
|
|
50
|
+
}
|
|
51
|
+
/** All paths are supplied by the invocation; tests use a temporary HOME, never the owner's app stores. */
|
|
52
|
+
export class AgentTargets {
|
|
53
|
+
home;
|
|
54
|
+
dir;
|
|
55
|
+
identityFile;
|
|
56
|
+
knownHosts;
|
|
57
|
+
sshFile;
|
|
58
|
+
claudeFile;
|
|
59
|
+
codexFile;
|
|
60
|
+
manifestFile;
|
|
61
|
+
constructor(env, dir) {
|
|
62
|
+
this.home = resolve(env.HOME || homedir());
|
|
63
|
+
this.dir = resolve(dir);
|
|
64
|
+
this.identityFile = join(this.dir, 'ssh', 'id_ed25519');
|
|
65
|
+
this.knownHosts = join(this.dir, 'known_hosts');
|
|
66
|
+
this.sshFile = join(this.dir, 'ssh_config');
|
|
67
|
+
this.claudeFile = join(env.CLAUDE_CONFIG_DIR || join(this.home, '.claude'), 'settings.json');
|
|
68
|
+
this.codexFile = join(env.CODEX_HOME || join(this.home, '.codex'), 'codex-app', 'config.json');
|
|
69
|
+
this.manifestFile = join(this.dir, 'targets.json');
|
|
70
|
+
for (const path of [this.home, this.dir, this.identityFile, this.knownHosts, this.sshFile, this.claudeFile, this.codexFile])
|
|
71
|
+
if (/[\r\n\0]/.test(path))
|
|
72
|
+
throw new UsageError('target paths must not contain line breaks or NUL');
|
|
73
|
+
}
|
|
74
|
+
#text(file) {
|
|
75
|
+
try {
|
|
76
|
+
if (lstatSync(file).isSymbolicLink())
|
|
77
|
+
throw new UsageError(`refusing to replace symlink ${file}`);
|
|
78
|
+
return readFileSync(file, 'utf8');
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
if (e.code === 'ENOENT')
|
|
82
|
+
return '';
|
|
83
|
+
throw e;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
#json(file) {
|
|
87
|
+
const text = this.#text(file);
|
|
88
|
+
if (!text)
|
|
89
|
+
return {};
|
|
90
|
+
let doc;
|
|
91
|
+
try {
|
|
92
|
+
doc = JSON.parse(text);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
throw new UsageError(`invalid JSON in ${file}; it was left unchanged`);
|
|
96
|
+
}
|
|
97
|
+
if (!object(doc))
|
|
98
|
+
throw new UsageError(`${file} must contain a JSON object`);
|
|
99
|
+
return doc;
|
|
100
|
+
}
|
|
101
|
+
#manifest() {
|
|
102
|
+
const doc = this.#json(this.manifestFile);
|
|
103
|
+
if (!Object.keys(doc).length)
|
|
104
|
+
return { version: 1, entries: [], created: {} };
|
|
105
|
+
if (doc.version !== 1 || !Array.isArray(doc.entries) || !object(doc.created))
|
|
106
|
+
throw new UsageError('unsupported Shardflux targets manifest');
|
|
107
|
+
if (Object.values(doc.created).some(v => !Array.isArray(v) || v.some(k => typeof k !== 'string')))
|
|
108
|
+
throw new UsageError('invalid target ownership metadata');
|
|
109
|
+
for (const e of doc.entries) {
|
|
110
|
+
if (!object(e) || typeof e.workspace !== 'string' || typeof e.api_url !== 'string' || /[\r\n\0]/.test(e.api_url) || !Array.isArray(e.targets) || e.targets.some(t => t !== 'claude-desktop' && t !== 'codex-app'))
|
|
111
|
+
throw new UsageError('invalid Shardflux target entry');
|
|
112
|
+
const identity = targetIdentity(e.workspace);
|
|
113
|
+
if (identity.host !== e.host || identity.project !== e.project || identity.agent !== e.agent)
|
|
114
|
+
throw new UsageError('invalid Shardflux target identity');
|
|
115
|
+
}
|
|
116
|
+
return doc;
|
|
117
|
+
}
|
|
118
|
+
entry(host, apiUrl) {
|
|
119
|
+
const entry = this.#manifest().entries.find(e => e.host === host);
|
|
120
|
+
if (!entry)
|
|
121
|
+
throw new UsageError(`unknown managed SSH host ${host}; run shard agents targets <project>/<agent>`);
|
|
122
|
+
if (entry.api_url !== apiUrl)
|
|
123
|
+
throw new UsageError(`SSH host ${host} belongs to ${entry.api_url}; use that API URL`);
|
|
124
|
+
return entry;
|
|
125
|
+
}
|
|
126
|
+
deviceKey() {
|
|
127
|
+
mkdirSync(dirname(this.identityFile), { recursive: true, mode: 0o700 });
|
|
128
|
+
if (!existsSync(this.identityFile)) {
|
|
129
|
+
const r = spawnSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'shardflux-device', '-f', this.identityFile], { stdio: 'ignore', timeout: 10_000 });
|
|
130
|
+
if (r.status !== 0)
|
|
131
|
+
throw new Error('could not generate the Shardflux device SSH key');
|
|
132
|
+
}
|
|
133
|
+
if (lstatSync(this.identityFile).isSymbolicLink())
|
|
134
|
+
throw new UsageError('device SSH key must not be a symlink');
|
|
135
|
+
chmodSync(this.identityFile, 0o600);
|
|
136
|
+
const r = spawnSync('ssh-keygen', ['-y', '-f', this.identityFile], { encoding: 'utf8', input: '', timeout: 10_000 });
|
|
137
|
+
if (r.status !== 0 || !/^ssh-ed25519 [A-Za-z0-9+/=]+(?: [^\r\n]*)?$/.test(r.stdout.trim()))
|
|
138
|
+
throw new Error('Shardflux device key must be an unencrypted ed25519 key');
|
|
139
|
+
const key = r.stdout.trim().split(/\s+/).slice(0, 2).join(' ');
|
|
140
|
+
return `${key} shardflux-device-${createHash('sha256').update(key).digest('hex').slice(0, 16)}`;
|
|
141
|
+
}
|
|
142
|
+
pin(host, publicKey) {
|
|
143
|
+
this.#locked(() => this.#pin(host, publicKey));
|
|
144
|
+
}
|
|
145
|
+
#pin(host, publicKey) {
|
|
146
|
+
if (!/^shardflux-[a-z0-9._-]+$/.test(host) || !/^ssh-ed25519 [A-Za-z0-9+/=]+(?: [^\r\n]*)?$/.test(publicKey.trim()))
|
|
147
|
+
throw new Error('API returned an invalid ed25519 SSH host key');
|
|
148
|
+
const key = publicKey.trim().split(/\s+/).slice(0, 2).join(' ');
|
|
149
|
+
const bytes = Buffer.from(key.split(' ')[1], 'base64');
|
|
150
|
+
if (bytes.length !== 51 || bytes.readUInt32BE(0) !== 11 || bytes.subarray(4, 15).toString() !== 'ssh-ed25519' || bytes.readUInt32BE(15) !== 32)
|
|
151
|
+
throw new Error('API returned an invalid ed25519 SSH host key');
|
|
152
|
+
const text = this.#text(this.knownHosts), lines = text.split('\n').filter(Boolean);
|
|
153
|
+
const previous = lines.filter(l => l.split(/\s+/)[0]?.split(',').includes(host));
|
|
154
|
+
if (previous.some(l => l.split(/\s+/).slice(1, 3).join(' ') !== key))
|
|
155
|
+
throw new Error(`SSH host key changed for ${host}; verify the workspace identity before removing its pin`);
|
|
156
|
+
if (!previous.length)
|
|
157
|
+
writePrivateFile(this.dir, this.knownHosts, text + (text && !text.endsWith('\n') ? '\n' : '') + `${host} ${key}\n`);
|
|
158
|
+
}
|
|
159
|
+
configure(key, apiUrl, targets, remove = false) {
|
|
160
|
+
return this.#locked(() => this.#configure(key, apiUrl, targets, remove));
|
|
161
|
+
}
|
|
162
|
+
#locked(work) {
|
|
163
|
+
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
164
|
+
const lock = join(this.dir, 'targets.lock');
|
|
165
|
+
try {
|
|
166
|
+
mkdirSync(lock, { mode: 0o700 });
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
if (e.code === 'EEXIST')
|
|
170
|
+
throw new UsageError('another target update holds targets.lock; retry after it finishes');
|
|
171
|
+
throw e;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
return work();
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
rmSync(lock, { recursive: true });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
#configure(key, apiUrl, targets, remove) {
|
|
181
|
+
const url = new URL(apiUrl);
|
|
182
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || /[\r\n\0]/.test(apiUrl))
|
|
183
|
+
throw new UsageError('invalid target API URL');
|
|
184
|
+
const manifest = this.#manifest(), old = manifest.entries;
|
|
185
|
+
const identity = key ? targetIdentity(key) : undefined;
|
|
186
|
+
if (!remove && !identity)
|
|
187
|
+
return old;
|
|
188
|
+
const existing = old.find(e => e.host === identity?.host);
|
|
189
|
+
if (existing && (existing.workspace !== key || existing.api_url !== apiUrl))
|
|
190
|
+
throw new UsageError('SSH alias already belongs to another workspace or API URL');
|
|
191
|
+
const added = identity ? { ...identity, api_url: apiUrl, targets: [...new Set([...(existing?.targets ?? []), ...targets])] } : undefined;
|
|
192
|
+
const entries = remove ? old.filter(e => identity ? e.host !== identity.host : e.api_url !== apiUrl) : existing ? old.map(e => e === existing ? added : e) : [...old, added];
|
|
193
|
+
const sshConfig = join(this.home, '.ssh', 'config');
|
|
194
|
+
const writes = new Map();
|
|
195
|
+
const rememberFile = (file) => {
|
|
196
|
+
manifest.created[file] ??= [];
|
|
197
|
+
if (!existsSync(file) && !manifest.created[file].includes('$file'))
|
|
198
|
+
manifest.created[file].push('$file');
|
|
199
|
+
};
|
|
200
|
+
for (const file of [sshConfig, this.sshFile])
|
|
201
|
+
rememberFile(file);
|
|
202
|
+
const outside = managedBlock(this.#text(sshConfig), '') + '\n' + managedBlock(this.#text(this.sshFile), '');
|
|
203
|
+
for (const e of entries)
|
|
204
|
+
if (outside.split('\n').some(line => /^\s*Host\s+/i.test(line) && line.trim().split(/\s+/).slice(1).includes(e.host)))
|
|
205
|
+
throw new UsageError(`unmanaged SSH Host already defines ${e.host}`);
|
|
206
|
+
const removed = old.filter(e => !entries.some(n => n.host === e.host));
|
|
207
|
+
if (removed.length && existsSync(this.knownHosts)) {
|
|
208
|
+
const text = this.#text(this.knownHosts);
|
|
209
|
+
writes.set(this.knownHosts, text.split(/(?<=\n)/).filter(line => !removed.some(e => line.split(/\s+/)[0] === e.host)).join(''));
|
|
210
|
+
}
|
|
211
|
+
writes.set(sshConfig, managedBlock(this.#text(sshConfig), entries.length ? `Include ${quote(this.sshFile)}` : ''));
|
|
212
|
+
const blocks = entries.map(e => `Host ${e.host}\n HostName ${e.host}\n ProxyCommand ${this.dir === join(this.home, '.config', 'shardflux') ? '' : `env ${shellQuote(`SHARDFLUX_CONFIG_DIR=${this.dir}`)} `}shard ssh-proxy %n --api-url ${shellQuote(e.api_url)}\n User user\n UserKnownHostsFile ${quote(this.knownHosts)}\n StrictHostKeyChecking yes\n IdentityFile ${quote(this.identityFile)}\n IdentitiesOnly yes`);
|
|
213
|
+
writes.set(this.sshFile, managedBlock(this.#text(this.sshFile), blocks.join('\n\n')));
|
|
214
|
+
for (const [file, prop, target] of [[this.claudeFile, 'sshConfigs', 'claude-desktop'], [this.codexFile, 'remoteConnections', 'codex-app']]) {
|
|
215
|
+
if (!old.some(e => e.targets.includes(target)) && !entries.some(e => e.targets.includes(target)))
|
|
216
|
+
continue;
|
|
217
|
+
rememberFile(file);
|
|
218
|
+
const doc = this.#json(file);
|
|
219
|
+
if (file === this.codexFile && doc.version !== undefined && doc.version !== 1)
|
|
220
|
+
throw new UsageError('unsupported Codex app config version; file was left unchanged');
|
|
221
|
+
if (doc[prop] !== undefined && !Array.isArray(doc[prop]))
|
|
222
|
+
throw new UsageError(`${file}: ${prop} must be an array`);
|
|
223
|
+
const values = (doc[prop] ?? []);
|
|
224
|
+
const identifies = (v, host) => object(v) && (target === 'claude-desktop' ? v.id === host : v.sshAlias === host);
|
|
225
|
+
for (const e of entries.filter(e => e.targets.includes(target)))
|
|
226
|
+
if (values.some(v => identifies(v, e.host)) && !old.some(o => o.host === e.host && o.targets.includes(target)))
|
|
227
|
+
throw new UsageError(`refusing to replace an unmanaged ${target} entry for ${e.host}`);
|
|
228
|
+
const kept = values.filter(v => !old.some(e => e.targets.includes(target) && identifies(v, e.host)));
|
|
229
|
+
manifest.created[file] ??= [];
|
|
230
|
+
if (!(prop in doc) && !manifest.created[file].includes(prop))
|
|
231
|
+
manifest.created[file].push(prop);
|
|
232
|
+
doc[prop] = [...kept, ...entries.filter(e => e.targets.includes(target)).map(e => target === 'claude-desktop' ? { id: e.host, name: `[SF] ${e.project} · ${e.agent}`, sshHost: e.host, startDirectory: projectWorkspacePath({ slug: e.project }) } : { sshAlias: e.host, projects: [{ remotePath: projectWorkspacePath({ slug: e.project }), label: `[SF] ${e.project} · ${e.agent}` }] })];
|
|
233
|
+
if (!doc[prop].length && manifest.created[file].includes(prop))
|
|
234
|
+
delete doc[prop];
|
|
235
|
+
if (file === this.codexFile) {
|
|
236
|
+
if (!("version" in doc) && prop in doc) {
|
|
237
|
+
doc.version = 1;
|
|
238
|
+
manifest.created[file].push('version');
|
|
239
|
+
}
|
|
240
|
+
if (!(prop in doc) && manifest.created[file].includes('version'))
|
|
241
|
+
delete doc.version;
|
|
242
|
+
}
|
|
243
|
+
writes.set(file, Object.keys(doc).length || !manifest.created[file].includes('$file') ? JSON.stringify(doc, null, 2) + '\n' : '');
|
|
244
|
+
}
|
|
245
|
+
if (!remove)
|
|
246
|
+
this.deviceKey();
|
|
247
|
+
// Record ownership before touching app stores, so a partial write can still be removed.
|
|
248
|
+
manifest.entries = entries;
|
|
249
|
+
if (!remove)
|
|
250
|
+
writePrivateFile(this.dir, this.manifestFile, JSON.stringify(manifest, null, 2) + '\n');
|
|
251
|
+
for (const [file, text] of writes) {
|
|
252
|
+
if (text) {
|
|
253
|
+
if (text !== this.#text(file))
|
|
254
|
+
writePrivateFile(dirname(file), file, text);
|
|
255
|
+
}
|
|
256
|
+
else if (existsSync(file)) {
|
|
257
|
+
if (manifest.created[file]?.includes('$file') || file === this.knownHosts)
|
|
258
|
+
rmSync(file);
|
|
259
|
+
else
|
|
260
|
+
writePrivateFile(dirname(file), file, '');
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (remove)
|
|
264
|
+
writePrivateFile(this.dir, this.manifestFile, JSON.stringify(manifest, null, 2) + '\n');
|
|
265
|
+
return entries;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { PtyConnection } from '@shardflux/sdk';
|
|
2
|
+
import type { CellClient, PtyServerMessage } from '@shardflux/sdk';
|
|
3
|
+
import type { Io } from './commands.js';
|
|
4
|
+
export interface TerminalConnection {
|
|
5
|
+
input(bytes: Uint8Array): void;
|
|
6
|
+
resize(rows: number, cols: number): void;
|
|
7
|
+
messages(): AsyncIterable<PtyServerMessage>;
|
|
8
|
+
close(): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
export declare function retryableAttach(error: unknown): boolean;
|
|
11
|
+
/** Offsets address bytes, including escape sequences and UTF-8 split across frames. */
|
|
12
|
+
export declare class PtyOutputCursor {
|
|
13
|
+
offset: number;
|
|
14
|
+
consume(message: PtyServerMessage): Buffer | null;
|
|
15
|
+
}
|
|
16
|
+
export interface AttachOptions {
|
|
17
|
+
io: Io;
|
|
18
|
+
credentialPrompts?: (ask: (message: string) => Promise<string>, signal: AbortSignal) => Promise<void>;
|
|
19
|
+
signal: AbortSignal;
|
|
20
|
+
connect(offset: number, signal: AbortSignal): Promise<TerminalConnection>;
|
|
21
|
+
wake(signal: AbortSignal): Promise<unknown>;
|
|
22
|
+
invalidate(): void;
|
|
23
|
+
sleep(ms: number, signal: AbortSignal): Promise<void>;
|
|
24
|
+
dimensions?: () => {
|
|
25
|
+
rows: number;
|
|
26
|
+
cols: number;
|
|
27
|
+
};
|
|
28
|
+
resizeEvents?: {
|
|
29
|
+
on(event: 'SIGWINCH', listener: () => void): unknown;
|
|
30
|
+
off(event: 'SIGWINCH', listener: () => void): unknown;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** Detaching closes only this attachment; the workspace PTY and agent keep running. */
|
|
34
|
+
export declare function attachTerminal(options: AttachOptions): Promise<number>;
|
|
35
|
+
export declare function cellConnector(cell: CellClient, sessionId: string): (offset: number, signal: AbortSignal) => Promise<PtyConnection>;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
2
|
+
import { PtyConnection, ShardfluxApiError, ShardfluxProtocolError, exitCodePosix } from '@shardflux/sdk';
|
|
3
|
+
import { UsageError } from "./errors.js";
|
|
4
|
+
export function retryableAttach(error) {
|
|
5
|
+
if (error instanceof ShardfluxProtocolError && /invalid|byte gap|omitted/.test(error.message))
|
|
6
|
+
return false;
|
|
7
|
+
return !(error instanceof ShardfluxApiError) || error.retryable || ['stale_epoch', 'token_expired', 'workspace_not_running', 'workspace_busy'].includes(error.code);
|
|
8
|
+
}
|
|
9
|
+
/** Offsets address bytes, including escape sequences and UTF-8 split across frames. */
|
|
10
|
+
export class PtyOutputCursor {
|
|
11
|
+
offset = 0;
|
|
12
|
+
consume(message) {
|
|
13
|
+
if (message.type !== 'output')
|
|
14
|
+
return null;
|
|
15
|
+
const start = message.offset;
|
|
16
|
+
if (!Number.isSafeInteger(start) || start < 0 || start > this.offset || typeof message.data !== 'string')
|
|
17
|
+
throw new ShardfluxProtocolError('PTY output has a byte gap or invalid offset', 0, 'cell');
|
|
18
|
+
const bytes = Buffer.from(message.data, 'base64');
|
|
19
|
+
const fresh = bytes.subarray(Math.min(bytes.length, this.offset - start));
|
|
20
|
+
this.offset += fresh.length;
|
|
21
|
+
return fresh;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Detaching closes only this attachment; the workspace PTY and agent keep running. */
|
|
25
|
+
export async function attachTerminal(options) {
|
|
26
|
+
const { io } = options;
|
|
27
|
+
const stdin = io.stdin;
|
|
28
|
+
if (!stdin.isTTY || !stdin.setRawMode || !stdin.on || !stdin.off)
|
|
29
|
+
throw new UsageError('terminal attach requires a TTY');
|
|
30
|
+
const controller = new AbortController();
|
|
31
|
+
const abort = () => controller.abort(options.signal.reason);
|
|
32
|
+
options.signal.addEventListener('abort', abort, { once: true });
|
|
33
|
+
if (options.signal.aborted)
|
|
34
|
+
abort();
|
|
35
|
+
const signal = controller.signal;
|
|
36
|
+
const cursor = new PtyOutputCursor();
|
|
37
|
+
let connection;
|
|
38
|
+
let acceptingInput = false;
|
|
39
|
+
let promptAnswer;
|
|
40
|
+
let promptLine = '';
|
|
41
|
+
let promptDecoder = new StringDecoder('utf8');
|
|
42
|
+
let prefix = false;
|
|
43
|
+
let detached = false;
|
|
44
|
+
let inputFailure;
|
|
45
|
+
const pending = [];
|
|
46
|
+
let pendingBytes = 0;
|
|
47
|
+
const dimensions = options.dimensions ?? (() => ({ rows: process.stdout.rows || 24, cols: process.stdout.columns || 80 }));
|
|
48
|
+
const resizeEvents = options.resizeEvents ?? process;
|
|
49
|
+
const resize = () => {
|
|
50
|
+
const size = dimensions();
|
|
51
|
+
connection?.resize(Math.min(1000, Math.max(1, size.rows)), Math.min(1000, Math.max(1, size.cols)));
|
|
52
|
+
};
|
|
53
|
+
const send = (bytes) => {
|
|
54
|
+
if (!bytes.length)
|
|
55
|
+
return;
|
|
56
|
+
if (connection && acceptingInput) {
|
|
57
|
+
try {
|
|
58
|
+
connection.input(bytes);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (!retryableAttach(error)) {
|
|
62
|
+
inputFailure = error instanceof Error ? error : new Error(String(error));
|
|
63
|
+
controller.abort(error);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
acceptingInput = false;
|
|
67
|
+
void connection.close().catch((failure) => { inputFailure = failure instanceof Error ? failure : new Error(String(failure)); controller.abort(failure); });
|
|
68
|
+
}
|
|
69
|
+
if (acceptingInput)
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
{
|
|
73
|
+
pendingBytes += bytes.length;
|
|
74
|
+
if (pendingBytes > 64 * 1024) {
|
|
75
|
+
inputFailure = new UsageError('terminal input exceeded the reconnect buffer');
|
|
76
|
+
controller.abort(inputFailure);
|
|
77
|
+
}
|
|
78
|
+
else
|
|
79
|
+
pending.push(bytes);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const data = (value) => {
|
|
83
|
+
const bytes = typeof value === 'string' ? Buffer.from(value) : value;
|
|
84
|
+
if (promptAnswer) {
|
|
85
|
+
for (const byte of bytes) {
|
|
86
|
+
if (byte === 3) {
|
|
87
|
+
controller.abort();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (byte === 13 || byte === 10) {
|
|
91
|
+
const answer = promptAnswer;
|
|
92
|
+
promptAnswer = undefined;
|
|
93
|
+
answer(promptLine + promptDecoder.end());
|
|
94
|
+
promptLine = '';
|
|
95
|
+
promptDecoder = new StringDecoder('utf8');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (byte === 127 || byte === 8) {
|
|
99
|
+
promptLine = Array.from(promptLine).slice(0, -1).join('');
|
|
100
|
+
io.stderr.write('\b \b');
|
|
101
|
+
}
|
|
102
|
+
else if (byte >= 32 && promptLine.length < 4096) {
|
|
103
|
+
const character = promptDecoder.write(Buffer.from([byte]));
|
|
104
|
+
promptLine += character;
|
|
105
|
+
io.stderr.write(character);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (!prefix && !bytes.includes(29)) {
|
|
111
|
+
send(bytes);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const output = [];
|
|
115
|
+
for (const byte of bytes) {
|
|
116
|
+
if (prefix) {
|
|
117
|
+
prefix = false;
|
|
118
|
+
if (byte === 100) {
|
|
119
|
+
send(Buffer.from(output));
|
|
120
|
+
detached = true;
|
|
121
|
+
controller.abort();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
output.push(29);
|
|
125
|
+
}
|
|
126
|
+
if (byte === 29)
|
|
127
|
+
prefix = true;
|
|
128
|
+
else
|
|
129
|
+
output.push(byte);
|
|
130
|
+
}
|
|
131
|
+
send(Buffer.from(output));
|
|
132
|
+
};
|
|
133
|
+
const end = () => { detached = true; controller.abort(); };
|
|
134
|
+
const wasRaw = stdin.isRaw ?? false;
|
|
135
|
+
stdin.setRawMode(true);
|
|
136
|
+
stdin.on('data', data);
|
|
137
|
+
stdin.on('end', end);
|
|
138
|
+
resizeEvents.on('SIGWINCH', resize);
|
|
139
|
+
stdin.resume?.();
|
|
140
|
+
const promptTask = options.credentialPrompts?.(message => new Promise((resolve, reject) => {
|
|
141
|
+
const abort = () => { promptAnswer = undefined; reject(new Error('terminal detached')); };
|
|
142
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
143
|
+
promptAnswer = line => { signal.removeEventListener('abort', abort); io.stderr.write('\n'); resolve(line); };
|
|
144
|
+
io.stderr.write(message);
|
|
145
|
+
}), signal).catch(error => { if (!signal.aborted) {
|
|
146
|
+
inputFailure = error instanceof Error ? error : new Error('credential prompt unavailable');
|
|
147
|
+
controller.abort();
|
|
148
|
+
} });
|
|
149
|
+
let reconnects = 0;
|
|
150
|
+
try {
|
|
151
|
+
while (!signal.aborted) {
|
|
152
|
+
try {
|
|
153
|
+
if (reconnects) {
|
|
154
|
+
options.invalidate();
|
|
155
|
+
await options.wake(signal);
|
|
156
|
+
}
|
|
157
|
+
connection = await options.connect(cursor.offset, signal);
|
|
158
|
+
acceptingInput = true;
|
|
159
|
+
resize();
|
|
160
|
+
while (pending.length) {
|
|
161
|
+
connection.input(pending[0]);
|
|
162
|
+
pendingBytes -= pending.shift().length;
|
|
163
|
+
}
|
|
164
|
+
for await (const message of connection.messages()) {
|
|
165
|
+
if (signal.aborted)
|
|
166
|
+
break;
|
|
167
|
+
const bytes = cursor.consume(message);
|
|
168
|
+
if (bytes?.length)
|
|
169
|
+
io.stdout.write(bytes);
|
|
170
|
+
if (message.type === 'exit') {
|
|
171
|
+
if (!message.session)
|
|
172
|
+
throw new ShardfluxProtocolError('PTY exit omitted the session', 0, 'cell');
|
|
173
|
+
return exitCodePosix({ exitCode: message.session.exit_code ?? null, termSignal: message.session.term_signal ?? null, timedOut: false });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (signal.aborted)
|
|
179
|
+
break;
|
|
180
|
+
if (!retryableAttach(error))
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
const previous = connection;
|
|
185
|
+
connection = undefined;
|
|
186
|
+
acceptingInput = false;
|
|
187
|
+
await previous?.close();
|
|
188
|
+
}
|
|
189
|
+
reconnects += 1;
|
|
190
|
+
await options.sleep(Math.min(2_000, 100 * 2 ** Math.min(reconnects, 5)), signal);
|
|
191
|
+
}
|
|
192
|
+
if (inputFailure)
|
|
193
|
+
throw inputFailure;
|
|
194
|
+
return detached ? 0 : 130;
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
controller.abort();
|
|
198
|
+
await promptTask;
|
|
199
|
+
options.signal.removeEventListener('abort', abort);
|
|
200
|
+
stdin.off('data', data);
|
|
201
|
+
stdin.off('end', end);
|
|
202
|
+
resizeEvents.off('SIGWINCH', resize);
|
|
203
|
+
stdin.setRawMode(wasRaw);
|
|
204
|
+
stdin.pause?.();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
export function cellConnector(cell, sessionId) {
|
|
208
|
+
return (offset, signal) => PtyConnection.attach(cell, sessionId, offset, signal);
|
|
209
|
+
}
|
package/dist/args.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const CLI_VERSION = "0.
|
|
1
|
+
export declare const CLI_VERSION = "0.13.0";
|
|
2
2
|
export interface OptionSpec {
|
|
3
3
|
type: 'string' | 'boolean';
|
|
4
4
|
short?: string;
|
|
@@ -27,7 +27,8 @@ export interface CommandSpec {
|
|
|
27
27
|
examples?: string[];
|
|
28
28
|
}
|
|
29
29
|
export declare const GLOBAL_OPTIONS: Record<string, OptionSpec>;
|
|
30
|
-
|
|
30
|
+
export declare const PROJECT_COMMAND: CommandSpec;
|
|
31
|
+
export declare const CENTER_COMMAND: CommandSpec;
|
|
31
32
|
export declare const COMMANDS: CommandSpec[];
|
|
32
33
|
export declare function normalizeWords(words: string[]): string[];
|
|
33
34
|
export declare function findCommand(words: string[]): CommandSpec | undefined;
|
|
@@ -67,9 +68,12 @@ export type Parsed = {
|
|
|
67
68
|
};
|
|
68
69
|
/**
|
|
69
70
|
* Parses argv (without node/script). Throws UsageError for anything invalid;
|
|
70
|
-
* returns help text for --help, bare groups and
|
|
71
|
+
* returns help text for --help, bare groups and non-TTY bare `shard`.
|
|
71
72
|
*/
|
|
72
|
-
export declare function parseCommandLine(argv: readonly string[]
|
|
73
|
+
export declare function parseCommandLine(argv: readonly string[], options?: {
|
|
74
|
+
tty?: boolean;
|
|
75
|
+
isProjectPath?: (path: string) => boolean;
|
|
76
|
+
}): Parsed;
|
|
73
77
|
export declare function usageLine(c: CommandSpec): string;
|
|
74
78
|
export declare function commandHelp(c: CommandSpec): string;
|
|
75
79
|
/** Help of a group (`auth`) or a sub-group (`auth totp`): its commands with their summaries. */
|