primcli 1.34.0 → 1.35.0

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.
@@ -0,0 +1,2 @@
1
+ import { i as runBackgroundListenSupervisor } from "../listen-background-D6UhIbkO.js";
2
+ export { runBackgroundListenSupervisor };
@@ -1,3 +1,4 @@
1
+ import { n as jsonOutputRequested } from "../json-output-KAIuP9rG.js";
1
2
  import { spawnSync } from "node:child_process";
2
3
  //#region src/oclif/proxy-auto-detect.ts
3
4
  const PROXY_ENV_VARS = [
@@ -44,7 +45,7 @@ function restartWithProxyEnvIfNeeded(options = {}) {
44
45
  process.exit(code);
45
46
  throw new Error("process.exit returned unexpectedly");
46
47
  });
47
- if (!hintPrinted) {
48
+ if (!hintPrinted && !jsonOutputRequested(argv.slice(2))) {
48
49
  hintPrinted = true;
49
50
  const names = detectedVars.join("/");
50
51
  stderr.write(`primitive: proxy detected via ${names}, restarting with NODE_USE_ENV_PROXY=1\n`);
@@ -1,4 +1,4 @@
1
- import { S as normalizeApiBaseUrl, c as resolveConfigEnvironment, i as loadCliConfig } from "../cli-config-x39ZVsXJ.js";
1
+ import { S as normalizeApiBaseUrl, c as resolveConfigEnvironment, i as loadCliConfig } from "../cli-config-DmhlcS3s.js";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { homedir } from "node:os";
@@ -0,0 +1,351 @@
1
+ import { chmodSync, closeSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { dirname, join, win32 } from "node:path";
4
+ import { setTimeout } from "node:timers/promises";
5
+ import { execFileSync } from "node:child_process";
6
+ //#region src/oclif/listen-state.ts
7
+ var ListenStateError = class extends Error {};
8
+ function normalizeListenOrigin(value) {
9
+ const url = new URL(value);
10
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new ListenStateError("The API base URL must be an HTTP(S) URL without credentials, query, or fragment.");
11
+ return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
12
+ }
13
+ function listenIdentity(origin, accountId) {
14
+ return createHash("sha256").update(JSON.stringify([normalizeListenOrigin(origin), accountId])).digest("hex");
15
+ }
16
+ function privateDirectory(path) {
17
+ mkdirSync(path, {
18
+ recursive: true,
19
+ mode: 448
20
+ });
21
+ if (!lstatSync(path).isDirectory() || lstatSync(path).isSymbolicLink()) throw new ListenStateError("Listener state must be a private directory.");
22
+ chmodSync(path, 448);
23
+ }
24
+ function processAlive(pid) {
25
+ try {
26
+ process.kill(pid, 0);
27
+ return true;
28
+ } catch (error) {
29
+ return error.code !== "ESRCH";
30
+ }
31
+ }
32
+ function listenProcessIdentity(pid) {
33
+ if (!Number.isSafeInteger(pid) || pid < 1) return null;
34
+ try {
35
+ if (process.platform === "linux") {
36
+ const boot = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
37
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
38
+ const end = stat.lastIndexOf(")");
39
+ const started = stat.slice(end + 1).trim().split(/\s+/)[19];
40
+ if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(boot) || !stat.startsWith(`${pid} (`) || end < 0 || !started || !/^\d+$/.test(started)) return null;
41
+ return `linux:${boot}:${started}`;
42
+ }
43
+ if (process.platform === "darwin") {
44
+ const options = {
45
+ encoding: "utf8",
46
+ timeout: 1e3,
47
+ maxBuffer: 4096,
48
+ stdio: [
49
+ "ignore",
50
+ "pipe",
51
+ "ignore"
52
+ ],
53
+ env: {
54
+ LC_ALL: "C",
55
+ TZ: "UTC",
56
+ PATH: "/usr/bin:/bin:/usr/sbin:/sbin"
57
+ }
58
+ };
59
+ const bootId = execFileSync("/usr/sbin/sysctl", ["-n", "kern.bootsessionuuid"], options).trim().toLowerCase();
60
+ const started = execFileSync("/bin/ps", [
61
+ "-p",
62
+ String(pid),
63
+ "-o",
64
+ "lstart="
65
+ ], options).trim().replace(/\s+/g, " ");
66
+ if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(bootId) || !/^[A-Z][a-z]{2} [A-Z][a-z]{2} \d{1,2} \d{2}:\d{2}:\d{2} \d{4}$/.test(started)) return null;
67
+ return `darwin-boot:${bootId}:${started}`;
68
+ }
69
+ if (process.platform === "win32") {
70
+ const root = process.env.SystemRoot;
71
+ if (!root || !win32.isAbsolute(root)) return null;
72
+ const started = execFileSync(win32.join(root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), [
73
+ "-NoLogo",
74
+ "-NoProfile",
75
+ "-NonInteractive",
76
+ "-Command",
77
+ `$ErrorActionPreference='Stop'; (Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks.ToString([System.Globalization.CultureInfo]::InvariantCulture)`
78
+ ], {
79
+ encoding: "utf8",
80
+ timeout: 2e3,
81
+ maxBuffer: 4096,
82
+ windowsHide: true,
83
+ stdio: [
84
+ "ignore",
85
+ "pipe",
86
+ "ignore"
87
+ ]
88
+ }).trim();
89
+ return /^\d{1,20}$/.test(started) ? `win32:${started}` : null;
90
+ }
91
+ } catch {}
92
+ return null;
93
+ }
94
+ /** Legacy macOS timestamps cannot prove that a live PID changed owners. */
95
+ function compareListenProcessIdentity(saved, current) {
96
+ if (saved === null || current === null) return null;
97
+ if (saved === current) return true;
98
+ if (saved.startsWith("darwin:") || current.startsWith("darwin:")) return null;
99
+ return false;
100
+ }
101
+ function ownerIdentity(path) {
102
+ try {
103
+ const stat = lstatSync(path);
104
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024) return null;
105
+ const saved = JSON.parse(readFileSync(path, "utf8"));
106
+ if (!saved || typeof saved !== "object" || !("version" in saved) || saved.version !== 1 || !("identity" in saved) || typeof saved.identity !== "string") return null;
107
+ return /^(?:linux:[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}:\d+|darwin:\d+:\d+:[A-Z][a-z]{2} [A-Z][a-z]{2} \d{1,2} \d{2}:\d{2}:\d{2} \d{4}|darwin-boot:[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}:[A-Z][a-z]{2} [A-Z][a-z]{2} \d{1,2} \d{2}:\d{2}:\d{2} \d{4}|win32:\d{1,20})$/.test(saved.identity) ? saved.identity : null;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+ function removeOwner(path, owner) {
113
+ try {
114
+ unlinkSync(join(path, owner));
115
+ } catch (error) {
116
+ if (error.code !== "ENOENT") throw error;
117
+ }
118
+ try {
119
+ rmdirSync(path);
120
+ } catch (error) {
121
+ if (![
122
+ "ENOENT",
123
+ "ENOTEMPTY",
124
+ "EEXIST"
125
+ ].includes(error.code ?? "")) throw error;
126
+ }
127
+ }
128
+ function acquireListenLock(directory, scope) {
129
+ privateDirectory(directory);
130
+ const path = join(directory, `${createHash("sha256").update(scope).digest("hex")}.lock`);
131
+ const owner = `${process.pid}-${randomUUID()}`;
132
+ const candidate = mkdtempSync(join(directory, ".claim-"));
133
+ chmodSync(candidate, 448);
134
+ writeFileSync(join(candidate, owner), JSON.stringify({
135
+ version: 1,
136
+ identity: listenProcessIdentity(process.pid)
137
+ }), {
138
+ mode: 384,
139
+ flag: "wx"
140
+ });
141
+ let installed = false;
142
+ try {
143
+ for (let attempt = 0; attempt < 8; attempt++) {
144
+ try {
145
+ renameSync(candidate, path);
146
+ installed = true;
147
+ break;
148
+ } catch (error) {
149
+ if (!["EEXIST", "ENOTEMPTY"].includes(error.code ?? "")) throw error;
150
+ }
151
+ let entries;
152
+ try {
153
+ entries = readdirSync(path);
154
+ } catch (error) {
155
+ if (error.code === "ENOENT") continue;
156
+ throw error;
157
+ }
158
+ if (entries.length === 0) continue;
159
+ const match = entries.length === 1 ? /^(\d+)-[a-f0-9-]{36}$/.exec(entries[0] ?? "") : null;
160
+ const pid = match ? Number(match[1]) : null;
161
+ let stale = pid !== null && !processAlive(pid);
162
+ if (!stale && pid !== null) {
163
+ const saved = ownerIdentity(join(path, entries[0] ?? ""));
164
+ stale = compareListenProcessIdentity(saved, saved === null ? null : listenProcessIdentity(pid)) === false;
165
+ }
166
+ if (!stale) throw new ListenStateError("Another listener is using this subscription, or its owner cannot be verified. Stop the original listener before reconnecting. To use a separate subscription instead, run: primitive listen --subscription local-" + randomUUID());
167
+ removeOwner(path, entries[0] ?? "");
168
+ }
169
+ if (!installed) throw new ListenStateError("Subscription lock changed concurrently. Retry the command.");
170
+ } finally {
171
+ if (!installed) removeOwner(candidate, owner);
172
+ }
173
+ let released = false;
174
+ return () => {
175
+ if (!released) {
176
+ released = true;
177
+ removeOwner(path, owner);
178
+ }
179
+ };
180
+ }
181
+ function resolveListenSubscription(configDir, identity, explicit) {
182
+ const directory = join(configDir, "listen", identity);
183
+ privateDirectory(directory);
184
+ let name = explicit;
185
+ if (name === void 0) {
186
+ const unlock = acquireListenLock(directory, "default-name");
187
+ try {
188
+ const file = join(directory, "default.json");
189
+ try {
190
+ if (!lstatSync(file).isFile() || lstatSync(file).isSymbolicLink()) throw new ListenStateError("Invalid listener identity file.");
191
+ const saved = JSON.parse(readFileSync(file, "utf8"));
192
+ if (typeof saved.name !== "string" || !/^local-[a-f0-9-]{36}$/.test(saved.name)) throw new ListenStateError("Invalid listener identity file.");
193
+ name = saved.name;
194
+ } catch (error) {
195
+ if (error.code !== "ENOENT") throw error;
196
+ name = `local-${randomUUID()}`;
197
+ const temporary = join(directory, `.state-${randomUUID()}`);
198
+ const fd = openSync(temporary, "wx", 384);
199
+ try {
200
+ writeFileSync(fd, `${JSON.stringify({ name })}\n`);
201
+ fsyncSync(fd);
202
+ } finally {
203
+ closeSync(fd);
204
+ }
205
+ renameSync(temporary, file);
206
+ }
207
+ if (process.platform !== "win32") for (const path of [
208
+ directory,
209
+ join(configDir, "listen"),
210
+ configDir
211
+ ]) {
212
+ const directoryFd = openSync(path, "r");
213
+ try {
214
+ fsyncSync(directoryFd);
215
+ } finally {
216
+ closeSync(directoryFd);
217
+ }
218
+ }
219
+ } finally {
220
+ unlock();
221
+ }
222
+ }
223
+ if (!name || name.length > 64 || !/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(name)) throw new ListenStateError("Use a subscription name of 1–64 letters, digits, underscores, or hyphens.");
224
+ return {
225
+ name,
226
+ release: acquireListenLock(directory, `subscription:${name}`)
227
+ };
228
+ }
229
+ //#endregion
230
+ //#region src/oclif/shared-mail-files.ts
231
+ var SharedMailStateError = class extends ListenStateError {};
232
+ const invalidSharedMail = () => new SharedMailStateError("Shared mail state is invalid or inconsistent. Preserve it before retrying to avoid duplicate delivery.");
233
+ function privateMailPermissions(info, platform = process.platform) {
234
+ return platform === "win32" || info.uid === process.getuid?.() && (info.mode & 63) === 0;
235
+ }
236
+ function privateMailDirectory(path, create = false) {
237
+ if (create) {
238
+ const created = mkdirSync(path, {
239
+ recursive: true,
240
+ mode: 448
241
+ });
242
+ if (created !== void 0) {
243
+ let current = path;
244
+ for (;;) {
245
+ syncMailDirectory(current);
246
+ syncMailDirectory(dirname(current));
247
+ if (current === created) break;
248
+ current = dirname(current);
249
+ }
250
+ }
251
+ }
252
+ const stat = lstatSync(path);
253
+ if (!stat.isDirectory() || !privateMailPermissions(stat)) throw invalidSharedMail();
254
+ }
255
+ function syncMailDirectory(path, platform = process.platform) {
256
+ if (platform === "win32") return;
257
+ const fd = openSync(path, "r");
258
+ try {
259
+ fsyncSync(fd);
260
+ } finally {
261
+ closeSync(fd);
262
+ }
263
+ }
264
+ function readMailJson(path, maxBytes = 16384) {
265
+ try {
266
+ const info = lstatSync(path);
267
+ if (!info.isFile() || !privateMailPermissions(info) || info.size > maxBytes) throw invalidSharedMail();
268
+ const value = JSON.parse(readFileSync(path, "utf8"));
269
+ if (value === null) throw invalidSharedMail();
270
+ return value;
271
+ } catch (error) {
272
+ if (error.code === "ENOENT") return null;
273
+ throw invalidSharedMail();
274
+ }
275
+ }
276
+ function writeMailJson(path, value) {
277
+ privateMailDirectory(dirname(path), true);
278
+ const temporary = join(dirname(path), `.write-${randomUUID()}`);
279
+ try {
280
+ const fd = openSync(temporary, "wx", 384);
281
+ try {
282
+ writeFileSync(fd, `${JSON.stringify(value)}\n`);
283
+ fsyncSync(fd);
284
+ } finally {
285
+ closeSync(fd);
286
+ }
287
+ renameSync(temporary, path);
288
+ syncMailDirectory(dirname(path));
289
+ } finally {
290
+ try {
291
+ unlinkSync(temporary);
292
+ } catch {}
293
+ }
294
+ }
295
+ function removeMailFile(path) {
296
+ try {
297
+ unlinkSync(path);
298
+ } catch (error) {
299
+ if (error.code !== "ENOENT") throw error;
300
+ }
301
+ syncMailDirectory(dirname(path));
302
+ }
303
+ function mailLockBusy(error) {
304
+ return error instanceof ListenStateError && /^(Another listener|Subscription lock changed)/.test(error.message);
305
+ }
306
+ async function withMailLock(directory, action, signal) {
307
+ const deadline = performance.now() + 5e3;
308
+ for (;;) {
309
+ signal?.throwIfAborted();
310
+ let release;
311
+ try {
312
+ release = acquireListenLock(directory, "shared-mail-state");
313
+ } catch (error) {
314
+ if (!mailLockBusy(error) || performance.now() >= deadline) throw error;
315
+ await setTimeout(25, void 0, { signal });
316
+ continue;
317
+ }
318
+ try {
319
+ signal?.throwIfAborted();
320
+ return action();
321
+ } finally {
322
+ release();
323
+ }
324
+ }
325
+ }
326
+ function mailObject(value, keys) {
327
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw invalidSharedMail();
328
+ const result = value;
329
+ if (Object.keys(result).length !== keys.length || keys.some((key) => !Object.hasOwn(result, key))) throw invalidSharedMail();
330
+ return result;
331
+ }
332
+ function mailId(value) {
333
+ if (typeof value !== "string" || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value)) throw invalidSharedMail();
334
+ return value.toLowerCase();
335
+ }
336
+ function mailString(value, max = 256) {
337
+ if (typeof value !== "string" || !value || value.length > max || Array.from(value).some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127)) throw invalidSharedMail();
338
+ return value;
339
+ }
340
+ function mailAddress(value) {
341
+ const address = mailString(value, 254).toLowerCase();
342
+ if (!/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(address)) throw invalidSharedMail();
343
+ return address;
344
+ }
345
+ function mailTime(value) {
346
+ const time = mailString(value, 40);
347
+ if (!Number.isFinite(Date.parse(time))) throw invalidSharedMail();
348
+ return new Date(time).toISOString();
349
+ }
350
+ //#endregion
351
+ export { resolveListenSubscription as S, acquireListenLock as _, mailLockBusy as a, listenProcessIdentity as b, mailTime as c, readMailJson as d, removeMailFile as f, ListenStateError as g, writeMailJson as h, mailId as i, privateMailDirectory as l, withMailLock as m, invalidSharedMail as n, mailObject as o, syncMailDirectory as p, mailAddress as r, mailString as s, SharedMailStateError as t, privateMailPermissions as u, compareListenProcessIdentity as v, normalizeListenOrigin as x, listenIdentity as y };
package/man/primitive.1 CHANGED
@@ -20,6 +20,29 @@ Most commands print human-readable output by default. Commands that expose JSON
20
20
  .B --json
