even-keel 0.2.0-rc.1 → 0.2.0-rc.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.
@@ -1,8 +1,6 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
3
  const SERVICE = 'Claude Code-credentials';
4
- /** The `-a` the CLI writes its sign-in item with (CLI 2.1.317). */
5
- const ACCOUNT = 'claude-code-user';
6
4
  const NOT_FOUND = 44;
7
5
  const TTL_MS = 60_000;
8
6
  const TIMEOUT_MS = 3_000;
@@ -53,32 +51,53 @@ export class KeychainCredentials {
53
51
  }
54
52
  /**
55
53
  * Carries a folder's sign-in item to the name of the folder's new path (BTN-745): the `.pending-*` folder of "Add
56
- * account" is renamed after the sign-in, and its item would stay behind matching no folder. An item already under the
57
- * new name wins and the old one is only deleted; a failed write keeps the old one. The secret goes to `security -i` on
58
- * stdin as hex, as the CLI writes it, never in the arguments.
54
+ * account" is renamed after the sign-in, and its item would stay behind matching no folder. The old item is found by
55
+ * its name alone and keeps its own account (`acct`): the CLI writes the macOS user name there (BTN-748). An item
56
+ * already under the new name, whatever its account, wins and the old one is only deleted; a failed write keeps the old
57
+ * one. The secret goes to `security -i` on stdin as hex, as the CLI writes it, never in the arguments.
59
58
  */
60
59
  move(from, to) {
61
60
  this.forget();
62
- const old = this.ask(['find-generic-password', '-a', ACCOUNT, '-s', from, '-w']);
63
- if (old?.status !== 0)
61
+ const found = this.ask(['find-generic-password', '-s', from]);
62
+ if (found === undefined)
64
63
  return;
65
- const text = decode(old.stdout.trim());
66
- if (text === undefined)
64
+ if (found.status !== 0) {
65
+ this.warn(found.status === NOT_FOUND
66
+ ? `Keychain: no item "${from}" to carry to "${to}"; the sign-in may be in .credentials.json`
67
+ : `Keychain: could not look up "${from}" (security exit ${found.status}); nothing carried to "${to}"`);
67
68
  return;
68
- const present = this.ask(['find-generic-password', '-a', ACCOUNT, '-s', to]);
69
+ }
70
+ const account = accountOf(found.stdout);
71
+ if (account !== null && !QUOTABLE.test(account)) {
72
+ this.warn(`Keychain: the account of "${from}" cannot be passed to security -i; the sign-in stays there`);
73
+ return;
74
+ }
75
+ const by = account === null ? [] : ['-a', account];
76
+ const old = this.ask(['find-generic-password', ...by, '-s', from, '-w']);
77
+ if (old === undefined)
78
+ return;
79
+ const text = old.status === 0 ? decode(old.stdout.trim()) : undefined;
80
+ if (text === undefined) {
81
+ this.warn(`Keychain: could not read "${from}"; nothing carried to "${to}"`);
82
+ return;
83
+ }
84
+ const present = this.ask(['find-generic-password', '-s', to]);
69
85
  if (present === undefined)
70
86
  return;
71
87
  if (present.status === NOT_FOUND) {
72
88
  const hex = Buffer.from(text, 'utf8').toString('hex');
73
- const written = this.ask(['-i'], `add-generic-password -U -a "${ACCOUNT}" -s "${to}" -X "${hex}"\n`);
89
+ const as = account === null ? '' : ` -a "${account}"`;
90
+ const written = this.ask(['-i'], `add-generic-password -U${as} -s "${to}" -X "${hex}"\n`);
74
91
  if (written?.status !== 0) {
75
92
  this.warn(`Keychain: could not write "${to}"; the sign-in stays under "${from}"`);
76
93
  return;
77
94
  }
78
95
  }
79
- else if (present.status !== 0)
96
+ else if (present.status !== 0) {
97
+ this.warn(`Keychain: could not look up "${to}" (security exit ${present.status}); the sign-in stays under "${from}"`);
80
98
  return;
81
- if (this.ask(['delete-generic-password', '-a', ACCOUNT, '-s', from])?.status !== 0)
99
+ }
100
+ if (this.ask(['delete-generic-password', ...by, '-s', from])?.status !== 0)
82
101
  this.warn(`Keychain: could not delete "${from}"`);
83
102
  }
84
103
  /** One `security` call; `undefined` when it timed out, likely on an access dialog. */
@@ -93,6 +112,18 @@ export class KeychainCredentials {
93
112
  this.answers.clear();
94
113
  }
95
114
  }
115
+ /**
116
+ * The `acct` attribute from the dump of `find-generic-password` without `-w`: `"acct"<blob>="m1"`, or `0x<hex>` and an
117
+ * escaped copy when a byte is outside printable ASCII; `null` for `<NULL>` or no such line.
118
+ */
119
+ function accountOf(dump) {
120
+ const line = /^\s*"acct"<blob>=(?:"(.*)"|0x([0-9a-f]*))/im.exec(dump);
121
+ if (line === null)
122
+ return null;
123
+ return line[1] ?? Buffer.from(line[2], 'hex').toString('utf8');
124
+ }
125
+ /** `security -i` splits its line on quotes: an account with a quote, a backslash or a control character is not passed. */
126
+ const QUOTABLE = /^[^"\\\x00-\x1f\x7f]*$/;
96
127
  /** `security -w` prints a secret that is not plain text as hex. */
97
128
  function decode(text) {
98
129
  if (!text)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "even-keel",
3
- "version": "0.2.0-rc.1",
3
+ "version": "0.2.0-rc.2",
4
4
  "description": "Runs Claude Code sessions over a tracker's tasks one after another, in the order of their blocks relations",
5
5
  "license": "MIT",
6
6
  "repository": {