@paneui/cli 0.0.9 → 0.0.11

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,9 +1,9 @@
1
- // `pane surface participant <new|revoke>` — mint or invalidate one
2
- // participant URL on an existing surface. Recovery + leak-containment
3
- // primitives that together replace the destructive `pane surface delete +
4
- // pane surface create` workaround for the lost-URL case.
1
+ // `pane participant <new|revoke>` — mint or invalidate one
2
+ // participant URL on an existing pane. Recovery + leak-containment
3
+ // primitives that together replace the destructive `pane delete +
4
+ // pane create` workaround for the lost-URL case.
5
5
  //
6
- // This file is a sub-noun dispatcher under `pane surface`. The surface
6
+ // This file is a sub-noun dispatcher under `pane pane`. The pane
7
7
  // dispatcher hands us a ParsedArgs whose positionals[0] is "participant"
8
8
  // (our sub-noun marker), so we read the verb from positionals[1] and the
9
9
  // args from positionals[2..]. This mirrors the way every other sub-verb
@@ -14,32 +14,32 @@ import { makeClient } from "../config.js";
14
14
  import { printJson, fail, failFromError } from "../output.js";
15
15
  const NO_FLAGS = [];
16
16
  const NO_BOOLS = [];
17
- export const participantHelp = `pane surface participant — manage one surface's participant URLs
17
+ export const participantHelp = `pane participant — manage one pane's participant URLs
18
18
 
19
19
  Participant tokens are stored hashed on the relay and CANNOT be recovered.
20
20
  If you lost the create-response (and the URL with it), use 'new' to mint a
21
- fresh URL — the surface keeps its event log, template pin, and created_at.
22
- Use 'revoke' to invalidate a single URL while keeping the surface alive.
21
+ fresh URL — the pane keeps its event log, template pin, and created_at.
22
+ Use 'revoke' to invalidate a single URL while keeping the pane alive.
23
23
 
24
24
  Usage:
25
- pane surface participant <verb> <args>
25
+ pane participant <verb> <args>
26
26
 
27
27
  Verbs:
28
- list <surface-id> List the participants on one surface, including
28
+ list <pane-id> List the participants on one pane, including
29
29
  revoked rows (for audit). Returns
30
- { surface_id, items: [...] } where each item
30
+ { pane_id, items: [...] } where each item
31
31
  carries { participant_id, kind, token_prefix,
32
32
  joined_at, revoked_at }. Use this to find the
33
33
  participant_id you need to pass to 'revoke'.
34
34
 
35
- new <surface-id> Mint a fresh human URL on an existing surface.
35
+ new <pane-id> Mint a fresh human URL on an existing pane.
36
36
  Returns { participant_id, kind, token, url,
37
37
  created_at } — ONCE. The plaintext token is
38
38
  never recoverable; save the response (pipe to
39
39
  a JSONL log) before delivering the URL.
40
40
 
41
- revoke <surface-id> <participant-id>
42
- Invalidate one participant URL. The surface's
41
+ revoke <pane-id> <participant-id>
42
+ Invalidate one participant URL. The pane's
43
43
  other participants (and the agent's own
44
44
  websocket) are untouched. Idempotent: running
45
45
  revoke twice still returns success.
@@ -54,24 +54,24 @@ Options:
54
54
  -h, --help Show this help.
55
55
 
56
56
  Recovery recipe:
57
- pane surface list # find surface_id
58
- pane surface participant list <surface-id> # find participant
57
+ pane list # find pane_id
58
+ pane participant list <pane-id> # find participant
59
59
  # ids on that
60
- # surface
61
- pane surface participant new <surface-id> # mint a new URL
62
- pane surface participant revoke <surface-id> <p-id> # invalidate the
60
+ # pane
61
+ pane participant new <pane-id> # mint a new URL
62
+ pane participant revoke <pane-id> <p-id> # invalidate the
63
63
  # old URL
64
64
 