21
21
  flag or use generated operation output that can be piped into tools such as
22
22
  .BR jq (1).
23
+ .PP
24
+ With
25
+ .BR --json ,
26
+ stdout is exactly one JSON document, on success and on failure, and stderr
27
+ stays empty, so output merged with 2>&1 still parses. Notices the command
28
+ would print on stderr go in the document's
29
+ .B warnings
30
+ array; a failure adds
31
+ .B error
32
+ and
33
+ .BR exit_code .
34
+ Generated API commands keep their usual stdout shape with
35
+ .BR --json :
36
+ the data payload only. They do not print the
37
+ .B next cursor:
38
+ line then; add
39
+ .B --envelope
40
+ to get the full response envelope, with the pagination cursor in
41
+ .B meta.cursor
42
+ and empty-result hints in
43
+ .BR summary .
44
+ .B primitive listen
45
+ streams JSONL and is not covered by this rule.
23
46
  .SH LOCAL WEBHOOK EVENTS
24
47
  .PP
25
48
  .B primitive listen
@@ -312,6 +335,19 @@ chat sent the message but no reply arrived before the timeout. Wait; do not rese
312
335
  .B 4
313
336
  .BR uncertain :
314
337
  transport error, conflict, server error, or a send record with status unknown. The message may have gone out; check sent history before retrying.
