@standchat/cli 0.1.0 → 0.1.1

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 CHANGED
@@ -7,7 +7,7 @@ Command-line access for installing and managing Stand Chat. The CLI covers the 1
7
7
  After the release is publicly published:
8
8
 
9
9
  ```sh
10
- npm install --global @standchat/cli
10
+ npm install --global @standchat/cli@0.1.1
11
11
  stand auth login --setup --json
12
12
  stand setup status --json
13
13
  ```
package/dist/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
- import { link, lstat, mkdir, open, readdir, rename, rm } from 'node:fs/promises';
3
+ import { lstat, mkdir, open, readdir, rename, rm } from 'node:fs/promises';
4
4
  import { homedir } from 'node:os';
5
5
  import { isAbsolute, join } from 'node:path';
6
6
  import { spawn } from 'node:child_process';
@@ -82,39 +82,6 @@ export async function writePrivateJson(path, data) {
82
82
  await rm(temporary, { force: true });
83
83
  }
84
84
  }
85
- // Keep the observed inode alive until recovery finishes. A pathname can be
86
- // released and reused by another legacy client while we check the first PID.
87
- async function observeCredentialLock(path) {
88
- let file;
89
- try {
90
- file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
91
- const stat = await file.stat({ bigint: true });
92
- if (!stat.isFile() || stat.size > 128n * 1024n || (process.platform !== 'win32'
93
- && ((stat.mode & 63n) !== 0n || (process.getuid && stat.uid !== BigInt(process.getuid()))))) {
94
- throw new CliError('UNSAFE_CONFIG', 'Invalid credential lock type, size, ownership, or permissions.', 4);
95
- }
96
- const buffer = Buffer.alloc(Number(stat.size) + 1);
97
- const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
98
- let owner;
99
- // A size change or short read makes this observation opaque.
100
- if (BigInt(bytesRead) === stat.size) {
101
- try {
102
- owner = JSON.parse(buffer.toString('utf8', 0, bytesRead));
103
- }
104
- catch { /* Wait for an opaque owner. */ }
105
- }
106
- return { file, dev: stat.dev, ino: stat.ino, owner };
107
- }
108
- catch (error) {
109
- await file?.close();
110
- if (isObject(error) && error.code === 'ENOENT')
111
- return undefined;
112
- if (isObject(error) && error.code === 'ELOOP') {
113
- throw new CliError('UNSAFE_CONFIG', 'Credential locks must be regular files, not symbolic links.', 4);
114
- }
115
- throw error;
116
- }
117
- }
118
85
  async function withPrivateFileLock(root, path, resource, fn, signal) {
119
86
  const busyCode = resource === 'credential' ? 'CREDENTIAL_BUSY' : 'CONFIG_BUSY';
120
87
  const title = resource === 'credential' ? 'Credential' : 'Configuration';
@@ -126,12 +93,11 @@ async function withPrivateFileLock(root, path, resource, fn, signal) {
126
93
  await privateDirectory(queue);
127
94
  const id = `${process.pid}-${randomUUID()}.json`;
128
95
  const claim = join(queue, id);
129
- const candidate = `${claim}.lock`;
130
96
  const deadline = performance.now() + 120_000;
131
97
  const wait = async () => {
132
98
  cancelled();
133
99
  if (performance.now() >= deadline)
134
- throw new CliError(busyCode, `Another command owns this ${resource}, or its ownership cannot be verified. Retry after it finishes. Lock: ${path}; queue: ${queue}. Repair these paths only after verifying no Stand CLI process is running; never remove a live process’s lock.`, 4);
100
+ throw new CliError(busyCode, `Another command owns this ${resource}, or its ownership cannot be verified. Retry after it finishes. Queue: ${queue}. Repair this directory only after verifying no Stand CLI process is running; never remove a live process’s lock.`, 4);
135
101
  await sleep(50, signal);
136
102
  };
137
103
  const dead = (pid) => {
@@ -154,12 +120,11 @@ async function withPrivateFileLock(root, path, resource, fn, signal) {
154
120
  continue;
155
121
  if (!isObject(value) || !Number.isSafeInteger(value.pid) || Number(value.pid) <= 0
156
122
  || name.split('-')[0] !== String(value.pid) || !Number.isSafeInteger(value.ticket) || Number(value.ticket) < 0) {
157
- throw new CliError(busyCode, `${title} lock ownership is invalid. Lock: ${path}; queue: ${queue}. Verify no Stand CLI process is running before repairing these files.`, 4);
123
+ throw new CliError(busyCode, `${title} lock ownership is invalid. Queue: ${queue}. Verify no Stand CLI process is running before repairing these files.`, 4);
158
124
  }
159
125
  if (dead(Number(value.pid))) {
160
126
  // Names are unique and never reused. A competing cleanup cannot delete
161
127
  // another process's replacement claim as it could with a fixed lock path.
162
- await rm(`${file}.lock`, { force: true });
163
128
  await rm(file, { force: true });
164
129
  }
165
130
  else
@@ -167,12 +132,12 @@ async function withPrivateFileLock(root, path, resource, fn, signal) {
167
132
  }
168
133
  return active;
169
134
  };
170
- // Lamport's bakery arbitration serializes new clients before any stale
171
- // canonical-lock recovery. Publish choosing=0 atomically before inspecting
172
- // peers; new arrivals then observe us or wait for our ticket publication.
173
- let acquired = false;
174
- await writePrivateJson(claim, { pid: process.pid, ticket: 0 });
135
+ // Each attempt owns a unique claim for its whole lifetime, so stale cleanup
136
+ // never unlinks a new owner's claim. Publish choosing=0 atomically before
137
+ // inspecting peers; arrivals then observe us or wait for our ticket. The
138
+ // bakery queue is the only lock: there is no reusable PID-file pathname.
175
139
  try {
140
+ await writePrivateJson(claim, { pid: process.pid, ticket: 0 });
176
141
  const ticket = Math.max(0, ...(await claims()).map(peer => peer.ticket)) + 1;
177
142
  if (!Number.isSafeInteger(ticket))
178
143
  throw new CliError(busyCode, `${title} lock ticket limit reached.`, 4);
@@ -180,65 +145,11 @@ async function withPrivateFileLock(root, path, resource, fn, signal) {
180
145
  while ((await claims()).some(peer => peer.id !== id
181
146
  && (peer.ticket === 0 || peer.ticket < ticket || (peer.ticket === ticket && peer.id < id))))
182
147
  await wait();
183
- // Credential users retain the prior PID-file protocol for N-1 clients.
184
- // Configuration serialization requires all writers to use this new lock.
185
- // A complete candidate is linked exclusively, avoiding new empty locks
186
- // if this process dies between creation and writing its PID.
187
- const file = await open(candidate, 'wx', 0o600);
188
- try {
189
- await file.writeFile(String(process.pid));
190
- await file.sync();
191
- }
192
- finally {
193
- await file.close();
194
- }
195
- for (;;) {
196
- cancelled();
197
- try {
198
- await link(candidate, path);
199
- acquired = true;
200
- break;
201
- }
202
- catch (error) {
203
- if (!isObject(error) || error.code !== 'EEXIST')
204
- throw error;
205
- }
206
- const observed = await observeCredentialLock(path);
207
- try {
208
- if (observed && Number.isSafeInteger(observed.owner) && Number(observed.owner) > 0 && dead(Number(observed.owner))) {
209
- const current = await observeCredentialLock(path);
210
- try {
211
- // Holding the original descriptor prevents inode reuse. Only
212
- // remove that same dead owner's lock, never a replacement owner.
213
- // Bakery arbitration excludes another new-client recoverer;
214
- // legacy clients never remove a lock owned by another process.
215
- if (current && current.dev === observed.dev && current.ino === observed.ino && current.owner === observed.owner) {
216
- cancelled();
217
- await rm(path, { force: true });
218
- }
219
- }
220
- finally {
221
- await current?.file.close();
222
- }
223
- }
224
- }
225
- finally {
226
- await observed?.file.close();
227
- }
228
- await wait();
229
- }
230
148
  cancelled();
231
149
  return await fn();
232
150
  }
233
151
  finally {
234
- try {
235
- if (acquired)
236
- await rm(path, { force: true });
237
- }
238
- finally {
239
- await rm(candidate, { force: true });
240
- await rm(claim, { force: true });
241
- }
152
+ await rm(claim, { force: true });
242
153
  }
243
154
  }
244
155
  export class ConfigStore {
@@ -290,7 +201,6 @@ export class ConfigStore {
290
201
  }
291
202
  // Every mutation of the shared file must derive its result after acquiring
292
203
  // this global lock. A per-profile lock cannot protect the other profile rows.
293
- // Older CLI versions do not take this lock and must not write concurrently.
294
204
  async update(name, change, signal) {
295
205
  const selected = profileName(name);
296
206
  return withPrivateFileLock(this.root, join(this.root, 'config.json.lock'), 'configuration', async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@standchat/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Stand Chat command-line client for humans and agents",
5
5
  "type": "module",
6
6
  "bin": {