65
65
  Output: stdout is machine-readable JSON.`;
66
66
  async function runParticipantList(args) {
67
- assertKnownFlags(args, NO_FLAGS, NO_BOOLS, "pane surface participant list");
68
- const surfaceId = args.positionals[1];
69
- if (!surfaceId) {
70
- fail("missing <surface-id> — usage: pane surface participant list <surface-id>", "invalid_args");
67
+ assertKnownFlags(args, NO_FLAGS, NO_BOOLS, "pane participant list");
68
+ const paneId = args.positionals[1];
69
+ if (!paneId) {
70
+ fail("missing <pane-id> — usage: pane participant list <pane-id>", "invalid_args");
71
71
  }
72
72
  const client = makeClient(args);
73
73
  try {
74
- const res = await client.listParticipants(surfaceId);
74
+ const res = await client.listParticipants(paneId);
75
75
  printJson(res);
76
76
  }
77
77
  catch (e) {
@@ -79,14 +79,14 @@ async function runParticipantList(args) {
79
79
  }
80
80
  }
81
81
  async function runParticipantNew(args) {
82
- assertKnownFlags(args, NO_FLAGS, NO_BOOLS, "pane surface participant new");
83
- const surfaceId = args.positionals[1];
84
- if (!surfaceId) {
85
- fail("missing <surface-id> — usage: pane surface participant new <surface-id>", "invalid_args");
82
+ assertKnownFlags(args, NO_FLAGS, NO_BOOLS, "pane participant new");
83
+ const paneId = args.positionals[1];
84
+ if (!paneId) {
85
+ fail("missing <pane-id> — usage: pane participant new <pane-id>", "invalid_args");
86
86
  }
87
87
  const client = makeClient(args);
88
88
  try {
89
- const res = await client.mintParticipant(surfaceId);
89
+ const res = await client.mintParticipant(paneId);
90
90
  printJson(res);
91
91
  }
92
92
  catch (e) {
@@ -94,17 +94,17 @@ async function runParticipantNew(args) {
94
94
  }
95
95
  }
96
96
  async function runParticipantRevoke(args) {
97
- assertKnownFlags(args, NO_FLAGS, NO_BOOLS, "pane surface participant revoke");
98
- const surfaceId = args.positionals[1];
97
+ assertKnownFlags(args, NO_FLAGS, NO_BOOLS, "pane participant revoke");
98
+ const paneId = args.positionals[1];
99
99
  const participantId = args.positionals[2];
100
- if (!surfaceId || !participantId) {
101
- fail("missing arguments — usage: pane surface participant revoke <surface-id> <participant-id>", "invalid_args");
100
+ if (!paneId || !participantId) {
101
+ fail("missing arguments — usage: pane participant revoke <pane-id> <participant-id>", "invalid_args");
102
102
  }
103
103
  const client = makeClient(args);
104
104
  try {
105
- await client.revokeParticipant(surfaceId, participantId);
105
+ await client.revokeParticipant(paneId, participantId);
106
106
  printJson({
107
- surface_id: surfaceId,
107
+ pane_id: paneId,
108
108
  participant_id: participantId,
109
109
  revoked: true,
110
110
  });
@@ -115,7 +115,7 @@ async function runParticipantRevoke(args) {
115
115
  }
116
116
  export async function runParticipant(args) {
117
117
  // positionals[0] is the verb (list | new | revoke), positionals[1..] are
118
- // the verb's args. (The surface.ts dispatcher already shifted off the
118
+ // the verb's args. (The pane.ts dispatcher already shifted off the
119
119
  // "participant" marker before calling us.)
120
120
  const verb = args.positionals[0];
121
121
  switch (verb) {
@@ -129,9 +129,9 @@ export async function runParticipant(args) {
129
129
  await runParticipantRevoke(args);
130
130
  break;
131
131
  case undefined:
132
- fail("missing verb — usage: pane surface participant <list|new|revoke> (run 'pane surface participant --help')", "invalid_args");
132
+ fail("missing verb — usage: pane participant <list|new|revoke> (run 'pane participant --help')", "invalid_args");
133
133
  break;
134
134
  default:
135
- fail(`unknown participant verb '${verb}' — expected list|new|revoke (run 'pane surface participant --help')`, "invalid_args");
135
+ fail(`unknown participant verb '${verb}' — expected list|new|revoke (run 'pane participant --help')`, "invalid_args");
136
136
  }
137
137
  }
@@ -0,0 +1,204 @@
1
+ // `pane query` — read-only SQL over the calling agent's scoped data (#355).
2
+ //
3
+ // The whole feature lives on the relay (POST /v1/query). The CLI is a thin
4
+ // shell over @paneui/core's `client.query(sql)`:
5
+ // 1. Read the SQL from args (positional), stdin, or --file.
6
+ // 2. Send it to the relay.
7
+ // 3. Format the result for human or pipe (json | csv | tsv | table),
8
+ // auto-detecting TTY when --format isn't passed.
9
+ import { assertKnownFlags } from "../argv.js";
10
+ import { makeClient } from "../config.js";
11
+ import { fail, failFromError, printJson } from "../output.js";
12
+ import { readFileSync } from "node:fs";
13
+ const KNOWN_FLAGS = new Set(["file", "format", "pane", "url", "api-key"]);
14
+ const KNOWN_BOOLS = new Set(["help"]);
15
+ const VALID_FORMATS = new Set(["json", "csv", "tsv", "table"]);
16
+ export const queryHelp = `pane query — run read-only SQL over your scoped data (#355)
17
+
18
+ Available tables (all rows already scoped to panes you own):
19
+
20
+ panes id, title, template_id, template_version, status,
21
+ created_at, expires_at, deleted_at, metadata, input_data
22
+ records id, pane_id, collection, key, data, version, seq,
23
+ author_kind, author_id, created_at, updated_at, deleted_at
24
+ events id, pane_id, type, ts, author_kind, author_id, data,
25
+ template_version_id
26
+
27
+ \`data\` is a JSON column — project with Postgres-style operators:
28
+ data->>'title' text
29
+ (data->>'done')::boolean cast
30
+ data->'nested'->>'inner_field' deep
31
+
32
+ Usage:
33
+ pane query "<SQL>" SQL as positional argument
34
+ pane query --file ./report.sql read from a file
35
+ echo "SELECT ..." | pane query read from stdin
36
+
37
+ Options:
38
+ --file <path> read SQL from a file instead of an argument / stdin
39
+ --format <fmt> json | csv | tsv | table (default: table for TTYs,
40
+ json otherwise)
41
+ --pane <id> scope the query to a single pane (resolves Phase 2
42
+ view_conflict when two of your panes have the same
43
+ collection name with different schemas)
44
+ --url <url> relay base URL (overrides PANE_URL)
45
+ --api-key <key> agent API key (overrides PANE_API_KEY)
46
+ -h, --help show this help
47
+
48
+ Limits:
49
+ - Result is capped at 10,000 rows (response.truncated = true if hit).
50
+ - Statement timeout: 10 seconds.
51
+ - SQL: SELECT / WITH / SHOW / DESCRIBE / EXPLAIN / PRAGMA only.
52
+
53
+ Examples:
54
+ pane query "SELECT title FROM panes ORDER BY created_at DESC LIMIT 10"
55
+ pane query "SELECT type, COUNT(*) AS n FROM events GROUP BY 1 ORDER BY n DESC"
56
+ pane query "SELECT data->>'title' AS title, version
57
+ FROM records WHERE collection = 'todos' AND deleted_at IS NULL"
58
+ `;
59
+ export async function runQuery(args) {
60
+ if (args.bools.has("help")) {
61
+ process.stdout.write(queryHelp + "\n");
62
+ return;
63
+ }
64
+ assertKnownFlags(args, KNOWN_FLAGS, KNOWN_BOOLS, "pane query");
65
+ // Decide format. Default depends on TTY-ness of stdout so piping is
66
+ // never broken by accidentally getting a column-aligned table.
67
+ let format = args.flags.get("format");
68
+ if (format === undefined || format === "") {
69
+ format = process.stdout.isTTY ? "table" : "json";
70
+ }
71
+ if (!VALID_FORMATS.has(format)) {
72
+ fail(`--format must be one of ${[...VALID_FORMATS].join("|")} (got '${format}')`, "invalid_args");
73
+ }
74
+ // Source the SQL: --file > positional > stdin (in that order).
75
+ let sql = null;
76
+ const file = args.flags.get("file");
77
+ if (file !== undefined && file !== "") {
78
+ try {
79
+ sql = readFileSync(file, "utf8");
80
+ }
81
+ catch (e) {
82
+ fail(`--file '${file}' could not be read: ${e.message}`, "invalid_args");
83
+ }
84
+ }
85
+ else if (args.positionals.length > 0) {
86
+ sql = args.positionals.join(" ");
87
+ }
88
+ else if (!process.stdin.isTTY) {
89
+ sql = await readAllStdin();
90
+ }
91
+ if (sql == null || sql.trim().length === 0) {
92
+ fail("missing SQL — pass it as the positional argument, --file <path>, or pipe it to stdin", "invalid_args");
93
+ }
94
+ const paneId = args.flags.get("pane");
95
+ if (paneId !== undefined && paneId !== "" && !paneId.startsWith("pan_")) {
96
+ fail(`--pane must be a pane id (starts with 'pan_'); got '${paneId}'`, "invalid_args");
97
+ }
98
+ const client = makeClient(args);
99
+ let result;
100
+ try {
101
+ result = await client.query(sql, paneId !== undefined && paneId !== "" ? { paneId } : {});
102
+ }
103
+ catch (e) {
104
+ failFromError(e);
105
+ return; // unreachable; failFromError exits
106
+ }
107
+ switch (format) {
108
+ case "json":
109
+ printJson(result);
110
+ break;
111
+ case "csv":
112
+ writeDelimited(result, ",");
113
+ break;
114
+ case "tsv":
115
+ writeDelimited(result, "\t");
116
+ break;
117
+ case "table":
118
+ writeTable(result);
119
+ break;
120
+ }
121
+ }
122
+ async function readAllStdin() {
123
+ const chunks = [];
124
+ for await (const chunk of process.stdin) {
125
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
126
+ }
127
+ return Buffer.concat(chunks).toString("utf8");
128
+ }
129
+ // --------------------------------------------------------------------------
130
+ // Formatters
131
+ // --------------------------------------------------------------------------
132
+ function writeDelimited(result, sep) {
133
+ process.stdout.write(result.columns.map((c) => escapeDelimited(sep, c)).join(sep) + "\n");
134
+ for (const row of result.rows) {
135
+ process.stdout.write(row
136
+ .map((c) => escapeDelimited(sep, formatCellForText(c)))
137
+ .join(sep) + "\n");
138
+ }
139
+ if (result.truncated) {
140
+ process.stderr.write(`[truncated: result capped at ${result.rows.length} rows]\n`);
141
+ }
142
+ }
143
+ function escapeDelimited(sep, value) {
144
+ // RFC-4180-ish: quote when the cell contains the delimiter, a quote, or a newline.
145
+ if (value.includes(sep) ||
146
+ value.includes('"') ||
147
+ value.includes("\n") ||
148
+ value.includes("\r")) {
149
+ return `"${value.replace(/"/g, '""')}"`;
150
+ }
151
+ return value;
152
+ }
153
+ function writeTable(result) {
154
+ if (result.columns.length === 0) {
155
+ process.stdout.write("(no columns)\n");
156
+ return;
157
+ }
158
+ const cells = [];
159
+ cells.push(result.columns.slice());
160
+ for (const row of result.rows) {
161
+ cells.push(row.map((c) => formatCellForText(c)));
162
+ }
163
+ // Compute column widths (cap at 80 to keep the table sane for wide JSON).
164
+ const COL_MAX = 80;
165
+ const widths = result.columns.map((_, ci) => Math.min(COL_MAX, Math.max(...cells.map((r) => visualWidth(r[ci] ?? "")))));
166
+ const rule = "─".repeat(widths.reduce((a, b) => a + b, 0) + (widths.length - 1) * 3);
167
+ // Header
168
+ process.stdout.write(formatRow(cells[0], widths) + "\n");
169
+ process.stdout.write(rule + "\n");
170
+ for (let i = 1; i < cells.length; i++) {
171
+ process.stdout.write(formatRow(cells[i], widths) + "\n");
172
+ }
173
+ process.stderr.write(`\n${result.rows.length} row${result.rows.length === 1 ? "" : "s"}${result.truncated ? " (truncated; cap = 10000)" : ""} · scope: ${result.scope.kind} (${result.scope.pane_count} panes) · ${result.elapsed_ms}ms\n`);
174
+ }
175
+ function formatRow(cells, widths) {
176
+ return cells
177
+ .map((c, i) => truncate(c, widths[i] ?? 0).padEnd(widths[i] ?? 0))
178
+ .join(" │ ");
179
+ }
180
+ function truncate(s, w) {
181
+ if (visualWidth(s) <= w)
182
+ return s;
183
+ return s.slice(0, Math.max(0, w - 1)) + "…";
184
+ }
185
+ function visualWidth(s) {
186
+ // Strip ANSI / treat as raw text — agents won't be styling SQL output.
187
+ return s.length;
188
+ }
189
+ function formatCellForText(v) {
190
+ if (v === null || v === undefined)
191
+ return "";
192
+ if (typeof v === "string")
193
+ return v;
194
+ if (typeof v === "number" || typeof v === "boolean")
195
+ return String(v);
196
+ if (typeof v === "bigint")
197
+ return v.toString();
198
+ try {
199
+ return JSON.stringify(v);
200
+ }
201
+ catch {
202
+ return String(v);
203
+ }
204
+ }
@@ -0,0 +1,285 @@
1
+ // `pane records` — CRUD + watch for per-pane mutable record collections
2
+ // (#297). Thin wrapper over the @paneui/core PaneClient + openStream APIs.
3
+ import { assertKnownFlags } from "../argv.js";
4
+ import { makeClient, resolveConfig } from "../config.js";
5
+ import { fail, failFromError, printJson } from "../output.js";
6
+ import { resolveJson } from "../input.js";
7
+ import { openStream } from "@paneui/core";
8
+ // ---------------------------------------------------------------------------
9
+ // Help text
10
+ // ---------------------------------------------------------------------------
11
+ export const recordsHelp = `pane records — CRUD + watch for per-pane record collections
12
+
13
+ A record is a row in a mutable per-pane collection (posts, comments,
14
+ reactions, etc.) declared by the template's recordSchema. The deep design is
15
+ at https://github.com/aerolalit/paneui/issues/287.
16
+
17
+ Usage:
18
+ pane records <verb> [options]
19
+
20
+ Verbs:
21
+ list <pane-id> <collection>
22
+ [--since <seq>] [--limit <n>] [--include-tombstones]
23
+ get <pane-id> <collection> <record-key>
24
+ upsert <pane-id> <collection>
25
+ --data <path|json> [--key <record-key>]
26
+ update <pane-id> <collection> <record-key>
27
+ --data <path|json> [--if-match <version>]
28
+ delete <pane-id> <collection> <record-key>
29
+ [--if-match <version>] [--yes]
30
+ watch <pane-id>
31
+ [--collection <name>]... [--since-seq <name>=<n>]...
32
+
33
+ Output (stdout, JSON-per-line for watch, single JSON for others).
34
+ Errors on stderr: {"error":{"code","message"}} with non-zero exit.`;
35
+ // ---------------------------------------------------------------------------
36
+ // Dispatch
37
+ // ---------------------------------------------------------------------------
38
+ export async function runRecords(args) {
39
+ const verb = args.positionals[0];
40
+ // `pane records --help` (top-level, no verb)
41
+ if ((verb === undefined || verb === "help") && args.bools.has("help")) {
42
+ process.stdout.write(recordsHelp + "\n");
43
+ return;
44
+ }
45
+ if (verb === undefined) {
46
+ fail("missing verb — pane records <list|get|upsert|update|delete|watch>", "invalid_args");
47
+ }
48
+ const sub = {
49
+ positionals: args.positionals.slice(1),
50
+ flags: args.flags,
51
+ bools: args.bools,
52
+ ...(args.danglingValueFlags !== undefined
53
+ ? { danglingValueFlags: args.danglingValueFlags }
54
+ : {}),
55
+ };
56
+ switch (verb) {
57
+ case "list":
58
+ return runList(sub);
59
+ case "get":
60
+ return runGet(sub);
61
+ case "upsert":
62
+ return runUpsert(sub);
63
+ case "update":
64
+ return runUpdate(sub);
65
+ case "delete":
66
+ return runDelete(sub);
67
+ case "watch":
68
+ return runWatch(sub);
69
+ default:
70
+ fail(`unknown verb '${verb}' — pane records <list|get|upsert|update|delete|watch>`, "invalid_args");
71
+ }
72
+ }
73
+ // ---------------------------------------------------------------------------
74
+ // list
75
+ // ---------------------------------------------------------------------------
76
+ async function runList(args) {
77
+ assertKnownFlags(args, ["since", "limit", "url", "api-key"], ["include-tombstones", "help"], "pane records list");
78
+ const paneId = args.positionals[0];
79
+ const collection = args.positionals[1];
80
+ if (!paneId || !collection) {
81
+ fail("usage: pane records list <pane-id> <collection>", "invalid_args");
82
+ }
83
+ const since = parseIntFlag(args, "since", 0);
84
+ const limit = parseIntFlag(args, "limit", undefined, { min: 1, max: 200 });
85
+ const includeTombstones = args.bools.has("include-tombstones");
86
+ const client = makeClient(args);
87
+ try {
88
+ const page = await client.listRecords(paneId, collection, {
89
+ since,
90
+ ...(limit !== undefined ? { limit } : {}),
91
+ });
92
+ const records = includeTombstones
93
+ ? page.records
94
+ : page.records.filter((r) => r.deleted_at === null);
95
+ printJson({
96
+ records,
97
+ next_since: page.next_since,
98
+ has_more: page.has_more,
99
+ });
100
+ }
101
+ catch (e) {
102
+ failFromError(e);
103
+ }
104
+ }
105
+ // ---------------------------------------------------------------------------
106
+ // get — client-side scan via listRecords (no dedicated route today)
107
+ // ---------------------------------------------------------------------------
108
+ async function runGet(args) {
109
+ assertKnownFlags(args, ["url", "api-key"], ["help"], "pane records get");
110
+ const [paneId, collection, recordKey] = args.positionals;
111
+ if (!paneId || !collection || !recordKey) {
112
+ fail("usage: pane records get <pane-id> <collection> <record-key>", "invalid_args");
113
+ }
114
+ const client = makeClient(args);
115
+ try {
116
+ const row = await client.getRecord(paneId, collection, recordKey);
117
+ if (!row) {
118
+ fail(`no record at key '${recordKey}' in collection '${collection}'`, "record_not_found");
119
+ }
120
+ printJson({ record: row });
121
+ }
122
+ catch (e) {
123
+ failFromError(e);
124
+ }
125
+ }
126
+ // ---------------------------------------------------------------------------
127
+ // upsert — create-or-return-existing
128
+ // ---------------------------------------------------------------------------
129
+ async function runUpsert(args) {
130
+ assertKnownFlags(args, ["data", "key", "url", "api-key"], ["help"], "pane records upsert");
131
+ const [paneId, collection] = args.positionals;
132
+ if (!paneId || !collection) {
133
+ fail("usage: pane records upsert <pane-id> <collection> --data <path|json>", "invalid_args");
134
+ }
135
+ const dataRaw = args.flags.get("data");
136
+ if (dataRaw === undefined) {
137
+ fail("--data is required (path to JSON file, or inline JSON)", "invalid_args");
138
+ }
139
+ const data = resolveJson(dataRaw, "--data");
140
+ const key = args.flags.get("key");
141
+ const client = makeClient(args);
142
+ try {
143
+ const body = { data };
144
+ if (key !== undefined)
145
+ body.record_key = key;
146
+ const out = await client.upsertRecord(paneId, collection, body);
147
+ printJson(out);
148
+ }
149
+ catch (e) {
150
+ failFromError(e);
151
+ }
152
+ }
153
+ // ---------------------------------------------------------------------------
154
+ // update — optimistic-lock mutate
155
+ // ---------------------------------------------------------------------------
156
+ async function runUpdate(args) {
157
+ assertKnownFlags(args, ["data", "if-match", "url", "api-key"], ["help"], "pane records update");
158
+ const [paneId, collection, recordKey] = args.positionals;
159
+ if (!paneId || !collection || !recordKey) {
160
+ fail("usage: pane records update <pane-id> <collection> <record-key> --data <path|json>", "invalid_args");
161
+ }
162
+ const dataRaw = args.flags.get("data");
163
+ if (dataRaw === undefined) {
164
+ fail("--data is required (path to JSON file, or inline JSON)", "invalid_args");
165
+ }
166
+ const data = resolveJson(dataRaw, "--data");
167
+ const ifMatch = parseIntFlag(args, "if-match", undefined, { min: 0 });
168
+ const client = makeClient(args);
169
+ try {
170
+ const body = { data };
171
+ if (ifMatch !== undefined)
172
+ body.if_match = ifMatch;
173
+ const out = await client.updateRecord(paneId, collection, recordKey, body);
174
+ printJson(out);
175
+ }
176
+ catch (e) {
177
+ failFromError(e);
178
+ }
179
+ }
180
+ // ---------------------------------------------------------------------------
181
+ // delete — soft-delete
182
+ // ---------------------------------------------------------------------------
183
+ async function runDelete(args) {
184
+ assertKnownFlags(args, ["if-match", "url", "api-key"], ["yes", "help"], "pane records delete");
185
+ const [paneId, collection, recordKey] = args.positionals;
186
+ if (!paneId || !collection || !recordKey) {
187
+ fail("usage: pane records delete <pane-id> <collection> <record-key>", "invalid_args");
188
+ }
189
+ const ifMatch = parseIntFlag(args, "if-match", undefined, { min: 0 });
190
+ const client = makeClient(args);
191
+ try {
192
+ await client.deleteRecord(paneId, collection, recordKey, {
193
+ ...(ifMatch !== undefined ? { ifMatch } : {}),
194
+ });
195
+ printJson({ deleted: true, key: recordKey });
196
+ }
197
+ catch (e) {
198
+ failFromError(e);
199
+ }
200
+ }
201
+ // ---------------------------------------------------------------------------
202
+ // watch — stream record deltas as JSON-lines
203
+ // ---------------------------------------------------------------------------
204
+ async function runWatch(args) {
205
+ // --collection is repeated-value; collected via danglingValueFlags + flags
206
+ assertKnownFlags(args, ["collection", "since-seq", "url", "api-key"], ["help"], "pane records watch");
207
+ const [paneId] = args.positionals;
208
+ if (!paneId) {
209
+ fail("usage: pane records watch <pane-id>", "invalid_args");
210
+ }
211
+ // --collection a,b,c (single comma list) OR repeated --collection foo flags.
212
+ // The shared argv parser uses a Map<string,string> for flags so a repeated
213
+ // flag last-write-wins. To support repeats here would need a parser
214
+ // extension; for now we accept a single comma list — the common case.
215
+ const collectionsRaw = args.flags.get("collection");
216
+ const subscribeRecords = collectionsRaw && collectionsRaw.length > 0 ? collectionsRaw : "*";
217
+ // --since-seq is a single comma list "name=N,name=M" for the same reason.
218
+ const sinceRaw = args.flags.get("since-seq");
219
+ const sinceRecordSeq = {};
220
+ if (sinceRaw) {
221
+ for (const part of sinceRaw.split(",")) {
222
+ const [name, vRaw] = part.split("=");
223
+ if (!name || vRaw === undefined) {
224
+ fail("--since-seq must be a comma list of name=N pairs", "invalid_args");
225
+ }
226
+ const n = Number(vRaw);
227
+ if (!Number.isInteger(n) || n < 0) {
228
+ fail(`--since-seq ${name}: value must be a non-negative integer`, "invalid_args");
229
+ }
230
+ sinceRecordSeq[name] = n;
231
+ }
232
+ }
233
+ const cfg = resolveConfig(args);
234
+ const handle = openStream({
235
+ wsBaseUrl: cfg.url.replace(/^http/, "ws"),
236
+ paneId: paneId,
237
+ token: cfg.apiKey,
238
+ subscribeRecords,
239
+ sinceRecordSeq,
240
+ }, {
241
+ onRecord: (msg) => {
242
+ process.stdout.write(JSON.stringify(msg) + "\n");
243
+ },
244
+ onRelayError: (err) => {
245
+ process.stderr.write(JSON.stringify({ error: err }) + "\n");
246
+ },
247
+ onError: (err) => {
248
+ process.stderr.write(JSON.stringify({
249
+ error: { code: "ws_error", message: err.message },
250
+ }) + "\n");
251
+ },
252
+ onClose: () => {
253
+ // Clean exit on close (e.g. SIGINT closed the socket). Emit nothing —
254
+ // the JSON-line stream is the contract; a trailing summary would
255
+ // confuse pipe readers.
256
+ },
257
+ });
258
+ // Hold the process open until SIGINT closes the stream.
259
+ process.on("SIGINT", () => {
260
+ handle.close();
261
+ process.exit(0);
262
+ });
263
+ await new Promise(() => {
264
+ /* never resolves — SIGINT exits */
265
+ });
266
+ }
267
+ // ---------------------------------------------------------------------------
268
+ // Helpers
269
+ // ---------------------------------------------------------------------------
270
+ function parseIntFlag(args, name, defaultValue, bounds = {}) {
271
+ const raw = args.flags.get(name);
272
+ if (raw === undefined)
273
+ return defaultValue;
274
+ const n = Number(raw);
275
+ if (!Number.isInteger(n)) {
276
+ fail(`--${name} must be an integer`, "invalid_args");
277
+ }
278
+ if (bounds.min !== undefined && n < bounds.min) {
279
+ fail(`--${name} must be >= ${bounds.min}`, "invalid_args");
280
+ }
281
+ if (bounds.max !== undefined && n > bounds.max) {
282
+ fail(`--${name} must be <= ${bounds.max}`, "invalid_args");
283
+ }
284
+ return n;
285
+ }