338
+ .PP
339
+ Every
340
+ .B --json
341
+ envelope from these commands carries
342
+ .B sent_email_id
343
+ (null until a send record is known) and
344
+ .BR idempotency_key ,
345
+ including on an uncertain outcome. Reconcile with
346
+ .BR "primitive sent get --idempotency-key KEY" ;
347
+ not_found means no record is visible yet, not that nothing was created, so
348
+ retry only with the same
349
+ .BR --idempotency-key ,
350
+ never a new one.
315
351
  .SH ENVIRONMENT
316
352
  .TP
317
353
  .B PRIMITIVE_API_KEY
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primcli",
3
- "version": "1.34.0",
3
+ "version": "1.35.0",
4
4
  "description": "Official Primitive CLI: deploy Primitive Functions, send and inspect mail, manage endpoints, all from the terminal. Wraps the @primitivedotdev/sdk runtime client with one-shot commands.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -145,6 +145,9 @@
145
145
  },
146
146
  "agent:notes": {
147
147
  "description": "Read and maintain organization address notes. Connected profiles default to their own address and can write only their own notes. Use agent notes list|get|set|delete; new notes are private, and updates preserve visibility unless explicitly changed. Commands return JSON by default."
148
+ },
149
+ "agent:working": {
150
+ "description": "Short, expiring work claims stored in the AGENT_WORKING address note. Set a claim when work starts, clear it when work ends, and check a peer's claim with get --address before editing a shared file. Claims are advisory, not locks."
148
151
  }
149
152
  },
150
153
  "topicSeparator": " "