@resolflow/cli 0.1.0-beta.1 → 0.1.0-beta.2
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/commands.js +63 -7
- package/dist/index.js +3 -2
- package/dist/secrets.js +68 -21
- package/package.json +1 -1
package/dist/commands.js
CHANGED
|
@@ -71,9 +71,11 @@ const urls_1 = require("./urls");
|
|
|
71
71
|
const runtime_1 = require("./runtime");
|
|
72
72
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
73
73
|
exports.CLI_VERSION = require('../package.json').version;
|
|
74
|
+
/** `forPairing`: try the best store again (a new pairing may reach the keychain that failed last time). */
|
|
74
75
|
function makeCtx(opts = {}) {
|
|
75
76
|
const config = new config_1.ConfigStore();
|
|
76
|
-
const
|
|
77
|
+
const fileFallback = !opts.forPairing && config.load().secretStore === 'file';
|
|
78
|
+
const { store, warning } = (0, secrets_1.chooseSecretStore)(config.dir, process.env, undefined, { fileFallback });
|
|
77
79
|
return { config, secrets: store, secretWarning: warning, logger: (0, log_1.createLogger)({ verbose: opts.verbose }), out: (l) => process.stdout.write(l + '\n') };
|
|
78
80
|
}
|
|
79
81
|
const platformLabel = () => `${process.platform}-${process.arch}`;
|
|
@@ -97,17 +99,66 @@ async function cmdPair(ctx, code, opts) {
|
|
|
97
99
|
const name = (opts.name ?? os.hostname()).slice(0, 60);
|
|
98
100
|
const res = await (0, api_1.pairDevice)(apiUrl, theCode, { name, platform: (0, exports.platformLabel)(), cliVersion: exports.CLI_VERSION });
|
|
99
101
|
// Replace an earlier pairing (its token is dropped locally; revoke it in the web app if it still shows)
|
|
100
|
-
await ctx
|
|
101
|
-
|
|
102
|
-
ctx.logger.warn(ctx.secretWarning);
|
|
102
|
+
const usedFallback = await storeDeviceToken(ctx, apiUrl, res.token);
|
|
103
|
+
const { secretStore: _old, ...rest } = cfg;
|
|
103
104
|
ctx.config.save({
|
|
104
|
-
...
|
|
105
|
+
...rest,
|
|
106
|
+
...(usedFallback ? { secretStore: 'file' } : {}),
|
|
105
107
|
apiUrl,
|
|
106
108
|
device: { id: res.deviceId, name: res.name, workspaceId: res.workspaceId, workspaceName: res.workspaceName, pairedAt: new Date().toISOString() },
|
|
107
109
|
});
|
|
108
110
|
ctx.out(`Paired "${res.name}" with workspace ${res.workspaceName ?? res.workspaceId}.`);
|
|
109
111
|
ctx.out(`Token stored in: ${describeStore(ctx.secrets)}. Next: ${(0, runtime_1.cliCommand)('start')}`);
|
|
110
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* RF-9142b1a6: save the token of a device the server has just created, and never hang or leave an orphan device.
|
|
115
|
+
* The keychain calls time out (secrets.ts); when the keychain fails the token goes to the 0600 file with a warning;
|
|
116
|
+
* when that fails too the device is revoked on the server and pairing fails with a clear message.
|
|
117
|
+
* Returns true when the file fallback was used (ctx.secrets is then the file store).
|
|
118
|
+
*/
|
|
119
|
+
async function storeDeviceToken(ctx, apiUrl, token) {
|
|
120
|
+
const where = describeStore(ctx.secrets);
|
|
121
|
+
if (ctx.secrets.kind !== 'file') {
|
|
122
|
+
ctx.out(`Saving the device token to the ${where}…${ctx.secrets.kind === 'macos-keychain' ? ' (macOS may ask for permission)' : ''}`);
|
|
123
|
+
}
|
|
124
|
+
let keychainError;
|
|
125
|
+
try {
|
|
126
|
+
await ctx.secrets.set(token);
|
|
127
|
+
if (ctx.secretWarning)
|
|
128
|
+
ctx.logger.warn(ctx.secretWarning);
|
|
129
|
+
if (ctx.secrets.kind !== 'file')
|
|
130
|
+
await new secrets_1.FileSecretStore(ctx.config.dir).delete(); // no stale copy of an earlier fallback
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
keychainError = err;
|
|
135
|
+
}
|
|
136
|
+
if (ctx.secrets.kind !== 'file') {
|
|
137
|
+
const file = new secrets_1.FileSecretStore(ctx.config.dir);
|
|
138
|
+
try {
|
|
139
|
+
await file.set(token);
|
|
140
|
+
ctx.logger.warn((0, secrets_1.keychainFallbackWarning)(file.file, `Could not save to the ${where}: ${keychainError.message}`));
|
|
141
|
+
ctx.secrets = file;
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
keychainError = new Error(`${keychainError.message}; the file fallback failed too (${err.message})`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Nowhere to keep the token: revoke the device just created so no orphan stays in Agents › Devices
|
|
149
|
+
let revoked = false;
|
|
150
|
+
try {
|
|
151
|
+
await (0, api_1.unpairDevice)(apiUrl, token);
|
|
152
|
+
revoked = true;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
/* reported below */
|
|
156
|
+
}
|
|
157
|
+
throw new Error(`Could not save the device token (${keychainError.message}). ` +
|
|
158
|
+
(revoked
|
|
159
|
+
? 'The new device was revoked on Resolflow; fix the keychain or set RESOLFLOW_SECRET_STORE=file, then pair again.'
|
|
160
|
+
: 'Could not revoke the new device on Resolflow either: remove it in Agents › Devices, then pair again.'));
|
|
161
|
+
}
|
|
111
162
|
function describeStore(s) {
|
|
112
163
|
return { keytar: 'OS keychain (keytar)', 'macos-keychain': 'macOS keychain', libsecret: 'Secret Service (libsecret)', file: 'a 0600 file in the config directory' }[s.kind];
|
|
113
164
|
}
|
|
@@ -324,7 +375,10 @@ async function cmdModels(ctx, opts) {
|
|
|
324
375
|
/** `resolflow unpair` — revoke on the server (best effort) and forget locally. */
|
|
325
376
|
async function cmdUnpair(ctx) {
|
|
326
377
|
const cfg = ctx.config.load();
|
|
327
|
-
const token = await ctx.secrets.get()
|
|
378
|
+
const token = await ctx.secrets.get().catch((err) => {
|
|
379
|
+
ctx.out(`Could not read the device token (${err.message}); revoke this device in Agents › Devices.`);
|
|
380
|
+
return null;
|
|
381
|
+
});
|
|
328
382
|
if (token) {
|
|
329
383
|
try {
|
|
330
384
|
await (0, api_1.unpairDevice)(cfg.apiUrl, token);
|
|
@@ -335,7 +389,9 @@ async function cmdUnpair(ctx) {
|
|
|
335
389
|
}
|
|
336
390
|
}
|
|
337
391
|
await ctx.secrets.delete();
|
|
338
|
-
|
|
392
|
+
if (ctx.secrets.kind !== 'file')
|
|
393
|
+
await new secrets_1.FileSecretStore(ctx.config.dir).delete();
|
|
394
|
+
const { device: _gone, secretStore: _store, ...rest } = cfg;
|
|
339
395
|
ctx.config.save({ ...rest });
|
|
340
396
|
ctx.out('Pairing removed from this machine.');
|
|
341
397
|
}
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ program
|
|
|
15
15
|
.version(commands_1.CLI_VERSION)
|
|
16
16
|
.option('-v, --verbose', 'more logging (never prompts, answers or secrets)');
|
|
17
17
|
const ctx = () => (0, commands_1.makeCtx)({ verbose: program.opts().verbose });
|
|
18
|
+
const pairCtx = () => (0, commands_1.makeCtx)({ verbose: program.opts().verbose, forPairing: true });
|
|
18
19
|
const run = (fn) => async () => {
|
|
19
20
|
try {
|
|
20
21
|
await fn();
|
|
@@ -29,14 +30,14 @@ program
|
|
|
29
30
|
.description('pair this machine with Resolflow using the code from Agents › Devices')
|
|
30
31
|
.option('--api <url>', 'Resolflow API URL (default https://api.resolflow.ai)')
|
|
31
32
|
.option('--name <name>', 'device name shown in Resolflow (default: hostname)')
|
|
32
|
-
.action((code, opts) => run(() => (0, commands_1.cmdPair)(
|
|
33
|
+
.action((code, opts) => run(() => (0, commands_1.cmdPair)(pairCtx(), code, opts))());
|
|
33
34
|
program
|
|
34
35
|
.command('login')
|
|
35
36
|
.description('same as pair')
|
|
36
37
|
.option('--pair <code>', 'pairing code')
|
|
37
38
|
.option('--api <url>', 'Resolflow API URL')
|
|
38
39
|
.option('--name <name>', 'device name')
|
|
39
|
-
.action((opts) => run(() => (0, commands_1.cmdPair)(
|
|
40
|
+
.action((opts) => run(() => (0, commands_1.cmdPair)(pairCtx(), opts.pair, opts))());
|
|
40
41
|
program
|
|
41
42
|
.command('start')
|
|
42
43
|
.description('connect to Resolflow and serve requests with your local models (Ctrl+C to stop)')
|
package/dist/secrets.js
CHANGED
|
@@ -33,8 +33,9 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.SAFE_SECRET = exports.FileSecretStore = void 0;
|
|
36
|
+
exports.LibsecretStore = exports.MacKeychainStore = exports.SAFE_SECRET = exports.FileSecretStore = exports.SecretStoreTimeoutError = exports.KEYCHAIN_TIMEOUT_MS = void 0;
|
|
37
37
|
exports.keychainAccount = keychainAccount;
|
|
38
|
+
exports.keychainFallbackWarning = keychainFallbackWarning;
|
|
38
39
|
exports.agentKeyEntry = agentKeyEntry;
|
|
39
40
|
exports.chooseSecretStore = chooseSecretStore;
|
|
40
41
|
const child_process_1 = require("child_process");
|
|
@@ -50,13 +51,37 @@ const SERVICE = 'resolflow-cli';
|
|
|
50
51
|
function keychainAccount(dir) {
|
|
51
52
|
return `device-token:${crypto.createHash('sha256').update(path.resolve(dir)).digest('hex').slice(0, 16)}`;
|
|
52
53
|
}
|
|
53
|
-
|
|
54
|
+
/** How long one keychain / libsecret call may take (a hidden macOS permission dialog never answers). */
|
|
55
|
+
exports.KEYCHAIN_TIMEOUT_MS = 10_000;
|
|
56
|
+
/** A keychain tool that did not answer in time; the child was killed. */
|
|
57
|
+
class SecretStoreTimeoutError extends Error {
|
|
58
|
+
constructor(what, timeoutMs) {
|
|
59
|
+
super(`${what} did not answer within ${Math.round(timeoutMs / 1000)} s (a permission dialog may be waiting, or the keychain is locked)`);
|
|
60
|
+
this.name = 'SecretStoreTimeoutError';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
exports.SecretStoreTimeoutError = SecretStoreTimeoutError;
|
|
64
|
+
/** Run a fixed binary; the secret (if any) goes over stdin. Never waits longer than `timeoutMs`: then the child is killed. */
|
|
65
|
+
function run(cmd, args, stdin, timeoutMs) {
|
|
54
66
|
return new Promise((resolve) => {
|
|
67
|
+
let settled = false;
|
|
68
|
+
const done = (r) => {
|
|
69
|
+
if (settled)
|
|
70
|
+
return;
|
|
71
|
+
settled = true;
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
resolve(r);
|
|
74
|
+
};
|
|
55
75
|
const child = (0, child_process_1.spawn)(cmd, args, { stdio: ['pipe', 'pipe', 'ignore'], shell: false });
|
|
76
|
+
const timer = setTimeout(() => {
|
|
77
|
+
child.kill('SIGKILL');
|
|
78
|
+
done({ code: -1, stdout: '', timedOut: true });
|
|
79
|
+
}, timeoutMs);
|
|
56
80
|
let stdout = '';
|
|
57
81
|
child.stdout.on('data', (d) => (stdout += d.toString()));
|
|
58
|
-
child.on('error', () =>
|
|
59
|
-
child.on('
|
|
82
|
+
child.stdin.on('error', () => undefined); // the child may exit before reading stdin (EPIPE)
|
|
83
|
+
child.on('error', () => done({ code: -1, stdout: '', timedOut: false }));
|
|
84
|
+
child.on('close', (code) => done({ code: code ?? -1, stdout, timedOut: false }));
|
|
60
85
|
if (stdin !== undefined)
|
|
61
86
|
child.stdin.write(stdin);
|
|
62
87
|
child.stdin.end();
|
|
@@ -98,53 +123,68 @@ exports.FileSecretStore = FileSecretStore;
|
|
|
98
123
|
exports.SAFE_SECRET = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,511}$/;
|
|
99
124
|
class MacKeychainStore {
|
|
100
125
|
account;
|
|
101
|
-
label;
|
|
102
126
|
kind = 'macos-keychain';
|
|
103
127
|
static BIN = '/usr/bin/security';
|
|
104
|
-
|
|
128
|
+
bin;
|
|
129
|
+
label;
|
|
130
|
+
timeoutMs;
|
|
131
|
+
constructor(account, opts = {}) {
|
|
105
132
|
this.account = account;
|
|
106
|
-
this.
|
|
133
|
+
this.bin = opts.bin ?? MacKeychainStore.BIN;
|
|
134
|
+
this.label = opts.label ?? 'Resolflow CLI device';
|
|
135
|
+
this.timeoutMs = opts.timeoutMs ?? exports.KEYCHAIN_TIMEOUT_MS;
|
|
107
136
|
}
|
|
108
137
|
async get() {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
138
|
+
const r = await run(this.bin, ['find-generic-password', '-a', this.account, '-s', SERVICE, '-w'], undefined, this.timeoutMs);
|
|
139
|
+
if (r.timedOut)
|
|
140
|
+
throw new SecretStoreTimeoutError('The macOS keychain', this.timeoutMs);
|
|
141
|
+
return r.code === 0 ? r.stdout.trim() || null : null;
|
|
112
142
|
}
|
|
113
143
|
async set(secret) {
|
|
114
144
|
if (!exports.SAFE_SECRET.test(secret))
|
|
115
145
|
throw new Error('Unexpected secret format');
|
|
116
146
|
// `security -i` reads commands from stdin: the secret never appears in a process list
|
|
117
|
-
const r = await run(
|
|
147
|
+
const r = await run(this.bin, ['-i'], `add-generic-password -U -a ${this.account} -s ${SERVICE} -l "${this.label}" -w ${secret}\n`, this.timeoutMs);
|
|
148
|
+
if (r.timedOut)
|
|
149
|
+
throw new SecretStoreTimeoutError('The macOS keychain', this.timeoutMs);
|
|
118
150
|
if (r.code !== 0)
|
|
119
151
|
throw new Error('Could not write to the macOS keychain');
|
|
120
152
|
}
|
|
121
153
|
async delete() {
|
|
122
|
-
await run(
|
|
154
|
+
await run(this.bin, ['delete-generic-password', '-a', this.account, '-s', SERVICE], undefined, this.timeoutMs);
|
|
123
155
|
}
|
|
124
156
|
}
|
|
157
|
+
exports.MacKeychainStore = MacKeychainStore;
|
|
125
158
|
class LibsecretStore {
|
|
126
159
|
bin;
|
|
127
160
|
account;
|
|
128
|
-
label;
|
|
129
161
|
kind = 'libsecret';
|
|
130
|
-
|
|
162
|
+
label;
|
|
163
|
+
timeoutMs;
|
|
164
|
+
constructor(bin, account, opts = {}) {
|
|
131
165
|
this.bin = bin;
|
|
132
166
|
this.account = account;
|
|
133
|
-
this.label = label;
|
|
167
|
+
this.label = opts.label ?? 'Resolflow CLI device';
|
|
168
|
+
this.timeoutMs = opts.timeoutMs ?? exports.KEYCHAIN_TIMEOUT_MS;
|
|
134
169
|
}
|
|
135
170
|
async get() {
|
|
136
|
-
const r = await run(this.bin, ['lookup', 'service', SERVICE, 'account', this.account]);
|
|
171
|
+
const r = await run(this.bin, ['lookup', 'service', SERVICE, 'account', this.account], undefined, this.timeoutMs);
|
|
172
|
+
if (r.timedOut)
|
|
173
|
+
throw new SecretStoreTimeoutError('The Secret Service (libsecret)', this.timeoutMs);
|
|
137
174
|
return r.code === 0 ? r.stdout.trim() || null : null;
|
|
138
175
|
}
|
|
139
176
|
async set(secret) {
|
|
140
|
-
const r = await run(this.bin, ['store', `--label=${this.label}`, 'service', SERVICE, 'account', this.account], secret);
|
|
177
|
+
const r = await run(this.bin, ['store', `--label=${this.label}`, 'service', SERVICE, 'account', this.account], secret, this.timeoutMs);
|
|
178
|
+
if (r.timedOut)
|
|
179
|
+
throw new SecretStoreTimeoutError('The Secret Service (libsecret)', this.timeoutMs);
|
|
141
180
|
if (r.code !== 0)
|
|
142
181
|
throw new Error('Could not write to the Secret Service (libsecret)');
|
|
143
182
|
}
|
|
144
183
|
async delete() {
|
|
145
|
-
await run(this.bin, ['clear', 'service', SERVICE, 'account', this.account]);
|
|
184
|
+
await run(this.bin, ['clear', 'service', SERVICE, 'account', this.account], undefined, this.timeoutMs);
|
|
146
185
|
}
|
|
147
186
|
}
|
|
187
|
+
exports.LibsecretStore = LibsecretStore;
|
|
148
188
|
class KeytarStore {
|
|
149
189
|
keytar;
|
|
150
190
|
account;
|
|
@@ -173,6 +213,10 @@ function loadKeytar() {
|
|
|
173
213
|
return null;
|
|
174
214
|
}
|
|
175
215
|
}
|
|
216
|
+
/** The warning when the OS keychain did not work and the device token is kept in the 0600 file instead. */
|
|
217
|
+
function keychainFallbackWarning(file, reason) {
|
|
218
|
+
return `${reason ? `${reason}. ` : ''}The OS keychain did not work, so the device token is stored in ${file} (readable by your user only). Pair again later to move it to the keychain, or set RESOLFLOW_SECRET_STORE=file to skip the keychain.`;
|
|
219
|
+
}
|
|
176
220
|
const LIBSECRET_BINS = ['/usr/bin/secret-tool', '/usr/local/bin/secret-tool'];
|
|
177
221
|
function agentKeyEntry(dir, tool) {
|
|
178
222
|
if (!/^[a-z0-9-]{1,40}$/.test(tool))
|
|
@@ -181,7 +225,7 @@ function agentKeyEntry(dir, tool) {
|
|
|
181
225
|
return { account: `agent-key:${tool}:${hash}`, fileName: `agent-key-${tool}`, label: `Resolflow CLI ${tool} API key`, what: `the ${tool} API key` };
|
|
182
226
|
}
|
|
183
227
|
/** The best store available here (see the header). `warning` is set when only the file store is left. */
|
|
184
|
-
function chooseSecretStore(dir, env = process.env, entry) {
|
|
228
|
+
function chooseSecretStore(dir, env = process.env, entry, opts = {}) {
|
|
185
229
|
const forced = env.RESOLFLOW_SECRET_STORE;
|
|
186
230
|
const account = entry?.account ?? keychainAccount(dir);
|
|
187
231
|
const label = entry?.label ?? 'Resolflow CLI device';
|
|
@@ -189,6 +233,9 @@ function chooseSecretStore(dir, env = process.env, entry) {
|
|
|
189
233
|
const fileWarning = `No OS keychain available: ${entry?.what ?? 'the device token'} is stored in ${file.file} (readable by your user only). Install "keytar" or libsecret's secret-tool for keychain storage.`;
|
|
190
234
|
if (forced === 'file')
|
|
191
235
|
return { store: file, warning: fileWarning };
|
|
236
|
+
// RF-9142b1a6: the keychain failed at pairing time and the token went to the file (config.secretStore = 'file')
|
|
237
|
+
if (opts.fileFallback && !forced)
|
|
238
|
+
return { store: file, warning: keychainFallbackWarning(file.file) };
|
|
192
239
|
if (forced !== 'keychain') {
|
|
193
240
|
const keytar = loadKeytar();
|
|
194
241
|
if (keytar)
|
|
@@ -197,11 +244,11 @@ function chooseSecretStore(dir, env = process.env, entry) {
|
|
|
197
244
|
return { store: file, warning: fileWarning };
|
|
198
245
|
}
|
|
199
246
|
if (process.platform === 'darwin' && fs.existsSync(MacKeychainStore.BIN))
|
|
200
|
-
return { store: new MacKeychainStore(account, label) };
|
|
247
|
+
return { store: new MacKeychainStore(account, { label }) };
|
|
201
248
|
if (process.platform === 'linux') {
|
|
202
249
|
const bin = LIBSECRET_BINS.find((b) => fs.existsSync(b));
|
|
203
250
|
if (bin && (env.DBUS_SESSION_BUS_ADDRESS || env.DISPLAY || env.WAYLAND_DISPLAY))
|
|
204
|
-
return { store: new LibsecretStore(bin, account, label) };
|
|
251
|
+
return { store: new LibsecretStore(bin, account, { label }) };
|
|
205
252
|
}
|
|
206
253
|
return { store: file, warning: fileWarning };
|
|
207
254
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@resolflow/cli",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.2",
|
|
4
4
|
"description": "Resolflow CLI (Beta): pair your machine with Resolflow and run agent profiles on local models (Ollama, LM Studio, vLLM) or local coding agents (Claude Code, Codex).",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|