@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 +1 -1
- package/dist/config.js +9 -99
- package/package.json +1 -1
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 {
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
171
|
-
//
|
|
172
|
-
// peers;
|
|
173
|
-
|
|
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
|
-
|
|
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 () => {
|