@resolflow/cli 0.1.0-beta.0 → 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 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 { store, warning } = (0, secrets_1.chooseSecretStore)(config.dir);
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.secrets.set(res.token);
101
- if (ctx.secretWarning)
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
- ...cfg,
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
- const { device: _gone, ...rest } = cfg;
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)(ctx(), code, opts))());
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)(ctx(), opts.pair, opts))());
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
- function run(cmd, args, stdin) {
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', () => resolve({ code: -1, stdout: '' }));
59
- child.on('close', (code) => resolve({ code: code ?? -1, stdout }));
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
- constructor(account, label = 'Resolflow CLI device') {
128
+ bin;
129
+ label;
130
+ timeoutMs;
131
+ constructor(account, opts = {}) {
105
132
  this.account = account;
106
- this.label = label;
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
- return new Promise((resolve) => {
110
- (0, child_process_1.execFile)(MacKeychainStore.BIN, ['find-generic-password', '-a', this.account, '-s', SERVICE, '-w'], (err, stdout) => resolve(err ? null : stdout.trim() || null));
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(MacKeychainStore.BIN, ['-i'], `add-generic-password -U -a ${this.account} -s ${SERVICE} -l "${this.label}" -w ${secret}\n`);
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(MacKeychainStore.BIN, ['delete-generic-password', '-a', this.account, '-s', SERVICE]);
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
- constructor(bin, account, label = 'Resolflow CLI device') {
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.0",
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": {